feat(ocr-pipeline): ground-truth comparison tool for column detection

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>
This commit is contained in:
Benjamin Admin
2026-02-27 22:48:37 +01:00
parent 03fa186fec
commit 587b066a40
5 changed files with 352 additions and 16 deletions

View File

@@ -81,6 +81,7 @@ export interface ColumnResult {
export interface ColumnGroundTruth {
is_correct: boolean
corrected_columns?: PageRegion[]
notes?: string
}

View File

@@ -1,15 +1,17 @@
'use client'
import { useState } from 'react'
import { useState, useMemo } from 'react'
import type { ColumnResult, ColumnGroundTruth, PageRegion } from '@/app/(admin)/ai/ocr-pipeline/types'
interface ColumnControlsProps {
columnResult: ColumnResult | null
onRerun: () => void
onManualMode: () => void
onGtMode: () => void
onGroundTruth: (gt: ColumnGroundTruth) => void
onNext: () => void
isDetecting: boolean
savedGtColumns: PageRegion[] | null
}
const TYPE_COLORS: Record<string, string> = {
@@ -42,9 +44,95 @@ const METHOD_LABELS: Record<string, string> = {
position_fallback: 'Fallback',
}
export function ColumnControls({ columnResult, onRerun, onManualMode, onGroundTruth, onNext, isDetecting }: ColumnControlsProps) {
interface DiffRow {
index: number
autoCol: PageRegion | null
gtCol: PageRegion | null
diffX: number | null
diffW: number | null
typeMismatch: boolean
}
/** Match auto columns to GT columns by overlap on X-axis (IoU > 50%) */
function computeDiff(autoCols: PageRegion[], gtCols: PageRegion[]): DiffRow[] {
const rows: DiffRow[] = []
const usedGt = new Set<number>()
const usedAuto = new Set<number>()
// Match auto → GT by best X-axis overlap
for (let ai = 0; ai < autoCols.length; ai++) {
const a = autoCols[ai]
let bestIdx = -1
let bestIoU = 0
for (let gi = 0; gi < gtCols.length; gi++) {
if (usedGt.has(gi)) continue
const g = gtCols[gi]
const overlapStart = Math.max(a.x, g.x)
const overlapEnd = Math.min(a.x + a.width, g.x + g.width)
const overlap = Math.max(0, overlapEnd - overlapStart)
const union = (a.width + g.width) - overlap
const iou = union > 0 ? overlap / union : 0
if (iou > bestIoU) {
bestIoU = iou
bestIdx = gi
}
}
if (bestIdx >= 0 && bestIoU > 0.3) {
usedGt.add(bestIdx)
usedAuto.add(ai)
const g = gtCols[bestIdx]
rows.push({
index: rows.length + 1,
autoCol: a,
gtCol: g,
diffX: g.x - a.x,
diffW: g.width - a.width,
typeMismatch: a.type !== g.type,
})
}
}
// Unmatched auto columns
for (let ai = 0; ai < autoCols.length; ai++) {
if (usedAuto.has(ai)) continue
rows.push({
index: rows.length + 1,
autoCol: autoCols[ai],
gtCol: null,
diffX: null,
diffW: null,
typeMismatch: false,
})
}
// Unmatched GT columns
for (let gi = 0; gi < gtCols.length; gi++) {
if (usedGt.has(gi)) continue
rows.push({
index: rows.length + 1,
autoCol: null,
gtCol: gtCols[gi],
diffX: null,
diffW: null,
typeMismatch: false,
})
}
return rows
}
export function ColumnControls({ columnResult, onRerun, onManualMode, onGtMode, onGroundTruth, onNext, isDetecting, savedGtColumns }: ColumnControlsProps) {
const [gtSaved, setGtSaved] = useState(false)
const diffRows = useMemo(() => {
if (!columnResult || !savedGtColumns) return null
const autoCols = columnResult.columns.filter(c => c.type.startsWith('column') || c.type === 'page_ref')
const gtCols = savedGtColumns.filter(c => c.type.startsWith('column') || c.type === 'page_ref')
return computeDiff(autoCols, gtCols)
}, [columnResult, savedGtColumns])
if (!columnResult) return null
const columns = columnResult.columns.filter((c: PageRegion) => c.type.startsWith('column') || c.type === 'page_ref')
@@ -58,7 +146,7 @@ export function ColumnControls({ columnResult, onRerun, onManualMode, onGroundTr
return (
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4 space-y-4">
{/* Summary */}
<div className="flex items-center gap-3">
<div className="flex items-center gap-3 flex-wrap">
<div className="text-sm text-gray-600 dark:text-gray-400">
<span className="font-medium text-gray-800 dark:text-gray-200">{columns.length} Spalten</span> erkannt
{columnResult.duration_seconds > 0 && (
@@ -78,6 +166,12 @@ export function ColumnControls({ columnResult, onRerun, onManualMode, onGroundTr
>
Manuell markieren
</button>
<button
onClick={onGtMode}
className="text-xs px-2 py-1 bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400 rounded hover:bg-amber-200 dark:hover:bg-amber-900/50 transition-colors"
>
{savedGtColumns ? 'Ground Truth bearbeiten' : 'Ground Truth eintragen'}
</button>
</div>
{/* Column list */}
@@ -114,6 +208,82 @@ export function ColumnControls({ columnResult, onRerun, onManualMode, onGroundTr
))}
</div>
{/* Diff table (Auto vs GT) */}
{diffRows && diffRows.length > 0 && (
<div className="border-t border-gray-100 dark:border-gray-700 pt-3">
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-2">
Vergleich: Auto vs Ground Truth
</div>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="text-gray-500 dark:text-gray-400 border-b border-gray-100 dark:border-gray-700">
<th className="text-left py-1 pr-2">#</th>
<th className="text-left py-1 pr-2">Auto (Typ, x, w)</th>
<th className="text-left py-1 pr-2">GT (Typ, x, w)</th>
<th className="text-right py-1 pr-2">Diff X</th>
<th className="text-right py-1">Diff W</th>
</tr>
</thead>
<tbody>
{diffRows.map((row) => (
<tr
key={row.index}
className={
!row.autoCol || !row.gtCol || row.typeMismatch
? 'bg-red-50 dark:bg-red-900/10'
: (row.diffX !== null && Math.abs(row.diffX) > 20) || (row.diffW !== null && Math.abs(row.diffW) > 20)
? 'bg-amber-50 dark:bg-amber-900/10'
: ''
}
>
<td className="py-1 pr-2 font-mono text-gray-400">{row.index}</td>
<td className="py-1 pr-2 font-mono">
{row.autoCol ? (
<span>
<span className={`inline-block px-1 rounded ${TYPE_COLORS[row.autoCol.type] || ''}`}>
{TYPE_LABELS[row.autoCol.type] || row.autoCol.type}
</span>
{' '}{row.autoCol.x}, {row.autoCol.width}
</span>
) : (
<span className="text-red-400">fehlt</span>
)}
</td>
<td className="py-1 pr-2 font-mono">
{row.gtCol ? (
<span>
<span className={`inline-block px-1 rounded ${TYPE_COLORS[row.gtCol.type] || ''}`}>
{TYPE_LABELS[row.gtCol.type] || row.gtCol.type}
</span>
{' '}{row.gtCol.x}, {row.gtCol.width}
</span>
) : (
<span className="text-red-400">fehlt</span>
)}
</td>
<td className="py-1 pr-2 text-right font-mono">
{row.diffX !== null ? (
<span className={Math.abs(row.diffX) > 20 ? 'text-amber-600 dark:text-amber-400' : 'text-gray-500'}>
{row.diffX > 0 ? '+' : ''}{row.diffX}
</span>
) : '—'}
</td>
<td className="py-1 text-right font-mono">
{row.diffW !== null ? (
<span className={Math.abs(row.diffW) > 20 ? 'text-amber-600 dark:text-amber-400' : 'text-gray-500'}>
{row.diffW > 0 ? '+' : ''}{row.diffW}
</span>
) : '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Ground Truth + Navigation */}
<div className="flex items-center justify-between pt-2 border-t border-gray-100 dark:border-gray-700">
<div className="flex items-center gap-2">

View File

@@ -47,6 +47,10 @@ interface ManualColumnEditorProps {
onApply: (columns: PageRegion[]) => void
onCancel: () => void
applying: boolean
mode?: 'manual' | 'ground-truth'
layout?: 'two-column' | 'stacked'
initialDividers?: number[]
initialColumnTypes?: ColumnTypeKey[]
}
export function ManualColumnEditor({
@@ -56,13 +60,19 @@ export function ManualColumnEditor({
onApply,
onCancel,
applying,
mode = 'manual',
layout = 'two-column',
initialDividers,
initialColumnTypes,
}: ManualColumnEditorProps) {
const containerRef = useRef<HTMLDivElement>(null)
const [dividers, setDividers] = useState<number[]>([]) // xPercent values
const [columnTypes, setColumnTypes] = useState<ColumnTypeKey[]>([])
const [dividers, setDividers] = useState<number[]>(initialDividers ?? [])
const [columnTypes, setColumnTypes] = useState<ColumnTypeKey[]>(initialColumnTypes ?? [])
const [dragging, setDragging] = useState<number | null>(null)
const [imageLoaded, setImageLoaded] = useState(false)
const isGT = mode === 'ground-truth'
// Sync columnTypes length when dividers change
useEffect(() => {
const numColumns = dividers.length + 1
@@ -187,8 +197,8 @@ export function ManualColumnEditor({
return (
<div className="space-y-4">
{/* Two-column layout: image (left) + controls (right) */}
<div className="grid grid-cols-2 gap-4">
{/* Layout: image + controls */}
<div className={layout === 'stacked' ? 'space-y-4' : 'grid grid-cols-2 gap-4'}>
{/* Left: Interactive image */}
<div>
<div className="flex items-center justify-between mb-1">
@@ -328,7 +338,11 @@ export function ManualColumnEditor({
disabled={dividers.length === 0 || applying}
className="w-full px-4 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 transition-colors text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
{applying ? 'Wird gespeichert...' : `${dividers.length + 1} Spalten uebernehmen`}
{applying
? 'Wird gespeichert...'
: isGT
? `${dividers.length + 1} Spalten als Ground Truth speichern`
: `${dividers.length + 1} Spalten uebernehmen`}
</button>
<button
onClick={() => setDividers([])}

View File

@@ -4,21 +4,44 @@ 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 [manualMode, setManualMode] = useState(false)
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(() => {
@@ -49,6 +72,24 @@ export function StepColumnDetection({ sessionId, onNext }: StepColumnDetectionPr
// 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)
@@ -106,7 +147,30 @@ export function StepColumnDetection({ sessionId, onNext }: StepColumnDetectionPr
columns: data.columns,
duration_seconds: data.duration_seconds ?? 0,
})
setManualMode(false)
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 {
@@ -131,6 +195,11 @@ export function StepColumnDetection({ sessionId, onNext }: StepColumnDetectionPr
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 */}
@@ -141,18 +210,77 @@ export function StepColumnDetection({ sessionId, onNext }: StepColumnDetectionPr
</div>
)}
{manualMode ? (
/* Manual column editor */
{viewMode === 'manual' ? (
/* Manual column editor - overwrites column_result */
<ManualColumnEditor
imageUrl={dewarpedUrl}
imageWidth={imageDimensions?.width ?? 1000}
imageHeight={imageDimensions?.height ?? 1400}
onApply={handleManualApply}
onCancel={() => setManualMode(false)}
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>
) : (
/* Image comparison: overlay (left) vs clean (right) */
/* 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">
@@ -190,14 +318,16 @@ export function StepColumnDetection({ sessionId, onNext }: StepColumnDetectionPr
)}
{/* Controls */}
{!manualMode && (
{viewMode === 'normal' && (
<ColumnControls
columnResult={columnResult}
onRerun={handleRerun}
onManualMode={() => setManualMode(true)}
onManualMode={() => setViewMode('manual')}
onGtMode={() => setViewMode('ground-truth')}
onGroundTruth={handleGroundTruth}
onNext={onNext}
isDetecting={detecting}
savedGtColumns={savedGtColumns}
/>
)}

View File

@@ -135,6 +135,7 @@ class ManualColumnsRequest(BaseModel):
class ColumnGroundTruthRequest(BaseModel):
is_correct: bool
corrected_columns: Optional[List[Dict[str, Any]]] = None
notes: Optional[str] = None
@@ -711,6 +712,7 @@ async def save_column_ground_truth(session_id: str, req: ColumnGroundTruthReques
ground_truth = session.get("ground_truth") or {}
gt = {
"is_correct": req.is_correct,
"corrected_columns": req.corrected_columns,
"notes": req.notes,
"saved_at": datetime.utcnow().isoformat(),
"column_result": session.get("column_result"),
@@ -725,6 +727,25 @@ async def save_column_ground_truth(session_id: str, req: ColumnGroundTruthReques
return {"session_id": session_id, "ground_truth": gt}
@router.get("/sessions/{session_id}/ground-truth/columns")
async def get_column_ground_truth(session_id: str):
"""Retrieve saved ground truth for column detection, including auto vs GT diff."""
session = await get_session_db(session_id)
if not session:
raise HTTPException(status_code=404, detail=f"Session {session_id} not found")
ground_truth = session.get("ground_truth") or {}
columns_gt = ground_truth.get("columns")
if not columns_gt:
raise HTTPException(status_code=404, detail="No column ground truth saved")
return {
"session_id": session_id,
"columns_gt": columns_gt,
"columns_auto": session.get("column_result"),
}
async def _get_columns_overlay(session_id: str) -> Response:
"""Generate dewarped image with column borders drawn on it."""
session = await get_session_db(session_id)