Files
L'électron rare 4ea0bb2c2a
CI build oscope-of / build-check (push) Has been cancelled
feat(pose): fist kill-switch, default off
User request live 2026-07-02: disable the fist gesture completely
(false held-fist actions during play). fist is forced to 0.0 at the
extractor unless FIST_ENABLE=1; continuous features unchanged. SC
held-fist actions (reload/next) go silent without touching SC.
2026-07-02 14:22:31 +02:00

244 lines
9.1 KiB
Python

import math
import pytest
from data_only_viz.hand_features import HandFeatureExtractor, NEUTRAL_HAND
class LM:
"""Minimal landmark stub (PoseKp-shaped)."""
def __init__(self, x, y, z=0.0, c=1.0):
self.x, self.y, self.z, self.c = x, y, z, c
def _hand(cx, cy, span):
"""21 landmarks centered at (cx,cy); thumb/pinky separated by `span`.
Index 0 wrist, 4 thumb_tip, 9 middle_mcp, 20 pinky_tip.
hand_size (wrist->middle_mcp) fixed at 0.10 so openness ~ span/size.
"""
pts = [LM(cx, cy) for _ in range(21)]
pts[0] = LM(cx, cy + 0.05) # wrist below center
pts[9] = LM(cx, cy - 0.05) # middle_mcp above -> size 0.10
pts[4] = LM(cx - span / 2, cy) # thumb_tip
pts[20] = LM(cx + span / 2, cy) # pinky_tip
return pts
def test_open_hand_high_openness():
ext = HandFeatureExtractor()
out = ext.step([_hand(0.5, 0.5, span=0.20)]) # span/size = 2.0
hand = out["L"] or out["R"]
assert hand["openness"] > 0.8
def test_fist_low_openness():
ext = HandFeatureExtractor()
out = ext.step([_hand(0.5, 0.5, span=0.03)]) # span/size = 0.3
hand = out["L"] or out["R"]
assert hand["openness"] < 0.2
def test_static_hand_zero_speed():
ext = HandFeatureExtractor()
h = _hand(0.5, 0.5, span=0.1)
ext.step([h])
out = ext.step([h])
hand = out["L"] or out["R"]
assert hand["speed"] == pytest.approx(0.0, abs=1e-6)
def test_moving_hand_positive_speed():
ext = HandFeatureExtractor()
ext.step([_hand(0.2, 0.5, span=0.1)])
out = ext.step([_hand(0.6, 0.5, span=0.1)])
hand = out["L"] or out["R"]
assert hand["speed"] > 0.0
def test_left_right_by_screen_x():
ext = HandFeatureExtractor()
out = ext.step([_hand(0.8, 0.5, span=0.1), _hand(0.2, 0.5, span=0.1)])
assert out["L"]["cx"] < out["R"]["cx"] # L is leftmost on screen
assert out["dist"] > 0.0
def test_single_hand_zero_dist():
ext = HandFeatureExtractor()
out = ext.step([_hand(0.5, 0.5, span=0.1)])
assert out["dist"] == 0.0
def test_no_hands_returns_none():
ext = HandFeatureExtractor()
out = ext.step([])
assert out["L"] is None and out["R"] is None and out["dist"] == 0.0
def test_nan_landmark_falls_back_no_exception():
ext = HandFeatureExtractor()
h = _hand(0.5, 0.5, span=0.1)
h[0] = LM(float("nan"), float("nan"))
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
def _fist_hand(tip_ext):
"""All 4 finger MCPs at center; each fingertip placed tip_ext*size away
(so the per-finger tip->MCP ratio == tip_ext). size (wrist->mcp9) = 0.10."""
cx, cy = 0.5, 0.5
pts = [LM(cx, cy) for _ in range(21)]
pts[0] = LM(cx, cy + 0.10) # wrist -> size 0.10
for mcp in (5, 9, 13, 17):
pts[mcp] = LM(cx, cy)
for tip in (8, 12, 16, 20):
pts[tip] = LM(cx + tip_ext * 0.10, cy)
pts[4] = LM(cx - 0.05, cy) # thumb
return pts
def test_fist_all_fingers_curled():
out = HandFeatureExtractor(fist_enable=True).step([_fist_hand(0.20)]) # tips at knuckles
assert (out["L"] or out["R"])["fist"] > 0.9
def test_open_hand_is_not_a_fist():
out = HandFeatureExtractor(fist_enable=True).step([_fist_hand(0.80)]) # fingers extended
assert (out["L"] or out["R"])["fist"] < 0.1
def test_one_extended_finger_breaks_the_fist():
h = _fist_hand(0.20) # all curled...
h[8] = LM(0.5 + 0.80 * 0.10, 0.5) # ...except the index
out = HandFeatureExtractor(fist_enable=True).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)
# ---------------------------------------------------------------------------
# fist_enabled gate
# ---------------------------------------------------------------------------
def test_fist_enabled_false_zeroes_fist_preserves_openness():
"""fist_enabled=(False, False) forces fist=0 but leaves openness intact."""
ext = HandFeatureExtractor()
h = _fist_hand(0.20) # all curled -> fist > 0.9 normally
out = ext.step([h], fist_enabled=(False, False))
hand = out["L"] or out["R"]
assert hand["fist"] == 0.0, "fist must be zeroed when disabled"
assert hand["openness"] < 0.2, "openness unaffected"
def test_fist_enabled_true_computes_fist():
"""fist_enabled=(True, True) leaves fist computation unchanged."""
ext = HandFeatureExtractor(fist_enable=True)
h = _fist_hand(0.20)
out = ext.step([h], fist_enabled=(True, True))
hand = out["L"] or out["R"]
assert hand["fist"] > 0.9
def test_mirror_flips_cx_fallback_slot_assignment():
"""mirror=True inverts the cx fallback so screen-right hand goes to slot L."""
ext = HandFeatureExtractor()
h_low = _hand(0.2, 0.5, span=0.10) # cx=0.2 -> slot L normally (leftmost)
h_high = _hand(0.8, 0.5, span=0.10) # cx=0.8 -> slot R normally
# No chirality -> cx fallback
plain = ext.step([h_low, h_high], chirality=None, mirror=False)
mirrored = ext.step([h_low, h_high], chirality=None, mirror=True)
assert plain["L"]["cx"] == pytest.approx(0.2, abs=0.02), "non-mirrored: L gets leftmost"
assert mirrored["L"]["cx"] == pytest.approx(0.8, abs=0.02), "mirrored: L gets rightmost"
def test_mirror_default_false_unchanged():
"""mirror defaults to False; existing tests that pass no mirror kwarg are unaffected."""
ext = HandFeatureExtractor()
h_a = _hand(0.2, 0.5, span=0.10)
h_b = _hand(0.8, 0.5, span=0.10)
no_kwarg = ext.step([h_a, h_b])
explicit = ext.step([h_a, h_b], mirror=False)
assert no_kwarg["L"]["cx"] == pytest.approx(explicit["L"]["cx"], abs=1e-6)
def test_fist_enabled_per_slot_independence():
"""fist_enabled masks only the specified slot."""
ext = HandFeatureExtractor()
# Build two 21-kp hands with distinct wrist positions for L/R routing
# Left hand: cx~0.2, all fingers curled (fist)
h_fist = _fist_hand(0.20)
h_fist[0] = LM(0.2, 0.6)
h_fist[9] = LM(0.2, 0.5)
for mcp in (5, 13, 17):
h_fist[mcp] = LM(0.2, 0.5)
for tip in (8, 12, 16, 20):
h_fist[tip] = LM(0.2 + 0.20 * 0.10, 0.5)
# Right hand: cx~0.8, fingers extended (open)
h_open = _fist_hand(0.80)
h_open[0] = LM(0.8, 0.6)
h_open[9] = LM(0.8, 0.5)
for mcp in (5, 13, 17):
h_open[mcp] = LM(0.8, 0.5)
for tip in (8, 12, 16, 20):
h_open[tip] = LM(0.8 + 0.80 * 0.10, 0.5)
out = ext.step([h_fist, h_open], fist_enabled=(False, True))
# L slot (cx~0.2) should have fist=0 even though it's curled
assert out["L"]["fist"] == 0.0
# R slot (cx~0.8) should compute normally (open -> fist < 0.1)
assert out["R"]["fist"] < 0.1
def test_fist_kill_switch_default_off(monkeypatch):
"""FIST_ENABLE unset -> fist forced 0.0 even on a perfect fist
(user-disabled live 2026-07-02); continuous features unaffected."""
monkeypatch.delenv("FIST_ENABLE", raising=False)
out = HandFeatureExtractor().step([_fist_hand(0.20)])
hand = out["L"] or out["R"]
assert hand["fist"] == 0.0
assert hand["openness"] < 0.2
def test_fist_kill_switch_env_reenable(monkeypatch):
monkeypatch.setenv("FIST_ENABLE", "1")
out = HandFeatureExtractor().step([_fist_hand(0.20)])
assert (out["L"] or out["R"])["fist"] > 0.9