This commit is contained in:
Linrador 2026-06-17 09:24:18 +02:00
parent f0eef673d9
commit 4320f040ff
10 changed files with 712 additions and 74 deletions

View File

@ -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 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

View File

@ -1,3 +0,0 @@
{
"items": []
}

View File

@ -126,9 +126,22 @@ func appLogf(format string, args ...any) {
appLogWriteLine(fmt.Sprintf(format, args...)) 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 { func appErrorf(format string, args ...any) error {
err := fmt.Errorf(format, args...) err := fmt.Errorf(format, args...)
appLogln("❌", err) if !appErrorLogSuppressed(err.Error()) {
appLogln("❌", err)
}
return err return err
} }

View File

@ -3,7 +3,7 @@
import argparse import argparse
import json import json
import shutil import shutil
import time from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from ultralytics import YOLO from ultralytics import YOLO
@ -53,23 +53,27 @@ def safe_int(value, fallback):
return fallback return fallback
def batch_sample_id(batch): def batch_sample_ids(batch):
if not isinstance(batch, dict): if not isinstance(batch, dict):
return "" return []
image_paths = batch.get("im_file") image_paths = batch.get("im_file")
if not image_paths: if not image_paths:
return "" return []
if isinstance(image_paths, (str, Path)): if isinstance(image_paths, (str, Path)):
image_path = image_paths image_paths = [image_paths]
else:
try:
image_path = image_paths[0]
except (IndexError, KeyError, TypeError):
return ""
return Path(str(image_path)).stem.strip() out = []
seen = set()
for image_path in image_paths:
stem = Path(str(image_path)).stem.strip()
if stem and stem not in seen:
seen.add(stem)
out.append(stem)
return out
def progress_detection_trainer(train_count, val_count, train_device, fallback_epochs): def progress_detection_trainer(train_count, val_count, train_device, fallback_epochs):
@ -78,7 +82,6 @@ def progress_detection_trainer(train_count, val_count, train_device, fallback_ep
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self._preview_epoch = 0 self._preview_epoch = 0
self._preview_batch = 0 self._preview_batch = 0
self._preview_emitted_at = 0.0
def preprocess_batch(self, batch): def preprocess_batch(self, batch):
epoch = int(getattr(self, "epoch", 0)) + 1 epoch = int(getattr(self, "epoch", 0)) + 1
@ -90,27 +93,25 @@ def progress_detection_trainer(train_count, val_count, train_device, fallback_ep
self._preview_batch += 1 self._preview_batch += 1
now = time.monotonic() # Pro Batch das gerade trainierte Bild melden ohne Drosselung, damit
if self._preview_batch == 1 or now - self._preview_emitted_at >= 0.5: # das Frontend live immer das aktuell trainierte Bild anzeigen kann.
sample_id = batch_sample_id(batch) 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: emit_progress(
total_batches = max(1, len(self.train_loader)) "detector",
completed = (epoch - 1) + min(1.0, self._preview_batch / total_batches) progress,
"Object Detector trainiert…",
emit_progress( epoch=epoch,
"detector", epochs=total_epochs,
0.04 + 0.90 * (completed / max(1, total_epochs)), sampleId=sample_ids[0],
"Object Detector trainiert…", trainSamples=train_count,
epoch=epoch, valSamples=val_count,
epochs=total_epochs, device=str(train_device),
sampleId=sample_id, )
trainSamples=train_count,
valSamples=val_count,
device=str(train_device),
)
self._preview_emitted_at = now
return super().preprocess_batch(batch) return super().preprocess_batch(batch)
@ -181,6 +182,8 @@ def main():
best_epoch = 0 best_epoch = 0
last_epoch = 0 last_epoch = 0
best_map50 = 0.0
best_map5095 = 0.0
def on_train_epoch_start(trainer): def on_train_epoch_start(trainer):
epoch = int(getattr(trainer, "epoch", 0)) + 1 epoch = int(getattr(trainer, "epoch", 0)) + 1
@ -216,7 +219,7 @@ def main():
) )
def on_fit_epoch_end(trainer): def on_fit_epoch_end(trainer):
nonlocal best_epoch nonlocal best_epoch, best_map50, best_map5095
epoch = int(getattr(trainer, "epoch", 0)) + 1 epoch = int(getattr(trainer, "epoch", 0)) + 1
total = int(getattr(trainer, "epochs", epochs) or epochs) total = int(getattr(trainer, "epochs", epochs) or epochs)
@ -237,6 +240,13 @@ def main():
if map50 is not None or map5095 is not None: if map50 is not None or map5095 is not None:
best_epoch = epoch 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( emit_progress(
"detector", "detector",
0.04 + 0.90 * (epoch / max(1, total)), 0.04 + 0.90 * (epoch / max(1, total)),
@ -310,11 +320,15 @@ def main():
"model": str(final_model), "model": str(final_model),
"sourceModel": str(best), "sourceModel": str(best),
"runs": str(result_path), "runs": str(result_path),
"trainedAt": datetime.now(timezone.utc).isoformat(),
"trainSamples": train_count, "trainSamples": train_count,
"valSamples": val_count, "valSamples": val_count,
"epochs": epochs, "epochs": epochs,
"imgsz": imgsz, "imgsz": imgsz,
"device": str(train_device), "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: with (out_dir / "status.json").open("w", encoding="utf-8") as f:

View File

@ -96,6 +96,7 @@ func registerRoutes(mux *http.ServeMux, auth *AuthManager) *ModelStore {
api.HandleFunc("/api/training/cancel", trainingCancelHandler) api.HandleFunc("/api/training/cancel", trainingCancelHandler)
api.HandleFunc("/api/training/status", trainingStatusHandler) api.HandleFunc("/api/training/status", trainingStatusHandler)
api.HandleFunc("/api/training/stats", trainingStatsHandler) api.HandleFunc("/api/training/stats", trainingStatsHandler)
api.HandleFunc("/api/training/history", trainingHistoryHandler)
api.HandleFunc("/api/training/delete-all", trainingDeleteAllHandler) api.HandleFunc("/api/training/delete-all", trainingDeleteAllHandler)
api.HandleFunc("/api/training/skip", trainingSkipHandler) api.HandleFunc("/api/training/skip", trainingSkipHandler)
api.HandleFunc("/api/training/import-video", trainingImportVideoHandler) api.HandleFunc("/api/training/import-video", trainingImportVideoHandler)

View File

@ -102,9 +102,18 @@ func buildStartupNotificationMessage() string {
} }
type aiServerProcess struct { 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 { func aiServerAutostartEnabled() bool {
raw := strings.ToLower(strings.TrimSpace(os.Getenv("AI_SERVER_AUTOSTART"))) raw := strings.ToLower(strings.TrimSpace(os.Getenv("AI_SERVER_AUTOSTART")))
@ -572,7 +581,8 @@ func startAIServer(ctx context.Context) (*aiServerProcess, error) {
} }
proc := &aiServerProcess{ proc := &aiServerProcess{
cmd: cmd, cmd: cmd,
done: make(chan struct{}),
} }
go func() { go func() {
@ -583,6 +593,8 @@ func startAIServer(ctx context.Context) (*aiServerProcess, error) {
} else { } else {
appLogln("🛑 AI Server beendet.") appLogln("🛑 AI Server beendet.")
} }
close(proc.done)
}() }()
waitCtx, cancel := context.WithTimeout(ctx, 30*time.Second) waitCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
@ -606,6 +618,52 @@ func (p *aiServerProcess) Stop() {
if p.cmd != nil && p.cmd.Process != nil { if p.cmd != nil && p.cmd.Process != nil {
_ = p.cmd.Process.Kill() _ = 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) { func setEnvIfMissing(key, value string) {
@ -801,6 +859,13 @@ func main() {
appLogln("⚠️ AI Server konnte nicht gestartet werden:", err) 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. // ✅ Hier: alte manuelle Autostart-Pause beim echten App-Start zurücksetzen.
resetAutostartPauseOnStartup() resetAutostartPauseOnStartup()
@ -882,8 +947,13 @@ func main() {
var shutdownOnce sync.Once var shutdownOnce sync.Once
shutdown := func() { shutdown := func() {
shutdownOnce.Do(func() { shutdownOnce.Do(func() {
if aiProc != nil { aiServerMu.Lock()
aiProc.Stop() current := aiServerCurrent
aiServerCurrent = nil
aiServerMu.Unlock()
if current != nil {
current.Stop()
} }
appCancel() appCancel()

View File

@ -527,16 +527,8 @@ func cleanupDeleteRecordOrphanFile(ctx context.Context, jobID string, path strin
return return
} }
updateCleanupJobState(jobID, func(st *CleanupTaskState) { // Fortschritt (Done/Total/CurrentFile) steuert der Aufrufer
st.Queued = false // cleanupRecordDirOrphanAVFiles, damit die Progress-Bar sichtbar bleibt.
st.Running = true
st.Done = 0
st.Total = 0
st.CurrentFile = name
st.Text = "Räume Record-Reste auf…"
st.Error = ""
st.FinishedAt = nil
})
if err := removeWithRetry(path); err != nil && !os.IsNotExist(err) { if err := removeWithRetry(path); err != nil && !os.IsNotExist(err) {
resp.ErrorCount++ resp.ErrorCount++
@ -582,6 +574,34 @@ func cleanupRecordDirOrphanAVFiles(ctx context.Context, jobID string, recordAbs
return nil 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 { for _, e := range entries {
select { select {
case <-ctx.Done(): case <-ctx.Done():
@ -598,6 +618,13 @@ func cleanupRecordDirOrphanAVFiles(ctx context.Context, jobID string, recordAbs
continue continue
} }
processed++
updateCleanupJobState(jobID, func(st *CleanupTaskState) {
st.Done = processed
st.Total = total
st.CurrentFile = name
})
full := filepath.Join(recordAbs, name) full := filepath.Join(recordAbs, name)
low := strings.ToLower(name) low := strings.ToLower(name)

View File

@ -153,6 +153,9 @@ type TrainingJobStatus struct {
Stage string `json:"stage,omitempty"` Stage string `json:"stage,omitempty"`
Epoch int `json:"epoch,omitempty"` Epoch int `json:"epoch,omitempty"`
Epochs int `json:"epochs,omitempty"` Epochs int `json:"epochs,omitempty"`
MAP50 float64 `json:"map50,omitempty"`
MAP5095 float64 `json:"map5095,omitempty"`
} }
type TrainingConfidence struct { type TrainingConfidence struct {
@ -175,6 +178,18 @@ type TrainingStatsLabels struct {
Clothing []TrainingLabelStat `json:"clothing"` 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 { type TrainingStatsResponse struct {
OK bool `json:"ok"` OK bool `json:"ok"`
FeedbackCount int `json:"feedbackCount"` FeedbackCount int `json:"feedbackCount"`
@ -184,18 +199,21 @@ type TrainingStatsResponse struct {
SampleCount int `json:"sampleCount"` SampleCount int `json:"sampleCount"`
BoxCount int `json:"boxCount"` BoxCount int `json:"boxCount"`
ModelAvailable bool `json:"modelAvailable"` ModelAvailable bool `json:"modelAvailable"`
ModelInfo *TrainingModelInfo `json:"modelInfo,omitempty"`
Confidence TrainingConfidence `json:"confidence"` Confidence TrainingConfidence `json:"confidence"`
Labels TrainingStatsLabels `json:"labels"` Labels TrainingStatsLabels `json:"labels"`
} }
type trainingProgressEvent struct { type trainingProgressEvent struct {
Type string `json:"type"` Type string `json:"type"`
Stage string `json:"stage"` Stage string `json:"stage"`
Progress float64 `json:"progress"` // 0..1 Progress float64 `json:"progress"` // 0..1
Message string `json:"message,omitempty"` Message string `json:"message,omitempty"`
Epoch int `json:"epoch,omitempty"` Epoch int `json:"epoch,omitempty"`
Epochs int `json:"epochs,omitempty"` Epochs int `json:"epochs,omitempty"`
SampleID string `json:"sampleId,omitempty"` SampleID string `json:"sampleId,omitempty"`
MAP50 *float64 `json:"mAP50,omitempty"`
MAP5095 *float64 `json:"mAP5095,omitempty"`
} }
type TrainingFeedbackListResponse struct { type TrainingFeedbackListResponse struct {
@ -689,6 +707,13 @@ func trainingHandleProgressLine(line string, start int, end int, defaultStep str
s.Epochs = ev.Epochs 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) sampleID := strings.TrimSpace(ev.SampleID)
if sampleID != "" && if sampleID != "" &&
!strings.Contains(sampleID, "/") && !strings.Contains(sampleID, "/") &&
@ -2499,7 +2524,13 @@ func trainingRunJob(ctx context.Context, root string, count int) {
} }
if detectorStatus == "trained" { 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) { trainingSetJobStatus(func(s *TrainingJobStatus) {
@ -2591,6 +2622,7 @@ func trainingBuildStats(root string) (*TrainingStatsResponse, error) {
if os.IsNotExist(err) { if os.IsNotExist(err) {
stats.SampleCount = trainingCountSampleFiles(filepath.Join(root, "samples")) stats.SampleCount = trainingCountSampleFiles(filepath.Join(root, "samples"))
stats.ModelAvailable = trainingStatsModelAvailable(root) stats.ModelAvailable = trainingStatsModelAvailable(root)
stats.ModelInfo = trainingReadModelInfo(root)
return stats, nil return stats, nil
} }
@ -2684,6 +2716,7 @@ func trainingBuildStats(root string) (*TrainingStatsResponse, error) {
stats.SampleCount = trainingCountSampleFiles(filepath.Join(root, "samples")) stats.SampleCount = trainingCountSampleFiles(filepath.Join(root, "samples"))
stats.ModelAvailable = trainingStatsModelAvailable(root) stats.ModelAvailable = trainingStatsModelAvailable(root)
stats.ModelInfo = trainingReadModelInfo(root)
stats.Labels = TrainingStatsLabels{ stats.Labels = TrainingStatsLabels{
// Personen/Box-Labels brauchen mehr Beispiele, weil der Detector Boxen lernen muss. // Personen/Box-Labels brauchen mehr Beispiele, weil der Detector Boxen lernen muss.
@ -2927,6 +2960,188 @@ func trainingStatsModelAvailable(root string) bool {
return fileExistsNonEmpty(detectorModelPath) 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 { func trainingConfidenceFromScore(score float64) TrainingConfidence {
if math.IsNaN(score) || math.IsInf(score, 0) { if math.IsNaN(score) || math.IsInf(score, 0) {
score = 0 score = 0

View File

@ -68,6 +68,8 @@ type TrainingJobStatus = {
epoch?: number epoch?: number
epochs?: number epochs?: number
previewUrl?: string previewUrl?: string
map50?: number
map5095?: number
} }
type TrainingPrediction = { type TrainingPrediction = {
@ -165,6 +167,31 @@ type TrainingLabelStat = {
confidence?: TrainingConfidence 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 = { type TrainingStats = {
feedbackCount: number feedbackCount: number
acceptedCount: number acceptedCount: number
@ -173,6 +200,7 @@ type TrainingStats = {
sampleCount: number sampleCount: number
boxCount: number boxCount: number
modelAvailable: boolean modelAvailable: boolean
modelInfo?: TrainingModelInfo
confidence?: TrainingConfidence confidence?: TrainingConfidence
labels: { labels: {
people: TrainingLabelStat[] people: TrainingLabelStat[]
@ -252,6 +280,54 @@ function confidenceLabel(confidence?: TrainingConfidence | null) {
return confidence?.label || 'Keine' 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) { function confidencePillClass(confidence?: TrainingConfidence | null) {
switch (confidence?.level) { switch (confidence?.level) {
case 'high': case 'high':
@ -780,6 +856,7 @@ function TrainingStageOverlay(props: {
progress?: number progress?: number
backgroundUrl?: string backgroundUrl?: string
visible?: boolean visible?: boolean
instantBackground?: boolean
}) { }) {
const progress = clampPercent(props.progress ?? 0) const progress = clampPercent(props.progress ?? 0)
const isTraining = props.mode === 'training' const isTraining = props.mode === 'training'
@ -819,15 +896,21 @@ function TrainingStageOverlay(props: {
<div className="absolute inset-1 overflow-hidden rounded-md sm:inset-2"> <div className="absolute inset-1 overflow-hidden rounded-md sm:inset-2">
{hasBackground ? ( {hasBackground ? (
<img <img
key={props.backgroundUrl} // Beim schnellen Durchlaufen (Training) dasselbe Element wiederverwenden,
// damit nur die Bildquelle wechselt und kein Ein-/Ausblenden je Bild läuft.
key={props.instantBackground ? undefined : props.backgroundUrl}
src={props.backgroundUrl} src={props.backgroundUrl}
alt="" alt=""
aria-hidden="true" aria-hidden="true"
draggable={false} draggable={false}
className={[ className={[
'absolute inset-0 z-0 h-full w-full object-contain blur-[1px]', 'absolute inset-0 z-0 h-full w-full object-contain blur-[1px]',
'transition-opacity duration-500 ease-out will-change-opacity motion-reduce:transition-none', props.instantBackground
backgroundVisible ? 'opacity-80' : 'opacity-0', ? 'opacity-80'
: [
'transition-opacity duration-500 ease-out will-change-opacity motion-reduce:transition-none',
backgroundVisible ? 'opacity-80' : 'opacity-0',
].join(' '),
].join(' ')} ].join(' ')}
/> />
) : null} ) : null}
@ -1591,6 +1674,7 @@ function TrainingStatsModal(props: {
open: boolean open: boolean
onClose: () => void onClose: () => void
stats: TrainingStats | null stats: TrainingStats | null
history?: TrainingHistoryEntry[]
loading: boolean loading: boolean
error: string | null error: string | null
feedbackCount: number feedbackCount: number
@ -1607,6 +1691,24 @@ function TrainingStatsModal(props: {
const sampleCount = stats?.sampleCount ?? 0 const sampleCount = stats?.sampleCount ?? 0
const overallConfidence = stats?.confidence 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<{ const tabItems: Array<{
key: TrainingStatsTabKey key: TrainingStatsTabKey
title: string title: string
@ -1858,11 +1960,42 @@ function TrainingStatsModal(props: {
: 'Noch kein trainiertes Modell verfügbar'} : 'Noch kein trainiertes Modell verfügbar'}
</div> </div>
<div className="mt-1 text-xs leading-relaxed text-indigo-800/80 dark:text-indigo-100/70"> {stats?.modelAvailable && modelTrainedAtLabel ? (
{stats?.modelAvailable <div className="mt-2 space-y-1">
? 'Die aktuellen Trainingsdaten können bereits von einem Modell genutzt werden.' <div className="flex items-center justify-between gap-2 text-xs">
: 'Sammle weiter Feedback und starte anschließend das Training.'} <span className="font-medium text-indigo-800/80 dark:text-indigo-100/70">
</div> Version vom
</span>
<span className="font-semibold tabular-nums text-indigo-950 dark:text-indigo-50">
{modelTrainedAtLabel}
</span>
</div>
{modelMap50Label ? (
<div className="flex items-center justify-between gap-2 text-xs">
<span className="font-medium text-indigo-800/80 dark:text-indigo-100/70">
Qualität (mAP50{modelMap5095Label ? ' / 50-95' : ''})
</span>
<span className="font-semibold tabular-nums text-indigo-950 dark:text-indigo-50">
{modelMap50Label}
{modelMap5095Label ? ` / ${modelMap5095Label}` : ''}
</span>
</div>
) : null}
{modelInfoDetails ? (
<div className="text-[11px] text-indigo-800/70 dark:text-indigo-100/60">
{modelInfoDetails}
</div>
) : null}
</div>
) : (
<div className="mt-1 text-xs leading-relaxed text-indigo-800/80 dark:text-indigo-100/70">
{stats?.modelAvailable
? 'Die aktuellen Trainingsdaten können bereits von einem Modell genutzt werden.'
: 'Sammle weiter Feedback und starte anschließend das Training.'}
</div>
)}
</div> </div>
<div className="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-gray-900/70"> <div className="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-white/10 dark:bg-gray-900/70">
@ -1900,6 +2033,77 @@ function TrainingStatsModal(props: {
</div> </div>
</div> </div>
{history.length > 0 ? (
<div className="overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm dark:border-white/10 dark:bg-gray-900/70">
<div className="border-b border-gray-200 bg-gray-50/80 px-4 py-3 dark:border-white/10 dark:bg-white/[0.03]">
<div className="text-sm font-semibold text-gray-900 dark:text-white">
Trainings-Verlauf
</div>
<div className="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
Modellqualität (mAP) über die letzten Trainingsläufe
</div>
</div>
<div className="max-h-56 divide-y divide-gray-100 overflow-y-auto dark:divide-white/10">
{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 (
<div key={`${entry.trainedAtMs}-${idx}`} className="px-4 py-2.5">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-gray-900 dark:text-white">
{formatHistoryDate(entry)}
{idx === 0 ? (
<span className="ml-2 align-middle rounded-full bg-indigo-100 px-1.5 py-0.5 text-[9px] font-bold text-indigo-700 ring-1 ring-indigo-200 dark:bg-indigo-500/20 dark:text-indigo-100 dark:ring-indigo-300/30">
aktuell
</span>
) : null}
</div>
{meta.length > 0 ? (
<div className="mt-0.5 truncate text-[11px] text-gray-500 dark:text-gray-400">
{meta.join(' · ')}
</div>
) : null}
</div>
<div className="shrink-0 text-right">
<div className="text-sm font-bold tabular-nums text-gray-900 dark:text-white">
{map50 || '—'}
</div>
<div className="text-[10px] tabular-nums text-gray-500 dark:text-gray-400">
{map5095 ? `50-95: ${map5095}` : 'mAP50'}
</div>
</div>
</div>
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-gray-200 dark:bg-white/10">
<div
className="h-full rounded-full bg-indigo-500 transition-all"
style={{ width: `${barPct}%` }}
/>
</div>
</div>
)
})}
</div>
</div>
) : null}
{/* Mobile: Tabs kompakter, direkt nach Summary */} {/* Mobile: Tabs kompakter, direkt nach Summary */}
<div className="overflow-hidden rounded-xl border border-gray-200 bg-gray-50/70 p-1.5 dark:border-white/10 dark:bg-white/[0.03] sm:p-2"> <div className="overflow-hidden rounded-xl border border-gray-200 bg-gray-50/70 p-1.5 dark:border-white/10 dark:bg-white/[0.03] sm:p-2">
<div className="grid grid-cols-2 gap-1 sm:grid-cols-5"> <div className="grid grid-cols-2 gap-1 sm:grid-cols-5">
@ -2060,6 +2264,7 @@ export default function TrainingTab(props: {
const [trainingStats, setTrainingStats] = useState<TrainingStats | null>(null) const [trainingStats, setTrainingStats] = useState<TrainingStats | null>(null)
const [trainingStatsLoading, setTrainingStatsLoading] = useState(false) const [trainingStatsLoading, setTrainingStatsLoading] = useState(false)
const [trainingStatsError, setTrainingStatsError] = useState<string | null>(null) const [trainingStatsError, setTrainingStatsError] = useState<string | null>(null)
const [trainingHistory, setTrainingHistory] = useState<TrainingHistoryEntry[]>([])
const wasTrainingRunningRef = useRef(false) const wasTrainingRunningRef = useRef(false)
const shownTrainingCompletionRef = useRef<string | null>(null) const shownTrainingCompletionRef = useRef<string | null>(null)
const [dismissedTrainingInfoKey, setDismissedTrainingInfoKey] = useState(() => { const [dismissedTrainingInfoKey, setDismissedTrainingInfoKey] = useState(() => {
@ -2116,6 +2321,14 @@ export default function TrainingTab(props: {
const [loadingPreviewLoaded, setLoadingPreviewLoaded] = useState(false) const [loadingPreviewLoaded, setLoadingPreviewLoaded] = useState(false)
const [loadingPreviewFailed, setLoadingPreviewFailed] = 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 [stageOverlayMounted, setStageOverlayMounted] = useState(false)
const [stageOverlayVisible, setStageOverlayVisible] = useState(false) const [stageOverlayVisible, setStageOverlayVisible] = useState(false)
@ -2717,6 +2930,8 @@ export default function TrainingTab(props: {
epoch: Number(job.epoch ?? 0), epoch: Number(job.epoch ?? 0),
epochs: Number(job.epochs ?? 0), epochs: Number(job.epochs ?? 0),
previewUrl: String(job.previewUrl ?? ''), previewUrl: String(job.previewUrl ?? ''),
map50: Number(job.map50 ?? 0),
map5095: Number(job.map5095 ?? 0),
} }
: prev?.training, : prev?.training,
})) }))
@ -3068,6 +3283,19 @@ export default function TrainingTab(props: {
sampleCount: Number(data?.sampleCount ?? 0), sampleCount: Number(data?.sampleCount ?? 0),
boxCount: Number(data?.boxCount ?? 0), boxCount: Number(data?.boxCount ?? 0),
modelAvailable: Boolean(data?.modelAvailable), 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, confidence: data?.confidence,
labels: { labels: {
people: Array.isArray(data?.labels?.people) ? data.labels.people : [], 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(() => { useEffect(() => {
updateImageLayerStyle() updateImageLayerStyle()
@ -3125,9 +3381,23 @@ export default function TrainingTab(props: {
if (data?.type !== 'training_status') return if (data?.type !== 'training_status') return
applyTrainingStatus({ const job = data.training || null
training: data.training, 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 { } catch {
// ignore // ignore
} }
@ -3140,6 +3410,25 @@ export default function TrainingTab(props: {
} }
}, [applyTrainingStatus]) }, [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(() => { useEffect(() => {
const onAnalysis = (event: Event) => { const onAnalysis = (event: Event) => {
try { try {
@ -3266,7 +3555,8 @@ export default function TrainingTab(props: {
if (!statsModalOpen) return if (!statsModalOpen) return
void loadTrainingStats() void loadTrainingStats()
}, [statsModalOpen, loadTrainingStats]) void loadTrainingHistory()
}, [statsModalOpen, loadTrainingStats, loadTrainingHistory])
const onTrainingRunningChange = props.onTrainingRunningChange const onTrainingRunningChange = props.onTrainingRunningChange
@ -5800,9 +6090,10 @@ export default function TrainingTab(props: {
text={stageOverlayText} text={stageOverlayText}
progress={stageOverlayProgress} progress={stageOverlayProgress}
visible={stageOverlayIsVisible} visible={stageOverlayIsVisible}
instantBackground={stageOverlayMode === 'training'}
backgroundUrl={ backgroundUrl={
stageOverlayMode === 'training' stageOverlayMode === 'training'
? trainingStatus?.training?.previewUrl || imageSrc ? trainingPreviewUrl || trainingStatus?.training?.previewUrl || imageSrc
: loadingPreviewBackgroundUrl : loadingPreviewBackgroundUrl
} }
/> />
@ -5933,7 +6224,7 @@ export default function TrainingTab(props: {
</span> </span>
</div> </div>
<div className="grid grid-cols-4 gap-2 text-[11px]"> <div className="grid grid-cols-5 gap-2 text-[11px]">
<div className="rounded-lg bg-indigo-50 px-2 py-1.5 text-center text-indigo-900 ring-1 ring-indigo-100 dark:bg-indigo-500/10 dark:text-indigo-100 dark:ring-indigo-400/20"> <div className="rounded-lg bg-indigo-50 px-2 py-1.5 text-center text-indigo-900 ring-1 ring-indigo-100 dark:bg-indigo-500/10 dark:text-indigo-100 dark:ring-indigo-400/20">
<div className="text-[9px] font-semibold uppercase tracking-wide opacity-70"> <div className="text-[9px] font-semibold uppercase tracking-wide opacity-70">
Laufzeit Laufzeit
@ -5971,6 +6262,15 @@ export default function TrainingTab(props: {
{estimatedEpochMs > 0 ? formatDuration(estimatedEpochMs) : '—'} {estimatedEpochMs > 0 ? formatDuration(estimatedEpochMs) : '—'}
</div> </div>
</div> </div>
<div className="rounded-lg bg-emerald-50 px-2 py-1.5 text-center text-emerald-900 ring-1 ring-emerald-100 dark:bg-emerald-500/10 dark:text-emerald-100 dark:ring-emerald-400/20">
<div className="text-[9px] font-semibold uppercase tracking-wide opacity-70">
mAP50
</div>
<div className="mt-0.5 font-bold tabular-nums">
{formatMapPercent(trainingStatus?.training?.map50) || '—'}
</div>
</div>
</div> </div>
</div> </div>
) : null} ) : null}
@ -6378,6 +6678,7 @@ export default function TrainingTab(props: {
open={statsModalOpen} open={statsModalOpen}
onClose={() => setStatsModalOpen(false)} onClose={() => setStatsModalOpen(false)}
stats={trainingStats} stats={trainingStats}
history={trainingHistory}
loading={trainingStatsLoading} loading={trainingStatsLoading}
error={trainingStatsError} error={trainingStatsError}
feedbackCount={feedbackCount} feedbackCount={feedbackCount}