diff --git a/admin-lehrer/app/(admin)/ai/ocr-pipeline/types.ts b/admin-lehrer/app/(admin)/ai/ocr-pipeline/types.ts index f836696..917dd75 100644 --- a/admin-lehrer/app/(admin)/ai/ocr-pipeline/types.ts +++ b/admin-lehrer/app/(admin)/ai/ocr-pipeline/types.ts @@ -84,6 +84,12 @@ export interface ColumnGroundTruth { notes?: string } +export interface ManualColumnDivider { + xPercent: number // Position in % of image width (0-100) +} + +export type ColumnTypeKey = PageRegion['type'] + export const PIPELINE_STEPS: PipelineStep[] = [ { id: 'deskew', name: 'Begradigung', icon: '📐', status: 'pending' }, { id: 'dewarp', name: 'Entzerrung', icon: '🔧', status: 'pending' }, diff --git a/admin-lehrer/components/ocr-pipeline/ColumnControls.tsx b/admin-lehrer/components/ocr-pipeline/ColumnControls.tsx index 329842a..e551c98 100644 --- a/admin-lehrer/components/ocr-pipeline/ColumnControls.tsx +++ b/admin-lehrer/components/ocr-pipeline/ColumnControls.tsx @@ -6,6 +6,7 @@ import type { ColumnResult, ColumnGroundTruth, PageRegion } from '@/app/(admin)/ interface ColumnControlsProps { columnResult: ColumnResult | null onRerun: () => void + onManualMode: () => void onGroundTruth: (gt: ColumnGroundTruth) => void onNext: () => void isDetecting: boolean @@ -39,7 +40,7 @@ const METHOD_LABELS: Record = { position_fallback: 'Fallback', } -export function ColumnControls({ columnResult, onRerun, onGroundTruth, onNext, isDetecting }: ColumnControlsProps) { +export function ColumnControls({ columnResult, onRerun, onManualMode, onGroundTruth, onNext, isDetecting }: ColumnControlsProps) { const [gtSaved, setGtSaved] = useState(false) if (!columnResult) return null @@ -69,6 +70,12 @@ export function ColumnControls({ columnResult, onRerun, onGroundTruth, onNext, i > Erneut erkennen + {/* Column list */} diff --git a/admin-lehrer/components/ocr-pipeline/ManualColumnEditor.tsx b/admin-lehrer/components/ocr-pipeline/ManualColumnEditor.tsx new file mode 100644 index 0000000..84b0e91 --- /dev/null +++ b/admin-lehrer/components/ocr-pipeline/ManualColumnEditor.tsx @@ -0,0 +1,325 @@ +'use client' + +import { useCallback, useEffect, useRef, useState } from 'react' +import type { ColumnTypeKey, PageRegion } from '@/app/(admin)/ai/ocr-pipeline/types' + +const COLUMN_TYPES: { value: ColumnTypeKey; label: string }[] = [ + { value: 'column_en', label: 'EN' }, + { value: 'column_de', label: 'DE' }, + { value: 'column_example', label: 'Beispiel' }, + { value: 'column_text', label: 'Text' }, + { value: 'page_ref', label: 'Seite' }, + { value: 'column_marker', label: 'Marker' }, +] + +const TYPE_OVERLAY_COLORS: Record = { + column_en: 'rgba(59, 130, 246, 0.12)', + column_de: 'rgba(34, 197, 94, 0.12)', + column_example: 'rgba(249, 115, 22, 0.12)', + column_text: 'rgba(6, 182, 212, 0.12)', + page_ref: 'rgba(168, 85, 247, 0.12)', + column_marker: 'rgba(239, 68, 68, 0.12)', +} + +const TYPE_BADGE_COLORS: Record = { + column_en: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400', + column_de: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400', + column_example: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400', + column_text: 'bg-cyan-100 text-cyan-700 dark:bg-cyan-900/30 dark:text-cyan-400', + page_ref: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400', + column_marker: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400', +} + +// Default column type sequence for newly created columns +const DEFAULT_TYPE_SEQUENCE: ColumnTypeKey[] = [ + 'page_ref', 'column_en', 'column_de', 'column_example', 'column_text', +] + +const MIN_DIVIDER_DISTANCE_PERCENT = 2 // Minimum 2% apart + +interface ManualColumnEditorProps { + imageUrl: string + imageWidth: number + imageHeight: number + onApply: (columns: PageRegion[]) => void + onCancel: () => void + applying: boolean +} + +export function ManualColumnEditor({ + imageUrl, + imageWidth, + imageHeight, + onApply, + onCancel, + applying, +}: ManualColumnEditorProps) { + const containerRef = useRef(null) + const [dividers, setDividers] = useState([]) // xPercent values + const [columnTypes, setColumnTypes] = useState([]) + const [dragging, setDragging] = useState(null) + const [imageLoaded, setImageLoaded] = useState(false) + + // Sync columnTypes length when dividers change + useEffect(() => { + const numColumns = dividers.length + 1 + setColumnTypes(prev => { + if (prev.length === numColumns) return prev + const next = [...prev] + while (next.length < numColumns) { + const idx = next.length + next.push(DEFAULT_TYPE_SEQUENCE[idx] || 'column_text') + } + while (next.length > numColumns) { + next.pop() + } + return next + }) + }, [dividers.length]) + + const getXPercent = useCallback((clientX: number): number => { + if (!containerRef.current) return 0 + const rect = containerRef.current.getBoundingClientRect() + const pct = ((clientX - rect.left) / rect.width) * 100 + return Math.max(0, Math.min(100, pct)) + }, []) + + const canPlaceDivider = useCallback((xPct: number, excludeIndex?: number): boolean => { + for (let i = 0; i < dividers.length; i++) { + if (i === excludeIndex) continue + if (Math.abs(dividers[i] - xPct) < MIN_DIVIDER_DISTANCE_PERCENT) return false + } + return xPct > MIN_DIVIDER_DISTANCE_PERCENT && xPct < (100 - MIN_DIVIDER_DISTANCE_PERCENT) + }, [dividers]) + + // Click on image to add a divider + const handleImageClick = useCallback((e: React.MouseEvent) => { + if (dragging !== null) return + // Don't add if clicking on a divider handle + if ((e.target as HTMLElement).dataset.divider) return + + const xPct = getXPercent(e.clientX) + if (!canPlaceDivider(xPct)) return + + setDividers(prev => [...prev, xPct].sort((a, b) => a - b)) + }, [dragging, getXPercent, canPlaceDivider]) + + // Drag handlers + const handleDividerMouseDown = useCallback((e: React.MouseEvent, index: number) => { + e.stopPropagation() + e.preventDefault() + setDragging(index) + }, []) + + useEffect(() => { + if (dragging === null) return + + const handleMouseMove = (e: MouseEvent) => { + const xPct = getXPercent(e.clientX) + if (canPlaceDivider(xPct, dragging)) { + setDividers(prev => { + const next = [...prev] + next[dragging] = xPct + return next.sort((a, b) => a - b) + }) + } + } + + const handleMouseUp = () => { + setDragging(null) + } + + window.addEventListener('mousemove', handleMouseMove) + window.addEventListener('mouseup', handleMouseUp) + return () => { + window.removeEventListener('mousemove', handleMouseMove) + window.removeEventListener('mouseup', handleMouseUp) + } + }, [dragging, getXPercent, canPlaceDivider]) + + const removeDivider = useCallback((index: number) => { + setDividers(prev => prev.filter((_, i) => i !== index)) + }, []) + + const updateColumnType = useCallback((colIndex: number, type: ColumnTypeKey) => { + setColumnTypes(prev => { + const next = [...prev] + next[colIndex] = type + return next + }) + }, []) + + const handleApply = useCallback(() => { + // Build PageRegion array from dividers + const sorted = [...dividers].sort((a, b) => a - b) + const columns: PageRegion[] = [] + + for (let i = 0; i <= sorted.length; i++) { + const leftPct = i === 0 ? 0 : sorted[i - 1] + const rightPct = i === sorted.length ? 100 : sorted[i] + const x = Math.round((leftPct / 100) * imageWidth) + const w = Math.round(((rightPct - leftPct) / 100) * imageWidth) + + columns.push({ + type: columnTypes[i] || 'column_text', + x, + y: 0, + width: w, + height: imageHeight, + classification_confidence: 1.0, + classification_method: 'manual', + }) + } + + onApply(columns) + }, [dividers, columnTypes, imageWidth, imageHeight, onApply]) + + // Compute column regions for overlay + const sorted = [...dividers].sort((a, b) => a - b) + const columnRegions = Array.from({ length: sorted.length + 1 }, (_, i) => ({ + leftPct: i === 0 ? 0 : sorted[i - 1], + rightPct: i === sorted.length ? 100 : sorted[i], + type: columnTypes[i] || 'column_text', + })) + + return ( +
+
+
+ Klicken Sie auf das Bild, um vertikale Trennlinien zu setzen. + {dividers.length > 0 && ( + + {dividers.length} Linien = {dividers.length + 1} Spalten + + )} +
+ +
+ + {/* Interactive image area */} +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + Entzerrtes Bild setImageLoaded(true)} + /> + + {imageLoaded && ( + <> + {/* Column overlays */} + {columnRegions.map((region, i) => ( +
+ + {i + 1} + +
+ ))} + + {/* Divider lines */} + {sorted.map((xPct, i) => ( +
handleDividerMouseDown(e, i)} + > + {/* Visible line */} +
+ {/* Delete button */} + +
+ ))} + + )} +
+ + {/* Column type assignment */} + {dividers.length > 0 && ( +
+
+ Spaltentypen zuweisen +
+
+ {columnRegions.map((region, i) => ( +
+ + Spalte {i + 1} + + + + {Math.round(region.rightPct - region.leftPct)}% + +
+ ))} +
+
+ )} + + {/* Action buttons */} +
+ + +
+
+ ) +} diff --git a/admin-lehrer/components/ocr-pipeline/StepColumnDetection.tsx b/admin-lehrer/components/ocr-pipeline/StepColumnDetection.tsx index 6f76e18..5f14fbb 100644 --- a/admin-lehrer/components/ocr-pipeline/StepColumnDetection.tsx +++ b/admin-lehrer/components/ocr-pipeline/StepColumnDetection.tsx @@ -1,8 +1,9 @@ 'use client' import { useCallback, useEffect, useState } from 'react' -import type { ColumnResult, ColumnGroundTruth } from '@/app/(admin)/ai/ocr-pipeline/types' +import type { ColumnResult, ColumnGroundTruth, PageRegion } from '@/app/(admin)/ai/ocr-pipeline/types' import { ColumnControls } from './ColumnControls' +import { ManualColumnEditor } from './ManualColumnEditor' const KLAUSUR_API = '/klausur-api' @@ -15,6 +16,9 @@ export function StepColumnDetection({ sessionId, onNext }: StepColumnDetectionPr const [columnResult, setColumnResult] = useState(null) const [detecting, setDetecting] = useState(false) const [error, setError] = useState(null) + const [manualMode, setManualMode] = useState(false) + const [applying, setApplying] = useState(false) + const [imageDimensions, setImageDimensions] = useState<{ width: number; height: number } | null>(null) // Auto-trigger column detection on mount useEffect(() => { @@ -28,6 +32,9 @@ export function StepColumnDetection({ sessionId, onNext }: StepColumnDetectionPr 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) setDetecting(false) @@ -86,6 +93,33 @@ export function StepColumnDetection({ sessionId, onNext }: StepColumnDetectionPr } }, [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, + }) + setManualMode(false) + } catch (e) { + setError(e instanceof Error ? e.message : 'Fehler beim Speichern') + } finally { + setApplying(false) + } + }, [sessionId]) + if (!sessionId) { return (
@@ -113,50 +147,65 @@ export function StepColumnDetection({ sessionId, onNext }: StepColumnDetectionPr
)} - {/* Image comparison: overlay (left) vs clean (right) */} -
-
-
- Mit Spalten-Overlay + {manualMode ? ( + /* Manual column editor */ + setManualMode(false)} + applying={applying} + /> + ) : ( + /* Image comparison: overlay (left) vs clean (right) */ +
+
+
+ Mit Spalten-Overlay +
+
+ {columnResult ? ( + // eslint-disable-next-line @next/next/no-img-element + Spalten-Overlay + ) : ( +
+ {detecting ? 'Erkenne Spalten...' : 'Keine Daten'} +
+ )} +
-
- {columnResult ? ( - // eslint-disable-next-line @next/next/no-img-element +
+
+ Entzerrtes Bild +
+
+ {/* eslint-disable-next-line @next/next/no-img-element */} Spalten-Overlay - ) : ( -
- {detecting ? 'Erkenne Spalten...' : 'Keine Daten'} -
- )} +
-
-
- Entzerrtes Bild -
-
- {/* eslint-disable-next-line @next/next/no-img-element */} - Entzerrt -
-
-
+ )} {/* Controls */} - + {!manualMode && ( + setManualMode(true)} + onGroundTruth={handleGroundTruth} + onNext={onNext} + isDetecting={detecting} + /> + )} {error && (