From 4320f040ff1d5d4126bf6d56d8a07a7a377b295a Mon Sep 17 00:00:00 2001 From: Linrador <68631622+Linrador@users.noreply.github.com> Date: Wed, 17 Jun 2026 09:24:18 +0200 Subject: [PATCH] bugfixes --- backend/.env | 2 +- backend/data/pending-autostart/admin.json | 3 - backend/log.go | 15 +- .../train_detector_model.cpython-314.pyc | Bin 0 -> 13480 bytes backend/ml/train_detector_model.py | 80 +++-- backend/routes.go | 1 + backend/server.go | 78 ++++- backend/tasks_cleanup.go | 47 ++- backend/training.go | 231 +++++++++++- frontend/src/components/ui/TrainingTab.tsx | 329 +++++++++++++++++- 10 files changed, 712 insertions(+), 74 deletions(-) delete mode 100644 backend/data/pending-autostart/admin.json create mode 100644 backend/ml/__pycache__/train_detector_model.cpython-314.pyc diff --git a/backend/.env b/backend/.env index e33764f..c1ed77b 100644 --- a/backend/.env +++ b/backend/.env @@ -1,2 +1,2 @@ -HTTPS_ENABLED=1 +HTTPS_ENABLED=0 AUTH_RP_ORIGINS=https://l14pbbk95100006.tegdssd.de:9999,https://l14pbbk95100006.tegdssd.de:5173,https://localhost:9999,https://127.0.0.1:9999,https://10.0.1.25:9999,http://localhost:5173,http://127.0.0.1:5173,http://10.0.1.25:5173 \ No newline at end of file diff --git a/backend/data/pending-autostart/admin.json b/backend/data/pending-autostart/admin.json deleted file mode 100644 index fc69ce2..0000000 --- a/backend/data/pending-autostart/admin.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "items": [] -} \ No newline at end of file diff --git a/backend/log.go b/backend/log.go index dd888ba..91cca93 100644 --- a/backend/log.go +++ b/backend/log.go @@ -126,9 +126,22 @@ func appLogf(format string, args ...any) { appLogWriteLine(fmt.Sprintf(format, args...)) } +// appErrorLogSuppressed unterdrückt das automatische ❌-Logging für bekannte, +// erwartbare und sehr häufige Fehler (z.B. abgelaufene HLS-Edge-URLs während +// eines Stream-Reconnects). Der Fehler wird weiterhin zurückgegeben, damit die +// Retry-/Reconnect-Logik unverändert funktioniert – nur die Log-Zeile entfällt. +func appErrorLogSuppressed(msg string) bool { + m := strings.ToLower(msg) + + return strings.Contains(m, "leere hls url") || + strings.Contains(m, "http 403") +} + func appErrorf(format string, args ...any) error { err := fmt.Errorf(format, args...) - appLogln("❌", err) + if !appErrorLogSuppressed(err.Error()) { + appLogln("❌", err) + } return err } diff --git a/backend/ml/__pycache__/train_detector_model.cpython-314.pyc b/backend/ml/__pycache__/train_detector_model.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..dc21c5d7c3abaa31a148ed01961dbc86b737df67 GIT binary patch literal 13480 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 0.5: - sample_id = batch_sample_id(batch) + # Pro Batch das gerade trainierte Bild melden – ohne Drosselung, damit + # das Frontend live immer das aktuell trainierte Bild anzeigen kann. + sample_ids = batch_sample_ids(batch) + if sample_ids: + total_batches = max(1, len(self.train_loader)) + completed = (epoch - 1) + min(1.0, self._preview_batch / total_batches) + progress = 0.04 + 0.90 * (completed / max(1, total_epochs)) - if sample_id: - total_batches = max(1, len(self.train_loader)) - completed = (epoch - 1) + min(1.0, self._preview_batch / total_batches) - - emit_progress( - "detector", - 0.04 + 0.90 * (completed / max(1, total_epochs)), - "Object Detector trainiert…", - epoch=epoch, - epochs=total_epochs, - sampleId=sample_id, - trainSamples=train_count, - valSamples=val_count, - device=str(train_device), - ) - - self._preview_emitted_at = now + emit_progress( + "detector", + progress, + "Object Detector trainiert…", + epoch=epoch, + epochs=total_epochs, + sampleId=sample_ids[0], + trainSamples=train_count, + valSamples=val_count, + device=str(train_device), + ) return super().preprocess_batch(batch) @@ -181,6 +182,8 @@ def main(): best_epoch = 0 last_epoch = 0 + best_map50 = 0.0 + best_map5095 = 0.0 def on_train_epoch_start(trainer): epoch = int(getattr(trainer, "epoch", 0)) + 1 @@ -216,7 +219,7 @@ def main(): ) def on_fit_epoch_end(trainer): - nonlocal best_epoch + nonlocal best_epoch, best_map50, best_map5095 epoch = int(getattr(trainer, "epoch", 0)) + 1 total = int(getattr(trainer, "epochs", epochs) or epochs) @@ -237,6 +240,13 @@ def main(): if map50 is not None or map5095 is not None: best_epoch = epoch + # Bestes Modell merken (YOLO speichert best.pt nach Fitness ~ mAP). + m50 = float(map50) if map50 is not None else 0.0 + if m50 >= best_map50: + best_map50 = m50 + if map5095 is not None: + best_map5095 = float(map5095) + emit_progress( "detector", 0.04 + 0.90 * (epoch / max(1, total)), @@ -310,11 +320,15 @@ def main(): "model": str(final_model), "sourceModel": str(best), "runs": str(result_path), + "trainedAt": datetime.now(timezone.utc).isoformat(), "trainSamples": train_count, "valSamples": val_count, "epochs": epochs, "imgsz": imgsz, "device": str(train_device), + "mAP50": round(best_map50, 4), + "mAP5095": round(best_map5095, 4), + "bestEpoch": best_epoch, } with (out_dir / "status.json").open("w", encoding="utf-8") as f: diff --git a/backend/routes.go b/backend/routes.go index b81b336..4a7ca49 100644 --- a/backend/routes.go +++ b/backend/routes.go @@ -96,6 +96,7 @@ func registerRoutes(mux *http.ServeMux, auth *AuthManager) *ModelStore { api.HandleFunc("/api/training/cancel", trainingCancelHandler) api.HandleFunc("/api/training/status", trainingStatusHandler) api.HandleFunc("/api/training/stats", trainingStatsHandler) + api.HandleFunc("/api/training/history", trainingHistoryHandler) api.HandleFunc("/api/training/delete-all", trainingDeleteAllHandler) api.HandleFunc("/api/training/skip", trainingSkipHandler) api.HandleFunc("/api/training/import-video", trainingImportVideoHandler) diff --git a/backend/server.go b/backend/server.go index c5cce95..a05d5aa 100644 --- a/backend/server.go +++ b/backend/server.go @@ -102,9 +102,18 @@ func buildStartupNotificationMessage() string { } type aiServerProcess struct { - cmd *exec.Cmd + cmd *exec.Cmd + done chan struct{} } +// Globales Handle auf den laufenden AI-Server, damit er (z.B. nach einem neuen +// Modell) sauber neu gestartet werden kann. +var ( + aiServerMu sync.Mutex + aiServerCurrent *aiServerProcess + aiServerCtx context.Context +) + func aiServerAutostartEnabled() bool { raw := strings.ToLower(strings.TrimSpace(os.Getenv("AI_SERVER_AUTOSTART"))) @@ -572,7 +581,8 @@ func startAIServer(ctx context.Context) (*aiServerProcess, error) { } proc := &aiServerProcess{ - cmd: cmd, + cmd: cmd, + done: make(chan struct{}), } go func() { @@ -583,6 +593,8 @@ func startAIServer(ctx context.Context) (*aiServerProcess, error) { } else { appLogln("🛑 AI Server beendet.") } + + close(proc.done) }() waitCtx, cancel := context.WithTimeout(ctx, 30*time.Second) @@ -606,6 +618,52 @@ func (p *aiServerProcess) Stop() { if p.cmd != nil && p.cmd.Process != nil { _ = p.cmd.Process.Kill() } + + // Auf das tatsächliche Prozessende warten, damit der Port frei ist, + // bevor ggf. ein neuer Server gestartet wird. + if p.done != nil { + select { + case <-p.done: + case <-time.After(10 * time.Second): + appLogln("⚠️ AI Server reagiert nicht auf Stop (Timeout).") + } + } +} + +// restartAIServer beendet den laufenden AI-Server und startet ihn frisch. +// Wird z.B. nach einem erfolgreichen Training aufgerufen, damit das neue Modell +// in einem sauberen Prozess geladen wird. +func restartAIServer() { + aiServerMu.Lock() + defer aiServerMu.Unlock() + + ctx := aiServerCtx + if ctx == nil { + ctx = context.Background() + } + + if ctx.Err() != nil { + appLogln("ℹ️ AI Server Neustart übersprungen (App wird beendet).") + return + } + + if aiServerCurrent != nil { + appLogln("🔄 AI Server wird neu gestartet…") + aiServerCurrent.Stop() + aiServerCurrent = nil + } + + proc, err := startAIServer(ctx) + if err != nil { + appLogln("⚠️ AI Server Neustart fehlgeschlagen:", err) + return + } + + aiServerCurrent = proc + + if proc != nil { + appLogln("✅ AI Server neu gestartet.") + } } func setEnvIfMissing(key, value string) { @@ -801,6 +859,13 @@ func main() { appLogln("⚠️ AI Server konnte nicht gestartet werden:", err) } + // Handle/Context global hinterlegen, damit der Server später neu gestartet + // werden kann (z.B. nach einem neuen Modell). + aiServerMu.Lock() + aiServerCtx = appCtx + aiServerCurrent = aiProc + aiServerMu.Unlock() + // ✅ Hier: alte manuelle Autostart-Pause beim echten App-Start zurücksetzen. resetAutostartPauseOnStartup() @@ -882,8 +947,13 @@ func main() { var shutdownOnce sync.Once shutdown := func() { shutdownOnce.Do(func() { - if aiProc != nil { - aiProc.Stop() + aiServerMu.Lock() + current := aiServerCurrent + aiServerCurrent = nil + aiServerMu.Unlock() + + if current != nil { + current.Stop() } appCancel() diff --git a/backend/tasks_cleanup.go b/backend/tasks_cleanup.go index 9d0d1d1..6966d92 100644 --- a/backend/tasks_cleanup.go +++ b/backend/tasks_cleanup.go @@ -527,16 +527,8 @@ func cleanupDeleteRecordOrphanFile(ctx context.Context, jobID string, path strin return } - updateCleanupJobState(jobID, func(st *CleanupTaskState) { - st.Queued = false - st.Running = true - st.Done = 0 - st.Total = 0 - st.CurrentFile = name - st.Text = "Räume Record-Reste auf…" - st.Error = "" - st.FinishedAt = nil - }) + // Fortschritt (Done/Total/CurrentFile) steuert der Aufrufer + // cleanupRecordDirOrphanAVFiles, damit die Progress-Bar sichtbar bleibt. if err := removeWithRetry(path); err != nil && !os.IsNotExist(err) { resp.ErrorCount++ @@ -582,6 +574,34 @@ func cleanupRecordDirOrphanAVFiles(ctx context.Context, jobID string, recordAbs return nil } + // Gesamtzahl der zu prüfenden Dateien für einen sichtbaren Fortschritt. + total := 0 + for _, e := range entries { + if e == nil || e.IsDir() { + continue + } + if strings.TrimSpace(e.Name()) == "" { + continue + } + total++ + } + + updateCleanupJobState(jobID, func(st *CleanupTaskState) { + st.Queued = false + st.Running = true + if st.StartedAt.IsZero() { + st.StartedAt = time.Now() + } + st.Done = 0 + st.Total = total + st.CurrentFile = "" + st.Text = "Räume Record-Reste auf…" + st.Error = "" + st.FinishedAt = nil + }) + + processed := 0 + for _, e := range entries { select { case <-ctx.Done(): @@ -598,6 +618,13 @@ func cleanupRecordDirOrphanAVFiles(ctx context.Context, jobID string, recordAbs continue } + processed++ + updateCleanupJobState(jobID, func(st *CleanupTaskState) { + st.Done = processed + st.Total = total + st.CurrentFile = name + }) + full := filepath.Join(recordAbs, name) low := strings.ToLower(name) diff --git a/backend/training.go b/backend/training.go index 7d72334..0aff095 100644 --- a/backend/training.go +++ b/backend/training.go @@ -153,6 +153,9 @@ type TrainingJobStatus struct { Stage string `json:"stage,omitempty"` Epoch int `json:"epoch,omitempty"` Epochs int `json:"epochs,omitempty"` + + MAP50 float64 `json:"map50,omitempty"` + MAP5095 float64 `json:"map5095,omitempty"` } type TrainingConfidence struct { @@ -175,6 +178,18 @@ type TrainingStatsLabels struct { Clothing []TrainingLabelStat `json:"clothing"` } +type TrainingModelInfo struct { + TrainedAt string `json:"trainedAt,omitempty"` + TrainedAtMs int64 `json:"trainedAtMs,omitempty"` + Epochs int `json:"epochs,omitempty"` + TrainSamples int `json:"trainSamples,omitempty"` + ValSamples int `json:"valSamples,omitempty"` + Imgsz int `json:"imgsz,omitempty"` + Device string `json:"device,omitempty"` + MAP50 float64 `json:"map50,omitempty"` + MAP5095 float64 `json:"map5095,omitempty"` +} + type TrainingStatsResponse struct { OK bool `json:"ok"` FeedbackCount int `json:"feedbackCount"` @@ -184,18 +199,21 @@ type TrainingStatsResponse struct { SampleCount int `json:"sampleCount"` BoxCount int `json:"boxCount"` ModelAvailable bool `json:"modelAvailable"` + ModelInfo *TrainingModelInfo `json:"modelInfo,omitempty"` Confidence TrainingConfidence `json:"confidence"` Labels TrainingStatsLabels `json:"labels"` } type trainingProgressEvent struct { - Type string `json:"type"` - Stage string `json:"stage"` - Progress float64 `json:"progress"` // 0..1 - Message string `json:"message,omitempty"` - Epoch int `json:"epoch,omitempty"` - Epochs int `json:"epochs,omitempty"` - SampleID string `json:"sampleId,omitempty"` + Type string `json:"type"` + Stage string `json:"stage"` + Progress float64 `json:"progress"` // 0..1 + Message string `json:"message,omitempty"` + Epoch int `json:"epoch,omitempty"` + Epochs int `json:"epochs,omitempty"` + SampleID string `json:"sampleId,omitempty"` + MAP50 *float64 `json:"mAP50,omitempty"` + MAP5095 *float64 `json:"mAP5095,omitempty"` } type TrainingFeedbackListResponse struct { @@ -689,6 +707,13 @@ func trainingHandleProgressLine(line string, start int, end int, defaultStep str s.Epochs = ev.Epochs } + if ev.MAP50 != nil && *ev.MAP50 > 0 { + s.MAP50 = *ev.MAP50 + } + if ev.MAP5095 != nil && *ev.MAP5095 > 0 { + s.MAP5095 = *ev.MAP5095 + } + sampleID := strings.TrimSpace(ev.SampleID) if sampleID != "" && !strings.Contains(sampleID, "/") && @@ -2499,7 +2524,13 @@ func trainingRunJob(ctx context.Context, root string, count int) { } if detectorStatus == "trained" { - reloadAIServerModelAfterTraining() + // Verlaufseintrag schreiben (vor dem Neustart, solange die Job-Startzeit + // für die Dauer noch verfügbar ist). + trainingAppendRunHistory(root) + + // Neues Modell → AI-Server frisch neu starten (sauberer Zustand, + // statt nur das Modell im laufenden Prozess neu zu laden). + restartAIServer() } trainingSetJobStatus(func(s *TrainingJobStatus) { @@ -2591,6 +2622,7 @@ func trainingBuildStats(root string) (*TrainingStatsResponse, error) { if os.IsNotExist(err) { stats.SampleCount = trainingCountSampleFiles(filepath.Join(root, "samples")) stats.ModelAvailable = trainingStatsModelAvailable(root) + stats.ModelInfo = trainingReadModelInfo(root) return stats, nil } @@ -2684,6 +2716,7 @@ func trainingBuildStats(root string) (*TrainingStatsResponse, error) { stats.SampleCount = trainingCountSampleFiles(filepath.Join(root, "samples")) stats.ModelAvailable = trainingStatsModelAvailable(root) + stats.ModelInfo = trainingReadModelInfo(root) stats.Labels = TrainingStatsLabels{ // Personen/Box-Labels brauchen mehr Beispiele, weil der Detector Boxen lernen muss. @@ -2927,6 +2960,188 @@ func trainingStatsModelAvailable(root string) bool { return fileExistsNonEmpty(detectorModelPath) } +// trainingReadModelInfo liest Versions-/Datums-Infos zum aktuell trainierten +// Modell. Datum/Version stammen primär aus status.json (vom Trainingsskript), +// Fallback ist die Änderungszeit der best.pt-Datei. +func trainingReadModelInfo(root string) *TrainingModelInfo { + modelPath := filepath.Join(root, "detector", "model", "best.pt") + + fi, err := os.Stat(modelPath) + if err != nil || fi.IsDir() || fi.Size() <= 0 { + return nil + } + + info := &TrainingModelInfo{ + TrainedAt: fi.ModTime().UTC().Format(time.RFC3339), + TrainedAtMs: fi.ModTime().UnixMilli(), + } + + statusPath := filepath.Join(root, "detector", "model", "status.json") + if b, err := os.ReadFile(statusPath); err == nil { + var raw struct { + TrainedAt string `json:"trainedAt"` + Epochs int `json:"epochs"` + TrainSamples int `json:"trainSamples"` + ValSamples int `json:"valSamples"` + Imgsz int `json:"imgsz"` + Device string `json:"device"` + MAP50 float64 `json:"mAP50"` + MAP5095 float64 `json:"mAP5095"` + } + + if json.Unmarshal(b, &raw) == nil { + if trimmed := strings.TrimSpace(raw.TrainedAt); trimmed != "" { + if t, err := time.Parse(time.RFC3339, trimmed); err == nil { + info.TrainedAt = t.UTC().Format(time.RFC3339) + info.TrainedAtMs = t.UnixMilli() + } + } + + info.Epochs = raw.Epochs + info.TrainSamples = raw.TrainSamples + info.ValSamples = raw.ValSamples + info.Imgsz = raw.Imgsz + info.Device = strings.TrimSpace(raw.Device) + info.MAP50 = raw.MAP50 + info.MAP5095 = raw.MAP5095 + } + } + + return info +} + +type TrainingHistoryEntry struct { + TrainedAt string `json:"trainedAt,omitempty"` + TrainedAtMs int64 `json:"trainedAtMs,omitempty"` + DurationMs int64 `json:"durationMs,omitempty"` + Epochs int `json:"epochs,omitempty"` + TrainSamples int `json:"trainSamples,omitempty"` + ValSamples int `json:"valSamples,omitempty"` + Imgsz int `json:"imgsz,omitempty"` + Device string `json:"device,omitempty"` + MAP50 float64 `json:"map50,omitempty"` + MAP5095 float64 `json:"map5095,omitempty"` +} + +type TrainingHistoryResponse struct { + OK bool `json:"ok"` + Entries []TrainingHistoryEntry `json:"entries"` +} + +func trainingHistoryPath(root string) string { + return filepath.Join(root, "detector", "training_history.jsonl") +} + +// trainingAppendRunHistory hängt nach einem erfolgreichen Trainingslauf einen +// Verlaufseintrag an (Datum, mAP, Samples, Epochen, Dauer). +func trainingAppendRunHistory(root string) { + info := trainingReadModelInfo(root) + if info == nil { + return + } + + entry := TrainingHistoryEntry{ + TrainedAt: info.TrainedAt, + TrainedAtMs: info.TrainedAtMs, + Epochs: info.Epochs, + TrainSamples: info.TrainSamples, + ValSamples: info.ValSamples, + Imgsz: info.Imgsz, + Device: info.Device, + MAP50: info.MAP50, + MAP5095: info.MAP5095, + } + + // Dauer aus der Startzeit des aktuellen Jobs ableiten. + job := trainingGetJobStatus() + if startedAt, err := time.Parse(time.RFC3339, strings.TrimSpace(job.StartedAt)); err == nil { + if ms := time.Now().UTC().Sub(startedAt).Milliseconds(); ms > 0 { + entry.DurationMs = ms + } + } + + b, err := json.Marshal(entry) + if err != nil { + return + } + + path := trainingHistoryPath(root) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + appLogln("⚠️ training history dir konnte nicht erstellt werden:", err) + return + } + + f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) + if err != nil { + appLogln("⚠️ training history konnte nicht geöffnet werden:", err) + return + } + defer f.Close() + + if _, err := f.Write(append(b, '\n')); err != nil { + appLogln("⚠️ training history konnte nicht geschrieben werden:", err) + } +} + +// trainingReadHistory liest die Verlaufseinträge, neueste zuerst. +func trainingReadHistory(root string, limit int) []TrainingHistoryEntry { + b, err := os.ReadFile(trainingHistoryPath(root)) + if err != nil { + return nil + } + + lines := strings.Split(string(b), "\n") + entries := make([]TrainingHistoryEntry, 0, len(lines)) + + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + + var e TrainingHistoryEntry + if json.Unmarshal([]byte(line), &e) != nil { + continue + } + + entries = append(entries, e) + } + + // Datei ist chronologisch → umdrehen, damit der neueste Lauf oben steht. + for i, j := 0, len(entries)-1; i < j; i, j = i+1, j-1 { + entries[i], entries[j] = entries[j], entries[i] + } + + if limit > 0 && len(entries) > limit { + entries = entries[:limit] + } + + return entries +} + +func trainingHistoryHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + trainingWriteError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + + root, err := trainingRootDir() + if err != nil { + trainingWriteError(w, http.StatusInternalServerError, err.Error()) + return + } + + entries := trainingReadHistory(root, 50) + if entries == nil { + entries = []TrainingHistoryEntry{} + } + + trainingWriteJSON(w, http.StatusOK, TrainingHistoryResponse{ + OK: true, + Entries: entries, + }) +} + func trainingConfidenceFromScore(score float64) TrainingConfidence { if math.IsNaN(score) || math.IsInf(score, 0) { score = 0 diff --git a/frontend/src/components/ui/TrainingTab.tsx b/frontend/src/components/ui/TrainingTab.tsx index baa4b27..1e44d94 100644 --- a/frontend/src/components/ui/TrainingTab.tsx +++ b/frontend/src/components/ui/TrainingTab.tsx @@ -68,6 +68,8 @@ type TrainingJobStatus = { epoch?: number epochs?: number previewUrl?: string + map50?: number + map5095?: number } type TrainingPrediction = { @@ -165,6 +167,31 @@ type TrainingLabelStat = { confidence?: TrainingConfidence } +type TrainingModelInfo = { + trainedAt?: string + trainedAtMs?: number + epochs?: number + trainSamples?: number + valSamples?: number + imgsz?: number + device?: string + map50?: number + map5095?: number +} + +type TrainingHistoryEntry = { + trainedAt?: string + trainedAtMs?: number + durationMs?: number + epochs?: number + trainSamples?: number + valSamples?: number + imgsz?: number + device?: string + map50?: number + map5095?: number +} + type TrainingStats = { feedbackCount: number acceptedCount: number @@ -173,6 +200,7 @@ type TrainingStats = { sampleCount: number boxCount: number modelAvailable: boolean + modelInfo?: TrainingModelInfo confidence?: TrainingConfidence labels: { people: TrainingLabelStat[] @@ -252,6 +280,54 @@ function confidenceLabel(confidence?: TrainingConfidence | null) { return confidence?.label || 'Keine' } +function formatModelTrainedAt(info?: TrainingModelInfo): string { + if (!info) return '' + + const ms = Number(info.trainedAtMs) + const date = + Number.isFinite(ms) && ms > 0 + ? new Date(ms) + : info.trainedAt + ? new Date(info.trainedAt) + : null + + if (!date || Number.isNaN(date.getTime())) return '' + + return date.toLocaleString('de-DE', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }) +} + +function formatMapPercent(value?: number | null): string { + const n = Number(value) + if (!Number.isFinite(n) || n <= 0) return '' + return `${(n * 100).toFixed(1)}%` +} + +function formatHistoryDate(entry: TrainingHistoryEntry): string { + const ms = Number(entry.trainedAtMs) + const date = + Number.isFinite(ms) && ms > 0 + ? new Date(ms) + : entry.trainedAt + ? new Date(entry.trainedAt) + : null + + if (!date || Number.isNaN(date.getTime())) return '—' + + return date.toLocaleString('de-DE', { + day: '2-digit', + month: '2-digit', + year: '2-digit', + hour: '2-digit', + minute: '2-digit', + }) +} + function confidencePillClass(confidence?: TrainingConfidence | null) { switch (confidence?.level) { case 'high': @@ -780,6 +856,7 @@ function TrainingStageOverlay(props: { progress?: number backgroundUrl?: string visible?: boolean + instantBackground?: boolean }) { const progress = clampPercent(props.progress ?? 0) const isTraining = props.mode === 'training' @@ -819,15 +896,21 @@ function TrainingStageOverlay(props: {
{hasBackground ? ( ) : null} @@ -1591,6 +1674,7 @@ function TrainingStatsModal(props: { open: boolean onClose: () => void stats: TrainingStats | null + history?: TrainingHistoryEntry[] loading: boolean error: string | null feedbackCount: number @@ -1607,6 +1691,24 @@ function TrainingStatsModal(props: { const sampleCount = stats?.sampleCount ?? 0 const overallConfidence = stats?.confidence + const modelTrainedAtLabel = formatModelTrainedAt(stats?.modelInfo) + const modelMap50Label = formatMapPercent(stats?.modelInfo?.map50) + const modelMap5095Label = formatMapPercent(stats?.modelInfo?.map5095) + const modelInfoDetails = (() => { + const info = stats?.modelInfo + if (!info) return '' + + const parts: string[] = [] + if (Number(info.epochs) > 0) parts.push(`${info.epochs} Epochen`) + if (Number(info.trainSamples) > 0) parts.push(`${info.trainSamples} Train`) + if (Number(info.valSamples) > 0) parts.push(`${info.valSamples} Val`) + if (String(info.device || '').trim()) parts.push(String(info.device).trim()) + + return parts.join(' · ') + })() + + const history = props.history ?? [] + const tabItems: Array<{ key: TrainingStatsTabKey title: string @@ -1858,11 +1960,42 @@ function TrainingStatsModal(props: { : 'Noch kein trainiertes Modell verfügbar'}
-
- {stats?.modelAvailable - ? 'Die aktuellen Trainingsdaten können bereits von einem Modell genutzt werden.' - : 'Sammle weiter Feedback und starte anschließend das Training.'} -
+ {stats?.modelAvailable && modelTrainedAtLabel ? ( +
+
+ + Version vom + + + {modelTrainedAtLabel} + +
+ + {modelMap50Label ? ( +
+ + Qualität (mAP50{modelMap5095Label ? ' / 50-95' : ''}) + + + {modelMap50Label} + {modelMap5095Label ? ` / ${modelMap5095Label}` : ''} + +
+ ) : null} + + {modelInfoDetails ? ( +
+ {modelInfoDetails} +
+ ) : null} +
+ ) : ( +
+ {stats?.modelAvailable + ? 'Die aktuellen Trainingsdaten können bereits von einem Modell genutzt werden.' + : 'Sammle weiter Feedback und starte anschließend das Training.'} +
+ )}
@@ -1900,6 +2033,77 @@ function TrainingStatsModal(props: {
+ {history.length > 0 ? ( +
+
+
+ Trainings-Verlauf +
+
+ Modellqualität (mAP) über die letzten Trainingsläufe +
+
+ +
+ {history.map((entry, idx) => { + const map50 = formatMapPercent(entry.map50) + const map5095 = formatMapPercent(entry.map5095) + const duration = + Number(entry.durationMs) > 0 + ? formatDuration(Number(entry.durationMs)) + : '' + const barPct = Math.max( + 0, + Math.min(100, Math.round(Number(entry.map50 ?? 0) * 100)) + ) + + const meta: string[] = [] + if (Number(entry.epochs) > 0) meta.push(`${entry.epochs} Ep.`) + if (Number(entry.trainSamples) > 0) meta.push(`${entry.trainSamples} Train`) + if (duration) meta.push(duration) + + return ( +
+
+
+
+ {formatHistoryDate(entry)} + {idx === 0 ? ( + + aktuell + + ) : null} +
+ {meta.length > 0 ? ( +
+ {meta.join(' · ')} +
+ ) : null} +
+ +
+
+ {map50 || '—'} +
+
+ {map5095 ? `50-95: ${map5095}` : 'mAP50'} +
+
+
+ +
+
+
+
+ ) + })} +
+
+ ) : null} + {/* Mobile: Tabs kompakter, direkt nach Summary */}
@@ -2060,6 +2264,7 @@ export default function TrainingTab(props: { const [trainingStats, setTrainingStats] = useState(null) const [trainingStatsLoading, setTrainingStatsLoading] = useState(false) const [trainingStatsError, setTrainingStatsError] = useState(null) + const [trainingHistory, setTrainingHistory] = useState([]) const wasTrainingRunningRef = useRef(false) const shownTrainingCompletionRef = useRef(null) const [dismissedTrainingInfoKey, setDismissedTrainingInfoKey] = useState(() => { @@ -2116,6 +2321,14 @@ export default function TrainingTab(props: { const [loadingPreviewLoaded, setLoadingPreviewLoaded] = useState(false) const [loadingPreviewFailed, setLoadingPreviewFailed] = useState(false) + // Während des Trainings sendet das Backend zum gerade trainierten Batch eine + // Vorschau. Wir zeigen immer das zuletzt eingegangene Bild (live) und merken es + // in einem Ref, damit die Anzeige unabhängig von den (gedrosselten) Status- + // Updates bleibt und die Render-Rate begrenzt ist. + const [trainingPreviewUrl, setTrainingPreviewUrl] = useState('') + const latestTrainingPreviewRef = useRef('') + const lastTrainingStatusApplyRef = useRef(0) + const [stageOverlayMounted, setStageOverlayMounted] = useState(false) const [stageOverlayVisible, setStageOverlayVisible] = useState(false) @@ -2717,6 +2930,8 @@ export default function TrainingTab(props: { epoch: Number(job.epoch ?? 0), epochs: Number(job.epochs ?? 0), previewUrl: String(job.previewUrl ?? ''), + map50: Number(job.map50 ?? 0), + map5095: Number(job.map5095 ?? 0), } : prev?.training, })) @@ -3068,6 +3283,19 @@ export default function TrainingTab(props: { sampleCount: Number(data?.sampleCount ?? 0), boxCount: Number(data?.boxCount ?? 0), modelAvailable: Boolean(data?.modelAvailable), + modelInfo: data?.modelInfo + ? { + trainedAt: data.modelInfo.trainedAt, + trainedAtMs: Number(data.modelInfo.trainedAtMs ?? 0), + epochs: Number(data.modelInfo.epochs ?? 0), + trainSamples: Number(data.modelInfo.trainSamples ?? 0), + valSamples: Number(data.modelInfo.valSamples ?? 0), + imgsz: Number(data.modelInfo.imgsz ?? 0), + device: data.modelInfo.device, + map50: Number(data.modelInfo.map50 ?? 0), + map5095: Number(data.modelInfo.map5095 ?? 0), + } + : undefined, confidence: data?.confidence, labels: { people: Array.isArray(data?.labels?.people) ? data.labels.people : [], @@ -3084,6 +3312,34 @@ export default function TrainingTab(props: { } }, []) + const loadTrainingHistory = useCallback(async () => { + try { + const res = await fetch('/api/training/history', { cache: 'no-store' }) + const data = await res.json().catch(() => null) + + if (!res.ok || !data) return + + setTrainingHistory( + Array.isArray(data?.entries) + ? data.entries.map((e: any) => ({ + trainedAt: e?.trainedAt, + trainedAtMs: Number(e?.trainedAtMs ?? 0), + durationMs: Number(e?.durationMs ?? 0), + epochs: Number(e?.epochs ?? 0), + trainSamples: Number(e?.trainSamples ?? 0), + valSamples: Number(e?.valSamples ?? 0), + imgsz: Number(e?.imgsz ?? 0), + device: e?.device, + map50: Number(e?.map50 ?? 0), + map5095: Number(e?.map5095 ?? 0), + })) + : [] + ) + } catch { + // ignore + } + }, []) + useEffect(() => { updateImageLayerStyle() @@ -3125,9 +3381,23 @@ export default function TrainingTab(props: { if (data?.type !== 'training_status') return - applyTrainingStatus({ - training: data.training, - }) + const job = data.training || null + const running = Boolean(job?.running) + + // Immer das zuletzt gesendete Bild merken (live, ältere werden übersprungen). + const previewUrl = String(job?.previewUrl ?? '').trim() + if (running && previewUrl) { + latestTrainingPreviewRef.current = previewUrl + } + + // Status (Progress/Epoche) nur gedrosselt anwenden, damit pro Bild + // kein Re-Render des gesamten Tabs ausgelöst wird. Das Abschluss-Event + // (running=false) wird immer angewendet. + const now = Date.now() + if (!running || now - lastTrainingStatusApplyRef.current >= 200) { + lastTrainingStatusApplyRef.current = now + applyTrainingStatus({ training: job }) + } } catch { // ignore } @@ -3140,6 +3410,25 @@ export default function TrainingTab(props: { } }, [applyTrainingStatus]) + // Im festen Takt immer das zuletzt eingegangene Bild anzeigen. + // So bleibt die Anzeige live (max. ~110 ms Versatz) und die Render-Rate begrenzt. + useEffect(() => { + if (!trainingRunning) { + latestTrainingPreviewRef.current = '' + setTrainingPreviewUrl('') + return + } + + const timer = window.setInterval(() => { + const latest = latestTrainingPreviewRef.current + if (!latest) return + + setTrainingPreviewUrl((cur) => (cur === latest ? cur : latest)) + }, 110) + + return () => window.clearInterval(timer) + }, [trainingRunning]) + useEffect(() => { const onAnalysis = (event: Event) => { try { @@ -3266,7 +3555,8 @@ export default function TrainingTab(props: { if (!statsModalOpen) return void loadTrainingStats() - }, [statsModalOpen, loadTrainingStats]) + void loadTrainingHistory() + }, [statsModalOpen, loadTrainingStats, loadTrainingHistory]) const onTrainingRunningChange = props.onTrainingRunningChange @@ -5800,9 +6090,10 @@ export default function TrainingTab(props: { text={stageOverlayText} progress={stageOverlayProgress} visible={stageOverlayIsVisible} + instantBackground={stageOverlayMode === 'training'} backgroundUrl={ stageOverlayMode === 'training' - ? trainingStatus?.training?.previewUrl || imageSrc + ? trainingPreviewUrl || trainingStatus?.training?.previewUrl || imageSrc : loadingPreviewBackgroundUrl } /> @@ -5933,7 +6224,7 @@ export default function TrainingTab(props: {
-
+
Laufzeit @@ -5971,6 +6262,15 @@ export default function TrainingTab(props: { {estimatedEpochMs > 0 ? formatDuration(estimatedEpochMs) : '—'}
+ +
+
+ mAP50 +
+
+ {formatMapPercent(trainingStatus?.training?.map50) || '—'} +
+
) : null} @@ -6378,6 +6678,7 @@ export default function TrainingTab(props: { open={statsModalOpen} onClose={() => setStatsModalOpen(false)} stats={trainingStats} + history={trainingHistory} loading={trainingStatsLoading} error={trainingStatsError} feedbackCount={feedbackCount}