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:
+19
@@ -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
|
||||
@@ -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>"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -0,0 +1,18 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 matthias
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
|
||||
associated documentation files (the "Software"), to deal in the Software without restriction, including
|
||||
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
|
||||
following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial
|
||||
portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
|
||||
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
|
||||
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
|
||||
USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
@@ -0,0 +1,3 @@
|
||||
# gastronomy
|
||||
|
||||
Extracting and organising fuel data from fuel receipts.
|
||||
@@ -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"
|
||||
@@ -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=
|
||||
@@ -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"
|
||||
@@ -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 []
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user