// backend\training.go package main import ( "bufio" "crypto/sha1" "encoding/hex" "encoding/json" "errors" "fmt" "math" "math/rand" "net/http" "os" "os/exec" "path/filepath" "sort" "strconv" "strings" "sync" "syscall" "time" ) type TrainingLabels struct { People []string `json:"people"` SexPositions []string `json:"sexPositions"` BodyParts []string `json:"bodyParts"` Objects []string `json:"objects"` Clothing []string `json:"clothing"` } type TrainingBox struct { Label string `json:"label"` Score float64 `json:"score,omitempty"` X float64 `json:"x"` Y float64 `json:"y"` W float64 `json:"w"` H float64 `json:"h"` } type TrainingScoredLabel struct { Label string `json:"label"` Score float64 `json:"score"` } type TrainingPrediction struct { ModelAvailable bool `json:"modelAvailable"` Source string `json:"source,omitempty"` PeopleCount int `json:"peopleCount"` MaleCount int `json:"maleCount"` FemaleCount int `json:"femaleCount"` UnknownCount int `json:"unknownCount"` SexPosition string `json:"sexPosition"` SexPositionScore float64 `json:"sexPositionScore"` BodyPartsPresent []TrainingScoredLabel `json:"bodyPartsPresent"` ObjectsPresent []TrainingScoredLabel `json:"objectsPresent"` ClothingPresent []TrainingScoredLabel `json:"clothingPresent"` Boxes []TrainingBox `json:"boxes"` } type TrainingCorrection struct { PeopleCount int `json:"peopleCount"` MaleCount int `json:"maleCount"` FemaleCount int `json:"femaleCount"` UnknownCount int `json:"unknownCount"` SexPosition string `json:"sexPosition"` BodyPartsPresent []string `json:"bodyPartsPresent"` ObjectsPresent []string `json:"objectsPresent"` ClothingPresent []string `json:"clothingPresent"` Boxes []TrainingBox `json:"boxes"` } type TrainingSample struct { SampleID string `json:"sampleId"` FrameURL string `json:"frameUrl"` SourceFile string `json:"sourceFile"` SourcePath string `json:"sourcePath,omitempty"` SourceSizeBytes int64 `json:"sourceSizeBytes,omitempty"` Second float64 `json:"second"` CreatedAt string `json:"createdAt"` Prediction TrainingPrediction `json:"prediction"` } type TrainingFeedbackRequest struct { SampleID string `json:"sampleId"` Accepted bool `json:"accepted"` Correction *TrainingCorrection `json:"correction,omitempty"` Notes string `json:"notes,omitempty"` } type TrainingAnnotation struct { SampleID string `json:"sampleId"` FrameURL string `json:"frameUrl"` SourceFile string `json:"sourceFile"` SourcePath string `json:"sourcePath,omitempty"` SourceSizeBytes int64 `json:"sourceSizeBytes,omitempty"` Second float64 `json:"second"` CreatedAt string `json:"createdAt"` AnsweredAt string `json:"answeredAt"` Prediction TrainingPrediction `json:"prediction"` Accepted bool `json:"accepted"` Correction *TrainingCorrection `json:"correction,omitempty"` Notes string `json:"notes,omitempty"` } type TrainingDetectorPrediction struct { Available bool `json:"available"` Source string `json:"source,omitempty"` Boxes []TrainingBox `json:"boxes"` } type TrainingScenePositionPrediction struct { Available bool `json:"available"` Source string `json:"source,omitempty"` SexPosition string `json:"sexPosition"` SexPositionScore float64 `json:"sexPositionScore"` } type TrainingJobStatus struct { Running bool `json:"running"` Progress int `json:"progress"` Step string `json:"step"` Message string `json:"message,omitempty"` Error string `json:"error,omitempty"` StartedAt string `json:"startedAt,omitempty"` FinishedAt string `json:"finishedAt,omitempty"` DurationMs int64 `json:"durationMs,omitempty"` Stage string `json:"stage,omitempty"` Epoch int `json:"epoch,omitempty"` Epochs int `json:"epochs,omitempty"` } type TrainingConfidence struct { Score float64 `json:"score"` Level string `json:"level"` Label string `json:"label"` } type TrainingLabelStat struct { Label string `json:"label"` Count int `json:"count"` Confidence TrainingConfidence `json:"confidence"` } type TrainingStatsLabels struct { People []TrainingLabelStat `json:"people"` SexPositions []TrainingLabelStat `json:"sexPositions"` BodyParts []TrainingLabelStat `json:"bodyParts"` Objects []TrainingLabelStat `json:"objects"` Clothing []TrainingLabelStat `json:"clothing"` } type TrainingStatsResponse struct { OK bool `json:"ok"` FeedbackCount int `json:"feedbackCount"` AcceptedCount int `json:"acceptedCount"` CorrectedCount int `json:"correctedCount"` SampleCount int `json:"sampleCount"` BoxCount int `json:"boxCount"` ModelAvailable bool `json:"modelAvailable"` 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"` } func trainingScaleProgress(local float64, start int, end int) int { if math.IsNaN(local) || math.IsInf(local, 0) { local = 0 } local = clamp01(local) if end < start { end = start } return start + int(math.Round(local*float64(end-start))) } func trainingHandleProgressLine(line string, start int, end int, defaultStep string) bool { line = strings.TrimSpace(line) if line == "" { return false } var ev trainingProgressEvent if err := json.Unmarshal([]byte(line), &ev); err != nil { return false } if ev.Type != "progress" { return false } progress := trainingScaleProgress(ev.Progress, start, end) step := strings.TrimSpace(ev.Message) if step == "" { step = defaultStep } trainingSetJobStatus(func(s *TrainingJobStatus) { if progress > s.Progress { s.Progress = progress } s.Step = step if strings.TrimSpace(ev.Stage) != "" { s.Stage = strings.TrimSpace(ev.Stage) } if ev.Epoch > 0 { s.Epoch = ev.Epoch } if ev.Epochs > 0 { s.Epochs = ev.Epochs } }) return true } func trainingPublishJobStatus(status TrainingJobStatus) { b, err := json.Marshal(map[string]any{ "type": "training_status", "training": status, "ts": time.Now().UnixMilli(), }) if err != nil { return } publishSSE("training", b) } func trainingRunCommandStreaming( python string, script string, onLine func(line string) bool, args ...string, ) (string, error) { cmdArgs := append([]string{script}, args...) cmd := exec.Command(python, cmdArgs...) cmd.SysProcAttr = &syscall.SysProcAttr{ HideWindow: true, CreationFlags: 0x08000000, } stdout, err := cmd.StdoutPipe() if err != nil { return "", err } stderr, err := cmd.StderrPipe() if err != nil { return "", err } if err := cmd.Start(); err != nil { return "", err } var outMu sync.Mutex var lines []string readPipe := func(scanner *bufio.Scanner) { scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if line == "" { continue } handled := false if onLine != nil { handled = onLine(line) } // Progress-Events nicht in den finalen Output übernehmen. if handled { continue } outMu.Lock() lines = append(lines, line) outMu.Unlock() } } var wg sync.WaitGroup wg.Add(2) go func() { defer wg.Done() readPipe(bufio.NewScanner(stdout)) }() go func() { defer wg.Done() readPipe(bufio.NewScanner(stderr)) }() err = cmd.Wait() wg.Wait() outMu.Lock() out := strings.Join(lines, "\n") outMu.Unlock() return strings.TrimSpace(out), err } const minTrainingFeedbackCount = 5 const minDetectorTrainCount = 20 const minDetectorValCount = 3 var trainingJob = struct { mu sync.Mutex status TrainingJobStatus }{} func trainingSetJobStatus(update func(*TrainingJobStatus)) { trainingJob.mu.Lock() update(&trainingJob.status) snapshot := trainingJob.status trainingJob.mu.Unlock() trainingPublishJobStatus(snapshot) } func trainingGetJobStatus() TrainingJobStatus { trainingJob.mu.Lock() defer trainingJob.mu.Unlock() return trainingJob.status } func trainingRunCommand(python string, script string, args ...string) (string, error) { cmdArgs := append([]string{script}, args...) cmd := exec.Command(python, cmdArgs...) cmd.SysProcAttr = &syscall.SysProcAttr{ HideWindow: true, CreationFlags: 0x08000000, } out, err := cmd.CombinedOutput() return strings.TrimSpace(string(out)), err } func trainingLabelsHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { trainingWriteError(w, http.StatusMethodNotAllowed, "method not allowed") return } trainingWriteJSON(w, http.StatusOK, defaultTrainingLabelsFromJSON()) } func trainingNextHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { trainingWriteError(w, http.StatusMethodNotAllowed, "method not allowed") return } forceNew := r.URL.Query().Get("force") == "1" || strings.EqualFold(r.URL.Query().Get("force"), "true") root, err := trainingRootDir() if err != nil { trainingWriteError(w, http.StatusInternalServerError, err.Error()) return } refreshPrediction := r.URL.Query().Get("refresh") == "1" || strings.EqualFold(r.URL.Query().Get("refresh"), "true") if !forceNew { 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 } } 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, startedAtMs int64) (*TrainingSample, bool, error) { answered, err := trainingAnsweredSampleIDs(root) if err != nil { return nil, false, err } samplesDir := filepath.Join(root, "samples") entries, err := os.ReadDir(samplesDir) if err != nil { if os.IsNotExist(err) { return nil, false, nil } return nil, false, err } type sampleFile struct { id string path string modTime time.Time } files := []sampleFile{} for _, entry := range entries { if entry.IsDir() { continue } name := entry.Name() if strings.ToLower(filepath.Ext(name)) != ".json" { continue } id := strings.TrimSuffix(name, filepath.Ext(name)) if id == "" || answered[id] { continue } info, err := entry.Info() if err != nil { continue } files = append(files, sampleFile{ id: id, path: filepath.Join(samplesDir, name), modTime: info.ModTime(), }) } sort.Slice(files, func(i, j int) bool { return files[i].modTime.After(files[j].modTime) }) for _, file := range files { sample, err := trainingReadSample(root, file.id) if err != nil { continue } framePath := filepath.Join(root, "frames", sample.SampleID+".jpg") if !fileExistsNonEmpty(framePath) { continue } return sample, true, nil } return nil, false, nil } func trainingAnsweredSampleIDs(root string) (map[string]bool, error) { out := map[string]bool{} path := filepath.Join(root, "feedback.jsonl") b, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { return out, nil } return nil, err } for _, line := range strings.Split(string(b), "\n") { line = strings.TrimSpace(line) if line == "" { continue } var row TrainingAnnotation if err := json.Unmarshal([]byte(line), &row); err != nil { continue } id := strings.TrimSpace(row.SampleID) if id != "" { out[id] = true } } return out, nil } func trainingFrameHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { trainingWriteError(w, http.StatusMethodNotAllowed, "method not allowed") return } id := strings.TrimSpace(r.URL.Query().Get("id")) if id == "" || strings.Contains(id, "/") || strings.Contains(id, "\\") { trainingWriteError(w, http.StatusBadRequest, "invalid frame id") return } root, err := trainingRootDir() if err != nil { trainingWriteError(w, http.StatusInternalServerError, err.Error()) return } path := filepath.Join(root, "frames", id+".jpg") if _, err := os.Stat(path); err != nil { trainingWriteError(w, http.StatusNotFound, "frame not found") return } w.Header().Set("Cache-Control", "no-store") http.ServeFile(w, r, path) } func trainingFeedbackHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { trainingWriteError(w, http.StatusMethodNotAllowed, "method not allowed") return } var req TrainingFeedbackRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { trainingWriteError(w, http.StatusBadRequest, "invalid json") return } req.SampleID = strings.TrimSpace(req.SampleID) if req.SampleID == "" { trainingWriteError(w, http.StatusBadRequest, "sampleId missing") return } root, err := trainingRootDir() if err != nil { trainingWriteError(w, http.StatusInternalServerError, err.Error()) return } sample, err := trainingReadSample(root, req.SampleID) if err != nil { trainingWriteError(w, http.StatusNotFound, "sample not found") return } annotation := TrainingAnnotation{ SampleID: sample.SampleID, FrameURL: sample.FrameURL, SourceFile: sample.SourceFile, SourcePath: sample.SourcePath, SourceSizeBytes: sample.SourceSizeBytes, Second: sample.Second, CreatedAt: sample.CreatedAt, AnsweredAt: time.Now().UTC().Format(time.RFC3339), Prediction: sample.Prediction, Accepted: req.Accepted, Correction: req.Correction, Notes: strings.TrimSpace(req.Notes), } if !annotation.Accepted && annotation.Correction == nil { trainingWriteError(w, http.StatusBadRequest, "correction missing") return } if err := trainingAppendAnnotation(root, annotation); err != nil { trainingWriteError(w, http.StatusInternalServerError, err.Error()) return } 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) } } trainingWriteJSON(w, http.StatusOK, map[string]any{ "ok": true, }) } func trainingHasDetectorTrainingData(imagesDir string, labelsDir string) bool { imageExts := map[string]bool{ ".jpg": true, ".jpeg": true, ".png": true, ".webp": true, } entries, err := os.ReadDir(imagesDir) if err != nil { return false } count := 0 for _, e := range entries { if e.IsDir() { continue } ext := strings.ToLower(filepath.Ext(e.Name())) if !imageExts[ext] { continue } id := strings.TrimSuffix(e.Name(), filepath.Ext(e.Name())) labelPath := filepath.Join(labelsDir, id+".txt") if fileExistsNonEmpty(labelPath) { count++ } } return count >= 5 } func trainingTrainHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { trainingWriteError(w, http.StatusMethodNotAllowed, "method not allowed") return } current := trainingGetJobStatus() if current.Running { trainingWriteJSON(w, http.StatusOK, map[string]any{ "ok": true, "message": "Training läuft bereits.", "training": current, }) return } root, err := trainingRootDir() if err != nil { trainingWriteError(w, http.StatusInternalServerError, err.Error()) return } if err := trainingEnsureDetectorDirs(root); err != nil { trainingWriteError(w, http.StatusInternalServerError, err.Error()) return } // Falls bisher alles zufällig in train gelandet ist, erzeugen wir mindestens // ein Validation-Sample durch Kopieren. Bei mehr Daten solltest du später // einen echten 80/20 Split verwenden. if err := trainingEnsureDetectorValidationSample(root); err != nil { fmt.Println("⚠️ detector val sample ensure failed:", err) } feedbackPath := filepath.Join(root, "feedback.jsonl") feedbackCount, _ := trainingCountAnnotations(feedbackPath) if feedbackCount < minTrainingFeedbackCount { trainingWriteError( w, http.StatusBadRequest, fmt.Sprintf( "Zu wenige Bewertungen für das Scene-Positionsmodell. Mindestens %d, aktuell %d.", minTrainingFeedbackCount, feedbackCount, ), ) return } detectorTrainImages := filepath.Join(root, "detector", "dataset", "images", "train") detectorTrainLabels := filepath.Join(root, "detector", "dataset", "labels", "train") detectorValImages := filepath.Join(root, "detector", "dataset", "images", "val") detectorValLabels := filepath.Join(root, "detector", "dataset", "labels", "val") detectorDatasetYAML := filepath.Join(root, "detector", "dataset", "dataset.yaml") trainCount := trainingCountDetectorSamples(detectorTrainImages, detectorTrainLabels) valCount := trainingCountDetectorSamples(detectorValImages, detectorValLabels) trainingSetJobStatus(func(s *TrainingJobStatus) { *s = TrainingJobStatus{ Running: true, Progress: 5, Step: "Training wird vorbereitet…", StartedAt: time.Now().UTC().Format(time.RFC3339), } }) go trainingRunJob(root, feedbackCount) trainingWriteJSON(w, http.StatusAccepted, map[string]any{ "ok": true, "message": "Training gestartet.", "training": trainingGetJobStatus(), "detector": map[string]any{ "trainCount": trainCount, "valCount": valCount, "requiredTrain": minDetectorTrainCount, "requiredVal": minDetectorValCount, "datasetYAML": detectorDatasetYAML, "usesSceneCLIP": true, "usesSceneKNN": true, "source": "yolo_detector+scene_position_clip", }, }) } func trainingRunJob(root string, count int) { python := trainingPythonExe() cleanOutput := func(text string) string { out := strings.TrimSpace(text) if len(out) > 1500 { out = out[:1500] + "…" } return out } trainingSetJobStatus(func(s *TrainingJobStatus) { s.Progress = 10 s.Step = "CLIP-Scene-Positionsmodell wird trainiert…" }) sceneStatus := "skipped" sceneOutput := "" sceneScript := trainingScriptPath("train_scene_model.py") sceneOut, sceneErr := trainingRunCommandStreaming( python, sceneScript, func(line string) bool { return trainingHandleProgressLine( line, 10, 45, "CLIP-Scene-Positionsmodell wird trainiert…", ) }, "--root", root, ) sceneOutput = sceneOut sceneOutputClean := cleanOutput(sceneOutput) if sceneErr != nil { sceneStatus = "failed" fmt.Println("⚠️ scene position training failed:", sceneErr) if sceneOutputClean != "" { fmt.Println("⚠️ scene position output:", sceneOutputClean) } } else { sceneStatus = "trained" if sceneOutputClean != "" { fmt.Println("✅ scene position training:", sceneOutputClean) } } trainingSetJobStatus(func(s *TrainingJobStatus) { s.Progress = 45 s.Step = "Object Detector-Daten werden geprüft…" }) detectorOutput := "" detectorStatus := "skipped" detectorDatasetYAML := filepath.Join(root, "detector", "dataset", "dataset.yaml") detectorTrainImages := filepath.Join(root, "detector", "dataset", "images", "train") detectorTrainLabels := filepath.Join(root, "detector", "dataset", "labels", "train") detectorValImages := filepath.Join(root, "detector", "dataset", "images", "val") detectorValLabels := filepath.Join(root, "detector", "dataset", "labels", "val") if err := trainingEnsureDetectorValidationSample(root); err != nil { fmt.Println("⚠️ detector val sample ensure failed:", err) } trainCount := trainingCountDetectorSamples(detectorTrainImages, detectorTrainLabels) valCount := trainingCountDetectorSamples(detectorValImages, detectorValLabels) fmt.Printf( "🔎 detector data: train=%d val=%d yaml=%v\n", trainCount, valCount, fileExistsNonEmpty(detectorDatasetYAML), ) if fileExistsNonEmpty(detectorDatasetYAML) && trainCount >= minDetectorTrainCount && valCount >= minDetectorValCount { trainingSetJobStatus(func(s *TrainingJobStatus) { s.Progress = 60 s.Step = "Object Detector wird trainiert…" }) detectorScript := trainingScriptPath("train_detector_model.py") detectorOut, detectorErr := trainingRunCommandStreaming( python, detectorScript, func(line string) bool { return trainingHandleProgressLine( line, 60, 98, "Object Detector wird trainiert…", ) }, "--root", root, "--base", "yolo11n.pt", "--epochs", strconv.Itoa(trainingDetectorEpochs()), "--imgsz", "640", ) detectorOutput = detectorOut detectorOutputClean := cleanOutput(detectorOutput) if detectorErr != nil { detectorStatus = "failed" fmt.Println("⚠️ detector training failed:", detectorErr) if detectorOutputClean != "" { fmt.Println("⚠️ detector output:", detectorOutputClean) } } else { detectorStatus = "trained" if detectorOutputClean != "" { fmt.Println("✅ detector training:", detectorOutputClean) } } } else { detectorStatus = "skipped_no_detector_data" detectorOutput = fmt.Sprintf( "Object Detector übersprungen: zu wenige Box-Labels. Train=%d, Val=%d. Benötigt: mindestens %d Train und %d Val.", trainCount, valCount, minDetectorTrainCount, minDetectorValCount, ) fmt.Println("⚠️", detectorOutput) } detectorOutputClean := cleanOutput(detectorOutput) message := "Training abgeschlossen." errorParts := []string{} if sceneStatus == "failed" { if sceneOutputClean != "" { errorParts = append(errorParts, "Scene-Positionsmodell fehlgeschlagen: "+sceneOutputClean) } else { errorParts = append(errorParts, "Scene-Positionsmodell fehlgeschlagen. Details stehen in der Backend-Konsole.") } } if detectorStatus == "failed" { if detectorOutputClean != "" { errorParts = append(errorParts, "Object Detector fehlgeschlagen: "+detectorOutputClean) } else { errorParts = append(errorParts, "Object Detector fehlgeschlagen. Details stehen in der Backend-Konsole.") } } switch { case sceneStatus == "trained" && detectorStatus == "trained": message = "Training abgeschlossen. CLIP-Scene-Positionsmodell und Object Detector wurden trainiert." case sceneStatus == "trained" && detectorStatus == "skipped_no_detector_data": message = "CLIP-Scene-Positionsmodell wurde trainiert. " + detectorOutput case sceneStatus == "trained" && detectorStatus == "failed": message = "CLIP-Scene-Positionsmodell wurde trainiert. Object Detector ist fehlgeschlagen." if detectorOutputClean != "" { message += " Grund: " + detectorOutputClean } case sceneStatus == "failed" && detectorStatus == "trained": message = "Object Detector wurde trainiert. Scene-Positionsmodell ist fehlgeschlagen." if sceneOutputClean != "" { message += " Grund: " + sceneOutputClean } case sceneStatus == "failed" && detectorStatus == "skipped_no_detector_data": message = "Scene-Positionsmodell ist fehlgeschlagen. " + detectorOutput if sceneOutputClean != "" { message += " Scene-Grund: " + sceneOutputClean } case sceneStatus == "failed" && detectorStatus == "failed": message = "Scene-Positionsmodell und Object Detector sind fehlgeschlagen." default: message = "Training abgeschlossen, aber kein Modell wurde erfolgreich trainiert." if sceneOutputClean != "" { message += " Scene-Ausgabe: " + sceneOutputClean } if detectorOutputClean != "" { message += " Detector-Ausgabe: " + detectorOutputClean } } errorText := strings.Join(errorParts, " ") trainingSetJobStatus(func(s *TrainingJobStatus) { finishedAt := time.Now().UTC() var durationMs int64 if startedAt, err := time.Parse(time.RFC3339, strings.TrimSpace(s.StartedAt)); err == nil { durationMs = finishedAt.Sub(startedAt).Milliseconds() if durationMs < 0 { durationMs = 0 } } s.Running = false s.Progress = 100 s.Step = "Training abgeschlossen." s.Message = message s.Error = errorText s.FinishedAt = finishedAt.Format(time.RFC3339) s.DurationMs = durationMs }) } func trainingStatusHandler(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 } job := trainingGetJobStatus() if !job.Running { if err := trainingEnsureDetectorDirs(root); err != nil { trainingWriteError(w, http.StatusInternalServerError, err.Error()) return } if err := trainingEnsureDetectorValidationSample(root); err != nil { fmt.Println("⚠️ detector val sample ensure failed:", err) } } feedbackPath := filepath.Join(root, "feedback.jsonl") feedbackCount, _ := trainingCountAnnotations(feedbackPath) detectorDatasetYAML := filepath.Join(root, "detector", "dataset", "dataset.yaml") detectorTrainImages := filepath.Join(root, "detector", "dataset", "images", "train") detectorTrainLabels := filepath.Join(root, "detector", "dataset", "labels", "train") detectorValImages := filepath.Join(root, "detector", "dataset", "images", "val") detectorValLabels := filepath.Join(root, "detector", "dataset", "labels", "val") detectorModelPath := filepath.Join(root, "detector", "model", "best.pt") sceneEmbeddingsPath := filepath.Join(root, "model", "scene_clip_embeddings.npz") sceneTargetsPath := filepath.Join(root, "model", "scene_clip_targets.json") sceneKNNPath := filepath.Join(root, "model", "scene_clip_knn.joblib") sceneLRPath := filepath.Join(root, "model", "scene_clip_lr.joblib") sceneStatusPath := filepath.Join(root, "model", "status.json") trainCount := trainingCountDetectorSamples(detectorTrainImages, detectorTrainLabels) valCount := trainingCountDetectorSamples(detectorValImages, detectorValLabels) datasetReady := fileExistsNonEmpty(detectorDatasetYAML) detectorDataReady := datasetReady && trainCount >= minDetectorTrainCount && valCount >= minDetectorValCount sceneEmbeddingsExists := fileExistsNonEmpty(sceneEmbeddingsPath) sceneTargetsExists := fileExistsNonEmpty(sceneTargetsPath) sceneKNNExists := fileExistsNonEmpty(sceneKNNPath) sceneLRExists := fileExistsNonEmpty(sceneLRPath) sceneReady := sceneEmbeddingsExists && sceneTargetsExists && (sceneKNNExists || sceneLRExists) canTrain := feedbackCount >= minTrainingFeedbackCount // Pipeline: // - YOLO erkennt Personen/Gender für die Counts. // - Personenboxen werden jetzt auch sichtbar zurückgegeben. // - Manuell gezeichnete Personenboxen werden trotzdem als Trainingsdaten gespeichert. trainingWriteJSON(w, http.StatusOK, map[string]any{ "ok": true, "feedbackCount": feedbackCount, "requiredCount": minTrainingFeedbackCount, "canTrain": canTrain, "training": job, "detector": map[string]any{ "source": "yolo_detector", "usesSceneKNN": false, "usesResNet18KNN": false, "detectsPeople": true, "detectsGender": true, "detectsBodyParts": true, "detectsObjects": true, "detectsClothing": true, "detectsBoxes": true, "trainCount": trainCount, "valCount": valCount, "requiredTrain": minDetectorTrainCount, "requiredVal": minDetectorValCount, "datasetReady": datasetReady, "datasetYAML": detectorDatasetYAML, "dataReady": detectorDataReady, "modelExists": fileExistsNonEmpty(detectorModelPath), "modelPath": detectorModelPath, }, "scene": map[string]any{ "source": "scene_position_clip", "usesSceneCLIP": true, "usesSceneKNN": true, "usesResNet18KNN": false, "usesLogisticRegression": true, "predictsSexPosition": true, // Wichtig: // Diese Werte kommen NICHT mehr vom Scene-KNN. "predictsPeople": false, "predictsGender": false, "predictsBodyParts": false, "predictsObjects": false, "predictsClothing": false, "predictsBoxes": false, "feedbackCount": feedbackCount, "requiredCount": minTrainingFeedbackCount, "dataReady": feedbackCount >= minTrainingFeedbackCount, "modelReady": sceneReady, "embeddingsExists": sceneEmbeddingsExists, "targetsExists": sceneTargetsExists, "knnExists": sceneKNNExists, "lrExists": sceneLRExists, "statusExists": fileExistsNonEmpty(sceneStatusPath), "embeddingsPath": sceneEmbeddingsPath, "targetsPath": sceneTargetsPath, "knnPath": sceneKNNPath, "lrPath": sceneLRPath, "statusPath": sceneStatusPath, }, "pipeline": map[string]any{ "variant": "B", "peopleSource": "yolo_detector", "genderSource": "yolo_detector", "bodyPartsSource": "yolo_detector", "objectsSource": "yolo_detector", "clothingSource": "yolo_detector", "boxesSource": "yolo_detector", "sexPositionSource": "scene_position_clip_lr_or_knn", "usesSceneKNNForDetection": false, "usesYOLOForDetection": true, }, }) } func trainingStatsHandler(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 } stats, err := trainingBuildStats(root) if err != nil { trainingWriteError(w, http.StatusInternalServerError, err.Error()) return } trainingWriteJSON(w, http.StatusOK, stats) } func trainingBuildStats(root string) (*TrainingStatsResponse, error) { grouped, err := trainingGroupedLabels() if err != nil { // Fallback: Stats sollen trotzdem funktionieren, auch wenn Label-Gruppierung scheitert. fallbackLabels := defaultTrainingLabelsFromJSON() grouped = TrainingGroupedLabels{ People: fallbackLabels.People, SexPositions: fallbackLabels.SexPositions, BodyParts: fallbackLabels.BodyParts, Objects: fallbackLabels.Objects, Clothing: fallbackLabels.Clothing, } } peopleSet := stringSet(grouped.People) sexPositionSet := stringSet(grouped.SexPositions) bodyPartSet := stringSet(grouped.BodyParts) objectSet := stringSet(grouped.Objects) clothingSet := stringSet(grouped.Clothing) peopleCounts := map[string]int{} sexPositionCounts := map[string]int{} bodyPartCounts := map[string]int{} objectCounts := map[string]int{} clothingCounts := map[string]int{} stats := &TrainingStatsResponse{ OK: true, Labels: TrainingStatsLabels{ People: []TrainingLabelStat{}, SexPositions: []TrainingLabelStat{}, BodyParts: []TrainingLabelStat{}, Objects: []TrainingLabelStat{}, Clothing: []TrainingLabelStat{}, }, } feedbackPath := filepath.Join(root, "feedback.jsonl") b, err := os.ReadFile(feedbackPath) if err != nil { if os.IsNotExist(err) { stats.SampleCount = trainingCountSampleFiles(filepath.Join(root, "samples")) stats.ModelAvailable = trainingStatsModelAvailable(root) return stats, nil } return nil, err } for _, line := range strings.Split(string(b), "\n") { line = strings.TrimSpace(line) if line == "" { continue } var annotation TrainingAnnotation if err := json.Unmarshal([]byte(line), &annotation); err != nil { continue } stats.FeedbackCount++ if annotation.Accepted { stats.AcceptedCount++ } else { stats.CorrectedCount++ } effective := trainingEffectiveCorrection(annotation) sexPosition := strings.TrimSpace(effective.SexPosition) if sexPosition == "" { sexPosition = "unknown" } if len(sexPositionSet) == 0 || sexPositionSet[sexPosition] { sexPositionCounts[sexPosition]++ } for _, label := range effective.BodyPartsPresent { clean := strings.TrimSpace(label) if clean == "" { continue } if len(bodyPartSet) == 0 || bodyPartSet[clean] { bodyPartCounts[clean]++ } } for _, label := range effective.ObjectsPresent { clean := strings.TrimSpace(label) if clean == "" { continue } if len(objectSet) == 0 || objectSet[clean] { objectCounts[clean]++ } } for _, label := range effective.ClothingPresent { clean := strings.TrimSpace(label) if clean == "" { continue } if len(clothingSet) == 0 || clothingSet[clean] { clothingCounts[clean]++ } } for _, box := range effective.Boxes { label := strings.TrimSpace(box.Label) if label == "" { continue } stats.BoxCount++ switch { case peopleSet[label]: peopleCounts[label]++ case bodyPartSet[label]: bodyPartCounts[label]++ case objectSet[label]: objectCounts[label]++ case clothingSet[label]: clothingCounts[label]++ } } } stats.SampleCount = trainingCountSampleFiles(filepath.Join(root, "samples")) stats.ModelAvailable = trainingStatsModelAvailable(root) stats.Labels = TrainingStatsLabels{ // Personen/Box-Labels brauchen mehr Beispiele, weil der Detector Boxen lernen muss. People: trainingStatsMapToList(peopleCounts, 20), // Scene-Positionen sind Sample-Labels, hier reichen grob weniger pro Klasse. SexPositions: trainingStatsMapToList(sexPositionCounts, 8), // Detector-Klassen: grob 15 Beispiele pro Label als solide Untergrenze. BodyParts: trainingStatsMapToList(bodyPartCounts, 15), Objects: trainingStatsMapToList(objectCounts, 15), Clothing: trainingStatsMapToList(clothingCounts, 15), } stats.Confidence = trainingOverallConfidence( stats.FeedbackCount, stats.BoxCount, stats.AcceptedCount, stats.CorrectedCount, stats.Labels, ) return stats, nil } func trainingEffectiveCorrection(annotation TrainingAnnotation) TrainingCorrection { if annotation.Correction != nil { return *annotation.Correction } p := annotation.Prediction return TrainingCorrection{ PeopleCount: p.PeopleCount, MaleCount: p.MaleCount, FemaleCount: p.FemaleCount, UnknownCount: p.UnknownCount, SexPosition: p.SexPosition, BodyPartsPresent: trainingScoredLabelsToStrings(p.BodyPartsPresent), ObjectsPresent: trainingScoredLabelsToStrings(p.ObjectsPresent), ClothingPresent: trainingScoredLabelsToStrings(p.ClothingPresent), Boxes: p.Boxes, } } func trainingScoredLabelsToStrings(values []TrainingScoredLabel) []string { out := make([]string, 0, len(values)) seen := map[string]bool{} for _, value := range values { label := strings.TrimSpace(value.Label) if label == "" || seen[label] { continue } seen[label] = true out = append(out, label) } return out } func trainingStatsMapToList(values map[string]int, target int) []TrainingLabelStat { out := make([]TrainingLabelStat, 0, len(values)) for label, count := range values { label = strings.TrimSpace(label) if label == "" || count <= 0 { continue } out = append(out, TrainingLabelStat{ Label: label, Count: count, Confidence: trainingLabelConfidence(count, target), }) } sort.Slice(out, func(i, j int) bool { if out[i].Count == out[j].Count { return out[i].Label < out[j].Label } return out[i].Count > out[j].Count }) return out } func trainingCountSampleFiles(samplesDir string) int { entries, err := os.ReadDir(samplesDir) if err != nil { return 0 } count := 0 for _, entry := range entries { if entry.IsDir() { continue } if strings.ToLower(filepath.Ext(entry.Name())) == ".json" { count++ } } return count } func trainingStatsModelAvailable(root string) bool { detectorModelPath := filepath.Join(root, "detector", "model", "best.pt") sceneKNNPath := filepath.Join(root, "model", "scene_clip_knn.joblib") sceneLRPath := filepath.Join(root, "model", "scene_clip_lr.joblib") sceneEmbeddingsPath := filepath.Join(root, "model", "scene_clip_embeddings.npz") sceneTargetsPath := filepath.Join(root, "model", "scene_clip_targets.json") detectorReady := fileExistsNonEmpty(detectorModelPath) sceneReady := fileExistsNonEmpty(sceneEmbeddingsPath) && fileExistsNonEmpty(sceneTargetsPath) && (fileExistsNonEmpty(sceneKNNPath) || fileExistsNonEmpty(sceneLRPath)) return detectorReady || sceneReady } func trainingConfidenceFromScore(score float64) TrainingConfidence { if math.IsNaN(score) || math.IsInf(score, 0) { score = 0 } score = clamp01(score) level := "none" label := "Keine" switch { case score >= 0.75: level = "high" label = "Hoch" case score >= 0.45: level = "mid" label = "Mittel" case score > 0: level = "low" label = "Niedrig" } return TrainingConfidence{ Score: score, Level: level, Label: label, } } func trainingLabelConfidence(count int, target int) TrainingConfidence { if target <= 0 { target = 10 } if count <= 0 { return trainingConfidenceFromScore(0) } // Grobe Datenabdeckung: target erreicht = 100%. // sqrt macht kleine Mengen etwas weniger hart, aber 1 Treffer bleibt niedrig. score := math.Sqrt(float64(count) / float64(target*2)) return trainingConfidenceFromScore(score) } func trainingSaturationScore(value int, target int) float64 { if value <= 0 || target <= 0 { return 0 } // Sanfter Anstieg, aber nie über 1. return clamp01(math.Sqrt(float64(value) / float64(target))) } func trainingAverageLabelConfidence(labels TrainingStatsLabels) float64 { values := []float64{} appendScores := func(items []TrainingLabelStat) { for _, item := range items { values = append(values, clamp01(item.Confidence.Score)) } } appendScores(labels.People) appendScores(labels.SexPositions) appendScores(labels.BodyParts) appendScores(labels.Objects) appendScores(labels.Clothing) if len(values) == 0 { return 0 } sum := 0.0 for _, value := range values { sum += value } return clamp01(sum / float64(len(values))) } func trainingOverallConfidence( feedbackCount int, boxCount int, acceptedCount int, correctedCount int, labels TrainingStatsLabels, ) TrainingConfidence { if feedbackCount <= 0 { return trainingConfidenceFromScore(0) } // Datenmenge: 300 Feedbacks sind grob "voll", darunter anteilig. feedbackScore := trainingSaturationScore(feedbackCount, 300) // Detector-Daten: 1000 Boxen sind grob "voll", darunter anteilig. boxScore := trainingSaturationScore(boxCount, 1000) // Label-Abdeckung aus den einzelnen Label-Confidence-Werten. labelScore := trainingAverageLabelConfidence(labels) // Modell-/Prediction-Zustimmung: // Viele "Passt so"-Antworten bedeuten, dass die Vorhersagen brauchbar sind. // Bei 4/229 ist dieser Teil bewusst sehr niedrig. agreementScore := 0.0 if feedbackCount > 0 { agreementScore = clamp01(float64(acceptedCount) / float64(feedbackCount)) } // Korrekturquote als zusätzlicher Dämpfer. // 98% korrigiert soll die Gesamt-Confidence sichtbar drücken, // aber nicht alle gesammelten Daten entwerten. correctionRate := 0.0 if feedbackCount > 0 { correctionRate = clamp01(float64(correctedCount) / float64(feedbackCount)) } correctionPenalty := 1.0 - math.Min(0.45, correctionRate*0.45) // Gesamt: // - Datenmenge zählt viel // - Boxen und Label-Abdeckung zählen mittel // - echte Modell-Zustimmung zählt bewusst mit rein score := feedbackScore*0.30 + boxScore*0.25 + labelScore*0.25 + agreementScore*0.20 score *= correctionPenalty return trainingConfidenceFromScore(score) } func stringSet(values []string) map[string]bool { out := map[string]bool{} for _, value := range values { clean := strings.TrimSpace(value) if clean == "" { continue } out[clean] = true } return out } func trainingRecognitionEnabled() bool { return getSettings().TrainingRecognitionEnabled } func trainingDetectorEpochs() int { epochs := getSettings().TrainingDetectorEpochs if epochs < 1 { return 60 } if epochs > 300 { return 300 } return epochs } func trainingDeleteAllHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodDelete && r.Method != http.MethodPost { trainingWriteError(w, http.StatusMethodNotAllowed, "method not allowed") return } root, err := trainingRootDir() if err != nil { trainingWriteError(w, http.StatusInternalServerError, err.Error()) return } if err := os.RemoveAll(root); err != nil { trainingWriteError(w, http.StatusInternalServerError, fmt.Sprintf("Trainingsdaten konnten nicht gelöscht werden: %v", err)) return } // Basisordner direkt wieder anlegen, damit die nächsten API-Aufrufe sauber funktionieren. if _, err := trainingRootDir(); err != nil { trainingWriteError(w, http.StatusInternalServerError, err.Error()) return } trainingSetJobStatus(func(s *TrainingJobStatus) { *s = TrainingJobStatus{} }) trainingWriteJSON(w, http.StatusOK, map[string]any{ "ok": true, "message": "Alle Trainingsdaten wurden gelöscht.", }) } func trainingCountDetectorSamples(imagesDir string, labelsDir string) int { imageExts := map[string]bool{ ".jpg": true, ".jpeg": true, ".png": true, ".webp": true, } entries, err := os.ReadDir(imagesDir) if err != nil { return 0 } count := 0 for _, e := range entries { if e.IsDir() { continue } ext := strings.ToLower(filepath.Ext(e.Name())) if !imageExts[ext] { continue } id := strings.TrimSuffix(e.Name(), filepath.Ext(e.Name())) labelPath := filepath.Join(labelsDir, id+".txt") if fileExistsNonEmpty(labelPath) { count++ } } return count } func trainingWriteDetectorDatasetYAML(root string) error { datasetDir := filepath.Join(root, "detector", "dataset") absDatasetDir, err := filepath.Abs(datasetDir) if err != nil { return err } namesYAML, err := trainingDetectorDatasetNamesYAML() if err != nil { return err } path := filepath.Join(datasetDir, "dataset.yaml") yoloPath := filepath.ToSlash(absDatasetDir) body := fmt.Sprintf(`path: %s train: images/train val: images/val names: %s`, yoloPath, namesYAML) return os.WriteFile(path, []byte(body), 0644) } func trainingEnsureDetectorDirs(root string) error { dirs := []string{ filepath.Join(root, "detector"), filepath.Join(root, "detector", "dataset"), filepath.Join(root, "detector", "dataset", "images"), filepath.Join(root, "detector", "dataset", "images", "train"), filepath.Join(root, "detector", "dataset", "images", "val"), filepath.Join(root, "detector", "dataset", "labels"), filepath.Join(root, "detector", "dataset", "labels", "train"), filepath.Join(root, "detector", "dataset", "labels", "val"), filepath.Join(root, "detector", "runs"), } for _, dir := range dirs { if err := os.MkdirAll(dir, 0755); err != nil { return err } } if err := trainingWriteDetectorDatasetYAML(root); err != nil { return err } return nil } func trainingCreateNextSample() (*TrainingSample, error) { 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 } 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 { // Fallback auf Sekunde 0, falls random second nicht funktioniert. 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) } } 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, } if err := trainingWriteSample(root, sample); err != nil { return nil, err } 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, ".mkv": true, ".webm": true, ".mov": true, ".avi": true, } var files []string err := filepath.WalkDir(doneDir, func(path string, d os.DirEntry, err error) error { if err != nil { return nil } if d.IsDir() { name := strings.ToLower(d.Name()) if name == ".trash" || name == "training" || name == "generated" { return filepath.SkipDir } return nil } ext := strings.ToLower(filepath.Ext(path)) if extOK[ext] { files = append(files, path) } return nil }) if err != nil { return "", err } if len(files) == 0 { return "", errors.New("keine Videos im doneDir gefunden") } return files[rand.Intn(len(files))], nil } func trainingExtractFrame(videoPath string, framePath string, second float64) error { settings := getSettings() ffmpeg := strings.TrimSpace(settings.FFmpegPath) if ffmpeg == "" { ffmpeg = "ffmpeg" } _ = os.Remove(framePath) ss := strconv.FormatFloat(math.Max(0, second), 'f', 3, 64) cmd := exec.Command( ffmpeg, "-hide_banner", "-loglevel", "error", "-ss", ss, "-i", videoPath, "-frames:v", "1", "-q:v", "2", "-y", framePath, ) cmd.SysProcAttr = &syscall.SysProcAttr{ HideWindow: true, CreationFlags: 0x08000000, } out, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("%w: %s", err, strings.TrimSpace(string(out))) } if st, err := os.Stat(framePath); err != nil || st.Size() == 0 { return errors.New("ffmpeg erzeugte kein Frame") } return nil } func trainingPredictFrame(framePath string) TrainingPrediction { settings := getSettings() if !settings.TrainingRecognitionEnabled { return trainingEmptyPrediction("recognition_disabled") } root, err := trainingRootDir() if err != nil { fmt.Println("⚠️ training predict root error:", err) return trainingEmptyPrediction("root_error") } // 1) YOLO erkennt Boxen, Personen, Körperteile, Gegenstände, Kleidung. det := trainingPredictDetector(root, framePath) pred := trainingPredictionFromDetector(det) // 2) Scene-KNN erkennt ausschließlich die Sexposition. scene := trainingPredictScenePosition(root, framePath) pred = trainingApplyScenePosition(pred, scene) return pred } func trainingPredictScenePosition(root string, framePath string) TrainingScenePositionPrediction { python := trainingPythonExe() script := trainingScriptPath("predict_scene_model.py") lrPath := filepath.Join(root, "model", "scene_clip_lr.joblib") knnPath := filepath.Join(root, "model", "scene_clip_knn.joblib") if !fileExistsNonEmpty(lrPath) && !fileExistsNonEmpty(knnPath) { return TrainingScenePositionPrediction{ Available: false, Source: "scene_position_missing", SexPosition: "unknown", SexPositionScore: 0, } } cmd := exec.Command( python, script, "--root", root, "--image", framePath, ) cmd.SysProcAttr = &syscall.SysProcAttr{ HideWindow: true, CreationFlags: 0x08000000, } var stdout strings.Builder var stderr strings.Builder cmd.Stdout = &stdout cmd.Stderr = &stderr err := cmd.Run() outText := strings.TrimSpace(stdout.String()) errText := strings.TrimSpace(stderr.String()) if err != nil { fmt.Println("⚠️ scene position predict failed:", err) fmt.Println(" stdout:", outText) fmt.Println(" stderr:", errText) return TrainingScenePositionPrediction{ Available: false, Source: "scene_position_failed", SexPosition: "unknown", SexPositionScore: 0, } } if outText == "" { fmt.Println("⚠️ scene position predict empty stdout") return TrainingScenePositionPrediction{ Available: false, Source: "scene_position_empty", SexPosition: "unknown", SexPositionScore: 0, } } var scenePred TrainingPrediction if err := json.Unmarshal([]byte(outText), &scenePred); err != nil { fmt.Println("⚠️ scene position json failed:", err) fmt.Println(" stdout:", outText) return TrainingScenePositionPrediction{ Available: false, Source: "scene_position_json_failed", SexPosition: "unknown", SexPositionScore: 0, } } sexPosition := strings.TrimSpace(scenePred.SexPosition) if sexPosition == "" { sexPosition = "unknown" } return TrainingScenePositionPrediction{ Available: scenePred.ModelAvailable, Source: scenePred.Source, SexPosition: sexPosition, SexPositionScore: scenePred.SexPositionScore, } } func trainingApplyScenePosition( pred TrainingPrediction, scene TrainingScenePositionPrediction, ) TrainingPrediction { if pred.SexPosition == "" { pred.SexPosition = "unknown" } if scene.Available { sexPosition := strings.TrimSpace(scene.SexPosition) if sexPosition == "" { sexPosition = "unknown" } pred.SexPosition = sexPosition pred.SexPositionScore = scene.SexPositionScore pred.ModelAvailable = pred.ModelAvailable || scene.Available if pred.Source == "" { pred.Source = scene.Source } else if !strings.Contains(pred.Source, scene.Source) { pred.Source = pred.Source + "+" + scene.Source } } if pred.SexPosition == "" { pred.SexPosition = "unknown" } return pred } func trainingPredictionFromDetector(det TrainingDetectorPrediction) TrainingPrediction { rawBoxes := det.Boxes if rawBoxes == nil { rawBoxes = []TrainingBox{} } pred := TrainingPrediction{ ModelAvailable: det.Available, Source: det.Source, PeopleCount: 0, MaleCount: 0, FemaleCount: 0, UnknownCount: 0, SexPosition: "unknown", SexPositionScore: 0, BodyPartsPresent: []TrainingScoredLabel{}, ObjectsPresent: []TrainingScoredLabel{}, ClothingPresent: []TrainingScoredLabel{}, Boxes: []TrainingBox{}, } if pred.Source == "" { if det.Available { pred.Source = "yolo_detector" } else { pred.Source = "detector_missing" } } grouped, err := trainingGroupedLabels() if err != nil { fmt.Println("⚠️ detector label grouping failed:", err) return pred } peopleSet := map[string]bool{} for _, label := range grouped.People { clean := strings.TrimSpace(label) if clean != "" { peopleSet[clean] = true } } detectionSet := map[string]bool{} for _, label := range grouped.BodyParts { clean := strings.TrimSpace(label) if clean != "" { detectionSet[clean] = true } } for _, label := range grouped.Objects { clean := strings.TrimSpace(label) if clean != "" { detectionSet[clean] = true } } for _, label := range grouped.Clothing { clean := strings.TrimSpace(label) if clean != "" { detectionSet[clean] = true } } visibleBoxes := []TrainingBox{} for _, box := range rawBoxes { if box.Score > 0 && box.Score < 0.25 { continue } label := strings.TrimSpace(box.Label) if label == "" { continue } box.Label = label // Personen erkennen und zählen. // Personenboxen werden jetzt auch sichtbar zurückgegeben, // damit sie im Frontend gezeichnet, verschoben, gelöscht und trainiert werden können. if peopleSet[label] { switch label { case "person_male", "male_person": pred.MaleCount++ case "person_female", "female_person": pred.FemaleCount++ case "person", "person_unknown": pred.UnknownCount++ } visibleBoxes = append(visibleBoxes, box) continue } // Nur Bodyparts/Objects/Clothing bleiben als Boxen sichtbar. if detectionSet[label] { visibleBoxes = append(visibleBoxes, box) } } pred.PeopleCount = pred.MaleCount + pred.FemaleCount + pred.UnknownCount pred.Boxes = visibleBoxes pred.BodyPartsPresent = trainingScoredLabelsFromDetectorBoxes(visibleBoxes, grouped.BodyParts) pred.ObjectsPresent = trainingScoredLabelsFromDetectorBoxes(visibleBoxes, grouped.Objects) pred.ClothingPresent = trainingScoredLabelsFromDetectorBoxes(visibleBoxes, grouped.Clothing) return pred } func trainingPredictDetector(root string, framePath string) TrainingDetectorPrediction { python := trainingPythonExe() script := trainingScriptPath("predict_detector_model.py") modelPath := filepath.Join(root, "detector", "model", "best.pt") if !fileExistsNonEmpty(modelPath) { return TrainingDetectorPrediction{ Available: false, Source: "detector_missing", Boxes: []TrainingBox{}, } } confValues := []string{"0.40", "0.30", "0.20"} best := TrainingDetectorPrediction{ Available: true, Source: "yolo_detector", Boxes: []TrainingBox{}, } for _, conf := range confValues { cmd := exec.Command( python, script, "--root", root, "--image", framePath, "--conf", conf, "--imgsz", "640", ) cmd.SysProcAttr = &syscall.SysProcAttr{ HideWindow: true, CreationFlags: 0x08000000, } var stdout strings.Builder var stderr strings.Builder cmd.Stdout = &stdout cmd.Stderr = &stderr err := cmd.Run() outText := strings.TrimSpace(stdout.String()) errText := strings.TrimSpace(stderr.String()) if errText != "" { fmt.Println("🔎 detector stderr:", errText) } if err != nil { fmt.Println("⚠️ detector predict failed") fmt.Println(" conf:", conf) fmt.Println(" error:", err) fmt.Println(" stdout:", outText) fmt.Println(" stderr:", errText) continue } if outText == "" { fmt.Println("⚠️ detector predict empty stdout") fmt.Println(" conf:", conf) fmt.Println(" stderr:", errText) continue } var det TrainingDetectorPrediction if err := json.Unmarshal([]byte(outText), &det); err != nil { fmt.Println("⚠️ detector predict json failed:", err) fmt.Println(" conf:", conf) fmt.Println(" stdout:", outText) fmt.Println(" stderr:", errText) continue } if det.Boxes == nil { det.Boxes = []TrainingBox{} } if det.Source == "" { det.Source = "yolo_detector" } best = det if len(det.Boxes) > 0 { return det } } if best.Boxes == nil { best.Boxes = []TrainingBox{} } return best } func trainingScoredLabelsFromDetectorBoxes( boxes []TrainingBox, group []string, ) []TrainingScoredLabel { groupSet := map[string]bool{} for _, label := range group { clean := strings.TrimSpace(label) if clean != "" { groupSet[clean] = true } } best := map[string]float64{} for _, box := range boxes { label := strings.TrimSpace(box.Label) if label == "" || !groupSet[label] { continue } score := box.Score if score <= 0 { score = 1 } if old, ok := best[label]; !ok || score > old { best[label] = score } } out := make([]TrainingScoredLabel, 0, len(best)) for label, score := range best { out = append(out, TrainingScoredLabel{ Label: label, Score: score, }) } sort.Slice(out, func(i, j int) bool { if out[i].Score == out[j].Score { return out[i].Label < out[j].Label } return out[i].Score > out[j].Score }) return out } func trainingApplyDetectorToPrediction(pred TrainingPrediction, det TrainingDetectorPrediction) TrainingPrediction { boxes := det.Boxes if boxes == nil { boxes = []TrainingBox{} } grouped, err := trainingGroupedLabels() if err != nil { fmt.Println("⚠️ detector label grouping failed:", err) pred.Boxes = boxes pred.BodyPartsPresent = []TrainingScoredLabel{} pred.ObjectsPresent = []TrainingScoredLabel{} pred.ClothingPresent = []TrainingScoredLabel{} return pred } // Wichtig: // Ab jetzt kommen diese drei Bereiche ausschließlich vom Object Detector. // Kein Scene-KNN-Fallback, damit keine Labels ohne Box angezeigt werden. pred.BodyPartsPresent = trainingScoredLabelsFromDetectorBoxes(boxes, grouped.BodyParts) pred.ObjectsPresent = trainingScoredLabelsFromDetectorBoxes(boxes, grouped.Objects) pred.ClothingPresent = trainingScoredLabelsFromDetectorBoxes(boxes, grouped.Clothing) pred.Boxes = boxes if det.Available { if pred.Source == "" { pred.Source = "yolo_detector" } else { pred.Source = pred.Source + "+yolo_detector" } pred.ModelAvailable = true } return pred } func trainingWriteDetectorSample(root string, sample *TrainingSample, boxes []TrainingBox) error { if sample == nil { return errors.New("sample missing") } classMap, err := trainingDetectorClassMap() if err != nil { return err } srcFrame := filepath.Join(root, "frames", sample.SampleID+".jpg") if _, err := os.Stat(srcFrame); err != nil { return fmt.Errorf("frame missing: %w", err) } // Stabiler 80/20 Split: gleicher sampleID landet immer im gleichen Split. split := trainingStableSplit(sample.SampleID) imgDir := filepath.Join(root, "detector", "dataset", "images", split) lblDir := filepath.Join(root, "detector", "dataset", "labels", split) if err := os.MkdirAll(imgDir, 0755); err != nil { return err } if err := os.MkdirAll(lblDir, 0755); err != nil { return err } dstFrame := filepath.Join(imgDir, sample.SampleID+".jpg") if err := copyFile(srcFrame, dstFrame); err != nil { return err } var lines []string for _, box := range boxes { label := strings.TrimSpace(box.Label) if label == "" { continue } classID, ok := classMap[label] if !ok { continue } x := clamp01(box.X) y := clamp01(box.Y) w := clamp01(box.W) h := clamp01(box.H) if w <= 0 || h <= 0 { continue } // Frontend/Predictor nutzen x/y als linke obere Ecke. // YOLO erwartet x_center/y_center. xCenter := clamp01(x + w/2) yCenter := clamp01(y + h/2) lines = append(lines, fmt.Sprintf( "%d %.6f %.6f %.6f %.6f", classID, xCenter, yCenter, w, h, )) } if len(lines) == 0 { return errors.New("no valid detector boxes") } labelPath := filepath.Join(lblDir, sample.SampleID+".txt") return os.WriteFile(labelPath, []byte(strings.Join(lines, "\n")+"\n"), 0644) } func trainingEnsureDetectorValidationSample(root string) error { trainImages := filepath.Join(root, "detector", "dataset", "images", "train") trainLabels := filepath.Join(root, "detector", "dataset", "labels", "train") valImages := filepath.Join(root, "detector", "dataset", "images", "val") valLabels := filepath.Join(root, "detector", "dataset", "labels", "val") currentVal := trainingCountDetectorSamples(valImages, valLabels) if currentVal >= minDetectorValCount { return nil } if trainingCountDetectorSamples(trainImages, trainLabels) < minDetectorTrainCount { return nil } entries, err := os.ReadDir(trainImages) if err != nil { return nil } if err := os.MkdirAll(valImages, 0755); err != nil { return err } if err := os.MkdirAll(valLabels, 0755); err != nil { return err } copied := 0 needed := minDetectorValCount - currentVal for _, e := range entries { if copied >= needed { break } if e.IsDir() { continue } ext := strings.ToLower(filepath.Ext(e.Name())) if ext != ".jpg" && ext != ".jpeg" && ext != ".png" && ext != ".webp" { continue } id := strings.TrimSuffix(e.Name(), filepath.Ext(e.Name())) srcImage := filepath.Join(trainImages, e.Name()) srcLabel := filepath.Join(trainLabels, id+".txt") if !fileExistsNonEmpty(srcImage) || !fileExistsNonEmpty(srcLabel) { continue } dstImage := filepath.Join(valImages, e.Name()) dstLabel := filepath.Join(valLabels, id+".txt") if fileExistsNonEmpty(dstImage) && fileExistsNonEmpty(dstLabel) { continue } if err := copyFile(srcImage, dstImage); err != nil { return err } if err := copyFile(srcLabel, dstLabel); err != nil { return err } copied++ } return nil } func trainingStableSplit(sampleID string) string { sum := sha1.Sum([]byte(sampleID)) if int(sum[0])%5 == 0 { return "val" } return "train" } func copyFile(src string, dst string) error { b, err := os.ReadFile(src) if err != nil { return err } return os.WriteFile(dst, b, 0644) } func trainingEmptyPrediction(source string) TrainingPrediction { return TrainingPrediction{ ModelAvailable: false, Source: source, PeopleCount: 0, MaleCount: 0, FemaleCount: 0, UnknownCount: 0, SexPosition: "unknown", SexPositionScore: 0, BodyPartsPresent: []TrainingScoredLabel{}, ObjectsPresent: []TrainingScoredLabel{}, ClothingPresent: []TrainingScoredLabel{}, Boxes: []TrainingBox{}, } } func trainingPythonExe() string { v := strings.TrimSpace(os.Getenv("TRAINING_PYTHON")) if v != "" { return v } return "python" } func trainingProjectRoot() string { wd, err := os.Getwd() if err != nil { return "." } if _, err := os.Stat(filepath.Join(wd, "backend", "ml", "predict_detector_model.py")); err == nil { return wd } if _, err := os.Stat(filepath.Join(wd, "ml", "predict_detector_model.py")); err == nil { return filepath.Dir(wd) } parent := filepath.Dir(wd) if _, err := os.Stat(filepath.Join(parent, "backend", "ml", "predict_detector_model.py")); err == nil { return parent } return wd } func trainingScriptPath(name string) string { // 1) Eingebettete Scripts bevorzugen. if dir, err := trainingEmbeddedMLDir(); err == nil { p := filepath.Join(dir, name) if _, err := os.Stat(p); err == nil { return p } } // 2) App-/Backend-relativ wie record_paths.go. if backendRoot, err := trainingBackendRootDir(); err == nil { p := filepath.Join(backendRoot, "ml", name) if _, err := os.Stat(p); err == nil { return p } } // 3) Dev-Fallback. root := trainingProjectRoot() p := filepath.Join(root, "backend", "ml", name) if _, err := os.Stat(p); err == nil { return p } p = filepath.Join("ml", name) if _, err := os.Stat(p); err == nil { return p } return filepath.Join(root, "backend", "ml", name) } func isTempBuildDir(dir string) bool { low := strings.ToLower(filepath.Clean(dir)) return strings.Contains(low, `\appdata\local\temp`) || strings.Contains(low, `\temp\`) || strings.Contains(low, `\tmp\`) || strings.Contains(low, `\go-build`) || strings.Contains(low, `/tmp/`) || strings.Contains(low, `/go-build`) } func trainingBackendRootDir() (string, error) { if script, err := resolvePathRelativeToApp(filepath.Join("ml", "predict_detector_model.py")); err == nil { if st, statErr := os.Stat(script); statErr == nil && !st.IsDir() { return filepath.Dir(filepath.Dir(script)), nil } } if script, err := resolvePathRelativeToApp(filepath.Join("backend", "ml", "predict_detector_model.py")); err == nil { if st, statErr := os.Stat(script); statErr == nil && !st.IsDir() { return filepath.Dir(filepath.Dir(script)), nil } } if dir, err := exeDir(); err == nil && strings.TrimSpace(dir) != "" && !isTempBuildDir(dir) { return dir, nil } wd, err := os.Getwd() if err != nil { return "", err } if _, err := os.Stat(filepath.Join(wd, "ml", "predict_detector_model.py")); err == nil { return wd, nil } if _, err := os.Stat(filepath.Join(wd, "backend", "ml", "predict_detector_model.py")); err == nil { return filepath.Join(wd, "backend"), nil } projectRoot := trainingProjectRoot() return filepath.Join(projectRoot, "backend"), nil } func trainingRootDir() (string, error) { // Optionaler Override, falls du später explizit einen anderen Speicherort willst. // Relative Pfade werden wie in record_paths.go app-relativ aufgelöst. if override := strings.TrimSpace(os.Getenv("TRAINING_ROOT")); override != "" { root, err := resolvePathRelativeToApp(override) if err != nil { return "", err } root, err = filepath.Abs(root) if err != nil { return "", err } if err := os.MkdirAll(root, 0755); err != nil { return "", err } return root, nil } backendRoot, err := trainingBackendRootDir() if err != nil { return "", err } root, err := filepath.Abs(filepath.Join(backendRoot, "generated", "training")) if err != nil { return "", err } if err := os.MkdirAll(root, 0755); err != nil { return "", err } return root, nil } func trainingWriteSample(root string, sample *TrainingSample) error { path := filepath.Join(root, "samples", sample.SampleID+".json") b, err := json.MarshalIndent(sample, "", " ") if err != nil { return err } return os.WriteFile(path, b, 0644) } func trainingReadSample(root string, sampleID string) (*TrainingSample, error) { if strings.Contains(sampleID, "/") || strings.Contains(sampleID, "\\") { return nil, errors.New("invalid sample id") } path := filepath.Join(root, "samples", sampleID+".json") b, err := os.ReadFile(path) if err != nil { return nil, err } var sample TrainingSample if err := json.Unmarshal(b, &sample); err != nil { return nil, err } return &sample, nil } func trainingAppendAnnotation(root string, annotation TrainingAnnotation) error { path := filepath.Join(root, "feedback.jsonl") f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) if err != nil { return err } defer f.Close() b, err := json.Marshal(annotation) if err != nil { return err } if _, err := f.Write(append(b, '\n')); err != nil { return err } return nil } func trainingCountAnnotations(path string) (int, error) { b, err := os.ReadFile(path) if err != nil { return 0, err } text := strings.TrimSpace(string(b)) if text == "" { return 0, nil } return len(strings.Split(text, "\n")), nil } func trainingProbeDurationSeconds(videoPath string) float64 { settings := getSettings() ffmpeg := strings.TrimSpace(settings.FFmpegPath) ffprobe := "ffprobe" if ffmpeg != "" { dir := filepath.Dir(ffmpeg) base := filepath.Base(ffmpeg) if strings.Contains(strings.ToLower(base), "ffmpeg") { ffprobeBase := strings.Replace(base, "ffmpeg", "ffprobe", 1) ffprobe = filepath.Join(dir, ffprobeBase) } } cmd := exec.Command( ffprobe, "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", videoPath, ) cmd.SysProcAttr = &syscall.SysProcAttr{ HideWindow: true, CreationFlags: 0x08000000, } out, err := cmd.Output() if err != nil { return 0 } v, err := strconv.ParseFloat(strings.TrimSpace(string(out)), 64) if err != nil || math.IsNaN(v) || math.IsInf(v, 0) || v <= 0 { return 0 } return v } func trainingRandomSecond(duration float64) float64 { if duration <= 2 { return 0 } minSec := 1.0 maxSec := math.Max(minSec, duration-1) return minSec + rand.Float64()*(maxSec-minSec) } func trainingMakeSampleID(videoPath string, second float64) string { h := sha1.New() _, _ = h.Write([]byte(videoPath)) _, _ = h.Write([]byte("|")) _, _ = h.Write([]byte(strconv.FormatFloat(second, 'f', 3, 64))) _, _ = h.Write([]byte("|")) _, _ = h.Write([]byte(strconv.FormatInt(time.Now().UnixNano(), 10))) return hex.EncodeToString(h.Sum(nil))[:20] } func trainingWriteJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(v) } func trainingWriteError(w http.ResponseWriter, status int, msg string) { trainingWriteJSON(w, status, map[string]any{ "ok": false, "error": msg, }) }