This commit is contained in:
Linrador 2026-05-04 07:38:10 +02:00
parent a1000e9085
commit 9cf9dfeb21

View File

@ -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}
</div>
{props.notice.items?.length ? (
<div className="mt-2 grid grid-cols-1 gap-1.5 text-xs sm:grid-cols-2">
{props.notice.items.map((item) => {
const Icon = item.icon
return (
<div
key={`${item.label}-${item.value}`}
className="flex min-w-0 items-center gap-2 rounded-lg bg-black/5 px-2 py-1.5 ring-1 ring-black/5 dark:bg-white/10 dark:ring-white/10"
>
<Icon
className="h-4 w-4 shrink-0 opacity-80"
aria-hidden="true"
/>
<span className="min-w-0 flex-1 truncate opacity-75">
{item.label}
</span>
<span className="shrink-0 font-bold">
{item.value}
</span>
</div>
)
})}
</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 ? (
@ -1966,9 +1997,11 @@ export default function TrainingTab(props: {
const detectorBoxItemRefs = useRef<Array<HTMLDivElement | null>>([])
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<TrainingBox | null>(null)
const [drawingBox, setDrawingBox] = useState<DrawingTrainingBox | null>(null)
const [boxInteraction, setBoxInteraction] = useState<BoxInteraction | null>(null)
const [touchMagnifier, setTouchMagnifier] = useState<MagnifierState | null>(null)
const [boxLabel, setBoxLabel] = useState('')
@ -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}
>
<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',
'pointer-events-auto absolute left-0 top-0 z-[95] flex h-5 -translate-y-[calc(100%+1px)] touch-none select-none items-stretch overflow-hidden rounded-md text-[10px] font-semibold leading-none shadow',
'[-webkit-user-select:none] [-webkit-touch-callout:none]',
isSmallBox
? 'max-w-none'
: 'max-w-[min(180px,calc(100vw-24px))]',
@ -3501,8 +3671,9 @@ export default function TrainingTab(props: {
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',
? 'flex min-w-0 touch-none select-none items-center gap-1 px-2 text-left'
: 'flex min-w-0 touch-none select-none items-center gap-1 px-1.5 text-left',
'[-webkit-user-select:none] [-webkit-touch-callout:none]',
isActiveBox
? '!bg-blue-600 !text-white hover:!bg-blue-700'
: '',
@ -3519,6 +3690,7 @@ export default function TrainingTab(props: {
e.preventDefault()
e.stopPropagation()
window.getSelection()?.removeAllRanges()
const requiresActivationFirst = !isActiveBox
@ -3615,7 +3787,7 @@ export default function TrainingTab(props: {
className={[
isActiveBox
? [
'pointer-events-auto absolute z-20 flex h-7 w-7 touch-none items-center justify-center',
'pointer-events-auto absolute z-[90] 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',
@ -3637,6 +3809,7 @@ export default function TrainingTab(props: {
e.preventDefault()
e.stopPropagation()
window.getSelection()?.removeAllRanges()
const requiresActivationFirst = !isActiveBox