improvements

This commit is contained in:
Linrador 2026-04-30 11:34:22 +02:00
parent 7d649525d9
commit 03ff0029f2
11 changed files with 2757 additions and 528 deletions

View File

@ -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"
]
}

View File

@ -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()

View File

@ -2,3 +2,7 @@ torch
torchvision
pillow
numpy
transformers
scikit-learn
joblib
safetensors

View File

@ -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__":

View File

@ -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=="
}

View File

@ -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)

View File

@ -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,7 +519,7 @@ 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,
@ -531,9 +527,9 @@ func trainingTrainHandler(w http.ResponseWriter, r *http.Request) {
"requiredTrain": minDetectorTrainCount,
"requiredVal": minDetectorValCount,
"datasetYAML": detectorDatasetYAML,
"usesSceneKNN": false,
"usesResNet18KNN": false,
"source": "yolo_detector",
"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,
"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)

View File

@ -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,

View File

@ -172,6 +172,431 @@ export function FaceIcon(props: IconProps) {
)
}
export function FemalePersonIcon(props: IconProps) {
return (
<IconBase {...props}>
<circle cx="12" cy="5.2" r="2.1" />
<path d="M8.7 10.2C9.4 8.8 10.5 8.1 12 8.1C13.5 8.1 14.6 8.8 15.3 10.2" />
<path d="M9.2 10.5L7.2 16.2H16.8L14.8 10.5" />
<path d="M12 16.2V21" />
<path d="M9.2 21H14.8" />
<path d="M8.2 13.2L5.6 15.8" />
<path d="M15.8 13.2L18.4 15.8" />
</IconBase>
)
}
export function MalePersonIcon(props: IconProps) {
return (
<IconBase {...props}>
<circle cx="12" cy="5.2" r="2.1" />
<path d="M8.8 10.2C9.5 8.8 10.6 8.1 12 8.1C13.4 8.1 14.5 8.8 15.2 10.2" />
<path d="M8.8 10.5H15.2V16.4H8.8V10.5Z" />
<path d="M10.2 16.4L9.2 21" />
<path d="M13.8 16.4L14.8 21" />
<path d="M8.8 12.2L5.8 15.2" />
<path d="M15.2 12.2L18.2 15.2" />
</IconBase>
)
}
export function ClothingIcon(props: IconProps) {
return (
<IconBase {...props}>
<path d="M9 4.5L12 6L15 4.5" />
<path d="M8.2 5L4.8 7.2L6.3 10.2L8 9.4V20H16V9.4L17.7 10.2L19.2 7.2L15.8 5" />
<path d="M10 6.2C10.4 7.2 11.1 7.8 12 7.8C12.9 7.8 13.6 7.2 14 6.2" />
</IconBase>
)
}
export function CropTopIcon(props: IconProps) {
return (
<IconBase {...props}>
{/* Träger */}
<path d="M9.2 4.5L12 6.2L14.8 4.5" />
<path d="M8.2 5.2L5.4 7.6" />
<path d="M15.8 5.2L18.6 7.6" />
{/* kurze Top-Form */}
<path d="M8.2 5.2C8.5 7.1 9.6 8.4 12 8.4C14.4 8.4 15.5 7.1 15.8 5.2" />
<path d="M7.2 8.2L6.3 15.2H17.7L16.8 8.2" />
<path d="M7.2 8.2C8.4 9.2 10 9.8 12 9.8C14 9.8 15.6 9.2 16.8 8.2" />
{/* Crop-Kante */}
<path d="M6.3 15.2C8.1 16 10 16.4 12 16.4C14 16.4 15.9 16 17.7 15.2" />
{/* leichte Stofffalten */}
<path d="M9.2 10.8L8.8 14.5" />
<path d="M14.8 10.8L15.2 14.5" />
<path d="M12 10.2V15.5" />
</IconBase>
)
}
export function BraIcon(props: IconProps) {
return (
<IconBase {...props}>
<path d="M4.5 8.5C6.4 7.4 8.8 7.6 10.2 9.2C11 10.1 11.5 11.5 12 13.2C12.5 11.5 13 10.1 13.8 9.2C15.2 7.6 17.6 7.4 19.5 8.5" />
<path d="M5 8.8C5.2 12.5 7 15.5 9.4 15.5C10.8 15.5 11.6 14.6 12 13.2" />
<path d="M19 8.8C18.8 12.5 17 15.5 14.6 15.5C13.2 15.5 12.4 14.6 12 13.2" />
<path d="M4.5 8.5L3.5 6.5" />
<path d="M19.5 8.5L20.5 6.5" />
<path d="M9.8 15.5H14.2" />
</IconBase>
)
}
export function UnderwearIcon(props: IconProps) {
return (
<IconBase {...props}>
<path d="M5 7.5H19" />
<path d="M6 7.5L8.2 18.5H10.6L12 14.8L13.4 18.5H15.8L18 7.5" />
<path d="M8.2 7.5C8.9 10.1 10.1 12.2 12 14.8C13.9 12.2 15.1 10.1 15.8 7.5" />
</IconBase>
)
}
export function PantsIcon(props: IconProps) {
return (
<IconBase {...props}>
<path d="M8 4.5H16" />
<path d="M8 4.5L6.8 20H10.2L12 10.8L13.8 20H17.2L16 4.5" />
<path d="M12 4.8V10.8" />
<path d="M9.2 6.5H10.2" />
<path d="M13.8 6.5H14.8" />
</IconBase>
)
}
export function DressIcon(props: IconProps) {
return (
<IconBase {...props}>
<path d="M9 4.5H15" />
<path d="M9 4.5C9.2 6.8 10 8.2 12 9.4C14 8.2 14.8 6.8 15 4.5" />
<path d="M12 9.4L7.2 20H16.8L12 9.4Z" />
<path d="M8.4 13.8H15.6" />
</IconBase>
)
}
export function ShoesIcon(props: IconProps) {
return (
<IconBase {...props}>
<path d="M4.5 14.5C6.7 14.6 8.4 14.1 9.8 12.8L12 15.5H5.2C4.6 15.5 4.2 15.1 4.5 14.5Z" />
<path d="M12 14.5C14.2 14.6 15.9 14.1 17.3 12.8L19.5 15.5H12.7C12.1 15.5 11.7 15.1 12 14.5Z" />
<path d="M7.2 13.9L8.1 15.4" />
<path d="M14.7 13.9L15.6 15.4" />
</IconBase>
)
}
export function SockIcon(props: IconProps) {
return (
<IconBase {...props}>
<path d="M9 3.8H15" />
<path d="M9.5 3.8V12.7L6.2 15.9C5.2 16.9 5.4 18.5 6.6 19.2C7.5 19.8 8.7 19.7 9.5 18.9L14.5 14.1V3.8" />
<path d="M9.5 8H14.5" />
</IconBase>
)
}
export function CondomIcon(props: IconProps) {
return (
<IconBase {...props}>
<path d="M12 4.5C14.2 4.5 16 6.3 16 8.5V15.2C16 17.4 14.2 19.2 12 19.2C9.8 19.2 8 17.4 8 15.2V8.5C8 6.3 9.8 4.5 12 4.5Z" />
<path d="M10 19.2H14" />
<path d="M9.3 8.5H14.7" />
<path d="M12 6.2V7.2" />
</IconBase>
)
}
export function ToyIcon(props: IconProps) {
return (
<IconBase {...props}>
<path d="M12 4.5C13.7 4.5 15 5.8 15 7.5V15.5C15 17.2 13.7 18.5 12 18.5C10.3 18.5 9 17.2 9 15.5V7.5C9 5.8 10.3 4.5 12 4.5Z" />
<path d="M10 18.5H14" />
<path d="M10.2 7.8H13.8" />
<path d="M10.2 10.5H13.8" />
<circle cx="12" cy="14.5" r="0.8" fill="currentColor" stroke="none" />
</IconBase>
)
}
export function BottleIcon(props: IconProps) {
return (
<IconBase {...props}>
<path d="M10 3.8H14" />
<path d="M10.7 3.8V7.3C9.4 8.1 8.5 9.5 8.5 11.1V18.5C8.5 19.4 9.1 20 10 20H14C14.9 20 15.5 19.4 15.5 18.5V11.1C15.5 9.5 14.6 8.1 13.3 7.3V3.8" />
<path d="M8.5 12.5H15.5" />
</IconBase>
)
}
export function BedIcon(props: IconProps) {
return (
<IconBase {...props}>
<path d="M4.5 10.5V19" />
<path d="M19.5 13.5V19" />
<path d="M4.5 14H19.5" />
<path d="M4.5 10.5H10.5C11.3 10.5 12 11.2 12 12V14" />
<path d="M12 12H17.2C18.5 12 19.5 13 19.5 14.3V14" />
<path d="M6 8.5H9.5" />
</IconBase>
)
}
export function ChairIcon(props: IconProps) {
return (
<IconBase {...props}>
<path d="M8 4.5H16V12H8V4.5Z" />
<path d="M6.5 12H17.5" />
<path d="M8 12V20" />
<path d="M16 12V20" />
<path d="M9 16H15" />
</IconBase>
)
}
export function PhoneIcon(props: IconProps) {
return (
<IconBase {...props}>
<rect x="8" y="3.5" width="8" height="17" rx="1.8" />
<path d="M10.5 6H13.5" />
<circle cx="12" cy="17.5" r="0.7" fill="currentColor" stroke="none" />
</IconBase>
)
}
export function CameraIcon(props: IconProps) {
return (
<IconBase {...props}>
<path d="M8.5 7L9.8 5H14.2L15.5 7H18.5C19.3 7 20 7.7 20 8.5V17C20 17.8 19.3 18.5 18.5 18.5H5.5C4.7 18.5 4 17.8 4 17V8.5C4 7.7 4.7 7 5.5 7H8.5Z" />
<circle cx="12" cy="12.8" r="3" />
<circle cx="17" cy="9.5" r="0.6" fill="currentColor" stroke="none" />
</IconBase>
)
}
export function RopeIcon(props: IconProps) {
return (
<IconBase {...props}>
<path d="M8.5 5.5C10.5 3.7 13.5 3.7 15.5 5.5C17.6 7.4 17.6 10.6 15.5 12.5L12 15.8L8.5 12.5C6.4 10.6 6.4 7.4 8.5 5.5Z" />
<path d="M12 15.8V21" />
<path d="M10.5 18L13.5 16.8" />
<path d="M10.5 20.2L13.5 19" />
</IconBase>
)
}
export function HandIcon(props: IconProps) {
return (
<IconBase {...props}>
<path d="M8.5 12V6.5C8.5 5.7 9.1 5.1 9.9 5.1C10.7 5.1 11.3 5.7 11.3 6.5V11" />
<path d="M11.3 11V5.5C11.3 4.7 11.9 4.1 12.7 4.1C13.5 4.1 14.1 4.7 14.1 5.5V11.5" />
<path d="M14.1 11.5V7.2C14.1 6.4 14.7 5.8 15.5 5.8C16.3 5.8 16.9 6.4 16.9 7.2V14.5C16.9 18.2 14.7 20.5 11.7 20.5C9.7 20.5 8.2 19.5 7 17.8L5.5 15.6C5.1 15 5.3 14.2 5.9 13.8C6.5 13.4 7.2 13.6 7.7 14.1L8.5 15" />
</IconBase>
)
}
export function MouthIcon(props: IconProps) {
return (
<IconBase {...props}>
<path d="M4.5 12C6.5 9.8 8.4 8.8 12 10.8C15.6 8.8 17.5 9.8 19.5 12" />
<path d="M4.5 12C6.5 15.2 9.1 16.5 12 16.5C14.9 16.5 17.5 15.2 19.5 12" />
<path d="M7.5 12.2H16.5" />
</IconBase>
)
}
export function TongueIcon(props: IconProps) {
return (
<IconBase {...props}>
{/* äußere Lippenkontur */}
<path d="M3.8 10.2C5.7 7.6 8 6.6 10.3 7.4C11.1 7.7 11.5 8 12 8C12.5 8 12.9 7.7 13.7 7.4C16 6.6 18.3 7.6 20.2 10.2" />
<path d="M3.8 10.2C5.1 12.7 7.1 14.2 9.4 14.9" />
<path d="M20.2 10.2C18.9 12.7 16.9 14.2 14.6 14.9" />
{/* innere Lippen / Mundöffnung */}
<path d="M6.2 10.8C7.8 9.6 9.5 9.6 10.9 10.3C11.4 10.6 11.7 10.8 12 10.8C12.3 10.8 12.6 10.6 13.1 10.3C14.5 9.6 16.2 9.6 17.8 10.8" />
<path d="M6.2 10.8C7.6 12.3 9.1 13 10.6 13.2" />
<path d="M17.8 10.8C16.4 12.3 14.9 13 13.4 13.2" />
{/* Zunge */}
<path d="M9.4 12.8C9.4 16.8 10.4 20.2 12 20.2C13.6 20.2 14.6 16.8 14.6 12.8" />
<path d="M9.4 12.8C10.2 13.4 11 13.7 12 13.7C13 13.7 13.8 13.4 14.6 12.8" />
<path d="M12 14.4V18.6" />
</IconBase>
)
}
export function BackIcon(props: IconProps) {
return (
<IconBase {...props}>
{/* äußere Rücken-/Schulterkontur links */}
<path d="M7.2 4C7.2 5.8 6.5 6.9 5.3 7.7C3.9 8.7 3.2 10.1 3.2 12V20" />
{/* äußere Rücken-/Schulterkontur rechts */}
<path d="M16.8 4C16.8 5.8 17.5 6.9 18.7 7.7C20.1 8.7 20.8 10.1 20.8 12V20" />
{/* obere innere Schulterlinien */}
<path d="M10 6.2C10 7.4 9.5 8.3 8.7 9" />
<path d="M14 6.2C14 7.4 14.5 8.3 15.3 9" />
{/* innere Rückenlinien */}
<path d="M9 10.3C9.6 12.3 9.9 14.5 9.9 16.9V20" />
<path d="M15 10.3C14.4 12.3 14.1 14.5 14.1 16.9V20" />
{/* Wirbelsäulenlinie */}
<path d="M12 9.4V20" />
</IconBase>
)
}
export function BathIcon(props: IconProps) {
return (
<IconBase {...props}>
<path d="M5 11.5H20" />
<path d="M6 11.5V14.5C6 17.3 8.2 19.5 11 19.5H14C16.8 19.5 19 17.3 19 14.5V11.5" />
<path d="M8 19.5L7 21" />
<path d="M17 19.5L18 21" />
<path d="M5 11.5V7.5C5 5.6 6.4 4.2 8.2 4.2C9.8 4.2 11 5.4 11 7" />
<path d="M9.8 7H12.2" />
<circle cx="9" cy="9" r="0.5" fill="currentColor" stroke="none" />
<circle cx="12" cy="9.5" r="0.5" fill="currentColor" stroke="none" />
<circle cx="15" cy="9" r="0.5" fill="currentColor" stroke="none" />
</IconBase>
)
}
export function ShowerIcon(props: IconProps) {
return (
<IconBase {...props}>
<path d="M7 8.5C7 5.7 9.2 3.5 12 3.5C14.8 3.5 17 5.7 17 8.5" />
<path d="M8.5 8.5H15.5" />
<path d="M8 11.5L7.2 13" />
<path d="M11 11.5L10.2 13.2" />
<path d="M14 11.5L13.2 13.2" />
<path d="M17 11.5L16.2 13" />
<path d="M9 16L8.2 17.5" />
<path d="M12 16L11.2 17.8" />
<path d="M15 16L14.2 17.5" />
<path d="M17 8.5V20" />
</IconBase>
)
}
export function BlindfoldIcon(props: IconProps) {
return (
<IconBase {...props}>
<path d="M4 11C6.2 9.2 8.7 8.5 12 8.5C15.3 8.5 17.8 9.2 20 11" />
<path d="M4 11C6.2 12.8 8.7 13.5 12 13.5C15.3 13.5 17.8 12.8 20 11" />
<path d="M7 10.2L9.2 12.6" />
<path d="M10.8 8.7L13.8 13.2" />
<path d="M15.2 9.2L17.2 11.8" />
<path d="M4 11L2.8 10.2" />
<path d="M20 11L21.2 10.2" />
</IconBase>
)
}
export function CollarIcon(props: IconProps) {
return (
<IconBase {...props}>
<path d="M6 8.5C7.7 7.2 9.7 6.5 12 6.5C14.3 6.5 16.3 7.2 18 8.5" />
<path d="M6 8.5V12C6 13.6 8.7 15 12 15C15.3 15 18 13.6 18 12V8.5" />
<path d="M6 12C7.7 13.3 9.7 14 12 14C14.3 14 16.3 13.3 18 12" />
<circle cx="12" cy="16.8" r="1.3" />
<path d="M12 15V15.6" />
</IconBase>
)
}
export function ButtPlugIcon(props: IconProps) {
return (
<IconBase {...props}>
<path d="M12 4.5C14.2 6.2 15.2 8.1 15.2 10.2C15.2 12.4 13.8 14 12 14C10.2 14 8.8 12.4 8.8 10.2C8.8 8.1 9.8 6.2 12 4.5Z" />
<path d="M10 14H14" />
<path d="M9 17H15" />
<path d="M10.5 14L9 17" />
<path d="M13.5 14L15 17" />
<path d="M8.2 19.5H15.8" />
</IconBase>
)
}
export function StrapOnIcon(props: IconProps) {
return (
<IconBase {...props}>
<path d="M6 13.5C7.8 11.8 9.8 11 12 11C14.2 11 16.2 11.8 18 13.5" />
<path d="M7.5 14.5C8.8 15.8 10.3 16.5 12 16.5C13.7 16.5 15.2 15.8 16.5 14.5" />
<path d="M12 5C13.5 5 14.7 6.2 14.7 7.7V11.4" />
<path d="M12 5C10.5 5 9.3 6.2 9.3 7.7V11.4" />
<path d="M12 5V3.8" />
<path d="M9 18.5H15" />
</IconBase>
)
}
export function TowelIcon(props: IconProps) {
return (
<IconBase {...props}>
{/* äußere Handtuchform */}
<rect x="6" y="3.8" width="12" height="15.2" rx="1.6" />
{/* obere innere Faltkante */}
<path d="M14.3 3.8V9.8" />
{/* gefaltete Lagen unten */}
<path d="M6 12.2H18" />
<path d="M6 15H18" />
{/* kleiner Aufhänger / untere Lasche */}
<path d="M10.2 19V20.8H13.2V19" />
</IconBase>
)
}
export function BikiniIcon(props: IconProps) {
return (
<IconBase {...props}>
<path d="M5 8.5C6.2 7.2 8.7 7.3 10.2 9.1C11 10.1 11.5 11.5 12 13C12.5 11.5 13 10.1 13.8 9.1C15.3 7.3 17.8 7.2 19 8.5" />
<path d="M6 9C6.2 11.8 7.4 13.8 9.3 13.8C10.5 13.8 11.3 13.1 12 13" />
<path d="M18 9C17.8 11.8 16.6 13.8 14.7 13.8C13.5 13.8 12.7 13.1 12 13" />
<path d="M8 17.5H16" />
<path d="M8 17.5L10 20H14L16 17.5" />
</IconBase>
)
}
export function FishnetIcon(props: IconProps) {
return (
<IconBase {...props}>
<path d="M8 4.5H16" />
<path d="M8.5 4.5L6.5 20" />
<path d="M15.5 4.5L17.5 20" />
<path d="M7.4 9H16.6" />
<path d="M6.9 13.5H17.1" />
<path d="M6.5 18H17.5" />
<path d="M9 5L16.5 19.5" />
<path d="M15 5L7.5 19.5" />
</IconBase>
)
}
export function HotpantsIcon(props: IconProps) {
return (
<IconBase {...props}>
<path d="M6 7.5H18" />
<path d="M6.5 7.5L7.5 16.5H10.7L12 12.5L13.3 16.5H16.5L17.5 7.5" />
<path d="M8.5 9.8H10" />
<path d="M14 9.8H15.5" />
<path d="M12 7.8V12.5" />
</IconBase>
)
}
export function UnknownContentIcon(props: IconProps) {
return (
<IconBase {...props}>
@ -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')

View File

@ -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) {
</div>
</div>
<div className="mt-5 rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-white/10 dark:bg-white/5">
<LabeledSwitch
checked={!!value.trainingRecognitionEnabled}
onChange={(checked) =>
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."
/>
<div className="mt-3 grid grid-cols-1 gap-2 sm:grid-cols-12 sm:items-center">
<div className="sm:col-span-4">
<div className="text-sm font-medium text-gray-900 dark:text-gray-200">
Object Detection Epochs
</div>
<div className="text-xs text-gray-600 dark:text-gray-300">
Maximale Trainingsdauer. 60 ist ein guter Standard.
</div>
</div>
<div className="sm:col-span-8">
<div className="flex items-center justify-end gap-2">
<input
type="number"
min={1}
max={300}
step={1}
value={value.trainingDetectorEpochs ?? 60}
onChange={(e) =>
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"
/>
<span className="shrink-0 text-xs text-gray-600 dark:text-gray-300">
Epochs
</span>
</div>
</div>
</div>
</div>
<LabeledSwitch
checked={!!value.blurPreviews}
onChange={(checked) => setValue((v) => ({ ...v, blurPreviews: checked }))}

File diff suppressed because it is too large Load Diff