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.
This commit is contained in:
@@ -25,14 +25,58 @@ def normalize_illumination(image_bgr: np.ndarray, blur_fraction: float = 1 / 15)
|
|||||||
return cv2.divide(gray, background, scale=255)
|
return cv2.divide(gray, background, scale=255)
|
||||||
|
|
||||||
|
|
||||||
def enhance_for_ocr(image_bgr: np.ndarray) -> np.ndarray:
|
def darken_print(image_bgr: np.ndarray, gamma: float = 1.6) -> np.ndarray:
|
||||||
"""Illumination-normalize + local contrast boost. Returns a BGR image.
|
"""Pull pale grey ink toward black while keeping paper white and colour.
|
||||||
|
|
||||||
Always grayscale-looking (3 identical channels) since normalization
|
Dot-matrix / faded branch prints photograph as light grey; gamma on the
|
||||||
operates on luminance. Intended primarily as an OCR preprocessing step;
|
L channel in Lab space darkens midtones (the ink) without flattening the
|
||||||
only baked into the saved crop/PDF when document.enhance is enabled.
|
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))
|
clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
|
||||||
boosted = clahe.apply(normalized)
|
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)
|
return cv2.cvtColor(boosted, cv2.COLOR_GRAY2BGR)
|
||||||
|
|||||||
+16
-1
@@ -30,8 +30,23 @@ def test_normalize_illumination_returns_grayscale_shape():
|
|||||||
|
|
||||||
def test_enhance_for_ocr_returns_bgr():
|
def test_enhance_for_ocr_returns_bgr():
|
||||||
page = _shaded_page()
|
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
|
assert enhanced.shape == page.shape
|
||||||
# Grayscale-derived: all 3 channels should be identical.
|
# Grayscale-derived: all 3 channels should be identical.
|
||||||
assert np.array_equal(enhanced[:, :, 0], enhanced[:, :, 1])
|
assert np.array_equal(enhanced[:, :, 0], enhanced[:, :, 1])
|
||||||
assert np.array_equal(enhanced[:, :, 1], enhanced[:, :, 2])
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user