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.
Tesseract liefert für erkannte Wörter im TSV-Ausgabeformat einen Confidence-Wert, üblicherweise zwischen null und hundert; Strukturzeilen können -1 tragen. Dieser interne Score ist ein nützliches Signal, aber keine kalibrierte Wahrscheinlichkeit dafür, dass das Wort korrekt ist. image_to_string gibt ihn nicht heraus. Wer nur den String nimmt, wirft damit eine wichtige Information über die Erkennung weg.
Aufgefallen ist mir das in einem kleinen Inventur-Projekt, in dem Buchungen aus einem Kontoblatt mit den dazugehörigen Rechnungspositionen zusammengeführt werden sollten. 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. OCR ist keine Funktion, die Text zurückgibt. OCR ist ein Messgerät: Es trifft mit einer gewissen Wahrscheinlichkeit das Richtige und ist im Rest der Fälle eine teure Fehlerquelle.
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 speichert die Messung, weil sie teuer ist und sich mit Vorverarbeitung, Konfiguration oder Engine-Version ändern kann. Man behält den Confidence-Score, weil sonst ein Lesefehler genauso aussieht wie ein korrekter Treffer. Und man prüft das Ergebnis gegen Fachregeln, 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 darin liegt die Gefahr: Ein OCR-Fehler ohne Confidence und ohne Validierung richtet mehr Schaden an 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 liest einen vorhandenen Textlayer. OCR arbeitet dagegen auf Pixeln, und das Ergebnis hängt von Auflösung, Kontrast, Schrift, Seitenausrichtung, Vorverarbeitung, Konfiguration und Engine-Version ab. Ein lokaler Tesseract-Lauf mit identischen Bytes und identischer Umgebung ist in der Regel reproduzierbar; ändert sich einer dieser Faktoren, kann auch das Ergebnis wechseln.
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. Bei Tesseract kommt die Wort-Confidence über das TSV-Ausgabeformat, 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 er landet trotzdem meist im Nichts, 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.
In meinem Belegdatensatz waren Wortwerte unter etwa 95 oft kritisch und Werte oberhalb von 99 deutlich verlässlicher. Das sind keine allgemein gültigen Grenzen. Die Schwelle muss auf gelabelten Beispielen für Engine, Dokumenttyp und Feld kalibriert werden und hängt zusätzlich 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. Ein Cache pinnt zusätzlich das einmal beobachtete Ergebnis, sodass ein Wechsel von Engine, Konfiguration oder externem Dienst nicht unbemerkt historische Auswertungen verändert.
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
# Changes to this configuration must invalidate the cache,
# otherwise results from different setups get compared.
OCR_VERSION = "tesseract-5.3.4+psm6+v3"
CONF_MIN = 95.0 # word values below this count as uncertain
CACHE_DIR = Path(".ocr-cache")
@dataclass
class Word:
text: str
conf: float # 0..100, straight from the TSV output format
def file_key(path: Path) -> str:
"""Content hash plus pipeline version. The file name deliberately plays no role."""
digest = hashlib.sha256(path.read_bytes()).hexdigest()
return hashlib.sha256(f"{digest}:{OCR_VERSION}".encode()).hexdigest()
def run_ocr(path: Path) -> list[Word]:
"""Expensive; cache the observed result together with its pipeline version."""
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]:
"""Idempotent access: same content always yields the same cache entry."""
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 lokal diffbar. Bei Rechnungen und anderen personenbezogenen Dokumenten gehören solche OCR-Ausgaben allerdings nicht unverschlüsselt ins Git-Repository. Nachvollziehbarkeit braucht einen geschützten Speicher mit passender Aufbewahrung und Zugriffskontrolle.
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}$") # e.g. 1.234,56
@dataclass
class Field:
raw: str
conf: float
needs_review: bool
reason: str = ""
def extract_amount(words: list[Word]) -> Field:
"""Finds the gross amount and decides how to proceed based on the confidence."""
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="no amount detected")
# The last plausible amount on the receipt is usually the total.
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} below threshold {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. 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:
"""A high confidence is unconvincing when the sum does not add up."""
if any(f.needs_review or not f.raw for f in (netto, steuer, brutto)):
return brutto # missing parts are already handled by the review anyway
expected = parse_eur(netto.raw) + parse_eur(steuer.raw)
actual = parse_eur(brutto.raw)
if abs(expected - actual) > Decimal("0.02"): # tolerance only for rounding
return Field(
raw=brutto.raw,
conf=brutto.conf,
needs_review=True,
reason=f"Sum {expected} differs from read gross {actual}",
)
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 erfüllt?}
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 vollwertige Daten. Eine Korrektur bindet sich an den Inhalts-Hash des Dokuments und überschreibt das OCR-Ergebnis für genau dieses Feld. Sie liegt geschützt 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 sensibler Datensatz, an dem man später ablesen kann, an welchen Stellen die Erkennung systematisch schwächelt – mit denselben Schutz- und Löschpflichten wie für die Quelldokumente.
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. Wo ein Modell einen Confidence- oder Unsicherheitswert liefert, sollte das System ihn aufbewahren, ohne ihn vorschnell als Wahrscheinlichkeit zu lesen. Wo ein solcher Wert fehlt oder schlecht kalibriert ist, müssen Fachregeln, Stichproben und menschliche Prüfung die Unsicherheit sichtbar machen. Bei Geldbeträgen ist mir 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
Wie fandest du diesen Beitrag?
Kommentare