nsfwapp/frontend/src/components/ui/TrainingTab.tsx
2026-05-03 15:42:44 +02:00

4096 lines
133 KiB
TypeScript

// frontend/src/components/ui/TrainingTab.tsx
'use client'
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react'
import Button from './Button'
import LoadingSpinner from './LoadingSpinner'
import { formatBytes, formatDuration } from './formatters'
import { TrashIcon } from '@heroicons/react/20/solid'
import { getSegmentLabelItem } from './Icons'
import Modal from './Modal'
type ScoredLabel = {
label: string
score: number
}
type AnalysisProgressEvent = {
type?: 'analysis_progress'
running?: boolean
phase?: string
progress?: number
current?: number
total?: number
file?: string
message?: string
error?: string
startedAtMs?: number
finishedAtMs?: number
durationMs?: number
ts?: number
}
type TrainingStatus = {
feedbackCount: number
requiredCount: number
canTrain: boolean
training?: TrainingJobStatus
}
type TrainingJobStatus = {
running: boolean
progress: number
step: string
message?: string
error?: string
startedAt?: string
finishedAt?: string
durationMs?: number
stage?: string
epoch?: number
epochs?: number
}
type TrainingPrediction = {
modelAvailable: boolean
source?: string
peopleCount: number
maleCount: number
femaleCount: number
unknownCount: number
sexPosition: string
sexPositionScore: number
bodyPartsPresent: ScoredLabel[]
objectsPresent: ScoredLabel[]
clothingPresent: ScoredLabel[]
boxes?: TrainingBox[]
}
type TrainingSample = {
sampleId: string
frameUrl: string
sourceFile: string
sourcePath?: string
sourceSizeBytes?: number
second: number
createdAt: string
prediction: TrainingPrediction
}
type TrainingLabels = {
people: string[]
sexPositions: string[]
bodyParts: string[]
objects: string[]
clothing: string[]
}
type CorrectionState = {
peopleCount: number
maleCount: number
femaleCount: number
unknownCount: number
sexPosition: 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 TrainingConfidence = {
score: number
level: 'none' | 'low' | 'mid' | 'high'
label: string
}
type TrainingLabelStat = {
label: string
count: number
confidence?: TrainingConfidence
}
type TrainingStats = {
feedbackCount: number
acceptedCount: number
correctedCount: number
sampleCount: number
boxCount: number
modelAvailable: boolean
confidence?: TrainingConfidence
labels: {
people: TrainingLabelStat[]
sexPositions: TrainingLabelStat[]
bodyParts: TrainingLabelStat[]
objects: TrainingLabelStat[]
clothing: TrainingLabelStat[]
}
}
type TrainingNoticeKind = 'success' | 'error' | 'info' | 'warning'
type TrainingNotice = {
kind: TrainingNoticeKind
title: string
message: string
detail?: string
progress?: number
}
function trainingNoticeClass(kind: TrainingNoticeKind) {
switch (kind) {
case 'success':
return {
wrap: 'border-emerald-200 bg-emerald-50 text-emerald-900 dark:border-emerald-400/30 dark:bg-emerald-500/10 dark:text-emerald-100',
icon: 'bg-emerald-500 text-white',
detail: 'text-emerald-800/80 dark:text-emerald-100/70',
}
case 'error':
return {
wrap: 'border-red-200 bg-red-50 text-red-900 dark:border-red-400/30 dark:bg-red-500/10 dark:text-red-100',
icon: 'bg-red-500 text-white',
detail: 'text-red-800/80 dark:text-red-100/70',
}
case 'warning':
return {
wrap: 'border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-400/30 dark:bg-amber-500/10 dark:text-amber-100',
icon: 'bg-amber-500 text-white',
detail: 'text-amber-800/80 dark:text-amber-100/70',
}
default:
return {
wrap: 'border-indigo-200 bg-indigo-50 text-indigo-900 dark:border-indigo-400/30 dark:bg-indigo-500/10 dark:text-indigo-100',
icon: 'bg-indigo-500 text-white',
detail: 'text-indigo-800/80 dark:text-indigo-100/70',
}
}
}
function TrainingNoticeCard(props: {
notice: TrainingNotice
onClose?: () => void
}) {
const cls = trainingNoticeClass(props.notice.kind)
const icon =
props.notice.kind === 'success'
? '✓'
: props.notice.kind === 'error'
? '!'
: props.notice.kind === 'warning'
? '⚠'
: 'i'
return (
<div
className={[
'rounded-xl border px-3 py-2 shadow-sm',
cls.wrap,
].join(' ')}
role={props.notice.kind === 'error' ? 'alert' : 'status'}
aria-live={props.notice.kind === 'error' ? 'assertive' : 'polite'}
>
<div className="flex items-start gap-3">
<div
className={[
'mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-xs font-black',
cls.icon,
].join(' ')}
aria-hidden="true"
>
{icon}
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2 text-sm font-semibold">
<span>{props.notice.title}</span>
{typeof props.notice.progress === 'number' ? (
<span className="rounded-full bg-black/5 px-2 py-0.5 text-[11px] font-bold ring-1 ring-black/10 dark:bg-white/10 dark:ring-white/10">
{Math.round(clampPercent(props.notice.progress))}%
</span>
) : null}
</div>
<div className="mt-0.5 break-words text-xs leading-snug">
{props.notice.message}
</div>
{props.notice.detail ? (
<details className="mt-2">
<summary className="cursor-pointer select-none text-xs font-medium opacity-80 hover:opacity-100">
Details anzeigen
</summary>
<pre
className={[
'mt-2 max-h-40 overflow-auto whitespace-pre-wrap rounded-lg bg-black/5 p-2 text-[11px] leading-relaxed dark:bg-black/20',
cls.detail,
].join(' ')}
>
{props.notice.detail}
</pre>
</details>
) : null}
</div>
{props.onClose ? (
<button
type="button"
onClick={props.onClose}
className="shrink-0 rounded-lg px-2 py-1 text-xs font-semibold opacity-70 transition hover:bg-black/5 hover:opacity-100 dark:hover:bg-white/10"
aria-label="Meldung schließen"
title="Meldung schließen"
>
</button>
) : null}
</div>
</div>
)
}
function backendText(data: any, fallback: string) {
return String(
data?.message ||
data?.error ||
data?.detail ||
fallback
).trim()
}
function trainingDurationMs(job?: TrainingJobStatus | null) {
const direct = Number(job?.durationMs)
if (Number.isFinite(direct) && direct > 0) {
return direct
}
const started = job?.startedAt ? Date.parse(job.startedAt) : NaN
const finished = job?.finishedAt ? Date.parse(job.finishedAt) : NaN
if (!Number.isFinite(started) || !Number.isFinite(finished)) {
return 0
}
return Math.max(0, finished - started)
}
function countPercent(count: number, total: number) {
if (!Number.isFinite(count) || !Number.isFinite(total) || total <= 0) return '0%'
return `${Math.round((count / total) * 100)}%`
}
function confidencePercent(confidence?: TrainingConfidence | null) {
const score = Number(confidence?.score)
if (!Number.isFinite(score)) return '0%'
return `${Math.round(clamp01(score) * 100)}%`
}
function confidenceLabel(confidence?: TrainingConfidence | null) {
return confidence?.label || 'Keine'
}
function confidencePillClass(confidence?: TrainingConfidence | null) {
switch (confidence?.level) {
case 'high':
return 'bg-emerald-50 text-emerald-800 ring-emerald-200 dark:bg-emerald-500/15 dark:text-emerald-100 dark:ring-emerald-400/30'
case 'mid':
return 'bg-yellow-50 text-yellow-900 ring-yellow-200 dark:bg-yellow-500/15 dark:text-yellow-100 dark:ring-yellow-400/30'
case 'low':
return 'bg-red-50 text-red-800 ring-red-200 dark:bg-red-500/15 dark:text-red-100 dark:ring-red-400/30'
default:
return 'bg-gray-50 text-gray-700 ring-gray-200 dark:bg-white/5 dark:text-gray-200 dark:ring-white/10'
}
}
function averageCategoryConfidence(values: TrainingLabelStat[]) {
const scores = values
.map((item) => Number(item.confidence?.score))
.filter((score) => Number.isFinite(score))
if (scores.length === 0) {
return undefined
}
const avg = scores.reduce((sum, score) => sum + clamp01(score), 0) / scores.length
return confidenceFromScore(avg)
}
function confidenceFromScore(score: number): TrainingConfidence {
const safeScore = clamp01(Number(score))
if (safeScore >= 0.75) {
return {
score: safeScore,
level: 'high',
label: 'Hoch',
}
}
if (safeScore >= 0.45) {
return {
score: safeScore,
level: 'mid',
label: 'Mittel',
}
}
if (safeScore > 0) {
return {
score: safeScore,
level: 'low',
label: 'Niedrig',
}
}
return {
score: 0,
level: 'none',
label: 'Keine',
}
}
function currentAnalysisConfidence(prediction?: TrainingPrediction | null): TrainingConfidence {
if (!prediction?.modelAvailable) {
return confidenceFromScore(0)
}
const scores: number[] = []
const addScore = (value: unknown) => {
const n = Number(value)
if (!Number.isFinite(n)) return
if (n <= 0) return
scores.push(clamp01(n))
}
// Scene-Modell: Sexposition
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 scoreBgClass(score?: number | null, opts?: { draft?: boolean }) {
if (opts?.draft) return 'bg-amber-400'
switch (scoreLevel(score)) {
case 'low':
return 'bg-red-500'
case 'mid':
return 'bg-yellow-400'
case 'high':
return 'bg-emerald-400'
default:
return 'bg-gray-300'
}
}
function scoreHoverClass(score?: number | null, opts?: { draft?: boolean }) {
if (opts?.draft) return ''
switch (scoreLevel(score)) {
case 'low':
return 'hover:bg-red-400'
case 'mid':
return 'hover:bg-yellow-300'
case 'high':
return 'hover:bg-emerald-300'
default:
return 'hover:bg-gray-200'
}
}
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 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 safeCount(value: unknown) {
const n = Number(value)
return Number.isFinite(n) && n > 0 ? Math.floor(n) : 0
}
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 predictionToCorrection(sample: TrainingSample | null): CorrectionState {
const p = sample?.prediction
const maleCount = safeCount(p?.maleCount)
const femaleCount = safeCount(p?.femaleCount)
const unknownCount = safeCount(p?.unknownCount)
return {
peopleCount: maleCount + femaleCount + unknownCount,
maleCount,
femaleCount,
unknownCount,
sexPosition: p?.sexPosition || 'unknown',
bodyPartsPresent: (p?.bodyPartsPresent ?? []).map((x) => x.label),
objectsPresent: (p?.objectsPresent ?? []).map((x) => x.label),
clothingPresent: (p?.clothingPresent ?? []).map((x) => x.label),
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),
}
}
function applyBoxLabelToCorrection(
state: CorrectionState,
label: string,
labels: TrainingLabels
): CorrectionState {
const clean = String(label || '').trim()
if (!clean) return state
if (labels.people.includes(clean)) {
if (clean === 'person_male' || clean === 'male_person') {
return {
...state,
maleCount: safeCount(state.maleCount) + 1,
unknownCount: 0,
peopleCount:
safeCount(state.maleCount) + 1 + safeCount(state.femaleCount),
}
}
if (clean === 'person_female' || clean === 'female_person') {
return {
...state,
femaleCount: safeCount(state.femaleCount) + 1,
unknownCount: 0,
peopleCount:
safeCount(state.maleCount) + safeCount(state.femaleCount) + 1,
}
}
if (clean === 'person' || clean === 'person_unknown') {
return {
...state,
unknownCount: safeCount(state.unknownCount) + 1,
peopleCount:
safeCount(state.maleCount) +
safeCount(state.femaleCount) +
safeCount(state.unknownCount) +
1,
}
}
return state
}
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.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()
let next: CorrectionState = {
...state,
boxes: boxes.filter((_, i) => i !== index),
}
if (removedLabel === 'person_male' || removedLabel === 'male_person') {
next = {
...next,
maleCount: Math.max(0, safeCount(next.maleCount) - 1),
unknownCount: 0,
}
}
if (removedLabel === 'person_female' || removedLabel === 'female_person') {
next = {
...next,
femaleCount: Math.max(0, safeCount(next.femaleCount) - 1),
unknownCount: 0,
}
}
if (removedLabel === 'person' || removedLabel === 'person_unknown') {
next = {
...next,
unknownCount: Math.max(0, safeCount(next.unknownCount) - 1),
}
}
next = {
...next,
peopleCount:
safeCount(next.maleCount) +
safeCount(next.femaleCount) +
safeCount(next.unknownCount),
}
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,
}
}),
}
// Alte Personen-Zählung entfernen.
if (oldLabel === 'person_male' || oldLabel === 'male_person') {
next = {
...next,
maleCount: Math.max(0, safeCount(next.maleCount) - 1),
unknownCount: 0,
}
}
if (oldLabel === 'person_female' || oldLabel === 'female_person') {
next = {
...next,
femaleCount: Math.max(0, safeCount(next.femaleCount) - 1),
unknownCount: 0,
}
}
if (oldLabel === 'person' || oldLabel === 'person_unknown') {
next = {
...next,
unknownCount: Math.max(0, safeCount(next.unknownCount) - 1),
}
}
next = {
...next,
peopleCount:
safeCount(next.maleCount) +
safeCount(next.femaleCount) +
safeCount(next.unknownCount),
}
// Alte Bodypart/Object/Clothing-Badges entfernen, falls keine weitere Box damit existiert.
next = removeBoxLabelFromCorrection(next, oldLabel, labels)
// Neues Label anwenden.
next = applyBoxLabelToCorrection(next, cleanNextLabel, labels)
next = {
...next,
peopleCount:
safeCount(next.maleCount) +
safeCount(next.femaleCount) +
safeCount(next.unknownCount),
}
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 TrainingOverlay(props: { step: string; progress: number }) {
return (
<div className="absolute inset-0 z-20 flex items-center justify-center text-center text-white">
<div className="absolute -inset-2 bg-black/45 backdrop-blur-[8px] shadow-[inset_0_0_72px_30px_rgba(0,0,0,0.75)]" />
<div className="relative z-10 flex flex-col items-center justify-center">
<LoadingSpinner
size="lg"
className="text-white"
srLabel="Training läuft…"
/>
<div className="mt-3 text-sm font-semibold">
Training läuft
</div>
<div className="mt-1 max-w-[260px] px-4 text-xs text-white/80">
{props.step || 'Bitte warten. Die Oberfläche ist währenddessen gesperrt.'}
</div>
<div className="mt-3 h-2 w-48 overflow-hidden rounded-full bg-white/20">
<div
className="h-full rounded-full bg-emerald-400 transition-all duration-500"
style={{ width: `${clampPercent(props.progress)}%` }}
/>
</div>
<div className="mt-1 text-[11px] text-white/70">
{Math.round(props.progress)}%
</div>
</div>
</div>
)
}
function LoadingImageOverlay(props: {
text?: string
progress?: number
}) {
const progress = clampPercent(props.progress ?? 0)
return (
<div className="absolute inset-0 z-20 flex items-center justify-center text-center text-white">
<div className="absolute -inset-2 bg-black/45 backdrop-blur-[8px] shadow-[inset_0_0_72px_30px_rgba(0,0,0,0.75)]" />
<div className="relative z-10 flex flex-col items-center justify-center">
<LoadingSpinner
size="lg"
className="text-white"
srLabel="Analyse läuft…"
/>
<div className="mt-3 text-sm font-semibold">
Analyse läuft
</div>
<div className="mt-1 max-w-[260px] px-4 text-xs text-white/80">
{props.text || 'Frame wird erstellt und analysiert. Bitte warten.'}
</div>
<div className="mt-3 h-2 w-48 overflow-hidden rounded-full bg-white/20">
<div
className="h-full rounded-full bg-indigo-400 transition-all duration-500"
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-[74px] w-full flex-col items-center justify-center gap-1 rounded-xl px-2 py-2 text-center text-[10px] font-semibold leading-tight ring-1 transition',
'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-6 w-6 transition',
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
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: 9999,
})
}, [])
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="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 h-9 w-full items-center justify-between gap-2 rounded-md border border-gray-200 bg-white px-2 text-sm 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 dark:border-white/10 dark:bg-gray-950 dark:text-gray-100"
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 ? (
<div
role="listbox"
data-detector-label-select="true"
style={menuStyle}
className="overflow-y-auto rounded-lg border border-gray-200 bg-white py-1 shadow-xl ring-1 ring-black/5 dark:border-white/10 dark:bg-gray-950 dark:ring-white/10"
>
{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>
) : 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'
}
>
Modell {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-6 w-6 transition',
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 activeCount = props.singleDrawMode
? Math.max(props.selected.length, props.drawLabel ? 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-2xl 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-gray-950 dark:text-gray-200 dark:ring-white/10">
{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 TrainingStatsModal(props: {
open: boolean
onClose: () => void
stats: TrainingStats | null
loading: boolean
error: string | null
feedbackCount: number
requiredCount: number
}) {
const [activeTab, setActiveTab] = useState<TrainingStatsTabKey>('people')
const stats = props.stats
const acceptedCount = stats?.acceptedCount ?? 0
const correctedCount = stats?.correctedCount ?? 0
const totalFeedback = stats?.feedbackCount ?? props.feedbackCount
const boxCount = stats?.boxCount ?? 0
const sampleCount = stats?.sampleCount ?? 0
const overallConfidence = stats?.confidence
const tabItems: Array<{
key: TrainingStatsTabKey
title: string
description: string
values: TrainingLabelStat[]
total: number
confidence?: TrainingConfidence
}> = [
{
key: 'people',
title: 'Personen',
description: 'Personen- und Gender-Labels aus Boxen.',
values: stats?.labels.people ?? [],
total: Math.max(1, boxCount),
confidence: averageCategoryConfidence(stats?.labels.people ?? []),
},
{
key: 'sexPositions',
title: 'Sexpositionen',
description: 'Scene-Labels pro bewertetem Frame.',
values: stats?.labels.sexPositions ?? [],
total: Math.max(1, totalFeedback),
confidence: averageCategoryConfidence(stats?.labels.sexPositions ?? []),
},
{
key: 'bodyParts',
title: 'Körperteile',
description: 'Körperteil-Labels aus Korrekturen und Boxen.',
values: stats?.labels.bodyParts ?? [],
total: Math.max(1, totalFeedback),
confidence: averageCategoryConfidence(stats?.labels.bodyParts ?? []),
},
{
key: 'objects',
title: 'Gegenstände',
description: 'Objekt-Labels aus Korrekturen und Boxen.',
values: stats?.labels.objects ?? [],
total: Math.max(1, totalFeedback),
confidence: averageCategoryConfidence(stats?.labels.objects ?? []),
},
{
key: 'clothing',
title: 'Kleidung',
description: 'Kleidungs-Labels aus Korrekturen und Boxen.',
values: stats?.labels.clothing ?? [],
total: Math.max(1, totalFeedback),
confidence: averageCategoryConfidence(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-4 sm:p-6"
>
<div className="p-4 sm:p-6">
{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-4">
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
<div className="rounded-xl bg-gray-50 p-3 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
<div className="text-[11px] font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
Feedback
</div>
<div className="mt-1 text-2xl font-bold text-gray-900 dark:text-white">
{totalFeedback}
</div>
<div className="mt-1 text-[11px] text-gray-500 dark:text-gray-400">
benötigt: {props.requiredCount}
</div>
</div>
<div className="rounded-xl bg-gray-50 p-3 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
<div className="text-[11px] font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
Passt so
</div>
<div className="mt-1 text-2xl font-bold text-emerald-700 dark:text-emerald-300">
{acceptedCount}
</div>
<div className="mt-1 text-[11px] text-gray-500 dark:text-gray-400">
{countPercent(acceptedCount, totalFeedback)}
</div>
</div>
<div className="rounded-xl bg-gray-50 p-3 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
<div className="text-[11px] font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
Korrigiert
</div>
<div className="mt-1 text-2xl font-bold text-amber-700 dark:text-amber-300">
{correctedCount}
</div>
<div className="mt-1 text-[11px] text-gray-500 dark:text-gray-400">
{countPercent(correctedCount, totalFeedback)}
</div>
</div>
<div className="rounded-xl bg-gray-50 p-3 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
<div className="text-[11px] font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">
Boxen
</div>
<div className="mt-1 text-2xl font-bold text-gray-900 dark:text-white">
{boxCount}
</div>
<div className="mt-1 text-[11px] text-gray-500 dark:text-gray-400">
{sampleCount} Samples
</div>
</div>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="rounded-2xl border border-indigo-100 bg-indigo-50 p-4 dark:border-indigo-400/20 dark:bg-indigo-500/10">
<div className="text-[11px] font-medium uppercase tracking-wide text-indigo-700/80 dark:text-indigo-200/80">
Modellstatus
</div>
<div className="mt-2 text-sm font-semibold text-indigo-950 dark:text-indigo-50">
{stats?.modelAvailable
? 'Trainiertes Modell verfügbar'
: 'Noch kein trainiertes Modell verfügbar'}
</div>
<div className="mt-1 text-xs leading-relaxed text-indigo-800/80 dark:text-indigo-100/70">
{stats?.modelAvailable
? 'Die aktuellen Trainingsdaten können bereits von einem Modell genutzt werden.'
: 'Sammle weiter Feedback und starte anschließend das Training.'}
</div>
</div>
<div className="rounded-2xl 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>
<div className="overflow-hidden rounded-2xl border border-gray-200 bg-gray-50/70 p-2 dark:border-white/10 dark:bg-white/[0.03]">
<div className="grid grid-cols-2 gap-1 sm:grid-cols-5">
{tabItems.map((item) => {
const active = item.key === activeTab
const count = item.values.length
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-gray-950 dark:text-indigo-200 dark:ring-indigo-400/30'
: '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(' ')}
>
<div className="truncate text-xs font-semibold">
{item.title}
</div>
<div className="mt-1 flex items-center justify-between gap-2">
<span className="text-[10px] opacity-70">
{count} Labels
</span>
<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)}`}
>
{confidencePercent(item.confidence)}
</span>
</div>
</button>
)
})}
</div>
</div>
<TrainingStatsList
title={activeTabItem.title}
description={activeTabItem.description}
values={activeTabItem.values}
total={activeTabItem.total}
confidence={activeTabItem.confidence}
/>
</div>
)}
</div>
</Modal>
)
}
export default function TrainingTab(props: {
onTrainingRunningChange?: (running: boolean) => void
}) {
const [labels, setLabels] = useState<TrainingLabels>(emptyLabels)
const [sample, setSample] = useState<TrainingSample | null>(null)
const [correction, setCorrection] = useState<CorrectionState>(() => predictionToCorrection(null))
const [hasManualCorrection, setHasManualCorrection] = useState(false)
const [loading, setLoading] = useState(false)
const [analysisProgress, setAnalysisProgress] = useState(0)
const [analysisStep, setAnalysisStep] = useState('')
const [saving, setSaving] = useState(false)
const [training, setTraining] = useState(false)
const [trainingStatus, setTrainingStatus] = useState<TrainingStatus | null>(null)
const [deletingTrainingData, setDeletingTrainingData] = useState(false)
const [trainingProgress, setTrainingProgress] = useState(0)
const [trainingStep, setTrainingStep] = useState('')
const [error, setError] = useState<string | null>(null)
const [message, setMessage] = useState<string | null>(null)
const [statsModalOpen, setStatsModalOpen] = useState(false)
const [trainingStats, setTrainingStats] = useState<TrainingStats | null>(null)
const [trainingStatsLoading, setTrainingStatsLoading] = useState(false)
const [trainingStatsError, setTrainingStatsError] = useState<string | null>(null)
const wasTrainingRunningRef = useRef(false)
const shownTrainingCompletionRef = useRef<string | null>(null)
const imageBoxRef = useRef<HTMLDivElement | null>(null)
const detectorBoxesScrollRef = useRef<HTMLDivElement | null>(null)
const detectorBoxItemRefs = useRef<Array<HTMLDivElement | null>>([])
const epochTimingRef = useRef<{
lastEpoch: number
lastAt: number
}>({
lastEpoch: 0,
lastAt: 0,
})
const etaSmoothingRef = useRef<{
lastAt: number
}>({
lastAt: 0,
})
const [trainingNowMs, setTrainingNowMs] = useState(() => Date.now())
const [smoothedTrainingEtaMs, setSmoothedTrainingEtaMs] = useState(0)
const [estimatedEpochMs, setEstimatedEpochMs] = useState(0)
const [drawingBox, setDrawingBox] = useState<TrainingBox | 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 [expandedCorrectionSections, setExpandedCorrectionSections] = useState({
sexPosition: false,
people: false,
bodyParts: false,
objects: false,
clothing: false,
})
const labelsRef = useRef<TrainingLabels>(emptyLabels)
useEffect(() => {
labelsRef.current = labels
}, [labels])
const boxLabels = useMemo(() => {
return uniqStrings([
...labels.people,
...labels.bodyParts,
...labels.objects,
...labels.clothing,
]).sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }))
}, [labels.people, labels.bodyParts, labels.objects, labels.clothing])
const correctionBoxes = correction.boxes ?? []
const visibleBoxes = [
...correctionBoxes.map((box, index) => ({ box, index, isDraft: false })),
...(drawingBox
? [{ box: drawingBox, index: -1, isDraft: true }]
: []),
]
const bodyPartScores = useMemo(() => {
return Object.fromEntries(
(sample?.prediction.bodyPartsPresent ?? [])
.filter((x) => x.label)
.map((x) => [x.label, x.score])
)
}, [sample?.prediction.bodyPartsPresent])
const objectScores = useMemo(() => {
return Object.fromEntries(
(sample?.prediction.objectsPresent ?? [])
.filter((x) => x.label)
.map((x) => [x.label, x.score])
)
}, [sample?.prediction.objectsPresent])
const clothingScores = useMemo(() => {
return Object.fromEntries(
(sample?.prediction.clothingPresent ?? [])
.filter((x) => x.label)
.map((x) => [x.label, x.score])
)
}, [sample?.prediction.clothingPresent])
const peopleScores = useMemo(() => {
const bestScores = new Map<string, number>()
for (const box of sample?.prediction.boxes ?? []) {
const cleanLabel = String(box.label || '').trim()
const n = Number(box.score)
if (!cleanLabel || !labels.people.includes(cleanLabel)) continue
if (!Number.isFinite(n)) continue
const current = bestScores.get(cleanLabel)
if (current === undefined || n > current) {
bestScores.set(cleanLabel, n)
}
}
return Object.fromEntries(bestScores)
}, [sample?.prediction.boxes, labels.people])
const selectedPeopleLabels = useMemo(() => {
return uniqStrings(
correctionBoxes
.map((box) => String(box.label || '').trim())
.filter((label) => labels.people.includes(label))
)
}, [correctionBoxes, labels.people])
const imageSrc = useMemo(() => {
if (!sample?.frameUrl) return ''
return `${sample.frameUrl}&t=${encodeURIComponent(sample.sampleId)}&r=${imageReloadKey}`
}, [sample, imageReloadKey])
const canStartTraining = Boolean(trainingStatus?.canTrain)
const feedbackCount = trainingStatus?.feedbackCount ?? 0
const requiredCount = trainingStatus?.requiredCount ?? 5
const trainingRunning = training || Boolean(trainingStatus?.training?.running)
const uiLocked = loading || saving || trainingRunning || deletingTrainingData
const shownTrainingProgress = trainingRunning
? trainingStatus?.training?.progress ?? trainingProgress
: trainingProgress
const shownTrainingStep = trainingRunning
? trainingStatus?.training?.step || trainingStep || 'Training läuft…'
: trainingStep
const analysisConfidence = useMemo(() => {
return currentAnalysisConfidence(sample?.prediction)
}, [sample?.prediction])
const loadLabels = useCallback(async () => {
const res = await fetch('/api/training/labels', { cache: 'no-store' })
if (!res.ok) return
const data = await res.json().catch(() => null)
if (data) setLabels(sortTrainingLabels(data))
}, [])
const applyTrainingStatus = useCallback((data: any) => {
if (!data) return
const job = data.training || null
setTrainingStatus((prev) => ({
feedbackCount: Number(data.feedbackCount ?? prev?.feedbackCount ?? 0),
requiredCount: Number(data.requiredCount ?? prev?.requiredCount ?? 5),
canTrain: Boolean(data.canTrain ?? prev?.canTrain ?? false),
training: job
? {
running: Boolean(job.running),
progress: Number(job.progress ?? 0),
step: String(job.step ?? ''),
message: job.message,
error: job.error,
startedAt: job.startedAt,
finishedAt: job.finishedAt,
durationMs: Number(job.durationMs ?? 0),
stage: job.stage,
epoch: Number(job.epoch ?? 0),
epochs: Number(job.epochs ?? 0),
}
: prev?.training,
}))
setTraining(Boolean(job?.running))
if (job?.message && !job.running) {
setTrainingProgress(100)
setTrainingStep('Training abgeschlossen.')
const finishedAt = String(job.finishedAt || '').trim()
const completionKey =
finishedAt || `${String(job.startedAt || '')}:${String(job.message || '')}`
if (completionKey && shownTrainingCompletionRef.current !== completionKey) {
shownTrainingCompletionRef.current = completionKey
const duration = trainingDurationMs(job)
const durationText = duration > 0
? ` Dauer: ${formatDuration(duration)}.`
: ''
setMessage(`${String(job.message)}${durationText}`)
}
}
if (job?.error && !job.running) {
const finishedAt = String(job.finishedAt || '').trim()
const errorKey = finishedAt
? `${finishedAt}:error`
: `${String(job.startedAt || '')}:error`
if (errorKey && shownTrainingCompletionRef.current !== errorKey) {
shownTrainingCompletionRef.current = errorKey
setError(String(job.error))
}
}
}, [])
const loadNext = useCallback(async (opts?: {
forceNew?: boolean
refreshPrediction?: boolean
preserveNotice?: boolean
}) => {
setLoading(true)
setAnalysisProgress(8)
setAnalysisStep(
opts?.refreshPrediction
? 'Aktuelles Bild wird neu analysiert…'
: opts?.forceNew
? 'Neues Trainingsbild wird gesucht…'
: 'Trainingsbild wird geladen…'
)
if (!opts?.preserveNotice) {
setError(null)
setMessage(null)
}
try {
const params = new URLSearchParams()
if (opts?.forceNew) params.set('force', '1')
if (opts?.refreshPrediction) params.set('refresh', '1')
const url = `/api/training/next${params.toString() ? `?${params.toString()}` : ''}`
setAnalysisProgress(25)
setAnalysisStep('Frame 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}`)
}
setAnalysisProgress(92)
setAnalysisStep('Analyse-Ergebnis wird übernommen…')
const nextCorrection = predictionToCorrection(data)
setDrawingBox(null)
setBoxInteraction(null)
setTouchMagnifier(null)
setBoxLabel('')
setActiveBoxIndex(null)
setSample(data)
setCorrection(nextCorrection)
setHasManualCorrection(false)
const currentLabels = labelsRef.current
const personLabels = new Set(currentLabels.people)
const hasPersonBoxes = nextCorrection.boxes.some((box) =>
personLabels.has(box.label)
)
setExpandedCorrectionSections({
sexPosition:
Boolean(nextCorrection.sexPosition) &&
nextCorrection.sexPosition !== 'unknown',
people: hasPersonBoxes || nextCorrection.peopleCount > 0,
bodyParts: nextCorrection.bodyPartsPresent.length > 0,
objects: nextCorrection.objectsPresent.length > 0,
clothing: nextCorrection.clothingPresent.length > 0,
})
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally {
setAnalysisProgress((value) => Math.max(value, 100))
setAnalysisStep((value) => value || 'Analyse abgeschlossen.')
window.setTimeout(() => {
setLoading(false)
setAnalysisProgress(0)
setAnalysisStep('')
}, 500)
}
}, [])
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 loadTrainingStats = useCallback(async () => {
setTrainingStatsLoading(true)
setTrainingStatsError(null)
try {
const res = await fetch('/api/training/stats', { cache: 'no-store' })
const data = await res.json().catch(() => null)
if (!res.ok) {
throw new Error(data?.error || `HTTP ${res.status}`)
}
setTrainingStats({
feedbackCount: Number(data?.feedbackCount ?? 0),
acceptedCount: Number(data?.acceptedCount ?? 0),
correctedCount: Number(data?.correctedCount ?? 0),
sampleCount: Number(data?.sampleCount ?? 0),
boxCount: Number(data?.boxCount ?? 0),
modelAvailable: Boolean(data?.modelAvailable),
confidence: data?.confidence,
labels: {
people: Array.isArray(data?.labels?.people) ? data.labels.people : [],
sexPositions: Array.isArray(data?.labels?.sexPositions) ? data.labels.sexPositions : [],
bodyParts: Array.isArray(data?.labels?.bodyParts) ? data.labels.bodyParts : [],
objects: Array.isArray(data?.labels?.objects) ? data.labels.objects : [],
clothing: Array.isArray(data?.labels?.clothing) ? data.labels.clothing : [],
},
})
} catch (e) {
setTrainingStatsError(e instanceof Error ? e.message : String(e))
} finally {
setTrainingStatsLoading(false)
}
}, [])
useEffect(() => {
const es = new EventSource('/api/events/stream')
const onTraining = (ev: MessageEvent) => {
try {
const data = JSON.parse(String(ev.data ?? 'null'))
if (data?.type !== 'training_status') return
applyTrainingStatus({
training: data.training,
})
} catch {
// ignore
}
}
const onAnalysisProgress = (ev: MessageEvent) => {
try {
const data = JSON.parse(String(ev.data ?? 'null')) as AnalysisProgressEvent
if (data?.type !== 'analysis_progress') return
const progress = clampPercent(Number(data.progress ?? 0) * 100)
const message = String(data.message || '').trim()
setAnalysisProgress(progress)
if (message) {
setAnalysisStep(message)
}
if (data.running) {
setLoading(true)
}
if (!data.running && data.phase === 'error') {
setLoading(false)
setAnalysisProgress(0)
setAnalysisStep('')
if (data.error || data.message) {
setError(String(data.error || data.message))
}
}
if (!data.running && data.phase === 'done') {
setAnalysisProgress(100)
setAnalysisStep(message || 'Analyse abgeschlossen.')
}
} catch {
// ignore
}
}
es.addEventListener('training', onTraining as EventListener)
es.addEventListener('analysisProgress', onAnalysisProgress as EventListener)
es.onerror = () => {
// Optional: Polling-Fallback bleibt separat bestehen.
}
return () => {
es.removeEventListener('training', onTraining as EventListener)
es.removeEventListener('analysisProgress', onAnalysisProgress as EventListener)
es.close()
}
}, [applyTrainingStatus])
useEffect(() => {
if (!statsModalOpen) return
void loadTrainingStats()
}, [statsModalOpen, loadTrainingStats])
const onTrainingRunningChange = props.onTrainingRunningChange
useEffect(() => {
onTrainingRunningChange?.(trainingRunning)
}, [trainingRunning, onTrainingRunningChange])
useEffect(() => {
if (!trainingRunning) {
etaSmoothingRef.current = {
lastAt: 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(() => {
let cancelled = false
async function init() {
await loadLabels()
await loadTrainingStatus()
if (cancelled) return
// Wichtig: Auch während laufendem Training wieder das aktuelle offene Sample laden,
// damit nicht "Kein Frame geladen" angezeigt wird.
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 = {
lastEpoch: 0,
lastAt: 0,
}
setEstimatedEpochMs(0)
return
}
const now = Date.now()
const previous = epochTimingRef.current
if (
previous.lastEpoch > 0 &&
previous.lastAt > 0 &&
epoch > previous.lastEpoch
) {
const measuredMsPerEpoch = (now - previous.lastAt) / (epoch - previous.lastEpoch)
if (Number.isFinite(measuredMsPerEpoch) && measuredMsPerEpoch > 0) {
setEstimatedEpochMs((old) => {
if (!Number.isFinite(old) || old <= 0) return measuredMsPerEpoch
const clampedMeasured = Math.max(
old * 0.5,
Math.min(old * 2, measuredMsPerEpoch)
)
// Stärker glätten, damit Restzeit/Ø-Zeit nicht springt.
return old * 0.85 + clampedMeasured * 0.15
})
}
}
if (epoch !== previous.lastEpoch) {
epochTimingRef.current = {
lastEpoch: epoch,
lastAt: now,
}
}
}, [
trainingRunning,
trainingStatus?.training?.epoch,
trainingStatus?.training?.epochs,
])
const saveFeedback = useCallback(
async (accepted: boolean) => {
if (!sample) return
setSaving(true)
setError(null)
setMessage(null)
try {
const maleCount = safeCount(correction.maleCount)
const femaleCount = safeCount(correction.femaleCount)
const unknownCount = safeCount(correction.unknownCount)
const correctionPayload: CorrectionState = {
...correction,
maleCount,
femaleCount,
unknownCount,
peopleCount: maleCount + femaleCount + unknownCount,
boxes: (correction.boxes ?? [])
.map(normalizeBox)
.filter((box) => box.label && box.w > 0 && box.h > 0),
}
const payload = {
sampleId: sample.sampleId,
accepted,
correction: accepted && correctionPayload.boxes.length === 0
? undefined
: correctionPayload,
}
const res = await fetch('/api/training/feedback', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
const data = await res.json().catch(() => null)
if (!res.ok) {
throw new Error(backendText(data, `HTTP ${res.status}`))
}
setMessage(
accepted
? 'Feedback gespeichert: Prediction wurde als korrekt übernommen.'
: 'Korrektur gespeichert.'
)
await loadTrainingStatus()
await loadNext({ preserveNotice: true })
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally {
setSaving(false)
}
},
[sample, correction, loadNext, loadTrainingStatus]
)
const startTraining = useCallback(async () => {
shownTrainingCompletionRef.current = null
setTraining(true)
setTrainingProgress(5)
setTrainingStep('Training wird gestartet…')
setError(null)
setMessage(null)
try {
const res = await fetch('/api/training/train', {
method: 'POST',
})
const data = await res.json().catch(() => null)
if (!res.ok) {
throw new Error(backendText(data, `HTTP ${res.status}`))
}
await loadTrainingStatus()
// WICHTIG:
// Hier NICHT direkt loadNext() aufrufen.
// Das Training läuft im Backend asynchron weiter.
} catch (e) {
setTraining(false)
setTrainingProgress(0)
setTrainingStep('')
setError(e instanceof Error ? e.message : String(e))
}
}, [loadTrainingStatus])
const deleteAllTrainingData = useCallback(async () => {
const confirmed = window.confirm(
'Wirklich alle Trainingsdaten löschen? Das entfernt Feedback, Frames, Samples und Detector-Daten. Diese Aktion kann nicht rückgängig gemacht werden.'
)
if (!confirmed) return
setDeletingTrainingData(true)
setError(null)
setMessage(null)
try {
const res = await fetch('/api/training/delete-all', {
method: 'DELETE',
})
const data = await res.json().catch(() => null)
if (!res.ok) {
throw new Error(data?.error || `HTTP ${res.status}`)
}
setSample(null)
setCorrection(predictionToCorrection(null))
setTrainingStatus({
feedbackCount: 0,
requiredCount,
canTrain: false,
})
setMessage(backendText(data, 'Alle Trainingsdaten wurden gelöscht.'))
await loadTrainingStatus()
await loadNext({ forceNew: true, preserveNotice: true })
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
} finally {
setDeletingTrainingData(false)
}
}, [loadNext, loadTrainingStatus, requiredCount])
const getPointerPosInImage = useCallback((
clientX: number,
clientY: number,
opts?: { clamp?: boolean }
) => {
const el = imageBoxRef.current
if (!el) return null
const rect = el.getBoundingClientRect()
if (rect.width <= 0 || rect.height <= 0) 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),
}
}, [])
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 pos = getPointerPosInImage(e.clientX, e.clientY)
if (!pos) return
try {
e.currentTarget.setPointerCapture(e.pointerId)
} catch {
// Pointer wurde vom Browser bereits abgebrochen.
}
setTouchMagnifier({
visible: true,
clientX: e.clientX,
clientY: e.clientY,
imageX: pos.x,
imageY: pos.y,
})
setDrawingBox({
label: boxLabel,
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.x
const y1 = drawingBox.y
const x2 = pos.x
const y2 = pos.y
setDrawingBox({
label: drawingBox.label,
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>) => {
setTouchMagnifier(null)
if (drawingBox || boxInteraction) {
e?.preventDefault()
e?.stopPropagation()
}
if (boxInteraction) {
setBoxInteraction(null)
return
}
if (!drawingBox) return
const box = normalizeBox(drawingBox)
setDrawingBox(null)
if (box.w < 0.01 || box.h < 0.01) return
setHasManualCorrection(true)
setCorrection((prev) => {
const next: CorrectionState = {
...prev,
boxes: [...(prev.boxes ?? []), box],
}
return applyBoxLabelToCorrection(next, box.label, labelsRef.current)
})
}, [boxInteraction, drawingBox])
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)
}
setCorrection((prev) =>
changeBoxLabelInCorrection(prev, index, nextLabel, labelsRef.current)
)
}, [correction.boxes])
const clearBoxes = useCallback(() => {
setHasManualCorrection(true)
setBoxLabel('')
setActiveBoxIndex(null)
setCorrection((prev) => ({
...prev,
maleCount: 0,
femaleCount: 0,
unknownCount: 0,
peopleCount: 0,
bodyPartsPresent: [],
objectsPresent: [],
clothingPresent: [],
boxes: [],
}))
setExpandedCorrectionSections({
sexPosition: false,
people: false,
bodyParts: false,
objects: false,
clothing: false,
})
}, [])
const showImageBoxes = !loading && !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 &&
epoch < epochs &&
estimatedEpochMs > 0
) {
return Math.max(0, (epochs - epoch) * estimatedEpochMs)
}
const progress = clampPercent(Number(shownTrainingProgress))
if (
shownTrainingDurationMs > 0 &&
progress >= 5 &&
progress < 99
) {
return Math.max(0, shownTrainingDurationMs * ((100 - progress) / progress))
}
return 0
}, [
trainingRunning,
trainingStatus?.training,
estimatedEpochMs,
shownTrainingDurationMs,
shownTrainingProgress,
])
useEffect(() => {
if (!trainingRunning || rawTrainingEtaMs <= 0) {
etaSmoothingRef.current = {
lastAt: 0,
}
setSmoothedTrainingEtaMs(0)
return
}
setSmoothedTrainingEtaMs((previous) => {
const lastAt = etaSmoothingRef.current.lastAt || trainingNowMs
const elapsed = Math.max(0, trainingNowMs - lastAt)
// Anzeige zählt zwischen Backend-Updates weiter runter.
const countedDown = previous > 0
? Math.max(0, previous - elapsed)
: rawTrainingEtaMs
const diff = rawTrainingEtaMs - countedDown
// Nach oben sehr vorsichtig glätten, nach unten etwas schneller.
const factor = diff > 0 ? 0.08 : 0.18
let next = countedDown + diff * factor
// Harte Sprünge zusätzlich begrenzen.
if (diff > 0) {
next = Math.min(next, countedDown + 10_000)
} else {
next = Math.max(next, countedDown - 20_000)
}
next = Math.max(0, next)
etaSmoothingRef.current = {
lastAt: trainingNowMs,
}
return next
})
}, [trainingRunning, rawTrainingEtaMs, trainingNowMs])
const shownTrainingEtaMs = smoothedTrainingEtaMs
const shownTrainingEpochText = useMemo(() => {
const epoch = Number(trainingStatus?.training?.epoch ?? 0)
const epochs = Number(trainingStatus?.training?.epochs ?? 0)
if (!Number.isFinite(epoch) || !Number.isFinite(epochs)) return ''
if (epoch <= 0 || epochs <= 0) return ''
return `Epoche ${epoch}/${epochs}`
}, [
trainingStatus?.training?.epoch,
trainingStatus?.training?.epochs,
])
const activeNotice = useMemo<TrainingNotice | null>(() => {
if (error) {
return {
kind: 'error',
title: 'Aktion fehlgeschlagen',
message: error,
}
}
if (message) {
const looksPartial =
message.toLowerCase().includes('übersprungen') ||
message.toLowerCase().includes('fehlgeschlagen')
return {
kind: looksPartial ? 'warning' : 'success',
title: looksPartial ? 'Training teilweise abgeschlossen' : 'Erfolg',
message,
}
}
if (trainingRunning) {
const parts: string[] = []
if (shownTrainingDurationMs > 0) {
parts.push(`Laufzeit ${formatDuration(shownTrainingDurationMs)}`)
}
if (shownTrainingEtaMs > 0) {
parts.push(`Restzeit ca. ${formatDuration(shownTrainingEtaMs)}`)
}
if (shownTrainingEpochText) {
parts.push(shownTrainingEpochText)
}
if (estimatedEpochMs > 0) {
parts.push(`Ø ${formatDuration(estimatedEpochMs)} pro Epoche`)
}
return {
kind: 'info',
title: 'Trainingsprognose',
progress: shownTrainingProgress,
message: parts.length > 0
? parts.join(' · ')
: shownTrainingStep || 'Training läuft…',
detail: shownTrainingStep || undefined,
}
}
return null
}, [
error,
message,
trainingRunning,
shownTrainingStep,
shownTrainingDurationMs,
shownTrainingEtaMs,
shownTrainingEpochText,
estimatedEpochMs,
shownTrainingProgress,
])
useEffect(() => {
if (!message) return
if (error) return
if (trainingRunning) return
const looksPartial =
message.toLowerCase().includes('übersprungen') ||
message.toLowerCase().includes('fehlgeschlagen')
if (looksPartial) return
const timer = window.setTimeout(() => {
setMessage(null)
}, 2500)
return () => window.clearTimeout(timer)
}, [message, error, trainingRunning])
const detectorBoxesPanel = (
<div className="rounded-lg bg-gray-50 p-2 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
<div className="flex items-center justify-between gap-2">
<div>
<div className="text-xs font-medium text-gray-700 dark:text-gray-200">
Detector-Boxen
</div>
<div className="text-[10px] text-gray-500 dark:text-gray-400">
Boxen prüfen, Label ändern oder löschen.
</div>
</div>
<div className="rounded-full bg-white px-2 py-0.5 text-[11px] font-semibold text-gray-800 ring-1 ring-gray-200 dark:bg-gray-950 dark:text-gray-100 dark:ring-white/10">
{correctionBoxes.length}
</div>
</div>
<div
ref={detectorBoxesScrollRef}
className="mt-2 max-h-32 min-h-6 space-y-1 overflow-y-auto pr-1 overscroll-contain scroll-smooth lg:max-h-48"
>
{correctionBoxes.length === 0 ? (
<div className="text-[11px] text-gray-500 dark:text-gray-400">
Noch keine Boxen gezeichnet.
</div>
) : (
correctionBoxes.map((box, index) => {
const item = getSegmentLabelItem(box.label)
const Icon = item.icon
const isActive = activeBoxIndex === index
return (
<div
ref={(el) => {
detectorBoxItemRefs.current[index] = el
}}
key={`box-${index}`}
onClick={() => setActiveBoxIndex(index)}
className={[
'cursor-pointer rounded-lg px-2 py-1.5 text-[11px] ring-1 transition',
isActive
? [
'border-l-4 border-blue-500 bg-white text-gray-900 ring-blue-200 shadow-sm',
'dark:border-blue-400 dark:bg-white/10 dark:text-white dark:ring-blue-400/40',
].join(' ')
: [
'bg-white text-gray-700 ring-gray-200 hover:bg-gray-50 hover:ring-blue-200',
'dark:bg-white/5 dark:text-gray-200 dark:ring-white/10 dark:hover:bg-white/10 dark:hover:ring-blue-400/40',
].join(' '),
].join(' ')}
>
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 flex-1 items-center justify-between gap-2">
<span className="flex min-w-0 items-center gap-1.5">
<Icon
className={[
'h-3.5 w-3.5 shrink-0',
isActive
? 'text-blue-600 dark:text-blue-300'
: 'text-gray-600 dark:text-gray-300',
].join(' ')}
aria-hidden="true"
/>
<span
className={[
'min-w-0 truncate',
isActive
? 'font-semibold text-gray-900 dark:text-white'
: 'text-gray-700 dark:text-gray-200',
].join(' ')}
>
{index + 1}. {item.text}
</span>
</span>
<div className="flex shrink-0 items-center gap-1.5">
{typeof box.score === 'number' ? (
<span className="text-[10px] text-gray-500 dark:text-gray-400">
{percent(box.score)}
</span>
) : (
<span className="text-[10px] text-amber-600 dark:text-amber-300">
korrigiert
</span>
)}
</div>
</div>
<button
type="button"
disabled={uiLocked}
className={[
'group/delete shrink-0 inline-flex h-7 w-7 items-center justify-center rounded-md transition',
'text-red-600 hover:bg-red-50 hover:text-red-700',
'focus:outline-none focus:ring-2 focus:ring-red-500/30',
'disabled:cursor-not-allowed disabled:opacity-50',
'dark:text-red-300 dark:hover:bg-red-500/10 dark:hover:text-red-200',
].join(' ')}
title={`${item.text} löschen`}
aria-label={`${item.text} löschen`}
onClick={(e) => {
e.stopPropagation()
removeBox(index)
}}
>
<TrashIcon
className="h-4 w-4 transition group-hover/delete:scale-110"
aria-hidden="true"
/>
</button>
</div>
<DetectorBoxLabelSelect
values={boxLabels}
value={box.label}
disabled={uiLocked || boxLabels.length === 0}
onChange={(value) => changeBoxLabel(index, value)}
/>
</div>
)
})
)}
</div>
{correctionBoxes.length > 0 ? (
<Button
size="sm"
variant="secondary"
className="mt-2 w-full"
disabled={uiLocked}
onClick={clearBoxes}
>
Alle Boxen entfernen
</Button>
) : null}
</div>
)
return (
<>
<div className="grid grid-cols-1 items-stretch gap-3 lg:grid-cols-[300px_minmax(0,1fr)_300px] xl:grid-cols-[320px_minmax(0,1fr)_320px]">
{/* Sidebar links */}
<aside className="max-h-[calc(100dvh-190px)] overflow-y-auto rounded-xl border border-gray-200 bg-white p-3 shadow-sm dark:border-white/10 dark:bg-gray-900/60">
<div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<div className="text-sm font-semibold text-gray-900 dark:text-white">
Training
</div>
<button
type="button"
onClick={() => setStatsModalOpen(true)}
className={[
'rounded-full bg-gray-100 px-2 py-0.5 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"
>
{feedbackCount}
</button>
</div>
<Button
size="sm"
variant="primary"
color="red"
className="shrink-0 px-2 py-1 text-[11px]"
disabled={uiLocked}
onClick={() => void deleteAllTrainingData()}
title="Löscht alle gespeicherten Trainingsdaten."
>
{deletingTrainingData ? 'Lösche…' : 'Löschen'}
</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-4 space-y-2 text-xs">
<div className="rounded-lg bg-gray-50 p-2 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
<div className="font-medium text-gray-900 dark:text-white">Datei</div>
<div className="mt-1 break-words text-gray-600 dark:text-gray-300">
{sample?.sourceFile || '—'}
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="rounded-lg bg-gray-50 p-2 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
<div className="font-medium text-gray-900 dark:text-white">Dateigröße</div>
<div className="mt-1 text-gray-600 dark:text-gray-300">
{formatBytes(sample?.sourceSizeBytes)}
</div>
</div>
<div className="rounded-lg bg-gray-50 p-2 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
<div className="font-medium text-gray-900 dark:text-white">Frame-Zeit</div>
<div className="mt-1 text-gray-600 dark:text-gray-300">
{sample ? formatDuration(sample.second * 1000) : '—'}
</div>
</div>
</div>
</div>
<div className="mt-4 hidden lg:block">
{detectorBoxesPanel}
</div>
<div className="mt-4 space-y-2">
<Button
size="sm"
variant="secondary"
className="w-full"
disabled={uiLocked || !sample}
onClick={() => void reloadCurrentImage()}
title="Lädt das aktuelle Bild erneut und führt die Analyse neu aus."
>
Neuladen
</Button>
<Button
size="sm"
variant="soft"
className="w-full"
disabled={uiLocked || !canStartTraining}
onClick={() => void startTraining()}
title={
canStartTraining
? 'Training mit allen gespeicherten Bewertungen starten.'
: `Noch zu wenig Feedback: ${feedbackCount}/${requiredCount}.`
}
>
{trainingRunning ? 'Training läuft…' : 'Training starten'}
</Button>
<div className="text-[11px] leading-relaxed text-gray-500 dark:text-gray-400">
{canStartTraining ? (
<>
Nutzt alle bisher gespeicherten Bewertungen. Das aktuelle Bild wird nur einbezogen,
wenn du es vorher speicherst.
</>
) : (
<>
Noch nicht genug Feedback für das Training. Aktuell {feedbackCount}/{requiredCount} Bewertungen gespeichert.
</>
)}
</div>
</div>
</aside>
{/* Mitte */}
<section className="min-w-0 rounded-xl border border-gray-200 bg-white p-3 shadow-sm dark:border-white/10 dark:bg-gray-900/60">
<div className="mb-3 lg:hidden">
{detectorBoxesPanel}
</div>
<div className="relative flex min-h-[180px] flex-1 items-center justify-center overflow-visible rounded-lg bg-black p-3">
{imageSrc ? (
<div className="relative flex h-full w-full items-center justify-center">
<div
ref={imageBoxRef}
className={[
'relative inline-block max-h-[calc(46dvh-44px)] max-w-full select-none touch-none overscroll-contain transition sm:max-h-[60dvh] lg:max-h-[72dvh]',
'[-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}
onPointerUp={finishDrawBox}
onPointerCancel={finishDrawBox}
>
<img
src={imageSrc}
alt="Training Frame"
draggable={false}
onContextMenu={(e) => e.preventDefault()}
onDragStart={(e) => e.preventDefault()}
onPointerDown={(e) => {
e.currentTarget.setPointerCapture?.(e.pointerId)
}}
className={[
'block max-h-[calc(46dvh-44px)] max-w-full object-contain sm:max-h-[60dvh] lg:max-h-[72dvh]',
'select-none touch-none',
'[-webkit-user-drag:none] [-webkit-touch-callout:none]',
].join(' ')}
/>
{showImageBoxes ? (
<div className="absolute inset-0">
{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
return (
<div
key={`${box.label}-${index}-${isDraft ? 'draft' : 'box'}`}
className={[
'pointer-events-none absolute rounded border-2 shadow-[0_0_0_1px_rgba(0,0,0,0.75)]',
isActiveBox
? 'border-blue-500'
: scoreBorderClass(box.score, { draft: isDraft }),
].join(' ')}
style={{
left: `${left}%`,
top: `${top}%`,
width: `${width}%`,
height: `${height}%`,
backgroundColor: 'transparent',
}}
title={box.label}
>
<div
data-box-control="true"
className={[
'pointer-events-auto absolute left-0 top-0 z-40 flex h-5 -translate-y-[calc(100%+1px)] touch-none items-stretch overflow-hidden rounded-md text-[10px] font-semibold leading-none shadow',
isSmallBox
? 'max-w-none'
: 'max-w-[min(180px,calc(100vw-24px))]',
isActiveBox
? '!bg-blue-600 !text-white'
: ['text-black', scoreBgClass(box.score, { draft: isDraft })].join(' '),
].join(' ')}
title={isDraft ? box.label : `${item.text} verschieben`}
>
<button
type="button"
className={[
isSmallBox
? 'flex min-w-0 touch-none items-center gap-1 px-2 text-left'
: 'flex min-w-0 touch-none items-center gap-1 px-1.5 text-left',
isActiveBox
? '!bg-blue-600 !text-white hover:!bg-blue-700'
: '',
isDraft
? 'cursor-default'
: [
'cursor-move',
isActiveBox ? '' : scoreHoverClass(box.score),
].join(' '),
].join(' ')}
disabled={Boolean(isDraft) || uiLocked}
onPointerDown={(e) => {
if (isDraft || uiLocked) return
e.preventDefault()
e.stopPropagation()
const requiresActivationFirst = !isActiveBox
setActiveBoxIndex(index)
// Erster Klick/Touch aktiviert nur die Box.
// Erst ein zweiter Klick/Touch auf die aktive Box darf verschieben.
if (requiresActivationFirst) return
const pos = getPointerPosInImage(e.clientX, e.clientY, { clamp: false })
const clampedPos = getPointerPosInImage(e.clientX, e.clientY)
if (!pos || !clampedPos) return
try {
imageBoxRef.current?.setPointerCapture(e.pointerId)
} catch {
// Pointer wurde vom Browser bereits abgebrochen.
}
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 text-[9px] font-bold text-current opacity-80">
{index + 1}.
</span>
<Icon
className="h-3 w-3 shrink-0 text-current"
aria-hidden="true"
/>
{!isSmallBox ? (
<span className="max-w-[100px] truncate text-current sm:max-w-[140px]">
{item.text}
{typeof box.score === 'number' ? ` (${percent(box.score)})` : ''}
</span>
) : typeof box.score === 'number' ? (
<span className="shrink-0 text-[9px] font-bold text-current opacity-90">
{typeof box.score === 'number' ? ` (${percent(box.score)})` : ''}
</span>
) : null}
</button>
{!isDraft ? (
<button
type="button"
className={[
'hidden min-h-6 w-6 shrink-0 touch-none items-center justify-center rounded-none sm:flex',
'!bg-red-600 !text-white shadow-[inset_1px_0_0_rgba(0,0,0,0.2)]',
'hover:!bg-red-700 disabled:opacity-50',
'dark:!bg-red-500 dark:hover:!bg-red-600',
].join(' ')}
disabled={uiLocked}
title={`${box.label} löschen`}
aria-label={`${box.label} löschen`}
onPointerDown={(e) => {
e.preventDefault()
e.stopPropagation()
}}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
removeBox(index)
}}
>
<TrashIcon className="h-3.5 w-3.5 text-white" aria-hidden="true" />
</button>
) : null}
</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-20 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-20 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()
const requiresActivationFirst = !isActiveBox
setActiveBoxIndex(index)
// Erster Klick/Touch aktiviert nur die Box.
// Erst danach darf Resize starten.
if (requiresActivationFirst) return
const pos = getPointerPosInImage(e.clientX, e.clientY)
if (!pos) return
try {
imageBoxRef.current?.setPointerCapture(e.pointerId)
} catch {
// Pointer wurde vom Browser bereits abgebrochen.
}
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-[0_0_0_1px_rgba(0,0,0,0.75)] 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 el = imageBoxRef.current
const rect = el?.getBoundingClientRect()
if (!rect || rect.width <= 0 || rect.height <= 0) return null
const size = 156
const padding = 20
const activeBox =
drawingBox ||
(boxInteraction
? correction.boxes?.[boxInteraction.index] ?? null
: null)
const hasUsableBox =
activeBox &&
Number.isFinite(activeBox.w) &&
Number.isFinite(activeBox.h) &&
activeBox.w > 0.003 &&
activeBox.h > 0.003
const boxCenterX = hasUsableBox
? clamp01(activeBox.x + activeBox.w / 2)
: touchMagnifier.imageX
const boxCenterY = hasUsableBox
? clamp01(activeBox.y + activeBox.h / 2)
: touchMagnifier.imageY
const boxPixelW = hasUsableBox ? activeBox.w * rect.width : 0
const boxPixelH = hasUsableBox ? activeBox.h * rect.height : 0
const fitZoom = hasUsableBox
? Math.min(
(size - padding * 2) / Math.max(1, boxPixelW),
(size - padding * 2) / Math.max(1, boxPixelH)
)
: 2
const zoom = hasUsableBox
? Math.max(0.55, Math.min(2.25, fitZoom))
: 2
const viewportW = typeof window !== 'undefined' ? window.innerWidth : 390
const viewportH = typeof window !== 'undefined' ? window.innerHeight : 800
let left = touchMagnifier.clientX - size / 2
let top = touchMagnifier.clientY - size - 28
if (top < 8) {
top = touchMagnifier.clientY + 28
}
left = Math.max(8, Math.min(viewportW - size - 8, left))
top = Math.max(8, Math.min(viewportH - size - 8, top))
const imageWidth = rect.width * zoom
const imageHeight = rect.height * zoom
const imageLeft = size / 2 - boxCenterX * imageWidth
const imageTop = size / 2 - boxCenterY * imageHeight
const pointerX = imageLeft + touchMagnifier.imageX * imageWidth
const pointerY = imageTop + touchMagnifier.imageY * imageHeight
const boxLeft = hasUsableBox ? imageLeft + activeBox.x * imageWidth : 0
const boxTop = hasUsableBox ? imageTop + activeBox.y * imageHeight : 0
const boxWidth = hasUsableBox ? activeBox.w * imageWidth : 0
const boxHeight = hasUsableBox ? activeBox.h * imageHeight : 0
return (
<div
className="pointer-events-none fixed z-[9999] overflow-hidden rounded-2xl 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>
)
})() : null}
</div>
{trainingRunning ? (
<TrainingOverlay
step={shownTrainingStep}
progress={shownTrainingProgress}
/>
) : loading ? (
<LoadingImageOverlay
text={analysisStep}
progress={analysisProgress}
/>
) : null}
</div>
) : trainingRunning ? (
<TrainingOverlay
step={shownTrainingStep || 'Aktuelles Bild wird geladen…'}
progress={shownTrainingProgress}
/>
) : loading ? (
<LoadingImageOverlay
text={analysisStep}
progress={analysisProgress}
/>
) : (
<div className="text-sm text-white/80">Kein Frame geladen</div>
)}
</div>
<div className="mt-3 grid grid-cols-1 gap-2 sm:grid-cols-3">
<Button
variant="soft"
color="emerald"
disabled={uiLocked || !sample || !sample.prediction.modelAvailable || hasManualCorrection}
onClick={() => void saveFeedback(true)}
className="w-full justify-center"
title={
hasManualCorrection
? 'Du hast Korrekturen vorgenommen. Bitte mit „Speichern & weiter“ speichern.'
: sample?.prediction.modelAvailable
? 'Die Erkennung stimmt. Prediction wird als korrekt gespeichert.'
: 'Erst verfügbar, wenn ein Modell trainiert wurde.'
}
>
Passt so & weiter
</Button>
<Button
variant="primary"
disabled={uiLocked || !sample || !hasManualCorrection}
onClick={() => void saveFeedback(false)}
className="w-full justify-center"
title={
hasManualCorrection
? 'Die Werte rechts werden als richtige Korrektur gespeichert.'
: 'Erst verfügbar, wenn du eine Korrektur vorgenommen hast.'
}
>
Speichern & weiter
</Button>
<Button
variant="secondary"
disabled={uiLocked || !sample}
onClick={() => void loadNext({ forceNew: true })}
className="w-full justify-center"
title="Dieses Bild nicht bewerten und ein anderes laden."
>
Überspringen
</Button>
</div>
{activeNotice ? (
<div className="mx-auto mt-3 w-full max-w-2xl">
<TrainingNoticeCard
notice={activeNotice}
onClose={
trainingRunning
? undefined
: () => {
setError(null)
setMessage(null)
}
}
/>
</div>
) : (
<div className="mt-2 text-center text-xs text-gray-500 dark:text-gray-400">
Prüfe das Bild. Wenn die Erkennung stimmt: Passt so. Wenn nicht: korrigieren und speichern.
</div>
)}
</section>
{/* Rechte Details/Korrektur */}
<aside
className={[
'rounded-xl border border-gray-200 bg-white p-3 shadow-sm dark:border-white/10 dark:bg-gray-900/60',
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) =>
setExpandedCorrectionSections((p) => ({
...p,
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) =>
setExpandedCorrectionSections((p) => ({
...p,
people: expanded,
}))
}
onToggle={() => {}}
drawLabel={boxLabel}
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) =>
setExpandedCorrectionSections((p) => ({
...p,
bodyParts: expanded,
}))
}
onToggle={(value) =>
setCorrection((p) => {
setHasManualCorrection(true)
return {
...p,
bodyPartsPresent: toggleArrayValue(p.bodyPartsPresent, value),
}
})
}
drawLabel={boxLabel}
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) =>
setExpandedCorrectionSections((p) => ({
...p,
objects: expanded,
}))
}
onToggle={(value) =>
setCorrection((p) => {
setHasManualCorrection(true)
return {
...p,
objectsPresent: toggleArrayValue(p.objectsPresent, value),
}
})
}
drawLabel={boxLabel}
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) =>
setExpandedCorrectionSections((p) => ({
...p,
clothing: expanded,
}))
}
onToggle={(value) =>
setCorrection((p) => {
setHasManualCorrection(true)
return {
...p,
clothingPresent: toggleArrayValue(p.clothingPresent, value),
}
})
}
drawLabel={boxLabel}
onDrawLabelChange={setBoxLabel}
disabled={uiLocked}
/>
</div>
</aside>
</div>
<TrainingStatsModal
open={statsModalOpen}
onClose={() => setStatsModalOpen(false)}
stats={trainingStats}
loading={trainingStatsLoading}
error={trainingStatsError}
feedbackCount={feedbackCount}
requiredCount={requiredCount}
/>
</>
)
}