commit aae57d7a2bb2bfb75dde4f433131ba7bb585a5a3 Author: Matthias Ladkau Date: Mon Sep 28 19:17:47 2026 +0200 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. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c713aa7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +# Test receipt images / ground-truth spreadsheets (not checked in) +/testdata/ + +# Local registry credentials (see registry.env.example) +/registry.env + +# Local Claude Code settings +/.claude/settings.local.json + +# Generated pipeline output (see test_batch.sh, 1_preprocess_image.sh, +# 2_ocr_image.sh, 3_extract_data.sh) - intermediate/result dirs are +# created as siblings of whatever -in directory was processed +*-scratch/ +*-result/ + +# Python bytecode cache (picked up if src/ is ever imported on the host +# instead of run through the container) +__pycache__/ +*.pyc diff --git a/0_run_ollama.sh b/0_run_ollama.sh new file mode 100644 index 0000000..524699a --- /dev/null +++ b/0_run_ollama.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# Step 0: start the Ollama inference server that the pipeline's +# structuring step (3_extract_data.sh) talks to. Runs the official +# ollama/ollama image as a long-lived container with model weights kept +# in a named Docker volume - deliberately separate from the +# tooling-container (which is stateless and one-shot per receipt), so the +# model stays loaded in memory between pipeline runs instead of being +# reloaded on every receipt. +# +# Usage: ./0_run_ollama.sh [-model ] +set -euo pipefail + +cd "$(dirname "${BASH_SOURCE[0]}")" + +fail() { echo "FAIL: $*" >&2; exit 1; } + +command -v docker >/dev/null 2>&1 || fail "docker not found in PATH" + +MODEL="qwen2.5:3b" +while [ $# -gt 0 ]; do + case "$1" in + -model) MODEL="${2:-}"; shift 2 ;; + *) fail "unknown argument: $1" ;; + esac +done + +CONTAINER=gastronomy-ollama +VOLUME=gastronomy-ollama-data +PORT=11434 + +if docker ps --format '{{.Names}}' | grep -qx "$CONTAINER"; then + echo "== $CONTAINER already running ==" +elif docker ps -a --format '{{.Names}}' | grep -qx "$CONTAINER"; then + echo "== Starting existing $CONTAINER container ==" + docker start "$CONTAINER" >/dev/null +else + echo "== Creating $CONTAINER (model weights kept in volume $VOLUME) ==" + docker run -d \ + --name "$CONTAINER" \ + -p "127.0.0.1:$PORT:11434" \ + -v "$VOLUME:/root/.ollama" \ + --restart unless-stopped \ + ollama/ollama >/dev/null +fi + +echo "== Waiting for Ollama to become ready ==" +ready=0 +for _ in $(seq 1 30); do + if curl -s --max-time 2 "http://127.0.0.1:$PORT/api/tags" >/dev/null 2>&1; then + ready=1 + break + fi + sleep 1 +done +[ "$ready" = "1" ] || fail "Ollama did not become ready on port $PORT" + +if ! docker exec "$CONTAINER" ollama list | awk 'NR>1{print $1}' | grep -qx "$MODEL"; then + echo "== Pulling model $MODEL ==" + docker exec "$CONTAINER" ollama pull "$MODEL" +fi + +echo "== Done ==" +echo "Ollama running at http://127.0.0.1:$PORT (model: $MODEL)" +echo "Next: ./1_preprocess_image.sh -in -out " diff --git a/1_preprocess_image.sh b/1_preprocess_image.sh new file mode 100644 index 0000000..5bb9ac3 --- /dev/null +++ b/1_preprocess_image.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +# Step 1 of the OCR pipeline: mild smoothing + contrast-stretch a receipt +# photo. Writes each intermediate stage (01_gray, 02_bilateral, 03_final) +# into the output directory so every step can be inspected independently +# before trusting the final image to OCR. +# +# Usage: ./1_preprocess_image.sh -in -out +set -euo pipefail + +cd "$(dirname "${BASH_SOURCE[0]}")" +ROOT="$(pwd)" + +fail() { echo "FAIL: $*" >&2; exit 1; } + +IN_PATH="" +OUT_DIR="" +while [ $# -gt 0 ]; do + case "$1" in + -in) IN_PATH="${2:-}"; shift 2 ;; + -out) OUT_DIR="${2:-}"; shift 2 ;; + *) fail "unknown argument: $1" ;; + esac +done +[ -n "$IN_PATH" ] && [ -n "$OUT_DIR" ] \ + || fail "usage: $0 -in -out " +[ -f "$IN_PATH" ] || fail "input file not found: $IN_PATH" + +command -v docker >/dev/null 2>&1 || fail "docker not found in PATH" + +IMAGE="${REGISTRY_IMAGE:-gastronomy/tools}" +if [ -f "$ROOT/registry.env" ]; then + # shellcheck disable=SC1091 + source "$ROOT/registry.env" + IMAGE="${REGISTRY_IMAGE:-$IMAGE}" +fi +VERSION="$(<"$ROOT/tooling-container/VERSION")" +IMAGE="$IMAGE:$VERSION" +docker image inspect "$IMAGE" >/dev/null 2>&1 \ + || fail "$IMAGE not found locally - run ./build-image.sh first" + +IN_ABS="$(realpath "$IN_PATH")" +IN_DIR="$(dirname "$IN_ABS")" +IN_FILE="$(basename "$IN_ABS")" +mkdir -p "$OUT_DIR" +OUT_ABS="$(realpath "$OUT_DIR")" + +# On a vboxsf-mounted repo, files are owned by root:vboxsf with no +# "other" access - join the mounted dirs' groups so --user can read/write +# them without needing root inside the container. +GROUP_ADD_ARGS=(--group-add "$(stat -c %g "$IN_DIR")") +OUT_GID="$(stat -c %g "$OUT_ABS")" +[ "$OUT_GID" = "$(stat -c %g "$IN_DIR")" ] || GROUP_ADD_ARGS+=(--group-add "$OUT_GID") + +echo "== Preprocessing $IN_FILE ==" +docker run --rm \ + --user "$(id -u):$(id -g)" \ + "${GROUP_ADD_ARGS[@]}" \ + -v "$IN_DIR:/data/in:ro" \ + -v "$OUT_ABS:/data/out" \ + "$IMAGE" \ + python3 /opt/app/src/preprocess.py -in "/data/in/$IN_FILE" -out /data/out + +echo "== Done ==" +echo "Stages written to: $OUT_ABS/{01_gray,02_bilateral,03_final}.png" +echo "Next: ./2_ocr_image.sh -in $OUT_ABS/03_final.png -out $OUT_ABS" diff --git a/2_ocr_image.sh b/2_ocr_image.sh new file mode 100644 index 0000000..1a69f18 --- /dev/null +++ b/2_ocr_image.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# Step 2 of the pipeline: run Tesseract OCR (German) over a preprocessed +# receipt image, writing the raw recognized text to the output directory +# so it can be checked before it is handed to the AI structuring step. +# +# Usage: ./2_ocr_image.sh -in -out +set -euo pipefail + +cd "$(dirname "${BASH_SOURCE[0]}")" +ROOT="$(pwd)" + +fail() { echo "FAIL: $*" >&2; exit 1; } + +IN_PATH="" +OUT_DIR="" +while [ $# -gt 0 ]; do + case "$1" in + -in) IN_PATH="${2:-}"; shift 2 ;; + -out) OUT_DIR="${2:-}"; shift 2 ;; + *) fail "unknown argument: $1" ;; + esac +done +[ -n "$IN_PATH" ] && [ -n "$OUT_DIR" ] \ + || fail "usage: $0 -in -out " +[ -f "$IN_PATH" ] || fail "input file not found: $IN_PATH" + +command -v docker >/dev/null 2>&1 || fail "docker not found in PATH" + +IMAGE="${REGISTRY_IMAGE:-gastronomy/tools}" +if [ -f "$ROOT/registry.env" ]; then + # shellcheck disable=SC1091 + source "$ROOT/registry.env" + IMAGE="${REGISTRY_IMAGE:-$IMAGE}" +fi +VERSION="$(<"$ROOT/tooling-container/VERSION")" +IMAGE="$IMAGE:$VERSION" +docker image inspect "$IMAGE" >/dev/null 2>&1 \ + || fail "$IMAGE not found locally - run ./build-image.sh first" + +IN_ABS="$(realpath "$IN_PATH")" +IN_DIR="$(dirname "$IN_ABS")" +IN_FILE="$(basename "$IN_ABS")" +mkdir -p "$OUT_DIR" +OUT_ABS="$(realpath "$OUT_DIR")" + +# On a vboxsf-mounted repo, files are owned by root:vboxsf with no +# "other" access - join the mounted dirs' groups so --user can read/write +# them without needing root inside the container. +GROUP_ADD_ARGS=(--group-add "$(stat -c %g "$IN_DIR")") +OUT_GID="$(stat -c %g "$OUT_ABS")" +[ "$OUT_GID" = "$(stat -c %g "$IN_DIR")" ] || GROUP_ADD_ARGS+=(--group-add "$OUT_GID") + +echo "== OCR-ing $IN_FILE ==" +docker run --rm \ + --user "$(id -u):$(id -g)" \ + "${GROUP_ADD_ARGS[@]}" \ + -v "$IN_DIR:/data/in:ro" \ + -v "$OUT_ABS:/data/out" \ + "$IMAGE" \ + python3 /opt/app/src/ocr.py -in "/data/in/$IN_FILE" -out /data/out + +echo "== Done ==" +echo "OCR text written to: $OUT_ABS/ocr.txt" +echo "Next: ./3_extract_data.sh -in $OUT_ABS/ocr.txt -out $OUT_ABS" diff --git a/3_extract_data.sh b/3_extract_data.sh new file mode 100644 index 0000000..5233170 --- /dev/null +++ b/3_extract_data.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# Step 3 of the pipeline: send raw OCR text to a small text-only LLM +# (served by Ollama on the host) to structure it into the receipt JSON +# schema. Runs with --network host so the container can reach Ollama at +# 127.0.0.1 on the host; override with OLLAMA_URL / OLLAMA_MODEL env vars +# if Ollama runs elsewhere or you want a different model. +# +# Usage: ./3_extract_data.sh -in -out +set -euo pipefail + +cd "$(dirname "${BASH_SOURCE[0]}")" +ROOT="$(pwd)" + +fail() { echo "FAIL: $*" >&2; exit 1; } + +IN_PATH="" +OUT_DIR="" +while [ $# -gt 0 ]; do + case "$1" in + -in) IN_PATH="${2:-}"; shift 2 ;; + -out) OUT_DIR="${2:-}"; shift 2 ;; + *) fail "unknown argument: $1" ;; + esac +done +[ -n "$IN_PATH" ] && [ -n "$OUT_DIR" ] \ + || fail "usage: $0 -in -out " +[ -f "$IN_PATH" ] || fail "input file not found: $IN_PATH" + +command -v docker >/dev/null 2>&1 || fail "docker not found in PATH" + +IMAGE="${REGISTRY_IMAGE:-gastronomy/tools}" +if [ -f "$ROOT/registry.env" ]; then + # shellcheck disable=SC1091 + source "$ROOT/registry.env" + IMAGE="${REGISTRY_IMAGE:-$IMAGE}" +fi +VERSION="$(<"$ROOT/tooling-container/VERSION")" +IMAGE="$IMAGE:$VERSION" +docker image inspect "$IMAGE" >/dev/null 2>&1 \ + || fail "$IMAGE not found locally - run ./build-image.sh first" + +IN_ABS="$(realpath "$IN_PATH")" +IN_DIR="$(dirname "$IN_ABS")" +IN_FILE="$(basename "$IN_ABS")" +mkdir -p "$OUT_DIR" +OUT_ABS="$(realpath "$OUT_DIR")" + +# On a vboxsf-mounted repo, files are owned by root:vboxsf with no +# "other" access - join the mounted dirs' groups so --user can read/write +# them without needing root inside the container. +GROUP_ADD_ARGS=(--group-add "$(stat -c %g "$IN_DIR")") +OUT_GID="$(stat -c %g "$OUT_ABS")" +[ "$OUT_GID" = "$(stat -c %g "$IN_DIR")" ] || GROUP_ADD_ARGS+=(--group-add "$OUT_GID") + +echo "== Extracting structured data from $IN_FILE ==" +docker run --rm \ + --network host \ + --user "$(id -u):$(id -g)" \ + "${GROUP_ADD_ARGS[@]}" \ + -e OLLAMA_URL="${OLLAMA_URL:-http://127.0.0.1:11434/api/chat}" \ + -e OLLAMA_MODEL="${OLLAMA_MODEL:-qwen2.5:3b}" \ + -v "$IN_DIR:/data/in:ro" \ + -v "$OUT_ABS:/data/out" \ + "$IMAGE" \ + python3 /opt/app/src/extract.py -in "/data/in/$IN_FILE" -out /data/out + +echo "== Done ==" +echo "Extracted JSON written to: $OUT_ABS/extracted.json" diff --git a/build-image.sh b/build-image.sh new file mode 100644 index 0000000..ff090ce --- /dev/null +++ b/build-image.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# Builds the gastronomy tools image (tooling-container/Dockerfile) locally, tagged with +# tooling-container/VERSION and :latest. Does not push - test it with the numbered +# pipeline scripts first, then publish with ./upload-image.sh. +set -euo pipefail + +cd "$(dirname "${BASH_SOURCE[0]}")" +ROOT="$(pwd)" + +fail() { echo "PREFLIGHT FAIL: $*" >&2; exit 1; } + +command -v docker >/dev/null 2>&1 \ + || fail "docker not found in PATH" + +IMAGE="${REGISTRY_IMAGE:-gastronomy/tools}" +if [ -f "$ROOT/registry.env" ]; then + # shellcheck disable=SC1091 + source "$ROOT/registry.env" + IMAGE="${REGISTRY_IMAGE:-$IMAGE}" +fi + +VERSION="$(<"$ROOT/tooling-container/VERSION")" +[ -n "$VERSION" ] || fail "tooling-container/VERSION is empty" + +echo "== Building $IMAGE:$VERSION ==" +docker build \ + -f "$ROOT/tooling-container/Dockerfile" \ + -t "$IMAGE:$VERSION" \ + -t "$IMAGE:latest" \ + "$ROOT/tooling-container" + +echo "== Done ==" +echo "Image: $IMAGE:$VERSION (and :latest)" +echo "Test it with the numbered pipeline scripts, then publish with ./upload-image.sh" diff --git a/registry.env.example b/registry.env.example new file mode 100644 index 0000000..3b8d4c2 --- /dev/null +++ b/registry.env.example @@ -0,0 +1,9 @@ +# Copy to registry.env (gitignored, never commit the real values) and fill +# in your registry credentials. Used by build-image.sh, the numbered +# pipeline scripts, and upload-image.sh. Only REGISTRY_IMAGE is needed for +# local builds/runs; REGISTRY/REGISTRY_USER/REGISTRY_PASSWORD are only +# needed to push with upload-image.sh. +REGISTRY= +REGISTRY_IMAGE=gastronomy/tools +REGISTRY_USER= +REGISTRY_PASSWORD= diff --git a/test_batch.sh b/test_batch.sh new file mode 100644 index 0000000..5e2e700 --- /dev/null +++ b/test_batch.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash +# Test-only tool: runs the full container pipeline (1_preprocess_image.sh, +# 2_ocr_image.sh, 3_extract_data.sh) over every *.jpg in a test-data +# directory, then compares the results against that directory's +# ground-truth spreadsheet (benzin_kosten.ods). Everything - +# preprocessing, OCR, structuring, and the comparison itself - runs +# inside the tooling container; nothing here depends on a host Python +# environment. +# +# For an input directory /, two sibling directories are +# created (mirroring /): +# /-scratch// - intermediate step output +# (01_gray.png, 02_bilateral.png, +# 03_final.png, ocr.txt) for +# inspecting each step on its own +# /-result// - final extracted.json per receipt, +# plus summary.txt with the overall +# ground-truth comparison +# +# Usage: ./test_batch.sh -in +set -euo pipefail + +cd "$(dirname "${BASH_SOURCE[0]}")" +ROOT="$(pwd)" + +fail() { echo "FAIL: $*" >&2; exit 1; } + +IN_DIR="" +while [ $# -gt 0 ]; do + case "$1" in + -in) IN_DIR="${2:-}"; shift 2 ;; + *) fail "unknown argument: $1" ;; + esac +done +[ -n "$IN_DIR" ] || fail "usage: $0 -in " +[ -d "$IN_DIR" ] || fail "input directory not found: $IN_DIR" + +IN_ABS="$(realpath "$IN_DIR")" +ODS_PATH="$(find "$IN_ABS" -maxdepth 1 -name '*.ods' | head -n1)" +[ -n "$ODS_PATH" ] || fail "no .ods ground-truth file found in $IN_ABS" + +PARENT_DIR="$(dirname "$IN_ABS")" +BASE_NAME="$(basename "$IN_ABS")" +SCRATCH_DIR="$PARENT_DIR/$BASE_NAME-scratch" +RESULT_DIR="$PARENT_DIR/$BASE_NAME-result" +mkdir -p "$SCRATCH_DIR" "$RESULT_DIR" + +shopt -s nullglob +IMAGES=("$IN_ABS"/*.jpg) +[ "${#IMAGES[@]}" -gt 0 ] || fail "no .jpg files found in $IN_ABS" + +echo "== Running pipeline over ${#IMAGES[@]} receipts ==" +for img in "${IMAGES[@]}"; do + name="$(basename "${img%.jpg}")" + receipt_scratch="$SCRATCH_DIR/$name" + receipt_result="$RESULT_DIR/$name" + mkdir -p "$receipt_scratch" "$receipt_result" + echo "--- $name ---" + ./1_preprocess_image.sh -in "$img" -out "$receipt_scratch" >/dev/null + ./2_ocr_image.sh -in "$receipt_scratch/03_final.png" -out "$receipt_scratch" >/dev/null + ./3_extract_data.sh -in "$receipt_scratch/ocr.txt" -out "$receipt_result" >/dev/null +done + +echo +echo "== Comparing against ground truth ==" + +IMAGE="${REGISTRY_IMAGE:-gastronomy/tools}" +if [ -f "$ROOT/registry.env" ]; then + # shellcheck disable=SC1091 + source "$ROOT/registry.env" + IMAGE="${REGISTRY_IMAGE:-$IMAGE}" +fi +VERSION="$(<"$ROOT/tooling-container/VERSION")" +IMAGE="$IMAGE:$VERSION" + +ODS_DIR="$(dirname "$ODS_PATH")" +ODS_FILE="$(basename "$ODS_PATH")" +GROUP_ADD_ARGS=(--group-add "$(stat -c %g "$ODS_DIR")") +RESULT_GID="$(stat -c %g "$RESULT_DIR")" +[ "$RESULT_GID" = "$(stat -c %g "$ODS_DIR")" ] || GROUP_ADD_ARGS+=(--group-add "$RESULT_GID") + +docker run --rm \ + --user "$(id -u):$(id -g)" \ + "${GROUP_ADD_ARGS[@]}" \ + -v "$ODS_DIR:/data/gt:ro" \ + -v "$RESULT_DIR:/data/results:ro" \ + "$IMAGE" \ + python3 /opt/app/src/compare_ground_truth.py -gt "/data/gt/$ODS_FILE" -results /data/results \ + | tee "$RESULT_DIR/summary.txt" + +echo +echo "== Done ==" +echo "Intermediate step output: $SCRATCH_DIR//" +echo "Final results + summary: $RESULT_DIR//, $RESULT_DIR/summary.txt" diff --git a/tooling-container/Dockerfile b/tooling-container/Dockerfile new file mode 100644 index 0000000..357e872 --- /dev/null +++ b/tooling-container/Dockerfile @@ -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 [] diff --git a/tooling-container/VERSION b/tooling-container/VERSION new file mode 100644 index 0000000..6e8bf73 --- /dev/null +++ b/tooling-container/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/tooling-container/src/compare_ground_truth.py b/tooling-container/src/compare_ground_truth.py new file mode 100644 index 0000000..7c859b8 --- /dev/null +++ b/tooling-container/src/compare_ground_truth.py @@ -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() diff --git a/tooling-container/src/extract.py b/tooling-container/src/extract.py new file mode 100644 index 0000000..e63ec48 --- /dev/null +++ b/tooling-container/src/extract.py @@ -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: + " l 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() diff --git a/tooling-container/src/ocr.py b/tooling-container/src/ocr.py new file mode 100644 index 0000000..c9feff6 --- /dev/null +++ b/tooling-container/src/ocr.py @@ -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() diff --git a/tooling-container/src/preprocess.py b/tooling-container/src/preprocess.py new file mode 100644 index 0000000..2478608 --- /dev/null +++ b/tooling-container/src/preprocess.py @@ -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() diff --git a/upload-image.sh b/upload-image.sh new file mode 100644 index 0000000..e4a6b0b --- /dev/null +++ b/upload-image.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# Pushes the gastronomy tools image - already built locally with +# ./build-image.sh, and ideally verified with the numbered pipeline +# scripts - to a container registry, so the k3s cluster (or other +# machines) can pull it without rebuilding it. +set -euo pipefail + +cd "$(dirname "${BASH_SOURCE[0]}")" +ROOT="$(pwd)" + +fail() { echo "PREFLIGHT FAIL: $*" >&2; exit 1; } + +command -v docker >/dev/null 2>&1 \ + || fail "docker not found in PATH" + +[ -f "$ROOT/registry.env" ] \ + || fail "registry.env not found - copy registry.env.example to registry.env and fill in your registry credentials" + +# shellcheck disable=SC1091 +source "$ROOT/registry.env" + +: "${REGISTRY:?registry.env must set REGISTRY}" +: "${REGISTRY_IMAGE:?registry.env must set REGISTRY_IMAGE}" +: "${REGISTRY_USER:?registry.env must set REGISTRY_USER}" +: "${REGISTRY_PASSWORD:?registry.env must set REGISTRY_PASSWORD}" + +VERSION="$(<"$ROOT/tooling-container/VERSION")" +[ -n "$VERSION" ] || fail "tooling-container/VERSION is empty" + +docker image inspect "$REGISTRY_IMAGE:$VERSION" >/dev/null 2>&1 \ + || fail "$REGISTRY_IMAGE:$VERSION not found locally - run ./build-image.sh first" + +echo "== Logging in to $REGISTRY ==" +echo "$REGISTRY_PASSWORD" | docker login "$REGISTRY" -u "$REGISTRY_USER" --password-stdin + +echo "== Pushing $REGISTRY_IMAGE:$VERSION and :latest ==" +docker push "$REGISTRY_IMAGE:$VERSION" +docker push "$REGISTRY_IMAGE:latest" + +echo "== Done ==" +echo "Image: $REGISTRY_IMAGE:$VERSION"