# OCR Pipeline - Schrittweise Seitenrekonstruktion

**Version:** 4.3.0
**Status:** Produktiv (Schritte 1–10 implementiert)
**URL:** https://macmini:3002/ai/ocr-pipeline

## Uebersicht

Die OCR Pipeline zerlegt den OCR-Prozess in **10 einzelne Schritte**, um eingescannte Seiten
aus mehrspaltig gedruckten Schulbuechern Wort fuer Wort zu rekonstruieren.
Jeder Schritt kann individuell geprueft, korrigiert und mit Ground-Truth-Daten versehen werden.

**Ziel:** 10 Vokabelseiten fehlerfrei rekonstruieren.

### Pipeline-Schritte

| Schritt | Name | Beschreibung | Status |
|---------|------|--------------|--------|
| 1 | Orientierung | 90/180/270° Drehungen von Scannern korrigieren | Implementiert |
| 2 | Begradigung (Deskew) | Scan begradigen (Hough Lines + Word Alignment) | Implementiert |
| 3 | Entzerrung (Dewarp) | Buchwoelbung entzerren (Vertikalkanten-Analyse) | Implementiert |
| 4 | Zuschneiden (Crop) | Content-basierter Crop: Buchruecken-Schatten + Ink-Projektion | Implementiert |
| 5 | Spaltenerkennung | Unsichtbare Spalten finden (Projektionsprofile + Wortvalidierung) | Implementiert |
| 6 | Zeilenerkennung | Horizontale Zeilen + Kopf-/Fusszeilen-Klassifikation + Luecken-Heilung | Implementiert |
| 7 | Worterkennung | Hybrid-Grid (v2) oder Words-First (bottom-up) | Implementiert |
| 8 | Korrektur | Zeichenverwirrung + regel-basierte Rechtschreibkorrektur (SSE-Stream) | Implementiert |
| 9 | Rekonstruktion | Interaktive Zellenbearbeitung auf Bildhintergrund (Fabric.js) | Implementiert |
| 10 | Validierung | Ground-Truth-Vergleich und Qualitaetspruefung | Implementiert |

!!! note "Reihenfolge-Aenderung (v4.1)"
    Crop wurde hinter Deskew/Dewarp verschoben. Das Bild ist dann bereits gerade,
    was den Content-basierten Crop deutlich zuverlaessiger macht — insbesondere
    bei Buchscans mit Ruecken-Schatten und weissem Scanner-Hintergrund.

---

## Dokumenttyp-Erkennung und Pipeline-Pfade

### Automatische Weiche: `detect_document_type()`

Nicht jedes Dokument durchlaeuft denselben Pfad. Nach den gemeinsamen Vorverarbeitungsschritten
(Orientierung, Deskew, Dewarp, Crop) analysiert `detect_document_type()` die Seitenstruktur
**ohne OCR** — rein ueber Projektionsprofile und Textdichte-Analyse (< 2 Sekunden).

```
detect_document_type(ocr_img, img_bgr) → DocumentTypeResult
```

#### Entscheidungslogik

```mermaid
flowchart TD
    A[Bild-Input] --> B[Vertikales Projektionsprofil]
    B --> C{Interne Spalten-Gaps >= 2?}
    C -->|Ja| D{Zeilen-Gaps >= 5?}
    D -->|Ja| E["vocab_table<br/>pipeline = cell_first<br/>confidence 0.7–0.95"]
    D -->|Nein| F{Zeilen-Gaps >= 3?}
    C -->|Nein| G{Interne Spalten-Gaps >= 1?}
    G -->|Ja| F
    G -->|Nein| H["full_text<br/>pipeline = full_page<br/>skip: columns, rows"]
    F -->|Ja| I["generic_table<br/>pipeline = cell_first<br/>confidence 0.5–0.85"]
    F -->|Nein| H
```

| Dokumenttyp | Spalten-Gaps | Zeilen-Gaps | Pipeline | Beispiel |
|-------------|-------------|-------------|----------|----------|
| `vocab_table` | ≥ 2 | ≥ 5 | `cell_first` | 3-spaltige Schulbuch-Vokabeltabelle |
| `generic_table` | ≥ 1 | ≥ 3 | `cell_first` | 2-spaltiges Glossar |
| `full_text` | 0 | egal | `full_page` | Fliesstext, Aufsatz, Buchseite |

### Komplett-Flussdiagramm

```
┌─────────────────────────────────────────────────────────────────────┐
│ GEMEINSAME VORVERARBEITUNG (alle Dokumente)                         │
│                                                                     │
│ Schritt 1: Orientierung (90/180/270° Drehung korrigieren)           │
│ Schritt 2: Deskew (Hough Lines + Iterative Projektion + Ensemble)   │
│ Schritt 3: Dewarp (Vertikalkanten-Drift, Ensemble Shear)            │
│ Schritt 4: Crop (Content-basiert: Schatten + Ink-Projektion)        │
└─────────────────────────────────────┬───────────────────────────────┘
                                      │
                            detect_document_type()
                                      │
                    ┌──────────────────┼──────────────────┐
                    ▼                  ▼                  ▼
            FULL-TEXT PFAD     WORDS-FIRST PFAD    CELL-FIRST PFAD
            (pipeline=         (grid_method=        (grid_method=
             'full_page')       'words_first')       'v2', default)
                    │                  │                  │
            Keine Spalten/     Tesseract Full-Page  Spaltenerkennung
            Zeilen             word_boxes            detect_column_geometry()
            analyze_layout_    _cluster_columns()    _detect_sub_columns()
            by_words()         _cluster_rows()       expand_narrow_columns()
                    │          _build_cells()         Zeilenerkennung
                    │                  │              detect_row_geometry()
                    │          build_grid_from_       │
                    │          words()           build_cell_grid_v2()
                    │                  │              │
                    │                  │    ┌─────────┴──────────┐
                    │                  │    ▼                    ▼
                    │                  │  Breite Spalten   Schmale Spalten
                    │                  │  (>= 15% Breite)  (< 15% Breite)
                    │                  │  Full-Page Words  Cell-Crop OCR
                    │                  │  word_lookup       cell_crop_v2
                    │                  │    │                    │
                    └──────────────────┴────┴────────────────────┘
                                                │
                                    Post-Processing Pipeline
                                    (Lautschrift, Komma-Split, etc.)
                                                │
                                    Schritt 8: Korrektur (Spell)
                                    Schritt 9: Rekonstruktion
                                    Schritt 10: Validierung
```

---

## Architektur

```
Admin-Lehrer (Next.js)          klausur-service (FastAPI :8086)
┌────────────────────┐          ┌─────────────────────────────┐
│ /ai/ocr-pipeline   │          │ /api/v1/ocr-pipeline/       │
│                    │  REST    │                             │
│ PipelineStepper    │◄────────►│ Sessions CRUD               │
│ StepDeskew         │          │ Image Serving               │
│ StepDewarp         │   SSE    │ Deskew/Dewarp/Columns/Rows  │
│ StepColumnDetection│◄────────►│ Word Recognition            │
│ StepRowDetection   │          │ Correction (Spell-Checker)  │
│ StepWordRecognition│          │ Reconstruction              │
│ StepLlmReview      │          │ Ground Truth                │
│ StepReconstruction │          └─────────────────────────────┘
│ StepGroundTruth    │                    │
└────────────────────┘                    ▼
                               ┌─────────────────────┐
                               │ PostgreSQL           │
                               │ ocr_pipeline_sessions│
                               │ (Images + JSONB)     │
                               └─────────────────────┘
```

### Dateistruktur

```
klausur-service/backend/
├── services/
│   └── cv_vocab_pipeline.py            # Computer Vision + NLP Algorithmen
├── ocr_pipeline_api.py                 # FastAPI Router (Schritte 2-10)
├── orientation_crop_api.py             # FastAPI Router (Schritte 1 + 4)
├── cv_box_detect.py                    # Box-Erkennung + Zonen-Aufteilung
├── cv_graphic_detect.py                # Grafik-/Bilderkennung (Region-basiert)
├── cv_color_detect.py                  # Farbtext-Erkennung (HSV-Analyse)
├── cv_words_first.py                   # Words-First Grid Builder (bottom-up)
├── page_crop.py                        # Content-basierter Crop-Algorithmus
├── ocr_pipeline_session_store.py       # PostgreSQL Persistence
├── layout_reconstruction_service.py    # Fabric.js JSON + PDF/DOCX Export
└── migrations/
    ├── 002_ocr_pipeline_sessions.sql   # Basis-Schema
    ├── 003_add_row_result.sql          # Row-Result Spalte
    └── 004_add_word_result.sql         # Word-Result Spalte

admin-lehrer/
├── app/(admin)/ai/ocr-pipeline/
│   ├── page.tsx                        # Haupt-Page mit Session-Management
│   └── types.ts                        # TypeScript Interfaces
├── app/(admin)/ai/ocr-overlay/
│   ├── page.tsx                        # OCR Overlay: 3 Modi (Pipeline/Paddle/Kombi)
│   └── types.ts                        # OVERLAY_/PADDLE_DIRECT_/KOMBI_STEPS
├── components/ocr-overlay/
│   ├── PaddleDirectStep.tsx            # Wiederverwendbar fuer Paddle Direct + Kombi
│   └── OverlayReconstruction.tsx       # Overlay-Anzeige auf Bildhintergrund
└── components/ocr-pipeline/
    ├── PipelineStepper.tsx              # Fortschritts-Stepper
    ├── StepOrientation.tsx             # Schritt 1: Orientierung
    ├── StepDeskew.tsx                   # Schritt 2: Begradigung
    ├── StepDewarp.tsx                   # Schritt 3: Entzerrung
    ├── StepCrop.tsx                     # Schritt 4: Zuschneiden
    ├── StepColumnDetection.tsx          # Schritt 5: Spaltenerkennung
    ├── StepRowDetection.tsx             # Schritt 6: Zeilenerkennung
    ├── StepWordRecognition.tsx          # Schritt 7: Worterkennung
    ├── StepStructureDetection.tsx       # Schritt 8: Strukturerkennung
    ├── StepLlmReview.tsx               # Schritt 9: Korrektur (SSE-Stream)
    ├── StepReconstruction.tsx           # Schritt 9: Rekonstruktion (Canvas + Overlay)
    ├── usePixelWordPositions.ts        # Shared Hook: Pixel-basierte Wortpositionierung
    ├── FabricReconstructionCanvas.tsx   # Fabric.js Editor
    └── StepGroundTruth.tsx             # Schritt 10: Validierung
```

---

## API-Referenz

Alle Endpoints unter `/api/v1/ocr-pipeline/`.

### Sessions

| Methode | Pfad | Beschreibung |
|---------|------|--------------|
| `POST` | `/sessions` | Neue Session erstellen (Bild hochladen) |
| `GET` | `/sessions` | Alle Sessions auflisten |
| `GET` | `/sessions/{id}` | Session-Info mit allen Step-Results |
| `PUT` | `/sessions/{id}` | Session umbenennen |
| `DELETE` | `/sessions/{id}` | Session loeschen |
| `POST` | `/sessions/{id}/detect-type` | Dokumenttyp erkennen |

### Bilder

| Methode | Pfad | Beschreibung |
|---------|------|--------------|
| `GET` | `/sessions/{id}/image/original` | Originalbild |
| `GET` | `/sessions/{id}/image/oriented` | Orientiertes Bild |
| `GET` | `/sessions/{id}/image/deskewed` | Begradigtes Bild |
| `GET` | `/sessions/{id}/image/dewarped` | Entzerrtes Bild |
| `GET` | `/sessions/{id}/image/cropped` | Zugeschnittenes Bild |
| `GET` | `/sessions/{id}/image/binarized` | Binarisiertes Bild |
| `GET` | `/sessions/{id}/image/columns-overlay` | Spalten-Overlay |
| `GET` | `/sessions/{id}/image/rows-overlay` | Zeilen-Overlay |
| `GET` | `/sessions/{id}/image/words-overlay` | Wort-Grid-Overlay |

### Schritt 1: Orientierung

| Methode | Pfad | Beschreibung |
|---------|------|--------------|
| `POST` | `/sessions/{id}/orientation` | 90/180/270° Drehung erkennen und korrigieren |

### Schritt 2: Begradigung

| Methode | Pfad | Beschreibung |
|---------|------|--------------|
| `POST` | `/sessions/{id}/deskew` | Automatische Begradigung |
| `POST` | `/sessions/{id}/deskew/manual` | Manuelle Winkelkorrektur |
| `POST` | `/sessions/{id}/ground-truth/deskew` | Ground Truth speichern |

### Schritt 3: Entzerrung

| Methode | Pfad | Beschreibung |
|---------|------|--------------|
| `POST` | `/sessions/{id}/dewarp` | Automatische Entzerrung |
| `POST` | `/sessions/{id}/dewarp/manual` | Manueller Scherbungswinkel |
| `POST` | `/sessions/{id}/adjust-combined` | Kombinierte Rotation + Shear Feinabstimmung |
| `POST` | `/sessions/{id}/ground-truth/dewarp` | Ground Truth speichern |

### Schritt 4: Zuschneiden

| Methode | Pfad | Beschreibung |
|---------|------|--------------|
| `POST` | `/sessions/{id}/crop` | Automatischer Content-Crop |
| `POST` | `/sessions/{id}/crop/manual` | Manueller Crop (Prozent-Koordinaten) |
| `POST` | `/sessions/{id}/crop/skip` | Crop ueberspringen |

### Schritt 5: Spalten

| Methode | Pfad | Beschreibung |
|---------|------|--------------|
| `POST` | `/sessions/{id}/columns` | Automatische Spaltenerkennung |
| `POST` | `/sessions/{id}/columns/manual` | Manuelle Spalten-Definition |
| `POST` | `/sessions/{id}/ground-truth/columns` | Ground Truth speichern |

### Schritt 6: Zeilen

| Methode | Pfad | Beschreibung |
|---------|------|--------------|
| `POST` | `/sessions/{id}/rows` | Automatische Zeilenerkennung |
| `POST` | `/sessions/{id}/rows/manual` | Manuelle Zeilen-Definition |
| `POST` | `/sessions/{id}/ground-truth/rows` | Ground Truth speichern |
| `GET` | `/sessions/{id}/ground-truth/rows` | Ground Truth abrufen |

### Schritt 7: Worterkennung

| Methode | Pfad | Beschreibung |
|---------|------|--------------|
| `POST` | `/sessions/{id}/words` | Wort-Grid erstellen |
| `POST` | `/sessions/{id}/ground-truth/words` | Ground Truth speichern |
| `GET` | `/sessions/{id}/ground-truth/words` | Ground Truth abrufen |

**Query-Parameter fuer `/sessions/{id}/words`:**

| Parameter | Default | Beschreibung |
|-----------|---------|--------------|
| `engine` | `auto` | OCR-Engine: `auto`, `tesseract`, `rapid`, `paddle` |
| `pronunciation` | `british` | IPA-Woerterbuch: `british` oder `american` |
| `stream` | `false` | SSE-Streaming (nur bei `grid_method=v2`) |
| `skip_heal_gaps` | `false` | Zeilen-Luecken nicht heilen (Overlay-Modus) |
| `grid_method` | `v2` | Grid-Strategie: `v2` (top-down) oder `words_first` (bottom-up) |

### Schritt 8: Strukturerkennung

| Methode | Pfad | Beschreibung |
|---------|------|--------------|
| `POST` | `/sessions/{id}/detect-structure` | Boxen, Zonen, Farben und Grafiken erkennen |
| `GET` | `/sessions/{id}/image/structure-overlay` | Overlay mit allen Strukturelementen |

### Schritt 9: Korrektur

| Methode | Pfad | Beschreibung |
|---------|------|--------------|
| `POST` | `/sessions/{id}/llm-review?stream=true` | SSE-Stream Korrektur starten |
| `POST` | `/sessions/{id}/llm-review/apply` | Ausgewaehlte Korrekturen speichern |

### Schritt 10: Rekonstruktion

| Methode | Pfad | Beschreibung |
|---------|------|--------------|
| `POST` | `/sessions/{id}/reconstruction` | Zellaenderungen speichern |
| `GET` | `/sessions/{id}/reconstruction/fabric-json` | Fabric.js Canvas-Daten |
| `GET` | `/sessions/{id}/reconstruction/export/pdf` | PDF-Export (reportlab) |
| `GET` | `/sessions/{id}/reconstruction/export/docx` | DOCX-Export (python-docx) |
| `POST` | `/sessions/{id}/reconstruction/detect-images` | Bildbereiche per VLM erkennen |
| `POST` | `/sessions/{id}/reconstruction/generate-image` | Bild per mflux generieren |
| `POST` | `/sessions/{id}/reconstruction/validate` | Validierung speichern (Step 10) |
| `GET` | `/sessions/{id}/reconstruction/validation` | Validierungsdaten abrufen |

---

## Schritt 4: Zuschneiden/Crop (Detail)

### Warum Crop nach Deskew/Dewarp?

In frueheren Versionen lief Crop als Schritt 2 (vor Deskew). Das fuehrte zu Problemen:

- **Schiefes Bild**: `boundingRect` einer schiefen Seite schliesst viel Scanner-Hintergrund ein
- **Buchscans**: Otsu-Binarisierung versagt bei weiss-auf-weiss (Seite auf weissem Scanner)
- **Buchruecken**: Gradueller Schatten-Uebergang wird nicht als Kante erkannt

**Loesung (v4.1):** Crop laeuft jetzt nach Dewarp — das Bild ist dann gerade.

### Algorithmus: Content-basierte 4-Kanten-Erkennung

Datei: `page_crop.py`

```
Input: Entzerrtes BGR-Bild
  │
  ├─ Adaptive Threshold (Gauss, blockSize=51)
  │   → binary (Text=255, Hintergrund=0)
  │
  ├─ Linker Rand (Buchruecken-Schatten):
  │   1. Grauwert-Spaltenmittel in linken 25%
  │   2. Glaetten mit Boxcar-Kernel
  │   3. Transition hell→dunkel finden (> 60% des Helligkeitsbereichs)
  │   4. Fallback: Binaere Vertikal-Projektion
  │
  ├─ Rechter Rand: Binaere Vertikal-Projektion (letzte Ink-Spalte)
  │
  ├─ Oben/Unten: Binaere Horizontal-Projektion (erste/letzte Ink-Zeile)
  │
  ├─ Rausch-Filter: Runs < 0.5% der Dimension ignorieren
  │
  ├─ Sanity-Checks:
  │   - Mindestens eine Kante > 2% Border
  │   - Crop-Flaeche >= 40% des Originals
  │
  └─ Crop + konfigurierbarer Rand (default 1%)
```

### Vergleich alt vs. neu

| Eigenschaft | Alt (Otsu + Kontur) | Neu (Content-basiert) |
|-------------|--------------------|-----------------------|
| Binarisierung | Otsu (global) | Adaptive Threshold |
| Methode | Groesste Kontur → boundingRect | 4-Kanten Ink-Projektion |
| Buchruecken | Nicht erkannt | Schatten-Gradient-Erkennung |
| Weiss-auf-weiss | Versagt | Funktioniert (adaptive) |
| Format-Matching | A4/Letter erzwungen | Kein Format-Matching (Content-Bounds) |
| Position in Pipeline | Vor Deskew (Schritt 2) | Nach Dewarp (Schritt 4) |

---

## Schritt 3: Entzerrung/Dewarp (Detail)

### Algorithmus: Vertikalkanten-Drift

Die Dewarp-Erkennung misst die **vertikale Spaltenkippung** (dx/dy) statt Textzeilen-Neigung:

1. Woerter werden nach X-Position in vertikale Spaltencluster gruppiert
2. Pro Cluster: Lineare Regression `x = a*y + b` → `a = dx/dy = tan(shear_angle)`
3. Ensemble aus drei Methoden: Textzeilen (1.5× Gewicht), Projektionsprofil (2-Pass), Vertikalkanten
4. Qualitaetspruefung: Horizontale Projektionsvarianz vor/nach Korrektur

**Schwellenwerte:**

| Parameter | Wert | Beschreibung |
|-----------|------|--------------|
| Min. Korrekturwinkel | 0.08° | Unter 0.08° wird nicht korrigiert |
| Ensemble Min-Confidence | 0.35 | Mindest-Konfidenz fuer Korrektur |
| Quality-Gate Skip | < 0.5° | Kleine Korrekturen ueberspringen Quality-Gate |

### Feinabstimmung (Combined Adjust)

Der Endpoint `POST /sessions/{id}/adjust-combined` erlaubt die kombinierte Feinabstimmung von
Rotation und Shear in einem Schritt. Im Frontend stehen **7 Schieberegler** zur Verfuegung:

**Rotation (3 Paesse):**

| Slider | Bereich | Beschreibung |
|--------|---------|--------------|
| P1 Iterative | ±5° | Erster Deskew-Pass (Hough Lines) |
| P2 Word-Alignment | ±3° | Zweiter Pass (Wort-Ausrichtung) |
| P3 Textline | ±3° | Dritter Pass (Textzeilen-Regression) |

Die Summe aller drei ergibt den finalen Rotationswinkel.

**Shear (4 Methoden, Radio-Auswahl):**

| Slider | Bereich | Beschreibung |
|--------|---------|--------------|
| A: Textline Drift | ±5° | Textzeilen-Drift |
| B: Projection Profile | ±5° | 2-Pass Projektionsprofil |
| C: Vertical Edges | ±5° | Vertikalkanten-Analyse |
| D: Ensemble | ±5° | Gewichteter Ensemble-Wert |

Nur der per Radio-Button ausgewaehlte Shear-Wert wird verwendet.

```
POST /sessions/{id}/adjust-combined
Body: {"rotation_degrees": 1.23, "shear_degrees": -0.45}
Response: {"method_used": "manual_combined", "shear_degrees": -0.45, "dewarped_image_url": "..."}
```

---

## Schritt 5: Spaltenerkennung (Detail)

### Algorithmus: `detect_column_geometry()`

Mehrstufige Erkennung: Seite segmentieren, vertikale Projektionsprofile finden Luecken, Wort-Bounding-Boxes validieren.

```
Bild → Binarisierung → Seiten-Segmentierung → Vertikalprofil → Lueckenerkennung → Wort-Validierung → ColumnGeometry
```

**Wichtige Implementierungsdetails:**

- **Initialer Tesseract-Scan:** Laeuft auf der vollen Bildbreite `[left_x : w]` (nicht nur bis zur Content-Grenze `right_x`), damit Woerter am rechten Rand der letzten Spalte nicht uebersehen werden.
- **Letzte Spalte:** Wird immer bis zur vollen Bildbreite `w` ausgedehnt, nicht nur bis zur erkannten Content-Grenze.
- **Phantom-Spalten-Filter (Step 9):** Spalten mit Breite < 3 % der Content-Breite UND < 3 Woerter werden als Artefakte entfernt; die angrenzenden Spalten schliessen die Luecke.
- **Spaltenzuweisung:** Woerter werden anhand des groessten horizontalen Ueberlappungsbereichs einer Spalte zugeordnet.

### Seiten-Segmentierung an Sub-Headern

Farbige Zwischenueberschriften (z.B. „Unit 4: Bonnie Scotland" mit blauem Hintergrund)
erzeugen nach Binarisierung Tinte ueber die gesamte Seitenbreite. Diese Baender fuellen
Spaltenluecken im vertikalen Projektionsprofil auf und fuehren zu fragmentierten Spalten
(z.B. 11 statt 5).

**Loesung: Horizontale Gap-Segmentierung (Step 2b)**

1. **Horizontales Projektionsprofil** berechnen: Zeilensummen ueber den Content-Bereich
2. **Leere Zeilen** erkennen: Zeilen mit < 2% Tinten-Dichte (`H_GAP_THRESH = 0.02`)
3. **Gaps sammeln**: Zusammenhaengende leere Zeilen zu Gaps buendeln (Mindestlaenge: `max(5, h/200)`)
4. **Grosse Gaps identifizieren**: Gaps > 1.8× Median-Gap-Hoehe = Sub-Header-Trennungen
5. **Segmente bilden**: Seite an grossen Gaps aufteilen
6. **Groesstes Segment waehlen**: Das hoechste Segment wird fuer die vertikale Projektion verwendet

```
┌─────────────────────────────────┐
│  Header / Titel                 │  ─── grosser Gap ───
├─────────────────────────────────┤
│  EN  │  DE  │ Example │ Page   │  ← Segment 1 (groesster)
│  ... │  ... │ ...     │ ...    │
├─────────────────────────────────┤
│  Unit 4: Bonnie Scotland        │  ─── grosser Gap ───
├─────────────────────────────────┤
│  EN  │  DE  │ Example │ Page   │  ← Segment 2
│  ... │  ... │ ...     │ ...    │
└─────────────────────────────────┘
```

**Segment-gefilterte Wort-Validierung:**

Die Wort-Validierung (Step 5) nutzt nur Tesseract-Woerter **innerhalb des gewaehlten Segments**.
Woerter aus Sub-Header-Bereichen (die die volle Breite einnehmen) werden so ausgeschlossen
und koennen die Spaltenluecken-Validierung nicht verfaelschen.

### Word-Coverage Gap Detection (Fallback)

Wenn die pixel-basierte Projektion keine ausreichenden Spaltenluecken findet
(z.B. bei Seiten mit Illustrationen, die Spaltenluecken teilweise verdecken),
greift ein Fallback auf Basis der Tesseract-Wort-Bounding-Boxes:

1. X-Achse in 2px-Bins aufteilen
2. Pro Bin zaehlen, wie viele Segment-Woerter ihn ueberdecken
3. Zusammenhaengende Bins mit 0 Woertern = Gap-Kandidaten
4. Nur Gaps im inneren 90%-Bereich beruecksichtigen (Raender ignorieren)
5. Gaps mit Mindestbreite (`max(8px, content_w * 0.5%)`) werden als Spaltenluecken akzeptiert

### Sub-Spalten-Erkennung: `_detect_sub_columns()`

Erkennt versteckte Sub-Spalten innerhalb breiter Spalten (z.B. Seitenzahl-Spalte links neben EN-Vokabeln).

**Algorithmus (Left-Edge Alignment Clustering):**

1. Fuer jede Spalte mit `width_ratio >= 0.15` und `word_count >= 5`:
2. Left-Edges aller Woerter mit `conf >= 30` sammeln
3. In Alignment-Bins clustern (8px Toleranz)
4. Linkester Bin mit >= 10% der Woerter = wahrer Spaltenanfang
5. Woerter links davon = Sub-Spalte, wenn >= 2 und < 35% Anteil
6. Neue ColumnGeometry-Objekte mit korrekten Indizes erzeugen

**Koordinatensystem:** Word `left`-Werte sind relativ zum Content-ROI (`left_x`), `ColumnGeometry.x` ist absolut. `left_x` wird als Parameter durchgereicht.

### Spalten-Erweiterung: `expand_narrow_columns()`

Laeuft **nach** `_detect_sub_columns()`. Erweitert sehr schmale Spalten (< 10% Content-Breite,
z.B. `page_ref`, `marker`) in den Weissraum zum Nachbar-Spalte hinein, aber nie ueber die
naechsten Woerter im Nachbarn hinaus (4px Sicherheitsabstand).

### Spaltentyp-Klassifikation: `classify_column_types()`

| Spaltentyp | Beschreibung | Erkennung |
|------------|--------------|-----------|
| `column_en` | Englische Vokabeln | EN-Funktionswoerter (the, a, is...) |
| `column_de` | Deutsche Uebersetzung | DE-Funktionswoerter (der, die, das...) |
| `column_example` | Beispielsaetze | Abkuerzungen, Grammatik-Marker |
| `page_ref` | Seitenzahlen | Schmal (< 20% Breite), wenige Woerter |
| `column_marker` | Dekorative Markierungen | Sehr schmal, spezielle Zeichen |
| `column_text` | Generischer Text | Fallback |

### Konfigurierbare Parameter

```python
# Mindestbreite fuer echte Spalten (automatisch: max(20px, 3% content_w))
min_real_col_w = max(20, int(content_w * 0.03))
```

---

## Schritt 6: Zeilenerkennung (Detail)

### Algorithmus: `detect_row_geometry()`

Horizontale Projektionsprofile finden Zeilen-Luecken; word-level Validierung verhindert Fehlschnitte.

**Zusaetzliche Post-Processing-Schritte:**

1. **Artefakt-Zeilen entfernen** (`_is_artifact_row`):
   Zeilen, in denen alle erkannten Tokens nur 1 Zeichen lang sind (Scan-Schatten, leere Zeilen),
   werden als Artefakte klassifiziert und aus dem Grid entfernt.

2. **Luecken-Heilung** (`_heal_row_gaps`):
   Nach dem Entfernen leerer/Artefakt-Zeilen werden die verbleibenden Zeilen auf die Mitte
   der entstehenden Luecke ausgedehnt, damit kein Zeileninhalt durch schrumpfende Grenzen
   abgeschnitten wird.

3. **Box-Boundary-Schutz** (`box_ranges_inner`, neu in v4.2):
   Bei Seiten mit Box-Zonen (Sub-Sessions) werden Zeilen am Box-Rand nicht faelschlich
   ausgeschlossen. Das Problem: Die letzte Textzeile ueber einer Box ueberlappt haeufig
   mit dem Box-Rahmen. Loesung: Die Exclusion-Zone wird um `max(border_thickness, 5px)`
   geschrumpft, sodass nur Zeilen **innerhalb** der Box ausgeschlossen werden.

```python
def _is_artifact_row(row: RowGeometry) -> bool:
    """Zeile ist Artefakt wenn alle Tokens <= 1 Zeichen."""
    if row.word_count == 0: return True
    return all(len(w.get('text','').strip()) <= 1 for w in row.words)

def _heal_row_gaps(rows, top_bound, bottom_bound):
    """Verbleibende Zeilen auf Mitte der Luecken ausdehnen."""
    ...
```

### Box-Zonen und Content-Strips (Detail)

Seiten mit Box-Bereichen (z.B. Grammatik-Tipps, Uebungsboxen) werden in Zonen aufgeteilt:

```
┌──────────────────────────┐
│ Content Zone 0 (Zeilen)  │ ← Vokabeltabelle oben
├──────────────────────────┤
│ ███ Box Zone (border) ███│ ← Sub-Session mit eigener OCR
├──────────────────────────┤
│ Content Zone 2 (Zeilen)  │ ← Vokabeltabelle unten
└──────────────────────────┘
```

**Content-Strip-Verfahren** (`detect_rows` in `ocr_pipeline_api.py`):

1. Box-Zonen identifizieren, `box_ranges_inner` berechnen (geschrumpft um Border-Dicke)
2. Content-Strips = Seitenbereiche **ohne** Box-Inneres, vertikal gestapelt
3. Zeilenerkennung auf gestapeltem Bild, Y-Koordinaten zurueckgemappt
4. Wort-Filterung: Woerter in Box-Innerem werden ausgeschlossen

**Wichtig:** `box_ranges_inner` (nicht `box_ranges`) wird verwendet, damit
Zeilen am Box-Rand nicht abgeschnitten werden. Minimum 5px Margin.

---

## Schritt 7: Worterkennung (Detail)

Schritt 7 bietet zwei Grid-Strategien, auswaehlbar per `grid_method`-Parameter:

| Strategie | Parameter | Ansatz | Benoetigt Spalten/Zeilen? |
|-----------|-----------|--------|--------------------------|
| **Hybrid-Grid v2** | `grid_method=v2` (Default) | Top-down: Spalten → Zeilen → Zellen → OCR | Ja (Schritte 5+6) |
| **Words-First** | `grid_method=words_first` | Bottom-up: Woerter → Spalten clustern → Zeilen clustern → Zellen | Nein |

---

### Words-First Grid Builder: `build_grid_from_words()`

**Datei:** `cv_words_first.py`

Der Words-First Builder arbeitet bottom-up: Er nimmt die pixelgenauen `word_boxes` aus einem
Tesseract Full-Page-Lauf und clustert sie direkt zu Spalten und Zeilen — ohne die
vorherige Spalten-/Zeilenerkennung (Schritte 5+6) zu benoetigen.

#### Algorithmus

```
Eingabe: word_dicts (flat list), img_w, img_h
                    │
        ┌───────────┴───────────┐
        │ 1. Confidence-Filter  │
        │    conf >= 30         │
        │    Whitespace entf.   │
        └───────────┬───────────┘
                    │
        ┌───────────┴───────────┐
        │ 2. _cluster_columns() │
        │    X-Gap-Analyse      │
        │    Schwelle: median_h │
        │    × 3 (min 3% Breite)│
        └───────────┬───────────┘
                    │
        ┌───────────┴───────────┐
        │ 3. _cluster_rows()    │
        │    Y-Proximity-Grupp. │
        │    Toleranz: median_h │
        │    / 2                │
        └───────────┬───────────┘
                    │
        ┌───────────┴───────────┐
        │ 4. _build_cells()     │
        │    Wort → (col, row)  │
        │    Text + bbox + conf │
        │    word_boxes pro Zelle│
        └───────────┬───────────┘
                    │
            Ausgabe: cells[], columns_meta[]
            (identisch zu build_cell_grid_v2)
```

#### Spalten-Clustering

1. Alle Woerter nach X-Mitte sortieren
2. Aufeinanderfolgende X-Gaps berechnen
3. Adaptiver Schwellwert: `median_word_height × 3` (min 3% Bildbreite)
4. Gaps > Schwellwert = Spaltengrenzen
5. Kein Gap gefunden → 1 Spalte (`column_text`)
6. Spaltentypen: `column_1`, `column_2`, ... (generisch, positionsbasiert)

#### Zeilen-Clustering

1. Woerter zu visuellen Zeilen gruppieren (Y-Toleranz: halbe Worthoehe)
2. Jede visuelle Zeile = eine Zeile im Grid
3. Sortiert von oben nach unten

#### Edge Cases

| Fall | Behandlung |
|------|------------|
| Einzelne Spalte (Fliesstext) | Kein X-Gap → 1 Spalte `column_text` |
| Keine Woerter erkannt | Leeres Ergebnis `([], [])` |
| Ueberschriften (grosse Schrift) | Eigene Zeile durch Y-Gap |
| Bilder/Grafiken | Keine Woerter → automatisch leerer Bereich |
| Schmale Spalten (Seitenzahlen) | Eigene Spalte durch X-Gap |

#### Vergleich v2 vs. Words-First

| Kriterium | v2 (Top-Down) | Words-First (Bottom-Up) |
|-----------|---------------|------------------------|
| **Abhaengigkeiten** | Spalten + Zeilen noetig | Nur Tesseract-Woerter |
| **Spaltentypen** | Semantisch (EN, DE, ...) | Positionsbasiert (1, 2, ...) |
| **OCR** | Hybrid (full-page + cell-crop) | Nur full-page Tesseract |
| **Robustheit** | Abhaengig von Spalten-/Zeilenerkennung | Direkt aus Wortpositionen |
| **Geschwindigkeit** | Langsamer (cell-crop pro Zelle) | Schneller (kein OCR-Lauf) |
| **Genauigkeit** | Besser bei schmalen Spalten | Besser bei ungewoehnlichen Layouts |

---

### Hybrid-Grid v2: `build_cell_grid_v2()`

Schritt 7 nutzt im Default eine **Hybrid-Strategie**: Breite Spalten verwenden die Full-Page-Tesseract-Woerter,
schmale Spalten werden isoliert per Cell-Crop OCR verarbeitet.

!!! success "Warum Hybrid?"
    Full-Page OCR liefert gute Ergebnisse fuer breite Spalten (Saetze, IPA-Klammern, Interpunktion).
    Aber bei schmalen Spalten (Seitenzahlen, Marker) „bluten" Woerter aus Nachbar-Spalten ein.
    Cell-Crop isoliert jede Zelle und verhindert dieses Bleeding.

### Broad vs. Narrow — Die 15%-Schwelle

```python
_NARROW_COL_THRESHOLD_PCT = 15.0  # cv_vocab_pipeline.py
```

| Eigenschaft | Breite Spalten (>= 15%) | Schmale Spalten (< 15%) |
|-------------|------------------------|------------------------|
| **OCR-Quelle** | Full-Page Tesseract (vorher gelaufen) | Isolierter Cell-Crop |
| **Wort-Zuweisung** | `_assign_row_words_to_columns()` | Direktes Zell-OCR |
| **Confidence-Filter** | `conf >= 30` | `conf >= 30` |
| **Text-Bereinigung** | `_clean_cell_text()` (mittel) | `_clean_cell_text_lite()` (aggressiv) |
| **Neighbour-Bleeding** | Risiko vorhanden | Verhindert (isoliert) |
| **Parallelisierung** | Sequentiell | Parallel (`max_workers=4`) |
| **OCR-Engine Label** | `word_lookup` | `cell_crop_v2` |
| **Typische Spalten** | EN-Vokabeln, DE-Uebersetzung, Beispielsaetze | Seitenzahlen, Marker |

**Empirische Grundlage:** Typische breite Spalten liegen bei 20–40% Bildbreite,
typische schmale bei 3–12%. Die 15%-Grenze trennt diese Gruppen sauber.

!!! note "Offener Punkt: Schwellen-Validierung"
    Die 15%-Schwelle wurde an Vokabeltabellen mit 3–5 Spalten validiert.
    Fuer eine breitere Validierung werden diverse Schulbuchseiten mit unterschiedlichen
    Layouts (2-, 3-, 4-, 5-spaltig, verschiedene Verlage) benoetigt. Aktuell gibt es
    in der Datenbank nur Sessions mit demselben Arbeitsblatt-Typ.

### Cell-Crop OCR: `_ocr_cell_crop()`

Isolierte OCR einer einzelnen Zelle (Spalte × Zeile Schnittflaeche):

1. **Crop:** Exakte Spalten- × Zeilengrenzen mit 3px internem Padding
2. **Density-Check:** Ueberspringe leere Zellen (`dark_ratio < 0.005`)
3. **Upscaling:** Kleine Crops (Hoehe < 80px) werden 3× vergroessert
4. **OCR:** Engine-spezifisch (Tesseract, TrOCR, RapidOCR, LightON, PaddleOCR)
5. **Fallback:** Bei leerem Ergebnis → PSM 7 (Einzelzeile) statt PSM 6
6. **Bereinigung:** `_clean_cell_text_lite()` (aggressives Noise-Filtering)

### PaddleOCR Remote-Engine (`engine=paddle`)

PaddleOCR (PP-OCRv5 Latin) laeuft als eigenstaendiger Microservice auf einem Hetzner x86_64 Server,
da PaddlePaddle nicht auf ARM64 (Mac Mini) laeuft.

```
Mac Mini (klausur-service)          Hetzner (paddleocr-service)
       │   HTTPS POST + Bild           │
       │ ──────────────────────────▶    │  PP-OCRv5 Latin
       │                                │  FastAPI (Port 8095)
       │   JSON word_boxes              │  API-Key Auth
       │ ◀──────────────────────────    │
```

**Besonderheiten:**

- Erzwingt automatisch `grid_method=words_first` (full-page OCR, kein cell-crop)
- Async HTTP-Client (`paddleocr_remote.py`) mit 30s Timeout
- Koordinaten sind bereits absolut (kein content_bounds Offset noetig)
- API-Key Authentifizierung ueber `X-API-Key` Header
- Dateien: `paddleocr-service/main.py`, `services/paddleocr_remote.py`, `cv_ocr_engines.py:ocr_region_paddle()`

### Ablauf von `build_cell_grid_v2()`

```
Eingabe: ocr_img, column_regions, row_geometries
                    │
        ┌───────────┴───────────┐
        │ Filter                 │
        │ • Phantom-Zeilen       │
        │ • Artefakt-Zeilen      │
        │ • Irrelevante Spalten  │
        │   (header, footer,     │
        │    margin, ignore)     │
        └───────────┬───────────┘
                    │
        ┌───────────┴───────────┐
        │ Klassifizierung        │
        │ Spalte.width / img_w   │
        │ >= 15% → broad         │
        │ < 15%  → narrow        │
        └───────────┬───────────┘
                    │
        ┌───────────┴────────────────┐
        │                            │
   Phase 1: Broad               Phase 2: Narrow
   (sequentiell)                 (parallel, max_workers=4)
        │                            │
   Pro (row, col):               Pro (row, col):
   1. Words aus Full-Page        1. _ocr_cell_crop()
   2. Filter conf >= 30         2. Isoliertes Zell-Bild
   3. _words_to_reading_order   3. Upscale wenn noetig
   4. _clean_cell_text()        4. _clean_cell_text_lite()
        │                            │
        └───────────┬────────────────┘
                    │
            Merge + Sortierung
            (row_index, col_index)
            Leere Zeilen entfernen
                    │
               Ausgabe: cells[], columns_meta[]
```

### Post-Processing Pipeline (in `build_vocab_pipeline_streaming`)

| # | Schritt | Funktion | Beschreibung |
|---|---------|----------|--------------|
| 0a | Lautschrift-Fortsetzung | `_merge_phonetic_continuation_rows` | IPA-only Folgezeilen zusammenfuehren |
| 0b | Zeilen-Fortsetzung | `_merge_continuation_rows` | Zeilen mit Kleinbuchstaben-Anfang zusammenfuehren |
| 2 | Lautschrift-Fix | `_fix_phonetic_brackets` | OCR-Lautschrift mit Woerterbuch-IPA ersetzen |
| 3 | Komma-Split | `_split_comma_entries` | `break, broke, broken` → 3 Eintraege |
| 4 | Beispielsaetze | `_attach_example_sentences` | Beispielsatz-Zeilen an vorangehenden Eintrag haengen |

!!! info "Zeichenkorrektur in Schritt 6"
    Die Zeichenverwirrungskorrektur (`|` → `I`, `1` → `I`, `8` → `B`) laeuft **nicht** in
    Schritt 5, sondern als erstes in Schritt 6 (Korrektur), damit die Aenderungen im UI
    sichtbar und rueckgaengig machbar sind.

---

## Schritt 8: Korrektur (Detail)

### Korrektur-Engine

Schritt 6 kombiniert drei Korrektur-Stufen, alle als SSE-Stream:

**Stufe 1 — Zeichenverwirrungskorrektur** (`_fix_character_confusion`):

| OCR-Fehler | Korrektur | Regel |
|------------|-----------|-------|
| `\|ch` | `Ich` | `\|` am Wortanfang vor Kleinbuchstaben → `I` |
| `\| want` | `I want` | Alleinstehendes `\|` → `I` |
| `8en` | `Ben` | `8` am Wortanfang vor `en` → `B` |
| `1 want` | `I want` | Alleinstehendes `1` → `I` (NICHT vor `.` oder `,`) |
| `1. Kreuz` | unveraendert | `1.` = Listennummer, wird **nicht** korrigiert |

**Stufe 2 — Regel-basierte Rechtschreibkorrektur** (`spell_review_entries_streaming`):

Nutzt `pyspellchecker` (MIT-Lizenz) mit EN+DE-Woerterbuch. Pro Token mit verdaechtigem Zeichen
(`0`, `1`, `5`, `6`, `8`, `|`) werden Kandidaten geprueft:

```python
_SPELL_SUBS = {
    '0': ['O', 'o'], '1': ['l', 'I'], '5': ['S', 's'],
    '6': ['G', 'g'], '8': ['B', 'b'], '|': ['I', 'l', '1'],
}
```

**Stufe 3 — Seitenzahl-Korrektur** (`page_ref`-Felder):

Korrigiert haeufige OCR-Fehler in Seitenverweisen (z.B. `p.5g` → `p.59`).

### Umgebungsvariablen

| Variable | Default | Beschreibung |
|----------|---------|--------------|
| `REVIEW_ENGINE` | `spell` | Korrektur-Engine: `spell` oder `llm` |
| `OLLAMA_REVIEW_MODEL` | `qwen3:0.6b` | Ollama-Modell (nur wenn `REVIEW_ENGINE=llm`) |
| `OLLAMA_REVIEW_BATCH_SIZE` | `20` | Eintraege pro LLM-Aufruf |

### SSE-Protokoll

```
POST /sessions/{id}/llm-review?stream=true

Events:
  data: {"type": "meta", "total_entries": 96, "to_review": 80, "skipped": 16, "model": "spell"}
  data: {"type": "batch", "changes": [...], "entries_reviewed": [0,1,2,...], "progress": {...}}
  data: {"type": "complete", "duration_ms": 234}
  data: {"type": "error", "detail": "..."}

Change-Format:
  {"row_index": 5, "field": "english", "old": "| want", "new": "I want"}
```

---

## Schritt 8: Strukturerkennung (Detail)

Erkennt Boxen, Zonen, Farbregionen und grafische Elemente auf der Seite.
Laeuft **nach** der Worterkennung (Schritt 7), damit OCR-Wortpositionen
fuer die Unterscheidung von Text vs. Grafik zur Verfuegung stehen.

### Teilschritte

1. **Box-Erkennung** (`cv_box_detect.py`): Linien-Rahmen und farbige Hintergruende
2. **Zonen-Aufteilung** (`split_page_into_zones`): Seite in Box- und Content-Zonen aufteilen
3. **Farb-Analyse** (`cv_color_detect.py`): HSV-basierte Erkennung farbiger Textbereiche
4. **Grafik-Erkennung** (`cv_graphic_detect.py`): Nicht-Text-Grafiken identifizieren

### Grafik-Erkennung: Region-basierter Ansatz

Zwei Paesse trennen farbige Grafiken von farbigem Text und erkennen
schwarze Illustrationen:

**Pass 1 — Farbige Bildregionen:**

1. HSV-Saturation-Kanal extrahieren (Schwelle > 40)
   - Schwarzer Text hat Saettigung ≈ 0 → unsichtbar auf diesem Kanal
2. Starke Dilation (25×25 Ellipse) verschmilzt nahe Farbpixel zu Regionen
3. Fuer jede Region: Wort-Ueberlappung pruefen
   - \> 50 % Ueberlappung mit OCR-Woertern → farbiger Text → ueberspringen
   - ≤ 50 % → farbige Grafik/Bild → behalten
4. Minimum 200 Farbpixel erforderlich (kein Rauschen)
5. Regionen > 50 % der Bildbreite oder -hoehe → Seitenumfassend → ueberspringen

**Pass 2 — Schwarze Illustrationen:**

1. Otsu-Binarisierung fuer Tinten-Maske
2. Ausschlusszonen: OCR-Woerter (5 px Padding) + erkannte Boxen (8 px Inset)
3. Farbige Pixel aus Pass 1 ebenfalls ausschliessen
4. Nur Konturen mit Flaeche > 5000 px und min(Breite, Hoehe) > 40 px

**Deduplizierung:** Ueberlappende Elemente (> 50 % IoU der kleineren
Bounding-Box) werden zusammengefasst. Ergebnis nach Flaeche absteigend
sortiert.

### Response-Format

```json
{
  "boxes": [
    {"x": 50, "y": 300, "w": 1100, "h": 200, "confidence": 0.85,
     "border_thickness": 3, "bg_color_name": "blue", "bg_color_hex": "#2563eb"}
  ],
  "zones": [
    {"index": 0, "zone_type": "content", "x": 50, "y": 50, "w": 1100, "h": 250},
    {"index": 1, "zone_type": "box", "x": 50, "y": 300, "w": 1100, "h": 200}
  ],
  "graphics": [
    {"x": 100, "y": 500, "w": 150, "h": 120, "area": 8500,
     "shape": "image", "color_name": "red", "color_hex": "#dc2626",
     "confidence": 0.72}
  ],
  "color_pixel_counts": {"red": 1234, "blue": 5678},
  "has_words": true,
  "word_count": 96,
  "duration_seconds": 0.45
}
```

### Grafik-Shape-Typen

| Shape | Quelle | Beschreibung |
|-------|--------|--------------|
| `image` | Pass 1 | Farbige Grafik/Bild (Ballons, Pfeile, Icons) |
| `illustration` | Pass 2 | Grosse schwarze Zeichnung/Illustration |

### Erkannte Farben

`red`, `orange`, `yellow`, `green`, `blue`, `purple`, `black`
— basierend auf dem Median-Hue der saturierten Pixel in der Region.

### Frontend-Anzeige

`StepStructureDetection.tsx` zeigt:

- Boxen-Liste mit Position, Hintergrundfarbe und Confidence
- Zonen-Uebersicht (Content vs. Box)
- Farb-Zusammenfassung (Pixel-Counts)
- Grafik-Liste mit Shape, Abmessungen, Farbe und Confidence

---

## Schritt 9: Rekonstruktion (Detail)

Drei Modi verfuegbar:

### Einfacher Modus

Das entzerrte Originalbild wird mit 30 % Opazitaet als Hintergrund
angezeigt, alle Grid-Zellen (auch leere!) werden als editierbare Textfelder darueber gelegt.

**Features:**

- Alle Zellen editierbar — auch leere Zellen (kein Filter mehr)
- Farbkodierung nach Spaltentyp (Blau=EN, Gruen=DE, Orange=Beispiel)
- Leere Pflichtfelder (EN/DE) rot gestrichelt markiert
- Undo/Redo (Ctrl+Z / Ctrl+Shift+Z)
- Tab-Navigation durch alle Zellen (inkl. leerer)
- Zoom 50–200 %
- Per-Zell-Reset-Button bei geaenderten Zellen

### Overlay-Modus (neu in v4.2)

Ganzseitige Tabellenrekonstruktion mit **Pixel-basierter Wortpositionierung**.
Nur verfuegbar bei Parent-Sessions mit Sub-Sessions (Box-Bereiche).

**Funktionsweise:**

1. **Sub-Session-Merging:** Zellen aus Sub-Sessions werden koordinaten-konvertiert
   und in die Parent-Session eingefuegt. Die Umrechnung laeuft ueber die Box-Zone:
   ```
   parentCellX = boxXPct + (subCell.bbox_pct.x / 100) * boxWPct
   parentCellY = boxYPct + (subCell.bbox_pct.y / 100) * boxHPct
   ```

2. **180°-Rotation:** Bei Parent-Sessions mit Boxen wird das Bild standardmaessig
   180° gedreht, da der Scan haeufig kopfueber vorliegt. Die Pixel-Analyse
   arbeitet auf dem rotierten Bild:
   - Canvas: `ctx.translate(W, H); ctx.rotate(Math.PI)`
   - Zell-Koordinaten: `(100 - x - w, 100 - y - h)` fuer rotiertes Space
   - Cluster-Ruecktransformation: `start → cw-1-end`, danach `reverse()`

3. **Pixel-Wortpositionierung:** Der `usePixelWordPositions` Hook analysiert
   dunkle Pixel per vertikaler Projektion, findet Wortgruppen-Cluster und
   berechnet die exakte horizontale Position + Auto-Schriftgroesse.

**Layout:** 50/50 Grid (links Originalbild, rechts Rekonstruktion)

**Toolbar:**

- Schriftgroessen-Slider (30–120%)
- Bold-Toggle
- 180°-Rotations-Toggle
- Speichern-Button

**Visuelle Elemente:**

- Spaltenlinien (aus `column_result.columns`)
- Zeilenlinien (aus `row_result.rows`)
- Box-Zonen-Markierung (blau, halbtransparent)
- Editierbare Inputs an Pixel-Positionen

### Shared Hook: `usePixelWordPositions`

Extrahierter Hook fuer die Pixel-basierte Wortpositionierung, genutzt in
StepLlmReview (Schritt 8) und StepReconstruction (Schritt 9).

```typescript
function usePixelWordPositions(
  imageUrl: string,
  cells: GridCell[],
  active: boolean,
  rotation: 0 | 180 = 0,
): Map<string, WordPosition[]>
```

**Algorithmus:**

1. Bild in offscreen Canvas laden (optional 180° gedreht)
2. Pro Zelle: `getImageData()` → vertikale Projektion (dunkle Pixel pro Spalte)
3. Cluster-Erkennung (Schwelle: 3% der Zellhoehe, Gap: 2% der Zellbreite)
4. Bei Rotation: Cluster zurueck ins Original-Koordinatensystem spiegeln
5. Text-Gruppen (split bei 3+ Leerzeichen) auf Cluster matchen
6. Auto-Schriftgroesse per `measureText()` + `fontRatio`
7. Mode-Normalisierung: Haeufigste `fontRatio` (gerundet auf 0.02) auf alle anwenden

**Rueckgabe:** `Map<cell_id, WordPosition[]>` mit `xPct`, `wPct`, `yPct`, `hPct`, `text`, `fontRatio`

!!! note "Per-Word Y-Positionierung (v4.3.1)"
    `WordPosition` enthaelt seit v4.3.1 auch `yPct` und `hPct`. Dadurch rendert jedes
    Wort an seiner tatsaechlichen vertikalen Position, statt alle Woerter einer Zelle
    auf der Zell-Mitte zu stapeln. Bei Zellen ohne `word_boxes` (Fallback) werden
    `yPct`/`hPct` aus `cell.bbox_pct` uebernommen.

### Fabric.js Editor

Erweiterter Canvas-Editor (`FabricReconstructionCanvas.tsx`):

- Drag & Drop fuer Zellen
- Freie Positionierung auf dem Canvas
- Export als PDF (reportlab) oder DOCX (python-docx)

```
POST /sessions/{id}/reconstruction
Body: {"cells": [{"cell_id": "r5_c2", "text": "corrected text"}]}
```

---

## Wichtige Konstanten

| Konstante | Wert | Datei | Beschreibung |
|-----------|------|-------|--------------|
| `_NARROW_COL_THRESHOLD_PCT` | 15.0% | cv_vocab_pipeline.py | Schwelle breit/schmal fuer Hybrid-OCR |
| `_NARROW_THRESHOLD_PCT` | 10.0% | cv_vocab_pipeline.py | Schwelle fuer Spalten-Erweiterung |
| `_MIN_WORD_CONF` | 30 | cv_vocab_pipeline.py | Mindest-Confidence fuer OCR-Woerter |
| `_PAD` | 3px | cv_vocab_pipeline.py | Internes Padding bei Cell-Crop |
| `PDF_ZOOM` | 3.0 | cv_vocab_pipeline.py | PDF-Rendering (= 432 DPI) |
| `_MIN_WORD_MARGIN` | 4px | cv_vocab_pipeline.py | Sicherheitsabstand bei Spalten-Erweiterung |

---

## Datenbank-Schema

```sql
CREATE TABLE ocr_pipeline_sessions (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name VARCHAR(255),
    filename VARCHAR(255),
    status VARCHAR(50) DEFAULT 'active',
    current_step INT DEFAULT 1,

    -- Dokumenttyp-Erkennung
    doc_type VARCHAR(50),        -- 'vocab_table', 'generic_table', 'full_text'
    doc_type_result JSONB,       -- Vollstaendiges DetectionResult

    -- Bilder (BYTEA)
    original_png BYTEA,
    deskewed_png BYTEA,
    binarized_png BYTEA,
    dewarped_png BYTEA,

    -- Step-Results (JSONB)
    deskew_result JSONB,
    dewarp_result JSONB,
    column_result JSONB,
    row_result JSONB,
    word_result JSONB,   -- enthaelt vocab_entries, cells, llm_review

    -- Ground Truth + Meta
    ground_truth JSONB,
    auto_shear_degrees REAL,
    created_at TIMESTAMP DEFAULT NOW(),
    updated_at TIMESTAMP DEFAULT NOW()
);
```

`word_result` JSONB-Struktur:

```json
{
  "vocab_entries": [...],
  "cells": [{"cell_id": "r0_c0", "text": "hello", "bbox_pct": {...}, "ocr_engine": "word_lookup", ...}],
  "columns_used": [...],
  "llm_review": {
    "changes": [{"row_index": 5, "field": "english", "old": "...", "new": "..."}],
    "model_used": "spell",
    "duration_ms": 234
  }
}
```

---

## Abhaengigkeiten

### Python (klausur-service)

| Paket | Version | Lizenz | Zweck |
|-------|---------|--------|-------|
| `pytesseract` | ≥0.3.10 | Apache-2.0 | Haupt-OCR (Schritt 3–5) |
| `opencv-python-headless` | ≥4.8.0 | Apache-2.0 | Bildverarbeitung, Projektionsprofile |
| `Pillow` | ≥10.0.0 | HPND (MIT-kompatibel) | Bildkonvertierung |
| `rapidocr` | latest | Apache-2.0 | Schnelles OCR (ARM64 via ONNX) |
| `onnxruntime` | latest | MIT | ONNX-Inferenz fuer RapidOCR |
| `pyspellchecker` | ≥0.8.1 | MIT | Regel-basierte OCR-Korrektur (Schritt 6) |
| `eng-to-ipa` | latest | MIT | IPA-Lautschrift-Lookup (Schritt 5) |
| `reportlab` | latest | BSD | PDF-Export (Schritt 7) |
| `python-docx` | ≥1.1.0 | MIT | DOCX-Export (Schritt 7) |
| `fabric` (JS) | ^6 | MIT | Canvas-Editor (Frontend) |

!!! info "pyspellchecker (neu seit 2026-03)"
    `pyspellchecker` (MIT-Lizenz) ersetzt die LLM-basierte Korrektur als Standard-Engine.
    EN+DE-Woerterbuch, ~134k Woerter. Kein Ollama noetig.
    Umschaltbar via `REVIEW_ENGINE=llm` fuer den LLM-Pfad.

---

## Bekannte Einschraenkungen

| Problem | Ursache | Workaround |
|---------|---------|------------|
| Schraeg gedruckte Seiten | Deskew erkennt Text-Rotation, nicht Seiten-Rotation | Manueller Winkel |
| Sehr kleine Schrift (< 8pt) | Tesseract PSM 7 braucht min. Zeichengroesse | Vorher zoomen |
| Handgeschriebene Eintraege | Tesseract/RapidOCR sind fuer Druckschrift optimiert | TrOCR-Engine |
| Mehr als 5 Spalten | Projektionsprofil kann verschmelzen (Segmentierung hilft) | Manuelle Spalten |
| Farbige Marker (rot/blau) | HSV-Erkennung erzeugt False Positives | Manuell im Rekonstruktions-Editor |
| 15%-Schwelle nicht breit validiert | Nur an einem Arbeitsblatt-Typ getestet | Diverse Schulbuchseiten testen |

---

## Deployment

```bash
# 1. Git push
git push origin main

# 2. Mac Mini pull + build
ssh macmini "git -C /Users/benjaminadmin/Projekte/breakpilot-lehrer pull --no-rebase origin main"

# klausur-service (Backend)
ssh macmini "/usr/local/bin/docker compose -f /Users/benjaminadmin/Projekte/breakpilot-lehrer/docker-compose.yml build klausur-service"
ssh macmini "/usr/local/bin/docker compose -f /Users/benjaminadmin/Projekte/breakpilot-lehrer/docker-compose.yml up -d klausur-service"

# admin-lehrer (Frontend)
ssh macmini "/usr/local/bin/docker compose -f /Users/benjaminadmin/Projekte/breakpilot-lehrer/docker-compose.yml build admin-lehrer"
ssh macmini "/usr/local/bin/docker compose -f /Users/benjaminadmin/Projekte/breakpilot-lehrer/docker-compose.yml up -d admin-lehrer"

# 3. Testen unter:
# https://macmini:3002/ai/ocr-pipeline
```

!!! warning "Base-Image bei neuen Python-Paketen"
    Wenn `requirements.txt` geaendert wird (z.B. neues Paket hinzugefuegt), muss zuerst
    das Base-Image neu gebaut werden:
    ```bash
    ssh macmini "/usr/local/bin/docker build -f /Users/benjaminadmin/Projekte/breakpilot-lehrer/klausur-service/Dockerfile.base \
      -t klausur-base:latest /Users/benjaminadmin/Projekte/breakpilot-lehrer/klausur-service/"
    ```

---

## OCR Overlay — Alternative Pipelines

**URL:** https://macmini:3002/ai/ocr-overlay

Neben der vollen 10-Schritt-Pipeline gibt es die **OCR Overlay**-Seite mit
vereinfachten Pfaden fuer schnelle Ergebnisse. Alle drei Modi teilen die
gleichen Vorverarbeitungsschritte (Orient → Deskew → Dewarp → Crop).

### Modus-Uebersicht

| Modus | Schritte | Engine | Endpoint | Beschreibung |
|-------|----------|--------|----------|--------------|
| **Pipeline** | 7 | Tesseract | `/words` (SSE) | Volle Pipeline: Zeilen + Woerter + Overlay |
| **Paddle Direct** | 5 | PaddleOCR | `/paddle-direct` | PaddleOCR ersetzt Zeilen + Woerter + Overlay |
| **Kombi** | 5 | PaddleOCR + Tesseract | `/paddle-kombi` | Beide Engines, Ergebnisse gemittelt |

### Flussdiagramm

```
┌──────────────────────────────────────────────────────────────┐
│ GEMEINSAME VORVERARBEITUNG (alle 3 Modi)                      │
│                                                                │
│ Schritt 1: Orientierung                                        │
│ Schritt 2: Deskew                                              │
│ Schritt 3: Dewarp                                              │
│ Schritt 4: Crop                                                │
└──────────────────┬────────────────────┬───────────────────────┘
                   │                    │
       ┌───────────┼────────────────────┼────────────────┐
       ▼           ▼                    ▼                ▼
   PIPELINE     PADDLE DIRECT       KOMBI-MODUS
   (7 Schritte) (5 Schritte)       (5 Schritte)
       │           │                    │
   Zeilen-     PaddleOCR           PaddleOCR
   erkennung   word_boxes          + Tesseract
       │           │                parallel
   Woerter-    build_grid_             │
   erkennung   from_words()       _merge_paddle_
       │           │               tesseract()
   Overlay     Overlay                 │
       │           │              build_grid_
       ▼           ▼              from_words()
   Ergebnis    Ergebnis                │
                                  Overlay
                                      │
                                  Ergebnis
```

### Paddle Direct

PaddleOCR laeuft auf dem vorverarbeiteten Bild und erkennt Woerter direkt.

**Endpoint:** `POST /api/v1/ocr-pipeline/sessions/{id}/paddle-direct`

**Ablauf:**

1. Cropped/dewarped Bild laden (Prioritaet: cropped > dewarped > original)
2. `ocr_region_paddle(img_bgr, region=None)` aufrufen
3. `build_grid_from_words(word_dicts, img_w, img_h)` fuer Grid-Erstellung
4. Cells mit `ocr_engine="paddle_direct"` taggen
5. In DB speichern (`current_step=8`)

**Frontend:** `PaddleDirectStep.tsx` — wiederverwendbare Komponente mit konfigurierbaren Props.

### Kombi-Modus (PaddleOCR + Tesseract)

!!! info "Motivation"
    PaddleOCR liefert gute Texterkennung, positioniert Woerter aber manchmal falsch
    (z.B. `!Betonung` als ein Wort, Bullet Points nicht erkannt). Tesseract erkennt
    Sonderzeichen besser und liefert feinere Word-Level-Boxen. Der Kombi-Modus
    nutzt beide Engines und mittelt die Koordinaten.

**Endpoint:** `POST /api/v1/ocr-pipeline/sessions/{id}/paddle-kombi`

**Ablauf:**

1. Cropped/dewarped Bild laden
2. **Parallel** beide Engines aufrufen:
    - `ocr_region_paddle(img_bgr, region=None)` → `paddle_words`
    - `pytesseract.image_to_data(pil_img, lang='eng+deu', config='--psm 6 --oem 3')` → `tess_words`
3. **Merge:** `_merge_paddle_tesseract(paddle_words, tess_words)`
4. `build_grid_from_words(merged_words, img_w, img_h)` fuer Grid
5. Cells mit `ocr_engine="kombi"` taggen
6. In DB speichern

#### Merge-Algorithmus (v2: Row-Based Sequence Alignment)

!!! info "Rewrite (v4.3)"
    Der Merge wurde von IoU-basiertem Matching auf **Row-Based Sequence Alignment** umgestellt.
    Multi-Word Paddle-Boxen werden vor dem Merge in Einzelwoerter aufgeteilt
    (`_split_paddle_multi_words`).

**Ablauf:**

1. **Row Grouping:** Woerter beider Engines nach Y-Position in Zeilen gruppieren (12px Toleranz)
2. **Row Matching:** Paddle- und Tesseract-Zeilen ueber vertikale Naehe zuordnen
3. **Sequence Alignment:** Innerhalb jeder gematchten Zeile links-nach-rechts durchlaufen:
    - **Gleicher Text** oder **Substring-Match:** Zusammenfuehren (Paddle-Text, gemittelte Koordinaten)
    - **Raeumlicher Overlap >= 50%:** Auch bei unterschiedlichem Text als Duplikat behandeln
    - **Nur bei einer Engine:** Wort beibehalten (falls Confidence >= 30)
4. **Ungematchte Zeilen:** Paddle-Zeilen behalten, Tesseract-Zeilen nur mit Confidence >= 40

```mermaid
flowchart TD
    A[Beide Engines] --> B[Row Grouping<br/>Y-Toleranz 12px]
    B --> C[Row Matching<br/>vertikale Naehe]
    C --> D{Gleicher Text<br/>oder Overlap >= 50%?}
    D -->|Ja| E[Deduplizieren:<br/>Paddle-Text + gemittelte Coords]
    D -->|Nein| F{Wort nur bei<br/>einer Engine?}
    F -->|Ja| G[Beibehalten<br/>falls conf >= 30]
    F -->|Nein| H[Beide behalten<br/>verschiedene Positionen]
```

**Koordinaten-Mittelung:**

```
merged_left = (paddle_left × paddle_conf + tess_left × tess_conf) / (paddle_conf + tess_conf)
```

Gleiches Prinzip fuer `top`, `width`, `height`. Der Text kommt immer von PaddleOCR (bessere Texterkennung).

**Raeumlicher Overlap-Check (v4.3.1):**

Wenn zwei Woerter >= 50% horizontal ueberlappen, werden sie als dasselbe physische Wort behandelt —
unabhaengig davon, ob die OCR-Texte unterschiedlich sind (z.B. "hello" vs "helo").
Dies verhindert, dass leicht unterschiedliche Erkennungen als separate Woerter uebereinander
im Overlay erscheinen.

#### Dateien

| Datei | Aenderung |
|-------|-----------|
| `ocr_pipeline_api.py` | `_split_paddle_multi_words()`, `_group_words_into_rows()`, `_merge_row_sequences()`, `_merge_paddle_tesseract()`, `/paddle-kombi` Endpoint |
| `admin-lehrer/.../ocr-overlay/types.ts` | `KOMBI_STEPS` Konstante |
| `admin-lehrer/.../ocr-overlay/useSlideWordPositions.ts` | Slide-Positionierung mit `yPct`/`hPct` |
| `admin-lehrer/.../ocr-overlay/usePixelWordPositions.ts` | Pixel-Cluster-Positionierung mit `yPct`/`hPct` |
| `admin-lehrer/.../ocr-overlay/OverlayReconstruction.tsx` | Rendering mit per-Word Y-Positionen |
| `admin-lehrer/.../PaddleDirectStep.tsx` | Wiederverwendbar mit `endpoint`/`engineKey` Props |
| `admin-lehrer/.../ocr-overlay/page.tsx` | 3er-Toggle: Pipeline / Paddle Direct / Kombi |

#### Tests

```bash
cd klausur-service/backend && pytest tests/test_paddle_kombi.py -v  # 36 Tests
```

**Test-Klassen:**

| Klasse | Tests | Beschreibung |
|--------|-------|--------------|
| `TestSplitPaddleMultiWords` | 7 | Multi-Word-Box-Splitting |
| `TestGroupWordsIntoRows` | 5 | Y-Position Row Clustering |
| `TestMergeRowSequences` | 10 | Sequence Alignment innerhalb einer Zeile |
| `TestMergePaddleTesseract` | 8 | Vollstaendiger Merge mit Row-Matching |
| `TestMergeRealWorldRegression` | 1 | Regression mit Echtdaten |
| `TestSpatialOverlapDedup` | 4 | Raeumliche Overlap-Deduplizierung |
| `TestSplitThenMerge` | 1 | Split + Merge End-to-End |

| Testklasse | Tests | Beschreibung |
|------------|-------|--------------|
| `TestBoxIoU` | 6 | IoU-Berechnung: identisch, kein Overlap, teilweise, enthalten, Kante, Null-Flaeche |
| `TestMergePaddleTesseract` | 10 | Merge: Match-Averaging, kein Match, Low-Conf-Drop, leer, IoU-Schwelle, Text-Praeferenz, Zero-Conf |
| `TestMergePaddleTesseractBulletPoints` | 2 | Bullet-Points und Sonderzeichen von Tesseract |

---

## Aenderungshistorie

| Datum | Version | Aenderung |
|-------|---------|----------|
| 2026-03-16 | 4.6.0 | Strukturerkennung (Schritt 8): Region-basierte Grafikerkennung (`cv_graphic_detect.py`) mit Zwei-Pass-Verfahren (Farbregionen + schwarze Illustrationen), Wort-Ueberlappungs-Filter, Box/Zonen/Farb-Analyse. Schritt laeuft nach Worterkennung. |
| 2026-03-12 | 4.5.0 | Kombi-Modus (PaddleOCR + Tesseract): Beide Engines laufen parallel, Koordinaten werden IoU-basiert gematcht und confidence-gewichtet gemittelt. Ungematchte Tesseract-Woerter (Bullets, Symbole) werden hinzugefuegt. 3er-Toggle in OCR Overlay. |
| 2026-03-12 | 4.4.0 | PaddleOCR Remote-Engine (`engine=paddle`): PP-OCRv5 Latin auf Hetzner x86_64. Neuer Microservice (`paddleocr-service/`), HTTP-Client (`paddleocr_remote.py`), Frontend-Dropdown-Option. Nutzt words_first Grid-Methode. |
| 2026-03-12 | 4.3.0 | Words-First Grid Builder (`cv_words_first.py`): Bottom-up-Algorithmus clustert Tesseract word_boxes direkt zu Spalten/Zeilen/Zellen. Neuer `grid_method` Parameter im `/words` Endpoint. Frontend-Toggle in StepWordRecognition. |
| 2026-03-10 | 4.2.0 | Rekonstruktion: Overlay-Modus mit Pixel-Wortpositionierung, 180°-Rotation, Sub-Session-Merging, usePixelWordPositions Hook, Box-Boundary-Schutz (box_ranges_inner) |
| 2026-03-05 | 3.1.0 | Spalten: Seiten-Segmentierung an Sub-Headern, Word-Coverage Fallback, Segment-gefilterte Validierung |
| 2026-03-05 | 3.0.1 | Dewarp: Feinabstimmung mit 7 Schiebereglern (3 Rotation + 4 Shear), Combined-Adjust-Endpoint |
| 2026-03-05 | 3.0.0 | Doku-Update: Dokumenttyp-Erkennung, Hybrid-Grid, Sub-Column-Detection, Pipeline-Pfade |
| 2026-03-04 | 2.2.0 | Dewarp: Vertikalkanten-Drift statt Textzeilen-Neigung, Schwellenwerte gesenkt |
| 2026-03-04 | 2.1.0 | Sub-Column-Detection, expand_narrow_columns, Fabric.js Editor, PDF/DOCX-Export |
| 2026-03-03 | 2.0.0 | Schritte 6–7 implementiert; Spell-Checker, Rekonstruktions-Canvas |
| 2026-03-03 | 1.5.0 | Spaltenerkennung: volle Bildbreite fuer initialen Scan, Phantom-Filter |
| 2026-03-03 | 1.4.0 | Zeilenerkennung: Artefakt-Zeilen entfernen + Luecken-Heilung |
| 2026-03-03 | 1.3.0 | Zeichenkorrektur: `1.`/`\|.` Listenpraefixe werden nicht zu `I.` |
| 2026-03-03 | 1.2.0 | LLM-Engine durch Spell-Checker ersetzt (REVIEW_ENGINE=spell) |
| 2026-02-28 | 1.0.0 | Schritt 5 (Worterkennung) implementiert |
| 2026-02-22 | 0.4.0 | Schritt 4 (Zeilenerkennung) implementiert |
| 2026-02-20 | 0.3.0 | Schritt 3 (Spaltenerkennung) mit Typ-Klassifikation |
| 2026-02-15 | 0.2.0 | Schritt 2 (Entzerrung/Dewarp) |
| 2026-02-12 | 0.1.0 | Schritt 1 (Begradigung/Deskew) + Session-Management |
