Implementiert Buchwoelbungs-Entzerrung mit zwei Methoden: - Methode A: Vertikale-Kanten-Analyse (Sobel + Polynom 2. Grades) - Methode B: Textzeilen-Baseline (Tesseract + Baseline-Kruemmung) Beste Methode wird automatisch gewaehlt, manueller Slider (-3 bis +3). Backend: 3 neue Endpoints (auto/manual dewarp, ground truth) Frontend: StepDewarp + DewarpControls, Pipeline von 6 auf 7 Schritte Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
223 lines
7.4 KiB
TypeScript
223 lines
7.4 KiB
TypeScript
'use client'
|
|
|
|
import { useCallback, useState } from 'react'
|
|
import type { DeskewGroundTruth, DeskewResult, SessionInfo } from '@/app/(admin)/ai/ocr-pipeline/types'
|
|
import { DeskewControls } from './DeskewControls'
|
|
import { ImageCompareView } from './ImageCompareView'
|
|
|
|
const KLAUSUR_API = '/klausur-api'
|
|
|
|
interface StepDeskewProps {
|
|
onNext: (sessionId: string) => void
|
|
}
|
|
|
|
export function StepDeskew({ onNext }: StepDeskewProps) {
|
|
const [session, setSession] = useState<SessionInfo | null>(null)
|
|
const [deskewResult, setDeskewResult] = useState<DeskewResult | null>(null)
|
|
const [uploading, setUploading] = useState(false)
|
|
const [deskewing, setDeskewing] = useState(false)
|
|
const [applying, setApplying] = useState(false)
|
|
const [showBinarized, setShowBinarized] = useState(false)
|
|
const [showGrid, setShowGrid] = useState(true)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [dragOver, setDragOver] = useState(false)
|
|
|
|
const handleUpload = useCallback(async (file: File) => {
|
|
setUploading(true)
|
|
setError(null)
|
|
setDeskewResult(null)
|
|
|
|
try {
|
|
const formData = new FormData()
|
|
formData.append('file', file)
|
|
|
|
const res = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions`, {
|
|
method: 'POST',
|
|
body: formData,
|
|
})
|
|
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({ detail: res.statusText }))
|
|
throw new Error(err.detail || 'Upload fehlgeschlagen')
|
|
}
|
|
|
|
const data: SessionInfo = await res.json()
|
|
// Prepend API prefix to relative URLs
|
|
data.original_image_url = `${KLAUSUR_API}${data.original_image_url}`
|
|
setSession(data)
|
|
|
|
// Auto-trigger deskew
|
|
setDeskewing(true)
|
|
const deskewRes = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${data.session_id}/deskew`, {
|
|
method: 'POST',
|
|
})
|
|
|
|
if (!deskewRes.ok) {
|
|
throw new Error('Begradigung fehlgeschlagen')
|
|
}
|
|
|
|
const deskewData: DeskewResult = await deskewRes.json()
|
|
deskewData.deskewed_image_url = `${KLAUSUR_API}${deskewData.deskewed_image_url}`
|
|
deskewData.binarized_image_url = `${KLAUSUR_API}${deskewData.binarized_image_url}`
|
|
setDeskewResult(deskewData)
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : 'Unbekannter Fehler')
|
|
} finally {
|
|
setUploading(false)
|
|
setDeskewing(false)
|
|
}
|
|
}, [])
|
|
|
|
const handleManualDeskew = useCallback(async (angle: number) => {
|
|
if (!session) return
|
|
setApplying(true)
|
|
setError(null)
|
|
|
|
try {
|
|
const res = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${session.session_id}/deskew/manual`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ angle }),
|
|
})
|
|
|
|
if (!res.ok) throw new Error('Manuelle Begradigung fehlgeschlagen')
|
|
|
|
const data = await res.json()
|
|
setDeskewResult((prev) =>
|
|
prev
|
|
? {
|
|
...prev,
|
|
angle_applied: data.angle_applied,
|
|
method_used: data.method_used,
|
|
// Force reload by appending timestamp
|
|
deskewed_image_url: `${KLAUSUR_API}${data.deskewed_image_url}?t=${Date.now()}`,
|
|
}
|
|
: null,
|
|
)
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : 'Fehler')
|
|
} finally {
|
|
setApplying(false)
|
|
}
|
|
}, [session])
|
|
|
|
const handleGroundTruth = useCallback(async (gt: DeskewGroundTruth) => {
|
|
if (!session) return
|
|
try {
|
|
await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${session.session_id}/ground-truth/deskew`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(gt),
|
|
})
|
|
} catch (e) {
|
|
console.error('Ground truth save failed:', e)
|
|
}
|
|
}, [session])
|
|
|
|
const handleDrop = useCallback((e: React.DragEvent) => {
|
|
e.preventDefault()
|
|
setDragOver(false)
|
|
const file = e.dataTransfer.files[0]
|
|
if (file) handleUpload(file)
|
|
}, [handleUpload])
|
|
|
|
const handleFileInput = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const file = e.target.files?.[0]
|
|
if (file) handleUpload(file)
|
|
}, [handleUpload])
|
|
|
|
// Upload area (no session yet)
|
|
if (!session) {
|
|
return (
|
|
<div className="space-y-4">
|
|
<div
|
|
onDragOver={(e) => { e.preventDefault(); setDragOver(true) }}
|
|
onDragLeave={() => setDragOver(false)}
|
|
onDrop={handleDrop}
|
|
className={`border-2 border-dashed rounded-xl p-12 text-center transition-colors ${
|
|
dragOver
|
|
? 'border-teal-400 bg-teal-50 dark:bg-teal-900/20'
|
|
: 'border-gray-300 dark:border-gray-600 hover:border-teal-400'
|
|
}`}
|
|
>
|
|
{uploading ? (
|
|
<div className="text-gray-500">
|
|
<div className="animate-spin inline-block w-8 h-8 border-2 border-teal-500 border-t-transparent rounded-full mb-3" />
|
|
<p>Wird hochgeladen...</p>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="text-4xl mb-3">📄</div>
|
|
<p className="text-gray-600 dark:text-gray-400 mb-2">
|
|
PDF oder Bild hierher ziehen
|
|
</p>
|
|
<p className="text-sm text-gray-400 mb-4">oder</p>
|
|
<label className="inline-block px-4 py-2 bg-teal-600 text-white rounded-lg cursor-pointer hover:bg-teal-700 transition-colors">
|
|
Datei auswaehlen
|
|
<input
|
|
type="file"
|
|
accept=".pdf,.png,.jpg,.jpeg,.tiff,.tif"
|
|
onChange={handleFileInput}
|
|
className="hidden"
|
|
/>
|
|
</label>
|
|
</>
|
|
)}
|
|
</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>
|
|
)
|
|
}
|
|
|
|
// Session active: show comparison + controls
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* Filename */}
|
|
<div className="text-sm text-gray-500 dark:text-gray-400">
|
|
Datei: <span className="font-medium text-gray-700 dark:text-gray-300">{session.filename}</span>
|
|
{' '}({session.image_width} x {session.image_height} px)
|
|
</div>
|
|
|
|
{/* Loading indicator */}
|
|
{deskewing && (
|
|
<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" />
|
|
Begradigung laeuft (beide Methoden)...
|
|
</div>
|
|
)}
|
|
|
|
{/* Image comparison */}
|
|
<ImageCompareView
|
|
originalUrl={session.original_image_url}
|
|
deskewedUrl={deskewResult?.deskewed_image_url ?? null}
|
|
showGrid={showGrid}
|
|
showBinarized={showBinarized}
|
|
binarizedUrl={deskewResult?.binarized_image_url ?? null}
|
|
/>
|
|
|
|
{/* Controls */}
|
|
<DeskewControls
|
|
deskewResult={deskewResult}
|
|
showBinarized={showBinarized}
|
|
onToggleBinarized={() => setShowBinarized((v) => !v)}
|
|
showGrid={showGrid}
|
|
onToggleGrid={() => setShowGrid((v) => !v)}
|
|
onManualDeskew={handleManualDeskew}
|
|
onGroundTruth={handleGroundTruth}
|
|
onNext={() => session && onNext(session.session_id)}
|
|
isApplying={applying}
|
|
/>
|
|
|
|
{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>
|
|
)
|
|
}
|