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:
+4
-2
@@ -66,9 +66,11 @@ llm:
|
|||||||
# Reads whole documents at once — far more robust than Tesseract for
|
# Reads whole documents at once — far more robust than Tesseract for
|
||||||
# curled receipts and stylized logos — and flags quality issues
|
# curled receipts and stylized logos — and flags quality issues
|
||||||
# (fingers_visible, blurry, glare) in export_summary.csv.
|
# (fingers_visible, blurry, glare) in export_summary.csv.
|
||||||
# Slower: ~20-40s per document. Falls back to Tesseract when Ollama is down.
|
# minicpm-v: ~8-12s/doc; avoids qwen2.5vl's false "blank" on dense
|
||||||
|
# bank statements. OCR still fills date/total when the model hallucinates.
|
||||||
|
# Falls back to Tesseract when Ollama is down.
|
||||||
enabled: true
|
enabled: true
|
||||||
model: qwen2.5vl
|
model: minicpm-v
|
||||||
base_url: http://localhost:11434
|
base_url: http://localhost:11434
|
||||||
timeout_s: 180
|
timeout_s: 180
|
||||||
|
|
||||||
|
|||||||
+5
-6
@@ -90,11 +90,14 @@ def cmd_export(args: argparse.Namespace) -> int:
|
|||||||
print(f"Running OCR + naming on {len(found)} detected document(s)...")
|
print(f"Running OCR + naming on {len(found)} detected document(s)...")
|
||||||
result = run_export(report, cfg, out_dir)
|
result = run_export(report, cfg, out_dir)
|
||||||
|
|
||||||
print(f"\n{'pod':>3} {'date':>10} vendor / final filename")
|
print(f"\n{'pod':>3} {'pages':>5} {'date':>10} vendor / final filename")
|
||||||
print("-" * 60)
|
print("-" * 60)
|
||||||
for row in result["rows"]:
|
for row in result["rows"]:
|
||||||
vendor = row["ocr_vendor"] or "(no vendor found)"
|
vendor = row["ocr_vendor"] or "(no vendor found)"
|
||||||
print(f"{row['pod_id']:>3} {row['ocr_date'] or '-':>10} {vendor}")
|
print(
|
||||||
|
f"{row['pod_id']:>3} {row['page_count']:>5} "
|
||||||
|
f"{row['ocr_date'] or '-':>10} {vendor}"
|
||||||
|
)
|
||||||
print(f" -> {row['pdf_path']} [source: {row['source_of_name']}]")
|
print(f" -> {row['pdf_path']} [source: {row['source_of_name']}]")
|
||||||
if row.get("llm_description"):
|
if row.get("llm_description"):
|
||||||
issues = f" issues: {row['llm_issues']}" if row.get("llm_issues") else ""
|
issues = f" issues: {row['llm_issues']}" if row.get("llm_issues") else ""
|
||||||
@@ -102,10 +105,6 @@ def cmd_export(args: argparse.Namespace) -> int:
|
|||||||
|
|
||||||
print(f"\nPDFs: {result['pdf_dir']}")
|
print(f"\nPDFs: {result['pdf_dir']}")
|
||||||
print(f"Summary: {out_dir / 'export_summary.csv'}")
|
print(f"Summary: {out_dir / 'export_summary.csv'}")
|
||||||
print(
|
|
||||||
"\nNote: no page-flip/multi-page grouping yet — each detected "
|
|
||||||
"document is a separate single-page PDF."
|
|
||||||
)
|
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+3
-2
@@ -102,9 +102,10 @@ class OcrConfig:
|
|||||||
class LlmConfig:
|
class LlmConfig:
|
||||||
# Use a local Ollama vision model to identify/name documents and flag
|
# Use a local Ollama vision model to identify/name documents and flag
|
||||||
# quality issues. Falls back to Tesseract heuristics when Ollama is
|
# quality issues. Falls back to Tesseract heuristics when Ollama is
|
||||||
# down or the model errors. ~20-40s per document vs <2s for Tesseract.
|
# down or the model errors. minicpm-v is ~8-12s/doc and much less prone
|
||||||
|
# to false "blank" labels than qwen2.5vl on dense statement pages.
|
||||||
enabled: bool = True
|
enabled: bool = True
|
||||||
model: str = "qwen2.5vl"
|
model: str = "minicpm-v"
|
||||||
base_url: str = "http://localhost:11434"
|
base_url: str = "http://localhost:11434"
|
||||||
timeout_s: float = 180.0
|
timeout_s: float = 180.0
|
||||||
|
|
||||||
|
|||||||
+18
-9
@@ -1,13 +1,17 @@
|
|||||||
"""Identify documents with a local vision LLM served by Ollama.
|
"""Identify documents with a local vision LLM served by Ollama.
|
||||||
|
|
||||||
Everything stays on-machine: images go to localhost:11434, never the cloud.
|
Everything stays on-machine: images go to localhost:11434, never the cloud.
|
||||||
A vision model (qwen2.5-vl class) reads the whole document at once, which is
|
A vision model (default: minicpm-v) reads the whole document at once, which
|
||||||
far more robust than Tesseract + regex for curled receipts, hands in frame,
|
is far more robust than Tesseract + regex for curled receipts, hands in
|
||||||
and stylized logos — it correctly reads "COSTCO WHOLESALE" where Tesseract
|
frame, and stylized logos — it correctly reads "COSTCO WHOLESALE" where
|
||||||
produced "Cosrco". It also reports quality issues (fingers, blur, glare) so
|
Tesseract produced "Cosrco". It also reports quality issues (fingers, blur,
|
||||||
bad captures can be flagged for re-scanning.
|
glare) so bad captures can be flagged for re-scanning.
|
||||||
|
|
||||||
Trade-off: ~20-40s per document on an M-series Mac, vs <2s for Tesseract.
|
minicpm-v was chosen over qwen2.5vl after A/B on overhead statement scans:
|
||||||
|
~2x faster and far fewer false "blank" labels on dense mono bank pages.
|
||||||
|
Dates can still hallucinate — the pipeline keeps OCR as date/total fill-in.
|
||||||
|
|
||||||
|
Trade-off: ~8-12s per document on an M-series Mac, vs <2s for Tesseract.
|
||||||
The pipeline uses the LLM for naming when available and falls back to the
|
The pipeline uses the LLM for naming when available and falls back to the
|
||||||
OCR heuristics when Ollama isn't running.
|
OCR heuristics when Ollama isn't running.
|
||||||
"""
|
"""
|
||||||
@@ -20,6 +24,7 @@ import re
|
|||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
import cv2
|
import cv2
|
||||||
import numpy as np
|
import numpy as np
|
||||||
@@ -27,6 +32,7 @@ import numpy as np
|
|||||||
_PROMPT = """Look at this image of a document. Reply with ONLY a JSON object, no other text:
|
_PROMPT = """Look at this image of a document. Reply with ONLY a JSON object, no other text:
|
||||||
{"doc_type": "receipt|tax_form|letter|invoice|blank|other",
|
{"doc_type": "receipt|tax_form|letter|invoice|blank|other",
|
||||||
"vendor": "business or organization name, or null",
|
"vendor": "business or organization name, or null",
|
||||||
|
"party": "account holder / customer / SHORTNAME / payee printed on the document, or null",
|
||||||
"form_code": "tax form code like T4/T4A/T5008 if this is a tax form, else null",
|
"form_code": "tax form code like T4/T4A/T5008 if this is a tax form, else null",
|
||||||
"date": "document/transaction date as YYYY-MM-DD, or null",
|
"date": "document/transaction date as YYYY-MM-DD, or null",
|
||||||
"time": "transaction time as 24-hour HH:MM if printed on the document, else null",
|
"time": "transaction time as 24-hour HH:MM if printed on the document, else null",
|
||||||
@@ -35,13 +41,15 @@ _PROMPT = """Look at this image of a document. Reply with ONLY a JSON object, no
|
|||||||
"description": "3-6 word summary",
|
"description": "3-6 word summary",
|
||||||
"issues": ["any of: fingers_visible, blurry, cut_off, upside_down, glare, partial_document"]}
|
"issues": ["any of: fingers_visible, blurry, cut_off, upside_down, glare, partial_document"]}
|
||||||
Use doc_type "blank" if the page has no meaningful printed content (blank back of a page, empty sheet).
|
Use doc_type "blank" if the page has no meaningful printed content (blank back of a page, empty sheet).
|
||||||
Set vendor to null unless you can actually read a real business/organization name — never output garbled text."""
|
Set vendor to null unless you can actually read a real business/organization name — never output garbled text.
|
||||||
|
Set party to the person/entity the document is about (e.g. SHORTNAME on a bank statement, payee on a slip) when clearly printed; otherwise null."""
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class LlmAnalysis:
|
class LlmAnalysis:
|
||||||
doc_type: str
|
doc_type: str
|
||||||
vendor: str | None
|
vendor: str | None
|
||||||
|
party: str | None
|
||||||
form_code: str | None
|
form_code: str | None
|
||||||
date: str | None # normalized YYYY-MM-DD
|
date: str | None # normalized YYYY-MM-DD
|
||||||
time: str | None # normalized HH:MM (24h)
|
time: str | None # normalized HH:MM (24h)
|
||||||
@@ -77,7 +85,7 @@ def _normalize_date(raw: str | None) -> str | None:
|
|||||||
if not match:
|
if not match:
|
||||||
return None
|
return None
|
||||||
y, m, d = (int(g) for g in match.groups())
|
y, m, d = (int(g) for g in match.groups())
|
||||||
if not (1 <= m <= 12 and 1 <= d <= 31 and 1990 <= y <= 2100):
|
if not (1 <= m <= 12 and 1 <= d <= 31 and 1990 <= y <= datetime.now().year + 1):
|
||||||
return None
|
return None
|
||||||
return f"{y:04d}-{m:02d}-{d:02d}"
|
return f"{y:04d}-{m:02d}-{d:02d}"
|
||||||
|
|
||||||
@@ -100,7 +108,7 @@ def _normalize_time(raw: str | None) -> str | None:
|
|||||||
|
|
||||||
def analyze_document(
|
def analyze_document(
|
||||||
image_bgr: np.ndarray,
|
image_bgr: np.ndarray,
|
||||||
model: str = "qwen2.5vl",
|
model: str = "minicpm-v",
|
||||||
base_url: str = "http://localhost:11434",
|
base_url: str = "http://localhost:11434",
|
||||||
timeout: float = 180.0,
|
timeout: float = 180.0,
|
||||||
) -> LlmAnalysis | None:
|
) -> LlmAnalysis | None:
|
||||||
@@ -137,6 +145,7 @@ def analyze_document(
|
|||||||
return LlmAnalysis(
|
return LlmAnalysis(
|
||||||
doc_type=_clean_str(data.get("doc_type")) or "other",
|
doc_type=_clean_str(data.get("doc_type")) or "other",
|
||||||
vendor=_clean_str(data.get("vendor")),
|
vendor=_clean_str(data.get("vendor")),
|
||||||
|
party=_clean_str(data.get("party")),
|
||||||
form_code=_clean_str(data.get("form_code")),
|
form_code=_clean_str(data.get("form_code")),
|
||||||
date=_normalize_date(data.get("date")),
|
date=_normalize_date(data.get("date")),
|
||||||
time=_normalize_time(data.get("time")),
|
time=_normalize_time(data.get("time")),
|
||||||
|
|||||||
+46
-18
@@ -1,10 +1,11 @@
|
|||||||
"""Build the final filename/PDF title from OCR (and, later, speech) metadata.
|
"""Build the final filename/PDF title from OCR (and, later, speech) metadata.
|
||||||
|
|
||||||
Pattern: YYYY-MM-DD_vendor.pdf, falling back progressively as pieces of
|
Pattern: YYYY-MM-DD_vendor[_doc-title].pdf. When the document's own date
|
||||||
information go missing, down to UNSORTED_<timestamp>.pdf when nothing usable
|
can't be read, the scan date (capture timestamp from the video) is used
|
||||||
was extracted at all. Module 4 (speech) isn't built yet, so "description" in
|
instead so files still sort chronologically and never get an opaque
|
||||||
the original spec's pattern is currently always the OCR vendor slug; once
|
"UNSORTED" prefix; export_summary.csv records that the date came from the
|
||||||
speech transcription lands, a spoken description will take priority here.
|
scan, not the document. Module 4 (speech) isn't built yet; once speech
|
||||||
|
transcription lands, a spoken description will take priority here.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -24,7 +25,7 @@ def slugify(text: str, max_len: int = 40) -> str:
|
|||||||
@dataclass
|
@dataclass
|
||||||
class NamingResult:
|
class NamingResult:
|
||||||
stem: str # filename without extension
|
stem: str # filename without extension
|
||||||
source: str # "ocr" | "none" (will include "speech" once module 4 exists)
|
source: str # "ocr" | "scan_date" | "none" ("speech" once module 4 exists)
|
||||||
|
|
||||||
|
|
||||||
_TIME_RE = re.compile(r"^([01]\d|2[0-3]):([0-5]\d)$")
|
_TIME_RE = re.compile(r"^([01]\d|2[0-3]):([0-5]\d)$")
|
||||||
@@ -39,21 +40,23 @@ def build_name(
|
|||||||
tax_year: str | None = None,
|
tax_year: str | None = None,
|
||||||
org_name: str | None = None,
|
org_name: str | None = None,
|
||||||
time: str | None = None,
|
time: str | None = None,
|
||||||
|
doc_title: str | None = None,
|
||||||
|
party: str | None = None,
|
||||||
) -> NamingResult:
|
) -> NamingResult:
|
||||||
"""date must already be normalized to YYYY-MM-DD, time to HH:MM, or None.
|
"""date must already be normalized to YYYY-MM-DD, time to HH:MM, or None.
|
||||||
|
|
||||||
Time is appended to the date (e.g. 2023-03-20_1525) whenever it was
|
Time is appended to the date (e.g. 2023-03-20_1525) whenever it was
|
||||||
extracted, so two same-day visits to the same vendor (Walmart in the
|
extracted, so two same-day visits to the same vendor (Walmart in the
|
||||||
morning and again in the evening) don't collide or silently overwrite.
|
morning and again in the evening) don't collide or silently overwrite.
|
||||||
|
|
||||||
|
doc_title is a recognized printed title slug ("transaction_history",
|
||||||
|
"invoice"), appended after the vendor unless it would be redundant.
|
||||||
|
party is an account holder / SHORTNAME when printed on the document.
|
||||||
"""
|
"""
|
||||||
vendor_slug = slugify(vendor, max_vendor_len) if vendor else None
|
vendor_slug = slugify(vendor, max_vendor_len) if vendor else None
|
||||||
ts = fallback_dt.strftime("%Y%m%d_%H%M%S")
|
party_slug = slugify(party, max_vendor_len) if party else None
|
||||||
|
|
||||||
date_part = date
|
# Tax slips: prefer 2023_T4_York_University over a date-based name.
|
||||||
if date and time and _TIME_RE.match(time):
|
|
||||||
date_part = f"{date}_{time.replace(':', '')}"
|
|
||||||
|
|
||||||
# Tax slips: prefer 2023_T4_York_University over UNSORTED_t4_york_university_<timestamp>.
|
|
||||||
if form_code:
|
if form_code:
|
||||||
org_slug = slugify(org_name, max_vendor_len) if org_name else None
|
org_slug = slugify(org_name, max_vendor_len) if org_name else None
|
||||||
if not org_slug and vendor:
|
if not org_slug and vendor:
|
||||||
@@ -71,13 +74,38 @@ def build_name(
|
|||||||
return NamingResult(stem=f"{code}_{org_slug}", source="ocr")
|
return NamingResult(stem=f"{code}_{org_slug}", source="ocr")
|
||||||
return NamingResult(stem=code.lower(), source="ocr")
|
return NamingResult(stem=code.lower(), source="ocr")
|
||||||
|
|
||||||
if date_part and vendor_slug:
|
if date:
|
||||||
return NamingResult(stem=f"{date_part}_{vendor_slug}", source="ocr")
|
date_part = date
|
||||||
if date_part:
|
if time and _TIME_RE.match(time):
|
||||||
return NamingResult(stem=f"{date_part}_UNSORTED", source="ocr")
|
date_part = f"{date}_{time.replace(':', '')}"
|
||||||
|
source = "ocr"
|
||||||
|
else:
|
||||||
|
# Scan date beats an opaque "UNSORTED" prefix: files still sort,
|
||||||
|
# and the CSV records that the date is when it was scanned.
|
||||||
|
date_part = fallback_dt.strftime("%Y-%m-%d")
|
||||||
|
source = "scan_date"
|
||||||
|
|
||||||
|
title_slug = slugify(doc_title, max_vendor_len) if doc_title else None
|
||||||
|
parts = [date_part]
|
||||||
if vendor_slug:
|
if vendor_slug:
|
||||||
return NamingResult(stem=f"UNSORTED_{vendor_slug}_{ts}", source="ocr")
|
parts.append(vendor_slug)
|
||||||
return NamingResult(stem=f"UNSORTED_{ts}", source="none")
|
# Skip the title when the vendor line already says the same thing
|
||||||
|
# (Tesseract often picks the printed title as the "vendor").
|
||||||
|
if title_slug and not (vendor_slug and title_slug in vendor_slug):
|
||||||
|
parts.append(title_slug)
|
||||||
|
# Account-holder shortname after the doc type — disambiguates family
|
||||||
|
# members who share a bank (LEVIT I vs LEVIT M).
|
||||||
|
if party_slug and party_slug not in parts and (
|
||||||
|
not vendor_slug or party_slug not in vendor_slug
|
||||||
|
):
|
||||||
|
parts.append(party_slug)
|
||||||
|
|
||||||
|
if len(parts) == 1:
|
||||||
|
parts.append("document" if date else "scan")
|
||||||
|
if not date:
|
||||||
|
source = "none"
|
||||||
|
|
||||||
|
return NamingResult(stem="_".join(parts), source=source)
|
||||||
|
|
||||||
|
|
||||||
def dedupe_stem(stem: str, used: set[str]) -> str:
|
def dedupe_stem(stem: str, used: set[str]) -> str:
|
||||||
|
|||||||
+262
-52
@@ -11,6 +11,8 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import csv
|
import csv
|
||||||
import json
|
import json
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
@@ -21,16 +23,27 @@ import numpy as np
|
|||||||
from paperpod.capture.video import iter_frames, probe_video
|
from paperpod.capture.video import iter_frames, probe_video
|
||||||
from paperpod.config import Config, config_to_dict
|
from paperpod.config import Config, config_to_dict
|
||||||
from paperpod.llm.vision import analyze_document, ollama_available
|
from paperpod.llm.vision import analyze_document, ollama_available
|
||||||
from paperpod.naming.filename import build_name, dedupe_stem
|
from paperpod.naming.filename import build_name, dedupe_stem, slugify
|
||||||
from paperpod.ocr.extract import run_ocr, extract_store_name
|
from paperpod.ocr.extract import extract_store_name, run_ocr
|
||||||
from paperpod.pdf.build import images_to_pdf
|
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.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.motion import find_segments, motion_score, prepare_gray
|
||||||
from paperpod.vision.orient import auto_rotate
|
from paperpod.vision.orient import auto_rotate
|
||||||
from paperpod.vision.refine import refine_crop
|
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]:
|
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.
|
"""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
|
# Unlike stable-window-only detection, this still captures receipts shown
|
||||||
# while hands are adjusting them (motion scores stay high but the contour
|
# while hands are adjusting them (motion scores stay high but the contour
|
||||||
# is visible frame-by-frame).
|
# 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(
|
det_kwargs = dict(
|
||||||
min_area_ratio=cfg.document.min_area_ratio,
|
min_area_ratio=cfg.document.min_area_ratio,
|
||||||
max_area_ratio=cfg.document.max_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,
|
canny_high=cfg.document.canny_high,
|
||||||
detect_width=cfg.document.detect_width,
|
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):
|
for frame in iter_frames(video_path, sample_fps=cfg.capture.sample_fps):
|
||||||
detection = detect_document(frame.image, **det_kwargs)
|
detection = detect_document(frame.image, **det_kwargs)
|
||||||
if detection is not None and _plausible_capture(detection):
|
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(
|
doc_events = find_document_events(
|
||||||
samples,
|
scored_samples,
|
||||||
gap_s=cfg.document.event_gap_s,
|
gap_s=cfg.document.event_gap_s,
|
||||||
min_samples=cfg.document.event_min_samples,
|
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]] = []
|
window_records: list[dict[str, Any]] = []
|
||||||
for event in doc_events:
|
for event in doc_events:
|
||||||
i = event.event_id
|
i = event.event_id
|
||||||
frame = event.frame
|
frame = event.frame
|
||||||
|
if frame is None:
|
||||||
|
# Winner index was skipped (sample_fps / codec edge); skip event.
|
||||||
|
continue
|
||||||
detection = event.detection
|
detection = event.detection
|
||||||
sharp = event.sharpness
|
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),
|
"duration_s": round(event.t_end - event.t_start, 3),
|
||||||
"sample_count": event.sample_count,
|
"sample_count": event.sample_count,
|
||||||
"document_found": False,
|
"document_found": False,
|
||||||
"best_frame_t": round(frame.t, 3),
|
"best_frame_t": round(event.best_frame_t, 3),
|
||||||
"best_frame_index": frame.index,
|
"best_frame_index": event.best_frame_index,
|
||||||
"sharpness": round(sharp, 1),
|
"sharpness": round(sharp, 1),
|
||||||
|
"best_frame_skin_fraction": event.skin_fraction,
|
||||||
}
|
}
|
||||||
|
|
||||||
frame_path = frames_dir / f"window_{i:03d}_full.png"
|
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:
|
if cfg.document.enhance:
|
||||||
crop = enhance_for_ocr(crop)
|
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"
|
crop_path = crops_dir / f"window_{i:03d}.png"
|
||||||
cv2.imwrite(str(crop_path), crop)
|
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."""
|
"""Skip obvious mat-transition junk that has no usable naming signal."""
|
||||||
if ocr.form_code or ocr.date or ocr.total:
|
if ocr.form_code or ocr.date or ocr.total:
|
||||||
return True
|
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 True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def run_export(report: dict[str, Any], cfg: Config, out_dir: str | Path) -> dict[str, Any]:
|
@dataclass
|
||||||
"""OCR-name and PDF-export every detected crop from a detect() report.
|
class _PageCandidate:
|
||||||
|
"""One exportable capture: crop + everything extracted from it."""
|
||||||
|
|
||||||
NOTE: there is no page-grouping yet (module 3 / events/ is not built),
|
window: dict[str, Any]
|
||||||
so every detected crop becomes its own single-page PDF, even if it was
|
crop_path: Path
|
||||||
physically one side of a multi-page stack. pod_id == window_id for now.
|
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:
|
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,
|
export_summary.csv timestamp, pod_id, page_count, source_of_name,
|
||||||
ocr_vendor, ocr_date, ocr_total, final_filename
|
ocr_vendor, ocr_date, ocr_total, final_filename
|
||||||
Returns the summary rows (also embedded back for programmatic use).
|
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:
|
if cfg.llm.enabled and not use_llm:
|
||||||
print("Ollama not reachable — falling back to Tesseract-only naming.")
|
print("Ollama not reachable — falling back to Tesseract-only naming.")
|
||||||
|
|
||||||
used_stems: set[str] = set()
|
# ---- Phase 1: OCR/LLM every crop, keep the exportable ones --------------
|
||||||
rows: list[dict[str, Any]] = []
|
candidates: list[_PageCandidate] = []
|
||||||
prev_signature: tuple = ()
|
|
||||||
|
|
||||||
for window in report["stable_windows"]:
|
for window in report["stable_windows"]:
|
||||||
if not window.get("document_found"):
|
if not window.get("document_found"):
|
||||||
continue
|
continue
|
||||||
@@ -277,66 +442,110 @@ def run_export(report: dict[str, Any], cfg: Config, out_dir: str | Path) -> dict
|
|||||||
timeout=cfg.llm.timeout_s,
|
timeout=cfg.llm.timeout_s,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Blank pages (backs of slips, empty sheets) OCR to garbled noise;
|
# Drop LLM-flagged blank pages (backs of slips, empty sheets) only
|
||||||
# the LLM identifies them reliably, so drop them outright.
|
# 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 (
|
if llm is not None and (
|
||||||
llm.doc_type == "blank" or llm.description.lower().startswith("blank")
|
llm.doc_type == "blank" or llm.description.lower().startswith("blank")
|
||||||
):
|
):
|
||||||
|
if len(ocr.text.split()) < 8:
|
||||||
continue
|
continue
|
||||||
|
llm = None # its other fields aren't trustworthy for this page
|
||||||
|
|
||||||
vendor = (llm.vendor if llm else None) or ocr.vendor
|
vendor = _first(llm.vendor if llm else None, ocr.vendor)
|
||||||
date = (llm.date if llm else None) or ocr.date
|
date = _first(llm.date if llm else None, ocr.date)
|
||||||
time = (llm.time if llm else None) or ocr.time
|
form_code = _first(llm.form_code if llm else None, ocr.form_code)
|
||||||
total = (llm.total if llm else None) or ocr.total
|
total = _first(llm.total if llm else None, 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
|
|
||||||
|
|
||||||
if llm is None and not _is_exportable(ocr):
|
if llm is None and not _is_exportable(ocr):
|
||||||
continue
|
continue
|
||||||
if llm is not None and not any((vendor, date, total, form_code)):
|
if llm is not None and not any((vendor, date, total, form_code)):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# The same physical document sometimes splits into two capture
|
candidates.append(
|
||||||
# events (hand briefly breaks the contour). If the previous export
|
_PageCandidate(
|
||||||
# has the identical identity signature, it's the same document.
|
window=window,
|
||||||
signature = (vendor, date, total, form_code)
|
crop_path=crop_path,
|
||||||
if any(signature) and signature == prev_signature:
|
ocr=ocr,
|
||||||
continue
|
llm=llm,
|
||||||
prev_signature = signature
|
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(
|
naming = build_name(
|
||||||
date,
|
date,
|
||||||
vendor,
|
vendor,
|
||||||
event_time,
|
event_time,
|
||||||
cfg.naming.max_vendor_len,
|
cfg.naming.max_vendor_len,
|
||||||
form_code=form_code,
|
form_code=_first(*(p.form_code for p in pages)),
|
||||||
tax_year=tax_year,
|
tax_year=_first(*(p.tax_year for p in pages)),
|
||||||
org_name=org_name,
|
org_name=_first(*(p.org_name for p in pages)),
|
||||||
time=time,
|
time=_first(*(p.time for p in pages)),
|
||||||
|
doc_title=doc_title,
|
||||||
|
party=party,
|
||||||
)
|
)
|
||||||
stem = dedupe_stem(naming.stem, used_stems)
|
stem = dedupe_stem(naming.stem, used_stems)
|
||||||
|
|
||||||
pdf_path = pdf_dir / f"{stem}.pdf"
|
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(
|
rows.append(
|
||||||
{
|
{
|
||||||
"timestamp": event_time.isoformat(timespec="seconds"),
|
"timestamp": event_time.isoformat(timespec="seconds"),
|
||||||
"pod_id": window["window_id"],
|
"pod_id": first.window["window_id"],
|
||||||
"page_count": 1,
|
"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,
|
"source_of_name": "llm" if llm else naming.source,
|
||||||
"ocr_vendor": vendor or "",
|
"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_date": date or "",
|
||||||
"ocr_time": time or "",
|
"ocr_time": _first(*(p.time for p in pages)) or "",
|
||||||
"ocr_total": total or "",
|
"ocr_total": _first(*(p.total for p in pages)) or "",
|
||||||
"ocr_confidence": round(ocr.mean_confidence, 1),
|
"doc_title": doc_title or "",
|
||||||
|
"party": party or "",
|
||||||
|
"ocr_confidence": round(first.ocr.mean_confidence, 1),
|
||||||
"llm_description": llm.description if llm else "",
|
"llm_description": llm.description if llm else "",
|
||||||
"llm_issues": ";".join(llm.issues) if llm else "",
|
"llm_issues": ";".join(llm.issues) if llm else "",
|
||||||
"detection_confidence": window.get("detection_confidence", ""),
|
"detection_confidence": first.window.get("detection_confidence", ""),
|
||||||
"rotation_applied": window.get("rotation_applied", 0),
|
"rotation_applied": first.window.get("rotation_applied", 0),
|
||||||
"final_filename": pdf_path.name,
|
"final_filename": pdf_path.name,
|
||||||
"pdf_path": str(pdf_path.relative_to(out_dir)),
|
"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:
|
with open(out_dir / "export_summary.csv", "w", newline="") as f:
|
||||||
fieldnames = [
|
fieldnames = [
|
||||||
"timestamp", "pod_id", "page_count", "source_of_name",
|
"timestamp", "pod_id", "page_count", "page_windows", "source_of_name",
|
||||||
"ocr_vendor", "ocr_form_code", "ocr_date", "ocr_time", "ocr_total", "ocr_confidence",
|
"ocr_vendor", "ocr_form_code", "ocr_date", "ocr_time", "ocr_total",
|
||||||
|
"doc_title", "party", "ocr_confidence",
|
||||||
"llm_description", "llm_issues",
|
"llm_description", "llm_issues",
|
||||||
"detection_confidence", "rotation_applied",
|
"detection_confidence", "rotation_applied",
|
||||||
"final_filename", "pdf_path",
|
"final_filename", "pdf_path",
|
||||||
|
|||||||
@@ -4,19 +4,57 @@ Stable-window-only detection misses documents shown while hands are still
|
|||||||
adjusting them — motion scores stay above threshold even though a receipt
|
adjusting them — motion scores stay above threshold even though a receipt
|
||||||
is clearly visible and detectable frame-by-frame. This clusters consecutive
|
is clearly visible and detectable frame-by-frame. This clusters consecutive
|
||||||
frames where a document contour was found (with a time-gap to split separate
|
frames where a document contour was found (with a time-gap to split separate
|
||||||
placements) and keeps the sharpest frame from each cluster.
|
placements) and keeps the best frame from each cluster.
|
||||||
|
|
||||||
|
"Best" means hands-free first, sharp second: fingers holding a page flat are
|
||||||
|
usually present in most frames of an event but absent in at least a few
|
||||||
|
(right after placing / before flipping). A sharpness-only pick routinely
|
||||||
|
selects a hand-in-frame frame, which then has to be inpainted (lossy).
|
||||||
|
Preferring the lowest-skin frame keeps the original pixels instead.
|
||||||
|
|
||||||
|
Memory: scoring runs as frames arrive; only tiny gray proxies + metrics are
|
||||||
|
retained for clustering. Full-res BGR is dropped immediately so a long 4K
|
||||||
|
video cannot OOM by holding thousands of detections in RAM (callers re-decode
|
||||||
|
winning frame indices in a third pass).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
import cv2
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
from paperpod.capture.video import Frame
|
from paperpod.capture.video import Frame
|
||||||
from paperpod.vision.document import DocumentDetection
|
from paperpod.vision.document import DocumentDetection
|
||||||
|
from paperpod.vision.motion import motion_score, prepare_gray
|
||||||
|
from paperpod.vision.refine import skin_mask
|
||||||
from paperpod.vision.sharpness import sharpness_score
|
from paperpod.vision.sharpness import sharpness_score
|
||||||
|
|
||||||
|
# Downscale width for per-frame skin analysis; full-res 4K would dominate
|
||||||
|
# pass-2 runtime for no accuracy gain.
|
||||||
|
_SKIN_ANALYSIS_WIDTH = 400
|
||||||
|
|
||||||
|
# Skin coverage below this fraction of the document region counts as a
|
||||||
|
# "clean" (hands-free) frame; between the two thresholds counts as "touched"
|
||||||
|
# (fingertip on an edge); above is "held" (hand across the page).
|
||||||
|
_SKIN_CLEAN = 0.003
|
||||||
|
_SKIN_TOUCHED = 0.03
|
||||||
|
|
||||||
|
# Reject a "cleaner" frame if it's this dull relative to the sharpest
|
||||||
|
# sample seen in the same event.
|
||||||
|
_SHARP_FLOOR = 0.4
|
||||||
|
|
||||||
|
# Long placements that include a page flip keep the same paper outline, so
|
||||||
|
# contour clustering never splits them. Instead we look for *quiet dwells*
|
||||||
|
# (runs of near-zero inter-sample motion) inside a long event — each dwell
|
||||||
|
# is one page lying flat. Page 2 of the Jan '25 TD statement sat still for
|
||||||
|
# ~0.7s around t=10.4; hand-wobble stretches between dwells never qualify.
|
||||||
|
_QUIET_MOTION = 0.015
|
||||||
|
_MIN_DWELL_S = 0.55
|
||||||
|
_LONG_EVENT_S = 4.0
|
||||||
|
_MOTION_SIZE = (160, 90)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class DocumentEvent:
|
class DocumentEvent:
|
||||||
@@ -27,8 +65,84 @@ class DocumentEvent:
|
|||||||
best_frame_t: float
|
best_frame_t: float
|
||||||
best_frame_index: int
|
best_frame_index: int
|
||||||
sharpness: float
|
sharpness: float
|
||||||
frame: Frame
|
skin_fraction: float
|
||||||
detection: DocumentDetection
|
detection: DocumentDetection
|
||||||
|
# Populated by the pipeline after a re-decode pass; None right after
|
||||||
|
# clustering so the scorer never retains full-res images.
|
||||||
|
frame: Frame | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ScoredSample:
|
||||||
|
"""Per-detection metrics without the full-res BGR frame."""
|
||||||
|
|
||||||
|
index: int
|
||||||
|
t: float
|
||||||
|
shape: tuple[int, ...] # (h, w, c) of the source frame
|
||||||
|
det: DocumentDetection
|
||||||
|
sharp: float
|
||||||
|
skin: float
|
||||||
|
gray_tiny: np.ndarray # for page-flip motion between samples
|
||||||
|
|
||||||
|
|
||||||
|
def _quad_region(image: np.ndarray, quad: np.ndarray) -> np.ndarray:
|
||||||
|
"""Bounding-box crop of the detected document within the frame."""
|
||||||
|
h, w = image.shape[:2]
|
||||||
|
x0 = max(0, int(quad[:, 0].min()))
|
||||||
|
x1 = min(w, int(np.ceil(quad[:, 0].max())))
|
||||||
|
y0 = max(0, int(quad[:, 1].min()))
|
||||||
|
y1 = min(h, int(np.ceil(quad[:, 1].max())))
|
||||||
|
if x1 - x0 >= 8 and y1 - y0 >= 8:
|
||||||
|
return image[y0:y1, x0:x1]
|
||||||
|
return image
|
||||||
|
|
||||||
|
|
||||||
|
def skin_fraction_in_quad(image: np.ndarray, quad: np.ndarray) -> float:
|
||||||
|
"""Fraction of the detected document region that looks like skin.
|
||||||
|
|
||||||
|
Measured on the quad's bounding box (downscaled), so a hand resting on
|
||||||
|
the mat next to the document doesn't count — only skin over the paper.
|
||||||
|
"""
|
||||||
|
region = _quad_region(image, quad)
|
||||||
|
rh, rw = region.shape[:2]
|
||||||
|
if rw > _SKIN_ANALYSIS_WIDTH:
|
||||||
|
region = cv2.resize(
|
||||||
|
region, (_SKIN_ANALYSIS_WIDTH, max(1, round(rh * _SKIN_ANALYSIS_WIDTH / rw)))
|
||||||
|
)
|
||||||
|
return float((skin_mask(region) > 0).mean())
|
||||||
|
|
||||||
|
|
||||||
|
def document_sharpness(image: np.ndarray, quad: np.ndarray) -> float:
|
||||||
|
"""Sharpness of the document region only.
|
||||||
|
|
||||||
|
Whole-frame Laplacian variance is dominated by high-frequency background
|
||||||
|
(wood grain), so a blurry mid-flip frame that exposes more table can
|
||||||
|
outscore a crisp flat page. Scoring inside the detection quad measures
|
||||||
|
the thing we actually keep.
|
||||||
|
"""
|
||||||
|
return sharpness_score(_quad_region(image, quad))
|
||||||
|
|
||||||
|
|
||||||
|
def score_sample(frame: Frame, det: DocumentDetection) -> ScoredSample:
|
||||||
|
"""Extract clustering metrics and drop the full-res pixels."""
|
||||||
|
small = cv2.resize(frame.image, _MOTION_SIZE)
|
||||||
|
return ScoredSample(
|
||||||
|
index=frame.index,
|
||||||
|
t=frame.t,
|
||||||
|
shape=frame.image.shape,
|
||||||
|
det=det,
|
||||||
|
sharp=document_sharpness(frame.image, det.quad),
|
||||||
|
skin=skin_fraction_in_quad(frame.image, det.quad),
|
||||||
|
gray_tiny=prepare_gray(small, blur_ksize=5),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _skin_bucket(fraction: float) -> int:
|
||||||
|
if fraction < _SKIN_CLEAN:
|
||||||
|
return 0
|
||||||
|
if fraction < _SKIN_TOUCHED:
|
||||||
|
return 1
|
||||||
|
return 2
|
||||||
|
|
||||||
|
|
||||||
def _same_document(
|
def _same_document(
|
||||||
@@ -65,60 +179,148 @@ def _plausible_capture(det: DocumentDetection) -> bool:
|
|||||||
return ar <= 0.40 or ar >= 0.58
|
return ar <= 0.40 or ar >= 0.58
|
||||||
|
|
||||||
|
|
||||||
|
def _split_on_page_flips(
|
||||||
|
samples: list[ScoredSample], min_samples: int
|
||||||
|
) -> list[list[ScoredSample]]:
|
||||||
|
"""Pull quiet dwells out of a long continuous contour (page flips).
|
||||||
|
|
||||||
|
Returns one segment per flat-page dwell when two or more qualify;
|
||||||
|
otherwise returns the original samples unchanged so ordinary
|
||||||
|
single-page placements aren't shredded by brief hand motion.
|
||||||
|
"""
|
||||||
|
if len(samples) < 4:
|
||||||
|
return [samples]
|
||||||
|
duration = samples[-1].t - samples[0].t
|
||||||
|
if duration < _LONG_EVENT_S:
|
||||||
|
return [samples]
|
||||||
|
|
||||||
|
motions = [0.0]
|
||||||
|
for i in range(1, len(samples)):
|
||||||
|
motions.append(
|
||||||
|
motion_score(samples[i - 1].gray_tiny, samples[i].gray_tiny, pixel_threshold=12)
|
||||||
|
)
|
||||||
|
|
||||||
|
dwells: list[list[ScoredSample]] = []
|
||||||
|
start: int | None = None
|
||||||
|
for i, sample in enumerate(samples):
|
||||||
|
quiet = motions[i] <= _QUIET_MOTION
|
||||||
|
if quiet and start is None:
|
||||||
|
start = i
|
||||||
|
elif not quiet and start is not None:
|
||||||
|
dwell = samples[start:i]
|
||||||
|
if dwell[-1].t - dwell[0].t >= _MIN_DWELL_S and len(dwell) >= min_samples:
|
||||||
|
dwells.append(dwell)
|
||||||
|
start = None
|
||||||
|
if start is not None:
|
||||||
|
dwell = samples[start:]
|
||||||
|
if dwell[-1].t - dwell[0].t >= _MIN_DWELL_S and len(dwell) >= min_samples:
|
||||||
|
dwells.append(dwell)
|
||||||
|
|
||||||
|
# Only commit to multi-page split when we actually found 2+ quiet dwells.
|
||||||
|
return dwells if len(dwells) >= 2 else [samples]
|
||||||
|
|
||||||
|
|
||||||
|
class _Cluster:
|
||||||
|
"""Accumulates samples for one placement; picks the best frame on flush.
|
||||||
|
|
||||||
|
Preference among frames ≥ 40% as sharp as the cluster's sharpest sample:
|
||||||
|
lowest skin bucket, then highest sharpness. Soft clean frames never
|
||||||
|
outrank sharp lightly-touched ones.
|
||||||
|
|
||||||
|
First/last samples of long events are ignored — they are usually the
|
||||||
|
hand placing or lifting the page (high Laplacian streaks, low ink),
|
||||||
|
which is how page 7 of a statement once lost to its own lift frame.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, sample: ScoredSample) -> None:
|
||||||
|
self.t_start = sample.t
|
||||||
|
self.t_end = sample.t
|
||||||
|
self.samples: list[ScoredSample] = [sample]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def count(self) -> int:
|
||||||
|
return len(self.samples)
|
||||||
|
|
||||||
|
def add(self, sample: ScoredSample) -> None:
|
||||||
|
self.t_end = sample.t
|
||||||
|
self.samples.append(sample)
|
||||||
|
|
||||||
|
def pick_best(self, samples: list[ScoredSample] | None = None) -> ScoredSample:
|
||||||
|
samples = samples if samples is not None else self.samples
|
||||||
|
pick_from = samples
|
||||||
|
if len(pick_from) >= 5:
|
||||||
|
# Drop the placing/lifting edges.
|
||||||
|
pick_from = pick_from[1:-1]
|
||||||
|
max_sharp = max(s.sharp for s in pick_from)
|
||||||
|
floor = max_sharp * _SHARP_FLOOR
|
||||||
|
eligible = [s for s in pick_from if s.sharp >= floor] or pick_from
|
||||||
|
return min(eligible, key=lambda s: (_skin_bucket(s.skin), -s.sharp))
|
||||||
|
|
||||||
|
|
||||||
def find_document_events(
|
def find_document_events(
|
||||||
samples: list[tuple[Frame, DocumentDetection]],
|
samples: list[tuple[Frame, DocumentDetection]] | list[ScoredSample],
|
||||||
gap_s: float = 2.0,
|
gap_s: float = 2.0,
|
||||||
min_samples: int = 2,
|
min_samples: int = 2,
|
||||||
) -> list[DocumentEvent]:
|
) -> list[DocumentEvent]:
|
||||||
"""Cluster consecutive detections; split when gap_s passes with no detection.
|
"""Cluster consecutive detections; split when gap_s passes with no detection.
|
||||||
|
|
||||||
samples must be in time order. Each sample is a (frame, detection) pair
|
Long clusters (page kept under the camera while flipped) are further
|
||||||
from a frame where detect_document() succeeded.
|
split on motion peaks into separate dwells so each page gets its own
|
||||||
|
capture. samples must be in time order.
|
||||||
|
|
||||||
|
Accepts either (Frame, detection) tuples (scores immediately, drops BGR)
|
||||||
|
or pre-built ScoredSample list from the streaming pipeline.
|
||||||
"""
|
"""
|
||||||
if not samples:
|
if not samples:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
clusters: list[tuple[float, float, int, float, Frame, DocumentDetection]] = []
|
scored: list[ScoredSample]
|
||||||
active: tuple[float, float, int, float, Frame, DocumentDetection] | None = None
|
if isinstance(samples[0], ScoredSample):
|
||||||
|
scored = list(samples) # type: ignore[arg-type]
|
||||||
|
else:
|
||||||
|
scored = [score_sample(frame, det) for frame, det in samples] # type: ignore[misc]
|
||||||
|
|
||||||
|
clusters: list[_Cluster] = []
|
||||||
|
active: _Cluster | None = None
|
||||||
|
|
||||||
def flush() -> None:
|
def flush() -> None:
|
||||||
nonlocal active
|
nonlocal active
|
||||||
if active is None:
|
if active is not None and active.count >= min_samples:
|
||||||
return
|
|
||||||
t_start, t_end, count, sharp, frame, det = active
|
|
||||||
if count >= min_samples:
|
|
||||||
clusters.append(active)
|
clusters.append(active)
|
||||||
active = None
|
active = None
|
||||||
|
|
||||||
for frame, det in samples:
|
for sample in scored:
|
||||||
if active is None:
|
if active is None:
|
||||||
active = (frame.t, frame.t, 1, sharpness_score(frame.image), frame, det)
|
active = _Cluster(sample)
|
||||||
continue
|
continue
|
||||||
|
if sample.t - active.t_end > gap_s or not _same_document(
|
||||||
t_start, t_end, count, best_sharp, best_frame, best_det = active
|
active.samples[-1].det, sample.det, sample.shape
|
||||||
if frame.t - t_end > gap_s or not _same_document(best_det, det, frame.image.shape):
|
):
|
||||||
flush()
|
flush()
|
||||||
active = (frame.t, frame.t, 1, sharpness_score(frame.image), frame, det)
|
active = _Cluster(sample)
|
||||||
continue
|
continue
|
||||||
|
active.add(sample)
|
||||||
sharp = sharpness_score(frame.image)
|
|
||||||
if sharp > best_sharp:
|
|
||||||
best_sharp, best_frame, best_det = sharp, frame, det
|
|
||||||
active = (t_start, frame.t, count + 1, best_sharp, best_frame, best_det)
|
|
||||||
|
|
||||||
flush()
|
flush()
|
||||||
|
|
||||||
return [
|
events: list[DocumentEvent] = []
|
||||||
|
event_id = 0
|
||||||
|
for cluster in clusters:
|
||||||
|
for segment in _split_on_page_flips(cluster.samples, min_samples):
|
||||||
|
best = cluster.pick_best(segment)
|
||||||
|
events.append(
|
||||||
DocumentEvent(
|
DocumentEvent(
|
||||||
event_id=i,
|
event_id=event_id,
|
||||||
t_start=t_start,
|
t_start=segment[0].t,
|
||||||
t_end=t_end,
|
t_end=segment[-1].t,
|
||||||
sample_count=count,
|
sample_count=len(segment),
|
||||||
best_frame_t=frame.t,
|
best_frame_t=best.t,
|
||||||
best_frame_index=frame.index,
|
best_frame_index=best.index,
|
||||||
sharpness=sharp,
|
sharpness=best.sharp,
|
||||||
frame=frame,
|
skin_fraction=round(best.skin, 4),
|
||||||
detection=det,
|
detection=best.det,
|
||||||
|
frame=None,
|
||||||
)
|
)
|
||||||
for i, (t_start, t_end, count, sharp, frame, det) in enumerate(clusters)
|
)
|
||||||
]
|
event_id += 1
|
||||||
|
return events
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
from paperpod.capture.video import Frame
|
|
||||||
from paperpod.vision.capture_events import find_document_events
|
|
||||||
from paperpod.vision.document import DocumentDetection
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
|
from paperpod.capture.video import Frame
|
||||||
|
from paperpod.vision.capture_events import find_document_events, skin_fraction_in_quad
|
||||||
|
from paperpod.vision.document import DocumentDetection
|
||||||
|
|
||||||
|
_SKIN_BGR = (80, 120, 180) # inside the YCrCb skin range used by refine
|
||||||
|
|
||||||
|
|
||||||
def _frame(t: float, idx: int = 0) -> Frame:
|
def _frame(t: float, idx: int = 0) -> Frame:
|
||||||
return Frame(index=idx, t=t, image=np.zeros((100, 100, 3), dtype=np.uint8))
|
return Frame(index=idx, t=t, image=np.zeros((100, 100, 3), dtype=np.uint8))
|
||||||
@@ -66,3 +69,113 @@ def test_area_change_splits_documents():
|
|||||||
assert len(events) == 2
|
assert len(events) == 2
|
||||||
assert events[0].detection.area_ratio == 0.25
|
assert events[0].detection.area_ratio == 0.25
|
||||||
assert events[1].detection.area_ratio == 0.65
|
assert events[1].detection.area_ratio == 0.65
|
||||||
|
|
||||||
|
|
||||||
|
def _paper_frame(t: float, idx: int, with_finger: bool, sharper: bool) -> Frame:
|
||||||
|
"""White page frame; optionally with a skin-colored blob and extra texture."""
|
||||||
|
rng = np.random.default_rng(idx)
|
||||||
|
image = np.full((100, 100, 3), 245, dtype=np.uint8)
|
||||||
|
if sharper:
|
||||||
|
# High-frequency noise inflates Laplacian variance (the old
|
||||||
|
# sharpness-only criterion would always pick this frame).
|
||||||
|
noise = rng.integers(0, 60, size=(100, 100, 1), dtype=np.uint8)
|
||||||
|
image = np.clip(image.astype(int) - noise, 0, 255).astype(np.uint8)
|
||||||
|
if with_finger:
|
||||||
|
image[30:70, 40:75] = _SKIN_BGR
|
||||||
|
return Frame(index=idx, t=t, image=image)
|
||||||
|
|
||||||
|
|
||||||
|
def _full_quad() -> DocumentDetection:
|
||||||
|
quad = np.array([[0, 0], [100, 0], [100, 100], [0, 100]], dtype=np.float32)
|
||||||
|
return DocumentDetection(quad=quad, area_ratio=0.3, method="quad", source="edges", confidence=0.8)
|
||||||
|
|
||||||
|
|
||||||
|
def test_skin_fraction_detects_finger():
|
||||||
|
clean = _paper_frame(1.0, 0, with_finger=False, sharper=False)
|
||||||
|
finger = _paper_frame(1.2, 1, with_finger=True, sharper=False)
|
||||||
|
quad = _full_quad().quad
|
||||||
|
assert skin_fraction_in_quad(clean.image, quad) < 0.003
|
||||||
|
assert skin_fraction_in_quad(finger.image, quad) > 0.05
|
||||||
|
|
||||||
|
|
||||||
|
def test_hands_free_frame_beats_equally_sharp_finger_frame():
|
||||||
|
# When sharpness is similar, prefer the clean frame.
|
||||||
|
samples = [
|
||||||
|
(_paper_frame(1.0, 0, with_finger=True, sharper=True), _full_quad()),
|
||||||
|
(_paper_frame(1.2, 1, with_finger=False, sharper=True), _full_quad()),
|
||||||
|
(_paper_frame(1.4, 2, with_finger=True, sharper=True), _full_quad()),
|
||||||
|
]
|
||||||
|
events = find_document_events(samples, gap_s=2.0, min_samples=2)
|
||||||
|
assert len(events) == 1
|
||||||
|
assert events[0].best_frame_index == 1
|
||||||
|
assert events[0].skin_fraction < 0.003
|
||||||
|
|
||||||
|
|
||||||
|
def test_much_sharper_touched_frame_beats_blurry_clean_frame():
|
||||||
|
# Mid-flip blur with no fingers must not beat a sharp frame that has a
|
||||||
|
# fingertip on the margin — that's how page 7 of a statement got lost.
|
||||||
|
samples = [
|
||||||
|
(_paper_frame(1.0, 0, with_finger=False, sharper=False), _full_quad()),
|
||||||
|
(_paper_frame(1.2, 1, with_finger=True, sharper=True), _full_quad()),
|
||||||
|
(_paper_frame(1.4, 2, with_finger=False, sharper=False), _full_quad()),
|
||||||
|
]
|
||||||
|
events = find_document_events(samples, gap_s=2.0, min_samples=2)
|
||||||
|
assert len(events) == 1
|
||||||
|
assert events[0].best_frame_index == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_sharpest_wins_among_equally_clean_frames():
|
||||||
|
samples = [
|
||||||
|
(_paper_frame(1.0, 0, with_finger=False, sharper=False), _full_quad()),
|
||||||
|
(_paper_frame(1.2, 1, with_finger=False, sharper=True), _full_quad()),
|
||||||
|
]
|
||||||
|
events = find_document_events(samples, gap_s=2.0, min_samples=2)
|
||||||
|
assert len(events) == 1
|
||||||
|
assert events[0].best_frame_index == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_long_event_splits_on_page_flip_motion():
|
||||||
|
"""Quiet dwells inside a long placement become separate events.
|
||||||
|
|
||||||
|
Synthetic: quiet page A, motion/hands, quiet page B. Contour is the
|
||||||
|
same throughout (page flip under a fixed outline), so without dwell
|
||||||
|
splitting only page A would be kept.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def page_frame(t: float, idx: int, pattern: int) -> Frame:
|
||||||
|
# Nearly identical consecutive frames so inter-sample motion is quiet.
|
||||||
|
img = np.full((180, 320, 3), 240, dtype=np.uint8)
|
||||||
|
img[40:140, 40:280] = 200 if pattern == 1 else 160
|
||||||
|
# Seeded by pattern only — same noise every page-A frame.
|
||||||
|
noise = np.random.default_rng(pattern).integers(0, 3, size=img.shape, dtype=np.uint8)
|
||||||
|
img = np.clip(img.astype(int) + noise, 0, 255).astype(np.uint8)
|
||||||
|
return Frame(index=idx, t=t, image=img)
|
||||||
|
|
||||||
|
samples = []
|
||||||
|
# Quiet page 1 dwell: t=0.0 .. 3.0
|
||||||
|
for i in range(0, 13):
|
||||||
|
samples.append((page_frame(i * 0.25, i, pattern=1), _full_quad()))
|
||||||
|
# Motion / hands between pages
|
||||||
|
for i, shade in enumerate((40, 80, 120, 60)):
|
||||||
|
img = np.full((180, 320, 3), shade, dtype=np.uint8)
|
||||||
|
samples.append((Frame(index=13 + i, t=3.25 + i * 0.25, image=img), _full_quad()))
|
||||||
|
# Quiet page 2 dwell: t=4.25 .. 5.25
|
||||||
|
for i in range(0, 5):
|
||||||
|
samples.append((page_frame(4.25 + i * 0.25, 20 + i, pattern=2), _full_quad()))
|
||||||
|
|
||||||
|
events = find_document_events(samples, gap_s=2.0, min_samples=2)
|
||||||
|
assert len(events) >= 2
|
||||||
|
assert events[0].t_end <= 3.5
|
||||||
|
assert events[1].t_start >= 3.5
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def test_short_event_not_resegmented():
|
||||||
|
samples = [
|
||||||
|
(_paper_frame(1.0, 0, with_finger=False, sharper=True), _full_quad()),
|
||||||
|
(_paper_frame(1.2, 1, with_finger=False, sharper=True), _full_quad()),
|
||||||
|
(_paper_frame(1.4, 2, with_finger=False, sharper=True), _full_quad()),
|
||||||
|
]
|
||||||
|
events = find_document_events(samples, gap_s=2.0, min_samples=2)
|
||||||
|
assert len(events) == 1
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,266 @@
|
|||||||
|
"""Multi-page grouping and duplicate-capture handling in run_export's phase 2."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from paperpod.pipeline import _group_documents, _PageCandidate
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeOcr:
|
||||||
|
def __init__(self, mean_confidence: float = 80.0):
|
||||||
|
self.mean_confidence = mean_confidence
|
||||||
|
|
||||||
|
|
||||||
|
def _cand(
|
||||||
|
window_id: int,
|
||||||
|
vendor: str | None = None,
|
||||||
|
date: str | None = None,
|
||||||
|
page_number: int | None = None,
|
||||||
|
page_total: int | None = None,
|
||||||
|
confidence: float = 80.0,
|
||||||
|
total: str | None = None,
|
||||||
|
doc_title: str | None = None,
|
||||||
|
t_start: float | None = None,
|
||||||
|
text: str = "",
|
||||||
|
) -> _PageCandidate:
|
||||||
|
t0 = t_start if t_start is not None else float(window_id)
|
||||||
|
ocr = _FakeOcr(confidence)
|
||||||
|
ocr.text = text
|
||||||
|
return _PageCandidate(
|
||||||
|
window={"window_id": window_id, "t_start": t0, "t_end": t0 + 0.5},
|
||||||
|
crop_path=Path(f"crops/window_{window_id:03d}.png"),
|
||||||
|
ocr=ocr,
|
||||||
|
llm=None,
|
||||||
|
vendor=vendor,
|
||||||
|
date=date,
|
||||||
|
time=None,
|
||||||
|
total=total,
|
||||||
|
form_code=None,
|
||||||
|
tax_year=None,
|
||||||
|
org_name=None,
|
||||||
|
doc_title=doc_title,
|
||||||
|
party=None,
|
||||||
|
page_number=page_number,
|
||||||
|
page_total=page_total,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_incrementing_page_markers_group_into_one_document():
|
||||||
|
docs = _group_documents(
|
||||||
|
[
|
||||||
|
_cand(0, vendor="TD Canada Trust", date="2025-01-31", page_number=1, page_total=2),
|
||||||
|
_cand(1, vendor="TD Canada Trust", page_number=2, page_total=2),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
assert len(docs) == 1
|
||||||
|
assert len(docs[0].pages) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_new_page_1_starts_a_new_document():
|
||||||
|
docs = _group_documents(
|
||||||
|
[
|
||||||
|
_cand(0, vendor="TD Canada Trust", page_number=1, page_total=2),
|
||||||
|
_cand(1, vendor="TD Canada Trust", page_number=2, page_total=2),
|
||||||
|
_cand(2, vendor="TD Canada Trust", page_number=1, page_total=2),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
assert len(docs) == 2
|
||||||
|
assert [len(d.pages) for d in docs] == [2, 1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_different_page_totals_do_not_group():
|
||||||
|
docs = _group_documents(
|
||||||
|
[
|
||||||
|
_cand(0, page_number=1, page_total=2, vendor="TD"),
|
||||||
|
_cand(1, page_number=2, page_total=17, vendor="TD"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
assert len(docs) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_documents_without_markers_stay_single_page():
|
||||||
|
docs = _group_documents(
|
||||||
|
[
|
||||||
|
_cand(0, vendor="Costco", date="2024-01-01", total="10.00"),
|
||||||
|
_cand(1, vendor="Walmart", date="2024-01-02", total="20.00"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
assert len(docs) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_duplicate_capture_keeps_higher_confidence_read():
|
||||||
|
docs = _group_documents(
|
||||||
|
[
|
||||||
|
_cand(0, vendor="TD Canada Trust", page_number=1, page_total=2, confidence=60.0),
|
||||||
|
_cand(1, vendor="TD Canada Trust", page_number=1, page_total=2, confidence=90.0),
|
||||||
|
_cand(2, vendor="TD Canada Trust", page_number=2, page_total=2),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
assert len(docs) == 1
|
||||||
|
assert len(docs[0].pages) == 2
|
||||||
|
assert docs[0].pages[0].window["window_id"] == 1 # better read replaced the first
|
||||||
|
|
||||||
|
|
||||||
|
def test_duplicate_signature_without_markers_is_dropped():
|
||||||
|
docs = _group_documents(
|
||||||
|
[
|
||||||
|
_cand(0, vendor="Costco", date="2024-01-01", total="10.00"),
|
||||||
|
_cand(1, vendor="Costco", date="2024-01-01", total="10.00"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
assert len(docs) == 1
|
||||||
|
assert len(docs[0].pages) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_duplicate_signature_far_apart_is_not_dropped():
|
||||||
|
# Marker-less pages of one statement share vendor/date; a deliberate
|
||||||
|
# page flip (many seconds) means it's a different page, not a dup.
|
||||||
|
docs = _group_documents(
|
||||||
|
[
|
||||||
|
_cand(0, vendor="TD Canada Trust", date="2024-12-31", t_start=10.0),
|
||||||
|
_cand(1, vendor="TD Canada Trust", date="2024-12-31", t_start=25.0),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
assert len(docs) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_unreadable_marker_gap_does_not_split_document():
|
||||||
|
# Page 12's marker OCR'd fine, page 13's didn't get captured, page 14
|
||||||
|
# still belongs to the same statement.
|
||||||
|
docs = _group_documents(
|
||||||
|
[
|
||||||
|
_cand(0, vendor="TD", page_number=12, page_total=17),
|
||||||
|
_cand(1, vendor="TD", page_number=14, page_total=17),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
assert len(docs) == 1
|
||||||
|
assert len(docs[0].pages) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_markerless_page_inside_marker_chain_does_not_split():
|
||||||
|
# Page 10's marker was unreadable but pages 9 and 11 anchor it: one
|
||||||
|
# statement, three pages, in order.
|
||||||
|
docs = _group_documents(
|
||||||
|
[
|
||||||
|
_cand(0, vendor="TD Canada Trust", page_number=9, page_total=17, t_start=0.0),
|
||||||
|
_cand(1, vendor="TD Canada Trust", t_start=10.0),
|
||||||
|
_cand(2, vendor="TD Canada Trust", page_number=11, page_total=17, t_start=20.0),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
assert len(docs) == 1
|
||||||
|
assert [p.window["window_id"] for p in docs[0].pages] == [0, 1, 2]
|
||||||
|
|
||||||
|
|
||||||
|
def test_markerless_page_attaches_to_incomplete_document():
|
||||||
|
# "PAGE 1 OF 2" followed (after a deliberate page flip) by a
|
||||||
|
# same-letterhead capture whose marker was unreadable: it's page 2,
|
||||||
|
# not a new document — as long as the amounts don't match page 1.
|
||||||
|
docs = _group_documents(
|
||||||
|
[
|
||||||
|
_cand(0, vendor="TD Canada Trust", page_number=1, page_total=2,
|
||||||
|
doc_title="transaction_history", t_start=0.0,
|
||||||
|
text="PAGE 1 OF 2 70.22 750.00 3000.00"),
|
||||||
|
_cand(1, vendor="TD Canada Trust", doc_title="transaction_history",
|
||||||
|
t_start=10.0,
|
||||||
|
text="PAGE 2 OF 2 15.00 22.50 100.00 200.00"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
assert len(docs) == 1
|
||||||
|
assert len(docs[0].pages) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_markerless_recapture_of_same_page_is_dropped():
|
||||||
|
# Garbled second capture of PAGE 1 shares the amount fingerprint with
|
||||||
|
# the real page 1 — must not become a fake "page 2".
|
||||||
|
docs = _group_documents(
|
||||||
|
[
|
||||||
|
_cand(0, vendor="TD Canada Trust", page_number=1, page_total=2,
|
||||||
|
doc_title="transaction_history", t_start=0.0, confidence=90.0,
|
||||||
|
text="PAGE 1 OF 2 70.22 DR 750.00 DR 3000.00 DR 490.96 1076 6254276"),
|
||||||
|
_cand(1, vendor="TD Canada Trust", doc_title="transaction_history",
|
||||||
|
t_start=10.0, confidence=55.0,
|
||||||
|
text="PAGK 1 of 10,22 150,00 3000 490 1076 6254276"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
assert len(docs) == 1
|
||||||
|
assert len(docs[0].pages) == 1
|
||||||
|
assert docs[0].pages[0].window["window_id"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_successive_statement_pages_not_collapsed_by_shared_account_numbers():
|
||||||
|
# Pages of one statement share branch/account numbers (~0.3 overlap) —
|
||||||
|
# that must not replace an earlier marked page with a later one.
|
||||||
|
docs = _group_documents(
|
||||||
|
[
|
||||||
|
_cand(0, vendor="TD Canada Trust", page_number=2, page_total=17,
|
||||||
|
doc_title="transaction_history", t_start=0.0, confidence=92.0,
|
||||||
|
text="PAGE 2 OF 17 1076 6254276 500.00 600.00 700.00"),
|
||||||
|
_cand(1, vendor="TD Canada Trust", doc_title="transaction_history",
|
||||||
|
t_start=10.0, confidence=91.0,
|
||||||
|
text="PAGE 4 OF 17 1076 6254276 800.00 900.00 1000.00"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
# Second has no readable marker in this unit test (page_number=None):
|
||||||
|
# attach as incomplete-fill; must NOT replace page 2.
|
||||||
|
assert len(docs) == 1
|
||||||
|
assert len(docs[0].pages) == 2
|
||||||
|
assert docs[0].pages[0].window["window_id"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_markerless_low_confidence_does_not_fill_incomplete_doc():
|
||||||
|
# Conf 63 mid-flip noise must not slot in as "page 2" just because the
|
||||||
|
# statement is still incomplete.
|
||||||
|
docs = _group_documents(
|
||||||
|
[
|
||||||
|
_cand(0, vendor="TD Canada Trust", page_number=1, page_total=2,
|
||||||
|
doc_title="transaction_history", t_start=0.0, confidence=91.0,
|
||||||
|
text="PAGE 1 OF 2 70.22 750.00"),
|
||||||
|
_cand(1, vendor="TD Canada Trust", doc_title="transaction_history",
|
||||||
|
t_start=10.0, confidence=63.0,
|
||||||
|
text="garble noise 11 22 33 44 55 66 77"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
assert len(docs) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_markerless_page_does_not_attach_to_complete_document():
|
||||||
|
# After "PAGE 2 OF 2" the statement is done; the next capture is new.
|
||||||
|
docs = _group_documents(
|
||||||
|
[
|
||||||
|
_cand(0, vendor="TD Canada Trust", page_number=2, page_total=2,
|
||||||
|
doc_title="transaction_history", t_start=0.0),
|
||||||
|
_cand(1, vendor="TD Canada Trust", doc_title="transaction_history",
|
||||||
|
t_start=10.0),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
assert len(docs) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_markerless_recapture_moments_later_is_a_duplicate():
|
||||||
|
# Identical signature 0.5s later is a contour blip, not a new page.
|
||||||
|
docs = _group_documents(
|
||||||
|
[
|
||||||
|
_cand(0, vendor="Costco", date="2024-01-01", total="10.00", t_start=0.0),
|
||||||
|
_cand(1, vendor="Costco", date="2024-01-01", total="10.00", t_start=1.0),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
assert len(docs) == 1
|
||||||
|
assert len(docs[0].pages) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_marked_page_not_replaced_by_nearby_same_vendor_date():
|
||||||
|
# Statement pages share vendor+date and flip in ~3s — must not treat the
|
||||||
|
# next page as a signature duplicate of the previous.
|
||||||
|
docs = _group_documents(
|
||||||
|
[
|
||||||
|
_cand(0, vendor="TD Canada Trust", date="2024-12-31",
|
||||||
|
page_number=3, page_total=17, doc_title="transaction_history",
|
||||||
|
t_start=40.0, confidence=88.0,
|
||||||
|
text="PAGE 3 OF 17 1076 6254276 100.00 200.00"),
|
||||||
|
_cand(1, vendor="TD Canada Trust", date="2024-12-31",
|
||||||
|
doc_title="transaction_history", t_start=49.0, confidence=91.0,
|
||||||
|
text="PAGE 4 OF 17 1076 6254276 300.00 400.00 500.00"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
assert len(docs) == 1
|
||||||
|
assert len(docs[0].pages) == 2
|
||||||
|
assert docs[0].pages[0].page_number == 3
|
||||||
+27
-6
@@ -28,24 +28,45 @@ def test_build_name_full_metadata():
|
|||||||
def test_build_name_date_only():
|
def test_build_name_date_only():
|
||||||
dt = datetime(2026, 1, 1, 12, 0, 0)
|
dt = datetime(2026, 1, 1, 12, 0, 0)
|
||||||
result = build_name("2023-03-14", None, dt)
|
result = build_name("2023-03-14", None, dt)
|
||||||
assert result.stem == "2023-03-14_UNSORTED"
|
assert result.stem == "2023-03-14_document"
|
||||||
assert result.source == "ocr"
|
assert result.source == "ocr"
|
||||||
|
|
||||||
|
|
||||||
def test_build_name_vendor_only():
|
def test_build_name_vendor_only_uses_scan_date():
|
||||||
dt = datetime(2026, 1, 1, 12, 0, 0)
|
dt = datetime(2026, 1, 1, 12, 0, 0)
|
||||||
result = build_name(None, "METRO", dt)
|
result = build_name(None, "METRO", dt)
|
||||||
assert result.stem == "UNSORTED_metro_20260101_120000"
|
assert result.stem == "2026-01-01_metro"
|
||||||
assert result.source == "ocr"
|
assert result.source == "scan_date"
|
||||||
|
|
||||||
|
|
||||||
def test_build_name_nothing_found():
|
def test_build_name_nothing_found():
|
||||||
dt = datetime(2026, 1, 1, 12, 0, 0)
|
dt = datetime(2026, 1, 1, 12, 0, 0)
|
||||||
result = build_name(None, None, dt)
|
result = build_name(None, None, dt)
|
||||||
assert result.stem == "UNSORTED_20260101_120000"
|
assert result.stem == "2026-01-01_scan"
|
||||||
assert result.source == "none"
|
assert result.source == "none"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_name_appends_doc_title():
|
||||||
|
dt = datetime(2026, 1, 1, 12, 0, 0)
|
||||||
|
result = build_name("2025-01-31", "TD Canada Trust", dt, doc_title="transaction_history")
|
||||||
|
assert result.stem == "2025-01-31_td_canada_trust_transaction_history"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_name_appends_party_shortname():
|
||||||
|
dt = datetime(2026, 1, 1, 12, 0, 0)
|
||||||
|
result = build_name(
|
||||||
|
"2025-01-31", "TD Canada Trust", dt,
|
||||||
|
doc_title="transaction_history", party="LEVIT I",
|
||||||
|
)
|
||||||
|
assert result.stem == "2025-01-31_td_canada_trust_transaction_history_levit_i"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_name_doc_title_not_duplicated_when_vendor_says_same():
|
||||||
|
dt = datetime(2026, 1, 1, 12, 0, 0)
|
||||||
|
result = build_name("2025-01-31", "Transaction History", dt, doc_title="transaction_history")
|
||||||
|
assert result.stem == "2025-01-31_transaction_history"
|
||||||
|
|
||||||
|
|
||||||
def test_build_name_with_time_disambiguates_same_day_visits():
|
def test_build_name_with_time_disambiguates_same_day_visits():
|
||||||
dt = datetime(2026, 1, 1, 12, 0, 0)
|
dt = datetime(2026, 1, 1, 12, 0, 0)
|
||||||
morning = build_name("2023-03-20", "WALMART", dt, time="09:05")
|
morning = build_name("2023-03-20", "WALMART", dt, time="09:05")
|
||||||
@@ -64,7 +85,7 @@ def test_build_name_invalid_time_ignored():
|
|||||||
def test_build_name_time_without_date_ignored():
|
def test_build_name_time_without_date_ignored():
|
||||||
dt = datetime(2026, 1, 1, 12, 0, 0)
|
dt = datetime(2026, 1, 1, 12, 0, 0)
|
||||||
result = build_name(None, "METRO", dt, time="09:05")
|
result = build_name(None, "METRO", dt, time="09:05")
|
||||||
assert result.stem == "UNSORTED_metro_20260101_120000"
|
assert result.stem == "2026-01-01_metro"
|
||||||
|
|
||||||
|
|
||||||
def test_build_name_tax_form_with_year_and_org():
|
def test_build_name_tax_form_with_year_and_org():
|
||||||
|
|||||||
Reference in New Issue
Block a user