From 32cdfa0d3e476c41c84417497e3de046bf91fd7d 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:00:25 +0200 Subject: [PATCH] fix(pose): swap chirality only, default on User-confirmed live: ARBodyTracker Vision chirality arrives inverted vs the mirrored view. HAND_SWAP_LR now inverts the chirality mapping inside route_hands (cx fallback untouched, it is already screen relative) and defaults to 1. Also: HandPersistenceGate greedy-match limitation documented + malformed-entry guard (review follow-ups). --- CLAUDE.md | 2 +- data_only_viz/action_head_pub.py | 7 ++++--- data_only_viz/hand_display.py | 12 ++++++++++-- data_only_viz/hand_slots.py | 22 ++++++++++++---------- data_only_viz/renderer.py | 7 ++++--- data_only_viz/tests/test_hand_slots.py | 17 +++++++++-------- 6 files changed, 40 insertions(+), 27 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 250bb53..95db926 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -82,7 +82,7 @@ Toujours répondre en français à l'utilisateur. Code, commentaires de code, co | `PINCH_DEBOUNCE_FRAMES` | `3` | consecutive qualifying frames before a pinch engage fires (release stays immediate); `1` disables | | `HAND_CONF_MIN` | `0.45` | min Vision hand confidence to draw (raised from 0.3; applies to both overlay and side panels) | | `HAND_PERSIST_FRAMES` | `3` | consecutive frames a hand track must match before being drawn (1 = off, all hands drawn immediately) | -| `HAND_SWAP_LR` | `0` | swap the two routed hand slots (gestures + panels). Safety knob: set `1` live if the iPhone app's camera config inverts Vision chirality — fixes the L/R direction without redeploy | +| `HAND_SWAP_LR` | `1` | invert the Vision chirality interpretation when routing hands (gestures + panels); cx fallback untouched. Default `1`: ARBodyTracker chirality arrives inverted vs the mirrored view (observed live 2026-07-02); set `0` if a future app build fixes it at source | | `HAND_NEAR_MIN` | `0.10` | Minimum wrist-to-middle-MCP distance (normalised) for a hand to trigger pinch/strike. Hands smaller than this are gated out. Display panels are unaffected | | `ARKIT_BONE_MAX` | `0.5` | max ARKit body bone length in normalized units; longer bones are skipped (tracking-loss garbage filter) | diff --git a/data_only_viz/action_head_pub.py b/data_only_viz/action_head_pub.py index e0b24db..a62f1ce 100644 --- a/data_only_viz/action_head_pub.py +++ b/data_only_viz/action_head_pub.py @@ -200,9 +200,10 @@ class ActionHeadPublisher(threading.Thread): # "iphone" / "mediapipe". self._finger_source = os.environ.get("FINGER_SOURCE", "auto").lower() self._fdbg_n = 0 - # Safety knob: swap the two routed slots if the iPhone app's camera - # config inverts Vision chirality (fix live without redeploy). - self._hand_swap_lr = os.environ.get("HAND_SWAP_LR", "0") not in ( + # 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")) diff --git a/data_only_viz/hand_display.py b/data_only_viz/hand_display.py index ad331da..30c5c0a 100644 --- a/data_only_viz/hand_display.py +++ b/data_only_viz/hand_display.py @@ -263,6 +263,11 @@ class HandPersistenceGate: min_frames=1 disables gating: every incoming hand is drawable on the first call (useful for the single-person MediaPipe fallback path). + + Known limit: matching is greedy nearest-track — two hands crossing + within `radius` can inherit each other's track (and a ghost appearing + exactly where a real hand just left inherits its count). Acceptable + for a display gate; do not reuse for gesture-state tracking. """ def __init__(self, min_frames: int = 3, radius: float = 0.15) -> None: @@ -288,8 +293,11 @@ class HandPersistenceGate: used: set[int] = set() for h_idx, hand in enumerate(hands): - wx = hand[0].x - wy = hand[0].y + try: + wx = hand[0].x + wy = hand[0].y + except (TypeError, IndexError, AttributeError): + continue # malformed entry: never drawable, no track # Find the nearest existing track within radius (greedy). best_dist = float("inf") diff --git a/data_only_viz/hand_slots.py b/data_only_viz/hand_slots.py index 98633fb..588da47 100644 --- a/data_only_viz/hand_slots.py +++ b/data_only_viz/hand_slots.py @@ -7,9 +7,11 @@ First match per slot wins; extras dropped. Fallback (chirality absent/misaligned): sort valid hands by screen cx ascending (flipped when mirror=True) and assign leftmost to slot 0, next to slot 1. -`swap=True`: swaps the two slots at the very end (safety knob: if the iPhone -app's camera config inverts Vision chirality, HAND_SWAP_LR=1 fixes the -direction live without redeploy). +`swap=True`: inverts the chirality INTERPRETATION only (chir 0 -> right slot, +1 -> left slot). ARBodyTracker's Vision chirality arrives inverted vs the +user's mirrored view (observed live 2026-07-02), so HAND_SWAP_LR defaults to +1 in the consumers; set 0 if a future app build fixes chirality at the +source. The cx fallback is screen-relative and is never swapped. `near_min > 0`: after routing, replace a slot with None when the hand's wrist-to-middle-MCP distance (normalised) is below `near_min`. @@ -72,8 +74,9 @@ def route_hands( chirality: int list aligned 1-to-1 with `hands` (0=left, 1=right). None / wrong length -> cx fallback. mirror: flip cx for the fallback path so screen-left stays slot 0. - swap: swap the two slots at the very end (both paths). Safety knob - for a source whose Vision chirality arrives inverted. + swap: invert the chirality mapping (0 -> right, 1 -> left). Safety + knob for a source whose Vision chirality arrives inverted. + Never touches the cx fallback (already screen-relative). near_min: replace a routed slot with None when hand_size < near_min. """ chir_aligned = ( @@ -94,9 +97,12 @@ def route_hands( slots: list = [None, None] if chir_aligned and any(c is not None for c, _ in valid_pairs): - # ---- Chirality path: chir 0 -> slot 0 (left), 1 -> slot 1 (right) ---- + # ---- Chirality path: chir 0 -> slot 0 (left), 1 -> slot 1 (right); + # swap inverts the mapping when the source chirality is flipped ---- for c, hand in valid_pairs: target = c if c in (0, 1) else None + if target is not None and swap: + target = 1 - target if target is not None and slots[target] is None: slots[target] = hand else: @@ -118,8 +124,4 @@ def route_hands( if slots[i] is not None and _hand_size(slots[i]) < near_min: slots[i] = None - # ---- swap: exchange the two slots at the very end (safety knob) ---- - if swap: - slots[0], slots[1] = slots[1], slots[0] - return slots diff --git a/data_only_viz/renderer.py b/data_only_viz/renderer.py index 2726bdb..6b14909 100644 --- a/data_only_viz/renderer.py +++ b/data_only_viz/renderer.py @@ -165,9 +165,10 @@ class MetalRenderer(NSObject): self._hand_gate = HandPersistenceGate( min_frames=int(os.environ.get("HAND_PERSIST_FRAMES", "3")) ) - # Safety knob: swap the two routed hand slots if the iPhone app's - # camera config inverts Vision chirality (fix live without redeploy). - self._hand_swap_lr = os.environ.get("HAND_SWAP_LR", "0") not in ( + # 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._init_skel_cpu_buffer() diff --git a/data_only_viz/tests/test_hand_slots.py b/data_only_viz/tests/test_hand_slots.py index 850bd95..ced70e0 100644 --- a/data_only_viz/tests/test_hand_slots.py +++ b/data_only_viz/tests/test_hand_slots.py @@ -89,24 +89,25 @@ def test_mirror_flips_cx_fallback_order(): # --------------------------------------------------------------------------- -# swap: exchanges the two slots at the very end (both paths) +# swap: inverts the CHIRALITY interpretation only (source chirality flipped); +# the cx fallback is screen-relative and must never be affected. # --------------------------------------------------------------------------- -def test_swap_swaps_chirality_slots(): +def test_swap_inverts_chirality_mapping(): h_l = _hand(cx=0.8) # Vision says left (chir=0) but source is inverted h_r = _hand(cx=0.2) # Vision says right (chir=1) result = route_hands([h_l, h_r], [0, 1], swap=True) - assert result[0] is h_r # slots exchanged at the end - assert result[1] is h_l + assert result[0] is h_r # chir 1 -> left slot under swap + assert result[1] is h_l # chir 0 -> right slot under swap -def test_swap_swaps_cx_fallback_slots(): - """swap=True also exchanges the slots on the cx fallback path.""" +def test_swap_does_not_alter_cx_fallback(): + """swap only concerns chirality; the cx fallback stays screen-relative.""" h_a = _hand(cx=0.2) h_b = _hand(cx=0.8) result = route_hands([h_a, h_b], None, swap=True) - assert result[0] is h_b - assert result[1] is h_a + assert result[0] is h_a # unchanged: leftmost keeps slot 0 + assert result[1] is h_b def test_swap_single_hand_moves_slot():