diff --git a/backend/.env b/backend/.env index c1ed77b..e33764f 100644 --- a/backend/.env +++ b/backend/.env @@ -1,2 +1,2 @@ -HTTPS_ENABLED=0 +HTTPS_ENABLED=1 AUTH_RP_ORIGINS=https://l14pbbk95100006.tegdssd.de:9999,https://l14pbbk95100006.tegdssd.de:5173,https://localhost:9999,https://127.0.0.1:9999,https://10.0.1.25:9999,http://localhost:5173,http://127.0.0.1:5173,http://10.0.1.25:5173 \ No newline at end of file diff --git a/backend/__pycache__/ai_server.cpython-314.pyc b/backend/__pycache__/ai_server.cpython-314.pyc index 228399b..0dfeb85 100644 Binary files a/backend/__pycache__/ai_server.cpython-314.pyc and b/backend/__pycache__/ai_server.cpython-314.pyc differ diff --git a/backend/ai_server.py b/backend/ai_server.py index 7e042e7..77a63c1 100644 --- a/backend/ai_server.py +++ b/backend/ai_server.py @@ -12,6 +12,52 @@ from ultralytics import YOLO BASE_DIR = Path(__file__).resolve().parent +KEYPOINT_NAMES = [ + "nose", + "left_eye", "right_eye", + "left_ear", "right_ear", + "left_shoulder", "right_shoulder", + "left_elbow", "right_elbow", + "left_wrist", "right_wrist", + "left_hip", "right_hip", + "left_knee", "right_knee", + "left_ankle", "right_ankle", +] + +NO_SEX_POSITION_LABEL = "keine" +NO_SEX_POSITION_ALIASES = { + "", + NO_SEX_POSITION_LABEL, +} + + +def normalize_sex_position_label(value) -> str: + clean = str(value or "").strip().lower() + if clean in NO_SEX_POSITION_ALIASES: + return NO_SEX_POSITION_LABEL + return clean + + +def is_no_sex_position_label(value) -> bool: + return normalize_sex_position_label(value) == NO_SEX_POSITION_LABEL + + +def normalize_sex_position_labels(values) -> set[str]: + labels = set() + has_no_position = False + + for value in values or []: + clean = normalize_sex_position_label(value) + if is_no_sex_position_label(clean): + has_no_position = True + continue + if clean: + labels.add(clean) + + if has_no_position: + labels.add(NO_SEX_POSITION_LABEL) + + return labels def existing_file(path: Path) -> Optional[Path]: @@ -60,6 +106,7 @@ def resolve_training_root() -> Path: TRAINING_ROOT = resolve_training_root() DEFAULT_MODEL_PATH = TRAINING_ROOT / "detector" / "model" / "best.pt" +DEFAULT_POSE_MODEL_PATH = TRAINING_ROOT / "pose" / "model" / "best.pt" def resolve_detection_labels_path() -> Path: @@ -91,12 +138,26 @@ def resolve_model_path() -> str: raise RuntimeError(f"YOLO model not found: {DEFAULT_MODEL_PATH}") +def resolve_pose_model_path() -> Optional[Path]: + env_path = os.environ.get("YOLO_POSE_MODEL", "").strip() + if env_path: + p = Path(env_path).expanduser().resolve() + if existing_file(p): + return p + raise RuntimeError(f"YOLO_POSE_MODEL not found: {p}") + + if existing_file(DEFAULT_POSE_MODEL_PATH): + return DEFAULT_POSE_MODEL_PATH.resolve() + + return None + + # Server darf auch ohne Labels/Model starten. DETECTION_LABELS_PATH: Optional[Path] = None LABEL_GROUPS = { "people": set(), - "sexPositions": {"unknown"}, + "sexPositions": {NO_SEX_POSITION_LABEL}, "bodyParts": set(), "objects": set(), "clothing": set(), @@ -114,15 +175,19 @@ PERSON_LABELS = { _MODEL_PATH = "" _MODEL_ERROR = "" +_POSE_MODEL_PATH = "" +_POSE_MODEL_ERROR = "" _LABEL_ERROR = "" _DEVICE = os.environ.get("YOLO_DEVICE", "") _CONF = float(os.environ.get("YOLO_CONF", "0.25")) +_POSE_CONF = float(os.environ.get("YOLO_POSE_CONF", "0.30")) _BATCH = int(os.environ.get("YOLO_BATCH", "16")) _IMGSZ = int(os.environ.get("YOLO_IMGSZ", "640")) _HALF = os.environ.get("YOLO_HALF", "0").lower() in {"1", "true", "yes", "on"} model = None +pose_model = None app = FastAPI() @@ -174,13 +239,14 @@ def empty_prediction(source: str = "model_missing") -> dict: return { "modelAvailable": False, "source": source, - "sexPosition": "unknown", + "sexPosition": NO_SEX_POSITION_LABEL, "sexPositionScore": 0.0, "peoplePresent": [], "bodyPartsPresent": [], "objectsPresent": [], "clothingPresent": [], "boxes": [], + "persons": [], } @@ -207,11 +273,7 @@ def load_label_groups_safe() -> None: for x in data.get("people", []) if str(x).strip() ), - "sexPositions": set( - str(x).strip().lower() - for x in data.get("sexPositions", []) - if str(x).strip() - ), + "sexPositions": normalize_sex_position_labels(data.get("sexPositions", [])), "bodyParts": set( str(x).strip().lower() for x in data.get("bodyParts", []) @@ -230,7 +292,7 @@ def load_label_groups_safe() -> None: } if not LABEL_GROUPS["sexPositions"]: - LABEL_GROUPS["sexPositions"] = {"unknown"} + LABEL_GROUPS["sexPositions"] = {NO_SEX_POSITION_LABEL} _LABEL_ERROR = "" @@ -240,7 +302,7 @@ def load_label_groups_safe() -> None: LABEL_GROUPS = { "people": set(), - "sexPositions": {"unknown"}, + "sexPositions": {NO_SEX_POSITION_LABEL}, "bodyParts": set(), "objects": set(), "clothing": set(), @@ -252,7 +314,7 @@ def load_label_groups_safe() -> None: POSITION_LABELS = { label for label in LABEL_GROUPS["sexPositions"] - if label and label != "unknown" + if label and not is_no_sex_position_label(label) } PERSON_LABELS = { @@ -289,6 +351,37 @@ def get_model(): return None +def get_pose_model(): + global pose_model + global _POSE_MODEL_PATH + global _POSE_MODEL_ERROR + + if pose_model is not None: + return pose_model + + try: + path = resolve_pose_model_path() + if path is None: + pose_model = None + _POSE_MODEL_PATH = "" + _POSE_MODEL_ERROR = "" + return None + + loaded = YOLO(str(path)) + + pose_model = loaded + _POSE_MODEL_PATH = str(path) + _POSE_MODEL_ERROR = "" + + return pose_model + + except Exception as exc: + pose_model = None + _POSE_MODEL_PATH = "" + _POSE_MODEL_ERROR = str(exc) + return None + + def scored(label: str, score: float) -> dict: return { "label": label, @@ -315,9 +408,6 @@ def prediction_from_result(result) -> dict: objects = [] clothing = [] - sex_position = "unknown" - sex_position_score = 0.0 - if result.boxes is not None: xywhn = result.boxes.xywhn.cpu().tolist() cls_values = result.boxes.cls.cpu().tolist() @@ -341,13 +431,6 @@ def prediction_from_result(result) -> dict: is_body = label in BODY_LABELS is_object = label in OBJECT_LABELS is_clothing = label in CLOTHING_LABELS - is_position = label in POSITION_LABELS - - if is_position: - if score > sex_position_score: - sex_position = label - sex_position_score = score - continue if not (is_person or is_body or is_object or is_clothing): continue @@ -376,13 +459,165 @@ def prediction_from_result(result) -> dict: return { "modelAvailable": True, "source": f"yolo-server:{Path(_MODEL_PATH).name}", - "sexPosition": sex_position, - "sexPositionScore": sex_position_score, + "sexPosition": NO_SEX_POSITION_LABEL, + "sexPositionScore": 0.0, "peoplePresent": people_present, "bodyPartsPresent": body_parts, "objectsPresent": objects, "clothingPresent": clothing, "boxes": boxes_out, + "persons": [], + } + + +def pose_persons_from_result(result) -> list[dict]: + names = result.names or {} + persons = [] + + if result.boxes is None: + return persons + + kpts_xyn = None + kpts_conf = None + + if result.keypoints is not None: + try: + kpts_xyn = result.keypoints.xyn.cpu().tolist() + except Exception: + kpts_xyn = None + try: + kpts_conf = result.keypoints.conf.cpu().tolist() + except Exception: + kpts_conf = None + + xywhn_values = result.boxes.xywhn.cpu().tolist() + cls_values = result.boxes.cls.cpu().tolist() + conf_values = result.boxes.conf.cpu().tolist() + + for i, (box_xywhn, cls_id, conf) in enumerate( + zip(xywhn_values, cls_values, conf_values) + ): + label = str(names.get(int(cls_id), int(cls_id))).strip().lower() + score = float(conf) + + cx, cy, w, h = [float(v) for v in box_xywhn] + x = max(0.0, min(1.0, cx - w / 2.0)) + y = max(0.0, min(1.0, cy - h / 2.0)) + w = max(0.0, min(1.0 - x, w)) + h = max(0.0, min(1.0 - y, h)) + + if w <= 0 or h <= 0: + continue + + keypoints = [] + if kpts_xyn is not None and i < len(kpts_xyn): + for ki, point in enumerate(kpts_xyn[i]): + if len(point) < 2: + continue + + kconf = 0.0 + if ( + kpts_conf is not None + and i < len(kpts_conf) + and ki < len(kpts_conf[i]) + ): + kconf = float(kpts_conf[i][ki]) + + keypoints.append({ + "name": KEYPOINT_NAMES[ki] if ki < len(KEYPOINT_NAMES) else str(ki), + "x": float(point[0]), + "y": float(point[1]), + "conf": kconf, + }) + + persons.append({ + "label": label, + "score": score, + "box": { + "x": x, + "y": y, + "w": w, + "h": h, + }, + "keypoints": keypoints, + }) + + return persons + + +def apply_pose_result_to_prediction(prediction: dict, result) -> dict: + persons = pose_persons_from_result(result) + if not persons: + return prediction + + prediction["persons"] = persons + + best_position = "" + best_score_value = 0.0 + + for person in persons: + label = str(person.get("label") or "").strip().lower() + score = float(person.get("score") or 0.0) + + if is_no_sex_position_label(label) or label not in POSITION_LABELS: + continue + + if score > best_score_value: + best_position = label + best_score_value = score + + if best_position: + prediction["sexPosition"] = best_position + prediction["sexPositionScore"] = best_score_value + + source = str(prediction.get("source") or "").strip() + prediction["source"] = f"{source}+yolo_pose" if source else "yolo_pose" + + return prediction + + +def apply_pose_batch_to_predictions(paths: list[str], predictions: list[dict], imgsz: int) -> None: + global _POSE_MODEL_ERROR + + current_pose_model = get_pose_model() + if current_pose_model is None: + return + + try: + pose_results = current_pose_model.predict( + source=paths, + imgsz=imgsz, + conf=_POSE_CONF, + batch=_BATCH, + device=_DEVICE or None, + half=_HALF, + verbose=False, + ) + except Exception as exc: + _POSE_MODEL_ERROR = str(exc) + return + + for prediction, pose_result in zip(predictions, pose_results): + apply_pose_result_to_prediction(prediction, pose_result) + + +def pose_model_status() -> dict: + try: + expected = resolve_pose_model_path() + expected_text = str(expected) if expected else str(DEFAULT_POSE_MODEL_PATH) + exists = expected is not None + error = _POSE_MODEL_ERROR + except Exception as exc: + expected_text = str(DEFAULT_POSE_MODEL_PATH) + exists = False + error = str(exc) + + return { + "poseModelAvailable": exists, + "poseModelLoaded": pose_model is not None, + "poseModel": _POSE_MODEL_PATH or (expected_text if exists else ""), + "poseModelError": error, + "expectedPoseModel": expected_text, } @@ -426,6 +661,9 @@ def predict_batch(req: PredictBatchRequest): predictions = [prediction_from_result(result) for result in results] + if not req.detectorOnly: + apply_pose_batch_to_predictions(paths, predictions, imgsz) + return { "ok": True, "predictions": predictions, @@ -444,7 +682,7 @@ def health(): current_model = get_model() names = getattr(current_model, "names", {}) or {} if current_model is not None else {} - return { + status_payload = { "ok": True, "ready": current_model is not None, "modelAvailable": current_model is not None, @@ -458,22 +696,31 @@ def health(): "labelError": _LABEL_ERROR, } + status_payload.update(pose_model_status()) + return status_payload + @app.post("/reload", dependencies=[Depends(require_ai_server_auth)]) def reload_model(): global model + global pose_model global _MODEL_PATH global _MODEL_ERROR + global _POSE_MODEL_PATH + global _POSE_MODEL_ERROR global DETECTION_LABELS_PATH model = None + pose_model = None _MODEL_PATH = "" _MODEL_ERROR = "" + _POSE_MODEL_PATH = "" + _POSE_MODEL_ERROR = "" DETECTION_LABELS_PATH = None current_model = get_model() names = getattr(current_model, "names", {}) or {} if current_model is not None else {} - return { + status_payload = { "ok": current_model is not None, "ready": current_model is not None, "modelAvailable": current_model is not None, @@ -485,4 +732,7 @@ def reload_model(): "classes": list(names.values())[:80] if isinstance(names, dict) else names, "labelConfig": str(DETECTION_LABELS_PATH) if DETECTION_LABELS_PATH else "", "labelError": _LABEL_ERROR, - } \ No newline at end of file + } + + status_payload.update(pose_model_status()) + return status_payload diff --git a/backend/analyze.go b/backend/analyze.go index 34f0820..2e8519e 100644 --- a/backend/analyze.go +++ b/backend/analyze.go @@ -135,7 +135,7 @@ func autoSelectedAILabelSet() map[string]struct{} { add := func(values []string) { for _, value := range values { label := strings.ToLower(strings.TrimSpace(value)) - if label == "" || label == "unknown" { + if isNoSexPositionLabel(label) { continue } out[label] = struct{}{} @@ -155,7 +155,7 @@ var autoSelectedAILabelsCache map[string]struct{} func shouldAutoSelectAnalyzeHit(label string) bool { label = strings.ToLower(strings.TrimSpace(label)) - if label == "" || label == "unknown" { + if isNoSexPositionLabel(label) { return false } @@ -202,7 +202,7 @@ func isIgnoredNSFWLabel(label string) bool { func addHighlightResult(best map[string]float64, label string, score float64) { label = strings.ToLower(strings.TrimSpace(label)) - if label == "" || label == "unknown" { + if isNoSexPositionLabel(label) { return } @@ -223,7 +223,7 @@ func addScoredHighlightLabels(best map[string]float64, prefix string, items []Tr for _, item := range items { label := strings.ToLower(strings.TrimSpace(item.Label)) - if label == "" || label == "unknown" { + if isNoSexPositionLabel(label) { continue } @@ -235,7 +235,7 @@ func trainingPredictionToHighlightResults(pred TrainingPrediction) []NsfwFrameRe best := map[string]float64{} sexPosition := strings.ToLower(strings.TrimSpace(pred.SexPosition)) - if sexPosition != "" && sexPosition != "unknown" && isKnownPositionLabel(sexPosition) { + if !isNoSexPositionLabel(sexPosition) && isKnownPositionLabel(sexPosition) { addHighlightResult(best, "position:"+sexPosition, pred.SexPositionScore) } @@ -245,7 +245,7 @@ func trainingPredictionToHighlightResults(pred TrainingPrediction) []NsfwFrameRe for _, box := range pred.Boxes { label := strings.ToLower(strings.TrimSpace(box.Label)) - if label == "" || label == "unknown" { + if isNoSexPositionLabel(label) { continue } @@ -264,7 +264,7 @@ func trainingPredictionToHighlightResults(pred TrainingPrediction) []NsfwFrameRe } // Kombis nur erzeugen, wenn wirklich Position + Zusatz vorhanden ist. - if sexPosition != "" && sexPosition != "unknown" && isKnownPositionLabel(sexPosition) { + if !isNoSexPositionLabel(sexPosition) && isKnownPositionLabel(sexPosition) { positionScore := pred.SexPositionScore if positionScore <= 0 { positionScore = 1 @@ -273,7 +273,7 @@ func trainingPredictionToHighlightResults(pred TrainingPrediction) []NsfwFrameRe addCombo := func(prefix string, items []TrainingScoredLabel) { for _, item := range items { label := strings.ToLower(strings.TrimSpace(item.Label)) - if label == "" || label == "unknown" { + if isNoSexPositionLabel(label) { continue } @@ -316,7 +316,7 @@ func pickHighlightResults(results []NsfwFrameResult) []NsfwFrameResult { for _, r := range results { label := strings.ToLower(strings.TrimSpace(r.Label)) - if label == "" || label == "unknown" { + if isNoSexPositionLabel(label) { continue } @@ -1075,7 +1075,7 @@ type highlightSignal struct { func normalizeHighlightSignalLabel(label string) string { label = strings.ToLower(strings.TrimSpace(label)) - if label == "" || label == "unknown" { + if isNoSexPositionLabel(label) { return "" } @@ -1094,28 +1094,28 @@ func normalizeHighlightSignalLabel(label string) string { case strings.HasPrefix(label, "body:"): raw := strings.TrimPrefix(label, "body:") - if raw == "" || raw == "unknown" { + if isNoSexPositionLabel(raw) { return "" } return "body:" + raw case strings.HasPrefix(label, "object:"): raw := strings.TrimPrefix(label, "object:") - if raw == "" || raw == "unknown" { + if isNoSexPositionLabel(raw) { return "" } return "object:" + raw case strings.HasPrefix(label, "clothing:"): raw := strings.TrimPrefix(label, "clothing:") - if raw == "" || raw == "unknown" { + if isNoSexPositionLabel(raw) { return "" } return "clothing:" + raw case strings.HasPrefix(label, "position:"): raw := strings.TrimPrefix(label, "position:") - if raw == "" || raw == "unknown" || !isKnownPositionLabel(raw) { + if isNoSexPositionLabel(raw) || !isKnownPositionLabel(raw) { return "" } return "position:" + raw @@ -1173,7 +1173,7 @@ func rawAnalyzeSignalSeverityWeight(label string) float64 { } raw := normalizeSegmentLabel(label) - if raw == "" || raw == "unknown" { + if isNoSexPositionLabel(raw) { return 0 } @@ -1273,7 +1273,7 @@ func addHighlightSignalsFromScoredLabels( for _, item := range items { label := strings.ToLower(strings.TrimSpace(item.Label)) - if label == "" || label == "unknown" { + if isNoSexPositionLabel(label) { continue } @@ -1302,7 +1302,7 @@ func highlightComboPartOrder(label string) int { func predictionHasAnyAnalyzeSignal(pred TrainingPrediction) bool { sexPosition := strings.ToLower(strings.TrimSpace(pred.SexPosition)) - if sexPosition != "" && sexPosition != "unknown" { + if !isNoSexPositionLabel(sexPosition) { return true } @@ -1342,7 +1342,7 @@ func buildCombinedHighlightHitFromPrediction(pred TrainingPrediction, t float64) sexPosition := strings.ToLower(strings.TrimSpace(pred.SexPosition)) - if sexPosition != "" && sexPosition != "unknown" && isKnownPositionLabel(sexPosition) { + if !isNoSexPositionLabel(sexPosition) && isKnownPositionLabel(sexPosition) { positionScore := pred.SexPositionScore if positionScore <= 0 { positionScore = 0.35 @@ -1364,7 +1364,7 @@ func buildCombinedHighlightHitFromPrediction(pred TrainingPrediction, t float64) for _, box := range pred.Boxes { label := strings.ToLower(strings.TrimSpace(box.Label)) - if label == "" || label == "unknown" { + if isNoSexPositionLabel(label) { continue } if isIgnoredNSFWLabel(label) { @@ -1540,7 +1540,7 @@ func buildHighlightHitsFromPrediction(pred TrainingPrediction, t float64) []anal best := map[string]highlightSignal{} sexPosition := strings.ToLower(strings.TrimSpace(pred.SexPosition)) - if sexPosition != "" && sexPosition != "unknown" && isKnownPositionLabel(sexPosition) { + if !isNoSexPositionLabel(sexPosition) && isKnownPositionLabel(sexPosition) { addHighlightSignal(best, "position:"+sexPosition, pred.SexPositionScore) } @@ -1550,7 +1550,7 @@ func buildHighlightHitsFromPrediction(pred TrainingPrediction, t float64) []anal for _, box := range pred.Boxes { label := strings.ToLower(strings.TrimSpace(box.Label)) - if label == "" || label == "unknown" { + if isNoSexPositionLabel(label) { continue } if isIgnoredNSFWLabel(label) { @@ -2322,7 +2322,7 @@ func mergeAnalyzeHits(in []analyzeHit) []analyzeHit { for _, h := range in { label := strings.ToLower(strings.TrimSpace(h.Label)) - if label == "" || label == "unknown" { + if isNoSexPositionLabel(label) { continue } if isIgnoredNSFWLabel(label) || isPersonSegmentLabel(label) { @@ -2542,7 +2542,7 @@ type analyzeLabelSegmentPoint struct { func isAllowedAnalyzeSegmentLabel(label string) bool { label = strings.ToLower(strings.TrimSpace(label)) - if label == "" || label == "unknown" { + if isNoSexPositionLabel(label) { return false } if isIgnoredNSFWLabel(label) || isPersonSegmentLabel(label) { @@ -2559,7 +2559,7 @@ func isAllowedAnalyzeSegmentLabel(label string) bool { } raw := normalizeSegmentLabel(label) - if raw == "" || raw == "unknown" { + if isNoSexPositionLabel(raw) { return false } diff --git a/backend/disk_guard.go b/backend/disk_guard.go index 411f07c..eac11d3 100644 --- a/backend/disk_guard.go +++ b/backend/disk_guard.go @@ -76,20 +76,29 @@ func stopJobsInternal(list []*RecordJob) { } pl := make([]payload, 0, len(list)) + ids := make([]string, 0, len(list)) + nowMs := time.Now().UnixMilli() jobsMu.Lock() for _, job := range list { if job == nil { continue } + if job.EndedAt != nil || isTerminalJobStatus(job.Status) || isPostworkJob(job) { + continue + } + job.Phase = "stopping" job.Progress = 10 + job.StopRequestedAtMs = nowMs + job.StopAttempts++ pl = append(pl, payload{ cmd: job.previewCmd, cancel: job.cancel, previewCancel: job.previewCancel, }) + ids = append(ids, job.ID) job.previewCmd = nil job.previewCancel = nil @@ -98,6 +107,11 @@ func stopJobsInternal(list []*RecordJob) { } jobsMu.Unlock() + for _, id := range ids { + _ = publishJobSnapshot(id) + _ = publishModelJobUpsert(id) + } + for _, p := range pl { if p.previewCancel != nil { p.previewCancel() @@ -109,6 +123,11 @@ func stopJobsInternal(list []*RecordJob) { p.cancel() } } + + for _, id := range ids { + _ = publishJobSnapshot(id) + _ = publishModelJobUpsert(id) + } } func stopAllStoppableJobs() int { diff --git a/backend/embed.go b/backend/embed.go index 68c3a01..2b9bee4 100644 --- a/backend/embed.go +++ b/backend/embed.go @@ -8,9 +8,17 @@ import ( "path/filepath" ) -//go:embed ml/*.py ai_server.py .env recorder-cert.pem recorder-key.pem generated/training/detection_labels.json +//go:embed ml/*.py ml/detection_labels.json ai_server.py .env recorder-cert.pem recorder-key.pem yolo26n-pose.pt var embeddedMLFiles embed.FS +func embeddedWriteFileIfNeeded(dstPath string, b []byte, perm os.FileMode) error { + if fi, err := os.Stat(dstPath); err == nil && fi != nil && !fi.IsDir() && fi.Size() == int64(len(b)) { + return nil + } + + return os.WriteFile(dstPath, b, perm) +} + func trainingEmbeddedMLDir() (string, error) { dir := filepath.Join(os.TempDir(), "nsfwapp-ml") @@ -22,6 +30,8 @@ func trainingEmbeddedMLDir() (string, error) { "predict_detector_model.py", "train_detector_model.py", "detection_labels.json", + "predict_pose_model.py", + "train_pose_model.py", } // Falls du die alten Scene-Skripte noch embedded hast, kannst du sie optional mitkopieren. @@ -33,10 +43,6 @@ func trainingEmbeddedMLDir() (string, error) { for _, name := range append(files, optionalFiles...) { srcPath := filepath.ToSlash(filepath.Join("ml", name)) - if name == "detection_labels.json" { - srcPath = "generated/training/detection_labels.json" - } - b, err := embeddedMLFiles.ReadFile(srcPath) if err != nil { // Pflichtdateien müssen vorhanden sein. @@ -51,14 +57,24 @@ func trainingEmbeddedMLDir() (string, error) { } dstPath := filepath.Join(dir, name) - if err := os.WriteFile(dstPath, b, 0644); err != nil { + if err := embeddedWriteFileIfNeeded(dstPath, b, 0644); err != nil { return "", err } } + if _, err := embeddedPoseModelPath(); err != nil { + return "", err + } + return dir, nil } +func embeddedMLScriptExists(name string) bool { + srcPath := filepath.ToSlash(filepath.Join("ml", name)) + _, err := embeddedMLFiles.ReadFile(srcPath) + return err == nil +} + func embeddedAIServerDir() (string, error) { dir := filepath.Join(os.TempDir(), "nsfwapp-ai-server") @@ -72,13 +88,33 @@ func embeddedAIServerDir() (string, error) { } dstPath := filepath.Join(dir, "ai_server.py") - if err := os.WriteFile(dstPath, b, 0644); err != nil { + if err := embeddedWriteFileIfNeeded(dstPath, b, 0644); err != nil { return "", err } return dir, nil } +func embeddedPoseModelPath() (string, error) { + dir := filepath.Join(os.TempDir(), "nsfwapp-ml") + + if err := os.MkdirAll(dir, 0755); err != nil { + return "", err + } + + b, err := embeddedMLFiles.ReadFile("yolo26n-pose.pt") + if err != nil { + return "", err + } + + dstPath := filepath.Join(dir, "yolo26n-pose.pt") + if err := embeddedWriteFileIfNeeded(dstPath, b, 0644); err != nil { + return "", err + } + + return dstPath, nil +} + func embeddedDotEnvBytes() ([]byte, error) { return embeddedMLFiles.ReadFile(".env") } diff --git a/backend/log.go b/backend/log.go index 91cca93..bbde88b 100644 --- a/backend/log.go +++ b/backend/log.go @@ -103,6 +103,9 @@ func appLogWriter() io.Writer { func appLogWriteLine(line string) { line = strings.TrimRight(line, "\r\n") + if appLogLineSuppressed(line) { + return + } ts := time.Now().Format("2006-01-02 15:04:05") out := fmt.Sprintf("[%s] %s\n", ts, line) @@ -130,11 +133,71 @@ func appLogf(format string, args ...any) { // erwartbare und sehr häufige Fehler (z.B. abgelaufene HLS-Edge-URLs während // eines Stream-Reconnects). Der Fehler wird weiterhin zurückgegeben, damit die // Retry-/Reconnect-Logik unverändert funktioniert – nur die Log-Zeile entfällt. +func appLogLineSuppressed(line string) bool { + m := strings.ToLower(strings.TrimSpace(line)) + if m == "" { + return true + } + + if strings.Contains(m, "get playlist: context canceled") { + return true + } + + if strings.Contains(m, "usernamelookup ohne user.id") { + return true + } + + if strings.Contains(m, "mfc status failed") && + (strings.Contains(m, "user not found") || strings.Contains(m, "usernamelookup")) { + return true + } + + if strings.Contains(m, "mfc playlist candidate failed") && + (strings.Contains(m, "http 403") || strings.Contains(m, "http 404")) { + return true + } + + if strings.Contains(m, "mfc lookup:") { + return true + } + + isHLS := strings.Contains(m, ".m3u8") || + strings.Contains(m, "abruf der m3u8") || + strings.Contains(m, "variant-playlist") + + if isHLS && strings.Contains(m, "http 403") { + return true + } + + if isHLS && (strings.Contains(m, "http 500") || + strings.Contains(m, "http 502") || + strings.Contains(m, "http 503") || + strings.Contains(m, "http 504")) { + return true + } + + return false +} + +func appLogFilterText(text string) string { + lines := strings.Split(text, "\n") + out := make([]string, 0, len(lines)) + + for _, line := range lines { + if appLogLineSuppressed(line) { + continue + } + out = append(out, line) + } + + return strings.Join(out, "\n") +} + func appErrorLogSuppressed(msg string) bool { m := strings.ToLower(msg) - return strings.Contains(m, "leere hls url") || - strings.Contains(m, "http 403") + return appLogLineSuppressed(msg) || + strings.Contains(m, "leere hls url") } func appErrorf(format string, args ...any) error { @@ -243,7 +306,7 @@ func appLogHandler(w http.ResponseWriter, r *http.Request) { } } - logText := string(buf) + logText := appLogFilterText(string(buf)) if offset > 0 { logText = "… Log gekürzt, zeige die letzten " + fmt.Sprint(maxBytes/1024) + " KB …\n\n" + logText } diff --git a/backend/main.go b/backend/main.go index ed3dc50..f3cc8d8 100644 --- a/backend/main.go +++ b/backend/main.go @@ -95,8 +95,10 @@ type RecordJob struct { PreviewUA string `json:"-"` // user-agent // ✅ Frontend Progress beim Stop/Finalize - Phase string `json:"phase,omitempty"` // stopping | remuxing | moving - Progress int `json:"progress,omitempty"` // 0..100 + Phase string `json:"phase,omitempty"` // stopping | remuxing | moving + Progress int `json:"progress,omitempty"` // 0..100 + StopRequestedAtMs int64 `json:"stopRequestedAtMs,omitempty"` // ms since epoch + StopAttempts int `json:"stopAttempts,omitempty"` PostWorkKey string `json:"postWorkKey,omitempty"` PostWork *PostWorkKeyStatus `json:"postWork,omitempty"` @@ -105,24 +107,26 @@ type RecordJob struct { } type jobEventSnapshot struct { - ID string - SourceURL string - Output string - Status JobStatus - StartedAt time.Time - StartedAtMs int64 - EndedAt *time.Time - EndedAtMs int64 - Error string - Phase string - Progress int - SizeBytes int64 - DurationSeconds float64 - PreviewState string - PostWorkKey string - PostWork *PostWorkKeyStatus - ModelImageURL string - Avatar string + ID string + SourceURL string + Output string + Status JobStatus + StartedAt time.Time + StartedAtMs int64 + EndedAt *time.Time + EndedAtMs int64 + Error string + Phase string + Progress int + StopRequestedAtMs int64 + StopAttempts int + SizeBytes int64 + DurationSeconds float64 + PreviewState string + PostWorkKey string + PostWork *PostWorkKeyStatus + ModelImageURL string + Avatar string } type dummyResponseWriter struct { @@ -165,25 +169,27 @@ func canonicalAssetIDFromName(name string) string { func publishJobUpsertSnapshot(s jobEventSnapshot) { ev := map[string]any{ - "type": "job_upsert", - "jobId": s.ID, - "status": string(s.Status), - "sourceUrl": s.SourceURL, - "output": s.Output, - "startedAt": s.StartedAt, - "startedAtMs": s.StartedAtMs, - "endedAt": s.EndedAt, - "endedAtMs": s.EndedAtMs, - "error": s.Error, - "phase": s.Phase, - "progress": s.Progress, - "sizeBytes": s.SizeBytes, - "durationSeconds": s.DurationSeconds, - "previewState": s.PreviewState, - "postWorkKey": s.PostWorkKey, - "ts": time.Now().UnixMilli(), - "modelImageUrl": s.ModelImageURL, - "avatar": s.Avatar, + "type": "job_upsert", + "jobId": s.ID, + "status": string(s.Status), + "sourceUrl": s.SourceURL, + "output": s.Output, + "startedAt": s.StartedAt, + "startedAtMs": s.StartedAtMs, + "endedAt": s.EndedAt, + "endedAtMs": s.EndedAtMs, + "error": s.Error, + "phase": s.Phase, + "progress": s.Progress, + "stopRequestedAtMs": s.StopRequestedAtMs, + "stopAttempts": s.StopAttempts, + "sizeBytes": s.SizeBytes, + "durationSeconds": s.DurationSeconds, + "previewState": s.PreviewState, + "postWorkKey": s.PostWorkKey, + "ts": time.Now().UnixMilli(), + "modelImageUrl": s.ModelImageURL, + "avatar": s.Avatar, } if s.PostWork != nil { @@ -222,24 +228,26 @@ func publishJobSnapshot(jobID string) bool { } snap := jobEventSnapshot{ - ID: job.ID, - SourceURL: job.SourceURL, - Output: job.Output, - Status: job.Status, - StartedAt: job.StartedAt, - StartedAtMs: job.StartedAtMs, - EndedAt: endedAtCopy, - EndedAtMs: job.EndedAtMs, - Error: job.Error, - Phase: job.Phase, - Progress: job.Progress, - SizeBytes: job.SizeBytes, - DurationSeconds: job.DurationSeconds, - PreviewState: job.PreviewState, - PostWorkKey: job.PostWorkKey, - PostWork: pw, - ModelImageURL: job.ModelImageURL, - Avatar: job.Avatar, + ID: job.ID, + SourceURL: job.SourceURL, + Output: job.Output, + Status: job.Status, + StartedAt: job.StartedAt, + StartedAtMs: job.StartedAtMs, + EndedAt: endedAtCopy, + EndedAtMs: job.EndedAtMs, + Error: job.Error, + Phase: job.Phase, + Progress: job.Progress, + StopRequestedAtMs: job.StopRequestedAtMs, + StopAttempts: job.StopAttempts, + SizeBytes: job.SizeBytes, + DurationSeconds: job.DurationSeconds, + PreviewState: job.PreviewState, + PostWorkKey: job.PostWorkKey, + PostWork: pw, + ModelImageURL: job.ModelImageURL, + Avatar: job.Avatar, } jobsMu.RUnlock() diff --git a/backend/ml/detection_labels.json b/backend/ml/detection_labels.json new file mode 100644 index 0000000..f11ee28 --- /dev/null +++ b/backend/ml/detection_labels.json @@ -0,0 +1,56 @@ +{ + "people": [ + "person_female", + "person_male" + ], + "sexPositions": [ + "keine", + "missionary", + "doggy", + "cowgirl", + "reverse_cowgirl", + "cunnilingus", + "prone_bone", + "standing", + "standing_doggy", + "spooning", + "facesitting", + "handjob", + "blowjob", + "boobjob", + "toy_play", + "fingering", + "69" + ], + "bodyParts": [ + "anus", + "ass", + "breasts", + "penis", + "tongue", + "pussy" + ], + "objects": [ + "blindfold", + "buttplug", + "collar", + "dildo", + "handcuffs", + "shower", + "strapon", + "towel", + "vibrator" + ], + "clothing": [ + "bikini", + "bra", + "dress", + "heels", + "hotpants", + "lingerie", + "panties", + "skirt", + "stockings", + "croptop" + ] +} diff --git a/backend/ml/predict_detector_model.py b/backend/ml/predict_detector_model.py index 1876ba1..0b15f63 100644 --- a/backend/ml/predict_detector_model.py +++ b/backend/ml/predict_detector_model.py @@ -13,10 +13,36 @@ def clamp01(v): return max(0.0, min(1.0, float(v))) +def existing_file(path): + try: + p = Path(path).expanduser().resolve() + if p.exists() and p.is_file() and p.stat().st_size > 0: + return p + except Exception: + pass + return None + + +def resolve_model_path(root, requested): + if requested: + p = existing_file(requested) + if p: + return p, "yolo26_model" + return Path(requested).expanduser(), "detector_missing" + + trained = root / "detector" / "model" / "best.pt" + p = existing_file(trained) + if p: + return p, "yolo26_detector" + + return trained, "detector_missing" + + def main(): parser = argparse.ArgumentParser() parser.add_argument("--root", required=True) parser.add_argument("--image", required=True) + parser.add_argument("--model", default="") parser.add_argument("--conf", type=float, default=0.30) parser.add_argument("--imgsz", type=int, default=640) parser.add_argument("--debug-image", action="store_true") @@ -25,11 +51,11 @@ def main(): root = Path(args.root) image_path = Path(args.image) - model_path = root / "detector" / "model" / "best.pt" + model_path, model_source = resolve_model_path(root, args.model) if not model_path.exists(): print(json.dumps({ "available": False, - "source": "detector_missing", + "source": model_source, "modelPath": str(model_path), "boxes": [], }, ensure_ascii=False)) @@ -124,7 +150,7 @@ def main(): print(json.dumps({ "available": True, - "source": "yolo26_detector", + "source": model_source, "modelPath": str(model_path), "image": str(image_path), "conf": float(args.conf), @@ -139,4 +165,4 @@ def main(): if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/backend/ml/predict_pose_model.py b/backend/ml/predict_pose_model.py new file mode 100644 index 0000000..eac8988 --- /dev/null +++ b/backend/ml/predict_pose_model.py @@ -0,0 +1,210 @@ +# backend/ml/predict_pose_model.py + +import argparse +import json +from pathlib import Path + +from PIL import Image +from ultralytics import YOLO +import torch + +KEYPOINT_NAMES = [ + "nose", + "left_eye", "right_eye", + "left_ear", "right_ear", + "left_shoulder", "right_shoulder", + "left_elbow", "right_elbow", + "left_wrist", "right_wrist", + "left_hip", "right_hip", + "left_knee", "right_knee", + "left_ankle", "right_ankle", +] + +BASE_MODEL_NAME = "yolo26n-pose.pt" + + +def existing_file(path): + try: + p = Path(path).expanduser().resolve() + if p.exists() and p.is_file() and p.stat().st_size > 0: + return p + except Exception: + pass + return None + + +def base_model_candidates(root): + script_dir = Path(__file__).resolve().parent + + return [ + script_dir / BASE_MODEL_NAME, + Path.cwd() / BASE_MODEL_NAME, + root / BASE_MODEL_NAME, + root.parent / BASE_MODEL_NAME, + root.parent.parent / BASE_MODEL_NAME, + ] + + +def resolve_model_path(root, requested): + if requested: + p = existing_file(requested) + if p: + return p, "yolo_pose_model" + return Path(requested).expanduser(), "pose_missing" + + trained = root / "pose" / "model" / "best.pt" + p = existing_file(trained) + if p: + return p, "yolo_pose" + + for candidate in base_model_candidates(root): + p = existing_file(candidate) + if p: + return p, "yolo26_pose_base" + + return trained, "pose_missing" + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--root", required=True) + parser.add_argument("--image", required=True) + parser.add_argument("--model", default="") + parser.add_argument("--conf", type=float, default=0.30) + parser.add_argument("--imgsz", type=int, default=640) + args = parser.parse_args() + + root = Path(args.root) + image_path = Path(args.image) + + model_path, model_source = resolve_model_path(root, args.model) + if not model_path.exists(): + print(json.dumps({ + "available": False, + "source": model_source, + "modelPath": str(model_path), + "persons": [], + }, ensure_ascii=False)) + return + + img = Image.open(image_path).convert("RGB") + img_w, img_h = img.size + + try: + model = YOLO(str(model_path)) + except Exception as e: + print(json.dumps({ + "available": False, + "source": "pose_load_failed", + "modelPath": str(model_path), + "error": repr(e), + "persons": [], + }, ensure_ascii=False)) + return + + device = 0 if torch.cuda.is_available() else "cpu" + + try: + results = model.predict( + source=str(image_path), + conf=float(args.conf), + imgsz=int(args.imgsz), + verbose=False, + device=device, + ) + except Exception as e: + print(json.dumps({ + "available": False, + "source": "pose_predict_failed", + "modelPath": str(model_path), + "image": str(image_path), + "error": repr(e), + "persons": [], + }, ensure_ascii=False)) + return + + persons = [] + + if results: + r = results[0] + names = r.names or {} + + kpts_xyn = None + kpts_conf = None + + if r.keypoints is not None: + try: + kpts_xyn = r.keypoints.xyn.cpu().numpy() + except Exception: + kpts_xyn = None + try: + kpts_conf = r.keypoints.conf.cpu().numpy() + except Exception: + kpts_conf = None + + if r.boxes is not None: + for i, b in enumerate(r.boxes): + score = float(b.conf[0].item()) + label = "" + try: + cls_id = int(b.cls[0].item()) + label = str(names.get(cls_id, cls_id)).strip().lower() + except Exception: + label = "" + + x1, y1, x2, y2 = [float(v) for v in b.xyxy[0].tolist()] + x1 = max(0.0, min(float(img_w), x1)) + y1 = max(0.0, min(float(img_h), y1)) + x2 = max(0.0, min(float(img_w), x2)) + y2 = max(0.0, min(float(img_h), y2)) + + if x2 <= x1 or y2 <= y1: + continue + + box = { + "x": x1 / img_w, + "y": y1 / img_h, + "w": (x2 - x1) / img_w, + "h": (y2 - y1) / img_h, + } + + keypoints = [] + if kpts_xyn is not None and i < len(kpts_xyn): + person_kpts = kpts_xyn[i] + for ki, (kx, ky) in enumerate(person_kpts): + kconf = 0.0 + if kpts_conf is not None and i < len(kpts_conf) and ki < len(kpts_conf[i]): + kconf = float(kpts_conf[i][ki]) + + name = KEYPOINT_NAMES[ki] if ki < len(KEYPOINT_NAMES) else str(ki) + keypoints.append({ + "name": name, + "x": float(kx), + "y": float(ky), + "conf": kconf, + }) + + persons.append({ + "label": label, + "score": score, + "box": box, + "keypoints": keypoints, + }) + + print(json.dumps({ + "available": True, + "source": model_source, + "modelPath": str(model_path), + "image": str(image_path), + "conf": float(args.conf), + "imgsz": int(args.imgsz), + "device": str(device), + "imageWidth": img_w, + "imageHeight": img_h, + "personCount": len(persons), + "persons": persons, + }, ensure_ascii=False)) + + +if __name__ == "__main__": + main() diff --git a/backend/ml/train_pose_model.py b/backend/ml/train_pose_model.py new file mode 100644 index 0000000..8ba27c5 --- /dev/null +++ b/backend/ml/train_pose_model.py @@ -0,0 +1,385 @@ +# backend/ml/train_pose_model.py + +import argparse +import json +import shutil +from datetime import datetime, timezone +from pathlib import Path + +from ultralytics import YOLO +from ultralytics.models.yolo.pose.train import PoseTrainer + +BASE_MODEL_NAME = "yolo26n-pose.pt" + + +def emit_progress(stage, progress, message="", **extra): + out = { + "type": "progress", + "stage": stage, + "progress": max(0.0, min(1.0, float(progress))), + "message": message, + } + out.update(extra) + print(json.dumps(out, ensure_ascii=False), flush=True) + + +def count_yolo_samples(dataset_root: Path, split: str) -> int: + images_dir = dataset_root / "images" / split + labels_dir = dataset_root / "labels" / split + + if not images_dir.exists() or not labels_dir.exists(): + return 0 + + image_exts = {".jpg", ".jpeg", ".png", ".webp"} + count = 0 + + for image_path in images_dir.iterdir(): + if not image_path.is_file(): + continue + + if image_path.suffix.lower() not in image_exts: + continue + + label_path = labels_dir / f"{image_path.stem}.txt" + if label_path.exists() and label_path.is_file(): + count += 1 + + return count + + +def safe_int(value, fallback): + try: + return int(value) + except Exception: + return fallback + + +def existing_file(path): + try: + p = Path(path).expanduser().resolve() + if p.exists() and p.is_file() and p.stat().st_size > 0: + return p + except Exception: + pass + return None + + +def base_model_candidates(root): + script_dir = Path(__file__).resolve().parent + + return [ + script_dir / BASE_MODEL_NAME, + Path.cwd() / BASE_MODEL_NAME, + root / BASE_MODEL_NAME, + root.parent / BASE_MODEL_NAME, + root.parent.parent / BASE_MODEL_NAME, + ] + + +def resolve_base_model(root, requested): + requested = str(requested or "").strip() + + if requested and requested != BASE_MODEL_NAME: + p = existing_file(requested) + if p: + return str(p) + return requested + + for candidate in base_model_candidates(root): + p = existing_file(candidate) + if p: + return str(p) + + return requested or BASE_MODEL_NAME + + +def batch_sample_ids(batch): + if not isinstance(batch, dict): + return [] + + image_paths = batch.get("im_file") + if not image_paths: + return [] + + if isinstance(image_paths, (str, Path)): + image_paths = [image_paths] + + out = [] + seen = set() + + for image_path in image_paths: + stem = Path(str(image_path)).stem.strip() + if stem and stem not in seen: + seen.add(stem) + out.append(stem) + + return out + + +def progress_pose_trainer(train_count, val_count, train_device, fallback_epochs): + class ProgressPoseTrainer(PoseTrainer): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._preview_epoch = 0 + self._preview_batch = 0 + + def preprocess_batch(self, batch): + epoch = int(getattr(self, "epoch", 0)) + 1 + total_epochs = int(getattr(self, "epochs", fallback_epochs) or fallback_epochs) + + if epoch != self._preview_epoch: + self._preview_epoch = epoch + self._preview_batch = 0 + + self._preview_batch += 1 + + sample_ids = batch_sample_ids(batch) + if sample_ids: + total_batches = max(1, len(self.train_loader)) + completed = (epoch - 1) + min(1.0, self._preview_batch / total_batches) + progress = 0.04 + 0.90 * (completed / max(1, total_epochs)) + + emit_progress( + "pose", + progress, + "Pose Detector trainiert…", + epoch=epoch, + epochs=total_epochs, + sampleId=sample_ids[0], + trainSamples=train_count, + valSamples=val_count, + device=str(train_device), + ) + + return super().preprocess_batch(batch) + + return ProgressPoseTrainer + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--root", required=True) + parser.add_argument("--base", default="yolo26n-pose.pt") + parser.add_argument("--epochs", default="80") + parser.add_argument("--imgsz", default="640") + parser.add_argument("--device", default="auto") + parser.add_argument("--workers", default="2") + parser.add_argument("--patience", default="20") + args = parser.parse_args() + + import torch + + root = Path(args.root).resolve() + dataset_root = root / "pose" / "dataset" + yaml_path = dataset_root / "dataset.yaml" + runs_dir = root / "pose" / "runs" + out_dir = root / "pose" / "model" + + epochs = max(1, safe_int(args.epochs, 80)) + imgsz = max(64, safe_int(args.imgsz, 640)) + workers = max(0, safe_int(args.workers, 2)) + patience = max(0, safe_int(args.patience, 20)) + + if str(args.device).lower() == "auto": + train_device = 0 if torch.cuda.is_available() else "cpu" + else: + train_device = args.device + + if not yaml_path.exists(): + raise SystemExit(f"dataset.yaml not found: {yaml_path}") + + train_count = count_yolo_samples(dataset_root, "train") + val_count = count_yolo_samples(dataset_root, "val") + + emit_progress( + "pose", + 0.01, + "YOLO-Pose-Dataset wird geprüft…", + trainSamples=train_count, + valSamples=val_count, + epochs=epochs, + imgsz=imgsz, + device=str(train_device), + ) + + if train_count <= 0: + raise SystemExit("no YOLO pose train samples found") + + if val_count <= 0: + raise SystemExit("no YOLO pose val samples found") + + emit_progress( + "pose", + 0.03, + "YOLO-Pose-Basismodell wird geladen…", + base=resolve_base_model(root, args.base), + device=str(train_device), + ) + + base_model = resolve_base_model(root, args.base) + model = YOLO(base_model) + + best_epoch = 0 + last_epoch = 0 + best_map50 = 0.0 + best_map5095 = 0.0 + + def on_train_epoch_start(trainer): + epoch = int(getattr(trainer, "epoch", 0)) + 1 + total = int(getattr(trainer, "epochs", epochs) or epochs) + + emit_progress( + "pose", + 0.04 + 0.90 * ((epoch - 1) / max(1, total)), + "Pose Detector trainiert…", + epoch=epoch, + epochs=total, + trainSamples=train_count, + valSamples=val_count, + device=str(train_device), + ) + + def on_train_epoch_end(trainer): + nonlocal last_epoch + + epoch = int(getattr(trainer, "epoch", 0)) + 1 + total = int(getattr(trainer, "epochs", epochs) or epochs) + last_epoch = max(last_epoch, epoch) + + emit_progress( + "pose", + 0.04 + 0.90 * (epoch / max(1, total)), + "Pose Detector trainiert…", + epoch=epoch, + epochs=total, + trainSamples=train_count, + valSamples=val_count, + device=str(train_device), + ) + + def on_fit_epoch_end(trainer): + nonlocal best_epoch, best_map50, best_map5095 + + epoch = int(getattr(trainer, "epoch", 0)) + 1 + total = int(getattr(trainer, "epochs", epochs) or epochs) + metrics = getattr(trainer, "metrics", None) or {} + + map50 = ( + metrics.get("metrics/mAP50(B)") + or metrics.get("metrics/mAP50") + or metrics.get("mAP50") + ) + map5095 = ( + metrics.get("metrics/mAP50-95(B)") + or metrics.get("metrics/mAP50-95") + or metrics.get("mAP50-95") + ) + + if map50 is not None or map5095 is not None: + best_epoch = epoch + + m50 = float(map50) if map50 is not None else 0.0 + if m50 >= best_map50: + best_map50 = m50 + if map5095 is not None: + best_map5095 = float(map5095) + + emit_progress( + "pose", + 0.04 + 0.90 * (epoch / max(1, total)), + "Pose Detector validiert…", + epoch=epoch, + epochs=total, + mAP50=map50, + mAP5095=map5095, + device=str(train_device), + ) + + model.add_callback("on_train_epoch_start", on_train_epoch_start) + model.add_callback("on_train_epoch_end", on_train_epoch_end) + model.add_callback("on_fit_epoch_end", on_fit_epoch_end) + + emit_progress( + "pose", + 0.05, + "Pose Detector Training startet…", + trainSamples=train_count, + valSamples=val_count, + epochs=epochs, + imgsz=imgsz, + device=str(train_device), + ) + + result = model.train( + trainer=progress_pose_trainer( + train_count, + val_count, + train_device, + epochs, + ), + data=str(yaml_path), + epochs=epochs, + imgsz=imgsz, + project=str(runs_dir), + name="pose", + exist_ok=True, + device=train_device, + workers=workers, + patience=patience, + ) + + emit_progress( + "pose", + 0.96, + "Bestes YOLO-Pose-Modell wird übernommen…", + lastEpoch=last_epoch, + bestEpoch=best_epoch, + device=str(train_device), + ) + + best = runs_dir / "pose" / "weights" / "best.pt" + last = runs_dir / "pose" / "weights" / "last.pt" + + if not best.exists(): + if last.exists(): + best = last + else: + raise SystemExit(f"best.pt not found after training: {best}") + + out_dir.mkdir(parents=True, exist_ok=True) + + final_model = out_dir / "best.pt" + shutil.copy2(best, final_model) + + result_path = runs_dir / "pose" + status = { + "ok": True, + "model": str(final_model), + "sourceModel": str(best), + "runs": str(result_path), + "trainedAt": datetime.now(timezone.utc).isoformat(), + "trainSamples": train_count, + "valSamples": val_count, + "epochs": epochs, + "imgsz": imgsz, + "device": str(train_device), + "mAP50": round(best_map50, 4), + "mAP5095": round(best_map5095, 4), + "bestEpoch": best_epoch, + } + + with (out_dir / "status.json").open("w", encoding="utf-8") as f: + json.dump(status, f, ensure_ascii=False, indent=2) + + emit_progress( + "pose", + 1.0, + "Pose Detector fertig.", + **status, + ) + + print(json.dumps(status, ensure_ascii=False), flush=True) + + +if __name__ == "__main__": + main() diff --git a/backend/rating.go b/backend/rating.go index 09b8d2b..5b2dfd8 100644 --- a/backend/rating.go +++ b/backend/rating.go @@ -121,10 +121,12 @@ func isPersonSegmentLabel(label string) bool { func isKnownPositionLabel(label string) bool { label = strings.ToLower(strings.TrimSpace(label)) + if isNoSexPositionLabel(label) { + return false + } switch label { - case "unknown", - "missionary", + case "missionary", "doggy", "doggystyle", "cowgirl", @@ -306,7 +308,7 @@ type ratingSignalSet struct { func normalizeRatingSignalLabel(label string) string { label = strings.ToLower(strings.TrimSpace(label)) - if label == "" || label == "unknown" { + if isNoSexPositionLabel(label) { return "" } @@ -359,7 +361,7 @@ func normalizeRatingSignalLabel(label string) string { } raw := normalizeSegmentLabel(label) - if raw == "" || raw == "unknown" { + if isNoSexPositionLabel(raw) { return "" } diff --git a/backend/record.go b/backend/record.go index 5b1dfc5..fcd47bc 100644 --- a/backend/record.go +++ b/backend/record.go @@ -1540,13 +1540,21 @@ func recordStop(w http.ResponseWriter, r *http.Request) { jobsMu.RLock() job, ok := jobs[id] + shouldStop := ok && + job != nil && + job.EndedAt == nil && + !isTerminalJobStatus(job.Status) && + !isPostworkJob(job) jobsMu.RUnlock() if !ok { http.Error(w, "job nicht gefunden", http.StatusNotFound) return } - stopJobsInternal([]*RecordJob{job}) + if shouldStop { + stopJobsInternal([]*RecordJob{job}) + } + respondJSON(w, job) } diff --git a/backend/record_stream_hls.go b/backend/record_stream_hls.go index faefa46..2c46def 100644 --- a/backend/record_stream_hls.go +++ b/backend/record_stream_hls.go @@ -1206,6 +1206,13 @@ func recordHLSPlaylistSegments( shouldFinalize := parentCanceled || endOfStream || finalErr == nil + if parentCanceled { + if verboseLogs() { + appLogln("⏹️", logPrefix, "stop requested; deferring audio/video mux to postwork") + } + return ctx.Err() + } + if shouldFinalize { if !fileExistsNonEmpty(outFile) { if finalErr != nil { diff --git a/backend/record_stream_mfc.go b/backend/record_stream_mfc.go index a21be96..cf5856d 100644 --- a/backend/record_stream_mfc.go +++ b/backend/record_stream_mfc.go @@ -63,7 +63,11 @@ func RecordStreamMFC( return appErrorf("mfc status blieb %s für %s", lastStatus, username) } - time.Sleep(5 * time.Second) + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(5 * time.Second): + } } if job != nil { diff --git a/backend/recorder.go b/backend/recorder.go index 7ba0375..efc4a52 100644 --- a/backend/recorder.go +++ b/backend/recorder.go @@ -354,8 +354,6 @@ func waitForUsableOutput(path string, timeout time.Duration) (os.FileInfo, error lastErr = appErrorf("stat returned nil") } else if fi.IsDir() { lastErr = appErrorf("path is dir") - } else { - lastErr = appErrorf("file size is 0") } if time.Now().After(deadline) { @@ -411,8 +409,6 @@ func waitForStableUsableOutput(path string, timeout time.Duration, stableFor tim lastErr = appErrorf("stat returned nil") } else if fi.IsDir() { lastErr = appErrorf("path is dir") - } else { - lastErr = appErrorf("file size is 0") } } diff --git a/backend/server.go b/backend/server.go index a05d5aa..e743876 100644 --- a/backend/server.go +++ b/backend/server.go @@ -112,6 +112,7 @@ var ( aiServerMu sync.Mutex aiServerCurrent *aiServerProcess aiServerCtx context.Context + aiServerRoot string ) func aiServerAutostartEnabled() bool { @@ -349,13 +350,17 @@ func findTrainingRootDir(scriptDir string) (trainingRoot string, appBaseDir stri // Wichtig für Entwicklung: // findAIServerScriptDir() findet meistens dein backend-Verzeichnis. - add(scriptDir) + if scriptDir != "" && !isTempBuildDir(scriptDir) { + add(scriptDir) + } // Falls du aus repo-root oder backend startest. add(cwd) // Wichtig für gebaute EXE. - add(exeDir) + if exeDir != "" && !isTempBuildDir(exeDir) { + add(exeDir) + } // Bevorzugt den Pfad, wo detection_labels.json liegt. for _, c := range candidates { @@ -472,6 +477,7 @@ func startAIServer(ctx context.Context) (*aiServerProcess, error) { } trainingRoot, appBaseDir := findTrainingRootDir(scriptDir) + aiServerRoot = trainingRoot mlDir, mlErr := trainingEmbeddedMLDir() if mlErr != nil { @@ -493,7 +499,8 @@ func startAIServer(ctx context.Context) (*aiServerProcess, error) { detectionLabelsPath = filepath.Clean(detectionLabelsPath) } - defaultModelPath := filepath.Join(trainingRoot, "detector", "model", "best.pt") + defaultModel := trainingResolveDetectorModel(trainingRoot) + defaultModelPath := defaultModel.EffectivePath pythonPath := aiServerPythonPath() port := aiServerPortFromURL() @@ -550,7 +557,7 @@ func startAIServer(ctx context.Context) (*aiServerProcess, error) { if fi, err := os.Stat(defaultModelPath); err != nil || fi == nil || fi.IsDir() { appLogln("⚠️ YOLO Modell nicht gefunden:", defaultModelPath) } else { - appLogln("✅ YOLO Modell:", defaultModelPath) + appLogln("✅ YOLO Modell:", defaultModelPath, "Quelle:", defaultModel.Source) } if strings.TrimSpace(os.Getenv("YOLO_IMGSZ")) == "" { @@ -647,8 +654,10 @@ func restartAIServer() { return } + appLogln("🔄 AI Server wird neu gestartet…") + publishAppNotification("info", "AI Server", "AI Server wird neu gestartet…", 2200) + if aiServerCurrent != nil { - appLogln("🔄 AI Server wird neu gestartet…") aiServerCurrent.Stop() aiServerCurrent = nil } @@ -656,6 +665,7 @@ func restartAIServer() { proc, err := startAIServer(ctx) if err != nil { appLogln("⚠️ AI Server Neustart fehlgeschlagen:", err) + publishAppNotification("warning", "AI Server", "Neustart fehlgeschlagen: "+err.Error(), 5000) return } @@ -663,6 +673,97 @@ func restartAIServer() { if proc != nil { appLogln("✅ AI Server neu gestartet.") + publishAppNotification("success", "AI Server", "AI Server wurde neu gestartet.", 2600) + } +} + +type trainingBestModelSnapshot struct { + exists bool + size int64 + modTime time.Time +} + +func snapshotTrainingBestModel(path string) trainingBestModelSnapshot { + fi, err := os.Stat(path) + if err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 { + return trainingBestModelSnapshot{} + } + + return trainingBestModelSnapshot{ + exists: true, + size: fi.Size(), + modTime: fi.ModTime(), + } +} + +func (s trainingBestModelSnapshot) changed(next trainingBestModelSnapshot) bool { + if s.exists != next.exists { + return true + } + + if !s.exists { + return false + } + + return s.size != next.size || !s.modTime.Equal(next.modTime) +} + +func startTrainingBestModelWatcher(ctx context.Context, trainingRoot string) { + trainingRoot = filepath.Clean(strings.TrimSpace(trainingRoot)) + if trainingRoot == "" { + return + } + + paths := []string{ + filepath.Join(trainingRoot, "detector", "model", "best.pt"), + filepath.Join(trainingRoot, "pose", "model", "best.pt"), + } + + snapshots := make(map[string]trainingBestModelSnapshot, len(paths)) + for _, path := range paths { + snapshots[path] = snapshotTrainingBestModel(path) + } + + appLogln("👁️ Training-Model-Watcher aktiv:", strings.Join(paths, " | ")) + + ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() + + var lastRestart time.Time + + for { + select { + case <-ctx.Done(): + return + + case <-ticker.C: + changedPaths := []string{} + + for _, path := range paths { + previous := snapshots[path] + next := snapshotTrainingBestModel(path) + + if previous.changed(next) { + snapshots[path] = next + + if next.exists { + changedPaths = append(changedPaths, path) + } + } + } + + if len(changedPaths) == 0 { + continue + } + + if time.Since(lastRestart) < 5*time.Second { + continue + } + + lastRestart = time.Now() + appLogln("🔄 Training-Modell geändert:", strings.Join(changedPaths, " | ")) + go restartAIServer() + } } } @@ -864,8 +965,11 @@ func main() { aiServerMu.Lock() aiServerCtx = appCtx aiServerCurrent = aiProc + watchedTrainingRoot := aiServerRoot aiServerMu.Unlock() + go startTrainingBestModelWatcher(appCtx, watchedTrainingRoot) + // ✅ Hier: alte manuelle Autostart-Pause beim echten App-Start zurücksetzen. resetAutostartPauseOnStartup() diff --git a/backend/sse.go b/backend/sse.go index deb5a1a..ba292f9 100644 --- a/backend/sse.go +++ b/backend/sse.go @@ -23,25 +23,27 @@ type appSSE struct { var sseApp *appSSE type jobEvent struct { - Type string `json:"type"` - Model string `json:"model"` - JobID string `json:"jobId"` - Status JobStatus `json:"status"` - Phase string `json:"phase,omitempty"` - Progress int `json:"progress,omitempty"` - SourceURL string `json:"sourceUrl,omitempty"` - Output string `json:"output,omitempty"` - StartedAt string `json:"startedAt,omitempty"` - StartedAtMs int64 `json:"startedAtMs,omitempty"` - EndedAt string `json:"endedAt,omitempty"` - EndedAtMs int64 `json:"endedAtMs,omitempty"` - SizeBytes int64 `json:"sizeBytes,omitempty"` - DurationSeconds float64 `json:"durationSeconds,omitempty"` - PreviewState string `json:"previewState,omitempty"` - RoomStatus string `json:"roomStatus,omitempty"` - IsOnline bool `json:"isOnline,omitempty"` - ModelImageURL string `json:"modelImageUrl,omitempty"` - ModelChatRoomURL string `json:"modelChatRoomUrl,omitempty"` + Type string `json:"type"` + Model string `json:"model"` + JobID string `json:"jobId"` + Status JobStatus `json:"status"` + Phase string `json:"phase,omitempty"` + Progress int `json:"progress,omitempty"` + StopRequestedAtMs int64 `json:"stopRequestedAtMs,omitempty"` + StopAttempts int `json:"stopAttempts,omitempty"` + SourceURL string `json:"sourceUrl,omitempty"` + Output string `json:"output,omitempty"` + StartedAt string `json:"startedAt,omitempty"` + StartedAtMs int64 `json:"startedAtMs,omitempty"` + EndedAt string `json:"endedAt,omitempty"` + EndedAtMs int64 `json:"endedAtMs,omitempty"` + SizeBytes int64 `json:"sizeBytes,omitempty"` + DurationSeconds float64 `json:"durationSeconds,omitempty"` + PreviewState string `json:"previewState,omitempty"` + RoomStatus string `json:"roomStatus,omitempty"` + IsOnline bool `json:"isOnline,omitempty"` + ModelImageURL string `json:"modelImageUrl,omitempty"` + ModelChatRoomURL string `json:"modelChatRoomUrl,omitempty"` PostWorkKey string `json:"postWorkKey,omitempty"` PostWork any `json:"postWork,omitempty"` @@ -82,6 +84,42 @@ type taskStateEvent struct { TS int64 `json:"ts"` } +type appNotificationEvent struct { + Type string `json:"type"` // "notification" + Level string `json:"level"` + Title string `json:"title"` + Message string `json:"message,omitempty"` + DurationMs int `json:"durationMs,omitempty"` + TS int64 `json:"ts"` +} + +func publishAppNotification(level string, title string, message string, durationMs int) { + level = strings.TrimSpace(strings.ToLower(level)) + if level == "" { + level = "info" + } + + ev := appNotificationEvent{ + Type: "notification", + Level: level, + Title: strings.TrimSpace(title), + Message: strings.TrimSpace(message), + DurationMs: durationMs, + TS: time.Now().UnixMilli(), + } + + if ev.Title == "" { + return + } + + b, err := json.Marshal(ev) + if err != nil { + return + } + + publishSSE("notification", b) +} + type analysisProgressEvent struct { Type string `json:"type"` Scope string `json:"scope,omitempty"` @@ -413,22 +451,24 @@ func visibleJobEventsJSON() []ssePublishItem { } payload := jobEvent{ - Type: "job_upsert", - Model: eventName, - JobID: j.ID, - Status: j.Status, - Phase: j.Phase, - Progress: j.Progress, - SourceURL: j.SourceURL, - Output: j.Output, - StartedAt: j.StartedAt.Format(time.RFC3339Nano), - StartedAtMs: j.StartedAtMs, - SizeBytes: j.SizeBytes, - DurationSeconds: j.DurationSeconds, - PreviewState: j.PreviewState, - PostWorkKey: strings.TrimSpace(j.PostWorkKey), - PostWork: j.PostWork, - TS: 0, // wichtig: stabil halten, damit Dedupe funktioniert + Type: "job_upsert", + Model: eventName, + JobID: j.ID, + Status: j.Status, + Phase: j.Phase, + Progress: j.Progress, + StopRequestedAtMs: j.StopRequestedAtMs, + StopAttempts: j.StopAttempts, + SourceURL: j.SourceURL, + Output: j.Output, + StartedAt: j.StartedAt.Format(time.RFC3339Nano), + StartedAtMs: j.StartedAtMs, + SizeBytes: j.SizeBytes, + DurationSeconds: j.DurationSeconds, + PreviewState: j.PreviewState, + PostWorkKey: strings.TrimSpace(j.PostWorkKey), + PostWork: j.PostWork, + TS: 0, // wichtig: stabil halten, damit Dedupe funktioniert } if sm := sseStoredModelForJob(j); sm != nil { @@ -478,6 +518,69 @@ func publishJobRemove(j *RecordJob) { publishSSE(eventName, b) } +func publishModelJobUpsert(jobID string) bool { + jobsMu.RLock() + j := jobs[jobID] + if j == nil || j.Hidden { + jobsMu.RUnlock() + return false + } + + eventName := sseModelEventNameForJob(j) + if eventName == "" { + jobsMu.RUnlock() + return false + } + + var postWork any + if j.PostWork != nil { + tmp := *j.PostWork + postWork = &tmp + } + + payload := jobEvent{ + Type: "job_upsert", + Model: eventName, + JobID: j.ID, + Status: j.Status, + Phase: j.Phase, + Progress: j.Progress, + StopRequestedAtMs: j.StopRequestedAtMs, + StopAttempts: j.StopAttempts, + SourceURL: j.SourceURL, + Output: j.Output, + StartedAt: j.StartedAt.Format(time.RFC3339Nano), + StartedAtMs: j.StartedAtMs, + SizeBytes: j.SizeBytes, + DurationSeconds: j.DurationSeconds, + PreviewState: j.PreviewState, + PostWorkKey: strings.TrimSpace(j.PostWorkKey), + PostWork: postWork, + TS: time.Now().UnixMilli(), + } + + if sm := sseStoredModelForJob(j); sm != nil { + payload.RoomStatus = strings.ToLower(strings.TrimSpace(sm.RoomStatus)) + payload.IsOnline = sm.IsOnline + payload.ModelImageURL = strings.TrimSpace(sm.ImageURL) + payload.ModelChatRoomURL = strings.TrimSpace(sm.ChatRoomURL) + } + + if j.EndedAt != nil { + payload.EndedAt = j.EndedAt.Format(time.RFC3339Nano) + payload.EndedAtMs = j.EndedAtMs + } + jobsMu.RUnlock() + + b, err := json.Marshal(payload) + if err != nil { + return false + } + + publishSSE(eventName, b) + return true +} + func sseModelEventNameForJob(j *RecordJob) string { if j == nil { return "" diff --git a/backend/training.go b/backend/training.go index 0aff095..61287dd 100644 --- a/backend/training.go +++ b/backend/training.go @@ -61,6 +61,7 @@ type TrainingPrediction struct { ObjectsPresent []TrainingScoredLabel `json:"objectsPresent"` ClothingPresent []TrainingScoredLabel `json:"clothingPresent"` Boxes []TrainingBox `json:"boxes"` + Persons []TrainingPosePerson `json:"persons,omitempty"` } type TrainingCorrection struct { @@ -139,6 +140,27 @@ type TrainingDetectorPrediction struct { 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"` +} + +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"` @@ -191,17 +213,21 @@ type TrainingModelInfo struct { } 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"` - Confidence TrainingConfidence `json:"confidence"` - Labels TrainingStatsLabels `json:"labels"` + 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"` + Confidence TrainingConfidence `json:"confidence"` + Labels TrainingStatsLabels `json:"labels"` } type trainingProgressEvent struct { @@ -1013,6 +1039,9 @@ const minTrainingFeedbackCount = 5 const minDetectorTrainCount = 20 const minDetectorValCount = 3 +const minPoseTrainCount = 20 +const minPoseValCount = 3 +const trainingPoseKeypointCount = 17 var errTrainingCancelled = errors.New("training cancelled") @@ -1117,6 +1146,66 @@ func trainingRunCommand(python string, script string, args ...string) (string, e 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") @@ -1934,31 +2023,12 @@ func trainingDetectorBoxesForAnnotation(sample *TrainingSample, req TrainingFeed boxes = append(boxes, sample.Prediction.Boxes...) } - sexPosition := "unknown" - - if req.Correction != nil { - sexPosition = strings.TrimSpace(req.Correction.SexPosition) - } else if req.Accepted { - sexPosition = strings.TrimSpace(sample.Prediction.SexPosition) - } - - if sexPosition != "" && sexPosition != "unknown" { - boxes = append(boxes, TrainingBox{ - Label: sexPosition, - Score: 1, - X: 0, - Y: 0, - W: 1, - H: 1, - }) - } - return boxes } func trainingNegativeCorrection() *TrainingCorrection { return &TrainingCorrection{ - SexPosition: "unknown", + SexPosition: trainingNoSexPositionLabel, PeoplePresent: []string{}, BodyPartsPresent: []string{}, ObjectsPresent: []string{}, @@ -2035,6 +2105,13 @@ func trainingFeedbackHandler(w http.ResponseWriter, r *http.Request) { } } + trainingDeletePoseSample(root, req.SampleID) + if sexPosition := trainingSexPositionForFeedback(sample, req); !isNoSexPositionLabel(sexPosition) { + if err := trainingWritePoseSample(root, sample, sexPosition); err != nil { + appLogln("pose sample write failed:", err) + } + } + trainingWriteJSON(w, http.StatusOK, map[string]any{ "ok": true, }) @@ -2156,6 +2233,19 @@ func trainingFeedbackUpdateHandler(w http.ResponseWriter, r *http.Request) { } } + 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); err != nil { + appLogln("pose sample update failed:", err) + } + } + trainingWriteJSON(w, http.StatusOK, map[string]any{ "ok": true, "item": updated, @@ -2272,10 +2362,19 @@ func trainingTrainHandler(w http.ResponseWriter, r *http.Request) { // 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 := 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) @@ -2297,11 +2396,18 @@ func trainingTrainHandler(w http.ResponseWriter, r *http.Request) { 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") trainCount := trainingCountDetectorSamples(detectorTrainImages, detectorTrainLabels) valCount := trainingCountDetectorSamples(detectorValImages, detectorValLabels) positiveTrainCount := trainingCountPositiveDetectorSamples(detectorTrainImages, detectorTrainLabels) positiveValCount := trainingCountPositiveDetectorSamples(detectorValImages, detectorValLabels) + poseTrainCount := trainingCountDetectorSamples(poseTrainImages, poseTrainLabels) + poseValCount := trainingCountDetectorSamples(poseValImages, poseValLabels) if !fileExistsNonEmpty(detectorDatasetYAML) || trainCount < minDetectorTrainCount || @@ -2324,6 +2430,23 @@ func trainingTrainHandler(w http.ResponseWriter, r *http.Request) { 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 + } + ctx, cancel := context.WithCancel(context.Background()) trainingStartJob(cancel) @@ -2345,7 +2468,15 @@ func trainingTrainHandler(w http.ResponseWriter, r *http.Request) { "usesSceneCLIP": false, "usesSceneKNN": false, "source": "yolo26_detector", - "detectsPosition": true, + "detectsPosition": false, + }, + "pose": map[string]any{ + "trainCount": poseTrainCount, + "valCount": poseValCount, + "requiredTrain": minPoseTrainCount, + "requiredVal": minPoseValCount, + "datasetYAML": poseDatasetYAML, + "source": "yolo26_pose", }, }) } @@ -2405,6 +2536,8 @@ func trainingRunJob(ctx context.Context, root string, count int) { detectorOutput := "" detectorStatus := "skipped" + poseOutput := "" + poseStatus := "skipped" detectorDatasetYAML := filepath.Join(root, "detector", "dataset", "dataset.yaml") detectorTrainImages := filepath.Join(root, "detector", "dataset", "images", "train") @@ -2449,7 +2582,7 @@ func trainingRunJob(ctx context.Context, root string, count int) { return trainingHandleProgressLine( line, 15, - 98, + 58, "YOLO26 Detector wird trainiert…", ) }, @@ -2498,6 +2631,112 @@ func trainingRunJob(ctx context.Context, root string, count int) { } 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") + + trainingSetJobStatus(func(s *TrainingJobStatus) { + 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 poseStatus != "failed" && + fileExistsNonEmpty(poseDatasetYAML) && + poseTrainCount >= minPoseTrainCount && + poseValCount >= minPoseValCount { + trainingSetJobStatus(func(s *TrainingJobStatus) { + if s.Progress < 62 { + s.Progress = 62 + } + s.Step = "YOLO26 Pose wird trainiert..." + }) + + poseBasePath := "yolo26n-pose.pt" + if p, err := embeddedPoseModelPath(); err == nil && fileExistsNonEmpty(p) { + poseBasePath = p + } + + poseScript := trainingScriptPath("train_pose_model.py") + poseOut, poseErr := trainingRunCommandStreaming( + ctx, + python, + poseScript, + func(line string) bool { + return trainingHandleProgressLine( + line, + 62, + 98, + "YOLO26 Pose wird trainiert...", + ) + }, + "--root", root, + "--base", poseBasePath, + "--epochs", strconv.Itoa(trainingDetectorEpochs()), + "--imgsz", "640", + ) + + 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 poseStatus != "failed" { + poseStatus = "skipped_no_pose_data" + poseOutput = fmt.Sprintf( + "YOLO26 Pose uebersprungen: zu wenige Skeleton-Beispiele. Train=%d, Val=%d. Benoetigt: mindestens %d Train und %d Val.", + poseTrainCount, + poseValCount, + minPoseTrainCount, + minPoseValCount, + ) + appLogln(poseOutput) + } + + poseOutputClean := cleanOutput(poseOutput) message := "Training abgeschlossen." errorText := "" @@ -2523,14 +2762,27 @@ func trainingRunJob(ctx context.Context, root string, count int) { } } - if detectorStatus == "trained" { - // Verlaufseintrag schreiben (vor dem Neustart, solange die Job-Startzeit - // für die Dauer noch verfügbar ist). - trainingAppendRunHistory(root) + 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 uebersprungen: 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 + } - // Neues Modell → AI-Server frisch neu starten (sauberer Zustand, - // statt nur das Modell im laufenden Prozess neu zu laden). - restartAIServer() + if detectorStatus == "trained" { + // Verlaufseintrag schreiben, solange die Job-Startzeit für die Dauer noch verfügbar ist. + trainingAppendRunHistory(root) } trainingSetJobStatus(func(s *TrainingJobStatus) { @@ -2621,8 +2873,7 @@ func trainingBuildStats(root string) (*TrainingStatsResponse, error) { if err != nil { if os.IsNotExist(err) { stats.SampleCount = trainingCountSampleFiles(filepath.Join(root, "samples")) - stats.ModelAvailable = trainingStatsModelAvailable(root) - stats.ModelInfo = trainingReadModelInfo(root) + trainingApplyStatsModelInfo(root, stats) return stats, nil } @@ -2652,10 +2903,7 @@ func trainingBuildStats(root string) (*TrainingStatsResponse, error) { effective := trainingEffectiveCorrection(annotation) - sexPosition := strings.TrimSpace(effective.SexPosition) - if sexPosition == "" { - sexPosition = "unknown" - } + sexPosition := normalizeSexPositionLabel(effective.SexPosition) if len(sexPositionSet) == 0 || sexPositionSet[sexPosition] { sexPositionCounts[sexPosition]++ } @@ -2715,8 +2963,7 @@ func trainingBuildStats(root string) (*TrainingStatsResponse, error) { } stats.SampleCount = trainingCountSampleFiles(filepath.Join(root, "samples")) - stats.ModelAvailable = trainingStatsModelAvailable(root) - stats.ModelInfo = trainingReadModelInfo(root) + trainingApplyStatsModelInfo(root, stats) stats.Labels = TrainingStatsLabels{ // Personen/Box-Labels brauchen mehr Beispiele, weil der Detector Boxen lernen muss. @@ -2847,10 +3094,17 @@ func trainingStatusHandler(w http.ResponseWriter, r *http.Request) { trainingWriteError(w, http.StatusInternalServerError, err.Error()) return } + if err := trainingEnsurePoseDirs(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") @@ -2861,12 +3115,20 @@ func trainingStatusHandler(w http.ResponseWriter, r *http.Request) { detectorTrainLabels := filepath.Join(root, "detector", "dataset", "labels", "train") detectorValImages := filepath.Join(root, "detector", "dataset", "images", "val") detectorValLabels := filepath.Join(root, "detector", "dataset", "labels", "val") - detectorModelPath := filepath.Join(root, "detector", "model", "best.pt") + 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) trainCount := trainingCountDetectorSamples(detectorTrainImages, detectorTrainLabels) valCount := trainingCountDetectorSamples(detectorValImages, detectorValLabels) positiveTrainCount := trainingCountPositiveDetectorSamples(detectorTrainImages, detectorTrainLabels) positiveValCount := trainingCountPositiveDetectorSamples(detectorValImages, detectorValLabels) + poseTrainCount := trainingCountDetectorSamples(poseTrainImages, poseTrainLabels) + poseValCount := trainingCountDetectorSamples(poseValImages, poseValLabels) datasetReady := fileExistsNonEmpty(detectorDatasetYAML) detectorDataReady := datasetReady && @@ -2874,8 +3136,12 @@ func trainingStatusHandler(w http.ResponseWriter, r *http.Request) { valCount >= minDetectorValCount && positiveTrainCount > 0 && positiveValCount > 0 + poseDatasetReady := fileExistsNonEmpty(poseDatasetYAML) + poseDataReady := poseDatasetReady && + poseTrainCount >= minPoseTrainCount && + poseValCount >= minPoseValCount - canTrain := feedbackCount >= minTrainingFeedbackCount && detectorDataReady + canTrain := feedbackCount >= minTrainingFeedbackCount && detectorDataReady && poseDataReady trainingWriteJSON(w, http.StatusOK, map[string]any{ "ok": true, @@ -2893,7 +3159,7 @@ func trainingStatusHandler(w http.ResponseWriter, r *http.Request) { "detectsPeople": true, "detectsGender": true, - "detectsSexPosition": true, + "detectsSexPosition": false, "detectsBodyParts": true, "detectsObjects": true, "detectsClothing": true, @@ -2910,8 +3176,31 @@ func trainingStatusHandler(w http.ResponseWriter, r *http.Request) { "datasetYAML": detectorDatasetYAML, "dataReady": detectorDataReady, - "modelExists": fileExistsNonEmpty(detectorModelPath), - "modelPath": detectorModelPath, + "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{ @@ -2940,7 +3229,7 @@ func trainingStatusHandler(w http.ResponseWriter, r *http.Request) { "peopleSource": "yolo26_detector", "genderSource": "yolo26_detector", - "sexPositionSource": "yolo26_detector", + "sexPositionSource": "yolo26_pose", "bodyPartsSource": "yolo26_detector", "objectsSource": "yolo26_detector", "clothingSource": "yolo26_detector", @@ -2955,16 +3244,39 @@ func trainingStatusHandler(w http.ResponseWriter, r *http.Request) { }) } +func trainingApplyStatsModelInfo(root string, stats *TrainingStatsResponse) { + detectorAvailable := trainingStatsModelAvailableFor(root, "detector") + detectorInfo := trainingReadModelInfoFor(root, "detector") + poseAvailable := trainingStatsModelAvailableFor(root, "pose") + poseInfo := trainingReadModelInfoFor(root, "pose") + + // 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 +} + func trainingStatsModelAvailable(root string) bool { - detectorModelPath := filepath.Join(root, "detector", "model", "best.pt") - return fileExistsNonEmpty(detectorModelPath) + return trainingStatsModelAvailableFor(root, "detector") +} + +func trainingStatsModelAvailableFor(root string, kind string) bool { + modelPath := filepath.Join(root, kind, "model", "best.pt") + return fileExistsNonEmpty(modelPath) } // trainingReadModelInfo liest Versions-/Datums-Infos zum aktuell trainierten -// Modell. Datum/Version stammen primär aus status.json (vom Trainingsskript), +// 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 { - modelPath := filepath.Join(root, "detector", "model", "best.pt") + return trainingReadModelInfoFor(root, "detector") +} + +func trainingReadModelInfoFor(root string, kind string) *TrainingModelInfo { + modelPath := filepath.Join(root, kind, "model", "best.pt") fi, err := os.Stat(modelPath) if err != nil || fi.IsDir() || fi.Size() <= 0 { @@ -2976,7 +3288,7 @@ func trainingReadModelInfo(root string) *TrainingModelInfo { TrainedAtMs: fi.ModTime().UnixMilli(), } - statusPath := filepath.Join(root, "detector", "model", "status.json") + statusPath := filepath.Join(root, kind, "model", "status.json") if b, err := os.ReadFile(statusPath); err == nil { var raw struct { TrainedAt string `json:"trainedAt"` @@ -3441,6 +3753,35 @@ names: 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"), @@ -3467,6 +3808,32 @@ func trainingEnsureDetectorDirs(root string) error { 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() @@ -3722,8 +4089,7 @@ func trainingPredictionUncertaintyScore(pred TrainingPrediction) float64 { scores = append(scores, clamp01(score)) } - if strings.TrimSpace(pred.SexPosition) != "" && - strings.TrimSpace(pred.SexPosition) != "unknown" { + if !isNoSexPositionLabel(pred.SexPosition) { addScore(pred.SexPositionScore) } @@ -4051,7 +4417,12 @@ func trainingPredictFrame(framePath string) TrainingPrediction { } det := trainingPredictDetector(root, framePath) - return trainingPredictionFromDetector(det) + pred := trainingPredictionFromDetector(det) + + pose := trainingPredictPose(root, framePath) + pred = trainingApplyPoseToPrediction(pred, pose) + + return pred } func trainingPredictFrameDetectorOnly(framePath string) TrainingPrediction { @@ -4079,7 +4450,7 @@ func trainingPredictionFromDetector(det TrainingDetectorPrediction) TrainingPred pred := TrainingPrediction{ ModelAvailable: det.Available, Source: det.Source, - SexPosition: "unknown", + SexPosition: trainingNoSexPositionLabel, SexPositionScore: 0, PeoplePresent: []TrainingScoredLabel{}, BodyPartsPresent: []TrainingScoredLabel{}, @@ -4110,14 +4481,6 @@ func trainingPredictionFromDetector(det TrainingDetectorPrediction) TrainingPred } } - sexPositionSet := map[string]bool{} - for _, label := range grouped.SexPositions { - clean := strings.TrimSpace(label) - if clean != "" && clean != "unknown" { - sexPositionSet[clean] = true - } - } - detectionSet := map[string]bool{} for _, label := range grouped.BodyParts { @@ -4143,9 +4506,6 @@ func trainingPredictionFromDetector(det TrainingDetectorPrediction) TrainingPred visibleBoxes := []TrainingBox{} - bestSexPosition := "" - bestSexPositionScore := 0.0 - for _, box := range rawBoxes { if box.Score > 0 && box.Score < 0.25 { continue @@ -4158,22 +4518,6 @@ func trainingPredictionFromDetector(det TrainingDetectorPrediction) TrainingPred box.Label = label - if sexPositionSet[label] { - score := box.Score - if score <= 0 { - score = 1 - } - - if score > bestSexPositionScore { - bestSexPosition = label - bestSexPositionScore = score - } - - // Wichtig: - // Positions-Full-Frame-Boxen nicht als normale sichtbare Box anzeigen. - continue - } - if peopleSet[label] { visibleBoxes = append(visibleBoxes, box) continue @@ -4184,11 +4528,6 @@ func trainingPredictionFromDetector(det TrainingDetectorPrediction) TrainingPred } } - if bestSexPosition != "" { - pred.SexPosition = bestSexPosition - pred.SexPositionScore = bestSexPositionScore - } - pred.Boxes = visibleBoxes pred.PeoplePresent = trainingScoredLabelsFromDetectorBoxes(visibleBoxes, grouped.People) @@ -4203,12 +4542,11 @@ func trainingPredictDetector(root string, framePath string) TrainingDetectorPred python := trainingPythonExe() script := trainingScriptPath("predict_detector_model.py") - modelPath := filepath.Join(root, "detector", "model", "best.pt") - - if !fileExistsNonEmpty(modelPath) { + model := trainingResolveDetectorModel(root) + if !model.EffectiveExists { return TrainingDetectorPrediction{ Available: false, - Source: "detector_missing", + Source: model.Source, Boxes: []TrainingBox{}, } } @@ -4227,6 +4565,7 @@ func trainingPredictDetector(root string, framePath string) TrainingDetectorPred script, "--root", root, "--image", framePath, + "--model", model.EffectivePath, "--conf", conf, "--imgsz", "640", ) @@ -4280,9 +4619,7 @@ func trainingPredictDetector(root string, framePath string) TrainingDetectorPred det.Boxes = []TrainingBox{} } - if det.Source == "" { - det.Source = "yolo26_detector" - } + det.Source = model.Source best = det @@ -4386,6 +4723,123 @@ func trainingApplyDetectorToPrediction(pred TrainingPrediction, det TrainingDete 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", + ) + + cmd.SysProcAttr = &syscall.SysProcAttr{ + HideWindow: true, + CreationFlags: 0x08000000, + } + + var stdout strings.Builder + var stderr strings.Builder + + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + + outText := strings.TrimSpace(stdout.String()) + errText := strings.TrimSpace(stderr.String()) + + if errText != "" { + 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.Source = model.Source + + return pose +} + +func trainingApplyPoseToPrediction(pred TrainingPrediction, pose TrainingPosePrediction) TrainingPrediction { + if !pose.Available || len(pose.Persons) == 0 { + return pred + } + + pred.Persons = pose.Persons + + positionSet := stringSet(defaultTrainingLabelsFromJSON().SexPositions) + bestPosition := "" + bestPositionScore := 0.0 + + for _, person := range pose.Persons { + label := strings.TrimSpace(person.Label) + if isNoSexPositionLabel(label) || !positionSet[label] { + continue + } + + if person.Score > bestPositionScore { + bestPosition = label + bestPositionScore = person.Score + } + } + + if bestPosition != "" { + pred.SexPosition = bestPosition + pred.SexPositionScore = bestPositionScore + } + + if pred.Source == "" { + pred.Source = "yolo_pose" + } else { + pred.Source = pred.Source + "+yolo_pose" + } + + return pred +} + func trainingDetectorLabelContent( boxes []TrainingBox, classMap map[string]int, @@ -4485,6 +4939,161 @@ func trainingWriteDetectorSample( 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 trainingPoseLabelContent(persons []TrainingPosePerson, classID int) ([]byte, error) { + lines := []string{} + + for _, person := range persons { + 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 >= 0.20 && 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) 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 := trainingPosePersonsForSample(root, sample) + 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, "\\") { @@ -4503,6 +5112,74 @@ func trainingDeleteDetectorSample(root string, sampleID string) { } } +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); 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") @@ -4590,6 +5267,79 @@ func trainingEnsureDetectorValidationSample(root string) error { 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 { @@ -4610,7 +5360,7 @@ func trainingEmptyPrediction(source string) TrainingPrediction { return TrainingPrediction{ ModelAvailable: false, Source: source, - SexPosition: "unknown", + SexPosition: trainingNoSexPositionLabel, SexPositionScore: 0, PeoplePresent: []TrainingScoredLabel{}, BodyPartsPresent: []TrainingScoredLabel{}, diff --git a/backend/training_label_loader.go b/backend/training_label_loader.go index 94f5071..3368637 100644 --- a/backend/training_label_loader.go +++ b/backend/training_label_loader.go @@ -18,6 +18,56 @@ type TrainingGroupedLabels struct { Clothing []string `json:"clothing"` } +const trainingNoSexPositionLabel = "keine" + +func isNoSexPositionLabel(label string) bool { + switch strings.ToLower(strings.TrimSpace(label)) { + case "", trainingNoSexPositionLabel: + return true + default: + return false + } +} + +func normalizeSexPositionLabel(label string) string { + clean := strings.TrimSpace(label) + if isNoSexPositionLabel(clean) { + return trainingNoSexPositionLabel + } + return clean +} + +func normalizeSexPositionLabels(values []string) []string { + seen := map[string]bool{} + out := []string{} + hasNoPosition := false + + for _, value := range values { + label := strings.TrimSpace(value) + if label == "" { + continue + } + + if isNoSexPositionLabel(label) { + hasNoPosition = true + continue + } + + if seen[label] { + continue + } + + seen[label] = true + out = append(out, label) + } + + if hasNoPosition { + out = append([]string{trainingNoSexPositionLabel}, out...) + } + + return out +} + func trainingDetectionLabelsPath() string { if p, err := ensureTrainingDetectionLabelsFile(); err == nil { return p @@ -114,13 +164,13 @@ func trainingGroupedLabels() (TrainingGroupedLabels, error) { } grouped.People = uniqueNonEmptyLabels(grouped.People) - grouped.SexPositions = uniqueNonEmptyLabels(grouped.SexPositions) + grouped.SexPositions = normalizeSexPositionLabels(grouped.SexPositions) grouped.BodyParts = uniqueNonEmptyLabels(grouped.BodyParts) grouped.Objects = uniqueNonEmptyLabels(grouped.Objects) grouped.Clothing = uniqueNonEmptyLabels(grouped.Clothing) if len(grouped.SexPositions) == 0 { - grouped.SexPositions = []string{"unknown"} + grouped.SexPositions = []string{trainingNoSexPositionLabel} } if len(grouped.People)+len(grouped.SexPositions)+len(grouped.BodyParts)+len(grouped.Objects)+len(grouped.Clothing) == 0 { @@ -146,7 +196,7 @@ func trainingDetectorLabels() ([]string, error) { for _, label := range grouped.SexPositions { clean := strings.TrimSpace(label) - if clean == "" || clean == "unknown" { + if isNoSexPositionLabel(clean) { continue } @@ -205,11 +255,65 @@ func trainingDetectorDatasetNamesYAML() (string, error) { return b.String(), nil } +func trainingPoseLabels() ([]string, error) { + grouped, err := trainingGroupedLabels() + if err != nil { + return nil, err + } + + labels := []string{} + seen := map[string]bool{} + + for _, value := range grouped.SexPositions { + label := strings.TrimSpace(value) + if isNoSexPositionLabel(label) || seen[label] { + continue + } + + seen[label] = true + labels = append(labels, label) + } + + if len(labels) == 0 { + return nil, fmt.Errorf("no pose sex position labels configured") + } + + return labels, nil +} + +func trainingPoseClassMap() (map[string]int, error) { + labels, err := trainingPoseLabels() + if err != nil { + return nil, err + } + + out := map[string]int{} + for i, label := range labels { + out[label] = i + } + + return out, nil +} + +func trainingPoseDatasetNamesYAML() (string, error) { + labels, err := trainingPoseLabels() + if err != nil { + return "", err + } + + var b strings.Builder + for i, label := range labels { + b.WriteString(fmt.Sprintf(" %d: %s\n", i, label)) + } + + return b.String(), nil +} + func defaultTrainingLabelsFromJSON() TrainingLabels { grouped, err := trainingGroupedLabels() if err != nil { grouped = TrainingGroupedLabels{ - SexPositions: []string{"unknown"}, + SexPositions: []string{trainingNoSexPositionLabel}, } } diff --git a/backend/training_test.go b/backend/training_test.go index 603dd1f..561a64f 100644 --- a/backend/training_test.go +++ b/backend/training_test.go @@ -124,8 +124,8 @@ func TestTrainingEffectiveCorrectionClearsNegativeAnnotation(t *testing.T) { }, }) - if effective.SexPosition != "unknown" { - t.Fatalf("sex position = %q, want unknown", effective.SexPosition) + if effective.SexPosition != trainingNoSexPositionLabel { + t.Fatalf("sex position = %q, want %s", effective.SexPosition, trainingNoSexPositionLabel) } if len(effective.Boxes) != 0 { t.Fatalf("negative annotation has %d boxes, want 0", len(effective.Boxes)) diff --git a/backend/yolo26n-pose.pt b/backend/yolo26n-pose.pt new file mode 100644 index 0000000..c8d9c92 Binary files /dev/null and b/backend/yolo26n-pose.pt differ diff --git a/backend/yolo26n.pt b/backend/yolo26n.pt deleted file mode 100644 index be48188..0000000 Binary files a/backend/yolo26n.pt and /dev/null differ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8ee2c07..5650143 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2956,6 +2956,40 @@ export default function App() { } } + const onNotification = (ev: MessageEvent) => { + try { + const data = JSON.parse(String(ev.data ?? 'null')) + + if (data?.type !== 'notification') return + + const title = String(data?.title ?? '').trim() + if (!title) return + + const message = String(data?.message ?? '').trim() || undefined + const durationRaw = Number(data?.durationMs ?? 0) + const opts = Number.isFinite(durationRaw) && durationRaw > 0 + ? { durationMs: durationRaw } + : undefined + + switch (String(data?.level ?? 'info').trim().toLowerCase()) { + case 'success': + notifyRef.current?.success(title, message, opts) + break + case 'warning': + notifyRef.current?.warning(title, message, opts) + break + case 'error': + notifyRef.current?.error(title, message, opts) + break + default: + notifyRef.current?.info(title, message, opts) + break + } + } catch { + // ignore + } + } + void loadJobs() void loadDoneCount() void loadTaskStateOnce() @@ -2981,6 +3015,7 @@ export default function App() { es.addEventListener('taskState', onTaskState as any) es.addEventListener('training', onTraining as any) es.addEventListener('analysisProgress', onAnalysisProgress as any) + es.addEventListener('notification', onNotification as any) const onVis = () => { if (document.hidden) return @@ -3008,6 +3043,7 @@ export default function App() { es.removeEventListener('taskState', onTaskState as any) es.removeEventListener('training', onTraining as any) es.removeEventListener('analysisProgress', onAnalysisProgress as any) + es.removeEventListener('notification', onNotification as any) const handler = onModelJobEventRef.current if (handler) { @@ -3110,6 +3146,7 @@ export default function App() { async function stopJob(id: string) { try { await apiJSON(`/api/record/stop?id=${encodeURIComponent(id)}`, { method: 'POST' }) + void loadJobs() } catch (e: any) { notify.error('Stop fehlgeschlagen', e?.message ?? String(e)) } @@ -3118,6 +3155,7 @@ export default function App() { async function stopAllJobs() { try { await apiJSON('/api/record/stop-all', { method: 'POST' }) + void loadJobs() } catch (e: any) { notify.error('Alle stoppen fehlgeschlagen', e?.message ?? String(e)) } @@ -3887,6 +3925,7 @@ export default function App() { }, [autoAddEnabled, autoStartEnabled, startUrl]) const isTrainingTab = selectedTab === 'training' + const [trainingTabMounted, setTrainingTabMounted] = useState(() => isTrainingTab) useEffect(() => { if (!isTrainingTab) { @@ -3894,6 +3933,12 @@ export default function App() { } }, [isTrainingTab]) + useEffect(() => { + if (isTrainingTab) { + setTrainingTabMounted(true) + } + }, [isTrainingTab]) + if (!authChecked) { return