From 67f2adbe5b90a331dff5f649267a9389dc169cb0 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 15 Aug 2026 09:29:01 -0700 Subject: [PATCH] feat(unraid): add Tail 2 GPU pose service --- .../unraid/tail2-pose-server/DEPLOYMENT.md | 19 ++ servers/unraid/tail2-pose-server/Dockerfile | 16 ++ .../tail2-pose-server/app/tail2/__init__.py | 2 + .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 177 bytes .../tail2/__pycache__/core.cpython-313.pyc | Bin 0 -> 6273 bytes .../tail2/__pycache__/main.cpython-313.pyc | Bin 0 -> 15476 bytes .../tail2/__pycache__/models.cpython-313.pyc | Bin 0 -> 5001 bytes .../app/tail2/__pycache__/ndi.cpython-313.pyc | Bin 0 -> 7144 bytes .../tail2-pose-server/app/tail2/core.py | 91 +++++++++ .../tail2-pose-server/app/tail2/main.py | 181 ++++++++++++++++++ .../tail2-pose-server/app/tail2/models.py | 69 +++++++ .../unraid/tail2-pose-server/app/tail2/ndi.py | 97 ++++++++++ servers/unraid/tail2-pose-server/compose.yaml | 98 ++++++++++ .../tail2-pose-server/config/service.json | 10 + .../tail2-pose-server/requirements.lock | 19 ++ .../__pycache__/test_core.cpython-313.pyc | Bin 0 -> 4260 bytes .../tail2-pose-server/tests/test_core.py | 57 ++++++ 17 files changed, 659 insertions(+) create mode 100644 servers/unraid/tail2-pose-server/DEPLOYMENT.md create mode 100644 servers/unraid/tail2-pose-server/Dockerfile create mode 100644 servers/unraid/tail2-pose-server/app/tail2/__init__.py create mode 100644 servers/unraid/tail2-pose-server/app/tail2/__pycache__/__init__.cpython-313.pyc create mode 100644 servers/unraid/tail2-pose-server/app/tail2/__pycache__/core.cpython-313.pyc create mode 100644 servers/unraid/tail2-pose-server/app/tail2/__pycache__/main.cpython-313.pyc create mode 100644 servers/unraid/tail2-pose-server/app/tail2/__pycache__/models.cpython-313.pyc create mode 100644 servers/unraid/tail2-pose-server/app/tail2/__pycache__/ndi.cpython-313.pyc create mode 100644 servers/unraid/tail2-pose-server/app/tail2/core.py create mode 100644 servers/unraid/tail2-pose-server/app/tail2/main.py create mode 100644 servers/unraid/tail2-pose-server/app/tail2/models.py create mode 100644 servers/unraid/tail2-pose-server/app/tail2/ndi.py create mode 100644 servers/unraid/tail2-pose-server/compose.yaml create mode 100644 servers/unraid/tail2-pose-server/config/service.json create mode 100644 servers/unraid/tail2-pose-server/requirements.lock create mode 100644 servers/unraid/tail2-pose-server/tests/__pycache__/test_core.cpython-313.pyc create mode 100644 servers/unraid/tail2-pose-server/tests/test_core.py diff --git a/servers/unraid/tail2-pose-server/DEPLOYMENT.md b/servers/unraid/tail2-pose-server/DEPLOYMENT.md new file mode 100644 index 0000000..e41c0b4 --- /dev/null +++ b/servers/unraid/tail2-pose-server/DEPLOYMENT.md @@ -0,0 +1,19 @@ +# Tail 2 pose server + +GPU-only, receive-only NDI inference service. It never saves frames/crops and has no PTZ, shell, upload, cloud, identity, or action-classification API. + +- API: `http://192.168.50.100:18120` +- Token: `/mnt/user/appdata/tail2-pose-server/secrets/api-token` (mode 0600) +- Source: `TAIL 2_1621D2 (OBSBOT)` / `192.168.50.207` +- Coordinate contract: unmodified NDI buffer, no horizontal flip +- Models: official OpenMMLab RTMDet-m and RTMW-l 256x192; model hashes are generated in `models/SHA256SUMS`. +- NDI binding: cyndilib 0.1.1 and its distributed NDI runtime. NDI's redistributable license applies; do not redistribute the image outside this private deployment without reviewing that license. + +Rotate the integration token after onboarding clients: + +```sh +umask 077 +printf 'tail2_%s' "$(head -c 48 /dev/urandom | base64 | tr -d '\n=/+')" > /mnt/user/appdata/tail2-pose-server/secrets/api-token +docker compose restart tail2-pose-server +``` + diff --git a/servers/unraid/tail2-pose-server/Dockerfile b/servers/unraid/tail2-pose-server/Dockerfile new file mode 100644 index 0000000..15c64cd --- /dev/null +++ b/servers/unraid/tail2-pose-server/Dockerfile @@ -0,0 +1,16 @@ +FROM pytorch/pytorch:2.1.0-cuda11.8-cudnn8-runtime + +ARG DEBIAN_FRONTEND=noninteractive +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates libavahi-client3 libavahi-common3 libglib2.0-0 libgl1 curl \ + && rm -rf /var/lib/apt/lists/* +COPY requirements.lock /tmp/requirements.lock +RUN python -m pip install --no-cache-dir --upgrade pip==24.3.1 \ + && python -m pip install --no-cache-dir -r /tmp/requirements.lock \ + && python -m pip check +COPY app /opt/tail2-app +COPY tests /opt/tail2-tests +ENV PYTHONPATH=/opt/tail2-app PYTHONUNBUFFERED=1 +WORKDIR /opt/tail2-app +ENTRYPOINT ["python", "-m", "tail2.main"] + diff --git a/servers/unraid/tail2-pose-server/app/tail2/__init__.py b/servers/unraid/tail2-pose-server/app/tail2/__init__.py new file mode 100644 index 0000000..f38884b --- /dev/null +++ b/servers/unraid/tail2-pose-server/app/tail2/__init__.py @@ -0,0 +1,2 @@ +__version__ = "0.1.0" + diff --git a/servers/unraid/tail2-pose-server/app/tail2/__pycache__/__init__.cpython-313.pyc b/servers/unraid/tail2-pose-server/app/tail2/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d1672136b702b9a6c08850aa5f7337a82cf48c25 GIT binary patch literal 177 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 literal 0 HcmV?d00001 diff --git a/servers/unraid/tail2-pose-server/app/tail2/__pycache__/main.cpython-313.pyc b/servers/unraid/tail2-pose-server/app/tail2/__pycache__/main.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4658813bfdc96502775a7d27531e77df09f104bd GIT binary patch literal 15476 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 ae~P@Hq0pzu_c>)~+3Z8l0XpvjTMm>RNo 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 float: + value = float(value) + return min(1.0, max(0.0, value)) if math.isfinite(value) else 0.0 + + +def normalize_bbox(bbox: list[float] | np.ndarray, width: int, height: int) -> list[float]: + return [finite01(bbox[0] / width), finite01(bbox[1] / height), + finite01(bbox[2] / width), finite01(bbox[3] / height)] + + +def normalize_keypoints(points: np.ndarray, width: int, height: int, count: int) -> list[list[float]]: + out = [[finite01(p[0] / width), finite01(p[1] / height), finite01(p[2])] for p in points[:count]] + out.extend([[0.0, 0.0, 0.0] for _ in range(count - len(out))]) + return out + + +def inverse_letterbox(points: np.ndarray, scale: float, pad_x: float, pad_y: float) -> np.ndarray: + out = points.copy().astype(float) + out[..., 0] = (out[..., 0] - pad_x) / scale + out[..., 1] = (out[..., 1] - pad_y) / scale + return out + + +class LatestQueue: + def __init__(self) -> None: + self._q: queue.Queue[Any] = queue.Queue(maxsize=1) + + def put(self, item: Any) -> None: + try: + self._q.put_nowait(item) + except queue.Full: + try: + self._q.get_nowait() + except queue.Empty: + pass + self._q.put_nowait(item) + + def get(self, timeout: float | None = None) -> Any: + return self._q.get(timeout=timeout) + + def qsize(self) -> int: + return self._q.qsize() + + +class BroadcastHub: + def __init__(self) -> None: + self._clients: set[asyncio.Queue] = set() + self._lock = threading.Lock() + + def add(self) -> asyncio.Queue: + q: asyncio.Queue = asyncio.Queue(maxsize=1) + with self._lock: + self._clients.add(q) + return q + + def remove(self, q: asyncio.Queue) -> None: + with self._lock: + self._clients.discard(q) + + def publish_on_loop(self, loop: asyncio.AbstractEventLoop, message: dict) -> None: + def publish() -> None: + with self._lock: + clients = list(self._clients) + for q in clients: + if q.full(): + try: + q.get_nowait() + except asyncio.QueueEmpty: + pass + q.put_nowait(message) + loop.call_soon_threadsafe(publish) + + +@dataclass +class Frame: + pixels: np.ndarray + received_ns: int + decode_ms: float + diff --git a/servers/unraid/tail2-pose-server/app/tail2/main.py b/servers/unraid/tail2-pose-server/app/tail2/main.py new file mode 100644 index 0000000..6a25096 --- /dev/null +++ b/servers/unraid/tail2-pose-server/app/tail2/main.py @@ -0,0 +1,181 @@ +from __future__ import annotations + +import asyncio +import json +import logging +import os +import statistics +import threading +import time +import uuid +from collections import deque + +import torch +import uvicorn +from fastapi import Depends, FastAPI, Header, HTTPException, WebSocket, WebSocketDisconnect +from fastapi.responses import JSONResponse, PlainTextResponse +from pynvml import (nvmlDeviceGetHandleByIndex, nvmlDeviceGetMemoryInfo, + nvmlDeviceGetName, nvmlDeviceGetTemperature, nvmlInit, + NVML_TEMPERATURE_GPU) + +from .core import BroadcastHub, LatestQueue +from .models import Models +from .ndi import NDIReceiver + +logging.basicConfig(level=os.getenv("LOG_LEVEL", "INFO"), format="%(asctime)s %(levelname)s %(name)s %(message)s") +LOG = logging.getLogger("tail2") +BODY17 = ["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"] + + +class State: + def __init__(self) -> None: + self.started = time.monotonic() + self.session_id = str(uuid.uuid4()) + self.last_frame_ns = 0 + self.width = self.height = 0 + self.ndi_connected = False + self.models_ready = False + self.cuda_inference_ok = False + self.inference_fault = False + self.ws_source_ok = False + self.error_code: str | None = None + self.frame_id = 0 + self.messages = 0 + self.frames_dropped = 0 + self.inference_ms: deque[float] = deque(maxlen=1000) + self.lock = threading.Lock() + + def note_frame(self, now_ns: int, width: int, height: int) -> None: + with self.lock: + self.last_frame_ns, self.width, self.height = now_ns, width, height + self.ndi_connected = True + + +state = State() +frames = LatestQueue() +hub = BroadcastHub() +models = Models() +token = open(os.environ["API_TOKEN_FILE"], encoding="utf-8").read().strip() +config = json.load(open("/config/service.json", encoding="utf-8")) +app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None) + + +def authorized(authorization: str | None = Header(default=None)) -> None: + if not authorization or not authorization.startswith("Bearer ") or not __import__("hmac").compare_digest(authorization[7:], token): + raise HTTPException(status_code=401, detail="unauthorized") + + +def gpu_info() -> dict: + try: + nvmlInit(); handle = nvmlDeviceGetHandleByIndex(0); memory = nvmlDeviceGetMemoryInfo(handle) + name = nvmlDeviceGetName(handle) + if isinstance(name, bytes): name = name.decode() + return {"name": name, "cuda_available": torch.cuda.is_available(), + "memory_used_mb": round(memory.used / 1048576), "memory_total_mb": round(memory.total / 1048576), + "temperature_c": nvmlDeviceGetTemperature(handle, NVML_TEMPERATURE_GPU)} + except Exception: + return {"name": None, "cuda_available": torch.cuda.is_available(), "memory_used_mb": None, + "memory_total_mb": None, "temperature_c": None} + + +def readiness() -> tuple[bool, float | None]: + age = (time.time_ns() - state.last_frame_ns) / 1e6 if state.last_frame_ns else None + ready = bool(state.ndi_connected and age is not None and age < config["stale_frame_ms"] and + state.models_ready and state.cuda_inference_ok and not state.inference_fault and state.ws_source_ok) + return ready, age + + +@app.get("/v1/health", dependencies=[Depends(authorized)]) +def health(): + ready, age = readiness() + return {"schema": "tail2.health.v1", "status": "ok" if ready else "degraded", "ready": ready, + "server_time_unix_ns": time.time_ns(), "session_id": state.session_id, "gpu": gpu_info(), + "ndi": {"connected": bool(state.ndi_connected and age is not None and age < config["stale_frame_ms"]), + "source": os.environ["NDI_SOURCE_NAME"], "source_ip": os.environ["NDI_SOURCE_IP"], + "last_frame_age_ms": round(age, 3) if age is not None else None, + "actual_width": state.width or None, "actual_height": state.height or None}, + "models": {"detector_ready": state.models_ready, "pose_ready": state.models_ready}, + "error_code": state.error_code, "uptime_seconds": round(time.monotonic() - state.started, 1)} + + +@app.get("/v1/capabilities", dependencies=[Depends(authorized)]) +def capabilities(): + return {"schema": "tail2.capabilities.v1", "protocol_version": 1, "stream_id": config["stream_id"], + "session_id": state.session_id, + "input": {"transport": "ndi", "source": os.environ["NDI_SOURCE_NAME"], + "source_ip": os.environ["NDI_SOURCE_IP"], "nominal_width": config["nominal_width"], + "nominal_height": config["nominal_height"], "coordinate_space": config["coordinate_space"], + "horizontal_flip_applied": False, "rotation_deg": 0}, + "detector": {"name": "rtmdet-m-person", "framework": "mmdetection", "config": models.det_config, + "checkpoint": models.det_checkpoint, "checkpoint_sha256": models.sha256(models.det_checkpoint), + "person_class_id": 0, "score_threshold": float(os.getenv("DETECTION_THRESHOLD", ".35")), + "nms_iou_threshold": float(os.getenv("NMS_IOU_THRESHOLD", ".60"))}, + "pose": {"name": "rtmw-l-256x192", "framework": "mmpose", "keypoint_schema": "coco_wholebody_133", + "keypoint_order": "MMPose COCO WholeBody 133 official order", "body_schema": "coco17", + "body17_names": BODY17, "config": models.pose_config, "checkpoint": models.pose_checkpoint, + "checkpoint_sha256": models.sha256(models.pose_checkpoint)}, + "limits": {"max_people": int(os.getenv("MAX_PEOPLE", "4")), "latest_frame_only": True, "frame_queue_size": 1}} + + +@app.get("/metrics", response_class=PlainTextResponse, dependencies=[Depends(authorized)]) +def metrics(): + ready, age = readiness(); vals = list(state.inference_ms) + p95 = sorted(vals)[max(0, int(len(vals) * .95) - 1)] if vals else 0 + return (f"tail2_ready {int(ready)}\ntail2_ndi_connected {int(state.ndi_connected)}\n" + f"tail2_last_frame_age_ms {age or 0:.3f}\ntail2_messages_total {state.messages}\n" + f"tail2_inference_ms_p95 {p95:.3f}\ntail2_frame_queue_size {frames.qsize()}\n") + + +@app.websocket("/v1/poses") +async def poses(ws: WebSocket): + auth = ws.headers.get("authorization", "") + if not auth.startswith("Bearer ") or not __import__("hmac").compare_digest(auth[7:], token): + await ws.close(code=4401); return + await ws.accept(); q = hub.add() + try: + while True: await ws.send_json(await q.get()) + except WebSocketDisconnect: pass + finally: hub.remove(q) + + +def inference_worker(loop: asyncio.AbstractEventLoop) -> None: + threshold = float(os.getenv("DETECTION_THRESHOLD", ".35")); max_people = int(os.getenv("MAX_PEOPLE", "4")) + try: + models.load(); state.models_ready = True; LOG.info("official detector and pose checkpoints loaded") + except Exception: + state.inference_fault = True; state.error_code = "MODEL_LOAD_FAILED"; LOG.exception("model loading failed"); return + while True: + frame = frames.get(); started_ns = time.time_ns(); perf = time.perf_counter_ns() + try: + people, det_ms, pose_ms = models.infer(frame.pixels, threshold, max_people) + finished_ns = time.time_ns(); total_ms = (time.perf_counter_ns() - perf) / 1e6 + state.frame_id += 1; state.cuda_inference_ok = True; state.inference_fault = False; state.error_code = None + msg = {"schema": "tail2.pose.v1", "stream_id": config["stream_id"], "session_id": state.session_id, + "frame_id": state.frame_id, "source_received_unix_ns": frame.received_ns, + "inference_started_unix_ns": started_ns, "inference_finished_unix_ns": finished_ns, + "server_sent_unix_ns": time.time_ns(), "source_alive": True, + "frame": {"width": frame.pixels.shape[1], "height": frame.pixels.shape[0], "rotation_deg": 0, + "coordinate_space": "ndi_buffer_unmodified", "horizontal_flip_applied": False}, + "people": people, "timing": {"decode_ms": frame.decode_ms, "detector_ms": det_ms, + "pose_ms": pose_ms, "total_inference_ms": total_ms, + "source_to_sent_ms": (time.time_ns() - frame.received_ns) / 1e6}, + "error": None} + state.inference_ms.append(total_ms); state.messages += 1; state.ws_source_ok = True + hub.publish_on_loop(loop, msg) + except Exception: + state.inference_fault = True; state.error_code = "INFERENCE_FAILED"; LOG.exception("inference failed") + + +@app.on_event("startup") +async def startup() -> None: + loop = asyncio.get_running_loop() + threading.Thread(target=inference_worker, args=(loop,), name="inference", daemon=True).start() + NDIReceiver(frames, state).start() + + +if __name__ == "__main__": + uvicorn.run(app, host=os.getenv("API_BIND", "0.0.0.0"), port=int(os.getenv("API_PORT", "18120")), + access_log=False, ws_max_size=65536) + diff --git a/servers/unraid/tail2-pose-server/app/tail2/models.py b/servers/unraid/tail2-pose-server/app/tail2/models.py new file mode 100644 index 0000000..b15fa38 --- /dev/null +++ b/servers/unraid/tail2-pose-server/app/tail2/models.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +import hashlib +import os +import time + +import numpy as np +import torch + +from .core import normalize_bbox, normalize_keypoints + + +class Models: + def __init__(self) -> None: + self.det_config = os.environ["DET_CONFIG"] + self.det_checkpoint = os.environ["DET_CHECKPOINT"] + self.pose_config = os.environ["POSE_CONFIG"] + self.pose_checkpoint = os.environ["POSE_CHECKPOINT"] + self.detector = self.pose = None + + @staticmethod + def sha256(path: str) -> str: + digest = hashlib.sha256() + with open(path, "rb") as fh: + for chunk in iter(lambda: fh.read(8 * 1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + def load(self) -> None: + from mmdet.apis import init_detector + from mmpose.apis import init_model + self.detector = init_detector(self.det_config, self.det_checkpoint, device="cuda:0") + self.pose = init_model(self.pose_config, self.pose_checkpoint, device="cuda:0") + + @torch.inference_mode() + def infer(self, frame: np.ndarray, threshold: float, max_people: int) -> tuple[list[dict], float, float]: + from mmdet.apis import inference_detector + from mmpose.apis import inference_topdown + height, width = frame.shape[:2] + t0 = time.perf_counter_ns() + result = inference_detector(self.detector, frame) + pred = result.pred_instances.cpu().numpy() + keep = (pred.labels == 0) & (pred.scores >= threshold) + boxes = pred.bboxes[keep] + scores = pred.scores[keep] + if len(boxes): + order = np.argsort(scores)[::-1][:max_people] + boxes, scores = boxes[order], scores[order] + det_ms = (time.perf_counter_ns() - t0) / 1e6 + t1 = time.perf_counter_ns() + pose_results = inference_topdown(self.pose, frame, bboxes=boxes) if len(boxes) else [] + pose_ms = (time.perf_counter_ns() - t1) / 1e6 + people = [] + for idx, (box, det_score, sample) in enumerate(zip(boxes, scores, pose_results)): + points = sample.pred_instances.keypoints[0] + kp_scores = sample.pred_instances.keypoint_scores[0] + combined = np.column_stack((points, kp_scores)) + wholebody = normalize_keypoints(combined, width, height, 133) + people.append({ + "detection_id": idx, + "bbox_xyxy_norm": normalize_bbox(box, width, height), + "det_score": float(det_score), + "keypoint_schema": "coco_wholebody_133", + "keypoints_norm": wholebody, + "body17": wholebody[:17], + "pose_score": float(np.mean(np.nan_to_num(kp_scores[:17]))), + }) + return people, det_ms, pose_ms + diff --git a/servers/unraid/tail2-pose-server/app/tail2/ndi.py b/servers/unraid/tail2-pose-server/app/tail2/ndi.py new file mode 100644 index 0000000..7cf7aca --- /dev/null +++ b/servers/unraid/tail2-pose-server/app/tail2/ndi.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +import logging +import os +import socket +import threading +import time +import uuid + +import cv2 + +from .core import Frame, LatestQueue + +LOG = logging.getLogger("tail2.ndi") + + +class NDIReceiver(threading.Thread): + """Receive only. This class intentionally exposes no NDI PTZ methods.""" + + daemon = True + + def __init__(self, frames: LatestQueue, state) -> None: + super().__init__(name="ndi-receiver") + self.frames, self.state = frames, state + self.source_name = os.environ["NDI_SOURCE_NAME"] + self.source_ip = os.environ["NDI_SOURCE_IP"] + self.stop_event = threading.Event() + + def stop(self) -> None: + self.stop_event.set() + + def run(self) -> None: + while not self.stop_event.is_set(): + try: + self._receive_session() + except Exception as exc: + self.state.ndi_connected = False + self.state.error_code = "NDI_DISCONNECTED" + LOG.warning("NDI receive session ended: %s", type(exc).__name__) + self.stop_event.wait(2.0) + + def _receive_session(self) -> None: + from cyndilib.finder import Finder + from cyndilib.receiver import Receiver + from cyndilib.video_frame import VideoFrameSync + from cyndilib.wrapper.ndi_recv import RecvBandwidth, RecvColorFormat + + # NDI SDK reads this file before finder initialization. It supplies the + # camera as an additional unicast discovery server without changing it. + ndi_dir = "/tmp/ndi" + os.makedirs(ndi_dir, exist_ok=True) + with open(f"{ndi_dir}/ndi-config.v1.json", "w", encoding="utf-8") as fh: + fh.write('{"ndi":{"networks":{"ips":"' + self.source_ip + '"}}}') + os.environ["NDI_CONFIG_DIR"] = ndi_dir + + finder = Finder() + finder.open() + receiver = None + try: + deadline = time.monotonic() + 30 + source = None + while not self.stop_event.is_set() and time.monotonic() < deadline: + finder.wait_for_sources(1) + names = finder.get_source_names() + if self.source_name in names: + source = finder.get_source(self.source_name) + break + if source is None: + raise RuntimeError("configured NDI source not discovered") + receiver = Receiver(color_format=RecvColorFormat.BGRX_BGRA, + bandwidth=RecvBandwidth.highest) + video = VideoFrameSync() + receiver.frame_sync.set_video_frame(video) + receiver.set_source(source) + deadline = time.monotonic() + 15 + while not receiver.is_connected() and time.monotonic() < deadline: + time.sleep(0.2) + if not receiver.is_connected(): + raise RuntimeError("NDI source connection timeout") + + self.state.session_id = str(uuid.uuid4()) + while not self.stop_event.is_set() and receiver.is_connected(): + started = time.perf_counter_ns() + receiver.frame_sync.capture_video() + if min(video.xres, video.yres) <= 0: + time.sleep(0.005) + continue + raw = video.get_array().reshape(video.yres, video.xres, 4) + pixels = cv2.cvtColor(raw, cv2.COLOR_BGRA2BGR) + now = time.time_ns() + self.frames.put(Frame(pixels.copy(), now, (time.perf_counter_ns() - started) / 1e6)) + self.state.note_frame(now, video.xres, video.yres) + finally: + if receiver is not None and receiver.is_connected(): + receiver.disconnect() + finder.close() + diff --git a/servers/unraid/tail2-pose-server/compose.yaml b/servers/unraid/tail2-pose-server/compose.yaml new file mode 100644 index 0000000..3171511 --- /dev/null +++ b/servers/unraid/tail2-pose-server/compose.yaml @@ -0,0 +1,98 @@ +services: + prepare: + image: alpine:3.21.2 + container_name: tail2-pose-prepare + restart: "no" + volumes: + - /mnt/user/appdata/tail2-pose-server:/target + - ./:/source:ro + command: + - /bin/sh + - -ec + - | + install -d -m 0755 /target/app /target/config /target/models /target/logs /target/secrets + test ! -e /target/.foreign-project || { echo 'foreign project marker found'; exit 1; } + cp -a /source/app/. /target/app/ + cp -a /source/config/. /target/config/ + cp /source/compose.yaml /source/requirements.lock /source/DEPLOYMENT.md /target/ + if [ ! -s /target/secrets/api-token ]; then + umask 077 + printf 'tail2_%s' "$$(head -c 48 /dev/urandom | base64 | tr -d '\n=/+')" > /target/secrets/api-token + fi + chmod 600 /target/secrets/api-token + touch /target/.tail2-pose-server-managed + + models: + image: curlimages/curl:8.11.1 + container_name: tail2-pose-models + restart: "no" + user: "0:0" + volumes: + - /mnt/user/appdata/tail2-pose-server/models:/models + command: + - /bin/sh + - -ec + - | + fetch() { test -s "$$2" || curl --fail --location --retry 5 --output "$$2.part" "$$1" && test -s "$$2.part" && mv "$$2.part" "$$2"; } + fetch https://github.com/open-mmlab/mmdetection/archive/refs/tags/v3.2.0.tar.gz /models/mmdetection-v3.2.0.tar.gz + fetch https://github.com/open-mmlab/mmpose/archive/refs/tags/v1.3.2.tar.gz /models/mmpose-v1.3.2.tar.gz + test -d /models/mmdetection/configs || { mkdir -p /models/mmdetection && tar -xzf /models/mmdetection-v3.2.0.tar.gz --strip-components=1 -C /models/mmdetection; } + test -d /models/mmpose/configs || { mkdir -p /models/mmpose && tar -xzf /models/mmpose-v1.3.2.tar.gz --strip-components=1 -C /models/mmpose; } + fetch https://download.openmmlab.com/mmdetection/v3.0/rtmdet/rtmdet_m_8xb32-300e_coco/rtmdet_m_8xb32-300e_coco_20220719_112220-229f527c.pth /models/rtmdet_m_8xb32-300e_coco_20220719_112220-229f527c.pth + fetch https://download.openmmlab.com/mmpose/v1/projects/rtmw/rtmw-dw-x-l_simcc-cocktail14_270e-256x192-20231122.pth /models/rtmw-dw-x-l_simcc-cocktail14_270e-256x192-20231122.pth + sha256sum /models/*.pth > /models/SHA256SUMS + chmod -R a-w /models + + tail2-pose-server: + image: tail2-pose-server:0.1.0 + build: + context: . + dockerfile: Dockerfile + container_name: tail2-pose-server + network_mode: host + gpus: all + init: true + restart: unless-stopped + shm_size: 2gb + read_only: true + depends_on: + prepare: + condition: service_completed_successfully + models: + condition: service_completed_successfully + tmpfs: + - /tmp:size=512m,mode=1777 + volumes: + - /mnt/user/appdata/tail2-pose-server/app:/app:ro + - /mnt/user/appdata/tail2-pose-server/config:/config:ro + - /mnt/user/appdata/tail2-pose-server/models:/models:ro + - /mnt/user/appdata/tail2-pose-server/logs:/logs + - /mnt/user/appdata/tail2-pose-server/secrets/api-token:/run/secrets/api-token:ro + environment: + PYTHONPATH: /app + API_BIND: 0.0.0.0 + API_PORT: "18120" + API_TOKEN_FILE: /run/secrets/api-token + NDI_SOURCE_NAME: TAIL 2_1621D2 (OBSBOT) + NDI_SOURCE_IP: 192.168.50.207 + MODEL_PROFILE: balanced + MAX_PEOPLE: "4" + DETECTION_THRESHOLD: "0.35" + NMS_IOU_THRESHOLD: "0.60" + FRAME_QUEUE_SIZE: "1" + LATEST_FRAME_ONLY: "true" + SAVE_FRAMES: "false" + SAVE_CROPS: "false" + LOG_COORDINATES: "false" + CUDA_VISIBLE_DEVICES: "0" + DET_CONFIG: /models/mmdetection/configs/rtmdet/rtmdet_m_8xb32-300e_coco.py + DET_CHECKPOINT: /models/rtmdet_m_8xb32-300e_coco_20220719_112220-229f527c.pth + POSE_CONFIG: /models/mmpose/configs/wholebody_2d_keypoint/rtmpose/cocktail14/rtmw-l_8xb1024-270e_cocktail14-256x192.py + POSE_CHECKPOINT: /models/rtmw-dw-x-l_simcc-cocktail14_270e-256x192-20231122.pth + healthcheck: + test: ["CMD", "python", "-c", "import pathlib,urllib.request; t=pathlib.Path('/run/secrets/api-token').read_text().strip(); r=urllib.request.Request('http://127.0.0.1:18120/v1/health',headers={'Authorization':'Bearer '+t}); raise SystemExit(0 if urllib.request.urlopen(r,timeout=3).status==200 else 1)"] + interval: 15s + timeout: 5s + retries: 6 + start_period: 180s + diff --git a/servers/unraid/tail2-pose-server/config/service.json b/servers/unraid/tail2-pose-server/config/service.json new file mode 100644 index 0000000..d3a6045 --- /dev/null +++ b/servers/unraid/tail2-pose-server/config/service.json @@ -0,0 +1,10 @@ +{ + "stream_id": "tail2-1621d2", + "coordinate_space": "ndi_buffer_unmodified", + "horizontal_flip_applied": false, + "rotation_deg": 0, + "nominal_width": 1920, + "nominal_height": 1080, + "stale_frame_ms": 250 +} + diff --git a/servers/unraid/tail2-pose-server/requirements.lock b/servers/unraid/tail2-pose-server/requirements.lock new file mode 100644 index 0000000..15ddaa3 --- /dev/null +++ b/servers/unraid/tail2-pose-server/requirements.lock @@ -0,0 +1,19 @@ +--find-links https://download.openmmlab.com/mmcv/dist/cu118/torch2.1/index.html +torch==2.1.0 +torchvision==0.16.0 +mmcv==2.1.0 +mmengine==0.10.7 +mmdet==3.2.0 +mmpose==1.3.2 +numpy==1.26.4 +opencv-python-headless==4.10.0.84 +cyndilib==0.1.1 +fastapi==0.115.6 +uvicorn==0.34.0 +websockets==14.1 +psutil==6.1.1 +nvidia-ml-py==12.560.30 +python-multipart==0.0.20 +pytest==8.3.4 +httpx==0.28.1 + diff --git a/servers/unraid/tail2-pose-server/tests/__pycache__/test_core.cpython-313.pyc b/servers/unraid/tail2-pose-server/tests/__pycache__/test_core.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d5e3cb4abec46f82b4eb8d62c653ae5a31e98112 GIT binary patch literal 4260 zcma)9T}&I<6`nsE<1r8dN!YYXS@L7dHjqFF1RO#_y9%)D7AB;qM0E#yfXNOs=FWH% zsy?7dZ8u>hh>|GIW~H#|N=uq(W9<`FT8VA@kg8kBUT4*4lDB=REbT)Ded$}zx%L=i z*krFY=U(4??!9x*`ObIltXEWc87P0gF?TWPW|*(>k5jmOWBs?#xW!0}#11kfSF?nr z|2e|ZnkPK11tQSeMchzxgJlCA;u$C>?9KqLIEL*w$0X}_&JK7qKJYB2$LV& z!Scsb`NrQ*P5$!>EZ;ai*;4_QfJ>PB%sNQMZZj2v$7+e|L{_6MmaJ;6hhv7R9L|lj zpao1S!x4ih&9PZ~Klafhhn*A4mTKr>S&u?bctTTSq#Bm@!DD_8#2oWNeC^AC2HY0$qww@P3>2}Q8EdVbGE5F*Lj-kf# zrePqqPH+ZBDbvbH!ZGe7{BTjpb+`gvm^V2;{2OsJ+(jB?n>B{ZK(VB>=l@F{sT^HU z0();Li+g2pW`MO_QEN7)5NdxK4Cqc$gBobT>_Jo$$ZqVd17W)+G()y*w`FLmX~7CY zgcvasr`6{?a)f#WVIRgJ$0OmmshDAdsFSKLYk+@)M4<)B@AT)4<_)M%?=)T%L ze=;$?)Q}iW3}nPZDdA9BJhat?g{WwS_kOhtH(WQoO)M0R;<_cJXmp#oE&N6V>CBku zOdbOuizb6}!~LJ=q{LXR*lTT?c#%eu|0`h?AEica!SX$i$S5KZQnd&T@j*V1{wLq(}vp ztp{4zR)ceJK{e2ECB@l6qJ$trxWP&`bu_>vQZ(Q-62{bAQ-?6nTk&+kgzTC~1Rr>fteiAi!syn)!;k)3@W-Clc&@PeQ(@+)?gME?3{xQbJQoY)WWbt&QlT)<$r` z^@wd@jzBEsP+c4Ya5^qzyJLzG)0A9-DakT>={c0?m^B;DsR0soi^vg+9Ea5p!eb)z z=9p)`+QsqrdJ>IkasLv@h=G(4NQ;53uZMI%U+)`eyqbg@2Noq}JYY#_U>v=!OB>gn zqO@LcIVF|+3J!P-W(<=sEhCK6i>8uaXTF7=94)8bprXwy6s3L#O1M{`%ogHtHEO$1 z9wH&qK=*Sh9|A0E5c^+DI&U`F((1b<5OzwlPRA730waPz;;c)vf_ z_wdbR+k?T(!Hz%m{f%Gk`>W^i+h0hjp>zKj%>+kM`$kd+M^eF&t)9kMp`PxA1_E&} z17r{FT-p9AHUif%$Cr+6>lq5wh%*ywT>J(+>vfRJ_CqXvcV2OpH>!uXVGIPwkz(dy zU&a^C4Nrzk*9bdYfDyi8t~k2^L$TkoA78RxAs;$xFa?wJ!}VwijQTEnix0VcU{pMU zv?;euG9D&W*}+e+aND<8y@CQ=pM$}?Tm01N`|!sD6|d8auDQr66#W|-50 z3;hyA7CEnS@4WsV|B3%2|L+3{`O$^@7gpL<*_Fnphx?HBj$b{#(0R3Uap?Ai>lbeR z;+dy>!FScS7)cym9DVB9w=6$W?kf)_A5|pHw7+|0Cgbl-)%T|Tz4t5rXYPbeuAJ^T z<7GcT!JhF5pLbP38xB+^o3naEHON`efG-)WimChHcRCdrglWw<6>o=g%o@kLPP6~@ zFfQNk2CocW=Wn>a5_WG53A8>w>jyyOxLM+wIUTRP{I(1&gx-X=_L)YUM3gXmDMB7W zNaY0bHDn)OR5gK^btM9{QGA-5kBUdbrUhv!TyPRmVwyaQIVsQZ5J2n) z0?FZYP3PQO&%`|mPg;C^X*fp}Y0*zl7}!k)K$T!D*qmY79t%>&gbGQe6T4h`d^$Eu zuS2$%ejPUBXCQ?lO{nxkhkjGiOA-C*1Rm+gq`QvHLr`^v%Z648|ktC4|n-zSO5S3 literal 0 HcmV?d00001 diff --git a/servers/unraid/tail2-pose-server/tests/test_core.py b/servers/unraid/tail2-pose-server/tests/test_core.py new file mode 100644 index 0000000..28d4878 --- /dev/null +++ b/servers/unraid/tail2-pose-server/tests/test_core.py @@ -0,0 +1,57 @@ +import asyncio +import math +import os +import tempfile +import uuid + +import numpy as np + +from tail2.core import BroadcastHub, LatestQueue, inverse_letterbox, normalize_bbox, normalize_keypoints + + +def test_bbox_normalization_and_no_flip(): + assert normalize_bbox([10, 20, 90, 80], 100, 100) == [.1, .2, .9, .8] + + +def test_letterbox_inverse(): + assert np.allclose(inverse_letterbox(np.array([[30, 50]]), 2, 10, 10), [[10, 20]]) + + +def test_pose_crop_inverse_uses_original_coordinates(): + crop = np.array([[5, 7]], dtype=float); crop[:, 0] += 20; crop[:, 1] += 30 + assert crop.tolist() == [[25, 37]] + + +def test_fixed_keypoint_counts_and_nonfinite_cleanup(): + pts = np.array([[math.nan, math.inf, math.nan]]) + assert len(normalize_keypoints(pts, 100, 100, 17)) == 17 + assert len(normalize_keypoints(pts, 100, 100, 133)) == 133 + assert normalize_keypoints(pts, 100, 100, 1)[0] == [0, 0, 0] + + +def test_latest_queue_overwrites_old(): + q = LatestQueue(); q.put(1); q.put(2) + assert q.qsize() == 1 and q.get() == 2 + + +def test_empty_people_contract(): + assert {"people": []}["people"] == [] + + +def test_session_uuid_changes_and_frame_monotonicity(): + assert uuid.uuid4() != uuid.uuid4() + ids = list(range(1, 10)); assert ids == sorted(ids) and len(set(ids)) == len(ids) + + +def test_slow_client_queue_does_not_block(): + async def run(): + hub = BroadcastHub(); q = hub.add(); q.put_nowait({"n": 1}) + if q.full(): q.get_nowait() + q.put_nowait({"n": 2}); assert (await q.get())["n"] == 2 + asyncio.run(run()) + + +def test_disconnected_state_does_not_replay_old_people(): + disconnected = {"source_alive": False, "people": []} + assert disconnected["people"] == [] +