9771 lines
340 KiB
TypeScript
9771 lines
340 KiB
TypeScript
// 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>
|
||
)
|
||
}
|