Files
AV-Live/data_only_viz/hand_display.py
T
L'électron rare 0a2865e136
CI build oscope-of / build-check (push) Has been cancelled
feat(viz): left/right hand front-view panels
Add panel_rect/panel_segments/panel_frame helpers to hand_display.py
(PANEL_SIDE=0.30, PANEL_MARGIN=0.02, PANEL_INNER=0.10). Segments use
uniform pixel-space scale so the hand aspect ratio is preserved
regardless of view aspect. mirror=True flips X within the panel.

In renderer._update_skeleton add push_panel (no mirror flip) and a
panels block before the segs==0 epilogue: routes persons_hands to
left/right panel via chirality list when aligned, screen-cx fallback
otherwise. Draws frame (pid 7) + wireframe (pid 5 left / 6 right).
Runs on every path including use_arkit. Guards: _mp_bones None, empty
hands, misaligned chirality, height==0.

26 tests pass (10 new for panel helpers).
2026-07-02 09:56:08 +02:00

225 lines
6.8 KiB
Python

"""Pure geometry helpers for hand overlay filtering and panel rendering.
No Metal, no numpy, no pyobjc — safe to unit-test anywhere.
"""
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 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_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
- size (wrist->middle-MCP) outside [size_min, size_max]
- mean confidence below conf_min
"""
if len(kp) < 21:
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,
) -> 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.
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):
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):
continue
ax, ay = _map(A)
bx, by = _map(B)
result.append((ax, ay, bx, by))
return result