Add crop refinement, finger removal, and local LLM naming

- vision/refine.py: tighten crops to the paper band (removes mat
  margins and hands beside receipts) and inpaint border-connected
  skin regions so fingers disappear from output
- llm/vision.py: identify documents with a local Ollama vision model
  (qwen2.5vl); extracts vendor/date/total/form code and flags quality
  issues (fingers, blur, glare); falls back to Tesseract when down
- pipeline: drop blank pages, dedupe consecutive captures of the same
  document, record refine/LLM fields in report and export summary
This commit is contained in:
ilia 2026-07-08 18:23:50 -04:00
parent 2c28a623fb
commit a43ab20df6
22 changed files with 1497 additions and 111 deletions

View File

@ -11,10 +11,19 @@ offline on your machine.
one at a time (optionally saying out loud what each one is).
2. PaperPod finds "stable windows" where nothing is moving, picks the sharpest
frame in each, detects the document outline, and produces a
perspective-corrected crop.
3. (Upcoming) Spoken descriptions (faster-whisper) or OCR (Tesseract) name
each document; multi-page stacks are grouped into single PDFs; a review
step lets you rename/merge/split before export.
perspective-corrected crop. Detection scores candidate contours by shape
(filled, convex, plausible aspect ratio) rather than just picking the
largest one, and runs on a resolution-independent downscale so it works
the same on FHD and 4K phone video.
3. The crop is auto-rotated upright (0/90/180/270) — no need to place
documents facing a particular way.
4. Tesseract OCR (run against an illumination-normalized "flattened" version
of the crop, similar to a scanner app's contrast enhancement) extracts a
vendor/date/total for receipts, or a form code + organization name for
recognized Canadian tax slips (T4, T4A, T5008, ...), and names the PDF.
5. (Upcoming) Spoken descriptions (faster-whisper) as an alternate naming
source; multi-page stacks grouped into single PDFs; a review step to
rename/merge/split before export.
## Status
@ -75,10 +84,17 @@ python -m paperpod extract-audio my_recording.mp4
`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
`YYYY-MM-DD_vendor.pdf` from OCR-extracted date/vendor (or
`<form_code>_<org_name>` for recognized tax slips), 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
(`ocr`/`none`), OCR vendor/form_code/date/total/confidence, detection
confidence, rotation applied, final filename
Before naming, each crop is auto-rotated to be right-side-up (Tesseract's
built-in orientation detection, falling back to a 4-way OCR-confidence sweep
when a crop has too little text for that to work) — you don't need to worry
about which way documents face when placing them.
Nothing is copied into a Paperless-ngx consume directory yet — review the
`pdf/` folder yourself before moving files anywhere.
@ -86,10 +102,18 @@ Nothing is copied into a Paperless-ngx consume directory yet — review the
## Tuning
All thresholds live in `config.yaml` (motion sensitivity, stable window
duration, contour size floor, Canny thresholds, speech matching window,
duration, contour size/shape filters, Canny thresholds, detection
downscale width, auto-rotate/enhance toggles, speech matching window,
output paths). Plot `motion_scores.csv` to pick a `motion.threshold` that
separates your camera's noise floor from real hand movement.
**Recording tips (from real-world testing):** shoot in 4K, keep hands out
of frame once a document is placed, and use a plain dark, matte (non-glossy)
surface under documents — busy wood grain or shiny surfaces make contour
detection and OCR meaningfully harder. Document detection and orientation
correction are tuned against real phone-camera footage (receipts on a wood
table and multi-page CRA tax slips), not just synthetic test videos.
## Tests
```bash

View File

@ -27,11 +27,45 @@ motion:
document:
# Contours smaller than this fraction of the frame area are ignored.
min_area_ratio: 0.04
# Contours larger than this fraction of the frame are rejected as likely
# background/lighting rather than a document.
max_area_ratio: 0.92
# Reject candidates whose long side is more than this many times their
# short side (filters out thin slivers/edge-strip false detections).
max_aspect: 6.0
# Canny edge detection thresholds.
canny_low: 50
canny_high: 150
# Apply contrast enhancement (CLAHE) to crops for OCR-readiness.
# Contour search runs on a frame downscaled to this width (then the
# winning quad is scaled back up for the full-resolution crop). Keeps
# edge/gap-closing behavior consistent regardless of source resolution —
# important for 4K phone video.
detect_width: 1000
# Bake illumination-normalization + contrast enhancement into the saved
# crop/PDF (grayscale "flattened scan" look). OCR always uses this
# enhancement internally regardless of this setting.
enhance: false
# Auto-detect and correct 0/90/180/270 rotation before OCR/PDF export.
auto_rotate: true
# Tighten crops to the actual paper region (cuts mat margins/hands) and
# inpaint fingers pressing on the page.
refine: true
remove_fingers: true
# Split separate document placements when no contour is seen for this long.
event_gap_s: 2.0
# Ignore single-frame detection blips.
event_min_samples: 2
llm:
# Identify/name documents with a local Ollama vision model (all offline).
# Reads whole documents at once — far more robust than Tesseract for
# curled receipts and stylized logos — and flags quality issues
# (fingers_visible, blurry, glare) in export_summary.csv.
# Slower: ~20-40s per document. Falls back to Tesseract when Ollama is down.
enabled: true
model: qwen2.5vl
base_url: http://localhost:11434
timeout_s: 180
speech:
# Window around a capture event in which to look for a spoken description.

View File

@ -57,7 +57,10 @@ def cmd_detect(args: argparse.Namespace) -> int:
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"
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:
@ -67,6 +70,8 @@ def cmd_detect(args: argparse.Namespace) -> int:
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'}")
@ -91,6 +96,9 @@ def cmd_export(args: argparse.Namespace) -> int:
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']}]")
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'}")

View File

@ -31,9 +31,39 @@ class MotionConfig:
@dataclass
class DocumentConfig:
min_area_ratio: float = 0.04
# Candidates covering more than this fraction of the frame are rejected
# as likely background/lighting rather than a document.
max_area_ratio: float = 0.92
# Candidates whose long side is more than this many times their short
# side are rejected (fixes thin sliver/edge-strip false detections).
max_aspect: float = 6.0
canny_low: int = 50
canny_high: int = 150
# Contour search runs on a frame downscaled to this width; edge/threshold
# gap-closing kernels are sized relative to it rather than the source
# resolution, and the winning quad is scaled back up for the actual crop.
# Verified fix: a 4K frame's document outline was fragmenting into
# sub-regions with a fixed-pixel kernel tuned for ~1000px-wide frames.
detect_width: int = 1000
# Bake illumination-normalization + contrast enhancement into the saved
# crop/PDF, not just the internal OCR pass (which always applies it).
enhance: bool = False
# Auto-detect and correct 0/90/180/270 rotation before OCR/PDF export.
# Fixes the "placed the document upside down" case, which otherwise
# produces a fine-looking crop that OCR reads as garbage.
auto_rotate: bool = True
# Tighten each crop to the actual paper region (removes mat margins and
# hands hanging off the side of receipts) and inpaint fingers pressing
# on the page. Content *under* a finger can't be recovered — it's filled
# with plausible paper texture — but margins clean up completely.
refine: bool = True
remove_fingers: bool = True
# Frame-based capture: cluster consecutive per-frame detections into
# separate documents when no contour is seen for this many seconds.
event_gap_s: float = 2.0
# Require at least this many sampled frames with a detection before
# committing a capture event (filters single-frame noise).
event_min_samples: int = 2
@dataclass
@ -65,6 +95,17 @@ class OcrConfig:
min_word_confidence: float = 10.0
@dataclass
class LlmConfig:
# Use a local Ollama vision model to identify/name documents and flag
# quality issues. Falls back to Tesseract heuristics when Ollama is
# down or the model errors. ~20-40s per document vs <2s for Tesseract.
enabled: bool = True
model: str = "qwen2.5vl"
base_url: str = "http://localhost:11434"
timeout_s: float = 180.0
@dataclass
class NamingConfig:
max_vendor_len: int = 40
@ -84,6 +125,7 @@ class Config:
document: DocumentConfig = field(default_factory=DocumentConfig)
speech: SpeechConfig = field(default_factory=SpeechConfig)
ocr: OcrConfig = field(default_factory=OcrConfig)
llm: LlmConfig = field(default_factory=LlmConfig)
naming: NamingConfig = field(default_factory=NamingConfig)
output: OutputConfig = field(default_factory=OutputConfig)

5
paperpod/llm/__init__.py Normal file
View File

@ -0,0 +1,5 @@
"""Local vision-LLM document analysis (Ollama)."""
from paperpod.llm.vision import LlmAnalysis, analyze_document, ollama_available
__all__ = ["LlmAnalysis", "analyze_document", "ollama_available"]

135
paperpod/llm/vision.py Normal file
View File

@ -0,0 +1,135 @@
"""Identify documents with a local vision LLM served by Ollama.
Everything stays on-machine: images go to localhost:11434, never the cloud.
A vision model (qwen2.5-vl class) reads the whole document at once, which is
far more robust than Tesseract + regex for curled receipts, hands in frame,
and stylized logos it correctly reads "COSTCO WHOLESALE" where Tesseract
produced "Cosrco". It also reports quality issues (fingers, blur, glare) so
bad captures can be flagged for re-scanning.
Trade-off: ~20-40s per document on an M-series Mac, vs <2s for Tesseract.
The pipeline uses the LLM for naming when available and falls back to the
OCR heuristics when Ollama isn't running.
"""
from __future__ import annotations
import base64
import json
import re
import urllib.error
import urllib.request
from dataclasses import dataclass, field
import cv2
import numpy as np
_PROMPT = """Look at this image of a document. Reply with ONLY a JSON object, no other text:
{"doc_type": "receipt|tax_form|letter|invoice|blank|other",
"vendor": "business or organization name, or null",
"form_code": "tax form code like T4/T4A/T5008 if this is a tax form, else null",
"date": "document/transaction date as YYYY-MM-DD, or null",
"tax_year": "tax year like 2023 if this is a tax form, else null",
"total": "total amount like 103.60, or null",
"description": "3-6 word summary",
"issues": ["any of: fingers_visible, blurry, cut_off, upside_down, glare, partial_document"]}
Use doc_type "blank" if the page has no meaningful printed content (blank back of a page, empty sheet).
Set vendor to null unless you can actually read a real business/organization name never output garbled text."""
@dataclass
class LlmAnalysis:
doc_type: str
vendor: str | None
form_code: str | None
date: str | None # normalized YYYY-MM-DD
tax_year: str | None
total: str | None
description: str
issues: list[str] = field(default_factory=list)
def ollama_available(base_url: str = "http://localhost:11434", timeout: float = 2.0) -> bool:
try:
with urllib.request.urlopen(f"{base_url}/api/tags", timeout=timeout):
return True
except (urllib.error.URLError, OSError):
return False
def _encode_image(image_bgr: np.ndarray, max_width: int = 1200) -> str:
"""JPEG-encode (downscaled) for the API; PNG of a 4K crop is wastefully big."""
h, w = image_bgr.shape[:2]
if w > max_width:
image_bgr = cv2.resize(image_bgr, (max_width, round(h * max_width / w)))
ok, buf = cv2.imencode(".jpg", image_bgr, [cv2.IMWRITE_JPEG_QUALITY, 90])
if not ok:
raise ValueError("Could not JPEG-encode image")
return base64.b64encode(buf.tobytes()).decode()
def _normalize_date(raw: str | None) -> str | None:
if not raw:
return None
match = re.search(r"(\d{4})[-/](\d{1,2})[-/](\d{1,2})", str(raw))
if not match:
return None
y, m, d = (int(g) for g in match.groups())
if not (1 <= m <= 12 and 1 <= d <= 31 and 1990 <= y <= 2100):
return None
return f"{y:04d}-{m:02d}-{d:02d}"
def _clean_str(value) -> str | None:
if value is None:
return None
text = str(value).strip()
return text if text and text.lower() not in ("null", "none", "n/a", "unknown") else None
def analyze_document(
image_bgr: np.ndarray,
model: str = "qwen2.5vl",
base_url: str = "http://localhost:11434",
timeout: float = 180.0,
) -> LlmAnalysis | None:
"""Ask the local vision model what this document is. None on any failure."""
try:
body = json.dumps(
{
"model": model,
"prompt": _PROMPT,
"images": [_encode_image(image_bgr)],
"stream": False,
"format": "json",
"options": {"temperature": 0},
}
).encode()
req = urllib.request.Request(
f"{base_url}/api/generate", data=body, headers={"Content-Type": "application/json"}
)
with urllib.request.urlopen(req, timeout=timeout) as resp:
payload = json.load(resp)
data = json.loads(payload["response"])
except (urllib.error.URLError, OSError, KeyError, ValueError):
return None
issues = data.get("issues") or []
if not isinstance(issues, list):
issues = [str(issues)]
tax_year = _clean_str(data.get("tax_year"))
if tax_year:
year_match = re.search(r"(19|20)\d{2}", tax_year)
tax_year = year_match.group(0) if year_match else None
return LlmAnalysis(
doc_type=_clean_str(data.get("doc_type")) or "other",
vendor=_clean_str(data.get("vendor")),
form_code=_clean_str(data.get("form_code")),
date=_normalize_date(data.get("date")),
tax_year=tax_year,
total=_clean_str(data.get("total")),
description=_clean_str(data.get("description")) or "",
issues=[str(i) for i in issues],
)

View File

@ -32,11 +32,32 @@ def build_name(
vendor: str | None,
fallback_dt: datetime,
max_vendor_len: int = 40,
form_code: str | None = None,
tax_year: str | None = None,
org_name: str | None = None,
) -> 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")
# Tax slips: prefer 2023_T4_York_University over UNSORTED_t4_york_university_<timestamp>.
if form_code:
org_slug = slugify(org_name, max_vendor_len) if org_name else None
if not org_slug and vendor:
stripped = vendor
if stripped.upper().startswith(form_code.upper()):
stripped = stripped[len(form_code) :].strip()
org_slug = slugify(stripped, max_vendor_len) if stripped else None
year = tax_year or (date[:4] if date else None)
code = form_code.upper()
if year and org_slug:
return NamingResult(stem=f"{year}_{code}_{org_slug}", source="ocr")
if year:
return NamingResult(stem=f"{year}_{code}", source="ocr")
if org_slug:
return NamingResult(stem=f"{code}_{org_slug}", source="ocr")
return NamingResult(stem=code.lower(), source="ocr")
if date and vendor_slug:
return NamingResult(stem=f"{date}_{vendor_slug}", source="ocr")
if date:

View File

@ -42,6 +42,106 @@ _DATE_PATTERNS: list[tuple[re.Pattern, str]] = [
_TOTAL_LINE_RE = re.compile(r"\btotal\b", re.IGNORECASE)
_MONEY_RE = re.compile(r"\$?\s*(\d{1,4}\.\d{2})\b")
# Common Canadian tax-slip codes, longest/most-specific first so "T4A" and
# "T5008" match before the more generic "T4"/"T5" would. Receipts and
# letters won't match any of these, so this only ever affects naming for
# actual government forms.
_FORM_CODES = ["T5008", "T4A", "T4E", "T2202", "RL-1", "RL-2", "T5", "T4", "T3", "T1"]
_FORM_CODE_RE = re.compile(
r"\b(" + "|".join(re.escape(c) for c in _FORM_CODES) + r")\b"
)
# Boilerplate that shows up near the top of CRA slips and would otherwise be
# mistaken for the employer/payer name by a naive "first all-caps line" scan.
_FORM_BOILERPLATE_RE = re.compile(
r"CANADA REVENUE|AGENCE DU REVENU|AGENCY|PROTECTED B|PROTEG|STATEMENT OF"
r"|ETAT DU|ETAT DE|SUMMARY OF|EMPLOYER|EMPLOYEE|EMPLOYE|PAYER|PAYOUR|RECIPIENT"
r"|YEAR|ANNEE|BOX \d|ACCOUNT NO|IDENTIFICATION|REPORT CODE|ORIGINAL|AMENDED"
r"|DU CANADA",
re.IGNORECASE,
)
# 2-5 consecutive ALL-CAPS words, e.g. "YORK UNIVERSITY", "AMC ENTERTAINMENT
# HOLDINGS INC". Searched as a substring (not a full-line match) since OCR
# on boxed forms often merges an organization name with adjacent column
# text ("YORK UNIVERSITY Year 2023 Statement of...") on one line.
_ORG_NAME_RE = re.compile(r"\b[A-Z][A-Z&.']{1,20}(?:\s+[A-Z][A-Z0-9&.',-]{1,20}){1,4}\b")
# Known retail chains — searched anywhere in OCR text when the vendor-line
# heuristic fails (common on narrow thermal receipts with hands in frame).
_STORE_PATTERNS: list[tuple[re.Pattern, str]] = [
(re.compile(r"COSTCO|WHOLE?\s*SALE", re.IGNORECASE), "Costco"),
(re.compile(r"WALMART", re.IGNORECASE), "Walmart"),
(re.compile(r"HOME\s+DEPOT", re.IGNORECASE), "Home Depot"),
(re.compile(r"\bMETRO\b", re.IGNORECASE), "Metro"),
(re.compile(r"PETRO[\s-]*CANADA", re.IGNORECASE), "Petro-Canada"),
]
_TAX_YEAR_RE = re.compile(
r"\b(?:year|ann[eé]e)\s*[:\s]?\s*(\d{4})\b", re.IGNORECASE
)
_TAX_YEAR_TITLE_RE = re.compile(
r"\b(?:SUMMARY|STATEMENT|DISPOSITIONS|REMUNERATION)\b[^\n]{0,40}\b(20\d{2})\b",
re.IGNORECASE,
)
def extract_form_code(text: str) -> str | None:
"""Recognized Canadian tax slip code (T4, T4A, T5008, ...), if present."""
match = _FORM_CODE_RE.search(text.upper())
return match.group(1) if match else None
def extract_tax_year(text: str) -> str | None:
"""Tax year from a government slip (e.g. 'Year 2023' on a T4), not a transaction date."""
for pattern in (_TAX_YEAR_RE, _TAX_YEAR_TITLE_RE):
match = pattern.search(text)
if match:
year = int(match.group(1))
if 1990 <= year <= 2100:
return str(year)
return None
def extract_store_name(text: str) -> str | None:
"""Known retail chain name if it appears anywhere in the OCR text."""
for pattern, name in _STORE_PATTERNS:
if pattern.search(text):
return name
return None
def extract_org_name(text: str) -> str | None:
"""First plausible employer/payer name line, skipping known form boilerplate.
Used as a naming fallback for government tax slips, where the "largest
text near the top" heuristic in extract_vendor tends to pick up form
titles or bilingual labels instead of the actual organization name.
"""
for line in text.splitlines():
stripped = line.strip()
# Civic addresses ("153 NIAGARA DR", "4700 KEELE STREET") start with
# a street number; organization name lines don't, so this cheaply
# tells an employer/payer name apart from the address line next to it.
if not stripped or stripped[0].isdigit():
continue
# Check boilerplate/digit-heaviness per *candidate*, not per line:
# OCR on boxed forms often merges the org name with an adjacent
# bilingual label on one physical line ("YORK UNIVERSITY Year 2023
# Statement of Remuneration Paid"), and the all-caps-words pattern
# naturally only matches the "YORK UNIVERSITY" portion of that line
# since the rest is mixed-case.
for match in _ORG_NAME_RE.finditer(stripped):
candidate = match.group(0)
if _FORM_BOILERPLATE_RE.search(candidate):
continue
# Reject serial numbers / barcodes ("JTA9500902-0301613-..."),
# which pass the ALL-CAPS-words shape but are mostly digits.
digits = sum(c.isdigit() for c in candidate)
if digits > len(candidate) * 0.2:
continue
return candidate
return None
@dataclass
class OcrResult:
@ -50,6 +150,9 @@ class OcrResult:
date: str | None # normalized YYYY-MM-DD
total: str | None # e.g. "81.52"
mean_confidence: float # 0-100, avg Tesseract word confidence
form_code: str | None = None # e.g. "T4", "T5008", if a known tax slip
tax_year: str | None = None # e.g. "2023" from "Year 2023" on a T4
org_name: str | None = None # employer/payer on tax slips
def _normalize_date(match: re.Match, kind: str) -> str | None:
@ -193,9 +296,26 @@ def run_ocr(
)
date = extract_date(text)
total = extract_total(text)
tax_year = extract_tax_year(text)
store = extract_store_name(text)
if store and (not vendor or store.upper().split("-")[0].split()[0] not in vendor.upper()):
vendor = store
form_code = extract_form_code(text)
org_name = extract_org_name(text) if form_code else None
if form_code:
vendor = f"{form_code} {org_name}" if org_name else form_code
return OcrResult(
text=text, vendor=vendor, date=date, total=total, mean_confidence=mean_confidence
text=text,
vendor=vendor,
date=date,
total=total,
mean_confidence=mean_confidence,
form_code=form_code,
tax_year=tax_year,
org_name=org_name,
)

View File

@ -2,8 +2,9 @@
Two sequential passes over the video (no seeking, works on any codec):
Pass 1: sample downscaled frames, score motion, find stable/moving segments.
Pass 2: within each stable window, pick the sharpest full-resolution frame,
detect the document contour, perspective-correct, save the crop.
Pass 2: scan every sampled frame for document contours, cluster consecutive
detections into capture events (works even while hands are moving),
pick the sharpest frame per event, perspective-correct, save the crop.
"""
from __future__ import annotations
@ -19,13 +20,16 @@ import numpy as np
from paperpod.capture.video import iter_frames, probe_video
from paperpod.config import Config, config_to_dict
from paperpod.llm.vision import analyze_document, ollama_available
from paperpod.naming.filename import build_name, dedupe_stem
from paperpod.ocr.extract import run_ocr
from paperpod.ocr.extract import run_ocr, extract_store_name
from paperpod.pdf.build import images_to_pdf
from paperpod.vision.capture_events import _plausible_capture, find_document_events
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
from paperpod.vision.sharpness import sharpness_score
from paperpod.vision.motion import find_segments, motion_score, prepare_gray
from paperpod.vision.orient import auto_rotate
from paperpod.vision.refine import refine_crop
def run_detection(video_path: str | Path, cfg: Config, out_dir: str | Path) -> dict[str, Any]:
@ -73,71 +77,98 @@ def run_detection(video_path: str | Path, cfg: Config, out_dir: str | Path) -> d
segments = find_segments(
timestamps, scores, cfg.motion.threshold, cfg.motion.stable_min_duration_s
)
stable_windows = [s for s in segments if s.kind == "stable"]
with open(out_dir / "motion_scores.csv", "w", newline="") as f:
writer = csv.writer(f)
writer.writerow(["t", "score"])
writer.writerows(zip(timestamps, scores))
# ---- Pass 2: best frame per stable window, document detection -----------
# Map each sampled timestamp range to its stable window for quick lookup.
# ---- Pass 2: frame-based document capture events ------------------------
# Scan every sampled frame for a document contour, cluster consecutive
# detections into separate placements, keep the sharpest frame per cluster.
# Unlike stable-window-only detection, this still captures receipts shown
# while hands are adjusting them (motion scores stay high but the contour
# is visible frame-by-frame).
det_kwargs = dict(
min_area_ratio=cfg.document.min_area_ratio,
max_area_ratio=cfg.document.max_area_ratio,
max_aspect=cfg.document.max_aspect,
canny_low=cfg.document.canny_low,
canny_high=cfg.document.canny_high,
detect_width=cfg.document.detect_width,
)
samples: list[tuple[Any, Any]] = []
for frame in iter_frames(video_path, sample_fps=cfg.capture.sample_fps):
detection = detect_document(frame.image, **det_kwargs)
if detection is not None and _plausible_capture(detection):
samples.append((frame, detection))
doc_events = find_document_events(
samples,
gap_s=cfg.document.event_gap_s,
min_samples=cfg.document.event_min_samples,
)
window_records: list[dict[str, Any]] = []
best_frames: dict[int, tuple[float, Any]] = {} # window_idx -> (sharpness, frame)
for event in doc_events:
i = event.event_id
frame = event.frame
detection = event.detection
sharp = event.sharpness
def window_for(t: float) -> int | None:
for i, w in enumerate(stable_windows):
if w.t_start <= t <= w.t_end:
return i
return None
if stable_windows:
for frame in iter_frames(video_path, sample_fps=cfg.capture.sample_fps):
idx = window_for(frame.t)
if idx is None:
continue
score = sharpness_score(frame.image)
if idx not in best_frames or score > best_frames[idx][0]:
best_frames[idx] = (score, frame)
for i, window in enumerate(stable_windows):
record: dict[str, Any] = {
"window_id": i,
"t_start": round(window.t_start, 3),
"t_end": round(window.t_end, 3),
"duration_s": round(window.duration, 3),
"t_start": round(event.t_start, 3),
"t_end": round(event.t_end, 3),
"duration_s": round(event.t_end - event.t_start, 3),
"sample_count": event.sample_count,
"document_found": False,
"best_frame_t": round(frame.t, 3),
"best_frame_index": frame.index,
"sharpness": round(sharp, 1),
}
if i in best_frames:
sharp, frame = best_frames[i]
record["best_frame_t"] = round(frame.t, 3)
record["best_frame_index"] = frame.index
record["sharpness"] = round(sharp, 1)
frame_path = frames_dir / f"window_{i:03d}_full.png"
cv2.imwrite(str(frame_path), frame.image)
record["frame_path"] = str(frame_path.relative_to(out_dir))
frame_path = frames_dir / f"window_{i:03d}_full.png"
cv2.imwrite(str(frame_path), frame.image)
record["frame_path"] = str(frame_path.relative_to(out_dir))
detection = detect_document(
frame.image,
min_area_ratio=cfg.document.min_area_ratio,
canny_low=cfg.document.canny_low,
canny_high=cfg.document.canny_high,
)
if detection is not None:
crop = warp_document(frame.image, detection.quad)
if cfg.document.enhance:
crop = enhance_for_ocr(crop)
crop_path = crops_dir / f"window_{i:03d}.png"
cv2.imwrite(str(crop_path), crop)
record.update(
document_found=True,
detection_method=detection.method,
area_ratio=round(detection.area_ratio, 4),
quad=[[round(float(x), 1), round(float(y), 1)] for x, y in detection.quad],
crop_path=str(crop_path.relative_to(out_dir)),
crop_size=[crop.shape[1], crop.shape[0]],
)
crop = warp_document(frame.image, detection.quad)
refine_info: dict[str, Any] = {}
if cfg.document.refine:
refined = refine_crop(crop, remove_fingers=cfg.document.remove_fingers)
crop = refined.image
refine_info = {
"crop_tightened": refined.tightened,
"fingers_removed": refined.fingers_removed,
"skin_fraction": refined.skin_fraction,
}
rotation_applied = 0
rotation_method = "none"
if cfg.document.auto_rotate:
crop, orientation = auto_rotate(crop, lang=cfg.ocr.lang, psm=cfg.ocr.psm)
rotation_applied = orientation.rotation
rotation_method = orientation.method
if cfg.document.enhance:
crop = enhance_for_ocr(crop)
crop_path = crops_dir / f"window_{i:03d}.png"
cv2.imwrite(str(crop_path), crop)
record.update(
document_found=True,
detection_method=detection.method,
detection_source=detection.source,
detection_confidence=round(detection.confidence, 3),
area_ratio=round(detection.area_ratio, 4),
quad=[[round(float(x), 1), round(float(y), 1)] for x, y in detection.quad],
rotation_applied=rotation_applied,
rotation_method=rotation_method,
crop_path=str(crop_path.relative_to(out_dir)),
crop_size=[crop.shape[1], crop.shape[0]],
**refine_info,
)
window_records.append(record)
report = {
@ -166,6 +197,15 @@ def run_detection(video_path: str | Path, cfg: Config, out_dir: str | Path) -> d
return report
def _is_exportable(ocr) -> bool:
"""Skip obvious mat-transition junk that has no usable naming signal."""
if ocr.form_code or ocr.date or ocr.total:
return True
if extract_store_name(ocr.text):
return True
return False
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.
@ -192,8 +232,13 @@ def run_export(report: dict[str, Any], cfg: Config, out_dir: str | Path) -> dict
except FileNotFoundError:
recording_start = datetime.now() - timedelta(seconds=duration_s)
use_llm = cfg.llm.enabled and ollama_available(cfg.llm.base_url)
if cfg.llm.enabled and not use_llm:
print("Ollama not reachable — falling back to Tesseract-only naming.")
used_stems: set[str] = set()
rows: list[dict[str, Any]] = []
prev_signature: tuple = ()
for window in report["stable_windows"]:
if not window.get("document_found"):
@ -204,15 +249,70 @@ def run_export(report: dict[str, Any], cfg: Config, out_dir: str | Path) -> dict
if image is None:
continue
# Always OCR the illumination-normalized version, even if the saved
# crop/PDF itself wasn't enhanced (document.enhance only controls the
# saved image's appearance) — this is pure naming-accuracy upside.
# Skip re-normalizing if the crop was already enhanced at detect time
# (cfg.document.enhance) to avoid dividing an already-flat image by
# its own blurred background twice.
ocr_image = image if cfg.document.enhance else enhance_for_ocr(image)
ocr = run_ocr(
image,
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,
)
# Local vision LLM (Ollama): reads the whole document at once, so it
# names curled receipts and hand-occluded captures that defeat
# Tesseract. Its fields take priority; OCR heuristics fill gaps.
llm = None
if use_llm:
llm = analyze_document(
image,
model=cfg.llm.model,
base_url=cfg.llm.base_url,
timeout=cfg.llm.timeout_s,
)
# Blank pages (backs of slips, empty sheets) OCR to garbled noise;
# the LLM identifies them reliably, so drop them outright.
if llm is not None and (
llm.doc_type == "blank" or llm.description.lower().startswith("blank")
):
continue
vendor = (llm.vendor if llm else None) or ocr.vendor
date = (llm.date if llm else None) or ocr.date
total = (llm.total if llm else None) or ocr.total
form_code = (llm.form_code if llm else None) or ocr.form_code
tax_year = (llm.tax_year if llm else None) or ocr.tax_year
org_name = (llm.vendor if llm and llm.form_code else None) or ocr.org_name
if llm is None and not _is_exportable(ocr):
continue
if llm is not None and not any((vendor, date, total, form_code)):
continue
# The same physical document sometimes splits into two capture
# events (hand briefly breaks the contour). If the previous export
# has the identical identity signature, it's the same document.
signature = (vendor, date, total, form_code)
if any(signature) and signature == prev_signature:
continue
prev_signature = signature
event_time = recording_start + timedelta(seconds=window["t_start"])
naming = build_name(ocr.date, ocr.vendor, event_time, cfg.naming.max_vendor_len)
naming = build_name(
date,
vendor,
event_time,
cfg.naming.max_vendor_len,
form_code=form_code,
tax_year=tax_year,
org_name=org_name,
)
stem = dedupe_stem(naming.stem, used_stems)
pdf_path = pdf_dir / f"{stem}.pdf"
@ -223,11 +323,16 @@ def run_export(report: dict[str, Any], cfg: Config, out_dir: str | Path) -> dict
"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 "",
"source_of_name": "llm" if llm else naming.source,
"ocr_vendor": vendor or "",
"ocr_form_code": form_code or "",
"ocr_date": date or "",
"ocr_total": total or "",
"ocr_confidence": round(ocr.mean_confidence, 1),
"llm_description": llm.description if llm else "",
"llm_issues": ";".join(llm.issues) if llm else "",
"detection_confidence": window.get("detection_confidence", ""),
"rotation_applied": window.get("rotation_applied", 0),
"final_filename": pdf_path.name,
"pdf_path": str(pdf_path.relative_to(out_dir)),
}
@ -236,7 +341,9 @@ def run_export(report: dict[str, Any], cfg: Config, out_dir: str | Path) -> dict
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",
"ocr_vendor", "ocr_form_code", "ocr_date", "ocr_total", "ocr_confidence",
"llm_description", "llm_issues",
"detection_confidence", "rotation_applied",
"final_filename", "pdf_path",
]
writer = csv.DictWriter(f, fieldnames=fieldnames)

View File

@ -0,0 +1,124 @@
"""Group per-frame document detections into capture events.
Stable-window-only detection misses documents shown while hands are still
adjusting them motion scores stay above threshold even though a receipt
is clearly visible and detectable frame-by-frame. This clusters consecutive
frames where a document contour was found (with a time-gap to split separate
placements) and keeps the sharpest frame from each cluster.
"""
from __future__ import annotations
from dataclasses import dataclass
import numpy as np
from paperpod.capture.video import Frame
from paperpod.vision.document import DocumentDetection
from paperpod.vision.sharpness import sharpness_score
@dataclass
class DocumentEvent:
event_id: int
t_start: float
t_end: float
sample_count: int
best_frame_t: float
best_frame_index: int
sharpness: float
frame: Frame
detection: DocumentDetection
def _same_document(
prev: DocumentDetection,
curr: DocumentDetection,
frame_shape: tuple[int, ...],
area_tolerance: float = 0.12,
centroid_tolerance: float = 0.12,
) -> bool:
"""True if two detections likely belong to the same physical document.
On a black mat, the empty table is often detected as a large contour on
almost every frame, which bridges separate receipts/forms into one giant
cluster if we only use time gaps. A sudden change in area or position
means a new document was placed (Costco receipt area ~0.26 vs mat ~0.65).
"""
if abs(prev.area_ratio - curr.area_ratio) > area_tolerance:
return False
h, w = frame_shape[:2]
prev_c = prev.quad.mean(axis=0)
curr_c = curr.quad.mean(axis=0)
if float(np.linalg.norm(prev_c - curr_c)) > centroid_tolerance * max(w, h):
return False
return True
def _plausible_capture(det: DocumentDetection) -> bool:
"""Skip mat/table false positives that sit between receipt and full-page sizes.
On a black mat, empty-table contours usually land around area_ratio
0.45-0.57. Real receipts cluster below 0.40; full tax forms above 0.58.
"""
ar = det.area_ratio
return ar <= 0.40 or ar >= 0.58
def find_document_events(
samples: list[tuple[Frame, DocumentDetection]],
gap_s: float = 2.0,
min_samples: int = 2,
) -> list[DocumentEvent]:
"""Cluster consecutive detections; split when gap_s passes with no detection.
samples must be in time order. Each sample is a (frame, detection) pair
from a frame where detect_document() succeeded.
"""
if not samples:
return []
clusters: list[tuple[float, float, int, float, Frame, DocumentDetection]] = []
active: tuple[float, float, int, float, Frame, DocumentDetection] | None = None
def flush() -> None:
nonlocal active
if active is None:
return
t_start, t_end, count, sharp, frame, det = active
if count >= min_samples:
clusters.append(active)
active = None
for frame, det in samples:
if active is None:
active = (frame.t, frame.t, 1, sharpness_score(frame.image), frame, det)
continue
t_start, t_end, count, best_sharp, best_frame, best_det = active
if frame.t - t_end > gap_s or not _same_document(best_det, det, frame.image.shape):
flush()
active = (frame.t, frame.t, 1, sharpness_score(frame.image), frame, det)
continue
sharp = sharpness_score(frame.image)
if sharp > best_sharp:
best_sharp, best_frame, best_det = sharp, frame, det
active = (t_start, frame.t, count + 1, best_sharp, best_frame, best_det)
flush()
return [
DocumentEvent(
event_id=i,
t_start=t_start,
t_end=t_end,
sample_count=count,
best_frame_t=frame.t,
best_frame_index=frame.index,
sharpness=sharp,
frame=frame,
detection=det,
)
for i, (t_start, t_end, count, sharp, frame, det) in enumerate(clusters)
]

View File

@ -1,4 +1,14 @@
"""Document/receipt contour detection and perspective correction."""
"""Document/receipt contour detection and perspective correction.
Detection runs two independent candidate generators Canny edges and Otsu
brightness thresholding because each fails on different real-world cases
(Canny struggles with low-contrast paper edges; brightness thresholding
struggles when a bright hand or lighting glare merges with the page). Every
candidate contour from both is scored on how "paper-shaped" it is (filled,
convex, plausible aspect ratio) rather than just picking whichever contour
happens to have the largest area, which was previously fooled by fused
hand+background blobs and by fragmented outlines on high-resolution frames.
"""
from __future__ import annotations
@ -13,6 +23,8 @@ class DocumentDetection:
quad: np.ndarray # 4x2 float32, corner points in source-image coordinates
area_ratio: float # contour area / frame area
method: str # "quad" (clean 4-corner polygon) | "min_rect" (fallback)
source: str # "edges" | "threshold" — which candidate generator won
confidence: float # 0-1 composite shape score (higher = more paper-like)
def _order_points(pts: np.ndarray) -> np.ndarray:
@ -31,47 +43,134 @@ def _order_points(pts: np.ndarray) -> np.ndarray:
)
def _shape_score(contour: np.ndarray, frame_area: float) -> dict | None:
area = cv2.contourArea(contour)
area_ratio = area / frame_area
x, y, w, h = cv2.boundingRect(contour)
if w == 0 or h == 0:
return None
extent = area / (w * h)
hull = cv2.convexHull(contour)
hull_area = cv2.contourArea(hull)
solidity = area / hull_area if hull_area > 0 else 0.0
aspect = max(w, h) / max(1, min(w, h))
return {
"area_ratio": area_ratio,
"extent": extent,
"solidity": solidity,
"aspect": aspect,
}
def _composite_score(
stats: dict, min_area_ratio: float, max_area_ratio: float, max_aspect: float
) -> float:
"""Higher is more "paper-like". Negative disqualifies the candidate.
A page should be a large-ish (but not whole-frame that's usually
background/lighting, not a document), filled (high extent), convex
(high solidity) rectangle. Weighted toward shape over raw size so a
smaller clean rectangle beats a larger fused hand+background blob.
"""
if stats["area_ratio"] < min_area_ratio or stats["area_ratio"] > max_area_ratio:
return -1.0
if stats["aspect"] > max_aspect:
return -1.0
size_term = min(stats["area_ratio"] / 0.5, 1.0)
return stats["extent"] * 0.45 + stats["solidity"] * 0.35 + size_term * 0.20
def _candidates(mask: np.ndarray, frame_area: float, top_n: int = 8) -> list[np.ndarray]:
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
contours = sorted(contours, key=cv2.contourArea, reverse=True)[:top_n]
return contours
def _edge_mask(gray: np.ndarray, canny_low: int, canny_high: int) -> np.ndarray:
blur = cv2.GaussianBlur(gray, (5, 5), 0)
edges = cv2.Canny(blur, canny_low, canny_high)
# Kernel scaled to image size so gaps in the outline close reliably
# regardless of source resolution (a fixed pixel kernel that works at
# 1000px wide leaves 4x-larger gaps unclosed at 4K).
k = max(3, round(min(gray.shape) * 0.01)) | 1 # odd
return cv2.dilate(edges, np.ones((k, k), np.uint8), iterations=2)
def _threshold_mask(gray: np.ndarray) -> np.ndarray:
blur = cv2.GaussianBlur(gray, (5, 5), 0)
_, mask = cv2.threshold(blur, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
k = max(3, round(min(gray.shape) * 0.01)) | 1
kernel = np.ones((k, k), np.uint8)
return cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel, iterations=2)
def _quad_from_contour(contour: np.ndarray) -> tuple[np.ndarray, str]:
peri = cv2.arcLength(contour, True)
approx = cv2.approxPolyDP(contour, 0.02 * peri, True)
if len(approx) == 4:
return _order_points(approx.reshape(4, 2)), "quad"
rect = cv2.minAreaRect(contour)
return _order_points(cv2.boxPoints(rect)), "min_rect"
def detect_document(
image_bgr: np.ndarray,
min_area_ratio: float = 0.04,
max_area_ratio: float = 0.92,
max_aspect: float = 6.0,
canny_low: int = 50,
canny_high: int = 150,
detect_width: int = 1000,
) -> DocumentDetection | None:
"""Find the largest document-like contour in the frame.
"""Find the best document-like contour in the frame.
Pipeline: grayscale -> blur -> Canny -> dilate -> contours -> largest
contour above min_area_ratio. If its polygon approximation has 4 corners
we use it directly; otherwise we fall back to the minimum-area rectangle.
Contour search runs on a copy downscaled to detect_width (edge/threshold
gap-closing kernels are sized relative to this, not the source
resolution), then the winning quad is scaled back up to source
resolution for a full-quality perspective warp.
"""
h, w = image_bgr.shape[:2]
frame_area = float(h * w)
gray = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2GRAY)
gray = cv2.GaussianBlur(gray, (5, 5), 0)
edges = cv2.Canny(gray, canny_low, canny_high)
# Close gaps in the document outline so it forms one contour.
edges = cv2.dilate(edges, np.ones((5, 5), np.uint8), iterations=2)
scale = detect_width / w if w > detect_width else 1.0
small = (
cv2.resize(image_bgr, (round(w * scale), round(h * scale)), interpolation=cv2.INTER_AREA)
if scale != 1.0
else image_bgr
)
small_area = float(small.shape[0] * small.shape[1])
gray = cv2.cvtColor(small, cv2.COLOR_BGR2GRAY)
contours, _ = cv2.findContours(edges, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
if not contours:
best: tuple[float, np.ndarray, str] | None = None # (score, contour, source)
for source, mask in (
("edges", _edge_mask(gray, canny_low, canny_high)),
("threshold", _threshold_mask(gray)),
):
for contour in _candidates(mask, small_area):
stats = _shape_score(contour, small_area)
if stats is None:
continue
score = _composite_score(stats, min_area_ratio, max_area_ratio, max_aspect)
if score < 0:
continue
if best is None or score > best[0]:
best = (score, contour, source)
if best is None:
return None
best = max(contours, key=cv2.contourArea)
area_ratio = cv2.contourArea(best) / frame_area
if area_ratio < min_area_ratio:
return None
score, contour, source = best
small_quad, method = _quad_from_contour(contour)
quad = scale_quad(small_quad, 1.0 / scale, 1.0 / scale) if scale != 1.0 else small_quad
area_ratio = cv2.contourArea(contour) / small_area
peri = cv2.arcLength(best, True)
approx = cv2.approxPolyDP(best, 0.02 * peri, True)
if len(approx) == 4:
quad = _order_points(approx.reshape(4, 2))
method = "quad"
else:
rect = cv2.minAreaRect(best)
quad = _order_points(cv2.boxPoints(rect))
method = "min_rect"
return DocumentDetection(quad=quad, area_ratio=float(area_ratio), method=method)
return DocumentDetection(
quad=quad,
area_ratio=float(area_ratio),
method=method,
source=source,
confidence=float(score),
)
def warp_document(image_bgr: np.ndarray, quad: np.ndarray) -> np.ndarray:

View File

@ -1,4 +1,12 @@
"""Optional image enhancement for OCR-readiness."""
"""Scan-style image enhancement, for OCR-readiness and optionally the saved crop.
`normalize_illumination` is the "flatten it, make it easier to read" trick
scanner apps like CamScanner use: divide the image by a heavily-blurred copy
of itself (an estimate of the local lighting/shadow), which cancels out
gradients, glare, and wood-grain/shadow texture far better than a plain
contrast boost. Confirmed on a real low-contrast receipt-on-wood-grain photo:
raw OCR read almost nothing, normalized OCR recovered full lines of text.
"""
from __future__ import annotations
@ -6,15 +14,25 @@ import cv2
import numpy as np
def enhance_for_ocr(image_bgr: np.ndarray) -> np.ndarray:
"""Boost local contrast (CLAHE on the luminance channel).
def normalize_illumination(image_bgr: np.ndarray, blur_fraction: float = 1 / 15) -> np.ndarray:
"""Cancel out uneven lighting/shadow/background texture. Returns grayscale."""
gray = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2GRAY)
sigma = max(image_bgr.shape[1] * blur_fraction, 10.0)
background = cv2.GaussianBlur(gray, (0, 0), sigmaX=sigma)
# Background approaches white; dividing pulls the true page background
# up to ~255 wherever lighting made it dim, while text (locally much
# darker than its surroundings) stays dark.
return cv2.divide(gray, background, scale=255)
Deliberately conservative: no binarization, so the crop stays pleasant to
read in the PDF while giving OCR more to work with. Thresholding can be
added behind a config flag later if OCR needs it.
def enhance_for_ocr(image_bgr: np.ndarray) -> np.ndarray:
"""Illumination-normalize + local contrast boost. Returns a BGR image.
Always grayscale-looking (3 identical channels) since normalization
operates on luminance. Intended primarily as an OCR preprocessing step;
only baked into the saved crop/PDF when document.enhance is enabled.
"""
lab = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2LAB)
l_chan, a_chan, b_chan = cv2.split(lab)
normalized = normalize_illumination(image_bgr)
clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
lab = cv2.merge((clahe.apply(l_chan), a_chan, b_chan))
return cv2.cvtColor(lab, cv2.COLOR_LAB2BGR)
boosted = clahe.apply(normalized)
return cv2.cvtColor(boosted, cv2.COLOR_GRAY2BGR)

110
paperpod/vision/orient.py Normal file
View File

@ -0,0 +1,110 @@
"""Auto-rotate a document crop to right-side-up before OCR/PDF export.
Handles the "I placed it upside down" case that broke naming on the real T4/
T4A test recordings: images looked fine, but OCR read garbage because the
form was rotated 180 degrees.
"""
from __future__ import annotations
from dataclasses import dataclass
import cv2
import numpy as np
import pytesseract
_ROTATIONS = {
0: None,
90: cv2.ROTATE_90_CLOCKWISE,
180: cv2.ROTATE_180,
270: cv2.ROTATE_90_COUNTERCLOCKWISE,
}
@dataclass
class OrientationResult:
rotation: int # degrees clockwise applied: 0, 90, 180, or 270
method: str # "osd" | "confidence_sweep" | "none"
def apply_rotation(image_bgr: np.ndarray, rotation: int) -> np.ndarray:
code = _ROTATIONS.get(rotation % 360)
return image_bgr if code is None else cv2.rotate(image_bgr, code)
def _osd_rotation(image_bgr: np.ndarray, lang: str) -> int | None:
"""Tesseract's orientation-and-script-detection: fast and purpose-built.
Raises on pages with too little recognizable text (e.g. a mostly-blank
or badly-cropped receipt sliver); callers should fall back in that case.
Note: OSD's own confidence scores run on a small, model-dependent scale
(values well under 10 are common even when correct) so we trust any
result it returns rather than gating on a confidence threshold.
"""
try:
osd = pytesseract.image_to_osd(
image_bgr, lang=lang, output_type=pytesseract.Output.DICT
)
except pytesseract.TesseractError:
return None
return int(osd.get("rotate", 0)) % 360
def _confidence_sweep_rotation(
image_bgr: np.ndarray, lang: str, psm: int, min_words: int = 3, min_word_len: int = 3
) -> int:
"""Fallback: OCR all 4 rotations, keep the one with the most confident text.
Used when OSD can't commit (too little text). Scores by *total*
confidence summed over recognized words at least min_word_len long.
Both pieces matter and were tuned against real failures, not guessed:
- Summing (not averaging) confidence: on noisy real-world photos
(worn thermal print, low-contrast background), mean confidence
stays within a similar narrow band at every rotation, but the
correct orientation recognizes many more words than a sideways one.
Mean-confidence-only picked a 90-degrees-off rotation (9 words) over
the correct one (23 words) on a real low-contrast receipt.
- Filtering to words >= min_word_len: sideways/upside-down *clean*
synthetic text tends to fragment into dozens of single-character
glyph artifacts that Tesseract reports with deceptively high
individual confidence, which otherwise wins the sum outright.
Requiring 3+ characters drops almost all of that noise while barely
touching real multi-character words.
"""
best_rotation = 0
best_score = -1.0
for rotation in (0, 90, 180, 270):
candidate = apply_rotation(image_bgr, rotation)
data = pytesseract.image_to_data(
candidate, lang=lang, config=f"--psm {psm}", output_type=pytesseract.Output.DICT
)
confidences = [
float(c)
for c, t in zip(data["conf"], data["text"])
if c not in ("-1", -1) and len(t.strip()) >= min_word_len
]
if len(confidences) < min_words:
continue
total_conf = sum(confidences)
if total_conf > best_score:
best_score = total_conf
best_rotation = rotation
return best_rotation
def detect_orientation(image_bgr: np.ndarray, lang: str = "eng", psm: int = 6) -> OrientationResult:
"""Figure out how many degrees clockwise to rotate image_bgr to be upright."""
rotation = _osd_rotation(image_bgr, lang)
if rotation is not None:
return OrientationResult(rotation=rotation, method="osd")
rotation = _confidence_sweep_rotation(image_bgr, lang, psm)
return OrientationResult(rotation=rotation, method="confidence_sweep")
def auto_rotate(
image_bgr: np.ndarray, lang: str = "eng", psm: int = 6
) -> tuple[np.ndarray, OrientationResult]:
"""Detect and correct orientation. Returns (rotated_image, result)."""
result = detect_orientation(image_bgr, lang=lang, psm=psm)
return apply_rotation(image_bgr, result.rotation), result

169
paperpod/vision/refine.py Normal file
View File

@ -0,0 +1,169 @@
"""Post-warp crop refinement: tighten to the paper region and remove fingers.
The initial contour crop often includes a margin of mat/table and any hand
holding the receipt (thermal receipts curl, so people press them flat).
This module:
1. builds a paper mask (bright pixels that are NOT skin-colored),
2. tightens the crop to the rows/columns that actually contain paper,
3. optionally inpaints skin regions so fingers disappear from the output.
Skin detection uses the classic YCrCb range, which cleanly separates skin
from both white paper (low Cr) and the black mat.
"""
from __future__ import annotations
from dataclasses import dataclass
import cv2
import numpy as np
_SKIN_LOW = (0, 133, 77)
_SKIN_HIGH = (255, 178, 127)
@dataclass
class RefineResult:
image: np.ndarray
tightened: bool # crop bounds were shrunk to the paper band
fingers_removed: bool # skin regions were inpainted
skin_fraction: float # fraction of the crop that looked like skin
def skin_mask(image_bgr: np.ndarray) -> np.ndarray:
"""Binary mask (255 = skin-colored) with small speckles removed."""
ycrcb = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2YCrCb)
mask = cv2.inRange(ycrcb, _SKIN_LOW, _SKIN_HIGH)
return cv2.morphologyEx(mask, cv2.MORPH_OPEN, np.ones((9, 9), np.uint8))
def _border_connected(mask: np.ndarray) -> np.ndarray:
"""Keep only mask components touching the image border.
Fingers always reach in from an edge; skin-colored false positives in
the middle of the page (beige logos, tinted paper) do not, and
inpainting those would smear real content.
"""
n, labels, stats, _ = cv2.connectedComponentsWithStats(mask, connectivity=8)
h, w = mask.shape
keep = np.zeros_like(mask)
for i in range(1, n):
x, y, bw, bh, _area = stats[i]
if x == 0 or y == 0 or x + bw >= w or y + bh >= h:
keep[labels == i] = 255
return keep
def _paper_mask(image_bgr: np.ndarray, skin: np.ndarray) -> np.ndarray:
gray = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2GRAY)
blur = cv2.GaussianBlur(gray, (7, 7), 0)
_, bright = cv2.threshold(blur, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
skin_dilated = cv2.dilate(skin, np.ones((15, 15), np.uint8), iterations=1)
paper = cv2.bitwise_and(bright, cv2.bitwise_not(skin_dilated))
return cv2.morphologyEx(paper, cv2.MORPH_OPEN, np.ones((11, 11), np.uint8), iterations=2)
def _density_band(
density: np.ndarray, threshold: float, gap_fraction: float = 0.15
) -> tuple[int, int] | None:
"""Longest contiguous run of rows/columns above threshold.
First-to-last-above-threshold would bridge across gaps: a bright strip
of wood grain at the far edge of the frame can extend the band across
the entire (dark) mat, keeping all the background in the crop.
Gaps up to gap_fraction of the axis length are closed first, so dark
printed regions *on* the paper (a promo banner on a receipt, a filled
table header) don't split the paper band in two.
"""
above = density > threshold
if not above.any():
return None
# 1-D morphological closing: dilate then erode with the gap window.
gap = max(1, int(len(density) * gap_fraction))
kernel = np.ones(gap, dtype=bool)
closed = np.convolve(above, kernel, mode="same") > 0 # dilate
eroded = np.convolve(~closed, kernel, mode="same") == 0 # erode
above = eroded if eroded.any() else above
best_start, best_len = 0, 0
start = None
for i, flag in enumerate(np.append(above, False)):
if flag and start is None:
start = i
elif not flag and start is not None:
if i - start > best_len:
best_start, best_len = start, i - start
start = None
return best_start, best_start + best_len - 1
def refine_crop(
image_bgr: np.ndarray,
tighten: bool = True,
remove_fingers: bool = True,
density_threshold: float = 0.25,
min_skin_fraction: float = 0.01,
inpaint_radius: int = 15,
inpaint_max_width: int = 1200,
) -> RefineResult:
"""Tighten a warped document crop to its paper band and erase fingers.
tighten: cut rows/columns whose paper coverage is below
density_threshold (removes mat margins and the hand hanging
off the side of a receipt).
remove_fingers: inpaint border-connected skin blobs. Content under a
finger is unrecoverable inpainting fills it with plausible
paper texture but margins/blank areas clean up completely.
"""
skin = skin_mask(image_bgr)
skin_fraction = float((skin > 0).mean())
result = image_bgr
tightened = False
if tighten:
paper = _paper_mask(image_bgr, skin)
h, w = paper.shape
col_band = _density_band(paper.sum(axis=0) / (255.0 * h), density_threshold)
row_band = _density_band(paper.sum(axis=1) / (255.0 * w), density_threshold)
if col_band and row_band:
x0, x1 = col_band
y0, y1 = row_band
# Only shrink, never grow; skip degenerate bands.
if (x1 - x0) > w * 0.2 and (y1 - y0) > h * 0.2:
if x0 > 0 or x1 < w - 1 or y0 > 0 or y1 < h - 1:
result = result[y0 : y1 + 1, x0 : x1 + 1]
skin = skin[y0 : y1 + 1, x0 : x1 + 1]
tightened = True
fingers_removed = False
if remove_fingers and skin_fraction >= min_skin_fraction:
fingers = _border_connected(skin)
if (fingers > 0).any():
fingers = cv2.dilate(fingers, np.ones((25, 25), np.uint8), iterations=1)
# Inpaint at reduced resolution: cv2.inpaint is O(radius * area)
# and full-res 4K crops take tens of seconds for no visible gain.
h, w = result.shape[:2]
if w > inpaint_max_width:
scale = inpaint_max_width / w
small = cv2.resize(result, (inpaint_max_width, round(h * scale)))
small_mask = cv2.resize(
fingers, (small.shape[1], small.shape[0]), interpolation=cv2.INTER_NEAREST
)
inpainted = cv2.inpaint(small, small_mask, inpaint_radius, cv2.INPAINT_TELEA)
inpainted = cv2.resize(inpainted, (w, h))
# Blend: keep original pixels outside the finger mask so
# only the finger region loses resolution.
mask3 = cv2.cvtColor(fingers, cv2.COLOR_GRAY2BGR) > 0
result = np.where(mask3, inpainted, result)
else:
result = cv2.inpaint(result, fingers, inpaint_radius, cv2.INPAINT_TELEA)
fingers_removed = True
return RefineResult(
image=result,
tightened=tightened,
fingers_removed=fingers_removed,
skin_fraction=round(skin_fraction, 4),
)

View File

@ -0,0 +1,68 @@
from paperpod.capture.video import Frame
from paperpod.vision.capture_events import find_document_events
from paperpod.vision.document import DocumentDetection
import numpy as np
def _frame(t: float, idx: int = 0) -> Frame:
return Frame(index=idx, t=t, image=np.zeros((100, 100, 3), dtype=np.uint8))
def _det() -> DocumentDetection:
quad = np.array([[0, 0], [1, 0], [1, 1], [0, 1]], dtype=np.float32)
return DocumentDetection(quad=quad, area_ratio=0.3, method="quad", source="edges", confidence=0.8)
def test_single_event_from_consecutive_detections():
samples = [(_frame(1.0, 0), _det()), (_frame(1.2, 1), _det()), (_frame(1.4, 2), _det())]
events = find_document_events(samples, gap_s=2.0, min_samples=2)
assert len(events) == 1
assert events[0].t_start == 1.0
assert events[0].sample_count == 3
def test_gap_splits_separate_documents():
samples = [
(_frame(1.0, 0), _det()),
(_frame(1.2, 1), _det()),
(_frame(5.0, 2), _det()),
(_frame(5.2, 3), _det()),
]
events = find_document_events(samples, gap_s=2.0, min_samples=2)
assert len(events) == 2
assert events[0].t_end == 1.2
assert events[1].t_start == 5.0
def test_min_samples_filters_single_frame_noise():
samples = [(_frame(1.0, 0), _det()), (_frame(5.0, 1), _det()), (_frame(5.2, 2), _det())]
events = find_document_events(samples, gap_s=2.0, min_samples=2)
assert len(events) == 1
assert events[0].t_start == 5.0
def test_area_change_splits_documents():
small = DocumentDetection(
quad=np.array([[0, 0], [1, 0], [1, 1], [0, 1]], dtype=np.float32),
area_ratio=0.25,
method="quad",
source="threshold",
confidence=0.8,
)
large = DocumentDetection(
quad=np.array([[0, 0], [1, 0], [1, 1], [0, 1]], dtype=np.float32),
area_ratio=0.65,
method="quad",
source="threshold",
confidence=0.8,
)
samples = [
(_frame(1.0, 0), small),
(_frame(1.2, 1), small),
(_frame(1.4, 2), large),
(_frame(1.6, 3), large),
]
events = find_document_events(samples, gap_s=2.0, min_samples=2)
assert len(events) == 2
assert events[0].detection.area_ratio == 0.25
assert events[1].detection.area_ratio == 0.65

View File

@ -6,6 +6,10 @@ def test_defaults_without_file():
assert cfg.capture.sample_fps == 8.0
assert cfg.motion.threshold == 0.02
assert cfg.motion.pixel_threshold == 12
assert cfg.document.detect_width == 1000
assert cfg.document.max_area_ratio == 0.92
assert cfg.document.max_aspect == 6.0
assert cfg.document.auto_rotate is True
def test_partial_override(tmp_path):

View File

@ -47,3 +47,33 @@ def test_sharpness_prefers_sharp_frame():
sharp = synthetic_frame()
blurred = cv2.GaussianBlur(sharp, (31, 31), 0)
assert sharpness_score(sharp) > sharpness_score(blurred)
def test_detection_reports_source_and_confidence():
det = detect_document(synthetic_frame())
assert det.source in ("edges", "threshold")
assert 0.0 < det.confidence <= 1.0
def test_rejects_thin_sliver_candidate():
"""A narrow strip (e.g. a barcode edge) shouldn't be mistaken for a page."""
frame = np.full((720, 1280, 3), (60, 90, 120), dtype=np.uint8)
# 30x600 sliver: aspect ratio 20:1, well past max_aspect.
frame[60:660, 600:630] = 245
assert detect_document(frame, max_aspect=6.0) is None
def test_rejects_near_whole_frame_candidate():
"""A candidate covering almost the entire frame is probably background/lighting."""
frame = np.full((720, 1280, 3), 245, dtype=np.uint8)
frame[10:20, 10:20] = (60, 90, 120) # tiny dark speck so it's not perfectly blank
assert detect_document(frame, max_area_ratio=0.92) is None
def test_downscaled_detection_matches_full_resolution():
"""detect_width downscaling shouldn't change which document gets found."""
frame = synthetic_frame()
det_full = detect_document(frame, detect_width=10_000) # effectively no downscale
det_small = detect_document(frame, detect_width=640)
assert det_full is not None and det_small is not None
assert abs(det_full.area_ratio - det_small.area_ratio) < 0.05

37
tests/test_enhance.py Normal file
View File

@ -0,0 +1,37 @@
import cv2
import numpy as np
from paperpod.vision.enhance import enhance_for_ocr, normalize_illumination
def _shaded_page() -> np.ndarray:
"""White page with a synthetic lighting gradient (bright left, dim right)."""
page = np.full((300, 400, 3), 255, dtype=np.uint8)
gradient = np.tile(np.linspace(1.0, 0.4, 400), (300, 1))
for c in range(3):
page[:, :, c] = (page[:, :, c] * gradient).astype(np.uint8)
return page
def test_normalize_illumination_flattens_gradient():
page = _shaded_page()
normalized = normalize_illumination(page)
# Brightness across the page should become far more uniform.
raw_gray = cv2.cvtColor(page, cv2.COLOR_BGR2GRAY)
assert normalized.std() < raw_gray.std()
def test_normalize_illumination_returns_grayscale_shape():
page = _shaded_page()
normalized = normalize_illumination(page)
assert normalized.ndim == 2
assert normalized.shape == page.shape[:2]
def test_enhance_for_ocr_returns_bgr():
page = _shaded_page()
enhanced = enhance_for_ocr(page)
assert enhanced.shape == page.shape
# Grayscale-derived: all 3 channels should be identical.
assert np.array_equal(enhanced[:, :, 0], enhanced[:, :, 1])
assert np.array_equal(enhanced[:, :, 1], enhanced[:, :, 2])

View File

@ -46,6 +46,22 @@ def test_build_name_nothing_found():
assert result.source == "none"
def test_build_name_tax_form_with_year_and_org():
dt = datetime(2026, 1, 1, 12, 0, 0)
result = build_name(
None, "T4 YORK UNIVERSITY", dt,
form_code="T4", tax_year="2023", org_name="YORK UNIVERSITY",
)
assert result.stem == "2023_T4_york_university"
assert result.source == "ocr"
def test_build_name_tax_form_year_only():
dt = datetime(2026, 1, 1, 12, 0, 0)
result = build_name(None, "T4", dt, form_code="T4", tax_year="2023")
assert result.stem == "2023_T4"
def test_dedupe_stem_no_collision():
used: set[str] = set()
assert dedupe_stem("2023-03-14_metro", used) == "2023-03-14_metro"

View File

@ -5,7 +5,7 @@ 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
from paperpod.ocr.extract import extract_date, extract_form_code, extract_org_name, extract_store_name, extract_tax_year, extract_total
def test_extract_date_iso():
@ -53,3 +53,68 @@ def test_extract_total_amount_on_next_line():
def test_extract_total_none_when_absent():
assert extract_total("no totals here") is None
def test_extract_form_code_t4():
text = "Employer's name\nYORK UNIVERSITY\nT4\nStatement of Remuneration Paid"
assert extract_form_code(text) == "T4"
def test_extract_form_code_prefers_more_specific_match():
# T4A should win over the more generic T4 substring it contains.
text = "Payer's name\nT4A\nStatement of Pension, Retirement, Annuity"
assert extract_form_code(text) == "T4A"
def test_extract_form_code_t5008():
text = "BOX 14:\nT5008\nSUMMARY OF SECURITY DISPOSITIONS 2023"
assert extract_form_code(text) == "T5008"
def test_extract_form_code_none_for_receipt():
assert extract_form_code("WALMART\nSUBTOTAL 21.97\nTOTAL 23.71") is None
def test_extract_org_name_finds_employer():
text = (
"Employer's name\nYORK UNIVERSITY Year 2023 Statement of Remuneration Paid\n"
"4700 KEELE STREET\nTORONTO ON M3J 1P3"
)
assert extract_org_name(text) == "YORK UNIVERSITY"
def test_extract_org_name_skips_addresses():
text = "MR ILIA DOBKIN\n153 NIAGARA DR\nOSHAWA ON L1G 8A6"
assert extract_org_name(text) == "MR ILIA DOBKIN"
def test_extract_org_name_skips_boilerplate_and_barcodes():
text = (
"JTA9500902-0301613-26631-0004-0004-00-\n"
"Canada Revenue Agence du revenu\n"
"Agency du Canada\n"
"AMC ENTERTAINMENT HOLDINGS INC AMC PRFRD EQT"
)
assert extract_org_name(text) == "AMC ENTERTAINMENT HOLDINGS INC AMC"
def test_extract_org_name_none_when_nothing_plausible():
assert extract_org_name("Canada Revenue Agency\nStatement of Remuneration Paid") is None
def test_extract_tax_year_from_t4():
text = "YORK UNIVERSITY Year 2023 Statement of Remuneration Paid"
assert extract_tax_year(text) == "2023"
def test_extract_tax_year_from_t5008_title():
text = "T5008\nSUMMARY OF SECURITY DISPOSITIONS 2023"
assert extract_tax_year(text) == "2023"
def test_extract_store_name_costco():
assert extract_store_name("COSTCO WHOLESALE\nVaughan #547") == "Costco"
def test_extract_store_name_walmart():
assert extract_store_name("HOW DID WE DO TODAY?\nWalmart") == "Walmart"

77
tests/test_orient.py Normal file
View File

@ -0,0 +1,77 @@
"""End-to-end orientation-detection tests against synthetic text pages.
Skipped automatically if the tesseract binary isn't installed.
"""
from __future__ import annotations
import shutil
import cv2
import numpy as np
import pytest
from PIL import Image, ImageDraw, ImageFont
from paperpod.vision.orient import apply_rotation, auto_rotate
pytestmark = pytest.mark.skipif(
shutil.which("tesseract") is None, reason="tesseract binary not installed"
)
_FONT_CANDIDATES = [
"/System/Library/Fonts/Helvetica.ttc",
"/System/Library/Fonts/Supplemental/Arial.ttf",
]
def _font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
for path in _FONT_CANDIDATES:
try:
return ImageFont.truetype(path, size)
except OSError:
continue
return ImageFont.load_default()
def _text_page() -> np.ndarray:
"""A tall white page with a few lines of real words (upright, 0 degrees).
Rendered with a real TrueType font (not cv2.putText's blocky stroke
font), which produces normal glyph shapes; cv2.putText text fragments
into dozens of spurious single-character "words" when rotated sideways,
which isn't representative of real documents/photos.
"""
img = Image.new("RGB", (400, 600), (255, 255, 255))
draw = ImageDraw.Draw(img)
font = _font(22)
lines = [
"INVOICE FROM ACME CORP",
"Date: January 5, 2024",
"Description of services rendered",
"Thank you for your business",
"Please remit payment within 30 days",
]
for i, line in enumerate(lines):
draw.text((20, 80 + i * 60), line, fill=(0, 0, 0), font=font)
return cv2.cvtColor(np.array(img), cv2.COLOR_RGB2BGR)
@pytest.mark.parametrize("applied_rotation", [0, 90, 180, 270])
def test_auto_rotate_corrects_known_rotation(applied_rotation):
"""Rotating upright text by N clockwise needs (360-N) clockwise to undo."""
upright = _text_page()
rotated_input = apply_rotation(upright, applied_rotation)
corrected, result = auto_rotate(rotated_input)
assert result.rotation == (360 - applied_rotation) % 360
assert corrected.shape == upright.shape
def test_apply_rotation_identity_at_zero():
page = _text_page()
assert np.array_equal(apply_rotation(page, 0), page)
def test_apply_rotation_180_is_involutive():
page = _text_page()
twice = apply_rotation(apply_rotation(page, 180), 180)
assert np.array_equal(twice, page)

73
tests/test_refine.py Normal file
View File

@ -0,0 +1,73 @@
import cv2
import numpy as np
from paperpod.vision.refine import _density_band, refine_crop, skin_mask
def _crop_with_margin() -> np.ndarray:
"""White paper occupying the left 60% of a dark-mat crop."""
img = np.full((600, 500, 3), (60, 60, 60), dtype=np.uint8)
img[20:580, 20:300] = 245
return img
def _skin_patch(img: np.ndarray, y0: int, y1: int, x0: int, x1: int) -> None:
# BGR value squarely inside the YCrCb skin range.
img[y0:y1, x0:x1] = (120, 160, 210)
def test_tightens_to_paper_band():
result = refine_crop(_crop_with_margin(), remove_fingers=False)
assert result.tightened
h, w = result.image.shape[:2]
assert w < 350 # dark right margin removed
assert result.image.mean() > 150 # mostly paper now
def test_no_tighten_when_paper_fills_crop():
img = np.full((600, 500, 3), 245, dtype=np.uint8)
result = refine_crop(img, remove_fingers=False)
assert result.image.shape == img.shape
def test_skin_mask_detects_skin_tone():
img = _crop_with_margin()
_skin_patch(img, 0, 150, 350, 500)
mask = skin_mask(img)
assert (mask[50, 400] > 0) and (mask[300, 100] == 0)
def test_fingers_inpainted_when_touching_border():
img = _crop_with_margin()
_skin_patch(img, 0, 120, 100, 220) # finger reaching in from the top edge
result = refine_crop(img, tighten=False, remove_fingers=True)
assert result.fingers_removed
# Skin tone should be gone from the finger region.
assert skin_mask(result.image).sum() < skin_mask(img).sum() * 0.2
def test_interior_skin_tone_not_inpainted():
"""A skin-colored logo in the middle of the page must not be smeared."""
img = np.full((600, 500, 3), 245, dtype=np.uint8)
_skin_patch(img, 250, 300, 200, 260)
result = refine_crop(img, tighten=False, remove_fingers=True)
assert not result.fingers_removed
def test_density_band_ignores_far_edge_strip():
# Paper columns 0-59, gap, thin bright strip at the far edge.
density = np.zeros(100)
density[0:60] = 0.9
density[97:100] = 0.9
band = _density_band(density, threshold=0.25, gap_fraction=0.1)
assert band is not None
x0, x1 = band
assert x0 == 0 and x1 < 80
def test_density_band_bridges_dark_print_on_paper():
# Paper band with a dark printed banner (low density) in the middle.
density = np.full(100, 0.9)
density[40:48] = 0.05
band = _density_band(density, threshold=0.25, gap_fraction=0.15)
assert band == (0, 99)