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:
@@ -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()
|
||||
Reference in New Issue
Block a user