Some checks failed
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-school (push) Successful in 28s
CI / test-go-edu-search (push) Successful in 28s
CI / test-python-klausur (push) Failing after 1m58s
CI / test-python-agent-core (push) Successful in 18s
CI / test-nodejs-website (push) Successful in 19s
Add cv_graphic_detect.py for detecting non-text visual elements (arrows, circles, lines, exclamation marks, icons, illustrations). Draw detected graphics on structure overlay image and display them in the frontend StepStructureDetection component with shape counts and individual listings. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
335 lines
13 KiB
TypeScript
335 lines
13 KiB
TypeScript
'use client'
|
||
|
||
import { useEffect, useState } from 'react'
|
||
import type { 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',
|
||
}
|
||
|
||
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)
|
||
|
||
// Auto-trigger detection on mount
|
||
useEffect(() => {
|
||
if (!sessionId || hasRun) return
|
||
setHasRun(true)
|
||
|
||
const runDetection = async () => {
|
||
setDetecting(true)
|
||
setError(null)
|
||
|
||
try {
|
||
// Check if session already has structure result
|
||
const sessionRes = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}`)
|
||
if (sessionRes.ok) {
|
||
const sessionData = await sessionRes.json()
|
||
if (sessionData.structure_result) {
|
||
setResult(sessionData.structure_result)
|
||
setOverlayTs(Date.now())
|
||
setDetecting(false)
|
||
return
|
||
}
|
||
}
|
||
|
||
const res = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/detect-structure`, {
|
||
method: 'POST',
|
||
})
|
||
|
||
if (!res.ok) {
|
||
throw new Error('Strukturerkennung fehlgeschlagen')
|
||
}
|
||
|
||
const data = await res.json()
|
||
setResult(data)
|
||
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 res = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/detect-structure`, {
|
||
method: 'POST',
|
||
})
|
||
if (!res.ok) throw new Error('Erneute Erkennung fehlgeschlagen')
|
||
const data = await res.json()
|
||
setResult(data)
|
||
setOverlayTs(Date.now())
|
||
} catch (e) {
|
||
setError(e instanceof Error ? e.message : 'Unbekannter Fehler')
|
||
} finally {
|
||
setDetecting(false)
|
||
}
|
||
}
|
||
|
||
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}` : ''}`
|
||
|
||
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>
|
||
)}
|
||
|
||
{/* Two-column image comparison */}
|
||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||
{/* Left: Original document */}
|
||
<div className="space-y-2">
|
||
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||
Original
|
||
</div>
|
||
<div 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={croppedUrl}
|
||
alt="Originaldokument"
|
||
className="w-full h-full object-contain"
|
||
onError={(e) => {
|
||
(e.target as HTMLImageElement).style.display = 'none'
|
||
}}
|
||
/>
|
||
</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
|
||
</div>
|
||
<div 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'
|
||
}}
|
||
/>
|
||
</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.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>
|
||
)}
|
||
<span className="text-gray-400 text-xs ml-auto">
|
||
{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>
|
||
)}
|
||
|
||
{/* 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">×{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>
|
||
)
|
||
}
|