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:
2026-09-28 19:35:29 +02:00
commit 7678d28717
17 changed files with 794 additions and 0 deletions
+65
View File
@@ -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()