feat(pose): hand router chirality + near gate
New module hand_slots.route_hands(): - Chirality path: iPhone ARBodyTracker Vision 0/1 -> L/R slots, alignment preserved before invalid-hand filtering. - Fallback: ascending cx sort (mirror-aware). - swap=True: swaps the two slots at the very end (safety knob, HAND_SWAP_LR=1, if a source inverts Vision chirality). - near_min gate: drops slots below wrist->middle-MCP distance. 18 new tests covering all branches.
This commit is contained in:
@@ -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
|
||||
@@ -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}"
|
||||
Reference in New Issue
Block a user