+
+
+
+ Training-Schonmodus
+
+
+ Wähle einen Leistungsmodus. „Automatisch“ passt Threads, Worker und Batchgröße anhand der CPU des Servers an.
+ Änderungen an diesen Trainingsparametern gelten ab dem nächsten Trainingsstart.
+
+
+
+ {trainingCpuCoreCount > 0 ? (
+
+ {trainingCpuCoreCount} CPU-Threads erkannt
+
+ ) : null}
+
+
+ {trainingSettingsLocked ? (
+
+ Training läuft gerade. Leistungsmodus, Epochs, Threads, Worker, Batchgröße und VideoMAE können erst nach Abschluss oder Abbruch des aktuellen Trainings geändert werden.
+ {trainingJobStatus?.step ? (
+ {trainingJobStatus.step}
+ ) : null}
+
+ ) : (
+
+ Hinweis: Diese Werte werden beim Start des Trainings an Python/PyTorch übergeben und wirken nicht nachträglich auf bereits laufende Trainingsprozesse.
+
+ )}
+
+
+ {TRAINING_PERFORMANCE_MODES.map((mode) => {
+ const active = trainingPerformanceMode === mode.key
+
+ return (
+
+ setValue((v) => ({
+ ...v,
+ trainingPerformanceMode: mode.key,
+ trainingPowerSaveMode: mode.key === 'eco',
+ trainingLowPriority:
+ mode.key === 'eco'
+ ? true
+ : mode.key === 'custom'
+ ? v.trainingLowPriority
+ : false,
+ }))
+ }
+ className={[
+ 'rounded-lg border px-3 py-2 text-left transition disabled:cursor-not-allowed disabled:opacity-60',
+ active
+ ? 'border-indigo-300 bg-indigo-50 text-indigo-950 ring-1 ring-indigo-200 dark:border-indigo-400/40 dark:bg-indigo-500/15 dark:text-indigo-100 dark:ring-indigo-400/30'
+ : 'border-gray-200 bg-white text-gray-800 hover:bg-gray-50 dark:border-white/10 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-white/10',
+ ].join(' ')}
+ >
+
+ {mode.title}
+
+
+ {mode.description}
+
+
+ )
+ })}
+
+
+
+
+
Wirksam
+
{trainingEffectiveMode}
+
+
+
Threads
+
{formatTrainingEffectiveValue(trainingEffectiveCpuThreads, 'Auto')}
+
+
+
Worker
+
{formatTrainingEffectiveValue(trainingEffectiveWorkers, '0')}
+
+
+
YOLO-Batch
+
{formatTrainingEffectiveValue(trainingEffectiveYoloBatchSize, 'Auto')}
+
+
+
Priorität
+
{trainingEffectiveLowPriority ? 'Niedrig' : 'Normal'}
+
+
+
+
+
+
+ CPU-Threads
+
+
+
+ setValue((v) => ({
+ ...v,
+ trainingCpuThreads: Number(e.target.value || 0),
+ }))
+ }
+ className="h-9 w-full rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-900 shadow-sm disabled:cursor-not-allowed disabled:opacity-60 dark:border-white/10 dark:bg-gray-900 dark:text-gray-100"
+ />
+
+ 0 = Auto
+
+
+
+
+
+
+ Loader-Worker
+
+
+
+ setValue((v) => ({
+ ...v,
+ trainingWorkers: Number(e.target.value || 0),
+ }))
+ }
+ className="h-9 w-full rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-900 shadow-sm disabled:cursor-not-allowed disabled:opacity-60 dark:border-white/10 dark:bg-gray-900 dark:text-gray-100"
+ />
+
+ 0-16
+
+
+
+
+
+
+ YOLO-Batch
+
+
+
+ setValue((v) => ({
+ ...v,
+ trainingYoloBatchSize: Number(e.target.value || 0),
+ }))
+ }
+ className="h-9 w-full rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-900 shadow-sm disabled:cursor-not-allowed disabled:opacity-60 dark:border-white/10 dark:bg-gray-900 dark:text-gray-100"
+ />
+
+ 0 = Auto
+
+
+
+
+
+
+
+ setValue((v) => ({
+ ...v,
+ trainingLowPriority: checked,
+ }))
+ }
+ disabled={!trainingManualMode || trainingSettingsLocked}
+ label="Niedrige Prozesspriorität"
+ description="Im manuellen Modus steuerbar. Presets setzen die Priorität passend zum gewählten Leistungsmodus."
+ />
+
+
+ setValue((v) => ({
+ ...v,
+ trainingVideoMAEEnabled: checked,
+ }))
+ }
+ disabled={trainingSettingsLocked}
+ label="VideoMAE mittrainieren"
+ description="Deaktivieren spart viel CPU-Zeit. YOLO Detector und Pose können weiterhin trainiert werden."
+ />
+
+
| null | undefined):
}
function TrainingStageOverlay(props: {
- mode: 'training' | 'analysis'
+ mode: 'training' | 'analysis' | 'saving'
+ title?: string
text?: string
+ sourceFile?: string
+ frameLabel?: string
+ statusText?: string
progress?: number
backgroundUrl?: string
visible?: boolean
@@ -1140,6 +1144,7 @@ function TrainingStageOverlay(props: {
}) {
const progress = clampPercent(props.progress ?? 0)
const isTraining = props.mode === 'training'
+ const isSaving = props.mode === 'saving'
const visible = props.visible ?? true
const [displayedBackgroundUrl, setDisplayedBackgroundUrl] = useState('')
@@ -1221,15 +1226,29 @@ function TrainingStageOverlay(props: {
? 'transition-opacity duration-200 ease-out will-change-opacity motion-reduce:transition-none'
: 'transition-opacity duration-500 ease-out will-change-opacity motion-reduce:transition-none'
- const title = isTraining ? 'Training läuft…' : 'Analyse läuft…'
+ const title = props.title || (
+ isTraining
+ ? 'Training läuft…'
+ : isSaving
+ ? 'Speichert…'
+ : 'Analyse läuft…'
+ )
const fallbackText = isTraining
? 'Bitte warten. Die Oberfläche ist währenddessen gesperrt.'
- : 'Bild wird erstellt und analysiert. Bitte warten.'
+ : isSaving
+ ? 'Feedback wird gespeichert. Bitte warten.'
+ : 'Bild wird erstellt und analysiert. Bitte warten.'
+ const sourceFile = String(props.sourceFile || '').trim()
+ const frameLabel = String(props.frameLabel || '').trim()
+ const statusText = String(props.statusText || props.text || fallbackText).trim()
+ const hasStructuredDetails = Boolean(sourceFile || frameLabel)
+ const primaryText = hasStructuredDetails ? statusText : title
+ const secondaryText = hasStructuredDetails ? sourceFile : statusText
return (
-
+
-
- {title}
+
+ {primaryText}
-
- {props.text || fallbackText}
-
-
-
+ className="mt-1 flex max-w-full items-center justify-center gap-1.5"
+ title={secondaryText}
+ >
+
+ {secondaryText}
+
-
- {Math.round(progress)}%
+ {frameLabel ? (
+
+ {frameLabel}
+
+ ) : null}
+
+ ) : frameLabel ? (
+
+
+ Frame
+
+
+
+ {frameLabel}
+
+
+ ) : null}
+
+
+
+
+
+ {Math.round(progress)}%
+
)
}
+function compactTrainingSourceFile(sourceFile: string) {
+ let cleanSourceFile = String(sourceFile || '').trim()
+ let frameLabel = ''
+
+ const sourceFrameMatch = cleanSourceFile.match(/^(.*?)\s*\((\d+)\s*\/\s*(\d+)\)\s*$/)
+
+ if (sourceFrameMatch) {
+ cleanSourceFile = sourceFrameMatch[1].trim()
+ frameLabel = `${sourceFrameMatch[2]} / ${sourceFrameMatch[3]}`
+ }
+
+ return {
+ sourceFile: cleanSourceFile,
+ frameLabel,
+ }
+}
+
+function withTrainingFrameLabels(samples: TrainingSample[]) {
+ if (samples.length <= 1) return samples
+
+ return samples.map((sample, index) => {
+ const sourceDetails = compactTrainingSourceFile(sample.sourceFile)
+ const sourceFile = sourceDetails.sourceFile || String(sample.sourceFile || '').trim()
+
+ if (!sourceFile || sourceDetails.frameLabel) {
+ return sample
+ }
+
+ return {
+ ...sample,
+ sourceFile: `${sourceFile} (${index + 1} / ${samples.length})`,
+ }
+ })
+}
+
+function formatTrainingStageStatus(value: string) {
+ const text = String(value || '').trim()
+
+ if (!text) return text
+
+ return text.charAt(0).toLocaleUpperCase('de-DE') + text.slice(1)
+}
+
+function compactTrainingStageDetails(sourceFile: string, stepText: string) {
+ const sourceDetails = compactTrainingSourceFile(sourceFile)
+ let cleanSourceFile = sourceDetails.sourceFile
+ let frameLabel = sourceDetails.frameLabel
+ let statusText = String(stepText || '').trim()
+
+ const stepFrameMatch = statusText.match(/^Frame\s+(\d+)\s*\/\s*(\d+)\s+(.+)$/i)
+
+ if (stepFrameMatch) {
+ if (!frameLabel) {
+ frameLabel = `${stepFrameMatch[1]} / ${stepFrameMatch[2]}`
+ }
+
+ statusText = stepFrameMatch[3].trim()
+ }
+
+ return {
+ sourceFile: cleanSourceFile,
+ frameLabel,
+ statusText: formatTrainingStageStatus(statusText || 'Bild wird geladen…'),
+ }
+}
+
function labelTileClass(active: boolean) {
return [
'group flex min-h-[58px] w-full flex-col items-center justify-center gap-1 rounded-xl px-2 py-1.5 text-center text-[10px] font-semibold leading-tight ring-1 transition sm:min-h-[74px] sm:py-2',
@@ -2753,6 +2863,7 @@ const TRAINING_INFO_DISMISSED_STORAGE_KEY = 'training:info-dismissed-key'
const TRAINING_IMAGE_EXPANDED_STORAGE_KEY = 'training:image-expanded'
const TRAINING_PENDING_IMPORT_VIDEO_STORAGE_KEY = 'training:pending-import-video'
const TRAINING_ACTIVE_IMPORT_VIDEO_STORAGE_KEY = 'training:active-import-video'
+const TRAINING_ACTIVE_NEXT_STORAGE_KEY = 'training:active-next'
export default function TrainingTab(props: {
active?: boolean
@@ -2770,6 +2881,7 @@ export default function TrainingTab(props: {
const [analysisStep, setAnalysisStep] = useState('')
const [analysisSourceFile, setAnalysisSourceFile] = useState('')
const [saving, setSaving] = useState(false)
+ const [savingOverlayText, setSavingOverlayText] = useState('')
const [training, setTraining] = useState(false)
const [trainingStatus, setTrainingStatus] = useState
(null)
const [deletingTrainingData, setDeletingTrainingData] = useState(false)
@@ -3015,8 +3127,8 @@ export default function TrainingTab(props: {
setFeedbackModalOpen(false)
window.requestAnimationFrame(() => {
- mobileLabelsScrollRef.current?.scrollTo({
- top: 0,
+ mobileLabelsScrollRef.current?.scrollIntoView({
+ block: 'start',
behavior: 'smooth',
})
})
@@ -3100,6 +3212,7 @@ export default function TrainingTab(props: {
const videoImportStartedRef = useRef(false)
const videoImportInFlightKeyRef = useRef(null)
+ const nextAnalysisInFlightRequestIdRef = useRef(null)
const epochTimingRef = useRef<{
@@ -3182,16 +3295,12 @@ export default function TrainingTab(props: {
const scrollMobileSectionToTop = useCallback(
(key: keyof typeof expandedCorrectionSections) => {
window.requestAnimationFrame(() => {
- const scrollEl = mobileLabelsScrollRef.current
const sectionEl = mobileSectionRefs.current[key]
- if (!scrollEl || !sectionEl) return
+ if (!sectionEl) return
- const scrollRect = scrollEl.getBoundingClientRect()
- const sectionRect = sectionEl.getBoundingClientRect()
-
- scrollEl.scrollTo({
- top: scrollEl.scrollTop + sectionRect.top - scrollRect.top,
+ sectionEl.scrollIntoView({
+ block: 'start',
behavior: 'smooth',
})
})
@@ -3405,6 +3514,80 @@ export default function TrainingTab(props: {
setLoadingPreviewFailed(false)
}, [])
+ const applyTrainingAnalysisEvent = useCallback((raw: any, opts?: { requireActiveRequest?: boolean }) => {
+ const data = raw?.analysis || raw
+ if (!data) return false
+
+ const requestId = String(
+ data?.requestId ||
+ data?.analysisRequestId ||
+ ''
+ ).trim()
+
+ const activeRequestId = activeAnalysisRequestIdRef.current
+ if (opts?.requireActiveRequest && (!activeRequestId || requestId !== activeRequestId)) {
+ return false
+ }
+
+ const scope = String(data?.scope || '').trim()
+ if (scope && scope !== 'training') {
+ return false
+ }
+
+ const running = Boolean(data?.running)
+ if (running) {
+ loadingRef.current = true
+ setLoading(true)
+ }
+
+ const sourceFile = String(data?.sourceFile || '').trim()
+ if (sourceFile) {
+ setAnalysisSourceFile(sourceFile)
+ }
+
+ const previewUrl = String(data?.previewUrl || '').trim()
+ if (previewUrl) {
+ setLoadingPreviewCandidate(previewUrl)
+ }
+
+ const message = String(
+ data?.message ||
+ data?.step ||
+ data?.title ||
+ ''
+ ).trim()
+
+ if (message) {
+ setAnalysisStep(message)
+ }
+
+ const current = Number(data?.current ?? data?.stepIndex ?? data?.index)
+ const total = Number(data?.total ?? data?.steps ?? data?.stepTotal)
+ const rawProgress = Number(data?.progress ?? data?.percent)
+
+ let nextProgress: number | null = null
+
+ if (
+ Number.isFinite(current) &&
+ Number.isFinite(total) &&
+ total > 0
+ ) {
+ nextProgress = (current / total) * 100
+ } else if (Number.isFinite(rawProgress)) {
+ nextProgress = rawProgress <= 1 ? rawProgress * 100 : rawProgress
+ }
+
+ if (nextProgress !== null) {
+ setAnalysisProgress((prev) =>
+ running
+ ? Math.max(prev, clampPercent(nextProgress))
+ : clampPercent(nextProgress)
+ )
+ }
+
+ return true
+ }, [setLoadingPreviewCandidate])
+
useEffect(() => {
if (!imageSrc) {
setFrameImageLoaded(false)
@@ -3615,8 +3798,8 @@ export default function TrainingTab(props: {
setMobilePanel(trainingRunningRef.current ? 'training' : 'labels')
window.requestAnimationFrame(() => {
- mobileLabelsScrollRef.current?.scrollTo({
- top: 0,
+ mobileLabelsScrollRef.current?.scrollIntoView({
+ block: 'start',
behavior: 'smooth',
})
})
@@ -3737,6 +3920,60 @@ export default function TrainingTab(props: {
return true
}, [currentSampleToQueuedItem, loadQueuedTrainingSample, setQueuedTrainingSamples])
+ const completeNextFromData = useCallback((data: any, opts?: { deferCurrentSampleToQueueEnd?: boolean }) => {
+ const nextSample = data?.sample || (
+ data?.sampleId && data?.frameUrl
+ ? data as TrainingSample
+ : null
+ )
+
+ if (!nextSample) {
+ throw new Error(backendText(data, 'Es wurde kein Trainingsbild erzeugt.'))
+ }
+
+ setAnalysisProgress(92)
+ setAnalysisStep('Analyse-Ergebnis wird übernommen…')
+
+ if (opts?.deferCurrentSampleToQueueEnd) {
+ deferCurrentSampleToQueueEnd()
+ }
+
+ loadTrainingSampleIntoTab(nextSample as TrainingSample)
+ setImageReloadKey((value) => value + 1)
+ return true
+ }, [deferCurrentSampleToQueueEnd, loadTrainingSampleIntoTab])
+
+ const waitForNextResult = useCallback(async (
+ requestId: string,
+ opts?: { deferCurrentSampleToQueueEnd?: boolean }
+ ) => {
+ const id = String(requestId || '').trim()
+ if (!id) throw new Error('requestId fehlt.')
+
+ for (;;) {
+ const res = await fetch(
+ `/api/training/next/status?requestId=${encodeURIComponent(id)}`,
+ { cache: 'no-store' }
+ )
+ const data = await res.json().catch(() => null)
+
+ if (data?.analysis) {
+ applyTrainingAnalysisEvent(data.analysis)
+ }
+
+ if (res.status === 202 || data?.running) {
+ await new Promise((resolve) => window.setTimeout(resolve, 800))
+ continue
+ }
+
+ if (!res.ok || !data?.ok) {
+ throw new Error(backendText(data, `HTTP ${res.status}`))
+ }
+
+ return completeNextFromData(data, opts)
+ }
+ }, [applyTrainingAnalysisEvent, completeNextFromData])
+
const loadNext = useCallback(async (opts?: {
forceNew?: boolean
refreshPrediction?: boolean
@@ -3747,6 +3984,7 @@ export default function TrainingTab(props: {
}) => {
const requestId = makeRequestId()
activeAnalysisRequestIdRef.current = requestId
+ nextAnalysisInFlightRequestIdRef.current = requestId
const isCurrentRequest = () => activeAnalysisRequestIdRef.current === requestId
const mode = opts?.mode ?? trainingSampleModeRef.current
@@ -3756,6 +3994,7 @@ export default function TrainingTab(props: {
setLoadingPreviewFallbackUrl(previewUrl)
setLoadingPreviewCandidate(previewUrl)
+ loadingRef.current = true
setLoading(true)
setAnalysisSourceFile('')
setAnalysisProgress(8)
@@ -3774,15 +4013,37 @@ export default function TrainingTab(props: {
setMessage(null)
}
+ let keepActiveJob = false
+ let completed = false
+
try {
const params = new URLSearchParams()
params.set('analysisRequestId', requestId)
+ params.set('async', '1')
if (opts?.forceNew) params.set('force', '1')
if (opts?.refreshPrediction) params.set('refresh', '1')
if (uncertainMode) params.set('mode', 'uncertain')
+ try {
+ window.sessionStorage.setItem(
+ TRAINING_ACTIVE_NEXT_STORAGE_KEY,
+ JSON.stringify({
+ requestId,
+ opts: {
+ forceNew: Boolean(opts?.forceNew),
+ refreshPrediction: Boolean(opts?.refreshPrediction),
+ mode,
+ previewUrl,
+ deferCurrentSampleToQueueEnd: Boolean(opts?.deferCurrentSampleToQueueEnd),
+ },
+ })
+ )
+ } catch {
+ // ignore
+ }
+
const url = `/api/training/next${params.toString() ? `?${params.toString()}` : ''}`
setAnalysisProgress(uncertainMode ? 5 : 25)
@@ -3803,14 +4064,21 @@ export default function TrainingTab(props: {
return
}
- setAnalysisProgress(92)
- setAnalysisStep('Analyse-Ergebnis wird übernommen…')
-
- if (opts?.deferCurrentSampleToQueueEnd) {
- deferCurrentSampleToQueueEnd()
+ if (data?.analysis) {
+ applyTrainingAnalysisEvent(data.analysis)
}
- loadTrainingSampleIntoTab(data as TrainingSample)
+ if (res.status === 202 || data?.accepted || data?.running) {
+ await waitForNextResult(requestId, {
+ deferCurrentSampleToQueueEnd: Boolean(opts?.deferCurrentSampleToQueueEnd),
+ })
+ } else {
+ completeNextFromData(data, {
+ deferCurrentSampleToQueueEnd: Boolean(opts?.deferCurrentSampleToQueueEnd),
+ })
+ }
+
+ completed = true
} catch (e) {
if (isCurrentRequest()) {
const msg = e instanceof Error ? e.message : String(e)
@@ -3818,8 +4086,14 @@ export default function TrainingTab(props: {
/load failed|failed to fetch|networkerror|network error/i.test(msg)
if (mayStillRun) {
+ keepActiveJob = true
setMessage('Analyse läuft im Backend weiter. Beim Zurückkehren wird das nächste offene Trainingsbild wieder geladen.')
} else {
+ try {
+ window.sessionStorage.removeItem(TRAINING_ACTIVE_NEXT_STORAGE_KEY)
+ } catch {
+ // ignore
+ }
setError(msg)
}
}
@@ -3828,6 +4102,18 @@ export default function TrainingTab(props: {
return
}
+ if (completed) {
+ try {
+ window.sessionStorage.removeItem(TRAINING_ACTIVE_NEXT_STORAGE_KEY)
+ } catch {
+ // ignore
+ }
+ }
+
+ if (keepActiveJob) {
+ return
+ }
+
setAnalysisProgress((value) => Math.max(value, 100))
setAnalysisStep((value) => value || 'Analyse abgeschlossen.')
@@ -3837,13 +4123,20 @@ export default function TrainingTab(props: {
if (activeAnalysisRequestIdRef.current !== finishedRequestId) return
activeAnalysisRequestIdRef.current = null
+ nextAnalysisInFlightRequestIdRef.current = null
+ loadingRef.current = false
setLoading(false)
setAnalysisSourceFile('')
setAnalysisProgress(0)
setAnalysisStep('')
}, 500)
}
- }, [deferCurrentSampleToQueueEnd, loadTrainingSampleIntoTab, setLoadingPreviewCandidate])
+ }, [
+ applyTrainingAnalysisEvent,
+ completeNextFromData,
+ setLoadingPreviewCandidate,
+ waitForNextResult,
+ ])
const reloadCurrentImage = useCallback(async () => {
setDrawingBox(null)
@@ -3868,11 +4161,12 @@ export default function TrainingTab(props: {
}, [applyTrainingStatus])
const completeVideoImportFromData = useCallback(async (data: any) => {
- const samples: TrainingSample[] = Array.isArray(data?.samples)
+ const rawSamples: TrainingSample[] = Array.isArray(data?.samples)
? data.samples
: data?.sample
? [data.sample]
: []
+ const samples = withTrainingFrameLabels(rawSamples)
if (samples.length === 0) {
throw new Error('Es wurden keine Trainingsframes erzeugt.')
@@ -4002,11 +4296,12 @@ export default function TrainingTab(props: {
return true
}
- const samples: TrainingSample[] = Array.isArray(data.samples)
+ const rawSamples: TrainingSample[] = Array.isArray(data.samples)
? data.samples
: data.sample
? [data.sample]
: []
+ const samples = withTrainingFrameLabels(rawSamples)
if (samples.length === 0) {
throw new Error('Es wurden keine Trainingsframes erzeugt.')
@@ -4250,87 +4545,8 @@ export default function TrainingTab(props: {
const onAnalysis = (event: Event) => {
try {
const data = (event as CustomEvent).detail
-
- const activeRequestId = activeAnalysisRequestIdRef.current
-
- if (!loadingRef.current || !activeRequestId) return
-
- const requestId = String(
- data?.requestId ||
- data?.analysisRequestId ||
- data?.analysis?.requestId ||
- data?.analysis?.analysisRequestId ||
- ''
- ).trim()
-
- const scope = String(
- data?.scope ||
- data?.analysis?.scope ||
- ''
- ).trim()
-
- if (scope !== 'training') return
- if (requestId !== activeRequestId) return
-
- const message = String(
- data?.message ||
- data?.step ||
- data?.title ||
- ''
- ).trim()
-
- const sourceFile = String(
- data?.sourceFile ||
- data?.analysis?.sourceFile ||
- ''
- ).trim()
-
- if (sourceFile) {
- setAnalysisSourceFile(sourceFile)
- }
-
- const previewUrl = String(
- data?.previewUrl ||
- data?.analysis?.previewUrl ||
- ''
- ).trim()
-
- if (previewUrl) {
- setLoadingPreviewCandidate(previewUrl)
- }
-
- const current = Number(data?.current ?? data?.stepIndex ?? data?.index)
- const total = Number(data?.total ?? data?.steps ?? data?.stepTotal)
-
- const rawProgress = Number(
- data?.progress ??
- data?.percent ??
- data?.analysis?.progress
- )
-
- let nextProgress: number | null = null
-
- if (
- Number.isFinite(current) &&
- Number.isFinite(total) &&
- total > 0
- ) {
- nextProgress = (current / total) * 100
- } else if (Number.isFinite(rawProgress)) {
- nextProgress = rawProgress <= 1
- ? rawProgress * 100
- : rawProgress
- }
-
- if (message) {
- setAnalysisStep(message)
- }
-
- if (nextProgress !== null) {
- setAnalysisProgress((prev) =>
- Math.max(prev, clampPercent(nextProgress))
- )
- }
+ if (!loadingRef.current || !activeAnalysisRequestIdRef.current) return
+ applyTrainingAnalysisEvent(data, { requireActiveRequest: true })
} catch {
// ignore
}
@@ -4341,7 +4557,7 @@ export default function TrainingTab(props: {
return () => {
window.removeEventListener('app:sse:analysis', onAnalysis as EventListener)
}
- }, [setLoadingPreviewCandidate])
+ }, [applyTrainingAnalysisEvent])
useEffect(() => {
const draggingBox = Boolean(drawingBox || boxInteraction)
@@ -4489,6 +4705,7 @@ export default function TrainingTab(props: {
window.addEventListener('training:import-video', onImportVideo as EventListener)
let resumedActiveImport = false
+ let resumedActiveNext = false
try {
const activeRaw = window.sessionStorage.getItem(TRAINING_ACTIVE_IMPORT_VIDEO_STORAGE_KEY)
@@ -4546,7 +4763,75 @@ export default function TrainingTab(props: {
}
}
+ const activeNextRaw = resumedActiveImport
+ ? ''
+ : window.sessionStorage.getItem(TRAINING_ACTIVE_NEXT_STORAGE_KEY)
+
+ if (activeNextRaw) {
+ const active = JSON.parse(activeNextRaw)
+ const requestId = String(active?.requestId || '').trim()
+ const activeOpts = active?.opts || {}
+
+ if (requestId) {
+ activeAnalysisRequestIdRef.current = requestId
+ nextAnalysisInFlightRequestIdRef.current = requestId
+ loadingRef.current = true
+ setLoading(true)
+ setAnalysisSourceFile('')
+ setAnalysisProgress(5)
+ setAnalysisStep('Analyse wird fortgesetzt…')
+ setError(null)
+
+ void fetch(
+ `/api/training/next/status?requestId=${encodeURIComponent(requestId)}`,
+ { cache: 'no-store' }
+ )
+ .then((res) => res.json().catch(() => null))
+ .then((data) => {
+ if (data?.analysis) {
+ applyTrainingAnalysisEvent(data.analysis)
+ }
+ })
+ .catch(() => {
+ // ignore; waitForNextResult pollt danach weiter.
+ })
+
+ void waitForNextResult(requestId, {
+ deferCurrentSampleToQueueEnd: Boolean(activeOpts?.deferCurrentSampleToQueueEnd),
+ })
+ .then(() => {
+ window.sessionStorage.removeItem(TRAINING_ACTIVE_NEXT_STORAGE_KEY)
+ })
+ .catch((e) => {
+ const msg = e instanceof Error ? e.message : String(e)
+ const mayStillRun =
+ /load failed|failed to fetch|networkerror|network error/i.test(msg)
+
+ if (mayStillRun) {
+ setMessage('Analyse läuft im Backend weiter und wird beim Zurückkehren wieder aufgenommen.')
+ } else {
+ window.sessionStorage.removeItem(TRAINING_ACTIVE_NEXT_STORAGE_KEY)
+ setError(msg)
+ }
+ })
+ .finally(() => {
+ if (activeAnalysisRequestIdRef.current === requestId) {
+ activeAnalysisRequestIdRef.current = null
+ nextAnalysisInFlightRequestIdRef.current = null
+ loadingRef.current = false
+ setLoading(false)
+ setAnalysisSourceFile('')
+ setAnalysisProgress(0)
+ setAnalysisStep('')
+ }
+ })
+
+ resumedActiveNext = true
+ }
+ }
+
const raw = resumedActiveImport
+ || resumedActiveNext
? ''
: window.sessionStorage.getItem(TRAINING_PENDING_IMPORT_VIDEO_STORAGE_KEY)
@@ -4561,7 +4846,7 @@ export default function TrainingTab(props: {
return () => {
window.removeEventListener('training:import-video', onImportVideo as EventListener)
}
- }, [importVideoIntoTraining, waitForVideoImportResult])
+ }, [applyTrainingAnalysisEvent, importVideoIntoTraining, waitForNextResult, waitForVideoImportResult])
useEffect(() => {
if (!tabActive || initializedRef.current) return
@@ -4579,7 +4864,11 @@ export default function TrainingTab(props: {
// Wichtig:
// Wenn gerade ein Video-Import über "Video ins Training übernehmen" läuft,
// darf loadNext() nicht danach ein zufälliges/letztes Sample darüberlegen.
- if (videoImportStartedRef.current || videoImportInFlightKeyRef.current) {
+ if (
+ videoImportStartedRef.current ||
+ videoImportInFlightKeyRef.current ||
+ nextAnalysisInFlightRequestIdRef.current
+ ) {
initializedRef.current = true
return
}
@@ -4610,6 +4899,57 @@ export default function TrainingTab(props: {
return () => window.cancelAnimationFrame(frame)
}, [tabActive, loadTrainingStatus, updateImageLayerStyle])
+ useEffect(() => {
+ if (!tabActive) return
+
+ let cancelled = false
+
+ async function refreshActiveAnalysisStatus() {
+ let nextRequestId = nextAnalysisInFlightRequestIdRef.current || ''
+ let importRequestId = activeAnalysisRequestIdRef.current || ''
+
+ try {
+ const activeNextRaw = window.sessionStorage.getItem(TRAINING_ACTIVE_NEXT_STORAGE_KEY)
+ if (!nextRequestId && activeNextRaw) {
+ nextRequestId = String(JSON.parse(activeNextRaw)?.requestId || '').trim()
+ }
+ } catch {
+ // ignore
+ }
+
+ try {
+ const activeImportRaw = window.sessionStorage.getItem(TRAINING_ACTIVE_IMPORT_VIDEO_STORAGE_KEY)
+ if (activeImportRaw) {
+ importRequestId = String(JSON.parse(activeImportRaw)?.requestId || importRequestId || '').trim()
+ }
+ } catch {
+ // ignore
+ }
+
+ const url = nextRequestId
+ ? `/api/training/next/status?requestId=${encodeURIComponent(nextRequestId)}`
+ : importRequestId
+ ? `/api/training/import-video/status?requestId=${encodeURIComponent(importRequestId)}`
+ : '/api/training/analysis/status'
+
+ try {
+ const res = await fetch(url, { cache: 'no-store' })
+ const data = await res.json().catch(() => null)
+ if (cancelled || !res.ok || !data?.analysis) return
+
+ applyTrainingAnalysisEvent(data.analysis)
+ } catch {
+ // ignore
+ }
+ }
+
+ void refreshActiveAnalysisStatus()
+
+ return () => {
+ cancelled = true
+ }
+ }, [applyTrainingAnalysisEvent, tabActive])
+
useEffect(() => {
if (!trainingRunning) return
@@ -4718,8 +5058,9 @@ export default function TrainingTab(props: {
negative?: boolean
}
) => {
- if (!sample) return
+ if (!sample || trainingRunning) return
+ setSavingOverlayText(editingFeedback ? 'Feedback wird aktualisiert…' : 'Feedback wird gespeichert…')
setSaving(true)
setError(null)
setMessage(null)
@@ -4751,6 +5092,19 @@ export default function TrainingTab(props: {
boxes: normalizedBoxes,
}
const effectiveAccepted = negative ? false : accepted
+ setSavingOverlayText(
+ negative
+ ? editingFeedback
+ ? 'Negativbeispiel wird aktualisiert…'
+ : 'Negativbeispiel wird gespeichert…'
+ : effectiveAccepted
+ ? editingFeedback
+ ? 'Feedback wird aktualisiert…'
+ : 'Feedback wird gespeichert…'
+ : editingFeedback
+ ? 'Korrektur wird aktualisiert…'
+ : 'Korrektur wird gespeichert…'
+ )
const payload = {
sampleId: sample.sampleId,
@@ -4852,6 +5206,7 @@ export default function TrainingTab(props: {
setError(`Feedback konnte nicht gespeichert werden. ${short}`)
} finally {
setSaving(false)
+ setSavingOverlayText('')
}
},
[
@@ -4862,6 +5217,7 @@ export default function TrainingTab(props: {
loadTrainingStatus,
loadQueuedTrainingSample,
loadNextImportedQueuedSample,
+ trainingRunning,
]
)
@@ -4885,6 +5241,7 @@ export default function TrainingTab(props: {
const skippedSampleId = sample.sampleId
+ setSavingOverlayText('Bild wird übersprungen…')
setSaving(true)
setError(null)
setMessage(null)
@@ -4924,6 +5281,7 @@ export default function TrainingTab(props: {
setError(e instanceof Error ? e.message : String(e))
} finally {
setSaving(false)
+ setSavingOverlayText('')
}
}, [
sample,
@@ -6370,11 +6728,16 @@ export default function TrainingTab(props: {
detectorBoxItemRefs.current[index] = el
}}
key={`box-${index}`}
- onClick={() => setActiveBoxIndex(index)}
+ aria-disabled={uiLocked}
+ onClick={() => {
+ if (uiLocked) return
+ setActiveBoxIndex(index)
+ }}
className={[
- 'group relative cursor-pointer overflow-hidden rounded-2xl border transition-all duration-200',
+ 'group relative overflow-hidden rounded-2xl border transition-all duration-200',
'bg-white shadow-sm',
'dark:border-white/10 dark:bg-gray-950/55',
+ uiLocked ? 'cursor-not-allowed opacity-60' : 'cursor-pointer',
isActive
? [
'border-gray-200 bg-white',
@@ -6382,9 +6745,11 @@ export default function TrainingTab(props: {
tone.activeSurface,
].join(' ')
: [
- 'border-gray-200 hover:bg-gray-50/80 hover:shadow-md',
- 'dark:border-white/10 dark:hover:bg-white/[0.04]',
- tone.idleHover,
+ 'border-gray-200',
+ uiLocked ? '' : 'hover:bg-gray-50/80 hover:shadow-md',
+ 'dark:border-white/10',
+ uiLocked ? '' : 'dark:hover:bg-white/[0.04]',
+ uiLocked ? '' : tone.idleHover,
].join(' '),
].join(' ')}
>
@@ -6405,6 +6770,7 @@ export default function TrainingTab(props: {
disabled={uiLocked}
onClick={(e) => {
e.stopPropagation()
+ if (uiLocked) return
setActiveBoxIndex(index)
}}
className={[
@@ -6491,6 +6857,7 @@ export default function TrainingTab(props: {
aria-label={`${item.text} löschen`}
onClick={(e) => {
e.stopPropagation()
+ if (uiLocked) return
removeBox(index)
}}
>
@@ -6530,6 +6897,7 @@ export default function TrainingTab(props: {
loadingPreviewUrl && loadingPreviewLoaded && !loadingPreviewFailed
? loadingPreviewUrl
: loadingPreviewFallbackUrl
+ const sampleSourceDetails = compactTrainingSourceFile(sample?.sourceFile || '')
const frameLayoutSize = useMemo(() => {
const width = Number(frameNaturalSize?.width)
@@ -6602,24 +6970,50 @@ export default function TrainingTab(props: {
'lg:w-[min(100%,var(--image-stage-w-lg))]',
].join(' ')
- const stageBusy = trainingRunning || frameBusy
+ const savingStageActive = saving && !trainingRunning && !frameBusy
+ const stageBusy = trainingRunning || frameBusy || saving
- const stageOverlayMode: 'training' | 'analysis' =
- trainingRunning ? 'training' : 'analysis'
+ const stageOverlayMode: 'training' | 'analysis' | 'saving' =
+ trainingRunning
+ ? 'training'
+ : savingStageActive
+ ? 'saving'
+ : 'analysis'
- const analysisOverlayText = analysisSourceFile
- ? `${analysisSourceFile} · ${analysisStep || 'Bild wird geladen…'}`
- : analysisStep || 'Bild wird geladen…'
+ const analysisOverlayDetails = compactTrainingStageDetails(
+ analysisSourceFile,
+ analysisStep || 'Bild wird geladen…'
+ )
const stageOverlayText = trainingRunning
? shownTrainingStep || 'Aktuelles Bild wird geladen…'
- : analysisOverlayText
+ : savingStageActive
+ ? savingOverlayText || 'Feedback wird gespeichert…'
+ : analysisOverlayDetails.statusText
const stageOverlayProgress = trainingRunning
? shownTrainingProgress
: loading
? analysisProgress
- : 100
+ : savingStageActive
+ ? 65
+ : 100
+
+ const stageOverlaySourceFile = stageOverlayMode === 'analysis'
+ ? analysisOverlayDetails.sourceFile
+ : stageOverlayMode === 'saving'
+ ? sampleSourceDetails.sourceFile
+ : undefined
+ const stageOverlayFrameLabel = stageOverlayMode === 'analysis'
+ ? analysisOverlayDetails.frameLabel
+ : stageOverlayMode === 'saving'
+ ? sampleSourceDetails.frameLabel
+ : undefined
+ const stageOverlayStatusText = stageOverlayMode === 'analysis'
+ ? analysisOverlayDetails.statusText
+ : stageOverlayMode === 'saving'
+ ? stageOverlayText
+ : undefined
const stageOverlayFadeMs = 300
@@ -6683,14 +7077,20 @@ export default function TrainingTab(props: {
Training
- {sample?.sourceFile ? (
+ {sampleSourceDetails.sourceFile ? (
- {sample.sourceFile}
+ {sampleSourceDetails.sourceFile}
+
+ {sampleSourceDetails.frameLabel ? (
+
+ {sampleSourceDetails.frameLabel}
+
+ ) : null}
) : null}
@@ -6734,14 +7134,20 @@ export default function TrainingTab(props: {
Training
- {sample?.sourceFile ? (
+ {sampleSourceDetails.sourceFile ? (
- {sample.sourceFile}
+ {sampleSourceDetails.sourceFile}
+
+ {sampleSourceDetails.frameLabel ? (
+
+ {sampleSourceDetails.frameLabel}
+
+ ) : null}
) : null}
@@ -7540,24 +7946,24 @@ export default function TrainingTab(props: {
) : null}
void saveFeedback(!hasManualCorrection)}
className="w-full justify-center px-2 text-xs sm:text-sm"
title={
- willSaveAsNegative
+ trainingRunning
+ ? 'Während das Training läuft, kann kein Bild ins Training übernommen werden.'
+ : willSaveAsNegative
? 'Keine Box und keine Position gesetzt. Das Bild wird als Negativbeispiel gespeichert.'
: hasManualCorrection
? 'Die korrigierten Werte werden gespeichert.'
@@ -7731,7 +8140,7 @@ export default function TrainingTab(props: {
) : null}
-
+
{[
{ key: 'labels', label: 'Labels' },
@@ -7780,7 +8189,7 @@ export default function TrainingTab(props: {
{ /* Rechte Seite */ }
{mobilePanel === 'labels' ? (