added detection model
This commit is contained in:
parent
bd2dc1f965
commit
58b39ac42a
@ -11,6 +11,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
@ -1211,6 +1212,9 @@ func uniqueDestPath(dstDir, file string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func clamp01(v float64) float64 {
|
func clamp01(v float64) float64 {
|
||||||
|
if math.IsNaN(v) || math.IsInf(v, 0) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
if v < 0 {
|
if v < 0 {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|||||||
61
backend/ml/detection_labels.json
Normal file
61
backend/ml/detection_labels.json
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
{
|
||||||
|
"sexPositions": [
|
||||||
|
"unknown",
|
||||||
|
"solo",
|
||||||
|
"missionary",
|
||||||
|
"doggy",
|
||||||
|
"cowgirl",
|
||||||
|
"reverse_cowgirl",
|
||||||
|
"cunnilingus",
|
||||||
|
"standing",
|
||||||
|
"standing_doggy",
|
||||||
|
"spooning",
|
||||||
|
"sideways",
|
||||||
|
"sitting",
|
||||||
|
"facesitting",
|
||||||
|
"handjob",
|
||||||
|
"blowjob",
|
||||||
|
"toy_play",
|
||||||
|
"fingering",
|
||||||
|
"69",
|
||||||
|
"other"
|
||||||
|
],
|
||||||
|
"bodyParts": [
|
||||||
|
"anus",
|
||||||
|
"back",
|
||||||
|
"breasts",
|
||||||
|
"buttocks",
|
||||||
|
"chest",
|
||||||
|
"penis",
|
||||||
|
"tongue",
|
||||||
|
"vagina"
|
||||||
|
],
|
||||||
|
"objects": [
|
||||||
|
"bath",
|
||||||
|
"blindfold",
|
||||||
|
"buttplug",
|
||||||
|
"collar",
|
||||||
|
"dildo",
|
||||||
|
"handcuffs",
|
||||||
|
"rope",
|
||||||
|
"shower",
|
||||||
|
"strapon",
|
||||||
|
"towel",
|
||||||
|
"vibrator"
|
||||||
|
],
|
||||||
|
"clothing": [
|
||||||
|
"bikini",
|
||||||
|
"bra",
|
||||||
|
"dress",
|
||||||
|
"fishnet",
|
||||||
|
"heels",
|
||||||
|
"hotpants",
|
||||||
|
"lingerie",
|
||||||
|
"nude",
|
||||||
|
"panties",
|
||||||
|
"shirt",
|
||||||
|
"skirt",
|
||||||
|
"stockings",
|
||||||
|
"top"
|
||||||
|
]
|
||||||
|
}
|
||||||
86
backend/ml/predict_detector_model.py
Normal file
86
backend/ml/predict_detector_model.py
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
# backend\ml\predict_detector_model.py
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
from ultralytics import YOLO
|
||||||
|
|
||||||
|
|
||||||
|
def clamp01(v):
|
||||||
|
return max(0.0, min(1.0, float(v)))
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--root", required=True)
|
||||||
|
parser.add_argument("--image", required=True)
|
||||||
|
parser.add_argument("--conf", type=float, default=0.30)
|
||||||
|
parser.add_argument("--imgsz", type=int, default=640)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
root = Path(args.root)
|
||||||
|
image_path = Path(args.image)
|
||||||
|
|
||||||
|
model_path = root / "detector" / "best.pt"
|
||||||
|
if not model_path.exists():
|
||||||
|
print(json.dumps({
|
||||||
|
"available": False,
|
||||||
|
"source": "detector_missing",
|
||||||
|
"boxes": [],
|
||||||
|
}))
|
||||||
|
return
|
||||||
|
|
||||||
|
img = Image.open(image_path).convert("RGB")
|
||||||
|
img_w, img_h = img.size
|
||||||
|
|
||||||
|
model = YOLO(str(model_path))
|
||||||
|
results = model.predict(
|
||||||
|
source=str(image_path),
|
||||||
|
conf=args.conf,
|
||||||
|
imgsz=args.imgsz,
|
||||||
|
verbose=False,
|
||||||
|
device=0 if __import__("torch").cuda.is_available() else "cpu",
|
||||||
|
)
|
||||||
|
|
||||||
|
boxes = []
|
||||||
|
|
||||||
|
if results:
|
||||||
|
r = results[0]
|
||||||
|
names = r.names or {}
|
||||||
|
|
||||||
|
if r.boxes is not None:
|
||||||
|
for b in r.boxes:
|
||||||
|
cls_id = int(b.cls[0].item())
|
||||||
|
score = float(b.conf[0].item())
|
||||||
|
label = str(names.get(cls_id, cls_id))
|
||||||
|
|
||||||
|
x1, y1, x2, y2 = [float(v) for v in b.xyxy[0].tolist()]
|
||||||
|
|
||||||
|
x = clamp01(x1 / img_w)
|
||||||
|
y = clamp01(y1 / img_h)
|
||||||
|
w = clamp01((x2 - x1) / img_w)
|
||||||
|
h = clamp01((y2 - y1) / img_h)
|
||||||
|
|
||||||
|
if w <= 0 or h <= 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
boxes.append({
|
||||||
|
"label": label,
|
||||||
|
"score": score,
|
||||||
|
"x": x,
|
||||||
|
"y": y,
|
||||||
|
"w": w,
|
||||||
|
"h": h,
|
||||||
|
})
|
||||||
|
|
||||||
|
print(json.dumps({
|
||||||
|
"available": True,
|
||||||
|
"source": "yolo_detector",
|
||||||
|
"boxes": boxes,
|
||||||
|
}, ensure_ascii=False))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
197
backend/ml/predict_scene_model.py
Normal file
197
backend/ml/predict_scene_model.py
Normal file
@ -0,0 +1,197 @@
|
|||||||
|
# backend\ml\predict_scene_model.py
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import torch
|
||||||
|
from PIL import Image
|
||||||
|
from torchvision import models
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def empty_prediction(source="no_model"):
|
||||||
|
return {
|
||||||
|
"modelAvailable": False,
|
||||||
|
"source": source,
|
||||||
|
"peopleCount": 0,
|
||||||
|
"maleCount": 0,
|
||||||
|
"femaleCount": 0,
|
||||||
|
"unknownCount": 0,
|
||||||
|
"sexPosition": "unknown",
|
||||||
|
"sexPositionScore": 0,
|
||||||
|
"bodyPartsPresent": [],
|
||||||
|
"objectsPresent": [],
|
||||||
|
"clothingPresent": [],
|
||||||
|
"boxes": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def load_encoder():
|
||||||
|
weights = models.ResNet18_Weights.DEFAULT
|
||||||
|
model = models.resnet18(weights=weights)
|
||||||
|
model.fc = torch.nn.Identity()
|
||||||
|
model.eval()
|
||||||
|
|
||||||
|
preprocess = weights.transforms()
|
||||||
|
return model, preprocess
|
||||||
|
|
||||||
|
|
||||||
|
def embed_image(model, preprocess, image_path: Path):
|
||||||
|
img = Image.open(image_path).convert("RGB")
|
||||||
|
x = preprocess(img).unsqueeze(0)
|
||||||
|
|
||||||
|
with torch.no_grad():
|
||||||
|
emb = model(x).cpu().numpy()[0].astype("float32")
|
||||||
|
|
||||||
|
norm = np.linalg.norm(emb)
|
||||||
|
if norm > 0:
|
||||||
|
emb = emb / norm
|
||||||
|
|
||||||
|
return emb
|
||||||
|
|
||||||
|
|
||||||
|
def weighted_mode_string(items, weights, fallback="unknown"):
|
||||||
|
scores = {}
|
||||||
|
|
||||||
|
for item, weight in zip(items, weights):
|
||||||
|
key = str(item or fallback)
|
||||||
|
scores[key] = scores.get(key, 0.0) + float(weight)
|
||||||
|
|
||||||
|
if not scores:
|
||||||
|
return fallback, 0.0
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--root", required=True)
|
||||||
|
parser.add_argument("--image", required=True)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
root = Path(args.root)
|
||||||
|
image_path = Path(args.image)
|
||||||
|
|
||||||
|
model_path = root / "model" / "scene_knn.npz"
|
||||||
|
targets_path = root / "model" / "targets.json"
|
||||||
|
|
||||||
|
if not model_path.exists() or not targets_path.exists():
|
||||||
|
print(json.dumps(empty_prediction("model_missing")))
|
||||||
|
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")))
|
||||||
|
return
|
||||||
|
|
||||||
|
encoder, preprocess = load_encoder()
|
||||||
|
emb = embed_image(encoder, preprocess, image_path)
|
||||||
|
|
||||||
|
sims = embeddings @ emb
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
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),
|
||||||
|
"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),
|
||||||
|
"boxes": predict_boxes(root, image_path),
|
||||||
|
}
|
||||||
|
|
||||||
|
print(json.dumps(pred, ensure_ascii=False))
|
||||||
|
|
||||||
|
def predict_boxes(root: Path, image_path: Path):
|
||||||
|
script = Path(__file__).parent / "predict_detector_model.py"
|
||||||
|
|
||||||
|
if not script.exists():
|
||||||
|
return []
|
||||||
|
|
||||||
|
try:
|
||||||
|
proc = subprocess.run(
|
||||||
|
[
|
||||||
|
sys.executable,
|
||||||
|
str(script),
|
||||||
|
"--root",
|
||||||
|
str(root),
|
||||||
|
"--image",
|
||||||
|
str(image_path),
|
||||||
|
],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=20,
|
||||||
|
)
|
||||||
|
|
||||||
|
if proc.returncode != 0:
|
||||||
|
return []
|
||||||
|
|
||||||
|
data = json.loads(proc.stdout or "{}")
|
||||||
|
return data.get("boxes") or []
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
4
backend/ml/requirements-ml.txt
Normal file
4
backend/ml/requirements-ml.txt
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
torch
|
||||||
|
torchvision
|
||||||
|
pillow
|
||||||
|
numpy
|
||||||
56
backend/ml/train_detector_model.py
Normal file
56
backend/ml/train_detector_model.py
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
# backend\ml\train_detector_model.py
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from ultralytics import YOLO
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--root", required=True)
|
||||||
|
parser.add_argument("--base", default="yolo11n.pt")
|
||||||
|
parser.add_argument("--epochs", default="80")
|
||||||
|
parser.add_argument("--imgsz", default="640")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
root = Path(args.root).resolve()
|
||||||
|
yaml_path = root / "detector" / "dataset" / "dataset.yaml"
|
||||||
|
runs_dir = root / "detector" / "runs"
|
||||||
|
|
||||||
|
if not yaml_path.exists():
|
||||||
|
raise SystemExit(f"dataset.yaml not found: {yaml_path}")
|
||||||
|
|
||||||
|
model = YOLO(args.base)
|
||||||
|
|
||||||
|
result = model.train(
|
||||||
|
data=str(yaml_path),
|
||||||
|
epochs=int(args.epochs),
|
||||||
|
imgsz=int(args.imgsz),
|
||||||
|
project=str(runs_dir),
|
||||||
|
name="detect",
|
||||||
|
exist_ok=True,
|
||||||
|
device="cpu",
|
||||||
|
workers=2,
|
||||||
|
patience=20,
|
||||||
|
)
|
||||||
|
|
||||||
|
best = runs_dir / "detect" / "weights" / "best.pt"
|
||||||
|
if not best.exists():
|
||||||
|
raise SystemExit(f"best.pt not found after training: {best}")
|
||||||
|
|
||||||
|
out_dir = root / "detector" / "model"
|
||||||
|
out_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
final_model = out_dir / "best.pt"
|
||||||
|
final_model.write_bytes(best.read_bytes())
|
||||||
|
|
||||||
|
print(json.dumps({
|
||||||
|
"ok": True,
|
||||||
|
"model": str(final_model),
|
||||||
|
"runs": str(runs_dir / "detect"),
|
||||||
|
}))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
167
backend/ml/train_scene_model.py
Normal file
167
backend/ml/train_scene_model.py
Normal file
@ -0,0 +1,167 @@
|
|||||||
|
# backend\ml\train_scene_model.py
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import torch
|
||||||
|
from PIL import Image
|
||||||
|
from torchvision import models, transforms
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def read_jsonl(path: Path):
|
||||||
|
if not path.exists():
|
||||||
|
return []
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
with path.open("r", encoding="utf-8") as f:
|
||||||
|
for line in f:
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
rows.append(json.loads(line))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def prediction_target(annotation):
|
||||||
|
pred = annotation.get("prediction") or {}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"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")],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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 []),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def target_from_annotation(annotation):
|
||||||
|
if annotation.get("accepted") is True:
|
||||||
|
return prediction_target(annotation)
|
||||||
|
|
||||||
|
return correction_target(annotation)
|
||||||
|
|
||||||
|
|
||||||
|
def embed_image(model, preprocess, image_path: Path):
|
||||||
|
img = Image.open(image_path).convert("RGB")
|
||||||
|
x = preprocess(img).unsqueeze(0)
|
||||||
|
|
||||||
|
with torch.no_grad():
|
||||||
|
emb = model(x).cpu().numpy()[0].astype("float32")
|
||||||
|
|
||||||
|
norm = np.linalg.norm(emb)
|
||||||
|
if norm > 0:
|
||||||
|
emb = emb / norm
|
||||||
|
|
||||||
|
return emb
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--root", required=True)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
root = Path(args.root)
|
||||||
|
feedback_path = root / "feedback.jsonl"
|
||||||
|
frames_dir = root / "frames"
|
||||||
|
model_dir = root / "model"
|
||||||
|
model_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
rows = read_jsonl(feedback_path)
|
||||||
|
|
||||||
|
model, preprocess = load_encoder()
|
||||||
|
|
||||||
|
embeddings = []
|
||||||
|
targets = []
|
||||||
|
used = 0
|
||||||
|
skipped = 0
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
sample_id = str(row.get("sampleId") or "").strip()
|
||||||
|
if not sample_id:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
image_path = frames_dir / f"{sample_id}.jpg"
|
||||||
|
if not image_path.exists():
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
emb = embed_image(model, preprocess, image_path)
|
||||||
|
target = target_from_annotation(row)
|
||||||
|
|
||||||
|
embeddings.append(emb)
|
||||||
|
targets.append(target)
|
||||||
|
used += 1
|
||||||
|
except Exception:
|
||||||
|
skipped += 1
|
||||||
|
|
||||||
|
if used < 5:
|
||||||
|
raise SystemExit(f"too few usable samples: {used}")
|
||||||
|
|
||||||
|
embeddings_np = np.stack(embeddings).astype("float32")
|
||||||
|
|
||||||
|
np.savez_compressed(
|
||||||
|
model_dir / "scene_knn.npz",
|
||||||
|
embeddings=embeddings_np,
|
||||||
|
)
|
||||||
|
|
||||||
|
with (model_dir / "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,
|
||||||
|
)
|
||||||
|
|
||||||
|
print(json.dumps({
|
||||||
|
"ok": True,
|
||||||
|
"usedSamples": used,
|
||||||
|
"skippedSamples": skipped,
|
||||||
|
"modelPath": str(model_dir / "scene_knn.npz"),
|
||||||
|
}))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
42
backend/ml_embed.go
Normal file
42
backend/ml_embed.go
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed ml/*.py ml/*.json
|
||||||
|
var embeddedMLFiles embed.FS
|
||||||
|
|
||||||
|
func trainingEmbeddedMLDir() (string, error) {
|
||||||
|
dir := filepath.Join(os.TempDir(), "nsfwapp-ml")
|
||||||
|
|
||||||
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
files := []string{
|
||||||
|
"predict_scene_model.py",
|
||||||
|
"train_scene_model.py",
|
||||||
|
"predict_detector_model.py",
|
||||||
|
"train_detector_model.py",
|
||||||
|
"detection_labels.json",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, name := range files {
|
||||||
|
srcPath := filepath.Join("ml", name)
|
||||||
|
|
||||||
|
b, err := embeddedMLFiles.ReadFile(filepath.ToSlash(srcPath))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
dstPath := filepath.Join(dir, name)
|
||||||
|
if err := os.WriteFile(dstPath, b, 0644); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return dir, nil
|
||||||
|
}
|
||||||
@ -69,7 +69,16 @@ func registerRoutes(mux *http.ServeMux, auth *AuthManager) *ModelStore {
|
|||||||
api.HandleFunc("/api/record/delete-many", handleDeleteMany)
|
api.HandleFunc("/api/record/delete-many", handleDeleteMany)
|
||||||
api.HandleFunc("/api/record/keep-many", handleKeepMany)
|
api.HandleFunc("/api/record/keep-many", handleKeepMany)
|
||||||
api.HandleFunc("/api/record/concurrency", recordConcurrentDownloadsStatus)
|
api.HandleFunc("/api/record/concurrency", recordConcurrentDownloadsStatus)
|
||||||
mux.HandleFunc("/api/record/release-file", handleReleaseFileTasks)
|
api.HandleFunc("/api/record/release-file", handleReleaseFileTasks)
|
||||||
|
|
||||||
|
// Training / ML Annotation
|
||||||
|
api.HandleFunc("/api/training/labels", trainingLabelsHandler)
|
||||||
|
api.HandleFunc("/api/training/next", trainingNextHandler)
|
||||||
|
api.HandleFunc("/api/training/frame", trainingFrameHandler)
|
||||||
|
api.HandleFunc("/api/training/feedback", trainingFeedbackHandler)
|
||||||
|
api.HandleFunc("/api/training/train", trainingTrainHandler)
|
||||||
|
api.HandleFunc("/api/training/status", trainingStatusHandler)
|
||||||
|
api.HandleFunc("/api/training/delete-all", trainingDeleteAllHandler)
|
||||||
|
|
||||||
api.HandleFunc("/api/chaturbate/online", chaturbateOnlineHandler)
|
api.HandleFunc("/api/chaturbate/online", chaturbateOnlineHandler)
|
||||||
api.HandleFunc("/api/chaturbate/biocontext", chaturbateBioContextHandler)
|
api.HandleFunc("/api/chaturbate/biocontext", chaturbateBioContextHandler)
|
||||||
|
|||||||
1180
backend/training.go
Normal file
1180
backend/training.go
Normal file
File diff suppressed because it is too large
Load Diff
149
backend/training_label_loader.go
Normal file
149
backend/training_label_loader.go
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
// backend\training_label_loader.go
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TrainingGroupedLabels struct {
|
||||||
|
SexPositions []string `json:"sexPositions"`
|
||||||
|
BodyParts []string `json:"bodyParts"`
|
||||||
|
Objects []string `json:"objects"`
|
||||||
|
Clothing []string `json:"clothing"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func trainingDetectionLabelsPath() string {
|
||||||
|
if dir, err := trainingEmbeddedMLDir(); err == nil {
|
||||||
|
p := filepath.Join(dir, "detection_labels.json")
|
||||||
|
if _, err := os.Stat(p); err == nil {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
projectRoot := trainingProjectRoot()
|
||||||
|
|
||||||
|
candidates := []string{
|
||||||
|
filepath.Join(projectRoot, "backend", "ml", "detection_labels.json"),
|
||||||
|
filepath.Join(projectRoot, "backend", "detection_labels.json"),
|
||||||
|
filepath.Join(projectRoot, "detection_labels.json"),
|
||||||
|
filepath.Join("ml", "detection_labels.json"),
|
||||||
|
filepath.Join("detection_labels.json"),
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, p := range candidates {
|
||||||
|
if _, err := os.Stat(p); err == nil {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return filepath.Join(projectRoot, "backend", "ml", "detection_labels.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
func trainingGroupedLabels() (TrainingGroupedLabels, error) {
|
||||||
|
path := trainingDetectionLabelsPath()
|
||||||
|
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return TrainingGroupedLabels{}, fmt.Errorf("detection_labels.json konnte nicht gelesen werden: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var grouped TrainingGroupedLabels
|
||||||
|
if err := json.Unmarshal(b, &grouped); err != nil {
|
||||||
|
return TrainingGroupedLabels{}, fmt.Errorf("detection_labels.json ist ungültig: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
grouped.SexPositions = uniqueNonEmptyLabels(grouped.SexPositions)
|
||||||
|
grouped.BodyParts = uniqueNonEmptyLabels(grouped.BodyParts)
|
||||||
|
grouped.Objects = uniqueNonEmptyLabels(grouped.Objects)
|
||||||
|
grouped.Clothing = uniqueNonEmptyLabels(grouped.Clothing)
|
||||||
|
|
||||||
|
if len(grouped.SexPositions) == 0 {
|
||||||
|
grouped.SexPositions = []string{"unknown"}
|
||||||
|
}
|
||||||
|
|
||||||
|
if 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
|
||||||
|
}
|
||||||
|
|
||||||
|
func trainingDetectorLabels() ([]string, error) {
|
||||||
|
grouped, err := trainingGroupedLabels()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
labels := []string{}
|
||||||
|
labels = append(labels, grouped.BodyParts...)
|
||||||
|
labels = append(labels, grouped.Objects...)
|
||||||
|
labels = append(labels, grouped.Clothing...)
|
||||||
|
|
||||||
|
return uniqueNonEmptyLabels(labels), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func uniqueNonEmptyLabels(values []string) []string {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
out := []string{}
|
||||||
|
|
||||||
|
for _, value := range values {
|
||||||
|
label := strings.TrimSpace(value)
|
||||||
|
if label == "" || seen[label] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
seen[label] = true
|
||||||
|
out = append(out, label)
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func trainingDetectorClassMap() (map[string]int, error) {
|
||||||
|
labels, err := trainingDetectorLabels()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
out := map[string]int{}
|
||||||
|
for i, label := range labels {
|
||||||
|
out[label] = i
|
||||||
|
}
|
||||||
|
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func trainingDetectorDatasetNamesYAML() (string, error) {
|
||||||
|
labels, err := trainingDetectorLabels()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
var b strings.Builder
|
||||||
|
for i, label := range labels {
|
||||||
|
b.WriteString(fmt.Sprintf(" %d: %s\n", i, label))
|
||||||
|
}
|
||||||
|
|
||||||
|
return b.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultTrainingLabelsFromJSON() TrainingLabels {
|
||||||
|
grouped, err := trainingGroupedLabels()
|
||||||
|
if err != nil {
|
||||||
|
grouped = TrainingGroupedLabels{
|
||||||
|
SexPositions: []string{"unknown"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return TrainingLabels{
|
||||||
|
SexPositions: grouped.SexPositions,
|
||||||
|
BodyParts: grouped.BodyParts,
|
||||||
|
Objects: grouped.Objects,
|
||||||
|
Clothing: grouped.Clothing,
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
backend/yolo11n.pt
Normal file
BIN
backend/yolo11n.pt
Normal file
Binary file not shown.
@ -23,6 +23,7 @@ import {
|
|||||||
UserGroupIcon,
|
UserGroupIcon,
|
||||||
Squares2X2Icon,
|
Squares2X2Icon,
|
||||||
Cog6ToothIcon,
|
Cog6ToothIcon,
|
||||||
|
BeakerIcon,
|
||||||
} from '@heroicons/react/24/solid'
|
} from '@heroicons/react/24/solid'
|
||||||
import PerformanceMonitor from './components/ui/PerformanceMonitor'
|
import PerformanceMonitor from './components/ui/PerformanceMonitor'
|
||||||
import { useNotify } from './components/ui/notify'
|
import { useNotify } from './components/ui/notify'
|
||||||
@ -31,6 +32,7 @@ import LoginPage from './components/ui/LoginPage'
|
|||||||
import VideoSplitModal from './components/ui/VideoSplitModal'
|
import VideoSplitModal from './components/ui/VideoSplitModal'
|
||||||
import TextInput from './components/ui/TextInput'
|
import TextInput from './components/ui/TextInput'
|
||||||
import LastUpdatedText from './components/ui/LastUpdatedText'
|
import LastUpdatedText from './components/ui/LastUpdatedText'
|
||||||
|
import TrainingTab from './components/ui/TrainingTab'
|
||||||
|
|
||||||
const COOKIE_STORAGE_KEY = 'record_cookies'
|
const COOKIE_STORAGE_KEY = 'record_cookies'
|
||||||
|
|
||||||
@ -2577,6 +2579,11 @@ export default function App() {
|
|||||||
label: 'Kategorien',
|
label: 'Kategorien',
|
||||||
icon: Squares2X2Icon,
|
icon: Squares2X2Icon,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'training',
|
||||||
|
label: 'Training',
|
||||||
|
icon: BeakerIcon,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'settings',
|
id: 'settings',
|
||||||
label: 'Einstellungen',
|
label: 'Einstellungen',
|
||||||
@ -2954,6 +2961,7 @@ export default function App() {
|
|||||||
if (d.tab === 'finished') setSelectedTab('finished')
|
if (d.tab === 'finished') setSelectedTab('finished')
|
||||||
if (d.tab === 'categories') setSelectedTab('categories')
|
if (d.tab === 'categories') setSelectedTab('categories')
|
||||||
if (d.tab === 'models') setSelectedTab('models')
|
if (d.tab === 'models') setSelectedTab('models')
|
||||||
|
if (d.tab === 'training') setSelectedTab('training')
|
||||||
if (d.tab === 'running') setSelectedTab('running')
|
if (d.tab === 'running') setSelectedTab('running')
|
||||||
if (d.tab === 'settings') setSelectedTab('settings')
|
if (d.tab === 'settings') setSelectedTab('settings')
|
||||||
}
|
}
|
||||||
@ -3970,7 +3978,13 @@ export default function App() {
|
|||||||
{selectedTab === 'models' ? (
|
{selectedTab === 'models' ? (
|
||||||
<ModelsTab onAddToDownloads={handleAddToDownloads} />
|
<ModelsTab onAddToDownloads={handleAddToDownloads} />
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{selectedTab === 'training' ? (
|
||||||
|
<TrainingTab />
|
||||||
|
) : null}
|
||||||
|
|
||||||
{selectedTab === 'categories' ? <CategoriesTab /> : null}
|
{selectedTab === 'categories' ? <CategoriesTab /> : null}
|
||||||
|
|
||||||
{selectedTab === 'settings' ? (
|
{selectedTab === 'settings' ? (
|
||||||
<RecorderSettings
|
<RecorderSettings
|
||||||
onAssetsGenerated={bumpAssets}
|
onAssetsGenerated={bumpAssets}
|
||||||
|
|||||||
@ -33,7 +33,7 @@ import RecordJobActions from './RecordJobActions'
|
|||||||
import Button from './Button'
|
import Button from './Button'
|
||||||
import { apiUrl, apiFetch } from '../../lib/api'
|
import { apiUrl, apiFetch } from '../../lib/api'
|
||||||
import LiveVideo from './LiveVideo'
|
import LiveVideo from './LiveVideo'
|
||||||
import { formatResolution, formatFps } from './formatters'
|
import { formatResolution, formatFps, formatDateTime, formatBytes, formatDuration } from './formatters'
|
||||||
import LoadingSpinner from './LoadingSpinner'
|
import LoadingSpinner from './LoadingSpinner'
|
||||||
|
|
||||||
function buildChaturbateCookieHeader(): string {
|
function buildChaturbateCookieHeader(): string {
|
||||||
@ -88,44 +88,6 @@ const parseTags = (raw?: string): string[] => {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDuration(ms: number): string {
|
|
||||||
if (!Number.isFinite(ms) || ms <= 0) return '—'
|
|
||||||
const totalSec = Math.floor(ms / 1000)
|
|
||||||
const h = Math.floor(totalSec / 3600)
|
|
||||||
const m = Math.floor((totalSec % 3600) / 60)
|
|
||||||
const s = totalSec % 60
|
|
||||||
if (h > 0) return `${h}h ${m}m`
|
|
||||||
if (m > 0) return `${m}m ${s}s`
|
|
||||||
return `${s}s`
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatBytes(bytes?: number | null): string {
|
|
||||||
if (typeof bytes !== 'number' || !Number.isFinite(bytes) || bytes <= 0) return '—'
|
|
||||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
|
||||||
let v = bytes
|
|
||||||
let i = 0
|
|
||||||
while (v >= 1024 && i < units.length - 1) {
|
|
||||||
v /= 1024
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
const digits = i === 0 ? 0 : v >= 100 ? 0 : v >= 10 ? 1 : 2
|
|
||||||
return `${v.toFixed(digits)} ${units[i]}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDateTime(v?: string | number | Date | null): string {
|
|
||||||
if (!v) return '—'
|
|
||||||
const d = v instanceof Date ? v : new Date(v)
|
|
||||||
const t = d.getTime()
|
|
||||||
if (!Number.isFinite(t)) return '—'
|
|
||||||
return d.toLocaleString(undefined, {
|
|
||||||
year: 'numeric',
|
|
||||||
month: '2-digit',
|
|
||||||
day: '2-digit',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const pickNum = (...vals: any[]): number | null => {
|
const pickNum = (...vals: any[]): number | null => {
|
||||||
for (const v of vals) {
|
for (const v of vals) {
|
||||||
const n = typeof v === 'string' ? Number(v) : v
|
const n = typeof v === 'string' ? Number(v) : v
|
||||||
|
|||||||
805
frontend/src/components/ui/TrainingTab.tsx
Normal file
805
frontend/src/components/ui/TrainingTab.tsx
Normal file
@ -0,0 +1,805 @@
|
|||||||
|
// frontend/src/components/ui/TrainingTab.tsx
|
||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
|
import Button from './Button'
|
||||||
|
import LoadingSpinner from './LoadingSpinner'
|
||||||
|
import { formatBytes, formatDuration } from './formatters'
|
||||||
|
|
||||||
|
type ScoredLabel = {
|
||||||
|
label: string
|
||||||
|
score: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type TrainingStatus = {
|
||||||
|
feedbackCount: number
|
||||||
|
requiredCount: number
|
||||||
|
canTrain: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type TrainingPrediction = {
|
||||||
|
modelAvailable: boolean
|
||||||
|
source?: string
|
||||||
|
|
||||||
|
peopleCount: number
|
||||||
|
maleCount: number
|
||||||
|
femaleCount: number
|
||||||
|
unknownCount: number
|
||||||
|
sexPosition: string
|
||||||
|
sexPositionScore: number
|
||||||
|
bodyPartsPresent: ScoredLabel[]
|
||||||
|
objectsPresent: ScoredLabel[]
|
||||||
|
clothingPresent: ScoredLabel[]
|
||||||
|
boxes?: TrainingBox[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type TrainingSample = {
|
||||||
|
sampleId: string
|
||||||
|
frameUrl: string
|
||||||
|
sourceFile: string
|
||||||
|
sourcePath?: string
|
||||||
|
sourceSizeBytes?: number
|
||||||
|
second: number
|
||||||
|
createdAt: string
|
||||||
|
prediction: TrainingPrediction
|
||||||
|
}
|
||||||
|
|
||||||
|
type TrainingLabels = {
|
||||||
|
sexPositions: string[]
|
||||||
|
bodyParts: string[]
|
||||||
|
objects: string[]
|
||||||
|
clothing: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type CorrectionState = {
|
||||||
|
peopleCount: number
|
||||||
|
maleCount: number
|
||||||
|
femaleCount: number
|
||||||
|
unknownCount: number
|
||||||
|
sexPosition: string
|
||||||
|
bodyPartsPresent: string[]
|
||||||
|
objectsPresent: string[]
|
||||||
|
clothingPresent: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type TrainingBox = {
|
||||||
|
label: string
|
||||||
|
score?: number
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
w: number
|
||||||
|
h: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyLabels: TrainingLabels = {
|
||||||
|
sexPositions: ['unknown'],
|
||||||
|
bodyParts: [],
|
||||||
|
objects: [],
|
||||||
|
clothing: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
function percent(v: number) {
|
||||||
|
if (!Number.isFinite(v)) return '—'
|
||||||
|
return `${Math.round(v * 100)}%`
|
||||||
|
}
|
||||||
|
|
||||||
|
function scoredLabelsText(items?: ScoredLabel[] | null) {
|
||||||
|
const list = Array.isArray(items) ? items : []
|
||||||
|
|
||||||
|
if (list.length === 0) return '—'
|
||||||
|
|
||||||
|
return list
|
||||||
|
.map((x) => {
|
||||||
|
const label = String(x?.label ?? '').trim()
|
||||||
|
if (!label) return null
|
||||||
|
|
||||||
|
return `${label} (${percent(Number(x?.score ?? 0))})`
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(', ')
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectionBoxes(sample: TrainingSample | null) {
|
||||||
|
const boxes = sample?.prediction.boxes ?? []
|
||||||
|
|
||||||
|
return boxes.filter((box) => {
|
||||||
|
const label = String(box.label || '').trim()
|
||||||
|
if (!label) return false
|
||||||
|
|
||||||
|
const isBodyPart = sample?.prediction.bodyPartsPresent?.some((x) => x.label === label)
|
||||||
|
const isObject = sample?.prediction.objectsPresent?.some((x) => x.label === label)
|
||||||
|
|
||||||
|
return isBodyPart || isObject
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampPercent(v: number) {
|
||||||
|
if (!Number.isFinite(v)) return 0
|
||||||
|
return Math.max(0, Math.min(100, v))
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleArrayValue(arr: string[], value: string) {
|
||||||
|
return arr.includes(value)
|
||||||
|
? arr.filter((x) => x !== value)
|
||||||
|
: [...arr, value]
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeCount(value: unknown) {
|
||||||
|
const n = Number(value)
|
||||||
|
return Number.isFinite(n) && n > 0 ? Math.floor(n) : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function totalPeopleFromGenderCounts(state: Pick<CorrectionState, 'maleCount' | 'femaleCount'>) {
|
||||||
|
return safeCount(state.maleCount) + safeCount(state.femaleCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
function predictionToCorrection(sample: TrainingSample | null): CorrectionState {
|
||||||
|
const p = sample?.prediction
|
||||||
|
|
||||||
|
const maleCount = safeCount(p?.maleCount)
|
||||||
|
const femaleCount = safeCount(p?.femaleCount)
|
||||||
|
|
||||||
|
return {
|
||||||
|
peopleCount: maleCount + femaleCount,
|
||||||
|
maleCount,
|
||||||
|
femaleCount,
|
||||||
|
unknownCount: 0,
|
||||||
|
sexPosition: p?.sexPosition || 'unknown',
|
||||||
|
bodyPartsPresent: (p?.bodyPartsPresent ?? []).map((x) => x.label),
|
||||||
|
objectsPresent: (p?.objectsPresent ?? []).map((x) => x.label),
|
||||||
|
clothingPresent: (p?.clothingPresent ?? []).map((x) => x.label),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortLabelList(values?: string[], opts?: { keepUnknownFirst?: boolean }) {
|
||||||
|
const list = [...(values ?? [])].sort((a, b) =>
|
||||||
|
a.localeCompare(b, undefined, { sensitivity: 'base' })
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!opts?.keepUnknownFirst) return list
|
||||||
|
|
||||||
|
return [
|
||||||
|
...list.filter((x) => x === 'unknown'),
|
||||||
|
...list.filter((x) => x !== 'unknown'),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortTrainingLabels(input: Partial<TrainingLabels> | null | undefined): TrainingLabels {
|
||||||
|
return {
|
||||||
|
sexPositions: sortLabelList(input?.sexPositions, { keepUnknownFirst: true }),
|
||||||
|
bodyParts: sortLabelList(input?.bodyParts),
|
||||||
|
objects: sortLabelList(input?.objects),
|
||||||
|
clothing: sortLabelList(input?.clothing),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TrainingTab() {
|
||||||
|
const [labels, setLabels] = useState<TrainingLabels>(emptyLabels)
|
||||||
|
const [sample, setSample] = useState<TrainingSample | null>(null)
|
||||||
|
const [correction, setCorrection] = useState<CorrectionState>(() => predictionToCorrection(null))
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [training, setTraining] = useState(false)
|
||||||
|
const [trainingStatus, setTrainingStatus] = useState<TrainingStatus | null>(null)
|
||||||
|
const [deletingTrainingData, setDeletingTrainingData] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [message, setMessage] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const computedPeopleCount = useMemo(
|
||||||
|
() => totalPeopleFromGenderCounts(correction),
|
||||||
|
[correction.maleCount, correction.femaleCount]
|
||||||
|
)
|
||||||
|
|
||||||
|
const imageSrc = useMemo(() => {
|
||||||
|
if (!sample?.frameUrl) return ''
|
||||||
|
return `${sample.frameUrl}&t=${encodeURIComponent(sample.sampleId)}`
|
||||||
|
}, [sample])
|
||||||
|
|
||||||
|
const boxes = useMemo(() => detectionBoxes(sample), [sample])
|
||||||
|
|
||||||
|
const canStartTraining = Boolean(trainingStatus?.canTrain)
|
||||||
|
const feedbackCount = trainingStatus?.feedbackCount ?? 0
|
||||||
|
const requiredCount = trainingStatus?.requiredCount ?? 5
|
||||||
|
|
||||||
|
const loadLabels = useCallback(async () => {
|
||||||
|
const res = await fetch('/api/training/labels', { cache: 'no-store' })
|
||||||
|
if (!res.ok) return
|
||||||
|
|
||||||
|
const data = await res.json().catch(() => null)
|
||||||
|
if (data) setLabels(sortTrainingLabels(data))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const loadNext = useCallback(async (opts?: { forceNew?: boolean }) => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
setMessage(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = opts?.forceNew
|
||||||
|
? '/api/training/next?force=1'
|
||||||
|
: '/api/training/next'
|
||||||
|
|
||||||
|
const res = await fetch(url, { cache: 'no-store' })
|
||||||
|
const data = await res.json().catch(() => null)
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(data?.error || `HTTP ${res.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
setSample(data)
|
||||||
|
setCorrection(predictionToCorrection(data))
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e))
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const loadTrainingStatus = useCallback(async () => {
|
||||||
|
const res = await fetch('/api/training/status', { cache: 'no-store' })
|
||||||
|
const data = await res.json().catch(() => null)
|
||||||
|
|
||||||
|
if (!res.ok || !data) return
|
||||||
|
|
||||||
|
setTrainingStatus({
|
||||||
|
feedbackCount: Number(data.feedbackCount ?? 0),
|
||||||
|
requiredCount: Number(data.requiredCount ?? 5),
|
||||||
|
canTrain: Boolean(data.canTrain),
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadLabels()
|
||||||
|
void loadNext()
|
||||||
|
void loadTrainingStatus()
|
||||||
|
}, [loadLabels, loadNext, loadTrainingStatus])
|
||||||
|
|
||||||
|
const saveFeedback = useCallback(
|
||||||
|
async (accepted: boolean) => {
|
||||||
|
if (!sample) return
|
||||||
|
|
||||||
|
setSaving(true)
|
||||||
|
setError(null)
|
||||||
|
setMessage(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const correctionPayload: CorrectionState = {
|
||||||
|
...correction,
|
||||||
|
peopleCount: totalPeopleFromGenderCounts(correction),
|
||||||
|
unknownCount: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
sampleId: sample.sampleId,
|
||||||
|
accepted,
|
||||||
|
correction: accepted ? undefined : correctionPayload,
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch('/api/training/feedback', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
|
||||||
|
const data = await res.json().catch(() => null)
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(data?.error || `HTTP ${res.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadTrainingStatus()
|
||||||
|
await loadNext()
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e))
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[sample, correction, loadNext, loadTrainingStatus]
|
||||||
|
)
|
||||||
|
|
||||||
|
const startTraining = useCallback(async () => {
|
||||||
|
setTraining(true)
|
||||||
|
setError(null)
|
||||||
|
setMessage(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/training/train', {
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
|
||||||
|
const data = await res.json().catch(() => null)
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(data?.error || `HTTP ${res.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
setMessage(data?.message || 'Training gestartet.')
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e))
|
||||||
|
} finally {
|
||||||
|
setTraining(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const deleteAllTrainingData = useCallback(async () => {
|
||||||
|
const confirmed = window.confirm(
|
||||||
|
'Wirklich alle Trainingsdaten löschen? Das entfernt Feedback, Frames, Samples und Detector-Daten. Diese Aktion kann nicht rückgängig gemacht werden.'
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
|
setDeletingTrainingData(true)
|
||||||
|
setError(null)
|
||||||
|
setMessage(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/training/delete-all', {
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
|
||||||
|
const data = await res.json().catch(() => null)
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(data?.error || `HTTP ${res.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
setSample(null)
|
||||||
|
setCorrection(predictionToCorrection(null))
|
||||||
|
setTrainingStatus({
|
||||||
|
feedbackCount: 0,
|
||||||
|
requiredCount,
|
||||||
|
canTrain: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
setMessage(data?.message || 'Alle Trainingsdaten wurden gelöscht.')
|
||||||
|
|
||||||
|
await loadTrainingStatus()
|
||||||
|
await loadNext({ forceNew: true })
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : String(e))
|
||||||
|
} finally {
|
||||||
|
setDeletingTrainingData(false)
|
||||||
|
}
|
||||||
|
}, [loadNext, loadTrainingStatus, requiredCount])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 items-stretch gap-3 lg:grid-cols-[300px_minmax(0,1fr)_300px] xl:grid-cols-[320px_minmax(0,1fr)_320px]">
|
||||||
|
{/* Sidebar links */}
|
||||||
|
<aside className="max-h-[calc(100dvh-190px)] overflow-y-auto rounded-xl border border-gray-200 bg-white p-3 shadow-sm dark:border-white/10 dark:bg-gray-900/60">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||||
|
Training
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="primary"
|
||||||
|
color='red'
|
||||||
|
className="shrink-0 px-2 py-1 text-[11px]"
|
||||||
|
disabled={loading || saving || training || deletingTrainingData}
|
||||||
|
onClick={() => void deleteAllTrainingData()}
|
||||||
|
title="Löscht alle gespeicherten Trainingsdaten."
|
||||||
|
>
|
||||||
|
{deletingTrainingData ? 'Lösche…' : 'Löschen'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 text-xs text-gray-600 dark:text-gray-300">
|
||||||
|
Bestätige oder korrigiere die Analyse. Jede Antwort wird als Trainingsdatenpunkt gespeichert.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 rounded-lg bg-gray-50 p-2 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="text-xs font-medium text-gray-900 dark:text-white">
|
||||||
|
Feedback
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={[
|
||||||
|
'rounded-full px-2 py-0.5 text-[11px] font-medium',
|
||||||
|
canStartTraining
|
||||||
|
? 'bg-emerald-50 text-emerald-700 ring-1 ring-emerald-200 dark:bg-emerald-500/10 dark:text-emerald-200 dark:ring-emerald-500/20'
|
||||||
|
: 'bg-amber-50 text-amber-700 ring-1 ring-amber-200 dark:bg-amber-500/10 dark:text-amber-200 dark:ring-amber-500/20',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
{feedbackCount}/{requiredCount}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-1 text-[11px] leading-relaxed text-gray-500 dark:text-gray-400">
|
||||||
|
{canStartTraining
|
||||||
|
? 'Genug Feedback für das Training vorhanden.'
|
||||||
|
: `${Math.max(0, requiredCount - feedbackCount)} weitere Bewertung(en) bis zum Training.`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 space-y-2 text-xs">
|
||||||
|
<div className="rounded-lg bg-gray-50 p-2 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
|
||||||
|
<div className="font-medium text-gray-900 dark:text-white">Datei</div>
|
||||||
|
<div className="mt-1 break-words text-gray-600 dark:text-gray-300">
|
||||||
|
{sample?.sourceFile || '—'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div className="rounded-lg bg-gray-50 p-2 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
|
||||||
|
<div className="font-medium text-gray-900 dark:text-white">Dateigröße</div>
|
||||||
|
<div className="mt-1 text-gray-600 dark:text-gray-300">
|
||||||
|
{formatBytes(sample?.sourceSizeBytes)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg bg-gray-50 p-2 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
|
||||||
|
<div className="font-medium text-gray-900 dark:text-white">Frame-Zeit</div>
|
||||||
|
<div className="mt-1 text-gray-600 dark:text-gray-300">
|
||||||
|
{sample ? formatDuration(sample.second * 1000) : '—'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 rounded-lg bg-gray-50 p-2 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
|
||||||
|
<div className="text-xs font-medium text-gray-700 dark:text-gray-200">
|
||||||
|
Aktuelle Modell-Erkennung
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-1 text-[11px] text-gray-500 dark:text-gray-400">
|
||||||
|
{sample?.prediction.modelAvailable
|
||||||
|
? `Modell aktiv: ${sample.prediction.source || 'lokal'}`
|
||||||
|
: 'Noch kein trainiertes Modell vorhanden.'}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 space-y-1 text-xs text-gray-600 dark:text-gray-300">
|
||||||
|
<div>Personen: {sample?.prediction.peopleCount ?? '—'}</div>
|
||||||
|
<div>Männlich: {sample?.prediction.maleCount ?? '—'}</div>
|
||||||
|
<div>Weiblich: {sample?.prediction.femaleCount ?? '—'}</div>
|
||||||
|
<div>Unklar: {sample?.prediction.unknownCount ?? '—'}</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
Position: {sample?.prediction.sexPosition ?? '—'}{' '}
|
||||||
|
{sample ? `(${percent(sample.prediction.sexPositionScore)})` : ''}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
Körperteile: {scoredLabelsText(sample?.prediction.bodyPartsPresent)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
Gegenstände: {scoredLabelsText(sample?.prediction.objectsPresent)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
Kleidung: {scoredLabelsText(sample?.prediction.clothingPresent)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 space-y-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
className="w-full"
|
||||||
|
disabled={loading || saving}
|
||||||
|
onClick={() => void loadNext({ forceNew: true })}
|
||||||
|
>
|
||||||
|
Anderes Bild
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="soft"
|
||||||
|
className="w-full"
|
||||||
|
disabled={training || !canStartTraining}
|
||||||
|
onClick={() => void startTraining()}
|
||||||
|
title={
|
||||||
|
canStartTraining
|
||||||
|
? 'Training mit allen gespeicherten Bewertungen starten.'
|
||||||
|
: `Noch zu wenig Feedback: ${feedbackCount}/${requiredCount}.`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Training starten
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="text-[11px] leading-relaxed text-gray-500 dark:text-gray-400">
|
||||||
|
{canStartTraining ? (
|
||||||
|
<>
|
||||||
|
Nutzt alle bisher gespeicherten Bewertungen. Das aktuelle Bild wird nur einbezogen,
|
||||||
|
wenn du es vorher speicherst.
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
Noch nicht genug Feedback für das Training. Aktuell {feedbackCount}/{requiredCount} Bewertungen gespeichert.
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{message ? (
|
||||||
|
<div className="mt-3 rounded-lg bg-emerald-50 px-3 py-2 text-xs text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-200">
|
||||||
|
{message}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<div className="mt-3 rounded-lg bg-red-50 px-3 py-2 text-xs text-red-700 dark:bg-red-500/10 dark:text-red-200">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Mitte */}
|
||||||
|
<section className="min-w-0 rounded-xl border border-gray-200 bg-white p-3 shadow-sm dark:border-white/10 dark:bg-gray-900/60">
|
||||||
|
<div className="relative flex min-h-[360px] flex-1 items-center justify-center overflow-hidden rounded-lg bg-black">
|
||||||
|
{loading ? (
|
||||||
|
<LoadingSpinner
|
||||||
|
size="lg"
|
||||||
|
center
|
||||||
|
className="text-white/80"
|
||||||
|
srLabel="Frame wird geladen…"
|
||||||
|
/>
|
||||||
|
) : imageSrc ? (
|
||||||
|
<div className="relative flex h-full w-full items-center justify-center">
|
||||||
|
<img
|
||||||
|
src={imageSrc}
|
||||||
|
alt="Training Frame"
|
||||||
|
className="max-h-[72dvh] w-full object-contain"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{boxes.length > 0 ? (
|
||||||
|
<div className="pointer-events-none absolute inset-0">
|
||||||
|
{boxes.map((box, index) => {
|
||||||
|
const left = clampPercent(box.x * 100)
|
||||||
|
const top = clampPercent(box.y * 100)
|
||||||
|
const width = clampPercent(box.w * 100)
|
||||||
|
const height = clampPercent(box.h * 100)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`${box.label}-${index}`}
|
||||||
|
className="absolute rounded border-2 border-emerald-400 shadow-[0_0_0_1px_rgba(0,0,0,0.75)]"
|
||||||
|
style={{
|
||||||
|
left: `${left}%`,
|
||||||
|
top: `${top}%`,
|
||||||
|
width: `${width}%`,
|
||||||
|
height: `${height}%`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="absolute left-0 top-0 -translate-y-full rounded-t bg-emerald-400 px-1.5 py-0.5 text-[11px] font-semibold text-black shadow">
|
||||||
|
{box.label} {typeof box.score === 'number' ? percent(box.score) : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-white/80">Kein Frame geladen</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 grid grid-cols-1 gap-2 sm:grid-cols-3">
|
||||||
|
<Button
|
||||||
|
variant="soft"
|
||||||
|
color="emerald"
|
||||||
|
disabled={!sample || saving || loading || !sample.prediction.modelAvailable}
|
||||||
|
onClick={() => void saveFeedback(true)}
|
||||||
|
className="w-full justify-center"
|
||||||
|
title={
|
||||||
|
sample?.prediction.modelAvailable
|
||||||
|
? 'Die Erkennung stimmt. Prediction wird als korrekt gespeichert.'
|
||||||
|
: 'Erst verfügbar, wenn ein Modell trainiert wurde.'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Passt so & weiter
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
disabled={!sample || saving || loading}
|
||||||
|
onClick={() => void saveFeedback(false)}
|
||||||
|
className="w-full justify-center"
|
||||||
|
title="Die Werte rechts werden als richtige Korrektur gespeichert."
|
||||||
|
>
|
||||||
|
Korrektur speichern & weiter
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
disabled={loading || saving}
|
||||||
|
onClick={() => void loadNext({ forceNew: true })}
|
||||||
|
className="w-full justify-center"
|
||||||
|
title="Dieses Bild nicht bewerten und ein anderes laden."
|
||||||
|
>
|
||||||
|
Überspringen
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 text-center text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Prüfe das Bild. Wenn die Erkennung stimmt: „Passt so“. Wenn nicht: rechts korrigieren und speichern.
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Rechte Details/Korrektur */}
|
||||||
|
<aside className="rounded-xl border border-gray-200 bg-white p-3 shadow-sm dark:border-white/10 dark:bg-gray-900/60">
|
||||||
|
<div className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||||
|
Korrektur / Wahrheit
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Diese Werte werden gespeichert, wenn du „Korrektur speichern & weiter“ klickst.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 space-y-3">
|
||||||
|
<div className="flex items-center justify-between rounded-lg bg-gray-50 px-2 py-1.5 ring-1 ring-black/5 dark:bg-white/5 dark:ring-white/10">
|
||||||
|
<div>
|
||||||
|
<div className="text-[11px] font-medium text-gray-700 dark:text-gray-200">
|
||||||
|
Personenanzahl
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-gray-500 dark:text-gray-400">
|
||||||
|
automatisch aus weiblich + männlich
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-full bg-white px-2 py-0.5 text-xs font-semibold text-gray-800 ring-1 ring-gray-200 dark:bg-gray-950 dark:text-gray-100 dark:ring-white/10">
|
||||||
|
{computedPeopleCount}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-medium text-gray-700 dark:text-gray-200">
|
||||||
|
Weiblich
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={correction.femaleCount}
|
||||||
|
onChange={(e) =>
|
||||||
|
setCorrection((p) => ({
|
||||||
|
...p,
|
||||||
|
femaleCount: safeCount(e.target.value),
|
||||||
|
unknownCount: 0,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
className="mt-1 h-9 w-full rounded-md border border-gray-200 bg-white px-2 text-sm dark:border-white/10 dark:bg-gray-950"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-medium text-gray-700 dark:text-gray-200">
|
||||||
|
Männlich
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={correction.maleCount}
|
||||||
|
onChange={(e) =>
|
||||||
|
setCorrection((p) => ({
|
||||||
|
...p,
|
||||||
|
maleCount: safeCount(e.target.value),
|
||||||
|
unknownCount: 0,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
className="mt-1 h-9 w-full rounded-md border border-gray-200 bg-white px-2 text-sm dark:border-white/10 dark:bg-gray-950"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-medium text-gray-700 dark:text-gray-200">
|
||||||
|
Sexposition
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={correction.sexPosition}
|
||||||
|
onChange={(e) =>
|
||||||
|
setCorrection((p) => ({ ...p, sexPosition: e.target.value }))
|
||||||
|
}
|
||||||
|
className="mt-1 h-9 w-full rounded-md border border-gray-200 bg-white px-2 text-sm dark:border-white/10 dark:bg-gray-950"
|
||||||
|
>
|
||||||
|
{labels.sexPositions.map((x) => (
|
||||||
|
<option key={x} value={x}>
|
||||||
|
{x}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-medium text-gray-700 dark:text-gray-200">
|
||||||
|
Körperteile
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 flex flex-wrap gap-1.5">
|
||||||
|
{labels.bodyParts.map((x) => {
|
||||||
|
const active = correction.bodyPartsPresent.includes(x)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={x}
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setCorrection((p) => ({
|
||||||
|
...p,
|
||||||
|
bodyPartsPresent: toggleArrayValue(p.bodyPartsPresent, x),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
className={[
|
||||||
|
'rounded-full px-2 py-1 text-[11px] ring-1 transition',
|
||||||
|
active
|
||||||
|
? 'bg-indigo-50 text-indigo-700 ring-indigo-200 dark:bg-indigo-500/20 dark:text-indigo-200 dark:ring-indigo-500/30'
|
||||||
|
: 'bg-gray-50 text-gray-600 ring-gray-200 dark:bg-white/5 dark:text-gray-300 dark:ring-white/10',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
{x}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-medium text-gray-700 dark:text-gray-200">
|
||||||
|
Gegenstände
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 flex flex-wrap gap-1.5">
|
||||||
|
{labels.objects.map((x) => {
|
||||||
|
const active = correction.objectsPresent.includes(x)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={x}
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setCorrection((p) => ({
|
||||||
|
...p,
|
||||||
|
objectsPresent: toggleArrayValue(p.objectsPresent, x),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
className={[
|
||||||
|
'rounded-full px-2 py-1 text-[11px] ring-1 transition',
|
||||||
|
active
|
||||||
|
? 'bg-indigo-50 text-indigo-700 ring-indigo-200 dark:bg-indigo-500/20 dark:text-indigo-200 dark:ring-indigo-500/30'
|
||||||
|
: 'bg-gray-50 text-gray-600 ring-gray-200 dark:bg-white/5 dark:text-gray-300 dark:ring-white/10',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
{x}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-medium text-gray-700 dark:text-gray-200">
|
||||||
|
Kleidung
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 flex flex-wrap gap-1.5">
|
||||||
|
{labels.clothing.map((x) => {
|
||||||
|
const active = correction.clothingPresent.includes(x)
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={x}
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setCorrection((p) => ({
|
||||||
|
...p,
|
||||||
|
clothingPresent: toggleArrayValue(p.clothingPresent, x),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
className={[
|
||||||
|
'rounded-full px-2 py-1 text-[11px] ring-1 transition',
|
||||||
|
active
|
||||||
|
? 'bg-indigo-50 text-indigo-700 ring-indigo-200 dark:bg-indigo-500/20 dark:text-indigo-200 dark:ring-indigo-500/30'
|
||||||
|
: 'bg-gray-50 text-gray-600 ring-gray-200 dark:bg-white/5 dark:text-gray-300 dark:ring-white/10',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
{x}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -12,6 +12,7 @@ import {
|
|||||||
getSegmentLabelText,
|
getSegmentLabelText,
|
||||||
getHighestPrioritySegmentLabelItem,
|
getHighestPrioritySegmentLabelItem,
|
||||||
} from './Icons'
|
} from './Icons'
|
||||||
|
import { formatDateTime } from './formatters'
|
||||||
|
|
||||||
type Segment = {
|
type Segment = {
|
||||||
index: number
|
index: number
|
||||||
@ -364,20 +365,6 @@ function formatBytes(bytes?: number | null): string {
|
|||||||
return `${v.toFixed(digits)} ${units[i]}`
|
return `${v.toFixed(digits)} ${units[i]}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDateTime(v?: string | number | Date | null): string {
|
|
||||||
if (!v) return '—'
|
|
||||||
const d = v instanceof Date ? v : new Date(v)
|
|
||||||
const t = d.getTime()
|
|
||||||
if (!Number.isFinite(t)) return '—'
|
|
||||||
return d.toLocaleString(undefined, {
|
|
||||||
year: 'numeric',
|
|
||||||
month: '2-digit',
|
|
||||||
day: '2-digit',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function pickNum(...vals: any[]): number | null {
|
function pickNum(...vals: any[]): number | null {
|
||||||
for (const v of vals) {
|
for (const v of vals) {
|
||||||
const n = typeof v === 'string' ? Number(v) : v
|
const n = typeof v === 'string' ? Number(v) : v
|
||||||
|
|||||||
@ -35,7 +35,6 @@ export function formatResolution(r?: { w: number; h: number } | null): string {
|
|||||||
return `${p}p`
|
return `${p}p`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export function formatDuration(ms: number): string {
|
export function formatDuration(ms: number): string {
|
||||||
if (!Number.isFinite(ms) || ms <= 0) return '—'
|
if (!Number.isFinite(ms) || ms <= 0) return '—'
|
||||||
const totalSec = Math.floor(ms / 1000)
|
const totalSec = Math.floor(ms / 1000)
|
||||||
@ -59,3 +58,18 @@ export function formatBytes(bytes?: number | null): string {
|
|||||||
const digits = i === 0 ? 0 : v >= 100 ? 0 : v >= 10 ? 1 : 2
|
const digits = i === 0 ? 0 : v >= 100 ? 0 : v >= 10 ? 1 : 2
|
||||||
return `${v.toFixed(digits)} ${units[i]}`
|
return `${v.toFixed(digits)} ${units[i]}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function formatDateTime(v?: string | number | Date | null): string {
|
||||||
|
if (!v) return '—'
|
||||||
|
const d = v instanceof Date ? v : new Date(v)
|
||||||
|
const t = d.getTime()
|
||||||
|
if (!Number.isFinite(t)) return '—'
|
||||||
|
return d.toLocaleString(undefined, {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user