// frontend\src\components\ui\TrainingTab.tsx 'use client' import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react' import Button from './Button' import LoadingSpinner from './LoadingSpinner' import { formatDuration } from './formatters' import { ArrowPathIcon, ArrowsPointingInIcon, ArrowsPointingOutIcon, BoltIcon, CheckIcon, ForwardIcon, InboxArrowDownIcon, TrashIcon, XCircleIcon, } from '@heroicons/react/20/solid' import { getSegmentLabelItem } from './Icons' import Modal from './Modal' import { useNotify } from './notify' import { createPortal } from 'react-dom' import TrainingFeedbackHistoryModal from './TrainingFeedbackHistoryModal' type DrawingTrainingBox = TrainingBox & { startX: number startY: number } type ScoredLabel = { label: string score: number } type TrainingDetectorStatus = { trainCount: number valCount: number positiveTrainCount: number positiveValCount: 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 previewUrl?: string map50?: number map5095?: 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 uncertaintyScore?: number 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 PendingTrainingVideoImport = { jobId?: string output: string sourceFile?: string count?: number } type TrainingConfidence = { score: number level: 'none' | 'low' | 'mid' | 'high' label: string } type TrainingLabelStat = { label: string count: number confidence?: TrainingConfidence } type TrainingModelInfo = { trainedAt?: string trainedAtMs?: number epochs?: number trainSamples?: number valSamples?: number imgsz?: number device?: string map50?: number map5095?: number } type TrainingHistoryEntry = { trainedAt?: string trainedAtMs?: number durationMs?: number epochs?: number trainSamples?: number valSamples?: number imgsz?: number device?: string map50?: number map5095?: number } type TrainingStats = { feedbackCount: number acceptedCount: number correctedCount: number negativeCount: number sampleCount: number boxCount: number modelAvailable: boolean modelInfo?: TrainingModelInfo confidence?: TrainingConfidence labels: { people: TrainingLabelStat[] sexPositions: TrainingLabelStat[] bodyParts: TrainingLabelStat[] objects: TrainingLabelStat[] clothing: TrainingLabelStat[] } } type TrainingAnnotation = { sampleId: string frameUrl: string sourceFile: string sourcePath?: string sourceSizeBytes?: number second: number createdAt: string answeredAt: string prediction: TrainingPrediction accepted: boolean negative?: boolean correction?: CorrectionState notes?: string } type TrainingFeedbackListResponse = { ok: boolean items: TrainingAnnotation[] total: number limit: number offset: number hasMore: boolean } type TrainingSampleMode = 'random' | 'uncertain' type FeedbackFilter = 'all' | 'accepted' | 'corrected' | 'negative' 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 formatModelTrainedAt(info?: TrainingModelInfo): string { if (!info) return '' const ms = Number(info.trainedAtMs) const date = Number.isFinite(ms) && ms > 0 ? new Date(ms) : info.trainedAt ? new Date(info.trainedAt) : null if (!date || Number.isNaN(date.getTime())) return '' return date.toLocaleString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit', }) } function formatMapPercent(value?: number | null): string { const n = Number(value) if (!Number.isFinite(n) || n <= 0) return '' return `${(n * 100).toFixed(1)}%` } function formatHistoryDate(entry: TrainingHistoryEntry): string { const ms = Number(entry.trainedAtMs) const date = Number.isFinite(ms) && ms > 0 ? new Date(ms) : entry.trainedAt ? new Date(entry.trainedAt) : null if (!date || Number.isNaN(date.getTime())) return '—' return date.toLocaleString('de-DE', { day: '2-digit', month: '2-digit', year: '2-digit', hour: '2-digit', minute: '2-digit', }) } 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 correctionHasRelevantContent(value: CorrectionState) { return ( (value.sexPosition && value.sexPosition !== 'unknown') || (value.peoplePresent?.length ?? 0) > 0 || (value.bodyPartsPresent?.length ?? 0) > 0 || (value.objectsPresent?.length ?? 0) > 0 || (value.clothingPresent?.length ?? 0) > 0 || (value.boxes?.length ?? 0) > 0 ) } 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 TrainingStageOverlay(props: { mode: 'training' | 'analysis' text?: string progress?: number backgroundUrl?: string visible?: boolean instantBackground?: boolean }) { const progress = clampPercent(props.progress ?? 0) const isTraining = props.mode === 'training' const hasBackground = Boolean(props.backgroundUrl) const visible = props.visible ?? true const [backgroundVisible, setBackgroundVisible] = useState(false) useEffect(() => { if (!props.backgroundUrl) { setBackgroundVisible(false) return } setBackgroundVisible(false) const frame = window.requestAnimationFrame(() => { setBackgroundVisible(true) }) return () => window.cancelAnimationFrame(frame) }, [props.backgroundUrl]) const title = isTraining ? 'Training läuft…' : 'Analyse läuft…' const fallbackText = isTraining ? 'Bitte warten. Die Oberfläche ist währenddessen gesperrt.' : 'Bild wird erstellt und analysiert. Bitte warten.' return (
{hasBackground ? ( ) : null}
{title}
{props.text || fallbackText}
{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 topTrainingLabelStat(values: TrainingLabelStat[]) { return [...values] .filter((item) => String(item.label || '').trim()) .sort((a, b) => b.count - a.count)[0] } function TrainingStatsModal(props: { open: boolean onClose: () => void stats: TrainingStats | null history?: TrainingHistoryEntry[] 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 negativeCount = stats?.negativeCount ?? 0 const totalFeedback = stats?.feedbackCount ?? props.feedbackCount const boxCount = stats?.boxCount ?? 0 const sampleCount = stats?.sampleCount ?? 0 const overallConfidence = stats?.confidence const modelTrainedAtLabel = formatModelTrainedAt(stats?.modelInfo) const modelMap50Label = formatMapPercent(stats?.modelInfo?.map50) const modelMap5095Label = formatMapPercent(stats?.modelInfo?.map5095) const modelInfoDetails = (() => { const info = stats?.modelInfo if (!info) return '' const parts: string[] = [] if (Number(info.epochs) > 0) parts.push(`${info.epochs} Epochen`) if (Number(info.trainSamples) > 0) parts.push(`${info.trainSamples} Train`) if (Number(info.valSamples) > 0) parts.push(`${info.valSamples} Val`) if (String(info.device || '').trim()) parts.push(String(info.device).trim()) return parts.join(' · ') })() const history = props.history ?? [] const tabItems: Array<{ key: TrainingStatsTabKey title: string shortTitle: string description: string values: TrainingLabelStat[] total: number confidence?: TrainingConfidence topLabel?: TrainingLabelStat }> = [ { 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 ?? []), topLabel: topTrainingLabelStat(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 ?? []), topLabel: topTrainingLabelStat(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 ?? []), topLabel: topTrainingLabelStat(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 ?? []), topLabel: topTrainingLabelStat(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 ?? []), topLabel: topTrainingLabelStat(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}
Negativ
{negativeCount}
Boxen
{boxCount}
{/* Desktop/Tablet: ausführliche Karten */}
Feedback
{totalFeedback}
benötigt: {props.requiredCount}
Passt so
{acceptedCount}
{countPercent(acceptedCount, totalFeedback)}
Korrigiert
{correctedCount}
{countPercent(correctedCount, totalFeedback)}
Negativ
{negativeCount}
{countPercent(negativeCount, totalFeedback)}
Boxen
{boxCount}
{sampleCount} Samples
Modellstatus
{stats?.modelAvailable ? 'Trainiertes Modell verfügbar' : 'Noch kein trainiertes Modell verfügbar'}
{stats?.modelAvailable && modelTrainedAtLabel ? (
Version vom {modelTrainedAtLabel}
{modelMap50Label ? (
Qualität (mAP50{modelMap5095Label ? ' / 50-95' : ''}) {modelMap50Label} {modelMap5095Label ? ` / ${modelMap5095Label}` : ''}
) : null} {modelInfoDetails ? (
{modelInfoDetails}
) : null}
) : (
{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.
{history.length > 0 ? (
Trainings-Verlauf
Modellqualität (mAP) über die letzten Trainingsläufe
{history.map((entry, idx) => { const map50 = formatMapPercent(entry.map50) const map5095 = formatMapPercent(entry.map5095) const duration = Number(entry.durationMs) > 0 ? formatDuration(Number(entry.durationMs)) : '' const barPct = Math.max( 0, Math.min(100, Math.round(Number(entry.map50 ?? 0) * 100)) ) const meta: string[] = [] if (Number(entry.epochs) > 0) meta.push(`${entry.epochs} Ep.`) if (Number(entry.trainSamples) > 0) meta.push(`${entry.trainSamples} Train`) if (duration) meta.push(duration) return (
{formatHistoryDate(entry)} {idx === 0 ? ( aktuell ) : null}
{meta.length > 0 ? (
{meta.join(' · ')}
) : null}
{map50 || '—'}
{map5095 ? `50-95: ${map5095}` : 'mAP50'}
) })}
) : null} {/* Mobile: Tabs kompakter, direkt nach Summary */}
{tabItems.map((item) => { const active = item.key === activeTab const topLabelItem = item.topLabel ? getSegmentLabelItem(item.topLabel.label) : null const TopIcon = topLabelItem?.icon return ( ) })}
)}
) } function makeRequestId() { if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) { return crypto.randomUUID() } return `${Date.now()}-${Math.random().toString(16).slice(2)}` } function annotationToTrainingSample(item: TrainingAnnotation): TrainingSample { return { sampleId: item.sampleId, frameUrl: item.frameUrl, sourceFile: item.sourceFile, sourcePath: item.sourcePath, sourceSizeBytes: item.sourceSizeBytes, second: item.second, createdAt: item.createdAt, prediction: item.prediction, } } function annotationToCorrectionState(item: TrainingAnnotation): CorrectionState { if (item.negative) { return { sexPosition: 'unknown', peoplePresent: [], bodyPartsPresent: [], objectsPresent: [], clothingPresent: [], boxes: [], } } if (item.correction) { return item.correction } return predictionToCorrection(annotationToTrainingSample(item)) } const FEEDBACK_PAGE_SIZE = 10 const TRAINING_INFO_DISMISSED_STORAGE_KEY = 'training:info-dismissed-key' const TRAINING_IMAGE_EXPANDED_STORAGE_KEY = 'training:image-expanded' export default function TrainingTab(props: { onTrainingRunningChange?: (running: boolean) => void onImageExpandedChange?: (expanded: boolean) => void }) { const [labels, setLabels] = useState(emptyLabels) const [sample, setSample] = useState(null) const [correction, setCorrection] = useState(() => predictionToCorrection(null)) const [hasManualCorrection, setHasManualCorrection] = useState(false) const [negativeExample, setNegativeExample] = 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 [trainingHistory, setTrainingHistory] = useState([]) const wasTrainingRunningRef = useRef(false) const shownTrainingCompletionRef = useRef(null) const [dismissedTrainingInfoKey, setDismissedTrainingInfoKey] = useState(() => { if (typeof window === 'undefined') return '' try { return window.localStorage.getItem(TRAINING_INFO_DISMISSED_STORAGE_KEY) || '' } catch { return '' } }) const [importedSampleQueue, setImportedSampleQueue] = useState([]) const importedSampleQueueRef = useRef([]) const [feedbackModalOpen, setFeedbackModalOpen] = useState(false) const [feedbackItems, setFeedbackItems] = useState([]) const [feedbackLoading, setFeedbackLoading] = useState(false) const [feedbackLoadingMore, setFeedbackLoadingMore] = useState(false) const [feedbackError, setFeedbackError] = useState(null) const [feedbackTotal, setFeedbackTotal] = useState(0) const [feedbackHasMore, setFeedbackHasMore] = useState(false) const [selectedFeedbackIndex, setSelectedFeedbackIndex] = useState(0) const [feedbackSearchQuery, setFeedbackSearchQuery] = useState('') const [feedbackSearchFilter, setFeedbackSearchFilter] = useState('all') const [editingFeedback, setEditingFeedback] = useState<{ sampleId: string answeredAt: string } | null>(null) const notify = useNotify() const activePointerIdRef = useRef(null) const finishingGestureRef = useRef(false) const [frameImageLoaded, setFrameImageLoaded] = useState(false) const [imageExpanded, setImageExpanded] = useState(() => { if (typeof window === 'undefined') return false try { return window.localStorage.getItem(TRAINING_IMAGE_EXPANDED_STORAGE_KEY) === '1' } catch { return false } }) const [frameNaturalSize, setFrameNaturalSize] = useState<{ width: number height: number } | null>(null) const [loadingPreviewUrl, setLoadingPreviewUrl] = useState('') const [loadingPreviewLoaded, setLoadingPreviewLoaded] = useState(false) const [loadingPreviewFailed, setLoadingPreviewFailed] = useState(false) // Während des Trainings sendet das Backend zum gerade trainierten Batch eine // Vorschau. Wir zeigen immer das zuletzt eingegangene Bild (live) und merken es // in einem Ref, damit die Anzeige unabhängig von den (gedrosselten) Status- // Updates bleibt und die Render-Rate begrenzt ist. const [trainingPreviewUrl, setTrainingPreviewUrl] = useState('') const latestTrainingPreviewRef = useRef('') const lastTrainingStatusApplyRef = useRef(0) const [stageOverlayMounted, setStageOverlayMounted] = useState(false) const [stageOverlayVisible, setStageOverlayVisible] = useState(false) const imageBoxRef = useRef(null) const frameImageRef = useRef(null) const [imageLayerStyle, setImageLayerStyle] = useState(null) type ImageContentRect = { left: number top: number width: number height: number right: number bottom: number } const loadFeedbackHistoryInitial = useCallback(async ( options: { query?: string filter?: FeedbackFilter } = {} ) => { const query = options.query ?? '' const filter = options.filter ?? 'all' setFeedbackLoading(true) setFeedbackError(null) try { const params = new URLSearchParams() params.set('limit', String(FEEDBACK_PAGE_SIZE)) params.set('offset', '0') if (query.trim()) { params.set('q', query.trim()) } if (filter !== 'all') { params.set('filter', filter) } const res = await fetch(`/api/training/feedback/list?${params.toString()}`, { cache: 'no-store', }) const data = await res.json().catch(() => null) as TrainingFeedbackListResponse | null if (!res.ok || !data) { throw new Error(data && 'error' in data ? String((data as any).error) : `HTTP ${res.status}`) } setFeedbackItems(data.items) setFeedbackTotal(data.total) setFeedbackHasMore(data.hasMore) setSelectedFeedbackIndex(0) } catch (e) { setFeedbackError(e instanceof Error ? e.message : String(e)) } finally { setFeedbackLoading(false) } }, []) const loadMoreFeedbackHistory = useCallback(async () => { if (feedbackLoading || feedbackLoadingMore || !feedbackHasMore) return setFeedbackLoadingMore(true) setFeedbackError(null) try { const params = new URLSearchParams() params.set('limit', String(FEEDBACK_PAGE_SIZE)) params.set('offset', String(feedbackItems.length)) if (feedbackSearchQuery.trim()) { params.set('q', feedbackSearchQuery.trim()) } if (feedbackSearchFilter !== 'all') { params.set('filter', feedbackSearchFilter) } const res = await fetch(`/api/training/feedback/list?${params.toString()}`, { cache: 'no-store', }) const data = await res.json().catch(() => null) as TrainingFeedbackListResponse | null if (!res.ok || !data) { throw new Error(data && 'error' in data ? String((data as any).error) : `HTTP ${res.status}`) } setFeedbackItems((current) => { const existing = new Set( current.map((item) => `${item.sampleId}-${item.answeredAt}`) ) const nextItems = data.items.filter( (item) => !existing.has(`${item.sampleId}-${item.answeredAt}`) ) return [...current, ...nextItems] }) setFeedbackTotal(data.total) setFeedbackHasMore(data.hasMore) } catch (e) { setFeedbackError(e instanceof Error ? e.message : String(e)) } finally { setFeedbackLoadingMore(false) } }, [ feedbackHasMore, feedbackItems.length, feedbackLoading, feedbackLoadingMore, feedbackSearchFilter, feedbackSearchQuery, ]) const editFeedbackItem = useCallback((item: TrainingAnnotation) => { setSample(annotationToTrainingSample(item)) setCorrection(annotationToCorrectionState(item)) setHasManualCorrection(!item.accepted) setNegativeExample(Boolean(item.negative)) setEditingFeedback({ sampleId: item.sampleId, answeredAt: item.answeredAt, }) setDrawingBox(null) setBoxInteraction(null) setTouchMagnifier(null) setBoxLabel('') setActiveBoxIndex(null) setFeedbackModalOpen(false) window.requestAnimationFrame(() => { mobileLabelsScrollRef.current?.scrollTo({ top: 0, behavior: 'smooth', }) }) }, []) const getImageContentRect = useCallback((): ImageContentRect | null => { const imgEl = frameImageRef.current if (!imgEl) return null const rect = imgEl.getBoundingClientRect() const naturalW = imgEl.naturalWidth const naturalH = imgEl.naturalHeight if ( rect.width <= 0 || rect.height <= 0 || naturalW <= 0 || naturalH <= 0 ) { return null } // Entspricht object-contain: sichtbarer Bildinhalt innerhalb der img-Box. const scale = Math.min(rect.width / naturalW, rect.height / naturalH) const contentW = naturalW * scale const contentH = naturalH * scale const left = rect.left + (rect.width - contentW) / 2 const top = rect.top + (rect.height - contentH) / 2 return { left, top, width: contentW, height: contentH, right: left + contentW, bottom: top + contentH, } }, []) const updateImageLayerStyle = useCallback(() => { const boxEl = imageBoxRef.current const contentRect = getImageContentRect() if (!boxEl || !contentRect) { setImageLayerStyle(null) return } const boxRect = boxEl.getBoundingClientRect() setImageLayerStyle({ left: contentRect.left - boxRect.left, top: contentRect.top - boxRect.top, width: contentRect.width, height: contentRect.height, }) }, [getImageContentRect]) const detectorBoxesScrollRef = useRef(null) const detectorBoxItemRefs = useRef>([]) const activeAnalysisRequestIdRef = useRef(null) const loadingRef = useRef(false) const videoImportStartedRef = useRef(false) const videoImportInFlightKeyRef = useRef(null) const epochTimingRef = useRef<{ firstEpochAt: number lastEpoch: number lastAt: number }>({ firstEpochAt: 0, lastEpoch: 0, lastAt: 0, }) const etaSmoothingRef = useRef<{ lastAt: number lastRawEtaMs: number }>({ lastAt: 0, lastRawEtaMs: 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 [trainingSampleMode, setTrainingSampleMode] = useState('random') const trainingSampleModeRef = useRef('random') const [expandedCorrectionSections, setExpandedCorrectionSections] = useState({ sexPosition: false, people: false, bodyParts: false, objects: false, clothing: false, }) type CorrectionSectionKey = keyof typeof expandedCorrectionSections function nextExpandedCorrectionSections( key: CorrectionSectionKey, expanded: boolean ) { return { sexPosition: false, people: false, bodyParts: false, objects: false, clothing: false, [key]: expanded, } } const [mobilePanel, setMobilePanel] = useState<'labels' | 'boxes' | 'training'>('labels') const trainingRunningRef = useRef(false) const mobileLabelsScrollRef = useRef(null) const mobileSectionRefs = useRef>({ 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: CorrectionSectionKey, expanded: boolean) => { setExpandedCorrectionSections( nextExpandedCorrectionSections(key, expanded) ) if (expanded && mobilePanel === 'labels') { scrollMobileSectionToTop(key) } }, [mobilePanel, scrollMobileSectionToTop] ) const toggleCorrectionSection = useCallback( (key: CorrectionSectionKey, expanded: boolean) => { setExpandedCorrectionSections( nextExpandedCorrectionSections(key, expanded) ) }, [] ) const labelsRef = useRef(emptyLabels) useEffect(() => { importedSampleQueueRef.current = importedSampleQueue }, [importedSampleQueue]) useEffect(() => { if (!feedbackModalOpen) return setFeedbackSearchQuery('') setFeedbackSearchFilter('all') void loadFeedbackHistoryInitial({ query: '', filter: 'all', }) }, [feedbackModalOpen, loadFeedbackHistoryInitial]) useEffect(() => { loadingRef.current = loading }, [loading]) useEffect(() => { labelsRef.current = labels }, [labels]) useEffect(() => { trainingSampleModeRef.current = trainingSampleMode }, [trainingSampleMode]) 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 isNegativeCorrection = negativeExample && !correctionHasRelevantContent(correction) 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 '' const sep = sample.frameUrl.includes('?') ? '&' : '?' return `${sample.frameUrl}${sep}t=${encodeURIComponent(sample.sampleId)}&r=${imageReloadKey}` }, [sample?.frameUrl, sample?.sampleId, imageReloadKey]) const loadingPreviewRawUrlRef = useRef('') const setLoadingPreviewCandidate = useCallback((url: string) => { const clean = String(url || '').trim() if (!clean) { loadingPreviewRawUrlRef.current = '' setLoadingPreviewUrl('') setLoadingPreviewLoaded(false) setLoadingPreviewFailed(false) return } // Wichtig: // Bei gleichem Preview nicht neu cache-busten und nicht loaded=false setzen. // Sonst flackert das Overlay bei jedem Phasenwechsel. if (loadingPreviewRawUrlRef.current === clean) { return } loadingPreviewRawUrlRef.current = clean const sep = clean.includes('?') ? '&' : '?' setLoadingPreviewUrl(`${clean}${sep}r=${Date.now()}`) setLoadingPreviewLoaded(false) setLoadingPreviewFailed(false) }, []) 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 feedbackBadgeText = feedbackCount > requiredCount ? String(feedbackCount) : `${feedbackCount}/${requiredCount}` 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 drawingCursorClass = boxInteraction?.type === 'move' ? '[@media_(hover:hover)_and_(pointer:fine)]:cursor-grabbing' : drawingBox || (boxLabel && !uiLocked) ? '[@media_(hover:hover)_and_(pointer:fine)]:cursor-crosshair' : '' const trainingInfoJob = trainingStatus?.training ?? null const trainingInfoKey = useMemo(() => { const job = trainingInfoJob if (!job || job.running) return '' const finishedAt = String(job.finishedAt || '').trim() const startedAt = String(job.startedAt || '').trim() const message = String(job.message || job.error || '').trim() if (finishedAt) return finishedAt if (startedAt && message) return `${startedAt}:${message}` return '' }, [ trainingInfoJob?.running, trainingInfoJob?.finishedAt, trainingInfoJob?.startedAt, trainingInfoJob?.message, trainingInfoJob?.error, ]) const showTrainingInfo = Boolean(trainingInfoKey) && !trainingRunning && dismissedTrainingInfoKey !== trainingInfoKey const trainingInfoMessage = String( trainingInfoJob?.message || trainingInfoJob?.error || 'Training abgeschlossen.' ).trim() const trainingInfoDurationMs = trainingDurationMs(trainingInfoJob) const trainingInfoLooksPartial = Boolean(trainingInfoJob?.error) || /übersprungen|fehlgeschlagen|abgebrochen/i.test(trainingInfoMessage) const dismissTrainingInfo = useCallback(() => { if (!trainingInfoKey) return setDismissedTrainingInfoKey(trainingInfoKey) try { window.localStorage.setItem( TRAINING_INFO_DISMISSED_STORAGE_KEY, trainingInfoKey ) } catch { // ignore } }, [trainingInfoKey]) 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), positiveTrainCount: Number(data.detector.positiveTrainCount ?? 0), positiveValCount: Number(data.detector.positiveValCount ?? 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), previewUrl: String(job.previewUrl ?? ''), map50: Number(job.map50 ?? 0), map5095: Number(job.map5095 ?? 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 loadTrainingSampleIntoTab = useCallback(( nextSample: TrainingSample, opts?: { manualCorrection?: boolean } ) => { const nextCorrection = predictionToCorrection(nextSample) setDrawingBox(null) setBoxInteraction(null) setTouchMagnifier(null) setBoxLabel('') setActiveBoxIndex(null) setMobilePanel(trainingRunningRef.current ? 'training' : 'labels') window.requestAnimationFrame(() => { mobileLabelsScrollRef.current?.scrollTo({ top: 0, behavior: 'smooth', }) }) setSample(nextSample) setCorrection(nextCorrection) setHasManualCorrection(Boolean(opts?.manualCorrection)) setNegativeExample(false) const initiallyExpandedSection: CorrectionSectionKey | null = nextCorrection.sexPosition && nextCorrection.sexPosition !== 'unknown' ? 'sexPosition' : nextCorrection.peoplePresent.length > 0 ? 'people' : nextCorrection.bodyPartsPresent.length > 0 ? 'bodyParts' : nextCorrection.objectsPresent.length > 0 ? 'objects' : nextCorrection.clothingPresent.length > 0 ? 'clothing' : null setExpandedCorrectionSections( initiallyExpandedSection ? nextExpandedCorrectionSections(initiallyExpandedSection, true) : { sexPosition: false, people: false, bodyParts: false, objects: false, clothing: false, } ) }, []) const loadNextImportedQueuedSample = useCallback(() => { const [nextSample, ...rest] = importedSampleQueueRef.current if (!nextSample) { return false } importedSampleQueueRef.current = rest setImportedSampleQueue(rest) loadTrainingSampleIntoTab(nextSample) return true }, [loadTrainingSampleIntoTab]) const loadNext = useCallback(async (opts?: { forceNew?: boolean refreshPrediction?: boolean preserveNotice?: boolean mode?: TrainingSampleMode }) => { const requestId = makeRequestId() activeAnalysisRequestIdRef.current = requestId const isCurrentRequest = () => activeAnalysisRequestIdRef.current === requestId const mode = opts?.mode ?? trainingSampleModeRef.current const uncertainMode = mode === 'uncertain' && !opts?.refreshPrediction setLoadingPreviewCandidate('') setLoading(true) setAnalysisProgress(8) setAnalysisStep( opts?.refreshPrediction ? 'Aktuelles Bild wird neu analysiert…' : uncertainMode ? 'Unsichere Prediction wird gesucht…' : opts?.forceNew ? 'Neues Trainingsbild wird gesucht…' : 'Trainingsbild wird geladen…' ) if (!opts?.preserveNotice) { setError(null) setMessage(null) } try { const params = new URLSearchParams() params.set('analysisRequestId', requestId) if (opts?.forceNew) params.set('force', '1') if (opts?.refreshPrediction) params.set('refresh', '1') if (uncertainMode) params.set('mode', 'uncertain') const url = `/api/training/next${params.toString() ? `?${params.toString()}` : ''}` setAnalysisProgress(uncertainMode ? 5 : 25) setAnalysisStep( uncertainMode ? 'Mehrere Kandidaten werden vorbereitet…' : '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}`) } if (!isCurrentRequest()) { return } setAnalysisProgress(92) setAnalysisStep('Analyse-Ergebnis wird übernommen…') loadTrainingSampleIntoTab(data as TrainingSample) } catch (e) { if (isCurrentRequest()) { setError(e instanceof Error ? e.message : String(e)) } } finally { if (!isCurrentRequest()) { return } setAnalysisProgress((value) => Math.max(value, 100)) setAnalysisStep((value) => value || 'Analyse abgeschlossen.') const finishedRequestId = requestId window.setTimeout(() => { if (activeAnalysisRequestIdRef.current !== finishedRequestId) return activeAnalysisRequestIdRef.current = null setLoading(false) setAnalysisProgress(0) setAnalysisStep('') }, 500) } }, [loadTrainingSampleIntoTab, setLoadingPreviewCandidate]) 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 importVideoIntoTraining = useCallback(async (raw: any) => { const output = String(raw?.output || '').trim() if (!output) return false setLoadingPreviewCandidate('') const TRAINING_IMPORT_FRAME_COUNT = 10 const detail: PendingTrainingVideoImport = { jobId: String(raw?.jobId || '').trim(), output, sourceFile: String(raw?.sourceFile || '').trim(), count: Number(raw?.count || TRAINING_IMPORT_FRAME_COUNT), } const importKey = `${detail.jobId || ''}|${detail.output}|${detail.count || TRAINING_IMPORT_FRAME_COUNT}` // Verhindert Doppelimport durch sessionStorage + CustomEvent. if (videoImportInFlightKeyRef.current === importKey) { return false } videoImportStartedRef.current = true videoImportInFlightKeyRef.current = importKey const requestId = makeRequestId() activeAnalysisRequestIdRef.current = requestId loadingRef.current = true setLoading(true) setAnalysisProgress(5) setAnalysisStep('Video wird ins Training übernommen…') setError(null) setMessage(null) try { try { window.sessionStorage.removeItem('training:pending-import-video') } catch { // ignore } const res = await fetch('/api/training/import-video', { method: 'POST', headers: { 'Content-Type': 'application/json' }, cache: 'no-store', body: JSON.stringify({ jobId: detail.jobId, output: detail.output, count: detail.count || TRAINING_IMPORT_FRAME_COUNT, analysisRequestId: requestId, }), }) const data = await res.json().catch(() => null) if (!res.ok || !data?.ok) { throw new Error(backendText(data, `HTTP ${res.status}`)) } const samples: TrainingSample[] = Array.isArray(data.samples) ? data.samples : data.sample ? [data.sample] : [] if (samples.length === 0) { throw new Error('Es wurden keine Trainingsframes erzeugt.') } const [firstSample, ...queuedSamples] = samples importedSampleQueueRef.current = queuedSamples setImportedSampleQueue(queuedSamples) loadTrainingSampleIntoTab(firstSample) setImageReloadKey((value) => value + 1) await loadTrainingStatus() const errorCount = Array.isArray(data.errors) ? data.errors.length : 0 setMessage( errorCount > 0 ? `${samples.length} Frames ins Training übernommen, ${errorCount} Frames fehlgeschlagen.` : `${samples.length} Frames ins Training übernommen.` ) return true } catch (e) { setError(e instanceof Error ? e.message : String(e)) return false } finally { setAnalysisProgress(100) setAnalysisStep('Video-Import abgeschlossen.') const finishedRequestId = requestId window.setTimeout(() => { if (activeAnalysisRequestIdRef.current === finishedRequestId) { activeAnalysisRequestIdRef.current = null loadingRef.current = false setLoading(false) setAnalysisProgress(0) setAnalysisStep('') } if (videoImportInFlightKeyRef.current === importKey) { videoImportInFlightKeyRef.current = null } }, 500) } }, [ loadTrainingSampleIntoTab, loadTrainingStatus, setLoadingPreviewCandidate, ]) 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), negativeCount: Number(data?.negativeCount ?? 0), sampleCount: Number(data?.sampleCount ?? 0), boxCount: Number(data?.boxCount ?? 0), modelAvailable: Boolean(data?.modelAvailable), modelInfo: data?.modelInfo ? { trainedAt: data.modelInfo.trainedAt, trainedAtMs: Number(data.modelInfo.trainedAtMs ?? 0), epochs: Number(data.modelInfo.epochs ?? 0), trainSamples: Number(data.modelInfo.trainSamples ?? 0), valSamples: Number(data.modelInfo.valSamples ?? 0), imgsz: Number(data.modelInfo.imgsz ?? 0), device: data.modelInfo.device, map50: Number(data.modelInfo.map50 ?? 0), map5095: Number(data.modelInfo.map5095 ?? 0), } : undefined, 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) } }, []) const loadTrainingHistory = useCallback(async () => { try { const res = await fetch('/api/training/history', { cache: 'no-store' }) const data = await res.json().catch(() => null) if (!res.ok || !data) return setTrainingHistory( Array.isArray(data?.entries) ? data.entries.map((e: any) => ({ trainedAt: e?.trainedAt, trainedAtMs: Number(e?.trainedAtMs ?? 0), durationMs: Number(e?.durationMs ?? 0), epochs: Number(e?.epochs ?? 0), trainSamples: Number(e?.trainSamples ?? 0), valSamples: Number(e?.valSamples ?? 0), imgsz: Number(e?.imgsz ?? 0), device: e?.device, map50: Number(e?.map50 ?? 0), map5095: Number(e?.map5095 ?? 0), })) : [] ) } catch { // ignore } }, []) useEffect(() => { updateImageLayerStyle() const boxEl = imageBoxRef.current const imgEl = frameImageRef.current const resizeObserver = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(() => updateImageLayerStyle()) : null if (resizeObserver) { if (boxEl) resizeObserver.observe(boxEl) if (imgEl) resizeObserver.observe(imgEl) } window.addEventListener('resize', updateImageLayerStyle) window.addEventListener('scroll', updateImageLayerStyle, true) return () => { resizeObserver?.disconnect() window.removeEventListener('resize', updateImageLayerStyle) window.removeEventListener('scroll', updateImageLayerStyle, true) } }, [imageSrc, imageExpanded, frameImageLoaded, updateImageLayerStyle]) useEffect(() => { trainingRunningRef.current = trainingRunning if (trainingRunning) { setMobilePanel('training') } }, [trainingRunning]) useEffect(() => { const onTraining = (event: Event) => { try { const data = (event as CustomEvent).detail if (data?.type !== 'training_status') return const job = data.training || null const running = Boolean(job?.running) // Immer das zuletzt gesendete Bild merken (live, ältere werden übersprungen). const previewUrl = String(job?.previewUrl ?? '').trim() if (running && previewUrl) { latestTrainingPreviewRef.current = previewUrl } // Status (Progress/Epoche) nur gedrosselt anwenden, damit pro Bild // kein Re-Render des gesamten Tabs ausgelöst wird. Das Abschluss-Event // (running=false) wird immer angewendet. const now = Date.now() if (!running || now - lastTrainingStatusApplyRef.current >= 200) { lastTrainingStatusApplyRef.current = now applyTrainingStatus({ training: job }) } } catch { // ignore } } window.addEventListener('app:sse:training', onTraining as EventListener) return () => { window.removeEventListener('app:sse:training', onTraining as EventListener) } }, [applyTrainingStatus]) // Im festen Takt immer das zuletzt eingegangene Bild anzeigen. // So bleibt die Anzeige live (max. ~110 ms Versatz) und die Render-Rate begrenzt. useEffect(() => { if (!trainingRunning) { latestTrainingPreviewRef.current = '' setTrainingPreviewUrl('') return } const timer = window.setInterval(() => { const latest = latestTrainingPreviewRef.current if (!latest) return setTrainingPreviewUrl((cur) => (cur === latest ? cur : latest)) }, 110) return () => window.clearInterval(timer) }, [trainingRunning]) useEffect(() => { const onAnalysis = (event: Event) => { try { const data = (event as CustomEvent).detail const activeRequestId = activeAnalysisRequestIdRef.current if (!loadingRef.current || !activeRequestId) return const requestId = String( data?.requestId || data?.analysisRequestId || data?.analysis?.requestId || data?.analysis?.analysisRequestId || '' ).trim() const scope = String( data?.scope || data?.analysis?.scope || '' ).trim() if (scope !== 'training') return if (requestId !== activeRequestId) return const message = String( data?.message || data?.step || data?.title || '' ).trim() const previewUrl = String( data?.previewUrl || data?.analysis?.previewUrl || '' ).trim() if (previewUrl) { setLoadingPreviewCandidate(previewUrl) } const current = Number(data?.current ?? data?.stepIndex ?? data?.index) const total = Number(data?.total ?? data?.steps ?? data?.stepTotal) const rawProgress = Number( data?.progress ?? data?.percent ?? data?.analysis?.progress ) let nextProgress: number | null = null if ( Number.isFinite(current) && Number.isFinite(total) && total > 0 ) { nextProgress = (current / total) * 100 } else if (Number.isFinite(rawProgress)) { nextProgress = rawProgress <= 1 ? rawProgress * 100 : rawProgress } if (message) { setAnalysisStep(message) } if (nextProgress !== null) { setAnalysisProgress((prev) => Math.max(prev, clampPercent(nextProgress)) ) } } catch { // ignore } } window.addEventListener('app:sse:analysis', onAnalysis as EventListener) return () => { window.removeEventListener('app:sse:analysis', onAnalysis as EventListener) } }, [setLoadingPreviewCandidate]) 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() void loadTrainingHistory() }, [statsModalOpen, loadTrainingStats, loadTrainingHistory]) const onTrainingRunningChange = props.onTrainingRunningChange useEffect(() => { onTrainingRunningChange?.(trainingRunning) }, [trainingRunning, onTrainingRunningChange]) useEffect(() => { props.onImageExpandedChange?.(imageExpanded) return () => { props.onImageExpandedChange?.(false) } }, [imageExpanded, props.onImageExpandedChange]) useEffect(() => { try { window.localStorage.setItem( TRAINING_IMAGE_EXPANDED_STORAGE_KEY, imageExpanded ? '1' : '0' ) } catch { // ignore } }, [imageExpanded]) useEffect(() => { if (!trainingRunning) { etaSmoothingRef.current = { lastAt: 0, lastRawEtaMs: 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(() => { const onImportVideo = (event: Event) => { const detail = (event as CustomEvent).detail void importVideoIntoTraining(detail) } window.addEventListener('training:import-video', onImportVideo as EventListener) try { const raw = window.sessionStorage.getItem('training:pending-import-video') if (raw) { const detail = JSON.parse(raw) void importVideoIntoTraining(detail) } } catch { // ignore } return () => { window.removeEventListener('training:import-video', onImportVideo as EventListener) } }, [importVideoIntoTraining]) useEffect(() => { let cancelled = false async function init() { await loadLabels() await loadTrainingStatus() if (cancelled) return // Wichtig: // Wenn gerade ein Video-Import über "Video ins Training übernehmen" läuft, // darf loadNext() nicht danach ein zufälliges/letztes Sample darüberlegen. if (videoImportStartedRef.current || videoImportInFlightKeyRef.current) { return } 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, options?: { negative?: 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 negative = options?.negative ?? isNegativeCorrection const correctionPayload: CorrectionState = negative ? { sexPosition: 'unknown', peoplePresent: [], bodyPartsPresent: [], objectsPresent: [], clothingPresent: [], boxes: [], } : { ...correction, peoplePresent: peopleLabelsFromBoxes(normalizedBoxes, labelsRef.current), boxes: normalizedBoxes, } const effectiveAccepted = negative ? false : accepted const payload = { sampleId: sample.sampleId, accepted: effectiveAccepted, negative, correction: effectiveAccepted && correctionPayload.boxes.length === 0 ? undefined : correctionPayload, } const res = await fetch( editingFeedback ? '/api/training/feedback/update' : '/api/training/feedback', { method: editingFeedback ? 'PUT' : 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify( editingFeedback ? { ...payload, sampleId: editingFeedback.sampleId, answeredAt: editingFeedback.answeredAt, } : payload ), } ) const data = await res.json().catch(() => null) if (!res.ok) { throw new Error(backendText(data, `HTTP ${res.status}`)) } const wasEditingFeedback = Boolean(editingFeedback) if (wasEditingFeedback) { setEditingFeedback(null) setFeedbackItems((current) => current.map((item) => item.sampleId === editingFeedback?.sampleId && item.answeredAt === editingFeedback?.answeredAt ? { ...item, accepted: effectiveAccepted, negative, correction: effectiveAccepted ? undefined : correctionPayload, } : item ) ) } setMessage( wasEditingFeedback ? negative ? 'Negativbeispiel aktualisiert.' : effectiveAccepted ? 'Feedback aktualisiert.' : 'Korrektur aktualisiert.' : negative ? 'Negativbeispiel gespeichert.' : effectiveAccepted ? 'Feedback gespeichert.' : 'Korrektur gespeichert.' ) await loadTrainingStatus() if (!loadNextImportedQueuedSample()) { await loadNext({ forceNew: true, preserveNotice: true, }) } } catch (e) { console.error('Feedback speichern fehlgeschlagen:', e) const raw = e instanceof Error ? e.message : String(e) const short = raw.length > 220 ? `${raw.slice(0, 220).trimEnd()}…` : raw setError(`Feedback konnte nicht gespeichert werden. ${short}`) } finally { setSaving(false) } }, [ sample, correction, editingFeedback, isNegativeCorrection, loadNext, loadTrainingStatus, loadNextImportedQueuedSample, ] ) const skipCurrentSample = useCallback(async () => { if (!sample) return const skippedSampleId = sample.sampleId setSaving(true) setError(null) setMessage(null) try { const res = await fetch('/api/training/skip', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sampleId: skippedSampleId }), }) const data = await res.json().catch(() => null) if (!res.ok) { throw new Error(backendText(data, `HTTP ${res.status}`)) } setSample(null) setCorrection(predictionToCorrection(null)) setHasManualCorrection(false) setNegativeExample(false) setDrawingBox(null) setBoxInteraction(null) setTouchMagnifier(null) setBoxLabel('') setActiveBoxIndex(null) if (!loadNextImportedQueuedSample()) { await loadNext({ forceNew: true, preserveNotice: true, }) } } catch (e) { setError(e instanceof Error ? e.message : String(e)) } finally { setSaving(false) } }, [sample, loadNext, loadNextImportedQueuedSample]) const startTraining = useCallback(async () => { shownTrainingCompletionRef.current = null setDismissedTrainingInfoKey('') try { window.localStorage.removeItem(TRAINING_INFO_DISMISSED_STORAGE_KEY) } catch { // ignore } 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)) setNegativeExample(false) 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 rect = getImageContentRect() if (!rect) 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), } }, [getImageContentRect]) const releaseActivePointerCapture = useCallback((pointerId?: number | null) => { const id = typeof pointerId === 'number' ? pointerId : activePointerIdRef.current if (typeof id !== 'number') { activePointerIdRef.current = null return } try { imageBoxRef.current?.releasePointerCapture(id) } catch { // Pointer-Capture war evtl. schon weg. } activePointerIdRef.current = null }, []) 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 rawPos = getPointerPosInImage(e.clientX, e.clientY, { clamp: false }) if (!rawPos) return // Nicht im schwarzen Randbereich starten. if ( rawPos.x < 0 || rawPos.x > 1 || rawPos.y < 0 || rawPos.y > 1 ) { return } const pos = { x: clamp01(rawPos.x), y: clamp01(rawPos.y), } e.preventDefault() e.stopPropagation() window.getSelection()?.removeAllRanges() finishingGestureRef.current = false activePointerIdRef.current = e.pointerId try { e.currentTarget.setPointerCapture(e.pointerId) } catch { activePointerIdRef.current = null } 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 | PointerEvent) => { releaseActivePointerCapture( typeof e?.pointerId === 'number' ? e.pointerId : null ) setTouchMagnifier(null) if (finishingGestureRef.current) { setDrawingBox(null) setBoxInteraction(null) return } finishingGestureRef.current = true if (drawingBox || boxInteraction) { e?.preventDefault() e?.stopPropagation() } if (boxInteraction) { setBoxInteraction(null) return } if (!drawingBox) { setDrawingBox(null) 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, releaseActivePointerCapture]) useEffect(() => { if (!drawingBox && !boxInteraction) return const finishPointer = (event: PointerEvent) => { finishDrawBox(event) } const finishWithoutPointer = () => { finishDrawBox() } const finishOnHidden = () => { if (document.hidden) { finishDrawBox() } } window.addEventListener('pointerup', finishPointer, true) window.addEventListener('pointercancel', finishPointer, true) window.addEventListener('blur', finishWithoutPointer) document.addEventListener('visibilitychange', finishOnHidden) return () => { window.removeEventListener('pointerup', finishPointer, true) window.removeEventListener('pointercancel', finishPointer, true) window.removeEventListener('blur', finishWithoutPointer) document.removeEventListener('visibilitychange', finishOnHidden) } }, [drawingBox, boxInteraction, finishDrawBox]) 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) useEffect(() => { if (loading || frameBusy) return if (!loadingPreviewUrl) return setLoadingPreviewUrl('') setLoadingPreviewLoaded(false) setLoadingPreviewFailed(false) }, [loading, frameBusy, loadingPreviewUrl]) 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, lastRawEtaMs: 0, } setSmoothedTrainingEtaMs(0) return } setSmoothedTrainingEtaMs((previous) => { const state = etaSmoothingRef.current const lastAt = state.lastAt || trainingNowMs const elapsed = Math.max(0, trainingNowMs - lastAt) // 1) Immer erstmal echte Anzeige runterzählen. const countedDown = previous > 0 ? Math.max(0, previous - elapsed) : rawTrainingEtaMs const rawChanged = state.lastRawEtaMs <= 0 || Math.abs(rawTrainingEtaMs - state.lastRawEtaMs) > 1000 let next = countedDown if (rawChanged) { const diff = rawTrainingEtaMs - countedDown // Neue Backend-Schätzung einblenden, aber nicht hart springen. if (diff > 0) { // Restzeit wurde höher neu berechnet: langsam nach oben. next = countedDown + Math.min(diff * 0.20, 15_000) } else { // Restzeit wurde niedriger neu berechnet: schneller nach unten. next = countedDown + diff * 0.45 } } next = Math.max(0, next) etaSmoothingRef.current = { lastAt: trainingNowMs, lastRawEtaMs: rawTrainingEtaMs, } 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, ]) useEffect(() => { const toastImage = imageSrc ? { imageUrl: imageSrc, imageAlt: sample?.sourceFile || 'Training Frame', } : undefined if (error) { notify.error('Aktion fehlgeschlagen', error, toastImage) setError(null) return } if (!message) return const lowerMessage = message.toLowerCase() const looksPartial = lowerMessage.includes('übersprungen') || lowerMessage.includes('fehlgeschlagen') || lowerMessage.includes('abgebrochen') if (looksPartial) { notify.warning('Training teilweise abgeschlossen', message, toastImage) } else { notify.success('Erfolg', message, toastImage) } setMessage(null) }, [error, message, notify, imageSrc, sample?.sourceFile]) 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 missingPositiveTrain = Number(detector?.positiveTrainCount ?? 0) < 1 const missingPositiveVal = Number(detector?.positiveValCount ?? 0) < 1 const positiveSamplesMissing = missingPositiveTrain || missingPositiveVal const statusText = trainingRunning ? shownTrainingStep || 'Training läuft…' : !feedbackReady ? `${Math.max(0, requiredCount - feedbackCount)} Feedback fehlen noch` : !detectorReady ? missingTrain > 0 || missingVal > 0 ? `YOLO-Beispiele fehlen: ${missingTrain} Train, ${missingVal} Val` : positiveSamplesMissing ? 'Je ein positives Beispiel in Train und Val erforderlich' : 'YOLO-Datensatz noch nicht bereit' : canStartTraining ? 'Bereit zum Trainieren' : 'Noch nicht trainingsbereit' return (
{trainingRunning ? (
Training-Aktionen
{statusText}
{trainingRunning || feedbackCount < requiredCount ? (
{trainingRunning ? 'Trainingsfortschritt' : 'Feedback-Fortschritt'} {Math.round(progress)}%
) : null} {showTrainingInfo ? (
{trainingInfoLooksPartial ? (
Training-Info
{trainingInfoMessage}
Dauer
{trainingInfoDurationMs > 0 ? formatDuration(trainingInfoDurationMs) : '—'}
Feedback
{feedbackCount}/{requiredCount}
YOLO Train
{trainingStatus?.detector?.trainCount ?? 0}/{trainingStatus?.detector?.requiredTrain ?? 20}
YOLO Val
{trainingStatus?.detector?.valCount ?? 0}/{trainingStatus?.detector?.requiredVal ?? 3}
) : null}
{trainingRunning && compact ? (
Laufzeit
{formatDuration(shownTrainingDurationMs)}
Restzeit
{shownTrainingEtaMs > 0 ? `ca. ${formatDuration(shownTrainingEtaMs)}` : '—'}
Epoche
{shownTrainingEpochText ? shownTrainingEpochText.replace(/^Epoche\s+/i, '') : '—'}
Ø / Epoche
{estimatedEpochMs > 0 ? formatDuration(estimatedEpochMs) : '—'}
) : 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(' ')} > ) }) )}
) } const imageTouchClass = boxLabel || drawingBox || boxInteraction ? 'touch-none' : 'touch-pan-y' const loadingPreviewBackgroundUrl = loadingPreviewUrl && loadingPreviewLoaded && !loadingPreviewFailed ? loadingPreviewUrl : '' const frameLayoutSize = useMemo(() => { const width = Number(frameNaturalSize?.width) const height = Number(frameNaturalSize?.height) if ( Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0 ) { return { width, height } } // Fallback, bis das echte Bildformat bekannt ist. return { width: 1600, height: 900 } }, [frameNaturalSize]) const imageAspectRatio = Math.max( 0.25, Math.min(4, frameLayoutSize.width / frameLayoutSize.height) ) const imageStageLimits = imageExpanded ? { baseDvh: 52, smDvh: 60, lgDvh: 78, lgPx: 820, // Platz für: // - Speichern/Überspringen // - Bild vergrößern/Normal anzeigen // - Abstände bottomReservePx: 158, } : { baseDvh: 44, smDvh: 52, lgDvh: 64, lgPx: 680, bottomReservePx: 138, } const imageStageStyle = imageExpanded ? ({ '--image-stage-max-h': `${imageStageLimits.baseDvh}dvh`, '--image-stage-max-h-sm': `${imageStageLimits.smDvh}dvh`, } as CSSProperties & Record) : ({ aspectRatio: `${frameLayoutSize.width} / ${frameLayoutSize.height}`, '--image-stage-max-h': `${imageStageLimits.baseDvh}dvh`, '--image-stage-max-h-sm': `${imageStageLimits.smDvh}dvh`, '--image-stage-max-h-lg': `min(${imageStageLimits.lgDvh}dvh, ${imageStageLimits.lgPx}px)`, '--image-stage-w': `${imageStageLimits.baseDvh * imageAspectRatio}dvh`, '--image-stage-w-sm': `${imageStageLimits.smDvh * imageAspectRatio}dvh`, '--image-stage-w-lg': `min(${imageStageLimits.lgDvh * imageAspectRatio}dvh, ${Math.round( imageStageLimits.lgPx * imageAspectRatio )}px)`, } as CSSProperties & Record) const imageStageHeightClass = [ 'max-h-[var(--image-stage-max-h)]', 'sm:max-h-[var(--image-stage-max-h-sm)]', 'lg:max-h-[var(--image-stage-max-h-lg)]', 'w-[min(100%,var(--image-stage-w))]', 'sm:w-[min(100%,var(--image-stage-w-sm))]', 'lg:w-[min(100%,var(--image-stage-w-lg))]', ].join(' ') const stageBusy = trainingRunning || frameBusy const stageOverlayMode: 'training' | 'analysis' = trainingRunning ? 'training' : 'analysis' const stageOverlayText = trainingRunning ? shownTrainingStep || 'Aktuelles Bild wird geladen…' : analysisStep || 'Bild wird geladen…' const stageOverlayProgress = trainingRunning ? shownTrainingProgress : loading ? analysisProgress : 100 const stageOverlayFadeMs = 300 useEffect(() => { if (stageBusy) { setStageOverlayMounted(true) const frame = window.requestAnimationFrame(() => { setStageOverlayVisible(true) }) return () => window.cancelAnimationFrame(frame) } setStageOverlayVisible(false) const timer = window.setTimeout(() => { setStageOverlayMounted(false) }, stageOverlayFadeMs) return () => window.clearTimeout(timer) }, [stageBusy]) const renderStageOverlay = stageBusy || stageOverlayMounted const stageOverlayIsVisible = stageBusy && stageOverlayVisible return (
{loadingPreviewUrl ? ( { const img = e.currentTarget if (img.naturalWidth > 0 && img.naturalHeight > 0) { setFrameNaturalSize({ width: img.naturalWidth, height: img.naturalHeight, }) } setLoadingPreviewLoaded(true) setLoadingPreviewFailed(false) }} onError={() => { setLoadingPreviewLoaded(false) setLoadingPreviewFailed(true) }} /> ) : null}
Training
{sample?.sourceFile ? (
{sample.sourceFile}
) : null}
{/* Sidebar links */} {/* Mitte */}
{imageSrc ? (
e.preventDefault()} onPointerDown={startDrawBox} onPointerMove={moveDrawBox} > Training Frame { const img = e.currentTarget if (img.naturalWidth > 0 && img.naturalHeight > 0) { setFrameNaturalSize({ width: img.naturalWidth, height: img.naturalHeight, }) } setFrameImageLoaded(true) window.requestAnimationFrame(updateImageLayerStyle) }} onError={() => { setFrameImageLoaded(true) window.requestAnimationFrame(updateImageLayerStyle) }} onContextMenu={(e) => e.preventDefault()} onDragStart={(e) => e.preventDefault()} className={[ imageExpanded ? 'block h-full w-full min-h-0 rounded-md object-contain' : 'block h-auto min-h-0 max-h-full max-w-full rounded-md object-contain', 'select-none', 'transition-opacity duration-500 ease-out will-change-opacity motion-reduce:transition-none', frameImageLoaded ? 'opacity-100' : 'opacity-0', imageTouchClass, '[-webkit-user-drag:none] [-webkit-touch-callout:none]', ].join(' ')} /> {showImageBoxes && imageLayerStyle ? (
{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 alignLabelRight = box.x + box.w > 0.62 const imageRect = frameImageRef.current?.getBoundingClientRect() const labelMaxWidth = imageRect?.width ? Math.max(72, Math.min(210, imageRect.width - 8)) : 210 return (
{ if (isDraft || uiLocked) return e.preventDefault() e.stopPropagation() window.getSelection()?.removeAllRanges() const target = e.target as HTMLElement | null if (target?.closest('[data-box-trash="true"]')) return const requiresActivationFirst = !isActiveBox setActiveBoxIndex(index) if (requiresActivationFirst) return const pos = getPointerPosInImage(e.clientX, e.clientY, { clamp: false }) const clampedPos = getPointerPosInImage(e.clientX, e.clientY) if (!pos || !clampedPos) return finishingGestureRef.current = false activePointerIdRef.current = e.pointerId try { imageBoxRef.current?.setPointerCapture(e.pointerId) } catch { activePointerIdRef.current = null } setTouchMagnifier({ visible: true, clientX: e.clientX, clientY: e.clientY, imageX: clampedPos.x, imageY: clampedPos.y, }) setBoxInteraction({ type: 'move', index, startX: pos.x, startY: pos.y, original: box, }) }} > {isDraft ? 'neu' : `#${index + 1}`}
{!isDraft ? ( <> {(['nw', 'ne', 'sw', 'se'] as const).map((handle) => ( ))} ) : null}
) })}
) : null} {touchMagnifier?.visible && imageSrc && showImageBoxes ? (() => { const rect = getImageContentRect() if (!rect || rect.width <= 0 || rect.height <= 0) return null const visualViewport = typeof window !== 'undefined' ? window.visualViewport : undefined const viewportLeft = visualViewport?.offsetLeft ?? 0 const viewportTop = visualViewport?.offsetTop ?? 0 const viewportW = visualViewport?.width ?? (typeof window !== 'undefined' ? window.innerWidth : 390) const viewportH = visualViewport?.height ?? (typeof window !== 'undefined' ? window.innerHeight : 800) const isCoarsePointer = typeof window !== 'undefined' && typeof window.matchMedia === 'function' && window.matchMedia('(pointer: coarse)').matches const isTouchLike = isCoarsePointer || viewportW < 640 const baseSize = isTouchLike ? 136 : 156 const largeBoxSize = isTouchLike ? Math.min(176, Math.max(152, viewportW - 32)) : 190 const padding = isTouchLike ? 16 : 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 boxNeedsLargeMagnifier = hasUsableBox && ( boxPixelW > baseSize - padding * 2 || boxPixelH > baseSize - padding * 2 ) const size = boxNeedsLargeMagnifier ? largeBoxSize : baseSize const fitPadding = isTouchLike ? 14 : 18 const fitZoom = hasUsableBox ? Math.min( (size - fitPadding * 2) / Math.max(1, boxPixelW), (size - fitPadding * 2) / Math.max(1, boxPixelH) ) : 2 const zoom = hasUsableBox ? Math.min(isTouchLike ? 2.25 : 2.5, fitZoom * 0.94) : 2 const gap = isTouchLike ? 10 : 12 const edgeGap = isTouchLike ? 8 : 10 // Die Lupe wird an der Box ausgerichtet, nicht am Finger. // Wenn gerade noch keine echte Box existiert, fällt sie auf den aktuellen Bildpunkt zurück. const anchorX = hasUsableBox ? rect.left + boxCenterX * rect.width : rect.left + touchMagnifier.imageX * rect.width const anchorTop = hasUsableBox ? rect.top + activeBox.y * rect.height : rect.top + touchMagnifier.imageY * rect.height const anchorBottom = hasUsableBox ? rect.top + (activeBox.y + activeBox.h) * rect.height : rect.top + touchMagnifier.imageY * rect.height let left = anchorX - size / 2 // Normalfall: Lupe über der Box. let top = anchorTop - size - gap // Wenn oben kein Platz ist: Lupe unter die Box setzen. if (top < viewportTop + edgeGap) { top = anchorBottom + gap } // Wenn die Lupe unten aus dem sichtbaren Bereich laufen würde: // an das untere Ende des sichtbaren Viewports klemmen. if (top + size > viewportTop + viewportH - edgeGap) { top = viewportTop + viewportH - size - edgeGap } // Falls der Viewport extrem klein ist, trotzdem sichtbar halten. if (top < viewportTop + edgeGap) { top = viewportTop + edgeGap } left = Math.max( viewportLeft + edgeGap, Math.min(viewportLeft + viewportW - size - edgeGap, left) ) 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 typeof document !== 'undefined' ? createPortal(
{hasUsableBox ? (
) : null}
, document.body ) : null })() : null}
) : (
Kein Bild geladen
)} {renderStageOverlay ? ( ) : null}
{trainingRunning ? (
Trainingszeit
{shownTrainingStep || 'Training läuft…'}
{Math.round(shownTrainingProgress)}%
Laufzeit
{formatDuration(shownTrainingDurationMs)}
Restzeit
{shownTrainingEtaMs > 0 ? `ca. ${formatDuration(shownTrainingEtaMs)}` : '—'}
Epoche
{shownTrainingEpochText ? shownTrainingEpochText.replace(/^Epoche\s+/i, '') : '—'}
Ø / Epoche
{estimatedEpochMs > 0 ? formatDuration(estimatedEpochMs) : '—'}
mAP50
{formatMapPercent(trainingStatus?.training?.map50) || '—'}
) : null}
{[ { key: 'labels', label: 'Labels' }, { key: 'boxes', label: 'Boxen', count: correctionBoxes.length }, { key: 'training', label: 'Training' }, ].map((item) => { const active = mobilePanel === item.key return ( ) })}
{ /* Rechte Seite */ }
{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} history={trainingHistory} loading={trainingStatsLoading} error={trainingStatsError} feedbackCount={feedbackCount} requiredCount={requiredCount} /> setFeedbackModalOpen(false)} items={feedbackItems} loading={feedbackLoading || feedbackLoadingMore} error={feedbackError} total={feedbackTotal} hasMore={feedbackHasMore} selectedIndex={selectedFeedbackIndex} onSelectedIndexChange={setSelectedFeedbackIndex} onLoadMore={() => void loadMoreFeedbackHistory()} onEditItem={editFeedbackItem} onSearchChange={(query, filter) => { setFeedbackSearchQuery(query) setFeedbackSearchFilter(filter) void loadFeedbackHistoryInitial({ query, filter, }) }} />
) }