diff --git a/backend/ml/detection_labels.json b/backend/ml/detection_labels.json index f1ddad0..aad8492 100644 --- a/backend/ml/detection_labels.json +++ b/backend/ml/detection_labels.json @@ -5,7 +5,6 @@ ], "sexPositions": [ "unknown", - "solo", "missionary", "doggy", "cowgirl", @@ -33,13 +32,11 @@ "pussy" ], "objects": [ - "bath", "blindfold", "buttplug", "collar", "dildo", "handcuffs", - "rope", "shower", "strapon", "towel", @@ -50,11 +47,11 @@ "bra", "dress", "fishnet", + "harness", "heels", "hotpants", "lingerie", "panties", - "shirt", "skirt", "stockings", "croptop" diff --git a/backend/record_stream_hls.go b/backend/record_stream_hls.go index 36bbe5b..deeb239 100644 --- a/backend/record_stream_hls.go +++ b/backend/record_stream_hls.go @@ -405,28 +405,6 @@ func appendFileAndRemove(dstPath, srcPath string) error { return nil } -func cleanupHLSRetryChunks(outFile string) { - dir := filepath.Dir(outFile) - ext := filepath.Ext(outFile) - base := strings.TrimSuffix(filepath.Base(outFile), ext) - - canonical := canonicalAssetIDFromName(base) - if canonical == "" { - canonical = base - } - - pattern := filepath.Join(dir, fmt.Sprintf(".%s.retry.*%s", canonical, ext)) - - matches, err := filepath.Glob(pattern) - if err != nil { - return - } - - for _, p := range matches { - _ = removeWithRetry(p) - } -} - func handleM3U8Mode( ctx context.Context, stream *selectedHLSStream, @@ -460,20 +438,15 @@ func handleM3U8Mode( return errors.New("output file path leer") } - cleanupHLSRetryChunks(outFile) - defer cleanupHLSRetryChunks(outFile) - ext := strings.ToLower(filepath.Ext(outFile)) if ext != ".ts" && ext != ".mp4" { return fmt.Errorf("nicht unterstützte output-endung: %q", ext) } - // Wichtig: ffmpeg schreibt in temporäre Retry-Datei. - // Danach wird sie an outFile angehängt und gelöscht. - retryOut := hlsRetryChunkPath(outFile, 1) - defer func() { - _ = removeWithRetry(retryOut) - }() + // Wichtig: + // handleM3U8Mode schreibt immer in den übergebenen outFile. + // Wenn der Caller einen Retry-Chunk möchte, muss er bereits + // einen Retry-Pfad als outFile übergeben. args := []string{ "-y", @@ -534,12 +507,12 @@ func handleM3U8Mode( case ".ts": args = append(args, "-f", "mpegts", - retryOut, + outFile, ) case ".mp4": args = append(args, "-movflags", "+faststart", - retryOut, + outFile, ) } @@ -568,9 +541,7 @@ func handleM3U8Mode( case <-stopStat: return case <-t.C: - mainSize := safeFileSize(outFile) - retrySize := safeFileSize(retryOut) - sz := mainSize + retrySize + sz := safeFileSize(outFile) if sz > 0 && sz != last { jobsMu.Lock() @@ -596,10 +567,6 @@ func handleM3U8Mode( return fmt.Errorf("ffmpeg m3u8 failed: %w", err) } - if err := appendFileAndRemove(outFile, retryOut); err != nil { - return fmt.Errorf("retry chunk übernehmen fehlgeschlagen: %w", err) - } - if job != nil { if fi, statErr := os.Stat(outFile); statErr == nil && fi != nil && !fi.IsDir() { jobsMu.Lock() diff --git a/backend/sse.go b/backend/sse.go index bb7b161..c76b8c4 100644 --- a/backend/sse.go +++ b/backend/sse.go @@ -171,7 +171,11 @@ func publishTaskState() { TS: time.Now().UnixMilli(), } - b, _ := json.Marshal(ev) + b, err := json.Marshal(ev) + if err != nil { + return + } + publishSSE("taskState", b) } diff --git a/backend/training.go b/backend/training.go index b3236e3..5e7c0ec 100644 --- a/backend/training.go +++ b/backend/training.go @@ -127,6 +127,11 @@ type TrainingJobStatus struct { Error string `json:"error,omitempty"` StartedAt string `json:"startedAt,omitempty"` FinishedAt string `json:"finishedAt,omitempty"` + DurationMs int64 `json:"durationMs,omitempty"` + + Stage string `json:"stage,omitempty"` + Epoch int `json:"epoch,omitempty"` + Epochs int `json:"epochs,omitempty"` } type TrainingConfidence struct { @@ -209,12 +214,38 @@ func trainingHandleProgressLine(line string, start int, end int, defaultStep str if progress > s.Progress { s.Progress = progress } + s.Step = step + + if strings.TrimSpace(ev.Stage) != "" { + s.Stage = strings.TrimSpace(ev.Stage) + } + + if ev.Epoch > 0 { + s.Epoch = ev.Epoch + } + + if ev.Epochs > 0 { + s.Epochs = ev.Epochs + } }) return true } +func trainingPublishJobStatus(status TrainingJobStatus) { + b, err := json.Marshal(map[string]any{ + "type": "training_status", + "training": status, + "ts": time.Now().UnixMilli(), + }) + if err != nil { + return + } + + publishSSE("training", b) +} + func trainingRunCommandStreaming( python string, script string, @@ -306,8 +337,11 @@ var trainingJob = struct { func trainingSetJobStatus(update func(*TrainingJobStatus)) { trainingJob.mu.Lock() - defer trainingJob.mu.Unlock() update(&trainingJob.status) + snapshot := trainingJob.status + trainingJob.mu.Unlock() + + trainingPublishJobStatus(snapshot) } func trainingGetJobStatus() TrainingJobStatus { @@ -901,12 +935,23 @@ func trainingRunJob(root string, count int) { errorText := strings.Join(errorParts, " ") trainingSetJobStatus(func(s *TrainingJobStatus) { + finishedAt := time.Now().UTC() + + var durationMs int64 + if startedAt, err := time.Parse(time.RFC3339, strings.TrimSpace(s.StartedAt)); err == nil { + durationMs = finishedAt.Sub(startedAt).Milliseconds() + if durationMs < 0 { + durationMs = 0 + } + } + s.Running = false s.Progress = 100 s.Step = "Training abgeschlossen." s.Message = message s.Error = errorText - s.FinishedAt = time.Now().UTC().Format(time.RFC3339) + s.FinishedAt = finishedAt.Format(time.RFC3339) + s.DurationMs = durationMs }) } @@ -1528,6 +1573,10 @@ func trainingDeleteAllHandler(w http.ResponseWriter, r *http.Request) { return } + trainingSetJobStatus(func(s *TrainingJobStatus) { + *s = TrainingJobStatus{} + }) + trainingWriteJSON(w, http.StatusOK, map[string]any{ "ok": true, "message": "Alle Trainingsdaten wurden gelöscht.", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7756ae8..34d5c34 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -121,6 +121,7 @@ type TaskStateEvent = { type?: 'task_state' generateAssetsRunning?: boolean regenerateAssetsRunning?: boolean + checkVideosRunning?: boolean cleanupRunning?: boolean anyRunning?: boolean ts?: number @@ -1109,13 +1110,6 @@ export default function App() { void loadTrainingStatus() - const timer = window.setInterval( - () => { - void loadTrainingStatus() - }, - trainingTabRunning ? 1500 : 5000 - ) - const onVisibilityChange = () => { if (!document.hidden) { void loadTrainingStatus() @@ -1126,10 +1120,9 @@ export default function App() { return () => { cancelled = true - window.clearInterval(timer) document.removeEventListener('visibilitychange', onVisibilityChange) } - }, [authed, trainingTabRunning]) + }, [authed]) const loadPendingAutoStarts = useCallback(async (opts?: { force?: boolean }) => { const force = Boolean(opts?.force) @@ -2837,12 +2830,25 @@ export default function App() { msg?.anyRunning || msg?.generateAssetsRunning || msg?.regenerateAssetsRunning || + msg?.checkVideosRunning || msg?.cleanupRunning ) ) } catch {} } + const onTraining = (ev: MessageEvent) => { + try { + const data = JSON.parse(String(ev.data ?? 'null')) + + if (data?.type !== 'training_status') return + + setTrainingTabRunning(Boolean(data?.training?.running)) + } catch { + // ignore + } + } + void loadJobs() void loadDoneCount() void loadTaskStateOnce() @@ -2880,6 +2886,7 @@ export default function App() { es.addEventListener('autostart', onAutostart as any) es.addEventListener('finishedPostwork', onFinishedPostwork as any) es.addEventListener('taskState', onTaskState as any) + es.addEventListener('training', onTraining as any) const onVis = () => { if (document.hidden) return @@ -2904,6 +2911,7 @@ export default function App() { es.removeEventListener('autostart', onAutostart as any) es.removeEventListener('finishedPostwork', onFinishedPostwork as any) es.removeEventListener('taskState', onTaskState as any) + es.removeEventListener('training', onTraining as any) const handler = onModelJobEventRef.current if (handler) { diff --git a/frontend/src/components/ui/Icons.tsx b/frontend/src/components/ui/Icons.tsx index 90b991f..a470627 100644 --- a/frontend/src/components/ui/Icons.tsx +++ b/frontend/src/components/ui/Icons.tsx @@ -512,6 +512,39 @@ export function DressIcon(props: IconProps) { ) } +export function HarnessIcon(props: IconProps) { + return ( + + {/* Hals-/Brustband */} + + + + + {/* Schulter-/Brustgurte */} + + + + + {/* Mittelgurt */} + + + {/* Taillen-/Hüftband */} + + + + {/* untere Verbindungsgurte */} + + + + {/* Ringe / Metallösen */} + + + + + + ) +} + export function ShoesIcon(props: IconProps) { return ( @@ -663,22 +696,6 @@ export function TongueIcon(props: IconProps) { ) } -export function BathIcon(props: IconProps) { - return ( - - - - - - - - - - - - ) -} - export function ShowerIcon(props: IconProps) { return ( @@ -883,11 +900,6 @@ const SEGMENT_LABEL_META: SegmentLabelMeta[] = [ text: 'Fingering', icon: FingeringIcon, }, - { - match: ['solo', 'masturbation'], - text: 'Solo', - icon: HandIcon, - }, { match: ['standing_doggy', 'standing-doggy', 'standing doggy'], text: 'Standing Doggy', @@ -983,11 +995,6 @@ const SEGMENT_LABEL_META: SegmentLabelMeta[] = [ }, // Objects - { - match: ['bath', 'bathtub', 'tub'], - text: 'Badewanne', - icon: BathIcon, - }, { match: ['blindfold'], text: 'Augenbinde', @@ -1013,11 +1020,6 @@ const SEGMENT_LABEL_META: SegmentLabelMeta[] = [ text: 'Handschellen', icon: RopeIcon, }, - { - match: ['rope', 'tie', 'restraint', 'restraints'], - text: 'Seil', - icon: RopeIcon, - }, { match: ['shower'], text: 'Dusche', @@ -1095,6 +1097,11 @@ const SEGMENT_LABEL_META: SegmentLabelMeta[] = [ text: 'Fishnet', icon: FishnetIcon, }, + { + match: ['harness', 'body_harness', 'body-harness'], + text: 'Harness', + icon: HarnessIcon, + }, { match: ['heels', 'heel', 'high_heels', 'high-heels'], text: 'High Heels', @@ -1115,11 +1122,6 @@ const SEGMENT_LABEL_META: SegmentLabelMeta[] = [ text: 'Panties', icon: UnderwearIcon, }, - { - match: ['shirt', 'tshirt', 't-shirt', 'blouse', 'hoodie', 'sweater', 'jacket', 'coat'], - text: 'Shirt', - icon: ClothingIcon, - }, { match: ['skirt'], text: 'Rock', @@ -1262,10 +1264,6 @@ function findSegmentLabelMeta(label?: string): SegmentLabelMeta | null { return { match: [], text: 'Mund', icon: MouthIcon } } - if (normalized.includes('bath') || normalized.includes('tub')) { - return { match: [], text: 'Badewanne', icon: BathIcon } - } - if (normalized.includes('shower')) { return { match: [], text: 'Dusche', icon: ShowerIcon } } @@ -1302,10 +1300,6 @@ function findSegmentLabelMeta(label?: string): SegmentLabelMeta | null { return { match: [], text: 'Handschellen', icon: RopeIcon } } - if (normalized.includes('rope') || normalized.includes('restraint') || normalized.includes('tie')) { - return { match: [], text: 'Seil', icon: RopeIcon } - } - if (normalized.includes('towel')) { return { match: [], text: 'Handtuch', icon: TowelIcon } } @@ -1362,17 +1356,17 @@ function findSegmentLabelMeta(label?: string): SegmentLabelMeta | null { } if ( - normalized.includes('shirt') || - normalized.includes('tshirt') || - normalized.includes('t-shirt') || - normalized.includes('blouse') || - normalized.includes('hoodie') || - normalized.includes('sweater') || - normalized.includes('jacket') || - normalized.includes('coat') || - normalized.includes('clothing') + normalized.includes('harness') || + normalized.includes('clothing') || + normalized.includes('clothes') || + normalized.includes('garment') || + normalized.includes('outfit') ) { - return { match: [], text: 'Oberteil', icon: ClothingIcon } + return { + match: [], + text: normalized.includes('harness') ? 'Harness' : 'Kleidung', + icon: HarnessIcon, + } } if ( @@ -1446,14 +1440,12 @@ function prettifyUnknownLabel(label?: string): string { .replace(/\bpussy\b/g, 'Vagina') .replace(/\bpenis\b/g, 'Penis') .replace(/\btongue\b/g, 'Zunge') - .replace(/\bbath\b/g, 'Badewanne') .replace(/\bblindfold\b/g, 'Augenbinde') .replace(/\bbuttplug\b/g, 'Buttplug') .replace(/\bbutt plug\b/g, 'Buttplug') .replace(/\bcollar\b/g, 'Halsband') .replace(/\bdildo\b/g, 'Dildo') .replace(/\bhandcuffs\b/g, 'Handschellen') - .replace(/\brope\b/g, 'Seil') .replace(/\bshower\b/g, 'Dusche') .replace(/\bstrapon\b/g, 'Strap-on') .replace(/\bstrap on\b/g, 'Strap-on') @@ -1463,12 +1455,12 @@ function prettifyUnknownLabel(label?: string): string { .replace(/\bbra\b/g, 'BH') .replace(/\bdress\b/g, 'Kleid') .replace(/\bfishnet\b/g, 'Fishnet') + .replace(/\bharness\b/g, 'Harness') .replace(/\bheels\b/g, 'High Heels') .replace(/\bheel\b/g, 'High Heel') .replace(/\bhotpants\b/g, 'Hotpants') .replace(/\blingerie\b/g, 'Lingerie') .replace(/\bpanties\b/g, 'Panties') - .replace(/\bshirt\b/g, 'Shirt') .replace(/\bskirt\b/g, 'Rock') .replace(/\bstockings\b/g, 'Stockings') .replace(/\bstocking\b/g, 'Stocking') diff --git a/frontend/src/components/ui/TrainingTab.tsx b/frontend/src/components/ui/TrainingTab.tsx index 4018841..bc2d988 100644 --- a/frontend/src/components/ui/TrainingTab.tsx +++ b/frontend/src/components/ui/TrainingTab.tsx @@ -22,6 +22,10 @@ type TrainingJobStatus = { error?: string startedAt?: string finishedAt?: string + durationMs?: number + stage?: string + epoch?: number + epochs?: number } type TrainingStatus = { @@ -148,6 +152,7 @@ type TrainingNotice = { title: string message: string detail?: string + progress?: number } function trainingNoticeClass(kind: TrainingNoticeKind) { @@ -215,8 +220,14 @@ function TrainingNoticeCard(props: {
-
- {props.notice.title} +
+ {props.notice.title} + + {typeof props.notice.progress === 'number' ? ( + + {Math.round(clampPercent(props.notice.progress))}% + + ) : null}
@@ -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: { @@ -3553,10 +3896,16 @@ export default function TrainingTab(props: { })) } onChange={(value) => - setCorrection((p) => ({ - ...p, - sexPosition: value, - })) + setCorrection((p) => { + if (p.sexPosition !== value) { + setHasManualCorrection(true) + } + + return { + ...p, + sexPosition: value, + } + }) } disabled={uiLocked} gridClassName="grid grid-cols-3 gap-2" @@ -3595,11 +3944,15 @@ export default function TrainingTab(props: { bodyParts: expanded, })) } - onToggle={(value) => - setCorrection((p) => ({ - ...p, - bodyPartsPresent: toggleArrayValue(p.bodyPartsPresent, value), - })) + onToggle={(value) => + setCorrection((p) => { + setHasManualCorrection(true) + + return { + ...p, + bodyPartsPresent: toggleArrayValue(p.bodyPartsPresent, value), + } + }) } drawLabel={boxLabel} onDrawLabelChange={setBoxLabel} @@ -3620,10 +3973,14 @@ export default function TrainingTab(props: { })) } onToggle={(value) => - setCorrection((p) => ({ - ...p, - objectsPresent: toggleArrayValue(p.objectsPresent, value), - })) + setCorrection((p) => { + setHasManualCorrection(true) + + return { + ...p, + objectsPresent: toggleArrayValue(p.objectsPresent, value), + } + }) } drawLabel={boxLabel} onDrawLabelChange={setBoxLabel} @@ -3644,10 +4001,14 @@ export default function TrainingTab(props: { })) } onToggle={(value) => - setCorrection((p) => ({ - ...p, - clothingPresent: toggleArrayValue(p.clothingPresent, value), - })) + setCorrection((p) => { + setHasManualCorrection(true) + + return { + ...p, + clothingPresent: toggleArrayValue(p.clothingPresent, value), + } + }) } drawLabel={boxLabel} onDrawLabelChange={setBoxLabel}