"""Scan-style image enhancement, for OCR-readiness and optionally the saved crop. `normalize_illumination` is the "flatten it, make it easier to read" trick scanner apps like CamScanner use: divide the image by a heavily-blurred copy of itself (an estimate of the local lighting/shadow), which cancels out gradients, glare, and wood-grain/shadow texture far better than a plain contrast boost. Confirmed on a real low-contrast receipt-on-wood-grain photo: raw OCR read almost nothing, normalized OCR recovered full lines of text. """ from __future__ import annotations import cv2 import numpy as np def normalize_illumination(image_bgr: np.ndarray, blur_fraction: float = 1 / 15) -> np.ndarray: """Cancel out uneven lighting/shadow/background texture. Returns grayscale.""" gray = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2GRAY) sigma = max(image_bgr.shape[1] * blur_fraction, 10.0) background = cv2.GaussianBlur(gray, (0, 0), sigmaX=sigma) # Background approaches white; dividing pulls the true page background # up to ~255 wherever lighting made it dim, while text (locally much # darker than its surroundings) stays dark. return cv2.divide(gray, background, scale=255) def darken_print(image_bgr: np.ndarray, gamma: float = 1.6) -> np.ndarray: """Pull pale grey ink toward black while keeping paper white and colour. Dot-matrix / faded branch prints photograph as light grey; gamma on the L channel in Lab space darkens midtones (the ink) without flattening the image to grayscale the way enhance_for_ocr does. Applied to saved crops when document.enhance is off. """ lab = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2LAB) l_ch, a_ch, b_ch = cv2.split(lab) lut = np.clip(((np.arange(256) / 255.0) ** gamma) * 255.0, 0, 255).astype(np.uint8) l_ch = cv2.LUT(l_ch, lut) return cv2.cvtColor(cv2.merge([l_ch, a_ch, b_ch]), cv2.COLOR_LAB2BGR) def enhance_for_ocr( image_bgr: np.ndarray, gamma: float = 2.2, upscale_below_width: int = 1800, upscale_factor: float = 1.5, ) -> np.ndarray: """Upscale (if small) + illumination-normalize + local contrast + gamma. Returns a BGR image, always grayscale-looking (3 identical channels) since normalization operates on luminance. Intended primarily as an OCR preprocessing step; only baked into the saved crop/PDF when document.enhance is enabled. The gamma curve darkens faint print without touching the (near-white) page background. Illumination normalization alone leaves light-ribbon dot-matrix text (TD branch statements) too pale for Tesseract — on real footage it was the difference between reading "PAGE 1 OF 2" plus the statement period and reading nothing past the letterhead. Dot-matrix ribbon print is made of sparse dots per character stroke, so at typical webcam-distance capture resolution a stroke can be only 1-2px wide — CLAHE/gamma have nothing left to boost once that detail is gone. Cubic-upsampling before contrast work (done once, before CLAHE, so the interpolation works on smooth tonal gradients rather than already-boosted edges) recovers enough of that stroke to read reliably. Skipped above upscale_below_width since upscaling an already-sharp crop just slows Tesseract for no readability gain. """ working = image_bgr if upscale_factor and upscale_factor != 1.0 and working.shape[1] < upscale_below_width: working = cv2.resize( working, None, fx=upscale_factor, fy=upscale_factor, interpolation=cv2.INTER_CUBIC ) normalized = normalize_illumination(working) clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8)) boosted = clahe.apply(normalized) if gamma and gamma != 1.0: lut = np.clip(((np.arange(256) / 255.0) ** gamma) * 255.0, 0, 255).astype(np.uint8) boosted = cv2.LUT(boosted, lut) return cv2.cvtColor(boosted, cv2.COLOR_GRAY2BGR)