feat(pose): facing gate for gestures

Add hand_facing() palm-spread metric (dist(kp5,kp17)/hand_size).
Gate GestureSlotStabilizer slot activation on facing >= HAND_FACE_MIN
(default 0.5) with 0.8x hysteresis; side-on palms no longer arm
pinch/strike. Add VizConfig.hand_face_min + HAND_FACE_MIN env var.

Update test fixtures: _near_hand() now spreads kp[5]/kp[17] so
existing tests keep passing through both near+face gates.
This commit is contained in:
L'électron rare
2026-07-02 15:16:01 +02:00
parent bcf7ac180e
commit eb3803680b
8 changed files with 199 additions and 9 deletions
+3
View File
@@ -35,6 +35,7 @@ HAND_CONF_MIN 0.45 float min hand landmark confidence to ren
HAND_PERSIST_FRAMES 3 int consecutive frames before a hand track is drawn; 1=off
HAND_SWAP_LR "0" bool Safety knob: invert Vision chirality (validated correct live 2026-07-02; keep 0 unless a future iPhone app build flips it)
HAND_NEAR_MIN 0.10 float min hand size (norm) to consider "near"
HAND_FACE_MIN 0.5 float palm-spread ratio threshold: slot activates gestures only when facing >= this (side-on < 0.4, facing ~0.7-1.0)
HAND_PERSIST_GRACE 2 int grace frames before dropping a hand track on Vision miss (0 = strict)
HAND_NEAR_OFF 0.08 float hand size below which near gate deactivates (hysteresis off-threshold)
HAND_HOLD_FRAMES 2 int gesture slot hold: frames to carry last hand on Vision miss
@@ -125,6 +126,7 @@ class VizConfig:
hand_near_min: float = 0.10
hand_near_off: float = 0.08
hand_hold_frames: int = 2
hand_face_min: float = 0.5
arkit_bone_max: float = 0.5
arkit_full_skeleton: bool = True
@@ -272,6 +274,7 @@ class VizConfig:
hand_near_min=_float("HAND_NEAR_MIN", 0.10),
hand_near_off=_float("HAND_NEAR_OFF", 0.08),
hand_hold_frames=_int("HAND_HOLD_FRAMES", 2),
hand_face_min=_float("HAND_FACE_MIN", 0.5),
arkit_bone_max=_float("ARKIT_BONE_MAX", 0.5),
# ARKIT_FULL_SKELETON uses != "0" convention (empty = True)
arkit_full_skeleton=_bool_ne0("ARKIT_FULL_SKELETON", True),
+27
View File
@@ -1,6 +1,12 @@
"""Pure geometry helpers for hand overlay filtering and panel rendering.
No Metal, no numpy, no pyobjc — safe to unit-test anywhere.
Panel frame status indicator color legend (used in renderer.py):
pid 7 conf 0.3 → status 0: absent (dim frame)
pid 7 conf 1.0 → status 1: detected (plausible+established, not armed)
pid 8 conf 1.0 → status 2: armed (near + facing camera)
pid 9 conf 1.0 → status 3: pinch engaged (double-stroke bold)
"""
from __future__ import annotations
@@ -56,6 +62,27 @@ def hand_size(kp: list[_HasXYC]) -> float:
return math.hypot(m.x - w.x, m.y - w.y)
def hand_facing(kp: list) -> float:
"""Palm-spread ratio for camera-facing detection.
Returns dist(index-MCP kp[5], pinky-MCP kp[17]) / hand_size(kp).
A palm facing the camera spans ~70-100 % of hand_size; a side-on
hand collapses to < 40 %. Returns 0.0 when hand_size is ~0.
Accepts both attribute-style (.x/.y) and index-style ([x, y, ...]) kp.
"""
def _gx(p: object) -> float:
return float(p.x) if hasattr(p, "x") else float(p[0]) # type: ignore[index]
def _gy(p: object) -> float:
return float(p.y) if hasattr(p, "y") else float(p[1]) # type: ignore[index]
sz = math.hypot(_gx(kp[9]) - _gx(kp[0]), _gy(kp[9]) - _gy(kp[0]))
if sz < 1e-6:
return 0.0
return math.hypot(_gx(kp[17]) - _gx(kp[5]), _gy(kp[17]) - _gy(kp[5])) / sz
def hand_plausible(
kp: list[_HasXYC],
conf_min: float = 0.3,
+26 -8
View File
@@ -23,6 +23,7 @@ import math
# ---- micro-helpers imported from hand_features (acyclic: hand_features only
# imports hand_slots lazily inside step(), so a top-level import here is safe) ---
from .hand_features import _finite, _coord, _clamp
from .hand_display import hand_facing as _hand_facing
def _hand_size(hand) -> float:
@@ -134,14 +135,21 @@ class GestureSlotStabilizer:
hold_frames: int = 2,
near_on: float = 0.10,
near_off: float = 0.08,
face_min: float = 0.5,
) -> None:
self._hold = hold_frames
self._near_on = near_on
self._near_off = near_off
self._face_min = face_min
# face_off uses 0.8× hysteresis (mirrors the near on/off pattern) so that
# a slot active at face_min does not flap when facing oscillates near the
# threshold — it only deactivates when facing drops below face_off.
self._face_off = 0.8 * face_min
# Per-slot state
self._last: list = [None, None] # last seen hand object
self._miss: list[int] = [0, 0] # consecutive miss count
self._near_active: list[bool] = [False, False]
self._face_active: list[bool] = [False, False]
self._out: list = [None, None] # last step() output (for active_flags)
# Hold-tracking: True when previous step() output was a held replay.
self._held_flag: list[bool] = [False, False]
@@ -175,28 +183,38 @@ class GestureSlotStabilizer:
else:
if size >= self._near_on:
self._near_active[i] = True
# Track the hand regardless of near state
# Update face hysteresis (face_off = 0.8 * face_min)
facing = _hand_facing(hand)
if self._face_active[i]:
if facing < self._face_off:
self._face_active[i] = False
else:
if facing >= self._face_min:
self._face_active[i] = True
# Track the hand regardless of near/face state
self._last[i] = hand
self._miss[i] = 0
# Output only if near-active
out[i] = hand if self._near_active[i] else None
# Output only if near-active AND facing the camera
out[i] = hand if (self._near_active[i] and self._face_active[i]) else None
else:
# Hand absent this frame
self._resumed[i] = False
self._miss[i] += 1
if self._miss[i] <= self._hold:
# Hold: output last hand if it was near-active
if self._near_active[i] and self._last[i] is not None:
# Hold: output last hand only if it was near+face-active
if (self._near_active[i] and self._face_active[i]
and self._last[i] is not None):
self._held_flag[i] = True
out[i] = self._last[i]
else:
# Not near-active: no hold replay; slot stays None
# Not active: no hold replay; slot stays None
self._held_flag[i] = False
else:
# Past hold limit — clear near_active so next appearance
# must re-qualify from near_on.
# Past hold limit — clear both gates so next appearance
# must re-qualify from near_on and face_min.
self._held_flag[i] = False
self._near_active[i] = False
self._face_active[i] = False
out[i] = None
self._out = out
return out
@@ -38,6 +38,11 @@ def _hand(index_tip_y: float, base_y: float = 0.4, c: float = 1.0):
lm[9] = _Kp(0.3, base_y, 0.0, c) # middle-MCP at center -> size 0.15 > near_min
lm[FINGERTIPS[1]] = _Kp(0.3, index_tip_y, 0.0, c)
lm[FINGER_BASES[1]] = _Kp(0.3, base_y, 0.0, c)
# Spread index-MCP(5) and pinky-MCP(17) AFTER FINGER_BASES[1]=5 assignment
# so lm[5] is not overwritten. facing = 0.12/0.15 = 0.8 >= face_min=0.5.
# Only x changes; y=base_y is preserved so FingerStrikeDetector base_y is correct.
lm[5] = _Kp(0.3 - 0.06, base_y, 0.0, c)
lm[17] = _Kp(0.3 + 0.06, base_y, 0.0, c)
return lm
+12
View File
@@ -395,3 +395,15 @@ def test_multiworker_period_formula_for_iphone_usb():
assert "target_fps" in sig.parameters, (
"MultiWorker.__init__ must accept target_fps kwarg"
)
def test_hand_face_min_default():
"""VizConfig.from_env({}) exposes hand_face_min with a default of 0.5."""
cfg = VizConfig.from_env({})
assert cfg.hand_face_min == 0.5
def test_hand_face_min_env_override():
"""HAND_FACE_MIN env var is parsed as float and stored in hand_face_min."""
cfg = VizConfig.from_env({"HAND_FACE_MIN": "0.3"})
assert cfg.hand_face_min == pytest.approx(0.3)
+58
View File
@@ -12,6 +12,7 @@ import pytest
from data_only_viz.hand_display import (
HandPersistenceGate,
arkit_2d_fresh,
hand_facing,
hand_plausible,
hand_size,
segment_ok,
@@ -558,3 +559,60 @@ def test_gate_grace_ghost_stays_suppressed():
gate.step([]) # miss=1 ≤ grace → track [0.5, 0.5, 1, 1] survives
# Reappears: count increments to 2 (still not established)
assert gate.step([hand]) == [False]
# ---------------------------------------------------------------------------
# hand_facing
# ---------------------------------------------------------------------------
def _make_facing_kp(wrist_x=0.5, wrist_y=0.7, size=0.15, spread=0.14) -> list[Kp]:
"""21-kp hand with index-MCP(5) and pinky-MCP(17) spread wide in x."""
kp = _make_hand(wrist_x=wrist_x, wrist_y=wrist_y, size=size)
kp[5] = Kp(x=wrist_x - spread / 2, y=wrist_y - size / 2)
kp[17] = Kp(x=wrist_x + spread / 2, y=wrist_y - size / 2)
return kp
def _make_sideon_kp(wrist_x=0.5, wrist_y=0.7, size=0.15) -> list[Kp]:
"""21-kp hand with index-MCP(5) and pinky-MCP(17) almost co-located."""
kp = _make_hand(wrist_x=wrist_x, wrist_y=wrist_y, size=size)
kp[5] = Kp(x=wrist_x, y=wrist_y - size / 2)
kp[17] = Kp(x=wrist_x + 0.005, y=wrist_y - size / 2)
return kp
def test_hand_facing_camera_above_threshold():
"""A palm facing the camera has a spread ratio >= 0.7."""
kp = _make_facing_kp(size=0.15, spread=0.14)
# facing = 0.14 / 0.15 ~ 0.93
assert hand_facing(kp) >= 0.7
def test_hand_facing_side_on_below_threshold():
"""A side-on palm has a spread ratio < 0.4."""
kp = _make_sideon_kp(size=0.15)
# facing = 0.005 / 0.15 ~ 0.033
assert hand_facing(kp) < 0.4
def test_hand_facing_zero_size_returns_zero():
"""Guard: when hand_size is ~0 (degenerate hand), returns 0.0."""
kp = _make_hand(wrist_x=0.5, wrist_y=0.5, size=0.0)
# Force wrist and MCP to the same position so hand_size = 0
kp[0] = Kp(x=0.5, y=0.5)
kp[9] = Kp(x=0.5, y=0.5)
assert hand_facing(kp) == 0.0
def test_hand_facing_list_format():
"""hand_facing also accepts list-style kp ([x, y, z]) as used in hand_slots tests."""
# Build a 21-element list-of-lists
lm = [[0.5, 0.5, 0.0] for _ in range(21)]
lm[0] = [0.5, 0.7, 0.0] # wrist
lm[9] = [0.5, 0.55, 0.0] # middle-MCP, size = 0.15
# Wide spread: index-MCP and pinky-MCP 0.14 apart in x
lm[5] = [0.43, 0.62, 0.0]
lm[17] = [0.57, 0.62, 0.0]
f = hand_facing(lm)
# facing = 0.14 / 0.15 ~ 0.93
assert f >= 0.7
+67 -1
View File
@@ -205,7 +205,24 @@ def test_result_always_length_2():
# ---------------------------------------------------------------------------
def _near_hand(cx: float = 0.5, size: float = 0.15) -> list:
"""Hand with hand_size == size (near enough for near_on=0.10)."""
"""Near hand (size >= near_on=0.10) that also passes the face gate.
kp[5]/kp[17] spread by 0.12 → facing = 0.12/0.15 = 0.8 >= face_min=0.5.
All tests that verify near-hysteresis / hold / resumed behavior should use
this helper so they continue to pass after the facing gate was added.
"""
lm = _hand(cx=cx, size=size)
lm[5] = [cx - 0.06, 0.5, 0.0] # index-MCP
lm[17] = [cx + 0.06, 0.5, 0.0] # pinky-MCP → spread=0.12, facing≈0.8
return lm
def _sideon_near_hand(cx: float = 0.5, size: float = 0.15) -> list:
"""Near hand (size >= near_on) whose kp[5]==kp[17] → facing=0.0 (fails face gate).
Use this for tests that specifically verify the face gate rejects a side-on
palm even when the hand is close enough to pass the near gate.
"""
return _hand(cx=cx, size=size)
@@ -356,3 +373,52 @@ def test_resumed_flags_independent_per_slot():
r = stab.resumed_flags()
assert r[0] is True, "slot 0 just transitioned held -> real"
assert r[1] is False, "slot 1 was never held"
# ---------------------------------------------------------------------------
# GestureSlotStabilizer — face gate
# ---------------------------------------------------------------------------
def test_stab_face_gate_sideon_never_activates():
"""A side-on hand (facing=0.0) never activates the slot even when near."""
stab = GestureSlotStabilizer(hold_frames=2, near_on=0.10, near_off=0.08)
h = _sideon_near_hand() # size=0.15 >= near_on, but facing=0.0 < face_min=0.5
out1 = stab.step([h, None])
out2 = stab.step([h, None])
assert out1[0] is None, "side-on: slot must be None (face gate rejects)"
assert out2[0] is None, "side-on: slot still None after two frames"
assert stab.active_flags() == (False, False)
def test_stab_face_gate_facing_activates():
"""A near, camera-facing hand activates the slot (passes both gates)."""
stab = GestureSlotStabilizer(hold_frames=2, near_on=0.10, near_off=0.08)
h = _near_hand() # size=0.15 >= near_on, facing=0.8 >= face_min=0.5
stab.step([h, None])
out = stab.step([h, None])
assert out[0] is h, "near+facing hand must be in slot 0"
assert stab.active_flags() == (True, False)
def test_stab_face_gate_hysteresis_stays_active_between_thresholds():
"""Once active, slot stays active when facing drops between face_off and face_min.
face_min=0.5, face_off=0.4. A hand that was facing (0.8) then turns
slightly side-on to facing=0.43 must NOT deactivate (hysteresis protects
against flapping). Only a drop below face_off (0.4) would deactivate.
"""
stab = GestureSlotStabilizer(hold_frames=2, near_on=0.10, near_off=0.08)
# Activate: near+facing hand
h_facing = _near_hand() # facing=0.8
stab.step([h_facing, None])
assert stab.step([h_facing, None])[0] is h_facing
# Build a hand with facing=0.43 (between face_off=0.4 and face_min=0.5)
# size=0.15, spread=0.43*0.15=0.0645
h_mid = _hand()
h_mid[5] = [0.5 - 0.032, 0.5, 0.0]
h_mid[17] = [0.5 + 0.032, 0.5, 0.0] # dist≈0.064/0.15≈0.43
out = stab.step([h_mid, None])
assert out[0] is h_mid, "hysteresis: slot stays active between face_off and face_min"
assert stab.active_flags() == (True, False)