@@ -266,6 +277,23 @@ function backendText(data: any, fallback: string) {
).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)}%`
@@ -1883,6 +1911,7 @@ export default function TrainingTab(props: {
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('')
@@ -1899,12 +1928,32 @@ export default function TrainingTab(props: {
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)
@@ -2024,6 +2073,67 @@ export default function TrainingTab(props: {
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
@@ -2046,6 +2156,8 @@ export default function TrainingTab(props: {
if (value < 35) return Math.min(35, value + 4)
if (value < 65) return Math.min(65, value + 2)
if (value < 95) return Math.min(95, value + 1)
+
+ setAnalysisStep('Analyse läuft noch… Ergebnis wird erwartet.')
return value
})
}, 350)
@@ -2086,6 +2198,7 @@ export default function TrainingTab(props: {
setSample(data)
setCorrection(nextCorrection)
+ setHasManualCorrection(false)
const currentLabels = labelsRef.current
const personLabels = new Set(currentLabels.people)
@@ -2137,37 +2250,8 @@ export default function TrainingTab(props: {
if (!res.ok || !data) return
- const job = data.training || null
-
- setTrainingStatus({
- feedbackCount: Number(data.feedbackCount ?? 0),
- requiredCount: Number(data.requiredCount ?? 5),
- canTrain: Boolean(data.canTrain),
- 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,
- }
- : undefined,
- })
-
- setTraining(Boolean(job?.running))
-
- if (job?.message && !job.running) {
- setTrainingProgress(100)
- setTrainingStep('Training abgeschlossen.')
- setMessage((prev) => prev || String(job.message))
- }
-
- if (job?.error && !job.running) {
- setError((prev) => prev || String(job.error))
- }
- }, [])
+ applyTrainingStatus(data)
+ }, [applyTrainingStatus])
const loadTrainingStats = useCallback(async () => {
setTrainingStatsLoading(true)
@@ -2204,6 +2288,35 @@ export default function TrainingTab(props: {
}
}, [])
+ 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
+ }
+ }
+
+ es.addEventListener('training', onTraining as EventListener)
+
+ es.onerror = () => {
+ // Optional: Polling-Fallback bleibt separat bestehen.
+ }
+
+ return () => {
+ es.removeEventListener('training', onTraining as EventListener)
+ es.close()
+ }
+ }, [applyTrainingStatus])
+
useEffect(() => {
if (!statsModalOpen) return
@@ -2216,6 +2329,25 @@ export default function TrainingTab(props: {
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
@@ -2287,14 +2419,6 @@ export default function TrainingTab(props: {
}
}, [loadLabels, loadNext, loadTrainingStatus])
- useEffect(() => {
- const timer = window.setInterval(() => {
- void loadTrainingStatus()
- }, trainingRunning ? 1500 : 5000)
-
- return () => window.clearInterval(timer)
- }, [loadTrainingStatus, trainingRunning])
-
useEffect(() => {
if (!trainingRunning) return
@@ -2332,6 +2456,57 @@ export default function TrainingTab(props: {
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
@@ -2390,6 +2565,8 @@ export default function TrainingTab(props: {
)
const startTraining = useCallback(async () => {
+ shownTrainingCompletionRef.current = null
+
setTraining(true)
setTrainingProgress(5)
setTrainingStep('Training wird gestartet…')
@@ -2407,8 +2584,6 @@ export default function TrainingTab(props: {
throw new Error(backendText(data, `HTTP ${res.status}`))
}
- setMessage(backendText(data, 'Training wurde gestartet.'))
-
await loadTrainingStatus()
// WICHTIG:
@@ -2585,10 +2760,16 @@ export default function TrainingTab(props: {
})
}
- const correctedNextBox = boxGeometryChanged(original, nextBox)
+ const geometryChanged = boxGeometryChanged(original, nextBox)
+
+ const correctedNextBox = geometryChanged
? markBoxCorrected(nextBox)
: nextBox
+ if (geometryChanged) {
+ setHasManualCorrection(true)
+ }
+
setCorrection((prev) => ({
...prev,
boxes: (prev.boxes ?? []).map((box, index) =>
@@ -2636,6 +2817,8 @@ export default function TrainingTab(props: {
if (box.w < 0.01 || box.h < 0.01) return
+ setHasManualCorrection(true)
+
setCorrection((prev) => {
const next: CorrectionState = {
...prev,
@@ -2647,6 +2830,8 @@ export default function TrainingTab(props: {
}, [boxInteraction, drawingBox])
const removeBox = useCallback((index: number) => {
+ setHasManualCorrection(true)
+
let removedLabel = ''
let shouldClearBoxLabel = false
@@ -2678,12 +2863,21 @@ export default function TrainingTab(props: {
}, [])
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)
@@ -2710,6 +2904,117 @@ export default function TrainingTab(props: {
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 {
@@ -2732,15 +3037,47 @@ export default function TrainingTab(props: {
}
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: 'Training läuft',
- message: shownTrainingStep || 'Training läuft im Hintergrund.',
+ title: 'Trainingsprognose',
+ progress: shownTrainingProgress,
+ message: parts.length > 0
+ ? parts.join(' · ')
+ : shownTrainingStep || 'Training läuft…',
+ detail: shownTrainingStep || undefined,
}
}
return null
- }, [error, message, trainingRunning, shownTrainingStep])
+ }, [
+ error,
+ message,
+ trainingRunning,
+ shownTrainingStep,
+ shownTrainingDurationMs,
+ shownTrainingEtaMs,
+ shownTrainingEpochText,
+ estimatedEpochMs,
+ shownTrainingProgress,
+ ])
useEffect(() => {
if (!message) return
@@ -3455,13 +3792,15 @@ export default function TrainingTab(props: {