Add common OCR fallback for image-only bank statements

This commit is contained in:
A R R R Associates
2026-07-21 14:46:09 +05:30
parent 321f49db52
commit dba7424dbd
3 changed files with 174 additions and 18 deletions
+2
View File
@@ -9,6 +9,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \ build-essential \
libpq-dev \ libpq-dev \
curl \ curl \
poppler-utils \
tesseract-ocr \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
COPY requirements.txt . COPY requirements.txt .
@@ -2,7 +2,8 @@ from __future__ import annotations
from dataclasses import dataclass, asdict from dataclasses import dataclass, asdict
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
import re, subprocess, tempfile from concurrent.futures import ThreadPoolExecutor, as_completed
import os, re, subprocess, tempfile
import pandas as pd import pandas as pd
import pdfplumber import pdfplumber
@@ -51,20 +52,153 @@ def amount(v):
def norm(s): return re.sub(r'\s+',' ',str(s or '')).strip() def norm(s): return re.sub(r'\s+',' ',str(s or '')).strip()
def extract_text(path: str|Path) -> str: _MIN_DIGITAL_TEXT_CHARS = 120
"""Prefer pdftotext layout output; fall back to pdfplumber.""" _MIN_DIGITAL_ALPHA_CHARS = 30
path=str(path) _OCR_DPI = max(120, min(int(os.getenv("BANK_ANALYZER_OCR_DPI", "180")), 300))
_OCR_WORKERS = max(1, min(int(os.getenv("BANK_ANALYZER_OCR_WORKERS", "2")), 4))
def _has_meaningful_text(value: str) -> bool:
text = str(value or "").strip()
if len(text) < _MIN_DIGITAL_TEXT_CHARS:
return False
return sum(character.isalpha() for character in text) >= _MIN_DIGITAL_ALPHA_CHARS
def _run_command(arguments: list[str], timeout: int) -> subprocess.CompletedProcess[str]:
try: try:
p=subprocess.run(['pdftotext','-layout',path,'-'], capture_output=True, text=True, timeout=120) return subprocess.run(
if p.returncode==0 and len(p.stdout.strip())>50: arguments,
return p.stdout capture_output=True,
except Exception: text=True,
timeout=timeout,
check=False,
)
except FileNotFoundError as exc:
raise RuntimeError(
f"Required bank-statement extraction command is unavailable: {arguments[0]}"
) from exc
def _ocr_pdf_page(path: Path, page_number: int, work_dir: Path) -> tuple[int, str]:
prefix = work_dir / f"page_{page_number:05d}"
rendered_image = prefix.with_suffix(".png")
render = _run_command(
[
"pdftoppm",
"-f", str(page_number),
"-l", str(page_number),
"-r", str(_OCR_DPI),
"-png",
"-singlefile",
str(path),
str(prefix),
],
timeout=180,
)
if render.returncode != 0 or not rendered_image.exists():
message = render.stderr.strip() or f"Unable to render page {page_number} for OCR."
raise RuntimeError(message)
try:
environment = os.environ.copy()
environment.setdefault("OMP_THREAD_LIMIT", "1")
ocr = subprocess.run(
[
"tesseract",
str(rendered_image),
"stdout",
"-l", "eng",
"--psm", "4",
"-c", "preserve_interword_spaces=1",
],
capture_output=True,
text=True,
timeout=180,
check=False,
env=environment,
)
if ocr.returncode != 0:
raise RuntimeError(ocr.stderr.strip() or f"OCR failed for page {page_number}.")
return page_number, ocr.stdout or ""
finally:
rendered_image.unlink(missing_ok=True)
def _ocr_selected_pages(path: Path, page_numbers: list[int]) -> dict[int, str]:
if not page_numbers:
return {}
extracted: dict[int, str] = {}
with tempfile.TemporaryDirectory(prefix="bank_statement_ocr_") as temporary:
work_dir = Path(temporary)
with ThreadPoolExecutor(max_workers=min(_OCR_WORKERS, len(page_numbers))) as executor:
futures = {
executor.submit(_ocr_pdf_page, path, page_number, work_dir): page_number
for page_number in page_numbers
}
for future in as_completed(futures):
page_number, text = future.result()
extracted[page_number] = text
return extracted
def extract_text(path: str | Path) -> str:
"""Extract PDF text and transparently OCR image-only or mixed pages.
Existing digitally-generated statements continue to use the original fast
``pdftotext``/``pdfplumber`` path. OCR is invoked only when a page has no
meaningful embedded text, so existing parsers and workbook behaviour remain
unchanged for supported digital statements.
"""
pdf_path = Path(path)
try:
result = _run_command(
["pdftotext", "-layout", str(pdf_path), "-"],
timeout=120,
)
if result.returncode == 0 and _has_meaningful_text(result.stdout):
return result.stdout
except RuntimeError:
# pdfplumber remains the normal fallback when pdftotext is unavailable.
pass pass
parts=[]
with pdfplumber.open(path) as pdf: digital_pages: list[str] = []
for page in pdf.pages: pages_requiring_ocr: list[int] = []
parts.append(page.extract_text(x_tolerance=1,y_tolerance=3,layout=True) or '') with pdfplumber.open(str(pdf_path)) as pdf:
return '\n\f\n'.join(parts) for page_number, page in enumerate(pdf.pages, start=1):
page_text = page.extract_text(
x_tolerance=1,
y_tolerance=3,
layout=True,
) or ""
digital_pages.append(page_text)
if not _has_meaningful_text(page_text):
pages_requiring_ocr.append(page_number)
if not pages_requiring_ocr:
return "\n\f\n".join(digital_pages)
try:
ocr_pages = _ocr_selected_pages(pdf_path, pages_requiring_ocr)
except RuntimeError as exc:
if any(_has_meaningful_text(page) for page in digital_pages):
# Preserve the former mixed-PDF behaviour when OCR tooling is absent.
return "\n\f\n".join(digital_pages)
raise ValueError(
"This bank statement is image-only and OCR processing is unavailable. "
"Install poppler-utils and tesseract-ocr in the application image."
) from exc
merged_pages = [
ocr_pages.get(page_number, digital_pages[page_number - 1])
if page_number in pages_requiring_ocr
else digital_pages[page_number - 1]
for page_number in range(1, len(digital_pages) + 1)
]
return "\n\f\n".join(merged_pages)
def page_of_line(text: str, position: int) -> int: def page_of_line(text: str, position: int) -> int:
return text[:position].count('\f')+1 return text[:position].count('\f')+1
@@ -8,13 +8,26 @@ class SBIModernParser(BaseParser):
bank_name='State Bank of India'; parser_name='SBIModernParser' bank_name='State Bank of India'; parser_name='SBIModernParser'
@classmethod @classmethod
def detect(cls,text): def detect(cls,text):
u=text.upper(); return 0.98 if 'STATE BANK OF INDIA' in u and 'REF NO./CHEQUE' in u and 'DETAILS' in u else 0 u=norm(text).upper()
bank_marker='STATE BANK OF INDIA' in u
digital_layout='REF NO./CHEQUE' in u and 'DETAILS' in u
yono_print_layout=(
'STATEMENT OF ACCOUNT' in u
and 'VALUE DATE' in u
and 'POST DATE' in u
and 'DEBIT' in u
and 'CREDIT' in u
and 'BALANCE' in u
)
return 0.98 if bank_marker and (digital_layout or yono_print_layout) else 0
def parse(self,path,text=None): def parse(self,path,text=None):
text=text or extract_text(path); meta=StatementMeta(bank_name=self.bank_name,source_file=Path(path).name,parser_name=self.parser_name,confidence='High') text=text or extract_text(path); meta=StatementMeta(bank_name=self.bank_name,source_file=Path(path).name,parser_name=self.parser_name,confidence='High')
meta.customer_name=find(r'Account Name\s*:?\s*([^\n]+)',text) meta.customer_name=find(r'Account Name\s*:?\s*([^\n]+)',text)
if not meta.customer_name:
meta.customer_name=find(r'\bMs\.?\s+([A-Z][A-Z .]{3,})',text)
meta.account_number=find(r'Account Number\s*:?\s*([0-9X*]+)',text) meta.account_number=find(r'Account Number\s*:?\s*([0-9X*]+)',text)
meta.ifsc=find(r'IFS Code\s*:?\s*([A-Z0-9]+)',text) meta.ifsc=find(r'(?:IFS|IFSC) Code\s*:?\s*([A-Z0-9]+)',text)
m=re.search(rf'Account Statement from\s*({DATE_TOKEN_PATTERN})\s+to\s+({DATE_TOKEN_PATTERN})', text, re.I) m=re.search(rf'(?:Account Statement from|Statement From\s*:?)\s*({DATE_TOKEN_PATTERN})\s+to\s+({DATE_TOKEN_PATTERN})', text, re.I)
if m: if m:
meta.period_from=date_iso(m.group(1)); meta.period_to=date_iso(m.group(2)) meta.period_from=date_iso(m.group(1)); meta.period_to=date_iso(m.group(2))
meta.opening_balance=amount(find(r'Balance as on[^\n]*\n\s*([\d,]+\.\d{2})',text)) meta.opening_balance=amount(find(r'Balance as on[^\n]*\n\s*([\d,]+\.\d{2})',text))
@@ -43,8 +56,15 @@ class SBIModernParser(BaseParser):
if abs((prev-txn)-bal)<0.05: debit=txn if abs((prev-txn)-bal)<0.05: debit=txn
elif abs((prev+txn)-bal)<0.05: credit=txn elif abs((prev+txn)-bal)<0.05: credit=txn
if debit is None and credit is None: if debit is None and credit is None:
if p >= 78: credit=txn upper=alltxt.upper()
else: debit=txn if '/CR/' in upper or upper.startswith('DEP TFR') or 'INTEREST CREDIT' in upper:
credit=txn
elif '/DR/' in upper or upper.startswith('WDL TFR') or upper.startswith('DEBIT '):
debit=txn
elif p >= 78:
credit=txn
else:
debit=txn
narr=first[:nums[-2].start() if len(nums)>=2 else nums[-1].start()].strip()+' '+' '.join(x.strip() for x in r['lines'][1:]) narr=first[:nums[-2].start() if len(nums)>=2 else nums[-1].start()].strip()+' '+' '.join(x.strip() for x in r['lines'][1:])
ref=''; z=re.search(r'(?:UPI|NEFT|IMPS|RTGS)[/A-Z0-9-]{6,}',alltxt,re.I); ref=z.group(0) if z else '' ref=''; z=re.search(r'(?:UPI|NEFT|IMPS|RTGS)[/A-Z0-9-]{6,}',alltxt,re.I); ref=z.group(0) if z else ''
if debit is None and credit is None: continue if debit is None and credit is None: continue