fix(pose): swap chirality only, default on
CI build oscope-of / build-check (push) Has been cancelled

User-confirmed live: ARBodyTracker Vision chirality arrives inverted
vs the mirrored view. HAND_SWAP_LR now inverts the chirality mapping
inside route_hands (cx fallback untouched, it is already screen
relative) and defaults to 1. Also: HandPersistenceGate greedy-match
limitation documented + malformed-entry guard (review follow-ups).
This commit is contained in:
L'électron rare
2026-07-02 11:00:25 +02:00
parent e20a4d130a
commit 32cdfa0d3e
6 changed files with 40 additions and 27 deletions
+4 -3
View File
@@ -200,9 +200,10 @@ 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 (
# ARBodyTracker's Vision chirality arrives inverted vs the user's
# mirrored view (observed live 2026-07-02); default-swap corrects it.
# Set HAND_SWAP_LR=0 if a future app build fixes chirality at source.
self._hand_swap_lr = os.environ.get("HAND_SWAP_LR", "1") not in (
"0", "", "false", "False",
)
self._hand_near_min = float(os.environ.get("HAND_NEAR_MIN", "0.10"))
+10 -2
View File
@@ -263,6 +263,11 @@ class HandPersistenceGate:
min_frames=1 disables gating: every incoming hand is drawable on the
first call (useful for the single-person MediaPipe fallback path).
Known limit: matching is greedy nearest-track — two hands crossing
within `radius` can inherit each other's track (and a ghost appearing
exactly where a real hand just left inherits its count). Acceptable
for a display gate; do not reuse for gesture-state tracking.
"""
def __init__(self, min_frames: int = 3, radius: float = 0.15) -> None:
@@ -288,8 +293,11 @@ class HandPersistenceGate:
used: set[int] = set()
for h_idx, hand in enumerate(hands):
wx = hand[0].x
wy = hand[0].y
try:
wx = hand[0].x
wy = hand[0].y
except (TypeError, IndexError, AttributeError):
continue # malformed entry: never drawable, no track
# Find the nearest existing track within radius (greedy).
best_dist = float("inf")
+12 -10
View File
@@ -7,9 +7,11 @@ 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).
`swap=True`: inverts the chirality INTERPRETATION only (chir 0 -> right slot,
1 -> left slot). ARBodyTracker's Vision chirality arrives inverted vs the
user's mirrored view (observed live 2026-07-02), so HAND_SWAP_LR defaults to
1 in the consumers; set 0 if a future app build fixes chirality at the
source. The cx fallback is screen-relative and is never swapped.
`near_min > 0`: after routing, replace a slot with None when the hand's
wrist-to-middle-MCP distance (normalised) is below `near_min`.
@@ -72,8 +74,9 @@ def route_hands(
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.
swap: invert the chirality mapping (0 -> right, 1 -> left). Safety
knob for a source whose Vision chirality arrives inverted.
Never touches the cx fallback (already screen-relative).
near_min: replace a routed slot with None when hand_size < near_min.
"""
chir_aligned = (
@@ -94,9 +97,12 @@ def route_hands(
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) ----
# ---- Chirality path: chir 0 -> slot 0 (left), 1 -> slot 1 (right);
# swap inverts the mapping when the source chirality is flipped ----
for c, hand in valid_pairs:
target = c if c in (0, 1) else None
if target is not None and swap:
target = 1 - target
if target is not None and slots[target] is None:
slots[target] = hand
else:
@@ -118,8 +124,4 @@ def route_hands(
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
+4 -3
View File
@@ -165,9 +165,10 @@ 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 (
# ARBodyTracker's Vision chirality arrives inverted vs the user's
# mirrored view (observed live 2026-07-02); default-swap corrects it.
# Set HAND_SWAP_LR=0 if a future app build fixes chirality at source.
self._hand_swap_lr = os.environ.get("HAND_SWAP_LR", "1") not in (
"0", "", "false", "False",
)
self._init_skel_cpu_buffer()
+9 -8
View File
@@ -89,24 +89,25 @@ def test_mirror_flips_cx_fallback_order():
# ---------------------------------------------------------------------------
# swap: exchanges the two slots at the very end (both paths)
# swap: inverts the CHIRALITY interpretation only (source chirality flipped);
# the cx fallback is screen-relative and must never be affected.
# ---------------------------------------------------------------------------
def test_swap_swaps_chirality_slots():
def test_swap_inverts_chirality_mapping():
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
assert result[0] is h_r # chir 1 -> left slot under swap
assert result[1] is h_l # chir 0 -> right slot under swap
def test_swap_swaps_cx_fallback_slots():
"""swap=True also exchanges the slots on the cx fallback path."""
def test_swap_does_not_alter_cx_fallback():
"""swap only concerns chirality; the cx fallback stays screen-relative."""
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
assert result[0] is h_a # unchanged: leftmost keeps slot 0
assert result[1] is h_b
def test_swap_single_hand_moves_slot():