diff --git a/data_only_viz/action_head_pub.py b/data_only_viz/action_head_pub.py index 33256fb..147f56c 100644 --- a/data_only_viz/action_head_pub.py +++ b/data_only_viz/action_head_pub.py @@ -186,6 +186,7 @@ class ActionHeadPublisher(threading.Thread): ratio_on=float(os.environ.get("PINCH_RATIO_ON", "0.45")), ratio_off=float(os.environ.get("PINCH_RATIO_OFF", "0.65")), refractory_ms=float(os.environ.get("PINCH_REFRACTORY_MS", "250")), + margin=float(os.environ.get("PINCH_MARGIN", "0.20")), ) self._finger_dbg = os.environ.get("FINGER_DEBUG", "0") not in ( "0", "", "false", "False", diff --git a/data_only_viz/finger_strike.py b/data_only_viz/finger_strike.py index 1d41fd1..8f08efb 100644 --- a/data_only_viz/finger_strike.py +++ b/data_only_viz/finger_strike.py @@ -149,10 +149,15 @@ class PinchDetector: """ def __init__(self, ratio_on: float = 0.45, ratio_off: float = 0.65, - refractory_ms: float = 250.0, history_slots: int = 2) -> None: + refractory_ms: float = 250.0, history_slots: int = 2, + margin: float = 0.20) -> None: self.ratio_on = ratio_on self.ratio_off = ratio_off self.refractory_s = refractory_ms / 1000.0 + # Only the single nearest fingertip may engage, and only if it is at + # least `margin` (in size-normalized units) nearer than the runner-up. + # Rejects the adjacent-finger ambiguity when fingers curl together. + self.margin = margin self._state = [[_PinchState() for _ in range(4)] for _ in range(history_slots)] @@ -169,17 +174,27 @@ class PinchDetector: my = _finite(_coord(lm[MIDDLE_MCP], "y", 1), 0.5) size = math.hypot(mx - wx, my - wy) size = size if size > 1e-4 else 1e-4 - for i, tip_idx in enumerate(PINCH_TIPS): + ratios = [] + 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) - ratio = math.hypot(fx - tx, fy - ty) / size + ratios.append(math.hypot(fx - tx, fy - ty) / 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] + winner = nearest if ( + ratios[nearest] < self.ratio_on + and (ratios[runner] - ratios[nearest]) >= self.margin + ) else -1 + for i in range(4): st = self._state[slot][i] if st.engaged: - if ratio > self.ratio_off: + # release when this finger opens, or another finger took over. + if i != winner or ratios[i] > 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: + 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)) diff --git a/data_only_viz/tests/test_finger_strike.py b/data_only_viz/tests/test_finger_strike.py index 8169cbb..b6b64b2 100644 --- a/data_only_viz/tests/test_finger_strike.py +++ b/data_only_viz/tests/test_finger_strike.py @@ -144,3 +144,37 @@ def test_pinch_hand_disappear_emits_release(): 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 + + +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), {})], 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) + 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), {})], 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) + assert ev == []