Add adaptive preprocessing escalation and single-receipt test scripts
build / build (push) Successful in 2m57s
build / build (push) Successful in 2m57s
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.
This commit is contained in:
@@ -8,12 +8,22 @@ 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, unsharp
|
||||
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 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.
|
||||
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
|
||||
@@ -22,6 +32,13 @@ 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)
|
||||
@@ -29,23 +46,66 @@ def load_grayscale(path: str) -> np.ndarray:
|
||||
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) -> str:
|
||||
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)
|
||||
|
||||
gray = load_grayscale(path)
|
||||
cv2.imwrite(os.path.join(out_dir, "01_gray.png"), gray)
|
||||
stage = load_grayscale(path)
|
||||
cv2.imwrite(os.path.join(out_dir, "01_gray.png"), stage)
|
||||
|
||||
bilateral = cv2.bilateralFilter(gray, d=5, sigmaColor=50, sigmaSpace=50)
|
||||
cv2.imwrite(os.path.join(out_dir, "02_bilateral.png"), bilateral)
|
||||
# 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, "03_final.png")
|
||||
final_path = os.path.join(out_dir, f"0{next_n}_final.png")
|
||||
cv2.imwrite(final_path, final)
|
||||
|
||||
return final_path
|
||||
@@ -55,9 +115,10 @@ 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)
|
||||
final_path = preprocess(args.in_path, args.out_dir, args.variant)
|
||||
print(final_path)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user