OCR mit Cache und Misstrauen
Warum sich eine OCR-Pipeline für Belegdaten erst dann zuverlässig anfühlt, wenn man jeden erkannten Wert mit Hash-Cache, Confidence-Gate und Plausibilitätsregeln absichert statt ihm zu glauben.
OCR mit Cache und Misstrauen
In einem kleinen Inventur-Projekt sollte ich Buchungen aus einem Kontoblatt mit den dazugehörigen Rechnungspositionen zusammenführen. Klingt nach einem Nachmittag Arbeit: PDFs einlesen, Beträge, Datumsangaben und Belegnummern herausziehen, gegen die Buchungen matchen, fertig. Der Haken steckte nicht im Matching. Er steckte darin, dass ein Teil der Rechnungen als gescannte Bilder vorlag und ich die Felder per OCR herausholen musste. Und OCR ist keine Funktion, die Text zurückgibt. OCR ist ein Messgerät, das mit einer gewissen Wahrscheinlichkeit das Richtige misst und mit dem Rest eine teure Fehlerquelle bleibt.
Diese Sichtweise hat die Architektur des kleinen Werkzeugs stärker geprägt als jede Matching-Regel. Sobald man OCR als unsicheren Sensor begreift, ergeben sich drei Pflichten fast von selbst: Man muss die Messung speichern, weil sie teuer und nicht-deterministisch ist. Man muss ihr ein Vertrauensmaß zur Seite stellen, weil sonst ein Lesefehler genauso aussieht wie ein korrekter Treffer. Und man muss das Ergebnis gegen Fachregeln prüfen, weil ein plausibel aussehender Betrag noch lange nicht der richtige Betrag ist.
Der Fehler, der mich überzeugt hat
Die ganze Vorsicht klingt zuerst übertrieben. Überzeugt hat mich ein einzelner Datensatz. Auf einer gescannten Rechnung stand ein Bruttobetrag von 138,00 Euro. Die OCR las daraus 133,00 Euro, weil die 8 in der verwendeten Schrift schmal gesetzt war und der Scan die obere Schleife verschluckt hatte. Eine 8, die zur 3 wird, ist optisch nicht weit entfernt; für ein Modell, das Glyphen aus Pixeln rekonstruiert, ist das ein naheliegender Fehlgriff.
Das Tückische war nicht der Lesefehler. Lesefehler passieren. Das Tückische war, was danach geschah. Im Kontoblatt gab es tatsächlich eine Buchung über 133,00 Euro von einem anderen Vorgang. Das Fuzzy-Matching, das Belegnummer, Datum und Betrag gewichtet, fand für die falsch gelesene Rechnung einen vermeintlich sauberen Partner und ordnete sie der falschen Buchung zu. Der eigentliche 138-Euro-Posten blieb als verwaister Kontoeintrag liegen. Zwei Fehler, die sich gegenseitig plausibel machten. Kein Stacktrace, keine Exception, kein rotes Log. Nur ein Report, der ordentlich aussah und falsch war.
Genau dort liegt die These dieses Textes: Ein OCR-Fehler ohne Confidence und ohne Validierung ist gefährlicher als ein Absturz. Ein Absturz zwingt dich, hinzuschauen. Ein stiller Lesefehler reist durch die ganze Pipeline und tarnt sich als korrektes Ergebnis. Bei Geldbeträgen ist das keine akademische Sorge.
Warum OCR ein Sensor ist und keine Funktion
Es lohnt sich, kurz auseinanderzuhalten, womit man es zu tun hat. Eine reine Textextraktion aus einem digital erzeugten PDF ist deterministisch: Dieselbe Datei liefert denselben Textlayer, jedes Mal. OCR ist das nicht. Sie arbeitet auf Pixeln, ihre Engine ist ein statistisches Modell, und das Ergebnis hängt von Auflösung, Kontrast, Schrift, Seitenausrichtung und der gewählten Konfiguration ab. Zwei Läufe können auseinanderlaufen, wenn man an Vorverarbeitung oder Engine-Version etwas dreht.
Deshalb behandle ich beide Wege getrennt. Erst versuche ich, den eingebetteten Textlayer auszulesen. Nur wenn dieser fehlt oder leer ist, fällt die Pipeline auf OCR zurück. Dieser Fallback ist die Stelle, an der Unsicherheit ins System kommt, und genau diese Stelle muss man markieren, messen und einhegen.
Hilfreich ist, dass ernsthafte OCR-Engines ihre eigene Unsicherheit mitliefern. Tesseract gibt über das TSV-Ausgabeformat pro erkanntem Wort eine Confidence zwischen 0 und 100 aus, zusammen mit Bounding Box und Position auf der Seite. Cloud-Dienste liefern vergleichbare Werte, oft als Float zwischen 0 und 1 pro Feld. Dieser Wert ist die wichtigste Information der ganzen Erkennung, und sie wird in vielen Pipelines einfach weggeworfen, weil image_to_string eben nur einen String zurückgibt. Wer stattdessen image_to_data mit tsv nutzt, bekommt die Confidence pro Wort und kann eine Entscheidung darauf aufbauen.
Eine Faustregel aus der Praxis: Wortwerte unter etwa 95 sind bei Belegdaten oft schon kritisch, oberhalb von 99 meist verlässlich. Wo genau man die Schwelle zieht, hängt vom Schaden eines Fehlers ab. Bei einem Freitextfeld darf die Latte tiefer liegen als bei einem Betrag, der in eine Buchung wandert.
Cache per Inhalts-Hash, nicht per Dateiname
Bevor ich über Schwellen rede, zuerst die unspektakuläre Maßnahme, die am meisten Ruhe in die Pipeline bringt: ein Cache, der die OCR eines Dokuments genau einmal berechnet.
OCR ist langsam und, bei Cloud-Diensten, teuer pro Seite. Ein Inventurlauf, den man während der Entwicklung zwanzig Mal wiederholt, würde zwanzigmal dieselben Seiten erkennen. Das kostet Zeit und Geld und macht jeden Lauf zur Wundertüte, weil das nicht-deterministische Ergebnis bei jedem Durchgang leicht anders ausfallen kann. Ein Cache löst beide Probleme auf einmal: Er macht Läufe reproduzierbar und billig.
Der entscheidende Punkt ist der Cache-Schlüssel. Der Dateiname taugt nicht. Dateien werden umbenannt, verschoben, neu exportiert, und zwei inhaltlich identische Scans können unterschiedlich heißen. Umgekehrt kann hinter demselben Namen ein veränderter Inhalt stecken. Der einzige stabile Schlüssel ist der Inhalt selbst, also ein kryptografischer Hash der Bytes, üblicherweise SHA-256. Gleicher Inhalt, gleicher Hash, gleicher Cache-Eintrag, unabhängig davon, wie die Datei gerade heißt.
In den Schlüssel gehört aber mehr als nur der Datei-Hash. Wenn ich die OCR-Konfiguration ändere, etwa den Page-Segmentation-Mode oder die Engine-Version, sind alte Ergebnisse nicht mehr vergleichbar. Deshalb fließt auch eine Versionskennung der Pipeline in den Schlüssel ein. So invalidiert eine geänderte Konfiguration den Cache automatisch, ohne dass ich von Hand aufräumen muss.
import hashlib
import json
from dataclasses import dataclass, asdict
from pathlib import Path
import pytesseract
from PIL import Image
# Änderungen an dieser Konfiguration müssen den Cache invalidieren,
# sonst vergleicht man Ergebnisse aus unterschiedlichen Setups.
OCR_VERSION = "tesseract-5.3.4+psm6+v3"
CONF_MIN = 95.0 # Wortwerte darunter gelten als unsicher
CACHE_DIR = Path(".ocr-cache")
@dataclass
class Word:
text: str
conf: float # 0..100, direkt aus dem TSV-Ausgabeformat
def file_key(path: Path) -> str:
"""Inhalts-Hash plus Pipeline-Version. Der Dateiname spielt bewusst keine Rolle."""
digest = hashlib.sha256(path.read_bytes()).hexdigest()
return hashlib.sha256(f"{digest}:{OCR_VERSION}".encode()).hexdigest()
def run_ocr(path: Path) -> list[Word]:
"""Teuer und nicht-deterministisch. Genau deshalb wird das Ergebnis gecacht."""
data = pytesseract.image_to_data(
Image.open(path),
config="--psm 6",
output_type=pytesseract.Output.DICT,
)
words: list[Word] = []
for text, conf in zip(data["text"], data["conf"]):
text = text.strip()
if not text:
continue
words.append(Word(text=text, conf=float(conf)))
return words
def ocr_cached(path: Path) -> list[Word]:
"""Idempotenter Zugriff: gleicher Inhalt liefert immer denselben Cache-Eintrag."""
CACHE_DIR.mkdir(exist_ok=True)
cache_file = CACHE_DIR / f"{file_key(path)}.json"
if cache_file.exists():
raw = json.loads(cache_file.read_text())
return [Word(**w) for w in raw]
words = run_ocr(path)
cache_file.write_text(json.dumps([asdict(w) for w in words], ensure_ascii=False))
return words
Der Cache liegt hier bewusst als JSON pro Dokument auf der Platte. Das ist für ein lokales Werkzeug völlig ausreichend und hat einen angenehmen Nebeneffekt: Die Cache-Dateien sind lesbar und diffbar. Wer wissen will, was die Erkennung für ein bestimmtes Dokument geliefert hat, öffnet die Datei. Ein Review eines OCR-Ergebnisses ist damit ein git diff und keine Datenbankabfrage.
Das Confidence-Gate
Mit Cache und Wort-Confidence in der Hand wird aus dem stillen Fehler von oben ein lauter. Der Trick ist, die Confidence nicht zu ignorieren, sondern sie zur Entscheidung zu machen: Felder über der Schwelle gelten als vertrauenswürdig und dürfen automatisch weiterfließen, Felder darunter werden markiert und landen in der manuellen Prüfung.
import re
AMOUNT_RE = re.compile(r"^\d{1,3}(?:[.\s]\d{3})*,\d{2}$") # z. B. 1.234,56
@dataclass
class Field:
raw: str
conf: float
needs_review: bool
reason: str = ""
def extract_amount(words: list[Word]) -> Field:
"""Sucht den Bruttobetrag und entscheidet anhand der Confidence über das weitere Vorgehen."""
candidates = [w for w in words if AMOUNT_RE.match(w.text)]
if not candidates:
return Field(raw="", conf=0.0, needs_review=True, reason="kein Betrag erkannt")
# Der letzte plausible Betrag auf dem Beleg ist meist der Gesamtbetrag.
best = candidates[-1]
if best.conf < CONF_MIN:
return Field(
raw=best.text,
conf=best.conf,
needs_review=True,
reason=f"Confidence {best.conf:.0f} unter Schwelle {CONF_MIN:.0f}",
)
return Field(raw=best.text, conf=best.conf, needs_review=False)
Hätte die falsch gelesene 133,00 eine niedrige Confidence gehabt, wäre sie hier hängengeblieben. Damit ist das Gate aber noch nicht fertig, denn die unbequeme Wahrheit ist: OCR-Engines sind manchmal selbstbewusst falsch. Eine sauber gedruckte 3, die eigentlich eine 8 sein sollte, kann mit Confidence 98 zurückkommen. Das Modell ist sich sicher, die Glyphe heißt 3, weil sie wie eine 3 aussieht. Die Information, dass es eigentlich 8 sein müsste, steckt nicht im Pixelbild, sondern im Fachkontext. Confidence allein reicht deshalb nicht. Sie ist notwendig, aber sie fängt nur die Fälle, in denen das Modell selbst zögert.
Validierung gegen die Welt, nicht gegen sich selbst
Die zweite Verteidigungslinie prüft nicht, wie sicher sich die Erkennung war, sondern ob das Ergebnis zur Realität passt. Solche Regeln kosten wenig und fangen genau die selbstbewussten Fehler, an denen das Confidence-Gate scheitert.
Bei Belegdaten gibt es eine Reihe naheliegender Prüfungen. Ein Bruttobetrag muss der Summe aus Nettobetrag und Steuer entsprechen; weicht das ab, stimmt mindestens einer der drei Werte nicht. Ein Rechnungsdatum muss vor dem Buchungsdatum liegen und darf nicht in der Zukunft stehen. Eine Belegnummer hat oft ein festes Format oder gar eine Prüfziffer; passt die nicht, war die Erkennung falsch. Und ein Betrag, der gegen eine Buchung gematcht werden soll, muss innerhalb einer engen Toleranz liegen, nicht innerhalb von fünf Euro.
Im konkreten Fehlerfall hätte eine einzige dieser Regeln gereicht. Wenn der Beleg Netto und Steuer separat ausweist, dann ergibt 138,00 als Brutto bei 19 Prozent einen Nettowert von 115,97, während 133,00 zu 111,76 führt. Liest die OCR Netto und Brutto unabhängig und stimmt die Summenrechnung nicht, fällt die falsche 3 sofort auf, ganz egal wie sicher sich die Engine war. Die Redundanz auf dem Beleg ist das Kontrollmaß, das die Pixel nicht liefern können.
from decimal import Decimal
def parse_eur(s: str) -> Decimal:
return Decimal(s.replace(".", "").replace(",", "."))
def cross_check_total(netto: Field, steuer: Field, brutto: Field) -> Field:
"""Eine hohe Confidence überzeugt nicht, wenn die Summe nicht aufgeht."""
if any(f.needs_review or not f.raw for f in (netto, steuer, brutto)):
return brutto # fehlende Teile klärt ohnehin schon die Prüfung
expected = parse_eur(netto.raw) + parse_eur(steuer.raw)
actual = parse_eur(brutto.raw)
if abs(expected - actual) > Decimal("0.02"): # Toleranz nur für Rundung
return Field(
raw=brutto.raw,
conf=brutto.conf,
needs_review=True,
reason=f"Summe {expected} weicht vom gelesenen Brutto {actual} ab",
)
return brutto
Wichtig ist die Reihenfolge im Kopf: Confidence und Validierung sind kein Entweder-oder. Ein Feld mit hoher Confidence, das eine Fachregel verletzt, gehört trotzdem in die Prüfung. Und ein Feld mit niedriger Confidence, das alle Regeln erfüllt, ist immer noch verdächtig genug, um es einem Menschen zu zeigen. Beide Tore müssen offen sein, damit ein Wert ohne Aufsicht durchläuft.
Der Entscheidungsfluss im Überblick
flowchart TD
A[Dokument] --> B{Textlayer vorhanden?}
B -->|ja| C[Text direkt lesen<br/>deterministisch]
B -->|nein| D{Im Hash-Cache?}
D -->|ja| E[Cache-Eintrag laden]
D -->|nein| F[OCR ausführen<br/>teuer, unsicher]
F --> G[Ergebnis unter Inhalts-Hash cachen]
G --> E
C --> H[Felder extrahieren]
E --> H
H --> I{Confidence über Schwelle?}
I -->|nein| R[markieren: manuelle Prüfung]
I -->|ja| J{Fachregeln erfuellt?}
J -->|nein| R
J -->|ja| K[automatisch ins Matching]
R --> L[Korrektur als Daten speichern]
L --> K
Zwei Tore stehen zwischen einem erkannten Wert und seiner automatischen Verwendung. Wer sie ernst nimmt, akzeptiert, dass ein Teil der Belege manuell durch die Hände eines Menschen geht. Das ist keine Niederlage. Reife Systeme verarbeiten den Großteil der Dokumente ohne Eingriff und leiten je nach Beleglage einen kleineren Anteil in die Prüfung. Dass dieser Anteil existiert, ist das Eingeständnis, dass OCR ein Sensor bleibt und kein Orakel.
Korrekturen sind Daten, keine Einmal-Aktionen
Was passiert mit den Feldern, die in der Prüfung landen? Ein Mensch schaut auf den Beleg und auf den markierten Wert und korrigiert die 133 zurück auf 138. Wenn diese Korrektur nur in den aktuellen Lauf einfließt und danach verschwindet, hat man das Problem nicht gelöst, sondern nur einmal umschifft. Beim nächsten Lauf mit derselben Datei liest die OCR wieder 133, und jemand korrigiert wieder.
Deshalb behandle ich Korrekturen als erstklassige Daten. Eine Korrektur bindet sich an den Inhalts-Hash des Dokuments und überschreibt das OCR-Ergebnis für genau dieses Feld. Sie liegt neben dem Cache, ist versioniert und nachvollziehbar. Beim nächsten Lauf greift die Korrektur, bevor die unsichere Messung überhaupt zum Zug kommt. Das verwandelt die manuelle Prüfung von einer wiederkehrenden Last in eine einmalige Investition pro Dokument. Nebenbei entsteht ein Datensatz, an dem man später ablesen kann, an welchen Stellen die Erkennung systematisch schwächelt, welche Schriften, welche Scanner, welche Felder.
Diese Trennung hat noch einen Vorteil für die Nachvollziehbarkeit. Im Report lässt sich für jeden Wert sagen, woher er stammt: aus dem deterministischen Textlayer, aus der OCR oberhalb der Schwelle, oder aus einer menschlichen Korrektur. Drei Herkünfte, drei Vertrauensstufen, und alle drei sind sichtbar. Ein Wert, der aus einer Korrektur stammt, trägt seine Geschichte mit sich, statt sich als anonymer String zu tarnen.
Was ich beim nächsten Mal gleich so bauen würde
Im Rückblick sind es wenige Entscheidungen, die den Unterschied gemacht haben, und keine davon war aufwendig. Der Inhalts-Hash als Cache-Schlüssel hat eine halbe Stunde gekostet und jeden weiteren Entwicklungslauf billig und reproduzierbar gemacht. Das Confidence-Gate war eine Handvoll Zeilen, sobald ich von image_to_string auf image_to_data umgestiegen bin und aufgehört habe, die Confidence wegzuwerfen. Die Fachregeln waren am Ende die wertvollste Schicht, weil sie genau die Klasse von Fehlern fängt, bei denen das Modell sich sicher und gleichzeitig irrt.
Der gemeinsame Nenner ist eine Haltung, kein Framework. Man kann sie in einem Satz fassen, der über OCR hinausreicht: Jede Größe, die ein statistisches Modell erzeugt, kommt mit einem Vertrauensmaß, und ein System, das dieses Maß ignoriert, verschiebt seine Fehler nur an eine Stelle, an der niemand mehr hinschaut. Bei Geldbeträgen ist diese Stelle der Jahresabschluss. Mir ist eine markierte Zeile im Report lieber als eine saubere Zahl, der ich nicht trauen kann.
Weiterführende Quellen
- Tesseract, TSV-Ausgabeformat mit Wort-Confidence: Tesseract OCR und Erläuterung des TSV-Formats
- Confidence-Schwellen in der Praxis: Diskussion zur Berechnung der Tesseract-Confidence
- Validierung und menschliche Prüfung bei Beleg-OCR: Invoice OCR Error Handling
Kommentare
Kommentar schreiben