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 // frontend/src/components/ui/TrainingTab.tsx
'use client' '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 Button from './Button'
import LoadingSpinner from './LoadingSpinner' import LoadingSpinner from './LoadingSpinner'
import { formatBytes, formatDuration } from './formatters' 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 { getSegmentLabelItem } from './Icons'
import Modal from './Modal' import Modal from './Modal'
type DrawingTrainingBox = TrainingBox & {
startX: number
startY: number
}
type ScoredLabel = { type ScoredLabel = {
label: string label: string
score: number score: number
@ -163,11 +174,20 @@ type TrainingStats = {
type TrainingNoticeKind = 'success' | 'error' | 'info' | 'warning' type TrainingNoticeKind = 'success' | 'error' | 'info' | 'warning'
type TrainingNoticeItem = {
icon: ComponentType<{
className?: string
'aria-hidden'?: boolean | 'true' | 'false'
}>
label: string
value: string
}
type TrainingNotice = { type TrainingNotice = {
kind: TrainingNoticeKind kind: TrainingNoticeKind
title: string title: string
message: string message: string
detail?: string items?: TrainingNoticeItem[]
progress?: number progress?: number
} }
@ -246,26 +266,37 @@ function TrainingNoticeCard(props: {
) : null} ) : null}
</div> </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"> <div className="mt-0.5 break-words text-xs leading-snug">
{props.notice.message} {props.notice.message}
</div> </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> </div>
{props.onClose ? ( {props.onClose ? (
@ -1966,9 +1997,11 @@ export default function TrainingTab(props: {
const detectorBoxItemRefs = useRef<Array<HTMLDivElement | null>>([]) const detectorBoxItemRefs = useRef<Array<HTMLDivElement | null>>([])
const epochTimingRef = useRef<{ const epochTimingRef = useRef<{
firstEpochAt: number
lastEpoch: number lastEpoch: number
lastAt: number lastAt: number
}>({ }>({
firstEpochAt: 0,
lastEpoch: 0, lastEpoch: 0,
lastAt: 0, lastAt: 0,
}) })
@ -1984,7 +2017,7 @@ export default function TrainingTab(props: {
const [estimatedEpochMs, setEstimatedEpochMs] = useState(0) 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 [boxInteraction, setBoxInteraction] = useState<BoxInteraction | null>(null)
const [touchMagnifier, setTouchMagnifier] = useState<MagnifierState | null>(null) const [touchMagnifier, setTouchMagnifier] = useState<MagnifierState | null>(null)
const [boxLabel, setBoxLabel] = useState('') const [boxLabel, setBoxLabel] = useState('')
@ -2370,6 +2403,41 @@ export default function TrainingTab(props: {
} }
}, [applyTrainingStatus]) }, [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(() => { useEffect(() => {
if (!statsModalOpen) return if (!statsModalOpen) return
@ -2516,9 +2584,11 @@ export default function TrainingTab(props: {
if (!trainingRunning || epoch <= 0 || epochs <= 0) { if (!trainingRunning || epoch <= 0 || epochs <= 0) {
epochTimingRef.current = { epochTimingRef.current = {
firstEpochAt: 0,
lastEpoch: 0, lastEpoch: 0,
lastAt: 0, lastAt: 0,
} }
setEstimatedEpochMs(0) setEstimatedEpochMs(0)
return return
} }
@ -2526,33 +2596,48 @@ export default function TrainingTab(props: {
const now = Date.now() const now = Date.now()
const previous = epochTimingRef.current const previous = epochTimingRef.current
const firstEpochAt =
previous.firstEpochAt > 0
? previous.firstEpochAt
: now
if ( if (
previous.lastEpoch > 0 && previous.lastEpoch > 0 &&
previous.lastAt > 0 && previous.lastAt > 0 &&
epoch > previous.lastEpoch 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) { if (Number.isFinite(measuredMsPerEpoch) && measuredMsPerEpoch > 0) {
setEstimatedEpochMs((old) => { setEstimatedEpochMs((old) => {
if (!Number.isFinite(old) || old <= 0) return measuredMsPerEpoch if (!Number.isFinite(old) || old <= 0) {
return measuredMsPerEpoch
}
const clampedMeasured = Math.max( const clampedMeasured = Math.max(
old * 0.5, old * 0.60,
Math.min(old * 2, measuredMsPerEpoch) Math.min(old * 1.60, measuredMsPerEpoch)
) )
// Stärker glätten, damit Restzeit/Ø-Zeit nicht springt. // Glättung:
return old * 0.85 + clampedMeasured * 0.15 // Genug stabil, aber reagiert schneller als vorher auf echte Änderungen.
return old * 0.75 + clampedMeasured * 0.25
}) })
} }
} }
if (epoch !== previous.lastEpoch) { if (epoch !== previous.lastEpoch) {
epochTimingRef.current = { epochTimingRef.current = {
firstEpochAt,
lastEpoch: epoch, lastEpoch: epoch,
lastAt: now, lastAt: now,
} }
} else if (previous.firstEpochAt <= 0) {
epochTimingRef.current = {
...previous,
firstEpochAt,
}
} }
}, [ }, [
trainingRunning, trainingRunning,
@ -2746,6 +2831,8 @@ export default function TrainingTab(props: {
setDrawingBox({ setDrawingBox({
label: boxLabel, label: boxLabel,
startX: pos.x,
startY: pos.y,
x: pos.x, x: pos.x,
y: pos.y, y: pos.y,
w: 0, w: 0,
@ -2846,13 +2933,13 @@ export default function TrainingTab(props: {
if (!drawingBox) return if (!drawingBox) return
const x1 = drawingBox.x const x1 = drawingBox.startX
const y1 = drawingBox.y const y1 = drawingBox.startY
const x2 = pos.x const x2 = pos.x
const y2 = pos.y const y2 = pos.y
setDrawingBox({ setDrawingBox({
label: drawingBox.label, ...drawingBox,
x: Math.min(x1, x2), x: Math.min(x1, x2),
y: Math.min(y1, y2), y: Math.min(y1, y2),
w: Math.abs(x2 - x1), w: Math.abs(x2 - x1),
@ -2875,7 +2962,8 @@ export default function TrainingTab(props: {
if (!drawingBox) return if (!drawingBox) return
const box = normalizeBox(drawingBox) const { startX, startY, ...drawingTrainingBox } = drawingBox
const box = normalizeBox(drawingTrainingBox)
setDrawingBox(null) setDrawingBox(null)
@ -2983,25 +3071,60 @@ export default function TrainingTab(props: {
return trainingDurationMs(job) return trainingDurationMs(job)
}, [trainingStatus?.training, trainingNowMs]) }, [trainingStatus?.training, trainingNowMs])
const rawTrainingEtaMs = useMemo(() => { const estimatedTrainingTotalMs = useMemo(() => {
if (!trainingRunning) return 0 if (!trainingRunning) return 0
const job = trainingStatus?.training const job = trainingStatus?.training
const started = job?.startedAt ? Date.parse(job.startedAt) : NaN
const epoch = Number(job?.epoch ?? 0) const epoch = Number(job?.epoch ?? 0)
const epochs = Number(job?.epochs ?? 0) const epochs = Number(job?.epochs ?? 0)
const firstEpochAt = epochTimingRef.current.firstEpochAt
if ( if (
Number.isFinite(epoch) && !Number.isFinite(started) ||
Number.isFinite(epochs) && started <= 0 ||
Number.isFinite(estimatedEpochMs) && firstEpochAt <= 0 ||
epoch > 0 && !Number.isFinite(epoch) ||
epochs > 0 && !Number.isFinite(epochs) ||
epoch < epochs && epoch <= 0 ||
estimatedEpochMs > 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)) const progress = clampPercent(Number(shownTrainingProgress))
if ( if (
@ -3009,14 +3132,16 @@ export default function TrainingTab(props: {
progress >= 5 && progress >= 5 &&
progress < 99 progress < 99
) { ) {
return Math.max(0, shownTrainingDurationMs * ((100 - progress) / progress)) return Math.max(
0,
shownTrainingDurationMs * ((100 - progress) / progress)
)
} }
return 0 return 0
}, [ }, [
trainingRunning, trainingRunning,
trainingStatus?.training, estimatedTrainingTotalMs,
estimatedEpochMs,
shownTrainingDurationMs, shownTrainingDurationMs,
shownTrainingProgress, shownTrainingProgress,
]) ])
@ -3066,6 +3191,28 @@ export default function TrainingTab(props: {
const shownTrainingEtaMs = smoothedTrainingEtaMs 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 shownTrainingEpochText = useMemo(() => {
const epoch = Number(trainingStatus?.training?.epoch ?? 0) const epoch = Number(trainingStatus?.training?.epoch ?? 0)
const epochs = Number(trainingStatus?.training?.epochs ?? 0) const epochs = Number(trainingStatus?.training?.epochs ?? 0)
@ -3101,32 +3248,52 @@ export default function TrainingTab(props: {
} }
if (trainingRunning) { if (trainingRunning) {
const parts: string[] = [] const items: TrainingNoticeItem[] = []
if (shownTrainingDurationMs > 0) { items.push({
parts.push(`Laufzeit ${formatDuration(shownTrainingDurationMs)}`) icon: ClockIcon,
} label: 'Bisherige Laufzeit',
value: formatDuration(shownTrainingDurationMs),
})
if (shownTrainingEtaMs > 0) { 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) { if (shownTrainingEpochText) {
parts.push(shownTrainingEpochText) items.push({
icon: ArrowPathIcon,
label: 'Epoche',
value: shownTrainingEpochText.replace(/^Epoche\s+/i, ''),
})
} }
if (estimatedEpochMs > 0) { if (estimatedEpochMs > 0) {
parts.push(`Ø ${formatDuration(estimatedEpochMs)} pro Epoche`) items.push({
icon: BoltIcon,
label: 'Ø Zeit pro Epoche',
value: formatDuration(estimatedEpochMs),
})
} }
return { return {
kind: 'info', kind: 'info',
title: 'Trainingsprognose', title: 'Training läuft',
progress: shownTrainingProgress, progress: shownTrainingProgress,
message: parts.length > 0 message: shownTrainingStep || 'Training läuft…',
? parts.join(' · ') items,
: shownTrainingStep || 'Training läuft…',
detail: shownTrainingStep || undefined,
} }
} }
@ -3137,6 +3304,7 @@ export default function TrainingTab(props: {
trainingRunning, trainingRunning,
shownTrainingStep, shownTrainingStep,
shownTrainingDurationMs, shownTrainingDurationMs,
shownTrainingTotalMs,
shownTrainingEtaMs, shownTrainingEtaMs,
shownTrainingEpochText, shownTrainingEpochText,
estimatedEpochMs, estimatedEpochMs,
@ -3481,13 +3649,15 @@ export default function TrainingTab(props: {
width: `${width}%`, width: `${width}%`,
height: `${height}%`, height: `${height}%`,
backgroundColor: 'transparent', backgroundColor: 'transparent',
zIndex: isActiveBox ? 80 : isDraft ? 70 : 10,
}} }}
title={box.label} title={box.label}
> >
<div <div
data-box-control="true" data-box-control="true"
className={[ 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 isSmallBox
? 'max-w-none' ? 'max-w-none'
: 'max-w-[min(180px,calc(100vw-24px))]', : 'max-w-[min(180px,calc(100vw-24px))]',
@ -3501,8 +3671,9 @@ export default function TrainingTab(props: {
type="button" type="button"
className={[ className={[
isSmallBox isSmallBox
? 'flex min-w-0 touch-none items-center gap-1 px-2 text-left' ? 'flex min-w-0 touch-none select-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-1.5 text-left',
'[-webkit-user-select:none] [-webkit-touch-callout:none]',
isActiveBox isActiveBox
? '!bg-blue-600 !text-white hover:!bg-blue-700' ? '!bg-blue-600 !text-white hover:!bg-blue-700'
: '', : '',
@ -3519,6 +3690,7 @@ export default function TrainingTab(props: {
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
window.getSelection()?.removeAllRanges()
const requiresActivationFirst = !isActiveBox const requiresActivationFirst = !isActiveBox
@ -3615,7 +3787,7 @@ export default function TrainingTab(props: {
className={[ className={[
isActiveBox 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', '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', '!bg-transparent hover:!bg-transparent active:!bg-transparent focus:!bg-transparent',
'focus:outline-none disabled:opacity-50 sm:h-5 sm:w-5', 'focus:outline-none disabled:opacity-50 sm:h-5 sm:w-5',
@ -3637,6 +3809,7 @@ export default function TrainingTab(props: {
e.preventDefault() e.preventDefault()
e.stopPropagation() e.stopPropagation()
window.getSelection()?.removeAllRanges()
const requiresActivationFirst = !isActiveBox const requiresActivationFirst = !isActiveBox