From ca85530b5ac24c745dc867b7b4544b7606a2b78c Mon Sep 17 00:00:00 2001 From: ml Date: Sun, 4 Oct 2026 10:25:41 +0200 Subject: [PATCH] Add adaptive preprocessing escalation and single-receipt test scripts Preprocessing now tries progressively heavier variants per receipt (plain -> high-pass -> edge-enhance -> both), stopping as soon as a cheap LLM-free arithmetic check on the OCR text looks plausible, so the extra processing only applies to receipts that need it. A flat version of the same heavier processing regressed the full batch by helping hard receipts but hurting easy ones; the adaptive version improves date-matching 37/52 -> 40/52 with no regression (see testing/2026_10_04_gimp_highpass_nlfilter_experiment.md). Also adds test_ocr.sh and test_single.sh for checking a single receipt's OCR text or full pipeline result without a batch run. --- 12_adaptive_preprocess_ocr.sh | 70 +++++++ AGENT.md | 83 +++++--- README.md | 17 ++ test_batch.sh | 20 +- test_ocr.sh | 37 ++++ test_single.sh | 94 +++++++++ ...10_04_gimp_highpass_nlfilter_experiment.md | 182 ++++++++++++++++++ tooling-container/src/adaptive.py | 78 ++++++++ tooling-container/src/plausibility.py | 70 +++++++ tooling-container/src/preprocess.py | 83 ++++++-- tooling-container/src/server.py | 12 +- 11 files changed, 694 insertions(+), 52 deletions(-) create mode 100755 12_adaptive_preprocess_ocr.sh create mode 100755 test_ocr.sh create mode 100755 test_single.sh create mode 100644 testing/2026_10_04_gimp_highpass_nlfilter_experiment.md create mode 100644 tooling-container/src/adaptive.py create mode 100644 tooling-container/src/plausibility.py diff --git a/12_adaptive_preprocess_ocr.sh b/12_adaptive_preprocess_ocr.sh new file mode 100755 index 0000000..6b6ff41 --- /dev/null +++ b/12_adaptive_preprocess_ocr.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +# Combines steps 1+2 (preprocess + OCR) adaptively: tries preprocess.py's +# variants cheapest-first (0=baseline, same as 1_preprocess_image.sh's +# default, up to 3=most elaborate), scoring each variant's OCR text with +# tooling-container/src/plausibility.py and stopping at the first one +# that looks receipt-consistent - no LLM call involved, so this costs +# nothing extra over the single-variant path for the receipts the +# baseline already reads correctly. See tooling-container/src/adaptive.py +# and testing/2026_10_04_gimp_highpass_nlfilter_experiment.md for why. +# +# Writes /variant/{01_gray,...,0X_final}.png and its own ocr.txt +# for every variant tried (for inspection - which variant was kept is in +# the printed summary) and /ocr.txt (the winning variant's OCR +# text, ready for ./3_extract_data.sh). +# +# Usage: ./12_adaptive_preprocess_ocr.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:-fuelish/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")" + +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 "== Adaptively preprocessing + OCRing $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/adaptive.py -in "/data/in/$IN_FILE" -out /data/out + +echo "== Done ==" +echo "Next: ./3_extract_data.sh -in $OUT_ABS/ocr.txt -out $OUT_ABS" diff --git a/AGENT.md b/AGENT.md index a5c58bd..6575f18 100644 --- a/AGENT.md +++ b/AGENT.md @@ -11,12 +11,17 @@ containers; there is no host Python environment to set up. ## Pipeline Three verifiable, independently-inspectable steps, each a thin host -script that runs the actual work inside the `fuelish/tools` container: +script that runs the actual work inside the `fuelish/tools` container +(`server.py` runs the same functions in-process for the live app - see +`12_adaptive_preprocess_ocr.sh` below for why steps 1+2 are combined +there): 1. `1_preprocess_image.sh -in -out ` - grayscale conversion, - a mild bilateral filter, and a percentile contrast stretch. Writes - every intermediate stage (`01_gray.png`, `02_bilateral.png`, - `03_final.png`) so each step can be checked on its own. + a mild bilateral filter, and a percentile contrast stretch (variant 0 + of `tooling-container/src/preprocess.py`'s 4 variants - see + `12_adaptive_preprocess_ocr.sh`). Writes every intermediate stage + (`01_gray.png`, `02_bilateral.png`, `03_final.png`) so each step can + be checked on its own. 2. `2_ocr_image.sh -in /03_final.png -out ` - Tesseract OCR (German), writes `ocr.txt`. 3. `3_extract_data.sh -in /ocr.txt -out ` - sends the raw OCR @@ -24,13 +29,29 @@ script that runs the actual work inside the `fuelish/tools` container: schema, writes `extracted.json`. Preprocessing choice is evidence-based, not assumed: heavier processing -(strong denoising, CLAHE, unsharp masking) was tried and measured worse - -it amplifies the receipt's dotted security-background pattern into OCR -noise. The lighter bilateral+autocontrast approach in -`tooling-container/src/preprocess.py` is what survived testing. +(strong denoising, CLAHE, naive unsharp masking, and a GIMP-derived +high-pass/edge-enhance combo - see +`testing/2026_10_04_gimp_highpass_nlfilter_experiment.md`) consistently +measured worse on the full batch *when applied unconditionally to every +receipt* - each one amplifies the receipt's dotted security-background +pattern into OCR noise, even though each visibly helps the specific hard +receipts it was designed for. That experiment's accepted fix - trying +heavier variants only on receipts the cheap pass already reads +implausibly - is `12_adaptive_preprocess_ocr.sh` below. ## Supporting scripts +- `12_adaptive_preprocess_ocr.sh -in -out ` - combines steps + 1+2 adaptively: tries `preprocess.py`'s 4 variants (0=baseline/same as + `1_preprocess_image.sh`'s default, up to 3=highpass+edge-enhance + combined) cheapest-first, scoring each variant's OCR text with + `tooling-container/src/plausibility.py`'s cheap regex-based arithmetic + check (no LLM call) and stopping at the first variant that looks + receipt-consistent. This is what `test_batch.sh`, `test_single.sh`, and + `server.py` actually use (not steps 1+2 directly) - see + `testing/2026_10_04_gimp_highpass_nlfilter_experiment.md` for why. + Writes `/variant/` per variant tried plus `/ocr.txt` for + the winning one. - `0_run_ollama.sh [-model ]` - starts Ollama as its own long-lived Docker container (`fuelish-ollama`, model weights in the named volume `fuelish-ollama-data`), pulling the model if not already @@ -50,22 +71,36 @@ noise. The lighter bilateral+autocontrast approach in creates sibling `/-scratch/` (intermediate step output per receipt) and `/-result/` (final `extracted.json` per receipt, plus `summary.txt`). Both are gitignored. +- `test_single.sh -in ` - the same pipeline run and + ground-truth comparison as `test_batch.sh`, for one receipt instead of + a whole directory - useful for re-checking a single known failure + without re-running a full batch. Ground truth is optional: if a `*.ods` + is found next to the photo it compares against it, otherwise it just + prints the extracted JSON. Same sibling `-scratch`/`-result` layout as + `test_batch.sh`, scoped to the one receipt. +- `test_ocr.sh -in ` - preprocessing + OCR only (no LLM + extraction, no ground truth), printing the raw OCR text to stdout. + Leaves no files behind (runs in a temp dir, cleaned up on exit) - + for quickly checking what Tesseract actually reads off a receipt, e.g. + while iterating on `tooling-container/src/preprocess.py`. ## Current state -Last full run against the 17-receipt ground-truth test set -(`testdata/2026_new/`, not checked in - see `.gitignore`): +Last full run against the 52-receipt ground-truth test set +(`testdata/2026_benzin_kosten/`, not checked in - see `.gitignore`), +with the adaptive preprocessing escalation described above: -- 15/17 receipts date-matched to ground truth (up from 11/17 with the - original plain-grayscale preprocessing) -- price_per_litre: 11/15 correct, litres: 9/15, discount: 11/15, - total_price: 12/15 +- 40/52 receipts date-matched to ground truth (up from 37/52 with the + single-variant/baseline-only preprocessing) +- price_per_litre: 34/40 correct, litres: 24/40, discount: 34/40, + total_price: 24/40 -Known remaining failure modes, not expected to be fixable by further -preprocessing tuning: +Known remaining failure modes: - OCR garbling on the compact "litres EUR/l price" line on some receipts - (the raw text sometimes lacks recoverable digits) + (the raw text sometimes lacks recoverable digits) even after trying + all 4 preprocessing variants - the adaptive escalation above + meaningfully improved this but didn't eliminate it - Occasional year-digit misreads in the date causing a false date-match failure - Low-resolution source photos (well below the ~4000x3000 the good @@ -79,9 +114,11 @@ text - a subtle failure mode worth remembering if the prompt is revised. ## Not yet built -Per the original idea (see git history / project notes): an Android app -to capture and confirm receipts, a backend server to store raw + confirmed -submissions, a simple authenticated web UI to query/filter/export -(CSV/JSON), and cross-checking total_price against -price_per_litre * litres - discount. None of that exists yet - this repo -currently covers only the extraction pipeline. +The Android app (`android/`), the HTTP job API (`docker-compose.yml`/ +`server.py`), and cross-checking total_price against +price_per_litre * litres - discount (`android/.../ReceiptPlausibility.kt` +on the app side, `tooling-container/src/plausibility.py` on the pipeline +side) all now exist - see `README.md` for how they fit together. Not yet +built: a web UI to query/filter/export outside the app, and auth/TLS on +the server (deliberately out of scope for now - see `server.py`'s +docstring). diff --git a/README.md b/README.md index de5d8ba..f6b4ca9 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,10 @@ tooling-container/ Pipeline source (preprocess.py, ocr.py, extract.py, test_batch.sh Runs the full pipeline over a directory of test receipts and compares against its ground-truth .ods - see AGENT.md's "Current state". +test_single.sh test_batch.sh for one receipt instead of a + directory - re-check a single known failure. +test_ocr.sh Preprocessing + OCR only, prints the raw OCR + text to stdout - no files left behind. docker-compose.yml Runs the pipeline as a long-lived HTTP job API (api + ollama services) for the Android app. android/ The Android app: Kotlin + Jetpack Compose, Room @@ -134,6 +138,19 @@ receipt at a time: # file in that directory ``` +**Re-check one receipt** (ground truth optional - compared if a `*.ods` +is found next to the photo): + +```bash +./test_single.sh -in testdata//some-receipt.jpg +``` + +**Just see what Tesseract reads**, no LLM call, no files left behind: + +```bash +./test_ocr.sh -in testdata//some-receipt.jpg +``` + **Serve it for the app**: ```bash diff --git a/test_batch.sh b/test_batch.sh index d2c31d3..0dbf637 100755 --- a/test_batch.sh +++ b/test_batch.sh @@ -1,17 +1,20 @@ #!/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 - +# Test-only tool: runs the full container pipeline +# (12_adaptive_preprocess_ocr.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 +# /-scratch// - intermediate step output: one +# variant/ subdir per +# preprocessing variant tried +# (01_gray.png, ..., 0X_final.png, +# ocr.txt each) plus ocr.txt for +# the winning variant, for # inspecting each step on its own # /-result// - final extracted.json per receipt, # plus summary.txt with the overall @@ -56,8 +59,7 @@ for img in "${IMAGES[@]}"; do 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 + ./12_adaptive_preprocess_ocr.sh -in "$img" -out "$receipt_scratch" >/dev/null ./3_extract_data.sh -in "$receipt_scratch/ocr.txt" -out "$receipt_result" >/dev/null done diff --git a/test_ocr.sh b/test_ocr.sh new file mode 100755 index 0000000..61a8733 --- /dev/null +++ b/test_ocr.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# Test-only tool: runs preprocessing + OCR (1_preprocess_image.sh, +# 2_ocr_image.sh - variant 0/baseline only, deliberately not +# 12_adaptive_preprocess_ocr.sh) on a single receipt photo and prints the +# raw OCR text to stdout - no LLM extraction step, no ground-truth +# comparison, and no files left behind (unlike test_single.sh/ +# test_batch.sh, which persist scratch/result directories and go through +# the real adaptive pipeline). Useful for quickly checking what Tesseract +# actually reads off one specific preprocessing variant in isolation - +# e.g. while iterating on tooling-container/src/preprocess.py - without +# the adaptive step's variant selection in the way, waiting on the LLM +# call, or needing ground truth. +# +# Usage: ./test_ocr.sh -in +set -euo pipefail + +cd "$(dirname "${BASH_SOURCE[0]}")" + +fail() { echo "FAIL: $*" >&2; exit 1; } + +IN_PATH="" +while [ $# -gt 0 ]; do + case "$1" in + -in) IN_PATH="${2:-}"; shift 2 ;; + *) fail "unknown argument: $1" ;; + esac +done +[ -n "$IN_PATH" ] || fail "usage: $0 -in " +[ -f "$IN_PATH" ] || fail "input file not found: $IN_PATH" + +SCRATCH_DIR="$(mktemp -d)" +trap 'rm -rf "$SCRATCH_DIR"' EXIT + +./1_preprocess_image.sh -in "$IN_PATH" -out "$SCRATCH_DIR" >/dev/null +./2_ocr_image.sh -in "$SCRATCH_DIR/03_final.png" -out "$SCRATCH_DIR" >/dev/null + +cat "$SCRATCH_DIR/ocr.txt" diff --git a/test_single.sh b/test_single.sh new file mode 100755 index 0000000..b6b7c0a --- /dev/null +++ b/test_single.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash +# Test-only tool: runs the full container pipeline +# (12_adaptive_preprocess_ocr.sh, 3_extract_data.sh) on a single receipt +# photo - the test_batch.sh equivalent for re-checking one receipt (e.g. +# a known failure, after a preprocessing/prompt tweak) without re-running +# a whole directory. If a ground-truth spreadsheet (*.ods) is found next +# to the photo, compares the result against it the same way +# test_batch.sh does; otherwise just prints the extracted JSON. +# Everything - preprocessing, OCR, structuring, and the comparison itself +# - runs inside the tooling container; nothing here depends on a host +# Python environment. +# +# For an input photo /.jpg, two sibling directories are +# created (mirroring test_batch.sh's layout, just for one receipt): +# /-scratch// - intermediate step output: one +# variant/ subdir per preprocessing +# variant tried, plus ocr.txt for the +# winning variant +# /-result// - extracted.json, plus summary.txt +# if a ground-truth .ods was found +# +# Usage: ./test_single.sh -in +set -euo pipefail + +cd "$(dirname "${BASH_SOURCE[0]}")" +ROOT="$(pwd)" + +fail() { echo "FAIL: $*" >&2; exit 1; } + +IN_PATH="" +while [ $# -gt 0 ]; do + case "$1" in + -in) IN_PATH="${2:-}"; shift 2 ;; + *) fail "unknown argument: $1" ;; + esac +done +[ -n "$IN_PATH" ] || fail "usage: $0 -in " +[ -f "$IN_PATH" ] || fail "input file not found: $IN_PATH" + +IN_ABS="$(realpath "$IN_PATH")" +IN_DIR="$(dirname "$IN_ABS")" +NAME="$(basename "${IN_ABS%.jpg}")" + +SCRATCH_DIR="$IN_DIR/$NAME-scratch" +RESULT_DIR="$IN_DIR/$NAME-result" +receipt_scratch="$SCRATCH_DIR/$NAME" +receipt_result="$RESULT_DIR/$NAME" +mkdir -p "$receipt_scratch" "$receipt_result" + +echo "== Running pipeline on $NAME ==" +./12_adaptive_preprocess_ocr.sh -in "$IN_ABS" -out "$receipt_scratch" >/dev/null +./3_extract_data.sh -in "$receipt_scratch/ocr.txt" -out "$receipt_result" >/dev/null + +echo +echo "== Extracted ==" +cat "$receipt_result/extracted.json" +echo + +ODS_PATH="$(find "$IN_DIR" -maxdepth 1 -name '*.ods' | head -n1)" +if [ -n "$ODS_PATH" ]; then + echo + echo "== Comparing against ground truth ==" + + IMAGE="${REGISTRY_IMAGE:-fuelish/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" +else + echo "(no ground-truth .ods found next to $IN_PATH - skipping comparison)" +fi + +echo +echo "== Done ==" +echo "Intermediate step output: $receipt_scratch/" +echo "Final result: $receipt_result/extracted.json" diff --git a/testing/2026_10_04_gimp_highpass_nlfilter_experiment.md b/testing/2026_10_04_gimp_highpass_nlfilter_experiment.md new file mode 100644 index 0000000..6438a1a --- /dev/null +++ b/testing/2026_10_04_gimp_highpass_nlfilter_experiment.md @@ -0,0 +1,182 @@ +# GIMP High Pass + NL Filter preprocessing experiment + +Tested whether a GIMP sharpening/denoising combo, applied to the photo +*before* our existing `preprocess.py` (grayscale → bilateral filter → +percentile autocontrast), recovers the compact "litres / EUR-per-litre" +price line that's the pipeline's worst-performing OCR target (see +`AGENT.md`'s "Known remaining failure modes"). Three stress-test receipts +with known-bad baseline OCR on that line (`testdata/2026_stress_test/`, +not checked in): + +- **A** `IMG_20260525_182252232` — ground truth 29,31 l / 1,949 EUR/l +- **B** `IMG_20260525_182144541` — ground truth 17,88 l / 1,989 EUR/l +- **C** `IMG_20260929_182218993` — ground truth 18,86 l / 2,129 EUR/l + +The recipe (discovered manually in GIMP's GUI, applied to the full +uncropped color photo): + +1. **Gaussian Blur** (Size X=Y=4.15, Filter=Auto, Abyss=Clamp) with + **Blending Options: Mode=Grain Extract, Opacity=100** set directly in + the filter dialog — GIMP 2.10's GEGL-based "High Pass" filter has no + Script-Fu/PDB wrapper at all, so this is a Script-Fu-reachable + reproduction of it via the classic manual technique (blur, then + grain-extract-blend against the original). +2. **Levels**: input low=58, gamma=0.96, high=135; output 0–255. +3. **NL Filter**, filter-type=2 (edge-enhancement), alpha=0.69, + radius=0.52. + +## Result + +Every variant beat the pipeline's baseline (no GIMP step) on at least one +field, for all three images — confirming the general direction helps. +But no single step generalized on its own; only the full 2-step recipe +was consistently good-to-exact across all three: + +| Image (ground truth) | Baseline | 1. Blur+GrainExtract | 2. +Levels | 3. NL Filter alone | 4. Full recipe (2+3) | +|---|---|---|---|---|---| +| **A** (29,31l / 1,949) | both unreadable | price≈missing, litres✗ | litres 29,91✗(1 digit) / price **1,949✓** | litres 28,31✗(1 digit) / price 1,983≈ | litres **29,31✓** / price 1,993≈ | +| **B** (17,88l / 1,989) | litres+price both wrong | litres **17,88✓** / price 1,909≈ | litres **17,88✓** / price **1,989✓** | litres✗missing / price≈1989 (garbled) | litres 17,B8≈(1 char) / price **1,989✓** | +| **C** (18,86l / 2,129) | litres wrong, price✗missing | litres **18,86✓** / price✗missing | litres **0.86✗** (broke it!) / price≈2,129 | litres **18,86✓** / price✗missing | litres **18,86✓** / price≈2,129 | + +Note step 2 (Levels) actively *breaks* C's litres reading that was fine +after step 1 alone — a clear example of a step helping one receipt while +hurting another, which is why no single step is a safe generalization on +its own even though each one looks promising in isolation. + +## Root causes / methodology notes + +1. **GIMP's "High Pass" filter has no PDB/Script-Fu wrapper** — confirmed + by querying the full procedure database (`gimp-procedural-db-query`) + for anything matching `high.?pass`; only `plug-in-edge`, + `plug-in-unsharp-mask`, `plug-in-sharpen`, and `gimp-selection-sharpen` + exist. It's GEGL-only in this GIMP version (2.10.30). The manual + Gaussian-Blur-with-Grain-Extract-blend technique is the only + automatable equivalent. +2. **GIMP's own "default" vs "legacy" grain-extract blend-mode variants + turned out not to matter** — tested both (`LAYER-MODE-GRAIN-EXTRACT` + = 46 and `LAYER-MODE-GRAIN-EXTRACT-LEGACY` = 20 against the same + input): numerically near-identical output either way. +3. **A real, unresolved gap remains between GIMP's GUI-applied + Gaussian-Blur-with-in-dialog-blend and a classic two-layer Script-Fu + reproduction** (duplicate layer → blur → `gimp-layer-set-mode` → + flatten) of the *same nominal parameters*. The GUI version's grain- + extract output has roughly double the standard deviation (spread) of + the scripted version's, confirmed via direct pixel-value percentile + comparison (user's real `_5_1.jpg`: mean 118.5, std 40.4; scripted + equivalent: mean 128.0, std 8.7–23.2 across a wide blur-radius sweep + from 8 to 180 — never converges, ruling out a simple radius/scale + miscalibration). `plug-in-gauss`'s legacy-compat wrapper applies an + internal ~0.32× multiplier converting its "radius" input to GEGL's + `std-dev`, and the Grain Extract formula itself was confirmed correct + from GEGL source (`CLAMP(lower - upper + 0.5)` in normalized space). + The actual cause (most likely GEGL's abyss-policy/edge-handling or an + image-precision difference) was not pinned down — **GIMP batch + scripting cannot currently reproduce this step's real-world output + closely enough to trust for pipeline automation.** +4. Separately (fixed along the way, not the cause of the above): GIMP + batch-mode has two sharp edges worth remembering if scripted again — + (a) `gimp-console -b