// frontend\src\components\ui\TrainingTab.tsx 'use client' import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react' import Button from './Button' import LabeledSwitch from './LabeledSwitch' import LoadingSpinner from './LoadingSpinner' import { formatDuration } from './formatters' import { ArrowPathIcon, ArrowsPointingInIcon, ArrowsPointingOutIcon, BoltIcon, CheckIcon, ForwardIcon, InboxArrowDownIcon, PauseIcon, RectangleGroupIcon, UserGroupIcon, TrashIcon, VideoCameraIcon, 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' import type { RecorderSettingsState } from '../../types' 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 trainedModelExists?: boolean trainedModelPath?: string source?: string } type TrainingPoseStatus = { trainCount: number valCount: number requiredTrain: number requiredVal: number datasetReady: boolean dataReady: boolean modelExists: boolean modelPath?: string trainedModelExists?: boolean trainedModelPath?: string source?: string } type TrainingVideoMAEStatus = { eligibleCount: number trainCount: number valCount: number requiredTrain: number requiredVal: number datasetReady: boolean dataReady: boolean modelExists: boolean modelPath?: string trainedModelExists?: boolean trainedModelPath?: string source?: string } type TrainingStatus = { feedbackCount: number requiredCount: number canTrain: boolean training?: TrainingJobStatus detector?: TrainingDetectorStatus pose?: TrainingPoseStatus videoMAE?: TrainingVideoMAEStatus } 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 paused?: boolean pauseReason?: string cpuPercent?: number temperatureC?: number map50?: number map5095?: number } type TrainingPrediction = { modelAvailable: boolean source?: string sexPosition: string sexPositionScore: number peoplePresent: ScoredLabel[] bodyPartsPresent: ScoredLabel[] objectsPresent: ScoredLabel[] clothingPresent: ScoredLabel[] boxes?: TrainingBox[] persons?: TrainingPosePerson[] } 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 QueuedTrainingSample = { sample: TrainingSample correction?: CorrectionState manualCorrection?: boolean } type TrainingBox = { label: string score?: number x: number y: number w: number h: number } type TrainingKeypoint = { name: string x: number y: number conf: number } type TrainingPosePerson = { label?: string score: number box: TrainingBox keypoints: TrainingKeypoint[] quality?: number visibleKeypoints?: number reliable?: boolean } 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 target?: string status?: string durationMs?: number epochs?: number trainSamples?: number valSamples?: number imgsz?: number device?: string map50?: number map5095?: number performanceMode?: string cpuCoreCount?: number cpuThreads?: number workers?: number yoloBatchSize?: number lowPriority?: boolean autoPauseEnabled?: boolean autoPauseCpuPercent?: number autoPauseTemperatureC?: number } type TrainingStats = { feedbackCount: number acceptedCount: number correctedCount: number negativeCount: number sampleCount: number boxCount: number modelAvailable: boolean modelInfo?: TrainingModelInfo detectorModelAvailable?: boolean detectorModelInfo?: TrainingModelInfo poseModelAvailable?: boolean poseModelInfo?: TrainingModelInfo videoMAEModelAvailable?: boolean videoMAEModelInfo?: 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 TrainingTargetKey = 'detector' | 'pose' | 'videomae' type TrainingStartMode = 'full' | 'custom' type TrainingStartOptions = { mode: TrainingStartMode targets?: TrainingTargetKey[] } type TrainingEstimateMode = 'auto' | 'eco' | 'balanced' | 'performance' | 'custom' type TrainingEstimateRuntime = { mode: TrainingEstimateMode modeLabel: string threadsLabel: string workers: number yoloBatchLabel: string lowPriority: boolean autoPause: boolean factor: number yoloFactor: number } type FeedbackFilter = 'all' | 'accepted' | 'corrected' | 'negative' const POSE_KEYPOINT_MIN_CONFIDENCE = 0.15 const POSE_PERSON_MIN_SCORE = 0.30 const POSE_RELIABLE_KEYPOINT_MIN_CONFIDENCE = 0.20 const POSE_RELIABLE_MIN_SCORE = 0.30 const POSE_RELIABLE_MIN_KEYPOINTS = 6 const POSE_RELIABLE_MIN_QUALITY = 0.45 const POSE_UNRELIABLE_COLOR = '#94a3b8' const POSE_PERSON_COLORS = ['#38bdf8', '#a78bfa', '#34d399', '#f59e0b', '#fb7185'] const POSE_SKELETON_EDGES: Array = [ ['left_ear', 'left_eye'], ['left_eye', 'nose'], ['nose', 'right_eye'], ['right_eye', 'right_ear'], ['left_shoulder', 'right_shoulder'], ['left_shoulder', 'left_elbow'], ['left_elbow', 'left_wrist'], ['right_shoulder', 'right_elbow'], ['right_elbow', 'right_wrist'], ['left_shoulder', 'left_hip'], ['right_shoulder', 'right_hip'], ['left_hip', 'right_hip'], ['left_hip', 'left_knee'], ['left_knee', 'left_ankle'], ['right_hip', 'right_knee'], ['right_knee', 'right_ankle'], ] const POSE_KEYPOINT_LABELS: Record = { nose: 'Nase', left_eye: 'L Auge', right_eye: 'R Auge', left_ear: 'L Ohr', right_ear: 'R Ohr', left_shoulder: 'L Schulter', right_shoulder: 'R Schulter', left_elbow: 'L Ellbogen', right_elbow: 'R Ellbogen', left_wrist: 'L Hand', right_wrist: 'R Hand', left_hip: 'L Huefte', right_hip: 'R Huefte', left_knee: 'L Knie', right_knee: 'R Knie', left_ankle: 'L Fuss', right_ankle: 'R Fuss', } function poseKeypointId(name?: string | null) { return String(name ?? '').trim().toLowerCase() } function poseKeypointLabel(name?: string | null) { const key = poseKeypointId(name) return POSE_KEYPOINT_LABELS[key] || key.replace(/_/g, ' ') || 'Punkt' } function isPoseKeypointVisible(point?: TrainingKeypoint | null): point is TrainingKeypoint { if (!point) return false const x = Number(point.x) const y = Number(point.y) const conf = Number(point.conf) return ( Number.isFinite(x) && Number.isFinite(y) && Number.isFinite(conf) && conf >= POSE_KEYPOINT_MIN_CONFIDENCE ) } function isPoseReliableKeypoint(point?: TrainingKeypoint | null): point is TrainingKeypoint { if (!point) return false const x = Number(point.x) const y = Number(point.y) const conf = Number(point.conf) return ( Number.isFinite(x) && Number.isFinite(y) && Number.isFinite(conf) && x >= 0 && x <= 1 && y >= 0 && y <= 1 && conf >= POSE_RELIABLE_KEYPOINT_MIN_CONFIDENCE ) } function posePersonVisibleKeypoints(person: TrainingPosePerson) { const direct = Number(person.visibleKeypoints) if (Number.isFinite(direct) && direct >= 0) { return Math.floor(direct) } return (person.keypoints ?? []).filter(isPoseReliableKeypoint).length } function posePersonQuality(person: TrainingPosePerson) { const direct = Number(person.quality) if (Number.isFinite(direct) && direct >= 0) { return clamp01(direct) } const reliableKeypoints = (person.keypoints ?? []).filter(isPoseReliableKeypoint) if (reliableKeypoints.length === 0) return 0 const totalConfidence = reliableKeypoints.reduce( (sum, point) => sum + clamp01(Number(point.conf)), 0 ) const coverage = clamp01(reliableKeypoints.length / 17) const averageConfidence = clamp01(totalConfidence / reliableKeypoints.length) return clamp01(coverage * 0.45 + averageConfidence * 0.55) } function isPosePersonReliable(person: TrainingPosePerson) { if (typeof person.reliable === 'boolean') { return person.reliable } return ( clamp01(Number(person.score)) >= POSE_RELIABLE_MIN_SCORE && posePersonVisibleKeypoints(person) >= POSE_RELIABLE_MIN_KEYPOINTS && posePersonQuality(person) >= POSE_RELIABLE_MIN_QUALITY ) } function hasVisiblePoseBox(person: TrainingPosePerson) { const box = person.box if (!box) return false const w = Number(box.w) const h = Number(box.h) return ( clamp01(Number(person.score)) >= POSE_PERSON_MIN_SCORE && Number.isFinite(w) && Number.isFinite(h) && w > 0 && h > 0 ) } function poseCoordPx(value: number, size: number) { return clamp01(Number(value)) * Math.max(0, Number(size) || 0) } function poseBoxPixelStyle(box: TrainingBox, layerWidth: number, layerHeight: number): CSSProperties { const x = clamp01(Number(box.x)) const y = clamp01(Number(box.y)) const w = Math.max(0, Math.min(1 - x, clamp01(Number(box.w)))) const h = Math.max(0, Math.min(1 - y, clamp01(Number(box.h)))) return { left: poseCoordPx(x, layerWidth), top: poseCoordPx(y, layerHeight), width: w * Math.max(0, Number(layerWidth) || 0), height: h * Math.max(0, Number(layerHeight) || 0), } } 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 roundedTrainingEstimateMs(ms: number) { const safe = Number(ms) if (!Number.isFinite(safe) || safe <= 0) return 0 const step = safe >= 60 * 60 * 1000 ? 5 * 60 * 1000 : safe >= 10 * 60 * 1000 ? 60 * 1000 : safe >= 2 * 60 * 1000 ? 30 * 1000 : 10 * 1000 return Math.max(step, Math.round(safe / step) * step) } function formatApproxTrainingDuration(ms: number) { const rounded = roundedTrainingEstimateMs(ms) if (rounded <= 0) return 'ca. —' return `ca. ${formatDuration(rounded)}` } function trainingHistoryDurationFloorMs(entries: TrainingHistoryEntry[]) { const durations = (entries ?? []) .filter((entry) => !String(entry.target ?? '').trim()) .map((entry) => Number(entry.durationMs ?? 0)) .filter((duration) => Number.isFinite(duration) && duration > 60 * 1000) .slice(0, 5) .sort((a, b) => a - b) if (durations.length === 0) return 0 return durations[Math.floor(durations.length / 2)] } function trainingHistoryTarget(value?: string | null): TrainingTargetKey | '' { switch (String(value ?? '').trim().toLowerCase()) { case 'detector': case 'yolo': case 'yolo26': case 'box': case 'boxes': return 'detector' case 'pose': case 'yolo26_pose': return 'pose' case 'videomae': case 'video_mae': case 'scene': case 'clip': return 'videomae' default: return '' } } function isTrainingTargetKey(value: unknown): value is TrainingTargetKey { switch (String(value ?? '').trim().toLowerCase()) { case 'detector': case 'pose': case 'videomae': return true default: return false } } function trainingTargetFromStageText( stage?: string | null, step?: string | null ): TrainingTargetKey | '' { const direct = trainingHistoryTarget(stage) if (direct) return direct const text = `${stage ?? ''} ${step ?? ''}`.toLowerCase() if (text.includes('videomae') || text.includes('clip')) return 'videomae' if (text.includes('pose')) return 'pose' if ( text.includes('detector') || text.includes('object') || text.includes('yolo26') || text.includes('yolo') ) { return 'detector' } return '' } function trainingTargetProgressWindow(target: TrainingTargetKey | '') { switch (target) { case 'detector': return { start: 15, end: 58 } case 'pose': return { start: 62, end: 82 } case 'videomae': return { start: 84, end: 98 } default: return { start: 0, end: 100 } } } function combineTrainingEtaMs(stageRemainingMs: number, epochRemainingMs: number) { const stage = Number(stageRemainingMs) const epoch = Number(epochRemainingMs) const hasStage = Number.isFinite(stage) && stage > 0 const hasEpoch = Number.isFinite(epoch) && epoch > 0 if (!hasStage && !hasEpoch) return 0 if (!hasStage) return epoch if (!hasEpoch) return stage const boundedEpoch = Math.max(stage * 0.35, Math.min(stage * 1.65, epoch)) return boundedEpoch * 0.7 + stage * 0.3 } function normalizeTrainingEstimateMode(value?: string | null): TrainingEstimateMode { switch (String(value ?? '').trim().toLowerCase()) { case 'eco': case 'schonend': case 'schonmodus': case 'powersave': case 'power-save': case 'power_save': return 'eco' case 'balanced': case 'ausgewogen': case 'normal': return 'balanced' case 'performance': case 'leistung': case 'fast': case 'schnell': return 'performance' case 'custom': case 'manual': case 'manuell': return 'custom' default: return 'auto' } } function trainingEstimateModeLabel(mode: TrainingEstimateMode) { switch (mode) { case 'eco': return 'Schonmodus' case 'balanced': return 'Ausgewogen' case 'performance': return 'Leistung' case 'custom': return 'Manuell' default: return 'Auto' } } function clampTrainingEstimate(value: number, minValue: number, maxValue: number) { if (!Number.isFinite(value)) return minValue return Math.max(minValue, Math.min(maxValue, value)) } function trainingEstimateInt(value: unknown, fallback: number, minValue: number, maxValue: number) { const raw = Math.floor(Number(value)) const safe = Number.isFinite(raw) ? raw : fallback return Math.max(minValue, Math.min(maxValue, safe)) } function trainingEstimateRuntimeFromSettings(settings?: RecorderSettingsState | null): TrainingEstimateRuntime { const mode = normalizeTrainingEstimateMode( settings?.trainingEffectiveMode ?? settings?.trainingPerformanceMode ) const cpuCores = trainingEstimateInt(settings?.trainingCpuCoreCount, 0, 0, 256) const rawThreads = trainingEstimateInt( settings?.trainingEffectiveCpuThreads ?? settings?.trainingCpuThreads, 0, 0, 256 ) const workers = trainingEstimateInt( settings?.trainingEffectiveWorkers ?? settings?.trainingWorkers, 1, 0, 32 ) const rawBatch = trainingEstimateInt( settings?.trainingEffectiveYoloBatchSize ?? settings?.trainingYoloBatchSize, 0, 0, 64 ) const lowPriority = Boolean( settings?.trainingEffectiveLowPriority ?? settings?.trainingLowPriority ) const autoPause = Boolean(settings?.trainingAutoPauseEnabled) const autoThreads = cpuCores > 0 ? Math.max(1, Math.min(16, Math.round(cpuCores * 0.75))) : 4 const effectiveThreads = rawThreads > 0 ? rawThreads : autoThreads const effectiveBatch = rawBatch > 0 ? rawBatch : 2 const modeFactor = mode === 'eco' ? 1.45 : mode === 'balanced' ? 1.15 : mode === 'performance' ? 0.92 : mode === 'custom' ? 1 : 1.08 const threadFactor = effectiveThreads <= 1 ? 1.7 : effectiveThreads === 2 ? 1.35 : effectiveThreads === 3 ? 1.18 : effectiveThreads >= 12 ? 0.86 : effectiveThreads >= 8 ? 0.9 : effectiveThreads >= 6 ? 0.96 : 1.04 const workerFactor = workers <= 0 ? 1.2 : workers === 1 ? 1.08 : workers >= 6 ? 0.98 : 1 const batchFactor = effectiveBatch <= 1 ? 1.25 : effectiveBatch === 2 ? 1.08 : effectiveBatch >= 12 ? 0.88 : effectiveBatch >= 8 ? 0.92 : effectiveBatch >= 4 ? 0.98 : 1 const lowPriorityFactor = lowPriority ? 1.12 : 1 const autoPauseFactor = autoPause ? 1.12 : 1 const corePenalty = cpuCores > 0 && effectiveThreads > cpuCores ? 1 + Math.min(0.3, (effectiveThreads - cpuCores) * 0.04) : 1 const factor = modeFactor * threadFactor * workerFactor * lowPriorityFactor * autoPauseFactor * corePenalty const yoloFactor = factor * batchFactor return { mode, modeLabel: trainingEstimateModeLabel(mode), threadsLabel: rawThreads > 0 ? String(rawThreads) : 'Auto', workers, yoloBatchLabel: rawBatch > 0 ? String(rawBatch) : 'Auto', lowPriority, autoPause, factor: Number.isFinite(factor) && factor > 0 ? factor : 1, yoloFactor: Number.isFinite(yoloFactor) && yoloFactor > 0 ? yoloFactor : 1, } } function trainingEstimateRuntimeText(runtime: TrainingEstimateRuntime) { const priority = runtime.lowPriority ? ' · niedrige Priorität' : '' const autoPause = runtime.autoPause ? ' · Auto-Pause' : '' return `${runtime.modeLabel} · ${runtime.threadsLabel} Threads · ${runtime.workers} Worker · Batch ${runtime.yoloBatchLabel}${priority}${autoPause}` } function trainingEstimateRuntimeFromHistory(entry: TrainingHistoryEntry) { return trainingEstimateRuntimeFromSettings({ trainingPerformanceMode: entry.performanceMode, trainingEffectiveMode: entry.performanceMode, trainingCpuCoreCount: entry.cpuCoreCount, trainingEffectiveCpuThreads: entry.cpuThreads, trainingEffectiveWorkers: entry.workers, trainingEffectiveYoloBatchSize: entry.yoloBatchSize, trainingEffectiveLowPriority: entry.lowPriority, trainingAutoPauseEnabled: entry.autoPauseEnabled, } as RecorderSettingsState) } function trainingRuntimeFactorForTarget(runtime: TrainingEstimateRuntime, target: TrainingTargetKey) { return target === 'videomae' ? runtime.factor : runtime.yoloFactor } function trainingHasTargetHistory(entries: TrainingHistoryEntry[], target: TrainingTargetKey) { return (entries ?? []).some((entry) => { const duration = Number(entry.durationMs ?? 0) if (!Number.isFinite(duration) || duration <= 60 * 1000) return false const status = String(entry.status ?? 'trained').trim().toLowerCase() if (status && status !== 'trained') return false return trainingHistoryTarget(entry.target) === target }) } function trainingFineTuneEstimateFactor( target: TrainingTargetKey, trainedModelExists: boolean, hasTargetHistory: boolean ) { if (!trainedModelExists) return 1 if (hasTargetHistory) { return 0.92 } return target === 'videomae' ? 0.85 : 0.75 } function estimateTrainingHistoryDurationMs( entries: TrainingHistoryEntry[], target: TrainingTargetKey, trainCount: number, valCount: number, eligibleCount: number, runtime: TrainingEstimateRuntime, epochs: number ) { const currentSamples = Math.max( 1, Math.max(0, Number(trainCount) || 0) + Math.max(0, Number(valCount) || 0), target === 'videomae' ? Math.max(0, Number(eligibleCount) || 0) : 0 ) const currentEpochs = trainingEstimateInt( target === 'videomae' ? epochs || 8 : epochs, target === 'videomae' ? 8 : 60, 1, target === 'videomae' ? 200 : 300 ) const currentRuntimeFactor = trainingRuntimeFactorForTarget(runtime, target) const estimates = (entries ?? []) .filter((entry) => { const duration = Number(entry.durationMs ?? 0) if (!Number.isFinite(duration) || duration <= 60 * 1000) return false const status = String(entry.status ?? 'trained').trim().toLowerCase() if (status && status !== 'trained') return false return trainingHistoryTarget(entry.target) === target }) .slice(0, 8) .map((entry) => { const duration = Number(entry.durationMs ?? 0) const historySamples = Math.max( 1, Math.max(0, Number(entry.trainSamples ?? 0)) + Math.max(0, Number(entry.valSamples ?? 0)) ) const historyEpochs = trainingEstimateInt( entry.epochs, target === 'videomae' ? 8 : 60, 1, target === 'videomae' ? 200 : 300 ) const historyRuntime = trainingEstimateRuntimeFromHistory(entry) const historyRuntimeFactor = trainingRuntimeFactorForTarget(historyRuntime, target) const sampleFactor = clampTrainingEstimate( Math.pow(currentSamples / historySamples, 0.85), 0.55, 2.8 ) const epochFactor = clampTrainingEstimate(currentEpochs / historyEpochs, 0.35, 4) const runtimeFactor = historyRuntimeFactor > 0 ? clampTrainingEstimate(currentRuntimeFactor / historyRuntimeFactor, 0.45, 2.4) : 1 return duration * sampleFactor * epochFactor * runtimeFactor }) .filter((value) => Number.isFinite(value) && value > 0) .sort((a, b) => a - b) if (estimates.length === 0) return 0 return estimates[Math.floor(estimates.length / 2)] } function estimateTrainingDurationMs( target: TrainingTargetKey, trainCount: number, valCount: number, eligibleCount = 0, historyEstimateMs = 0, runtimeFactor = 1, detectorEpochs = 60, fineTuneFactor = 1 ) { const samples = Math.max( 0, Number.isFinite(trainCount) ? trainCount : 0, ) + Math.max( 0, Number.isFinite(valCount) ? valCount : 0, ) const eligible = Math.max(0, Number.isFinite(eligibleCount) ? eligibleCount : 0) const historyEstimate = Number.isFinite(historyEstimateMs) ? Math.max(0, historyEstimateMs) : 0 const factor = Number.isFinite(runtimeFactor) && runtimeFactor > 0 ? runtimeFactor : 1 const fineTune = Number.isFinite(fineTuneFactor) && fineTuneFactor > 0 ? clampTrainingEstimate(fineTuneFactor, 0.5, 1) : 1 const epochs = trainingEstimateInt(detectorEpochs, 60, 1, 300) const yoloEpochFactor = Math.max(0.5, epochs / 60) switch (target) { case 'detector': { const base = Math.max( 12 * 60 * 1000, (10 * 60 * 1000 + samples * 8500) * yoloEpochFactor ) return Math.max(base * factor * fineTune, historyEstimate) } case 'pose': { const base = Math.max( 14 * 60 * 1000, (12 * 60 * 1000 + samples * 9500) * yoloEpochFactor ) return Math.max(base * factor * fineTune, historyEstimate) } case 'videomae': { const base = Math.max( 25 * 60 * 1000, 18 * 60 * 1000 + Math.max(samples, eligible) * 16000 ) return Math.max(base * factor * fineTune, historyEstimate) } default: return 0 } } 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 formatTrainingModelDetails(info?: TrainingModelInfo): string { 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(' | ') } function parseTrainingModelInfo(value: unknown): TrainingModelInfo | undefined { if (!value || typeof value !== 'object') return undefined const raw = value as Record return { trainedAt: typeof raw.trainedAt === 'string' ? raw.trainedAt : undefined, trainedAtMs: Number(raw.trainedAtMs ?? 0), epochs: Number(raw.epochs ?? 0), trainSamples: Number(raw.trainSamples ?? 0), valSamples: Number(raw.valSamples ?? 0), imgsz: Number(raw.imgsz ?? 0), device: typeof raw.device === 'string' ? raw.device : undefined, map50: Number(raw.map50 ?? 0), map5095: Number(raw.map5095 ?? 0), } } 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', } } const NO_SEX_POSITION_LABEL = 'keine' const NO_SEX_POSITION_ALIASES = new Set([ '', NO_SEX_POSITION_LABEL, ]) function normalizeSexPositionValue(value?: string | null) { const clean = String(value ?? '').trim() return NO_SEX_POSITION_ALIASES.has(clean.toLowerCase()) ? NO_SEX_POSITION_LABEL : clean } function isNoSexPositionValue(value?: string | null) { return NO_SEX_POSITION_ALIASES.has(String(value ?? '').trim().toLowerCase()) } function normalizeSexPositionValues(values?: string[]) { return uniqStrings( (values ?? []).map((value) => normalizeSexPositionValue(value)) ) } 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)) } // Pose-Modell: Sexposition als Sample-Label. if ( prediction.sexPosition && !isNoSexPositionValue(prediction.sexPosition) ) { 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: [NO_SEX_POSITION_LABEL], 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 boxVisualStateChanged(a: TrainingBox, b: TrainingBox) { return ( a.label !== b.label || !Object.is(a.score, b.score) || boxGeometryChanged(a, b) ) } 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: normalizeSexPositionValue(p?.sexPosition), 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 cloneCorrectionState(value: CorrectionState): CorrectionState { return { sexPosition: value.sexPosition, peoplePresent: [...value.peoplePresent], bodyPartsPresent: [...value.bodyPartsPresent], objectsPresent: [...value.objectsPresent], clothingPresent: [...value.clothingPresent], boxes: (value.boxes ?? []).map((box) => ({ ...box })), } } function correctionHasTrainablePositionOrBoxes(value: CorrectionState) { return ( (value.sexPosition && !isNoSexPositionValue(value.sexPosition)) || (value.boxes ?? []).some((box) => { const normalized = normalizeBox(box) return Boolean(normalized.label && normalized.w > 0 && normalized.h > 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?: { keepNoPositionFirst?: boolean }) { const list = [...(values ?? [])].sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }) ) if (!opts?.keepNoPositionFirst) return list return [ ...list.filter((x) => isNoSexPositionValue(x)), ...list.filter((x) => !isNoSexPositionValue(x)), ] } function sortTrainingLabels(input: Partial | null | undefined): TrainingLabels { return { people: sortLabelList(input?.people), sexPositions: sortLabelList( normalizeSexPositionValues(input?.sexPositions), { keepNoPositionFirst: true } ), bodyParts: sortLabelList(input?.bodyParts), objects: sortLabelList(input?.objects), clothing: sortLabelList(input?.clothing), } } function TrainingStageOverlay(props: { mode: 'training' | 'analysis' | 'saving' title?: string text?: string sourceFile?: string frameLabel?: string statusText?: string progress?: number backgroundUrl?: string visible?: boolean instantBackground?: boolean }) { const progress = clampPercent(props.progress ?? 0) const isTraining = props.mode === 'training' const isSaving = props.mode === 'saving' const visible = props.visible ?? true const [displayedBackgroundUrl, setDisplayedBackgroundUrl] = useState('') const [incomingBackgroundUrl, setIncomingBackgroundUrl] = useState('') const [incomingBackgroundVisible, setIncomingBackgroundVisible] = useState(false) const latestBackgroundUrlRef = useRef('') const backgroundFadeTimerRef = useRef(null) const backgroundFadeMs = props.instantBackground ? 200 : 500 const clearBackgroundFadeTimer = useCallback(() => { if (backgroundFadeTimerRef.current === null) return window.clearTimeout(backgroundFadeTimerRef.current) backgroundFadeTimerRef.current = null }, []) useEffect(() => { latestBackgroundUrlRef.current = props.backgroundUrl || '' }, [props.backgroundUrl]) useEffect(() => { return () => clearBackgroundFadeTimer() }, [clearBackgroundFadeTimer]) useEffect(() => { const nextUrl = props.backgroundUrl || '' if (!nextUrl) { clearBackgroundFadeTimer() setDisplayedBackgroundUrl('') setIncomingBackgroundUrl('') setIncomingBackgroundVisible(false) return } if (!displayedBackgroundUrl) { clearBackgroundFadeTimer() setDisplayedBackgroundUrl(nextUrl) setIncomingBackgroundUrl('') setIncomingBackgroundVisible(false) return } if (nextUrl === displayedBackgroundUrl || nextUrl === incomingBackgroundUrl) { return } clearBackgroundFadeTimer() setIncomingBackgroundUrl(nextUrl) setIncomingBackgroundVisible(false) }, [ clearBackgroundFadeTimer, displayedBackgroundUrl, incomingBackgroundUrl, props.backgroundUrl, ]) const finishIncomingBackground = useCallback((loadedUrl: string) => { if (!loadedUrl || latestBackgroundUrlRef.current !== loadedUrl) return setIncomingBackgroundVisible(true) clearBackgroundFadeTimer() backgroundFadeTimerRef.current = window.setTimeout(() => { backgroundFadeTimerRef.current = null if (latestBackgroundUrlRef.current !== loadedUrl) return setDisplayedBackgroundUrl(loadedUrl) setIncomingBackgroundUrl((current) => ( current === loadedUrl ? '' : current )) setIncomingBackgroundVisible(false) }, backgroundFadeMs) }, [backgroundFadeMs, clearBackgroundFadeTimer]) const hasBackground = Boolean(displayedBackgroundUrl || incomingBackgroundUrl) const backgroundTransitionClass = props.instantBackground ? 'transition-opacity duration-200 ease-out will-change-opacity motion-reduce:transition-none' : 'transition-opacity duration-500 ease-out will-change-opacity motion-reduce:transition-none' const title = props.title || ( isTraining ? 'Training läuft…' : isSaving ? 'Speichert…' : 'Analyse läuft…' ) const fallbackText = isTraining ? 'Bitte warten. Die Oberfläche ist währenddessen gesperrt.' : isSaving ? 'Feedback wird gespeichert. Bitte warten.' : 'Bild wird erstellt und analysiert. Bitte warten.' const sourceFile = String(props.sourceFile || '').trim() const frameLabel = String(props.frameLabel || '').trim() const statusText = String(props.statusText || props.text || fallbackText).trim() const hasStructuredDetails = Boolean(sourceFile || frameLabel) const primaryText = hasStructuredDetails ? statusText : title const secondaryText = hasStructuredDetails ? sourceFile : statusText return (
{hasBackground ? ( <> {displayedBackgroundUrl ? ( ) : null} {incomingBackgroundUrl ? ( finishIncomingBackground(incomingBackgroundUrl)} onError={() => { if (latestBackgroundUrlRef.current !== incomingBackgroundUrl) return setIncomingBackgroundUrl('') setIncomingBackgroundVisible(false) }} className={[ 'absolute inset-0 z-[1] h-full w-full object-contain blur-[1px]', backgroundTransitionClass, incomingBackgroundVisible ? 'opacity-80' : 'opacity-0', ].join(' ')} /> ) : null} ) : null}
{primaryText}
{secondaryText ? (
{secondaryText}
{frameLabel ? (
{frameLabel}
) : null}
) : frameLabel ? (
Frame
{frameLabel}
) : null}
{Math.round(progress)}%
) } function compactTrainingSourceFile(sourceFile: string) { let cleanSourceFile = String(sourceFile || '').trim() let frameLabel = '' const sourceFrameMatch = cleanSourceFile.match(/^(.*?)\s*\((\d+)\s*\/\s*(\d+)\)\s*$/) if (sourceFrameMatch) { cleanSourceFile = sourceFrameMatch[1].trim() frameLabel = `${sourceFrameMatch[2]} / ${sourceFrameMatch[3]}` } return { sourceFile: cleanSourceFile, frameLabel, } } function withTrainingFrameLabels(samples: TrainingSample[]) { if (samples.length <= 1) return samples return samples.map((sample, index) => { const sourceDetails = compactTrainingSourceFile(sample.sourceFile) const sourceFile = sourceDetails.sourceFile || String(sample.sourceFile || '').trim() if (!sourceFile || sourceDetails.frameLabel) { return sample } return { ...sample, sourceFile: `${sourceFile} (${index + 1} / ${samples.length})`, } }) } function formatTrainingStageStatus(value: string) { const text = String(value || '').trim() if (!text) return text return text.charAt(0).toLocaleUpperCase('de-DE') + text.slice(1) } function compactTrainingStageDetails(sourceFile: string, stepText: string) { const sourceDetails = compactTrainingSourceFile(sourceFile) let cleanSourceFile = sourceDetails.sourceFile let frameLabel = sourceDetails.frameLabel let statusText = String(stepText || '').trim() const stepFrameMatch = statusText.match(/^Frame\s+(\d+)\s*\/\s*(\d+)\s+(.+)$/i) if (stepFrameMatch) { if (!frameLabel) { frameLabel = `${stepFrameMatch[1]} / ${stepFrameMatch[2]}` } statusText = stepFrameMatch[3].trim() } return { sourceFile: cleanSourceFile, frameLabel, statusText: formatTrainingStageStatus(statusText || 'Bild wird geladen…'), } } 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 activeCounts?: 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 activeCount = Math.max(0, Math.floor(Number(props.activeCounts?.[value] ?? 0))) const active = props.selected.includes(value) || activeCount > 0 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 = normalizeSexPositionValue(props.value) const selectedItem = getSegmentLabelItem(currentValue) const SelectedIcon = selectedItem.icon const shown = props.expanded const hasSelection = !isNoSexPositionValue(currentValue) 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 activeCounts?: 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 countedActiveItems = props.activeCounts ? props.values.reduce( (sum, value) => sum + Math.max(0, Math.floor(Number(props.activeCounts?.[value] ?? 0))), 0 ) : null const activeCount = countedActiveItems !== null ? Math.max(countedActiveItems, hasDrawLabelInSection ? 1 : 0) : 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 deletingTrainingData?: boolean deleteTrainingDataDisabled?: boolean onDeleteTrainingData?: () => void | Promise }) { 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 detectorModelAvailable = Boolean( stats?.detectorModelAvailable ?? stats?.modelAvailable ) const detectorModelInfo = stats?.detectorModelInfo ?? stats?.modelInfo const poseModelAvailable = Boolean(stats?.poseModelAvailable) const poseModelInfo = stats?.poseModelInfo const videoMAEModelAvailable = Boolean(stats?.videoMAEModelAvailable) const videoMAEModelInfo = stats?.videoMAEModelInfo const modelTrainedAtLabel = formatModelTrainedAt(detectorModelInfo) const modelMap50Label = formatMapPercent(detectorModelInfo?.map50) const modelMap5095Label = formatMapPercent(detectorModelInfo?.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 modelCards = [ { key: 'detector', title: 'Detector', available: detectorModelAvailable, info: detectorModelInfo, }, { key: 'pose', title: 'Pose', available: poseModelAvailable, info: poseModelInfo, }, { key: 'videomae', title: 'VideoMAE', available: videoMAEModelAvailable, info: videoMAEModelInfo, }, ].map((model) => ({ ...model, trainedAtLabel: formatModelTrainedAt(model.info), map50Label: formatMapPercent(model.info?.map50), map5095Label: formatMapPercent(model.info?.map5095), details: model.key === 'detector' ? modelInfoDetails : formatTrainingModelDetails(model.info), })) const availableModelCount = modelCards.filter((model) => model.available).length const totalModelCount = modelCards.length const availableModelNames = modelCards .filter((model) => model.available) .map((model) => model.title) const modelSummaryLabel = availableModelCount === totalModelCount ? 'Alle Modelle verfuegbar' : availableModelCount > 0 ? `${availableModelNames.join(' + ')} verfuegbar` : 'Noch kein trainiertes Modell verfuegbar' 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 */}
{availableModelCount > 0 ? 'Modell verfügbar' : 'Noch kein Modell'}
{totalFeedback} Feedback · {boxCount} Boxen · {sampleCount} Samples
{confidencePercent(overallConfidence)}
Feedback
{totalFeedback}
Passt
{acceptedCount}
Korr.
{correctedCount}
Negativ
{negativeCount}
Boxen
{boxCount}
{modelCards.map((model) => (
{model.title}
{model.available ? 'bereit' : 'fehlt'} {model.map50Label ? ` | ${model.map50Label}` : ''}
))}
{/* 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
{availableModelCount > 0 ? 'Trainiertes Modell verfügbar' : 'Noch kein trainiertes Modell verfügbar'}
{detectorModelAvailable && modelTrainedAtLabel ? (
Version vom {modelTrainedAtLabel}
{modelMap50Label ? (
Qualität (mAP50{modelMap5095Label ? ' / 50-95' : ''}) {modelMap50Label} {modelMap5095Label ? ` / ${modelMap5095Label}` : ''}
) : null} {modelInfoDetails ? (
{modelInfoDetails}
) : null}
) : (
{availableModelCount > 0 ? 'Die aktuellen Trainingsdaten können bereits von einem Modell genutzt werden.' : 'Sammle weiter Feedback und starte anschließend das Training.'}
)}
{modelSummaryLabel} - {availableModelCount}/{totalModelCount}
{modelCards.map((model) => (
{model.title} {model.available ? 'bereit' : 'fehlt'}
{model.available && model.trainedAtLabel ? (
Version {model.trainedAtLabel}
{model.map50Label ? (
mAP50{model.map5095Label ? ' / 50-95' : ''} {model.map50Label} {model.map5095Label ? ` / ${model.map5095Label}` : ''}
) : null} {model.details ? (
{model.details}
) : null}
) : (
{model.available ? 'Modell gefunden, Details fehlen.' : 'Noch nicht trainiert.'}
)}
))}
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[] = [] const target = trainingHistoryTarget(entry.target) if (target === 'detector') meta.push('Detector') if (target === 'pose') meta.push('Pose') if (target === 'videomae') meta.push('VideoMAE') 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 ( ) })}
Trainingsdaten löschen
Entfernt Feedback, Frames, Samples und Trainingsdaten. Diese Aktion kann nicht rückgängig gemacht werden.
)}
) } 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: NO_SEX_POSITION_LABEL, peoplePresent: [], bodyPartsPresent: [], objectsPresent: [], clothingPresent: [], boxes: [], } } if (item.correction) { return { ...item.correction, sexPosition: normalizeSexPositionValue(item.correction.sexPosition), } } 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' const TRAINING_PENDING_IMPORT_VIDEO_STORAGE_KEY = 'training:pending-import-video' const TRAINING_ACTIVE_IMPORT_VIDEO_STORAGE_KEY = 'training:active-import-video' const TRAINING_ACTIVE_NEXT_STORAGE_KEY = 'training:active-next' const ALL_TRAINING_TARGETS: TrainingTargetKey[] = ['detector', 'pose', 'videomae'] export default function TrainingTab(props: { active?: boolean onTrainingRunningChange?: (running: boolean) => void onImageExpandedChange?: (expanded: boolean) => void }) { const tabActive = props.active ?? true const [labels, setLabels] = useState(emptyLabels) const [sample, setSample] = useState(null) const sampleRef = useRef(null) const [correction, setCorrection] = useState(() => predictionToCorrection(null)) const [hasManualCorrection, setHasManualCorrection] = useState(false) const [loading, setLoading] = useState(false) const [analysisProgress, setAnalysisProgress] = useState(0) const [analysisStep, setAnalysisStep] = useState('') const [analysisSourceFile, setAnalysisSourceFile] = useState('') const [saving, setSaving] = useState(false) const [savingOverlayText, setSavingOverlayText] = useState('') 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 [trainingStartModalOpen, setTrainingStartModalOpen] = useState(false) const [trainingStartMode, setTrainingStartMode] = useState('full') const [trainingStartTargets, setTrainingStartTargets] = useState(ALL_TRAINING_TARGETS) const [activeTrainingTargets, setActiveTrainingTargets] = useState([]) 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 [trainingEstimateSettings, setTrainingEstimateSettings] = useState(null) 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 feedbackEditReturnSampleRef = useRef(null) 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 initializedRef = useRef(false) const initRunIdRef = useRef(0) 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 [showPoseSkeleton, setShowPoseSkeleton] = useState(true) const [frameNaturalSize, setFrameNaturalSize] = useState<{ width: number height: number } | null>(null) const [loadingPreviewUrl, setLoadingPreviewUrl] = useState('') const [loadingPreviewFallbackUrl, setLoadingPreviewFallbackUrl] = 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 activeImageContentRectRef = useRef(null) 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) => { const currentSample = sampleRef.current if ( !editingFeedback && !feedbackEditReturnSampleRef.current && currentSample && currentSample.sampleId !== item.sampleId ) { feedbackEditReturnSampleRef.current = { sample: currentSample, correction: cloneCorrectionState(correctionRef.current), manualCorrection: hasManualCorrectionRef.current, } } const nextSample = annotationToTrainingSample(item) const nextCorrection = annotationToCorrectionState(item) const nextManualCorrection = !item.accepted sampleRef.current = nextSample correctionRef.current = nextCorrection hasManualCorrectionRef.current = nextManualCorrection setSample(nextSample) setCorrection(nextCorrection) setHasManualCorrection(nextManualCorrection) setEditingFeedback({ sampleId: item.sampleId, answeredAt: item.answeredAt, }) setDrawingBox(null) setBoxInteraction(null) setTouchMagnifier(null) setBoxLabel('') setActiveBoxIndex(null) setFeedbackModalOpen(false) window.requestAnimationFrame(() => { mobileLabelsScrollRef.current?.scrollIntoView({ block: 'start', behavior: 'smooth', }) }) }, [editingFeedback]) 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() const nextStyle: CSSProperties = { left: contentRect.left - boxRect.left, top: contentRect.top - boxRect.top, width: contentRect.width, height: contentRect.height, } setImageLayerStyle((prev) => { if ( prev && Number(prev.left) === nextStyle.left && Number(prev.top) === nextStyle.top && Number(prev.width) === nextStyle.width && Number(prev.height) === nextStyle.height ) { return prev } return nextStyle }) }, [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 nextAnalysisInFlightRequestIdRef = useRef(null) const epochTimingRef = useRef<{ target: string firstEpochAt: number lastEpoch: number lastAt: number }>({ target: '', 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 drawingBoxRef = useRef(null) const boxInteractionRef = useRef(null) const correctionRef = useRef(correction) const hasManualCorrectionRef = useRef(hasManualCorrection) const latestGestureBoxRef = useRef(null) const pendingPointerMoveRef = useRef<{ clientX: number; clientY: number } | null>(null) const pointerMoveRafRef = useRef(null) 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 sectionEl = mobileSectionRefs.current[key] if (!sectionEl) return sectionEl.scrollIntoView({ block: 'start', 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(() => { sampleRef.current = sample }, [sample]) 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(() => { drawingBoxRef.current = drawingBox }, [drawingBox]) useEffect(() => { boxInteractionRef.current = boxInteraction }, [boxInteraction]) useEffect(() => { correctionRef.current = correction }, [correction]) useEffect(() => { hasManualCorrectionRef.current = hasManualCorrection }, [hasManualCorrection]) 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 hasTrainableFeedbackContent = useMemo( () => correctionHasTrainablePositionOrBoxes(correction), [correction] ) const willSaveAsNegative = Boolean(sample) && !hasTrainableFeedbackContent const visibleBoxes = [ ...correctionBoxes.map((box, index) => ({ box, index, isDraft: false })), ...(drawingBox ? [{ box: drawingBox, index: -1, isDraft: true }] : []), ] const posePersons = useMemo(() => { return (sample?.prediction.persons ?? []).filter((person) => hasVisiblePoseBox(person) || person.keypoints?.some(isPoseKeypointVisible) ) }, [sample?.prediction.persons]) const hasPosePersons = posePersons.length > 0 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 peopleBoxCounts = useMemo(() => { const counts = new Map() for (const box of correctionBoxes) { const cleanLabel = String(box.label || '').trim() if (!labels.people.includes(cleanLabel)) continue counts.set(cleanLabel, (counts.get(cleanLabel) ?? 0) + 1) } return Object.fromEntries(counts) }, [correctionBoxes, 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('') setLoadingPreviewFallbackUrl('') 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) }, []) const applyTrainingAnalysisEvent = useCallback((raw: any, opts?: { requireActiveRequest?: boolean }) => { const data = raw?.analysis || raw if (!data) return false const requestId = String( data?.requestId || data?.analysisRequestId || '' ).trim() const activeRequestId = activeAnalysisRequestIdRef.current if (opts?.requireActiveRequest && (!activeRequestId || requestId !== activeRequestId)) { return false } const scope = String(data?.scope || '').trim() if (scope && scope !== 'training') { return false } const running = Boolean(data?.running) if (running) { loadingRef.current = true setLoading(true) } const sourceFile = String(data?.sourceFile || '').trim() if (sourceFile) { setAnalysisSourceFile(sourceFile) } const previewUrl = String(data?.previewUrl || '').trim() if (previewUrl) { setLoadingPreviewCandidate(previewUrl) } const message = String( data?.message || data?.step || data?.title || '' ).trim() if (message) { setAnalysisStep(message) } 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) 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 (nextProgress !== null) { setAnalysisProgress((prev) => running ? Math.max(prev, clampPercent(nextProgress)) : clampPercent(nextProgress) ) } return true }, [setLoadingPreviewCandidate]) 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 serverTrainingProgress = trainingRunning ? trainingStatus?.training?.progress ?? trainingProgress : trainingProgress const shownTrainingStep = trainingRunning ? trainingStatus?.training?.step || trainingStep || 'Training läuft…' : trainingStep const trainingHistoryFloorMs = useMemo( () => trainingHistoryDurationFloorMs(trainingHistory), [trainingHistory] ) const trainingEstimateRuntime = useMemo( () => trainingEstimateRuntimeFromSettings(trainingEstimateSettings), [trainingEstimateSettings] ) const trainingEstimateRuntimeLabel = useMemo( () => trainingEstimateRuntimeText(trainingEstimateRuntime), [trainingEstimateRuntime] ) const trainingEstimateDetectorEpochs = useMemo( () => trainingEstimateInt(trainingEstimateSettings?.trainingDetectorEpochs, 60, 1, 300), [trainingEstimateSettings?.trainingDetectorEpochs] ) const trainingStartOptions = useMemo(() => { const feedbackReady = feedbackCount >= requiredCount const detector = trainingStatus?.detector const pose = trainingStatus?.pose const videoMAE = trainingStatus?.videoMAE const detectorReady = feedbackReady && Boolean(detector?.dataReady) const poseReady = feedbackReady && Boolean(pose?.dataReady) const videoMAEReady = feedbackReady && Boolean(videoMAE?.dataReady) const detectorHasHistory = trainingHasTargetHistory(trainingHistory, 'detector') const poseHasHistory = trainingHasTargetHistory(trainingHistory, 'pose') const videoMAEHasHistory = trainingHasTargetHistory(trainingHistory, 'videomae') const detectorHistoryEstimateMs = estimateTrainingHistoryDurationMs( trainingHistory, 'detector', Number(detector?.trainCount ?? 0), Number(detector?.valCount ?? 0), 0, trainingEstimateRuntime, trainingEstimateDetectorEpochs ) const poseHistoryEstimateMs = estimateTrainingHistoryDurationMs( trainingHistory, 'pose', Number(pose?.trainCount ?? 0), Number(pose?.valCount ?? 0), 0, trainingEstimateRuntime, trainingEstimateDetectorEpochs ) const videoMAEHistoryEstimateMs = estimateTrainingHistoryDurationMs( trainingHistory, 'videomae', Number(videoMAE?.trainCount ?? 0), Number(videoMAE?.valCount ?? 0), Number(videoMAE?.eligibleCount ?? 0), trainingEstimateRuntime, 8 ) const detectorFineTuneFactor = trainingFineTuneEstimateFactor( 'detector', Boolean(detector?.trainedModelExists), detectorHasHistory ) const poseFineTuneFactor = trainingFineTuneEstimateFactor( 'pose', Boolean(pose?.trainedModelExists), poseHasHistory ) const videoMAEFineTuneFactor = trainingFineTuneEstimateFactor( 'videomae', Boolean(videoMAE?.trainedModelExists), videoMAEHasHistory ) const detectorFallbackHistoryMs = detectorHistoryEstimateMs || (trainingHistoryFloorMs > 0 ? trainingHistoryFloorMs * detectorFineTuneFactor : 0) const poseFallbackHistoryMs = poseHistoryEstimateMs || (trainingHistoryFloorMs > 0 ? trainingHistoryFloorMs * poseFineTuneFactor : 0) const videoMAEFallbackHistoryMs = videoMAEHistoryEstimateMs || (trainingHistoryFloorMs > 0 ? trainingHistoryFloorMs * videoMAEFineTuneFactor : 0) const detectorEstimateMs = estimateTrainingDurationMs( 'detector', Number(detector?.trainCount ?? 0), Number(detector?.valCount ?? 0), 0, detectorFallbackHistoryMs, trainingEstimateRuntime.yoloFactor, trainingEstimateDetectorEpochs, detectorFineTuneFactor ) const poseEstimateMs = estimateTrainingDurationMs( 'pose', Number(pose?.trainCount ?? 0), Number(pose?.valCount ?? 0), 0, poseFallbackHistoryMs, trainingEstimateRuntime.yoloFactor, trainingEstimateDetectorEpochs, poseFineTuneFactor ) const videoMAEEstimateMs = estimateTrainingDurationMs( 'videomae', Number(videoMAE?.trainCount ?? 0), Number(videoMAE?.valCount ?? 0), Number(videoMAE?.eligibleCount ?? 0), videoMAEFallbackHistoryMs, trainingEstimateRuntime.factor, 60, videoMAEFineTuneFactor ) return [ { key: 'detector' as const, label: 'YOLO26 Detector', icon: RectangleGroupIcon, description: 'Boxen, Personen, Körperteile, Objekte und Kleidung.', ready: detectorReady, estimateMs: detectorEstimateMs, estimateText: formatApproxTrainingDuration(detectorEstimateMs), detail: `Train ${Number(detector?.trainCount ?? 0)}/${Number(detector?.requiredTrain ?? 20)}, Val ${Number(detector?.valCount ?? 0)}/${Number(detector?.requiredVal ?? 3)}, Positiv ${Number(detector?.positiveTrainCount ?? 0)}/${Number(detector?.positiveValCount ?? 0)}${detector?.trainedModelExists ? ', Fine-Tuning' : ''}`, blockedText: !feedbackReady ? `Noch zu wenig Feedback: ${feedbackCount}/${requiredCount}.` : 'Detector-Datensatz ist noch nicht bereit.', }, { key: 'pose' as const, label: 'YOLO26 Pose', icon: UserGroupIcon, description: 'Keypoints und Positions-Kontext aus Skeleton-Beispielen.', ready: poseReady, estimateMs: poseEstimateMs, estimateText: formatApproxTrainingDuration(poseEstimateMs), detail: `Train ${Number(pose?.trainCount ?? 0)}/${Number(pose?.requiredTrain ?? 20)}, Val ${Number(pose?.valCount ?? 0)}/${Number(pose?.requiredVal ?? 3)}${pose?.trainedModelExists ? ', Fine-Tuning' : ''}`, blockedText: !feedbackReady ? `Noch zu wenig Feedback: ${feedbackCount}/${requiredCount}.` : 'Pose-Datensatz ist noch nicht bereit.', }, { key: 'videomae' as const, label: 'VideoMAE Clip-Analyse', icon: VideoCameraIcon, description: 'Clip-basierte Positionsanalyse aus mehreren Video-Frames.', ready: videoMAEReady, estimateMs: videoMAEEstimateMs, estimateText: formatApproxTrainingDuration(videoMAEEstimateMs), detail: `Eligible ${Number(videoMAE?.eligibleCount ?? 0)}, Train ${Number(videoMAE?.trainCount ?? 0)}/${Number(videoMAE?.requiredTrain ?? 40)}, Val ${Number(videoMAE?.valCount ?? 0)}/${Number(videoMAE?.requiredVal ?? 5)}${videoMAE?.trainedModelExists ? ', Fine-Tuning' : ''}`, blockedText: !feedbackReady ? `Noch zu wenig Feedback: ${feedbackCount}/${requiredCount}.` : 'VideoMAE-Datensatz ist noch nicht bereit.', }, ] }, [ feedbackCount, requiredCount, trainingStatus?.detector, trainingStatus?.pose, trainingStatus?.videoMAE, trainingHistory, trainingHistoryFloorMs, trainingEstimateRuntime.factor, trainingEstimateRuntime.yoloFactor, trainingEstimateDetectorEpochs, ]) const selectableTrainingTargets = useMemo( () => trainingStartOptions .filter((option) => option.ready) .map((option) => option.key), [trainingStartOptions] ) const selectedTrainingTargets = useMemo( () => trainingStartTargets.filter((key) => selectableTrainingTargets.includes(key)), [selectableTrainingTargets, trainingStartTargets] ) const canConfirmTrainingStart = trainingStartMode === 'full' ? canStartTraining : selectedTrainingTargets.length > 0 const plannedTrainingTargets = useMemo( () => trainingStartMode === 'full' ? selectableTrainingTargets : selectedTrainingTargets, [selectableTrainingTargets, selectedTrainingTargets, trainingStartMode] ) const trainingStartTotalEstimateMs = useMemo(() => { const planned = new Set(plannedTrainingTargets) const estimate = trainingStartOptions.reduce((sum, option) => ( planned.has(option.key) ? sum + Number(option.estimateMs ?? 0) : sum ), 0) return trainingHistoryFloorMs > 0 && plannedTrainingTargets.length > 1 ? Math.max(estimate, trainingHistoryFloorMs) : estimate }, [plannedTrainingTargets, trainingHistoryFloorMs, trainingStartOptions]) const trainingStartTotalEstimateText = plannedTrainingTargets.length > 0 ? formatApproxTrainingDuration(trainingStartTotalEstimateMs) : 'ca. —' const trainingEstimateByTarget = useMemo(() => { const estimates: Record = { detector: 0, pose: 0, videomae: 0, } for (const option of trainingStartOptions) { estimates[option.key] = Math.max(0, Number(option.estimateMs ?? 0) || 0) } return estimates }, [trainingStartOptions]) const activeTrainingTarget = useMemo( () => trainingTargetFromStageText( trainingStatus?.training?.stage, trainingStatus?.training?.step ), [ trainingStatus?.training?.stage, trainingStatus?.training?.step, ] ) const effectiveTrainingTargets = useMemo(() => { const selected = activeTrainingTargets.filter(isTrainingTargetKey) if (selected.length > 0) return selected if (selectableTrainingTargets.length > 0) { return selectableTrainingTargets } if (activeTrainingTarget) { const activeIndex = ALL_TRAINING_TARGETS.indexOf(activeTrainingTarget) return activeIndex >= 0 ? ALL_TRAINING_TARGETS.slice(activeIndex) : [activeTrainingTarget] } return ALL_TRAINING_TARGETS }, [ activeTrainingTarget, activeTrainingTargets, selectableTrainingTargets, ]) const currentTrainingTarget = useMemo(() => { if (!trainingRunning || effectiveTrainingTargets.length === 0) return '' const progress = clampPercent(Number(serverTrainingProgress) || 0) const activeIndex = activeTrainingTarget ? effectiveTrainingTargets.indexOf(activeTrainingTarget) : -1 if (activeIndex >= 0) { const window = trainingTargetProgressWindow(activeTrainingTarget) if (progress < window.end || activeIndex === effectiveTrainingTargets.length - 1) { return activeTrainingTarget } } return effectiveTrainingTargets.find((target) => { const window = trainingTargetProgressWindow(target) return progress < window.end }) ?? effectiveTrainingTargets[effectiveTrainingTargets.length - 1] ?? '' }, [ activeTrainingTarget, effectiveTrainingTargets, serverTrainingProgress, trainingRunning, ]) const currentTrainingTargetProgress = useMemo(() => { if (!currentTrainingTarget) return 0 const window = trainingTargetProgressWindow(currentTrainingTarget) const span = Math.max(1, window.end - window.start) const progress = clampPercent(Number(serverTrainingProgress) || 0) return clamp01((progress - window.start) / span) }, [ currentTrainingTarget, serverTrainingProgress, ]) const estimatedTrainingProgress = useMemo(() => { if (!trainingRunning || effectiveTrainingTargets.length === 0) return 0 const totalEstimateMs = effectiveTrainingTargets.reduce( (sum, target) => sum + Math.max(0, trainingEstimateByTarget[target] || 0), 0 ) if (totalEstimateMs <= 0) return 0 const currentTarget = currentTrainingTarget if (!currentTarget) return 0 const currentIndex = effectiveTrainingTargets.indexOf(currentTarget) if (currentIndex < 0) return 0 const completedMs = effectiveTrainingTargets .slice(0, currentIndex) .reduce((sum, target) => sum + Math.max(0, trainingEstimateByTarget[target] || 0), 0) const currentMs = Math.max(0, trainingEstimateByTarget[currentTarget] || 0) const progressMs = completedMs + currentMs * currentTrainingTargetProgress return clampPercent((progressMs / totalEstimateMs) * 100) }, [ currentTrainingTarget, currentTrainingTargetProgress, effectiveTrainingTargets, trainingEstimateByTarget, trainingRunning, ]) const serverTrainingProgressPercent = clampPercent(Number(serverTrainingProgress) || 0) const usesSelectedTargetProgress = activeTrainingTargets.length > 0 && activeTrainingTargets.length < ALL_TRAINING_TARGETS.length const shownTrainingProgress = trainingRunning ? usesSelectedTargetProgress ? Math.max( Math.min(serverTrainingProgressPercent, 6), estimatedTrainingProgress ) : Math.max(serverTrainingProgressPercent, estimatedTrainingProgress) : serverTrainingProgress 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 const videoMAE = data.videoMAE || data.videomae || data.scene || 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, trainedModelExists: Boolean(data.detector.trainedModelExists ?? data.detector.modelExists), trainedModelPath: data.detector.trainedModelPath, source: data.detector.source || data.detector.modelSource, } : prev?.detector, pose: data.pose ? { trainCount: Number(data.pose.trainCount ?? 0), valCount: Number(data.pose.valCount ?? 0), requiredTrain: Number(data.pose.requiredTrain ?? 20), requiredVal: Number(data.pose.requiredVal ?? 3), datasetReady: Boolean(data.pose.datasetReady), dataReady: Boolean(data.pose.dataReady), modelExists: Boolean(data.pose.modelExists), modelPath: data.pose.modelPath, trainedModelExists: Boolean(data.pose.trainedModelExists ?? data.pose.modelExists), trainedModelPath: data.pose.trainedModelPath, source: data.pose.source || data.pose.modelSource, } : prev?.pose, videoMAE: videoMAE ? { eligibleCount: Number(videoMAE.eligibleCount ?? 0), trainCount: Number(videoMAE.trainCount ?? 0), valCount: Number(videoMAE.valCount ?? 0), requiredTrain: Number(videoMAE.requiredTrain ?? videoMAE.requiredCount ?? 40), requiredVal: Number(videoMAE.requiredVal ?? 5), datasetReady: Boolean(videoMAE.datasetReady), dataReady: Boolean(videoMAE.dataReady), modelExists: Boolean(videoMAE.modelExists ?? videoMAE.modelReady), modelPath: videoMAE.modelPath, trainedModelExists: Boolean( videoMAE.trainedModelExists ?? videoMAE.trainedModelReady ?? videoMAE.modelExists ?? videoMAE.modelReady ), trainedModelPath: videoMAE.trainedModelPath, source: videoMAE.source || videoMAE.modelSource, } : prev?.videoMAE, 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 ?? ''), paused: Boolean(job.paused), pauseReason: String(job.pauseReason ?? ''), cpuPercent: Number(job.cpuPercent ?? 0), temperatureC: Number(job.temperatureC ?? 0), 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; correction?: CorrectionState } ) => { const nextCorrection = opts?.correction ? cloneCorrectionState(opts.correction) : predictionToCorrection(nextSample) setDrawingBox(null) setBoxInteraction(null) setTouchMagnifier(null) setBoxLabel('') setActiveBoxIndex(null) setMobilePanel(trainingRunningRef.current ? 'training' : 'labels') window.requestAnimationFrame(() => { mobileLabelsScrollRef.current?.scrollIntoView({ block: 'start', behavior: 'smooth', }) }) sampleRef.current = nextSample correctionRef.current = nextCorrection hasManualCorrectionRef.current = Boolean(opts?.manualCorrection) setSample(nextSample) setCorrection(nextCorrection) setHasManualCorrection(Boolean(opts?.manualCorrection)) const initiallyExpandedSection: CorrectionSectionKey | null = nextCorrection.sexPosition && !isNoSexPositionValue(nextCorrection.sexPosition) ? '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 setQueuedTrainingSamples = useCallback((nextQueue: QueuedTrainingSample[]) => { importedSampleQueueRef.current = nextQueue setImportedSampleQueue(nextQueue) }, []) const currentSampleToQueuedItem = useCallback((): QueuedTrainingSample | null => { const currentSample = sampleRef.current if (!currentSample) { return null } return { sample: currentSample, correction: cloneCorrectionState(correctionRef.current), manualCorrection: hasManualCorrectionRef.current, } }, []) const loadQueuedTrainingSample = useCallback((item: QueuedTrainingSample) => { loadTrainingSampleIntoTab(item.sample, { correction: item.correction, manualCorrection: item.manualCorrection, }) }, [loadTrainingSampleIntoTab]) const loadNextImportedQueuedSample = useCallback(() => { const [nextItem, ...rest] = importedSampleQueueRef.current if (!nextItem) { return false } setQueuedTrainingSamples(rest) loadQueuedTrainingSample(nextItem) return true }, [loadQueuedTrainingSample, setQueuedTrainingSamples]) const deferCurrentSampleToQueueEnd = useCallback(() => { const currentItem = currentSampleToQueuedItem() if (!currentItem) { return false } setQueuedTrainingSamples([...importedSampleQueueRef.current, currentItem]) return true }, [currentSampleToQueuedItem, setQueuedTrainingSamples]) const loadPriorityTrainingSamples = useCallback(( prioritySamples: TrainingSample[], opts?: { deferCurrentSampleToQueueEnd?: boolean } ) => { const priorityItems = prioritySamples.map((prioritySample) => ({ sample: prioritySample, })) if (priorityItems.length === 0) { return false } const currentItem = opts?.deferCurrentSampleToQueueEnd ? currentSampleToQueuedItem() : null const nextQueue = [ ...priorityItems, ...importedSampleQueueRef.current, ...(currentItem ? [currentItem] : []), ] const [nextItem, ...rest] = nextQueue if (!nextItem) { return false } setQueuedTrainingSamples(rest) loadQueuedTrainingSample(nextItem) return true }, [currentSampleToQueuedItem, loadQueuedTrainingSample, setQueuedTrainingSamples]) const completeNextFromData = useCallback((data: any, opts?: { deferCurrentSampleToQueueEnd?: boolean }) => { const nextSample = data?.sample || ( data?.sampleId && data?.frameUrl ? data as TrainingSample : null ) if (!nextSample) { throw new Error(backendText(data, 'Es wurde kein Trainingsbild erzeugt.')) } setAnalysisProgress(92) setAnalysisStep('Analyse-Ergebnis wird übernommen…') if (opts?.deferCurrentSampleToQueueEnd) { deferCurrentSampleToQueueEnd() } loadTrainingSampleIntoTab(nextSample as TrainingSample) setImageReloadKey((value) => value + 1) return true }, [deferCurrentSampleToQueueEnd, loadTrainingSampleIntoTab]) const waitForNextResult = useCallback(async ( requestId: string, opts?: { deferCurrentSampleToQueueEnd?: boolean } ) => { const id = String(requestId || '').trim() if (!id) throw new Error('requestId fehlt.') for (;;) { const res = await fetch( `/api/training/next/status?requestId=${encodeURIComponent(id)}`, { cache: 'no-store' } ) const data = await res.json().catch(() => null) if (data?.analysis) { applyTrainingAnalysisEvent(data.analysis) } if (res.status === 202 || data?.running) { await new Promise((resolve) => window.setTimeout(resolve, 800)) continue } if (!res.ok || !data?.ok) { throw new Error(backendText(data, `HTTP ${res.status}`)) } return completeNextFromData(data, opts) } }, [applyTrainingAnalysisEvent, completeNextFromData]) const loadNext = useCallback(async (opts?: { forceNew?: boolean refreshPrediction?: boolean preserveNotice?: boolean mode?: TrainingSampleMode previewUrl?: string deferCurrentSampleToQueueEnd?: boolean }) => { const requestId = makeRequestId() activeAnalysisRequestIdRef.current = requestId nextAnalysisInFlightRequestIdRef.current = requestId const isCurrentRequest = () => activeAnalysisRequestIdRef.current === requestId const mode = opts?.mode ?? trainingSampleModeRef.current const uncertainMode = mode === 'uncertain' && !opts?.refreshPrediction const previewUrl = String(opts?.previewUrl ?? '').trim() setLoadingPreviewFallbackUrl(previewUrl) setLoadingPreviewCandidate(previewUrl) loadingRef.current = true setLoading(true) setAnalysisSourceFile('') 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) } let keepActiveJob = false let completed = false try { const params = new URLSearchParams() params.set('analysisRequestId', requestId) params.set('async', '1') if (opts?.forceNew) params.set('force', '1') if (opts?.refreshPrediction) params.set('refresh', '1') if (uncertainMode) params.set('mode', 'uncertain') try { window.sessionStorage.setItem( TRAINING_ACTIVE_NEXT_STORAGE_KEY, JSON.stringify({ requestId, opts: { forceNew: Boolean(opts?.forceNew), refreshPrediction: Boolean(opts?.refreshPrediction), mode, previewUrl, deferCurrentSampleToQueueEnd: Boolean(opts?.deferCurrentSampleToQueueEnd), }, }) ) } catch { // ignore } 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 } if (data?.analysis) { applyTrainingAnalysisEvent(data.analysis) } if (res.status === 202 || data?.accepted || data?.running) { await waitForNextResult(requestId, { deferCurrentSampleToQueueEnd: Boolean(opts?.deferCurrentSampleToQueueEnd), }) } else { completeNextFromData(data, { deferCurrentSampleToQueueEnd: Boolean(opts?.deferCurrentSampleToQueueEnd), }) } completed = true } catch (e) { if (isCurrentRequest()) { const msg = e instanceof Error ? e.message : String(e) const mayStillRun = /load failed|failed to fetch|networkerror|network error/i.test(msg) if (mayStillRun) { keepActiveJob = true setMessage('Analyse läuft im Backend weiter. Beim Zurückkehren wird das nächste offene Trainingsbild wieder geladen.') } else { try { window.sessionStorage.removeItem(TRAINING_ACTIVE_NEXT_STORAGE_KEY) } catch { // ignore } setError(msg) } } } finally { if (!isCurrentRequest()) { return } if (completed) { try { window.sessionStorage.removeItem(TRAINING_ACTIVE_NEXT_STORAGE_KEY) } catch { // ignore } } if (keepActiveJob) { 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 nextAnalysisInFlightRequestIdRef.current = null loadingRef.current = false setLoading(false) setAnalysisSourceFile('') setAnalysisProgress(0) setAnalysisStep('') }, 500) } }, [ applyTrainingAnalysisEvent, completeNextFromData, setLoadingPreviewCandidate, waitForNextResult, ]) const reloadCurrentImage = useCallback(async () => { setDrawingBox(null) setBoxInteraction(null) setTouchMagnifier(null) setActiveBoxIndex(null) await loadNext({ refreshPrediction: true, previewUrl: imageSrc, }) setImageReloadKey((value) => value + 1) }, [imageSrc, 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 completeVideoImportFromData = useCallback(async (data: any) => { const rawSamples: TrainingSample[] = Array.isArray(data?.samples) ? data.samples : data?.sample ? [data.sample] : [] const samples = withTrainingFrameLabels(rawSamples) if (samples.length === 0) { throw new Error('Es wurden keine Trainingsframes erzeugt.') } const deferredCurrentSample = Boolean(sampleRef.current) loadPriorityTrainingSamples(samples, { deferCurrentSampleToQueueEnd: deferredCurrentSample, }) setImageReloadKey((value) => value + 1) await loadTrainingStatus() const errorCount = Array.isArray(data?.errors) ? data.errors.length : 0 const baseMessage = errorCount > 0 ? `${samples.length} Frames ins Training übernommen, ${errorCount} Frames fehlgeschlagen.` : `${samples.length} Frames ins Training übernommen.` setMessage( deferredCurrentSample ? `${baseMessage} Das aktuelle Bild wurde ans Ende der Queue gelegt.` : baseMessage ) return true }, [loadPriorityTrainingSamples, loadTrainingStatus]) const waitForVideoImportResult = useCallback(async (requestId: string) => { const id = String(requestId || '').trim() if (!id) throw new Error('requestId fehlt.') for (;;) { const res = await fetch( `/api/training/import-video/status?requestId=${encodeURIComponent(id)}`, { cache: 'no-store' } ) const data = await res.json().catch(() => null) if (res.status === 202 || data?.running) { await new Promise((resolve) => window.setTimeout(resolve, 1200)) continue } if (!res.ok || !data?.ok) { throw new Error(backendText(data, `HTTP ${res.status}`)) } return completeVideoImportFromData(data) } }, [completeVideoImportFromData]) const importVideoIntoTraining = useCallback(async (raw: any) => { const output = String(raw?.output || '').trim() if (!output) return false if (trainingRunning) { setMessage('Während das Training läuft, kann kein Video ins Training übernommen werden.') 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) setAnalysisSourceFile(detail.sourceFile || detail.output.split(/[\\/]/).pop() || '') setAnalysisProgress(5) setAnalysisStep('Video wird ins Training übernommen…') setError(null) setMessage(null) try { try { window.sessionStorage.removeItem(TRAINING_PENDING_IMPORT_VIDEO_STORAGE_KEY) window.sessionStorage.setItem( TRAINING_ACTIVE_IMPORT_VIDEO_STORAGE_KEY, JSON.stringify({ requestId, importKey, detail }) ) } 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}`)) } if (res.status === 202 || data?.accepted || data?.running) { await waitForVideoImportResult(String(data?.requestId || requestId)) try { window.sessionStorage.removeItem(TRAINING_ACTIVE_IMPORT_VIDEO_STORAGE_KEY) } catch { // ignore } return true } const rawSamples: TrainingSample[] = Array.isArray(data.samples) ? data.samples : data.sample ? [data.sample] : [] const samples = withTrainingFrameLabels(rawSamples) if (samples.length === 0) { throw new Error('Es wurden keine Trainingsframes erzeugt.') } const deferredCurrentSample = Boolean(sampleRef.current) loadPriorityTrainingSamples(samples, { deferCurrentSampleToQueueEnd: deferredCurrentSample, }) 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.` ) if (deferredCurrentSample) { setMessage( errorCount > 0 ? `${samples.length} Frames ins Training übernommen, ${errorCount} Frames fehlgeschlagen. Das aktuelle Bild wurde ans Ende der Queue gelegt.` : `${samples.length} Frames ins Training übernommen. Das aktuelle Bild wurde ans Ende der Queue gelegt.` ) } return true } catch (e) { const msg = e instanceof Error ? e.message : String(e) const mayStillRun = /load failed|failed to fetch|networkerror|network error/i.test(msg) if (mayStillRun) { setMessage('Video-Import läuft im Backend weiter und wird beim Zurückkehren wieder aufgenommen.') } else { try { window.sessionStorage.removeItem(TRAINING_ACTIVE_IMPORT_VIDEO_STORAGE_KEY) } catch { // ignore } setError(msg) } 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) setAnalysisSourceFile('') setAnalysisProgress(0) setAnalysisStep('') } if (videoImportInFlightKeyRef.current === importKey) { videoImportInFlightKeyRef.current = null } }, 500) } }, [ loadPriorityTrainingSamples, loadTrainingStatus, setLoadingPreviewCandidate, trainingRunning, waitForVideoImportResult, ]) 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: parseTrainingModelInfo(data?.modelInfo), detectorModelAvailable: Boolean( data?.detectorModelAvailable ?? data?.modelAvailable ), detectorModelInfo: parseTrainingModelInfo( data?.detectorModelInfo ?? data?.modelInfo ), poseModelAvailable: Boolean(data?.poseModelAvailable), poseModelInfo: parseTrainingModelInfo(data?.poseModelInfo), videoMAEModelAvailable: Boolean( data?.videoMAEModelAvailable ?? data?.videomaeModelAvailable ), videoMAEModelInfo: parseTrainingModelInfo( data?.videoMAEModelInfo ?? data?.videomaeModelInfo ), 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), target: e?.target, status: e?.status, 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), performanceMode: e?.performanceMode, cpuCoreCount: Number(e?.cpuCoreCount ?? 0), cpuThreads: Number(e?.cpuThreads ?? 0), workers: Number(e?.workers ?? 0), yoloBatchSize: Number(e?.yoloBatchSize ?? 0), lowPriority: Boolean(e?.lowPriority), })) : [] ) } catch { // ignore } }, []) const loadTrainingEstimateSettings = useCallback(async () => { try { const res = await fetch('/api/settings', { cache: 'no-store' }) const data = await res.json().catch(() => null) if (!res.ok || !data) return setTrainingEstimateSettings(data as RecorderSettingsState) } catch { // ignore } }, []) useEffect(() => { if (!tabActive) return 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) } }, [tabActive, imageSrc, imageExpanded, frameImageLoaded, updateImageLayerStyle]) useEffect(() => { trainingRunningRef.current = trainingRunning if (trainingRunning) { setMobilePanel('training') } else { setActiveTrainingTargets([]) } }, [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. // Rund 500 ms halten die Anzeige ruhig genug, damit der Crossfade weich bleibt. useEffect(() => { if (!trainingRunning) { latestTrainingPreviewRef.current = '' setTrainingPreviewUrl('') return } const timer = window.setInterval(() => { const latest = latestTrainingPreviewRef.current if (!latest) return setTrainingPreviewUrl((cur) => (cur === latest ? cur : latest)) }, 500) return () => window.clearInterval(timer) }, [trainingRunning]) useEffect(() => { const onAnalysis = (event: Event) => { try { const data = (event as CustomEvent).detail if (!loadingRef.current || !activeAnalysisRequestIdRef.current) return applyTrainingAnalysisEvent(data, { requireActiveRequest: true }) } catch { // ignore } } window.addEventListener('app:sse:analysis', onAnalysis as EventListener) return () => { window.removeEventListener('app:sse:analysis', onAnalysis as EventListener) } }, [applyTrainingAnalysisEvent]) 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?.(tabActive ? imageExpanded : false) return () => { props.onImageExpandedChange?.(false) } }, [tabActive, 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) let resumedActiveImport = false let resumedActiveNext = false try { const activeRaw = window.sessionStorage.getItem(TRAINING_ACTIVE_IMPORT_VIDEO_STORAGE_KEY) if (activeRaw) { const active = JSON.parse(activeRaw) const requestId = String(active?.requestId || '').trim() const importKey = String(active?.importKey || '').trim() if (requestId) { if (importKey) { videoImportInFlightKeyRef.current = importKey } activeAnalysisRequestIdRef.current = requestId loadingRef.current = true setLoading(true) setAnalysisSourceFile(String(active?.detail?.sourceFile || active?.detail?.output || '').split(/[\\/]/).pop() || '') setAnalysisProgress(5) setAnalysisStep('Video-Import wird fortgesetzt…') setError(null) void waitForVideoImportResult(requestId) .then(() => { window.sessionStorage.removeItem(TRAINING_ACTIVE_IMPORT_VIDEO_STORAGE_KEY) }) .catch((e) => { const msg = e instanceof Error ? e.message : String(e) const mayStillRun = /load failed|failed to fetch|networkerror|network error/i.test(msg) if (mayStillRun) { setMessage('Video-Import läuft im Backend weiter und wird beim Zurückkehren wieder aufgenommen.') } else { window.sessionStorage.removeItem(TRAINING_ACTIVE_IMPORT_VIDEO_STORAGE_KEY) setError(msg) } }) .finally(() => { if (activeAnalysisRequestIdRef.current === requestId) { activeAnalysisRequestIdRef.current = null loadingRef.current = false setLoading(false) setAnalysisSourceFile('') setAnalysisProgress(0) setAnalysisStep('') } if (importKey && videoImportInFlightKeyRef.current === importKey) { videoImportInFlightKeyRef.current = null } }) resumedActiveImport = true } } const activeNextRaw = resumedActiveImport ? '' : window.sessionStorage.getItem(TRAINING_ACTIVE_NEXT_STORAGE_KEY) if (activeNextRaw) { const active = JSON.parse(activeNextRaw) const requestId = String(active?.requestId || '').trim() const activeOpts = active?.opts || {} if (requestId) { activeAnalysisRequestIdRef.current = requestId nextAnalysisInFlightRequestIdRef.current = requestId loadingRef.current = true setLoading(true) setAnalysisSourceFile('') setAnalysisProgress(5) setAnalysisStep('Analyse wird fortgesetzt…') setError(null) void fetch( `/api/training/next/status?requestId=${encodeURIComponent(requestId)}`, { cache: 'no-store' } ) .then((res) => res.json().catch(() => null)) .then((data) => { if (data?.analysis) { applyTrainingAnalysisEvent(data.analysis) } }) .catch(() => { // ignore; waitForNextResult pollt danach weiter. }) void waitForNextResult(requestId, { deferCurrentSampleToQueueEnd: Boolean(activeOpts?.deferCurrentSampleToQueueEnd), }) .then(() => { window.sessionStorage.removeItem(TRAINING_ACTIVE_NEXT_STORAGE_KEY) }) .catch((e) => { const msg = e instanceof Error ? e.message : String(e) const mayStillRun = /load failed|failed to fetch|networkerror|network error/i.test(msg) if (mayStillRun) { setMessage('Analyse läuft im Backend weiter und wird beim Zurückkehren wieder aufgenommen.') } else { window.sessionStorage.removeItem(TRAINING_ACTIVE_NEXT_STORAGE_KEY) setError(msg) } }) .finally(() => { if (activeAnalysisRequestIdRef.current === requestId) { activeAnalysisRequestIdRef.current = null nextAnalysisInFlightRequestIdRef.current = null loadingRef.current = false setLoading(false) setAnalysisSourceFile('') setAnalysisProgress(0) setAnalysisStep('') } }) resumedActiveNext = true } } const raw = resumedActiveImport || resumedActiveNext ? '' : window.sessionStorage.getItem(TRAINING_PENDING_IMPORT_VIDEO_STORAGE_KEY) if (raw) { const detail = JSON.parse(raw) void importVideoIntoTraining(detail) } } catch { // ignore } return () => { window.removeEventListener('training:import-video', onImportVideo as EventListener) } }, [applyTrainingAnalysisEvent, importVideoIntoTraining, waitForNextResult, waitForVideoImportResult]) useEffect(() => { if (!tabActive || initializedRef.current) return const runId = initRunIdRef.current + 1 initRunIdRef.current = runId let cancelled = false async function init() { await loadLabels() await loadTrainingStatus() if (cancelled || initRunIdRef.current !== runId) 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 || nextAnalysisInFlightRequestIdRef.current ) { initializedRef.current = true return } await loadNext() if (!cancelled && initRunIdRef.current === runId) { initializedRef.current = true } } void init() return () => { cancelled = true } }, [tabActive, loadLabels, loadNext, loadTrainingStatus]) useEffect(() => { if (!tabActive || !initializedRef.current) return void loadTrainingStatus() const frame = window.requestAnimationFrame(() => { updateImageLayerStyle() }) return () => window.cancelAnimationFrame(frame) }, [tabActive, loadTrainingStatus, updateImageLayerStyle]) useEffect(() => { if (!tabActive) return let cancelled = false async function refreshActiveAnalysisStatus() { let nextRequestId = nextAnalysisInFlightRequestIdRef.current || '' let importRequestId = activeAnalysisRequestIdRef.current || '' try { const activeNextRaw = window.sessionStorage.getItem(TRAINING_ACTIVE_NEXT_STORAGE_KEY) if (!nextRequestId && activeNextRaw) { nextRequestId = String(JSON.parse(activeNextRaw)?.requestId || '').trim() } } catch { // ignore } try { const activeImportRaw = window.sessionStorage.getItem(TRAINING_ACTIVE_IMPORT_VIDEO_STORAGE_KEY) if (activeImportRaw) { importRequestId = String(JSON.parse(activeImportRaw)?.requestId || importRequestId || '').trim() } } catch { // ignore } const url = nextRequestId ? `/api/training/next/status?requestId=${encodeURIComponent(nextRequestId)}` : importRequestId ? `/api/training/import-video/status?requestId=${encodeURIComponent(importRequestId)}` : '/api/training/analysis/status' try { const res = await fetch(url, { cache: 'no-store' }) const data = await res.json().catch(() => null) if (cancelled || !res.ok || !data?.analysis) return applyTrainingAnalysisEvent(data.analysis) } catch { // ignore } } void refreshActiveAnalysisStatus() return () => { cancelled = true } }, [applyTrainingAnalysisEvent, tabActive]) 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 = { target: '', firstEpochAt: 0, lastEpoch: 0, lastAt: 0, } setEstimatedEpochMs(0) return } const now = Date.now() const previous = epochTimingRef.current const target = currentTrainingTarget || activeTrainingTarget || '' const targetChanged = previous.target !== target if (targetChanged) { setEstimatedEpochMs(0) } const firstEpochAt = targetChanged ? now : 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 = { target, firstEpochAt, lastEpoch: safeEpoch, lastAt: now, } }, [ trainingRunning, trainingStatus?.training?.epoch, trainingStatus?.training?.epochs, trainingStatus?.training?.startedAt, activeTrainingTarget, currentTrainingTarget, trainingNowMs, ]) const saveFeedback = useCallback( async ( accepted: boolean, options?: { negative?: boolean } ) => { if (!sample || trainingRunning) return setSavingOverlayText(editingFeedback ? 'Feedback wird aktualisiert…' : 'Feedback wird gespeichert…') setSaving(true) setError(null) setMessage(null) try { const normalizedBoxes = (correction.boxes ?? []) .map(normalizeBox) .filter((box) => box.label && box.w > 0 && box.h > 0) const feedbackCorrection = { ...correction, boxes: normalizedBoxes, } const negative = options?.negative ?? !correctionHasTrainablePositionOrBoxes(feedbackCorrection) const correctionPayload: CorrectionState = negative ? { sexPosition: NO_SEX_POSITION_LABEL, peoplePresent: [], bodyPartsPresent: [], objectsPresent: [], clothingPresent: [], boxes: [], } : { ...correction, peoplePresent: peopleLabelsFromBoxes(normalizedBoxes, labelsRef.current), boxes: normalizedBoxes, } const effectiveAccepted = negative ? false : accepted setSavingOverlayText( negative ? editingFeedback ? 'Negativbeispiel wird aktualisiert…' : 'Negativbeispiel wird gespeichert…' : effectiveAccepted ? editingFeedback ? 'Feedback wird aktualisiert…' : 'Feedback wird gespeichert…' : editingFeedback ? 'Korrektur wird aktualisiert…' : 'Korrektur wird gespeichert…' ) 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 (wasEditingFeedback) { const returnItem = feedbackEditReturnSampleRef.current feedbackEditReturnSampleRef.current = null if (returnItem) { loadQueuedTrainingSample(returnItem) return } if (!loadNextImportedQueuedSample()) { await loadNext({ preserveNotice: true }) } return } 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) setSavingOverlayText('') } }, [ sample, correction, editingFeedback, loadNext, loadTrainingStatus, loadQueuedTrainingSample, loadNextImportedQueuedSample, trainingRunning, ] ) const skipCurrentSample = useCallback(async () => { if (!sample) return if (editingFeedback) { const returnItem = feedbackEditReturnSampleRef.current feedbackEditReturnSampleRef.current = null setEditingFeedback(null) setError(null) setMessage('Feedback-Bearbeitung abgebrochen.') if (returnItem) { loadQueuedTrainingSample(returnItem) } return } const skippedSampleId = sample.sampleId setSavingOverlayText('Bild wird übersprungen…') 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}`)) } sampleRef.current = null correctionRef.current = predictionToCorrection(null) hasManualCorrectionRef.current = false setSample(null) setCorrection(predictionToCorrection(null)) setHasManualCorrection(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) setSavingOverlayText('') } }, [ sample, editingFeedback, loadNext, loadQueuedTrainingSample, loadNextImportedQueuedSample, ]) const openTrainingStartModal = useCallback(() => { const readyTargets = selectableTrainingTargets.length > 0 ? selectableTrainingTargets : ALL_TRAINING_TARGETS setTrainingStartMode('full') setTrainingStartTargets(readyTargets) setTrainingStartModalOpen(true) void loadTrainingHistory() void loadTrainingEstimateSettings() }, [loadTrainingEstimateSettings, loadTrainingHistory, selectableTrainingTargets]) const toggleTrainingStartTarget = useCallback((target: TrainingTargetKey) => { setTrainingStartTargets((prev) => prev.includes(target) ? prev.filter((item) => item !== target) : [...prev, target] ) }, []) const startTraining = useCallback(async (options?: TrainingStartOptions) => { shownTrainingCompletionRef.current = null setDismissedTrainingInfoKey('') try { window.localStorage.removeItem(TRAINING_INFO_DISMISSED_STORAGE_KEY) } catch { // ignore } const nextActiveTargets = options?.mode === 'custom' ? (options.targets ?? []).filter(isTrainingTargetKey) : selectableTrainingTargets.length > 0 ? selectableTrainingTargets : ALL_TRAINING_TARGETS setActiveTrainingTargets(nextActiveTargets) setTraining(true) setTrainingProgress(5) setTrainingStep('Training wird gestartet…') setError(null) setMessage(null) try { const payload = options?.mode === 'custom' ? { scope: 'custom', targets: options.targets ?? [], } : { scope: 'full', } const res = await fetch('/api/training/train', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify(payload), }) const data = await res.json().catch(() => null) if (!res.ok) { throw new Error(backendText(data, `HTTP ${res.status}`)) } await loadTrainingStatus() // WICHTIG: // Hier NICHT direkt loadNext() aufrufen. // Das Training läuft im Backend asynchron weiter. } catch (e) { setActiveTrainingTargets([]) setTraining(false) setTrainingProgress(0) setTrainingStep('') setError(e instanceof Error ? e.message : String(e)) } }, [loadTrainingStatus, selectableTrainingTargets]) const startTrainingFromModal = useCallback(() => { if (!canConfirmTrainingStart) return const options: TrainingStartOptions = trainingStartMode === 'custom' ? { mode: 'custom', targets: selectedTrainingTargets, } : { mode: 'full', } setTrainingStartModalOpen(false) void startTraining(options) }, [ canConfirmTrainingStart, selectedTrainingTargets, startTraining, trainingStartMode, ]) 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}`) } sampleRef.current = null correctionRef.current = predictionToCorrection(null) hasManualCorrectionRef.current = false setSample(null) setCorrection(predictionToCorrection(null)) setTrainingStatus({ feedbackCount: 0, requiredCount, canTrain: false, }) setMessage(backendText(data, 'Alle Trainingsdaten wurden gelöscht.')) await loadTrainingStatus() await loadNext({ forceNew: true, preserveNotice: true }) if (statsModalOpen) { await loadTrainingStats() } } catch (e) { setError(e instanceof Error ? e.message : String(e)) } finally { setDeletingTrainingData(false) } }, [loadNext, loadTrainingStats, loadTrainingStatus, requiredCount, statsModalOpen]) const getPointerPosFromRect = useCallback(( rect: ImageContentRect, clientX: number, clientY: number, opts?: { clamp?: boolean } ) => { const x = (clientX - rect.left) / rect.width const y = (clientY - rect.top) / rect.height if (opts?.clamp === false) { return { x, y } } return { x: clamp01(x), y: clamp01(y), } }, []) const getPointerPosInImage = useCallback(( clientX: number, clientY: number, opts?: { clamp?: boolean } ) => { const rect = activeImageContentRectRef.current ?? getImageContentRect() if (!rect) return null return getPointerPosFromRect(rect, clientX, clientY, opts) }, [getImageContentRect, getPointerPosFromRect]) 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 markManualCorrection = useCallback(() => { if (hasManualCorrectionRef.current) return hasManualCorrectionRef.current = true setHasManualCorrection(true) }, []) const cancelPointerMoveFrame = useCallback(() => { if (pointerMoveRafRef.current !== null) { window.cancelAnimationFrame(pointerMoveRafRef.current) pointerMoveRafRef.current = null } pendingPointerMoveRef.current = null }, []) const clearBoxGestureRefs = useCallback(() => { cancelPointerMoveFrame() activeImageContentRectRef.current = null drawingBoxRef.current = null boxInteractionRef.current = null latestGestureBoxRef.current = null }, [cancelPointerMoveFrame]) const applyPointerMoveSnapshot = useCallback((snapshot: { clientX: number clientY: number }) => { const drawing = drawingBoxRef.current const interaction = boxInteractionRef.current if (!drawing && !interaction) return const clampedPos = getPointerPosInImage(snapshot.clientX, snapshot.clientY) if (!clampedPos) return const pos = interaction?.type === 'move' ? getPointerPosInImage(snapshot.clientX, snapshot.clientY, { clamp: false }) ?? clampedPos : clampedPos const nextMagnifier: MagnifierState = { visible: true, clientX: snapshot.clientX, clientY: snapshot.clientY, imageX: clampedPos.x, imageY: clampedPos.y, } setTouchMagnifier((prev) => { if ( prev?.visible && prev.clientX === nextMagnifier.clientX && prev.clientY === nextMagnifier.clientY && Math.abs(prev.imageX - nextMagnifier.imageX) <= 0.0005 && Math.abs(prev.imageY - nextMagnifier.imageY) <= 0.0005 ) { return prev } return nextMagnifier }) if (interaction) { const dx = pos.x - interaction.startX const dy = pos.y - interaction.startY const original = interaction.original let nextBox: TrainingBox = original if (interaction.type === 'move') { nextBox = normalizeMovedBox({ ...original, x: original.x + dx, y: original.y + dy, }) } if (interaction.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) if (interaction.handle.includes('n')) y1 = pointerY if (interaction.handle.includes('s')) y2 = pointerY if (interaction.handle.includes('w')) x1 = pointerX if (interaction.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 latestGestureBoxRef.current = correctedNextBox if (geometryChanged) { markManualCorrection() } setCorrection((prev) => { const boxes = prev.boxes ?? [] const currentBox = boxes[interaction.index] if (!currentBox || !boxVisualStateChanged(currentBox, correctedNextBox)) { return prev } const next: CorrectionState = { ...prev, boxes: boxes.map((box, index) => index === interaction.index ? correctedNextBox : box ), } correctionRef.current = next return next }) return } if (!drawing) return const x1 = drawing.startX const y1 = drawing.startY const x2 = pos.x const y2 = pos.y const nextDrawing: DrawingTrainingBox = { ...drawing, x: Math.min(x1, x2), y: Math.min(y1, y2), w: Math.abs(x2 - x1), h: Math.abs(y2 - y1), } latestGestureBoxRef.current = nextDrawing drawingBoxRef.current = nextDrawing setDrawingBox((prev) => { if (prev && !boxVisualStateChanged(prev, nextDrawing)) { return prev } return nextDrawing }) }, [getPointerPosInImage, markManualCorrection]) const schedulePointerMove = useCallback((snapshot: { clientX: number clientY: number }) => { pendingPointerMoveRef.current = snapshot if (pointerMoveRafRef.current !== null) return pointerMoveRafRef.current = window.requestAnimationFrame(() => { pointerMoveRafRef.current = null const pending = pendingPointerMoveRef.current pendingPointerMoveRef.current = null if (pending) { applyPointerMoveSnapshot(pending) } }) }, [applyPointerMoveSnapshot]) const flushPendingPointerMove = useCallback(() => { const pending = pendingPointerMoveRef.current pendingPointerMoveRef.current = null if (pointerMoveRafRef.current !== null) { window.cancelAnimationFrame(pointerMoveRafRef.current) pointerMoveRafRef.current = null } if (pending) { applyPointerMoveSnapshot(pending) } }, [applyPointerMoveSnapshot]) useEffect(() => { return () => cancelPointerMoveFrame() }, [cancelPointerMoveFrame]) const startDrawBox = useCallback((e: React.PointerEvent) => { if (!boxLabel) return if (uiLocked) return if (drawingBoxRef.current || boxInteractionRef.current) return const target = e.target as HTMLElement | null if (target?.closest('[data-box-control="true"]')) return const contentRect = getImageContentRect() if (!contentRect) return const rawPos = getPointerPosFromRect(contentRect, e.clientX, e.clientY, { clamp: false }) // Nicht im schwarzen Randbereich starten. if ( rawPos.x < 0 || rawPos.x > 1 || rawPos.y < 0 || rawPos.y > 1 ) { return } activeImageContentRectRef.current = contentRect 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 } const nextMagnifier: MagnifierState = { visible: true, clientX: e.clientX, clientY: e.clientY, imageX: pos.x, imageY: pos.y, } const nextDrawingBox: DrawingTrainingBox = { label: boxLabel, startX: pos.x, startY: pos.y, x: pos.x, y: pos.y, w: 0, h: 0, } latestGestureBoxRef.current = nextDrawingBox drawingBoxRef.current = nextDrawingBox setTouchMagnifier(nextMagnifier) setDrawingBox(nextDrawingBox) }, [boxLabel, getImageContentRect, getPointerPosFromRect, uiLocked]) const moveDrawBox = useCallback((e: React.PointerEvent) => { const hasActiveGesture = Boolean(drawingBoxRef.current || boxInteractionRef.current) if (hasActiveGesture) { e.preventDefault() e.stopPropagation() schedulePointerMove({ clientX: e.clientX, clientY: e.clientY, }) } }, [schedulePointerMove]) const finishDrawBox = useCallback((e?: React.PointerEvent | PointerEvent) => { flushPendingPointerMove() const activeDrawingBox = drawingBoxRef.current const activeInteraction = boxInteractionRef.current releaseActivePointerCapture( typeof e?.pointerId === 'number' ? e.pointerId : null ) setTouchMagnifier(null) if (finishingGestureRef.current) { setDrawingBox(null) setBoxInteraction(null) clearBoxGestureRefs() return } finishingGestureRef.current = true if (activeDrawingBox || activeInteraction) { e?.preventDefault() e?.stopPropagation() } if (activeInteraction) { const finalBox = latestGestureBoxRef.current if (finalBox) { setCorrection((prev) => { const boxes = prev.boxes ?? [] const currentBox = boxes[activeInteraction.index] if (!currentBox || !boxVisualStateChanged(currentBox, finalBox)) { return prev } const next: CorrectionState = { ...prev, boxes: boxes.map((box, index) => index === activeInteraction.index ? finalBox : box ), } correctionRef.current = next return next }) } setBoxInteraction(null) clearBoxGestureRefs() return } if (!activeDrawingBox) { setDrawingBox(null) clearBoxGestureRefs() return } const box = normalizeBox(latestGestureBoxRef.current ?? activeDrawingBox) setDrawingBox(null) if (box.w < 0.01 || box.h < 0.01) { clearBoxGestureRefs() return } markManualCorrection() setCorrection((prev) => { const previousBoxes = prev.boxes ?? [] const newBoxIndex = previousBoxes.length const next: CorrectionState = { ...prev, boxes: [...previousBoxes, box], } setActiveBoxIndex(newBoxIndex) const applied = applyBoxLabelToCorrection(next, box.label, labelsRef.current) correctionRef.current = applied return applied }) clearBoxGestureRefs() }, [ clearBoxGestureRefs, flushPendingPointerMove, markManualCorrection, 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 && !loadingPreviewFallbackUrl) return setLoadingPreviewUrl('') setLoadingPreviewFallbackUrl('') setLoadingPreviewLoaded(false) setLoadingPreviewFailed(false) }, [loading, frameBusy, loadingPreviewFallbackUrl, loadingPreviewUrl]) const showImageBoxes = !frameBusy && !trainingRunning && !saving 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) const hasEpochInfo = Number.isFinite(epoch) && Number.isFinite(epochs) && epoch > 0 && epochs > 0 const completedEpochs = hasEpochInfo ? Math.max(0, Math.min(epochs, Math.floor(epoch))) : 0 const remainingEpochs = Number.isFinite(epochs) && epochs > 0 ? Math.max(0, epochs - completedEpochs) : 0 const epochEtaMs = Number.isFinite(estimatedEpochMs) && estimatedEpochMs > 0 ? remainingEpochs * estimatedEpochMs : 0 if (!currentTrainingTarget || effectiveTrainingTargets.length === 0) { return Math.max(0, epochEtaMs) } const currentIndex = effectiveTrainingTargets.indexOf(currentTrainingTarget) if (currentIndex < 0) { return Math.max(0, epochEtaMs) } const currentEstimateMs = Math.max( 0, trainingEstimateByTarget[currentTrainingTarget] || 0 ) const stageRemainingMs = currentEstimateMs > 0 ? currentEstimateMs * (1 - currentTrainingTargetProgress) : 0 const currentRemainingMs = combineTrainingEtaMs(stageRemainingMs, epochEtaMs) const laterTargetsMs = effectiveTrainingTargets .slice(currentIndex + 1) .reduce((sum, target) => sum + Math.max(0, trainingEstimateByTarget[target] || 0), 0) return Math.max(0, currentRemainingMs + laterTargetsMs) }, [ currentTrainingTarget, currentTrainingTargetProgress, effectiveTrainingTargets, trainingRunning, trainingStatus?.training?.epoch, trainingStatus?.training?.epochs, estimatedEpochMs, trainingEstimateByTarget, ]) 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 feedbackReady = feedbackCount >= requiredCount const detector = trainingStatus?.detector const detectorReady = Boolean(detector?.dataReady) const pose = trainingStatus?.pose const poseReady = Boolean(pose?.dataReady) const videoMAE = trainingStatus?.videoMAE const videoMAEReady = Boolean(videoMAE?.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 missingPoseTrain = Math.max( 0, Number(pose?.requiredTrain ?? 20) - Number(pose?.trainCount ?? 0) ) const missingPoseVal = Math.max( 0, Number(pose?.requiredVal ?? 3) - Number(pose?.valCount ?? 0) ) const boundedCount = (value: number, required: number) => { const cleanValue = Number.isFinite(value) ? Math.max(0, value) : 0 const cleanRequired = Number.isFinite(required) ? Math.max(0, required) : 0 return Math.min(cleanValue, cleanRequired) } const detectorTrainCount = Number(detector?.trainCount ?? 0) const detectorValCount = Number(detector?.valCount ?? 0) const detectorRequiredTrain = Number(detector?.requiredTrain ?? 20) const detectorRequiredVal = Number(detector?.requiredVal ?? 3) const detectorPositiveDone = (Number(detector?.positiveTrainCount ?? 0) >= 1 ? 1 : 0) + (Number(detector?.positiveValCount ?? 0) >= 1 ? 1 : 0) const detectorPositiveRequired = 2 const detectorDone = boundedCount(detectorTrainCount, detectorRequiredTrain) + boundedCount(detectorValCount, detectorRequiredVal) + detectorPositiveDone const detectorRequired = Math.max(0, detectorRequiredTrain) + Math.max(0, detectorRequiredVal) + detectorPositiveRequired const poseTrainCount = Number(pose?.trainCount ?? 0) const poseValCount = Number(pose?.valCount ?? 0) const poseRequiredTrain = Number(pose?.requiredTrain ?? 20) const poseRequiredVal = Number(pose?.requiredVal ?? 3) const poseDone = boundedCount(poseTrainCount, poseRequiredTrain) + boundedCount(poseValCount, poseRequiredVal) const poseRequired = Math.max(0, poseRequiredTrain) + Math.max(0, poseRequiredVal) const videoMAETrainCount = Number(videoMAE?.trainCount ?? 0) const videoMAEValCount = Number(videoMAE?.valCount ?? 0) const videoMAEEligibleCount = Number(videoMAE?.eligibleCount ?? 0) const videoMAERequiredTrain = Number(videoMAE?.requiredTrain ?? 40) const videoMAERequiredVal = Number(videoMAE?.requiredVal ?? 5) const videoMAEDone = boundedCount(videoMAETrainCount, videoMAERequiredTrain) + boundedCount(videoMAEValCount, videoMAERequiredVal) const videoMAERequired = Math.max(0, videoMAERequiredTrain) + Math.max(0, videoMAERequiredVal) const feedbackDone = boundedCount(feedbackCount, requiredCount) const feedbackRequired = Math.max(0, requiredCount) const readinessDone = feedbackDone + detectorDone + poseDone + videoMAEDone const readinessRequired = feedbackRequired + detectorRequired + poseRequired + videoMAERequired const readinessProgress = readinessRequired > 0 ? readinessDone / readinessRequired : 0 const readinessItems = [ { key: 'detector', label: 'Object Detection', value: detectorRequired > 0 ? detectorDone / detectorRequired : 1, text: `${detectorDone}/${detectorRequired}`, detail: `Train ${detectorTrainCount}/${detectorRequiredTrain}, Val ${detectorValCount}/${detectorRequiredVal}, Positiv ${detectorPositiveDone}/${detectorPositiveRequired}`, }, { key: 'pose', label: 'Pose Detection', value: poseRequired > 0 ? poseDone / poseRequired : 1, text: `${poseDone}/${poseRequired}`, detail: `Train ${poseTrainCount}/${poseRequiredTrain}, Val ${poseValCount}/${poseRequiredVal}`, }, { key: 'videomae', label: 'VideoMAE', value: videoMAERequired > 0 ? videoMAEDone / videoMAERequired : 1, text: `${videoMAEDone}/${videoMAERequired}`, detail: `Eligible ${videoMAEEligibleCount}, Train ${videoMAETrainCount}/${videoMAERequiredTrain}, Val ${videoMAEValCount}/${videoMAERequiredVal}`, }, ] const trainingPaused = Boolean(trainingStatus?.training?.paused) const pauseReason = String(trainingStatus?.training?.pauseReason || '').trim() const pauseMetrics = [ trainingStatus?.training?.cpuPercent ? `CPU ${Math.round(Number(trainingStatus.training.cpuPercent))}%` : '', trainingStatus?.training?.temperatureC ? `${Math.round(Number(trainingStatus.training.temperatureC))}°C` : '', ].filter(Boolean).join(' · ') const progress = clampPercent( trainingRunning ? shownTrainingProgress : readinessProgress * 100 ) const statusText = trainingRunning ? trainingPaused ? `Pausiert${pauseReason ? `: ${pauseReason}` : ''}${pauseMetrics ? ` · ${pauseMetrics}` : ''}` : shownTrainingStep || 'Training läuft…' : !feedbackReady ? `${Math.max(0, requiredCount - feedbackCount)} Feedback fehlen noch` : canStartTraining ? 'Bereit zum Trainieren' : !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' : !poseReady ? missingPoseTrain > 0 || missingPoseVal > 0 ? `Pose-Beispiele fehlen: ${missingPoseTrain} Train, ${missingPoseVal} Val` : 'Pose-Datensatz noch nicht bereit' : !videoMAEReady ? 'VideoMAE-Datensatz noch nicht bereit' : 'Noch nicht trainingsbereit' return (
{trainingPaused ? (
Training-Aktionen
{statusText}
{trainingRunning || !canStartTraining ? (
{trainingRunning ? 'Trainingsfortschritt' : 'Trainingsbereitschaft'} {Math.round(progress)}%
0 ? 'bg-amber-500' : 'bg-gray-400', ].join(' ')} style={{ width: `${progress}%` }} />
{!trainingRunning ? (
{readinessItems.map((item) => { const itemProgress = clampPercent(item.value * 100) return (
{item.label}
{item.text}
{item.detail}
) })}
) : null}
) : 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}`} aria-disabled={uiLocked} onClick={() => { if (uiLocked) return setActiveBoxIndex(index) }} className={[ 'group relative overflow-hidden rounded-2xl border transition-all duration-200', 'bg-white shadow-sm', 'dark:border-white/10 dark:bg-gray-950/55', uiLocked ? 'cursor-not-allowed opacity-60' : 'cursor-pointer', isActive ? [ 'border-gray-200 bg-white', 'dark:border-white/10', tone.activeSurface, ].join(' ') : [ 'border-gray-200', uiLocked ? '' : 'hover:bg-gray-50/80 hover:shadow-md', 'dark:border-white/10', uiLocked ? '' : 'dark:hover:bg-white/[0.04]', uiLocked ? '' : tone.idleHover, ].join(' '), ].join(' ')} > ) }) )}
) } const imageTouchClass = boxLabel || drawingBox || boxInteraction ? 'touch-none' : 'touch-pan-y' const loadingPreviewBackgroundUrl = loadingPreviewUrl && loadingPreviewLoaded && !loadingPreviewFailed ? loadingPreviewUrl : loadingPreviewFallbackUrl const sampleSourceDetails = compactTrainingSourceFile(sample?.sourceFile || '') 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 savingStageActive = saving && !trainingRunning && !frameBusy const stageBusy = trainingRunning || frameBusy || saving const stageOverlayMode: 'training' | 'analysis' | 'saving' = trainingRunning ? 'training' : savingStageActive ? 'saving' : 'analysis' const analysisOverlayDetails = compactTrainingStageDetails( analysisSourceFile, analysisStep || 'Bild wird geladen…' ) const stageOverlayText = trainingRunning ? shownTrainingStep || 'Aktuelles Bild wird geladen…' : savingStageActive ? savingOverlayText || 'Feedback wird gespeichert…' : analysisOverlayDetails.statusText const stageOverlayProgress = trainingRunning ? shownTrainingProgress : loading ? analysisProgress : savingStageActive ? 65 : 100 const stageOverlaySourceFile = stageOverlayMode === 'analysis' ? analysisOverlayDetails.sourceFile : stageOverlayMode === 'saving' ? sampleSourceDetails.sourceFile : undefined const stageOverlayFrameLabel = stageOverlayMode === 'analysis' ? analysisOverlayDetails.frameLabel : stageOverlayMode === 'saving' ? sampleSourceDetails.frameLabel : undefined const stageOverlayStatusText = stageOverlayMode === 'analysis' ? analysisOverlayDetails.statusText : stageOverlayMode === 'saving' ? stageOverlayText : undefined 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
{sampleSourceDetails.sourceFile ? (
{sampleSourceDetails.sourceFile} {sampleSourceDetails.frameLabel ? ( {sampleSourceDetails.frameLabel} ) : null}
) : null}
{/* Sidebar links */} {/* Mitte */}
{hasPosePersons ? (
) : null} {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(' ')} /> {showPoseSkeleton && imageLayerStyle && hasPosePersons ? (() => { const layerWidth = Number(imageLayerStyle.width) const layerHeight = Number(imageLayerStyle.height) if ( !Number.isFinite(layerWidth) || !Number.isFinite(layerHeight) || layerWidth <= 0 || layerHeight <= 0 ) { return null } return ( ) })() : null} {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 layerWidth = Number(imageLayerStyle.width) const labelMaxWidth = Number.isFinite(layerWidth) && layerWidth > 0 ? Math.max(72, Math.min(210, layerWidth - 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 contentRect = getImageContentRect() if (!contentRect) return activeImageContentRectRef.current = contentRect const pos = getPointerPosFromRect(contentRect, e.clientX, e.clientY, { clamp: false }) const clampedPos = getPointerPosFromRect(contentRect, e.clientX, e.clientY) finishingGestureRef.current = false activePointerIdRef.current = e.pointerId try { imageBoxRef.current?.setPointerCapture(e.pointerId) } catch { activePointerIdRef.current = null } const nextMagnifier: MagnifierState = { visible: true, clientX: e.clientX, clientY: e.clientY, imageX: clampedPos.x, imageY: clampedPos.y, } const nextInteraction: BoxInteraction = { type: 'move', index, startX: pos.x, startY: pos.y, original: box, } latestGestureBoxRef.current = box boxInteractionRef.current = nextInteraction setTouchMagnifier(nextMagnifier) setBoxInteraction(nextInteraction) }} > {isDraft ? 'neu' : `#${index + 1}`}
{!isDraft ? ( <> {(['nw', 'ne', 'sw', 'se'] as const).map((handle) => ( ))} ) : null}
) })}
) : null} {touchMagnifier?.visible && imageSrc && showImageBoxes ? (() => { const rect = activeImageContentRectRef.current ?? 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 */}
setTrainingStartModalOpen(false)} title="Training starten" width="max-w-xl" footer={ <> } >
Geschätzte Gesamtdauer {trainingStartTotalEstimateText}
{plannedTrainingTargets.length > 0 ? `${plannedTrainingTargets.length} Training${plannedTrainingTargets.length === 1 ? '' : 's'} eingeplant` : 'Noch kein bereites Training ausgewählt'}
{trainingEstimateSettings ? trainingEstimateRuntimeLabel : 'Trainingsmodus wird geladen...'}
{trainingStartOptions.map((option) => { const fullMode = trainingStartMode === 'full' const selected = !fullMode && trainingStartTargets.includes(option.key) const disabled = fullMode || !option.ready const Icon = option.icon return ( ) })}
{trainingStartMode === 'custom' && selectedTrainingTargets.length === 0 ? (
Wähle mindestens ein bereites Training aus.
) : null}
setStatsModalOpen(false)} stats={trainingStats} history={trainingHistory} loading={trainingStatsLoading} error={trainingStatsError} feedbackCount={feedbackCount} requiredCount={requiredCount} deletingTrainingData={deletingTrainingData} deleteTrainingDataDisabled={uiLocked} onDeleteTrainingData={deleteAllTrainingData} /> 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, }) }} />
) }