Add page-marker grouping and party-aware naming
Consecutive captures whose printed PAGE X OF Y markers advance under the same total are grouped into one multi-page PDF, with duplicate pages keeping the better read. Naming gains doc-title/party components and a scan-date fallback so nothing lands in UNSORTED. Vision LLM default switches to minicpm-v (faster, fewer false blanks on dense mono pages).
This commit is contained in:
+263
-53
@@ -11,6 +11,8 @@ 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
|
||||
@@ -21,16 +23,27 @@ 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
|
||||
from paperpod.ocr.extract import run_ocr, extract_store_name
|
||||
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
|
||||
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 enhance_for_ocr
|
||||
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.
|
||||
@@ -89,6 +102,9 @@ def run_detection(video_path: str | Path, cfg: Config, out_dir: str | Path) -> d
|
||||
# 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,
|
||||
@@ -97,22 +113,37 @@ def run_detection(video_path: str | Path, cfg: Config, out_dir: str | Path) -> d
|
||||
canny_high=cfg.document.canny_high,
|
||||
detect_width=cfg.document.detect_width,
|
||||
)
|
||||
samples: list[tuple[Any, Any]] = []
|
||||
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):
|
||||
samples.append((frame, detection))
|
||||
scored_samples.append(score_sample(frame, detection))
|
||||
|
||||
doc_events = find_document_events(
|
||||
samples,
|
||||
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
|
||||
|
||||
@@ -123,9 +154,10 @@ def run_detection(video_path: str | Path, cfg: Config, out_dir: str | Path) -> d
|
||||
"duration_s": round(event.t_end - event.t_start, 3),
|
||||
"sample_count": event.sample_count,
|
||||
"document_found": False,
|
||||
"best_frame_t": round(frame.t, 3),
|
||||
"best_frame_index": frame.index,
|
||||
"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"
|
||||
@@ -154,6 +186,9 @@ def run_detection(video_path: str | Path, cfg: Config, out_dir: str | Path) -> d
|
||||
|
||||
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)
|
||||
@@ -202,20 +237,152 @@ 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):
|
||||
if extract_store_name(ocr.text) or ocr.doc_title or ocr.page_number:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def run_export(report: dict[str, Any], cfg: Config, out_dir: str | Path) -> dict[str, Any]:
|
||||
"""OCR-name and PDF-export every detected crop from a detect() report.
|
||||
@dataclass
|
||||
class _PageCandidate:
|
||||
"""One exportable capture: crop + everything extracted from it."""
|
||||
|
||||
NOTE: there is no page-grouping yet (module 3 / events/ is not built),
|
||||
so every detected crop becomes its own single-page PDF, even if it was
|
||||
physically one side of a multi-page stack. pod_id == window_id for now.
|
||||
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/<name>.pdf one PDF per detected document
|
||||
pdf/<name>.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).
|
||||
@@ -237,10 +404,8 @@ def run_export(report: dict[str, Any], cfg: Config, out_dir: str | Path) -> dict
|
||||
if cfg.llm.enabled and not use_llm:
|
||||
print("Ollama not reachable — falling back to Tesseract-only naming.")
|
||||
|
||||
used_stems: set[str] = set()
|
||||
rows: list[dict[str, Any]] = []
|
||||
prev_signature: tuple = ()
|
||||
|
||||
# ---- 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
|
||||
@@ -277,66 +442,110 @@ def run_export(report: dict[str, Any], cfg: Config, out_dir: str | Path) -> dict
|
||||
timeout=cfg.llm.timeout_s,
|
||||
)
|
||||
|
||||
# Blank pages (backs of slips, empty sheets) OCR to garbled noise;
|
||||
# the LLM identifies them reliably, so drop them outright.
|
||||
# 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")
|
||||
):
|
||||
continue
|
||||
if len(ocr.text.split()) < 8:
|
||||
continue
|
||||
llm = None # its other fields aren't trustworthy for this page
|
||||
|
||||
vendor = (llm.vendor if llm else None) or ocr.vendor
|
||||
date = (llm.date if llm else None) or ocr.date
|
||||
time = (llm.time if llm else None) or ocr.time
|
||||
total = (llm.total if llm else None) or ocr.total
|
||||
form_code = (llm.form_code if llm else None) or ocr.form_code
|
||||
tax_year = (llm.tax_year if llm else None) or ocr.tax_year
|
||||
org_name = (llm.vendor if llm and llm.form_code else None) or ocr.org_name
|
||||
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
|
||||
|
||||
# The same physical document sometimes splits into two capture
|
||||
# events (hand briefly breaks the contour). If the previous export
|
||||
# has the identical identity signature, it's the same document.
|
||||
signature = (vendor, date, total, form_code)
|
||||
if any(signature) and signature == prev_signature:
|
||||
continue
|
||||
prev_signature = signature
|
||||
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,
|
||||
)
|
||||
)
|
||||
|
||||
event_time = recording_start + timedelta(seconds=window["t_start"])
|
||||
# ---- 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=form_code,
|
||||
tax_year=tax_year,
|
||||
org_name=org_name,
|
||||
time=time,
|
||||
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([crop_path], pdf_path, title=stem)
|
||||
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": window["window_id"],
|
||||
"page_count": 1,
|
||||
"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": form_code or "",
|
||||
"ocr_form_code": _first(*(p.form_code for p in pages)) or "",
|
||||
"ocr_date": date or "",
|
||||
"ocr_time": time or "",
|
||||
"ocr_total": total or "",
|
||||
"ocr_confidence": round(ocr.mean_confidence, 1),
|
||||
"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": window.get("detection_confidence", ""),
|
||||
"rotation_applied": window.get("rotation_applied", 0),
|
||||
"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)),
|
||||
}
|
||||
@@ -344,8 +553,9 @@ def run_export(report: dict[str, Any], cfg: Config, out_dir: str | Path) -> dict
|
||||
|
||||
with open(out_dir / "export_summary.csv", "w", newline="") as f:
|
||||
fieldnames = [
|
||||
"timestamp", "pod_id", "page_count", "source_of_name",
|
||||
"ocr_vendor", "ocr_form_code", "ocr_date", "ocr_time", "ocr_total", "ocr_confidence",
|
||||
"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",
|
||||
|
||||
Reference in New Issue
Block a user