diff --git a/paperpod/ocr/extract.py b/paperpod/ocr/extract.py index 5c405ce..2f74a43 100644 --- a/paperpod/ocr/extract.py +++ b/paperpod/ocr/extract.py @@ -10,6 +10,7 @@ from __future__ import annotations import re import statistics from dataclasses import dataclass +from datetime import datetime import cv2 import numpy as np @@ -22,13 +23,25 @@ MONTHS = { "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]] = [ + # 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", + r"[a-z]*\.?\s+(\d{1,2})[.,]?\s+(\d{4})\b", re.IGNORECASE, ), "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 # style receipts this app was designed around). Revisit if you record # 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) _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 @@ -90,11 +126,139 @@ _TAX_YEAR_TITLE_RE = re.compile( 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()) - 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: @@ -160,6 +324,11 @@ class OcrResult: 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: @@ -176,7 +345,10 @@ def _normalize_date(match: re.Match, kind: str) -> str | None: return None else: 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 f"{y:04d}-{m:02d}-{d:02d}" except (ValueError, TypeError): @@ -185,6 +357,8 @@ def _normalize_date(match: re.Match, kind: str) -> str | 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: @@ -217,6 +391,29 @@ def extract_total(text: str) -> str | 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( image_bgr: np.ndarray, lang: str = "eng", @@ -303,12 +500,14 @@ def run_ocr( 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 = extract_vendor( - image_bgr, - lang=lang, - top_fraction=vendor_top_fraction, - min_word_confidence=min_word_confidence, - psm=psm, + 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) @@ -324,6 +523,9 @@ def run_ocr( 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, @@ -334,6 +536,11 @@ def run_ocr( 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, ) diff --git a/paperpod/vision/orient.py b/paperpod/vision/orient.py index 15739f2..9250ec5 100644 --- a/paperpod/vision/orient.py +++ b/paperpod/vision/orient.py @@ -42,7 +42,9 @@ def _downscale(image_bgr: np.ndarray, max_dim: int = 1000) -> np.ndarray: if longest <= max_dim: return image_bgr 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: diff --git a/tests/test_ocr_parsing.py b/tests/test_ocr_parsing.py index 53451b7..0c0c775 100644 --- a/tests/test_ocr_parsing.py +++ b/tests/test_ocr_parsing.py @@ -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. """ -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(): @@ -19,6 +32,8 @@ def test_extract_date_slash_mdy(): def test_extract_date_month_name(): assert extract_date("Invoice date: March 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(): @@ -55,6 +70,23 @@ def test_extract_total_none_when_absent(): 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(): 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 +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(): text = ( "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(): 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