0a2865e136
CI build oscope-of / build-check (push) Has been cancelled
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).
225 lines
6.8 KiB
Python
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
|