Files
AV-Live/data_only_viz/tests/test_finger_strike.py
T
L'électron rare 32a722e281
CI build oscope-of / build-check (push) Has been cancelled
feat(viz): gesture status panel indicators
Add gesture_slot_status [0..3] to State (absent/detected/armed/engaged).
PinchDetector.engaged_slots() exposes per-slot pinch state. Publisher
writes status after each tick via _update_gesture_slot_status().
Renderer draws panel frame always: dim (pid7 conf=0.3) when absent,
normal (pid7) detected, pid8 armed, pid9 double-stroke engaged.
2026-07-02 15:16:14 +02:00

366 lines
15 KiB
Python

"""Tests for FingerStrikeDetector (air-piano strike detection)."""
from __future__ import annotations
from data_only_viz.finger_strike import (
FingerStrikeDetector,
PinchDetector,
StrikeEvent,
FINGERTIPS,
FINGER_BASES,
)
def _hand(tip_y_by_finger: dict[int, float], base_y: float = 0.4,
cx: float = 0.3) -> list[list[float]]:
"""Build a 21-landmark hand. Every base knuckle sits at base_y; each
fingertip sits at base_y unless overridden in tip_y_by_finger (keyed by
finger 0..4). x is set near cx so L/R slotting is deterministic."""
lm = [[cx, base_y, 0.0] for _ in range(21)]
for f, base_idx in enumerate(FINGER_BASES):
lm[base_idx] = [cx, base_y, 0.0]
for f, tip_idx in enumerate(FINGERTIPS):
ty = tip_y_by_finger.get(f, base_y)
lm[tip_idx] = [cx, ty, 0.0]
return lm
def test_downward_spike_fires_exactly_one_strike():
det = FingerStrikeDetector(vel_thresh=0.02, refractory_ms=120.0)
# frame 0: neutral (primes prev), index tip level with base
det.step([_hand({1: 0.40}), None], t_now=0.00)
# frame 1: index tip drops 0.06 below -> downward velocity 0.06 > thresh
e1 = det.step([_hand({1: 0.46}), None], t_now=0.04)
# frame 2: tip stays down -> velocity ~0, must NOT refire
e2 = det.step([_hand({1: 0.46}), None], t_now=0.08)
strikes = e1 + e2
assert len(strikes) == 1
assert strikes[0].finger == 1
assert strikes[0].hand == 0
assert strikes[0].strike_speed > 0.0
def test_whole_hand_translation_does_not_fire():
det = FingerStrikeDetector(vel_thresh=0.02)
out = []
# tip and base move down together each frame -> relative y constant
for i, by in enumerate((0.40, 0.50, 0.60, 0.70)):
out += det.step([_hand({1: by}, base_y=by), None], t_now=i * 0.04)
assert out == []
def test_refractory_blocks_second_strike():
det = FingerStrikeDetector(vel_thresh=0.02, refractory_ms=120.0)
det.step([_hand({1: 0.40}), None], t_now=0.00) # prime
a = det.step([_hand({1: 0.46}), None], t_now=0.02) # strike 1
det.step([_hand({1: 0.40}), None], t_now=0.04) # lift -> rearm
b = det.step([_hand({1: 0.46}), None], t_now=0.06) # within 120 ms -> blocked
assert len(a) == 1
assert b == []
def test_strike_speed_scales_with_velocity():
soft = FingerStrikeDetector(vel_thresh=0.02, speed_scale=0.10)
hard = FingerStrikeDetector(vel_thresh=0.02, speed_scale=0.10)
soft.step([_hand({1: 0.40}), None], t_now=0.0)
hard.step([_hand({1: 0.40}), None], t_now=0.0)
s = soft.step([_hand({1: 0.44}), None], t_now=0.04) # delta 0.04
h = hard.step([_hand({1: 0.50}), None], t_now=0.04) # delta 0.10
assert h[0].strike_speed > s[0].strike_speed
def _pinch_hand(thumb_xy, index_xy):
"""21-kp hand with fixed wrist & middle-MCP (hand size = 0.3). Middle/
ring/pinky tips are parked far from the thumb so only the thumb-index
pair can pinch."""
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 -> size 0.3
lm[4] = [thumb_xy[0], thumb_xy[1], 0.0] # THUMB_TIP
lm[8] = [index_xy[0], index_xy[1], 0.0] # INDEX_TIP
lm[12] = [0.9, 0.5, 0.0] # MIDDLE_TIP (far)
lm[16] = [0.9, 0.6, 0.0] # RING_TIP (far)
lm[20] = [0.9, 0.7, 0.0] # LITTLE_TIP (far)
return lm
_OPEN = ((0.2, 0.5), (0.8, 0.5)) # thumb-index dist 0.6 -> ratio 2.0
_PINCH = ((0.5, 0.5), (0.52, 0.5)) # dist 0.02 -> ratio 0.067
def test_pinch_fires_on_thumb_index_contact():
det = PinchDetector(ratio_on=0.45, ratio_off=0.65)
assert det.step([_pinch_hand(*_OPEN), None], 0.0) == []
ev = det.step([_pinch_hand(*_PINCH), None], 0.1)
assert len(ev) == 1
assert ev[0].finger == 1 # index = finger 1
assert ev[0].hand == 0
def test_pinch_does_not_refire_while_held():
det = PinchDetector(ratio_on=0.45, ratio_off=0.65, refractory_ms=0)
det.step([_pinch_hand(*_OPEN), None], 0.0)
a = det.step([_pinch_hand(*_PINCH), None], 0.1) # fire
b = det.step([_pinch_hand(*_PINCH), None], 0.2) # still held -> no refire
assert len(a) == 1
assert b == []
def test_pinch_rearms_after_release():
det = PinchDetector(ratio_on=0.45, ratio_off=0.65, refractory_ms=0)
det.step([_pinch_hand(*_OPEN), None], 0.0)
a = det.step([_pinch_hand(*_PINCH), None], 0.1) # fire
det.step([_pinch_hand(*_OPEN), None], 0.2) # release (ratio > off)
c = det.step([_pinch_hand(*_PINCH), None], 0.3) # pinch again -> fire
assert len(a) == 1
assert len(c) == 1
def test_pinch_refractory_blocks():
det = PinchDetector(ratio_on=0.45, ratio_off=0.65, refractory_ms=200)
det.step([_pinch_hand(*_OPEN), None], 0.0)
a = det.step([_pinch_hand(*_PINCH), None], 0.05) # fire t=0.05
det.step([_pinch_hand(*_OPEN), None], 0.08) # release
b = det.step([_pinch_hand(*_PINCH), None], 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), None], 0.0)
engage = det.step([_pinch_hand(*_PINCH), None], 0.1) # engage edge
release = det.step([_pinch_hand(*_OPEN), None], 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), None], 0.0)
engage = det.step([_pinch_hand(*_PINCH), None], 0.1) # engage
release = det.step([None, None], 0.2) # hand gone
assert len(engage) == 1 and engage[0].state == 1
assert len(release) == 1 and release[0].state == 0
def _pinch_hand_multi(thumb_xy, tips):
"""21-kp hand (size 0.3); `tips` maps finger 0..3 (index/middle/ring/pinky)
-> (x,y) for that fingertip. Unlisted fingertips are parked far from thumb."""
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 -> size 0.3
lm[4] = [thumb_xy[0], thumb_xy[1], 0.0] # THUMB_TIP
far = [(0.9, 0.5), (0.9, 0.6), (0.9, 0.7), (0.9, 0.8)]
for i, idx in enumerate((8, 12, 16, 20)): # PINCH_TIPS
x, y = tips.get(i, far[i])
lm[idx] = [x, y, 0.0]
return lm
def test_pinch_closest_finger_wins():
# index ratio ~0.067, middle ratio ~0.33 -> margin 0.27 >= 0.20 -> index only.
det = PinchDetector(ratio_on=0.45, ratio_off=0.65, refractory_ms=0)
det.step([_pinch_hand_multi((0.5, 0.5), {}), None], 0.0) # open
ev = det.step([_pinch_hand_multi((0.5, 0.5),
{0: (0.52, 0.5), 1: (0.60, 0.5)}), None], 0.1)
assert len(ev) == 1
assert ev[0].finger == 1 and ev[0].state == 1 # index only
def test_pinch_ambiguous_adjacent_fires_nothing():
# index ratio ~0.067, middle ratio ~0.10 -> margin 0.033 < 0.20 -> no winner.
# (both are below ratio_on, so the OLD per-finger logic would fire both.)
det = PinchDetector(ratio_on=0.45, ratio_off=0.65, refractory_ms=0)
det.step([_pinch_hand_multi((0.5, 0.5), {}), None], 0.0) # open
ev = det.step([_pinch_hand_multi((0.5, 0.5),
{0: (0.52, 0.5), 1: (0.53, 0.5)}), None], 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(), None], 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), None], 0.0)
ev = det.step([_pinch_hand(*_PINCH), None], 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(), None], 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), None], 0.00)
a = det.step([_pinch_hand(*_PINCH), None], 0.10) # qualifying 1
b = det.step([_pinch_hand(*_PINCH), None], 0.13) # qualifying 2
c = det.step([_pinch_hand(*_PINCH), None], 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), None], 0.00) # qualifying 1
det.step([_pinch_hand(*_PINCH), None], 0.03) # qualifying 2
det.step([_pinch_hand(*_OPEN), None], 0.06) # jitter -> counter resets
a = det.step([_pinch_hand(*_PINCH), None], 0.09) # qualifying 1 again
b = det.step([_pinch_hand(*_PINCH), None], 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), None], 0.00)
det.step([_pinch_hand(*_PINCH), None], 0.03)
eng = det.step([_pinch_hand(*_PINCH), None], 0.06) # 3rd frame -> engage
rel = det.step([_pinch_hand(*_OPEN), None], 0.09) # very next frame
assert len(eng) == 1 and eng[0].state == 1
assert len(rel) == 1 and rel[0].state == 0
# ---------------------------------------------------------------------------
# FingerStrikeDetector.reset_slot — phantom-strike prevention
# ---------------------------------------------------------------------------
def _strike_hand(tip_y: float, base_y: float = 0.4, cx: float = 0.3) -> list:
"""Hand where index finger tip_y controls the relative position."""
return _hand({1: tip_y}, base_y=base_y, cx=cx)
def test_reset_slot_prevents_phantom_strike_on_reappear():
"""After reset_slot, the first real-hand frame primes without firing.
Scenario: prime detector, hold the last hand (frozen coords),
then reset_slot before the real hand returns with a large delta.
Expected: no strike on reappear (first frame after reset is a prime).
"""
det = FingerStrikeDetector(vel_thresh=0.02, refractory_ms=0)
# Prime: index tip at 0.40
det.step([_strike_hand(0.40), None], t_now=0.00)
# "Held" frames: stabilizer would replay the same hand — same coords,
# vel=0, no strike, but prev_rel stays at 0.40.
det.step([_strike_hand(0.40), None], t_now=0.03)
det.step([_strike_hand(0.40), None], t_now=0.06)
# Simulate held -> real transition: reset before consuming new position.
det.reset_slot(0)
# Real hand reappears with a large downward delta (0.40 -> 0.70 = 0.30 >> thresh).
events = det.step([_strike_hand(0.70), None], t_now=0.09)
assert events == [], (
"after reset_slot, first frame should prime without firing "
f"(got {events})"
)
def test_without_reset_big_delta_fires_strike():
"""Control: WITHOUT reset_slot, the same scenario fires a phantom strike."""
det = FingerStrikeDetector(vel_thresh=0.02, refractory_ms=0)
det.step([_strike_hand(0.40), None], t_now=0.00) # prime
det.step([_strike_hand(0.40), None], t_now=0.03) # held
det.step([_strike_hand(0.40), None], t_now=0.06) # held
# No reset — prev_rel is still 0.40 → delta 0.30 > thresh → fires
events = det.step([_strike_hand(0.70), None], t_now=0.09)
assert len(events) > 0, "without reset_slot, phantom strike must fire (control)"
def test_reset_slot_clears_all_five_fingers():
"""reset_slot clears every finger state for the specified slot."""
det = FingerStrikeDetector(vel_thresh=0.02, refractory_ms=0)
# Prime all 5 fingers by stepping a neutral hand
neutral = _hand({f: 0.40 for f in range(5)}, base_y=0.40)
det.step([neutral, None], t_now=0.00)
# All prev_rel should be non-None now
for f in range(5):
assert det._state[0][f].prev_rel is not None
det.reset_slot(0)
for f in range(5):
assert det._state[0][f].prev_rel is None, f"finger {f} prev_rel not cleared"
assert det._state[0][f].armed is True, f"finger {f} not re-armed"
def test_reset_slot_does_not_affect_other_slot():
"""Resetting slot 0 leaves slot 1 state intact."""
det = FingerStrikeDetector(vel_thresh=0.02, refractory_ms=0)
h = _hand({1: 0.40})
det.step([h, h], t_now=0.00) # prime both slots
# slot 1 finger 1 should have a non-None prev_rel
assert det._state[1][1].prev_rel is not None
det.reset_slot(0)
# slot 1 unchanged
assert det._state[1][1].prev_rel is not None, "slot 1 must be unaffected"
# ---------------------------------------------------------------------------
# PinchDetector.engaged_slots
# ---------------------------------------------------------------------------
def test_engaged_slots_both_false_initially():
"""Fresh PinchDetector: no pinch engaged in either slot."""
det = PinchDetector()
assert det.engaged_slots() == (False, False)
def test_engaged_slots_true_for_engaged_slot():
"""engaged_slots() reflects per-slot engaged state correctly."""
det = PinchDetector()
det._state[0][0].engaged = True # slot 0, index finger
s0, s1 = det.engaged_slots()
assert s0 is True, "slot 0 has an engaged finger"
assert s1 is False, "slot 1 has no engaged finger"
def test_engaged_slots_false_after_release():
"""Clearing engaged flag → engaged_slots returns False for that slot."""
det = PinchDetector()
det._state[1][2].engaged = True # slot 1, ring finger
assert det.engaged_slots()[1] is True
det._state[1][2].engaged = False
assert det.engaged_slots() == (False, False)