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

View File

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

View File

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

View File

@ -3,7 +3,7 @@
import argparse
import json
import shutil
import time
from datetime import datetime, timezone
from pathlib import Path
from ultralytics import YOLO
@ -53,23 +53,27 @@ def safe_int(value, fallback):
return fallback
def batch_sample_id(batch):
def batch_sample_ids(batch):
if not isinstance(batch, dict):
return ""
return []
image_paths = batch.get("im_file")
if not image_paths:
return ""
return []
if isinstance(image_paths, (str, Path)):
image_path = image_paths
else:
try:
image_path = image_paths[0]
except (IndexError, KeyError, TypeError):
return ""
image_paths = [image_paths]
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):
@ -78,7 +82,6 @@ def progress_detection_trainer(train_count, val_count, train_device, fallback_ep
super().__init__(*args, **kwargs)
self._preview_epoch = 0
self._preview_batch = 0
self._preview_emitted_at = 0.0
def preprocess_batch(self, batch):
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
now = time.monotonic()
if self._preview_batch == 1 or now - self._preview_emitted_at >= 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:

View File

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

View File

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

View File

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

View File

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

View File

@ -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: {
<div className="absolute inset-1 overflow-hidden rounded-md sm:inset-2">
{hasBackground ? (
<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}
alt=""
aria-hidden="true"
draggable={false}
className={[
'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',
backgroundVisible ? 'opacity-80' : 'opacity-0',
props.instantBackground
? 'opacity-80'
: [
'transition-opacity duration-500 ease-out will-change-opacity motion-reduce:transition-none',
backgroundVisible ? 'opacity-80' : 'opacity-0',
].join(' '),
].join(' ')}
/>
) : 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'}
</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>
{stats?.modelAvailable && modelTrainedAtLabel ? (
<div className="mt-2 space-y-1">
<div className="flex items-center justify-between gap-2 text-xs">
<span className="font-medium text-indigo-800/80 dark:text-indigo-100/70">
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 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>
{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 */}
<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">
@ -2060,6 +2264,7 @@ export default function TrainingTab(props: {
const [trainingStats, setTrainingStats] = useState<TrainingStats | null>(null)
const [trainingStatsLoading, setTrainingStatsLoading] = useState(false)
const [trainingStatsError, setTrainingStatsError] = useState<string | null>(null)
const [trainingHistory, setTrainingHistory] = useState<TrainingHistoryEntry[]>([])
const wasTrainingRunningRef = useRef(false)
const shownTrainingCompletionRef = useRef<string | null>(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: {
</span>
</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="text-[9px] font-semibold uppercase tracking-wide opacity-70">
Laufzeit
@ -5971,6 +6262,15 @@ export default function TrainingTab(props: {
{estimatedEpochMs > 0 ? formatDuration(estimatedEpochMs) : '—'}
</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>
) : 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}