Files
AV-Live/data_only_viz/scripts/multihmr_server.py
T
L'électron rare 1828d7ca3e feat(av-live): scenes react + finger joints
Two new capabilities on top of RC0.1 :

1. Pose drives Metal scenes via 12 new SceneUniforms (144 bytes total,
   36 floats): mouth_open, eye_open_l/r, head_tilt, head_yaw,
   finger_pinch_l/r, body_x/y/z, body_height, arm_spread, pose_velocity.
   BodyView.updateNSView derives them from PoseOSCListener.faces /
   hands / body3d with EMA smoothing (alpha=0.7). Five scenes wired :
   storm (velocity+tilt), plasma (mouth+yaw), kaleido (arm_spread
   segments), starfield (pinch -> star density), bars (height+eye).

2. Multi-HMR mlpackage extended with 6th output : 127 SMPL-X joints
   (4, 127, 3) incl. 30 finger joints (indices 25-54). TracedMHMR
   stacks the joints already computed in smpl_layer.py. Output
   names shifted (var_2420 etc.); constants updated in
   multihmr_coreml.py and multihmr_server.py. Propagation to
   client backends + Skeleton3DRenderer finger override remains TODO.
2026-05-14 02:31:10 +02:00

608 lines
22 KiB
Python

"""Multi-HMR inference server (TCP, coremltools backend).
Runs on a remote Mac (macm1 in the AV-Live cluster), loads the
mlpackage via coremltools (Python 3.12), and serves frames over TCP.
Protocol (little-endian, persistent connection):
Request:
[4 bytes uint32 payload_len]
[4 bytes magic "REQ\\x01"]
[1 byte uint8 format_id] # 1 = raw RGB uint8 HWC, 2 = JPEG
[3 bytes padding]
[variable image bytes] # IMG_BYTES if format=1, else JPEG bytes
[9 float32 LE = 36 bytes K] # always last 36 bytes
Response:
[4 bytes uint32 payload_len]
[4 bytes magic "RSP\\x01"]
[4 bytes int32 status] # 0 = OK, 1 = error
[v3d: 4*10475*3 float32]
[transl: 4*1*3 float32]
[scores: 4 float32]
[betas: 4*10 float32]
[expr: 4*10 float32]
Connection handler runs a 3-thread pipeline: reader -> worker -> writer.
While the worker predicts frame N, the reader has already buffered frame
N+1 so the next predict can start the instant the previous response is
handed to the writer. Queue depth is 2 to absorb network jitter.
Bench mode (--bench): synthetic frames against the loaded backend.
"""
from __future__ import annotations
import argparse
import logging
import os
import queue
import signal
import socket
import struct
import sys
import threading
import time
from pathlib import Path
import numpy as np
LOG = logging.getLogger("multihmr_server")
IMG_SIZE = 672
N_PERSONS_FIXED = 4
N_VERTS = 10475
MAGIC_REQ = b"REQ\x01"
MAGIC_RSP = b"RSP\x01"
FORMAT_RAW = 1
FORMAT_JPEG = 2
IMG_BYTES = IMG_SIZE * IMG_SIZE * 3 # 1_354_752
K_BYTES = 9 * 4 # 36
REQ_HEADER = 4 + 1 + 3 # magic + fmt u8 + 3 pad
V3D_BYTES = N_PERSONS_FIXED * N_VERTS * 3 * 4
TRANSL_BYTES = N_PERSONS_FIXED * 1 * 3 * 4
SCORES_BYTES = N_PERSONS_FIXED * 4
BETAS_BYTES = N_PERSONS_FIXED * 10 * 4
EXPR_BYTES = N_PERSONS_FIXED * 10 * 4
RSP_HEADER = 4 + 4
RSP_PAYLOAD_LEN = (RSP_HEADER + V3D_BYTES + TRANSL_BYTES
+ SCORES_BYTES + BETAS_BYTES + EXPR_BYTES)
DEFAULT_MLPACKAGE = Path(
os.environ.get("MULTIHMR_MLPACKAGE")
or str(Path.home() / ".cache" / "av-live-multihmr"
/ "multihmr_full_672_s.mlpackage"))
OUT_V3D = "var_2420"
OUT_TRANSL = "var_2423"
OUT_SCORES = "var_2436"
OUT_BETAS = "var_2439"
OUT_EXPR = "var_2442"
OUT_JOINTS = "var_2445" # (4, 127, 3) SMPL-X joints incl fingers
N_JOINTS = 127
def recv_exact(sock: socket.socket, n: int) -> bytes:
buf = bytearray(n)
view = memoryview(buf)
pos = 0
while pos < n:
got = sock.recv_into(view[pos:])
if got == 0:
raise ConnectionError("peer closed")
pos += got
return bytes(buf)
def encode_response(v3d: np.ndarray, transl: np.ndarray,
scores: np.ndarray, betas: np.ndarray,
expr: np.ndarray, status: int = 0) -> bytes:
parts = [
struct.pack("<I", RSP_PAYLOAD_LEN),
MAGIC_RSP,
struct.pack("<i", status),
np.ascontiguousarray(v3d, dtype=np.float32).tobytes(),
np.ascontiguousarray(transl, dtype=np.float32).tobytes(),
np.ascontiguousarray(scores, dtype=np.float32).tobytes(),
np.ascontiguousarray(betas, dtype=np.float32).tobytes(),
np.ascontiguousarray(expr, dtype=np.float32).tobytes(),
]
return b"".join(parts)
def decode_request(payload: bytes) -> tuple[np.ndarray, np.ndarray, float]:
"""Decode a request payload (without the leading 4-byte length).
Returns (image_uint8_hwc, K_33_f32, decode_ms_overhead).
"""
if len(payload) < REQ_HEADER + K_BYTES:
raise ValueError(f"req payload too short: {len(payload)}")
magic = payload[:4]
if magic != MAGIC_REQ:
raise ValueError(f"bad magic {magic!r}")
fmt = payload[4]
# payload[5:8] reserved.
img_end = len(payload) - K_BYTES
img_bytes = payload[REQ_HEADER:img_end]
K = np.frombuffer(payload, dtype="<f4", count=9,
offset=img_end).reshape(3, 3).astype(np.float32)
t0 = time.monotonic()
if fmt == FORMAT_RAW:
if len(img_bytes) != IMG_BYTES:
raise ValueError(
f"raw img bytes {len(img_bytes)} != {IMG_BYTES}")
img = np.frombuffer(img_bytes, dtype=np.uint8).reshape(
IMG_SIZE, IMG_SIZE, 3)
elif fmt == FORMAT_JPEG:
import cv2
arr = np.frombuffer(img_bytes, dtype=np.uint8)
bgr = cv2.imdecode(arr, cv2.IMREAD_COLOR)
if bgr is None:
raise ValueError("cv2.imdecode failed")
if bgr.shape[:2] != (IMG_SIZE, IMG_SIZE):
bgr = cv2.resize(bgr, (IMG_SIZE, IMG_SIZE))
img = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)
else:
raise ValueError(f"unknown format_id {fmt}")
decode_ms = (time.monotonic() - t0) * 1e3
return img, K, decode_ms
ML_DTYPE_FLOAT16 = 65552
ML_DTYPE_FLOAT32 = 65568
ML_DTYPE_DOUBLE = 65600
def _np_to_mlarray(arr: np.ndarray, MLMultiArray):
"""Create a contiguous float32 MLMultiArray from a numpy array."""
import ctypes
arr = np.ascontiguousarray(arr, dtype=np.float32)
shape = [int(s) for s in arr.shape]
ml = MLMultiArray.alloc().initWithShape_dataType_error_(
shape, ML_DTYPE_FLOAT32, None)
if ml is None:
raise RuntimeError("MLMultiArray alloc failed")
ptr = ml.dataPointer()
addr = int(ptr) if isinstance(ptr, int) else ctypes.cast(
ptr, ctypes.c_void_p).value
if addr is None:
raise RuntimeError("MLMultiArray dataPointer null")
ctypes.memmove(addr, arr.ctypes.data, arr.nbytes)
return ml
def _mlarray_to_np(ml) -> np.ndarray:
"""Copy an MLMultiArray (FLOAT16/32/64) to numpy float32."""
import ctypes
shape = tuple(int(s) for s in ml.shape())
dtype_id = int(ml.dataType())
count = 1
for s in shape:
count *= s
ptr = ml.dataPointer()
addr = int(ptr) if isinstance(ptr, int) else ctypes.cast(
ptr, ctypes.c_void_p).value
if addr is None:
raise RuntimeError("MLMultiArray dataPointer null")
if dtype_id == ML_DTYPE_FLOAT16:
raw = (ctypes.c_uint16 * count).from_address(addr)
arr = np.ctypeslib.as_array(raw).view(np.float16).astype(np.float32)
elif dtype_id == ML_DTYPE_FLOAT32:
raw = (ctypes.c_float * count).from_address(addr)
arr = np.ctypeslib.as_array(raw).copy()
elif dtype_id == ML_DTYPE_DOUBLE:
raw = (ctypes.c_double * count).from_address(addr)
arr = np.ctypeslib.as_array(raw).astype(np.float32)
else:
raise RuntimeError(f"unsupported MLMultiArray dtype {dtype_id}")
return arr.reshape(shape)
class CoreMLModel:
"""pyobjc-direct CoreML wrapper. Drops the ~30 ms coremltools.MLModel.predict
overhead by using CoreML.framework directly (MLDictionaryFeatureProvider
+ MLMultiArray ctypes memcpy). Fallback to coremltools if pyobjc missing,
via MULTIHMR_SERVER_BACKEND=coremltools env."""
def __init__(self, mlpackage_path: Path) -> None:
self.path = Path(mlpackage_path)
if not self.path.exists():
raise FileNotFoundError(f"mlpackage missing: {self.path}")
backend = os.environ.get(
"MULTIHMR_SERVER_BACKEND", "pyobjc").strip().lower()
cu_env = os.environ.get(
"COREML_COMPUTE_UNITS", "cpu_and_gpu").strip().lower()
if backend == "pyobjc":
self._use_pyobjc = True
self._init_pyobjc(cu_env)
else:
self._use_pyobjc = False
self._init_coremltools(cu_env)
def _init_pyobjc(self, cu_env: str) -> None:
import objc
from Foundation import NSURL
ns: dict = {}
objc.loadBundle("CoreML", ns,
"/System/Library/Frameworks/CoreML.framework")
cu_map = {"cpu_only": 0, "cpu_and_gpu": 1, "all": 2,
"cpu_and_ne": 3}
cu = cu_map.get(cu_env, 1)
MLModel = ns["MLModel"]
MLModelConfiguration = ns["MLModelConfiguration"]
cfg = MLModelConfiguration.alloc().init()
try:
cfg.setComputeUnits_(cu)
except Exception: # noqa: BLE001
pass
url = NSURL.fileURLWithPath_(str(self.path))
compiled_url = MLModel.compileModelAtURL_error_(url, None)
if compiled_url is None:
raise RuntimeError(f"compileModelAtURL failed for {self.path}")
model = MLModel.modelWithContentsOfURL_configuration_error_(
compiled_url, cfg, None)
if model is None:
raise RuntimeError(f"MLModel load failed for {compiled_url}")
self._model = model
self._ns = ns
LOG.info("loading mlpackage %s via pyobjc (computeUnit=%s)",
self.path.name, cu_env)
def _init_coremltools(self, cu_env: str) -> None:
import coremltools as ct
from coremltools.models import MLModel as CTMLModel
cu_map = {
"cpu_only": ct.ComputeUnit.CPU_ONLY,
"cpu_and_gpu": ct.ComputeUnit.CPU_AND_GPU,
"all": ct.ComputeUnit.ALL,
"cpu_and_ne": ct.ComputeUnit.CPU_AND_NE,
}
cu = cu_map.get(cu_env, ct.ComputeUnit.CPU_AND_GPU)
LOG.info("loading mlpackage %s via coremltools (computeUnit=%s)",
self.path.name, cu_env)
self.model = CTMLModel(str(self.path), compute_units=cu)
def predict(self, image_uint8_hwc: np.ndarray, K_33: np.ndarray
) -> dict[str, np.ndarray]:
img_chw = image_uint8_hwc.transpose(2, 0, 1).astype(np.float32) / 255.0
img4 = img_chw[np.newaxis, ...]
K = K_33.astype(np.float32)
if K.ndim == 2:
K = K[np.newaxis, ...]
if self._use_pyobjc:
return self._predict_pyobjc(img4, K)
return self.model.predict({"image": img4, "cam_K": K})
def _predict_pyobjc(self, image_4d: np.ndarray, K_33: np.ndarray
) -> dict[str, np.ndarray]:
ns = self._ns
MLMultiArray = ns["MLMultiArray"]
MLDictionaryFeatureProvider = ns["MLDictionaryFeatureProvider"]
MLFeatureValue = ns["MLFeatureValue"]
img_ml = _np_to_mlarray(image_4d, MLMultiArray)
k_ml = _np_to_mlarray(K_33, MLMultiArray)
feats = {
"image": MLFeatureValue.featureValueWithMultiArray_(img_ml),
"cam_K": MLFeatureValue.featureValueWithMultiArray_(k_ml),
}
provider = MLDictionaryFeatureProvider.alloc(
).initWithDictionary_error_(feats, None)
if provider is None:
raise RuntimeError("MLDictionaryFeatureProvider alloc failed")
out = self._model.predictionFromFeatures_error_(provider, None)
if out is None:
raise RuntimeError("MLModel predict failed")
names = [str(n) for n in out.featureNames()]
result: dict[str, np.ndarray] = {}
for n in names:
fv = out.featureValueForName_(n)
if fv is None:
continue
ml = fv.multiArrayValue()
if ml is None:
continue
result[n] = _mlarray_to_np(ml)
return result
def _zero_outputs() -> tuple[np.ndarray, ...]:
return (
np.zeros((N_PERSONS_FIXED, N_VERTS, 3), dtype=np.float32),
np.zeros((N_PERSONS_FIXED, 1, 3), dtype=np.float32),
np.zeros((N_PERSONS_FIXED,), dtype=np.float32),
np.zeros((N_PERSONS_FIXED, 10), dtype=np.float32),
np.zeros((N_PERSONS_FIXED, 10), dtype=np.float32),
)
def _extract_outputs(raw: dict[str, np.ndarray]
) -> tuple[np.ndarray, ...]:
v3d = np.asarray(raw[OUT_V3D], dtype=np.float32).reshape(
N_PERSONS_FIXED, N_VERTS, 3)
transl = np.asarray(raw[OUT_TRANSL], dtype=np.float32).reshape(
N_PERSONS_FIXED, 1, 3)
scores = np.asarray(raw[OUT_SCORES], dtype=np.float32).reshape(
N_PERSONS_FIXED)
betas = np.asarray(raw[OUT_BETAS], dtype=np.float32).reshape(
N_PERSONS_FIXED, 10)
expr = np.asarray(raw[OUT_EXPR], dtype=np.float32).reshape(
N_PERSONS_FIXED, 10)
return v3d, transl, scores, betas, expr
class Server:
def __init__(self, model: CoreMLModel, host: str, port: int) -> None:
self.model = model
self.host = host
self.port = port
self._stop = threading.Event()
self._sock: socket.socket | None = None
def stop(self) -> None:
self._stop.set()
if self._sock is not None:
try:
self._sock.close()
except OSError:
pass
def serve(self) -> None:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind((self.host, self.port))
sock.listen(4)
sock.settimeout(1.0)
self._sock = sock
LOG.info("listening %s:%d", self.host, self.port)
while not self._stop.is_set():
try:
conn, addr = sock.accept()
except socket.timeout:
continue
except OSError:
break
LOG.info("client connected %s", addr)
try:
self._handle_pipelined(conn)
except (ConnectionError, BrokenPipeError, OSError) as e:
LOG.info("client disconnected: %s", e)
finally:
try:
conn.close()
except OSError:
pass
LOG.info("server stopped")
# -- pipelined per-connection handler -----------------------------
def _handle_pipelined(self, conn: socket.socket) -> None:
conn.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
conn_stop = threading.Event()
# raw requests in, encoded responses out.
req_q: queue.Queue[bytes] = queue.Queue(maxsize=2)
rsp_q: queue.Queue[bytes] = queue.Queue(maxsize=2)
# stats
served = {"n": 0, "t0": time.monotonic(),
"sum_decode": 0.0, "sum_pred": 0.0,
"sum_encode": 0.0}
def reader() -> None:
try:
while not conn_stop.is_set() and not self._stop.is_set():
len_buf = recv_exact(conn, 4)
payload_len = struct.unpack("<I", len_buf)[0]
if payload_len > 8 * 1024 * 1024:
raise ValueError(f"reqlen too big {payload_len}")
payload = recv_exact(conn, payload_len)
req_q.put(payload)
except (ConnectionError, BrokenPipeError, OSError) as e:
LOG.info("reader exit: %s", e)
finally:
conn_stop.set()
# poison-pill the worker
try:
req_q.put_nowait(b"")
except queue.Full:
pass
def worker() -> None:
try:
while not conn_stop.is_set() and not self._stop.is_set():
try:
payload = req_q.get(timeout=0.5)
except queue.Empty:
continue
if payload == b"":
break
try:
img, K, decode_ms = decode_request(payload)
except Exception as e: # noqa: BLE001
LOG.warning("decode failed: %s", e)
v3d, transl, scores, betas, expr = _zero_outputs()
rsp_q.put(encode_response(
v3d, transl, scores, betas, expr, status=1))
continue
t_pred = time.monotonic()
try:
raw = self.model.predict(img, K)
v3d, transl, scores, betas, expr = _extract_outputs(
raw)
status = 0
except Exception as e: # noqa: BLE001
LOG.warning("predict failed: %s", e)
v3d, transl, scores, betas, expr = _zero_outputs()
status = 1
t_pred_end = time.monotonic()
t_enc = time.monotonic()
rsp = encode_response(
v3d, transl, scores, betas, expr, status=status)
t_enc_end = time.monotonic()
pred_ms = (t_pred_end - t_pred) * 1e3
encode_ms = (t_enc_end - t_enc) * 1e3
served["n"] += 1
served["sum_decode"] += decode_ms
served["sum_pred"] += pred_ms
served["sum_encode"] += encode_ms
rsp_q.put(rsp)
now = time.monotonic()
if served["n"] % 30 == 0:
dt = now - served["t0"]
fps = served["n"] / max(1e-6, dt)
LOG.info(
"served %d frames at %.1f fps over %.1f s "
"(decode=%.1fms pred=%.1fms encode=%.1fms)",
served["n"], fps, dt,
served["sum_decode"] / served["n"],
served["sum_pred"] / served["n"],
served["sum_encode"] / served["n"])
finally:
conn_stop.set()
try:
rsp_q.put_nowait(b"")
except queue.Full:
pass
def writer() -> None:
try:
while not conn_stop.is_set() and not self._stop.is_set():
try:
rsp = rsp_q.get(timeout=0.5)
except queue.Empty:
continue
if rsp == b"":
break
conn.sendall(rsp)
except (ConnectionError, BrokenPipeError, OSError) as e:
LOG.info("writer exit: %s", e)
finally:
conn_stop.set()
t_r = threading.Thread(target=reader, name="srv-reader", daemon=True)
t_w = threading.Thread(target=worker, name="srv-worker", daemon=True)
t_x = threading.Thread(target=writer, name="srv-writer", daemon=True)
t_r.start()
t_w.start()
t_x.start()
t_r.join()
t_w.join()
t_x.join()
dt = time.monotonic() - served["t0"]
if served["n"] > 0:
LOG.info("connection closed: served %d frames at %.1f fps "
"over %.1f s", served["n"],
served["n"] / max(1e-6, dt), dt)
def run_bench(model: CoreMLModel, n: int = 30) -> None:
"""Local synthetic bench (no socket)."""
rng = np.random.default_rng(0)
K = np.array([[672.0, 0.0, 336.0],
[0.0, 672.0, 336.0],
[0.0, 0.0, 1.0]], dtype=np.float32)
img0 = rng.integers(0, 256, (IMG_SIZE, IMG_SIZE, 3), dtype=np.uint8)
model.predict(img0, K)
times = []
for _ in range(n):
img = rng.integers(0, 256, (IMG_SIZE, IMG_SIZE, 3), dtype=np.uint8)
t0 = time.monotonic()
model.predict(img, K)
times.append((time.monotonic() - t0) * 1e3)
ts = sorted(times)
median = ts[len(ts) // 2]
mean = sum(times) / len(times)
p90 = ts[int(len(ts) * 0.9)]
LOG.info("bench n=%d median=%.1fms mean=%.1fms p90=%.1fms (%.1f fps)",
n, median, mean, p90, 1000.0 / median)
def run_bench_async(model: CoreMLModel, host: str, port: int,
n: int = 60) -> None:
"""End-to-end pipeline bench via real socket loopback."""
import threading
server = Server(model, host, port)
th = threading.Thread(target=server.serve, daemon=True)
th.start()
time.sleep(0.5)
try:
from data_only_viz.multihmr_remote import MultiHMRRemoteBackend
except ImportError:
# When the server runs standalone, the client package may not be
# importable. Skip with a friendly message.
LOG.warning("data_only_viz package not importable on this host, "
"skipping --bench-async")
server.stop()
return
os.environ.setdefault("MULTIHMR_REMOTE_HOST", host)
os.environ.setdefault("MULTIHMR_REMOTE_PORT", str(port))
be = MultiHMRRemoteBackend(host=host, port=port)
rng = np.random.default_rng(0)
K = np.array([[672.0, 0.0, 336.0],
[0.0, 672.0, 336.0],
[0.0, 0.0, 1.0]], dtype=np.float32)
t0 = time.monotonic()
got = 0
for _ in range(n):
img = (rng.random((3, IMG_SIZE, IMG_SIZE), dtype=np.float32))
out = be.infer(img, K)
if out is not None:
got += 1
time.sleep(0.01)
dt = time.monotonic() - t0
LOG.info("bench-async submitted=%d got=%d in %.2fs (%.1f fps submit)",
n, got, dt, n / max(1e-6, dt))
be.close()
server.stop()
def main(argv: list[str] | None = None) -> int:
ap = argparse.ArgumentParser(description="Multi-HMR TCP server")
ap.add_argument("--mlpackage", type=Path, default=DEFAULT_MLPACKAGE)
ap.add_argument("--host", default=os.environ.get(
"MULTIHMR_SERVER_HOST", "0.0.0.0"))
ap.add_argument("--port", type=int, default=int(os.environ.get(
"MULTIHMR_SERVER_PORT", "57140")))
ap.add_argument("--bench", action="store_true",
help="local synthetic bench, no socket")
ap.add_argument("--bench-async", action="store_true",
help="loopback pipeline bench through real sockets")
ap.add_argument("--bench-n", type=int, default=30)
ap.add_argument("--log-level", default="INFO")
args = ap.parse_args(argv)
logging.basicConfig(
level=args.log_level.upper(),
format="%(asctime)s %(levelname)s %(name)s %(message)s")
model = CoreMLModel(args.mlpackage)
if args.bench:
run_bench(model, n=args.bench_n)
return 0
if args.bench_async:
run_bench_async(model, "127.0.0.1", args.port, n=args.bench_n)
return 0
server = Server(model, args.host, args.port)
def _sigint(*_a):
LOG.info("SIGINT received, stopping")
server.stop()
signal.signal(signal.SIGINT, _sigint)
signal.signal(signal.SIGTERM, _sigint)
try:
server.serve()
except KeyboardInterrupt:
server.stop()
return 0
if __name__ == "__main__":
sys.exit(main())