added detection model
This commit is contained in:
parent
bd2dc1f965
commit
58b39ac42a
@ -11,6 +11,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
@ -1211,6 +1212,9 @@ func uniqueDestPath(dstDir, file string) (string, error) {
|
||||
}
|
||||
|
||||
func clamp01(v float64) float64 {
|
||||
if math.IsNaN(v) || math.IsInf(v, 0) {
|
||||
return 0
|
||||
}
|
||||
if v < 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/keep-many", handleKeepMany)
|
||||
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/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,
|
||||
Squares2X2Icon,
|
||||
Cog6ToothIcon,
|
||||
BeakerIcon,
|
||||
} from '@heroicons/react/24/solid'
|
||||
import PerformanceMonitor from './components/ui/PerformanceMonitor'
|
||||
import { useNotify } from './components/ui/notify'
|
||||
@ -31,6 +32,7 @@ import LoginPage from './components/ui/LoginPage'
|
||||
import VideoSplitModal from './components/ui/VideoSplitModal'
|
||||
import TextInput from './components/ui/TextInput'
|
||||
import LastUpdatedText from './components/ui/LastUpdatedText'
|
||||
import TrainingTab from './components/ui/TrainingTab'
|
||||
|
||||
const COOKIE_STORAGE_KEY = 'record_cookies'
|
||||
|
||||
@ -2577,6 +2579,11 @@ export default function App() {
|
||||
label: 'Kategorien',
|
||||
icon: Squares2X2Icon,
|
||||
},
|
||||
{
|
||||
id: 'training',
|
||||
label: 'Training',
|
||||
icon: BeakerIcon,
|
||||
},
|
||||
{
|
||||
id: 'settings',
|
||||
label: 'Einstellungen',
|
||||
@ -2954,6 +2961,7 @@ export default function App() {
|
||||
if (d.tab === 'finished') setSelectedTab('finished')
|
||||
if (d.tab === 'categories') setSelectedTab('categories')
|
||||
if (d.tab === 'models') setSelectedTab('models')
|
||||
if (d.tab === 'training') setSelectedTab('training')
|
||||
if (d.tab === 'running') setSelectedTab('running')
|
||||
if (d.tab === 'settings') setSelectedTab('settings')
|
||||
}
|
||||
@ -3970,7 +3978,13 @@ export default function App() {
|
||||
{selectedTab === 'models' ? (
|
||||
<ModelsTab onAddToDownloads={handleAddToDownloads} />
|
||||
) : null}
|
||||
|
||||
{selectedTab === 'training' ? (
|
||||
<TrainingTab />
|
||||
) : null}
|
||||
|
||||
{selectedTab === 'categories' ? <CategoriesTab /> : null}
|
||||
|
||||
{selectedTab === 'settings' ? (
|
||||
<RecorderSettings
|
||||
onAssetsGenerated={bumpAssets}
|
||||
|
||||
@ -33,7 +33,7 @@ import RecordJobActions from './RecordJobActions'
|
||||
import Button from './Button'
|
||||
import { apiUrl, apiFetch } from '../../lib/api'
|
||||
import LiveVideo from './LiveVideo'
|
||||
import { formatResolution, formatFps } from './formatters'
|
||||
import { formatResolution, formatFps, formatDateTime, formatBytes, formatDuration } from './formatters'
|
||||
import LoadingSpinner from './LoadingSpinner'
|
||||
|
||||
function buildChaturbateCookieHeader(): string {
|
||||
@ -88,44 +88,6 @@ const parseTags = (raw?: string): string[] => {
|
||||
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 => {
|
||||
for (const v of vals) {
|
||||
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,
|
||||
getHighestPrioritySegmentLabelItem,
|
||||
} from './Icons'
|
||||
import { formatDateTime } from './formatters'
|
||||
|
||||
type Segment = {
|
||||
index: number
|
||||
@ -364,20 +365,6 @@ function formatBytes(bytes?: number | null): string {
|
||||
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 {
|
||||
for (const v of vals) {
|
||||
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`
|
||||
}
|
||||
|
||||
|
||||
export function formatDuration(ms: number): string {
|
||||
if (!Number.isFinite(ms) || ms <= 0) return '—'
|
||||
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
|
||||
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