Side-by-side view: auto result (readonly) vs GT editor where teacher draws correct columns. Diff table shows Auto vs GT with IoU matching. GT data persisted per session for algorithm tuning. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
342 lines
12 KiB
TypeScript
342 lines
12 KiB
TypeScript
'use client'
|
|
|
|
import { useCallback, useEffect, useState } from 'react'
|
|
import type { ColumnResult, ColumnGroundTruth, PageRegion } from '@/app/(admin)/ai/ocr-pipeline/types'
|
|
import { ColumnControls } from './ColumnControls'
|
|
import { ManualColumnEditor } from './ManualColumnEditor'
|
|
import type { ColumnTypeKey } from '@/app/(admin)/ai/ocr-pipeline/types'
|
|
|
|
const KLAUSUR_API = '/klausur-api'
|
|
|
|
type ViewMode = 'normal' | 'ground-truth' | 'manual'
|
|
|
|
interface StepColumnDetectionProps {
|
|
sessionId: string | null
|
|
onNext: () => void
|
|
}
|
|
|
|
/** Convert PageRegion[] to divider percentages + column types for ManualColumnEditor */
|
|
function columnsToEditorState(
|
|
columns: PageRegion[],
|
|
imageWidth: number
|
|
): { dividers: number[]; columnTypes: ColumnTypeKey[] } {
|
|
if (!columns.length || !imageWidth) return { dividers: [], columnTypes: [] }
|
|
|
|
const sorted = [...columns].sort((a, b) => a.x - b.x)
|
|
const dividers: number[] = []
|
|
const columnTypes: ColumnTypeKey[] = sorted.map(c => c.type)
|
|
|
|
for (let i = 1; i < sorted.length; i++) {
|
|
const xPct = (sorted[i].x / imageWidth) * 100
|
|
dividers.push(xPct)
|
|
}
|
|
|
|
return { dividers, columnTypes }
|
|
}
|
|
|
|
export function StepColumnDetection({ sessionId, onNext }: StepColumnDetectionProps) {
|
|
const [columnResult, setColumnResult] = useState<ColumnResult | null>(null)
|
|
const [detecting, setDetecting] = useState(false)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [viewMode, setViewMode] = useState<ViewMode>('normal')
|
|
const [applying, setApplying] = useState(false)
|
|
const [imageDimensions, setImageDimensions] = useState<{ width: number; height: number } | null>(null)
|
|
const [savedGtColumns, setSavedGtColumns] = useState<PageRegion[] | null>(null)
|
|
|
|
// Fetch session info (image dimensions) + check for cached column result
|
|
useEffect(() => {
|
|
if (!sessionId || imageDimensions) return
|
|
|
|
const fetchSessionInfo = async () => {
|
|
try {
|
|
const infoRes = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}`)
|
|
if (infoRes.ok) {
|
|
const info = await infoRes.json()
|
|
if (info.image_width && info.image_height) {
|
|
setImageDimensions({ width: info.image_width, height: info.image_height })
|
|
}
|
|
if (info.column_result) {
|
|
setColumnResult(info.column_result)
|
|
return
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to fetch session info:', e)
|
|
}
|
|
|
|
// No cached result - run auto-detection
|
|
runAutoDetection()
|
|
}
|
|
|
|
fetchSessionInfo()
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [sessionId])
|
|
|
|
// Load saved GT if exists
|
|
useEffect(() => {
|
|
if (!sessionId) return
|
|
const fetchGt = async () => {
|
|
try {
|
|
const res = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/ground-truth/columns`)
|
|
if (res.ok) {
|
|
const data = await res.json()
|
|
const corrected = data.columns_gt?.corrected_columns
|
|
if (corrected) setSavedGtColumns(corrected)
|
|
}
|
|
} catch {
|
|
// No saved GT - that's fine
|
|
}
|
|
}
|
|
fetchGt()
|
|
}, [sessionId])
|
|
|
|
const runAutoDetection = useCallback(async () => {
|
|
if (!sessionId) return
|
|
setDetecting(true)
|
|
setError(null)
|
|
try {
|
|
const res = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/columns`, {
|
|
method: 'POST',
|
|
})
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({ detail: res.statusText }))
|
|
throw new Error(err.detail || 'Spaltenerkennung fehlgeschlagen')
|
|
}
|
|
const data: ColumnResult = await res.json()
|
|
setColumnResult(data)
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : 'Unbekannter Fehler')
|
|
} finally {
|
|
setDetecting(false)
|
|
}
|
|
}, [sessionId])
|
|
|
|
const handleRerun = useCallback(() => {
|
|
runAutoDetection()
|
|
}, [runAutoDetection])
|
|
|
|
const handleGroundTruth = useCallback(async (gt: ColumnGroundTruth) => {
|
|
if (!sessionId) return
|
|
try {
|
|
await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/ground-truth/columns`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(gt),
|
|
})
|
|
} catch (e) {
|
|
console.error('Ground truth save failed:', e)
|
|
}
|
|
}, [sessionId])
|
|
|
|
const handleManualApply = useCallback(async (columns: PageRegion[]) => {
|
|
if (!sessionId) return
|
|
setApplying(true)
|
|
setError(null)
|
|
try {
|
|
const res = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/columns/manual`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ columns }),
|
|
})
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({ detail: res.statusText }))
|
|
throw new Error(err.detail || 'Manuelle Spalten konnten nicht gespeichert werden')
|
|
}
|
|
const data = await res.json()
|
|
setColumnResult({
|
|
columns: data.columns,
|
|
duration_seconds: data.duration_seconds ?? 0,
|
|
})
|
|
setViewMode('normal')
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : 'Fehler beim Speichern')
|
|
} finally {
|
|
setApplying(false)
|
|
}
|
|
}, [sessionId])
|
|
|
|
const handleGtApply = useCallback(async (columns: PageRegion[]) => {
|
|
if (!sessionId) return
|
|
setApplying(true)
|
|
setError(null)
|
|
try {
|
|
const gt: ColumnGroundTruth = {
|
|
is_correct: false,
|
|
corrected_columns: columns,
|
|
}
|
|
await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/ground-truth/columns`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(gt),
|
|
})
|
|
setSavedGtColumns(columns)
|
|
setViewMode('normal')
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : 'Fehler beim Speichern')
|
|
} finally {
|
|
setApplying(false)
|
|
}
|
|
}, [sessionId])
|
|
|
|
if (!sessionId) {
|
|
return (
|
|
<div className="flex flex-col items-center justify-center py-16 text-center">
|
|
<div className="text-5xl mb-4">📊</div>
|
|
<h3 className="text-lg font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
Schritt 3: Spaltenerkennung
|
|
</h3>
|
|
<p className="text-gray-500 dark:text-gray-400 max-w-md">
|
|
Bitte zuerst Schritt 1 und 2 abschliessen.
|
|
</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const dewarpedUrl = `${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/image/dewarped`
|
|
const overlayUrl = `${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/image/columns-overlay`
|
|
|
|
// Pre-compute editor state from saved GT or auto columns for GT mode
|
|
const gtInitial = savedGtColumns
|
|
? columnsToEditorState(savedGtColumns, imageDimensions?.width ?? 1000)
|
|
: undefined
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* Loading indicator */}
|
|
{detecting && (
|
|
<div className="flex items-center gap-2 text-teal-600 dark:text-teal-400 text-sm">
|
|
<div className="animate-spin w-4 h-4 border-2 border-teal-500 border-t-transparent rounded-full" />
|
|
Spaltenerkennung laeuft...
|
|
</div>
|
|
)}
|
|
|
|
{viewMode === 'manual' ? (
|
|
/* Manual column editor - overwrites column_result */
|
|
<ManualColumnEditor
|
|
imageUrl={dewarpedUrl}
|
|
imageWidth={imageDimensions?.width ?? 1000}
|
|
imageHeight={imageDimensions?.height ?? 1400}
|
|
onApply={handleManualApply}
|
|
onCancel={() => setViewMode('normal')}
|
|
applying={applying}
|
|
mode="manual"
|
|
/>
|
|
) : viewMode === 'ground-truth' ? (
|
|
/* GT mode: auto result (left, readonly) + GT editor (right) */
|
|
<div className="grid grid-cols-2 gap-4">
|
|
{/* Left: Auto result (readonly overlay) */}
|
|
<div>
|
|
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
|
|
Auto-Ergebnis (readonly)
|
|
</div>
|
|
<div className="border rounded-lg overflow-hidden dark:border-gray-700 bg-gray-50 dark:bg-gray-900">
|
|
{columnResult ? (
|
|
// eslint-disable-next-line @next/next/no-img-element
|
|
<img
|
|
src={`${overlayUrl}?t=${Date.now()}`}
|
|
alt="Auto Spalten-Overlay"
|
|
className="w-full h-auto"
|
|
/>
|
|
) : (
|
|
<div className="aspect-[3/4] flex items-center justify-center text-gray-400 text-sm">
|
|
Keine Auto-Daten
|
|
</div>
|
|
)}
|
|
</div>
|
|
{/* Auto column list */}
|
|
{columnResult && (
|
|
<div className="mt-2 space-y-1">
|
|
<div className="text-xs font-medium text-gray-500 dark:text-gray-400">
|
|
Auto: {columnResult.columns.length} Spalten
|
|
</div>
|
|
{columnResult.columns
|
|
.filter(c => c.type.startsWith('column') || c.type === 'page_ref')
|
|
.map((col, i) => (
|
|
<div key={i} className="text-xs text-gray-500 dark:text-gray-400 font-mono">
|
|
{i + 1}. {col.type} x={col.x} w={col.width}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Right: GT editor */}
|
|
<div>
|
|
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
|
|
Ground Truth Editor
|
|
</div>
|
|
<ManualColumnEditor
|
|
imageUrl={dewarpedUrl}
|
|
imageWidth={imageDimensions?.width ?? 1000}
|
|
imageHeight={imageDimensions?.height ?? 1400}
|
|
onApply={handleGtApply}
|
|
onCancel={() => setViewMode('normal')}
|
|
applying={applying}
|
|
mode="ground-truth"
|
|
layout="stacked"
|
|
initialDividers={gtInitial?.dividers}
|
|
initialColumnTypes={gtInitial?.columnTypes}
|
|
/>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
/* Normal mode: overlay (left) vs clean (right) */
|
|
<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 Spalten-Overlay
|
|
</div>
|
|
<div className="border rounded-lg overflow-hidden dark:border-gray-700 bg-gray-50 dark:bg-gray-900">
|
|
{columnResult ? (
|
|
// eslint-disable-next-line @next/next/no-img-element
|
|
<img
|
|
src={`${overlayUrl}?t=${Date.now()}`}
|
|
alt="Spalten-Overlay"
|
|
className="w-full h-auto"
|
|
/>
|
|
) : (
|
|
<div className="aspect-[3/4] flex items-center justify-center text-gray-400 text-sm">
|
|
{detecting ? 'Erkenne Spalten...' : '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>
|
|
)}
|
|
|
|
{/* Controls */}
|
|
{viewMode === 'normal' && (
|
|
<ColumnControls
|
|
columnResult={columnResult}
|
|
onRerun={handleRerun}
|
|
onManualMode={() => setViewMode('manual')}
|
|
onGtMode={() => setViewMode('ground-truth')}
|
|
onGroundTruth={handleGroundTruth}
|
|
onNext={onNext}
|
|
isDetecting={detecting}
|
|
savedGtColumns={savedGtColumns}
|
|
/>
|
|
)}
|
|
|
|
{error && (
|
|
<div className="p-3 bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 rounded-lg text-sm">
|
|
{error}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|