diff --git a/backend/ml/detection_labels.json b/backend/ml/detection_labels.json index 73abb11..feeaeb1 100644 --- a/backend/ml/detection_labels.json +++ b/backend/ml/detection_labels.json @@ -1,4 +1,8 @@ { + "people": [ + "person_female", + "person_male" + ], "sexPositions": [ "unknown", "solo", @@ -22,13 +26,12 @@ ], "bodyParts": [ "anus", + "ass", "back", "breasts", - "buttocks", - "chest", "penis", "tongue", - "vagina" + "pussy" ], "objects": [ "bath", @@ -51,11 +54,10 @@ "heels", "hotpants", "lingerie", - "nude", "panties", "shirt", "skirt", "stockings", - "top" + "croptop" ] } \ No newline at end of file diff --git a/backend/ml/predict_scene_model.py b/backend/ml/predict_scene_model.py index 3e0ec2d..54f0750 100644 --- a/backend/ml/predict_scene_model.py +++ b/backend/ml/predict_scene_model.py @@ -4,13 +4,14 @@ import argparse import json from pathlib import Path +import joblib import numpy as np import torch from PIL import Image -from torchvision import models +from transformers import CLIPModel, CLIPProcessor -import subprocess -import sys + +CLIP_MODEL_NAME = "openai/clip-vit-base-patch32" def empty_prediction(source="no_model"): @@ -30,76 +31,81 @@ def empty_prediction(source="no_model"): } -def load_encoder(): - weights = models.ResNet18_Weights.DEFAULT - model = models.resnet18(weights=weights) - model.fc = torch.nn.Identity() +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) - preprocess = weights.transforms() - return model, preprocess + return model, processor, device -def embed_image(model, preprocess, image_path: Path): +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") - x = preprocess(img).unsqueeze(0) + + inputs = processor(images=img, return_tensors="pt") + inputs = {k: v.to(device) for k, v in inputs.items()} with torch.no_grad(): - emb = model(x).cpu().numpy()[0].astype("float32") + 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 + return emb.reshape(1, -1) -def weighted_mode_string(items, weights, fallback="unknown"): - scores = {} +def predict_with_model(model, emb): + label = str(model.predict(emb)[0]) - for item, weight in zip(items, weights): - key = str(item or fallback) - scores[key] = scores.get(key, 0.0) + float(weight) + score = 0.0 + if hasattr(model, "predict_proba"): + probs = model.predict_proba(emb)[0] + classes = [str(x) for x in model.classes_] - if not scores: - return fallback, 0.0 + if label in classes: + score = float(probs[classes.index(label)]) + elif len(probs) > 0: + score = float(np.max(probs)) - label, score = max(scores.items(), key=lambda x: x[1]) - total = sum(scores.values()) or 1.0 - return label, float(score / total) - - -def weighted_int(items, weights): - if not items: - return 0 - - total_w = float(np.sum(weights)) or 1.0 - value = sum(float(v or 0) * float(w) for v, w in zip(items, weights)) / total_w - return int(round(value)) - - -def weighted_multilabel(targets, key, weights, threshold=0.35): - scores = {} - total = float(np.sum(weights)) or 1.0 - - for target, weight in zip(targets, weights): - for label in target.get(key) or []: - label = str(label).strip() - if not label: - continue - scores[label] = scores.get(label, 0.0) + float(weight) - - out = [] - for label, score in scores.items(): - conf = float(score / total) - if conf >= threshold: - out.append({ - "label": label, - "score": conf, - }) - - out.sort(key=lambda x: x["score"], reverse=True) - return out + return label, score def main(): @@ -111,57 +117,61 @@ def main(): root = Path(args.root) image_path = Path(args.image) - model_path = root / "model" / "scene_knn.npz" - targets_path = root / "model" / "targets.json" + model_dir = root / "model" + lr_path = model_dir / "scene_clip_lr.joblib" + knn_path = model_dir / "scene_clip_knn.joblib" - if not model_path.exists() or not targets_path.exists(): - print(json.dumps(empty_prediction("model_missing"))) + if not lr_path.exists() and not knn_path.exists(): + print(json.dumps(empty_prediction("scene_clip_missing"), ensure_ascii=False)) return - data = np.load(model_path) - embeddings = data["embeddings"].astype("float32") - - with targets_path.open("r", encoding="utf-8") as f: - targets = json.load(f) - - if len(embeddings) == 0 or len(targets) == 0: - print(json.dumps(empty_prediction("model_empty"))) + 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 - encoder, preprocess = load_encoder() - emb = embed_image(encoder, preprocess, image_path) + # 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" - sims = embeddings @ emb + 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 - k = min(7, len(sims)) - idx = np.argsort(-sims)[:k] - top_targets = [targets[int(i)] for i in idx] - - raw_weights = np.maximum(sims[idx], 0.0) - if float(np.sum(raw_weights)) <= 0: - raw_weights = np.ones_like(raw_weights, dtype="float32") - - weights = raw_weights / (float(np.sum(raw_weights)) or 1.0) - - sex_labels = [t.get("sexPosition") or "unknown" for t in top_targets] - sex_position, sex_score = weighted_mode_string(sex_labels, weights, "unknown") + if not sex_position: + sex_position = "unknown" pred = { "modelAvailable": True, - "source": "resnet18_knn", - "peopleCount": weighted_int([t.get("peopleCount") for t in top_targets], weights), - "maleCount": weighted_int([t.get("maleCount") for t in top_targets], weights), - "femaleCount": weighted_int([t.get("femaleCount") for t in top_targets], weights), - "unknownCount": weighted_int([t.get("unknownCount") for t in top_targets], weights), + "source": source, + "peopleCount": 0, + "maleCount": 0, + "femaleCount": 0, + "unknownCount": 0, "sexPosition": sex_position, - "sexPositionScore": sex_score, - "bodyPartsPresent": weighted_multilabel(top_targets, "bodyPartsPresent", weights), - "objectsPresent": weighted_multilabel(top_targets, "objectsPresent", weights), - "clothingPresent": weighted_multilabel(top_targets, "clothingPresent", weights), + "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/requirements-ml.txt b/backend/ml/requirements-ml.txt index f73288e..4ad455c 100644 --- a/backend/ml/requirements-ml.txt +++ b/backend/ml/requirements-ml.txt @@ -1,4 +1,8 @@ torch torchvision pillow -numpy \ No newline at end of file +numpy +transformers +scikit-learn +joblib +safetensors \ No newline at end of file diff --git a/backend/ml/train_scene_model.py b/backend/ml/train_scene_model.py index 3a66462..8df3578 100644 --- a/backend/ml/train_scene_model.py +++ b/backend/ml/train_scene_model.py @@ -4,20 +4,16 @@ import argparse import json from pathlib import Path +import joblib import numpy as np import torch from PIL import Image -from torchvision import models, transforms +from sklearn.linear_model import LogisticRegression +from sklearn.neighbors import KNeighborsClassifier +from transformers import CLIPModel, CLIPProcessor -def load_encoder(): - weights = models.ResNet18_Weights.DEFAULT - model = models.resnet18(weights=weights) - model.fc = torch.nn.Identity() - model.eval() - - preprocess = weights.transforms() - return model, preprocess +CLIP_MODEL_NAME = "openai/clip-vit-base-patch32" def read_jsonl(path: Path): @@ -30,41 +26,23 @@ def read_jsonl(path: Path): 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 { - "peopleCount": int(pred.get("peopleCount") or 0), - "maleCount": int(pred.get("maleCount") or 0), - "femaleCount": int(pred.get("femaleCount") or 0), - "unknownCount": int(pred.get("unknownCount") or 0), - "sexPosition": str(pred.get("sexPosition") or "unknown"), - "bodyPartsPresent": [x.get("label") for x in pred.get("bodyPartsPresent") or [] if x.get("label")], - "objectsPresent": [x.get("label") for x in pred.get("objectsPresent") or [] if x.get("label")], - "clothingPresent": [x.get("label") for x in pred.get("clothingPresent") or [] if x.get("label")], - } + return str(pred.get("sexPosition") or "unknown").strip() or "unknown" def correction_target(annotation): corr = annotation.get("correction") or {} - - return { - "peopleCount": int(corr.get("peopleCount") or 0), - "maleCount": int(corr.get("maleCount") or 0), - "femaleCount": int(corr.get("femaleCount") or 0), - "unknownCount": int(corr.get("unknownCount") or 0), - "sexPosition": str(corr.get("sexPosition") or "unknown"), - "bodyPartsPresent": list(corr.get("bodyPartsPresent") or []), - "objectsPresent": list(corr.get("objectsPresent") or []), - "clothingPresent": list(corr.get("clothingPresent") or []), - } + return str(corr.get("sexPosition") or "unknown").strip() or "unknown" def target_from_annotation(annotation): @@ -74,12 +52,59 @@ def target_from_annotation(annotation): return correction_target(annotation) -def embed_image(model, preprocess, image_path: Path): +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") - x = preprocess(img).unsqueeze(0) + + inputs = processor(images=img, return_tensors="pt") + inputs = {k: v.to(device) for k, v in inputs.items()} with torch.no_grad(): - emb = model(x).cpu().numpy()[0].astype("float32") + 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: @@ -88,6 +113,38 @@ def embed_image(model, preprocess, image_path: Path): 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) @@ -101,9 +158,10 @@ def main(): rows = read_jsonl(feedback_path) - model, preprocess = load_encoder() + clip_model, processor, device = load_clip() embeddings = [] + labels = [] targets = [] used = 0 skipped = 0 @@ -119,48 +177,74 @@ def main(): skipped += 1 continue + label = target_from_annotation(row) + if not label: + label = "unknown" + try: - emb = embed_image(model, preprocess, image_path) - target = target_from_annotation(row) + emb = embed_image(clip_model, processor, device, image_path) embeddings.append(emb) - targets.append(target) + labels.append(label) + targets.append({ + "sampleId": sample_id, + "sexPosition": label, + }) used += 1 - except Exception: + except Exception as e: + print(f"skip {sample_id}: {repr(e)}") skipped += 1 if used < 5: raise SystemExit(f"too few usable samples: {used}") - embeddings_np = np.stack(embeddings).astype("float32") + x = np.stack(embeddings).astype("float32") + y = np.array(labels) np.savez_compressed( - model_dir / "scene_knn.npz", - embeddings=embeddings_np, + model_dir / "scene_clip_embeddings.npz", + embeddings=x, + labels=y, ) - with (model_dir / "targets.json").open("w", encoding="utf-8") as f: + with (model_dir / "scene_clip_targets.json").open("w", encoding="utf-8") as f: json.dump(targets, f, ensure_ascii=False, indent=2) - with (model_dir / "status.json").open("w", encoding="utf-8") as f: - json.dump( - { - "ok": True, - "usedSamples": used, - "skippedSamples": skipped, - "model": "resnet18_knn", - }, - f, - ensure_ascii=False, - indent=2, - ) + knn = train_knn(x, y) + joblib.dump(knn, model_dir / "scene_clip_knn.joblib") - print(json.dumps({ + 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, - "modelPath": str(model_dir / "scene_knn.npz"), - })) + "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) if __name__ == "__main__": diff --git a/backend/recorder_settings.json b/backend/recorder_settings.json index fc67600..c602d34 100644 --- a/backend/recorder_settings.json +++ b/backend/recorder_settings.json @@ -22,5 +22,7 @@ "generateAssetsTeaser": true, "generateAssetsSprites": false, "generateAssetsAnalyze": false, + "trainingRecognitionEnabled": true, + "trainingDetectorEpochs": 60, "encryptedCookies": "FEPj/igIPaDy8xr709QjHKOqXWVGesegmwzhTP1Whnusetrl1WenN7t0V1Rm/lw2nLkyis78kz+4yjsDtVgO3MXLU4Uq85AhAJQOE7SMVD+YMNenCS8Qr0JLkmI6cr/kR6sKrx7Jm6x6DY1BolFFMX1Ii3EO+8b4Re2GYaFi1iuh97oI8Zg4r5sMYmt7FIMRkr1uy0u0ToH8hsO0iDoExcxubQNHIZTQ07kkWP70Up78tYs59INxzosijATbiVhOT1H0YwS23iWC3bwLgeo2lOzMejJWpEG16CN55CoEAymFd2ktlQZ4Lmv0FlnBJ48H27Cj0TKR1yRbBxpGjgch9x0421C5qYGeXNB9jgczEKh28oYdt8ljXejoDsV7/oKYNaAFfIlICsC/Zz97ZpPRrKiRTSTkTLmIXjnWtVdwGRfCvJrYjJpoe3ZOaubulqCQs6Ug501b6JTzdZujKAl68tL5stZD7hH9XGQSpquZWGeSUGOrI5jU8dULbxCLpkGtiOEmxGb93OLYdHQ8JJg6zHeExVtPSqANHoKX3NpFc33fqOX1vOQVeg8Ei4ymxntIMOwQWs8xnbuKXmnNx6GTBSqHiD7X/a8oeEkMSw1+bCODvwuXlNBcdxHlf3/wkvHb9lhnh231BD1+Ufrnij65Apko8A==" } diff --git a/backend/settings.go b/backend/settings.go index 20f4cc2..72a3c8b 100644 --- a/backend/settings.go +++ b/backend/settings.go @@ -46,6 +46,9 @@ type RecorderSettings struct { GenerateAssetsSprites bool `json:"generateAssetsSprites"` GenerateAssetsAnalyze bool `json:"generateAssetsAnalyze"` + TrainingRecognitionEnabled bool `json:"trainingRecognitionEnabled"` + TrainingDetectorEpochs int `json:"trainingDetectorEpochs"` + // EncryptedCookies contains base64(nonce+ciphertext) of a JSON cookie map. EncryptedCookies string `json:"encryptedCookies"` } @@ -83,6 +86,9 @@ var ( GenerateAssetsSprites: false, GenerateAssetsAnalyze: false, + TrainingRecognitionEnabled: true, + TrainingDetectorEpochs: 60, + EncryptedCookies: "", } settingsFile = "recorder_settings.json" @@ -144,6 +150,13 @@ func loadSettings() { s.LowDiskPauseBelowGB = 10_000 } + if s.TrainingDetectorEpochs < 1 { + s.TrainingDetectorEpochs = 60 + } + if s.TrainingDetectorEpochs > 300 { + s.TrainingDetectorEpochs = 300 + } + settingsMu.Lock() settings = s settingsMu.Unlock() @@ -244,6 +257,9 @@ type RecorderSettingsPublic struct { GenerateAssetsTeaser bool `json:"generateAssetsTeaser"` GenerateAssetsSprites bool `json:"generateAssetsSprites"` GenerateAssetsAnalyze bool `json:"generateAssetsAnalyze"` + + TrainingRecognitionEnabled bool `json:"trainingRecognitionEnabled"` + TrainingDetectorEpochs int `json:"trainingDetectorEpochs"` } func toPublicSettings(s RecorderSettings) RecorderSettingsPublic { @@ -278,6 +294,9 @@ func toPublicSettings(s RecorderSettings) RecorderSettingsPublic { GenerateAssetsTeaser: s.GenerateAssetsTeaser, GenerateAssetsSprites: s.GenerateAssetsSprites, GenerateAssetsAnalyze: s.GenerateAssetsAnalyze, + + TrainingRecognitionEnabled: s.TrainingRecognitionEnabled, + TrainingDetectorEpochs: s.TrainingDetectorEpochs, } } @@ -346,6 +365,13 @@ func recordSettingsHandler(w http.ResponseWriter, r *http.Request) { in.MaxConcurrentDownloads = 1 } + if in.TrainingDetectorEpochs < 1 { + in.TrainingDetectorEpochs = 60 + } + if in.TrainingDetectorEpochs > 300 { + in.TrainingDetectorEpochs = 300 + } + // --- ensure folders (Fehler zurückgeben, falls z.B. keine Rechte) --- recAbs, err := resolvePathRelativeToApp(in.RecordDir) if err != nil { @@ -436,6 +462,9 @@ func recordSettingsHandler(w http.ResponseWriter, r *http.Request) { next.GenerateAssetsSprites = in.GenerateAssetsSprites next.GenerateAssetsAnalyze = in.GenerateAssetsAnalyze + next.TrainingRecognitionEnabled = in.TrainingRecognitionEnabled + next.TrainingDetectorEpochs = in.TrainingDetectorEpochs + dbChanged := strings.TrimSpace(next.DatabaseURL) != strings.TrimSpace(current.DatabaseURL) || strings.TrimSpace(next.EncryptedDBPassword) != strings.TrimSpace(current.EncryptedDBPassword) diff --git a/backend/training.go b/backend/training.go index dc3dc8d..bd5aedc 100644 --- a/backend/training.go +++ b/backend/training.go @@ -23,6 +23,7 @@ import ( ) type TrainingLabels struct { + People []string `json:"people"` SexPositions []string `json:"sexPositions"` BodyParts []string `json:"bodyParts"` Objects []string `json:"objects"` @@ -110,6 +111,13 @@ 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"` @@ -476,6 +484,19 @@ func trainingTrainHandler(w http.ResponseWriter, r *http.Request) { feedbackPath := filepath.Join(root, "feedback.jsonl") feedbackCount, _ := trainingCountAnnotations(feedbackPath) + if feedbackCount < minTrainingFeedbackCount { + trainingWriteError( + w, + http.StatusBadRequest, + fmt.Sprintf( + "Zu wenige Bewertungen für das Scene-Positionsmodell. Mindestens %d, aktuell %d.", + minTrainingFeedbackCount, + feedbackCount, + ), + ) + return + } + detectorTrainImages := filepath.Join(root, "detector", "dataset", "images", "train") detectorTrainLabels := filepath.Join(root, "detector", "dataset", "labels", "train") detectorValImages := filepath.Join(root, "detector", "dataset", "images", "val") @@ -485,36 +506,11 @@ func trainingTrainHandler(w http.ResponseWriter, r *http.Request) { trainCount := trainingCountDetectorSamples(detectorTrainImages, detectorTrainLabels) valCount := trainingCountDetectorSamples(detectorValImages, detectorValLabels) - if !fileExistsNonEmpty(detectorDatasetYAML) { - trainingWriteError( - w, - http.StatusBadRequest, - "YOLO dataset.yaml fehlt oder ist leer. Bitte Detector-Ordner/Dataset prüfen.", - ) - return - } - - if trainCount < minDetectorTrainCount || valCount < minDetectorValCount { - trainingWriteError( - w, - http.StatusBadRequest, - fmt.Sprintf( - "Zu wenige YOLO-Box-Labels. Benötigt: mindestens %d Train und %d Val. Aktuell: Train=%d, Val=%d. Feedback gesamt: %d.", - minDetectorTrainCount, - minDetectorValCount, - trainCount, - valCount, - feedbackCount, - ), - ) - return - } - trainingSetJobStatus(func(s *TrainingJobStatus) { *s = TrainingJobStatus{ Running: true, Progress: 5, - Step: "YOLO-Detector-Training wird vorbereitet…", + Step: "Training wird vorbereitet…", StartedAt: time.Now().UTC().Format(time.RFC3339), } }) @@ -523,17 +519,17 @@ func trainingTrainHandler(w http.ResponseWriter, r *http.Request) { trainingWriteJSON(w, http.StatusAccepted, map[string]any{ "ok": true, - "message": "YOLO-Detector-Training gestartet.", + "message": "Training gestartet.", "training": trainingGetJobStatus(), "detector": map[string]any{ - "trainCount": trainCount, - "valCount": valCount, - "requiredTrain": minDetectorTrainCount, - "requiredVal": minDetectorValCount, - "datasetYAML": detectorDatasetYAML, - "usesSceneKNN": false, - "usesResNet18KNN": false, - "source": "yolo_detector", + "trainCount": trainCount, + "valCount": valCount, + "requiredTrain": minDetectorTrainCount, + "requiredVal": minDetectorValCount, + "datasetYAML": detectorDatasetYAML, + "usesSceneCLIP": true, + "usesSceneKNN": true, + "source": "yolo_detector+scene_position_clip", }, }) } @@ -541,9 +537,50 @@ func trainingTrainHandler(w http.ResponseWriter, r *http.Request) { func trainingRunJob(root string, count int) { python := trainingPythonExe() + cleanOutput := func(text string) string { + out := strings.TrimSpace(text) + if len(out) > 1500 { + out = out[:1500] + "…" + } + return out + } + trainingSetJobStatus(func(s *TrainingJobStatus) { - s.Progress = 20 - s.Step = "Detector-Daten werden geprüft…" + s.Progress = 10 + s.Step = "CLIP-Scene-Positionsmodell wird trainiert…" + }) + + sceneStatus := "skipped" + sceneOutput := "" + + sceneScript := trainingScriptPath("train_scene_model.py") + sceneOut, sceneErr := trainingRunCommand( + python, + sceneScript, + "--root", root, + ) + + sceneOutput = sceneOut + sceneOutputClean := cleanOutput(sceneOutput) + + if sceneErr != nil { + sceneStatus = "failed" + + fmt.Println("⚠️ scene position training failed:", sceneErr) + if sceneOutputClean != "" { + fmt.Println("⚠️ scene position output:", sceneOutputClean) + } + } else { + sceneStatus = "trained" + + if sceneOutputClean != "" { + fmt.Println("✅ scene position training:", sceneOutputClean) + } + } + + trainingSetJobStatus(func(s *TrainingJobStatus) { + s.Progress = 45 + s.Step = "Object Detector-Daten werden geprüft…" }) detectorOutput := "" @@ -562,14 +599,19 @@ func trainingRunJob(root string, count int) { trainCount := trainingCountDetectorSamples(detectorTrainImages, detectorTrainLabels) valCount := trainingCountDetectorSamples(detectorValImages, detectorValLabels) - fmt.Printf("🔎 detector data: train=%d val=%d yaml=%v\n", trainCount, valCount, fileExistsNonEmpty(detectorDatasetYAML)) + fmt.Printf( + "🔎 detector data: train=%d val=%d yaml=%v\n", + trainCount, + valCount, + fileExistsNonEmpty(detectorDatasetYAML), + ) if fileExistsNonEmpty(detectorDatasetYAML) && trainCount >= minDetectorTrainCount && valCount >= minDetectorValCount { trainingSetJobStatus(func(s *TrainingJobStatus) { - s.Progress = 35 - s.Step = "YOLO-Detector wird trainiert…" + s.Progress = 60 + s.Step = "Object Detector wird trainiert…" }) detectorScript := trainingScriptPath("train_detector_model.py") @@ -578,22 +620,31 @@ func trainingRunJob(root string, count int) { detectorScript, "--root", root, "--base", "yolo11n.pt", - "--epochs", "60", + "--epochs", strconv.Itoa(trainingDetectorEpochs()), "--imgsz", "640", ) detectorOutput = detectorOut + detectorOutputClean := cleanOutput(detectorOutput) if detectorErr != nil { detectorStatus = "failed" - fmt.Println("⚠️ detector training failed:", detectorErr, detectorOutput) + + fmt.Println("⚠️ detector training failed:", detectorErr) + if detectorOutputClean != "" { + fmt.Println("⚠️ detector output:", detectorOutputClean) + } } else { detectorStatus = "trained" + + if detectorOutputClean != "" { + fmt.Println("✅ detector training:", detectorOutputClean) + } } } else { detectorStatus = "skipped_no_detector_data" detectorOutput = fmt.Sprintf( - "Detector übersprungen: zu wenige YOLO-Box-Labels. Train=%d, Val=%d. Benötigt: mindestens %d Train und %d Val.", + "Object Detector übersprungen: zu wenige Box-Labels. Train=%d, Val=%d. Benötigt: mindestens %d Train und %d Val.", trainCount, valCount, minDetectorTrainCount, @@ -603,23 +654,73 @@ func trainingRunJob(root string, count int) { fmt.Println("⚠️", detectorOutput) } + detectorOutputClean := cleanOutput(detectorOutput) + message := "Training abgeschlossen." - if detectorStatus == "trained" { - message = "Training abgeschlossen. YOLO-Detector wurde trainiert." + errorParts := []string{} + + if sceneStatus == "failed" { + if sceneOutputClean != "" { + errorParts = append(errorParts, "Scene-Positionsmodell fehlgeschlagen: "+sceneOutputClean) + } else { + errorParts = append(errorParts, "Scene-Positionsmodell fehlgeschlagen. Details stehen in der Backend-Konsole.") + } } + if detectorStatus == "failed" { - message = "YOLO-Detector-Training ist fehlgeschlagen." + if detectorOutputClean != "" { + errorParts = append(errorParts, "Object Detector fehlgeschlagen: "+detectorOutputClean) + } else { + errorParts = append(errorParts, "Object Detector fehlgeschlagen. Details stehen in der Backend-Konsole.") + } } - if detectorStatus == "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." + if detectorOutputClean != "" { + message += " Grund: " + detectorOutputClean + } + + case sceneStatus == "failed" && detectorStatus == "trained": + message = "Object Detector wurde trainiert. Scene-Positionsmodell ist fehlgeschlagen." + if sceneOutputClean != "" { + message += " Grund: " + sceneOutputClean + } + + case sceneStatus == "failed" && detectorStatus == "skipped_no_detector_data": + message = "Scene-Positionsmodell ist fehlgeschlagen. " + detectorOutput + if sceneOutputClean != "" { + message += " Scene-Grund: " + sceneOutputClean + } + + case sceneStatus == "failed" && detectorStatus == "failed": + message = "Scene-Positionsmodell und Object Detector sind fehlgeschlagen." + + default: + message = "Training abgeschlossen, aber kein Modell wurde erfolgreich trainiert." + if sceneOutputClean != "" { + message += " Scene-Ausgabe: " + sceneOutputClean + } + if detectorOutputClean != "" { + message += " Detector-Ausgabe: " + detectorOutputClean + } } + errorText := strings.Join(errorParts, " ") + trainingSetJobStatus(func(s *TrainingJobStatus) { s.Running = false s.Progress = 100 s.Step = "Training abgeschlossen." s.Message = message - s.Error = "" + s.Error = errorText s.FinishedAt = time.Now().UTC().Format(time.RFC3339) }) } @@ -641,8 +742,9 @@ func trainingStatusHandler(w http.ResponseWriter, r *http.Request) { return } - // Optional, aber praktisch: Status zeigt dann sofort valCount > 0, - // falls mindestens genug train-Daten vorhanden sind. + // Praktisch für kleine Datensätze: + // Wenn genug Train-Daten existieren, aber noch zu wenig Val-Daten, + // werden ein paar Train-Samples nach Val kopiert. if err := trainingEnsureDetectorValidationSample(root); err != nil { fmt.Println("⚠️ detector val sample ensure failed:", err) } @@ -659,6 +761,12 @@ func trainingStatusHandler(w http.ResponseWriter, r *http.Request) { 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) @@ -667,21 +775,38 @@ func trainingStatusHandler(w http.ResponseWriter, r *http.Request) { 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 trainiert nur BodyParts, Objects, Clothing und deren Boxen. + // - CLIP + Logistic Regression/KNN trainiert die Sexposition. + // - Personen/Gender werden manuell korrigiert und nicht per YOLO erkannt. trainingWriteJSON(w, http.StatusOK, map[string]any{ "ok": true, "feedbackCount": feedbackCount, "requiredCount": minTrainingFeedbackCount, - - // Für YOLO-only ist canTrain jetzt bewusst an Box-Labels gekoppelt, - // nicht mehr nur an feedback.jsonl. - "canTrain": detectorDataReady, + "canTrain": canTrain, "training": job, + "detector": map[string]any{ "source": "yolo_detector", "usesSceneKNN": false, "usesResNet18KNN": false, + "detectsPeople": false, + "detectsGender": false, + "detectsBodyParts": true, + "detectsObjects": true, + "detectsClothing": true, + "detectsBoxes": true, + "trainCount": trainCount, "valCount": valCount, "requiredTrain": minDetectorTrainCount, @@ -689,15 +814,81 @@ func trainingStatusHandler(w http.ResponseWriter, r *http.Request) { "datasetReady": datasetReady, "datasetYAML": detectorDatasetYAML, - - "dataReady": detectorDataReady, + "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": "manual", + "genderSource": "manual", + "bodyPartsSource": "yolo_detector", + "objectsSource": "yolo_detector", + "clothingSource": "yolo_detector", + "boxesSource": "yolo_detector", + + "sexPositionSource": "scene_position_clip_lr_or_knn", + + "usesSceneKNNForDetection": false, + "usesYOLOForDetection": true, + }, }) } +func trainingRecognitionEnabled() bool { + return getSettings().TrainingRecognitionEnabled +} + +func trainingDetectorEpochs() int { + epochs := getSettings().TrainingDetectorEpochs + + if epochs < 1 { + return 60 + } + + if epochs > 300 { + return 300 + } + + return epochs +} + func trainingDeleteAllHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodDelete && r.Method != http.MethodPost { trainingWriteError(w, http.StatusMethodNotAllowed, "method not allowed") @@ -969,24 +1160,145 @@ func trainingExtractFrame(videoPath string, framePath string, second float64) er } func trainingPredictFrame(framePath string) TrainingPrediction { + settings := getSettings() + if !settings.TrainingRecognitionEnabled { + return trainingEmptyPrediction("recognition_disabled") + } + root, err := trainingRootDir() if err != nil { fmt.Println("⚠️ training predict root error:", err) return trainingEmptyPrediction("root_error") } + // 1) YOLO erkennt Boxen, Personen, Körperteile, Gegenstände, Kleidung. det := trainingPredictDetector(root, framePath) - pred := trainingPredictionFromDetector(det) - fmt.Println("✅ training predict result") - fmt.Println(" modelAvailable:", pred.ModelAvailable) - fmt.Println(" source:", pred.Source) - fmt.Println(" sexPosition:", pred.SexPosition) - fmt.Println(" bodyParts:", len(pred.BodyPartsPresent)) - fmt.Println(" objects:", len(pred.ObjectsPresent)) - fmt.Println(" clothing:", len(pred.ClothingPresent)) - fmt.Println(" boxes:", len(pred.Boxes)) + // 2) Scene-KNN erkennt ausschließlich die Sexposition. + scene := trainingPredictScenePosition(root, framePath) + pred = trainingApplyScenePosition(pred, scene) + + return pred +} + +func trainingPredictScenePosition(root string, framePath string) TrainingScenePositionPrediction { + python := trainingPythonExe() + script := trainingScriptPath("predict_scene_model.py") + + lrPath := filepath.Join(root, "model", "scene_clip_lr.joblib") + knnPath := filepath.Join(root, "model", "scene_clip_knn.joblib") + + if !fileExistsNonEmpty(lrPath) && !fileExistsNonEmpty(knnPath) { + return TrainingScenePositionPrediction{ + Available: false, + Source: "scene_position_missing", + SexPosition: "unknown", + SexPositionScore: 0, + } + } + + cmd := exec.Command( + python, + script, + "--root", root, + "--image", framePath, + ) + + cmd.SysProcAttr = &syscall.SysProcAttr{ + HideWindow: true, + CreationFlags: 0x08000000, + } + + var stdout strings.Builder + var stderr strings.Builder + + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + + outText := strings.TrimSpace(stdout.String()) + errText := strings.TrimSpace(stderr.String()) + + if err != nil { + fmt.Println("⚠️ scene position predict failed:", err) + fmt.Println(" stdout:", outText) + fmt.Println(" stderr:", errText) + + return TrainingScenePositionPrediction{ + Available: false, + Source: "scene_position_failed", + SexPosition: "unknown", + SexPositionScore: 0, + } + } + + if outText == "" { + fmt.Println("⚠️ scene position predict empty stdout") + + return TrainingScenePositionPrediction{ + Available: false, + Source: "scene_position_empty", + SexPosition: "unknown", + SexPositionScore: 0, + } + } + + var scenePred TrainingPrediction + if err := json.Unmarshal([]byte(outText), &scenePred); err != nil { + fmt.Println("⚠️ scene position json failed:", err) + fmt.Println(" stdout:", outText) + + return TrainingScenePositionPrediction{ + Available: false, + Source: "scene_position_json_failed", + SexPosition: "unknown", + SexPositionScore: 0, + } + } + + sexPosition := strings.TrimSpace(scenePred.SexPosition) + if sexPosition == "" { + sexPosition = "unknown" + } + + return TrainingScenePositionPrediction{ + Available: scenePred.ModelAvailable, + Source: scenePred.Source, + SexPosition: sexPosition, + SexPositionScore: scenePred.SexPositionScore, + } +} + +func trainingApplyScenePosition( + pred TrainingPrediction, + scene TrainingScenePositionPrediction, +) TrainingPrediction { + if pred.SexPosition == "" { + pred.SexPosition = "unknown" + } + + if scene.Available { + sexPosition := strings.TrimSpace(scene.SexPosition) + if sexPosition == "" { + sexPosition = "unknown" + } + + pred.SexPosition = sexPosition + pred.SexPositionScore = scene.SexPositionScore + pred.ModelAvailable = pred.ModelAvailable || scene.Available + + if pred.Source == "" { + pred.Source = scene.Source + } else if !strings.Contains(pred.Source, scene.Source) { + pred.Source = pred.Source + "+" + scene.Source + } + } + + if pred.SexPosition == "" { + pred.SexPosition = "unknown" + } return pred } @@ -1026,10 +1338,37 @@ func trainingPredictionFromDetector(det TrainingDetectorPrediction) TrainingPred return pred } + allowed := map[string]bool{} + for _, label := range grouped.BodyParts { + allowed[strings.TrimSpace(label)] = true + } + for _, label := range grouped.Objects { + allowed[strings.TrimSpace(label)] = true + } + for _, label := range grouped.Clothing { + allowed[strings.TrimSpace(label)] = true + } + + filteredBoxes := []TrainingBox{} + for _, box := range boxes { + label := strings.TrimSpace(box.Label) + if allowed[label] { + filteredBoxes = append(filteredBoxes, box) + } + } + + boxes = filteredBoxes + pred.Boxes = boxes + pred.BodyPartsPresent = trainingScoredLabelsFromDetectorBoxes(boxes, grouped.BodyParts) pred.ObjectsPresent = trainingScoredLabelsFromDetectorBoxes(boxes, grouped.Objects) pred.ClothingPresent = trainingScoredLabelsFromDetectorBoxes(boxes, grouped.Clothing) + pred.UnknownCount = 0 + pred.PeopleCount = 0 + pred.MaleCount = 0 + pred.FemaleCount = 0 + return pred } @@ -1039,14 +1378,6 @@ func trainingPredictDetector(root string, framePath string) TrainingDetectorPred modelPath := filepath.Join(root, "detector", "model", "best.pt") - fmt.Println("🔎 detector predict") - fmt.Println(" python:", python) - fmt.Println(" root:", root) - fmt.Println(" script:", script) - fmt.Println(" image:", framePath) - fmt.Println(" model:", modelPath) - fmt.Println(" modelExists:", fileExistsNonEmpty(modelPath)) - if !fileExistsNonEmpty(modelPath) { return TrainingDetectorPrediction{ Available: false, @@ -1126,12 +1457,6 @@ func trainingPredictDetector(root string, framePath string) TrainingDetectorPred det.Source = "yolo_detector" } - fmt.Println("✅ detector predict result") - fmt.Println(" conf:", conf) - fmt.Println(" available:", det.Available) - fmt.Println(" source:", det.Source) - fmt.Println(" boxes:", len(det.Boxes)) - best = det if len(det.Boxes) > 0 { @@ -1215,7 +1540,7 @@ func trainingApplyDetectorToPrediction(pred TrainingPrediction, det TrainingDete } // Wichtig: - // Ab jetzt kommen diese drei Bereiche ausschließlich vom YOLO-Detector. + // Ab jetzt kommen diese drei Bereiche ausschließlich vom Object Detector. // Kein Scene-KNN-Fallback, damit keine Labels ohne Box angezeigt werden. pred.BodyPartsPresent = trainingScoredLabelsFromDetectorBoxes(boxes, grouped.BodyParts) pred.ObjectsPresent = trainingScoredLabelsFromDetectorBoxes(boxes, grouped.Objects) diff --git a/backend/training_label_loader.go b/backend/training_label_loader.go index afa0b6f..6fada92 100644 --- a/backend/training_label_loader.go +++ b/backend/training_label_loader.go @@ -11,6 +11,7 @@ import ( ) type TrainingGroupedLabels struct { + People []string `json:"people"` SexPositions []string `json:"sexPositions"` BodyParts []string `json:"bodyParts"` Objects []string `json:"objects"` @@ -57,6 +58,7 @@ func trainingGroupedLabels() (TrainingGroupedLabels, error) { return TrainingGroupedLabels{}, fmt.Errorf("detection_labels.json ist ungültig: %w", err) } + grouped.People = uniqueNonEmptyLabels(grouped.People) grouped.SexPositions = uniqueNonEmptyLabels(grouped.SexPositions) grouped.BodyParts = uniqueNonEmptyLabels(grouped.BodyParts) grouped.Objects = uniqueNonEmptyLabels(grouped.Objects) @@ -70,6 +72,10 @@ func trainingGroupedLabels() (TrainingGroupedLabels, error) { return TrainingGroupedLabels{}, fmt.Errorf("detection_labels.json enthält keine Detection-Labels") } + if len(grouped.People)+len(grouped.BodyParts)+len(grouped.Objects)+len(grouped.Clothing) == 0 { + return TrainingGroupedLabels{}, fmt.Errorf("detection_labels.json enthält keine Detection-Labels") + } + return grouped, nil } @@ -80,6 +86,8 @@ func trainingDetectorLabels() ([]string, error) { } labels := []string{} + + // Bestehende Reihenfolge beibehalten, damit alte Class-IDs stabil bleiben. labels = append(labels, grouped.BodyParts...) labels = append(labels, grouped.Objects...) labels = append(labels, grouped.Clothing...) @@ -141,6 +149,7 @@ func defaultTrainingLabelsFromJSON() TrainingLabels { } return TrainingLabels{ + People: grouped.People, SexPositions: grouped.SexPositions, BodyParts: grouped.BodyParts, Objects: grouped.Objects, diff --git a/frontend/src/components/ui/Icons.tsx b/frontend/src/components/ui/Icons.tsx index 1f04b5c..0498f69 100644 --- a/frontend/src/components/ui/Icons.tsx +++ b/frontend/src/components/ui/Icons.tsx @@ -172,6 +172,431 @@ export function FaceIcon(props: IconProps) { ) } +export function FemalePersonIcon(props: IconProps) { + return ( + + + + + + + + + + ) +} + +export function MalePersonIcon(props: IconProps) { + return ( + + + + + + + + + + ) +} + +export function ClothingIcon(props: IconProps) { + return ( + + + + + + ) +} + +export function CropTopIcon(props: IconProps) { + return ( + + {/* Träger */} + + + + + {/* kurze Top-Form */} + + + + + {/* Crop-Kante */} + + + {/* leichte Stofffalten */} + + + + + ) +} + +export function BraIcon(props: IconProps) { + return ( + + + + + + + + + ) +} + +export function UnderwearIcon(props: IconProps) { + return ( + + + + + + ) +} + +export function PantsIcon(props: IconProps) { + return ( + + + + + + + + ) +} + +export function DressIcon(props: IconProps) { + return ( + + + + + + + ) +} + +export function ShoesIcon(props: IconProps) { + return ( + + + + + + + ) +} + +export function SockIcon(props: IconProps) { + return ( + + + + + + ) +} + +export function CondomIcon(props: IconProps) { + return ( + + + + + + + ) +} + +export function ToyIcon(props: IconProps) { + return ( + + + + + + + + ) +} + +export function BottleIcon(props: IconProps) { + return ( + + + + + + ) +} + +export function BedIcon(props: IconProps) { + return ( + + + + + + + + + ) +} + +export function ChairIcon(props: IconProps) { + return ( + + + + + + + + ) +} + +export function PhoneIcon(props: IconProps) { + return ( + + + + + + ) +} + +export function CameraIcon(props: IconProps) { + return ( + + + + + + ) +} + +export function RopeIcon(props: IconProps) { + return ( + + + + + + + ) +} + +export function HandIcon(props: IconProps) { + return ( + + + + + + ) +} + +export function MouthIcon(props: IconProps) { + return ( + + + + + + ) +} + +export function TongueIcon(props: IconProps) { + return ( + + {/* äußere Lippenkontur */} + + + + + {/* innere Lippen / Mundöffnung */} + + + + + {/* Zunge */} + + + + + ) +} + +export function BackIcon(props: IconProps) { + return ( + + {/* äußere Rücken-/Schulterkontur links */} + + + {/* äußere Rücken-/Schulterkontur rechts */} + + + {/* obere innere Schulterlinien */} + + + + {/* innere Rückenlinien */} + + + + {/* Wirbelsäulenlinie */} + + + ) +} + +export function BathIcon(props: IconProps) { + return ( + + + + + + + + + + + + ) +} + +export function ShowerIcon(props: IconProps) { + return ( + + + + + + + + + + + + + ) +} + +export function BlindfoldIcon(props: IconProps) { + return ( + + + + + + + + + + ) +} + +export function CollarIcon(props: IconProps) { + return ( + + + + + + + + ) +} + +export function ButtPlugIcon(props: IconProps) { + return ( + + + + + + + + + ) +} + +export function StrapOnIcon(props: IconProps) { + return ( + + + + + + + + + ) +} + +export function TowelIcon(props: IconProps) { + return ( + + {/* äußere Handtuchform */} + + + {/* obere innere Faltkante */} + + + {/* gefaltete Lagen unten */} + + + + {/* kleiner Aufhänger / untere Lasche */} + + + ) +} + +export function BikiniIcon(props: IconProps) { + return ( + + + + + + + + ) +} + +export function FishnetIcon(props: IconProps) { + return ( + + + + + + + + + + + ) +} + +export function HotpantsIcon(props: IconProps) { + return ( + + + + + + + + ) +} + export function UnknownContentIcon(props: IconProps) { return ( @@ -193,43 +618,71 @@ type SegmentLabelMeta = { } const SEGMENT_LABEL_META: SegmentLabelMeta[] = [ + // People + { + match: ['person_female', 'female_person'], + text: 'Weibliche Person', + icon: FemalePersonIcon, + }, + { + match: ['person_male', 'male_person'], + text: 'Männliche Person', + icon: MalePersonIcon, + }, + { + match: ['person_unknown', 'person'], + text: 'Person', + icon: UnknownContentIcon, + }, + + // Bodyparts { match: ['anus_exposed', 'anus'], text: 'Anus', icon: AnusIcon, }, { - match: ['female_genitalia_exposed', 'vulva_exposed', 'labia_exposed'], - text: 'Vagina', - icon: FemaleGenitaliaIcon, - }, - { - match: ['male_genitalia_exposed', 'penis_exposed'], - text: 'Penis', - icon: MaleGenitaliaIcon, - }, - { - match: ['female_breast_exposed', 'breast_exposed'], - text: 'Brüste', - icon: FemaleBreastIcon, - }, - { - match: ['male_breast_exposed'], - text: 'Männliche Brust', - icon: MaleBreastIcon, - }, - { - match: ['buttocks_exposed', 'buttocks'], + match: ['ass', 'buttocks_exposed', 'buttocks', 'bottom'], text: 'Hintern', icon: ButtocksIcon, }, { - match: ['belly_exposed', 'stomach_exposed', 'abdomen_exposed', 'navel_exposed'], + match: ['back'], + text: 'Rücken', + icon: BackIcon, + }, + { + match: ['breasts', 'female_breast_exposed', 'breast_exposed', 'breasts_exposed', 'female_breast'], + text: 'Brüste', + icon: FemaleBreastIcon, + }, + { + match: ['penis', 'male_genitalia_exposed', 'penis_exposed'], + text: 'Penis', + icon: MaleGenitaliaIcon, + }, + { + match: ['tongue'], + text: 'Zunge', + icon: TongueIcon, + }, + { + match: ['mouth', 'lips'], + text: 'Mund', + icon: MouthIcon, + }, + { + match: ['pussy', 'female_genitalia_exposed', 'vulva_exposed', 'labia_exposed', 'vagina'], + text: 'Vagina', + icon: FemaleGenitaliaIcon, + }, + { + match: ['belly_exposed', 'stomach_exposed', 'abdomen_exposed', 'navel_exposed', 'belly', 'stomach', 'abdomen', 'navel'], text: 'Bauch', icon: BellyIcon, }, { - match: ['feet_exposed', 'foot_exposed'], + match: ['feet_exposed', 'foot_exposed', 'feet', 'foot'], text: 'Füße', icon: FeetIcon, }, @@ -238,18 +691,214 @@ const SEGMENT_LABEL_META: SegmentLabelMeta[] = [ text: 'Gesicht', icon: FaceIcon, }, + { + match: ['hand', 'hands', 'finger', 'fingers'], + text: 'Hand', + icon: HandIcon, + }, + + // Objects + { + match: ['bath', 'bathtub', 'tub'], + text: 'Badewanne', + icon: BathIcon, + }, + { + match: ['blindfold'], + text: 'Augenbinde', + icon: BlindfoldIcon, + }, + { + match: ['buttplug', 'butt_plug'], + text: 'Buttplug', + icon: ButtPlugIcon, + }, + { + match: ['collar', 'choker'], + text: 'Halsband / Choker', + icon: CollarIcon, + }, + { + match: ['dildo'], + text: 'Dildo', + icon: ToyIcon, + }, + { + match: ['handcuffs', 'cuffs'], + text: 'Handschellen', + icon: RopeIcon, + }, + { + match: ['rope', 'tie', 'restraint', 'restraints'], + text: 'Seil', + icon: RopeIcon, + }, + { + match: ['shower'], + text: 'Dusche', + icon: ShowerIcon, + }, + { + match: ['strapon', 'strap_on', 'strap-on'], + text: 'Strap-on', + icon: StrapOnIcon, + }, + { + match: ['towel'], + text: 'Handtuch', + icon: TowelIcon, + }, + { + match: ['vibrator'], + text: 'Vibrator', + icon: ToyIcon, + }, + { + match: ['toy', 'plug'], + text: 'Toy', + icon: ToyIcon, + }, + { + match: ['condom'], + text: 'Kondom', + icon: CondomIcon, + }, + { + match: ['bottle', 'lotion', 'lubricant', 'lube'], + text: 'Flasche', + icon: BottleIcon, + }, + { + match: ['bed', 'mattress', 'pillow', 'blanket', 'sheet', 'sheets'], + text: 'Bett', + icon: BedIcon, + }, + { + match: ['chair', 'sofa', 'couch', 'bench', 'stool'], + text: 'Möbel', + icon: ChairIcon, + }, + { + match: ['phone', 'smartphone', 'mobile'], + text: 'Handy', + icon: PhoneIcon, + }, + { + match: ['camera', 'webcam'], + text: 'Kamera', + icon: CameraIcon, + }, + + // Clothing + { + match: ['bikini'], + text: 'Bikini', + icon: BikiniIcon, + }, + { + match: ['bra', 'brassiere'], + text: 'BH', + icon: BraIcon, + }, + { + match: ['dress'], + text: 'Kleid', + icon: DressIcon, + }, + { + match: ['fishnet', 'fishnets'], + text: 'Fishnet', + icon: FishnetIcon, + }, + { + match: ['heels', 'heel', 'high_heels', 'high-heels'], + text: 'High Heels', + icon: ShoesIcon, + }, + { + match: ['hotpants', 'shorts'], + text: 'Hotpants', + icon: HotpantsIcon, + }, + { + match: ['lingerie'], + text: 'Lingerie', + icon: UnderwearIcon, + }, + { + match: ['panties', 'underwear', 'briefs', 'boxers', 'thong'], + text: 'Panties', + icon: UnderwearIcon, + }, + { + match: ['shirt', 'tshirt', 't-shirt', 'blouse', 'hoodie', 'sweater', 'jacket', 'coat'], + text: 'Shirt', + icon: ClothingIcon, + }, + { + match: ['skirt'], + text: 'Rock', + icon: DressIcon, + }, + { + match: ['stockings', 'stocking', 'socks', 'sock'], + text: 'Stockings', + icon: SockIcon, + }, + { + match: ['top', 'crop_top', 'crop-top', 'croptop'], + text: 'Crop Top', + icon: CropTopIcon, + }, + { + match: ['pants', 'jeans', 'trousers', 'leggings'], + text: 'Hose', + icon: PantsIcon, + }, + { + match: ['shoe', 'shoes', 'boot', 'boots', 'sneaker', 'sneakers'], + text: 'Schuhe', + icon: ShoesIcon, + }, + { + match: ['clothing', 'clothes', 'garment', 'outfit'], + text: 'Kleidung', + icon: ClothingIcon, + }, ] function normalizeSegmentLabel(label?: string): string { return String(label || '').trim().toLowerCase() } +function normalizeLabelKey(value?: string): string { + return String(value || '') + .trim() + .toLowerCase() + .replaceAll('-', '_') + .replaceAll(' ', '_') +} + +function labelMatches(normalizedLabel: string, key: string): boolean { + const label = normalizeLabelKey(normalizedLabel) + const matchKey = normalizeLabelKey(key) + + if (!label || !matchKey) return false + + return ( + label === matchKey || + label.startsWith(`${matchKey}_`) || + label.endsWith(`_${matchKey}`) || + label.includes(`_${matchKey}_`) + ) +} + function findSegmentLabelMeta(label?: string): SegmentLabelMeta | null { const normalized = normalizeSegmentLabel(label) if (!normalized) return null for (const item of SEGMENT_LABEL_META) { - if (item.match.some((key) => normalized === key || normalized.includes(key))) { + if (item.match.some((key) => labelMatches(normalized, key))) { return item } } @@ -262,11 +911,179 @@ function findSegmentLabelMeta(label?: string): SegmentLabelMeta | null { } } + if (normalized.includes('back')) { + return { match: [], text: 'Rücken', icon: BackIcon } + } + + if (normalized.includes('ass') || normalized.includes('butt') || normalized.includes('bottom')) { + return { match: [], text: 'Hintern', icon: ButtocksIcon } + } + + if ( + normalized.includes('pussy') || + normalized.includes('vagina') || + normalized.includes('vulva') || + normalized.includes('labia') + ) { + return { match: [], text: 'Vagina', icon: FemaleGenitaliaIcon } + } + + if (normalized.includes('penis')) { + return { match: [], text: 'Penis', icon: MaleGenitaliaIcon } + } + + if (normalized.includes('breast')) { + return { match: [], text: 'Brüste', icon: FemaleBreastIcon } + } + + if (normalized.includes('tongue')) { + return { match: [], text: 'Zunge', icon: TongueIcon } + } + + if (normalized.includes('mouth') || normalized.includes('lips')) { + return { match: [], text: 'Mund', icon: MouthIcon } + } + + if (normalized.includes('bath') || normalized.includes('tub')) { + return { match: [], text: 'Badewanne', icon: BathIcon } + } + + if (normalized.includes('shower')) { + return { match: [], text: 'Dusche', icon: ShowerIcon } + } + + if (normalized.includes('blindfold')) { + return { match: [], text: 'Augenbinde', icon: BlindfoldIcon } + } + + if (normalized.includes('collar')) { + return { match: [], text: 'Halsband', icon: CollarIcon } + } + + if (normalized.includes('buttplug') || normalized.includes('butt_plug')) { + return { match: [], text: 'Buttplug', icon: ButtPlugIcon } + } + + if ( + normalized.includes('strapon') || + normalized.includes('strap_on') || + normalized.includes('strap-on') + ) { + return { match: [], text: 'Strap-on', icon: StrapOnIcon } + } + + if (normalized.includes('dildo')) { + return { match: [], text: 'Dildo', icon: ToyIcon } + } + + if (normalized.includes('vibrator')) { + return { match: [], text: 'Vibrator', icon: ToyIcon } + } + + if (normalized.includes('handcuff') || normalized.includes('cuff')) { + return { match: [], text: 'Handschellen', icon: RopeIcon } + } + + if (normalized.includes('rope') || normalized.includes('restraint') || normalized.includes('tie')) { + return { match: [], text: 'Seil', icon: RopeIcon } + } + + if (normalized.includes('towel')) { + return { match: [], text: 'Handtuch', icon: TowelIcon } + } + + if (normalized.includes('bikini')) { + return { match: [], text: 'Bikini', icon: BikiniIcon } + } + + if (normalized.includes('bra')) { + return { match: [], text: 'BH', icon: BraIcon } + } + + if (normalized.includes('dress')) { + return { match: [], text: 'Kleid', icon: DressIcon } + } + + if (normalized.includes('skirt')) { + return { match: [], text: 'Rock', icon: DressIcon } + } + + if (normalized.includes('fishnet')) { + return { match: [], text: 'Fishnet', icon: FishnetIcon } + } + + if (normalized.includes('heel')) { + return { match: [], text: 'High Heels', icon: ShoesIcon } + } + + if (normalized.includes('hotpants') || normalized.includes('shorts')) { + return { match: [], text: 'Hotpants', icon: HotpantsIcon } + } + + if ( + normalized.includes('lingerie') || + normalized.includes('panties') || + normalized.includes('underwear') || + normalized.includes('briefs') || + normalized.includes('thong') + ) { + return { match: [], text: 'Unterwäsche', icon: UnderwearIcon } + } + + if (normalized.includes('stocking') || normalized.includes('sock')) { + return { match: [], text: 'Stockings', icon: SockIcon } + } + + if ( + normalized === 'top' || + normalized.includes('crop_top') || + normalized.includes('crop-top') || + normalized.includes('croptop') + ) { + return { match: [], text: 'Crop Top', icon: CropTopIcon } + } + + if ( + normalized.includes('shirt') || + normalized.includes('tshirt') || + normalized.includes('t-shirt') || + normalized.includes('blouse') || + normalized.includes('hoodie') || + normalized.includes('sweater') || + normalized.includes('jacket') || + normalized.includes('coat') || + normalized.includes('clothing') + ) { + return { match: [], text: 'Oberteil', icon: ClothingIcon } + } + + if ( + normalized.includes('pants') || + normalized.includes('jeans') || + normalized.includes('trousers') || + normalized.includes('leggings') + ) { + return { match: [], text: 'Hose', icon: PantsIcon } + } + + if ( + normalized.includes('shoe') || + normalized.includes('boot') || + normalized.includes('sneaker') + ) { + return { match: [], text: 'Schuhe', icon: ShoesIcon } + } + if (normalized.includes('face')) { return { match: [], text: 'Gesicht', icon: FaceIcon } } - if (normalized.includes('belly') || normalized.includes('stomach') || normalized.includes('abdomen') || normalized.includes('navel')) { + if ( + normalized.includes('belly') || + normalized.includes('stomach') || + normalized.includes('abdomen') || + normalized.includes('navel') + ) { return { match: [], text: 'Bauch', icon: BellyIcon } } @@ -287,12 +1104,47 @@ function prettifyUnknownLabel(label?: string): string { return normalized .replaceAll('_', ' ') + .replace(/\bcrop top\b/g, 'Crop Top') + .replace(/\bcroptop\b/g, 'Crop Top') .replace(/\bmale\b/g, 'männlich') .replace(/\bfemale\b/g, 'weiblich') .replace(/\bgenitalia\b/g, 'Genitalien') + .replace(/\bbreasts\b/g, 'Brüste') .replace(/\bbreast\b/g, 'Brust') .replace(/\bbuttocks\b/g, 'Gesäß') + .replace(/\bass\b/g, 'Hintern') + .replace(/\bback\b/g, 'Rücken') .replace(/\banus\b/g, 'Anus') + .replace(/\bpussy\b/g, 'Vagina') + .replace(/\bpenis\b/g, 'Penis') + .replace(/\btongue\b/g, 'Zunge') + .replace(/\bbath\b/g, 'Badewanne') + .replace(/\bblindfold\b/g, 'Augenbinde') + .replace(/\bbuttplug\b/g, 'Buttplug') + .replace(/\bbutt plug\b/g, 'Buttplug') + .replace(/\bcollar\b/g, 'Halsband') + .replace(/\bdildo\b/g, 'Dildo') + .replace(/\bhandcuffs\b/g, 'Handschellen') + .replace(/\brope\b/g, 'Seil') + .replace(/\bshower\b/g, 'Dusche') + .replace(/\bstrapon\b/g, 'Strap-on') + .replace(/\bstrap on\b/g, 'Strap-on') + .replace(/\btowel\b/g, 'Handtuch') + .replace(/\bvibrator\b/g, 'Vibrator') + .replace(/\bbikini\b/g, 'Bikini') + .replace(/\bbra\b/g, 'BH') + .replace(/\bdress\b/g, 'Kleid') + .replace(/\bfishnet\b/g, 'Fishnet') + .replace(/\bheels\b/g, 'High Heels') + .replace(/\bheel\b/g, 'High Heel') + .replace(/\bhotpants\b/g, 'Hotpants') + .replace(/\blingerie\b/g, 'Lingerie') + .replace(/\bpanties\b/g, 'Panties') + .replace(/\bshirt\b/g, 'Shirt') + .replace(/\bskirt\b/g, 'Rock') + .replace(/\bstockings\b/g, 'Stockings') + .replace(/\bstocking\b/g, 'Stocking') + .replace(/\btop\b/g, 'Crop Top') .replace(/\bbelly\b/g, 'Bauch') .replace(/\bstomach\b/g, 'Bauch') .replace(/\babdomen\b/g, 'Bauch') diff --git a/frontend/src/components/ui/RecorderSettings.tsx b/frontend/src/components/ui/RecorderSettings.tsx index 56d0acb..70fd42d 100644 --- a/frontend/src/components/ui/RecorderSettings.tsx +++ b/frontend/src/components/ui/RecorderSettings.tsx @@ -36,6 +36,9 @@ type RecorderSettings = { generateAssetsTeaser?: boolean generateAssetsSprites?: boolean generateAssetsAnalyze?: boolean + + trainingRecognitionEnabled?: boolean + trainingDetectorEpochs?: number } type DiskStatus = { @@ -73,6 +76,9 @@ const DEFAULTS: RecorderSettings = { generateAssetsTeaser: true, generateAssetsSprites: true, generateAssetsAnalyze: true, + + trainingRecognitionEnabled: true, + trainingDetectorEpochs: 60, } type Props = { @@ -411,6 +417,11 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) { generateAssetsTeaser: (data as any).generateAssetsTeaser ?? DEFAULTS.generateAssetsTeaser, generateAssetsSprites: (data as any).generateAssetsSprites ?? DEFAULTS.generateAssetsSprites, generateAssetsAnalyze: (data as any).generateAssetsAnalyze ?? DEFAULTS.generateAssetsAnalyze, + + trainingRecognitionEnabled: + (data as any).trainingRecognitionEnabled ?? DEFAULTS.trainingRecognitionEnabled, + trainingDetectorEpochs: + (data as any).trainingDetectorEpochs ?? DEFAULTS.trainingDetectorEpochs, }) setLoadedDatabaseUrl(String((data as any).databaseUrl ?? '').trim()) }) @@ -707,6 +718,12 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) { const generateAssetsSprites = !!value.generateAssetsSprites const generateAssetsAnalyze = !!value.generateAssetsAnalyze + const trainingRecognitionEnabled = !!value.trainingRecognitionEnabled + const trainingDetectorEpochs = Math.max( + 1, + Math.min(300, Math.floor(Number(value.trainingDetectorEpochs ?? DEFAULTS.trainingDetectorEpochs ?? 60))) + ) + setSaving(true) try { const res = await fetch('/api/settings', { @@ -736,6 +753,8 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) { generateAssetsTeaser, generateAssetsSprites, generateAssetsAnalyze, + trainingRecognitionEnabled, + trainingDetectorEpochs, }), }) if (!res.ok) { @@ -1348,6 +1367,55 @@ export default function RecorderSettings({ onAssetsGenerated }: Props) { +
+ + setValue((v) => ({ + ...v, + trainingRecognitionEnabled: checked, + })) + } + label="Training-Erkennung aktivieren" + description="Wenn aktiv, werden neue Trainingsbilder automatisch mit YOLO und Scene-KNN analysiert. Wenn deaktiviert, kannst du weiterhin manuell korrigieren und Boxen zeichnen." + /> + +
+
+
+ Object Detection Epochs +
+
+ Maximale Trainingsdauer. 60 ist ein guter Standard. +
+
+ +
+
+ + setValue((v) => ({ + ...v, + trainingDetectorEpochs: Number(e.target.value || 60), + })) + } + className="h-9 w-32 rounded-md border border-gray-200 bg-white px-3 text-sm text-gray-900 shadow-sm + dark:border-white/10 dark:bg-gray-900 dark:text-gray-100" + /> + + + Epochs + +
+
+
+
+ setValue((v) => ({ ...v, blurPreviews: checked }))} diff --git a/frontend/src/components/ui/TrainingTab.tsx b/frontend/src/components/ui/TrainingTab.tsx index d7d8bda..66cab17 100644 --- a/frontend/src/components/ui/TrainingTab.tsx +++ b/frontend/src/components/ui/TrainingTab.tsx @@ -5,6 +5,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import Button from './Button' import LoadingSpinner from './LoadingSpinner' import { formatBytes, formatDuration } from './formatters' +import { TrashIcon } from '@heroicons/react/20/solid' +import { getSegmentLabelItem } from './Icons' type ScoredLabel = { label: string @@ -56,6 +58,7 @@ type TrainingSample = { } type TrainingLabels = { + people: string[] sexPositions: string[] bodyParts: string[] objects: string[] @@ -83,7 +86,33 @@ type TrainingBox = { h: number } +type BoxInteraction = + | { + type: 'move' + index: number + startX: number + startY: number + original: TrainingBox + } + | { + type: 'resize' + index: number + handle: 'nw' | 'ne' | 'sw' | 'se' + startX: number + startY: number + original: TrainingBox + } + +type MagnifierState = { + visible: boolean + clientX: number + clientY: number + imageX: number + imageY: number +} + const emptyLabels: TrainingLabels = { + people: [], sexPositions: ['unknown'], bodyParts: [], objects: [], @@ -95,6 +124,19 @@ function percent(v: number) { return `${Math.round(v * 100)}%` } +function normalizeMovedBox(box: TrainingBox): TrainingBox { + const w = clamp01(box.w) + const h = clamp01(box.h) + + return { + ...box, + x: Math.max(0, Math.min(1 - w, Number(box.x) || 0)), + y: Math.max(0, Math.min(1 - h, Number(box.y) || 0)), + w, + h, + } +} + function scoredLabelsText(items?: ScoredLabel[] | null) { const list = Array.isArray(items) ? items : [] @@ -127,7 +169,9 @@ function safeCount(value: unknown) { return Number.isFinite(n) && n > 0 ? Math.floor(n) : 0 } -function totalPeopleFromGenderCounts(state: Pick) { +function totalPeopleFromGenderCounts( + state: Pick +) { return safeCount(state.maleCount) + safeCount(state.femaleCount) } @@ -165,6 +209,19 @@ function uniqStrings(values: string[]) { return out } +function isPersonBoxLabel(label?: string) { + const normalized = String(label || '').trim().toLowerCase() + + return ( + normalized === 'person' || + normalized === 'person_male' || + normalized === 'person_female' || + normalized === 'person_unknown' || + normalized === 'male_person' || + normalized === 'female_person' + ) +} + function predictionToCorrection(sample: TrainingSample | null): CorrectionState { const p = sample?.prediction @@ -180,14 +237,17 @@ function predictionToCorrection(sample: TrainingSample | null): CorrectionState bodyPartsPresent: (p?.bodyPartsPresent ?? []).map((x) => x.label), objectsPresent: (p?.objectsPresent ?? []).map((x) => x.label), clothingPresent: (p?.clothingPresent ?? []).map((x) => x.label), - boxes: (p?.boxes ?? []).map((box) => ({ - label: String(box.label || '').trim(), - score: box.score, - x: clamp01(Number(box.x)), - y: clamp01(Number(box.y)), - w: clamp01(Number(box.w)), - h: clamp01(Number(box.h)), - })).filter((box) => box.label && box.w > 0 && box.h > 0), + boxes: (p?.boxes ?? []) + .map((box) => ({ + label: String(box.label || '').trim(), + score: box.score, + x: clamp01(Number(box.x)), + y: clamp01(Number(box.y)), + w: clamp01(Number(box.w)), + h: clamp01(Number(box.h)), + })) + .filter((box) => box.label && box.w > 0 && box.h > 0) + .filter((box) => !isPersonBoxLabel(box.label)), } } @@ -199,6 +259,28 @@ function applyBoxLabelToCorrection( const clean = String(label || '').trim() if (!clean) return state + if (labels.people.includes(clean)) { + if (clean === 'person_male') { + return { + ...state, + maleCount: state.maleCount + 1, + unknownCount: 0, + peopleCount: state.peopleCount + 1, + } + } + + if (clean === 'person_female') { + return { + ...state, + femaleCount: state.femaleCount + 1, + unknownCount: 0, + peopleCount: state.peopleCount + 1, + } + } + + return state + } + if (labels.bodyParts.includes(clean)) { return { ...state, @@ -229,6 +311,86 @@ function applyBoxLabelToCorrection( return state } +function removeBoxLabelFromCorrection( + state: CorrectionState, + label: string, + labels: TrainingLabels +): CorrectionState { + const clean = String(label || '').trim() + if (!clean) return state + + const remainingBoxesWithSameLabel = (state.boxes ?? []).some( + (box) => String(box.label || '').trim() === clean + ) + + // Badge nur abwählen, wenn keine weitere Box mit diesem Label existiert. + if (remainingBoxesWithSameLabel) return state + + if (labels.bodyParts.includes(clean)) { + return { + ...state, + bodyPartsPresent: state.bodyPartsPresent.filter((x) => x !== clean), + } + } + + if (labels.objects.includes(clean)) { + return { + ...state, + objectsPresent: state.objectsPresent.filter((x) => x !== clean), + } + } + + if (labels.clothing.includes(clean)) { + return { + ...state, + clothingPresent: state.clothingPresent.filter((x) => x !== clean), + } + } + + return state +} + +function removeBoxFromCorrection( + state: CorrectionState, + index: number, + labels: TrainingLabels +): CorrectionState { + const boxes = state.boxes ?? [] + const removed = boxes[index] + + if (!removed) return state + + const removedLabel = String(removed.label || '').trim() + + let next: CorrectionState = { + ...state, + boxes: boxes.filter((_, i) => i !== index), + } + + if (removedLabel === 'person_male') { + next = { + ...next, + maleCount: Math.max(0, safeCount(next.maleCount) - 1), + unknownCount: 0, + } + } + + if (removedLabel === 'person_female') { + next = { + ...next, + femaleCount: Math.max(0, safeCount(next.femaleCount) - 1), + unknownCount: 0, + } + } + + next = { + ...next, + peopleCount: safeCount(next.maleCount) + safeCount(next.femaleCount), + } + + return removeBoxLabelFromCorrection(next, removedLabel, labels) +} + function sortLabelList(values?: string[], opts?: { keepUnknownFirst?: boolean }) { const list = [...(values ?? [])].sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }) @@ -244,6 +406,7 @@ function sortLabelList(values?: string[], opts?: { keepUnknownFirst?: boolean }) function sortTrainingLabels(input: Partial | null | undefined): TrainingLabels { return { + people: sortLabelList(input?.people), sexPositions: sortLabelList(input?.sexPositions, { keepUnknownFirst: true }), bodyParts: sortLabelList(input?.bodyParts), objects: sortLabelList(input?.objects), @@ -302,6 +465,261 @@ function LoadingImageOverlay(props: { text?: string }) { ) } +function labelTileClass(active: boolean) { + return [ + 'group flex min-h-[74px] w-full flex-col items-center justify-center gap-1 rounded-xl px-2 py-2 text-center text-[10px] font-semibold leading-tight ring-1 transition', + 'focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-1 dark:focus:ring-offset-gray-900', + active + ? [ + 'bg-indigo-100 text-indigo-900 ring-2 ring-indigo-500 shadow-sm', + 'hover:bg-indigo-200', + 'dark:bg-indigo-500/30 dark:text-indigo-50 dark:ring-indigo-300/70', + 'dark:hover:bg-indigo-500/40', + ].join(' ') + : [ + 'bg-white text-gray-700 ring-gray-200 hover:bg-gray-50 hover:text-gray-900', + 'dark:bg-white/5 dark:text-gray-300 dark:ring-white/10 dark:hover:bg-white/10 dark:hover:text-white', + ].join(' '), + ].join(' ') +} + +function LabelToggleGrid(props: { + values: string[] + selected: string[] + onToggle: (value: string) => void +}) { + if (props.values.length === 0) { + return ( +
+ Keine Einträge verfügbar. +
+ ) + } + + return ( +
+ {props.values.map((value) => { + const active = props.selected.includes(value) + const item = getSegmentLabelItem(value) + const Icon = item.icon + + return ( + + ) + })} +
+ ) +} + +function DetectorBoxLabelSelect(props: { + values: string[] + value: string + disabled?: boolean + onChange: (value: string) => void +}) { + const [open, setOpen] = useState(false) + + const selectedItem = getSegmentLabelItem(props.value) + const SelectedIcon = selectedItem.icon + + useEffect(() => { + if (!open) return + + const onPointerDown = () => setOpen(false) + window.addEventListener('pointerdown', onPointerDown) + + return () => { + window.removeEventListener('pointerdown', onPointerDown) + } + }, [open]) + + if (props.values.length === 0) { + return ( + + ) + } + + return ( +
e.stopPropagation()} + > + + + {open ? ( +
+ {props.values.map((value) => { + const active = value === props.value + const item = getSegmentLabelItem(value) + const Icon = item.icon + + return ( + + ) + })} +
+ ) : null} +
+ ) +} + +function CollapsibleLabelSection(props: { + title: string + values: string[] + selected: string[] + expanded: boolean + onExpandedChange: (expanded: boolean) => void + onToggle: (value: string) => void +}) { + const activeCount = props.selected.length + const hasActiveItems = activeCount > 0 + const shown = props.expanded + + return ( +
+ + + {shown ? ( +
+ +
+ ) : null} +
+ ) +} + export default function TrainingTab() { const [labels, setLabels] = useState(emptyLabels) const [sample, setSample] = useState(null) @@ -320,21 +738,33 @@ export default function TrainingTab() { const imageBoxRef = useRef(null) const [drawingBox, setDrawingBox] = useState(null) + const [boxInteraction, setBoxInteraction] = useState(null) + const [touchMagnifier, setTouchMagnifier] = useState(null) const [boxLabel, setBoxLabel] = useState('') + const [imageReloadKey, setImageReloadKey] = useState(0) + const [expandedCorrectionSections, setExpandedCorrectionSections] = useState({ + bodyParts: false, + objects: false, + clothing: false, + }) const boxLabels = useMemo(() => { return uniqStrings([ + ...labels.people, ...labels.bodyParts, ...labels.objects, ...labels.clothing, ]).sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' })) - }, [labels.bodyParts, labels.objects, labels.clothing]) + }, [labels.people, labels.bodyParts, labels.objects, labels.clothing]) const correctionBoxes = correction.boxes ?? [] - const visibleBoxes = drawingBox - ? [...correctionBoxes, drawingBox] - : correctionBoxes + const visibleBoxes = [ + ...correctionBoxes.map((box, index) => ({ box, index, isDraft: false })), + ...(drawingBox + ? [{ box: drawingBox, index: -1, isDraft: true }] + : []), + ] const computedPeopleCount = useMemo( () => totalPeopleFromGenderCounts(correction), @@ -343,8 +773,9 @@ export default function TrainingTab() { const imageSrc = useMemo(() => { if (!sample?.frameUrl) return '' - return `${sample.frameUrl}&t=${encodeURIComponent(sample.sampleId)}` - }, [sample]) + + return `${sample.frameUrl}&t=${encodeURIComponent(sample.sampleId)}&r=${imageReloadKey}` + }, [sample, imageReloadKey]) const canStartTraining = Boolean(trainingStatus?.canTrain) const feedbackCount = trainingStatus?.feedbackCount ?? 0 @@ -383,9 +814,17 @@ export default function TrainingTab() { if (!res.ok) { throw new Error(data?.error || `HTTP ${res.status}`) } + + const nextCorrection = predictionToCorrection(data) setSample(data) - setCorrection(predictionToCorrection(data)) + setCorrection(nextCorrection) + + setExpandedCorrectionSections({ + bodyParts: nextCorrection.bodyPartsPresent.length > 0, + objects: nextCorrection.objectsPresent.length > 0, + clothing: nextCorrection.clothingPresent.length > 0, + }) } catch (e) { setError(e instanceof Error ? e.message : String(e)) } finally { @@ -393,6 +832,15 @@ export default function TrainingTab() { } }, []) + const reloadCurrentImage = useCallback(async () => { + setDrawingBox(null) + setBoxInteraction(null) + setTouchMagnifier(null) + + await loadNext() + setImageReloadKey((value) => value + 1) + }, [loadNext]) + const loadTrainingStatus = useCallback(async () => { const res = await fetch('/api/training/status', { cache: 'no-store' }) const data = await res.json().catch(() => null) @@ -539,7 +987,9 @@ export default function TrainingTab() { try { const correctionPayload: CorrectionState = { ...correction, - peopleCount: totalPeopleFromGenderCounts(correction), + peopleCount: + safeCount(correction.maleCount) + + safeCount(correction.femaleCount), unknownCount: 0, boxes: (correction.boxes ?? []) .map(normalizeBox) @@ -649,27 +1099,54 @@ export default function TrainingTab() { } }, [loadNext, loadTrainingStatus, requiredCount]) - const getPointerPosInImage = useCallback((clientX: number, clientY: number) => { + const getPointerPosInImage = useCallback(( + clientX: number, + clientY: number, + opts?: { clamp?: boolean } + ) => { const el = imageBoxRef.current if (!el) return null const rect = el.getBoundingClientRect() if (rect.width <= 0 || rect.height <= 0) return null + const x = (clientX - rect.left) / rect.width + const y = (clientY - rect.top) / rect.height + + if (opts?.clamp === false) { + return { x, y } + } + return { - x: clamp01((clientX - rect.left) / rect.width), - y: clamp01((clientY - rect.top) / rect.height), + x: clamp01(x), + y: clamp01(y), } }, []) const startDrawBox = useCallback((e: React.PointerEvent) => { if (!boxLabel) return if (uiLocked) return + if (boxInteraction) return + + const target = e.target as HTMLElement | null + if (target?.closest('[data-box-control="true"]')) return const pos = getPointerPosInImage(e.clientX, e.clientY) if (!pos) return - e.currentTarget.setPointerCapture(e.pointerId) + try { + e.currentTarget.setPointerCapture(e.pointerId) + } catch { + // Pointer wurde vom Browser bereits abgebrochen. + } + + setTouchMagnifier({ + visible: true, + clientX: e.clientX, + clientY: e.clientY, + imageX: pos.x, + imageY: pos.y, + }) setDrawingBox({ label: boxLabel, @@ -678,13 +1155,83 @@ export default function TrainingTab() { w: 0, h: 0, }) - }, [boxLabel, getPointerPosInImage, uiLocked]) + }, [boxLabel, boxInteraction, getPointerPosInImage, uiLocked]) const moveDrawBox = useCallback((e: React.PointerEvent) => { - if (!drawingBox) return + if (drawingBox || boxInteraction) { + e.preventDefault() + e.stopPropagation() + } - const pos = getPointerPosInImage(e.clientX, e.clientY) - if (!pos) return + const clampedPos = getPointerPosInImage(e.clientX, e.clientY) + if (!clampedPos) return + + const pos = + boxInteraction?.type === 'move' + ? getPointerPosInImage(e.clientX, e.clientY, { clamp: false }) ?? clampedPos + : clampedPos + + if (drawingBox || boxInteraction) { + setTouchMagnifier({ + visible: true, + clientX: e.clientX, + clientY: e.clientY, + imageX: clampedPos.x, + imageY: clampedPos.y, + }) + } + + if (boxInteraction) { + const dx = pos.x - boxInteraction.startX + const dy = pos.y - boxInteraction.startY + const original = boxInteraction.original + + let nextBox: TrainingBox = original + + if (boxInteraction.type === 'move') { + nextBox = normalizeMovedBox({ + ...original, + x: original.x + dx, + y: original.y + dy, + }) + } + + if (boxInteraction.type === 'resize') { + let x1 = original.x + let y1 = original.y + let x2 = original.x + original.w + let y2 = original.y + original.h + + if (boxInteraction.handle.includes('n')) y1 = clamp01(y1 + dy) + if (boxInteraction.handle.includes('s')) y2 = clamp01(y2 + dy) + if (boxInteraction.handle.includes('w')) x1 = clamp01(x1 + dx) + if (boxInteraction.handle.includes('e')) x2 = clamp01(x2 + dx) + + const left = Math.min(x1, x2) + const top = Math.min(y1, y2) + const right = Math.max(x1, x2) + const bottom = Math.max(y1, y2) + + nextBox = normalizeBox({ + ...original, + x: left, + y: top, + w: right - left, + h: bottom - top, + }) + } + + setCorrection((prev) => ({ + ...prev, + boxes: (prev.boxes ?? []).map((box, index) => + index === boxInteraction.index ? nextBox : box + ), + })) + + return + } + + if (!drawingBox) return const x1 = drawingBox.x const y1 = drawingBox.y @@ -698,9 +1245,21 @@ export default function TrainingTab() { w: Math.abs(x2 - x1), h: Math.abs(y2 - y1), }) - }, [drawingBox, getPointerPosInImage]) + }, [boxInteraction, drawingBox, getPointerPosInImage]) + + const finishDrawBox = useCallback((e?: React.PointerEvent) => { + setTouchMagnifier(null) + + if (drawingBox || boxInteraction) { + e?.preventDefault() + e?.stopPropagation() + } + + if (boxInteraction) { + setBoxInteraction(null) + return + } - const finishDrawBox = useCallback(() => { if (!drawingBox) return const box = normalizeBox(drawingBox) @@ -717,39 +1276,70 @@ export default function TrainingTab() { return applyBoxLabelToCorrection(next, box.label, labels) }) - }, [drawingBox, labels]) + }, [boxInteraction, drawingBox, labels]) const removeBox = useCallback((index: number) => { - setCorrection((prev) => ({ - ...prev, - boxes: (prev.boxes ?? []).filter((_, i) => i !== index), - })) - }, []) + setCorrection((prev) => removeBoxFromCorrection(prev, index, labels)) + }, [labels]) - const clearBoxes = useCallback(() => { - setCorrection((prev) => ({ - ...prev, - boxes: [], - })) - }, []) + const clearBoxes = useCallback(() => { + setCorrection((prev) => ({ + ...prev, + maleCount: 0, + femaleCount: 0, + unknownCount: 0, + peopleCount: 0, + bodyPartsPresent: [], + objectsPresent: [], + clothingPresent: [], + boxes: [], + })) + + setExpandedCorrectionSections({ + bodyParts: false, + objects: false, + clothing: false, + }) + }, []) + + function changeGenderCount(key: 'femaleCount' | 'maleCount', delta: number) { + setCorrection((p) => { + const nextValue = Math.max(0, safeCount(p[key]) + delta) + + const next = { + ...p, + [key]: nextValue, + unknownCount: 0, + } + + return { + ...next, + peopleCount: safeCount(next.maleCount) + safeCount(next.femaleCount), + } + }) + } + + const showImageBoxes = !loading && !trainingRunning return (
{/* Sidebar links */}
+ ) + }) )} @@ -903,10 +1495,11 @@ export default function TrainingTab() { size="sm" variant="secondary" className="w-full" - disabled={uiLocked} - onClick={() => void loadNext({ forceNew: true })} + disabled={uiLocked || !sample} + onClick={() => void reloadCurrentImage()} + title="Lädt das aktuelle Bild erneut und führt die Analyse neu aus." > - Anderes Bild + Neuladen + + {!isDraft ? ( + + ) : null} + + + {!isDraft ? ( + <> + {(['nw', 'ne', 'sw', 'se'] as const).map((handle) => ( + + ))} + ) : null} - - - ) - })} - + + ) + })} + + ) : null} + + {touchMagnifier?.visible && imageSrc && showImageBoxes ? (() => { + const el = imageBoxRef.current + const rect = el?.getBoundingClientRect() + + if (!rect || rect.width <= 0 || rect.height <= 0) return null + + const size = 156 + const padding = 20 + + const activeBox = + drawingBox || + (boxInteraction + ? correction.boxes?.[boxInteraction.index] ?? null + : null) + + const hasUsableBox = + activeBox && + Number.isFinite(activeBox.w) && + Number.isFinite(activeBox.h) && + activeBox.w > 0.003 && + activeBox.h > 0.003 + + const boxCenterX = hasUsableBox + ? clamp01(activeBox.x + activeBox.w / 2) + : touchMagnifier.imageX + + const boxCenterY = hasUsableBox + ? clamp01(activeBox.y + activeBox.h / 2) + : touchMagnifier.imageY + + const boxPixelW = hasUsableBox ? activeBox.w * rect.width : 0 + const boxPixelH = hasUsableBox ? activeBox.h * rect.height : 0 + + const fitZoom = hasUsableBox + ? Math.min( + (size - padding * 2) / Math.max(1, boxPixelW), + (size - padding * 2) / Math.max(1, boxPixelH) + ) + : 2 + + const zoom = hasUsableBox + ? Math.max(0.55, Math.min(2.25, fitZoom)) + : 2 + + const viewportW = typeof window !== 'undefined' ? window.innerWidth : 390 + const viewportH = typeof window !== 'undefined' ? window.innerHeight : 800 + + let left = touchMagnifier.clientX - size / 2 + let top = touchMagnifier.clientY - size - 28 + + if (top < 8) { + top = touchMagnifier.clientY + 28 + } + + left = Math.max(8, Math.min(viewportW - size - 8, left)) + top = Math.max(8, Math.min(viewportH - size - 8, top)) + + const imageWidth = rect.width * zoom + const imageHeight = rect.height * zoom + + const imageLeft = size / 2 - boxCenterX * imageWidth + const imageTop = size / 2 - boxCenterY * imageHeight + + const pointerX = imageLeft + touchMagnifier.imageX * imageWidth + const pointerY = imageTop + touchMagnifier.imageY * imageHeight + + const boxLeft = hasUsableBox ? imageLeft + activeBox.x * imageWidth : 0 + const boxTop = hasUsableBox ? imageTop + activeBox.y * imageHeight : 0 + const boxWidth = hasUsableBox ? activeBox.w * imageWidth : 0 + const boxHeight = hasUsableBox ? activeBox.h * imageHeight : 0 + + return ( +
+ + + {hasUsableBox ? ( +
+ ) : null} + +
+
+ +
+
+ ) + })() : null}
{trainingRunning ? ( @@ -1078,7 +1929,7 @@ export default function TrainingTab() { className="w-full justify-center" title="Die Werte rechts werden als richtige Korrektur gespeichert." > - Korrektur speichern & weiter + Speichern & weiter
-
-
- - - setCorrection((p) => ({ - ...p, - femaleCount: safeCount(e.target.value), - unknownCount: 0, - })) - } - className="mt-1 h-9 w-full rounded-md border border-gray-200 bg-white px-2 text-sm dark:border-white/10 dark:bg-gray-950" - /> -
+
+
+ {[ + { key: 'femaleCount' as const, label: 'Weiblich' }, + { key: 'maleCount' as const, label: 'Männlich' }, + ].map((item) => { + const value = safeCount(correction[item.key]) -
- - - setCorrection((p) => ({ - ...p, - maleCount: safeCount(e.target.value), - unknownCount: 0, - })) - } - className="mt-1 h-9 w-full rounded-md border border-gray-200 bg-white px-2 text-sm dark:border-white/10 dark:bg-gray-950" - /> + return ( +
+
+ {item.label} +
+ +
+ + + { + const raw = e.target.value.replace(/\D/g, '') + const nextValue = safeCount(raw) + + setCorrection((p) => { + const next = { + ...p, + [item.key]: nextValue, + unknownCount: 0, + } + + return { + ...next, + peopleCount: safeCount(next.maleCount) + safeCount(next.femaleCount), + } + }) + }} + className="h-8 w-full rounded-lg border border-gray-200 bg-white px-1 text-center text-sm font-semibold tabular-nums text-gray-900 shadow-sm dark:border-white/10 dark:bg-gray-950 dark:text-gray-100 md:text-base" + /> + + +
+
+ ) + })}
@@ -1186,99 +2066,63 @@ export default function TrainingTab() { ))}
+ + + setExpandedCorrectionSections((p) => ({ + ...p, + bodyParts: expanded, + })) + } + onToggle={(value) => + setCorrection((p) => ({ + ...p, + bodyPartsPresent: toggleArrayValue(p.bodyPartsPresent, value), + })) + } + /> -
-
- Körperteile -
-
- {labels.bodyParts.map((x) => { - const active = correction.bodyPartsPresent.includes(x) - return ( - - ) - })} -
-
+ + setExpandedCorrectionSections((p) => ({ + ...p, + objects: expanded, + })) + } + onToggle={(value) => + setCorrection((p) => ({ + ...p, + objectsPresent: toggleArrayValue(p.objectsPresent, value), + })) + } + /> -
-
- Gegenstände -
-
- {labels.objects.map((x) => { - const active = correction.objectsPresent.includes(x) - return ( - - ) - })} -
-
- -
-
- Kleidung -
-
- {labels.clothing.map((x) => { - const active = correction.clothingPresent.includes(x) - return ( - - ) - })} -
-
+ + setExpandedCorrectionSections((p) => ({ + ...p, + clothing: expanded, + })) + } + onToggle={(value) => + setCorrection((p) => ({ + ...p, + clothingPresent: toggleArrayValue(p.clothingPresent, value), + })) + } + />