Add OCR naming and PNG-to-PDF export (modules 5-7, single-page only)

- ocr/: Tesseract-based vendor/date/total extraction. PSM 6 (single
  uniform text block) instead of the default fixes receipts where
  item-name/price columns were otherwise split into separate blocks and
  dropped. Vendor line picked by median (not max) word height within the
  top fraction of the crop, breaking ties by topmost line - max() was
  fooled by single descender/ascender glyphs (commas, parens) inflating
  one word's bounding box.
- naming/: merge OCR metadata into YYYY-MM-DD_vendor.pdf filenames,
  degrading gracefully to UNSORTED_<timestamp>.pdf; per-run dedup.
- pdf/: img2pdf-based multi-page-capable assembly (images_to_pdf).
- pipeline.run_export(): OCR + name + PDF each detected crop from a
  detect() report, writes pdf/ and export_summary.csv.
- New `paperpod export <video>` CLI command.
- Verified against synthetic Home Depot/Metro/Petro-Canada receipts:
  3/3 correct vendor, date, and total after tuning.

Known limitation (documented in README): no page-flip/multi-page
grouping yet (that's events/, still unbuilt) - every detected document
becomes its own single-page PDF.
This commit is contained in:
2026-07-07 18:04:06 -04:00
parent 8f5be59b7e
commit 2c28a623fb
14 changed files with 723 additions and 12 deletions
+37 -1
View File
@@ -3,6 +3,7 @@
Usage:
python -m paperpod probe <video>
python -m paperpod detect <video> [--out DIR] [--config config.yaml]
python -m paperpod export <video> [--out DIR] [--config config.yaml]
python -m paperpod extract-audio <video> [--out FILE] [--config config.yaml]
"""
@@ -15,7 +16,7 @@ from pathlib import Path
from paperpod.capture.audio import extract_audio
from paperpod.capture.video import probe_video
from paperpod.config import load_config
from paperpod.pipeline import run_detection
from paperpod.pipeline import run_detection, run_export
def _add_config_arg(parser: argparse.ArgumentParser) -> None:
@@ -73,6 +74,33 @@ def cmd_detect(args: argparse.Namespace) -> int:
return 0
def cmd_export(args: argparse.Namespace) -> int:
cfg = _resolve_config(args.config)
out_dir = Path(args.out or Path(cfg.output.dir) / Path(args.video).stem)
print(f"Analyzing {args.video} -> {out_dir}")
report = run_detection(args.video, cfg, out_dir)
found = [w for w in report["stable_windows"] if w["document_found"]]
print(f"Running OCR + naming on {len(found)} detected document(s)...")
result = run_export(report, cfg, out_dir)
print(f"\n{'pod':>3} {'date':>10} vendor / final filename")
print("-" * 60)
for row in result["rows"]:
vendor = row["ocr_vendor"] or "(no vendor found)"
print(f"{row['pod_id']:>3} {row['ocr_date'] or '-':>10} {vendor}")
print(f" -> {row['pdf_path']} [source: {row['source_of_name']}]")
print(f"\nPDFs: {result['pdf_dir']}")
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
def cmd_extract_audio(args: argparse.Namespace) -> int:
cfg = _resolve_config(args.config)
out = Path(args.out or Path(cfg.output.dir) / (Path(args.video).stem + ".wav"))
@@ -100,6 +128,14 @@ def main(argv: list[str] | None = None) -> int:
_add_config_arg(p_detect)
p_detect.set_defaults(func=cmd_detect)
p_export = sub.add_parser(
"export", help="Detect, OCR-name, and export each document as a PDF"
)
p_export.add_argument("video")
p_export.add_argument("--out", default=None, help="Output directory")
_add_config_arg(p_export)
p_export.set_defaults(func=cmd_export)
p_audio = sub.add_parser("extract-audio", help="Extract audio track as WAV")
p_audio.add_argument("video")
p_audio.add_argument("--out", default=None, help="Output WAV path")