From 5d425b1fbd157b326a4b63aa2eec699492db1738 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:09:06 +0200 Subject: [PATCH] fix(pose): plumb swap into hand features slots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review C1 (Critical): /pose/hands per-voice L/R mods routed without the HAND_SWAP_LR swap while pinch/strike used it — the two paths contradicted each other on an inverted-chirality source. step() now takes swap, the publisher passes its env-read value, two tests pin the contract. Also hoists the route_hands imports (M5) and fixes the stale slot comment (M1). --- data_only_viz/action_head_pub.py | 10 +++++----- data_only_viz/hand_features.py | 9 ++++++--- data_only_viz/tests/test_hand_features.py | 22 ++++++++++++++++++++++ 3 files changed, 33 insertions(+), 8 deletions(-) diff --git a/data_only_viz/action_head_pub.py b/data_only_viz/action_head_pub.py index a62f1ce..e8b36b2 100644 --- a/data_only_viz/action_head_pub.py +++ b/data_only_viz/action_head_pub.py @@ -24,6 +24,7 @@ from data_only_viz.action_head import ( J3D_FINGERS_PER_HAND, LABELS, ) +from data_only_viz.hand_slots import route_hands LOG = logging.getLogger("action_head_pub") @@ -160,7 +161,8 @@ class ActionHeadPublisher(threading.Thread): rkp = getattr(self.state, "right_hand_kp", None) 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) + feats = self._hand_ext.step(hands, chirality or None, + swap=getattr(self, "_hand_swap_lr", True)) with self.state.lock(): self.state.hand_feats = feats self.bridge.send_hands(feats, t_now) @@ -229,11 +231,10 @@ class ActionHeadPublisher(threading.Thread): """Detect finger strikes (air-piano) and emit /pose/finger.""" if not getattr(self, "_finger_enabled", False): return - from data_only_viz.hand_slots import route_hands hands, chirality, used, ip_fresh = self._pick_hands() slotted = route_hands( hands, chirality or None, - swap=getattr(self, "_hand_swap_lr", False), + swap=getattr(self, "_hand_swap_lr", True), near_min=getattr(self, "_hand_near_min", 0.10), ) events = self._finger_det.step(slotted, t_now) @@ -250,11 +251,10 @@ class ActionHeadPublisher(threading.Thread): /pose/pinch. Gated by PINCH_ENABLE, independently of the air-piano.""" if not getattr(self, "_pinch_enabled", False): return - from data_only_viz.hand_slots import route_hands hands, chirality, used, _ip = self._pick_hands() slotted = route_hands( hands, chirality or None, - swap=getattr(self, "_hand_swap_lr", False), + swap=getattr(self, "_hand_swap_lr", True), 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/hand_features.py b/data_only_viz/hand_features.py index 9882d95..e47c095 100644 --- a/data_only_viz/hand_features.py +++ b/data_only_viz/hand_features.py @@ -45,7 +45,8 @@ def _clamp(v: float, lo: float, hi: float) -> float: class HandFeatureExtractor: def __init__(self, history: int = 5): self._history = max(2, history) - # two screen slots: 0 = leftmost (L), 1 = rightmost (R) + # two routed slots (hand_slots.route_hands): 0 = user's left (L), + # 1 = user's right (R); cx fallback when chirality is absent. self._buf = [deque(maxlen=self._history), deque(maxlen=self._history)] def _features(self, lm: list) -> dict: @@ -78,9 +79,11 @@ class HandFeatureExtractor: buf.append((cx, cy)) return _clamp(spd, 0.0, 1.0) - def step(self, hands: list, chirality=None) -> dict: + def step(self, hands: list, chirality=None, swap: bool = False) -> dict: from data_only_viz.hand_slots import route_hands - left_lm, right_lm = route_hands(hands, chirality) + # swap must mirror the gesture path (HAND_SWAP_LR) or per-voice + # L/R mods contradict pinch/strike in the same performance. + left_lm, right_lm = route_hands(hands, chirality, swap=swap) if left_lm is None and right_lm is None: self._buf[0].clear() diff --git a/data_only_viz/tests/test_hand_features.py b/data_only_viz/tests/test_hand_features.py index e3aa540..2033fb6 100644 --- a/data_only_viz/tests/test_hand_features.py +++ b/data_only_viz/tests/test_hand_features.py @@ -131,3 +131,25 @@ def test_one_extended_finger_breaks_the_fist(): h[8] = LM(0.5 + 0.80 * 0.10, 0.5) # ...except the index out = HandFeatureExtractor().step([h]) assert (out["L"] or out["R"])["fist"] < 0.1 # min over fingers + + +def test_swap_routes_inverted_chirality_to_user_slots(): + """C1 pin: with the inverted-source swap ON (HAND_SWAP_LR default), + a physical LEFT hand arriving labeled chir=1 must land in feature + slot L — consistent with the pinch/strike gesture path.""" + ext = HandFeatureExtractor() + left_hand = _hand(0.30, 0.5, span=0.20) # user's left (source says 1) + right_hand = _hand(0.70, 0.5, span=0.06) # user's right (source says 0) + out = ext.step([left_hand, right_hand], [1, 0], swap=True) + assert out["L"] is not None and out["R"] is not None + assert out["L"]["cx"] == pytest.approx(0.30, abs=0.01) # left hand in L + assert out["R"]["cx"] == pytest.approx(0.70, abs=0.01) + + +def test_no_swap_keeps_source_chirality(): + ext = HandFeatureExtractor() + a = _hand(0.30, 0.5, span=0.20) + b = _hand(0.70, 0.5, span=0.06) + out = ext.step([a, b], [1, 0], swap=False) + assert out["L"]["cx"] == pytest.approx(0.70, abs=0.01) # chir 0 -> L + assert out["R"]["cx"] == pytest.approx(0.30, abs=0.01)