feat(ocr): Word-based image deskew for Ground Truth pipeline
Begradigt schiefe Scans vor der OCR-Extraktion anhand der linksbuendigen
Wortanfaenge der Vokabelspalte. Tesseract liefert achsenparallele Boxen,
die bei ~2-3 Grad Schraege in Nachbarzeilen bluten — der Deskew behebt das.
- Neue Funktion deskew_image_by_word_alignment() in cv_vocab_pipeline.py
- Deskew-Integration im extract-with-boxes Endpoint (vor OCR)
- Neuer GET Endpoint /deskewed-image/{page} fuer begradigtes Seitenbild
- Frontend: GroundTruthPanel wechselt nach Extraktion auf deskewed Image
- ~1s Overhead durch schnellen Tesseract-Pass auf halbiertem Bild
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -111,6 +111,9 @@ export function GroundTruthPanel({ sessionId, selectedPage, pageImageUrl }: Grou
|
||||
const [imageNatural, setImageNatural] = useState({ w: 0, h: 0 })
|
||||
const [showSummary, setShowSummary] = useState(false)
|
||||
const [savedMessage, setSavedMessage] = useState<string | null>(null)
|
||||
const [isFullscreen, setIsFullscreen] = useState(false)
|
||||
const [imageUrl, setImageUrl] = useState(pageImageUrl)
|
||||
const [deskewAngle, setDeskewAngle] = useState<number | null>(null)
|
||||
|
||||
// Editable fields for current entry
|
||||
const [editEn, setEditEn] = useState('')
|
||||
@@ -120,13 +123,19 @@ export function GroundTruthPanel({ sessionId, selectedPage, pageImageUrl }: Grou
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
const enInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Reset image URL when page changes
|
||||
useEffect(() => {
|
||||
setImageUrl(pageImageUrl)
|
||||
setDeskewAngle(null)
|
||||
}, [pageImageUrl])
|
||||
|
||||
// Load natural image dimensions
|
||||
useEffect(() => {
|
||||
if (!pageImageUrl) return
|
||||
if (!imageUrl) return
|
||||
const img = new Image()
|
||||
img.onload = () => setImageNatural({ w: img.naturalWidth, h: img.naturalHeight })
|
||||
img.src = pageImageUrl
|
||||
}, [pageImageUrl])
|
||||
img.src = imageUrl
|
||||
}, [imageUrl])
|
||||
|
||||
// Sync edit fields when current entry changes
|
||||
useEffect(() => {
|
||||
@@ -157,6 +166,12 @@ export function GroundTruthPanel({ sessionId, selectedPage, pageImageUrl }: Grou
|
||||
const loaded: GTEntry[] = (data.entries || []).map((e: GTEntry) => ({ ...e, status: 'pending' as const }))
|
||||
setEntries(loaded)
|
||||
setCurrentIndex(0)
|
||||
|
||||
// Switch to deskewed image if available
|
||||
if (data.deskewed) {
|
||||
setImageUrl(`${KLAUSUR_API}/api/v1/vocab/sessions/${sessionId}/deskewed-image/${selectedPage}`)
|
||||
setDeskewAngle(data.deskew_angle)
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Extraction failed')
|
||||
} finally {
|
||||
@@ -225,9 +240,15 @@ export function GroundTruthPanel({ sessionId, selectedPage, pageImageUrl }: Grou
|
||||
// ---------- Keyboard shortcuts ----------
|
||||
|
||||
useEffect(() => {
|
||||
if (entries.length === 0 || showSummary) return
|
||||
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && isFullscreen) {
|
||||
e.preventDefault()
|
||||
setIsFullscreen(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (entries.length === 0 || showSummary) return
|
||||
|
||||
// Don't capture when typing in inputs
|
||||
const tag = (e.target as HTMLElement)?.tagName
|
||||
const isInput = tag === 'INPUT' || tag === 'TEXTAREA'
|
||||
@@ -251,7 +272,7 @@ export function GroundTruthPanel({ sessionId, selectedPage, pageImageUrl }: Grou
|
||||
|
||||
window.addEventListener('keydown', handler)
|
||||
return () => window.removeEventListener('keydown', handler)
|
||||
}, [entries.length, showSummary, confirmEntry, skipEntry, goTo, currentIndex])
|
||||
}, [entries.length, showSummary, isFullscreen, confirmEntry, skipEntry, goTo, currentIndex])
|
||||
|
||||
// ---------- Computed ----------
|
||||
|
||||
@@ -298,8 +319,27 @@ export function GroundTruthPanel({ sessionId, selectedPage, pageImageUrl }: Grou
|
||||
|
||||
if (showSummary) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-6" ref={panelRef}>
|
||||
<h3 className="text-lg font-semibold text-slate-900 mb-4">Zusammenfassung</h3>
|
||||
<div className={`bg-white rounded-xl border border-slate-200 p-6 ${
|
||||
isFullscreen ? 'fixed inset-0 z-50 overflow-auto m-0 rounded-none' : ''
|
||||
}`} ref={panelRef}>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-slate-900">Zusammenfassung</h3>
|
||||
<button
|
||||
onClick={() => setIsFullscreen(!isFullscreen)}
|
||||
className="p-1.5 rounded-lg hover:bg-slate-100 text-slate-500 hover:text-slate-700 transition-colors"
|
||||
title={isFullscreen ? 'Vollbild verlassen (Esc)' : 'Vollbild'}
|
||||
>
|
||||
{isFullscreen ? (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 9V4.5M9 9H4.5M9 9 3.75 3.75M9 15v4.5M9 15H4.5M9 15l-5.25 5.25M15 9h4.5M15 9V4.5M15 9l5.25-5.25M15 15h4.5M15 15v4.5m0-4.5 5.25 5.25" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 3.75v4.5m0-4.5h4.5m-4.5 0L9 9M3.75 20.25v-4.5m0 4.5h4.5m-4.5 0L9 15M20.25 3.75h-4.5m4.5 0v4.5m0-4.5L15 9m5.25 11.25h-4.5m4.5 0v-4.5m0 4.5L15 15" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4 mb-6">
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4 text-center">
|
||||
<div className="text-2xl font-bold text-green-700">{confirmedCount}</div>
|
||||
@@ -385,22 +425,47 @@ export function GroundTruthPanel({ sessionId, selectedPage, pageImageUrl }: Grou
|
||||
// ---------- Render: Main Review UI ----------
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden" ref={panelRef}>
|
||||
{/* Progress bar */}
|
||||
<div className="h-1.5 bg-slate-100">
|
||||
<div
|
||||
className="h-full bg-teal-500 transition-all duration-300"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
<div className={`bg-white rounded-xl border border-slate-200 overflow-hidden ${
|
||||
isFullscreen ? 'fixed inset-0 z-50 overflow-auto m-0 rounded-none bg-white' : ''
|
||||
}`} ref={panelRef}>
|
||||
{/* Header with progress + fullscreen toggle */}
|
||||
<div className="flex items-center gap-2 px-4 pt-2">
|
||||
<div className="flex-1 h-1.5 bg-slate-100 rounded-full">
|
||||
<div
|
||||
className="h-full bg-teal-500 transition-all duration-300 rounded-full"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-slate-400 whitespace-nowrap">{currentIndex + 1}/{entries.length}</span>
|
||||
{deskewAngle !== null && (
|
||||
<span className="text-xs text-teal-600 whitespace-nowrap" title="Bild wurde begradigt">
|
||||
{deskewAngle.toFixed(1)}°
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setIsFullscreen(!isFullscreen)}
|
||||
className="p-1.5 rounded-lg hover:bg-slate-100 text-slate-500 hover:text-slate-700 transition-colors"
|
||||
title={isFullscreen ? 'Vollbild verlassen (Esc)' : 'Vollbild'}
|
||||
>
|
||||
{isFullscreen ? (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 9V4.5M9 9H4.5M9 9 3.75 3.75M9 15v4.5M9 15H4.5M9 15l-5.25 5.25M15 9h4.5M15 9V4.5M15 9l5.25-5.25M15 15h4.5M15 15v4.5m0-4.5 5.25 5.25" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 3.75v4.5m0-4.5h4.5m-4.5 0L9 9M3.75 20.25v-4.5m0 4.5h4.5m-4.5 0L9 15M20.25 3.75h-4.5m4.5 0v4.5m0-4.5L15 9m5.25 11.25h-4.5m4.5 0v-4.5m0 4.5L15 15" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col lg:flex-row">
|
||||
<div className={`flex flex-col ${isFullscreen ? 'lg:flex-row h-[calc(100vh-3rem)]' : 'lg:flex-row'}`}>
|
||||
{/* Left: Page image with SVG overlay (2/3) */}
|
||||
<div className="lg:w-2/3 p-4">
|
||||
<div className={`${isFullscreen ? 'lg:w-2/3 p-4 overflow-y-auto h-full' : 'lg:w-2/3 p-4'}`}>
|
||||
<div className="relative bg-slate-50 rounded-lg overflow-hidden">
|
||||
{pageImageUrl && (
|
||||
{imageUrl && (
|
||||
<img
|
||||
src={pageImageUrl}
|
||||
src={imageUrl}
|
||||
alt={`Seite ${selectedPage + 1}`}
|
||||
className="w-full"
|
||||
draggable={false}
|
||||
@@ -451,13 +516,13 @@ export function GroundTruthPanel({ sessionId, selectedPage, pageImageUrl }: Grou
|
||||
</div>
|
||||
|
||||
{/* Right: Crops + Edit fields (1/3) */}
|
||||
<div className="lg:w-1/3 border-l border-slate-200 p-4 space-y-4">
|
||||
<div className={`lg:w-1/3 border-l border-slate-200 p-4 space-y-4 ${isFullscreen ? 'overflow-y-auto h-full' : ''}`}>
|
||||
{currentEntry && (
|
||||
<>
|
||||
{/* Row crop */}
|
||||
{imageNatural.w > 0 && (
|
||||
<ImageCrop
|
||||
imageUrl={pageImageUrl}
|
||||
imageUrl={imageUrl}
|
||||
bbox={currentEntry.bbox}
|
||||
naturalWidth={imageNatural.w}
|
||||
naturalHeight={imageNatural.h}
|
||||
@@ -470,7 +535,7 @@ export function GroundTruthPanel({ sessionId, selectedPage, pageImageUrl }: Grou
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{currentEntry.bbox_en.w > 0 && (
|
||||
<ImageCrop
|
||||
imageUrl={pageImageUrl}
|
||||
imageUrl={imageUrl}
|
||||
bbox={currentEntry.bbox_en}
|
||||
naturalWidth={imageNatural.w}
|
||||
naturalHeight={imageNatural.h}
|
||||
@@ -480,7 +545,7 @@ export function GroundTruthPanel({ sessionId, selectedPage, pageImageUrl }: Grou
|
||||
)}
|
||||
{currentEntry.bbox_de.w > 0 && (
|
||||
<ImageCrop
|
||||
imageUrl={pageImageUrl}
|
||||
imageUrl={imageUrl}
|
||||
bbox={currentEntry.bbox_de}
|
||||
naturalWidth={imageNatural.w}
|
||||
naturalHeight={imageNatural.h}
|
||||
@@ -490,7 +555,7 @@ export function GroundTruthPanel({ sessionId, selectedPage, pageImageUrl }: Grou
|
||||
)}
|
||||
{currentEntry.bbox_ex.w > 0 && (
|
||||
<ImageCrop
|
||||
imageUrl={pageImageUrl}
|
||||
imageUrl={imageUrl}
|
||||
bbox={currentEntry.bbox_ex}
|
||||
naturalWidth={imageNatural.w}
|
||||
naturalHeight={imageNatural.h}
|
||||
@@ -590,7 +655,7 @@ export function GroundTruthPanel({ sessionId, selectedPage, pageImageUrl }: Grou
|
||||
|
||||
{/* Keyboard hints */}
|
||||
<div className="text-xs text-slate-400 text-center border-t border-slate-100 pt-2">
|
||||
Enter = Bestaetigen · Tab = Ueberspringen · ←→ = Navigieren
|
||||
Enter = Bestaetigen · Tab = Ueberspringen · ←→ = Navigieren{isFullscreen ? ' \u00B7 Esc = Vollbild verlassen' : ''}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user