feat: Implement page-split step with auto-detection and sub-session naming
StepPageSplit now: - Auto-calls POST /page-split on step entry - Shows oriented image + detection result - If double page: creates sub-sessions named "Title — S. 1/2" - If single page: green badge "keine Trennung noetig" - Manual "Weiter" button (no auto-advance) Also: - StepOrientation wrapper simplified (no page-split in orientation) - StepUpload passes name back via onUploaded(sid, name) - page.tsx: after page-split "Weiter" switches to first sub-session - useKombiPipeline exposes setSessionName Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -40,9 +40,9 @@ function OcrKombiContent() {
|
||||
deleteSession,
|
||||
renameSession,
|
||||
updateCategory,
|
||||
handleOrientationComplete,
|
||||
handleSessionChange,
|
||||
setSessionId,
|
||||
setSessionName,
|
||||
setSubSessions,
|
||||
setParentSessionId,
|
||||
setIsGroundTruth,
|
||||
@@ -54,8 +54,9 @@ function OcrKombiContent() {
|
||||
return (
|
||||
<StepUpload
|
||||
sessionId={sessionId}
|
||||
onUploaded={(sid) => {
|
||||
onUploaded={(sid, name) => {
|
||||
setSessionId(sid)
|
||||
setSessionName(name)
|
||||
loadSessions()
|
||||
}}
|
||||
onNext={handleNext}
|
||||
@@ -65,7 +66,7 @@ function OcrKombiContent() {
|
||||
return (
|
||||
<StepOrientation
|
||||
sessionId={sessionId}
|
||||
onNext={handleOrientationComplete}
|
||||
onNext={() => handleNext()}
|
||||
onSessionList={() => { loadSessions(); handleNewSession() }}
|
||||
/>
|
||||
)
|
||||
@@ -73,10 +74,19 @@ function OcrKombiContent() {
|
||||
return (
|
||||
<StepPageSplit
|
||||
sessionId={sessionId}
|
||||
onNext={handleNext}
|
||||
sessionName={sessionName}
|
||||
onNext={() => {
|
||||
// If sub-sessions were created, switch to the first one
|
||||
if (subSessions.length > 0) {
|
||||
setSessionId(subSessions[0].id)
|
||||
setSessionName(subSessions[0].name)
|
||||
}
|
||||
handleNext()
|
||||
}}
|
||||
onSubSessionsCreated={(subs) => {
|
||||
setSubSessions(subs)
|
||||
if (sessionId) setParentSessionId(sessionId)
|
||||
loadSessions()
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -356,6 +356,7 @@ export function useKombiPipeline() {
|
||||
setSessionId,
|
||||
setSubSessions,
|
||||
setParentSessionId,
|
||||
setSessionName,
|
||||
setIsGroundTruth,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,17 +4,17 @@ import { StepOrientation as BaseStepOrientation } from '@/components/ocr-pipelin
|
||||
|
||||
interface StepOrientationProps {
|
||||
sessionId: string | null
|
||||
onNext: (sessionId: string) => void
|
||||
onNext: () => void
|
||||
onSessionList: () => void
|
||||
}
|
||||
|
||||
/** Thin wrapper around the shared StepOrientation component */
|
||||
/** Thin wrapper — adapts the shared StepOrientation to the Kombi pipeline's simpler onNext() */
|
||||
export function StepOrientation({ sessionId, onNext, onSessionList }: StepOrientationProps) {
|
||||
return (
|
||||
<BaseStepOrientation
|
||||
key={sessionId}
|
||||
sessionId={sessionId}
|
||||
onNext={onNext}
|
||||
onNext={() => onNext()}
|
||||
onSessionList={onSessionList}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -1,123 +1,201 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import type { SubSession } from '@/app/(admin)/ai/ocr-pipeline/types'
|
||||
|
||||
const KLAUSUR_API = '/klausur-api'
|
||||
|
||||
interface PageSplitResult {
|
||||
multi_page: boolean
|
||||
page_count?: number
|
||||
page_splits?: { x: number; y: number; width: number; height: number; page_index: number }[]
|
||||
sub_sessions?: { id: string; name: string; page_index: number }[]
|
||||
used_original?: boolean
|
||||
duration_seconds?: number
|
||||
}
|
||||
|
||||
interface StepPageSplitProps {
|
||||
sessionId: string | null
|
||||
sessionName: string
|
||||
onNext: () => void
|
||||
onSubSessionsCreated: (subs: SubSession[]) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 3: Page split detection.
|
||||
* Checks if the image is a double-page spread and offers to split it.
|
||||
* If no split needed, auto-advances.
|
||||
*/
|
||||
export function StepPageSplit({ sessionId, onNext, onSubSessionsCreated }: StepPageSplitProps) {
|
||||
const [checking, setChecking] = useState(false)
|
||||
const [splitResult, setSplitResult] = useState<{ is_double_page: boolean; pages?: number } | null>(null)
|
||||
const [splitting, setSplitting] = useState(false)
|
||||
export function StepPageSplit({ sessionId, sessionName, onNext, onSubSessionsCreated }: StepPageSplitProps) {
|
||||
const [detecting, setDetecting] = useState(false)
|
||||
const [splitResult, setSplitResult] = useState<PageSplitResult | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
const didDetect = useRef(false)
|
||||
|
||||
// Auto-detect page split when step opens
|
||||
useEffect(() => {
|
||||
if (!sessionId) return
|
||||
// Auto-check for page split
|
||||
checkPageSplit()
|
||||
if (!sessionId || didDetect.current) return
|
||||
didDetect.current = true
|
||||
detectPageSplit()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sessionId])
|
||||
|
||||
const checkPageSplit = async () => {
|
||||
const detectPageSplit = async () => {
|
||||
if (!sessionId) return
|
||||
setChecking(true)
|
||||
setDetecting(true)
|
||||
setError('')
|
||||
try {
|
||||
const res = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}`)
|
||||
if (!res.ok) throw new Error('Session nicht gefunden')
|
||||
const data = await res.json()
|
||||
|
||||
// If sub-sessions already exist, this was already split
|
||||
if (data.sub_sessions?.length > 0) {
|
||||
onSubSessionsCreated(data.sub_sessions)
|
||||
onNext()
|
||||
return
|
||||
// First check if sub-sessions already exist
|
||||
const sessionRes = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}`)
|
||||
if (sessionRes.ok) {
|
||||
const sessionData = await sessionRes.json()
|
||||
if (sessionData.sub_sessions?.length > 0) {
|
||||
// Already split — show existing sub-sessions
|
||||
const subs = sessionData.sub_sessions as { id: string; name: string; page_index?: number; box_index?: number; current_step?: number }[]
|
||||
setSplitResult({
|
||||
multi_page: true,
|
||||
page_count: subs.length,
|
||||
sub_sessions: subs.map((s: { id: string; name: string; page_index?: number; box_index?: number }) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
page_index: s.page_index ?? s.box_index ?? 0,
|
||||
})),
|
||||
})
|
||||
onSubSessionsCreated(subs.map((s: { id: string; name: string; page_index?: number; box_index?: number; current_step?: number }) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
box_index: s.page_index ?? s.box_index ?? 0,
|
||||
current_step: s.current_step ?? 2,
|
||||
})))
|
||||
setDetecting(false)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Check aspect ratio to guess if double-page
|
||||
// For now, just auto-advance (page-split detection happens in orientation step)
|
||||
setSplitResult({ is_double_page: false })
|
||||
// Auto-advance if single page
|
||||
onNext()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setChecking(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSplit = async () => {
|
||||
if (!sessionId) return
|
||||
setSplitting(true)
|
||||
setError('')
|
||||
try {
|
||||
// Run page-split detection
|
||||
const res = await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/page-split`, {
|
||||
method: 'POST',
|
||||
})
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}))
|
||||
throw new Error(data.detail || 'Split fehlgeschlagen')
|
||||
throw new Error(data.detail || 'Seitentrennung fehlgeschlagen')
|
||||
}
|
||||
const data = await res.json()
|
||||
if (data.sub_sessions?.length > 0) {
|
||||
onSubSessionsCreated(data.sub_sessions)
|
||||
const data: PageSplitResult = await res.json()
|
||||
setSplitResult(data)
|
||||
|
||||
if (data.multi_page && data.sub_sessions?.length) {
|
||||
// Rename sub-sessions to "Title — S. 1", "Title — S. 2"
|
||||
const baseName = sessionName || 'Dokument'
|
||||
for (let i = 0; i < data.sub_sessions.length; i++) {
|
||||
const sub = data.sub_sessions[i]
|
||||
const newName = `${baseName} — S. ${i + 1}`
|
||||
await fetch(`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sub.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: newName }),
|
||||
}).catch(() => {})
|
||||
sub.name = newName
|
||||
}
|
||||
|
||||
onSubSessionsCreated(data.sub_sessions.map(s => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
box_index: s.page_index,
|
||||
current_step: 2,
|
||||
})))
|
||||
}
|
||||
onNext()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setSplitting(false)
|
||||
setDetecting(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (checking) {
|
||||
return <div className="text-sm text-gray-500 py-8 text-center">Pruefe Seitenformat...</div>
|
||||
}
|
||||
if (!sessionId) return null
|
||||
|
||||
if (splitResult?.is_double_page) {
|
||||
return (
|
||||
<div className="space-y-4 p-6 bg-blue-50 dark:bg-blue-900/20 rounded-xl border border-blue-200 dark:border-blue-800">
|
||||
<h3 className="text-sm font-medium text-blue-700 dark:text-blue-300">
|
||||
Doppelseite erkannt
|
||||
</h3>
|
||||
<p className="text-sm text-blue-600 dark:text-blue-400">
|
||||
Das Bild scheint eine Doppelseite zu sein. Soll es in zwei Einzelseiten aufgeteilt werden?
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleSplit}
|
||||
disabled={splitting}
|
||||
className="px-4 py-2 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{splitting ? 'Wird aufgeteilt...' : 'Aufteilen'}
|
||||
</button>
|
||||
<button
|
||||
onClick={onNext}
|
||||
className="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-sm rounded-lg hover:bg-gray-300"
|
||||
>
|
||||
Einzelseite beibehalten
|
||||
</button>
|
||||
</div>
|
||||
{error && <div className="text-sm text-red-500">{error}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const imageUrl = `${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/image/oriented`
|
||||
|
||||
return (
|
||||
<div className="text-sm text-gray-500 py-8 text-center">
|
||||
Einzelseite erkannt — weiter zum naechsten Schritt.
|
||||
{error && <div className="text-sm text-red-500 mt-2">{error}</div>}
|
||||
<div className="space-y-4">
|
||||
{/* Image */}
|
||||
<div className="relative rounded-lg overflow-hidden bg-gray-100 dark:bg-gray-700">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt="Orientiertes Bild"
|
||||
className="w-full object-contain max-h-[500px]"
|
||||
onError={(e) => {
|
||||
// Fallback to non-oriented image
|
||||
(e.target as HTMLImageElement).src =
|
||||
`${KLAUSUR_API}/api/v1/ocr-pipeline/sessions/${sessionId}/image`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Detection status */}
|
||||
{detecting && (
|
||||
<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" />
|
||||
Doppelseiten-Erkennung laeuft...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Detection result */}
|
||||
{splitResult && !detecting && (
|
||||
splitResult.multi_page ? (
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-700 p-4 space-y-2">
|
||||
<div className="text-sm font-medium text-blue-700 dark:text-blue-300">
|
||||
Doppelseite erkannt — {splitResult.page_count} Seiten getrennt
|
||||
</div>
|
||||
<p className="text-xs text-blue-600 dark:text-blue-400">
|
||||
Jede Seite wird als eigene Session weiterverarbeitet (eigene Begradigung, Entzerrung, etc.).
|
||||
{splitResult.used_original && ' Trennung auf Originalbild, da Orientierung die Doppelseite gedreht hat.'}
|
||||
</p>
|
||||
<div className="flex gap-2 mt-2">
|
||||
{splitResult.sub_sessions?.map(s => (
|
||||
<span
|
||||
key={s.id}
|
||||
className="text-xs px-2.5 py-1 rounded-md bg-blue-100 dark:bg-blue-800/40 text-blue-700 dark:text-blue-300 font-medium"
|
||||
>
|
||||
{s.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{splitResult.duration_seconds != null && (
|
||||
<div className="text-xs text-gray-400">{splitResult.duration_seconds.toFixed(1)}s</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-green-50 dark:bg-green-900/20 rounded-lg border border-green-200 dark:border-green-800 p-4">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-green-700 dark:text-green-300">
|
||||
<span>✓</span> Einzelseite — keine Trennung noetig
|
||||
</div>
|
||||
{splitResult.duration_seconds != null && (
|
||||
<div className="text-xs text-gray-400 mt-1">{splitResult.duration_seconds.toFixed(1)}s</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="text-sm text-red-500 bg-red-50 dark:bg-red-900/20 p-3 rounded-lg">
|
||||
{error}
|
||||
<button
|
||||
onClick={() => { didDetect.current = false; detectPageSplit() }}
|
||||
className="ml-2 text-teal-600 hover:underline"
|
||||
>
|
||||
Erneut versuchen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Next button — only show when detection is done */}
|
||||
{(splitResult || error) && !detecting && (
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={onNext}
|
||||
className="px-6 py-2.5 bg-teal-600 text-white text-sm font-medium rounded-lg hover:bg-teal-700 transition-colors"
|
||||
>
|
||||
Weiter →
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ const KLAUSUR_API = '/klausur-api'
|
||||
|
||||
interface StepUploadProps {
|
||||
sessionId: string | null
|
||||
onUploaded: (sessionId: string) => void
|
||||
onUploaded: (sessionId: string, name: string) => void
|
||||
onNext: () => void
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ export function StepUpload({ sessionId, onUploaded, onNext }: StepUploadProps) {
|
||||
})
|
||||
}
|
||||
|
||||
onUploaded(sid)
|
||||
onUploaded(sid, title.trim() || selectedFile.name)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
|
||||
Reference in New Issue
Block a user