- 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.
151 lines
5.1 KiB
Python
151 lines
5.1 KiB
Python
"""PaperPod command-line interface.
|
|
|
|
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]
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import sys
|
|
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, run_export
|
|
|
|
|
|
def _add_config_arg(parser: argparse.ArgumentParser) -> None:
|
|
parser.add_argument(
|
|
"--config",
|
|
default=None,
|
|
help="Path to config.yaml (defaults to ./config.yaml if present)",
|
|
)
|
|
|
|
|
|
def _resolve_config(arg: str | None):
|
|
if arg is not None:
|
|
return load_config(arg)
|
|
default = Path("config.yaml")
|
|
return load_config(default if default.exists() else None)
|
|
|
|
|
|
def cmd_probe(args: argparse.Namespace) -> int:
|
|
meta = probe_video(args.video)
|
|
print(f"path: {meta.path}")
|
|
print(f"fps: {meta.fps:.3f}")
|
|
print(f"frames: {meta.frame_count}")
|
|
print(f"duration: {meta.duration_s:.2f}s")
|
|
print(f"size: {meta.width}x{meta.height}")
|
|
return 0
|
|
|
|
|
|
def cmd_detect(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)
|
|
|
|
windows = report["stable_windows"]
|
|
found = [w for w in windows if w["document_found"]]
|
|
print(f"\nVideo: {report['video_meta']['duration_s']}s, "
|
|
f"{len(report['motion_events'])} motion events, "
|
|
f"{len(windows)} stable windows, "
|
|
f"{len(found)} documents detected\n")
|
|
header = f"{'win':>3} {'start':>7} {'end':>7} {'sharp':>8} {'doc':>4} crop"
|
|
print(header)
|
|
print("-" * len(header))
|
|
for w in windows:
|
|
print(
|
|
f"{w['window_id']:>3} "
|
|
f"{w['t_start']:>7.2f} "
|
|
f"{w['t_end']:>7.2f} "
|
|
f"{w.get('sharpness', 0):>8.1f} "
|
|
f"{'yes' if w['document_found'] else 'no':>4} "
|
|
f"{w.get('crop_path', '-')}"
|
|
)
|
|
print(f"\nFull report: {out_dir / 'report.json'}")
|
|
print(f"Motion scores (for threshold tuning): {out_dir / 'motion_scores.csv'}")
|
|
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"))
|
|
wav = extract_audio(args.video, out, sample_rate=cfg.audio.sample_rate)
|
|
print(f"Audio written to {wav}")
|
|
return 0
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
parser = argparse.ArgumentParser(
|
|
prog="paperpod",
|
|
description="Convert an overhead video of documents into cropped, named PDFs.",
|
|
)
|
|
sub = parser.add_subparsers(dest="command", required=True)
|
|
|
|
p_probe = sub.add_parser("probe", help="Print video metadata")
|
|
p_probe.add_argument("video")
|
|
p_probe.set_defaults(func=cmd_probe)
|
|
|
|
p_detect = sub.add_parser(
|
|
"detect", help="Detect stable windows and document candidates"
|
|
)
|
|
p_detect.add_argument("video")
|
|
p_detect.add_argument("--out", default=None, help="Output directory")
|
|
_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")
|
|
_add_config_arg(p_audio)
|
|
p_audio.set_defaults(func=cmd_extract_audio)
|
|
|
|
args = parser.parse_args(argv)
|
|
return args.func(args)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|