nsfwapp/frontend/src/components/ui/TrainingTab.tsx
2026-05-15 11:14:06 +02:00

6039 lines
204 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 LoadingSpinner from './LoadingSpinner'
import { formatDuration } from './formatters'
import {
ArrowPathIcon,
ArrowsPointingInIcon,
ArrowsPointingOutIcon,
BoltIcon,
CheckIcon,
ForwardIcon,
InboxArrowDownIcon,
TrashIcon,
XCircleIcon,
} from '@heroicons/react/20/solid'
import { getSegmentLabelItem } from './Icons'
import Modal from './Modal'
import { useNotify } from './notify'
import { createPortal } from 'react-dom'
import TrainingFeedbackHistoryModal from './TrainingFeedbackHistoryModal'
type DrawingTrainingBox = TrainingBox & {
startX: number
startY: number
}
type ScoredLabel = {
label: string
score: number
}
type TrainingDetectorStatus = {
trainCount: number
valCount: number
requiredTrain: number
requiredVal: number
datasetReady: boolean
dataReady: boolean
modelExists: boolean
modelPath?: string
source?: string
}
type TrainingStatus = {
feedbackCount: number
requiredCount: number
canTrain: boolean
training?: TrainingJobStatus
detector?: TrainingDetectorStatus
}
type TrainingJobStatus = {
running: boolean
progress: number
step: string
message?: string
error?: string
startedAt?: string
finishedAt?: string
durationMs?: number
stage?: string
epoch?: number
epochs?: number
}
type TrainingPrediction = {
modelAvailable: boolean
source?: string
sexPosition: string
sexPositionScore: number
peoplePresent: ScoredLabel[]
bodyPartsPresent: ScoredLabel[]
objectsPresent: ScoredLabel[]
clothingPresent: ScoredLabel[]
boxes?: TrainingBox[]
}
type TrainingSample = {
sampleId: string
frameUrl: string
sourceFile: string
sourcePath?: string
sourceSizeBytes?: number
second: number
createdAt: string
uncertaintyScore?: number
prediction: TrainingPrediction
}
type TrainingLabels = {
people: string[]
sexPositions: string[]
bodyParts: string[]
objects: string[]
clothing: string[]
}
type CorrectionState = {
sexPosition: string
peoplePresent: string[]
bodyPartsPresent: string[]
objectsPresent: string[]
clothingPresent: string[]
boxes: TrainingBox[]
}
type TrainingBox = {
label: string
score?: number
x: number
y: number
w: number
h: number
}
type BoxInteraction =
| {
type: 'move'
index: number
startX: number
startY: number
original: TrainingBox
}
| {
type: 'resize'
index: number
handle: 'nw' | 'ne' | 'sw' | 'se'
startX: number
startY: number
original: TrainingBox
}
type MagnifierState = {
visible: boolean
clientX: number
clientY: number
imageX: number
imageY: number
}
type PendingTrainingVideoImport = {
jobId?: string
output: string
sourceFile?: string
count?: number
}
type TrainingConfidence = {
score: number
level: 'none' | 'low' | 'mid' | 'high'
label: string
}
type TrainingLabelStat = {
label: string
count: number
confidence?: TrainingConfidence
}
type TrainingStats = {
feedbackCount: number
acceptedCount: number
correctedCount: number
sampleCount: number
boxCount: number
modelAvailable: boolean
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
correction?: CorrectionState
notes?: string
}
type TrainingFeedbackListResponse = {
ok: boolean
items: TrainingAnnotation[]
total: number
limit: number
offset: number
hasMore: boolean
}
type TrainingSampleMode = 'random' | 'uncertain'
type FeedbackFilter = 'all' | 'accepted' | 'corrected'
function backendText(data: any, fallback: string) {
return String(
data?.message ||
data?.error ||
data?.detail ||
fallback
).trim()
}
function trainingDurationMs(job?: TrainingJobStatus | null) {
const direct = Number(job?.durationMs)
if (Number.isFinite(direct) && direct > 0) {
return direct
}
const started = job?.startedAt ? Date.parse(job.startedAt) : NaN
const finished = job?.finishedAt ? Date.parse(job.finishedAt) : NaN
if (!Number.isFinite(started) || !Number.isFinite(finished)) {
return 0
}
return Math.max(0, finished - started)
}
function countPercent(count: number, total: number) {
if (!Number.isFinite(count) || !Number.isFinite(total) || total <= 0) return '0%'
return `${Math.round((count / total) * 100)}%`
}
function confidencePercent(confidence?: TrainingConfidence | null) {
const score = Number(confidence?.score)
if (!Number.isFinite(score)) return '0%'
return `${Math.round(clamp01(score) * 100)}%`
}
function confidenceLabel(confidence?: TrainingConfidence | null) {
return confidence?.label || 'Keine'
}
function confidencePillClass(confidence?: TrainingConfidence | null) {
switch (confidence?.level) {
case 'high':
return 'bg-emerald-50 text-emerald-800 ring-emerald-200 dark:bg-emerald-500/15 dark:text-emerald-100 dark:ring-emerald-400/30'
case 'mid':
return 'bg-yellow-50 text-yellow-900 ring-yellow-200 dark:bg-yellow-500/15 dark:text-yellow-100 dark:ring-yellow-400/30'
case 'low':
return 'bg-red-50 text-red-800 ring-red-200 dark:bg-red-500/15 dark:text-red-100 dark:ring-red-400/30'
default:
return 'bg-gray-50 text-gray-700 ring-gray-200 dark:bg-white/5 dark:text-gray-200 dark:ring-white/10'
}
}
function averageCategoryConfidence(values: TrainingLabelStat[]) {
const scores = values
.map((item) => Number(item.confidence?.score))
.filter((score) => Number.isFinite(score))
if (scores.length === 0) {
return undefined
}
const avg = scores.reduce((sum, score) => sum + clamp01(score), 0) / scores.length
return confidenceFromScore(avg)
}
function confidenceFromScore(score: number): TrainingConfidence {
const safeScore = clamp01(Number(score))
if (safeScore >= 0.75) {
return {
score: safeScore,
level: 'high',
label: 'Hoch',
}
}
if (safeScore >= 0.45) {
return {
score: safeScore,
level: 'mid',
label: 'Mittel',
}
}
if (safeScore > 0) {
return {
score: safeScore,
level: 'low',
label: 'Niedrig',
}
}
return {
score: 0,
level: 'none',
label: 'Keine',
}
}
function currentAnalysisConfidence(prediction?: TrainingPrediction | null): TrainingConfidence {
if (!prediction?.modelAvailable) {
return confidenceFromScore(0)
}
const scores: number[] = []
const addScore = (value: unknown) => {
const n = Number(value)
if (!Number.isFinite(n)) return
if (n <= 0) return
scores.push(clamp01(n))
}
// YOLO-Detector: Sexposition als Full-Frame-/Positions-Label
if (
prediction.sexPosition &&
prediction.sexPosition !== 'unknown'
) {
addScore(prediction.sexPositionScore)
}
// Detector: sichtbare Boxen sind der wichtigste Signalträger.
for (const box of prediction.boxes ?? []) {
addScore(box.score)
}
// Fallback, falls Labels Scores haben, aber keine Boxen vorhanden sind.
if ((prediction.boxes ?? []).length === 0) {
for (const item of prediction.bodyPartsPresent ?? []) {
addScore(item.score)
}
for (const item of prediction.objectsPresent ?? []) {
addScore(item.score)
}
for (const item of prediction.clothingPresent ?? []) {
addScore(item.score)
}
}
if (scores.length === 0) {
return confidenceFromScore(0)
}
const avg = scores.reduce((sum, score) => sum + score, 0) / scores.length
return confidenceFromScore(avg)
}
const emptyLabels: TrainingLabels = {
people: [],
sexPositions: ['unknown'],
bodyParts: [],
objects: [],
clothing: [],
}
function percent(v: number) {
if (!Number.isFinite(v)) return '—'
return `${Math.round(v * 100)}%`
}
function scoreLevel(score?: number | null): 'none' | 'low' | 'mid' | 'high' {
const n = Number(score)
if (!Number.isFinite(n)) return 'none'
if (n < 0.5) return 'low'
if (n < 0.75) return 'mid'
return 'high'
}
function scoreBorderClass(score?: number | null, opts?: { draft?: boolean }) {
if (opts?.draft) return 'border-amber-400'
switch (scoreLevel(score)) {
case 'low':
return 'border-red-500'
case 'mid':
return 'border-yellow-400'
case 'high':
return 'border-emerald-400'
default:
return 'border-gray-300'
}
}
function scoreRingClass(score?: number | null, opts?: { draft?: boolean }) {
if (opts?.draft) return 'ring-amber-500'
switch (scoreLevel(score)) {
case 'low':
return 'ring-red-500'
case 'mid':
return 'ring-yellow-400'
case 'high':
return 'ring-emerald-500'
default:
return 'ring-gray-400'
}
}
function scoreDetectionPillClass(score?: number | null) {
switch (scoreLevel(score)) {
case 'low':
return 'bg-red-50 text-red-800 ring-red-200 dark:bg-red-500/15 dark:text-red-100 dark:ring-red-400/30'
case 'mid':
return 'bg-yellow-50 text-yellow-900 ring-yellow-200 dark:bg-yellow-500/15 dark:text-yellow-100 dark:ring-yellow-400/30'
case 'high':
return 'bg-emerald-50 text-emerald-800 ring-emerald-200 dark:bg-emerald-500/15 dark:text-emerald-100 dark:ring-emerald-400/30'
default:
return 'bg-gray-50 text-gray-700 ring-gray-200 dark:bg-white/5 dark:text-gray-200 dark:ring-white/10'
}
}
function detectorBoxAppearance(label: string) {
const clean = String(label || '').trim()
if (clean === 'person_female' || clean === 'female_person') {
return {
activeSurface:
'dark:bg-pink-500/10 dark:shadow-[0_0_0_1px_rgba(244,114,182,0.20),0_10px_24px_rgba(2,6,23,0.38)]',
idleHover: 'dark:hover:bg-pink-500/[0.05]',
line: 'bg-pink-400',
lineHover: 'dark:group-hover:bg-pink-400/70',
iconActive:
'dark:bg-pink-500/15 dark:text-pink-100 dark:ring-pink-400/25',
iconIdle:
'dark:text-pink-200/80 dark:group-hover:bg-pink-500/10 dark:group-hover:text-pink-100 dark:group-hover:ring-pink-400/20',
selectedText: 'dark:text-pink-300',
}
}
if (clean === 'person_male' || clean === 'male_person') {
return {
activeSurface:
'dark:bg-sky-500/10 dark:shadow-[0_0_0_1px_rgba(56,189,248,0.20),0_10px_24px_rgba(2,6,23,0.38)]',
idleHover: 'dark:hover:bg-sky-500/[0.05]',
line: 'bg-sky-400',
lineHover: 'dark:group-hover:bg-sky-400/70',
iconActive:
'dark:bg-sky-500/15 dark:text-sky-100 dark:ring-sky-400/25',
iconIdle:
'dark:text-sky-200/80 dark:group-hover:bg-sky-500/10 dark:group-hover:text-sky-100 dark:group-hover:ring-sky-400/20',
selectedText: 'dark:text-sky-300',
}
}
return {
activeSurface:
'dark:bg-indigo-500/10 dark:shadow-[0_0_0_1px_rgba(129,140,248,0.20),0_10px_24px_rgba(2,6,23,0.38)]',
idleHover: 'dark:hover:bg-indigo-500/[0.05]',
line: 'bg-indigo-400',
lineHover: 'dark:group-hover:bg-indigo-400/70',
iconActive:
'dark:bg-indigo-500/15 dark:text-indigo-100 dark:ring-indigo-400/25',
iconIdle:
'dark:text-indigo-200/80 dark:group-hover:bg-indigo-500/10 dark:group-hover:text-indigo-100 dark:group-hover:ring-indigo-400/20',
selectedText: 'dark:text-indigo-300',
}
}
function normalizeMovedBox(box: TrainingBox): TrainingBox {
const w = clamp01(box.w)
const h = clamp01(box.h)
return {
...box,
x: Math.max(0, Math.min(1 - w, Number(box.x) || 0)),
y: Math.max(0, Math.min(1 - h, Number(box.y) || 0)),
w,
h,
}
}
function clampPercent(v: number) {
if (!Number.isFinite(v)) return 0
return Math.max(0, Math.min(100, v))
}
function toggleArrayValue(arr: string[], value: string) {
return arr.includes(value)
? arr.filter((x) => x !== value)
: [...arr, value]
}
function clamp01(v: number) {
if (!Number.isFinite(v)) return 0
return Math.max(0, Math.min(1, v))
}
function snap01(v: number, epsilon = 0.006) {
const n = clamp01(v)
if (n <= epsilon) return 0
if (n >= 1 - epsilon) return 1
return n
}
function normalizeBox(box: TrainingBox): TrainingBox {
const x = clamp01(box.x)
const y = clamp01(box.y)
const w = clamp01(Math.min(box.w, 1 - x))
const h = clamp01(Math.min(box.h, 1 - y))
return {
...box,
x,
y,
w,
h,
}
}
function boxGeometryChanged(a: TrainingBox, b: TrainingBox) {
const epsilon = 0.0005
return (
Math.abs(a.x - b.x) > epsilon ||
Math.abs(a.y - b.y) > epsilon ||
Math.abs(a.w - b.w) > epsilon ||
Math.abs(a.h - b.h) > epsilon
)
}
function markBoxCorrected(box: TrainingBox): TrainingBox {
const { score: _oldScore, ...boxWithoutScore } = box
return boxWithoutScore
}
function uniqStrings(values: string[]) {
const seen = new Set<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: p?.sexPosition || 'unknown',
peoplePresent: (p?.peoplePresent ?? []).map((x) => x.label),
bodyPartsPresent: (p?.bodyPartsPresent ?? []).map((x) => x.label),
objectsPresent: (p?.objectsPresent ?? []).map((x) => x.label),
clothingPresent: (p?.clothingPresent ?? []).map((x) => x.label),
boxes,
}
}
function applyBoxLabelToCorrection(
state: CorrectionState,
label: string,
labels: TrainingLabels
): CorrectionState {
const clean = String(label || '').trim()
if (!clean) return state
if (labels.people.includes(clean)) {
return {
...state,
peoplePresent: state.peoplePresent.includes(clean)
? state.peoplePresent
: [...state.peoplePresent, clean],
}
}
if (labels.bodyParts.includes(clean)) {
return {
...state,
bodyPartsPresent: state.bodyPartsPresent.includes(clean)
? state.bodyPartsPresent
: [...state.bodyPartsPresent, clean],
}
}
if (labels.objects.includes(clean)) {
return {
...state,
objectsPresent: state.objectsPresent.includes(clean)
? state.objectsPresent
: [...state.objectsPresent, clean],
}
}
if (labels.clothing.includes(clean)) {
return {
...state,
clothingPresent: state.clothingPresent.includes(clean)
? state.clothingPresent
: [...state.clothingPresent, clean],
}
}
return state
}
function removeBoxLabelFromCorrection(
state: CorrectionState,
label: string,
labels: TrainingLabels
): CorrectionState {
const clean = String(label || '').trim()
if (!clean) return state
const remainingBoxesWithSameLabel = (state.boxes ?? []).some(
(box) => String(box.label || '').trim() === clean
)
// Badge nur abwählen, wenn keine weitere Box mit diesem Label existiert.
if (remainingBoxesWithSameLabel) return state
if (labels.people.includes(clean)) {
return {
...state,
peoplePresent: state.peoplePresent.filter((x) => x !== clean),
}
}
if (labels.bodyParts.includes(clean)) {
return {
...state,
bodyPartsPresent: state.bodyPartsPresent.filter((x) => x !== clean),
}
}
if (labels.objects.includes(clean)) {
return {
...state,
objectsPresent: state.objectsPresent.filter((x) => x !== clean),
}
}
if (labels.clothing.includes(clean)) {
return {
...state,
clothingPresent: state.clothingPresent.filter((x) => x !== clean),
}
}
return state
}
function removeBoxFromCorrection(
state: CorrectionState,
index: number,
labels: TrainingLabels
): CorrectionState {
const boxes = state.boxes ?? []
const removed = boxes[index]
if (!removed) return state
const removedLabel = String(removed.label || '').trim()
const next: CorrectionState = {
...state,
boxes: boxes.filter((_, i) => i !== index),
}
return removeBoxLabelFromCorrection(next, removedLabel, labels)
}
function changeBoxLabelInCorrection(
state: CorrectionState,
index: number,
nextLabel: string,
labels: TrainingLabels
): CorrectionState {
const boxes = state.boxes ?? []
const currentBox = boxes[index]
const cleanNextLabel = String(nextLabel || '').trim()
if (!currentBox || !cleanNextLabel) return state
const oldLabel = String(currentBox.label || '').trim()
if (oldLabel === cleanNextLabel) return state
let next: CorrectionState = {
...state,
boxes: boxes.map((box, i) => {
if (i !== index) return box
const { score: _oldScore, ...boxWithoutScore } = box
return {
...boxWithoutScore,
label: cleanNextLabel,
}
}),
}
next = removeBoxLabelFromCorrection(next, oldLabel, labels)
next = applyBoxLabelToCorrection(next, cleanNextLabel, labels)
return next
}
function sortLabelList(values?: string[], opts?: { keepUnknownFirst?: boolean }) {
const list = [...(values ?? [])].sort((a, b) =>
a.localeCompare(b, undefined, { sensitivity: 'base' })
)
if (!opts?.keepUnknownFirst) return list
return [
...list.filter((x) => x === 'unknown'),
...list.filter((x) => x !== 'unknown'),
]
}
function sortTrainingLabels(input: Partial<TrainingLabels> | null | undefined): TrainingLabels {
return {
people: sortLabelList(input?.people),
sexPositions: sortLabelList(input?.sexPositions, { keepUnknownFirst: true }),
bodyParts: sortLabelList(input?.bodyParts),
objects: sortLabelList(input?.objects),
clothing: sortLabelList(input?.clothing),
}
}
function TrainingStageOverlay(props: {
mode: 'training' | 'analysis'
text?: string
progress?: number
backgroundUrl?: string
visible?: boolean
}) {
const progress = clampPercent(props.progress ?? 0)
const isTraining = props.mode === 'training'
const hasBackground = !isTraining && Boolean(props.backgroundUrl)
const visible = props.visible ?? true
const [backgroundVisible, setBackgroundVisible] = useState(false)
useEffect(() => {
if (!props.backgroundUrl || isTraining) {
setBackgroundVisible(false)
return
}
setBackgroundVisible(false)
const frame = window.requestAnimationFrame(() => {
setBackgroundVisible(true)
})
return () => window.cancelAnimationFrame(frame)
}, [props.backgroundUrl, isTraining])
const title = isTraining ? 'Training läuft…' : 'Analyse läuft…'
const fallbackText = isTraining
? 'Bitte warten. Die Oberfläche ist währenddessen gesperrt.'
: 'Bild wird erstellt und analysiert. Bitte warten.'
return (
<div
className={[
'absolute inset-0 z-[500] flex items-center justify-center overflow-hidden rounded-md bg-black 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 ? (
<img
key={props.backgroundUrl}
src={props.backgroundUrl}
alt=""
aria-hidden="true"
draggable={false}
className={[
'absolute inset-0 z-0 h-full w-full scale-105 object-cover blur-[1px]',
'transition-opacity duration-500 ease-out will-change-opacity motion-reduce:transition-none',
backgroundVisible ? 'opacity-80' : 'opacity-0',
].join(' ')}
/>
) : 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 flex-col items-center justify-center">
<LoadingSpinner
size="lg"
className="text-white"
srLabel={title}
/>
<div className="mt-3 text-sm font-semibold">
{title}
</div>
<div className="mt-1 max-w-[260px] px-4 text-xs text-white/80">
{props.text || fallbackText}
</div>
<div className="mt-3 h-2 w-48 overflow-hidden rounded-full bg-white/20">
<div
className={[
'h-full rounded-full transition-all duration-500',
isTraining ? 'bg-indigo-400' : 'bg-emerald-400',
].join(' ')}
style={{ width: `${progress}%` }}
/>
</div>
<div className="mt-1 text-[11px] text-white/70">
{Math.round(progress)}%
</div>
</div>
</div>
)
}
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>
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 active = props.selected.includes(value)
const item = getSegmentLabelItem(value)
const Icon = item.icon
const score = props.scores?.[value]
const hasScore = typeof score === 'number' && Number.isFinite(score)
const isDrawLabel = props.drawLabel === value
return (
<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>
{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 = String(props.value || 'unknown').trim() || 'unknown'
const selectedItem = getSegmentLabelItem(currentValue)
const SelectedIcon = selectedItem.icon
const shown = props.expanded
const hasSelection = currentValue !== '' && currentValue !== 'unknown'
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>
expanded: boolean
onExpandedChange: (expanded: boolean) => void
onToggle: (value: string) => void
drawLabel?: string
onDrawLabelChange?: (value: string) => void
disabled?: boolean
singleDrawMode?: boolean
gridClassName?: string
}) {
const cleanDrawLabel = String(props.drawLabel || '').trim()
const hasDrawLabelInSection =
cleanDrawLabel !== '' && props.values.includes(cleanDrawLabel)
const activeCount = props.singleDrawMode
? Math.max(props.selected.length, hasDrawLabelInSection ? 1 : 0)
: props.selected.length
const hasActiveItems = activeCount > 0
const shown = props.expanded
return (
<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}
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
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 totalFeedback = stats?.feedbackCount ?? props.feedbackCount
const boxCount = stats?.boxCount ?? 0
const sampleCount = stats?.sampleCount ?? 0
const overallConfidence = stats?.confidence
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">
{stats?.modelAvailable
? '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-4 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">
Boxen
</div>
<div className="mt-0.5 text-sm font-black text-gray-900 dark:text-white">
{boxCount}
</div>
</div>
</div>
</div>
</div>
{/* Desktop/Tablet: ausführliche Karten */}
<div className="hidden sm:grid sm:grid-cols-4 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">
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">
{stats?.modelAvailable
? 'Trainiertes Modell verfügbar'
: 'Noch kein trainiertes Modell verfügbar'}
</div>
<div className="mt-1 text-xs leading-relaxed text-indigo-800/80 dark:text-indigo-100/70">
{stats?.modelAvailable
? 'Die aktuellen Trainingsdaten können bereits von einem Modell genutzt werden.'
: 'Sammle weiter Feedback und starte anschließend das Training.'}
</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>
{/* 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.correction) {
return item.correction
}
return predictionToCorrection(annotationToTrainingSample(item))
}
const FEEDBACK_PAGE_SIZE = 10
export default function TrainingTab(props: {
onTrainingRunningChange?: (running: boolean) => void
onImageExpandedChange?: (expanded: boolean) => void
}) {
const [labels, setLabels] = useState<TrainingLabels>(emptyLabels)
const [sample, setSample] = useState<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 [saving, setSaving] = useState(false)
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 [cancellingTraining, setCancellingTraining] = useState(false)
const [trainingStats, setTrainingStats] = useState<TrainingStats | null>(null)
const [trainingStatsLoading, setTrainingStatsLoading] = useState(false)
const [trainingStatsError, setTrainingStatsError] = useState<string | null>(null)
const wasTrainingRunningRef = useRef(false)
const shownTrainingCompletionRef = useRef<string | null>(null)
const [importedSampleQueue, setImportedSampleQueue] = useState<TrainingSample[]>([])
const importedSampleQueueRef = useRef<TrainingSample[]>([])
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 [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(false)
const [frameNaturalSize, setFrameNaturalSize] = useState<{
width: number
height: number
} | null>(null)
const [loadingPreviewUrl, setLoadingPreviewUrl] = useState('')
const [loadingPreviewLoaded, setLoadingPreviewLoaded] = useState(false)
const [loadingPreviewFailed, setLoadingPreviewFailed] = useState(false)
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 loadFeedbackHistoryInitial = useCallback(async (
options: {
query?: string
filter?: FeedbackFilter
} = {}
) => {
const query = options.query ?? ''
const filter = options.filter ?? 'all'
setFeedbackLoading(true)
setFeedbackError(null)
try {
const params = new URLSearchParams()
params.set('limit', String(FEEDBACK_PAGE_SIZE))
params.set('offset', '0')
if (query.trim()) {
params.set('q', query.trim())
}
if (filter !== 'all') {
params.set('filter', filter)
}
const res = await fetch(`/api/training/feedback/list?${params.toString()}`, {
cache: 'no-store',
})
const data = await res.json().catch(() => null) as TrainingFeedbackListResponse | null
if (!res.ok || !data) {
throw new Error(data && 'error' in data ? String((data as any).error) : `HTTP ${res.status}`)
}
setFeedbackItems(data.items)
setFeedbackTotal(data.total)
setFeedbackHasMore(data.hasMore)
setSelectedFeedbackIndex(0)
} catch (e) {
setFeedbackError(e instanceof Error ? e.message : String(e))
} finally {
setFeedbackLoading(false)
}
}, [])
const loadMoreFeedbackHistory = useCallback(async () => {
if (feedbackLoading || feedbackLoadingMore || !feedbackHasMore) return
setFeedbackLoadingMore(true)
setFeedbackError(null)
try {
const params = new URLSearchParams()
params.set('limit', String(FEEDBACK_PAGE_SIZE))
params.set('offset', String(feedbackItems.length))
if (feedbackSearchQuery.trim()) {
params.set('q', feedbackSearchQuery.trim())
}
if (feedbackSearchFilter !== 'all') {
params.set('filter', feedbackSearchFilter)
}
const res = await fetch(`/api/training/feedback/list?${params.toString()}`, {
cache: 'no-store',
})
const data = await res.json().catch(() => null) as TrainingFeedbackListResponse | null
if (!res.ok || !data) {
throw new Error(data && 'error' in data ? String((data as any).error) : `HTTP ${res.status}`)
}
setFeedbackItems((current) => {
const existing = new Set(
current.map((item) => `${item.sampleId}-${item.answeredAt}`)
)
const nextItems = data.items.filter(
(item) => !existing.has(`${item.sampleId}-${item.answeredAt}`)
)
return [...current, ...nextItems]
})
setFeedbackTotal(data.total)
setFeedbackHasMore(data.hasMore)
} catch (e) {
setFeedbackError(e instanceof Error ? e.message : String(e))
} finally {
setFeedbackLoadingMore(false)
}
}, [
feedbackHasMore,
feedbackItems.length,
feedbackLoading,
feedbackLoadingMore,
feedbackSearchFilter,
feedbackSearchQuery,
])
const editFeedbackItem = useCallback((item: TrainingAnnotation) => {
setSample(annotationToTrainingSample(item))
setCorrection(annotationToCorrectionState(item))
setHasManualCorrection(!item.accepted)
setEditingFeedback({
sampleId: item.sampleId,
answeredAt: item.answeredAt,
})
setDrawingBox(null)
setBoxInteraction(null)
setTouchMagnifier(null)
setBoxLabel('')
setActiveBoxIndex(null)
setFeedbackModalOpen(false)
window.requestAnimationFrame(() => {
mobileLabelsScrollRef.current?.scrollTo({
top: 0,
behavior: 'smooth',
})
})
}, [])
const getImageContentRect = useCallback((): ImageContentRect | null => {
const imgEl = frameImageRef.current
if (!imgEl) return null
const rect = imgEl.getBoundingClientRect()
const naturalW = imgEl.naturalWidth
const naturalH = imgEl.naturalHeight
if (
rect.width <= 0 ||
rect.height <= 0 ||
naturalW <= 0 ||
naturalH <= 0
) {
return null
}
// Entspricht object-contain: sichtbarer Bildinhalt innerhalb der img-Box.
const scale = Math.min(rect.width / naturalW, rect.height / naturalH)
const contentW = naturalW * scale
const contentH = naturalH * scale
const left = rect.left + (rect.width - contentW) / 2
const top = rect.top + (rect.height - contentH) / 2
return {
left,
top,
width: contentW,
height: contentH,
right: left + contentW,
bottom: top + contentH,
}
}, [])
const updateImageLayerStyle = useCallback(() => {
const boxEl = imageBoxRef.current
const contentRect = getImageContentRect()
if (!boxEl || !contentRect) {
setImageLayerStyle(null)
return
}
const boxRect = boxEl.getBoundingClientRect()
setImageLayerStyle({
left: contentRect.left - boxRect.left,
top: contentRect.top - boxRect.top,
width: contentRect.width,
height: contentRect.height,
})
}, [getImageContentRect])
const detectorBoxesScrollRef = useRef<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 epochTimingRef = useRef<{
firstEpochAt: number
lastEpoch: number
lastAt: number
}>({
firstEpochAt: 0,
lastEpoch: 0,
lastAt: 0,
})
const etaSmoothingRef = useRef<{
lastAt: number
lastRawEtaMs: number
}>({
lastAt: 0,
lastRawEtaMs: 0,
})
const [trainingNowMs, setTrainingNowMs] = useState(() => Date.now())
const [smoothedTrainingEtaMs, setSmoothedTrainingEtaMs] = useState(0)
const [estimatedEpochMs, setEstimatedEpochMs] = useState(0)
const [drawingBox, setDrawingBox] = useState<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 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 scrollEl = mobileLabelsScrollRef.current
const sectionEl = mobileSectionRefs.current[key]
if (!scrollEl || !sectionEl) return
const scrollRect = scrollEl.getBoundingClientRect()
const sectionRect = sectionEl.getBoundingClientRect()
scrollEl.scrollTo({
top: scrollEl.scrollTop + sectionRect.top - scrollRect.top,
behavior: 'smooth',
})
})
},
[]
)
const toggleMobileCorrectionSection = useCallback(
(key: CorrectionSectionKey, expanded: boolean) => {
setExpandedCorrectionSections(
nextExpandedCorrectionSections(key, expanded)
)
if (expanded && mobilePanel === 'labels') {
scrollMobileSectionToTop(key)
}
},
[mobilePanel, scrollMobileSectionToTop]
)
const toggleCorrectionSection = useCallback(
(key: CorrectionSectionKey, expanded: boolean) => {
setExpandedCorrectionSections(
nextExpandedCorrectionSections(key, expanded)
)
},
[]
)
const labelsRef = useRef<TrainingLabels>(emptyLabels)
useEffect(() => {
importedSampleQueueRef.current = importedSampleQueue
}, [importedSampleQueue])
useEffect(() => {
if (!feedbackModalOpen) return
setFeedbackSearchQuery('')
setFeedbackSearchFilter('all')
void loadFeedbackHistoryInitial({
query: '',
filter: 'all',
})
}, [feedbackModalOpen, loadFeedbackHistoryInitial])
useEffect(() => {
loadingRef.current = loading
}, [loading])
useEffect(() => {
labelsRef.current = labels
}, [labels])
useEffect(() => {
trainingSampleModeRef.current = trainingSampleMode
}, [trainingSampleMode])
const boxLabels = useMemo(() => {
return uniqStrings([
...labels.people,
...labels.bodyParts,
...labels.objects,
...labels.clothing,
]).sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }))
}, [labels.people, labels.bodyParts, labels.objects, labels.clothing])
const correctionBoxes = correction.boxes ?? []
const visibleBoxes = [
...correctionBoxes.map((box, index) => ({ box, index, isDraft: false })),
...(drawingBox
? [{ box: drawingBox, index: -1, isDraft: true }]
: []),
]
const bodyPartScores = useMemo(() => {
return Object.fromEntries(
(sample?.prediction.bodyPartsPresent ?? [])
.filter((x) => x.label)
.map((x) => [x.label, x.score])
)
}, [sample?.prediction.bodyPartsPresent])
const objectScores = useMemo(() => {
return Object.fromEntries(
(sample?.prediction.objectsPresent ?? [])
.filter((x) => x.label)
.map((x) => [x.label, x.score])
)
}, [sample?.prediction.objectsPresent])
const clothingScores = useMemo(() => {
return Object.fromEntries(
(sample?.prediction.clothingPresent ?? [])
.filter((x) => x.label)
.map((x) => [x.label, x.score])
)
}, [sample?.prediction.clothingPresent])
const peopleScores = useMemo(() => {
const bestScores = new Map<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 selectedPeopleLabels = useMemo(() => {
return correction.peoplePresent
}, [correction.peoplePresent])
const drawLabelForSection = useCallback((values: string[]) => {
const clean = String(boxLabel || '').trim()
return values.includes(clean) ? clean : ''
}, [boxLabel])
const imageSrc = useMemo(() => {
if (!sample?.frameUrl) return ''
const sep = sample.frameUrl.includes('?') ? '&' : '?'
return `${sample.frameUrl}${sep}t=${encodeURIComponent(sample.sampleId)}&r=${imageReloadKey}`
}, [sample?.frameUrl, sample?.sampleId, imageReloadKey])
const loadingPreviewRawUrlRef = useRef('')
const setLoadingPreviewCandidate = useCallback((url: string) => {
const clean = String(url || '').trim()
if (!clean) {
loadingPreviewRawUrlRef.current = ''
setLoadingPreviewUrl('')
setLoadingPreviewLoaded(false)
setLoadingPreviewFailed(false)
return
}
// Wichtig:
// Bei gleichem Preview nicht neu cache-busten und nicht loaded=false setzen.
// Sonst flackert das Overlay bei jedem Phasenwechsel.
if (loadingPreviewRawUrlRef.current === clean) {
return
}
loadingPreviewRawUrlRef.current = clean
const sep = clean.includes('?') ? '&' : '?'
setLoadingPreviewUrl(`${clean}${sep}r=${Date.now()}`)
setLoadingPreviewLoaded(false)
setLoadingPreviewFailed(false)
}, [])
useEffect(() => {
if (!imageSrc) {
setFrameImageLoaded(false)
return
}
setFrameImageLoaded(false)
}, [imageSrc])
const canStartTraining = Boolean(trainingStatus?.canTrain)
const feedbackCount = trainingStatus?.feedbackCount ?? 0
const requiredCount = trainingStatus?.requiredCount ?? 5
const feedbackBadgeText =
feedbackCount > requiredCount
? String(feedbackCount)
: `${feedbackCount}/${requiredCount}`
const trainingRunning = training || Boolean(trainingStatus?.training?.running)
const uiLocked = loading || saving || trainingRunning || deletingTrainingData
const shownTrainingProgress = trainingRunning
? trainingStatus?.training?.progress ?? trainingProgress
: trainingProgress
const shownTrainingStep = trainingRunning
? trainingStatus?.training?.step || trainingStep || 'Training läuft…'
: trainingStep
const analysisConfidence = useMemo(() => {
return currentAnalysisConfidence(sample?.prediction)
}, [sample?.prediction])
const loadLabels = useCallback(async () => {
const res = await fetch('/api/training/labels', { cache: 'no-store' })
if (!res.ok) return
const data = await res.json().catch(() => null)
if (data) setLabels(sortTrainingLabels(data))
}, [])
const applyTrainingStatus = useCallback((data: any) => {
if (!data) return
const job = data.training || null
setTrainingStatus((prev) => ({
feedbackCount: Number(data.feedbackCount ?? prev?.feedbackCount ?? 0),
requiredCount: Number(data.requiredCount ?? prev?.requiredCount ?? 5),
canTrain: Boolean(data.canTrain ?? prev?.canTrain ?? false),
detector: data.detector
? {
trainCount: Number(data.detector.trainCount ?? 0),
valCount: Number(data.detector.valCount ?? 0),
requiredTrain: Number(data.detector.requiredTrain ?? 20),
requiredVal: Number(data.detector.requiredVal ?? 3),
datasetReady: Boolean(data.detector.datasetReady),
dataReady: Boolean(data.detector.dataReady),
modelExists: Boolean(data.detector.modelExists),
modelPath: data.detector.modelPath,
source: data.detector.source,
}
: prev?.detector,
training: job
? {
running: Boolean(job.running),
progress: Number(job.progress ?? 0),
step: String(job.step ?? ''),
message: job.message,
error: job.error,
startedAt: job.startedAt,
finishedAt: job.finishedAt,
durationMs: Number(job.durationMs ?? 0),
stage: job.stage,
epoch: Number(job.epoch ?? 0),
epochs: Number(job.epochs ?? 0),
}
: prev?.training,
}))
setTraining(Boolean(job?.running))
if (job?.message && !job.running) {
setTrainingProgress(100)
setTrainingStep('Training abgeschlossen.')
const finishedAt = String(job.finishedAt || '').trim()
const completionKey =
finishedAt || `${String(job.startedAt || '')}:${String(job.message || '')}`
if (completionKey && shownTrainingCompletionRef.current !== completionKey) {
shownTrainingCompletionRef.current = completionKey
const duration = trainingDurationMs(job)
const durationText = duration > 0
? ` Dauer: ${formatDuration(duration)}.`
: ''
setMessage(`${String(job.message)}${durationText}`)
}
}
if (job?.error && !job.running) {
const finishedAt = String(job.finishedAt || '').trim()
const errorKey = finishedAt
? `${finishedAt}:error`
: `${String(job.startedAt || '')}:error`
if (errorKey && shownTrainingCompletionRef.current !== errorKey) {
shownTrainingCompletionRef.current = errorKey
setError(String(job.error))
}
}
}, [])
const loadTrainingSampleIntoTab = useCallback((
nextSample: TrainingSample,
opts?: { manualCorrection?: boolean }
) => {
const nextCorrection = predictionToCorrection(nextSample)
setDrawingBox(null)
setBoxInteraction(null)
setTouchMagnifier(null)
setBoxLabel('')
setActiveBoxIndex(null)
setMobilePanel(trainingRunningRef.current ? 'training' : 'labels')
window.requestAnimationFrame(() => {
mobileLabelsScrollRef.current?.scrollTo({
top: 0,
behavior: 'smooth',
})
})
setSample(nextSample)
setCorrection(nextCorrection)
setHasManualCorrection(Boolean(opts?.manualCorrection))
const initiallyExpandedSection: CorrectionSectionKey | null =
nextCorrection.sexPosition && nextCorrection.sexPosition !== 'unknown'
? 'sexPosition'
: nextCorrection.peoplePresent.length > 0
? 'people'
: nextCorrection.bodyPartsPresent.length > 0
? 'bodyParts'
: nextCorrection.objectsPresent.length > 0
? 'objects'
: nextCorrection.clothingPresent.length > 0
? 'clothing'
: null
setExpandedCorrectionSections(
initiallyExpandedSection
? nextExpandedCorrectionSections(initiallyExpandedSection, true)
: {
sexPosition: false,
people: false,
bodyParts: false,
objects: false,
clothing: false,
}
)
}, [])
const loadNextImportedQueuedSample = useCallback(() => {
const [nextSample, ...rest] = importedSampleQueueRef.current
if (!nextSample) {
return false
}
importedSampleQueueRef.current = rest
setImportedSampleQueue(rest)
loadTrainingSampleIntoTab(nextSample)
return true
}, [loadTrainingSampleIntoTab])
const loadNext = useCallback(async (opts?: {
forceNew?: boolean
refreshPrediction?: boolean
preserveNotice?: boolean
mode?: TrainingSampleMode
}) => {
const requestId = makeRequestId()
activeAnalysisRequestIdRef.current = requestId
const isCurrentRequest = () => activeAnalysisRequestIdRef.current === requestId
const mode = opts?.mode ?? trainingSampleModeRef.current
const uncertainMode = mode === 'uncertain' && !opts?.refreshPrediction
setLoadingPreviewCandidate('')
setLoading(true)
setAnalysisProgress(8)
setAnalysisStep(
opts?.refreshPrediction
? 'Aktuelles Bild wird neu analysiert…'
: uncertainMode
? 'Unsichere Prediction wird gesucht…'
: opts?.forceNew
? 'Neues Trainingsbild wird gesucht…'
: 'Trainingsbild wird geladen…'
)
if (!opts?.preserveNotice) {
setError(null)
setMessage(null)
}
try {
const params = new URLSearchParams()
params.set('analysisRequestId', requestId)
if (opts?.forceNew) params.set('force', '1')
if (opts?.refreshPrediction) params.set('refresh', '1')
if (uncertainMode) params.set('mode', 'uncertain')
const url = `/api/training/next${params.toString() ? `?${params.toString()}` : ''}`
setAnalysisProgress(uncertainMode ? 5 : 25)
setAnalysisStep(
uncertainMode
? 'Mehrere Kandidaten werden vorbereitet…'
: 'Bild wird vorbereitet…'
)
const res = await fetch(url, { cache: 'no-store' })
const data = await res.json().catch(() => null)
if (!res.ok) {
throw new Error(data?.error || `HTTP ${res.status}`)
}
if (!isCurrentRequest()) {
return
}
setAnalysisProgress(92)
setAnalysisStep('Analyse-Ergebnis wird übernommen…')
loadTrainingSampleIntoTab(data as TrainingSample)
} catch (e) {
if (isCurrentRequest()) {
setError(e instanceof Error ? e.message : String(e))
}
} finally {
if (!isCurrentRequest()) {
return
}
setAnalysisProgress((value) => Math.max(value, 100))
setAnalysisStep((value) => value || 'Analyse abgeschlossen.')
const finishedRequestId = requestId
window.setTimeout(() => {
if (activeAnalysisRequestIdRef.current !== finishedRequestId) return
activeAnalysisRequestIdRef.current = null
setLoading(false)
setAnalysisProgress(0)
setAnalysisStep('')
}, 500)
}
}, [loadTrainingSampleIntoTab, setLoadingPreviewCandidate])
const reloadCurrentImage = useCallback(async () => {
setDrawingBox(null)
setBoxInteraction(null)
setTouchMagnifier(null)
setActiveBoxIndex(null)
await loadNext({ refreshPrediction: true })
setImageReloadKey((value) => value + 1)
}, [loadNext])
const loadTrainingStatus = useCallback(async () => {
const res = await fetch('/api/training/status', { cache: 'no-store' })
const data = await res.json().catch(() => null)
if (!res.ok || !data) return
applyTrainingStatus(data)
}, [applyTrainingStatus])
const importVideoIntoTraining = useCallback(async (raw: any) => {
const output = String(raw?.output || '').trim()
if (!output) return false
setLoadingPreviewCandidate('')
const detail: PendingTrainingVideoImport = {
jobId: String(raw?.jobId || '').trim(),
output,
sourceFile: String(raw?.sourceFile || '').trim(),
count: Number(raw?.count || 8),
}
const importKey = `${detail.jobId || ''}|${detail.output}|${detail.count || 8}`
// Verhindert Doppelimport durch sessionStorage + CustomEvent.
if (videoImportInFlightKeyRef.current === importKey) {
return false
}
videoImportStartedRef.current = true
videoImportInFlightKeyRef.current = importKey
const requestId = makeRequestId()
activeAnalysisRequestIdRef.current = requestId
loadingRef.current = true
setLoading(true)
setAnalysisProgress(5)
setAnalysisStep('Video wird ins Training übernommen…')
setError(null)
setMessage(null)
try {
try {
window.sessionStorage.removeItem('training:pending-import-video')
} catch {
// ignore
}
const res = await fetch('/api/training/import-video', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
cache: 'no-store',
body: JSON.stringify({
jobId: detail.jobId,
output: detail.output,
count: detail.count || 8,
analysisRequestId: requestId,
}),
})
const data = await res.json().catch(() => null)
if (!res.ok || !data?.ok) {
throw new Error(backendText(data, `HTTP ${res.status}`))
}
const samples: TrainingSample[] = Array.isArray(data.samples)
? data.samples
: data.sample
? [data.sample]
: []
if (samples.length === 0) {
throw new Error('Es wurden keine Trainingsframes erzeugt.')
}
const [firstSample, ...queuedSamples] = samples
importedSampleQueueRef.current = queuedSamples
setImportedSampleQueue(queuedSamples)
loadTrainingSampleIntoTab(firstSample)
setImageReloadKey((value) => value + 1)
await loadTrainingStatus()
const errorCount = Array.isArray(data.errors) ? data.errors.length : 0
setMessage(
errorCount > 0
? `${samples.length} Frames ins Training übernommen, ${errorCount} Frames fehlgeschlagen.`
: `${samples.length} Frames ins Training übernommen.`
)
return true
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
return false
} finally {
setAnalysisProgress(100)
setAnalysisStep('Video-Import abgeschlossen.')
const finishedRequestId = requestId
window.setTimeout(() => {
if (activeAnalysisRequestIdRef.current === finishedRequestId) {
activeAnalysisRequestIdRef.current = null
loadingRef.current = false
setLoading(false)
setAnalysisProgress(0)
setAnalysisStep('')
}
if (videoImportInFlightKeyRef.current === importKey) {
videoImportInFlightKeyRef.current = null
}
}, 500)
}
}, [
loadTrainingSampleIntoTab,
loadTrainingStatus,
setLoadingPreviewCandidate,
])
const loadTrainingStats = useCallback(async () => {
setTrainingStatsLoading(true)
setTrainingStatsError(null)
try {
const res = await fetch('/api/training/stats', { cache: 'no-store' })
const data = await res.json().catch(() => null)
if (!res.ok) {
throw new Error(data?.error || `HTTP ${res.status}`)
}
setTrainingStats({
feedbackCount: Number(data?.feedbackCount ?? 0),
acceptedCount: Number(data?.acceptedCount ?? 0),
correctedCount: Number(data?.correctedCount ?? 0),
sampleCount: Number(data?.sampleCount ?? 0),
boxCount: Number(data?.boxCount ?? 0),
modelAvailable: Boolean(data?.modelAvailable),
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)
}
}, [])
useEffect(() => {
updateImageLayerStyle()
const boxEl = imageBoxRef.current
const imgEl = frameImageRef.current
const resizeObserver =
typeof ResizeObserver !== 'undefined'
? new ResizeObserver(() => updateImageLayerStyle())
: null
if (resizeObserver) {
if (boxEl) resizeObserver.observe(boxEl)
if (imgEl) resizeObserver.observe(imgEl)
}
window.addEventListener('resize', updateImageLayerStyle)
window.addEventListener('scroll', updateImageLayerStyle, true)
return () => {
resizeObserver?.disconnect()
window.removeEventListener('resize', updateImageLayerStyle)
window.removeEventListener('scroll', updateImageLayerStyle, true)
}
}, [imageSrc, imageExpanded, frameImageLoaded, updateImageLayerStyle])
useEffect(() => {
trainingRunningRef.current = trainingRunning
if (trainingRunning) {
setMobilePanel('training')
}
}, [trainingRunning])
useEffect(() => {
const onTraining = (event: Event) => {
try {
const data = (event as CustomEvent<any>).detail
if (data?.type !== 'training_status') return
applyTrainingStatus({
training: data.training,
})
} catch {
// ignore
}
}
window.addEventListener('app:sse:training', onTraining as EventListener)
return () => {
window.removeEventListener('app:sse:training', onTraining as EventListener)
}
}, [applyTrainingStatus])
useEffect(() => {
const onAnalysis = (event: Event) => {
try {
const data = (event as CustomEvent<any>).detail
const activeRequestId = activeAnalysisRequestIdRef.current
if (!loadingRef.current || !activeRequestId) return
const requestId = String(
data?.requestId ||
data?.analysisRequestId ||
data?.analysis?.requestId ||
data?.analysis?.analysisRequestId ||
''
).trim()
const scope = String(
data?.scope ||
data?.analysis?.scope ||
''
).trim()
if (scope !== 'training') return
if (requestId !== activeRequestId) return
const message = String(
data?.message ||
data?.step ||
data?.title ||
''
).trim()
const previewUrl = String(
data?.previewUrl ||
data?.analysis?.previewUrl ||
''
).trim()
if (previewUrl) {
setLoadingPreviewCandidate(previewUrl)
}
const current = Number(data?.current ?? data?.stepIndex ?? data?.index)
const total = Number(data?.total ?? data?.steps ?? data?.stepTotal)
const rawProgress = Number(
data?.progress ??
data?.percent ??
data?.analysis?.progress
)
let nextProgress: number | null = null
if (
Number.isFinite(current) &&
Number.isFinite(total) &&
total > 0
) {
nextProgress = (current / total) * 100
} else if (Number.isFinite(rawProgress)) {
nextProgress = rawProgress <= 1
? rawProgress * 100
: rawProgress
}
if (message) {
setAnalysisStep(message)
}
if (nextProgress !== null) {
setAnalysisProgress((prev) =>
Math.max(prev, clampPercent(nextProgress))
)
}
} catch {
// ignore
}
}
window.addEventListener('app:sse:analysis', onAnalysis as EventListener)
return () => {
window.removeEventListener('app:sse:analysis', onAnalysis as EventListener)
}
}, [setLoadingPreviewCandidate])
useEffect(() => {
const draggingBox = Boolean(drawingBox || boxInteraction)
if (!draggingBox) return
const previousUserSelect = document.body.style.userSelect
const previousWebkitUserSelect = document.body.style.webkitUserSelect
document.body.style.userSelect = 'none'
document.body.style.webkitUserSelect = 'none'
const clearSelection = () => {
window.getSelection()?.removeAllRanges()
}
const preventSelection = (event: Event) => {
event.preventDefault()
}
clearSelection()
document.addEventListener('selectionchange', clearSelection)
document.addEventListener('selectstart', preventSelection)
return () => {
document.body.style.userSelect = previousUserSelect
document.body.style.webkitUserSelect = previousWebkitUserSelect
document.removeEventListener('selectionchange', clearSelection)
document.removeEventListener('selectstart', preventSelection)
clearSelection()
}
}, [drawingBox, boxInteraction])
useEffect(() => {
if (!statsModalOpen) return
void loadTrainingStats()
}, [statsModalOpen, loadTrainingStats])
const onTrainingRunningChange = props.onTrainingRunningChange
useEffect(() => {
onTrainingRunningChange?.(trainingRunning)
}, [trainingRunning, onTrainingRunningChange])
useEffect(() => {
props.onImageExpandedChange?.(imageExpanded)
return () => {
props.onImageExpandedChange?.(false)
}
}, [imageExpanded, props.onImageExpandedChange])
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)
try {
const raw = window.sessionStorage.getItem('training:pending-import-video')
if (raw) {
const detail = JSON.parse(raw)
void importVideoIntoTraining(detail)
}
} catch {
// ignore
}
return () => {
window.removeEventListener('training:import-video', onImportVideo as EventListener)
}
}, [importVideoIntoTraining])
useEffect(() => {
let cancelled = false
async function init() {
await loadLabels()
await loadTrainingStatus()
if (cancelled) return
// Wichtig:
// Wenn gerade ein Video-Import über "Video ins Training übernehmen" läuft,
// darf loadNext() nicht danach ein zufälliges/letztes Sample darüberlegen.
if (videoImportStartedRef.current || videoImportInFlightKeyRef.current) {
return
}
await loadNext()
}
void init()
return () => {
cancelled = true
}
}, [loadLabels, loadNext, loadTrainingStatus])
useEffect(() => {
if (!trainingRunning) return
const onVisibilityChange = () => {
if (!document.hidden) {
void loadTrainingStatus()
}
}
document.addEventListener('visibilitychange', onVisibilityChange)
return () => {
document.removeEventListener('visibilitychange', onVisibilityChange)
}
}, [loadTrainingStatus, trainingRunning])
useEffect(() => {
if (!trainingRunning) {
const timer = window.setTimeout(() => {
setTrainingProgress(0)
setTrainingStep('')
}, 800)
return () => window.clearTimeout(timer)
}
const serverProgress = Number(trainingStatus?.training?.progress ?? 0)
const serverStep = String(trainingStatus?.training?.step ?? '')
setTrainingProgress(Number.isFinite(serverProgress) ? clampPercent(serverProgress) : 0)
setTrainingStep(serverStep || 'Training läuft…')
}, [
trainingRunning,
trainingStatus?.training?.progress,
trainingStatus?.training?.step,
])
useEffect(() => {
const job = trainingStatus?.training
const epoch = Number(job?.epoch ?? 0)
const epochs = Number(job?.epochs ?? 0)
if (!trainingRunning || epoch <= 0 || epochs <= 0) {
epochTimingRef.current = {
firstEpochAt: 0,
lastEpoch: 0,
lastAt: 0,
}
setEstimatedEpochMs(0)
return
}
const now = Date.now()
const previous = epochTimingRef.current
const firstEpochAt =
previous.firstEpochAt > 0
? previous.firstEpochAt
: job?.startedAt && Number.isFinite(Date.parse(job.startedAt))
? Date.parse(job.startedAt)
: now
const safeEpoch = Math.max(1, Math.min(epochs, Math.floor(epoch)))
const elapsedSinceStartMs = Math.max(0, now - firstEpochAt)
// Direkt ab Epoche 1 eine erste Schätzung:
// bisherige Laufzeit / aktuelle Epoche.
const averageFromElapsed =
elapsedSinceStartMs > 0 && safeEpoch > 0
? elapsedSinceStartMs / safeEpoch
: 0
if (Number.isFinite(averageFromElapsed) && averageFromElapsed > 0) {
setEstimatedEpochMs((old) => {
if (!Number.isFinite(old) || old <= 0) {
return averageFromElapsed
}
const clampedMeasured = Math.max(
old * 0.60,
Math.min(old * 1.60, averageFromElapsed)
)
return old * 0.75 + clampedMeasured * 0.25
})
}
epochTimingRef.current = {
firstEpochAt,
lastEpoch: safeEpoch,
lastAt: now,
}
}, [
trainingRunning,
trainingStatus?.training?.epoch,
trainingStatus?.training?.epochs,
trainingStatus?.training?.startedAt,
trainingNowMs,
])
const saveFeedback = useCallback(
async (accepted: boolean) => {
if (!sample) return
setSaving(true)
setError(null)
setMessage(null)
try {
const normalizedBoxes = (correction.boxes ?? [])
.map(normalizeBox)
.filter((box) => box.label && box.w > 0 && box.h > 0)
const correctionPayload: CorrectionState = {
...correction,
peoplePresent: peopleLabelsFromBoxes(normalizedBoxes, labelsRef.current),
boxes: normalizedBoxes,
}
const payload = {
sampleId: sample.sampleId,
accepted,
correction: accepted && 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,
correction: accepted ? undefined : correctionPayload,
}
: item
)
)
}
setMessage(
wasEditingFeedback
? accepted
? 'Feedback aktualisiert.'
: 'Korrektur aktualisiert.'
: accepted
? 'Feedback gespeichert.'
: 'Korrektur gespeichert.'
)
await loadTrainingStatus()
if (!loadNextImportedQueuedSample()) {
await loadNext({
forceNew: true,
preserveNotice: true,
})
}
} catch (e) {
console.error('Feedback speichern fehlgeschlagen:', e)
const raw = e instanceof Error ? e.message : String(e)
const short = raw.length > 220
? `${raw.slice(0, 220).trimEnd()}`
: raw
setError(`Feedback konnte nicht gespeichert werden. ${short}`)
} finally {
setSaving(false)
}
},
[
sample,
correction,
editingFeedback,
loadNext,
loadTrainingStatus,
loadNextImportedQueuedSample,
]
)
const skipCurrentSample = useCallback(async () => {
if (!sample) return
const skippedSampleId = sample.sampleId
setSaving(true)
setError(null)
setMessage(null)
try {
const res = await fetch('/api/training/skip', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sampleId: skippedSampleId }),
})
const data = await res.json().catch(() => null)
if (!res.ok) {
throw new Error(backendText(data, `HTTP ${res.status}`))
}
setSample(null)
setCorrection(predictionToCorrection(null))
setHasManualCorrection(false)
setDrawingBox(null)
setBoxInteraction(null)
setTouchMagnifier(null)
setBoxLabel('')
setActiveBoxIndex(null)
if (!loadNextImportedQueuedSample()) {
await loadNext({
forceNew: true,
preserveNotice: true,
})
}
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally {
setSaving(false)
}
}, [sample, loadNext, loadNextImportedQueuedSample])
const startTraining = useCallback(async () => {
shownTrainingCompletionRef.current = null
setTraining(true)
setTrainingProgress(5)
setTrainingStep('Training wird gestartet…')
setError(null)
setMessage(null)
try {
const res = await fetch('/api/training/train', {
method: 'POST',
})
const data = await res.json().catch(() => null)
if (!res.ok) {
throw new Error(backendText(data, `HTTP ${res.status}`))
}
await loadTrainingStatus()
// WICHTIG:
// Hier NICHT direkt loadNext() aufrufen.
// Das Training läuft im Backend asynchron weiter.
} catch (e) {
setTraining(false)
setTrainingProgress(0)
setTrainingStep('')
setError(e instanceof Error ? e.message : String(e))
}
}, [loadTrainingStatus])
const cancelTraining = useCallback(async () => {
const confirmed = window.confirm(
'Training wirklich abbrechen? Temporäre Trainingsausgaben dieses Laufs werden gelöscht. Deine Feedbacks und Labels bleiben erhalten.'
)
if (!confirmed) return
setCancellingTraining(true)
setError(null)
setMessage(null)
setTrainingStep('Training wird abgebrochen…')
try {
const res = await fetch('/api/training/cancel', {
method: 'POST',
})
const data = await res.json().catch(() => null)
if (!res.ok) {
throw new Error(backendText(data, `HTTP ${res.status}`))
}
await loadTrainingStatus()
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally {
setCancellingTraining(false)
}
}, [loadTrainingStatus])
const deleteAllTrainingData = useCallback(async () => {
const confirmed = window.confirm(
'Wirklich alle Trainingsdaten löschen? Das entfernt Feedback, Frames, Samples und Detector-Daten. Diese Aktion kann nicht rückgängig gemacht werden.'
)
if (!confirmed) return
setDeletingTrainingData(true)
setError(null)
setMessage(null)
try {
const res = await fetch('/api/training/delete-all', {
method: 'DELETE',
})
const data = await res.json().catch(() => null)
if (!res.ok) {
throw new Error(data?.error || `HTTP ${res.status}`)
}
setSample(null)
setCorrection(predictionToCorrection(null))
setTrainingStatus({
feedbackCount: 0,
requiredCount,
canTrain: false,
})
setMessage(backendText(data, 'Alle Trainingsdaten wurden gelöscht.'))
await loadTrainingStatus()
await loadNext({ forceNew: true, preserveNotice: true })
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally {
setDeletingTrainingData(false)
}
}, [loadNext, loadTrainingStatus, requiredCount])
const getPointerPosInImage = useCallback((
clientX: number,
clientY: number,
opts?: { clamp?: boolean }
) => {
const rect = getImageContentRect()
if (!rect) return null
const x = (clientX - rect.left) / rect.width
const y = (clientY - rect.top) / rect.height
if (opts?.clamp === false) {
return { x, y }
}
return {
x: clamp01(x),
y: clamp01(y),
}
}, [getImageContentRect])
const releaseActivePointerCapture = useCallback((pointerId?: number | null) => {
const id = typeof pointerId === 'number'
? pointerId
: activePointerIdRef.current
if (typeof id !== 'number') {
activePointerIdRef.current = null
return
}
try {
imageBoxRef.current?.releasePointerCapture(id)
} catch {
// Pointer-Capture war evtl. schon weg.
}
activePointerIdRef.current = null
}, [])
const startDrawBox = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
if (!boxLabel) return
if (uiLocked) return
if (boxInteraction) return
const target = e.target as HTMLElement | null
if (target?.closest('[data-box-control="true"]')) return
const rawPos = getPointerPosInImage(e.clientX, e.clientY, { clamp: false })
if (!rawPos) return
// Nicht im schwarzen Randbereich starten.
if (
rawPos.x < 0 ||
rawPos.x > 1 ||
rawPos.y < 0 ||
rawPos.y > 1
) {
return
}
const pos = {
x: clamp01(rawPos.x),
y: clamp01(rawPos.y),
}
e.preventDefault()
e.stopPropagation()
window.getSelection()?.removeAllRanges()
finishingGestureRef.current = false
activePointerIdRef.current = e.pointerId
try {
e.currentTarget.setPointerCapture(e.pointerId)
} catch {
activePointerIdRef.current = null
}
setTouchMagnifier({
visible: true,
clientX: e.clientX,
clientY: e.clientY,
imageX: pos.x,
imageY: pos.y,
})
setDrawingBox({
label: boxLabel,
startX: pos.x,
startY: pos.y,
x: pos.x,
y: pos.y,
w: 0,
h: 0,
})
}, [boxLabel, boxInteraction, getPointerPosInImage, uiLocked])
const moveDrawBox = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
if (drawingBox || boxInteraction) {
e.preventDefault()
e.stopPropagation()
}
const clampedPos = getPointerPosInImage(e.clientX, e.clientY)
if (!clampedPos) return
const pos =
boxInteraction?.type === 'move'
? getPointerPosInImage(e.clientX, e.clientY, { clamp: false }) ?? clampedPos
: clampedPos
if (drawingBox || boxInteraction) {
setTouchMagnifier({
visible: true,
clientX: e.clientX,
clientY: e.clientY,
imageX: clampedPos.x,
imageY: clampedPos.y,
})
}
if (boxInteraction) {
const dx = pos.x - boxInteraction.startX
const dy = pos.y - boxInteraction.startY
const original = boxInteraction.original
let nextBox: TrainingBox = original
if (boxInteraction.type === 'move') {
nextBox = normalizeMovedBox({
...original,
x: original.x + dx,
y: original.y + dy,
})
}
if (boxInteraction.type === 'resize') {
let x1 = original.x
let y1 = original.y
let x2 = original.x + original.w
let y2 = original.y + original.h
const pointerX = snap01(clampedPos.x)
const pointerY = snap01(clampedPos.y)
// Wichtig:
// Beim Resize folgt die gezogene Ecke direkt dem Pointer.
// Dadurch bleibt kein Grab-Offset übrig, wenn der Handle nicht exakt
// auf der mathematischen Box-Ecke getroffen wurde.
if (boxInteraction.handle.includes('n')) y1 = pointerY
if (boxInteraction.handle.includes('s')) y2 = pointerY
if (boxInteraction.handle.includes('w')) x1 = pointerX
if (boxInteraction.handle.includes('e')) x2 = pointerX
const left = Math.min(x1, x2)
const top = Math.min(y1, y2)
const right = Math.max(x1, x2)
const bottom = Math.max(y1, y2)
nextBox = normalizeBox({
...original,
x: left,
y: top,
w: right - left,
h: bottom - top,
})
}
const geometryChanged = boxGeometryChanged(original, nextBox)
const correctedNextBox = geometryChanged
? markBoxCorrected(nextBox)
: nextBox
if (geometryChanged) {
setHasManualCorrection(true)
}
setCorrection((prev) => ({
...prev,
boxes: (prev.boxes ?? []).map((box, index) =>
index === boxInteraction.index ? correctedNextBox : box
),
}))
return
}
if (!drawingBox) return
const x1 = drawingBox.startX
const y1 = drawingBox.startY
const x2 = pos.x
const y2 = pos.y
setDrawingBox({
...drawingBox,
x: Math.min(x1, x2),
y: Math.min(y1, y2),
w: Math.abs(x2 - x1),
h: Math.abs(y2 - y1),
})
}, [boxInteraction, drawingBox, getPointerPosInImage])
const finishDrawBox = useCallback((e?: React.PointerEvent<HTMLDivElement> | PointerEvent) => {
releaseActivePointerCapture(
typeof e?.pointerId === 'number' ? e.pointerId : null
)
setTouchMagnifier(null)
if (finishingGestureRef.current) {
setDrawingBox(null)
setBoxInteraction(null)
return
}
finishingGestureRef.current = true
if (drawingBox || boxInteraction) {
e?.preventDefault()
e?.stopPropagation()
}
if (boxInteraction) {
setBoxInteraction(null)
return
}
if (!drawingBox) {
setDrawingBox(null)
return
}
const { startX, startY, ...drawingTrainingBox } = drawingBox
const box = normalizeBox(drawingTrainingBox)
setDrawingBox(null)
if (box.w < 0.01 || box.h < 0.01) return
setHasManualCorrection(true)
setCorrection((prev) => {
const previousBoxes = prev.boxes ?? []
const newBoxIndex = previousBoxes.length
const next: CorrectionState = {
...prev,
boxes: [...previousBoxes, box],
}
setActiveBoxIndex(newBoxIndex)
return applyBoxLabelToCorrection(next, box.label, labelsRef.current)
})
}, [boxInteraction, drawingBox, releaseActivePointerCapture])
useEffect(() => {
if (!drawingBox && !boxInteraction) return
const finishPointer = (event: PointerEvent) => {
finishDrawBox(event)
}
const finishWithoutPointer = () => {
finishDrawBox()
}
const finishOnHidden = () => {
if (document.hidden) {
finishDrawBox()
}
}
window.addEventListener('pointerup', finishPointer, true)
window.addEventListener('pointercancel', finishPointer, true)
window.addEventListener('blur', finishWithoutPointer)
document.addEventListener('visibilitychange', finishOnHidden)
return () => {
window.removeEventListener('pointerup', finishPointer, true)
window.removeEventListener('pointercancel', finishPointer, true)
window.removeEventListener('blur', finishWithoutPointer)
document.removeEventListener('visibilitychange', finishOnHidden)
}
}, [drawingBox, boxInteraction, finishDrawBox])
const removeBox = useCallback((index: number) => {
setHasManualCorrection(true)
let removedLabel = ''
let shouldClearBoxLabel = false
setCorrection((prev) => {
const removed = prev.boxes?.[index]
removedLabel = String(removed?.label || '').trim()
const next = removeBoxFromCorrection(prev, index, labelsRef.current)
if (removedLabel) {
shouldClearBoxLabel = !next.boxes.some(
(box) => String(box.label || '').trim() === removedLabel
)
}
return next
})
if (removedLabel && shouldClearBoxLabel) {
setBoxLabel((current) => current === removedLabel ? '' : current)
}
setActiveBoxIndex((current) => {
if (current === null) return null
if (current === index) return null
if (current > index) return current - 1
return current
})
}, [])
const changeBoxLabel = useCallback((index: number, nextLabel: string) => {
const currentLabel = String(correction.boxes?.[index]?.label || '').trim()
const cleanNextLabel = String(nextLabel || '').trim()
if (currentLabel !== cleanNextLabel) {
setHasManualCorrection(true)
}
if (cleanNextLabel) {
setBoxLabel(cleanNextLabel)
}
setCorrection((prev) =>
changeBoxLabelInCorrection(prev, index, nextLabel, labelsRef.current)
)
}, [correction.boxes])
const clearBoxes = useCallback(() => {
setHasManualCorrection(true)
setBoxLabel('')
setActiveBoxIndex(null)
setCorrection((prev) => ({
...prev,
peoplePresent: [],
bodyPartsPresent: [],
objectsPresent: [],
clothingPresent: [],
boxes: [],
}))
setExpandedCorrectionSections({
sexPosition: false,
people: false,
bodyParts: false,
objects: false,
clothing: false,
})
}, [])
const frameBusy = loading || (!!imageSrc && !frameImageLoaded)
useEffect(() => {
if (loading || frameBusy) return
if (!loadingPreviewUrl) return
setLoadingPreviewUrl('')
setLoadingPreviewLoaded(false)
setLoadingPreviewFailed(false)
}, [loading, frameBusy, loadingPreviewUrl])
const showImageBoxes = !frameBusy && !trainingRunning
const shownTrainingDurationMs = useMemo(() => {
const job = trainingStatus?.training
if (!job) return 0
if (job.running && job.startedAt) {
const started = Date.parse(job.startedAt)
if (Number.isFinite(started)) {
return Math.max(0, trainingNowMs - started)
}
}
return trainingDurationMs(job)
}, [trainingStatus?.training, trainingNowMs])
const rawTrainingEtaMs = useMemo(() => {
if (!trainingRunning) return 0
const job = trainingStatus?.training
const epoch = Number(job?.epoch ?? 0)
const epochs = Number(job?.epochs ?? 0)
if (
!Number.isFinite(epoch) ||
!Number.isFinite(epochs) ||
!Number.isFinite(estimatedEpochMs) ||
epoch <= 0 ||
epochs <= 0 ||
estimatedEpochMs <= 0
) {
return 0
}
const completedEpochs = Math.max(1, Math.min(epochs, Math.floor(epoch)))
const remainingEpochs = Math.max(0, epochs - completedEpochs)
return Math.max(0, remainingEpochs * estimatedEpochMs)
}, [
trainingRunning,
trainingStatus?.training?.epoch,
trainingStatus?.training?.epochs,
estimatedEpochMs,
])
useEffect(() => {
if (!trainingRunning || rawTrainingEtaMs <= 0) {
etaSmoothingRef.current = {
lastAt: 0,
lastRawEtaMs: 0,
}
setSmoothedTrainingEtaMs(0)
return
}
setSmoothedTrainingEtaMs((previous) => {
const state = etaSmoothingRef.current
const lastAt = state.lastAt || trainingNowMs
const elapsed = Math.max(0, trainingNowMs - lastAt)
// 1) Immer erstmal echte Anzeige runterzählen.
const countedDown =
previous > 0
? Math.max(0, previous - elapsed)
: rawTrainingEtaMs
const rawChanged =
state.lastRawEtaMs <= 0 ||
Math.abs(rawTrainingEtaMs - state.lastRawEtaMs) > 1000
let next = countedDown
if (rawChanged) {
const diff = rawTrainingEtaMs - countedDown
// Neue Backend-Schätzung einblenden, aber nicht hart springen.
if (diff > 0) {
// Restzeit wurde höher neu berechnet: langsam nach oben.
next = countedDown + Math.min(diff * 0.20, 15_000)
} else {
// Restzeit wurde niedriger neu berechnet: schneller nach unten.
next = countedDown + diff * 0.45
}
}
next = Math.max(0, next)
etaSmoothingRef.current = {
lastAt: trainingNowMs,
lastRawEtaMs: rawTrainingEtaMs,
}
return next
})
}, [trainingRunning, rawTrainingEtaMs, trainingNowMs])
const shownTrainingEtaMs = smoothedTrainingEtaMs
const shownTrainingEpochText = useMemo(() => {
const epoch = Number(trainingStatus?.training?.epoch ?? 0)
const epochs = Number(trainingStatus?.training?.epochs ?? 0)
if (!Number.isFinite(epoch) || !Number.isFinite(epochs)) return ''
if (epoch <= 0 || epochs <= 0) return ''
return `Epoche ${epoch}/${epochs}`
}, [
trainingStatus?.training?.epoch,
trainingStatus?.training?.epochs,
])
useEffect(() => {
const toastImage =
imageSrc
? {
imageUrl: imageSrc,
imageAlt: sample?.sourceFile || 'Training Frame',
}
: undefined
if (error) {
notify.error('Aktion fehlgeschlagen', error, toastImage)
setError(null)
return
}
if (!message) return
const lowerMessage = message.toLowerCase()
const looksPartial =
lowerMessage.includes('übersprungen') ||
lowerMessage.includes('fehlgeschlagen') ||
lowerMessage.includes('abgebrochen')
if (looksPartial) {
notify.warning('Training teilweise abgeschlossen', message, toastImage)
} else {
notify.success('Erfolg', message, toastImage)
}
setMessage(null)
}, [error, message, notify, imageSrc, sample?.sourceFile])
const trainingActionsPanel = (opts?: { compact?: boolean }) => {
const compact = Boolean(opts?.compact)
const progress = clampPercent(
trainingRunning
? shownTrainingProgress
: Math.min(100, (feedbackCount / Math.max(1, requiredCount)) * 100)
)
const feedbackReady = feedbackCount >= requiredCount
const detector = trainingStatus?.detector
const detectorReady = Boolean(detector?.dataReady)
const missingTrain = Math.max(
0,
Number(detector?.requiredTrain ?? 20) - Number(detector?.trainCount ?? 0)
)
const missingVal = Math.max(
0,
Number(detector?.requiredVal ?? 3) - Number(detector?.valCount ?? 0)
)
const statusText = trainingRunning
? shownTrainingStep || 'Training läuft…'
: !feedbackReady
? `${Math.max(0, requiredCount - feedbackCount)} Feedback fehlen noch`
: !detectorReady
? `YOLO-Boxen fehlen: ${missingTrain} Train, ${missingVal} Val`
: canStartTraining
? 'Bereit zum Trainieren'
: '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'
: feedbackReady
? '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) {
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 || feedbackCount < requiredCount ? (
<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' : 'Feedback-Fortschritt'}</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'
: feedbackReady
? 'bg-emerald-500'
: 'bg-gray-400',
].join(' ')}
style={{ width: `${progress}%` }}
/>
</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
}
void startTraining()
}}
title={
trainingRunning
? 'Training abbrechen und temporäre Trainingsausgaben löschen.'
: canStartTraining
? 'YOLO26-Detector mit allen gespeicherten Box-Labels trainieren.'
: !feedbackReady
? `Noch zu wenig Feedback: ${feedbackCount}/${requiredCount}.`
: !detectorReady
? `Noch zu wenige YOLO-Box-Labels: Train ${detector?.trainCount ?? 0}/${detector?.requiredTrain ?? 20}, Val ${detector?.valCount ?? 0}/${detector?.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}`}
onClick={() => setActiveBoxIndex(index)}
className={[
'group relative cursor-pointer overflow-hidden rounded-2xl border transition-all duration-200',
'bg-white shadow-sm',
'dark:border-white/10 dark:bg-gray-950/55',
isActive
? [
'border-gray-200 bg-white',
'dark:border-white/10',
tone.activeSurface,
].join(' ')
: [
'border-gray-200 hover:bg-gray-50/80 hover:shadow-md',
'dark:border-white/10 dark:hover:bg-white/[0.04]',
tone.idleHover,
].join(' '),
].join(' ')}
>
<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()
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()
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
: ''
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 = {
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(calc(100dvh - ${imageStageLimits.bottomReservePx}px - env(safe-area-inset-bottom)), ${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 stageBusy = trainingRunning || frameBusy
const stageOverlayMode: 'training' | 'analysis' =
trainingRunning ? 'training' : 'analysis'
const stageOverlayText = trainingRunning
? shownTrainingStep || 'Aktuelles Bild wird geladen…'
: analysisStep || 'Bild wird geladen…'
const stageOverlayProgress = trainingRunning
? shownTrainingProgress
: loading
? analysisProgress
: 100
const stageOverlayFadeMs = 300
useEffect(() => {
if (stageBusy) {
setStageOverlayMounted(true)
const frame = window.requestAnimationFrame(() => {
setStageOverlayVisible(true)
})
return () => window.cancelAnimationFrame(frame)
}
setStageOverlayVisible(false)
const timer = window.setTimeout(() => {
setStageOverlayMounted(false)
}, stageOverlayFadeMs)
return () => window.clearTimeout(timer)
}, [stageBusy])
const renderStageOverlay = stageBusy || stageOverlayMounted
const stageOverlayIsVisible = stageBusy && stageOverlayVisible
return (
<div className="min-h-full lg:h-full lg:min-h-0 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>
{sample?.sourceFile ? (
<div
className="inline-flex h-6 min-w-0 max-w-[52vw] items-center 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}
>
<span className="min-w-0 translate-y-px truncate leading-[14px]">
{sample.sourceFile}
</span>
</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:overflow-visible',
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>
{sample?.sourceFile ? (
<div
className="inline-flex h-6 min-w-0 max-w-[52vw] items-center 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}
>
<span className="min-w-0 translate-y-px truncate leading-[14px]">
{sample.sourceFile}
</span>
</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:h-full lg:min-h-0 lg:overflow-visible">
<section
className={[
'min-w-0 rounded-xl border border-gray-200 bg-white p-2 shadow-sm dark:border-white/10 dark:bg-gray-900/60 sm:p-3',
'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',
imageStageHeightClass,
'overflow-visible',
].join(' ')}
style={imageStageStyle}
>
{imageSrc ? (
<div className="relative z-50 flex h-full min-h-0 w-full items-center justify-center overflow-visible">
<div
ref={imageBoxRef}
className={[
'relative z-50 inline-flex min-h-0 max-h-full max-w-full select-none overscroll-contain transition',
imageTouchClass,
'[-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={[
'block h-auto min-h-0 max-h-full max-w-full rounded-md object-contain',
'select-none',
'transition-opacity duration-500 ease-out will-change-opacity motion-reduce:transition-none',
frameImageLoaded ? 'opacity-100' : 'opacity-0',
imageTouchClass,
'[-webkit-user-drag:none] [-webkit-touch-callout:none]',
].join(' ')}
/>
{showImageBoxes && imageLayerStyle ? (
<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 imageRect = frameImageRef.current?.getBoundingClientRect()
const labelMaxWidth = imageRect?.width
? Math.max(72, Math.min(210, imageRect.width - 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`}
>
<button
type="button"
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(' ')}
disabled={Boolean(isDraft) || uiLocked}
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 pos = getPointerPosInImage(e.clientX, e.clientY, { clamp: false })
const clampedPos = getPointerPosInImage(e.clientX, e.clientY)
if (!pos || !clampedPos) return
finishingGestureRef.current = false
activePointerIdRef.current = e.pointerId
try {
imageBoxRef.current?.setPointerCapture(e.pointerId)
} catch {
activePointerIdRef.current = null
}
setTouchMagnifier({
visible: true,
clientX: e.clientX,
clientY: e.clientY,
imageX: clampedPos.x,
imageY: clampedPos.y,
})
setBoxInteraction({
type: 'move',
index,
startX: pos.x,
startY: pos.y,
original: box,
})
}}
>
<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 ? (
<span
data-box-trash="true"
role="button"
tabIndex={0}
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',
'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.preventDefault()
e.stopPropagation()
}}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
removeBox(index)
}}
onKeyDown={(e) => {
if (e.key !== 'Enter' && e.key !== ' ') return
e.preventDefault()
e.stopPropagation()
removeBox(index)
}}
>
<TrashIcon
className="h-3 w-3 shrink-0 !text-white"
aria-hidden="true"
/>
</span>
) : null}
</button>
</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 pos = getPointerPosInImage(e.clientX, e.clientY)
if (!pos) return
finishingGestureRef.current = false
activePointerIdRef.current = e.pointerId
try {
imageBoxRef.current?.setPointerCapture(e.pointerId)
} catch {
activePointerIdRef.current = null
}
setTouchMagnifier({
visible: true,
clientX: e.clientX,
clientY: e.clientY,
imageX: pos.x,
imageY: pos.y,
})
setBoxInteraction({
type: 'resize',
index,
handle,
startX: pos.x,
startY: pos.y,
original: box,
})
}}
>
<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 = getImageContentRect()
if (!rect || rect.width <= 0 || rect.height <= 0) return null
const size = 156
const padding = 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 fitZoom = hasUsableBox
? Math.min(
(size - padding * 2) / Math.max(1, boxPixelW),
(size - padding * 2) / Math.max(1, boxPixelH)
)
: 2
const zoom = hasUsableBox
? Math.max(0.55, Math.min(2.25, fitZoom))
: 2
const viewportW = typeof window !== 'undefined' ? window.innerWidth : 390
const viewportH = typeof window !== 'undefined' ? window.innerHeight : 800
let left = touchMagnifier.clientX - size / 2
let top = touchMagnifier.clientY - size - 28
if (top < 8) {
top = touchMagnifier.clientY + 28
}
left = Math.max(8, Math.min(viewportW - size - 8, left))
top = Math.max(8, Math.min(viewportH - size - 8, top))
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}
progress={stageOverlayProgress}
visible={stageOverlayIsVisible}
backgroundUrl={
stageOverlayMode === 'analysis'
? loadingPreviewBackgroundUrl
: undefined
}
/>
) : null}
</div>
<div
className={[
'z-10 grid shrink-0 grid-cols-2 gap-2',
imageExpanded
? 'mt-3'
: 'sticky bottom-0 mt-3 sm:static sm:mt-4',
].join(' ')}
>
<Button
size="md"
variant={hasManualCorrection ? 'primary' : 'soft'}
color={hasManualCorrection ? undefined : 'emerald'}
disabled={
uiLocked ||
frameBusy ||
!sample ||
(!hasManualCorrection && !sample.prediction.modelAvailable)
}
onClick={() => void saveFeedback(!hasManualCorrection)}
className="w-full justify-center px-2 text-xs sm:text-sm"
title={
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>
) : 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={
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">
<ForwardIcon className="h-3.5 w-3.5" aria-hidden="true" />
Ü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>
<div
className={[
'mt-2 hidden text-center text-xs text-gray-500 dark:text-gray-400',
imageExpanded ? 'lg:hidden' : 'lg:block',
].join(' ')}
>
Prüfe das Bild. Wenn die Erkennung stimmt: Passt so. Wenn nicht: korrigieren und speichern.
</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-4 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>
</div>
) : null}
</div>
<div className="flex h-[48dvh] min-h-[260px] flex-col overflow-hidden 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>
<div
ref={mobileLabelsScrollRef}
className="min-h-0 h-full flex-1 overflow-y-auto overscroll-contain p-3 scroll-smooth"
>
{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}
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}
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>
<TrainingStatsModal
open={statsModalOpen}
onClose={() => setStatsModalOpen(false)}
stats={trainingStats}
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>
)
}