// 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 (
{props.notice.title} {typeof props.notice.progress === 'number' ? ( {Math.round(clampPercent(props.notice.progress))}% ) : null}
{props.notice.message}
{props.notice.detail ? (
Details anzeigen
                {props.notice.detail}
              
) : null}
{props.onClose ? ( ) : null}
) } 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() 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 | 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 (
Training läuft…
{props.step || 'Bitte warten. Die Oberfläche ist währenddessen gesperrt.'}
{Math.round(props.progress)}%
) } function LoadingImageOverlay(props: { text?: string progress?: number }) { const progress = clampPercent(props.progress ?? 0) return (
Analyse läuft…
{props.text || 'Frame wird erstellt und analysiert. Bitte warten.'}
{Math.round(progress)}%
) } 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 onToggle: (value: string) => void drawLabel?: string onDrawLabelChange?: (value: string) => void disabled?: boolean gridClassName?: string }) { if (props.values.length === 0) { return (
Keine Einträge verfügbar.
) } return (
{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 ( ) })}
) } function DetectorBoxLabelSelect(props: { values: string[] value: string disabled?: boolean onChange: (value: string) => void }) { const [open, setOpen] = useState(false) const [menuStyle, setMenuStyle] = useState({}) const buttonRef = useRef(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 ( ) } return (
e.stopPropagation()} onClick={(e) => e.stopPropagation()} > {open ? (
{props.values.map((value) => { const active = value === props.value const item = getSegmentLabelItem(value) const Icon = item.icon return ( ) })}
) : null}
) } 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 (
{shown ? (
{props.values.length === 0 ? (
Keine Einträge verfügbar.
) : (
{props.values.map((value) => { const active = value === currentValue const item = getSegmentLabelItem(value) const Icon = item.icon const isPrediction = value === props.predictionValue return ( ) })}
)}
) : null}
) } function CollapsibleLabelSection(props: { title: string values: string[] selected: string[] scores?: Record 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 (
{shown ? (
) : null}
) } 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 (
{props.title}
{props.description ? (
{props.description}
) : null}
{confidenceLabel(props.confidence)} · {confidencePercent(props.confidence)} {sorted.length} Labels
Häufigkeit Confidence
{sorted.length === 0 ? (
Keine Labels gefunden
{props.emptyText || 'Sobald Feedback für diese Kategorie gespeichert wurde, erscheinen hier die Werte.'}
) : (
{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 (
{labelItem.text}
{item.label}
{confidenceLabel(item.confidence)}
{item.count}
Häufigkeit in dieser Gruppe {shareWidth}
Daten-Confidence {confWidth}
) })}
)}
) } 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('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 (
{props.loading ? (
Statistiken werden geladen…
) : props.error ? (
{props.error}
) : (
Feedback
{totalFeedback}
benötigt: {props.requiredCount}
Passt so
{acceptedCount}
{countPercent(acceptedCount, totalFeedback)}
Korrigiert
{correctedCount}
{countPercent(correctedCount, totalFeedback)}
Boxen
{boxCount}
{sampleCount} Samples
Modellstatus
{stats?.modelAvailable ? 'Trainiertes Modell verfügbar' : 'Noch kein trainiertes Modell verfügbar'}
{stats?.modelAvailable ? 'Die aktuellen Trainingsdaten können bereits von einem Modell genutzt werden.' : 'Sammle weiter Feedback und starte anschließend das Training.'}
Daten-Confidence
{confidenceLabel(overallConfidence)}
{confidencePercent(overallConfidence)}
Daten-Confidence aus Feedback-Menge, Boxen, Label-Abdeckung und Korrekturanteil. Kein direkter Modell-Qualitätswert.
{tabItems.map((item) => { const active = item.key === activeTab const count = item.values.length return ( ) })}
)}
) } export default function TrainingTab(props: { onTrainingRunningChange?: (running: boolean) => void }) { const [labels, setLabels] = useState(emptyLabels) const [sample, setSample] = useState(null) const [correction, setCorrection] = useState(() => 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(null) const [deletingTrainingData, setDeletingTrainingData] = useState(false) const [trainingProgress, setTrainingProgress] = useState(0) const [trainingStep, setTrainingStep] = useState('') const [error, setError] = useState(null) const [message, setMessage] = useState(null) const [statsModalOpen, setStatsModalOpen] = useState(false) const [trainingStats, setTrainingStats] = useState(null) const [trainingStatsLoading, setTrainingStatsLoading] = useState(false) const [trainingStatsError, setTrainingStatsError] = useState(null) const wasTrainingRunningRef = useRef(false) const shownTrainingCompletionRef = useRef(null) const imageBoxRef = useRef(null) const detectorBoxesScrollRef = useRef(null) const detectorBoxItemRefs = useRef>([]) 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(null) const [boxInteraction, setBoxInteraction] = useState(null) const [touchMagnifier, setTouchMagnifier] = useState(null) const [boxLabel, setBoxLabel] = useState('') const [activeBoxIndex, setActiveBoxIndex] = useState(null) const [imageReloadKey, setImageReloadKey] = useState(0) const [expandedCorrectionSections, setExpandedCorrectionSections] = useState({ sexPosition: false, people: false, bodyParts: false, objects: false, clothing: false, }) const labelsRef = useRef(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() 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) => { 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) => { 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) => { 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(() => { 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 = (
Detector-Boxen
Boxen prüfen, Label ändern oder löschen.
{correctionBoxes.length}
{correctionBoxes.length === 0 ? (
Noch keine Boxen gezeichnet.
) : ( correctionBoxes.map((box, index) => { const item = getSegmentLabelItem(box.label) const Icon = item.icon const isActive = activeBoxIndex === index return (
{ 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(' ')} >
{typeof box.score === 'number' ? ( {percent(box.score)} ) : ( korrigiert )}
changeBoxLabel(index, value)} />
) }) )}
{correctionBoxes.length > 0 ? ( ) : null}
) return ( <>
{/* Sidebar links */} {/* Mitte */}
{detectorBoxesPanel}
{imageSrc ? (
e.preventDefault()} onPointerDown={startDrawBox} onPointerMove={moveDrawBox} onPointerUp={finishDrawBox} onPointerCancel={finishDrawBox} > Training Frame 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 ? (
{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 (
{!isDraft ? ( ) : null}
{!isDraft ? ( <> {(['nw', 'ne', 'sw', 'se'] as const).map((handle) => ( ))} ) : null}
) })}
) : 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 (
{hasUsableBox ? (
) : null}
) })() : null}
{trainingRunning ? ( ) : loading ? ( ) : null}
) : trainingRunning ? ( ) : loading ? ( ) : (
Kein Frame geladen
)}
{activeNotice ? (
{ setError(null) setMessage(null) } } />
) : (
Prüfe das Bild. Wenn die Erkennung stimmt: „Passt so“. Wenn nicht: korrigieren und speichern.
)}
{/* Rechte Details/Korrektur */}
setStatsModalOpen(false)} stats={trainingStats} loading={trainingStatsLoading} error={trainingStatsError} feedbackCount={feedbackCount} requiredCount={requiredCount} /> ) }