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.
This commit is contained in:
2026-09-28 19:17:47 +02:00
commit aae57d7a2b
15 changed files with 773 additions and 0 deletions
+19
View File
@@ -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
+64
View File
@@ -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 <name>]
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 <photo> -out <scratch-dir>"
+65
View File
@@ -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 <photo> -out <scratch-dir>
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 <photo> -out <scratch-dir>"
[ -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"
+64
View File
@@ -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 <preprocessed-image> -out <scratch-dir>
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 <preprocessed-image> -out <scratch-dir>"
[ -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"
+68
View File
@@ -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 <ocr.txt> -out <scratch-dir>
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 <ocr.txt> -out <scratch-dir>"
[ -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"
+34
View File
@@ -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"
+9
View File
@@ -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=
+94
View File
@@ -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 <path>/<name>, two sibling directories are
# created (mirroring <path>/<name>):
# <path>/<name>-scratch/<receipt>/ - intermediate step output
# (01_gray.png, 02_bilateral.png,
# 03_final.png, ocr.txt) for
# inspecting each step on its own
# <path>/<name>-result/<receipt>/ - final extracted.json per receipt,
# plus summary.txt with the overall
# ground-truth comparison
#
# Usage: ./test_batch.sh -in <test-data-dir>
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 <test-data-dir>"
[ -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/<receipt>/"
echo "Final results + summary: $RESULT_DIR/<receipt>/, $RESULT_DIR/summary.txt"
+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()
+41
View File
@@ -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"