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 26s
CI / test-go-edu-search (push) Successful in 27s
CI / test-python-klausur (push) Failing after 1m56s
CI / test-python-agent-core (push) Successful in 16s
CI / test-nodejs-website (push) Successful in 17s
Pipeline-Reihenfolge neu: Orientierung → Begradigung → Entzerrung → Zuschneiden → Spalten... Crop arbeitet jetzt auf dem bereits geraden Bild, was bessere Ergebnisse liefert. page_crop.py komplett ersetzt: Adaptive Threshold + 4-Kanten-Erkennung (Buchruecken-Schatten links, Ink-Projektion fuer alle Raender) statt Otsu + groesste Kontur. Backend: Step-Nummern, Input-Bilder, Reprocess-Kaskade angepasst. Frontend: PIPELINE_STEPS umgeordnet, Switch-Cases, Vorher-Bilder aktualisiert. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
186 lines
6.2 KiB
TypeScript
186 lines
6.2 KiB
TypeScript
'use client'
|
||
|
||
import { useEffect, useState } from 'react'
|
||
import type { CropResult } from '@/app/(admin)/ai/ocr-pipeline/types'
|
||
import { ImageCompareView } from './ImageCompareView'
|
||
|
||
const KLAUSUR_API = '/klausur-api'
|
||
|
||
interface StepCropProps {
|
||
sessionId: string | null
|
||
onNext: () => void
|
||
}
|
||
|
||
export function StepCrop({ sessionId, onNext }: StepCropProps) {
|
||
const [cropResult, setCropResult] = useState<CropResult | null>(null)
|
||
const [cropping, setCropping] = useState(false)
|
||
const [error, setError] = useState<string | null>(null)
|
||
const [hasRun, setHasRun] = useState(false)
|
||
|
||
// Auto-trigger crop on mount
|
||
useEffect(() => {
|
||
if (!sessionId || hasRun) return
|
||
setHasRun(true)
|
||
|
||
const runCrop = async () => {
|
||
setCropping(true)
|
||
setError(null)
|
||
|
||
try {
|
||
// Check if session already has crop result
|
||
const sessionRes = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}`)
|
||
if (sessionRes.ok) {
|
||
const sessionData = await sessionRes.json()
|
||
if (sessionData.crop_result) {
|
||
setCropResult(sessionData.crop_result)
|
||
setCropping(false)
|
||
return
|
||
}
|
||
}
|
||
|
||
const res = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/crop`, {
|
||
method: 'POST',
|
||
})
|
||
|
||
if (!res.ok) {
|
||
throw new Error('Zuschnitt fehlgeschlagen')
|
||
}
|
||
|
||
const data = await res.json()
|
||
setCropResult(data)
|
||
} catch (e) {
|
||
setError(e instanceof Error ? e.message : 'Unbekannter Fehler')
|
||
} finally {
|
||
setCropping(false)
|
||
}
|
||
}
|
||
|
||
runCrop()
|
||
}, [sessionId, hasRun])
|
||
|
||
const handleSkip = async () => {
|
||
if (!sessionId) return
|
||
try {
|
||
const res = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/crop/skip`, {
|
||
method: 'POST',
|
||
})
|
||
if (res.ok) {
|
||
const data = await res.json()
|
||
setCropResult(data)
|
||
}
|
||
} catch (e) {
|
||
console.error('Skip crop failed:', e)
|
||
}
|
||
onNext()
|
||
}
|
||
|
||
if (!sessionId) {
|
||
return <div className="text-sm text-gray-400">Keine Session ausgewaehlt.</div>
|
||
}
|
||
|
||
const dewarpedUrl = `${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/image/dewarped`
|
||
const croppedUrl = cropResult
|
||
? `${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/image/cropped`
|
||
: null
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
{/* Loading indicator */}
|
||
{cropping && (
|
||
<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" />
|
||
Scannerraender werden erkannt...
|
||
</div>
|
||
)}
|
||
|
||
{/* Image comparison */}
|
||
<ImageCompareView
|
||
originalUrl={dewarpedUrl}
|
||
deskewedUrl={croppedUrl}
|
||
showGrid={false}
|
||
showBinarized={false}
|
||
binarizedUrl={null}
|
||
leftLabel="Entzerrt"
|
||
rightLabel="Zugeschnitten"
|
||
/>
|
||
|
||
{/* Crop result info */}
|
||
{cropResult && (
|
||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
||
<div className="flex flex-wrap items-center gap-3 text-sm">
|
||
{cropResult.crop_applied ? (
|
||
<>
|
||
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-amber-50 dark:bg-amber-900/20 text-amber-700 dark:text-amber-400 text-xs font-medium">
|
||
✂️ Zugeschnitten
|
||
</span>
|
||
{cropResult.detected_format && (
|
||
<>
|
||
<div className="h-4 w-px bg-gray-300 dark:bg-gray-600" />
|
||
<span className="text-gray-600 dark:text-gray-400">
|
||
Format: <span className="font-medium">{cropResult.detected_format}</span>
|
||
{cropResult.format_confidence != null && (
|
||
<span className="text-gray-400 ml-1">
|
||
({Math.round(cropResult.format_confidence * 100)}%)
|
||
</span>
|
||
)}
|
||
</span>
|
||
</>
|
||
)}
|
||
<div className="h-4 w-px bg-gray-300 dark:bg-gray-600" />
|
||
<span className="text-gray-400 text-xs">
|
||
{cropResult.original_size.width}x{cropResult.original_size.height} → {cropResult.cropped_size.width}x{cropResult.cropped_size.height}
|
||
</span>
|
||
{cropResult.border_fractions && (
|
||
<>
|
||
<div className="h-4 w-px bg-gray-300 dark:bg-gray-600" />
|
||
<span className="text-gray-400 text-xs">
|
||
Raender: O={pct(cropResult.border_fractions.top)} U={pct(cropResult.border_fractions.bottom)} L={pct(cropResult.border_fractions.left)} R={pct(cropResult.border_fractions.right)}
|
||
</span>
|
||
</>
|
||
)}
|
||
</>
|
||
) : (
|
||
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-400 text-xs font-medium">
|
||
✓ Kein Zuschnitt noetig
|
||
</span>
|
||
)}
|
||
{cropResult.duration_seconds != null && (
|
||
<span className="text-gray-400 text-xs ml-auto">
|
||
{cropResult.duration_seconds}s
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Action buttons */}
|
||
{cropResult && (
|
||
<div className="flex justify-between">
|
||
<button
|
||
onClick={handleSkip}
|
||
className="px-4 py-2 text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 transition-colors"
|
||
>
|
||
Ueberspringen
|
||
</button>
|
||
<button
|
||
onClick={onNext}
|
||
className="px-6 py-2 bg-teal-600 text-white rounded-lg hover:bg-teal-700 font-medium transition-colors"
|
||
>
|
||
Weiter →
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{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>
|
||
)
|
||
}
|
||
|
||
function pct(v: number): string {
|
||
return `${(v * 100).toFixed(1)}%`
|
||
}
|