Merge pull request 'Improve OCR on pale grey / low-res dot-matrix print' (#2) from feature/ocr-grey-print-upscale into master

This commit is contained in:
ilia 2026-07-13 22:16:15 -05:00
commit b11f46b00f
2 changed files with 66 additions and 7 deletions

View File

@ -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)

View File

@ -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