Files
AV-Live/data_only_viz/tests/test_hand_display.py
T
L'électron rare 6fe98c6b00
CI build oscope-of / build-check (push) Has been cancelled
feat(viz): continuous frame quality intensity
Add gesture_quality() pure helper (hand_display.py) that maps a slot's
plausibility, facing, and proximity to a score in [0,1]. Renderer uses
it to modulate panel frame brightness (0.25+0.75*q) and stroke count
(1..3 passes at q≥0, ≥0.5, ≥0.85 / status==3). Status still drives
hue (pid 7/8/9). New gesture_slot_quality field in State written
alongside gesture_slot_status each tick.

Add gauge_segments() for X/Y position gauges around each panel: a
horizontal rail below and a vertical rail on the outer side, each with
a bold two-tick notch at the hand's normalised cx/cy (mirror-aware for
X). Dim rails (conf 0.25) when slot absent. Renderer reads hand_feats
L/R directly from state.

11 new tests (6 gesture_quality + 5 gauge_segments); suite 430 passed.
2026-07-02 16:34:08 +02:00

773 lines
29 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Unit tests for hand_display geometry helpers.
All tests use tiny synthetic hands — no Metal, no pyobjc, no hardware required.
"""
from __future__ import annotations
import math
from dataclasses import dataclass
import pytest
from data_only_viz.hand_display import (
HandPersistenceGate,
arkit_2d_fresh,
gauge_segments,
gesture_quality,
hand_facing,
hand_plausible,
hand_size,
segment_ok,
panel_rect,
panel_segments,
panel_frame,
GAUGE_GAP,
GAUGE_TICK_HALF,
PANEL_SIDE,
PANEL_MARGIN,
PANEL_INNER,
)
# ---------------------------------------------------------------------------
# Minimal keypoint stub
# ---------------------------------------------------------------------------
@dataclass
class Kp:
x: float = 0.0
y: float = 0.0
z: float = 0.0
c: float = 1.0
def _make_hand(
wrist_x: float = 0.5,
wrist_y: float = 0.5,
size: float = 0.1,
c: float = 1.0,
n: int = 21,
) -> list[Kp]:
"""Build a synthetic 21-landmark hand.
Landmark 0 = wrist, landmark 9 = middle-MCP at distance `size` along x.
All other landmarks are scattered uniformly between wrist and MCP.
"""
kps: list[Kp] = []
for i in range(n):
# Spread landmarks roughly from wrist (0) to slightly past MCP (9)
t = i / max(n - 1, 1)
kps.append(Kp(x=wrist_x + t * size, y=wrist_y, z=0.0, c=c))
# Ensure wrist and MCP are exactly placed so hand_size is deterministic
kps[0] = Kp(x=wrist_x, y=wrist_y, z=0.0, c=c)
kps[9] = Kp(x=wrist_x + size, y=wrist_y, z=0.0, c=c)
return kps
# ---------------------------------------------------------------------------
# hand_size
# ---------------------------------------------------------------------------
def test_hand_size_simple():
kp = _make_hand(wrist_x=0.3, wrist_y=0.4, size=0.15)
assert math.isclose(hand_size(kp), 0.15, rel_tol=1e-6)
# ---------------------------------------------------------------------------
# hand_plausible — acceptance
# ---------------------------------------------------------------------------
def test_normal_mediapipe_hand_passes():
"""CRITICAL: c=1.0 MediaPipe hand at normal size must pass gate unchanged."""
kp = _make_hand(size=0.12, c=1.0)
assert hand_plausible(kp) is True
def test_plausible_boundary_size_min_exact():
"""Size exactly at size_min (0.02) should be accepted."""
kp = _make_hand(size=0.02, c=1.0)
assert hand_plausible(kp) is True
def test_plausible_boundary_size_max_exact():
"""Size exactly at size_max (0.5) should be accepted."""
kp = _make_hand(size=0.5, c=1.0)
assert hand_plausible(kp) is True
# ---------------------------------------------------------------------------
# hand_plausible — rejections
# ---------------------------------------------------------------------------
def test_fewer_than_21_landmarks_rejected():
kp = _make_hand(size=0.1, c=1.0, n=20)
assert hand_plausible(kp) is False
def test_tiny_hand_rejected():
"""size=0.005 < size_min=0.02 → reject."""
kp = _make_hand(size=0.005, c=1.0)
assert hand_plausible(kp) is False
def test_huge_hand_rejected():
"""size=0.8 > size_max=0.5 → reject."""
kp = _make_hand(size=0.8, c=1.0)
assert hand_plausible(kp) is False
def test_low_conf_hand_rejected():
"""mean(c)=0.1 < conf_min=0.3 → reject."""
kp = _make_hand(size=0.1, c=0.1)
assert hand_plausible(kp) is False
def test_borderline_conf_just_below_rejected():
"""mean(c) just under conf_min (0.29) → reject."""
kp = _make_hand(size=0.1, c=0.29)
assert hand_plausible(kp) is False
def test_borderline_conf_at_threshold_accepted():
"""mean(c) = conf_min (0.3) → accept."""
kp = _make_hand(size=0.1, c=0.3)
assert hand_plausible(kp) is True
# ---------------------------------------------------------------------------
# segment_ok
# ---------------------------------------------------------------------------
def test_normal_bone_passes():
"""A normal short bone at c=1.0 must pass."""
size = 0.1
A = Kp(x=0.5, y=0.5, c=1.0)
B = Kp(x=0.55, y=0.5, c=1.0) # bone_len = 0.05 < 1.2 * 0.1 = 0.12
assert segment_ok(A, B, size) is True
def test_spike_segment_rejected():
"""Bone length 2× hand size exceeds max_bone_ratio=1.2 → reject."""
size = 0.1
A = Kp(x=0.5, y=0.5, c=1.0)
B = Kp(x=0.5 + 2 * size, y=0.5, c=1.0) # bone_len = 0.2 > 0.12
assert segment_ok(A, B, size) is False
def test_low_conf_endpoint_a_rejected():
"""Endpoint A has c=0.1 < conf_min=0.3 → reject."""
size = 0.1
A = Kp(x=0.5, y=0.5, c=0.1)
B = Kp(x=0.55, y=0.5, c=1.0)
assert segment_ok(A, B, size) is False
def test_low_conf_endpoint_b_rejected():
"""Endpoint B has c=0.2 < conf_min=0.3 → reject."""
size = 0.1
A = Kp(x=0.5, y=0.5, c=1.0)
B = Kp(x=0.55, y=0.5, c=0.2)
assert segment_ok(A, B, size) is False
def test_bone_exactly_at_ratio_limit_accepted():
"""Bone length exactly max_bone_ratio * size should be accepted (<=)."""
size = 0.1
bone_len = 1.2 * size # exactly at limit
A = Kp(x=0.5, y=0.5, c=1.0)
B = Kp(x=0.5 + bone_len, y=0.5, c=1.0)
# bone_len == 1.2 * size → not strictly greater, should pass
assert segment_ok(A, B, size) is True
def test_mediapipe_hand_all_bones_pass():
"""CRITICAL invariant: for c=1.0 normal-size MediaPipe hand, ALL bones pass
segment_ok (none treated as spikes given hand_size=0.12)."""
kp = _make_hand(size=0.12, c=1.0)
size = hand_size(kp)
assert hand_plausible(kp) is True
# All adjacent pairs in [0..20] should pass segment_ok
# (spacing between consecutive kps = size/20 << 1.2 * size)
failures = []
for i in range(len(kp) - 1):
if not segment_ok(kp[i], kp[i + 1], size):
failures.append(i)
assert failures == [], f"Segments failed segment_ok: {failures}"
# ---------------------------------------------------------------------------
# 2D hand helper: spans a proper bbox with both x and y extent
# ---------------------------------------------------------------------------
def _make_hand_2d(
cx: float = 0.5,
cy: float = 0.5,
half_w: float = 0.05,
half_h: float = 0.05,
c: float = 1.0,
) -> list[Kp]:
"""21 landmarks filling bbox [cx±half_w, cy±half_h].
kp[0] = wrist at (cx-half_w, cy+half_h) — bottom-left of bbox
kp[9] = MCP at (cx+half_w, cy-half_h) — top-right of bbox
All other kp are spread uniformly across the bbox via a diagonal.
hand_size = dist(kp[0], kp[9]) = 2 * sqrt(half_w² + half_h²).
"""
n = 21
kps: list[Kp] = []
for i in range(n):
t = i / (n - 1)
kps.append(Kp(
x=cx - half_w + t * 2 * half_w,
y=cy - half_h + t * 2 * half_h,
z=0.0,
c=c,
))
# Override wrist and MCP so bbox spans full [cx-half_w..cx+half_w, cy-half_h..cy+half_h]
kps[0] = Kp(x=cx - half_w, y=cy + half_h, z=0.0, c=c) # bottom-left
kps[9] = Kp(x=cx + half_w, y=cy - half_h, z=0.0, c=c) # top-right
return kps
# Minimal bone list for panel tests: wrist(0) → middle-MCP(9)
_SIMPLE_BONES: list[tuple[int, int]] = [(0, 9)]
# ---------------------------------------------------------------------------
# panel_rect
# ---------------------------------------------------------------------------
def test_panel_rect_left_x0_anchor():
"""Left panel x0 == PANEL_MARGIN."""
x0, _y0, _x1, _y1 = panel_rect("left", aspect=1.0)
assert math.isclose(x0, PANEL_MARGIN, rel_tol=1e-6)
def test_panel_rect_right_x1_anchor():
"""Right panel x1 == 1 - PANEL_MARGIN."""
_x0, _y0, x1, _y1 = panel_rect("right", aspect=1.0)
assert math.isclose(x1, 1.0 - PANEL_MARGIN, rel_tol=1e-6)
def test_panel_rect_vertically_centered():
"""Panel vertical center is at 0.5."""
_x0, y0, _x1, y1 = panel_rect("left", aspect=1.0)
assert math.isclose((y0 + y1) / 2.0, 0.5, rel_tol=1e-6)
def test_panel_rect_square_in_pixels_landscape():
"""With 16:9 aspect ratio, normalized width × aspect == normalized height."""
aspect = 16.0 / 9.0
x0, y0, x1, y1 = panel_rect("left", aspect=aspect)
norm_w = x1 - x0
norm_h = y1 - y0
# pixel_w = norm_w * view_w = norm_w * aspect * view_h == norm_h * view_h = pixel_h
assert math.isclose(norm_w * aspect, norm_h, rel_tol=1e-6)
# ---------------------------------------------------------------------------
# panel_segments
# ---------------------------------------------------------------------------
def test_panel_segments_implausible_returns_empty():
"""hand_plausible fails → panel_segments returns []."""
kp = _make_hand(size=0.005, c=1.0) # size < size_min=0.02 → implausible
result = panel_segments(kp, "left", _SIMPLE_BONES, aspect=1.0, mirror=False)
assert result == []
def test_panel_segments_inside_inner_rect():
"""All output segment endpoints lie inside the inner panel rect."""
kp = _make_hand_2d(cx=0.5, cy=0.5, half_w=0.04, half_h=0.08, c=1.0)
x0, y0, x1, y1 = panel_rect("left", aspect=1.0)
pw, ph = x1 - x0, y1 - y0
ix0 = x0 + PANEL_INNER * pw
ix1 = x1 - PANEL_INNER * pw
iy0 = y0 + PANEL_INNER * ph
iy1 = y1 - PANEL_INNER * ph
segs = panel_segments(kp, "left", _SIMPLE_BONES, aspect=1.0, mirror=False)
assert segs, "expected at least one segment"
eps = 1e-9
for ax, ay, bx, by in segs:
assert ix0 - eps <= ax <= ix1 + eps, f"ax={ax} outside [{ix0}, {ix1}]"
assert ix0 - eps <= bx <= ix1 + eps, f"bx={bx} outside [{ix0}, {ix1}]"
assert iy0 - eps <= ay <= iy1 + eps, f"ay={ay} outside [{iy0}, {iy1}]"
assert iy0 - eps <= by <= iy1 + eps, f"by={by} outside [{iy0}, {iy1}]"
def test_panel_segments_aspect_preserved():
"""A hand 2× taller than wide in pixel space maps to 2× taller than wide in panel.
With aspect=1.0 (square screen), pixel ratio == normalized ratio.
The hand has half_h = 2*half_w, so pixel bbox is 2× taller than wide.
After uniform pixel-space scaling the output segment's |Δy|/|Δx| must also be 2.
"""
half_w, half_h = 0.04, 0.08 # kh=0.16, kw=0.08 → ratio 2
kp = _make_hand_2d(cx=0.5, cy=0.5, half_w=half_w, half_h=half_h, c=1.0)
segs = panel_segments(kp, "left", _SIMPLE_BONES, aspect=1.0, mirror=False)
assert len(segs) == 1, f"expected 1 segment, got {len(segs)}"
ax, ay, bx, by = segs[0]
dx = abs(bx - ax)
dy = abs(by - ay)
assert dx > 1e-9 and dy > 1e-9, "segment must have both x and y extent"
ratio = dy / dx # pixel ratio (= normalized ratio at aspect=1)
assert math.isclose(ratio, 2.0, rel_tol=1e-3), f"expected pixel ratio 2.0, got {ratio}"
def test_panel_segments_mirror_flips_x():
"""mirror=True flips X within the panel relative to mirror=False."""
kp = _make_hand_2d(cx=0.5, cy=0.5, half_w=0.04, half_h=0.04, c=1.0)
# kp[0] at bottom-left (kx_min), kp[9] at top-right (kx_max)
segs_normal = panel_segments(kp, "left", _SIMPLE_BONES, aspect=1.0, mirror=False)
segs_mirror = panel_segments(kp, "left", _SIMPLE_BONES, aspect=1.0, mirror=True)
assert len(segs_normal) == 1
assert len(segs_mirror) == 1
ax0, _ay0, bx0, _by0 = segs_normal[0]
ax1, _ay1, bx1, _by1 = segs_mirror[0]
# kp[0].x < kp[9].x → without mirror: ax < bx; with mirror: ax > bx
assert ax0 < bx0, "without mirror: wrist (kx_min) should project left of MCP (kx_max)"
assert ax1 > bx1, "with mirror: X is flipped so wrist projects right of MCP"
# ---------------------------------------------------------------------------
# panel_frame
# ---------------------------------------------------------------------------
def test_panel_frame_returns_four_segments():
"""panel_frame returns exactly 4 segments."""
segs = panel_frame("left", aspect=1.0)
assert len(segs) == 4
def test_panel_frame_traces_rect():
"""Every endpoint of each frame segment is a corner of the panel rect."""
aspect = 1.0
x0, y0, x1, y1 = panel_rect("left", aspect)
corners = [(x0, y0), (x0, y1), (x1, y0), (x1, y1)]
segs = panel_frame("left", aspect)
for ax, ay, bx, by in segs:
for ex, ey in [(ax, ay), (bx, by)]:
on_corner = any(
math.isclose(ex, cx, abs_tol=1e-9) and math.isclose(ey, cy, abs_tol=1e-9)
for cx, cy in corners
)
assert on_corner, f"Endpoint ({ex:.4f}, {ey:.4f}) not a corner of panel rect"
# ---------------------------------------------------------------------------
# arkit_2d_fresh
# ---------------------------------------------------------------------------
def test_arkit_2d_fresh_empty_dict_is_false() -> None:
"""No pids → never fresh."""
assert arkit_2d_fresh({}, now=1000.0) is False
def test_arkit_2d_fresh_single_fresh_pid() -> None:
"""A timestamp 0.5 s old with max_age=1.0 → fresh."""
ts_by_pid = {0: 999.5}
assert arkit_2d_fresh(ts_by_pid, now=1000.0) is True
def test_arkit_2d_fresh_single_stale_pid() -> None:
"""A timestamp 2 s old with max_age=1.0 → stale."""
ts_by_pid = {0: 998.0}
assert arkit_2d_fresh(ts_by_pid, now=1000.0) is False
def test_arkit_2d_fresh_exactly_at_max_age_is_stale() -> None:
"""age == max_age is NOT fresh (strict less-than)."""
ts_by_pid = {0: 999.0}
assert arkit_2d_fresh(ts_by_pid, now=1000.0, max_age=1.0) is False
def test_arkit_2d_fresh_mix_stale_and_fresh() -> None:
"""At least one fresh pid makes the whole dict fresh."""
ts_by_pid = {0: 990.0, 1: 999.8} # pid 0 stale, pid 1 fresh
assert arkit_2d_fresh(ts_by_pid, now=1000.0) is True
def test_arkit_2d_fresh_all_pids_stale() -> None:
"""All pids stale → False."""
ts_by_pid = {0: 990.0, 1: 980.0}
assert arkit_2d_fresh(ts_by_pid, now=1000.0) is False
def test_arkit_2d_fresh_custom_max_age() -> None:
"""max_age=0.5 makes a 0.4 s old timestamp fresh."""
ts_by_pid = {5: 999.6}
assert arkit_2d_fresh(ts_by_pid, now=1000.0, max_age=0.5) is True
def test_arkit_2d_fresh_custom_max_age_stale() -> None:
"""max_age=0.5 makes a 0.6 s old timestamp stale."""
ts_by_pid = {5: 999.4}
assert arkit_2d_fresh(ts_by_pid, now=1000.0, max_age=0.5) is False
# ---------------------------------------------------------------------------
# hand_plausible — wrist out-of-frame rejection
# ---------------------------------------------------------------------------
def test_plausible_wrist_out_of_frame_x_neg_rejected():
"""Wrist x < -0.1 → ghost anchor, reject."""
kp = _make_hand(wrist_x=-0.2, wrist_y=0.5, size=0.12, c=1.0)
assert hand_plausible(kp) is False
def test_plausible_wrist_out_of_frame_x_pos_rejected():
"""Wrist x > 1.1 → ghost anchor, reject."""
kp = _make_hand(wrist_x=1.2, wrist_y=0.5, size=0.12, c=1.0)
assert hand_plausible(kp) is False
def test_plausible_wrist_out_of_frame_y_rejected():
"""Wrist y < -0.1 → ghost anchor, reject."""
kp = _make_hand(wrist_x=0.5, wrist_y=-0.15, size=0.12, c=1.0)
assert hand_plausible(kp) is False
def test_plausible_wrist_at_exact_boundary_accepted():
"""Wrist at x=-0.1 is at the boundary — still accepted (inclusive)."""
kp = _make_hand(wrist_x=-0.1, wrist_y=0.5, size=0.12, c=1.0)
assert hand_plausible(kp) is True
# ---------------------------------------------------------------------------
# HandPersistenceGate
# ---------------------------------------------------------------------------
def test_gate_ghost_two_frames_never_draws():
"""A hand appearing only 2 times is never drawn (min_frames=3)."""
gate = HandPersistenceGate(min_frames=3)
hand = _make_hand(wrist_x=0.5, wrist_y=0.5)
assert gate.step([hand]) == [False] # frame 1: count=1
assert gate.step([hand]) == [False] # frame 2: count=2
def test_gate_steady_hand_appears_at_third_frame():
"""A steady hand becomes drawable from the 3rd consecutive frame."""
gate = HandPersistenceGate(min_frames=3)
hand = _make_hand(wrist_x=0.5, wrist_y=0.5)
assert gate.step([hand]) == [False] # frame 1
assert gate.step([hand]) == [False] # frame 2
assert gate.step([hand]) == [True] # frame 3: count=3 >= min_frames
assert gate.step([hand]) == [True] # frame 4: count=4, stays drawable
def test_gate_teleporting_hand_never_draws():
"""A hand that jumps >radius every frame never accumulates a track."""
gate = HandPersistenceGate(min_frames=3, radius=0.15)
for px in (0.1, 0.5, 0.9): # each jump is 0.4 > radius=0.15
hand = _make_hand(wrist_x=px, wrist_y=0.5)
assert gate.step([hand]) == [False]
def test_gate_two_steady_hands_both_drawable():
"""Two hands that remain steady both become drawable at frame 3."""
gate = HandPersistenceGate(min_frames=3)
lh = _make_hand(wrist_x=0.3, wrist_y=0.5)
rh = _make_hand(wrist_x=0.7, wrist_y=0.5)
for _ in range(2):
gate.step([lh, rh])
result = gate.step([lh, rh])
assert result == [True, True]
def test_gate_min_frames_one_immediate():
"""min_frames=1 makes any hand drawable on first appearance."""
gate = HandPersistenceGate(min_frames=1)
hand = _make_hand(wrist_x=0.5, wrist_y=0.5)
assert gate.step([hand]) == [True]
def test_gate_track_resets_after_absence():
"""A track that disappears for one frame must re-accumulate from scratch."""
gate = HandPersistenceGate(min_frames=3)
hand = _make_hand(wrist_x=0.5, wrist_y=0.5)
gate.step([hand])
gate.step([hand]) # count=2, not yet drawable
gate.step([]) # hand absent → track dropped
# Reappears: back to count=1
assert gate.step([hand]) == [False]
def test_gate_empty_input_returns_empty():
"""step([]) always returns []."""
gate = HandPersistenceGate()
assert gate.step([]) == []
def test_gate_greedy_no_double_assign():
"""Two hands close together: each matches its own track, not the same one."""
gate = HandPersistenceGate(min_frames=2, radius=0.15)
h1 = _make_hand(wrist_x=0.3, wrist_y=0.5)
h2 = _make_hand(wrist_x=0.4, wrist_y=0.5) # 0.1 apart, both within radius
gate.step([h1, h2])
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]
# ---------------------------------------------------------------------------
# hand_facing
# ---------------------------------------------------------------------------
def _make_facing_kp(wrist_x=0.5, wrist_y=0.7, size=0.15, spread=0.14) -> list[Kp]:
"""21-kp hand with index-MCP(5) and pinky-MCP(17) spread wide in x."""
kp = _make_hand(wrist_x=wrist_x, wrist_y=wrist_y, size=size)
kp[5] = Kp(x=wrist_x - spread / 2, y=wrist_y - size / 2)
kp[17] = Kp(x=wrist_x + spread / 2, y=wrist_y - size / 2)
return kp
def _make_sideon_kp(wrist_x=0.5, wrist_y=0.7, size=0.15) -> list[Kp]:
"""21-kp hand with index-MCP(5) and pinky-MCP(17) almost co-located."""
kp = _make_hand(wrist_x=wrist_x, wrist_y=wrist_y, size=size)
kp[5] = Kp(x=wrist_x, y=wrist_y - size / 2)
kp[17] = Kp(x=wrist_x + 0.005, y=wrist_y - size / 2)
return kp
def test_hand_facing_camera_above_threshold():
"""A palm facing the camera has a spread ratio >= 0.7."""
kp = _make_facing_kp(size=0.15, spread=0.14)
# facing = 0.14 / 0.15 ~ 0.93
assert hand_facing(kp) >= 0.7
def test_hand_facing_side_on_below_threshold():
"""A side-on palm has a spread ratio < 0.4."""
kp = _make_sideon_kp(size=0.15)
# facing = 0.005 / 0.15 ~ 0.033
assert hand_facing(kp) < 0.4
def test_hand_facing_zero_size_returns_zero():
"""Guard: when hand_size is ~0 (degenerate hand), returns 0.0."""
kp = _make_hand(wrist_x=0.5, wrist_y=0.5, size=0.0)
# Force wrist and MCP to the same position so hand_size = 0
kp[0] = Kp(x=0.5, y=0.5)
kp[9] = Kp(x=0.5, y=0.5)
assert hand_facing(kp) == 0.0
def test_hand_facing_list_format():
"""hand_facing also accepts list-style kp ([x, y, z]) as used in hand_slots tests."""
# Build a 21-element list-of-lists
lm = [[0.5, 0.5, 0.0] for _ in range(21)]
lm[0] = [0.5, 0.7, 0.0] # wrist
lm[9] = [0.5, 0.55, 0.0] # middle-MCP, size = 0.15
# Wide spread: index-MCP and pinky-MCP 0.14 apart in x
lm[5] = [0.43, 0.62, 0.0]
lm[17] = [0.57, 0.62, 0.0]
f = hand_facing(lm)
# facing = 0.14 / 0.15 ~ 0.93
assert f >= 0.7
# ---------------------------------------------------------------------------
# gesture_quality
# ---------------------------------------------------------------------------
def _make_quality_hand(size: float = 0.10, facing: float = 0.7) -> list[Kp]:
"""21-kp hand with precise hand_size and hand_facing values.
hand_size = wrist(kp[0]) to middle-MCP(kp[9]) distance = size
hand_facing = dist(kp[5], kp[17]) / hand_size = facing
"""
kp = _make_hand(wrist_x=0.5, wrist_y=0.5, size=size, c=1.0)
spread = facing * size
mid_y = 0.5 - size / 2
kp[5] = Kp(x=0.5 - spread / 2, y=mid_y)
kp[17] = Kp(x=0.5 + spread / 2, y=mid_y)
return kp
def test_quality_absent_hand_returns_zero():
"""hand=None → quality 0.0 regardless of other params."""
assert gesture_quality(None, face_min=0.5, near_on=0.10) == 0.0
def test_quality_engaged_forces_one():
"""engaged=True → quality 1.0 regardless of hand geometry."""
hand = _make_quality_hand(size=0.05, facing=0.1) # far and side-on
assert gesture_quality(hand, face_min=0.5, near_on=0.10, engaged=True) == 1.0
def test_quality_far_but_fully_facing():
"""Hand at near-threshold (size=0.5*near_on) but fully facing → 0.65.
near_norm = 0.0 (at lower bound), facing_norm = 1.0 → 0.30 + 0.35 = 0.65.
"""
face_min, near_on = 0.5, 0.10
# size = 0.5 * near_on = 0.05 → near_norm = 0
# facing = face_min = 0.5 → facing_norm = (0.5-0.25)/(0.5-0.25) = 1.0
hand = _make_quality_hand(size=0.05, facing=face_min)
q = gesture_quality(hand, face_min=face_min, near_on=near_on)
assert math.isclose(q, 0.65, rel_tol=1e-6), f"expected 0.65, got {q}"
def test_quality_near_but_side_on():
"""Hand fully near (size=near_on) but side-on (facing=0.25) → 0.65.
facing_norm = 0.0 (at lower bound), near_norm = 1.0 → 0.30 + 0.35 = 0.65.
"""
face_min, near_on = 0.5, 0.10
# size = near_on = 0.10 → near_norm = (0.10-0.05)/0.05 = 1.0
# facing = 0.25 → facing_norm = (0.25-0.25)/0.25 = 0.0
hand = _make_quality_hand(size=near_on, facing=0.25)
q = gesture_quality(hand, face_min=face_min, near_on=near_on)
assert math.isclose(q, 0.65, rel_tol=1e-6), f"expected 0.65, got {q}"
def test_quality_full_caps_at_one():
"""Hand well above near_on and facing threshold → quality = 1.0."""
face_min, near_on = 0.5, 0.10
hand = _make_quality_hand(size=0.20, facing=0.9) # near_norm=1, facing_norm=1
q = gesture_quality(hand, face_min=face_min, near_on=near_on)
assert math.isclose(q, 1.0, rel_tol=1e-6), f"expected 1.0, got {q}"
def test_quality_monotonic_in_size():
"""For fixed full-facing, quality increases strictly with hand size."""
face_min, near_on = 0.5, 0.10
facing = face_min # facing_norm = 1.0 at exactly face_min
sizes = [0.5 * near_on, 0.75 * near_on, near_on]
qs = [gesture_quality(_make_quality_hand(size=s, facing=facing),
face_min=face_min, near_on=near_on)
for s in sizes]
assert qs[0] < qs[1] < qs[2], f"not monotone: {qs}"
# ---------------------------------------------------------------------------
# gauge_segments
# ---------------------------------------------------------------------------
def test_gauge_segments_no_data_returns_two_dim_rails():
"""cx/cy=None → 2 rail segments (dim, no markers)."""
segs = gauge_segments(None, None, "left", aspect=1.0)
# Exactly 2 rails: one horizontal (X gauge), one vertical (Y gauge)
assert len(segs) == 2, f"expected 2 rail segs, got {len(segs)}"
confs = [s[4] for s in segs]
assert all(c == 0.25 for c in confs), f"dim conf expected 0.25, got {confs}"
pids = [s[5] for s in segs]
assert all(p == 7 for p in pids), f"rail pid should be 7, got {pids}"
def test_gauge_segments_with_data_returns_rails_and_markers():
"""cx/cy provided → 2 rails + 2 X-marker ticks + 2 Y-marker ticks = 6 total."""
segs = gauge_segments(0.5, 0.5, "left", aspect=1.0)
assert len(segs) == 6, f"expected 6 segments (2 rail + 4 marker), got {len(segs)}"
rail_segs = [s for s in segs if s[5] == 7]
marker_segs = [s for s in segs if s[5] != 7]
assert len(rail_segs) == 2 and len(marker_segs) == 4
def test_gauge_x_marker_centered_on_rail():
"""cx=0.5 → primary X marker tick is at x = (x0+x1)/2 of the panel."""
x0, _y0, x1, _y1 = panel_rect("left", aspect=1.0)
center_x = (x0 + x1) / 2.0
segs = gauge_segments(0.5, 0.5, "left", aspect=1.0, content_pid=5)
# X marker ticks are vertical segments (same x on both endpoints), pid != 7
x_marker_segs = [s for s in segs
if s[5] != 7 and math.isclose(s[0], s[2], abs_tol=1e-9)]
assert x_marker_segs, "no vertical X-marker ticks found"
# The primary tick (minimum x) should land at center_x; the bold-offset
# tick is at center_x + GAUGE_BOLD (a tiny offset for visual thickness).
primary_x = min(s[0] for s in x_marker_segs)
assert math.isclose(primary_x, center_x, rel_tol=1e-6), \
f"primary X marker at {primary_x}, expected {center_x}"
def test_gauge_mirror_flips_x_marker():
"""mirror=True → X marker at 1-cx (mirrored position), cx unchanged for Y."""
x0, _y0, x1, _y1 = panel_rect("left", aspect=1.0)
cx = 0.3
# Without mirror: marker at x0 + 0.3*(x1-x0)
segs_norm = gauge_segments(cx, 0.5, "left", 1.0, mirror=False, content_pid=5)
# With mirror: marker at x0 + 0.7*(x1-x0)
segs_mirr = gauge_segments(cx, 0.5, "left", 1.0, mirror=True, content_pid=5)
# Extract first X-marker tick (vertical segment, pid=5)
def _x_tick_x(segs):
for ax, ay, bx, by, c, p in segs:
if p == 5 and math.isclose(ax, bx, rel_tol=1e-9):
return ax
return None
xn = _x_tick_x(segs_norm)
xm = _x_tick_x(segs_mirr)
assert xn is not None and xm is not None
expected_n = x0 + cx * (x1 - x0)
expected_m = x0 + (1.0 - cx) * (x1 - x0)
assert math.isclose(xn, expected_n, rel_tol=1e-6), f"normal: {xn} != {expected_n}"
assert math.isclose(xm, expected_m, rel_tol=1e-6), f"mirror: {xm} != {expected_m}"
def test_gauge_y_rail_below_panel_x_rail():
"""X gauge rail y-coordinate is below the panel bottom edge."""
_x0, _y0, _x1, y1 = panel_rect("left", aspect=1.0)
segs = gauge_segments(0.5, 0.5, "left", 1.0)
# Horizontal rail: ay == by (same y, spanning x)
h_rail = [s for s in segs if math.isclose(s[1], s[3], rel_tol=1e-9)
and s[5] == 7]
assert h_rail, "no horizontal rail segment"
rail_y = h_rail[0][1]
assert rail_y > y1, f"X gauge rail y={rail_y} should be below panel y1={y1}"
assert math.isclose(rail_y, y1 + GAUGE_GAP, rel_tol=1e-6)