D2: TrOCR ONNX export script (printed + handwritten, int8 quantization) D3: PP-DocLayout ONNX export script (download or Docker-based conversion) B3: Model Management admin page (PyTorch vs ONNX status, benchmarks, config) A4: TrOCR ONNX service with runtime routing (auto/pytorch/onnx via TROCR_BACKEND) A5: PP-DocLayout ONNX detection with OpenCV fallback (via GRAPHIC_DETECT_BACKEND) B4: Structure Detection UI toggle (OpenCV vs PP-DocLayout) with class color coding C3: TrOCR-ONNX.md documentation C4: OCR-Pipeline.md ONNX section added C5: mkdocs.yml nav updated, optimum added to requirements.txt Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
778 lines
31 KiB
TypeScript
778 lines
31 KiB
TypeScript
'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<string, string> = {
|
|
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<string, string> = {
|
|
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<StructureResult | null>(null)
|
|
const [detecting, setDetecting] = useState(false)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [hasRun, setHasRun] = useState(false)
|
|
const [overlayTs, setOverlayTs] = useState(0)
|
|
const [detectionMethod, setDetectionMethod] = useState<DetectionMethod>('auto')
|
|
|
|
// Exclude region drawing state
|
|
const [excludeRegions, setExcludeRegions] = useState<ExcludeRegion[]>([])
|
|
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<HTMLDivElement>(null)
|
|
const overlayContainerRef = useRef<HTMLDivElement>(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 <div className="text-sm text-gray-400">Keine Session ausgewaehlt.</div>
|
|
}
|
|
|
|
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 (
|
|
<div className="space-y-4">
|
|
{/* Loading indicator */}
|
|
{detecting && (
|
|
<div className="flex items-center gap-2 text-teal-600 dark:text-teal-400 text-sm">
|
|
<div className="animate-spin w-4 h-4 border-2 border-teal-500 border-t-transparent rounded-full" />
|
|
Dokumentstruktur wird analysiert...
|
|
</div>
|
|
)}
|
|
|
|
{/* Detection method toggle */}
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-xs font-medium text-gray-500 dark:text-gray-400">Methode:</span>
|
|
{(['auto', 'opencv', 'ppdoclayout'] as DetectionMethod[]).map((method) => (
|
|
<button
|
|
key={method}
|
|
onClick={() => setDetectionMethod(method)}
|
|
className={`px-3 py-1.5 text-xs rounded-md font-medium transition-colors ${
|
|
detectionMethod === method
|
|
? 'bg-teal-600 text-white'
|
|
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'
|
|
}`}
|
|
>
|
|
{method === 'auto' ? 'Auto' : method === 'opencv' ? 'OpenCV' : 'PP-DocLayout'}
|
|
</button>
|
|
))}
|
|
<span className="text-[10px] text-gray-400 dark:text-gray-500 ml-1">
|
|
{detectionMethod === 'auto'
|
|
? 'PP-DocLayout wenn verfuegbar, sonst OpenCV'
|
|
: detectionMethod === 'ppdoclayout'
|
|
? 'ONNX-basierte Layouterkennung mit Klassifikation'
|
|
: 'Klassische OpenCV-Konturerkennung'}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Draw mode toggle */}
|
|
{result && (
|
|
<div className="flex items-center gap-3">
|
|
<button
|
|
onClick={() => setDrawMode(!drawMode)}
|
|
className={`px-4 py-2 text-sm rounded-lg font-medium transition-colors ${
|
|
drawMode
|
|
? 'bg-red-600 text-white hover:bg-red-700'
|
|
: 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'
|
|
}`}
|
|
>
|
|
{drawMode ? 'Zeichnen beenden' : 'Ausschlussbereich zeichnen'}
|
|
</button>
|
|
{drawMode && (
|
|
<span className="text-xs text-red-600 dark:text-red-400">
|
|
Rechteck auf dem Bild zeichnen um Bereiche von der OCR-Erkennung auszuschliessen
|
|
</span>
|
|
)}
|
|
{saving && (
|
|
<span className="text-xs text-gray-400">Speichern...</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Two-column image comparison */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
|
{/* Left: Original document with exclude region drawing */}
|
|
<div className="space-y-2">
|
|
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
|
Original {excludeRegions.length > 0 && `(${excludeRegions.length} Ausschlussbereich${excludeRegions.length !== 1 ? 'e' : ''})`}
|
|
</div>
|
|
<div
|
|
ref={containerRef}
|
|
className={`relative bg-gray-100 dark:bg-gray-800 rounded-lg overflow-hidden ${
|
|
drawMode ? 'cursor-crosshair' : ''
|
|
}`}
|
|
style={{ aspectRatio: '210/297' }}
|
|
onMouseDown={handleMouseDown}
|
|
onMouseMove={handleMouseMove}
|
|
onMouseUp={handleMouseUp}
|
|
onMouseLeave={() => {
|
|
if (drawing) {
|
|
handleMouseUp()
|
|
}
|
|
}}
|
|
>
|
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
<img
|
|
src={croppedUrl}
|
|
alt="Originaldokument"
|
|
className="w-full h-full object-contain pointer-events-none"
|
|
draggable={false}
|
|
onError={(e) => {
|
|
(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 (
|
|
<div
|
|
key={i}
|
|
className="absolute border-2 border-red-500 bg-red-500/20 group"
|
|
style={pos}
|
|
>
|
|
<div className="absolute -top-5 left-0 flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
|
<span className="text-[10px] bg-red-600 text-white px-1 rounded whitespace-nowrap">
|
|
{region.label || `Bereich ${i + 1}`}
|
|
</span>
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); handleDeleteRegion(i) }}
|
|
className="w-4 h-4 bg-red-600 text-white rounded-full text-[10px] flex items-center justify-center hover:bg-red-700"
|
|
>
|
|
x
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
|
|
{/* Current drag rectangle */}
|
|
{dragRect && result && containerSize.w > 0 && (() => {
|
|
const pos = imageToOverlayPct(dragRect, containerSize.w, containerSize.h, result.image_width, result.image_height)
|
|
return (
|
|
<div
|
|
className="absolute border-2 border-red-500 border-dashed bg-red-500/15"
|
|
style={pos}
|
|
/>
|
|
)
|
|
})()}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Right: Structure overlay */}
|
|
<div className="space-y-2">
|
|
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
|
Erkannte Struktur
|
|
{result?.detection_method && (
|
|
<span className="ml-2 text-[10px] font-normal normal-case">
|
|
({result.detection_method === 'ppdoclayout' ? 'PP-DocLayout' : 'OpenCV'})
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div
|
|
ref={overlayContainerRef}
|
|
className="relative bg-gray-100 dark:bg-gray-800 rounded-lg overflow-hidden"
|
|
style={{ aspectRatio: '210/297' }}
|
|
>
|
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
<img
|
|
src={overlayUrl}
|
|
alt="Strukturerkennung"
|
|
className="w-full h-full object-contain"
|
|
onError={(e) => {
|
|
(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 (
|
|
<div
|
|
key={`layout-${i}`}
|
|
className="absolute border-2 pointer-events-none"
|
|
style={{
|
|
...pos,
|
|
borderColor: color,
|
|
backgroundColor: `${color}18`,
|
|
}}
|
|
>
|
|
<span
|
|
className="absolute -top-4 left-0 px-1 py-px text-[9px] font-medium text-white rounded-sm whitespace-nowrap leading-tight"
|
|
style={{ backgroundColor: color }}
|
|
>
|
|
{region.class_name} {Math.round(region.confidence * 100)}%
|
|
</span>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
|
|
{/* 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 (
|
|
<div className="flex flex-wrap gap-x-3 gap-y-1 px-1">
|
|
{usedClasses.sort().map((cls) => (
|
|
<span key={cls} className="inline-flex items-center gap-1 text-[10px] text-gray-500 dark:text-gray-400">
|
|
<span
|
|
className="w-2.5 h-2.5 rounded-sm border"
|
|
style={{
|
|
backgroundColor: `${getDocLayoutColor(cls)}30`,
|
|
borderColor: getDocLayoutColor(cls),
|
|
}}
|
|
/>
|
|
{cls}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)
|
|
})()}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Exclude regions list */}
|
|
{excludeRegions.length > 0 && (
|
|
<div className="bg-red-50 dark:bg-red-900/10 rounded-lg border border-red-200 dark:border-red-800 p-3">
|
|
<h4 className="text-xs font-medium text-red-700 dark:text-red-400 mb-2">
|
|
Ausschlussbereiche ({excludeRegions.length}) — Woerter in diesen Bereichen werden nicht erkannt
|
|
</h4>
|
|
<div className="space-y-1">
|
|
{excludeRegions.map((region, i) => (
|
|
<div key={i} className="flex items-center gap-3 text-xs">
|
|
<span className="w-3 h-3 rounded-sm flex-shrink-0 bg-red-500/30 border border-red-500" />
|
|
<span className="text-red-700 dark:text-red-400 font-medium">
|
|
{region.label || `Bereich ${i + 1}`}
|
|
</span>
|
|
<span className="font-mono text-red-600/70 dark:text-red-400/70">
|
|
{region.w}x{region.h}px @ ({region.x}, {region.y})
|
|
</span>
|
|
<button
|
|
onClick={() => handleDeleteRegion(i)}
|
|
className="ml-auto text-red-500 hover:text-red-700 dark:hover:text-red-300"
|
|
>
|
|
Entfernen
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Result info */}
|
|
{result && (
|
|
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4 space-y-3">
|
|
{/* Summary badges */}
|
|
<div className="flex flex-wrap items-center gap-3 text-sm">
|
|
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-teal-50 dark:bg-teal-900/20 text-teal-700 dark:text-teal-400 text-xs font-medium">
|
|
{result.zones.length} Zone(n)
|
|
</span>
|
|
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-amber-50 dark:bg-amber-900/20 text-amber-700 dark:text-amber-400 text-xs font-medium">
|
|
{result.boxes.length} Box(en)
|
|
</span>
|
|
{result.layout_regions && result.layout_regions.length > 0 && (
|
|
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-indigo-50 dark:bg-indigo-900/20 text-indigo-700 dark:text-indigo-400 text-xs font-medium">
|
|
{result.layout_regions.length} Layout-Region(en)
|
|
</span>
|
|
)}
|
|
{result.graphics && result.graphics.length > 0 && (
|
|
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-purple-50 dark:bg-purple-900/20 text-purple-700 dark:text-purple-400 text-xs font-medium">
|
|
{result.graphics.length} Grafik(en)
|
|
</span>
|
|
)}
|
|
{result.has_words && (
|
|
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-400 text-xs font-medium">
|
|
{result.word_count} Woerter
|
|
</span>
|
|
)}
|
|
{excludeRegions.length > 0 && (
|
|
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-400 text-xs font-medium">
|
|
{excludeRegions.length} Ausschluss
|
|
</span>
|
|
)}
|
|
{(result.border_ghosts_removed ?? 0) > 0 && (
|
|
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-400 text-xs font-medium">
|
|
{result.border_ghosts_removed} Rahmenlinien entfernt
|
|
</span>
|
|
)}
|
|
<span className="text-gray-400 text-xs ml-auto">
|
|
{result.detection_method && (
|
|
<span className="mr-1.5">
|
|
{result.detection_method === 'ppdoclayout' ? 'PP-DocLayout' : 'OpenCV'} |
|
|
</span>
|
|
)}
|
|
{result.image_width}x{result.image_height}px | {result.duration_seconds}s
|
|
</span>
|
|
</div>
|
|
|
|
{/* Boxes detail */}
|
|
{result.boxes.length > 0 && (
|
|
<div>
|
|
<h4 className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-2">Erkannte Boxen</h4>
|
|
<div className="space-y-1.5">
|
|
{result.boxes.map((box, i) => (
|
|
<div key={i} className="flex items-center gap-3 text-xs">
|
|
<span
|
|
className="w-3 h-3 rounded-sm flex-shrink-0 border border-gray-300 dark:border-gray-600"
|
|
style={{ backgroundColor: box.bg_color_hex || '#6b7280' }}
|
|
/>
|
|
<span className="text-gray-600 dark:text-gray-400">
|
|
Box {i + 1}:
|
|
</span>
|
|
<span className="font-mono text-gray-500">
|
|
{box.w}x{box.h}px @ ({box.x}, {box.y})
|
|
</span>
|
|
{box.bg_color_name && box.bg_color_name !== 'unknown' && box.bg_color_name !== 'white' && (
|
|
<span className="px-1.5 py-0.5 rounded bg-gray-100 dark:bg-gray-700 text-gray-500">
|
|
{box.bg_color_name}
|
|
</span>
|
|
)}
|
|
{box.border_thickness > 0 && (
|
|
<span className="text-gray-400">
|
|
Rahmen: {box.border_thickness}px
|
|
</span>
|
|
)}
|
|
<span className="text-gray-400">
|
|
{Math.round(box.confidence * 100)}%
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* PP-DocLayout regions detail */}
|
|
{result.layout_regions && result.layout_regions.length > 0 && (
|
|
<div>
|
|
<h4 className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-2">
|
|
PP-DocLayout Regionen ({result.layout_regions.length})
|
|
</h4>
|
|
<div className="space-y-1.5">
|
|
{result.layout_regions.map((region, i) => {
|
|
const color = getDocLayoutColor(region.class_name)
|
|
return (
|
|
<div key={i} className="flex items-center gap-3 text-xs">
|
|
<span
|
|
className="w-3 h-3 rounded-sm flex-shrink-0 border"
|
|
style={{ backgroundColor: `${color}40`, borderColor: color }}
|
|
/>
|
|
<span className="text-gray-600 dark:text-gray-400 font-medium min-w-[60px]">
|
|
{region.class_name}
|
|
</span>
|
|
<span className="font-mono text-gray-500">
|
|
{region.w}x{region.h}px @ ({region.x}, {region.y})
|
|
</span>
|
|
<span className="text-gray-400">
|
|
{Math.round(region.confidence * 100)}%
|
|
</span>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Zones detail */}
|
|
<div>
|
|
<h4 className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-2">Seitenzonen</h4>
|
|
<div className="flex flex-wrap gap-2">
|
|
{result.zones.map((zone) => (
|
|
<span
|
|
key={zone.index}
|
|
className={`inline-flex items-center gap-1 px-2 py-1 rounded text-[11px] font-medium ${
|
|
zone.zone_type === 'box'
|
|
? 'bg-amber-50 dark:bg-amber-900/20 text-amber-700 dark:text-amber-300 border border-amber-200 dark:border-amber-800'
|
|
: 'bg-gray-50 dark:bg-gray-800 text-gray-500 dark:text-gray-400 border border-gray-200 dark:border-gray-700'
|
|
}`}
|
|
>
|
|
{zone.zone_type === 'box' ? 'Box' : 'Inhalt'} {zone.index}
|
|
<span className="text-[10px] font-normal opacity-70">
|
|
({zone.w}x{zone.h})
|
|
</span>
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Graphics / visual elements */}
|
|
{result.graphics && result.graphics.length > 0 && (
|
|
<div>
|
|
<h4 className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-2">
|
|
Graphische Elemente ({result.graphics.length})
|
|
</h4>
|
|
{/* Summary by shape */}
|
|
{(() => {
|
|
const shapeCounts: Record<string, number> = {}
|
|
for (const g of result.graphics) {
|
|
shapeCounts[g.shape] = (shapeCounts[g.shape] || 0) + 1
|
|
}
|
|
return (
|
|
<div className="flex flex-wrap gap-2 mb-2">
|
|
{Object.entries(shapeCounts)
|
|
.sort(([, a], [, b]) => b - a)
|
|
.map(([shape, count]) => (
|
|
<span
|
|
key={shape}
|
|
className="inline-flex items-center gap-1 px-2 py-1 rounded text-[11px] bg-purple-50 dark:bg-purple-900/20 text-purple-700 dark:text-purple-300 border border-purple-200 dark:border-purple-800"
|
|
>
|
|
{shape === 'arrow' ? '→' : shape === 'circle' ? '●' : shape === 'line' ? '─' : shape === 'exclamation' ? '❗' : shape === 'dot' ? '•' : shape === 'illustration' ? '🖼' : '◆'}
|
|
{' '}{shape} <span className="font-semibold">x{count}</span>
|
|
</span>
|
|
))}
|
|
</div>
|
|
)
|
|
})()}
|
|
{/* Individual graphics list */}
|
|
<div className="space-y-1.5 max-h-40 overflow-y-auto">
|
|
{result.graphics.map((g, i) => (
|
|
<div key={i} className="flex items-center gap-3 text-xs">
|
|
<span
|
|
className="w-3 h-3 rounded-full flex-shrink-0 border border-gray-300 dark:border-gray-600"
|
|
style={{ backgroundColor: g.color_hex || '#6b7280' }}
|
|
/>
|
|
<span className="text-gray-600 dark:text-gray-400 font-medium min-w-[60px]">
|
|
{g.shape}
|
|
</span>
|
|
<span className="font-mono text-gray-500">
|
|
{g.w}x{g.h}px @ ({g.x}, {g.y})
|
|
</span>
|
|
<span className="text-gray-400">
|
|
{g.color_name}
|
|
</span>
|
|
<span className="text-gray-400">
|
|
{Math.round(g.confidence * 100)}%
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Color regions */}
|
|
{Object.keys(result.color_pixel_counts).length > 0 && (
|
|
<div>
|
|
<h4 className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-2">Erkannte Farben</h4>
|
|
<div className="flex flex-wrap gap-2">
|
|
{Object.entries(result.color_pixel_counts)
|
|
.sort(([, a], [, b]) => b - a)
|
|
.map(([name, count]) => (
|
|
<span key={name} className="inline-flex items-center gap-1.5 px-2 py-1 rounded text-[11px] bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700">
|
|
<span
|
|
className="w-2.5 h-2.5 rounded-full"
|
|
style={{ backgroundColor: COLOR_HEX[name] || '#6b7280' }}
|
|
/>
|
|
<span className="text-gray-600 dark:text-gray-400">{name}</span>
|
|
<span className="text-gray-400 text-[10px]">{count.toLocaleString()}px</span>
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Action buttons */}
|
|
{result && (
|
|
<div className="flex justify-between">
|
|
<button
|
|
onClick={handleRerun}
|
|
disabled={detecting}
|
|
className="px-4 py-2 text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 transition-colors disabled:opacity-50"
|
|
>
|
|
Erneut erkennen
|
|
</button>
|
|
<button
|
|
onClick={onNext}
|
|
className="px-6 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 font-medium transition-colors"
|
|
>
|
|
Weiter →
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{error && (
|
|
<div className="p-3 bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 rounded-lg text-sm">
|
|
{error}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|