fix(viz): numpy-safe hand filter + tests

- _emit_hands: replace bare `if h` with numpy-safe predicate
  `h is not None and len(h) > 0` in single-person fallback
- test_hand_features: add numpy-row landmark test for
  HandFeatureExtractor.step(), asserting finite cx/cy/openness
- test_action_head_pub: add fallback-path test and two-pid
  once-per-tick invariant test for send_hands
This commit is contained in:
L'électron rare
2026-06-26 09:52:20 +02:00
parent ec2c387091
commit de2748accb
3 changed files with 54 additions and 1 deletions
+1 -1
View File
@@ -153,7 +153,7 @@ class ActionHeadPublisher(threading.Thread):
if not hands and getattr(self.state, "hands_present", False):
lkp = getattr(self.state, "left_hand_kp", None)
rkp = getattr(self.state, "right_hand_kp", None)
hands = [h for h in (lkp, rkp) if h]
hands = [h for h in (lkp, rkp) if h is not None and len(h) > 0]
feats = self._hand_ext.step(hands)
self.bridge.send_hands(feats, t_now)
@@ -207,3 +207,36 @@ def test_emit_hands_called_once_per_tick_empty() -> None:
assert feats["L"] is None
assert feats["R"] is None
assert feats["dist"] == 0.0
def test_emit_hands_single_person_fallback_sends_once() -> None:
"""Single-person fallback (hands_present=True + left/right_hand_kp) → send_hands once."""
from data_only_viz.action_head_pub import ActionHeadPublisher
state = _FakeState()
bridge = MagicMock()
pub = ActionHeadPublisher(state, bridge, ckpt_path=None)
# No per-person hands list; exercise the single-person fallback branch.
state.persons_hands = []
state.hands_present = True
state.left_hand_kp = _fake_hand_21(0)
state.right_hand_kp = _fake_hand_21(1)
state.persons_smplx = [_make_smplx_person(0)]
state.smplx_last_t = 1.0
pub._tick(t_now=0.0)
bridge.send_hands.assert_called_once()
def test_emit_hands_once_for_two_pids() -> None:
"""Two pids in persons_smplx → send_hands called exactly once, not once per pid."""
from data_only_viz.action_head_pub import ActionHeadPublisher
state = _FakeState()
bridge = MagicMock()
pub = ActionHeadPublisher(state, bridge, ckpt_path=None)
state.persons_smplx = [_make_smplx_person(0), _make_smplx_person(1)]
state.smplx_last_t = 1.0
pub._tick(t_now=0.0)
assert bridge.send_hands.call_count == 1
+20
View File
@@ -80,3 +80,23 @@ def test_nan_landmark_falls_back_no_exception():
out = ext.step([h]) # must not raise
hand = out["L"] or out["R"]
assert math.isfinite(hand["cx"]) and math.isfinite(hand["openness"])
def test_numpy_row_landmarks_finite_features():
"""Landmarks as numpy rows (indexable [x, y, ...]) must yield finite cx/cy/openness."""
import numpy as np
arr = np.zeros((21, 3), dtype=np.float32)
# replicate _hand(0.5, 0.5, span=0.20) geometry via index access
arr[:] = [0.5, 0.5, 0.0]
arr[0] = [0.5, 0.55, 0.0] # wrist
arr[9] = [0.5, 0.45, 0.0] # middle_mcp → size 0.10
arr[4] = [0.4, 0.5, 0.0] # thumb_tip
arr[20] = [0.6, 0.5, 0.0] # pinky_tip → span 0.20
ext = HandFeatureExtractor()
out = ext.step([arr]) # must not raise
hand = out["L"] or out["R"]
assert hand is not None
assert math.isfinite(hand["cx"])
assert math.isfinite(hand["cy"])
assert math.isfinite(hand["openness"])
assert hand["openness"] > 0.8 # span/size = 2.0, same as test_open_hand