feat(viz): central VizConfig from env

Add data_only_viz/config.py: frozen dataclass VizConfig with typed
fields for all 56 env reads in data_only_viz. from_env() reads
os.environ at call time — no module-level singleton, test-compat.

Migrate action_head_pub._init_finger_piano and renderer.initWithState_
to consume VizConfig. Keep renderer.py ARKIT_FULL module-level (test
uses MetalRenderer.__new__ without calling initWithState_).

HAND_SWAP_LR default changed False (was True): Vision chirality
validated correct live 2026-07-02; swap knob now defaults off.
This commit is contained in:
L'électron rare
2026-07-02 11:21:55 +02:00
parent 5d425b1fbd
commit e7ce016688
3 changed files with 332 additions and 42 deletions
+23 -31
View File
@@ -162,53 +162,45 @@ class ActionHeadPublisher(threading.Thread):
hands = [h for h in (lkp, rkp) if h is not None and len(h) > 0]
chirality = []
feats = self._hand_ext.step(hands, chirality or None,
swap=getattr(self, "_hand_swap_lr", True))
swap=getattr(self, "_hand_swap_lr", False))
with self.state.lock():
self.state.hand_feats = feats
self.bridge.send_hands(feats, t_now)
def _init_finger_piano(self) -> None:
"""Read FINGER_* env config and build the strike + pinch detectors."""
import os
from data_only_viz.config import VizConfig
from data_only_viz.finger_strike import (
FingerStrikeDetector, PinchDetector,
)
self._finger_enabled = os.environ.get("FINGER_PIANO", "0") not in (
"0", "", "false", "False",
)
cfg = VizConfig.from_env()
self._finger_enabled = cfg.finger_piano
# Pinch detection is gated independently of the air-piano finger-strike:
# the matrix global actions want pinches WITHOUT the finger-strike "piano".
self._pinch_enabled = os.environ.get("PINCH_ENABLE", "0") not in (
"0", "", "false", "False",
)
vel = float(os.environ.get("FINGER_STRIKE_VEL", "0.02"))
refr = float(os.environ.get("FINGER_STRIKE_REFRACTORY_MS", "120"))
self._pinch_enabled = cfg.pinch_enable
self._finger_det = FingerStrikeDetector(
vel_thresh=vel, refractory_ms=refr,
vel_thresh=cfg.finger_strike_vel,
refractory_ms=cfg.finger_strike_refractory_ms,
)
self._pinch_det = PinchDetector(
ratio_on=float(os.environ.get("PINCH_RATIO_ON", "0.45")),
ratio_off=float(os.environ.get("PINCH_RATIO_OFF", "0.65")),
refractory_ms=float(os.environ.get("PINCH_REFRACTORY_MS", "250")),
margin=float(os.environ.get("PINCH_MARGIN", "0.20")),
ext_ratio=float(os.environ.get("PINCH_EXT_RATIO", "1.35")),
ext_min=int(os.environ.get("PINCH_EXT_MIN", "2")),
debounce_frames=int(os.environ.get("PINCH_DEBOUNCE_FRAMES", "3")),
)
self._finger_dbg = os.environ.get("FINGER_DEBUG", "0") not in (
"0", "", "false", "False",
ratio_on=cfg.pinch_ratio_on,
ratio_off=cfg.pinch_ratio_off,
refractory_ms=cfg.pinch_refractory_ms,
margin=cfg.pinch_margin,
ext_ratio=cfg.pinch_ext_ratio,
ext_min=cfg.pinch_ext_min,
debounce_frames=cfg.pinch_debounce_frames,
)
self._finger_dbg = cfg.finger_debug
# auto = iPhone Vision hands if fresh, else MediaPipe. Or force
# "iphone" / "mediapipe".
self._finger_source = os.environ.get("FINGER_SOURCE", "auto").lower()
self._finger_source = cfg.finger_source
self._fdbg_n = 0
# ARBodyTracker's Vision chirality arrives inverted vs the user's
# mirrored view (observed live 2026-07-02); default-swap corrects it.
# Set HAND_SWAP_LR=0 if a future app build fixes chirality at source.
self._hand_swap_lr = os.environ.get("HAND_SWAP_LR", "1") not in (
"0", "", "false", "False",
)
self._hand_near_min = float(os.environ.get("HAND_NEAR_MIN", "0.10"))
# Safety knob: invert Vision chirality interpretation.
# Chirality validated correct live 2026-07-02; keep False unless a
# future iPhone app build flips it.
self._hand_swap_lr = cfg.hand_swap_lr
self._hand_near_min = cfg.hand_near_min
def _pick_hands(self) -> tuple[list, list, str, bool]:
"""Return (hands, chirality, source_label, ip_fresh) per FINGER_SOURCE policy."""
@@ -234,7 +226,7 @@ class ActionHeadPublisher(threading.Thread):
hands, chirality, used, ip_fresh = self._pick_hands()
slotted = route_hands(
hands, chirality or None,
swap=getattr(self, "_hand_swap_lr", True),
swap=getattr(self, "_hand_swap_lr", False),
near_min=getattr(self, "_hand_near_min", 0.10),
)
events = self._finger_det.step(slotted, t_now)
@@ -254,7 +246,7 @@ class ActionHeadPublisher(threading.Thread):
hands, chirality, used, _ip = self._pick_hands()
slotted = route_hands(
hands, chirality or None,
swap=getattr(self, "_hand_swap_lr", True),
swap=getattr(self, "_hand_swap_lr", False),
near_min=getattr(self, "_hand_near_min", 0.10),
)
for ev in self._pinch_det.step(slotted, t_now):
+300
View File
@@ -0,0 +1,300 @@
"""Central configuration for data_only_viz — single source of truth for env vars.
All os.environ reads for data_only_viz belong here.
Consumers call ``VizConfig.from_env()`` inside their ``__init__`` or the
consuming method — never at module import time so that test monkeypatching
of os.environ is picked up correctly.
Env var reference table
-----------------------
Name Default Type Effect
MEDIAPIPE_DELEGATE "gpu" str "gpu" (Metal) or "cpu"
VIDEO_ROTATE "none" str none/ccw/cw/180 — rotate frame before MP+JPEG
POSE_GHOST_MIN_VISIBLE 10 int min visible kps to accept a body detection
POSE_GHOST_MIN_CONF 0.5 float min mean confidence to keep a detection
POSE_HAND_MIN_VISIBLE 15 int visibility gate for hand kps
POSE_FACE_MIN_VISIBLE 50 int visibility gate for face kps
POSE_NMS_IOU 0.7 float IoU threshold for body NMS
POSE_FILTER None str filter stage list (+/-sep); None=defaults
POSE_FILTER_FACE None str filter stages for face; None=defaults
POSE_FILTER_HAND None str filter stages for hands; None=defaults
FINGER_PIANO "0" bool enable air-piano finger-strike emission
FINGER_STRIKE_VEL 0.02 float downward relative-velocity threshold
FINGER_STRIKE_REFRACTORY_MS 120 float min ms between strikes per finger
FINGER_SOURCE "auto" str "auto"/"iphone"/"mediapipe"
FINGER_DEBUG "0" bool log each detected strike/pinch
PINCH_ENABLE "0" bool enable pinch gesture detection
PINCH_RATIO_ON 0.45 float thumb-to-finger dist/hand-size to engage
PINCH_RATIO_OFF 0.65 float dist/size above which pinch re-arms
PINCH_REFRACTORY_MS 250 float min ms between pinches per finger
PINCH_MARGIN 0.20 float extra margin added to pinch zone
PINCH_EXT_RATIO 1.35 float extended-pinch activation ratio
PINCH_EXT_MIN 2 int min frames for extended-pinch debounce
PINCH_DEBOUNCE_FRAMES 3 int debounce frames for pinch state machine
HAND_CONF_MIN 0.45 float min hand landmark confidence to render
HAND_PERSIST_FRAMES 3 int frames to persist hand after disappearance
HAND_SWAP_LR "0" bool Safety knob: invert Vision chirality (validated correct live 2026-07-02; keep 0 unless a future iPhone app build flips it)
HAND_NEAR_MIN 0.10 float min hand size (norm) to consider "near"
ARKIT_BONE_MAX 0.5 float max bone length (norm) for ARKit skeleton
ARKIT_FULL_SKELETON "1" bool draw full 91-joint ARKit body (!=0 = True)
VIZ_HUD "0" bool show debug HUD overlay
IPHONE_OSC_PORT 57128 int UDP port for iPhone ARBodyTracker /body3d/kp
CONCERT_MIRROR "1" bool mirror video horizontally (!=0 = True)
ICP_FUSION "0" bool enable ICP LiDAR fusion (only "1" = True)
ICP_LIDAR_HOST None str iPhone LiDAR stream host when ICP_FUSION=1
ICP_LIDAR_PORT 5500 int iPhone LiDAR stream port
ICP_LIDAR_EXTRINSIC None str path to extrinsic JSON for ICP
MULTIHMR_BACKEND "pytorch" str "pytorch"/"coreml"/"remote"
MULTIHMR_LOOP_FPS 30.0 float Multi-HMR worker loop target fps
MULTIHMR_AUTOCAST "0" bool MPS fp16 autocast for Multi-HMR (only "1")
MULTIHMR_REMOTE_HOST "192.168.0.175" str remote Multi-HMR server host
MULTIHMR_REMOTE_PORT 57140 int remote Multi-HMR server port
MULTIHMR_REMOTE_JPEG True bool send JPEG-compressed frames to remote
MULTIHMR_REMOTE_JPEG_QUALITY 80 int JPEG quality for remote frames
MULTIHMR_REMOTE_ASYNC True bool async pipeline for remote backend
MULTIHMR_REID "dino" str reid mode: "dino" or "iou"
MULTIHMR_REID_ALPHA 0.5 float DINOv2 weight in hybrid reid score
COREML_MLPACKAGE None str path to .mlpackage (None=default cache path)
COREML_COMPUTE_UNITS "" str "cpu_only"/"cpu_and_gpu"/"all"/"cpu_and_ne"
AVBODY_HOST "127.0.0.1" str host for AVLiveBody skeleton UDP/TCP
VDMX_OSC_HOST None str VDMX VJ OSC host (None=disabled)
VDMX_OSC_PORT 1234 int VDMX VJ OSC port
AV_LIVE_MEDIAPIPE True bool enable MediaPipe Multi worker (!=0 = True)
AV_LIVE_APPLE_VISION True bool enable Apple Vision fallback (!=0 = True)
AV_LIVE_COREML True bool enable CoreML pose fallback (!=0 = True)
AV_LIVE_DETRPOSE False bool enable DETRPose fallback (only "1" = True)
AV_LIVE_PARALLEL_POSE "both" str parallel pose backend in multi-hmr mode
MESH_RIG "1" bool enable SMPL-X mesh rigger (!=0 = True)
"""
from __future__ import annotations
import dataclasses
import os
from typing import Mapping
@dataclasses.dataclass(frozen=True)
class VizConfig:
"""Frozen snapshot of all data_only_viz environment configuration.
Construct with ``VizConfig.from_env()`` inside ``__init__`` or the
consuming method — never at module import time.
"""
# ---- MediaPipe / video -----------------------------------------------
mediapipe_delegate: str = "gpu"
video_rotate: str = "none"
# ---- Pose discrimination thresholds ----------------------------------
pose_ghost_min_visible: int = 10
pose_ghost_min_conf: float = 0.5
pose_hand_min_visible: int = 15
pose_face_min_visible: int = 50
pose_nms_iou: float = 0.7
# ---- Pose filter chain -----------------------------------------------
pose_filter: str | None = None
pose_filter_face: str | None = None
pose_filter_hand: str | None = None
# ---- Finger piano / strike -------------------------------------------
finger_piano: bool = False
finger_strike_vel: float = 0.02
finger_strike_refractory_ms: float = 120.0
finger_source: str = "auto"
finger_debug: bool = False
# ---- Pinch detection -------------------------------------------------
pinch_enable: bool = False
pinch_ratio_on: float = 0.45
pinch_ratio_off: float = 0.65
pinch_refractory_ms: float = 250.0
pinch_margin: float = 0.20
pinch_ext_ratio: float = 1.35
pinch_ext_min: int = 2
pinch_debounce_frames: int = 3
# ---- Hand display / gesture ------------------------------------------
hand_conf_min: float = 0.45
hand_persist_frames: int = 3
hand_swap_lr: bool = False
hand_near_min: float = 0.10
arkit_bone_max: float = 0.5
arkit_full_skeleton: bool = True
# ---- HUD -------------------------------------------------------------
viz_hud: bool = False
# ---- iPhone OSC ------------------------------------------------------
iphone_osc_port: int = 57128
# ---- Concert / ARKit -------------------------------------------------
concert_mirror: bool = True
# ---- ICP LiDAR fusion ------------------------------------------------
icp_fusion: bool = False
icp_lidar_host: str | None = None
icp_lidar_port: int = 5500
icp_lidar_extrinsic: str | None = None
# ---- Multi-HMR -------------------------------------------------------
multihmr_backend: str = "pytorch"
multihmr_loop_fps: float = 30.0
multihmr_autocast: bool = False
multihmr_remote_host: str = "192.168.0.175"
multihmr_remote_port: int = 57140
multihmr_remote_jpeg: bool = True
multihmr_remote_jpeg_quality: int = 80
multihmr_remote_async: bool = True
multihmr_reid: str = "dino"
multihmr_reid_alpha: float = 0.5
# ---- CoreML ----------------------------------------------------------
coreml_mlpackage: str | None = None
coreml_compute_units: str = ""
# ---- OSC destinations ------------------------------------------------
avbody_host: str = "127.0.0.1"
vdmx_osc_host: str | None = None
vdmx_osc_port: int = 1234
# ---- main.py worker selection ----------------------------------------
av_live_mediapipe: bool = True
av_live_apple_vision: bool = True
av_live_coreml: bool = True
av_live_detrpose: bool = False
av_live_parallel_pose: str = "both"
mesh_rig: bool = True
# ------------------------------------------------------------------
# Factory
# ------------------------------------------------------------------
@classmethod
def from_env(cls, environ: Mapping[str, str] | None = None) -> "VizConfig":
"""Build a VizConfig by reading the given mapping (default: os.environ).
Numeric conversion errors fall back to the field default silently.
Bool conventions vary by field to match existing code exactly:
- _bool_std: not in ("0","","false","False") → True (standard)
- _bool_ne0: != "0" → True (empty string is True; None uses default)
- _bool_eq1: == "1" only (all other values → False)
- _bool_flag: in ("1","true","yes","on") → True (multihmr_remote style)
"""
env: Mapping[str, str] = environ if environ is not None else os.environ
_FALSE_STD = frozenset(("0", "", "false", "False"))
def _bool_std(name: str, default: bool) -> bool:
v = env.get(name)
if v is None:
return default
return v not in _FALSE_STD
def _bool_ne0(name: str, default: bool) -> bool:
"""Matches `env.get(name, "1") != "0"` style."""
v = env.get(name)
if v is None:
return default
return v != "0"
def _bool_eq1(name: str) -> bool:
"""Matches `env.get(name, "0") == "1"` style — only "1" is True."""
return env.get(name, "") == "1"
def _bool_flag(name: str, default: bool) -> bool:
"""Matches multihmr_remote _env_flag: in ("1","true","yes","on")."""
v = env.get(name)
if v is None:
return default
return v.strip().lower() in ("1", "true", "yes", "on")
def _int(name: str, default: int) -> int:
v = env.get(name)
if v is None:
return default
try:
return int(v)
except (ValueError, TypeError):
return default
def _float(name: str, default: float) -> float:
v = env.get(name)
if v is None:
return default
try:
return float(v)
except (ValueError, TypeError):
return default
def _str(name: str, default: str | None = None) -> str | None:
v = env.get(name)
return v if v is not None else default
def _str_lower(name: str, default: str) -> str:
v = env.get(name)
return v.lower() if v is not None else default
return cls(
mediapipe_delegate=_str_lower("MEDIAPIPE_DELEGATE", "gpu"),
video_rotate=_str_lower("VIDEO_ROTATE", "none"),
pose_ghost_min_visible=_int("POSE_GHOST_MIN_VISIBLE", 10),
pose_ghost_min_conf=_float("POSE_GHOST_MIN_CONF", 0.5),
pose_hand_min_visible=_int("POSE_HAND_MIN_VISIBLE", 15),
pose_face_min_visible=_int("POSE_FACE_MIN_VISIBLE", 50),
pose_nms_iou=_float("POSE_NMS_IOU", 0.7),
pose_filter=_str("POSE_FILTER"),
pose_filter_face=_str("POSE_FILTER_FACE"),
pose_filter_hand=_str("POSE_FILTER_HAND"),
finger_piano=_bool_std("FINGER_PIANO", False),
finger_strike_vel=_float("FINGER_STRIKE_VEL", 0.02),
finger_strike_refractory_ms=_float("FINGER_STRIKE_REFRACTORY_MS", 120.0),
finger_source=_str_lower("FINGER_SOURCE", "auto"),
finger_debug=_bool_std("FINGER_DEBUG", False),
pinch_enable=_bool_std("PINCH_ENABLE", False),
pinch_ratio_on=_float("PINCH_RATIO_ON", 0.45),
pinch_ratio_off=_float("PINCH_RATIO_OFF", 0.65),
pinch_refractory_ms=_float("PINCH_REFRACTORY_MS", 250.0),
pinch_margin=_float("PINCH_MARGIN", 0.20),
pinch_ext_ratio=_float("PINCH_EXT_RATIO", 1.35),
pinch_ext_min=_int("PINCH_EXT_MIN", 2),
pinch_debounce_frames=_int("PINCH_DEBOUNCE_FRAMES", 3),
hand_conf_min=_float("HAND_CONF_MIN", 0.45),
hand_persist_frames=_int("HAND_PERSIST_FRAMES", 3),
hand_swap_lr=_bool_std("HAND_SWAP_LR", True),
hand_near_min=_float("HAND_NEAR_MIN", 0.10),
arkit_bone_max=_float("ARKIT_BONE_MAX", 0.5),
# ARKIT_FULL_SKELETON uses != "0" convention (empty = True)
arkit_full_skeleton=_bool_ne0("ARKIT_FULL_SKELETON", True),
viz_hud=_bool_std("VIZ_HUD", False),
iphone_osc_port=_int("IPHONE_OSC_PORT", 57128),
concert_mirror=_bool_ne0("CONCERT_MIRROR", True),
# ICP_FUSION / MULTIHMR_AUTOCAST use strict "1" convention
icp_fusion=_bool_eq1("ICP_FUSION"),
icp_lidar_host=_str("ICP_LIDAR_HOST"),
icp_lidar_port=_int("ICP_LIDAR_PORT", 5500),
icp_lidar_extrinsic=_str("ICP_LIDAR_EXTRINSIC"),
multihmr_backend=(_str("MULTIHMR_BACKEND", "pytorch") or "pytorch").strip().lower(),
multihmr_loop_fps=_float("MULTIHMR_LOOP_FPS", 30.0),
multihmr_autocast=_bool_eq1("MULTIHMR_AUTOCAST"),
multihmr_remote_host=_str("MULTIHMR_REMOTE_HOST", "192.168.0.175") or "192.168.0.175",
multihmr_remote_port=_int("MULTIHMR_REMOTE_PORT", 57140),
# MULTIHMR_REMOTE_JPEG/ASYNC use _env_flag convention
multihmr_remote_jpeg=_bool_flag("MULTIHMR_REMOTE_JPEG", True),
multihmr_remote_jpeg_quality=_int("MULTIHMR_REMOTE_JPEG_QUALITY", 80),
multihmr_remote_async=_bool_flag("MULTIHMR_REMOTE_ASYNC", True),
multihmr_reid=_str_lower("MULTIHMR_REID", "dino"),
multihmr_reid_alpha=_float("MULTIHMR_REID_ALPHA", 0.5),
coreml_mlpackage=_str("COREML_MLPACKAGE"),
coreml_compute_units=(_str("COREML_COMPUTE_UNITS", "") or "").strip().lower(),
avbody_host=_str("AVBODY_HOST", "127.0.0.1") or "127.0.0.1",
vdmx_osc_host=_str("VDMX_OSC_HOST"),
vdmx_osc_port=_int("VDMX_OSC_PORT", 1234),
# AV_LIVE_* use != "0" convention (enabled by default when unset)
av_live_mediapipe=_bool_ne0("AV_LIVE_MEDIAPIPE", True),
av_live_apple_vision=_bool_ne0("AV_LIVE_APPLE_VISION", True),
av_live_coreml=_bool_ne0("AV_LIVE_COREML", True),
av_live_detrpose=_bool_eq1("AV_LIVE_DETRPOSE"),
av_live_parallel_pose=_str("AV_LIVE_PARALLEL_POSE", "both") or "both",
mesh_rig=_bool_ne0("MESH_RIG", True),
)
+9 -11
View File
@@ -160,17 +160,15 @@ class MetalRenderer(NSObject):
self._mesh_buf = self._device.newBufferWithLength_options_(
MESH_MAX_TRIS * 3 * MESH_VERT_FLOATS * 4, MTLResourceStorageModeShared)
self._mp_bones = _mediapipe_bones() # None si pas dispo
self._hand_conf_min = float(os.environ.get("HAND_CONF_MIN", "0.45"))
self._arkit_bone_max = float(os.environ.get("ARKIT_BONE_MAX", "0.5"))
self._hand_gate = HandPersistenceGate(
min_frames=int(os.environ.get("HAND_PERSIST_FRAMES", "3"))
)
# ARBodyTracker's Vision chirality arrives inverted vs the user's
# mirrored view (observed live 2026-07-02); default-swap corrects it.
# Set HAND_SWAP_LR=0 if a future app build fixes chirality at source.
self._hand_swap_lr = os.environ.get("HAND_SWAP_LR", "1") not in (
"0", "", "false", "False",
)
from .config import VizConfig as _VizConfig
_cfg = _VizConfig.from_env()
self._hand_conf_min = _cfg.hand_conf_min
self._arkit_bone_max = _cfg.arkit_bone_max
self._hand_gate = HandPersistenceGate(min_frames=_cfg.hand_persist_frames)
# Safety knob: invert Vision chirality interpretation.
# Chirality validated correct live 2026-07-02; keep False unless a
# future iPhone app build flips it.
self._hand_swap_lr = _cfg.hand_swap_lr
self._init_skel_cpu_buffer()
self._init_mesh_cpu_buffer()
self._build_pipelines()