// backend\training.go package main import ( "bufio" "context" "crypto/sha1" "encoding/hex" "encoding/json" "errors" "fmt" "io" "math" "math/rand" "net/http" "net/url" "os" "os/exec" "path/filepath" "runtime" "sort" "strconv" "strings" "sync" "time" goprocess "github.com/shirou/gopsutil/v3/process" ) const trainingUncertainCandidateCount = 10 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"` SexPosition string `json:"sexPosition"` SexPositionScore float64 `json:"sexPositionScore"` PeoplePresent []TrainingScoredLabel `json:"peoplePresent"` BodyPartsPresent []TrainingScoredLabel `json:"bodyPartsPresent"` ObjectsPresent []TrainingScoredLabel `json:"objectsPresent"` ClothingPresent []TrainingScoredLabel `json:"clothingPresent"` Boxes []TrainingBox `json:"boxes"` Persons []TrainingPosePerson `json:"persons,omitempty"` } type TrainingCorrection struct { SexPosition string `json:"sexPosition"` PeoplePresent []string `json:"peoplePresent"` BodyPartsPresent []string `json:"bodyPartsPresent"` ObjectsPresent []string `json:"objectsPresent"` ClothingPresent []string `json:"clothingPresent"` Boxes []TrainingBox `json:"boxes"` PosePersons []TrainingPosePerson `json:"posePersons,omitempty"` } 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"` UncertaintyScore float64 `json:"uncertaintyScore,omitempty"` Prediction TrainingPrediction `json:"prediction"` } type trainingUncertainQueueItem struct { SampleID string `json:"sampleId"` UncertaintyScore float64 `json:"uncertaintyScore"` SourceFile string `json:"sourceFile,omitempty"` CreatedAt string `json:"createdAt,omitempty"` } type trainingUncertainCandidate struct { sample *TrainingSample score float64 } type TrainingFeedbackRequest struct { SampleID string `json:"sampleId"` Accepted bool `json:"accepted"` Negative bool `json:"negative,omitempty"` Correction *TrainingCorrection `json:"correction,omitempty"` Notes string `json:"notes,omitempty"` } type TrainingFeedbackUpdateRequest struct { SampleID string `json:"sampleId"` AnsweredAt string `json:"answeredAt"` Accepted bool `json:"accepted"` Negative bool `json:"negative,omitempty"` Correction *TrainingCorrection `json:"correction,omitempty"` Notes string `json:"notes,omitempty"` } type TrainingSkipRequest struct { SampleID string `json:"sampleId"` } type TrainingTrainRequest struct { Scope string `json:"scope,omitempty"` Targets []string `json:"targets,omitempty"` } type trainingTrainTargets struct { Detector bool Pose bool VideoMAE bool } func trainingAllTrainTargets() trainingTrainTargets { return trainingTrainTargets{ Detector: true, Pose: true, VideoMAE: true, } } func (t trainingTrainTargets) empty() bool { return !t.Detector && !t.Pose && !t.VideoMAE } func (t trainingTrainTargets) list() []string { out := make([]string, 0, 3) if t.Detector { out = append(out, "detector") } if t.Pose { out = append(out, "pose") } if t.VideoMAE { out = append(out, "videomae") } return out } func trainingReadTrainRequest(r *http.Request) (TrainingTrainRequest, error) { var req TrainingTrainRequest if r.Body == nil { return req, nil } body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) if err != nil { return req, err } if strings.TrimSpace(string(body)) == "" { return req, nil } if err := json.Unmarshal(body, &req); err != nil { return req, err } return req, nil } func trainingNormalizeTrainTargets(req TrainingTrainRequest) (trainingTrainTargets, bool, error) { scope := strings.ToLower(strings.TrimSpace(req.Scope)) if scope == "" || scope == "full" || scope == "all" || scope == "complete" { return trainingAllTrainTargets(), false, nil } if scope != "custom" && scope != "selected" && scope != "partial" { return trainingTrainTargets{}, false, fmt.Errorf("unbekannter Trainingsumfang: %s", req.Scope) } var targets trainingTrainTargets for _, raw := range req.Targets { key := strings.ToLower(strings.TrimSpace(raw)) key = strings.ReplaceAll(key, "_", "") key = strings.ReplaceAll(key, "-", "") key = strings.ReplaceAll(key, " ", "") switch key { case "detector", "yolo", "yolo26", "yolo26detector", "box", "boxes", "boxdetection": targets.Detector = true case "pose", "yolo26pose", "posedetection": targets.Pose = true case "videomae", "video", "clip", "scene", "clipanalysis", "clipanalyse": targets.VideoMAE = true case "": continue default: return trainingTrainTargets{}, true, fmt.Errorf("unbekanntes Training: %s", raw) } } if targets.empty() { return trainingTrainTargets{}, true, errors.New("kein Training ausgewählt") } return targets, true, nil } 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"` Negative bool `json:"negative,omitempty"` 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 TrainingKeypoint struct { Name string `json:"name"` X float64 `json:"x"` Y float64 `json:"y"` Conf float64 `json:"conf"` } type TrainingPosePerson struct { Label string `json:"label,omitempty"` Score float64 `json:"score"` Box TrainingBox `json:"box"` Keypoints []TrainingKeypoint `json:"keypoints"` Quality float64 `json:"quality,omitempty"` VisibleKeypoints int `json:"visibleKeypoints,omitempty"` Reliable bool `json:"reliable,omitempty"` } type TrainingPosePrediction struct { Available bool `json:"available"` Source string `json:"source,omitempty"` PersonCount int `json:"personCount"` Persons []TrainingPosePerson `json:"persons"` } 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"` PreviewURL string `json:"previewUrl,omitempty"` Paused bool `json:"paused,omitempty"` PauseReason string `json:"pauseReason,omitempty"` CPUPercent float64 `json:"cpuPercent,omitempty"` TemperatureC float64 `json:"temperatureC,omitempty"` Stage string `json:"stage,omitempty"` StageStartedAt string `json:"stageStartedAt,omitempty"` StageProgress float64 `json:"stageProgress,omitempty"` Epoch int `json:"epoch,omitempty"` Epochs int `json:"epochs,omitempty"` MAP50 float64 `json:"map50,omitempty"` MAP5095 float64 `json:"map5095,omitempty"` Accuracy float64 `json:"accuracy,omitempty"` Loss float64 `json:"loss,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 TrainingModelInfo struct { TrainedAt string `json:"trainedAt,omitempty"` TrainedAtMs int64 `json:"trainedAtMs,omitempty"` Epochs int `json:"epochs,omitempty"` TrainSamples int `json:"trainSamples,omitempty"` ValSamples int `json:"valSamples,omitempty"` Imgsz int `json:"imgsz,omitempty"` Device string `json:"device,omitempty"` MAP50 float64 `json:"map50,omitempty"` MAP5095 float64 `json:"map5095,omitempty"` } type TrainingStatsResponse struct { OK bool `json:"ok"` FeedbackCount int `json:"feedbackCount"` AcceptedCount int `json:"acceptedCount"` CorrectedCount int `json:"correctedCount"` NegativeCount int `json:"negativeCount"` SampleCount int `json:"sampleCount"` BoxCount int `json:"boxCount"` ModelAvailable bool `json:"modelAvailable"` ModelInfo *TrainingModelInfo `json:"modelInfo,omitempty"` DetectorModelAvailable bool `json:"detectorModelAvailable"` DetectorModelInfo *TrainingModelInfo `json:"detectorModelInfo,omitempty"` PoseModelAvailable bool `json:"poseModelAvailable"` PoseModelInfo *TrainingModelInfo `json:"poseModelInfo,omitempty"` VideoMAEModelAvailable bool `json:"videoMAEModelAvailable"` VideoMAEModelInfo *TrainingModelInfo `json:"videoMAEModelInfo,omitempty"` Confidence TrainingConfidence `json:"confidence"` Labels TrainingStatsLabels `json:"labels"` } type trainingProgressEvent struct { Type string `json:"type"` Stage string `json:"stage"` Progress float64 `json:"progress"` // 0..1 Message string `json:"message,omitempty"` Epoch int `json:"epoch,omitempty"` Epochs int `json:"epochs,omitempty"` SampleID string `json:"sampleId,omitempty"` MAP50 *float64 `json:"mAP50,omitempty"` MAP5095 *float64 `json:"mAP5095,omitempty"` Accuracy *float64 `json:"accuracy,omitempty"` Loss *float64 `json:"loss,omitempty"` } type TrainingFeedbackListResponse struct { OK bool `json:"ok"` Items []TrainingAnnotation `json:"items"` Total int `json:"total"` Limit int `json:"limit"` Offset int `json:"offset"` HasMore bool `json:"hasMore"` } func trainingFeedbackListHandler(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 } limit := 30 offset := 0 if raw := strings.TrimSpace(r.URL.Query().Get("limit")); raw != "" { if n, err := strconv.Atoi(raw); err == nil { limit = n } } if raw := strings.TrimSpace(r.URL.Query().Get("offset")); raw != "" { if n, err := strconv.Atoi(raw); err == nil { offset = n } } if limit < 1 { limit = 30 } if limit > 200 { limit = 200 } if offset < 0 { offset = 0 } items, err := trainingReadAnnotations(root) if err != nil { trainingWriteError(w, http.StatusInternalServerError, err.Error()) return } // Neueste zuerst. sort.SliceStable(items, func(i, j int) bool { ai := strings.TrimSpace(items[i].AnsweredAt) aj := strings.TrimSpace(items[j].AnsweredAt) if ai == aj { return items[i].CreatedAt > items[j].CreatedAt } return ai > aj }) query := strings.TrimSpace(r.URL.Query().Get("q")) filter := strings.TrimSpace(r.URL.Query().Get("filter")) items = trainingFilterAnnotations(items, query, filter) total := len(items) if offset > total { offset = total } end := offset + limit if end > total { end = total } page := items[offset:end] trainingWriteJSON(w, http.StatusOK, TrainingFeedbackListResponse{ OK: true, Items: page, Total: total, Limit: limit, Offset: offset, HasMore: end < total, }) } func trainingReadAnnotations(root string) ([]TrainingAnnotation, error) { path := filepath.Join(root, "feedback.jsonl") b, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { return []TrainingAnnotation{}, nil } return nil, err } items := []TrainingAnnotation{} for _, line := range strings.Split(string(b), "\n") { line = strings.TrimSpace(line) if line == "" { continue } var item TrainingAnnotation if err := json.Unmarshal([]byte(line), &item); err != nil { continue } // Alte Einträge robust machen. if strings.TrimSpace(item.FrameURL) == "" && strings.TrimSpace(item.SampleID) != "" { item.FrameURL = "/api/training/frame?id=" + item.SampleID } items = append(items, item) } return items, nil } func trainingFilterAnnotations( items []TrainingAnnotation, query string, filter string, ) []TrainingAnnotation { cleanQuery := strings.ToLower(strings.TrimSpace(query)) cleanFilter := strings.ToLower(strings.TrimSpace(filter)) out := make([]TrainingAnnotation, 0, len(items)) for _, item := range items { switch cleanFilter { case "accepted": if !item.Accepted || item.Negative { continue } case "corrected": if item.Accepted || item.Negative { continue } case "negative": if !item.Negative { continue } } if cleanQuery != "" && !trainingAnnotationMatchesQuery(item, cleanQuery) { continue } out = append(out, item) } return out } func trainingAnnotationMatchesQuery(item TrainingAnnotation, cleanQuery string) bool { effective := trainingEffectiveCorrection(item) parts := []string{ item.SampleID, item.SourceFile, item.SourcePath, item.CreatedAt, item.AnsweredAt, item.Notes, effective.SexPosition, } if item.Negative { parts = append(parts, "negative negativ leer keine labels") } parts = append(parts, effective.PeoplePresent...) parts = append(parts, effective.BodyPartsPresent...) parts = append(parts, effective.ObjectsPresent...) parts = append(parts, effective.ClothingPresent...) for _, box := range effective.Boxes { parts = append(parts, box.Label) } haystack := strings.ToLower(strings.Join(parts, " ")) return strings.Contains(haystack, cleanQuery) } func trainingRemoveSampleFromUncertainQueue(root string, sampleID string) error { sampleID = strings.TrimSpace(sampleID) if sampleID == "" { return nil } items, err := trainingReadUncertainQueue(root) if err != nil { return err } if len(items) == 0 { return nil } next := make([]trainingUncertainQueueItem, 0, len(items)) changed := false for _, item := range items { if strings.TrimSpace(item.SampleID) == sampleID { changed = true continue } next = append(next, item) } if !changed { return nil } return trainingWriteUncertainQueue(root, next) } func trainingReadValidUncertainCandidates(root string) ([]trainingUncertainCandidate, error) { items, err := trainingReadUncertainQueue(root) if err != nil { return nil, err } if len(items) == 0 { return []trainingUncertainCandidate{}, nil } answered, err := trainingAnsweredSampleIDs(root) if err != nil { return nil, err } candidates := make([]trainingUncertainCandidate, 0, len(items)) for _, item := range items { id := strings.TrimSpace(item.SampleID) if id == "" || answered[id] { continue } framePath := filepath.Join(root, "frames", id+".jpg") if !fileExistsNonEmpty(framePath) { continue } sample, err := trainingReadSample(root, id) if err != nil { continue } score := item.UncertaintyScore if score <= 0 { score = sample.UncertaintyScore } if score <= 0 { score = trainingPredictionUncertaintyScore(sample.Prediction) } score = clamp01(score) sample.UncertaintyScore = score candidates = append(candidates, trainingUncertainCandidate{ sample: sample, score: score, }) } sort.Slice(candidates, func(i, j int) bool { if candidates[i].score == candidates[j].score { return candidates[i].sample.CreatedAt < candidates[j].sample.CreatedAt } return candidates[i].score > candidates[j].score }) return candidates, nil } func trainingWriteUncertainCandidateQueue(root string, candidates []trainingUncertainCandidate) error { items := make([]trainingUncertainQueueItem, 0, len(candidates)) for _, candidate := range candidates { if candidate.sample == nil { continue } id := strings.TrimSpace(candidate.sample.SampleID) if id == "" { continue } items = append(items, trainingUncertainQueueItem{ SampleID: id, UncertaintyScore: clamp01(candidate.score), SourceFile: candidate.sample.SourceFile, CreatedAt: candidate.sample.CreatedAt, }) } return trainingWriteUncertainQueue(root, items) } func trainingCreateUncertainCandidateWithProgress( root string, startedAtMs int64, requestID string, stepStart int, stepTotal int, prefix string, ) (*trainingUncertainCandidate, error) { sample, err := trainingCreateNextSampleWithProgressRange( startedAtMs, requestID, stepStart, stepTotal, prefix, ) if err != nil { return nil, err } score := trainingPredictionUncertaintyScore(sample.Prediction) score = clamp01(score) sample.UncertaintyScore = score if err := trainingWriteSample(root, sample); err != nil { return nil, err } return &trainingUncertainCandidate{ sample: sample, score: score, }, nil } func trainingUncertainQueuePath(root string) string { return filepath.Join(root, "uncertain_queue.json") } func trainingReadUncertainQueue(root string) ([]trainingUncertainQueueItem, error) { path := trainingUncertainQueuePath(root) b, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { return []trainingUncertainQueueItem{}, nil } return nil, err } var items []trainingUncertainQueueItem if err := json.Unmarshal(b, &items); err != nil { return []trainingUncertainQueueItem{}, nil } return items, nil } func trainingWriteUncertainQueue(root string, items []trainingUncertainQueueItem) error { path := trainingUncertainQueuePath(root) if len(items) == 0 { _ = os.Remove(path) return nil } b, err := json.MarshalIndent(items, "", " ") if err != nil { return err } return os.WriteFile(path, b, 0644) } func trainingPopQueuedUncertainSample(root string) (*TrainingSample, bool, error) { items, err := trainingReadUncertainQueue(root) if err != nil { return nil, false, err } if len(items) == 0 { return nil, false, nil } answered, err := trainingAnsweredSampleIDs(root) if err != nil { return nil, false, err } remaining := make([]trainingUncertainQueueItem, 0, len(items)) for index, item := range items { id := strings.TrimSpace(item.SampleID) if id == "" || answered[id] { continue } framePath := filepath.Join(root, "frames", id+".jpg") if !fileExistsNonEmpty(framePath) { continue } sample, err := trainingReadSample(root, id) if err != nil { continue } sample.UncertaintyScore = item.UncertaintyScore remaining = append(remaining, items[index+1:]...) if err := trainingWriteUncertainQueue(root, remaining); err != nil { return nil, false, err } return sample, true, nil } _ = trainingWriteUncertainQueue(root, []trainingUncertainQueueItem{}) return nil, false, nil } 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 trainingApplyStageProgress(s *TrainingJobStatus, stage string, localProgress float64) { stage = strings.TrimSpace(stage) if stage == "" { return } if math.IsNaN(localProgress) || math.IsInf(localProgress, 0) { localProgress = 0 } localProgress = clamp01(localProgress) if strings.TrimSpace(s.Stage) != stage { s.StageStartedAt = time.Now().UTC().Format(time.RFC3339) s.Epoch = 0 s.Epochs = 0 s.MAP50 = 0 s.MAP5095 = 0 s.Accuracy = 0 s.Loss = 0 } s.Stage = stage s.StageProgress = localProgress } 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 trainingApplyStageProgress(s, ev.Stage, ev.Progress) if ev.Epoch > 0 { if s.Epoch <= 0 { s.StageStartedAt = time.Now().UTC().Format(time.RFC3339) } s.Epoch = ev.Epoch } if ev.Epochs > 0 { 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 } if ev.Accuracy != nil { s.Accuracy = *ev.Accuracy } if ev.Loss != nil { s.Loss = *ev.Loss } sampleID := strings.TrimSpace(ev.SampleID) if sampleID != "" && !strings.Contains(sampleID, "/") && !strings.Contains(sampleID, "\\") { s.PreviewURL = "/api/training/frame?id=" + url.QueryEscape(sampleID) } }) 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) } type trainingAnalysisStatusEntry struct { updatedAt time.Time payload map[string]any } var trainingAnalysisStatuses = struct { sync.Mutex items map[string]trainingAnalysisStatusEntry }{ items: map[string]trainingAnalysisStatusEntry{}, } const trainingAnalysisStatusTTL = 2 * time.Hour func trainingRememberAnalysisPayload(payload map[string]any) { requestID := strings.TrimSpace(fmt.Sprint(payload["requestId"])) if requestID == "" { return } now := time.Now().UTC() copied := make(map[string]any, len(payload)) for k, v := range payload { copied[k] = v } trainingAnalysisStatuses.Lock() for id, entry := range trainingAnalysisStatuses.items { if !entry.updatedAt.IsZero() && now.Sub(entry.updatedAt) > trainingAnalysisStatusTTL { delete(trainingAnalysisStatuses.items, id) } } trainingAnalysisStatuses.items[requestID] = trainingAnalysisStatusEntry{ updatedAt: now, payload: copied, } trainingAnalysisStatuses.Unlock() } func trainingAnalysisStatusPayload(requestID string) map[string]any { requestID = strings.TrimSpace(requestID) trainingAnalysisStatuses.Lock() defer trainingAnalysisStatuses.Unlock() if requestID != "" { entry, ok := trainingAnalysisStatuses.items[requestID] if !ok || entry.payload == nil { return nil } out := make(map[string]any, len(entry.payload)) for k, v := range entry.payload { out[k] = v } return out } var latest *trainingAnalysisStatusEntry for _, entry := range trainingAnalysisStatuses.items { if entry.payload == nil || !trainingAnalysisValueTruthy(entry.payload["running"]) { continue } if latest == nil || entry.updatedAt.After(latest.updatedAt) { copyEntry := entry latest = ©Entry } } if latest == nil { return nil } out := make(map[string]any, len(latest.payload)) for k, v := range latest.payload { out[k] = v } return out } func trainingAnalysisValueTruthy(value any) bool { switch v := value.(type) { case bool: return v case string: return strings.EqualFold(strings.TrimSpace(v), "true") || strings.TrimSpace(v) == "1" case int: return v != 0 case int64: return v != 0 case float64: return v != 0 default: return false } } func trainingPublishAnalysisPayload(payload map[string]any) { trainingRememberAnalysisPayload(payload) b, err := json.Marshal(payload) if err != nil { return } publishSSE("analysisProgress", b) } func trainingPublishAnalysisStep( requestID string, startedAtMs int64, current int, total int, sourceFile string, message string, ) { trainingPublishAnalysisStepWithPreview( requestID, startedAtMs, current, total, sourceFile, "", message, ) } func trainingPublishAnalysisStepWithPreview( requestID string, startedAtMs int64, current int, total int, sourceFile string, previewURL string, message string, ) { progress := 0.0 if total > 0 { progress = float64(current) / float64(total) } payload := map[string]any{ "type": "analysis_progress", "scope": "training", "requestId": requestID, "running": true, "phase": "running", "progress": progress, "startedAtMs": startedAtMs, "current": current, "total": total, "sourceFile": strings.TrimSpace(sourceFile), "message": strings.TrimSpace(message), "ts": time.Now().UnixMilli(), } if strings.TrimSpace(previewURL) != "" { payload["previewUrl"] = strings.TrimSpace(previewURL) } trainingPublishAnalysisPayload(payload) } func trainingPublishAnalysisStarted( requestID string, total int, sourceFile string, message string, ) int64 { return trainingPublishAnalysisStartedWithPreview( requestID, total, sourceFile, "", message, ) } func trainingPublishAnalysisStartedWithPreview( requestID string, total int, sourceFile string, previewURL string, message string, ) int64 { startedAtMs := time.Now().UnixMilli() payload := map[string]any{ "type": "analysis_progress", "scope": "training", "requestId": requestID, "running": true, "phase": "starting", "progress": 0, "startedAtMs": startedAtMs, "current": 0, "total": total, "sourceFile": strings.TrimSpace(sourceFile), "message": strings.TrimSpace(message), "ts": time.Now().UnixMilli(), } if strings.TrimSpace(previewURL) != "" { payload["previewUrl"] = strings.TrimSpace(previewURL) } trainingPublishAnalysisPayload(payload) return startedAtMs } func trainingPublishAnalysisFinished( requestID string, startedAtMs int64, total int, sourceFile string, message string, ) { finishedAtMs := time.Now().UnixMilli() durationMs := finishedAtMs - startedAtMs if durationMs < 0 { durationMs = 0 } payload := map[string]any{ "type": "analysis_progress", "scope": "training", "requestId": requestID, "running": false, "phase": "done", "progress": 1, "startedAtMs": startedAtMs, "finishedAtMs": finishedAtMs, "durationMs": durationMs, "current": total, "total": total, "sourceFile": strings.TrimSpace(sourceFile), "message": strings.TrimSpace(message), "ts": time.Now().UnixMilli(), } trainingPublishAnalysisPayload(payload) } func trainingPublishAnalysisError( requestID string, startedAtMs int64, sourceFile string, message string, err error, ) { finishedAtMs := time.Now().UnixMilli() durationMs := finishedAtMs - startedAtMs if durationMs < 0 { durationMs = 0 } errText := "" if err != nil { errText = err.Error() } payload := map[string]any{ "type": "analysis_progress", "scope": "training", "requestId": requestID, "running": false, "phase": "error", "progress": 0, "startedAtMs": startedAtMs, "finishedAtMs": finishedAtMs, "durationMs": durationMs, "sourceFile": strings.TrimSpace(sourceFile), "message": strings.TrimSpace(message), "error": errText, "ts": time.Now().UnixMilli(), } trainingPublishAnalysisPayload(payload) } func trainingRunCommandStreaming( ctx context.Context, python string, script string, onLine func(line string) bool, args ...string, ) (string, error) { cmdArgs := append([]string{script}, args...) cmd := exec.Command(python, cmdArgs...) hideCommandWindow(cmd) opts := trainingRuntimeOptionsFromSettings() cmd.Env = trainingCommandEnv(opts) prepareTrainingCommandForCancel(cmd) applyTrainingLowPriorityBeforeStart(cmd, opts.LowPriority) stdout, err := cmd.StdoutPipe() if err != nil { return "", err } stderr, err := cmd.StderrPipe() if err != nil { return "", err } if ctx.Err() != nil { return "", errTrainingCancelled } if err := cmd.Start(); err != nil { return "", err } applyTrainingLowPriorityAfterStart(cmd, opts.LowPriority) monitorCtx, stopResourcePauseMonitor := context.WithCancel(ctx) resumeResourcePause := startTrainingResourcePauseMonitor(monitorCtx, cmd, opts) 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)) }() waitCh := make(chan error, 1) go func() { waitCh <- cmd.Wait() }() select { case err = <-waitCh: case <-ctx.Done(): resumeResourcePause() terminateTrainingCommand(cmd) err = <-waitCh } stopResourcePauseMonitor() resumeResourcePause() wg.Wait() outMu.Lock() out := strings.Join(lines, "\n") outMu.Unlock() if ctx.Err() != nil { return strings.TrimSpace(out), errTrainingCancelled } return strings.TrimSpace(out), err } const minTrainingFeedbackCount = 5 const minDetectorTrainCount = 20 const minDetectorValCount = 3 const minPoseTrainCount = 20 const minPoseValCount = 3 const trainingPoseKeypointCount = 17 const trainingPoseKeypointMinConfidence = 0.20 const trainingPoseReliableMinScore = 0.30 const trainingPoseReliableMinKeypoints = 6 const trainingPoseReliableMinQuality = 0.45 const trainingPositionContextMinScore = 0.22 const trainingPositionContextMaxScore = 0.44 const trainingPositionContextBoostWeight = 0.60 const trainingPoseConfirmingContextMinScore = 0.14 const trainingPoseUnconfirmedMaxScore = 0.38 const trainingPoseStrongUnconfirmedMinScore = 0.70 const trainingPoseStrongUnconfirmedMaxScore = 0.46 var errTrainingCancelled = errors.New("training cancelled") const ( trainingPerformanceAuto = "auto" trainingPerformanceEco = "eco" trainingPerformanceBalanced = "balanced" trainingPerformancePerformance = "performance" trainingPerformanceCustom = "custom" ) type trainingRuntimeOptions struct { PerformanceMode string PowerSaveMode bool CPUCoreCount int CPUThreads int Workers int YoloBatchSize int LowPriority bool VideoMAEEnabled bool AutoPauseEnabled bool AutoPauseCPUPercent int AutoPauseTemperatureC int } func normalizeTrainingPerformanceMode(raw string) string { switch strings.ToLower(strings.TrimSpace(raw)) { case "", "auto", "automatic", "automatisch": return trainingPerformanceAuto case "eco", "schonend", "schonmodus", "powersave", "power-save", "power_save": return trainingPerformanceEco case "balanced", "ausgewogen", "normal": return trainingPerformanceBalanced case "performance", "leistung", "fast", "schnell": return trainingPerformancePerformance case "custom", "manual", "manuell": return trainingPerformanceCustom default: return trainingPerformanceAuto } } func clampTrainingInt(value int, minValue int, maxValue int) int { if value < minValue { return minValue } if value > maxValue { return maxValue } return value } func trainingPresetValues(mode string, cpuCores int) (threads int, workers int, yoloBatch int, lowPriority bool) { cpuCores = clampTrainingInt(cpuCores, 1, 256) switch normalizeTrainingPerformanceMode(mode) { case trainingPerformanceEco: threads = 1 if cpuCores >= 4 { threads = 2 } workers = 0 yoloBatch = 1 if cpuCores >= 6 { yoloBatch = 2 } lowPriority = true case trainingPerformancePerformance: threads = clampTrainingInt(cpuCores-1, 2, 16) workers = clampTrainingInt(threads/2, 2, 4) yoloBatch = 4 if cpuCores >= 8 { yoloBatch = 8 } if cpuCores >= 16 { yoloBatch = 12 } lowPriority = false default: threads = clampTrainingInt(cpuCores/2, 2, 8) workers = clampTrainingInt(threads/2, 1, 2) yoloBatch = 2 if cpuCores >= 8 { yoloBatch = 4 } if cpuCores >= 16 { yoloBatch = 6 } lowPriority = false } return threads, workers, yoloBatch, lowPriority } func trainingRuntimeOptionsFromSettings() trainingRuntimeOptions { s := getSettings() return trainingRuntimeOptionsFromRecorderSettings(s) } func trainingRuntimeOptionsFromRecorderSettings(s RecorderSettings) trainingRuntimeOptions { normalizeTrainingSettings(&s) cpuCores := runtime.NumCPU() if cpuCores < 1 { cpuCores = 1 } mode := normalizeTrainingPerformanceMode(s.TrainingPerformanceMode) if mode == trainingPerformanceAuto { if cpuCores <= 4 { mode = trainingPerformanceEco } else if cpuCores >= 12 { mode = trainingPerformancePerformance } else { mode = trainingPerformanceBalanced } } presetThreads, presetWorkers, presetBatch, presetLowPriority := trainingPresetValues(mode, cpuCores) opts := trainingRuntimeOptions{ PerformanceMode: mode, PowerSaveMode: mode == trainingPerformanceEco, CPUCoreCount: cpuCores, CPUThreads: presetThreads, Workers: presetWorkers, YoloBatchSize: presetBatch, LowPriority: presetLowPriority, VideoMAEEnabled: s.TrainingVideoMAEEnabled, AutoPauseEnabled: s.TrainingAutoPauseEnabled, AutoPauseCPUPercent: s.TrainingAutoPauseCPUPercent, AutoPauseTemperatureC: s.TrainingAutoPauseTemperatureC, } if mode == trainingPerformanceCustom { opts.PowerSaveMode = s.TrainingPowerSaveMode if s.TrainingCPUThreads > 0 { opts.CPUThreads = clampTrainingInt(s.TrainingCPUThreads, 1, cpuCores) } else { opts.CPUThreads = 0 } opts.Workers = clampTrainingInt(s.TrainingWorkers, 0, 16) opts.YoloBatchSize = clampTrainingInt(s.TrainingYoloBatchSize, 0, 64) opts.LowPriority = s.TrainingLowPriority } else { if opts.CPUThreads > cpuCores { opts.CPUThreads = cpuCores } } return opts } func trainingYoloEarlyStoppingPatience(epochs int) int { epochs = clampTrainingInt(epochs, 1, 300) patience := epochs / 4 if patience < 5 { patience = 5 } if patience > 20 { patience = 20 } if patience > epochs { patience = epochs } return patience } func trainingCommandEnv(opts trainingRuntimeOptions) []string { env := os.Environ() if opts.CPUThreads <= 0 { return env } threads := strconv.Itoa(opts.CPUThreads) limitVars := []string{ "OMP_NUM_THREADS", "MKL_NUM_THREADS", "OPENBLAS_NUM_THREADS", "NUMEXPR_NUM_THREADS", "VECLIB_MAXIMUM_THREADS", "TORCH_NUM_THREADS", } for _, name := range limitVars { env = append(env, name+"="+threads) } return env } type trainingResourceSnapshot struct { CPUPercent float64 TemperatureC float64 TemperatureAvailable bool } func trainingRoundMetric(v float64) float64 { if math.IsNaN(v) || math.IsInf(v, 0) || v < 0 { return 0 } return math.Round(v*10) / 10 } func trainingReadResourceSnapshot() trainingResourceSnapshot { temp, tempOK := getMaxCPUTemperatureC() return trainingResourceSnapshot{ CPUPercent: trainingRoundMetric(getLastCPUUsage()), TemperatureC: trainingRoundMetric(temp), TemperatureAvailable: tempOK, } } func trainingPauseReasonForResources(opts trainingRuntimeOptions, snap trainingResourceSnapshot) (string, bool) { if !opts.AutoPauseEnabled { return "", false } reasons := []string{} if opts.AutoPauseCPUPercent > 0 && snap.CPUPercent >= float64(opts.AutoPauseCPUPercent) { reasons = append( reasons, fmt.Sprintf("CPU %.0f%% >= %d%%", snap.CPUPercent, opts.AutoPauseCPUPercent), ) } if opts.AutoPauseTemperatureC > 0 && snap.TemperatureAvailable && snap.TemperatureC >= float64(opts.AutoPauseTemperatureC) { reasons = append( reasons, fmt.Sprintf("Temperatur %.0f°C >= %d°C", snap.TemperatureC, opts.AutoPauseTemperatureC), ) } if len(reasons) == 0 { return "", false } return strings.Join(reasons, ", "), true } func trainingResourcesRecovered(opts trainingRuntimeOptions, snap trainingResourceSnapshot) bool { if !opts.AutoPauseEnabled { return true } if opts.AutoPauseCPUPercent > 0 { resumeCPU := float64(opts.AutoPauseCPUPercent - 15) if resumeCPU < 50 { resumeCPU = 50 } if snap.CPUPercent > resumeCPU { return false } } if opts.AutoPauseTemperatureC > 0 && snap.TemperatureAvailable { resumeTemp := float64(opts.AutoPauseTemperatureC - 5) if resumeTemp < 40 { resumeTemp = 40 } if snap.TemperatureC > resumeTemp { return false } } return true } func trainingProcessTree(pid int32) []*goprocess.Process { if pid <= 0 { return nil } root, err := goprocess.NewProcess(pid) if err != nil || root == nil { return nil } seen := map[int32]bool{} var out []*goprocess.Process var walk func(*goprocess.Process) walk = func(p *goprocess.Process) { if p == nil || seen[p.Pid] { return } seen[p.Pid] = true out = append(out, p) children, err := p.Children() if err != nil { return } for _, child := range children { walk(child) } } walk(root) return out } func trainingSuspendProcessTree(cmd *exec.Cmd) error { if cmd == nil || cmd.Process == nil || cmd.Process.Pid <= 0 { return nil } procs := trainingProcessTree(int32(cmd.Process.Pid)) if len(procs) == 0 { return nil } var firstErr error success := 0 for i := len(procs) - 1; i >= 0; i-- { if err := procs[i].Suspend(); err != nil { if firstErr == nil { firstErr = err } continue } success++ } if success > 0 { return nil } return firstErr } func trainingResumeProcessTree(cmd *exec.Cmd) error { if cmd == nil || cmd.Process == nil || cmd.Process.Pid <= 0 { return nil } procs := trainingProcessTree(int32(cmd.Process.Pid)) if len(procs) == 0 { return nil } var firstErr error success := 0 for _, proc := range procs { if err := proc.Resume(); err != nil { if firstErr == nil { firstErr = err } continue } success++ } if success > 0 { return nil } return firstErr } func startTrainingResourcePauseMonitor(ctx context.Context, cmd *exec.Cmd, opts trainingRuntimeOptions) func() { if !opts.AutoPauseEnabled || cmd == nil || cmd.Process == nil { return func() {} } const ( checkInterval = 5 * time.Second requiredHighSamples = 2 requiredCoolSamples = 2 minPauseDuration = 45 * time.Second ) var pauseMu sync.Mutex paused := false previousStep := "" highSamples := 0 coolSamples := 0 pauseStartedAt := time.Time{} loggedSuspendError := false resumeNow := func(reason string) { pauseMu.Lock() if !paused { pauseMu.Unlock() return } err := trainingResumeProcessTree(cmd) paused = false restoreStep := strings.TrimSpace(previousStep) previousStep = "" pauseMu.Unlock() if err != nil { appLogln("⚠️ Training konnte nach Ressourcenpause nicht sauber fortgesetzt werden:", err) } if strings.TrimSpace(reason) != "" { appLogln(reason) } trainingSetJobStatus(func(s *TrainingJobStatus) { s.Paused = false s.PauseReason = "" s.CPUPercent = 0 s.TemperatureC = 0 s.Message = "" if restoreStep != "" && s.Running { s.Step = restoreStep } }) } go func() { ticker := time.NewTicker(checkInterval) defer ticker.Stop() for { select { case <-ctx.Done(): resumeNow("Training-Ressourcenpause wurde beendet.") return case <-ticker.C: } snap := trainingReadResourceSnapshot() pauseMu.Lock() isPaused := paused pauseAge := time.Since(pauseStartedAt) pauseMu.Unlock() if !isPaused { reason, shouldPause := trainingPauseReasonForResources(opts, snap) if !shouldPause { highSamples = 0 continue } highSamples++ if highSamples < requiredHighSamples { continue } pauseMu.Lock() if paused { pauseMu.Unlock() continue } currentStatus := trainingGetJobStatus() previousStep = strings.TrimSpace(currentStatus.Step) if previousStep == "" { previousStep = "Training läuft..." } if err := trainingSuspendProcessTree(cmd); err != nil { pauseMu.Unlock() highSamples = 0 if !loggedSuspendError { loggedSuspendError = true appLogln("⚠️ Training-Ressourcenpause konnte Prozess nicht pausieren:", err) } continue } paused = true pauseStartedAt = time.Now() coolSamples = 0 pauseMu.Unlock() appLogln("⏸ Training pausiert wegen Ressourcenlimit:", reason) trainingSetJobStatus(func(s *TrainingJobStatus) { s.Paused = true s.PauseReason = reason s.CPUPercent = snap.CPUPercent if snap.TemperatureAvailable { s.TemperatureC = snap.TemperatureC } else { s.TemperatureC = 0 } s.Step = "Training pausiert: " + reason s.Message = "Training wird automatisch fortgesetzt, sobald CPU/Temperatur wieder im grünen Bereich sind." }) continue } trainingSetJobStatus(func(s *TrainingJobStatus) { if !s.Running || !s.Paused { return } s.CPUPercent = snap.CPUPercent if snap.TemperatureAvailable { s.TemperatureC = snap.TemperatureC } }) if pauseAge < minPauseDuration || !trainingResourcesRecovered(opts, snap) { coolSamples = 0 continue } coolSamples++ if coolSamples < requiredCoolSamples { continue } highSamples = 0 coolSamples = 0 resumeNow("▶ Training nach Ressourcenpause fortgesetzt.") } }() return func() { resumeNow("") } } var trainingJob = struct { mu sync.Mutex status TrainingJobStatus cancel context.CancelFunc }{} 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 trainingStartJob(cancel context.CancelFunc) { trainingJob.mu.Lock() startedAt := time.Now().UTC().Format(time.RFC3339) trainingJob.status = TrainingJobStatus{ Running: true, Progress: 5, Step: "Training wird vorbereitet…", StartedAt: startedAt, StageStartedAt: startedAt, } trainingJob.cancel = cancel snapshot := trainingJob.status trainingJob.mu.Unlock() trainingPublishJobStatus(snapshot) } func trainingClearJobCancel() { trainingJob.mu.Lock() trainingJob.cancel = nil trainingJob.mu.Unlock() } func trainingCleanupPartialDetectorTraining(root string) error { // Löscht nur temporäre Trainingsläufe. // Feedback, Samples, Frames und Dataset bleiben erhalten. runsDir := filepath.Join(root, "detector", "runs") if err := os.RemoveAll(runsDir); err != nil { return err } return os.MkdirAll(runsDir, 0755) } func trainingFinishCancelled(root string) { cleanupErr := trainingCleanupPartialDetectorTraining(root) 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.Step = "Training abgebrochen." s.Message = "Training wurde abgebrochen. Temporäre Trainingsausgaben wurden gelöscht." s.Error = "" s.FinishedAt = finishedAt.Format(time.RFC3339) s.DurationMs = durationMs s.PreviewURL = "" s.Paused = false s.PauseReason = "" s.CPUPercent = 0 s.TemperatureC = 0 if cleanupErr != nil { s.Message = "Training wurde abgebrochen, aber temporäre Trainingsausgaben konnten nicht vollständig gelöscht werden." s.Error = cleanupErr.Error() } }) trainingClearJobCancel() } func trainingRunCommand(python string, script string, args ...string) (string, error) { cmdArgs := append([]string{script}, args...) cmd := exec.Command(python, cmdArgs...) hideCommandWindow(cmd) out, err := cmd.CombinedOutput() return strings.TrimSpace(string(out)), err } type trainingModelResolution struct { BestPath string EffectivePath string Source string TrainedExists bool EffectiveExists bool } func trainingResolveModel( root string, kind string, trainedSource string, ) trainingModelResolution { bestPath := filepath.Join(root, kind, "model", "best.pt") if fileExistsNonEmpty(bestPath) { return trainingModelResolution{ BestPath: bestPath, EffectivePath: bestPath, Source: trainedSource, TrainedExists: true, EffectiveExists: true, } } return trainingModelResolution{ BestPath: bestPath, EffectivePath: bestPath, Source: kind + "_missing", TrainedExists: false, EffectiveExists: false, } } func trainingResolveDetectorModel(root string) trainingModelResolution { return trainingResolveModel( root, "detector", "yolo26_detector", ) } func trainingResolvePoseModel(root string) trainingModelResolution { res := trainingResolveModel( root, "pose", "yolo_pose", ) if res.EffectiveExists { return res } if poseBase, err := embeddedPoseModelPath(); err == nil && fileExistsNonEmpty(poseBase) { res.EffectivePath = poseBase res.Source = "yolo26_pose_base" res.EffectiveExists = true } return res } 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()) } const trainingImportVideoDefaultFrameCount = 10 const trainingImportVideoMaxFrameCount = 30 type TrainingImportVideoRequest struct { JobID string `json:"jobId"` Output string `json:"output"` Count int `json:"count"` AnalysisRequestID string `json:"analysisRequestId"` } type TrainingImportVideoResponse struct { OK bool `json:"ok"` Accepted bool `json:"accepted,omitempty"` Running bool `json:"running,omitempty"` RequestID string `json:"requestId,omitempty"` Count int `json:"count"` Sample *TrainingSample `json:"sample,omitempty"` Samples []TrainingSample `json:"samples,omitempty"` Errors []string `json:"errors,omitempty"` Error string `json:"error,omitempty"` Analysis map[string]any `json:"analysis,omitempty"` } type TrainingNextRequest struct { ForceNew bool RefreshPrediction bool PreferUncertain bool AnalysisRequestID string ExcludeIDs map[string]bool } type TrainingNextResponse struct { OK bool `json:"ok"` Accepted bool `json:"accepted,omitempty"` Running bool `json:"running,omitempty"` RequestID string `json:"requestId,omitempty"` Sample *TrainingSample `json:"sample,omitempty"` Error string `json:"error,omitempty"` Analysis map[string]any `json:"analysis,omitempty"` } type trainingNextJobState struct { requestID string startedAt time.Time finishedAt time.Time running bool statusCode int response *TrainingNextResponse errorText string } var trainingNextJobs = struct { sync.Mutex items map[string]*trainingNextJobState }{ items: map[string]*trainingNextJobState{}, } const trainingNextJobTTL = 2 * time.Hour type trainingImportVideoJobState struct { requestID string startedAt time.Time finishedAt time.Time running bool statusCode int response *TrainingImportVideoResponse errorText string } var trainingImportVideoJobs = struct { sync.Mutex items map[string]*trainingImportVideoJobState }{ items: map[string]*trainingImportVideoJobState{}, } const trainingImportVideoJobTTL = 2 * time.Hour func trainingCleanImportVideoCount(count int) int { if count <= 0 { return trainingImportVideoDefaultFrameCount } if count > trainingImportVideoMaxFrameCount { return trainingImportVideoMaxFrameCount } return count } func trainingSupportedImportVideo(path string) bool { switch strings.ToLower(filepath.Ext(path)) { case ".mp4", ".m4v", ".mov", ".mkv", ".webm": return true default: return false } } func trainingGeneratedAssetIDCandidatesForVideo(videoPath string) []string { videoPath = strings.TrimSpace(videoPath) if videoPath == "" { return nil } out := []string{} seen := map[string]bool{} add := func(id string) { id = stripHotPrefix(strings.TrimSpace(id)) if id == "" || id == "." || id == ".." || strings.Contains(id, "/") || strings.Contains(id, "\\") { return } if seen[id] { return } seen[id] = true out = append(out, id) } // Fall 1: // Video liegt selbst unter /generated//... // // Beispiel: // C:\app\generated\abc123\video.mp4 // => abc123 slashPath := filepath.ToSlash(filepath.Clean(videoPath)) parts := strings.Split(slashPath, "/") for i := 0; i+1 < len(parts); i++ { if strings.EqualFold(parts[i], "generated") { add(parts[i+1]) } } // Fall 2: // Video liegt z.B. in done/keep, aber generated//preview.jpg // basiert auf dem Dateinamen ohne Extension. // // Beispiel: // done/keep/model/abc123.mp4 // => generated/abc123/preview.jpg base := filepath.Base(videoPath) stem := strings.TrimSuffix(base, filepath.Ext(base)) add(stem) return out } func trainingGeneratedPreviewPathForAssetID(assetID string) (string, bool) { assetID = stripHotPrefix(strings.TrimSpace(assetID)) if assetID == "" || assetID == "." || assetID == ".." || strings.Contains(assetID, "/") || strings.Contains(assetID, "\\") { return "", false } previewPath, err := resolvePathRelativeToApp( filepath.Join("generated", assetID, "preview.jpg"), ) if err != nil { return "", false } if !fileExistsNonEmpty(previewPath) { return "", false } return previewPath, true } func trainingPreviewPathForVideo(videoPath string) (string, bool) { for _, assetID := range trainingGeneratedAssetIDCandidatesForVideo(videoPath) { if previewPath, ok := trainingGeneratedPreviewPathForAssetID(assetID); ok { return previewPath, true } } return "", false } func trainingPreviewURLForVideoPath(videoPath string) string { videoPath = strings.TrimSpace(videoPath) if videoPath == "" { return "" } if !trainingSupportedImportVideo(videoPath) { return "" } return "/api/training/video-preview?output=" + url.QueryEscape(videoPath) } func trainingPreviewAssetIDForVideo(videoPath string) string { candidates := trainingGeneratedAssetIDCandidatesForVideo(videoPath) for _, assetID := range candidates { if _, ok := trainingGeneratedPreviewPathForAssetID(assetID); ok { return assetID } } for _, assetID := range candidates { if _, err := findFinishedFileByID(assetID); err == nil { return assetID } } if len(candidates) > 0 { return candidates[0] } return "" } func trainingVideoPreviewHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet && r.Method != http.MethodHead { trainingWriteError(w, http.StatusMethodNotAllowed, "method not allowed") return } outPath := strings.TrimSpace(r.URL.Query().Get("output")) if outPath == "" { trainingWriteError(w, http.StatusBadRequest, "output missing") return } if !trainingSupportedImportVideo(outPath) { trainingWriteError(w, http.StatusBadRequest, "unsupported video type") return } st, err := os.Stat(outPath) if err != nil || st == nil || st.IsDir() || st.Size() <= 0 { trainingWriteError(w, http.StatusNotFound, "video not found") return } // Fast path: Wenn /generated//preview.jpg schon existiert, direkt ausliefern. if previewPath, ok := trainingPreviewPathForVideo(outPath); ok { w.Header().Set("Cache-Control", "no-store") servePreviewJPGFile(w, r, previewPath) return } assetID := trainingPreviewAssetIDForVideo(outPath) if assetID == "" { trainingWriteError(w, http.StatusNotFound, "preview asset id not found") return } // Wichtig: // Nicht file=preview.jpg setzen. // Ohne file=... darf recordPreviewWithBase die Preview bei Bedarf erzeugen. r2 := r.Clone(r.Context()) u := *r.URL q := u.Query() q.Set("id", assetID) q.Del("output") q.Del("file") q.Del("fallbackOnly") u.RawQuery = q.Encode() r2.URL = &u recordPreviewWithBase(w, r2, "/api/training/video-preview") } func trainingFrameSecondsForVideo(duration float64, count int) []float64 { count = trainingCleanImportVideoCount(count) if duration <= 0 { return []float64{0} } if duration <= 2 { return []float64{0} } minSec := 0.5 maxSec := duration - 0.5 if maxSec <= minSec { return []float64{0} } out := make([]float64, 0, count) // Lokaler RNG, damit jeder Import neue Frames bekommt. rng := rand.New(rand.NewSource(time.Now().UnixNano())) // Mindestabstand zwischen Frames, damit nicht mehrfach fast dieselbe Stelle kommt. minDistance := 0.4 // Bei sehr kurzen Videos Abstand automatisch verkleinern. availableRange := maxSec - minSec if availableRange/float64(count) < minDistance { minDistance = math.Max(0.1, availableRange/float64(count+1)) } const maxAttempts = 4000 for attempts := 0; len(out) < count && attempts < maxAttempts; attempts++ { sec := minSec + rng.Float64()*(maxSec-minSec) // Auf 0.1s runden, damit IDs/Logs lesbar bleiben. sec = math.Round(sec*10) / 10 tooClose := false for _, existing := range out { if math.Abs(sec-existing) < minDistance { tooClose = true break } } if tooClose { continue } out = append(out, sec) } // Fallback, falls ein extrem kurzes Video nicht genug unterschiedliche Random-Punkte hergibt. for len(out) < count { sec := minSec + rng.Float64()*(maxSec-minSec) sec = math.Round(sec*10) / 10 out = append(out, sec) } sort.Float64s(out) if len(out) == 0 { out = append(out, 0) } return out } func trainingRunImportVideoRequest(req TrainingImportVideoRequest) (TrainingImportVideoResponse, int, string) { outPath := strings.TrimSpace(req.Output) if outPath == "" { msg := "output missing" return TrainingImportVideoResponse{OK: false, Error: msg}, http.StatusBadRequest, msg } if !trainingSupportedImportVideo(outPath) { msg := "unsupported video type" return TrainingImportVideoResponse{OK: false, Error: msg}, http.StatusBadRequest, msg } fi, err := os.Stat(outPath) if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 { if err == nil { err = errors.New("video file missing or empty") } msg := "video not found: " + err.Error() return TrainingImportVideoResponse{OK: false, Error: msg}, http.StatusBadRequest, msg } duration := trainingProbeDurationSeconds(outPath) if duration <= 0 { msg := "Videolaenge konnte nicht bestimmt werden" return TrainingImportVideoResponse{OK: false, Error: msg}, http.StatusBadRequest, msg } root, err := trainingRootDir() if err != nil { msg := err.Error() return TrainingImportVideoResponse{OK: false, Error: msg}, http.StatusInternalServerError, msg } if err := trainingEnsureDetectorDirs(root); err != nil { msg := err.Error() return TrainingImportVideoResponse{OK: false, Error: msg}, http.StatusInternalServerError, msg } if err := os.MkdirAll(filepath.Join(root, "frames"), 0755); err != nil { msg := err.Error() return TrainingImportVideoResponse{OK: false, Error: msg}, http.StatusInternalServerError, msg } if err := os.MkdirAll(filepath.Join(root, "samples"), 0755); err != nil { msg := err.Error() return TrainingImportVideoResponse{OK: false, Error: msg}, http.StatusInternalServerError, msg } seconds := trainingFrameSecondsForVideo(duration, req.Count) sourceFile := filepath.Base(outPath) previewURL := "" sourceFileWithFrame := func(index int) string { return fmt.Sprintf("%s (%d / %d)", sourceFile, index+1, len(seconds)) } requestID := strings.TrimSpace(req.AnalysisRequestID) if requestID == "" { requestID = trainingMakeSampleID(outPath, float64(time.Now().UnixNano())) } totalSteps := len(seconds) * 3 if totalSteps < 1 { totalSteps = 1 } startedAtMs := trainingPublishAnalysisStartedWithPreview( requestID, totalSteps, sourceFile, previewURL, "Video wird ins Training uebernommen...", ) var sourceSizeBytes int64 if st, err := os.Stat(outPath); err == nil && st != nil && !st.IsDir() { sourceSizeBytes = st.Size() } samples := make([]TrainingSample, 0, len(seconds)) errs := []string{} for i, second := range seconds { stepBase := i * 3 trainingPublishAnalysisStepWithPreview( requestID, startedAtMs, stepBase+1, totalSteps, sourceFileWithFrame(i), previewURL, fmt.Sprintf("Frame %d/%d wird extrahiert...", i+1, len(seconds)), ) id := trainingMakeSampleID(outPath, second) framePath := filepath.Join(root, "frames", id+".jpg") if err := trainingExtractFrame(outPath, framePath, second); err != nil { errs = append(errs, fmt.Sprintf("Frame bei %.1fs: %v", second, err)) continue } previewURL = "/api/training/frame?id=" + url.QueryEscape(id) trainingPublishAnalysisStepWithPreview( requestID, startedAtMs, stepBase+2, totalSteps, sourceFileWithFrame(i), previewURL, fmt.Sprintf("Frame %d/%d wird analysiert...", i+1, len(seconds)), ) prediction := trainingPredictFrame(framePath) sample := &TrainingSample{ SampleID: id, FrameURL: "/api/training/frame?id=" + id, SourceFile: sourceFileWithFrame(i), SourcePath: outPath, SourceSizeBytes: sourceSizeBytes, Second: second, CreatedAt: time.Now().UTC().Format(time.RFC3339), Prediction: prediction, } trainingPublishAnalysisStepWithPreview( requestID, startedAtMs, stepBase+3, totalSteps, sourceFileWithFrame(i), previewURL, fmt.Sprintf("Frame %d/%d wird gespeichert...", i+1, len(seconds)), ) if err := trainingWriteSample(root, sample); err != nil { _ = os.Remove(framePath) errs = append(errs, fmt.Sprintf("Frame bei %.1fs speichern: %v", second, err)) continue } samples = append(samples, *sample) } if len(samples) == 0 { msg := "keine Trainingsframes erzeugt" if len(errs) > 0 { msg += ": " + strings.Join(errs, "; ") } err := errors.New(msg) trainingPublishAnalysisError( requestID, startedAtMs, sourceFile, "Video konnte nicht ins Training uebernommen werden.", err, ) return TrainingImportVideoResponse{OK: false, RequestID: requestID, Error: msg}, http.StatusInternalServerError, msg } trainingPublishAnalysisFinished( requestID, startedAtMs, totalSteps, sourceFile, fmt.Sprintf("%d Frames ins Training uebernommen.", len(samples)), ) return TrainingImportVideoResponse{ OK: true, RequestID: requestID, Count: len(samples), Sample: &samples[0], Samples: samples, Errors: errs, }, http.StatusOK, "" } func trainingPruneImportVideoJobsLocked(now time.Time) { for requestID, job := range trainingImportVideoJobs.items { if job == nil || job.running { continue } if !job.finishedAt.IsZero() && now.Sub(job.finishedAt) > trainingImportVideoJobTTL { delete(trainingImportVideoJobs.items, requestID) } } } func trainingLatestRunningImportVideoJobRequestID() string { now := time.Now().UTC() trainingImportVideoJobs.Lock() defer trainingImportVideoJobs.Unlock() trainingPruneImportVideoJobsLocked(now) requestID := "" var latestStarted time.Time for id, job := range trainingImportVideoJobs.items { if job == nil || !job.running { continue } if requestID == "" || job.startedAt.After(latestStarted) { requestID = id latestStarted = job.startedAt } } return requestID } func trainingStartImportVideoJob(req TrainingImportVideoRequest) { requestID := strings.TrimSpace(req.AnalysisRequestID) if requestID == "" { requestID = trainingMakeSampleID(req.Output, float64(time.Now().UnixNano())) req.AnalysisRequestID = requestID } now := time.Now().UTC() trainingImportVideoJobs.Lock() trainingPruneImportVideoJobsLocked(now) if _, exists := trainingImportVideoJobs.items[requestID]; exists { trainingImportVideoJobs.Unlock() return } trainingImportVideoJobs.items[requestID] = &trainingImportVideoJobState{ requestID: requestID, startedAt: now, running: true, statusCode: http.StatusAccepted, } trainingImportVideoJobs.Unlock() go func() { resp, statusCode, errorText := trainingRunImportVideoRequest(req) resp.RequestID = requestID trainingImportVideoJobs.Lock() if job := trainingImportVideoJobs.items[requestID]; job != nil { job.running = false job.finishedAt = time.Now().UTC() job.statusCode = statusCode job.response = &resp job.errorText = strings.TrimSpace(errorText) } trainingImportVideoJobs.Unlock() }() } func trainingImportVideoJobResponse(requestID string) (TrainingImportVideoResponse, int) { requestID = strings.TrimSpace(requestID) if requestID == "" { requestID = trainingLatestRunningImportVideoJobRequestID() } if requestID == "" { return TrainingImportVideoResponse{OK: false, Error: "Kein laufender Import-Job gefunden."}, http.StatusNotFound } trainingImportVideoJobs.Lock() job := trainingImportVideoJobs.items[requestID] trainingImportVideoJobs.Unlock() if job == nil { return TrainingImportVideoResponse{OK: false, RequestID: requestID, Error: "Import-Job nicht gefunden."}, http.StatusNotFound } if job.running { return TrainingImportVideoResponse{ OK: true, Accepted: true, Running: true, RequestID: requestID, Analysis: trainingAnalysisStatusPayload(requestID), }, http.StatusAccepted } if job.response != nil { resp := *job.response resp.Running = false resp.RequestID = requestID resp.Analysis = trainingAnalysisStatusPayload(requestID) if resp.Error == "" { resp.Error = job.errorText } statusCode := job.statusCode if statusCode < 100 { statusCode = http.StatusOK } return resp, statusCode } return TrainingImportVideoResponse{OK: false, RequestID: requestID, Error: job.errorText}, http.StatusInternalServerError } func trainingImportVideoStatusHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { trainingWriteError(w, http.StatusMethodNotAllowed, "method not allowed") return } requestID := strings.TrimSpace(r.URL.Query().Get("requestId")) if requestID == "" { requestID = strings.TrimSpace(r.URL.Query().Get("id")) } resp, statusCode := trainingImportVideoJobResponse(requestID) trainingWriteJSON(w, statusCode, resp) } func trainingAnalysisStatusHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { trainingWriteError(w, http.StatusMethodNotAllowed, "method not allowed") return } requestID := strings.TrimSpace(r.URL.Query().Get("requestId")) if requestID == "" { requestID = strings.TrimSpace(r.URL.Query().Get("id")) } payload := trainingAnalysisStatusPayload(requestID) if payload == nil { trainingWriteJSON(w, http.StatusNotFound, map[string]any{ "ok": false, "error": "Analyse-Status nicht gefunden.", }) return } trainingWriteJSON(w, http.StatusOK, map[string]any{ "ok": true, "analysis": payload, }) } func trainingImportVideoHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { trainingWriteError(w, http.StatusMethodNotAllowed, "method not allowed") return } var req TrainingImportVideoRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { trainingWriteError(w, http.StatusBadRequest, "invalid json") return } analysisRequestID := strings.TrimSpace(req.AnalysisRequestID) if analysisRequestID == "" { analysisRequestID = trainingMakeSampleID(req.Output, float64(time.Now().UnixNano())) req.AnalysisRequestID = analysisRequestID } trainingStartImportVideoJob(req) resp, statusCode := trainingImportVideoJobResponse(analysisRequestID) trainingWriteJSON(w, statusCode, resp) return outPath := strings.TrimSpace(req.Output) if outPath == "" { trainingWriteError(w, http.StatusBadRequest, "output missing") return } if !trainingSupportedImportVideo(outPath) { trainingWriteError(w, http.StatusBadRequest, "unsupported video type") return } fi, err := os.Stat(outPath) if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 { if err == nil { err = errors.New("video file missing or empty") } trainingWriteError(w, http.StatusBadRequest, "video not found: "+err.Error()) return } duration := trainingProbeDurationSeconds(outPath) if duration <= 0 { trainingWriteError(w, http.StatusBadRequest, "Videolänge konnte nicht bestimmt werden") 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 } if err := os.MkdirAll(filepath.Join(root, "frames"), 0755); err != nil { trainingWriteError(w, http.StatusInternalServerError, err.Error()) return } if err := os.MkdirAll(filepath.Join(root, "samples"), 0755); err != nil { trainingWriteError(w, http.StatusInternalServerError, err.Error()) return } seconds := trainingFrameSecondsForVideo(duration, req.Count) sourceFile := filepath.Base(outPath) previewURL := "" sourceFileWithFrame := func(index int) string { return fmt.Sprintf("%s (%d / %d)", sourceFile, index+1, len(seconds)) } requestID := strings.TrimSpace(req.AnalysisRequestID) if requestID == "" { requestID = trainingMakeSampleID(outPath, float64(time.Now().UnixNano())) } totalSteps := len(seconds) * 3 if totalSteps < 1 { totalSteps = 1 } startedAtMs := trainingPublishAnalysisStartedWithPreview( requestID, totalSteps, sourceFile, previewURL, "Video wird ins Training übernommen…", ) var sourceSizeBytes int64 if st, err := os.Stat(outPath); err == nil && st != nil && !st.IsDir() { sourceSizeBytes = st.Size() } samples := make([]TrainingSample, 0, len(seconds)) errs := []string{} for i, second := range seconds { stepBase := i * 3 trainingPublishAnalysisStepWithPreview( requestID, startedAtMs, stepBase+1, totalSteps, sourceFileWithFrame(i), previewURL, fmt.Sprintf("Frame %d/%d wird extrahiert…", i+1, len(seconds)), ) id := trainingMakeSampleID(outPath, second) framePath := filepath.Join(root, "frames", id+".jpg") if err := trainingExtractFrame(outPath, framePath, second); err != nil { errs = append(errs, fmt.Sprintf("Frame bei %.1fs: %v", second, err)) continue } // Nach jeder erfolgreichen Extraktion das aktuell verarbeitete Frame zeigen. previewURL = "/api/training/frame?id=" + url.QueryEscape(id) trainingPublishAnalysisStepWithPreview( requestID, startedAtMs, stepBase+2, totalSteps, sourceFileWithFrame(i), previewURL, fmt.Sprintf("Frame %d/%d wird analysiert…", i+1, len(seconds)), ) prediction := trainingPredictFrame(framePath) sample := &TrainingSample{ SampleID: id, FrameURL: "/api/training/frame?id=" + id, SourceFile: sourceFileWithFrame(i), SourcePath: outPath, SourceSizeBytes: sourceSizeBytes, Second: second, CreatedAt: time.Now().UTC().Format(time.RFC3339), Prediction: prediction, } trainingPublishAnalysisStepWithPreview( requestID, startedAtMs, stepBase+3, totalSteps, sourceFileWithFrame(i), previewURL, fmt.Sprintf("Frame %d/%d wird gespeichert…", i+1, len(seconds)), ) if err := trainingWriteSample(root, sample); err != nil { _ = os.Remove(framePath) errs = append(errs, fmt.Sprintf("Frame bei %.1fs speichern: %v", second, err)) continue } samples = append(samples, *sample) } if len(samples) == 0 { msg := "keine Trainingsframes erzeugt" if len(errs) > 0 { msg += ": " + strings.Join(errs, "; ") } err := errors.New(msg) trainingPublishAnalysisError( requestID, startedAtMs, sourceFile, "Video konnte nicht ins Training übernommen werden.", err, ) trainingWriteError(w, http.StatusInternalServerError, msg) return } trainingPublishAnalysisFinished( requestID, startedAtMs, totalSteps, sourceFile, fmt.Sprintf("%d Frames ins Training übernommen.", len(samples)), ) trainingWriteJSON(w, http.StatusOK, TrainingImportVideoResponse{ OK: true, Count: len(samples), Sample: &samples[0], Samples: samples, Errors: errs, }) } func trainingNextHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { trainingWriteError(w, http.StatusMethodNotAllowed, "method not allowed") return } req := trainingNextRequestFromHTTP(r) if strings.TrimSpace(req.AnalysisRequestID) == "" { req.AnalysisRequestID = trainingMakeSampleID("training-next", float64(time.Now().UnixNano())) } async := r.URL.Query().Get("async") == "1" || strings.EqualFold(r.URL.Query().Get("async"), "true") if async { trainingStartNextJob(req) resp, statusCode := trainingNextJobResponse(req.AnalysisRequestID) trainingWriteJSON(w, statusCode, resp) return } resp, statusCode, errorText := trainingRunNextRequest(req) if statusCode >= 400 || !resp.OK || resp.Sample == nil { if errorText == "" { errorText = resp.Error } if errorText == "" { errorText = "Trainingsbild konnte nicht geladen werden." } trainingWriteError(w, statusCode, errorText) return } trainingWriteJSON(w, http.StatusOK, resp.Sample) } func trainingNextRequestFromHTTP(r *http.Request) TrainingNextRequest { forceNew := r.URL.Query().Get("force") == "1" || strings.EqualFold(r.URL.Query().Get("force"), "true") analysisRequestID := strings.TrimSpace(r.URL.Query().Get("analysisRequestId")) excludeIDs := trainingExcludedSampleIDs(r) preferUncertain := strings.EqualFold(r.URL.Query().Get("mode"), "uncertain") || strings.EqualFold(r.URL.Query().Get("sampleMode"), "uncertain") refreshPrediction := r.URL.Query().Get("refresh") == "1" || strings.EqualFold(r.URL.Query().Get("refresh"), "true") return TrainingNextRequest{ ForceNew: forceNew, RefreshPrediction: refreshPrediction, PreferUncertain: preferUncertain, AnalysisRequestID: analysisRequestID, ExcludeIDs: excludeIDs, } } func trainingRunNextRequest(req TrainingNextRequest) (TrainingNextResponse, int, string) { forceNew := req.ForceNew refreshPrediction := req.RefreshPrediction preferUncertain := req.PreferUncertain analysisRequestID := strings.TrimSpace(req.AnalysisRequestID) if analysisRequestID == "" { analysisRequestID = trainingMakeSampleID("training-next", float64(time.Now().UnixNano())) } excludeIDs := req.ExcludeIDs if excludeIDs == nil { excludeIDs = map[string]bool{} } root, err := trainingRootDir() if err != nil { return TrainingNextResponse{ OK: false, RequestID: analysisRequestID, Error: err.Error(), Analysis: trainingAnalysisStatusPayload(analysisRequestID), }, http.StatusInternalServerError, err.Error() } if !forceNew && !preferUncertain { var startedAtMs int64 if refreshPrediction { startedAtMs = time.Now().UnixMilli() trainingPublishAnalysisStep( analysisRequestID, startedAtMs, 1, 2, "", "Aktuelles Bild wird neu analysiert…", ) } if sample, ok, err := trainingLatestOpenSample(root, refreshPrediction, startedAtMs, analysisRequestID, excludeIDs); err != nil { if refreshPrediction { trainingPublishAnalysisError( analysisRequestID, startedAtMs, "", "Aktuelles Bild konnte nicht neu analysiert werden.", err, ) } return TrainingNextResponse{ OK: false, RequestID: analysisRequestID, Error: err.Error(), Analysis: trainingAnalysisStatusPayload(analysisRequestID), }, http.StatusInternalServerError, err.Error() } else if ok { if refreshPrediction { trainingPublishAnalysisFinished( analysisRequestID, startedAtMs, 2, sample.SourceFile, "Analyse abgeschlossen.", ) } return TrainingNextResponse{ OK: true, RequestID: analysisRequestID, Sample: sample, Analysis: trainingAnalysisStatusPayload(analysisRequestID), }, http.StatusOK, "" } } totalSteps := 4 if preferUncertain { totalSteps = trainingUncertainCandidateCount*4 + 1 } startedAtMs := trainingPublishAnalysisStarted( analysisRequestID, totalSteps, "", func() string { if preferUncertain { return "Unsichere Prediction wird gesucht…" } return "Neues Trainingsbild wird vorbereitet…" }(), ) var sample *TrainingSample if preferUncertain { sample, err = trainingCreateUncertainNextSampleWithProgress(startedAtMs, analysisRequestID) } else { sample, err = trainingCreateNextSampleWithProgress(startedAtMs, analysisRequestID) } if err != nil { trainingPublishAnalysisError( analysisRequestID, startedAtMs, "", "Trainingsbild konnte nicht erstellt werden.", err, ) return TrainingNextResponse{ OK: false, RequestID: analysisRequestID, Error: err.Error(), Analysis: trainingAnalysisStatusPayload(analysisRequestID), }, http.StatusInternalServerError, err.Error() } trainingPublishAnalysisFinished( analysisRequestID, startedAtMs, totalSteps, sample.SourceFile, "Analyse abgeschlossen.", ) return TrainingNextResponse{ OK: true, RequestID: analysisRequestID, Sample: sample, Analysis: trainingAnalysisStatusPayload(analysisRequestID), }, http.StatusOK, "" } func trainingPruneNextJobsLocked(now time.Time) { for requestID, job := range trainingNextJobs.items { if job == nil || job.running { continue } if !job.finishedAt.IsZero() && now.Sub(job.finishedAt) > trainingNextJobTTL { delete(trainingNextJobs.items, requestID) } } } func trainingLatestRunningNextJobRequestID() string { now := time.Now().UTC() trainingNextJobs.Lock() defer trainingNextJobs.Unlock() trainingPruneNextJobsLocked(now) requestID := "" var latestStarted time.Time for id, job := range trainingNextJobs.items { if job == nil || !job.running { continue } if requestID == "" || job.startedAt.After(latestStarted) { requestID = id latestStarted = job.startedAt } } return requestID } func trainingStartNextJob(req TrainingNextRequest) { requestID := strings.TrimSpace(req.AnalysisRequestID) if requestID == "" { requestID = trainingMakeSampleID("training-next", float64(time.Now().UnixNano())) req.AnalysisRequestID = requestID } now := time.Now().UTC() trainingNextJobs.Lock() trainingPruneNextJobsLocked(now) if _, exists := trainingNextJobs.items[requestID]; exists { trainingNextJobs.Unlock() return } trainingNextJobs.items[requestID] = &trainingNextJobState{ requestID: requestID, startedAt: now, running: true, statusCode: http.StatusAccepted, } trainingNextJobs.Unlock() go func() { resp, statusCode, errorText := trainingRunNextRequest(req) resp.RequestID = requestID resp.Analysis = trainingAnalysisStatusPayload(requestID) trainingNextJobs.Lock() if job := trainingNextJobs.items[requestID]; job != nil { job.running = false job.finishedAt = time.Now().UTC() job.statusCode = statusCode job.response = &resp job.errorText = strings.TrimSpace(errorText) } trainingNextJobs.Unlock() }() } func trainingNextJobResponse(requestID string) (TrainingNextResponse, int) { requestID = strings.TrimSpace(requestID) if requestID == "" { requestID = trainingLatestRunningNextJobRequestID() } if requestID == "" { return TrainingNextResponse{OK: false, Error: "Kein laufender Analyse-Job gefunden."}, http.StatusNotFound } trainingNextJobs.Lock() job := trainingNextJobs.items[requestID] trainingNextJobs.Unlock() if job == nil { return TrainingNextResponse{ OK: false, RequestID: requestID, Error: "Analyse-Job nicht gefunden.", Analysis: trainingAnalysisStatusPayload(requestID), }, http.StatusNotFound } if job.running { return TrainingNextResponse{ OK: true, Accepted: true, Running: true, RequestID: requestID, Analysis: trainingAnalysisStatusPayload(requestID), }, http.StatusAccepted } if job.response != nil { resp := *job.response resp.Running = false resp.RequestID = requestID resp.Analysis = trainingAnalysisStatusPayload(requestID) if resp.Error == "" { resp.Error = job.errorText } statusCode := job.statusCode if statusCode < 100 { statusCode = http.StatusOK } return resp, statusCode } return TrainingNextResponse{ OK: false, RequestID: requestID, Error: job.errorText, Analysis: trainingAnalysisStatusPayload(requestID), }, http.StatusInternalServerError } func trainingNextStatusHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { trainingWriteError(w, http.StatusMethodNotAllowed, "method not allowed") return } requestID := strings.TrimSpace(r.URL.Query().Get("requestId")) if requestID == "" { requestID = strings.TrimSpace(r.URL.Query().Get("id")) } resp, statusCode := trainingNextJobResponse(requestID) trainingWriteJSON(w, statusCode, resp) } func trainingLatestOpenSample( root string, refreshPrediction bool, startedAtMs int64, requestID string, excludeIDs map[string]bool, ) (*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] || excludeIDs[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 } if refreshPrediction { sourceFile := strings.TrimSpace(sample.SourceFile) if sourceFile == "" { sourceFile = filepath.Base(sample.SourcePath) } trainingPublishAnalysisStepWithPreview( requestID, startedAtMs, 1, 2, sourceFile, sample.FrameURL, "Aktuelles Bild wird analysiert…", ) sample.Prediction = trainingPredictFrame(framePath) trainingPublishAnalysisStepWithPreview( requestID, startedAtMs, 2, 2, sourceFile, sample.FrameURL, "Analyse-Ergebnis wird gespeichert…", ) if err := trainingWriteSample(root, sample); err != nil { return nil, false, err } } return sample, true, nil } return nil, false, nil } func trainingExcludedSampleIDs(r *http.Request) map[string]bool { out := map[string]bool{} for _, raw := range r.URL.Query()["exclude"] { for _, part := range strings.Split(raw, ",") { id := strings.TrimSpace(part) if id == "" { continue } if strings.Contains(id, "/") || strings.Contains(id, "\\") { continue } out[id] = true } } return out } 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 trainingDetectorBoxesForAnnotation(sample *TrainingSample, req TrainingFeedbackRequest) []TrainingBox { if req.Negative { return []TrainingBox{} } boxes := []TrainingBox{} if req.Correction != nil { boxes = append(boxes, req.Correction.Boxes...) } else if req.Accepted { boxes = append(boxes, sample.Prediction.Boxes...) } return boxes } func trainingNegativeCorrection() *TrainingCorrection { return &TrainingCorrection{ SexPosition: trainingNoSexPositionLabel, PeoplePresent: []string{}, BodyPartsPresent: []string{}, ObjectsPresent: []string{}, ClothingPresent: []string{}, Boxes: []TrainingBox{}, PosePersons: []TrainingPosePerson{}, } } func trainingNormalizeCorrectionForStorage(correction *TrainingCorrection) *TrainingCorrection { if correction == nil { return nil } normalized := *correction normalized.SexPosition = normalizeSexPositionLabel(normalized.SexPosition) if isNoSexPositionLabel(normalized.SexPosition) { normalized.SexPosition = trainingNoSexPositionLabel normalized.PosePersons = []TrainingPosePerson{} } return &normalized } func trainingAnnotationEffectiveSexPosition(annotation TrainingAnnotation) string { if annotation.Negative { return trainingNoSexPositionLabel } if annotation.Correction != nil { return normalizeSexPositionLabel(annotation.Correction.SexPosition) } return normalizeSexPositionLabel(annotation.Prediction.SexPosition) } func trainingStripPosePersonsForNoSexPosition(annotation TrainingAnnotation) TrainingAnnotation { if !isNoSexPositionLabel(trainingAnnotationEffectiveSexPosition(annotation)) { return annotation } annotation.Prediction.Persons = nil if annotation.Correction != nil { correction := trainingNormalizeCorrectionForStorage(annotation.Correction) correction.PosePersons = []TrainingPosePerson{} annotation.Correction = correction } return annotation } 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 } if req.Negative { req.Accepted = false req.Correction = trainingNegativeCorrection() } req.Correction = trainingNormalizeCorrectionForStorage(req.Correction) 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, Negative: req.Negative, Correction: req.Correction, Notes: strings.TrimSpace(req.Notes), } annotation = trainingStripPosePersonsForNoSexPosition(annotation) 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 := trainingDetectorBoxesForAnnotation(sample, req) if req.Negative || len(detectorBoxes) > 0 { if err := trainingWriteDetectorSample(root, sample, detectorBoxes, req.Negative); err != nil { appLogln("⚠️ detector sample write failed:", err) } } trainingDeletePoseSample(root, req.SampleID) if sexPosition := trainingSexPositionForFeedback(sample, req); !isNoSexPositionLabel(sexPosition) { if err := trainingWritePoseSample(root, sample, sexPosition, detectorBoxes, req.Correction); err != nil { appLogln("pose sample write failed:", err) } } trainingWriteJSON(w, http.StatusOK, map[string]any{ "ok": true, }) } func trainingFeedbackUpdateHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPut && r.Method != http.MethodPost { trainingWriteError(w, http.StatusMethodNotAllowed, "method not allowed") return } var req TrainingFeedbackUpdateRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { trainingWriteError(w, http.StatusBadRequest, "invalid json") return } req.SampleID = strings.TrimSpace(req.SampleID) req.AnsweredAt = strings.TrimSpace(req.AnsweredAt) if req.Negative { req.Accepted = false req.Correction = trainingNegativeCorrection() } req.Correction = trainingNormalizeCorrectionForStorage(req.Correction) if req.SampleID == "" { trainingWriteError(w, http.StatusBadRequest, "sampleId missing") return } if req.AnsweredAt == "" { trainingWriteError(w, http.StatusBadRequest, "answeredAt missing") return } if strings.Contains(req.SampleID, "/") || strings.Contains(req.SampleID, "\\") { trainingWriteError(w, http.StatusBadRequest, "invalid sampleId") return } if !req.Accepted && req.Correction == nil { trainingWriteError(w, http.StatusBadRequest, "correction missing") return } root, err := trainingRootDir() if err != nil { trainingWriteError(w, http.StatusInternalServerError, err.Error()) return } items, err := trainingReadAnnotations(root) if err != nil { trainingWriteError(w, http.StatusInternalServerError, err.Error()) return } matchIndex := -1 for i, item := range items { if strings.TrimSpace(item.SampleID) == req.SampleID && strings.TrimSpace(item.AnsweredAt) == req.AnsweredAt { matchIndex = i break } } if matchIndex < 0 { trainingWriteError(w, http.StatusNotFound, "feedback not found") return } old := items[matchIndex] updated := old updated.Accepted = req.Accepted updated.Negative = req.Negative updated.Notes = strings.TrimSpace(req.Notes) if req.Accepted { updated.Correction = nil } else { updated.Correction = req.Correction } updated = trainingStripPosePersonsForNoSexPosition(updated) items[matchIndex] = updated if err := trainingWriteAnnotations(root, items); err != nil { trainingWriteError(w, http.StatusInternalServerError, err.Error()) return } sample, sampleErr := trainingReadSample(root, req.SampleID) if sampleErr != nil { sample = &TrainingSample{ SampleID: old.SampleID, FrameURL: old.FrameURL, SourceFile: old.SourceFile, SourcePath: old.SourcePath, SourceSizeBytes: old.SourceSizeBytes, Second: old.Second, CreatedAt: old.CreatedAt, Prediction: old.Prediction, } } trainingDeleteDetectorSample(root, req.SampleID) detectorBoxes := trainingDetectorBoxesForAnnotation(sample, TrainingFeedbackRequest{ SampleID: req.SampleID, Accepted: req.Accepted, Negative: req.Negative, Correction: req.Correction, Notes: req.Notes, }) if req.Negative || len(detectorBoxes) > 0 { if err := trainingWriteDetectorSample(root, sample, detectorBoxes, req.Negative); err != nil { appLogln("⚠️ detector sample update failed:", err) } } trainingDeletePoseSample(root, req.SampleID) if sexPosition := trainingSexPositionForFeedback(sample, TrainingFeedbackRequest{ SampleID: req.SampleID, Accepted: req.Accepted, Negative: req.Negative, Correction: req.Correction, Notes: req.Notes, }); !isNoSexPositionLabel(sexPosition) { if err := trainingWritePoseSample(root, sample, sexPosition, detectorBoxes, req.Correction); err != nil { appLogln("pose sample update failed:", err) } } trainingWriteJSON(w, http.StatusOK, map[string]any{ "ok": true, "item": updated, }) } func trainingSkipHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost && r.Method != http.MethodDelete { trainingWriteError(w, http.StatusMethodNotAllowed, "method not allowed") return } var req TrainingSkipRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { trainingWriteError(w, http.StatusBadRequest, "invalid json") return } sampleID := strings.TrimSpace(req.SampleID) if sampleID == "" { trainingWriteError(w, http.StatusBadRequest, "sampleId missing") return } if strings.Contains(sampleID, "/") || strings.Contains(sampleID, "\\") { trainingWriteError(w, http.StatusBadRequest, "invalid sampleId") return } root, err := trainingRootDir() if err != nil { trainingWriteError(w, http.StatusInternalServerError, err.Error()) return } // Aus Uncertain-Queue entfernen, falls es dort noch liegt. if err := trainingRemoveSampleFromUncertainQueue(root, sampleID); err != nil { trainingWriteError(w, http.StatusInternalServerError, err.Error()) return } // Sample + Frame löschen. trainingDeleteSampleFiles(root, sampleID) trainingWriteJSON(w, http.StatusOK, map[string]any{ "ok": true, "sampleId": sampleID, }) } 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 fileExists(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 } req, err := trainingReadTrainRequest(r) if err != nil { trainingWriteError(w, http.StatusBadRequest, "invalid json") return } targets, customTargets, err := trainingNormalizeTrainTargets(req) if err != nil { trainingWriteError(w, http.StatusBadRequest, err.Error()) 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 := trainingEnsurePoseDirs(root); err != nil { trainingWriteError(w, http.StatusInternalServerError, err.Error()) return } if err := trainingEnsureVideoMAEDirs(root); err != nil { trainingWriteError(w, http.StatusInternalServerError, err.Error()) return } if err := trainingEnsureDetectorValidationSample(root); err != nil { appLogln("⚠️ detector val sample ensure failed:", err) } if err := trainingEnsurePoseValidationSample(root); err != nil { appLogln("pose 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 YOLO26-Training. 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") poseTrainImages := filepath.Join(root, "pose", "dataset", "images", "train") poseTrainLabels := filepath.Join(root, "pose", "dataset", "labels", "train") poseValImages := filepath.Join(root, "pose", "dataset", "images", "val") poseValLabels := filepath.Join(root, "pose", "dataset", "labels", "val") poseDatasetYAML := filepath.Join(root, "pose", "dataset", "dataset.yaml") videoMAEManifest := trainingVideoMAEManifestPath(root) trainCount := trainingCountDetectorSamples(detectorTrainImages, detectorTrainLabels) valCount := trainingCountDetectorSamples(detectorValImages, detectorValLabels) positiveTrainCount := trainingCountPositiveDetectorSamples(detectorTrainImages, detectorTrainLabels) positiveValCount := trainingCountPositiveDetectorSamples(detectorValImages, detectorValLabels) poseTrainCount := trainingCountDetectorSamples(poseTrainImages, poseTrainLabels) poseValCount := trainingCountDetectorSamples(poseValImages, poseValLabels) videoMAETrainCount, videoMAEValCount := trainingCountVideoMAEManifestSamples(root) videoMAEEligibleCount, _ := trainingCountVideoMAEEligibleAnnotations(root) detectorDataReady := fileExistsNonEmpty(detectorDatasetYAML) && trainCount >= minDetectorTrainCount && valCount >= minDetectorValCount && positiveTrainCount > 0 && positiveValCount > 0 poseDataReady := fileExistsNonEmpty(poseDatasetYAML) && poseTrainCount >= minPoseTrainCount && poseValCount >= minPoseValCount videoMAEDataReady := videoMAEEligibleCount >= minVideoMAETrainCount || (videoMAETrainCount >= minVideoMAETrainCount && videoMAEValCount >= minVideoMAEValCount) runtimeOpts := trainingRuntimeOptionsFromSettings() selectedDataReady := (targets.Detector && detectorDataReady) || (targets.Pose && poseDataReady) || (targets.VideoMAE && videoMAEDataReady) if customTargets { if targets.Detector && !detectorDataReady { trainingWriteError( w, http.StatusBadRequest, fmt.Sprintf( "YOLO26 Detector ist noch nicht trainingsbereit. Train=%d (%d positiv), Val=%d (%d positiv). Benoetigt: mindestens %d Train, %d Val und je ein positives Beispiel.", trainCount, positiveTrainCount, valCount, positiveValCount, minDetectorTrainCount, minDetectorValCount, ), ) return } if targets.Pose && !poseDataReady { trainingWriteError( w, http.StatusBadRequest, fmt.Sprintf( "YOLO26 Pose ist noch nicht trainingsbereit. Train=%d, Val=%d. Benoetigt: mindestens %d Train und %d Val.", poseTrainCount, poseValCount, minPoseTrainCount, minPoseValCount, ), ) return } if targets.VideoMAE && !runtimeOpts.VideoMAEEnabled { trainingWriteError(w, http.StatusBadRequest, "VideoMAE ist in den Training-Settings deaktiviert.") return } if targets.VideoMAE && !videoMAEDataReady { trainingWriteError( w, http.StatusBadRequest, fmt.Sprintf( "VideoMAE ist noch nicht trainingsbereit. Eligible=%d, Train=%d, Val=%d. Benoetigt: mindestens %d Train und %d Val.", videoMAEEligibleCount, videoMAETrainCount, videoMAEValCount, minVideoMAETrainCount, minVideoMAEValCount, ), ) return } goto startTraining } if selectedDataReady { goto startTraining } if !fileExistsNonEmpty(detectorDatasetYAML) || trainCount < minDetectorTrainCount || valCount < minDetectorValCount || positiveTrainCount == 0 || positiveValCount == 0 { trainingWriteError( w, http.StatusBadRequest, fmt.Sprintf( "Zu wenige YOLO26-Beispiele. Train=%d (%d positiv), Val=%d (%d positiv). Benötigt: mindestens %d Train, %d Val und je ein positives Beispiel.", trainCount, positiveTrainCount, valCount, positiveValCount, minDetectorTrainCount, minDetectorValCount, ), ) return } if !fileExistsNonEmpty(poseDatasetYAML) || poseTrainCount < minPoseTrainCount || poseValCount < minPoseValCount { trainingWriteError( w, http.StatusBadRequest, fmt.Sprintf( "Zu wenige YOLO26-Pose-Beispiele. Train=%d, Val=%d. Benoetigt: mindestens %d Train und %d Val.", poseTrainCount, poseValCount, minPoseTrainCount, minPoseValCount, ), ) return } startTraining: ctx, cancel := context.WithCancel(context.Background()) trainingStartJob(cancel) go trainingRunJob(ctx, root, feedbackCount, targets) trainingWriteJSON(w, http.StatusAccepted, map[string]any{ "ok": true, "message": "Training gestartet.", "training": trainingGetJobStatus(), "targets": targets.list(), "detector": map[string]any{ "trainCount": trainCount, "valCount": valCount, "positiveTrainCount": positiveTrainCount, "positiveValCount": positiveValCount, "requiredTrain": minDetectorTrainCount, "requiredVal": minDetectorValCount, "datasetYAML": detectorDatasetYAML, "usesSceneCLIP": false, "usesSceneKNN": false, "source": "yolo26_detector", "detectsPosition": false, }, "pose": map[string]any{ "trainCount": poseTrainCount, "valCount": poseValCount, "requiredTrain": minPoseTrainCount, "requiredVal": minPoseValCount, "datasetYAML": poseDatasetYAML, "source": "yolo26_pose", }, "videomae": map[string]any{ "eligibleCount": videoMAEEligibleCount, "trainCount": videoMAETrainCount, "valCount": videoMAEValCount, "requiredTrain": minVideoMAETrainCount, "requiredVal": minVideoMAEValCount, "manifest": videoMAEManifest, "dataReady": videoMAEDataReady, "source": "videomae_clip", }, }) } func trainingCancelHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost && r.Method != http.MethodDelete { trainingWriteError(w, http.StatusMethodNotAllowed, "method not allowed") return } trainingJob.mu.Lock() status := trainingJob.status cancel := trainingJob.cancel trainingJob.mu.Unlock() if !status.Running { trainingWriteJSON(w, http.StatusOK, map[string]any{ "ok": true, "message": "Es läuft kein Training.", "training": status, }) return } trainingSetJobStatus(func(s *TrainingJobStatus) { s.Step = "Training wird abgebrochen…" s.Message = "" s.Error = "" }) if cancel != nil { cancel() } trainingWriteJSON(w, http.StatusAccepted, map[string]any{ "ok": true, "message": "Training wird abgebrochen.", "training": trainingGetJobStatus(), }) } func trainingRunJob(ctx context.Context, root string, count int, targets trainingTrainTargets) { if err := ensureMLPythonSetup(ctx); err != nil { if errors.Is(err, context.Canceled) { trainingFinishCancelled(root) return } appLogln("ML-Python setup for training failed:", err) trainingSetJobStatus(func(s *TrainingJobStatus) { finishedAt := time.Now().UTC() var durationMs int64 if startedAt, parseErr := time.Parse(time.RFC3339, strings.TrimSpace(s.StartedAt)); parseErr == nil { durationMs = finishedAt.Sub(startedAt).Milliseconds() if durationMs < 0 { durationMs = 0 } } s.Running = false s.Progress = 100 s.Step = "Training fehlgeschlagen." s.Message = "ML-Python-Umgebung konnte nicht vorbereitet werden." s.Error = err.Error() s.FinishedAt = finishedAt.Format(time.RFC3339) s.DurationMs = durationMs s.PreviewURL = "" s.Paused = false s.PauseReason = "" s.CPUPercent = 0 s.TemperatureC = 0 }) trainingClearJobCancel() return } python := trainingPythonExe() appLogln("ML-Python für Training:", python) runtimeOpts := trainingRuntimeOptionsFromSettings() appLogf( "Training-Laufzeit: mode=%s cpuCores=%d schonmodus=%v threads=%d workers=%d yoloBatch=%d lowPriority=%v videoMAE=%v autoPause=%v cpuLimit=%d tempLimit=%d targets=%s", runtimeOpts.PerformanceMode, runtimeOpts.CPUCoreCount, runtimeOpts.PowerSaveMode, runtimeOpts.CPUThreads, runtimeOpts.Workers, runtimeOpts.YoloBatchSize, runtimeOpts.LowPriority, runtimeOpts.VideoMAEEnabled, runtimeOpts.AutoPauseEnabled, runtimeOpts.AutoPauseCPUPercent, runtimeOpts.AutoPauseTemperatureC, strings.Join(targets.list(), ","), ) 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 = "YOLO26-Daten werden geprüft…" }) detectorOutput := "" detectorStatus := "skipped" detectorDurationMs := int64(0) poseOutput := "" poseStatus := "skipped" poseDurationMs := int64(0) videoMAEOutput := "" videoMAEStatus := "skipped" videoMAEDurationMs := int64(0) 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 { appLogln("⚠️ detector val sample ensure failed:", err) } trainCount := trainingCountDetectorSamples(detectorTrainImages, detectorTrainLabels) valCount := trainingCountDetectorSamples(detectorValImages, detectorValLabels) positiveTrainCount := trainingCountPositiveDetectorSamples(detectorTrainImages, detectorTrainLabels) positiveValCount := trainingCountPositiveDetectorSamples(detectorValImages, detectorValLabels) fmt.Printf( "🔎 detector data: train=%d (%d positive) val=%d (%d positive) yaml=%v\n", trainCount, positiveTrainCount, valCount, positiveValCount, fileExistsNonEmpty(detectorDatasetYAML), ) if !targets.Detector { trainingSetJobStatus(func(s *TrainingJobStatus) { trainingApplyStageProgress(s, "detector", 1) if s.Progress < 58 { s.Progress = 58 } s.Step = "YOLO26 Detector wurde nicht ausgewählt." }) detectorStatus = "skipped_unselected" detectorOutput = "YOLO26 Detector übersprungen: nicht ausgewählt." appLogln(detectorOutput) } else if fileExistsNonEmpty(detectorDatasetYAML) && trainCount >= minDetectorTrainCount && valCount >= minDetectorValCount && positiveTrainCount > 0 && positiveValCount > 0 { detectorStartedAt := time.Now() trainingSetJobStatus(func(s *TrainingJobStatus) { trainingApplyStageProgress(s, "detector", 0) s.Epochs = trainingDetectorEpochs() s.Progress = 15 s.Step = "YOLO26 Detector wird trainiert…" }) detectorModel := trainingResolveDetectorModel(root) detectorBasePath := detectorModel.BestPath if !detectorModel.TrainedExists { detectorBasePath = "yolo26n.pt" if p, err := trainingMLCacheFilePath("yolo26n.pt"); err == nil { detectorBasePath = p } } detectorEpochs := trainingDetectorEpochs() detectorPatience := trainingYoloEarlyStoppingPatience(detectorEpochs) if detectorModel.TrainedExists { appLogln("YOLO26 Detector Fine-Tuning startet von:", detectorBasePath) } detectorScript := trainingScriptPath("train_detector_model.py") detectorArgs := []string{ "--root", root, "--base", detectorBasePath, "--epochs", strconv.Itoa(detectorEpochs), "--imgsz", "640", "--workers", strconv.Itoa(runtimeOpts.Workers), "--threads", strconv.Itoa(runtimeOpts.CPUThreads), "--patience", strconv.Itoa(detectorPatience), } if runtimeOpts.YoloBatchSize > 0 { detectorArgs = append(detectorArgs, "--batch", strconv.Itoa(runtimeOpts.YoloBatchSize)) } detectorOut, detectorErr := trainingRunCommandStreaming( ctx, python, detectorScript, func(line string) bool { return trainingHandleProgressLine( line, 15, 58, "YOLO26 Detector wird trainiert…", ) }, detectorArgs..., ) detectorDurationMs = time.Since(detectorStartedAt).Milliseconds() if errors.Is(detectorErr, errTrainingCancelled) { appLogln("⛔ YOLO26 detector training cancelled") trainingFinishCancelled(root) return } detectorOutput = detectorOut detectorOutputClean := cleanOutput(detectorOutput) if detectorErr != nil { detectorStatus = "failed" appLogln("⚠️ YOLO26 detector training failed:", detectorErr) if detectorOutputClean != "" { appLogln("⚠️ YOLO26 detector output:", detectorOutputClean) } } else { detectorStatus = "trained" if detectorOutputClean != "" { appLogln("✅ YOLO26 detector training:", detectorOutputClean) } } } else { detectorStatus = "skipped_no_detector_data" detectorOutput = fmt.Sprintf( "YOLO26 Detector übersprungen: zu wenige Beispiele. Train=%d (%d positiv), Val=%d (%d positiv). Benötigt: mindestens %d Train, %d Val und je ein positives Beispiel.", trainCount, positiveTrainCount, valCount, positiveValCount, minDetectorTrainCount, minDetectorValCount, ) appLogln("⚠️", detectorOutput) } detectorOutputClean := cleanOutput(detectorOutput) poseDatasetYAML := filepath.Join(root, "pose", "dataset", "dataset.yaml") poseTrainImages := filepath.Join(root, "pose", "dataset", "images", "train") poseTrainLabels := filepath.Join(root, "pose", "dataset", "labels", "train") poseValImages := filepath.Join(root, "pose", "dataset", "images", "val") poseValLabels := filepath.Join(root, "pose", "dataset", "labels", "val") poseStartedAt := time.Time{} if !targets.Pose { trainingSetJobStatus(func(s *TrainingJobStatus) { trainingApplyStageProgress(s, "pose", 1) if s.Progress < 82 { s.Progress = 82 } s.Step = "YOLO26 Pose wurde nicht ausgewählt." }) poseStatus = "skipped_unselected" poseOutput = "YOLO26 Pose übersprungen: nicht ausgewählt." appLogln(poseOutput) } else { poseStartedAt = time.Now() trainingSetJobStatus(func(s *TrainingJobStatus) { trainingApplyStageProgress(s, "pose", 0) s.Epochs = trainingDetectorEpochs() if s.Progress < 60 { s.Progress = 60 } s.Step = "YOLO26 Pose-Daten werden aufgebaut..." }) if written, err := trainingSyncPoseDataset(root); err != nil { poseStatus = "failed" poseOutput = "YOLO26 Pose-Dataset konnte nicht aufgebaut werden: " + err.Error() appLogln(poseOutput) } else { appLogln("pose samples synced:", written) } if err := trainingEnsurePoseValidationSample(root); err != nil { appLogln("pose val sample ensure failed:", err) } } poseTrainCount := trainingCountDetectorSamples(poseTrainImages, poseTrainLabels) poseValCount := trainingCountDetectorSamples(poseValImages, poseValLabels) fmt.Printf( "pose data: train=%d val=%d yaml=%v\n", poseTrainCount, poseValCount, fileExistsNonEmpty(poseDatasetYAML), ) if targets.Pose && poseStatus != "failed" && fileExistsNonEmpty(poseDatasetYAML) && poseTrainCount >= minPoseTrainCount && poseValCount >= minPoseValCount { trainingSetJobStatus(func(s *TrainingJobStatus) { trainingApplyStageProgress(s, "pose", 0.04) s.Epochs = trainingDetectorEpochs() if s.Progress < 62 { s.Progress = 62 } s.Step = "YOLO26 Pose wird trainiert..." }) poseModel := trainingResolvePoseModel(root) poseBasePath := "yolo26n-pose.pt" if poseModel.EffectiveExists { poseBasePath = poseModel.EffectivePath } poseEpochs := trainingDetectorEpochs() posePatience := trainingYoloEarlyStoppingPatience(poseEpochs) if poseModel.TrainedExists { appLogln("YOLO26 Pose Fine-Tuning startet von:", poseBasePath) } poseScript := trainingScriptPath("train_pose_model.py") poseArgs := []string{ "--root", root, "--base", poseBasePath, "--epochs", strconv.Itoa(poseEpochs), "--imgsz", "640", "--workers", strconv.Itoa(runtimeOpts.Workers), "--threads", strconv.Itoa(runtimeOpts.CPUThreads), "--patience", strconv.Itoa(posePatience), } if runtimeOpts.YoloBatchSize > 0 { poseArgs = append(poseArgs, "--batch", strconv.Itoa(runtimeOpts.YoloBatchSize)) } poseOut, poseErr := trainingRunCommandStreaming( ctx, python, poseScript, func(line string) bool { return trainingHandleProgressLine( line, 62, 82, "YOLO26 Pose wird trainiert...", ) }, poseArgs..., ) poseDurationMs = time.Since(poseStartedAt).Milliseconds() if errors.Is(poseErr, errTrainingCancelled) { appLogln("YOLO26 pose training cancelled") trainingFinishCancelled(root) return } poseOutput = poseOut poseOutputClean := cleanOutput(poseOutput) if poseErr != nil { poseStatus = "failed" appLogln("YOLO26 pose training failed:", poseErr) if poseOutputClean != "" { appLogln("YOLO26 pose output:", poseOutputClean) } } else { poseStatus = "trained" if poseOutputClean != "" { appLogln("YOLO26 pose training:", poseOutputClean) } } } else if targets.Pose && poseStatus != "failed" { poseStatus = "skipped_no_pose_data" poseOutput = fmt.Sprintf( "YOLO26 Pose übersprungen: zu wenige Skeleton-Beispiele. Train=%d, Val=%d. Benoetigt: mindestens %d Train und %d Val.", poseTrainCount, poseValCount, minPoseTrainCount, minPoseValCount, ) appLogln(poseOutput) } if poseStatus == "trained" && poseDurationMs <= 0 && !poseStartedAt.IsZero() { poseDurationMs = time.Since(poseStartedAt).Milliseconds() } poseOutputClean := cleanOutput(poseOutput) videoMAEStartedAt := time.Time{} videoMAETrainCount := 0 videoMAEValCount := 0 if !targets.VideoMAE { trainingSetJobStatus(func(s *TrainingJobStatus) { trainingApplyStageProgress(s, "videomae", 1) if s.Progress < 98 { s.Progress = 98 } s.Step = "VideoMAE wurde nicht ausgewählt." }) videoMAEStatus = "skipped_unselected" videoMAEOutput = "VideoMAE übersprungen: nicht ausgewählt." appLogln(videoMAEOutput) } else if !runtimeOpts.VideoMAEEnabled { trainingSetJobStatus(func(s *TrainingJobStatus) { trainingApplyStageProgress(s, "videomae", 1) if s.Progress < 84 { s.Progress = 84 } s.Step = "VideoMAE wurde in den Settings übersprungen." }) videoMAEStatus = "skipped_disabled" videoMAEOutput = "VideoMAE übersprungen: in den Training-Settings deaktiviert." appLogln(videoMAEOutput) } else { videoMAEStartedAt = time.Now() trainingSetJobStatus(func(s *TrainingJobStatus) { trainingApplyStageProgress(s, "videomae", 0) s.Epochs = trainingVideoMAEEpochs() if s.Progress < 84 { s.Progress = 84 } s.Step = "VideoMAE Clip-Daten werden aufgebaut..." }) var videoMAEWritten int var videoMAESyncErr error videoMAETrainCount, videoMAEValCount, videoMAEWritten, videoMAESyncErr = trainingSyncVideoMAEDataset(ctx, root) if errors.Is(videoMAESyncErr, context.Canceled) || errors.Is(videoMAESyncErr, errTrainingCancelled) { appLogln("VideoMAE dataset sync cancelled") trainingFinishCancelled(root) return } if videoMAESyncErr != nil { videoMAEStatus = "failed" videoMAEOutput = "VideoMAE-Dataset konnte nicht aufgebaut werden: " + videoMAESyncErr.Error() appLogln(videoMAEOutput) } else { appLogf( "VideoMAE samples synced: written=%d train=%d val=%d", videoMAEWritten, videoMAETrainCount, videoMAEValCount, ) } if videoMAEStatus != "failed" && videoMAETrainCount >= minVideoMAETrainCount && videoMAEValCount >= minVideoMAEValCount { trainingSetJobStatus(func(s *TrainingJobStatus) { trainingApplyStageProgress(s, "videomae", 0.04) s.Epochs = trainingVideoMAEEpochs() if s.Progress < 86 { s.Progress = 86 } s.Step = "VideoMAE Clip-Classifier wird trainiert..." }) videoMAEScript := trainingScriptPath("train_videomae_model.py") videoMAEBase := strings.TrimSpace(os.Getenv("VIDEOMAE_BASE_MODEL")) if videoMAEBase == "" { if model := trainingResolveVideoMAEModel(root); model.TrainedExists { videoMAEBase = model.EffectivePath appLogln("VideoMAE Fine-Tuning startet von:", videoMAEBase) } } videoMAEArgs := []string{ "--root", root, "--epochs", strconv.Itoa(trainingVideoMAEEpochs()), "--batch-size", strconv.Itoa(trainingVideoMAEBatchSize()), "--num-frames", strconv.Itoa(trainingVideoMAENumFrames), "--workers", strconv.Itoa(runtimeOpts.Workers), "--threads", strconv.Itoa(runtimeOpts.CPUThreads), "--patience", strconv.Itoa(trainingVideoMAEEarlyStoppingPatience()), } if videoMAEBase != "" { videoMAEArgs = append(videoMAEArgs, "--base", videoMAEBase) } videoMAEOut, videoMAEErr := trainingRunCommandStreaming( ctx, python, videoMAEScript, func(line string) bool { return trainingHandleProgressLine( line, 86, 98, "VideoMAE Clip-Classifier wird trainiert...", ) }, videoMAEArgs..., ) videoMAEDurationMs = time.Since(videoMAEStartedAt).Milliseconds() if errors.Is(videoMAEErr, errTrainingCancelled) { appLogln("VideoMAE training cancelled") trainingFinishCancelled(root) return } videoMAEOutput = videoMAEOut videoMAEOutputClean := cleanOutput(videoMAEOutput) if videoMAEErr != nil { videoMAEStatus = "failed" appLogln("VideoMAE training failed:", videoMAEErr) if videoMAEOutputClean != "" { appLogln("VideoMAE output:", videoMAEOutputClean) } } else { videoMAEStatus = "trained" if videoMAEOutputClean != "" { appLogln("VideoMAE training:", videoMAEOutputClean) } } } else if videoMAEStatus != "failed" { videoMAEStatus = "skipped_no_videomae_data" videoMAEOutput = fmt.Sprintf( "VideoMAE übersprungen: zu wenige Clip-Beispiele. Train=%d, Val=%d. Benoetigt: mindestens %d Train und %d Val.", videoMAETrainCount, videoMAEValCount, minVideoMAETrainCount, minVideoMAEValCount, ) appLogln(videoMAEOutput) } } if videoMAEStatus == "trained" && videoMAEDurationMs <= 0 && !videoMAEStartedAt.IsZero() { videoMAEDurationMs = time.Since(videoMAEStartedAt).Milliseconds() } videoMAEOutputClean := cleanOutput(videoMAEOutput) message := "Training abgeschlossen." errorText := "" switch detectorStatus { case "trained": message = "Training abgeschlossen. YOLO26 Detector wurde trainiert." case "skipped_unselected": message = "Training abgeschlossen." case "skipped_no_detector_data": message = detectorOutput case "failed": message = "YOLO26 Detector ist fehlgeschlagen." if detectorOutputClean != "" { message += " Grund: " + detectorOutputClean } errorText = message default: message = "Training abgeschlossen, aber YOLO26 wurde nicht trainiert." if detectorOutputClean != "" { message += " Ausgabe: " + detectorOutputClean } } if poseStatus == "trained" && detectorStatus == "trained" { message = "Training abgeschlossen. YOLO26 Detector und YOLO26 Pose wurden trainiert." } else if poseStatus == "trained" && detectorStatus != "failed" { message = "Training abgeschlossen. YOLO26 Pose wurde trainiert." } else if poseStatus == "skipped_no_pose_data" && detectorStatus == "trained" { message += " YOLO26 Pose wurde übersprungen: zu wenige Skeleton-Beispiele." } else if poseStatus == "failed" { if detectorStatus == "failed" { message += " YOLO26 Pose ist ebenfalls fehlgeschlagen." } else { message = "YOLO26 Pose ist fehlgeschlagen." } if poseOutputClean != "" { message += " Grund: " + poseOutputClean } errorText = message } if videoMAEStatus == "trained" { if !strings.Contains(message, "Training abgeschlossen.") { message = "Training abgeschlossen. " + message } message += " VideoMAE wurde trainiert." } else if videoMAEStatus == "skipped_no_videomae_data" { if detectorStatus == "trained" || poseStatus == "trained" { message += " VideoMAE wurde übersprungen: zu wenige Clip-Beispiele." } } else if videoMAEStatus == "skipped_disabled" { if detectorStatus == "trained" || poseStatus == "trained" { message += " VideoMAE wurde übersprungen: in den Settings deaktiviert." } } else if videoMAEStatus == "failed" { message += " VideoMAE ist fehlgeschlagen." if videoMAEOutputClean != "" { message += " Grund: " + videoMAEOutputClean } } if detectorStatus == "trained" || poseStatus == "trained" || videoMAEStatus == "trained" { // Verlauf pro erfolgreich trainiertem Ziel schreiben. if detectorStatus == "trained" { trainingAppendTargetHistory(root, "detector", detectorStatus, detectorDurationMs, runtimeOpts, TrainingHistoryEntry{ Epochs: trainingDetectorEpochs(), TrainSamples: trainCount, ValSamples: valCount, Imgsz: 640, }) } if poseStatus == "trained" { trainingAppendTargetHistory(root, "pose", poseStatus, poseDurationMs, runtimeOpts, TrainingHistoryEntry{ Epochs: trainingDetectorEpochs(), TrainSamples: poseTrainCount, ValSamples: poseValCount, Imgsz: 640, }) } if videoMAEStatus == "trained" { trainingAppendTargetHistory(root, "videomae", videoMAEStatus, videoMAEDurationMs, runtimeOpts, TrainingHistoryEntry{ Epochs: trainingVideoMAEEpochs(), TrainSamples: videoMAETrainCount, ValSamples: videoMAEValCount, Imgsz: trainingVideoMAEFrameSize, }) } } 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 if strings.TrimSpace(s.Stage) != "" { s.StageProgress = 1 } s.Step = "Training abgeschlossen." s.Message = message s.Error = errorText s.FinishedAt = finishedAt.Format(time.RFC3339) s.DurationMs = durationMs s.PreviewURL = "" s.Paused = false s.PauseReason = "" s.CPUPercent = 0 s.TemperatureC = 0 }) trainingClearJobCancel() } 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")) trainingApplyStatsModelInfo(root, stats) 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.Negative { stats.NegativeCount++ } else if annotation.Accepted { stats.AcceptedCount++ } else { stats.CorrectedCount++ } effective := trainingEffectiveCorrection(annotation) sexPosition := normalizeSexPositionLabel(effective.SexPosition) 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")) trainingApplyStatsModelInfo(root, stats) 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.Negative { return *trainingNegativeCorrection() } if annotation.Correction != nil { correction := trainingNormalizeCorrectionForStorage(annotation.Correction) return *correction } p := annotation.Prediction sexPosition := normalizeSexPositionLabel(p.SexPosition) posePersons := p.Persons if isNoSexPositionLabel(sexPosition) { posePersons = []TrainingPosePerson{} } return TrainingCorrection{ SexPosition: sexPosition, PeoplePresent: trainingScoredLabelsToStrings(p.PeoplePresent), BodyPartsPresent: trainingScoredLabelsToStrings(p.BodyPartsPresent), ObjectsPresent: trainingScoredLabelsToStrings(p.ObjectsPresent), ClothingPresent: trainingScoredLabelsToStrings(p.ClothingPresent), Boxes: p.Boxes, PosePersons: posePersons, } } 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 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 := trainingEnsurePoseDirs(root); err != nil { trainingWriteError(w, http.StatusInternalServerError, err.Error()) return } if err := trainingEnsureVideoMAEDirs(root); err != nil { trainingWriteError(w, http.StatusInternalServerError, err.Error()) return } if err := trainingEnsureDetectorValidationSample(root); err != nil { appLogln("⚠️ detector val sample ensure failed:", err) } if err := trainingEnsurePoseValidationSample(root); err != nil { appLogln("pose 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") poseDatasetYAML := filepath.Join(root, "pose", "dataset", "dataset.yaml") poseTrainImages := filepath.Join(root, "pose", "dataset", "images", "train") poseTrainLabels := filepath.Join(root, "pose", "dataset", "labels", "train") poseValImages := filepath.Join(root, "pose", "dataset", "images", "val") poseValLabels := filepath.Join(root, "pose", "dataset", "labels", "val") detectorModel := trainingResolveDetectorModel(root) poseModel := trainingResolvePoseModel(root) videoMAEModel := trainingResolveVideoMAEModel(root) videoMAEManifest := trainingVideoMAEManifestPath(root) trainCount := trainingCountDetectorSamples(detectorTrainImages, detectorTrainLabels) valCount := trainingCountDetectorSamples(detectorValImages, detectorValLabels) positiveTrainCount := trainingCountPositiveDetectorSamples(detectorTrainImages, detectorTrainLabels) positiveValCount := trainingCountPositiveDetectorSamples(detectorValImages, detectorValLabels) poseTrainCount := trainingCountDetectorSamples(poseTrainImages, poseTrainLabels) poseValCount := trainingCountDetectorSamples(poseValImages, poseValLabels) videoMAETrainCount, videoMAEValCount := trainingCountVideoMAEManifestSamples(root) videoMAEEligibleCount, _ := trainingCountVideoMAEEligibleAnnotations(root) datasetReady := fileExistsNonEmpty(detectorDatasetYAML) detectorDataReady := datasetReady && trainCount >= minDetectorTrainCount && valCount >= minDetectorValCount && positiveTrainCount > 0 && positiveValCount > 0 poseDatasetReady := fileExistsNonEmpty(poseDatasetYAML) poseDataReady := poseDatasetReady && poseTrainCount >= minPoseTrainCount && poseValCount >= minPoseValCount videoMAEDatasetReady := fileExistsNonEmpty(videoMAEManifest) videoMAEDataReady := (videoMAETrainCount >= minVideoMAETrainCount && videoMAEValCount >= minVideoMAEValCount) || videoMAEEligibleCount >= minVideoMAETrainCount canTrain := feedbackCount >= minTrainingFeedbackCount && (detectorDataReady || poseDataReady || videoMAEDataReady) trainingWriteJSON(w, http.StatusOK, map[string]any{ "ok": true, "feedbackCount": feedbackCount, "requiredCount": minTrainingFeedbackCount, "canTrain": canTrain, "training": job, "detector": map[string]any{ "source": "yolo26_detector", "usesSceneCLIP": false, "usesSceneKNN": false, "usesResNet18KNN": false, "detectsPeople": true, "detectsGender": true, "detectsSexPosition": false, "detectsBodyParts": true, "detectsObjects": true, "detectsClothing": true, "detectsBoxes": true, "trainCount": trainCount, "valCount": valCount, "positiveTrainCount": positiveTrainCount, "positiveValCount": positiveValCount, "requiredTrain": minDetectorTrainCount, "requiredVal": minDetectorValCount, "datasetReady": datasetReady, "datasetYAML": detectorDatasetYAML, "dataReady": detectorDataReady, "modelExists": detectorModel.EffectiveExists, "modelPath": detectorModel.EffectivePath, "trainedModelExists": detectorModel.TrainedExists, "trainedModelPath": detectorModel.BestPath, "modelSource": detectorModel.Source, }, "pose": map[string]any{ "source": "yolo26_pose", "usesKeypoints": true, "predictsPersons": true, "predictsSexPosition": poseModel.TrainedExists, "trainedFromFeedback": true, "trainCount": poseTrainCount, "valCount": poseValCount, "requiredTrain": minPoseTrainCount, "requiredVal": minPoseValCount, "datasetReady": poseDatasetReady, "datasetYAML": poseDatasetYAML, "dataReady": poseDataReady, "modelExists": poseModel.EffectiveExists, "modelPath": poseModel.EffectivePath, "trainedModelExists": poseModel.TrainedExists, "trainedModelPath": poseModel.BestPath, "modelSource": poseModel.Source, }, "scene": map[string]any{ "source": "videomae_clip", "usesVideoMAE": true, "usesSceneCLIP": false, "usesSceneKNN": false, "usesResNet18KNN": false, "usesLogisticRegression": false, "predictsSexPosition": videoMAEModel.TrainedExists, "predictsPeople": false, "predictsGender": false, "predictsBodyParts": false, "predictsObjects": false, "predictsClothing": false, "predictsBoxes": false, "feedbackCount": feedbackCount, "eligibleCount": videoMAEEligibleCount, "trainCount": videoMAETrainCount, "valCount": videoMAEValCount, "requiredTrain": minVideoMAETrainCount, "requiredVal": minVideoMAEValCount, "requiredCount": minVideoMAETrainCount, "datasetReady": videoMAEDatasetReady, "manifest": videoMAEManifest, "dataReady": videoMAEDataReady, "modelReady": videoMAEModel.EffectiveExists, "modelExists": videoMAEModel.EffectiveExists, "modelPath": videoMAEModel.EffectivePath, "modelSource": videoMAEModel.Source, "trainedModelExists": videoMAEModel.TrainedExists, "trainedModelPath": videoMAEModel.BestPath, }, "pipeline": map[string]any{ "variant": "YOLO26_VIDEO_CLIP_HYBRID", "peopleSource": "yolo26_detector", "genderSource": "yolo26_detector", "sexPositionSource": "yolo26_pose+box_context+videomae_clip", "bodyPartsSource": "yolo26_detector", "objectsSource": "yolo26_detector", "clothingSource": "yolo26_detector", "boxesSource": "yolo26_detector", "usesSceneKNNForDetection": false, "usesSceneCLIP": false, "usesSceneKNN": false, "usesVideoMAE": true, "usesYOLOForDetection": true, "usesYOLOForSexPosition": true, }, }) } func trainingApplyStatsModelInfo(root string, stats *TrainingStatsResponse) { detectorAvailable := trainingStatsModelAvailableFor(root, "detector") detectorInfo := trainingReadModelInfoFor(root, "detector") poseAvailable := trainingStatsModelAvailableFor(root, "pose") poseInfo := trainingReadModelInfoFor(root, "pose") videoMAEModel := trainingResolveVideoMAEModel(root) videoMAEInfo := trainingReadModelInfoFor(root, "videomae") // modelAvailable/modelInfo bleiben aus Kompatibilitaetsgruenden der Detector. stats.ModelAvailable = detectorAvailable stats.ModelInfo = detectorInfo stats.DetectorModelAvailable = detectorAvailable stats.DetectorModelInfo = detectorInfo stats.PoseModelAvailable = poseAvailable stats.PoseModelInfo = poseInfo stats.VideoMAEModelAvailable = videoMAEModel.EffectiveExists stats.VideoMAEModelInfo = videoMAEInfo } func trainingStatsModelAvailable(root string) bool { return trainingStatsModelAvailableFor(root, "detector") } func trainingStatsModelAvailableFor(root string, kind string) bool { modelPath := filepath.Join(root, kind, "model", "best.pt") if kind == "videomae" { modelPath = filepath.Join(root, kind, "model", "config.json") } return fileExistsNonEmpty(modelPath) } // trainingReadModelInfo liest Versions-/Datums-Infos zum aktuell trainierten // Detector-Modell. Datum/Version stammen primär aus status.json (vom Trainingsskript), // Fallback ist die Änderungszeit der best.pt-Datei. func trainingReadModelInfo(root string) *TrainingModelInfo { return trainingReadModelInfoFor(root, "detector") } func trainingReadModelInfoFor(root string, kind string) *TrainingModelInfo { modelPath := filepath.Join(root, kind, "model", "best.pt") if kind == "videomae" { modelPath = filepath.Join(root, kind, "model", "config.json") } 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, kind, "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"` Target string `json:"target,omitempty"` Status string `json:"status,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"` PerformanceMode string `json:"performanceMode,omitempty"` CPUCoreCount int `json:"cpuCoreCount,omitempty"` CPUThreads int `json:"cpuThreads,omitempty"` Workers int `json:"workers,omitempty"` YoloBatchSize int `json:"yoloBatchSize,omitempty"` LowPriority bool `json:"lowPriority,omitempty"` AutoPauseEnabled bool `json:"autoPauseEnabled,omitempty"` AutoPauseCPUPercent int `json:"autoPauseCpuPercent,omitempty"` AutoPauseTemperatureC int `json:"autoPauseTemperatureC,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") } // trainingAppendTargetHistory haengt nach einem erfolgreichen Trainingsziel einen // Verlaufseintrag an. Alte History-Zeilen ohne Target bleiben weiterhin lesbar. func trainingHistoryKindForTarget(target string) string { switch strings.ToLower(strings.TrimSpace(target)) { case "pose": return "pose" case "videomae", "video_mae", "scene": return "videomae" default: return "detector" } } func trainingAppendTargetHistory(root string, target string, status string, durationMs int64, runtimeOpts trainingRuntimeOptions, fallback TrainingHistoryEntry) { kind := trainingHistoryKindForTarget(target) info := trainingReadModelInfoFor(root, kind) now := time.Now().UTC() entry := TrainingHistoryEntry{ TrainedAt: now.Format(time.RFC3339), TrainedAtMs: now.UnixMilli(), Target: kind, Status: strings.TrimSpace(status), DurationMs: durationMs, Epochs: fallback.Epochs, TrainSamples: fallback.TrainSamples, ValSamples: fallback.ValSamples, Imgsz: fallback.Imgsz, Device: strings.TrimSpace(fallback.Device), MAP50: fallback.MAP50, MAP5095: fallback.MAP5095, PerformanceMode: runtimeOpts.PerformanceMode, CPUCoreCount: runtimeOpts.CPUCoreCount, CPUThreads: runtimeOpts.CPUThreads, Workers: runtimeOpts.Workers, YoloBatchSize: runtimeOpts.YoloBatchSize, LowPriority: runtimeOpts.LowPriority, AutoPauseEnabled: runtimeOpts.AutoPauseEnabled, AutoPauseCPUPercent: runtimeOpts.AutoPauseCPUPercent, AutoPauseTemperatureC: runtimeOpts.AutoPauseTemperatureC, } if info != nil { if strings.TrimSpace(info.TrainedAt) != "" { entry.TrainedAt = info.TrainedAt } if info.TrainedAtMs > 0 { entry.TrainedAtMs = info.TrainedAtMs } if info.Epochs > 0 { entry.Epochs = info.Epochs } if info.TrainSamples > 0 { entry.TrainSamples = info.TrainSamples } if info.ValSamples > 0 { entry.ValSamples = info.ValSamples } if info.Imgsz > 0 { entry.Imgsz = info.Imgsz } if strings.TrimSpace(info.Device) != "" { entry.Device = strings.TrimSpace(info.Device) } if info.MAP50 > 0 { entry.MAP50 = info.MAP50 } if info.MAP5095 > 0 { entry.MAP5095 = info.MAP5095 } } if entry.DurationMs <= 0 { job := trainingGetJobStatus() if startedAt, err := time.Parse(time.RFC3339, strings.TrimSpace(job.StartedAt)); err == nil { if ms := now.Sub(startedAt).Milliseconds(); ms > 0 { entry.DurationMs = ms } } } b, err := json.Marshal(entry) if err != nil { return } path := trainingHistoryPath(root) if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { appLogln("⚠️ training history dir konnte nicht erstellt werden:", err) return } f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) if err != nil { appLogln("⚠️ training history konnte nicht geöffnet werden:", err) return } defer f.Close() if _, err := f.Write(append(b, '\n')); err != nil { appLogln("⚠️ training history konnte nicht geschrieben werden:", err) } } // trainingReadHistory liest die Verlaufseinträge, neueste zuerst. func trainingReadHistory(root string, limit int) []TrainingHistoryEntry { b, err := os.ReadFile(trainingHistoryPath(root)) if err != nil { return nil } lines := strings.Split(string(b), "\n") entries := make([]TrainingHistoryEntry, 0, len(lines)) for _, line := range lines { line = strings.TrimSpace(line) if line == "" { continue } var e TrainingHistoryEntry if json.Unmarshal([]byte(line), &e) != nil { continue } entries = append(entries, e) } // Datei ist chronologisch → umdrehen, damit der neueste Lauf oben steht. for i, j := 0, len(entries)-1; i < j; i, j = i+1, j-1 { entries[i], entries[j] = entries[j], entries[i] } if limit > 0 && len(entries) > limit { entries = entries[:limit] } return entries } func trainingHistoryHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { trainingWriteError(w, http.StatusMethodNotAllowed, "method not allowed") return } root, err := trainingRootDir() if err != nil { trainingWriteError(w, http.StatusInternalServerError, err.Error()) return } entries := trainingReadHistory(root, 50) if entries == nil { entries = []TrainingHistoryEntry{} } trainingWriteJSON(w, http.StatusOK, TrainingHistoryResponse{ OK: true, Entries: entries, }) } func trainingConfidenceFromScore(score float64) TrainingConfidence { if math.IsNaN(score) || math.IsInf(score, 0) { score = 0 } 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 decisionCount := acceptedCount + correctedCount if decisionCount > 0 { agreementScore = clamp01(float64(acceptedCount) / float64(decisionCount)) } // 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 decisionCount > 0 { correctionRate = clamp01(float64(correctedCount) / float64(decisionCount)) } 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 fileExists(labelPath) { count++ } } return count } func trainingCountPositiveDetectorSamples(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 trainingWritePoseDatasetYAML(root string) error { datasetDir := filepath.Join(root, "pose", "dataset") absDatasetDir, err := filepath.Abs(datasetDir) if err != nil { return err } namesYAML, err := trainingPoseDatasetNamesYAML() 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 kpt_shape: [17, 3] flip_idx: [0, 2, 1, 4, 3, 6, 5, 8, 7, 10, 9, 12, 11, 14, 13, 16, 15] 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 trainingEnsurePoseDirs(root string) error { dirs := []string{ filepath.Join(root, "pose"), filepath.Join(root, "pose", "dataset"), filepath.Join(root, "pose", "dataset", "images"), filepath.Join(root, "pose", "dataset", "images", "train"), filepath.Join(root, "pose", "dataset", "images", "val"), filepath.Join(root, "pose", "dataset", "labels"), filepath.Join(root, "pose", "dataset", "labels", "train"), filepath.Join(root, "pose", "dataset", "labels", "val"), filepath.Join(root, "pose", "runs"), } for _, dir := range dirs { if err := os.MkdirAll(dir, 0755); err != nil { return err } } if err := trainingWritePoseDatasetYAML(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, appErrorf("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 trainingCreateUncertainNextSampleWithProgress(startedAtMs int64, requestID string) (*TrainingSample, error) { root, err := trainingRootDir() if err != nil { return nil, err } candidates, err := trainingReadValidUncertainCandidates(root) if err != nil { return nil, err } // Ziel: // Vor jeder Auswahl sollen wieder 5 Kandidaten verglichen werden. // Wenn noch 4 aus der Queue übrig sind, wird genau 1 neues Bild extrahiert. // Wenn keine Queue existiert, wird ein kompletter 5er-Batch aufgebaut. missing := trainingUncertainCandidateCount - len(candidates) if missing < 1 { // Trotzdem mindestens 1 neues Bild nachziehen, // damit die Auswahl nach jedem Speichern frisch bleibt. missing = 1 } candidateWindowTotal := trainingUncertainCandidateCount const stepsPerCandidate = 4 totalSteps := missing*stepsPerCandidate + 1 errs := []string{} for i := 0; i < missing; i++ { attempt := i + 1 stepStart := i*stepsPerCandidate + 1 prefix := fmt.Sprintf("Kandidat %d/%d: ", attempt, candidateWindowTotal) candidate, err := trainingCreateUncertainCandidateWithProgress( root, startedAtMs, requestID, stepStart, totalSteps, prefix, ) if err != nil { errs = append(errs, err.Error()) trainingPublishAnalysisStep( requestID, startedAtMs, stepStart+stepsPerCandidate-1, totalSteps, "", fmt.Sprintf("Kandidat %d/%d fehlgeschlagen…", attempt, candidateWindowTotal), ) continue } candidates = append(candidates, *candidate) trainingPublishAnalysisStep( requestID, startedAtMs, stepStart+stepsPerCandidate-1, totalSteps, candidate.sample.SourceFile, fmt.Sprintf( "Kandidat %d/%d bewertet · Unsicherheit %.0f%%", attempt, candidateWindowTotal, candidate.score*100, ), ) } if len(candidates) == 0 { if len(errs) > 0 { return nil, errors.New(strings.Join(errs, "; ")) } return nil, errors.New("keine unsicheren Trainingskandidaten gefunden") } sort.Slice(candidates, func(i, j int) bool { if candidates[i].score == candidates[j].score { return candidates[i].sample.CreatedAt < candidates[j].sample.CreatedAt } return candidates[i].score > candidates[j].score }) best := candidates[0] remaining := candidates[1:] if len(remaining) > trainingUncertainCandidateCount-1 { remaining = remaining[:trainingUncertainCandidateCount-1] } if err := trainingWriteUncertainCandidateQueue(root, remaining); err != nil { return nil, err } trainingPublishAnalysisStep( requestID, startedAtMs, totalSteps, totalSteps, best.sample.SourceFile, fmt.Sprintf( "Unsicherster Kandidat gewählt · Score %.0f%% · Fenster %d/%d", best.score*100, len(remaining)+1, trainingUncertainCandidateCount, ), ) best.sample.UncertaintyScore = best.score return best.sample, nil } func reloadAIServerModelAfterTraining() { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() req, err := http.NewRequestWithContext(ctx, http.MethodPost, aiServerURL()+"/reload", nil) if err != nil { appLogln("⚠️ AI Server Reload Request konnte nicht gebaut werden:", err) return } addAIServerAuth(req) client := &http.Client{ Timeout: 30 * time.Second, } resp, err := client.Do(req) if err != nil { appLogln("⚠️ AI Server Reload fehlgeschlagen:", err) return } defer resp.Body.Close() body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) if resp.StatusCode < 200 || resp.StatusCode >= 300 { appLogln("⚠️ AI Server Reload HTTP", resp.StatusCode, strings.TrimSpace(string(body))) return } appLogln("✅ AI Server Modell nach Training neu geladen:", strings.TrimSpace(string(body))) } func trainingDeleteSampleFiles(root string, sampleID string) { sampleID = strings.TrimSpace(sampleID) if sampleID == "" || strings.Contains(sampleID, "/") || strings.Contains(sampleID, "\\") { return } _ = os.Remove(filepath.Join(root, "samples", sampleID+".json")) _ = os.Remove(filepath.Join(root, "frames", sampleID+".jpg")) } func trainingPredictionUncertaintyScore(pred TrainingPrediction) float64 { if !pred.ModelAvailable { return 0.10 } scores := []float64{} addScore := func(score float64) { if math.IsNaN(score) || math.IsInf(score, 0) { return } if score <= 0 { return } scores = append(scores, clamp01(score)) } if !isNoSexPositionLabel(pred.SexPosition) { addScore(pred.SexPositionScore) } for _, box := range pred.Boxes { addScore(box.Score) } if len(pred.Boxes) == 0 { for _, item := range pred.BodyPartsPresent { addScore(item.Score) } for _, item := range pred.ObjectsPresent { addScore(item.Score) } for _, item := range pred.ClothingPresent { addScore(item.Score) } } if len(scores) == 0 { // Modell ist verfügbar, erkennt aber nichts. // Das kann ein nützliches Hard-Negative oder ein False-Negative sein. return 0.35 } sum := 0.0 for _, score := range scores { // Höchste Unsicherheit ungefähr im mittleren Bereich. // 0.55 ist absichtlich etwas niedriger als 0.75, // damit Low/Mid-Confidence-Fälle bevorzugt werden. distance := math.Abs(score - 0.55) uncertainty := 1.0 - distance/0.55 sum += clamp01(uncertainty) } avg := sum / float64(len(scores)) // Viele Boxen mit mittlerer Confidence sind besonders wertvoll. boxBonus := math.Min(0.12, float64(len(pred.Boxes))*0.025) // Sehr niedrige Confidence soll ebenfalls auftauchen, // aber nicht alle Ergebnisse dominieren. lowConfidenceBonus := 0.0 for _, score := range scores { if score >= 0.25 && score <= 0.75 { lowConfidenceBonus = 0.08 break } } return clamp01(avg + boxBonus + lowConfidenceBonus) } func trainingCreateNextSampleWithProgress(startedAtMs int64, requestID string) (*TrainingSample, error) { return trainingCreateNextSampleWithProgressRange( startedAtMs, requestID, 1, 4, "", ) } func trainingCreateNextSampleWithProgressRange( startedAtMs int64, requestID string, stepStart int, stepTotal int, prefix string, ) (*TrainingSample, error) { publishStep := func(localStep int, sourceFile string, message string) { trainingPublishAnalysisStep( requestID, startedAtMs, stepStart+localStep-1, stepTotal, sourceFile, prefix+message, ) } publishStep(1, "", "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 } sourceFile := filepath.Base(videoPath) previewURL := "" publishStep = func(localStep int, sourceFile string, message string) { trainingPublishAnalysisStepWithPreview( requestID, startedAtMs, stepStart+localStep-1, stepTotal, sourceFile, previewURL, prefix+message, ) } publishStep(2, sourceFile, "Bild 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 { publishStep(2, sourceFile, "Bild wird erneut bei Sekunde 0 extrahiert…") second = 0 id = trainingMakeSampleID(videoPath, second) framePath = filepath.Join(root, "frames", id+".jpg") if err2 := trainingExtractFrame(videoPath, framePath, second); err2 != nil { return nil, appErrorf("frame extraction failed: %v / fallback: %w", err, err2) } } previewURL = "/api/training/frame?id=" + url.QueryEscape(id) publishStep(3, sourceFile, "Bild 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: sourceFile, SourcePath: videoPath, SourceSizeBytes: sourceSizeBytes, Second: second, CreatedAt: time.Now().UTC().Format(time.RFC3339), Prediction: prediction, } publishStep(4, sourceFile, "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, } doneDir = strings.TrimSpace(doneDir) if doneDir == "" { return "", errors.New("doneDir ist leer") } doneAbs, err := filepath.Abs(doneDir) if err != nil { return "", err } var files []string err = filepath.WalkDir(doneAbs, func(path string, d os.DirEntry, walkErr error) error { if walkErr != nil { return nil } rel, err := filepath.Rel(doneAbs, path) if err != nil { return nil } rel = filepath.Clean(rel) // Root "done" selbst erlauben. if rel == "." { return nil } parts := strings.Split(rel, string(os.PathSeparator)) top := strings.ToLower(strings.TrimSpace(parts[0])) name := strings.ToLower(strings.TrimSpace(d.Name())) if d.IsDir() { // Nur diese Bereiche fürs Trainingsbild: // - done/ // - done/keep/... // // Alles andere unter done wird ignoriert, z.B.: // .postwork_tmp, .trash, generated, training, sonstige Temp-Ordner. if top != "keep" { return filepath.SkipDir } // Innerhalb von keep trotzdem versteckte/temporäre Ordner überspringen. if name == ".trash" || name == ".postwork_tmp" || name == "training" || name == "generated" || strings.HasPrefix(name, ".") { return filepath.SkipDir } return nil } // Dateien nur direkt in done/ oder unter done/keep/... erlauben. if len(parts) > 1 && top != "keep" { return nil } // Keine versteckten/temp-Dateien verwenden. if strings.HasPrefix(name, ".") || strings.Contains(name, ".tmp.") || strings.Contains(name, ".part") { 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 in done oder done/keep 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, ) hideCommandWindow(cmd) out, err := cmd.CombinedOutput() if err != nil { return appErrorf("%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 { appLogln("⚠️ training predict root error:", err) return trainingEmptyPrediction("root_error") } det := trainingPredictDetector(root, framePath) pred := trainingPredictionFromDetector(det) pose := trainingPredictPose(root, framePath) pred = trainingApplyPoseToPrediction(pred, pose) return pred } func trainingPredictFrameDetectorOnly(framePath string) TrainingPrediction { settings := getSettings() if !settings.TrainingRecognitionEnabled { return trainingEmptyPrediction("recognition_disabled") } root, err := trainingRootDir() if err != nil { appLogln("⚠️ training predict root error:", err) return trainingEmptyPrediction("root_error") } det := trainingPredictDetector(root, framePath) return trainingPredictionFromDetector(det) } func trainingPredictionFromDetector(det TrainingDetectorPrediction) TrainingPrediction { rawBoxes := det.Boxes if rawBoxes == nil { rawBoxes = []TrainingBox{} } pred := TrainingPrediction{ ModelAvailable: det.Available, Source: det.Source, SexPosition: trainingNoSexPositionLabel, SexPositionScore: 0, PeoplePresent: []TrainingScoredLabel{}, BodyPartsPresent: []TrainingScoredLabel{}, ObjectsPresent: []TrainingScoredLabel{}, ClothingPresent: []TrainingScoredLabel{}, Boxes: []TrainingBox{}, } if pred.Source == "" { if det.Available { pred.Source = "yolo26_detector" } else { pred.Source = "detector_missing" } } grouped, err := trainingGroupedLabels() if err != nil { appLogln("⚠️ 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 if peopleSet[label] { visibleBoxes = append(visibleBoxes, box) continue } if detectionSet[label] { visibleBoxes = append(visibleBoxes, box) } } pred.Boxes = visibleBoxes pred.PeoplePresent = trainingScoredLabelsFromDetectorBoxes(visibleBoxes, grouped.People) 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") model := trainingResolveDetectorModel(root) if !model.EffectiveExists { return TrainingDetectorPrediction{ Available: false, Source: model.Source, Boxes: []TrainingBox{}, } } confValues := []string{"0.30"} best := TrainingDetectorPrediction{ Available: true, Source: "yolo26_detector", Boxes: []TrainingBox{}, } for _, conf := range confValues { cmd := exec.Command( python, script, "--root", root, "--image", framePath, "--model", model.EffectivePath, "--conf", conf, "--imgsz", "640", ) hideCommandWindow(cmd) 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 != "" { appLogln("🔎 detector stderr:", errText) } if err != nil { appLogln("⚠️ detector predict failed") appLogln(" conf:", conf) appLogln(" error:", err) appLogln(" stdout:", outText) appLogln(" stderr:", errText) continue } if outText == "" { appLogln("⚠️ detector predict empty stdout") appLogln(" conf:", conf) appLogln(" stderr:", errText) continue } var det TrainingDetectorPrediction if err := json.Unmarshal([]byte(outText), &det); err != nil { appLogln("⚠️ detector predict json failed:", err) appLogln(" conf:", conf) appLogln(" stdout:", outText) appLogln(" stderr:", errText) continue } if det.Boxes == nil { det.Boxes = []TrainingBox{} } det.Source = model.Source 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 { appLogln("⚠️ 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 = "yolo26_detector" } else { pred.Source = pred.Source + "+yolo_detector" } pred.ModelAvailable = true } return pred } func trainingPredictPose(root string, framePath string) TrainingPosePrediction { python := trainingPythonExe() script := trainingScriptPath("predict_pose_model.py") model := trainingResolvePoseModel(root) if !model.EffectiveExists { return TrainingPosePrediction{ Available: false, Source: model.Source, Persons: []TrainingPosePerson{}, } } cmd := exec.Command( python, script, "--root", root, "--image", framePath, "--model", model.EffectivePath, "--conf", "0.30", "--imgsz", "640", ) hideCommandWindow(cmd) 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 != "" { appLogln("🔎 pose stderr:", errText) } if err != nil { appLogln("⚠️ pose predict failed:", err) appLogln(" stdout:", outText) return TrainingPosePrediction{ Available: false, Source: "pose_error", Persons: []TrainingPosePerson{}, } } if outText == "" { return TrainingPosePrediction{ Available: false, Source: "pose_empty", Persons: []TrainingPosePerson{}, } } var pose TrainingPosePrediction if err := json.Unmarshal([]byte(outText), &pose); err != nil { appLogln("⚠️ pose predict json failed:", err) return TrainingPosePrediction{ Available: false, Source: "pose_json_error", Persons: []TrainingPosePerson{}, } } if pose.Persons == nil { pose.Persons = []TrainingPosePerson{} } pose = trainingAnnotatePosePrediction(pose) pose.Source = model.Source return pose } func trainingCombinePositionScore(scores map[string]float64, positionSet map[string]bool, label string, score float64) { label = normalizeSexPositionLabel(label) if isNoSexPositionLabel(label) || !positionSet[label] || score <= 0 { return } score = clamp01(score) current := clamp01(scores[label]) scores[label] = clamp01(1 - (1-current)*(1-score)) } func trainingAppendPredictionSource(pred *TrainingPrediction, source string) { source = strings.TrimSpace(source) if source == "" { return } current := strings.TrimSpace(pred.Source) if current == "" { pred.Source = source return } for _, part := range strings.Split(current, "+") { if strings.TrimSpace(part) == source { return } } pred.Source = current + "+" + source } func trainingFuseHybridPositionScores( poseScores map[string]float64, contextScores map[string]float64, ) (string, float64, bool, bool) { labels := map[string]bool{} for label := range poseScores { if !isNoSexPositionLabel(label) { labels[label] = true } } for label := range contextScores { if !isNoSexPositionLabel(label) { labels[label] = true } } bestPosition := "" bestPositionScore := 0.0 bestHasPose := false bestHasContext := false for label := range labels { poseScore := clamp01(poseScores[label]) contextScore := clamp01(contextScores[label]) score := 0.0 hasPose := poseScore > 0 hasContext := contextScore > 0 if hasPose { if hasContext && contextScore >= trainingPoseConfirmingContextMinScore { score = poseScore // Kontext boostet die Pose nur, wenn er dieselbe Position wirklich stützt. boost := clamp01(contextScore * trainingPositionContextBoostWeight) score = clamp01(1 - (1-score)*(1-boost)) } else { // Unbestätigte Pose bleibt nutzbar, dominiert aber nicht mehr gegen // stärkere Box-/Szenen-Signale. Das reduziert False-Positives bei // falsch erkannten oder schlecht sitzenden Skeletten. maxUnconfirmedScore := trainingPoseUnconfirmedMaxScore if poseScore >= trainingPoseStrongUnconfirmedMinScore { maxUnconfirmedScore = trainingPoseStrongUnconfirmedMaxScore } score = math.Min(maxUnconfirmedScore, poseScore) } } else if contextScore >= trainingPositionContextMinScore { // Reiner Box-/Szenen-Kontext darf eine unsichere Prediction liefern, // aber keine hohe Sicherheit vortaeuschen. score = math.Min(trainingPositionContextMaxScore, contextScore) } if score > bestPositionScore { bestPosition = label bestPositionScore = score bestHasPose = hasPose bestHasContext = hasContext } } return bestPosition, clamp01(bestPositionScore), bestHasPose, bestHasContext } func trainingIsFinite01(v float64) bool { return !math.IsNaN(v) && !math.IsInf(v, 0) && v >= 0 && v <= 1 } func trainingNormalizedBox(box TrainingBox) (TrainingBox, bool) { x := clamp01(box.X) y := clamp01(box.Y) w := clamp01(box.W) h := clamp01(box.H) if w <= 0 || h <= 0 { return TrainingBox{}, false } if x+w > 1 { w = 1 - x } if y+h > 1 { h = 1 - y } if w <= 0 || h <= 0 { return TrainingBox{}, false } box.X = x box.Y = y box.W = w box.H = h return box, true } func trainingBoxArea(box TrainingBox) float64 { box, ok := trainingNormalizedBox(box) if !ok { return 0 } return box.W * box.H } func trainingBoxCenter(box TrainingBox) (float64, float64, bool) { box, ok := trainingNormalizedBox(box) if !ok { return 0, 0, false } return box.X + box.W/2, box.Y + box.H/2, true } func trainingBoxGap(a TrainingBox, b TrainingBox) float64 { a, okA := trainingNormalizedBox(a) b, okB := trainingNormalizedBox(b) if !okA || !okB { return 1 } dx := math.Max(0, math.Max(a.X-(b.X+b.W), b.X-(a.X+a.W))) dy := math.Max(0, math.Max(a.Y-(b.Y+b.H), b.Y-(a.Y+a.H))) return math.Sqrt(dx*dx + dy*dy) } func trainingBoxOverlapRatio(a TrainingBox, b TrainingBox) float64 { a, okA := trainingNormalizedBox(a) b, okB := trainingNormalizedBox(b) if !okA || !okB { return 0 } left := math.Max(a.X, b.X) top := math.Max(a.Y, b.Y) right := math.Min(a.X+a.W, b.X+b.W) bottom := math.Min(a.Y+a.H, b.Y+b.H) if right <= left || bottom <= top { return 0 } intersection := (right - left) * (bottom - top) minArea := math.Min(trainingBoxArea(a), trainingBoxArea(b)) if minArea <= 0 { return 0 } return clamp01(intersection / minArea) } func trainingBoxHorizontalOverlapRatio(a TrainingBox, b TrainingBox) float64 { a, okA := trainingNormalizedBox(a) b, okB := trainingNormalizedBox(b) if !okA || !okB { return 0 } left := math.Max(a.X, b.X) right := math.Min(a.X+a.W, b.X+b.W) if right <= left { return 0 } minWidth := math.Min(a.W, b.W) if minWidth <= 0 { return 0 } return clamp01((right - left) / minWidth) } func trainingBoxVerticalOverlapRatio(a TrainingBox, b TrainingBox) float64 { a, okA := trainingNormalizedBox(a) b, okB := trainingNormalizedBox(b) if !okA || !okB { return 0 } top := math.Max(a.Y, b.Y) bottom := math.Min(a.Y+a.H, b.Y+b.H) if bottom <= top { return 0 } minHeight := math.Min(a.H, b.H) if minHeight <= 0 { return 0 } return clamp01((bottom - top) / minHeight) } func trainingBoxesByLabel(boxes []TrainingBox, labels ...string) []TrainingBox { wanted := map[string]bool{} for _, label := range labels { clean := strings.TrimSpace(label) if clean != "" { wanted[clean] = true } } out := []TrainingBox{} for _, box := range boxes { label := strings.TrimSpace(box.Label) if !wanted[label] { continue } if normalized, ok := trainingNormalizedBox(box); ok { out = append(out, normalized) } } return out } func trainingAnyBoxesNear(a []TrainingBox, b []TrainingBox, margin float64) bool { for _, left := range a { for _, right := range b { if trainingBoxGap(left, right) <= margin || trainingBoxOverlapRatio(left, right) > 0 { return true } } } return false } func trainingPointNearBox(x float64, y float64, box TrainingBox, margin float64) bool { if !trainingIsFinite01(x) || !trainingIsFinite01(y) { return false } box, ok := trainingNormalizedBox(box) if !ok { return false } return x >= box.X-margin && x <= box.X+box.W+margin && y >= box.Y-margin && y <= box.Y+box.H+margin } func trainingAnyPoseKeypointNearBoxes( pose TrainingPosePrediction, keypointNames []string, boxes []TrainingBox, margin float64, ) bool { if len(boxes) == 0 { return false } nameSet := stringSet(keypointNames) for _, person := range pose.Persons { if !trainingPosePersonReliable(person) { continue } for _, point := range person.Keypoints { if !nameSet[strings.TrimSpace(point.Name)] { continue } if point.Conf < trainingPoseKeypointMinConfidence { continue } for _, box := range boxes { if trainingPointNearBox(point.X, point.Y, box, margin) { return true } } } } return false } func trainingPoseKeypointStats(person TrainingPosePerson) (int, float64) { if len(person.Keypoints) == 0 { return 0, 0 } visible := 0 totalConf := 0.0 for _, point := range person.Keypoints { if point.Conf < trainingPoseKeypointMinConfidence || !trainingIsFinite01(point.X) || !trainingIsFinite01(point.Y) { continue } visible++ totalConf += clamp01(point.Conf) } if visible == 0 { return 0, 0 } coverage := clamp01(float64(visible) / float64(trainingPoseKeypointCount)) avgConf := clamp01(totalConf / float64(visible)) return visible, clamp01(coverage*0.45 + avgConf*0.55) } func trainingPoseKeypointQuality(person TrainingPosePerson) float64 { _, quality := trainingPoseKeypointStats(person) return quality } func trainingAnnotatePosePersonQuality(person TrainingPosePerson) TrainingPosePerson { visible, quality := trainingPoseKeypointStats(person) person.VisibleKeypoints = visible person.Quality = quality person.Reliable = person.Score >= trainingPoseReliableMinScore && visible >= trainingPoseReliableMinKeypoints && quality >= trainingPoseReliableMinQuality return person } func trainingPosePersonReliable(person TrainingPosePerson) bool { visible := person.VisibleKeypoints quality := person.Quality if visible == 0 && quality == 0 && len(person.Keypoints) > 0 { visible, quality = trainingPoseKeypointStats(person) } return person.Score >= trainingPoseReliableMinScore && visible >= trainingPoseReliableMinKeypoints && quality >= trainingPoseReliableMinQuality } func trainingAnnotatePosePrediction(pose TrainingPosePrediction) TrainingPosePrediction { for i := range pose.Persons { pose.Persons[i] = trainingAnnotatePosePersonQuality(pose.Persons[i]) } if pose.PersonCount == 0 { pose.PersonCount = len(pose.Persons) } return pose } func trainingReliablePosePrediction(pose TrainingPosePrediction) TrainingPosePrediction { pose = trainingAnnotatePosePrediction(pose) reliable := make([]TrainingPosePerson, 0, len(pose.Persons)) for _, person := range pose.Persons { if trainingPosePersonReliable(person) { reliable = append(reliable, person) } } pose.Persons = reliable pose.PersonCount = len(reliable) return pose } func trainingScenePersonBoxes(pred TrainingPrediction, pose TrainingPosePrediction) []TrainingBox { detectorBoxes := []TrainingBox{} poseBoxes := []TrainingBox{} for _, box := range pred.Boxes { if trainingIsPersonLikeLabel(box.Label) { if normalized, ok := trainingNormalizedBox(box); ok { detectorBoxes = append(detectorBoxes, normalized) } } } for _, person := range pose.Persons { if !trainingPosePersonReliable(person) { continue } if normalized, ok := trainingNormalizedBox(person.Box); ok { poseBoxes = append(poseBoxes, normalized) } } if len(detectorBoxes) >= len(poseBoxes) && len(detectorBoxes) > 0 { return detectorBoxes } return poseBoxes } func trainingIsPersonLikeLabel(label string) bool { label = strings.TrimSpace(label) return label == "person" || strings.HasPrefix(label, "person_") } type trainingPersonPairSignals struct { close bool overlap bool horizontal bool vertical bool stacked bool } func trainingScenePersonPairSignals(boxes []TrainingBox) trainingPersonPairSignals { signals := trainingPersonPairSignals{} for i := 0; i < len(boxes); i++ { a, okA := trainingNormalizedBox(boxes[i]) if !okA { continue } for j := i + 1; j < len(boxes); j++ { b, okB := trainingNormalizedBox(boxes[j]) if !okB { continue } gap := trainingBoxGap(a, b) overlap := trainingBoxOverlapRatio(a, b) close := gap <= 0.08 || overlap >= 0.08 if close { signals.close = true } if overlap >= 0.18 { signals.overlap = true } if close && a.W > a.H*1.15 && b.W > b.H*1.15 { signals.horizontal = true } if close && a.H > a.W*1.25 && b.H > b.W*1.25 { signals.vertical = true } _, ay, okACenter := trainingBoxCenter(a) _, by, okBCenter := trainingBoxCenter(b) if okACenter && okBCenter && gap <= 0.12 && trainingBoxHorizontalOverlapRatio(a, b) >= 0.35 && math.Abs(ay-by) >= 0.12 { signals.stacked = true } } } return signals } type trainingPosePoint struct { x float64 y float64 } type trainingPosePersonGeometry struct { box TrainingBox hasBox bool center trainingPosePoint torsoAngle float64 hasTorsoAxis bool axisX float64 axisY float64 perpX float64 perpY float64 bodyLong float64 bodyCross float64 elongated bool lying bool upright bool straddling bool kneesBelowHips bool kneesWide bool allFours bool bentOrKneeling bool } func trainingPosePointByName(person TrainingPosePerson, name string) (trainingPosePoint, bool) { name = strings.TrimSpace(name) if name == "" { return trainingPosePoint{}, false } for _, point := range person.Keypoints { if strings.TrimSpace(point.Name) != name { continue } if point.Conf < trainingPoseKeypointMinConfidence || !trainingIsFinite01(point.X) || !trainingIsFinite01(point.Y) { continue } return trainingPosePoint{x: point.X, y: point.Y}, true } return trainingPosePoint{}, false } func trainingPoseMidpoint(person TrainingPosePerson, names ...string) (trainingPosePoint, bool) { count := 0 x := 0.0 y := 0.0 for _, name := range names { point, ok := trainingPosePointByName(person, name) if !ok { continue } count++ x += point.x y += point.y } if count == 0 { return trainingPosePoint{}, false } return trainingPosePoint{ x: x / float64(count), y: y / float64(count), }, true } func trainingPosePointDistance(a trainingPosePoint, okA bool, b trainingPosePoint, okB bool) float64 { if !okA || !okB { return 0 } dx := a.x - b.x dy := a.y - b.y return math.Sqrt(dx*dx + dy*dy) } func trainingPoseProjectedDistance(a trainingPosePoint, okA bool, b trainingPosePoint, okB bool, axisX float64, axisY float64) float64 { if !okA || !okB { return 0 } return math.Abs((a.x-b.x)*axisX + (a.y-b.y)*axisY) } func trainingPoseAxisAlignment(a trainingPosePersonGeometry, b trainingPosePersonGeometry) (float64, bool) { if !a.hasTorsoAxis || !b.hasTorsoAxis { return 0, false } // Körperachsen sind richtungslos: 0° und 180° gelten beide als parallel. return math.Abs(math.Cos(a.torsoAngle - b.torsoAngle)), true } func trainingPoseExtentsAlongAxis(person TrainingPosePerson, origin trainingPosePoint, axisX float64, axisY float64, perpX float64, perpY float64) (float64, float64, bool) { minLong := 0.0 maxLong := 0.0 minCross := 0.0 maxCross := 0.0 count := 0 for _, point := range person.Keypoints { if point.Conf < trainingPoseKeypointMinConfidence || !trainingIsFinite01(point.X) || !trainingIsFinite01(point.Y) { continue } dx := point.X - origin.x dy := point.Y - origin.y long := dx*axisX + dy*axisY cross := dx*perpX + dy*perpY if count == 0 { minLong = long maxLong = long minCross = cross maxCross = cross } else { minLong = math.Min(minLong, long) maxLong = math.Max(maxLong, long) minCross = math.Min(minCross, cross) maxCross = math.Max(maxCross, cross) } count++ } if count < 3 { return 0, 0, false } return math.Abs(maxLong - minLong), math.Abs(maxCross - minCross), true } func trainingPosePersonGeometryFor(person TrainingPosePerson) trainingPosePersonGeometry { box, hasBox := trainingNormalizedBox(person.Box) center := trainingPosePoint{x: 0.5, y: 0.5} if hasBox { if x, y, ok := trainingBoxCenter(box); ok { center = trainingPosePoint{x: x, y: y} } } leftHip, okLeftHip := trainingPosePointByName(person, "left_hip") rightHip, okRightHip := trainingPosePointByName(person, "right_hip") leftKnee, okLeftKnee := trainingPosePointByName(person, "left_knee") rightKnee, okRightKnee := trainingPosePointByName(person, "right_knee") hip, okHip := trainingPoseMidpoint(person, "left_hip", "right_hip") shoulder, okShoulder := trainingPoseMidpoint(person, "left_shoulder", "right_shoulder") knee, okKnee := trainingPoseMidpoint(person, "left_knee", "right_knee") if okHip { center = hip } torsoDX := 0.0 torsoDY := 0.0 torsoLen := 0.0 torsoAngle := 0.0 hasTorsoAxis := false axisX := 0.0 axisY := 1.0 perpX := -1.0 perpY := 0.0 if okHip && okShoulder { rawDX := hip.x - shoulder.x rawDY := hip.y - shoulder.y torsoDX = math.Abs(rawDX) torsoDY = math.Abs(rawDY) torsoLen = math.Sqrt(rawDX*rawDX + rawDY*rawDY) if torsoLen >= 0.07 { hasTorsoAxis = true axisX = rawDX / torsoLen axisY = rawDY / torsoLen perpX = -axisY perpY = axisX torsoAngle = math.Atan2(axisY, axisX) } } hipWidth := trainingPosePointDistance(leftHip, okLeftHip, rightHip, okRightHip) kneeWidth := trainingPosePointDistance(leftKnee, okLeftKnee, rightKnee, okRightKnee) kneesBelowHips := okKnee && okHip && knee.y > hip.y+0.045 if hasTorsoAxis && okKnee && okHip { kneeProjection := (knee.x-hip.x)*axisX + (knee.y-hip.y)*axisY kneesBelowHips = kneeProjection > 0.045 } if hasTorsoAxis && okLeftKnee && okRightKnee { kneeWidth = trainingPoseProjectedDistance(leftKnee, okLeftKnee, rightKnee, okRightKnee, perpX, perpY) if okLeftHip && okRightHip { hipWidth = trainingPoseProjectedDistance(leftHip, okLeftHip, rightHip, okRightHip, perpX, perpY) } } kneesWide := kneeWidth > 0 && kneeWidth >= math.Max(0.11, hipWidth*1.12) straddling := kneesBelowHips && kneesWide bodyLong := torsoLen bodyCross := math.Max(hipWidth, kneeWidth) if hasTorsoAxis { if poseLong, poseCross, ok := trainingPoseExtentsAlongAxis(person, center, axisX, axisY, perpX, perpY); ok { bodyLong = math.Max(bodyLong, poseLong) bodyCross = math.Max(bodyCross, poseCross) } if hasBox { boxLong := math.Abs(axisX)*box.W + math.Abs(axisY)*box.H boxCross := math.Abs(perpX)*box.W + math.Abs(perpY)*box.H bodyLong = math.Max(bodyLong, boxLong) bodyCross = math.Max(bodyCross, boxCross) } } else if hasBox { bodyLong = math.Max(box.W, box.H) bodyCross = math.Min(box.W, box.H) } elongated := bodyLong >= math.Max(0.18, bodyCross*1.18) torsoHorizontal := torsoLen >= 0.07 && torsoDX >= torsoDY*1.15 torsoVertical := torsoLen >= 0.07 && torsoDY >= torsoDX*1.15 boxHorizontal := hasBox && box.W >= box.H*1.05 boxVertical := hasBox && box.H >= box.W*1.25 lying := torsoHorizontal || boxHorizontal || (hasTorsoAxis && elongated && !boxVertical) upright := torsoVertical || boxVertical allFours := kneesBelowHips && !straddling && (torsoHorizontal || (hasTorsoAxis && elongated)) bentOrKneeling := allFours || (kneesBelowHips && !straddling) return trainingPosePersonGeometry{ box: box, hasBox: hasBox, center: center, torsoAngle: torsoAngle, hasTorsoAxis: hasTorsoAxis, axisX: axisX, axisY: axisY, perpX: perpX, perpY: perpY, bodyLong: bodyLong, bodyCross: bodyCross, elongated: elongated, lying: lying, upright: upright, straddling: straddling, kneesBelowHips: kneesBelowHips, kneesWide: kneesWide, allFours: allFours, bentOrKneeling: bentOrKneeling, } } func trainingAddPosePairGeometryScores( scores map[string]float64, positionSet map[string]bool, pose TrainingPosePrediction, ) { add := func(label string, score float64) { trainingCombinePositionScore(scores, positionSet, label, score) } pose = trainingReliablePosePrediction(pose) if len(pose.Persons) < 2 { return } geometries := make([]trainingPosePersonGeometry, 0, len(pose.Persons)) for _, person := range pose.Persons { geometries = append(geometries, trainingPosePersonGeometryFor(person)) } for i := 0; i < len(geometries); i++ { left := geometries[i] if !left.hasBox { continue } for j := i + 1; j < len(geometries); j++ { right := geometries[j] if !right.hasBox { continue } gap := trainingBoxGap(left.box, right.box) overlap := trainingBoxOverlapRatio(left.box, right.box) horizontalOverlap := trainingBoxHorizontalOverlapRatio(left.box, right.box) verticalOverlap := trainingBoxVerticalOverlapRatio(left.box, right.box) close := gap <= 0.12 || overlap >= 0.08 if !close { continue } top := left bottom := right if right.center.y < left.center.y { top = right bottom = left } topAbove := top.center.y <= bottom.center.y-0.055 horizontalStack := horizontalOverlap >= 0.35 && (topAbove || overlap >= 0.22) lateralStack := verticalOverlap >= 0.35 && overlap >= 0.12 strongStack := horizontalStack || lateralStack axisAlignment, hasAxisAlignment := trainingPoseAxisAlignment(left, right) axesParallel := hasAxisAlignment && axisAlignment >= 0.74 axesCrossed := hasAxisAlignment && axisAlignment <= 0.56 hasStrongRiderShape := func(g trainingPosePersonGeometry) bool { return g.straddling || (g.kneesWide && g.kneesBelowHips && (g.upright || axesCrossed)) } hasWeakRiderShape := func(g trainingPosePersonGeometry) bool { return g.kneesWide && g.kneesBelowHips } leftHasStrongRiderShape := hasStrongRiderShape(left) rightHasStrongRiderShape := hasStrongRiderShape(right) topHasStrongRiderShape := hasStrongRiderShape(top) topHasWeakRiderShape := hasWeakRiderShape(top) rider := top base := bottom riderHasStrongShape := topHasStrongRiderShape riderHasWeakShape := topHasWeakRiderShape if leftHasStrongRiderShape != rightHasStrongRiderShape { if leftHasStrongRiderShape { rider = left base = right riderHasStrongShape = true riderHasWeakShape = hasWeakRiderShape(left) } else { rider = right base = left riderHasStrongShape = true riderHasWeakShape = hasWeakRiderShape(right) } } if strongStack && riderHasStrongShape && !axesParallel { add("cowgirl", 0.20) add("reverse_cowgirl", 0.17) if base.lying { add("cowgirl", 0.12) add("reverse_cowgirl", 0.10) } if rider.straddling { add("cowgirl", 0.08) add("reverse_cowgirl", 0.06) } } else if strongStack && riderHasWeakShape && !hasAxisAlignment { // Ohne verwertbare Achsen bleibt Cowgirl nur ein schwaches Signal. // Sobald die Achsen parallel sind, sieht die Szene eher nach // Missionary/Überlagerung aus und soll nicht in Cowgirl kippen. add("cowgirl", 0.08) add("reverse_cowgirl", 0.06) } if strongStack && (bottom.lying || (axesParallel && topAbove)) { if !topHasStrongRiderShape || axesParallel { add("missionary", 0.14) } if axesParallel { add("missionary", 0.08) } if !top.straddling && !topHasStrongRiderShape { add("missionary", 0.08) } } bothLying := left.lying && right.lying sameLevel := math.Abs(left.center.y-right.center.y) <= 0.15 sideBySide := math.Abs(left.center.x-right.center.x) >= 0.10 parallelSideBySide := axesParallel && left.elongated && right.elongated && close && !strongStack if (bothLying && (sameLevel || sideBySide)) || parallelSideBySide { add("spooning", 0.18) if overlap >= 0.10 { add("prone_bone", 0.07) } } leftBent := left.allFours || left.bentOrKneeling rightBent := right.allFours || right.bentOrKneeling if leftBent != rightBent { other := right if rightBent { other = left } add("doggy", 0.16) if other.upright { add("doggy", 0.06) add("standing_doggy", 0.07) } if left.lying || right.lying { add("prone_bone", 0.06) } } else if left.allFours || right.allFours { add("doggy", 0.13) if left.lying || right.lying { add("prone_bone", 0.07) } } } } } func trainingAddPoseSceneContextScores( scores map[string]float64, positionSet map[string]bool, pred TrainingPrediction, pose TrainingPosePrediction, ) { add := func(label string, score float64) { trainingCombinePositionScore(scores, positionSet, label, score) } pose = trainingReliablePosePrediction(pose) personBoxes := trainingScenePersonBoxes(pred, pose) personCount := len(personBoxes) if len(pose.Persons) > personCount { personCount = len(pose.Persons) } pair := trainingScenePersonPairSignals(personBoxes) trainingAddPosePairGeometryScores(scores, positionSet, pose) penisBoxes := trainingBoxesByLabel(pred.Boxes, "penis") pussyBoxes := trainingBoxesByLabel(pred.Boxes, "pussy", "vagina", "vulva", "labia") assBoxes := trainingBoxesByLabel(pred.Boxes, "ass", "anus") breastBoxes := trainingBoxesByLabel(pred.Boxes, "breasts") tongueBoxes := trainingBoxesByLabel(pred.Boxes, "tongue") toyBoxes := trainingBoxesByLabel(pred.Boxes, "dildo", "vibrator", "strapon", "buttplug") hasPenis := len(penisBoxes) > 0 hasPussy := len(pussyBoxes) > 0 hasAss := len(assBoxes) > 0 hasBreasts := len(breastBoxes) > 0 hasTongue := len(tongueBoxes) > 0 hasToy := len(toyBoxes) > 0 headNames := []string{"nose", "left_eye", "right_eye", "left_ear", "right_ear"} handNames := []string{"left_wrist", "right_wrist"} hipNames := []string{"left_hip", "right_hip"} headNearPenis := trainingAnyPoseKeypointNearBoxes(pose, headNames, penisBoxes, 0.09) headNearPussy := trainingAnyPoseKeypointNearBoxes(pose, headNames, pussyBoxes, 0.09) headNearAss := trainingAnyPoseKeypointNearBoxes(pose, headNames, assBoxes, 0.09) handNearPenis := trainingAnyPoseKeypointNearBoxes(pose, handNames, penisBoxes, 0.08) handNearPussy := trainingAnyPoseKeypointNearBoxes(pose, handNames, pussyBoxes, 0.08) handNearToy := trainingAnyPoseKeypointNearBoxes(pose, handNames, toyBoxes, 0.08) hipsNearGenitals := trainingAnyPoseKeypointNearBoxes(pose, hipNames, append(penisBoxes, pussyBoxes...), 0.08) if personCount >= 2 { add("missionary", 0.04) add("doggy", 0.04) add("cowgirl", 0.04) add("reverse_cowgirl", 0.04) add("standing_doggy", 0.04) add("spooning", 0.04) add("69", 0.03) } if pair.close { add("missionary", 0.04) add("doggy", 0.04) add("cowgirl", 0.04) add("spooning", 0.04) } if pair.overlap { add("missionary", 0.05) add("cowgirl", 0.05) add("prone_bone", 0.04) } if pair.horizontal { add("spooning", 0.12) add("prone_bone", 0.07) } if pair.vertical { add("standing", 0.08) add("standing_doggy", 0.10) } if pair.stacked { add("missionary", 0.07) add("cowgirl", 0.07) add("reverse_cowgirl", 0.06) add("facesitting", 0.05) } if hasPenis && hasPussy { add("missionary", 0.08) add("doggy", 0.08) add("cowgirl", 0.08) add("reverse_cowgirl", 0.07) add("prone_bone", 0.06) add("standing_doggy", 0.06) add("spooning", 0.05) if trainingAnyBoxesNear(penisBoxes, pussyBoxes, 0.09) || hipsNearGenitals { add("missionary", 0.08) add("doggy", 0.08) add("cowgirl", 0.08) add("reverse_cowgirl", 0.07) } } if hasPenis && (headNearPenis || hasTongue) { add("blowjob", 0.16) if headNearPenis { add("blowjob", 0.10) } } if hasPussy && (headNearPussy || hasTongue || trainingAnyBoxesNear(tongueBoxes, pussyBoxes, 0.08)) { add("cunnilingus", 0.16) if headNearPussy || trainingAnyBoxesNear(tongueBoxes, pussyBoxes, 0.08) { add("cunnilingus", 0.10) } } if hasPenis && handNearPenis { add("handjob", 0.18) } if hasPussy && handNearPussy { add("fingering", 0.18) } if hasPenis && hasBreasts && trainingAnyBoxesNear(penisBoxes, breastBoxes, 0.10) { add("boobjob", 0.20) } if hasToy { add("toy_play", 0.12) if handNearToy || trainingAnyBoxesNear(toyBoxes, pussyBoxes, 0.10) || trainingAnyBoxesNear(toyBoxes, penisBoxes, 0.10) || trainingAnyBoxesNear(toyBoxes, assBoxes, 0.10) { add("toy_play", 0.12) } } if personCount >= 2 && (headNearAss || headNearPussy) { if hasAss { add("facesitting", 0.14) } if hasPussy && hasPenis { add("69", 0.10) } } if hasAss && hasPussy && pair.horizontal { add("doggy", 0.07) add("prone_bone", 0.07) } } func trainingApplyPoseToPrediction(pred TrainingPrediction, pose TrainingPosePrediction) TrainingPrediction { if pose.Available { pred.ModelAvailable = true } positionSet := stringSet(defaultTrainingLabelsFromJSON().SexPositions) poseScores := map[string]float64{} contextScores := map[string]float64{} reliablePose := TrainingPosePrediction{ Available: pose.Available, Source: pose.Source, Persons: []TrainingPosePerson{}, } if pose.Available && len(pose.Persons) > 0 { pose = trainingAnnotatePosePrediction(pose) pred.Persons = pose.Persons reliablePose = trainingReliablePosePrediction(pose) } for _, person := range reliablePose.Persons { label := normalizeSexPositionLabel(person.Label) if isNoSexPositionLabel(label) || !positionSet[label] { continue } trainingCombinePositionScore(poseScores, positionSet, label, person.Score) if quality := trainingPoseKeypointQuality(person); quality > 0 { trainingCombinePositionScore(poseScores, positionSet, label, 0.04*quality) } } trainingAddPoseSceneContextScores(contextScores, positionSet, pred, reliablePose) bestPosition, bestPositionScore, hasPoseSignal, hasContextSignal := trainingFuseHybridPositionScores(poseScores, contextScores) if bestPosition != "" && (hasPoseSignal || bestPositionScore >= trainingPositionContextMinScore) { pred.SexPosition = bestPosition pred.SexPositionScore = clamp01(bestPositionScore) } if pose.Available { trainingAppendPredictionSource(&pred, "yolo_pose") } if hasContextSignal { trainingAppendPredictionSource(&pred, "box_context") } return pred } func trainingDetectorLabelContent( boxes []TrainingBox, classMap map[string]int, allowEmpty bool, ) ([]byte, error) { 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 { if allowEmpty { return []byte{}, nil } return nil, errors.New("no valid detector boxes") } return []byte(strings.Join(lines, "\n") + "\n"), nil } func trainingWriteDetectorSample( root string, sample *TrainingSample, boxes []TrainingBox, allowEmpty bool, ) error { if sample == nil { return errors.New("sample missing") } classMap, err := trainingDetectorClassMap() if err != nil { return err } labelContent, err := trainingDetectorLabelContent(boxes, classMap, allowEmpty) if err != nil { return err } srcFrame := filepath.Join(root, "frames", sample.SampleID+".jpg") if _, err := os.Stat(srcFrame); err != nil { return appErrorf("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 } labelPath := filepath.Join(lblDir, sample.SampleID+".txt") return os.WriteFile(labelPath, labelContent, 0644) } func trainingSexPositionForFeedback(sample *TrainingSample, req TrainingFeedbackRequest) string { if req.Negative { return "" } if req.Correction != nil { return normalizeSexPositionLabel(req.Correction.SexPosition) } if req.Accepted && sample != nil { return normalizeSexPositionLabel(sample.Prediction.SexPosition) } return "" } func trainingPosePersonsForSample(root string, sample *TrainingSample) []TrainingPosePerson { if sample == nil { return nil } if len(sample.Prediction.Persons) > 0 { return sample.Prediction.Persons } framePath := filepath.Join(root, "frames", sample.SampleID+".jpg") if !fileExistsNonEmpty(framePath) { return nil } pose := trainingPredictPose(root, framePath) if !pose.Available || len(pose.Persons) == 0 { return nil } return pose.Persons } func trainingPosePersonsForCorrection(root string, sample *TrainingSample, correction *TrainingCorrection) []TrainingPosePerson { if correction != nil && correction.PosePersons != nil { persons := make([]TrainingPosePerson, 0, len(correction.PosePersons)) for _, person := range correction.PosePersons { persons = append(persons, trainingAnnotatePosePersonQuality(person)) } return persons } return trainingPosePersonsForSample(root, sample) } func trainingPoseContextPersonBoxes(boxes []TrainingBox) []TrainingBox { out := []TrainingBox{} for _, box := range boxes { if !trainingIsPersonLikeLabel(box.Label) { continue } if normalized, ok := trainingNormalizedBox(box); ok { out = append(out, normalized) } } return out } func trainingFilterPosePersonsByContext( persons []TrainingPosePerson, contextBoxes []TrainingBox, ) []TrainingPosePerson { if len(persons) == 0 { return persons } personBoxes := trainingPoseContextPersonBoxes(contextBoxes) if len(personBoxes) == 0 { return persons } filtered := []TrainingPosePerson{} for _, person := range persons { personBox, ok := trainingNormalizedBox(person.Box) if !ok { continue } for _, contextBox := range personBoxes { if trainingBoxOverlapRatio(personBox, contextBox) >= 0.12 || trainingBoxGap(personBox, contextBox) <= 0.08 { filtered = append(filtered, person) break } } } if len(filtered) == 0 { return persons } return filtered } func trainingPoseLabelContent(persons []TrainingPosePerson, classID int) ([]byte, error) { lines := []string{} for _, person := range persons { person = trainingAnnotatePosePersonQuality(person) if !trainingPosePersonReliable(person) { continue } if len(person.Keypoints) < trainingPoseKeypointCount { continue } x := clamp01(person.Box.X) y := clamp01(person.Box.Y) w := clamp01(person.Box.W) h := clamp01(person.Box.H) if w <= 0 || h <= 0 { continue } xCenter := clamp01(x + w/2) yCenter := clamp01(y + h/2) parts := []string{ strconv.Itoa(classID), fmt.Sprintf("%.6f", xCenter), fmt.Sprintf("%.6f", yCenter), fmt.Sprintf("%.6f", w), fmt.Sprintf("%.6f", h), } visible := 0 for i := 0; i < trainingPoseKeypointCount; i++ { kp := person.Keypoints[i] kx := clamp01(kp.X) ky := clamp01(kp.Y) visibility := 0 if kp.Conf >= trainingPoseKeypointMinConfidence && kx > 0 && ky > 0 { visibility = 2 visible++ } else { kx = 0 ky = 0 } parts = append( parts, fmt.Sprintf("%.6f", kx), fmt.Sprintf("%.6f", ky), strconv.Itoa(visibility), ) } if visible < 5 { continue } lines = append(lines, strings.Join(parts, " ")) } if len(lines) == 0 { return nil, errors.New("no valid pose persons") } return []byte(strings.Join(lines, "\n") + "\n"), nil } func trainingWritePoseSample( root string, sample *TrainingSample, sexPosition string, contextBoxes []TrainingBox, correction *TrainingCorrection, ) error { if sample == nil { return errors.New("sample missing") } sexPosition = strings.TrimSpace(sexPosition) if isNoSexPositionLabel(sexPosition) { return errors.New("pose sex position missing") } classMap, err := trainingPoseClassMap() if err != nil { return err } classID, ok := classMap[sexPosition] if !ok { return fmt.Errorf("pose class not configured: %s", sexPosition) } persons := trainingPosePersonsForCorrection(root, sample, correction) persons = trainingFilterPosePersonsByContext(persons, contextBoxes) labelContent, err := trainingPoseLabelContent(persons, classID) if err != nil { return err } srcFrame := filepath.Join(root, "frames", sample.SampleID+".jpg") if _, err := os.Stat(srcFrame); err != nil { return appErrorf("frame missing: %w", err) } split := trainingStableSplit(sample.SampleID) imgDir := filepath.Join(root, "pose", "dataset", "images", split) lblDir := filepath.Join(root, "pose", "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 } labelPath := filepath.Join(lblDir, sample.SampleID+".txt") return os.WriteFile(labelPath, labelContent, 0644) } func trainingDeleteDetectorSample(root string, sampleID string) { sampleID = strings.TrimSpace(sampleID) if sampleID == "" || strings.Contains(sampleID, "/") || strings.Contains(sampleID, "\\") { return } for _, split := range []string{"train", "val"} { labelsDir := filepath.Join(root, "detector", "dataset", "labels", split) imagesDir := filepath.Join(root, "detector", "dataset", "images", split) _ = os.Remove(filepath.Join(labelsDir, sampleID+".txt")) for _, ext := range []string{".jpg", ".jpeg", ".png", ".webp"} { _ = os.Remove(filepath.Join(imagesDir, sampleID+ext)) } } } func trainingDeletePoseSample(root string, sampleID string) { sampleID = strings.TrimSpace(sampleID) if sampleID == "" || strings.Contains(sampleID, "/") || strings.Contains(sampleID, "\\") { return } for _, split := range []string{"train", "val"} { labelsDir := filepath.Join(root, "pose", "dataset", "labels", split) imagesDir := filepath.Join(root, "pose", "dataset", "images", split) _ = os.Remove(filepath.Join(labelsDir, sampleID+".txt")) for _, ext := range []string{".jpg", ".jpeg", ".png", ".webp"} { _ = os.Remove(filepath.Join(imagesDir, sampleID+ext)) } } } func trainingSyncPoseDataset(root string) (int, error) { if err := trainingEnsurePoseDirs(root); err != nil { return 0, err } items, err := trainingReadAnnotations(root) if err != nil { return 0, err } written := 0 for _, item := range items { sampleID := strings.TrimSpace(item.SampleID) if sampleID == "" { continue } sample := &TrainingSample{ SampleID: item.SampleID, FrameURL: item.FrameURL, SourceFile: item.SourceFile, SourcePath: item.SourcePath, SourceSizeBytes: item.SourceSizeBytes, Second: item.Second, CreatedAt: item.CreatedAt, UncertaintyScore: 0, Prediction: item.Prediction, } effective := trainingEffectiveCorrection(item) sexPosition := strings.TrimSpace(effective.SexPosition) trainingDeletePoseSample(root, sampleID) if item.Negative || isNoSexPositionLabel(sexPosition) { continue } if err := trainingWritePoseSample(root, sample, sexPosition, effective.Boxes, &effective); err != nil { appLogln("pose sample sync skipped:", sampleID, err) continue } written++ } return written, nil } 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) currentPositiveVal := trainingCountPositiveDetectorSamples(valImages, valLabels) if currentVal >= minDetectorValCount && currentPositiveVal > 0 { return nil } if trainingCountDetectorSamples(trainImages, trainLabels) < minDetectorTrainCount || trainingCountPositiveDetectorSamples(trainImages, trainLabels) == 0 { 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 := max(0, minDetectorValCount-currentVal) needsPositive := currentPositiveVal == 0 sort.SliceStable(entries, func(i, j int) bool { iID := strings.TrimSuffix(entries[i].Name(), filepath.Ext(entries[i].Name())) jID := strings.TrimSuffix(entries[j].Name(), filepath.Ext(entries[j].Name())) return fileExistsNonEmpty(filepath.Join(trainLabels, iID+".txt")) && !fileExistsNonEmpty(filepath.Join(trainLabels, jID+".txt")) }) for _, e := range entries { if copied >= needed && !needsPositive { 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) || !fileExists(srcLabel) { continue } dstImage := filepath.Join(valImages, e.Name()) dstLabel := filepath.Join(valLabels, id+".txt") if fileExistsNonEmpty(dstImage) && fileExists(dstLabel) { continue } if err := copyFile(srcImage, dstImage); err != nil { return err } if err := copyFile(srcLabel, dstLabel); err != nil { return err } copied++ if fileExistsNonEmpty(srcLabel) { needsPositive = false } } return nil } func trainingEnsurePoseValidationSample(root string) error { trainImages := filepath.Join(root, "pose", "dataset", "images", "train") trainLabels := filepath.Join(root, "pose", "dataset", "labels", "train") valImages := filepath.Join(root, "pose", "dataset", "images", "val") valLabels := filepath.Join(root, "pose", "dataset", "labels", "val") currentVal := trainingCountDetectorSamples(valImages, valLabels) if currentVal >= minPoseValCount { return nil } if trainingCountDetectorSamples(trainImages, trainLabels) < minPoseTrainCount { 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 := max(0, minPoseValCount-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, SexPosition: trainingNoSexPositionLabel, SexPositionScore: 0, PeoplePresent: []TrainingScoredLabel{}, BodyPartsPresent: []TrainingScoredLabel{}, ObjectsPresent: []TrainingScoredLabel{}, ClothingPresent: []TrainingScoredLabel{}, Boxes: []TrainingBox{}, } } func trainingPythonExe() string { v := strings.TrimSpace(os.Getenv("TRAINING_PYTHON")) if v != "" { return v } return aiServerPythonPath() } 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 trainingWriteAnnotations(root string, items []TrainingAnnotation) error { path := filepath.Join(root, "feedback.jsonl") tmpPath := path + ".tmp" var b strings.Builder for _, item := range items { line, err := json.Marshal(item) if err != nil { return err } b.Write(line) b.WriteByte('\n') } if err := os.WriteFile(tmpPath, []byte(b.String()), 0644); err != nil { return err } return os.Rename(tmpPath, path) } 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, ) hideCommandWindow(cmd) 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, }) }