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.
This commit is contained in:
2026-07-26 15:37:37 -04:00
parent 12e5d1aeb1
commit 9af29dc614
3 changed files with 338 additions and 13 deletions
+213 -6
View File
@@ -10,6 +10,7 @@ from __future__ import annotations
import re import re
import statistics import statistics
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime
import cv2 import cv2
import numpy as np import numpy as np
@@ -22,13 +23,25 @@ MONTHS = {
"october": 10, "nov": 11, "november": 11, "dec": 12, "december": 12, "october": 10, "nov": 11, "november": 11, "dec": 12, "december": 12,
} }
# Ordered most- to least-specific; first match wins. # 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]] = [ _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(\d{4})[-/](\d{1,2})[-/](\d{1,2})\b"), "ymd"),
( (
re.compile( re.compile(
r"\b(jan|feb|mar|apr|may|jun|jul|aug|sep|sept|oct|nov|dec)" 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", r"[a-z]*\.?\s+(\d{1,2})[.,]?\s+(\d{4})\b",
re.IGNORECASE, re.IGNORECASE,
), ),
"month_name_dmy", "month_name_dmy",
@@ -36,12 +49,35 @@ _DATE_PATTERNS: list[tuple[re.Pattern, str]] = [
# Assumes North American MM/DD/YYYY (matches Home Depot / Metro / Petro-Canada # Assumes North American MM/DD/YYYY (matches Home Depot / Metro / Petro-Canada
# style receipts this app was designed around). Revisit if you record # style receipts this app was designed around). Revisit if you record
# receipts using DD/MM/YYYY formatting. # receipts using DD/MM/YYYY formatting.
(re.compile(r"\b(\d{1,2})[/-](\d{1,2})[/-](\d{4})\b"), "mdy"), (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) _TOTAL_LINE_RE = re.compile(r"\btotal\b", re.IGNORECASE)
_MONEY_RE = re.compile(r"\$?\s*(\d{1,4}\.\d{2})\b") _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 # 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 # receipts (e.g. "03/20/23 15:25:35"). Distinct enough from other
# colon-free receipt numbers (barcodes, reference IDs) that the first match # colon-free receipt numbers (barcodes, reference IDs) that the first match
@@ -90,11 +126,139 @@ _TAX_YEAR_TITLE_RE = re.compile(
re.IGNORECASE, 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: def extract_form_code(text: str) -> str | None:
"""Recognized Canadian tax slip code (T4, T4A, T5008, ...), if present.""" """Recognized Canadian tax slip code (T4, T4A, T5008, ...), if present."""
match = _FORM_CODE_RE.search(text.upper()) match = _FORM_CODE_RE.search(text.upper())
return match.group(1) if match else None 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: def extract_tax_year(text: str) -> str | None:
@@ -160,6 +324,11 @@ class OcrResult:
form_code: str | None = None # e.g. "T4", "T5008", if a known tax slip 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 tax_year: str | None = None # e.g. "2023" from "Year 2023" on a T4
org_name: str | None = None # employer/payer on tax slips 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: def _normalize_date(match: re.Match, kind: str) -> str | None:
@@ -176,7 +345,10 @@ def _normalize_date(match: re.Match, kind: str) -> str | None:
return None return None
else: else:
return None return None
if not (1 <= m <= 12 and 1 <= d <= 31 and 1990 <= y <= 2100): # 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 None
return f"{y:04d}-{m:02d}-{d:02d}" return f"{y:04d}-{m:02d}-{d:02d}"
except (ValueError, TypeError): except (ValueError, TypeError):
@@ -185,6 +357,8 @@ def _normalize_date(match: re.Match, kind: str) -> str | None:
def extract_date(text: str) -> str | None: def extract_date(text: str) -> str | None:
"""First plausible date found in OCR text, normalized to YYYY-MM-DD.""" """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: for pattern, kind in _DATE_PATTERNS:
match = pattern.search(text) match = pattern.search(text)
if match: if match:
@@ -217,6 +391,29 @@ def extract_total(text: str) -> str | None:
return None 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( def extract_vendor(
image_bgr: np.ndarray, image_bgr: np.ndarray,
lang: str = "eng", lang: str = "eng",
@@ -303,13 +500,15 @@ def run_ocr(
confidences = [float(c) for c in data["conf"] if c not in ("-1", -1)] 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 mean_confidence = sum(confidences) / len(confidences) if confidences else 0.0
vendor = extract_vendor( vendor = clean_vendor(
extract_vendor(
image_bgr, image_bgr,
lang=lang, lang=lang,
top_fraction=vendor_top_fraction, top_fraction=vendor_top_fraction,
min_word_confidence=min_word_confidence, min_word_confidence=min_word_confidence,
psm=psm, psm=psm,
) )
)
date = extract_date(text) date = extract_date(text)
time = extract_time(text) time = extract_time(text)
total = extract_total(text) total = extract_total(text)
@@ -324,6 +523,9 @@ def run_ocr(
if form_code: if form_code:
vendor = f"{form_code} {org_name}" if org_name else 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( return OcrResult(
text=text, text=text,
vendor=vendor, vendor=vendor,
@@ -334,6 +536,11 @@ def run_ocr(
form_code=form_code, form_code=form_code,
tax_year=tax_year, tax_year=tax_year,
org_name=org_name, 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,
) )
+3 -1
View File
@@ -42,7 +42,9 @@ def _downscale(image_bgr: np.ndarray, max_dim: int = 1000) -> np.ndarray:
if longest <= max_dim: if longest <= max_dim:
return image_bgr return image_bgr
scale = max_dim / longest scale = max_dim / longest
return cv2.resize(image_bgr, (round(w * scale), round(h * scale)), interpolation=cv2.INTER_AREA) new_w = max(1, round(w * scale))
new_h = max(1, round(h * scale))
return cv2.resize(image_bgr, (new_w, new_h), interpolation=cv2.INTER_AREA)
def _osd_rotation(image_bgr: np.ndarray, lang: str) -> int | None: def _osd_rotation(image_bgr: np.ndarray, lang: str) -> int | None:
+117 -1
View File
@@ -5,7 +5,20 @@ 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. accuracy against rendered receipts is exercised in test_ocr_integration.py.
""" """
from paperpod.ocr.extract import extract_date, extract_form_code, extract_org_name, extract_store_name, extract_tax_year, extract_time, extract_total from paperpod.ocr.extract import (
clean_vendor,
extract_date,
extract_doc_title,
extract_form_code,
extract_line_items,
extract_org_name,
extract_page_marker,
extract_party,
extract_store_name,
extract_tax_year,
extract_time,
extract_total,
)
def test_extract_date_iso(): def test_extract_date_iso():
@@ -19,6 +32,8 @@ def test_extract_date_slash_mdy():
def test_extract_date_month_name(): def test_extract_date_month_name():
assert extract_date("Invoice date: March 14, 2023") == "2023-03-14" assert extract_date("Invoice date: March 14, 2023") == "2023-03-14"
assert extract_date("Mar 14 2023") == "2023-03-14" assert extract_date("Mar 14 2023") == "2023-03-14"
# OCR often reads the comma as a period ("July 13. 2026").
assert extract_date("Date: July 13. 2025") == "2025-07-13"
def test_extract_date_none_when_absent(): def test_extract_date_none_when_absent():
@@ -55,6 +70,23 @@ def test_extract_total_none_when_absent():
assert extract_total("no totals here") is None assert extract_total("no totals here") is None
def test_extract_line_items_skips_totals_and_keeps_skus():
text = (
"COSTCO WHOLESALE\n"
"1234567 LEGO CLASSIC SET 24.99 H\n"
"KIRKLAND PAPER TOWEL 18.99\n"
"SUBTOTAL 43.98\n"
"HST 5.72\n"
"TOTAL 49.70\n"
"MASTERCARD 49.70\n"
)
items = extract_line_items(text)
assert items == [
("LEGO CLASSIC SET", "24.99"),
("KIRKLAND PAPER TOWEL", "18.99"),
]
def test_extract_time_hms(): def test_extract_time_hms():
assert extract_time("03/20/23 15:25:35") == "15:25" assert extract_time("03/20/23 15:25:35") == "15:25"
@@ -95,6 +127,12 @@ def test_extract_form_code_none_for_receipt():
assert extract_form_code("WALMART\nSUBTOTAL 21.97\nTOTAL 23.71") is None assert extract_form_code("WALMART\nSUBTOTAL 21.97\nTOTAL 23.71") is None
def test_extract_form_code_requires_slip_boilerplate():
# Garbled OCR of a bank statement once misread "LEVIT I" as "T4";
# without CRA boilerplate nearby the code must not count.
assert extract_form_code("HORT NAMY nV T4\nRAN AMOUNY HATL") is None
def test_extract_org_name_finds_employer(): def test_extract_org_name_finds_employer():
text = ( text = (
"Employer's name\nYORK UNIVERSITY Year 2023 Statement of Remuneration Paid\n" "Employer's name\nYORK UNIVERSITY Year 2023 Statement of Remuneration Paid\n"
@@ -138,3 +176,81 @@ def test_extract_store_name_costco():
def test_extract_store_name_walmart(): def test_extract_store_name_walmart():
assert extract_store_name("HOW DID WE DO TODAY?\nWalmart") == "Walmart" assert extract_store_name("HOW DID WE DO TODAY?\nWalmart") == "Walmart"
def test_extract_date_rejects_far_future_ocr_misreads():
# "2053" is a misread of "2023" — a document can't be dated decades ahead.
assert extract_date("PERIOD ENDING 2053-06-30") is None
def test_extract_date_statement_period_end_wins():
# The period end is the statement's document date, not the first
# transaction row.
text = "PERIOD: FROM : 01/ 01/ 2025 TO : 01/ 31/ 2025\n01/02/2025 PYT TO"
assert extract_date(text) == "2025-01-31"
def test_extract_date_tolerates_ocr_spaces_and_letter_o():
assert extract_date("TO : O1/ 31/2025") == "2025-01-31"
assert extract_date("12/ 31/2024") == "2024-12-31"
def test_extract_page_marker_english():
assert extract_page_marker("DEPOSIT ACCOUNT HISTORY PAGE 1 OF 2") == (1, 2)
assert extract_page_marker("PAGE 13 OF 17") == (13, 17)
def test_extract_page_marker_french():
assert extract_page_marker("HISTORIQUE DES OPERATIONS PAGE 2 DE 3") == (2, 3)
def test_extract_page_marker_rejects_nonsense():
assert extract_page_marker("PAGE 5 OF 2") is None
assert extract_page_marker("no marker here") is None
def test_extract_doc_title_transaction_history():
assert extract_doc_title("TD Canada Trust\nTransaction History") == "transaction_history"
assert extract_doc_title("Historique des opérations") == "transaction_history"
def test_extract_doc_title_direct_deposit():
assert (
extract_doc_title("Government of Canada - Direct Deposit Enrolment")
== "direct_deposit"
)
def test_extract_doc_title_none_for_receipt():
assert extract_doc_title("WALMART\nSUBTOTAL 21.97") is None
def test_extract_party_shortname():
text = "BR # : 1076 ACCOUNT: 6254276 MBA - MIN SHORTNAME : LEVIT I\nPERIOD: FROM"
assert extract_party(text) == "LEVIT I"
def test_extract_party_none_when_absent():
assert extract_party("WALMART\nTOTAL 23.71") is None
def test_clean_vendor_strips_edge_junk():
assert clean_vendor("| ») Transaction History i") == "Transaction History"
assert clean_vendor("7D TD Canada Trust") == "TD Canada Trust"
assert clean_vendor("+7 TD Canada Trus") == "TD Canada Trus"
assert clean_vendor(": TD Canada Trust") == "TD Canada Trust"
def test_clean_vendor_rejects_gibberish():
assert clean_vendor("BH tc rn te So Gee EA Nie PR ae UE aa a RR Pia") is None
def test_clean_vendor_keeps_real_names():
assert clean_vendor("TD Canada Trust") == "TD Canada Trust"
assert clean_vendor("Costco") == "Costco"
assert clean_vendor("Historique des opérations") == "Historique des opérations"
def test_clean_vendor_none_passthrough():
assert clean_vendor(None) is None
assert clean_vendor("|| 123") is None