Files
fuelish/tooling-container/src/preprocess.py
T
ml ca85530b5a
build / build (push) Successful in 2m57s
Add adaptive preprocessing escalation and single-receipt test scripts
Preprocessing now tries progressively heavier variants per receipt
(plain -> high-pass -> edge-enhance -> both), stopping as soon as a
cheap LLM-free arithmetic check on the OCR text looks plausible, so
the extra processing only applies to receipts that need it. A flat
version of the same heavier processing regressed the full batch by
helping hard receipts but hurting easy ones; the adaptive version
improves date-matching 37/52 -> 40/52 with no regression (see
testing/2026_10_04_gimp_highpass_nlfilter_experiment.md).

Also adds test_ocr.sh and test_single.sh for checking a single
receipt's OCR text or full pipeline result without a batch run.
2026-10-04 10:25:41 +02:00

127 lines
4.9 KiB
Python

#!/usr/bin/env python3
"""Step 1: preprocess a receipt photo for OCR.
Applies a mild edge-preserving smoothing pass (to take the edge off the
receipt's dotted security background pattern) followed by a global
percentile-based contrast stretch. Every intermediate stage is written to
the output directory (01_gray, 02_bilateral, 03_final) so the effect of
each step can be inspected on its own before trusting the final image to
OCR.
Heavier processing (strong denoising, local/CLAHE contrast, naive unsharp
masking) was tried and tested worse: each one amplifies the security
background pattern into OCR noise rather than suppressing it. This
lighter two-step version (variant 0 below, and the default) was chosen
because, across a set of receipts that the plain-grayscale baseline
OCR'd incorrectly, it recovered data the baseline missed and never made
a previously-correct read worse.
Variants 1-3 add a high-pass-style detail boost and/or edge enhancement
before this same bilateral+autocontrast stage. Applied unconditionally
to every receipt, this combination also tested worse on the full batch
for the same reason (see
testing/2026_10_04_gimp_highpass_nlfilter_experiment.md) - but it
noticeably helps the specific hard receipts it was designed for, so
adaptive.py tries these variants only when the cheap variant 0 already
looks implausible, rather than applying them globally.
"""
import argparse
import os
import cv2
import numpy as np
from PIL import Image, ImageOps
# Width the variant 1-3 parameters below were calibrated at; their blur
# radii scale relative to this so they hold up across this project's test
# photos, which range from ~768px to ~4096px wide.
REFERENCE_WIDTH = 768
VARIANT_COUNT = 4 # 0=baseline, 1=+highpass, 2=+edge-enhance, 3=+both
def load_grayscale(path: str) -> np.ndarray:
img = Image.open(path)
img = ImageOps.exif_transpose(img).convert("L")
return np.array(img)
def highpass_substitute(
gray: np.ndarray, blur_sigma=4.15, low_in=58, high_in=135, gamma=0.96
) -> np.ndarray:
scale = gray.shape[1] / REFERENCE_WIDTH
blurred = cv2.GaussianBlur(gray, (0, 0), sigmaX=blur_sigma * scale)
grain = np.clip(gray.astype(np.float32) - blurred.astype(np.float32) + 128.0, 0, 255)
normalized = np.clip((grain - low_in) / (high_in - low_in), 0, 1)
gamma_applied = np.power(normalized, 1.0 / gamma)
return np.clip(gamma_applied * 255.0, 0, 255).astype(np.uint8)
def edge_enhance(gray: np.ndarray, amount=0.69, sigma=0.52) -> np.ndarray:
scale = gray.shape[1] / REFERENCE_WIDTH
blurred = cv2.GaussianBlur(gray, (0, 0), sigmaX=sigma * scale)
sharpened = gray.astype(np.float32) + amount * (
gray.astype(np.float32) - blurred.astype(np.float32)
)
return np.clip(sharpened, 0, 255).astype(np.uint8)
def autocontrast(gray: np.ndarray, low_pct=2, high_pct=98) -> np.ndarray:
lo, hi = np.percentile(gray, (low_pct, high_pct))
stretched = (gray.astype(np.float32) - lo) * 255.0 / (hi - lo)
return np.clip(stretched, 0, 255).astype(np.uint8)
def preprocess(path: str, out_dir: str, variant: int = 0) -> str:
"""variant: 0=baseline (bilateral+autocontrast only, unchanged default
behavior), 1=+highpass_substitute, 2=+edge_enhance, 3=+both. See
adaptive.py for why this is a parameter rather than always-on."""
if not 0 <= variant < VARIANT_COUNT:
raise ValueError(f"variant must be 0-{VARIANT_COUNT - 1}, got {variant}")
os.makedirs(out_dir, exist_ok=True)
stage = load_grayscale(path)
cv2.imwrite(os.path.join(out_dir, "01_gray.png"), stage)
# Stage filenames only extend beyond the original 01/02/03 numbering
# for variants 1-3, which adaptive.py always runs in their own
# per-variant subdirectory - so this never changes what
# 1_preprocess_image.sh's default (variant 0) manual single-stage
# inspection produces, or any doc/script that hardcodes 03_final.png.
next_n = 2
if variant in (1, 3):
stage = highpass_substitute(stage)
cv2.imwrite(os.path.join(out_dir, f"0{next_n}_highpass.png"), stage)
next_n += 1
if variant in (2, 3):
stage = edge_enhance(stage)
cv2.imwrite(os.path.join(out_dir, f"0{next_n}_edgeenhance.png"), stage)
next_n += 1
bilateral = cv2.bilateralFilter(stage, d=5, sigmaColor=50, sigmaSpace=50)
cv2.imwrite(os.path.join(out_dir, f"0{next_n}_bilateral.png"), bilateral)
next_n += 1
final = autocontrast(bilateral)
final_path = os.path.join(out_dir, f"0{next_n}_final.png")
cv2.imwrite(final_path, final)
return final_path
def main():
parser = argparse.ArgumentParser()
parser.add_argument("-in", dest="in_path", required=True)
parser.add_argument("-out", dest="out_dir", required=True)
parser.add_argument("-variant", dest="variant", type=int, default=0)
args = parser.parse_args()
final_path = preprocess(args.in_path, args.out_dir, args.variant)
print(final_path)
if __name__ == "__main__":
main()