Add page-marker grouping and party-aware naming
Consecutive captures whose printed PAGE X OF Y markers advance under the same total are grouped into one multi-page PDF, with duplicate pages keeping the better read. Naming gains doc-title/party components and a scan-date fallback so nothing lands in UNSORTED. Vision LLM default switches to minicpm-v (faster, fewer false blanks on dense mono pages).
This commit is contained in:
+18
-9
@@ -1,13 +1,17 @@
|
||||
"""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.
|
||||
A vision model (default: minicpm-v) 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.
|
||||
minicpm-v was chosen over qwen2.5vl after A/B on overhead statement scans:
|
||||
~2x faster and far fewer false "blank" labels on dense mono bank pages.
|
||||
Dates can still hallucinate — the pipeline keeps OCR as date/total fill-in.
|
||||
|
||||
Trade-off: ~8-12s 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.
|
||||
"""
|
||||
@@ -20,6 +24,7 @@ import re
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
@@ -27,6 +32,7 @@ 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",
|
||||
"party": "account holder / customer / SHORTNAME / payee printed on the document, 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",
|
||||
"time": "transaction time as 24-hour HH:MM if printed on the document, else null",
|
||||
@@ -35,13 +41,15 @@ _PROMPT = """Look at this image of a document. Reply with ONLY a JSON object, no
|
||||
"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."""
|
||||
Set vendor to null unless you can actually read a real business/organization name — never output garbled text.
|
||||
Set party to the person/entity the document is about (e.g. SHORTNAME on a bank statement, payee on a slip) when clearly printed; otherwise null."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class LlmAnalysis:
|
||||
doc_type: str
|
||||
vendor: str | None
|
||||
party: str | None
|
||||
form_code: str | None
|
||||
date: str | None # normalized YYYY-MM-DD
|
||||
time: str | None # normalized HH:MM (24h)
|
||||
@@ -77,7 +85,7 @@ def _normalize_date(raw: str | None) -> str | None:
|
||||
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):
|
||||
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}"
|
||||
|
||||
@@ -100,7 +108,7 @@ def _normalize_time(raw: str | None) -> str | None:
|
||||
|
||||
def analyze_document(
|
||||
image_bgr: np.ndarray,
|
||||
model: str = "qwen2.5vl",
|
||||
model: str = "minicpm-v",
|
||||
base_url: str = "http://localhost:11434",
|
||||
timeout: float = 180.0,
|
||||
) -> LlmAnalysis | None:
|
||||
@@ -137,6 +145,7 @@ def analyze_document(
|
||||
return LlmAnalysis(
|
||||
doc_type=_clean_str(data.get("doc_type")) or "other",
|
||||
vendor=_clean_str(data.get("vendor")),
|
||||
party=_clean_str(data.get("party")),
|
||||
form_code=_clean_str(data.get("form_code")),
|
||||
date=_normalize_date(data.get("date")),
|
||||
time=_normalize_time(data.get("time")),
|
||||
|
||||
Reference in New Issue
Block a user