From c905753f7ae0ab4f193dbc781c1aea61f6e5da15 Mon Sep 17 00:00:00 2001 From: Linrador <68631622+Linrador@users.noreply.github.com> Date: Sat, 2 May 2026 13:02:48 +0200 Subject: [PATCH] bugfixes + added Training Stats --- frontend/src/App.tsx | 81 +- frontend/src/components/ui/Downloads.tsx | 54 +- frontend/src/components/ui/TrainingTab.tsx | 1937 +++++++++++++------- 3 files changed, 1396 insertions(+), 676 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ff962d3..61ed1a5 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2033,6 +2033,24 @@ export default function App() { pausedByDisk: false, }) + const applyAutostartState = useCallback((data: unknown) => { + const d = (data ?? {}) as any + + setAutostartState({ + paused: Boolean(d?.paused), + pausedByUser: Boolean(d?.pausedByUser), + pausedByDisk: Boolean(d?.pausedByDisk), + }) + }, []) + + const loadAutostartState = useCallback(async () => { + const s = await apiJSON('/api/autostart/state', { + cache: 'no-store' as any, + }) + + applyAutostartState(s) + }, [applyAutostartState]) + useEffect(() => { busyRef.current = busy }, [busy]) @@ -2714,6 +2732,7 @@ export default function App() { if (!authed) return let es: EventSource | null = null + let autostartEs: EventSource | null = null let fallbackTimer: number | null = null const stopFallbackPoll = () => { @@ -2758,15 +2777,6 @@ export default function App() { } } - const applyAutostartState = (data: unknown) => { - const d = (data ?? {}) as any - setAutostartState({ - paused: Boolean(d?.paused), - pausedByUser: Boolean(d?.pausedByUser), - pausedByDisk: Boolean(d?.pausedByDisk), - }) - } - const onDoneChanged = () => { if (selectedTabRef.current !== 'finished') return void loadDonePage(donePageRef.current, doneSortRef.current) @@ -2837,20 +2847,26 @@ export default function App() { void loadDoneCount() void loadTaskStateOnce() - void apiJSON('/api/autostart/state', { cache: 'no-store' as any }) - .then((s) => { - setAutostartState({ - paused: Boolean(s?.paused), - pausedByUser: Boolean(s?.pausedByUser), - pausedByDisk: Boolean(s?.pausedByDisk), - }) - }) - .catch(() => {}) + void loadAutostartState().catch(() => {}) es = new EventSource('/api/events/stream') eventSourceRef.current = es modelEventNamesRef.current = new Set() + autostartEs = new EventSource('/api/autostart/state/stream') + + autostartEs.addEventListener('autostart', (ev) => { + try { + applyAutostartState(JSON.parse(String((ev as MessageEvent).data ?? 'null'))) + } catch { + // ignore + } + }) + + autostartEs.onerror = () => { + // Falls der separate Stream ausfällt, wenigstens beim Fokus/Visibility wieder korrekt ziehen. + } + es.onopen = () => { stopFallbackPoll() void loadTaskStateOnce() @@ -2867,8 +2883,10 @@ export default function App() { const onVis = () => { if (document.hidden) return + void loadJobs() void loadTaskStateOnce() + void loadAutostartState().catch(() => {}) if (selectedTabRef.current === 'finished') { void loadDonePage(donePageRef.current, doneSortRef.current) @@ -2897,10 +2915,21 @@ export default function App() { es.close() } + if (autostartEs) { + autostartEs.close() + } + eventSourceRef.current = null modelEventNamesRef.current = new Set() } - }, [authed, loadJobs, loadDoneCount, loadPendingAutoStarts]) + }, [ + authed, + loadJobs, + loadDoneCount, + loadPendingAutoStarts, + applyAutostartState, + loadAutostartState, + ]) useEffect(() => { const desired = new Set() @@ -3970,14 +3999,14 @@ export default function App() { roomStatusByModelKey={roomStatusByModelKey} pending={pendingWatchedRooms} autostartState={autostartState} - onRefreshAutostartState={async () => { + onRefreshAutostartState={async (next?: AutostartState) => { try { - const s = await apiJSON('/api/autostart/state', { cache: 'no-store' as any }) - setAutostartState({ - paused: Boolean(s?.paused), - pausedByUser: Boolean(s?.pausedByUser), - pausedByDisk: Boolean(s?.pausedByDisk), - }) + if (next) { + applyAutostartState(next) + return + } + + await loadAutostartState() } catch { // ignore } diff --git a/frontend/src/components/ui/Downloads.tsx b/frontend/src/components/ui/Downloads.tsx index f6d3fcc..b9b25d6 100644 --- a/frontend/src/components/ui/Downloads.tsx +++ b/frontend/src/components/ui/Downloads.tsx @@ -32,7 +32,7 @@ type Props = { jobs: RecordJob[] pending?: PendingWatchedRoom[] autostartState?: AutostartState - onRefreshAutostartState?: () => Promise | void + onRefreshAutostartState?: (next?: AutostartState) => Promise | void modelsByKey?: Record< string, { @@ -644,9 +644,9 @@ export default function Downloads({ const [watchedBusy, setWatchedBusy] = useState(false) - const refreshWatchedState = useCallback(async () => { + const refreshWatchedState = useCallback(async (next?: AutostartState) => { try { - await onRefreshAutostartState?.() + await onRefreshAutostartState?.(next) } catch { // ignore } @@ -678,11 +678,22 @@ export default function Downloads({ setWatchedBusy(true) try { - const res = await fetch('/api/autostart/pause', { method: 'POST' }) - if (!res.ok) throw new Error(`HTTP ${res.status}`) - await refreshWatchedState() - } catch { - // ignore + const res = await fetch('/api/autostart/pause', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + cache: 'no-store' as any, + body: JSON.stringify({ paused: true }), + }) + + if (!res.ok) { + const text = await res.text().catch(() => '') + throw new Error(text || `HTTP ${res.status}`) + } + + const data = await res.json().catch(() => null) + await refreshWatchedState(data ?? undefined) + } catch (e) { + console.warn('Autostart pausieren fehlgeschlagen:', e) } finally { setWatchedBusy(false) } @@ -693,11 +704,20 @@ export default function Downloads({ setWatchedBusy(true) try { - const res = await fetch('/api/autostart/resume', { method: 'POST' }) - if (!res.ok) throw new Error(`HTTP ${res.status}`) - await refreshWatchedState() - } catch { - // ignore + const res = await fetch('/api/autostart/resume', { + method: 'POST', + cache: 'no-store' as any, + }) + + if (!res.ok) { + const text = await res.text().catch(() => '') + throw new Error(text || `HTTP ${res.status}`) + } + + const data = await res.json().catch(() => null) + await refreshWatchedState(data ?? undefined) + } catch (e) { + console.warn('Autostart fortsetzen fehlgeschlagen:', e) } finally { setWatchedBusy(false) } @@ -1578,8 +1598,8 @@ export default function Downloads({ } leadingIcon={ watchedPaused - ? - : + ? + : } > Autostart @@ -1652,8 +1672,8 @@ export default function Downloads({ } leadingIcon={ watchedPaused - ? - : + ? + : } > Autostart diff --git a/frontend/src/components/ui/TrainingTab.tsx b/frontend/src/components/ui/TrainingTab.tsx index 12d3909..0ff6739 100644 --- a/frontend/src/components/ui/TrainingTab.tsx +++ b/frontend/src/components/ui/TrainingTab.tsx @@ -7,6 +7,7 @@ import LoadingSpinner from './LoadingSpinner' import { formatBytes, formatDuration } from './formatters' import { TrashIcon } from '@heroicons/react/20/solid' import { getSegmentLabelItem } from './Icons' +import Modal from './Modal' type ScoredLabel = { label: string @@ -111,6 +112,163 @@ type MagnifierState = { imageY: number } +type TrainingConfidence = { + score: number + level: 'none' | 'low' | 'mid' | 'high' + label: string +} + +type TrainingLabelStat = { + label: string + count: number + confidence?: TrainingConfidence +} + +type TrainingStats = { + feedbackCount: number + acceptedCount: number + correctedCount: number + sampleCount: number + boxCount: number + modelAvailable: boolean + confidence?: TrainingConfidence + labels: { + people: TrainingLabelStat[] + sexPositions: TrainingLabelStat[] + bodyParts: TrainingLabelStat[] + objects: TrainingLabelStat[] + clothing: TrainingLabelStat[] + } +} + +function countPercent(count: number, total: number) { + if (!Number.isFinite(count) || !Number.isFinite(total) || total <= 0) return '0%' + return `${Math.round((count / total) * 100)}%` +} + +function confidencePercent(confidence?: TrainingConfidence | null) { + const score = Number(confidence?.score) + if (!Number.isFinite(score)) return '0%' + return `${Math.round(clamp01(score) * 100)}%` +} + +function confidenceLabel(confidence?: TrainingConfidence | null) { + return confidence?.label || 'Keine' +} + +function confidencePillClass(confidence?: TrainingConfidence | null) { + switch (confidence?.level) { + case 'high': + return 'bg-emerald-50 text-emerald-800 ring-emerald-200 dark:bg-emerald-500/15 dark:text-emerald-100 dark:ring-emerald-400/30' + case 'mid': + return 'bg-yellow-50 text-yellow-900 ring-yellow-200 dark:bg-yellow-500/15 dark:text-yellow-100 dark:ring-yellow-400/30' + case 'low': + return 'bg-red-50 text-red-800 ring-red-200 dark:bg-red-500/15 dark:text-red-100 dark:ring-red-400/30' + default: + return 'bg-gray-50 text-gray-700 ring-gray-200 dark:bg-white/5 dark:text-gray-200 dark:ring-white/10' + } +} + +function averageCategoryConfidence(values: TrainingLabelStat[]) { + const scores = values + .map((item) => Number(item.confidence?.score)) + .filter((score) => Number.isFinite(score)) + + if (scores.length === 0) { + return undefined + } + + const avg = scores.reduce((sum, score) => sum + clamp01(score), 0) / scores.length + + return confidenceFromScore(avg) +} + +function confidenceFromScore(score: number): TrainingConfidence { + const safeScore = clamp01(Number(score)) + + if (safeScore >= 0.75) { + return { + score: safeScore, + level: 'high', + label: 'Hoch', + } + } + + if (safeScore >= 0.45) { + return { + score: safeScore, + level: 'mid', + label: 'Mittel', + } + } + + if (safeScore > 0) { + return { + score: safeScore, + level: 'low', + label: 'Niedrig', + } + } + + return { + score: 0, + level: 'none', + label: 'Keine', + } +} + +function currentAnalysisConfidence(prediction?: TrainingPrediction | null): TrainingConfidence { + if (!prediction?.modelAvailable) { + return confidenceFromScore(0) + } + + const scores: number[] = [] + + const addScore = (value: unknown) => { + const n = Number(value) + if (!Number.isFinite(n)) return + if (n <= 0) return + + scores.push(clamp01(n)) + } + + // Scene-Modell: Sexposition + if ( + prediction.sexPosition && + prediction.sexPosition !== 'unknown' + ) { + addScore(prediction.sexPositionScore) + } + + // Detector: sichtbare Boxen sind der wichtigste Signalträger. + for (const box of prediction.boxes ?? []) { + addScore(box.score) + } + + // Fallback, falls Labels Scores haben, aber keine Boxen vorhanden sind. + if ((prediction.boxes ?? []).length === 0) { + for (const item of prediction.bodyPartsPresent ?? []) { + addScore(item.score) + } + + for (const item of prediction.objectsPresent ?? []) { + addScore(item.score) + } + + for (const item of prediction.clothingPresent ?? []) { + addScore(item.score) + } + } + + if (scores.length === 0) { + return confidenceFromScore(0) + } + + const avg = scores.reduce((sum, score) => sum + score, 0) / scores.length + + return confidenceFromScore(avg) +} + const emptyLabels: TrainingLabels = { people: [], sexPositions: ['unknown'], @@ -1152,6 +1310,432 @@ function CollapsibleLabelSection(props: { ) } +function TrainingStatsList(props: { + title: string + description?: string + values: TrainingLabelStat[] + total: number + confidence?: TrainingConfidence + emptyText?: string +}) { + const sorted = [...props.values].sort((a, b) => b.count - a.count) + + return ( +
+
+
+
+
+ {props.title} +
+ + {props.description ? ( +
+ {props.description} +
+ ) : null} +
+ +
+ + {confidenceLabel(props.confidence)} · {confidencePercent(props.confidence)} + + + + {sorted.length} Labels + +
+
+ +
+ + + Häufigkeit + + + + + Confidence + +
+
+ + {sorted.length === 0 ? ( +
+
+
+ Keine Labels gefunden +
+ +
+ {props.emptyText || 'Sobald Feedback für diese Kategorie gespeichert wurde, erscheinen hier die Werte.'} +
+
+
+ ) : ( +
+ {sorted.map((item) => { + const labelItem = getSegmentLabelItem(item.label) + const Icon = labelItem.icon + + const shareWidth = countPercent(item.count, props.total) + const confWidth = confidencePercent(item.confidence) + + return ( +
+
+
+
+
+ +
+
+ {labelItem.text} +
+ +
+ {item.label} +
+
+
+ +
+ + {confidenceLabel(item.confidence)} + + +
+ {item.count} +
+
+
+ +
+
+
+ Häufigkeit in dieser Gruppe + {shareWidth} +
+ +
+
+
+
+ +
+
+ Daten-Confidence + {confWidth} +
+ +
+
+
+
+
+
+ ) + })} +
+ )} +
+ ) +} + +type TrainingStatsTabKey = + | 'people' + | 'sexPositions' + | 'bodyParts' + | 'objects' + | 'clothing' + +function TrainingStatsModal(props: { + open: boolean + onClose: () => void + stats: TrainingStats | null + loading: boolean + error: string | null + feedbackCount: number + requiredCount: number +}) { + const [activeTab, setActiveTab] = useState('people') + const stats = props.stats + + const acceptedCount = stats?.acceptedCount ?? 0 + const correctedCount = stats?.correctedCount ?? 0 + const totalFeedback = stats?.feedbackCount ?? props.feedbackCount + const boxCount = stats?.boxCount ?? 0 + const sampleCount = stats?.sampleCount ?? 0 + const overallConfidence = stats?.confidence + + const tabItems: Array<{ + key: TrainingStatsTabKey + title: string + description: string + values: TrainingLabelStat[] + total: number + confidence?: TrainingConfidence + }> = [ + { + key: 'people', + title: 'Personen', + description: 'Personen- und Gender-Labels aus Boxen.', + values: stats?.labels.people ?? [], + total: Math.max(1, boxCount), + confidence: averageCategoryConfidence(stats?.labels.people ?? []), + }, + { + key: 'sexPositions', + title: 'Sexpositionen', + description: 'Scene-Labels pro bewertetem Frame.', + values: stats?.labels.sexPositions ?? [], + total: Math.max(1, totalFeedback), + confidence: averageCategoryConfidence(stats?.labels.sexPositions ?? []), + }, + { + key: 'bodyParts', + title: 'Körperteile', + description: 'Körperteil-Labels aus Korrekturen und Boxen.', + values: stats?.labels.bodyParts ?? [], + total: Math.max(1, totalFeedback), + confidence: averageCategoryConfidence(stats?.labels.bodyParts ?? []), + }, + { + key: 'objects', + title: 'Gegenstände', + description: 'Objekt-Labels aus Korrekturen und Boxen.', + values: stats?.labels.objects ?? [], + total: Math.max(1, totalFeedback), + confidence: averageCategoryConfidence(stats?.labels.objects ?? []), + }, + { + key: 'clothing', + title: 'Kleidung', + description: 'Kleidungs-Labels aus Korrekturen und Boxen.', + values: stats?.labels.clothing ?? [], + total: Math.max(1, totalFeedback), + confidence: averageCategoryConfidence(stats?.labels.clothing ?? []), + }, + ] + + const activeTabItem = + tabItems.find((item) => item.key === activeTab) ?? tabItems[0] + + return ( + +
+ {props.loading ? ( +
+
+ + +
+ Statistiken werden geladen… +
+
+
+ ) : props.error ? ( +
+ {props.error} +
+ ) : ( +
+
+
+
+ Feedback +
+
+ {totalFeedback} +
+
+ benötigt: {props.requiredCount} +
+
+ +
+
+ Passt so +
+
+ {acceptedCount} +
+
+ {countPercent(acceptedCount, totalFeedback)} +
+
+ +
+
+ Korrigiert +
+
+ {correctedCount} +
+
+ {countPercent(correctedCount, totalFeedback)} +
+
+ +
+
+ Boxen +
+
+ {boxCount} +
+
+ {sampleCount} Samples +
+
+
+ +
+
+
+ Modellstatus +
+ +
+ {stats?.modelAvailable + ? 'Trainiertes Modell verfügbar' + : 'Noch kein trainiertes Modell verfügbar'} +
+ +
+ {stats?.modelAvailable + ? 'Die aktuellen Trainingsdaten können bereits von einem Modell genutzt werden.' + : 'Sammle weiter Feedback und starte anschließend das Training.'} +
+
+ +
+
+
+
+ Gesamt-Confidence +
+ +
+ {confidenceLabel(overallConfidence)} +
+
+ + + {confidencePercent(overallConfidence)} + +
+ +
+
+
+ +
+ Grober Wert aus Feedback-Menge, Boxen, Label-Abdeckung und Korrekturanteil. +
+
+
+ +
+
+ {tabItems.map((item) => { + const active = item.key === activeTab + const count = item.values.length + + return ( + + ) + })} +
+
+ + +
+ )} +
+ + ) +} + export default function TrainingTab() { const [labels, setLabels] = useState(emptyLabels) const [sample, setSample] = useState(null) @@ -1165,6 +1749,10 @@ export default function TrainingTab() { const [trainingStep, setTrainingStep] = useState('') const [error, setError] = useState(null) const [message, setMessage] = useState(null) + const [statsModalOpen, setStatsModalOpen] = useState(false) + const [trainingStats, setTrainingStats] = useState(null) + const [trainingStatsLoading, setTrainingStatsLoading] = useState(false) + const [trainingStatsError, setTrainingStatsError] = useState(null) const wasTrainingRunningRef = useRef(false) const imageBoxRef = useRef(null) @@ -1279,6 +1867,9 @@ export default function TrainingTab() { const shownTrainingStep = trainingRunning ? trainingStatus?.training?.step || trainingStep || 'Training läuft…' : trainingStep + const analysisConfidence = useMemo(() => { + return currentAnalysisConfidence(sample?.prediction) + }, [sample?.prediction]) const loadLabels = useCallback(async () => { const res = await fetch('/api/training/labels', { cache: 'no-store' }) @@ -1391,6 +1982,47 @@ export default function TrainingTab() { } }, []) + const loadTrainingStats = useCallback(async () => { + setTrainingStatsLoading(true) + setTrainingStatsError(null) + + try { + const res = await fetch('/api/training/stats', { cache: 'no-store' }) + const data = await res.json().catch(() => null) + + if (!res.ok) { + throw new Error(data?.error || `HTTP ${res.status}`) + } + + setTrainingStats({ + feedbackCount: Number(data?.feedbackCount ?? 0), + acceptedCount: Number(data?.acceptedCount ?? 0), + correctedCount: Number(data?.correctedCount ?? 0), + sampleCount: Number(data?.sampleCount ?? 0), + boxCount: Number(data?.boxCount ?? 0), + modelAvailable: Boolean(data?.modelAvailable), + confidence: data?.confidence, + labels: { + people: Array.isArray(data?.labels?.people) ? data.labels.people : [], + sexPositions: Array.isArray(data?.labels?.sexPositions) ? data.labels.sexPositions : [], + bodyParts: Array.isArray(data?.labels?.bodyParts) ? data.labels.bodyParts : [], + objects: Array.isArray(data?.labels?.objects) ? data.labels.objects : [], + clothing: Array.isArray(data?.labels?.clothing) ? data.labels.clothing : [], + }, + }) + } catch (e) { + setTrainingStatsError(e instanceof Error ? e.message : String(e)) + } finally { + setTrainingStatsLoading(false) + } + }, []) + + useEffect(() => { + if (!statsModalOpen) return + + void loadTrainingStats() + }, [statsModalOpen, loadTrainingStats]) + useEffect(() => { if (!boxLabel) return @@ -1893,11 +2525,6 @@ export default function TrainingTab() { }) }, []) - const isCoarsePointer = useCallback(() => { - if (typeof window === 'undefined') return false - return window.matchMedia('(pointer: coarse)').matches - }, []) - const showImageBoxes = !loading && !trainingRunning const detectorBoxesPanel = ( @@ -2040,708 +2667,752 @@ export default function TrainingTab() { ) return ( -
- {/* Sidebar links */} - - - {/* Mitte */} -
-
- {detectorBoxesPanel} -
- -
- {imageSrc ? ( -
-
setStatsModalOpen(true)} className={[ - 'relative inline-block max-h-[calc(46dvh-44px)] max-w-full select-none touch-none overscroll-contain transition sm:max-h-[60dvh] lg:max-h-[72dvh]', - '[-webkit-touch-callout:none] [-webkit-user-select:none] [user-select:none]', - trainingRunning || loading ? 'pointer-events-none' : '', + 'rounded-full bg-gray-100 px-2 py-0.5 text-[11px] font-medium text-gray-700 ring-1 ring-gray-200 transition', + 'hover:bg-indigo-50 hover:text-indigo-700 hover:ring-indigo-200', + 'focus:outline-none focus:ring-2 focus:ring-indigo-500/40', + 'dark:bg-white/10 dark:text-gray-200 dark:ring-white/10 dark:hover:bg-indigo-500/20 dark:hover:text-indigo-100 dark:hover:ring-indigo-300/30', ].join(' ')} - onContextMenu={(e) => e.preventDefault()} - onPointerDown={startDrawBox} - onPointerMove={moveDrawBox} - onPointerUp={finishDrawBox} - onPointerCancel={finishDrawBox} + title="Training-Statistiken anzeigen" + aria-label="Training-Statistiken anzeigen" > - Training Frame e.preventDefault()} - onDragStart={(e) => e.preventDefault()} - onPointerDown={(e) => { - e.currentTarget.setPointerCapture?.(e.pointerId) - }} + {feedbackCount} + +
+ + +
+ +
+ Bestätige oder korrigiere die Analyse. Jede Antwort wird als Trainingsdatenpunkt gespeichert. +
+ +
+
+
Datei
+
+ {sample?.sourceFile || '—'} +
+
+ +
+
+
Dateigröße
+
+ {formatBytes(sample?.sourceSizeBytes)} +
+
+ +
+
Frame-Zeit
+
+ {sample ? formatDuration(sample.second * 1000) : '—'} +
+
+
+
+ +
+ {detectorBoxesPanel} +
+ +
+ + + + +
+ {canStartTraining ? ( + <> + Nutzt alle bisher gespeicherten Bewertungen. Das aktuelle Bild wird nur einbezogen, + wenn du es vorher speicherst. + + ) : ( + <> + Noch nicht genug Feedback für das Training. Aktuell {feedbackCount}/{requiredCount} Bewertungen gespeichert. + + )} +
+
+ + {message ? ( +
+ {message} +
+ ) : null} + + {error ? ( +
+ {error} +
+ ) : null} + + + {/* Mitte */} +
+
+ {detectorBoxesPanel} +
+ +
+ {imageSrc ? ( +
+
+ onContextMenu={(e) => e.preventDefault()} + onPointerDown={startDrawBox} + onPointerMove={moveDrawBox} + onPointerUp={finishDrawBox} + onPointerCancel={finishDrawBox} + > + Training Frame e.preventDefault()} + onDragStart={(e) => e.preventDefault()} + onPointerDown={(e) => { + e.currentTarget.setPointerCapture?.(e.pointerId) + }} + className={[ + 'block max-h-[calc(46dvh-44px)] max-w-full object-contain sm:max-h-[60dvh] lg:max-h-[72dvh]', + 'select-none touch-none', + '[-webkit-user-drag:none] [-webkit-touch-callout:none]', + ].join(' ')} + /> - {showImageBoxes ? ( -
- {visibleBoxes.map(({ box, index, isDraft }) => { - const left = clampPercent(box.x * 100) - const top = clampPercent(box.y * 100) - const width = clampPercent(box.w * 100) - const height = clampPercent(box.h * 100) + {showImageBoxes ? ( +
+ {visibleBoxes.map(({ box, index, isDraft }) => { + const left = clampPercent(box.x * 100) + const top = clampPercent(box.y * 100) + const width = clampPercent(box.w * 100) + const height = clampPercent(box.h * 100) - const item = getSegmentLabelItem(box.label) - const Icon = item.icon - const isSmallBox = width < 18 || height < 12 - const isActiveBox = !isDraft && activeBoxIndex === index + const item = getSegmentLabelItem(box.label) + const Icon = item.icon + const isSmallBox = width < 18 || height < 12 + const isActiveBox = !isDraft && activeBoxIndex === index - return ( -
+ return (
- - - {!isDraft ? ( - ) : null} -
- {!isDraft ? ( - <> - {(['nw', 'ne', 'sw', 'se'] as const).map((handle) => ( + {!isDraft ? ( - ))} - - ) : null} -
- ) - })} -
- ) : null} + ) : null} +
- {touchMagnifier?.visible && imageSrc && showImageBoxes ? (() => { - const el = imageBoxRef.current - const rect = el?.getBoundingClientRect() + {!isDraft ? ( + <> + {(['nw', 'ne', 'sw', 'se'] as const).map((handle) => ( + + ))} + + ) : null} +
+ ) + })} +
+ ) : null} - const zoom = hasUsableBox - ? Math.max(0.55, Math.min(2.25, fitZoom)) - : 2 + {touchMagnifier?.visible && imageSrc && showImageBoxes ? (() => { + const el = imageBoxRef.current + const rect = el?.getBoundingClientRect() - const viewportW = typeof window !== 'undefined' ? window.innerWidth : 390 - const viewportH = typeof window !== 'undefined' ? window.innerHeight : 800 + if (!rect || rect.width <= 0 || rect.height <= 0) return null - let left = touchMagnifier.clientX - size / 2 - let top = touchMagnifier.clientY - size - 28 + const size = 156 + const padding = 20 - if (top < 8) { - top = touchMagnifier.clientY + 28 - } + const activeBox = + drawingBox || + (boxInteraction + ? correction.boxes?.[boxInteraction.index] ?? null + : null) - left = Math.max(8, Math.min(viewportW - size - 8, left)) - top = Math.max(8, Math.min(viewportH - size - 8, top)) + const hasUsableBox = + activeBox && + Number.isFinite(activeBox.w) && + Number.isFinite(activeBox.h) && + activeBox.w > 0.003 && + activeBox.h > 0.003 - const imageWidth = rect.width * zoom - const imageHeight = rect.height * zoom + const boxCenterX = hasUsableBox + ? clamp01(activeBox.x + activeBox.w / 2) + : touchMagnifier.imageX - const imageLeft = size / 2 - boxCenterX * imageWidth - const imageTop = size / 2 - boxCenterY * imageHeight + const boxCenterY = hasUsableBox + ? clamp01(activeBox.y + activeBox.h / 2) + : touchMagnifier.imageY - const pointerX = imageLeft + touchMagnifier.imageX * imageWidth - const pointerY = imageTop + touchMagnifier.imageY * imageHeight + const boxPixelW = hasUsableBox ? activeBox.w * rect.width : 0 + const boxPixelH = hasUsableBox ? activeBox.h * rect.height : 0 - const boxLeft = hasUsableBox ? imageLeft + activeBox.x * imageWidth : 0 - const boxTop = hasUsableBox ? imageTop + activeBox.y * imageHeight : 0 - const boxWidth = hasUsableBox ? activeBox.w * imageWidth : 0 - const boxHeight = hasUsableBox ? activeBox.h * imageHeight : 0 + const fitZoom = hasUsableBox + ? Math.min( + (size - padding * 2) / Math.max(1, boxPixelW), + (size - padding * 2) / Math.max(1, boxPixelH) + ) + : 2 - return ( -
- - - {hasUsableBox ? ( -
+ - ) : null} -
-
+ {hasUsableBox ? ( +
+ ) : null} -
-
- ) - })() : null} +
+
+ +
+
+ ) + })() : null} +
+ + {trainingRunning ? ( + + ) : loading ? ( + + ) : null} +
+ ) : trainingRunning ? ( + + ) : loading ? ( + + ) : ( +
Kein Frame geladen
+ )} +
+ +
+ + + + + +
+ +
+ Prüfe das Bild. Wenn die Erkennung stimmt: „Passt so“. Wenn nicht: korrigieren und speichern. +
+
+ + {/* Rechte Details/Korrektur */} +
+ +
-
- Prüfe das Bild. Wenn die Erkennung stimmt: „Passt so“. Wenn nicht: korrigieren und speichern. -
- - - {/* Rechte Details/Korrektur */} - -
+ setStatsModalOpen(false)} + stats={trainingStats} + loading={trainingStatsLoading} + error={trainingStatsError} + feedbackCount={feedbackCount} + requiredCount={requiredCount} + /> + ) } \ No newline at end of file