'use client' /** * GroundTruthPanel — Step-through UI for labeling OCR ground truth. * * Shows page image with SVG overlay (color-coded bounding boxes), * alongside crops of the current entry and editable text fields. * Keyboard-driven: Enter=confirm, Tab=skip, Arrow keys=navigate. */ import { useState, useEffect, useCallback, useRef } from 'react' // ---------- Types ---------- interface BBox { x: number y: number w: number h: number } interface GTEntry { row_index: number english: string german: string example: string confidence: number bbox: BBox bbox_en: BBox bbox_de: BBox bbox_ex: BBox status?: 'pending' | 'confirmed' | 'edited' | 'skipped' } interface GroundTruthPanelProps { sessionId: string selectedPage: number pageImageUrl: string } // ---------- Helpers ---------- const STATUS_COLORS: Record = { current: { fill: 'rgba(250,204,21,0.25)', stroke: '#eab308' }, // yellow confirmed: { fill: 'rgba(34,197,94,0.18)', stroke: '#16a34a' }, // green edited: { fill: 'rgba(59,130,246,0.18)', stroke: '#2563eb' }, // blue skipped: { fill: 'rgba(148,163,184,0.15)', stroke: '#94a3b8' }, // gray pending: { fill: 'rgba(0,0,0,0)', stroke: '#cbd5e1' }, // outline only } function getEntryColor(entry: GTEntry, index: number, currentIndex: number) { if (index === currentIndex) return STATUS_COLORS.current return STATUS_COLORS[entry.status || 'pending'] } // ---------- ImageCrop ---------- function ImageCrop({ imageUrl, bbox, naturalWidth, naturalHeight, maxWidth = 380, label }: { imageUrl: string bbox: BBox naturalWidth: number naturalHeight: number maxWidth?: number label?: string }) { if (!bbox || bbox.w === 0 || bbox.h === 0) return null const cropWPx = (bbox.w / 100) * naturalWidth const cropHPx = (bbox.h / 100) * naturalHeight if (cropWPx < 1 || cropHPx < 1) return null const scale = maxWidth / cropWPx const displayH = cropHPx * scale return (
{label &&
{label}
}
) } // ---------- Main Component ---------- export function GroundTruthPanel({ sessionId, selectedPage, pageImageUrl }: GroundTruthPanelProps) { const KLAUSUR_API = '/klausur-api' // State const [entries, setEntries] = useState([]) const [currentIndex, setCurrentIndex] = useState(0) const [loading, setLoading] = useState(false) const [saving, setSaving] = useState(false) const [error, setError] = useState(null) const [imageNatural, setImageNatural] = useState({ w: 0, h: 0 }) const [showSummary, setShowSummary] = useState(false) const [savedMessage, setSavedMessage] = useState(null) const [isFullscreen, setIsFullscreen] = useState(false) const [imageUrl, setImageUrl] = useState(pageImageUrl) const [deskewAngle, setDeskewAngle] = useState(null) // Editable fields for current entry const [editEn, setEditEn] = useState('') const [editDe, setEditDe] = useState('') const [editEx, setEditEx] = useState('') const panelRef = useRef(null) const enInputRef = useRef(null) // Reset image URL when page changes useEffect(() => { setImageUrl(pageImageUrl) setDeskewAngle(null) }, [pageImageUrl]) // Load natural image dimensions useEffect(() => { if (!imageUrl) return const img = new Image() img.onload = () => setImageNatural({ w: img.naturalWidth, h: img.naturalHeight }) img.src = imageUrl }, [imageUrl]) // Sync edit fields when current entry changes useEffect(() => { const entry = entries[currentIndex] if (entry) { setEditEn(entry.english) setEditDe(entry.german) setEditEx(entry.example) } }, [currentIndex, entries]) // ---------- Actions ---------- const handleExtract = useCallback(async () => { setLoading(true) setError(null) setShowSummary(false) setSavedMessage(null) try { const res = await fetch(`${KLAUSUR_API}/api/v1/vocab/sessions/${sessionId}/extract-with-boxes/${selectedPage}`, { method: 'POST', }) if (!res.ok) { const err = await res.json().catch(() => ({ detail: res.statusText })) throw new Error(err.detail || 'Extract failed') } const data = await res.json() const loaded: GTEntry[] = (data.entries || []).map((e: GTEntry) => ({ ...e, status: 'pending' as const })) setEntries(loaded) setCurrentIndex(0) // Switch to deskewed image if available if (data.deskewed) { setImageUrl(`${KLAUSUR_API}/api/v1/vocab/sessions/${sessionId}/deskewed-image/${selectedPage}`) setDeskewAngle(data.deskew_angle) } } catch (err) { setError(err instanceof Error ? err.message : 'Extraction failed') } finally { setLoading(false) } }, [sessionId, selectedPage]) const confirmEntry = useCallback(() => { if (entries.length === 0) return const entry = entries[currentIndex] const isEdited = editEn !== entry.english || editDe !== entry.german || editEx !== entry.example const updated = [...entries] updated[currentIndex] = { ...entry, english: editEn, german: editDe, example: editEx, status: isEdited ? 'edited' : 'confirmed', } setEntries(updated) if (currentIndex < entries.length - 1) { setCurrentIndex(currentIndex + 1) } else { setShowSummary(true) } }, [entries, currentIndex, editEn, editDe, editEx]) const skipEntry = useCallback(() => { if (entries.length === 0) return const updated = [...entries] updated[currentIndex] = { ...updated[currentIndex], status: 'skipped' } setEntries(updated) if (currentIndex < entries.length - 1) { setCurrentIndex(currentIndex + 1) } else { setShowSummary(true) } }, [entries, currentIndex]) const goTo = useCallback((idx: number) => { if (idx >= 0 && idx < entries.length) { setCurrentIndex(idx) setShowSummary(false) } }, [entries.length]) const handleSave = useCallback(async () => { setSaving(true) setError(null) try { const res = await fetch(`${KLAUSUR_API}/api/v1/vocab/sessions/${sessionId}/ground-truth/${selectedPage}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ entries }), }) if (!res.ok) throw new Error('Save failed') const data = await res.json() setSavedMessage(`Gespeichert: ${data.confirmed} bestaetigt, ${data.edited} editiert, ${data.skipped} uebersprungen`) } catch (err) { setError(err instanceof Error ? err.message : 'Save failed') } finally { setSaving(false) } }, [sessionId, selectedPage, entries]) // ---------- Keyboard shortcuts ---------- useEffect(() => { const handler = (e: KeyboardEvent) => { if (e.key === 'Escape' && isFullscreen) { e.preventDefault() setIsFullscreen(false) return } if (entries.length === 0 || showSummary) return // Don't capture when typing in inputs const tag = (e.target as HTMLElement)?.tagName const isInput = tag === 'INPUT' || tag === 'TEXTAREA' if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault() confirmEntry() } else if (e.key === 'Tab' && !e.shiftKey) { if (!isInput) { e.preventDefault() skipEntry() } } else if (e.key === 'ArrowLeft' && !isInput) { e.preventDefault() goTo(currentIndex - 1) } else if (e.key === 'ArrowRight' && !isInput) { e.preventDefault() goTo(currentIndex + 1) } } window.addEventListener('keydown', handler) return () => window.removeEventListener('keydown', handler) }, [entries.length, showSummary, isFullscreen, confirmEntry, skipEntry, goTo, currentIndex]) // ---------- Computed ---------- const currentEntry = entries[currentIndex] const confirmedCount = entries.filter(e => e.status === 'confirmed').length const editedCount = entries.filter(e => e.status === 'edited').length const skippedCount = entries.filter(e => e.status === 'skipped').length const processedCount = confirmedCount + editedCount + skippedCount const progress = entries.length > 0 ? Math.round((processedCount / entries.length) * 100) : 0 // ---------- Render: No entries yet ---------- if (entries.length === 0) { return (

Ground Truth Labeling

Erkennung starten um Vokabeln mit Positionen zu extrahieren. Danach jede Zeile durchgehen und bestaetigen oder korrigieren.

{error && (
{error}
)}
) } // ---------- Render: Summary ---------- if (showSummary) { return (

Zusammenfassung

{confirmedCount}
Bestaetigt
{editedCount}
Editiert
{skippedCount}
Uebersprungen
{savedMessage && (
{savedMessage}
)} {error && (
{error}
)} {/* Entry list for quick review */}
{entries.map((e, i) => ( goTo(i)} className="border-b border-slate-100 hover:bg-slate-50 cursor-pointer" > ))}
# English Deutsch Status
{i + 1} {e.english} {e.german} {e.status === 'confirmed' ? 'OK' : e.status === 'edited' ? 'Editiert' : e.status === 'skipped' ? 'Skip' : 'Offen'}
) } // ---------- Render: Main Review UI ---------- return (
{/* Header with progress + fullscreen toggle */}
{currentIndex + 1}/{entries.length} {deskewAngle !== null && ( {deskewAngle.toFixed(1)}° )}
{/* Left: Page image with SVG overlay (2/3) */}
{imageUrl && ( {`Seite )} {/* SVG Overlay */} {entries.map((entry, i) => { const colors = getEntryColor(entry, i, currentIndex) return ( goTo(i)} /> ) })}
{/* Legend */}
Aktuell Bestaetigt Editiert Uebersprungen
{/* Right: Crops + Edit fields (1/3) */}
{currentEntry && ( <> {/* Row crop */} {imageNatural.w > 0 && ( )} {/* Column crops */} {imageNatural.w > 0 && (
{currentEntry.bbox_en.w > 0 && ( )} {currentEntry.bbox_de.w > 0 && ( )} {currentEntry.bbox_ex.w > 0 && ( )}
)} {/* Confidence badge */}
= 70 ? 'bg-green-100 text-green-700' : currentEntry.confidence >= 40 ? 'bg-yellow-100 text-yellow-700' : 'bg-red-100 text-red-700' }`}> Konfidenz: {currentEntry.confidence}%
{/* Edit fields */}
setEditEn(e.target.value)} className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-teal-500 focus:border-teal-500" />
setEditDe(e.target.value)} className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-teal-500 focus:border-teal-500" />
setEditEx(e.target.value)} className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-teal-500 focus:border-teal-500" />
{/* Action buttons */}
{/* Navigation */}
{currentIndex + 1} / {entries.length}
{/* Progress stats */}
{confirmedCount} bestaetigt · {editedCount} editiert · {skippedCount} uebersprungen · {progress}%
{/* Keyboard hints */}
Enter = Bestaetigen · Tab = Ueberspringen · ←→ = Navigieren{isFullscreen ? ' \u00B7 Esc = Vollbild verlassen' : ''}
)}
{error && (
{error}
)}
) }