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:
@@ -81,6 +81,7 @@ export interface ColumnResult {
|
|||||||
|
|
||||||
export interface ColumnGroundTruth {
|
export interface ColumnGroundTruth {
|
||||||
is_correct: boolean
|
is_correct: boolean
|
||||||
|
corrected_columns?: PageRegion[]
|
||||||
notes?: string
|
notes?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,17 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState } from 'react'
|
import { useState, useMemo } from 'react'
|
||||||
import type { ColumnResult, ColumnGroundTruth, PageRegion } from '@/app/(admin)/ai/ocr-pipeline/types'
|
import type { ColumnResult, ColumnGroundTruth, PageRegion } from '@/app/(admin)/ai/ocr-pipeline/types'
|
||||||
|
|
||||||
interface ColumnControlsProps {
|
interface ColumnControlsProps {
|
||||||
columnResult: ColumnResult | null
|
columnResult: ColumnResult | null
|
||||||
onRerun: () => void
|
onRerun: () => void
|
||||||
onManualMode: () => void
|
onManualMode: () => void
|
||||||
|
onGtMode: () => void
|
||||||
onGroundTruth: (gt: ColumnGroundTruth) => void
|
onGroundTruth: (gt: ColumnGroundTruth) => void
|
||||||
onNext: () => void
|
onNext: () => void
|
||||||
isDetecting: boolean
|
isDetecting: boolean
|
||||||
|
savedGtColumns: PageRegion[] | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const TYPE_COLORS: Record<string, string> = {
|
const TYPE_COLORS: Record<string, string> = {
|
||||||
@@ -42,9 +44,95 @@ const METHOD_LABELS: Record<string, string> = {
|
|||||||
position_fallback: 'Fallback',
|
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 [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
|
if (!columnResult) return null
|
||||||
|
|
||||||
const columns = columnResult.columns.filter((c: PageRegion) => c.type.startsWith('column') || c.type === 'page_ref')
|
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 (
|
return (
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4 space-y-4">
|
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 p-4 space-y-4">
|
||||||
{/* Summary */}
|
{/* 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">
|
<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
|
<span className="font-medium text-gray-800 dark:text-gray-200">{columns.length} Spalten</span> erkannt
|
||||||
{columnResult.duration_seconds > 0 && (
|
{columnResult.duration_seconds > 0 && (
|
||||||
@@ -78,6 +166,12 @@ export function ColumnControls({ columnResult, onRerun, onManualMode, onGroundTr
|
|||||||
>
|
>
|
||||||
Manuell markieren
|
Manuell markieren
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Column list */}
|
{/* Column list */}
|
||||||
@@ -114,6 +208,82 @@ export function ColumnControls({ columnResult, onRerun, onManualMode, onGroundTr
|
|||||||
))}
|
))}
|
||||||
</div>
|
</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 */}
|
{/* 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 justify-between pt-2 border-t border-gray-100 dark:border-gray-700">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|||||||
@@ -47,6 +47,10 @@ interface ManualColumnEditorProps {
|
|||||||
onApply: (columns: PageRegion[]) => void
|
onApply: (columns: PageRegion[]) => void
|
||||||
onCancel: () => void
|
onCancel: () => void
|
||||||
applying: boolean
|
applying: boolean
|
||||||
|
mode?: 'manual' | 'ground-truth'
|
||||||
|
layout?: 'two-column' | 'stacked'
|
||||||
|
initialDividers?: number[]
|
||||||
|
initialColumnTypes?: ColumnTypeKey[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ManualColumnEditor({
|
export function ManualColumnEditor({
|
||||||
@@ -56,13 +60,19 @@ export function ManualColumnEditor({
|
|||||||
onApply,
|
onApply,
|
||||||
onCancel,
|
onCancel,
|
||||||
applying,
|
applying,
|
||||||
|
mode = 'manual',
|
||||||
|
layout = 'two-column',
|
||||||
|
initialDividers,
|
||||||
|
initialColumnTypes,
|
||||||
}: ManualColumnEditorProps) {
|
}: ManualColumnEditorProps) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
const [dividers, setDividers] = useState<number[]>([]) // xPercent values
|
const [dividers, setDividers] = useState<number[]>(initialDividers ?? [])
|
||||||
const [columnTypes, setColumnTypes] = useState<ColumnTypeKey[]>([])
|
const [columnTypes, setColumnTypes] = useState<ColumnTypeKey[]>(initialColumnTypes ?? [])
|
||||||
const [dragging, setDragging] = useState<number | null>(null)
|
const [dragging, setDragging] = useState<number | null>(null)
|
||||||
const [imageLoaded, setImageLoaded] = useState(false)
|
const [imageLoaded, setImageLoaded] = useState(false)
|
||||||
|
|
||||||
|
const isGT = mode === 'ground-truth'
|
||||||
|
|
||||||
// Sync columnTypes length when dividers change
|
// Sync columnTypes length when dividers change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const numColumns = dividers.length + 1
|
const numColumns = dividers.length + 1
|
||||||
@@ -187,8 +197,8 @@ export function ManualColumnEditor({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Two-column layout: image (left) + controls (right) */}
|
{/* Layout: image + controls */}
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className={layout === 'stacked' ? 'space-y-4' : 'grid grid-cols-2 gap-4'}>
|
||||||
{/* Left: Interactive image */}
|
{/* Left: Interactive image */}
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-1">
|
<div className="flex items-center justify-between mb-1">
|
||||||
@@ -328,7 +338,11 @@ export function ManualColumnEditor({
|
|||||||
disabled={dividers.length === 0 || applying}
|
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"
|
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>
|
||||||
<button
|
<button
|
||||||
onClick={() => setDividers([])}
|
onClick={() => setDividers([])}
|
||||||
|
|||||||
@@ -4,21 +4,44 @@ import { useCallback, useEffect, useState } from 'react'
|
|||||||
import type { ColumnResult, ColumnGroundTruth, PageRegion } from '@/app/(admin)/ai/ocr-pipeline/types'
|
import type { ColumnResult, ColumnGroundTruth, PageRegion } from '@/app/(admin)/ai/ocr-pipeline/types'
|
||||||
import { ColumnControls } from './ColumnControls'
|
import { ColumnControls } from './ColumnControls'
|
||||||
import { ManualColumnEditor } from './ManualColumnEditor'
|
import { ManualColumnEditor } from './ManualColumnEditor'
|
||||||
|
import type { ColumnTypeKey } from '@/app/(admin)/ai/ocr-pipeline/types'
|
||||||
|
|
||||||
const KLAUSUR_API = '/klausur-api'
|
const KLAUSUR_API = '/klausur-api'
|
||||||
|
|
||||||
|
type ViewMode = 'normal' | 'ground-truth' | 'manual'
|
||||||
|
|
||||||
interface StepColumnDetectionProps {
|
interface StepColumnDetectionProps {
|
||||||
sessionId: string | null
|
sessionId: string | null
|
||||||
onNext: () => void
|
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) {
|
export function StepColumnDetection({ sessionId, onNext }: StepColumnDetectionProps) {
|
||||||
const [columnResult, setColumnResult] = useState<ColumnResult | null>(null)
|
const [columnResult, setColumnResult] = useState<ColumnResult | null>(null)
|
||||||
const [detecting, setDetecting] = useState(false)
|
const [detecting, setDetecting] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [manualMode, setManualMode] = useState(false)
|
const [viewMode, setViewMode] = useState<ViewMode>('normal')
|
||||||
const [applying, setApplying] = useState(false)
|
const [applying, setApplying] = useState(false)
|
||||||
const [imageDimensions, setImageDimensions] = useState<{ width: number; height: number } | null>(null)
|
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
|
// Fetch session info (image dimensions) + check for cached column result
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -49,6 +72,24 @@ export function StepColumnDetection({ sessionId, onNext }: StepColumnDetectionPr
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [sessionId])
|
}, [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 () => {
|
const runAutoDetection = useCallback(async () => {
|
||||||
if (!sessionId) return
|
if (!sessionId) return
|
||||||
setDetecting(true)
|
setDetecting(true)
|
||||||
@@ -106,7 +147,30 @@ export function StepColumnDetection({ sessionId, onNext }: StepColumnDetectionPr
|
|||||||
columns: data.columns,
|
columns: data.columns,
|
||||||
duration_seconds: data.duration_seconds ?? 0,
|
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) {
|
} catch (e) {
|
||||||
setError(e instanceof Error ? e.message : 'Fehler beim Speichern')
|
setError(e instanceof Error ? e.message : 'Fehler beim Speichern')
|
||||||
} finally {
|
} finally {
|
||||||
@@ -131,6 +195,11 @@ export function StepColumnDetection({ sessionId, onNext }: StepColumnDetectionPr
|
|||||||
const dewarpedUrl = `${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/image/dewarped`
|
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`
|
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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Loading indicator */}
|
{/* Loading indicator */}
|
||||||
@@ -141,18 +210,77 @@ export function StepColumnDetection({ sessionId, onNext }: StepColumnDetectionPr
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{manualMode ? (
|
{viewMode === 'manual' ? (
|
||||||
/* Manual column editor */
|
/* Manual column editor - overwrites column_result */
|
||||||
<ManualColumnEditor
|
<ManualColumnEditor
|
||||||
imageUrl={dewarpedUrl}
|
imageUrl={dewarpedUrl}
|
||||||
imageWidth={imageDimensions?.width ?? 1000}
|
imageWidth={imageDimensions?.width ?? 1000}
|
||||||
imageHeight={imageDimensions?.height ?? 1400}
|
imageHeight={imageDimensions?.height ?? 1400}
|
||||||
onApply={handleManualApply}
|
onApply={handleManualApply}
|
||||||
onCancel={() => setManualMode(false)}
|
onCancel={() => setViewMode('normal')}
|
||||||
applying={applying}
|
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"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
/* Image comparison: overlay (left) vs clean (right) */
|
<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 className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
|
<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 */}
|
{/* Controls */}
|
||||||
{!manualMode && (
|
{viewMode === 'normal' && (
|
||||||
<ColumnControls
|
<ColumnControls
|
||||||
columnResult={columnResult}
|
columnResult={columnResult}
|
||||||
onRerun={handleRerun}
|
onRerun={handleRerun}
|
||||||
onManualMode={() => setManualMode(true)}
|
onManualMode={() => setViewMode('manual')}
|
||||||
|
onGtMode={() => setViewMode('ground-truth')}
|
||||||
onGroundTruth={handleGroundTruth}
|
onGroundTruth={handleGroundTruth}
|
||||||
onNext={onNext}
|
onNext={onNext}
|
||||||
isDetecting={detecting}
|
isDetecting={detecting}
|
||||||
|
savedGtColumns={savedGtColumns}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -135,6 +135,7 @@ class ManualColumnsRequest(BaseModel):
|
|||||||
|
|
||||||
class ColumnGroundTruthRequest(BaseModel):
|
class ColumnGroundTruthRequest(BaseModel):
|
||||||
is_correct: bool
|
is_correct: bool
|
||||||
|
corrected_columns: Optional[List[Dict[str, Any]]] = None
|
||||||
notes: Optional[str] = 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 {}
|
ground_truth = session.get("ground_truth") or {}
|
||||||
gt = {
|
gt = {
|
||||||
"is_correct": req.is_correct,
|
"is_correct": req.is_correct,
|
||||||
|
"corrected_columns": req.corrected_columns,
|
||||||
"notes": req.notes,
|
"notes": req.notes,
|
||||||
"saved_at": datetime.utcnow().isoformat(),
|
"saved_at": datetime.utcnow().isoformat(),
|
||||||
"column_result": session.get("column_result"),
|
"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}
|
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:
|
async def _get_columns_overlay(session_id: str) -> Response:
|
||||||
"""Generate dewarped image with column borders drawn on it."""
|
"""Generate dewarped image with column borders drawn on it."""
|
||||||
session = await get_session_db(session_id)
|
session = await get_session_db(session_id)
|
||||||
|
|||||||
Reference in New Issue
Block a user