improvements
This commit is contained in:
parent
7d649525d9
commit
03ff0029f2
@ -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"
|
||||
]
|
||||
}
|
||||
@ -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()
|
||||
@ -2,3 +2,7 @@ torch
|
||||
torchvision
|
||||
pillow
|
||||
numpy
|
||||
transformers
|
||||
scikit-learn
|
||||
joblib
|
||||
safetensors
|
||||
@ -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__":
|
||||
|
||||
@ -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=="
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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
Loading…
x
Reference in New Issue
Block a user