Spaces:
Sleeping
Sleeping
File size: 10,111 Bytes
cbb1b1a b861cd9 cbb1b1a b861cd9 cbb1b1a b861cd9 cbb1b1a b861cd9 cbb1b1a b861cd9 cbb1b1a b861cd9 cbb1b1a b861cd9 cbb1b1a b861cd9 cbb1b1a b861cd9 cbb1b1a b861cd9 cbb1b1a b861cd9 cbb1b1a b861cd9 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 | """
OCR pipeline for complaint documents.
Pre-processing steps applied before Tesseract:
1. Convert to greyscale
2. Adaptive threshold (Gaussian-blur background subtraction)
3. Deskew (pytesseract OSD; graceful no-op if unavailable)
Supported formats: PDF (pdfplumber), PNG / JPG / JPEG / WEBP.
Public API:
extract_text(file_path) -> str
extract_with_entities(file_path) -> tuple[str, list[Entity]]
preprocess_image(image_path) -> PIL.Image
"""
from __future__ import annotations
import logging
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
import numpy as np
import pytesseract
from PIL import Image, ImageFilter
from src.ner.model import Entity
logger = logging.getLogger(__name__)
SUPPORTED_IMAGE_EXTS: frozenset[str] = frozenset(
{".png", ".jpg", ".jpeg", ".webp", ".bmp", ".tiff"}
)
# ---------------------------------------------------------------------------
# Image pre-processing
# ---------------------------------------------------------------------------
def _adaptive_threshold(img: Image.Image) -> Image.Image:
"""
Binarise a greyscale image using local background estimation.
Gaussian-blurred copy β local background estimate; pixels darker than
the background by >10 grey levels become black (foreground text),
everything else becomes white.
"""
bg = img.filter(ImageFilter.GaussianBlur(radius=15))
arr = np.array(img, dtype=np.int32)
arr_bg = np.array(bg, dtype=np.int32)
# Dark relative to local background β foreground (text)
mask = ((arr_bg - arr) > 10).astype(np.uint8) * 255
return Image.fromarray(mask)
def _deskew(img: Image.Image) -> Image.Image:
"""
Correct page skew using pytesseract's Orientation and Script Detection (OSD).
Silent no-op if OSD data is unavailable or the image has insufficient text
for detection.
"""
try:
osd = pytesseract.image_to_osd(
img, output_type=pytesseract.Output.DICT, nice=0
)
angle = osd.get("rotate", 0)
if abs(angle) > 1:
img = img.rotate(-angle, expand=True, fillcolor=255)
except Exception:
pass # OSD traineddata not installed or too little text β skip
return img
def _preprocess_pil(img: Image.Image) -> Image.Image:
"""Apply the full pre-processing pipeline to a PIL image."""
img = img.convert("L") # 1. greyscale
img = img.filter(ImageFilter.MedianFilter(3)) # 2. denoise
img = _deskew(img) # 3. deskew (on greyscale)
img = _adaptive_threshold(img) # 4. binarise
return img
def preprocess_image(image_path: str) -> Image.Image:
"""Load *image_path* and return a pre-processed PIL image ready for Tesseract."""
return _preprocess_pil(Image.open(image_path))
# ---------------------------------------------------------------------------
# Regex-based entity extraction from OCR text
# ---------------------------------------------------------------------------
# Patterns ordered by specificity; each produces an (Entity) object.
_PATTERNS: list[tuple[str, re.Pattern]] = [
("AMOUNT", re.compile(
r"(?:βΉ|Rs\.?|INR|Rupees?)\s*[\d,]+(?:\.\d{1,2})?"
r"|[\d,]{2,}(?:\.\d{2})?\s*/\s*-",
re.IGNORECASE,
)),
("DATE", re.compile(
r"\b\d{1,2}[/\-\.]\d{1,2}[/\-\.]\d{2,4}\b"
r"|\b\d{1,2}\s+(?:Jan(?:uary)?|Feb(?:ruary)?|Mar(?:ch)?|Apr(?:il)?"
r"|May|Jun(?:e)?|Jul(?:y)?|Aug(?:ust)?|Sep(?:tember)?"
r"|Oct(?:ober)?|Nov(?:ember)?|Dec(?:ember)?)\s+\d{4}\b"
r"|\b(?:Jan(?:uary)?|Feb(?:ruary)?|Mar(?:ch)?|Apr(?:il)?"
r"|May|Jun(?:e)?|Jul(?:y)?|Aug(?:ust)?|Sep(?:tember)?"
r"|Oct(?:ober)?|Nov(?:ember)?|Dec(?:ember)?)\s+\d{1,2},?\s+\d{4}\b",
re.IGNORECASE,
)),
("REF_ID", re.compile(
r"(?:Order|Ref(?:erence)?|TXN|Txn|Transaction\s*ID|Ticket|Receipt"
r"|Invoice|Booking|Payment\s*ID|Claim|Case|Complaint)\s*[#:\s]*"
r"([A-Z0-9][-A-Z0-9]{3,24})",
re.IGNORECASE,
)),
("ACCOUNT", re.compile(
r"(?:A/c|Account|Acct)\.?\s*(?:No\.?|Number|#)?\s*:?\s*"
r"([Xx\*\d]{4,}(?:[- ][Xx\*\d]{4,})*)"
r"|ending\s+(?:in|with)\s+(\d{4})",
re.IGNORECASE,
)),
("ORG", re.compile(
r"\b(?:Flipkart|Amazon(?:\s+India)?|Myntra|Snapdeal|Meesho"
r"|HDFC\s+Bank|ICICI\s+Bank|State\s+Bank\s+of\s+India|SBI|Axis\s+Bank"
r"|Kotak(?:\s+Mahindra)?\s+Bank|Punjab\s+National\s+Bank|PNB|Bank\s+of\s+Baroda"
r"|Airtel|Reliance\s+Jio|Jio|Vodafone(?:\s+Idea)?|Vi|BSNL"
r"|LIC(?:\s+of\s+India)?|Star\s+Health|New\s+India\s+Assurance"
r"|ICICI\s+Lombard|HDFC\s+ERGO|CIBIL|Experian(?:\s+India)?"
r"|Swiggy|Zomato|Ola(?:\s+Cabs)?|Uber(?:\s+India)?|IRCTC|MakeMyTrip|Paytm|PhonePe)\b",
re.IGNORECASE,
)),
]
def _extract_entities_from_text(text: str, base_confidence: float = 0.75) -> list[Entity]:
"""Apply regex patterns to *text* and return Entity spans."""
entities: list[Entity] = []
for label, pattern in _PATTERNS:
for m in pattern.finditer(text):
# For patterns with groups (REF_ID, ACCOUNT), prefer the captured group
span_text = next(
(g for g in m.groups() if g is not None), m.group(0)
).strip()
if not span_text:
continue
start = text.find(span_text, m.start())
if start == -1:
start = m.start()
entities.append(Entity(
text=span_text,
label=label,
start=start,
end=start + len(span_text),
confidence=base_confidence,
))
return entities
# ---------------------------------------------------------------------------
# Text extraction β images
# ---------------------------------------------------------------------------
def _clean_text(raw: str) -> str:
"""Normalise whitespace and remove form-feed characters from OCR output."""
raw = raw.replace("\f", "\n")
raw = re.sub(r"\n{3,}", "\n\n", raw)
return raw.strip()
def ocr_image(image_path: str) -> str:
"""Pre-process *image_path* and return Tesseract OCR text."""
img = preprocess_image(image_path)
raw = pytesseract.image_to_string(img, lang="eng")
return _clean_text(raw)
def _ocr_pil(img: Image.Image) -> str:
"""OCR a PIL image that has already been loaded (used by the PDF path)."""
preprocessed = _preprocess_pil(img)
return _clean_text(pytesseract.image_to_string(preprocessed, lang="eng"))
# ---------------------------------------------------------------------------
# Text extraction β PDF
# ---------------------------------------------------------------------------
def extract_text_pdf(pdf_path: str) -> str:
"""
Extract text from *pdf_path* using pdfplumber.
Text-native pages: direct character extraction.
Scanned pages (extracted text < 20 chars): rendered to image and OCR-ed.
Page rendering requires either pdfplumber's wand backend (ImageMagick) or
pdf2image + poppler; if neither is available the scanned page is skipped
with a WARNING log rather than crashing.
"""
import pdfplumber
page_texts: list[str] = []
with pdfplumber.open(pdf_path) as pdf:
for page in pdf.pages:
text = (page.extract_text() or "").strip()
if len(text) >= 20:
page_texts.append(text)
continue
# Scanned page β try image render + OCR
pil: Optional[Image.Image] = None
try:
pil = page.to_image(resolution=200).original
except Exception as exc:
logger.warning(
"PDF page %d: could not render to image (%s). "
"Install ImageMagick/wand or pdf2image+poppler for scanned PDF support.",
page.page_number, exc,
)
if pil is None:
# Try pdf2image as an alternative
try:
from pdf2image import convert_from_path
imgs = convert_from_path(
pdf_path,
dpi=200,
first_page=page.page_number,
last_page=page.page_number,
)
if imgs:
pil = imgs[0]
except Exception:
pass
if pil is not None:
text = _ocr_pil(pil)
if text:
page_texts.append(text)
return "\n\n".join(page_texts).strip()
# ---------------------------------------------------------------------------
# Public dispatch
# ---------------------------------------------------------------------------
def extract_text(file_path: str) -> str:
"""
Extract clean text from *file_path*.
Dispatches to extract_text_pdf for .pdf, or ocr_image for image formats.
Raises ValueError for unsupported extensions.
"""
ext = Path(file_path).suffix.lower()
if ext == ".pdf":
return extract_text_pdf(file_path)
if ext in SUPPORTED_IMAGE_EXTS:
return ocr_image(file_path)
raise ValueError(
f"Unsupported file extension {ext!r}. "
f"Supported: .pdf, {', '.join(sorted(SUPPORTED_IMAGE_EXTS))}"
)
def extract_with_entities(
file_path: str,
) -> tuple[str, list[Entity]]:
"""
Extract text and regex-detected entities from *file_path*.
Returns (text, entities) where entities are in the same schema as
EvidenceNER (Entity dataclass with text/label/start/end/confidence).
These regex spans complement the model-based spans from EvidenceNER and
DocumentViT; the DocumentProcessor merges all three sources.
"""
text = extract_text(file_path)
entities = _extract_entities_from_text(text)
return text, entities
|