diff --git a/backend/__pycache__/ai_server.cpython-314.pyc b/backend/__pycache__/ai_server.cpython-314.pyc index aa8d7bc..49e3746 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 d5fd22b..eefee16 100644 --- a/backend/ai_server.py +++ b/backend/ai_server.py @@ -1,6 +1,8 @@ -# backend\ai_server.py +# backend/ai_server.py +import json import os +from pathlib import Path from typing import List, Optional from fastapi import FastAPI @@ -8,59 +10,144 @@ from pydantic import BaseModel from ultralytics import YOLO -BODY_LABELS = { - "anus", - "ass", - "breasts", - "penis", - "tongue", - "pussy", +BASE_DIR = Path(__file__).resolve().parent + + +def existing_file(path: Path) -> Optional[Path]: + try: + if path.exists() and path.is_file() and path.stat().st_size > 0: + return path + except OSError: + pass + return None + + +def resolve_training_root() -> Path: + env_root = os.environ.get("TRAINING_ROOT", "").strip() + if env_root: + root = Path(env_root).expanduser().resolve() + root.mkdir(parents=True, exist_ok=True) + return root + + candidates = [ + # Wenn ai_server.py aus backend/ läuft: + BASE_DIR / "generated" / "training", + + # Wenn ai_server.py aus backend/ml/ laufen würde: + BASE_DIR.parent / "generated" / "training", + + # Wenn ai_server.py embedded aus Temp läuft, aber backendRoot als cwd gesetzt wurde: + Path.cwd() / "generated" / "training", + + # Wenn Working Directory Projektroot ist: + Path.cwd() / "backend" / "generated" / "training", + ] + + for root in candidates: + if ( + existing_file(root / "detection_labels.json") + or existing_file(root / "detector" / "model" / "best.pt") + ): + root.mkdir(parents=True, exist_ok=True) + return root.resolve() + + # Fallback: Server soll trotzdem starten. + root = (Path.cwd() / "generated" / "training").resolve() + root.mkdir(parents=True, exist_ok=True) + return root + + +TRAINING_ROOT = resolve_training_root() +DEFAULT_MODEL_PATH = TRAINING_ROOT / "detector" / "model" / "best.pt" + + +def resolve_detection_labels_path() -> Path: + env_path = os.environ.get("DETECTION_LABELS_PATH", "").strip() + if env_path: + p = Path(env_path).expanduser().resolve() + if existing_file(p): + return p + raise RuntimeError(f"DETECTION_LABELS_PATH not found: {p}") + + candidates = [ + TRAINING_ROOT / "detection_labels.json", + + # Wenn ai_server.py direkt neben detection_labels.json embedded liegt: + BASE_DIR / "detection_labels.json", + + # Dev-Fallbacks: + BASE_DIR / "ml" / "detection_labels.json", + BASE_DIR.parent / "ml" / "detection_labels.json", + Path.cwd() / "ml" / "detection_labels.json", + Path.cwd() / "backend" / "ml" / "detection_labels.json", + ] + + for p in candidates: + if existing_file(p): + return p.resolve() + + raise RuntimeError( + "detection_labels.json not found. Checked: " + + ", ".join(str(p) for p in candidates) + ) + + +def resolve_model_path() -> str: + env_path = os.environ.get("YOLO_MODEL", "").strip() + if env_path: + p = Path(env_path).expanduser().resolve() + if existing_file(p): + return str(p) + raise RuntimeError(f"YOLO_MODEL not found: {p}") + + if existing_file(DEFAULT_MODEL_PATH): + return str(DEFAULT_MODEL_PATH) + + raise RuntimeError(f"YOLO model not found: {DEFAULT_MODEL_PATH}") + + +# Server darf auch ohne Labels/Model starten. +DETECTION_LABELS_PATH: Optional[Path] = None + +LABEL_GROUPS = { + "people": set(), + "sexPositions": {"unknown"}, + "bodyParts": set(), + "objects": set(), + "clothing": set(), } -OBJECT_LABELS = { - "blindfold", - "buttplug", - "collar", - "dildo", - "handcuffs", - "shower", - "strapon", - "towel", - "vibrator", +BODY_LABELS = LABEL_GROUPS["bodyParts"] +OBJECT_LABELS = LABEL_GROUPS["objects"] +CLOTHING_LABELS = LABEL_GROUPS["clothing"] +POSITION_LABELS = set() + +PERSON_LABELS = { + "person", + "person_male", + "person_female", + "person_unknown", + "male_person", + "female_person", } -CLOTHING_LABELS = { - "bikini", - "bra", - "dress", - "heels", - "hotpants", - "lingerie", - "panties", - "skirt", - "stockings", - "croptop", -} +MALE_LABELS = {"person_male", "male_person"} +FEMALE_LABELS = {"person_female", "female_person"} +UNKNOWN_PERSON_LABELS = {"person", "person_unknown"} -POSITION_LABELS = { - "missionary", - "doggy", - "cowgirl", - "reverse_cowgirl", - "cunnilingus", - "prone_bone", - "standing", - "standing_doggy", - "spooning", - "sitting", - "facesitting", - "handjob", - "blowjob", - "toy_play", - "fingering", - "69", - "other", -} +_MODEL_PATH = "" +_MODEL_ERROR = "" +_LABEL_ERROR = "" + +_DEVICE = os.environ.get("YOLO_DEVICE", "") +_CONF = float(os.environ.get("YOLO_CONF", "0.25")) +_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 + +app = FastAPI() class PredictBatchRequest(BaseModel): @@ -70,36 +157,133 @@ class PredictBatchRequest(BaseModel): model: Optional[str] = None -app = FastAPI() - -from pathlib import Path - -BASE_DIR = Path(__file__).resolve().parent -DEFAULT_MODEL_PATH = BASE_DIR / "generated" / "training" / "detector" / "model" / "best.pt" - -def resolve_model_path() -> str: - env_path = os.environ.get("YOLO_MODEL", "").strip() - if env_path: - p = Path(env_path) - if p.exists(): - return str(p) - raise RuntimeError(f"YOLO_MODEL not found: {p}") - - default_path = DEFAULT_MODEL_PATH - if default_path.exists(): - return str(default_path) - - raise RuntimeError(f"YOLO model not found: {default_path}") +def empty_prediction(source: str = "model_missing") -> dict: + return { + "modelAvailable": False, + "source": source, + "peopleCount": 0, + "maleCount": 0, + "femaleCount": 0, + "unknownCount": 0, + "sexPosition": "unknown", + "sexPositionScore": 0.0, + "bodyPartsPresent": [], + "objectsPresent": [], + "clothingPresent": [], + "boxes": [], + } -_MODEL_PATH = resolve_model_path() -_DEVICE = os.environ.get("YOLO_DEVICE", "") -_CONF = float(os.environ.get("YOLO_CONF", "0.25")) -_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"} +def load_label_groups_safe() -> None: + global DETECTION_LABELS_PATH + global LABEL_GROUPS + global BODY_LABELS + global OBJECT_LABELS + global CLOTHING_LABELS + global POSITION_LABELS + global PERSON_LABELS + global _LABEL_ERROR -model = YOLO(_MODEL_PATH) + try: + path = resolve_detection_labels_path() + DETECTION_LABELS_PATH = path + + with path.open("r", encoding="utf-8") as f: + data = json.load(f) + + LABEL_GROUPS = { + "people": set( + str(x).strip().lower() + 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() + ), + "bodyParts": set( + str(x).strip().lower() + for x in data.get("bodyParts", []) + if str(x).strip() + ), + "objects": set( + str(x).strip().lower() + for x in data.get("objects", []) + if str(x).strip() + ), + "clothing": set( + str(x).strip().lower() + for x in data.get("clothing", []) + if str(x).strip() + ), + } + + if not LABEL_GROUPS["sexPositions"]: + LABEL_GROUPS["sexPositions"] = {"unknown"} + + _LABEL_ERROR = "" + + except Exception as exc: + DETECTION_LABELS_PATH = None + _LABEL_ERROR = str(exc) + + LABEL_GROUPS = { + "people": set(), + "sexPositions": {"unknown"}, + "bodyParts": set(), + "objects": set(), + "clothing": set(), + } + + BODY_LABELS = LABEL_GROUPS["bodyParts"] + OBJECT_LABELS = LABEL_GROUPS["objects"] + CLOTHING_LABELS = LABEL_GROUPS["clothing"] + + POSITION_LABELS = { + label for label in LABEL_GROUPS["sexPositions"] + if label and label != "unknown" + } + + PERSON_LABELS = { + label for label in LABEL_GROUPS["people"] + if label + } | { + "person", + "person_male", + "person_female", + "person_unknown", + "male_person", + "female_person", + } + + +def get_model(): + global model + global _MODEL_PATH + global _MODEL_ERROR + + if model is not None: + return model + + try: + path = resolve_model_path() + loaded = YOLO(path) + + model = loaded + _MODEL_PATH = path + _MODEL_ERROR = "" + + # Labels erst laden, wenn Inference wirklich gebraucht wird. + load_label_groups_safe() + + return model + + except Exception as exc: + model = None + _MODEL_PATH = "" + _MODEL_ERROR = str(exc) + return None def scored(label: str, score: float) -> dict: @@ -143,6 +327,7 @@ def prediction_from_result(result) -> dict: continue 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)) @@ -170,13 +355,10 @@ def prediction_from_result(result) -> dict: sex_position = label sex_position_score = score - people_count = sum( - 1 for box in boxes_out - if box["label"] in {"person", "person_male", "person_female", "person_unknown"} - ) - male_count = sum(1 for box in boxes_out if box["label"] in {"person_male", "male_person"}) - female_count = sum(1 for box in boxes_out if box["label"] in {"person_female", "female_person"}) - unknown_count = max(0, people_count - male_count - female_count) + people_count = sum(1 for box in boxes_out if box["label"] in PERSON_LABELS) + male_count = sum(1 for box in boxes_out if box["label"] in MALE_LABELS) + female_count = sum(1 for box in boxes_out if box["label"] in FEMALE_LABELS) + unknown_count = sum(1 for box in boxes_out if box["label"] in UNKNOWN_PERSON_LABELS) return { "modelAvailable": True, @@ -204,10 +386,18 @@ def predict_batch(req: PredictBatchRequest): "error": "no paths supplied", } + current_model = get_model() + if current_model is None: + return { + "ok": True, + "predictions": [empty_prediction("model_missing") for _ in paths], + "error": _MODEL_ERROR or f"YOLO model not found: {DEFAULT_MODEL_PATH}", + } + imgsz = int(req.imageSize or _IMGSZ or 640) try: - results = model.predict( + results = current_model.predict( source=paths, imgsz=imgsz, conf=_CONF, @@ -234,11 +424,19 @@ def predict_batch(req: PredictBatchRequest): @app.get("/health") def health(): - names = getattr(model, "names", {}) or {} + current_model = get_model() + names = getattr(current_model, "names", {}) or {} if current_model is not None else {} return { "ok": True, + "ready": current_model is not None, + "modelAvailable": current_model is not None, "model": _MODEL_PATH, + "modelError": _MODEL_ERROR, + "expectedModel": str(DEFAULT_MODEL_PATH), + "trainingRoot": str(TRAINING_ROOT), "classCount": len(names), "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 diff --git a/backend/analyze.go b/backend/analyze.go index abc3502..76ce244 100644 --- a/backend/analyze.go +++ b/backend/analyze.go @@ -8,7 +8,6 @@ import ( "encoding/json" "fmt" "image" - "image/draw" "image/jpeg" "math" "net/http" @@ -17,6 +16,7 @@ import ( "path/filepath" "sort" "strings" + "sync" "syscall" "time" ) @@ -46,11 +46,6 @@ type analyzeVideoResp struct { Error string `json:"error,omitempty"` } -type spriteFrameCandidate struct { - Index int - Time float64 -} - type videoFrameSample struct { Index int Time float64 @@ -62,9 +57,6 @@ const ( nsfwThresholdModerate = 0.35 nsfwThresholdStrong = 0.60 - // Sprite-Modus ist aktuell deaktiviert. Analyse läuft über Video-Frames. - analyzeMaxSpriteCandidates = 24 - // Video-Modus: extrahiert 1 Frame alle N Sekunden. // 1 = jeder Sekunde, 3 = alle 3 Sekunden, 5 = alle 5 Sekunden. analyzeVideoFrameIntervalSeconds = 3 @@ -81,56 +73,48 @@ const ( analyzeAIServerDefaultURL = "http://127.0.0.1:8765" ) -var autoSelectedAILabels = map[string]struct{}{ - // bodyParts aus detecton_labels.json - "anus": {}, - "ass": {}, - "breasts": {}, - "penis": {}, - "tongue": {}, - "pussy": {}, +func autoSelectedAILabelSet() map[string]struct{} { + grouped, err := trainingGroupedLabels() + if err != nil { + appLogln("⚠️ analyze labels fallback:", err) + return map[string]struct{}{} + } - // objects aus detecton_labels.json - "blindfold": {}, - "buttplug": {}, - "collar": {}, - "dildo": {}, - "handcuffs": {}, - "shower": {}, - "strapon": {}, - "towel": {}, - "vibrator": {}, + out := map[string]struct{}{} - // clothing aus detecton_labels.json - "bikini": {}, - "bra": {}, - "dress": {}, - "heels": {}, - "hotpants": {}, - "lingerie": {}, - "panties": {}, - "skirt": {}, - "stockings": {}, - "croptop": {}, + add := func(values []string) { + for _, value := range values { + label := strings.ToLower(strings.TrimSpace(value)) + if label == "" || label == "unknown" { + continue + } + out[label] = struct{}{} + } + } - // sexPositions aus detecton_labels.json - "missionary": {}, - "doggy": {}, - "cowgirl": {}, - "reverse_cowgirl": {}, - "cunnilingus": {}, - "prone_bone": {}, - "standing": {}, - "standing_doggy": {}, - "spooning": {}, - "sitting": {}, - "facesitting": {}, - "handjob": {}, - "blowjob": {}, - "toy_play": {}, - "fingering": {}, - "69": {}, - "other": {}, + add(grouped.BodyParts) + add(grouped.Objects) + add(grouped.Clothing) + add(grouped.SexPositions) + + return out +} + +var autoSelectedAILabelsOnce sync.Once +var autoSelectedAILabelsCache map[string]struct{} + +func shouldAutoSelectAnalyzeHit(label string) bool { + label = strings.ToLower(strings.TrimSpace(label)) + if label == "" || label == "unknown" { + return false + } + + autoSelectedAILabelsOnce.Do(func() { + autoSelectedAILabelsCache = autoSelectedAILabelSet() + }) + + _, ok := autoSelectedAILabelsCache[label] + return ok } var nsfwIgnoredLabels = map[string]struct{}{ @@ -139,90 +123,20 @@ var nsfwIgnoredLabels = map[string]struct{}{ "person_male": {}, "person_female": {}, "person_unknown": {}, + "male_person": {}, + "female_person": {}, // Falls dein Detector irgendwann diese Varianten liefert: "people_male": {}, "people_female": {}, } -func shouldAutoSelectAnalyzeHit(label string) bool { - label = strings.ToLower(strings.TrimSpace(label)) - _, ok := autoSelectedAILabels[label] - return ok -} - func isIgnoredNSFWLabel(label string) bool { label = strings.ToLower(strings.TrimSpace(label)) _, ok := nsfwIgnoredLabels[label] return ok } -func extractSpriteFrames(spritePath string, ps previewSpriteMetaFileInfo) ([]image.Image, error) { - f, err := os.Open(spritePath) - if err != nil { - return nil, err - } - defer f.Close() - - img, _, err := image.Decode(f) - if err != nil { - return nil, err - } - - b := img.Bounds() - if ps.Cols <= 0 || ps.Rows <= 0 { - return nil, appErrorf("sprite cols/rows fehlen") - } - - cellW := b.Dx() / ps.Cols - cellH := b.Dy() / ps.Rows - if cellW <= 0 || cellH <= 0 { - return nil, appErrorf("ungültige sprite cell size") - } - - count := ps.Count - if count <= 0 { - count = ps.Cols * ps.Rows - } - - out := make([]image.Image, 0, count) - - for i := 0; i < count; i++ { - col := i % ps.Cols - row := i / ps.Cols - if row >= ps.Rows { - break - } - - srcRect := image.Rect( - b.Min.X+col*cellW, - b.Min.Y+row*cellH, - b.Min.X+(col+1)*cellW, - b.Min.Y+(row+1)*cellH, - ) - - dst := image.NewRGBA(image.Rect(0, 0, cellW, cellH)) - draw.Draw(dst, dst.Bounds(), img, srcRect.Min, draw.Src) - out = append(out, dst) - } - - return out, nil -} - -func classifyFrameNSFW(ctx context.Context, img image.Image) (*NsfwImageResponse, error) { - _ = ctx - - results, err := detectNSFWFromImage(img) - if err != nil { - return nil, err - } - - return &NsfwImageResponse{ - Ok: true, - Results: results, - }, nil -} - func addTrainingAnalyzeResult(best map[string]float64, label string, score float64) { label = strings.ToLower(strings.TrimSpace(label)) if label == "" { @@ -1146,109 +1060,6 @@ func recordAnalyzeVideo(w http.ResponseWriter, r *http.Request) { }) } -func analyzeVideoFromSpriteAllGoals(ctx context.Context, outPath string) (nsfwHits []analyzeHit, highlightHits []analyzeHit, err error) { - id := strings.TrimSpace(videoIDFromOutputPath(outPath)) - if id == "" { - return nil, nil, appErrorf("konnte keine video-id aus output ableiten") - } - - metaPath, err := generatedMetaFile(id) - if err != nil || strings.TrimSpace(metaPath) == "" { - return nil, nil, appErrorf("meta.json nicht gefunden") - } - - ps, ok := readPreviewSpriteMetaFromMetaFile(metaPath) - if !ok { - return nil, nil, appErrorf("previewSprite meta fehlt") - } - if ps.Count <= 0 { - return nil, nil, appErrorf("previewSprite count fehlt") - } - - spritePath := filepath.Join(filepath.Dir(metaPath), "preview-sprite.jpg") - if fi, err := os.Stat(spritePath); err != nil || fi == nil || fi.IsDir() || fi.Size() <= 0 { - return nil, nil, appErrorf("preview-sprite.jpg nicht gefunden") - } - - durationSec := ps.StepSeconds * math.Max(1, float64(ps.Count-1)) - if durationSec <= 0 { - durationSec, _ = durationSecondsForAnalyze(ctx, outPath) - } - - candidates := buildSpriteFrameCandidates(ps.Count, ps.StepSeconds, durationSec) - candidates = limitSpriteFrameCandidates(candidates, analyzeMaxSpriteCandidates) - - if len(candidates) == 0 { - return nil, nil, appErrorf("keine sprite-kandidaten vorhanden") - } - - // 1) Schneller Pfad: Python-Batch. - results, batchErr := trainingPredictSpriteBatch(ctx, spritePath, ps, candidates) - if batchErr == nil { - for _, item := range results { - pred := item.Prediction - if !pred.ModelAvailable { - continue - } - - t := item.Time - - nsfwResults := trainingPredictionToNSFWResults(pred) - bestLabel, bestScore := pickBestNSFWResult(nsfwResults) - if bestLabel != "" && bestScore >= nsfwThresholdForLabel(bestLabel) { - nsfwHits = append(nsfwHits, analyzeHit{ - Time: t, - Label: bestLabel, - Score: bestScore, - Start: t, - End: t, - }) - } - - highlightHits = appendHighlightHitsFromPrediction(highlightHits, pred, t) - } - - return mergeAnalyzeHits(nsfwHits), mergeAnalyzeHits(highlightHits), nil - } - - // 2) Fallback: alte langsame Methode, damit Analyse nicht komplett fehlschlägt. - appLogln("⚠️ sprite batch analyse fehlgeschlagen, fallback auf langsame Analyse:", batchErr) - - frames, err := extractSpriteFrames(spritePath, ps) - if err != nil { - return nil, nil, appErrorf("sprite frames extrahieren fehlgeschlagen: %w", err) - } - - for _, c := range candidates { - if c.Index < 0 || c.Index >= len(frames) { - continue - } - - pred := predictFrameForAnalyze(ctx, frames[c.Index]) - if !pred.ModelAvailable { - continue - } - - t := c.Time - - nsfwResults := trainingPredictionToNSFWResults(pred) - bestLabel, bestScore := pickBestNSFWResult(nsfwResults) - if bestLabel != "" && bestScore >= nsfwThresholdForLabel(bestLabel) { - nsfwHits = append(nsfwHits, analyzeHit{ - Time: t, - Label: bestLabel, - Score: bestScore, - Start: t, - End: t, - }) - } - - highlightHits = appendHighlightHitsFromPrediction(highlightHits, pred, t) - } - - return mergeAnalyzeHits(nsfwHits), mergeAnalyzeHits(highlightHits), nil -} - func nsfwThresholdForLabel(label string) float64 { label = strings.ToLower(strings.TrimSpace(label)) @@ -2174,65 +1985,6 @@ func analyzeVideoFromFramesForGoal( return cleanNSFWHits, cleanHighlightHits, nil } -func analyzeSpriteCandidatesWithAI( - ctx context.Context, - spritePath string, - ps previewSpriteMetaFileInfo, - candidates []spriteFrameCandidate, - goal string, -) ([]analyzeHit, error) { - frames, err := extractSpriteFrames(spritePath, ps) - if err != nil { - return nil, appErrorf("sprite frames extrahieren fehlgeschlagen: %w", err) - } - - hits := make([]analyzeHit, 0, len(candidates)) - - for _, c := range candidates { - if c.Index < 0 || c.Index >= len(frames) { - continue - } - - img := frames[c.Index] - - switch goal { - case "nsfw": - res, err := classifyFrameForAnalyze(ctx, img) - if err != nil { - continue - } - - bestLabel, bestScore := pickBestNSFWResult(res.Results) - if bestLabel == "" { - continue - } - - threshold := nsfwThresholdForLabel(bestLabel) - if bestScore < threshold { - continue - } - - hits = append(hits, analyzeHit{ - Time: c.Time, - Label: bestLabel, - Score: bestScore, - Start: c.Time, - End: c.Time, - }) - - case "highlights": - pred := predictFrameForAnalyze(ctx, img) - if !pred.ModelAvailable { - continue - } - - hits = appendHighlightHitsFromPrediction(hits, pred, c.Time) - } - } - - return hits, nil -} - func sameAnalyzeComboLabel(a, b string) bool { a = strings.ToLower(strings.TrimSpace(a)) b = strings.ToLower(strings.TrimSpace(b)) @@ -2884,127 +2636,6 @@ func buildAnalyzeSegmentsForGoal( } } -func buildSpriteFrameCandidates(count int, stepSeconds, durationSec float64) []spriteFrameCandidate { - if count <= 0 { - return nil - } - - out := make([]spriteFrameCandidate, 0, count) - - stepLooksUsable := false - if stepSeconds > 0 && durationSec > 0 { - coverage := stepSeconds * math.Max(1, float64(count-1)) - stepLooksUsable = coverage >= durationSec*0.7 && coverage <= durationSec*1.3 - } - - for i := 0; i < count; i++ { - var t float64 - - if stepLooksUsable { - t = float64(i) * stepSeconds - } else if durationSec > 0 && count > 1 { - t = (float64(i) / float64(count-1)) * durationSec - } else if stepSeconds > 0 { - t = float64(i) * stepSeconds - } else { - t = float64(i) - } - - out = append(out, spriteFrameCandidate{ - Index: i, - Time: t, - }) - } - - return out -} - -func limitSpriteFrameCandidates(in []spriteFrameCandidate, max int) []spriteFrameCandidate { - if max <= 0 || len(in) <= max { - return in - } - - out := make([]spriteFrameCandidate, 0, max) - seen := map[int]bool{} - - if max == 1 { - return []spriteFrameCandidate{in[len(in)/2]} - } - - for i := 0; i < max; i++ { - ratio := float64(i) / float64(max-1) - idx := int(math.Round(ratio * float64(len(in)-1))) - - if idx < 0 { - idx = 0 - } - if idx >= len(in) { - idx = len(in) - 1 - } - - if seen[idx] { - continue - } - seen[idx] = true - - out = append(out, in[idx]) - } - - if len(out) == 0 { - return in - } - - return out -} - -func buildVideoSampleTimes(durationSec float64, sampleCount int) []float64 { - if durationSec <= 0 || sampleCount <= 0 { - return nil - } - - // Nicht exakt bei 0.0s und nicht exakt am Videoende sampeln. - // Anfang/Ende sind häufiger schwarz, unscharf oder ffmpeg schlägt am Ende fehl. - startPad := math.Min(1.0, durationSec*0.05) - endPad := math.Min(1.0, durationSec*0.05) - - start := startPad - end := durationSec - endPad - - if end <= start { - start = 0 - end = durationSec - } - - if sampleCount == 1 { - return []float64{(start + end) / 2} - } - - out := make([]float64, 0, sampleCount) - - for i := 0; i < sampleCount; i++ { - ratio := float64(i) / float64(sampleCount-1) - t := start + ratio*(end-start) - - if t < 0 { - t = 0 - } - if t > durationSec { - t = durationSec - } - - out = append(out, t) - } - - return out -} - -func inferredSpanSeconds(stepSeconds float64, fallback float64) float64 { - if stepSeconds > 0 { - return math.Max(2, stepSeconds*1.5) - } - return fallback -} - func durationSecondsForAnalyze(ctx context.Context, outPath string) (float64, error) { ctx2, cancel := context.WithTimeout(ctx, 8*time.Second) defer cancel() diff --git a/backend/embed.go b/backend/embed.go index a8bdd18..37e828f 100644 --- a/backend/embed.go +++ b/backend/embed.go @@ -19,19 +19,31 @@ func trainingEmbeddedMLDir() (string, error) { } files := []string{ - "predict_scene_model.py", - "train_scene_model.py", "predict_detector_model.py", "train_detector_model.py", "detection_labels.json", } - for _, name := range files { - srcPath := filepath.Join("ml", name) + // Falls du die alten Scene-Skripte noch embedded hast, kannst du sie optional mitkopieren. + optionalFiles := []string{ + "predict_scene_model.py", + "train_scene_model.py", + } - b, err := embeddedMLFiles.ReadFile(filepath.ToSlash(srcPath)) + for _, name := range append(files, optionalFiles...) { + srcPath := filepath.ToSlash(filepath.Join("ml", name)) + + b, err := embeddedMLFiles.ReadFile(srcPath) if err != nil { - return "", err + // Pflichtdateien müssen vorhanden sein. + if name == "detection_labels.json" || + name == "predict_detector_model.py" || + name == "train_detector_model.py" { + return "", err + } + + // Optionale alte Dateien ignorieren. + continue } dstPath := filepath.Join(dir, name) diff --git a/backend/ml/predict_detector_model.py b/backend/ml/predict_detector_model.py index 87719e4..1876ba1 100644 --- a/backend/ml/predict_detector_model.py +++ b/backend/ml/predict_detector_model.py @@ -124,7 +124,7 @@ def main(): print(json.dumps({ "available": True, - "source": "yolo_detector", + "source": "yolo26_detector", "modelPath": str(model_path), "image": str(image_path), "conf": float(args.conf), diff --git a/backend/ml/predict_scene_model.py b/backend/ml/predict_scene_model.py deleted file mode 100644 index 54f0750..0000000 --- a/backend/ml/predict_scene_model.py +++ /dev/null @@ -1,177 +0,0 @@ -# backend\ml\predict_scene_model.py - -import argparse -import json -from pathlib import Path - -import joblib -import numpy as np -import torch -from PIL import Image -from transformers import CLIPModel, CLIPProcessor - - -CLIP_MODEL_NAME = "openai/clip-vit-base-patch32" - - -def empty_prediction(source="no_model"): - return { - "modelAvailable": False, - "source": source, - "peopleCount": 0, - "maleCount": 0, - "femaleCount": 0, - "unknownCount": 0, - "sexPosition": "unknown", - "sexPositionScore": 0, - "bodyPartsPresent": [], - "objectsPresent": [], - "clothingPresent": [], - "boxes": [], - } - - -def load_clip(): - device = "cuda" if torch.cuda.is_available() else "cpu" - - processor = CLIPProcessor.from_pretrained(CLIP_MODEL_NAME) - model = CLIPModel.from_pretrained(CLIP_MODEL_NAME) - model.eval() - model.to(device) - - return model, processor, device - - -def image_features_to_tensor(model, out): - if torch.is_tensor(out): - return out - - if hasattr(out, "image_embeds") and out.image_embeds is not None: - return out.image_embeds - - if hasattr(out, "pooler_output") and out.pooler_output is not None: - emb = out.pooler_output - - # Nur projizieren, wenn pooler_output noch die erwartete Eingangsgröße hat. - # Bei manchen Transformers-Versionen ist pooler_output bereits 512-dimensional. - projection = getattr(model, "visual_projection", None) - if projection is not None and hasattr(projection, "in_features"): - if emb.shape[-1] == projection.in_features: - emb = projection(emb) - - return emb - - if isinstance(out, (tuple, list)) and len(out) > 0: - first = out[0] - if torch.is_tensor(first): - return first - - raise TypeError(f"Unsupported CLIP image feature output: {type(out)!r}") - - -def embed_image(model, processor, device, image_path: Path): - img = Image.open(image_path).convert("RGB") - - inputs = processor(images=img, return_tensors="pt") - inputs = {k: v.to(device) for k, v in inputs.items()} - - with torch.no_grad(): - try: - out = model.get_image_features(**inputs) - except Exception: - out = model.vision_model(pixel_values=inputs["pixel_values"]) - - emb = image_features_to_tensor(model, out) - - emb = emb.detach().cpu().numpy()[0].astype("float32") - - norm = np.linalg.norm(emb) - if norm > 0: - emb = emb / norm - - return emb.reshape(1, -1) - - -def predict_with_model(model, emb): - label = str(model.predict(emb)[0]) - - score = 0.0 - if hasattr(model, "predict_proba"): - probs = model.predict_proba(emb)[0] - classes = [str(x) for x in model.classes_] - - if label in classes: - score = float(probs[classes.index(label)]) - elif len(probs) > 0: - score = float(np.max(probs)) - - return label, score - - -def main(): - parser = argparse.ArgumentParser() - parser.add_argument("--root", required=True) - parser.add_argument("--image", required=True) - args = parser.parse_args() - - root = Path(args.root) - image_path = Path(args.image) - - model_dir = root / "model" - lr_path = model_dir / "scene_clip_lr.joblib" - knn_path = model_dir / "scene_clip_knn.joblib" - - if not lr_path.exists() and not knn_path.exists(): - print(json.dumps(empty_prediction("scene_clip_missing"), ensure_ascii=False)) - return - - try: - clip_model, processor, device = load_clip() - emb = embed_image(clip_model, processor, device, image_path) - except Exception as e: - out = empty_prediction("scene_clip_embed_failed") - out["error"] = repr(e) - print(json.dumps(out, ensure_ascii=False)) - return - - # Bevorzugt Logistic Regression, weil sie stabilere Wahrscheinlichkeiten liefert. - # KNN bleibt Fallback, wenn nur eine Klasse oder sehr wenig Daten vorhanden sind. - source = "scene_position_clip_lr" - - try: - if lr_path.exists(): - model = joblib.load(lr_path) - sex_position, score = predict_with_model(model, emb) - else: - source = "scene_position_clip_knn" - model = joblib.load(knn_path) - sex_position, score = predict_with_model(model, emb) - except Exception as e: - out = empty_prediction("scene_clip_predict_failed") - out["error"] = repr(e) - print(json.dumps(out, ensure_ascii=False)) - return - - if not sex_position: - sex_position = "unknown" - - pred = { - "modelAvailable": True, - "source": source, - "peopleCount": 0, - "maleCount": 0, - "femaleCount": 0, - "unknownCount": 0, - "sexPosition": sex_position, - "sexPositionScore": float(score), - "bodyPartsPresent": [], - "objectsPresent": [], - "clothingPresent": [], - "boxes": [], - } - - print(json.dumps(pred, ensure_ascii=False)) - - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/backend/ml/train_detector_model.py b/backend/ml/train_detector_model.py index 158e3af..df11abe 100644 --- a/backend/ml/train_detector_model.py +++ b/backend/ml/train_detector_model.py @@ -53,7 +53,7 @@ def safe_int(value, fallback): def main(): parser = argparse.ArgumentParser() parser.add_argument("--root", required=True) - parser.add_argument("--base", default="yolo11n.pt") + parser.add_argument("--base", default="yolo26n.pt") parser.add_argument("--epochs", default="80") parser.add_argument("--imgsz", default="640") parser.add_argument("--device", default="auto") diff --git a/backend/ml/train_scene_model.py b/backend/ml/train_scene_model.py deleted file mode 100644 index 3535ebf..0000000 --- a/backend/ml/train_scene_model.py +++ /dev/null @@ -1,327 +0,0 @@ -# backend\ml\train_scene_model.py - -import argparse -import json -from pathlib import Path - -import joblib -import numpy as np -import torch -from PIL import Image -from sklearn.linear_model import LogisticRegression -from sklearn.neighbors import KNeighborsClassifier -from transformers import CLIPModel, CLIPProcessor - - -CLIP_MODEL_NAME = "openai/clip-vit-base-patch32" - - -def read_jsonl(path: Path): - if not path.exists(): - return [] - - rows = [] - with path.open("r", encoding="utf-8") as f: - for line in f: - line = line.strip() - if not line: - continue - - try: - rows.append(json.loads(line)) - except Exception: - pass - - return rows - - -def prediction_target(annotation): - pred = annotation.get("prediction") or {} - return str(pred.get("sexPosition") or "unknown").strip() or "unknown" - - -def correction_target(annotation): - corr = annotation.get("correction") or {} - return str(corr.get("sexPosition") or "unknown").strip() or "unknown" - - -def target_from_annotation(annotation): - if annotation.get("accepted") is True: - return prediction_target(annotation) - - return correction_target(annotation) - -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 load_clip(): - device = "cuda" if torch.cuda.is_available() else "cpu" - - processor = CLIPProcessor.from_pretrained(CLIP_MODEL_NAME) - model = CLIPModel.from_pretrained(CLIP_MODEL_NAME) - model.eval() - model.to(device) - - return model, processor, device - - -def image_features_to_tensor(model, out): - if torch.is_tensor(out): - return out - - if hasattr(out, "image_embeds") and out.image_embeds is not None: - return out.image_embeds - - if hasattr(out, "pooler_output") and out.pooler_output is not None: - emb = out.pooler_output - - # Nur projizieren, wenn pooler_output noch die erwartete Eingangsgröße hat. - # Bei manchen Transformers-Versionen ist pooler_output bereits 512-dimensional. - projection = getattr(model, "visual_projection", None) - if projection is not None and hasattr(projection, "in_features"): - if emb.shape[-1] == projection.in_features: - emb = projection(emb) - - return emb - - if isinstance(out, (tuple, list)) and len(out) > 0: - first = out[0] - if torch.is_tensor(first): - return first - - raise TypeError(f"Unsupported CLIP image feature output: {type(out)!r}") - - -def embed_image(model, processor, device, image_path: Path): - img = Image.open(image_path).convert("RGB") - - inputs = processor(images=img, return_tensors="pt") - inputs = {k: v.to(device) for k, v in inputs.items()} - - with torch.no_grad(): - try: - out = model.get_image_features(**inputs) - except Exception: - out = model.vision_model(pixel_values=inputs["pixel_values"]) - - emb = image_features_to_tensor(model, out) - - emb = emb.detach().cpu().numpy()[0].astype("float32") - - norm = np.linalg.norm(emb) - if norm > 0: - emb = emb / norm - - return emb - - -def train_lr_if_possible(x, y): - classes = sorted(set(y)) - - if len(classes) < 2: - return None - - # Logistic Regression braucht mindestens zwei Klassen. - # class_weight hilft bei unausgeglichenen Positionen. - clf = LogisticRegression( - max_iter=2000, - class_weight="balanced", - solver="lbfgs", - ) - - clf.fit(x, y) - return clf - - -def train_knn(x, y): - n_neighbors = min(7, len(y)) - - clf = KNeighborsClassifier( - n_neighbors=n_neighbors, - metric="cosine", - weights="distance", - algorithm="brute", - ) - - clf.fit(x, y) - return clf - - -def main(): - parser = argparse.ArgumentParser() - parser.add_argument("--root", required=True) - args = parser.parse_args() - - root = Path(args.root) - feedback_path = root / "feedback.jsonl" - frames_dir = root / "frames" - model_dir = root / "model" - model_dir.mkdir(parents=True, exist_ok=True) - - rows = read_jsonl(feedback_path) - total = max(1, len(rows)) - - emit_progress( - "scene", - 0.02, - "CLIP-Modell wird geladen…", - totalSamples=len(rows), - ) - - clip_model, processor, device = load_clip() - - emit_progress( - "scene", - 0.08, - "CLIP-Embeddings werden vorbereitet…", - totalSamples=len(rows), - device=device, - ) - - embeddings = [] - labels = [] - targets = [] - used = 0 - skipped = 0 - - for idx, row in enumerate(rows, start=1): - sample_id = str(row.get("sampleId") or "").strip() - - try: - if not sample_id: - skipped += 1 - continue - - image_path = frames_dir / f"{sample_id}.jpg" - if not image_path.exists(): - skipped += 1 - continue - - label = target_from_annotation(row) - if not label or label == "unknown": - skipped += 1 - continue - - emb = embed_image(clip_model, processor, device, image_path) - - embeddings.append(emb) - labels.append(label) - targets.append({ - "sampleId": sample_id, - "sexPosition": label, - }) - used += 1 - - except Exception as e: - print(f"skip {sample_id or ''}: {repr(e)}", flush=True) - skipped += 1 - - finally: - emit_progress( - "scene", - 0.08 + 0.78 * (idx / total), - f"Scene-Samples werden verarbeitet… {idx}/{len(rows)}", - currentSample=idx, - totalSamples=len(rows), - usedSamples=used, - skippedSamples=skipped, - ) - - if used < 5: - raise SystemExit(f"too few usable samples: {used}") - - emit_progress( - "scene", - 0.88, - "Scene-Embeddings werden gespeichert…", - usedSamples=used, - skippedSamples=skipped, - ) - - x = np.stack(embeddings).astype("float32") - y = np.array(labels) - - np.savez_compressed( - model_dir / "scene_clip_embeddings.npz", - embeddings=x, - labels=y, - ) - - with (model_dir / "scene_clip_targets.json").open("w", encoding="utf-8") as f: - json.dump(targets, f, ensure_ascii=False, indent=2) - - emit_progress( - "scene", - 0.93, - "Scene-KNN wird trainiert…", - usedSamples=used, - skippedSamples=skipped, - ) - - knn = train_knn(x, y) - joblib.dump(knn, model_dir / "scene_clip_knn.joblib") - - emit_progress( - "scene", - 0.96, - "Scene-Logistic-Regression wird trainiert…", - usedSamples=used, - skippedSamples=skipped, - ) - - lr_status = "skipped_single_class" - lr = train_lr_if_possible(x, y) - if lr is not None: - joblib.dump(lr, model_dir / "scene_clip_lr.joblib") - lr_status = "trained" - else: - old_lr = model_dir / "scene_clip_lr.joblib" - if old_lr.exists(): - old_lr.unlink() - - counts = {} - for label in labels: - counts[label] = counts.get(label, 0) + 1 - - status = { - "ok": True, - "usedSamples": used, - "skippedSamples": skipped, - "model": "scene_position_clip", - "clipModel": CLIP_MODEL_NAME, - "device": device, - "classes": sorted(counts.keys()), - "classCounts": counts, - "logisticRegression": lr_status, - "knn": "trained", - "embeddingsPath": str(model_dir / "scene_clip_embeddings.npz"), - "knnPath": str(model_dir / "scene_clip_knn.joblib"), - "lrPath": str(model_dir / "scene_clip_lr.joblib"), - } - - with (model_dir / "status.json").open("w", encoding="utf-8") as f: - json.dump(status, f, ensure_ascii=False, indent=2) - - emit_progress( - "scene", - 1.0, - "CLIP-Scene-Positionsmodell fertig.", - usedSamples=used, - skippedSamples=skipped, - classes=sorted(counts.keys()), - logisticRegression=lr_status, - knn="trained", - ) - - print(json.dumps(status, ensure_ascii=False), flush=True) - - -if __name__ == "__main__": - main() \ No newline at end of file diff --git a/backend/routes.go b/backend/routes.go index 3df0824..ba41375 100644 --- a/backend/routes.go +++ b/backend/routes.go @@ -78,6 +78,7 @@ func registerRoutes(mux *http.ServeMux, auth *AuthManager) *ModelStore { api.HandleFunc("/api/training/frame", trainingFrameHandler) api.HandleFunc("/api/training/feedback", trainingFeedbackHandler) api.HandleFunc("/api/training/train", trainingTrainHandler) + api.HandleFunc("/api/training/cancel", trainingCancelHandler) api.HandleFunc("/api/training/status", trainingStatusHandler) api.HandleFunc("/api/training/stats", trainingStatsHandler) api.HandleFunc("/api/training/delete-all", trainingDeleteAllHandler) diff --git a/backend/training.go b/backend/training.go index 0a4b76e..da25643 100644 --- a/backend/training.go +++ b/backend/training.go @@ -113,13 +113,6 @@ type TrainingDetectorPrediction struct { Boxes []TrainingBox `json:"boxes"` } -type TrainingScenePositionPrediction struct { - Available bool `json:"available"` - Source string `json:"source,omitempty"` - SexPosition string `json:"sexPosition"` - SexPositionScore float64 `json:"sexPositionScore"` -} - type TrainingJobStatus struct { Running bool `json:"running"` Progress int `json:"progress"` @@ -248,13 +241,14 @@ func trainingPublishJobStatus(status TrainingJobStatus) { } func trainingRunCommandStreaming( + ctx context.Context, python string, script string, onLine func(line string) bool, args ...string, ) (string, error) { cmdArgs := append([]string{script}, args...) - cmd := exec.Command(python, cmdArgs...) + cmd := exec.CommandContext(ctx, python, cmdArgs...) cmd.SysProcAttr = &syscall.SysProcAttr{ HideWindow: true, @@ -323,6 +317,10 @@ func trainingRunCommandStreaming( out := strings.Join(lines, "\n") outMu.Unlock() + if ctx.Err() != nil { + return strings.TrimSpace(out), errTrainingCancelled + } + return strings.TrimSpace(out), err } @@ -331,9 +329,12 @@ const minTrainingFeedbackCount = 5 const minDetectorTrainCount = 20 const minDetectorValCount = 3 +var errTrainingCancelled = errors.New("training cancelled") + var trainingJob = struct { mu sync.Mutex status TrainingJobStatus + cancel context.CancelFunc }{} func trainingSetJobStatus(update func(*TrainingJobStatus)) { @@ -351,6 +352,72 @@ func trainingGetJobStatus() TrainingJobStatus { return trainingJob.status } +func trainingStartJob(cancel context.CancelFunc) { + trainingJob.mu.Lock() + + trainingJob.status = TrainingJobStatus{ + Running: true, + Progress: 5, + Step: "Training wird vorbereitet…", + StartedAt: time.Now().UTC().Format(time.RFC3339), + } + + trainingJob.cancel = cancel + snapshot := trainingJob.status + + trainingJob.mu.Unlock() + + trainingPublishJobStatus(snapshot) +} + +func trainingClearJobCancel() { + trainingJob.mu.Lock() + trainingJob.cancel = nil + trainingJob.mu.Unlock() +} + +func trainingCleanupPartialDetectorTraining(root string) error { + // Löscht nur temporäre Trainingsläufe. + // Feedback, Samples, Frames und Dataset bleiben erhalten. + runsDir := filepath.Join(root, "detector", "runs") + + if err := os.RemoveAll(runsDir); err != nil { + return err + } + + return os.MkdirAll(runsDir, 0755) +} + +func trainingFinishCancelled(root string) { + cleanupErr := trainingCleanupPartialDetectorTraining(root) + + trainingSetJobStatus(func(s *TrainingJobStatus) { + finishedAt := time.Now().UTC() + + var durationMs int64 + if startedAt, err := time.Parse(time.RFC3339, strings.TrimSpace(s.StartedAt)); err == nil { + durationMs = finishedAt.Sub(startedAt).Milliseconds() + if durationMs < 0 { + durationMs = 0 + } + } + + s.Running = false + s.Step = "Training abgebrochen." + s.Message = "Training wurde abgebrochen. Temporäre Trainingsausgaben wurden gelöscht." + s.Error = "" + s.FinishedAt = finishedAt.Format(time.RFC3339) + s.DurationMs = durationMs + + if cleanupErr != nil { + s.Message = "Training wurde abgebrochen, aber temporäre Trainingsausgaben konnten nicht vollständig gelöscht werden." + s.Error = cleanupErr.Error() + } + }) + + trainingClearJobCancel() +} + func trainingRunCommand(python string, script string, args ...string) (string, error) { cmdArgs := append([]string{script}, args...) cmd := exec.Command(python, cmdArgs...) @@ -592,6 +659,37 @@ func trainingFrameHandler(w http.ResponseWriter, r *http.Request) { http.ServeFile(w, r, path) } +func trainingDetectorBoxesForAnnotation(sample *TrainingSample, req TrainingFeedbackRequest) []TrainingBox { + boxes := []TrainingBox{} + + if req.Correction != nil { + boxes = append(boxes, req.Correction.Boxes...) + } else if req.Accepted { + 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 trainingFeedbackHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { trainingWriteError(w, http.StatusMethodNotAllowed, "method not allowed") @@ -647,13 +745,7 @@ func trainingFeedbackHandler(w http.ResponseWriter, r *http.Request) { return } - detectorBoxes := []TrainingBox{} - - if req.Correction != nil { - detectorBoxes = req.Correction.Boxes - } else if req.Accepted { - detectorBoxes = sample.Prediction.Boxes - } + detectorBoxes := trainingDetectorBoxesForAnnotation(sample, req) if len(detectorBoxes) > 0 { if err := trainingWriteDetectorSample(root, sample, detectorBoxes); err != nil { @@ -744,7 +836,7 @@ func trainingTrainHandler(w http.ResponseWriter, r *http.Request) { w, http.StatusBadRequest, fmt.Sprintf( - "Zu wenige Bewertungen für das Scene-Positionsmodell. Mindestens %d, aktuell %d.", + "Zu wenige Bewertungen für das YOLO26-Training. Mindestens %d, aktuell %d.", minTrainingFeedbackCount, feedbackCount, ), @@ -761,35 +853,85 @@ func trainingTrainHandler(w http.ResponseWriter, r *http.Request) { trainCount := trainingCountDetectorSamples(detectorTrainImages, detectorTrainLabels) valCount := trainingCountDetectorSamples(detectorValImages, detectorValLabels) - trainingSetJobStatus(func(s *TrainingJobStatus) { - *s = TrainingJobStatus{ - Running: true, - Progress: 5, - Step: "Training wird vorbereitet…", - StartedAt: time.Now().UTC().Format(time.RFC3339), - } - }) + if !fileExistsNonEmpty(detectorDatasetYAML) || + trainCount < minDetectorTrainCount || + valCount < minDetectorValCount { + trainingWriteError( + w, + http.StatusBadRequest, + fmt.Sprintf( + "Zu wenige YOLO26-Box-Labels. Train=%d, Val=%d. Benötigt: mindestens %d Train und %d Val.", + trainCount, + valCount, + minDetectorTrainCount, + minDetectorValCount, + ), + ) + return + } - go trainingRunJob(root, feedbackCount) + ctx, cancel := context.WithCancel(context.Background()) + + trainingStartJob(cancel) + + go trainingRunJob(ctx, root, feedbackCount) trainingWriteJSON(w, http.StatusAccepted, map[string]any{ "ok": true, "message": "Training gestartet.", "training": trainingGetJobStatus(), "detector": map[string]any{ - "trainCount": trainCount, - "valCount": valCount, - "requiredTrain": minDetectorTrainCount, - "requiredVal": minDetectorValCount, - "datasetYAML": detectorDatasetYAML, - "usesSceneCLIP": true, - "usesSceneKNN": true, - "source": "yolo_detector+scene_position_clip", + "trainCount": trainCount, + "valCount": valCount, + "requiredTrain": minDetectorTrainCount, + "requiredVal": minDetectorValCount, + "datasetYAML": detectorDatasetYAML, + "usesSceneCLIP": false, + "usesSceneKNN": false, + "source": "yolo26_detector", + "detectsPosition": true, }, }) } -func trainingRunJob(root string, count int) { +func trainingCancelHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost && r.Method != http.MethodDelete { + trainingWriteError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + + trainingJob.mu.Lock() + status := trainingJob.status + cancel := trainingJob.cancel + trainingJob.mu.Unlock() + + if !status.Running { + trainingWriteJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "message": "Es läuft kein Training.", + "training": status, + }) + return + } + + trainingSetJobStatus(func(s *TrainingJobStatus) { + s.Step = "Training wird abgebrochen…" + s.Message = "" + s.Error = "" + }) + + if cancel != nil { + cancel() + } + + trainingWriteJSON(w, http.StatusAccepted, map[string]any{ + "ok": true, + "message": "Training wird abgebrochen.", + "training": trainingGetJobStatus(), + }) +} + +func trainingRunJob(ctx context.Context, root string, count int) { python := trainingPythonExe() cleanOutput := func(text string) string { @@ -802,48 +944,7 @@ func trainingRunJob(root string, count int) { trainingSetJobStatus(func(s *TrainingJobStatus) { s.Progress = 10 - s.Step = "CLIP-Scene-Positionsmodell wird trainiert…" - }) - - sceneStatus := "skipped" - sceneOutput := "" - - sceneScript := trainingScriptPath("train_scene_model.py") - sceneOut, sceneErr := trainingRunCommandStreaming( - python, - sceneScript, - func(line string) bool { - return trainingHandleProgressLine( - line, - 10, - 45, - "CLIP-Scene-Positionsmodell wird trainiert…", - ) - }, - "--root", root, - ) - - sceneOutput = sceneOut - sceneOutputClean := cleanOutput(sceneOutput) - - if sceneErr != nil { - sceneStatus = "failed" - - appLogln("⚠️ scene position training failed:", sceneErr) - if sceneOutputClean != "" { - appLogln("⚠️ scene position output:", sceneOutputClean) - } - } else { - sceneStatus = "trained" - - if sceneOutputClean != "" { - appLogln("✅ scene position training:", sceneOutputClean) - } - } - - trainingSetJobStatus(func(s *TrainingJobStatus) { - s.Progress = 45 - s.Step = "Object Detector-Daten werden geprüft…" + s.Step = "YOLO26-Daten werden geprüft…" }) detectorOutput := "" @@ -873,20 +974,21 @@ func trainingRunJob(root string, count int) { trainCount >= minDetectorTrainCount && valCount >= minDetectorValCount { trainingSetJobStatus(func(s *TrainingJobStatus) { - s.Progress = 60 - s.Step = "Object Detector wird trainiert…" + s.Progress = 15 + s.Step = "YOLO26 Detector wird trainiert…" }) detectorScript := trainingScriptPath("train_detector_model.py") detectorOut, detectorErr := trainingRunCommandStreaming( + ctx, python, detectorScript, func(line string) bool { return trainingHandleProgressLine( line, - 60, + 15, 98, - "Object Detector wird trainiert…", + "YOLO26 Detector wird trainiert…", ) }, "--root", root, @@ -895,27 +997,33 @@ func trainingRunJob(root string, count int) { "--imgsz", "640", ) + if errors.Is(detectorErr, errTrainingCancelled) { + appLogln("⛔ YOLO26 detector training cancelled") + trainingFinishCancelled(root) + return + } + detectorOutput = detectorOut detectorOutputClean := cleanOutput(detectorOutput) if detectorErr != nil { detectorStatus = "failed" - appLogln("⚠️ detector training failed:", detectorErr) + appLogln("⚠️ YOLO26 detector training failed:", detectorErr) if detectorOutputClean != "" { - appLogln("⚠️ detector output:", detectorOutputClean) + appLogln("⚠️ YOLO26 detector output:", detectorOutputClean) } } else { detectorStatus = "trained" if detectorOutputClean != "" { - appLogln("✅ detector training:", detectorOutputClean) + appLogln("✅ YOLO26 detector training:", detectorOutputClean) } } } else { detectorStatus = "skipped_no_detector_data" detectorOutput = fmt.Sprintf( - "Object Detector übersprungen: zu wenige Box-Labels. Train=%d, Val=%d. Benötigt: mindestens %d Train und %d Val.", + "YOLO26 Detector übersprungen: zu wenige Box-Labels. Train=%d, Val=%d. Benötigt: mindestens %d Train und %d Val.", trainCount, valCount, minDetectorTrainCount, @@ -928,64 +1036,29 @@ func trainingRunJob(root string, count int) { detectorOutputClean := cleanOutput(detectorOutput) message := "Training abgeschlossen." - errorParts := []string{} + errorText := "" - if sceneStatus == "failed" { - if sceneOutputClean != "" { - errorParts = append(errorParts, "Scene-Positionsmodell fehlgeschlagen: "+sceneOutputClean) - } else { - errorParts = append(errorParts, "Scene-Positionsmodell fehlgeschlagen. Details stehen in der Backend-Konsole.") - } - } + switch detectorStatus { + case "trained": + message = "Training abgeschlossen. YOLO26 Detector wurde trainiert." - if detectorStatus == "failed" { - if detectorOutputClean != "" { - errorParts = append(errorParts, "Object Detector fehlgeschlagen: "+detectorOutputClean) - } else { - errorParts = append(errorParts, "Object Detector fehlgeschlagen. Details stehen in der Backend-Konsole.") - } - } + case "skipped_no_detector_data": + message = detectorOutput - switch { - case sceneStatus == "trained" && detectorStatus == "trained": - message = "Training abgeschlossen. CLIP-Scene-Positionsmodell und Object Detector wurden trainiert." - - case sceneStatus == "trained" && detectorStatus == "skipped_no_detector_data": - message = "CLIP-Scene-Positionsmodell wurde trainiert. " + detectorOutput - - case sceneStatus == "trained" && detectorStatus == "failed": - message = "CLIP-Scene-Positionsmodell wurde trainiert. Object Detector ist fehlgeschlagen." + case "failed": + message = "YOLO26 Detector ist fehlgeschlagen." if detectorOutputClean != "" { message += " Grund: " + detectorOutputClean } - - case sceneStatus == "failed" && detectorStatus == "trained": - message = "Object Detector wurde trainiert. Scene-Positionsmodell ist fehlgeschlagen." - if sceneOutputClean != "" { - message += " Grund: " + sceneOutputClean - } - - case sceneStatus == "failed" && detectorStatus == "skipped_no_detector_data": - message = "Scene-Positionsmodell ist fehlgeschlagen. " + detectorOutput - if sceneOutputClean != "" { - message += " Scene-Grund: " + sceneOutputClean - } - - case sceneStatus == "failed" && detectorStatus == "failed": - message = "Scene-Positionsmodell und Object Detector sind fehlgeschlagen." + errorText = message default: - message = "Training abgeschlossen, aber kein Modell wurde erfolgreich trainiert." - if sceneOutputClean != "" { - message += " Scene-Ausgabe: " + sceneOutputClean - } + message = "Training abgeschlossen, aber YOLO26 wurde nicht trainiert." if detectorOutputClean != "" { - message += " Detector-Ausgabe: " + detectorOutputClean + message += " Ausgabe: " + detectorOutputClean } } - errorText := strings.Join(errorParts, " ") - trainingSetJobStatus(func(s *TrainingJobStatus) { finishedAt := time.Now().UTC() @@ -1005,151 +1078,8 @@ func trainingRunJob(root string, count int) { s.FinishedAt = finishedAt.Format(time.RFC3339) s.DurationMs = durationMs }) -} -func trainingStatusHandler(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - trainingWriteError(w, http.StatusMethodNotAllowed, "method not allowed") - return - } - - root, err := trainingRootDir() - if err != nil { - trainingWriteError(w, http.StatusInternalServerError, err.Error()) - return - } - - job := trainingGetJobStatus() - - if !job.Running { - if err := trainingEnsureDetectorDirs(root); err != nil { - trainingWriteError(w, http.StatusInternalServerError, err.Error()) - return - } - - if err := trainingEnsureDetectorValidationSample(root); err != nil { - appLogln("⚠️ detector val sample ensure failed:", err) - } - } - - feedbackPath := filepath.Join(root, "feedback.jsonl") - feedbackCount, _ := trainingCountAnnotations(feedbackPath) - - detectorDatasetYAML := filepath.Join(root, "detector", "dataset", "dataset.yaml") - detectorTrainImages := filepath.Join(root, "detector", "dataset", "images", "train") - detectorTrainLabels := filepath.Join(root, "detector", "dataset", "labels", "train") - detectorValImages := filepath.Join(root, "detector", "dataset", "images", "val") - detectorValLabels := filepath.Join(root, "detector", "dataset", "labels", "val") - detectorModelPath := filepath.Join(root, "detector", "model", "best.pt") - - sceneEmbeddingsPath := filepath.Join(root, "model", "scene_clip_embeddings.npz") - sceneTargetsPath := filepath.Join(root, "model", "scene_clip_targets.json") - sceneKNNPath := filepath.Join(root, "model", "scene_clip_knn.joblib") - sceneLRPath := filepath.Join(root, "model", "scene_clip_lr.joblib") - sceneStatusPath := filepath.Join(root, "model", "status.json") - - trainCount := trainingCountDetectorSamples(detectorTrainImages, detectorTrainLabels) - valCount := trainingCountDetectorSamples(detectorValImages, detectorValLabels) - - datasetReady := fileExistsNonEmpty(detectorDatasetYAML) - detectorDataReady := datasetReady && - trainCount >= minDetectorTrainCount && - valCount >= minDetectorValCount - - sceneEmbeddingsExists := fileExistsNonEmpty(sceneEmbeddingsPath) - sceneTargetsExists := fileExistsNonEmpty(sceneTargetsPath) - sceneKNNExists := fileExistsNonEmpty(sceneKNNPath) - sceneLRExists := fileExistsNonEmpty(sceneLRPath) - sceneReady := sceneEmbeddingsExists && sceneTargetsExists && (sceneKNNExists || sceneLRExists) - - canTrain := feedbackCount >= minTrainingFeedbackCount - - // Pipeline: - // - YOLO erkennt Personen/Gender für die Counts. - // - Personenboxen werden jetzt auch sichtbar zurückgegeben. - // - Manuell gezeichnete Personenboxen werden trotzdem als Trainingsdaten gespeichert. - trainingWriteJSON(w, http.StatusOK, map[string]any{ - "ok": true, - "feedbackCount": feedbackCount, - "requiredCount": minTrainingFeedbackCount, - "canTrain": canTrain, - - "training": job, - - "detector": map[string]any{ - "source": "yolo_detector", - "usesSceneKNN": false, - "usesResNet18KNN": false, - - "detectsPeople": true, - "detectsGender": true, - "detectsBodyParts": true, - "detectsObjects": true, - "detectsClothing": true, - "detectsBoxes": true, - - "trainCount": trainCount, - "valCount": valCount, - "requiredTrain": minDetectorTrainCount, - "requiredVal": minDetectorValCount, - - "datasetReady": datasetReady, - "datasetYAML": detectorDatasetYAML, - "dataReady": detectorDataReady, - - "modelExists": fileExistsNonEmpty(detectorModelPath), - "modelPath": detectorModelPath, - }, - - "scene": map[string]any{ - "source": "scene_position_clip", - "usesSceneCLIP": true, - "usesSceneKNN": true, - "usesResNet18KNN": false, - "usesLogisticRegression": true, - "predictsSexPosition": true, - - // Wichtig: - // Diese Werte kommen NICHT mehr vom Scene-KNN. - "predictsPeople": false, - "predictsGender": false, - "predictsBodyParts": false, - "predictsObjects": false, - "predictsClothing": false, - "predictsBoxes": false, - - "feedbackCount": feedbackCount, - "requiredCount": minTrainingFeedbackCount, - "dataReady": feedbackCount >= minTrainingFeedbackCount, - "modelReady": sceneReady, - "embeddingsExists": sceneEmbeddingsExists, - "targetsExists": sceneTargetsExists, - "knnExists": sceneKNNExists, - "lrExists": sceneLRExists, - "statusExists": fileExistsNonEmpty(sceneStatusPath), - "embeddingsPath": sceneEmbeddingsPath, - "targetsPath": sceneTargetsPath, - "knnPath": sceneKNNPath, - "lrPath": sceneLRPath, - "statusPath": sceneStatusPath, - }, - - "pipeline": map[string]any{ - "variant": "B", - - "peopleSource": "yolo_detector", - "genderSource": "yolo_detector", - "bodyPartsSource": "yolo_detector", - "objectsSource": "yolo_detector", - "clothingSource": "yolo_detector", - "boxesSource": "yolo_detector", - - "sexPositionSource": "scene_position_clip_lr_or_knn", - - "usesSceneKNNForDetection": false, - "usesYOLOForDetection": true, - }, - }) + trainingClearJobCancel() } func trainingStatsHandler(w http.ResponseWriter, r *http.Request) { @@ -1418,21 +1348,130 @@ func trainingCountSampleFiles(samplesDir string) int { return count } -func trainingStatsModelAvailable(root string) bool { +func trainingStatusHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + trainingWriteError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + + root, err := trainingRootDir() + if err != nil { + trainingWriteError(w, http.StatusInternalServerError, err.Error()) + return + } + + job := trainingGetJobStatus() + + if !job.Running { + if err := trainingEnsureDetectorDirs(root); err != nil { + trainingWriteError(w, http.StatusInternalServerError, err.Error()) + return + } + + if err := trainingEnsureDetectorValidationSample(root); err != nil { + appLogln("⚠️ detector val sample ensure failed:", err) + } + } + + feedbackPath := filepath.Join(root, "feedback.jsonl") + feedbackCount, _ := trainingCountAnnotations(feedbackPath) + + detectorDatasetYAML := filepath.Join(root, "detector", "dataset", "dataset.yaml") + detectorTrainImages := filepath.Join(root, "detector", "dataset", "images", "train") + detectorTrainLabels := filepath.Join(root, "detector", "dataset", "labels", "train") + detectorValImages := filepath.Join(root, "detector", "dataset", "images", "val") + detectorValLabels := filepath.Join(root, "detector", "dataset", "labels", "val") detectorModelPath := filepath.Join(root, "detector", "model", "best.pt") - sceneKNNPath := filepath.Join(root, "model", "scene_clip_knn.joblib") - sceneLRPath := filepath.Join(root, "model", "scene_clip_lr.joblib") - sceneEmbeddingsPath := filepath.Join(root, "model", "scene_clip_embeddings.npz") - sceneTargetsPath := filepath.Join(root, "model", "scene_clip_targets.json") + trainCount := trainingCountDetectorSamples(detectorTrainImages, detectorTrainLabels) + valCount := trainingCountDetectorSamples(detectorValImages, detectorValLabels) - detectorReady := fileExistsNonEmpty(detectorModelPath) - sceneReady := - fileExistsNonEmpty(sceneEmbeddingsPath) && - fileExistsNonEmpty(sceneTargetsPath) && - (fileExistsNonEmpty(sceneKNNPath) || fileExistsNonEmpty(sceneLRPath)) + datasetReady := fileExistsNonEmpty(detectorDatasetYAML) + detectorDataReady := datasetReady && + trainCount >= minDetectorTrainCount && + valCount >= minDetectorValCount - return detectorReady || sceneReady + canTrain := feedbackCount >= minTrainingFeedbackCount && detectorDataReady + + trainingWriteJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "feedbackCount": feedbackCount, + "requiredCount": minTrainingFeedbackCount, + "canTrain": canTrain, + + "training": job, + + "detector": map[string]any{ + "source": "yolo26_detector", + "usesSceneCLIP": false, + "usesSceneKNN": false, + "usesResNet18KNN": false, + + "detectsPeople": true, + "detectsGender": true, + "detectsSexPosition": true, + "detectsBodyParts": true, + "detectsObjects": true, + "detectsClothing": true, + "detectsBoxes": true, + + "trainCount": trainCount, + "valCount": valCount, + "requiredTrain": minDetectorTrainCount, + "requiredVal": minDetectorValCount, + + "datasetReady": datasetReady, + "datasetYAML": detectorDatasetYAML, + "dataReady": detectorDataReady, + + "modelExists": fileExistsNonEmpty(detectorModelPath), + "modelPath": detectorModelPath, + }, + + "scene": map[string]any{ + "source": "disabled", + "usesSceneCLIP": false, + "usesSceneKNN": false, + "usesResNet18KNN": false, + "usesLogisticRegression": false, + + "predictsSexPosition": false, + "predictsPeople": false, + "predictsGender": false, + "predictsBodyParts": false, + "predictsObjects": false, + "predictsClothing": false, + "predictsBoxes": false, + + "feedbackCount": feedbackCount, + "requiredCount": minTrainingFeedbackCount, + "dataReady": false, + "modelReady": false, + }, + + "pipeline": map[string]any{ + "variant": "YOLO26_ONLY", + + "peopleSource": "yolo26_detector", + "genderSource": "yolo26_detector", + "sexPositionSource": "yolo26_detector", + "bodyPartsSource": "yolo26_detector", + "objectsSource": "yolo26_detector", + "clothingSource": "yolo26_detector", + "boxesSource": "yolo26_detector", + + "usesSceneKNNForDetection": false, + "usesSceneCLIP": false, + "usesSceneKNN": false, + "usesYOLOForDetection": true, + "usesYOLOForSexPosition": true, + }, + }) +} + +func trainingStatsModelAvailable(root string) bool { + detectorModelPath := filepath.Join(root, "detector", "model", "best.pt") + return fileExistsNonEmpty(detectorModelPath) } func trainingConfidenceFromScore(score float64) TrainingConfidence { @@ -1809,7 +1848,7 @@ func trainingCreateNextSampleWithProgress(startedAtMs int64) (*TrainingSample, e return nil, err } - publishAnalysisStep(startedAtMs, 2, 4, filepath.Base(videoPath), "Frame wird extrahiert…") + publishAnalysisStep(startedAtMs, 2, 4, filepath.Base(videoPath), "Bild wird extrahiert…") duration := trainingProbeDurationSeconds(videoPath) second := trainingRandomSecond(duration) @@ -1843,7 +1882,7 @@ func trainingCreateNextSampleWithProgress(startedAtMs int64) (*TrainingSample, e } } - publishAnalysisStep(startedAtMs, 3, 4, filepath.Base(videoPath), "Frame wird analysiert…") + publishAnalysisStep(startedAtMs, 3, 4, filepath.Base(videoPath), "Bild wird analysiert…") prediction := trainingPredictFrame(framePath) @@ -1966,15 +2005,8 @@ func trainingPredictFrame(framePath string) TrainingPrediction { return trainingEmptyPrediction("root_error") } - // 1) YOLO erkennt Boxen, Personen, Körperteile, Gegenstände, Kleidung. det := trainingPredictDetector(root, framePath) - pred := trainingPredictionFromDetector(det) - - // 2) Scene-KNN erkennt ausschließlich die Sexposition. - scene := trainingPredictScenePosition(root, framePath) - pred = trainingApplyScenePosition(pred, scene) - - return pred + return trainingPredictionFromDetector(det) } func trainingPredictFrameDetectorOnly(framePath string) TrainingPrediction { @@ -1993,277 +2025,6 @@ func trainingPredictFrameDetectorOnly(framePath string) TrainingPrediction { return trainingPredictionFromDetector(det) } -type TrainingSpriteBatchCandidate struct { - Index int `json:"index"` - Time float64 `json:"time"` -} - -type TrainingSpriteBatchRequest struct { - SpritePath string `json:"spritePath"` - Cols int `json:"cols"` - Rows int `json:"rows"` - Count int `json:"count"` - Candidates []TrainingSpriteBatchCandidate `json:"candidates"` -} - -type TrainingSpriteBatchResult struct { - Index int `json:"index"` - Time float64 `json:"time"` - Prediction TrainingPrediction `json:"prediction"` - Error string `json:"error,omitempty"` -} - -type TrainingSpriteBatchResponse struct { - OK bool `json:"ok"` - Results []TrainingSpriteBatchResult `json:"results"` - Error string `json:"error,omitempty"` -} - -func trainingPredictSpriteBatch( - ctx context.Context, - spritePath string, - ps previewSpriteMetaFileInfo, - candidates []spriteFrameCandidate, -) ([]TrainingSpriteBatchResult, error) { - spritePath = strings.TrimSpace(spritePath) - if spritePath == "" { - return nil, appErrorf("spritePath fehlt") - } - - if !trainingRecognitionEnabled() { - out := make([]TrainingSpriteBatchResult, 0, len(candidates)) - for _, c := range candidates { - out = append(out, TrainingSpriteBatchResult{ - Index: c.Index, - Time: c.Time, - Prediction: trainingEmptyPrediction("recognition_disabled"), - }) - } - return out, nil - } - - if ps.Cols <= 0 || ps.Rows <= 0 || ps.Count <= 0 { - return nil, appErrorf("ungültige sprite meta") - } - - reqCandidates := make([]TrainingSpriteBatchCandidate, 0, len(candidates)) - for _, c := range candidates { - if c.Index < 0 || c.Index >= ps.Count { - continue - } - reqCandidates = append(reqCandidates, TrainingSpriteBatchCandidate{ - Index: c.Index, - Time: c.Time, - }) - } - - if len(reqCandidates) == 0 { - return []TrainingSpriteBatchResult{}, nil - } - - tmp, err := os.CreateTemp("", "sprite-batch-request-*.json") - if err != nil { - return nil, err - } - tmpPath := tmp.Name() - defer os.Remove(tmpPath) - - req := TrainingSpriteBatchRequest{ - SpritePath: spritePath, - Cols: ps.Cols, - Rows: ps.Rows, - Count: ps.Count, - Candidates: reqCandidates, - } - - enc := json.NewEncoder(tmp) - if err := enc.Encode(req); err != nil { - _ = tmp.Close() - return nil, err - } - if err := tmp.Close(); err != nil { - return nil, err - } - - python := trainingPythonExe() - script := trainingScriptPath("predict_sprite_batch.py") - - cmd := exec.CommandContext( - ctx, - python, - script, - "--input", - tmpPath, - ) - - 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("🔎 sprite batch stderr:", errText) - } - - if err != nil { - return nil, appErrorf("sprite batch predict failed: %w: %s", err, errText) - } - - if outText == "" { - return nil, appErrorf("sprite batch predict returned empty stdout") - } - - var resp TrainingSpriteBatchResponse - if err := json.Unmarshal([]byte(outText), &resp); err != nil { - return nil, appErrorf("sprite batch json failed: %w: %s", err, outText) - } - - if !resp.OK { - if strings.TrimSpace(resp.Error) != "" { - return nil, appErrorf("%s", resp.Error) - } - return nil, appErrorf("sprite batch predict failed") - } - - if resp.Results == nil { - resp.Results = []TrainingSpriteBatchResult{} - } - - return resp.Results, nil -} - -func trainingPredictScenePosition(root string, framePath string) TrainingScenePositionPrediction { - python := trainingPythonExe() - script := trainingScriptPath("predict_scene_model.py") - - lrPath := filepath.Join(root, "model", "scene_clip_lr.joblib") - knnPath := filepath.Join(root, "model", "scene_clip_knn.joblib") - - if !fileExistsNonEmpty(lrPath) && !fileExistsNonEmpty(knnPath) { - return TrainingScenePositionPrediction{ - Available: false, - Source: "scene_position_missing", - SexPosition: "unknown", - SexPositionScore: 0, - } - } - - cmd := exec.Command( - python, - script, - "--root", root, - "--image", framePath, - ) - - cmd.SysProcAttr = &syscall.SysProcAttr{ - HideWindow: true, - CreationFlags: 0x08000000, - } - - var stdout strings.Builder - var stderr strings.Builder - - cmd.Stdout = &stdout - cmd.Stderr = &stderr - - err := cmd.Run() - - outText := strings.TrimSpace(stdout.String()) - errText := strings.TrimSpace(stderr.String()) - - if err != nil { - appLogln("⚠️ scene position predict failed:", err) - appLogln(" stdout:", outText) - appLogln(" stderr:", errText) - - return TrainingScenePositionPrediction{ - Available: false, - Source: "scene_position_failed", - SexPosition: "unknown", - SexPositionScore: 0, - } - } - - if outText == "" { - appLogln("⚠️ scene position predict empty stdout") - - return TrainingScenePositionPrediction{ - Available: false, - Source: "scene_position_empty", - SexPosition: "unknown", - SexPositionScore: 0, - } - } - - var scenePred TrainingPrediction - if err := json.Unmarshal([]byte(outText), &scenePred); err != nil { - appLogln("⚠️ scene position json failed:", err) - appLogln(" stdout:", outText) - - return TrainingScenePositionPrediction{ - Available: false, - Source: "scene_position_json_failed", - SexPosition: "unknown", - SexPositionScore: 0, - } - } - - sexPosition := strings.TrimSpace(scenePred.SexPosition) - if sexPosition == "" { - sexPosition = "unknown" - } - - return TrainingScenePositionPrediction{ - Available: scenePred.ModelAvailable, - Source: scenePred.Source, - SexPosition: sexPosition, - SexPositionScore: scenePred.SexPositionScore, - } -} - -func trainingApplyScenePosition( - pred TrainingPrediction, - scene TrainingScenePositionPrediction, -) TrainingPrediction { - if pred.SexPosition == "" { - pred.SexPosition = "unknown" - } - - if scene.Available { - sexPosition := strings.TrimSpace(scene.SexPosition) - if sexPosition == "" { - sexPosition = "unknown" - } - - pred.SexPosition = sexPosition - pred.SexPositionScore = scene.SexPositionScore - pred.ModelAvailable = pred.ModelAvailable || scene.Available - - if pred.Source == "" { - pred.Source = scene.Source - } else if !strings.Contains(pred.Source, scene.Source) { - pred.Source = pred.Source + "+" + scene.Source - } - } - - if pred.SexPosition == "" { - pred.SexPosition = "unknown" - } - - return pred -} - func trainingPredictionFromDetector(det TrainingDetectorPrediction) TrainingPrediction { rawBoxes := det.Boxes if rawBoxes == nil { @@ -2287,7 +2048,7 @@ func trainingPredictionFromDetector(det TrainingDetectorPrediction) TrainingPred if pred.Source == "" { if det.Available { - pred.Source = "yolo_detector" + pred.Source = "yolo26_detector" } else { pred.Source = "detector_missing" } @@ -2307,6 +2068,14 @@ 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 { @@ -2332,6 +2101,9 @@ func trainingPredictionFromDetector(det TrainingDetectorPrediction) TrainingPred visibleBoxes := []TrainingBox{} + bestSexPosition := "" + bestSexPositionScore := 0.0 + for _, box := range rawBoxes { if box.Score > 0 && box.Score < 0.25 { continue @@ -2344,9 +2116,22 @@ func trainingPredictionFromDetector(det TrainingDetectorPrediction) TrainingPred box.Label = label - // Personen erkennen und zählen. - // Personenboxen werden jetzt auch sichtbar zurückgegeben, - // damit sie im Frontend gezeichnet, verschoben, gelöscht und trainiert werden können. + if 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] { switch label { case "person_male", "male_person": @@ -2363,12 +2148,16 @@ func trainingPredictionFromDetector(det TrainingDetectorPrediction) TrainingPred continue } - // Nur Bodyparts/Objects/Clothing bleiben als Boxen sichtbar. if detectionSet[label] { visibleBoxes = append(visibleBoxes, box) } } + if bestSexPosition != "" { + pred.SexPosition = bestSexPosition + pred.SexPositionScore = bestSexPositionScore + } + pred.PeopleCount = pred.MaleCount + pred.FemaleCount + pred.UnknownCount pred.Boxes = visibleBoxes @@ -2397,7 +2186,7 @@ func trainingPredictDetector(root string, framePath string) TrainingDetectorPred best := TrainingDetectorPrediction{ Available: true, - Source: "yolo_detector", + Source: "yolo26_detector", Boxes: []TrainingBox{}, } @@ -2461,7 +2250,7 @@ func trainingPredictDetector(root string, framePath string) TrainingDetectorPred } if det.Source == "" { - det.Source = "yolo_detector" + det.Source = "yolo26_detector" } best = det @@ -2556,7 +2345,7 @@ func trainingApplyDetectorToPrediction(pred TrainingPrediction, det TrainingDete if det.Available { if pred.Source == "" { - pred.Source = "yolo_detector" + pred.Source = "yolo26_detector" } else { pred.Source = pred.Source + "+yolo_detector" } diff --git a/backend/training_label_loader.go b/backend/training_label_loader.go index 8bb91ec..94f5071 100644 --- a/backend/training_label_loader.go +++ b/backend/training_label_loader.go @@ -19,9 +19,14 @@ type TrainingGroupedLabels struct { } func trainingDetectionLabelsPath() string { + if p, err := ensureTrainingDetectionLabelsFile(); err == nil { + return p + } + + // Fallback: Temp-embedded ML. if dir, err := trainingEmbeddedMLDir(); err == nil { p := filepath.Join(dir, "detection_labels.json") - if _, err := os.Stat(p); err == nil { + if st, err := os.Stat(p); err == nil && st != nil && !st.IsDir() && st.Size() > 0 { return p } } @@ -37,7 +42,7 @@ func trainingDetectionLabelsPath() string { } for _, p := range candidates { - if _, err := os.Stat(p); err == nil { + if st, err := os.Stat(p); err == nil && st != nil && !st.IsDir() && st.Size() > 0 { return p } } @@ -45,12 +50,62 @@ func trainingDetectionLabelsPath() string { return filepath.Join(projectRoot, "backend", "ml", "detection_labels.json") } +func trainingGeneratedRootDirNoLabels() (string, error) { + backendRoot, err := trainingBackendRootDir() + if err != nil { + return "", err + } + + root, err := filepath.Abs(filepath.Join(backendRoot, "generated", "training")) + if err != nil { + return "", err + } + + if err := os.MkdirAll(root, 0755); err != nil { + return "", err + } + + return root, nil +} + +func ensureTrainingDetectionLabelsFile() (string, error) { + root, err := trainingGeneratedRootDirNoLabels() + if err != nil { + return "", err + } + + if err := os.MkdirAll(root, 0755); err != nil { + return "", appErrorf("generated/training konnte nicht erstellt werden: %w", err) + } + + dstPath := filepath.Join(root, "detection_labels.json") + + if st, err := os.Stat(dstPath); err == nil && st != nil && !st.IsDir() && st.Size() > 0 { + return dstPath, nil + } + + b, err := embeddedMLFiles.ReadFile("ml/detection_labels.json") + if err != nil { + return "", appErrorf("embedded detection_labels.json fehlt: %w", err) + } + + if err := os.MkdirAll(filepath.Dir(dstPath), 0755); err != nil { + return "", appErrorf("generated/training konnte nicht erstellt werden: %w", err) + } + + if err := os.WriteFile(dstPath, b, 0644); err != nil { + return "", appErrorf("detection_labels.json konnte nicht nach generated/training kopiert werden: %w", err) + } + + return dstPath, nil +} + func trainingGroupedLabels() (TrainingGroupedLabels, error) { path := trainingDetectionLabelsPath() b, err := os.ReadFile(path) if err != nil { - return TrainingGroupedLabels{}, appErrorf("detection_labels.json konnte nicht gelesen werden: %w", err) + return TrainingGroupedLabels{}, appErrorf("detection_labels.json konnte nicht gelesen werden (%s): %w", path, err) } var grouped TrainingGroupedLabels @@ -68,8 +123,8 @@ func trainingGroupedLabels() (TrainingGroupedLabels, error) { grouped.SexPositions = []string{"unknown"} } - if len(grouped.BodyParts)+len(grouped.Objects)+len(grouped.Clothing) == 0 { - return TrainingGroupedLabels{}, appErrorf("detection_labels.json enthält keine Detection-Labels") + if len(grouped.People)+len(grouped.SexPositions)+len(grouped.BodyParts)+len(grouped.Objects)+len(grouped.Clothing) == 0 { + return TrainingGroupedLabels{}, appErrorf("detection_labels.json enthält keine Labels") } if len(grouped.People)+len(grouped.BodyParts)+len(grouped.Objects)+len(grouped.Clothing) == 0 { @@ -87,10 +142,17 @@ func trainingDetectorLabels() ([]string, error) { labels := []string{} - // Wichtig: - // People zuerst oder zuletzt ist egal, aber die Reihenfolge bestimmt YOLO-Class-IDs. - // Wenn du schon ein bestehendes Detector-Modell hast, musst du danach neu trainieren. labels = append(labels, grouped.People...) + + for _, label := range grouped.SexPositions { + clean := strings.TrimSpace(label) + if clean == "" || clean == "unknown" { + continue + } + + labels = append(labels, clean) + } + labels = append(labels, grouped.BodyParts...) labels = append(labels, grouped.Objects...) labels = append(labels, grouped.Clothing...) 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/components/ui/FinishedDownloadsCardsView.tsx b/frontend/src/components/ui/FinishedDownloadsCardsView.tsx index f49f275..c3386e9 100644 --- a/frontend/src/components/ui/FinishedDownloadsCardsView.tsx +++ b/frontend/src/components/ui/FinishedDownloadsCardsView.tsx @@ -685,7 +685,19 @@ function FinishedDownloadsCardsView({
{isSmall ? ( !inlineActive && renderRatingOverlay ? ( -
+
{ + e.preventDefault() + e.stopPropagation() + }} + onPointerDown={(e) => { + e.stopPropagation() + }} + onTouchStart={(e) => { + e.stopPropagation() + }} + > {renderRatingOverlay(j)}
) : null diff --git a/frontend/src/components/ui/FinishedDownloadsGalleryCard.tsx b/frontend/src/components/ui/FinishedDownloadsGalleryCard.tsx index 176f808..de40014 100644 --- a/frontend/src/components/ui/FinishedDownloadsGalleryCard.tsx +++ b/frontend/src/components/ui/FinishedDownloadsGalleryCard.tsx @@ -422,7 +422,19 @@ function FinishedDownloadsGalleryCardInner({ {/* Mobile: Stern unten links */} {renderRatingOverlay ? ( -
+
{ + e.preventDefault() + e.stopPropagation() + }} + onPointerDown={(e) => { + e.stopPropagation() + }} + onTouchStart={(e) => { + e.stopPropagation() + }} + > {renderRatingOverlay(j)}
) : null} diff --git a/frontend/src/components/ui/Icons.tsx b/frontend/src/components/ui/Icons.tsx index 74885cc..a3c473f 100644 --- a/frontend/src/components/ui/Icons.tsx +++ b/frontend/src/components/ui/Icons.tsx @@ -1217,10 +1217,15 @@ const SEGMENT_LABEL_META: SegmentLabelMeta[] = [ icon: TowelIcon, }, { - match: ['vibrator','toy_position', 'toy-position', 'toy sex', 'toy_sex', 'toy_play', 'toy-play'], + match: ['vibrator'], text: 'Vibrator', icon: ToyIcon, }, + { + match: ['toy_position', 'toy-position', 'toy sex', 'toy_sex', 'toy_play', 'toy-play'], + text: 'Toy', + icon: ToyIcon, + }, // Clothing { @@ -1283,6 +1288,7 @@ function normalizeLabelKey(value?: string): string { return String(value || '') .trim() .toLowerCase() + .replace(/^(object|position|body|clothing|detector):/, '') .replaceAll('-', '_') .replaceAll(' ', '_') } diff --git a/frontend/src/components/ui/Player.tsx b/frontend/src/components/ui/Player.tsx index 73851c6..db9abc8 100644 --- a/frontend/src/components/ui/Player.tsx +++ b/frontend/src/components/ui/Player.tsx @@ -583,11 +583,18 @@ export default function Player({ }, [isLive, metaReady, job.output, job.id, buildVideoSrc]) const containerRef = useRef(null) + const [containerEl, setContainerEl] = useState(null) + + const setVideoContainerRef = useCallback((el: HTMLDivElement | null) => { + containerRef.current = el + setContainerEl(el) + }, []) const playerRef = useRef(null) const videoNodeRef = useRef(null) const mobileSegmentsScrollRef = useRef(null) const mobileHeaderTouchYRef = useRef(null) + const mobileSegmentsTouchYRef = useRef(null) const [mounted, setMounted] = useState(false) @@ -766,7 +773,6 @@ export default function Player({ const isDesktop = useMediaQuery('(min-width: 640px)') const miniDesktop = mini && isDesktop - const usePortal = expanded || miniDesktop const WIN_KEY = 'player_window_v1' @@ -810,33 +816,36 @@ export default function Player({ useEffect(() => setMounted(true), []) useEffect(() => { - if (!usePortal) { - setPortalTarget(null) - return - } + if (!mounted) return let el = document.getElementById('player-root') as HTMLElement | null + if (!el) { el = document.createElement('div') el.id = 'player-root' } - // Desktop / Expanded: im Top-Layer (Dialog) oder body let host: HTMLElement | null = null if (isDesktop) { - const dialogs = Array.from(document.querySelectorAll('dialog[open]')) as HTMLElement[] + const dialogs = Array.from( + document.querySelectorAll('dialog[open]') + ) as HTMLElement[] + host = dialogs.length ? dialogs[dialogs.length - 1] : null } host = host ?? document.body - host.appendChild(el) + + if (el.parentElement !== host) { + host.appendChild(el) + } el.style.position = 'relative' el.style.zIndex = '2147483647' setPortalTarget(el) - }, [isDesktop, usePortal]) + }, [mounted, isDesktop]) useEffect(() => { const p: any = playerRef.current @@ -875,17 +884,38 @@ export default function Player({ }, [job.output, isLive]) useLayoutEffect(() => { - if (!mounted) return - if (!containerRef.current) return - if (playerRef.current) return - if (isLive) return // ✅ neu: für Live keinen Video.js mounten - if (!metaReady) return + if (!mounted) return + if (!containerEl) return + if (isLive) return + if (!metaReady) return - const videoEl = document.createElement('video') + // Falls der Player schon existiert, wurde nur der React-Container ersetzt. + // Dann hängen wir das bestehende Video.js-Element einfach in den neuen Container. + const existingPlayer = playerRef.current as any + if (existingPlayer && !existingPlayer.isDisposed?.()) { + const playerEl = existingPlayer.el?.() as HTMLElement | null + + if (playerEl && playerEl.parentElement !== containerEl) { + containerEl.replaceChildren(playerEl) + } + + requestAnimationFrame(() => { + try { + existingPlayer.trigger?.('resize') + existingPlayer.resize?.() + existingPlayer.play?.()?.catch?.(() => {}) + } catch {} + }) + + return + } + + const videoEl = document.createElement('video') videoEl.className = 'video-js vjs-big-play-centered w-full h-full' videoEl.setAttribute('playsinline', 'true') + videoEl.setAttribute('webkit-playsinline', 'true') - containerRef.current.appendChild(videoEl) + containerEl.replaceChildren(videoEl) videoNodeRef.current = videoEl const p = videojs(videoEl, { @@ -901,6 +931,9 @@ export default function Player({ html5: { vhs: { lowLatencyMode: true }, + nativeVideoTracks: true, + nativeAudioTracks: true, + nativeTextTracks: true, }, inactivityTimeout: 0, @@ -935,19 +968,25 @@ export default function Player({ p.on('userinactive', () => p.userActive(true)) return () => { - try { - if (playerRef.current) { - playerRef.current.dispose() - playerRef.current = null - } - } finally { - if (videoNodeRef.current) { - videoNodeRef.current.remove() - videoNodeRef.current = null - } - } + // Wichtig: + // NICHT disposen, nur weil containerEl gewechselt hat. + // Dispose nur beim echten Component-Unmount machen wir separat unten. } - }, [mounted, startMuted, isLive, metaReady, updateIntrinsicDims]) + }, [mounted, containerEl, startMuted, isLive, metaReady, updateIntrinsicDims]) + + useEffect(() => { + return () => { + try { + const p = playerRef.current as any + if (p && !p.isDisposed?.()) { + p.dispose() + } + } catch {} + + playerRef.current = null + videoNodeRef.current = null + } + }, []) useEffect(() => { const p = playerRef.current @@ -1167,10 +1206,28 @@ export default function Player({ ]) useEffect(() => { - const p = playerRef.current - if (!p || (p as any).isDisposed?.()) return - queueMicrotask(() => p.trigger('resize')) - }, [expanded]) + const p = playerRef.current as any + if (!p || p.isDisposed?.()) return + + const triggerResize = () => { + try { + p.trigger('resize') + p.resize?.() + } catch {} + } + + triggerResize() + + const r1 = requestAnimationFrame(triggerResize) + const r2 = requestAnimationFrame(() => { + requestAnimationFrame(triggerResize) + }) + + return () => { + cancelAnimationFrame(r1) + cancelAnimationFrame(r2) + } + }, [expanded, segmentsPanelOpen, isDesktop]) useEffect(() => { const onRelease = (ev: Event) => { @@ -1434,7 +1491,6 @@ export default function Player({ const draggingRef = useRef(null) const HOLD_TO_DRAG_MS = 220 - const HOLD_MOVE_TOLERANCE = 6 const holdDragTimerRef = useRef(null) const suppressClickUntilRef = useRef(0) @@ -1564,15 +1620,12 @@ export default function Player({ const startX = e.clientX const startY = e.clientY + let didStartDrag = false let cleanup = () => {} - const onMoveBeforeHold = (ev: globalThis.PointerEvent) => { - const dx = ev.clientX - startX - const dy = ev.clientY - startY - - if (Math.hypot(dx, dy) > HOLD_MOVE_TOLERANCE) { - cleanup() - } + const onMoveBeforeHold = () => { + // Absichtlich leer: + // Bewegung vor Ablauf des Hold-Timers soll den Drag nicht abbrechen. } const onUpBeforeHold = () => { @@ -1595,9 +1648,11 @@ export default function Player({ holdDragTimerRef.current = window.setTimeout(() => { cleanup() - const started = startDragAt(startX, startY) - if (started) { - // verhindert Play/Pause-Klick nach dem Loslassen + // Wenn die Maus in der Hold-Zeit schon minimal bewegt wurde, + // starten wir trotzdem am ursprünglichen Punkt, damit die Position sauber bleibt. + didStartDrag = startDragAt(startX, startY) + + if (didStartDrag) { suppressClickUntilRef.current = Date.now() + 1000 } }, HOLD_TO_DRAG_MS) @@ -1746,8 +1801,64 @@ export default function Player({ if (job.status !== 'running') setStopPending(false) }, [job.id, job.status]) + useEffect(() => { + if (typeof window === 'undefined') return + if (typeof document === 'undefined') return + if (isDesktop) return + + const shouldLock = + !isLive && + hasRatingSegments && + segmentsPanelOpen + + if (!shouldLock) return + + const html = document.documentElement + const body = document.body + const scrollY = window.scrollY || window.pageYOffset || 0 + + const prevHtmlOverflow = html.style.overflow + const prevHtmlOverscrollBehavior = html.style.overscrollBehavior + const prevBodyOverflow = body.style.overflow + const prevBodyPosition = body.style.position + const prevBodyTop = body.style.top + const prevBodyLeft = body.style.left + const prevBodyRight = body.style.right + const prevBodyWidth = body.style.width + const prevBodyTouchAction = body.style.touchAction + const prevBodyOverscrollBehavior = body.style.overscrollBehavior + + html.style.overflow = 'hidden' + html.style.overscrollBehavior = 'none' + + body.style.overflow = 'hidden' + body.style.position = 'fixed' + body.style.top = `-${scrollY}px` + body.style.left = '0' + body.style.right = '0' + body.style.width = '100%' + body.style.touchAction = 'none' + body.style.overscrollBehavior = 'none' + + return () => { + html.style.overflow = prevHtmlOverflow + html.style.overscrollBehavior = prevHtmlOverscrollBehavior + + body.style.overflow = prevBodyOverflow + body.style.position = prevBodyPosition + body.style.top = prevBodyTop + body.style.left = prevBodyLeft + body.style.right = prevBodyRight + body.style.width = prevBodyWidth + body.style.touchAction = prevBodyTouchAction + body.style.overscrollBehavior = prevBodyOverscrollBehavior + + window.scrollTo(0, scrollY) + } + }, [isDesktop, isLive, hasRatingSegments, segmentsPanelOpen]) + if (!mounted) return null - if (usePortal && !portalTarget) return null + if (!portalTarget) return null const overlayBtn = 'inline-flex items-center justify-center rounded-md p-2 transition ' + @@ -1885,7 +1996,7 @@ export default function Player({ }} >
{isLive ? ( @@ -1947,7 +2058,7 @@ export default function Player({
) : ( -
+
)} {/* ✅ Top overlay */} @@ -2365,118 +2476,182 @@ export default function Player({ const MOBILE_PLAYER_H = 'min(220px, 40vh)' const MOBILE_PLAYER_GAP = 10 + const isMobileExpanded = expanded && !isDesktop + + const MOBILE_EXPANDED_MARGIN_X = 8 + const MOBILE_EXPANDED_TOP = 8 + const MOBILE_EXPANDED_BOTTOM = 8 + const MOBILE_EXPANDED_GAP = 10 + const MOBILE_EXPANDED_SEGMENTS_H = 'min(38dvh, 360px)' + const mobilePlayerBottom = `calc(${bottomInset}px + env(safe-area-inset-bottom))` - const mobileSegmentsBottom = `calc(${mobilePlayerBottom} + ${MOBILE_PLAYER_H} + ${MOBILE_PLAYER_GAP}px)` - const mobileSegmentsMaxHeight = `calc(100dvh - ${mobileSegmentsBottom} - 12px)` + + const mobileExpandedSafeBottom = + `calc(${bottomInset}px + env(safe-area-inset-bottom) + ${MOBILE_EXPANDED_BOTTOM}px)` + + const mobileExpandedSegmentsVisible = + isMobileExpanded && !isLive && hasRatingSegments && segmentsPanelOpen + + const mobileExpandedPlayerH = mobileExpandedSegmentsVisible + ? `calc(100dvh - ${MOBILE_EXPANDED_TOP}px - ${bottomInset}px - env(safe-area-inset-bottom) - ${MOBILE_EXPANDED_BOTTOM}px - ${MOBILE_EXPANDED_SEGMENTS_H} - ${MOBILE_EXPANDED_GAP}px)` + : `calc(100dvh - ${MOBILE_EXPANDED_TOP}px - ${bottomInset}px - env(safe-area-inset-bottom) - ${MOBILE_EXPANDED_BOTTOM}px)` + + const mobileSegmentsBottom = isMobileExpanded + ? mobileExpandedSafeBottom + : `calc(${mobilePlayerBottom} + ${MOBILE_PLAYER_H} + ${MOBILE_PLAYER_GAP}px)` + + const mobileSegmentsMaxHeight = isMobileExpanded + ? MOBILE_EXPANDED_SEGMENTS_H + : `calc(100dvh - ${mobileSegmentsBottom} - 12px)` const mobileSegmentsSheetEl = - !isDesktop && !isLive && hasRatingSegments && segmentsPanelOpen ? ( -
e.stopPropagation()} - onTouchMoveCapture={(e) => { - e.stopPropagation() - }} - onWheelCapture={(e) => { - e.stopPropagation() - }} - > + !isDesktop && !isLive && hasRatingSegments && segmentsPanelOpen ? (
{ - mobileHeaderTouchYRef.current = e.touches[0]?.clientY ?? null - e.stopPropagation() - }} - onTouchMove={(e) => { - const y = e.touches[0]?.clientY ?? null - const lastY = mobileHeaderTouchYRef.current - - if (y != null && lastY != null) { - const dy = lastY - y - mobileSegmentsScrollRef.current?.scrollBy({ top: dy }) - mobileHeaderTouchYRef.current = y - } - - e.preventDefault() - e.stopPropagation() - }} - onTouchEnd={(e) => { - mobileHeaderTouchYRef.current = null - e.stopPropagation() - }} - onWheel={(e) => { - mobileSegmentsScrollRef.current?.scrollBy({ top: e.deltaY }) - e.preventDefault() - e.stopPropagation() - }} + onClick={(e) => e.stopPropagation()} > -
-
- Interessante Stellen -
-
- {ratingSegments.length} Segmente -
-
+
{ + mobileHeaderTouchYRef.current = e.touches[0]?.clientY ?? null + e.stopPropagation() + }} + onTouchMove={(e) => { + const y = e.touches[0]?.clientY ?? null + const lastY = mobileHeaderTouchYRef.current + + if (y != null && lastY != null) { + const dy = lastY - y + mobileSegmentsScrollRef.current?.scrollBy({ top: dy }) + mobileHeaderTouchYRef.current = y + } - -
+
+
+ Interessante Stellen +
+
+ {ratingSegments.length} Segmente +
+
-
{ - e.stopPropagation() - }} - > - { - seekPlayerToAbsolute(seconds) + +
+ +
-
-
- ) : null + onTouchStart={(e) => { + mobileSegmentsTouchYRef.current = e.touches[0]?.clientY ?? null + e.stopPropagation() + }} + onTouchMove={(e) => { + const el = mobileSegmentsScrollRef.current + const y = e.touches[0]?.clientY ?? null + const lastY = mobileSegmentsTouchYRef.current - const expandedRect = { - left: ox + 16, - top: oy + 16, - width: Math.max(0, vw - 32), - height: Math.max(0, vh - 32), - } + e.preventDefault() + e.stopPropagation() + + if (!el || y == null || lastY == null) return + + const dy = lastY - y + el.scrollTop += dy + mobileSegmentsTouchYRef.current = y + }} + onTouchEnd={(e) => { + mobileSegmentsTouchYRef.current = null + e.stopPropagation() + }} + onWheel={(e) => { + const el = mobileSegmentsScrollRef.current + + e.preventDefault() + e.stopPropagation() + + if (!el) return + + el.scrollTop += e.deltaY + }} + > + { + seekPlayerToAbsolute(seconds) + }} + maxHeight={mobileSegmentsMaxHeight} + className="min-h-0 shadow-none" + /> +
+
+ ) : null + + const expandedRect = isMobileExpanded + ? { + left: ox + MOBILE_EXPANDED_MARGIN_X, + top: oy + MOBILE_EXPANDED_TOP, + width: Math.max(0, vw - MOBILE_EXPANDED_MARGIN_X * 2), + height: mobileExpandedPlayerH, + } + : { + left: ox + 16, + top: oy + 16, + width: Math.max(0, vw - 32), + height: Math.max(0, vh - 32), + } const wrapStyle = expanded ? expandedRect @@ -2610,39 +2785,63 @@ export default function Player({ .player-sidebar-segments svg { color: rgba(255, 255, 255, 0.85) !important; } + + .player-video-frame .video-js { + width: 100% !important; + height: 100% !important; + } + + .player-video-frame .video-js .vjs-tech { + width: 100% !important; + height: 100% !important; + object-fit: contain !important; + object-position: center center !important; + } + + .player-video-frame .video-js.vjs-fill, + .player-video-frame .video-js .vjs-tech { + position: absolute !important; + inset: 0 !important; + } `} - + {expanded || miniDesktop ? ( -
- {snapGhostEl} + <> +
+ {snapGhostEl} - {cardEl} + {cardEl} - {segmentsPanelEl} + {!isMobileExpanded ? segmentsPanelEl : null} - {miniDesktop ? ( -
-
-
-
-
+ {miniDesktop ? ( +
+
+
+
+
-
-
-
-
+
+
+
+
+
+ ) : null}
- ) : null} -
+ + {isMobileExpanded ? mobileSegmentsSheetEl : null} + ) : ( <> {mobileSegmentsSheetEl} @@ -2664,9 +2863,5 @@ export default function Player({ ) - if (usePortal) { - return createPortal(content, portalTarget!) - } - - return content + return createPortal(content, portalTarget) } diff --git a/frontend/src/components/ui/RatingOverlay.tsx b/frontend/src/components/ui/RatingOverlay.tsx index 7fc184a..9b9e71a 100644 --- a/frontend/src/components/ui/RatingOverlay.tsx +++ b/frontend/src/components/ui/RatingOverlay.tsx @@ -4,9 +4,9 @@ import { useEffect, + useRef, useState, type CSSProperties, - type KeyboardEvent, type MouseEvent, } from 'react' import { createPortal } from 'react-dom' @@ -1199,12 +1199,14 @@ export function RatingSegmentsPanel({ onOpenAt, className = '', maxHeight = 420, + scroll = true, }: { metaRaw?: unknown segments?: RatingSegment[] onOpenAt?: (seconds: number) => void className?: string maxHeight?: number | string + scroll?: boolean }) { const segments = providedSegments ?? readRatingSegments(metaRaw) @@ -1239,7 +1241,20 @@ export function RatingSegmentsPanel({
-
+
{segments.map((segment, index) => ( + e.currentTarget.setPointerCapture?.(e.pointerId) + }} + onClick={(e) => { + e.stopPropagation() + }} + > + + + + {/* Header */} +
+
+
+
+ AI Rating +
+
+ {toneLabel} · {stars}/5 Sterne · {segments.length} Stellen +
+
+ +
+ + + +
-
+ {/* Content */} +
{entries.length > 0 ? ( -
-
- {entries.map((entry) => ( -
- - {entry.label} - - { + e.stopPropagation() + }} + > + + Rating-Details anzeigen + + +
+
+ {entries.map((entry) => ( +
- {entry.value} - -
- ))} + + {entry.label} + + + + {entry.value} + +
+ ))} +
-
+ ) : null} {segments.length > 0 ? ( @@ -1566,16 +1842,20 @@ function RatingMobileSheet({ onOpenAt={ onOpenAt ? (seconds) => { - onClose() + closeWithAnimation() onOpenAt(seconds) } : undefined } maxHeight="none" - className="max-h-none shadow-none" + scroll={false} + className=" + max-h-none border-0 bg-transparent shadow-none ring-0 + dark:bg-transparent dark:ring-0 + " /> ) : ( -
+
Keine interessanten Stellen vorhanden.
)} @@ -1621,21 +1901,24 @@ function RatingWithPopover({ ? `Rating ${stars}/5 · Details anzeigen` : `Rating ${stars}/5` } + onPointerDown={(e) => { + if (!touchLike) return + e.stopPropagation() + }} + onTouchStart={(e) => { + if (!touchLike) return + e.stopPropagation() + }} + onMouseDown={(e) => { + if (!touchLike) return + e.stopPropagation() + }} onClick={(e: MouseEvent) => { if (!touchLike) return e.preventDefault() e.stopPropagation() - setTapOpen((v) => !v) - }} - onKeyDown={(e: KeyboardEvent) => { - if (!touchLike) return - if (e.key !== 'Enter' && e.key !== ' ') return - - e.preventDefault() - e.stopPropagation() - setTapOpen((v) => !v) }} > @@ -1666,31 +1949,54 @@ function RatingWithPopover({
{entries.length > 0 ? ( -
-
- {entries.map((entry) => ( -
- - {entry.label} - +
{ + e.stopPropagation() + }} + > + { + e.stopPropagation() + }} + > + Rating-Details anzeigen + - +
+ {entries.map((entry) => ( +
- {entry.value} - -
- ))} + + {entry.label} + + + + {entry.value} + +
+ ))} +
-
+ ) : null} {segments.length > 0 ? ( diff --git a/frontend/src/components/ui/TrainingTab.tsx b/frontend/src/components/ui/TrainingTab.tsx index f0abca8..817c900 100644 --- a/frontend/src/components/ui/TrainingTab.tsx +++ b/frontend/src/components/ui/TrainingTab.tsx @@ -11,9 +11,11 @@ import { ClockIcon, ForwardIcon, TrashIcon, + XCircleIcon, } from '@heroicons/react/20/solid' import { getSegmentLabelItem } from './Icons' import Modal from './Modal' +import { createPortal } from 'react-dom' type DrawingTrainingBox = TrainingBox & { startX: number @@ -25,11 +27,24 @@ type ScoredLabel = { score: number } +type TrainingDetectorStatus = { + trainCount: number + valCount: number + requiredTrain: number + requiredVal: number + datasetReady: boolean + dataReady: boolean + modelExists: boolean + modelPath?: string + source?: string +} + type TrainingStatus = { feedbackCount: number requiredCount: number canTrain: boolean training?: TrainingJobStatus + detector?: TrainingDetectorStatus } type TrainingJobStatus = { @@ -416,7 +431,7 @@ function currentAnalysisConfidence(prediction?: TrainingPrediction | null): Trai scores.push(clamp01(n)) } - // Scene-Modell: Sexposition + // YOLO-Detector: Sexposition als Full-Frame-/Positions-Label if ( prediction.sexPosition && prediction.sexPosition !== 'unknown' @@ -490,36 +505,6 @@ function scoreBorderClass(score?: number | null, opts?: { draft?: boolean }) { } } -function scoreBgClass(score?: number | null, opts?: { draft?: boolean }) { - if (opts?.draft) return 'bg-amber-400' - - switch (scoreLevel(score)) { - case 'low': - return 'bg-red-500' - case 'mid': - return 'bg-yellow-400' - case 'high': - return 'bg-emerald-400' - default: - return 'bg-gray-300' - } -} - -function scoreHoverClass(score?: number | null, opts?: { draft?: boolean }) { - if (opts?.draft) return '' - - switch (scoreLevel(score)) { - case 'low': - return 'hover:bg-red-400' - case 'mid': - return 'hover:bg-yellow-300' - case 'high': - return 'hover:bg-emerald-300' - default: - return 'hover:bg-gray-200' - } -} - function scoreRingClass(score?: number | null, opts?: { draft?: boolean }) { if (opts?.draft) return 'ring-amber-500' @@ -548,6 +533,53 @@ function scoreDetectionPillClass(score?: number | null) { } } +function detectorBoxAppearance(label: string) { + const clean = String(label || '').trim() + + if (clean === 'person_female' || clean === 'female_person') { + return { + activeSurface: + 'dark:bg-pink-500/10 dark:shadow-[0_0_0_1px_rgba(244,114,182,0.20),0_10px_24px_rgba(2,6,23,0.38)]', + idleHover: 'dark:hover:bg-pink-500/[0.05]', + line: 'bg-pink-400', + lineHover: 'dark:group-hover:bg-pink-400/70', + iconActive: + 'dark:bg-pink-500/15 dark:text-pink-100 dark:ring-pink-400/25', + iconIdle: + 'dark:text-pink-200/80 dark:group-hover:bg-pink-500/10 dark:group-hover:text-pink-100 dark:group-hover:ring-pink-400/20', + selectedText: 'dark:text-pink-300', + } + } + + if (clean === 'person_male' || clean === 'male_person') { + return { + activeSurface: + 'dark:bg-sky-500/10 dark:shadow-[0_0_0_1px_rgba(56,189,248,0.20),0_10px_24px_rgba(2,6,23,0.38)]', + idleHover: 'dark:hover:bg-sky-500/[0.05]', + line: 'bg-sky-400', + lineHover: 'dark:group-hover:bg-sky-400/70', + iconActive: + 'dark:bg-sky-500/15 dark:text-sky-100 dark:ring-sky-400/25', + iconIdle: + 'dark:text-sky-200/80 dark:group-hover:bg-sky-500/10 dark:group-hover:text-sky-100 dark:group-hover:ring-sky-400/20', + selectedText: 'dark:text-sky-300', + } + } + + return { + activeSurface: + 'dark:bg-indigo-500/10 dark:shadow-[0_0_0_1px_rgba(129,140,248,0.20),0_10px_24px_rgba(2,6,23,0.38)]', + idleHover: 'dark:hover:bg-indigo-500/[0.05]', + line: 'bg-indigo-400', + lineHover: 'dark:group-hover:bg-indigo-400/70', + iconActive: + 'dark:bg-indigo-500/15 dark:text-indigo-100 dark:ring-indigo-400/25', + iconIdle: + 'dark:text-indigo-200/80 dark:group-hover:bg-indigo-500/10 dark:group-hover:text-indigo-100 dark:group-hover:ring-indigo-400/20', + selectedText: 'dark:text-indigo-300', + } +} + function normalizeMovedBox(box: TrainingBox): TrainingBox { const w = clamp01(box.w) const h = clamp01(box.h) @@ -987,7 +1019,7 @@ function LoadingImageOverlay(props: {
- {props.text || 'Frame wird erstellt und analysiert. Bitte warten.'} + {props.text || 'Bild wird erstellt und analysiert. Bitte warten.'}
@@ -1155,7 +1187,7 @@ function DetectorBoxLabelSelect(props: { bottom: openUp ? viewportH - rect.top + 4 : undefined, width: rect.width, maxHeight, - zIndex: 9999, + zIndex: 2147483647, }) }, []) @@ -1212,7 +1244,10 @@ function DetectorBoxLabelSelect(props: { setOpen((value) => !value) }} className={[ - 'flex w-full items-center justify-between gap-2 rounded-md border border-gray-200 bg-white px-2 text-gray-900 shadow-sm outline-none transition focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500/30 disabled:cursor-not-allowed disabled:opacity-50 dark:border-white/10 dark:bg-gray-950 dark:text-gray-100', + 'flex w-full items-center justify-between gap-2 rounded-xl border px-2.5 text-gray-900 shadow-sm outline-none transition', + 'focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500/30 disabled:cursor-not-allowed disabled:opacity-50', + 'border-gray-200 bg-white', + 'dark:border-white/10 dark:bg-white/[0.05] dark:text-gray-100 dark:hover:bg-white/[0.07]', props.compact ? 'h-8 text-xs' : 'h-9 text-sm', ].join(' ')} aria-haspopup="listbox" @@ -1240,57 +1275,63 @@ function DetectorBoxLabelSelect(props: { - {open ? ( -
- {props.values.map((value) => { - const active = value === props.value - const item = getSegmentLabelItem(value) - const Icon = item.icon + {open && typeof document !== 'undefined' + ? createPortal( +
+ {props.values.map((value) => { + const active = value === props.value + const item = getSegmentLabelItem(value) + const Icon = item.icon - return ( - - ) - })} -
- ) : null} + + {value} + + + ) + })} +
, + document.body + ) + : null}
) } @@ -1733,7 +1774,7 @@ function TrainingStatsModal(props: { key: 'sexPositions', title: 'Sexpositionen', shortTitle: 'Positionen', - description: 'Scene-Labels pro bewertetem Frame.', + description: 'Positions-Labels aus YOLO-Detector-Labels pro bewertetem Frame.', values: stats?.labels.sexPositions ?? [], total: Math.max(1, totalFeedback), confidence: averageCategoryConfidence(stats?.labels.sexPositions ?? []), @@ -2056,6 +2097,7 @@ export default function TrainingTab(props: { const [error, setError] = useState(null) const [message, setMessage] = useState(null) const [statsModalOpen, setStatsModalOpen] = useState(false) + const [cancellingTraining, setCancellingTraining] = useState(false) const [trainingStats, setTrainingStats] = useState(null) const [trainingStatsLoading, setTrainingStatsLoading] = useState(false) const [trainingStatsError, setTrainingStatsError] = useState(null) @@ -2275,6 +2317,21 @@ export default function TrainingTab(props: { feedbackCount: Number(data.feedbackCount ?? prev?.feedbackCount ?? 0), requiredCount: Number(data.requiredCount ?? prev?.requiredCount ?? 5), canTrain: Boolean(data.canTrain ?? prev?.canTrain ?? false), + + detector: data.detector + ? { + trainCount: Number(data.detector.trainCount ?? 0), + valCount: Number(data.detector.valCount ?? 0), + requiredTrain: Number(data.detector.requiredTrain ?? 20), + requiredVal: Number(data.detector.requiredVal ?? 3), + datasetReady: Boolean(data.detector.datasetReady), + dataReady: Boolean(data.detector.dataReady), + modelExists: Boolean(data.detector.modelExists), + modelPath: data.detector.modelPath, + source: data.detector.source, + } + : prev?.detector, + training: job ? { running: Boolean(job.running), @@ -2356,7 +2413,7 @@ export default function TrainingTab(props: { const url = `/api/training/next${params.toString() ? `?${params.toString()}` : ''}` setAnalysisProgress(25) - setAnalysisStep('Frame wird vorbereitet…') + setAnalysisStep('Bild wird vorbereitet…') const res = await fetch(url, { cache: 'no-store' }) const data = await res.json().catch(() => null) @@ -2627,7 +2684,7 @@ export default function TrainingTab(props: { if (cancelled) return // Wichtig: Auch während laufendem Training wieder das aktuelle offene Sample laden, - // damit nicht "Kein Frame geladen" angezeigt wird. + // damit nicht "Kein Bild geladen" angezeigt wird. await loadNext() } @@ -2697,50 +2754,46 @@ export default function TrainingTab(props: { const firstEpochAt = previous.firstEpochAt > 0 ? previous.firstEpochAt - : now + : job?.startedAt && Number.isFinite(Date.parse(job.startedAt)) + ? Date.parse(job.startedAt) + : now - if ( - previous.lastEpoch > 0 && - previous.lastAt > 0 && - epoch > previous.lastEpoch - ) { - const measuredMsPerEpoch = - (now - previous.lastAt) / (epoch - previous.lastEpoch) + const safeEpoch = Math.max(1, Math.min(epochs, Math.floor(epoch))) + const elapsedSinceStartMs = Math.max(0, now - firstEpochAt) - if (Number.isFinite(measuredMsPerEpoch) && measuredMsPerEpoch > 0) { - setEstimatedEpochMs((old) => { - if (!Number.isFinite(old) || old <= 0) { - return measuredMsPerEpoch - } + // Direkt ab Epoche 1 eine erste Schätzung: + // bisherige Laufzeit / aktuelle Epoche. + const averageFromElapsed = + elapsedSinceStartMs > 0 && safeEpoch > 0 + ? elapsedSinceStartMs / safeEpoch + : 0 - const clampedMeasured = Math.max( - old * 0.60, - Math.min(old * 1.60, measuredMsPerEpoch) - ) + if (Number.isFinite(averageFromElapsed) && averageFromElapsed > 0) { + setEstimatedEpochMs((old) => { + if (!Number.isFinite(old) || old <= 0) { + return averageFromElapsed + } - // Glättung: - // Genug stabil, aber reagiert schneller als vorher auf echte Änderungen. - return old * 0.75 + clampedMeasured * 0.25 - }) - } + const clampedMeasured = Math.max( + old * 0.60, + Math.min(old * 1.60, averageFromElapsed) + ) + + return old * 0.75 + clampedMeasured * 0.25 + }) } - if (epoch !== previous.lastEpoch) { - epochTimingRef.current = { - firstEpochAt, - lastEpoch: epoch, - lastAt: now, - } - } else if (previous.firstEpochAt <= 0) { - epochTimingRef.current = { - ...previous, - firstEpochAt, - } + epochTimingRef.current = { + firstEpochAt, + lastEpoch: safeEpoch, + lastAt: now, } }, [ trainingRunning, trainingStatus?.training?.epoch, trainingStatus?.training?.epochs, + trainingStatus?.training?.startedAt, + trainingNowMs, ]) const saveFeedback = useCallback( @@ -2837,6 +2890,37 @@ export default function TrainingTab(props: { } }, [loadTrainingStatus]) + const cancelTraining = useCallback(async () => { + const confirmed = window.confirm( + 'Training wirklich abbrechen? Temporäre Trainingsausgaben dieses Laufs werden gelöscht. Deine Feedbacks und Labels bleiben erhalten.' + ) + + if (!confirmed) return + + setCancellingTraining(true) + setError(null) + setMessage(null) + setTrainingStep('Training wird abgebrochen…') + + try { + const res = await fetch('/api/training/cancel', { + method: 'POST', + }) + + const data = await res.json().catch(() => null) + + if (!res.ok) { + throw new Error(backendText(data, `HTTP ${res.status}`)) + } + + await loadTrainingStatus() + } catch (e) { + setError(e instanceof Error ? e.message : String(e)) + } finally { + setCancellingTraining(false) + } + }, [loadTrainingStatus]) + const deleteAllTrainingData = useCallback(async () => { const confirmed = window.confirm( 'Wirklich alle Trainingsdaten löschen? Das entfernt Feedback, Frames, Samples und Detector-Daten. Diese Aktion kann nicht rückgängig gemacht werden.' @@ -3129,6 +3213,10 @@ export default function TrainingTab(props: { setHasManualCorrection(true) } + if (cleanNextLabel) { + setBoxLabel(cleanNextLabel) + } + setCorrection((prev) => changeBoxLabelInCorrection(prev, index, nextLabel, labelsRef.current) ) @@ -3180,8 +3268,6 @@ export default function TrainingTab(props: { return trainingDurationMs(job) }, [trainingStatus?.training, trainingNowMs]) - const finalizeOverheadMs = 8_000 - const rawTrainingEtaMs = useMemo(() => { if (!trainingRunning) return 0 @@ -3197,55 +3283,18 @@ export default function TrainingTab(props: { epochs <= 0 || estimatedEpochMs <= 0 ) { - // Fallback, solange noch keine brauchbare Epochenzeit bekannt ist. - const progress = clampPercent(Number(shownTrainingProgress)) - - if ( - shownTrainingDurationMs > 0 && - progress >= 5 && - progress < 99 - ) { - return Math.max( - 0, - shownTrainingDurationMs * ((100 - progress) / progress) - ) - } - return 0 } - const safeEpoch = Math.max(1, Math.min(epochs, Math.floor(epoch))) + const completedEpochs = Math.max(1, Math.min(epochs, Math.floor(epoch))) + const remainingEpochs = Math.max(0, epochs - completedEpochs) - // lastAt ist der Zeitpunkt, an dem die aktuelle Epoche begonnen hat. - const currentEpochStartedAt = epochTimingRef.current.lastAt - const currentEpochElapsedMs = - currentEpochStartedAt > 0 - ? Math.max(0, trainingNowMs - currentEpochStartedAt) - : 0 - - // Rest der aktuell laufenden Epoche. - const currentEpochRemainingMs = Math.max( - 0, - estimatedEpochMs - currentEpochElapsedMs - ) - - // Volle Epochen nach der aktuell laufenden Epoche. - const remainingFullEpochs = Math.max(0, epochs - safeEpoch) - - return Math.max( - 0, - currentEpochRemainingMs + - remainingFullEpochs * estimatedEpochMs + - finalizeOverheadMs - ) + return Math.max(0, remainingEpochs * estimatedEpochMs) }, [ trainingRunning, trainingStatus?.training?.epoch, trainingStatus?.training?.epochs, estimatedEpochMs, - trainingNowMs, - shownTrainingDurationMs, - shownTrainingProgress, ]) useEffect(() => { @@ -3293,20 +3342,6 @@ export default function TrainingTab(props: { const shownTrainingEtaMs = smoothedTrainingEtaMs - const shownTrainingTotalMs = useMemo(() => { - if (!trainingRunning) return 0 - - if (shownTrainingEtaMs > 0) { - return shownTrainingDurationMs + shownTrainingEtaMs - } - - return 0 - }, [ - trainingRunning, - shownTrainingDurationMs, - shownTrainingEtaMs, - ]) - const shownTrainingEpochText = useMemo(() => { const epoch = Number(trainingStatus?.training?.epoch ?? 0) const epochs = Number(trainingStatus?.training?.epochs ?? 0) @@ -3330,9 +3365,12 @@ export default function TrainingTab(props: { } if (message) { + const lowerMessage = message.toLowerCase() + const looksPartial = - message.toLowerCase().includes('übersprungen') || - message.toLowerCase().includes('fehlgeschlagen') + lowerMessage.includes('übersprungen') || + lowerMessage.includes('fehlgeschlagen') || + lowerMessage.includes('abgebrochen') return { kind: looksPartial ? 'warning' : 'success', @@ -3358,14 +3396,6 @@ export default function TrainingTab(props: { }) } - if (shownTrainingTotalMs > 0) { - items.push({ - icon: ClockIcon, - label: 'Geschätzte Gesamtdauer', - value: `ca. ${formatDuration(shownTrainingTotalMs)}`, - }) - } - if (shownTrainingEpochText) { items.push({ icon: ArrowPathIcon, @@ -3398,7 +3428,6 @@ export default function TrainingTab(props: { trainingRunning, shownTrainingStep, shownTrainingDurationMs, - shownTrainingTotalMs, shownTrainingEtaMs, shownTrainingEpochText, estimatedEpochMs, @@ -3432,12 +3461,25 @@ export default function TrainingTab(props: { ) const feedbackReady = feedbackCount >= requiredCount + const detector = trainingStatus?.detector + const detectorReady = Boolean(detector?.dataReady) + const missingTrain = Math.max( + 0, + Number(detector?.requiredTrain ?? 20) - Number(detector?.trainCount ?? 0) + ) + const missingVal = Math.max( + 0, + Number(detector?.requiredVal ?? 3) - Number(detector?.valCount ?? 0) + ) const statusText = trainingRunning ? shownTrainingStep || 'Training läuft…' - : feedbackReady - ? 'Bereit zum Trainieren' - : `${Math.max(0, requiredCount - feedbackCount)} Feedback fehlen noch` - + : !feedbackReady + ? `${Math.max(0, requiredCount - feedbackCount)} Feedback fehlen noch` + : !detectorReady + ? `YOLO-Boxen fehlen: ${missingTrain} Train, ${missingVal} Val` + : canStartTraining + ? 'Bereit zum Trainieren' + : 'Noch nicht trainingsbereit' return (
void startTraining()} + disabled={ + trainingRunning + ? cancellingTraining || deletingTrainingData + : uiLocked || !canStartTraining + } + onClick={() => { + if (trainingRunning) { + void cancelTraining() + return + } + + void startTraining() + }} title={ - canStartTraining - ? 'Training mit allen gespeicherten Bewertungen starten.' - : `Noch zu wenig Feedback: ${feedbackCount}/${requiredCount}.` + trainingRunning + ? 'Training abbrechen und temporäre Trainingsausgaben löschen.' + : canStartTraining + ? 'YOLO26-Detector mit allen gespeicherten Box-Labels trainieren.' + : !feedbackReady + ? `Noch zu wenig Feedback: ${feedbackCount}/${requiredCount}.` + : !detectorReady + ? `Noch zu wenige YOLO-Box-Labels: Train ${detector?.trainCount ?? 0}/${detector?.requiredTrain ?? 20}, Val ${detector?.valCount ?? 0}/${detector?.requiredVal ?? 3}.` + : 'Training ist aktuell nicht verfügbar.' } > {trainingRunning ? ( -
@@ -3598,54 +3663,95 @@ export default function TrainingTab(props: { }) => { const compact = Boolean(opts?.compact) const stretch = Boolean(opts?.stretch) + const hasBoxes = correctionBoxes.length > 0 return (
-
-
-
- Detector-Boxen +
+
+
+
+
+ Detector-Boxen +
+ + + {correctionBoxes.length} + +
+ + {!compact ? ( +
+ Prüfen, Label ändern oder einzeln löschen. +
+ ) : null}
- {!compact ? ( -
- Boxen prüfen, Label ändern oder löschen. -
+ {hasBoxes ? ( + ) : null}
- -
- {correctionBoxes.length} -
- {correctionBoxes.length === 0 ? ( -
- Noch keine Boxen gezeichnet. + {!hasBoxes ? ( +
+
+
+ Keine Boxen vorhanden +
+ +
+ Wähle rechts ein Label aus und zeichne im Bild eine neue Box. +
+
) : ( correctionBoxes.map((box, index) => { const item = getSegmentLabelItem(box.label) const Icon = item.icon const isActive = activeBoxIndex === index + const isCorrected = typeof box.score !== 'number' + const scoreText = typeof box.score === 'number' ? percent(box.score) : 'korrigiert' + const tone = detectorBoxAppearance(box.label) return (
setActiveBoxIndex(index)} className={[ - 'cursor-pointer rounded-lg text-[11px] ring-1 transition', - compact ? 'px-2 py-1' : 'px-2 py-1.5', + 'group relative cursor-pointer overflow-hidden rounded-2xl border transition-all duration-200', + 'bg-white shadow-sm', + 'dark:border-white/10 dark:bg-gray-950/55', isActive ? [ - 'border-l-4 border-blue-500 bg-white text-gray-900 ring-blue-200 shadow-sm', - 'dark:border-blue-400 dark:bg-white/10 dark:text-white dark:ring-blue-400/40', + 'border-gray-200 bg-white', + 'dark:border-white/10', + tone.activeSurface, ].join(' ') : [ - 'bg-white text-gray-700 ring-gray-200 hover:bg-gray-50 hover:ring-blue-200', - 'dark:bg-white/5 dark:text-gray-200 dark:ring-white/10 dark:hover:bg-white/10 dark:hover:ring-blue-400/40', + 'border-gray-200 hover:bg-gray-50/80 hover:shadow-md', + 'dark:border-white/10 dark:hover:bg-white/[0.04]', + tone.idleHover, ].join(' '), ].join(' ')} > -
-
- -
) @@ -3819,7 +3978,7 @@ export default function TrainingTab(props: {
-
+
{detectorBoxesPanel({ stretch: true, maxHeightClassName: 'lg:max-h-none', @@ -3884,10 +4043,13 @@ export default function TrainingTab(props: {
- - {!isDraft ? ( - - ) : null}
{!isDraft ? ( @@ -4094,7 +4274,7 @@ export default function TrainingTab(props: { > @@ -4251,7 +4431,7 @@ export default function TrainingTab(props: { /> ) : frameBusy ? ( ) : null} @@ -4267,7 +4447,7 @@ export default function TrainingTab(props: { progress={analysisProgress} /> ) : ( -
Kein Frame geladen
+
Kein Bild geladen
)}