From e7ce0166887df2fa22da47cf0ccfa5183052795b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=27=C3=A9lectron=20rare?= <108685187+electron-rare@users.noreply.github.com> Date: Thu, 2 Jul 2026 11:21:55 +0200 Subject: [PATCH] feat(viz): central VizConfig from env MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- data_only_viz/action_head_pub.py | 54 +++--- data_only_viz/config.py | 300 +++++++++++++++++++++++++++++++ data_only_viz/renderer.py | 20 +-- 3 files changed, 332 insertions(+), 42 deletions(-) create mode 100644 data_only_viz/config.py diff --git a/data_only_viz/action_head_pub.py b/data_only_viz/action_head_pub.py index e8b36b2..cf4e607 100644 --- a/data_only_viz/action_head_pub.py +++ b/data_only_viz/action_head_pub.py @@ -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): diff --git a/data_only_viz/config.py b/data_only_viz/config.py new file mode 100644 index 0000000..f81e792 --- /dev/null +++ b/data_only_viz/config.py @@ -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), + ) diff --git a/data_only_viz/renderer.py b/data_only_viz/renderer.py index 6b14909..52e250b 100644 --- a/data_only_viz/renderer.py +++ b/data_only_viz/renderer.py @@ -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()