[split-required] Split 58 monoliths across Python, Go, TypeScript (Phases 1-3)
Phase 1 — Python (klausur-service): 5 monoliths → 36 files - dsfa_corpus_ingestion.py (1,828 LOC → 5 files) - cv_ocr_engines.py (2,102 LOC → 7 files) - cv_layout.py (3,653 LOC → 10 files) - vocab_worksheet_api.py (2,783 LOC → 8 files) - grid_build_core.py (1,958 LOC → 6 files) Phase 2 — Go (edu-search-service, school-service): 8 monoliths → 19 files - staff_crawler.go (1,402 → 4), policy/store.go (1,168 → 3) - policy_handlers.go (700 → 2), repository.go (684 → 2) - search.go (592 → 2), ai_extraction_handlers.go (554 → 2) - seed_data.go (591 → 2), grade_service.go (646 → 2) Phase 3 — TypeScript (admin-lehrer): 45 monoliths → 220+ files - sdk/types.ts (2,108 → 16 domain files) - ai/rag/page.tsx (2,686 → 14 files) - 22 page.tsx files split into _components/ + _hooks/ - 11 component files split into sub-components - 10 SDK data catalogs added to loc-exceptions - Deleted dead backup index_original.ts (4,899 LOC) All original public APIs preserved via re-export facades. Zero new errors: Python imports verified, Go builds clean, TypeScript tsc --noEmit shows only pre-existing errors. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
'use client'
|
||||
|
||||
import { confColor } from './dewarp-constants'
|
||||
|
||||
/** Short confidence bar (visual). */
|
||||
export function ConfBar({ value }: { value: number }) {
|
||||
const pct = Math.round(value * 100)
|
||||
const bg = value >= 0.7 ? 'bg-green-500' : value >= 0.5 ? 'bg-yellow-500' : 'bg-gray-400'
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-16 h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
|
||||
<div className={`h-full rounded-full ${bg}`} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<span className={`text-xs font-mono ${confColor(value)}`}>{pct}%</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { DeskewResult, DewarpResult, DewarpDetection, DewarpGroundTruth } from '@/app/(admin)/ai/ocr-kombi/types'
|
||||
import type { DeskewResult, DewarpResult, DewarpGroundTruth } from '@/app/(admin)/ai/ocr-kombi/types'
|
||||
import { DewarpSummaryBanner } from './DewarpSummaryBanner'
|
||||
import { DewarpFineTunePanel } from './DewarpFineTunePanel'
|
||||
import { DewarpGroundTruthPanel } from './DewarpGroundTruthPanel'
|
||||
|
||||
interface DewarpControlsProps {
|
||||
dewarpResult: DewarpResult | null
|
||||
@@ -15,93 +18,6 @@ interface DewarpControlsProps {
|
||||
isApplying: boolean
|
||||
}
|
||||
|
||||
const METHOD_LABELS: Record<string, string> = {
|
||||
vertical_edge: 'A: Vertikale Kanten',
|
||||
projection: 'B: Projektions-Varianz',
|
||||
hough_lines: 'C: Hough-Linien',
|
||||
text_lines: 'D: Textzeilenanalyse',
|
||||
manual: 'Manuell',
|
||||
manual_combined: 'Manuell (kombiniert)',
|
||||
none: 'Keine Korrektur',
|
||||
}
|
||||
|
||||
const SHEAR_METHOD_KEYS = ['vertical_edge', 'projection', 'hough_lines', 'text_lines'] as const
|
||||
|
||||
/** Colour for a confidence value (0-1). */
|
||||
function confColor(conf: number): string {
|
||||
if (conf >= 0.7) return 'text-green-600 dark:text-green-400'
|
||||
if (conf >= 0.5) return 'text-yellow-600 dark:text-yellow-400'
|
||||
return 'text-gray-400'
|
||||
}
|
||||
|
||||
/** Short confidence bar (visual). */
|
||||
function ConfBar({ value }: { value: number }) {
|
||||
const pct = Math.round(value * 100)
|
||||
const bg = value >= 0.7 ? 'bg-green-500' : value >= 0.5 ? 'bg-yellow-500' : 'bg-gray-400'
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="w-16 h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
|
||||
<div className={`h-full rounded-full ${bg}`} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<span className={`text-xs font-mono ${confColor(value)}`}>{pct}%</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** A single slider row for fine-tuning. */
|
||||
function FineTuneSlider({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
unit = '\u00B0',
|
||||
radioName,
|
||||
radioChecked,
|
||||
onRadioChange,
|
||||
}: {
|
||||
label: string
|
||||
value: number
|
||||
onChange: (v: number) => void
|
||||
min: number
|
||||
max: number
|
||||
step: number
|
||||
unit?: string
|
||||
radioName?: string
|
||||
radioChecked?: boolean
|
||||
onRadioChange?: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{radioName !== undefined && (
|
||||
<input
|
||||
type="radio"
|
||||
name={radioName}
|
||||
checked={radioChecked}
|
||||
onChange={onRadioChange}
|
||||
className="w-3.5 h-3.5 accent-teal-500"
|
||||
/>
|
||||
)}
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 w-36 shrink-0">{label}</span>
|
||||
<span className="text-xs text-gray-400 w-8 text-right">{min}{unit}</span>
|
||||
<input
|
||||
type="range"
|
||||
min={min * 100}
|
||||
max={max * 100}
|
||||
step={step * 100}
|
||||
value={Math.round(value * 100)}
|
||||
onChange={(e) => onChange(parseInt(e.target.value) / 100)}
|
||||
className="flex-1 h-1.5 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700 accent-teal-500"
|
||||
/>
|
||||
<span className="text-xs text-gray-400 w-8">+{max}{unit}</span>
|
||||
<span className="font-mono text-xs w-14 text-right tabular-nums">
|
||||
{value >= 0 ? '+' : ''}{value.toFixed(2)}{unit}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function DewarpControls({
|
||||
dewarpResult,
|
||||
deskewResult,
|
||||
@@ -114,26 +30,9 @@ export function DewarpControls({
|
||||
isApplying,
|
||||
}: DewarpControlsProps) {
|
||||
const [manualShear, setManualShear] = useState(0)
|
||||
const [gtFeedback, setGtFeedback] = useState<'correct' | 'incorrect' | null>(null)
|
||||
const [gtNotes, setGtNotes] = useState('')
|
||||
const [gtSaved, setGtSaved] = useState(false)
|
||||
const [showDetails, setShowDetails] = useState(false)
|
||||
const [showFineTune, setShowFineTune] = useState(false)
|
||||
|
||||
// Fine-tuning rotation sliders (3 passes)
|
||||
const [p1Iterative, setP1Iterative] = useState(0)
|
||||
const [p2Residual, setP2Residual] = useState(0)
|
||||
const [p3Textline, setP3Textline] = useState(0)
|
||||
|
||||
// Fine-tuning shear sliders (4 methods) + selected method
|
||||
const [shearValues, setShearValues] = useState<Record<string, number>>({
|
||||
vertical_edge: 0,
|
||||
projection: 0,
|
||||
hough_lines: 0,
|
||||
text_lines: 0,
|
||||
})
|
||||
const [selectedShearMethod, setSelectedShearMethod] = useState<string>('vertical_edge')
|
||||
|
||||
// Initialize slider to auto-detected value when result arrives
|
||||
useEffect(() => {
|
||||
if (dewarpResult && dewarpResult.shear_degrees !== undefined) {
|
||||
@@ -141,192 +40,15 @@ export function DewarpControls({
|
||||
}
|
||||
}, [dewarpResult?.shear_degrees])
|
||||
|
||||
// Initialize fine-tuning sliders from deskew result
|
||||
useEffect(() => {
|
||||
if (deskewResult) {
|
||||
setP1Iterative(deskewResult.angle_iterative ?? 0)
|
||||
setP2Residual(deskewResult.angle_residual ?? 0)
|
||||
setP3Textline(deskewResult.angle_textline ?? 0)
|
||||
}
|
||||
}, [deskewResult])
|
||||
|
||||
// Initialize shear sliders from dewarp detections
|
||||
useEffect(() => {
|
||||
if (dewarpResult?.detections) {
|
||||
const newValues = { ...shearValues }
|
||||
let bestMethod = selectedShearMethod
|
||||
let bestConf = -1
|
||||
for (const d of dewarpResult.detections) {
|
||||
if (d.method in newValues) {
|
||||
newValues[d.method] = d.shear_degrees
|
||||
if (d.confidence > bestConf) {
|
||||
bestConf = d.confidence
|
||||
bestMethod = d.method
|
||||
}
|
||||
}
|
||||
}
|
||||
setShearValues(newValues)
|
||||
// Select the method that was actually used, or the highest confidence
|
||||
if (dewarpResult.method_used && dewarpResult.method_used in newValues) {
|
||||
setSelectedShearMethod(dewarpResult.method_used)
|
||||
} else {
|
||||
setSelectedShearMethod(bestMethod)
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [dewarpResult?.detections])
|
||||
|
||||
const rotationSum = p1Iterative + p2Residual + p3Textline
|
||||
const activeShear = shearValues[selectedShearMethod] ?? 0
|
||||
|
||||
const handleGroundTruth = (isCorrect: boolean) => {
|
||||
setGtFeedback(isCorrect ? 'correct' : 'incorrect')
|
||||
if (isCorrect) {
|
||||
onGroundTruth({ is_correct: true })
|
||||
setGtSaved(true)
|
||||
}
|
||||
}
|
||||
|
||||
const handleGroundTruthIncorrect = () => {
|
||||
onGroundTruth({
|
||||
is_correct: false,
|
||||
corrected_shear: manualShear !== 0 ? manualShear : undefined,
|
||||
notes: gtNotes || undefined,
|
||||
})
|
||||
setGtSaved(true)
|
||||
}
|
||||
|
||||
const handleShearValueChange = (method: string, value: number) => {
|
||||
setShearValues((prev) => ({ ...prev, [method]: value }))
|
||||
}
|
||||
|
||||
const handleFineTunePreview = () => {
|
||||
if (onCombinedAdjust) {
|
||||
onCombinedAdjust(rotationSum, activeShear)
|
||||
}
|
||||
}
|
||||
|
||||
const wasRejected = dewarpResult && dewarpResult.method_used === 'none' && (dewarpResult.detections || []).length > 0
|
||||
const wasApplied = dewarpResult && dewarpResult.method_used !== 'none' && dewarpResult.method_used !== 'manual' && dewarpResult.method_used !== 'manual_combined'
|
||||
const detections = dewarpResult?.detections || []
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Summary banner */}
|
||||
{dewarpResult && (
|
||||
<div className={`rounded-lg border p-4 ${
|
||||
wasRejected
|
||||
? 'bg-amber-50 border-amber-200 dark:bg-amber-900/20 dark:border-amber-700'
|
||||
: wasApplied
|
||||
? 'bg-green-50 border-green-200 dark:bg-green-900/20 dark:border-green-700'
|
||||
: 'bg-white border-gray-200 dark:bg-gray-800 dark:border-gray-700'
|
||||
}`}>
|
||||
{/* Status line */}
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className={`text-lg ${wasRejected ? '' : wasApplied ? '' : ''}`}>
|
||||
{wasRejected ? '\u26A0\uFE0F' : wasApplied ? '\u2705' : '\u2796'}
|
||||
</span>
|
||||
<span className="text-sm font-medium text-gray-800 dark:text-gray-200">
|
||||
{wasRejected
|
||||
? 'Quality Gate: Korrektur verworfen (Projektion nicht verbessert)'
|
||||
: wasApplied
|
||||
? `Korrektur angewendet: ${dewarpResult.shear_degrees.toFixed(2)}\u00B0`
|
||||
: dewarpResult.method_used === 'manual' || dewarpResult.method_used === 'manual_combined'
|
||||
? `Manuelle Korrektur: ${dewarpResult.shear_degrees.toFixed(2)}\u00B0`
|
||||
: 'Keine Korrektur noetig'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Key metrics */}
|
||||
<div className="flex flex-wrap items-center gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-500">Scherung:</span>{' '}
|
||||
<span className="font-mono font-medium">{dewarpResult.shear_degrees.toFixed(2)}\u00B0</span>
|
||||
</div>
|
||||
<div className="h-4 w-px bg-gray-300 dark:bg-gray-600" />
|
||||
<div>
|
||||
<span className="text-gray-500">Methode:</span>{' '}
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-teal-100 text-teal-700 dark:bg-teal-900/40 dark:text-teal-300">
|
||||
{dewarpResult.method_used.includes('+')
|
||||
? `Ensemble (${dewarpResult.method_used.split('+').map(m => METHOD_LABELS[m] || m).join(' + ')})`
|
||||
: METHOD_LABELS[dewarpResult.method_used] || dewarpResult.method_used}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-4 w-px bg-gray-300 dark:bg-gray-600" />
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-gray-500">Konfidenz:</span>
|
||||
<ConfBar value={dewarpResult.confidence} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toggles row */}
|
||||
<div className="flex gap-2 mt-3">
|
||||
<button
|
||||
onClick={onToggleGrid}
|
||||
className={`text-xs px-3 py-1 rounded-full border transition-colors ${
|
||||
showGrid
|
||||
? 'bg-teal-100 border-teal-300 text-teal-700 dark:bg-teal-900/40 dark:border-teal-600 dark:text-teal-300'
|
||||
: 'border-gray-300 text-gray-500 dark:border-gray-600 dark:text-gray-400'
|
||||
}`}
|
||||
>
|
||||
Raster
|
||||
</button>
|
||||
{detections.length > 0 && (
|
||||
<button
|
||||
onClick={() => setShowDetails(v => !v)}
|
||||
className={`text-xs px-3 py-1 rounded-full border transition-colors ${
|
||||
showDetails
|
||||
? 'bg-blue-100 border-blue-300 text-blue-700 dark:bg-blue-900/40 dark:border-blue-600 dark:text-blue-300'
|
||||
: 'border-gray-300 text-gray-500 dark:border-gray-600 dark:text-gray-400'
|
||||
}`}
|
||||
>
|
||||
Details ({detections.length} Methoden)
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Detailed detections */}
|
||||
{showDetails && detections.length > 0 && (
|
||||
<div className="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700">
|
||||
<div className="text-xs text-gray-500 mb-2">Einzelne Detektoren:</div>
|
||||
<div className="space-y-1.5">
|
||||
{detections.map((d: DewarpDetection) => {
|
||||
const isUsed = dewarpResult.method_used.includes(d.method)
|
||||
const aboveThreshold = d.confidence >= 0.5
|
||||
return (
|
||||
<div
|
||||
key={d.method}
|
||||
className={`flex items-center gap-3 text-xs px-2 py-1.5 rounded ${
|
||||
isUsed
|
||||
? 'bg-teal-50 dark:bg-teal-900/20'
|
||||
: 'bg-gray-50 dark:bg-gray-800'
|
||||
}`}
|
||||
>
|
||||
<span className="w-4 text-center">
|
||||
{isUsed ? '\u2713' : aboveThreshold ? '\u2012' : '\u2717'}
|
||||
</span>
|
||||
<span className={`w-40 ${isUsed ? 'font-medium text-gray-800 dark:text-gray-200' : 'text-gray-500'}`}>
|
||||
{METHOD_LABELS[d.method] || d.method}
|
||||
</span>
|
||||
<span className="font-mono w-16 text-right">
|
||||
{d.shear_degrees.toFixed(2)}\u00B0
|
||||
</span>
|
||||
<ConfBar value={d.confidence} />
|
||||
{!aboveThreshold && (
|
||||
<span className="text-gray-400 ml-1">(unter Schwelle)</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{wasRejected && (
|
||||
<div className="mt-2 text-xs text-amber-600 dark:text-amber-400">
|
||||
Die Korrektur wurde verworfen, weil die horizontale Projektions-Varianz nach Anwendung nicht besser war als vorher.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DewarpSummaryBanner
|
||||
dewarpResult={dewarpResult}
|
||||
showGrid={showGrid}
|
||||
onToggleGrid={onToggleGrid}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Manual shear angle slider */}
|
||||
@@ -334,7 +56,7 @@ export function DewarpControls({
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
||||
<div className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Scherwinkel (manuell)</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-gray-400 w-10 text-right">-2.0\u00B0</span>
|
||||
<span className="text-xs text-gray-400 w-10 text-right">-2.0{'\u00B0'}</span>
|
||||
<input
|
||||
type="range"
|
||||
min={-200}
|
||||
@@ -344,8 +66,8 @@ export function DewarpControls({
|
||||
onChange={(e) => setManualShear(parseInt(e.target.value) / 100)}
|
||||
className="flex-1 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700 accent-teal-500"
|
||||
/>
|
||||
<span className="text-xs text-gray-400 w-10">+2.0\u00B0</span>
|
||||
<span className="font-mono text-sm w-16 text-right">{manualShear.toFixed(2)}\u00B0</span>
|
||||
<span className="text-xs text-gray-400 w-10">+2.0{'\u00B0'}</span>
|
||||
<span className="font-mono text-sm w-16 text-right">{manualShear.toFixed(2)}{'\u00B0'}</span>
|
||||
<button
|
||||
onClick={() => onManualDewarp(manualShear)}
|
||||
disabled={isApplying}
|
||||
@@ -362,179 +84,27 @@ export function DewarpControls({
|
||||
|
||||
{/* Fine-tuning panel */}
|
||||
{dewarpResult && onCombinedAdjust && (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
onClick={() => setShowFineTune(v => !v)}
|
||||
className="w-full flex items-center justify-between p-4 text-left"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm">⚙️</span>
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Feinabstimmung</span>
|
||||
<span className="text-xs text-gray-400">(7 Regler)</span>
|
||||
</div>
|
||||
<span className="text-gray-400 text-sm">{showFineTune ? '\u25B2' : '\u25BC'}</span>
|
||||
</button>
|
||||
|
||||
{showFineTune && (
|
||||
<div className="px-4 pb-4 space-y-5">
|
||||
{/* Rotation section */}
|
||||
<div>
|
||||
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">
|
||||
Rotation (Begradigung)
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<FineTuneSlider
|
||||
label="P1 Iterative Projection"
|
||||
value={p1Iterative}
|
||||
onChange={setP1Iterative}
|
||||
min={-5}
|
||||
max={5}
|
||||
step={0.05}
|
||||
/>
|
||||
<FineTuneSlider
|
||||
label="P2 Word-Alignment"
|
||||
value={p2Residual}
|
||||
onChange={setP2Residual}
|
||||
min={-3}
|
||||
max={3}
|
||||
step={0.05}
|
||||
/>
|
||||
<FineTuneSlider
|
||||
label="P3 Textline-Regression"
|
||||
value={p3Textline}
|
||||
onChange={setP3Textline}
|
||||
min={-3}
|
||||
max={3}
|
||||
step={0.05}
|
||||
/>
|
||||
<div className="flex items-center gap-2 pt-1 border-t border-gray-100 dark:border-gray-700">
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 w-36 shrink-0">Summe Rotation</span>
|
||||
<span className="font-mono text-sm font-medium text-teal-600 dark:text-teal-400">
|
||||
{rotationSum >= 0 ? '+' : ''}{rotationSum.toFixed(2)}\u00B0
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Shear section */}
|
||||
<div>
|
||||
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">
|
||||
Scherung (Entzerrung) — einen Wert waehlen
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{SHEAR_METHOD_KEYS.map((method) => (
|
||||
<FineTuneSlider
|
||||
key={method}
|
||||
label={METHOD_LABELS[method] || method}
|
||||
value={shearValues[method]}
|
||||
onChange={(v) => handleShearValueChange(method, v)}
|
||||
min={-5}
|
||||
max={5}
|
||||
step={0.05}
|
||||
radioName="shear-method"
|
||||
radioChecked={selectedShearMethod === method}
|
||||
onRadioChange={() => setSelectedShearMethod(method)}
|
||||
/>
|
||||
))}
|
||||
<div className="flex items-center gap-2 pt-1 border-t border-gray-100 dark:border-gray-700">
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 w-36 shrink-0">Gewaehlte Scherung</span>
|
||||
<span className="font-mono text-sm font-medium text-teal-600 dark:text-teal-400">
|
||||
{activeShear >= 0 ? '+' : ''}{activeShear.toFixed(2)}\u00B0
|
||||
</span>
|
||||
<span className="text-xs text-gray-400 ml-1">
|
||||
({METHOD_LABELS[selectedShearMethod]})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preview + Save */}
|
||||
<div className="flex items-center gap-3 pt-2">
|
||||
<button
|
||||
onClick={handleFineTunePreview}
|
||||
disabled={isApplying}
|
||||
className="px-4 py-2 text-sm bg-teal-600 text-white rounded-md hover:bg-teal-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{isApplying ? 'Wird angewendet...' : 'Vorschau'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
onGroundTruth({
|
||||
is_correct: false,
|
||||
corrected_shear: activeShear,
|
||||
notes: `Fine-tuned: rotation=${rotationSum.toFixed(3)}, shear=${activeShear.toFixed(3)} (${selectedShearMethod})`,
|
||||
})
|
||||
setGtSaved(true)
|
||||
}}
|
||||
disabled={gtSaved}
|
||||
className="px-4 py-2 text-sm bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{gtSaved ? 'Gespeichert' : 'Als Ground Truth speichern'}
|
||||
</button>
|
||||
<span className="text-xs text-gray-400">
|
||||
Rotation: {rotationSum >= 0 ? '+' : ''}{rotationSum.toFixed(2)}\u00B0 + Scherung: {activeShear >= 0 ? '+' : ''}{activeShear.toFixed(2)}\u00B0
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DewarpFineTunePanel
|
||||
dewarpResult={dewarpResult}
|
||||
deskewResult={deskewResult}
|
||||
showFineTune={showFineTune}
|
||||
onToggleFineTune={() => setShowFineTune(v => !v)}
|
||||
onCombinedAdjust={onCombinedAdjust}
|
||||
onGroundTruth={onGroundTruth}
|
||||
isApplying={isApplying}
|
||||
gtSaved={gtSaved}
|
||||
onGtSaved={() => setGtSaved(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Ground Truth */}
|
||||
{dewarpResult && !showFineTune && (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
||||
<div className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Spalten vertikal ausgerichtet?
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 mb-2">Pruefen ob die Spaltenraender jetzt senkrecht zum Raster stehen.</p>
|
||||
{!gtSaved ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleGroundTruth(true)}
|
||||
className={`px-4 py-1.5 rounded-md text-sm font-medium transition-colors ${
|
||||
gtFeedback === 'correct'
|
||||
? 'bg-green-100 text-green-700 ring-2 ring-green-400'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-green-50 dark:bg-gray-700 dark:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
Ja
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleGroundTruth(false)}
|
||||
className={`px-4 py-1.5 rounded-md text-sm font-medium transition-colors ${
|
||||
gtFeedback === 'incorrect'
|
||||
? 'bg-red-100 text-red-700 ring-2 ring-red-400'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-red-50 dark:bg-gray-700 dark:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
Nein
|
||||
</button>
|
||||
</div>
|
||||
{gtFeedback === 'incorrect' && (
|
||||
<div className="space-y-2">
|
||||
<textarea
|
||||
value={gtNotes}
|
||||
onChange={(e) => setGtNotes(e.target.value)}
|
||||
placeholder="Notizen zur Korrektur..."
|
||||
className="w-full text-sm border border-gray-300 dark:border-gray-600 rounded-md p-2 bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-200"
|
||||
rows={2}
|
||||
/>
|
||||
<button
|
||||
onClick={handleGroundTruthIncorrect}
|
||||
className="text-sm px-3 py-1 bg-red-600 text-white rounded-md hover:bg-red-700 transition-colors"
|
||||
>
|
||||
Feedback speichern
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-green-600 dark:text-green-400">
|
||||
Feedback gespeichert
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DewarpGroundTruthPanel
|
||||
manualShear={manualShear}
|
||||
onGroundTruth={onGroundTruth}
|
||||
gtSaved={gtSaved}
|
||||
onGtSaved={() => setGtSaved(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Next button */}
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { DeskewResult, DewarpResult, DewarpGroundTruth } from '@/app/(admin)/ai/ocr-kombi/types'
|
||||
import { METHOD_LABELS, SHEAR_METHOD_KEYS } from './dewarp-constants'
|
||||
import { FineTuneSlider } from './FineTuneSlider'
|
||||
|
||||
interface DewarpFineTunePanelProps {
|
||||
dewarpResult: DewarpResult
|
||||
deskewResult?: DeskewResult | null
|
||||
showFineTune: boolean
|
||||
onToggleFineTune: () => void
|
||||
onCombinedAdjust: (rotationDegrees: number, shearDegrees: number) => void
|
||||
onGroundTruth: (gt: DewarpGroundTruth) => void
|
||||
isApplying: boolean
|
||||
gtSaved: boolean
|
||||
onGtSaved: () => void
|
||||
}
|
||||
|
||||
export function DewarpFineTunePanel({
|
||||
dewarpResult,
|
||||
deskewResult,
|
||||
showFineTune,
|
||||
onToggleFineTune,
|
||||
onCombinedAdjust,
|
||||
onGroundTruth,
|
||||
isApplying,
|
||||
gtSaved,
|
||||
onGtSaved,
|
||||
}: DewarpFineTunePanelProps) {
|
||||
// Fine-tuning rotation sliders (3 passes)
|
||||
const [p1Iterative, setP1Iterative] = useState(0)
|
||||
const [p2Residual, setP2Residual] = useState(0)
|
||||
const [p3Textline, setP3Textline] = useState(0)
|
||||
|
||||
// Fine-tuning shear sliders (4 methods) + selected method
|
||||
const [shearValues, setShearValues] = useState<Record<string, number>>({
|
||||
vertical_edge: 0,
|
||||
projection: 0,
|
||||
hough_lines: 0,
|
||||
text_lines: 0,
|
||||
})
|
||||
const [selectedShearMethod, setSelectedShearMethod] = useState<string>('vertical_edge')
|
||||
|
||||
// Initialize fine-tuning sliders from deskew result
|
||||
useEffect(() => {
|
||||
if (deskewResult) {
|
||||
setP1Iterative(deskewResult.angle_iterative ?? 0)
|
||||
setP2Residual(deskewResult.angle_residual ?? 0)
|
||||
setP3Textline(deskewResult.angle_textline ?? 0)
|
||||
}
|
||||
}, [deskewResult])
|
||||
|
||||
// Initialize shear sliders from dewarp detections
|
||||
useEffect(() => {
|
||||
if (dewarpResult?.detections) {
|
||||
const newValues = { ...shearValues }
|
||||
let bestMethod = selectedShearMethod
|
||||
let bestConf = -1
|
||||
for (const d of dewarpResult.detections) {
|
||||
if (d.method in newValues) {
|
||||
newValues[d.method] = d.shear_degrees
|
||||
if (d.confidence > bestConf) {
|
||||
bestConf = d.confidence
|
||||
bestMethod = d.method
|
||||
}
|
||||
}
|
||||
}
|
||||
setShearValues(newValues)
|
||||
// Select the method that was actually used, or the highest confidence
|
||||
if (dewarpResult.method_used && dewarpResult.method_used in newValues) {
|
||||
setSelectedShearMethod(dewarpResult.method_used)
|
||||
} else {
|
||||
setSelectedShearMethod(bestMethod)
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [dewarpResult?.detections])
|
||||
|
||||
const rotationSum = p1Iterative + p2Residual + p3Textline
|
||||
const activeShear = shearValues[selectedShearMethod] ?? 0
|
||||
|
||||
const handleShearValueChange = (method: string, value: number) => {
|
||||
setShearValues((prev) => ({ ...prev, [method]: value }))
|
||||
}
|
||||
|
||||
const handleFineTunePreview = () => {
|
||||
onCombinedAdjust(rotationSum, activeShear)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
onClick={onToggleFineTune}
|
||||
className="w-full flex items-center justify-between p-4 text-left"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm">⚙️</span>
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Feinabstimmung</span>
|
||||
<span className="text-xs text-gray-400">(7 Regler)</span>
|
||||
</div>
|
||||
<span className="text-gray-400 text-sm">{showFineTune ? '\u25B2' : '\u25BC'}</span>
|
||||
</button>
|
||||
|
||||
{showFineTune && (
|
||||
<div className="px-4 pb-4 space-y-5">
|
||||
{/* Rotation section */}
|
||||
<div>
|
||||
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">
|
||||
Rotation (Begradigung)
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<FineTuneSlider
|
||||
label="P1 Iterative Projection"
|
||||
value={p1Iterative}
|
||||
onChange={setP1Iterative}
|
||||
min={-5}
|
||||
max={5}
|
||||
step={0.05}
|
||||
/>
|
||||
<FineTuneSlider
|
||||
label="P2 Word-Alignment"
|
||||
value={p2Residual}
|
||||
onChange={setP2Residual}
|
||||
min={-3}
|
||||
max={3}
|
||||
step={0.05}
|
||||
/>
|
||||
<FineTuneSlider
|
||||
label="P3 Textline-Regression"
|
||||
value={p3Textline}
|
||||
onChange={setP3Textline}
|
||||
min={-3}
|
||||
max={3}
|
||||
step={0.05}
|
||||
/>
|
||||
<div className="flex items-center gap-2 pt-1 border-t border-gray-100 dark:border-gray-700">
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 w-36 shrink-0">Summe Rotation</span>
|
||||
<span className="font-mono text-sm font-medium text-teal-600 dark:text-teal-400">
|
||||
{rotationSum >= 0 ? '+' : ''}{rotationSum.toFixed(2)}\u00B0
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Shear section */}
|
||||
<div>
|
||||
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">
|
||||
Scherung (Entzerrung) — einen Wert waehlen
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{SHEAR_METHOD_KEYS.map((method) => (
|
||||
<FineTuneSlider
|
||||
key={method}
|
||||
label={METHOD_LABELS[method] || method}
|
||||
value={shearValues[method]}
|
||||
onChange={(v) => handleShearValueChange(method, v)}
|
||||
min={-5}
|
||||
max={5}
|
||||
step={0.05}
|
||||
radioName="shear-method"
|
||||
radioChecked={selectedShearMethod === method}
|
||||
onRadioChange={() => setSelectedShearMethod(method)}
|
||||
/>
|
||||
))}
|
||||
<div className="flex items-center gap-2 pt-1 border-t border-gray-100 dark:border-gray-700">
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 w-36 shrink-0">Gewaehlte Scherung</span>
|
||||
<span className="font-mono text-sm font-medium text-teal-600 dark:text-teal-400">
|
||||
{activeShear >= 0 ? '+' : ''}{activeShear.toFixed(2)}\u00B0
|
||||
</span>
|
||||
<span className="text-xs text-gray-400 ml-1">
|
||||
({METHOD_LABELS[selectedShearMethod]})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preview + Save */}
|
||||
<div className="flex items-center gap-3 pt-2">
|
||||
<button
|
||||
onClick={handleFineTunePreview}
|
||||
disabled={isApplying}
|
||||
className="px-4 py-2 text-sm bg-teal-600 text-white rounded-md hover:bg-teal-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{isApplying ? 'Wird angewendet...' : 'Vorschau'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
onGroundTruth({
|
||||
is_correct: false,
|
||||
corrected_shear: activeShear,
|
||||
notes: `Fine-tuned: rotation=${rotationSum.toFixed(3)}, shear=${activeShear.toFixed(3)} (${selectedShearMethod})`,
|
||||
})
|
||||
onGtSaved()
|
||||
}}
|
||||
disabled={gtSaved}
|
||||
className="px-4 py-2 text-sm bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{gtSaved ? 'Gespeichert' : 'Als Ground Truth speichern'}
|
||||
</button>
|
||||
<span className="text-xs text-gray-400">
|
||||
Rotation: {rotationSum >= 0 ? '+' : ''}{rotationSum.toFixed(2)}\u00B0 + Scherung: {activeShear >= 0 ? '+' : ''}{activeShear.toFixed(2)}\u00B0
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import type { DewarpGroundTruth } from '@/app/(admin)/ai/ocr-kombi/types'
|
||||
|
||||
interface DewarpGroundTruthPanelProps {
|
||||
manualShear: number
|
||||
onGroundTruth: (gt: DewarpGroundTruth) => void
|
||||
gtSaved: boolean
|
||||
onGtSaved: () => void
|
||||
}
|
||||
|
||||
export function DewarpGroundTruthPanel({
|
||||
manualShear,
|
||||
onGroundTruth,
|
||||
gtSaved,
|
||||
onGtSaved,
|
||||
}: DewarpGroundTruthPanelProps) {
|
||||
const [gtFeedback, setGtFeedback] = useState<'correct' | 'incorrect' | null>(null)
|
||||
const [gtNotes, setGtNotes] = useState('')
|
||||
|
||||
const handleGroundTruth = (isCorrect: boolean) => {
|
||||
setGtFeedback(isCorrect ? 'correct' : 'incorrect')
|
||||
if (isCorrect) {
|
||||
onGroundTruth({ is_correct: true })
|
||||
onGtSaved()
|
||||
}
|
||||
}
|
||||
|
||||
const handleGroundTruthIncorrect = () => {
|
||||
onGroundTruth({
|
||||
is_correct: false,
|
||||
corrected_shear: manualShear !== 0 ? manualShear : undefined,
|
||||
notes: gtNotes || undefined,
|
||||
})
|
||||
onGtSaved()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
||||
<div className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Spalten vertikal ausgerichtet?
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 mb-2">Pruefen ob die Spaltenraender jetzt senkrecht zum Raster stehen.</p>
|
||||
{!gtSaved ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleGroundTruth(true)}
|
||||
className={`px-4 py-1.5 rounded-md text-sm font-medium transition-colors ${
|
||||
gtFeedback === 'correct'
|
||||
? 'bg-green-100 text-green-700 ring-2 ring-green-400'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-green-50 dark:bg-gray-700 dark:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
Ja
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleGroundTruth(false)}
|
||||
className={`px-4 py-1.5 rounded-md text-sm font-medium transition-colors ${
|
||||
gtFeedback === 'incorrect'
|
||||
? 'bg-red-100 text-red-700 ring-2 ring-red-400'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-red-50 dark:bg-gray-700 dark:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
Nein
|
||||
</button>
|
||||
</div>
|
||||
{gtFeedback === 'incorrect' && (
|
||||
<div className="space-y-2">
|
||||
<textarea
|
||||
value={gtNotes}
|
||||
onChange={(e) => setGtNotes(e.target.value)}
|
||||
placeholder="Notizen zur Korrektur..."
|
||||
className="w-full text-sm border border-gray-300 dark:border-gray-600 rounded-md p-2 bg-white dark:bg-gray-900 text-gray-800 dark:text-gray-200"
|
||||
rows={2}
|
||||
/>
|
||||
<button
|
||||
onClick={handleGroundTruthIncorrect}
|
||||
className="text-sm px-3 py-1 bg-red-600 text-white rounded-md hover:bg-red-700 transition-colors"
|
||||
>
|
||||
Feedback speichern
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-green-600 dark:text-green-400">
|
||||
Feedback gespeichert
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import type { DewarpResult, DewarpDetection } from '@/app/(admin)/ai/ocr-kombi/types'
|
||||
import { METHOD_LABELS } from './dewarp-constants'
|
||||
import { ConfBar } from './ConfBar'
|
||||
|
||||
interface DewarpSummaryBannerProps {
|
||||
dewarpResult: DewarpResult
|
||||
showGrid: boolean
|
||||
onToggleGrid: () => void
|
||||
}
|
||||
|
||||
export function DewarpSummaryBanner({ dewarpResult, showGrid, onToggleGrid }: DewarpSummaryBannerProps) {
|
||||
const detections = dewarpResult.detections || []
|
||||
const wasRejected = dewarpResult.method_used === 'none' && detections.length > 0
|
||||
const wasApplied = dewarpResult.method_used !== 'none' && dewarpResult.method_used !== 'manual' && dewarpResult.method_used !== 'manual_combined'
|
||||
|
||||
const [showDetails, setShowDetails] = useState(false)
|
||||
|
||||
return (
|
||||
<div className={`rounded-lg border p-4 ${
|
||||
wasRejected
|
||||
? 'bg-amber-50 border-amber-200 dark:bg-amber-900/20 dark:border-amber-700'
|
||||
: wasApplied
|
||||
? 'bg-green-50 border-green-200 dark:bg-green-900/20 dark:border-green-700'
|
||||
: 'bg-white border-gray-200 dark:bg-gray-800 dark:border-gray-700'
|
||||
}`}>
|
||||
{/* Status line */}
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className={`text-lg ${wasRejected ? '' : wasApplied ? '' : ''}`}>
|
||||
{wasRejected ? '\u26A0\uFE0F' : wasApplied ? '\u2705' : '\u2796'}
|
||||
</span>
|
||||
<span className="text-sm font-medium text-gray-800 dark:text-gray-200">
|
||||
{wasRejected
|
||||
? 'Quality Gate: Korrektur verworfen (Projektion nicht verbessert)'
|
||||
: wasApplied
|
||||
? `Korrektur angewendet: ${dewarpResult.shear_degrees.toFixed(2)}\u00B0`
|
||||
: dewarpResult.method_used === 'manual' || dewarpResult.method_used === 'manual_combined'
|
||||
? `Manuelle Korrektur: ${dewarpResult.shear_degrees.toFixed(2)}\u00B0`
|
||||
: 'Keine Korrektur noetig'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Key metrics */}
|
||||
<div className="flex flex-wrap items-center gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-500">Scherung:</span>{' '}
|
||||
<span className="font-mono font-medium">{dewarpResult.shear_degrees.toFixed(2)}\u00B0</span>
|
||||
</div>
|
||||
<div className="h-4 w-px bg-gray-300 dark:bg-gray-600" />
|
||||
<div>
|
||||
<span className="text-gray-500">Methode:</span>{' '}
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-teal-100 text-teal-700 dark:bg-teal-900/40 dark:text-teal-300">
|
||||
{dewarpResult.method_used.includes('+')
|
||||
? `Ensemble (${dewarpResult.method_used.split('+').map(m => METHOD_LABELS[m] || m).join(' + ')})`
|
||||
: METHOD_LABELS[dewarpResult.method_used] || dewarpResult.method_used}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-4 w-px bg-gray-300 dark:bg-gray-600" />
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-gray-500">Konfidenz:</span>
|
||||
<ConfBar value={dewarpResult.confidence} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toggles row */}
|
||||
<div className="flex gap-2 mt-3">
|
||||
<button
|
||||
onClick={onToggleGrid}
|
||||
className={`text-xs px-3 py-1 rounded-full border transition-colors ${
|
||||
showGrid
|
||||
? 'bg-teal-100 border-teal-300 text-teal-700 dark:bg-teal-900/40 dark:border-teal-600 dark:text-teal-300'
|
||||
: 'border-gray-300 text-gray-500 dark:border-gray-600 dark:text-gray-400'
|
||||
}`}
|
||||
>
|
||||
Raster
|
||||
</button>
|
||||
{detections.length > 0 && (
|
||||
<button
|
||||
onClick={() => setShowDetails(v => !v)}
|
||||
className={`text-xs px-3 py-1 rounded-full border transition-colors ${
|
||||
showDetails
|
||||
? 'bg-blue-100 border-blue-300 text-blue-700 dark:bg-blue-900/40 dark:border-blue-600 dark:text-blue-300'
|
||||
: 'border-gray-300 text-gray-500 dark:border-gray-600 dark:text-gray-400'
|
||||
}`}
|
||||
>
|
||||
Details ({detections.length} Methoden)
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Detailed detections */}
|
||||
{showDetails && detections.length > 0 && (
|
||||
<div className="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700">
|
||||
<div className="text-xs text-gray-500 mb-2">Einzelne Detektoren:</div>
|
||||
<div className="space-y-1.5">
|
||||
{detections.map((d: DewarpDetection) => {
|
||||
const isUsed = dewarpResult.method_used.includes(d.method)
|
||||
const aboveThreshold = d.confidence >= 0.5
|
||||
return (
|
||||
<div
|
||||
key={d.method}
|
||||
className={`flex items-center gap-3 text-xs px-2 py-1.5 rounded ${
|
||||
isUsed
|
||||
? 'bg-teal-50 dark:bg-teal-900/20'
|
||||
: 'bg-gray-50 dark:bg-gray-800'
|
||||
}`}
|
||||
>
|
||||
<span className="w-4 text-center">
|
||||
{isUsed ? '\u2713' : aboveThreshold ? '\u2012' : '\u2717'}
|
||||
</span>
|
||||
<span className={`w-40 ${isUsed ? 'font-medium text-gray-800 dark:text-gray-200' : 'text-gray-500'}`}>
|
||||
{METHOD_LABELS[d.method] || d.method}
|
||||
</span>
|
||||
<span className="font-mono w-16 text-right">
|
||||
{d.shear_degrees.toFixed(2)}\u00B0
|
||||
</span>
|
||||
<ConfBar value={d.confidence} />
|
||||
{!aboveThreshold && (
|
||||
<span className="text-gray-400 ml-1">(unter Schwelle)</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{wasRejected && (
|
||||
<div className="mt-2 text-xs text-amber-600 dark:text-amber-400">
|
||||
Die Korrektur wurde verworfen, weil die horizontale Projektions-Varianz nach Anwendung nicht besser war als vorher.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
'use client'
|
||||
|
||||
import type { ExcludeRegion } from '@/app/(admin)/ai/ocr-kombi/types'
|
||||
|
||||
interface ExcludeRegionsListProps {
|
||||
regions: ExcludeRegion[]
|
||||
onDeleteRegion: (index: number) => void
|
||||
}
|
||||
|
||||
export function ExcludeRegionsList({ regions, onDeleteRegion }: ExcludeRegionsListProps) {
|
||||
if (regions.length === 0) return null
|
||||
|
||||
return (
|
||||
<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 ({regions.length}) — Woerter in diesen Bereichen werden nicht erkannt
|
||||
</h4>
|
||||
<div className="space-y-1">
|
||||
{regions.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={() => onDeleteRegion(i)}
|
||||
className="ml-auto text-red-500 hover:text-red-700 dark:hover:text-red-300"
|
||||
>
|
||||
Entfernen
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
'use client'
|
||||
|
||||
/** A single slider row for fine-tuning. */
|
||||
export function FineTuneSlider({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
unit = '\u00B0',
|
||||
radioName,
|
||||
radioChecked,
|
||||
onRadioChange,
|
||||
}: {
|
||||
label: string
|
||||
value: number
|
||||
onChange: (v: number) => void
|
||||
min: number
|
||||
max: number
|
||||
step: number
|
||||
unit?: string
|
||||
radioName?: string
|
||||
radioChecked?: boolean
|
||||
onRadioChange?: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{radioName !== undefined && (
|
||||
<input
|
||||
type="radio"
|
||||
name={radioName}
|
||||
checked={radioChecked}
|
||||
onChange={onRadioChange}
|
||||
className="w-3.5 h-3.5 accent-teal-500"
|
||||
/>
|
||||
)}
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 w-36 shrink-0">{label}</span>
|
||||
<span className="text-xs text-gray-400 w-8 text-right">{min}{unit}</span>
|
||||
<input
|
||||
type="range"
|
||||
min={min * 100}
|
||||
max={max * 100}
|
||||
step={step * 100}
|
||||
value={Math.round(value * 100)}
|
||||
onChange={(e) => onChange(parseInt(e.target.value) / 100)}
|
||||
className="flex-1 h-1.5 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700 accent-teal-500"
|
||||
/>
|
||||
<span className="text-xs text-gray-400 w-8">+{max}{unit}</span>
|
||||
<span className="font-mono text-xs w-14 text-right tabular-nums">
|
||||
{value >= 0 ? '+' : ''}{value.toFixed(2)}{unit}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
'use client'
|
||||
|
||||
import type { ImageStyle } from '@/app/(admin)/ai/ocr-kombi/types'
|
||||
import { IMAGE_STYLES as STYLES } from '@/app/(admin)/ai/ocr-kombi/types'
|
||||
import type { ImageRegionWithState } from './ground-truth-types'
|
||||
|
||||
interface ImageRegionsPanelProps {
|
||||
imageRegions: ImageRegionWithState[]
|
||||
onUpdateRegion: (index: number, update: Partial<ImageRegionWithState>) => void
|
||||
onGenerateImage: (index: number) => void
|
||||
onRemoveRegion: (index: number) => void
|
||||
}
|
||||
|
||||
export function ImageRegionsPanel({
|
||||
imageRegions, onUpdateRegion, onGenerateImage, onRemoveRegion,
|
||||
}: ImageRegionsPanelProps) {
|
||||
if (imageRegions.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="border rounded-lg dark:border-gray-700 p-4">
|
||||
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||
Bildbereiche ({imageRegions.length} gefunden)
|
||||
</h4>
|
||||
<div className="space-y-3">
|
||||
{imageRegions.map((region, i) => (
|
||||
<div key={i} className="flex items-start gap-3 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
||||
{/* Preview thumbnail */}
|
||||
<div className="w-16 h-16 flex-shrink-0 border rounded dark:border-gray-600 overflow-hidden bg-white">
|
||||
{region.image_b64 ? (
|
||||
<img src={region.image_b64} alt="" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-gray-400 text-xs">
|
||||
{Math.round(region.bbox_pct.w)}x{Math.round(region.bbox_pct.h)}%
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Prompt + controls */}
|
||||
<div className="flex-1 min-w-0 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 flex-shrink-0">
|
||||
Bereich {i + 1}:
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={region.prompt}
|
||||
onChange={e => onUpdateRegion(i, { prompt: e.target.value })}
|
||||
placeholder="Beschreibung / Prompt..."
|
||||
className="flex-1 text-sm px-2 py-1 border rounded dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={region.style}
|
||||
onChange={e => onUpdateRegion(i, { style: e.target.value as ImageStyle })}
|
||||
className="text-sm px-2 py-1 border rounded dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
>
|
||||
{STYLES.map(s => (
|
||||
<option key={s.value} value={s.value}>{s.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={() => onGenerateImage(i)}
|
||||
disabled={!!region.generating || !region.prompt}
|
||||
className="px-3 py-1 text-sm bg-teal-600 text-white rounded hover:bg-teal-700 disabled:opacity-50"
|
||||
>
|
||||
{region.generating ? 'Generiere...' : 'Generieren'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onRemoveRegion(i)}
|
||||
className="px-2 py-1 text-sm text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded"
|
||||
>
|
||||
Entfernen
|
||||
</button>
|
||||
</div>
|
||||
{region.description && region.description !== region.prompt && (
|
||||
<p className="text-xs text-gray-400">{region.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
'use client'
|
||||
|
||||
import type { LlmChange, ReviewMeta } from './llm-review-types'
|
||||
import { FIELD_LABELS } from './llm-review-types'
|
||||
|
||||
interface LlmReviewCorrectionsProps {
|
||||
changes: LlmChange[]
|
||||
accepted: Set<number>
|
||||
meta: ReviewMeta | null
|
||||
totalDuration: number
|
||||
applying: boolean
|
||||
onToggleChange: (index: number) => void
|
||||
onToggleAll: () => void
|
||||
onApply: () => void
|
||||
onNext: () => void
|
||||
}
|
||||
|
||||
export function LlmReviewCorrections({
|
||||
changes,
|
||||
accepted,
|
||||
meta,
|
||||
totalDuration,
|
||||
applying,
|
||||
onToggleChange,
|
||||
onToggleAll,
|
||||
onApply,
|
||||
onNext,
|
||||
}: LlmReviewCorrectionsProps) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Summary */}
|
||||
<div className="bg-gray-50 dark:bg-gray-800/50 rounded-lg p-3 text-xs text-gray-500 dark:text-gray-400">
|
||||
{changes.length === 0 ? (
|
||||
<span>Keine Korrekturen noetig — alle Eintraege sind korrekt.</span>
|
||||
) : (
|
||||
<span>
|
||||
{changes.length} Korrektur{changes.length !== 1 ? 'en' : ''} gefunden ·{' '}
|
||||
{accepted.size} ausgewaehlt ·{' '}
|
||||
{meta?.skipped || 0} uebersprungen (Lautschrift) ·{' '}
|
||||
{totalDuration}ms
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Corrections detail list (if any) */}
|
||||
{changes.length > 0 && (
|
||||
<div className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||
<div className="bg-gray-50 dark:bg-gray-800 px-3 py-2 border-b border-gray-200 dark:border-gray-700">
|
||||
<span className="text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||
Korrekturvorschlaege ({accepted.size}/{changes.length} ausgewaehlt)
|
||||
</span>
|
||||
</div>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-gray-50/50 dark:bg-gray-800/50 border-b border-gray-200 dark:border-gray-700">
|
||||
<th className="w-10 px-3 py-1.5 text-center">
|
||||
<input type="checkbox" checked={accepted.size === changes.length} onChange={onToggleAll}
|
||||
className="rounded border-gray-300 dark:border-gray-600" />
|
||||
</th>
|
||||
<th className="px-2 py-1.5 text-left text-gray-500 dark:text-gray-400 font-medium text-xs">Zeile</th>
|
||||
<th className="px-2 py-1.5 text-left text-gray-500 dark:text-gray-400 font-medium text-xs">Feld</th>
|
||||
<th className="px-2 py-1.5 text-left text-gray-500 dark:text-gray-400 font-medium text-xs">Vorher</th>
|
||||
<th className="px-2 py-1.5 text-left text-gray-500 dark:text-gray-400 font-medium text-xs">Nachher</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{changes.map((change, idx) => (
|
||||
<tr key={idx} className={`border-b border-gray-100 dark:border-gray-700/50 ${
|
||||
accepted.has(idx) ? 'bg-teal-50/50 dark:bg-teal-900/10' : ''
|
||||
}`}>
|
||||
<td className="px-3 py-1.5 text-center">
|
||||
<input type="checkbox" checked={accepted.has(idx)} onChange={() => onToggleChange(idx)}
|
||||
className="rounded border-gray-300 dark:border-gray-600" />
|
||||
</td>
|
||||
<td className="px-2 py-1.5 text-gray-500 dark:text-gray-400 font-mono text-xs">R{change.row_index}</td>
|
||||
<td className="px-2 py-1.5">
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400">
|
||||
{FIELD_LABELS[change.field] || change.field}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-2 py-1.5"><span className="line-through text-red-500 dark:text-red-400 text-xs">{change.old}</span></td>
|
||||
<td className="px-2 py-1.5"><span className="text-green-600 dark:text-green-400 font-medium text-xs">{change.new}</span></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<p className="text-xs text-gray-400">
|
||||
{changes.length > 0 ? `${accepted.size} von ${changes.length} ausgewaehlt` : ''}
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
{changes.length > 0 && (
|
||||
<button onClick={onNext}
|
||||
className="px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors text-gray-600 dark:text-gray-400">
|
||||
Alle ablehnen
|
||||
</button>
|
||||
)}
|
||||
{changes.length > 0 ? (
|
||||
<button onClick={onApply} disabled={applying || accepted.size === 0}
|
||||
className="px-5 py-2 text-sm bg-teal-600 text-white rounded-lg hover:bg-teal-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors font-medium">
|
||||
{applying ? 'Wird uebernommen...' : `${accepted.size} Korrektur${accepted.size !== 1 ? 'en' : ''} uebernehmen`}
|
||||
</button>
|
||||
) : (
|
||||
<button onClick={onNext}
|
||||
className="px-6 py-2.5 bg-teal-600 text-white rounded-lg hover:bg-teal-700 transition-colors font-medium">
|
||||
Weiter →
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState, useMemo } from 'react'
|
||||
import type { GridCell } from '@/app/(admin)/ai/ocr-kombi/types'
|
||||
import type { WordPosition } from './usePixelWordPositions'
|
||||
|
||||
interface LlmReviewOverlayProps {
|
||||
cells: GridCell[]
|
||||
imageNaturalSize: { w: number; h: number } | null
|
||||
fontScale: number
|
||||
leftPaddingPct: number
|
||||
globalBold: boolean
|
||||
cellWordPositions: Map<string, WordPosition[]>
|
||||
}
|
||||
|
||||
export function LlmReviewOverlay({
|
||||
cells,
|
||||
imageNaturalSize,
|
||||
fontScale,
|
||||
leftPaddingPct,
|
||||
globalBold,
|
||||
cellWordPositions,
|
||||
}: LlmReviewOverlayProps) {
|
||||
const reconRef = useRef<HTMLDivElement>(null)
|
||||
const [reconWidth, setReconWidth] = useState(0)
|
||||
|
||||
// Track reconstruction container width for font size calculation
|
||||
useEffect(() => {
|
||||
const el = reconRef.current
|
||||
if (!el) return
|
||||
const obs = new ResizeObserver(entries => {
|
||||
for (const entry of entries) setReconWidth(entry.contentRect.width)
|
||||
})
|
||||
obs.observe(el)
|
||||
return () => obs.disconnect()
|
||||
}, [])
|
||||
|
||||
// Snap all cells in the same column to consistent x/w positions
|
||||
const colPositions = useMemo(() => {
|
||||
const byCol = new Map<number, { xs: number[]; ws: number[] }>()
|
||||
for (const cell of cells) {
|
||||
if (!cell.bbox_pct) continue
|
||||
const entry = byCol.get(cell.col_index) || { xs: [], ws: [] }
|
||||
entry.xs.push(cell.bbox_pct.x)
|
||||
entry.ws.push(cell.bbox_pct.w)
|
||||
byCol.set(cell.col_index, entry)
|
||||
}
|
||||
const result = new Map<number, { x: number; w: number }>()
|
||||
for (const [colIdx, { xs, ws }] of byCol) {
|
||||
xs.sort((a, b) => a - b)
|
||||
ws.sort((a, b) => a - b)
|
||||
const medianX = xs[Math.floor(xs.length / 2)]
|
||||
const medianW = ws[Math.floor(ws.length / 2)]
|
||||
result.set(colIdx, { x: medianX, w: medianW })
|
||||
}
|
||||
return result
|
||||
}, [cells])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
|
||||
Text-Rekonstruktion ({cells.filter(c => c.text).length} Zellen)
|
||||
</div>
|
||||
<div className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden bg-white dark:bg-white">
|
||||
<div
|
||||
ref={reconRef}
|
||||
className="relative"
|
||||
style={{
|
||||
aspectRatio: imageNaturalSize ? `${imageNaturalSize.w} / ${imageNaturalSize.h}` : '3 / 4',
|
||||
}}
|
||||
>
|
||||
{cells.map(cell => {
|
||||
if (!cell.bbox_pct || !cell.text) return null
|
||||
const col = colPositions.get(cell.col_index)
|
||||
const cellX = col?.x ?? cell.bbox_pct.x
|
||||
const cellW = col?.w ?? cell.bbox_pct.w
|
||||
const aspect = imageNaturalSize ? imageNaturalSize.h / imageNaturalSize.w : 4 / 3
|
||||
const containerH = reconWidth * aspect
|
||||
const cellHeightPx = containerH * (cell.bbox_pct.h / 100)
|
||||
|
||||
const wordPos = cellWordPositions.get(cell.cell_id)
|
||||
|
||||
// Pixel-analysed: render word-groups at detected positions
|
||||
if (wordPos) {
|
||||
return wordPos.map((wp, i) => {
|
||||
const autoFontPx = cellHeightPx * wp.fontRatio * fontScale
|
||||
const fs = Math.max(6, autoFontPx)
|
||||
return (
|
||||
<span
|
||||
key={`${cell.cell_id}_${i}`}
|
||||
className="absolute leading-none pointer-events-none select-none"
|
||||
style={{
|
||||
left: `${wp.xPct}%`,
|
||||
top: `${cell.bbox_pct.y}%`,
|
||||
width: `${wp.wPct}%`,
|
||||
height: `${cell.bbox_pct.h}%`,
|
||||
fontSize: `${fs}px`,
|
||||
fontWeight: globalBold ? 'bold' : (cell.is_bold ? 'bold' : 'normal'),
|
||||
fontFamily: "'Liberation Sans', Arial, sans-serif",
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'visible',
|
||||
color: '#1a1a1a',
|
||||
}}
|
||||
>
|
||||
{wp.text}
|
||||
</span>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// Fallback: no pixel data - single span for entire cell
|
||||
const fontSize = Math.max(6, cellHeightPx * fontScale)
|
||||
return (
|
||||
<span
|
||||
key={cell.cell_id}
|
||||
className="absolute leading-none pointer-events-none select-none"
|
||||
style={{
|
||||
left: `${cellX}%`,
|
||||
top: `${cell.bbox_pct.y}%`,
|
||||
width: `${cellW}%`,
|
||||
height: `${cell.bbox_pct.h}%`,
|
||||
fontSize: `${fontSize}px`,
|
||||
fontWeight: globalBold ? 'bold' : (cell.is_bold ? 'bold' : 'normal'),
|
||||
paddingLeft: `${leftPaddingPct}%`,
|
||||
fontFamily: "'Liberation Sans', Arial, sans-serif",
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
whiteSpace: 'pre',
|
||||
overflow: 'visible',
|
||||
color: '#1a1a1a',
|
||||
}}
|
||||
>
|
||||
{cell.text}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
'use client'
|
||||
|
||||
import type { LlmChange, RowStatus } from './llm-review-types'
|
||||
|
||||
/** Cell content with inline diff for corrections */
|
||||
export function CellContent({ text, field, rowChanges }: {
|
||||
text: string
|
||||
field: string
|
||||
rowChanges?: LlmChange[]
|
||||
}) {
|
||||
const change = rowChanges?.find(c => c.field === field)
|
||||
|
||||
if (!text && !change) {
|
||||
return <span className="text-gray-300 dark:text-gray-600">—</span>
|
||||
}
|
||||
|
||||
if (change) {
|
||||
return (
|
||||
<span>
|
||||
<span className="line-through text-red-400 dark:text-red-500 text-xs mr-1">{change.old}</span>
|
||||
<span className="text-green-600 dark:text-green-400 font-medium text-xs">{change.new}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return <span className="text-gray-700 dark:text-gray-300 text-xs">{text}</span>
|
||||
}
|
||||
|
||||
/** Status icon for each row */
|
||||
export function StatusIcon({ status }: { status: RowStatus }) {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return <span className="text-gray-300 dark:text-gray-600 text-xs">—</span>
|
||||
case 'active':
|
||||
return (
|
||||
<span className="inline-block w-3 h-3 rounded-full bg-yellow-400 animate-pulse" title="Wird geprueft" />
|
||||
)
|
||||
case 'reviewed':
|
||||
return (
|
||||
<svg className="w-4 h-4 text-green-500 inline-block" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)
|
||||
case 'corrected':
|
||||
return (
|
||||
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-teal-100 dark:bg-teal-900/30 text-teal-700 dark:text-teal-400">
|
||||
korr.
|
||||
</span>
|
||||
)
|
||||
case 'skipped':
|
||||
return (
|
||||
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400">
|
||||
skip
|
||||
</span>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Loading spinner screen */
|
||||
export function LoadingScreen() {
|
||||
return (
|
||||
<div className="flex items-center gap-3 justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-teal-500" />
|
||||
<span className="text-gray-500">Session-Daten werden geladen...</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Error screen with retry */
|
||||
export function ErrorScreen({ error, onRetry, onSkip }: {
|
||||
error: string
|
||||
onRetry: () => void
|
||||
onSkip: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<div className="text-5xl mb-4">⚠️</div>
|
||||
<h3 className="text-lg font-medium text-red-600 dark:text-red-400 mb-2">Fehler bei OCR-Zeichenkorrektur</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 max-w-lg mb-4">{error}</p>
|
||||
<div className="flex gap-3">
|
||||
<button onClick={onRetry}
|
||||
className="px-5 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 transition-colors text-sm">
|
||||
Erneut versuchen
|
||||
</button>
|
||||
<button onClick={onSkip}
|
||||
className="px-5 py-2 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors text-sm">
|
||||
Ueberspringen →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Applied screen shown after corrections are applied */
|
||||
export function AppliedScreen({ acceptedCount, totalChanges, onNext }: {
|
||||
acceptedCount: number
|
||||
totalChanges: number
|
||||
onNext: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<div className="text-5xl mb-4">✅</div>
|
||||
<h3 className="text-lg font-medium text-gray-700 dark:text-gray-300 mb-2">Korrekturen uebernommen</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-6">
|
||||
{acceptedCount} von {totalChanges} Korrekturen wurden angewendet.
|
||||
</p>
|
||||
<button onClick={onNext}
|
||||
className="px-6 py-2.5 bg-teal-600 text-white rounded-lg hover:bg-teal-700 transition-colors font-medium">
|
||||
Weiter →
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** No session placeholder */
|
||||
export function NoSessionScreen() {
|
||||
return <div className="text-center py-12 text-gray-400">Bitte zuerst eine Session auswaehlen.</div>
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
'use client'
|
||||
|
||||
import { forwardRef } from 'react'
|
||||
import type { WordEntry, ColumnMeta } from '@/app/(admin)/ai/ocr-kombi/types'
|
||||
import type { LlmChange, RowStatus } from './llm-review-types'
|
||||
import { COL_TYPE_TO_FIELD, COL_TYPE_COLOR, FIELD_LABELS } from './llm-review-types'
|
||||
import { CellContent, StatusIcon } from './LlmReviewStatusScreens'
|
||||
|
||||
interface LlmReviewVocabTableProps {
|
||||
vocabEntries: WordEntry[]
|
||||
columnsUsed: ColumnMeta[]
|
||||
getRowStatus: (rowIndex: number) => RowStatus
|
||||
correctedMap: Map<number, LlmChange[]>
|
||||
activeRowRef: React.RefObject<HTMLTableRowElement | null>
|
||||
}
|
||||
|
||||
export const LlmReviewVocabTable = forwardRef<HTMLDivElement, LlmReviewVocabTableProps>(
|
||||
function LlmReviewVocabTable({ vocabEntries, columnsUsed, getRowStatus, correctedMap, activeRowRef }, ref) {
|
||||
return (
|
||||
<div ref={ref}>
|
||||
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
|
||||
{columnsUsed.length === 1 && columnsUsed[0]?.type === 'column_text' ? 'Tabelle' : 'Vokabeltabelle'} ({vocabEntries.length} Eintraege)
|
||||
</div>
|
||||
<div className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||
<div className="max-h-[70vh] overflow-y-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="sticky top-0 z-10">
|
||||
<tr className="bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
|
||||
<th className="px-2 py-2 text-left text-gray-500 dark:text-gray-400 font-medium w-10">#</th>
|
||||
{columnsUsed.length > 0 ? (
|
||||
columnsUsed.map((col, i) => {
|
||||
const field = COL_TYPE_TO_FIELD[col.type]
|
||||
if (!field) return null
|
||||
return (
|
||||
<th key={i} className={`px-2 py-2 text-left font-medium ${COL_TYPE_COLOR[col.type] || 'text-gray-500 dark:text-gray-400'}`}>
|
||||
{FIELD_LABELS[field] || field}
|
||||
</th>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<>
|
||||
<th className="px-2 py-2 text-left text-gray-500 dark:text-gray-400 font-medium">EN</th>
|
||||
<th className="px-2 py-2 text-left text-gray-500 dark:text-gray-400 font-medium">DE</th>
|
||||
<th className="px-2 py-2 text-left text-gray-500 dark:text-gray-400 font-medium">Beispiel</th>
|
||||
</>
|
||||
)}
|
||||
<th className="px-2 py-2 text-center text-gray-500 dark:text-gray-400 font-medium w-16">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{vocabEntries.map((entry, idx) => {
|
||||
const rowStatus = getRowStatus(idx)
|
||||
const rowChanges = correctedMap.get(idx)
|
||||
|
||||
const rowBg = {
|
||||
pending: '',
|
||||
active: 'bg-yellow-50 dark:bg-yellow-900/20',
|
||||
reviewed: '',
|
||||
corrected: 'bg-teal-50/50 dark:bg-teal-900/10',
|
||||
skipped: 'bg-gray-50 dark:bg-gray-800/50',
|
||||
}[rowStatus]
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={idx}
|
||||
ref={rowStatus === 'active' ? activeRowRef : undefined}
|
||||
className={`border-b border-gray-100 dark:border-gray-700/50 ${rowBg} ${
|
||||
rowStatus === 'active' ? 'ring-1 ring-yellow-400 ring-inset' : ''
|
||||
}`}
|
||||
>
|
||||
<td className="px-2 py-1.5 text-gray-400 font-mono text-xs">{idx}</td>
|
||||
{columnsUsed.length > 0 ? (
|
||||
columnsUsed.map((col, i) => {
|
||||
const field = COL_TYPE_TO_FIELD[col.type]
|
||||
if (!field) return null
|
||||
const text = (entry as Record<string, unknown>)[field] as string || ''
|
||||
return (
|
||||
<td key={i} className="px-2 py-1.5 text-xs">
|
||||
<CellContent text={text} field={field} rowChanges={rowChanges} />
|
||||
</td>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<>
|
||||
<td className="px-2 py-1.5">
|
||||
<CellContent text={entry.english} field="english" rowChanges={rowChanges} />
|
||||
</td>
|
||||
<td className="px-2 py-1.5">
|
||||
<CellContent text={entry.german} field="german" rowChanges={rowChanges} />
|
||||
</td>
|
||||
<td className="px-2 py-1.5 text-xs">
|
||||
<CellContent text={entry.example} field="example" rowChanges={rowChanges} />
|
||||
</td>
|
||||
</>
|
||||
)}
|
||||
<td className="px-2 py-1.5 text-center">
|
||||
<StatusIcon status={rowStatus} />
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,302 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { StructureBox, StructureGraphic } from '@/app/(admin)/ai/ocr-kombi/types'
|
||||
import type { WordPosition } from './usePixelWordPositions'
|
||||
import type { EditableCell, PageRegion, RowItem, PageZone } from './StepReconstructionTypes'
|
||||
import { adjustCellForBoxZones } from './StepReconstructionTypes'
|
||||
import { StructureLayer } from './StructureLayer'
|
||||
|
||||
interface ReconstructionOverlayProps {
|
||||
cells: EditableCell[]
|
||||
dewarpedUrl: string
|
||||
imageNaturalSize: { w: number; h: number } | null
|
||||
parentColumns: PageRegion[]
|
||||
parentRows: RowItem[]
|
||||
parentZones: PageZone[]
|
||||
structureBoxes: StructureBox[]
|
||||
structureGraphics: StructureGraphic[]
|
||||
showStructure: boolean
|
||||
fontScale: number
|
||||
globalBold: boolean
|
||||
boxZonesPct: { topPct: number; bottomPct: number }[]
|
||||
cellWordPositions: Map<string, WordPosition[]>
|
||||
onTextChange: (cellId: string, newText: string) => void
|
||||
onKeyDown: (e: React.KeyboardEvent, cellId: string) => void
|
||||
onResetCell: (cellId: string) => void
|
||||
onImageNaturalSize: (size: { w: number; h: number }) => void
|
||||
getDisplayText: (cell: EditableCell) => string
|
||||
isEdited: (cell: EditableCell) => boolean
|
||||
}
|
||||
|
||||
export function ReconstructionOverlay({
|
||||
cells,
|
||||
dewarpedUrl,
|
||||
imageNaturalSize,
|
||||
parentColumns,
|
||||
parentRows,
|
||||
parentZones,
|
||||
structureBoxes,
|
||||
structureGraphics,
|
||||
showStructure,
|
||||
fontScale,
|
||||
globalBold,
|
||||
boxZonesPct,
|
||||
cellWordPositions,
|
||||
onTextChange,
|
||||
onKeyDown,
|
||||
onResetCell,
|
||||
onImageNaturalSize,
|
||||
getDisplayText,
|
||||
isEdited,
|
||||
}: ReconstructionOverlayProps) {
|
||||
const reconRef = useRef<HTMLDivElement>(null)
|
||||
const [reconWidth, setReconWidth] = useState(0)
|
||||
|
||||
// Track reconstruction container width for font size calculation
|
||||
useEffect(() => {
|
||||
const el = reconRef.current
|
||||
if (!el) return
|
||||
const obs = new ResizeObserver(entries => {
|
||||
for (const entry of entries) setReconWidth(entry.contentRect.width)
|
||||
})
|
||||
obs.observe(el)
|
||||
return () => obs.disconnect()
|
||||
}, [])
|
||||
|
||||
const imgW = imageNaturalSize?.w || 1
|
||||
const imgH = imageNaturalSize?.h || 1
|
||||
const aspect = imgH / imgW
|
||||
const containerH = reconWidth * aspect
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{/* Left: Original image */}
|
||||
<div>
|
||||
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
|
||||
Originalbild
|
||||
</div>
|
||||
<div className="border rounded-lg overflow-hidden dark:border-gray-700 bg-gray-50 dark:bg-gray-900 sticky top-4">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={dewarpedUrl}
|
||||
alt="Original"
|
||||
className="w-full h-auto"
|
||||
onLoad={(e) => {
|
||||
const img = e.target as HTMLImageElement
|
||||
onImageNaturalSize({ w: img.naturalWidth, h: img.naturalHeight })
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Reconstructed table overlay */}
|
||||
<div>
|
||||
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
|
||||
Rekonstruktion ({cells.length} Zellen)
|
||||
</div>
|
||||
<div className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden bg-white">
|
||||
<div
|
||||
ref={reconRef}
|
||||
className="relative"
|
||||
style={{ aspectRatio: `${imgW} / ${imgH}` }}
|
||||
>
|
||||
{/* Column lines */}
|
||||
{parentColumns
|
||||
.filter(c => !['header', 'footer'].includes(c.type))
|
||||
.map((col, i) => (
|
||||
<div
|
||||
key={`col-${i}`}
|
||||
className="absolute top-0 bottom-0 border-l border-gray-300/50"
|
||||
style={{ left: `${(col.x / imgW) * 100}%` }}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Row lines */}
|
||||
{parentRows.map((row, i) => (
|
||||
<div
|
||||
key={`row-${i}`}
|
||||
className="absolute left-0 right-0 border-t border-gray-300/50"
|
||||
style={{ top: `${(row.y / imgH) * 100}%` }}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Box zone highlight */}
|
||||
{parentZones
|
||||
.filter(z => z.zone_type === 'box' && z.box)
|
||||
.map((z, i) => {
|
||||
const box = z.box!
|
||||
return (
|
||||
<div
|
||||
key={`box-${i}`}
|
||||
className="absolute border-2 border-blue-400/30 bg-blue-50/10 pointer-events-none"
|
||||
style={{
|
||||
left: `${(box.x / imgW) * 100}%`,
|
||||
top: `${(box.y / imgH) * 100}%`,
|
||||
width: `${(box.width / imgW) * 100}%`,
|
||||
height: `${(box.height / imgH) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Structure elements (boxes, graphics) */}
|
||||
<StructureLayer
|
||||
boxes={structureBoxes}
|
||||
graphics={structureGraphics}
|
||||
imgW={imgW}
|
||||
imgH={imgH}
|
||||
show={showStructure}
|
||||
/>
|
||||
|
||||
{/* Pixel-positioned words / editable inputs */}
|
||||
{cells.map((cell) => renderOverlayCell(
|
||||
cell,
|
||||
getDisplayText(cell),
|
||||
isEdited(cell),
|
||||
cellWordPositions.get(cell.cellId),
|
||||
adjustCellForBoxZones(cell.bboxPct, cell.cellId, boxZonesPct),
|
||||
containerH,
|
||||
fontScale,
|
||||
globalBold,
|
||||
onTextChange,
|
||||
onKeyDown,
|
||||
onResetCell,
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Cell rendering for overlay mode ---
|
||||
|
||||
function renderOverlayCell(
|
||||
cell: EditableCell,
|
||||
displayText: string,
|
||||
edited: boolean,
|
||||
wordPos: WordPosition[] | undefined,
|
||||
adjBbox: { x: number; y: number; w: number; h: number },
|
||||
containerH: number,
|
||||
fontScale: number,
|
||||
globalBold: boolean,
|
||||
onTextChange: (cellId: string, text: string) => void,
|
||||
onKeyDown: (e: React.KeyboardEvent, cellId: string) => void,
|
||||
onResetCell: (cellId: string) => void,
|
||||
): React.ReactNode {
|
||||
const cellHeightPx = containerH * (adjBbox.h / 100)
|
||||
|
||||
// Pixel-analysed: render word-groups at detected positions as inputs
|
||||
if (wordPos && wordPos.length > 0) {
|
||||
return wordPos.map((wp, i) => {
|
||||
const autoFontPx = cellHeightPx * wp.fontRatio * fontScale
|
||||
const fs = Math.max(6, autoFontPx)
|
||||
|
||||
// For multi-group cells, render as span (read-only positioned)
|
||||
if (wordPos.length > 1) {
|
||||
return (
|
||||
<span
|
||||
key={`${cell.cellId}_wp_${i}`}
|
||||
className="absolute leading-none pointer-events-none select-none"
|
||||
style={{
|
||||
left: `${wp.xPct}%`,
|
||||
top: `${adjBbox.y}%`,
|
||||
width: `${wp.wPct}%`,
|
||||
height: `${adjBbox.h}%`,
|
||||
fontSize: `${fs}px`,
|
||||
fontWeight: globalBold ? 'bold' : (cell.colType === 'column_en' ? 'bold' : 'normal'),
|
||||
fontFamily: "'Liberation Sans', Arial, sans-serif",
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'visible',
|
||||
color: '#1a1a1a',
|
||||
}}
|
||||
>
|
||||
{wp.text}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// Single group: render as editable input at pixel position
|
||||
return (
|
||||
<div key={`${cell.cellId}_wp_${i}`} className="absolute group" style={{
|
||||
left: `${wp.xPct}%`,
|
||||
top: `${adjBbox.y}%`,
|
||||
width: `${wp.wPct}%`,
|
||||
height: `${adjBbox.h}%`,
|
||||
}}>
|
||||
<input
|
||||
id={`cell-${cell.cellId}`}
|
||||
type="text"
|
||||
value={displayText}
|
||||
onChange={(e) => onTextChange(cell.cellId, e.target.value)}
|
||||
onKeyDown={(e) => onKeyDown(e, cell.cellId)}
|
||||
className={`w-full h-full bg-transparent border-0 outline-none px-0 transition-colors ${
|
||||
edited ? 'bg-green-50/30' : ''
|
||||
}`}
|
||||
style={{
|
||||
fontSize: `${fs}px`,
|
||||
fontWeight: globalBold ? 'bold' : (cell.colType === 'column_en' ? 'bold' : 'normal'),
|
||||
fontFamily: "'Liberation Sans', Arial, sans-serif",
|
||||
lineHeight: '1',
|
||||
color: '#1a1a1a',
|
||||
}}
|
||||
title={`${cell.cellId} (${cell.colType})`}
|
||||
/>
|
||||
{edited && (
|
||||
<button
|
||||
onClick={() => onResetCell(cell.cellId)}
|
||||
className="absolute -top-1 -right-1 w-4 h-4 bg-red-500 text-white rounded-full text-[9px] leading-none opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"
|
||||
title="Zuruecksetzen"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// Fallback: no pixel data — single input at cell bbox
|
||||
if (!cell.text) return null
|
||||
|
||||
const fontSize = Math.max(6, cellHeightPx * fontScale)
|
||||
return (
|
||||
<div key={cell.cellId} className="absolute group" style={{
|
||||
left: `${adjBbox.x}%`,
|
||||
top: `${adjBbox.y}%`,
|
||||
width: `${adjBbox.w}%`,
|
||||
height: `${adjBbox.h}%`,
|
||||
}}>
|
||||
<input
|
||||
id={`cell-${cell.cellId}`}
|
||||
type="text"
|
||||
value={displayText}
|
||||
onChange={(e) => onTextChange(cell.cellId, e.target.value)}
|
||||
onKeyDown={(e) => onKeyDown(e, cell.cellId)}
|
||||
className={`w-full h-full bg-transparent border-0 outline-none px-0 transition-colors ${
|
||||
edited ? 'bg-green-50/30' : ''
|
||||
}`}
|
||||
style={{
|
||||
fontSize: `${fontSize}px`,
|
||||
fontWeight: globalBold ? 'bold' : 'normal',
|
||||
fontFamily: "'Liberation Sans', Arial, sans-serif",
|
||||
lineHeight: '1',
|
||||
color: '#1a1a1a',
|
||||
}}
|
||||
title={`${cell.cellId} (${cell.colType})`}
|
||||
/>
|
||||
{edited && (
|
||||
<button
|
||||
onClick={() => onResetCell(cell.cellId)}
|
||||
className="absolute -top-1 -right-1 w-4 h-4 bg-red-500 text-white rounded-full text-[9px] leading-none opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"
|
||||
title="Zuruecksetzen"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
'use client'
|
||||
|
||||
import type { GridCell, ImageRegionWithState } from './ground-truth-types'
|
||||
|
||||
interface ReconstructionPanelProps {
|
||||
cells: GridCell[]
|
||||
aspect: number
|
||||
zoom: number
|
||||
reconWidth: number
|
||||
imageRegions: ImageRegionWithState[]
|
||||
drawingRegion: boolean
|
||||
dragStart: { x: number; y: number } | null
|
||||
dragEnd: { x: number; y: number } | null
|
||||
panelRef: React.RefObject<HTMLDivElement | null>
|
||||
reconRef: React.RefObject<HTMLDivElement | null>
|
||||
onScroll: () => void
|
||||
onToggleDrawing: () => void
|
||||
onMouseDown: (e: React.MouseEvent<HTMLDivElement>) => void
|
||||
onMouseMove: (e: React.MouseEvent<HTMLDivElement>) => void
|
||||
onMouseUp: () => void
|
||||
}
|
||||
|
||||
export function ReconstructionPanel({
|
||||
cells, aspect, zoom, reconWidth, imageRegions, drawingRegion,
|
||||
dragStart, dragEnd, panelRef, reconRef,
|
||||
onScroll, onToggleDrawing, onMouseDown, onMouseMove, onMouseUp,
|
||||
}: ReconstructionPanelProps) {
|
||||
return (
|
||||
<div className="border rounded-lg dark:border-gray-700 overflow-hidden flex flex-col">
|
||||
<div className="px-3 py-1.5 bg-gray-50 dark:bg-gray-800 text-sm font-medium text-gray-600 dark:text-gray-400 border-b dark:border-gray-700 flex items-center justify-between">
|
||||
<span>Rekonstruktion</span>
|
||||
<button
|
||||
onClick={onToggleDrawing}
|
||||
className={`text-xs px-2 py-0.5 rounded ${drawingRegion ? 'bg-indigo-600 text-white' : 'bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-400'}`}
|
||||
>
|
||||
{drawingRegion ? 'Region zeichnen...' : '+ Region'}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="flex-1 overflow-auto"
|
||||
onScroll={onScroll}
|
||||
>
|
||||
<div style={{ width: `${zoom}%`, minWidth: '100%' }}>
|
||||
{/* Reconstruction container */}
|
||||
<div
|
||||
ref={reconRef}
|
||||
className="relative bg-white"
|
||||
style={{
|
||||
paddingBottom: `${aspect * 100}%`,
|
||||
cursor: drawingRegion ? 'crosshair' : 'default',
|
||||
}}
|
||||
onMouseDown={onMouseDown}
|
||||
onMouseMove={onMouseMove}
|
||||
onMouseUp={onMouseUp}
|
||||
>
|
||||
{/* Row separator lines -- derive from cells */}
|
||||
<RowSeparators cells={cells} />
|
||||
|
||||
{/* Cell texts -- black on white, font size derived from cell height */}
|
||||
{cells.map(cell => {
|
||||
if (!cell.bbox_pct || !cell.text) return null
|
||||
const containerH = reconWidth * aspect
|
||||
const cellHeightPx = containerH * (cell.bbox_pct.h / 100)
|
||||
const fontSize = Math.max(6, cellHeightPx * 0.7)
|
||||
return (
|
||||
<span
|
||||
key={cell.cell_id}
|
||||
className="absolute leading-none overflow-hidden whitespace-nowrap"
|
||||
style={{
|
||||
left: `${cell.bbox_pct.x}%`,
|
||||
top: `${cell.bbox_pct.y}%`,
|
||||
width: `${cell.bbox_pct.w}%`,
|
||||
height: `${cell.bbox_pct.h}%`,
|
||||
color: '#1a1a1a',
|
||||
fontSize: `${fontSize}px`,
|
||||
fontWeight: cell.is_bold ? 'bold' : 'normal',
|
||||
fontFamily: "'Liberation Sans', 'DejaVu Sans', Arial, sans-serif",
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '0 1px',
|
||||
}}
|
||||
title={`${cell.cell_id}: ${cell.text}`}
|
||||
>
|
||||
{cell.text}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Generated images at region positions */}
|
||||
{imageRegions.map((region, i) => (
|
||||
<div
|
||||
key={`region-${i}`}
|
||||
className="absolute border-2 border-dashed border-indigo-400"
|
||||
style={{
|
||||
left: `${region.bbox_pct.x}%`,
|
||||
top: `${region.bbox_pct.y}%`,
|
||||
width: `${region.bbox_pct.w}%`,
|
||||
height: `${region.bbox_pct.h}%`,
|
||||
}}
|
||||
>
|
||||
{region.image_b64 ? (
|
||||
<img src={region.image_b64} alt={region.description} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center bg-indigo-50/50 text-indigo-400 text-[0.5em]">
|
||||
{region.generating ? '...' : `Bild ${i + 1}`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Drawing rectangle */}
|
||||
{dragStart && dragEnd && (
|
||||
<div
|
||||
className="absolute border-2 border-dashed border-red-500 bg-red-100/20 pointer-events-none"
|
||||
style={{
|
||||
left: `${Math.min(dragStart.x, dragEnd.x)}%`,
|
||||
top: `${Math.min(dragStart.y, dragEnd.y)}%`,
|
||||
width: `${Math.abs(dragEnd.x - dragStart.x)}%`,
|
||||
height: `${Math.abs(dragEnd.y - dragStart.y)}%`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Row separator lines derived from first-column cells */
|
||||
function RowSeparators({ cells }: { cells: GridCell[] }) {
|
||||
const rowYs = new Set<number>()
|
||||
for (const cell of cells) {
|
||||
if (cell.col_index === 0 && cell.bbox_pct) {
|
||||
rowYs.add(cell.bbox_pct.y)
|
||||
}
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{Array.from(rowYs).map((y, i) => (
|
||||
<div
|
||||
key={`row-${i}`}
|
||||
className="absolute left-0 right-0"
|
||||
style={{
|
||||
top: `${y}%`,
|
||||
height: '1px',
|
||||
backgroundColor: 'rgba(0,0,0,0.06)',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useRef } from 'react'
|
||||
import type { StructureBox, StructureGraphic } from '@/app/(admin)/ai/ocr-kombi/types'
|
||||
import type { EditableCell } from './StepReconstructionTypes'
|
||||
import { colTypeColor } from './StepReconstructionTypes'
|
||||
import { StructureLayer } from './StructureLayer'
|
||||
|
||||
interface ReconstructionSimpleViewProps {
|
||||
cells: EditableCell[]
|
||||
dewarpedUrl: string
|
||||
zoom: number
|
||||
imageNaturalSize: { w: number; h: number } | null
|
||||
imageNaturalH: number
|
||||
emptyCellIds: Set<string>
|
||||
showEmptyHighlight: boolean
|
||||
structureBoxes: StructureBox[]
|
||||
structureGraphics: StructureGraphic[]
|
||||
showStructure: boolean
|
||||
onTextChange: (cellId: string, newText: string) => void
|
||||
onKeyDown: (e: React.KeyboardEvent, cellId: string) => void
|
||||
onResetCell: (cellId: string) => void
|
||||
onImageLoad: () => void
|
||||
getDisplayText: (cell: EditableCell) => string
|
||||
isEdited: (cell: EditableCell) => boolean
|
||||
imageRef: React.RefObject<HTMLImageElement | null>
|
||||
}
|
||||
|
||||
export function ReconstructionSimpleView({
|
||||
cells,
|
||||
dewarpedUrl,
|
||||
zoom,
|
||||
imageNaturalSize,
|
||||
imageNaturalH,
|
||||
emptyCellIds,
|
||||
showEmptyHighlight,
|
||||
structureBoxes,
|
||||
structureGraphics,
|
||||
showStructure,
|
||||
onTextChange,
|
||||
onKeyDown,
|
||||
onResetCell,
|
||||
onImageLoad,
|
||||
getDisplayText,
|
||||
isEdited,
|
||||
imageRef,
|
||||
}: ReconstructionSimpleViewProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Font size based on image natural height (not container) scaled by zoom
|
||||
const getFontSize = useCallback((bboxH: number): number => {
|
||||
const baseH = imageNaturalH || 800
|
||||
const px = (bboxH / 100) * baseH * 0.55
|
||||
return Math.max(8, Math.min(18, px * (zoom / 100)))
|
||||
}, [imageNaturalH, zoom])
|
||||
|
||||
return (
|
||||
<div className="border rounded-lg overflow-auto dark:border-gray-700 bg-gray-100 dark:bg-gray-900" style={{ maxHeight: '75vh' }}>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative inline-block"
|
||||
style={{ transform: `scale(${zoom / 100})`, transformOrigin: 'top left' }}
|
||||
>
|
||||
{/* Background image at reduced opacity */}
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
ref={imageRef}
|
||||
src={dewarpedUrl}
|
||||
alt="Dewarped"
|
||||
className="block"
|
||||
style={{ opacity: 0.3 }}
|
||||
onLoad={onImageLoad}
|
||||
/>
|
||||
|
||||
{/* Structure elements (boxes, graphics) */}
|
||||
{imageNaturalSize && (
|
||||
<StructureLayer
|
||||
boxes={structureBoxes}
|
||||
graphics={structureGraphics}
|
||||
imgW={imageNaturalSize.w}
|
||||
imgH={imageNaturalSize.h}
|
||||
show={showStructure}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Empty field markers */}
|
||||
{showEmptyHighlight && cells
|
||||
.filter(c => emptyCellIds.has(c.cellId))
|
||||
.map(cell => (
|
||||
<div
|
||||
key={`empty-${cell.cellId}`}
|
||||
className="absolute border-2 border-dashed border-red-400/60 rounded pointer-events-none"
|
||||
style={{
|
||||
left: `${cell.bboxPct.x}%`,
|
||||
top: `${cell.bboxPct.y}%`,
|
||||
width: `${cell.bboxPct.w}%`,
|
||||
height: `${cell.bboxPct.h}%`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Editable text fields at bbox positions */}
|
||||
{cells.map((cell) => {
|
||||
const displayText = getDisplayText(cell)
|
||||
const edited = isEdited(cell)
|
||||
|
||||
return (
|
||||
<div key={cell.cellId} className="absolute group" style={{
|
||||
left: `${cell.bboxPct.x}%`,
|
||||
top: `${cell.bboxPct.y}%`,
|
||||
width: `${cell.bboxPct.w}%`,
|
||||
height: `${cell.bboxPct.h}%`,
|
||||
}}>
|
||||
<input
|
||||
id={`cell-${cell.cellId}`}
|
||||
type="text"
|
||||
value={displayText}
|
||||
onChange={(e) => onTextChange(cell.cellId, e.target.value)}
|
||||
onKeyDown={(e) => onKeyDown(e, cell.cellId)}
|
||||
className={`w-full h-full bg-transparent text-black dark:text-white border px-0.5 outline-none transition-colors ${
|
||||
colTypeColor(cell.colType)
|
||||
} ${edited ? 'border-green-500 bg-green-50/30 dark:bg-green-900/20' : ''}`}
|
||||
style={{
|
||||
fontSize: `${getFontSize(cell.bboxPct.h)}px`,
|
||||
lineHeight: '1',
|
||||
}}
|
||||
title={`${cell.cellId} (${cell.colType})`}
|
||||
/>
|
||||
{/* Per-cell reset button (X) — only shown for edited cells on hover */}
|
||||
{edited && (
|
||||
<button
|
||||
onClick={() => onResetCell(cell.cellId)}
|
||||
className="absolute -top-1 -right-1 w-4 h-4 bg-red-500 text-white rounded-full text-[9px] leading-none opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"
|
||||
title="Zuruecksetzen"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
'use client'
|
||||
|
||||
import type { EditorMode, ReconstructionStatus } from './StepReconstructionTypes'
|
||||
|
||||
interface ReconstructionToolbarProps {
|
||||
editorMode: EditorMode
|
||||
setEditorMode: (mode: EditorMode) => void
|
||||
isParentWithBoxes: boolean
|
||||
cellCount: number
|
||||
changedCount: number
|
||||
emptyCellCount: number
|
||||
showEmptyHighlight: boolean
|
||||
setShowEmptyHighlight: (v: boolean) => void
|
||||
showStructure: boolean
|
||||
setShowStructure: (v: boolean) => void
|
||||
hasStructureElements: boolean
|
||||
zoom: number
|
||||
setZoom: (fn: (z: number) => number) => void
|
||||
undoCount: number
|
||||
redoCount: number
|
||||
onUndo: () => void
|
||||
onRedo: () => void
|
||||
status: ReconstructionStatus
|
||||
onSave: () => void
|
||||
// Overlay-specific
|
||||
fontScale: number
|
||||
setFontScale: (v: number) => void
|
||||
globalBold: boolean
|
||||
setGlobalBold: (fn: (b: boolean) => boolean) => void
|
||||
imageRotation: 0 | 180
|
||||
setImageRotation: (fn: (r: 0 | 180) => 0 | 180) => void
|
||||
}
|
||||
|
||||
export function ReconstructionToolbar({
|
||||
editorMode,
|
||||
setEditorMode,
|
||||
isParentWithBoxes,
|
||||
cellCount,
|
||||
changedCount,
|
||||
emptyCellCount,
|
||||
showEmptyHighlight,
|
||||
setShowEmptyHighlight,
|
||||
showStructure,
|
||||
setShowStructure,
|
||||
hasStructureElements,
|
||||
zoom,
|
||||
setZoom,
|
||||
undoCount,
|
||||
redoCount,
|
||||
onUndo,
|
||||
onRedo,
|
||||
status,
|
||||
onSave,
|
||||
fontScale,
|
||||
setFontScale,
|
||||
globalBold,
|
||||
setGlobalBold,
|
||||
imageRotation,
|
||||
setImageRotation,
|
||||
}: ReconstructionToolbarProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-between bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Schritt 7: Rekonstruktion
|
||||
</h3>
|
||||
{/* Mode toggle */}
|
||||
<div className="flex items-center ml-2 border border-gray-300 dark:border-gray-600 rounded overflow-hidden text-xs">
|
||||
<button
|
||||
onClick={() => setEditorMode('simple')}
|
||||
className={`px-2 py-0.5 transition-colors ${
|
||||
editorMode === 'simple'
|
||||
? 'bg-teal-600 text-white'
|
||||
: 'hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-600 dark:text-gray-400'
|
||||
}`}
|
||||
>
|
||||
Einfach
|
||||
</button>
|
||||
{isParentWithBoxes && (
|
||||
<button
|
||||
onClick={() => setEditorMode('overlay')}
|
||||
className={`px-2 py-0.5 transition-colors ${
|
||||
editorMode === 'overlay'
|
||||
? 'bg-teal-600 text-white'
|
||||
: 'hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-600 dark:text-gray-400'
|
||||
}`}
|
||||
>
|
||||
Overlay
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setEditorMode('editor')}
|
||||
className={`px-2 py-0.5 transition-colors ${
|
||||
editorMode === 'editor'
|
||||
? 'bg-teal-600 text-white'
|
||||
: 'hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-600 dark:text-gray-400'
|
||||
}`}
|
||||
>
|
||||
Editor
|
||||
</button>
|
||||
</div>
|
||||
<span className="text-xs text-gray-400">
|
||||
{cellCount} Zellen · {changedCount} geaendert
|
||||
{emptyCellCount > 0 && showEmptyHighlight && (
|
||||
<span className="text-red-400 ml-1">· {emptyCellCount} leer</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Undo/Redo */}
|
||||
<button
|
||||
onClick={onUndo}
|
||||
disabled={undoCount === 0}
|
||||
className="px-2 py-1 text-xs border border-gray-300 dark:border-gray-600 rounded hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-30"
|
||||
title="Rueckgaengig (Ctrl+Z)"
|
||||
>
|
||||
↩
|
||||
</button>
|
||||
<button
|
||||
onClick={onRedo}
|
||||
disabled={redoCount === 0}
|
||||
className="px-2 py-1 text-xs border border-gray-300 dark:border-gray-600 rounded hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-30"
|
||||
title="Wiederholen (Ctrl+Shift+Z)"
|
||||
>
|
||||
↪
|
||||
</button>
|
||||
|
||||
<div className="w-px h-5 bg-gray-300 dark:bg-gray-600 mx-1" />
|
||||
|
||||
{/* Overlay-specific toolbar */}
|
||||
{editorMode === 'overlay' && (
|
||||
<>
|
||||
<label className="flex items-center gap-1 text-xs text-gray-600 dark:text-gray-400">
|
||||
Schrift
|
||||
<input
|
||||
type="range" min={30} max={120} value={Math.round(fontScale * 100)}
|
||||
onChange={e => setFontScale(Number(e.target.value) / 100)}
|
||||
className="w-20 h-1 accent-teal-600"
|
||||
/>
|
||||
<span className="w-8 text-right font-mono">{Math.round(fontScale * 100)}%</span>
|
||||
</label>
|
||||
<button
|
||||
onClick={() => setGlobalBold(b => !b)}
|
||||
className={`px-2 py-1 text-xs rounded border transition-colors font-bold ${
|
||||
globalBold
|
||||
? 'bg-teal-600 text-white border-teal-600'
|
||||
: 'bg-white dark:bg-gray-700 text-gray-600 dark:text-gray-400 border-gray-300 dark:border-gray-600'
|
||||
}`}
|
||||
>
|
||||
B
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setImageRotation(r => r === 0 ? 180 : 0)}
|
||||
className={`px-2 py-1 text-xs rounded border transition-colors ${
|
||||
imageRotation === 180
|
||||
? 'bg-teal-600 text-white border-teal-600'
|
||||
: 'bg-white dark:bg-gray-700 text-gray-600 dark:text-gray-400 border-gray-300 dark:border-gray-600'
|
||||
}`}
|
||||
title="Bild 180 Grad drehen"
|
||||
>
|
||||
180°
|
||||
</button>
|
||||
{hasStructureElements && (
|
||||
<button
|
||||
onClick={() => setShowStructure(!showStructure)}
|
||||
className={`px-2 py-1 text-xs border rounded transition-colors ${
|
||||
showStructure
|
||||
? 'border-violet-300 bg-violet-50 text-violet-600 dark:border-violet-700 dark:bg-violet-900/30 dark:text-violet-400'
|
||||
: 'border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
title="Strukturelemente anzeigen"
|
||||
>
|
||||
Struktur
|
||||
</button>
|
||||
)}
|
||||
<div className="w-px h-5 bg-gray-300 dark:bg-gray-600 mx-1" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Non-overlay controls */}
|
||||
{editorMode !== 'overlay' && (
|
||||
<>
|
||||
{/* Empty field toggle */}
|
||||
<button
|
||||
onClick={() => setShowEmptyHighlight(!showEmptyHighlight)}
|
||||
className={`px-2 py-1 text-xs border rounded transition-colors ${
|
||||
showEmptyHighlight
|
||||
? 'border-red-300 bg-red-50 text-red-600 dark:border-red-700 dark:bg-red-900/30 dark:text-red-400'
|
||||
: 'border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
title="Leere Pflichtfelder markieren"
|
||||
>
|
||||
Leer
|
||||
</button>
|
||||
|
||||
{/* Structure toggle */}
|
||||
{hasStructureElements && (
|
||||
<button
|
||||
onClick={() => setShowStructure(!showStructure)}
|
||||
className={`px-2 py-1 text-xs border rounded transition-colors ${
|
||||
showStructure
|
||||
? 'border-violet-300 bg-violet-50 text-violet-600 dark:border-violet-700 dark:bg-violet-900/30 dark:text-violet-400'
|
||||
: 'border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
title="Strukturelemente anzeigen"
|
||||
>
|
||||
Struktur
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="w-px h-5 bg-gray-300 dark:bg-gray-600 mx-1" />
|
||||
|
||||
{/* Zoom controls */}
|
||||
<button
|
||||
onClick={() => setZoom(z => Math.max(50, z - 25))}
|
||||
className="px-2 py-1 text-xs border border-gray-300 dark:border-gray-600 rounded hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<span className="text-xs text-gray-500 w-10 text-center">{zoom}%</span>
|
||||
<button
|
||||
onClick={() => setZoom(z => Math.min(200, z + 25))}
|
||||
className="px-2 py-1 text-xs border border-gray-300 dark:border-gray-600 rounded hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setZoom(() => 100)}
|
||||
className="px-2 py-1 text-xs border border-gray-300 dark:border-gray-600 rounded hover:bg-gray-50 dark:hover:bg-gray-700"
|
||||
>
|
||||
Fit
|
||||
</button>
|
||||
|
||||
<div className="w-px h-5 bg-gray-300 dark:bg-gray-600 mx-1" />
|
||||
</>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={onSave}
|
||||
disabled={status === 'saving'}
|
||||
className="px-4 py-1.5 text-xs bg-teal-600 text-white rounded-lg hover:bg-teal-700 disabled:opacity-50 transition-colors font-medium"
|
||||
>
|
||||
{status === 'saving' ? 'Speichert...' : 'Speichern'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,278 +1,21 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import type {
|
||||
GridCell, ColumnMeta, ImageRegion, ImageStyle,
|
||||
} from '@/app/(admin)/ai/ocr-kombi/types'
|
||||
import { IMAGE_STYLES as STYLES } from '@/app/(admin)/ai/ocr-kombi/types'
|
||||
|
||||
const KLAUSUR_API = '/klausur-api'
|
||||
|
||||
const COL_TYPE_COLORS: Record<string, string> = {
|
||||
column_en: '#3b82f6',
|
||||
column_de: '#22c55e',
|
||||
column_example: '#f97316',
|
||||
column_text: '#a855f7',
|
||||
page_ref: '#06b6d4',
|
||||
column_marker: '#6b7280',
|
||||
}
|
||||
|
||||
interface StepGroundTruthProps {
|
||||
sessionId: string | null
|
||||
onNext: () => void
|
||||
}
|
||||
|
||||
interface SessionData {
|
||||
cells: GridCell[]
|
||||
columnsUsed: ColumnMeta[]
|
||||
imageWidth: number
|
||||
imageHeight: number
|
||||
originalImageUrl: string
|
||||
}
|
||||
import type { StepGroundTruthProps, ImageRegionWithState } from './ground-truth-types'
|
||||
import { useGroundTruthSession } from './useGroundTruthSession'
|
||||
import { ReconstructionPanel } from './ReconstructionPanel'
|
||||
import { ImageRegionsPanel } from './ImageRegionsPanel'
|
||||
import { ValidationPanel } from './ValidationPanel'
|
||||
|
||||
export function StepGroundTruth({ sessionId, onNext }: StepGroundTruthProps) {
|
||||
const [status, setStatus] = useState<'loading' | 'ready' | 'saving' | 'saved' | 'error'>('loading')
|
||||
const [error, setError] = useState('')
|
||||
const [session, setSession] = useState<SessionData | null>(null)
|
||||
const [imageRegions, setImageRegions] = useState<(ImageRegion & { generating?: boolean })[]>([])
|
||||
const [detecting, setDetecting] = useState(false)
|
||||
const [zoom, setZoom] = useState(100)
|
||||
const [syncScroll, setSyncScroll] = useState(true)
|
||||
const [notes, setNotes] = useState('')
|
||||
const [score, setScore] = useState<number | null>(null)
|
||||
const [drawingRegion, setDrawingRegion] = useState(false)
|
||||
const [dragStart, setDragStart] = useState<{ x: number; y: number } | null>(null)
|
||||
const [dragEnd, setDragEnd] = useState<{ x: number; y: number } | null>(null)
|
||||
const [isGroundTruth, setIsGroundTruth] = useState(false)
|
||||
const [gtSaving, setGtSaving] = useState(false)
|
||||
const [gtMessage, setGtMessage] = useState('')
|
||||
const s = useGroundTruthSession(sessionId)
|
||||
|
||||
const leftPanelRef = useRef<HTMLDivElement>(null)
|
||||
const rightPanelRef = useRef<HTMLDivElement>(null)
|
||||
const reconRef = useRef<HTMLDivElement>(null)
|
||||
const [reconWidth, setReconWidth] = useState(0)
|
||||
|
||||
// Track reconstruction container width for font size calculation
|
||||
useEffect(() => {
|
||||
const el = reconRef.current
|
||||
if (!el) return
|
||||
const obs = new ResizeObserver(entries => {
|
||||
for (const entry of entries) setReconWidth(entry.contentRect.width)
|
||||
})
|
||||
obs.observe(el)
|
||||
return () => obs.disconnect()
|
||||
}, [session])
|
||||
|
||||
// Load session data
|
||||
useEffect(() => {
|
||||
if (!sessionId) return
|
||||
loadSessionData()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sessionId])
|
||||
|
||||
const loadSessionData = async () => {
|
||||
if (!sessionId) return
|
||||
setStatus('loading')
|
||||
try {
|
||||
const resp = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}`)
|
||||
if (!resp.ok) throw new Error(`Failed to load session: ${resp.status}`)
|
||||
const data = await resp.json()
|
||||
|
||||
const wordResult = data.word_result || {}
|
||||
setSession({
|
||||
cells: wordResult.cells || [],
|
||||
columnsUsed: wordResult.columns_used || [],
|
||||
imageWidth: wordResult.image_width || data.image_width || 800,
|
||||
imageHeight: wordResult.image_height || data.image_height || 600,
|
||||
originalImageUrl: data.original_image_url
|
||||
? `${KLAUSUR_API}${data.original_image_url}`
|
||||
: `${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/image/original`,
|
||||
})
|
||||
|
||||
// Check if session has ground truth reference
|
||||
const gt = data.ground_truth
|
||||
setIsGroundTruth(!!gt?.build_grid_reference)
|
||||
|
||||
// Load existing validation data
|
||||
const valResp = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/reconstruction/validation`)
|
||||
if (valResp.ok) {
|
||||
const valData = await valResp.json()
|
||||
const validation = valData.validation
|
||||
if (validation) {
|
||||
setImageRegions(validation.image_regions || [])
|
||||
setNotes(validation.notes || '')
|
||||
setScore(validation.score ?? null)
|
||||
}
|
||||
}
|
||||
|
||||
setStatus('ready')
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
setStatus('error')
|
||||
}
|
||||
const handleUpdateRegion = (index: number, update: Partial<ImageRegionWithState>) => {
|
||||
s.setImageRegions(prev => prev.map((r, j) =>
|
||||
j === index ? { ...r, ...update } : r
|
||||
))
|
||||
}
|
||||
|
||||
// Sync scroll between panels
|
||||
const handleScroll = useCallback((source: 'left' | 'right') => {
|
||||
if (!syncScroll) return
|
||||
const from = source === 'left' ? leftPanelRef.current : rightPanelRef.current
|
||||
const to = source === 'left' ? rightPanelRef.current : leftPanelRef.current
|
||||
if (from && to) {
|
||||
to.scrollTop = from.scrollTop
|
||||
to.scrollLeft = from.scrollLeft
|
||||
}
|
||||
}, [syncScroll])
|
||||
|
||||
// Detect images via VLM
|
||||
const handleDetectImages = async () => {
|
||||
if (!sessionId) return
|
||||
setDetecting(true)
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/reconstruction/detect-images`,
|
||||
{ method: 'POST' }
|
||||
)
|
||||
if (!resp.ok) throw new Error(`Detection failed: ${resp.status}`)
|
||||
const data = await resp.json()
|
||||
setImageRegions(data.regions || [])
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setDetecting(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Generate image for a region
|
||||
const handleGenerateImage = async (index: number) => {
|
||||
if (!sessionId) return
|
||||
const region = imageRegions[index]
|
||||
if (!region) return
|
||||
|
||||
setImageRegions(prev => prev.map((r, i) => i === index ? { ...r, generating: true } : r))
|
||||
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/reconstruction/generate-image`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
region_index: index,
|
||||
prompt: region.prompt,
|
||||
style: region.style,
|
||||
}),
|
||||
}
|
||||
)
|
||||
if (!resp.ok) throw new Error(`Generation failed: ${resp.status}`)
|
||||
const data = await resp.json()
|
||||
|
||||
setImageRegions(prev => prev.map((r, i) =>
|
||||
i === index ? { ...r, image_b64: data.image_b64, generating: false } : r
|
||||
))
|
||||
} catch (e) {
|
||||
setImageRegions(prev => prev.map((r, i) => i === index ? { ...r, generating: false } : r))
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
}
|
||||
|
||||
// Save validation
|
||||
const handleSave = async () => {
|
||||
if (!sessionId) {
|
||||
setError('Keine Session-ID vorhanden')
|
||||
return
|
||||
}
|
||||
setStatus('saving')
|
||||
setError('')
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/reconstruction/validate`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ notes, score: score ?? 0 }),
|
||||
}
|
||||
)
|
||||
if (!resp.ok) {
|
||||
const body = await resp.text().catch(() => '')
|
||||
throw new Error(`Speichern fehlgeschlagen (${resp.status}): ${body}`)
|
||||
}
|
||||
setStatus('saved')
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
setStatus('ready')
|
||||
}
|
||||
}
|
||||
|
||||
// Mark/update ground truth reference
|
||||
const handleMarkGroundTruth = async () => {
|
||||
if (!sessionId) return
|
||||
setGtSaving(true)
|
||||
setGtMessage('')
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/mark-ground-truth?pipeline=ocr-pipeline`,
|
||||
{ method: 'POST' }
|
||||
)
|
||||
if (!resp.ok) {
|
||||
const body = await resp.text().catch(() => '')
|
||||
throw new Error(`Ground Truth fehlgeschlagen (${resp.status}): ${body}`)
|
||||
}
|
||||
const data = await resp.json()
|
||||
setIsGroundTruth(true)
|
||||
setGtMessage(`Ground Truth gespeichert (${data.cells_saved} Zellen)`)
|
||||
setTimeout(() => setGtMessage(''), 5000)
|
||||
} catch (e) {
|
||||
setGtMessage(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setGtSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle manual region drawing on reconstruction
|
||||
const handleReconMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!drawingRegion) return
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
const x = ((e.clientX - rect.left) / rect.width) * 100
|
||||
const y = ((e.clientY - rect.top) / rect.height) * 100
|
||||
setDragStart({ x, y })
|
||||
setDragEnd({ x, y })
|
||||
}
|
||||
|
||||
const handleReconMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!dragStart) return
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
const x = ((e.clientX - rect.left) / rect.width) * 100
|
||||
const y = ((e.clientY - rect.top) / rect.height) * 100
|
||||
setDragEnd({ x, y })
|
||||
}
|
||||
|
||||
const handleReconMouseUp = () => {
|
||||
if (!dragStart || !dragEnd) return
|
||||
const x = Math.min(dragStart.x, dragEnd.x)
|
||||
const y = Math.min(dragStart.y, dragEnd.y)
|
||||
const w = Math.abs(dragEnd.x - dragStart.x)
|
||||
const h = Math.abs(dragEnd.y - dragStart.y)
|
||||
|
||||
if (w > 2 && h > 2) {
|
||||
setImageRegions(prev => [...prev, {
|
||||
bbox_pct: { x, y, w, h },
|
||||
prompt: '',
|
||||
description: 'Manually selected region',
|
||||
image_b64: null,
|
||||
style: 'educational' as ImageStyle,
|
||||
}])
|
||||
}
|
||||
|
||||
setDragStart(null)
|
||||
setDragEnd(null)
|
||||
setDrawingRegion(false)
|
||||
}
|
||||
|
||||
const handleRemoveRegion = (index: number) => {
|
||||
setImageRegions(prev => prev.filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
if (status === 'loading') {
|
||||
if (s.status === 'loading') {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-teal-500 mr-3" />
|
||||
@@ -281,20 +24,20 @@ export function StepGroundTruth({ sessionId, onNext }: StepGroundTruthProps) {
|
||||
)
|
||||
}
|
||||
|
||||
if (status === 'error' && !session) {
|
||||
if (s.status === 'error' && !s.session) {
|
||||
return (
|
||||
<div className="text-center py-16">
|
||||
<p className="text-red-500">{error}</p>
|
||||
<button onClick={loadSessionData} className="mt-4 px-4 py-2 bg-teal-600 text-white rounded hover:bg-teal-700">
|
||||
<p className="text-red-500">{s.error}</p>
|
||||
<button onClick={s.loadSessionData} className="mt-4 px-4 py-2 bg-teal-600 text-white rounded hover:bg-teal-700">
|
||||
Erneut laden
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!session) return null
|
||||
if (!s.session) return null
|
||||
|
||||
const aspect = session.imageHeight / session.imageWidth
|
||||
const aspect = s.session.imageHeight / s.session.imageWidth
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -305,33 +48,33 @@ export function StepGroundTruth({ sessionId, onNext }: StepGroundTruthProps) {
|
||||
</h3>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={handleDetectImages}
|
||||
disabled={detecting}
|
||||
onClick={s.handleDetectImages}
|
||||
disabled={s.detecting}
|
||||
className="px-3 py-1.5 text-sm bg-indigo-600 text-white rounded hover:bg-indigo-700 disabled:opacity-50"
|
||||
>
|
||||
{detecting ? 'Erkennung laeuft...' : 'Bilder erkennen'}
|
||||
{s.detecting ? 'Erkennung laeuft...' : 'Bilder erkennen'}
|
||||
</button>
|
||||
<label className="flex items-center gap-1.5 text-sm text-gray-600 dark:text-gray-400">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={syncScroll}
|
||||
onChange={e => setSyncScroll(e.target.checked)}
|
||||
checked={s.syncScroll}
|
||||
onChange={e => s.setSyncScroll(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
Sync Scroll
|
||||
</label>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button onClick={() => setZoom(z => Math.max(50, z - 25))} className="px-2 py-1 text-sm border rounded dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700">-</button>
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400 w-12 text-center">{zoom}%</span>
|
||||
<button onClick={() => setZoom(z => Math.min(200, z + 25))} className="px-2 py-1 text-sm border rounded dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700">+</button>
|
||||
<button onClick={() => s.setZoom(z => Math.max(50, z - 25))} className="px-2 py-1 text-sm border rounded dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700">-</button>
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400 w-12 text-center">{s.zoom}%</span>
|
||||
<button onClick={() => s.setZoom(z => Math.min(200, z + 25))} className="px-2 py-1 text-sm border rounded dark:border-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700">+</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
{s.error && (
|
||||
<div className="p-2 bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 text-sm rounded">
|
||||
{error}
|
||||
<button onClick={() => setError('')} className="ml-2 underline">Schliessen</button>
|
||||
{s.error}
|
||||
<button onClick={() => s.setError('')} className="ml-2 underline">Schliessen</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -343,13 +86,13 @@ export function StepGroundTruth({ sessionId, onNext }: StepGroundTruthProps) {
|
||||
Original
|
||||
</div>
|
||||
<div
|
||||
ref={leftPanelRef}
|
||||
ref={s.leftPanelRef}
|
||||
className="flex-1 overflow-auto"
|
||||
onScroll={() => handleScroll('left')}
|
||||
onScroll={() => s.handleScroll('left')}
|
||||
>
|
||||
<div style={{ width: `${zoom}%`, minWidth: '100%' }}>
|
||||
<div style={{ width: `${s.zoom}%`, minWidth: '100%' }}>
|
||||
<img
|
||||
src={session.originalImageUrl}
|
||||
src={s.session.originalImageUrl}
|
||||
alt="Original"
|
||||
className="w-full h-auto"
|
||||
draggable={false}
|
||||
@@ -359,282 +102,47 @@ export function StepGroundTruth({ sessionId, onNext }: StepGroundTruthProps) {
|
||||
</div>
|
||||
|
||||
{/* Right: Reconstruction */}
|
||||
<div className="border rounded-lg dark:border-gray-700 overflow-hidden flex flex-col">
|
||||
<div className="px-3 py-1.5 bg-gray-50 dark:bg-gray-800 text-sm font-medium text-gray-600 dark:text-gray-400 border-b dark:border-gray-700 flex items-center justify-between">
|
||||
<span>Rekonstruktion</span>
|
||||
<button
|
||||
onClick={() => setDrawingRegion(!drawingRegion)}
|
||||
className={`text-xs px-2 py-0.5 rounded ${drawingRegion ? 'bg-indigo-600 text-white' : 'bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-400'}`}
|
||||
>
|
||||
{drawingRegion ? 'Region zeichnen...' : '+ Region'}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
ref={rightPanelRef}
|
||||
className="flex-1 overflow-auto"
|
||||
onScroll={() => handleScroll('right')}
|
||||
>
|
||||
<div style={{ width: `${zoom}%`, minWidth: '100%' }}>
|
||||
{/* Reconstruction container */}
|
||||
<div
|
||||
ref={reconRef}
|
||||
className="relative bg-white"
|
||||
style={{
|
||||
paddingBottom: `${aspect * 100}%`,
|
||||
cursor: drawingRegion ? 'crosshair' : 'default',
|
||||
}}
|
||||
onMouseDown={handleReconMouseDown}
|
||||
onMouseMove={handleReconMouseMove}
|
||||
onMouseUp={handleReconMouseUp}
|
||||
>
|
||||
{/* Row separator lines — derive from cells */}
|
||||
{(() => {
|
||||
const rowYs = new Set<number>()
|
||||
for (const cell of session.cells) {
|
||||
if (cell.col_index === 0 && cell.bbox_pct) {
|
||||
rowYs.add(cell.bbox_pct.y)
|
||||
}
|
||||
}
|
||||
return Array.from(rowYs).map((y, i) => (
|
||||
<div
|
||||
key={`row-${i}`}
|
||||
className="absolute left-0 right-0"
|
||||
style={{
|
||||
top: `${y}%`,
|
||||
height: '1px',
|
||||
backgroundColor: 'rgba(0,0,0,0.06)',
|
||||
}}
|
||||
/>
|
||||
))
|
||||
})()}
|
||||
|
||||
{/* Cell texts — black on white, font size derived from cell height */}
|
||||
{session.cells.map(cell => {
|
||||
if (!cell.bbox_pct || !cell.text) return null
|
||||
// Container height in px = reconWidth * aspect
|
||||
// Cell height in px = containerHeightPx * (bbox_pct.h / 100)
|
||||
// Font size ≈ 70% of cell height
|
||||
const containerH = reconWidth * aspect
|
||||
const cellHeightPx = containerH * (cell.bbox_pct.h / 100)
|
||||
const fontSize = Math.max(6, cellHeightPx * 0.7)
|
||||
return (
|
||||
<span
|
||||
key={cell.cell_id}
|
||||
className="absolute leading-none overflow-hidden whitespace-nowrap"
|
||||
style={{
|
||||
left: `${cell.bbox_pct.x}%`,
|
||||
top: `${cell.bbox_pct.y}%`,
|
||||
width: `${cell.bbox_pct.w}%`,
|
||||
height: `${cell.bbox_pct.h}%`,
|
||||
color: '#1a1a1a',
|
||||
fontSize: `${fontSize}px`,
|
||||
fontWeight: cell.is_bold ? 'bold' : 'normal',
|
||||
fontFamily: "'Liberation Sans', 'DejaVu Sans', Arial, sans-serif",
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '0 1px',
|
||||
}}
|
||||
title={`${cell.cell_id}: ${cell.text}`}
|
||||
>
|
||||
{cell.text}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Generated images at region positions */}
|
||||
{imageRegions.map((region, i) => (
|
||||
<div
|
||||
key={`region-${i}`}
|
||||
className="absolute border-2 border-dashed border-indigo-400"
|
||||
style={{
|
||||
left: `${region.bbox_pct.x}%`,
|
||||
top: `${region.bbox_pct.y}%`,
|
||||
width: `${region.bbox_pct.w}%`,
|
||||
height: `${region.bbox_pct.h}%`,
|
||||
}}
|
||||
>
|
||||
{region.image_b64 ? (
|
||||
<img src={region.image_b64} alt={region.description} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center bg-indigo-50/50 text-indigo-400 text-[0.5em]">
|
||||
{region.generating ? '...' : `Bild ${i + 1}`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Drawing rectangle */}
|
||||
{dragStart && dragEnd && (
|
||||
<div
|
||||
className="absolute border-2 border-dashed border-red-500 bg-red-100/20 pointer-events-none"
|
||||
style={{
|
||||
left: `${Math.min(dragStart.x, dragEnd.x)}%`,
|
||||
top: `${Math.min(dragStart.y, dragEnd.y)}%`,
|
||||
width: `${Math.abs(dragEnd.x - dragStart.x)}%`,
|
||||
height: `${Math.abs(dragEnd.y - dragStart.y)}%`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ReconstructionPanel
|
||||
cells={s.session.cells}
|
||||
aspect={aspect}
|
||||
zoom={s.zoom}
|
||||
reconWidth={s.reconWidth}
|
||||
imageRegions={s.imageRegions}
|
||||
drawingRegion={s.drawingRegion}
|
||||
dragStart={s.dragStart}
|
||||
dragEnd={s.dragEnd}
|
||||
panelRef={s.rightPanelRef}
|
||||
reconRef={s.reconRef}
|
||||
onScroll={() => s.handleScroll('right')}
|
||||
onToggleDrawing={() => s.setDrawingRegion(!s.drawingRegion)}
|
||||
onMouseDown={s.handleReconMouseDown}
|
||||
onMouseMove={s.handleReconMouseMove}
|
||||
onMouseUp={s.handleReconMouseUp}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Image regions panel */}
|
||||
{imageRegions.length > 0 && (
|
||||
<div className="border rounded-lg dark:border-gray-700 p-4">
|
||||
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||
Bildbereiche ({imageRegions.length} gefunden)
|
||||
</h4>
|
||||
<div className="space-y-3">
|
||||
{imageRegions.map((region, i) => (
|
||||
<div key={i} className="flex items-start gap-3 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
||||
{/* Preview thumbnail */}
|
||||
<div className="w-16 h-16 flex-shrink-0 border rounded dark:border-gray-600 overflow-hidden bg-white">
|
||||
{region.image_b64 ? (
|
||||
<img src={region.image_b64} alt="" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-gray-400 text-xs">
|
||||
{Math.round(region.bbox_pct.w)}x{Math.round(region.bbox_pct.h)}%
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Image regions editor */}
|
||||
<ImageRegionsPanel
|
||||
imageRegions={s.imageRegions}
|
||||
onUpdateRegion={handleUpdateRegion}
|
||||
onGenerateImage={s.handleGenerateImage}
|
||||
onRemoveRegion={s.handleRemoveRegion}
|
||||
/>
|
||||
|
||||
{/* Prompt + controls */}
|
||||
<div className="flex-1 min-w-0 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 flex-shrink-0">
|
||||
Bereich {i + 1}:
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={region.prompt}
|
||||
onChange={e => {
|
||||
setImageRegions(prev => prev.map((r, j) =>
|
||||
j === i ? { ...r, prompt: e.target.value } : r
|
||||
))
|
||||
}}
|
||||
placeholder="Beschreibung / Prompt..."
|
||||
className="flex-1 text-sm px-2 py-1 border rounded dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={region.style}
|
||||
onChange={e => {
|
||||
setImageRegions(prev => prev.map((r, j) =>
|
||||
j === i ? { ...r, style: e.target.value as ImageStyle } : r
|
||||
))
|
||||
}}
|
||||
className="text-sm px-2 py-1 border rounded dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
>
|
||||
{STYLES.map(s => (
|
||||
<option key={s.value} value={s.value}>{s.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={() => handleGenerateImage(i)}
|
||||
disabled={!!region.generating || !region.prompt}
|
||||
className="px-3 py-1 text-sm bg-teal-600 text-white rounded hover:bg-teal-700 disabled:opacity-50"
|
||||
>
|
||||
{region.generating ? 'Generiere...' : 'Generieren'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleRemoveRegion(i)}
|
||||
className="px-2 py-1 text-sm text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded"
|
||||
>
|
||||
Entfernen
|
||||
</button>
|
||||
</div>
|
||||
{region.description && region.description !== region.prompt && (
|
||||
<p className="text-xs text-gray-400">{region.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notes and score */}
|
||||
<div className="border rounded-lg dark:border-gray-700 p-4 space-y-3">
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Bewertung (1-10):
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={10}
|
||||
value={score ?? ''}
|
||||
onChange={e => setScore(e.target.value ? parseInt(e.target.value) : null)}
|
||||
className="w-20 text-sm px-2 py-1 border rounded dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
/>
|
||||
<div className="flex gap-1">
|
||||
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(v => (
|
||||
<button
|
||||
key={v}
|
||||
onClick={() => setScore(v)}
|
||||
className={`w-7 h-7 text-xs rounded ${score === v ? 'bg-teal-600 text-white' : 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600'}`}
|
||||
>
|
||||
{v}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-700 dark:text-gray-300 block mb-1">
|
||||
Notizen:
|
||||
</label>
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={e => setNotes(e.target.value)}
|
||||
rows={3}
|
||||
placeholder="Anmerkungen zur Qualitaet der Rekonstruktion..."
|
||||
className="w-full text-sm px-3 py-2 border rounded dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions — sticky bottom bar */}
|
||||
<div className="sticky bottom-0 bg-white dark:bg-gray-900 border-t dark:border-gray-700 py-3 px-1 -mx-1 flex items-center justify-between">
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{status === 'saved' && <span className="text-green-600 dark:text-green-400">Validierung gespeichert</span>}
|
||||
{status === 'saving' && <span>Speichere...</span>}
|
||||
{gtMessage && (
|
||||
<span className={gtMessage.includes('fehlgeschlagen') ? 'text-red-500' : 'text-amber-600 dark:text-amber-400'}>
|
||||
{gtMessage}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={handleMarkGroundTruth}
|
||||
disabled={gtSaving || status === 'saving'}
|
||||
className="px-4 py-2 text-sm bg-amber-600 text-white rounded hover:bg-amber-700 disabled:opacity-50"
|
||||
>
|
||||
{gtSaving ? 'Speichere...' : isGroundTruth ? 'Ground Truth aktualisieren' : 'Als Ground Truth markieren'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={status === 'saving'}
|
||||
className="px-4 py-2 text-sm bg-gray-600 text-white rounded hover:bg-gray-700 disabled:opacity-50"
|
||||
>
|
||||
Speichern
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
await handleSave()
|
||||
onNext()
|
||||
}}
|
||||
disabled={status === 'saving'}
|
||||
className="px-4 py-2 text-sm bg-teal-600 text-white rounded hover:bg-teal-700 disabled:opacity-50"
|
||||
>
|
||||
Abschliessen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Notes, score, and action bar */}
|
||||
<ValidationPanel
|
||||
notes={s.notes}
|
||||
score={s.score}
|
||||
status={s.status}
|
||||
isGroundTruth={s.isGroundTruth}
|
||||
gtSaving={s.gtSaving}
|
||||
gtMessage={s.gtMessage}
|
||||
onNotesChange={s.setNotes}
|
||||
onScoreChange={s.setScore}
|
||||
onSave={s.handleSave}
|
||||
onMarkGroundTruth={s.handleMarkGroundTruth}
|
||||
onFinish={onNext}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,66 +1,14 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import type { GridCell, GridResult, WordEntry, ColumnMeta } from '@/app/(admin)/ai/ocr-kombi/types'
|
||||
import { usePixelWordPositions } from './usePixelWordPositions'
|
||||
|
||||
const KLAUSUR_API = '/klausur-api'
|
||||
|
||||
interface LlmChange {
|
||||
row_index: number
|
||||
field: 'english' | 'german' | 'example'
|
||||
old: string
|
||||
new: string
|
||||
}
|
||||
|
||||
interface StepLlmReviewProps {
|
||||
sessionId: string | null
|
||||
onNext: () => void
|
||||
}
|
||||
|
||||
interface ReviewMeta {
|
||||
total_entries: number
|
||||
to_review: number
|
||||
skipped: number
|
||||
model: string
|
||||
skipped_indices?: number[]
|
||||
}
|
||||
|
||||
interface StreamProgress {
|
||||
current: number
|
||||
total: number
|
||||
}
|
||||
|
||||
const FIELD_LABELS: Record<string, string> = {
|
||||
english: 'EN',
|
||||
german: 'DE',
|
||||
example: 'Beispiel',
|
||||
source_page: 'Seite',
|
||||
marker: 'Marker',
|
||||
text: 'Text',
|
||||
}
|
||||
|
||||
/** Map column type to WordEntry field name */
|
||||
const COL_TYPE_TO_FIELD: Record<string, string> = {
|
||||
column_en: 'english',
|
||||
column_de: 'german',
|
||||
column_example: 'example',
|
||||
page_ref: 'source_page',
|
||||
column_marker: 'marker',
|
||||
column_text: 'text',
|
||||
}
|
||||
|
||||
/** Column type → color class */
|
||||
const COL_TYPE_COLOR: Record<string, string> = {
|
||||
column_en: 'text-blue-600 dark:text-blue-400',
|
||||
column_de: 'text-green-600 dark:text-green-400',
|
||||
column_example: 'text-orange-600 dark:text-orange-400',
|
||||
page_ref: 'text-cyan-600 dark:text-cyan-400',
|
||||
column_marker: 'text-gray-500 dark:text-gray-400',
|
||||
column_text: 'text-gray-700 dark:text-gray-300',
|
||||
}
|
||||
|
||||
type RowStatus = 'pending' | 'active' | 'reviewed' | 'corrected' | 'skipped'
|
||||
import type { LlmChange, StepLlmReviewProps, ReviewMeta, StreamProgress, RowStatus } from './llm-review-types'
|
||||
import { COL_TYPE_TO_FIELD, KLAUSUR_API } from './llm-review-types'
|
||||
import { LoadingScreen, ErrorScreen, AppliedScreen, NoSessionScreen } from './LlmReviewStatusScreens'
|
||||
import { LlmReviewVocabTable } from './LlmReviewVocabTable'
|
||||
import { LlmReviewOverlay } from './LlmReviewOverlay'
|
||||
import { LlmReviewCorrections } from './LlmReviewCorrections'
|
||||
|
||||
export function StepLlmReview({ sessionId, onNext }: StepLlmReviewProps) {
|
||||
// Core state
|
||||
@@ -90,8 +38,6 @@ export function StepLlmReview({ sessionId, onNext }: StepLlmReviewProps) {
|
||||
const [leftPaddingPct, setLeftPaddingPct] = useState(0)
|
||||
const [globalBold, setGlobalBold] = useState(false)
|
||||
const [cells, setCells] = useState<GridCell[]>([])
|
||||
const reconRef = useRef<HTMLDivElement>(null)
|
||||
const [reconWidth, setReconWidth] = useState(0)
|
||||
|
||||
// Pixel-analysed word positions via shared hook
|
||||
const overlayImageUrl = sessionId
|
||||
@@ -102,17 +48,6 @@ export function StepLlmReview({ sessionId, onNext }: StepLlmReviewProps) {
|
||||
const tableRef = useRef<HTMLDivElement>(null)
|
||||
const activeRowRef = useRef<HTMLTableRowElement>(null)
|
||||
|
||||
// Track reconstruction container width for font size calculation
|
||||
useEffect(() => {
|
||||
const el = reconRef.current
|
||||
if (!el) return
|
||||
const obs = new ResizeObserver(entries => {
|
||||
for (const entry of entries) setReconWidth(entry.contentRect.width)
|
||||
})
|
||||
obs.observe(el)
|
||||
return () => obs.disconnect()
|
||||
}, [viewMode])
|
||||
|
||||
// Load session data on mount
|
||||
useEffect(() => {
|
||||
if (!sessionId) return
|
||||
@@ -235,7 +170,6 @@ export function StepLlmReview({ sessionId, onNext }: StepLlmReviewProps) {
|
||||
model: event.model,
|
||||
skipped_indices: event.skipped_indices,
|
||||
})
|
||||
// Mark skipped rows
|
||||
if (event.skipped_indices) {
|
||||
allSkipped = new Set(event.skipped_indices)
|
||||
setSkippedRows(allSkipped)
|
||||
@@ -246,15 +180,12 @@ export function StepLlmReview({ sessionId, onNext }: StepLlmReviewProps) {
|
||||
const batchChanges: LlmChange[] = event.changes || []
|
||||
const batchRows: number[] = event.entries_reviewed || []
|
||||
|
||||
// Update active rows (currently being reviewed)
|
||||
setActiveRowIndices(new Set(batchRows))
|
||||
|
||||
// Accumulate changes
|
||||
allChanges = [...allChanges, ...batchChanges]
|
||||
setChanges(allChanges)
|
||||
setProgress(event.progress)
|
||||
|
||||
// Update corrected map
|
||||
for (const c of batchChanges) {
|
||||
const existing = cMap.get(c.row_index) || []
|
||||
existing.push(c)
|
||||
@@ -262,13 +193,11 @@ export function StepLlmReview({ sessionId, onNext }: StepLlmReviewProps) {
|
||||
}
|
||||
setCorrectedMap(new Map(cMap))
|
||||
|
||||
// Mark batch rows as reviewed
|
||||
for (const r of batchRows) {
|
||||
allReviewed.add(r)
|
||||
}
|
||||
setReviewedRows(new Set(allReviewed))
|
||||
|
||||
// Scroll to active row in table
|
||||
setTimeout(() => {
|
||||
activeRowRef.current?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
}, 50)
|
||||
@@ -278,7 +207,6 @@ export function StepLlmReview({ sessionId, onNext }: StepLlmReviewProps) {
|
||||
setActiveRowIndices(new Set())
|
||||
setTotalDuration(event.duration_ms)
|
||||
setAccepted(new Set(allChanges.map((_: LlmChange, i: number) => i)))
|
||||
// Mark all non-skipped as reviewed
|
||||
const allEntryIndices = vocabEntries.map((_: WordEntry, i: number) => i)
|
||||
for (const i of allEntryIndices) {
|
||||
if (!allSkipped.has(i)) allReviewed.add(i)
|
||||
@@ -293,7 +221,6 @@ export function StepLlmReview({ sessionId, onNext }: StepLlmReviewProps) {
|
||||
}
|
||||
}
|
||||
|
||||
// If stream ended without complete event
|
||||
if (allChanges.length === 0) {
|
||||
setStatus('done')
|
||||
}
|
||||
@@ -354,90 +281,24 @@ export function StepLlmReview({ sessionId, onNext }: StepLlmReviewProps) {
|
||||
? `${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/image/cropped`
|
||||
: ''
|
||||
|
||||
// Snap all cells in the same column to consistent x/w positions
|
||||
// Uses the median x and width per col_index so columns align vertically
|
||||
const colPositions = useMemo(() => {
|
||||
const byCol = new Map<number, { xs: number[]; ws: number[] }>()
|
||||
for (const cell of cells) {
|
||||
if (!cell.bbox_pct) continue
|
||||
const entry = byCol.get(cell.col_index) || { xs: [], ws: [] }
|
||||
entry.xs.push(cell.bbox_pct.x)
|
||||
entry.ws.push(cell.bbox_pct.w)
|
||||
byCol.set(cell.col_index, entry)
|
||||
}
|
||||
const result = new Map<number, { x: number; w: number }>()
|
||||
for (const [colIdx, { xs, ws }] of byCol) {
|
||||
xs.sort((a, b) => a - b)
|
||||
ws.sort((a, b) => a - b)
|
||||
const medianX = xs[Math.floor(xs.length / 2)]
|
||||
const medianW = ws[Math.floor(ws.length / 2)]
|
||||
result.set(colIdx, { x: medianX, w: medianW })
|
||||
}
|
||||
return result
|
||||
}, [cells])
|
||||
|
||||
if (!sessionId) {
|
||||
return <div className="text-center py-12 text-gray-400">Bitte zuerst eine Session auswaehlen.</div>
|
||||
}
|
||||
|
||||
// --- Loading session data ---
|
||||
if (status === 'loading' || status === 'idle') {
|
||||
return (
|
||||
<div className="flex items-center gap-3 justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-teal-500" />
|
||||
<span className="text-gray-500">Session-Daten werden geladen...</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Error ---
|
||||
// --- Early returns for non-main states ---
|
||||
if (!sessionId) return <NoSessionScreen />
|
||||
if (status === 'loading' || status === 'idle') return <LoadingScreen />
|
||||
if (status === 'error') {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<div className="text-5xl mb-4">⚠️</div>
|
||||
<h3 className="text-lg font-medium text-red-600 dark:text-red-400 mb-2">Fehler bei OCR-Zeichenkorrektur</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 max-w-lg mb-4">{error}</p>
|
||||
<div className="flex gap-3">
|
||||
<button onClick={() => { setError(''); loadSessionData() }}
|
||||
className="px-5 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 transition-colors text-sm">
|
||||
Erneut versuchen
|
||||
</button>
|
||||
<button onClick={onNext}
|
||||
className="px-5 py-2 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors text-sm">
|
||||
Ueberspringen →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
return <ErrorScreen error={error} onRetry={() => { setError(''); loadSessionData() }} onSkip={onNext} />
|
||||
}
|
||||
|
||||
// --- Applied ---
|
||||
if (status === 'applied') {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||
<div className="text-5xl mb-4">✅</div>
|
||||
<h3 className="text-lg font-medium text-gray-700 dark:text-gray-300 mb-2">Korrekturen uebernommen</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-6">
|
||||
{accepted.size} von {changes.length} Korrekturen wurden angewendet.
|
||||
</p>
|
||||
<button onClick={onNext}
|
||||
className="px-6 py-2.5 bg-teal-600 text-white rounded-lg hover:bg-teal-700 transition-colors font-medium">
|
||||
Weiter →
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
return <AppliedScreen acceptedCount={accepted.size} totalChanges={changes.length} onNext={onNext} />
|
||||
}
|
||||
|
||||
// Active entry for highlighting on image
|
||||
const activeEntry = vocabEntries.find((_: WordEntry, i: number) => activeRowIndices.has(i))
|
||||
|
||||
const pct = progress ? Math.round((progress.current / progress.total) * 100) : 0
|
||||
|
||||
/** Handle inline edit of a cell in the overlay */
|
||||
const handleCellEdit = (cellId: string, rowIndex: number, newText: string | null) => {
|
||||
if (newText === null) return
|
||||
setCells(prev => prev.map(c => c.cell_id === cellId ? { ...c, text: newText } : c))
|
||||
// Also update vocabEntries if this cell maps to a known field
|
||||
const cell = cells.find(c => c.cell_id === cellId)
|
||||
if (cell) {
|
||||
const field = COL_TYPE_TO_FIELD[cell.col_type]
|
||||
@@ -599,324 +460,40 @@ export function StepLlmReview({ sessionId, onNext }: StepLlmReviewProps) {
|
||||
{/* Right: Table or Overlay */}
|
||||
<div className={viewMode === 'table' ? 'col-span-2' : 'col-span-1'} ref={tableRef}>
|
||||
{viewMode === 'table' ? (
|
||||
<>
|
||||
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
|
||||
{columnsUsed.length === 1 && columnsUsed[0]?.type === 'column_text' ? 'Tabelle' : 'Vokabeltabelle'} ({vocabEntries.length} Eintraege)
|
||||
</div>
|
||||
<div className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||
<div className="max-h-[70vh] overflow-y-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="sticky top-0 z-10">
|
||||
<tr className="bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
|
||||
<th className="px-2 py-2 text-left text-gray-500 dark:text-gray-400 font-medium w-10">#</th>
|
||||
{columnsUsed.length > 0 ? (
|
||||
columnsUsed.map((col, i) => {
|
||||
const field = COL_TYPE_TO_FIELD[col.type]
|
||||
if (!field) return null
|
||||
return (
|
||||
<th key={i} className={`px-2 py-2 text-left font-medium ${COL_TYPE_COLOR[col.type] || 'text-gray-500 dark:text-gray-400'}`}>
|
||||
{FIELD_LABELS[field] || field}
|
||||
</th>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<>
|
||||
<th className="px-2 py-2 text-left text-gray-500 dark:text-gray-400 font-medium">EN</th>
|
||||
<th className="px-2 py-2 text-left text-gray-500 dark:text-gray-400 font-medium">DE</th>
|
||||
<th className="px-2 py-2 text-left text-gray-500 dark:text-gray-400 font-medium">Beispiel</th>
|
||||
</>
|
||||
)}
|
||||
<th className="px-2 py-2 text-center text-gray-500 dark:text-gray-400 font-medium w-16">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{vocabEntries.map((entry, idx) => {
|
||||
const rowStatus = getRowStatus(idx)
|
||||
const rowChanges = correctedMap.get(idx)
|
||||
|
||||
const rowBg = {
|
||||
pending: '',
|
||||
active: 'bg-yellow-50 dark:bg-yellow-900/20',
|
||||
reviewed: '',
|
||||
corrected: 'bg-teal-50/50 dark:bg-teal-900/10',
|
||||
skipped: 'bg-gray-50 dark:bg-gray-800/50',
|
||||
}[rowStatus]
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={idx}
|
||||
ref={rowStatus === 'active' ? activeRowRef : undefined}
|
||||
className={`border-b border-gray-100 dark:border-gray-700/50 ${rowBg} ${
|
||||
rowStatus === 'active' ? 'ring-1 ring-yellow-400 ring-inset' : ''
|
||||
}`}
|
||||
>
|
||||
<td className="px-2 py-1.5 text-gray-400 font-mono text-xs">{idx}</td>
|
||||
{columnsUsed.length > 0 ? (
|
||||
columnsUsed.map((col, i) => {
|
||||
const field = COL_TYPE_TO_FIELD[col.type]
|
||||
if (!field) return null
|
||||
const text = (entry as Record<string, unknown>)[field] as string || ''
|
||||
return (
|
||||
<td key={i} className="px-2 py-1.5 text-xs">
|
||||
<CellContent text={text} field={field} rowChanges={rowChanges} />
|
||||
</td>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<>
|
||||
<td className="px-2 py-1.5">
|
||||
<CellContent text={entry.english} field="english" rowChanges={rowChanges} />
|
||||
</td>
|
||||
<td className="px-2 py-1.5">
|
||||
<CellContent text={entry.german} field="german" rowChanges={rowChanges} />
|
||||
</td>
|
||||
<td className="px-2 py-1.5 text-xs">
|
||||
<CellContent text={entry.example} field="example" rowChanges={rowChanges} />
|
||||
</td>
|
||||
</>
|
||||
)}
|
||||
<td className="px-2 py-1.5 text-center">
|
||||
<StatusIcon status={rowStatus} />
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
<LlmReviewVocabTable
|
||||
vocabEntries={vocabEntries}
|
||||
columnsUsed={columnsUsed}
|
||||
getRowStatus={getRowStatus}
|
||||
correctedMap={correctedMap}
|
||||
activeRowRef={activeRowRef}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
|
||||
Text-Rekonstruktion ({cells.filter(c => c.text).length} Zellen)
|
||||
</div>
|
||||
<div className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden bg-white dark:bg-white">
|
||||
<div
|
||||
ref={reconRef}
|
||||
className="relative"
|
||||
style={{
|
||||
aspectRatio: imageNaturalSize ? `${imageNaturalSize.w} / ${imageNaturalSize.h}` : '3 / 4',
|
||||
}}
|
||||
>
|
||||
{cells.map(cell => {
|
||||
if (!cell.bbox_pct || !cell.text) return null
|
||||
const col = colPositions.get(cell.col_index)
|
||||
const cellX = col?.x ?? cell.bbox_pct.x
|
||||
const cellW = col?.w ?? cell.bbox_pct.w
|
||||
const aspect = imageNaturalSize ? imageNaturalSize.h / imageNaturalSize.w : 4 / 3
|
||||
const containerH = reconWidth * aspect
|
||||
const cellHeightPx = containerH * (cell.bbox_pct.h / 100)
|
||||
|
||||
const wordPos = cellWordPositions.get(cell.cell_id)
|
||||
|
||||
// Pixel-analysed: render word-groups at detected positions
|
||||
if (wordPos) {
|
||||
return wordPos.map((wp, i) => {
|
||||
// Auto font-size from pixel analysis, scaled by user slider
|
||||
const autoFontPx = cellHeightPx * wp.fontRatio * fontScale
|
||||
const fs = Math.max(6, autoFontPx)
|
||||
return (
|
||||
<span
|
||||
key={`${cell.cell_id}_${i}`}
|
||||
className="absolute leading-none pointer-events-none select-none"
|
||||
style={{
|
||||
left: `${wp.xPct}%`,
|
||||
top: `${cell.bbox_pct.y}%`,
|
||||
width: `${wp.wPct}%`,
|
||||
height: `${cell.bbox_pct.h}%`,
|
||||
fontSize: `${fs}px`,
|
||||
fontWeight: globalBold ? 'bold' : (cell.is_bold ? 'bold' : 'normal'),
|
||||
fontFamily: "'Liberation Sans', Arial, sans-serif",
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'visible',
|
||||
color: '#1a1a1a',
|
||||
}}
|
||||
>
|
||||
{wp.text}
|
||||
</span>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// Fallback: no pixel data — single span for entire cell
|
||||
const fontSize = Math.max(6, cellHeightPx * fontScale)
|
||||
return (
|
||||
<span
|
||||
key={cell.cell_id}
|
||||
className="absolute leading-none pointer-events-none select-none"
|
||||
style={{
|
||||
left: `${cellX}%`,
|
||||
top: `${cell.bbox_pct.y}%`,
|
||||
width: `${cellW}%`,
|
||||
height: `${cell.bbox_pct.h}%`,
|
||||
fontSize: `${fontSize}px`,
|
||||
fontWeight: globalBold ? 'bold' : (cell.is_bold ? 'bold' : 'normal'),
|
||||
paddingLeft: `${leftPaddingPct}%`,
|
||||
fontFamily: "'Liberation Sans', Arial, sans-serif",
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
whiteSpace: 'pre',
|
||||
overflow: 'visible',
|
||||
color: '#1a1a1a',
|
||||
}}
|
||||
>
|
||||
{cell.text}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
<LlmReviewOverlay
|
||||
cells={cells}
|
||||
imageNaturalSize={imageNaturalSize}
|
||||
fontScale={fontScale}
|
||||
leftPaddingPct={leftPaddingPct}
|
||||
globalBold={globalBold}
|
||||
cellWordPositions={cellWordPositions}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Done state: summary + actions */}
|
||||
{status === 'done' && (
|
||||
<div className="space-y-4">
|
||||
{/* Summary */}
|
||||
<div className="bg-gray-50 dark:bg-gray-800/50 rounded-lg p-3 text-xs text-gray-500 dark:text-gray-400">
|
||||
{changes.length === 0 ? (
|
||||
<span>Keine Korrekturen noetig — alle Eintraege sind korrekt.</span>
|
||||
) : (
|
||||
<span>
|
||||
{changes.length} Korrektur{changes.length !== 1 ? 'en' : ''} gefunden ·{' '}
|
||||
{accepted.size} ausgewaehlt ·{' '}
|
||||
{meta?.skipped || 0} uebersprungen (Lautschrift) ·{' '}
|
||||
{totalDuration}ms
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Corrections detail list (if any) */}
|
||||
{changes.length > 0 && (
|
||||
<div className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||
<div className="bg-gray-50 dark:bg-gray-800 px-3 py-2 border-b border-gray-200 dark:border-gray-700">
|
||||
<span className="text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||
Korrekturvorschlaege ({accepted.size}/{changes.length} ausgewaehlt)
|
||||
</span>
|
||||
</div>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-gray-50/50 dark:bg-gray-800/50 border-b border-gray-200 dark:border-gray-700">
|
||||
<th className="w-10 px-3 py-1.5 text-center">
|
||||
<input type="checkbox" checked={accepted.size === changes.length} onChange={toggleAll}
|
||||
className="rounded border-gray-300 dark:border-gray-600" />
|
||||
</th>
|
||||
<th className="px-2 py-1.5 text-left text-gray-500 dark:text-gray-400 font-medium text-xs">Zeile</th>
|
||||
<th className="px-2 py-1.5 text-left text-gray-500 dark:text-gray-400 font-medium text-xs">Feld</th>
|
||||
<th className="px-2 py-1.5 text-left text-gray-500 dark:text-gray-400 font-medium text-xs">Vorher</th>
|
||||
<th className="px-2 py-1.5 text-left text-gray-500 dark:text-gray-400 font-medium text-xs">Nachher</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{changes.map((change, idx) => (
|
||||
<tr key={idx} className={`border-b border-gray-100 dark:border-gray-700/50 ${
|
||||
accepted.has(idx) ? 'bg-teal-50/50 dark:bg-teal-900/10' : ''
|
||||
}`}>
|
||||
<td className="px-3 py-1.5 text-center">
|
||||
<input type="checkbox" checked={accepted.has(idx)} onChange={() => toggleChange(idx)}
|
||||
className="rounded border-gray-300 dark:border-gray-600" />
|
||||
</td>
|
||||
<td className="px-2 py-1.5 text-gray-500 dark:text-gray-400 font-mono text-xs">R{change.row_index}</td>
|
||||
<td className="px-2 py-1.5">
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400">
|
||||
{FIELD_LABELS[change.field] || change.field}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-2 py-1.5"><span className="line-through text-red-500 dark:text-red-400 text-xs">{change.old}</span></td>
|
||||
<td className="px-2 py-1.5"><span className="text-green-600 dark:text-green-400 font-medium text-xs">{change.new}</span></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<p className="text-xs text-gray-400">
|
||||
{changes.length > 0 ? `${accepted.size} von ${changes.length} ausgewaehlt` : ''}
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
{changes.length > 0 && (
|
||||
<button onClick={onNext}
|
||||
className="px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors text-gray-600 dark:text-gray-400">
|
||||
Alle ablehnen
|
||||
</button>
|
||||
)}
|
||||
{changes.length > 0 ? (
|
||||
<button onClick={applyChanges} disabled={applying || accepted.size === 0}
|
||||
className="px-5 py-2 text-sm bg-teal-600 text-white rounded-lg hover:bg-teal-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors font-medium">
|
||||
{applying ? 'Wird uebernommen...' : `${accepted.size} Korrektur${accepted.size !== 1 ? 'en' : ''} uebernehmen`}
|
||||
</button>
|
||||
) : (
|
||||
<button onClick={onNext}
|
||||
className="px-6 py-2.5 bg-teal-600 text-white rounded-lg hover:bg-teal-700 transition-colors font-medium">
|
||||
Weiter →
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<LlmReviewCorrections
|
||||
changes={changes}
|
||||
accepted={accepted}
|
||||
meta={meta}
|
||||
totalDuration={totalDuration}
|
||||
applying={applying}
|
||||
onToggleChange={toggleChange}
|
||||
onToggleAll={toggleAll}
|
||||
onApply={applyChanges}
|
||||
onNext={onNext}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Cell content with inline diff for corrections */
|
||||
function CellContent({ text, field, rowChanges }: {
|
||||
text: string
|
||||
field: string
|
||||
rowChanges?: LlmChange[]
|
||||
}) {
|
||||
const change = rowChanges?.find(c => c.field === field)
|
||||
|
||||
if (!text && !change) {
|
||||
return <span className="text-gray-300 dark:text-gray-600">—</span>
|
||||
}
|
||||
|
||||
if (change) {
|
||||
return (
|
||||
<span>
|
||||
<span className="line-through text-red-400 dark:text-red-500 text-xs mr-1">{change.old}</span>
|
||||
<span className="text-green-600 dark:text-green-400 font-medium text-xs">{change.new}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return <span className="text-gray-700 dark:text-gray-300 text-xs">{text}</span>
|
||||
}
|
||||
|
||||
/** Status icon for each row */
|
||||
function StatusIcon({ status }: { status: RowStatus }) {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return <span className="text-gray-300 dark:text-gray-600 text-xs">—</span>
|
||||
case 'active':
|
||||
return (
|
||||
<span className="inline-block w-3 h-3 rounded-full bg-yellow-400 animate-pulse" title="Wird geprueft" />
|
||||
)
|
||||
case 'reviewed':
|
||||
return (
|
||||
<svg className="w-4 h-4 text-green-500 inline-block" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)
|
||||
case 'corrected':
|
||||
return (
|
||||
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-teal-100 dark:bg-teal-900/30 text-teal-700 dark:text-teal-400">
|
||||
korr.
|
||||
</span>
|
||||
)
|
||||
case 'skipped':
|
||||
return (
|
||||
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400">
|
||||
skip
|
||||
</span>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,82 @@
|
||||
// Types, constants and helpers for StepReconstruction
|
||||
|
||||
export const KLAUSUR_API = '/klausur-api'
|
||||
|
||||
export type EditorMode = 'simple' | 'editor' | 'overlay'
|
||||
|
||||
export interface StepReconstructionProps {
|
||||
sessionId: string | null
|
||||
onNext: () => void
|
||||
}
|
||||
|
||||
export interface EditableCell {
|
||||
cellId: string
|
||||
text: string
|
||||
originalText: string
|
||||
bboxPct: { x: number; y: number; w: number; h: number }
|
||||
colType: string
|
||||
rowIndex: number
|
||||
colIndex: number
|
||||
}
|
||||
|
||||
export type UndoAction = { cellId: string; oldText: string; newText: string }
|
||||
|
||||
export type ReconstructionStatus = 'loading' | 'ready' | 'saving' | 'saved' | 'error'
|
||||
|
||||
// --- PageRegion / RowItem / PageZone ---
|
||||
// These match the backend API response shapes used by overlay mode.
|
||||
|
||||
export interface PageRegion {
|
||||
x: number
|
||||
width: number
|
||||
type: string
|
||||
}
|
||||
|
||||
export interface RowItem {
|
||||
y: number
|
||||
}
|
||||
|
||||
export interface PageZone {
|
||||
zone_type: string
|
||||
box?: { x: number; y: number; width: number; height: number }
|
||||
}
|
||||
|
||||
// --- Helper functions ---
|
||||
|
||||
export function colTypeColor(colType: string): string {
|
||||
const colors: Record<string, string> = {
|
||||
column_en: 'border-blue-400/40 focus:border-blue-500',
|
||||
column_de: 'border-green-400/40 focus:border-green-500',
|
||||
column_example: 'border-orange-400/40 focus:border-orange-500',
|
||||
column_text: 'border-purple-400/40 focus:border-purple-500',
|
||||
page_ref: 'border-cyan-400/40 focus:border-cyan-500',
|
||||
column_marker: 'border-gray-400/40 focus:border-gray-500',
|
||||
}
|
||||
return colors[colType] || 'border-gray-400/40 focus:border-gray-500'
|
||||
}
|
||||
|
||||
/**
|
||||
* Clamp cell positions so they don't overlap with box zones.
|
||||
* Sub-session cells (inside box) are not adjusted.
|
||||
*/
|
||||
export function adjustCellForBoxZones(
|
||||
bboxPct: { x: number; y: number; w: number; h: number },
|
||||
cellId: string,
|
||||
boxZonesPct: { topPct: number; bottomPct: number }[],
|
||||
): { x: number; y: number; w: number; h: number } {
|
||||
if (cellId.startsWith('sub_')) return bboxPct
|
||||
if (boxZonesPct.length === 0) return bboxPct
|
||||
|
||||
const cellTop = bboxPct.y
|
||||
const cellBottom = bboxPct.y + bboxPct.h
|
||||
const boxMid = (boxZonesPct[0].topPct + boxZonesPct[0].bottomPct) / 2
|
||||
|
||||
for (const { topPct, bottomPct } of boxZonesPct) {
|
||||
if (cellBottom <= topPct || cellTop >= bottomPct) continue
|
||||
if (cellTop < boxMid) {
|
||||
return { ...bboxPct, h: Math.max(0.5, topPct - cellTop) }
|
||||
}
|
||||
return { ...bboxPct, y: bottomPct, h: Math.max(0.5, cellBottom - bottomPct) }
|
||||
}
|
||||
return bboxPct
|
||||
}
|
||||
@@ -1,115 +1,17 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import type { ExcludeRegion, StructureResult } from '@/app/(admin)/ai/ocr-kombi/types'
|
||||
|
||||
const KLAUSUR_API = '/klausur-api'
|
||||
import { KLAUSUR_API, type DetectionMethod } from './structure-detection-utils'
|
||||
import { StructureImageComparison } from './StructureImageComparison'
|
||||
import { StructureResultDetails } from './StructureResultDetails'
|
||||
import { ExcludeRegionsList } from './ExcludeRegionsList'
|
||||
|
||||
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)
|
||||
@@ -118,45 +20,11 @@ export function StepStructureDetection({ sessionId, onNext }: StepStructureDetec
|
||||
const [overlayTs, setOverlayTs] = useState(0)
|
||||
const [detectionMethod, setDetectionMethod] = useState<DetectionMethod>('auto')
|
||||
|
||||
// Exclude region drawing state
|
||||
// Exclude region 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
|
||||
@@ -229,48 +97,11 @@ export function StepStructureDetection({ sessionId, onNext }: StepStructureDetec
|
||||
}
|
||||
}, [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 handleAddRegion = useCallback((region: ExcludeRegion) => {
|
||||
const updated = [...excludeRegions, region]
|
||||
setExcludeRegions(updated)
|
||||
saveExcludeRegions(updated)
|
||||
}, [excludeRegions, saveExcludeRegions])
|
||||
|
||||
const handleDeleteRegion = useCallback(async (index: number) => {
|
||||
if (!sessionId) return
|
||||
@@ -293,19 +124,6 @@ export function StepStructureDetection({ sessionId, onNext }: StepStructureDetec
|
||||
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 */}
|
||||
@@ -366,386 +184,27 @@ export function StepStructureDetection({ sessionId, onNext }: StepStructureDetec
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
{result && (
|
||||
<StructureImageComparison
|
||||
sessionId={sessionId}
|
||||
result={result}
|
||||
overlayTs={overlayTs}
|
||||
excludeRegions={excludeRegions}
|
||||
drawMode={drawMode}
|
||||
onAddRegion={handleAddRegion}
|
||||
onDeleteRegion={handleDeleteRegion}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
<ExcludeRegionsList
|
||||
regions={excludeRegions}
|
||||
onDeleteRegion={handleDeleteRegion}
|
||||
/>
|
||||
|
||||
{/* 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>
|
||||
<StructureResultDetails result={result} excludeRegions={excludeRegions} />
|
||||
)}
|
||||
|
||||
{/* Action buttons */}
|
||||
|
||||
@@ -2,44 +2,12 @@
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import type { GridResult, GridCell, WordEntry, WordGroundTruth } from '@/app/(admin)/ai/ocr-kombi/types'
|
||||
import { WordRecognitionOverview } from './WordRecognitionOverview'
|
||||
import { WordRecognitionLabeling } from './WordRecognitionLabeling'
|
||||
import { WordRecognitionControls } from './WordRecognitionControls'
|
||||
|
||||
const KLAUSUR_API = '/klausur-api'
|
||||
|
||||
/** Render text with \n as line breaks */
|
||||
function MultilineText({ text }: { text: string }) {
|
||||
if (!text) return <span className="text-gray-300 dark:text-gray-600">—</span>
|
||||
const lines = text.split('\n')
|
||||
if (lines.length === 1) return <>{text}</>
|
||||
return <>{lines.map((line, i) => (
|
||||
<span key={i}>{line}{i < lines.length - 1 && <br />}</span>
|
||||
))}</>
|
||||
}
|
||||
|
||||
/** Column type → human-readable header */
|
||||
function colTypeLabel(colType: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
column_en: 'English',
|
||||
column_de: 'Deutsch',
|
||||
column_example: 'Example',
|
||||
column_text: 'Text',
|
||||
column_marker: 'Marker',
|
||||
page_ref: 'Seite',
|
||||
}
|
||||
return labels[colType] || colType.replace('column_', '')
|
||||
}
|
||||
|
||||
/** Column type → color class */
|
||||
function colTypeColor(colType: string): string {
|
||||
const colors: Record<string, string> = {
|
||||
column_en: 'text-blue-600 dark:text-blue-400',
|
||||
column_de: 'text-green-600 dark:text-green-400',
|
||||
column_example: 'text-orange-600 dark:text-orange-400',
|
||||
column_text: 'text-purple-600 dark:text-purple-400',
|
||||
column_marker: 'text-gray-500 dark:text-gray-400',
|
||||
}
|
||||
return colors[colType] || 'text-gray-600 dark:text-gray-400'
|
||||
}
|
||||
|
||||
interface StepWordRecognitionProps {
|
||||
sessionId: string | null
|
||||
onNext: () => void
|
||||
@@ -75,7 +43,7 @@ export function StepWordRecognition({ sessionId, onNext, goToStep, skipHealGaps
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId) return
|
||||
// Always run fresh detection — word-lookup is fast (~0.03s)
|
||||
// Always run fresh detection -- word-lookup is fast (~0.03s)
|
||||
// and avoids stale cached results from previous pipeline versions.
|
||||
runAutoDetection()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -253,13 +221,6 @@ export function StepWordRecognition({ sessionId, onNext, goToStep, skipHealGaps
|
||||
}
|
||||
}, [sessionId, gtNotes, editedEntries, isVocab])
|
||||
|
||||
// Vocab mode: update entry field
|
||||
const updateEntry = (index: number, field: 'english' | 'german' | 'example', value: string) => {
|
||||
setEditedEntries(prev => prev.map((e, i) =>
|
||||
i === index ? { ...e, [field]: value, status: 'edited' as const } : e
|
||||
))
|
||||
}
|
||||
|
||||
// Generic mode: update cell text
|
||||
const updateCell = (cellId: string, value: string) => {
|
||||
setEditedCells(prev => prev.map(c =>
|
||||
@@ -267,6 +228,19 @@ export function StepWordRecognition({ sessionId, onNext, goToStep, skipHealGaps
|
||||
))
|
||||
}
|
||||
|
||||
// Helper: get unique row indices from cells
|
||||
const getUniqueRowCount = () => {
|
||||
if (!editedCells.length) return 0
|
||||
return new Set(editedCells.map(c => c.row_index)).size
|
||||
}
|
||||
|
||||
// Helper: get cells for a given row index (by position in sorted unique rows)
|
||||
const getRowCells = (rowPosition: number) => {
|
||||
const uniqueRows = [...new Set(editedCells.map(c => c.row_index))].sort((a, b) => a - b)
|
||||
const rowIdx = uniqueRows[rowPosition]
|
||||
return editedCells.filter(c => c.row_index === rowIdx)
|
||||
}
|
||||
|
||||
// Step-through: confirm current row (always cell-based)
|
||||
const confirmEntry = () => {
|
||||
const rowCells = getRowCells(activeIndex)
|
||||
@@ -293,19 +267,6 @@ export function StepWordRecognition({ sessionId, onNext, goToStep, skipHealGaps
|
||||
}
|
||||
}
|
||||
|
||||
// Helper: get unique row indices from cells
|
||||
const getUniqueRowCount = () => {
|
||||
if (!editedCells.length) return 0
|
||||
return new Set(editedCells.map(c => c.row_index)).size
|
||||
}
|
||||
|
||||
// Helper: get cells for a given row index (by position in sorted unique rows)
|
||||
const getRowCells = (rowPosition: number) => {
|
||||
const uniqueRows = [...new Set(editedCells.map(c => c.row_index))].sort((a, b) => a - b)
|
||||
const rowIdx = uniqueRows[rowPosition]
|
||||
return editedCells.filter(c => c.row_index === rowIdx)
|
||||
}
|
||||
|
||||
// Focus english input when active entry changes in labeling mode
|
||||
useEffect(() => {
|
||||
if (mode === 'labeling' && enRef.current) {
|
||||
@@ -347,26 +308,6 @@ export function StepWordRecognition({ sessionId, onNext, goToStep, skipHealGaps
|
||||
)
|
||||
}
|
||||
|
||||
const overlayUrl = `${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/image/words-overlay`
|
||||
const dewarpedUrl = `${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/image/cropped`
|
||||
|
||||
const confColor = (conf: number) => {
|
||||
if (conf >= 70) return 'text-green-600 dark:text-green-400'
|
||||
if (conf >= 50) return 'text-yellow-600 dark:text-yellow-400'
|
||||
return 'text-red-600 dark:text-red-400'
|
||||
}
|
||||
|
||||
const statusBadge = (status?: string) => {
|
||||
const map: Record<string, string> = {
|
||||
pending: 'bg-gray-100 dark:bg-gray-700 text-gray-500',
|
||||
confirmed: 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400',
|
||||
edited: 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400',
|
||||
skipped: 'bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-400',
|
||||
}
|
||||
return map[status || 'pending'] || map.pending
|
||||
}
|
||||
|
||||
const summary = gridResult?.summary
|
||||
const columnsUsed = gridResult?.columns_used || []
|
||||
const gridShape = gridResult?.grid_shape
|
||||
|
||||
@@ -377,7 +318,7 @@ export function StepWordRecognition({ sessionId, onNext, goToStep, skipHealGaps
|
||||
const confirmedCount = confirmedRowIds.size
|
||||
const totalCount = getUniqueRowCount()
|
||||
|
||||
// Group cells by row for generic table display
|
||||
// Group cells by row (shared between overview and labeling)
|
||||
const cellsByRow: Map<number, GridCell[]> = new Map()
|
||||
for (const cell of editedCells) {
|
||||
const existing = cellsByRow.get(cell.row_index) || []
|
||||
@@ -453,453 +394,59 @@ export function StepWordRecognition({ sessionId, onNext, goToStep, skipHealGaps
|
||||
|
||||
{/* Overview mode */}
|
||||
{mode === 'overview' && (
|
||||
<>
|
||||
{/* Images: overlay vs clean */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
|
||||
Mit Grid-Overlay
|
||||
</div>
|
||||
<div className="border rounded-lg overflow-hidden dark:border-gray-700 bg-gray-50 dark:bg-gray-900">
|
||||
{gridResult ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={`${overlayUrl}?t=${Date.now()}`}
|
||||
alt="Wort-Overlay"
|
||||
className="w-full h-auto"
|
||||
/>
|
||||
) : (
|
||||
<div className="aspect-[3/4] flex items-center justify-center text-gray-400 text-sm">
|
||||
{detecting ? 'Erkenne Woerter...' : 'Keine Daten'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
|
||||
Entzerrtes Bild
|
||||
</div>
|
||||
<div className="border rounded-lg overflow-hidden dark:border-gray-700 bg-gray-50 dark:bg-gray-900">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={dewarpedUrl}
|
||||
alt="Entzerrt"
|
||||
className="w-full h-auto"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Result summary (only after streaming completes) */}
|
||||
{gridResult && summary && !detecting && (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Ergebnis: {summary.non_empty_cells}/{summary.total_cells} Zellen mit Text
|
||||
({sortedRowIndices.length} Zeilen, {columnsUsed.length} Spalten)
|
||||
</h4>
|
||||
<span className="text-xs text-gray-400">
|
||||
{gridResult.duration_seconds}s
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Summary badges */}
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<span className="px-2 py-0.5 rounded text-xs font-medium bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300">
|
||||
Zellen: {summary.non_empty_cells}/{summary.total_cells}
|
||||
</span>
|
||||
{columnsUsed.map((col, i) => (
|
||||
<span key={i} className={`px-2 py-0.5 rounded text-xs font-medium bg-gray-100 dark:bg-gray-700 ${colTypeColor(col.type)}`}>
|
||||
C{col.index}: {colTypeLabel(col.type)}
|
||||
</span>
|
||||
))}
|
||||
{summary.low_confidence > 0 && (
|
||||
<span className="px-2 py-0.5 rounded text-xs font-medium bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300">
|
||||
Unsicher: {summary.low_confidence}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Entry/Cell table */}
|
||||
<div className="max-h-80 overflow-y-auto">
|
||||
{/* Unified dynamic table — columns driven by columns_used */}
|
||||
<table className="w-full text-xs">
|
||||
<thead className="sticky top-0 bg-white dark:bg-gray-800">
|
||||
<tr className="text-left text-gray-500 dark:text-gray-400 border-b dark:border-gray-700">
|
||||
<th className="py-1 pr-2 w-12">Zeile</th>
|
||||
{columnsUsed.map((col, i) => (
|
||||
<th key={i} className={`py-1 pr-2 ${colTypeColor(col.type)}`}>
|
||||
{colTypeLabel(col.type)}
|
||||
</th>
|
||||
))}
|
||||
<th className="py-1 w-12 text-right">Conf</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedRowIndices.map((rowIdx, posIdx) => {
|
||||
const rowCells = cellsByRow.get(rowIdx) || []
|
||||
const avgConf = rowCells.length
|
||||
? Math.round(rowCells.reduce((s, c) => s + c.confidence, 0) / rowCells.length)
|
||||
: 0
|
||||
return (
|
||||
<tr
|
||||
key={rowIdx}
|
||||
className={`border-b dark:border-gray-700/50 ${
|
||||
posIdx === activeIndex ? 'bg-teal-50 dark:bg-teal-900/20' : ''
|
||||
}`}
|
||||
onClick={() => { setActiveIndex(posIdx); setMode('labeling') }}
|
||||
>
|
||||
<td className="py-1 pr-2 text-gray-400 font-mono text-[10px]">
|
||||
R{String(rowIdx).padStart(2, '0')}
|
||||
</td>
|
||||
{columnsUsed.map((col) => {
|
||||
const cell = rowCells.find(c => c.col_index === col.index)
|
||||
return (
|
||||
<td key={col.index} className="py-1 pr-2 font-mono text-gray-700 dark:text-gray-300 cursor-pointer">
|
||||
<MultilineText text={cell?.text || ''} />
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
<td className={`py-1 text-right font-mono ${confColor(avgConf)}`}>
|
||||
{avgConf}%
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
<div ref={tableEndRef} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Streaming cell table (shown while detecting, before complete) */}
|
||||
{detecting && editedCells.length > 0 && !gridResult?.summary?.non_empty_cells && (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4 space-y-3">
|
||||
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Live: {editedCells.length} Zellen erkannt...
|
||||
</h4>
|
||||
<div className="max-h-80 overflow-y-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="sticky top-0 bg-white dark:bg-gray-800">
|
||||
<tr className="text-left text-gray-500 dark:text-gray-400 border-b dark:border-gray-700">
|
||||
<th className="py-1 pr-2 w-12">Zelle</th>
|
||||
{columnsUsed.map((col, i) => (
|
||||
<th key={i} className={`py-1 pr-2 ${colTypeColor(col.type)}`}>
|
||||
{colTypeLabel(col.type)}
|
||||
</th>
|
||||
))}
|
||||
<th className="py-1 w-12 text-right">Conf</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(() => {
|
||||
const liveByRow: Map<number, GridCell[]> = new Map()
|
||||
for (const cell of editedCells) {
|
||||
const existing = liveByRow.get(cell.row_index) || []
|
||||
existing.push(cell)
|
||||
liveByRow.set(cell.row_index, existing)
|
||||
}
|
||||
const liveSorted = [...liveByRow.keys()].sort((a, b) => a - b)
|
||||
return liveSorted.map(rowIdx => {
|
||||
const rowCells = liveByRow.get(rowIdx) || []
|
||||
const avgConf = rowCells.length
|
||||
? Math.round(rowCells.reduce((s, c) => s + c.confidence, 0) / rowCells.length)
|
||||
: 0
|
||||
return (
|
||||
<tr key={rowIdx} className="border-b dark:border-gray-700/50 animate-fade-in">
|
||||
<td className="py-1 pr-2 text-gray-400 font-mono text-[10px]">
|
||||
R{String(rowIdx).padStart(2, '0')}
|
||||
</td>
|
||||
{columnsUsed.map((col) => {
|
||||
const cell = rowCells.find(c => c.col_index === col.index)
|
||||
return (
|
||||
<td key={col.index} className="py-1 pr-2 font-mono text-gray-700 dark:text-gray-300">
|
||||
<MultilineText text={cell?.text || ''} />
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
<td className={`py-1 text-right font-mono ${confColor(avgConf)}`}>
|
||||
{avgConf}%
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})
|
||||
})()}
|
||||
</tbody>
|
||||
</table>
|
||||
<div ref={tableEndRef} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
<WordRecognitionOverview
|
||||
sessionId={sessionId}
|
||||
gridResult={gridResult}
|
||||
detecting={detecting}
|
||||
editedCells={editedCells}
|
||||
activeIndex={activeIndex}
|
||||
setActiveIndex={setActiveIndex}
|
||||
setMode={setMode}
|
||||
tableEndRef={tableEndRef}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Labeling mode */}
|
||||
{mode === 'labeling' && editedCells.length > 0 && (
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{/* Left 2/3: Image with highlighted active row */}
|
||||
<div className="col-span-2">
|
||||
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
|
||||
Zeile {activeIndex + 1} von {getUniqueRowCount()}
|
||||
</div>
|
||||
<div className="border rounded-lg overflow-hidden dark:border-gray-700 bg-gray-50 dark:bg-gray-900 relative">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={`${overlayUrl}?t=${Date.now()}`}
|
||||
alt="Wort-Overlay"
|
||||
className="w-full h-auto"
|
||||
/>
|
||||
{/* Highlight overlay for active row */}
|
||||
{(() => {
|
||||
const rowCells = getRowCells(activeIndex)
|
||||
return rowCells.map(cell => (
|
||||
<div
|
||||
key={cell.cell_id}
|
||||
className="absolute border-2 border-yellow-400 bg-yellow-400/10 pointer-events-none"
|
||||
style={{
|
||||
left: `${cell.bbox_pct.x}%`,
|
||||
top: `${cell.bbox_pct.y}%`,
|
||||
width: `${cell.bbox_pct.w}%`,
|
||||
height: `${cell.bbox_pct.h}%`,
|
||||
}}
|
||||
/>
|
||||
))
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right 1/3: Editable fields */}
|
||||
<div className="space-y-3">
|
||||
{/* Navigation */}
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
onClick={() => setActiveIndex(Math.max(0, activeIndex - 1))}
|
||||
disabled={activeIndex === 0}
|
||||
className="px-2 py-1 text-xs border rounded hover:bg-gray-50 dark:hover:bg-gray-700 dark:border-gray-600 disabled:opacity-30"
|
||||
>
|
||||
Zurueck
|
||||
</button>
|
||||
<span className="text-xs text-gray-500">
|
||||
{activeIndex + 1} / {getUniqueRowCount()}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setActiveIndex(Math.min(
|
||||
getUniqueRowCount() - 1,
|
||||
activeIndex + 1
|
||||
))}
|
||||
disabled={activeIndex >= getUniqueRowCount() - 1}
|
||||
className="px-2 py-1 text-xs border rounded hover:bg-gray-50 dark:hover:bg-gray-700 dark:border-gray-600 disabled:opacity-30"
|
||||
>
|
||||
Weiter
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Status badge */}
|
||||
<div className="flex items-center gap-2">
|
||||
{(() => {
|
||||
const rowCells = getRowCells(activeIndex)
|
||||
const avgConf = rowCells.length
|
||||
? Math.round(rowCells.reduce((s, c) => s + c.confidence, 0) / rowCells.length)
|
||||
: 0
|
||||
return (
|
||||
<span className={`text-xs font-mono ${confColor(avgConf)}`}>
|
||||
{avgConf}% Konfidenz
|
||||
</span>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* Editable fields — one per column, driven by columns_used */}
|
||||
<div className="space-y-2">
|
||||
{(() => {
|
||||
const rowCells = getRowCells(activeIndex)
|
||||
return columnsUsed.map((col, colIdx) => {
|
||||
const cell = rowCells.find(c => c.col_index === col.index)
|
||||
if (!cell) return null
|
||||
return (
|
||||
<div key={col.index}>
|
||||
<div className="flex items-center gap-1 mb-0.5">
|
||||
<label className={`text-[10px] font-medium ${colTypeColor(col.type)}`}>
|
||||
{colTypeLabel(col.type)}
|
||||
</label>
|
||||
<span className="text-[9px] text-gray-400">{cell.cell_id}</span>
|
||||
</div>
|
||||
{/* Cell crop */}
|
||||
<div className="border rounded dark:border-gray-700 overflow-hidden bg-white dark:bg-gray-900 h-10 relative mb-1">
|
||||
<CellCrop imageUrl={dewarpedUrl} bbox={cell.bbox_pct} />
|
||||
</div>
|
||||
<textarea
|
||||
ref={colIdx === 0 ? enRef as any : undefined}
|
||||
rows={Math.max(1, (cell.text || '').split('\n').length)}
|
||||
value={cell.text || ''}
|
||||
onChange={(e) => updateCell(cell.cell_id, e.target.value)}
|
||||
className="w-full px-2 py-1.5 text-sm border rounded dark:bg-gray-700 dark:border-gray-600 font-mono resize-none"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={confirmEntry}
|
||||
className="flex-1 px-3 py-1.5 text-xs bg-green-600 text-white rounded-lg hover:bg-green-700 font-medium"
|
||||
>
|
||||
Bestaetigen (Enter)
|
||||
</button>
|
||||
<button
|
||||
onClick={skipEntry}
|
||||
className="px-3 py-1.5 text-xs border rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 dark:border-gray-600"
|
||||
>
|
||||
Skip
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Shortcuts hint */}
|
||||
<div className="text-[10px] text-gray-400 space-y-0.5">
|
||||
<div>Enter = Bestaetigen & weiter</div>
|
||||
<div>Ctrl+Down = Ueberspringen</div>
|
||||
<div>Ctrl+Up = Zurueck</div>
|
||||
</div>
|
||||
|
||||
{/* Row list (compact) */}
|
||||
<div className="border-t dark:border-gray-700 pt-2 mt-2">
|
||||
<div className="text-[10px] font-medium text-gray-500 dark:text-gray-400 mb-1">
|
||||
Alle Zeilen
|
||||
</div>
|
||||
<div className="max-h-48 overflow-y-auto space-y-0.5">
|
||||
{sortedRowIndices.map((rowIdx, posIdx) => {
|
||||
const rowCells = cellsByRow.get(rowIdx) || []
|
||||
const textParts = rowCells.filter(c => c.text).map(c => c.text.replace(/\n/g, ' '))
|
||||
return (
|
||||
<div
|
||||
key={rowIdx}
|
||||
onClick={() => setActiveIndex(posIdx)}
|
||||
className={`flex items-center gap-1 px-2 py-1 rounded text-[10px] cursor-pointer transition-colors ${
|
||||
posIdx === activeIndex
|
||||
? 'bg-teal-50 dark:bg-teal-900/30 border border-teal-200 dark:border-teal-700'
|
||||
: 'hover:bg-gray-50 dark:hover:bg-gray-700/50'
|
||||
}`}
|
||||
>
|
||||
<span className="w-6 text-right text-gray-400 font-mono">R{String(rowIdx).padStart(2, '0')}</span>
|
||||
<span className="truncate text-gray-600 dark:text-gray-400 font-mono">
|
||||
{textParts.join(' \u2192 ') || '\u2014'}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{mode === 'labeling' && (
|
||||
<WordRecognitionLabeling
|
||||
sessionId={sessionId}
|
||||
gridResult={gridResult}
|
||||
editedCells={editedCells}
|
||||
activeIndex={activeIndex}
|
||||
setActiveIndex={setActiveIndex}
|
||||
columnsUsed={columnsUsed}
|
||||
getUniqueRowCount={getUniqueRowCount}
|
||||
getRowCells={getRowCells}
|
||||
updateCell={updateCell}
|
||||
confirmEntry={confirmEntry}
|
||||
skipEntry={skipEntry}
|
||||
enRef={enRef}
|
||||
cellsByRow={cellsByRow}
|
||||
sortedRowIndices={sortedRowIndices}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Controls */}
|
||||
{gridResult && (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4 space-y-3">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
{/* Grid method selector */}
|
||||
<select
|
||||
value={gridMethod}
|
||||
onChange={(e) => setGridMethod(e.target.value as 'v2' | 'words_first')}
|
||||
className="px-2 py-1.5 text-xs border rounded-lg dark:bg-gray-700 dark:border-gray-600"
|
||||
>
|
||||
<option value="v2">Standard (v2)</option>
|
||||
<option value="words_first">Words-First</option>
|
||||
</select>
|
||||
|
||||
{/* OCR Engine selector */}
|
||||
<select
|
||||
value={ocrEngine}
|
||||
onChange={(e) => setOcrEngine(e.target.value as 'auto' | 'tesseract' | 'rapid' | 'paddle')}
|
||||
className="px-2 py-1.5 text-xs border rounded-lg dark:bg-gray-700 dark:border-gray-600"
|
||||
>
|
||||
<option value="auto">Auto (RapidOCR wenn verfuegbar)</option>
|
||||
<option value="rapid">RapidOCR (ONNX)</option>
|
||||
<option value="tesseract">Tesseract</option>
|
||||
<option value="paddle">PP-OCRv5 (lokal)</option>
|
||||
</select>
|
||||
|
||||
{/* Pronunciation selector (only for vocab) */}
|
||||
{isVocab && (
|
||||
<select
|
||||
value={pronunciation}
|
||||
onChange={(e) => setPronunciation(e.target.value as 'british' | 'american')}
|
||||
className="px-2 py-1.5 text-xs border rounded-lg dark:bg-gray-700 dark:border-gray-600"
|
||||
>
|
||||
<option value="british">Britisch (RP)</option>
|
||||
<option value="american">Amerikanisch</option>
|
||||
</select>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => runAutoDetection()}
|
||||
disabled={detecting}
|
||||
className="px-3 py-1.5 text-xs border rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 dark:border-gray-600 disabled:opacity-50"
|
||||
>
|
||||
Erneut erkennen
|
||||
</button>
|
||||
|
||||
{/* Show which engine was used */}
|
||||
{usedEngine && (
|
||||
<span className={`px-2 py-0.5 rounded text-[10px] uppercase font-semibold ${
|
||||
usedEngine === 'rapid' || usedEngine === 'paddle'
|
||||
? 'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300'
|
||||
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400'
|
||||
}`}>
|
||||
{usedEngine === 'paddle' ? 'pp-ocrv5' : usedEngine}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => goToStep(3)}
|
||||
className="px-3 py-1.5 text-xs border rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 dark:border-gray-600 text-orange-600 dark:text-orange-400 border-orange-300 dark:border-orange-700"
|
||||
>
|
||||
Zeilen korrigieren (Step 4)
|
||||
</button>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Ground truth */}
|
||||
{!gtSaved ? (
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Notizen (optional)"
|
||||
value={gtNotes}
|
||||
onChange={(e) => setGtNotes(e.target.value)}
|
||||
className="px-2 py-1 text-xs border rounded dark:bg-gray-700 dark:border-gray-600 w-48"
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleGroundTruth(true)}
|
||||
className="px-3 py-1.5 text-xs bg-green-600 text-white rounded-lg hover:bg-green-700"
|
||||
>
|
||||
Korrekt
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleGroundTruth(false)}
|
||||
className="px-3 py-1.5 text-xs bg-red-600 text-white rounded-lg hover:bg-red-700"
|
||||
>
|
||||
Fehlerhaft
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-xs text-green-600 dark:text-green-400">
|
||||
Ground Truth gespeichert
|
||||
</span>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={onNext}
|
||||
className="px-4 py-1.5 text-xs bg-teal-600 text-white rounded-lg hover:bg-teal-700 font-medium"
|
||||
>
|
||||
Weiter
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<WordRecognitionControls
|
||||
gridResult={gridResult}
|
||||
isVocab={!!isVocab}
|
||||
detecting={detecting}
|
||||
usedEngine={usedEngine}
|
||||
ocrEngine={ocrEngine}
|
||||
setOcrEngine={setOcrEngine}
|
||||
pronunciation={pronunciation}
|
||||
setPronunciation={setPronunciation}
|
||||
gridMethod={gridMethod}
|
||||
setGridMethod={setGridMethod}
|
||||
gtNotes={gtNotes}
|
||||
setGtNotes={setGtNotes}
|
||||
gtSaved={gtSaved}
|
||||
runAutoDetection={runAutoDetection}
|
||||
handleGroundTruth={handleGroundTruth}
|
||||
goToStep={goToStep}
|
||||
onNext={onNext}
|
||||
/>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
@@ -910,27 +457,3 @@ export function StepWordRecognition({ sessionId, onNext, goToStep, skipHealGaps
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* CellCrop: Shows a cropped portion of the dewarped image based on percent bbox.
|
||||
* Uses CSS background-image + background-position for efficient cropping.
|
||||
*/
|
||||
function CellCrop({ imageUrl, bbox }: { imageUrl: string; bbox: { x: number; y: number; w: number; h: number } }) {
|
||||
// Scale factor: how much to zoom into the cell
|
||||
const scaleX = 100 / bbox.w
|
||||
const scaleY = 100 / bbox.h
|
||||
const scale = Math.min(scaleX, scaleY, 8) // Cap zoom at 8x
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-full h-full"
|
||||
style={{
|
||||
backgroundImage: `url(${imageUrl})`,
|
||||
backgroundSize: `${scale * 100}%`,
|
||||
backgroundPosition: `${-bbox.x * scale}% ${-bbox.y * scale}%`,
|
||||
backgroundRepeat: 'no-repeat',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import type { ExcludeRegion, StructureResult } from '@/app/(admin)/ai/ocr-kombi/types'
|
||||
import {
|
||||
KLAUSUR_API,
|
||||
getDocLayoutColor,
|
||||
imageToOverlayPct,
|
||||
mouseToImageCoords,
|
||||
} from './structure-detection-utils'
|
||||
|
||||
interface StructureImageComparisonProps {
|
||||
sessionId: string
|
||||
result: StructureResult
|
||||
overlayTs: number
|
||||
excludeRegions: ExcludeRegion[]
|
||||
drawMode: boolean
|
||||
onAddRegion: (region: ExcludeRegion) => void
|
||||
onDeleteRegion: (index: number) => void
|
||||
}
|
||||
|
||||
export function StructureImageComparison({
|
||||
sessionId,
|
||||
result,
|
||||
overlayTs,
|
||||
excludeRegions,
|
||||
drawMode,
|
||||
onAddRegion,
|
||||
onDeleteRegion,
|
||||
}: StructureImageComparisonProps) {
|
||||
// Exclude region drawing state
|
||||
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 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()
|
||||
}, [])
|
||||
|
||||
// Mouse handlers for drawing exclude rectangles
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
if (!drawMode || !containerRef.current) 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) 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}` }
|
||||
onAddRegion(newRegion)
|
||||
}
|
||||
|
||||
setDrawing(false)
|
||||
setDrawStart(null)
|
||||
setDrawCurrent(null)
|
||||
}, [drawing, drawStart, drawCurrent, excludeRegions.length, onAddRegion])
|
||||
|
||||
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="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 */}
|
||||
{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(); onDeleteRegion(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 && 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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
'use client'
|
||||
|
||||
import type { StructureBox, StructureGraphic } from '@/app/(admin)/ai/ocr-kombi/types'
|
||||
|
||||
interface StructureLayerProps {
|
||||
boxes: StructureBox[]
|
||||
graphics: StructureGraphic[]
|
||||
imgW: number
|
||||
imgH: number
|
||||
show: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders structure boxes and graphic elements as a positioned overlay.
|
||||
* Used as a background layer in both simple and overlay reconstruction modes.
|
||||
*/
|
||||
export function StructureLayer({ boxes, graphics, imgW, imgH, show }: StructureLayerProps) {
|
||||
if (!show) return null
|
||||
if (boxes.length === 0 && graphics.length === 0) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Structure boxes */}
|
||||
{boxes.map((box, i) => {
|
||||
const bgColor = box.bg_color_hex || '#6b7280'
|
||||
return (
|
||||
<div
|
||||
key={`sbox-${i}`}
|
||||
className="absolute pointer-events-none"
|
||||
style={{
|
||||
left: `${(box.x / imgW) * 100}%`,
|
||||
top: `${(box.y / imgH) * 100}%`,
|
||||
width: `${(box.w / imgW) * 100}%`,
|
||||
height: `${(box.h / imgH) * 100}%`,
|
||||
border: `${Math.max(1, box.border_thickness)}px solid ${bgColor}40`,
|
||||
backgroundColor: `${bgColor}0a`,
|
||||
borderRadius: '2px',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Graphic elements */}
|
||||
{graphics.map((g, i) => (
|
||||
<div
|
||||
key={`sgfx-${i}`}
|
||||
className="absolute pointer-events-none"
|
||||
style={{
|
||||
left: `${(g.x / imgW) * 100}%`,
|
||||
top: `${(g.y / imgH) * 100}%`,
|
||||
width: `${(g.w / imgW) * 100}%`,
|
||||
height: `${(g.h / imgH) * 100}%`,
|
||||
border: `1px dashed ${g.color_hex}60`,
|
||||
backgroundColor: `${g.color_hex}08`,
|
||||
borderRadius: '2px',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="absolute text-[8px] leading-none opacity-50"
|
||||
style={{
|
||||
top: '1px',
|
||||
left: '2px',
|
||||
color: g.color_hex,
|
||||
}}
|
||||
>
|
||||
{g.shape === 'illustration' ? 'Illust' : 'Bild'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
'use client'
|
||||
|
||||
import type { ExcludeRegion, StructureResult } from '@/app/(admin)/ai/ocr-kombi/types'
|
||||
import { COLOR_HEX, getDocLayoutColor } from './structure-detection-utils'
|
||||
|
||||
interface StructureResultDetailsProps {
|
||||
result: StructureResult
|
||||
excludeRegions: ExcludeRegion[]
|
||||
}
|
||||
|
||||
export function StructureResultDetails({ result, excludeRegions }: StructureResultDetailsProps) {
|
||||
return (
|
||||
<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 && (
|
||||
<BoxesDetail boxes={result.boxes} />
|
||||
)}
|
||||
|
||||
{/* PP-DocLayout regions detail */}
|
||||
{result.layout_regions && result.layout_regions.length > 0 && (
|
||||
<LayoutRegionsDetail regions={result.layout_regions} />
|
||||
)}
|
||||
|
||||
{/* Zones detail */}
|
||||
<ZonesDetail zones={result.zones} />
|
||||
|
||||
{/* Graphics / visual elements */}
|
||||
{result.graphics && result.graphics.length > 0 && (
|
||||
<GraphicsDetail graphics={result.graphics} />
|
||||
)}
|
||||
|
||||
{/* Color regions */}
|
||||
{Object.keys(result.color_pixel_counts).length > 0 && (
|
||||
<ColorRegionsDetail colorPixelCounts={result.color_pixel_counts} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Sub-sections */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function BoxesDetail({ boxes }: { boxes: StructureResult['boxes'] }) {
|
||||
return (
|
||||
<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">
|
||||
{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>
|
||||
)
|
||||
}
|
||||
|
||||
function LayoutRegionsDetail({ regions }: { regions: NonNullable<StructureResult['layout_regions']> }) {
|
||||
return (
|
||||
<div>
|
||||
<h4 className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-2">
|
||||
PP-DocLayout Regionen ({regions.length})
|
||||
</h4>
|
||||
<div className="space-y-1.5">
|
||||
{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>
|
||||
)
|
||||
}
|
||||
|
||||
function ZonesDetail({ zones }: { zones: StructureResult['zones'] }) {
|
||||
return (
|
||||
<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">
|
||||
{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>
|
||||
)
|
||||
}
|
||||
|
||||
function GraphicsDetail({ graphics }: { graphics: StructureResult['graphics'] }) {
|
||||
// Summary by shape
|
||||
const shapeCounts: Record<string, number> = {}
|
||||
for (const g of graphics) {
|
||||
shapeCounts[g.shape] = (shapeCounts[g.shape] || 0) + 1
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h4 className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-2">
|
||||
Graphische Elemente ({graphics.length})
|
||||
</h4>
|
||||
{/* Summary by shape */}
|
||||
<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' ? '\u2192' : shape === 'circle' ? '\u25CF' : shape === 'line' ? '\u2500' : shape === 'exclamation' ? '\u2757' : shape === 'dot' ? '\u2022' : shape === 'illustration' ? '\uD83D\uDDBC' : '\u25C6'}
|
||||
{' '}{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">
|
||||
{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>
|
||||
)
|
||||
}
|
||||
|
||||
function ColorRegionsDetail({ colorPixelCounts }: { colorPixelCounts: Record<string, number> }) {
|
||||
return (
|
||||
<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(colorPixelCounts)
|
||||
.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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
'use client'
|
||||
|
||||
import type { GroundTruthStatus } from './ground-truth-types'
|
||||
|
||||
interface ValidationPanelProps {
|
||||
notes: string
|
||||
score: number | null
|
||||
status: GroundTruthStatus
|
||||
isGroundTruth: boolean
|
||||
gtSaving: boolean
|
||||
gtMessage: string
|
||||
onNotesChange: (notes: string) => void
|
||||
onScoreChange: (score: number | null) => void
|
||||
onSave: () => Promise<void>
|
||||
onMarkGroundTruth: () => Promise<void>
|
||||
onFinish: () => void
|
||||
}
|
||||
|
||||
export function ValidationPanel({
|
||||
notes, score, status, isGroundTruth, gtSaving, gtMessage,
|
||||
onNotesChange, onScoreChange, onSave, onMarkGroundTruth, onFinish,
|
||||
}: ValidationPanelProps) {
|
||||
return (
|
||||
<>
|
||||
{/* Notes and score */}
|
||||
<div className="border rounded-lg dark:border-gray-700 p-4 space-y-3">
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Bewertung (1-10):
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={10}
|
||||
value={score ?? ''}
|
||||
onChange={e => onScoreChange(e.target.value ? parseInt(e.target.value) : null)}
|
||||
className="w-20 text-sm px-2 py-1 border rounded dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
/>
|
||||
<div className="flex gap-1">
|
||||
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(v => (
|
||||
<button
|
||||
key={v}
|
||||
onClick={() => onScoreChange(v)}
|
||||
className={`w-7 h-7 text-xs rounded ${score === v ? 'bg-teal-600 text-white' : 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600'}`}
|
||||
>
|
||||
{v}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-700 dark:text-gray-300 block mb-1">
|
||||
Notizen:
|
||||
</label>
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={e => onNotesChange(e.target.value)}
|
||||
rows={3}
|
||||
placeholder="Anmerkungen zur Qualitaet der Rekonstruktion..."
|
||||
className="w-full text-sm px-3 py-2 border rounded dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions -- sticky bottom bar */}
|
||||
<div className="sticky bottom-0 bg-white dark:bg-gray-900 border-t dark:border-gray-700 py-3 px-1 -mx-1 flex items-center justify-between">
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{status === 'saved' && <span className="text-green-600 dark:text-green-400">Validierung gespeichert</span>}
|
||||
{status === 'saving' && <span>Speichere...</span>}
|
||||
{gtMessage && (
|
||||
<span className={gtMessage.includes('fehlgeschlagen') ? 'text-red-500' : 'text-amber-600 dark:text-amber-400'}>
|
||||
{gtMessage}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={onMarkGroundTruth}
|
||||
disabled={gtSaving || status === 'saving'}
|
||||
className="px-4 py-2 text-sm bg-amber-600 text-white rounded hover:bg-amber-700 disabled:opacity-50"
|
||||
>
|
||||
{gtSaving ? 'Speichere...' : isGroundTruth ? 'Ground Truth aktualisieren' : 'Als Ground Truth markieren'}
|
||||
</button>
|
||||
<button
|
||||
onClick={onSave}
|
||||
disabled={status === 'saving'}
|
||||
className="px-4 py-2 text-sm bg-gray-600 text-white rounded hover:bg-gray-700 disabled:opacity-50"
|
||||
>
|
||||
Speichern
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
await onSave()
|
||||
onFinish()
|
||||
}}
|
||||
disabled={status === 'saving'}
|
||||
className="px-4 py-2 text-sm bg-teal-600 text-white rounded hover:bg-teal-700 disabled:opacity-50"
|
||||
>
|
||||
Abschliessen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
'use client'
|
||||
|
||||
import type { GridResult } from '@/app/(admin)/ai/ocr-kombi/types'
|
||||
|
||||
interface WordRecognitionControlsProps {
|
||||
gridResult: GridResult
|
||||
isVocab: boolean
|
||||
detecting: boolean
|
||||
usedEngine: string
|
||||
ocrEngine: string
|
||||
setOcrEngine: (engine: 'auto' | 'tesseract' | 'rapid' | 'paddle') => void
|
||||
pronunciation: 'british' | 'american'
|
||||
setPronunciation: (p: 'british' | 'american') => void
|
||||
gridMethod: 'v2' | 'words_first'
|
||||
setGridMethod: (m: 'v2' | 'words_first') => void
|
||||
gtNotes: string
|
||||
setGtNotes: (notes: string) => void
|
||||
gtSaved: boolean
|
||||
runAutoDetection: () => void
|
||||
handleGroundTruth: (isCorrect: boolean) => void
|
||||
goToStep: (step: number) => void
|
||||
onNext: () => void
|
||||
}
|
||||
|
||||
export function WordRecognitionControls({
|
||||
gridResult,
|
||||
isVocab,
|
||||
detecting,
|
||||
usedEngine,
|
||||
ocrEngine,
|
||||
setOcrEngine,
|
||||
pronunciation,
|
||||
setPronunciation,
|
||||
gridMethod,
|
||||
setGridMethod,
|
||||
gtNotes,
|
||||
setGtNotes,
|
||||
gtSaved,
|
||||
runAutoDetection,
|
||||
handleGroundTruth,
|
||||
goToStep,
|
||||
onNext,
|
||||
}: WordRecognitionControlsProps) {
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4 space-y-3">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
{/* Grid method selector */}
|
||||
<select
|
||||
value={gridMethod}
|
||||
onChange={(e) => setGridMethod(e.target.value as 'v2' | 'words_first')}
|
||||
className="px-2 py-1.5 text-xs border rounded-lg dark:bg-gray-700 dark:border-gray-600"
|
||||
>
|
||||
<option value="v2">Standard (v2)</option>
|
||||
<option value="words_first">Words-First</option>
|
||||
</select>
|
||||
|
||||
{/* OCR Engine selector */}
|
||||
<select
|
||||
value={ocrEngine}
|
||||
onChange={(e) => setOcrEngine(e.target.value as 'auto' | 'tesseract' | 'rapid' | 'paddle')}
|
||||
className="px-2 py-1.5 text-xs border rounded-lg dark:bg-gray-700 dark:border-gray-600"
|
||||
>
|
||||
<option value="auto">Auto (RapidOCR wenn verfuegbar)</option>
|
||||
<option value="rapid">RapidOCR (ONNX)</option>
|
||||
<option value="tesseract">Tesseract</option>
|
||||
<option value="paddle">PP-OCRv5 (lokal)</option>
|
||||
</select>
|
||||
|
||||
{/* Pronunciation selector (only for vocab) */}
|
||||
{isVocab && (
|
||||
<select
|
||||
value={pronunciation}
|
||||
onChange={(e) => setPronunciation(e.target.value as 'british' | 'american')}
|
||||
className="px-2 py-1.5 text-xs border rounded-lg dark:bg-gray-700 dark:border-gray-600"
|
||||
>
|
||||
<option value="british">Britisch (RP)</option>
|
||||
<option value="american">Amerikanisch</option>
|
||||
</select>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => runAutoDetection()}
|
||||
disabled={detecting}
|
||||
className="px-3 py-1.5 text-xs border rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 dark:border-gray-600 disabled:opacity-50"
|
||||
>
|
||||
Erneut erkennen
|
||||
</button>
|
||||
|
||||
{/* Show which engine was used */}
|
||||
{usedEngine && (
|
||||
<span className={`px-2 py-0.5 rounded text-[10px] uppercase font-semibold ${
|
||||
usedEngine === 'rapid' || usedEngine === 'paddle'
|
||||
? 'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300'
|
||||
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400'
|
||||
}`}>
|
||||
{usedEngine === 'paddle' ? 'pp-ocrv5' : usedEngine}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => goToStep(3)}
|
||||
className="px-3 py-1.5 text-xs border rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 dark:border-gray-600 text-orange-600 dark:text-orange-400 border-orange-300 dark:border-orange-700"
|
||||
>
|
||||
Zeilen korrigieren (Step 4)
|
||||
</button>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Ground truth */}
|
||||
{!gtSaved ? (
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Notizen (optional)"
|
||||
value={gtNotes}
|
||||
onChange={(e) => setGtNotes(e.target.value)}
|
||||
className="px-2 py-1 text-xs border rounded dark:bg-gray-700 dark:border-gray-600 w-48"
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleGroundTruth(true)}
|
||||
className="px-3 py-1.5 text-xs bg-green-600 text-white rounded-lg hover:bg-green-700"
|
||||
>
|
||||
Korrekt
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleGroundTruth(false)}
|
||||
className="px-3 py-1.5 text-xs bg-red-600 text-white rounded-lg hover:bg-red-700"
|
||||
>
|
||||
Fehlerhaft
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-xs text-green-600 dark:text-green-400">
|
||||
Ground Truth gespeichert
|
||||
</span>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={onNext}
|
||||
className="px-4 py-1.5 text-xs bg-teal-600 text-white rounded-lg hover:bg-teal-700 font-medium"
|
||||
>
|
||||
Weiter
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
'use client'
|
||||
|
||||
import { RefObject } from 'react'
|
||||
import type { GridResult, GridCell } from '@/app/(admin)/ai/ocr-kombi/types'
|
||||
import { colTypeLabel, colTypeColor, confColor, CellCrop } from './WordRecognitionUtils'
|
||||
|
||||
const KLAUSUR_API = '/klausur-api'
|
||||
|
||||
interface WordRecognitionLabelingProps {
|
||||
sessionId: string
|
||||
gridResult: GridResult | null
|
||||
editedCells: GridCell[]
|
||||
activeIndex: number
|
||||
setActiveIndex: (idx: number) => void
|
||||
columnsUsed: GridResult['columns_used']
|
||||
getUniqueRowCount: () => number
|
||||
getRowCells: (rowPosition: number) => GridCell[]
|
||||
updateCell: (cellId: string, value: string) => void
|
||||
confirmEntry: () => void
|
||||
skipEntry: () => void
|
||||
enRef: RefObject<HTMLInputElement | null>
|
||||
/** cellsByRow map + sorted row indices, pre-computed by parent */
|
||||
cellsByRow: Map<number, GridCell[]>
|
||||
sortedRowIndices: number[]
|
||||
}
|
||||
|
||||
export function WordRecognitionLabeling({
|
||||
sessionId,
|
||||
gridResult,
|
||||
editedCells,
|
||||
activeIndex,
|
||||
setActiveIndex,
|
||||
columnsUsed,
|
||||
getUniqueRowCount,
|
||||
getRowCells,
|
||||
updateCell,
|
||||
confirmEntry,
|
||||
skipEntry,
|
||||
enRef,
|
||||
cellsByRow,
|
||||
sortedRowIndices,
|
||||
}: WordRecognitionLabelingProps) {
|
||||
if (editedCells.length === 0) return null
|
||||
|
||||
const overlayUrl = `${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/image/words-overlay`
|
||||
const dewarpedUrl = `${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/image/cropped`
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{/* Left 2/3: Image with highlighted active row */}
|
||||
<div className="col-span-2">
|
||||
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
|
||||
Zeile {activeIndex + 1} von {getUniqueRowCount()}
|
||||
</div>
|
||||
<div className="border rounded-lg overflow-hidden dark:border-gray-700 bg-gray-50 dark:bg-gray-900 relative">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={`${overlayUrl}?t=${Date.now()}`}
|
||||
alt="Wort-Overlay"
|
||||
className="w-full h-auto"
|
||||
/>
|
||||
{/* Highlight overlay for active row */}
|
||||
{(() => {
|
||||
const rowCells = getRowCells(activeIndex)
|
||||
return rowCells.map(cell => (
|
||||
<div
|
||||
key={cell.cell_id}
|
||||
className="absolute border-2 border-yellow-400 bg-yellow-400/10 pointer-events-none"
|
||||
style={{
|
||||
left: `${cell.bbox_pct.x}%`,
|
||||
top: `${cell.bbox_pct.y}%`,
|
||||
width: `${cell.bbox_pct.w}%`,
|
||||
height: `${cell.bbox_pct.h}%`,
|
||||
}}
|
||||
/>
|
||||
))
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right 1/3: Editable fields */}
|
||||
<div className="space-y-3">
|
||||
{/* Navigation */}
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
onClick={() => setActiveIndex(Math.max(0, activeIndex - 1))}
|
||||
disabled={activeIndex === 0}
|
||||
className="px-2 py-1 text-xs border rounded hover:bg-gray-50 dark:hover:bg-gray-700 dark:border-gray-600 disabled:opacity-30"
|
||||
>
|
||||
Zurueck
|
||||
</button>
|
||||
<span className="text-xs text-gray-500">
|
||||
{activeIndex + 1} / {getUniqueRowCount()}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setActiveIndex(Math.min(
|
||||
getUniqueRowCount() - 1,
|
||||
activeIndex + 1
|
||||
))}
|
||||
disabled={activeIndex >= getUniqueRowCount() - 1}
|
||||
className="px-2 py-1 text-xs border rounded hover:bg-gray-50 dark:hover:bg-gray-700 dark:border-gray-600 disabled:opacity-30"
|
||||
>
|
||||
Weiter
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Status badge */}
|
||||
<div className="flex items-center gap-2">
|
||||
{(() => {
|
||||
const rowCells = getRowCells(activeIndex)
|
||||
const avgConf = rowCells.length
|
||||
? Math.round(rowCells.reduce((s, c) => s + c.confidence, 0) / rowCells.length)
|
||||
: 0
|
||||
return (
|
||||
<span className={`text-xs font-mono ${confColor(avgConf)}`}>
|
||||
{avgConf}% Konfidenz
|
||||
</span>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* Editable fields -- one per column, driven by columns_used */}
|
||||
<div className="space-y-2">
|
||||
{(() => {
|
||||
const rowCells = getRowCells(activeIndex)
|
||||
return columnsUsed.map((col, colIdx) => {
|
||||
const cell = rowCells.find(c => c.col_index === col.index)
|
||||
if (!cell) return null
|
||||
return (
|
||||
<div key={col.index}>
|
||||
<div className="flex items-center gap-1 mb-0.5">
|
||||
<label className={`text-[10px] font-medium ${colTypeColor(col.type)}`}>
|
||||
{colTypeLabel(col.type)}
|
||||
</label>
|
||||
<span className="text-[9px] text-gray-400">{cell.cell_id}</span>
|
||||
</div>
|
||||
{/* Cell crop */}
|
||||
<div className="border rounded dark:border-gray-700 overflow-hidden bg-white dark:bg-gray-900 h-10 relative mb-1">
|
||||
<CellCrop imageUrl={dewarpedUrl} bbox={cell.bbox_pct} />
|
||||
</div>
|
||||
<textarea
|
||||
ref={colIdx === 0 ? enRef as any : undefined}
|
||||
rows={Math.max(1, (cell.text || '').split('\n').length)}
|
||||
value={cell.text || ''}
|
||||
onChange={(e) => updateCell(cell.cell_id, e.target.value)}
|
||||
className="w-full px-2 py-1.5 text-sm border rounded dark:bg-gray-700 dark:border-gray-600 font-mono resize-none"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={confirmEntry}
|
||||
className="flex-1 px-3 py-1.5 text-xs bg-green-600 text-white rounded-lg hover:bg-green-700 font-medium"
|
||||
>
|
||||
Bestaetigen (Enter)
|
||||
</button>
|
||||
<button
|
||||
onClick={skipEntry}
|
||||
className="px-3 py-1.5 text-xs border rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 dark:border-gray-600"
|
||||
>
|
||||
Skip
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Shortcuts hint */}
|
||||
<div className="text-[10px] text-gray-400 space-y-0.5">
|
||||
<div>Enter = Bestaetigen & weiter</div>
|
||||
<div>Ctrl+Down = Ueberspringen</div>
|
||||
<div>Ctrl+Up = Zurueck</div>
|
||||
</div>
|
||||
|
||||
{/* Row list (compact) */}
|
||||
<div className="border-t dark:border-gray-700 pt-2 mt-2">
|
||||
<div className="text-[10px] font-medium text-gray-500 dark:text-gray-400 mb-1">
|
||||
Alle Zeilen
|
||||
</div>
|
||||
<div className="max-h-48 overflow-y-auto space-y-0.5">
|
||||
{sortedRowIndices.map((rowIdx, posIdx) => {
|
||||
const rowCells = cellsByRow.get(rowIdx) || []
|
||||
const textParts = rowCells.filter(c => c.text).map(c => c.text.replace(/\n/g, ' '))
|
||||
return (
|
||||
<div
|
||||
key={rowIdx}
|
||||
onClick={() => setActiveIndex(posIdx)}
|
||||
className={`flex items-center gap-1 px-2 py-1 rounded text-[10px] cursor-pointer transition-colors ${
|
||||
posIdx === activeIndex
|
||||
? 'bg-teal-50 dark:bg-teal-900/30 border border-teal-200 dark:border-teal-700'
|
||||
: 'hover:bg-gray-50 dark:hover:bg-gray-700/50'
|
||||
}`}
|
||||
>
|
||||
<span className="w-6 text-right text-gray-400 font-mono">R{String(rowIdx).padStart(2, '0')}</span>
|
||||
<span className="truncate text-gray-600 dark:text-gray-400 font-mono">
|
||||
{textParts.join(' \u2192 ') || '\u2014'}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
'use client'
|
||||
|
||||
import { RefObject } from 'react'
|
||||
import type { GridResult, GridCell } from '@/app/(admin)/ai/ocr-kombi/types'
|
||||
import { MultilineText, colTypeLabel, colTypeColor, confColor } from './WordRecognitionUtils'
|
||||
|
||||
const KLAUSUR_API = '/klausur-api'
|
||||
|
||||
interface WordRecognitionOverviewProps {
|
||||
sessionId: string
|
||||
gridResult: GridResult | null
|
||||
detecting: boolean
|
||||
editedCells: GridCell[]
|
||||
activeIndex: number
|
||||
setActiveIndex: (idx: number) => void
|
||||
setMode: (mode: 'overview' | 'labeling') => void
|
||||
tableEndRef: RefObject<HTMLDivElement | null>
|
||||
}
|
||||
|
||||
export function WordRecognitionOverview({
|
||||
sessionId,
|
||||
gridResult,
|
||||
detecting,
|
||||
editedCells,
|
||||
activeIndex,
|
||||
setActiveIndex,
|
||||
setMode,
|
||||
tableEndRef,
|
||||
}: WordRecognitionOverviewProps) {
|
||||
const overlayUrl = `${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/image/words-overlay`
|
||||
const dewarpedUrl = `${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/image/cropped`
|
||||
|
||||
const summary = gridResult?.summary
|
||||
const columnsUsed = gridResult?.columns_used || []
|
||||
|
||||
// Group cells by row for table display
|
||||
const cellsByRow: Map<number, GridCell[]> = new Map()
|
||||
for (const cell of editedCells) {
|
||||
const existing = cellsByRow.get(cell.row_index) || []
|
||||
existing.push(cell)
|
||||
cellsByRow.set(cell.row_index, existing)
|
||||
}
|
||||
const sortedRowIndices = [...cellsByRow.keys()].sort((a, b) => a - b)
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Images: overlay vs clean */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
|
||||
Mit Grid-Overlay
|
||||
</div>
|
||||
<div className="border rounded-lg overflow-hidden dark:border-gray-700 bg-gray-50 dark:bg-gray-900">
|
||||
{gridResult ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={`${overlayUrl}?t=${Date.now()}`}
|
||||
alt="Wort-Overlay"
|
||||
className="w-full h-auto"
|
||||
/>
|
||||
) : (
|
||||
<div className="aspect-[3/4] flex items-center justify-center text-gray-400 text-sm">
|
||||
{detecting ? 'Erkenne Woerter...' : 'Keine Daten'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
|
||||
Entzerrtes Bild
|
||||
</div>
|
||||
<div className="border rounded-lg overflow-hidden dark:border-gray-700 bg-gray-50 dark:bg-gray-900">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={dewarpedUrl}
|
||||
alt="Entzerrt"
|
||||
className="w-full h-auto"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Result summary (only after streaming completes) */}
|
||||
{gridResult && summary && !detecting && (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Ergebnis: {summary.non_empty_cells}/{summary.total_cells} Zellen mit Text
|
||||
({sortedRowIndices.length} Zeilen, {columnsUsed.length} Spalten)
|
||||
</h4>
|
||||
<span className="text-xs text-gray-400">
|
||||
{gridResult.duration_seconds}s
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Summary badges */}
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<span className="px-2 py-0.5 rounded text-xs font-medium bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300">
|
||||
Zellen: {summary.non_empty_cells}/{summary.total_cells}
|
||||
</span>
|
||||
{columnsUsed.map((col, i) => (
|
||||
<span key={i} className={`px-2 py-0.5 rounded text-xs font-medium bg-gray-100 dark:bg-gray-700 ${colTypeColor(col.type)}`}>
|
||||
C{col.index}: {colTypeLabel(col.type)}
|
||||
</span>
|
||||
))}
|
||||
{summary.low_confidence > 0 && (
|
||||
<span className="px-2 py-0.5 rounded text-xs font-medium bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300">
|
||||
Unsicher: {summary.low_confidence}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Entry/Cell table */}
|
||||
<div className="max-h-80 overflow-y-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="sticky top-0 bg-white dark:bg-gray-800">
|
||||
<tr className="text-left text-gray-500 dark:text-gray-400 border-b dark:border-gray-700">
|
||||
<th className="py-1 pr-2 w-12">Zeile</th>
|
||||
{columnsUsed.map((col, i) => (
|
||||
<th key={i} className={`py-1 pr-2 ${colTypeColor(col.type)}`}>
|
||||
{colTypeLabel(col.type)}
|
||||
</th>
|
||||
))}
|
||||
<th className="py-1 w-12 text-right">Conf</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedRowIndices.map((rowIdx, posIdx) => {
|
||||
const rowCells = cellsByRow.get(rowIdx) || []
|
||||
const avgConf = rowCells.length
|
||||
? Math.round(rowCells.reduce((s, c) => s + c.confidence, 0) / rowCells.length)
|
||||
: 0
|
||||
return (
|
||||
<tr
|
||||
key={rowIdx}
|
||||
className={`border-b dark:border-gray-700/50 ${
|
||||
posIdx === activeIndex ? 'bg-teal-50 dark:bg-teal-900/20' : ''
|
||||
}`}
|
||||
onClick={() => { setActiveIndex(posIdx); setMode('labeling') }}
|
||||
>
|
||||
<td className="py-1 pr-2 text-gray-400 font-mono text-[10px]">
|
||||
R{String(rowIdx).padStart(2, '0')}
|
||||
</td>
|
||||
{columnsUsed.map((col) => {
|
||||
const cell = rowCells.find(c => c.col_index === col.index)
|
||||
return (
|
||||
<td key={col.index} className="py-1 pr-2 font-mono text-gray-700 dark:text-gray-300 cursor-pointer">
|
||||
<MultilineText text={cell?.text || ''} />
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
<td className={`py-1 text-right font-mono ${confColor(avgConf)}`}>
|
||||
{avgConf}%
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
<div ref={tableEndRef} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Streaming cell table (shown while detecting, before complete) */}
|
||||
{detecting && editedCells.length > 0 && !gridResult?.summary?.non_empty_cells && (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4 space-y-3">
|
||||
<h4 className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Live: {editedCells.length} Zellen erkannt...
|
||||
</h4>
|
||||
<div className="max-h-80 overflow-y-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="sticky top-0 bg-white dark:bg-gray-800">
|
||||
<tr className="text-left text-gray-500 dark:text-gray-400 border-b dark:border-gray-700">
|
||||
<th className="py-1 pr-2 w-12">Zelle</th>
|
||||
{columnsUsed.map((col, i) => (
|
||||
<th key={i} className={`py-1 pr-2 ${colTypeColor(col.type)}`}>
|
||||
{colTypeLabel(col.type)}
|
||||
</th>
|
||||
))}
|
||||
<th className="py-1 w-12 text-right">Conf</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(() => {
|
||||
const liveByRow: Map<number, GridCell[]> = new Map()
|
||||
for (const cell of editedCells) {
|
||||
const existing = liveByRow.get(cell.row_index) || []
|
||||
existing.push(cell)
|
||||
liveByRow.set(cell.row_index, existing)
|
||||
}
|
||||
const liveSorted = [...liveByRow.keys()].sort((a, b) => a - b)
|
||||
return liveSorted.map(rowIdx => {
|
||||
const rowCells = liveByRow.get(rowIdx) || []
|
||||
const avgConf = rowCells.length
|
||||
? Math.round(rowCells.reduce((s, c) => s + c.confidence, 0) / rowCells.length)
|
||||
: 0
|
||||
return (
|
||||
<tr key={rowIdx} className="border-b dark:border-gray-700/50 animate-fade-in">
|
||||
<td className="py-1 pr-2 text-gray-400 font-mono text-[10px]">
|
||||
R{String(rowIdx).padStart(2, '0')}
|
||||
</td>
|
||||
{columnsUsed.map((col) => {
|
||||
const cell = rowCells.find(c => c.col_index === col.index)
|
||||
return (
|
||||
<td key={col.index} className="py-1 pr-2 font-mono text-gray-700 dark:text-gray-300">
|
||||
<MultilineText text={cell?.text || ''} />
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
<td className={`py-1 text-right font-mono ${confColor(avgConf)}`}>
|
||||
{avgConf}%
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})
|
||||
})()}
|
||||
</tbody>
|
||||
</table>
|
||||
<div ref={tableEndRef} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Shared utilities and small presentational components for StepWordRecognition.
|
||||
*/
|
||||
|
||||
/** Render text with \n as line breaks */
|
||||
export function MultilineText({ text }: { text: string }) {
|
||||
if (!text) return <span className="text-gray-300 dark:text-gray-600">—</span>
|
||||
const lines = text.split('\n')
|
||||
if (lines.length === 1) return <>{text}</>
|
||||
return <>{lines.map((line, i) => (
|
||||
<span key={i}>{line}{i < lines.length - 1 && <br />}</span>
|
||||
))}</>
|
||||
}
|
||||
|
||||
/** Column type to human-readable header */
|
||||
export function colTypeLabel(colType: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
column_en: 'English',
|
||||
column_de: 'Deutsch',
|
||||
column_example: 'Example',
|
||||
column_text: 'Text',
|
||||
column_marker: 'Marker',
|
||||
page_ref: 'Seite',
|
||||
}
|
||||
return labels[colType] || colType.replace('column_', '')
|
||||
}
|
||||
|
||||
/** Column type to color class */
|
||||
export function colTypeColor(colType: string): string {
|
||||
const colors: Record<string, string> = {
|
||||
column_en: 'text-blue-600 dark:text-blue-400',
|
||||
column_de: 'text-green-600 dark:text-green-400',
|
||||
column_example: 'text-orange-600 dark:text-orange-400',
|
||||
column_text: 'text-purple-600 dark:text-purple-400',
|
||||
column_marker: 'text-gray-500 dark:text-gray-400',
|
||||
}
|
||||
return colors[colType] || 'text-gray-600 dark:text-gray-400'
|
||||
}
|
||||
|
||||
/** Confidence score to color class */
|
||||
export function confColor(conf: number): string {
|
||||
if (conf >= 70) return 'text-green-600 dark:text-green-400'
|
||||
if (conf >= 50) return 'text-yellow-600 dark:text-yellow-400'
|
||||
return 'text-red-600 dark:text-red-400'
|
||||
}
|
||||
|
||||
/** Status to badge color class */
|
||||
export function statusBadge(status?: string): string {
|
||||
const map: Record<string, string> = {
|
||||
pending: 'bg-gray-100 dark:bg-gray-700 text-gray-500',
|
||||
confirmed: 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400',
|
||||
edited: 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400',
|
||||
skipped: 'bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-400',
|
||||
}
|
||||
return map[status || 'pending'] || map.pending
|
||||
}
|
||||
|
||||
/**
|
||||
* CellCrop: Shows a cropped portion of the dewarped image based on percent bbox.
|
||||
* Uses CSS background-image + background-position for efficient cropping.
|
||||
*/
|
||||
export function CellCrop({ imageUrl, bbox }: { imageUrl: string; bbox: { x: number; y: number; w: number; h: number } }) {
|
||||
// Scale factor: how much to zoom into the cell
|
||||
const scaleX = 100 / bbox.w
|
||||
const scaleY = 100 / bbox.h
|
||||
const scale = Math.min(scaleX, scaleY, 8) // Cap zoom at 8x
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-full h-full"
|
||||
style={{
|
||||
backgroundImage: `url(${imageUrl})`,
|
||||
backgroundSize: `${scale * 100}%`,
|
||||
backgroundPosition: `${-bbox.x * scale}% ${-bbox.y * scale}%`,
|
||||
backgroundRepeat: 'no-repeat',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
export const METHOD_LABELS: Record<string, string> = {
|
||||
vertical_edge: 'A: Vertikale Kanten',
|
||||
projection: 'B: Projektions-Varianz',
|
||||
hough_lines: 'C: Hough-Linien',
|
||||
text_lines: 'D: Textzeilenanalyse',
|
||||
manual: 'Manuell',
|
||||
manual_combined: 'Manuell (kombiniert)',
|
||||
none: 'Keine Korrektur',
|
||||
}
|
||||
|
||||
export const SHEAR_METHOD_KEYS = ['vertical_edge', 'projection', 'hough_lines', 'text_lines'] as const
|
||||
|
||||
/** Colour for a confidence value (0-1). */
|
||||
export function confColor(conf: number): string {
|
||||
if (conf >= 0.7) return 'text-green-600 dark:text-green-400'
|
||||
if (conf >= 0.5) return 'text-yellow-600 dark:text-yellow-400'
|
||||
return 'text-gray-400'
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { ImageRegion, ImageStyle } from '@/app/(admin)/ai/ocr-kombi/types'
|
||||
import type { GridCell, ColumnMeta } from '@/app/(admin)/ai/ocr-kombi/types'
|
||||
|
||||
export const KLAUSUR_API = '/klausur-api'
|
||||
|
||||
export const COL_TYPE_COLORS: Record<string, string> = {
|
||||
column_en: '#3b82f6',
|
||||
column_de: '#22c55e',
|
||||
column_example: '#f97316',
|
||||
column_text: '#a855f7',
|
||||
page_ref: '#06b6d4',
|
||||
column_marker: '#6b7280',
|
||||
}
|
||||
|
||||
export interface StepGroundTruthProps {
|
||||
sessionId: string | null
|
||||
onNext: () => void
|
||||
}
|
||||
|
||||
export interface SessionData {
|
||||
cells: GridCell[]
|
||||
columnsUsed: ColumnMeta[]
|
||||
imageWidth: number
|
||||
imageHeight: number
|
||||
originalImageUrl: string
|
||||
}
|
||||
|
||||
export type ImageRegionWithState = ImageRegion & { generating?: boolean }
|
||||
|
||||
export type GroundTruthStatus = 'loading' | 'ready' | 'saving' | 'saved' | 'error'
|
||||
|
||||
export type { GridCell, ColumnMeta, ImageRegion, ImageStyle }
|
||||
@@ -0,0 +1,57 @@
|
||||
export interface LlmChange {
|
||||
row_index: number
|
||||
field: 'english' | 'german' | 'example'
|
||||
old: string
|
||||
new: string
|
||||
}
|
||||
|
||||
export interface StepLlmReviewProps {
|
||||
sessionId: string | null
|
||||
onNext: () => void
|
||||
}
|
||||
|
||||
export interface ReviewMeta {
|
||||
total_entries: number
|
||||
to_review: number
|
||||
skipped: number
|
||||
model: string
|
||||
skipped_indices?: number[]
|
||||
}
|
||||
|
||||
export interface StreamProgress {
|
||||
current: number
|
||||
total: number
|
||||
}
|
||||
|
||||
export const FIELD_LABELS: Record<string, string> = {
|
||||
english: 'EN',
|
||||
german: 'DE',
|
||||
example: 'Beispiel',
|
||||
source_page: 'Seite',
|
||||
marker: 'Marker',
|
||||
text: 'Text',
|
||||
}
|
||||
|
||||
/** Map column type to WordEntry field name */
|
||||
export const COL_TYPE_TO_FIELD: Record<string, string> = {
|
||||
column_en: 'english',
|
||||
column_de: 'german',
|
||||
column_example: 'example',
|
||||
page_ref: 'source_page',
|
||||
column_marker: 'marker',
|
||||
column_text: 'text',
|
||||
}
|
||||
|
||||
/** Column type to color class */
|
||||
export const COL_TYPE_COLOR: Record<string, string> = {
|
||||
column_en: 'text-blue-600 dark:text-blue-400',
|
||||
column_de: 'text-green-600 dark:text-green-400',
|
||||
column_example: 'text-orange-600 dark:text-orange-400',
|
||||
page_ref: 'text-cyan-600 dark:text-cyan-400',
|
||||
column_marker: 'text-gray-500 dark:text-gray-400',
|
||||
column_text: 'text-gray-700 dark:text-gray-300',
|
||||
}
|
||||
|
||||
export type RowStatus = 'pending' | 'active' | 'reviewed' | 'corrected' | 'skipped'
|
||||
|
||||
export const KLAUSUR_API = '/klausur-api'
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Utility functions and constants for StepStructureDetection.
|
||||
* Pure logic — no React, no 'use client' needed.
|
||||
*/
|
||||
|
||||
export const KLAUSUR_API = '/klausur-api'
|
||||
|
||||
export const COLOR_HEX: Record<string, string> = {
|
||||
red: '#dc2626',
|
||||
orange: '#ea580c',
|
||||
yellow: '#ca8a04',
|
||||
green: '#16a34a',
|
||||
blue: '#2563eb',
|
||||
purple: '#9333ea',
|
||||
}
|
||||
|
||||
export type DetectionMethod = 'auto' | 'opencv' | 'ppdoclayout'
|
||||
|
||||
/** Color map for PP-DocLayout region classes */
|
||||
export 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'
|
||||
|
||||
export 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.
|
||||
*/
|
||||
export 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.
|
||||
*/
|
||||
export 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}%`,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import type { ImageStyle } from '@/app/(admin)/ai/ocr-kombi/types'
|
||||
import type {
|
||||
SessionData, ImageRegionWithState, GroundTruthStatus,
|
||||
} from './ground-truth-types'
|
||||
import { KLAUSUR_API } from './ground-truth-types'
|
||||
|
||||
export function useGroundTruthSession(sessionId: string | null) {
|
||||
const [status, setStatus] = useState<GroundTruthStatus>('loading')
|
||||
const [error, setError] = useState('')
|
||||
const [session, setSession] = useState<SessionData | null>(null)
|
||||
const [imageRegions, setImageRegions] = useState<ImageRegionWithState[]>([])
|
||||
const [detecting, setDetecting] = useState(false)
|
||||
const [zoom, setZoom] = useState(100)
|
||||
const [syncScroll, setSyncScroll] = useState(true)
|
||||
const [notes, setNotes] = useState('')
|
||||
const [score, setScore] = useState<number | null>(null)
|
||||
const [drawingRegion, setDrawingRegion] = useState(false)
|
||||
const [dragStart, setDragStart] = useState<{ x: number; y: number } | null>(null)
|
||||
const [dragEnd, setDragEnd] = useState<{ x: number; y: number } | null>(null)
|
||||
const [isGroundTruth, setIsGroundTruth] = useState(false)
|
||||
const [gtSaving, setGtSaving] = useState(false)
|
||||
const [gtMessage, setGtMessage] = useState('')
|
||||
|
||||
const leftPanelRef = useRef<HTMLDivElement>(null)
|
||||
const rightPanelRef = useRef<HTMLDivElement>(null)
|
||||
const reconRef = useRef<HTMLDivElement>(null)
|
||||
const [reconWidth, setReconWidth] = useState(0)
|
||||
|
||||
// Track reconstruction container width for font size calculation
|
||||
useEffect(() => {
|
||||
const el = reconRef.current
|
||||
if (!el) return
|
||||
const obs = new ResizeObserver(entries => {
|
||||
for (const entry of entries) setReconWidth(entry.contentRect.width)
|
||||
})
|
||||
obs.observe(el)
|
||||
return () => obs.disconnect()
|
||||
}, [session])
|
||||
|
||||
const loadSessionData = useCallback(async () => {
|
||||
if (!sessionId) return
|
||||
setStatus('loading')
|
||||
try {
|
||||
const resp = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}`)
|
||||
if (!resp.ok) throw new Error(`Failed to load session: ${resp.status}`)
|
||||
const data = await resp.json()
|
||||
|
||||
const wordResult = data.word_result || {}
|
||||
setSession({
|
||||
cells: wordResult.cells || [],
|
||||
columnsUsed: wordResult.columns_used || [],
|
||||
imageWidth: wordResult.image_width || data.image_width || 800,
|
||||
imageHeight: wordResult.image_height || data.image_height || 600,
|
||||
originalImageUrl: data.original_image_url
|
||||
? `${KLAUSUR_API}${data.original_image_url}`
|
||||
: `${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/image/original`,
|
||||
})
|
||||
|
||||
// Check if session has ground truth reference
|
||||
const gt = data.ground_truth
|
||||
setIsGroundTruth(!!gt?.build_grid_reference)
|
||||
|
||||
// Load existing validation data
|
||||
const valResp = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/reconstruction/validation`)
|
||||
if (valResp.ok) {
|
||||
const valData = await valResp.json()
|
||||
const validation = valData.validation
|
||||
if (validation) {
|
||||
setImageRegions(validation.image_regions || [])
|
||||
setNotes(validation.notes || '')
|
||||
setScore(validation.score ?? null)
|
||||
}
|
||||
}
|
||||
|
||||
setStatus('ready')
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
setStatus('error')
|
||||
}
|
||||
}, [sessionId])
|
||||
|
||||
// Load session data
|
||||
useEffect(() => {
|
||||
if (!sessionId) return
|
||||
loadSessionData()
|
||||
}, [sessionId, loadSessionData])
|
||||
|
||||
// Sync scroll between panels
|
||||
const handleScroll = useCallback((source: 'left' | 'right') => {
|
||||
if (!syncScroll) return
|
||||
const from = source === 'left' ? leftPanelRef.current : rightPanelRef.current
|
||||
const to = source === 'left' ? rightPanelRef.current : leftPanelRef.current
|
||||
if (from && to) {
|
||||
to.scrollTop = from.scrollTop
|
||||
to.scrollLeft = from.scrollLeft
|
||||
}
|
||||
}, [syncScroll])
|
||||
|
||||
// Detect images via VLM
|
||||
const handleDetectImages = useCallback(async () => {
|
||||
if (!sessionId) return
|
||||
setDetecting(true)
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/reconstruction/detect-images`,
|
||||
{ method: 'POST' }
|
||||
)
|
||||
if (!resp.ok) throw new Error(`Detection failed: ${resp.status}`)
|
||||
const data = await resp.json()
|
||||
setImageRegions(data.regions || [])
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setDetecting(false)
|
||||
}
|
||||
}, [sessionId])
|
||||
|
||||
// Generate image for a region
|
||||
const handleGenerateImage = useCallback(async (index: number) => {
|
||||
if (!sessionId) return
|
||||
const region = imageRegions[index]
|
||||
if (!region) return
|
||||
|
||||
setImageRegions(prev => prev.map((r, i) => i === index ? { ...r, generating: true } : r))
|
||||
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/reconstruction/generate-image`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
region_index: index,
|
||||
prompt: region.prompt,
|
||||
style: region.style,
|
||||
}),
|
||||
}
|
||||
)
|
||||
if (!resp.ok) throw new Error(`Generation failed: ${resp.status}`)
|
||||
const data = await resp.json()
|
||||
|
||||
setImageRegions(prev => prev.map((r, i) =>
|
||||
i === index ? { ...r, image_b64: data.image_b64, generating: false } : r
|
||||
))
|
||||
} catch (e) {
|
||||
setImageRegions(prev => prev.map((r, i) => i === index ? { ...r, generating: false } : r))
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
}, [sessionId, imageRegions])
|
||||
|
||||
// Save validation
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!sessionId) {
|
||||
setError('Keine Session-ID vorhanden')
|
||||
return
|
||||
}
|
||||
setStatus('saving')
|
||||
setError('')
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/reconstruction/validate`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ notes, score: score ?? 0 }),
|
||||
}
|
||||
)
|
||||
if (!resp.ok) {
|
||||
const body = await resp.text().catch(() => '')
|
||||
throw new Error(`Speichern fehlgeschlagen (${resp.status}): ${body}`)
|
||||
}
|
||||
setStatus('saved')
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
setStatus('ready')
|
||||
}
|
||||
}, [sessionId, notes, score])
|
||||
|
||||
// Mark/update ground truth reference
|
||||
const handleMarkGroundTruth = useCallback(async () => {
|
||||
if (!sessionId) return
|
||||
setGtSaving(true)
|
||||
setGtMessage('')
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/mark-ground-truth?pipeline=ocr-pipeline`,
|
||||
{ method: 'POST' }
|
||||
)
|
||||
if (!resp.ok) {
|
||||
const body = await resp.text().catch(() => '')
|
||||
throw new Error(`Ground Truth fehlgeschlagen (${resp.status}): ${body}`)
|
||||
}
|
||||
const data = await resp.json()
|
||||
setIsGroundTruth(true)
|
||||
setGtMessage(`Ground Truth gespeichert (${data.cells_saved} Zellen)`)
|
||||
setTimeout(() => setGtMessage(''), 5000)
|
||||
} catch (e) {
|
||||
setGtMessage(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setGtSaving(false)
|
||||
}
|
||||
}, [sessionId])
|
||||
|
||||
// Handle manual region drawing on reconstruction
|
||||
const handleReconMouseDown = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!drawingRegion) return
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
const x = ((e.clientX - rect.left) / rect.width) * 100
|
||||
const y = ((e.clientY - rect.top) / rect.height) * 100
|
||||
setDragStart({ x, y })
|
||||
setDragEnd({ x, y })
|
||||
}, [drawingRegion])
|
||||
|
||||
const handleReconMouseMove = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!dragStart) return
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
const x = ((e.clientX - rect.left) / rect.width) * 100
|
||||
const y = ((e.clientY - rect.top) / rect.height) * 100
|
||||
setDragEnd({ x, y })
|
||||
}, [dragStart])
|
||||
|
||||
const handleReconMouseUp = useCallback(() => {
|
||||
if (!dragStart || !dragEnd) return
|
||||
const x = Math.min(dragStart.x, dragEnd.x)
|
||||
const y = Math.min(dragStart.y, dragEnd.y)
|
||||
const w = Math.abs(dragEnd.x - dragStart.x)
|
||||
const h = Math.abs(dragEnd.y - dragStart.y)
|
||||
|
||||
if (w > 2 && h > 2) {
|
||||
setImageRegions(prev => [...prev, {
|
||||
bbox_pct: { x, y, w, h },
|
||||
prompt: '',
|
||||
description: 'Manually selected region',
|
||||
image_b64: null,
|
||||
style: 'educational' as ImageStyle,
|
||||
}])
|
||||
}
|
||||
|
||||
setDragStart(null)
|
||||
setDragEnd(null)
|
||||
setDrawingRegion(false)
|
||||
}, [dragStart, dragEnd])
|
||||
|
||||
const handleRemoveRegion = useCallback((index: number) => {
|
||||
setImageRegions(prev => prev.filter((_, i) => i !== index))
|
||||
}, [])
|
||||
|
||||
return {
|
||||
// State
|
||||
status, error, session, imageRegions, detecting, zoom, syncScroll,
|
||||
notes, score, drawingRegion, dragStart, dragEnd,
|
||||
isGroundTruth, gtSaving, gtMessage, reconWidth,
|
||||
// Refs
|
||||
leftPanelRef, rightPanelRef, reconRef,
|
||||
// Setters
|
||||
setError, setZoom, setSyncScroll, setNotes, setScore,
|
||||
setDrawingRegion, setImageRegions,
|
||||
// Handlers
|
||||
loadSessionData, handleScroll, handleDetectImages, handleGenerateImage,
|
||||
handleSave, handleMarkGroundTruth,
|
||||
handleReconMouseDown, handleReconMouseMove, handleReconMouseUp,
|
||||
handleRemoveRegion,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import type { GridResult, GridCell, StructureResult, StructureBox, StructureGraphic } from '@/app/(admin)/ai/ocr-kombi/types'
|
||||
import type { EditableCell, EditorMode, PageRegion, RowItem, PageZone, ReconstructionStatus } from './StepReconstructionTypes'
|
||||
import { KLAUSUR_API } from './StepReconstructionTypes'
|
||||
|
||||
interface ReconstructionData {
|
||||
status: ReconstructionStatus
|
||||
setStatus: (s: ReconstructionStatus) => void
|
||||
error: string
|
||||
setError: (e: string) => void
|
||||
cells: EditableCell[]
|
||||
setCells: (cells: EditableCell[]) => void
|
||||
gridCells: GridCell[]
|
||||
editorMode: EditorMode
|
||||
setEditorMode: (mode: EditorMode) => void
|
||||
isParentWithBoxes: boolean
|
||||
mergedGridCells: GridCell[]
|
||||
parentColumns: PageRegion[]
|
||||
parentRows: RowItem[]
|
||||
parentZones: PageZone[]
|
||||
imageNaturalSize: { w: number; h: number } | null
|
||||
setImageNaturalSize: (size: { w: number; h: number } | null) => void
|
||||
structureBoxes: StructureBox[]
|
||||
structureGraphics: StructureGraphic[]
|
||||
imageRotation: 0 | 180
|
||||
setImageRotation: (fn: (r: 0 | 180) => 0 | 180) => void
|
||||
loadSessionData: () => void
|
||||
}
|
||||
|
||||
function gridCellToEditable(c: GridCell): EditableCell {
|
||||
return {
|
||||
cellId: c.cell_id,
|
||||
text: c.text,
|
||||
originalText: c.text,
|
||||
bboxPct: c.bbox_pct,
|
||||
colType: c.col_type,
|
||||
rowIndex: c.row_index,
|
||||
colIndex: c.col_index,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads reconstruction session data from the API, including sub-session
|
||||
* merging for parent sessions with box zones.
|
||||
*/
|
||||
export function useReconstructionData(
|
||||
sessionId: string | null,
|
||||
onResetEditing: () => void,
|
||||
): ReconstructionData {
|
||||
const [status, setStatus] = useState<ReconstructionStatus>('loading')
|
||||
const [error, setError] = useState('')
|
||||
const [cells, setCells] = useState<EditableCell[]>([])
|
||||
const [gridCells, setGridCells] = useState<GridCell[]>([])
|
||||
const [editorMode, setEditorMode] = useState<EditorMode>('simple')
|
||||
|
||||
const [isParentWithBoxes, setIsParentWithBoxes] = useState(false)
|
||||
const [mergedGridCells, setMergedGridCells] = useState<GridCell[]>([])
|
||||
const [parentColumns, setParentColumns] = useState<PageRegion[]>([])
|
||||
const [parentRows, setParentRows] = useState<RowItem[]>([])
|
||||
const [parentZones, setParentZones] = useState<PageZone[]>([])
|
||||
const [imageNaturalSize, setImageNaturalSize] = useState<{ w: number; h: number } | null>(null)
|
||||
const [imageRotation, setImageRotation] = useState<0 | 180>(0)
|
||||
const [structureBoxes, setStructureBoxes] = useState<StructureBox[]>([])
|
||||
const [structureGraphics, setStructureGraphics] = useState<StructureGraphic[]>([])
|
||||
|
||||
const loadSessionData = useCallback(async () => {
|
||||
if (!sessionId) return
|
||||
setStatus('loading')
|
||||
try {
|
||||
const res = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}`)
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const data = await res.json()
|
||||
|
||||
const wordResult: GridResult | undefined = data.word_result
|
||||
if (!wordResult) {
|
||||
setError('Keine Worterkennungsdaten gefunden. Bitte zuerst Schritt 5 abschliessen.')
|
||||
setStatus('error')
|
||||
return
|
||||
}
|
||||
|
||||
const rawGridCells: GridCell[] = wordResult.cells || []
|
||||
setGridCells(rawGridCells)
|
||||
const allEditableCells = rawGridCells.map(gridCellToEditable)
|
||||
|
||||
setCells(allEditableCells)
|
||||
onResetEditing()
|
||||
|
||||
// Load structure result (boxes, graphics, colors)
|
||||
const structureResult: StructureResult | undefined = data.structure_result
|
||||
if (structureResult) {
|
||||
setStructureBoxes(structureResult.boxes || [])
|
||||
setStructureGraphics(structureResult.graphics || [])
|
||||
}
|
||||
|
||||
// Check for parent with boxes (sub-sessions + zones)
|
||||
const columnResult = data.column_result as { columns?: PageRegion[]; zones?: PageZone[] } | undefined
|
||||
const rowResult = data.row_result as { rows?: RowItem[] } | undefined
|
||||
const subSessions: { id: string; box_index: number }[] = data.sub_sessions || []
|
||||
const zones: PageZone[] = columnResult?.zones || []
|
||||
const hasBoxes = subSessions.length > 0 && zones.some(z => z.zone_type === 'box')
|
||||
|
||||
setIsParentWithBoxes(hasBoxes)
|
||||
if (hasBoxes) setImageRotation(() => 180)
|
||||
|
||||
if (columnResult?.columns) setParentColumns(columnResult.columns)
|
||||
if (rowResult?.rows) setParentRows(rowResult.rows)
|
||||
if (zones.length > 0) setParentZones(zones)
|
||||
|
||||
if (wordResult.image_width && wordResult.image_height) {
|
||||
setImageNaturalSize({ w: wordResult.image_width, h: wordResult.image_height })
|
||||
}
|
||||
|
||||
if (hasBoxes) {
|
||||
setEditorMode('overlay')
|
||||
await loadMergedCells(rawGridCells, subSessions, zones, wordResult)
|
||||
} else {
|
||||
setMergedGridCells(rawGridCells)
|
||||
}
|
||||
|
||||
setStatus('ready')
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
setStatus('error')
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sessionId])
|
||||
|
||||
/** Load sub-session cells and merge into parent coordinate space */
|
||||
const loadMergedCells = async (
|
||||
rawGridCells: GridCell[],
|
||||
subSessions: { id: string; box_index: number }[],
|
||||
zones: PageZone[],
|
||||
wordResult: GridResult,
|
||||
) => {
|
||||
const imgW = wordResult.image_width || 1
|
||||
const imgH = wordResult.image_height || 1
|
||||
const allMergedCells: GridCell[] = [...rawGridCells]
|
||||
|
||||
for (const sub of subSessions) {
|
||||
try {
|
||||
const subRes = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sub.id}`)
|
||||
if (!subRes.ok) continue
|
||||
const subData = await subRes.json()
|
||||
const subWordResult: GridResult | undefined = subData.word_result
|
||||
if (!subWordResult?.cells) continue
|
||||
|
||||
const boxZone = zones.find(z => z.zone_type === 'box')
|
||||
if (!boxZone?.box) continue
|
||||
|
||||
const box = boxZone.box
|
||||
const boxXPct = (box.x / imgW) * 100
|
||||
const boxYPct = (box.y / imgH) * 100
|
||||
const boxWPct = (box.width / imgW) * 100
|
||||
const boxHPct = (box.height / imgH) * 100
|
||||
|
||||
for (const subCell of subWordResult.cells) {
|
||||
if (!subCell.bbox_pct) continue
|
||||
const parentCellX = boxXPct + (subCell.bbox_pct.x / 100) * boxWPct
|
||||
const parentCellY = boxYPct + (subCell.bbox_pct.y / 100) * boxHPct
|
||||
const parentCellW = (subCell.bbox_pct.w / 100) * boxWPct
|
||||
const parentCellH = (subCell.bbox_pct.h / 100) * boxHPct
|
||||
|
||||
allMergedCells.push({
|
||||
...subCell,
|
||||
cell_id: `sub_${sub.id}_${subCell.cell_id}`,
|
||||
bbox_pct: { x: parentCellX, y: parentCellY, w: parentCellW, h: parentCellH },
|
||||
bbox_px: {
|
||||
x: Math.round(parentCellX / 100 * imgW),
|
||||
y: Math.round(parentCellY / 100 * imgH),
|
||||
w: Math.round(parentCellW / 100 * imgW),
|
||||
h: Math.round(parentCellH / 100 * imgH),
|
||||
},
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
// Skip failing sub-sessions
|
||||
}
|
||||
}
|
||||
|
||||
setMergedGridCells(allMergedCells)
|
||||
setCells(allMergedCells.map(gridCellToEditable))
|
||||
}
|
||||
|
||||
// Load session data on mount
|
||||
useEffect(() => {
|
||||
if (!sessionId) return
|
||||
loadSessionData()
|
||||
}, [sessionId, loadSessionData])
|
||||
|
||||
return {
|
||||
status, setStatus, error, setError,
|
||||
cells, setCells, gridCells,
|
||||
editorMode, setEditorMode,
|
||||
isParentWithBoxes, mergedGridCells,
|
||||
parentColumns, parentRows, parentZones,
|
||||
imageNaturalSize, setImageNaturalSize,
|
||||
structureBoxes, structureGraphics,
|
||||
imageRotation, setImageRotation,
|
||||
loadSessionData,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user