diff --git a/admin-lehrer/components/grid-editor/useGridEditor.ts b/admin-lehrer/components/grid-editor/useGridEditor.ts index 3e9288e..f3bd451 100644 --- a/admin-lehrer/components/grid-editor/useGridEditor.ts +++ b/admin-lehrer/components/grid-editor/useGridEditor.ts @@ -80,6 +80,38 @@ export function useGridEditor(sessionId: string | null) { } }, [sessionId, ipaMode, syllableMode, ocrEnhance, ocrMaxCols, ocrMinConf]) + /** Re-run OCR with current quality settings, then rebuild grid */ + const rerunOcr = useCallback(async () => { + if (!sessionId) return + setLoading(true) + setError(null) + try { + const params = new URLSearchParams() + params.set('ipa_mode', ipaMode) + params.set('syllable_mode', syllableMode) + params.set('enhance', String(ocrEnhance)) + if (ocrMaxCols > 0) params.set('max_cols', String(ocrMaxCols)) + if (ocrMinConf > 0) params.set('min_conf', String(ocrMinConf)) + const res = await fetch( + `${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/rerun-ocr-and-build-grid?${params}`, + { method: 'POST' }, + ) + if (!res.ok) { + const data = await res.json().catch(() => ({})) + throw new Error(data.detail || `HTTP ${res.status}`) + } + const data: StructuredGrid = await res.json() + setGrid(data) + setDirty(false) + undoStack.current = [] + redoStack.current = [] + } catch (e) { + setError(e instanceof Error ? e.message : String(e)) + } finally { + setLoading(false) + } + }, [sessionId, ipaMode, syllableMode, ocrEnhance, ocrMaxCols, ocrMinConf]) + const loadGrid = useCallback(async () => { if (!sessionId) return setLoading(true) @@ -998,5 +1030,6 @@ export function useGridEditor(sessionId: string | null) { setOcrMaxCols, ocrMinConf, setOcrMinConf, + rerunOcr, } } diff --git a/admin-lehrer/components/ocr-pipeline/StepGridReview.tsx b/admin-lehrer/components/ocr-pipeline/StepGridReview.tsx index c19fd97..22df350 100644 --- a/admin-lehrer/components/ocr-pipeline/StepGridReview.tsx +++ b/admin-lehrer/components/ocr-pipeline/StepGridReview.tsx @@ -67,6 +67,7 @@ export function StepGridReview({ sessionId, onNext, saveRef }: StepGridReviewPro setOcrMaxCols, ocrMinConf, setOcrMinConf, + rerunOcr, } = useGridEditor(sessionId) const [showImage, setShowImage] = useState(true) @@ -336,6 +337,14 @@ export function StepGridReview({ sessionId, onNext, saveRef }: StepGridReviewPro onIpaModeChange={setIpaMode} onSyllableModeChange={setSyllableMode} /> + {/* Split View: Image left + Grid right */} diff --git a/klausur-service/backend/grid_editor_api.py b/klausur-service/backend/grid_editor_api.py index 82e5ae1..a66b41a 100644 --- a/klausur-service/backend/grid_editor_api.py +++ b/klausur-service/backend/grid_editor_api.py @@ -17,6 +17,11 @@ from ocr_pipeline_session_store import ( get_session_db, update_session_db, ) +from ocr_pipeline_common import ( + _cache, + _load_session_to_cache, + _get_cached, +) logger = logging.getLogger(__name__) @@ -98,6 +103,168 @@ async def build_grid( return result +@router.post("/sessions/{session_id}/rerun-ocr-and-build-grid") +async def rerun_ocr_and_build_grid( + session_id: str, + ipa_mode: str = Query("auto", pattern="^(auto|all|de|en|none)$"), + syllable_mode: str = Query("auto", pattern="^(auto|all|de|en|none)$"), + enhance: bool = Query(True, description="Step 3: CLAHE + denoise for degraded scans"), + max_cols: int = Query(0, description="Step 2: Max column count (0=unlimited)"), + min_conf: int = Query(0, description="Step 1: Min OCR confidence (0=auto)"), +): + """Re-run OCR with quality settings, then rebuild the grid. + + Unlike build-grid (which only rebuilds from existing words), + this endpoint re-runs the full OCR pipeline on the cropped image + with optional CLAHE enhancement, then builds the grid. + + Steps executed: Image Enhancement → OCR → Grid Build + """ + session = await get_session_db(session_id) + if not session: + raise HTTPException(status_code=404, detail=f"Session {session_id} not found") + + import time as _time + t0 = _time.time() + + # 1. Load the cropped/dewarped image from cache or session + if session_id not in _cache: + await _load_session_to_cache(session_id) + cached = _get_cached(session_id) + + dewarped_bgr = cached.get("cropped_bgr") if cached.get("cropped_bgr") is not None else cached.get("dewarped_bgr") + if dewarped_bgr is None: + raise HTTPException(status_code=400, detail="No cropped/dewarped image available. Run preprocessing steps first.") + + import numpy as np + img_h, img_w = dewarped_bgr.shape[:2] + ocr_input = dewarped_bgr.copy() + + # 2. Scan quality assessment + scan_quality_info = {} + try: + from scan_quality import score_scan_quality + quality_report = score_scan_quality(ocr_input) + scan_quality_info = quality_report.to_dict() + actual_min_conf = min_conf if min_conf > 0 else quality_report.recommended_min_conf + except Exception as e: + logger.warning(f"rerun-ocr: scan quality failed: {e}") + actual_min_conf = min_conf if min_conf > 0 else 40 + + # 3. Image enhancement (Step 3) + is_degraded = scan_quality_info.get("is_degraded", False) + if enhance and is_degraded: + try: + from ocr_image_enhance import enhance_for_ocr + ocr_input = enhance_for_ocr(ocr_input, is_degraded=True) + logger.info("rerun-ocr: CLAHE enhancement applied") + except Exception as e: + logger.warning(f"rerun-ocr: enhancement failed: {e}") + + # 4. Run dual-engine OCR + from PIL import Image + import pytesseract + + # RapidOCR + rapid_words = [] + try: + from cv_ocr_engines import ocr_region_rapid + from cv_vocab_types import PageRegion + full_region = PageRegion(type="full_page", x=0, y=0, width=img_w, height=img_h) + rapid_words = ocr_region_rapid(ocr_input, full_region) or [] + except Exception as e: + logger.warning(f"rerun-ocr: RapidOCR failed: {e}") + + # Tesseract + pil_img = Image.fromarray(ocr_input[:, :, ::-1]) + data = pytesseract.image_to_data(pil_img, lang='eng+deu', config='--psm 6 --oem 3', output_type=pytesseract.Output.DICT) + tess_words = [] + for i in range(len(data["text"])): + text = (data["text"][i] or "").strip() + conf_raw = str(data["conf"][i]) + conf = int(conf_raw) if conf_raw.lstrip("-").isdigit() else -1 + if not text or conf < actual_min_conf: + continue + tess_words.append({ + "text": text, "left": data["left"][i], "top": data["top"][i], + "width": data["width"][i], "height": data["height"][i], "conf": conf, + }) + + # 5. Merge OCR results + from ocr_pipeline_ocr_merge import _split_paddle_multi_words, _merge_paddle_tesseract, _deduplicate_words + rapid_split = _split_paddle_multi_words(rapid_words) if rapid_words else [] + if rapid_split or tess_words: + merged_words = _merge_paddle_tesseract(rapid_split, tess_words, img_w, img_h) + merged_words = _deduplicate_words(merged_words) + else: + merged_words = tess_words + + # 6. Store updated word_result in session + cells_for_storage = [{"text": w["text"], "left": w["left"], "top": w["top"], + "width": w["width"], "height": w["height"], "conf": w.get("conf", 0)} + for w in merged_words] + word_result = { + "cells": [{"text": " ".join(w["text"] for w in merged_words), + "word_boxes": cells_for_storage}], + "image_width": img_w, + "image_height": img_h, + "ocr_engine": "rapid_kombi", + "word_count": len(merged_words), + "raw_paddle_words": rapid_words, + } + await update_session_db(session_id, word_result=word_result) + + # Reload session with updated word_result + session = await get_session_db(session_id) + + ocr_duration = _time.time() - t0 + logger.info( + "rerun-ocr session %s: %d words (rapid=%d, tess=%d, merged=%d) in %.1fs " + "(enhance=%s, min_conf=%d, quality=%s)", + session_id, len(merged_words), len(rapid_words), len(tess_words), + len(merged_words), ocr_duration, enhance, actual_min_conf, + scan_quality_info.get("quality_pct", "?"), + ) + + # 7. Build grid from new words + try: + result = await _build_grid_core( + session_id, session, + ipa_mode=ipa_mode, syllable_mode=syllable_mode, + enhance=enhance, + max_columns=max_cols if max_cols > 0 else None, + min_conf=min_conf if min_conf > 0 else None, + ) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + # Persist grid + await update_session_db(session_id, grid_editor_result=result, current_step=11) + + # Add quality info to response + result["scan_quality"] = scan_quality_info + result["ocr_stats"] = { + "rapid_words": len(rapid_words), + "tess_words": len(tess_words), + "merged_words": len(merged_words), + "min_conf_used": actual_min_conf, + "enhance_applied": enhance and is_degraded, + "ocr_duration_seconds": round(ocr_duration, 1), + } + + total_duration = _time.time() - t0 + logger.info( + "rerun-ocr+build-grid session %s: %d zones, %d cols, %d cells in %.1fs", + session_id, + len(result.get("zones", [])), + result.get("summary", {}).get("total_columns", 0), + result.get("summary", {}).get("total_cells", 0), + total_duration, + ) + + return result + + @router.post("/sessions/{session_id}/save-grid") async def save_grid(session_id: str, request: Request): """Save edited grid data from the frontend Excel-like editor.