feat(viz): persistence grace frames

This commit is contained in:
L'électron rare
2026-07-02 11:46:01 +02:00
parent 8b08282aa5
commit 4e33a03e1b
4 changed files with 88 additions and 10 deletions
+9
View File
@@ -35,6 +35,9 @@ HAND_CONF_MIN 0.45 float min hand landmark confidence to ren
HAND_PERSIST_FRAMES 3 int frames to persist hand after disappearance HAND_PERSIST_FRAMES 3 int frames to persist hand after disappearance
HAND_SWAP_LR "0" bool Safety knob: invert Vision chirality (validated correct live 2026-07-02; keep 0 unless a future iPhone app build flips it) HAND_SWAP_LR "0" bool Safety knob: invert Vision chirality (validated correct live 2026-07-02; keep 0 unless a future iPhone app build flips it)
HAND_NEAR_MIN 0.10 float min hand size (norm) to consider "near" HAND_NEAR_MIN 0.10 float min hand size (norm) to consider "near"
HAND_PERSIST_GRACE 2 int grace frames before dropping a hand track on Vision miss (0 = strict)
HAND_NEAR_OFF 0.08 float hand size below which near gate deactivates (hysteresis off-threshold)
HAND_HOLD_FRAMES 2 int gesture slot hold: frames to carry last hand on Vision miss
ARKIT_BONE_MAX 0.5 float max bone length (norm) for ARKit skeleton ARKIT_BONE_MAX 0.5 float max bone length (norm) for ARKit skeleton
ARKIT_FULL_SKELETON "1" bool draw full 91-joint ARKit body (!=0 = True) ARKIT_FULL_SKELETON "1" bool draw full 91-joint ARKit body (!=0 = True)
VIZ_HUD "0" bool show debug HUD overlay VIZ_HUD "0" bool show debug HUD overlay
@@ -117,8 +120,11 @@ class VizConfig:
# ---- Hand display / gesture ------------------------------------------ # ---- Hand display / gesture ------------------------------------------
hand_conf_min: float = 0.45 hand_conf_min: float = 0.45
hand_persist_frames: int = 3 hand_persist_frames: int = 3
hand_persist_grace: int = 2
hand_swap_lr: bool = False hand_swap_lr: bool = False
hand_near_min: float = 0.10 hand_near_min: float = 0.10
hand_near_off: float = 0.08
hand_hold_frames: int = 2
arkit_bone_max: float = 0.5 arkit_bone_max: float = 0.5
arkit_full_skeleton: bool = True arkit_full_skeleton: bool = True
@@ -261,8 +267,11 @@ class VizConfig:
pinch_debounce_frames=_int("PINCH_DEBOUNCE_FRAMES", 3), pinch_debounce_frames=_int("PINCH_DEBOUNCE_FRAMES", 3),
hand_conf_min=_float("HAND_CONF_MIN", 0.45), hand_conf_min=_float("HAND_CONF_MIN", 0.45),
hand_persist_frames=_int("HAND_PERSIST_FRAMES", 3), hand_persist_frames=_int("HAND_PERSIST_FRAMES", 3),
hand_persist_grace=_int("HAND_PERSIST_GRACE", 2),
hand_swap_lr=_bool_std("HAND_SWAP_LR", False), hand_swap_lr=_bool_std("HAND_SWAP_LR", False),
hand_near_min=_float("HAND_NEAR_MIN", 0.10), hand_near_min=_float("HAND_NEAR_MIN", 0.10),
hand_near_off=_float("HAND_NEAR_OFF", 0.08),
hand_hold_frames=_int("HAND_HOLD_FRAMES", 2),
arkit_bone_max=_float("ARKIT_BONE_MAX", 0.5), arkit_bone_max=_float("ARKIT_BONE_MAX", 0.5),
# ARKIT_FULL_SKELETON uses != "0" convention (empty = True) # ARKIT_FULL_SKELETON uses != "0" convention (empty = True)
arkit_full_skeleton=_bool_ne0("ARKIT_FULL_SKELETON", True), arkit_full_skeleton=_bool_ne0("ARKIT_FULL_SKELETON", True),
+22 -9
View File
@@ -258,8 +258,13 @@ class HandPersistenceGate:
Tracks each hand by wrist position (landmark 0). A hand is only Tracks each hand by wrist position (landmark 0). A hand is only
drawable once the same wrist position has been matched for drawable once the same wrist position has been matched for
*min_frames* consecutive calls. A track that goes unmatched for even *min_frames* consecutive calls.
one frame is immediately dropped (resets the consecutive count).
With ``grace=0`` (default) a track that goes unmatched for even one
frame is immediately dropped (resets the consecutive count). With
``grace>0`` an unmatched track survives up to *grace* consecutive
missed calls, so a single Vision drop-frame does not wipe the
accumulated count and cause a 4-frame visual flicker.
min_frames=1 disables gating: every incoming hand is drawable on the min_frames=1 disables gating: every incoming hand is drawable on the
first call (useful for the single-person MediaPipe fallback path). first call (useful for the single-person MediaPipe fallback path).
@@ -270,10 +275,11 @@ class HandPersistenceGate:
for a display gate; do not reuse for gesture-state tracking. for a display gate; do not reuse for gesture-state tracking.
""" """
def __init__(self, min_frames: int = 3, radius: float = 0.15) -> None: def __init__(self, min_frames: int = 3, radius: float = 0.15, grace: int = 0) -> None:
self._min_frames = min_frames self._min_frames = min_frames
self._radius = radius self._radius = radius
# Each track: [wrist_x, wrist_y, consecutive_count] self._grace = grace
# Each track: [wrist_x, wrist_y, consecutive_count, miss_count]
self._tracks: list[list] = [] self._tracks: list[list] = []
def step(self, hands: list) -> list[bool]: def step(self, hands: list) -> list[bool]:
@@ -302,9 +308,10 @@ class HandPersistenceGate:
# Find the nearest existing track within radius (greedy). # Find the nearest existing track within radius (greedy).
best_dist = float("inf") best_dist = float("inf")
best_t = -1 best_t = -1
for t_idx, (tx, ty, _count) in enumerate(self._tracks): for t_idx, track in enumerate(self._tracks):
if t_idx in used: if t_idx in used:
continue continue
tx, ty = track[0], track[1]
d = math.hypot(wx - tx, wy - ty) d = math.hypot(wx - tx, wy - ty)
if d < best_dist and d < self._radius: if d < best_dist and d < self._radius:
best_dist = d best_dist = d
@@ -313,13 +320,19 @@ class HandPersistenceGate:
if best_t >= 0: if best_t >= 0:
used.add(best_t) used.add(best_t)
new_count = self._tracks[best_t][2] + 1 new_count = self._tracks[best_t][2] + 1
new_tracks.append([wx, wy, new_count]) new_tracks.append([wx, wy, new_count, 0]) # reset miss
result[h_idx] = new_count >= self._min_frames result[h_idx] = new_count >= self._min_frames
else: else:
# New track — starts at count 1. # New track — starts at count 1, miss 0.
new_tracks.append([wx, wy, 1]) new_tracks.append([wx, wy, 1, 0])
result[h_idx] = 1 >= self._min_frames result[h_idx] = 1 >= self._min_frames
# Unmatched tracks are dropped (not carried into new_tracks). # Carry forward unmatched tracks within grace window.
for t_idx, track in enumerate(self._tracks):
if t_idx not in used:
miss = track[3] + 1
if miss <= self._grace:
new_tracks.append([track[0], track[1], track[2], miss])
self._tracks = new_tracks self._tracks = new_tracks
return result return result
+1 -1
View File
@@ -164,7 +164,7 @@ class MetalRenderer(NSObject):
_cfg = _VizConfig.from_env() _cfg = _VizConfig.from_env()
self._hand_conf_min = _cfg.hand_conf_min self._hand_conf_min = _cfg.hand_conf_min
self._arkit_bone_max = _cfg.arkit_bone_max self._arkit_bone_max = _cfg.arkit_bone_max
self._hand_gate = HandPersistenceGate(min_frames=_cfg.hand_persist_frames) self._hand_gate = HandPersistenceGate(min_frames=_cfg.hand_persist_frames, grace=_cfg.hand_persist_grace)
# Safety knob: invert Vision chirality interpretation. # Safety knob: invert Vision chirality interpretation.
# Chirality validated correct live 2026-07-02; keep False unless a # Chirality validated correct live 2026-07-02; keep False unless a
# future iPhone app build flips it. # future iPhone app build flips it.
+56
View File
@@ -502,3 +502,59 @@ def test_gate_greedy_no_double_assign():
result = gate.step([h1, h2]) result = gate.step([h1, h2])
# Both should have count=2 >= min_frames=2 # Both should have count=2 >= min_frames=2
assert result == [True, True] assert result == [True, True]
# ---------------------------------------------------------------------------
# HandPersistenceGate — grace frames
# ---------------------------------------------------------------------------
def test_gate_grace_zero_is_strict():
"""grace=0 restores original strict behavior (1 miss drops the track)."""
gate = HandPersistenceGate(min_frames=3, grace=0)
hand = _make_hand(wrist_x=0.5, wrist_y=0.5)
for _ in range(3):
gate.step([hand]) # establish count=3
gate.step([]) # miss=1 > grace=0 → dropped
assert gate.step([hand]) == [False] # fresh count=1
def test_gate_grace_preserves_track_across_one_miss():
"""With grace=2, a 1-frame hole preserves the established count."""
gate = HandPersistenceGate(min_frames=3, grace=2)
hand = _make_hand(wrist_x=0.5, wrist_y=0.5)
for _ in range(3):
gate.step([hand]) # establish count=3
gate.step([]) # miss=1 ≤ grace → track survives
result = gate.step([hand]) # matches surviving track: count=4
assert result == [True]
def test_gate_grace_gap_frame_has_empty_result():
"""During a grace frame (no hands input), result list is empty."""
gate = HandPersistenceGate(min_frames=3, grace=2)
hand = _make_hand(wrist_x=0.5, wrist_y=0.5)
for _ in range(3):
gate.step([hand])
assert gate.step([]) == [] # no hands → result is []
def test_gate_grace_drops_after_grace_plus_one_misses():
"""After grace+1 consecutive misses the track is dropped."""
gate = HandPersistenceGate(min_frames=3, grace=2)
hand = _make_hand(wrist_x=0.5, wrist_y=0.5)
for _ in range(3):
gate.step([hand]) # establish
for _ in range(3): # 3 misses: miss=1,2,3 → last one > grace=2 → dropped
gate.step([])
assert gate.step([hand]) == [False] # fresh count=1
def test_gate_grace_ghost_stays_suppressed():
"""A ghost appearing 1 frame then disappearing never becomes drawable
(count=1 < min_frames=3 even after being carried by grace)."""
gate = HandPersistenceGate(min_frames=3, grace=2)
hand = _make_hand(wrist_x=0.5, wrist_y=0.5)
gate.step([hand]) # count=1
gate.step([]) # miss=1 ≤ grace → track [0.5, 0.5, 1, 1] survives
# Reappears: count increments to 2 (still not established)
assert gate.step([hand]) == [False]