438 lines
12 KiB
Python
438 lines
12 KiB
Python
# backend/ai_server.py
|
|
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
from typing import List, Optional
|
|
|
|
from fastapi import FastAPI
|
|
from pydantic import BaseModel
|
|
from ultralytics import YOLO
|
|
|
|
|
|
BASE_DIR = Path(__file__).resolve().parent
|
|
|
|
|
|
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 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")
|
|
):
|
|
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"
|
|
|
|
|
|
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}")
|
|
|
|
candidates = [
|
|
TRAINING_ROOT / "detection_labels.json",
|
|
|
|
# Wenn ai_server.py direkt neben detection_labels.json embedded liegt:
|
|
BASE_DIR / "detection_labels.json",
|
|
|
|
# Dev-Fallbacks:
|
|
BASE_DIR / "ml" / "detection_labels.json",
|
|
BASE_DIR.parent / "ml" / "detection_labels.json",
|
|
Path.cwd() / "ml" / "detection_labels.json",
|
|
Path.cwd() / "backend" / "ml" / "detection_labels.json",
|
|
]
|
|
|
|
for p in candidates:
|
|
if existing_file(p):
|
|
return p.resolve()
|
|
|
|
raise RuntimeError(
|
|
"detection_labels.json not found. Checked: "
|
|
+ ", ".join(str(p) for p in candidates)
|
|
)
|
|
|
|
|
|
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}")
|
|
|
|
|
|
# Server darf auch ohne Labels/Model starten.
|
|
DETECTION_LABELS_PATH: Optional[Path] = None
|
|
|
|
LABEL_GROUPS = {
|
|
"people": set(),
|
|
"sexPositions": {"unknown"},
|
|
"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 = ""
|
|
_LABEL_ERROR = ""
|
|
|
|
_DEVICE = os.environ.get("YOLO_DEVICE", "")
|
|
_CONF = float(os.environ.get("YOLO_CONF", "0.25"))
|
|
_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"}
|
|
|
|
model = None
|
|
|
|
app = FastAPI()
|
|
|
|
|
|
class PredictBatchRequest(BaseModel):
|
|
paths: List[str]
|
|
detectorOnly: bool = False
|
|
imageSize: int = 640
|
|
model: Optional[str] = None
|
|
|
|
|
|
def empty_prediction(source: str = "model_missing") -> dict:
|
|
return {
|
|
"modelAvailable": False,
|
|
"source": source,
|
|
"sexPosition": "unknown",
|
|
"sexPositionScore": 0.0,
|
|
"peoplePresent": [],
|
|
"bodyPartsPresent": [],
|
|
"objectsPresent": [],
|
|
"clothingPresent": [],
|
|
"boxes": [],
|
|
}
|
|
|
|
|
|
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": set(
|
|
str(x).strip().lower()
|
|
for x in data.get("sexPositions", [])
|
|
if str(x).strip()
|
|
),
|
|
"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"] = {"unknown"}
|
|
|
|
_LABEL_ERROR = ""
|
|
|
|
except Exception as exc:
|
|
DETECTION_LABELS_PATH = None
|
|
_LABEL_ERROR = str(exc)
|
|
|
|
LABEL_GROUPS = {
|
|
"people": set(),
|
|
"sexPositions": {"unknown"},
|
|
"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 label != "unknown"
|
|
}
|
|
|
|
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 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 = []
|
|
|
|
sex_position = "unknown"
|
|
sex_position_score = 0.0
|
|
|
|
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
|
|
is_position = label in POSITION_LABELS
|
|
|
|
if is_position:
|
|
if score > sex_position_score:
|
|
sex_position = label
|
|
sex_position_score = score
|
|
continue
|
|
|
|
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": sex_position,
|
|
"sexPositionScore": sex_position_score,
|
|
"peoplePresent": people_present,
|
|
"bodyPartsPresent": body_parts,
|
|
"objectsPresent": objects,
|
|
"clothingPresent": clothing,
|
|
"boxes": boxes_out,
|
|
}
|
|
|
|
|
|
@app.post("/predict-batch")
|
|
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",
|
|
}
|
|
|
|
current_model = get_model()
|
|
if current_model is None:
|
|
return {
|
|
"ok": True,
|
|
"predictions": [empty_prediction("model_missing") for _ in paths],
|
|
"error": _MODEL_ERROR or f"YOLO model not found: {DEFAULT_MODEL_PATH}",
|
|
}
|
|
|
|
if DETECTION_LABELS_PATH is None or _LABEL_ERROR:
|
|
return {
|
|
"ok": False,
|
|
"predictions": [],
|
|
"error": f"detection labels missing: {_LABEL_ERROR}",
|
|
}
|
|
|
|
imgsz = int(req.imageSize or _IMGSZ or 640)
|
|
|
|
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]
|
|
|
|
return {
|
|
"ok": True,
|
|
"predictions": predictions,
|
|
}
|
|
|
|
except Exception as exc:
|
|
return {
|
|
"ok": False,
|
|
"predictions": [],
|
|
"error": str(exc),
|
|
}
|
|
|
|
|
|
@app.get("/health")
|
|
def health():
|
|
current_model = get_model()
|
|
names = getattr(current_model, "names", {}) or {} if current_model is not None else {}
|
|
|
|
return {
|
|
"ok": True,
|
|
"ready": current_model is not None,
|
|
"modelAvailable": current_model is not None,
|
|
"model": _MODEL_PATH,
|
|
"modelError": _MODEL_ERROR,
|
|
"expectedModel": str(DEFAULT_MODEL_PATH),
|
|
"trainingRoot": str(TRAINING_ROOT),
|
|
"classCount": len(names),
|
|
"classes": list(names.values())[:80] if isinstance(names, dict) else names,
|
|
"labelConfig": str(DETECTION_LABELS_PATH) if DETECTION_LABELS_PATH else "",
|
|
"labelError": _LABEL_ERROR,
|
|
} |