// frontend/src/components/ui/TrainingTab.tsx 'use client' import { useCallback, useEffect, useMemo, useRef, useState, type ComponentType, type CSSProperties } from 'react' import Button from './Button' import LoadingSpinner from './LoadingSpinner' import { formatDuration } from './formatters' import { ArrowPathIcon, BoltIcon, ClockIcon, ForwardIcon, TrashIcon, XCircleIcon, } from '@heroicons/react/20/solid' import { getSegmentLabelItem } from './Icons' import Modal from './Modal' import { createPortal } from 'react-dom' type DrawingTrainingBox = TrainingBox & { startX: number startY: number } type ScoredLabel = { label: string score: number } type TrainingDetectorStatus = { trainCount: number valCount: number requiredTrain: number requiredVal: number datasetReady: boolean dataReady: boolean modelExists: boolean modelPath?: string source?: string } type TrainingStatus = { feedbackCount: number requiredCount: number canTrain: boolean training?: TrainingJobStatus detector?: TrainingDetectorStatus } type TrainingJobStatus = { running: boolean progress: number step: string message?: string error?: string startedAt?: string finishedAt?: string durationMs?: number stage?: string epoch?: number epochs?: number } type TrainingPrediction = { modelAvailable: boolean source?: string sexPosition: string sexPositionScore: number peoplePresent: ScoredLabel[] bodyPartsPresent: ScoredLabel[] objectsPresent: ScoredLabel[] clothingPresent: ScoredLabel[] boxes?: TrainingBox[] } type TrainingSample = { sampleId: string frameUrl: string sourceFile: string sourcePath?: string sourceSizeBytes?: number second: number createdAt: string prediction: TrainingPrediction } type TrainingLabels = { people: string[] sexPositions: string[] bodyParts: string[] objects: string[] clothing: string[] } type CorrectionState = { sexPosition: string peoplePresent: string[] bodyPartsPresent: string[] objectsPresent: string[] clothingPresent: string[] boxes: TrainingBox[] } type TrainingBox = { label: string score?: number x: number y: number w: number h: number } type BoxInteraction = | { type: 'move' index: number startX: number startY: number original: TrainingBox } | { type: 'resize' index: number handle: 'nw' | 'ne' | 'sw' | 'se' startX: number startY: number original: TrainingBox } type MagnifierState = { visible: boolean clientX: number clientY: number imageX: number 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[] } } type TrainingNoticeKind = 'success' | 'error' | 'info' | 'warning' type TrainingNoticeItem = { icon: ComponentType<{ className?: string 'aria-hidden'?: boolean | 'true' | 'false' }> label: string value: string } type TrainingNotice = { kind: TrainingNoticeKind title: string message: string items?: TrainingNoticeItem[] progress?: number } function trainingNoticeClass(kind: TrainingNoticeKind) { switch (kind) { case 'success': return { wrap: 'border-emerald-200 bg-emerald-50 text-emerald-900 dark:border-emerald-400/30 dark:bg-emerald-500/10 dark:text-emerald-100', icon: 'bg-emerald-500 text-white', detail: 'text-emerald-800/80 dark:text-emerald-100/70', } case 'error': return { wrap: 'border-red-200 bg-red-50 text-red-900 dark:border-red-400/30 dark:bg-red-500/10 dark:text-red-100', icon: 'bg-red-500 text-white', detail: 'text-red-800/80 dark:text-red-100/70', } case 'warning': return { wrap: 'border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-400/30 dark:bg-amber-500/10 dark:text-amber-100', icon: 'bg-amber-500 text-white', detail: 'text-amber-800/80 dark:text-amber-100/70', } default: return { wrap: 'border-indigo-200 bg-indigo-50 text-indigo-900 dark:border-indigo-400/30 dark:bg-indigo-500/10 dark:text-indigo-100', icon: 'bg-indigo-500 text-white', detail: 'text-indigo-800/80 dark:text-indigo-100/70', } } } function TrainingNoticeCard(props: { notice: TrainingNotice onClose?: () => void }) { const cls = trainingNoticeClass(props.notice.kind) const icon = props.notice.kind === 'success' ? '✓' : props.notice.kind === 'error' ? '!' : props.notice.kind === 'warning' ? '⚠' : 'i' return (
{props.notice.title} {typeof props.notice.progress === 'number' ? ( {Math.round(clampPercent(props.notice.progress))}% ) : null}
{props.notice.items?.length ? (
{props.notice.items.map((item) => { const Icon = item.icon return (
) })}
) : (
{props.notice.message}
)}
{props.onClose ? ( ) : null}
) } function backendText(data: any, fallback: string) { return String( data?.message || data?.error || data?.detail || fallback ).trim() } function trainingDurationMs(job?: TrainingJobStatus | null) { const direct = Number(job?.durationMs) if (Number.isFinite(direct) && direct > 0) { return direct } const started = job?.startedAt ? Date.parse(job.startedAt) : NaN const finished = job?.finishedAt ? Date.parse(job.finishedAt) : NaN if (!Number.isFinite(started) || !Number.isFinite(finished)) { return 0 } return Math.max(0, finished - started) } 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)) } // YOLO-Detector: Sexposition als Full-Frame-/Positions-Label 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'], bodyParts: [], objects: [], clothing: [], } function percent(v: number) { if (!Number.isFinite(v)) return '—' return `${Math.round(v * 100)}%` } function scoreLevel(score?: number | null): 'none' | 'low' | 'mid' | 'high' { const n = Number(score) if (!Number.isFinite(n)) return 'none' if (n < 0.5) return 'low' if (n < 0.75) return 'mid' return 'high' } function scoreBorderClass(score?: number | null, opts?: { draft?: boolean }) { if (opts?.draft) return 'border-amber-400' switch (scoreLevel(score)) { case 'low': return 'border-red-500' case 'mid': return 'border-yellow-400' case 'high': return 'border-emerald-400' default: return 'border-gray-300' } } function scoreRingClass(score?: number | null, opts?: { draft?: boolean }) { if (opts?.draft) return 'ring-amber-500' switch (scoreLevel(score)) { case 'low': return 'ring-red-500' case 'mid': return 'ring-yellow-400' case 'high': return 'ring-emerald-500' default: return 'ring-gray-400' } } function scoreDetectionPillClass(score?: number | null) { switch (scoreLevel(score)) { 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' 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 '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' default: return 'bg-gray-50 text-gray-700 ring-gray-200 dark:bg-white/5 dark:text-gray-200 dark:ring-white/10' } } function detectorBoxAppearance(label: string) { const clean = String(label || '').trim() if (clean === 'person_female' || clean === 'female_person') { return { activeSurface: 'dark:bg-pink-500/10 dark:shadow-[0_0_0_1px_rgba(244,114,182,0.20),0_10px_24px_rgba(2,6,23,0.38)]', idleHover: 'dark:hover:bg-pink-500/[0.05]', line: 'bg-pink-400', lineHover: 'dark:group-hover:bg-pink-400/70', iconActive: 'dark:bg-pink-500/15 dark:text-pink-100 dark:ring-pink-400/25', iconIdle: 'dark:text-pink-200/80 dark:group-hover:bg-pink-500/10 dark:group-hover:text-pink-100 dark:group-hover:ring-pink-400/20', selectedText: 'dark:text-pink-300', } } if (clean === 'person_male' || clean === 'male_person') { return { activeSurface: 'dark:bg-sky-500/10 dark:shadow-[0_0_0_1px_rgba(56,189,248,0.20),0_10px_24px_rgba(2,6,23,0.38)]', idleHover: 'dark:hover:bg-sky-500/[0.05]', line: 'bg-sky-400', lineHover: 'dark:group-hover:bg-sky-400/70', iconActive: 'dark:bg-sky-500/15 dark:text-sky-100 dark:ring-sky-400/25', iconIdle: 'dark:text-sky-200/80 dark:group-hover:bg-sky-500/10 dark:group-hover:text-sky-100 dark:group-hover:ring-sky-400/20', selectedText: 'dark:text-sky-300', } } return { activeSurface: 'dark:bg-indigo-500/10 dark:shadow-[0_0_0_1px_rgba(129,140,248,0.20),0_10px_24px_rgba(2,6,23,0.38)]', idleHover: 'dark:hover:bg-indigo-500/[0.05]', line: 'bg-indigo-400', lineHover: 'dark:group-hover:bg-indigo-400/70', iconActive: 'dark:bg-indigo-500/15 dark:text-indigo-100 dark:ring-indigo-400/25', iconIdle: 'dark:text-indigo-200/80 dark:group-hover:bg-indigo-500/10 dark:group-hover:text-indigo-100 dark:group-hover:ring-indigo-400/20', selectedText: 'dark:text-indigo-300', } } function normalizeMovedBox(box: TrainingBox): TrainingBox { const w = clamp01(box.w) const h = clamp01(box.h) return { ...box, x: Math.max(0, Math.min(1 - w, Number(box.x) || 0)), y: Math.max(0, Math.min(1 - h, Number(box.y) || 0)), w, h, } } function clampPercent(v: number) { if (!Number.isFinite(v)) return 0 return Math.max(0, Math.min(100, v)) } function toggleArrayValue(arr: string[], value: string) { return arr.includes(value) ? arr.filter((x) => x !== value) : [...arr, value] } function clamp01(v: number) { if (!Number.isFinite(v)) return 0 return Math.max(0, Math.min(1, v)) } function snap01(v: number, epsilon = 0.006) { const n = clamp01(v) if (n <= epsilon) return 0 if (n >= 1 - epsilon) return 1 return n } function normalizeBox(box: TrainingBox): TrainingBox { const x = clamp01(box.x) const y = clamp01(box.y) const w = clamp01(Math.min(box.w, 1 - x)) const h = clamp01(Math.min(box.h, 1 - y)) return { ...box, x, y, w, h, } } function boxGeometryChanged(a: TrainingBox, b: TrainingBox) { const epsilon = 0.0005 return ( Math.abs(a.x - b.x) > epsilon || Math.abs(a.y - b.y) > epsilon || Math.abs(a.w - b.w) > epsilon || Math.abs(a.h - b.h) > epsilon ) } function markBoxCorrected(box: TrainingBox): TrainingBox { const { score: _oldScore, ...boxWithoutScore } = box return boxWithoutScore } function uniqStrings(values: string[]) { const seen = new Set() const out: string[] = [] for (const value of values) { const clean = String(value || '').trim() if (!clean || seen.has(clean)) continue seen.add(clean) out.push(clean) } return out } function peopleLabelsFromBoxes(boxes: TrainingBox[], labels: TrainingLabels) { return uniqStrings( boxes .map((box) => String(box.label || '').trim()) .filter((label) => labels.people.includes(label)) ) } function predictionToCorrection(sample: TrainingSample | null): CorrectionState { const p = sample?.prediction const boxes = (p?.boxes ?? []) .map((box) => ({ label: String(box.label || '').trim(), score: box.score, x: clamp01(Number(box.x)), y: clamp01(Number(box.y)), w: clamp01(Number(box.w)), h: clamp01(Number(box.h)), })) .filter((box) => box.label && box.w > 0 && box.h > 0) return { sexPosition: p?.sexPosition || 'unknown', peoplePresent: (p?.peoplePresent ?? []).map((x) => x.label), bodyPartsPresent: (p?.bodyPartsPresent ?? []).map((x) => x.label), objectsPresent: (p?.objectsPresent ?? []).map((x) => x.label), clothingPresent: (p?.clothingPresent ?? []).map((x) => x.label), boxes, } } function applyBoxLabelToCorrection( state: CorrectionState, label: string, labels: TrainingLabels ): CorrectionState { const clean = String(label || '').trim() if (!clean) return state if (labels.people.includes(clean)) { return { ...state, peoplePresent: state.peoplePresent.includes(clean) ? state.peoplePresent : [...state.peoplePresent, clean], } } if (labels.bodyParts.includes(clean)) { return { ...state, bodyPartsPresent: state.bodyPartsPresent.includes(clean) ? state.bodyPartsPresent : [...state.bodyPartsPresent, clean], } } if (labels.objects.includes(clean)) { return { ...state, objectsPresent: state.objectsPresent.includes(clean) ? state.objectsPresent : [...state.objectsPresent, clean], } } if (labels.clothing.includes(clean)) { return { ...state, clothingPresent: state.clothingPresent.includes(clean) ? state.clothingPresent : [...state.clothingPresent, clean], } } return state } function removeBoxLabelFromCorrection( state: CorrectionState, label: string, labels: TrainingLabels ): CorrectionState { const clean = String(label || '').trim() if (!clean) return state const remainingBoxesWithSameLabel = (state.boxes ?? []).some( (box) => String(box.label || '').trim() === clean ) // Badge nur abwählen, wenn keine weitere Box mit diesem Label existiert. if (remainingBoxesWithSameLabel) return state if (labels.people.includes(clean)) { return { ...state, peoplePresent: state.peoplePresent.filter((x) => x !== clean), } } if (labels.bodyParts.includes(clean)) { return { ...state, bodyPartsPresent: state.bodyPartsPresent.filter((x) => x !== clean), } } if (labels.objects.includes(clean)) { return { ...state, objectsPresent: state.objectsPresent.filter((x) => x !== clean), } } if (labels.clothing.includes(clean)) { return { ...state, clothingPresent: state.clothingPresent.filter((x) => x !== clean), } } return state } function removeBoxFromCorrection( state: CorrectionState, index: number, labels: TrainingLabels ): CorrectionState { const boxes = state.boxes ?? [] const removed = boxes[index] if (!removed) return state const removedLabel = String(removed.label || '').trim() const next: CorrectionState = { ...state, boxes: boxes.filter((_, i) => i !== index), } return removeBoxLabelFromCorrection(next, removedLabel, labels) } function changeBoxLabelInCorrection( state: CorrectionState, index: number, nextLabel: string, labels: TrainingLabels ): CorrectionState { const boxes = state.boxes ?? [] const currentBox = boxes[index] const cleanNextLabel = String(nextLabel || '').trim() if (!currentBox || !cleanNextLabel) return state const oldLabel = String(currentBox.label || '').trim() if (oldLabel === cleanNextLabel) return state let next: CorrectionState = { ...state, boxes: boxes.map((box, i) => { if (i !== index) return box const { score: _oldScore, ...boxWithoutScore } = box return { ...boxWithoutScore, label: cleanNextLabel, } }), } next = removeBoxLabelFromCorrection(next, oldLabel, labels) next = applyBoxLabelToCorrection(next, cleanNextLabel, labels) return next } function sortLabelList(values?: string[], opts?: { keepUnknownFirst?: boolean }) { const list = [...(values ?? [])].sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }) ) if (!opts?.keepUnknownFirst) return list return [ ...list.filter((x) => x === 'unknown'), ...list.filter((x) => x !== 'unknown'), ] } function sortTrainingLabels(input: Partial | null | undefined): TrainingLabels { return { people: sortLabelList(input?.people), sexPositions: sortLabelList(input?.sexPositions, { keepUnknownFirst: true }), bodyParts: sortLabelList(input?.bodyParts), objects: sortLabelList(input?.objects), clothing: sortLabelList(input?.clothing), } } function TrainingOverlay(props: { step: string; progress: number }) { return (
Training läuft…
{props.step || 'Bitte warten. Die Oberfläche ist währenddessen gesperrt.'}
{Math.round(props.progress)}%
) } function LoadingImageOverlay(props: { text?: string progress?: number }) { const progress = clampPercent(props.progress ?? 0) return (
Analyse läuft…
{props.text || 'Bild wird erstellt und analysiert. Bitte warten.'}
{Math.round(progress)}%
) } function labelTileClass(active: boolean) { return [ 'group flex min-h-[58px] w-full flex-col items-center justify-center gap-1 rounded-xl px-2 py-1.5 text-center text-[10px] font-semibold leading-tight ring-1 transition sm:min-h-[74px] sm:py-2', 'focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1 dark:focus:ring-offset-gray-900', active ? [ 'bg-indigo-100 text-indigo-900 ring-2 ring-indigo-500 shadow-sm', 'hover:bg-indigo-200', 'dark:bg-indigo-500/30 dark:text-indigo-50 dark:ring-indigo-300/70', 'dark:hover:bg-indigo-500/40', ].join(' ') : [ 'bg-white text-gray-700 ring-gray-200 hover:bg-gray-50 hover:text-gray-900', 'dark:bg-white/5 dark:text-gray-300 dark:ring-white/10 dark:hover:bg-white/10 dark:hover:text-white', ].join(' '), ].join(' ') } function LabelToggleGrid(props: { values: string[] selected: string[] scores?: Record onToggle: (value: string) => void drawLabel?: string onDrawLabelChange?: (value: string) => void disabled?: boolean gridClassName?: string }) { if (props.values.length === 0) { return (
Keine Einträge verfügbar.
) } return (
{props.values.map((value) => { const active = props.selected.includes(value) const item = getSegmentLabelItem(value) const Icon = item.icon const score = props.scores?.[value] const hasScore = typeof score === 'number' && Number.isFinite(score) const isDrawLabel = props.drawLabel === value return ( ) })}
) } function DetectorBoxLabelSelect(props: { values: string[] value: string disabled?: boolean compact?: boolean onChange: (value: string) => void }) { const [open, setOpen] = useState(false) const [menuStyle, setMenuStyle] = useState({}) const buttonRef = useRef(null) const selectedItem = getSegmentLabelItem(props.value) const SelectedIcon = selectedItem.icon const close = useCallback(() => { setOpen(false) }, []) const updateMenuPosition = useCallback(() => { const button = buttonRef.current if (!button) return const rect = button.getBoundingClientRect() const viewportH = window.innerHeight const viewportW = window.innerWidth const spaceBelow = viewportH - rect.bottom const spaceAbove = rect.top const openUp = spaceBelow < 180 && spaceAbove > spaceBelow const maxHeight = openUp ? Math.min(260, Math.max(140, spaceAbove - 12)) : Math.min(260, Math.max(140, spaceBelow - 12)) setMenuStyle({ position: 'fixed', left: Math.max(8, Math.min(rect.left, viewportW - rect.width - 8)), top: openUp ? undefined : rect.bottom + 4, bottom: openUp ? viewportH - rect.top + 4 : undefined, width: rect.width, maxHeight, zIndex: 2147483647, }) }, []) useEffect(() => { if (!open) return updateMenuPosition() const onPointerDown = (e: PointerEvent) => { const target = e.target as HTMLElement | null if (target?.closest('[data-detector-label-select="true"]')) return close() } const onUpdate = () => updateMenuPosition() window.addEventListener('pointerdown', onPointerDown) window.addEventListener('resize', onUpdate) window.addEventListener('scroll', onUpdate, true) return () => { window.removeEventListener('pointerdown', onPointerDown) window.removeEventListener('resize', onUpdate) window.removeEventListener('scroll', onUpdate, true) } }, [open, close, updateMenuPosition]) if (props.values.length === 0) { return ( ) } return (
e.stopPropagation()} onClick={(e) => e.stopPropagation()} > {open && typeof document !== 'undefined' ? createPortal(
{props.values.map((value) => { const active = value === props.value const item = getSegmentLabelItem(value) const Icon = item.icon return ( ) })}
, document.body ) : null}
) } function CollapsibleSingleLabelSection(props: { title: string values: string[] value: string score?: number predictionValue?: string expanded: boolean onExpandedChange: (expanded: boolean) => void onChange: (value: string) => void disabled?: boolean gridClassName?: string }) { const currentValue = String(props.value || 'unknown').trim() || 'unknown' const selectedItem = getSegmentLabelItem(currentValue) const SelectedIcon = selectedItem.icon const shown = props.expanded const hasSelection = currentValue !== '' && currentValue !== 'unknown' return (
{shown ? (
{props.values.length === 0 ? (
Keine Einträge verfügbar.
) : (
{props.values.map((value) => { const active = value === currentValue const item = getSegmentLabelItem(value) const Icon = item.icon const isPrediction = value === props.predictionValue return ( ) })}
)}
) : null}
) } function CollapsibleLabelSection(props: { title: string values: string[] selected: string[] scores?: Record expanded: boolean onExpandedChange: (expanded: boolean) => void onToggle: (value: string) => void drawLabel?: string onDrawLabelChange?: (value: string) => void disabled?: boolean singleDrawMode?: boolean gridClassName?: string }) { const cleanDrawLabel = String(props.drawLabel || '').trim() const hasDrawLabelInSection = cleanDrawLabel !== '' && props.values.includes(cleanDrawLabel) const activeCount = props.singleDrawMode ? Math.max(props.selected.length, hasDrawLabelInSection ? 1 : 0) : props.selected.length const hasActiveItems = activeCount > 0 const shown = props.expanded return (
{shown ? (
) : null}
) } 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 shortTitle: string description: string values: TrainingLabelStat[] total: number confidence?: TrainingConfidence }> = [ { key: 'people', title: 'Personen', shortTitle: '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', shortTitle: 'Positionen', description: 'Positions-Labels aus YOLO-Detector-Labels pro bewertetem Frame.', values: stats?.labels.sexPositions ?? [], total: Math.max(1, totalFeedback), confidence: averageCategoryConfidence(stats?.labels.sexPositions ?? []), }, { key: 'bodyParts', title: 'Körperteile', shortTitle: 'Körper', 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', shortTitle: 'Objekte', 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', shortTitle: '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}
) : (
{/* Mobile: kompakte Top-Zusammenfassung */}
{stats?.modelAvailable ? 'Modell verfügbar' : 'Noch kein Modell'}
{totalFeedback} Feedback · {boxCount} Boxen · {sampleCount} Samples
{confidencePercent(overallConfidence)}
Feedback
{totalFeedback}
Passt
{acceptedCount}
Korr.
{correctedCount}
Boxen
{boxCount}
{/* Desktop/Tablet: ausführliche Karten */}
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.'}
Daten-Confidence
{confidenceLabel(overallConfidence)}
{confidencePercent(overallConfidence)}
Daten-Confidence aus Feedback-Menge, Boxen, Label-Abdeckung und Korrekturanteil. Kein direkter Modell-Qualitätswert.
{/* Mobile: Tabs kompakter, direkt nach Summary */}
{tabItems.map((item) => { const active = item.key === activeTab const count = item.values.length return ( ) })}
)}
) } export default function TrainingTab(props: { onTrainingRunningChange?: (running: boolean) => void }) { const [labels, setLabels] = useState(emptyLabels) const [sample, setSample] = useState(null) const [correction, setCorrection] = useState(() => predictionToCorrection(null)) const [hasManualCorrection, setHasManualCorrection] = useState(false) const [loading, setLoading] = useState(false) const [analysisProgress, setAnalysisProgress] = useState(0) const [analysisStep, setAnalysisStep] = useState('') const [saving, setSaving] = useState(false) const [training, setTraining] = useState(false) const [trainingStatus, setTrainingStatus] = useState(null) const [deletingTrainingData, setDeletingTrainingData] = useState(false) const [trainingProgress, setTrainingProgress] = useState(0) const [trainingStep, setTrainingStep] = useState('') const [error, setError] = useState(null) const [message, setMessage] = useState(null) const [statsModalOpen, setStatsModalOpen] = useState(false) const [cancellingTraining, setCancellingTraining] = useState(false) const [trainingStats, setTrainingStats] = useState(null) const [trainingStatsLoading, setTrainingStatsLoading] = useState(false) const [trainingStatsError, setTrainingStatsError] = useState(null) const wasTrainingRunningRef = useRef(false) const shownTrainingCompletionRef = useRef(null) const [frameImageLoaded, setFrameImageLoaded] = useState(false) const imageBoxRef = useRef(null) const detectorBoxesScrollRef = useRef(null) const detectorBoxItemRefs = useRef>([]) const epochTimingRef = useRef<{ firstEpochAt: number lastEpoch: number lastAt: number }>({ firstEpochAt: 0, lastEpoch: 0, lastAt: 0, }) const etaSmoothingRef = useRef<{ lastAt: number }>({ lastAt: 0, }) const [trainingNowMs, setTrainingNowMs] = useState(() => Date.now()) const [smoothedTrainingEtaMs, setSmoothedTrainingEtaMs] = useState(0) const [estimatedEpochMs, setEstimatedEpochMs] = useState(0) const [drawingBox, setDrawingBox] = useState(null) const [boxInteraction, setBoxInteraction] = useState(null) const [touchMagnifier, setTouchMagnifier] = useState(null) const [boxLabel, setBoxLabel] = useState('') const [activeBoxIndex, setActiveBoxIndex] = useState(null) const [imageReloadKey, setImageReloadKey] = useState(0) const [expandedCorrectionSections, setExpandedCorrectionSections] = useState({ sexPosition: false, people: false, bodyParts: false, objects: false, clothing: false, }) const [mobilePanel, setMobilePanel] = useState<'labels' | 'boxes' | 'training'>('labels') const mobileLabelsScrollRef = useRef(null) const mobileSectionRefs = useRef< Record >({ sexPosition: null, people: null, bodyParts: null, objects: null, clothing: null, }) const scrollMobileSectionToTop = useCallback( (key: keyof typeof expandedCorrectionSections) => { window.requestAnimationFrame(() => { const scrollEl = mobileLabelsScrollRef.current const sectionEl = mobileSectionRefs.current[key] if (!scrollEl || !sectionEl) return const scrollRect = scrollEl.getBoundingClientRect() const sectionRect = sectionEl.getBoundingClientRect() scrollEl.scrollTo({ top: scrollEl.scrollTop + sectionRect.top - scrollRect.top, behavior: 'smooth', }) }) }, [] ) const toggleMobileCorrectionSection = useCallback( (key: keyof typeof expandedCorrectionSections, expanded: boolean) => { setExpandedCorrectionSections((p) => ({ ...p, [key]: expanded, })) if (expanded && mobilePanel === 'labels') { scrollMobileSectionToTop(key) } }, [mobilePanel, scrollMobileSectionToTop] ) const labelsRef = useRef(emptyLabels) useEffect(() => { labelsRef.current = labels }, [labels]) const boxLabels = useMemo(() => { return uniqStrings([ ...labels.people, ...labels.bodyParts, ...labels.objects, ...labels.clothing, ]).sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' })) }, [labels.people, labels.bodyParts, labels.objects, labels.clothing]) const correctionBoxes = correction.boxes ?? [] const visibleBoxes = [ ...correctionBoxes.map((box, index) => ({ box, index, isDraft: false })), ...(drawingBox ? [{ box: drawingBox, index: -1, isDraft: true }] : []), ] const bodyPartScores = useMemo(() => { return Object.fromEntries( (sample?.prediction.bodyPartsPresent ?? []) .filter((x) => x.label) .map((x) => [x.label, x.score]) ) }, [sample?.prediction.bodyPartsPresent]) const objectScores = useMemo(() => { return Object.fromEntries( (sample?.prediction.objectsPresent ?? []) .filter((x) => x.label) .map((x) => [x.label, x.score]) ) }, [sample?.prediction.objectsPresent]) const clothingScores = useMemo(() => { return Object.fromEntries( (sample?.prediction.clothingPresent ?? []) .filter((x) => x.label) .map((x) => [x.label, x.score]) ) }, [sample?.prediction.clothingPresent]) const peopleScores = useMemo(() => { const bestScores = new Map() for (const box of sample?.prediction.boxes ?? []) { const cleanLabel = String(box.label || '').trim() const n = Number(box.score) if (!cleanLabel || !labels.people.includes(cleanLabel)) continue if (!Number.isFinite(n)) continue const current = bestScores.get(cleanLabel) if (current === undefined || n > current) { bestScores.set(cleanLabel, n) } } return Object.fromEntries(bestScores) }, [sample?.prediction.boxes, labels.people]) const selectedPeopleLabels = useMemo(() => { return correction.peoplePresent }, [correction.peoplePresent]) const drawLabelForSection = useCallback((values: string[]) => { const clean = String(boxLabel || '').trim() return values.includes(clean) ? clean : '' }, [boxLabel]) const imageSrc = useMemo(() => { if (!sample?.frameUrl) return '' return `${sample.frameUrl}&t=${encodeURIComponent(sample.sampleId)}&r=${imageReloadKey}` }, [sample, imageReloadKey]) useEffect(() => { if (!imageSrc) { setFrameImageLoaded(false) return } setFrameImageLoaded(false) }, [imageSrc]) const canStartTraining = Boolean(trainingStatus?.canTrain) const feedbackCount = trainingStatus?.feedbackCount ?? 0 const requiredCount = trainingStatus?.requiredCount ?? 5 const trainingRunning = training || Boolean(trainingStatus?.training?.running) const uiLocked = loading || saving || trainingRunning || deletingTrainingData const shownTrainingProgress = trainingRunning ? trainingStatus?.training?.progress ?? trainingProgress : trainingProgress 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' }) if (!res.ok) return const data = await res.json().catch(() => null) if (data) setLabels(sortTrainingLabels(data)) }, []) const applyTrainingStatus = useCallback((data: any) => { if (!data) return const job = data.training || null setTrainingStatus((prev) => ({ feedbackCount: Number(data.feedbackCount ?? prev?.feedbackCount ?? 0), requiredCount: Number(data.requiredCount ?? prev?.requiredCount ?? 5), canTrain: Boolean(data.canTrain ?? prev?.canTrain ?? false), detector: data.detector ? { trainCount: Number(data.detector.trainCount ?? 0), valCount: Number(data.detector.valCount ?? 0), requiredTrain: Number(data.detector.requiredTrain ?? 20), requiredVal: Number(data.detector.requiredVal ?? 3), datasetReady: Boolean(data.detector.datasetReady), dataReady: Boolean(data.detector.dataReady), modelExists: Boolean(data.detector.modelExists), modelPath: data.detector.modelPath, source: data.detector.source, } : prev?.detector, training: job ? { running: Boolean(job.running), progress: Number(job.progress ?? 0), step: String(job.step ?? ''), message: job.message, error: job.error, startedAt: job.startedAt, finishedAt: job.finishedAt, durationMs: Number(job.durationMs ?? 0), stage: job.stage, epoch: Number(job.epoch ?? 0), epochs: Number(job.epochs ?? 0), } : prev?.training, })) setTraining(Boolean(job?.running)) if (job?.message && !job.running) { setTrainingProgress(100) setTrainingStep('Training abgeschlossen.') const finishedAt = String(job.finishedAt || '').trim() const completionKey = finishedAt || `${String(job.startedAt || '')}:${String(job.message || '')}` if (completionKey && shownTrainingCompletionRef.current !== completionKey) { shownTrainingCompletionRef.current = completionKey const duration = trainingDurationMs(job) const durationText = duration > 0 ? ` Dauer: ${formatDuration(duration)}.` : '' setMessage(`${String(job.message)}${durationText}`) } } if (job?.error && !job.running) { const finishedAt = String(job.finishedAt || '').trim() const errorKey = finishedAt ? `${finishedAt}:error` : `${String(job.startedAt || '')}:error` if (errorKey && shownTrainingCompletionRef.current !== errorKey) { shownTrainingCompletionRef.current = errorKey setError(String(job.error)) } } }, []) const loadNext = useCallback(async (opts?: { forceNew?: boolean refreshPrediction?: boolean preserveNotice?: boolean }) => { setLoading(true) setAnalysisProgress(8) setAnalysisStep( opts?.refreshPrediction ? 'Aktuelles Bild wird neu analysiert…' : opts?.forceNew ? 'Neues Trainingsbild wird gesucht…' : 'Trainingsbild wird geladen…' ) if (!opts?.preserveNotice) { setError(null) setMessage(null) } try { const params = new URLSearchParams() if (opts?.forceNew) params.set('force', '1') if (opts?.refreshPrediction) params.set('refresh', '1') const url = `/api/training/next${params.toString() ? `?${params.toString()}` : ''}` setAnalysisProgress(25) setAnalysisStep('Bild wird vorbereitet…') const res = await fetch(url, { cache: 'no-store' }) const data = await res.json().catch(() => null) if (!res.ok) { throw new Error(data?.error || `HTTP ${res.status}`) } setAnalysisProgress(92) setAnalysisStep('Analyse-Ergebnis wird übernommen…') const nextCorrection = predictionToCorrection(data) setDrawingBox(null) setBoxInteraction(null) setTouchMagnifier(null) setBoxLabel('') setActiveBoxIndex(null) setMobilePanel('labels') window.requestAnimationFrame(() => { mobileLabelsScrollRef.current?.scrollTo({ top: 0, behavior: 'smooth', }) }) setSample(data) setCorrection(nextCorrection) setHasManualCorrection(false) setExpandedCorrectionSections({ sexPosition: Boolean(nextCorrection.sexPosition) && nextCorrection.sexPosition !== 'unknown', people: nextCorrection.peoplePresent.length > 0, bodyParts: nextCorrection.bodyPartsPresent.length > 0, objects: nextCorrection.objectsPresent.length > 0, clothing: nextCorrection.clothingPresent.length > 0, }) } catch (e) { setError(e instanceof Error ? e.message : String(e)) } finally { setAnalysisProgress((value) => Math.max(value, 100)) setAnalysisStep((value) => value || 'Analyse abgeschlossen.') window.setTimeout(() => { setLoading(false) setAnalysisProgress(0) setAnalysisStep('') }, 500) } }, []) const reloadCurrentImage = useCallback(async () => { setDrawingBox(null) setBoxInteraction(null) setTouchMagnifier(null) setActiveBoxIndex(null) await loadNext({ refreshPrediction: true }) setImageReloadKey((value) => value + 1) }, [loadNext]) const loadTrainingStatus = useCallback(async () => { const res = await fetch('/api/training/status', { cache: 'no-store' }) const data = await res.json().catch(() => null) if (!res.ok || !data) return applyTrainingStatus(data) }, [applyTrainingStatus]) 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(() => { const es = new EventSource('/api/events/stream') const onTraining = (ev: MessageEvent) => { try { const data = JSON.parse(String(ev.data ?? 'null')) if (data?.type !== 'training_status') return applyTrainingStatus({ training: data.training, }) } catch { // ignore } } es.addEventListener('training', onTraining as EventListener) es.onerror = () => { // Optional: Polling-Fallback bleibt separat bestehen. } return () => { es.removeEventListener('training', onTraining as EventListener) es.close() } }, [applyTrainingStatus]) useEffect(() => { const draggingBox = Boolean(drawingBox || boxInteraction) if (!draggingBox) return const previousUserSelect = document.body.style.userSelect const previousWebkitUserSelect = document.body.style.webkitUserSelect document.body.style.userSelect = 'none' document.body.style.webkitUserSelect = 'none' const clearSelection = () => { window.getSelection()?.removeAllRanges() } const preventSelection = (event: Event) => { event.preventDefault() } clearSelection() document.addEventListener('selectionchange', clearSelection) document.addEventListener('selectstart', preventSelection) return () => { document.body.style.userSelect = previousUserSelect document.body.style.webkitUserSelect = previousWebkitUserSelect document.removeEventListener('selectionchange', clearSelection) document.removeEventListener('selectstart', preventSelection) clearSelection() } }, [drawingBox, boxInteraction]) useEffect(() => { if (!statsModalOpen) return void loadTrainingStats() }, [statsModalOpen, loadTrainingStats]) const onTrainingRunningChange = props.onTrainingRunningChange useEffect(() => { onTrainingRunningChange?.(trainingRunning) }, [trainingRunning, onTrainingRunningChange]) useEffect(() => { if (!trainingRunning) { etaSmoothingRef.current = { lastAt: 0, } setSmoothedTrainingEtaMs(0) return } setTrainingNowMs(Date.now()) const timer = window.setInterval(() => { setTrainingNowMs(Date.now()) }, 1000) return () => window.clearInterval(timer) }, [trainingRunning]) useEffect(() => { if (!boxLabel) return const currentLabels = labelsRef.current const hasLoadedBoxLabels = currentLabels.people.length > 0 || currentLabels.bodyParts.length > 0 || currentLabels.objects.length > 0 || currentLabels.clothing.length > 0 // Wichtig: Während Labels noch laden oder kurz leer sind, Auswahl nicht löschen. if (!hasLoadedBoxLabels) return const stillExists = [ ...currentLabels.people, ...currentLabels.bodyParts, ...currentLabels.objects, ...currentLabels.clothing, ].includes(boxLabel) if (!stillExists) { setBoxLabel('') } }, [boxLabel, labels]) useEffect(() => { const wasRunning = wasTrainingRunningRef.current if (wasRunning && !trainingRunning && trainingStatus?.training?.finishedAt) { void loadNext({ refreshPrediction: true, preserveNotice: true }) } wasTrainingRunningRef.current = trainingRunning }, [trainingRunning, trainingStatus?.training?.finishedAt, loadNext]) useEffect(() => { if (activeBoxIndex === null) return const scrollEl = detectorBoxesScrollRef.current const itemEl = detectorBoxItemRefs.current[activeBoxIndex] if (!scrollEl || !itemEl) return itemEl.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'smooth', }) }, [activeBoxIndex]) useEffect(() => { let cancelled = false async function init() { await loadLabels() await loadTrainingStatus() if (cancelled) return // Wichtig: Auch während laufendem Training wieder das aktuelle offene Sample laden, // damit nicht "Kein Bild geladen" angezeigt wird. await loadNext() } void init() return () => { cancelled = true } }, [loadLabels, loadNext, loadTrainingStatus]) useEffect(() => { if (!trainingRunning) return const onVisibilityChange = () => { if (!document.hidden) { void loadTrainingStatus() } } document.addEventListener('visibilitychange', onVisibilityChange) return () => { document.removeEventListener('visibilitychange', onVisibilityChange) } }, [loadTrainingStatus, trainingRunning]) useEffect(() => { if (!trainingRunning) { const timer = window.setTimeout(() => { setTrainingProgress(0) setTrainingStep('') }, 800) return () => window.clearTimeout(timer) } const serverProgress = Number(trainingStatus?.training?.progress ?? 0) const serverStep = String(trainingStatus?.training?.step ?? '') setTrainingProgress(Number.isFinite(serverProgress) ? clampPercent(serverProgress) : 0) setTrainingStep(serverStep || 'Training läuft…') }, [ trainingRunning, trainingStatus?.training?.progress, trainingStatus?.training?.step, ]) useEffect(() => { const job = trainingStatus?.training const epoch = Number(job?.epoch ?? 0) const epochs = Number(job?.epochs ?? 0) if (!trainingRunning || epoch <= 0 || epochs <= 0) { epochTimingRef.current = { firstEpochAt: 0, lastEpoch: 0, lastAt: 0, } setEstimatedEpochMs(0) return } const now = Date.now() const previous = epochTimingRef.current const firstEpochAt = previous.firstEpochAt > 0 ? previous.firstEpochAt : job?.startedAt && Number.isFinite(Date.parse(job.startedAt)) ? Date.parse(job.startedAt) : now const safeEpoch = Math.max(1, Math.min(epochs, Math.floor(epoch))) const elapsedSinceStartMs = Math.max(0, now - firstEpochAt) // Direkt ab Epoche 1 eine erste Schätzung: // bisherige Laufzeit / aktuelle Epoche. const averageFromElapsed = elapsedSinceStartMs > 0 && safeEpoch > 0 ? elapsedSinceStartMs / safeEpoch : 0 if (Number.isFinite(averageFromElapsed) && averageFromElapsed > 0) { setEstimatedEpochMs((old) => { if (!Number.isFinite(old) || old <= 0) { return averageFromElapsed } const clampedMeasured = Math.max( old * 0.60, Math.min(old * 1.60, averageFromElapsed) ) return old * 0.75 + clampedMeasured * 0.25 }) } epochTimingRef.current = { firstEpochAt, lastEpoch: safeEpoch, lastAt: now, } }, [ trainingRunning, trainingStatus?.training?.epoch, trainingStatus?.training?.epochs, trainingStatus?.training?.startedAt, trainingNowMs, ]) const saveFeedback = useCallback( async (accepted: boolean) => { if (!sample) return setSaving(true) setError(null) setMessage(null) try { const normalizedBoxes = (correction.boxes ?? []) .map(normalizeBox) .filter((box) => box.label && box.w > 0 && box.h > 0) const correctionPayload: CorrectionState = { ...correction, peoplePresent: peopleLabelsFromBoxes(normalizedBoxes, labelsRef.current), boxes: normalizedBoxes, } const payload = { sampleId: sample.sampleId, accepted, correction: accepted && correctionPayload.boxes.length === 0 ? undefined : correctionPayload, } const res = await fetch('/api/training/feedback', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }) const data = await res.json().catch(() => null) if (!res.ok) { throw new Error(backendText(data, `HTTP ${res.status}`)) } setMessage( accepted ? 'Feedback gespeichert: Prediction wurde als korrekt übernommen.' : 'Korrektur gespeichert.' ) await loadTrainingStatus() await loadNext({ preserveNotice: true }) } catch (e) { setError(e instanceof Error ? e.message : String(e)) } finally { setSaving(false) } }, [sample, correction, loadNext, loadTrainingStatus] ) const startTraining = useCallback(async () => { shownTrainingCompletionRef.current = null setTraining(true) setTrainingProgress(5) setTrainingStep('Training wird gestartet…') setError(null) setMessage(null) try { const res = await fetch('/api/training/train', { method: 'POST', }) const data = await res.json().catch(() => null) if (!res.ok) { throw new Error(backendText(data, `HTTP ${res.status}`)) } await loadTrainingStatus() // WICHTIG: // Hier NICHT direkt loadNext() aufrufen. // Das Training läuft im Backend asynchron weiter. } catch (e) { setTraining(false) setTrainingProgress(0) setTrainingStep('') setError(e instanceof Error ? e.message : String(e)) } }, [loadTrainingStatus]) const cancelTraining = useCallback(async () => { const confirmed = window.confirm( 'Training wirklich abbrechen? Temporäre Trainingsausgaben dieses Laufs werden gelöscht. Deine Feedbacks und Labels bleiben erhalten.' ) if (!confirmed) return setCancellingTraining(true) setError(null) setMessage(null) setTrainingStep('Training wird abgebrochen…') try { const res = await fetch('/api/training/cancel', { method: 'POST', }) const data = await res.json().catch(() => null) if (!res.ok) { throw new Error(backendText(data, `HTTP ${res.status}`)) } await loadTrainingStatus() } catch (e) { setError(e instanceof Error ? e.message : String(e)) } finally { setCancellingTraining(false) } }, [loadTrainingStatus]) const deleteAllTrainingData = useCallback(async () => { const confirmed = window.confirm( 'Wirklich alle Trainingsdaten löschen? Das entfernt Feedback, Frames, Samples und Detector-Daten. Diese Aktion kann nicht rückgängig gemacht werden.' ) if (!confirmed) return setDeletingTrainingData(true) setError(null) setMessage(null) try { const res = await fetch('/api/training/delete-all', { method: 'DELETE', }) const data = await res.json().catch(() => null) if (!res.ok) { throw new Error(data?.error || `HTTP ${res.status}`) } setSample(null) setCorrection(predictionToCorrection(null)) setTrainingStatus({ feedbackCount: 0, requiredCount, canTrain: false, }) setMessage(backendText(data, 'Alle Trainingsdaten wurden gelöscht.')) await loadTrainingStatus() await loadNext({ forceNew: true, preserveNotice: true }) } catch (e) { setError(e instanceof Error ? e.message : String(e)) } finally { setDeletingTrainingData(false) } }, [loadNext, loadTrainingStatus, requiredCount]) const getPointerPosInImage = useCallback(( clientX: number, clientY: number, opts?: { clamp?: boolean } ) => { const el = imageBoxRef.current if (!el) return null const rect = el.getBoundingClientRect() if (rect.width <= 0 || rect.height <= 0) return null const x = (clientX - rect.left) / rect.width const y = (clientY - rect.top) / rect.height if (opts?.clamp === false) { return { x, y } } return { x: clamp01(x), y: clamp01(y), } }, []) const startDrawBox = useCallback((e: React.PointerEvent) => { if (!boxLabel) return if (uiLocked) return if (boxInteraction) return const target = e.target as HTMLElement | null if (target?.closest('[data-box-control="true"]')) return const pos = getPointerPosInImage(e.clientX, e.clientY) if (!pos) return e.preventDefault() e.stopPropagation() window.getSelection()?.removeAllRanges() try { e.currentTarget.setPointerCapture(e.pointerId) } catch { // Pointer wurde vom Browser bereits abgebrochen. } setTouchMagnifier({ visible: true, clientX: e.clientX, clientY: e.clientY, imageX: pos.x, imageY: pos.y, }) setDrawingBox({ label: boxLabel, startX: pos.x, startY: pos.y, x: pos.x, y: pos.y, w: 0, h: 0, }) }, [boxLabel, boxInteraction, getPointerPosInImage, uiLocked]) const moveDrawBox = useCallback((e: React.PointerEvent) => { if (drawingBox || boxInteraction) { e.preventDefault() e.stopPropagation() } const clampedPos = getPointerPosInImage(e.clientX, e.clientY) if (!clampedPos) return const pos = boxInteraction?.type === 'move' ? getPointerPosInImage(e.clientX, e.clientY, { clamp: false }) ?? clampedPos : clampedPos if (drawingBox || boxInteraction) { setTouchMagnifier({ visible: true, clientX: e.clientX, clientY: e.clientY, imageX: clampedPos.x, imageY: clampedPos.y, }) } if (boxInteraction) { const dx = pos.x - boxInteraction.startX const dy = pos.y - boxInteraction.startY const original = boxInteraction.original let nextBox: TrainingBox = original if (boxInteraction.type === 'move') { nextBox = normalizeMovedBox({ ...original, x: original.x + dx, y: original.y + dy, }) } if (boxInteraction.type === 'resize') { let x1 = original.x let y1 = original.y let x2 = original.x + original.w let y2 = original.y + original.h const pointerX = snap01(clampedPos.x) const pointerY = snap01(clampedPos.y) // Wichtig: // Beim Resize folgt die gezogene Ecke direkt dem Pointer. // Dadurch bleibt kein Grab-Offset übrig, wenn der Handle nicht exakt // auf der mathematischen Box-Ecke getroffen wurde. if (boxInteraction.handle.includes('n')) y1 = pointerY if (boxInteraction.handle.includes('s')) y2 = pointerY if (boxInteraction.handle.includes('w')) x1 = pointerX if (boxInteraction.handle.includes('e')) x2 = pointerX const left = Math.min(x1, x2) const top = Math.min(y1, y2) const right = Math.max(x1, x2) const bottom = Math.max(y1, y2) nextBox = normalizeBox({ ...original, x: left, y: top, w: right - left, h: bottom - top, }) } const geometryChanged = boxGeometryChanged(original, nextBox) const correctedNextBox = geometryChanged ? markBoxCorrected(nextBox) : nextBox if (geometryChanged) { setHasManualCorrection(true) } setCorrection((prev) => ({ ...prev, boxes: (prev.boxes ?? []).map((box, index) => index === boxInteraction.index ? correctedNextBox : box ), })) return } if (!drawingBox) return const x1 = drawingBox.startX const y1 = drawingBox.startY const x2 = pos.x const y2 = pos.y setDrawingBox({ ...drawingBox, x: Math.min(x1, x2), y: Math.min(y1, y2), w: Math.abs(x2 - x1), h: Math.abs(y2 - y1), }) }, [boxInteraction, drawingBox, getPointerPosInImage]) const finishDrawBox = useCallback((e?: React.PointerEvent) => { setTouchMagnifier(null) if (drawingBox || boxInteraction) { e?.preventDefault() e?.stopPropagation() } if (boxInteraction) { setBoxInteraction(null) return } if (!drawingBox) return const { startX, startY, ...drawingTrainingBox } = drawingBox const box = normalizeBox(drawingTrainingBox) setDrawingBox(null) if (box.w < 0.01 || box.h < 0.01) return setHasManualCorrection(true) setCorrection((prev) => { const previousBoxes = prev.boxes ?? [] const newBoxIndex = previousBoxes.length const next: CorrectionState = { ...prev, boxes: [...previousBoxes, box], } setActiveBoxIndex(newBoxIndex) return applyBoxLabelToCorrection(next, box.label, labelsRef.current) }) }, [boxInteraction, drawingBox]) const removeBox = useCallback((index: number) => { setHasManualCorrection(true) let removedLabel = '' let shouldClearBoxLabel = false setCorrection((prev) => { const removed = prev.boxes?.[index] removedLabel = String(removed?.label || '').trim() const next = removeBoxFromCorrection(prev, index, labelsRef.current) if (removedLabel) { shouldClearBoxLabel = !next.boxes.some( (box) => String(box.label || '').trim() === removedLabel ) } return next }) if (removedLabel && shouldClearBoxLabel) { setBoxLabel((current) => current === removedLabel ? '' : current) } setActiveBoxIndex((current) => { if (current === null) return null if (current === index) return null if (current > index) return current - 1 return current }) }, []) const changeBoxLabel = useCallback((index: number, nextLabel: string) => { const currentLabel = String(correction.boxes?.[index]?.label || '').trim() const cleanNextLabel = String(nextLabel || '').trim() if (currentLabel !== cleanNextLabel) { setHasManualCorrection(true) } if (cleanNextLabel) { setBoxLabel(cleanNextLabel) } setCorrection((prev) => changeBoxLabelInCorrection(prev, index, nextLabel, labelsRef.current) ) }, [correction.boxes]) const clearBoxes = useCallback(() => { setHasManualCorrection(true) setBoxLabel('') setActiveBoxIndex(null) setCorrection((prev) => ({ ...prev, peoplePresent: [], bodyPartsPresent: [], objectsPresent: [], clothingPresent: [], boxes: [], })) setExpandedCorrectionSections({ sexPosition: false, people: false, bodyParts: false, objects: false, clothing: false, }) }, []) const frameBusy = loading || (!!imageSrc && !frameImageLoaded) const showImageBoxes = !frameBusy && !trainingRunning const shownTrainingDurationMs = useMemo(() => { const job = trainingStatus?.training if (!job) return 0 if (job.running && job.startedAt) { const started = Date.parse(job.startedAt) if (Number.isFinite(started)) { return Math.max(0, trainingNowMs - started) } } return trainingDurationMs(job) }, [trainingStatus?.training, trainingNowMs]) const rawTrainingEtaMs = useMemo(() => { if (!trainingRunning) return 0 const job = trainingStatus?.training const epoch = Number(job?.epoch ?? 0) const epochs = Number(job?.epochs ?? 0) if ( !Number.isFinite(epoch) || !Number.isFinite(epochs) || !Number.isFinite(estimatedEpochMs) || epoch <= 0 || epochs <= 0 || estimatedEpochMs <= 0 ) { return 0 } const completedEpochs = Math.max(1, Math.min(epochs, Math.floor(epoch))) const remainingEpochs = Math.max(0, epochs - completedEpochs) return Math.max(0, remainingEpochs * estimatedEpochMs) }, [ trainingRunning, trainingStatus?.training?.epoch, trainingStatus?.training?.epochs, estimatedEpochMs, ]) useEffect(() => { if (!trainingRunning || rawTrainingEtaMs <= 0) { etaSmoothingRef.current = { lastAt: 0, } setSmoothedTrainingEtaMs(0) return } setSmoothedTrainingEtaMs((previous) => { const lastAt = etaSmoothingRef.current.lastAt || trainingNowMs const elapsed = Math.max(0, trainingNowMs - lastAt) // Anzeige zählt zwischen Backend-Updates weiter runter. const countedDown = previous > 0 ? Math.max(0, previous - elapsed) : rawTrainingEtaMs const diff = rawTrainingEtaMs - countedDown // Nach oben sehr vorsichtig glätten, nach unten etwas schneller. const factor = diff > 0 ? 0.08 : 0.18 let next = countedDown + diff * factor // Harte Sprünge zusätzlich begrenzen. if (diff > 0) { next = Math.min(next, countedDown + 10_000) } else { next = Math.max(next, countedDown - 20_000) } next = Math.max(0, next) etaSmoothingRef.current = { lastAt: trainingNowMs, } return next }) }, [trainingRunning, rawTrainingEtaMs, trainingNowMs]) const shownTrainingEtaMs = smoothedTrainingEtaMs const shownTrainingEpochText = useMemo(() => { const epoch = Number(trainingStatus?.training?.epoch ?? 0) const epochs = Number(trainingStatus?.training?.epochs ?? 0) if (!Number.isFinite(epoch) || !Number.isFinite(epochs)) return '' if (epoch <= 0 || epochs <= 0) return '' return `Epoche ${epoch}/${epochs}` }, [ trainingStatus?.training?.epoch, trainingStatus?.training?.epochs, ]) const activeNotice = useMemo(() => { if (error) { return { kind: 'error', title: 'Aktion fehlgeschlagen', message: error, } } if (message) { const lowerMessage = message.toLowerCase() const looksPartial = lowerMessage.includes('übersprungen') || lowerMessage.includes('fehlgeschlagen') || lowerMessage.includes('abgebrochen') return { kind: looksPartial ? 'warning' : 'success', title: looksPartial ? 'Training teilweise abgeschlossen' : 'Erfolg', message, } } if (trainingRunning) { const items: TrainingNoticeItem[] = [] items.push({ icon: ClockIcon, label: 'Bisherige Laufzeit', value: formatDuration(shownTrainingDurationMs), }) if (shownTrainingEtaMs > 0) { items.push({ icon: ForwardIcon, label: 'Geschätzte Restzeit', value: `ca. ${formatDuration(shownTrainingEtaMs)}`, }) } if (shownTrainingEpochText) { items.push({ icon: ArrowPathIcon, label: 'Epoche', value: shownTrainingEpochText.replace(/^Epoche\s+/i, ''), }) } if (estimatedEpochMs > 0) { items.push({ icon: BoltIcon, label: 'Ø Zeit pro Epoche', value: formatDuration(estimatedEpochMs), }) } return { kind: 'info', title: 'Training läuft', progress: shownTrainingProgress, message: shownTrainingStep || 'Training läuft…', items, } } return null }, [ error, message, trainingRunning, shownTrainingStep, shownTrainingDurationMs, shownTrainingEtaMs, shownTrainingEpochText, estimatedEpochMs, shownTrainingProgress, ]) useEffect(() => { if (!message) return if (error) return if (trainingRunning) return const looksPartial = message.toLowerCase().includes('übersprungen') || message.toLowerCase().includes('fehlgeschlagen') if (looksPartial) return const timer = window.setTimeout(() => { setMessage(null) }, 2500) return () => window.clearTimeout(timer) }, [message, error, trainingRunning]) const trainingActionsPanel = (opts?: { compact?: boolean }) => { const compact = Boolean(opts?.compact) const progress = clampPercent( trainingRunning ? shownTrainingProgress : Math.min(100, (feedbackCount / Math.max(1, requiredCount)) * 100) ) const feedbackReady = feedbackCount >= requiredCount const detector = trainingStatus?.detector const detectorReady = Boolean(detector?.dataReady) const missingTrain = Math.max( 0, Number(detector?.requiredTrain ?? 20) - Number(detector?.trainCount ?? 0) ) const missingVal = Math.max( 0, Number(detector?.requiredVal ?? 3) - Number(detector?.valCount ?? 0) ) const statusText = trainingRunning ? shownTrainingStep || 'Training läuft…' : !feedbackReady ? `${Math.max(0, requiredCount - feedbackCount)} Feedback fehlen noch` : !detectorReady ? `YOLO-Boxen fehlen: ${missingTrain} Train, ${missingVal} Val` : canStartTraining ? 'Bereit zum Trainieren' : 'Noch nicht trainingsbereit' return (
{trainingRunning ? (
Training-Aktionen
{statusText}
{trainingRunning || feedbackCount < requiredCount ? (
{trainingRunning ? 'Trainingsfortschritt' : 'Feedback-Fortschritt'} {Math.round(progress)}%
) : null}
{trainingRunning ? (
Laufzeit
{formatDuration(shownTrainingDurationMs)}
Restzeit
{shownTrainingEtaMs > 0 ? `ca. ${formatDuration(shownTrainingEtaMs)}` : '—'}
) : null}
) } const detectorBoxesPanel = (opts?: { compact?: boolean stretch?: boolean maxHeightClassName?: string }) => { const compact = Boolean(opts?.compact) const stretch = Boolean(opts?.stretch) const hasBoxes = correctionBoxes.length > 0 return (
Detector-Boxen
{correctionBoxes.length}
{!compact ? (
Prüfen, Label ändern oder einzeln löschen.
) : null}
{hasBoxes ? ( ) : null}
{!hasBoxes ? (
Keine Boxen vorhanden
Wähle rechts ein Label aus und zeichne im Bild eine neue Box.
) : ( correctionBoxes.map((box, index) => { const item = getSegmentLabelItem(box.label) const Icon = item.icon const isActive = activeBoxIndex === index const isCorrected = typeof box.score !== 'number' const scoreText = typeof box.score === 'number' ? percent(box.score) : 'korrigiert' const tone = detectorBoxAppearance(box.label) return (
{ detectorBoxItemRefs.current[index] = el }} key={`box-${index}`} onClick={() => setActiveBoxIndex(index)} className={[ 'group relative cursor-pointer overflow-hidden rounded-2xl border transition-all duration-200', 'bg-white shadow-sm', 'dark:border-white/10 dark:bg-gray-950/55', isActive ? [ 'border-gray-200 bg-white', 'dark:border-white/10', tone.activeSurface, ].join(' ') : [ 'border-gray-200 hover:bg-gray-50/80 hover:shadow-md', 'dark:border-white/10 dark:hover:bg-white/[0.04]', tone.idleHover, ].join(' '), ].join(' ')} > ) }) )}
{hasBoxes ? (
) : null}
) } const imageTouchClass = boxLabel || drawingBox || boxInteraction ? 'touch-none' : 'touch-pan-y' return (
Training
{/* Sidebar links */} {/* Mitte */}
{imageSrc ? (
e.preventDefault()} onPointerDown={startDrawBox} onPointerMove={moveDrawBox} onPointerUp={finishDrawBox} onPointerCancel={finishDrawBox} > Training Frame setFrameImageLoaded(true)} onError={() => setFrameImageLoaded(true)} onContextMenu={(e) => e.preventDefault()} onDragStart={(e) => e.preventDefault()} className={[ 'block rounded-lg max-h-[52dvh] max-w-full object-contain sm:max-h-[60dvh] lg:max-h-[72dvh]', 'select-none', imageTouchClass, '[-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) const item = getSegmentLabelItem(box.label) const Icon = item.icon const isSmallBox = width < 18 || height < 12 const isActiveBox = !isDraft && activeBoxIndex === index return (
{!isDraft ? ( <> {(['nw', 'ne', 'sw', 'se'] as const).map((handle) => ( ))} ) : null}
) })}
) : null} {touchMagnifier?.visible && imageSrc && showImageBoxes ? (() => { const el = imageBoxRef.current const rect = el?.getBoundingClientRect() if (!rect || rect.width <= 0 || rect.height <= 0) return null const size = 156 const padding = 20 const activeBox = drawingBox || (boxInteraction ? correction.boxes?.[boxInteraction.index] ?? null : null) const hasUsableBox = activeBox && Number.isFinite(activeBox.w) && Number.isFinite(activeBox.h) && activeBox.w > 0.003 && activeBox.h > 0.003 const boxCenterX = hasUsableBox ? clamp01(activeBox.x + activeBox.w / 2) : touchMagnifier.imageX const boxCenterY = hasUsableBox ? clamp01(activeBox.y + activeBox.h / 2) : touchMagnifier.imageY const boxPixelW = hasUsableBox ? activeBox.w * rect.width : 0 const boxPixelH = hasUsableBox ? activeBox.h * rect.height : 0 const fitZoom = hasUsableBox ? Math.min( (size - padding * 2) / Math.max(1, boxPixelW), (size - padding * 2) / Math.max(1, boxPixelH) ) : 2 const zoom = hasUsableBox ? Math.max(0.55, Math.min(2.25, fitZoom)) : 2 const viewportW = typeof window !== 'undefined' ? window.innerWidth : 390 const viewportH = typeof window !== 'undefined' ? window.innerHeight : 800 let left = touchMagnifier.clientX - size / 2 let top = touchMagnifier.clientY - size - 28 if (top < 8) { top = touchMagnifier.clientY + 28 } left = Math.max(8, Math.min(viewportW - size - 8, left)) top = Math.max(8, Math.min(viewportH - size - 8, top)) const imageWidth = rect.width * zoom const imageHeight = rect.height * zoom const imageLeft = size / 2 - boxCenterX * imageWidth const imageTop = size / 2 - boxCenterY * imageHeight const pointerX = imageLeft + touchMagnifier.imageX * imageWidth const pointerY = imageTop + touchMagnifier.imageY * imageHeight 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 return (
{hasUsableBox ? (
) : null}
) })() : null}
{trainingRunning ? ( ) : frameBusy ? ( ) : null}
) : trainingRunning ? ( ) : loading ? ( ) : (
Kein Bild geladen
)}
{activeNotice ? (
{ setError(null) setMessage(null) } } />
) : (
Prüfe das Bild. Wenn die Erkennung stimmt: „Passt so“. Wenn nicht: korrigieren und speichern.
)}
{[ { key: 'labels', label: 'Labels' }, { key: 'boxes', label: 'Boxen', count: correctionBoxes.length }, { key: 'training', label: 'Training' }, ].map((item) => { const active = mobilePanel === item.key return ( ) })}
{mobilePanel === 'labels' ? (
Korrektur
Label wählen, dann Box im Bild zeichnen.
{confidencePercent(analysisConfidence)}
{ mobileSectionRefs.current.sexPosition = el }} > toggleMobileCorrectionSection('sexPosition', expanded) } onChange={(value) => setCorrection((p) => { if (p.sexPosition !== value) { setHasManualCorrection(true) } return { ...p, sexPosition: value, } }) } disabled={uiLocked} gridClassName="grid grid-cols-3 gap-2" />
{ mobileSectionRefs.current.people = el }} > toggleMobileCorrectionSection('people', expanded) } onToggle={() => {}} drawLabel={drawLabelForSection(labels.people)} onDrawLabelChange={setBoxLabel} disabled={uiLocked} singleDrawMode gridClassName="grid grid-cols-2 gap-2" />
{ mobileSectionRefs.current.bodyParts = el }} > toggleMobileCorrectionSection('bodyParts', expanded) } onToggle={(value) => setCorrection((p) => { setHasManualCorrection(true) return { ...p, bodyPartsPresent: toggleArrayValue(p.bodyPartsPresent, value), } }) } drawLabel={drawLabelForSection(labels.bodyParts)} onDrawLabelChange={setBoxLabel} disabled={uiLocked} />
{ mobileSectionRefs.current.objects = el }} > toggleMobileCorrectionSection('objects', expanded) } onToggle={(value) => setCorrection((p) => { setHasManualCorrection(true) return { ...p, objectsPresent: toggleArrayValue(p.objectsPresent, value), } }) } drawLabel={drawLabelForSection(labels.objects)} onDrawLabelChange={setBoxLabel} disabled={uiLocked} />
{ mobileSectionRefs.current.clothing = el }} > toggleMobileCorrectionSection('clothing', expanded) } onToggle={(value) => setCorrection((p) => { setHasManualCorrection(true) return { ...p, clothingPresent: toggleArrayValue(p.clothingPresent, value), } }) } drawLabel={drawLabelForSection(labels.clothing)} onDrawLabelChange={setBoxLabel} disabled={uiLocked} />
) : null} {mobilePanel === 'boxes' ? (
{detectorBoxesPanel({ compact: true })}
) : null} {mobilePanel === 'training' ? (
{trainingActionsPanel({ compact: true })}
) : null}
{/* Rechte Details/Korrektur */}
setStatsModalOpen(false)} stats={trainingStats} loading={trainingStatsLoading} error={trainingStatsError} feedbackCount={feedbackCount} requiredCount={requiredCount} />
) }