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:
@@ -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
|
||||
Reference in New Issue
Block a user