Files
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

523 lines
19 KiB
Python
Raw Permalink 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.
"""Pure geometry helpers for hand overlay filtering and panel rendering.
No Metal, no numpy, no pyobjc — safe to unit-test anywhere.
Panel frame legend (renderer.py):
pid 7 conf=0.25+0.75*q → status 0: absent (q=0); status 1: detected (q≥0.30)
pid 8 conf=0.25+0.75*q → status 2: armed (near+facing camera, q≥0.30)
pid 9 conf=1.0 (q forced)→ status 3: pinch engaged
Thickness: 1 stroke always; +1 at q≥0.50; +1 at q≥0.85 or status==3.
See gesture_quality() for the continuous quality formula.
Position gauges (renderer.py, drawn around each panel):
X gauge: horizontal rail BELOW panel (pid 7, conf 0.4 or 0.25 if absent);
vertical notch marker at cx position (pid 5/6, conf 1.0).
Y gauge: vertical rail on outer side (pid 7, conf 0.4 or 0.25 if absent);
horizontal notch marker at cy position (pid 5/6, conf 1.0).
See gauge_segments() — marker x is mirror-flipped if mirror=True.
"""
from __future__ import annotations
import math
from typing import Protocol
# ---------------------------------------------------------------------------
# Panel layout constants
# ---------------------------------------------------------------------------
#: Panel square size in normalized HEIGHT units. A panel occupies
#: PANEL_SIDE * view_height pixels on both axes (square in pixel space).
PANEL_SIDE: float = 0.30
#: Gap between the screen edge and the near edge of the panel (normalized).
PANEL_MARGIN: float = 0.02
#: Fraction of each panel dimension removed from every side to form the
#: inner rect used as the drawing area for hand wireframes.
PANEL_INNER: float = 0.10
class _HasXYC(Protocol):
x: float
y: float
c: float
def arkit_2d_fresh(ts_by_pid: dict, now: float, max_age: float = 1.0) -> bool:
"""Return True if any pid has a timestamp younger than *max_age* seconds.
Args:
ts_by_pid: mapping pid -> perf_counter timestamp (from
``State.persons_arkit_2d_t``).
now: current ``time.perf_counter()`` value.
max_age: staleness threshold in seconds (exclusive: age < max_age).
Returns:
True if at least one entry satisfies ``now - ts < max_age``.
False if the dict is empty or every entry is stale.
"""
for ts in ts_by_pid.values():
if (now - ts) < max_age:
return True
return False
def hand_size(kp: list[_HasXYC]) -> float:
"""Euclidean distance wrist(0) -> middle-MCP(9) in normalised image units."""
w = kp[0]
m = kp[9]
return math.hypot(m.x - w.x, m.y - w.y)
def hand_facing(kp: list) -> float:
"""Palm-spread ratio for camera-facing detection.
Returns dist(index-MCP kp[5], pinky-MCP kp[17]) / hand_size(kp).
A palm facing the camera spans ~70-100 % of hand_size; a side-on
hand collapses to < 40 %. Returns 0.0 when hand_size is ~0.
Accepts both attribute-style (.x/.y) and index-style ([x, y, ...]) kp.
"""
def _gx(p: object) -> float:
return float(p.x) if hasattr(p, "x") else float(p[0]) # type: ignore[index]
def _gy(p: object) -> float:
return float(p.y) if hasattr(p, "y") else float(p[1]) # type: ignore[index]
sz = math.hypot(_gx(kp[9]) - _gx(kp[0]), _gy(kp[9]) - _gy(kp[0]))
if sz < 1e-6:
return 0.0
return math.hypot(_gx(kp[17]) - _gx(kp[5]), _gy(kp[17]) - _gy(kp[5])) / sz
def hand_plausible(
kp: list[_HasXYC],
conf_min: float = 0.3,
size_min: float = 0.02,
size_max: float = 0.5,
) -> bool:
"""Return True if the hand landmark list looks like a real hand.
Rejects:
- fewer than 21 landmarks
- wrist (kp[0]) outside [-0.1, 1.1] in x or y (out-of-frame ghost anchor)
- size (wrist->middle-MCP) outside [size_min, size_max]
- mean confidence below conf_min
"""
if len(kp) < 21:
return False
w = kp[0]
if not (-0.1 <= w.x <= 1.1 and -0.1 <= w.y <= 1.1):
return False
size = hand_size(kp)
if size < size_min or size > size_max:
return False
mean_c = sum(p.c for p in kp) / len(kp)
if mean_c < conf_min:
return False
return True
def segment_ok(
A: _HasXYC,
B: _HasXYC,
size: float,
conf_min: float = 0.3,
max_bone_ratio: float = 1.2,
) -> bool:
"""Return True if the bone segment between A and B is plausible.
Rejects:
- min endpoint confidence below conf_min
- bone length exceeding max_bone_ratio * size
"""
if min(A.c, B.c) < conf_min:
return False
bone_len = math.hypot(B.x - A.x, B.y - A.y)
if bone_len > max_bone_ratio * size:
return False
return True
# ---------------------------------------------------------------------------
# Panel helpers
# ---------------------------------------------------------------------------
def panel_rect(side: str, aspect: float) -> tuple[float, float, float, float]:
"""Normalized [0,1] screen rect of the hand side panel.
The panel is square in pixel space:
pixel_width = norm_width * view_width = (PANEL_SIDE / aspect) * aspect * view_height
= PANEL_SIDE * view_height = pixel_height.
Args:
side: "left" or "right".
aspect: view width / view height (> 0).
Returns:
(x0, y0, x1, y1) in normalized [0,1] coords, y down, origin top-left.
"""
norm_w = PANEL_SIDE / aspect # normalized width (square in pixel space)
norm_h = PANEL_SIDE # normalized height
y0 = 0.5 - norm_h / 2.0
y1 = 0.5 + norm_h / 2.0
if side == "left":
x0 = PANEL_MARGIN
x1 = PANEL_MARGIN + norm_w
else: # "right"
x1 = 1.0 - PANEL_MARGIN
x0 = x1 - norm_w
return x0, y0, x1, y1
def panel_frame(side: str, aspect: float) -> list[tuple[float, float, float, float]]:
"""Four border segments tracing the panel rect (clock-wise, starting top).
Returns:
List of (ax, ay, bx, by) in normalized [0,1] screen coords, y down.
"""
x0, y0, x1, y1 = panel_rect(side, aspect)
return [
(x0, y0, x1, y0), # top edge (left→right)
(x1, y0, x1, y1), # right edge (top→bottom)
(x1, y1, x0, y1), # bottom edge (right→left)
(x0, y1, x0, y0), # left edge (bottom→top)
]
def panel_segments(
kp: list[_HasXYC],
side: str,
bones: list[tuple[int, int]],
aspect: float,
mirror: bool = True,
conf_min: float = 0.3,
) -> list[tuple[float, float, float, float]]:
"""Map hand landmarks into a side panel using uniform pixel-space scale.
The hand bounding box is fit into the inner panel rect (panel rect shrunk
by PANEL_INNER fraction on each side) with a uniform pixel-space scale —
meaning the pixel aspect ratio of the hand is preserved regardless of the
view aspect ratio.
Scale derivation:
s = min(inner_pixel_w / bbox_pixel_w, inner_pixel_h / bbox_pixel_h)
= min(iw / kw, ih / kh)
where iw/kw are normalized-coord ratios whose view dimensions cancel.
Moving Δkp.x in kp-x corresponds to Δkp.x * s in normalized screen x, and
Δkp.y in kp-y corresponds to Δkp.y * s in normalized screen y — preserving
the original pixel ratio between any two segment endpoints.
When mirror=True, X is flipped within the panel so the zoomed hand matches
the mirrored video background (i.e. the panel always shows a "front view").
Args:
kp: 21+ keypoints with .x, .y (normalized image coords, y down), .c.
side: "left" or "right".
bones: List of (index_a, index_b) pairs defining which keypoints to connect.
aspect: view width / view height.
mirror: If True, flip X within the panel.
conf_min: Minimum confidence for hand_plausible and segment_ok checks.
Returns:
List of (ax, ay, bx, by) in normalized [0,1] screen coords, y down.
Returns [] if hand_plausible(kp) fails.
"""
if not hand_plausible(kp, conf_min=conf_min):
return []
x0, y0, x1, y1 = panel_rect(side, aspect)
pw = x1 - x0
ph = y1 - y0
# Inner rect: shrink by PANEL_INNER fraction on every side
ix0 = x0 + PANEL_INNER * pw
ix1 = x1 - PANEL_INNER * pw
iy0 = y0 + PANEL_INNER * ph
iy1 = y1 - PANEL_INNER * ph
iw = ix1 - ix0
ih = iy1 - iy0
# Keypoint bounding box in normalized image coords
kx_min = min(p.x for p in kp)
kx_max = max(p.x for p in kp)
ky_min = min(p.y for p in kp)
ky_max = max(p.y for p in kp)
kw = kx_max - kx_min
kh = ky_max - ky_min
if kw == 0.0 and kh == 0.0:
return []
# Uniform pixel-space scale factor (view dimensions cancel in the ratio)
if kw > 0.0 and kh > 0.0:
s = min(iw / kw, ih / kh)
elif kw > 0.0:
s = iw / kw
else:
s = ih / kh
# Centre the scaled hand bbox inside the inner rect
offset_x = (iw - kw * s) / 2.0
offset_y = (ih - kh * s) / 2.0
def _map(p: _HasXYC) -> tuple[float, float]:
rel_x = (kx_max - p.x) if mirror else (p.x - kx_min)
nx = ix0 + offset_x + rel_x * s
ny = iy0 + offset_y + (p.y - ky_min) * s
return nx, ny
sz = hand_size(kp)
result: list[tuple[float, float, float, float]] = []
for a, b in bones:
if a >= len(kp) or b >= len(kp):
continue
A = kp[a]
B = kp[b]
if not segment_ok(A, B, sz, conf_min=conf_min):
continue
ax, ay = _map(A)
bx, by = _map(B)
result.append((ax, ay, bx, by))
return result
# ---------------------------------------------------------------------------
# Hand temporal persistence gate
# ---------------------------------------------------------------------------
class HandPersistenceGate:
"""Suppress ghost hand detections that only flash for 1-2 frames.
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.
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).
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). Grace widens
that inheritance window to `grace` frames after the hand leaves, and a
ghost re-flashing at one spot every (grace+1)th frame can accumulate to
min_frames. Acceptable for a display gate; do not reuse for
gesture-state tracking.
"""
def __init__(self, min_frames: int = 3, radius: float = 0.15, grace: int = 0) -> None:
self._min_frames = min_frames
self._radius = radius
self._grace = grace
# Each track: [wrist_x, wrist_y, consecutive_count, miss_count]
self._tracks: list[list] = []
def step(self, hands: list) -> list[bool]:
"""Update tracks and return a draw flag per hand.
Args:
hands: list of 21-landmark hand objects; landmark 0 is the wrist
and must expose .x and .y attributes.
Returns:
list[bool] of length len(hands). True = this hand has been
seen at the same position for at least min_frames consecutive
calls and should be drawn.
"""
result: list[bool] = [False] * len(hands)
new_tracks: list[list] = []
used: set[int] = set()
for h_idx, hand in enumerate(hands):
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")
best_t = -1
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
best_t = t_idx
if best_t >= 0:
used.add(best_t)
new_count = self._tracks[best_t][2] + 1
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, miss 0.
new_tracks.append([wx, wy, 1, 0])
result[h_idx] = 1 >= self._min_frames
# 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
# ---------------------------------------------------------------------------
# Gauge layout constants
# ---------------------------------------------------------------------------
#: Normalised gap between a panel edge and its adjacent gauge rail.
GAUGE_GAP: float = 0.012
#: Half the tick length (total = 2 * GAUGE_TICK_HALF), in normalised coords.
GAUGE_TICK_HALF: float = 0.004
#: Parallel offset for the second stroke of a bold-notch marker.
GAUGE_BOLD: float = 0.001
# ---------------------------------------------------------------------------
# Continuous quality score
# ---------------------------------------------------------------------------
def gesture_quality(
hand: object,
*,
face_min: float,
near_on: float,
engaged: bool = False,
) -> float:
"""Continuous quality score ∈ [0, 1] for one gesture slot.
Drives panel frame brightness and stroke thickness in renderer.py.
Args:
hand: 21-kp hand object (attribute-style .x/.y) or None.
``None`` → 0.0 (slot absent or not yet validated by gates).
face_min: palm-spread ratio at which the slot is considered fully
armed (from GestureSlotStabilizer; default env 0.5).
near_on: wrist-to-middle-MCP distance (normalised) at which the slot
is fully near (from GestureSlotStabilizer; default 0.10).
engaged: True when a pinch is held for this slot → forces 1.0.
Returns:
quality ∈ [0, 1]:
* 0.0 when ``hand`` is None.
* 1.0 when ``engaged`` is True.
* else: 0.30·established + 0.35·facing_norm + 0.35·near_norm
where established = 1.0 (hand passed plausibility + temporal gates),
facing_norm = clamp((hand_facing(hand) 0.25) / (face_min 0.25), 0, 1),
near_norm = clamp((hand_size(hand) 0.5·near_on) / (0.5·near_on), 0, 1).
"""
if hand is None:
return 0.0
if engaged:
return 1.0
# facing_norm: 0 at facing = 0.25, 1 at facing = face_min.
denom_f = face_min - 0.25
if denom_f > 1e-9:
facing_norm = min(1.0, max(0.0, (hand_facing(hand) - 0.25) / denom_f))
else:
# face_min ≤ 0.25 — any spread qualifies.
facing_norm = 1.0
# near_norm: 0 at hand_size = 0.5·near_on, 1 at hand_size = near_on.
half_on = 0.5 * near_on
if half_on > 1e-9:
near_norm = min(1.0, max(0.0, (hand_size(hand) - half_on) / half_on))
else:
near_norm = 1.0
return 0.30 + 0.35 * facing_norm + 0.35 * near_norm
# ---------------------------------------------------------------------------
# Position gauges
# ---------------------------------------------------------------------------
def gauge_segments(
cx: float | None,
cy: float | None,
side: str,
aspect: float,
mirror: bool = False,
*,
content_pid: int = 5,
rail_pid: int = 7,
) -> list[tuple[float, float, float, float, float, int]]:
"""Rail + marker segments for the X and Y position gauges around a panel.
X gauge: a horizontal rail placed GAUGE_GAP below the panel bottom edge,
spanning the full panel width. A bold vertical notch marks the
hand's cx position on the rail.
Y gauge: a vertical rail placed GAUGE_GAP outside the panel's outer edge
(left of the left panel, right of the right panel), spanning the
full panel height. A bold horizontal notch marks cy.
Args:
cx: Normalised hand centre-x ∈ [0, 1], or None when absent.
cy: Normalised hand centre-y ∈ [0, 1], or None when absent.
side: "left" or "right".
aspect: View width / view height (> 0).
mirror: If True, flip the cx marker position (1 cx) so it moves
in the same direction as the mirrored video. cy is never
flipped.
content_pid: pid for marker segments (5 = left slot, 6 = right slot).
rail_pid: pid for rail segments (default 7).
Returns:
List of (ax, ay, bx, by, conf, pid) tuples ready to feed
``push_panel`` in the renderer. When cx/cy is None the rails are
drawn dim (conf 0.25) with no marker; otherwise rails use conf 0.4
and markers use conf 1.0.
"""
x0, y0, x1, y1 = panel_rect(side, aspect)
has_data = cx is not None and cy is not None
rail_conf = 0.4 if has_data else 0.25
out: list[tuple[float, float, float, float, float, int]] = []
# ---- X gauge: horizontal rail below panel ----
xg_y = y1 + GAUGE_GAP
out.append((x0, xg_y, x1, xg_y, rail_conf, rail_pid))
if has_data:
assert cx is not None and cy is not None # narrowing for type checkers
cx_eff = 1.0 - cx if mirror else cx
mx = x0 + cx_eff * (x1 - x0)
# Bold vertical notch: two parallel vertical ticks
out.append((mx, xg_y - GAUGE_TICK_HALF, mx, xg_y + GAUGE_TICK_HALF,
1.0, content_pid))
out.append((mx + GAUGE_BOLD, xg_y - GAUGE_TICK_HALF,
mx + GAUGE_BOLD, xg_y + GAUGE_TICK_HALF,
1.0, content_pid))
# ---- Y gauge: vertical rail on outer side ----
if side == "left":
yg_x = x0 - GAUGE_GAP
else:
yg_x = x1 + GAUGE_GAP
out.append((yg_x, y0, yg_x, y1, rail_conf, rail_pid))
if has_data:
assert cx is not None and cy is not None
my = y0 + cy * (y1 - y0)
# Bold horizontal notch: two parallel horizontal ticks
out.append((yg_x - GAUGE_TICK_HALF, my, yg_x + GAUGE_TICK_HALF, my,
1.0, content_pid))
out.append((yg_x - GAUGE_TICK_HALF, my + GAUGE_BOLD,
yg_x + GAUGE_TICK_HALF, my + GAUGE_BOLD,
1.0, content_pid))
return out