diff --git a/frontend/src/components/ui/TrainingTab.tsx b/frontend/src/components/ui/TrainingTab.tsx index 2b8eab8..ab01157 100644 --- a/frontend/src/components/ui/TrainingTab.tsx +++ b/frontend/src/components/ui/TrainingTab.tsx @@ -1,14 +1,25 @@ // frontend/src/components/ui/TrainingTab.tsx 'use client' -import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState, type ComponentType, 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 { + ArrowPathIcon, + BoltIcon, + ClockIcon, + ForwardIcon, + TrashIcon, +} from '@heroicons/react/20/solid' import { getSegmentLabelItem } from './Icons' import Modal from './Modal' +type DrawingTrainingBox = TrainingBox & { + startX: number + startY: number +} + type ScoredLabel = { label: string score: number @@ -163,11 +174,20 @@ type TrainingStats = { type TrainingNoticeKind = 'success' | 'error' | 'info' | 'warning' +type TrainingNoticeItem = { + icon: ComponentType<{ + className?: string + 'aria-hidden'?: boolean | 'true' | 'false' + }> + label: string + value: string +} + type TrainingNotice = { kind: TrainingNoticeKind title: string message: string - detail?: string + items?: TrainingNoticeItem[] progress?: number } @@ -246,26 +266,37 @@ function TrainingNoticeCard(props: { ) : null} -
- {props.notice.message} -
+ {props.notice.items?.length ? ( +
+ {props.notice.items.map((item) => { + const Icon = item.icon - {props.notice.detail ? ( -
- - Details anzeigen - + return ( +
+
- ) : null} + + {item.label} + + + + {item.value} + +
+ ) + })} + + ) : ( +
+ {props.notice.message} +
+ )} {props.onClose ? ( @@ -1966,9 +1997,11 @@ export default function TrainingTab(props: { const detectorBoxItemRefs = useRef>([]) const epochTimingRef = useRef<{ + firstEpochAt: number lastEpoch: number lastAt: number }>({ + firstEpochAt: 0, lastEpoch: 0, lastAt: 0, }) @@ -1984,7 +2017,7 @@ export default function TrainingTab(props: { const [estimatedEpochMs, setEstimatedEpochMs] = useState(0) - const [drawingBox, setDrawingBox] = useState(null) + const [drawingBox, setDrawingBox] = useState(null) const [boxInteraction, setBoxInteraction] = useState(null) const [touchMagnifier, setTouchMagnifier] = useState(null) const [boxLabel, setBoxLabel] = useState('') @@ -2370,6 +2403,41 @@ export default function TrainingTab(props: { } }, [applyTrainingStatus]) + useEffect(() => { + const draggingBox = Boolean(drawingBox || boxInteraction) + + if (!draggingBox) return + + const previousUserSelect = document.body.style.userSelect + const previousWebkitUserSelect = document.body.style.webkitUserSelect + + document.body.style.userSelect = 'none' + document.body.style.webkitUserSelect = 'none' + + const clearSelection = () => { + window.getSelection()?.removeAllRanges() + } + + const preventSelection = (event: Event) => { + event.preventDefault() + } + + clearSelection() + + document.addEventListener('selectionchange', clearSelection) + document.addEventListener('selectstart', preventSelection) + + return () => { + document.body.style.userSelect = previousUserSelect + document.body.style.webkitUserSelect = previousWebkitUserSelect + + document.removeEventListener('selectionchange', clearSelection) + document.removeEventListener('selectstart', preventSelection) + + clearSelection() + } + }, [drawingBox, boxInteraction]) + useEffect(() => { if (!statsModalOpen) return @@ -2516,9 +2584,11 @@ export default function TrainingTab(props: { if (!trainingRunning || epoch <= 0 || epochs <= 0) { epochTimingRef.current = { + firstEpochAt: 0, lastEpoch: 0, lastAt: 0, } + setEstimatedEpochMs(0) return } @@ -2526,33 +2596,48 @@ export default function TrainingTab(props: { const now = Date.now() const previous = epochTimingRef.current + const firstEpochAt = + previous.firstEpochAt > 0 + ? previous.firstEpochAt + : now + if ( previous.lastEpoch > 0 && previous.lastAt > 0 && epoch > previous.lastEpoch ) { - const measuredMsPerEpoch = (now - previous.lastAt) / (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 + if (!Number.isFinite(old) || old <= 0) { + return measuredMsPerEpoch + } const clampedMeasured = Math.max( - old * 0.5, - Math.min(old * 2, measuredMsPerEpoch) + old * 0.60, + Math.min(old * 1.60, measuredMsPerEpoch) ) - // Stärker glätten, damit Restzeit/Ø-Zeit nicht springt. - return old * 0.85 + clampedMeasured * 0.15 + // Glättung: + // Genug stabil, aber reagiert schneller als vorher auf echte Änderungen. + return old * 0.75 + clampedMeasured * 0.25 }) } } if (epoch !== previous.lastEpoch) { epochTimingRef.current = { + firstEpochAt, lastEpoch: epoch, lastAt: now, } + } else if (previous.firstEpochAt <= 0) { + epochTimingRef.current = { + ...previous, + firstEpochAt, + } } }, [ trainingRunning, @@ -2746,6 +2831,8 @@ export default function TrainingTab(props: { setDrawingBox({ label: boxLabel, + startX: pos.x, + startY: pos.y, x: pos.x, y: pos.y, w: 0, @@ -2846,13 +2933,13 @@ export default function TrainingTab(props: { if (!drawingBox) return - const x1 = drawingBox.x - const y1 = drawingBox.y + const x1 = drawingBox.startX + const y1 = drawingBox.startY const x2 = pos.x const y2 = pos.y setDrawingBox({ - label: drawingBox.label, + ...drawingBox, x: Math.min(x1, x2), y: Math.min(y1, y2), w: Math.abs(x2 - x1), @@ -2875,7 +2962,8 @@ export default function TrainingTab(props: { if (!drawingBox) return - const box = normalizeBox(drawingBox) + const { startX, startY, ...drawingTrainingBox } = drawingBox + const box = normalizeBox(drawingTrainingBox) setDrawingBox(null) @@ -2983,25 +3071,60 @@ export default function TrainingTab(props: { return trainingDurationMs(job) }, [trainingStatus?.training, trainingNowMs]) - const rawTrainingEtaMs = useMemo(() => { + const estimatedTrainingTotalMs = useMemo(() => { if (!trainingRunning) return 0 const job = trainingStatus?.training + const started = job?.startedAt ? Date.parse(job.startedAt) : NaN const epoch = Number(job?.epoch ?? 0) const epochs = Number(job?.epochs ?? 0) + const firstEpochAt = epochTimingRef.current.firstEpochAt if ( - Number.isFinite(epoch) && - Number.isFinite(epochs) && - Number.isFinite(estimatedEpochMs) && - epoch > 0 && - epochs > 0 && - epoch < epochs && - estimatedEpochMs > 0 + !Number.isFinite(started) || + started <= 0 || + firstEpochAt <= 0 || + !Number.isFinite(epoch) || + !Number.isFinite(epochs) || + epoch <= 0 || + epochs <= 0 || + !Number.isFinite(estimatedEpochMs) || + estimatedEpochMs <= 0 ) { - return Math.max(0, (epochs - epoch) * estimatedEpochMs) + return 0 } + // Zeit vor dem eigentlichen YOLO-Epochen-Training: + // z.B. Scene-Modell, Dataset-Check, Modell-Laden. + const warmupMs = Math.max(0, firstEpochAt - started) + + // Kleine Reserve für Validierung, best.pt kopieren, Status schreiben usw. + const finalizeOverheadMs = 8_000 + + const total = + warmupMs + + estimatedEpochMs * epochs + + finalizeOverheadMs + + // Nie kleiner anzeigen als die bereits vergangene Laufzeit. + return Math.max(total, shownTrainingDurationMs) + }, [ + trainingRunning, + trainingStatus?.training?.startedAt, + trainingStatus?.training?.epoch, + trainingStatus?.training?.epochs, + estimatedEpochMs, + shownTrainingDurationMs, + ]) + + const rawTrainingEtaMs = useMemo(() => { + if (!trainingRunning) return 0 + + if (estimatedTrainingTotalMs > 0) { + return Math.max(0, estimatedTrainingTotalMs - shownTrainingDurationMs) + } + + // Fallback, solange noch keine brauchbare Epochenzeit bekannt ist. const progress = clampPercent(Number(shownTrainingProgress)) if ( @@ -3009,14 +3132,16 @@ export default function TrainingTab(props: { progress >= 5 && progress < 99 ) { - return Math.max(0, shownTrainingDurationMs * ((100 - progress) / progress)) + return Math.max( + 0, + shownTrainingDurationMs * ((100 - progress) / progress) + ) } return 0 }, [ trainingRunning, - trainingStatus?.training, - estimatedEpochMs, + estimatedTrainingTotalMs, shownTrainingDurationMs, shownTrainingProgress, ]) @@ -3066,6 +3191,28 @@ export default function TrainingTab(props: { const shownTrainingEtaMs = smoothedTrainingEtaMs + const shownTrainingTotalMs = useMemo(() => { + if (!trainingRunning) return 0 + + if (estimatedTrainingTotalMs > 0) { + return Math.max( + estimatedTrainingTotalMs, + shownTrainingDurationMs + shownTrainingEtaMs + ) + } + + if (shownTrainingEtaMs > 0) { + return shownTrainingDurationMs + shownTrainingEtaMs + } + + return 0 + }, [ + trainingRunning, + estimatedTrainingTotalMs, + shownTrainingDurationMs, + shownTrainingEtaMs, + ]) + const shownTrainingEpochText = useMemo(() => { const epoch = Number(trainingStatus?.training?.epoch ?? 0) const epochs = Number(trainingStatus?.training?.epochs ?? 0) @@ -3101,32 +3248,52 @@ export default function TrainingTab(props: { } if (trainingRunning) { - const parts: string[] = [] + const items: TrainingNoticeItem[] = [] - if (shownTrainingDurationMs > 0) { - parts.push(`Laufzeit ${formatDuration(shownTrainingDurationMs)}`) - } + items.push({ + icon: ClockIcon, + label: 'Bisherige Laufzeit', + value: formatDuration(shownTrainingDurationMs), + }) if (shownTrainingEtaMs > 0) { - parts.push(`Restzeit ca. ${formatDuration(shownTrainingEtaMs)}`) + items.push({ + icon: ForwardIcon, + label: 'Geschätzte Restzeit', + value: `ca. ${formatDuration(shownTrainingEtaMs)}`, + }) + } + + if (shownTrainingTotalMs > 0) { + items.push({ + icon: ClockIcon, + label: 'Geschätzte Gesamtdauer', + value: `ca. ${formatDuration(shownTrainingTotalMs)}`, + }) } if (shownTrainingEpochText) { - parts.push(shownTrainingEpochText) + items.push({ + icon: ArrowPathIcon, + label: 'Epoche', + value: shownTrainingEpochText.replace(/^Epoche\s+/i, ''), + }) } if (estimatedEpochMs > 0) { - parts.push(`Ø ${formatDuration(estimatedEpochMs)} pro Epoche`) + items.push({ + icon: BoltIcon, + label: 'Ø Zeit pro Epoche', + value: formatDuration(estimatedEpochMs), + }) } return { kind: 'info', - title: 'Trainingsprognose', + title: 'Training läuft', progress: shownTrainingProgress, - message: parts.length > 0 - ? parts.join(' · ') - : shownTrainingStep || 'Training läuft…', - detail: shownTrainingStep || undefined, + message: shownTrainingStep || 'Training läuft…', + items, } } @@ -3137,6 +3304,7 @@ export default function TrainingTab(props: { trainingRunning, shownTrainingStep, shownTrainingDurationMs, + shownTrainingTotalMs, shownTrainingEtaMs, shownTrainingEpochText, estimatedEpochMs, @@ -3481,13 +3649,15 @@ export default function TrainingTab(props: { width: `${width}%`, height: `${height}%`, backgroundColor: 'transparent', + zIndex: isActiveBox ? 80 : isDraft ? 70 : 10, }} title={box.label} >