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).
158 lines
5.4 KiB
Python
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())
|