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:
parent
2c28a623fb
commit
a43ab20df6
38
README.md
38
README.md
@ -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
|
||||
|
||||
36
config.yaml
36
config.yaml
@ -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.
|
||||
|
||||
@ -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'}")
|
||||
|
||||
@ -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
5
paperpod/llm/__init__.py
Normal 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
135
paperpod/llm/vision.py
Normal 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],
|
||||
)
|
||||
@ -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:
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
124
paperpod/vision/capture_events.py
Normal file
124
paperpod/vision/capture_events.py
Normal 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)
|
||||
]
|
||||
@ -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:
|
||||
|
||||
@ -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
110
paperpod/vision/orient.py
Normal 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
169
paperpod/vision/refine.py
Normal 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),
|
||||
)
|
||||
68
tests/test_capture_events.py
Normal file
68
tests/test_capture_events.py
Normal 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
|
||||
@ -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):
|
||||
|
||||
@ -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
37
tests/test_enhance.py
Normal 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])
|
||||
@ -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"
|
||||
|
||||
@ -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
77
tests/test_orient.py
Normal 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
73
tests/test_refine.py
Normal 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)
|
||||
Loading…
x
Reference in New Issue
Block a user