From a253db6adb892bf5213930efedbbb1d4e0217dcc Mon Sep 17 00:00:00 2001 From: ilia Date: Mon, 13 Jul 2026 23:12:53 -0400 Subject: [PATCH] Improve OCR on pale grey / low-res dot-matrix print. 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. --- .gitleaks.toml | 7 +++++ paperpod/vision/enhance.py | 56 ++++++++++++++++++++++++++++++++++---- tests/test_enhance.py | 17 +++++++++++- 3 files changed, 73 insertions(+), 7 deletions(-) create mode 100644 .gitleaks.toml diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 0000000..7609837 --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,7 @@ +# Gitleaks config — pre-commit hook requires this file. +# useDefault must be true or gitleaks loads only this file and detects nothing. + +title = "PaperPod gitleaks" + +[extend] +useDefault = true diff --git a/paperpod/vision/enhance.py b/paperpod/vision/enhance.py index 6f639e1..d6cdc47 100644 --- a/paperpod/vision/enhance.py +++ b/paperpod/vision/enhance.py @@ -25,14 +25,58 @@ def normalize_illumination(image_bgr: np.ndarray, blur_fraction: float = 1 / 15) return cv2.divide(gray, background, scale=255) -def enhance_for_ocr(image_bgr: np.ndarray) -> np.ndarray: - """Illumination-normalize + local contrast boost. Returns a BGR image. +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. - 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. + 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. """ - normalized = normalize_illumination(image_bgr) + 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) diff --git a/tests/test_enhance.py b/tests/test_enhance.py index 9d313e2..d6e1326 100644 --- a/tests/test_enhance.py +++ b/tests/test_enhance.py @@ -30,8 +30,23 @@ def test_normalize_illumination_returns_grayscale_shape(): def test_enhance_for_ocr_returns_bgr(): page = _shaded_page() - enhanced = enhance_for_ocr(page) + # Page is narrower than the default upscale threshold; disable it here so + # this test only checks color/shape handling (upscaling is covered below). + enhanced = enhance_for_ocr(page, upscale_factor=1.0) assert enhanced.shape == page.shape # Grayscale-derived: all 3 channels should be identical. assert np.array_equal(enhanced[:, :, 0], enhanced[:, :, 1]) assert np.array_equal(enhanced[:, :, 1], enhanced[:, :, 2]) + + +def test_enhance_for_ocr_upscales_small_crops(): + page = _shaded_page() # 400px wide, below the default 1800px threshold + enhanced = enhance_for_ocr(page, upscale_factor=1.5) + assert enhanced.shape[1] == round(page.shape[1] * 1.5) + assert enhanced.shape[0] == round(page.shape[0] * 1.5) + + +def test_enhance_for_ocr_skips_upscale_above_threshold(): + page = _shaded_page() + enhanced = enhance_for_ocr(page, upscale_factor=1.5, upscale_below_width=100) + assert enhanced.shape == page.shape