feat(viz): persistence grace frames
This commit is contained in:
@@ -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_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_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_FULL_SKELETON "1" bool draw full 91-joint ARKit body (!=0 = True)
|
||||
VIZ_HUD "0" bool show debug HUD overlay
|
||||
@@ -117,8 +120,11 @@ class VizConfig:
|
||||
# ---- Hand display / gesture ------------------------------------------
|
||||
hand_conf_min: float = 0.45
|
||||
hand_persist_frames: int = 3
|
||||
hand_persist_grace: int = 2
|
||||
hand_swap_lr: bool = False
|
||||
hand_near_min: float = 0.10
|
||||
hand_near_off: float = 0.08
|
||||
hand_hold_frames: int = 2
|
||||
arkit_bone_max: float = 0.5
|
||||
arkit_full_skeleton: bool = True
|
||||
|
||||
@@ -261,8 +267,11 @@ class VizConfig:
|
||||
pinch_debounce_frames=_int("PINCH_DEBOUNCE_FRAMES", 3),
|
||||
hand_conf_min=_float("HAND_CONF_MIN", 0.45),
|
||||
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_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_FULL_SKELETON uses != "0" convention (empty = True)
|
||||
arkit_full_skeleton=_bool_ne0("ARKIT_FULL_SKELETON", True),
|
||||
|
||||
@@ -258,8 +258,13 @@ class HandPersistenceGate:
|
||||
|
||||
Tracks each hand by wrist position (landmark 0). A hand is only
|
||||
drawable once the same wrist position has been matched for
|
||||
*min_frames* consecutive calls. A track that goes unmatched for even
|
||||
one frame is immediately dropped (resets the consecutive count).
|
||||
*min_frames* consecutive calls.
|
||||
|
||||
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
|
||||
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.
|
||||
"""
|
||||
|
||||
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._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] = []
|
||||
|
||||
def step(self, hands: list) -> list[bool]:
|
||||
@@ -302,9 +308,10 @@ class HandPersistenceGate:
|
||||
# Find the nearest existing track within radius (greedy).
|
||||
best_dist = float("inf")
|
||||
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:
|
||||
continue
|
||||
tx, ty = track[0], track[1]
|
||||
d = math.hypot(wx - tx, wy - ty)
|
||||
if d < best_dist and d < self._radius:
|
||||
best_dist = d
|
||||
@@ -313,13 +320,19 @@ class HandPersistenceGate:
|
||||
if best_t >= 0:
|
||||
used.add(best_t)
|
||||
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
|
||||
else:
|
||||
# New track — starts at count 1.
|
||||
new_tracks.append([wx, wy, 1])
|
||||
# New track — starts at count 1, miss 0.
|
||||
new_tracks.append([wx, wy, 1, 0])
|
||||
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
|
||||
return result
|
||||
|
||||
@@ -164,7 +164,7 @@ class MetalRenderer(NSObject):
|
||||
_cfg = _VizConfig.from_env()
|
||||
self._hand_conf_min = _cfg.hand_conf_min
|
||||
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.
|
||||
# Chirality validated correct live 2026-07-02; keep False unless a
|
||||
# future iPhone app build flips it.
|
||||
|
||||
@@ -502,3 +502,59 @@ def test_gate_greedy_no_double_assign():
|
||||
result = gate.step([h1, h2])
|
||||
# Both should have count=2 >= min_frames=2
|
||||
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]
|
||||
|
||||
Reference in New Issue
Block a user