Some checks failed
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-school (push) Successful in 35s
CI / test-go-edu-search (push) Successful in 26s
CI / test-python-klausur (push) Failing after 1m56s
CI / test-python-agent-core (push) Successful in 15s
CI / test-nodejs-website (push) Successful in 19s
- Add DewarpDetection type with per-method results - Expand method labels for all 4 detectors (A-D) - Show green/amber banner: applied vs quality-gate-rejected - Expandable "Details" panel showing all 4 methods with confidence bars - Visual confidence bars instead of plain percentage Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
310 lines
13 KiB
TypeScript
310 lines
13 KiB
TypeScript
'use client'
|
|
|
|
import { useEffect, useState } from 'react'
|
|
import type { DewarpResult, DewarpDetection, DewarpGroundTruth } from '@/app/(admin)/ai/ocr-pipeline/types'
|
|
|
|
interface DewarpControlsProps {
|
|
dewarpResult: DewarpResult | null
|
|
showGrid: boolean
|
|
onToggleGrid: () => void
|
|
onManualDewarp: (shearDegrees: number) => void
|
|
onGroundTruth: (gt: DewarpGroundTruth) => void
|
|
onNext: () => void
|
|
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',
|
|
none: 'Keine Korrektur',
|
|
}
|
|
|
|
/** 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>
|
|
)
|
|
}
|
|
|
|
export function DewarpControls({
|
|
dewarpResult,
|
|
showGrid,
|
|
onToggleGrid,
|
|
onManualDewarp,
|
|
onGroundTruth,
|
|
onNext,
|
|
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)
|
|
|
|
// Initialize slider to auto-detected value when result arrives
|
|
useEffect(() => {
|
|
if (dewarpResult && dewarpResult.shear_degrees !== undefined) {
|
|
setManualShear(dewarpResult.shear_degrees)
|
|
}
|
|
}, [dewarpResult?.shear_degrees])
|
|
|
|
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 wasRejected = dewarpResult && dewarpResult.method_used === 'none' && (dewarpResult.detections || []).length > 0
|
|
const wasApplied = dewarpResult && dewarpResult.method_used !== 'none' && dewarpResult.method_used !== 'manual'
|
|
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)}°`
|
|
: dewarpResult.method_used === 'manual'
|
|
? `Manuelle Korrektur: ${dewarpResult.shear_degrees.toFixed(2)}°`
|
|
: '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)}°</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)}°
|
|
</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>
|
|
)}
|
|
|
|
{/* Manual shear angle slider */}
|
|
{dewarpResult && (
|
|
<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°</span>
|
|
<input
|
|
type="range"
|
|
min={-200}
|
|
max={200}
|
|
step={5}
|
|
value={Math.round(manualShear * 100)}
|
|
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°</span>
|
|
<span className="font-mono text-sm w-16 text-right">{manualShear.toFixed(2)}°</span>
|
|
<button
|
|
onClick={() => onManualDewarp(manualShear)}
|
|
disabled={isApplying}
|
|
className="px-3 py-1.5 text-sm bg-teal-600 text-white rounded-md hover:bg-teal-700 disabled:opacity-50 transition-colors"
|
|
>
|
|
{isApplying ? '...' : 'Anwenden'}
|
|
</button>
|
|
</div>
|
|
<p className="text-xs text-gray-400 mt-1">
|
|
Scherung der vertikalen Achse in Grad. Positiv = Spalten nach rechts kippen, negativ = nach links.
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Ground Truth */}
|
|
{dewarpResult && (
|
|
<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>
|
|
)}
|
|
|
|
{/* Next button */}
|
|
{dewarpResult && (
|
|
<div className="flex justify-end">
|
|
<button
|
|
onClick={onNext}
|
|
className="px-6 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 font-medium transition-colors"
|
|
>
|
|
Uebernehmen & Weiter →
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|