diff --git a/data_only_viz/hand_features.py b/data_only_viz/hand_features.py index 470e5ea..4602aca 100644 --- a/data_only_viz/hand_features.py +++ b/data_only_viz/hand_features.py @@ -10,10 +10,18 @@ from collections import deque WRIST, THUMB_TIP, MIDDLE_MCP, PINKY_TIP = 0, 4, 9, 20 -NEUTRAL_HAND = {"cx": 0.5, "cy": 0.5, "openness": 0.0, "speed": 0.0} +# index/middle/ring/pinky tips + their MCP knuckles, for the "all fingers +# curled" fist metric (thumb excluded — a fist curls the four fingers). +_FINGER_TIPS = (8, 12, 16, 20) +_FINGER_MCPS = (5, 9, 13, 17) + +NEUTRAL_HAND = {"cx": 0.5, "cy": 0.5, "openness": 0.0, "speed": 0.0, "fist": 0.0} # openness calibration: (span/size) maps fist~0.3 -> 0, open~2.0 -> 1 _OPEN_LO, _OPEN_HI = 0.5, 2.0 +# fist calibration: per-finger tip->MCP distance / hand size. curled ~0.25, +# extended ~0.75. closedness = 1 at/below LO, 0 at/above HI. +_EXT_LO, _EXT_HI = 0.25, 0.75 def _finite(v: float, fallback: float) -> float: @@ -51,7 +59,15 @@ class HandFeatureExtractor: ys[THUMB_TIP] - ys[PINKY_TIP]) openness = _clamp((span / size - _OPEN_LO) / (_OPEN_HI - _OPEN_LO), 0.0, 1.0) - return {"cx": cx, "cy": cy, "openness": openness} + # fist = 1.0 only when ALL four fingers are curled to the palm (min over + # fingers): one extended finger drops it toward 0, so a true fist is + # required rather than just a small thumb-pinky span. + fist = 1.0 + for tip, mcp in zip(_FINGER_TIPS, _FINGER_MCPS): + ext = math.hypot(xs[tip] - xs[mcp], ys[tip] - ys[mcp]) / size + curled = _clamp(1.0 - (ext - _EXT_LO) / (_EXT_HI - _EXT_LO), 0.0, 1.0) + fist = min(fist, curled) + return {"cx": cx, "cy": cy, "openness": openness, "fist": fist} def _speed(self, slot: int, cx: float, cy: float) -> float: buf = self._buf[slot] diff --git a/data_only_viz/tests/test_hand_features.py b/data_only_viz/tests/test_hand_features.py index 19b894b..e3aa540 100644 --- a/data_only_viz/tests/test_hand_features.py +++ b/data_only_viz/tests/test_hand_features.py @@ -100,3 +100,34 @@ def test_numpy_row_landmarks_finite_features(): 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().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().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().step([h]) + assert (out["L"] or out["R"])["fist"] < 0.1 # min over fingers