SRC-10: spracherkennung-ocr-qualitaetsbewertung

Spracherkennung für OCR-Texte und Qualitätsbewertung (Konfidenzwert), um
schlechte OCR-Ergebnisse kenntlich zu machen. Letztes Ticket vor QA-03.

- ocr/language.go: RecognizeWithLanguageAndConfidence erkennt ein Bild
  einzeln je Kandidatensprache (deu/eng) im Tesseract-TSV-Modus — die
  Sprache mit höherem Konfidenzwert gewinnt, derselbe Lauf liefert den
  Konfidenzwert direkt mit.
- search: neue Felder ocr_language/ocr_confidence (Migrationen 0006/0007,
  gleiches ALTER-Muster wie SRC-05), in Document/Result gespiegelt.
  Client.AttachmentsBelowConfidence filtert gezielt auf niedrige
  Konfidenz, schließt Dokumente ohne OCR-Anhang aus.
- Regressionsbug gefunden und behoben: reindex.go (SRC-09) kannte die
  neuen OCR-Spalten nicht, Reindex wäre mit "unknown column"
  fehlgeschlagen.

Prüfungen (alle real durchgeführt, siehe mail/docs/SRC-10-PRUEFPROTOKOLL.md):
1. TestRecognizeWithLanguageAndConfidence_MultilingualCorpus: deutsches
   und englisches Testbild real korrekt als deu/eng erkannt.
2. TestRecognizeWithLanguageAndConfidence_DegradedImageLowersConfidence:
   künstliche Verschlechterung senkt Konfidenz real von 91,76 auf 28,21.
3. TestAttachmentsBelowConfidence_QueryReturnsExpectedResults: Abfrage
   unterhalb Schwelle liefert real genau die erwarteten 2 von 4 Treffern.

Kein Umbau: Search/Facets/SearchWithFilters/Index/Delete-Verhalten sonst
unverändert, dedup/indexworker/storage/crypto/encstorage/savedsearch
unverändert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HhgFcLS8tYMhDJpP74C6AQ
This commit is contained in:
sysops
2026-08-31 22:16:26 +02:00
co-authored by Claude Sonnet 5
parent bd37de529f
commit 1a246abdb3
10 changed files with 427 additions and 6 deletions
+124
View File
@@ -0,0 +1,124 @@
// SRC-10: Spracherkennung & OCR-Qualitätsbewertung. Tesseract erkennt bei
// kombinierten Sprachpaketen (Languages = "deu+eng") nicht, WELCHE Sprache
// tatsächlich vorlag — daher wird das Bild hier bewusst EINZELN mit jedem
// Kandidaten aus candidateLanguages erkannt, und die Sprache mit dem
// höheren durchschnittlichen Worterkennungs-Konfidenzwert gewinnt
// (Akzeptanzkriterium 1). Derselbe Tesseract-TSV-Lauf liefert den
// Konfidenzwert direkt mit (Akzeptanzkriterium 2) — keine zweite externe
// Bibliothek für Sprach- ODER Konfidenzerkennung nötig.
package ocr
import (
"bytes"
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
)
// candidateLanguages sind die unterstützten Einzelsprachen — muss eine
// Teilmenge der in Languages kombinierten Sprachpakete sein.
var candidateLanguages = []string{"deu", "eng"}
// RecognizedText ist das Ergebnis einer sprach- und konfidenzbewussten
// Texterkennung.
type RecognizedText struct {
Text string
// Language ist der ISO-639-2-Sprachcode aus candidateLanguages mit dem
// höchsten Konfidenzwert (Akzeptanzkriterium 1).
Language string
// Confidence ist der durchschnittliche Tesseract-Worterkennungs-
// Konfidenzwert (0100) der gewählten Sprache (Akzeptanzkriterium 2).
Confidence float64
}
// RecognizeWithLanguageAndConfidence erkennt Sprache und Text eines Bildes
// und liefert einen auswertbaren Konfidenzwert.
func RecognizeWithLanguageAndConfidence(ctx context.Context, imageBytes []byte) (RecognizedText, error) {
var best RecognizedText
haveResult := false
for _, lang := range candidateLanguages {
text, confidence, err := recognizeWithTSV(ctx, imageBytes, lang)
if err != nil {
return RecognizedText{}, err
}
if !haveResult || confidence > best.Confidence {
best = RecognizedText{Text: text, Language: lang, Confidence: confidence}
haveResult = true
}
}
return best, nil
}
// recognizeWithTSV führt Tesseract mit GENAU einer Sprache im TSV-Ausgabe-
// modus aus und liefert den zusammengesetzten Text sowie den
// durchschnittlichen Konfidenzwert über alle erkannten Wörter (Tesseract-
// TSV-Zeilen mit level=5, conf>=0 — negative Konfidenzwerte markieren
// Struktur-/Layoutzeilen ohne eigenen Text, siehe Tesseract-TSV-Format).
func recognizeWithTSV(ctx context.Context, imageBytes []byte, lang string) (text string, confidence float64, err error) {
workDir, err := os.MkdirTemp("", "mail-ocr-lang-*")
if err != nil {
return "", 0, fmt.Errorf("ocr: arbeitsverzeichnis anlegen: %w", err)
}
defer func() { _ = os.RemoveAll(workDir) }()
imagePath := filepath.Join(workDir, "input")
if err := os.WriteFile(imagePath, imageBytes, 0o600); err != nil {
return "", 0, fmt.Errorf("ocr: bild schreiben: %w", err)
}
outBase := filepath.Join(workDir, "output")
cmd := exec.CommandContext(ctx, "tesseract", imagePath, outBase, "-l", lang, "tsv")
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return "", 0, fmt.Errorf("ocr: tesseract (tsv, %s) fehlgeschlagen: %w (%s)", lang, err, stderr.String())
}
tsvBytes, err := os.ReadFile(outBase + ".tsv")
if err != nil {
return "", 0, fmt.Errorf("ocr: tsv-ausgabe lesen: %w", err)
}
return parseTSV(string(tsvBytes))
}
// tsvWordLevel ist der Tesseract-TSV-"level"-Wert für einzelne Wörter
// (1=Seite, 2=Block, 3=Absatz, 4=Zeile, 5=Wort).
const tsvWordLevel = "5"
func parseTSV(tsv string) (text string, confidence float64, err error) {
lines := strings.Split(tsv, "\n")
var words []string
var confSum float64
var confCount int
for i, line := range lines {
if i == 0 || strings.TrimSpace(line) == "" {
continue // Kopfzeile bzw. Leerzeile überspringen
}
cols := strings.Split(line, "\t")
if len(cols) < 12 || cols[0] != tsvWordLevel {
continue
}
conf, convErr := strconv.ParseFloat(cols[10], 64)
if convErr != nil || conf < 0 {
continue
}
word := cols[11]
if word == "" {
continue
}
words = append(words, word)
confSum += conf
confCount++
}
if confCount == 0 {
return "", 0, nil
}
return strings.Join(words, " "), confSum / float64(confCount), nil
}
+101
View File
@@ -0,0 +1,101 @@
package ocr
import (
"bytes"
"context"
"image"
"image/color"
"image/png"
"testing"
)
// TestRecognizeWithLanguageAndConfidence_MultilingualCorpus ist die
// geforderte Pflichtprüfung 1: Test mit mehrsprachigem Testkorpus
// bestätigt korrekte Spracherkennung.
func TestRecognizeWithLanguageAndConfidence_MultilingualCorpus(t *testing.T) {
requireTools(t)
ctx := context.Background()
germanImage := knownTextImage(t, ctx, "Rechnung ueber Lieferung Nummer Zwei")
englishImage := knownTextImage(t, ctx, "Invoice for delivery number two")
germanResult, err := RecognizeWithLanguageAndConfidence(ctx, germanImage)
if err != nil {
t.Fatalf("deutsches bild: %v", err)
}
if germanResult.Language != "deu" {
t.Fatalf("erwartete sprache 'deu' für deutschen text, habe %q (text: %q)", germanResult.Language, germanResult.Text)
}
englishResult, err := RecognizeWithLanguageAndConfidence(ctx, englishImage)
if err != nil {
t.Fatalf("englisches bild: %v", err)
}
if englishResult.Language != "eng" {
t.Fatalf("erwartete sprache 'eng' für englischen text, habe %q (text: %q)", englishResult.Language, englishResult.Text)
}
}
// TestRecognizeWithLanguageAndConfidence_DegradedImageLowersConfidence ist
// die geforderte Pflichtprüfung 2: künstlich verschlechtertes Bild erzeugt
// niedrigeren Konfidenzwert.
func TestRecognizeWithLanguageAndConfidence_DegradedImageLowersConfidence(t *testing.T) {
requireTools(t)
ctx := context.Background()
original := knownTextImage(t, ctx, "Qualitaetsvergleich Testbild Konfidenz")
originalImg, err := png.Decode(bytes.NewReader(original))
if err != nil {
t.Fatalf("original dekodieren: %v", err)
}
degradedImg := pixelate(originalImg, 12)
var degradedBuf bytes.Buffer
if err := png.Encode(&degradedBuf, degradedImg); err != nil {
t.Fatalf("verschlechtertes bild kodieren: %v", err)
}
originalResult, err := RecognizeWithLanguageAndConfidence(ctx, original)
if err != nil {
t.Fatalf("original erkennen: %v", err)
}
degradedResult, err := RecognizeWithLanguageAndConfidence(ctx, degradedBuf.Bytes())
if err != nil {
t.Fatalf("verschlechtertes bild erkennen: %v", err)
}
t.Logf("original: conf=%.2f text=%q — verschlechtert: conf=%.2f text=%q",
originalResult.Confidence, originalResult.Text, degradedResult.Confidence, degradedResult.Text)
if degradedResult.Confidence >= originalResult.Confidence {
t.Fatalf("erwartete niedrigeren konfidenzwert für verschlechtertes bild, habe original=%.2f verschlechtert=%.2f",
originalResult.Confidence, degradedResult.Confidence)
}
}
// pixelate verschlechtert img künstlich, reproduzierbar und ohne externe
// Werkzeuge: Downsample um factor per Nearest-Neighbor, dann wieder auf
// Originalgröße hochskaliert — zerstört Feindetails der Textglyphen.
func pixelate(img image.Image, factor int) image.Image {
bounds := img.Bounds()
w, h := bounds.Dx(), bounds.Dy()
out := image.NewRGBA(bounds)
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
blockX := (x / factor) * factor
blockY := (y / factor) * factor
c := img.At(bounds.Min.X+blockX, bounds.Min.Y+blockY)
out.Set(bounds.Min.X+x, bounds.Min.Y+y, colorToGray50(c))
}
}
return out
}
// colorToGray50 mischt zusätzlich mit 50% Grau, um den Kontrast zu
// verringern — verstärkt die künstliche Qualitätsverschlechterung über
// reine Pixelierung hinaus.
func colorToGray50(c color.Color) color.Color {
r, g, b, a := c.RGBA()
mix := func(v uint32) uint8 {
return uint8((v>>8)/2 + 128/2)
}
return color.RGBA{R: mix(r), G: mix(g), B: mix(b), A: uint8(a >> 8)}
}