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 (
+
+
-
- {props.notice.detail}
-
-
- ) : 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}
>