"""Detection pipeline: video in, cropped document candidates + JSON report out. Two sequential passes over the video (no seeking, works on any codec): Pass 1: sample downscaled frames, score motion, find stable/moving segments. Pass 2: scan every sampled frame for document contours, cluster consecutive detections into capture events (works even while hands are moving), pick the sharpest frame per event, perspective-correct, save the crop. """ from __future__ import annotations import csv import json import re from dataclasses import dataclass, field from datetime import datetime, timedelta from pathlib import Path from typing import Any import cv2 import numpy as np from paperpod.capture.video import iter_frames, probe_video from paperpod.config import Config, config_to_dict from paperpod.llm.vision import analyze_document, ollama_available from paperpod.naming.filename import build_name, dedupe_stem, slugify from paperpod.ocr.extract import extract_store_name, run_ocr from paperpod.pdf.build import images_to_pdf from paperpod.vision.capture_events import _plausible_capture, find_document_events, score_sample from paperpod.vision.document import detect_document, pad_quad, warp_document from paperpod.vision.enhance import darken_print, enhance_for_ocr from paperpod.vision.motion import find_segments, motion_score, prepare_gray from paperpod.vision.orient import auto_rotate from paperpod.vision.refine import refine_crop _AMOUNT_RE = re.compile(r"\d[\d,]{2,}(?:\.\d{2})?") def _content_overlap(a: str, b: str) -> float: """Shared dollar/account numbers between two OCR texts (1.0 ≈ same page).""" fa = set(_AMOUNT_RE.findall(a or "")) fb = set(_AMOUNT_RE.findall(b or "")) if not fa or not fb: return 0.0 return len(fa & fb) / min(len(fa), len(fb)) def run_detection(video_path: str | Path, cfg: Config, out_dir: str | Path) -> dict[str, Any]: """Run the full detect pipeline and write artifacts into out_dir. Artifacts: crops/window_NNN.png perspective-corrected document candidates frames/window_NNN_full.png the full best frame per window (for debugging) motion_scores.csv per-sample motion scores (for threshold tuning) report.json stable windows, motion events, detections """ video_path = Path(video_path) out_dir = Path(out_dir) crops_dir = out_dir / "crops" frames_dir = out_dir / "frames" crops_dir.mkdir(parents=True, exist_ok=True) frames_dir.mkdir(parents=True, exist_ok=True) meta = probe_video(video_path) # ---- Pass 1: motion scores on downscaled frames ------------------------- timestamps: list[float] = [] scores: list[float] = [] prev_gray: np.ndarray | None = None small_size: tuple[int, int] | None = None # (w, h) of processing frames for frame in iter_frames( video_path, sample_fps=cfg.capture.sample_fps, resize_width=cfg.capture.processing_width, ): gray = prepare_gray(frame.image, cfg.motion.blur_ksize) if small_size is None: small_size = (frame.image.shape[1], frame.image.shape[0]) # First frame has no predecessor; treat it as perfectly stable. score = ( 0.0 if prev_gray is None else motion_score(prev_gray, gray, cfg.motion.pixel_threshold) ) timestamps.append(frame.t) scores.append(score) prev_gray = gray segments = find_segments( timestamps, scores, cfg.motion.threshold, cfg.motion.stable_min_duration_s ) with open(out_dir / "motion_scores.csv", "w", newline="") as f: writer = csv.writer(f) writer.writerow(["t", "score"]) writer.writerows(zip(timestamps, scores)) # ---- Pass 2: frame-based document capture events ------------------------ # Scan every sampled frame for a document contour, cluster consecutive # detections into separate placements, keep the sharpest frame per cluster. # Unlike stable-window-only detection, this still captures receipts shown # while hands are adjusting them (motion scores stay high but the contour # is visible frame-by-frame). # # Only tiny scored proxies are retained here — full-res 4K BGR would OOM # on a long phone video. Winning frame indices are re-decoded in pass 2b. det_kwargs = dict( min_area_ratio=cfg.document.min_area_ratio, max_area_ratio=cfg.document.max_area_ratio, max_aspect=cfg.document.max_aspect, canny_low=cfg.document.canny_low, canny_high=cfg.document.canny_high, detect_width=cfg.document.detect_width, ) scored_samples = [] for frame in iter_frames(video_path, sample_fps=cfg.capture.sample_fps): detection = detect_document(frame.image, **det_kwargs) if detection is not None and _plausible_capture(detection): scored_samples.append(score_sample(frame, detection)) doc_events = find_document_events( scored_samples, gap_s=cfg.document.event_gap_s, min_samples=cfg.document.event_min_samples, ) # Pass 2b: re-decode only the winning frame indices (one BGR at a time). want = {event.best_frame_index: event for event in doc_events} if want: for frame in iter_frames(video_path, sample_fps=cfg.capture.sample_fps): event = want.get(frame.index) if event is None: continue event.frame = frame del want[frame.index] if not want: break window_records: list[dict[str, Any]] = [] for event in doc_events: i = event.event_id frame = event.frame if frame is None: # Winner index was skipped (sample_fps / codec edge); skip event. continue detection = event.detection sharp = event.sharpness record: dict[str, Any] = { "window_id": i, "t_start": round(event.t_start, 3), "t_end": round(event.t_end, 3), "duration_s": round(event.t_end - event.t_start, 3), "sample_count": event.sample_count, "document_found": False, "best_frame_t": round(event.best_frame_t, 3), "best_frame_index": event.best_frame_index, "sharpness": round(sharp, 1), "best_frame_skin_fraction": event.skin_fraction, } frame_path = frames_dir / f"window_{i:03d}_full.png" cv2.imwrite(str(frame_path), frame.image) record["frame_path"] = str(frame_path.relative_to(out_dir)) padded_quad = pad_quad(detection.quad, frame.image.shape, cfg.document.pad_margin) crop = warp_document(frame.image, padded_quad) refine_info: dict[str, Any] = {} if cfg.document.refine: refined = refine_crop(crop, remove_fingers=cfg.document.remove_fingers) crop = refined.image refine_info = { "crop_tightened": refined.tightened, "fingers_removed": refined.fingers_removed, "skin_fraction": refined.skin_fraction, } rotation_applied = 0 rotation_method = "none" if cfg.document.auto_rotate: crop, orientation = auto_rotate(crop, lang=cfg.ocr.lang, psm=cfg.ocr.psm) rotation_applied = orientation.rotation rotation_method = orientation.method if cfg.document.enhance: crop = enhance_for_ocr(crop) else: # Keep the photographic look but pull pale grey print toward black. crop = darken_print(crop) crop_path = crops_dir / f"window_{i:03d}.png" cv2.imwrite(str(crop_path), crop) record.update( document_found=True, detection_method=detection.method, detection_source=detection.source, detection_confidence=round(detection.confidence, 3), area_ratio=round(detection.area_ratio, 4), quad=[[round(float(x), 1), round(float(y), 1)] for x, y in detection.quad], rotation_applied=rotation_applied, rotation_method=rotation_method, crop_path=str(crop_path.relative_to(out_dir)), crop_size=[crop.shape[1], crop.shape[0]], **refine_info, ) window_records.append(record) report = { "video": str(video_path), "video_meta": { "fps": round(meta.fps, 3), "frame_count": meta.frame_count, "duration_s": round(meta.duration_s, 3), "width": meta.width, "height": meta.height, }, "config": config_to_dict(cfg), "motion_events": [ { "kind": s.kind, "t_start": round(s.t_start, 3), "t_end": round(s.t_end, 3), "duration_s": round(s.duration, 3), } for s in segments ], "stable_windows": window_records, } with open(out_dir / "report.json", "w") as f: json.dump(report, f, indent=2) return report def _is_exportable(ocr) -> bool: """Skip obvious mat-transition junk that has no usable naming signal.""" if ocr.form_code or ocr.date or ocr.total: return True if extract_store_name(ocr.text) or ocr.doc_title or ocr.page_number: return True return False @dataclass class _PageCandidate: """One exportable capture: crop + everything extracted from it.""" window: dict[str, Any] crop_path: Path ocr: Any llm: Any vendor: str | None date: str | None time: str | None total: str | None form_code: str | None tax_year: str | None org_name: str | None doc_title: str | None party: str | None page_number: int | None page_total: int | None @property def signature(self) -> tuple: return (self.vendor, self.date, self.total, self.form_code) @dataclass class _Document: pages: list[_PageCandidate] = field(default_factory=list) def last_marker_page(self) -> _PageCandidate | None: """Most recent page whose printed 'PAGE X OF Y' marker was readable.""" for page in reversed(self.pages): if page.page_number is not None: return page return None def _same_letterhead(prev: _PageCandidate, curr: _PageCandidate) -> bool: """Loose 'same printed letterhead' check for grouping decisions.""" if prev.doc_title and curr.doc_title: return prev.doc_title == curr.doc_title if prev.vendor and curr.vendor: a, b = slugify(prev.vendor), slugify(curr.vendor) return a.startswith(b) or b.startswith(a) return False def _signature_duplicate(prev: _PageCandidate, curr: _PageCandidate) -> bool: """Same marker-less page captured twice in a row (a contour blip). Only trustworthy for events moments apart, and only when neither side carries a printed page marker. Pages of one bank statement share vendor + date, and flips are often 3–4s — the old 5s gate was replacing page 3 with page 4 (higher OCR conf) and wiping the earlier page. """ if prev.page_number is not None or curr.page_number is not None: return False if curr.window["t_start"] - prev.window["t_end"] > 5.0: return False return bool(any(curr.signature) and curr.signature == prev.signature) def _first(*values): return next((v for v in values if v), None) def _group_documents(candidates: list[_PageCandidate]) -> list[_Document]: """Merge consecutive captures into documents; drop duplicate captures. Grouping is anchored on the printed "PAGE X OF Y" marker: within one document, later captures must advance the page number under the same page total (gaps are fine — a page whose marker OCR couldn't read must not split one statement into several PDFs). A marker-less capture with the same letterhead may slot into a document that is still incomplete (e.g. the "PAGE 1 OF 2" statement's second page whose header came out garbled); unrelated marker-less single-pagers never group. """ documents: list[_Document] = [] for cand in candidates: doc = documents[-1] if documents else None if doc is None: documents.append(_Document(pages=[cand])) continue anchor = doc.last_marker_page() last = doc.pages[-1] if cand.page_number is not None: if ( anchor is not None and cand.page_total == anchor.page_total and _same_letterhead(anchor, cand) ): if cand.page_number == anchor.page_number: # Same page captured twice; keep the better read. if cand.ocr.mean_confidence > anchor.ocr.mean_confidence: idx = next(i for i, p in enumerate(doc.pages) if p is anchor) doc.pages[idx] = cand continue if cand.page_number > anchor.page_number: doc.pages.append(cand) continue documents.append(_Document(pages=[cand])) continue if _signature_duplicate(last, cand): if cand.ocr.mean_confidence > last.ocr.mean_confidence: doc.pages[-1] = cand continue # Content-fingerprint dedupe for marker-less recaptures only when the # amounts strongly match (≥0.55) or the new read looks garbled # (conf < 75). A 0.25–0.40 overlap is normal between successive # pages of one statement (shared account # / branch / balances); # using that as a duplicate gate previously wiped page 3 with page 4. overlap = _content_overlap(last.ocr.text, cand.ocr.text) if overlap >= 0.55 or (overlap >= 0.25 and cand.ocr.mean_confidence < 75.0): if cand.ocr.mean_confidence > last.ocr.mean_confidence: doc.pages[-1] = cand continue if ( anchor is not None and anchor.page_number < (anchor.page_total or 0) and _same_letterhead(anchor, cand) and cand.ocr.mean_confidence >= 70.0 and _content_overlap(anchor.ocr.text, cand.ocr.text) < 0.55 ): doc.pages.append(cand) continue documents.append(_Document(pages=[cand])) return documents def run_export(report: dict[str, Any], cfg: Config, out_dir: str | Path) -> dict[str, Any]: """OCR-name and PDF-export every detected document from a detect() report. Consecutive captures that carry an incrementing printed "PAGE X OF Y" marker are grouped into one multi-page PDF (page 1's metadata names the file). Everything else stays one PDF per capture. Writes: pdf/.pdf one PDF per document (possibly multi-page) export_summary.csv timestamp, pod_id, page_count, source_of_name, ocr_vendor, ocr_date, ocr_total, final_filename Returns the summary rows (also embedded back for programmatic use). """ out_dir = Path(out_dir) pdf_dir = out_dir / "pdf" pdf_dir.mkdir(parents=True, exist_ok=True) video_path = Path(report["video"]) duration_s = report["video_meta"]["duration_s"] try: recording_start = datetime.fromtimestamp(video_path.stat().st_mtime) - timedelta( seconds=duration_s ) except FileNotFoundError: recording_start = datetime.now() - timedelta(seconds=duration_s) use_llm = cfg.llm.enabled and ollama_available(cfg.llm.base_url) if cfg.llm.enabled and not use_llm: print("Ollama not reachable — falling back to Tesseract-only naming.") # ---- Phase 1: OCR/LLM every crop, keep the exportable ones -------------- candidates: list[_PageCandidate] = [] for window in report["stable_windows"]: if not window.get("document_found"): continue crop_path = out_dir / window["crop_path"] image = cv2.imread(str(crop_path)) if image is None: continue # Always OCR the illumination-normalized version, even if the saved # crop/PDF itself wasn't enhanced (document.enhance only controls the # saved image's appearance) — this is pure naming-accuracy upside. # Skip re-normalizing if the crop was already enhanced at detect time # (cfg.document.enhance) to avoid dividing an already-flat image by # its own blurred background twice. ocr_image = image if cfg.document.enhance else enhance_for_ocr(image) ocr = run_ocr( ocr_image, lang=cfg.ocr.lang, vendor_top_fraction=cfg.ocr.vendor_top_fraction, min_word_confidence=cfg.ocr.min_word_confidence, psm=cfg.ocr.psm, ) # Local vision LLM (Ollama): reads the whole document at once, so it # names curled receipts and hand-occluded captures that defeat # Tesseract. Its fields take priority; OCR heuristics fill gaps. llm = None if use_llm: llm = analyze_document( image, model=cfg.llm.model, base_url=cfg.llm.base_url, timeout=cfg.llm.timeout_s, ) # Drop LLM-flagged blank pages (backs of slips, empty sheets) only # when OCR agrees there's nothing there — vision models (especially # qwen2.5vl) have mislabeled real statement pages as "blank", and # losing a real page is far worse than exporting a blank one. if llm is not None and ( llm.doc_type == "blank" or llm.description.lower().startswith("blank") ): if len(ocr.text.split()) < 8: continue llm = None # its other fields aren't trustworthy for this page vendor = _first(llm.vendor if llm else None, ocr.vendor) date = _first(llm.date if llm else None, ocr.date) form_code = _first(llm.form_code if llm else None, ocr.form_code) total = _first(llm.total if llm else None, ocr.total) if llm is None and not _is_exportable(ocr): continue if llm is not None and not any((vendor, date, total, form_code)): continue candidates.append( _PageCandidate( window=window, crop_path=crop_path, ocr=ocr, llm=llm, vendor=vendor, date=date, time=_first(llm.time if llm else None, ocr.time), total=total, form_code=form_code, tax_year=_first(llm.tax_year if llm else None, ocr.tax_year), org_name=_first( llm.vendor if llm and llm.form_code else None, ocr.org_name ), doc_title=ocr.doc_title, party=_first(llm.party if llm else None, ocr.party), page_number=ocr.page_number, page_total=ocr.page_total, ) ) # ---- Phase 2: group consecutive captures into documents ------------------ documents = _group_documents(candidates) # ---- Phase 3: name and write one PDF per document ------------------------ used_stems: set[str] = set() rows: list[dict[str, Any]] = [] for doc in documents: pages = doc.pages first = pages[0] # Page 1 usually carries the letterhead/date, but any page may have # filled a field the others missed. date = _first(*(p.date for p in pages)) doc_title = _first(*(p.doc_title for p in pages)) party = _first(*(p.party for p in pages)) # Prefer a vendor that isn't just the printed title re-read (the # largest-font heuristic picks "Transaction History" on some pages # and the actual "TD Canada Trust" letterhead on others). vendors = [p.vendor for p in pages if p.vendor] vendor = _first( *(v for v in vendors if not doc_title or slugify(v) not in doc_title), *vendors, ) event_time = recording_start + timedelta(seconds=first.window["t_start"]) naming = build_name( date, vendor, event_time, cfg.naming.max_vendor_len, form_code=_first(*(p.form_code for p in pages)), tax_year=_first(*(p.tax_year for p in pages)), org_name=_first(*(p.org_name for p in pages)), time=_first(*(p.time for p in pages)), doc_title=doc_title, party=party, ) stem = dedupe_stem(naming.stem, used_stems) pdf_path = pdf_dir / f"{stem}.pdf" images_to_pdf([p.crop_path for p in pages], pdf_path, title=stem) llm = first.llm rows.append( { "timestamp": event_time.isoformat(timespec="seconds"), "pod_id": first.window["window_id"], "page_count": len(pages), "page_windows": ";".join(str(p.window["window_id"]) for p in pages), "source_of_name": "llm" if llm else naming.source, "ocr_vendor": vendor or "", "ocr_form_code": _first(*(p.form_code for p in pages)) or "", "ocr_date": date or "", "ocr_time": _first(*(p.time for p in pages)) or "", "ocr_total": _first(*(p.total for p in pages)) or "", "doc_title": doc_title or "", "party": party or "", "ocr_confidence": round(first.ocr.mean_confidence, 1), "llm_description": llm.description if llm else "", "llm_issues": ";".join(llm.issues) if llm else "", "detection_confidence": first.window.get("detection_confidence", ""), "rotation_applied": first.window.get("rotation_applied", 0), "final_filename": pdf_path.name, "pdf_path": str(pdf_path.relative_to(out_dir)), } ) with open(out_dir / "export_summary.csv", "w", newline="") as f: fieldnames = [ "timestamp", "pod_id", "page_count", "page_windows", "source_of_name", "ocr_vendor", "ocr_form_code", "ocr_date", "ocr_time", "ocr_total", "doc_title", "party", "ocr_confidence", "llm_description", "llm_issues", "detection_confidence", "rotation_applied", "final_filename", "pdf_path", ] writer = csv.DictWriter(f, fieldnames=fieldnames) writer.writeheader() writer.writerows(rows) return {"rows": rows, "pdf_dir": str(pdf_dir)}