bugfixes
This commit is contained in:
parent
f0eef673d9
commit
4320f040ff
@ -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
|
||||||
@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"items": []
|
|
||||||
}
|
|
||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
BIN
backend/ml/__pycache__/train_detector_model.cpython-314.pyc
Normal file
BIN
backend/ml/__pycache__/train_detector_model.cpython-314.pyc
Normal file
Binary file not shown.
@ -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:
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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)
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user