Merge project code with repository-initialized LICENSE/README
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,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