diff --git a/data_only_viz/action_head_pub.py b/data_only_viz/action_head_pub.py index 78236ac..9e7a639 100644 --- a/data_only_viz/action_head_pub.py +++ b/data_only_viz/action_head_pub.py @@ -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) diff --git a/data_only_viz/tests/test_action_head_pub.py b/data_only_viz/tests/test_action_head_pub.py index 0d3c623..1b33b12 100644 --- a/data_only_viz/tests/test_action_head_pub.py +++ b/data_only_viz/tests/test_action_head_pub.py @@ -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 diff --git a/data_only_viz/tests/test_hand_features.py b/data_only_viz/tests/test_hand_features.py index 03b0548..19b894b 100644 --- a/data_only_viz/tests/test_hand_features.py +++ b/data_only_viz/tests/test_hand_features.py @@ -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