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:
@@ -24,11 +24,18 @@ offline on your machine.
|
||||
| `vision/` | Motion detection, document contours, perspective crop, sharpness | Built |
|
||||
| `events/` | State machine: placed / page-flipped / cleared, pod grouping | Planned |
|
||||
| `transcribe/` | Local speech-to-text (faster-whisper) | Planned |
|
||||
| `ocr/` | OCR fallback naming (Tesseract) | Planned |
|
||||
| `naming/` | Final filename assembly + summary CSV | Planned |
|
||||
| `pdf/` | PDF assembly + Paperless-ngx consume staging | Planned |
|
||||
| `ocr/` | OCR fallback naming (Tesseract): vendor, date, total | Built |
|
||||
| `naming/` | Final filename assembly + summary CSV | Built |
|
||||
| `pdf/` | PDF assembly (img2pdf) | Built — single-page only, no consume-folder staging |
|
||||
| `review_cli/` | Pre-export review (rename/merge/split) | Planned |
|
||||
|
||||
**Known limitation:** there is no page-flip / multi-page grouping yet
|
||||
(that's `events/`). Every detected document currently becomes its own
|
||||
single-page PDF, even if it was physically one page of a stack or one side
|
||||
of a double-sided document. Don't point `export` at the Paperless-ngx
|
||||
consume folder for multi-page documents until `events/` exists — you'll get
|
||||
one PDF per page instead of one PDF per document.
|
||||
|
||||
## Setup
|
||||
|
||||
Requires Python 3.11+ and ffmpeg (`brew install ffmpeg`).
|
||||
@@ -51,6 +58,9 @@ python -m paperpod probe sample_data/videos/synthetic_sample.mp4
|
||||
# Detect stable windows + document candidates
|
||||
python -m paperpod detect sample_data/videos/synthetic_sample.mp4
|
||||
|
||||
# Detect, OCR-name, and export each document as its own PDF
|
||||
python -m paperpod export sample_data/videos/receipts_sample.mp4
|
||||
|
||||
# Extract the audio track (16 kHz mono WAV, whisper-ready)
|
||||
python -m paperpod extract-audio my_recording.mp4
|
||||
```
|
||||
@@ -62,6 +72,17 @@ python -m paperpod extract-audio my_recording.mp4
|
||||
- `report.json` — video metadata, motion events, stable windows, detections
|
||||
- `motion_scores.csv` — per-sample motion scores, for tuning `motion.threshold`
|
||||
|
||||
`export` runs `detect` and then, for every detected document, additionally writes:
|
||||
|
||||
- `pdf/<name>.pdf` — one single-page PDF per detected document, named
|
||||
`YYYY-MM-DD_vendor.pdf` from OCR-extracted date/vendor, or
|
||||
`UNSORTED_<timestamp>.pdf` if OCR couldn't find anything usable
|
||||
- `export_summary.csv` — timestamp, pod_id, page_count, source_of_name
|
||||
(`ocr`/`none`), OCR vendor/date/total/confidence, final filename
|
||||
|
||||
Nothing is copied into a Paperless-ngx consume directory yet — review the
|
||||
`pdf/` folder yourself before moving files anywhere.
|
||||
|
||||
## Tuning
|
||||
|
||||
All thresholds live in `config.yaml` (motion sensitivity, stable window
|
||||
|
||||
+37
-1
@@ -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")
|
||||
|
||||
@@ -42,6 +42,34 @@ class SpeechConfig:
|
||||
window_after_s: float = 5.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class OcrConfig:
|
||||
# Tesseract language pack (e.g. "eng", "eng+fra").
|
||||
lang: str = "eng"
|
||||
# Page segmentation mode. 6 = "assume a single uniform block of text",
|
||||
# which (unlike Tesseract's default of 3) keeps each receipt line's
|
||||
# item-name and price together instead of splitting them into separate
|
||||
# column blocks and dropping one. Verified against synthetic Home Depot/
|
||||
# Metro/Petro-Canada receipts; revisit if real receipts have a layout
|
||||
# PSM 6 handles worse (e.g. multi-column letters).
|
||||
psm: int = 6
|
||||
# Vendor name is assumed to live in this top fraction of the crop
|
||||
# (receipt headers / letterheads are typically near the top).
|
||||
vendor_top_fraction: float = 0.4
|
||||
# Words below this Tesseract confidence (0-100) are ignored when
|
||||
# picking the vendor line. Kept low deliberately: header text (larger,
|
||||
# sometimes bold/stylized fonts) can score much lower confidence than
|
||||
# crisp monospace body text despite being read correctly, and the
|
||||
# line-height + topmost heuristic already screens out low-confidence
|
||||
# noise (garbled OCR hallucinations measure much shorter than real text).
|
||||
min_word_confidence: float = 10.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class NamingConfig:
|
||||
max_vendor_len: int = 40
|
||||
|
||||
|
||||
@dataclass
|
||||
class OutputConfig:
|
||||
dir: str = "./output"
|
||||
@@ -55,6 +83,8 @@ class Config:
|
||||
motion: MotionConfig = field(default_factory=MotionConfig)
|
||||
document: DocumentConfig = field(default_factory=DocumentConfig)
|
||||
speech: SpeechConfig = field(default_factory=SpeechConfig)
|
||||
ocr: OcrConfig = field(default_factory=OcrConfig)
|
||||
naming: NamingConfig = field(default_factory=NamingConfig)
|
||||
output: OutputConfig = field(default_factory=OutputConfig)
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
"""Module 6 (not built yet): merge speech/OCR metadata into final filenames.
|
||||
"""Module 6: merge OCR (and later speech) metadata into final filenames.
|
||||
|
||||
Pattern: YYYY-MM-DD_source_description.pdf, falling back to
|
||||
UNSORTED_<timestamp>.pdf, plus a summary CSV per run.
|
||||
Pattern: YYYY-MM-DD_vendor.pdf, falling back to UNSORTED_<timestamp>.pdf.
|
||||
The per-run summary CSV is written by paperpod.pdf.export.
|
||||
"""
|
||||
|
||||
from paperpod.naming.filename import NamingResult, build_name, dedupe_stem, slugify
|
||||
|
||||
__all__ = ["NamingResult", "build_name", "dedupe_stem", "slugify"]
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Build the final filename/PDF title from OCR (and, later, speech) metadata.
|
||||
|
||||
Pattern: YYYY-MM-DD_vendor.pdf, falling back progressively as pieces of
|
||||
information go missing, down to UNSORTED_<timestamp>.pdf when nothing usable
|
||||
was extracted at all. Module 4 (speech) isn't built yet, so "description" in
|
||||
the original spec's pattern is currently always the OCR vendor slug; once
|
||||
speech transcription lands, a spoken description will take priority here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
def slugify(text: str, max_len: int = 40) -> str:
|
||||
text = text.lower()
|
||||
text = re.sub(r"[^a-z0-9]+", "_", text).strip("_")
|
||||
text = text[:max_len].strip("_")
|
||||
return text or "doc"
|
||||
|
||||
|
||||
@dataclass
|
||||
class NamingResult:
|
||||
stem: str # filename without extension
|
||||
source: str # "ocr" | "none" (will include "speech" once module 4 exists)
|
||||
|
||||
|
||||
def build_name(
|
||||
date: str | None,
|
||||
vendor: str | None,
|
||||
fallback_dt: datetime,
|
||||
max_vendor_len: int = 40,
|
||||
) -> NamingResult:
|
||||
"""date must already be normalized to YYYY-MM-DD, or None."""
|
||||
vendor_slug = slugify(vendor, max_vendor_len) if vendor else None
|
||||
ts = fallback_dt.strftime("%Y%m%d_%H%M%S")
|
||||
|
||||
if date and vendor_slug:
|
||||
return NamingResult(stem=f"{date}_{vendor_slug}", source="ocr")
|
||||
if date:
|
||||
return NamingResult(stem=f"{date}_UNSORTED", source="ocr")
|
||||
if vendor_slug:
|
||||
return NamingResult(stem=f"UNSORTED_{vendor_slug}_{ts}", source="ocr")
|
||||
return NamingResult(stem=f"UNSORTED_{ts}", source="none")
|
||||
|
||||
|
||||
def dedupe_stem(stem: str, used: set[str]) -> str:
|
||||
"""Append _2, _3, ... if stem was already used in this run."""
|
||||
if stem not in used:
|
||||
used.add(stem)
|
||||
return stem
|
||||
i = 2
|
||||
while f"{stem}_{i}" in used:
|
||||
i += 1
|
||||
deduped = f"{stem}_{i}"
|
||||
used.add(deduped)
|
||||
return deduped
|
||||
@@ -1,5 +1,10 @@
|
||||
"""Module 5 (not built yet): OCR fallback via Tesseract (pytesseract).
|
||||
"""Module 5: OCR fallback via Tesseract (pytesseract).
|
||||
|
||||
Will extract vendor name, date, and total from crops that have no matching
|
||||
speech segment.
|
||||
Extracts vendor name, date, and total from a document crop. Currently used
|
||||
for every crop (module 4's speech transcription isn't built yet, so there is
|
||||
no "speech vs. needs-OCR-fallback" branch to take yet).
|
||||
"""
|
||||
|
||||
from paperpod.ocr.extract import OcrResult, extract_date, extract_total, extract_vendor, run_ocr
|
||||
|
||||
__all__ = ["OcrResult", "extract_date", "extract_total", "extract_vendor", "run_ocr"]
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
"""OCR fallback naming: pull vendor / date / total out of a document crop.
|
||||
|
||||
Used when no spoken description is available (module 4, not built yet).
|
||||
Tesseract (via pytesseract) does the character recognition; everything else
|
||||
here is regex/heuristics tuned for North American receipts and letters.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import statistics
|
||||
from dataclasses import dataclass
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
import pytesseract
|
||||
|
||||
MONTHS = {
|
||||
"jan": 1, "january": 1, "feb": 2, "february": 2, "mar": 3, "march": 3,
|
||||
"apr": 4, "april": 4, "may": 5, "jun": 6, "june": 6, "jul": 7, "july": 7,
|
||||
"aug": 8, "august": 8, "sep": 9, "sept": 9, "september": 9, "oct": 10,
|
||||
"october": 10, "nov": 11, "november": 11, "dec": 12, "december": 12,
|
||||
}
|
||||
|
||||
# Ordered most- to least-specific; first match wins.
|
||||
_DATE_PATTERNS: list[tuple[re.Pattern, str]] = [
|
||||
(re.compile(r"\b(\d{4})[-/](\d{1,2})[-/](\d{1,2})\b"), "ymd"),
|
||||
(
|
||||
re.compile(
|
||||
r"\b(jan|feb|mar|apr|may|jun|jul|aug|sep|sept|oct|nov|dec)"
|
||||
r"[a-z]*\.?\s+(\d{1,2}),?\s+(\d{4})\b",
|
||||
re.IGNORECASE,
|
||||
),
|
||||
"month_name_dmy",
|
||||
),
|
||||
# Assumes North American MM/DD/YYYY (matches Home Depot / Metro / Petro-Canada
|
||||
# style receipts this app was designed around). Revisit if you record
|
||||
# receipts using DD/MM/YYYY formatting.
|
||||
(re.compile(r"\b(\d{1,2})[/-](\d{1,2})[/-](\d{4})\b"), "mdy"),
|
||||
]
|
||||
|
||||
_TOTAL_LINE_RE = re.compile(r"\btotal\b", re.IGNORECASE)
|
||||
_MONEY_RE = re.compile(r"\$?\s*(\d{1,4}\.\d{2})\b")
|
||||
|
||||
|
||||
@dataclass
|
||||
class OcrResult:
|
||||
text: str
|
||||
vendor: str | None
|
||||
date: str | None # normalized YYYY-MM-DD
|
||||
total: str | None # e.g. "81.52"
|
||||
mean_confidence: float # 0-100, avg Tesseract word confidence
|
||||
|
||||
|
||||
def _normalize_date(match: re.Match, kind: str) -> str | None:
|
||||
try:
|
||||
if kind == "ymd":
|
||||
y, m, d = (int(g) for g in match.groups())
|
||||
elif kind == "mdy":
|
||||
m, d, y = (int(g) for g in match.groups())
|
||||
elif kind == "month_name_dmy":
|
||||
month_str, day_str, year_str = match.groups()
|
||||
m = MONTHS.get(month_str.lower())
|
||||
d, y = int(day_str), int(year_str)
|
||||
if m is None:
|
||||
return None
|
||||
else:
|
||||
return None
|
||||
if not (1 <= m <= 12 and 1 <= d <= 31 and 1990 <= y <= 2100):
|
||||
return None
|
||||
return f"{y:04d}-{m:02d}-{d:02d}"
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def extract_date(text: str) -> str | None:
|
||||
"""First plausible date found in OCR text, normalized to YYYY-MM-DD."""
|
||||
for pattern, kind in _DATE_PATTERNS:
|
||||
match = pattern.search(text)
|
||||
if match:
|
||||
normalized = _normalize_date(match, kind)
|
||||
if normalized:
|
||||
return normalized
|
||||
return None
|
||||
|
||||
|
||||
def extract_total(text: str) -> str | None:
|
||||
"""Dollar amount on a line containing "total" but not "subtotal"."""
|
||||
lines = text.splitlines()
|
||||
for i, line in enumerate(lines):
|
||||
if not _TOTAL_LINE_RE.search(line):
|
||||
continue
|
||||
money = _MONEY_RE.search(line)
|
||||
if not money and i + 1 < len(lines):
|
||||
money = _MONEY_RE.search(lines[i + 1])
|
||||
if money:
|
||||
return money.group(1)
|
||||
return None
|
||||
|
||||
|
||||
def extract_vendor(
|
||||
image_bgr: np.ndarray,
|
||||
lang: str = "eng",
|
||||
top_fraction: float = 0.4,
|
||||
min_word_confidence: float = 40.0,
|
||||
psm: int = 6,
|
||||
) -> str | None:
|
||||
"""Largest-font text line within the top portion of the crop.
|
||||
|
||||
Receipt headers and letterheads are typically both topmost and printed
|
||||
larger than body text, so we group Tesseract's word boxes into lines and
|
||||
pick the tallest line above the confidence floor.
|
||||
"""
|
||||
height = image_bgr.shape[0]
|
||||
cutoff = int(height * top_fraction)
|
||||
region = image_bgr[:cutoff] if cutoff > 0 else image_bgr
|
||||
|
||||
config = f"--psm {psm}"
|
||||
data = pytesseract.image_to_data(
|
||||
region, lang=lang, config=config, output_type=pytesseract.Output.DICT
|
||||
)
|
||||
|
||||
lines: dict[tuple[int, int, int], list[int]] = {}
|
||||
for i, text in enumerate(data["text"]):
|
||||
if not text.strip():
|
||||
continue
|
||||
try:
|
||||
conf = float(data["conf"][i])
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if conf < min_word_confidence:
|
||||
continue
|
||||
key = (data["block_num"][i], data["par_num"][i], data["line_num"][i])
|
||||
lines.setdefault(key, []).append(i)
|
||||
|
||||
if not lines:
|
||||
return _first_nonblank_line(
|
||||
pytesseract.image_to_string(region, lang=lang, config=config)
|
||||
)
|
||||
|
||||
def line_stats(indices: list[int]) -> tuple[float, float]:
|
||||
# Median, not max: a single descender/ascender glyph (comma, "y",
|
||||
# parenthesis) can inflate one word's bounding box well above the
|
||||
# line's actual font size, which would otherwise skew line selection.
|
||||
height = statistics.median(data["height"][i] for i in indices)
|
||||
top = min(data["top"][i] for i in indices)
|
||||
return height, top
|
||||
|
||||
stats = {key: line_stats(idxs) for key, idxs in lines.items()}
|
||||
max_height = max(h for h, _ in stats.values())
|
||||
# Treat near-max heights as the same font size and break ties by
|
||||
# topmost line, rather than picking whichever line measured tallest.
|
||||
tolerance = max(3.0, max_height * 0.15)
|
||||
candidates = [key for key, (h, _) in stats.items() if h >= max_height - tolerance]
|
||||
best_key = min(candidates, key=lambda k: stats[k][1])
|
||||
|
||||
words = [data["text"][i].strip() for i in sorted(lines[best_key])]
|
||||
vendor = " ".join(w for w in words if w)
|
||||
return vendor or None
|
||||
|
||||
|
||||
def _first_nonblank_line(text: str) -> str | None:
|
||||
for line in text.splitlines():
|
||||
stripped = line.strip()
|
||||
if stripped:
|
||||
return stripped
|
||||
return None
|
||||
|
||||
|
||||
def run_ocr(
|
||||
image_bgr: np.ndarray,
|
||||
lang: str = "eng",
|
||||
vendor_top_fraction: float = 0.4,
|
||||
min_word_confidence: float = 40.0,
|
||||
psm: int = 6,
|
||||
) -> OcrResult:
|
||||
"""Run Tesseract once for full text, once (on a crop) for vendor detection."""
|
||||
config = f"--psm {psm}"
|
||||
text = pytesseract.image_to_string(image_bgr, lang=lang, config=config)
|
||||
|
||||
data = pytesseract.image_to_data(
|
||||
image_bgr, lang=lang, config=config, output_type=pytesseract.Output.DICT
|
||||
)
|
||||
confidences = [float(c) for c in data["conf"] if c not in ("-1", -1)]
|
||||
mean_confidence = sum(confidences) / len(confidences) if confidences else 0.0
|
||||
|
||||
vendor = extract_vendor(
|
||||
image_bgr,
|
||||
lang=lang,
|
||||
top_fraction=vendor_top_fraction,
|
||||
min_word_confidence=min_word_confidence,
|
||||
psm=psm,
|
||||
)
|
||||
date = extract_date(text)
|
||||
total = extract_total(text)
|
||||
|
||||
return OcrResult(
|
||||
text=text, vendor=vendor, date=date, total=total, mean_confidence=mean_confidence
|
||||
)
|
||||
|
||||
|
||||
def run_ocr_on_path(path: str, **kwargs) -> OcrResult:
|
||||
image = cv2.imread(path)
|
||||
if image is None:
|
||||
raise ValueError(f"Could not read image: {path}")
|
||||
return run_ocr(image, **kwargs)
|
||||
@@ -1,2 +1,13 @@
|
||||
"""Module 7 (not built yet): assemble pod page images into PDFs (img2pdf)
|
||||
and stage them for the Paperless-ngx consume directory."""
|
||||
"""Module 7 (partial): assemble page images into PDFs (img2pdf).
|
||||
|
||||
images_to_pdf() supports multi-page PDFs given an ordered list of image
|
||||
paths, but nothing yet decides *which* crops belong together as one
|
||||
document — that's module 3 (events/, page-flip vs. new-document detection),
|
||||
which isn't built. Until then, paperpod.pipeline.export_pdfs treats every
|
||||
detected crop as its own single-page PDF. Staging into a Paperless-ngx
|
||||
consume directory is also not wired up yet.
|
||||
"""
|
||||
|
||||
from paperpod.pdf.build import images_to_pdf
|
||||
|
||||
__all__ = ["images_to_pdf"]
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Assemble one or more page images into a single PDF (img2pdf)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import img2pdf
|
||||
|
||||
|
||||
def images_to_pdf(
|
||||
image_paths: list[str | Path],
|
||||
out_path: str | Path,
|
||||
title: str | None = None,
|
||||
) -> Path:
|
||||
"""Combine images (in order) into a single multi-page PDF.
|
||||
|
||||
img2pdf embeds the source images losslessly (no re-encoding/quality
|
||||
loss) rather than rasterizing through a rendering step.
|
||||
"""
|
||||
if not image_paths:
|
||||
raise ValueError("images_to_pdf requires at least one image")
|
||||
out_path = Path(out_path)
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
kwargs = {}
|
||||
if title:
|
||||
kwargs["title"] = title.encode("utf-8")
|
||||
|
||||
pdf_bytes = img2pdf.convert([str(p) for p in image_paths], **kwargs)
|
||||
out_path.write_bytes(pdf_bytes)
|
||||
return out_path
|
||||
@@ -10,6 +10,7 @@ from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -18,6 +19,9 @@ import numpy as np
|
||||
|
||||
from paperpod.capture.video import iter_frames, probe_video
|
||||
from paperpod.config import Config, config_to_dict
|
||||
from paperpod.naming.filename import build_name, dedupe_stem
|
||||
from paperpod.ocr.extract import run_ocr
|
||||
from paperpod.pdf.build import images_to_pdf
|
||||
from paperpod.vision.document import detect_document, warp_document
|
||||
from paperpod.vision.enhance import enhance_for_ocr
|
||||
from paperpod.vision.motion import Segment, find_segments, motion_score, prepare_gray
|
||||
@@ -160,3 +164,83 @@ def run_detection(video_path: str | Path, cfg: Config, out_dir: str | Path) -> d
|
||||
with open(out_dir / "report.json", "w") as f:
|
||||
json.dump(report, f, indent=2)
|
||||
return report
|
||||
|
||||
|
||||
def run_export(report: dict[str, Any], cfg: Config, out_dir: str | Path) -> dict[str, Any]:
|
||||
"""OCR-name and PDF-export every detected crop from a detect() report.
|
||||
|
||||
NOTE: there is no page-grouping yet (module 3 / events/ is not built),
|
||||
so every detected crop becomes its own single-page PDF, even if it was
|
||||
physically one side of a multi-page stack. pod_id == window_id for now.
|
||||
|
||||
Writes:
|
||||
pdf/<name>.pdf one PDF per detected document
|
||||
export_summary.csv timestamp, pod_id, page_count, source_of_name,
|
||||
ocr_vendor, ocr_date, ocr_total, final_filename
|
||||
Returns the summary rows (also embedded back for programmatic use).
|
||||
"""
|
||||
out_dir = Path(out_dir)
|
||||
pdf_dir = out_dir / "pdf"
|
||||
pdf_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
video_path = Path(report["video"])
|
||||
duration_s = report["video_meta"]["duration_s"]
|
||||
try:
|
||||
recording_start = datetime.fromtimestamp(video_path.stat().st_mtime) - timedelta(
|
||||
seconds=duration_s
|
||||
)
|
||||
except FileNotFoundError:
|
||||
recording_start = datetime.now() - timedelta(seconds=duration_s)
|
||||
|
||||
used_stems: set[str] = set()
|
||||
rows: list[dict[str, Any]] = []
|
||||
|
||||
for window in report["stable_windows"]:
|
||||
if not window.get("document_found"):
|
||||
continue
|
||||
|
||||
crop_path = out_dir / window["crop_path"]
|
||||
image = cv2.imread(str(crop_path))
|
||||
if image is None:
|
||||
continue
|
||||
|
||||
ocr = run_ocr(
|
||||
image,
|
||||
lang=cfg.ocr.lang,
|
||||
vendor_top_fraction=cfg.ocr.vendor_top_fraction,
|
||||
min_word_confidence=cfg.ocr.min_word_confidence,
|
||||
psm=cfg.ocr.psm,
|
||||
)
|
||||
event_time = recording_start + timedelta(seconds=window["t_start"])
|
||||
naming = build_name(ocr.date, ocr.vendor, event_time, cfg.naming.max_vendor_len)
|
||||
stem = dedupe_stem(naming.stem, used_stems)
|
||||
|
||||
pdf_path = pdf_dir / f"{stem}.pdf"
|
||||
images_to_pdf([crop_path], pdf_path, title=stem)
|
||||
|
||||
rows.append(
|
||||
{
|
||||
"timestamp": event_time.isoformat(timespec="seconds"),
|
||||
"pod_id": window["window_id"],
|
||||
"page_count": 1,
|
||||
"source_of_name": naming.source,
|
||||
"ocr_vendor": ocr.vendor or "",
|
||||
"ocr_date": ocr.date or "",
|
||||
"ocr_total": ocr.total or "",
|
||||
"ocr_confidence": round(ocr.mean_confidence, 1),
|
||||
"final_filename": pdf_path.name,
|
||||
"pdf_path": str(pdf_path.relative_to(out_dir)),
|
||||
}
|
||||
)
|
||||
|
||||
with open(out_dir / "export_summary.csv", "w", newline="") as f:
|
||||
fieldnames = [
|
||||
"timestamp", "pod_id", "page_count", "source_of_name",
|
||||
"ocr_vendor", "ocr_date", "ocr_total", "ocr_confidence",
|
||||
"final_filename", "pdf_path",
|
||||
]
|
||||
writer = csv.DictWriter(f, fieldnames=fieldnames)
|
||||
writer.writeheader()
|
||||
writer.writerows(rows)
|
||||
|
||||
return {"rows": rows, "pdf_dir": str(pdf_dir)}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
from datetime import datetime
|
||||
|
||||
from paperpod.naming.filename import build_name, dedupe_stem, slugify
|
||||
|
||||
|
||||
def test_slugify_basic():
|
||||
assert slugify("THE HOME DEPOT") == "the_home_depot"
|
||||
assert slugify("Café Déjà-Vu!!") == "caf_d_j_vu"
|
||||
|
||||
|
||||
def test_slugify_empty_falls_back():
|
||||
assert slugify("") == "doc"
|
||||
assert slugify("!!!") == "doc"
|
||||
|
||||
|
||||
def test_slugify_truncates():
|
||||
long_name = "a" * 100
|
||||
assert len(slugify(long_name, max_len=40)) <= 40
|
||||
|
||||
|
||||
def test_build_name_full_metadata():
|
||||
dt = datetime(2026, 1, 1, 12, 0, 0)
|
||||
result = build_name("2023-03-14", "THE HOME DEPOT", dt)
|
||||
assert result.stem == "2023-03-14_the_home_depot"
|
||||
assert result.source == "ocr"
|
||||
|
||||
|
||||
def test_build_name_date_only():
|
||||
dt = datetime(2026, 1, 1, 12, 0, 0)
|
||||
result = build_name("2023-03-14", None, dt)
|
||||
assert result.stem == "2023-03-14_UNSORTED"
|
||||
assert result.source == "ocr"
|
||||
|
||||
|
||||
def test_build_name_vendor_only():
|
||||
dt = datetime(2026, 1, 1, 12, 0, 0)
|
||||
result = build_name(None, "METRO", dt)
|
||||
assert result.stem == "UNSORTED_metro_20260101_120000"
|
||||
assert result.source == "ocr"
|
||||
|
||||
|
||||
def test_build_name_nothing_found():
|
||||
dt = datetime(2026, 1, 1, 12, 0, 0)
|
||||
result = build_name(None, None, dt)
|
||||
assert result.stem == "UNSORTED_20260101_120000"
|
||||
assert result.source == "none"
|
||||
|
||||
|
||||
def test_dedupe_stem_no_collision():
|
||||
used: set[str] = set()
|
||||
assert dedupe_stem("2023-03-14_metro", used) == "2023-03-14_metro"
|
||||
|
||||
|
||||
def test_dedupe_stem_collision_increments():
|
||||
used: set[str] = set()
|
||||
first = dedupe_stem("2023-03-14_metro", used)
|
||||
second = dedupe_stem("2023-03-14_metro", used)
|
||||
third = dedupe_stem("2023-03-14_metro", used)
|
||||
assert (first, second, third) == (
|
||||
"2023-03-14_metro",
|
||||
"2023-03-14_metro_2",
|
||||
"2023-03-14_metro_3",
|
||||
)
|
||||
@@ -0,0 +1,51 @@
|
||||
"""End-to-end OCR tests against the synthetic receipt renderer.
|
||||
|
||||
Skipped automatically if the tesseract binary isn't installed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
shutil.which("tesseract") is None, reason="tesseract binary not installed"
|
||||
)
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "sample_data"))
|
||||
|
||||
|
||||
def _receipt_image(index: int):
|
||||
from make_receipt_video import RECEIPTS, make_receipt_image
|
||||
|
||||
return make_receipt_image(RECEIPTS[index])
|
||||
|
||||
|
||||
def test_home_depot_receipt_fields():
|
||||
from paperpod.ocr.extract import run_ocr
|
||||
|
||||
result = run_ocr(_receipt_image(0))
|
||||
assert result.date == "2023-03-14"
|
||||
assert result.total == "81.52"
|
||||
assert "HOME DEPOT" in (result.vendor or "")
|
||||
|
||||
|
||||
def test_metro_receipt_fields():
|
||||
from paperpod.ocr.extract import run_ocr
|
||||
|
||||
result = run_ocr(_receipt_image(1))
|
||||
assert result.date == "2023-06-02"
|
||||
assert result.total == "30.63"
|
||||
assert "METRO" in (result.vendor or "")
|
||||
|
||||
|
||||
def test_petro_canada_receipt_fields():
|
||||
from paperpod.ocr.extract import run_ocr
|
||||
|
||||
result = run_ocr(_receipt_image(2))
|
||||
assert result.date == "2023-08-19"
|
||||
assert result.total == "65.10"
|
||||
assert "PETRO" in (result.vendor or "")
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Unit tests for the regex/heuristic parsing logic in paperpod.ocr.extract.
|
||||
|
||||
These test the parsing functions directly on strings so they stay
|
||||
deterministic and don't depend on Tesseract's OCR accuracy. End-to-end OCR
|
||||
accuracy against rendered receipts is exercised in test_ocr_integration.py.
|
||||
"""
|
||||
|
||||
from paperpod.ocr.extract import extract_date, extract_total
|
||||
|
||||
|
||||
def test_extract_date_iso():
|
||||
assert extract_date("2023-03-14 09:41 #4821") == "2023-03-14"
|
||||
|
||||
|
||||
def test_extract_date_slash_mdy():
|
||||
assert extract_date("Purchased on 03/14/2023 at noon") == "2023-03-14"
|
||||
|
||||
|
||||
def test_extract_date_month_name():
|
||||
assert extract_date("Invoice date: March 14, 2023") == "2023-03-14"
|
||||
assert extract_date("Mar 14 2023") == "2023-03-14"
|
||||
|
||||
|
||||
def test_extract_date_none_when_absent():
|
||||
assert extract_date("no date here, just text") is None
|
||||
|
||||
|
||||
def test_extract_date_rejects_implausible_values():
|
||||
# Not a real month/day -> not treated as a date.
|
||||
assert extract_date("13/45/2023") is None
|
||||
|
||||
|
||||
def test_extract_total_basic():
|
||||
text = "SUBTOTAL 72.14\nHST 13% 9.38\nTOTAL 81.52\n"
|
||||
assert extract_total(text) == "81.52"
|
||||
|
||||
|
||||
def test_extract_total_ignores_subtotal():
|
||||
text = "SUBTOTAL 30.63\n"
|
||||
assert extract_total(text) is None
|
||||
|
||||
|
||||
def test_extract_total_word_boundary_with_prefix():
|
||||
# "FUEL TOTAL" should still match "total" as its own word.
|
||||
text = "FUEL TOTAL 65.10\n"
|
||||
assert extract_total(text) == "65.10"
|
||||
|
||||
|
||||
def test_extract_total_amount_on_next_line():
|
||||
text = "TOTAL\n81.52\n"
|
||||
assert extract_total(text) == "81.52"
|
||||
|
||||
|
||||
def test_extract_total_none_when_absent():
|
||||
assert extract_total("no totals here") is None
|
||||
@@ -0,0 +1,55 @@
|
||||
import numpy as np
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
from paperpod.pdf.build import images_to_pdf
|
||||
|
||||
|
||||
def _write_png(path, color):
|
||||
Image.new("RGB", (200, 300), color).save(path)
|
||||
|
||||
|
||||
def test_images_to_pdf_single_page(tmp_path):
|
||||
img_path = tmp_path / "page.png"
|
||||
_write_png(img_path, (255, 255, 255))
|
||||
out = tmp_path / "out.pdf"
|
||||
|
||||
result = images_to_pdf([img_path], out, title="Test Doc")
|
||||
|
||||
assert result == out
|
||||
assert out.exists()
|
||||
data = out.read_bytes()
|
||||
assert data.startswith(b"%PDF")
|
||||
|
||||
|
||||
def test_images_to_pdf_multi_page(tmp_path):
|
||||
paths = []
|
||||
for i, color in enumerate([(255, 0, 0), (0, 255, 0), (0, 0, 255)]):
|
||||
p = tmp_path / f"page_{i}.png"
|
||||
_write_png(p, color)
|
||||
paths.append(p)
|
||||
out = tmp_path / "multi.pdf"
|
||||
|
||||
images_to_pdf(paths, out)
|
||||
|
||||
data = out.read_bytes()
|
||||
# img2pdf writes one xobject image per page; a 3-page PDF should be
|
||||
# meaningfully larger than a 1-page PDF of the same-size images.
|
||||
single = tmp_path / "single.pdf"
|
||||
images_to_pdf([paths[0]], single)
|
||||
assert out.stat().st_size > single.stat().st_size
|
||||
|
||||
|
||||
def test_images_to_pdf_requires_at_least_one_image(tmp_path):
|
||||
with pytest.raises(ValueError):
|
||||
images_to_pdf([], tmp_path / "empty.pdf")
|
||||
|
||||
|
||||
def test_images_to_pdf_creates_parent_dirs(tmp_path):
|
||||
img_path = tmp_path / "page.png"
|
||||
_write_png(img_path, (10, 20, 30))
|
||||
out = tmp_path / "nested" / "dir" / "out.pdf"
|
||||
|
||||
images_to_pdf([img_path], out)
|
||||
|
||||
assert out.exists()
|
||||
Reference in New Issue
Block a user