feat(pose): pinch open-hand extension gate
This commit is contained in:
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user