feat(unraid): add Tail 2 GPU pose service

This commit is contained in:
Codex
2026-08-15 09:29:01 -07:00
parent ee1df2cad5
commit 67f2adbe5b
17 changed files with 659 additions and 0 deletions
@@ -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"
@@ -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
@@ -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"] == []