feat(pose): pinch open-hand extension gate

This commit is contained in:
L'électron rare
2026-07-02 09:10:06 +02:00
parent 50bc6f5d71
commit 021c53f078
2 changed files with 66 additions and 1 deletions
+23 -1
View File
@@ -150,7 +150,8 @@ class PinchDetector:
def __init__(self, ratio_on: float = 0.45, ratio_off: float = 0.65,
refractory_ms: float = 250.0, history_slots: int = 2,
margin: float = 0.20) -> None:
margin: float = 0.20, ext_ratio: float = 1.35,
ext_min: int = 0, debounce_frames: int = 1) -> None:
self.ratio_on = ratio_on
self.ratio_off = ratio_off
self.refractory_s = refractory_ms / 1000.0
@@ -158,6 +159,18 @@ class PinchDetector:
# least `margin` (in size-normalized units) nearer than the runner-up.
# Rejects the adjacent-finger ambiguity when fingers curl together.
self.margin = margin
# Open-hand gate: the winner may engage only when at least ext_min
# of the 3 non-pinching fingers are extended (tip-to-wrist distance
# above ext_ratio hand-sizes). Rejects relaxed-hand/fist false
# pinches during full-body play. ext_min=0 disables the gate; the
# live defaults come from the PINCH_EXT_* env vars (action_head_pub),
# constructor defaults preserve legacy behavior.
self.ext_ratio = ext_ratio
self.ext_min = int(ext_min)
# Engage fires only after debounce_frames consecutive qualifying
# frames; release stays immediate. 1 = no debounce. (Enforced in
# step(); wired fully by the debounce task.)
self.debounce_frames = max(1, int(debounce_frames))
self._state = [[_PinchState() for _ in range(4)]
for _ in range(history_slots)]
@@ -175,17 +188,26 @@ class PinchDetector:
size = math.hypot(mx - wx, my - wy)
size = size if size > 1e-4 else 1e-4
ratios = []
exts = []
for tip_idx in PINCH_TIPS:
fx = _finite(_coord(lm[tip_idx], "x", 0), 0.5)
fy = _finite(_coord(lm[tip_idx], "y", 1), 0.5)
ratios.append(math.hypot(fx - tx, fy - ty) / size)
exts.append(math.hypot(fx - wx, fy - wy) / size)
# closest-wins + margin: pick the single nearest fingertip, and treat
# it as a pinch only if it is clearly nearer than the runner-up.
order = sorted(range(4), key=lambda j: ratios[j])
nearest, runner = order[0], order[1]
# open-hand gate: a deliberate pinch keeps the other fingers
# extended; a fist/relaxed hand has them curled at the wrist.
open_ok = self.ext_min <= 0 or sum(
1 for j in range(4)
if j != nearest and exts[j] >= self.ext_ratio
) >= self.ext_min
winner = nearest if (
ratios[nearest] < self.ratio_on
and (ratios[runner] - ratios[nearest]) >= self.margin
and open_ok
) else -1
for i in range(4):
st = self._state[slot][i]
+43
View File
@@ -178,3 +178,46 @@ def test_pinch_ambiguous_adjacent_fires_nothing():
ev = det.step([_pinch_hand_multi((0.5, 0.5),
{0: (0.52, 0.5), 1: (0.53, 0.5)})], 0.1)
assert ev == []
def _relaxed_hand():
"""Relaxed/fist hand: thumb touches the index tip, but middle/ring/
pinky are curled near the wrist (NOT extended). The runner-up tip is
far from the thumb so the closest-wins margin passes -- only the
extension gate can reject this hand."""
lm = [[0.5, 0.5, 0.0] for _ in range(21)]
lm[0] = [0.5, 0.8, 0.0] # WRIST
lm[9] = [0.5, 0.5, 0.0] # MIDDLE_MCP -> hand size 0.3
lm[4] = [0.45, 0.55, 0.0] # THUMB_TIP
lm[8] = [0.47, 0.55, 0.0] # INDEX_TIP: ratio 0.067 (pinch-close)
lm[12] = [0.60, 0.62, 0.0] # MIDDLE_TIP curled (ext ~0.69)
lm[16] = [0.62, 0.65, 0.0] # RING_TIP curled (ext ~0.64)
lm[20] = [0.64, 0.68, 0.0] # LITTLE_TIP curled (ext ~0.62)
return lm
def test_relaxed_hand_rejected_by_extension_gate():
det = PinchDetector(ratio_on=0.45, ratio_off=0.65, refractory_ms=0,
ext_min=2)
out = []
for i in range(10):
out += det.step([_relaxed_hand()], i * 0.033)
assert out == []
def test_open_hand_pinch_passes_extension_gate():
# _pinch_hand parks middle/ring/little far from the wrist
# (ext 1.67 / 1.49 / 1.37, all >= 1.35) -> 3 extended >= ext_min 2.
det = PinchDetector(ratio_on=0.45, ratio_off=0.65, refractory_ms=0,
ext_min=2)
det.step([_pinch_hand(*_OPEN)], 0.0)
ev = det.step([_pinch_hand(*_PINCH)], 0.1)
assert len(ev) == 1 and ev[0].state == 1 and ev[0].finger == 1
def test_extension_gate_defeat_reproduces_old_behavior():
# ext_min=0 disables the gate: the relaxed hand fires like today.
det = PinchDetector(ratio_on=0.45, ratio_off=0.65, refractory_ms=0,
ext_min=0)
ev = det.step([_relaxed_hand()], 0.1)
assert len(ev) == 1 and ev[0].state == 1