Add common OCR fallback for image-only bank statements
This commit is contained in:
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user