Files
PaperPod/paperpod/cli.py
T
ilia 12e5d1aeb1 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).
2026-07-26 15:37:28 -04:00

158 lines
5.4 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} "
f"{'conf':>5} {'rot':>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('detection_confidence', 0):>5.2f} "
f"{w.get('rotation_applied', 0):>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} {'pages':>5} {'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['page_count']:>5} "
f"{row['ocr_date'] or '-':>10} {vendor}"
)
print(f" -> {row['pdf_path']} [source: {row['source_of_name']}]")
if row.get("llm_description"):
issues = f" issues: {row['llm_issues']}" if row.get("llm_issues") else ""
print(f" {row['llm_description']}{issues}")
print(f"\nPDFs: {result['pdf_dir']}")
print(f"Summary: {out_dir / 'export_summary.csv'}")
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())