Files
AV-Live/data_feeds/feeds/pose.py
T
L'électron rare 8913393e4c feat(data-feeds): data-only profile + feeds
Add config.data-only.toml for headless bridge mode.
Update .gitignore to exclude .venv, __pycache__, *.pt.
Improve USGS, SWPC, RTE, GitHub, and pose feeds:
error handling, rate limiting, keypoint emission.
Refactor bridge.py to support -c config file flag.
2026-05-13 09:33:36 +02:00

169 lines
5.9 KiB
Python

"""Webcam → OpenCV → pose detection (YOLOv8-pose) → OSC.
Pourquoi YOLOv8-pose plutot qu'OpenPose proper ?
- OpenPose officiel = build CUDA, douloureux sur Mac ARM.
- YOLOv8-pose : pip install, MPS/Metal accelere, 17 keypoints COCO
(proche d'OpenPose BODY_25, suffisant pour de l'AV-live).
- Pour un vrai OpenPose, swap simple : remplacer `Detector` par un
wrapper autour de pyopenpose ou cmu-openpose et conserver le format
keypoints (x_norm, y_norm, conf) emis sur OSC.
Sortie OSC :
/data/pose/count <n>
/data/pose/person <idx> <cx> <cy> <w> <h> <conf>
/data/pose/skel <idx> <conf_avg> <x0 y0 c0 ... x16 y16 c16>
/data/pose/bone <idx> <kp_a> <kp_b> (a la connexion, statique)
Toutes les coordonnees sont normalisees 0..1 (origine top-left).
"""
from __future__ import annotations
import asyncio
import logging
import time
LOG = logging.getLogger("feed.pose")
# Squelette COCO 17 keypoints (paires d'os).
COCO_BONES: list[tuple[int, int]] = [
(0, 1), (0, 2), (1, 3), (2, 4), # tete
(5, 6), (5, 7), (7, 9), (6, 8), (8, 10),# bras
(5, 11), (6, 12), (11, 12), # torse
(11, 13), (13, 15), (12, 14), (14, 16), # jambes
]
class _Lazy:
"""Imports lourds differes pour ne pas casser le pont entier si pose
n'est pas demande."""
def __init__(self) -> None:
self.cv2 = None
self.YOLO = None
self.np = None
def load(self) -> None:
if self.cv2 is not None:
return
import cv2 # type: ignore
import numpy as np # type: ignore
from ultralytics import YOLO # type: ignore
self.cv2 = cv2
self.np = np
self.YOLO = YOLO
_LAZY = _Lazy()
async def run(ctx) -> None:
cfg = ctx.cfg
try:
_LAZY.load()
except ModuleNotFoundError as e:
LOG.error("dependances manquantes : %s — uv sync --extra pose", e)
await asyncio.Event().wait()
return
cv2, np, YOLO = _LAZY.cv2, _LAZY.np, _LAZY.YOLO
cam_idx = int(cfg.get("camera", 0))
width = int(cfg.get("width", 640))
height = int(cfg.get("height", 480))
target_fps = float(cfg.get("target_fps", 20))
conf_thresh = float(cfg.get("conf_thresh", 0.35))
max_persons = int(cfg.get("max_persons", 4))
emit_kp = bool(cfg.get("emit_keypoints", True))
model_name = cfg.get("model", "yolov8n-pose.pt")
device = cfg.get("device", "mps")
LOG.info("loading %s on %s", model_name, device)
model = YOLO(model_name)
cap = cv2.VideoCapture(cam_idx)
cap.set(cv2.CAP_PROP_FRAME_WIDTH, width)
cap.set(cv2.CAP_PROP_FRAME_HEIGHT, height)
if not cap.isOpened():
LOG.error("camera index %d indisponible", cam_idx)
await asyncio.Event().wait()
return
# Annonce du squelette (statique, une fois)
for a, b in COCO_BONES:
ctx.send("bone", float(a), float(b))
loop = asyncio.get_running_loop()
period = 1.0 / max(1.0, target_fps)
LOG.info("pose stream up: %dx%d @ %.1f fps target", width, height, target_fps)
def _grab():
ok, frame = cap.read()
return frame if ok else None
# ThreadPoolExecutor dedie : empeche l'inference longue (>20ms sur MPS)
# de monopoliser le pool partage et de bloquer les autres feeds.
import concurrent.futures
pool = concurrent.futures.ThreadPoolExecutor(max_workers=1,
thread_name_prefix="pose")
def _infer(fr):
return model.predict(fr, device=device, conf=conf_thresh,
verbose=False, max_det=max_persons)
try:
while True:
t0 = time.monotonic()
frame = await loop.run_in_executor(pool, _grab)
if frame is None:
await asyncio.sleep(period)
continue
h, w = frame.shape[:2]
try:
# Non-bloquant : l'event loop continue de servir les autres
# feeds pendant les ~20-80 ms d'inference.
results = await loop.run_in_executor(pool, _infer, frame)
except Exception as e: # noqa: BLE001
LOG.warning("inference failed: %s", e)
await asyncio.sleep(period)
continue
if not results:
ctx.send("count", 0.0)
await asyncio.sleep(period)
continue
res = results[0]
kp_xy = getattr(res.keypoints, "xy", None)
kp_conf = getattr(res.keypoints, "conf", None)
boxes = getattr(res, "boxes", None)
n = 0 if kp_xy is None else int(len(kp_xy))
ctx.send("count", float(n))
for i in range(n):
# bbox normalisee
if boxes is not None and i < len(boxes):
b = boxes.xywhn[i].cpu().numpy().tolist() # cx, cy, w, h
conf_b = float(boxes.conf[i].item())
ctx.send("person", float(i), *b, conf_b)
if not emit_kp or kp_xy is None:
continue
pts = kp_xy[i].cpu().numpy() # (17, 2) px
cfs = kp_conf[i].cpu().numpy() if kp_conf is not None \
else np.ones(len(pts), dtype=float)
flat: list[float] = []
conf_sum = 0.0
for (x, y), c in zip(pts, cfs):
xn = float(x) / max(1.0, w)
yn = float(y) / max(1.0, h)
cc = float(c)
flat.extend([xn, yn, cc])
conf_sum += cc
avg = conf_sum / max(1, len(pts))
ctx.send("skel", float(i), avg, *flat)
# cadence
dt = time.monotonic() - t0
if dt < period:
await asyncio.sleep(period - dt)
finally:
cap.release()
pool.shutdown(wait=False, cancel_futures=True)