diff --git a/data_only_viz/hand_slots.py b/data_only_viz/hand_slots.py new file mode 100644 index 0000000..98633fb --- /dev/null +++ b/data_only_viz/hand_slots.py @@ -0,0 +1,125 @@ +"""Hand L/R slotting: route raw hands to a fixed [left|None, right|None] pair. + +Chirality path (iPhone ARBodyTracker Vision): if `chirality` is aligned with +`hands` (same length), route chir==0 -> slot 0 (left), chir==1 -> slot 1 (right). +First match per slot wins; extras dropped. + +Fallback (chirality absent/misaligned): sort valid hands by screen cx ascending +(flipped when mirror=True) and assign leftmost to slot 0, next to slot 1. + +`swap=True`: swaps the two slots at the very end (safety knob: if the iPhone +app's camera config inverts Vision chirality, HAND_SWAP_LR=1 fixes the +direction live without redeploy). + +`near_min > 0`: after routing, replace a slot with None when the hand's +wrist-to-middle-MCP distance (normalised) is below `near_min`. +""" +from __future__ import annotations + +import math + + +# ---- micro-helpers (duplicated from hand_features to avoid circular import) --- + +def _finite(v: float, fallback: float) -> float: + return v if isinstance(v, float) and math.isfinite(v) else fallback + + +def _coord(p, attr: str, idx: int, fallback: float = 0.5) -> float: + if hasattr(p, attr): + return float(getattr(p, attr)) + try: + return float(p[idx]) + except (TypeError, IndexError): + return fallback + + +def _clamp(v: float, lo: float, hi: float) -> float: + return lo if v < lo else hi if v > hi else v + + +def _hand_size(hand) -> float: + """Wrist(0) -> middle-MCP(9) distance; works for both attr and list kp formats.""" + w = hand[0] + m = hand[9] + wx = _finite(_coord(w, "x", 0), 0.5) + wy = _finite(_coord(w, "y", 1), 0.5) + mx = _finite(_coord(m, "x", 0), 0.5) + my = _finite(_coord(m, "y", 1), 0.5) + return math.hypot(mx - wx, my - wy) + + +def _is_valid(hand) -> bool: + try: + return hand is not None and len(hand) >= 21 + except TypeError: + return False + + +def route_hands( + hands, + chirality=None, + *, + mirror: bool = False, + swap: bool = False, + near_min: float = 0.0, +) -> list: # always [left|None, right|None] + """Route hands to a 2-element [left, right] slot list. + + Parameters + ---------- + hands: raw hand list (any length, may contain None / short entries). + chirality: int list aligned 1-to-1 with `hands` (0=left, 1=right). + None / wrong length -> cx fallback. + mirror: flip cx for the fallback path so screen-left stays slot 0. + swap: swap the two slots at the very end (both paths). Safety knob + for a source whose Vision chirality arrives inverted. + near_min: replace a routed slot with None when hand_size < near_min. + """ + chir_aligned = ( + chirality is not None + and len(chirality) == len(hands) + and len(chirality) > 0 + ) + + # Build valid (chirality_value_or_None, hand) pairs — BEFORE filtering invalids + # so chirality[i] stays paired with hands[i] even when hands[j] is skipped. + valid_pairs: list[tuple] = [] + for i, hand in enumerate(hands): + if not _is_valid(hand): + continue + c = chirality[i] if chir_aligned else None + valid_pairs.append((c, hand)) + + slots: list = [None, None] + + if chir_aligned and any(c is not None for c, _ in valid_pairs): + # ---- Chirality path: chir 0 -> slot 0 (left), 1 -> slot 1 (right) ---- + for c, hand in valid_pairs: + target = c if c in (0, 1) else None + if target is not None and slots[target] is None: + slots[target] = hand + else: + # ---- cx fallback: leftmost on screen -> slot 0 ---- + def _cx(h): + xs = [_finite(_coord(p, "x", 0), 0.5) for p in h[:21]] + raw = _clamp(sum(xs) / len(xs), 0.0, 1.0) + return (1.0 - raw) if mirror else raw + + ordered = sorted((h for _, h in valid_pairs), key=_cx) + if ordered: + slots[0] = ordered[0] + if len(ordered) >= 2: + slots[1] = ordered[1] + + # ---- near_min gate (applied after routing, both paths) ---- + if near_min > 0.0: + for i in range(2): + if slots[i] is not None and _hand_size(slots[i]) < near_min: + slots[i] = None + + # ---- swap: exchange the two slots at the very end (safety knob) ---- + if swap: + slots[0], slots[1] = slots[1], slots[0] + + return slots diff --git a/data_only_viz/tests/test_hand_slots.py b/data_only_viz/tests/test_hand_slots.py new file mode 100644 index 0000000..850bd95 --- /dev/null +++ b/data_only_viz/tests/test_hand_slots.py @@ -0,0 +1,199 @@ +"""Tests for hand_slots.route_hands.""" +from __future__ import annotations + +import pytest +from data_only_viz.hand_slots import route_hands + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _hand(cx: float = 0.5, size: float = 0.15) -> list: + """21-landmark hand (list of [x, y, z]). Wrist at cx, middle-MCP 'size' above.""" + lm = [[cx, 0.5, 0.0] for _ in range(21)] + lm[0] = [cx, 0.6, 0.0] # wrist + lm[9] = [cx, 0.6 - size, 0.0] # middle-MCP -> hand_size == size + return lm + + +def _small_hand(cx: float = 0.5) -> list: + """Hand with size 0.02 (below default near_min 0.10).""" + return _hand(cx=cx, size=0.02) + + +# --------------------------------------------------------------------------- +# Basic chirality routing +# --------------------------------------------------------------------------- + +def test_chirality_routes_left_to_slot0(): + h_l = _hand(cx=0.8) # camera-right = user's left (mirror) + h_r = _hand(cx=0.2) + result = route_hands([h_l, h_r], [0, 1]) + assert result[0] is h_l + assert result[1] is h_r + + +def test_chirality_routes_right_to_slot1(): + h_r = _hand(cx=0.2) + result = route_hands([h_r], [1]) + assert result[0] is None + assert result[1] is h_r + + +def test_chirality_first_match_wins_per_slot(): + """Two hands with chirality==0: first wins, second dropped.""" + h1 = _hand(cx=0.3) + h2 = _hand(cx=0.7) + result = route_hands([h1, h2], [0, 0]) + assert result[0] is h1 + assert result[1] is None + + +# --------------------------------------------------------------------------- +# Chirality misaligned → cx fallback +# --------------------------------------------------------------------------- + +def test_chirality_misaligned_uses_cx_fallback(): + h_l = _hand(cx=0.2) + h_r = _hand(cx=0.8) + # chirality length 1 != hands length 2 → misaligned + result = route_hands([h_l, h_r], [0]) + # cx fallback: ascending cx order → cx=0.2 → slot 0, cx=0.8 → slot 1 + assert result[0] is h_l + assert result[1] is h_r + + +def test_chirality_none_uses_cx_fallback(): + h_l = _hand(cx=0.2) + h_r = _hand(cx=0.8) + result = route_hands([h_l, h_r], None) + assert result[0] is h_l + assert result[1] is h_r + + +# --------------------------------------------------------------------------- +# mirror flips fallback order +# --------------------------------------------------------------------------- + +def test_mirror_flips_cx_fallback_order(): + h_a = _hand(cx=0.2) # low cx = screen-left normally + h_b = _hand(cx=0.8) + # Without mirror: slot0=cx0.2, slot1=cx0.8 + plain = route_hands([h_a, h_b]) + assert plain[0] is h_a + # With mirror: flip cx → 1-0.2=0.8, 1-0.8=0.2 → sort ascending → cx0.8 goes slot0 + mirrored = route_hands([h_a, h_b], mirror=True) + assert mirrored[0] is h_b + assert mirrored[1] is h_a + + +# --------------------------------------------------------------------------- +# swap: exchanges the two slots at the very end (both paths) +# --------------------------------------------------------------------------- + +def test_swap_swaps_chirality_slots(): + h_l = _hand(cx=0.8) # Vision says left (chir=0) but source is inverted + h_r = _hand(cx=0.2) # Vision says right (chir=1) + result = route_hands([h_l, h_r], [0, 1], swap=True) + assert result[0] is h_r # slots exchanged at the end + assert result[1] is h_l + + +def test_swap_swaps_cx_fallback_slots(): + """swap=True also exchanges the slots on the cx fallback path.""" + h_a = _hand(cx=0.2) + h_b = _hand(cx=0.8) + result = route_hands([h_a, h_b], None, swap=True) + assert result[0] is h_b + assert result[1] is h_a + + +def test_swap_single_hand_moves_slot(): + """A lone chir==0 hand lands in slot 1 when swap is on.""" + h = _hand(cx=0.5) + result = route_hands([h], [0], swap=True) + assert result[0] is None + assert result[1] is h + + +# --------------------------------------------------------------------------- +# near_min gate +# --------------------------------------------------------------------------- + +def test_near_min_drops_small_hand(): + big = _hand(cx=0.3, size=0.15) + small = _hand(cx=0.7, size=0.02) + result = route_hands([big, small], near_min=0.10) + assert result[0] is big + assert result[1] is None # too small + + +def test_near_min_preserves_big_slot(): + big = _hand(cx=0.5, size=0.20) + result = route_hands([big], near_min=0.10) + assert result[0] is big + + +def test_near_min_drops_both_when_all_small(): + s1 = _small_hand(cx=0.3) + s2 = _small_hand(cx=0.7) + result = route_hands([s1, s2], near_min=0.10) + assert result == [None, None] + + +# --------------------------------------------------------------------------- +# Invalid hand filtering (alignment preserved) +# --------------------------------------------------------------------------- + +def test_invalid_hand_skipped_chirality_alignment_preserved(): + """An invalid hand at index 0 must not shift chirality for index 1.""" + bad = None # invalid + h_r = _hand(cx=0.5) + # chirality: bad=0(ignored), h_r=1(right) + result = route_hands([bad, h_r], [0, 1]) + assert result[0] is None + assert result[1] is h_r + + +def test_short_hand_rejected(): + short = [[0.5, 0.5, 0.0]] * 10 # <21 landmarks + h_ok = _hand(cx=0.5) + result = route_hands([short, h_ok], [0, 1]) + assert result[0] is None # short dropped + assert result[1] is h_ok + + +# --------------------------------------------------------------------------- +# Edge cases +# --------------------------------------------------------------------------- + +def test_empty_hands_returns_none_pair(): + assert route_hands([]) == [None, None] + + +def test_single_hand_no_chirality_slot0(): + h = _hand(cx=0.5) + result = route_hands([h]) + assert result[0] is h + assert result[1] is None + + +def test_two_hands_chirality_all_left(): + """Both hands claim left; first gets slot 0, second is dropped.""" + h1 = _hand(cx=0.2) + h2 = _hand(cx=0.8) + result = route_hands([h1, h2], [0, 0]) + assert result[0] is h1 + assert result[1] is None + + +def test_result_always_length_2(): + for hands, chir in [ + ([], None), + ([_hand()], None), + ([_hand(), _hand(cx=0.8)], [0, 1]), + ([_hand(), _hand(cx=0.8), _hand(cx=0.3)], [0, 1, 0]), + ]: + r = route_hands(hands, chir) + assert len(r) == 2, f"expected 2 slots, got {len(r)} for {chir}"