diff --git a/admin-lehrer/app/(admin)/ai/ocr-pipeline/types.ts b/admin-lehrer/app/(admin)/ai/ocr-pipeline/types.ts index a7ba6e0..78ce81e 100644 --- a/admin-lehrer/app/(admin)/ai/ocr-pipeline/types.ts +++ b/admin-lehrer/app/(admin)/ai/ocr-pipeline/types.ts @@ -79,8 +79,11 @@ export interface DeskewResult { session_id: string angle_hough: number angle_word_alignment: number + angle_iterative?: number + angle_residual?: number + angle_textline?: number angle_applied: number - method_used: 'hough' | 'word_alignment' | 'manual' + method_used: 'hough' | 'word_alignment' | 'manual' | 'iterative' | 'two_pass' | 'three_pass' | 'manual_combined' confidence: number duration_seconds: number deskewed_image_url: string diff --git a/admin-lehrer/components/ocr-pipeline/DewarpControls.tsx b/admin-lehrer/components/ocr-pipeline/DewarpControls.tsx index d5d83bf..874c887 100644 --- a/admin-lehrer/components/ocr-pipeline/DewarpControls.tsx +++ b/admin-lehrer/components/ocr-pipeline/DewarpControls.tsx @@ -1,13 +1,15 @@ 'use client' import { useEffect, useState } from 'react' -import type { DewarpResult, DewarpDetection, DewarpGroundTruth } from '@/app/(admin)/ai/ocr-pipeline/types' +import type { DeskewResult, DewarpResult, DewarpDetection, DewarpGroundTruth } from '@/app/(admin)/ai/ocr-pipeline/types' interface DewarpControlsProps { dewarpResult: DewarpResult | null + deskewResult?: DeskewResult | null showGrid: boolean onToggleGrid: () => void onManualDewarp: (shearDegrees: number) => void + onCombinedAdjust?: (rotationDegrees: number, shearDegrees: number) => void onGroundTruth: (gt: DewarpGroundTruth) => void onNext: () => void isApplying: boolean @@ -19,9 +21,12 @@ const METHOD_LABELS: Record = { hough_lines: 'C: Hough-Linien', text_lines: 'D: Textzeilenanalyse', manual: 'Manuell', + manual_combined: 'Manuell (kombiniert)', none: 'Keine Korrektur', } +const SHEAR_METHOD_KEYS = ['vertical_edge', 'projection', 'hough_lines', 'text_lines'] as const + /** Colour for a confidence value (0-1). */ function confColor(conf: number): string { if (conf >= 0.7) return 'text-green-600 dark:text-green-400' @@ -43,11 +48,67 @@ function ConfBar({ value }: { value: number }) { ) } +/** A single slider row for fine-tuning. */ +function FineTuneSlider({ + label, + value, + onChange, + min, + max, + step, + unit = '\u00B0', + radioName, + radioChecked, + onRadioChange, +}: { + label: string + value: number + onChange: (v: number) => void + min: number + max: number + step: number + unit?: string + radioName?: string + radioChecked?: boolean + onRadioChange?: () => void +}) { + return ( +
+ {radioName !== undefined && ( + + )} + {label} + {min}{unit} + onChange(parseInt(e.target.value) / 100)} + className="flex-1 h-1.5 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700 accent-teal-500" + /> + +{max}{unit} + + {value >= 0 ? '+' : ''}{value.toFixed(2)}{unit} + +
+ ) +} + export function DewarpControls({ dewarpResult, + deskewResult, showGrid, onToggleGrid, onManualDewarp, + onCombinedAdjust, onGroundTruth, onNext, isApplying, @@ -57,6 +118,21 @@ export function DewarpControls({ const [gtNotes, setGtNotes] = useState('') const [gtSaved, setGtSaved] = useState(false) const [showDetails, setShowDetails] = useState(false) + const [showFineTune, setShowFineTune] = useState(false) + + // Fine-tuning rotation sliders (3 passes) + const [p1Iterative, setP1Iterative] = useState(0) + const [p2Residual, setP2Residual] = useState(0) + const [p3Textline, setP3Textline] = useState(0) + + // Fine-tuning shear sliders (4 methods) + selected method + const [shearValues, setShearValues] = useState>({ + vertical_edge: 0, + projection: 0, + hough_lines: 0, + text_lines: 0, + }) + const [selectedShearMethod, setSelectedShearMethod] = useState('vertical_edge') // Initialize slider to auto-detected value when result arrives useEffect(() => { @@ -65,6 +141,44 @@ export function DewarpControls({ } }, [dewarpResult?.shear_degrees]) + // Initialize fine-tuning sliders from deskew result + useEffect(() => { + if (deskewResult) { + setP1Iterative(deskewResult.angle_iterative ?? 0) + setP2Residual(deskewResult.angle_residual ?? 0) + setP3Textline(deskewResult.angle_textline ?? 0) + } + }, [deskewResult]) + + // Initialize shear sliders from dewarp detections + useEffect(() => { + if (dewarpResult?.detections) { + const newValues = { ...shearValues } + let bestMethod = selectedShearMethod + let bestConf = -1 + for (const d of dewarpResult.detections) { + if (d.method in newValues) { + newValues[d.method] = d.shear_degrees + if (d.confidence > bestConf) { + bestConf = d.confidence + bestMethod = d.method + } + } + } + setShearValues(newValues) + // Select the method that was actually used, or the highest confidence + if (dewarpResult.method_used && dewarpResult.method_used in newValues) { + setSelectedShearMethod(dewarpResult.method_used) + } else { + setSelectedShearMethod(bestMethod) + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [dewarpResult?.detections]) + + const rotationSum = p1Iterative + p2Residual + p3Textline + const activeShear = shearValues[selectedShearMethod] ?? 0 + const handleGroundTruth = (isCorrect: boolean) => { setGtFeedback(isCorrect ? 'correct' : 'incorrect') if (isCorrect) { @@ -82,8 +196,18 @@ export function DewarpControls({ setGtSaved(true) } + const handleShearValueChange = (method: string, value: number) => { + setShearValues((prev) => ({ ...prev, [method]: value })) + } + + const handleFineTunePreview = () => { + if (onCombinedAdjust) { + onCombinedAdjust(rotationSum, activeShear) + } + } + const wasRejected = dewarpResult && dewarpResult.method_used === 'none' && (dewarpResult.detections || []).length > 0 - const wasApplied = dewarpResult && dewarpResult.method_used !== 'none' && dewarpResult.method_used !== 'manual' + const wasApplied = dewarpResult && dewarpResult.method_used !== 'none' && dewarpResult.method_used !== 'manual' && dewarpResult.method_used !== 'manual_combined' const detections = dewarpResult?.detections || [] return ( @@ -106,9 +230,9 @@ export function DewarpControls({ {wasRejected ? 'Quality Gate: Korrektur verworfen (Projektion nicht verbessert)' : wasApplied - ? `Korrektur angewendet: ${dewarpResult.shear_degrees.toFixed(2)}°` - : dewarpResult.method_used === 'manual' - ? `Manuelle Korrektur: ${dewarpResult.shear_degrees.toFixed(2)}°` + ? `Korrektur angewendet: ${dewarpResult.shear_degrees.toFixed(2)}\u00B0` + : dewarpResult.method_used === 'manual' || dewarpResult.method_used === 'manual_combined' + ? `Manuelle Korrektur: ${dewarpResult.shear_degrees.toFixed(2)}\u00B0` : 'Keine Korrektur noetig'} @@ -117,7 +241,7 @@ export function DewarpControls({
Scherung:{' '} - {dewarpResult.shear_degrees.toFixed(2)}° + {dewarpResult.shear_degrees.toFixed(2)}\u00B0
@@ -185,7 +309,7 @@ export function DewarpControls({ {METHOD_LABELS[d.method] || d.method} - {d.shear_degrees.toFixed(2)}° + {d.shear_degrees.toFixed(2)}\u00B0 {!aboveThreshold && ( @@ -210,7 +334,7 @@ export function DewarpControls({
Scherwinkel (manuell)
- -2.0° + -2.0\u00B0 setManualShear(parseInt(e.target.value) / 100)} className="flex-1 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700 accent-teal-500" /> - +2.0° - {manualShear.toFixed(2)}° + +2.0\u00B0 + {manualShear.toFixed(2)}\u00B0
)} + {/* Fine-tuning panel */} + {dewarpResult && onCombinedAdjust && ( +
+ + + {showFineTune && ( +
+ {/* Rotation section */} +
+
+ Rotation (Begradigung) +
+
+ + + +
+ Summe Rotation + + {rotationSum >= 0 ? '+' : ''}{rotationSum.toFixed(2)}\u00B0 + +
+
+
+ + {/* Shear section */} +
+
+ Scherung (Entzerrung) — einen Wert waehlen +
+
+ {SHEAR_METHOD_KEYS.map((method) => ( + handleShearValueChange(method, v)} + min={-5} + max={5} + step={0.05} + radioName="shear-method" + radioChecked={selectedShearMethod === method} + onRadioChange={() => setSelectedShearMethod(method)} + /> + ))} +
+ Gewaehlte Scherung + + {activeShear >= 0 ? '+' : ''}{activeShear.toFixed(2)}\u00B0 + + + ({METHOD_LABELS[selectedShearMethod]}) + +
+
+
+ + {/* Preview + Save */} +
+ + + + Rotation: {rotationSum >= 0 ? '+' : ''}{rotationSum.toFixed(2)}\u00B0 + Scherung: {activeShear >= 0 ? '+' : ''}{activeShear.toFixed(2)}\u00B0 + +
+
+ )} +
+ )} + {/* Ground Truth */} - {dewarpResult && ( + {dewarpResult && !showFineTune && (
Spalten vertikal ausgerichtet? diff --git a/admin-lehrer/components/ocr-pipeline/StepDewarp.tsx b/admin-lehrer/components/ocr-pipeline/StepDewarp.tsx index 39a69d3..98f8514 100644 --- a/admin-lehrer/components/ocr-pipeline/StepDewarp.tsx +++ b/admin-lehrer/components/ocr-pipeline/StepDewarp.tsx @@ -1,7 +1,7 @@ 'use client' import { useCallback, useEffect, useState } from 'react' -import type { DewarpResult, DewarpGroundTruth } from '@/app/(admin)/ai/ocr-pipeline/types' +import type { DeskewResult, DewarpResult, DewarpGroundTruth } from '@/app/(admin)/ai/ocr-pipeline/types' import { DewarpControls } from './DewarpControls' import { ImageCompareView } from './ImageCompareView' @@ -14,11 +14,31 @@ interface StepDewarpProps { export function StepDewarp({ sessionId, onNext }: StepDewarpProps) { const [dewarpResult, setDewarpResult] = useState(null) + const [deskewResult, setDeskewResult] = useState(null) const [dewarping, setDewarping] = useState(false) const [applying, setApplying] = useState(false) const [showGrid, setShowGrid] = useState(true) const [error, setError] = useState(null) + // Load session info to get deskew_result (for fine-tuning init values) + useEffect(() => { + if (!sessionId) return + const loadSession = async () => { + try { + const res = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}`) + if (res.ok) { + const data = await res.json() + if (data.deskew_result) { + setDeskewResult(data.deskew_result) + } + } + } catch (e) { + console.error('Failed to load session info:', e) + } + } + loadSession() + }, [sessionId]) + // Auto-trigger dewarp when component mounts with a sessionId useEffect(() => { if (!sessionId || dewarpResult) return @@ -78,6 +98,37 @@ export function StepDewarp({ sessionId, onNext }: StepDewarpProps) { } }, [sessionId]) + const handleCombinedAdjust = useCallback(async (rotationDegrees: number, shearDegrees: number) => { + if (!sessionId) return + setApplying(true) + setError(null) + + try { + const res = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/adjust-combined`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ rotation_degrees: rotationDegrees, shear_degrees: shearDegrees }), + }) + if (!res.ok) throw new Error('Kombinierte Anpassung fehlgeschlagen') + + const data = await res.json() + setDewarpResult((prev) => + prev + ? { + ...prev, + method_used: data.method_used, + shear_degrees: data.shear_degrees, + dewarped_image_url: `${KLAUSUR_API}${data.dewarped_image_url}?t=${Date.now()}`, + } + : null, + ) + } catch (e) { + setError(e instanceof Error ? e.message : 'Fehler') + } finally { + setApplying(false) + } + }, [sessionId]) + const handleGroundTruth = useCallback(async (gt: DewarpGroundTruth) => { if (!sessionId) return try { @@ -133,9 +184,11 @@ export function StepDewarp({ sessionId, onNext }: StepDewarpProps) { {/* Controls */} setShowGrid((v) => !v)} onManualDewarp={handleManualDewarp} + onCombinedAdjust={handleCombinedAdjust} onGroundTruth={handleGroundTruth} onNext={onNext} isApplying={applying} diff --git a/klausur-service/backend/ocr_pipeline_api.py b/klausur-service/backend/ocr_pipeline_api.py index 43425b3..d3a3295 100644 --- a/klausur-service/backend/ocr_pipeline_api.py +++ b/klausur-service/backend/ocr_pipeline_api.py @@ -148,6 +148,11 @@ class ManualDewarpRequest(BaseModel): shear_degrees: float +class CombinedAdjustRequest(BaseModel): + rotation_degrees: float = 0.0 + shear_degrees: float = 0.0 + + class DewarpGroundTruthRequest(BaseModel): is_correct: bool corrected_shear: Optional[float] = None @@ -848,6 +853,93 @@ async def manual_dewarp(session_id: str, req: ManualDewarpRequest): } +@router.post("/sessions/{session_id}/adjust-combined") +async def adjust_combined(session_id: str, req: CombinedAdjustRequest): + """Apply rotation + shear combined to the original image. + + Used by the fine-tuning sliders to preview arbitrary rotation/shear + combinations without re-running the full deskew/dewarp pipeline. + """ + if session_id not in _cache: + await _load_session_to_cache(session_id) + cached = _get_cached(session_id) + + img_bgr = cached.get("original_bgr") + if img_bgr is None: + raise HTTPException(status_code=400, detail="Original image not available") + + rotation = max(-15.0, min(15.0, req.rotation_degrees)) + shear_deg = max(-5.0, min(5.0, req.shear_degrees)) + + h, w = img_bgr.shape[:2] + result_bgr = img_bgr + + # Step 1: Apply rotation + if abs(rotation) >= 0.001: + center = (w // 2, h // 2) + M = cv2.getRotationMatrix2D(center, rotation, 1.0) + result_bgr = cv2.warpAffine(result_bgr, M, (w, h), + flags=cv2.INTER_LINEAR, + borderMode=cv2.BORDER_REPLICATE) + + # Step 2: Apply shear + if abs(shear_deg) >= 0.001: + result_bgr = dewarp_image_manual(result_bgr, shear_deg) + + # Encode + success, png_buf = cv2.imencode(".png", result_bgr) + dewarped_png = png_buf.tobytes() if success else b"" + + # Binarize + binarized_png = None + try: + binarized = create_ocr_image(result_bgr) + success_bin, bin_buf = cv2.imencode(".png", binarized) + binarized_png = bin_buf.tobytes() if success_bin else None + except Exception: + pass + + # Build combined result dicts + deskew_result = { + **(cached.get("deskew_result") or {}), + "angle_applied": round(rotation, 3), + "method_used": "manual_combined", + } + dewarp_result = { + **(cached.get("dewarp_result") or {}), + "method_used": "manual_combined", + "shear_degrees": round(shear_deg, 3), + } + + # Update cache + cached["deskewed_bgr"] = result_bgr + cached["dewarped_bgr"] = result_bgr + cached["deskew_result"] = deskew_result + cached["dewarp_result"] = dewarp_result + + # Persist to DB + db_update = { + "dewarped_png": dewarped_png, + "deskew_result": deskew_result, + "dewarp_result": dewarp_result, + } + if binarized_png: + db_update["binarized_png"] = binarized_png + db_update["deskewed_png"] = dewarped_png + await update_session_db(session_id, **db_update) + + logger.info(f"OCR Pipeline: combined adjust session {session_id}: " + f"rotation={rotation:.3f} shear={shear_deg:.3f}") + + return { + "session_id": session_id, + "rotation_degrees": round(rotation, 3), + "shear_degrees": round(shear_deg, 3), + "method_used": "manual_combined", + "dewarped_image_url": f"/api/v1/ocr-pipeline/sessions/{session_id}/image/dewarped", + } + + @router.post("/sessions/{session_id}/ground-truth/dewarp") async def save_dewarp_ground_truth(session_id: str, req: DewarpGroundTruthRequest): """Save ground truth feedback for the dewarp step."""