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
Lade…
} @@ -4236,11 +4281,17 @@ export default function App() { ) : null} - {selectedTab === 'training' ? ( - + {trainingTabMounted ? ( +
+ +
) : null} {selectedTab === 'categories' ? : null} diff --git a/frontend/src/aiLabels.ts b/frontend/src/aiLabels.ts index da38a5b..79fc067 100644 --- a/frontend/src/aiLabels.ts +++ b/frontend/src/aiLabels.ts @@ -8,8 +8,10 @@ export type AiLabelGroup = | 'clothing' | 'unknown' +const NO_POSITION_LABELS = new Set(['', 'keine']) + export const AI_POSITION_LABELS = [ - 'unknown', + 'keine', 'missionary', 'doggy', 'doggystyle', @@ -109,7 +111,7 @@ export const AI_LABEL_FALLBACKS: Record = { stockings: 'Strümpfe', croptop: 'Crop Top', - unknown: 'Unbekannt', + keine: 'Keine', missionary: 'Missionary', doggy: 'Doggy', doggystyle: 'Doggy', @@ -237,7 +239,7 @@ export function prettyAiLabel(value: unknown): string { export function aiLabelGroup(value: unknown): AiLabelGroup { const label = normalizeAiLabel(value) - if (!label || label === 'unknown') return 'unknown' + if (NO_POSITION_LABELS.has(label)) return 'unknown' if (PERSON_SET.has(label)) return 'people' if (POSITION_SET.has(label)) return 'position' if (BODY_SET.has(label)) return 'body' @@ -248,7 +250,8 @@ export function aiLabelGroup(value: unknown): AiLabelGroup { } export function isKnownFrontendPositionLabel(value: unknown): boolean { - return POSITION_SET.has(normalizeAiLabel(value)) + const label = normalizeAiLabel(value) + return !NO_POSITION_LABELS.has(label) && POSITION_SET.has(label) } export function isKnownAiContentLabel(value: unknown): boolean { diff --git a/frontend/src/components/ui/Downloads.tsx b/frontend/src/components/ui/Downloads.tsx index 26f9b85..572cdf8 100644 --- a/frontend/src/components/ui/Downloads.tsx +++ b/frontend/src/components/ui/Downloads.tsx @@ -308,7 +308,7 @@ const addedAtMsOf = (r: DownloadRow): number => { const phaseLabel = (p?: string) => { switch ((p ?? '').toLowerCase()) { case 'stopping': - return 'Stop wird angefordert…' + return 'Stoppe Aufnahme…' case 'probe': return 'Analysiere Datei (Dauer/Streams)…' case 'remuxing': @@ -326,6 +326,45 @@ const phaseLabel = (p?: string) => { } } +const STOP_RETRY_AFTER_MS = 15_000 + +const stopRequestedAtMsOf = (job: RecordJob): number => { + return toMs((job as any)?.stopRequestedAtMs) +} + +const isFreshStoppingPhase = (job: RecordJob, nowMs: number): boolean => { + const phase = String((job as any)?.phase ?? '').trim().toLowerCase() + if (phase !== 'stopping') return false + + const startedAt = stopRequestedAtMsOf(job) + return startedAt > 0 && Math.max(0, nowMs - startedAt) < STOP_RETRY_AFTER_MS +} + +const canRetryStoppingJob = (job: RecordJob, nowMs: number): boolean => { + const phase = String((job as any)?.phase ?? '').trim().toLowerCase() + const status = String((job as any)?.status ?? '').trim().toLowerCase() + return phase === 'stopping' && status === 'running' && !isFreshStoppingPhase(job, nowMs) +} + +const isStopWaitingForJob = ( + job: RecordJob, + localStopRequested: boolean, + nowMs: number +): boolean => { + return localStopRequested || isFreshStoppingPhase(job, nowMs) +} + +const isStoppingForUi = ( + job: RecordJob, + localStopRequested: boolean, + nowMs: number +): boolean => { + const phase = String((job as any)?.phase ?? '').trim().toLowerCase() + const status = String((job as any)?.status ?? '').trim().toLowerCase() + const isBusyPhase = phase !== '' && phase !== 'recording' && phase !== 'stopping' + return isBusyPhase || status !== 'running' || isStopWaitingForJob(job, localStopRequested, nowMs) +} + function postWorkLabel( job: RecordJob, override?: { pos?: number; total?: number } @@ -370,9 +409,11 @@ function postWorkLabel( function StatusCell({ job, postworkInfo, + nowMs, }: { job: RecordJob postworkInfo?: { pos?: number; total?: number } + nowMs: number }) { const anyJ = job as any const phaseRaw = String(anyJ?.phase ?? '').trim() @@ -398,7 +439,11 @@ function StatusCell({ } if (!text) { - text = phaseRaw ? (phaseLabel(phaseRaw) || phaseRaw) : String(anyJ?.status ?? '').trim().toLowerCase() + if (phase === 'stopping' && canRetryStoppingJob(job, nowMs)) { + text = 'Stop dauert zu lange - erneut stoppen' + } else { + text = phaseRaw ? (phaseLabel(phaseRaw) || phaseRaw) : String(anyJ?.status ?? '').trim().toLowerCase() + } } const showBar = @@ -720,30 +765,59 @@ export default function Downloads({ const [stopRequestedIds, setStopRequestedIds] = useState>({}) const [stopInitiatedIds, setStopInitiatedIds] = useState>({}) + const stopRequestClearTimersRef = useRef>({}) const [removePendingRequestedKeys, setRemovePendingRequestedKeys] = useState>({}) const [removeQueuedPostworkRequestedIds, setRemoveQueuedPostworkRequestedIds] = useState>({}) const markStopRequested = useCallback((ids: string | string[]) => { const arr = Array.isArray(ids) ? ids : [ids] + const cleanIds = arr.map((id) => String(id || '').trim()).filter(Boolean) + + for (const id of cleanIds) { + const oldTimer = stopRequestClearTimersRef.current[id] + if (oldTimer) { + window.clearTimeout(oldTimer) + } + + stopRequestClearTimersRef.current[id] = window.setTimeout(() => { + delete stopRequestClearTimersRef.current[id] + + setStopRequestedIds((prev) => { + if (!prev[id]) return prev + const next = { ...prev } + delete next[id] + return next + }) + }, 12_000) + } setStopRequestedIds((prev) => { const next = { ...prev } - for (const id of arr) { - if (id) next[id] = true + for (const id of cleanIds) { + next[id] = true } return next }) setStopInitiatedIds((prev) => { const next = { ...prev } - for (const id of arr) { - if (id) next[id] = true + for (const id of cleanIds) { + next[id] = true } return next }) }, []) + useEffect(() => { + return () => { + for (const timer of Object.values(stopRequestClearTimersRef.current)) { + window.clearTimeout(timer) + } + stopRequestClearTimersRef.current = {} + } + }, []) + const markRemovePendingRequested = useCallback((key: string) => { if (!key) return setRemovePendingRequestedKeys((prev) => ({ ...prev, [key]: true })) @@ -791,7 +865,10 @@ export default function Downloads({ } const phaseLower = String((j as any).phase ?? '').trim().toLowerCase() - const isBusyPhase = phaseLower !== '' && phaseLower !== 'recording' + const isBusyPhase = + phaseLower !== '' && + phaseLower !== 'recording' && + phaseLower !== 'stopping' const isStopping = isBusyPhase || j.status !== 'running' if (isStopping) { @@ -994,15 +1071,12 @@ export default function Downloads({ if (isPostworkJob(j)) return false if ((j as any).endedAt) return false - const phase = String((j as any).phase ?? '').trim() const isStopRequested = Boolean(stopRequestedIds[j.id]) - const phaseLower = phase.trim().toLowerCase() - const isBusyPhase = phaseLower !== '' && phaseLower !== 'recording' - const isStopping = isBusyPhase || j.status !== 'running' || isStopRequested + const isStopping = isStoppingForUi(j, isStopRequested, nowMs) return !isStopping }) .map((j) => j.id) - }, [jobs, stopRequestedIds]) + }, [jobs, stopRequestedIds, nowMs]) const columns = useMemo[]>(() => { return [ @@ -1187,7 +1261,7 @@ export default function Downloads({ cell: (r) => { if (r.kind === 'job') { const j = r.job - return + return } const p = r.pending @@ -1308,16 +1382,14 @@ export default function Downloads({ } const j = r.job - const phase = String((j as any).phase ?? '').trim() const isStopRequested = Boolean(stopRequestedIds[j.id]) - const phaseLower = phase.trim().toLowerCase() const postworkState = getEffectivePostworkState(j) const isQueuedPostwork = postworkState === 'queued' const isRemovingQueuedPostwork = Boolean(removeQueuedPostworkRequestedIds[j.id]) - const isBusyPhase = phaseLower !== '' && phaseLower !== 'recording' - const isStopping = isBusyPhase || j.status !== 'running' || isStopRequested + const canRetryStop = canRetryStoppingJob(j, nowMs) && !isStopRequested + const isStopping = isStoppingForUi(j, isStopRequested, nowMs) const buttonBusy = isQueuedPostwork ? isRemovingQueuedPostwork : isStopping const disableStopButton = isQueuedPostwork ? isRemovingQueuedPostwork : isStopping @@ -1375,7 +1447,9 @@ export default function Downloads({ > {isQueuedPostwork ? (isRemovingQueuedPostwork ? 'Entferne…' : 'Entfernen') - : (isStopping ? 'Stoppe…' : 'Stoppen')} + : canRetryStop + ? 'Stop erneut' + : (isStopping ? 'Stoppe…' : 'Stoppen')} ) @@ -1519,15 +1593,16 @@ export default function Downloads({ const stopRequested = Boolean(stopRequestedIds[j.id]) const removingQueued = Boolean(removeQueuedPostworkRequestedIds[j.id]) const isQueuedPostwork = getEffectivePostworkState(j) === 'queued' + const stopWaiting = isStopWaitingForJob(j, stopRequested, nowMs) return [ 'transition-all duration-300', - stopRequested && !isQueuedPostwork && 'bg-indigo-50/70 dark:bg-indigo-500/10 pointer-events-none animate-pulse', + stopWaiting && !isQueuedPostwork && 'bg-indigo-50/70 dark:bg-indigo-500/10 pointer-events-none animate-pulse', removingQueued && isQueuedPostwork && 'bg-red-50/70 dark:bg-red-500/10 pointer-events-none animate-pulse', ] .filter(Boolean) .join(' ') - }, [removePendingRequestedKeys, stopRequestedIds, removeQueuedPostworkRequestedIds]) + }, [removePendingRequestedKeys, stopRequestedIds, removeQueuedPostworkRequestedIds, nowMs]) return (
@@ -1707,17 +1782,16 @@ export default function Downloads({ if (r.kind !== 'job') return null const j = r.job - const phase = String((j as any).phase ?? '').trim().toLowerCase() const isStopRequested = Boolean(stopRequestedIds[j.id]) const postworkState = getEffectivePostworkState(j) const isQueuedPostwork = postworkState === 'queued' - const isBusyPhase = phase !== '' && phase !== 'recording' - const isStopping = isBusyPhase || j.status !== 'running' || isStopRequested + const canRetryStop = canRetryStoppingJob(j, nowMs) && !isStopRequested + const isStopping = isStoppingForUi(j, isStopRequested, nowMs) return ( { @@ -1822,20 +1896,21 @@ export default function Downloads({ if (r.kind !== 'job') return null const j = r.job - const phase = String((j as any).phase ?? '').trim().toLowerCase() const isStopRequested = Boolean(stopRequestedIds[j.id]) const postworkState = getEffectivePostworkState(j) const isQueuedPostwork = postworkState === 'queued' - const isBusyPhase = phase !== '' && phase !== 'recording' - const isStopping = isBusyPhase || j.status !== 'running' || isStopRequested + const canRetryStop = canRetryStoppingJob(j, nowMs) && !isStopRequested + const isStopping = isStoppingForUi(j, isStopRequested, nowMs) const isRemovingQueuedPostwork = Boolean(removeQueuedPostworkRequestedIds[j.id]) const label = isQueuedPostwork ? (isRemovingQueuedPostwork ? 'Entferne…' : 'Entfernen') - : (isStopping ? 'Stoppe…' : 'Stoppen') + : canRetryStop + ? 'Stop erneut' + : (isStopping ? 'Stoppe…' : 'Stoppen') const color = isQueuedPostwork ? 'red' : 'indigo' const disabled = isQueuedPostwork ? isRemovingQueuedPostwork : isStopping @@ -1979,4 +2054,4 @@ export default function Downloads({ )}
) -} \ No newline at end of file +} diff --git a/frontend/src/components/ui/DownloadsCardRow.tsx b/frontend/src/components/ui/DownloadsCardRow.tsx index 96e141a..115b04e 100644 --- a/frontend/src/components/ui/DownloadsCardRow.tsx +++ b/frontend/src/components/ui/DownloadsCardRow.tsx @@ -320,7 +320,7 @@ const formatBytes = (bytes: number | null): string => { const phaseLabel = (p?: string) => { switch ((p ?? '').toLowerCase()) { case 'stopping': - return 'Stop wird angefordert…' + return 'Stoppe Aufnahme…' case 'probe': return 'Analysiere Datei (Dauer/Streams)…' case 'remuxing': @@ -338,6 +338,45 @@ const phaseLabel = (p?: string) => { } } +const STOP_RETRY_AFTER_MS = 15_000 + +const stopRequestedAtMsOf = (job: RecordJob): number => { + return toMs((job as any)?.stopRequestedAtMs) +} + +const isFreshStoppingPhase = (job: RecordJob, nowMs: number): boolean => { + const phase = String((job as any)?.phase ?? '').trim().toLowerCase() + if (phase !== 'stopping') return false + + const startedAt = stopRequestedAtMsOf(job) + return startedAt > 0 && Math.max(0, nowMs - startedAt) < STOP_RETRY_AFTER_MS +} + +const canRetryStoppingJob = (job: RecordJob, nowMs: number): boolean => { + const phase = String((job as any)?.phase ?? '').trim().toLowerCase() + const status = String((job as any)?.status ?? '').trim().toLowerCase() + return phase === 'stopping' && status === 'running' && !isFreshStoppingPhase(job, nowMs) +} + +const isStopWaitingForJob = ( + job: RecordJob, + localStopRequested: boolean, + nowMs: number +): boolean => { + return localStopRequested || isFreshStoppingPhase(job, nowMs) +} + +const isStoppingForUi = ( + job: RecordJob, + localStopRequested: boolean, + nowMs: number +): boolean => { + const phase = String((job as any)?.phase ?? '').trim().toLowerCase() + const status = String((job as any)?.status ?? '').trim().toLowerCase() + const isBusyPhase = phase !== '' && phase !== 'recording' && phase !== 'stopping' + return isBusyPhase || status !== 'running' || isStopWaitingForJob(job, localStopRequested, nowMs) +} + const isTerminalStatus = (status?: unknown) => { const s = String(status ?? '').trim().toLowerCase() return ( @@ -734,13 +773,12 @@ export default function DownloadsCardRow({ const isRecording = phaseLower === 'recording' const isStopRequested = Boolean(stopRequestedIds[j.id]) - const rawStatus = String(j.status ?? '').toLowerCase() const postworkState = getEffectivePostworkState(j) const isQueuedPostwork = postworkState === 'queued' - const isBusyPhase = phaseLower !== '' && phaseLower !== 'recording' - const isStopping = isBusyPhase || rawStatus !== 'running' || isStopRequested + const canRetryStop = canRetryStoppingJob(j, nowMs) && !isStopRequested + const isStopping = isStoppingForUi(j, isStopRequested, nowMs) const showRemoveQueuedButton = isQueuedPostwork const disableStopButton = showRemoveQueuedButton ? false : isStopping @@ -768,7 +806,11 @@ export default function DownloadsCardRow({ phaseText = phaseLabel(phase) || postWorkLabel(j, postworkInfoOf(j)) } } else { - phaseText = phase ? (phaseLabel(phase) || phase) : '' + if (phaseLower === 'stopping' && canRetryStop) { + phaseText = 'Stop dauert zu lange - erneut stoppen' + } else { + phaseText = phase ? (phaseLabel(phase) || phase) : '' + } } const progressLabel = phaseText || roomStatus @@ -979,7 +1021,11 @@ export default function DownloadsCardRow({ await onStopJob(j.id) }} > - {showRemoveQueuedButton ? 'Entfernen' : (isStopping ? 'Stoppe…' : 'Stop')} + {showRemoveQueuedButton + ? 'Entfernen' + : canRetryStop + ? 'Stop erneut' + : (isStopping ? 'Stoppe…' : 'Stop')} @@ -987,4 +1033,4 @@ export default function DownloadsCardRow({ ) -} \ No newline at end of file +} diff --git a/frontend/src/components/ui/FinishedDownloads.tsx b/frontend/src/components/ui/FinishedDownloads.tsx index c431635..b70d8d0 100644 --- a/frontend/src/components/ui/FinishedDownloads.tsx +++ b/frontend/src/components/ui/FinishedDownloads.tsx @@ -120,6 +120,24 @@ const baseName = (p: string) => { return parts[parts.length - 1] || '' } +function resolveRenamedFile(file: string, renamedFiles: Record) { + let current = String(file || '').trim() + if (!current) return current + + const seen = new Set() + + while (current && !seen.has(current)) { + seen.add(current) + + const next = String(renamedFiles[current] || '').trim() + if (!next || next === current) break + + current = next + } + + return current +} + const stemWithoutExt = (p: string) => { const f = baseName(p || '') return stripHotPrefix(f.replace(/\.[^.]+$/, '')) @@ -2585,8 +2603,8 @@ export default function FinishedDownloads({ (job: RecordJob): RecordJob => { const out = norm(job.output || '') const file = baseName(out) - const override = renamedFiles[file] - if (!override) return job + const override = resolveRenamedFile(file, renamedFiles) + if (!override || override === file) return job const idx = out.lastIndexOf('/') const dir = idx >= 0 ? out.slice(0, idx + 1) : '' @@ -2897,8 +2915,11 @@ export default function FinishedDownloads({ }, [activeVisibleRows, keyFor, selectionStore, selectionVersion]) const hasSearchQuery = (searchQuery || '').trim() !== '' - const selectionActionsVisible = selectedCount > 0 && selectionActionsOpen - const selectionModeActive = selectedCount > 0 + const selectionActionsVisible = selectionActionsOpen && activeVisibleRows.length > 0 + const selectionModeActive = selectionActionsOpen || selectedCount > 0 + const selectionButtonDisabled = activeVisibleRows.length === 0 + const selectionButtonLabel = selectedCount > 0 ? `${selectedCount} ausgewählt` : 'Auswählen' + const selectedBulkActionsDisabled = bulkBusy || selectedCount === 0 const selectedKeys = useMemo(() => { const next = new Set() @@ -3241,6 +3262,28 @@ export default function FinishedDownloads({ } }, []) + const removeRowNow = useCallback( + (key: string, file?: string) => { + cancelRemoveTimer(key) + + if (file) { + setHiddenFiles((prev) => { + const next = new Set(prev) + next.add(file) + return next + }) + } + + markDeleted(key) + markRemoving(key, false) + + requestAnimationFrame(() => { + refreshHoverPreviewFromPointer() + }) + }, + [cancelRemoveTimer, markDeleted, markRemoving, refreshHoverPreviewFromPointer] + ) + const restoreRow = useCallback( (key: string, file?: string) => { cancelRemoveTimer(key) @@ -3470,7 +3513,7 @@ export default function FinishedDownloads({ rowKey: key, setBusy: (v) => markDeleting(key, v), isBusyNow: () => deletingKeys.has(key), - optimisticRemove: true, + optimisticRemove: false, alreadyRemoved: opts?.alreadyRemoved, labels: { invalidTitle: 'Löschen nicht möglich', @@ -3501,6 +3544,7 @@ export default function FinishedDownloads({ }, onSuccess: async (result: any) => { selectionStore.deselect(key) + removeRowNow(key, file) const undoToken = typeof result?.undoToken === 'string' ? result.undoToken : '' const from = typeof result?.from === 'string' ? result.from : undefined @@ -3525,6 +3569,7 @@ export default function FinishedDownloads({ runFileMutation, emitCountHint, selectionStore, + removeRowNow, ] ) @@ -3600,10 +3645,28 @@ export default function FinishedDownloads({ setRenamedFiles((prev) => { const next: Record = { ...prev } + + const sources = new Set([oldFile]) + for (const [k, v] of Object.entries(next)) { - if (k === oldFile || k === newFile || v === oldFile || v === newFile) delete next[k] + const final = resolveRenamedFile(k, prev) + + if (final === oldFile || v === oldFile) { + sources.add(k) + } + + if (k === newFile || v === newFile) { + delete next[k] + } } - next[oldFile] = newFile + + for (const source of sources) { + if (!source || source === newFile) continue + next[source] = newFile + } + + delete next[newFile] + return next }) @@ -3614,9 +3677,33 @@ export default function FinishedDownloads({ if (!a && !b) return setRenamedFiles((prev) => { const next: Record = { ...prev } + for (const [k, v] of Object.entries(next)) { - if (k === a || k === b || v === a || v === b) delete next[k] + const final = resolveRenamedFile(k, prev) + + if (k === b) { + delete next[k] + continue + } + + if (final === b || v === b) { + if (k && k !== a) { + next[k] = a + } else { + delete next[k] + } + continue + } + + if (k === a || v === a) { + delete next[k] + } } + + if (a && next[a] === a) { + delete next[a] + } + return next }) }, []) @@ -4025,7 +4112,7 @@ export default function FinishedDownloads({ if (item?.ok) { deletedKeys.add(key) - animateRemove(key, file) + removeRowNow(key, file) } else { failedKeys.add(key) } @@ -4089,9 +4176,9 @@ export default function FinishedDownloads({ }, [ bulkBusy, selectedFiles, - animateRemove, clearSelection, emitCountHint, + removeRowNow, notify, ]) @@ -5031,10 +5118,10 @@ export default function FinishedDownloads({ }, [isSmall]) useEffect(() => { - if (selectedCount === 0 && selectionActionsOpen) { + if (activeVisibleRows.length === 0 && selectionActionsOpen) { setSelectionActionsOpen(false) } - }, [selectedCount, selectionActionsOpen]) + }, [activeVisibleRows.length, selectionActionsOpen]) const desktopToolbarControlsClass = [ 'flex flex-col gap-3', @@ -5278,24 +5365,23 @@ export default function FinishedDownloads({ - {selectedCount > 0 ? ( - - ) : null} +
} - disabled={bulkBusy} + disabled={selectedBulkActionsDisabled} onClick={clearSelection} className="w-full justify-center" > @@ -5352,7 +5438,7 @@ export default function FinishedDownloads({ variant="soft" color="emerald" leadingIcon={} - disabled={bulkBusy} + disabled={selectedBulkActionsDisabled} onClick={() => void bulkKeepSelected()} className="w-full justify-center" > @@ -5364,7 +5450,7 @@ export default function FinishedDownloads({ variant="soft" color="red" leadingIcon={} - disabled={bulkBusy} + disabled={selectedBulkActionsDisabled} onClick={() => void bulkDeleteSelected()} className="w-full justify-center" > @@ -5396,7 +5482,7 @@ export default function FinishedDownloads({ size="sm" variant="secondary" leadingIcon={} - disabled={bulkBusy} + disabled={selectedBulkActionsDisabled} onClick={clearSelection} > Auswahl aufheben @@ -5409,7 +5495,7 @@ export default function FinishedDownloads({ variant="soft" color="emerald" leadingIcon={} - disabled={bulkBusy} + disabled={selectedBulkActionsDisabled} onClick={() => void bulkKeepSelected()} > Behalten @@ -5420,7 +5506,7 @@ export default function FinishedDownloads({ variant="soft" color="red" leadingIcon={} - disabled={bulkBusy} + disabled={selectedBulkActionsDisabled} onClick={() => void bulkDeleteSelected()} > Löschen @@ -5610,11 +5696,28 @@ export default function FinishedDownloads({
-
+
Abgeschlossene Downloads
+ + {bulkBusy ? ( } @@ -5699,23 +5802,22 @@ export default function FinishedDownloads({ Report - {selectedCount > 0 ? ( - - ) : null} +
- {stats?.modelAvailable + {availableModelCount > 0 ? 'Trainiertes Modell verfügbar' : 'Noch kein trainiertes Modell verfügbar'}
- {stats?.modelAvailable && modelTrainedAtLabel ? ( + {detectorModelAvailable && modelTrainedAtLabel ? (
@@ -1991,11 +2234,77 @@ function TrainingStatsModal(props: {
) : (
- {stats?.modelAvailable + {availableModelCount > 0 ? 'Die aktuellen Trainingsdaten können bereits von einem Modell genutzt werden.' : 'Sammle weiter Feedback und starte anschließend das Training.'}
)} + +
+
+ {modelSummaryLabel} - {availableModelCount}/2 +
+ + {modelCards.map((model) => ( +
+
+ + {model.title} + + + {model.available ? 'bereit' : 'fehlt'} + +
+ + {model.available && model.trainedAtLabel ? ( +
+
+ + Version + + + {model.trainedAtLabel} + +
+ + {model.map50Label ? ( +
+ + mAP50{model.map5095Label ? ' / 50-95' : ''} + + + {model.map50Label} + {model.map5095Label ? ` / ${model.map5095Label}` : ''} + +
+ ) : null} + + {model.details ? ( +
+ {model.details} +
+ ) : null} +
+ ) : ( +
+ {model.available + ? 'Modell gefunden, Details fehlen.' + : 'Noch nicht trainiert.'} +
+ )} +
+ ))} +
@@ -2218,7 +2527,7 @@ function annotationToTrainingSample(item: TrainingAnnotation): TrainingSample { function annotationToCorrectionState(item: TrainingAnnotation): CorrectionState { if (item.negative) { return { - sexPosition: 'unknown', + sexPosition: NO_SEX_POSITION_LABEL, peoplePresent: [], bodyPartsPresent: [], objectsPresent: [], @@ -2228,7 +2537,10 @@ function annotationToCorrectionState(item: TrainingAnnotation): CorrectionState } if (item.correction) { - return item.correction + return { + ...item.correction, + sexPosition: normalizeSexPositionValue(item.correction.sexPosition), + } } return predictionToCorrection(annotationToTrainingSample(item)) @@ -2240,9 +2552,11 @@ const TRAINING_INFO_DISMISSED_STORAGE_KEY = 'training:info-dismissed-key' const TRAINING_IMAGE_EXPANDED_STORAGE_KEY = 'training:image-expanded' export default function TrainingTab(props: { + active?: boolean onTrainingRunningChange?: (running: boolean) => void onImageExpandedChange?: (expanded: boolean) => void }) { + const tabActive = props.active ?? true const [labels, setLabels] = useState(emptyLabels) const [sample, setSample] = useState(null) const [correction, setCorrection] = useState(() => predictionToCorrection(null)) @@ -2291,6 +2605,9 @@ export default function TrainingTab(props: { const [feedbackSearchQuery, setFeedbackSearchQuery] = useState('') const [feedbackSearchFilter, setFeedbackSearchFilter] = useState('all') + const initializedRef = useRef(false) + const initRunIdRef = useRef(0) + const [editingFeedback, setEditingFeedback] = useState<{ sampleId: string answeredAt: string @@ -2318,6 +2635,7 @@ export default function TrainingTab(props: { } | null>(null) const [loadingPreviewUrl, setLoadingPreviewUrl] = useState('') + const [loadingPreviewFallbackUrl, setLoadingPreviewFallbackUrl] = useState('') const [loadingPreviewLoaded, setLoadingPreviewLoaded] = useState(false) const [loadingPreviewFailed, setLoadingPreviewFailed] = useState(false) @@ -2346,6 +2664,8 @@ export default function TrainingTab(props: { bottom: number } + const activeImageContentRectRef = useRef(null) + const loadFeedbackHistoryInitial = useCallback(async ( options: { query?: string @@ -2523,11 +2843,25 @@ export default function TrainingTab(props: { const boxRect = boxEl.getBoundingClientRect() - setImageLayerStyle({ + const nextStyle: CSSProperties = { left: contentRect.left - boxRect.left, top: contentRect.top - boxRect.top, width: contentRect.width, height: contentRect.height, + } + + setImageLayerStyle((prev) => { + if ( + prev && + Number(prev.left) === nextStyle.left && + Number(prev.top) === nextStyle.top && + Number(prev.width) === nextStyle.width && + Number(prev.height) === nextStyle.height + ) { + return prev + } + + return nextStyle }) }, [getImageContentRect]) @@ -2600,6 +2934,14 @@ export default function TrainingTab(props: { const trainingRunningRef = useRef(false) + const drawingBoxRef = useRef(null) + const boxInteractionRef = useRef(null) + const correctionRef = useRef(correction) + const hasManualCorrectionRef = useRef(hasManualCorrection) + const latestGestureBoxRef = useRef(null) + const pendingPointerMoveRef = useRef<{ clientX: number; clientY: number } | null>(null) + const pointerMoveRafRef = useRef(null) + const mobileLabelsScrollRef = useRef(null) const mobileSectionRefs = useRef>({ @@ -2678,6 +3020,22 @@ export default function TrainingTab(props: { labelsRef.current = labels }, [labels]) + useEffect(() => { + drawingBoxRef.current = drawingBox + }, [drawingBox]) + + useEffect(() => { + boxInteractionRef.current = boxInteraction + }, [boxInteraction]) + + useEffect(() => { + correctionRef.current = correction + }, [correction]) + + useEffect(() => { + hasManualCorrectionRef.current = hasManualCorrection + }, [hasManualCorrection]) + useEffect(() => { trainingSampleModeRef.current = trainingSampleMode }, [trainingSampleMode]) @@ -2770,6 +3128,7 @@ export default function TrainingTab(props: { if (!clean) { loadingPreviewRawUrlRef.current = '' setLoadingPreviewUrl('') + setLoadingPreviewFallbackUrl('') setLoadingPreviewLoaded(false) setLoadingPreviewFailed(false) return @@ -2916,6 +3275,20 @@ export default function TrainingTab(props: { } : prev?.detector, + pose: data.pose + ? { + trainCount: Number(data.pose.trainCount ?? 0), + valCount: Number(data.pose.valCount ?? 0), + requiredTrain: Number(data.pose.requiredTrain ?? 20), + requiredVal: Number(data.pose.requiredVal ?? 3), + datasetReady: Boolean(data.pose.datasetReady), + dataReady: Boolean(data.pose.dataReady), + modelExists: Boolean(data.pose.modelExists), + modelPath: data.pose.modelPath, + source: data.pose.source, + } + : prev?.pose, + training: job ? { running: Boolean(job.running), @@ -2997,7 +3370,7 @@ export default function TrainingTab(props: { setNegativeExample(false) const initiallyExpandedSection: CorrectionSectionKey | null = - nextCorrection.sexPosition && nextCorrection.sexPosition !== 'unknown' + nextCorrection.sexPosition && !isNoSexPositionValue(nextCorrection.sexPosition) ? 'sexPosition' : nextCorrection.peoplePresent.length > 0 ? 'people' @@ -3042,6 +3415,7 @@ export default function TrainingTab(props: { refreshPrediction?: boolean preserveNotice?: boolean mode?: TrainingSampleMode + previewUrl?: string }) => { const requestId = makeRequestId() activeAnalysisRequestIdRef.current = requestId @@ -3050,7 +3424,10 @@ export default function TrainingTab(props: { const mode = opts?.mode ?? trainingSampleModeRef.current const uncertainMode = mode === 'uncertain' && !opts?.refreshPrediction - setLoadingPreviewCandidate('') + const previewUrl = String(opts?.previewUrl ?? '').trim() + + setLoadingPreviewFallbackUrl(previewUrl) + setLoadingPreviewCandidate(previewUrl) setLoading(true) setAnalysisProgress(8) setAnalysisStep( @@ -3132,9 +3509,12 @@ export default function TrainingTab(props: { setTouchMagnifier(null) setActiveBoxIndex(null) - await loadNext({ refreshPrediction: true }) + await loadNext({ + refreshPrediction: true, + previewUrl: imageSrc, + }) setImageReloadKey((value) => value + 1) - }, [loadNext]) + }, [imageSrc, loadNext]) const loadTrainingStatus = useCallback(async () => { const res = await fetch('/api/training/status', { cache: 'no-store' }) @@ -3283,19 +3663,15 @@ export default function TrainingTab(props: { sampleCount: Number(data?.sampleCount ?? 0), boxCount: Number(data?.boxCount ?? 0), modelAvailable: Boolean(data?.modelAvailable), - modelInfo: data?.modelInfo - ? { - trainedAt: data.modelInfo.trainedAt, - trainedAtMs: Number(data.modelInfo.trainedAtMs ?? 0), - epochs: Number(data.modelInfo.epochs ?? 0), - trainSamples: Number(data.modelInfo.trainSamples ?? 0), - valSamples: Number(data.modelInfo.valSamples ?? 0), - imgsz: Number(data.modelInfo.imgsz ?? 0), - device: data.modelInfo.device, - map50: Number(data.modelInfo.map50 ?? 0), - map5095: Number(data.modelInfo.map5095 ?? 0), - } - : undefined, + modelInfo: parseTrainingModelInfo(data?.modelInfo), + detectorModelAvailable: Boolean( + data?.detectorModelAvailable ?? data?.modelAvailable + ), + detectorModelInfo: parseTrainingModelInfo( + data?.detectorModelInfo ?? data?.modelInfo + ), + poseModelAvailable: Boolean(data?.poseModelAvailable), + poseModelInfo: parseTrainingModelInfo(data?.poseModelInfo), confidence: data?.confidence, labels: { people: Array.isArray(data?.labels?.people) ? data.labels.people : [], @@ -3341,6 +3717,8 @@ export default function TrainingTab(props: { }, []) useEffect(() => { + if (!tabActive) return + updateImageLayerStyle() const boxEl = imageBoxRef.current @@ -3364,7 +3742,7 @@ export default function TrainingTab(props: { window.removeEventListener('resize', updateImageLayerStyle) window.removeEventListener('scroll', updateImageLayerStyle, true) } - }, [imageSrc, imageExpanded, frameImageLoaded, updateImageLayerStyle]) + }, [tabActive, imageSrc, imageExpanded, frameImageLoaded, updateImageLayerStyle]) useEffect(() => { trainingRunningRef.current = trainingRunning @@ -3411,7 +3789,7 @@ export default function TrainingTab(props: { }, [applyTrainingStatus]) // Im festen Takt immer das zuletzt eingegangene Bild anzeigen. - // So bleibt die Anzeige live (max. ~110 ms Versatz) und die Render-Rate begrenzt. + // Rund 500 ms halten die Anzeige ruhig genug, damit der Crossfade weich bleibt. useEffect(() => { if (!trainingRunning) { latestTrainingPreviewRef.current = '' @@ -3424,7 +3802,7 @@ export default function TrainingTab(props: { if (!latest) return setTrainingPreviewUrl((cur) => (cur === latest ? cur : latest)) - }, 110) + }, 500) return () => window.clearInterval(timer) }, [trainingRunning]) @@ -3565,12 +3943,12 @@ export default function TrainingTab(props: { }, [trainingRunning, onTrainingRunningChange]) useEffect(() => { - props.onImageExpandedChange?.(imageExpanded) + props.onImageExpandedChange?.(tabActive ? imageExpanded : false) return () => { props.onImageExpandedChange?.(false) } - }, [imageExpanded, props.onImageExpandedChange]) + }, [tabActive, imageExpanded, props.onImageExpandedChange]) useEffect(() => { try { @@ -3678,22 +4056,31 @@ export default function TrainingTab(props: { }, [importVideoIntoTraining]) useEffect(() => { + if (!tabActive || initializedRef.current) return + + const runId = initRunIdRef.current + 1 + initRunIdRef.current = runId let cancelled = false async function init() { await loadLabels() await loadTrainingStatus() - if (cancelled) return + if (cancelled || initRunIdRef.current !== runId) return // Wichtig: // Wenn gerade ein Video-Import über "Video ins Training übernehmen" läuft, // darf loadNext() nicht danach ein zufälliges/letztes Sample darüberlegen. if (videoImportStartedRef.current || videoImportInFlightKeyRef.current) { + initializedRef.current = true return } await loadNext() + + if (!cancelled && initRunIdRef.current === runId) { + initializedRef.current = true + } } void init() @@ -3701,7 +4088,19 @@ export default function TrainingTab(props: { return () => { cancelled = true } - }, [loadLabels, loadNext, loadTrainingStatus]) + }, [tabActive, loadLabels, loadNext, loadTrainingStatus]) + + useEffect(() => { + if (!tabActive || !initializedRef.current) return + + void loadTrainingStatus() + + const frame = window.requestAnimationFrame(() => { + updateImageLayerStyle() + }) + + return () => window.cancelAnimationFrame(frame) + }, [tabActive, loadTrainingStatus, updateImageLayerStyle]) useEffect(() => { if (!trainingRunning) return @@ -3825,7 +4224,7 @@ export default function TrainingTab(props: { const negative = options?.negative ?? isNegativeCorrection const correctionPayload: CorrectionState = negative ? { - sexPosition: 'unknown', + sexPosition: NO_SEX_POSITION_LABEL, peoplePresent: [], bodyPartsPresent: [], objectsPresent: [], @@ -4095,14 +4494,12 @@ export default function TrainingTab(props: { } }, [loadNext, loadTrainingStatus, requiredCount]) - const getPointerPosInImage = useCallback(( + const getPointerPosFromRect = useCallback(( + rect: ImageContentRect, clientX: number, clientY: number, opts?: { clamp?: boolean } ) => { - const rect = getImageContentRect() - if (!rect) return null - const x = (clientX - rect.left) / rect.width const y = (clientY - rect.top) / rect.height @@ -4114,7 +4511,18 @@ export default function TrainingTab(props: { x: clamp01(x), y: clamp01(y), } - }, [getImageContentRect]) + }, []) + + const getPointerPosInImage = useCallback(( + clientX: number, + clientY: number, + opts?: { clamp?: boolean } + ) => { + const rect = activeImageContentRectRef.current ?? getImageContentRect() + if (!rect) return null + + return getPointerPosFromRect(rect, clientX, clientY, opts) + }, [getImageContentRect, getPointerPosFromRect]) const releaseActivePointerCapture = useCallback((pointerId?: number | null) => { const id = typeof pointerId === 'number' @@ -4135,16 +4543,222 @@ export default function TrainingTab(props: { activePointerIdRef.current = null }, []) + const markManualCorrection = useCallback(() => { + if (hasManualCorrectionRef.current) return + + hasManualCorrectionRef.current = true + setHasManualCorrection(true) + }, []) + + const cancelPointerMoveFrame = useCallback(() => { + if (pointerMoveRafRef.current !== null) { + window.cancelAnimationFrame(pointerMoveRafRef.current) + pointerMoveRafRef.current = null + } + + pendingPointerMoveRef.current = null + }, []) + + const clearBoxGestureRefs = useCallback(() => { + cancelPointerMoveFrame() + activeImageContentRectRef.current = null + drawingBoxRef.current = null + boxInteractionRef.current = null + latestGestureBoxRef.current = null + }, [cancelPointerMoveFrame]) + + const applyPointerMoveSnapshot = useCallback((snapshot: { + clientX: number + clientY: number + }) => { + const drawing = drawingBoxRef.current + const interaction = boxInteractionRef.current + + if (!drawing && !interaction) return + + const clampedPos = getPointerPosInImage(snapshot.clientX, snapshot.clientY) + if (!clampedPos) return + + const pos = + interaction?.type === 'move' + ? getPointerPosInImage(snapshot.clientX, snapshot.clientY, { clamp: false }) ?? clampedPos + : clampedPos + + const nextMagnifier: MagnifierState = { + visible: true, + clientX: snapshot.clientX, + clientY: snapshot.clientY, + imageX: clampedPos.x, + imageY: clampedPos.y, + } + + setTouchMagnifier((prev) => { + if ( + prev?.visible && + prev.clientX === nextMagnifier.clientX && + prev.clientY === nextMagnifier.clientY && + Math.abs(prev.imageX - nextMagnifier.imageX) <= 0.0005 && + Math.abs(prev.imageY - nextMagnifier.imageY) <= 0.0005 + ) { + return prev + } + + return nextMagnifier + }) + + if (interaction) { + const dx = pos.x - interaction.startX + const dy = pos.y - interaction.startY + const original = interaction.original + + let nextBox: TrainingBox = original + + if (interaction.type === 'move') { + nextBox = normalizeMovedBox({ + ...original, + x: original.x + dx, + y: original.y + dy, + }) + } + + if (interaction.type === 'resize') { + let x1 = original.x + let y1 = original.y + let x2 = original.x + original.w + let y2 = original.y + original.h + + const pointerX = snap01(clampedPos.x) + const pointerY = snap01(clampedPos.y) + + if (interaction.handle.includes('n')) y1 = pointerY + if (interaction.handle.includes('s')) y2 = pointerY + if (interaction.handle.includes('w')) x1 = pointerX + if (interaction.handle.includes('e')) x2 = pointerX + + const left = Math.min(x1, x2) + const top = Math.min(y1, y2) + const right = Math.max(x1, x2) + const bottom = Math.max(y1, y2) + + nextBox = normalizeBox({ + ...original, + x: left, + y: top, + w: right - left, + h: bottom - top, + }) + } + + const geometryChanged = boxGeometryChanged(original, nextBox) + const correctedNextBox = geometryChanged + ? markBoxCorrected(nextBox) + : nextBox + + latestGestureBoxRef.current = correctedNextBox + + if (geometryChanged) { + markManualCorrection() + } + + setCorrection((prev) => { + const boxes = prev.boxes ?? [] + const currentBox = boxes[interaction.index] + + if (!currentBox || !boxVisualStateChanged(currentBox, correctedNextBox)) { + return prev + } + + const next: CorrectionState = { + ...prev, + boxes: boxes.map((box, index) => + index === interaction.index ? correctedNextBox : box + ), + } + + correctionRef.current = next + return next + }) + + return + } + + if (!drawing) return + + const x1 = drawing.startX + const y1 = drawing.startY + const x2 = pos.x + const y2 = pos.y + + const nextDrawing: DrawingTrainingBox = { + ...drawing, + x: Math.min(x1, x2), + y: Math.min(y1, y2), + w: Math.abs(x2 - x1), + h: Math.abs(y2 - y1), + } + + latestGestureBoxRef.current = nextDrawing + drawingBoxRef.current = nextDrawing + + setDrawingBox((prev) => { + if (prev && !boxVisualStateChanged(prev, nextDrawing)) { + return prev + } + + return nextDrawing + }) + }, [getPointerPosInImage, markManualCorrection]) + + const schedulePointerMove = useCallback((snapshot: { + clientX: number + clientY: number + }) => { + pendingPointerMoveRef.current = snapshot + + if (pointerMoveRafRef.current !== null) return + + pointerMoveRafRef.current = window.requestAnimationFrame(() => { + pointerMoveRafRef.current = null + + const pending = pendingPointerMoveRef.current + pendingPointerMoveRef.current = null + + if (pending) { + applyPointerMoveSnapshot(pending) + } + }) + }, [applyPointerMoveSnapshot]) + + const flushPendingPointerMove = useCallback(() => { + const pending = pendingPointerMoveRef.current + pendingPointerMoveRef.current = null + + if (pointerMoveRafRef.current !== null) { + window.cancelAnimationFrame(pointerMoveRafRef.current) + pointerMoveRafRef.current = null + } + + if (pending) { + applyPointerMoveSnapshot(pending) + } + }, [applyPointerMoveSnapshot]) + + useEffect(() => { + return () => cancelPointerMoveFrame() + }, [cancelPointerMoveFrame]) + const startDrawBox = useCallback((e: React.PointerEvent) => { if (!boxLabel) return if (uiLocked) return - if (boxInteraction) return + if (drawingBoxRef.current || boxInteractionRef.current) return const target = e.target as HTMLElement | null if (target?.closest('[data-box-control="true"]')) return - const rawPos = getPointerPosInImage(e.clientX, e.clientY, { clamp: false }) - if (!rawPos) return + const contentRect = getImageContentRect() + if (!contentRect) return + + const rawPos = getPointerPosFromRect(contentRect, e.clientX, e.clientY, { clamp: false }) // Nicht im schwarzen Randbereich starten. if ( @@ -4156,6 +4770,8 @@ export default function TrainingTab(props: { return } + activeImageContentRectRef.current = contentRect + const pos = { x: clamp01(rawPos.x), y: clamp01(rawPos.y), @@ -4174,15 +4790,15 @@ export default function TrainingTab(props: { activePointerIdRef.current = null } - setTouchMagnifier({ + const nextMagnifier: MagnifierState = { visible: true, clientX: e.clientX, clientY: e.clientY, imageX: pos.x, imageY: pos.y, - }) + } - setDrawingBox({ + const nextDrawingBox: DrawingTrainingBox = { label: boxLabel, startX: pos.x, startY: pos.y, @@ -4190,117 +4806,35 @@ export default function TrainingTab(props: { y: pos.y, w: 0, h: 0, - }) - }, [boxLabel, boxInteraction, getPointerPosInImage, uiLocked]) + } + + latestGestureBoxRef.current = nextDrawingBox + drawingBoxRef.current = nextDrawingBox + + setTouchMagnifier(nextMagnifier) + setDrawingBox(nextDrawingBox) + }, [boxLabel, getImageContentRect, getPointerPosFromRect, uiLocked]) const moveDrawBox = useCallback((e: React.PointerEvent) => { - if (drawingBox || boxInteraction) { + const hasActiveGesture = Boolean(drawingBoxRef.current || boxInteractionRef.current) + + if (hasActiveGesture) { e.preventDefault() e.stopPropagation() - } - const clampedPos = getPointerPosInImage(e.clientX, e.clientY) - if (!clampedPos) return - - const pos = - boxInteraction?.type === 'move' - ? getPointerPosInImage(e.clientX, e.clientY, { clamp: false }) ?? clampedPos - : clampedPos - - if (drawingBox || boxInteraction) { - setTouchMagnifier({ - visible: true, + schedulePointerMove({ clientX: e.clientX, clientY: e.clientY, - imageX: clampedPos.x, - imageY: clampedPos.y, }) } - - if (boxInteraction) { - const dx = pos.x - boxInteraction.startX - const dy = pos.y - boxInteraction.startY - const original = boxInteraction.original - - let nextBox: TrainingBox = original - - if (boxInteraction.type === 'move') { - nextBox = normalizeMovedBox({ - ...original, - x: original.x + dx, - y: original.y + dy, - }) - } - - if (boxInteraction.type === 'resize') { - let x1 = original.x - let y1 = original.y - let x2 = original.x + original.w - let y2 = original.y + original.h - - const pointerX = snap01(clampedPos.x) - const pointerY = snap01(clampedPos.y) - - // Wichtig: - // Beim Resize folgt die gezogene Ecke direkt dem Pointer. - // Dadurch bleibt kein Grab-Offset übrig, wenn der Handle nicht exakt - // auf der mathematischen Box-Ecke getroffen wurde. - if (boxInteraction.handle.includes('n')) y1 = pointerY - if (boxInteraction.handle.includes('s')) y2 = pointerY - if (boxInteraction.handle.includes('w')) x1 = pointerX - if (boxInteraction.handle.includes('e')) x2 = pointerX - - const left = Math.min(x1, x2) - const top = Math.min(y1, y2) - const right = Math.max(x1, x2) - const bottom = Math.max(y1, y2) - - nextBox = normalizeBox({ - ...original, - x: left, - y: top, - w: right - left, - h: bottom - top, - }) - } - - const geometryChanged = boxGeometryChanged(original, nextBox) - - const correctedNextBox = geometryChanged - ? markBoxCorrected(nextBox) - : nextBox - - if (geometryChanged) { - setHasManualCorrection(true) - } - - setCorrection((prev) => ({ - ...prev, - boxes: (prev.boxes ?? []).map((box, index) => - index === boxInteraction.index ? correctedNextBox : box - ), - })) - - return - } - - if (!drawingBox) return - - const x1 = drawingBox.startX - const y1 = drawingBox.startY - const x2 = pos.x - const y2 = pos.y - - setDrawingBox({ - ...drawingBox, - x: Math.min(x1, x2), - y: Math.min(y1, y2), - w: Math.abs(x2 - x1), - h: Math.abs(y2 - y1), - }) - }, [boxInteraction, drawingBox, getPointerPosInImage]) + }, [schedulePointerMove]) const finishDrawBox = useCallback((e?: React.PointerEvent | PointerEvent) => { + flushPendingPointerMove() + + const activeDrawingBox = drawingBoxRef.current + const activeInteraction = boxInteractionRef.current + releaseActivePointerCapture( typeof e?.pointerId === 'number' ? e.pointerId : null ) @@ -4310,34 +4844,62 @@ export default function TrainingTab(props: { if (finishingGestureRef.current) { setDrawingBox(null) setBoxInteraction(null) + clearBoxGestureRefs() return } finishingGestureRef.current = true - if (drawingBox || boxInteraction) { + if (activeDrawingBox || activeInteraction) { e?.preventDefault() e?.stopPropagation() } - if (boxInteraction) { + if (activeInteraction) { + const finalBox = latestGestureBoxRef.current + + if (finalBox) { + setCorrection((prev) => { + const boxes = prev.boxes ?? [] + const currentBox = boxes[activeInteraction.index] + + if (!currentBox || !boxVisualStateChanged(currentBox, finalBox)) { + return prev + } + + const next: CorrectionState = { + ...prev, + boxes: boxes.map((box, index) => + index === activeInteraction.index ? finalBox : box + ), + } + + correctionRef.current = next + return next + }) + } + setBoxInteraction(null) + clearBoxGestureRefs() return } - if (!drawingBox) { + if (!activeDrawingBox) { setDrawingBox(null) + clearBoxGestureRefs() return } - const { startX, startY, ...drawingTrainingBox } = drawingBox - const box = normalizeBox(drawingTrainingBox) + const box = normalizeBox(latestGestureBoxRef.current ?? activeDrawingBox) setDrawingBox(null) - if (box.w < 0.01 || box.h < 0.01) return + if (box.w < 0.01 || box.h < 0.01) { + clearBoxGestureRefs() + return + } - setHasManualCorrection(true) + markManualCorrection() setCorrection((prev) => { const previousBoxes = prev.boxes ?? [] @@ -4350,9 +4912,18 @@ export default function TrainingTab(props: { setActiveBoxIndex(newBoxIndex) - return applyBoxLabelToCorrection(next, box.label, labelsRef.current) + const applied = applyBoxLabelToCorrection(next, box.label, labelsRef.current) + correctionRef.current = applied + return applied }) - }, [boxInteraction, drawingBox, releaseActivePointerCapture]) + + clearBoxGestureRefs() + }, [ + clearBoxGestureRefs, + flushPendingPointerMove, + markManualCorrection, + releaseActivePointerCapture, + ]) useEffect(() => { if (!drawingBox && !boxInteraction) return @@ -4462,12 +5033,13 @@ export default function TrainingTab(props: { useEffect(() => { if (loading || frameBusy) return - if (!loadingPreviewUrl) return + if (!loadingPreviewUrl && !loadingPreviewFallbackUrl) return setLoadingPreviewUrl('') + setLoadingPreviewFallbackUrl('') setLoadingPreviewLoaded(false) setLoadingPreviewFailed(false) - }, [loading, frameBusy, loadingPreviewUrl]) + }, [loading, frameBusy, loadingPreviewFallbackUrl, loadingPreviewUrl]) const showImageBoxes = !frameBusy && !trainingRunning @@ -4618,15 +5190,11 @@ export default function TrainingTab(props: { const trainingActionsPanel = (opts?: { compact?: boolean }) => { const compact = Boolean(opts?.compact) - const progress = clampPercent( - trainingRunning - ? shownTrainingProgress - : Math.min(100, (feedbackCount / Math.max(1, requiredCount)) * 100) - ) - const feedbackReady = feedbackCount >= requiredCount const detector = trainingStatus?.detector const detectorReady = Boolean(detector?.dataReady) + const pose = trainingStatus?.pose + const poseReady = Boolean(pose?.dataReady) const missingTrain = Math.max( 0, Number(detector?.requiredTrain ?? 20) - Number(detector?.trainCount ?? 0) @@ -4638,6 +5206,76 @@ export default function TrainingTab(props: { const missingPositiveTrain = Number(detector?.positiveTrainCount ?? 0) < 1 const missingPositiveVal = Number(detector?.positiveValCount ?? 0) < 1 const positiveSamplesMissing = missingPositiveTrain || missingPositiveVal + const missingPoseTrain = Math.max( + 0, + Number(pose?.requiredTrain ?? 20) - Number(pose?.trainCount ?? 0) + ) + const missingPoseVal = Math.max( + 0, + Number(pose?.requiredVal ?? 3) - Number(pose?.valCount ?? 0) + ) + + const boundedCount = (value: number, required: number) => { + const cleanValue = Number.isFinite(value) ? Math.max(0, value) : 0 + const cleanRequired = Number.isFinite(required) ? Math.max(0, required) : 0 + return Math.min(cleanValue, cleanRequired) + } + + const detectorTrainCount = Number(detector?.trainCount ?? 0) + const detectorValCount = Number(detector?.valCount ?? 0) + const detectorRequiredTrain = Number(detector?.requiredTrain ?? 20) + const detectorRequiredVal = Number(detector?.requiredVal ?? 3) + const detectorPositiveDone = + (Number(detector?.positiveTrainCount ?? 0) >= 1 ? 1 : 0) + + (Number(detector?.positiveValCount ?? 0) >= 1 ? 1 : 0) + const detectorPositiveRequired = 2 + const detectorDone = + boundedCount(detectorTrainCount, detectorRequiredTrain) + + boundedCount(detectorValCount, detectorRequiredVal) + + detectorPositiveDone + const detectorRequired = + Math.max(0, detectorRequiredTrain) + + Math.max(0, detectorRequiredVal) + + detectorPositiveRequired + + const poseTrainCount = Number(pose?.trainCount ?? 0) + const poseValCount = Number(pose?.valCount ?? 0) + const poseRequiredTrain = Number(pose?.requiredTrain ?? 20) + const poseRequiredVal = Number(pose?.requiredVal ?? 3) + const poseDone = + boundedCount(poseTrainCount, poseRequiredTrain) + + boundedCount(poseValCount, poseRequiredVal) + const poseRequired = + Math.max(0, poseRequiredTrain) + + Math.max(0, poseRequiredVal) + + const feedbackDone = boundedCount(feedbackCount, requiredCount) + const feedbackRequired = Math.max(0, requiredCount) + const readinessDone = feedbackDone + detectorDone + poseDone + const readinessRequired = feedbackRequired + detectorRequired + poseRequired + const readinessProgress = readinessRequired > 0 ? readinessDone / readinessRequired : 0 + const readinessItems = [ + { + key: 'detector', + label: 'Object Detection', + value: detectorRequired > 0 ? detectorDone / detectorRequired : 1, + text: `${detectorDone}/${detectorRequired}`, + detail: `Train ${detectorTrainCount}/${detectorRequiredTrain}, Val ${detectorValCount}/${detectorRequiredVal}, Positiv ${detectorPositiveDone}/${detectorPositiveRequired}`, + }, + { + key: 'pose', + label: 'Pose Detection', + value: poseRequired > 0 ? poseDone / poseRequired : 1, + text: `${poseDone}/${poseRequired}`, + detail: `Train ${poseTrainCount}/${poseRequiredTrain}, Val ${poseValCount}/${poseRequiredVal}`, + }, + ] + + const progress = clampPercent( + trainingRunning + ? shownTrainingProgress + : readinessProgress * 100 + ) const statusText = trainingRunning ? shownTrainingStep || 'Training läuft…' : !feedbackReady @@ -4648,6 +5286,10 @@ export default function TrainingTab(props: { : positiveSamplesMissing ? 'Je ein positives Beispiel in Train und Val erforderlich' : 'YOLO-Datensatz noch nicht bereit' + : !poseReady + ? missingPoseTrain > 0 || missingPoseVal > 0 + ? `Pose-Beispiele fehlen: ${missingPoseTrain} Train, ${missingPoseVal} Val` + : 'Pose-Datensatz noch nicht bereit' : canStartTraining ? 'Bereit zum Trainieren' : 'Noch nicht trainingsbereit' @@ -4666,7 +5308,7 @@ export default function TrainingTab(props: { 'flex h-8 w-8 shrink-0 items-center justify-center rounded-lg ring-1', trainingRunning ? 'bg-indigo-50 text-indigo-700 ring-indigo-200 dark:bg-indigo-500/15 dark:text-indigo-200 dark:ring-indigo-400/30' - : feedbackReady + : canStartTraining ? 'bg-emerald-50 text-emerald-700 ring-emerald-200 dark:bg-emerald-500/15 dark:text-emerald-200 dark:ring-emerald-400/30' : 'bg-gray-50 text-gray-600 ring-gray-200 dark:bg-white/5 dark:text-gray-300 dark:ring-white/10', ].join(' ')} @@ -4759,10 +5401,10 @@ export default function TrainingTab(props: {
- {trainingRunning || feedbackCount < requiredCount ? ( + {trainingRunning || !canStartTraining ? (
- {trainingRunning ? 'Trainingsfortschritt' : 'Feedback-Fortschritt'} + {trainingRunning ? 'Trainingsfortschritt' : 'Trainingsbereitschaft'} {Math.round(progress)}%
@@ -4772,13 +5414,55 @@ export default function TrainingTab(props: { 'h-full rounded-full transition-all duration-500', trainingRunning ? 'bg-indigo-500' - : feedbackReady + : canStartTraining ? 'bg-emerald-500' - : 'bg-gray-400', + : readinessDone > 0 + ? 'bg-amber-500' + : 'bg-gray-400', ].join(' ')} style={{ width: `${progress}%` }} />
+ + {!trainingRunning ? ( +
+ {readinessItems.map((item) => { + const itemProgress = clampPercent(item.value * 100) + + return ( +
+
+
+ {item.label} +
+ +
+ {item.text} +
+
+ +
+ {item.detail} +
+ +
+
+
+
+ ) + })} +
+ ) : null}
) : null} @@ -4922,7 +5606,9 @@ export default function TrainingTab(props: { ? `Noch zu wenig Feedback: ${feedbackCount}/${requiredCount}.` : !detectorReady ? `YOLO-Datensatz noch nicht bereit: Train ${detector?.trainCount ?? 0}/${detector?.requiredTrain ?? 20} (${detector?.positiveTrainCount ?? 0} positiv), Val ${detector?.valCount ?? 0}/${detector?.requiredVal ?? 3} (${detector?.positiveValCount ?? 0} positiv).` - : 'Training ist aktuell nicht verfügbar.' + : !poseReady + ? `Pose-Datensatz noch nicht bereit: Train ${pose?.trainCount ?? 0}/${pose?.requiredTrain ?? 20}, Val ${pose?.valCount ?? 0}/${pose?.requiredVal ?? 3}.` + : 'Training ist aktuell nicht verfügbar.' } > @@ -5281,7 +5967,7 @@ export default function TrainingTab(props: { const loadingPreviewBackgroundUrl = loadingPreviewUrl && loadingPreviewLoaded && !loadingPreviewFailed ? loadingPreviewUrl - : '' + : loadingPreviewFallbackUrl const frameLayoutSize = useMemo(() => { const width = Number(frameNaturalSize?.width) @@ -5620,9 +6306,9 @@ export default function TrainingTab(props: { const isSmallBox = width < 18 || height < 12 const isActiveBox = !isDraft && activeBoxIndex === index const alignLabelRight = box.x + box.w > 0.62 - const imageRect = frameImageRef.current?.getBoundingClientRect() - const labelMaxWidth = imageRect?.width - ? Math.max(72, Math.min(210, imageRect.width - 8)) + const layerWidth = Number(imageLayerStyle.width) + const labelMaxWidth = Number.isFinite(layerWidth) && layerWidth > 0 + ? Math.max(72, Math.min(210, layerWidth - 8)) : 210 return ( @@ -5699,10 +6385,13 @@ export default function TrainingTab(props: { if (requiresActivationFirst) return - const pos = getPointerPosInImage(e.clientX, e.clientY, { clamp: false }) - const clampedPos = getPointerPosInImage(e.clientX, e.clientY) + const contentRect = getImageContentRect() + if (!contentRect) return - if (!pos || !clampedPos) return + activeImageContentRectRef.current = contentRect + + const pos = getPointerPosFromRect(contentRect, e.clientX, e.clientY, { clamp: false }) + const clampedPos = getPointerPosFromRect(contentRect, e.clientX, e.clientY) finishingGestureRef.current = false activePointerIdRef.current = e.pointerId @@ -5713,21 +6402,27 @@ export default function TrainingTab(props: { activePointerIdRef.current = null } - setTouchMagnifier({ + const nextMagnifier: MagnifierState = { visible: true, clientX: e.clientX, clientY: e.clientY, imageX: clampedPos.x, imageY: clampedPos.y, - }) + } - setBoxInteraction({ + const nextInteraction: BoxInteraction = { type: 'move', index, startX: pos.x, startY: pos.y, original: box, - }) + } + + latestGestureBoxRef.current = box + boxInteractionRef.current = nextInteraction + + setTouchMagnifier(nextMagnifier) + setBoxInteraction(nextInteraction) }} > { - const rect = getImageContentRect() + const rect = activeImageContentRectRef.current ?? getImageContentRect() if (!rect || rect.width <= 0 || rect.height <= 0) return null @@ -6090,7 +6795,6 @@ export default function TrainingTab(props: { text={stageOverlayText} progress={stageOverlayProgress} visible={stageOverlayIsVisible} - instantBackground={stageOverlayMode === 'training'} backgroundUrl={ stageOverlayMode === 'training' ? trainingPreviewUrl || trainingStatus?.training?.previewUrl || imageSrc diff --git a/frontend/src/types.ts b/frontend/src/types.ts index a81a31d..19ee7a9 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -190,6 +190,8 @@ export type RecordJob = { phase?: string progress?: number + stopRequestedAtMs?: number + stopAttempts?: number postWorkKey?: string postWork?: PostWorkKeyStatus @@ -741,4 +743,4 @@ export type VideoHeatmapSegment = { intensity: number label?: string source?: VideoHeatmapSource -} \ No newline at end of file +}