feat(pose): pinch engage/release edges + launcher
PinchDetector now emits state=1 on engage and state=0 on release (and on hand-disappear). send_pinch appends the state as the 4th OSC arg so SC can distinguish hold from lift. Launcher switches from FINGER_PIANO to PINCH_ENABLE to gate only the pinch detector.
This commit is contained in:
@@ -129,6 +129,7 @@ class FingerStrikeDetector:
|
||||
class PinchEvent:
|
||||
hand: int # 0 = left slot (leftmost cx), 1 = right slot
|
||||
finger: int # 1..4 = index, middle, ring, pinky (thumb is trigger)
|
||||
state: int = 1 # 1 = engage edge, 0 = release edge
|
||||
|
||||
|
||||
class _PinchState:
|
||||
@@ -176,13 +177,16 @@ class PinchDetector:
|
||||
if st.engaged:
|
||||
if ratio > self.ratio_off:
|
||||
st.engaged = False
|
||||
events.append(PinchEvent(hand=slot, finger=i + 1, state=0))
|
||||
elif ratio < self.ratio_on and (
|
||||
t_now - st.last_t) >= self.refractory_s:
|
||||
st.engaged = True
|
||||
st.last_t = t_now
|
||||
events.append(PinchEvent(hand=slot, finger=i + 1))
|
||||
events.append(PinchEvent(hand=slot, finger=i + 1, state=1))
|
||||
for slot in range(len(self._state)):
|
||||
if slot not in present:
|
||||
for i in range(4):
|
||||
self._state[slot][i].engaged = False
|
||||
if self._state[slot][i].engaged:
|
||||
self._state[slot][i].engaged = False
|
||||
events.append(PinchEvent(hand=slot, finger=i + 1, state=0))
|
||||
return events
|
||||
|
||||
@@ -398,8 +398,8 @@ class PoseSoundBridge:
|
||||
self._vj("/pose/finger", args)
|
||||
|
||||
def send_pinch(self, ev) -> None:
|
||||
"""Emit one thumb-to-finger pinch event (clip toggle trigger)."""
|
||||
args = [0, int(ev.hand), int(ev.finger)]
|
||||
"""Emit a thumb-to-finger pinch edge (state 1=engage, 0=release)."""
|
||||
args = [0, int(ev.hand), int(ev.finger), int(getattr(ev, "state", 1))]
|
||||
try:
|
||||
self._client.send_message("/pose/pinch", args)
|
||||
except OSError:
|
||||
|
||||
@@ -123,3 +123,24 @@ def test_pinch_refractory_blocks():
|
||||
b = det.step([_pinch_hand(*_PINCH)], 0.10) # within 200 ms -> blocked
|
||||
assert len(a) == 1
|
||||
assert b == []
|
||||
|
||||
|
||||
def test_pinch_engage_release_emits_both_edges():
|
||||
"""Engage emits state=1; subsequent open emits state=0 (same finger)."""
|
||||
det = PinchDetector(ratio_on=0.45, ratio_off=0.65, refractory_ms=0)
|
||||
det.step([_pinch_hand(*_OPEN)], 0.0)
|
||||
engage = det.step([_pinch_hand(*_PINCH)], 0.1) # engage edge
|
||||
release = det.step([_pinch_hand(*_OPEN)], 0.2) # release edge
|
||||
assert len(engage) == 1 and engage[0].state == 1
|
||||
assert len(release) == 1 and release[0].state == 0
|
||||
assert release[0].hand == 0 and release[0].finger == 1
|
||||
|
||||
|
||||
def test_pinch_hand_disappear_emits_release():
|
||||
"""When an engaged hand disappears, a release edge is synthesised."""
|
||||
det = PinchDetector(ratio_on=0.45, ratio_off=0.65, refractory_ms=0)
|
||||
det.step([_pinch_hand(*_OPEN)], 0.0)
|
||||
engage = det.step([_pinch_hand(*_PINCH)], 0.1) # engage
|
||||
release = det.step([], 0.2) # hand gone
|
||||
assert len(engage) == 1 and engage[0].state == 1
|
||||
assert len(release) == 1 and release[0].state == 0
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Tests for PoseSoundBridge.send_finger."""
|
||||
"""Tests for PoseSoundBridge.send_finger and send_pinch."""
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
@@ -34,3 +34,38 @@ def test_send_finger_emits_expected_osc():
|
||||
assert isinstance(args[0], int) and not isinstance(args[0], bool)
|
||||
assert isinstance(args[1], int) and isinstance(args[2], int)
|
||||
assert all(isinstance(a, float) for a in args[3:7])
|
||||
|
||||
|
||||
def test_send_pinch_emits_state_as_4th_arg():
|
||||
"""send_pinch must put the edge state (1=engage, 0=release) as 4th arg."""
|
||||
b = _bridge()
|
||||
|
||||
class _FakePinchEvent:
|
||||
hand = 0
|
||||
finger = 1
|
||||
state = 1
|
||||
|
||||
b.send_pinch(_FakePinchEvent())
|
||||
b._client.send_message.assert_called_once()
|
||||
addr, args = b._client.send_message.call_args[0]
|
||||
assert addr == "/pose/pinch"
|
||||
assert args[0] == 0 # pid
|
||||
assert args[1] == 0 # hand
|
||||
assert args[2] == 1 # finger
|
||||
assert args[3] == 1 # state (engage edge)
|
||||
assert len(args) == 4
|
||||
|
||||
|
||||
def test_send_pinch_release_state_is_zero():
|
||||
"""Release edge: state=0 appears as 4th arg."""
|
||||
b = _bridge()
|
||||
|
||||
class _FakeRelease:
|
||||
hand = 1
|
||||
finger = 2
|
||||
state = 0
|
||||
|
||||
b.send_pinch(_FakeRelease())
|
||||
addr, args = b._client.send_message.call_args[0]
|
||||
assert addr == "/pose/pinch"
|
||||
assert args[3] == 0 # state = release
|
||||
|
||||
@@ -20,10 +20,9 @@ export PATH=/opt/homebrew/bin:$PATH
|
||||
# delegate has headroom and stays fluid AND stable. Override with
|
||||
# MEDIAPIPE_DELEGATE=gpu only for short sessions where you accept the crash risk.
|
||||
export MEDIAPIPE_DELEGATE="${MEDIAPIPE_DELEGATE:-cpu}"
|
||||
# Finger pinches drive the matrix global actions (8 slots). Enabling the pinch
|
||||
# detector also enables the air-piano finger-strike detector, inert unless a
|
||||
# voice has a finger binding (none by default).
|
||||
export FINGER_PIANO="${FINGER_PIANO:-1}"
|
||||
# Finger pinches drive the matrix global actions (8 slots). PINCH_ENABLE gates
|
||||
# ONLY the pinch detector; the air-piano finger-strike (FINGER_PIANO) stays off.
|
||||
export PINCH_ENABLE="${PINCH_ENABLE:-1}"
|
||||
export POSE_FILTER=median+kalman+lookahead+ik+arkit_fuse
|
||||
# CONCERT_MIRROR=0 in the environment disables the video mirror.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user