nsfwapp/backend/ai_server.py
2026-07-13 11:49:46 +02:00

2088 lines
64 KiB
Python

# backend\ai_server.py
import json
import math
import os
import secrets
import tempfile
from pathlib import Path
from typing import List, Optional
from fastapi import Depends, FastAPI, HTTPException, Request, status
from pydantic import BaseModel
from ultralytics import YOLO
BASE_DIR = Path(__file__).resolve().parent
KEYPOINT_NAMES = [
"nose",
"left_eye", "right_eye",
"left_ear", "right_ear",
"left_shoulder", "right_shoulder",
"left_elbow", "right_elbow",
"left_wrist", "right_wrist",
"left_hip", "right_hip",
"left_knee", "right_knee",
"left_ankle", "right_ankle",
]
NO_SEX_POSITION_LABEL = "keine"
NO_SEX_POSITION_ALIASES = {
"",
NO_SEX_POSITION_LABEL,
}
def normalize_sex_position_label(value) -> str:
clean = str(value or "").strip().lower()
if clean in NO_SEX_POSITION_ALIASES:
return NO_SEX_POSITION_LABEL
return clean
def is_no_sex_position_label(value) -> bool:
return normalize_sex_position_label(value) == NO_SEX_POSITION_LABEL
def normalize_sex_position_labels(values) -> set[str]:
labels = set()
has_no_position = False
for value in values or []:
clean = normalize_sex_position_label(value)
if is_no_sex_position_label(clean):
has_no_position = True
continue
if clean:
labels.add(clean)
if has_no_position:
labels.add(NO_SEX_POSITION_LABEL)
return labels
def existing_file(path: Path) -> Optional[Path]:
try:
if path.exists() and path.is_file() and path.stat().st_size > 0:
return path
except OSError:
pass
return None
def existing_model_dir(path: Path) -> Optional[Path]:
try:
if path.exists() and path.is_dir() and existing_file(path / "config.json"):
return path
except OSError:
pass
return None
def resolve_training_root() -> Path:
env_root = os.environ.get("TRAINING_ROOT", "").strip()
if env_root:
root = Path(env_root).expanduser().resolve()
root.mkdir(parents=True, exist_ok=True)
return root
candidates = [
# Wenn ai_server.py aus backend/ läuft:
BASE_DIR / "generated" / "training",
# Wenn ai_server.py aus backend/ml/ laufen würde:
BASE_DIR.parent / "generated" / "training",
# Wenn ai_server.py embedded aus Temp läuft, aber backendRoot als cwd gesetzt wurde:
Path.cwd() / "generated" / "training",
# Wenn Working Directory Projektroot ist:
Path.cwd() / "backend" / "generated" / "training",
]
for root in candidates:
if (
existing_file(root / "detection_labels.json")
or existing_file(root / "detector" / "model" / "best.pt")
or existing_model_dir(root / "videomae" / "model")
):
root.mkdir(parents=True, exist_ok=True)
return root.resolve()
# Fallback: Server soll trotzdem starten.
root = (Path.cwd() / "generated" / "training").resolve()
root.mkdir(parents=True, exist_ok=True)
return root
TRAINING_ROOT = resolve_training_root()
DEFAULT_MODEL_PATH = TRAINING_ROOT / "detector" / "model" / "best.pt"
DEFAULT_POSE_MODEL_PATH = TRAINING_ROOT / "pose" / "model" / "best.pt"
DEFAULT_VIDEOMAE_MODEL_PATH = TRAINING_ROOT / "videomae" / "model"
def resolve_detection_labels_path() -> Path:
env_path = os.environ.get("DETECTION_LABELS_PATH", "").strip()
if env_path:
p = Path(env_path).expanduser().resolve()
if existing_file(p):
return p
raise RuntimeError(f"DETECTION_LABELS_PATH not found: {p}")
p = TRAINING_ROOT / "detection_labels.json"
if existing_file(p):
return p.resolve()
raise RuntimeError(f"detection_labels.json not found: {p}")
def resolve_model_path() -> str:
env_path = os.environ.get("YOLO_MODEL", "").strip()
if env_path:
p = Path(env_path).expanduser().resolve()
if existing_file(p):
return str(p)
raise RuntimeError(f"YOLO_MODEL not found: {p}")
if existing_file(DEFAULT_MODEL_PATH):
return str(DEFAULT_MODEL_PATH)
raise RuntimeError(f"YOLO model not found: {DEFAULT_MODEL_PATH}")
def resolve_pose_model_path() -> Optional[Path]:
env_path = os.environ.get("YOLO_POSE_MODEL", "").strip()
if env_path:
p = Path(env_path).expanduser().resolve()
if existing_file(p):
return p
raise RuntimeError(f"YOLO_POSE_MODEL not found: {p}")
if existing_file(DEFAULT_POSE_MODEL_PATH):
return DEFAULT_POSE_MODEL_PATH.resolve()
return None
def base_pose_model_candidates() -> list[Path]:
return [
BASE_DIR / "yolo26n-pose.pt",
Path.cwd() / "yolo26n-pose.pt",
TRAINING_ROOT / "yolo26n-pose.pt",
TRAINING_ROOT.parent / "yolo26n-pose.pt",
TRAINING_ROOT.parent.parent / "yolo26n-pose.pt",
Path(tempfile.gettempdir()) / "nsfwapp-ml" / "yolo26n-pose.pt",
]
def resolve_base_pose_model_path() -> Optional[Path]:
env_path = os.environ.get("YOLO_BASE_POSE_MODEL", "").strip()
if env_path:
p = Path(env_path).expanduser().resolve()
if existing_file(p):
return p
raise RuntimeError(f"YOLO_BASE_POSE_MODEL not found: {p}")
for candidate in base_pose_model_candidates():
p = existing_file(candidate)
if p:
return p.resolve()
return None
def resolve_videomae_model_path() -> Optional[Path]:
env_path = os.environ.get("VIDEOMAE_MODEL", "").strip()
if env_path:
p = Path(env_path).expanduser().resolve()
if existing_model_dir(p):
return p
raise RuntimeError(f"VIDEOMAE_MODEL not found: {p}")
if existing_model_dir(DEFAULT_VIDEOMAE_MODEL_PATH):
return DEFAULT_VIDEOMAE_MODEL_PATH.resolve()
return None
# Server darf auch ohne Labels/Model starten.
DETECTION_LABELS_PATH: Optional[Path] = None
LABEL_GROUPS = {
"people": set(),
"sexPositions": {NO_SEX_POSITION_LABEL},
"bodyParts": set(),
"objects": set(),
"clothing": set(),
}
BODY_LABELS = LABEL_GROUPS["bodyParts"]
OBJECT_LABELS = LABEL_GROUPS["objects"]
CLOTHING_LABELS = LABEL_GROUPS["clothing"]
POSITION_LABELS = set()
PERSON_LABELS = {
"person_male",
"person_female",
}
_MODEL_PATH = ""
_MODEL_ERROR = ""
_POSE_MODEL_PATH = ""
_POSE_MODEL_ERROR = ""
_BASE_POSE_MODEL_PATH = ""
_BASE_POSE_MODEL_ERROR = ""
_VIDEOMAE_MODEL_PATH = ""
_VIDEOMAE_MODEL_ERROR = ""
_VIDEOMAE_DEVICE_ACTIVE = ""
_LABEL_ERROR = ""
_DEVICE = os.environ.get("YOLO_DEVICE", "")
_CONF = float(os.environ.get("YOLO_CONF", "0.25"))
_POSE_CONF = float(os.environ.get("YOLO_POSE_CONF", "0.30"))
_BASE_POSE_CONF = float(os.environ.get("YOLO_BASE_POSE_CONF", "0.10"))
_POSE_RELIABLE_MIN_SCORE = float(os.environ.get("YOLO_POSE_RELIABLE_MIN_SCORE", "0.30"))
_POSE_RELIABLE_MIN_KEYPOINTS = int(os.environ.get("YOLO_POSE_RELIABLE_MIN_KEYPOINTS", "6"))
_POSE_RELIABLE_MIN_QUALITY = float(os.environ.get("YOLO_POSE_RELIABLE_MIN_QUALITY", "0.45"))
_POSITION_CONTEXT_MIN_SCORE = float(os.environ.get("YOLO_POSITION_CONTEXT_MIN_SCORE", "0.22"))
_POSITION_CONTEXT_MAX_SCORE = float(os.environ.get("YOLO_POSITION_CONTEXT_MAX_SCORE", "0.44"))
_POSITION_CONTEXT_BOOST_WEIGHT = float(os.environ.get("YOLO_POSITION_CONTEXT_BOOST_WEIGHT", "0.60"))
_POSE_CONFIRMING_CONTEXT_MIN_SCORE = float(os.environ.get("YOLO_POSE_CONFIRMING_CONTEXT_MIN_SCORE", "0.14"))
_POSE_UNCONFIRMED_MAX_SCORE = float(os.environ.get("YOLO_POSE_UNCONFIRMED_MAX_SCORE", "0.38"))
_POSE_STRONG_UNCONFIRMED_MIN_SCORE = float(os.environ.get("YOLO_POSE_STRONG_UNCONFIRMED_MIN_SCORE", "0.70"))
_POSE_STRONG_UNCONFIRMED_MAX_SCORE = float(os.environ.get("YOLO_POSE_STRONG_UNCONFIRMED_MAX_SCORE", "0.46"))
_BATCH = int(os.environ.get("YOLO_BATCH", "16"))
_IMGSZ = int(os.environ.get("YOLO_IMGSZ", "640"))
_HALF = os.environ.get("YOLO_HALF", "0").lower() in {"1", "true", "yes", "on"}
_VIDEOMAE_DEVICE = os.environ.get("VIDEOMAE_DEVICE", "auto")
_VIDEOMAE_NUM_FRAMES = int(os.environ.get("VIDEOMAE_NUM_FRAMES", "16"))
model = None
pose_model = None
base_pose_model = None
videomae_model = None
videomae_processor = None
app = FastAPI()
AI_SERVER_TOKEN = os.environ.get("AI_SERVER_TOKEN", "").strip()
AI_SERVER_AUTH_REQUIRED = os.environ.get("AI_SERVER_AUTH_REQUIRED", "1").strip().lower() not in {
"0",
"false",
"no",
"off",
}
def require_ai_server_auth(request: Request) -> None:
if not AI_SERVER_AUTH_REQUIRED:
return
if not AI_SERVER_TOKEN:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="AI server auth token not configured",
)
auth = request.headers.get("authorization", "").strip()
header_token = request.headers.get("x-ai-server-token", "").strip()
provided = ""
if auth.lower().startswith("bearer "):
provided = auth[7:].strip()
elif header_token:
provided = header_token
if not provided or not secrets.compare_digest(provided, AI_SERVER_TOKEN):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="unauthorized",
headers={"WWW-Authenticate": "Bearer"},
)
class PredictBatchRequest(BaseModel):
paths: List[str]
detectorOnly: bool = False
imageSize: int = 640
model: Optional[str] = None
class PositionClipItem(BaseModel):
time: float = 0.0
start: float = 0.0
end: float = 0.0
paths: List[str]
class PredictPositionClipsRequest(BaseModel):
clips: List[PositionClipItem]
numFrames: int = 16
def empty_prediction(source: str = "model_missing") -> dict:
return {
"modelAvailable": False,
"source": source,
"sexPosition": NO_SEX_POSITION_LABEL,
"sexPositionScore": 0.0,
"peoplePresent": [],
"bodyPartsPresent": [],
"objectsPresent": [],
"clothingPresent": [],
"boxes": [],
"persons": [],
}
def load_label_groups_safe() -> None:
global DETECTION_LABELS_PATH
global LABEL_GROUPS
global BODY_LABELS
global OBJECT_LABELS
global CLOTHING_LABELS
global POSITION_LABELS
global PERSON_LABELS
global _LABEL_ERROR
try:
path = resolve_detection_labels_path()
DETECTION_LABELS_PATH = path
with path.open("r", encoding="utf-8") as f:
data = json.load(f)
LABEL_GROUPS = {
"people": set(
str(x).strip().lower()
for x in data.get("people", [])
if str(x).strip()
),
"sexPositions": normalize_sex_position_labels(data.get("sexPositions", [])),
"bodyParts": set(
str(x).strip().lower()
for x in data.get("bodyParts", [])
if str(x).strip()
),
"objects": set(
str(x).strip().lower()
for x in data.get("objects", [])
if str(x).strip()
),
"clothing": set(
str(x).strip().lower()
for x in data.get("clothing", [])
if str(x).strip()
),
}
if not LABEL_GROUPS["sexPositions"]:
LABEL_GROUPS["sexPositions"] = {NO_SEX_POSITION_LABEL}
_LABEL_ERROR = ""
except Exception as exc:
DETECTION_LABELS_PATH = None
_LABEL_ERROR = str(exc)
LABEL_GROUPS = {
"people": set(),
"sexPositions": {NO_SEX_POSITION_LABEL},
"bodyParts": set(),
"objects": set(),
"clothing": set(),
}
BODY_LABELS = LABEL_GROUPS["bodyParts"]
OBJECT_LABELS = LABEL_GROUPS["objects"]
CLOTHING_LABELS = LABEL_GROUPS["clothing"]
POSITION_LABELS = {
label for label in LABEL_GROUPS["sexPositions"]
if label and not is_no_sex_position_label(label)
}
PERSON_LABELS = {
label for label in LABEL_GROUPS["people"]
if label
}
def get_model():
global model
global _MODEL_PATH
global _MODEL_ERROR
if model is not None:
return model
try:
path = resolve_model_path()
loaded = YOLO(path)
model = loaded
_MODEL_PATH = path
_MODEL_ERROR = ""
# Labels erst laden, wenn Inference wirklich gebraucht wird.
load_label_groups_safe()
return model
except Exception as exc:
model = None
_MODEL_PATH = ""
_MODEL_ERROR = str(exc)
return None
def get_pose_model():
global pose_model
global _POSE_MODEL_PATH
global _POSE_MODEL_ERROR
if pose_model is not None:
return pose_model
try:
path = resolve_pose_model_path()
if path is None:
pose_model = None
_POSE_MODEL_PATH = ""
_POSE_MODEL_ERROR = ""
return None
loaded = YOLO(str(path))
pose_model = loaded
_POSE_MODEL_PATH = str(path)
_POSE_MODEL_ERROR = ""
return pose_model
except Exception as exc:
pose_model = None
_POSE_MODEL_PATH = ""
_POSE_MODEL_ERROR = str(exc)
return None
def get_base_pose_model():
global base_pose_model
global _BASE_POSE_MODEL_PATH
global _BASE_POSE_MODEL_ERROR
if base_pose_model is not None:
return base_pose_model
try:
path = resolve_base_pose_model_path()
if path is None:
base_pose_model = None
_BASE_POSE_MODEL_PATH = ""
_BASE_POSE_MODEL_ERROR = ""
return None
loaded = YOLO(str(path))
base_pose_model = loaded
_BASE_POSE_MODEL_PATH = str(path)
_BASE_POSE_MODEL_ERROR = ""
return base_pose_model
except Exception as exc:
base_pose_model = None
_BASE_POSE_MODEL_PATH = ""
_BASE_POSE_MODEL_ERROR = str(exc)
return None
def get_videomae_components():
global videomae_model
global videomae_processor
global _VIDEOMAE_MODEL_PATH
global _VIDEOMAE_MODEL_ERROR
global _VIDEOMAE_DEVICE_ACTIVE
if videomae_model is not None and videomae_processor is not None:
return videomae_model, videomae_processor
try:
path = resolve_videomae_model_path()
if path is None:
videomae_model = None
videomae_processor = None
_VIDEOMAE_MODEL_PATH = ""
_VIDEOMAE_MODEL_ERROR = ""
_VIDEOMAE_DEVICE_ACTIVE = ""
return None, None
import torch
from transformers import AutoImageProcessor, VideoMAEForVideoClassification
if str(_VIDEOMAE_DEVICE).lower() == "auto":
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
else:
device = torch.device(_VIDEOMAE_DEVICE)
processor = AutoImageProcessor.from_pretrained(path)
loaded = VideoMAEForVideoClassification.from_pretrained(path)
loaded.to(device)
loaded.eval()
videomae_model = loaded
videomae_processor = processor
_VIDEOMAE_MODEL_PATH = str(path)
_VIDEOMAE_MODEL_ERROR = ""
_VIDEOMAE_DEVICE_ACTIVE = str(device)
return videomae_model, videomae_processor
except Exception as exc:
videomae_model = None
videomae_processor = None
_VIDEOMAE_MODEL_PATH = ""
_VIDEOMAE_DEVICE_ACTIVE = ""
_VIDEOMAE_MODEL_ERROR = str(exc)
return None, None
def resample_values(values: list, count: int) -> list:
if not values:
return []
if count <= 1:
return [values[0]]
if len(values) == 1:
return [values[0] for _ in range(count)]
last = len(values) - 1
return [
values[int(round((i * last) / max(1, count - 1)))]
for i in range(count)
]
def load_videomae_clip_frames(paths: list[str], num_frames: int):
from PIL import Image
clean_paths = [
str(path).strip()
for path in paths or []
if str(path).strip()
]
selected = resample_values(clean_paths, max(2, int(num_frames or _VIDEOMAE_NUM_FRAMES or 16)))
frames = []
for path in selected:
with Image.open(path) as img:
frames.append(img.convert("RGB").copy())
return frames
def predict_videomae_clip(clip: PositionClipItem, num_frames: int) -> dict:
current_model, current_processor = get_videomae_components()
if current_model is None or current_processor is None:
return {
"time": float(clip.time or 0.0),
"start": float(clip.start or 0.0),
"end": float(clip.end or 0.0),
"sexPosition": NO_SEX_POSITION_LABEL,
"sexPositionScore": 0.0,
"source": "videomae_missing",
"scores": [],
}
import torch
frames = load_videomae_clip_frames(clip.paths, num_frames)
if not frames:
return {
"time": float(clip.time or 0.0),
"start": float(clip.start or 0.0),
"end": float(clip.end or 0.0),
"sexPosition": NO_SEX_POSITION_LABEL,
"sexPositionScore": 0.0,
"source": "videomae_no_frames",
"scores": [],
}
inputs = current_processor(frames, return_tensors="pt")
device = next(current_model.parameters()).device
pixel_values = inputs["pixel_values"].to(device)
with torch.no_grad():
logits = current_model(pixel_values=pixel_values).logits
probs = torch.softmax(logits, dim=-1)[0].detach().cpu().tolist()
id_to_label = getattr(current_model.config, "id2label", {}) or {}
scores = []
best_label = NO_SEX_POSITION_LABEL
best_score = 0.0
for idx, score in enumerate(probs):
raw_label = id_to_label.get(idx, id_to_label.get(str(idx), idx))
label = normalize_sex_position_label(raw_label)
score = clamp01(score)
scores.append({
"label": label,
"score": score,
})
if score > best_score:
best_label = label
best_score = score
scores.sort(key=lambda item: item["score"], reverse=True)
return {
"time": float(clip.time or 0.0),
"start": float(clip.start or 0.0),
"end": float(clip.end or 0.0),
"sexPosition": best_label,
"sexPositionScore": best_score,
"source": "videomae",
"scores": scores[:10],
}
def scored(label: str, score: float) -> dict:
return {
"label": label,
"score": float(score),
}
def best_score(items: list[dict], label: str, score: float) -> None:
for item in items:
if item["label"] == label:
if score > item["score"]:
item["score"] = float(score)
return
items.append(scored(label, score))
def prediction_from_result(result) -> dict:
names = result.names or {}
boxes_out = []
people_present = []
body_parts = []
objects = []
clothing = []
if result.boxes is not None:
xywhn = result.boxes.xywhn.cpu().tolist()
cls_values = result.boxes.cls.cpu().tolist()
conf_values = result.boxes.conf.cpu().tolist()
for box_xywhn, cls_id, conf in zip(xywhn, cls_values, conf_values):
label = str(names.get(int(cls_id), int(cls_id))).strip().lower()
score = float(conf)
if not label:
continue
cx, cy, w, h = [float(v) for v in box_xywhn]
x = max(0.0, min(1.0, cx - w / 2.0))
y = max(0.0, min(1.0, cy - h / 2.0))
w = max(0.0, min(1.0 - x, w))
h = max(0.0, min(1.0 - y, h))
is_person = label in PERSON_LABELS
is_body = label in BODY_LABELS
is_object = label in OBJECT_LABELS
is_clothing = label in CLOTHING_LABELS
if not (is_person or is_body or is_object or is_clothing):
continue
boxes_out.append({
"label": label,
"score": score,
"x": x,
"y": y,
"w": w,
"h": h,
})
if is_person:
best_score(people_present, label, score)
if is_body:
best_score(body_parts, label, score)
if is_object:
best_score(objects, label, score)
if is_clothing:
best_score(clothing, label, score)
return {
"modelAvailable": True,
"source": f"yolo-server:{Path(_MODEL_PATH).name}",
"sexPosition": NO_SEX_POSITION_LABEL,
"sexPositionScore": 0.0,
"peoplePresent": people_present,
"bodyPartsPresent": body_parts,
"objectsPresent": objects,
"clothingPresent": clothing,
"boxes": boxes_out,
"persons": [],
}
def pose_persons_from_result(result) -> list[dict]:
names = result.names or {}
persons = []
if result.boxes is None:
return persons
kpts_xyn = None
kpts_conf = None
if result.keypoints is not None:
try:
kpts_xyn = result.keypoints.xyn.cpu().tolist()
except Exception:
kpts_xyn = None
try:
kpts_conf = result.keypoints.conf.cpu().tolist()
except Exception:
kpts_conf = None
xywhn_values = result.boxes.xywhn.cpu().tolist()
cls_values = result.boxes.cls.cpu().tolist()
conf_values = result.boxes.conf.cpu().tolist()
for i, (box_xywhn, cls_id, conf) in enumerate(
zip(xywhn_values, cls_values, conf_values)
):
label = str(names.get(int(cls_id), int(cls_id))).strip().lower()
score = float(conf)
cx, cy, w, h = [float(v) for v in box_xywhn]
x = max(0.0, min(1.0, cx - w / 2.0))
y = max(0.0, min(1.0, cy - h / 2.0))
w = max(0.0, min(1.0 - x, w))
h = max(0.0, min(1.0 - y, h))
if w <= 0 or h <= 0:
continue
keypoints = []
if kpts_xyn is not None and i < len(kpts_xyn):
for ki, point in enumerate(kpts_xyn[i]):
if len(point) < 2:
continue
kconf = 0.0
if (
kpts_conf is not None
and i < len(kpts_conf)
and ki < len(kpts_conf[i])
):
kconf = float(kpts_conf[i][ki])
keypoints.append({
"name": KEYPOINT_NAMES[ki] if ki < len(KEYPOINT_NAMES) else str(ki),
"x": float(point[0]),
"y": float(point[1]),
"conf": kconf,
})
persons.append(annotate_pose_person_quality({
"label": label,
"score": score,
"box": {
"x": x,
"y": y,
"w": w,
"h": h,
},
"keypoints": keypoints,
}))
return persons
def clamp01(value) -> float:
try:
n = float(value)
except Exception:
return 0.0
if not math.isfinite(n):
return 0.0
return max(0.0, min(1.0, n))
def is_finite01(value) -> bool:
try:
n = float(value)
except Exception:
return False
return math.isfinite(n) and 0.0 <= n <= 1.0
def normalized_box(box: dict | None) -> dict | None:
if not isinstance(box, dict):
return None
x = clamp01(box.get("x", 0.0))
y = clamp01(box.get("y", 0.0))
w = clamp01(box.get("w", 0.0))
h = clamp01(box.get("h", 0.0))
if w <= 0 or h <= 0:
return None
if x + w > 1:
w = 1 - x
if y + h > 1:
h = 1 - y
if w <= 0 or h <= 0:
return None
out = dict(box)
out.update({"x": x, "y": y, "w": w, "h": h})
return out
def box_area(box: dict) -> float:
clean = normalized_box(box)
if not clean:
return 0.0
return float(clean["w"]) * float(clean["h"])
def box_center(box: dict) -> tuple[float, float] | None:
clean = normalized_box(box)
if not clean:
return None
return float(clean["x"]) + float(clean["w"]) / 2.0, float(clean["y"]) + float(clean["h"]) / 2.0
def box_gap(a: dict, b: dict) -> float:
a = normalized_box(a)
b = normalized_box(b)
if not a or not b:
return 1.0
dx = max(0.0, max(float(a["x"]) - (float(b["x"]) + float(b["w"])), float(b["x"]) - (float(a["x"]) + float(a["w"]))))
dy = max(0.0, max(float(a["y"]) - (float(b["y"]) + float(b["h"])), float(b["y"]) - (float(a["y"]) + float(a["h"]))))
return math.sqrt(dx * dx + dy * dy)
def box_overlap_ratio(a: dict, b: dict) -> float:
a = normalized_box(a)
b = normalized_box(b)
if not a or not b:
return 0.0
left = max(float(a["x"]), float(b["x"]))
top = max(float(a["y"]), float(b["y"]))
right = min(float(a["x"]) + float(a["w"]), float(b["x"]) + float(b["w"]))
bottom = min(float(a["y"]) + float(a["h"]), float(b["y"]) + float(b["h"]))
if right <= left or bottom <= top:
return 0.0
min_area = min(box_area(a), box_area(b))
if min_area <= 0:
return 0.0
return clamp01(((right - left) * (bottom - top)) / min_area)
def box_horizontal_overlap_ratio(a: dict, b: dict) -> float:
a = normalized_box(a)
b = normalized_box(b)
if not a or not b:
return 0.0
left = max(float(a["x"]), float(b["x"]))
right = min(float(a["x"]) + float(a["w"]), float(b["x"]) + float(b["w"]))
if right <= left:
return 0.0
min_width = min(float(a["w"]), float(b["w"]))
if min_width <= 0:
return 0.0
return clamp01((right - left) / min_width)
def box_vertical_overlap_ratio(a: dict, b: dict) -> float:
a = normalized_box(a)
b = normalized_box(b)
if not a or not b:
return 0.0
top = max(float(a["y"]), float(b["y"]))
bottom = min(float(a["y"]) + float(a["h"]), float(b["y"]) + float(b["h"]))
if bottom <= top:
return 0.0
min_height = min(float(a["h"]), float(b["h"]))
if min_height <= 0:
return 0.0
return clamp01((bottom - top) / min_height)
def is_person_like_label(label: str) -> bool:
clean = str(label or "").strip().lower()
return clean == "person" or clean.startswith("person_")
def boxes_by_label(prediction: dict, *labels: str) -> list[dict]:
wanted = {str(label).strip().lower() for label in labels if str(label).strip()}
out = []
for box in prediction.get("boxes", []) or []:
label = str(box.get("label", "")).strip().lower() if isinstance(box, dict) else ""
if label not in wanted:
continue
clean = normalized_box(box)
if clean:
out.append(clean)
return out
def any_boxes_near(left_boxes: list[dict], right_boxes: list[dict], margin: float) -> bool:
for left in left_boxes:
for right in right_boxes:
if box_gap(left, right) <= margin or box_overlap_ratio(left, right) > 0:
return True
return False
def point_near_box(x: float, y: float, box: dict, margin: float) -> bool:
if not is_finite01(x) or not is_finite01(y):
return False
clean = normalized_box(box)
if not clean:
return False
return (
float(clean["x"]) - margin <= x <= float(clean["x"]) + float(clean["w"]) + margin
and float(clean["y"]) - margin <= y <= float(clean["y"]) + float(clean["h"]) + margin
)
def any_pose_keypoint_near_boxes(
persons: list[dict],
keypoint_names: list[str],
boxes: list[dict],
margin: float,
) -> bool:
if not boxes:
return False
wanted = {str(name).strip().lower() for name in keypoint_names}
for person in reliable_pose_persons(persons):
for point in person.get("keypoints", []) or []:
name = str(point.get("name", "")).strip().lower()
if name not in wanted:
continue
if float(point.get("conf") or 0.0) < 0.20:
continue
x = float(point.get("x") or 0.0)
y = float(point.get("y") or 0.0)
for box in boxes:
if point_near_box(x, y, box, margin):
return True
return False
def pose_keypoint_stats(person: dict) -> tuple[int, float]:
keypoints = person.get("keypoints", []) or []
if not keypoints:
return 0, 0.0
visible = 0
total_conf = 0.0
for point in keypoints:
x = float(point.get("x") or 0.0)
y = float(point.get("y") or 0.0)
conf = float(point.get("conf") or 0.0)
if conf < 0.20 or not is_finite01(x) or not is_finite01(y):
continue
visible += 1
total_conf += clamp01(conf)
if visible == 0:
return 0, 0.0
coverage = clamp01(visible / max(1, len(KEYPOINT_NAMES)))
avg_conf = clamp01(total_conf / visible)
return visible, clamp01(coverage * 0.45 + avg_conf * 0.55)
def pose_keypoint_quality(person: dict) -> float:
_, quality = pose_keypoint_stats(person)
return quality
def annotate_pose_person_quality(person: dict) -> dict:
visible, quality = pose_keypoint_stats(person)
score = clamp01(float(person.get("score") or 0.0))
person["visibleKeypoints"] = visible
person["quality"] = quality
person["reliable"] = (
score >= _POSE_RELIABLE_MIN_SCORE
and visible >= _POSE_RELIABLE_MIN_KEYPOINTS
and quality >= _POSE_RELIABLE_MIN_QUALITY
)
return person
def reliable_pose_persons(persons: list[dict]) -> list[dict]:
out = []
for person in persons:
if not isinstance(person, dict):
continue
if "reliable" not in person:
annotate_pose_person_quality(person)
if bool(person.get("reliable")):
out.append(person)
return out
def scene_person_boxes(prediction: dict, persons: list[dict]) -> list[dict]:
detector_boxes = []
pose_boxes = []
for box in prediction.get("boxes", []) or []:
if not isinstance(box, dict) or not is_person_like_label(box.get("label", "")):
continue
clean = normalized_box(box)
if clean:
detector_boxes.append(clean)
for person in reliable_pose_persons(persons):
clean = normalized_box(person.get("box"))
if clean:
pose_boxes.append(clean)
if detector_boxes and len(detector_boxes) >= len(pose_boxes):
return detector_boxes
return pose_boxes
def scene_person_pair_signals(boxes: list[dict]) -> dict:
signals = {
"close": False,
"overlap": False,
"horizontal": False,
"vertical": False,
"stacked": False,
}
for i, left in enumerate(boxes):
left = normalized_box(left)
if not left:
continue
for raw_right in boxes[i + 1:]:
right = normalized_box(raw_right)
if not right:
continue
gap = box_gap(left, right)
overlap = box_overlap_ratio(left, right)
close = gap <= 0.08 or overlap >= 0.08
if close:
signals["close"] = True
if overlap >= 0.18:
signals["overlap"] = True
if close and float(left["w"]) > float(left["h"]) * 1.15 and float(right["w"]) > float(right["h"]) * 1.15:
signals["horizontal"] = True
if close and float(left["h"]) > float(left["w"]) * 1.25 and float(right["h"]) > float(right["w"]) * 1.25:
signals["vertical"] = True
left_center = box_center(left)
right_center = box_center(right)
if (
left_center
and right_center
and gap <= 0.12
and box_horizontal_overlap_ratio(left, right) >= 0.35
and abs(left_center[1] - right_center[1]) >= 0.12
):
signals["stacked"] = True
return signals
def pose_point(person: dict, name: str, min_conf: float = 0.20) -> tuple[float, float] | None:
wanted = str(name or "").strip().lower()
if not wanted:
return None
for point in person.get("keypoints", []) or []:
point_name = str(point.get("name", "")).strip().lower()
if point_name != wanted:
continue
conf = float(point.get("conf") or 0.0)
x = float(point.get("x") or 0.0)
y = float(point.get("y") or 0.0)
if conf >= min_conf and is_finite01(x) and is_finite01(y):
return x, y
return None
def pose_midpoint(
person: dict,
names: list[str],
min_conf: float = 0.20,
min_points: int = 1,
) -> tuple[float, float] | None:
points = [
point
for name in names
if (point := pose_point(person, name, min_conf)) is not None
]
if len(points) < min_points:
return None
x = sum(point[0] for point in points) / len(points)
y = sum(point[1] for point in points) / len(points)
return x, y
def point_distance(a: tuple[float, float] | None, b: tuple[float, float] | None) -> float:
if not a or not b:
return 0.0
return math.sqrt((a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2)
def projected_point_distance(
a: tuple[float, float] | None,
b: tuple[float, float] | None,
axis_x: float,
axis_y: float,
) -> float:
if not a or not b:
return 0.0
return abs((a[0] - b[0]) * axis_x + (a[1] - b[1]) * axis_y)
def pose_extents_along_axis(
person: dict,
origin: tuple[float, float],
axis_x: float,
axis_y: float,
perp_x: float,
perp_y: float,
min_conf: float = 0.20,
) -> tuple[float, float] | None:
long_values: list[float] = []
cross_values: list[float] = []
for point in person.get("keypoints", []) or []:
conf = float(point.get("conf") or 0.0)
x = float(point.get("x") or 0.0)
y = float(point.get("y") or 0.0)
if conf < min_conf or not is_finite01(x) or not is_finite01(y):
continue
dx = x - origin[0]
dy = y - origin[1]
long_values.append(dx * axis_x + dy * axis_y)
cross_values.append(dx * perp_x + dy * perp_y)
if len(long_values) < 3:
return None
return abs(max(long_values) - min(long_values)), abs(max(cross_values) - min(cross_values))
def pose_person_geometry(person: dict) -> dict:
box = normalized_box(person.get("box"))
box_center_point = box_center(box) if box else None
left_hip = pose_point(person, "left_hip")
right_hip = pose_point(person, "right_hip")
left_knee = pose_point(person, "left_knee")
right_knee = pose_point(person, "right_knee")
hip = pose_midpoint(person, ["left_hip", "right_hip"])
shoulder = pose_midpoint(person, ["left_shoulder", "right_shoulder"])
knee = pose_midpoint(person, ["left_knee", "right_knee"])
head = pose_midpoint(person, ["nose", "left_eye", "right_eye", "left_ear", "right_ear"])
center = hip or box_center_point or (0.5, 0.5)
torso_dx = 0.0
torso_dy = 0.0
torso_len = 0.0
torso_angle = 0.0
has_torso_axis = False
axis_x = 0.0
axis_y = 1.0
perp_x = -1.0
perp_y = 0.0
if hip and shoulder:
raw_dx = hip[0] - shoulder[0]
raw_dy = hip[1] - shoulder[1]
torso_dx = abs(raw_dx)
torso_dy = abs(raw_dy)
torso_len = math.sqrt(raw_dx * raw_dx + raw_dy * raw_dy)
if torso_len >= 0.07:
has_torso_axis = True
axis_x = raw_dx / torso_len
axis_y = raw_dy / torso_len
perp_x = -axis_y
perp_y = axis_x
torso_angle = math.atan2(axis_y, axis_x)
hip_width = point_distance(left_hip, right_hip)
knee_width = point_distance(left_knee, right_knee)
knees_below_hips = bool(knee and hip and knee[1] > hip[1] + 0.045)
if has_torso_axis and knee and hip:
knee_projection = (knee[0] - hip[0]) * axis_x + (knee[1] - hip[1]) * axis_y
knees_below_hips = knee_projection > 0.045
if has_torso_axis and left_knee and right_knee:
knee_width = projected_point_distance(left_knee, right_knee, perp_x, perp_y)
if left_hip and right_hip:
hip_width = projected_point_distance(left_hip, right_hip, perp_x, perp_y)
knees_wide = bool(
knee_width > 0
and knee_width >= max(0.11, hip_width * 1.12)
)
straddling = knees_below_hips and knees_wide
body_long = torso_len
body_cross = max(hip_width, knee_width)
if has_torso_axis:
pose_extents = pose_extents_along_axis(person, center, axis_x, axis_y, perp_x, perp_y)
if pose_extents:
body_long = max(body_long, pose_extents[0])
body_cross = max(body_cross, pose_extents[1])
if box:
box_long = abs(axis_x) * float(box["w"]) + abs(axis_y) * float(box["h"])
box_cross = abs(perp_x) * float(box["w"]) + abs(perp_y) * float(box["h"])
body_long = max(body_long, box_long)
body_cross = max(body_cross, box_cross)
elif box:
body_long = max(float(box["w"]), float(box["h"]))
body_cross = min(float(box["w"]), float(box["h"]))
elongated = body_long >= max(0.18, body_cross * 1.18)
torso_horizontal = torso_len >= 0.07 and torso_dx >= torso_dy * 1.15
torso_vertical = torso_len >= 0.07 and torso_dy >= torso_dx * 1.15
box_horizontal = bool(box and float(box["w"]) >= float(box["h"]) * 1.05)
box_vertical = bool(box and float(box["h"]) >= float(box["w"]) * 1.25)
lying = torso_horizontal or box_horizontal or (has_torso_axis and elongated and not box_vertical)
upright = torso_vertical or box_vertical
all_fours = knees_below_hips and not straddling and (torso_horizontal or (has_torso_axis and elongated))
bent_or_kneeling = all_fours or (knees_below_hips and not straddling)
return {
"box": box,
"center": center,
"head": head,
"hip": hip,
"shoulder": shoulder,
"knee": knee,
"torso_angle": torso_angle,
"has_torso_axis": has_torso_axis,
"axis_x": axis_x,
"axis_y": axis_y,
"perp_x": perp_x,
"perp_y": perp_y,
"body_long": body_long,
"body_cross": body_cross,
"elongated": elongated,
"lying": lying,
"upright": upright,
"straddling": straddling,
"knees_below_hips": knees_below_hips,
"knees_wide": knees_wide,
"all_fours": all_fours,
"bent_or_kneeling": bent_or_kneeling,
}
def combine_position_score(scores: dict[str, float], label: str, score: float) -> None:
label = normalize_sex_position_label(label)
if is_no_sex_position_label(label) or label not in POSITION_LABELS or score <= 0:
return
score = clamp01(score)
current = clamp01(scores.get(label, 0.0))
scores[label] = clamp01(1 - (1 - current) * (1 - score))
def pose_axis_alignment(a: dict, b: dict) -> tuple[float, bool]:
if not a.get("has_torso_axis") or not b.get("has_torso_axis"):
return 0.0, False
# Körperachsen sind richtungslos: 0° und 180° gelten beide als parallel.
return abs(math.cos(float(a.get("torso_angle") or 0.0) - float(b.get("torso_angle") or 0.0))), True
def append_prediction_source(prediction: dict, source: str) -> None:
source = str(source or "").strip()
if not source:
return
current = str(prediction.get("source") or "").strip()
if not current:
prediction["source"] = source
return
if source in {part.strip() for part in current.split("+")}:
return
prediction["source"] = f"{current}+{source}"
def fuse_hybrid_position_scores(
pose_scores: dict[str, float],
context_scores: dict[str, float],
) -> tuple[str, float, bool, bool]:
labels = {
label
for label in set(pose_scores.keys()) | set(context_scores.keys())
if not is_no_sex_position_label(label)
}
best_position = ""
best_score = 0.0
best_has_pose = False
best_has_context = False
for label in labels:
pose_score = clamp01(pose_scores.get(label, 0.0))
context_score = clamp01(context_scores.get(label, 0.0))
has_pose = pose_score > 0
has_context = context_score > 0
score = 0.0
if has_pose:
if has_context and context_score >= _POSE_CONFIRMING_CONTEXT_MIN_SCORE:
score = pose_score
boost = clamp01(context_score * _POSITION_CONTEXT_BOOST_WEIGHT)
score = clamp01(1 - (1 - score) * (1 - boost))
else:
max_unconfirmed_score = _POSE_UNCONFIRMED_MAX_SCORE
if pose_score >= _POSE_STRONG_UNCONFIRMED_MIN_SCORE:
max_unconfirmed_score = _POSE_STRONG_UNCONFIRMED_MAX_SCORE
score = min(max_unconfirmed_score, pose_score)
elif context_score >= _POSITION_CONTEXT_MIN_SCORE:
score = min(_POSITION_CONTEXT_MAX_SCORE, context_score)
if score > best_score:
best_position = label
best_score = score
best_has_pose = has_pose
best_has_context = has_context
return best_position, clamp01(best_score), best_has_pose, best_has_context
def add_pose_pair_geometry_scores(scores: dict[str, float], persons: list[dict]) -> None:
def add(label: str, score: float) -> None:
combine_position_score(scores, label, score)
reliable_persons = reliable_pose_persons(persons)
if len(reliable_persons) < 2:
return
geometries = [pose_person_geometry(person) for person in reliable_persons]
for i, left in enumerate(geometries):
left_box = left["box"]
if not left_box:
continue
for right in geometries[i + 1:]:
right_box = right["box"]
if not right_box:
continue
gap = box_gap(left_box, right_box)
overlap = box_overlap_ratio(left_box, right_box)
horizontal_overlap = box_horizontal_overlap_ratio(left_box, right_box)
vertical_overlap = box_vertical_overlap_ratio(left_box, right_box)
close = gap <= 0.12 or overlap >= 0.08
if not close:
continue
left_center = left["center"]
right_center = right["center"]
top = left if left_center[1] <= right_center[1] else right
bottom = right if top is left else left
top_above = top["center"][1] <= bottom["center"][1] - 0.055
horizontal_stack = horizontal_overlap >= 0.35 and (top_above or overlap >= 0.22)
lateral_stack = vertical_overlap >= 0.35 and overlap >= 0.12
strong_stack = horizontal_stack or lateral_stack
axis_alignment, has_axis_alignment = pose_axis_alignment(left, right)
axes_parallel = has_axis_alignment and axis_alignment >= 0.74
axes_crossed = has_axis_alignment and axis_alignment <= 0.56
def has_strong_rider_shape(geometry: dict) -> bool:
return bool(
geometry["straddling"]
or (
geometry["knees_wide"]
and geometry["knees_below_hips"]
and (geometry["upright"] or axes_crossed)
)
)
def has_weak_rider_shape(geometry: dict) -> bool:
return bool(geometry["knees_wide"] and geometry["knees_below_hips"])
left_has_strong_rider_shape = has_strong_rider_shape(left)
right_has_strong_rider_shape = has_strong_rider_shape(right)
top_has_strong_rider_shape = has_strong_rider_shape(top)
top_has_weak_rider_shape = has_weak_rider_shape(top)
rider = top
base = bottom
rider_has_strong_shape = top_has_strong_rider_shape
rider_has_weak_shape = top_has_weak_rider_shape
if left_has_strong_rider_shape != right_has_strong_rider_shape:
if left_has_strong_rider_shape:
rider = left
base = right
rider_has_strong_shape = True
rider_has_weak_shape = has_weak_rider_shape(left)
else:
rider = right
base = left
rider_has_strong_shape = True
rider_has_weak_shape = has_weak_rider_shape(right)
if strong_stack and rider_has_strong_shape and not axes_parallel:
add("cowgirl", 0.20)
add("reverse_cowgirl", 0.17)
if base["lying"]:
add("cowgirl", 0.12)
add("reverse_cowgirl", 0.10)
if rider["straddling"]:
add("cowgirl", 0.08)
add("reverse_cowgirl", 0.06)
elif strong_stack and rider_has_weak_shape and not has_axis_alignment:
# Ohne verwertbare Achsen bleibt Cowgirl nur ein schwaches Signal.
# Sobald die Achsen parallel sind, sieht die Szene eher nach
# Missionary/Überlagerung aus und soll nicht in Cowgirl kippen.
add("cowgirl", 0.08)
add("reverse_cowgirl", 0.06)
if strong_stack and (bottom["lying"] or (axes_parallel and top_above)):
if not top_has_strong_rider_shape or axes_parallel:
add("missionary", 0.14)
if axes_parallel:
add("missionary", 0.08)
if not top["straddling"] and not top_has_strong_rider_shape:
add("missionary", 0.08)
both_lying = left["lying"] and right["lying"]
same_level = abs(left_center[1] - right_center[1]) <= 0.15
side_by_side = abs(left_center[0] - right_center[0]) >= 0.10
parallel_side_by_side = axes_parallel and left["elongated"] and right["elongated"] and close and not strong_stack
if (both_lying and (same_level or side_by_side)) or parallel_side_by_side:
add("spooning", 0.18)
if overlap >= 0.10:
add("prone_bone", 0.07)
left_bent = left["all_fours"] or left["bent_or_kneeling"]
right_bent = right["all_fours"] or right["bent_or_kneeling"]
if left_bent != right_bent:
other = right if left_bent else left
add("doggy", 0.16)
if other["upright"]:
add("doggy", 0.06)
add("standing_doggy", 0.07)
if left["lying"] or right["lying"]:
add("prone_bone", 0.06)
elif left["all_fours"] or right["all_fours"]:
add("doggy", 0.13)
if left["lying"] or right["lying"]:
add("prone_bone", 0.07)
def add_pose_scene_context_scores(scores: dict[str, float], prediction: dict, persons: list[dict]) -> None:
def add(label: str, score: float) -> None:
combine_position_score(scores, label, score)
reliable_persons = reliable_pose_persons(persons)
person_boxes = scene_person_boxes(prediction, reliable_persons)
person_count = max(len(person_boxes), len(reliable_persons))
pair = scene_person_pair_signals(person_boxes)
add_pose_pair_geometry_scores(scores, reliable_persons)
penis_boxes = boxes_by_label(prediction, "penis")
pussy_boxes = boxes_by_label(prediction, "pussy", "vagina", "vulva", "labia")
ass_boxes = boxes_by_label(prediction, "ass", "anus")
breast_boxes = boxes_by_label(prediction, "breasts")
tongue_boxes = boxes_by_label(prediction, "tongue")
toy_boxes = boxes_by_label(prediction, "dildo", "vibrator", "strapon", "buttplug")
has_penis = bool(penis_boxes)
has_pussy = bool(pussy_boxes)
has_ass = bool(ass_boxes)
has_breasts = bool(breast_boxes)
has_tongue = bool(tongue_boxes)
has_toy = bool(toy_boxes)
head_names = ["nose", "left_eye", "right_eye", "left_ear", "right_ear"]
hand_names = ["left_wrist", "right_wrist"]
hip_names = ["left_hip", "right_hip"]
head_near_penis = any_pose_keypoint_near_boxes(reliable_persons, head_names, penis_boxes, 0.09)
head_near_pussy = any_pose_keypoint_near_boxes(reliable_persons, head_names, pussy_boxes, 0.09)
head_near_ass = any_pose_keypoint_near_boxes(reliable_persons, head_names, ass_boxes, 0.09)
hand_near_penis = any_pose_keypoint_near_boxes(reliable_persons, hand_names, penis_boxes, 0.08)
hand_near_pussy = any_pose_keypoint_near_boxes(reliable_persons, hand_names, pussy_boxes, 0.08)
hand_near_toy = any_pose_keypoint_near_boxes(reliable_persons, hand_names, toy_boxes, 0.08)
hips_near_genitals = any_pose_keypoint_near_boxes(reliable_persons, hip_names, penis_boxes + pussy_boxes, 0.08)
if person_count >= 2:
for label, score in [
("missionary", 0.04),
("doggy", 0.04),
("cowgirl", 0.04),
("reverse_cowgirl", 0.04),
("standing_doggy", 0.04),
("spooning", 0.04),
("69", 0.03),
]:
add(label, score)
if pair["close"]:
for label in ["missionary", "doggy", "cowgirl", "spooning"]:
add(label, 0.04)
if pair["overlap"]:
add("missionary", 0.05)
add("cowgirl", 0.05)
add("prone_bone", 0.04)
if pair["horizontal"]:
add("spooning", 0.12)
add("prone_bone", 0.07)
if pair["vertical"]:
add("standing", 0.08)
add("standing_doggy", 0.10)
if pair["stacked"]:
add("missionary", 0.07)
add("cowgirl", 0.07)
add("reverse_cowgirl", 0.06)
add("facesitting", 0.05)
if has_penis and has_pussy:
for label, score in [
("missionary", 0.08),
("doggy", 0.08),
("cowgirl", 0.08),
("reverse_cowgirl", 0.07),
("prone_bone", 0.06),
("standing_doggy", 0.06),
("spooning", 0.05),
]:
add(label, score)
if any_boxes_near(penis_boxes, pussy_boxes, 0.09) or hips_near_genitals:
add("missionary", 0.08)
add("doggy", 0.08)
add("cowgirl", 0.08)
add("reverse_cowgirl", 0.07)
if has_penis and (head_near_penis or has_tongue):
add("blowjob", 0.16)
if head_near_penis:
add("blowjob", 0.10)
if has_pussy and (head_near_pussy or has_tongue or any_boxes_near(tongue_boxes, pussy_boxes, 0.08)):
add("cunnilingus", 0.16)
if head_near_pussy or any_boxes_near(tongue_boxes, pussy_boxes, 0.08):
add("cunnilingus", 0.10)
if has_penis and hand_near_penis:
add("handjob", 0.18)
if has_pussy and hand_near_pussy:
add("fingering", 0.18)
if has_penis and has_breasts and any_boxes_near(penis_boxes, breast_boxes, 0.10):
add("boobjob", 0.20)
if has_toy:
add("toy_play", 0.12)
if (
hand_near_toy
or any_boxes_near(toy_boxes, pussy_boxes, 0.10)
or any_boxes_near(toy_boxes, penis_boxes, 0.10)
or any_boxes_near(toy_boxes, ass_boxes, 0.10)
):
add("toy_play", 0.12)
if person_count >= 2 and (head_near_ass or head_near_pussy):
if has_ass:
add("facesitting", 0.14)
if has_pussy and has_penis:
add("69", 0.10)
if has_ass and has_pussy and pair["horizontal"]:
add("doggy", 0.07)
add("prone_bone", 0.07)
def best_hybrid_pose_scene_position(
prediction: dict,
position_persons: list[dict],
context_persons: list[dict],
) -> tuple[str, float, bool, bool]:
pose_scores: dict[str, float] = {}
context_scores: dict[str, float] = {}
reliable_position_persons = reliable_pose_persons(position_persons)
reliable_context_persons = reliable_pose_persons(context_persons)
if not reliable_context_persons:
reliable_context_persons = reliable_position_persons
for person in reliable_position_persons:
label = normalize_sex_position_label(person.get("label"))
score = float(person.get("score") or 0.0)
if is_no_sex_position_label(label) or label not in POSITION_LABELS:
continue
combine_position_score(pose_scores, label, score)
quality = pose_keypoint_quality(person)
if quality > 0:
combine_position_score(pose_scores, label, 0.04 * quality)
add_pose_scene_context_scores(context_scores, prediction, reliable_context_persons)
best_position, best_score, has_pose_signal, has_context_signal = fuse_hybrid_position_scores(
pose_scores,
context_scores,
)
if best_position and (has_pose_signal or best_score >= _POSITION_CONTEXT_MIN_SCORE):
return best_position, clamp01(best_score), has_pose_signal, has_context_signal
return "", 0.0, False, has_context_signal
def best_pose_scene_position(prediction: dict, persons: list[dict]) -> tuple[str, float, bool, bool]:
return best_hybrid_pose_scene_position(prediction, persons, persons)
def apply_pose_result_to_prediction(prediction: dict, result) -> dict:
persons = pose_persons_from_result(result)
if persons:
prediction["persons"] = persons
best_position, best_score_value, _has_pose_signal, has_context_signal = best_pose_scene_position(
prediction,
persons,
)
if best_position:
prediction["sexPosition"] = best_position
prediction["sexPositionScore"] = best_score_value
append_prediction_source(prediction, "yolo_pose")
if has_context_signal:
append_prediction_source(prediction, "box_context")
return prediction
def predict_pose_results(model, paths: list[str], imgsz: int, conf: float):
return model.predict(
source=paths,
imgsz=imgsz,
conf=conf,
batch=_BATCH,
device=_DEVICE or None,
half=_HALF,
verbose=False,
)
def has_reliable_pose_persons(persons: list[dict]) -> bool:
return bool(reliable_pose_persons(persons))
def apply_hybrid_pose_persons_to_prediction(
prediction: dict,
position_persons: list[dict],
context_persons: list[dict],
) -> None:
display_persons = context_persons or position_persons
if display_persons:
prediction["persons"] = display_persons
best_position, best_score_value, has_pose_signal, has_context_signal = best_hybrid_pose_scene_position(
prediction,
position_persons,
context_persons,
)
if best_position:
prediction["sexPosition"] = best_position
prediction["sexPositionScore"] = best_score_value
if position_persons or context_persons:
append_prediction_source(prediction, "yolo_pose")
if context_persons:
append_prediction_source(prediction, "base_pose")
if has_pose_signal:
append_prediction_source(prediction, "pose_position")
if has_context_signal:
append_prediction_source(prediction, "box_context")
def apply_pose_batch_to_predictions(paths: list[str], predictions: list[dict], imgsz: int) -> None:
global _POSE_MODEL_ERROR
global _BASE_POSE_MODEL_ERROR
position_persons_by_index: dict[int, list[dict]] = {}
context_persons_by_index: dict[int, list[dict]] = {}
current_pose_model = get_pose_model()
if current_pose_model is not None:
try:
pose_results = predict_pose_results(current_pose_model, paths, imgsz, _POSE_CONF)
for index, pose_result in enumerate(pose_results):
position_persons_by_index[index] = pose_persons_from_result(pose_result)
except Exception as exc:
_POSE_MODEL_ERROR = str(exc)
needs_base_pose = [
index
for index in range(len(paths))
if not has_reliable_pose_persons(position_persons_by_index.get(index, []))
]
current_base_pose_model = get_base_pose_model()
if current_base_pose_model is not None and needs_base_pose:
try:
base_paths = [paths[index] for index in needs_base_pose]
base_results = predict_pose_results(current_base_pose_model, base_paths, imgsz, _BASE_POSE_CONF)
for original_index, base_result in zip(needs_base_pose, base_results):
context_persons_by_index[original_index] = pose_persons_from_result(base_result)
except Exception as exc:
_BASE_POSE_MODEL_ERROR = str(exc)
for index, prediction in enumerate(predictions):
position_persons = position_persons_by_index.get(index, [])
context_persons = context_persons_by_index.get(index, [])
apply_hybrid_pose_persons_to_prediction(
prediction,
position_persons,
context_persons,
)
def pose_model_status() -> dict:
try:
expected = resolve_pose_model_path()
expected_text = str(expected) if expected else str(DEFAULT_POSE_MODEL_PATH)
exists = expected is not None
error = _POSE_MODEL_ERROR
except Exception as exc:
expected_text = str(DEFAULT_POSE_MODEL_PATH)
exists = False
error = str(exc)
return {
"poseModelAvailable": exists,
"poseModelLoaded": pose_model is not None,
"poseModel": _POSE_MODEL_PATH or (expected_text if exists else ""),
"poseModelError": error,
"expectedPoseModel": expected_text,
}
def base_pose_model_status() -> dict:
try:
expected = resolve_base_pose_model_path()
expected_text = str(expected) if expected else "yolo26n-pose.pt"
exists = expected is not None
error = _BASE_POSE_MODEL_ERROR
except Exception as exc:
expected_text = "yolo26n-pose.pt"
exists = False
error = str(exc)
return {
"basePoseModelAvailable": exists,
"basePoseModelLoaded": base_pose_model is not None,
"basePoseModel": _BASE_POSE_MODEL_PATH or (expected_text if exists else ""),
"basePoseModelError": error,
"expectedBasePoseModel": expected_text,
}
def videomae_model_status() -> dict:
try:
expected = resolve_videomae_model_path()
expected_text = str(expected) if expected else str(DEFAULT_VIDEOMAE_MODEL_PATH)
exists = expected is not None
error = _VIDEOMAE_MODEL_ERROR
except Exception as exc:
expected_text = str(DEFAULT_VIDEOMAE_MODEL_PATH)
exists = False
error = str(exc)
return {
"videoMAEModelAvailable": exists,
"videoMAEModelLoaded": videomae_model is not None,
"videoMAEModel": _VIDEOMAE_MODEL_PATH or (expected_text if exists else ""),
"videoMAEModelError": error,
"expectedVideoMAEModel": expected_text,
"videoMAEDevice": _VIDEOMAE_DEVICE_ACTIVE,
}
def detector_model_status(load: bool = False) -> dict:
current_model = get_model() if load else model
names = getattr(current_model, "names", {}) or {} if current_model is not None else {}
expected_text = str(DEFAULT_MODEL_PATH)
exists = False
error = _MODEL_ERROR
try:
expected = resolve_model_path()
expected_text = str(expected)
exists = True
except Exception as exc:
if not error:
error = str(exc)
return {
"modelAvailable": exists,
"modelLoaded": current_model is not None,
"model": _MODEL_PATH or (expected_text if exists else ""),
"modelError": error,
"expectedModel": expected_text,
"classCount": len(names),
"classes": list(names.values())[:80] if isinstance(names, dict) else names,
}
@app.post("/predict-batch", dependencies=[Depends(require_ai_server_auth)])
def predict_batch(req: PredictBatchRequest):
paths = [str(path).strip() for path in req.paths if str(path).strip()]
if not paths:
return {
"ok": False,
"predictions": [],
"error": "no paths supplied",
}
imgsz = int(req.imageSize or _IMGSZ or 640)
current_model = get_model()
if current_model is None:
predictions = [empty_prediction("detector_model_missing") for _ in paths]
if not req.detectorOnly:
apply_pose_batch_to_predictions(paths, predictions, imgsz)
return {
"ok": True,
"available": False,
"modelAvailable": False,
"predictions": predictions,
"modelError": _MODEL_ERROR,
"expectedModel": str(DEFAULT_MODEL_PATH),
}
if DETECTION_LABELS_PATH is None or _LABEL_ERROR:
return {
"ok": False,
"predictions": [],
"error": f"detection labels missing: {_LABEL_ERROR}",
}
try:
results = current_model.predict(
source=paths,
imgsz=imgsz,
conf=_CONF,
batch=_BATCH,
device=_DEVICE or None,
half=_HALF,
verbose=False,
)
predictions = [prediction_from_result(result) for result in results]
if not req.detectorOnly:
apply_pose_batch_to_predictions(paths, predictions, imgsz)
return {
"ok": True,
"available": True,
"modelAvailable": True,
"predictions": predictions,
}
except Exception as exc:
return {
"ok": False,
"predictions": [],
"error": str(exc),
}
@app.post("/predict-position-clips", dependencies=[Depends(require_ai_server_auth)])
def predict_position_clips(req: PredictPositionClipsRequest):
clips = [clip for clip in req.clips if clip.paths]
if not clips:
return {
"ok": True,
"available": False,
"predictions": [],
"error": "no clips supplied",
}
current_model, current_processor = get_videomae_components()
if current_model is None or current_processor is None:
return {
"ok": True,
"available": False,
"predictions": [],
"error": _VIDEOMAE_MODEL_ERROR or f"VideoMAE model not found: {DEFAULT_VIDEOMAE_MODEL_PATH}",
}
predictions = []
for clip in clips:
try:
predictions.append(predict_videomae_clip(clip, int(req.numFrames or _VIDEOMAE_NUM_FRAMES or 16)))
except Exception as exc:
predictions.append({
"time": float(clip.time or 0.0),
"start": float(clip.start or 0.0),
"end": float(clip.end or 0.0),
"sexPosition": NO_SEX_POSITION_LABEL,
"sexPositionScore": 0.0,
"source": "videomae_predict_failed",
"error": repr(exc),
"scores": [],
})
return {
"ok": True,
"available": True,
"predictions": predictions,
}
@app.get("/health", dependencies=[Depends(require_ai_server_auth)])
def health():
status_payload = {
"ok": True,
"ready": True,
"trainingRoot": str(TRAINING_ROOT),
"labelConfig": str(DETECTION_LABELS_PATH) if DETECTION_LABELS_PATH else "",
"labelError": _LABEL_ERROR,
}
status_payload.update(detector_model_status(load=False))
status_payload.update(pose_model_status())
status_payload.update(base_pose_model_status())
status_payload.update(videomae_model_status())
return status_payload
@app.post("/reload", dependencies=[Depends(require_ai_server_auth)])
def reload_model():
global model
global pose_model
global base_pose_model
global videomae_model
global videomae_processor
global _MODEL_PATH
global _MODEL_ERROR
global _POSE_MODEL_PATH
global _POSE_MODEL_ERROR
global _BASE_POSE_MODEL_PATH
global _BASE_POSE_MODEL_ERROR
global _VIDEOMAE_MODEL_PATH
global _VIDEOMAE_MODEL_ERROR
global _VIDEOMAE_DEVICE_ACTIVE
global DETECTION_LABELS_PATH
model = None
pose_model = None
base_pose_model = None
videomae_model = None
videomae_processor = None
_MODEL_PATH = ""
_MODEL_ERROR = ""
_POSE_MODEL_PATH = ""
_POSE_MODEL_ERROR = ""
_BASE_POSE_MODEL_PATH = ""
_BASE_POSE_MODEL_ERROR = ""
_VIDEOMAE_MODEL_PATH = ""
_VIDEOMAE_MODEL_ERROR = ""
_VIDEOMAE_DEVICE_ACTIVE = ""
DETECTION_LABELS_PATH = None
status_payload = {
"ok": True,
"ready": True,
"trainingRoot": str(TRAINING_ROOT),
"labelConfig": str(DETECTION_LABELS_PATH) if DETECTION_LABELS_PATH else "",
"labelError": _LABEL_ERROR,
}
status_payload.update(detector_model_status(load=True))
status_payload.update(pose_model_status())
status_payload.update(base_pose_model_status())
status_payload.update(videomae_model_status())
return status_payload