// frontend\src\components\ui\TrainingTab.tsx 'use client' import { Fragment, useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react' import Button from './Button' import LoadingSpinner from './LoadingSpinner' import { formatDuration } from './formatters' import { ArrowPathIcon, ArrowsPointingInIcon, ArrowsPointingOutIcon, BoltIcon, CheckIcon, ForwardIcon, InboxArrowDownIcon, PauseIcon, RectangleGroupIcon, UserGroupIcon, UserPlusIcon, 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' const bodyFrontSvgUrl = new URL('../../assets/body_front.svg', import.meta.url).href 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 stageStartedAt?: string stageProgress?: number epoch?: number epochs?: number previewUrl?: string paused?: boolean pauseReason?: string cpuPercent?: number temperatureC?: number map50?: number map5095?: number accuracy?: number loss?: 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[] posePersons?: TrainingPosePerson[] } 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 PoseInteraction = { type: 'keypoint' personIndex: number keypointIndex: number startClientX: number startClientY: number moved: boolean } type PoseKeypointPlacement = { personIndex: number keypointName: string } type PoseKeypointAction = { personIndex: number keypointIndex: number } 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 PoseKeypointMapView = 'body' | 'head' 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_KEYPOINT_TAP_MOVE_THRESHOLD_PX = 6 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', } const POSE_KEYPOINT_ORDER = [ 'nose', 'left_eye', 'right_eye', 'left_ear', 'right_ear', 'left_shoulder', 'right_shoulder', 'left_elbow', 'right_elbow', 'left_wrist', 'right_wrist', 'left_hip', 'right_hip', 'left_knee', 'right_knee', 'left_ankle', 'right_ankle', ] as const const POSE_KEYPOINT_OPTIONS = POSE_KEYPOINT_ORDER.map((key) => [key, POSE_KEYPOINT_LABELS[key]] as const) const POSE_FACE_KEYPOINT_NAME_SET = new Set([ 'nose', 'left_eye', 'right_eye', 'left_ear', 'right_ear', ]) function poseFaceKeypointLabelPlacement(keypointName: string) { switch (poseKeypointId(keypointName)) { case 'right_ear': return { labelToLeft: true, transform: 'translate(calc(-100% - 10px), -50%)', zIndex: 390, } case 'right_eye': return { labelToLeft: true, transform: 'translate(calc(-100% - 8px), -135%)', zIndex: 392, } case 'left_eye': return { labelToLeft: false, transform: 'translate(8px, -135%)', zIndex: 392, } case 'left_ear': return { labelToLeft: false, transform: 'translate(10px, -50%)', zIndex: 390, } case 'nose': return { labelToLeft: false, transform: 'translate(-50%, 10px)', zIndex: 394, } default: return null } } type PoseKeypointShape = { keypointName: keyof typeof POSE_KEYPOINT_LABELS d: string center: { x: number y: number } } const POSE_BODY_MAP_VIEW_BOX = '0 0 537 1386' const POSE_BODY_MAP_ASPECT_RATIO = '537 / 1386' const POSE_BODY_MAP_ASPECT_RATIO_VALUE = 537 / 1386 const POSE_HEAD_MAP_VIEW_BOX = '172.5 0 185 477' const POSE_HEAD_MAP_ASPECT_RATIO = POSE_BODY_MAP_ASPECT_RATIO const POSE_KEYPOINT_MAP_ZOOM_DURATION_MS = 400 const POSE_FACE_IMAGE_ZOOM_DURATION_MS = 650 function poseKeypointMapViewBox(view: PoseKeypointMapView) { return view === 'head' ? POSE_HEAD_MAP_VIEW_BOX : POSE_BODY_MAP_VIEW_BOX } function parsePoseMapViewBox(viewBox: string): [number, number, number, number] { const values = viewBox.split(/\s+/).map((value) => Number(value)) return [ Number.isFinite(values[0]) ? values[0] : 0, Number.isFinite(values[1]) ? values[1] : 0, Number.isFinite(values[2]) ? values[2] : 1, Number.isFinite(values[3]) ? values[3] : 1, ] } function formatPoseMapViewBox(values: [number, number, number, number]) { return values.map((value) => Number(value.toFixed(3))).join(' ') } function easePoseMapZoom(progress: number) { return progress < 0.5 ? 4 * progress * progress * progress : 1 - Math.pow(-2 * progress + 2, 3) / 2 } function interpolatePoseMapViewBox(from: string, to: string, progress: number) { const start = parsePoseMapViewBox(from) const end = parsePoseMapViewBox(to) const eased = easePoseMapZoom(Math.max(0, Math.min(1, progress))) return formatPoseMapViewBox([ start[0] + (end[0] - start[0]) * eased, start[1] + (end[1] - start[1]) * eased, start[2] + (end[2] - start[2]) * eased, start[3] + (end[3] - start[3]) * eased, ]) } const POSE_HEAD_SHAPE = { d: 'M252.24.38c22.82-.29,49.74,12.66,54,31,1.8,7.77-2.25,14.71,1,19l24,20c24.63,34.89,19.47,85.38-6,114l-8,19-15,17-2,10c-37.09,39.53-85.31-10.89-92-46-17.31-1.44-14.13-17.89-19-32-9.11-26.39-2.85-62.04,12-82,7.56-10.17,25.79-13.99,29-28-10.65-25.65,5.43-31.91,22-42Z', center: { x: 260, y: 128 }, } const POSE_BODY_SILHOUETTE_SHAPE = { d: 'M228.17,226.46c9.86,2.25,19.05,8.64,26,14,21.09-.75,30.2-5.19,42-14l2,1,2,39c17.4,30.36,95.95,20.16,110,54,17.09,41.15-.67,121.25,7,171,5.26,34.15,16.21,54.17,23,85l14,105c9.03,37.99,66.17,66.65,74,106h-4c-5.94-2.49-8.47-5.5-13-9-3.03,2.69-1.52,3.01-7,4l-4-3c-2.26-14.07-17-33.28-33-28,5.34,9.78,14.64,15.86,16,29-10.6,6.63-15.15-8.33-20-14-9.68-11.31-26.15-19.14-30-36l-1-33-45-107c-14.24-36.26-19.89-82.94-20-132l-5,4c-6.73,24.5-20.65,62.24-14,91,13.42,58.06,59.07,109.01,45,196-13.09,80.98-35.72,147.59-49,224-7.78,44.76,9.2,86.8,2,131-7.72,47.41-16.38,86.91-26,128-5.67,24.22-9.97,63.94-8,89,1.47,18.68,24.25,43.35,14,61-13.49,2.22-33.17,4.78-48,2-13.83-11.36-2.13-27.9-6-49-6.84-37.25,12.3-66.02,6-107l-3-66c-3.28-21.08-6.86-52.3-3-77l4-41-10-44,4-95-1-76c3.6-34.34,5.4-68.37,11-96h-1l-30,1c3.03,18.81,5.71,39.34,9,61v122l4,80-10,47,4,43c5.09,33.08-.46,82.98-5,113-5.85,38.71,3,77.04,8,106l-3,37c-.58,18.68,2.11,30.56-6,42-13.1-.03-40.18-.91-51-8-2.91-19.32,12.04-34.88,16-52,1.11-4.81.34-48.15-1-56-10.21-59.61-22.61-107.33-33-170-7.04-42.45,10.26-88.28,2-133l-43-176-5-47c-13.76-83.21,31.05-132.8,44-190,4.49-19.84-7.14-85.32-17-93v2l-2,35c-4.3,25.96-5.88,51.76-13,75-16.33,40-32.67,80-49,120l-3,40c-3.35,10.16-38.6,49.47-50,50v-2l-1-1c2.5-12.5,11.59-19,17-29-27.53,3.35-31.62,32.36-43,32l-2-3c-4.46,4.97-7.22,7.97-17,8,6.45-24.77,33.79-51.67,50-69,7.65-8.18,20.5-15.08,24-27,5.33-40.66,10.67-81.34,16-122,6.48-27.54,16.92-44.95,22-76,.67-40,1.33-80,2-120,0-18.13-1.35-37.56,4-51,9.09-22.83,23.12-18.83,49-26,15.32-4.24,52.29-17.52,59-31l3-39Z', } const POSE_FACE_KEYPOINT_SHAPES: PoseKeypointShape[] = [ { keypointName: 'left_ear', d: 'M333.4,141.79c6.55,2.02,7.24,6.5,10,12-4.25,12.13-7.47,21.63-15,30-4.53-.86-2.98.12-5-3-5.47-13.4,6.64-29.01,10-39Z', center: { x: 333, y: 162 }, }, { keypointName: 'left_eye', d: 'M275.17,161.23c0-9.1,2.33-11.16,6-16,13.79-3.91,20.05-5.92,34,0-.06,6.18.06,5.55,1,11-13.55,8.16-24.12,5.61-41,5Z', center: { x: 296, y: 153 }, }, { keypointName: 'nose', d: 'M255.72,141.52c9.21.01,16.7,3.13,19.65,10,3.56,9.05,4.51,27.57,2.81,37-7.05,3.67-18.89,3.38-27.13,1l-.94-3,3.74-44,1.87-1Z', center: { x: 265, y: 168 }, }, { keypointName: 'right_eye', d: 'M227.67,140.54c11.65-.29,15.55,2.71,22,7l3,11-2,2c-21.58-1.84-23.28.92-38-7v-3c5.11-5.09,7.9-6.27,15-10Z', center: { x: 233, y: 151 }, }, { keypointName: 'right_ear', d: 'M192.31,141.85h2c9.16,8.73,13.18,22.95,13,41l-7,1c-9.02-8.69-14.48-24.7-11-40l3-2Z', center: { x: 198, y: 163 }, }, ] const POSE_BODY_KEYPOINT_SHAPES: PoseKeypointShape[] = [ { keypointName: 'left_shoulder', d: 'M366.78,294.65c49.96-1.22,59.69,45.87,51,89-19.24,2.93-31.75-2.97-45-7-7.81-2.38-13.74.22-23-3-16.83-5.86-27.17-26-32-44-1.35-5.05-4.26-17.79-1-24,5.82-11.1,40.46-3.09,50-11Z', center: { x: 367, y: 339 }, }, { keypointName: 'right_shoulder', d: 'M151.37,294.09c15.59-.17,27.96,3.03,43,6,35.95,46.17-27.37,89.99-74,75-3.06.74-4.51.27-7-2-9.69-48.02,7-65.94,38-79Z', center: { x: 154, y: 335 }, }, { keypointName: 'left_elbow', d: 'M385.9,497.92l34,3c1.83,22.73,11.96,34.76,13,55l-2,1c-16.93,4.42-34.01,5.16-53,2-3.9-15.42-10.79-41.98-6-58l14-3Z', center: { x: 402, y: 528 }, }, { keypointName: 'right_elbow', d: 'M112.08,499.2c19.98-.26,31.68,1.45,45,5,6.4,18.88-4.46,48.43-8,66h-5c-11.54,1.91-39.73-14.5-46-23,1-6.62,10.3-43.27,14-48Z', center: { x: 128, y: 535 }, }, { keypointName: 'left_wrist', d: 'M442.19,698.5l17,1c18.5,11.77,65.86,67.9,71,91l-1,1c-10.64-1.08-11.24-6-18-10-1.03,5.43-.3,4.34-6,5l-5-4c-3.16-18.61-13.25-23.4-30-28,3.14,12.71,15.88,16.27,14,31-9.71,2.2-45.85-38.29-50-48-4.38-10.26-1.43-24.8-1-36h2l7-3Z', center: { x: 482, y: 745 }, }, { keypointName: 'right_wrist', d: 'M-.9,790.21c1.52-19.66,59.95-87.7,75-97,7.7-3.41,17.22,1.04,23,3l1,2c.79,43.23-12.04,47.2-31,68-6.74,7.4-8.48,13.96-18,19l-6-3v-1c2.62-12.82,12.3-18.1,17-29l-27,14c-5.09,6.8-3.71,14.23-11,19-7.41-2.6-7.5-3.82-13,4l-2,1H-.9Z', center: { x: 49, y: 742 }, }, { keypointName: 'left_hip', d: 'M361.83,617.76l16,2c13.34,42.76,19.07,80.94,18,137l-2,1c-26.41,5.52-56.7-24.7-61-45-2.39-11.27,2.04-29.55,3-40,1.59-17.35-3.1-31.43,7-45,4.37-5.87,12.1-6.54,19-10Z', center: { x: 365, y: 690 }, }, { keypointName: 'right_hip', d: 'M149.97,622.82c34.97-.84,40.11,21.46,46,49,.64,3.01-2,10-2,10,5.65,7.66,4.26,13.83,4,24v24c-8.03,22.25-40.04,38.2-64,33-7.58-53.57,5.98-100.8,16-140Z', center: { x: 166, y: 693 }, }, { keypointName: 'left_knee', d: 'M288.62,972.45c20.28-.46,45.13-2.06,57,6,.27,19.74,5.1,54.5-2,70-23.77.1-48.94,7.97-68,3-.05-25.22-19.01-51.23-7-77,6.75.33,16.29.85,20-2Z', center: { x: 311, y: 1012 }, }, { keypointName: 'right_knee', d: 'M184.44,972.61c22.02-.27,61.46-6.01,79,2,1.23,18.05-3.49,72.92-10,86-23.35.49-55.39-1.68-69-12-.19-25.65-5.78-61.25,0-76Z', center: { x: 223, y: 1017 }, }, { keypointName: 'left_ankle', d: 'M276.45,1281.69c14.15-.65,32.9.36,38,9l1,1-1,33c2.99,15.43,19.56,34.85,16,52l-1,1-5,7c-11.19.32-41.42,3.97-48-2-11.12-8.97-9-88.28,0-101Z', center: { x: 301, y: 1335 }, }, { keypointName: 'right_ankle', d: 'M217.81,1285.3c11.67.33,23.33.67,35,1,12.72,9.37,7.65,87.45-1,99-20.11.44-40.62-.06-53-8l-1-2c.32-21.95,15.9-34.18,18-54,1.3-12.3-6.69-25.48,2-36Z', center: { x: 233, y: 1337 }, }, ] 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 poseKeypointMarkerClassName(present: boolean, pending: boolean, highlighted = false) { if (pending) { return 'fill-amber-500 stroke-white/90 dark:stroke-gray-950/90' } if (highlighted) { return 'fill-cyan-500 stroke-white/95 dark:stroke-gray-950/90' } if (present) { return 'fill-blue-500 stroke-white/90 dark:stroke-gray-950/90' } return 'fill-rose-500 stroke-white/90 dark:stroke-gray-950/90' } 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 ) } type PoseFaceZoomRegion = { centerX: number centerY: number width: number height: number } type NormalizedImageRect = { x: number y: number w: number h: number } type PoseFaceImageCamera = { scale: number translateX: number translateY: number centerX: number centerY: number viewportWidth: number viewportHeight: number visibleRegion: NormalizedImageRect } type PoseFaceImagePanGesture = { pointerId: number source: 'image' | 'preview' startClientX: number startClientY: number startCenterX: number startCenterY: number contentWidth: number contentHeight: number scale: number moved: boolean } function poseFaceImageFullCamera(): PoseFaceImageCamera { return { scale: 1, translateX: 0, translateY: 0, centerX: 0.5, centerY: 0.5, viewportWidth: 1, viewportHeight: 1, visibleRegion: { x: 0, y: 0, w: 1, h: 1, }, } } function interpolatePoseFaceImageCamera( from: PoseFaceImageCamera, to: PoseFaceImageCamera, progress: number ): PoseFaceImageCamera { const eased = easePoseMapZoom(Math.max(0, Math.min(1, progress))) const lerp = (a: number, b: number) => a + (b - a) * eased const scale = lerp(from.scale, to.scale) const translateX = lerp(from.translateX, to.translateX) const translateY = lerp(from.translateY, to.translateY) const centerX = lerp(from.centerX, to.centerX) const centerY = lerp(from.centerY, to.centerY) const viewportWidth = Math.min(1, Math.max(0.015, lerp(from.viewportWidth, to.viewportWidth))) const viewportHeight = Math.min(1, Math.max(0.015, lerp(from.viewportHeight, to.viewportHeight))) return { scale, translateX, translateY, centerX, centerY, viewportWidth, viewportHeight, visibleRegion: { x: Math.max(0, Math.min(1 - viewportWidth, centerX - viewportWidth / 2)), y: Math.max(0, Math.min(1 - viewportHeight, centerY - viewportHeight / 2)), w: viewportWidth, h: viewportHeight, }, } } function normalizedZoomRegion( centerX: number, centerY: number, width: number, height: number ): PoseFaceZoomRegion { const w = Math.max(0.08, Math.min(1, Number(width) || 0)) const h = Math.max(0.08, Math.min(1, Number(height) || 0)) const cx = clamp01(centerX) const cy = clamp01(centerY) const left = Math.max(0, Math.min(1 - w, cx - w / 2)) const top = Math.max(0, Math.min(1 - h, cy - h / 2)) return { centerX: left + w / 2, centerY: top + h / 2, width: w, height: h, } } function clampPoseFaceCameraCenter( centerX: number, centerY: number, viewportWidth: number, viewportHeight: number ) { const w = Math.min(1, Math.max(0, Number(viewportWidth) || 0)) const h = Math.min(1, Math.max(0, Number(viewportHeight) || 0)) const minX = w >= 1 ? 0.5 : w / 2 const maxX = w >= 1 ? 0.5 : 1 - w / 2 const minY = h >= 1 ? 0.5 : h / 2 const maxY = h >= 1 ? 0.5 : 1 - h / 2 return { x: Math.max(minX, Math.min(maxX, clamp01(centerX))), y: Math.max(minY, Math.min(maxY, clamp01(centerY))), } } function posePersonFaceZoomRegion(person?: TrainingPosePerson | null): PoseFaceZoomRegion | null { if (!person) return null const facePoints = (person.keypoints ?? []).filter( (point) => POSE_FACE_KEYPOINT_NAME_SET.has(poseKeypointId(point.name)) && isPoseKeypointVisible(point) ) if (facePoints.length > 0) { const xs = facePoints.map((point) => clamp01(Number(point.x))) const ys = facePoints.map((point) => clamp01(Number(point.y))) const minX = Math.min(...xs) const maxX = Math.max(...xs) const minY = Math.min(...ys) const maxY = Math.max(...ys) const rawW = Math.max(0.035, maxX - minX) const rawH = Math.max(0.035, maxY - minY) const pad = Math.max(0.045, Math.max(rawW, rawH) * 1.35) return normalizedZoomRegion( (minX + maxX) / 2, (minY + maxY) / 2, Math.max(0.14, rawW + pad * 2), Math.max(0.16, rawH + pad * 2) ) } if (hasVisiblePoseBox(person)) { const box = normalizeBox(person.box) return normalizedZoomRegion( box.x + box.w / 2, box.y + box.h * 0.18, Math.max(0.16, box.w * 0.62), Math.max(0.18, box.h * 0.28) ) } return null } 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 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 boundedStage = Math.max(epoch * 0.35, Math.min(epoch * 1.65, stage)) return epoch * 0.75 + boundedStage * 0.25 } function trainingLocalProgressFromJob( job: TrainingJobStatus | null | undefined, target: TrainingTargetKey | '' ) { if (!job || !target) return null const stageTarget = trainingTargetFromStageText(job.stage, job.step) if (stageTarget !== target) return null const value = Number(job.stageProgress) if (!Number.isFinite(value)) return null return clamp01(value <= 1 ? value : value / 100) } function trainingCompletedEpochUnits( epoch: number, epochs: number, localProgress?: number | null ) { if (!Number.isFinite(epoch) || !Number.isFinite(epochs) || epoch <= 0 || epochs <= 0) { return 0 } const safeEpoch = Math.max(1, Math.min(epochs, Math.floor(epoch))) const progress = Number(localProgress) if (Number.isFinite(progress) && progress > 0) { const inferred = ((clamp01(progress) - 0.04) / 0.90) * epochs if (Number.isFinite(inferred) && inferred > 0) { return clampTrainingEstimate(inferred, Math.max(0, safeEpoch - 1), epochs) } } return Math.max(0.15, Math.min(epochs, safeEpoch - 0.5)) } 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, 'unknown', 'unbekannt', 'none', 'no_position', 'no-position', 'no position', 'n/a', 'na', ]) 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 normalizePosePerson(person: TrainingPosePerson): TrainingPosePerson | null { const box = normalizeBox(person.box) if (!box) return null const keypoints = (person.keypoints ?? []) .map((point) => ({ name: poseKeypointId(point.name), x: clamp01(Number(point.x)), y: clamp01(Number(point.y)), conf: clamp01(Number(point.conf ?? 1)), })) .filter((point) => point.name) return { label: String(person.label || '').trim() || 'person', score: clamp01(Number(person.score ?? 1)), box, keypoints, quality: typeof person.quality === 'number' ? clamp01(Number(person.quality)) : undefined, visibleKeypoints: typeof person.visibleKeypoints === 'number' ? Math.max(0, Math.round(Number(person.visibleKeypoints))) : undefined, reliable: typeof person.reliable === 'boolean' ? person.reliable : undefined, } } function normalizePosePersons(persons: TrainingPosePerson[] | undefined): TrainingPosePerson[] { return (persons ?? []) .map(normalizePosePerson) .filter((person): person is TrainingPosePerson => Boolean(person)) } function clonePosePersons(persons: TrainingPosePerson[] | undefined): TrainingPosePerson[] { return normalizePosePersons(persons).map((person) => ({ ...person, box: { ...person.box }, keypoints: person.keypoints.map((point) => ({ ...point })), })) } function createEmptyPosePerson(): TrainingPosePerson { return updatePosePersonQuality({ label: 'person', score: 1, box: { label: 'person', score: 1, x: 0.32, y: 0.06, w: 0.36, h: 0.88, }, keypoints: [], }) } function poseBoxFromKeypoints(person: TrainingPosePerson): TrainingBox { const points = (person.keypoints ?? []).filter((point) => Number.isFinite(point.x) && Number.isFinite(point.y) && clamp01(Number(point.conf ?? 1)) >= POSE_KEYPOINT_MIN_CONFIDENCE ) if (points.length === 0) { return normalizeBox(person.box) ?? person.box } const margin = 0.025 const minX = Math.min(...points.map((point) => clamp01(Number(point.x)))) const minY = Math.min(...points.map((point) => clamp01(Number(point.y)))) const maxX = Math.max(...points.map((point) => clamp01(Number(point.x)))) const maxY = Math.max(...points.map((point) => clamp01(Number(point.y)))) const left = clamp01(minX - margin) const top = clamp01(minY - margin) const right = clamp01(maxX + margin) const bottom = clamp01(maxY + margin) return normalizeBox({ label: person.box?.label || person.label || 'person', score: person.box?.score, x: left, y: top, w: right - left, h: bottom - top, }) ?? person.box } function updatePosePersonQuality(person: TrainingPosePerson): TrainingPosePerson { const visibleKeypoints = posePersonVisibleKeypoints(person) const quality = posePersonQuality(person) return { ...person, visibleKeypoints, quality, reliable: clamp01(Number(person.score ?? 0)) >= POSE_RELIABLE_MIN_SCORE && visibleKeypoints >= POSE_RELIABLE_MIN_KEYPOINTS && quality >= POSE_RELIABLE_MIN_QUALITY, } } 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, posePersons: clonePosePersons(p?.persons), } } 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 })), posePersons: clonePosePersons(value.posePersons), } } 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: [], posePersons: [], } } if (item.correction) { return { ...item.correction, sexPosition: normalizeSexPositionValue(item.correction.sexPosition), posePersons: clonePosePersons(item.correction.posePersons ?? item.prediction.persons), } } 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'] function readTrainingActiveJobStorage(key: string) { try { const raw = window.localStorage.getItem(key) if (raw) return raw } catch { // ignore } try { return window.sessionStorage.getItem(key) || '' } catch { return '' } } function writeTrainingActiveJobStorage(key: string, value: string) { try { window.localStorage.setItem(key, value) } catch { // ignore } try { window.sessionStorage.setItem(key, value) } catch { // ignore } } function removeTrainingActiveJobStorage(key: string) { try { window.localStorage.removeItem(key) } catch { // ignore } try { window.sessionStorage.removeItem(key) } catch { // ignore } } 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 activePointerCaptureElementRef = 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(false) 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) const [imageBoxSize, setImageBoxSize] = useState({ width: 0, height: 0 }) 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) setShowPoseSkeleton(false) setActivePosePersonIndex(null) setPendingPoseKeypoint(null) setHoveredPoseKeypoint(null) setSelectedPoseKeypointAction(null) if (poseKeypointMapAnimationFrameRef.current !== null) { cancelAnimationFrame(poseKeypointMapAnimationFrameRef.current) poseKeypointMapAnimationFrameRef.current = null } poseKeypointMapViewRef.current = 'body' poseKeypointMapViewBoxValueRef.current = POSE_BODY_MAP_VIEW_BOX setPoseKeypointMapView('body') setPoseKeypointMapViewBoxValue(POSE_BODY_MAP_VIEW_BOX) setPoseKeypointMapAnimating(false) 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 imgEl = frameImageRef.current if (!boxEl || !imgEl) { setImageLayerStyle(null) return } const naturalW = imgEl.naturalWidth const naturalH = imgEl.naturalHeight const imageLayoutW = imgEl.offsetWidth || imgEl.clientWidth const imageLayoutH = imgEl.offsetHeight || imgEl.clientHeight if ( naturalW <= 0 || naturalH <= 0 || imageLayoutW <= 0 || imageLayoutH <= 0 ) { setImageLayerStyle(null) return } // Untransformierte Layout-Werte verwenden. getBoundingClientRect() wuerde // beim Face-Zoom die bereits transformierte Groesse messen und die Kamera // dadurch schrittweise verschieben. const scale = Math.min(imageLayoutW / naturalW, imageLayoutH / naturalH) const contentW = naturalW * scale const contentH = naturalH * scale const nextBoxSize = { width: boxEl.clientWidth || boxEl.offsetWidth, height: boxEl.clientHeight || boxEl.offsetHeight, } const nextStyle: CSSProperties = { left: imgEl.offsetLeft + (imageLayoutW - contentW) / 2, top: imgEl.offsetTop + (imageLayoutH - contentH) / 2, width: contentW, height: contentH, } setImageBoxSize((prev) => prev.width === nextBoxSize.width && prev.height === nextBoxSize.height ? prev : nextBoxSize ) 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 }) }, []) 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 [poseInteraction, setPoseInteraction] = useState(null) const [pendingPoseKeypoint, setPendingPoseKeypoint] = useState(null) const [hoveredPoseKeypoint, setHoveredPoseKeypoint] = useState(null) const [selectedPoseKeypointAction, setSelectedPoseKeypointAction] = useState(null) const [poseKeypointMapView, setPoseKeypointMapView] = useState('body') const [poseKeypointMapViewBoxValue, setPoseKeypointMapViewBoxValue] = useState(POSE_BODY_MAP_VIEW_BOX) const [poseKeypointMapAnimating, setPoseKeypointMapAnimating] = useState(false) const [poseMapSlotHeight, setPoseMapSlotHeight] = useState(0) const [poseFaceImagePanCenter, setPoseFaceImagePanCenter] = useState<{ x: number y: number } | null>(null) const [poseFaceImageDisplayCamera, setPoseFaceImageDisplayCamera] = useState(null) const [poseFaceImageCameraAnimating, setPoseFaceImageCameraAnimating] = useState(false) const [poseFaceImageZoomOutActive, setPoseFaceImageZoomOutActive] = useState(false) const [poseFaceImagePanning, setPoseFaceImagePanning] = useState(false) const [touchMagnifier, setTouchMagnifier] = useState(null) const [boxLabel, setBoxLabel] = useState('') const [activeBoxIndex, setActiveBoxIndex] = useState(null) const [activePosePersonIndex, setActivePosePersonIndex] = 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 poseInteractionRef = useRef(null) const poseKeypointMapViewRef = useRef('body') const poseKeypointMapViewBoxValueRef = useRef(POSE_BODY_MAP_VIEW_BOX) const poseKeypointMapAnimationFrameRef = useRef(null) const poseMapSlotRef = useRef(null) const poseFaceImagePanCenterRef = useRef<{ x: number; y: number } | null>(null) const poseFaceImagePanGestureRef = useRef(null) const poseFaceImageDisplayCameraRef = useRef(null) const poseFaceImageCameraAnimationFrameRef = useRef(null) const poseFaceImageZoomOutActiveRef = useRef(false) 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 switchPoseKeypointMapView = useCallback((nextView: PoseKeypointMapView) => { const currentView = poseKeypointMapViewRef.current if (currentView === nextView) return if (poseKeypointMapAnimationFrameRef.current !== null) { cancelAnimationFrame(poseKeypointMapAnimationFrameRef.current) poseKeypointMapAnimationFrameRef.current = null } const fromViewBox = poseKeypointMapViewBoxValueRef.current const toViewBox = poseKeypointMapViewBox(nextView) const startedAt = performance.now() poseKeypointMapViewRef.current = nextView setPoseKeypointMapView(nextView) setPoseKeypointMapAnimating(true) const animateZoom = (now: number) => { const progress = Math.min(1, (now - startedAt) / POSE_KEYPOINT_MAP_ZOOM_DURATION_MS) const nextViewBox = interpolatePoseMapViewBox(fromViewBox, toViewBox, progress) poseKeypointMapViewBoxValueRef.current = nextViewBox setPoseKeypointMapViewBoxValue(nextViewBox) if (progress < 1) { poseKeypointMapAnimationFrameRef.current = requestAnimationFrame(animateZoom) return } poseKeypointMapAnimationFrameRef.current = null poseKeypointMapViewBoxValueRef.current = toViewBox setPoseKeypointMapViewBoxValue(toViewBox) setPoseKeypointMapAnimating(false) } poseKeypointMapAnimationFrameRef.current = requestAnimationFrame(animateZoom) }, []) const resetPoseUiToBoxes = useCallback(() => { setShowPoseSkeleton(false) setActivePosePersonIndex(null) setPendingPoseKeypoint(null) setHoveredPoseKeypoint(null) setSelectedPoseKeypointAction(null) poseFaceImagePanGestureRef.current = null poseFaceImagePanCenterRef.current = null setPoseFaceImagePanCenter(null) setPoseFaceImagePanning(false) if (poseFaceImageCameraAnimationFrameRef.current !== null) { cancelAnimationFrame(poseFaceImageCameraAnimationFrameRef.current) poseFaceImageCameraAnimationFrameRef.current = null } poseFaceImageDisplayCameraRef.current = null poseFaceImageZoomOutActiveRef.current = false setPoseFaceImageDisplayCamera(null) setPoseFaceImageCameraAnimating(false) setPoseFaceImageZoomOutActive(false) if (poseKeypointMapAnimationFrameRef.current !== null) { cancelAnimationFrame(poseKeypointMapAnimationFrameRef.current) poseKeypointMapAnimationFrameRef.current = null } poseKeypointMapViewRef.current = 'body' poseKeypointMapViewBoxValueRef.current = POSE_BODY_MAP_VIEW_BOX setPoseKeypointMapView('body') setPoseKeypointMapViewBoxValue(POSE_BODY_MAP_VIEW_BOX) setPoseKeypointMapAnimating(false) }, []) 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(() => { poseInteractionRef.current = poseInteraction }, [poseInteraction]) useEffect(() => { poseKeypointMapViewRef.current = poseKeypointMapView }, [poseKeypointMapView]) useEffect(() => { poseFaceImagePanCenterRef.current = poseFaceImagePanCenter }, [poseFaceImagePanCenter]) useEffect(() => { poseFaceImageDisplayCameraRef.current = poseFaceImageDisplayCamera }, [poseFaceImageDisplayCamera]) useEffect(() => { poseFaceImageZoomOutActiveRef.current = poseFaceImageZoomOutActive }, [poseFaceImageZoomOutActive]) useEffect(() => { return () => { if (poseKeypointMapAnimationFrameRef.current !== null) { cancelAnimationFrame(poseKeypointMapAnimationFrameRef.current) poseKeypointMapAnimationFrameRef.current = null } if (poseFaceImageCameraAnimationFrameRef.current !== null) { cancelAnimationFrame(poseFaceImageCameraAnimationFrameRef.current) poseFaceImageCameraAnimationFrameRef.current = null } poseFaceImagePanGestureRef.current = null } }, []) 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 correction.posePersons ?? [] }, [correction.posePersons]) const poseTrainingAllowed = !isNoSexPositionValue(correction.sexPosition) const hasPosePersons = posePersons.some((person) => hasVisiblePoseBox(person) || person.keypoints?.some(isPoseKeypointVisible) ) const visiblePosePersonCount = posePersons.filter((person) => hasVisiblePoseBox(person) || person.keypoints?.some(isPoseKeypointVisible) ).length const visiblePosePersonIndexes = useMemo( () => posePersons .map((person, index) => ({ person, index })) .filter( ({ person }) => hasVisiblePoseBox(person) || person.keypoints?.some(isPoseKeypointVisible) ) .map(({ index }) => index), [posePersons] ) const originalPosePersonCount = sample?.prediction.persons?.length ?? 0 const firstVisiblePosePersonIndex = posePersons.findIndex((person) => hasVisiblePoseBox(person) || person.keypoints?.some(isPoseKeypointVisible) ) const activePosePerson = activePosePersonIndex !== null ? posePersons[activePosePersonIndex] : undefined const activePosePersonVisible = Boolean( activePosePerson && (hasVisiblePoseBox(activePosePerson) || activePosePerson.keypoints?.some(isPoseKeypointVisible)) ) const poseCorrectionPersonIndex = activePosePersonIndex !== null && activePosePersonVisible ? activePosePersonIndex : visiblePosePersonCount === 1 ? firstVisiblePosePersonIndex : null const poseCorrectionPerson = poseCorrectionPersonIndex !== null ? posePersons[poseCorrectionPersonIndex] : undefined const poseCorrectionVisibleIndex = poseCorrectionPersonIndex !== null ? visiblePosePersonIndexes.indexOf(poseCorrectionPersonIndex) : -1 useEffect(() => { setActivePosePersonIndex((current) => current !== null && current >= posePersons.length ? null : current ) }, [posePersons.length]) 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]) useEffect(() => { poseFaceImagePanGestureRef.current = null poseFaceImagePanCenterRef.current = null setPoseFaceImagePanCenter(null) setPoseFaceImagePanning(false) }, [imageSrc, poseCorrectionPersonIndex, poseKeypointMapView]) useEffect(() => { if (poseFaceImageZoomOutActiveRef.current) { return } if (poseFaceImageCameraAnimationFrameRef.current !== null) { cancelAnimationFrame(poseFaceImageCameraAnimationFrameRef.current) poseFaceImageCameraAnimationFrameRef.current = null } poseFaceImageDisplayCameraRef.current = null setPoseFaceImageDisplayCamera(null) setPoseFaceImageCameraAnimating(false) }, [imageSrc, poseKeypointMapView]) 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) { if (!loadingRef.current) { resetPoseUiToBoxes() } 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 }, [resetPoseUiToBoxes, 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 localProgress = trainingLocalProgressFromJob( trainingStatus?.training, currentTrainingTarget ) if (localProgress !== null) return localProgress 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, trainingStatus?.training?.stage, trainingStatus?.training?.stageProgress, trainingStatus?.training?.step, ]) 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 shownTrainingProgress = trainingRunning ? estimatedTrainingProgress > 0 ? estimatedTrainingProgress : serverTrainingProgressPercent : serverTrainingProgress const drawingCursorClass = boxInteraction?.type === 'move' ? '[@media_(hover:hover)_and_(pointer:fine)]:cursor-grabbing' : drawingBox || pendingPoseKeypoint || (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, stageStartedAt: String(job.stageStartedAt ?? ''), stageProgress: job.stageProgress === undefined || job.stageProgress === null ? undefined : Number(job.stageProgress), 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), accuracy: Number(job.accuracy ?? 0), loss: Number(job.loss ?? 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) resetPoseUiToBoxes() 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, } ) }, [resetPoseUiToBoxes]) 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() resetPoseUiToBoxes() 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 { writeTrainingActiveJobStorage( 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 { removeTrainingActiveJobStorage(TRAINING_ACTIVE_NEXT_STORAGE_KEY) } catch { // ignore } setError(msg) } } } finally { if (!isCurrentRequest()) { return } if (completed) { try { removeTrainingActiveJobStorage(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, resetPoseUiToBoxes, setLoadingPreviewCandidate, waitForNextResult, ]) const reloadCurrentImage = useCallback(async () => { resetPoseUiToBoxes() setDrawingBox(null) setBoxInteraction(null) setTouchMagnifier(null) setActiveBoxIndex(null) await loadNext({ refreshPrediction: true, previewUrl: imageSrc, }) setImageReloadKey((value) => value + 1) }, [imageSrc, loadNext, resetPoseUiToBoxes]) 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 (data?.analysis) { applyTrainingAnalysisEvent(data.analysis) } 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) } }, [applyTrainingAnalysisEvent, 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 persistent gemerkte aktive Jobs + CustomEvent. if (videoImportInFlightKeyRef.current === importKey) { return false } videoImportStartedRef.current = true videoImportInFlightKeyRef.current = importKey const requestId = makeRequestId() activeAnalysisRequestIdRef.current = requestId loadingRef.current = true resetPoseUiToBoxes() setLoading(true) setAnalysisSourceFile(detail.sourceFile || detail.output.split(/[\\/]/).pop() || '') setAnalysisProgress(5) setAnalysisStep('Video wird ins Training übernommen…') setError(null) setMessage(null) let keepActiveJob = false let completed = false try { try { window.sessionStorage.removeItem(TRAINING_PENDING_IMPORT_VIDEO_STORAGE_KEY) writeTrainingActiveJobStorage( 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)) completed = true try { removeTrainingActiveJobStorage(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.` ) } completed = true 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) { keepActiveJob = true setMessage('Video-Import läuft im Backend weiter und wird beim Zurückkehren wieder aufgenommen.') } else { try { removeTrainingActiveJobStorage(TRAINING_ACTIVE_IMPORT_VIDEO_STORAGE_KEY) } catch { // ignore } setError(msg) } return false } finally { if (completed) { removeTrainingActiveJobStorage(TRAINING_ACTIVE_IMPORT_VIDEO_STORAGE_KEY) } if (keepActiveJob) { return } 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, resetPoseUiToBoxes, 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 || poseInteraction) 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, poseInteraction]) 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]) const resumeLatestServerAnalysisJob = useCallback(async () => { if ( activeAnalysisRequestIdRef.current || nextAnalysisInFlightRequestIdRef.current || videoImportInFlightKeyRef.current ) { return true } const resumeImport = (data: any) => { const requestId = String(data?.requestId || '').trim() if (!requestId) return false const importKey = `server|${requestId}` videoImportStartedRef.current = true videoImportInFlightKeyRef.current = importKey activeAnalysisRequestIdRef.current = requestId loadingRef.current = true writeTrainingActiveJobStorage( TRAINING_ACTIVE_IMPORT_VIDEO_STORAGE_KEY, JSON.stringify({ requestId, importKey, detail: {} }) ) resetPoseUiToBoxes() setLoading(true) setAnalysisSourceFile('') setAnalysisProgress(5) setAnalysisStep('Video-Import läuft im Backend…') setError(null) if (data?.analysis) { applyTrainingAnalysisEvent(data.analysis) } void waitForVideoImportResult(requestId) .then(() => { removeTrainingActiveJobStorage(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 { removeTrainingActiveJobStorage(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 (videoImportInFlightKeyRef.current === importKey) { videoImportInFlightKeyRef.current = null } }) return true } const resumeNext = (data: any) => { const requestId = String(data?.requestId || '').trim() if (!requestId) return false activeAnalysisRequestIdRef.current = requestId nextAnalysisInFlightRequestIdRef.current = requestId loadingRef.current = true writeTrainingActiveJobStorage( TRAINING_ACTIVE_NEXT_STORAGE_KEY, JSON.stringify({ requestId, opts: {} }) ) resetPoseUiToBoxes() setLoading(true) setAnalysisSourceFile('') setAnalysisProgress(5) setAnalysisStep('Analyse läuft im Backend…') setError(null) if (data?.analysis) { applyTrainingAnalysisEvent(data.analysis) } void waitForNextResult(requestId) .then(() => { removeTrainingActiveJobStorage(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 { removeTrainingActiveJobStorage(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('') } }) return true } try { const importRes = await fetch('/api/training/import-video/status', { cache: 'no-store' }) const importData = await importRes.json().catch(() => null) if ( (importRes.status === 202 || importData?.running || importData?.accepted) && importData?.ok && resumeImport(importData) ) { return true } } catch { // ignore } try { const nextRes = await fetch('/api/training/next/status', { cache: 'no-store' }) const nextData = await nextRes.json().catch(() => null) if ( (nextRes.status === 202 || nextData?.running || nextData?.accepted) && nextData?.ok && resumeNext(nextData) ) { return true } } catch { // ignore } return false }, [applyTrainingAnalysisEvent, resetPoseUiToBoxes, waitForNextResult, waitForVideoImportResult]) 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 = readTrainingActiveJobStorage(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 resetPoseUiToBoxes() 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(() => { removeTrainingActiveJobStorage(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 { removeTrainingActiveJobStorage(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 ? '' : readTrainingActiveJobStorage(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 resetPoseUiToBoxes() 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(() => { removeTrainingActiveJobStorage(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 { removeTrainingActiveJobStorage(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, resetPoseUiToBoxes, 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 const resumedServerJob = await resumeLatestServerAnalysisJob() if (cancelled || initRunIdRef.current !== runId) return if (resumedServerJob) { initializedRef.current = true 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, resumeLatestServerAnalysisJob]) 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 = readTrainingActiveJobStorage(TRAINING_ACTIVE_NEXT_STORAGE_KEY) if (!nextRequestId && activeNextRaw) { nextRequestId = String(JSON.parse(activeNextRaw)?.requestId || '').trim() } } catch { // ignore } try { const activeImportRaw = readTrainingActiveJobStorage(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 const stageStarted = job?.stageStartedAt && Number.isFinite(Date.parse(job.stageStartedAt)) ? Date.parse(job.stageStartedAt) : 0 const jobStarted = job?.startedAt && Number.isFinite(Date.parse(job.startedAt)) ? Date.parse(job.startedAt) : 0 if (targetChanged) { setEstimatedEpochMs(0) } const firstEpochAt = stageStarted > 0 ? stageStarted : !targetChanged && previous.firstEpochAt > 0 ? previous.firstEpochAt : jobStarted > 0 && (epoch > 1 || !targetChanged) ? jobStarted : now const safeEpoch = Math.max(1, Math.min(epochs, Math.floor(epoch))) const completedEpochUnits = trainingCompletedEpochUnits( epoch, epochs, currentTrainingTargetProgress ) const elapsedSinceStartMs = Math.max(0, now - firstEpochAt) // Direkt ab Epoche 1 eine erste Schätzung: // bisherige Laufzeit / aktuelle Epoche. const averageFromElapsed = elapsedSinceStartMs > 0 && completedEpochUnits > 0 ? elapsedSinceStartMs / completedEpochUnits : 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?.stageStartedAt, trainingStatus?.training?.startedAt, activeTrainingTarget, currentTrainingTarget, currentTrainingTargetProgress, trainingNowMs, ]) const saveFeedback = useCallback( async ( accepted: boolean, options?: { negative?: boolean } ) => { if (!sample || trainingRunning) return resetPoseUiToBoxes() 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, posePersons: clonePosePersons(correction.posePersons), } const negative = options?.negative ?? !correctionHasTrainablePositionOrBoxes(feedbackCorrection) const correctionPayload: CorrectionState = negative ? { sexPosition: NO_SEX_POSITION_LABEL, peoplePresent: [], bodyPartsPresent: [], objectsPresent: [], clothingPresent: [], boxes: [], posePersons: [], } : { ...correction, peoplePresent: peopleLabelsFromBoxes(normalizedBoxes, labelsRef.current), boxes: normalizedBoxes, posePersons: clonePosePersons(correction.posePersons), } 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, resetPoseUiToBoxes, 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 resetPoseUiToBoxes() 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, resetPoseUiToBoxes, ]) 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 activePointerCaptureElementRef.current = null return } try { const captureElement = activePointerCaptureElementRef.current ?? imageBoxRef.current captureElement?.releasePointerCapture(id) } catch { // Pointer-Capture war evtl. schon weg. } activePointerCaptureElementRef.current = null 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 poseInteractionRef.current = null latestGestureBoxRef.current = null }, [cancelPointerMoveFrame]) const resetBoxDrawingMode = useCallback(() => { releaseActivePointerCapture() clearBoxGestureRefs() setDrawingBox(null) setBoxInteraction(null) setTouchMagnifier(null) setBoxLabel('') setActiveBoxIndex(null) }, [clearBoxGestureRefs, releaseActivePointerCapture]) const applyPointerMoveSnapshot = useCallback((snapshot: { clientX: number clientY: number }) => { const drawing = drawingBoxRef.current const interaction = boxInteractionRef.current const poseInteraction = poseInteractionRef.current if (!drawing && !interaction && !poseInteraction) 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 if (poseInteraction) { const pointerDistance = Math.hypot( snapshot.clientX - poseInteraction.startClientX, snapshot.clientY - poseInteraction.startClientY ) const hasMoved = poseInteraction.moved || pointerDistance > POSE_KEYPOINT_TAP_MOVE_THRESHOLD_PX if (!hasMoved) { return } if (!poseInteraction.moved) { const nextInteraction = { ...poseInteraction, moved: true, } poseInteractionRef.current = nextInteraction setPoseInteraction(nextInteraction) setSelectedPoseKeypointAction(null) } markManualCorrection() let nextPoseMagnifier: MagnifierState | null = null const activePoseInteraction = poseInteractionRef.current ?? poseInteraction setCorrection((prev) => { const persons = clonePosePersons(prev.posePersons) const person = persons[activePoseInteraction.personIndex] const point = person?.keypoints?.[activePoseInteraction.keypointIndex] if (!person || !point) { return prev } const nextPoint = { ...point, x: snap01(clampedPos.x), y: snap01(clampedPos.y), conf: Math.max(clamp01(Number(point.conf ?? 1)), POSE_RELIABLE_KEYPOINT_MIN_CONFIDENCE), } nextPoseMagnifier = { visible: true, clientX: snapshot.clientX, clientY: snapshot.clientY, imageX: nextPoint.x, imageY: nextPoint.y, } const nextPerson = updatePosePersonQuality({ ...person, box: poseBoxFromKeypoints({ ...person, keypoints: person.keypoints.map((candidate, index) => index === activePoseInteraction.keypointIndex ? nextPoint : candidate ), }), keypoints: person.keypoints.map((candidate, index) => index === activePoseInteraction.keypointIndex ? nextPoint : candidate ), }) persons[activePoseInteraction.personIndex] = nextPerson const next: CorrectionState = { ...prev, posePersons: persons, } correctionRef.current = next return next }) if (nextPoseMagnifier) { setTouchMagnifier(nextPoseMagnifier) } return } 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]) const setPoseKeypointOnPerson = useCallback(( personIndex: number, keypointName: string, position: { x: number; y: number } ) => { const cleanKeypointName = poseKeypointId(keypointName) if (!cleanKeypointName) return false const currentPerson = correctionRef.current.posePersons?.[personIndex] if (!currentPerson) return false markManualCorrection() setCorrection((prev) => { const persons = clonePosePersons(prev.posePersons) const person = persons[personIndex] if (!person) { return prev } const nextPoint: TrainingKeypoint = { name: cleanKeypointName, x: snap01(position.x), y: snap01(position.y), conf: 1, } const existingIndex = person.keypoints.findIndex( (point) => poseKeypointId(point.name) === cleanKeypointName ) const keypoints = existingIndex >= 0 ? person.keypoints.map((point, index) => index === existingIndex ? nextPoint : point ) : [...person.keypoints, nextPoint] const nextPerson = updatePosePersonQuality({ ...person, keypoints, box: poseBoxFromKeypoints({ ...person, keypoints, }), }) persons[personIndex] = nextPerson const next: CorrectionState = { ...prev, posePersons: persons, } correctionRef.current = next return next }) setShowPoseSkeleton(true) setActivePosePersonIndex(personIndex) setPendingPoseKeypoint(null) return true }, [markManualCorrection]) useEffect(() => { return () => cancelPointerMoveFrame() }, [cancelPointerMoveFrame]) const startDrawBox = useCallback((e: React.PointerEvent) => { if (pendingPoseKeypoint) { const isFrameBusy = loading || (!!imageSrc && !frameImageLoaded) if (uiLocked || isFrameBusy || trainingRunning || saving) return if (drawingBoxRef.current || boxInteractionRef.current || poseInteractionRef.current) return const target = e.target as HTMLElement | null if (target?.closest('[data-box-control="true"]')) return if (target?.closest('[data-pose-control="true"]')) return const contentRect = getImageContentRect() if (!contentRect) return const rawPos = getPointerPosFromRect(contentRect, e.clientX, e.clientY, { clamp: false }) if ( rawPos.x < 0 || rawPos.x > 1 || rawPos.y < 0 || rawPos.y > 1 ) { return } e.preventDefault() e.stopPropagation() window.getSelection()?.removeAllRanges() setPoseKeypointOnPerson( pendingPoseKeypoint.personIndex, pendingPoseKeypoint.keypointName, { x: clamp01(rawPos.x), y: clamp01(rawPos.y), } ) setSelectedPoseKeypointAction(null) return } if (!boxLabel) return if (uiLocked) return if (drawingBoxRef.current || boxInteractionRef.current || poseInteractionRef.current) return const target = e.target as HTMLElement | null if (target?.closest('[data-box-control="true"]')) return if (target?.closest('[data-pose-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 { activePointerCaptureElementRef.current = e.currentTarget e.currentTarget.setPointerCapture(e.pointerId) } catch { activePointerIdRef.current = null activePointerCaptureElementRef.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, frameImageLoaded, getImageContentRect, getPointerPosFromRect, imageSrc, loading, pendingPoseKeypoint, saving, setPoseKeypointOnPerson, trainingRunning, uiLocked, ]) const moveDrawBox = useCallback((e: React.PointerEvent) => { const hasActiveGesture = Boolean( drawingBoxRef.current || boxInteractionRef.current || poseInteractionRef.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 const activePoseInteraction = poseInteractionRef.current releaseActivePointerCapture( typeof e?.pointerId === 'number' ? e.pointerId : null ) setTouchMagnifier(null) if (finishingGestureRef.current) { setDrawingBox(null) setBoxInteraction(null) setPoseInteraction(null) clearBoxGestureRefs() return } finishingGestureRef.current = true if (activeDrawingBox || activeInteraction || activePoseInteraction) { e?.preventDefault() e?.stopPropagation() } if (activePoseInteraction) { if (activePoseInteraction.moved) { setSelectedPoseKeypointAction(null) } else { setSelectedPoseKeypointAction({ personIndex: activePoseInteraction.personIndex, keypointIndex: activePoseInteraction.keypointIndex, }) setPendingPoseKeypoint(null) setActivePosePersonIndex(activePoseInteraction.personIndex) } setPoseInteraction(null) clearBoxGestureRefs() return } 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 && !poseInteraction) return const movePointer = (event: PointerEvent) => { if (!drawingBoxRef.current && !boxInteractionRef.current && !poseInteractionRef.current) return event.preventDefault() event.stopPropagation() schedulePointerMove({ clientX: event.clientX, clientY: event.clientY, }) } const finishPointer = (event: PointerEvent) => { finishDrawBox(event) } const finishWithoutPointer = () => { finishDrawBox() } const finishOnHidden = () => { if (document.hidden) { finishDrawBox() } } window.addEventListener('pointermove', movePointer, true) window.addEventListener('pointerup', finishPointer, true) window.addEventListener('pointercancel', finishPointer, true) window.addEventListener('blur', finishWithoutPointer) document.addEventListener('visibilitychange', finishOnHidden) return () => { window.removeEventListener('pointermove', movePointer, true) window.removeEventListener('pointerup', finishPointer, true) window.removeEventListener('pointercancel', finishPointer, true) window.removeEventListener('blur', finishWithoutPointer) document.removeEventListener('visibilitychange', finishOnHidden) } }, [drawingBox, boxInteraction, poseInteraction, finishDrawBox, schedulePointerMove]) 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 removePosePerson = useCallback((personIndex: number) => { markManualCorrection() setCorrection((prev) => { const persons = clonePosePersons(prev.posePersons) if (personIndex < 0 || personIndex >= persons.length) return prev const next: CorrectionState = { ...prev, posePersons: persons.filter((_, index) => index !== personIndex), } correctionRef.current = next return next }) setActivePosePersonIndex((current) => { if (current === null) return null if (current === personIndex) return null if (current > personIndex) return current - 1 return current }) setPendingPoseKeypoint((current) => { if (!current) return null if (current.personIndex === personIndex) return null if (current.personIndex > personIndex) { return { ...current, personIndex: current.personIndex - 1, } } return current }) setSelectedPoseKeypointAction((current) => { if (!current) return null if (current.personIndex === personIndex) return null if (current.personIndex > personIndex) { return { ...current, personIndex: current.personIndex - 1, } } return current }) }, [markManualCorrection]) const addPosePerson = useCallback(() => { if (uiLocked || !poseTrainingAllowed) return markManualCorrection() const nextPersonIndex = clonePosePersons(correctionRef.current.posePersons).length setCorrection((prev) => { const persons = clonePosePersons(prev.posePersons) const next: CorrectionState = { ...prev, posePersons: [...persons, createEmptyPosePerson()], } correctionRef.current = next return next }) setShowPoseSkeleton(true) resetBoxDrawingMode() setActivePosePersonIndex(nextPersonIndex) setPendingPoseKeypoint(null) setSelectedPoseKeypointAction(null) switchPoseKeypointMapView('body') setMobilePanel('labels') window.requestAnimationFrame(() => { mobileLabelsScrollRef.current?.scrollIntoView({ block: 'start', behavior: 'smooth', }) }) }, [markManualCorrection, poseTrainingAllowed, resetBoxDrawingMode, switchPoseKeypointMapView, uiLocked]) const removePoseKeypoint = useCallback((personIndex: number, keypointIndex: number) => { markManualCorrection() setCorrection((prev) => { const persons = clonePosePersons(prev.posePersons) const person = persons[personIndex] const point = person?.keypoints?.[keypointIndex] if (!person || !point) return prev const keypoints = person.keypoints.map((candidate, index) => index === keypointIndex ? { ...candidate, x: 0, y: 0, conf: 0, } : candidate ) const nextPerson = updatePosePersonQuality({ ...person, keypoints, box: poseBoxFromKeypoints({ ...person, keypoints, }), }) persons[personIndex] = nextPerson const next: CorrectionState = { ...prev, posePersons: persons, } correctionRef.current = next return next }) setSelectedPoseKeypointAction((current) => current?.personIndex === personIndex && current.keypointIndex === keypointIndex ? null : current ) }, [markManualCorrection]) const resetPosePersons = useCallback(() => { markManualCorrection() const persons = clonePosePersons(sampleRef.current?.prediction.persons) setCorrection((prev) => { const next: CorrectionState = { ...prev, posePersons: persons, } correctionRef.current = next return next }) setActivePosePersonIndex(null) setSelectedPoseKeypointAction(null) if (poseKeypointMapAnimationFrameRef.current !== null) { cancelAnimationFrame(poseKeypointMapAnimationFrameRef.current) poseKeypointMapAnimationFrameRef.current = null } setPendingPoseKeypoint(null) poseKeypointMapViewRef.current = 'body' poseKeypointMapViewBoxValueRef.current = POSE_BODY_MAP_VIEW_BOX setPoseKeypointMapView('body') setPoseKeypointMapViewBoxValue(POSE_BODY_MAP_VIEW_BOX) setPoseKeypointMapAnimating(false) }, [markManualCorrection]) const selectAdjacentPoseFace = useCallback((direction: -1 | 1) => { if (visiblePosePersonIndexes.length === 0) return const currentPosition = poseCorrectionPersonIndex !== null ? visiblePosePersonIndexes.indexOf(poseCorrectionPersonIndex) : -1 const fallbackPosition = direction > 0 ? -1 : 0 const nextPosition = (currentPosition >= 0 ? currentPosition : fallbackPosition) + direction const wrappedPosition = (nextPosition + visiblePosePersonIndexes.length) % visiblePosePersonIndexes.length const nextPersonIndex = visiblePosePersonIndexes[wrappedPosition] if (nextPersonIndex === undefined) return setActivePosePersonIndex(nextPersonIndex) setPendingPoseKeypoint(null) }, [poseCorrectionPersonIndex, visiblePosePersonIndexes]) const startPoseKeypointDrag = useCallback(( e: React.PointerEvent, personIndex: number, keypointIndex: number ) => { if (uiLocked) return if (drawingBoxRef.current || boxInteractionRef.current || poseInteractionRef.current) return const contentRect = getImageContentRect() if (!contentRect) return const pos = getPointerPosFromRect(contentRect, e.clientX, e.clientY) const point = correctionRef.current.posePersons?.[personIndex]?.keypoints?.[keypointIndex] const pointX = Number(point?.x) const pointY = Number(point?.y) const hasPointPosition = Number.isFinite(pointX) && Number.isFinite(pointY) e.preventDefault() e.stopPropagation() window.getSelection()?.removeAllRanges() activeImageContentRectRef.current = contentRect finishingGestureRef.current = false activePointerIdRef.current = e.pointerId try { activePointerCaptureElementRef.current = e.currentTarget e.currentTarget.setPointerCapture(e.pointerId) } catch { activePointerIdRef.current = null activePointerCaptureElementRef.current = null } const nextInteraction: PoseInteraction = { type: 'keypoint', personIndex, keypointIndex, startClientX: e.clientX, startClientY: e.clientY, moved: false, } poseInteractionRef.current = nextInteraction setPoseInteraction(nextInteraction) setActivePosePersonIndex(personIndex) setSelectedPoseKeypointAction(null) setTouchMagnifier({ visible: true, clientX: e.clientX, clientY: e.clientY, imageX: hasPointPosition ? clamp01(pointX) : pos.x, imageY: hasPointPosition ? clamp01(pointY) : pos.y, }) }, [getImageContentRect, getPointerPosFromRect, uiLocked]) 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 poseModeAvailable = poseTrainingAllowed && (Boolean(sample) || hasPosePersons || originalPosePersonCount > 0) const poseModeActive = showPoseSkeleton && poseModeAvailable const annotationLayerHidden = frameBusy || trainingRunning || saving const poseSkeletonVisible = poseModeActive && hasPosePersons && !annotationLayerHidden const showImageBoxes = !annotationLayerHidden && !poseModeActive const showAnnotationMagnifier = showImageBoxes || poseSkeletonVisible const focusedPoseOverlayPersonIndex = poseCorrectionPersonIndex const shouldRenderPoseOverlayPerson = useCallback((personIndex: number) => focusedPoseOverlayPersonIndex === null || personIndex === focusedPoseOverlayPersonIndex, [focusedPoseOverlayPersonIndex] ) useEffect(() => { if (poseTrainingAllowed || !showPoseSkeleton) return setShowPoseSkeleton(false) setActivePosePersonIndex(null) setPendingPoseKeypoint(null) setSelectedPoseKeypointAction(null) }, [poseTrainingAllowed, showPoseSkeleton]) const poseFaceImageZoomActive = poseModeActive && poseKeypointMapView === 'head' && Boolean(poseCorrectionPerson) const poseFaceImageZoomRegion = useMemo( () => poseFaceImageZoomActive ? posePersonFaceZoomRegion(poseCorrectionPerson) : null, [poseCorrectionPerson, poseFaceImageZoomActive] ) const poseFaceImageEffectiveZoomRegion = useMemo(() => { if (!poseFaceImageZoomRegion) return null if (!poseFaceImagePanCenter) { return poseFaceImageZoomRegion } return normalizedZoomRegion( poseFaceImagePanCenter.x, poseFaceImagePanCenter.y, poseFaceImageZoomRegion.width, poseFaceImageZoomRegion.height ) }, [poseFaceImagePanCenter, poseFaceImageZoomRegion]) const poseFaceImageCamera = useMemo((): PoseFaceImageCamera | null => { if (!poseFaceImageZoomActive || !poseFaceImageEffectiveZoomRegion || !imageLayerStyle) { return null } const boxWidth = Number(imageBoxSize.width) const boxHeight = Number(imageBoxSize.height) const contentLeft = Number(imageLayerStyle.left) const contentTop = Number(imageLayerStyle.top) const contentWidth = Number(imageLayerStyle.width) const contentHeight = Number(imageLayerStyle.height) if ( !Number.isFinite(boxWidth) || !Number.isFinite(boxHeight) || !Number.isFinite(contentLeft) || !Number.isFinite(contentTop) || !Number.isFinite(contentWidth) || !Number.isFinite(contentHeight) || boxWidth <= 0 || boxHeight <= 0 || contentWidth <= 0 || contentHeight <= 0 ) { return null } const targetWidth = Math.max(1, poseFaceImageEffectiveZoomRegion.width * contentWidth) const targetHeight = Math.max(1, poseFaceImageEffectiveZoomRegion.height * contentHeight) const fitScale = Math.min(boxWidth / targetWidth, boxHeight / targetHeight) const scale = Math.min(4.25, Math.max(1.45, fitScale * 0.72)) const viewportWidth = Math.min(1, Math.max(0.015, boxWidth / (scale * contentWidth))) const viewportHeight = Math.min(1, Math.max(0.015, boxHeight / (scale * contentHeight))) const cameraCenter = clampPoseFaceCameraCenter( poseFaceImageEffectiveZoomRegion.centerX, poseFaceImageEffectiveZoomRegion.centerY, viewportWidth, viewportHeight ) const centerX = cameraCenter.x const centerY = cameraCenter.y const focusX = contentLeft + centerX * contentWidth const focusY = contentTop + centerY * contentHeight const translateX = boxWidth / 2 - focusX * scale const translateY = boxHeight / 2 - focusY * scale return { scale, translateX, translateY, centerX, centerY, viewportWidth, viewportHeight, visibleRegion: { x: Math.max(0, Math.min(1 - viewportWidth, centerX - viewportWidth / 2)), y: Math.max(0, Math.min(1 - viewportHeight, centerY - viewportHeight / 2)), w: viewportWidth, h: viewportHeight, }, } }, [imageBoxSize.height, imageBoxSize.width, imageLayerStyle, poseFaceImageEffectiveZoomRegion, poseFaceImageZoomActive]) useEffect(() => { if (poseFaceImageZoomOutActiveRef.current) { return } if (poseFaceImageCameraAnimationFrameRef.current !== null) { cancelAnimationFrame(poseFaceImageCameraAnimationFrameRef.current) poseFaceImageCameraAnimationFrameRef.current = null } if (!poseFaceImageCamera) { poseFaceImageDisplayCameraRef.current = null setPoseFaceImageDisplayCamera(null) setPoseFaceImageCameraAnimating(false) return } if (poseFaceImagePanning) { poseFaceImageDisplayCameraRef.current = poseFaceImageCamera setPoseFaceImageDisplayCamera(poseFaceImageCamera) setPoseFaceImageCameraAnimating(false) return } const fromCamera = poseFaceImageDisplayCameraRef.current ?? poseFaceImageFullCamera() const toCamera = poseFaceImageCamera const startedAt = performance.now() const almostSame = Math.abs(fromCamera.scale - toCamera.scale) <= 0.001 && Math.abs(fromCamera.translateX - toCamera.translateX) <= 0.5 && Math.abs(fromCamera.translateY - toCamera.translateY) <= 0.5 if (almostSame) { poseFaceImageDisplayCameraRef.current = toCamera setPoseFaceImageDisplayCamera(toCamera) setPoseFaceImageCameraAnimating(false) return } setPoseFaceImageCameraAnimating(true) poseFaceImageDisplayCameraRef.current = fromCamera setPoseFaceImageDisplayCamera(fromCamera) const animateCamera = (now: number) => { const progress = Math.min(1, (now - startedAt) / POSE_FACE_IMAGE_ZOOM_DURATION_MS) const nextCamera = interpolatePoseFaceImageCamera(fromCamera, toCamera, progress) poseFaceImageDisplayCameraRef.current = nextCamera setPoseFaceImageDisplayCamera(nextCamera) if (progress < 1) { poseFaceImageCameraAnimationFrameRef.current = requestAnimationFrame(animateCamera) return } poseFaceImageCameraAnimationFrameRef.current = null poseFaceImageDisplayCameraRef.current = toCamera setPoseFaceImageDisplayCamera(toCamera) setPoseFaceImageCameraAnimating(false) } poseFaceImageCameraAnimationFrameRef.current = requestAnimationFrame(animateCamera) }, [poseFaceImageCamera, poseFaceImagePanning]) const poseFaceImageRenderedCamera = poseFaceImageCamera || poseFaceImageZoomOutActive ? poseFaceImageDisplayCamera ?? poseFaceImageFullCamera() : null const poseFaceImageZoomStyle = useMemo((): CSSProperties | undefined => { if (!poseFaceImageRenderedCamera) { return undefined } return { transform: `matrix(${poseFaceImageRenderedCamera.scale}, 0, 0, ${poseFaceImageRenderedCamera.scale}, ${poseFaceImageRenderedCamera.translateX}, ${poseFaceImageRenderedCamera.translateY})`, transformOrigin: '0 0', } }, [poseFaceImageRenderedCamera]) const poseFaceImageZoomApplied = Boolean(poseFaceImageZoomStyle) const setClampedPoseFaceImagePanCenter = useCallback(( centerX: number, centerY: number, camera: PoseFaceImageCamera | null = poseFaceImageCamera ) => { if (!camera) return const next = clampPoseFaceCameraCenter( centerX, centerY, camera.viewportWidth, camera.viewportHeight ) poseFaceImagePanCenterRef.current = next setPoseFaceImagePanCenter((current) => { if ( current && Math.abs(current.x - next.x) <= 0.0005 && Math.abs(current.y - next.y) <= 0.0005 ) { return current } return next }) }, [poseFaceImageCamera]) const startPoseFaceImagePan = useCallback((event: React.PointerEvent) => { if ( !poseFaceImageZoomApplied || !poseFaceImageCamera || !imageLayerStyle || poseFaceImageCameraAnimating ) { return } if (event.button !== 0) return if (pendingPoseKeypoint || boxLabel || uiLocked || frameBusy || trainingRunning || saving) return if (drawingBoxRef.current || boxInteractionRef.current || poseInteractionRef.current) return const target = event.target as HTMLElement | null if (target?.closest('[data-box-control="true"]')) return if (target?.closest('[data-pose-control="true"]')) return const contentWidth = Number(imageLayerStyle.width) const contentHeight = Number(imageLayerStyle.height) if ( !Number.isFinite(contentWidth) || !Number.isFinite(contentHeight) || contentWidth <= 0 || contentHeight <= 0 ) { return } event.preventDefault() event.stopPropagation() window.getSelection()?.removeAllRanges() poseFaceImagePanGestureRef.current = { pointerId: event.pointerId, source: 'image', startClientX: event.clientX, startClientY: event.clientY, startCenterX: poseFaceImageCamera.centerX, startCenterY: poseFaceImageCamera.centerY, contentWidth, contentHeight, scale: poseFaceImageCamera.scale, moved: false, } setPoseFaceImagePanning(true) try { event.currentTarget.setPointerCapture(event.pointerId) } catch { // Pointer-Capture ist nur Komfort; die lokale Geste funktioniert auch ohne. } }, [ boxLabel, frameBusy, imageLayerStyle, pendingPoseKeypoint, poseFaceImageCamera, poseFaceImageCameraAnimating, poseFaceImageZoomApplied, saving, trainingRunning, uiLocked, ]) const startPoseFacePreviewPan = useCallback((event: React.PointerEvent) => { if (!poseFaceImageZoomApplied || !poseFaceImageCamera || poseFaceImageCameraAnimating) return if (event.button !== 0) return event.preventDefault() event.stopPropagation() window.getSelection()?.removeAllRanges() poseFaceImagePanGestureRef.current = { pointerId: event.pointerId, source: 'preview', startClientX: event.clientX, startClientY: event.clientY, startCenterX: poseFaceImageCamera.centerX, startCenterY: poseFaceImageCamera.centerY, contentWidth: 1, contentHeight: 1, scale: 1, moved: false, } setPoseFaceImagePanning(true) try { event.currentTarget.setPointerCapture(event.pointerId) } catch { // Pointer-Capture ist nur Komfort; die lokale Geste funktioniert auch ohne. } }, [poseFaceImageCamera, poseFaceImageCameraAnimating, poseFaceImageZoomApplied]) const movePoseFaceImagePan = useCallback((event: React.PointerEvent) => { const gesture = poseFaceImagePanGestureRef.current if (!gesture || gesture.pointerId !== event.pointerId) return event.preventDefault() event.stopPropagation() const deltaX = event.clientX - gesture.startClientX const deltaY = event.clientY - gesture.startClientY if (!gesture.moved && Math.hypot(deltaX, deltaY) >= 4) { gesture.moved = true } if (gesture.source === 'image') { setClampedPoseFaceImagePanCenter( gesture.startCenterX - deltaX / (gesture.scale * gesture.contentWidth), gesture.startCenterY - deltaY / (gesture.scale * gesture.contentHeight) ) return } const rect = event.currentTarget.getBoundingClientRect() if (rect.width <= 0 || rect.height <= 0) return setClampedPoseFaceImagePanCenter( gesture.startCenterX + deltaX / rect.width, gesture.startCenterY + deltaY / rect.height ) }, [setClampedPoseFaceImagePanCenter]) const zoomOutPoseFaceImageToBody = useCallback(() => { if (poseFaceImageCameraAnimationFrameRef.current !== null) { cancelAnimationFrame(poseFaceImageCameraAnimationFrameRef.current) poseFaceImageCameraAnimationFrameRef.current = null } const fromCamera = poseFaceImageDisplayCameraRef.current ?? poseFaceImageCamera ?? poseFaceImageFullCamera() const toCamera = poseFaceImageFullCamera() const startedAt = performance.now() poseFaceImagePanGestureRef.current = null poseFaceImagePanCenterRef.current = null setPoseFaceImagePanCenter(null) setPoseFaceImagePanning(false) poseFaceImageZoomOutActiveRef.current = true setPoseFaceImageZoomOutActive(true) setPoseFaceImageCameraAnimating(true) poseFaceImageDisplayCameraRef.current = fromCamera setPoseFaceImageDisplayCamera(fromCamera) // Die rechte Map darf parallel zurueckfahren; die Bildkamera bleibt durch // den Override sichtbar, bis der Zoom-Out fertig ist. switchPoseKeypointMapView('body') const animateZoomOut = (now: number) => { const progress = Math.min(1, (now - startedAt) / POSE_FACE_IMAGE_ZOOM_DURATION_MS) const nextCamera = interpolatePoseFaceImageCamera(fromCamera, toCamera, progress) poseFaceImageDisplayCameraRef.current = nextCamera setPoseFaceImageDisplayCamera(nextCamera) if (progress < 1) { poseFaceImageCameraAnimationFrameRef.current = requestAnimationFrame(animateZoomOut) return } poseFaceImageCameraAnimationFrameRef.current = null poseFaceImageDisplayCameraRef.current = null poseFaceImageZoomOutActiveRef.current = false setPoseFaceImageDisplayCamera(null) setPoseFaceImageCameraAnimating(false) setPoseFaceImageZoomOutActive(false) } poseFaceImageCameraAnimationFrameRef.current = requestAnimationFrame(animateZoomOut) }, [poseFaceImageCamera, switchPoseKeypointMapView]) const deletePosePerson = useCallback((personIndex: number) => { const shouldNavigateAfterDelete = poseFaceImageZoomApplied && poseCorrectionPersonIndex === personIndex if (!shouldNavigateAfterDelete) { removePosePerson(personIndex) return } const remainingVisibleIndexes = visiblePosePersonIndexes.filter((index) => index !== personIndex) if (remainingVisibleIndexes.length === 0) { removePosePerson(personIndex) zoomOutPoseFaceImageToBody() return } const currentPosition = visiblePosePersonIndexes.indexOf(personIndex) const nextOldIndex = remainingVisibleIndexes.find((index) => visiblePosePersonIndexes.indexOf(index) > currentPosition ) ?? remainingVisibleIndexes[0] const nextPersonIndex = nextOldIndex > personIndex ? nextOldIndex - 1 : nextOldIndex removePosePerson(personIndex) setShowPoseSkeleton(true) setActivePosePersonIndex(nextPersonIndex) setPendingPoseKeypoint(null) switchPoseKeypointMapView('head') }, [ poseCorrectionPersonIndex, poseFaceImageZoomApplied, removePosePerson, switchPoseKeypointMapView, visiblePosePersonIndexes, zoomOutPoseFaceImageToBody, ]) const finishPoseFaceImagePan = useCallback((event: React.PointerEvent) => { const gesture = poseFaceImagePanGestureRef.current if (!gesture || gesture.pointerId !== event.pointerId) return event.preventDefault() event.stopPropagation() try { event.currentTarget.releasePointerCapture(event.pointerId) } catch { // Pointer-Capture war evtl. schon weg. } poseFaceImagePanGestureRef.current = null setPoseFaceImagePanning(false) if (gesture.source === 'preview' && !gesture.moved) { zoomOutPoseFaceImageToBody() } }, [zoomOutPoseFaceImageToBody]) useEffect(() => { if (!poseModeActive) { setPoseMapSlotHeight(0) return } const mediaQuery = window.matchMedia('(min-width: 1024px)') const updateHeight = () => { const element = poseMapSlotRef.current if (!mediaQuery.matches || !element) { setPoseMapSlotHeight(0) return } const nextHeight = Math.floor(element.getBoundingClientRect().height) setPoseMapSlotHeight((current) => current === nextHeight ? current : nextHeight ) } updateHeight() const resizeObserver = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(() => updateHeight()) : null if (resizeObserver && poseMapSlotRef.current) { resizeObserver.observe(poseMapSlotRef.current) } mediaQuery.addEventListener('change', updateHeight) window.addEventListener('resize', updateHeight) return () => { resizeObserver?.disconnect() mediaQuery.removeEventListener('change', updateHeight) window.removeEventListener('resize', updateHeight) } }, [poseModeActive]) const annotationModeTabs = () => { if (!poseModeAvailable) return null const modeButtonClass = (active: boolean) => [ 'inline-flex h-9 min-w-0 items-center justify-center gap-1.5 rounded-lg px-2 text-xs font-bold transition', 'focus:outline-none focus:ring-2 focus:ring-indigo-500/35', active ? 'bg-white text-indigo-700 shadow-sm ring-1 ring-indigo-200 dark:bg-indigo-500 dark:text-white dark:ring-indigo-300/30' : 'text-gray-600 hover:bg-white/80 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-white/10 dark:hover:text-white', ].join(' ') return (
) } 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 completedEpochUnits = hasEpochInfo ? trainingCompletedEpochUnits(epoch, epochs, currentTrainingTargetProgress) : 0 const remainingEpochs = Number.isFinite(epochs) && epochs > 0 ? Math.max(0, epochs - completedEpochUnits) : 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, ]) const trainingMetricLabel = currentTrainingTarget === 'videomae' ? 'Accuracy' : 'mAP50' const trainingMetricText = currentTrainingTarget === 'videomae' ? formatMapPercent(trainingStatus?.training?.accuracy) || '—' : formatMapPercent(trainingStatus?.training?.map50) || '—' 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 const poseItems = posePersons .map((person, index) => ({ person, index })) .filter(({ person }) => hasVisiblePoseBox(person) || person.keypoints?.some(isPoseKeypointVisible) ) const hasPoseItems = poseItems.length > 0 const activeAnnotationTitle = poseModeActive ? 'Pose' : 'Detector-Boxen' const activeAnnotationCount = poseModeActive ? poseItems.length : correctionBoxes.length const activeAnnotationHasItems = poseModeActive ? hasPoseItems : hasBoxes return (
{activeAnnotationTitle}
{activeAnnotationCount}
{!compact ? (
{poseModeActive ? 'Personen und Punkte prüfen oder einzeln löschen.' : 'Prüfen, Label ändern oder einzeln löschen.'}
) : null}
{poseModeActive && originalPosePersonCount > 0 ? ( ) : !poseModeActive && hasBoxes ? ( ) : null}
{poseModeAvailable ? (
{annotationModeTabs()}
) : null}
{poseModeActive ? ( <> {!hasPoseItems ? (
Keine Pose vorhanden
{originalPosePersonCount > 0 ? 'Reset stellt die zuletzt erkannte Pose wieder her.' : 'Lege eine Person an und setze die Pose-Punkte im Bild.'}
) : ( poseItems.map(({ person, index: personIndex }) => { const reliable = isPosePersonReliable(person) const isActive = activePosePersonIndex === personIndex const color = reliable ? POSE_PERSON_COLORS[personIndex % POSE_PERSON_COLORS.length] : POSE_UNRELIABLE_COLOR const scoreText = typeof person.score === 'number' ? percent(person.score) : '' const quality = Math.round(posePersonQuality(person) * 100) const visibleKeypoints = posePersonVisibleKeypoints(person) const keypoints = (person.keypoints ?? []) .map((point, pointIndex) => ({ point, pointIndex })) .filter(({ point }) => isPoseKeypointVisible(point)) return (
{ if (uiLocked) return setShowPoseSkeleton(true) setPendingPoseKeypoint(null) setActivePosePersonIndex((current) => current === personIndex ? null : personIndex ) }} 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-blue-200 bg-blue-50/40 ring-1 ring-blue-100 dark:border-blue-400/30 dark:bg-blue-500/10 dark:ring-blue-400/20' : 'border-gray-200 hover:bg-gray-50/80 hover:shadow-md dark:hover:bg-white/[0.04]', ].join(' ')} > ) }) )} ) : !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((current) => current === index ? null : 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 poseKeypointCorrectionPanel = () => { const targetIndex = poseCorrectionPersonIndex const targetPerson = targetIndex !== null && targetIndex >= 0 ? posePersons[targetIndex] : undefined const targetPoseLabel = targetIndex !== null && targetIndex >= 0 ? `Pose #${targetIndex + 1}` : 'Keine Pose ausgewählt' const visibleKeypointIds = new Set( (targetPerson?.keypoints ?? []) .filter(isPoseKeypointVisible) .map((point) => poseKeypointId(point.name)) ) const disabled = uiLocked || frameBusy || trainingRunning || saving || !targetPerson const faceVisibleCount = POSE_FACE_KEYPOINT_SHAPES.filter((shape) => visibleKeypointIds.has(shape.keypointName) ).length const mapShapes = poseKeypointMapView === 'head' ? POSE_FACE_KEYPOINT_SHAPES : POSE_BODY_KEYPOINT_SHAPES const mapAspectRatio = poseKeypointMapView === 'head' ? POSE_HEAD_MAP_ASPECT_RATIO : POSE_BODY_MAP_ASPECT_RATIO const mapViewBox = poseKeypointMapViewBoxValue const mapMarkerRadius = poseKeypointMapView === 'head' ? 6 : 7 const mapWidthLimitPx = poseMapSlotHeight > 0 ? Math.floor(poseMapSlotHeight * POSE_BODY_MAP_ASPECT_RATIO_VALUE) : 0 const mapContainerStyle = { aspectRatio: mapAspectRatio, ...(mapWidthLimitPx > 0 ? { maxHeight: `${poseMapSlotHeight}px`, width: `min(14.5rem, 86%, ${mapWidthLimitPx}px)`, } : {}), } as CSSProperties const headPending = pendingPoseKeypoint?.personIndex === targetIndex && POSE_FACE_KEYPOINT_SHAPES.some( (shape) => shape.keypointName === pendingPoseKeypoint.keypointName ) const headPresent = faceVisibleCount > 0 const headActive = headPending || headPresent const showMapParts = !poseKeypointMapAnimating const selectPoseKeypoint = (keypointName: string) => { if (disabled || targetIndex === null) return const pending = pendingPoseKeypoint?.personIndex === targetIndex && pendingPoseKeypoint.keypointName === keypointName if (pending) { setPendingPoseKeypoint(null) setSelectedPoseKeypointAction(null) return } setShowPoseSkeleton(true) setActivePosePersonIndex(targetIndex) setSelectedPoseKeypointAction(null) setPendingPoseKeypoint({ personIndex: targetIndex, keypointName, }) } return (
Pose-Körperteile
{targetPerson ? `${targetPoseLabel}: Punkt wählen und im Bild setzen.` : visiblePosePersonCount > 1 ? 'Links erst eine Pose auswählen.' : 'Keine Pose vorhanden.'}
{targetPerson ? ( {visibleKeypointIds.size}/{POSE_KEYPOINT_OPTIONS.length} ) : null}
{showMapParts && poseKeypointMapView === 'head' ? ( { event.preventDefault() event.stopPropagation() switchPoseKeypointMapView('body') }} onClick={(event) => { event.preventDefault() event.stopPropagation() switchPoseKeypointMapView('body') }} onKeyDown={(event) => { if (event.key !== 'Enter' && event.key !== ' ') return event.preventDefault() switchPoseKeypointMapView('body') }} > Koerperansicht oeffnen ) : null} {showMapParts && poseKeypointMapView === 'body' ? ( { event.preventDefault() event.stopPropagation() if (disabled) return switchPoseKeypointMapView('head') }} onClick={(event) => { event.preventDefault() event.stopPropagation() if (disabled) return switchPoseKeypointMapView('head') }} onKeyDown={(event) => { if (event.key !== 'Enter' && event.key !== ' ') return event.preventDefault() if (disabled) return switchPoseKeypointMapView('head') }} > Kopf vergrößern ) : null} {showMapParts && poseKeypointMapView === 'body' ? ( ) : null} {showMapParts ? mapShapes.map((shape) => { const present = visibleKeypointIds.has(shape.keypointName) const pending = pendingPoseKeypoint?.personIndex === targetIndex && pendingPoseKeypoint.keypointName === shape.keypointName const highlighted = hoveredPoseKeypoint?.personIndex === targetIndex && hoveredPoseKeypoint.keypointName === shape.keypointName return ( ) }) : null} {showMapParts ? mapShapes.map((shape) => { const keypointName = shape.keypointName const label = poseKeypointLabel(keypointName) const present = visibleKeypointIds.has(keypointName) const pending = pendingPoseKeypoint?.personIndex === targetIndex && pendingPoseKeypoint.keypointName === keypointName const highlighted = hoveredPoseKeypoint?.personIndex === targetIndex && hoveredPoseKeypoint.keypointName === keypointName const active = present || pending return ( { if (disabled || targetIndex === null) return setHoveredPoseKeypoint({ personIndex: targetIndex, keypointName, }) }} onPointerLeave={() => { setHoveredPoseKeypoint((current) => current?.personIndex === targetIndex && current.keypointName === keypointName ? null : current ) }} onFocus={() => { if (disabled || targetIndex === null) return setHoveredPoseKeypoint({ personIndex: targetIndex, keypointName, }) }} onBlur={() => { setHoveredPoseKeypoint((current) => current?.personIndex === targetIndex && current.keypointName === keypointName ? null : current ) }} onClick={() => selectPoseKeypoint(keypointName)} onKeyDown={(event) => { if (event.key !== 'Enter' && event.key !== ' ') return event.preventDefault() selectPoseKeypoint(keypointName) }} > {label} ) }) : null}
) } const imageTouchClass = boxLabel || drawingBox || boxInteraction || poseInteraction || pendingPoseKeypoint || poseFaceImageZoomApplied ? '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 */}
{imageSrc ? (
e.preventDefault()} onPointerDownCapture={startPoseFaceImagePan} onPointerMoveCapture={movePoseFaceImagePan} onPointerUpCapture={finishPoseFaceImagePan} onPointerCancelCapture={finishPoseFaceImagePan} 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(' ')} /> {poseSkeletonVisible && imageLayerStyle ? (() => { const layerWidth = Number(imageLayerStyle.width) const layerHeight = Number(imageLayerStyle.height) if ( !Number.isFinite(layerWidth) || !Number.isFinite(layerHeight) || layerWidth <= 0 || layerHeight <= 0 ) { return null } return (
{posePersons.map((person, personIndex) => { if (!shouldRenderPoseOverlayPerson(personIndex)) { return null } const reliable = isPosePersonReliable(person) const color = reliable ? POSE_PERSON_COLORS[personIndex % POSE_PERSON_COLORS.length] : POSE_UNRELIABLE_COLOR const keypointsByName = new Map( person.keypoints.map((point) => [poseKeypointId(point.name), point]) ) return ( {POSE_SKELETON_EDGES.map(([from, to]) => { if ( poseFaceImageZoomActive && (!POSE_FACE_KEYPOINT_NAME_SET.has(from) || !POSE_FACE_KEYPOINT_NAME_SET.has(to)) ) { return null } const fromPoint = keypointsByName.get(from) const toPoint = keypointsByName.get(to) if ( !isPoseKeypointVisible(fromPoint) || !isPoseKeypointVisible(toPoint) ) { return null } return ( ) })} ) })} {posePersons.map((person, personIndex) => { if (!shouldRenderPoseOverlayPerson(personIndex)) { return null } const reliable = isPosePersonReliable(person) const color = reliable ? POSE_PERSON_COLORS[personIndex % POSE_PERSON_COLORS.length] : POSE_UNRELIABLE_COLOR return person.keypoints .map((point, pointIndex) => { if (!isPoseKeypointVisible(point)) return null const keypointId = poseKeypointId(point.name) if ( poseFaceImageZoomActive && !POSE_FACE_KEYPOINT_NAME_SET.has(keypointId) ) { return null } const left = poseCoordPx(point.x, layerWidth) const top = poseCoordPx(point.y, layerHeight) const label = poseKeypointLabel(point.name) const confidence = Math.round(clamp01(Number(point.conf)) * 100) const selected = pendingPoseKeypoint?.personIndex === personIndex && pendingPoseKeypoint.keypointName === keypointId const actionSelected = selectedPoseKeypointAction?.personIndex === personIndex && selectedPoseKeypointAction.keypointIndex === pointIndex const hovered = hoveredPoseKeypoint?.personIndex === personIndex && hoveredPoseKeypoint.keypointName === keypointId const dragging = poseInteraction?.personIndex === personIndex && poseInteraction.keypointIndex === pointIndex const highlighted = selected || actionSelected || hovered || dragging const showLabel = selected || actionSelected || dragging const showDeleteAction = actionSelected && !dragging && !uiLocked const facePlacement = poseFaceImageZoomActive ? poseFaceKeypointLabelPlacement(keypointId) : null const labelToLeft = facePlacement?.labelToLeft ?? left > layerWidth * 0.72 return ( {showLabel ? (
{label} {showDeleteAction ? ( ) : null}
) : null}
) }) })}
) })() : 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 { activePointerCaptureElementRef.current = imageBoxRef.current imageBoxRef.current?.setPointerCapture(e.pointerId) } catch { activePointerIdRef.current = null activePointerCaptureElementRef.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 && showAnnotationMagnifier ? (() => { 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 poseMagnifierInteraction = poseInteraction const activePoseMagnifierPoint = poseMagnifierInteraction ? correction.posePersons?.[poseMagnifierInteraction.personIndex]?.keypoints?.[ poseMagnifierInteraction.keypointIndex ] ?? null : null const activePoseMagnifierX = Number(activePoseMagnifierPoint?.x) const activePoseMagnifierY = Number(activePoseMagnifierPoint?.y) const hasUsablePoseMagnifierPoint = Boolean(poseMagnifierInteraction) && Number.isFinite(activePoseMagnifierX) && Number.isFinite(activePoseMagnifierY) const magnifierFocusX = hasUsablePoseMagnifierPoint ? clamp01(activePoseMagnifierX) : boxCenterX const magnifierFocusY = hasUsablePoseMagnifierPoint ? clamp01(activePoseMagnifierY) : boxCenterY const magnifierMarkerX = hasUsablePoseMagnifierPoint ? magnifierFocusX : touchMagnifier.imageX const magnifierMarkerY = hasUsablePoseMagnifierPoint ? magnifierFocusY : touchMagnifier.imageY 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 + magnifierFocusX * rect.width const anchorTop = hasUsableBox ? rect.top + activeBox.y * rect.height : rect.top + magnifierFocusY * rect.height const anchorBottom = hasUsableBox ? rect.top + (activeBox.y + activeBox.h) * rect.height : rect.top + magnifierFocusY * 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 - magnifierFocusX * imageWidth const imageTop = size / 2 - magnifierFocusY * imageHeight const pointerX = imageLeft + magnifierMarkerX * imageWidth const pointerY = imageTop + magnifierMarkerY * 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 const showPoseMagnifierSkeleton = poseSkeletonVisible && Boolean(poseMagnifierInteraction) const magnifierIsAbovePointer = top + size / 2 <= touchMagnifier.clientY const activeMagnifierLabel = hasUsablePoseMagnifierPoint ? poseKeypointLabel(activePoseMagnifierPoint?.name) : hasUsableBox ? getSegmentLabelItem(activeBox.label).text || activeBox.label : '' return typeof document !== 'undefined' ? createPortal(
{showPoseMagnifierSkeleton ? ( {posePersons.map((person, personIndex) => { if (!shouldRenderPoseOverlayPerson(personIndex)) { return null } if (!person.keypoints?.some(isPoseKeypointVisible)) { return null } const reliable = isPosePersonReliable(person) const isActivePose = poseMagnifierInteraction?.personIndex === personIndex const color = reliable ? POSE_PERSON_COLORS[personIndex % POSE_PERSON_COLORS.length] : POSE_UNRELIABLE_COLOR const keypointsByName = new Map( person.keypoints.map((point) => [poseKeypointId(point.name), point]) ) return ( {POSE_SKELETON_EDGES.map(([from, to]) => { if ( poseFaceImageZoomActive && (!POSE_FACE_KEYPOINT_NAME_SET.has(from) || !POSE_FACE_KEYPOINT_NAME_SET.has(to)) ) { return null } const fromPoint = keypointsByName.get(from) const toPoint = keypointsByName.get(to) if ( !isPoseKeypointVisible(fromPoint) || !isPoseKeypointVisible(toPoint) ) { return null } return ( ) })} {person.keypoints.map((point, pointIndex) => { if (!isPoseKeypointVisible(point)) return null if ( poseFaceImageZoomActive && !POSE_FACE_KEYPOINT_NAME_SET.has(poseKeypointId(point.name)) ) { return null } const isActivePoint = poseMagnifierInteraction?.personIndex === personIndex && poseMagnifierInteraction.keypointIndex === pointIndex return ( ) })} ) })} ) : null} {hasUsableBox ? (
) : null}
{activeMagnifierLabel ? (
{activeMagnifierLabel}
) : null}
, document.body ) : null })() : null}
{poseFaceImageZoomActive && poseCorrectionPersonIndex !== null ? (
event.stopPropagation()} onClick={(event) => event.stopPropagation()} > {visiblePosePersonIndexes.length > 1 ? ( <> {Math.max(0, poseCorrectionVisibleIndex) + 1}/{visiblePosePersonIndexes.length} ) : null}
) : null} {poseFaceImageZoomApplied && poseFaceImageRenderedCamera?.visibleRegion ? (() => { const region = poseFaceImageRenderedCamera.visibleRegion return (
{ if (event.key !== 'Enter' && event.key !== ' ') return event.preventDefault() event.stopPropagation() zoomOutPoseFaceImageToBody() }} title="Vorschau öffnen" aria-label="Vorschau öffnen" >
) })() : 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) : '—'}
{trainingMetricLabel}
{trainingMetricText}
) : 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)}
{poseModeActive ? ( poseKeypointCorrectionPanel() ) : ( <>
{ 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, }) }} />
) }