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 31s
CI / test-go-edu-search (push) Successful in 29s
CI / test-python-klausur (push) Failing after 1m53s
CI / test-python-agent-core (push) Successful in 16s
CI / test-nodejs-website (push) Successful in 18s
Backend: merge gaps within 5% of image width — the spine area may have thin ink strips splitting one physical gap into multiple detected gaps. Only use gaps >= 2% width as split points. Frontend: StepCrop now handles multi_page crop responses without crashing on missing original_size/cropped_size fields. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
201 lines
7.1 KiB
TypeScript
201 lines
7.1 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 as Record<string, unknown>).multi_page ? (
|
|
<>
|
|
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-400 text-xs font-medium">
|
|
Mehrseitig: {(cropResult as Record<string, unknown>).page_count as number} Seiten erkannt
|
|
</span>
|
|
{((cropResult as Record<string, unknown>).sub_sessions as Array<{id: string; name: string; page_index: number}> | undefined)?.map((sub) => (
|
|
<span key={sub.id} className="text-gray-400 text-xs">
|
|
Seite {sub.page_index + 1}
|
|
</span>
|
|
))}
|
|
</>
|
|
) : 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>
|
|
</>
|
|
)}
|
|
{cropResult.original_size && cropResult.cropped_size && (
|
|
<>
|
|
<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)}%`
|
|
}
|