Files
PaperPod/paperpod/ocr/extract.py
T
ilia 9af29dc614 Improve OCR extraction for pale dot-matrix statements
OCR enhancement chain (illumination normalize, CLAHE, gamma darkening) now always runs before Tesseract, with a conditional 1.5x upscale for narrow crops. Adds printed doc-title and PAGE X OF Y marker extraction.
2026-07-26 15:37:37 -04:00

552 lines
21 KiB
Python

"""OCR fallback naming: pull vendor / date / total out of a document crop.
Used when no spoken description is available (module 4, not built yet).
Tesseract (via pytesseract) does the character recognition; everything else
here is regex/heuristics tuned for North American receipts and letters.
"""
from __future__ import annotations
import re
import statistics
from dataclasses import dataclass
from datetime import datetime
import cv2
import numpy as np
import pytesseract
MONTHS = {
"jan": 1, "january": 1, "feb": 2, "february": 2, "mar": 3, "march": 3,
"apr": 4, "april": 4, "may": 5, "jun": 6, "june": 6, "jul": 7, "july": 7,
"aug": 8, "august": 8, "sep": 9, "sept": 9, "september": 9, "oct": 10,
"october": 10, "nov": 11, "november": 11, "dec": 12, "december": 12,
}
# Ordered most- to least-specific; first match wins. Slash dates tolerate a
# single OCR-injected space after each separator ("01/ 31/ 2025" on TD
# dot-matrix statements).
_DATE_PATTERNS: list[tuple[re.Pattern, str]] = [
# Statement period end ("PERIOD: FROM 01/01/2025 TO: 01/31/2025") — the
# canonical document date for a bank statement, and checked first so the
# first transaction row's date doesn't win.
(
re.compile(
r"\b(?:TO|AU)\s*:?\s+(\d{1,2})\s?/\s?(\d{1,2})\s?/\s?(\d{4})\b",
re.IGNORECASE,
),
"mdy",
),
(re.compile(r"\b(\d{4})[-/](\d{1,2})[-/](\d{1,2})\b"), "ymd"),
(
re.compile(
r"\b(jan|feb|mar|apr|may|jun|jul|aug|sep|sept|oct|nov|dec)"
r"[a-z]*\.?\s+(\d{1,2})[.,]?\s+(\d{4})\b",
re.IGNORECASE,
),
"month_name_dmy",
),
# Assumes North American MM/DD/YYYY (matches Home Depot / Metro / Petro-Canada
# style receipts this app was designed around). Revisit if you record
# receipts using DD/MM/YYYY formatting.
(re.compile(r"\b(\d{1,2})\s?/\s?(\d{1,2})\s?/\s?(\d{4})\b"), "mdy"),
(re.compile(r"\b(\d{1,2})-(\d{1,2})-(\d{4})\b"), "mdy"),
]
# OCR misreads the digit 0 as the letter O in dotted print ("O1/ 31/2025").
_O_BEFORE_DIGIT_RE = re.compile(r"[Oo](?=\d)")
_O_AFTER_DIGIT_RE = re.compile(r"(?<=\d)[Oo]")
_TOTAL_LINE_RE = re.compile(r"\btotal\b", re.IGNORECASE)
_MONEY_RE = re.compile(r"\$?\s*(\d{1,4}\.\d{2})\b")
# Line items: "DESC 12.34" / "1234567 DESC 12.34 H" (Costco tax code).
# Totals / tender / card boilerplate intentionally excluded so callers can
# itemize SKUs for split booking (kids vs home, etc.) in the taxes pipeline.
_ITEM_LINE_RE = re.compile(
r"^(?:(?:\d{4,14})\s+)?(.+?)\s+\$?\s*(\d{1,4}\.\d{2})\s*[A-Z]?\s*$",
re.IGNORECASE,
)
_ITEM_SKIP_RE = re.compile(
r"\b("
r"sub\s*total|subtotal|total|hst|gst|pst|qst|tax|change|cash|debit|"
r"mastercard|visa|amex|interac|approved|thank|balance|save|"
r"account|card\s*number|auth|approval|reference|invoice|"
r"customer\s*copy|merchant\s*copy|amount\s*:|date/?time|"
r"no\s*return|signature|member|terminal|cashier"
r")\b",
re.IGNORECASE,
)
# HH:MM(:SS)? 24-hour clock, as printed near the transaction date on most
# receipts (e.g. "03/20/23 15:25:35"). Distinct enough from other
# colon-free receipt numbers (barcodes, reference IDs) that the first match
# in the document is reliably the transaction time.
_TIME_RE = re.compile(r"\b([01]?\d|2[0-3]):([0-5]\d)(?::[0-5]\d)?")
# 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,
)
# "PAGE 3 OF 17" / "PAGE 3 DE 17" (bilingual Canadian statements). Dot-matrix
# print OCRs sloppily, so the digits also admit the classic confusables
# ("PAGE ll OF 17" for 11, "0F" for OF) and stray underscores/punctuation.
_PAGE_MARKER_RE = re.compile(
r"\bPAGE\s*[:.,]?\s*([0-9lIiO]{1,3})\s*(?:[O0Q]F|DE)[_.,:]?\s+([0-9lIiO]{1,3})\b",
re.IGNORECASE,
)
_DIGIT_CONFUSABLES = str.maketrans({"l": "1", "I": "1", "i": "1", "O": "0", "o": "0"})
# Printed document titles worth carrying into the filename ("what is this?"),
# checked in order. Bank statements all say "TD Canada Trust" in the corner;
# the title line is what actually distinguishes a transaction history from a
# direct-deposit form.
_DOC_TITLE_PATTERNS: list[tuple[re.Pattern, str]] = [
(re.compile(r"DIRECT\s+DEPOSIT", re.IGNORECASE), "direct_deposit"),
(
re.compile(r"TRANSACTION\s+HISTORY|HISTORIQUE\s+DES\s+OP", re.IGNORECASE),
"transaction_history",
),
(re.compile(r"DEPOSIT\s+ACCOUNT\s+HISTORY", re.IGNORECASE), "transaction_history"),
(re.compile(r"ACCOUNT\s+STATEMENT|RELEV[EÉ]\s+DE\s+COMPTE", re.IGNORECASE), "statement"),
(re.compile(r"\bINVOICE\b|\bFACTURE\b", re.IGNORECASE), "invoice"),
(re.compile(r"NOTICE\s+OF\s+ASSESSMENT", re.IGNORECASE), "notice_of_assessment"),
(re.compile(r"VOID\s+CH(?:EQUE|ECK)", re.IGNORECASE), "void_cheque"),
]
# Letters only (Latin + accented) — used to judge whether a vendor token is
# a real word after ignoring OCR-injected punctuation ("PETRO-—CANADA").
_LETTERS_RE = re.compile(r"[^A-Za-zÀ-ÖØ-öø-ÿ]+")
_VOWELS = "aeiouyàâäéèêëîïôöùûü"
def extract_page_marker(text: str) -> tuple[int, int] | None:
"""(page_number, page_total) from a printed 'PAGE X OF Y', if present."""
match = _PAGE_MARKER_RE.search(text)
if not match:
return None
try:
page = int(match.group(1).translate(_DIGIT_CONFUSABLES))
total = int(match.group(2).translate(_DIGIT_CONFUSABLES))
except ValueError:
return None
if not (1 <= page <= total <= 500):
return None
return page, total
def extract_doc_title(text: str) -> str | None:
"""Recognized printed document title (as a filename-ready slug)."""
for pattern, slug in _DOC_TITLE_PATTERNS:
if pattern.search(text):
return slug
return None
# TD / credit-union letterheads print the account holder as SHORTNAME.
# Also match "Account name:" / "Customer:" / name-on-file style labels.
_PARTY_RE = re.compile(
r"\b(?:SHORT\s*NAME|ACCOUNT\s*NAME|CUSTOMER(?:\s*NAME)?|NAME)\s*:?\s*"
r"([A-Z][A-Z0-9 .'-]{1,40})",
re.IGNORECASE,
)
def extract_party(text: str) -> str | None:
"""Account holder / shortname printed on the document, if labeled."""
match = _PARTY_RE.search(text)
if not match:
return None
name = re.sub(r"\s+", " ", match.group(1)).strip(" .,-")
# Reject labels that ate the next field ("LEVIT I PERIOD").
name = re.split(r"\b(?:PERIOD|BR|ACCOUNT|FROM|TO|PAGE)\b", name, maxsplit=1)[0].strip()
if len(name) < 2:
return None
return name
def clean_vendor(vendor: str | None) -> str | None:
"""Strip OCR junk from a vendor line; reject it entirely if it's gibberish.
Tesseract routinely bolts noise onto real letterhead text: "| ») Transaction
History i", "7D TD Canada Trust", "+7 TD Canada Trus". Leading/trailing
tokens that aren't real words get trimmed; if fewer than half the remaining
tokens look wordlike ("BH tc rn te So Gee EA Nie..."), the whole line is
treated as noise and dropped so naming can fall back to something better.
"""
if not vendor:
return None
tokens = vendor.split()
def is_word(tok: str) -> bool:
# Judge on letters only, so hyphens/OCR dashes inside real names
# ("PETRO-—CANADA") don't disqualify the token.
core = _LETTERS_RE.sub("", tok)
return len(core) >= 3 and any(c in _VOWELS for c in core.lower())
def is_acronym(tok: str) -> bool:
# Pure 2-5 uppercase letters ("TD", "CRA") — "7D" or "»)" don't count.
return tok.isalpha() and tok.isupper() and 2 <= len(tok) <= 5
def keepable(tok: str) -> bool:
return is_word(tok) or is_acronym(tok)
while tokens and not keepable(tokens[0]):
tokens.pop(0)
while tokens and not keepable(tokens[-1]):
tokens.pop()
if not tokens:
return None
words = sum(1 for t in tokens if is_word(t))
if words < max(1, len(tokens) / 2):
return None
return " ".join(tokens)
# Real CRA slips always carry recognizable boilerplate near the code. Short
# codes like "T4" otherwise false-positive on garbled OCR of unrelated
# documents (a misread "LEVIT I" once produced a confident "T4").
_FORM_CONTEXT_RE = re.compile(
r"STATEMENT OF|ETAT D|SOMMAIRE|SUMMARY OF|CANADA REVENUE|AGENCE DU REVENU"
r"|REMUNERATION|PENSION|DISPOSITIONS|TUITION|PROTECTED B|PROT[EÉ]G[EÉ] 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())
if not match:
return None
if not _FORM_CONTEXT_RE.search(text):
return None
return match.group(1)
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:
text: str
vendor: str | None
date: str | None # normalized YYYY-MM-DD
time: str | None # normalized HH:MM (24h), e.g. "15:25"
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
doc_title: str | None = None # recognized printed title slug, e.g. "transaction_history"
party: str | None = None # account holder / SHORTNAME when labeled
page_number: int | None = None # from a printed "PAGE X OF Y"
page_total: int | None = None
line_items: list[tuple[str, str]] | None = None # (description, "12.34")
def _normalize_date(match: re.Match, kind: str) -> str | None:
try:
if kind == "ymd":
y, m, d = (int(g) for g in match.groups())
elif kind == "mdy":
m, d, y = (int(g) for g in match.groups())
elif kind == "month_name_dmy":
month_str, day_str, year_str = match.groups()
m = MONTHS.get(month_str.lower())
d, y = int(day_str), int(year_str)
if m is None:
return None
else:
return None
# Upper bound is next year, not 2100: a scanned document can't be
# dated decades in the future, and OCR misreads (2023 -> 2053)
# otherwise produce confidently wrong filenames.
if not (1 <= m <= 12 and 1 <= d <= 31 and 1990 <= y <= datetime.now().year + 1):
return None
return f"{y:04d}-{m:02d}-{d:02d}"
except (ValueError, TypeError):
return None
def extract_date(text: str) -> str | None:
"""First plausible date found in OCR text, normalized to YYYY-MM-DD."""
text = _O_BEFORE_DIGIT_RE.sub("0", text)
text = _O_AFTER_DIGIT_RE.sub("0", text)
for pattern, kind in _DATE_PATTERNS:
match = pattern.search(text)
if match:
normalized = _normalize_date(match, kind)
if normalized:
return normalized
return None
def extract_time(text: str) -> str | None:
"""First HH:MM clock time found in OCR text, normalized to zero-padded 24h."""
match = _TIME_RE.search(text)
if not match:
return None
hour, minute = int(match.group(1)), int(match.group(2))
return f"{hour:02d}:{minute:02d}"
def extract_total(text: str) -> str | None:
"""Dollar amount on a line containing "total" but not "subtotal"."""
lines = text.splitlines()
for i, line in enumerate(lines):
if not _TOTAL_LINE_RE.search(line):
continue
money = _MONEY_RE.search(line)
if not money and i + 1 < len(lines):
money = _MONEY_RE.search(lines[i + 1])
if money:
return money.group(1)
return None
def extract_line_items(text: str) -> list[tuple[str, str]]:
"""SKU lines as (description, amount_str), skipping totals/tender noise.
Amounts stay strings (same convention as extract_total). Categorization /
split booking lives in taxes `scripts/receipt_itemize.py`.
"""
out: list[tuple[str, str]] = []
for raw in text.splitlines():
line = re.sub(r"\s+", " ", raw).strip(" -\t")
if not line or len(line) < 3 or _ITEM_SKIP_RE.search(line):
continue
match = _ITEM_LINE_RE.match(line)
if not match:
continue
desc = match.group(1).strip(" -$")
if not desc or _ITEM_SKIP_RE.search(desc):
continue
if len(re.sub(r"[^A-Za-z]", "", desc)) < 2:
continue
out.append((re.sub(r"\s+", " ", desc), match.group(2)))
return out
def extract_vendor(
image_bgr: np.ndarray,
lang: str = "eng",
top_fraction: float = 0.4,
min_word_confidence: float = 40.0,
psm: int = 6,
) -> str | None:
"""Largest-font text line within the top portion of the crop.
Receipt headers and letterheads are typically both topmost and printed
larger than body text, so we group Tesseract's word boxes into lines and
pick the tallest line above the confidence floor.
"""
height = image_bgr.shape[0]
cutoff = int(height * top_fraction)
region = image_bgr[:cutoff] if cutoff > 0 else image_bgr
config = f"--psm {psm}"
data = pytesseract.image_to_data(
region, lang=lang, config=config, output_type=pytesseract.Output.DICT
)
lines: dict[tuple[int, int, int], list[int]] = {}
for i, text in enumerate(data["text"]):
if not text.strip():
continue
try:
conf = float(data["conf"][i])
except (TypeError, ValueError):
continue
if conf < min_word_confidence:
continue
key = (data["block_num"][i], data["par_num"][i], data["line_num"][i])
lines.setdefault(key, []).append(i)
if not lines:
return _first_nonblank_line(
pytesseract.image_to_string(region, lang=lang, config=config)
)
def line_stats(indices: list[int]) -> tuple[float, float]:
# Median, not max: a single descender/ascender glyph (comma, "y",
# parenthesis) can inflate one word's bounding box well above the
# line's actual font size, which would otherwise skew line selection.
height = statistics.median(data["height"][i] for i in indices)
top = min(data["top"][i] for i in indices)
return height, top
stats = {key: line_stats(idxs) for key, idxs in lines.items()}
max_height = max(h for h, _ in stats.values())
# Treat near-max heights as the same font size and break ties by
# topmost line, rather than picking whichever line measured tallest.
tolerance = max(3.0, max_height * 0.15)
candidates = [key for key, (h, _) in stats.items() if h >= max_height - tolerance]
best_key = min(candidates, key=lambda k: stats[k][1])
words = [data["text"][i].strip() for i in sorted(lines[best_key])]
vendor = " ".join(w for w in words if w)
return vendor or None
def _first_nonblank_line(text: str) -> str | None:
for line in text.splitlines():
stripped = line.strip()
if stripped:
return stripped
return None
def run_ocr(
image_bgr: np.ndarray,
lang: str = "eng",
vendor_top_fraction: float = 0.4,
min_word_confidence: float = 40.0,
psm: int = 6,
) -> OcrResult:
"""Run Tesseract once for full text, once (on a crop) for vendor detection."""
config = f"--psm {psm}"
text = pytesseract.image_to_string(image_bgr, lang=lang, config=config)
data = pytesseract.image_to_data(
image_bgr, lang=lang, config=config, output_type=pytesseract.Output.DICT
)
confidences = [float(c) for c in data["conf"] if c not in ("-1", -1)]
mean_confidence = sum(confidences) / len(confidences) if confidences else 0.0
vendor = clean_vendor(
extract_vendor(
image_bgr,
lang=lang,
top_fraction=vendor_top_fraction,
min_word_confidence=min_word_confidence,
psm=psm,
)
)
date = extract_date(text)
time = extract_time(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
page_marker = extract_page_marker(text)
line_items = extract_line_items(text) or None
return OcrResult(
text=text,
vendor=vendor,
date=date,
time=time,
total=total,
mean_confidence=mean_confidence,
form_code=form_code,
tax_year=tax_year,
org_name=org_name,
doc_title=extract_doc_title(text),
party=extract_party(text),
page_number=page_marker[0] if page_marker else None,
page_total=page_marker[1] if page_marker else None,
line_items=line_items,
)
def run_ocr_on_path(path: str, **kwargs) -> OcrResult:
image = cv2.imread(path)
if image is None:
raise ValueError(f"Could not read image: {path}")
return run_ocr(image, **kwargs)