diff --git a/data_only_viz/config.py b/data_only_viz/config.py index 951d6e7..f62af39 100644 --- a/data_only_viz/config.py +++ b/data_only_viz/config.py @@ -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), diff --git a/data_only_viz/hand_display.py b/data_only_viz/hand_display.py index 30c5c0a..17987da 100644 --- a/data_only_viz/hand_display.py +++ b/data_only_viz/hand_display.py @@ -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 diff --git a/data_only_viz/renderer.py b/data_only_viz/renderer.py index 52e250b..8d8ef91 100644 --- a/data_only_viz/renderer.py +++ b/data_only_viz/renderer.py @@ -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. diff --git a/data_only_viz/tests/test_hand_display.py b/data_only_viz/tests/test_hand_display.py index faf3d8a..2f8f60d 100644 --- a/data_only_viz/tests/test_hand_display.py +++ b/data_only_viz/tests/test_hand_display.py @@ -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]