nsfwapp/frontend/src/components/ui/TrainingTab.tsx
2026-06-17 09:24:18 +02:00

6713 lines
231 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

// frontend\src\components\ui\TrainingTab.tsx
'use client'
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react'
import Button from './Button'
import 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
positiveTrainCount: number
positiveValCount: 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
previewUrl?: string
map50?: number
map5095?: number
}
type TrainingPrediction = {
modelAvailable: boolean
source?: string
sexPosition: string
sexPositionScore: number
peoplePresent: ScoredLabel[]
bodyPartsPresent: ScoredLabel[]
objectsPresent: ScoredLabel[]
clothingPresent: ScoredLabel[]
boxes?: TrainingBox[]
}
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 TrainingModelInfo = {
trainedAt?: string
trainedAtMs?: number
epochs?: number
trainSamples?: number
valSamples?: number
imgsz?: number
device?: string
map50?: number
map5095?: number
}
type TrainingHistoryEntry = {
trainedAt?: string
trainedAtMs?: number
durationMs?: number
epochs?: number
trainSamples?: number
valSamples?: number
imgsz?: number
device?: string
map50?: number
map5095?: number
}
type TrainingStats = {
feedbackCount: number
acceptedCount: number
correctedCount: number
negativeCount: number
sampleCount: number
boxCount: number
modelAvailable: boolean
modelInfo?: TrainingModelInfo
confidence?: TrainingConfidence
labels: {
people: TrainingLabelStat[]
sexPositions: TrainingLabelStat[]
bodyParts: TrainingLabelStat[]
objects: TrainingLabelStat[]
clothing: TrainingLabelStat[]
}
}
type TrainingAnnotation = {
sampleId: string
frameUrl: string
sourceFile: string
sourcePath?: string
sourceSizeBytes?: number
second: number
createdAt: string
answeredAt: string
prediction: TrainingPrediction
accepted: boolean
negative?: boolean
correction?: CorrectionState
notes?: string
}
type TrainingFeedbackListResponse = {
ok: boolean
items: TrainingAnnotation[]
total: number
limit: number
offset: number
hasMore: boolean
}
type TrainingSampleMode = 'random' | 'uncertain'
type FeedbackFilter = 'all' | 'accepted' | 'corrected' | 'negative'
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 formatModelTrainedAt(info?: TrainingModelInfo): string {
if (!info) return ''
const ms = Number(info.trainedAtMs)
const date =
Number.isFinite(ms) && ms > 0
? new Date(ms)
: info.trainedAt
? new Date(info.trainedAt)
: null
if (!date || Number.isNaN(date.getTime())) return ''
return date.toLocaleString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
function formatMapPercent(value?: number | null): string {
const n = Number(value)
if (!Number.isFinite(n) || n <= 0) return ''
return `${(n * 100).toFixed(1)}%`
}
function formatHistoryDate(entry: TrainingHistoryEntry): string {
const ms = Number(entry.trainedAtMs)
const date =
Number.isFinite(ms) && ms > 0
? new Date(ms)
: entry.trainedAt
? new Date(entry.trainedAt)
: null
if (!date || Number.isNaN(date.getTime())) return '—'
return date.toLocaleString('de-DE', {
day: '2-digit',
month: '2-digit',
year: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
function confidencePillClass(confidence?: TrainingConfidence | null) {
switch (confidence?.level) {
case 'high':
return 'bg-emerald-50 text-emerald-800 ring-emerald-200 dark:bg-emerald-500/15 dark:text-emerald-100 dark:ring-emerald-400/30'
case 'mid':
return 'bg-yellow-50 text-yellow-900 ring-yellow-200 dark:bg-yellow-500/15 dark:text-yellow-100 dark:ring-yellow-400/30'
case 'low':
return 'bg-red-50 text-red-800 ring-red-200 dark:bg-red-500/15 dark:text-red-100 dark:ring-red-400/30'
default:
return 'bg-gray-50 text-gray-700 ring-gray-200 dark:bg-white/5 dark:text-gray-200 dark:ring-white/10'
}
}
function averageCategoryConfidence(values: TrainingLabelStat[]) {
const scores = values
.map((item) => Number(item.confidence?.score))
.filter((score) => Number.isFinite(score))
if (scores.length === 0) {
return undefined
}
const avg = scores.reduce((sum, score) => sum + clamp01(score), 0) / scores.length
return confidenceFromScore(avg)
}
function confidenceFromScore(score: number): TrainingConfidence {
const safeScore = clamp01(Number(score))
if (safeScore >= 0.75) {
return {
score: safeScore,
level: 'high',
label: 'Hoch',
}
}
if (safeScore >= 0.45) {
return {
score: safeScore,
level: 'mid',
label: 'Mittel',
}
}
if (safeScore > 0) {
return {
score: safeScore,
level: 'low',
label: 'Niedrig',
}
}
return {
score: 0,
level: 'none',
label: 'Keine',
}
}
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 correctionHasRelevantContent(value: CorrectionState) {
return (
(value.sexPosition && value.sexPosition !== 'unknown') ||
(value.peoplePresent?.length ?? 0) > 0 ||
(value.bodyPartsPresent?.length ?? 0) > 0 ||
(value.objectsPresent?.length ?? 0) > 0 ||
(value.clothingPresent?.length ?? 0) > 0 ||
(value.boxes?.length ?? 0) > 0
)
}
function applyBoxLabelToCorrection(
state: CorrectionState,
label: string,
labels: TrainingLabels
): CorrectionState {
const clean = String(label || '').trim()
if (!clean) return state
if (labels.people.includes(clean)) {
return {
...state,
peoplePresent: state.peoplePresent.includes(clean)
? state.peoplePresent
: [...state.peoplePresent, clean],
}
}
if (labels.bodyParts.includes(clean)) {
return {
...state,
bodyPartsPresent: state.bodyPartsPresent.includes(clean)
? state.bodyPartsPresent
: [...state.bodyPartsPresent, clean],
}
}
if (labels.objects.includes(clean)) {
return {
...state,
objectsPresent: state.objectsPresent.includes(clean)
? state.objectsPresent
: [...state.objectsPresent, clean],
}
}
if (labels.clothing.includes(clean)) {
return {
...state,
clothingPresent: state.clothingPresent.includes(clean)
? state.clothingPresent
: [...state.clothingPresent, clean],
}
}
return state
}
function removeBoxLabelFromCorrection(
state: CorrectionState,
label: string,
labels: TrainingLabels
): CorrectionState {
const clean = String(label || '').trim()
if (!clean) return state
const remainingBoxesWithSameLabel = (state.boxes ?? []).some(
(box) => String(box.label || '').trim() === clean
)
// Badge nur abwählen, wenn keine weitere Box mit diesem Label existiert.
if (remainingBoxesWithSameLabel) return state
if (labels.people.includes(clean)) {
return {
...state,
peoplePresent: state.peoplePresent.filter((x) => x !== clean),
}
}
if (labels.bodyParts.includes(clean)) {
return {
...state,
bodyPartsPresent: state.bodyPartsPresent.filter((x) => x !== clean),
}
}
if (labels.objects.includes(clean)) {
return {
...state,
objectsPresent: state.objectsPresent.filter((x) => x !== clean),
}
}
if (labels.clothing.includes(clean)) {
return {
...state,
clothingPresent: state.clothingPresent.filter((x) => x !== clean),
}
}
return state
}
function removeBoxFromCorrection(
state: CorrectionState,
index: number,
labels: TrainingLabels
): CorrectionState {
const boxes = state.boxes ?? []
const removed = boxes[index]
if (!removed) return state
const removedLabel = String(removed.label || '').trim()
const next: CorrectionState = {
...state,
boxes: boxes.filter((_, i) => i !== index),
}
return removeBoxLabelFromCorrection(next, removedLabel, labels)
}
function changeBoxLabelInCorrection(
state: CorrectionState,
index: number,
nextLabel: string,
labels: TrainingLabels
): CorrectionState {
const boxes = state.boxes ?? []
const currentBox = boxes[index]
const cleanNextLabel = String(nextLabel || '').trim()
if (!currentBox || !cleanNextLabel) return state
const oldLabel = String(currentBox.label || '').trim()
if (oldLabel === cleanNextLabel) return state
let next: CorrectionState = {
...state,
boxes: boxes.map((box, i) => {
if (i !== index) return box
const { score: _oldScore, ...boxWithoutScore } = box
return {
...boxWithoutScore,
label: cleanNextLabel,
}
}),
}
next = removeBoxLabelFromCorrection(next, oldLabel, labels)
next = applyBoxLabelToCorrection(next, cleanNextLabel, labels)
return next
}
function sortLabelList(values?: string[], opts?: { 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
instantBackground?: boolean
}) {
const progress = clampPercent(props.progress ?? 0)
const isTraining = props.mode === 'training'
const hasBackground = Boolean(props.backgroundUrl)
const visible = props.visible ?? true
const [backgroundVisible, setBackgroundVisible] = useState(false)
useEffect(() => {
if (!props.backgroundUrl) {
setBackgroundVisible(false)
return
}
setBackgroundVisible(false)
const frame = window.requestAnimationFrame(() => {
setBackgroundVisible(true)
})
return () => window.cancelAnimationFrame(frame)
}, [props.backgroundUrl])
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
// Beim schnellen Durchlaufen (Training) dasselbe Element wiederverwenden,
// damit nur die Bildquelle wechselt und kein Ein-/Ausblenden je Bild läuft.
key={props.instantBackground ? undefined : props.backgroundUrl}
src={props.backgroundUrl}
alt=""
aria-hidden="true"
draggable={false}
className={[
'absolute inset-0 z-0 h-full w-full object-contain blur-[1px]',
props.instantBackground
? 'opacity-80'
: [
'transition-opacity duration-500 ease-out will-change-opacity motion-reduce:transition-none',
backgroundVisible ? 'opacity-80' : 'opacity-0',
].join(' '),
].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
history?: TrainingHistoryEntry[]
loading: boolean
error: string | null
feedbackCount: number
requiredCount: number
}) {
const [activeTab, setActiveTab] = useState<TrainingStatsTabKey>('people')
const stats = props.stats
const acceptedCount = stats?.acceptedCount ?? 0
const correctedCount = stats?.correctedCount ?? 0
const negativeCount = stats?.negativeCount ?? 0
const totalFeedback = stats?.feedbackCount ?? props.feedbackCount
const boxCount = stats?.boxCount ?? 0
const sampleCount = stats?.sampleCount ?? 0
const overallConfidence = stats?.confidence
const modelTrainedAtLabel = formatModelTrainedAt(stats?.modelInfo)
const modelMap50Label = formatMapPercent(stats?.modelInfo?.map50)
const modelMap5095Label = formatMapPercent(stats?.modelInfo?.map5095)
const modelInfoDetails = (() => {
const info = stats?.modelInfo
if (!info) return ''
const parts: string[] = []
if (Number(info.epochs) > 0) parts.push(`${info.epochs} Epochen`)
if (Number(info.trainSamples) > 0) parts.push(`${info.trainSamples} Train`)
if (Number(info.valSamples) > 0) parts.push(`${info.valSamples} Val`)
if (String(info.device || '').trim()) parts.push(String(info.device).trim())
return parts.join(' · ')
})()
const history = props.history ?? []
const tabItems: Array<{
key: TrainingStatsTabKey
title: string
shortTitle: string
description: string
values: TrainingLabelStat[]
total: number
confidence?: TrainingConfidence
topLabel?: TrainingLabelStat
}> = [
{
key: 'people',
title: 'Personen',
shortTitle: 'Personen',
description: 'Personen- und Gender-Labels aus Boxen.',
values: stats?.labels.people ?? [],
total: Math.max(1, boxCount),
confidence: averageCategoryConfidence(stats?.labels.people ?? []),
topLabel: topTrainingLabelStat(stats?.labels.people ?? []),
},
{
key: 'sexPositions',
title: 'Sexpositionen',
shortTitle: 'Positionen',
description: 'Positions-Labels aus YOLO-Detector-Labels pro bewertetem Frame.',
values: stats?.labels.sexPositions ?? [],
total: Math.max(1, totalFeedback),
confidence: averageCategoryConfidence(stats?.labels.sexPositions ?? []),
topLabel: topTrainingLabelStat(stats?.labels.sexPositions ?? []),
},
{
key: 'bodyParts',
title: 'Körperteile',
shortTitle: 'Körper',
description: 'Körperteil-Labels aus Korrekturen und Boxen.',
values: stats?.labels.bodyParts ?? [],
total: Math.max(1, totalFeedback),
confidence: averageCategoryConfidence(stats?.labels.bodyParts ?? []),
topLabel: topTrainingLabelStat(stats?.labels.bodyParts ?? []),
},
{
key: 'objects',
title: 'Gegenstände',
shortTitle: 'Objekte',
description: 'Objekt-Labels aus Korrekturen und Boxen.',
values: stats?.labels.objects ?? [],
total: Math.max(1, totalFeedback),
confidence: averageCategoryConfidence(stats?.labels.objects ?? []),
topLabel: topTrainingLabelStat(stats?.labels.objects ?? []),
},
{
key: 'clothing',
title: 'Kleidung',
shortTitle: 'Kleidung',
description: 'Kleidungs-Labels aus Korrekturen und Boxen.',
values: stats?.labels.clothing ?? [],
total: Math.max(1, totalFeedback),
confidence: averageCategoryConfidence(stats?.labels.clothing ?? []),
topLabel: topTrainingLabelStat(stats?.labels.clothing ?? []),
},
]
const activeTabItem =
tabItems.find((item) => item.key === activeTab) ?? tabItems[0]
return (
<Modal
open={props.open}
onClose={props.onClose}
title="Training-Statistiken"
width="max-w-3xl"
bodyClassName="p-0"
>
<div className="px-3 pb-4 pt-2 sm:px-6 sm:pb-6 sm:pt-4">
{props.loading ? (
<div className="flex min-h-40 items-center justify-center">
<div className="text-center">
<LoadingSpinner
size="lg"
srLabel="Statistiken werden geladen…"
/>
<div className="mt-3 text-sm font-medium text-gray-700 dark:text-gray-200">
Statistiken werden geladen
</div>
</div>
</div>
) : props.error ? (
<div className="rounded-lg bg-red-50 px-3 py-2 text-sm text-red-700 dark:bg-red-500/10 dark:text-red-200">
{props.error}
</div>
) : (
<div className="space-y-3 sm:space-y-4">
{/* Mobile: kompakte Top-Zusammenfassung */}
<div className="sm:hidden">
<div className="rounded-xl border border-gray-200 bg-white p-3 shadow-sm dark:border-white/10 dark:bg-gray-900/70">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-sm font-semibold text-gray-900 dark:text-white">
{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-5 gap-1.5">
<div className="rounded-xl bg-gray-50 px-2 py-1.5 text-center ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
<div className="text-[9px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Feedback
</div>
<div className="mt-0.5 text-sm font-black text-gray-900 dark:text-white">
{totalFeedback}
</div>
</div>
<div className="rounded-xl bg-gray-50 px-2 py-1.5 text-center ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
<div className="text-[9px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Passt
</div>
<div className="mt-0.5 text-sm font-black text-emerald-700 dark:text-emerald-300">
{acceptedCount}
</div>
</div>
<div className="rounded-xl bg-gray-50 px-2 py-1.5 text-center ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
<div className="text-[9px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Korr.
</div>
<div className="mt-0.5 text-sm font-black text-amber-700 dark:text-amber-300">
{correctedCount}
</div>
</div>
<div className="rounded-xl bg-gray-50 px-2 py-1.5 text-center ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
<div className="text-[9px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Negativ
</div>
<div className="mt-0.5 text-sm font-black text-blue-700 dark:text-blue-300">
{negativeCount}
</div>
</div>
<div className="rounded-xl bg-gray-50 px-2 py-1.5 text-center ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
<div className="text-[9px] font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Boxen
</div>
<div className="mt-0.5 text-sm font-black text-gray-900 dark:text-white">
{boxCount}
</div>
</div>
</div>
</div>
</div>
{/* Desktop/Tablet: ausführliche Karten */}
<div className="hidden sm:grid sm:grid-cols-5 sm:gap-2">
<div className="rounded-xl bg-gray-50 p-3 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
<div className="text-[11px] font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
Feedback
</div>
<div className="mt-1 text-2xl font-bold text-gray-900 dark:text-white">
{totalFeedback}
</div>
<div className="mt-1 text-[11px] text-gray-500 dark:text-gray-400">
benötigt: {props.requiredCount}
</div>
</div>
<div className="rounded-xl bg-gray-50 p-3 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
<div className="text-[11px] font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
Passt so
</div>
<div className="mt-1 text-2xl font-bold text-emerald-700 dark:text-emerald-300">
{acceptedCount}
</div>
<div className="mt-1 text-[11px] text-gray-500 dark:text-gray-400">
{countPercent(acceptedCount, totalFeedback)}
</div>
</div>
<div className="rounded-xl bg-gray-50 p-3 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
<div className="text-[11px] font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
Korrigiert
</div>
<div className="mt-1 text-2xl font-bold text-amber-700 dark:text-amber-300">
{correctedCount}
</div>
<div className="mt-1 text-[11px] text-gray-500 dark:text-gray-400">
{countPercent(correctedCount, totalFeedback)}
</div>
</div>
<div className="rounded-xl bg-gray-50 p-3 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
<div className="text-[11px] font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
Negativ
</div>
<div className="mt-1 text-2xl font-bold text-blue-700 dark:text-blue-300">
{negativeCount}
</div>
<div className="mt-1 text-[11px] text-gray-500 dark:text-gray-400">
{countPercent(negativeCount, totalFeedback)}
</div>
</div>
<div className="rounded-xl bg-gray-50 p-3 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
<div className="text-[11px] font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
Boxen
</div>
<div className="mt-1 text-2xl font-bold text-gray-900 dark:text-white">
{boxCount}
</div>
<div className="mt-1 text-[11px] text-gray-500 dark:text-gray-400">
{sampleCount} Samples
</div>
</div>
</div>
<div className="hidden grid-cols-1 gap-3 sm:grid sm:grid-cols-2">
<div className="rounded-xl border border-indigo-100 bg-indigo-50 p-4 dark:border-indigo-400/20 dark:bg-indigo-500/10">
<div className="text-[11px] font-medium uppercase tracking-wide text-indigo-700/80 dark:text-indigo-200/80">
Modellstatus
</div>
<div className="mt-2 text-sm font-semibold text-indigo-950 dark:text-indigo-50">
{stats?.modelAvailable
? 'Trainiertes Modell verfügbar'
: 'Noch kein trainiertes Modell verfügbar'}
</div>
{stats?.modelAvailable && modelTrainedAtLabel ? (
<div className="mt-2 space-y-1">
<div className="flex items-center justify-between gap-2 text-xs">
<span className="font-medium text-indigo-800/80 dark:text-indigo-100/70">
Version vom
</span>
<span className="font-semibold tabular-nums text-indigo-950 dark:text-indigo-50">
{modelTrainedAtLabel}
</span>
</div>
{modelMap50Label ? (
<div className="flex items-center justify-between gap-2 text-xs">
<span className="font-medium text-indigo-800/80 dark:text-indigo-100/70">
Qualität (mAP50{modelMap5095Label ? ' / 50-95' : ''})
</span>
<span className="font-semibold tabular-nums text-indigo-950 dark:text-indigo-50">
{modelMap50Label}
{modelMap5095Label ? ` / ${modelMap5095Label}` : ''}
</span>
</div>
) : null}
{modelInfoDetails ? (
<div className="text-[11px] text-indigo-800/70 dark:text-indigo-100/60">
{modelInfoDetails}
</div>
) : null}
</div>
) : (
<div className="mt-1 text-xs leading-relaxed text-indigo-800/80 dark:text-indigo-100/70">
{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>
{history.length > 0 ? (
<div className="overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm dark:border-white/10 dark:bg-gray-900/70">
<div className="border-b border-gray-200 bg-gray-50/80 px-4 py-3 dark:border-white/10 dark:bg-white/[0.03]">
<div className="text-sm font-semibold text-gray-900 dark:text-white">
Trainings-Verlauf
</div>
<div className="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
Modellqualität (mAP) über die letzten Trainingsläufe
</div>
</div>
<div className="max-h-56 divide-y divide-gray-100 overflow-y-auto dark:divide-white/10">
{history.map((entry, idx) => {
const map50 = formatMapPercent(entry.map50)
const map5095 = formatMapPercent(entry.map5095)
const duration =
Number(entry.durationMs) > 0
? formatDuration(Number(entry.durationMs))
: ''
const barPct = Math.max(
0,
Math.min(100, Math.round(Number(entry.map50 ?? 0) * 100))
)
const meta: string[] = []
if (Number(entry.epochs) > 0) meta.push(`${entry.epochs} Ep.`)
if (Number(entry.trainSamples) > 0) meta.push(`${entry.trainSamples} Train`)
if (duration) meta.push(duration)
return (
<div key={`${entry.trainedAtMs}-${idx}`} className="px-4 py-2.5">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-gray-900 dark:text-white">
{formatHistoryDate(entry)}
{idx === 0 ? (
<span className="ml-2 align-middle rounded-full bg-indigo-100 px-1.5 py-0.5 text-[9px] font-bold text-indigo-700 ring-1 ring-indigo-200 dark:bg-indigo-500/20 dark:text-indigo-100 dark:ring-indigo-300/30">
aktuell
</span>
) : null}
</div>
{meta.length > 0 ? (
<div className="mt-0.5 truncate text-[11px] text-gray-500 dark:text-gray-400">
{meta.join(' · ')}
</div>
) : null}
</div>
<div className="shrink-0 text-right">
<div className="text-sm font-bold tabular-nums text-gray-900 dark:text-white">
{map50 || '—'}
</div>
<div className="text-[10px] tabular-nums text-gray-500 dark:text-gray-400">
{map5095 ? `50-95: ${map5095}` : 'mAP50'}
</div>
</div>
</div>
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-gray-200 dark:bg-white/10">
<div
className="h-full rounded-full bg-indigo-500 transition-all"
style={{ width: `${barPct}%` }}
/>
</div>
</div>
)
})}
</div>
</div>
) : null}
{/* Mobile: Tabs kompakter, direkt nach Summary */}
<div className="overflow-hidden rounded-xl border border-gray-200 bg-gray-50/70 p-1.5 dark:border-white/10 dark:bg-white/[0.03] sm:p-2">
<div className="grid grid-cols-2 gap-1 sm:grid-cols-5">
{tabItems.map((item) => {
const active = item.key === activeTab
const topLabelItem = item.topLabel
? getSegmentLabelItem(item.topLabel.label)
: null
const TopIcon = topLabelItem?.icon
return (
<button
key={item.key}
type="button"
onClick={() => setActiveTab(item.key)}
className={[
'rounded-xl px-2.5 py-2 text-left transition',
'focus:outline-none focus:ring-2 focus:ring-indigo-500/40',
active
? [
'bg-white text-indigo-700 shadow-sm ring-1 ring-indigo-200',
'dark:bg-indigo-500/18 dark:text-indigo-50 dark:ring-indigo-300/45',
'dark:shadow-[0_0_0_1px_rgba(129,140,248,0.20),0_10px_24px_rgba(79,70,229,0.18)]',
].join(' ')
: [
'text-gray-600 hover:bg-white/70 hover:text-gray-900',
'dark:text-gray-300 dark:hover:bg-white/10 dark:hover:text-white',
].join(' '),
].join(' ')}
>
<div className="flex min-w-0 items-center gap-2.5">
{TopIcon ? (
<div
className={[
'flex h-9 w-9 shrink-0 items-center justify-center rounded-xl ring-1',
active
? [
'bg-indigo-50 text-indigo-700 ring-indigo-200',
'dark:bg-white/12 dark:text-indigo-50 dark:ring-white/20',
].join(' ')
: [
'bg-white text-gray-500 ring-gray-200',
'dark:bg-white/7 dark:text-gray-300 dark:ring-white/10',
].join(' '),
].join(' ')}
aria-hidden="true"
>
<TopIcon className="h-5 w-5" aria-hidden="true" />
</div>
) : null}
<div className="min-w-0 flex-1">
<div className="truncate text-xs font-semibold">
<span className="sm:hidden">{item.shortTitle}</span>
<span className="hidden sm:inline">{item.title}</span>
</div>
<div className="mt-1 flex items-center justify-start gap-2">
<span
className={[
'rounded-full px-1.5 py-0.5 text-[9px] font-bold ring-1',
confidencePillClass(item.confidence),
].join(' ')}
title={`Kategorie-Confidence: ${confidencePercent(item.confidence)}`}
>
{confidenceLabel(item.confidence)} · {confidencePercent(item.confidence)}
</span>
</div>
</div>
</div>
</button>
)
})}
</div>
</div>
<TrainingStatsList
title={activeTabItem.title}
description={activeTabItem.description}
values={activeTabItem.values}
total={activeTabItem.total}
confidence={activeTabItem.confidence}
/>
</div>
)}
</div>
</Modal>
)
}
function makeRequestId() {
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
return crypto.randomUUID()
}
return `${Date.now()}-${Math.random().toString(16).slice(2)}`
}
function annotationToTrainingSample(item: TrainingAnnotation): TrainingSample {
return {
sampleId: item.sampleId,
frameUrl: item.frameUrl,
sourceFile: item.sourceFile,
sourcePath: item.sourcePath,
sourceSizeBytes: item.sourceSizeBytes,
second: item.second,
createdAt: item.createdAt,
prediction: item.prediction,
}
}
function annotationToCorrectionState(item: TrainingAnnotation): CorrectionState {
if (item.negative) {
return {
sexPosition: 'unknown',
peoplePresent: [],
bodyPartsPresent: [],
objectsPresent: [],
clothingPresent: [],
boxes: [],
}
}
if (item.correction) {
return item.correction
}
return predictionToCorrection(annotationToTrainingSample(item))
}
const FEEDBACK_PAGE_SIZE = 10
const TRAINING_INFO_DISMISSED_STORAGE_KEY = 'training:info-dismissed-key'
const TRAINING_IMAGE_EXPANDED_STORAGE_KEY = 'training:image-expanded'
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 [negativeExample, setNegativeExample] = 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 [trainingHistory, setTrainingHistory] = useState<TrainingHistoryEntry[]>([])
const wasTrainingRunningRef = useRef(false)
const shownTrainingCompletionRef = useRef<string | null>(null)
const [dismissedTrainingInfoKey, setDismissedTrainingInfoKey] = useState(() => {
if (typeof window === 'undefined') return ''
try {
return window.localStorage.getItem(TRAINING_INFO_DISMISSED_STORAGE_KEY) || ''
} catch {
return ''
}
})
const [importedSampleQueue, setImportedSampleQueue] = useState<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(() => {
if (typeof window === 'undefined') return false
try {
return window.localStorage.getItem(TRAINING_IMAGE_EXPANDED_STORAGE_KEY) === '1'
} catch {
return 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)
// Während des Trainings sendet das Backend zum gerade trainierten Batch eine
// Vorschau. Wir zeigen immer das zuletzt eingegangene Bild (live) und merken es
// in einem Ref, damit die Anzeige unabhängig von den (gedrosselten) Status-
// Updates bleibt und die Render-Rate begrenzt ist.
const [trainingPreviewUrl, setTrainingPreviewUrl] = useState('')
const latestTrainingPreviewRef = useRef('')
const lastTrainingStatusApplyRef = useRef(0)
const [stageOverlayMounted, setStageOverlayMounted] = useState(false)
const [stageOverlayVisible, setStageOverlayVisible] = useState(false)
const imageBoxRef = useRef<HTMLDivElement | null>(null)
const frameImageRef = useRef<HTMLImageElement | null>(null)
const [imageLayerStyle, setImageLayerStyle] = useState<CSSProperties | null>(null)
type ImageContentRect = {
left: number
top: number
width: number
height: number
right: number
bottom: number
}
const 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)
setNegativeExample(Boolean(item.negative))
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 isNegativeCorrection =
negativeExample && !correctionHasRelevantContent(correction)
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 drawingCursorClass =
boxInteraction?.type === 'move'
? '[@media_(hover:hover)_and_(pointer:fine)]:cursor-grabbing'
: drawingBox || (boxLabel && !uiLocked)
? '[@media_(hover:hover)_and_(pointer:fine)]:cursor-crosshair'
: ''
const trainingInfoJob = trainingStatus?.training ?? null
const trainingInfoKey = useMemo(() => {
const job = trainingInfoJob
if (!job || job.running) return ''
const finishedAt = String(job.finishedAt || '').trim()
const startedAt = String(job.startedAt || '').trim()
const message = String(job.message || job.error || '').trim()
if (finishedAt) return finishedAt
if (startedAt && message) return `${startedAt}:${message}`
return ''
}, [
trainingInfoJob?.running,
trainingInfoJob?.finishedAt,
trainingInfoJob?.startedAt,
trainingInfoJob?.message,
trainingInfoJob?.error,
])
const showTrainingInfo =
Boolean(trainingInfoKey) &&
!trainingRunning &&
dismissedTrainingInfoKey !== trainingInfoKey
const trainingInfoMessage = String(
trainingInfoJob?.message ||
trainingInfoJob?.error ||
'Training abgeschlossen.'
).trim()
const trainingInfoDurationMs = trainingDurationMs(trainingInfoJob)
const trainingInfoLooksPartial =
Boolean(trainingInfoJob?.error) ||
/übersprungen|fehlgeschlagen|abgebrochen/i.test(trainingInfoMessage)
const dismissTrainingInfo = useCallback(() => {
if (!trainingInfoKey) return
setDismissedTrainingInfoKey(trainingInfoKey)
try {
window.localStorage.setItem(
TRAINING_INFO_DISMISSED_STORAGE_KEY,
trainingInfoKey
)
} catch {
// ignore
}
}, [trainingInfoKey])
const analysisConfidence = useMemo(() => {
return currentAnalysisConfidence(sample?.prediction)
}, [sample?.prediction])
const loadLabels = useCallback(async () => {
const res = await fetch('/api/training/labels', { cache: 'no-store' })
if (!res.ok) return
const data = await res.json().catch(() => null)
if (data) setLabels(sortTrainingLabels(data))
}, [])
const applyTrainingStatus = useCallback((data: any) => {
if (!data) return
const job = data.training || null
setTrainingStatus((prev) => ({
feedbackCount: Number(data.feedbackCount ?? prev?.feedbackCount ?? 0),
requiredCount: Number(data.requiredCount ?? prev?.requiredCount ?? 5),
canTrain: Boolean(data.canTrain ?? prev?.canTrain ?? false),
detector: data.detector
? {
trainCount: Number(data.detector.trainCount ?? 0),
valCount: Number(data.detector.valCount ?? 0),
positiveTrainCount: Number(data.detector.positiveTrainCount ?? 0),
positiveValCount: Number(data.detector.positiveValCount ?? 0),
requiredTrain: Number(data.detector.requiredTrain ?? 20),
requiredVal: Number(data.detector.requiredVal ?? 3),
datasetReady: Boolean(data.detector.datasetReady),
dataReady: Boolean(data.detector.dataReady),
modelExists: Boolean(data.detector.modelExists),
modelPath: data.detector.modelPath,
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),
previewUrl: String(job.previewUrl ?? ''),
map50: Number(job.map50 ?? 0),
map5095: Number(job.map5095 ?? 0),
}
: prev?.training,
}))
setTraining(Boolean(job?.running))
if (job?.message && !job.running) {
setTrainingProgress(100)
setTrainingStep('Training abgeschlossen.')
const finishedAt = String(job.finishedAt || '').trim()
const completionKey =
finishedAt || `${String(job.startedAt || '')}:${String(job.message || '')}`
if (completionKey && shownTrainingCompletionRef.current !== completionKey) {
shownTrainingCompletionRef.current = completionKey
const duration = trainingDurationMs(job)
const durationText = duration > 0
? ` Dauer: ${formatDuration(duration)}.`
: ''
setMessage(`${String(job.message)}${durationText}`)
}
}
if (job?.error && !job.running) {
const finishedAt = String(job.finishedAt || '').trim()
const errorKey = finishedAt
? `${finishedAt}:error`
: `${String(job.startedAt || '')}:error`
if (errorKey && shownTrainingCompletionRef.current !== errorKey) {
shownTrainingCompletionRef.current = errorKey
setError(String(job.error))
}
}
}, [])
const loadTrainingSampleIntoTab = useCallback((
nextSample: TrainingSample,
opts?: { manualCorrection?: boolean }
) => {
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))
setNegativeExample(false)
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 TRAINING_IMPORT_FRAME_COUNT = 10
const detail: PendingTrainingVideoImport = {
jobId: String(raw?.jobId || '').trim(),
output,
sourceFile: String(raw?.sourceFile || '').trim(),
count: Number(raw?.count || TRAINING_IMPORT_FRAME_COUNT),
}
const importKey = `${detail.jobId || ''}|${detail.output}|${detail.count || TRAINING_IMPORT_FRAME_COUNT}`
// Verhindert Doppelimport durch sessionStorage + CustomEvent.
if (videoImportInFlightKeyRef.current === importKey) {
return false
}
videoImportStartedRef.current = true
videoImportInFlightKeyRef.current = importKey
const requestId = makeRequestId()
activeAnalysisRequestIdRef.current = requestId
loadingRef.current = true
setLoading(true)
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 || TRAINING_IMPORT_FRAME_COUNT,
analysisRequestId: requestId,
}),
})
const data = await res.json().catch(() => null)
if (!res.ok || !data?.ok) {
throw new Error(backendText(data, `HTTP ${res.status}`))
}
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),
negativeCount: Number(data?.negativeCount ?? 0),
sampleCount: Number(data?.sampleCount ?? 0),
boxCount: Number(data?.boxCount ?? 0),
modelAvailable: Boolean(data?.modelAvailable),
modelInfo: data?.modelInfo
? {
trainedAt: data.modelInfo.trainedAt,
trainedAtMs: Number(data.modelInfo.trainedAtMs ?? 0),
epochs: Number(data.modelInfo.epochs ?? 0),
trainSamples: Number(data.modelInfo.trainSamples ?? 0),
valSamples: Number(data.modelInfo.valSamples ?? 0),
imgsz: Number(data.modelInfo.imgsz ?? 0),
device: data.modelInfo.device,
map50: Number(data.modelInfo.map50 ?? 0),
map5095: Number(data.modelInfo.map5095 ?? 0),
}
: undefined,
confidence: data?.confidence,
labels: {
people: Array.isArray(data?.labels?.people) ? data.labels.people : [],
sexPositions: Array.isArray(data?.labels?.sexPositions) ? data.labels.sexPositions : [],
bodyParts: Array.isArray(data?.labels?.bodyParts) ? data.labels.bodyParts : [],
objects: Array.isArray(data?.labels?.objects) ? data.labels.objects : [],
clothing: Array.isArray(data?.labels?.clothing) ? data.labels.clothing : [],
},
})
} catch (e) {
setTrainingStatsError(e instanceof Error ? e.message : String(e))
} finally {
setTrainingStatsLoading(false)
}
}, [])
const loadTrainingHistory = useCallback(async () => {
try {
const res = await fetch('/api/training/history', { cache: 'no-store' })
const data = await res.json().catch(() => null)
if (!res.ok || !data) return
setTrainingHistory(
Array.isArray(data?.entries)
? data.entries.map((e: any) => ({
trainedAt: e?.trainedAt,
trainedAtMs: Number(e?.trainedAtMs ?? 0),
durationMs: Number(e?.durationMs ?? 0),
epochs: Number(e?.epochs ?? 0),
trainSamples: Number(e?.trainSamples ?? 0),
valSamples: Number(e?.valSamples ?? 0),
imgsz: Number(e?.imgsz ?? 0),
device: e?.device,
map50: Number(e?.map50 ?? 0),
map5095: Number(e?.map5095 ?? 0),
}))
: []
)
} catch {
// ignore
}
}, [])
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
const job = data.training || null
const running = Boolean(job?.running)
// Immer das zuletzt gesendete Bild merken (live, ältere werden übersprungen).
const previewUrl = String(job?.previewUrl ?? '').trim()
if (running && previewUrl) {
latestTrainingPreviewRef.current = previewUrl
}
// Status (Progress/Epoche) nur gedrosselt anwenden, damit pro Bild
// kein Re-Render des gesamten Tabs ausgelöst wird. Das Abschluss-Event
// (running=false) wird immer angewendet.
const now = Date.now()
if (!running || now - lastTrainingStatusApplyRef.current >= 200) {
lastTrainingStatusApplyRef.current = now
applyTrainingStatus({ training: job })
}
} catch {
// ignore
}
}
window.addEventListener('app:sse:training', onTraining as EventListener)
return () => {
window.removeEventListener('app:sse:training', onTraining as EventListener)
}
}, [applyTrainingStatus])
// Im festen Takt immer das zuletzt eingegangene Bild anzeigen.
// So bleibt die Anzeige live (max. ~110 ms Versatz) und die Render-Rate begrenzt.
useEffect(() => {
if (!trainingRunning) {
latestTrainingPreviewRef.current = ''
setTrainingPreviewUrl('')
return
}
const timer = window.setInterval(() => {
const latest = latestTrainingPreviewRef.current
if (!latest) return
setTrainingPreviewUrl((cur) => (cur === latest ? cur : latest))
}, 110)
return () => window.clearInterval(timer)
}, [trainingRunning])
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()
void loadTrainingHistory()
}, [statsModalOpen, loadTrainingStats, loadTrainingHistory])
const onTrainingRunningChange = props.onTrainingRunningChange
useEffect(() => {
onTrainingRunningChange?.(trainingRunning)
}, [trainingRunning, onTrainingRunningChange])
useEffect(() => {
props.onImageExpandedChange?.(imageExpanded)
return () => {
props.onImageExpandedChange?.(false)
}
}, [imageExpanded, props.onImageExpandedChange])
useEffect(() => {
try {
window.localStorage.setItem(
TRAINING_IMAGE_EXPANDED_STORAGE_KEY,
imageExpanded ? '1' : '0'
)
} catch {
// ignore
}
}, [imageExpanded])
useEffect(() => {
if (!trainingRunning) {
etaSmoothingRef.current = {
lastAt: 0,
lastRawEtaMs: 0,
}
setSmoothedTrainingEtaMs(0)
return
}
setTrainingNowMs(Date.now())
const timer = window.setInterval(() => {
setTrainingNowMs(Date.now())
}, 1000)
return () => window.clearInterval(timer)
}, [trainingRunning])
useEffect(() => {
if (!boxLabel) return
const currentLabels = labelsRef.current
const hasLoadedBoxLabels =
currentLabels.people.length > 0 ||
currentLabels.bodyParts.length > 0 ||
currentLabels.objects.length > 0 ||
currentLabels.clothing.length > 0
// Wichtig: Während Labels noch laden oder kurz leer sind, Auswahl nicht löschen.
if (!hasLoadedBoxLabels) return
const stillExists = [
...currentLabels.people,
...currentLabels.bodyParts,
...currentLabels.objects,
...currentLabels.clothing,
].includes(boxLabel)
if (!stillExists) {
setBoxLabel('')
}
}, [boxLabel, labels])
useEffect(() => {
const wasRunning = wasTrainingRunningRef.current
if (wasRunning && !trainingRunning && trainingStatus?.training?.finishedAt) {
void loadNext({ refreshPrediction: true, preserveNotice: true })
}
wasTrainingRunningRef.current = trainingRunning
}, [trainingRunning, trainingStatus?.training?.finishedAt, loadNext])
useEffect(() => {
if (activeBoxIndex === null) return
const scrollEl = detectorBoxesScrollRef.current
const itemEl = detectorBoxItemRefs.current[activeBoxIndex]
if (!scrollEl || !itemEl) return
itemEl.scrollIntoView({
block: 'nearest',
inline: 'nearest',
behavior: 'smooth',
})
}, [activeBoxIndex])
useEffect(() => {
const onImportVideo = (event: Event) => {
const detail = (event as CustomEvent<any>).detail
void importVideoIntoTraining(detail)
}
window.addEventListener('training:import-video', onImportVideo as EventListener)
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,
options?: {
negative?: 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 negative = options?.negative ?? isNegativeCorrection
const correctionPayload: CorrectionState = negative
? {
sexPosition: 'unknown',
peoplePresent: [],
bodyPartsPresent: [],
objectsPresent: [],
clothingPresent: [],
boxes: [],
}
: {
...correction,
peoplePresent: peopleLabelsFromBoxes(normalizedBoxes, labelsRef.current),
boxes: normalizedBoxes,
}
const effectiveAccepted = negative ? false : accepted
const payload = {
sampleId: sample.sampleId,
accepted: effectiveAccepted,
negative,
correction: effectiveAccepted && correctionPayload.boxes.length === 0
? undefined
: correctionPayload,
}
const res = await fetch(
editingFeedback ? '/api/training/feedback/update' : '/api/training/feedback',
{
method: editingFeedback ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(
editingFeedback
? {
...payload,
sampleId: editingFeedback.sampleId,
answeredAt: editingFeedback.answeredAt,
}
: payload
),
}
)
const data = await res.json().catch(() => null)
if (!res.ok) {
throw new Error(backendText(data, `HTTP ${res.status}`))
}
const wasEditingFeedback = Boolean(editingFeedback)
if (wasEditingFeedback) {
setEditingFeedback(null)
setFeedbackItems((current) =>
current.map((item) =>
item.sampleId === editingFeedback?.sampleId &&
item.answeredAt === editingFeedback?.answeredAt
? {
...item,
accepted: effectiveAccepted,
negative,
correction: effectiveAccepted ? undefined : correctionPayload,
}
: item
)
)
}
setMessage(
wasEditingFeedback
? negative
? 'Negativbeispiel aktualisiert.'
: effectiveAccepted
? 'Feedback aktualisiert.'
: 'Korrektur aktualisiert.'
: negative
? 'Negativbeispiel gespeichert.'
: effectiveAccepted
? 'Feedback gespeichert.'
: 'Korrektur gespeichert.'
)
await loadTrainingStatus()
if (!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,
isNegativeCorrection,
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)
setNegativeExample(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
setDismissedTrainingInfoKey('')
try {
window.localStorage.removeItem(TRAINING_INFO_DISMISSED_STORAGE_KEY)
} catch {
// ignore
}
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))
setNegativeExample(false)
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 missingPositiveTrain = Number(detector?.positiveTrainCount ?? 0) < 1
const missingPositiveVal = Number(detector?.positiveValCount ?? 0) < 1
const positiveSamplesMissing = missingPositiveTrain || missingPositiveVal
const statusText = trainingRunning
? shownTrainingStep || 'Training läuft…'
: !feedbackReady
? `${Math.max(0, requiredCount - feedbackCount)} Feedback fehlen noch`
: !detectorReady
? missingTrain > 0 || missingVal > 0
? `YOLO-Beispiele fehlen: ${missingTrain} Train, ${missingVal} Val`
: positiveSamplesMissing
? 'Je ein positives Beispiel in Train und Val erforderlich'
: 'YOLO-Datensatz noch nicht bereit'
: 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}
{showTrainingInfo ? (
<div
className={[
'mt-3 rounded-xl p-3 ring-1',
trainingInfoLooksPartial
? 'bg-amber-50 text-amber-950 ring-amber-200 dark:bg-amber-500/10 dark:text-amber-100 dark:ring-amber-400/30'
: 'bg-emerald-50 text-emerald-950 ring-emerald-200 dark:bg-emerald-500/10 dark:text-emerald-100 dark:ring-emerald-400/30',
].join(' ')}
>
<div className="flex items-start gap-3">
<div
className={[
'mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg ring-1',
trainingInfoLooksPartial
? 'bg-amber-100 text-amber-700 ring-amber-200 dark:bg-amber-500/15 dark:text-amber-200 dark:ring-amber-400/30'
: 'bg-emerald-100 text-emerald-700 ring-emerald-200 dark:bg-emerald-500/15 dark:text-emerald-200 dark:ring-emerald-400/30',
].join(' ')}
>
{trainingInfoLooksPartial ? (
<XCircleIcon className="h-4 w-4" aria-hidden="true" />
) : (
<CheckIcon className="h-4 w-4" aria-hidden="true" />
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="text-xs font-bold">
Training-Info
</div>
<div className="mt-1 text-[11px] leading-snug opacity-90">
{trainingInfoMessage}
</div>
</div>
<button
type="button"
onClick={dismissTrainingInfo}
className="shrink-0 rounded-md px-1.5 py-0.5 text-xs font-bold opacity-60 transition hover:bg-black/5 hover:opacity-100 dark:hover:bg-white/10"
aria-label="Training-Info ausblenden"
title="Bis zum nächsten Training ausblenden"
>
×
</button>
</div>
<div className="mt-3 grid grid-cols-2 gap-2 text-[10px]">
<div
className="rounded-lg bg-white/70 px-2 py-1.5 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10"
title={`${trainingStatus?.detector?.positiveTrainCount ?? 0} positive Trainingsbeispiele`}
>
<div className="font-semibold opacity-60">
Dauer
</div>
<div className="mt-0.5 font-bold">
{trainingInfoDurationMs > 0
? formatDuration(trainingInfoDurationMs)
: '—'}
</div>
</div>
<div
className="rounded-lg bg-white/70 px-2 py-1.5 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10"
title={`${trainingStatus?.detector?.positiveValCount ?? 0} positive Validierungsbeispiele`}
>
<div className="font-semibold opacity-60">
Feedback
</div>
<div className="mt-0.5 font-bold">
{feedbackCount}/{requiredCount}
</div>
</div>
<div className="rounded-lg bg-white/70 px-2 py-1.5 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
<div className="font-semibold opacity-60">
YOLO Train
</div>
<div className="mt-0.5 font-bold">
{trainingStatus?.detector?.trainCount ?? 0}/{trainingStatus?.detector?.requiredTrain ?? 20}
</div>
</div>
<div className="rounded-lg bg-white/70 px-2 py-1.5 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
<div className="font-semibold opacity-60">
YOLO Val
</div>
<div className="mt-0.5 font-bold">
{trainingStatus?.detector?.valCount ?? 0}/{trainingStatus?.detector?.requiredVal ?? 3}
</div>
</div>
</div>
</div>
</div>
</div>
) : null}
<div className="mt-3 grid grid-cols-2 gap-2">
<Button
size="md"
variant="secondary"
className="w-full justify-center px-2 text-[11px]"
disabled={uiLocked || !sample}
onClick={() => void reloadCurrentImage()}
title="Lädt das aktuelle Bild erneut und führt die Analyse neu aus."
>
<span className="inline-flex items-center gap-1.5">
<ArrowPathIcon className="h-3.5 w-3.5" aria-hidden="true" />
Neuladen
</span>
</Button>
<Button
size="md"
variant={trainingRunning ? 'primary' : canStartTraining ? 'primary' : 'soft'}
color={trainingRunning ? 'red' : undefined}
className="w-full justify-center px-2 text-[11px]"
disabled={
trainingRunning
? cancellingTraining || deletingTrainingData
: uiLocked || !canStartTraining
}
onClick={() => {
if (trainingRunning) {
void cancelTraining()
return
}
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
? `YOLO-Datensatz noch nicht bereit: Train ${detector?.trainCount ?? 0}/${detector?.requiredTrain ?? 20} (${detector?.positiveTrainCount ?? 0} positiv), Val ${detector?.valCount ?? 0}/${detector?.requiredVal ?? 3} (${detector?.positiveValCount ?? 0} positiv).`
: '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 = imageExpanded
? ({
'--image-stage-max-h': `${imageStageLimits.baseDvh}dvh`,
'--image-stage-max-h-sm': `${imageStageLimits.smDvh}dvh`,
} as CSSProperties & Record<string, string | number>)
: ({
aspectRatio: `${frameLayoutSize.width} / ${frameLayoutSize.height}`,
'--image-stage-max-h': `${imageStageLimits.baseDvh}dvh`,
'--image-stage-max-h-sm': `${imageStageLimits.smDvh}dvh`,
'--image-stage-max-h-lg': `min(${imageStageLimits.lgDvh}dvh, ${imageStageLimits.lgPx}px)`,
'--image-stage-w': `${imageStageLimits.baseDvh * imageAspectRatio}dvh`,
'--image-stage-w-sm': `${imageStageLimits.smDvh * imageAspectRatio}dvh`,
'--image-stage-w-lg': `min(${imageStageLimits.lgDvh * imageAspectRatio}dvh, ${Math.round(
imageStageLimits.lgPx * imageAspectRatio
)}px)`,
} as CSSProperties & Record<string, string | number>)
const imageStageHeightClass = [
'max-h-[var(--image-stage-max-h)]',
'sm:max-h-[var(--image-stage-max-h-sm)]',
'lg:max-h-[var(--image-stage-max-h-lg)]',
'w-[min(100%,var(--image-stage-w))]',
'sm:w-[min(100%,var(--image-stage-w-sm))]',
'lg:w-[min(100%,var(--image-stage-w-lg))]',
].join(' ')
const 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-[calc(100dvh-15rem)] lg:min-h-0 lg:max-h-[calc(100dvh-15rem)] lg:overflow-hidden">
{loadingPreviewUrl ? (
<img
src={loadingPreviewUrl}
alt=""
aria-hidden="true"
draggable={false}
className="pointer-events-none fixed h-px w-px opacity-0"
style={{ left: -9999, top: -9999 }}
onLoad={(e) => {
const img = e.currentTarget
if (img.naturalWidth > 0 && img.naturalHeight > 0) {
setFrameNaturalSize({
width: img.naturalWidth,
height: img.naturalHeight,
})
}
setLoadingPreviewLoaded(true)
setLoadingPreviewFailed(false)
}}
onError={() => {
setLoadingPreviewLoaded(false)
setLoadingPreviewFailed(true)
}}
/>
) : null}
<div className="mb-2 flex items-center justify-between gap-2 rounded-xl border border-gray-200 bg-white px-3 py-2 shadow-sm dark:border-white/10 dark:bg-gray-900/80 lg:hidden">
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<div className="shrink-0 text-sm font-semibold text-gray-900 dark:text-white">
Training
</div>
{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:items-stretch lg:overflow-hidden',
imageExpanded
? [
// Desktop: Sidebars bleiben sichtbar, Mitte bekommt deutlich mehr Breite.
'lg:grid-cols-[280px_minmax(0,1fr)_280px]',
'xl:grid-cols-[300px_minmax(0,1fr)_300px]',
'2xl:grid-cols-[320px_minmax(0,1fr)_320px]',
].join(' ')
: 'lg:grid-cols-[300px_minmax(0,1fr)_300px] xl:grid-cols-[320px_minmax(0,1fr)_320px]',
].join(' ')}
>
{/* Sidebar links */}
<aside
className={[
'hidden h-full max-h-full min-h-0 flex-col overflow-hidden rounded-xl border border-gray-200 bg-white p-3 shadow-sm dark:border-white/10 dark:bg-gray-900/60',
'lg:flex',
].join(' ')}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<div className="shrink-0 text-sm font-semibold text-gray-900 dark:text-white">
Training
</div>
{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:flex lg:h-full lg:min-h-0 lg:flex-col lg:overflow-hidden">
<section
className={[
'min-w-0 w-full rounded-xl border border-gray-200 bg-white p-2 shadow-sm dark:border-white/10 dark:bg-gray-900/60 sm:p-3',
imageExpanded
? 'lg:grid lg:h-full lg:min-h-0 lg:grid-rows-[minmax(0,1fr)_auto] lg:gap-3 lg:overflow-hidden'
: 'lg:self-start'
].join(' ')}
>
<div
className={[
'relative z-50 mx-auto flex items-center justify-center rounded-lg bg-black p-2 sm:p-3',
imageExpanded
? 'w-full min-h-0 max-h-[var(--image-stage-max-h)] sm:max-h-[var(--image-stage-max-h-sm)] lg:h-full lg:max-h-full lg:self-stretch'
: imageStageHeightClass,
'overflow-visible',
].join(' ')}
style={imageStageStyle}
>
{imageSrc ? (
<div className="relative z-50 flex h-full min-h-0 w-full items-center justify-center overflow-visible">
<div
ref={imageBoxRef}
className={[
imageExpanded
? 'relative z-50 flex h-full w-full min-h-0 select-none items-center justify-center overscroll-contain transition'
: 'relative z-50 inline-flex min-h-0 max-h-full max-w-full select-none overscroll-contain transition',
imageTouchClass,
drawingCursorClass,
'[-webkit-touch-callout:none] [-webkit-user-select:none] [user-select:none]',
trainingRunning || loading ? 'pointer-events-none' : '',
].join(' ')}
onContextMenu={(e) => e.preventDefault()}
onPointerDown={startDrawBox}
onPointerMove={moveDrawBox}
>
<img
ref={frameImageRef}
src={imageSrc}
alt="Training Frame"
draggable={false}
width={frameLayoutSize.width}
height={frameLayoutSize.height}
onLoad={(e) => {
const img = e.currentTarget
if (img.naturalWidth > 0 && img.naturalHeight > 0) {
setFrameNaturalSize({
width: img.naturalWidth,
height: img.naturalHeight,
})
}
setFrameImageLoaded(true)
window.requestAnimationFrame(updateImageLayerStyle)
}}
onError={() => {
setFrameImageLoaded(true)
window.requestAnimationFrame(updateImageLayerStyle)
}}
onContextMenu={(e) => e.preventDefault()}
onDragStart={(e) => e.preventDefault()}
className={[
imageExpanded
? 'block h-full w-full min-h-0 rounded-md object-contain'
: 'block h-auto min-h-0 max-h-full max-w-full rounded-md object-contain',
'select-none',
'transition-opacity duration-500 ease-out will-change-opacity motion-reduce:transition-none',
frameImageLoaded ? 'opacity-100' : 'opacity-0',
imageTouchClass,
'[-webkit-user-drag:none] [-webkit-touch-callout:none]',
].join(' ')}
/>
{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`}
>
<div
role={isDraft || uiLocked ? undefined : 'button'}
aria-disabled={Boolean(isDraft) || uiLocked}
className={[
'group/label flex h-5 max-w-full min-w-0 touch-none select-none items-center overflow-hidden rounded-full text-left',
isSmallBox ? 'gap-0.5 px-1' : 'gap-1 pl-1.5 pr-0.5',
'text-[10px] font-bold leading-none shadow-md ring-1 transition',
'[-webkit-user-select:none] [-webkit-touch-callout:none]',
isActiveBox
? [
'bg-white/95 text-blue-700 ring-blue-500/30 hover:bg-blue-50',
'dark:bg-blue-600 dark:text-white dark:ring-white/25 dark:hover:bg-blue-700',
].join(' ')
: isDraft
? [
'bg-amber-100/95 text-amber-950 ring-amber-500/30',
'dark:bg-amber-400 dark:text-black dark:ring-black/15',
].join(' ')
: [
'bg-white/95 text-gray-950 ring-black/10 hover:bg-gray-50',
'dark:bg-white/95 dark:text-gray-950 dark:ring-black/10 dark:hover:bg-gray-50',
].join(' '),
isDraft ? 'cursor-default' : 'cursor-move',
].join(' ')}
onPointerDown={(e) => {
if (isDraft || uiLocked) return
e.preventDefault()
e.stopPropagation()
window.getSelection()?.removeAllRanges()
const target = e.target as HTMLElement | null
if (target?.closest('[data-box-trash="true"]')) return
const requiresActivationFirst = !isActiveBox
setActiveBoxIndex(index)
if (requiresActivationFirst) return
const 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 ? (
<button
data-box-trash="true"
type="button"
disabled={uiLocked}
className={[
'ml-0.5 flex h-4.5 w-4.5 shrink-0 cursor-pointer touch-none items-center justify-center rounded-full',
'!bg-red-600 !text-white ring-1 ring-red-800/40 transition',
'hover:!bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500/60',
'disabled:cursor-not-allowed disabled:opacity-50',
'dark:!bg-red-500 dark:!text-white dark:ring-white/20 dark:hover:!bg-red-600',
].join(' ')}
title={`${box.label} löschen`}
aria-label={`${box.label} löschen`}
onPointerDown={(e) => {
e.stopPropagation()
}}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
removeBox(index)
}}
>
<TrashIcon
className="h-3 w-3 shrink-0 !text-white"
aria-hidden="true"
/>
</button>
) : null}
</div>
</div>
{!isDraft ? (
<>
{(['nw', 'ne', 'sw', 'se'] as const).map((handle) => (
<button
key={handle}
data-box-control="true"
type="button"
disabled={uiLocked}
className={[
isActiveBox
? [
'pointer-events-auto absolute z-[350] flex h-7 w-7 touch-none items-center justify-center',
'appearance-none rounded-none border-0 p-0 opacity-100 shadow-none ring-0',
'!bg-transparent hover:!bg-transparent active:!bg-transparent focus:!bg-transparent',
'focus:outline-none disabled:opacity-50 sm:h-5 sm:w-5',
].join(' ')
: [
'pointer-events-none absolute z-[100] hidden h-7 w-7 touch-none items-center justify-center',
'appearance-none rounded-none border-0 p-0 opacity-100 shadow-none ring-0',
'!bg-transparent hover:!bg-transparent active:!bg-transparent focus:!bg-transparent',
'focus:outline-none disabled:opacity-50 sm:h-5 sm:w-5',
].join(' '),
handle === 'nw' ? '-left-3.5 -top-3.5 cursor-nwse-resize sm:-left-2.5 sm:-top-2.5' : '',
handle === 'ne' ? '-right-3.5 -top-3.5 cursor-nesw-resize sm:-right-2.5 sm:-top-2.5' : '',
handle === 'sw' ? '-bottom-3.5 -left-3.5 cursor-nesw-resize sm:-bottom-2.5 sm:-left-2.5' : '',
handle === 'se' ? '-bottom-3.5 -right-3.5 cursor-nwse-resize sm:-bottom-2.5 sm:-right-2.5' : '',
].join(' ')}
title="Boxgröße ändern"
onPointerDown={(e) => {
if (uiLocked) return
e.preventDefault()
e.stopPropagation()
window.getSelection()?.removeAllRanges()
const requiresActivationFirst = !isActiveBox
setActiveBoxIndex(index)
if (requiresActivationFirst) return
const 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 visualViewport =
typeof window !== 'undefined' ? window.visualViewport : undefined
const viewportLeft = visualViewport?.offsetLeft ?? 0
const viewportTop = visualViewport?.offsetTop ?? 0
const viewportW =
visualViewport?.width ??
(typeof window !== 'undefined' ? window.innerWidth : 390)
const viewportH =
visualViewport?.height ??
(typeof window !== 'undefined' ? window.innerHeight : 800)
const isCoarsePointer =
typeof window !== 'undefined' &&
typeof window.matchMedia === 'function' &&
window.matchMedia('(pointer: coarse)').matches
const isTouchLike = isCoarsePointer || viewportW < 640
const baseSize = isTouchLike ? 136 : 156
const largeBoxSize = isTouchLike
? Math.min(176, Math.max(152, viewportW - 32))
: 190
const padding = isTouchLike ? 16 : 20
const activeBox =
drawingBox ||
(boxInteraction
? correction.boxes?.[boxInteraction.index] ?? null
: null)
const hasUsableBox =
activeBox &&
Number.isFinite(activeBox.w) &&
Number.isFinite(activeBox.h) &&
activeBox.w > 0.003 &&
activeBox.h > 0.003
const boxCenterX = hasUsableBox
? clamp01(activeBox.x + activeBox.w / 2)
: touchMagnifier.imageX
const boxCenterY = hasUsableBox
? clamp01(activeBox.y + activeBox.h / 2)
: touchMagnifier.imageY
const boxPixelW = hasUsableBox ? activeBox.w * rect.width : 0
const boxPixelH = hasUsableBox ? activeBox.h * rect.height : 0
const boxNeedsLargeMagnifier =
hasUsableBox &&
(
boxPixelW > baseSize - padding * 2 ||
boxPixelH > baseSize - padding * 2
)
const size = boxNeedsLargeMagnifier ? largeBoxSize : baseSize
const fitPadding = isTouchLike ? 14 : 18
const fitZoom = hasUsableBox
? Math.min(
(size - fitPadding * 2) / Math.max(1, boxPixelW),
(size - fitPadding * 2) / Math.max(1, boxPixelH)
)
: 2
const zoom = hasUsableBox
? Math.min(isTouchLike ? 2.25 : 2.5, fitZoom * 0.94)
: 2
const gap = isTouchLike ? 10 : 12
const edgeGap = isTouchLike ? 8 : 10
// Die Lupe wird an der Box ausgerichtet, nicht am Finger.
// Wenn gerade noch keine echte Box existiert, fällt sie auf den aktuellen Bildpunkt zurück.
const anchorX = hasUsableBox
? rect.left + boxCenterX * rect.width
: rect.left + touchMagnifier.imageX * rect.width
const anchorTop = hasUsableBox
? rect.top + activeBox.y * rect.height
: rect.top + touchMagnifier.imageY * rect.height
const anchorBottom = hasUsableBox
? rect.top + (activeBox.y + activeBox.h) * rect.height
: rect.top + touchMagnifier.imageY * rect.height
let left = anchorX - size / 2
// Normalfall: Lupe über der Box.
let top = anchorTop - size - gap
// Wenn oben kein Platz ist: Lupe unter die Box setzen.
if (top < viewportTop + edgeGap) {
top = anchorBottom + gap
}
// Wenn die Lupe unten aus dem sichtbaren Bereich laufen würde:
// an das untere Ende des sichtbaren Viewports klemmen.
if (top + size > viewportTop + viewportH - edgeGap) {
top = viewportTop + viewportH - size - edgeGap
}
// Falls der Viewport extrem klein ist, trotzdem sichtbar halten.
if (top < viewportTop + edgeGap) {
top = viewportTop + edgeGap
}
left = Math.max(
viewportLeft + edgeGap,
Math.min(viewportLeft + viewportW - size - edgeGap, left)
)
const imageWidth = rect.width * zoom
const imageHeight = rect.height * zoom
const imageLeft = size / 2 - boxCenterX * imageWidth
const imageTop = size / 2 - boxCenterY * imageHeight
const pointerX = imageLeft + touchMagnifier.imageX * imageWidth
const pointerY = imageTop + touchMagnifier.imageY * imageHeight
const boxLeft = hasUsableBox ? imageLeft + activeBox.x * imageWidth : 0
const boxTop = hasUsableBox ? imageTop + activeBox.y * imageHeight : 0
const boxWidth = hasUsableBox ? activeBox.w * imageWidth : 0
const boxHeight = hasUsableBox ? activeBox.h * imageHeight : 0
return typeof document !== 'undefined'
? createPortal(
<div
className="pointer-events-none fixed z-[2147483647] overflow-hidden rounded-xl border-2 border-white bg-black shadow-2xl ring-2 ring-black/70"
style={{
left,
top,
width: size,
height: size,
}}
>
<img
src={imageSrc}
alt=""
draggable={false}
className="absolute max-h-none max-w-none select-none"
style={{
left: imageLeft,
top: imageTop,
width: imageWidth,
height: imageHeight,
}}
/>
{hasUsableBox ? (
<div
className={[
'absolute rounded border-2 bg-black/0 shadow-[0_0_0_1px_rgba(0,0,0,0.85)]',
scoreBorderClass(activeBox.score, { draft: Boolean(drawingBox) }),
].join(' ')}
style={{
left: boxLeft,
top: boxTop,
width: boxWidth,
height: boxHeight,
}}
/>
) : null}
<div
className="absolute h-5 w-px -translate-x-1/2 -translate-y-1/2 bg-red-500/90"
style={{
left: pointerX,
top: pointerY,
}}
/>
<div
className="absolute h-px w-5 -translate-x-1/2 -translate-y-1/2 bg-red-500/90"
style={{
left: pointerX,
top: pointerY,
}}
/>
<div
className="absolute h-2 w-2 -translate-x-1/2 -translate-y-1/2 rounded-full border border-white bg-red-500 shadow"
style={{
left: pointerX,
top: pointerY,
}}
/>
</div>,
document.body
)
: null
})() : null}
</div>
</div>
) : (
<div className="relative z-10 text-sm text-white/80">
Kein Bild geladen
</div>
)}
{renderStageOverlay ? (
<TrainingStageOverlay
mode={stageOverlayMode}
text={stageOverlayText}
progress={stageOverlayProgress}
visible={stageOverlayIsVisible}
instantBackground={stageOverlayMode === 'training'}
backgroundUrl={
stageOverlayMode === 'training'
? trainingPreviewUrl || trainingStatus?.training?.previewUrl || imageSrc
: loadingPreviewBackgroundUrl
}
/>
) : null}
</div>
<div
className={[
'z-10 grid shrink-0 grid-cols-2 gap-2',
imageExpanded
? 'mt-3 lg:mt-0'
: '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>
<Button
size="md"
variant={isNegativeCorrection ? 'primary' : 'soft'}
color="blue"
disabled={uiLocked || frameBusy || !sample}
onClick={() => void saveFeedback(false, { negative: true })}
className="col-span-2 w-full justify-center px-2 text-xs sm:text-sm"
title="Speichert das Bild ausdrücklich ohne relevante Labels oder Boxen als YOLO-Negativbeispiel."
>
<span className="inline-flex items-center gap-1.5">
<XCircleIcon className="h-3.5 w-3.5" aria-hidden="true" />
<span className="sm:hidden">Negativbeispiel</span>
<span className="hidden sm:inline">Keine relevanten Inhalte & weiter</span>
</span>
</Button>
<div className="col-span-2 hidden lg:block">
<Button
size="md"
variant="secondary"
disabled={uiLocked}
onClick={() => setImageExpanded((value) => !value)}
className="w-full justify-center px-2 text-xs sm:text-sm"
title={imageExpanded ? 'Layout wieder normal anzeigen' : 'Mittlere Spalte vergrößern'}
aria-pressed={imageExpanded}
>
<span className="inline-flex items-center gap-1.5">
{imageExpanded ? (
<ArrowsPointingInIcon className="h-3.5 w-3.5" aria-hidden="true" />
) : (
<ArrowsPointingOutIcon className="h-3.5 w-3.5" aria-hidden="true" />
)}
{imageExpanded ? 'Normal anzeigen' : 'Bild vergrößern'}
</span>
</Button>
</div>
</div>
</section>
{trainingRunning ? (
<div className="mt-2 hidden rounded-xl border border-gray-200 bg-white p-3 shadow-sm dark:border-white/10 dark:bg-gray-900/70 lg:block">
<div className="mb-2 flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="text-xs font-semibold text-gray-900 dark:text-white">
Trainingszeit
</div>
<div className="mt-0.5 truncate text-[11px] text-gray-500 dark:text-gray-400">
{shownTrainingStep || 'Training läuft…'}
</div>
</div>
<span className="shrink-0 rounded-full bg-indigo-50 px-2 py-1 text-[11px] font-bold text-indigo-700 ring-1 ring-indigo-200 dark:bg-indigo-500/15 dark:text-indigo-100 dark:ring-indigo-400/30">
{Math.round(shownTrainingProgress)}%
</span>
</div>
<div className="grid grid-cols-5 gap-2 text-[11px]">
<div className="rounded-lg bg-indigo-50 px-2 py-1.5 text-center text-indigo-900 ring-1 ring-indigo-100 dark:bg-indigo-500/10 dark:text-indigo-100 dark:ring-indigo-400/20">
<div className="text-[9px] font-semibold uppercase tracking-wide opacity-70">
Laufzeit
</div>
<div className="mt-0.5 font-bold">
{formatDuration(shownTrainingDurationMs)}
</div>
</div>
<div className="rounded-lg bg-indigo-50 px-2 py-1.5 text-center text-indigo-900 ring-1 ring-indigo-100 dark:bg-indigo-500/10 dark:text-indigo-100 dark:ring-indigo-400/20">
<div className="text-[9px] font-semibold uppercase tracking-wide opacity-70">
Restzeit
</div>
<div className="mt-0.5 font-bold">
{shownTrainingEtaMs > 0 ? `ca. ${formatDuration(shownTrainingEtaMs)}` : '—'}
</div>
</div>
<div className="rounded-lg bg-indigo-50 px-2 py-1.5 text-center text-indigo-900 ring-1 ring-indigo-100 dark:bg-indigo-500/10 dark:text-indigo-100 dark:ring-indigo-400/20">
<div className="text-[9px] font-semibold uppercase tracking-wide opacity-70">
Epoche
</div>
<div className="mt-0.5 font-bold">
{shownTrainingEpochText
? shownTrainingEpochText.replace(/^Epoche\s+/i, '')
: '—'}
</div>
</div>
<div className="rounded-lg bg-indigo-50 px-2 py-1.5 text-center text-indigo-900 ring-1 ring-indigo-100 dark:bg-indigo-500/10 dark:text-indigo-100 dark:ring-indigo-400/20">
<div className="text-[9px] font-semibold uppercase tracking-wide opacity-70">
Ø / Epoche
</div>
<div className="mt-0.5 font-bold">
{estimatedEpochMs > 0 ? formatDuration(estimatedEpochMs) : '—'}
</div>
</div>
<div className="rounded-lg bg-emerald-50 px-2 py-1.5 text-center text-emerald-900 ring-1 ring-emerald-100 dark:bg-emerald-500/10 dark:text-emerald-100 dark:ring-emerald-400/20">
<div className="text-[9px] font-semibold uppercase tracking-wide opacity-70">
mAP50
</div>
<div className="mt-0.5 font-bold tabular-nums">
{formatMapPercent(trainingStatus?.training?.map50) || '—'}
</div>
</div>
</div>
</div>
) : null}
</div>
<div className="flex 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>
{ /* Rechte Seite */ }
<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}
history={trainingHistory}
loading={trainingStatsLoading}
error={trainingStatsError}
feedbackCount={feedbackCount}
requiredCount={requiredCount}
/>
<TrainingFeedbackHistoryModal
open={feedbackModalOpen}
onClose={() => setFeedbackModalOpen(false)}
items={feedbackItems}
loading={feedbackLoading || feedbackLoadingMore}
error={feedbackError}
total={feedbackTotal}
hasMore={feedbackHasMore}
selectedIndex={selectedFeedbackIndex}
onSelectedIndexChange={setSelectedFeedbackIndex}
onLoadMore={() => void loadMoreFeedbackHistory()}
onEditItem={editFeedbackItem}
onSearchChange={(query, filter) => {
setFeedbackSearchQuery(query)
setFeedbackSearchFilter(filter)
void loadFeedbackHistoryInitial({
query,
filter,
})
}}
/>
</div>
)
}