refactor(pose): detectors consume routed slots

Convert all four consumers to accept pre-routed [L|None, R|None]:
- finger_strike: remove order_hands_by_cx + _slot_hands; step()
  iterates enumerate(slotted), None slot resets state / synthesizes
  release edges in-place.
- hand_features: step(hands, chirality=None) delegates to route_hands;
  speed buffer indexed by slot, not cx order.
- action_head_pub: _pick_hands returns chirality; _emit_fingers/
  _emit_pinch call route_hands with HAND_SWAP_LR/HAND_NEAR_MIN;
  _emit_hands plumbs chirality to HandFeatureExtractor.
- renderer: panels block replaced with route_hands call; persistence
  gate + hand_plausible mask applied before routing so a ghost hand
  cannot steal a slot; HAND_SWAP_LR read at init.
Test updates: [hand, None] slot lists in test_finger_strike;
_hand() fixture in test_action_head_finger_emit gains realistic size
so near_min=0.10 gate passes.
This commit is contained in:
L'électron rare
2026-07-02 10:55:32 +02:00
parent 9ee7990dcd
commit 366d916e9d
6 changed files with 175 additions and 175 deletions
+33 -11
View File
@@ -154,11 +154,13 @@ class ActionHeadPublisher(threading.Thread):
"""Lock state, extract hand features, emit /pose/hands once per tick."""
with self.state.lock():
hands = list(getattr(self.state, "persons_hands", None) or [])
chirality = list(getattr(self.state, "persons_hands_chirality", None) or [])
if not hands and getattr(self.state, "hands_present", False):
lkp = getattr(self.state, "left_hand_kp", None)
rkp = getattr(self.state, "right_hand_kp", None)
hands = [h for h in (lkp, rkp) if h is not None and len(h) > 0]
feats = self._hand_ext.step(hands)
chirality = []
feats = self._hand_ext.step(hands, chirality or None)
with self.state.lock():
self.state.hand_feats = feats
self.bridge.send_hands(feats, t_now)
@@ -198,28 +200,42 @@ class ActionHeadPublisher(threading.Thread):
# "iphone" / "mediapipe".
self._finger_source = os.environ.get("FINGER_SOURCE", "auto").lower()
self._fdbg_n = 0
# Safety knob: swap the two routed slots if the iPhone app's camera
# config inverts Vision chirality (fix live without redeploy).
self._hand_swap_lr = os.environ.get("HAND_SWAP_LR", "0") not in (
"0", "", "false", "False",
)
self._hand_near_min = float(os.environ.get("HAND_NEAR_MIN", "0.10"))
def _pick_hands(self) -> tuple[list, str, bool]:
"""Return (hands, source_label, ip_fresh) per FINGER_SOURCE policy."""
def _pick_hands(self) -> tuple[list, list, str, bool]:
"""Return (hands, chirality, source_label, ip_fresh) per FINGER_SOURCE policy."""
with self.state.lock():
mp_hands = list(getattr(self.state, "persons_hands", None) or [])
ip_hands = list(getattr(self.state, "persons_hands_iphone", None) or [])
ip_t = getattr(self.state, "persons_hands_iphone_t", 0.0)
ip_chir = list(getattr(self.state, "persons_hands_chirality", None) or [])
ip_fresh = bool(ip_hands) and (time.perf_counter() - ip_t) < 0.5
src = getattr(self, "_finger_source", "auto")
if src == "iphone":
return ip_hands, "iphone", ip_fresh
return ip_hands, ip_chir, "iphone", ip_fresh
if src == "mediapipe":
return mp_hands, "mediapipe", ip_fresh
return (ip_hands, "iphone", ip_fresh) if ip_fresh else (
mp_hands, "mediapipe", ip_fresh)
return mp_hands, [], "mediapipe", ip_fresh
if ip_fresh:
return ip_hands, ip_chir, "iphone", ip_fresh
return mp_hands, [], "mediapipe", ip_fresh
def _emit_fingers(self, t_now: float) -> None:
"""Detect finger strikes (air-piano) and emit /pose/finger."""
if not getattr(self, "_finger_enabled", False):
return
hands, used, ip_fresh = self._pick_hands()
events = self._finger_det.step(hands, t_now)
from data_only_viz.hand_slots import route_hands
hands, chirality, used, ip_fresh = self._pick_hands()
slotted = route_hands(
hands, chirality or None,
swap=getattr(self, "_hand_swap_lr", False),
near_min=getattr(self, "_hand_near_min", 0.10),
)
events = self._finger_det.step(slotted, t_now)
if getattr(self, "_finger_dbg", False):
self._fdbg_n += 1
if events or self._fdbg_n % 30 == 0:
@@ -233,8 +249,14 @@ class ActionHeadPublisher(threading.Thread):
/pose/pinch. Gated by PINCH_ENABLE, independently of the air-piano."""
if not getattr(self, "_pinch_enabled", False):
return
hands, used, _ip = self._pick_hands()
for ev in self._pinch_det.step(hands, t_now):
from data_only_viz.hand_slots import route_hands
hands, chirality, used, _ip = self._pick_hands()
slotted = route_hands(
hands, chirality or None,
swap=getattr(self, "_hand_swap_lr", False),
near_min=getattr(self, "_hand_near_min", 0.10),
)
for ev in self._pinch_det.step(slotted, t_now):
if getattr(self, "_finger_dbg", False):
LOG.info("pinch dbg: src=%s hand=%d finger=%d",
used, ev.hand, ev.finger)
+37 -63
View File
@@ -1,9 +1,12 @@
"""Air-piano finger strike detection from raw MediaPipe hand joints.
"""Air-piano finger strike detection from pre-routed hand slots.
Mirrors hand_features.py conventions (L/R slotting by cx, finite-guarded
coordinate extraction). A "strike" is a fast downward motion of a fingertip
RELATIVE to its base knuckle, so translating the whole hand does not fire all
fingers. Output feeds the OSC /pose/finger route consumed by SuperCollider.
A "strike" is a fast downward motion of a fingertip RELATIVE to its base
knuckle, so translating the whole hand does not fire all fingers. Output feeds
the OSC /pose/finger route consumed by SuperCollider.
Consumers call route_hands() (hand_slots) once per tick and pass the resulting
2-element [left|None, right|None] list to FingerStrikeDetector.step() and
PinchDetector.step(). slot 0 = left hand (matrix "MG"), slot 1 = right.
"""
from __future__ import annotations
@@ -24,26 +27,9 @@ MIDDLE_MCP: int = 9
PINCH_TIPS: tuple[int, ...] = (8, 12, 16, 20)
def order_hands_by_cx(hands: list, max_slots: int) -> list:
"""Return up to max_slots hand-landmark lists ordered leftmost-first
(slot 0 = L), matching HandFeatureExtractor / FingerStrikeDetector."""
cand = []
for lm in hands:
try:
if lm is None or len(lm) < 21:
continue
except TypeError:
continue
xs = [_finite(_coord(p, "x", 0), 0.5) for p in lm[:21]]
cx = _clamp(sum(xs) / len(xs), 0.0, 1.0)
cand.append((cx, lm))
cand.sort(key=lambda c: c[0])
return [lm for _cx, lm in cand[:max_slots]]
@dataclass
class StrikeEvent:
hand: int # 0 = left slot (leftmost cx), 1 = right slot
hand: int # 0 = left slot, 1 = right slot
finger: int # 0..4 = thumb, index, middle, ring, pinky
strike_speed: float
z: float
@@ -70,27 +56,19 @@ class FingerStrikeDetector:
self._state = [[_FingerState() for _ in range(5)]
for _ in range(history_slots)]
def _slot_hands(self, hands: list) -> list:
"""Validate and order hands leftmost-first (slot 0 = L), like
HandFeatureExtractor. Returns up to 2 (cx, landmarks) ordered."""
cand = []
for lm in hands:
try:
if lm is None or len(lm) < 21:
continue
except TypeError:
continue
xs = [_finite(_coord(p, "x", 0), 0.5) for p in lm[:21]]
cx = _clamp(sum(xs) / len(xs), 0.0, 1.0)
cand.append((cx, lm))
cand.sort(key=lambda c: c[0])
return cand[:len(self._state)]
def step(self, slotted: list, t_now: float) -> list[StrikeEvent]:
"""Process a pre-routed 2-slot hand list [left|None, right|None].
def step(self, hands: list, t_now: float) -> list[StrikeEvent]:
ordered = self._slot_hands(hands)
present = set(range(len(ordered)))
Absent slots (None) reset their finger state so re-entry does not
produce spurious velocity spikes.
"""
events: list[StrikeEvent] = []
for slot, (_cx, lm) in enumerate(ordered):
for slot, lm in enumerate(slotted):
if lm is None:
for f in range(5):
self._state[slot][f].prev_rel = None
self._state[slot][f].armed = True
continue
for f in range(5):
tip = lm[FINGERTIPS[f]]
base = lm[FINGER_BASES[f]]
@@ -116,18 +94,12 @@ class FingerStrikeDetector:
tipx=_finite(_coord(tip, "x", 0), 0.5),
tipy=tip_y,
))
# reset slots not present this tick so re-entry does not spike
for slot in range(len(self._state)):
if slot not in present:
for f in range(5):
self._state[slot][f].prev_rel = None
self._state[slot][f].armed = True
return events
@dataclass
class PinchEvent:
hand: int # 0 = left slot (leftmost cx), 1 = right slot
hand: int # 0 = left slot, 1 = right slot
finger: int # 1..4 = index, middle, ring, pinky (thumb is trigger)
state: int = 1 # 1 = engage edge, 0 = release edge
@@ -169,17 +141,27 @@ class PinchDetector:
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.)
# frames; release stays immediate. 1 = no debounce.
self.debounce_frames = max(1, int(debounce_frames))
self._state = [[_PinchState() for _ in range(4)]
for _ in range(history_slots)]
def step(self, hands: list, t_now: float) -> list[PinchEvent]:
ordered = order_hands_by_cx(hands, len(self._state))
present = set(range(len(ordered)))
def step(self, slotted: list, t_now: float) -> list[PinchEvent]:
"""Process a pre-routed 2-slot hand list [left|None, right|None].
Absent slots synthesise release edges for any engaged fingers and
reset debounce state.
"""
events: list[PinchEvent] = []
for slot, lm in enumerate(ordered):
for slot, lm in enumerate(slotted):
if lm is None:
for i in range(4):
st = self._state[slot][i]
st.qual = 0
if st.engaged:
st.engaged = False
events.append(PinchEvent(hand=slot, finger=i + 1, state=0))
continue
tx = _finite(_coord(lm[THUMB_TIP], "x", 0), 0.5)
ty = _finite(_coord(lm[THUMB_TIP], "y", 1), 0.5)
wx = _finite(_coord(lm[WRIST], "x", 0), 0.5)
@@ -228,12 +210,4 @@ class PinchDetector:
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):
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
+27 -21
View File
@@ -78,31 +78,37 @@ class HandFeatureExtractor:
buf.append((cx, cy))
return _clamp(spd, 0.0, 1.0)
def step(self, hands: list) -> dict:
feats = []
for lm in hands:
try:
valid = lm is not None and len(lm) >= 21
except TypeError:
continue
if valid:
feats.append(self._features(lm))
if not feats:
def step(self, hands: list, chirality=None) -> dict:
from data_only_viz.hand_slots import route_hands
left_lm, right_lm = route_hands(hands, chirality)
if left_lm is None and right_lm is None:
self._buf[0].clear()
self._buf[1].clear()
return {"L": None, "R": None, "dist": 0.0}
feats.sort(key=lambda f: f["cx"]) # leftmost first
left = feats[0]
right = feats[-1] if len(feats) > 1 else None
left["speed"] = self._speed(0, left["cx"], left["cy"])
out_l = left
out_l = None
out_r = None
dist = 0.0
if right is not None:
right["speed"] = self._speed(1, right["cx"], right["cy"])
out_r = right
dist = _clamp(math.hypot(right["cx"] - left["cx"],
right["cy"] - left["cy"]), 0.0, 1.0)
if left_lm is not None:
lf = self._features(left_lm)
lf["speed"] = self._speed(0, lf["cx"], lf["cy"])
out_l = lf
else:
self._buf[0].clear()
if right_lm is not None:
rf = self._features(right_lm)
rf["speed"] = self._speed(1, rf["cx"], rf["cy"])
out_r = rf
else:
self._buf[1].clear()
dist = 0.0
if out_l is not None and out_r is not None:
dist = _clamp(
math.hypot(out_r["cx"] - out_l["cx"], out_r["cy"] - out_l["cy"]),
0.0, 1.0,
)
return {"L": out_l, "R": out_r, "dist": dist}
+24 -28
View File
@@ -53,6 +53,7 @@ from .hand_display import (
panel_frame,
panel_segments,
)
from .hand_slots import route_hands
from .mesh_topology import (
BODY_TRIANGLES,
FACE_TRIANGLES,
@@ -164,6 +165,11 @@ class MetalRenderer(NSObject):
self._hand_gate = HandPersistenceGate(
min_frames=int(os.environ.get("HAND_PERSIST_FRAMES", "3"))
)
# Safety knob: swap the two routed hand slots if the iPhone app's
# camera config inverts Vision chirality (fix live without redeploy).
self._hand_swap_lr = os.environ.get("HAND_SWAP_LR", "0") not in (
"0", "", "false", "False",
)
self._init_skel_cpu_buffer()
self._init_mesh_cpu_buffer()
self._build_pipelines()
@@ -485,39 +491,29 @@ class MetalRenderer(NSObject):
# even when use_arkit is True). Hands from s.persons_hands only.
if self._mp_bones is not None:
_body_b, _face_b, lhand_bones_p, _ = self._mp_bones
# Filter hands by persistence gate before routing to panels.
hands = [h for h, ok in zip(s.persons_hands, _hand_draw_flags) if ok]
# Filter hands by persistence gate + plausibility before routing
# to panels (a ghost hand must not steal a slot from a real one).
keep = [
ok and hand_plausible(h, conf_min=self._hand_conf_min)
for h, ok in zip(s.persons_hands, _hand_draw_flags)
]
gated_hands = [h for h, k in zip(s.persons_hands, keep) if k]
chir_src = getattr(s, "persons_hands_chirality", None) or []
# Keep chirality aligned with the gated hands (same filter mask).
if chir_src and len(chir_src) == len(s.persons_hands):
chir = [c for c, ok in zip(chir_src, _hand_draw_flags) if ok]
gated_chir = [c for c, k in zip(chir_src, keep) if k]
else:
chir = []
gated_chir = None
asp = float(s.width) / float(s.height) if s.width and s.height else 1.0
# Route hands to left/right panels via chirality when available,
# otherwise by screen position (mirror-aware).
left_kp = None
right_kp = None
if chir and len(chir) == len(hands):
# chirality: 0 = left hand, 1 = right hand
for h_kp, c in zip(hands, chir):
if c == 0 and left_kp is None:
left_kp = h_kp
elif c == 1 and right_kp is None:
right_kp = h_kp
else:
# Fallback: order plausible hands by screen cx, mirror-aware.
# Screen left has low cx normally; high cx when video is mirrored.
plausible = [
(h_kp, sum(p.x for p in h_kp) / len(h_kp))
for h_kp in hands
if hand_plausible(h_kp, conf_min=self._hand_conf_min)
]
plausible.sort(key=lambda t: -t[1] if mirror else t[1])
if plausible:
left_kp = plausible[0][0]
if len(plausible) >= 2:
right_kp = plausible[1][0]
# Route to L/R panels. mirror= passed for cx fallback path;
# no near_min — panels show far hands as user proximity feedback.
slotted = route_hands(
gated_hands, gated_chir,
mirror=mirror,
swap=self._hand_swap_lr,
)
left_kp, right_kp = slotted
# Draw frame (pid 7) then wireframe (pid 5 left / 6 right)
for side_name, h_kp, pid_s in (
@@ -18,6 +18,8 @@ class _FakeState:
def _hand(index_tip_y: float, base_y: float = 0.4):
lm = [[0.3, base_y, 0.0] for _ in range(21)]
lm[0] = [0.3, base_y + 0.15, 0.0] # wrist 0.15 below center -> hand_size 0.15
lm[9] = [0.3, base_y, 0.0] # middle-MCP at center -> size 0.15 > near_min
lm[FINGERTIPS[1]] = [0.3, index_tip_y, 0.0]
lm[FINGER_BASES[1]] = [0.3, base_y, 0.0]
return lm
+52 -52
View File
@@ -27,11 +27,11 @@ def _hand(tip_y_by_finger: dict[int, float], base_y: float = 0.4,
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})], t_now=0.00)
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})], t_now=0.04)
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})], t_now=0.08)
e2 = det.step([_hand({1: 0.46}), None], t_now=0.08)
strikes = e1 + e2
assert len(strikes) == 1
assert strikes[0].finger == 1
@@ -44,16 +44,16 @@ def test_whole_hand_translation_does_not_fire():
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)], t_now=i * 0.04)
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})], t_now=0.00) # prime
a = det.step([_hand({1: 0.46})], t_now=0.02) # strike 1
det.step([_hand({1: 0.40})], t_now=0.04) # lift -> rearm
b = det.step([_hand({1: 0.46})], t_now=0.06) # within 120 ms -> blocked
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 == []
@@ -61,10 +61,10 @@ def test_refractory_blocks_second_strike():
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})], t_now=0.0)
hard.step([_hand({1: 0.40})], t_now=0.0)
s = soft.step([_hand({1: 0.44})], t_now=0.04) # delta 0.04
h = hard.step([_hand({1: 0.50})], t_now=0.04) # delta 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
@@ -89,8 +89,8 @@ _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)], 0.0) == []
ev = det.step([_pinch_hand(*_PINCH)], 0.1)
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
@@ -98,29 +98,29 @@ def test_pinch_fires_on_thumb_index_contact():
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)], 0.0)
a = det.step([_pinch_hand(*_PINCH)], 0.1) # fire
b = det.step([_pinch_hand(*_PINCH)], 0.2) # still held -> no refire
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)], 0.0)
a = det.step([_pinch_hand(*_PINCH)], 0.1) # fire
det.step([_pinch_hand(*_OPEN)], 0.2) # release (ratio > off)
c = det.step([_pinch_hand(*_PINCH)], 0.3) # pinch again -> fire
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)], 0.0)
a = det.step([_pinch_hand(*_PINCH)], 0.05) # fire t=0.05
det.step([_pinch_hand(*_OPEN)], 0.08) # release
b = det.step([_pinch_hand(*_PINCH)], 0.10) # within 200 ms -> blocked
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 == []
@@ -128,9 +128,9 @@ def test_pinch_refractory_blocks():
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
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
@@ -139,9 +139,9 @@ def test_pinch_engage_release_emits_both_edges():
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
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
@@ -163,9 +163,9 @@ def _pinch_hand_multi(thumb_xy, tips):
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), {})], 0.0) # open
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)})], 0.1)
{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
@@ -174,9 +174,9 @@ 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), {})], 0.0) # open
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)})], 0.1)
{0: (0.52, 0.5), 1: (0.53, 0.5)}), None], 0.1)
assert ev == []
@@ -201,7 +201,7 @@ def test_relaxed_hand_rejected_by_extension_gate():
ext_min=2)
out = []
for i in range(10):
out += det.step([_relaxed_hand()], i * 0.033)
out += det.step([_relaxed_hand(), None], i * 0.033)
assert out == []
@@ -210,8 +210,8 @@ def test_open_hand_pinch_passes_extension_gate():
# (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)
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
@@ -219,17 +219,17 @@ 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)
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)], 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
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
@@ -237,20 +237,20 @@ def test_debounce_delays_engage_to_nth_frame():
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
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)], 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
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