feat(unraid): add Tail 2 GPU pose service
This commit is contained in:
@@ -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
|
||||||
|
```
|
||||||
|
|
||||||
@@ -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"]
|
||||||
|
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
__version__ = "0.1.0"
|
||||||
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,91 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import math
|
||||||
|
import queue
|
||||||
|
import threading
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
|
||||||
|
def finite01(value: float) -> 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
|
||||||
|
|
||||||
@@ -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)
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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()
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|
||||||
Binary file not shown.
@@ -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"] == []
|
||||||
|
|
||||||
Reference in New Issue
Block a user