'use client' import { useCallback, useEffect, useRef, useState } from 'react' import type { ExcludeRegion, StructureResult } from '@/app/(admin)/ai/ocr-pipeline/types' const KLAUSUR_API = '/klausur-api' interface StepStructureDetectionProps { sessionId: string | null onNext: () => void } const COLOR_HEX: Record = { red: '#dc2626', orange: '#ea580c', yellow: '#ca8a04', green: '#16a34a', blue: '#2563eb', purple: '#9333ea', } type DetectionMethod = 'auto' | 'opencv' | 'ppdoclayout' /** Color map for PP-DocLayout region classes */ const DOCLAYOUT_CLASS_COLORS: Record = { table: '#2563eb', figure: '#16a34a', title: '#ea580c', text: '#6b7280', list: '#9333ea', header: '#0ea5e9', footer: '#64748b', equation: '#dc2626', } const DOCLAYOUT_DEFAULT_COLOR = '#a3a3a3' function getDocLayoutColor(className: string): string { return DOCLAYOUT_CLASS_COLORS[className.toLowerCase()] || DOCLAYOUT_DEFAULT_COLOR } /** * Convert a mouse event on the image container to image-pixel coordinates. * The image uses object-contain inside an A4-ratio container, so we need * to account for letterboxing. */ function mouseToImageCoords( e: React.MouseEvent, containerEl: HTMLElement, imgWidth: number, imgHeight: number, ): { x: number; y: number } | null { const rect = containerEl.getBoundingClientRect() const containerW = rect.width const containerH = rect.height // object-contain: image is scaled to fit, centered const scaleX = containerW / imgWidth const scaleY = containerH / imgHeight const scale = Math.min(scaleX, scaleY) const renderedW = imgWidth * scale const renderedH = imgHeight * scale const offsetX = (containerW - renderedW) / 2 const offsetY = (containerH - renderedH) / 2 const relX = e.clientX - rect.left - offsetX const relY = e.clientY - rect.top - offsetY if (relX < 0 || relY < 0 || relX > renderedW || relY > renderedH) { return null } return { x: Math.round(relX / scale), y: Math.round(relY / scale), } } /** * Convert image-pixel coordinates to container-relative percentages * for overlay positioning. */ function imageToOverlayPct( region: { x: number; y: number; w: number; h: number }, containerW: number, containerH: number, imgWidth: number, imgHeight: number, ): { left: string; top: string; width: string; height: string } { const scaleX = containerW / imgWidth const scaleY = containerH / imgHeight const scale = Math.min(scaleX, scaleY) const renderedW = imgWidth * scale const renderedH = imgHeight * scale const offsetX = (containerW - renderedW) / 2 const offsetY = (containerH - renderedH) / 2 const left = offsetX + region.x * scale const top = offsetY + region.y * scale const width = region.w * scale const height = region.h * scale return { left: `${(left / containerW) * 100}%`, top: `${(top / containerH) * 100}%`, width: `${(width / containerW) * 100}%`, height: `${(height / containerH) * 100}%`, } } export function StepStructureDetection({ sessionId, onNext }: StepStructureDetectionProps) { const [result, setResult] = useState(null) const [detecting, setDetecting] = useState(false) const [error, setError] = useState(null) const [hasRun, setHasRun] = useState(false) const [overlayTs, setOverlayTs] = useState(0) const [detectionMethod, setDetectionMethod] = useState('auto') // Exclude region drawing state const [excludeRegions, setExcludeRegions] = useState([]) const [drawing, setDrawing] = useState(false) const [drawStart, setDrawStart] = useState<{ x: number; y: number } | null>(null) const [drawCurrent, setDrawCurrent] = useState<{ x: number; y: number } | null>(null) const [saving, setSaving] = useState(false) const [drawMode, setDrawMode] = useState(false) const containerRef = useRef(null) const overlayContainerRef = useRef(null) const [containerSize, setContainerSize] = useState({ w: 0, h: 0 }) const [overlayContainerSize, setOverlayContainerSize] = useState({ w: 0, h: 0 }) // Track container size for overlay positioning useEffect(() => { const el = containerRef.current if (!el) return const obs = new ResizeObserver((entries) => { for (const entry of entries) { setContainerSize({ w: entry.contentRect.width, h: entry.contentRect.height }) } }) obs.observe(el) return () => obs.disconnect() }, []) // Track overlay container size for PP-DocLayout region overlays useEffect(() => { const el = overlayContainerRef.current if (!el) return const obs = new ResizeObserver((entries) => { for (const entry of entries) { setOverlayContainerSize({ w: entry.contentRect.width, h: entry.contentRect.height }) } }) obs.observe(el) return () => obs.disconnect() }, []) // Auto-trigger detection on mount useEffect(() => { if (!sessionId || hasRun) return setHasRun(true) const runDetection = async () => { setDetecting(true) setError(null) try { const params = detectionMethod !== 'auto' ? `?method=${detectionMethod}` : '' const res = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/detect-structure${params}`, { method: 'POST', }) if (!res.ok) { throw new Error('Strukturerkennung fehlgeschlagen') } const data = await res.json() setResult(data) setExcludeRegions(data.exclude_regions || []) setOverlayTs(Date.now()) } catch (e) { setError(e instanceof Error ? e.message : 'Unbekannter Fehler') } finally { setDetecting(false) } } runDetection() }, [sessionId, hasRun]) const handleRerun = async () => { if (!sessionId) return setDetecting(true) setError(null) try { const params = detectionMethod !== 'auto' ? `?method=${detectionMethod}` : '' const res = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/detect-structure${params}`, { method: 'POST', }) if (!res.ok) throw new Error('Erneute Erkennung fehlgeschlagen') const data = await res.json() setResult(data) setExcludeRegions(data.exclude_regions || []) setOverlayTs(Date.now()) } catch (e) { setError(e instanceof Error ? e.message : 'Unbekannter Fehler') } finally { setDetecting(false) } } // Save exclude regions to backend const saveExcludeRegions = useCallback(async (regions: ExcludeRegion[]) => { if (!sessionId) return setSaving(true) try { const res = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/exclude-regions`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ regions }), }) if (!res.ok) throw new Error('Speichern fehlgeschlagen') } catch (e) { setError(e instanceof Error ? e.message : 'Speichern fehlgeschlagen') } finally { setSaving(false) } }, [sessionId]) // Mouse handlers for drawing exclude rectangles const handleMouseDown = useCallback((e: React.MouseEvent) => { if (!drawMode || !containerRef.current || !result) return const coords = mouseToImageCoords(e, containerRef.current, result.image_width, result.image_height) if (coords) { setDrawing(true) setDrawStart(coords) setDrawCurrent(coords) } }, [drawMode, result]) const handleMouseMove = useCallback((e: React.MouseEvent) => { if (!drawing || !containerRef.current || !result) return const coords = mouseToImageCoords(e, containerRef.current, result.image_width, result.image_height) if (coords) { setDrawCurrent(coords) } }, [drawing, result]) const handleMouseUp = useCallback(() => { if (!drawing || !drawStart || !drawCurrent) { setDrawing(false) return } const x = Math.min(drawStart.x, drawCurrent.x) const y = Math.min(drawStart.y, drawCurrent.y) const w = Math.abs(drawCurrent.x - drawStart.x) const h = Math.abs(drawCurrent.y - drawStart.y) // Minimum size to avoid accidental clicks if (w > 10 && h > 10) { const newRegion: ExcludeRegion = { x, y, w, h, label: `Bereich ${excludeRegions.length + 1}` } const updated = [...excludeRegions, newRegion] setExcludeRegions(updated) saveExcludeRegions(updated) } setDrawing(false) setDrawStart(null) setDrawCurrent(null) }, [drawing, drawStart, drawCurrent, excludeRegions, saveExcludeRegions]) const handleDeleteRegion = useCallback(async (index: number) => { if (!sessionId) return setSaving(true) try { const res = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/exclude-regions/${index}`, { method: 'DELETE', }) if (!res.ok) throw new Error('Loeschen fehlgeschlagen') const updated = excludeRegions.filter((_, i) => i !== index) setExcludeRegions(updated) } catch (e) { setError(e instanceof Error ? e.message : 'Loeschen fehlgeschlagen') } finally { setSaving(false) } }, [sessionId, excludeRegions]) if (!sessionId) { return
Keine Session ausgewaehlt.
} const croppedUrl = `${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/image/cropped` const overlayUrl = `${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/image/structure-overlay${overlayTs ? `?t=${overlayTs}` : ''}` // Current drag rectangle in image coords const dragRect = drawing && drawStart && drawCurrent ? { x: Math.min(drawStart.x, drawCurrent.x), y: Math.min(drawStart.y, drawCurrent.y), w: Math.abs(drawCurrent.x - drawStart.x), h: Math.abs(drawCurrent.y - drawStart.y), } : null return (
{/* Loading indicator */} {detecting && (
Dokumentstruktur wird analysiert...
)} {/* Detection method toggle */}
Methode: {(['auto', 'opencv', 'ppdoclayout'] as DetectionMethod[]).map((method) => ( ))} {detectionMethod === 'auto' ? 'PP-DocLayout wenn verfuegbar, sonst OpenCV' : detectionMethod === 'ppdoclayout' ? 'ONNX-basierte Layouterkennung mit Klassifikation' : 'Klassische OpenCV-Konturerkennung'}
{/* Draw mode toggle */} {result && (
{drawMode && ( Rechteck auf dem Bild zeichnen um Bereiche von der OCR-Erkennung auszuschliessen )} {saving && ( Speichern... )}
)} {/* Two-column image comparison */}
{/* Left: Original document with exclude region drawing */}
Original {excludeRegions.length > 0 && `(${excludeRegions.length} Ausschlussbereich${excludeRegions.length !== 1 ? 'e' : ''})`}
{ if (drawing) { handleMouseUp() } }} > {/* eslint-disable-next-line @next/next/no-img-element */} Originaldokument { (e.target as HTMLImageElement).style.display = 'none' }} /> {/* Saved exclude regions overlay */} {result && containerSize.w > 0 && excludeRegions.map((region, i) => { const pos = imageToOverlayPct(region, containerSize.w, containerSize.h, result.image_width, result.image_height) return (
{region.label || `Bereich ${i + 1}`}
) })} {/* Current drag rectangle */} {dragRect && result && containerSize.w > 0 && (() => { const pos = imageToOverlayPct(dragRect, containerSize.w, containerSize.h, result.image_width, result.image_height) return (
) })()}
{/* Right: Structure overlay */}
Erkannte Struktur {result?.detection_method && ( ({result.detection_method === 'ppdoclayout' ? 'PP-DocLayout' : 'OpenCV'}) )}
{/* eslint-disable-next-line @next/next/no-img-element */} Strukturerkennung { (e.target as HTMLImageElement).style.display = 'none' }} /> {/* PP-DocLayout region overlays with class colors and labels */} {result?.layout_regions && overlayContainerSize.w > 0 && result.layout_regions.map((region, i) => { const pos = imageToOverlayPct(region, overlayContainerSize.w, overlayContainerSize.h, result.image_width, result.image_height) const color = getDocLayoutColor(region.class_name) return (
{region.class_name} {Math.round(region.confidence * 100)}%
) })}
{/* PP-DocLayout legend */} {result?.layout_regions && result.layout_regions.length > 0 && (() => { const usedClasses = [...new Set(result.layout_regions!.map((r) => r.class_name.toLowerCase()))] return (
{usedClasses.sort().map((cls) => ( {cls} ))}
) })()}
{/* Exclude regions list */} {excludeRegions.length > 0 && (

Ausschlussbereiche ({excludeRegions.length}) — Woerter in diesen Bereichen werden nicht erkannt

{excludeRegions.map((region, i) => (
{region.label || `Bereich ${i + 1}`} {region.w}x{region.h}px @ ({region.x}, {region.y})
))}
)} {/* Result info */} {result && (
{/* Summary badges */}
{result.zones.length} Zone(n) {result.boxes.length} Box(en) {result.layout_regions && result.layout_regions.length > 0 && ( {result.layout_regions.length} Layout-Region(en) )} {result.graphics && result.graphics.length > 0 && ( {result.graphics.length} Grafik(en) )} {result.has_words && ( {result.word_count} Woerter )} {excludeRegions.length > 0 && ( {excludeRegions.length} Ausschluss )} {(result.border_ghosts_removed ?? 0) > 0 && ( {result.border_ghosts_removed} Rahmenlinien entfernt )} {result.detection_method && ( {result.detection_method === 'ppdoclayout' ? 'PP-DocLayout' : 'OpenCV'} | )} {result.image_width}x{result.image_height}px | {result.duration_seconds}s
{/* Boxes detail */} {result.boxes.length > 0 && (

Erkannte Boxen

{result.boxes.map((box, i) => (
Box {i + 1}: {box.w}x{box.h}px @ ({box.x}, {box.y}) {box.bg_color_name && box.bg_color_name !== 'unknown' && box.bg_color_name !== 'white' && ( {box.bg_color_name} )} {box.border_thickness > 0 && ( Rahmen: {box.border_thickness}px )} {Math.round(box.confidence * 100)}%
))}
)} {/* PP-DocLayout regions detail */} {result.layout_regions && result.layout_regions.length > 0 && (

PP-DocLayout Regionen ({result.layout_regions.length})

{result.layout_regions.map((region, i) => { const color = getDocLayoutColor(region.class_name) return (
{region.class_name} {region.w}x{region.h}px @ ({region.x}, {region.y}) {Math.round(region.confidence * 100)}%
) })}
)} {/* Zones detail */}

Seitenzonen

{result.zones.map((zone) => ( {zone.zone_type === 'box' ? 'Box' : 'Inhalt'} {zone.index} ({zone.w}x{zone.h}) ))}
{/* Graphics / visual elements */} {result.graphics && result.graphics.length > 0 && (

Graphische Elemente ({result.graphics.length})

{/* Summary by shape */} {(() => { const shapeCounts: Record = {} for (const g of result.graphics) { shapeCounts[g.shape] = (shapeCounts[g.shape] || 0) + 1 } return (
{Object.entries(shapeCounts) .sort(([, a], [, b]) => b - a) .map(([shape, count]) => ( {shape === 'arrow' ? '→' : shape === 'circle' ? '●' : shape === 'line' ? '─' : shape === 'exclamation' ? '❗' : shape === 'dot' ? '•' : shape === 'illustration' ? '🖼' : '◆'} {' '}{shape} x{count} ))}
) })()} {/* Individual graphics list */}
{result.graphics.map((g, i) => (
{g.shape} {g.w}x{g.h}px @ ({g.x}, {g.y}) {g.color_name} {Math.round(g.confidence * 100)}%
))}
)} {/* Color regions */} {Object.keys(result.color_pixel_counts).length > 0 && (

Erkannte Farben

{Object.entries(result.color_pixel_counts) .sort(([, a], [, b]) => b - a) .map(([name, count]) => ( {name} {count.toLocaleString()}px ))}
)}
)} {/* Action buttons */} {result && (
)} {error && (
{error}
)}
) }