From ea42ba4bb36c366d386cdc25ba9c699239dec394 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=27=C3=A9lectron=20rare?= <108685187+electron-rare@users.noreply.github.com> Date: Thu, 2 Jul 2026 09:14:18 +0200 Subject: [PATCH] feat(pose): pinch engage debounce --- data_only_viz/finger_strike.py | 22 ++++++++++----- data_only_viz/tests/test_finger_strike.py | 33 +++++++++++++++++++++++ 2 files changed, 49 insertions(+), 6 deletions(-) diff --git a/data_only_viz/finger_strike.py b/data_only_viz/finger_strike.py index 654f0af..af260ea 100644 --- a/data_only_viz/finger_strike.py +++ b/data_only_viz/finger_strike.py @@ -133,11 +133,12 @@ class PinchEvent: class _PinchState: - __slots__ = ("engaged", "last_t") + __slots__ = ("engaged", "last_t", "qual") def __init__(self) -> None: self.engaged: bool = False self.last_t: float = -1e9 + self.qual: int = 0 # consecutive qualifying frames (debounce) class PinchDetector: @@ -217,13 +218,22 @@ class PinchDetector: st.engaged = False events.append(PinchEvent(hand=slot, finger=i + 1, state=0)) elif i == winner 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, state=1)) + # engage only after debounce_frames consecutive qualifying + # frames; release below stays edge-immediate. + st.qual += 1 + if st.qual >= self.debounce_frames: + st.engaged = True + st.last_t = t_now + st.qual = 0 + events.append(PinchEvent(hand=slot, finger=i + 1, state=1)) + else: + st.qual = 0 for slot in range(len(self._state)): if slot not in present: for i in range(4): - if self._state[slot][i].engaged: - self._state[slot][i].engaged = False + st = self._state[slot][i] + st.qual = 0 + if st.engaged: + st.engaged = False events.append(PinchEvent(hand=slot, finger=i + 1, state=0)) return events diff --git a/data_only_viz/tests/test_finger_strike.py b/data_only_viz/tests/test_finger_strike.py index 94cc536..f70dec4 100644 --- a/data_only_viz/tests/test_finger_strike.py +++ b/data_only_viz/tests/test_finger_strike.py @@ -221,3 +221,36 @@ def test_extension_gate_defeat_reproduces_old_behavior(): ext_min=0) ev = det.step([_relaxed_hand()], 0.1) assert len(ev) == 1 and ev[0].state == 1 + + +def test_debounce_delays_engage_to_nth_frame(): + det = PinchDetector(ratio_on=0.45, ratio_off=0.65, refractory_ms=0, + debounce_frames=3) + det.step([_pinch_hand(*_OPEN)], 0.00) + a = det.step([_pinch_hand(*_PINCH)], 0.10) # qualifying 1 + b = det.step([_pinch_hand(*_PINCH)], 0.13) # qualifying 2 + c = det.step([_pinch_hand(*_PINCH)], 0.16) # qualifying 3 -> engage + assert a == [] and b == [] + assert len(c) == 1 and c[0].state == 1 and c[0].finger == 1 + + +def test_debounce_jitter_resets_counter(): + det = PinchDetector(ratio_on=0.45, ratio_off=0.65, refractory_ms=0, + debounce_frames=3) + det.step([_pinch_hand(*_PINCH)], 0.00) # qualifying 1 + det.step([_pinch_hand(*_PINCH)], 0.03) # qualifying 2 + det.step([_pinch_hand(*_OPEN)], 0.06) # jitter -> counter resets + a = det.step([_pinch_hand(*_PINCH)], 0.09) # qualifying 1 again + b = det.step([_pinch_hand(*_PINCH)], 0.12) # qualifying 2 + assert a == [] and b == [] + + +def test_release_immediate_after_debounced_engage(): + det = PinchDetector(ratio_on=0.45, ratio_off=0.65, refractory_ms=0, + debounce_frames=3) + det.step([_pinch_hand(*_PINCH)], 0.00) + det.step([_pinch_hand(*_PINCH)], 0.03) + eng = det.step([_pinch_hand(*_PINCH)], 0.06) # 3rd frame -> engage + rel = det.step([_pinch_hand(*_OPEN)], 0.09) # very next frame + assert len(eng) == 1 and eng[0].state == 1 + assert len(rel) == 1 and rel[0].state == 0