Add containerized OCR pipeline for fuel receipts
Self-hosted, verifiable pipeline for extracting structured data (station, date/time, price per litre, litres, discount, total) from German fuel receipt photos: grayscale + mild bilateral filter + contrast stretch, Tesseract OCR, then LLM structuring via a small local model (Qwen 2.5, served by Ollama). Each step is independently runnable and inspectable through numbered scripts against a shared tooling container. Ollama runs as its own long-lived container (0_run_ollama.sh), separate from the stateless per-receipt pipeline steps, so the model stays loaded in memory between runs. Includes a containerized ground-truth batch test (test_batch.sh) that reproduces the full pipeline over the test receipts and reports per-field accuracy. Targets eventual deployment on a Raspberry Pi 4 k3s cluster.
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
#!/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, 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.
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
from PIL import Image, ImageOps
|
||||
|
||||
|
||||
def load_grayscale(path: str) -> np.ndarray:
|
||||
img = Image.open(path)
|
||||
img = ImageOps.exif_transpose(img).convert("L")
|
||||
return np.array(img)
|
||||
|
||||
|
||||
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:
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
|
||||
gray = load_grayscale(path)
|
||||
cv2.imwrite(os.path.join(out_dir, "01_gray.png"), gray)
|
||||
|
||||
bilateral = cv2.bilateralFilter(gray, d=5, sigmaColor=50, sigmaSpace=50)
|
||||
cv2.imwrite(os.path.join(out_dir, "02_bilateral.png"), bilateral)
|
||||
|
||||
final = autocontrast(bilateral)
|
||||
final_path = os.path.join(out_dir, "03_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)
|
||||
args = parser.parse_args()
|
||||
|
||||
final_path = preprocess(args.in_path, args.out_dir)
|
||||
print(final_path)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user