feat: Feinabstimmung mit 7 Schiebereglern fuer Deskew/Dewarp
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 27s
CI / test-go-edu-search (push) Successful in 28s
CI / test-python-klausur (push) Failing after 2m1s
CI / test-python-agent-core (push) Successful in 16s
CI / test-nodejs-website (push) Successful in 18s
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 27s
CI / test-go-edu-search (push) Successful in 28s
CI / test-python-klausur (push) Failing after 2m1s
CI / test-python-agent-core (push) Successful in 16s
CI / test-nodejs-website (push) Successful in 18s
Neues aufklappbares Panel unter Entzerrung mit individuellen Reglern:
- 3 Rotations-Regler (P1 Iterative, P2 Word-Alignment, P3 Textline)
- 4 Scherungs-Regler (A-D Methoden) mit Radio-Auswahl
- Kombinierte Vorschau und Ground-Truth-Speicherung
- Backend: POST /sessions/{id}/adjust-combined Endpoint
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -79,8 +79,11 @@ export interface DeskewResult {
|
|||||||
session_id: string
|
session_id: string
|
||||||
angle_hough: number
|
angle_hough: number
|
||||||
angle_word_alignment: number
|
angle_word_alignment: number
|
||||||
|
angle_iterative?: number
|
||||||
|
angle_residual?: number
|
||||||
|
angle_textline?: number
|
||||||
angle_applied: number
|
angle_applied: number
|
||||||
method_used: 'hough' | 'word_alignment' | 'manual'
|
method_used: 'hough' | 'word_alignment' | 'manual' | 'iterative' | 'two_pass' | 'three_pass' | 'manual_combined'
|
||||||
confidence: number
|
confidence: number
|
||||||
duration_seconds: number
|
duration_seconds: number
|
||||||
deskewed_image_url: string
|
deskewed_image_url: string
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import type { DewarpResult, DewarpDetection, DewarpGroundTruth } from '@/app/(admin)/ai/ocr-pipeline/types'
|
import type { DeskewResult, DewarpResult, DewarpDetection, DewarpGroundTruth } from '@/app/(admin)/ai/ocr-pipeline/types'
|
||||||
|
|
||||||
interface DewarpControlsProps {
|
interface DewarpControlsProps {
|
||||||
dewarpResult: DewarpResult | null
|
dewarpResult: DewarpResult | null
|
||||||
|
deskewResult?: DeskewResult | null
|
||||||
showGrid: boolean
|
showGrid: boolean
|
||||||
onToggleGrid: () => void
|
onToggleGrid: () => void
|
||||||
onManualDewarp: (shearDegrees: number) => void
|
onManualDewarp: (shearDegrees: number) => void
|
||||||
|
onCombinedAdjust?: (rotationDegrees: number, shearDegrees: number) => void
|
||||||
onGroundTruth: (gt: DewarpGroundTruth) => void
|
onGroundTruth: (gt: DewarpGroundTruth) => void
|
||||||
onNext: () => void
|
onNext: () => void
|
||||||
isApplying: boolean
|
isApplying: boolean
|
||||||
@@ -19,9 +21,12 @@ const METHOD_LABELS: Record<string, string> = {
|
|||||||
hough_lines: 'C: Hough-Linien',
|
hough_lines: 'C: Hough-Linien',
|
||||||
text_lines: 'D: Textzeilenanalyse',
|
text_lines: 'D: Textzeilenanalyse',
|
||||||
manual: 'Manuell',
|
manual: 'Manuell',
|
||||||
|
manual_combined: 'Manuell (kombiniert)',
|
||||||
none: 'Keine Korrektur',
|
none: 'Keine Korrektur',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SHEAR_METHOD_KEYS = ['vertical_edge', 'projection', 'hough_lines', 'text_lines'] as const
|
||||||
|
|
||||||
/** Colour for a confidence value (0-1). */
|
/** Colour for a confidence value (0-1). */
|
||||||
function confColor(conf: number): string {
|
function confColor(conf: number): string {
|
||||||
if (conf >= 0.7) return 'text-green-600 dark:text-green-400'
|
if (conf >= 0.7) return 'text-green-600 dark:text-green-400'
|
||||||
@@ -43,11 +48,67 @@ function ConfBar({ value }: { value: number }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A single slider row for fine-tuning. */
|
||||||
|
function FineTuneSlider({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
step,
|
||||||
|
unit = '\u00B0',
|
||||||
|
radioName,
|
||||||
|
radioChecked,
|
||||||
|
onRadioChange,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
value: number
|
||||||
|
onChange: (v: number) => void
|
||||||
|
min: number
|
||||||
|
max: number
|
||||||
|
step: number
|
||||||
|
unit?: string
|
||||||
|
radioName?: string
|
||||||
|
radioChecked?: boolean
|
||||||
|
onRadioChange?: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{radioName !== undefined && (
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name={radioName}
|
||||||
|
checked={radioChecked}
|
||||||
|
onChange={onRadioChange}
|
||||||
|
className="w-3.5 h-3.5 accent-teal-500"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className="text-xs text-gray-500 dark:text-gray-400 w-36 shrink-0">{label}</span>
|
||||||
|
<span className="text-xs text-gray-400 w-8 text-right">{min}{unit}</span>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={min * 100}
|
||||||
|
max={max * 100}
|
||||||
|
step={step * 100}
|
||||||
|
value={Math.round(value * 100)}
|
||||||
|
onChange={(e) => onChange(parseInt(e.target.value) / 100)}
|
||||||
|
className="flex-1 h-1.5 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700 accent-teal-500"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-gray-400 w-8">+{max}{unit}</span>
|
||||||
|
<span className="font-mono text-xs w-14 text-right tabular-nums">
|
||||||
|
{value >= 0 ? '+' : ''}{value.toFixed(2)}{unit}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function DewarpControls({
|
export function DewarpControls({
|
||||||
dewarpResult,
|
dewarpResult,
|
||||||
|
deskewResult,
|
||||||
showGrid,
|
showGrid,
|
||||||
onToggleGrid,
|
onToggleGrid,
|
||||||
onManualDewarp,
|
onManualDewarp,
|
||||||
|
onCombinedAdjust,
|
||||||
onGroundTruth,
|
onGroundTruth,
|
||||||
onNext,
|
onNext,
|
||||||
isApplying,
|
isApplying,
|
||||||
@@ -57,6 +118,21 @@ export function DewarpControls({
|
|||||||
const [gtNotes, setGtNotes] = useState('')
|
const [gtNotes, setGtNotes] = useState('')
|
||||||
const [gtSaved, setGtSaved] = useState(false)
|
const [gtSaved, setGtSaved] = useState(false)
|
||||||
const [showDetails, setShowDetails] = useState(false)
|
const [showDetails, setShowDetails] = useState(false)
|
||||||
|
const [showFineTune, setShowFineTune] = useState(false)
|
||||||
|
|
||||||
|
// Fine-tuning rotation sliders (3 passes)
|
||||||
|
const [p1Iterative, setP1Iterative] = useState(0)
|
||||||
|
const [p2Residual, setP2Residual] = useState(0)
|
||||||
|
const [p3Textline, setP3Textline] = useState(0)
|
||||||
|
|
||||||
|
// Fine-tuning shear sliders (4 methods) + selected method
|
||||||
|
const [shearValues, setShearValues] = useState<Record<string, number>>({
|
||||||
|
vertical_edge: 0,
|
||||||
|
projection: 0,
|
||||||
|
hough_lines: 0,
|
||||||
|
text_lines: 0,
|
||||||
|
})
|
||||||
|
const [selectedShearMethod, setSelectedShearMethod] = useState<string>('vertical_edge')
|
||||||
|
|
||||||
// Initialize slider to auto-detected value when result arrives
|
// Initialize slider to auto-detected value when result arrives
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -65,6 +141,44 @@ export function DewarpControls({
|
|||||||
}
|
}
|
||||||
}, [dewarpResult?.shear_degrees])
|
}, [dewarpResult?.shear_degrees])
|
||||||
|
|
||||||
|
// Initialize fine-tuning sliders from deskew result
|
||||||
|
useEffect(() => {
|
||||||
|
if (deskewResult) {
|
||||||
|
setP1Iterative(deskewResult.angle_iterative ?? 0)
|
||||||
|
setP2Residual(deskewResult.angle_residual ?? 0)
|
||||||
|
setP3Textline(deskewResult.angle_textline ?? 0)
|
||||||
|
}
|
||||||
|
}, [deskewResult])
|
||||||
|
|
||||||
|
// Initialize shear sliders from dewarp detections
|
||||||
|
useEffect(() => {
|
||||||
|
if (dewarpResult?.detections) {
|
||||||
|
const newValues = { ...shearValues }
|
||||||
|
let bestMethod = selectedShearMethod
|
||||||
|
let bestConf = -1
|
||||||
|
for (const d of dewarpResult.detections) {
|
||||||
|
if (d.method in newValues) {
|
||||||
|
newValues[d.method] = d.shear_degrees
|
||||||
|
if (d.confidence > bestConf) {
|
||||||
|
bestConf = d.confidence
|
||||||
|
bestMethod = d.method
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setShearValues(newValues)
|
||||||
|
// Select the method that was actually used, or the highest confidence
|
||||||
|
if (dewarpResult.method_used && dewarpResult.method_used in newValues) {
|
||||||
|
setSelectedShearMethod(dewarpResult.method_used)
|
||||||
|
} else {
|
||||||
|
setSelectedShearMethod(bestMethod)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [dewarpResult?.detections])
|
||||||
|
|
||||||
|
const rotationSum = p1Iterative + p2Residual + p3Textline
|
||||||
|
const activeShear = shearValues[selectedShearMethod] ?? 0
|
||||||
|
|
||||||
const handleGroundTruth = (isCorrect: boolean) => {
|
const handleGroundTruth = (isCorrect: boolean) => {
|
||||||
setGtFeedback(isCorrect ? 'correct' : 'incorrect')
|
setGtFeedback(isCorrect ? 'correct' : 'incorrect')
|
||||||
if (isCorrect) {
|
if (isCorrect) {
|
||||||
@@ -82,8 +196,18 @@ export function DewarpControls({
|
|||||||
setGtSaved(true)
|
setGtSaved(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleShearValueChange = (method: string, value: number) => {
|
||||||
|
setShearValues((prev) => ({ ...prev, [method]: value }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFineTunePreview = () => {
|
||||||
|
if (onCombinedAdjust) {
|
||||||
|
onCombinedAdjust(rotationSum, activeShear)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const wasRejected = dewarpResult && dewarpResult.method_used === 'none' && (dewarpResult.detections || []).length > 0
|
const wasRejected = dewarpResult && dewarpResult.method_used === 'none' && (dewarpResult.detections || []).length > 0
|
||||||
const wasApplied = dewarpResult && dewarpResult.method_used !== 'none' && dewarpResult.method_used !== 'manual'
|
const wasApplied = dewarpResult && dewarpResult.method_used !== 'none' && dewarpResult.method_used !== 'manual' && dewarpResult.method_used !== 'manual_combined'
|
||||||
const detections = dewarpResult?.detections || []
|
const detections = dewarpResult?.detections || []
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -106,9 +230,9 @@ export function DewarpControls({
|
|||||||
{wasRejected
|
{wasRejected
|
||||||
? 'Quality Gate: Korrektur verworfen (Projektion nicht verbessert)'
|
? 'Quality Gate: Korrektur verworfen (Projektion nicht verbessert)'
|
||||||
: wasApplied
|
: wasApplied
|
||||||
? `Korrektur angewendet: ${dewarpResult.shear_degrees.toFixed(2)}°`
|
? `Korrektur angewendet: ${dewarpResult.shear_degrees.toFixed(2)}\u00B0`
|
||||||
: dewarpResult.method_used === 'manual'
|
: dewarpResult.method_used === 'manual' || dewarpResult.method_used === 'manual_combined'
|
||||||
? `Manuelle Korrektur: ${dewarpResult.shear_degrees.toFixed(2)}°`
|
? `Manuelle Korrektur: ${dewarpResult.shear_degrees.toFixed(2)}\u00B0`
|
||||||
: 'Keine Korrektur noetig'}
|
: 'Keine Korrektur noetig'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -117,7 +241,7 @@ export function DewarpControls({
|
|||||||
<div className="flex flex-wrap items-center gap-4 text-sm">
|
<div className="flex flex-wrap items-center gap-4 text-sm">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-gray-500">Scherung:</span>{' '}
|
<span className="text-gray-500">Scherung:</span>{' '}
|
||||||
<span className="font-mono font-medium">{dewarpResult.shear_degrees.toFixed(2)}°</span>
|
<span className="font-mono font-medium">{dewarpResult.shear_degrees.toFixed(2)}\u00B0</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-4 w-px bg-gray-300 dark:bg-gray-600" />
|
<div className="h-4 w-px bg-gray-300 dark:bg-gray-600" />
|
||||||
<div>
|
<div>
|
||||||
@@ -185,7 +309,7 @@ export function DewarpControls({
|
|||||||
{METHOD_LABELS[d.method] || d.method}
|
{METHOD_LABELS[d.method] || d.method}
|
||||||
</span>
|
</span>
|
||||||
<span className="font-mono w-16 text-right">
|
<span className="font-mono w-16 text-right">
|
||||||
{d.shear_degrees.toFixed(2)}°
|
{d.shear_degrees.toFixed(2)}\u00B0
|
||||||
</span>
|
</span>
|
||||||
<ConfBar value={d.confidence} />
|
<ConfBar value={d.confidence} />
|
||||||
{!aboveThreshold && (
|
{!aboveThreshold && (
|
||||||
@@ -210,7 +334,7 @@ export function DewarpControls({
|
|||||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
||||||
<div className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Scherwinkel (manuell)</div>
|
<div className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Scherwinkel (manuell)</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="text-xs text-gray-400 w-10 text-right">-2.0°</span>
|
<span className="text-xs text-gray-400 w-10 text-right">-2.0\u00B0</span>
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
min={-200}
|
min={-200}
|
||||||
@@ -220,8 +344,8 @@ export function DewarpControls({
|
|||||||
onChange={(e) => setManualShear(parseInt(e.target.value) / 100)}
|
onChange={(e) => setManualShear(parseInt(e.target.value) / 100)}
|
||||||
className="flex-1 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700 accent-teal-500"
|
className="flex-1 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700 accent-teal-500"
|
||||||
/>
|
/>
|
||||||
<span className="text-xs text-gray-400 w-10">+2.0°</span>
|
<span className="text-xs text-gray-400 w-10">+2.0\u00B0</span>
|
||||||
<span className="font-mono text-sm w-16 text-right">{manualShear.toFixed(2)}°</span>
|
<span className="font-mono text-sm w-16 text-right">{manualShear.toFixed(2)}\u00B0</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => onManualDewarp(manualShear)}
|
onClick={() => onManualDewarp(manualShear)}
|
||||||
disabled={isApplying}
|
disabled={isApplying}
|
||||||
@@ -236,8 +360,128 @@ export function DewarpControls({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Fine-tuning panel */}
|
||||||
|
{dewarpResult && onCombinedAdjust && (
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowFineTune(v => !v)}
|
||||||
|
className="w-full flex items-center justify-between p-4 text-left"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm">⚙️</span>
|
||||||
|
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Feinabstimmung</span>
|
||||||
|
<span className="text-xs text-gray-400">(7 Regler)</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-gray-400 text-sm">{showFineTune ? '\u25B2' : '\u25BC'}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showFineTune && (
|
||||||
|
<div className="px-4 pb-4 space-y-5">
|
||||||
|
{/* Rotation section */}
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">
|
||||||
|
Rotation (Begradigung)
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<FineTuneSlider
|
||||||
|
label="P1 Iterative Projection"
|
||||||
|
value={p1Iterative}
|
||||||
|
onChange={setP1Iterative}
|
||||||
|
min={-5}
|
||||||
|
max={5}
|
||||||
|
step={0.05}
|
||||||
|
/>
|
||||||
|
<FineTuneSlider
|
||||||
|
label="P2 Word-Alignment"
|
||||||
|
value={p2Residual}
|
||||||
|
onChange={setP2Residual}
|
||||||
|
min={-3}
|
||||||
|
max={3}
|
||||||
|
step={0.05}
|
||||||
|
/>
|
||||||
|
<FineTuneSlider
|
||||||
|
label="P3 Textline-Regression"
|
||||||
|
value={p3Textline}
|
||||||
|
onChange={setP3Textline}
|
||||||
|
min={-3}
|
||||||
|
max={3}
|
||||||
|
step={0.05}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-2 pt-1 border-t border-gray-100 dark:border-gray-700">
|
||||||
|
<span className="text-xs text-gray-500 dark:text-gray-400 w-36 shrink-0">Summe Rotation</span>
|
||||||
|
<span className="font-mono text-sm font-medium text-teal-600 dark:text-teal-400">
|
||||||
|
{rotationSum >= 0 ? '+' : ''}{rotationSum.toFixed(2)}\u00B0
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Shear section */}
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">
|
||||||
|
Scherung (Entzerrung) — einen Wert waehlen
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{SHEAR_METHOD_KEYS.map((method) => (
|
||||||
|
<FineTuneSlider
|
||||||
|
key={method}
|
||||||
|
label={METHOD_LABELS[method] || method}
|
||||||
|
value={shearValues[method]}
|
||||||
|
onChange={(v) => handleShearValueChange(method, v)}
|
||||||
|
min={-5}
|
||||||
|
max={5}
|
||||||
|
step={0.05}
|
||||||
|
radioName="shear-method"
|
||||||
|
radioChecked={selectedShearMethod === method}
|
||||||
|
onRadioChange={() => setSelectedShearMethod(method)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<div className="flex items-center gap-2 pt-1 border-t border-gray-100 dark:border-gray-700">
|
||||||
|
<span className="text-xs text-gray-500 dark:text-gray-400 w-36 shrink-0">Gewaehlte Scherung</span>
|
||||||
|
<span className="font-mono text-sm font-medium text-teal-600 dark:text-teal-400">
|
||||||
|
{activeShear >= 0 ? '+' : ''}{activeShear.toFixed(2)}\u00B0
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-400 ml-1">
|
||||||
|
({METHOD_LABELS[selectedShearMethod]})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Preview + Save */}
|
||||||
|
<div className="flex items-center gap-3 pt-2">
|
||||||
|
<button
|
||||||
|
onClick={handleFineTunePreview}
|
||||||
|
disabled={isApplying}
|
||||||
|
className="px-4 py-2 text-sm bg-teal-600 text-white rounded-md hover:bg-teal-700 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{isApplying ? 'Wird angewendet...' : 'Vorschau'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
onGroundTruth({
|
||||||
|
is_correct: false,
|
||||||
|
corrected_shear: activeShear,
|
||||||
|
notes: `Fine-tuned: rotation=${rotationSum.toFixed(3)}, shear=${activeShear.toFixed(3)} (${selectedShearMethod})`,
|
||||||
|
})
|
||||||
|
setGtSaved(true)
|
||||||
|
}}
|
||||||
|
disabled={gtSaved}
|
||||||
|
className="px-4 py-2 text-sm bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{gtSaved ? 'Gespeichert' : 'Als Ground Truth speichern'}
|
||||||
|
</button>
|
||||||
|
<span className="text-xs text-gray-400">
|
||||||
|
Rotation: {rotationSum >= 0 ? '+' : ''}{rotationSum.toFixed(2)}\u00B0 + Scherung: {activeShear >= 0 ? '+' : ''}{activeShear.toFixed(2)}\u00B0
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Ground Truth */}
|
{/* Ground Truth */}
|
||||||
{dewarpResult && (
|
{dewarpResult && !showFineTune && (
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
||||||
<div className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
<div className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
Spalten vertikal ausgerichtet?
|
Spalten vertikal ausgerichtet?
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from 'react'
|
import { useCallback, useEffect, useState } from 'react'
|
||||||
import type { DewarpResult, DewarpGroundTruth } from '@/app/(admin)/ai/ocr-pipeline/types'
|
import type { DeskewResult, DewarpResult, DewarpGroundTruth } from '@/app/(admin)/ai/ocr-pipeline/types'
|
||||||
import { DewarpControls } from './DewarpControls'
|
import { DewarpControls } from './DewarpControls'
|
||||||
import { ImageCompareView } from './ImageCompareView'
|
import { ImageCompareView } from './ImageCompareView'
|
||||||
|
|
||||||
@@ -14,11 +14,31 @@ interface StepDewarpProps {
|
|||||||
|
|
||||||
export function StepDewarp({ sessionId, onNext }: StepDewarpProps) {
|
export function StepDewarp({ sessionId, onNext }: StepDewarpProps) {
|
||||||
const [dewarpResult, setDewarpResult] = useState<DewarpResult | null>(null)
|
const [dewarpResult, setDewarpResult] = useState<DewarpResult | null>(null)
|
||||||
|
const [deskewResult, setDeskewResult] = useState<DeskewResult | null>(null)
|
||||||
const [dewarping, setDewarping] = useState(false)
|
const [dewarping, setDewarping] = useState(false)
|
||||||
const [applying, setApplying] = useState(false)
|
const [applying, setApplying] = useState(false)
|
||||||
const [showGrid, setShowGrid] = useState(true)
|
const [showGrid, setShowGrid] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Load session info to get deskew_result (for fine-tuning init values)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sessionId) return
|
||||||
|
const loadSession = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}`)
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
if (data.deskew_result) {
|
||||||
|
setDeskewResult(data.deskew_result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load session info:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadSession()
|
||||||
|
}, [sessionId])
|
||||||
|
|
||||||
// Auto-trigger dewarp when component mounts with a sessionId
|
// Auto-trigger dewarp when component mounts with a sessionId
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!sessionId || dewarpResult) return
|
if (!sessionId || dewarpResult) return
|
||||||
@@ -78,6 +98,37 @@ export function StepDewarp({ sessionId, onNext }: StepDewarpProps) {
|
|||||||
}
|
}
|
||||||
}, [sessionId])
|
}, [sessionId])
|
||||||
|
|
||||||
|
const handleCombinedAdjust = useCallback(async (rotationDegrees: number, shearDegrees: number) => {
|
||||||
|
if (!sessionId) return
|
||||||
|
setApplying(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/adjust-combined`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ rotation_degrees: rotationDegrees, shear_degrees: shearDegrees }),
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error('Kombinierte Anpassung fehlgeschlagen')
|
||||||
|
|
||||||
|
const data = await res.json()
|
||||||
|
setDewarpResult((prev) =>
|
||||||
|
prev
|
||||||
|
? {
|
||||||
|
...prev,
|
||||||
|
method_used: data.method_used,
|
||||||
|
shear_degrees: data.shear_degrees,
|
||||||
|
dewarped_image_url: `${KLAUSUR_API}${data.dewarped_image_url}?t=${Date.now()}`,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
)
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : 'Fehler')
|
||||||
|
} finally {
|
||||||
|
setApplying(false)
|
||||||
|
}
|
||||||
|
}, [sessionId])
|
||||||
|
|
||||||
const handleGroundTruth = useCallback(async (gt: DewarpGroundTruth) => {
|
const handleGroundTruth = useCallback(async (gt: DewarpGroundTruth) => {
|
||||||
if (!sessionId) return
|
if (!sessionId) return
|
||||||
try {
|
try {
|
||||||
@@ -133,9 +184,11 @@ export function StepDewarp({ sessionId, onNext }: StepDewarpProps) {
|
|||||||
{/* Controls */}
|
{/* Controls */}
|
||||||
<DewarpControls
|
<DewarpControls
|
||||||
dewarpResult={dewarpResult}
|
dewarpResult={dewarpResult}
|
||||||
|
deskewResult={deskewResult}
|
||||||
showGrid={showGrid}
|
showGrid={showGrid}
|
||||||
onToggleGrid={() => setShowGrid((v) => !v)}
|
onToggleGrid={() => setShowGrid((v) => !v)}
|
||||||
onManualDewarp={handleManualDewarp}
|
onManualDewarp={handleManualDewarp}
|
||||||
|
onCombinedAdjust={handleCombinedAdjust}
|
||||||
onGroundTruth={handleGroundTruth}
|
onGroundTruth={handleGroundTruth}
|
||||||
onNext={onNext}
|
onNext={onNext}
|
||||||
isApplying={applying}
|
isApplying={applying}
|
||||||
|
|||||||
@@ -148,6 +148,11 @@ class ManualDewarpRequest(BaseModel):
|
|||||||
shear_degrees: float
|
shear_degrees: float
|
||||||
|
|
||||||
|
|
||||||
|
class CombinedAdjustRequest(BaseModel):
|
||||||
|
rotation_degrees: float = 0.0
|
||||||
|
shear_degrees: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
class DewarpGroundTruthRequest(BaseModel):
|
class DewarpGroundTruthRequest(BaseModel):
|
||||||
is_correct: bool
|
is_correct: bool
|
||||||
corrected_shear: Optional[float] = None
|
corrected_shear: Optional[float] = None
|
||||||
@@ -848,6 +853,93 @@ async def manual_dewarp(session_id: str, req: ManualDewarpRequest):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/sessions/{session_id}/adjust-combined")
|
||||||
|
async def adjust_combined(session_id: str, req: CombinedAdjustRequest):
|
||||||
|
"""Apply rotation + shear combined to the original image.
|
||||||
|
|
||||||
|
Used by the fine-tuning sliders to preview arbitrary rotation/shear
|
||||||
|
combinations without re-running the full deskew/dewarp pipeline.
|
||||||
|
"""
|
||||||
|
if session_id not in _cache:
|
||||||
|
await _load_session_to_cache(session_id)
|
||||||
|
cached = _get_cached(session_id)
|
||||||
|
|
||||||
|
img_bgr = cached.get("original_bgr")
|
||||||
|
if img_bgr is None:
|
||||||
|
raise HTTPException(status_code=400, detail="Original image not available")
|
||||||
|
|
||||||
|
rotation = max(-15.0, min(15.0, req.rotation_degrees))
|
||||||
|
shear_deg = max(-5.0, min(5.0, req.shear_degrees))
|
||||||
|
|
||||||
|
h, w = img_bgr.shape[:2]
|
||||||
|
result_bgr = img_bgr
|
||||||
|
|
||||||
|
# Step 1: Apply rotation
|
||||||
|
if abs(rotation) >= 0.001:
|
||||||
|
center = (w // 2, h // 2)
|
||||||
|
M = cv2.getRotationMatrix2D(center, rotation, 1.0)
|
||||||
|
result_bgr = cv2.warpAffine(result_bgr, M, (w, h),
|
||||||
|
flags=cv2.INTER_LINEAR,
|
||||||
|
borderMode=cv2.BORDER_REPLICATE)
|
||||||
|
|
||||||
|
# Step 2: Apply shear
|
||||||
|
if abs(shear_deg) >= 0.001:
|
||||||
|
result_bgr = dewarp_image_manual(result_bgr, shear_deg)
|
||||||
|
|
||||||
|
# Encode
|
||||||
|
success, png_buf = cv2.imencode(".png", result_bgr)
|
||||||
|
dewarped_png = png_buf.tobytes() if success else b""
|
||||||
|
|
||||||
|
# Binarize
|
||||||
|
binarized_png = None
|
||||||
|
try:
|
||||||
|
binarized = create_ocr_image(result_bgr)
|
||||||
|
success_bin, bin_buf = cv2.imencode(".png", binarized)
|
||||||
|
binarized_png = bin_buf.tobytes() if success_bin else None
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Build combined result dicts
|
||||||
|
deskew_result = {
|
||||||
|
**(cached.get("deskew_result") or {}),
|
||||||
|
"angle_applied": round(rotation, 3),
|
||||||
|
"method_used": "manual_combined",
|
||||||
|
}
|
||||||
|
dewarp_result = {
|
||||||
|
**(cached.get("dewarp_result") or {}),
|
||||||
|
"method_used": "manual_combined",
|
||||||
|
"shear_degrees": round(shear_deg, 3),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Update cache
|
||||||
|
cached["deskewed_bgr"] = result_bgr
|
||||||
|
cached["dewarped_bgr"] = result_bgr
|
||||||
|
cached["deskew_result"] = deskew_result
|
||||||
|
cached["dewarp_result"] = dewarp_result
|
||||||
|
|
||||||
|
# Persist to DB
|
||||||
|
db_update = {
|
||||||
|
"dewarped_png": dewarped_png,
|
||||||
|
"deskew_result": deskew_result,
|
||||||
|
"dewarp_result": dewarp_result,
|
||||||
|
}
|
||||||
|
if binarized_png:
|
||||||
|
db_update["binarized_png"] = binarized_png
|
||||||
|
db_update["deskewed_png"] = dewarped_png
|
||||||
|
await update_session_db(session_id, **db_update)
|
||||||
|
|
||||||
|
logger.info(f"OCR Pipeline: combined adjust session {session_id}: "
|
||||||
|
f"rotation={rotation:.3f} shear={shear_deg:.3f}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"session_id": session_id,
|
||||||
|
"rotation_degrees": round(rotation, 3),
|
||||||
|
"shear_degrees": round(shear_deg, 3),
|
||||||
|
"method_used": "manual_combined",
|
||||||
|
"dewarped_image_url": f"/api/v1/ocr-pipeline/sessions/{session_id}/image/dewarped",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/sessions/{session_id}/ground-truth/dewarp")
|
@router.post("/sessions/{session_id}/ground-truth/dewarp")
|
||||||
async def save_dewarp_ground_truth(session_id: str, req: DewarpGroundTruthRequest):
|
async def save_dewarp_ground_truth(session_id: str, req: DewarpGroundTruthRequest):
|
||||||
"""Save ground truth feedback for the dewarp step."""
|
"""Save ground truth feedback for the dewarp step."""
|
||||||
|
|||||||
Reference in New Issue
Block a user