fix(pose): plumb swap into hand features slots
CI build oscope-of / build-check (push) Has been cancelled

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).
This commit is contained in:
L'électron rare
2026-07-02 11:09:06 +02:00
parent 32cdfa0d3e
commit 5d425b1fbd
3 changed files with 33 additions and 8 deletions
+5 -5
View File
@@ -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):
+6 -3
View File
@@ -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()
+22
View File
@@ -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)