Add Lab gamma darkening and optional 1.5x cubic upscale (crops under 1800px) so faint TD-style ribbon ink is readable without slowing sharp captures. Add .gitleaks.toml so the pre-commit secret scan can load default rules.
83 lines
3.9 KiB
Python
83 lines
3.9 KiB
Python
"""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)
|