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
+20
View File
@@ -0,0 +1,20 @@
FROM python:3.12-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
tesseract-ocr \
tesseract-ocr-deu \
&& rm -rf /var/lib/apt/lists/*
RUN pip install --no-cache-dir \
opencv-python-headless==4.10.0.84 \
numpy==2.1.3 \
pillow==11.0.0 \
pytesseract==0.3.13 \
requests==2.32.3 \
odfpy==1.4.1
COPY src/ /opt/app/src/
RUN chmod -R a+rX /opt/app/src
WORKDIR /data
ENTRYPOINT []
+1
View File
@@ -0,0 +1 @@
0.1.0
@@ -0,0 +1,94 @@
#!/usr/bin/env python3
"""Test-only tool: compare a directory of extracted.json results (produced
by running 1_preprocess_image.sh/2_ocr_image.sh/3_extract_data.sh over a
set of receipts) against the ground-truth spreadsheet, matching rows by
date and reporting per-field accuracy."""
import argparse
import glob
import json
import os
from odf.opendocument import load
from odf.table import Table, TableRow, TableCell
from odf.text import P
def load_ground_truth(ods_path: str) -> dict:
doc = load(ods_path)
table = doc.spreadsheet.getElementsByType(Table)[0]
rows = table.getElementsByType(TableRow)
gt = {}
for r in rows[1:]:
cells = r.getElementsByType(TableCell)
vals = []
for c in cells:
ps = c.getElementsByType(P)
vals.append(" ".join(str(p) for p in ps))
if not vals or not vals[0]:
continue
date = vals[0]
try:
gt[date] = {
"time": vals[1],
"price_per_litre": float(vals[2].replace(",", ".")) if vals[2] else None,
"litres": float(vals[3].replace(",", ".")) if vals[3] else None,
"discount": float(vals[4].replace("€", "").replace(",", ".").strip()) if vals[4] else None,
"total_price": float(vals[5].replace("€", "").replace(",", ".").strip()) if vals[5] else None,
}
except (ValueError, IndexError):
continue
return gt
def close(a, b, tol=0.05):
if a is None or b is None:
return a is None and b is None
return abs(abs(a) - abs(b)) <= tol
def main():
parser = argparse.ArgumentParser()
parser.add_argument("-gt", dest="ods_path", required=True, help="path to ground-truth .ods")
parser.add_argument("-results", dest="results_dir", required=True,
help="directory containing one subdirectory per receipt, each with an extracted.json")
args = parser.parse_args()
gt = load_ground_truth(args.ods_path)
fields = ["price_per_litre", "litres", "discount", "total_price"]
field_ok = {k: 0 for k in fields}
field_total = {k: 0 for k in fields}
matched = 0
total = 0
for json_path in sorted(glob.glob(os.path.join(args.results_dir, "*", "extracted.json"))):
receipt = os.path.basename(os.path.dirname(json_path))
total += 1
with open(json_path, "r", encoding="utf-8") as f:
data = json.load(f)
truth = gt.get(data.get("date"))
if not truth:
print(f"{receipt}: NO DATE MATCH (extracted date={data.get('date')!r})")
continue
matched += 1
bad = []
for k in fields:
field_total[k] += 1
ok = close(data.get(k), truth[k])
if ok:
field_ok[k] += 1
else:
bad.append(k)
status = "OK" if not bad else f"WRONG FIELDS {bad}"
print(f"{receipt}: {status}")
print("\n=== SUMMARY ===")
print(f"Receipts matched to ground truth: {matched}/{total}")
for k in fields:
print(f" {k}: {field_ok[k]}/{field_total[k]} correct")
if __name__ == "__main__":
main()
+101
View File
@@ -0,0 +1,101 @@
#!/usr/bin/env python3
"""Step 3: structure raw OCR text from a fuel receipt into JSON via a small
text-only LLM served by Ollama on the host (reached over --network host)."""
import argparse
import json
import os
import time
import requests
OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://127.0.0.1:11434/api/chat")
MODEL = os.environ.get("OLLAMA_MODEL", "qwen2.5:3b")
SCHEMA = {
"type": "object",
"properties": {
"petrol_station": {"type": ["string", "null"]},
"transaction_number": {"type": ["string", "null"]},
"date": {"type": ["string", "null"], "description": "DD.MM.YYYY"},
"time": {"type": ["string", "null"], "description": "HH:MM:SS"},
"price_per_litre": {"type": ["number", "null"]},
"litres": {"type": ["number", "null"]},
"discount": {"type": ["number", "null"]},
"total_price": {"type": ["number", "null"]},
},
"required": [
"petrol_station", "transaction_number", "date", "time",
"price_per_litre", "litres", "discount", "total_price",
],
}
PROMPT_TEMPLATE = """The following is raw OCR text (with some noise/garbage from a security background pattern) extracted from a German fuel/petrol receipt. Extract these fields:
- petrol_station: the name of the petrol station / company (e.g. "Shell", "Aral", "JET")
- transaction_number: the transaction number (look for "Transaktions-Nr." specifically, not "Beleg-Nr."), as a string
- date: the receipt date, formatted DD.MM.YYYY
- time: the receipt time, formatted HH:MM:SS (24h)
- litres and price_per_litre: these two numbers appear together on one line, in this order:
"<litres> l <price_per_litre> EUR/l" (the "l" litre symbol is often misread as "1" or "|").
For example "23,50 l 1,749 EUR/l" means litres=23.50 and price_per_litre=1.749. These are
two different numbers on the same line - don't confuse them with each other or with the
total price/Gesamtbetrag.
- discount: total discount amount in EUR as a positive decimal number (0 if none). Look for
lines with words like "Rabatt", "Bonus", "Tankbonus", "Nachlass" followed by a negative EUR
amount, e.g. "Tankbonus -0,15 EUR" means discount=0.15 (drop the minus sign).
- total_price: the final total amount paid, in EUR, as a decimal number
Ignore garbled/nonsense words - they are OCR noise, not real data. If a field is not present, use null.
OCR TEXT:
---
{ocr_text}
---
Respond with ONLY the JSON object, no other text.
"""
def structure(ocr_text: str) -> dict:
payload = {
"model": MODEL,
"messages": [
{"role": "user", "content": PROMPT_TEMPLATE.format(ocr_text=ocr_text)},
],
"format": SCHEMA,
"stream": False,
"options": {"temperature": 0, "num_ctx": 3072},
}
start = time.time()
resp = requests.post(OLLAMA_URL, json=payload, timeout=300)
resp.raise_for_status()
result = resp.json()
elapsed = time.time() - start
content = result["message"]["content"]
parsed = json.loads(content)
parsed["_elapsed_seconds"] = round(elapsed, 1)
return parsed
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()
os.makedirs(args.out_dir, exist_ok=True)
with open(args.in_path, "r", encoding="utf-8") as f:
ocr_text = f.read()
data = structure(ocr_text)
out_path = os.path.join(args.out_dir, "extracted.json")
with open(out_path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
print(out_path)
if __name__ == "__main__":
main()
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env python3
"""Step 2: run Tesseract OCR (German) over a preprocessed receipt image,
writing the raw recognized text to the output directory for inspection
before it is handed to the structuring step."""
import argparse
import os
import pytesseract
from PIL import Image
def ocr(path: str, out_dir: str) -> str:
os.makedirs(out_dir, exist_ok=True)
img = Image.open(path)
text = pytesseract.image_to_string(img, lang="deu", config="--psm 3")
out_path = os.path.join(out_dir, "ocr.txt")
with open(out_path, "w", encoding="utf-8") as f:
f.write(text)
return out_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()
out_path = ocr(args.in_path, args.out_dir)
print(out_path)
if __name__ == "__main__":
main()
+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()