diff --git a/backend/ml/predict_detector_model.py b/backend/ml/predict_detector_model.py index d1206a6..87719e4 100644 --- a/backend/ml/predict_detector_model.py +++ b/backend/ml/predict_detector_model.py @@ -52,13 +52,24 @@ def main(): device = 0 if torch.cuda.is_available() else "cpu" - results = model.predict( - source=str(image_path), - conf=float(args.conf), - imgsz=int(args.imgsz), - verbose=False, - device=device, - ) + try: + results = model.predict( + source=str(image_path), + conf=float(args.conf), + imgsz=int(args.imgsz), + verbose=False, + device=device, + ) + except Exception as e: + print(json.dumps({ + "available": False, + "source": "detector_predict_failed", + "modelPath": str(model_path), + "image": str(image_path), + "error": repr(e), + "boxes": [], + }, ensure_ascii=False)) + return boxes = [] model_names = {} diff --git a/backend/ml/train_detector_model.py b/backend/ml/train_detector_model.py index 2c90729..158e3af 100644 --- a/backend/ml/train_detector_model.py +++ b/backend/ml/train_detector_model.py @@ -56,11 +56,13 @@ def main(): parser.add_argument("--base", default="yolo11n.pt") parser.add_argument("--epochs", default="80") parser.add_argument("--imgsz", default="640") - parser.add_argument("--device", default="cpu") + parser.add_argument("--device", default="auto") parser.add_argument("--workers", default="2") parser.add_argument("--patience", default="20") args = parser.parse_args() + import torch + root = Path(args.root).resolve() dataset_root = root / "detector" / "dataset" yaml_path = dataset_root / "dataset.yaml" @@ -72,6 +74,11 @@ def main(): workers = max(0, safe_int(args.workers, 2)) patience = max(0, safe_int(args.patience, 20)) + if str(args.device).lower() == "auto": + train_device = 0 if torch.cuda.is_available() else "cpu" + else: + train_device = args.device + if not yaml_path.exists(): raise SystemExit(f"dataset.yaml not found: {yaml_path}") @@ -86,7 +93,7 @@ def main(): valSamples=val_count, epochs=epochs, imgsz=imgsz, - device=args.device, + device=str(train_device), ) if train_count <= 0: @@ -100,6 +107,7 @@ def main(): 0.03, "YOLO-Basismodell wird geladen…", base=args.base, + device=str(train_device), ) model = YOLO(args.base) @@ -119,6 +127,7 @@ def main(): epochs=total, trainSamples=train_count, valSamples=val_count, + device=str(train_device), ) def on_train_epoch_end(trainer): @@ -136,12 +145,14 @@ def main(): epochs=total, trainSamples=train_count, valSamples=val_count, + device=str(train_device), ) def on_fit_epoch_end(trainer): nonlocal best_epoch epoch = int(getattr(trainer, "epoch", 0)) + 1 + total = int(getattr(trainer, "epochs", epochs) or epochs) metrics = getattr(trainer, "metrics", None) or {} # Ultralytics nutzt je nach Version unterschiedliche Keys. @@ -161,12 +172,13 @@ def main(): emit_progress( "detector", - 0.04 + 0.90 * (epoch / max(1, epochs)), - f"Object Detector validiert… Epoche {epoch}/{epochs}", + 0.04 + 0.90 * (epoch / max(1, total)), + f"Object Detector validiert… Epoche {epoch}/{total}", epoch=epoch, - epochs=epochs, + epochs=total, mAP50=map50, mAP5095=map5095, + device=str(train_device), ) model.add_callback("on_train_epoch_start", on_train_epoch_start) @@ -180,6 +192,8 @@ def main(): trainSamples=train_count, valSamples=val_count, epochs=epochs, + imgsz=imgsz, + device=str(train_device), ) result = model.train( @@ -189,7 +203,7 @@ def main(): project=str(runs_dir), name="detect", exist_ok=True, - device=args.device, + device=train_device, workers=workers, patience=patience, ) @@ -200,6 +214,7 @@ def main(): "Bestes YOLO-Modell wird übernommen…", lastEpoch=last_epoch, bestEpoch=best_epoch, + device=str(train_device), ) best = runs_dir / "detect" / "weights" / "best.pt" @@ -226,7 +241,7 @@ def main(): "valSamples": val_count, "epochs": epochs, "imgsz": imgsz, - "device": str(args.device), + "device": str(train_device), } with (out_dir / "status.json").open("w", encoding="utf-8") as f: diff --git a/backend/ml/train_scene_model.py b/backend/ml/train_scene_model.py index d9895a0..3535ebf 100644 --- a/backend/ml/train_scene_model.py +++ b/backend/ml/train_scene_model.py @@ -205,8 +205,9 @@ def main(): continue label = target_from_annotation(row) - if not label: - label = "unknown" + if not label or label == "unknown": + skipped += 1 + continue emb = embed_image(clip_model, processor, device, image_path) diff --git a/backend/sse.go b/backend/sse.go index c76b8c4..4b67b62 100644 --- a/backend/sse.go +++ b/backend/sse.go @@ -82,6 +82,119 @@ type taskStateEvent struct { TS int64 `json:"ts"` } +type analysisProgressEvent struct { + Type string `json:"type"` // "analysis_progress" + Running bool `json:"running"` + Phase string `json:"phase,omitempty"` + Progress float64 `json:"progress"` // 0..1 + Current int `json:"current,omitempty"` + Total int `json:"total,omitempty"` + File string `json:"file,omitempty"` + Message string `json:"message,omitempty"` + Error string `json:"error,omitempty"` + StartedAtMs int64 `json:"startedAtMs,omitempty"` + FinishedAtMs int64 `json:"finishedAtMs,omitempty"` + DurationMs int64 `json:"durationMs,omitempty"` + TS int64 `json:"ts"` +} + +func publishAnalysisProgress(ev analysisProgressEvent) { + ev.Type = "analysis_progress" + ev.TS = time.Now().UnixMilli() + + if ev.Progress < 0 { + ev.Progress = 0 + } + if ev.Progress > 1 { + ev.Progress = 1 + } + + b, err := json.Marshal(ev) + if err != nil { + return + } + + publishSSE("analysisProgress", b) +} + +func publishAnalysisStarted(total int, message string) int64 { + startedAtMs := time.Now().UnixMilli() + + publishAnalysisProgress(analysisProgressEvent{ + Running: true, + Phase: "starting", + Progress: 0, + Current: 0, + Total: total, + Message: message, + StartedAtMs: startedAtMs, + }) + + return startedAtMs +} + +func publishAnalysisStep(startedAtMs int64, current int, total int, file string, message string) { + progress := 0.0 + if total > 0 { + progress = float64(current) / float64(total) + } + + publishAnalysisProgress(analysisProgressEvent{ + Running: true, + Phase: "running", + Progress: progress, + Current: current, + Total: total, + File: file, + Message: message, + StartedAtMs: startedAtMs, + }) +} + +func publishAnalysisFinished(startedAtMs int64, total int, message string) { + finishedAtMs := time.Now().UnixMilli() + durationMs := finishedAtMs - startedAtMs + if durationMs < 0 { + durationMs = 0 + } + + publishAnalysisProgress(analysisProgressEvent{ + Running: false, + Phase: "done", + Progress: 1, + Current: total, + Total: total, + Message: message, + StartedAtMs: startedAtMs, + FinishedAtMs: finishedAtMs, + DurationMs: durationMs, + }) +} + +func publishAnalysisError(startedAtMs int64, message string, err error) { + finishedAtMs := time.Now().UnixMilli() + durationMs := finishedAtMs - startedAtMs + if durationMs < 0 { + durationMs = 0 + } + + errText := "" + if err != nil { + errText = err.Error() + } + + publishAnalysisProgress(analysisProgressEvent{ + Running: false, + Phase: "error", + Progress: 0, + Message: message, + Error: errText, + StartedAtMs: startedAtMs, + FinishedAtMs: finishedAtMs, + DurationMs: durationMs, + }) +} + func publishFinishedPostworkStateForJob( j *RecordJob, queue string, diff --git a/backend/training.go b/backend/training.go index 5e7c0ec..a7a75b4 100644 --- a/backend/training.go +++ b/backend/training.go @@ -391,25 +391,44 @@ func trainingNextHandler(w http.ResponseWriter, r *http.Request) { strings.EqualFold(r.URL.Query().Get("refresh"), "true") if !forceNew { - if sample, ok, err := trainingLatestOpenSample(root, refreshPrediction); err != nil { + var startedAtMs int64 + + if refreshPrediction { + startedAtMs = publishAnalysisStarted(2, "Aktuelles Bild wird neu analysiert…") + } + + if sample, ok, err := trainingLatestOpenSample(root, refreshPrediction, startedAtMs); err != nil { + if refreshPrediction { + publishAnalysisError(startedAtMs, "Aktuelles Bild konnte nicht neu analysiert werden.", err) + } + trainingWriteError(w, http.StatusInternalServerError, err.Error()) return } else if ok { + if refreshPrediction { + publishAnalysisFinished(startedAtMs, 2, "Analyse abgeschlossen.") + } + trainingWriteJSON(w, http.StatusOK, sample) return } } - sample, err := trainingCreateNextSample() + startedAtMs := publishAnalysisStarted(4, "Neues Trainingsbild wird vorbereitet…") + + sample, err := trainingCreateNextSampleWithProgress(startedAtMs) if err != nil { + publishAnalysisError(startedAtMs, "Trainingsbild konnte nicht erstellt werden.", err) trainingWriteError(w, http.StatusInternalServerError, err.Error()) return } + publishAnalysisFinished(startedAtMs, 4, "Analyse abgeschlossen.") + trainingWriteJSON(w, http.StatusOK, sample) } -func trainingLatestOpenSample(root string, refreshPrediction bool) (*TrainingSample, bool, error) { +func trainingLatestOpenSample(root string, refreshPrediction bool, startedAtMs int64) (*TrainingSample, bool, error) { answered, err := trainingAnsweredSampleIDs(root) if err != nil { return nil, false, err @@ -475,11 +494,6 @@ func trainingLatestOpenSample(root string, refreshPrediction bool) (*TrainingSam continue } - if refreshPrediction { - sample.Prediction = trainingPredictFrame(framePath) - _ = trainingWriteSample(root, sample) - } - return sample, true, nil } @@ -603,8 +617,16 @@ func trainingFeedbackHandler(w http.ResponseWriter, r *http.Request) { return } - if req.Correction != nil && len(req.Correction.Boxes) > 0 { - if err := trainingWriteDetectorSample(root, sample, req.Correction.Boxes); err != nil { + detectorBoxes := []TrainingBox{} + + if req.Correction != nil { + detectorBoxes = req.Correction.Boxes + } else if req.Accepted { + detectorBoxes = sample.Prediction.Boxes + } + + if len(detectorBoxes) > 0 { + if err := trainingWriteDetectorSample(root, sample, detectorBoxes); err != nil { fmt.Println("⚠️ detector sample write failed:", err) } } @@ -1014,7 +1036,7 @@ func trainingStatusHandler(w http.ResponseWriter, r *http.Request) { // Pipeline: // - YOLO erkennt Personen/Gender für die Counts. - // - Automatisch erkannte Personenboxen werden nicht an das Frontend als sichtbare Boxen zurückgegeben. + // - Personenboxen werden jetzt auch sichtbar zurückgegeben. // - Manuell gezeichnete Personenboxen werden trotzdem als Trainingsdaten gespeichert. trainingWriteJSON(w, http.StatusOK, map[string]any{ "ok": true, @@ -1742,6 +1764,84 @@ func trainingCreateNextSample() (*TrainingSample, error) { return sample, nil } +func trainingCreateNextSampleWithProgress(startedAtMs int64) (*TrainingSample, error) { + publishAnalysisStep(startedAtMs, 1, 4, "", "Video wird ausgewählt…") + + settings := getSettings() + + doneDir := strings.TrimSpace(settings.DoneDir) + if doneDir == "" { + return nil, errors.New("doneDir ist leer") + } + + videoPath, err := trainingPickRandomVideo(doneDir) + if err != nil { + return nil, err + } + + publishAnalysisStep(startedAtMs, 2, 4, filepath.Base(videoPath), "Frame wird extrahiert…") + + duration := trainingProbeDurationSeconds(videoPath) + second := trainingRandomSecond(duration) + + root, err := trainingRootDir() + if err != nil { + return nil, err + } + + if err := trainingEnsureDetectorDirs(root); err != nil { + return nil, err + } + + if err := os.MkdirAll(filepath.Join(root, "frames"), 0755); err != nil { + return nil, err + } + if err := os.MkdirAll(filepath.Join(root, "samples"), 0755); err != nil { + return nil, err + } + + id := trainingMakeSampleID(videoPath, second) + framePath := filepath.Join(root, "frames", id+".jpg") + + if err := trainingExtractFrame(videoPath, framePath, second); err != nil { + second = 0 + id = trainingMakeSampleID(videoPath, second) + framePath = filepath.Join(root, "frames", id+".jpg") + + if err2 := trainingExtractFrame(videoPath, framePath, second); err2 != nil { + return nil, fmt.Errorf("frame extraction failed: %v / fallback: %w", err, err2) + } + } + + publishAnalysisStep(startedAtMs, 3, 4, filepath.Base(videoPath), "Frame wird analysiert…") + + prediction := trainingPredictFrame(framePath) + + var sourceSizeBytes int64 + if st, err := os.Stat(videoPath); err == nil && st != nil && !st.IsDir() { + sourceSizeBytes = st.Size() + } + + sample := &TrainingSample{ + SampleID: id, + FrameURL: "/api/training/frame?id=" + id, + SourceFile: filepath.Base(videoPath), + SourcePath: videoPath, + SourceSizeBytes: sourceSizeBytes, + Second: second, + CreatedAt: time.Now().UTC().Format(time.RFC3339), + Prediction: prediction, + } + + publishAnalysisStep(startedAtMs, 4, 4, filepath.Base(videoPath), "Analyse-Ergebnis wird gespeichert…") + + if err := trainingWriteSample(root, sample); err != nil { + return nil, err + } + + return sample, nil +} + func trainingPickRandomVideo(doneDir string) (string, error) { extOK := map[string]bool{ ".mp4": true, diff --git a/frontend/src/components/ui/TrainingTab.tsx b/frontend/src/components/ui/TrainingTab.tsx index bc2d988..2b8eab8 100644 --- a/frontend/src/components/ui/TrainingTab.tsx +++ b/frontend/src/components/ui/TrainingTab.tsx @@ -14,6 +14,29 @@ type ScoredLabel = { score: number } +type AnalysisProgressEvent = { + type?: 'analysis_progress' + running?: boolean + phase?: string + progress?: number + current?: number + total?: number + file?: string + message?: string + error?: string + startedAtMs?: number + finishedAtMs?: number + durationMs?: number + ts?: number +} + +type TrainingStatus = { + feedbackCount: number + requiredCount: number + canTrain: boolean + training?: TrainingJobStatus +} + type TrainingJobStatus = { running: boolean progress: number @@ -28,13 +51,6 @@ type TrainingJobStatus = { epochs?: number } -type TrainingStatus = { - feedbackCount: number - requiredCount: number - canTrain: boolean - training?: TrainingJobStatus -} - type TrainingPrediction = { modelAvailable: boolean source?: string @@ -551,6 +567,15 @@ function clamp01(v: number) { return Math.max(0, Math.min(1, v)) } +function snap01(v: number, epsilon = 0.006) { + const n = clamp01(v) + + if (n <= epsilon) return 0 + if (n >= 1 - epsilon) return 1 + + return n +} + function normalizeBox(box: TrainingBox): TrainingBox { const x = clamp01(box.x) const y = clamp01(box.y) @@ -601,12 +626,13 @@ function predictionToCorrection(sample: TrainingSample | null): CorrectionState const maleCount = safeCount(p?.maleCount) const femaleCount = safeCount(p?.femaleCount) + const unknownCount = safeCount(p?.unknownCount) return { - peopleCount: maleCount + femaleCount, + peopleCount: maleCount + femaleCount + unknownCount, maleCount, femaleCount, - unknownCount: 0, + unknownCount, sexPosition: p?.sexPosition || 'unknown', bodyPartsPresent: (p?.bodyPartsPresent ?? []).map((x) => x.label), objectsPresent: (p?.objectsPresent ?? []).map((x) => x.label), @@ -1099,8 +1125,12 @@ function DetectorBoxLabelSelect(props: { const rect = button.getBoundingClientRect() const viewportH = window.innerHeight const viewportW = window.innerWidth - const maxHeight = Math.min(260, Math.max(140, viewportH - rect.bottom - 12)) - const openUp = viewportH - rect.bottom < 180 && rect.top > rect.bottom + const spaceBelow = viewportH - rect.bottom + const spaceAbove = rect.top + const openUp = spaceBelow < 180 && spaceAbove > spaceBelow + const maxHeight = openUp + ? Math.min(260, Math.max(140, spaceAbove - 12)) + : Math.min(260, Math.max(140, spaceBelow - 12)) setMenuStyle({ position: 'fixed', @@ -2149,19 +2179,6 @@ export default function TrainingTab(props: { : 'Trainingsbild wird geladen…' ) - let progressTimer: number | undefined - - progressTimer = window.setInterval(() => { - setAnalysisProgress((value) => { - if (value < 35) return Math.min(35, value + 4) - if (value < 65) return Math.min(65, value + 2) - if (value < 95) return Math.min(95, value + 1) - - setAnalysisStep('Analyse läuft noch… Ergebnis wird erwartet.') - return value - }) - }, 350) - if (!opts?.preserveNotice) { setError(null) setMessage(null) @@ -2219,12 +2236,8 @@ export default function TrainingTab(props: { } catch (e) { setError(e instanceof Error ? e.message : String(e)) } finally { - if (progressTimer !== undefined) { - window.clearInterval(progressTimer) - } - - setAnalysisProgress(100) - setAnalysisStep('Analyse abgeschlossen.') + setAnalysisProgress((value) => Math.max(value, 100)) + setAnalysisStep((value) => value || 'Analyse abgeschlossen.') window.setTimeout(() => { setLoading(false) @@ -2305,7 +2318,46 @@ export default function TrainingTab(props: { } } + const onAnalysisProgress = (ev: MessageEvent) => { + try { + const data = JSON.parse(String(ev.data ?? 'null')) as AnalysisProgressEvent + + if (data?.type !== 'analysis_progress') return + + const progress = clampPercent(Number(data.progress ?? 0) * 100) + const message = String(data.message || '').trim() + + setAnalysisProgress(progress) + + if (message) { + setAnalysisStep(message) + } + + if (data.running) { + setLoading(true) + } + + if (!data.running && data.phase === 'error') { + setLoading(false) + setAnalysisProgress(0) + setAnalysisStep('') + + if (data.error || data.message) { + setError(String(data.error || data.message)) + } + } + + if (!data.running && data.phase === 'done') { + setAnalysisProgress(100) + setAnalysisStep(message || 'Analyse abgeschlossen.') + } + } catch { + // ignore + } + } + es.addEventListener('training', onTraining as EventListener) + es.addEventListener('analysisProgress', onAnalysisProgress as EventListener) es.onerror = () => { // Optional: Polling-Fallback bleibt separat bestehen. @@ -2313,6 +2365,7 @@ export default function TrainingTab(props: { return () => { es.removeEventListener('training', onTraining as EventListener) + es.removeEventListener('analysisProgress', onAnalysisProgress as EventListener) es.close() } }, [applyTrainingStatus]) @@ -2516,12 +2569,16 @@ export default function TrainingTab(props: { setMessage(null) try { + const maleCount = safeCount(correction.maleCount) + const femaleCount = safeCount(correction.femaleCount) + const unknownCount = safeCount(correction.unknownCount) + const correctionPayload: CorrectionState = { ...correction, - peopleCount: - safeCount(correction.maleCount) + - safeCount(correction.femaleCount), - unknownCount: 0, + maleCount, + femaleCount, + unknownCount, + peopleCount: maleCount + femaleCount + unknownCount, boxes: (correction.boxes ?? []) .map(normalizeBox) .filter((box) => box.label && box.w > 0 && box.h > 0), @@ -2741,10 +2798,17 @@ export default function TrainingTab(props: { let x2 = original.x + original.w let y2 = original.y + original.h - if (boxInteraction.handle.includes('n')) y1 = clamp01(y1 + dy) - if (boxInteraction.handle.includes('s')) y2 = clamp01(y2 + dy) - if (boxInteraction.handle.includes('w')) x1 = clamp01(x1 + dx) - if (boxInteraction.handle.includes('e')) x2 = clamp01(x2 + dx) + const pointerX = snap01(clampedPos.x) + const pointerY = snap01(clampedPos.y) + + // Wichtig: + // Beim Resize folgt die gezogene Ecke direkt dem Pointer. + // Dadurch bleibt kein Grab-Offset übrig, wenn der Handle nicht exakt + // auf der mathematischen Box-Ecke getroffen wurde. + if (boxInteraction.handle.includes('n')) y1 = pointerY + if (boxInteraction.handle.includes('s')) y2 = pointerY + if (boxInteraction.handle.includes('w')) x1 = pointerX + if (boxInteraction.handle.includes('e')) x2 = pointerX const left = Math.min(x1, x2) const top = Math.min(y1, y2)