diff --git a/admin-lehrer/app/(admin)/ai/ocr-pipeline/page.tsx b/admin-lehrer/app/(admin)/ai/ocr-pipeline/page.tsx index baaad9c..fdd202b 100644 --- a/admin-lehrer/app/(admin)/ai/ocr-pipeline/page.tsx +++ b/admin-lehrer/app/(admin)/ai/ocr-pipeline/page.tsx @@ -6,6 +6,7 @@ import { PipelineStepper } from '@/components/ocr-pipeline/PipelineStepper' import { StepDeskew } from '@/components/ocr-pipeline/StepDeskew' import { StepDewarp } from '@/components/ocr-pipeline/StepDewarp' import { StepColumnDetection } from '@/components/ocr-pipeline/StepColumnDetection' +import { StepRowDetection } from '@/components/ocr-pipeline/StepRowDetection' import { StepWordRecognition } from '@/components/ocr-pipeline/StepWordRecognition' import { StepCoordinates } from '@/components/ocr-pipeline/StepCoordinates' import { StepReconstruction } from '@/components/ocr-pipeline/StepReconstruction' @@ -142,10 +143,11 @@ export default function OcrPipelinePage() { 1: 'Begradigung', 2: 'Entzerrung', 3: 'Spalten', - 4: 'Woerter', - 5: 'Koordinaten', - 6: 'Rekonstruktion', - 7: 'Validierung', + 4: 'Zeilen', + 5: 'Woerter', + 6: 'Koordinaten', + 7: 'Rekonstruktion', + 8: 'Validierung', } const renderStep = () => { @@ -157,12 +159,14 @@ export default function OcrPipelinePage() { case 2: return case 3: - return + return case 4: - return + return case 5: - return + return case 6: + return + case 7: return default: return null diff --git a/admin-lehrer/app/(admin)/ai/ocr-pipeline/types.ts b/admin-lehrer/app/(admin)/ai/ocr-pipeline/types.ts index a0feca1..c1acb40 100644 --- a/admin-lehrer/app/(admin)/ai/ocr-pipeline/types.ts +++ b/admin-lehrer/app/(admin)/ai/ocr-pipeline/types.ts @@ -28,6 +28,7 @@ export interface SessionInfo { deskew_result?: DeskewResult dewarp_result?: DewarpResult column_result?: ColumnResult + row_result?: RowResult } export interface DeskewResult { @@ -91,10 +92,35 @@ export interface ManualColumnDivider { export type ColumnTypeKey = PageRegion['type'] +export interface RowResult { + rows: RowItem[] + summary: Record + total_rows: number + duration_seconds: number +} + +export interface RowItem { + index: number + x: number + y: number + width: number + height: number + word_count: number + row_type: 'content' | 'header' | 'footer' + gap_before: number +} + +export interface RowGroundTruth { + is_correct: boolean + corrected_rows?: RowItem[] + notes?: string +} + export const PIPELINE_STEPS: PipelineStep[] = [ { id: 'deskew', name: 'Begradigung', icon: '📐', status: 'pending' }, { id: 'dewarp', name: 'Entzerrung', icon: '🔧', status: 'pending' }, { id: 'columns', name: 'Spalten', icon: '📊', status: 'pending' }, + { id: 'rows', name: 'Zeilen', icon: '📏', status: 'pending' }, { id: 'words', name: 'Woerter', icon: '🔤', status: 'pending' }, { id: 'coordinates', name: 'Koordinaten', icon: '📍', status: 'pending' }, { id: 'reconstruction', name: 'Rekonstruktion', icon: '🏗️', status: 'pending' }, diff --git a/admin-lehrer/components/ocr-pipeline/StepRowDetection.tsx b/admin-lehrer/components/ocr-pipeline/StepRowDetection.tsx new file mode 100644 index 0000000..5bb5ad6 --- /dev/null +++ b/admin-lehrer/components/ocr-pipeline/StepRowDetection.tsx @@ -0,0 +1,263 @@ +'use client' + +import { useCallback, useEffect, useState } from 'react' +import type { RowResult, RowGroundTruth } from '@/app/(admin)/ai/ocr-pipeline/types' + +const KLAUSUR_API = '/klausur-api' + +interface StepRowDetectionProps { + sessionId: string | null + onNext: () => void +} + +export function StepRowDetection({ sessionId, onNext }: StepRowDetectionProps) { + const [rowResult, setRowResult] = useState(null) + const [detecting, setDetecting] = useState(false) + const [error, setError] = useState(null) + const [gtNotes, setGtNotes] = useState('') + const [gtSaved, setGtSaved] = useState(false) + + useEffect(() => { + if (!sessionId) return + + const fetchSession = async () => { + try { + const res = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}`) + if (res.ok) { + const info = await res.json() + if (info.row_result) { + setRowResult(info.row_result) + return + } + } + } catch (e) { + console.error('Failed to fetch session info:', e) + } + // No cached result — run auto + runAutoDetection() + } + + fetchSession() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [sessionId]) + + const runAutoDetection = useCallback(async () => { + if (!sessionId) return + setDetecting(true) + setError(null) + try { + const res = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/rows`, { + method: 'POST', + }) + if (!res.ok) { + const err = await res.json().catch(() => ({ detail: res.statusText })) + throw new Error(err.detail || 'Zeilenerkennung fehlgeschlagen') + } + const data: RowResult = await res.json() + setRowResult(data) + } catch (e) { + setError(e instanceof Error ? e.message : 'Unbekannter Fehler') + } finally { + setDetecting(false) + } + }, [sessionId]) + + const handleGroundTruth = useCallback(async (isCorrect: boolean) => { + if (!sessionId) return + const gt: RowGroundTruth = { + is_correct: isCorrect, + notes: gtNotes || undefined, + } + try { + await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/ground-truth/rows`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(gt), + }) + setGtSaved(true) + } catch (e) { + console.error('Ground truth save failed:', e) + } + }, [sessionId, gtNotes]) + + if (!sessionId) { + return ( +
+
📏
+

+ Schritt 4: Zeilenerkennung +

+

+ Bitte zuerst Schritte 1-3 abschliessen. +

+
+ ) + } + + const overlayUrl = `${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/image/rows-overlay` + const dewarpedUrl = `${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/image/dewarped` + + const rowTypeColors: Record = { + header: 'bg-gray-200 dark:bg-gray-600 text-gray-700 dark:text-gray-300', + content: 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300', + footer: 'bg-gray-200 dark:bg-gray-600 text-gray-700 dark:text-gray-300', + } + + return ( +
+ {/* Loading */} + {detecting && ( +
+
+ Zeilenerkennung laeuft... +
+ )} + + {/* Images: overlay vs clean */} +
+
+
+ Mit Zeilen-Overlay +
+
+ {rowResult ? ( + // eslint-disable-next-line @next/next/no-img-element + Zeilen-Overlay + ) : ( +
+ {detecting ? 'Erkenne Zeilen...' : 'Keine Daten'} +
+ )} +
+
+
+
+ Entzerrtes Bild +
+
+ {/* eslint-disable-next-line @next/next/no-img-element */} + Entzerrt +
+
+
+ + {/* Row summary */} + {rowResult && ( +
+
+

+ Ergebnis: {rowResult.total_rows} Zeilen erkannt +

+ + {rowResult.duration_seconds}s + +
+ + {/* Type summary badges */} +
+ {Object.entries(rowResult.summary).map(([type, count]) => ( + + {type}: {count} + + ))} +
+ + {/* Row list */} +
+ {rowResult.rows.map((row) => ( +
+ R{row.index} + + {row.row_type} + + y={row.y} + h={row.height}px + {row.word_count} Woerter + {row.gap_before > 0 && ( + gap={row.gap_before}px + )} +
+ ))} +
+
+ )} + + {/* Controls */} + {rowResult && ( +
+
+ + +
+ + {/* Ground truth */} + {!gtSaved ? ( + <> + setGtNotes(e.target.value)} + className="px-2 py-1 text-xs border rounded dark:bg-gray-700 dark:border-gray-600 w-48" + /> + + + + ) : ( + + Ground Truth gespeichert + + )} + + +
+
+ )} + + {error && ( +
+ {error} +
+ )} +
+ ) +}