feat: Orientierung + Zuschneiden als Schritte 1-2 in OCR-Pipeline
Some checks failed
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-school (push) Successful in 28s
CI / test-go-edu-search (push) Successful in 27s
CI / test-python-klausur (push) Failing after 1m59s
CI / test-python-agent-core (push) Successful in 17s
CI / test-nodejs-website (push) Successful in 18s

Zwei neue Wizard-Schritte vor Begradigung:
- Step 1: Orientierungserkennung (0/90/180/270° via Tesseract OSD)
- Step 2: Seitenrand-Erkennung und Zuschnitt (Scannerraender entfernen)

Backend:
- orientation_crop_api.py: POST /orientation, POST /crop, POST /crop/skip
- page_crop.py: detect_and_crop_page() mit Format-Erkennung (A4/A5/Letter)
- Session-Store: orientation_result, crop_result Felder
- Pipeline nutzt zugeschnittenes Bild fuer Deskew/Dewarp

Frontend:
- StepOrientation.tsx: Upload + Auto-Orientierung + Vorher/Nachher
- StepCrop.tsx: Auto-Crop + Format-Badge + Ueberspringen-Option
- Pipeline-Stepper: 10 Schritte (war 8)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-03-08 23:55:23 +01:00
parent 9a5a35bff1
commit 2763631711
12 changed files with 1247 additions and 259 deletions

View File

@@ -1,15 +1,17 @@
"""
OCR Pipeline API - Schrittweise Seitenrekonstruktion.
Zerlegt den OCR-Prozess in 8 einzelne Schritte:
1. Deskewing - Scan begradigen
2. Dewarping - Buchwoelbung entzerren
3. Spaltenerkennung - Unsichtbare Spalten finden
4. Zeilenerkennung - Horizontale Zeilen + Kopf-/Fusszeilen
5. Worterkennung - OCR mit Bounding Boxes
6. LLM-Korrektur - OCR-Fehler per LLM korrigieren
7. Seitenrekonstruktion - Seite nachbauen
8. Ground Truth Validierung - Gesamtpruefung
Zerlegt den OCR-Prozess in 10 einzelne Schritte:
1. Orientierung - 90/180/270° Drehungen korrigieren (orientation_crop_api.py)
2. Zuschneiden - Scannerraender entfernen (orientation_crop_api.py)
3. Deskewing - Scan begradigen
4. Dewarping - Buchwoelbung entzerren
5. Spaltenerkennung - Unsichtbare Spalten finden
6. Zeilenerkennung - Horizontale Zeilen + Kopf-/Fusszeilen
7. Worterkennung - OCR mit Bounding Boxes
8. LLM-Korrektur - OCR-Fehler per LLM korrigieren
9. Seitenrekonstruktion - Seite nachbauen
10. Ground Truth Validierung - Gesamtpruefung
Lizenz: Apache 2.0
DATENSCHUTZ: Alle Verarbeitung erfolgt lokal.
@@ -54,7 +56,6 @@ from cv_vocab_pipeline import (
deskew_image_by_word_alignment,
deskew_image_iterative,
deskew_two_pass,
detect_and_fix_orientation,
detect_column_geometry,
detect_document_type,
detect_row_geometry,
@@ -103,6 +104,8 @@ async def _load_session_to_cache(session_id: str) -> Dict[str, Any]:
"id": session_id,
**session,
"original_bgr": None,
"oriented_bgr": None,
"cropped_bgr": None,
"deskewed_bgr": None,
"dewarped_bgr": None,
}
@@ -110,6 +113,8 @@ async def _load_session_to_cache(session_id: str) -> Dict[str, Any]:
# Decode images from DB into BGR numpy arrays
for img_type, bgr_key in [
("original", "original_bgr"),
("oriented", "oriented_bgr"),
("cropped", "cropped_bgr"),
("deskewed", "deskewed_bgr"),
("dewarped", "dewarped_bgr"),
]:
@@ -252,8 +257,12 @@ async def create_session(
"filename": filename,
"name": session_name,
"original_bgr": img_bgr,
"oriented_bgr": None,
"cropped_bgr": None,
"deskewed_bgr": None,
"dewarped_bgr": None,
"orientation_result": None,
"crop_result": None,
"deskew_result": None,
"dewarp_result": None,
"ground_truth": {},
@@ -301,6 +310,10 @@ async def get_session_info(session_id: str):
"doc_type": session.get("doc_type"),
}
if session.get("orientation_result"):
result["orientation_result"] = session["orientation_result"]
if session.get("crop_result"):
result["crop_result"] = session["crop_result"]
if session.get("deskew_result"):
result["deskew_result"] = session["deskew_result"]
if session.get("dewarp_result"):
@@ -427,7 +440,7 @@ async def _append_pipeline_log(
@router.get("/sessions/{session_id}/image/{image_type}")
async def get_image(session_id: str, image_type: str):
"""Serve session images: original, deskewed, dewarped, binarized, columns-overlay, or rows-overlay."""
valid_types = {"original", "deskewed", "dewarped", "binarized", "columns-overlay", "rows-overlay", "words-overlay", "clean"}
valid_types = {"original", "oriented", "cropped", "deskewed", "dewarped", "binarized", "columns-overlay", "rows-overlay", "words-overlay", "clean"}
if image_type not in valid_types:
raise HTTPException(status_code=400, detail=f"Unknown image type: {image_type}")
@@ -470,22 +483,13 @@ async def auto_deskew(session_id: str):
await _load_session_to_cache(session_id)
cached = _get_cached(session_id)
img_bgr = cached.get("original_bgr")
# Use cropped image as input (from step 2), fall back to oriented, then original
img_bgr = cached.get("cropped_bgr") or cached.get("oriented_bgr") or cached.get("original_bgr")
if img_bgr is None:
raise HTTPException(status_code=400, detail="Original image not available")
raise HTTPException(status_code=400, detail="No image available for deskewing")
t0 = time.time()
# Orientation detection (fix 90/180/270° rotations from scanners)
img_bgr, orientation_deg = detect_and_fix_orientation(img_bgr)
if orientation_deg:
# Update original in cache + DB so all subsequent steps use corrected image
cached["original_bgr"] = img_bgr
success_ori, ori_buf = cv2.imencode(".png", img_bgr)
if success_ori:
await update_session_db(session_id, original_png=ori_buf.tobytes())
logger.info(f"OCR Pipeline: orientation corrected {orientation_deg}° for session {session_id}")
# Two-pass deskew: iterative (±5°) + word-alignment residual check
deskewed_bgr, angle_applied, two_pass_debug = deskew_two_pass(img_bgr.copy())
@@ -534,7 +538,6 @@ async def auto_deskew(session_id: str):
"angle_residual": round(angle_residual, 3),
"angle_textline": round(angle_textline, 3),
"angle_applied": round(angle_applied, 3),
"orientation_degrees": orientation_deg,
"method_used": method_used,
"confidence": round(confidence, 2),
"duration_seconds": round(duration, 2),
@@ -550,7 +553,7 @@ async def auto_deskew(session_id: str):
db_update = {
"deskewed_png": deskewed_png,
"deskew_result": deskew_result,
"current_step": 2,
"current_step": 4,
}
if binarized_png:
db_update["binarized_png"] = binarized_png
@@ -563,7 +566,6 @@ async def auto_deskew(session_id: str):
f"-> {method_used} total={angle_applied:.2f}")
await _append_pipeline_log(session_id, "deskew", {
"orientation": orientation_deg,
"angle_applied": round(angle_applied, 3),
"angle_iterative": round(angle_iterative, 3),
"angle_residual": round(angle_residual, 3),
@@ -582,14 +584,14 @@ async def auto_deskew(session_id: str):
@router.post("/sessions/{session_id}/deskew/manual")
async def manual_deskew(session_id: str, req: ManualDeskewRequest):
"""Apply a manual rotation angle to the original image."""
"""Apply a manual rotation angle to the cropped image."""
if session_id not in _cache:
await _load_session_to_cache(session_id)
cached = _get_cached(session_id)
img_bgr = cached.get("original_bgr")
img_bgr = cached.get("cropped_bgr") or cached.get("oriented_bgr") or cached.get("original_bgr")
if img_bgr is None:
raise HTTPException(status_code=400, detail="Original image not available")
raise HTTPException(status_code=400, detail="No image available for deskewing")
angle = max(-5.0, min(5.0, req.angle))
@@ -797,7 +799,7 @@ async def auto_dewarp(
dewarped_png=dewarped_png,
dewarp_result=dewarp_result,
auto_shear_degrees=dewarp_info.get("shear_degrees", 0.0),
current_step=3,
current_step=5,
)
logger.info(f"OCR Pipeline: dewarp session {session_id}: "
@@ -1109,7 +1111,7 @@ async def detect_columns(session_id: str):
column_result=column_result,
row_result=None,
word_result=None,
current_step=3,
current_step=5,
)
# Update cache
@@ -1335,7 +1337,7 @@ async def detect_rows(session_id: str):
session_id,
row_result=row_result,
word_result=None,
current_step=4,
current_step=6,
)
cached["row_result"] = row_result
@@ -1601,7 +1603,7 @@ async def detect_words(
await update_session_db(
session_id,
word_result=word_result,
current_step=5,
current_step=7,
)
cached["word_result"] = word_result
@@ -1745,7 +1747,7 @@ async def _word_batch_stream_generator(
word_result["summary"]["with_german"] = sum(1 for e in entries if e.get("german"))
vocab_entries = entries
await update_session_db(session_id, word_result=word_result, current_step=5)
await update_session_db(session_id, word_result=word_result, current_step=7)
cached["word_result"] = word_result
logger.info(f"OCR Pipeline SSE batch: words session {session_id}: "
@@ -1892,7 +1894,7 @@ async def _word_stream_generator(
await update_session_db(
session_id,
word_result=word_result,
current_step=5,
current_step=7,
)
cached["word_result"] = word_result
@@ -2016,7 +2018,7 @@ async def run_llm_review(session_id: str, request: Request, stream: bool = False
"duration_ms": result["duration_ms"],
"entries_corrected": result["entries_corrected"],
}
await update_session_db(session_id, word_result=word_result, current_step=6)
await update_session_db(session_id, word_result=word_result, current_step=8)
if session_id in _cache:
_cache[session_id]["word_result"] = word_result
@@ -2065,7 +2067,7 @@ async def _llm_review_stream_generator(
"duration_ms": event["duration_ms"],
"entries_corrected": event["entries_corrected"],
}
await update_session_db(session_id, word_result=word_result, current_step=6)
await update_session_db(session_id, word_result=word_result, current_step=8)
if session_id in _cache:
_cache[session_id]["word_result"] = word_result
@@ -2153,7 +2155,7 @@ async def save_reconstruction(session_id: str, request: Request):
cell_updates = body.get("cells", [])
if not cell_updates:
await update_session_db(session_id, current_step=7)
await update_session_db(session_id, current_step=9)
return {"session_id": session_id, "updated": 0}
# Build update map: cell_id -> new text
@@ -2189,7 +2191,7 @@ async def save_reconstruction(session_id: str, request: Request):
if "entries" in word_result:
word_result["entries"] = entries
await update_session_db(session_id, word_result=word_result, current_step=7)
await update_session_db(session_id, word_result=word_result, current_step=9)
if session_id in _cache:
_cache[session_id]["word_result"] = word_result
@@ -2572,7 +2574,7 @@ async def save_validation(session_id: str, req: ValidationRequest):
"""Save final validation results for step 8.
Stores notes, score, and preserves any detected/generated image regions.
Sets current_step = 8 to mark pipeline as complete.
Sets current_step = 10 to mark pipeline as complete.
"""
session = await get_session_db(session_id)
if not session:
@@ -2585,7 +2587,7 @@ async def save_validation(session_id: str, req: ValidationRequest):
validation["score"] = req.score
ground_truth["validation"] = validation
await update_session_db(session_id, ground_truth=ground_truth, current_step=8)
await update_session_db(session_id, ground_truth=ground_truth, current_step=10)
if session_id in _cache:
_cache[session_id]["ground_truth"] = ground_truth
@@ -2619,12 +2621,14 @@ async def reprocess_session(session_id: str, request: Request):
Body: {"from_step": 5} (1-indexed step number)
Clears downstream results:
- from_step <= 1: deskew_result, dewarp_result, column_result, row_result, word_result
- from_step <= 2: dewarp_result, column_result, row_result, word_result
- from_step <= 3: column_result, row_result, word_result
- from_step <= 4: row_result, word_result
- from_step <= 5: word_result (cells, vocab_entries)
- from_step <= 6: word_result.llm_review only
- from_step <= 1: orientation_result, crop_result, deskew_result, dewarp_result, column_result, row_result, word_result
- from_step <= 2: crop_result, deskew_result, dewarp_result, column_result, row_result, word_result
- from_step <= 3: deskew_result, dewarp_result, column_result, row_result, word_result
- from_step <= 4: dewarp_result, column_result, row_result, word_result
- from_step <= 5: column_result, row_result, word_result
- from_step <= 6: row_result, word_result
- from_step <= 7: word_result (cells, vocab_entries)
- from_step <= 8: word_result.llm_review only
"""
session = await get_session_db(session_id)
if not session:
@@ -2632,15 +2636,15 @@ async def reprocess_session(session_id: str, request: Request):
body = await request.json()
from_step = body.get("from_step", 1)
if not isinstance(from_step, int) or from_step < 1 or from_step > 7:
raise HTTPException(status_code=400, detail="from_step must be between 1 and 7")
if not isinstance(from_step, int) or from_step < 1 or from_step > 9:
raise HTTPException(status_code=400, detail="from_step must be between 1 and 9")
update_kwargs: Dict[str, Any] = {"current_step": from_step}
# Clear downstream data based on from_step
if from_step <= 5:
if from_step <= 7:
update_kwargs["word_result"] = None
elif from_step == 6:
elif from_step == 8:
# Only clear LLM review from word_result
word_result = session.get("word_result")
if word_result:
@@ -2648,14 +2652,18 @@ async def reprocess_session(session_id: str, request: Request):
word_result.pop("llm_corrections", None)
update_kwargs["word_result"] = word_result
if from_step <= 4:
if from_step <= 6:
update_kwargs["row_result"] = None
if from_step <= 3:
if from_step <= 5:
update_kwargs["column_result"] = None
if from_step <= 2:
if from_step <= 4:
update_kwargs["dewarp_result"] = None
if from_step <= 1:
if from_step <= 3:
update_kwargs["deskew_result"] = None
if from_step <= 2:
update_kwargs["crop_result"] = None
if from_step <= 1:
update_kwargs["orientation_result"] = None
await update_session_db(session_id, **update_kwargs)
@@ -3074,7 +3082,7 @@ async def run_auto(session_id: str, req: RunAutoRequest, request: Request):
deskewed_png=deskewed_png,
deskew_result=deskew_result,
auto_rotation_degrees=float(angle_applied),
current_step=2,
current_step=4,
)
session = await get_session_db(session_id)
@@ -3137,7 +3145,7 @@ async def run_auto(session_id: str, req: RunAutoRequest, request: Request):
dewarped_png=dewarped_png,
dewarp_result=dewarp_result,
auto_shear_degrees=dewarp_info.get("shear_degrees", 0.0),
current_step=3,
current_step=5,
)
session = await get_session_db(session_id)
@@ -3196,7 +3204,7 @@ async def run_auto(session_id: str, req: RunAutoRequest, request: Request):
cached["column_result"] = column_result
await update_session_db(session_id, column_result=column_result,
row_result=None, word_result=None, current_step=4)
row_result=None, word_result=None, current_step=6)
session = await get_session_db(session_id)
steps_run.append("columns")
@@ -3273,7 +3281,7 @@ async def run_auto(session_id: str, req: RunAutoRequest, request: Request):
}
cached["row_result"] = row_result
await update_session_db(session_id, row_result=row_result, current_step=5)
await update_session_db(session_id, row_result=row_result, current_step=7)
session = await get_session_db(session_id)
steps_run.append("rows")
@@ -3381,7 +3389,7 @@ async def run_auto(session_id: str, req: RunAutoRequest, request: Request):
word_result_data["entry_count"] = len(entries)
word_result_data["summary"]["total_entries"] = len(entries)
await update_session_db(session_id, word_result=word_result_data, current_step=6)
await update_session_db(session_id, word_result=word_result_data, current_step=8)
cached["word_result"] = word_result_data
session = await get_session_db(session_id)
@@ -3426,7 +3434,7 @@ async def run_auto(session_id: str, req: RunAutoRequest, request: Request):
word_result_updated["llm_reviewed"] = True
word_result_updated["llm_model"] = OLLAMA_REVIEW_MODEL
await update_session_db(session_id, word_result=word_result_updated, current_step=7)
await update_session_db(session_id, word_result=word_result_updated, current_step=9)
cached["word_result"] = word_result_updated
steps_run.append("llm_review")