nsfwapp/frontend/src/components/ui/TrainingTab.tsx
2026-06-26 15:25:36 +02:00

9771 lines
340 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// frontend\src\components\ui\TrainingTab.tsx
'use client'
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react'
import Button from './Button'
import LabeledSwitch from './LabeledSwitch'
import LoadingSpinner from './LoadingSpinner'
import { formatDuration } from './formatters'
import {
ArrowPathIcon,
ArrowsPointingInIcon,
ArrowsPointingOutIcon,
BoltIcon,
CheckIcon,
ForwardIcon,
InboxArrowDownIcon,
RectangleGroupIcon,
UserGroupIcon,
TrashIcon,
VideoCameraIcon,
XCircleIcon,
} from '@heroicons/react/20/solid'
import { getSegmentLabelItem } from './Icons'
import Modal from './Modal'
import { useNotify } from './notify'
import { createPortal } from 'react-dom'
import TrainingFeedbackHistoryModal from './TrainingFeedbackHistoryModal'
import type { RecorderSettingsState } from '../../types'
type DrawingTrainingBox = TrainingBox & {
startX: number
startY: number
}
type ScoredLabel = {
label: string
score: number
}
type TrainingDetectorStatus = {
trainCount: number
valCount: number
positiveTrainCount: number
positiveValCount: number
requiredTrain: number
requiredVal: number
datasetReady: boolean
dataReady: boolean
modelExists: boolean
modelPath?: string
trainedModelExists?: boolean
trainedModelPath?: string
source?: string
}
type TrainingPoseStatus = {
trainCount: number
valCount: number
requiredTrain: number
requiredVal: number
datasetReady: boolean
dataReady: boolean
modelExists: boolean
modelPath?: string
trainedModelExists?: boolean
trainedModelPath?: string
source?: string
}
type TrainingVideoMAEStatus = {
eligibleCount: number
trainCount: number
valCount: number
requiredTrain: number
requiredVal: number
datasetReady: boolean
dataReady: boolean
modelExists: boolean
modelPath?: string
trainedModelExists?: boolean
trainedModelPath?: string
source?: string
}
type TrainingStatus = {
feedbackCount: number
requiredCount: number
canTrain: boolean
training?: TrainingJobStatus
detector?: TrainingDetectorStatus
pose?: TrainingPoseStatus
videoMAE?: TrainingVideoMAEStatus
}
type TrainingJobStatus = {
running: boolean
progress: number
step: string
message?: string
error?: string
startedAt?: string
finishedAt?: string
durationMs?: number
stage?: string
epoch?: number
epochs?: number
previewUrl?: string
map50?: number
map5095?: number
}
type TrainingPrediction = {
modelAvailable: boolean
source?: string
sexPosition: string
sexPositionScore: number
peoplePresent: ScoredLabel[]
bodyPartsPresent: ScoredLabel[]
objectsPresent: ScoredLabel[]
clothingPresent: ScoredLabel[]
boxes?: TrainingBox[]
persons?: TrainingPosePerson[]
}
type TrainingSample = {
sampleId: string
frameUrl: string
sourceFile: string
sourcePath?: string
sourceSizeBytes?: number
second: number
createdAt: string
uncertaintyScore?: number
prediction: TrainingPrediction
}
type TrainingLabels = {
people: string[]
sexPositions: string[]
bodyParts: string[]
objects: string[]
clothing: string[]
}
type CorrectionState = {
sexPosition: string
peoplePresent: string[]
bodyPartsPresent: string[]
objectsPresent: string[]
clothingPresent: string[]
boxes: TrainingBox[]
}
type QueuedTrainingSample = {
sample: TrainingSample
correction?: CorrectionState
manualCorrection?: boolean
}
type TrainingBox = {
label: string
score?: number
x: number
y: number
w: number
h: number
}
type TrainingKeypoint = {
name: string
x: number
y: number
conf: number
}
type TrainingPosePerson = {
label?: string
score: number
box: TrainingBox
keypoints: TrainingKeypoint[]
quality?: number
visibleKeypoints?: number
reliable?: boolean
}
type BoxInteraction =
| {
type: 'move'
index: number
startX: number
startY: number
original: TrainingBox
}
| {
type: 'resize'
index: number
handle: 'nw' | 'ne' | 'sw' | 'se'
startX: number
startY: number
original: TrainingBox
}
type MagnifierState = {
visible: boolean
clientX: number
clientY: number
imageX: number
imageY: number
}
type PendingTrainingVideoImport = {
jobId?: string
output: string
sourceFile?: string
count?: number
}
type TrainingConfidence = {
score: number
level: 'none' | 'low' | 'mid' | 'high'
label: string
}
type TrainingLabelStat = {
label: string
count: number
confidence?: TrainingConfidence
}
type TrainingModelInfo = {
trainedAt?: string
trainedAtMs?: number
epochs?: number
trainSamples?: number
valSamples?: number
imgsz?: number
device?: string
map50?: number
map5095?: number
}
type TrainingHistoryEntry = {
trainedAt?: string
trainedAtMs?: number
target?: string
status?: string
durationMs?: number
epochs?: number
trainSamples?: number
valSamples?: number
imgsz?: number
device?: string
map50?: number
map5095?: number
performanceMode?: string
cpuCoreCount?: number
cpuThreads?: number
workers?: number
yoloBatchSize?: number
lowPriority?: boolean
}
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
confidence?: TrainingConfidence
labels: {
people: TrainingLabelStat[]
sexPositions: TrainingLabelStat[]
bodyParts: TrainingLabelStat[]
objects: TrainingLabelStat[]
clothing: TrainingLabelStat[]
}
}
type TrainingAnnotation = {
sampleId: string
frameUrl: string
sourceFile: string
sourcePath?: string
sourceSizeBytes?: number
second: number
createdAt: string
answeredAt: string
prediction: TrainingPrediction
accepted: boolean
negative?: boolean
correction?: CorrectionState
notes?: string
}
type TrainingFeedbackListResponse = {
ok: boolean
items: TrainingAnnotation[]
total: number
limit: number
offset: number
hasMore: boolean
}
type TrainingSampleMode = 'random' | 'uncertain'
type TrainingTargetKey = 'detector' | 'pose' | 'videomae'
type TrainingStartMode = 'full' | 'custom'
type TrainingStartOptions = {
mode: TrainingStartMode
targets?: TrainingTargetKey[]
}
type TrainingEstimateMode = 'auto' | 'eco' | 'balanced' | 'performance' | 'custom'
type TrainingEstimateRuntime = {
mode: TrainingEstimateMode
modeLabel: string
threadsLabel: string
workers: number
yoloBatchLabel: string
lowPriority: boolean
factor: number
yoloFactor: number
}
type FeedbackFilter = 'all' | 'accepted' | 'corrected' | 'negative'
const POSE_KEYPOINT_MIN_CONFIDENCE = 0.15
const POSE_PERSON_MIN_SCORE = 0.30
const POSE_RELIABLE_KEYPOINT_MIN_CONFIDENCE = 0.20
const POSE_RELIABLE_MIN_SCORE = 0.30
const POSE_RELIABLE_MIN_KEYPOINTS = 6
const POSE_RELIABLE_MIN_QUALITY = 0.45
const POSE_UNRELIABLE_COLOR = '#94a3b8'
const POSE_PERSON_COLORS = ['#38bdf8', '#a78bfa', '#34d399', '#f59e0b', '#fb7185']
const POSE_SKELETON_EDGES: Array<readonly [string, string]> = [
['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<string, string> = {
nose: 'Nase',
left_eye: 'L Auge',
right_eye: 'R Auge',
left_ear: 'L Ohr',
right_ear: 'R Ohr',
left_shoulder: 'L Schulter',
right_shoulder: 'R Schulter',
left_elbow: 'L Ellbogen',
right_elbow: 'R Ellbogen',
left_wrist: 'L Hand',
right_wrist: 'R Hand',
left_hip: 'L Huefte',
right_hip: 'R Huefte',
left_knee: 'L Knie',
right_knee: 'R Knie',
left_ankle: 'L Fuss',
right_ankle: 'R Fuss',
}
function poseKeypointId(name?: string | null) {
return String(name ?? '').trim().toLowerCase()
}
function poseKeypointLabel(name?: string | null) {
const key = poseKeypointId(name)
return POSE_KEYPOINT_LABELS[key] || key.replace(/_/g, ' ') || 'Punkt'
}
function isPoseKeypointVisible(point?: TrainingKeypoint | null): point is TrainingKeypoint {
if (!point) return false
const x = Number(point.x)
const y = Number(point.y)
const conf = Number(point.conf)
return (
Number.isFinite(x) &&
Number.isFinite(y) &&
Number.isFinite(conf) &&
conf >= POSE_KEYPOINT_MIN_CONFIDENCE
)
}
function isPoseReliableKeypoint(point?: TrainingKeypoint | null): point is TrainingKeypoint {
if (!point) return false
const x = Number(point.x)
const y = Number(point.y)
const conf = Number(point.conf)
return (
Number.isFinite(x) &&
Number.isFinite(y) &&
Number.isFinite(conf) &&
x >= 0 &&
x <= 1 &&
y >= 0 &&
y <= 1 &&
conf >= POSE_RELIABLE_KEYPOINT_MIN_CONFIDENCE
)
}
function posePersonVisibleKeypoints(person: TrainingPosePerson) {
const direct = Number(person.visibleKeypoints)
if (Number.isFinite(direct) && direct >= 0) {
return Math.floor(direct)
}
return (person.keypoints ?? []).filter(isPoseReliableKeypoint).length
}
function posePersonQuality(person: TrainingPosePerson) {
const direct = Number(person.quality)
if (Number.isFinite(direct) && direct >= 0) {
return clamp01(direct)
}
const reliableKeypoints = (person.keypoints ?? []).filter(isPoseReliableKeypoint)
if (reliableKeypoints.length === 0) return 0
const totalConfidence = reliableKeypoints.reduce(
(sum, point) => sum + clamp01(Number(point.conf)),
0
)
const coverage = clamp01(reliableKeypoints.length / 17)
const averageConfidence = clamp01(totalConfidence / reliableKeypoints.length)
return clamp01(coverage * 0.45 + averageConfidence * 0.55)
}
function isPosePersonReliable(person: TrainingPosePerson) {
if (typeof person.reliable === 'boolean') {
return person.reliable
}
return (
clamp01(Number(person.score)) >= POSE_RELIABLE_MIN_SCORE &&
posePersonVisibleKeypoints(person) >= POSE_RELIABLE_MIN_KEYPOINTS &&
posePersonQuality(person) >= POSE_RELIABLE_MIN_QUALITY
)
}
function hasVisiblePoseBox(person: TrainingPosePerson) {
const box = person.box
if (!box) return false
const w = Number(box.w)
const h = Number(box.h)
return (
clamp01(Number(person.score)) >= POSE_PERSON_MIN_SCORE &&
Number.isFinite(w) &&
Number.isFinite(h) &&
w > 0 &&
h > 0
)
}
function poseCoordPx(value: number, size: number) {
return clamp01(Number(value)) * Math.max(0, Number(size) || 0)
}
function poseBoxPixelStyle(box: TrainingBox, layerWidth: number, layerHeight: number): CSSProperties {
const x = clamp01(Number(box.x))
const y = clamp01(Number(box.y))
const w = Math.max(0, Math.min(1 - x, clamp01(Number(box.w))))
const h = Math.max(0, Math.min(1 - y, clamp01(Number(box.h))))
return {
left: poseCoordPx(x, layerWidth),
top: poseCoordPx(y, layerHeight),
width: w * Math.max(0, Number(layerWidth) || 0),
height: h * Math.max(0, Number(layerHeight) || 0),
}
}
function backendText(data: any, fallback: string) {
return String(
data?.message ||
data?.error ||
data?.detail ||
fallback
).trim()
}
function trainingDurationMs(job?: TrainingJobStatus | null) {
const direct = Number(job?.durationMs)
if (Number.isFinite(direct) && direct > 0) {
return direct
}
const started = job?.startedAt ? Date.parse(job.startedAt) : NaN
const finished = job?.finishedAt ? Date.parse(job.finishedAt) : NaN
if (!Number.isFinite(started) || !Number.isFinite(finished)) {
return 0
}
return Math.max(0, finished - started)
}
function roundedTrainingEstimateMs(ms: number) {
const safe = Number(ms)
if (!Number.isFinite(safe) || safe <= 0) return 0
const step =
safe >= 60 * 60 * 1000
? 5 * 60 * 1000
: safe >= 10 * 60 * 1000
? 60 * 1000
: safe >= 2 * 60 * 1000
? 30 * 1000
: 10 * 1000
return Math.max(step, Math.round(safe / step) * step)
}
function formatApproxTrainingDuration(ms: number) {
const rounded = roundedTrainingEstimateMs(ms)
if (rounded <= 0) return 'ca. —'
return `ca. ${formatDuration(rounded)}`
}
function trainingHistoryDurationFloorMs(entries: TrainingHistoryEntry[]) {
const durations = (entries ?? [])
.filter((entry) => !String(entry.target ?? '').trim())
.map((entry) => Number(entry.durationMs ?? 0))
.filter((duration) => Number.isFinite(duration) && duration > 60 * 1000)
.slice(0, 5)
.sort((a, b) => a - b)
if (durations.length === 0) return 0
return durations[Math.floor(durations.length / 2)]
}
function trainingHistoryTarget(value?: string | null): TrainingTargetKey | '' {
switch (String(value ?? '').trim().toLowerCase()) {
case 'detector':
case 'yolo':
case 'yolo26':
case 'box':
case 'boxes':
return 'detector'
case 'pose':
case 'yolo26_pose':
return 'pose'
case 'videomae':
case 'video_mae':
case 'scene':
case 'clip':
return 'videomae'
default:
return ''
}
}
function isTrainingTargetKey(value: unknown): value is TrainingTargetKey {
switch (String(value ?? '').trim().toLowerCase()) {
case 'detector':
case 'pose':
case 'videomae':
return true
default:
return false
}
}
function trainingTargetFromStageText(
stage?: string | null,
step?: string | null
): TrainingTargetKey | '' {
const direct = trainingHistoryTarget(stage)
if (direct) return direct
const text = `${stage ?? ''} ${step ?? ''}`.toLowerCase()
if (text.includes('videomae') || text.includes('clip')) return 'videomae'
if (text.includes('pose')) return 'pose'
if (
text.includes('detector') ||
text.includes('object') ||
text.includes('yolo26') ||
text.includes('yolo')
) {
return 'detector'
}
return ''
}
function trainingTargetProgressWindow(target: TrainingTargetKey | '') {
switch (target) {
case 'detector':
return { start: 15, end: 58 }
case 'pose':
return { start: 62, end: 82 }
case 'videomae':
return { start: 84, end: 98 }
default:
return { start: 0, end: 100 }
}
}
function combineTrainingEtaMs(stageRemainingMs: number, epochRemainingMs: number) {
const stage = Number(stageRemainingMs)
const epoch = Number(epochRemainingMs)
const hasStage = Number.isFinite(stage) && stage > 0
const hasEpoch = Number.isFinite(epoch) && epoch > 0
if (!hasStage && !hasEpoch) return 0
if (!hasStage) return epoch
if (!hasEpoch) return stage
const boundedEpoch = Math.max(stage * 0.35, Math.min(stage * 1.65, epoch))
return boundedEpoch * 0.7 + stage * 0.3
}
function normalizeTrainingEstimateMode(value?: string | null): TrainingEstimateMode {
switch (String(value ?? '').trim().toLowerCase()) {
case 'eco':
case 'schonend':
case 'schonmodus':
case 'powersave':
case 'power-save':
case 'power_save':
return 'eco'
case 'balanced':
case 'ausgewogen':
case 'normal':
return 'balanced'
case 'performance':
case 'leistung':
case 'fast':
case 'schnell':
return 'performance'
case 'custom':
case 'manual':
case 'manuell':
return 'custom'
default:
return 'auto'
}
}
function trainingEstimateModeLabel(mode: TrainingEstimateMode) {
switch (mode) {
case 'eco':
return 'Schonmodus'
case 'balanced':
return 'Ausgewogen'
case 'performance':
return 'Leistung'
case 'custom':
return 'Manuell'
default:
return 'Auto'
}
}
function clampTrainingEstimate(value: number, minValue: number, maxValue: number) {
if (!Number.isFinite(value)) return minValue
return Math.max(minValue, Math.min(maxValue, value))
}
function trainingEstimateInt(value: unknown, fallback: number, minValue: number, maxValue: number) {
const raw = Math.floor(Number(value))
const safe = Number.isFinite(raw) ? raw : fallback
return Math.max(minValue, Math.min(maxValue, safe))
}
function trainingEstimateRuntimeFromSettings(settings?: RecorderSettingsState | null): TrainingEstimateRuntime {
const mode = normalizeTrainingEstimateMode(
settings?.trainingEffectiveMode ?? settings?.trainingPerformanceMode
)
const cpuCores = trainingEstimateInt(settings?.trainingCpuCoreCount, 0, 0, 256)
const rawThreads = trainingEstimateInt(
settings?.trainingEffectiveCpuThreads ?? settings?.trainingCpuThreads,
0,
0,
256
)
const workers = trainingEstimateInt(
settings?.trainingEffectiveWorkers ?? settings?.trainingWorkers,
1,
0,
32
)
const rawBatch = trainingEstimateInt(
settings?.trainingEffectiveYoloBatchSize ?? settings?.trainingYoloBatchSize,
0,
0,
64
)
const lowPriority = Boolean(
settings?.trainingEffectiveLowPriority ?? settings?.trainingLowPriority
)
const 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 corePenalty =
cpuCores > 0 && effectiveThreads > cpuCores
? 1 + Math.min(0.3, (effectiveThreads - cpuCores) * 0.04)
: 1
const factor = modeFactor * threadFactor * workerFactor * lowPriorityFactor * corePenalty
const yoloFactor = factor * batchFactor
return {
mode,
modeLabel: trainingEstimateModeLabel(mode),
threadsLabel: rawThreads > 0 ? String(rawThreads) : 'Auto',
workers,
yoloBatchLabel: rawBatch > 0 ? String(rawBatch) : 'Auto',
lowPriority,
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' : ''
return `${runtime.modeLabel} · ${runtime.threadsLabel} Threads · ${runtime.workers} Worker · Batch ${runtime.yoloBatchLabel}${priority}`
}
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,
} 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<string, unknown>
return {
trainedAt: typeof raw.trainedAt === 'string' ? raw.trainedAt : undefined,
trainedAtMs: Number(raw.trainedAtMs ?? 0),
epochs: Number(raw.epochs ?? 0),
trainSamples: Number(raw.trainSamples ?? 0),
valSamples: Number(raw.valSamples ?? 0),
imgsz: Number(raw.imgsz ?? 0),
device: typeof raw.device === 'string' ? raw.device : undefined,
map50: Number(raw.map50 ?? 0),
map5095: Number(raw.map5095 ?? 0),
}
}
function formatHistoryDate(entry: TrainingHistoryEntry): string {
const ms = Number(entry.trainedAtMs)
const date =
Number.isFinite(ms) && ms > 0
? new Date(ms)
: entry.trainedAt
? new Date(entry.trainedAt)
: null
if (!date || Number.isNaN(date.getTime())) return '—'
return date.toLocaleString('de-DE', {
day: '2-digit',
month: '2-digit',
year: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
function confidencePillClass(confidence?: TrainingConfidence | null) {
switch (confidence?.level) {
case 'high':
return 'bg-emerald-50 text-emerald-800 ring-emerald-200 dark:bg-emerald-500/15 dark:text-emerald-100 dark:ring-emerald-400/30'
case 'mid':
return 'bg-yellow-50 text-yellow-900 ring-yellow-200 dark:bg-yellow-500/15 dark:text-yellow-100 dark:ring-yellow-400/30'
case 'low':
return 'bg-red-50 text-red-800 ring-red-200 dark:bg-red-500/15 dark:text-red-100 dark:ring-red-400/30'
default:
return 'bg-gray-50 text-gray-700 ring-gray-200 dark:bg-white/5 dark:text-gray-200 dark:ring-white/10'
}
}
function averageCategoryConfidence(values: TrainingLabelStat[]) {
const scores = values
.map((item) => Number(item.confidence?.score))
.filter((score) => Number.isFinite(score))
if (scores.length === 0) {
return undefined
}
const avg = scores.reduce((sum, score) => sum + clamp01(score), 0) / scores.length
return confidenceFromScore(avg)
}
function confidenceFromScore(score: number): TrainingConfidence {
const safeScore = clamp01(Number(score))
if (safeScore >= 0.75) {
return {
score: safeScore,
level: 'high',
label: 'Hoch',
}
}
if (safeScore >= 0.45) {
return {
score: safeScore,
level: 'mid',
label: 'Mittel',
}
}
if (safeScore > 0) {
return {
score: safeScore,
level: 'low',
label: 'Niedrig',
}
}
return {
score: 0,
level: 'none',
label: 'Keine',
}
}
const NO_SEX_POSITION_LABEL = 'keine'
const NO_SEX_POSITION_ALIASES = new Set([
'',
NO_SEX_POSITION_LABEL,
])
function normalizeSexPositionValue(value?: string | null) {
const clean = String(value ?? '').trim()
return NO_SEX_POSITION_ALIASES.has(clean.toLowerCase())
? NO_SEX_POSITION_LABEL
: clean
}
function isNoSexPositionValue(value?: string | null) {
return NO_SEX_POSITION_ALIASES.has(String(value ?? '').trim().toLowerCase())
}
function normalizeSexPositionValues(values?: string[]) {
return uniqStrings(
(values ?? []).map((value) => normalizeSexPositionValue(value))
)
}
function currentAnalysisConfidence(prediction?: TrainingPrediction | null): TrainingConfidence {
if (!prediction?.modelAvailable) {
return confidenceFromScore(0)
}
const scores: number[] = []
const addScore = (value: unknown) => {
const n = Number(value)
if (!Number.isFinite(n)) return
if (n <= 0) return
scores.push(clamp01(n))
}
// Pose-Modell: Sexposition als Sample-Label.
if (
prediction.sexPosition &&
!isNoSexPositionValue(prediction.sexPosition)
) {
addScore(prediction.sexPositionScore)
}
// Detector: sichtbare Boxen sind der wichtigste Signalträger.
for (const box of prediction.boxes ?? []) {
addScore(box.score)
}
// Fallback, falls Labels Scores haben, aber keine Boxen vorhanden sind.
if ((prediction.boxes ?? []).length === 0) {
for (const item of prediction.bodyPartsPresent ?? []) {
addScore(item.score)
}
for (const item of prediction.objectsPresent ?? []) {
addScore(item.score)
}
for (const item of prediction.clothingPresent ?? []) {
addScore(item.score)
}
}
if (scores.length === 0) {
return confidenceFromScore(0)
}
const avg = scores.reduce((sum, score) => sum + score, 0) / scores.length
return confidenceFromScore(avg)
}
const emptyLabels: TrainingLabels = {
people: [],
sexPositions: [NO_SEX_POSITION_LABEL],
bodyParts: [],
objects: [],
clothing: [],
}
function percent(v: number) {
if (!Number.isFinite(v)) return '—'
return `${Math.round(v * 100)}%`
}
function scoreLevel(score?: number | null): 'none' | 'low' | 'mid' | 'high' {
const n = Number(score)
if (!Number.isFinite(n)) return 'none'
if (n < 0.5) return 'low'
if (n < 0.75) return 'mid'
return 'high'
}
function scoreBorderClass(score?: number | null, opts?: { draft?: boolean }) {
if (opts?.draft) return 'border-amber-400'
switch (scoreLevel(score)) {
case 'low':
return 'border-red-500'
case 'mid':
return 'border-yellow-400'
case 'high':
return 'border-emerald-400'
default:
return 'border-gray-300'
}
}
function scoreRingClass(score?: number | null, opts?: { draft?: boolean }) {
if (opts?.draft) return 'ring-amber-500'
switch (scoreLevel(score)) {
case 'low':
return 'ring-red-500'
case 'mid':
return 'ring-yellow-400'
case 'high':
return 'ring-emerald-500'
default:
return 'ring-gray-400'
}
}
function scoreDetectionPillClass(score?: number | null) {
switch (scoreLevel(score)) {
case 'low':
return 'bg-red-50 text-red-800 ring-red-200 dark:bg-red-500/15 dark:text-red-100 dark:ring-red-400/30'
case 'mid':
return 'bg-yellow-50 text-yellow-900 ring-yellow-200 dark:bg-yellow-500/15 dark:text-yellow-100 dark:ring-yellow-400/30'
case 'high':
return 'bg-emerald-50 text-emerald-800 ring-emerald-200 dark:bg-emerald-500/15 dark:text-emerald-100 dark:ring-emerald-400/30'
default:
return 'bg-gray-50 text-gray-700 ring-gray-200 dark:bg-white/5 dark:text-gray-200 dark:ring-white/10'
}
}
function detectorBoxAppearance(label: string) {
const clean = String(label || '').trim()
if (clean === 'person_female' || clean === 'female_person') {
return {
activeSurface:
'dark:bg-pink-500/10 dark:shadow-[0_0_0_1px_rgba(244,114,182,0.20),0_10px_24px_rgba(2,6,23,0.38)]',
idleHover: 'dark:hover:bg-pink-500/[0.05]',
line: 'bg-pink-400',
lineHover: 'dark:group-hover:bg-pink-400/70',
iconActive:
'dark:bg-pink-500/15 dark:text-pink-100 dark:ring-pink-400/25',
iconIdle:
'dark:text-pink-200/80 dark:group-hover:bg-pink-500/10 dark:group-hover:text-pink-100 dark:group-hover:ring-pink-400/20',
selectedText: 'dark:text-pink-300',
}
}
if (clean === 'person_male' || clean === 'male_person') {
return {
activeSurface:
'dark:bg-sky-500/10 dark:shadow-[0_0_0_1px_rgba(56,189,248,0.20),0_10px_24px_rgba(2,6,23,0.38)]',
idleHover: 'dark:hover:bg-sky-500/[0.05]',
line: 'bg-sky-400',
lineHover: 'dark:group-hover:bg-sky-400/70',
iconActive:
'dark:bg-sky-500/15 dark:text-sky-100 dark:ring-sky-400/25',
iconIdle:
'dark:text-sky-200/80 dark:group-hover:bg-sky-500/10 dark:group-hover:text-sky-100 dark:group-hover:ring-sky-400/20',
selectedText: 'dark:text-sky-300',
}
}
return {
activeSurface:
'dark:bg-indigo-500/10 dark:shadow-[0_0_0_1px_rgba(129,140,248,0.20),0_10px_24px_rgba(2,6,23,0.38)]',
idleHover: 'dark:hover:bg-indigo-500/[0.05]',
line: 'bg-indigo-400',
lineHover: 'dark:group-hover:bg-indigo-400/70',
iconActive:
'dark:bg-indigo-500/15 dark:text-indigo-100 dark:ring-indigo-400/25',
iconIdle:
'dark:text-indigo-200/80 dark:group-hover:bg-indigo-500/10 dark:group-hover:text-indigo-100 dark:group-hover:ring-indigo-400/20',
selectedText: 'dark:text-indigo-300',
}
}
function normalizeMovedBox(box: TrainingBox): TrainingBox {
const w = clamp01(box.w)
const h = clamp01(box.h)
return {
...box,
x: Math.max(0, Math.min(1 - w, Number(box.x) || 0)),
y: Math.max(0, Math.min(1 - h, Number(box.y) || 0)),
w,
h,
}
}
function clampPercent(v: number) {
if (!Number.isFinite(v)) return 0
return Math.max(0, Math.min(100, v))
}
function toggleArrayValue(arr: string[], value: string) {
return arr.includes(value)
? arr.filter((x) => x !== value)
: [...arr, value]
}
function clamp01(v: number) {
if (!Number.isFinite(v)) return 0
return Math.max(0, Math.min(1, v))
}
function snap01(v: number, epsilon = 0.006) {
const n = clamp01(v)
if (n <= epsilon) return 0
if (n >= 1 - epsilon) return 1
return n
}
function normalizeBox(box: TrainingBox): TrainingBox {
const x = clamp01(box.x)
const y = clamp01(box.y)
const w = clamp01(Math.min(box.w, 1 - x))
const h = clamp01(Math.min(box.h, 1 - y))
return {
...box,
x,
y,
w,
h,
}
}
function boxGeometryChanged(a: TrainingBox, b: TrainingBox) {
const epsilon = 0.0005
return (
Math.abs(a.x - b.x) > epsilon ||
Math.abs(a.y - b.y) > epsilon ||
Math.abs(a.w - b.w) > epsilon ||
Math.abs(a.h - b.h) > epsilon
)
}
function boxVisualStateChanged(a: TrainingBox, b: TrainingBox) {
return (
a.label !== b.label ||
!Object.is(a.score, b.score) ||
boxGeometryChanged(a, b)
)
}
function markBoxCorrected(box: TrainingBox): TrainingBox {
const { score: _oldScore, ...boxWithoutScore } = box
return boxWithoutScore
}
function uniqStrings(values: string[]) {
const seen = new Set<string>()
const out: string[] = []
for (const value of values) {
const clean = String(value || '').trim()
if (!clean || seen.has(clean)) continue
seen.add(clean)
out.push(clean)
}
return out
}
function peopleLabelsFromBoxes(boxes: TrainingBox[], labels: TrainingLabels) {
return uniqStrings(
boxes
.map((box) => String(box.label || '').trim())
.filter((label) => labels.people.includes(label))
)
}
function predictionToCorrection(sample: TrainingSample | null): CorrectionState {
const p = sample?.prediction
const boxes = (p?.boxes ?? [])
.map((box) => ({
label: String(box.label || '').trim(),
score: box.score,
x: clamp01(Number(box.x)),
y: clamp01(Number(box.y)),
w: clamp01(Number(box.w)),
h: clamp01(Number(box.h)),
}))
.filter((box) => box.label && box.w > 0 && box.h > 0)
return {
sexPosition: normalizeSexPositionValue(p?.sexPosition),
peoplePresent: (p?.peoplePresent ?? []).map((x) => x.label),
bodyPartsPresent: (p?.bodyPartsPresent ?? []).map((x) => x.label),
objectsPresent: (p?.objectsPresent ?? []).map((x) => x.label),
clothingPresent: (p?.clothingPresent ?? []).map((x) => x.label),
boxes,
}
}
function cloneCorrectionState(value: CorrectionState): CorrectionState {
return {
sexPosition: value.sexPosition,
peoplePresent: [...value.peoplePresent],
bodyPartsPresent: [...value.bodyPartsPresent],
objectsPresent: [...value.objectsPresent],
clothingPresent: [...value.clothingPresent],
boxes: (value.boxes ?? []).map((box) => ({ ...box })),
}
}
function correctionHasTrainablePositionOrBoxes(value: CorrectionState) {
return (
(value.sexPosition && !isNoSexPositionValue(value.sexPosition)) ||
(value.boxes ?? []).some((box) => {
const normalized = normalizeBox(box)
return Boolean(normalized.label && normalized.w > 0 && normalized.h > 0)
})
)
}
function applyBoxLabelToCorrection(
state: CorrectionState,
label: string,
labels: TrainingLabels
): CorrectionState {
const clean = String(label || '').trim()
if (!clean) return state
if (labels.people.includes(clean)) {
return {
...state,
peoplePresent: state.peoplePresent.includes(clean)
? state.peoplePresent
: [...state.peoplePresent, clean],
}
}
if (labels.bodyParts.includes(clean)) {
return {
...state,
bodyPartsPresent: state.bodyPartsPresent.includes(clean)
? state.bodyPartsPresent
: [...state.bodyPartsPresent, clean],
}
}
if (labels.objects.includes(clean)) {
return {
...state,
objectsPresent: state.objectsPresent.includes(clean)
? state.objectsPresent
: [...state.objectsPresent, clean],
}
}
if (labels.clothing.includes(clean)) {
return {
...state,
clothingPresent: state.clothingPresent.includes(clean)
? state.clothingPresent
: [...state.clothingPresent, clean],
}
}
return state
}
function removeBoxLabelFromCorrection(
state: CorrectionState,
label: string,
labels: TrainingLabels
): CorrectionState {
const clean = String(label || '').trim()
if (!clean) return state
const remainingBoxesWithSameLabel = (state.boxes ?? []).some(
(box) => String(box.label || '').trim() === clean
)
// Badge nur abwählen, wenn keine weitere Box mit diesem Label existiert.
if (remainingBoxesWithSameLabel) return state
if (labels.people.includes(clean)) {
return {
...state,
peoplePresent: state.peoplePresent.filter((x) => x !== clean),
}
}
if (labels.bodyParts.includes(clean)) {
return {
...state,
bodyPartsPresent: state.bodyPartsPresent.filter((x) => x !== clean),
}
}
if (labels.objects.includes(clean)) {
return {
...state,
objectsPresent: state.objectsPresent.filter((x) => x !== clean),
}
}
if (labels.clothing.includes(clean)) {
return {
...state,
clothingPresent: state.clothingPresent.filter((x) => x !== clean),
}
}
return state
}
function removeBoxFromCorrection(
state: CorrectionState,
index: number,
labels: TrainingLabels
): CorrectionState {
const boxes = state.boxes ?? []
const removed = boxes[index]
if (!removed) return state
const removedLabel = String(removed.label || '').trim()
const next: CorrectionState = {
...state,
boxes: boxes.filter((_, i) => i !== index),
}
return removeBoxLabelFromCorrection(next, removedLabel, labels)
}
function changeBoxLabelInCorrection(
state: CorrectionState,
index: number,
nextLabel: string,
labels: TrainingLabels
): CorrectionState {
const boxes = state.boxes ?? []
const currentBox = boxes[index]
const cleanNextLabel = String(nextLabel || '').trim()
if (!currentBox || !cleanNextLabel) return state
const oldLabel = String(currentBox.label || '').trim()
if (oldLabel === cleanNextLabel) return state
let next: CorrectionState = {
...state,
boxes: boxes.map((box, i) => {
if (i !== index) return box
const { score: _oldScore, ...boxWithoutScore } = box
return {
...boxWithoutScore,
label: cleanNextLabel,
}
}),
}
next = removeBoxLabelFromCorrection(next, oldLabel, labels)
next = applyBoxLabelToCorrection(next, cleanNextLabel, labels)
return next
}
function sortLabelList(values?: string[], opts?: { keepNoPositionFirst?: boolean }) {
const list = [...(values ?? [])].sort((a, b) =>
a.localeCompare(b, undefined, { sensitivity: 'base' })
)
if (!opts?.keepNoPositionFirst) return list
return [
...list.filter((x) => isNoSexPositionValue(x)),
...list.filter((x) => !isNoSexPositionValue(x)),
]
}
function sortTrainingLabels(input: Partial<TrainingLabels> | 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<number | null>(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 (
<div
className={[
'absolute inset-0 z-[500] flex items-center justify-center overflow-hidden rounded-md bg-black px-3 text-center text-white',
'transition-opacity duration-300 ease-out will-change-opacity motion-reduce:transition-none',
visible ? 'opacity-100' : 'pointer-events-none opacity-0',
].join(' ')}
>
<div className="absolute inset-1 overflow-hidden rounded-md sm:inset-2">
{hasBackground ? (
<>
{displayedBackgroundUrl ? (
<img
src={displayedBackgroundUrl}
alt=""
aria-hidden="true"
draggable={false}
decoding="async"
loading="eager"
className="absolute inset-0 z-0 h-full w-full object-contain opacity-80 blur-[1px]"
/>
) : null}
{incomingBackgroundUrl ? (
<img
src={incomingBackgroundUrl}
alt=""
aria-hidden="true"
draggable={false}
decoding="async"
loading="eager"
onLoad={() => 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}
<div
className={[
'absolute inset-0 z-[1] rounded-md',
hasBackground
? 'bg-black/30 backdrop-blur-[4px] shadow-[inset_0_0_48px_18px_rgba(0,0,0,0.55)]'
: 'bg-black/45 backdrop-blur-[8px] shadow-[inset_0_0_72px_30px_rgba(0,0,0,0.75)]',
].join(' ')}
/>
</div>
<div className="relative z-10 flex w-[min(90%,22rem)] flex-col items-center justify-center text-center text-white">
<LoadingSpinner
size="md"
className="text-white drop-shadow-[0_1px_5px_rgba(0,0,0,0.85)]"
srLabel={title}
/>
<div className="mt-2 max-w-full truncate text-sm font-semibold leading-5 text-white drop-shadow-[0_1px_5px_rgba(0,0,0,0.9)]">
{primaryText}
</div>
{secondaryText ? (
<div
className="mt-1 flex max-w-full items-center justify-center gap-1.5"
title={secondaryText}
>
<div className="min-w-0 truncate text-[11px] font-semibold leading-4 text-white/75 drop-shadow-[0_1px_4px_rgba(0,0,0,0.9)]">
{secondaryText}
</div>
{frameLabel ? (
<div className="shrink-0 rounded-full bg-emerald-500/28 px-2 py-0.5 text-[10px] font-bold leading-3 text-emerald-50 shadow-md ring-1 ring-emerald-200/25 backdrop-blur-sm">
{frameLabel}
</div>
) : null}
</div>
) : frameLabel ? (
<div className="mt-1 flex max-w-full items-center justify-center gap-1.5">
<div className="min-w-0 truncate text-[11px] font-semibold leading-4 text-white/75 drop-shadow-[0_1px_4px_rgba(0,0,0,0.9)]">
Frame
</div>
<div className="shrink-0 rounded-full bg-emerald-500/28 px-2 py-0.5 text-[10px] font-bold leading-3 text-emerald-50 shadow-md ring-1 ring-emerald-200/25 backdrop-blur-sm">
{frameLabel}
</div>
</div>
) : null}
<div className="mt-3 flex w-52 max-w-[82%] items-center gap-2">
<div className="h-1.5 min-w-0 flex-1 overflow-hidden rounded-full bg-white/25 shadow-[0_1px_6px_rgba(0,0,0,0.45)]">
<div
className={[
'h-full rounded-full transition-all duration-500',
isTraining ? 'bg-indigo-400' : 'bg-emerald-400',
].join(' ')}
style={{ width: `${progress}%` }}
/>
</div>
<div className="w-9 shrink-0 text-right text-[11px] font-semibold tabular-nums text-white/75 drop-shadow-[0_1px_4px_rgba(0,0,0,0.85)]">
{Math.round(progress)}%
</div>
</div>
</div>
</div>
)
}
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<string, number>
activeCounts?: Record<string, number>
onToggle: (value: string) => void
drawLabel?: string
onDrawLabelChange?: (value: string) => void
disabled?: boolean
gridClassName?: string
}) {
if (props.values.length === 0) {
return (
<div className="rounded-lg bg-gray-50 px-3 py-2 text-[11px] text-gray-500 ring-1 ring-black/5 dark:bg-white/5 dark:text-gray-400 dark:ring-white/10">
Keine Einträge verfügbar.
</div>
)
}
return (
<div className={props.gridClassName || 'grid grid-cols-2 gap-2 sm:grid-cols-3'}>
{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 (
<button
key={value}
type="button"
aria-pressed={active || isDrawLabel}
title={
hasScore
? `${value} ${percent(score)}`
: isDrawLabel
? `${value} zum Zeichnen ausgewählt`
: value
}
disabled={props.disabled}
onClick={() => {
if (props.onDrawLabelChange) {
props.onDrawLabelChange(value)
return
}
props.onToggle(value)
}}
className={[
labelTileClass(active || isDrawLabel),
props.disabled ? 'cursor-not-allowed opacity-50' : '',
].join(' ')}
>
<Icon
className={[
'h-5 w-5 transition sm:h-6 sm:w-6',
active
? 'text-indigo-700 dark:text-indigo-100'
: 'text-gray-500 group-hover:text-gray-700 dark:text-gray-400 dark:group-hover:text-gray-200',
].join(' ')}
aria-hidden="true"
/>
<span className="line-clamp-2 max-w-full break-words">
{item.text}
</span>
{activeCount > 0 ? (
<span className="mt-0.5 rounded-full bg-indigo-50 px-1.5 py-0.5 text-[9px] font-bold text-indigo-700 ring-1 ring-indigo-200 dark:bg-indigo-500/15 dark:text-indigo-200 dark:ring-indigo-400/30">
{activeCount === 1 ? '1 Box' : `${activeCount} Boxen`}
</span>
) : hasScore ? (
<span
className={[
'mt-0.5 rounded-full px-1.5 py-0.5 text-[9px] font-bold ring-1',
scoreDetectionPillClass(score),
].join(' ')}
>
{percent(score)}
</span>
) : isDrawLabel ? (
<span className="mt-0.5 rounded-full bg-amber-50 px-1.5 py-0.5 text-[9px] font-bold text-amber-700 ring-1 ring-amber-200 dark:bg-amber-500/15 dark:text-amber-200 dark:ring-amber-400/30">
zeichnen
</span>
) : active ? (
<span className="mt-0.5 rounded-full bg-indigo-50 px-1.5 py-0.5 text-[9px] font-bold text-indigo-700 ring-1 ring-indigo-200 dark:bg-indigo-500/15 dark:text-indigo-200 dark:ring-indigo-400/30">
aktiv
</span>
) : null}
</button>
)
})}
</div>
)
}
function DetectorBoxLabelSelect(props: {
values: string[]
value: string
disabled?: boolean
compact?: boolean
onChange: (value: string) => void
}) {
const [open, setOpen] = useState(false)
const [menuStyle, setMenuStyle] = useState<CSSProperties>({})
const buttonRef = useRef<HTMLButtonElement | null>(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 (
<button
type="button"
disabled
className="mt-2 flex h-9 w-full items-center rounded-md border border-gray-200 bg-white px-2 text-sm text-gray-400 disabled:cursor-not-allowed dark:border-white/10 dark:bg-gray-950 dark:text-gray-500"
>
Keine Box-Labels
</button>
)
}
return (
<div
className={props.compact ? 'mt-1.5' : 'mt-2'}
data-detector-label-select="true"
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
<button
ref={buttonRef}
type="button"
disabled={props.disabled}
onClick={() => {
if (props.disabled) return
updateMenuPosition()
setOpen((value) => !value)
}}
className={[
'flex w-full items-center justify-between gap-2 rounded-xl border px-2.5 text-gray-900 shadow-sm outline-none transition',
'focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500/30 disabled:cursor-not-allowed disabled:opacity-50',
'border-gray-200 bg-white',
'dark:border-white/10 dark:bg-white/[0.05] dark:text-gray-100 dark:hover:bg-white/[0.07]',
props.compact ? 'h-8 text-xs' : 'h-9 text-sm',
].join(' ')}
aria-haspopup="listbox"
aria-expanded={open}
>
<span className="flex min-w-0 items-center gap-2">
<SelectedIcon
className="h-4 w-4 shrink-0 text-gray-600 dark:text-gray-300"
aria-hidden="true"
/>
<span className="truncate">
{selectedItem.text}
</span>
</span>
<span
className={[
'shrink-0 text-sm text-gray-500 transition-transform dark:text-gray-400',
open ? 'rotate-180' : '',
].join(' ')}
aria-hidden="true"
>
</span>
</button>
{open && typeof document !== 'undefined'
? createPortal(
<div
role="listbox"
data-detector-label-select="true"
style={menuStyle}
className={[
'overflow-y-auto rounded-xl border border-gray-200 bg-white py-1 shadow-2xl ring-1 ring-black/10',
'dark:border-white/10 dark:bg-gray-900 dark:ring-white/15',
].join(' ')}
>
{props.values.map((value) => {
const active = value === props.value
const item = getSegmentLabelItem(value)
const Icon = item.icon
return (
<button
key={value}
type="button"
role="option"
aria-selected={active}
onClick={() => {
props.onChange(value)
setOpen(false)
}}
className={[
'flex w-full items-center gap-2 px-2 py-2 text-left text-sm transition',
active
? 'bg-indigo-50 text-indigo-900 dark:bg-indigo-500/20 dark:text-indigo-100'
: 'text-gray-700 hover:bg-gray-50 hover:text-gray-900 dark:text-gray-200 dark:hover:bg-white/10 dark:hover:text-white',
].join(' ')}
>
<Icon
className={[
'h-4 w-4 shrink-0',
active
? 'text-indigo-700 dark:text-indigo-100'
: 'text-gray-600 dark:text-gray-300',
].join(' ')}
aria-hidden="true"
/>
<span className="min-w-0 flex-1 truncate">
{item.text}
</span>
<span className="shrink-0 text-[10px] text-gray-400">
{value}
</span>
</button>
)
})}
</div>,
document.body
)
: null}
</div>
)
}
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 (
<div className="rounded-lg bg-gray-50 p-2 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
<button
type="button"
className="flex w-full items-center justify-between gap-2 text-left"
onClick={() => props.onExpandedChange(!props.expanded)}
aria-expanded={shown}
>
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
<SelectedIcon
className={[
'h-4 w-4 shrink-0',
hasSelection
? 'text-indigo-600 dark:text-indigo-300'
: 'text-gray-500 dark:text-gray-400',
].join(' ')}
aria-hidden="true"
/>
<div className="min-w-0 truncate text-xs font-medium text-gray-700 dark:text-gray-200">
{props.title}
</div>
</div>
<div className="mt-0.5 text-[10px] text-gray-500 dark:text-gray-400">
{hasSelection
? selectedItem.text
: shown
? 'Zum Einklappen klicken'
: 'Zum Ausklappen klicken'}
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
{typeof props.score === 'number' && Number.isFinite(props.score) ? (
<span
className={[
'rounded-full px-1.5 py-0.5 text-[10px] font-bold ring-1',
scoreDetectionPillClass(props.score),
].join(' ')}
title={
props.predictionValue
? `Modell: ${props.predictionValue}`
: 'Modell-Vorhersage'
}
>
{percent(props.score)}
</span>
) : null}
{hasSelection ? (
<span className="rounded-full bg-indigo-100 px-2 py-0.5 text-[11px] font-semibold text-indigo-800 ring-1 ring-indigo-200 dark:bg-indigo-500/20 dark:text-indigo-100 dark:ring-indigo-300/30">
aktiv
</span>
) : null}
<span
className={[
'text-sm text-gray-500 transition-transform dark:text-gray-400',
shown ? 'rotate-180' : '',
].join(' ')}
aria-hidden="true"
>
</span>
</div>
</button>
{shown ? (
<div className="mt-2">
{props.values.length === 0 ? (
<div className="rounded-lg bg-gray-50 px-3 py-2 text-[11px] text-gray-500 ring-1 ring-black/5 dark:bg-white/5 dark:text-gray-400 dark:ring-white/10">
Keine Einträge verfügbar.
</div>
) : (
<div className={props.gridClassName || 'grid grid-cols-2 gap-2'}>
{props.values.map((value) => {
const active = value === currentValue
const item = getSegmentLabelItem(value)
const Icon = item.icon
const isPrediction = value === props.predictionValue
return (
<button
key={value}
type="button"
aria-pressed={active}
title={value}
disabled={props.disabled}
onClick={() => props.onChange(value)}
className={[
labelTileClass(active),
props.disabled ? 'cursor-not-allowed opacity-50' : '',
].join(' ')}
>
<Icon
className={[
'h-5 w-5 transition sm:h-6 sm:w-6',
active
? 'text-indigo-700 dark:text-indigo-100'
: 'text-gray-500 group-hover:text-gray-700 dark:text-gray-400 dark:group-hover:text-gray-200',
].join(' ')}
aria-hidden="true"
/>
<span className="line-clamp-2 max-w-full break-words">
{item.text}
</span>
{active ? (
<span className="mt-0.5 rounded-full bg-indigo-50 px-1.5 py-0.5 text-[9px] font-bold text-indigo-700 ring-1 ring-indigo-200 dark:bg-indigo-500/15 dark:text-indigo-200 dark:ring-indigo-400/30">
aktiv
</span>
) : isPrediction && typeof props.score === 'number' ? (
<span
className={[
'mt-0.5 rounded-full px-1.5 py-0.5 text-[9px] font-bold ring-1',
scoreDetectionPillClass(props.score),
].join(' ')}
>
Modell {percent(props.score)}
</span>
) : null}
</button>
)
})}
</div>
)}
</div>
) : null}
</div>
)
}
function CollapsibleLabelSection(props: {
title: string
values: string[]
selected: string[]
scores?: Record<string, number>
activeCounts?: Record<string, number>
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 (
<div className="rounded-lg bg-gray-50 p-2 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
<button
type="button"
className="flex w-full items-center justify-between gap-2 text-left"
onClick={() => props.onExpandedChange(!props.expanded)}
aria-expanded={shown}
>
<div className="min-w-0">
<div className="text-xs font-medium text-gray-700 dark:text-gray-200">
{props.title}
</div>
<div className="mt-0.5 text-[10px] text-gray-500 dark:text-gray-400">
{hasActiveItems
? `${activeCount} aktiv`
: shown
? 'Zum Einklappen klicken'
: 'Zum Ausklappen klicken'}
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
{hasActiveItems ? (
<span className="rounded-full bg-indigo-100 px-2 py-0.5 text-[11px] font-semibold text-indigo-800 ring-1 ring-indigo-200 dark:bg-indigo-500/20 dark:text-indigo-100 dark:ring-indigo-300/30">
{activeCount}
</span>
) : null}
<span
className={[
'text-sm text-gray-500 transition-transform dark:text-gray-400',
shown ? 'rotate-180' : '',
].join(' ')}
aria-hidden="true"
>
</span>
</div>
</button>
{shown ? (
<div className="mt-2">
<LabelToggleGrid
values={props.values}
selected={props.selected}
scores={props.scores}
activeCounts={props.activeCounts}
onToggle={props.onToggle}
drawLabel={props.drawLabel}
onDrawLabelChange={props.onDrawLabelChange}
disabled={props.disabled}
gridClassName={props.gridClassName}
/>
</div>
) : null}
</div>
)
}
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 (
<div className="overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm dark:border-white/10 dark:bg-gray-900/70">
<div className="border-b border-gray-200 bg-gray-50/80 px-4 py-3 dark:border-white/10 dark:bg-white/[0.03]">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-sm font-semibold text-gray-900 dark:text-white">
{props.title}
</div>
{props.description ? (
<div className="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
{props.description}
</div>
) : null}
</div>
<div className="flex shrink-0 items-center gap-2">
<span
className={[
'rounded-full px-2.5 py-1 text-xs font-bold ring-1',
confidencePillClass(props.confidence),
].join(' ')}
title={`Kategorie-Confidence: ${confidencePercent(props.confidence)}`}
>
{confidenceLabel(props.confidence)} · {confidencePercent(props.confidence)}
</span>
<span
className={[
'rounded-full bg-white px-2.5 py-1 text-xs font-semibold text-gray-700 ring-1 ring-gray-200',
'dark:bg-white/10 dark:text-gray-100 dark:ring-white/20',
'dark:shadow-sm',
].join(' ')}
>
{sorted.length} Labels
</span>
</div>
</div>
<div className="mt-3 flex flex-wrap items-center gap-2 text-[11px] text-gray-500 dark:text-gray-400">
<span className="inline-flex items-center gap-1">
<span className="h-1.5 w-5 rounded-full bg-indigo-500" />
Häufigkeit
</span>
<span className="inline-flex items-center gap-1">
<span className="h-1.5 w-5 rounded-full bg-emerald-500" />
Confidence
</span>
</div>
</div>
{sorted.length === 0 ? (
<div className="flex min-h-40 items-center justify-center px-4 py-8 text-center">
<div>
<div className="text-sm font-medium text-gray-700 dark:text-gray-200">
Keine Labels gefunden
</div>
<div className="mt-1 max-w-sm text-xs text-gray-500 dark:text-gray-400">
{props.emptyText || 'Sobald Feedback für diese Kategorie gespeichert wurde, erscheinen hier die Werte.'}
</div>
</div>
</div>
) : (
<div className="divide-y divide-gray-100 dark:divide-white/10">
{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 (
<div
key={item.label}
className="px-4 py-3 transition hover:bg-gray-50 dark:hover:bg-white/[0.03]"
>
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 items-start gap-3">
<div className="mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-gray-100 ring-1 ring-gray-200 dark:bg-white/10 dark:ring-white/10">
<Icon
className="h-5 w-5 text-gray-600 dark:text-gray-300"
aria-hidden="true"
/>
</div>
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-gray-900 dark:text-white">
{labelItem.text}
</div>
<div className="mt-0.5 truncate text-[11px] text-gray-500 dark:text-gray-400">
{item.label}
</div>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<span
className={[
'rounded-full px-2 py-0.5 text-[11px] font-bold ring-1',
confidencePillClass(item.confidence),
].join(' ')}
title={`Confidence: ${confidencePercent(item.confidence)}`}
>
{confidenceLabel(item.confidence)}
</span>
<div className="min-w-10 text-right text-sm font-bold text-gray-900 dark:text-white">
{item.count}
</div>
</div>
</div>
<div className="mt-3 space-y-1.5">
<div>
<div className="mb-1 flex items-center justify-between text-[10px] text-gray-500 dark:text-gray-400">
<span>Häufigkeit in dieser Gruppe</span>
<span>{shareWidth}</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-gray-200 dark:bg-white/10">
<div
className="h-full rounded-full bg-indigo-500 transition-all"
style={{ width: shareWidth }}
/>
</div>
</div>
<div>
<div className="mb-1 flex items-center justify-between text-[10px] text-gray-500 dark:text-gray-400">
<span>Daten-Confidence</span>
<span>{confWidth}</span>
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-gray-200 dark:bg-white/10">
<div
className="h-full rounded-full bg-emerald-500 transition-all"
style={{ width: confWidth }}
/>
</div>
</div>
</div>
</div>
)
})}
</div>
)}
</div>
)
}
type TrainingStatsTabKey =
| 'people'
| 'sexPositions'
| 'bodyParts'
| 'objects'
| 'clothing'
function topTrainingLabelStat(values: TrainingLabelStat[]) {
return [...values]
.filter((item) => String(item.label || '').trim())
.sort((a, b) => b.count - a.count)[0]
}
function TrainingStatsModal(props: {
open: boolean
onClose: () => void
stats: TrainingStats | null
history?: TrainingHistoryEntry[]
loading: boolean
error: string | null
feedbackCount: number
requiredCount: number
}) {
const [activeTab, setActiveTab] = useState<TrainingStatsTabKey>('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 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 availableModelCount =
(detectorModelAvailable ? 1 : 0) + (poseModelAvailable ? 1 : 0)
const modelSummaryLabel =
availableModelCount === 2
? 'Detector & Pose verfuegbar'
: detectorModelAvailable
? 'Detector verfuegbar'
: poseModelAvailable
? 'Pose verfuegbar'
: 'Noch kein trainiertes Modell verfuegbar'
const modelCards = [
{
key: 'detector',
title: 'Detector',
available: detectorModelAvailable,
info: detectorModelInfo,
},
{
key: 'pose',
title: 'Pose',
available: poseModelAvailable,
info: poseModelInfo,
},
].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 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 (
<Modal
open={props.open}
onClose={props.onClose}
title="Training-Statistiken"
width="max-w-3xl"
bodyClassName="p-0"
>
<div className="px-3 pb-4 pt-2 sm:px-6 sm:pb-6 sm:pt-4">
{props.loading ? (
<div className="flex min-h-40 items-center justify-center">
<div className="text-center">
<LoadingSpinner
size="lg"
srLabel="Statistiken werden geladen…"
/>
<div className="mt-3 text-sm font-medium text-gray-700 dark:text-gray-200">
Statistiken werden geladen
</div>
</div>
</div>
) : props.error ? (
<div className="rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700 dark:bg-red-500/10 dark:text-red-200">
{props.error}
</div>
) : (
<div className="space-y-3 sm:space-y-4">
{/* Mobile: kompakte Top-Zusammenfassung */}
<div className="sm:hidden">
<div className="rounded-xl border border-gray-200 bg-white p-3 shadow-sm dark:border-white/10 dark:bg-gray-900/70">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-sm font-semibold text-gray-900 dark:text-white">
{availableModelCount > 0
? 'Modell verfügbar'
: 'Noch kein Modell'}
</div>
<div className="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
{totalFeedback} Feedback · {boxCount} Boxen · {sampleCount} Samples
</div>
</div>
<span
className={[
'shrink-0 rounded-full px-2.5 py-1 text-xs font-bold ring-1',
confidencePillClass(overallConfidence),
].join(' ')}
title={`Daten-Confidence: ${confidencePercent(overallConfidence)}`}
>
{confidencePercent(overallConfidence)}
</span>
</div>
<div className="mt-3 h-2 overflow-hidden rounded-full bg-gray-200 dark:bg-white/10">
<div
className="h-full rounded-full bg-emerald-500 transition-all"
style={{ width: confidencePercent(overallConfidence) }}
/>
</div>
<div className="mt-3 grid grid-cols-5 gap-1.5">
<div className="rounded-xl bg-gray-50 px-2 py-1.5 text-center ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
<div className="text-[9px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Feedback
</div>
<div className="mt-0.5 text-sm font-black text-gray-900 dark:text-white">
{totalFeedback}
</div>
</div>
<div className="rounded-xl bg-gray-50 px-2 py-1.5 text-center ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
<div className="text-[9px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Passt
</div>
<div className="mt-0.5 text-sm font-black text-emerald-700 dark:text-emerald-300">
{acceptedCount}
</div>
</div>
<div className="rounded-xl bg-gray-50 px-2 py-1.5 text-center ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
<div className="text-[9px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Korr.
</div>
<div className="mt-0.5 text-sm font-black text-amber-700 dark:text-amber-300">
{correctedCount}
</div>
</div>
<div className="rounded-xl bg-gray-50 px-2 py-1.5 text-center ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
<div className="text-[9px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Negativ
</div>
<div className="mt-0.5 text-sm font-black text-blue-700 dark:text-blue-300">
{negativeCount}
</div>
</div>
<div className="rounded-xl bg-gray-50 px-2 py-1.5 text-center ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
<div className="text-[9px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Boxen
</div>
<div className="mt-0.5 text-sm font-black text-gray-900 dark:text-white">
{boxCount}
</div>
</div>
</div>
<div className="mt-3 grid grid-cols-2 gap-1.5">
{modelCards.map((model) => (
<div
key={model.key}
className="rounded-xl bg-gray-50 px-2 py-1.5 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10"
>
<div className="flex items-center justify-between gap-2">
<span className="truncate text-[9px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
{model.title}
</span>
<span
className={[
'h-2 w-2 shrink-0 rounded-full',
model.available
? 'bg-emerald-500'
: 'bg-gray-300 dark:bg-gray-600',
].join(' ')}
title={model.available ? 'bereit' : 'fehlt'}
/>
</div>
<div className="mt-0.5 truncate text-xs font-bold text-gray-900 dark:text-white">
{model.available ? 'bereit' : 'fehlt'}
{model.map50Label ? ` | ${model.map50Label}` : ''}
</div>
</div>
))}
</div>
</div>
</div>
{/* Desktop/Tablet: ausführliche Karten */}
<div className="hidden sm:grid sm:grid-cols-5 sm:gap-2">
<div className="rounded-xl bg-gray-50 p-3 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
<div className="text-[11px] font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
Feedback
</div>
<div className="mt-1 text-2xl font-bold text-gray-900 dark:text-white">
{totalFeedback}
</div>
<div className="mt-1 text-[11px] text-gray-500 dark:text-gray-400">
benötigt: {props.requiredCount}
</div>
</div>
<div className="rounded-xl bg-gray-50 p-3 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
<div className="text-[11px] font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
Passt so
</div>
<div className="mt-1 text-2xl font-bold text-emerald-700 dark:text-emerald-300">
{acceptedCount}
</div>
<div className="mt-1 text-[11px] text-gray-500 dark:text-gray-400">
{countPercent(acceptedCount, totalFeedback)}
</div>
</div>
<div className="rounded-xl bg-gray-50 p-3 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
<div className="text-[11px] font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
Korrigiert
</div>
<div className="mt-1 text-2xl font-bold text-amber-700 dark:text-amber-300">
{correctedCount}
</div>
<div className="mt-1 text-[11px] text-gray-500 dark:text-gray-400">
{countPercent(correctedCount, totalFeedback)}
</div>
</div>
<div className="rounded-xl bg-gray-50 p-3 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
<div className="text-[11px] font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
Negativ
</div>
<div className="mt-1 text-2xl font-bold text-blue-700 dark:text-blue-300">
{negativeCount}
</div>
<div className="mt-1 text-[11px] text-gray-500 dark:text-gray-400">
{countPercent(negativeCount, totalFeedback)}
</div>
</div>
<div className="rounded-xl bg-gray-50 p-3 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
<div className="text-[11px] font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
Boxen
</div>
<div className="mt-1 text-2xl font-bold text-gray-900 dark:text-white">
{boxCount}
</div>
<div className="mt-1 text-[11px] text-gray-500 dark:text-gray-400">
{sampleCount} Samples
</div>
</div>
</div>
<div className="hidden grid-cols-1 gap-3 sm:grid sm:grid-cols-2">
<div className="rounded-xl border border-indigo-100 bg-indigo-50 p-4 dark:border-indigo-400/20 dark:bg-indigo-500/10">
<div className="text-[11px] font-medium uppercase tracking-wide text-indigo-700/80 dark:text-indigo-200/80">
Modellstatus
</div>
<div className="mt-2 text-sm font-semibold text-indigo-950 dark:text-indigo-50">
{availableModelCount > 0
? 'Trainiertes Modell verfügbar'
: 'Noch kein trainiertes Modell verfügbar'}
</div>
{detectorModelAvailable && modelTrainedAtLabel ? (
<div className="mt-2 space-y-1">
<div className="flex items-center justify-between gap-2 text-xs">
<span className="font-medium text-indigo-800/80 dark:text-indigo-100/70">
Version vom
</span>
<span className="font-semibold tabular-nums text-indigo-950 dark:text-indigo-50">
{modelTrainedAtLabel}
</span>
</div>
{modelMap50Label ? (
<div className="flex items-center justify-between gap-2 text-xs">
<span className="font-medium text-indigo-800/80 dark:text-indigo-100/70">
Qualität (mAP50{modelMap5095Label ? ' / 50-95' : ''})
</span>
<span className="font-semibold tabular-nums text-indigo-950 dark:text-indigo-50">
{modelMap50Label}
{modelMap5095Label ? ` / ${modelMap5095Label}` : ''}
</span>
</div>
) : null}
{modelInfoDetails ? (
<div className="text-[11px] text-indigo-800/70 dark:text-indigo-100/60">
{modelInfoDetails}
</div>
) : null}
</div>
) : (
<div className="mt-1 text-xs leading-relaxed text-indigo-800/80 dark:text-indigo-100/70">
{availableModelCount > 0
? 'Die aktuellen Trainingsdaten können bereits von einem Modell genutzt werden.'
: 'Sammle weiter Feedback und starte anschließend das Training.'}
</div>
)}
<div className="mt-3 space-y-2">
<div className="text-[11px] font-semibold uppercase tracking-wide text-indigo-700/80 dark:text-indigo-200/80">
{modelSummaryLabel} - {availableModelCount}/2
</div>
{modelCards.map((model) => (
<div
key={model.key}
className="rounded-lg bg-white/60 p-2.5 ring-1 ring-indigo-200/70 dark:bg-white/5 dark:ring-indigo-300/20"
>
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-bold text-indigo-950 dark:text-indigo-50">
{model.title}
</span>
<span
className={[
'rounded-full px-2 py-0.5 text-[10px] font-bold ring-1',
model.available
? 'bg-emerald-50 text-emerald-800 ring-emerald-200 dark:bg-emerald-500/15 dark:text-emerald-100 dark:ring-emerald-400/30'
: 'bg-gray-50 text-gray-600 ring-gray-200 dark:bg-white/5 dark:text-gray-300 dark:ring-white/10',
].join(' ')}
>
{model.available ? 'bereit' : 'fehlt'}
</span>
</div>
{model.available && model.trainedAtLabel ? (
<div className="mt-2 space-y-1">
<div className="flex items-center justify-between gap-2 text-[11px]">
<span className="font-medium text-indigo-800/80 dark:text-indigo-100/70">
Version
</span>
<span className="font-semibold tabular-nums text-indigo-950 dark:text-indigo-50">
{model.trainedAtLabel}
</span>
</div>
{model.map50Label ? (
<div className="flex items-center justify-between gap-2 text-[11px]">
<span className="font-medium text-indigo-800/80 dark:text-indigo-100/70">
mAP50{model.map5095Label ? ' / 50-95' : ''}
</span>
<span className="font-semibold tabular-nums text-indigo-950 dark:text-indigo-50">
{model.map50Label}
{model.map5095Label ? ` / ${model.map5095Label}` : ''}
</span>
</div>
) : null}
{model.details ? (
<div className="text-[11px] text-indigo-800/70 dark:text-indigo-100/60">
{model.details}
</div>
) : null}
</div>
) : (
<div className="mt-1 text-[11px] text-indigo-800/70 dark:text-indigo-100/60">
{model.available
? 'Modell gefunden, Details fehlen.'
: 'Noch nicht trainiert.'}
</div>
)}
</div>
))}
</div>
</div>
<div className="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-gray-900/70">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-[11px] font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
Daten-Confidence
</div>
<div className="mt-2 text-xl font-bold text-gray-900 dark:text-white">
{confidenceLabel(overallConfidence)}
</div>
</div>
<span
className={[
'rounded-full px-3 py-1.5 text-sm font-bold ring-1',
confidencePillClass(overallConfidence),
].join(' ')}
>
{confidencePercent(overallConfidence)}
</span>
</div>
<div className="mt-3 h-2.5 overflow-hidden rounded-full bg-gray-200 dark:bg-white/10">
<div
className="h-full rounded-full bg-emerald-500 transition-all"
style={{ width: confidencePercent(overallConfidence) }}
/>
</div>
<div className="mt-2 text-xs leading-relaxed text-gray-500 dark:text-gray-400">
Daten-Confidence aus Feedback-Menge, Boxen, Label-Abdeckung und Korrekturanteil. Kein direkter Modell-Qualitätswert.
</div>
</div>
</div>
{history.length > 0 ? (
<div className="overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm dark:border-white/10 dark:bg-gray-900/70">
<div className="border-b border-gray-200 bg-gray-50/80 px-4 py-3 dark:border-white/10 dark:bg-white/[0.03]">
<div className="text-sm font-semibold text-gray-900 dark:text-white">
Trainings-Verlauf
</div>
<div className="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
Modellqualität (mAP) über die letzten Trainingsläufe
</div>
</div>
<div className="max-h-56 divide-y divide-gray-100 overflow-y-auto dark:divide-white/10">
{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 (
<div key={`${entry.trainedAtMs}-${idx}`} className="px-4 py-2.5">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-gray-900 dark:text-white">
{formatHistoryDate(entry)}
{idx === 0 ? (
<span className="ml-2 align-middle rounded-full bg-indigo-100 px-1.5 py-0.5 text-[9px] font-bold text-indigo-700 ring-1 ring-indigo-200 dark:bg-indigo-500/20 dark:text-indigo-100 dark:ring-indigo-300/30">
aktuell
</span>
) : null}
</div>
{meta.length > 0 ? (
<div className="mt-0.5 truncate text-[11px] text-gray-500 dark:text-gray-400">
{meta.join(' · ')}
</div>
) : null}
</div>
<div className="shrink-0 text-right">
<div className="text-sm font-bold tabular-nums text-gray-900 dark:text-white">
{map50 || '—'}
</div>
<div className="text-[10px] tabular-nums text-gray-500 dark:text-gray-400">
{map5095 ? `50-95: ${map5095}` : 'mAP50'}
</div>
</div>
</div>
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-gray-200 dark:bg-white/10">
<div
className="h-full rounded-full bg-indigo-500 transition-all"
style={{ width: `${barPct}%` }}
/>
</div>
</div>
)
})}
</div>
</div>
) : null}
{/* Mobile: Tabs kompakter, direkt nach Summary */}
<div className="overflow-hidden rounded-xl border border-gray-200 bg-gray-50/70 p-1.5 dark:border-white/10 dark:bg-white/[0.03] sm:p-2">
<div className="grid grid-cols-2 gap-1 sm:grid-cols-5">
{tabItems.map((item) => {
const active = item.key === activeTab
const topLabelItem = item.topLabel
? getSegmentLabelItem(item.topLabel.label)
: null
const TopIcon = topLabelItem?.icon
return (
<button
key={item.key}
type="button"
onClick={() => setActiveTab(item.key)}
className={[
'rounded-xl px-2.5 py-2 text-left transition',
'focus:outline-none focus:ring-2 focus:ring-indigo-500/40',
active
? [
'bg-white text-indigo-700 shadow-sm ring-1 ring-indigo-200',
'dark:bg-indigo-500/18 dark:text-indigo-50 dark:ring-indigo-300/45',
'dark:shadow-[0_0_0_1px_rgba(129,140,248,0.20),0_10px_24px_rgba(79,70,229,0.18)]',
].join(' ')
: [
'text-gray-600 hover:bg-white/70 hover:text-gray-900',
'dark:text-gray-300 dark:hover:bg-white/10 dark:hover:text-white',
].join(' '),
].join(' ')}
>
<div className="flex min-w-0 items-center gap-2.5">
{TopIcon ? (
<div
className={[
'flex h-9 w-9 shrink-0 items-center justify-center rounded-xl ring-1',
active
? [
'bg-indigo-50 text-indigo-700 ring-indigo-200',
'dark:bg-white/12 dark:text-indigo-50 dark:ring-white/20',
].join(' ')
: [
'bg-white text-gray-500 ring-gray-200',
'dark:bg-white/7 dark:text-gray-300 dark:ring-white/10',
].join(' '),
].join(' ')}
aria-hidden="true"
>
<TopIcon className="h-5 w-5" aria-hidden="true" />
</div>
) : null}
<div className="min-w-0 flex-1">
<div className="truncate text-xs font-semibold">
<span className="sm:hidden">{item.shortTitle}</span>
<span className="hidden sm:inline">{item.title}</span>
</div>
<div className="mt-1 flex items-center justify-start gap-2">
<span
className={[
'rounded-full px-1.5 py-0.5 text-[9px] font-bold ring-1',
confidencePillClass(item.confidence),
].join(' ')}
title={`Kategorie-Confidence: ${confidencePercent(item.confidence)}`}
>
{confidenceLabel(item.confidence)} · {confidencePercent(item.confidence)}
</span>
</div>
</div>
</div>
</button>
)
})}
</div>
</div>
<TrainingStatsList
title={activeTabItem.title}
description={activeTabItem.description}
values={activeTabItem.values}
total={activeTabItem.total}
confidence={activeTabItem.confidence}
/>
</div>
)}
</div>
</Modal>
)
}
function makeRequestId() {
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
return crypto.randomUUID()
}
return `${Date.now()}-${Math.random().toString(16).slice(2)}`
}
function annotationToTrainingSample(item: TrainingAnnotation): TrainingSample {
return {
sampleId: item.sampleId,
frameUrl: item.frameUrl,
sourceFile: item.sourceFile,
sourcePath: item.sourcePath,
sourceSizeBytes: item.sourceSizeBytes,
second: item.second,
createdAt: item.createdAt,
prediction: item.prediction,
}
}
function annotationToCorrectionState(item: TrainingAnnotation): CorrectionState {
if (item.negative) {
return {
sexPosition: NO_SEX_POSITION_LABEL,
peoplePresent: [],
bodyPartsPresent: [],
objectsPresent: [],
clothingPresent: [],
boxes: [],
}
}
if (item.correction) {
return {
...item.correction,
sexPosition: normalizeSexPositionValue(item.correction.sexPosition),
}
}
return predictionToCorrection(annotationToTrainingSample(item))
}
const FEEDBACK_PAGE_SIZE = 10
const TRAINING_INFO_DISMISSED_STORAGE_KEY = 'training:info-dismissed-key'
const TRAINING_IMAGE_EXPANDED_STORAGE_KEY = 'training:image-expanded'
const TRAINING_PENDING_IMPORT_VIDEO_STORAGE_KEY = 'training:pending-import-video'
const TRAINING_ACTIVE_IMPORT_VIDEO_STORAGE_KEY = 'training:active-import-video'
const TRAINING_ACTIVE_NEXT_STORAGE_KEY = 'training:active-next'
const ALL_TRAINING_TARGETS: TrainingTargetKey[] = ['detector', 'pose', 'videomae']
export default function TrainingTab(props: {
active?: boolean
onTrainingRunningChange?: (running: boolean) => void
onImageExpandedChange?: (expanded: boolean) => void
}) {
const tabActive = props.active ?? true
const [labels, setLabels] = useState<TrainingLabels>(emptyLabels)
const [sample, setSample] = useState<TrainingSample | null>(null)
const sampleRef = useRef<TrainingSample | null>(null)
const [correction, setCorrection] = useState<CorrectionState>(() => 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<TrainingStatus | null>(null)
const [deletingTrainingData, setDeletingTrainingData] = useState(false)
const [trainingProgress, setTrainingProgress] = useState(0)
const [trainingStep, setTrainingStep] = useState('')
const [error, setError] = useState<string | null>(null)
const [message, setMessage] = useState<string | null>(null)
const [statsModalOpen, setStatsModalOpen] = useState(false)
const [trainingStartModalOpen, setTrainingStartModalOpen] = useState(false)
const [trainingStartMode, setTrainingStartMode] = useState<TrainingStartMode>('full')
const [trainingStartTargets, setTrainingStartTargets] = useState<TrainingTargetKey[]>(ALL_TRAINING_TARGETS)
const [activeTrainingTargets, setActiveTrainingTargets] = useState<TrainingTargetKey[]>([])
const [cancellingTraining, setCancellingTraining] = useState(false)
const [trainingStats, setTrainingStats] = useState<TrainingStats | null>(null)
const [trainingStatsLoading, setTrainingStatsLoading] = useState(false)
const [trainingStatsError, setTrainingStatsError] = useState<string | null>(null)
const [trainingHistory, setTrainingHistory] = useState<TrainingHistoryEntry[]>([])
const [trainingEstimateSettings, setTrainingEstimateSettings] = useState<RecorderSettingsState | null>(null)
const wasTrainingRunningRef = useRef(false)
const shownTrainingCompletionRef = useRef<string | null>(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<QueuedTrainingSample[]>([])
const importedSampleQueueRef = useRef<QueuedTrainingSample[]>([])
const feedbackEditReturnSampleRef = useRef<QueuedTrainingSample | null>(null)
const [feedbackModalOpen, setFeedbackModalOpen] = useState(false)
const [feedbackItems, setFeedbackItems] = useState<TrainingAnnotation[]>([])
const [feedbackLoading, setFeedbackLoading] = useState(false)
const [feedbackLoadingMore, setFeedbackLoadingMore] = useState(false)
const [feedbackError, setFeedbackError] = useState<string | null>(null)
const [feedbackTotal, setFeedbackTotal] = useState(0)
const [feedbackHasMore, setFeedbackHasMore] = useState(false)
const [selectedFeedbackIndex, setSelectedFeedbackIndex] = useState(0)
const [feedbackSearchQuery, setFeedbackSearchQuery] = useState('')
const [feedbackSearchFilter, setFeedbackSearchFilter] = useState<FeedbackFilter>('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<number | null>(null)
const finishingGestureRef = useRef(false)
const [frameImageLoaded, setFrameImageLoaded] = useState(false)
const [imageExpanded, setImageExpanded] = useState(() => {
if (typeof window === 'undefined') return false
try {
return window.localStorage.getItem(TRAINING_IMAGE_EXPANDED_STORAGE_KEY) === '1'
} catch {
return false
}
})
const [showPoseSkeleton, setShowPoseSkeleton] = useState(true)
const [frameNaturalSize, setFrameNaturalSize] = useState<{
width: number
height: number
} | null>(null)
const [loadingPreviewUrl, setLoadingPreviewUrl] = useState('')
const [loadingPreviewFallbackUrl, setLoadingPreviewFallbackUrl] = useState('')
const [loadingPreviewLoaded, setLoadingPreviewLoaded] = useState(false)
const [loadingPreviewFailed, setLoadingPreviewFailed] = useState(false)
// Während des Trainings sendet das Backend zum gerade trainierten Batch eine
// Vorschau. Wir zeigen immer das zuletzt eingegangene Bild (live) und merken es
// in einem Ref, damit die Anzeige unabhängig von den (gedrosselten) Status-
// Updates bleibt und die Render-Rate begrenzt ist.
const [trainingPreviewUrl, setTrainingPreviewUrl] = useState('')
const latestTrainingPreviewRef = useRef('')
const lastTrainingStatusApplyRef = useRef(0)
const [stageOverlayMounted, setStageOverlayMounted] = useState(false)
const [stageOverlayVisible, setStageOverlayVisible] = useState(false)
const imageBoxRef = useRef<HTMLDivElement | null>(null)
const frameImageRef = useRef<HTMLImageElement | null>(null)
const [imageLayerStyle, setImageLayerStyle] = useState<CSSProperties | null>(null)
type ImageContentRect = {
left: number
top: number
width: number
height: number
right: number
bottom: number
}
const activeImageContentRectRef = useRef<ImageContentRect | null>(null)
const loadFeedbackHistoryInitial = useCallback(async (
options: {
query?: string
filter?: FeedbackFilter
} = {}
) => {
const query = options.query ?? ''
const filter = options.filter ?? 'all'
setFeedbackLoading(true)
setFeedbackError(null)
try {
const params = new URLSearchParams()
params.set('limit', String(FEEDBACK_PAGE_SIZE))
params.set('offset', '0')
if (query.trim()) {
params.set('q', query.trim())
}
if (filter !== 'all') {
params.set('filter', filter)
}
const res = await fetch(`/api/training/feedback/list?${params.toString()}`, {
cache: 'no-store',
})
const data = await res.json().catch(() => null) as TrainingFeedbackListResponse | null
if (!res.ok || !data) {
throw new Error(data && 'error' in data ? String((data as any).error) : `HTTP ${res.status}`)
}
setFeedbackItems(data.items)
setFeedbackTotal(data.total)
setFeedbackHasMore(data.hasMore)
setSelectedFeedbackIndex(0)
} catch (e) {
setFeedbackError(e instanceof Error ? e.message : String(e))
} finally {
setFeedbackLoading(false)
}
}, [])
const loadMoreFeedbackHistory = useCallback(async () => {
if (feedbackLoading || feedbackLoadingMore || !feedbackHasMore) return
setFeedbackLoadingMore(true)
setFeedbackError(null)
try {
const params = new URLSearchParams()
params.set('limit', String(FEEDBACK_PAGE_SIZE))
params.set('offset', String(feedbackItems.length))
if (feedbackSearchQuery.trim()) {
params.set('q', feedbackSearchQuery.trim())
}
if (feedbackSearchFilter !== 'all') {
params.set('filter', feedbackSearchFilter)
}
const res = await fetch(`/api/training/feedback/list?${params.toString()}`, {
cache: 'no-store',
})
const data = await res.json().catch(() => null) as TrainingFeedbackListResponse | null
if (!res.ok || !data) {
throw new Error(data && 'error' in data ? String((data as any).error) : `HTTP ${res.status}`)
}
setFeedbackItems((current) => {
const existing = new Set(
current.map((item) => `${item.sampleId}-${item.answeredAt}`)
)
const nextItems = data.items.filter(
(item) => !existing.has(`${item.sampleId}-${item.answeredAt}`)
)
return [...current, ...nextItems]
})
setFeedbackTotal(data.total)
setFeedbackHasMore(data.hasMore)
} catch (e) {
setFeedbackError(e instanceof Error ? e.message : String(e))
} finally {
setFeedbackLoadingMore(false)
}
}, [
feedbackHasMore,
feedbackItems.length,
feedbackLoading,
feedbackLoadingMore,
feedbackSearchFilter,
feedbackSearchQuery,
])
const editFeedbackItem = useCallback((item: TrainingAnnotation) => {
const currentSample = sampleRef.current
if (
!editingFeedback &&
!feedbackEditReturnSampleRef.current &&
currentSample &&
currentSample.sampleId !== item.sampleId
) {
feedbackEditReturnSampleRef.current = {
sample: currentSample,
correction: cloneCorrectionState(correctionRef.current),
manualCorrection: hasManualCorrectionRef.current,
}
}
const nextSample = annotationToTrainingSample(item)
const nextCorrection = annotationToCorrectionState(item)
const nextManualCorrection = !item.accepted
sampleRef.current = nextSample
correctionRef.current = nextCorrection
hasManualCorrectionRef.current = nextManualCorrection
setSample(nextSample)
setCorrection(nextCorrection)
setHasManualCorrection(nextManualCorrection)
setEditingFeedback({
sampleId: item.sampleId,
answeredAt: item.answeredAt,
})
setDrawingBox(null)
setBoxInteraction(null)
setTouchMagnifier(null)
setBoxLabel('')
setActiveBoxIndex(null)
setFeedbackModalOpen(false)
window.requestAnimationFrame(() => {
mobileLabelsScrollRef.current?.scrollIntoView({
block: 'start',
behavior: 'smooth',
})
})
}, [editingFeedback])
const getImageContentRect = useCallback((): ImageContentRect | null => {
const imgEl = frameImageRef.current
if (!imgEl) return null
const rect = imgEl.getBoundingClientRect()
const naturalW = imgEl.naturalWidth
const naturalH = imgEl.naturalHeight
if (
rect.width <= 0 ||
rect.height <= 0 ||
naturalW <= 0 ||
naturalH <= 0
) {
return null
}
// Entspricht object-contain: sichtbarer Bildinhalt innerhalb der img-Box.
const scale = Math.min(rect.width / naturalW, rect.height / naturalH)
const contentW = naturalW * scale
const contentH = naturalH * scale
const left = rect.left + (rect.width - contentW) / 2
const top = rect.top + (rect.height - contentH) / 2
return {
left,
top,
width: contentW,
height: contentH,
right: left + contentW,
bottom: top + contentH,
}
}, [])
const updateImageLayerStyle = useCallback(() => {
const boxEl = imageBoxRef.current
const contentRect = getImageContentRect()
if (!boxEl || !contentRect) {
setImageLayerStyle(null)
return
}
const boxRect = boxEl.getBoundingClientRect()
const nextStyle: CSSProperties = {
left: contentRect.left - boxRect.left,
top: contentRect.top - boxRect.top,
width: contentRect.width,
height: contentRect.height,
}
setImageLayerStyle((prev) => {
if (
prev &&
Number(prev.left) === nextStyle.left &&
Number(prev.top) === nextStyle.top &&
Number(prev.width) === nextStyle.width &&
Number(prev.height) === nextStyle.height
) {
return prev
}
return nextStyle
})
}, [getImageContentRect])
const detectorBoxesScrollRef = useRef<HTMLDivElement | null>(null)
const detectorBoxItemRefs = useRef<Array<HTMLDivElement | null>>([])
const activeAnalysisRequestIdRef = useRef<string | null>(null)
const loadingRef = useRef(false)
const videoImportStartedRef = useRef(false)
const videoImportInFlightKeyRef = useRef<string | null>(null)
const nextAnalysisInFlightRequestIdRef = useRef<string | null>(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<DrawingTrainingBox | null>(null)
const [boxInteraction, setBoxInteraction] = useState<BoxInteraction | null>(null)
const [touchMagnifier, setTouchMagnifier] = useState<MagnifierState | null>(null)
const [boxLabel, setBoxLabel] = useState('')
const [activeBoxIndex, setActiveBoxIndex] = useState<number | null>(null)
const [imageReloadKey, setImageReloadKey] = useState(0)
const [trainingSampleMode, setTrainingSampleMode] = useState<TrainingSampleMode>('random')
const trainingSampleModeRef = useRef<TrainingSampleMode>('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<DrawingTrainingBox | null>(null)
const boxInteractionRef = useRef<BoxInteraction | null>(null)
const correctionRef = useRef<CorrectionState>(correction)
const hasManualCorrectionRef = useRef(hasManualCorrection)
const latestGestureBoxRef = useRef<TrainingBox | null>(null)
const pendingPointerMoveRef = useRef<{ clientX: number; clientY: number } | null>(null)
const pointerMoveRafRef = useRef<number | null>(null)
const mobileLabelsScrollRef = useRef<HTMLDivElement | null>(null)
const mobileSectionRefs = useRef<Record<CorrectionSectionKey, HTMLDivElement | null>>({
sexPosition: null,
people: null,
bodyParts: null,
objects: null,
clothing: null,
})
const scrollMobileSectionToTop = useCallback(
(key: keyof typeof expandedCorrectionSections) => {
window.requestAnimationFrame(() => {
const sectionEl = mobileSectionRefs.current[key]
if (!sectionEl) return
sectionEl.scrollIntoView({
block: 'start',
behavior: 'smooth',
})
})
},
[]
)
const toggleMobileCorrectionSection = useCallback(
(key: CorrectionSectionKey, expanded: boolean) => {
setExpandedCorrectionSections(
nextExpandedCorrectionSections(key, expanded)
)
if (expanded && mobilePanel === 'labels') {
scrollMobileSectionToTop(key)
}
},
[mobilePanel, scrollMobileSectionToTop]
)
const toggleCorrectionSection = useCallback(
(key: CorrectionSectionKey, expanded: boolean) => {
setExpandedCorrectionSections(
nextExpandedCorrectionSections(key, expanded)
)
},
[]
)
const labelsRef = useRef<TrainingLabels>(emptyLabels)
useEffect(() => {
importedSampleQueueRef.current = importedSampleQueue
}, [importedSampleQueue])
useEffect(() => {
sampleRef.current = sample
}, [sample])
useEffect(() => {
if (!feedbackModalOpen) return
setFeedbackSearchQuery('')
setFeedbackSearchFilter('all')
void loadFeedbackHistoryInitial({
query: '',
filter: 'all',
})
}, [feedbackModalOpen, loadFeedbackHistoryInitial])
useEffect(() => {
loadingRef.current = loading
}, [loading])
useEffect(() => {
labelsRef.current = labels
}, [labels])
useEffect(() => {
drawingBoxRef.current = drawingBox
}, [drawingBox])
useEffect(() => {
boxInteractionRef.current = boxInteraction
}, [boxInteraction])
useEffect(() => {
correctionRef.current = correction
}, [correction])
useEffect(() => {
hasManualCorrectionRef.current = hasManualCorrection
}, [hasManualCorrection])
useEffect(() => {
trainingSampleModeRef.current = trainingSampleMode
}, [trainingSampleMode])
const boxLabels = useMemo(() => {
return uniqStrings([
...labels.people,
...labels.bodyParts,
...labels.objects,
...labels.clothing,
]).sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }))
}, [labels.people, labels.bodyParts, labels.objects, labels.clothing])
const correctionBoxes = correction.boxes ?? []
const hasTrainableFeedbackContent = useMemo(
() => correctionHasTrainablePositionOrBoxes(correction),
[correction]
)
const willSaveAsNegative = Boolean(sample) && !hasTrainableFeedbackContent
const visibleBoxes = [
...correctionBoxes.map((box, index) => ({ box, index, isDraft: false })),
...(drawingBox
? [{ box: drawingBox, index: -1, isDraft: true }]
: []),
]
const posePersons = useMemo(() => {
return (sample?.prediction.persons ?? []).filter((person) =>
hasVisiblePoseBox(person) || person.keypoints?.some(isPoseKeypointVisible)
)
}, [sample?.prediction.persons])
const hasPosePersons = posePersons.length > 0
const bodyPartScores = useMemo(() => {
return Object.fromEntries(
(sample?.prediction.bodyPartsPresent ?? [])
.filter((x) => x.label)
.map((x) => [x.label, x.score])
)
}, [sample?.prediction.bodyPartsPresent])
const objectScores = useMemo(() => {
return Object.fromEntries(
(sample?.prediction.objectsPresent ?? [])
.filter((x) => x.label)
.map((x) => [x.label, x.score])
)
}, [sample?.prediction.objectsPresent])
const clothingScores = useMemo(() => {
return Object.fromEntries(
(sample?.prediction.clothingPresent ?? [])
.filter((x) => x.label)
.map((x) => [x.label, x.score])
)
}, [sample?.prediction.clothingPresent])
const peopleScores = useMemo(() => {
const bestScores = new Map<string, number>()
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<string, number>()
for (const box of correctionBoxes) {
const cleanLabel = String(box.label || '').trim()
if (!labels.people.includes(cleanLabel)) continue
counts.set(cleanLabel, (counts.get(cleanLabel) ?? 0) + 1)
}
return Object.fromEntries(counts)
}, [correctionBoxes, labels.people])
const selectedPeopleLabels = useMemo(() => {
return correction.peoplePresent
}, [correction.peoplePresent])
const drawLabelForSection = useCallback((values: string[]) => {
const clean = String(boxLabel || '').trim()
return values.includes(clean) ? clean : ''
}, [boxLabel])
const imageSrc = useMemo(() => {
if (!sample?.frameUrl) return ''
const sep = sample.frameUrl.includes('?') ? '&' : '?'
return `${sample.frameUrl}${sep}t=${encodeURIComponent(sample.sampleId)}&r=${imageReloadKey}`
}, [sample?.frameUrl, sample?.sampleId, imageReloadKey])
const loadingPreviewRawUrlRef = useRef('')
const setLoadingPreviewCandidate = useCallback((url: string) => {
const clean = String(url || '').trim()
if (!clean) {
loadingPreviewRawUrlRef.current = ''
setLoadingPreviewUrl('')
setLoadingPreviewFallbackUrl('')
setLoadingPreviewLoaded(false)
setLoadingPreviewFailed(false)
return
}
// Wichtig:
// Bei gleichem Preview nicht neu cache-busten und nicht loaded=false setzen.
// Sonst flackert das Overlay bei jedem Phasenwechsel.
if (loadingPreviewRawUrlRef.current === clean) {
return
}
loadingPreviewRawUrlRef.current = clean
const sep = clean.includes('?') ? '&' : '?'
setLoadingPreviewUrl(`${clean}${sep}r=${Date.now()}`)
setLoadingPreviewLoaded(false)
setLoadingPreviewFailed(false)
}, [])
const applyTrainingAnalysisEvent = useCallback((raw: any, opts?: { requireActiveRequest?: boolean }) => {
const data = raw?.analysis || raw
if (!data) return false
const requestId = String(
data?.requestId ||
data?.analysisRequestId ||
''
).trim()
const activeRequestId = activeAnalysisRequestIdRef.current
if (opts?.requireActiveRequest && (!activeRequestId || requestId !== activeRequestId)) {
return false
}
const scope = String(data?.scope || '').trim()
if (scope && scope !== 'training') {
return false
}
const running = Boolean(data?.running)
if (running) {
loadingRef.current = true
setLoading(true)
}
const sourceFile = String(data?.sourceFile || '').trim()
if (sourceFile) {
setAnalysisSourceFile(sourceFile)
}
const previewUrl = String(data?.previewUrl || '').trim()
if (previewUrl) {
setLoadingPreviewCandidate(previewUrl)
}
const message = String(
data?.message ||
data?.step ||
data?.title ||
''
).trim()
if (message) {
setAnalysisStep(message)
}
const current = Number(data?.current ?? data?.stepIndex ?? data?.index)
const total = Number(data?.total ?? data?.steps ?? data?.stepTotal)
const rawProgress = Number(data?.progress ?? data?.percent)
let nextProgress: number | null = null
if (
Number.isFinite(current) &&
Number.isFinite(total) &&
total > 0
) {
nextProgress = (current / total) * 100
} else if (Number.isFinite(rawProgress)) {
nextProgress = rawProgress <= 1 ? rawProgress * 100 : rawProgress
}
if (nextProgress !== null) {
setAnalysisProgress((prev) =>
running
? Math.max(prev, clampPercent(nextProgress))
: clampPercent(nextProgress)
)
}
return true
}, [setLoadingPreviewCandidate])
useEffect(() => {
if (!imageSrc) {
setFrameImageLoaded(false)
return
}
setFrameImageLoaded(false)
}, [imageSrc])
const canStartTraining = Boolean(trainingStatus?.canTrain)
const feedbackCount = trainingStatus?.feedbackCount ?? 0
const requiredCount = trainingStatus?.requiredCount ?? 5
const feedbackBadgeText =
feedbackCount > requiredCount
? String(feedbackCount)
: `${feedbackCount}/${requiredCount}`
const trainingRunning = training || Boolean(trainingStatus?.training?.running)
const uiLocked = loading || saving || trainingRunning || deletingTrainingData
const serverTrainingProgress = trainingRunning
? trainingStatus?.training?.progress ?? trainingProgress
: trainingProgress
const shownTrainingStep = trainingRunning
? trainingStatus?.training?.step || trainingStep || 'Training läuft…'
: trainingStep
const trainingHistoryFloorMs = useMemo(
() => trainingHistoryDurationFloorMs(trainingHistory),
[trainingHistory]
)
const trainingEstimateRuntime = useMemo(
() => trainingEstimateRuntimeFromSettings(trainingEstimateSettings),
[trainingEstimateSettings]
)
const trainingEstimateRuntimeLabel = useMemo(
() => trainingEstimateRuntimeText(trainingEstimateRuntime),
[trainingEstimateRuntime]
)
const trainingEstimateDetectorEpochs = useMemo(
() => trainingEstimateInt(trainingEstimateSettings?.trainingDetectorEpochs, 60, 1, 300),
[trainingEstimateSettings?.trainingDetectorEpochs]
)
const trainingStartOptions = useMemo(() => {
const feedbackReady = feedbackCount >= requiredCount
const detector = trainingStatus?.detector
const pose = trainingStatus?.pose
const videoMAE = trainingStatus?.videoMAE
const detectorReady = feedbackReady && Boolean(detector?.dataReady)
const poseReady = feedbackReady && Boolean(pose?.dataReady)
const videoMAEReady = feedbackReady && Boolean(videoMAE?.dataReady)
const detectorHasHistory = trainingHasTargetHistory(trainingHistory, 'detector')
const poseHasHistory = trainingHasTargetHistory(trainingHistory, 'pose')
const videoMAEHasHistory = trainingHasTargetHistory(trainingHistory, 'videomae')
const detectorHistoryEstimateMs = estimateTrainingHistoryDurationMs(
trainingHistory,
'detector',
Number(detector?.trainCount ?? 0),
Number(detector?.valCount ?? 0),
0,
trainingEstimateRuntime,
trainingEstimateDetectorEpochs
)
const poseHistoryEstimateMs = estimateTrainingHistoryDurationMs(
trainingHistory,
'pose',
Number(pose?.trainCount ?? 0),
Number(pose?.valCount ?? 0),
0,
trainingEstimateRuntime,
trainingEstimateDetectorEpochs
)
const videoMAEHistoryEstimateMs = estimateTrainingHistoryDurationMs(
trainingHistory,
'videomae',
Number(videoMAE?.trainCount ?? 0),
Number(videoMAE?.valCount ?? 0),
Number(videoMAE?.eligibleCount ?? 0),
trainingEstimateRuntime,
8
)
const detectorFineTuneFactor = trainingFineTuneEstimateFactor(
'detector',
Boolean(detector?.trainedModelExists),
detectorHasHistory
)
const poseFineTuneFactor = trainingFineTuneEstimateFactor(
'pose',
Boolean(pose?.trainedModelExists),
poseHasHistory
)
const videoMAEFineTuneFactor = trainingFineTuneEstimateFactor(
'videomae',
Boolean(videoMAE?.trainedModelExists),
videoMAEHasHistory
)
const detectorFallbackHistoryMs = detectorHistoryEstimateMs ||
(trainingHistoryFloorMs > 0 ? trainingHistoryFloorMs * detectorFineTuneFactor : 0)
const poseFallbackHistoryMs = poseHistoryEstimateMs ||
(trainingHistoryFloorMs > 0 ? trainingHistoryFloorMs * poseFineTuneFactor : 0)
const videoMAEFallbackHistoryMs = videoMAEHistoryEstimateMs ||
(trainingHistoryFloorMs > 0 ? trainingHistoryFloorMs * videoMAEFineTuneFactor : 0)
const detectorEstimateMs = estimateTrainingDurationMs(
'detector',
Number(detector?.trainCount ?? 0),
Number(detector?.valCount ?? 0),
0,
detectorFallbackHistoryMs,
trainingEstimateRuntime.yoloFactor,
trainingEstimateDetectorEpochs,
detectorFineTuneFactor
)
const poseEstimateMs = estimateTrainingDurationMs(
'pose',
Number(pose?.trainCount ?? 0),
Number(pose?.valCount ?? 0),
0,
poseFallbackHistoryMs,
trainingEstimateRuntime.yoloFactor,
trainingEstimateDetectorEpochs,
poseFineTuneFactor
)
const videoMAEEstimateMs = estimateTrainingDurationMs(
'videomae',
Number(videoMAE?.trainCount ?? 0),
Number(videoMAE?.valCount ?? 0),
Number(videoMAE?.eligibleCount ?? 0),
videoMAEFallbackHistoryMs,
trainingEstimateRuntime.factor,
60,
videoMAEFineTuneFactor
)
return [
{
key: 'detector' as const,
label: 'YOLO26 Detector',
icon: RectangleGroupIcon,
description: 'Boxen, Personen, Körperteile, Objekte und Kleidung.',
ready: detectorReady,
estimateMs: detectorEstimateMs,
estimateText: formatApproxTrainingDuration(detectorEstimateMs),
detail: `Train ${Number(detector?.trainCount ?? 0)}/${Number(detector?.requiredTrain ?? 20)}, Val ${Number(detector?.valCount ?? 0)}/${Number(detector?.requiredVal ?? 3)}, Positiv ${Number(detector?.positiveTrainCount ?? 0)}/${Number(detector?.positiveValCount ?? 0)}${detector?.trainedModelExists ? ', Fine-Tuning' : ''}`,
blockedText: !feedbackReady
? `Noch zu wenig Feedback: ${feedbackCount}/${requiredCount}.`
: 'Detector-Datensatz ist noch nicht bereit.',
},
{
key: 'pose' as const,
label: 'YOLO26 Pose',
icon: UserGroupIcon,
description: 'Keypoints und Positions-Kontext aus Skeleton-Beispielen.',
ready: poseReady,
estimateMs: poseEstimateMs,
estimateText: formatApproxTrainingDuration(poseEstimateMs),
detail: `Train ${Number(pose?.trainCount ?? 0)}/${Number(pose?.requiredTrain ?? 20)}, Val ${Number(pose?.valCount ?? 0)}/${Number(pose?.requiredVal ?? 3)}${pose?.trainedModelExists ? ', Fine-Tuning' : ''}`,
blockedText: !feedbackReady
? `Noch zu wenig Feedback: ${feedbackCount}/${requiredCount}.`
: 'Pose-Datensatz ist noch nicht bereit.',
},
{
key: 'videomae' as const,
label: 'VideoMAE Clip-Analyse',
icon: VideoCameraIcon,
description: 'Clip-basierte Positionsanalyse aus mehreren Video-Frames.',
ready: videoMAEReady,
estimateMs: videoMAEEstimateMs,
estimateText: formatApproxTrainingDuration(videoMAEEstimateMs),
detail: `Eligible ${Number(videoMAE?.eligibleCount ?? 0)}, Train ${Number(videoMAE?.trainCount ?? 0)}/${Number(videoMAE?.requiredTrain ?? 40)}, Val ${Number(videoMAE?.valCount ?? 0)}/${Number(videoMAE?.requiredVal ?? 5)}${videoMAE?.trainedModelExists ? ', Fine-Tuning' : ''}`,
blockedText: !feedbackReady
? `Noch zu wenig Feedback: ${feedbackCount}/${requiredCount}.`
: 'VideoMAE-Datensatz ist noch nicht bereit.',
},
]
}, [
feedbackCount,
requiredCount,
trainingStatus?.detector,
trainingStatus?.pose,
trainingStatus?.videoMAE,
trainingHistory,
trainingHistoryFloorMs,
trainingEstimateRuntime.factor,
trainingEstimateRuntime.yoloFactor,
trainingEstimateDetectorEpochs,
])
const selectableTrainingTargets = useMemo(
() => trainingStartOptions
.filter((option) => option.ready)
.map((option) => option.key),
[trainingStartOptions]
)
const selectedTrainingTargets = useMemo(
() => trainingStartTargets.filter((key) => selectableTrainingTargets.includes(key)),
[selectableTrainingTargets, trainingStartTargets]
)
const canConfirmTrainingStart =
trainingStartMode === 'full'
? canStartTraining
: selectedTrainingTargets.length > 0
const plannedTrainingTargets = useMemo(
() => trainingStartMode === 'full'
? selectableTrainingTargets
: selectedTrainingTargets,
[selectableTrainingTargets, selectedTrainingTargets, trainingStartMode]
)
const trainingStartTotalEstimateMs = useMemo(() => {
const planned = new Set(plannedTrainingTargets)
const estimate = trainingStartOptions.reduce((sum, option) => (
planned.has(option.key) ? sum + Number(option.estimateMs ?? 0) : sum
), 0)
return trainingHistoryFloorMs > 0 && plannedTrainingTargets.length > 1
? Math.max(estimate, trainingHistoryFloorMs)
: estimate
}, [plannedTrainingTargets, trainingHistoryFloorMs, trainingStartOptions])
const trainingStartTotalEstimateText =
plannedTrainingTargets.length > 0
? formatApproxTrainingDuration(trainingStartTotalEstimateMs)
: 'ca. —'
const trainingEstimateByTarget = useMemo(() => {
const estimates: Record<TrainingTargetKey, number> = {
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<TrainingTargetKey | ''>(() => {
if (!trainingRunning || effectiveTrainingTargets.length === 0) return ''
const progress = clampPercent(Number(serverTrainingProgress) || 0)
const activeIndex = activeTrainingTarget
? effectiveTrainingTargets.indexOf(activeTrainingTarget)
: -1
if (activeIndex >= 0) {
const window = trainingTargetProgressWindow(activeTrainingTarget)
if (progress < window.end || activeIndex === effectiveTrainingTargets.length - 1) {
return activeTrainingTarget
}
}
return effectiveTrainingTargets.find((target) => {
const window = trainingTargetProgressWindow(target)
return progress < window.end
}) ?? effectiveTrainingTargets[effectiveTrainingTargets.length - 1] ?? ''
}, [
activeTrainingTarget,
effectiveTrainingTargets,
serverTrainingProgress,
trainingRunning,
])
const currentTrainingTargetProgress = useMemo(() => {
if (!currentTrainingTarget) return 0
const window = trainingTargetProgressWindow(currentTrainingTarget)
const span = Math.max(1, window.end - window.start)
const progress = clampPercent(Number(serverTrainingProgress) || 0)
return clamp01((progress - window.start) / span)
}, [
currentTrainingTarget,
serverTrainingProgress,
])
const estimatedTrainingProgress = useMemo(() => {
if (!trainingRunning || effectiveTrainingTargets.length === 0) return 0
const totalEstimateMs = effectiveTrainingTargets.reduce(
(sum, target) => sum + Math.max(0, trainingEstimateByTarget[target] || 0),
0
)
if (totalEstimateMs <= 0) return 0
const currentTarget = currentTrainingTarget
if (!currentTarget) return 0
const currentIndex = effectiveTrainingTargets.indexOf(currentTarget)
if (currentIndex < 0) return 0
const completedMs = effectiveTrainingTargets
.slice(0, currentIndex)
.reduce((sum, target) => sum + Math.max(0, trainingEstimateByTarget[target] || 0), 0)
const currentMs = Math.max(0, trainingEstimateByTarget[currentTarget] || 0)
const progressMs = completedMs + currentMs * currentTrainingTargetProgress
return clampPercent((progressMs / totalEstimateMs) * 100)
}, [
currentTrainingTarget,
currentTrainingTargetProgress,
effectiveTrainingTargets,
trainingEstimateByTarget,
trainingRunning,
])
const serverTrainingProgressPercent = clampPercent(Number(serverTrainingProgress) || 0)
const usesSelectedTargetProgress =
activeTrainingTargets.length > 0 &&
activeTrainingTargets.length < ALL_TRAINING_TARGETS.length
const shownTrainingProgress = trainingRunning
? usesSelectedTargetProgress
? Math.max(
Math.min(serverTrainingProgressPercent, 6),
estimatedTrainingProgress
)
: Math.max(serverTrainingProgressPercent, estimatedTrainingProgress)
: serverTrainingProgress
const drawingCursorClass =
boxInteraction?.type === 'move'
? '[@media_(hover:hover)_and_(pointer:fine)]:cursor-grabbing'
: drawingBox || (boxLabel && !uiLocked)
? '[@media_(hover:hover)_and_(pointer:fine)]:cursor-crosshair'
: ''
const trainingInfoJob = trainingStatus?.training ?? null
const trainingInfoKey = useMemo(() => {
const job = trainingInfoJob
if (!job || job.running) return ''
const finishedAt = String(job.finishedAt || '').trim()
const startedAt = String(job.startedAt || '').trim()
const message = String(job.message || job.error || '').trim()
if (finishedAt) return finishedAt
if (startedAt && message) return `${startedAt}:${message}`
return ''
}, [
trainingInfoJob?.running,
trainingInfoJob?.finishedAt,
trainingInfoJob?.startedAt,
trainingInfoJob?.message,
trainingInfoJob?.error,
])
const showTrainingInfo =
Boolean(trainingInfoKey) &&
!trainingRunning &&
dismissedTrainingInfoKey !== trainingInfoKey
const trainingInfoMessage = String(
trainingInfoJob?.message ||
trainingInfoJob?.error ||
'Training abgeschlossen.'
).trim()
const trainingInfoDurationMs = trainingDurationMs(trainingInfoJob)
const trainingInfoLooksPartial =
Boolean(trainingInfoJob?.error) ||
/übersprungen|fehlgeschlagen|abgebrochen/i.test(trainingInfoMessage)
const dismissTrainingInfo = useCallback(() => {
if (!trainingInfoKey) return
setDismissedTrainingInfoKey(trainingInfoKey)
try {
window.localStorage.setItem(
TRAINING_INFO_DISMISSED_STORAGE_KEY,
trainingInfoKey
)
} catch {
// ignore
}
}, [trainingInfoKey])
const analysisConfidence = useMemo(() => {
return currentAnalysisConfidence(sample?.prediction)
}, [sample?.prediction])
const loadLabels = useCallback(async () => {
const res = await fetch('/api/training/labels', { cache: 'no-store' })
if (!res.ok) return
const data = await res.json().catch(() => null)
if (data) setLabels(sortTrainingLabels(data))
}, [])
const applyTrainingStatus = useCallback((data: any) => {
if (!data) return
const job = data.training || null
const videoMAE = data.videoMAE || data.videomae || data.scene || null
setTrainingStatus((prev) => ({
feedbackCount: Number(data.feedbackCount ?? prev?.feedbackCount ?? 0),
requiredCount: Number(data.requiredCount ?? prev?.requiredCount ?? 5),
canTrain: Boolean(data.canTrain ?? prev?.canTrain ?? false),
detector: data.detector
? {
trainCount: Number(data.detector.trainCount ?? 0),
valCount: Number(data.detector.valCount ?? 0),
positiveTrainCount: Number(data.detector.positiveTrainCount ?? 0),
positiveValCount: Number(data.detector.positiveValCount ?? 0),
requiredTrain: Number(data.detector.requiredTrain ?? 20),
requiredVal: Number(data.detector.requiredVal ?? 3),
datasetReady: Boolean(data.detector.datasetReady),
dataReady: Boolean(data.detector.dataReady),
modelExists: Boolean(data.detector.modelExists),
modelPath: data.detector.modelPath,
trainedModelExists: Boolean(data.detector.trainedModelExists ?? data.detector.modelExists),
trainedModelPath: data.detector.trainedModelPath,
source: data.detector.source || data.detector.modelSource,
}
: prev?.detector,
pose: data.pose
? {
trainCount: Number(data.pose.trainCount ?? 0),
valCount: Number(data.pose.valCount ?? 0),
requiredTrain: Number(data.pose.requiredTrain ?? 20),
requiredVal: Number(data.pose.requiredVal ?? 3),
datasetReady: Boolean(data.pose.datasetReady),
dataReady: Boolean(data.pose.dataReady),
modelExists: Boolean(data.pose.modelExists),
modelPath: data.pose.modelPath,
trainedModelExists: Boolean(data.pose.trainedModelExists ?? data.pose.modelExists),
trainedModelPath: data.pose.trainedModelPath,
source: data.pose.source || data.pose.modelSource,
}
: prev?.pose,
videoMAE: videoMAE
? {
eligibleCount: Number(videoMAE.eligibleCount ?? 0),
trainCount: Number(videoMAE.trainCount ?? 0),
valCount: Number(videoMAE.valCount ?? 0),
requiredTrain: Number(videoMAE.requiredTrain ?? videoMAE.requiredCount ?? 40),
requiredVal: Number(videoMAE.requiredVal ?? 5),
datasetReady: Boolean(videoMAE.datasetReady),
dataReady: Boolean(videoMAE.dataReady),
modelExists: Boolean(videoMAE.modelExists ?? videoMAE.modelReady),
modelPath: videoMAE.modelPath,
trainedModelExists: Boolean(
videoMAE.trainedModelExists ??
videoMAE.trainedModelReady ??
videoMAE.modelExists ??
videoMAE.modelReady
),
trainedModelPath: videoMAE.trainedModelPath,
source: videoMAE.source || videoMAE.modelSource,
}
: prev?.videoMAE,
training: job
? {
running: Boolean(job.running),
progress: Number(job.progress ?? 0),
step: String(job.step ?? ''),
message: job.message,
error: job.error,
startedAt: job.startedAt,
finishedAt: job.finishedAt,
durationMs: Number(job.durationMs ?? 0),
stage: job.stage,
epoch: Number(job.epoch ?? 0),
epochs: Number(job.epochs ?? 0),
previewUrl: String(job.previewUrl ?? ''),
map50: Number(job.map50 ?? 0),
map5095: Number(job.map5095 ?? 0),
}
: prev?.training,
}))
setTraining(Boolean(job?.running))
if (job?.message && !job.running) {
setTrainingProgress(100)
setTrainingStep('Training abgeschlossen.')
const finishedAt = String(job.finishedAt || '').trim()
const completionKey =
finishedAt || `${String(job.startedAt || '')}:${String(job.message || '')}`
if (completionKey && shownTrainingCompletionRef.current !== completionKey) {
shownTrainingCompletionRef.current = completionKey
const duration = trainingDurationMs(job)
const durationText = duration > 0
? ` Dauer: ${formatDuration(duration)}.`
: ''
setMessage(`${String(job.message)}${durationText}`)
}
}
if (job?.error && !job.running) {
const finishedAt = String(job.finishedAt || '').trim()
const errorKey = finishedAt
? `${finishedAt}:error`
: `${String(job.startedAt || '')}:error`
if (errorKey && shownTrainingCompletionRef.current !== errorKey) {
shownTrainingCompletionRef.current = errorKey
setError(String(job.error))
}
}
}, [])
const loadTrainingSampleIntoTab = useCallback((
nextSample: TrainingSample,
opts?: { manualCorrection?: boolean; correction?: CorrectionState }
) => {
const nextCorrection = opts?.correction
? cloneCorrectionState(opts.correction)
: predictionToCorrection(nextSample)
setDrawingBox(null)
setBoxInteraction(null)
setTouchMagnifier(null)
setBoxLabel('')
setActiveBoxIndex(null)
setMobilePanel(trainingRunningRef.current ? 'training' : 'labels')
window.requestAnimationFrame(() => {
mobileLabelsScrollRef.current?.scrollIntoView({
block: 'start',
behavior: 'smooth',
})
})
sampleRef.current = nextSample
correctionRef.current = nextCorrection
hasManualCorrectionRef.current = Boolean(opts?.manualCorrection)
setSample(nextSample)
setCorrection(nextCorrection)
setHasManualCorrection(Boolean(opts?.manualCorrection))
const initiallyExpandedSection: CorrectionSectionKey | null =
nextCorrection.sexPosition && !isNoSexPositionValue(nextCorrection.sexPosition)
? 'sexPosition'
: nextCorrection.peoplePresent.length > 0
? 'people'
: nextCorrection.bodyPartsPresent.length > 0
? 'bodyParts'
: nextCorrection.objectsPresent.length > 0
? 'objects'
: nextCorrection.clothingPresent.length > 0
? 'clothing'
: null
setExpandedCorrectionSections(
initiallyExpandedSection
? nextExpandedCorrectionSections(initiallyExpandedSection, true)
: {
sexPosition: false,
people: false,
bodyParts: false,
objects: false,
clothing: false,
}
)
}, [])
const setQueuedTrainingSamples = useCallback((nextQueue: QueuedTrainingSample[]) => {
importedSampleQueueRef.current = nextQueue
setImportedSampleQueue(nextQueue)
}, [])
const currentSampleToQueuedItem = useCallback((): QueuedTrainingSample | null => {
const currentSample = sampleRef.current
if (!currentSample) {
return null
}
return {
sample: currentSample,
correction: cloneCorrectionState(correctionRef.current),
manualCorrection: hasManualCorrectionRef.current,
}
}, [])
const loadQueuedTrainingSample = useCallback((item: QueuedTrainingSample) => {
loadTrainingSampleIntoTab(item.sample, {
correction: item.correction,
manualCorrection: item.manualCorrection,
})
}, [loadTrainingSampleIntoTab])
const loadNextImportedQueuedSample = useCallback(() => {
const [nextItem, ...rest] = importedSampleQueueRef.current
if (!nextItem) {
return false
}
setQueuedTrainingSamples(rest)
loadQueuedTrainingSample(nextItem)
return true
}, [loadQueuedTrainingSample, setQueuedTrainingSamples])
const deferCurrentSampleToQueueEnd = useCallback(() => {
const currentItem = currentSampleToQueuedItem()
if (!currentItem) {
return false
}
setQueuedTrainingSamples([...importedSampleQueueRef.current, currentItem])
return true
}, [currentSampleToQueuedItem, setQueuedTrainingSamples])
const loadPriorityTrainingSamples = useCallback((
prioritySamples: TrainingSample[],
opts?: { deferCurrentSampleToQueueEnd?: boolean }
) => {
const priorityItems = prioritySamples.map((prioritySample) => ({
sample: prioritySample,
}))
if (priorityItems.length === 0) {
return false
}
const currentItem = opts?.deferCurrentSampleToQueueEnd
? currentSampleToQueuedItem()
: null
const nextQueue = [
...priorityItems,
...importedSampleQueueRef.current,
...(currentItem ? [currentItem] : []),
]
const [nextItem, ...rest] = nextQueue
if (!nextItem) {
return false
}
setQueuedTrainingSamples(rest)
loadQueuedTrainingSample(nextItem)
return true
}, [currentSampleToQueuedItem, loadQueuedTrainingSample, setQueuedTrainingSamples])
const completeNextFromData = useCallback((data: any, opts?: { deferCurrentSampleToQueueEnd?: boolean }) => {
const nextSample = data?.sample || (
data?.sampleId && data?.frameUrl
? data as TrainingSample
: null
)
if (!nextSample) {
throw new Error(backendText(data, 'Es wurde kein Trainingsbild erzeugt.'))
}
setAnalysisProgress(92)
setAnalysisStep('Analyse-Ergebnis wird übernommen…')
if (opts?.deferCurrentSampleToQueueEnd) {
deferCurrentSampleToQueueEnd()
}
loadTrainingSampleIntoTab(nextSample as TrainingSample)
setImageReloadKey((value) => value + 1)
return true
}, [deferCurrentSampleToQueueEnd, loadTrainingSampleIntoTab])
const waitForNextResult = useCallback(async (
requestId: string,
opts?: { deferCurrentSampleToQueueEnd?: boolean }
) => {
const id = String(requestId || '').trim()
if (!id) throw new Error('requestId fehlt.')
for (;;) {
const res = await fetch(
`/api/training/next/status?requestId=${encodeURIComponent(id)}`,
{ cache: 'no-store' }
)
const data = await res.json().catch(() => null)
if (data?.analysis) {
applyTrainingAnalysisEvent(data.analysis)
}
if (res.status === 202 || data?.running) {
await new Promise((resolve) => window.setTimeout(resolve, 800))
continue
}
if (!res.ok || !data?.ok) {
throw new Error(backendText(data, `HTTP ${res.status}`))
}
return completeNextFromData(data, opts)
}
}, [applyTrainingAnalysisEvent, completeNextFromData])
const loadNext = useCallback(async (opts?: {
forceNew?: boolean
refreshPrediction?: boolean
preserveNotice?: boolean
mode?: TrainingSampleMode
previewUrl?: string
deferCurrentSampleToQueueEnd?: boolean
}) => {
const requestId = makeRequestId()
activeAnalysisRequestIdRef.current = requestId
nextAnalysisInFlightRequestIdRef.current = requestId
const isCurrentRequest = () => activeAnalysisRequestIdRef.current === requestId
const mode = opts?.mode ?? trainingSampleModeRef.current
const uncertainMode = mode === 'uncertain' && !opts?.refreshPrediction
const previewUrl = String(opts?.previewUrl ?? '').trim()
setLoadingPreviewFallbackUrl(previewUrl)
setLoadingPreviewCandidate(previewUrl)
loadingRef.current = true
setLoading(true)
setAnalysisSourceFile('')
setAnalysisProgress(8)
setAnalysisStep(
opts?.refreshPrediction
? 'Aktuelles Bild wird neu analysiert…'
: uncertainMode
? 'Unsichere Prediction wird gesucht…'
: opts?.forceNew
? 'Neues Trainingsbild wird gesucht…'
: 'Trainingsbild wird geladen…'
)
if (!opts?.preserveNotice) {
setError(null)
setMessage(null)
}
let keepActiveJob = false
let completed = false
try {
const params = new URLSearchParams()
params.set('analysisRequestId', requestId)
params.set('async', '1')
if (opts?.forceNew) params.set('force', '1')
if (opts?.refreshPrediction) params.set('refresh', '1')
if (uncertainMode) params.set('mode', 'uncertain')
try {
window.sessionStorage.setItem(
TRAINING_ACTIVE_NEXT_STORAGE_KEY,
JSON.stringify({
requestId,
opts: {
forceNew: Boolean(opts?.forceNew),
refreshPrediction: Boolean(opts?.refreshPrediction),
mode,
previewUrl,
deferCurrentSampleToQueueEnd: Boolean(opts?.deferCurrentSampleToQueueEnd),
},
})
)
} catch {
// ignore
}
const url = `/api/training/next${params.toString() ? `?${params.toString()}` : ''}`
setAnalysisProgress(uncertainMode ? 5 : 25)
setAnalysisStep(
uncertainMode
? 'Mehrere Kandidaten werden vorbereitet…'
: 'Bild wird vorbereitet…'
)
const res = await fetch(url, { cache: 'no-store' })
const data = await res.json().catch(() => null)
if (!res.ok) {
throw new Error(data?.error || `HTTP ${res.status}`)
}
if (!isCurrentRequest()) {
return
}
if (data?.analysis) {
applyTrainingAnalysisEvent(data.analysis)
}
if (res.status === 202 || data?.accepted || data?.running) {
await waitForNextResult(requestId, {
deferCurrentSampleToQueueEnd: Boolean(opts?.deferCurrentSampleToQueueEnd),
})
} else {
completeNextFromData(data, {
deferCurrentSampleToQueueEnd: Boolean(opts?.deferCurrentSampleToQueueEnd),
})
}
completed = true
} catch (e) {
if (isCurrentRequest()) {
const msg = e instanceof Error ? e.message : String(e)
const mayStillRun =
/load failed|failed to fetch|networkerror|network error/i.test(msg)
if (mayStillRun) {
keepActiveJob = true
setMessage('Analyse läuft im Backend weiter. Beim Zurückkehren wird das nächste offene Trainingsbild wieder geladen.')
} else {
try {
window.sessionStorage.removeItem(TRAINING_ACTIVE_NEXT_STORAGE_KEY)
} catch {
// ignore
}
setError(msg)
}
}
} finally {
if (!isCurrentRequest()) {
return
}
if (completed) {
try {
window.sessionStorage.removeItem(TRAINING_ACTIVE_NEXT_STORAGE_KEY)
} catch {
// ignore
}
}
if (keepActiveJob) {
return
}
setAnalysisProgress((value) => Math.max(value, 100))
setAnalysisStep((value) => value || 'Analyse abgeschlossen.')
const finishedRequestId = requestId
window.setTimeout(() => {
if (activeAnalysisRequestIdRef.current !== finishedRequestId) return
activeAnalysisRequestIdRef.current = null
nextAnalysisInFlightRequestIdRef.current = null
loadingRef.current = false
setLoading(false)
setAnalysisSourceFile('')
setAnalysisProgress(0)
setAnalysisStep('')
}, 500)
}
}, [
applyTrainingAnalysisEvent,
completeNextFromData,
setLoadingPreviewCandidate,
waitForNextResult,
])
const reloadCurrentImage = useCallback(async () => {
setDrawingBox(null)
setBoxInteraction(null)
setTouchMagnifier(null)
setActiveBoxIndex(null)
await loadNext({
refreshPrediction: true,
previewUrl: imageSrc,
})
setImageReloadKey((value) => value + 1)
}, [imageSrc, loadNext])
const loadTrainingStatus = useCallback(async () => {
const res = await fetch('/api/training/status', { cache: 'no-store' })
const data = await res.json().catch(() => null)
if (!res.ok || !data) return
applyTrainingStatus(data)
}, [applyTrainingStatus])
const completeVideoImportFromData = useCallback(async (data: any) => {
const rawSamples: TrainingSample[] = Array.isArray(data?.samples)
? data.samples
: data?.sample
? [data.sample]
: []
const samples = withTrainingFrameLabels(rawSamples)
if (samples.length === 0) {
throw new Error('Es wurden keine Trainingsframes erzeugt.')
}
const deferredCurrentSample = Boolean(sampleRef.current)
loadPriorityTrainingSamples(samples, {
deferCurrentSampleToQueueEnd: deferredCurrentSample,
})
setImageReloadKey((value) => value + 1)
await loadTrainingStatus()
const errorCount = Array.isArray(data?.errors) ? data.errors.length : 0
const baseMessage = errorCount > 0
? `${samples.length} Frames ins Training übernommen, ${errorCount} Frames fehlgeschlagen.`
: `${samples.length} Frames ins Training übernommen.`
setMessage(
deferredCurrentSample
? `${baseMessage} Das aktuelle Bild wurde ans Ende der Queue gelegt.`
: baseMessage
)
return true
}, [loadPriorityTrainingSamples, loadTrainingStatus])
const waitForVideoImportResult = useCallback(async (requestId: string) => {
const id = String(requestId || '').trim()
if (!id) throw new Error('requestId fehlt.')
for (;;) {
const res = await fetch(
`/api/training/import-video/status?requestId=${encodeURIComponent(id)}`,
{ cache: 'no-store' }
)
const data = await res.json().catch(() => null)
if (res.status === 202 || data?.running) {
await new Promise((resolve) => window.setTimeout(resolve, 1200))
continue
}
if (!res.ok || !data?.ok) {
throw new Error(backendText(data, `HTTP ${res.status}`))
}
return completeVideoImportFromData(data)
}
}, [completeVideoImportFromData])
const importVideoIntoTraining = useCallback(async (raw: any) => {
const output = String(raw?.output || '').trim()
if (!output) return false
setLoadingPreviewCandidate('')
const TRAINING_IMPORT_FRAME_COUNT = 10
const detail: PendingTrainingVideoImport = {
jobId: String(raw?.jobId || '').trim(),
output,
sourceFile: String(raw?.sourceFile || '').trim(),
count: Number(raw?.count || TRAINING_IMPORT_FRAME_COUNT),
}
const importKey = `${detail.jobId || ''}|${detail.output}|${detail.count || TRAINING_IMPORT_FRAME_COUNT}`
// Verhindert Doppelimport durch sessionStorage + CustomEvent.
if (videoImportInFlightKeyRef.current === importKey) {
return false
}
videoImportStartedRef.current = true
videoImportInFlightKeyRef.current = importKey
const requestId = makeRequestId()
activeAnalysisRequestIdRef.current = requestId
loadingRef.current = true
setLoading(true)
setAnalysisSourceFile(detail.sourceFile || detail.output.split(/[\\/]/).pop() || '')
setAnalysisProgress(5)
setAnalysisStep('Video wird ins Training übernommen…')
setError(null)
setMessage(null)
try {
try {
window.sessionStorage.removeItem(TRAINING_PENDING_IMPORT_VIDEO_STORAGE_KEY)
window.sessionStorage.setItem(
TRAINING_ACTIVE_IMPORT_VIDEO_STORAGE_KEY,
JSON.stringify({ requestId, importKey, detail })
)
} catch {
// ignore
}
const res = await fetch('/api/training/import-video', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
cache: 'no-store',
body: JSON.stringify({
jobId: detail.jobId,
output: detail.output,
count: detail.count || TRAINING_IMPORT_FRAME_COUNT,
analysisRequestId: requestId,
}),
})
const data = await res.json().catch(() => null)
if (!res.ok || !data?.ok) {
throw new Error(backendText(data, `HTTP ${res.status}`))
}
if (res.status === 202 || data?.accepted || data?.running) {
await waitForVideoImportResult(String(data?.requestId || requestId))
try {
window.sessionStorage.removeItem(TRAINING_ACTIVE_IMPORT_VIDEO_STORAGE_KEY)
} catch {
// ignore
}
return true
}
const rawSamples: TrainingSample[] = Array.isArray(data.samples)
? data.samples
: data.sample
? [data.sample]
: []
const samples = withTrainingFrameLabels(rawSamples)
if (samples.length === 0) {
throw new Error('Es wurden keine Trainingsframes erzeugt.')
}
const deferredCurrentSample = Boolean(sampleRef.current)
loadPriorityTrainingSamples(samples, {
deferCurrentSampleToQueueEnd: deferredCurrentSample,
})
setImageReloadKey((value) => value + 1)
await loadTrainingStatus()
const errorCount = Array.isArray(data.errors) ? data.errors.length : 0
setMessage(
errorCount > 0
? `${samples.length} Frames ins Training übernommen, ${errorCount} Frames fehlgeschlagen.`
: `${samples.length} Frames ins Training übernommen.`
)
if (deferredCurrentSample) {
setMessage(
errorCount > 0
? `${samples.length} Frames ins Training übernommen, ${errorCount} Frames fehlgeschlagen. Das aktuelle Bild wurde ans Ende der Queue gelegt.`
: `${samples.length} Frames ins Training übernommen. Das aktuelle Bild wurde ans Ende der Queue gelegt.`
)
}
return true
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
const mayStillRun =
/load failed|failed to fetch|networkerror|network error/i.test(msg)
if (mayStillRun) {
setMessage('Video-Import läuft im Backend weiter und wird beim Zurückkehren wieder aufgenommen.')
} else {
try {
window.sessionStorage.removeItem(TRAINING_ACTIVE_IMPORT_VIDEO_STORAGE_KEY)
} catch {
// ignore
}
setError(msg)
}
return false
} finally {
setAnalysisProgress(100)
setAnalysisStep('Video-Import abgeschlossen.')
const finishedRequestId = requestId
window.setTimeout(() => {
if (activeAnalysisRequestIdRef.current === finishedRequestId) {
activeAnalysisRequestIdRef.current = null
loadingRef.current = false
setLoading(false)
setAnalysisSourceFile('')
setAnalysisProgress(0)
setAnalysisStep('')
}
if (videoImportInFlightKeyRef.current === importKey) {
videoImportInFlightKeyRef.current = null
}
}, 500)
}
}, [
loadPriorityTrainingSamples,
loadTrainingStatus,
setLoadingPreviewCandidate,
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),
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<any>).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<any>).detail
if (!loadingRef.current || !activeAnalysisRequestIdRef.current) return
applyTrainingAnalysisEvent(data, { requireActiveRequest: true })
} catch {
// ignore
}
}
window.addEventListener('app:sse:analysis', onAnalysis as EventListener)
return () => {
window.removeEventListener('app:sse:analysis', onAnalysis as EventListener)
}
}, [applyTrainingAnalysisEvent])
useEffect(() => {
const draggingBox = Boolean(drawingBox || boxInteraction)
if (!draggingBox) return
const previousUserSelect = document.body.style.userSelect
const previousWebkitUserSelect = document.body.style.webkitUserSelect
document.body.style.userSelect = 'none'
document.body.style.webkitUserSelect = 'none'
const clearSelection = () => {
window.getSelection()?.removeAllRanges()
}
const preventSelection = (event: Event) => {
event.preventDefault()
}
clearSelection()
document.addEventListener('selectionchange', clearSelection)
document.addEventListener('selectstart', preventSelection)
return () => {
document.body.style.userSelect = previousUserSelect
document.body.style.webkitUserSelect = previousWebkitUserSelect
document.removeEventListener('selectionchange', clearSelection)
document.removeEventListener('selectstart', preventSelection)
clearSelection()
}
}, [drawingBox, boxInteraction])
useEffect(() => {
if (!statsModalOpen) return
void loadTrainingStats()
void loadTrainingHistory()
}, [statsModalOpen, loadTrainingStats, loadTrainingHistory])
const onTrainingRunningChange = props.onTrainingRunningChange
useEffect(() => {
onTrainingRunningChange?.(trainingRunning)
}, [trainingRunning, onTrainingRunningChange])
useEffect(() => {
props.onImageExpandedChange?.(tabActive ? imageExpanded : false)
return () => {
props.onImageExpandedChange?.(false)
}
}, [tabActive, imageExpanded, props.onImageExpandedChange])
useEffect(() => {
try {
window.localStorage.setItem(
TRAINING_IMAGE_EXPANDED_STORAGE_KEY,
imageExpanded ? '1' : '0'
)
} catch {
// ignore
}
}, [imageExpanded])
useEffect(() => {
if (!trainingRunning) {
etaSmoothingRef.current = {
lastAt: 0,
lastRawEtaMs: 0,
}
setSmoothedTrainingEtaMs(0)
return
}
setTrainingNowMs(Date.now())
const timer = window.setInterval(() => {
setTrainingNowMs(Date.now())
}, 1000)
return () => window.clearInterval(timer)
}, [trainingRunning])
useEffect(() => {
if (!boxLabel) return
const currentLabels = labelsRef.current
const hasLoadedBoxLabels =
currentLabels.people.length > 0 ||
currentLabels.bodyParts.length > 0 ||
currentLabels.objects.length > 0 ||
currentLabels.clothing.length > 0
// Wichtig: Während Labels noch laden oder kurz leer sind, Auswahl nicht löschen.
if (!hasLoadedBoxLabels) return
const stillExists = [
...currentLabels.people,
...currentLabels.bodyParts,
...currentLabels.objects,
...currentLabels.clothing,
].includes(boxLabel)
if (!stillExists) {
setBoxLabel('')
}
}, [boxLabel, labels])
useEffect(() => {
const wasRunning = wasTrainingRunningRef.current
if (wasRunning && !trainingRunning && trainingStatus?.training?.finishedAt) {
void loadNext({ refreshPrediction: true, preserveNotice: true })
}
wasTrainingRunningRef.current = trainingRunning
}, [trainingRunning, trainingStatus?.training?.finishedAt, loadNext])
useEffect(() => {
if (activeBoxIndex === null) return
const scrollEl = detectorBoxesScrollRef.current
const itemEl = detectorBoxItemRefs.current[activeBoxIndex]
if (!scrollEl || !itemEl) return
itemEl.scrollIntoView({
block: 'nearest',
inline: 'nearest',
behavior: 'smooth',
})
}, [activeBoxIndex])
useEffect(() => {
const onImportVideo = (event: Event) => {
const detail = (event as CustomEvent<any>).detail
void importVideoIntoTraining(detail)
}
window.addEventListener('training:import-video', onImportVideo as EventListener)
let resumedActiveImport = false
let resumedActiveNext = false
try {
const activeRaw = window.sessionStorage.getItem(TRAINING_ACTIVE_IMPORT_VIDEO_STORAGE_KEY)
if (activeRaw) {
const active = JSON.parse(activeRaw)
const requestId = String(active?.requestId || '').trim()
const importKey = String(active?.importKey || '').trim()
if (requestId) {
if (importKey) {
videoImportInFlightKeyRef.current = importKey
}
activeAnalysisRequestIdRef.current = requestId
loadingRef.current = true
setLoading(true)
setAnalysisSourceFile(String(active?.detail?.sourceFile || active?.detail?.output || '').split(/[\\/]/).pop() || '')
setAnalysisProgress(5)
setAnalysisStep('Video-Import wird fortgesetzt…')
setError(null)
void waitForVideoImportResult(requestId)
.then(() => {
window.sessionStorage.removeItem(TRAINING_ACTIVE_IMPORT_VIDEO_STORAGE_KEY)
})
.catch((e) => {
const msg = e instanceof Error ? e.message : String(e)
const mayStillRun =
/load failed|failed to fetch|networkerror|network error/i.test(msg)
if (mayStillRun) {
setMessage('Video-Import läuft im Backend weiter und wird beim Zurückkehren wieder aufgenommen.')
} else {
window.sessionStorage.removeItem(TRAINING_ACTIVE_IMPORT_VIDEO_STORAGE_KEY)
setError(msg)
}
})
.finally(() => {
if (activeAnalysisRequestIdRef.current === requestId) {
activeAnalysisRequestIdRef.current = null
loadingRef.current = false
setLoading(false)
setAnalysisSourceFile('')
setAnalysisProgress(0)
setAnalysisStep('')
}
if (importKey && videoImportInFlightKeyRef.current === importKey) {
videoImportInFlightKeyRef.current = null
}
})
resumedActiveImport = true
}
}
const activeNextRaw = resumedActiveImport
? ''
: window.sessionStorage.getItem(TRAINING_ACTIVE_NEXT_STORAGE_KEY)
if (activeNextRaw) {
const active = JSON.parse(activeNextRaw)
const requestId = String(active?.requestId || '').trim()
const activeOpts = active?.opts || {}
if (requestId) {
activeAnalysisRequestIdRef.current = requestId
nextAnalysisInFlightRequestIdRef.current = requestId
loadingRef.current = true
setLoading(true)
setAnalysisSourceFile('')
setAnalysisProgress(5)
setAnalysisStep('Analyse wird fortgesetzt…')
setError(null)
void fetch(
`/api/training/next/status?requestId=${encodeURIComponent(requestId)}`,
{ cache: 'no-store' }
)
.then((res) => res.json().catch(() => null))
.then((data) => {
if (data?.analysis) {
applyTrainingAnalysisEvent(data.analysis)
}
})
.catch(() => {
// ignore; waitForNextResult pollt danach weiter.
})
void waitForNextResult(requestId, {
deferCurrentSampleToQueueEnd: Boolean(activeOpts?.deferCurrentSampleToQueueEnd),
})
.then(() => {
window.sessionStorage.removeItem(TRAINING_ACTIVE_NEXT_STORAGE_KEY)
})
.catch((e) => {
const msg = e instanceof Error ? e.message : String(e)
const mayStillRun =
/load failed|failed to fetch|networkerror|network error/i.test(msg)
if (mayStillRun) {
setMessage('Analyse läuft im Backend weiter und wird beim Zurückkehren wieder aufgenommen.')
} else {
window.sessionStorage.removeItem(TRAINING_ACTIVE_NEXT_STORAGE_KEY)
setError(msg)
}
})
.finally(() => {
if (activeAnalysisRequestIdRef.current === requestId) {
activeAnalysisRequestIdRef.current = null
nextAnalysisInFlightRequestIdRef.current = null
loadingRef.current = false
setLoading(false)
setAnalysisSourceFile('')
setAnalysisProgress(0)
setAnalysisStep('')
}
})
resumedActiveNext = true
}
}
const raw = resumedActiveImport
|| resumedActiveNext
? ''
: window.sessionStorage.getItem(TRAINING_PENDING_IMPORT_VIDEO_STORAGE_KEY)
if (raw) {
const detail = JSON.parse(raw)
void importVideoIntoTraining(detail)
}
} catch {
// ignore
}
return () => {
window.removeEventListener('training:import-video', onImportVideo as EventListener)
}
}, [applyTrainingAnalysisEvent, importVideoIntoTraining, waitForNextResult, waitForVideoImportResult])
useEffect(() => {
if (!tabActive || initializedRef.current) return
const runId = initRunIdRef.current + 1
initRunIdRef.current = runId
let cancelled = false
async function init() {
await loadLabels()
await loadTrainingStatus()
if (cancelled || initRunIdRef.current !== runId) return
// Wichtig:
// Wenn gerade ein Video-Import über "Video ins Training übernehmen" läuft,
// darf loadNext() nicht danach ein zufälliges/letztes Sample darüberlegen.
if (
videoImportStartedRef.current ||
videoImportInFlightKeyRef.current ||
nextAnalysisInFlightRequestIdRef.current
) {
initializedRef.current = true
return
}
await loadNext()
if (!cancelled && initRunIdRef.current === runId) {
initializedRef.current = true
}
}
void init()
return () => {
cancelled = true
}
}, [tabActive, loadLabels, loadNext, loadTrainingStatus])
useEffect(() => {
if (!tabActive || !initializedRef.current) return
void loadTrainingStatus()
const frame = window.requestAnimationFrame(() => {
updateImageLayerStyle()
})
return () => window.cancelAnimationFrame(frame)
}, [tabActive, loadTrainingStatus, updateImageLayerStyle])
useEffect(() => {
if (!tabActive) return
let cancelled = false
async function refreshActiveAnalysisStatus() {
let nextRequestId = nextAnalysisInFlightRequestIdRef.current || ''
let importRequestId = activeAnalysisRequestIdRef.current || ''
try {
const activeNextRaw = window.sessionStorage.getItem(TRAINING_ACTIVE_NEXT_STORAGE_KEY)
if (!nextRequestId && activeNextRaw) {
nextRequestId = String(JSON.parse(activeNextRaw)?.requestId || '').trim()
}
} catch {
// ignore
}
try {
const activeImportRaw = window.sessionStorage.getItem(TRAINING_ACTIVE_IMPORT_VIDEO_STORAGE_KEY)
if (activeImportRaw) {
importRequestId = String(JSON.parse(activeImportRaw)?.requestId || importRequestId || '').trim()
}
} catch {
// ignore
}
const url = nextRequestId
? `/api/training/next/status?requestId=${encodeURIComponent(nextRequestId)}`
: importRequestId
? `/api/training/import-video/status?requestId=${encodeURIComponent(importRequestId)}`
: '/api/training/analysis/status'
try {
const res = await fetch(url, { cache: 'no-store' })
const data = await res.json().catch(() => null)
if (cancelled || !res.ok || !data?.analysis) return
applyTrainingAnalysisEvent(data.analysis)
} catch {
// ignore
}
}
void refreshActiveAnalysisStatus()
return () => {
cancelled = true
}
}, [applyTrainingAnalysisEvent, tabActive])
useEffect(() => {
if (!trainingRunning) return
const onVisibilityChange = () => {
if (!document.hidden) {
void loadTrainingStatus()
}
}
document.addEventListener('visibilitychange', onVisibilityChange)
return () => {
document.removeEventListener('visibilitychange', onVisibilityChange)
}
}, [loadTrainingStatus, trainingRunning])
useEffect(() => {
if (!trainingRunning) {
const timer = window.setTimeout(() => {
setTrainingProgress(0)
setTrainingStep('')
}, 800)
return () => window.clearTimeout(timer)
}
const serverProgress = Number(trainingStatus?.training?.progress ?? 0)
const serverStep = String(trainingStatus?.training?.step ?? '')
setTrainingProgress(Number.isFinite(serverProgress) ? clampPercent(serverProgress) : 0)
setTrainingStep(serverStep || 'Training läuft…')
}, [
trainingRunning,
trainingStatus?.training?.progress,
trainingStatus?.training?.step,
])
useEffect(() => {
const job = trainingStatus?.training
const epoch = Number(job?.epoch ?? 0)
const epochs = Number(job?.epochs ?? 0)
if (!trainingRunning || epoch <= 0 || epochs <= 0) {
epochTimingRef.current = {
target: '',
firstEpochAt: 0,
lastEpoch: 0,
lastAt: 0,
}
setEstimatedEpochMs(0)
return
}
const now = Date.now()
const previous = epochTimingRef.current
const target = currentTrainingTarget || activeTrainingTarget || ''
const targetChanged = previous.target !== target
if (targetChanged) {
setEstimatedEpochMs(0)
}
const firstEpochAt =
targetChanged
? now
: previous.firstEpochAt > 0
? previous.firstEpochAt
: job?.startedAt && Number.isFinite(Date.parse(job.startedAt))
? Date.parse(job.startedAt)
: now
const safeEpoch = Math.max(1, Math.min(epochs, Math.floor(epoch)))
const elapsedSinceStartMs = Math.max(0, now - firstEpochAt)
// Direkt ab Epoche 1 eine erste Schätzung:
// bisherige Laufzeit / aktuelle Epoche.
const averageFromElapsed =
elapsedSinceStartMs > 0 && safeEpoch > 0
? elapsedSinceStartMs / safeEpoch
: 0
if (Number.isFinite(averageFromElapsed) && averageFromElapsed > 0) {
setEstimatedEpochMs((old) => {
if (!Number.isFinite(old) || old <= 0) {
return averageFromElapsed
}
const clampedMeasured = Math.max(
old * 0.60,
Math.min(old * 1.60, averageFromElapsed)
)
return old * 0.75 + clampedMeasured * 0.25
})
}
epochTimingRef.current = {
target,
firstEpochAt,
lastEpoch: safeEpoch,
lastAt: now,
}
}, [
trainingRunning,
trainingStatus?.training?.epoch,
trainingStatus?.training?.epochs,
trainingStatus?.training?.startedAt,
activeTrainingTarget,
currentTrainingTarget,
trainingNowMs,
])
const saveFeedback = useCallback(
async (
accepted: boolean,
options?: {
negative?: boolean
}
) => {
if (!sample || trainingRunning) return
setSavingOverlayText(editingFeedback ? 'Feedback wird aktualisiert…' : 'Feedback wird gespeichert…')
setSaving(true)
setError(null)
setMessage(null)
try {
const normalizedBoxes = (correction.boxes ?? [])
.map(normalizeBox)
.filter((box) => box.label && box.w > 0 && box.h > 0)
const feedbackCorrection = {
...correction,
boxes: normalizedBoxes,
}
const negative =
options?.negative ??
!correctionHasTrainablePositionOrBoxes(feedbackCorrection)
const correctionPayload: CorrectionState = negative
? {
sexPosition: NO_SEX_POSITION_LABEL,
peoplePresent: [],
bodyPartsPresent: [],
objectsPresent: [],
clothingPresent: [],
boxes: [],
}
: {
...correction,
peoplePresent: peopleLabelsFromBoxes(normalizedBoxes, labelsRef.current),
boxes: normalizedBoxes,
}
const effectiveAccepted = negative ? false : accepted
setSavingOverlayText(
negative
? editingFeedback
? 'Negativbeispiel wird aktualisiert…'
: 'Negativbeispiel wird gespeichert…'
: effectiveAccepted
? editingFeedback
? 'Feedback wird aktualisiert…'
: 'Feedback wird gespeichert…'
: editingFeedback
? 'Korrektur wird aktualisiert…'
: 'Korrektur wird gespeichert…'
)
const payload = {
sampleId: sample.sampleId,
accepted: effectiveAccepted,
negative,
correction: effectiveAccepted && correctionPayload.boxes.length === 0
? undefined
: correctionPayload,
}
const res = await fetch(
editingFeedback ? '/api/training/feedback/update' : '/api/training/feedback',
{
method: editingFeedback ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(
editingFeedback
? {
...payload,
sampleId: editingFeedback.sampleId,
answeredAt: editingFeedback.answeredAt,
}
: payload
),
}
)
const data = await res.json().catch(() => null)
if (!res.ok) {
throw new Error(backendText(data, `HTTP ${res.status}`))
}
const wasEditingFeedback = Boolean(editingFeedback)
if (wasEditingFeedback) {
setEditingFeedback(null)
setFeedbackItems((current) =>
current.map((item) =>
item.sampleId === editingFeedback?.sampleId &&
item.answeredAt === editingFeedback?.answeredAt
? {
...item,
accepted: effectiveAccepted,
negative,
correction: effectiveAccepted ? undefined : correctionPayload,
}
: item
)
)
}
setMessage(
wasEditingFeedback
? negative
? 'Negativbeispiel aktualisiert.'
: effectiveAccepted
? 'Feedback aktualisiert.'
: 'Korrektur aktualisiert.'
: negative
? 'Negativbeispiel gespeichert.'
: effectiveAccepted
? 'Feedback gespeichert.'
: 'Korrektur gespeichert.'
)
await loadTrainingStatus()
if (wasEditingFeedback) {
const returnItem = feedbackEditReturnSampleRef.current
feedbackEditReturnSampleRef.current = null
if (returnItem) {
loadQueuedTrainingSample(returnItem)
return
}
if (!loadNextImportedQueuedSample()) {
await loadNext({ preserveNotice: true })
}
return
}
if (!loadNextImportedQueuedSample()) {
await loadNext({
forceNew: true,
preserveNotice: true,
})
}
} catch (e) {
console.error('Feedback speichern fehlgeschlagen:', e)
const raw = e instanceof Error ? e.message : String(e)
const short = raw.length > 220
? `${raw.slice(0, 220).trimEnd()}`
: raw
setError(`Feedback konnte nicht gespeichert werden. ${short}`)
} finally {
setSaving(false)
setSavingOverlayText('')
}
},
[
sample,
correction,
editingFeedback,
loadNext,
loadTrainingStatus,
loadQueuedTrainingSample,
loadNextImportedQueuedSample,
trainingRunning,
]
)
const skipCurrentSample = useCallback(async () => {
if (!sample) return
if (editingFeedback) {
const returnItem = feedbackEditReturnSampleRef.current
feedbackEditReturnSampleRef.current = null
setEditingFeedback(null)
setError(null)
setMessage('Feedback-Bearbeitung abgebrochen.')
if (returnItem) {
loadQueuedTrainingSample(returnItem)
}
return
}
const skippedSampleId = sample.sampleId
setSavingOverlayText('Bild wird übersprungen…')
setSaving(true)
setError(null)
setMessage(null)
try {
const res = await fetch('/api/training/skip', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sampleId: skippedSampleId }),
})
const data = await res.json().catch(() => null)
if (!res.ok) {
throw new Error(backendText(data, `HTTP ${res.status}`))
}
sampleRef.current = null
correctionRef.current = predictionToCorrection(null)
hasManualCorrectionRef.current = false
setSample(null)
setCorrection(predictionToCorrection(null))
setHasManualCorrection(false)
setDrawingBox(null)
setBoxInteraction(null)
setTouchMagnifier(null)
setBoxLabel('')
setActiveBoxIndex(null)
if (!loadNextImportedQueuedSample()) {
await loadNext({
forceNew: true,
preserveNotice: true,
})
}
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally {
setSaving(false)
setSavingOverlayText('')
}
}, [
sample,
editingFeedback,
loadNext,
loadQueuedTrainingSample,
loadNextImportedQueuedSample,
])
const openTrainingStartModal = useCallback(() => {
const readyTargets = selectableTrainingTargets.length > 0
? selectableTrainingTargets
: ALL_TRAINING_TARGETS
setTrainingStartMode('full')
setTrainingStartTargets(readyTargets)
setTrainingStartModalOpen(true)
void loadTrainingHistory()
void loadTrainingEstimateSettings()
}, [loadTrainingEstimateSettings, loadTrainingHistory, selectableTrainingTargets])
const toggleTrainingStartTarget = useCallback((target: TrainingTargetKey) => {
setTrainingStartTargets((prev) =>
prev.includes(target)
? prev.filter((item) => item !== target)
: [...prev, target]
)
}, [])
const startTraining = useCallback(async (options?: TrainingStartOptions) => {
shownTrainingCompletionRef.current = null
setDismissedTrainingInfoKey('')
try {
window.localStorage.removeItem(TRAINING_INFO_DISMISSED_STORAGE_KEY)
} catch {
// ignore
}
const nextActiveTargets =
options?.mode === 'custom'
? (options.targets ?? []).filter(isTrainingTargetKey)
: selectableTrainingTargets.length > 0
? selectableTrainingTargets
: ALL_TRAINING_TARGETS
setActiveTrainingTargets(nextActiveTargets)
setTraining(true)
setTrainingProgress(5)
setTrainingStep('Training wird gestartet…')
setError(null)
setMessage(null)
try {
const payload =
options?.mode === 'custom'
? {
scope: 'custom',
targets: options.targets ?? [],
}
: {
scope: 'full',
}
const res = await fetch('/api/training/train', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
})
const data = await res.json().catch(() => null)
if (!res.ok) {
throw new Error(backendText(data, `HTTP ${res.status}`))
}
await loadTrainingStatus()
// WICHTIG:
// Hier NICHT direkt loadNext() aufrufen.
// Das Training läuft im Backend asynchron weiter.
} catch (e) {
setActiveTrainingTargets([])
setTraining(false)
setTrainingProgress(0)
setTrainingStep('')
setError(e instanceof Error ? e.message : String(e))
}
}, [loadTrainingStatus, selectableTrainingTargets])
const startTrainingFromModal = useCallback(() => {
if (!canConfirmTrainingStart) return
const options: TrainingStartOptions =
trainingStartMode === 'custom'
? {
mode: 'custom',
targets: selectedTrainingTargets,
}
: {
mode: 'full',
}
setTrainingStartModalOpen(false)
void startTraining(options)
}, [
canConfirmTrainingStart,
selectedTrainingTargets,
startTraining,
trainingStartMode,
])
const cancelTraining = useCallback(async () => {
const confirmed = window.confirm(
'Training wirklich abbrechen? Temporäre Trainingsausgaben dieses Laufs werden gelöscht. Deine Feedbacks und Labels bleiben erhalten.'
)
if (!confirmed) return
setCancellingTraining(true)
setError(null)
setMessage(null)
setTrainingStep('Training wird abgebrochen…')
try {
const res = await fetch('/api/training/cancel', {
method: 'POST',
})
const data = await res.json().catch(() => null)
if (!res.ok) {
throw new Error(backendText(data, `HTTP ${res.status}`))
}
await loadTrainingStatus()
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally {
setCancellingTraining(false)
}
}, [loadTrainingStatus])
const deleteAllTrainingData = useCallback(async () => {
const confirmed = window.confirm(
'Wirklich alle Trainingsdaten löschen? Das entfernt Feedback, Frames, Samples und Detector-Daten. Diese Aktion kann nicht rückgängig gemacht werden.'
)
if (!confirmed) return
setDeletingTrainingData(true)
setError(null)
setMessage(null)
try {
const res = await fetch('/api/training/delete-all', {
method: 'DELETE',
})
const data = await res.json().catch(() => null)
if (!res.ok) {
throw new Error(data?.error || `HTTP ${res.status}`)
}
sampleRef.current = null
correctionRef.current = predictionToCorrection(null)
hasManualCorrectionRef.current = false
setSample(null)
setCorrection(predictionToCorrection(null))
setTrainingStatus({
feedbackCount: 0,
requiredCount,
canTrain: false,
})
setMessage(backendText(data, 'Alle Trainingsdaten wurden gelöscht.'))
await loadTrainingStatus()
await loadNext({ forceNew: true, preserveNotice: true })
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally {
setDeletingTrainingData(false)
}
}, [loadNext, loadTrainingStatus, requiredCount])
const getPointerPosFromRect = useCallback((
rect: ImageContentRect,
clientX: number,
clientY: number,
opts?: { clamp?: boolean }
) => {
const x = (clientX - rect.left) / rect.width
const y = (clientY - rect.top) / rect.height
if (opts?.clamp === false) {
return { x, y }
}
return {
x: clamp01(x),
y: clamp01(y),
}
}, [])
const getPointerPosInImage = useCallback((
clientX: number,
clientY: number,
opts?: { clamp?: boolean }
) => {
const rect = activeImageContentRectRef.current ?? getImageContentRect()
if (!rect) return null
return getPointerPosFromRect(rect, clientX, clientY, opts)
}, [getImageContentRect, getPointerPosFromRect])
const releaseActivePointerCapture = useCallback((pointerId?: number | null) => {
const id = typeof pointerId === 'number'
? pointerId
: activePointerIdRef.current
if (typeof id !== 'number') {
activePointerIdRef.current = null
return
}
try {
imageBoxRef.current?.releasePointerCapture(id)
} catch {
// Pointer-Capture war evtl. schon weg.
}
activePointerIdRef.current = null
}, [])
const markManualCorrection = useCallback(() => {
if (hasManualCorrectionRef.current) return
hasManualCorrectionRef.current = true
setHasManualCorrection(true)
}, [])
const cancelPointerMoveFrame = useCallback(() => {
if (pointerMoveRafRef.current !== null) {
window.cancelAnimationFrame(pointerMoveRafRef.current)
pointerMoveRafRef.current = null
}
pendingPointerMoveRef.current = null
}, [])
const clearBoxGestureRefs = useCallback(() => {
cancelPointerMoveFrame()
activeImageContentRectRef.current = null
drawingBoxRef.current = null
boxInteractionRef.current = null
latestGestureBoxRef.current = null
}, [cancelPointerMoveFrame])
const applyPointerMoveSnapshot = useCallback((snapshot: {
clientX: number
clientY: number
}) => {
const drawing = drawingBoxRef.current
const interaction = boxInteractionRef.current
if (!drawing && !interaction) return
const clampedPos = getPointerPosInImage(snapshot.clientX, snapshot.clientY)
if (!clampedPos) return
const pos =
interaction?.type === 'move'
? getPointerPosInImage(snapshot.clientX, snapshot.clientY, { clamp: false }) ?? clampedPos
: clampedPos
const nextMagnifier: MagnifierState = {
visible: true,
clientX: snapshot.clientX,
clientY: snapshot.clientY,
imageX: clampedPos.x,
imageY: clampedPos.y,
}
setTouchMagnifier((prev) => {
if (
prev?.visible &&
prev.clientX === nextMagnifier.clientX &&
prev.clientY === nextMagnifier.clientY &&
Math.abs(prev.imageX - nextMagnifier.imageX) <= 0.0005 &&
Math.abs(prev.imageY - nextMagnifier.imageY) <= 0.0005
) {
return prev
}
return nextMagnifier
})
if (interaction) {
const dx = pos.x - interaction.startX
const dy = pos.y - interaction.startY
const original = interaction.original
let nextBox: TrainingBox = original
if (interaction.type === 'move') {
nextBox = normalizeMovedBox({
...original,
x: original.x + dx,
y: original.y + dy,
})
}
if (interaction.type === 'resize') {
let x1 = original.x
let y1 = original.y
let x2 = original.x + original.w
let y2 = original.y + original.h
const pointerX = snap01(clampedPos.x)
const pointerY = snap01(clampedPos.y)
if (interaction.handle.includes('n')) y1 = pointerY
if (interaction.handle.includes('s')) y2 = pointerY
if (interaction.handle.includes('w')) x1 = pointerX
if (interaction.handle.includes('e')) x2 = pointerX
const left = Math.min(x1, x2)
const top = Math.min(y1, y2)
const right = Math.max(x1, x2)
const bottom = Math.max(y1, y2)
nextBox = normalizeBox({
...original,
x: left,
y: top,
w: right - left,
h: bottom - top,
})
}
const geometryChanged = boxGeometryChanged(original, nextBox)
const correctedNextBox = geometryChanged
? markBoxCorrected(nextBox)
: nextBox
latestGestureBoxRef.current = correctedNextBox
if (geometryChanged) {
markManualCorrection()
}
setCorrection((prev) => {
const boxes = prev.boxes ?? []
const currentBox = boxes[interaction.index]
if (!currentBox || !boxVisualStateChanged(currentBox, correctedNextBox)) {
return prev
}
const next: CorrectionState = {
...prev,
boxes: boxes.map((box, index) =>
index === interaction.index ? correctedNextBox : box
),
}
correctionRef.current = next
return next
})
return
}
if (!drawing) return
const x1 = drawing.startX
const y1 = drawing.startY
const x2 = pos.x
const y2 = pos.y
const nextDrawing: DrawingTrainingBox = {
...drawing,
x: Math.min(x1, x2),
y: Math.min(y1, y2),
w: Math.abs(x2 - x1),
h: Math.abs(y2 - y1),
}
latestGestureBoxRef.current = nextDrawing
drawingBoxRef.current = nextDrawing
setDrawingBox((prev) => {
if (prev && !boxVisualStateChanged(prev, nextDrawing)) {
return prev
}
return nextDrawing
})
}, [getPointerPosInImage, markManualCorrection])
const schedulePointerMove = useCallback((snapshot: {
clientX: number
clientY: number
}) => {
pendingPointerMoveRef.current = snapshot
if (pointerMoveRafRef.current !== null) return
pointerMoveRafRef.current = window.requestAnimationFrame(() => {
pointerMoveRafRef.current = null
const pending = pendingPointerMoveRef.current
pendingPointerMoveRef.current = null
if (pending) {
applyPointerMoveSnapshot(pending)
}
})
}, [applyPointerMoveSnapshot])
const flushPendingPointerMove = useCallback(() => {
const pending = pendingPointerMoveRef.current
pendingPointerMoveRef.current = null
if (pointerMoveRafRef.current !== null) {
window.cancelAnimationFrame(pointerMoveRafRef.current)
pointerMoveRafRef.current = null
}
if (pending) {
applyPointerMoveSnapshot(pending)
}
}, [applyPointerMoveSnapshot])
useEffect(() => {
return () => cancelPointerMoveFrame()
}, [cancelPointerMoveFrame])
const startDrawBox = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
if (!boxLabel) return
if (uiLocked) return
if (drawingBoxRef.current || boxInteractionRef.current) return
const target = e.target as HTMLElement | null
if (target?.closest('[data-box-control="true"]')) return
const contentRect = getImageContentRect()
if (!contentRect) return
const rawPos = getPointerPosFromRect(contentRect, e.clientX, e.clientY, { clamp: false })
// Nicht im schwarzen Randbereich starten.
if (
rawPos.x < 0 ||
rawPos.x > 1 ||
rawPos.y < 0 ||
rawPos.y > 1
) {
return
}
activeImageContentRectRef.current = contentRect
const pos = {
x: clamp01(rawPos.x),
y: clamp01(rawPos.y),
}
e.preventDefault()
e.stopPropagation()
window.getSelection()?.removeAllRanges()
finishingGestureRef.current = false
activePointerIdRef.current = e.pointerId
try {
e.currentTarget.setPointerCapture(e.pointerId)
} catch {
activePointerIdRef.current = null
}
const nextMagnifier: MagnifierState = {
visible: true,
clientX: e.clientX,
clientY: e.clientY,
imageX: pos.x,
imageY: pos.y,
}
const nextDrawingBox: DrawingTrainingBox = {
label: boxLabel,
startX: pos.x,
startY: pos.y,
x: pos.x,
y: pos.y,
w: 0,
h: 0,
}
latestGestureBoxRef.current = nextDrawingBox
drawingBoxRef.current = nextDrawingBox
setTouchMagnifier(nextMagnifier)
setDrawingBox(nextDrawingBox)
}, [boxLabel, getImageContentRect, getPointerPosFromRect, uiLocked])
const moveDrawBox = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
const hasActiveGesture = Boolean(drawingBoxRef.current || boxInteractionRef.current)
if (hasActiveGesture) {
e.preventDefault()
e.stopPropagation()
schedulePointerMove({
clientX: e.clientX,
clientY: e.clientY,
})
}
}, [schedulePointerMove])
const finishDrawBox = useCallback((e?: React.PointerEvent<HTMLDivElement> | PointerEvent) => {
flushPendingPointerMove()
const activeDrawingBox = drawingBoxRef.current
const activeInteraction = boxInteractionRef.current
releaseActivePointerCapture(
typeof e?.pointerId === 'number' ? e.pointerId : null
)
setTouchMagnifier(null)
if (finishingGestureRef.current) {
setDrawingBox(null)
setBoxInteraction(null)
clearBoxGestureRefs()
return
}
finishingGestureRef.current = true
if (activeDrawingBox || activeInteraction) {
e?.preventDefault()
e?.stopPropagation()
}
if (activeInteraction) {
const finalBox = latestGestureBoxRef.current
if (finalBox) {
setCorrection((prev) => {
const boxes = prev.boxes ?? []
const currentBox = boxes[activeInteraction.index]
if (!currentBox || !boxVisualStateChanged(currentBox, finalBox)) {
return prev
}
const next: CorrectionState = {
...prev,
boxes: boxes.map((box, index) =>
index === activeInteraction.index ? finalBox : box
),
}
correctionRef.current = next
return next
})
}
setBoxInteraction(null)
clearBoxGestureRefs()
return
}
if (!activeDrawingBox) {
setDrawingBox(null)
clearBoxGestureRefs()
return
}
const box = normalizeBox(latestGestureBoxRef.current ?? activeDrawingBox)
setDrawingBox(null)
if (box.w < 0.01 || box.h < 0.01) {
clearBoxGestureRefs()
return
}
markManualCorrection()
setCorrection((prev) => {
const previousBoxes = prev.boxes ?? []
const newBoxIndex = previousBoxes.length
const next: CorrectionState = {
...prev,
boxes: [...previousBoxes, box],
}
setActiveBoxIndex(newBoxIndex)
const applied = applyBoxLabelToCorrection(next, box.label, labelsRef.current)
correctionRef.current = applied
return applied
})
clearBoxGestureRefs()
}, [
clearBoxGestureRefs,
flushPendingPointerMove,
markManualCorrection,
releaseActivePointerCapture,
])
useEffect(() => {
if (!drawingBox && !boxInteraction) return
const finishPointer = (event: PointerEvent) => {
finishDrawBox(event)
}
const finishWithoutPointer = () => {
finishDrawBox()
}
const finishOnHidden = () => {
if (document.hidden) {
finishDrawBox()
}
}
window.addEventListener('pointerup', finishPointer, true)
window.addEventListener('pointercancel', finishPointer, true)
window.addEventListener('blur', finishWithoutPointer)
document.addEventListener('visibilitychange', finishOnHidden)
return () => {
window.removeEventListener('pointerup', finishPointer, true)
window.removeEventListener('pointercancel', finishPointer, true)
window.removeEventListener('blur', finishWithoutPointer)
document.removeEventListener('visibilitychange', finishOnHidden)
}
}, [drawingBox, boxInteraction, finishDrawBox])
const removeBox = useCallback((index: number) => {
setHasManualCorrection(true)
let removedLabel = ''
let shouldClearBoxLabel = false
setCorrection((prev) => {
const removed = prev.boxes?.[index]
removedLabel = String(removed?.label || '').trim()
const next = removeBoxFromCorrection(prev, index, labelsRef.current)
if (removedLabel) {
shouldClearBoxLabel = !next.boxes.some(
(box) => String(box.label || '').trim() === removedLabel
)
}
return next
})
if (removedLabel && shouldClearBoxLabel) {
setBoxLabel((current) => current === removedLabel ? '' : current)
}
setActiveBoxIndex((current) => {
if (current === null) return null
if (current === index) return null
if (current > index) return current - 1
return current
})
}, [])
const changeBoxLabel = useCallback((index: number, nextLabel: string) => {
const currentLabel = String(correction.boxes?.[index]?.label || '').trim()
const cleanNextLabel = String(nextLabel || '').trim()
if (currentLabel !== cleanNextLabel) {
setHasManualCorrection(true)
}
if (cleanNextLabel) {
setBoxLabel(cleanNextLabel)
}
setCorrection((prev) =>
changeBoxLabelInCorrection(prev, index, nextLabel, labelsRef.current)
)
}, [correction.boxes])
const clearBoxes = useCallback(() => {
setHasManualCorrection(true)
setBoxLabel('')
setActiveBoxIndex(null)
setCorrection((prev) => ({
...prev,
peoplePresent: [],
bodyPartsPresent: [],
objectsPresent: [],
clothingPresent: [],
boxes: [],
}))
setExpandedCorrectionSections({
sexPosition: false,
people: false,
bodyParts: false,
objects: false,
clothing: false,
})
}, [])
const frameBusy = loading || (!!imageSrc && !frameImageLoaded)
useEffect(() => {
if (loading || frameBusy) return
if (!loadingPreviewUrl && !loadingPreviewFallbackUrl) return
setLoadingPreviewUrl('')
setLoadingPreviewFallbackUrl('')
setLoadingPreviewLoaded(false)
setLoadingPreviewFailed(false)
}, [loading, frameBusy, loadingPreviewFallbackUrl, loadingPreviewUrl])
const showImageBoxes = !frameBusy && !trainingRunning
const shownTrainingDurationMs = useMemo(() => {
const job = trainingStatus?.training
if (!job) return 0
if (job.running && job.startedAt) {
const started = Date.parse(job.startedAt)
if (Number.isFinite(started)) {
return Math.max(0, trainingNowMs - started)
}
}
return trainingDurationMs(job)
}, [trainingStatus?.training, trainingNowMs])
const rawTrainingEtaMs = useMemo(() => {
if (!trainingRunning) return 0
const job = trainingStatus?.training
const epoch = Number(job?.epoch ?? 0)
const epochs = Number(job?.epochs ?? 0)
const hasEpochInfo =
Number.isFinite(epoch) &&
Number.isFinite(epochs) &&
epoch > 0 &&
epochs > 0
const completedEpochs = hasEpochInfo
? Math.max(0, Math.min(epochs, Math.floor(epoch)))
: 0
const remainingEpochs =
Number.isFinite(epochs) && epochs > 0
? Math.max(0, epochs - completedEpochs)
: 0
const epochEtaMs =
Number.isFinite(estimatedEpochMs) && estimatedEpochMs > 0
? remainingEpochs * estimatedEpochMs
: 0
if (!currentTrainingTarget || effectiveTrainingTargets.length === 0) {
return Math.max(0, epochEtaMs)
}
const currentIndex = effectiveTrainingTargets.indexOf(currentTrainingTarget)
if (currentIndex < 0) {
return Math.max(0, epochEtaMs)
}
const currentEstimateMs = Math.max(
0,
trainingEstimateByTarget[currentTrainingTarget] || 0
)
const stageRemainingMs = currentEstimateMs > 0
? currentEstimateMs * (1 - currentTrainingTargetProgress)
: 0
const currentRemainingMs = combineTrainingEtaMs(stageRemainingMs, epochEtaMs)
const laterTargetsMs = effectiveTrainingTargets
.slice(currentIndex + 1)
.reduce((sum, target) => sum + Math.max(0, trainingEstimateByTarget[target] || 0), 0)
return Math.max(0, currentRemainingMs + laterTargetsMs)
}, [
currentTrainingTarget,
currentTrainingTargetProgress,
effectiveTrainingTargets,
trainingRunning,
trainingStatus?.training?.epoch,
trainingStatus?.training?.epochs,
estimatedEpochMs,
trainingEstimateByTarget,
])
useEffect(() => {
if (!trainingRunning || rawTrainingEtaMs <= 0) {
etaSmoothingRef.current = {
lastAt: 0,
lastRawEtaMs: 0,
}
setSmoothedTrainingEtaMs(0)
return
}
setSmoothedTrainingEtaMs((previous) => {
const state = etaSmoothingRef.current
const lastAt = state.lastAt || trainingNowMs
const elapsed = Math.max(0, trainingNowMs - lastAt)
// 1) Immer erstmal echte Anzeige runterzählen.
const countedDown =
previous > 0
? Math.max(0, previous - elapsed)
: rawTrainingEtaMs
const rawChanged =
state.lastRawEtaMs <= 0 ||
Math.abs(rawTrainingEtaMs - state.lastRawEtaMs) > 1000
let next = countedDown
if (rawChanged) {
const diff = rawTrainingEtaMs - countedDown
// Neue Backend-Schätzung einblenden, aber nicht hart springen.
if (diff > 0) {
// Restzeit wurde höher neu berechnet: langsam nach oben.
next = countedDown + Math.min(diff * 0.20, 15_000)
} else {
// Restzeit wurde niedriger neu berechnet: schneller nach unten.
next = countedDown + diff * 0.45
}
}
next = Math.max(0, next)
etaSmoothingRef.current = {
lastAt: trainingNowMs,
lastRawEtaMs: rawTrainingEtaMs,
}
return next
})
}, [trainingRunning, rawTrainingEtaMs, trainingNowMs])
const shownTrainingEtaMs = smoothedTrainingEtaMs
const shownTrainingEpochText = useMemo(() => {
const epoch = Number(trainingStatus?.training?.epoch ?? 0)
const epochs = Number(trainingStatus?.training?.epochs ?? 0)
if (!Number.isFinite(epoch) || !Number.isFinite(epochs)) return ''
if (epoch <= 0 || epochs <= 0) return ''
return `Epoche ${epoch}/${epochs}`
}, [
trainingStatus?.training?.epoch,
trainingStatus?.training?.epochs,
])
useEffect(() => {
const toastImage =
imageSrc
? {
imageUrl: imageSrc,
imageAlt: sample?.sourceFile || 'Training Frame',
}
: undefined
if (error) {
notify.error('Aktion fehlgeschlagen', error, toastImage)
setError(null)
return
}
if (!message) return
const lowerMessage = message.toLowerCase()
const looksPartial =
lowerMessage.includes('übersprungen') ||
lowerMessage.includes('fehlgeschlagen') ||
lowerMessage.includes('abgebrochen')
if (looksPartial) {
notify.warning('Training teilweise abgeschlossen', message, toastImage)
} else {
notify.success('Erfolg', message, toastImage)
}
setMessage(null)
}, [error, message, notify, imageSrc, sample?.sourceFile])
const trainingActionsPanel = (opts?: { compact?: boolean }) => {
const compact = Boolean(opts?.compact)
const feedbackReady = feedbackCount >= requiredCount
const detector = trainingStatus?.detector
const detectorReady = Boolean(detector?.dataReady)
const pose = trainingStatus?.pose
const poseReady = Boolean(pose?.dataReady)
const videoMAE = trainingStatus?.videoMAE
const videoMAEReady = Boolean(videoMAE?.dataReady)
const missingTrain = Math.max(
0,
Number(detector?.requiredTrain ?? 20) - Number(detector?.trainCount ?? 0)
)
const missingVal = Math.max(
0,
Number(detector?.requiredVal ?? 3) - Number(detector?.valCount ?? 0)
)
const missingPositiveTrain = Number(detector?.positiveTrainCount ?? 0) < 1
const missingPositiveVal = Number(detector?.positiveValCount ?? 0) < 1
const positiveSamplesMissing = missingPositiveTrain || missingPositiveVal
const missingPoseTrain = Math.max(
0,
Number(pose?.requiredTrain ?? 20) - Number(pose?.trainCount ?? 0)
)
const missingPoseVal = Math.max(
0,
Number(pose?.requiredVal ?? 3) - Number(pose?.valCount ?? 0)
)
const boundedCount = (value: number, required: number) => {
const cleanValue = Number.isFinite(value) ? Math.max(0, value) : 0
const cleanRequired = Number.isFinite(required) ? Math.max(0, required) : 0
return Math.min(cleanValue, cleanRequired)
}
const detectorTrainCount = Number(detector?.trainCount ?? 0)
const detectorValCount = Number(detector?.valCount ?? 0)
const detectorRequiredTrain = Number(detector?.requiredTrain ?? 20)
const detectorRequiredVal = Number(detector?.requiredVal ?? 3)
const detectorPositiveDone =
(Number(detector?.positiveTrainCount ?? 0) >= 1 ? 1 : 0) +
(Number(detector?.positiveValCount ?? 0) >= 1 ? 1 : 0)
const detectorPositiveRequired = 2
const detectorDone =
boundedCount(detectorTrainCount, detectorRequiredTrain) +
boundedCount(detectorValCount, detectorRequiredVal) +
detectorPositiveDone
const detectorRequired =
Math.max(0, detectorRequiredTrain) +
Math.max(0, detectorRequiredVal) +
detectorPositiveRequired
const poseTrainCount = Number(pose?.trainCount ?? 0)
const poseValCount = Number(pose?.valCount ?? 0)
const poseRequiredTrain = Number(pose?.requiredTrain ?? 20)
const poseRequiredVal = Number(pose?.requiredVal ?? 3)
const poseDone =
boundedCount(poseTrainCount, poseRequiredTrain) +
boundedCount(poseValCount, poseRequiredVal)
const poseRequired =
Math.max(0, poseRequiredTrain) +
Math.max(0, poseRequiredVal)
const videoMAETrainCount = Number(videoMAE?.trainCount ?? 0)
const videoMAEValCount = Number(videoMAE?.valCount ?? 0)
const videoMAEEligibleCount = Number(videoMAE?.eligibleCount ?? 0)
const videoMAERequiredTrain = Number(videoMAE?.requiredTrain ?? 40)
const videoMAERequiredVal = Number(videoMAE?.requiredVal ?? 5)
const videoMAEDone =
boundedCount(videoMAETrainCount, videoMAERequiredTrain) +
boundedCount(videoMAEValCount, videoMAERequiredVal)
const videoMAERequired =
Math.max(0, videoMAERequiredTrain) +
Math.max(0, videoMAERequiredVal)
const feedbackDone = boundedCount(feedbackCount, requiredCount)
const feedbackRequired = Math.max(0, requiredCount)
const readinessDone = feedbackDone + detectorDone + poseDone + videoMAEDone
const readinessRequired = feedbackRequired + detectorRequired + poseRequired + videoMAERequired
const readinessProgress = readinessRequired > 0 ? readinessDone / readinessRequired : 0
const readinessItems = [
{
key: 'detector',
label: 'Object Detection',
value: detectorRequired > 0 ? detectorDone / detectorRequired : 1,
text: `${detectorDone}/${detectorRequired}`,
detail: `Train ${detectorTrainCount}/${detectorRequiredTrain}, Val ${detectorValCount}/${detectorRequiredVal}, Positiv ${detectorPositiveDone}/${detectorPositiveRequired}`,
},
{
key: 'pose',
label: 'Pose Detection',
value: poseRequired > 0 ? poseDone / poseRequired : 1,
text: `${poseDone}/${poseRequired}`,
detail: `Train ${poseTrainCount}/${poseRequiredTrain}, Val ${poseValCount}/${poseRequiredVal}`,
},
{
key: 'videomae',
label: 'VideoMAE',
value: videoMAERequired > 0 ? videoMAEDone / videoMAERequired : 1,
text: `${videoMAEDone}/${videoMAERequired}`,
detail: `Eligible ${videoMAEEligibleCount}, Train ${videoMAETrainCount}/${videoMAERequiredTrain}, Val ${videoMAEValCount}/${videoMAERequiredVal}`,
},
]
const progress = clampPercent(
trainingRunning
? shownTrainingProgress
: readinessProgress * 100
)
const statusText = trainingRunning
? 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 (
<div
className={[
'relative z-0 overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm dark:border-white/10 dark:bg-gray-900/70',
compact ? 'p-2' : 'p-3',
].join(' ')}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<div
className={[
'flex h-8 w-8 shrink-0 items-center justify-center rounded-lg ring-1',
trainingRunning
? 'bg-indigo-50 text-indigo-700 ring-indigo-200 dark:bg-indigo-500/15 dark:text-indigo-200 dark:ring-indigo-400/30'
: canStartTraining
? 'bg-emerald-50 text-emerald-700 ring-emerald-200 dark:bg-emerald-500/15 dark:text-emerald-200 dark:ring-emerald-400/30'
: 'bg-gray-50 text-gray-600 ring-gray-200 dark:bg-white/5 dark:text-gray-300 dark:ring-white/10',
].join(' ')}
>
{trainingRunning ? (
<ArrowPathIcon className="h-4 w-4 animate-spin" aria-hidden="true" />
) : (
<BoltIcon className="h-4 w-4" aria-hidden="true" />
)}
</div>
<div className="min-w-0">
<div className="text-xs font-semibold text-gray-900 dark:text-white">
Training-Aktionen
</div>
<div className="mt-0.5 truncate text-[11px] text-gray-500 dark:text-gray-400">
{statusText}
</div>
</div>
</div>
</div>
<button
type="button"
onClick={() => setStatsModalOpen(true)}
className="shrink-0 rounded-full bg-gray-50 px-2 py-1 text-[11px] font-bold text-gray-700 ring-1 ring-gray-200 transition hover:bg-indigo-50 hover:text-indigo-700 hover:ring-indigo-200 dark:bg-white/5 dark:text-gray-200 dark:ring-white/10 dark:hover:bg-indigo-500/20 dark:hover:text-indigo-100 dark:hover:ring-indigo-300/30"
title="Training-Datenstatistiken anzeigen"
aria-label="Training-Datenstatistiken anzeigen"
>
{feedbackBadgeText}
</button>
</div>
<div className="mt-3 rounded-xl bg-gray-50 p-2 ring-1 ring-black/5 dark:bg-white/[0.04] dark:ring-white/10">
<button
type="button"
disabled={uiLocked}
onClick={() => {
const nextMode: TrainingSampleMode =
trainingSampleMode === 'uncertain' ? 'random' : 'uncertain'
setTrainingSampleMode(nextMode)
if (!uiLocked && nextMode === 'uncertain') {
void loadNext({
forceNew: true,
mode: nextMode,
deferCurrentSampleToQueueEnd: Boolean(sampleRef.current),
})
} else if (!uiLocked && !sampleRef.current) {
if (!loadNextImportedQueuedSample()) {
void loadNext({
forceNew: true,
mode: nextMode,
})
}
}
}}
className={[
'flex w-full items-center justify-between gap-3 rounded-lg px-2 py-1.5 text-left transition',
'focus:outline-none focus:ring-2 focus:ring-indigo-500/40',
uiLocked
? 'cursor-not-allowed opacity-50'
: 'hover:bg-white dark:hover:bg-white/10',
].join(' ')}
title="Wenn aktiv, werden bevorzugt Frames mit niedriger oder mittlerer Modell-Confidence geladen."
aria-pressed={trainingSampleMode === 'uncertain'}
>
<span className="min-w-0">
<span className="block text-[11px] font-bold text-gray-900 dark:text-white">
Unsichere zuerst
</span>
<span className="mt-0.5 block text-[10px] leading-snug text-gray-500 dark:text-gray-400">
Lädt bevorzugt Grenzfälle, die dem Modell am meisten helfen.
</span>
</span>
<span
className={[
'relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition',
trainingSampleMode === 'uncertain'
? 'bg-indigo-600'
: 'bg-gray-300 dark:bg-white/20',
].join(' ')}
aria-hidden="true"
>
<span
className={[
'inline-block h-4 w-4 rounded-full bg-white shadow transition',
trainingSampleMode === 'uncertain'
? 'translate-x-4'
: 'translate-x-0.5',
].join(' ')}
/>
</span>
</button>
</div>
{trainingRunning || !canStartTraining ? (
<div className="mt-3">
<div className="mb-1 flex items-center justify-between text-[10px] font-medium text-gray-500 dark:text-gray-400">
<span>{trainingRunning ? 'Trainingsfortschritt' : 'Trainingsbereitschaft'}</span>
<span>{Math.round(progress)}%</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-gray-100 ring-1 ring-black/5 dark:bg-white/10 dark:ring-white/10">
<div
className={[
'h-full rounded-full transition-all duration-500',
trainingRunning
? 'bg-indigo-500'
: canStartTraining
? 'bg-emerald-500'
: readinessDone > 0
? 'bg-amber-500'
: 'bg-gray-400',
].join(' ')}
style={{ width: `${progress}%` }}
/>
</div>
{!trainingRunning ? (
<div className="mt-2 grid gap-1.5">
{readinessItems.map((item) => {
const itemProgress = clampPercent(item.value * 100)
return (
<div key={item.key} className="min-w-0">
<div className="flex items-center justify-between gap-2 text-[10px] leading-tight">
<div className="min-w-0 truncate font-semibold text-gray-700 dark:text-gray-200">
{item.label}
</div>
<div className="shrink-0 font-semibold tabular-nums text-gray-500 dark:text-gray-400">
{item.text}
</div>
</div>
<div className="mt-0.5 truncate text-[10px] text-gray-500 dark:text-gray-400">
{item.detail}
</div>
<div className="mt-1 h-1 overflow-hidden rounded-full bg-gray-100 dark:bg-white/10">
<div
className={[
'h-full rounded-full transition-all duration-500',
item.key === 'detector'
? 'bg-sky-500'
: item.key === 'pose'
? 'bg-fuchsia-500'
: 'bg-emerald-500',
].join(' ')}
style={{ width: `${itemProgress}%` }}
/>
</div>
</div>
)
})}
</div>
) : null}
</div>
) : null}
{showTrainingInfo ? (
<div
className={[
'mt-3 rounded-xl p-3 ring-1',
trainingInfoLooksPartial
? 'bg-amber-50 text-amber-950 ring-amber-200 dark:bg-amber-500/10 dark:text-amber-100 dark:ring-amber-400/30'
: 'bg-emerald-50 text-emerald-950 ring-emerald-200 dark:bg-emerald-500/10 dark:text-emerald-100 dark:ring-emerald-400/30',
].join(' ')}
>
<div className="flex items-start gap-3">
<div
className={[
'mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg ring-1',
trainingInfoLooksPartial
? 'bg-amber-100 text-amber-700 ring-amber-200 dark:bg-amber-500/15 dark:text-amber-200 dark:ring-amber-400/30'
: 'bg-emerald-100 text-emerald-700 ring-emerald-200 dark:bg-emerald-500/15 dark:text-emerald-200 dark:ring-emerald-400/30',
].join(' ')}
>
{trainingInfoLooksPartial ? (
<XCircleIcon className="h-4 w-4" aria-hidden="true" />
) : (
<CheckIcon className="h-4 w-4" aria-hidden="true" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="text-xs font-bold">
Training-Info
</div>
<div className="mt-1 text-[11px] leading-snug opacity-90">
{trainingInfoMessage}
</div>
</div>
<button
type="button"
onClick={dismissTrainingInfo}
className="shrink-0 rounded-md px-1.5 py-0.5 text-xs font-bold opacity-60 transition hover:bg-black/5 hover:opacity-100 dark:hover:bg-white/10"
aria-label="Training-Info ausblenden"
title="Bis zum nächsten Training ausblenden"
>
×
</button>
</div>
<div className="mt-3 grid grid-cols-2 gap-2 text-[10px]">
<div
className="rounded-lg bg-white/70 px-2 py-1.5 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10"
title={`${trainingStatus?.detector?.positiveTrainCount ?? 0} positive Trainingsbeispiele`}
>
<div className="font-semibold opacity-60">
Dauer
</div>
<div className="mt-0.5 font-bold">
{trainingInfoDurationMs > 0
? formatDuration(trainingInfoDurationMs)
: '—'}
</div>
</div>
<div
className="rounded-lg bg-white/70 px-2 py-1.5 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10"
title={`${trainingStatus?.detector?.positiveValCount ?? 0} positive Validierungsbeispiele`}
>
<div className="font-semibold opacity-60">
Feedback
</div>
<div className="mt-0.5 font-bold">
{feedbackCount}/{requiredCount}
</div>
</div>
<div className="rounded-lg bg-white/70 px-2 py-1.5 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
<div className="font-semibold opacity-60">
YOLO Train
</div>
<div className="mt-0.5 font-bold">
{trainingStatus?.detector?.trainCount ?? 0}/{trainingStatus?.detector?.requiredTrain ?? 20}
</div>
</div>
<div className="rounded-lg bg-white/70 px-2 py-1.5 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
<div className="font-semibold opacity-60">
YOLO Val
</div>
<div className="mt-0.5 font-bold">
{trainingStatus?.detector?.valCount ?? 0}/{trainingStatus?.detector?.requiredVal ?? 3}
</div>
</div>
</div>
</div>
</div>
</div>
) : null}
<div className="mt-3 grid grid-cols-2 gap-2">
<Button
size="md"
variant="secondary"
className="w-full justify-center px-2 text-[11px]"
disabled={uiLocked || !sample}
onClick={() => void reloadCurrentImage()}
title="Lädt das aktuelle Bild erneut und führt die Analyse neu aus."
>
<span className="inline-flex items-center gap-1.5">
<ArrowPathIcon className="h-3.5 w-3.5" aria-hidden="true" />
Neuladen
</span>
</Button>
<Button
size="md"
variant={trainingRunning ? 'primary' : canStartTraining ? 'primary' : 'soft'}
color={trainingRunning ? 'red' : undefined}
className="w-full justify-center px-2 text-[11px]"
disabled={
trainingRunning
? cancellingTraining || deletingTrainingData
: uiLocked || !canStartTraining
}
onClick={() => {
if (trainingRunning) {
void cancelTraining()
return
}
openTrainingStartModal()
}}
title={
trainingRunning
? 'Training abbrechen und temporäre Trainingsausgaben löschen.'
: canStartTraining
? 'Trainingsumfang auswählen und Training starten.'
: !feedbackReady
? `Noch zu wenig Feedback: ${feedbackCount}/${requiredCount}.`
: !detectorReady
? `YOLO-Datensatz noch nicht bereit: Train ${detector?.trainCount ?? 0}/${detector?.requiredTrain ?? 20} (${detector?.positiveTrainCount ?? 0} positiv), Val ${detector?.valCount ?? 0}/${detector?.requiredVal ?? 3} (${detector?.positiveValCount ?? 0} positiv).`
: !poseReady
? `Pose-Datensatz noch nicht bereit: Train ${pose?.trainCount ?? 0}/${pose?.requiredTrain ?? 20}, Val ${pose?.valCount ?? 0}/${pose?.requiredVal ?? 3}.`
: 'Training ist aktuell nicht verfügbar.'
}
>
<span className="inline-flex items-center gap-1.5">
{trainingRunning ? (
<XCircleIcon className="h-3.5 w-3.5" aria-hidden="true" />
) : (
<BoltIcon className="h-3.5 w-3.5" aria-hidden="true" />
)}
{trainingRunning
? cancellingTraining
? 'Breche ab…'
: 'Abbrechen'
: 'Trainieren'}
</span>
</Button>
</div>
<div className="mt-2">
<Button
size="md"
variant="secondary"
className="w-full justify-center px-2 text-[11px]"
disabled={feedbackCount === 0 || uiLocked}
onClick={() => setFeedbackModalOpen(true)}
title="Bisher abgegebenes Feedback ansehen."
>
Abgegebenes Feedback ansehen
</Button>
</div>
{trainingRunning && compact ? (
<div className="mt-3 grid grid-cols-2 gap-2 text-[11px]">
<div className="rounded-lg bg-indigo-50 px-2 py-1.5 text-indigo-900 ring-1 ring-indigo-100 dark:bg-indigo-500/10 dark:text-indigo-100 dark:ring-indigo-400/20">
<div className="text-[9px] font-semibold uppercase tracking-wide opacity-70">
Laufzeit
</div>
<div className="mt-0.5 font-bold">
{formatDuration(shownTrainingDurationMs)}
</div>
</div>
<div className="rounded-lg bg-indigo-50 px-2 py-1.5 text-indigo-900 ring-1 ring-indigo-100 dark:bg-indigo-500/10 dark:text-indigo-100 dark:ring-indigo-400/20">
<div className="text-[9px] font-semibold uppercase tracking-wide opacity-70">
Restzeit
</div>
<div className="mt-0.5 font-bold">
{shownTrainingEtaMs > 0 ? `ca. ${formatDuration(shownTrainingEtaMs)}` : '—'}
</div>
</div>
<div className="rounded-lg bg-indigo-50 px-2 py-1.5 text-indigo-900 ring-1 ring-indigo-100 dark:bg-indigo-500/10 dark:text-indigo-100 dark:ring-indigo-400/20">
<div className="text-[9px] font-semibold uppercase tracking-wide opacity-70">
Epoche
</div>
<div className="mt-0.5 font-bold">
{shownTrainingEpochText
? shownTrainingEpochText.replace(/^Epoche\s+/i, '')
: '—'}
</div>
</div>
<div className="rounded-lg bg-indigo-50 px-2 py-1.5 text-indigo-900 ring-1 ring-indigo-100 dark:bg-indigo-500/10 dark:text-indigo-100 dark:ring-indigo-400/20">
<div className="text-[9px] font-semibold uppercase tracking-wide opacity-70">
Ø / Epoche
</div>
<div className="mt-0.5 font-bold">
{estimatedEpochMs > 0 ? formatDuration(estimatedEpochMs) : '—'}
</div>
</div>
</div>
) : null}
<div className="mt-3 border-t border-gray-100 pt-3 dark:border-white/10">
<Button
size="md"
variant="primary"
color="red"
className="w-full justify-center px-2 text-[11px]"
disabled={uiLocked}
onClick={() => void deleteAllTrainingData()}
title="Löscht Feedback, Frames, Samples und Detector-Daten."
>
<span className="inline-flex items-center gap-1.5">
<TrashIcon className="h-3.5 w-3.5" aria-hidden="true" />
{deletingTrainingData ? 'Lösche…' : 'Trainingsdaten löschen'}
</span>
</Button>
</div>
</div>
)
}
const detectorBoxesPanel = (opts?: {
compact?: boolean
stretch?: boolean
maxHeightClassName?: string
}) => {
const compact = Boolean(opts?.compact)
const stretch = Boolean(opts?.stretch)
const hasBoxes = correctionBoxes.length > 0
return (
<div
className={[
'relative z-0 flex min-h-0 flex-col overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm',
'dark:border-white/10 dark:bg-gray-900/70',
stretch ? 'h-full max-h-full flex-1' : 'max-h-full',
].join(' ')}
>
<div
className={[
'shrink-0 border-b border-gray-100 bg-gray-50/80 dark:border-white/10 dark:bg-white/[0.03]',
compact ? 'px-2.5 py-2' : 'px-3 py-2.5',
].join(' ')}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<div className="truncate text-xs font-bold text-gray-900 dark:text-white">
Detector-Boxen
</div>
<span
className={[
'rounded-full px-2 py-0.5 text-[10px] font-black ring-1',
hasBoxes
? 'bg-blue-50 text-blue-700 ring-blue-200 dark:bg-blue-500/15 dark:text-blue-100 dark:ring-blue-400/30'
: 'bg-gray-100 text-gray-500 ring-gray-200 dark:bg-white/10 dark:text-gray-300 dark:ring-white/10',
].join(' ')}
>
{correctionBoxes.length}
</span>
</div>
{!compact ? (
<div className="mt-0.5 truncate text-[10px] text-gray-500 dark:text-gray-400">
Prüfen, Label ändern oder einzeln löschen.
</div>
) : null}
</div>
{hasBoxes ? (
<button
type="button"
disabled={uiLocked}
onClick={clearBoxes}
className={[
'shrink-0 rounded-lg px-2 py-1 text-[10px] font-bold transition ring-1',
'bg-white text-red-600 ring-red-100 hover:bg-red-50 hover:text-red-700 hover:ring-red-200',
'disabled:cursor-not-allowed disabled:opacity-50',
'dark:bg-white/5 dark:text-red-300 dark:ring-red-400/20 dark:hover:bg-red-500/10',
].join(' ')}
title="Alle Boxen entfernen"
>
<span className="inline-flex items-center gap-1">
<TrashIcon className="h-3 w-3" aria-hidden="true" />
Alle löschen
</span>
</button>
) : null}
</div>
</div>
<div
ref={detectorBoxesScrollRef}
className={[
'min-h-0 flex-1 overflow-y-auto overscroll-contain scroll-smooth',
compact ? 'space-y-1 p-1.5' : 'space-y-1.5 p-2',
!stretch && (opts?.maxHeightClassName || 'lg:max-h-[32dvh]'),
].filter(Boolean).join(' ')}
>
{!hasBoxes ? (
<div className="flex min-h-32 items-center justify-center rounded-xl border border-dashed border-gray-200 bg-gray-50 px-3 py-6 text-center dark:border-white/10 dark:bg-white/[0.03]">
<div>
<div className="text-xs font-semibold text-gray-700 dark:text-gray-200">
Keine Boxen vorhanden
</div>
<div className="mt-1 max-w-[220px] text-[11px] leading-snug text-gray-500 dark:text-gray-400">
Wähle rechts ein Label aus und zeichne im Bild eine neue Box.
</div>
</div>
</div>
) : (
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 (
<div
ref={(el) => {
detectorBoxItemRefs.current[index] = el
}}
key={`box-${index}`}
aria-disabled={uiLocked}
onClick={() => {
if (uiLocked) return
setActiveBoxIndex(index)
}}
className={[
'group relative overflow-hidden rounded-2xl border transition-all duration-200',
'bg-white shadow-sm',
'dark:border-white/10 dark:bg-gray-950/55',
uiLocked ? 'cursor-not-allowed opacity-60' : 'cursor-pointer',
isActive
? [
'border-gray-200 bg-white',
'dark:border-white/10',
tone.activeSurface,
].join(' ')
: [
'border-gray-200',
uiLocked ? '' : 'hover:bg-gray-50/80 hover:shadow-md',
'dark:border-white/10',
uiLocked ? '' : 'dark:hover:bg-white/[0.04]',
uiLocked ? '' : tone.idleHover,
].join(' '),
].join(' ')}
>
<div
className={[
'absolute inset-y-2 left-0 w-1 rounded-r-full transition-all duration-200',
isActive
? tone.line
: ['bg-transparent', tone.lineHover].join(' '),
].join(' ')}
aria-hidden="true"
/>
<div className={compact ? 'p-2 pl-3' : 'p-2.5 pl-3.5'}>
<div className="flex items-center gap-2.5">
<button
type="button"
disabled={uiLocked}
onClick={(e) => {
e.stopPropagation()
if (uiLocked) return
setActiveBoxIndex(index)
}}
className={[
'flex h-9 w-9 shrink-0 items-center justify-center rounded-xl ring-1 transition',
isActive
? [
'bg-blue-100 text-blue-700 ring-blue-200',
tone.iconActive,
].join(' ')
: [
'bg-gray-50 text-gray-500 ring-gray-200',
'group-hover:bg-gray-100 group-hover:text-gray-700',
'dark:bg-white/5 dark:ring-white/10',
tone.iconIdle,
].join(' '),
].join(' ')}
title="Box im Bild markieren"
aria-label={`Box ${index + 1} im Bild markieren`}
>
<Icon className="h-4.5 w-4.5" aria-hidden="true" />
</button>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center justify-between gap-2">
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2">
<div className="truncate text-[13px] font-bold leading-tight text-gray-950 dark:text-white">
{item.text}
</div>
<span className="shrink-0 text-[10px] font-black text-gray-400 dark:text-gray-500">
#{index + 1}
</span>
</div>
<div className="mt-0.5 flex min-w-0 items-center gap-1.5 text-[10px] font-medium leading-tight text-gray-500 dark:text-gray-400">
<span
className={[
'h-1.5 w-1.5 shrink-0 rounded-full',
isCorrected
? 'bg-amber-400'
: scoreLevel(box.score) === 'high'
? 'bg-emerald-400'
: scoreLevel(box.score) === 'mid'
? 'bg-yellow-400'
: scoreLevel(box.score) === 'low'
? 'bg-red-400'
: 'bg-gray-300 dark:bg-gray-500',
].join(' ')}
aria-hidden="true"
/>
<span className="truncate">
{isCorrected ? 'korrigiert' : `Confidence ${scoreText}`}
</span>
{isActive ? (
<>
<span className="text-gray-300 dark:text-gray-600">·</span>
<span
className={[
'font-semibold text-blue-700',
tone.selectedText,
].join(' ')}
>
ausgewählt
</span>
</>
) : null}
</div>
</div>
<button
type="button"
disabled={uiLocked}
className={[
'inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-lg transition',
'text-gray-400 hover:bg-red-50 hover:text-red-600',
'focus:outline-none focus:ring-2 focus:ring-red-500/30',
'disabled:cursor-not-allowed disabled:opacity-50',
'dark:text-gray-500 dark:hover:bg-red-500/10 dark:hover:text-red-300',
].join(' ')}
title={`${item.text} löschen`}
aria-label={`${item.text} löschen`}
onClick={(e) => {
e.stopPropagation()
if (uiLocked) return
removeBox(index)
}}
>
<TrashIcon className="h-3.5 w-3.5" aria-hidden="true" />
</button>
</div>
{!isActive ? null : (
<div className={compact ? 'mt-2' : 'mt-3'}>
<DetectorBoxLabelSelect
values={boxLabels}
value={box.label}
compact={compact}
disabled={uiLocked || boxLabels.length === 0}
onChange={(value) => changeBoxLabel(index, value)}
/>
</div>
)}
</div>
</div>
</div>
</div>
)
})
)}
</div>
</div>
)
}
const imageTouchClass =
boxLabel || drawingBox || boxInteraction
? 'touch-none'
: 'touch-pan-y'
const loadingPreviewBackgroundUrl =
loadingPreviewUrl && loadingPreviewLoaded && !loadingPreviewFailed
? loadingPreviewUrl
: loadingPreviewFallbackUrl
const sampleSourceDetails = compactTrainingSourceFile(sample?.sourceFile || '')
const frameLayoutSize = useMemo(() => {
const width = Number(frameNaturalSize?.width)
const height = Number(frameNaturalSize?.height)
if (
Number.isFinite(width) &&
Number.isFinite(height) &&
width > 0 &&
height > 0
) {
return { width, height }
}
// Fallback, bis das echte Bildformat bekannt ist.
return { width: 1600, height: 900 }
}, [frameNaturalSize])
const imageAspectRatio = Math.max(
0.25,
Math.min(4, frameLayoutSize.width / frameLayoutSize.height)
)
const imageStageLimits = imageExpanded
? {
baseDvh: 52,
smDvh: 60,
lgDvh: 78,
lgPx: 820,
// Platz für:
// - Speichern/Überspringen
// - Bild vergrößern/Normal anzeigen
// - Abstände
bottomReservePx: 158,
}
: {
baseDvh: 44,
smDvh: 52,
lgDvh: 64,
lgPx: 680,
bottomReservePx: 138,
}
const imageStageStyle = imageExpanded
? ({
'--image-stage-max-h': `${imageStageLimits.baseDvh}dvh`,
'--image-stage-max-h-sm': `${imageStageLimits.smDvh}dvh`,
} as CSSProperties & Record<string, string | number>)
: ({
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<string, string | number>)
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 (
<div className="min-h-full lg:h-[calc(100dvh-15rem)] lg:min-h-0 lg:max-h-[calc(100dvh-15rem)] lg:overflow-hidden">
{loadingPreviewUrl ? (
<img
src={loadingPreviewUrl}
alt=""
aria-hidden="true"
draggable={false}
className="pointer-events-none fixed h-px w-px opacity-0"
style={{ left: -9999, top: -9999 }}
onLoad={(e) => {
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}
<div className="mb-2 flex items-center justify-between gap-2 rounded-xl border border-gray-200 bg-white px-3 py-2 shadow-sm dark:border-white/10 dark:bg-gray-900/80 lg:hidden">
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<div className="shrink-0 text-sm font-semibold text-gray-900 dark:text-white">
Training
</div>
{sampleSourceDetails.sourceFile ? (
<div
className="inline-flex h-6 min-w-0 max-w-[52vw] items-center gap-1.5 overflow-hidden rounded-full bg-gray-100 px-2.5 text-[11px] font-medium text-gray-600 ring-1 ring-gray-200 dark:bg-white/10 dark:text-gray-300 dark:ring-white/10"
title={sample?.sourceFile || sampleSourceDetails.sourceFile}
>
<span className="min-w-0 translate-y-px truncate leading-[14px]">
{sampleSourceDetails.sourceFile}
</span>
{sampleSourceDetails.frameLabel ? (
<span className="shrink-0 rounded-full bg-emerald-100 px-1.5 py-0.5 text-[9px] font-bold leading-none text-emerald-700 ring-1 ring-emerald-200 dark:bg-emerald-500/20 dark:text-emerald-100 dark:ring-emerald-400/25">
{sampleSourceDetails.frameLabel}
</span>
) : null}
</div>
) : null}
</div>
</div>
<button
type="button"
onClick={() => setStatsModalOpen(true)}
className="ml-auto inline-flex h-6 shrink-0 items-center rounded-full bg-gray-100 px-2 text-[11px] font-bold leading-none text-gray-700 ring-1 ring-gray-200 dark:bg-white/10 dark:text-gray-200 dark:ring-white/10"
title="Training-Datenstatistiken anzeigen"
aria-label="Training-Datenstatistiken anzeigen"
>
{feedbackBadgeText}
</button>
</div>
<div
className={[
'grid grid-cols-1 items-start gap-2 lg:h-full lg:min-h-0 lg:items-stretch lg:overflow-hidden',
imageExpanded
? [
// Desktop: Sidebars bleiben sichtbar, Mitte bekommt deutlich mehr Breite.
'lg:grid-cols-[280px_minmax(0,1fr)_280px]',
'xl:grid-cols-[300px_minmax(0,1fr)_300px]',
'2xl:grid-cols-[320px_minmax(0,1fr)_320px]',
].join(' ')
: 'lg:grid-cols-[300px_minmax(0,1fr)_300px] xl:grid-cols-[320px_minmax(0,1fr)_320px]',
].join(' ')}
>
{/* Sidebar links */}
<aside
className={[
'hidden h-full max-h-full min-h-0 flex-col overflow-hidden rounded-xl border border-gray-200 bg-white p-3 shadow-sm dark:border-white/10 dark:bg-gray-900/60',
'lg:flex',
].join(' ')}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<div className="shrink-0 text-sm font-semibold text-gray-900 dark:text-white">
Training
</div>
{sampleSourceDetails.sourceFile ? (
<div
className="inline-flex h-6 min-w-0 max-w-[11rem] items-center gap-1.5 overflow-hidden rounded-full bg-gray-100 px-2.5 text-[11px] font-medium text-gray-600 ring-1 ring-gray-200 dark:bg-white/10 dark:text-gray-300 dark:ring-white/10"
title={sample?.sourceFile || sampleSourceDetails.sourceFile}
>
<span className="min-w-0 translate-y-px truncate leading-[14px]">
{sampleSourceDetails.sourceFile}
</span>
{sampleSourceDetails.frameLabel ? (
<span className="shrink-0 rounded-full bg-emerald-100 px-1.5 py-0.5 text-[9px] font-bold leading-none text-emerald-700 ring-1 ring-emerald-200 dark:bg-emerald-500/20 dark:text-emerald-100 dark:ring-emerald-400/25">
{sampleSourceDetails.frameLabel}
</span>
) : null}
</div>
) : null}
</div>
</div>
<button
type="button"
onClick={() => setStatsModalOpen(true)}
className={[
'inline-flex h-6 shrink-0 items-center rounded-full bg-gray-100 px-2 text-[11px] font-medium text-gray-700 ring-1 ring-gray-200 transition',
'hover:bg-indigo-50 hover:text-indigo-700 hover:ring-indigo-200',
'focus:outline-none focus:ring-2 focus:ring-indigo-500/40',
'dark:bg-white/10 dark:text-gray-200 dark:ring-white/10 dark:hover:bg-indigo-500/20 dark:hover:text-indigo-100 dark:hover:ring-indigo-300/30',
].join(' ')}
title="Training-Datenstatistiken anzeigen"
aria-label="Training-Datenstatistiken anzeigen"
>
{feedbackBadgeText}
</button>
</div>
<div className="mt-2 text-xs text-gray-600 dark:text-gray-300">
Bestätige oder korrigiere die Analyse. Jede Antwort wird als Trainingsdatenpunkt gespeichert.
</div>
<div className="mt-3 flex min-h-0 flex-1 flex-col gap-3 overflow-hidden">
<div className="min-h-0 flex-1 rounded-xl overflow-hidden">
{detectorBoxesPanel({
stretch: true,
maxHeightClassName: 'lg:max-h-none',
})}
</div>
<div className="shrink-0">
{trainingActionsPanel()}
</div>
</div>
</aside>
{/* Mitte */}
<div className="min-w-0 lg:flex lg:h-full lg:min-h-0 lg:flex-col lg:overflow-hidden">
<section
className={[
'min-w-0 w-full rounded-xl border border-gray-200 bg-white p-2 shadow-sm dark:border-white/10 dark:bg-gray-900/60 sm:p-3',
imageExpanded
? 'lg:grid lg:h-full lg:min-h-0 lg:grid-rows-[minmax(0,1fr)_auto] lg:gap-3 lg:overflow-hidden'
: 'lg:self-start'
].join(' ')}
>
<div
className={[
'relative z-50 mx-auto flex items-center justify-center rounded-lg bg-black p-2 sm:p-3',
imageExpanded
? 'w-full min-h-0 max-h-[var(--image-stage-max-h)] sm:max-h-[var(--image-stage-max-h-sm)] lg:h-full lg:max-h-full lg:self-stretch'
: imageStageHeightClass,
'overflow-visible',
].join(' ')}
style={imageStageStyle}
>
{hasPosePersons ? (
<div className="absolute right-3 top-3 z-[620] max-w-[calc(100%-1.5rem)] rounded-lg border border-gray-200/80 bg-white/95 px-2.5 py-1.5 shadow-lg backdrop-blur dark:border-white/10 dark:bg-gray-950/90 sm:right-4 sm:top-4">
<LabeledSwitch
compact
size="short"
label={`Pose-Skelett (${posePersons.length})`}
checked={showPoseSkeleton}
onChange={setShowPoseSkeleton}
/>
</div>
) : null}
{imageSrc ? (
<div className="relative z-50 flex h-full min-h-0 w-full items-center justify-center overflow-visible">
<div
ref={imageBoxRef}
className={[
imageExpanded
? 'relative z-50 flex h-full w-full min-h-0 select-none items-center justify-center overscroll-contain transition'
: 'relative z-50 inline-flex min-h-0 max-h-full max-w-full select-none overscroll-contain transition',
imageTouchClass,
drawingCursorClass,
'[-webkit-touch-callout:none] [-webkit-user-select:none] [user-select:none]',
trainingRunning || loading ? 'pointer-events-none' : '',
].join(' ')}
onContextMenu={(e) => e.preventDefault()}
onPointerDown={startDrawBox}
onPointerMove={moveDrawBox}
>
<img
ref={frameImageRef}
src={imageSrc}
alt="Training Frame"
draggable={false}
width={frameLayoutSize.width}
height={frameLayoutSize.height}
onLoad={(e) => {
const img = e.currentTarget
if (img.naturalWidth > 0 && img.naturalHeight > 0) {
setFrameNaturalSize({
width: img.naturalWidth,
height: img.naturalHeight,
})
}
setFrameImageLoaded(true)
window.requestAnimationFrame(updateImageLayerStyle)
}}
onError={() => {
setFrameImageLoaded(true)
window.requestAnimationFrame(updateImageLayerStyle)
}}
onContextMenu={(e) => e.preventDefault()}
onDragStart={(e) => e.preventDefault()}
className={[
imageExpanded
? 'block h-full w-full min-h-0 rounded-md object-contain'
: 'block h-auto min-h-0 max-h-full max-w-full rounded-md object-contain',
'select-none',
'transition-opacity duration-500 ease-out will-change-opacity motion-reduce:transition-none',
frameImageLoaded ? 'opacity-100' : 'opacity-0',
imageTouchClass,
'[-webkit-user-drag:none] [-webkit-touch-callout:none]',
].join(' ')}
/>
{showPoseSkeleton && imageLayerStyle && hasPosePersons ? (() => {
const layerWidth = Number(imageLayerStyle.width)
const layerHeight = Number(imageLayerStyle.height)
if (
!Number.isFinite(layerWidth) ||
!Number.isFinite(layerHeight) ||
layerWidth <= 0 ||
layerHeight <= 0
) {
return null
}
return (
<div
aria-hidden="true"
className="pointer-events-none absolute z-[295] overflow-visible"
style={imageLayerStyle}
>
{posePersons.map((person, personIndex) => {
const reliable = isPosePersonReliable(person)
const color = reliable
? POSE_PERSON_COLORS[personIndex % POSE_PERSON_COLORS.length]
: POSE_UNRELIABLE_COLOR
const score = Math.round(clamp01(Number(person.score)) * 100)
const quality = Math.round(posePersonQuality(person) * 100)
const visibleKeypoints = posePersonVisibleKeypoints(person)
return (
<div
key={`pose-box-${personIndex}`}
className={[
'absolute rounded border border-dashed',
reliable ? '' : 'bg-slate-500/5',
].join(' ')}
style={{
...poseBoxPixelStyle(person.box, layerWidth, layerHeight),
borderColor: color,
boxShadow: reliable ? `0 0 0 1px ${color}55` : `0 0 0 1px ${color}33`,
opacity: reliable ? 1 : 0.78,
}}
>
<span
className="absolute left-0 top-0 -translate-y-[calc(100%+3px)] rounded px-1 py-0.5 text-[9px] font-black leading-none text-white shadow"
style={{ backgroundColor: color }}
title={`Score ${score}% | Qualitaet ${quality}% | Keypoints ${visibleKeypoints}`}
>
{posePersons.length > 1 ? `Pose ${personIndex + 1}` : 'Pose'} {score}%
</span>
</div>
)
})}
<svg
className="absolute inset-0 h-full w-full overflow-visible"
viewBox={`0 0 ${layerWidth} ${layerHeight}`}
preserveAspectRatio="none"
>
{posePersons.map((person, personIndex) => {
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 (
<g key={`pose-lines-${personIndex}`}>
{POSE_SKELETON_EDGES.map(([from, to]) => {
const fromPoint = keypointsByName.get(from)
const toPoint = keypointsByName.get(to)
if (
!isPoseKeypointVisible(fromPoint) ||
!isPoseKeypointVisible(toPoint)
) {
return null
}
return (
<line
key={`${from}-${to}`}
x1={poseCoordPx(fromPoint.x, layerWidth)}
y1={poseCoordPx(fromPoint.y, layerHeight)}
x2={poseCoordPx(toPoint.x, layerWidth)}
y2={poseCoordPx(toPoint.y, layerHeight)}
stroke={color}
strokeOpacity={reliable ? 0.9 : 0.45}
strokeWidth={2.5}
strokeLinecap="round"
strokeDasharray={reliable ? undefined : '4 4'}
vectorEffect="non-scaling-stroke"
/>
)
})}
</g>
)
})}
</svg>
{posePersons.map((person, personIndex) => {
const reliable = isPosePersonReliable(person)
const color = reliable
? POSE_PERSON_COLORS[personIndex % POSE_PERSON_COLORS.length]
: POSE_UNRELIABLE_COLOR
return person.keypoints
.filter(isPoseKeypointVisible)
.map((point, pointIndex) => {
const left = poseCoordPx(point.x, layerWidth)
const top = poseCoordPx(point.y, layerHeight)
const labelToLeft = left > layerWidth * 0.72
const label = poseKeypointLabel(point.name)
const confidence = Math.round(clamp01(Number(point.conf)) * 100)
const dot = (
<span
className="h-2 w-2 shrink-0 rounded-full border border-white shadow-[0_0_0_1px_rgba(0,0,0,0.45)]"
style={{ backgroundColor: color }}
/>
)
const text = (
<span
className={[
'rounded px-1 py-0.5 text-[9px] font-bold leading-none shadow ring-1',
reliable
? 'bg-white/95 text-gray-900 ring-black/10 dark:bg-gray-950/90 dark:text-white dark:ring-white/15'
: 'bg-slate-700/90 text-white ring-white/20',
].join(' ')}
>
{label}
</span>
)
return (
<div
key={`pose-point-${personIndex}-${poseKeypointId(point.name)}-${pointIndex}`}
className="absolute top-0 flex items-center gap-1 whitespace-nowrap"
style={{
left,
top,
transform: labelToLeft
? 'translate(calc(-100% + 4px), -50%)'
: 'translate(-4px, -50%)',
opacity: reliable ? 1 : 0.72,
}}
title={`${label} | ${confidence}%`}
>
{labelToLeft ? (
<>
{text}
{dot}
</>
) : (
<>
{dot}
{text}
</>
)}
</div>
)
})
})}
</div>
)
})() : null}
{showImageBoxes && imageLayerStyle ? (
<div
className="absolute z-[300] overflow-visible"
style={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 (
<div
key={`${box.label}-${index}-${isDraft ? 'draft' : 'box'}`}
className={[
'pointer-events-none absolute overflow-visible rounded border-2',
isActiveBox
? 'border-blue-500 shadow-none'
: [
'shadow-none',
scoreBorderClass(box.score, { draft: isDraft }),
].join(' '),
].join(' ')}
style={{
left: `${left}%`,
top: `${top}%`,
width: `${width}%`,
height: `${height}%`,
backgroundColor: 'transparent',
zIndex: isActiveBox ? 330 : isDraft ? 320 : 310,
}}
title={box.label}
>
<div
data-box-control="true"
className={[
'pointer-events-auto absolute top-0 z-[360] flex -translate-y-[calc(100%+4px)] touch-none select-none items-center',
'[-webkit-user-select:none] [-webkit-touch-callout:none]',
alignLabelRight ? 'right-0 justify-end' : 'left-0 justify-start',
].join(' ')}
style={{
maxWidth: labelMaxWidth,
}}
title={isDraft ? box.label : `${item.text} verschieben`}
>
<div
role={isDraft || uiLocked ? undefined : 'button'}
aria-disabled={Boolean(isDraft) || uiLocked}
className={[
'group/label flex h-5 max-w-full min-w-0 touch-none select-none items-center overflow-hidden rounded-full text-left',
isSmallBox ? 'gap-0.5 px-1' : 'gap-1 pl-1.5 pr-0.5',
'text-[10px] font-bold leading-none shadow-md ring-1 transition',
'[-webkit-user-select:none] [-webkit-touch-callout:none]',
isActiveBox
? [
'bg-white/95 text-blue-700 ring-blue-500/30 hover:bg-blue-50',
'dark:bg-blue-600 dark:text-white dark:ring-white/25 dark:hover:bg-blue-700',
].join(' ')
: isDraft
? [
'bg-amber-100/95 text-amber-950 ring-amber-500/30',
'dark:bg-amber-400 dark:text-black dark:ring-black/15',
].join(' ')
: [
'bg-white/95 text-gray-950 ring-black/10 hover:bg-gray-50',
'dark:bg-white/95 dark:text-gray-950 dark:ring-black/10 dark:hover:bg-gray-50',
].join(' '),
isDraft ? 'cursor-default' : 'cursor-move',
].join(' ')}
onPointerDown={(e) => {
if (isDraft || uiLocked) return
e.preventDefault()
e.stopPropagation()
window.getSelection()?.removeAllRanges()
const target = e.target as HTMLElement | null
if (target?.closest('[data-box-trash="true"]')) return
const requiresActivationFirst = !isActiveBox
setActiveBoxIndex(index)
if (requiresActivationFirst) return
const contentRect = getImageContentRect()
if (!contentRect) return
activeImageContentRectRef.current = contentRect
const pos = getPointerPosFromRect(contentRect, e.clientX, e.clientY, { clamp: false })
const clampedPos = getPointerPosFromRect(contentRect, e.clientX, e.clientY)
finishingGestureRef.current = false
activePointerIdRef.current = e.pointerId
try {
imageBoxRef.current?.setPointerCapture(e.pointerId)
} catch {
activePointerIdRef.current = null
}
const nextMagnifier: MagnifierState = {
visible: true,
clientX: e.clientX,
clientY: e.clientY,
imageX: clampedPos.x,
imageY: clampedPos.y,
}
const nextInteraction: BoxInteraction = {
type: 'move',
index,
startX: pos.x,
startY: pos.y,
original: box,
}
latestGestureBoxRef.current = box
boxInteractionRef.current = nextInteraction
setTouchMagnifier(nextMagnifier)
setBoxInteraction(nextInteraction)
}}
>
<span
className={[
'shrink-0 rounded-full px-1 py-0.5 text-[9px] font-black leading-none',
isActiveBox
? 'bg-blue-100 text-blue-700 dark:bg-white/20 dark:text-white'
: isDraft
? 'bg-amber-200 text-amber-950 dark:bg-black/15 dark:text-black'
: 'bg-gray-100 text-gray-700 dark:bg-gray-100 dark:text-gray-700',
].join(' ')}
>
{isDraft ? 'neu' : `#${index + 1}`}
</span>
<Icon
className="h-3 w-3 shrink-0 text-current"
aria-hidden="true"
/>
{!isSmallBox ? (
<span className="min-w-0 max-w-[104px] truncate text-current sm:max-w-[132px]">
{item.text}
</span>
) : null}
{!isDraft ? (
<button
data-box-trash="true"
type="button"
disabled={uiLocked}
className={[
'ml-0.5 flex h-4.5 w-4.5 shrink-0 cursor-pointer touch-none items-center justify-center rounded-full',
'!bg-red-600 !text-white ring-1 ring-red-800/40 transition',
'hover:!bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500/60',
'disabled:cursor-not-allowed disabled:opacity-50',
'dark:!bg-red-500 dark:!text-white dark:ring-white/20 dark:hover:!bg-red-600',
].join(' ')}
title={`${box.label} löschen`}
aria-label={`${box.label} löschen`}
onPointerDown={(e) => {
e.stopPropagation()
}}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
removeBox(index)
}}
>
<TrashIcon
className="h-3 w-3 shrink-0 !text-white"
aria-hidden="true"
/>
</button>
) : null}
</div>
</div>
{!isDraft ? (
<>
{(['nw', 'ne', 'sw', 'se'] as const).map((handle) => (
<button
key={handle}
data-box-control="true"
type="button"
disabled={uiLocked}
className={[
isActiveBox
? [
'pointer-events-auto absolute z-[350] flex h-7 w-7 touch-none items-center justify-center',
'appearance-none rounded-none border-0 p-0 opacity-100 shadow-none ring-0',
'!bg-transparent hover:!bg-transparent active:!bg-transparent focus:!bg-transparent',
'focus:outline-none disabled:opacity-50 sm:h-5 sm:w-5',
].join(' ')
: [
'pointer-events-none absolute z-[100] hidden h-7 w-7 touch-none items-center justify-center',
'appearance-none rounded-none border-0 p-0 opacity-100 shadow-none ring-0',
'!bg-transparent hover:!bg-transparent active:!bg-transparent focus:!bg-transparent',
'focus:outline-none disabled:opacity-50 sm:h-5 sm:w-5',
].join(' '),
handle === 'nw' ? '-left-3.5 -top-3.5 cursor-nwse-resize sm:-left-2.5 sm:-top-2.5' : '',
handle === 'ne' ? '-right-3.5 -top-3.5 cursor-nesw-resize sm:-right-2.5 sm:-top-2.5' : '',
handle === 'sw' ? '-bottom-3.5 -left-3.5 cursor-nesw-resize sm:-bottom-2.5 sm:-left-2.5' : '',
handle === 'se' ? '-bottom-3.5 -right-3.5 cursor-nwse-resize sm:-bottom-2.5 sm:-right-2.5' : '',
].join(' ')}
title="Boxgröße ändern"
onPointerDown={(e) => {
if (uiLocked) return
e.preventDefault()
e.stopPropagation()
window.getSelection()?.removeAllRanges()
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)
finishingGestureRef.current = false
activePointerIdRef.current = e.pointerId
try {
imageBoxRef.current?.setPointerCapture(e.pointerId)
} catch {
activePointerIdRef.current = null
}
const nextMagnifier: MagnifierState = {
visible: true,
clientX: e.clientX,
clientY: e.clientY,
imageX: pos.x,
imageY: pos.y,
}
const nextInteraction: BoxInteraction = {
type: 'resize',
index,
handle,
startX: pos.x,
startY: pos.y,
original: box,
}
latestGestureBoxRef.current = box
boxInteractionRef.current = nextInteraction
setTouchMagnifier(nextMagnifier)
setBoxInteraction(nextInteraction)
}}
>
<span
className={[
'block h-2 w-2 rounded-full bg-white shadow-none ring-2 sm:h-2 sm:w-2',
isActiveBox ? 'ring-blue-500' : scoreRingClass(box.score),
].join(' ')}
/>
</button>
))}
</>
) : null}
</div>
)
})}
</div>
) : null}
{touchMagnifier?.visible && imageSrc && showImageBoxes ? (() => {
const rect = activeImageContentRectRef.current ?? getImageContentRect()
if (!rect || rect.width <= 0 || rect.height <= 0) return null
const visualViewport =
typeof window !== 'undefined' ? window.visualViewport : undefined
const viewportLeft = visualViewport?.offsetLeft ?? 0
const viewportTop = visualViewport?.offsetTop ?? 0
const viewportW =
visualViewport?.width ??
(typeof window !== 'undefined' ? window.innerWidth : 390)
const viewportH =
visualViewport?.height ??
(typeof window !== 'undefined' ? window.innerHeight : 800)
const isCoarsePointer =
typeof window !== 'undefined' &&
typeof window.matchMedia === 'function' &&
window.matchMedia('(pointer: coarse)').matches
const isTouchLike = isCoarsePointer || viewportW < 640
const baseSize = isTouchLike ? 136 : 156
const largeBoxSize = isTouchLike
? Math.min(176, Math.max(152, viewportW - 32))
: 190
const padding = isTouchLike ? 16 : 20
const activeBox =
drawingBox ||
(boxInteraction
? correction.boxes?.[boxInteraction.index] ?? null
: null)
const hasUsableBox =
activeBox &&
Number.isFinite(activeBox.w) &&
Number.isFinite(activeBox.h) &&
activeBox.w > 0.003 &&
activeBox.h > 0.003
const boxCenterX = hasUsableBox
? clamp01(activeBox.x + activeBox.w / 2)
: touchMagnifier.imageX
const boxCenterY = hasUsableBox
? clamp01(activeBox.y + activeBox.h / 2)
: touchMagnifier.imageY
const boxPixelW = hasUsableBox ? activeBox.w * rect.width : 0
const boxPixelH = hasUsableBox ? activeBox.h * rect.height : 0
const boxNeedsLargeMagnifier =
hasUsableBox &&
(
boxPixelW > baseSize - padding * 2 ||
boxPixelH > baseSize - padding * 2
)
const size = boxNeedsLargeMagnifier ? largeBoxSize : baseSize
const fitPadding = isTouchLike ? 14 : 18
const fitZoom = hasUsableBox
? Math.min(
(size - fitPadding * 2) / Math.max(1, boxPixelW),
(size - fitPadding * 2) / Math.max(1, boxPixelH)
)
: 2
const zoom = hasUsableBox
? Math.min(isTouchLike ? 2.25 : 2.5, fitZoom * 0.94)
: 2
const gap = isTouchLike ? 10 : 12
const edgeGap = isTouchLike ? 8 : 10
// Die Lupe wird an der Box ausgerichtet, nicht am Finger.
// Wenn gerade noch keine echte Box existiert, fällt sie auf den aktuellen Bildpunkt zurück.
const anchorX = hasUsableBox
? rect.left + boxCenterX * rect.width
: rect.left + touchMagnifier.imageX * rect.width
const anchorTop = hasUsableBox
? rect.top + activeBox.y * rect.height
: rect.top + touchMagnifier.imageY * rect.height
const anchorBottom = hasUsableBox
? rect.top + (activeBox.y + activeBox.h) * rect.height
: rect.top + touchMagnifier.imageY * rect.height
let left = anchorX - size / 2
// Normalfall: Lupe über der Box.
let top = anchorTop - size - gap
// Wenn oben kein Platz ist: Lupe unter die Box setzen.
if (top < viewportTop + edgeGap) {
top = anchorBottom + gap
}
// Wenn die Lupe unten aus dem sichtbaren Bereich laufen würde:
// an das untere Ende des sichtbaren Viewports klemmen.
if (top + size > viewportTop + viewportH - edgeGap) {
top = viewportTop + viewportH - size - edgeGap
}
// Falls der Viewport extrem klein ist, trotzdem sichtbar halten.
if (top < viewportTop + edgeGap) {
top = viewportTop + edgeGap
}
left = Math.max(
viewportLeft + edgeGap,
Math.min(viewportLeft + viewportW - size - edgeGap, left)
)
const imageWidth = rect.width * zoom
const imageHeight = rect.height * zoom
const imageLeft = size / 2 - boxCenterX * imageWidth
const imageTop = size / 2 - boxCenterY * imageHeight
const pointerX = imageLeft + touchMagnifier.imageX * imageWidth
const pointerY = imageTop + touchMagnifier.imageY * imageHeight
const boxLeft = hasUsableBox ? imageLeft + activeBox.x * imageWidth : 0
const boxTop = hasUsableBox ? imageTop + activeBox.y * imageHeight : 0
const boxWidth = hasUsableBox ? activeBox.w * imageWidth : 0
const boxHeight = hasUsableBox ? activeBox.h * imageHeight : 0
return typeof document !== 'undefined'
? createPortal(
<div
className="pointer-events-none fixed z-[2147483647] overflow-hidden rounded-xl border-2 border-white bg-black shadow-2xl ring-2 ring-black/70"
style={{
left,
top,
width: size,
height: size,
}}
>
<img
src={imageSrc}
alt=""
draggable={false}
className="absolute max-h-none max-w-none select-none"
style={{
left: imageLeft,
top: imageTop,
width: imageWidth,
height: imageHeight,
}}
/>
{hasUsableBox ? (
<div
className={[
'absolute rounded border-2 bg-black/0 shadow-[0_0_0_1px_rgba(0,0,0,0.85)]',
scoreBorderClass(activeBox.score, { draft: Boolean(drawingBox) }),
].join(' ')}
style={{
left: boxLeft,
top: boxTop,
width: boxWidth,
height: boxHeight,
}}
/>
) : null}
<div
className="absolute h-5 w-px -translate-x-1/2 -translate-y-1/2 bg-red-500/90"
style={{
left: pointerX,
top: pointerY,
}}
/>
<div
className="absolute h-px w-5 -translate-x-1/2 -translate-y-1/2 bg-red-500/90"
style={{
left: pointerX,
top: pointerY,
}}
/>
<div
className="absolute h-2 w-2 -translate-x-1/2 -translate-y-1/2 rounded-full border border-white bg-red-500 shadow"
style={{
left: pointerX,
top: pointerY,
}}
/>
</div>,
document.body
)
: null
})() : null}
</div>
</div>
) : (
<div className="relative z-10 text-sm text-white/80">
Kein Bild geladen
</div>
)}
{renderStageOverlay ? (
<TrainingStageOverlay
mode={stageOverlayMode}
text={stageOverlayText}
sourceFile={stageOverlaySourceFile}
frameLabel={stageOverlayFrameLabel}
statusText={stageOverlayStatusText}
progress={stageOverlayProgress}
visible={stageOverlayIsVisible}
backgroundUrl={
stageOverlayMode === 'training'
? trainingPreviewUrl || trainingStatus?.training?.previewUrl || imageSrc
: stageOverlayMode === 'saving'
? imageSrc
: loadingPreviewBackgroundUrl
}
/>
) : null}
</div>
<div
className="mt-3 grid shrink-0 grid-cols-2 gap-2 sm:mt-4"
>
<Button
size="md"
variant={hasManualCorrection || willSaveAsNegative ? 'primary' : 'soft'}
color={
willSaveAsNegative
? 'blue'
: hasManualCorrection
? undefined
: 'emerald'
}
disabled={
trainingRunning ||
uiLocked ||
frameBusy ||
!sample ||
(!hasManualCorrection && !willSaveAsNegative && !sample.prediction.modelAvailable)
}
onClick={() => void saveFeedback(!hasManualCorrection)}
className="w-full justify-center px-2 text-xs sm:text-sm"
title={
trainingRunning
? 'Während das Training läuft, kann kein Bild ins Training übernommen werden.'
: willSaveAsNegative
? 'Keine Box und keine Position gesetzt. Das Bild wird als Negativbeispiel gespeichert.'
: hasManualCorrection
? 'Die korrigierten Werte werden gespeichert.'
: sample?.prediction.modelAvailable
? 'Die Erkennung stimmt. Prediction wird als korrekt gespeichert.'
: 'Erst verfügbar, wenn ein Modell trainiert wurde.'
}
>
{saving ? (
<span className="inline-flex items-center gap-1.5">
<ArrowPathIcon className="h-3.5 w-3.5 animate-spin" aria-hidden="true" />
Speichere
</span>
) : willSaveAsNegative ? (
<span className="inline-flex items-center gap-1.5">
<XCircleIcon className="h-3.5 w-3.5" aria-hidden="true" />
<span className="sm:hidden">Negativ</span>
<span className="hidden sm:inline">Negativbeispiel & weiter</span>
</span>
) : hasManualCorrection ? (
<span className="inline-flex items-center gap-1.5">
<InboxArrowDownIcon className="h-3.5 w-3.5" aria-hidden="true" />
<span className="sm:hidden">Speichern</span>
<span className="hidden sm:inline">Speichern & weiter</span>
</span>
) : (
<span className="inline-flex items-center gap-1.5">
<CheckIcon className="h-3.5 w-3.5" aria-hidden="true" />
<span className="sm:hidden">Passt</span>
<span className="hidden sm:inline">Passt so & weiter</span>
</span>
)}
</Button>
<Button
size="md"
variant="secondary"
disabled={uiLocked || frameBusy || !sample}
onClick={() => void skipCurrentSample()}
className="w-full justify-center px-2 text-xs sm:text-sm"
title={
editingFeedback
? 'Feedback-Bearbeitung abbrechen und zum vorherigen Trainingsbild zurückkehren.'
: trainingSampleMode === 'uncertain'
? 'Dieses Bild löschen und eine andere unsichere Prediction laden.'
: 'Dieses Bild löschen und ein anderes laden.'
}
>
<span className="inline-flex items-center gap-1.5">
{editingFeedback ? (
<XCircleIcon className="h-3.5 w-3.5" aria-hidden="true" />
) : (
<ForwardIcon className="h-3.5 w-3.5" aria-hidden="true" />
)}
{editingFeedback ? 'Bearbeitung abbrechen' : 'Überspringen'}
</span>
</Button>
<div className="col-span-2 hidden lg:block">
<Button
size="md"
variant="secondary"
disabled={uiLocked}
onClick={() => setImageExpanded((value) => !value)}
className="w-full justify-center px-2 text-xs sm:text-sm"
title={imageExpanded ? 'Layout wieder normal anzeigen' : 'Mittlere Spalte vergrößern'}
aria-pressed={imageExpanded}
>
<span className="inline-flex items-center gap-1.5">
{imageExpanded ? (
<ArrowsPointingInIcon className="h-3.5 w-3.5" aria-hidden="true" />
) : (
<ArrowsPointingOutIcon className="h-3.5 w-3.5" aria-hidden="true" />
)}
{imageExpanded ? 'Normal anzeigen' : 'Bild vergrößern'}
</span>
</Button>
</div>
</div>
</section>
{trainingRunning ? (
<div className="mt-2 hidden rounded-xl border border-gray-200 bg-white p-3 shadow-sm dark:border-white/10 dark:bg-gray-900/70 lg:block">
<div className="mb-2 flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="text-xs font-semibold text-gray-900 dark:text-white">
Trainingszeit
</div>
<div className="mt-0.5 truncate text-[11px] text-gray-500 dark:text-gray-400">
{shownTrainingStep || 'Training läuft…'}
</div>
</div>
<span className="shrink-0 rounded-full bg-indigo-50 px-2 py-1 text-[11px] font-bold text-indigo-700 ring-1 ring-indigo-200 dark:bg-indigo-500/15 dark:text-indigo-100 dark:ring-indigo-400/30">
{Math.round(shownTrainingProgress)}%
</span>
</div>
<div className="grid grid-cols-5 gap-2 text-[11px]">
<div className="rounded-lg bg-indigo-50 px-2 py-1.5 text-center text-indigo-900 ring-1 ring-indigo-100 dark:bg-indigo-500/10 dark:text-indigo-100 dark:ring-indigo-400/20">
<div className="text-[9px] font-semibold uppercase tracking-wide opacity-70">
Laufzeit
</div>
<div className="mt-0.5 font-bold">
{formatDuration(shownTrainingDurationMs)}
</div>
</div>
<div className="rounded-lg bg-indigo-50 px-2 py-1.5 text-center text-indigo-900 ring-1 ring-indigo-100 dark:bg-indigo-500/10 dark:text-indigo-100 dark:ring-indigo-400/20">
<div className="text-[9px] font-semibold uppercase tracking-wide opacity-70">
Restzeit
</div>
<div className="mt-0.5 font-bold">
{shownTrainingEtaMs > 0 ? `ca. ${formatDuration(shownTrainingEtaMs)}` : '—'}
</div>
</div>
<div className="rounded-lg bg-indigo-50 px-2 py-1.5 text-center text-indigo-900 ring-1 ring-indigo-100 dark:bg-indigo-500/10 dark:text-indigo-100 dark:ring-indigo-400/20">
<div className="text-[9px] font-semibold uppercase tracking-wide opacity-70">
Epoche
</div>
<div className="mt-0.5 font-bold">
{shownTrainingEpochText
? shownTrainingEpochText.replace(/^Epoche\s+/i, '')
: '—'}
</div>
</div>
<div className="rounded-lg bg-indigo-50 px-2 py-1.5 text-center text-indigo-900 ring-1 ring-indigo-100 dark:bg-indigo-500/10 dark:text-indigo-100 dark:ring-indigo-400/20">
<div className="text-[9px] font-semibold uppercase tracking-wide opacity-70">
Ø / Epoche
</div>
<div className="mt-0.5 font-bold">
{estimatedEpochMs > 0 ? formatDuration(estimatedEpochMs) : '—'}
</div>
</div>
<div className="rounded-lg bg-emerald-50 px-2 py-1.5 text-center text-emerald-900 ring-1 ring-emerald-100 dark:bg-emerald-500/10 dark:text-emerald-100 dark:ring-emerald-400/20">
<div className="text-[9px] font-semibold uppercase tracking-wide opacity-70">
mAP50
</div>
<div className="mt-0.5 font-bold tabular-nums">
{formatMapPercent(trainingStatus?.training?.map50) || '—'}
</div>
</div>
</div>
</div>
) : null}
</div>
<div className="flex min-h-[260px] flex-col rounded-xl border border-gray-200 bg-white shadow-sm dark:border-white/10 dark:bg-gray-900/80 lg:hidden">
<div className="grid grid-cols-3 gap-1 border-b border-gray-200 p-1 dark:border-white/10">
{[
{ key: 'labels', label: 'Labels' },
{ key: 'boxes', label: 'Boxen', count: correctionBoxes.length },
{ key: 'training', label: 'Training' },
].map((item) => {
const active = mobilePanel === item.key
return (
<button
key={item.key}
type="button"
onClick={() => setMobilePanel(item.key as 'labels' | 'boxes' | 'training')}
className={[
'rounded-md px-2 py-2 text-xs font-semibold transition',
active
? [
'bg-indigo-100 text-indigo-900 shadow-sm ring-1 ring-indigo-200',
'dark:bg-indigo-600 dark:text-white dark:ring-indigo-500',
].join(' ')
: 'text-gray-600 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-white/10',
].join(' ')}
>
<span className="inline-flex items-center justify-center gap-1.5">
<span>{item.label}</span>
{typeof item.count === 'number' ? (
<span
className={[
'min-w-5 rounded-full px-1.5 py-0.5 text-[10px] font-black leading-none ring-1',
active
? 'bg-indigo-600 text-white ring-indigo-600 dark:bg-white dark:text-indigo-700 dark:ring-white'
: 'bg-gray-100 text-gray-700 ring-gray-200 dark:bg-white/10 dark:text-gray-200 dark:ring-white/10',
].join(' ')}
aria-label={`${item.count} Boxen`}
>
{item.count}
</span>
) : null}
</span>
</button>
)
})}
</div>
{ /* Rechte Seite */ }
<div
ref={mobileLabelsScrollRef}
className="p-3"
>
{mobilePanel === 'labels' ? (
<div
className={[
'space-y-3',
uiLocked ? 'pointer-events-none opacity-60' : '',
].join(' ')}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-sm font-semibold text-gray-900 dark:text-white">
Korrektur
</div>
<div className="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
Label wählen, dann Box im Bild zeichnen.
</div>
</div>
<span
className={[
'shrink-0 rounded-full px-2.5 py-1 text-[11px] font-bold ring-1',
confidencePillClass(analysisConfidence),
].join(' ')}
title={`Analyse-Confidence aktuelles Bild: ${confidencePercent(analysisConfidence)}`}
>
{confidencePercent(analysisConfidence)}
</span>
</div>
<div
ref={(el) => {
mobileSectionRefs.current.sexPosition = el
}}
>
<CollapsibleSingleLabelSection
title="Sexposition"
values={labels.sexPositions}
value={correction.sexPosition}
score={sample?.prediction.sexPositionScore}
predictionValue={sample?.prediction.sexPosition}
expanded={expandedCorrectionSections.sexPosition}
onExpandedChange={(expanded) =>
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"
/>
</div>
<div
ref={(el) => {
mobileSectionRefs.current.people = el
}}
>
<CollapsibleLabelSection
title="Personen"
values={labels.people}
selected={selectedPeopleLabels}
scores={peopleScores}
activeCounts={peopleBoxCounts}
expanded={expandedCorrectionSections.people}
onExpandedChange={(expanded) =>
toggleMobileCorrectionSection('people', expanded)
}
onToggle={() => {}}
drawLabel={drawLabelForSection(labels.people)}
onDrawLabelChange={setBoxLabel}
disabled={uiLocked}
singleDrawMode
gridClassName="grid grid-cols-2 gap-2"
/>
</div>
<div
ref={(el) => {
mobileSectionRefs.current.bodyParts = el
}}
>
<CollapsibleLabelSection
title="Körperteile"
values={labels.bodyParts}
selected={correction.bodyPartsPresent}
scores={bodyPartScores}
expanded={expandedCorrectionSections.bodyParts}
gridClassName="grid grid-cols-3 gap-2"
onExpandedChange={(expanded) =>
toggleMobileCorrectionSection('bodyParts', expanded)
}
onToggle={(value) =>
setCorrection((p) => {
setHasManualCorrection(true)
return {
...p,
bodyPartsPresent: toggleArrayValue(p.bodyPartsPresent, value),
}
})
}
drawLabel={drawLabelForSection(labels.bodyParts)}
onDrawLabelChange={setBoxLabel}
disabled={uiLocked}
/>
</div>
<div
ref={(el) => {
mobileSectionRefs.current.objects = el
}}
>
<CollapsibleLabelSection
title="Gegenstände"
values={labels.objects}
selected={correction.objectsPresent}
scores={objectScores}
expanded={expandedCorrectionSections.objects}
gridClassName="grid grid-cols-3 gap-2"
onExpandedChange={(expanded) =>
toggleMobileCorrectionSection('objects', expanded)
}
onToggle={(value) =>
setCorrection((p) => {
setHasManualCorrection(true)
return {
...p,
objectsPresent: toggleArrayValue(p.objectsPresent, value),
}
})
}
drawLabel={drawLabelForSection(labels.objects)}
onDrawLabelChange={setBoxLabel}
disabled={uiLocked}
/>
</div>
<div
ref={(el) => {
mobileSectionRefs.current.clothing = el
}}
>
<CollapsibleLabelSection
title="Kleidung"
values={labels.clothing}
selected={correction.clothingPresent}
scores={clothingScores}
expanded={expandedCorrectionSections.clothing}
gridClassName="grid grid-cols-3 gap-2"
onExpandedChange={(expanded) =>
toggleMobileCorrectionSection('clothing', expanded)
}
onToggle={(value) =>
setCorrection((p) => {
setHasManualCorrection(true)
return {
...p,
clothingPresent: toggleArrayValue(p.clothingPresent, value),
}
})
}
drawLabel={drawLabelForSection(labels.clothing)}
onDrawLabelChange={setBoxLabel}
disabled={uiLocked}
/>
</div>
</div>
) : null}
{mobilePanel === 'boxes' ? (
<div>
{detectorBoxesPanel({ compact: true })}
</div>
) : null}
{mobilePanel === 'training' ? (
<div className="space-y-2 text-xs">
<div className="pt-1">
{trainingActionsPanel({ compact: true })}
</div>
</div>
) : null}
</div>
</div>
{/* Rechte Details/Korrektur */}
<aside
className={[
'hidden h-full max-h-full min-h-0 overflow-y-auto overscroll-contain rounded-xl border border-gray-200 bg-white p-3 shadow-sm dark:border-white/10 dark:bg-gray-900/60',
'lg:block',
uiLocked ? 'pointer-events-none opacity-60' : '',
].join(' ')}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-sm font-semibold text-gray-900 dark:text-white">
Korrektur
</div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Diese Werte werden gespeichert, wenn du Korrektur speichern & weiter klickst.
</div>
</div>
<span
className={[
'shrink-0 rounded-full px-2.5 py-1 text-[11px] font-bold ring-1',
confidencePillClass(analysisConfidence),
].join(' ')}
title={`Analyse-Confidence aktuelles Bild: ${confidencePercent(analysisConfidence)}`}
>
{confidencePercent(analysisConfidence)}
</span>
</div>
<div className="mt-3 space-y-3">
<CollapsibleSingleLabelSection
title="Sexposition"
values={labels.sexPositions}
value={correction.sexPosition}
score={sample?.prediction.sexPositionScore}
predictionValue={sample?.prediction.sexPosition}
expanded={expandedCorrectionSections.sexPosition}
onExpandedChange={(expanded) =>
toggleCorrectionSection('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"
/>
<CollapsibleLabelSection
title="Personen"
values={labels.people}
selected={selectedPeopleLabels}
scores={peopleScores}
activeCounts={peopleBoxCounts}
expanded={expandedCorrectionSections.people}
onExpandedChange={(expanded) =>
toggleCorrectionSection('people', expanded)
}
onToggle={() => {}}
drawLabel={drawLabelForSection(labels.people)}
onDrawLabelChange={setBoxLabel}
disabled={uiLocked}
singleDrawMode
gridClassName="grid grid-cols-2 gap-2"
/>
<CollapsibleLabelSection
title="Körperteile"
values={labels.bodyParts}
selected={correction.bodyPartsPresent}
scores={bodyPartScores}
expanded={expandedCorrectionSections.bodyParts}
gridClassName="grid grid-cols-3 gap-2"
onExpandedChange={(expanded) =>
toggleCorrectionSection('bodyParts', expanded)
}
onToggle={(value) =>
setCorrection((p) => {
setHasManualCorrection(true)
return {
...p,
bodyPartsPresent: toggleArrayValue(p.bodyPartsPresent, value),
}
})
}
drawLabel={drawLabelForSection(labels.bodyParts)}
onDrawLabelChange={setBoxLabel}
disabled={uiLocked}
/>
<CollapsibleLabelSection
title="Gegenstände"
values={labels.objects}
selected={correction.objectsPresent}
scores={objectScores}
expanded={expandedCorrectionSections.objects}
gridClassName="grid grid-cols-3 gap-2"
onExpandedChange={(expanded) =>
toggleCorrectionSection('objects', expanded)
}
onToggle={(value) =>
setCorrection((p) => {
setHasManualCorrection(true)
return {
...p,
objectsPresent: toggleArrayValue(p.objectsPresent, value),
}
})
}
drawLabel={drawLabelForSection(labels.objects)}
onDrawLabelChange={setBoxLabel}
disabled={uiLocked}
/>
<CollapsibleLabelSection
title="Kleidung"
values={labels.clothing}
selected={correction.clothingPresent}
scores={clothingScores}
expanded={expandedCorrectionSections.clothing}
gridClassName="grid grid-cols-3 gap-2"
onExpandedChange={(expanded) =>
toggleCorrectionSection('clothing', expanded)
}
onToggle={(value) =>
setCorrection((p) => {
setHasManualCorrection(true)
return {
...p,
clothingPresent: toggleArrayValue(p.clothingPresent, value),
}
})
}
drawLabel={drawLabelForSection(labels.clothing)}
onDrawLabelChange={setBoxLabel}
disabled={uiLocked}
/>
</div>
</aside>
</div>
<Modal
open={trainingStartModalOpen}
onClose={() => setTrainingStartModalOpen(false)}
title="Training starten"
width="max-w-xl"
footer={
<>
<Button
type="button"
size="sm"
variant="secondary"
onClick={() => setTrainingStartModalOpen(false)}
>
Abbrechen
</Button>
<Button
type="button"
size="sm"
variant="primary"
disabled={!canConfirmTrainingStart || trainingRunning}
onClick={startTrainingFromModal}
>
Training starten
</Button>
</>
}
>
<div className="space-y-4 px-4 pb-4 pt-3 sm:px-6 sm:pb-6 sm:pt-4">
<div className="grid gap-2 sm:grid-cols-2">
<button
type="button"
onClick={() => setTrainingStartMode('full')}
aria-pressed={trainingStartMode === 'full'}
className={[
'rounded-xl border px-3 py-2.5 text-left transition',
trainingStartMode === 'full'
? 'border-indigo-300 bg-indigo-50 text-indigo-950 ring-2 ring-indigo-500/20 dark:border-indigo-400/40 dark:bg-indigo-500/15 dark:text-indigo-100'
: 'border-gray-200 bg-white text-gray-800 hover:bg-gray-50 dark:border-white/10 dark:bg-white/5 dark:text-gray-100 dark:hover:bg-white/10',
].join(' ')}
>
<span className="block text-sm font-bold">Vollständig</span>
<span className="mt-1 block text-xs leading-snug text-gray-500 dark:text-gray-400">
Startet alle verfügbaren Trainings und überspringt intern nur nicht bereite Teile.
</span>
</button>
<button
type="button"
onClick={() => setTrainingStartMode('custom')}
aria-pressed={trainingStartMode === 'custom'}
className={[
'rounded-xl border px-3 py-2.5 text-left transition',
trainingStartMode === 'custom'
? 'border-indigo-300 bg-indigo-50 text-indigo-950 ring-2 ring-indigo-500/20 dark:border-indigo-400/40 dark:bg-indigo-500/15 dark:text-indigo-100'
: 'border-gray-200 bg-white text-gray-800 hover:bg-gray-50 dark:border-white/10 dark:bg-white/5 dark:text-gray-100 dark:hover:bg-white/10',
].join(' ')}
>
<span className="block text-sm font-bold">Einzeln auswählen</span>
<span className="mt-1 block text-xs leading-snug text-gray-500 dark:text-gray-400">
Trainiert nur die ausgewählten Modelle.
</span>
</button>
</div>
<div className="rounded-xl bg-gray-50 px-3 py-2.5 ring-1 ring-gray-200 dark:bg-white/[0.04] dark:ring-white/10">
<div className="flex items-center justify-between gap-3">
<span className="min-w-0 text-xs font-semibold text-gray-600 dark:text-gray-300">
Geschätzte Gesamtdauer
</span>
<span className="shrink-0 rounded-full bg-indigo-50 px-2.5 py-1 text-xs font-bold text-indigo-700 ring-1 ring-indigo-200 dark:bg-indigo-500/15 dark:text-indigo-100 dark:ring-indigo-400/30">
{trainingStartTotalEstimateText}
</span>
</div>
<div className="mt-1 truncate text-[11px] text-gray-500 dark:text-gray-400">
{plannedTrainingTargets.length > 0
? `${plannedTrainingTargets.length} Training${plannedTrainingTargets.length === 1 ? '' : 's'} eingeplant`
: 'Noch kein bereites Training ausgewählt'}
</div>
<div className="mt-0.5 truncate text-[11px] text-gray-500 dark:text-gray-400">
{trainingEstimateSettings
? trainingEstimateRuntimeLabel
: 'Trainingsmodus wird geladen...'}
</div>
</div>
<div className="space-y-2">
{trainingStartOptions.map((option) => {
const fullMode = trainingStartMode === 'full'
const selected =
!fullMode && trainingStartTargets.includes(option.key)
const disabled = fullMode || !option.ready
const Icon = option.icon
return (
<button
key={option.key}
type="button"
disabled={disabled}
aria-pressed={selected}
onClick={() => {
if (trainingStartMode !== 'custom' || !option.ready) return
toggleTrainingStartTarget(option.key)
}}
className={[
'flex w-full gap-3 rounded-xl border px-3 py-2.5 text-left transition ring-1',
fullMode
? 'border-gray-200 bg-gray-50 opacity-55 ring-transparent dark:border-white/10 dark:bg-white/[0.03]'
: selected
? 'border-indigo-300 bg-indigo-50 ring-indigo-500/20 dark:border-indigo-400/40 dark:bg-indigo-500/15 dark:ring-indigo-400/20'
: option.ready
? 'border-gray-200 bg-white ring-transparent dark:border-white/10 dark:bg-white/5'
: 'border-gray-200 bg-gray-50 opacity-70 ring-transparent dark:border-white/10 dark:bg-white/[0.03]',
!fullMode && option.ready
? 'cursor-pointer hover:border-indigo-200 hover:bg-indigo-50/60 hover:ring-indigo-500/10 dark:hover:border-indigo-400/30 dark:hover:bg-indigo-500/10'
: option.ready
? 'cursor-default'
: 'cursor-not-allowed',
].join(' ')}
>
<span
className={[
'mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-lg ring-1 transition',
fullMode
? 'bg-gray-100 text-gray-400 ring-gray-200 dark:bg-white/5 dark:text-white/35 dark:ring-white/10'
: selected
? 'bg-indigo-600 text-white ring-indigo-500 dark:bg-indigo-500 dark:ring-indigo-300/30'
: option.ready
? 'bg-gray-50 text-gray-700 ring-gray-200 dark:bg-white/10 dark:text-gray-100 dark:ring-white/10'
: 'bg-gray-100 text-gray-400 ring-gray-200 dark:bg-white/5 dark:text-white/35 dark:ring-white/10',
].join(' ')}
aria-hidden="true"
>
<Icon className="h-5 w-5" />
</span>
<span className="min-w-0 flex-1">
<span className="flex items-start justify-between gap-2">
<span className="min-w-0">
<span className="block truncate text-sm font-bold text-gray-900 dark:text-white">
{option.label}
</span>
<span className="mt-1 block text-xs leading-snug text-gray-500 dark:text-gray-400">
{option.description}
</span>
</span>
<span className="flex shrink-0 items-center gap-1.5">
<span className="rounded-full bg-gray-50 px-2 py-0.5 text-[10px] font-bold text-gray-600 ring-1 ring-gray-200 dark:bg-white/10 dark:text-gray-200 dark:ring-white/10">
{option.estimateText}
</span>
<span
className={[
'rounded-full px-2 py-0.5 text-[10px] font-bold ring-1',
option.ready
? 'bg-emerald-50 text-emerald-700 ring-emerald-200 dark:bg-emerald-500/15 dark:text-emerald-100 dark:ring-emerald-400/30'
: 'bg-amber-50 text-amber-800 ring-amber-200 dark:bg-amber-500/15 dark:text-amber-100 dark:ring-amber-400/30',
].join(' ')}
>
{option.ready ? 'bereit' : 'fehlt'}
</span>
</span>
</span>
<span className="mt-1.5 block truncate text-[11px] font-medium text-gray-500 dark:text-gray-400">
{option.ready ? option.detail : option.blockedText}
</span>
</span>
</button>
)
})}
</div>
{trainingStartMode === 'custom' && selectedTrainingTargets.length === 0 ? (
<div className="rounded-lg bg-amber-50 px-3 py-2 text-xs font-medium text-amber-900 ring-1 ring-amber-200 dark:bg-amber-500/10 dark:text-amber-100 dark:ring-amber-400/30">
Wähle mindestens ein bereites Training aus.
</div>
) : null}
</div>
</Modal>
<TrainingStatsModal
open={statsModalOpen}
onClose={() => setStatsModalOpen(false)}
stats={trainingStats}
history={trainingHistory}
loading={trainingStatsLoading}
error={trainingStatsError}
feedbackCount={feedbackCount}
requiredCount={requiredCount}
/>
<TrainingFeedbackHistoryModal
open={feedbackModalOpen}
onClose={() => 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,
})
}}
/>
</div>
)
}