Files
AV-Live/data_only_viz/renderer.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

717 lines
33 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.
"""Renderer Metal natif via pyobjc.
Architecture :
- MTKView : NSView Metal-managee, callback drawInMTKView
- MTLDevice : GPU par defaut
- MTLCommandQueue : queue de submission
- 2 pipelines :
bg_pipeline : fullscreen tri + fbm fragment shader (1 draw call)
skel_pipeline : lignes pour le squelette pose (max 16 segments)
- Uniforms buffer 56 octets (14 floats) cf scene.metal::SceneUniforms
Le renderer ne possede PAS de state thread : il lit le State partage
sous lock a chaque frame (60 fps).
"""
from __future__ import annotations
import logging
import os
import struct
import time
from pathlib import Path
import numpy as np
import objc
from Cocoa import NSObject, NSColor, NSMakeRect
from Metal import (
MTLCreateSystemDefaultDevice,
MTLCompileOptions,
MTLPrimitiveTypeTriangle,
MTLPrimitiveTypeLine,
MTLRenderPipelineDescriptor,
MTLResourceStorageModeShared,
MTLVertexAttributeDescriptor,
MTLVertexBufferLayoutDescriptor,
MTLVertexDescriptor,
MTLVertexFormatFloat,
MTLVertexFormatFloat2,
MTLVertexFormatFloat3,
MTLVertexStepFunctionPerVertex,
)
# MTKViewDelegate est un @protocol Obj-C ; pas besoin d'import Python.
# pyobjc detecte automatiquement l'implementation par signature.
from MetalKit import MTKView # noqa: F401 (utilise par d'autres modules)
from .arkit_skeleton import arkit_segments, finger_joint_mask
from .hand_display import (
HandPersistenceGate,
arkit_2d_fresh,
gauge_segments,
hand_plausible,
hand_size,
segment_ok,
panel_frame,
panel_segments,
)
from .hand_slots import route_hands
from .mesh_topology import (
BODY_TRIANGLES,
FACE_TRIANGLES,
build_face_triangles_dynamic,
)
from .state import State
# Draw the full ARKit 91-joint body (topology-driven) instead of the
# MP33-reduced body. Set ARKIT_FULL_SKELETON=0 to fall back to MP33.
ARKIT_FULL = os.environ.get("ARKIT_FULL_SKELETON", "1") != "0"
LOG = logging.getLogger("renderer")
# Triangle primitive constant (Metal MTLPrimitiveType.triangle = 3)
MTL_PRIMITIVE_TRIANGLE = 3
# 17 keypoints COCO bones (16 paires) — legacy YOLO fallback
COCO_BONES: list[tuple[int, int]] = [
(0, 1), (0, 2), (1, 3), (2, 4),
(5, 6), (5, 7), (7, 9), (6, 8), (8, 10),
(5, 11), (6, 12), (11, 12),
(11, 13), (13, 15), (12, 14), (14, 16),
]
def _mediapipe_bones():
"""Charge les connexions MediaPipe (body+face+hands) au runtime.
Retourne 4 listes : (body_bones, face_bones, lhand_bones, rhand_bones)
avec chaque bone = (idx_a, idx_b).
Retourne None si mediapipe absent (fallback COCO)."""
try:
from mediapipe.tasks.python.vision import (
FaceLandmarksConnections as F,
HandLandmarksConnections as H,
PoseLandmarksConnections as P,
)
except ImportError:
return None
def to_pairs(cs):
return [(c.start, c.end) for c in cs]
# 35 body
body = to_pairs(P.POSE_LANDMARKS)
# Visage HAUTE DENSITE : tesselation complete (2556) + contours +
# iris + nez. Donne ~2700 segments rien que pour le visage.
face = (
to_pairs(F.FACE_LANDMARKS_TESSELATION) # 2556 segs (mesh dense)
+ to_pairs(F.FACE_LANDMARKS_FACE_OVAL)
+ to_pairs(F.FACE_LANDMARKS_LIPS)
+ to_pairs(F.FACE_LANDMARKS_LEFT_EYE)
+ to_pairs(F.FACE_LANDMARKS_RIGHT_EYE)
+ to_pairs(F.FACE_LANDMARKS_LEFT_EYEBROW)
+ to_pairs(F.FACE_LANDMARKS_RIGHT_EYEBROW)
+ to_pairs(F.FACE_LANDMARKS_LEFT_IRIS)
+ to_pairs(F.FACE_LANDMARKS_RIGHT_IRIS)
+ to_pairs(F.FACE_LANDMARKS_NOSE)
)
# 21 mains (chacune)
hand = to_pairs(H.HAND_CONNECTIONS)
return body, face, hand, hand # left+right partagent les connexions
# Capacite max du vertex buffer skeleton.
# 16384 segs * 2 verts * 5 floats (xyz + conf + pid) * 4 bytes = 640 KB
# Couvre 4 personnes : 4×35 body + 4×2700 face + 8×21 hand = ~11000 segs.
SKEL_MAX_SEGS = 16384
SKEL_VERT_FLOATS = 5 # x, y, z, conf, person_id
# Mesh : ~8192 triangles × 3 verts × 5 floats × 4 = 480 KB.
# Couvre 4 personnes : 4×~80 face + 4×~16 body + 8×~18 hand ≈ 600 triangles
# pour le hardcode, ou ~4×~150 face Delaunay = ~600 triangles. Marge large.
MESH_MAX_TRIS = 8192
MESH_VERT_FLOATS = 5 # identique au skel
MESH_MAX_VERTS = 10475 # SMPL-X is the larger family; SMPL (6890) fits inside
# struct SceneUniforms : 17 floats packs = 68 octets, padding a 80 (multiple
# de 16, regle Metal). On y stocke (time, rms, kp_norm, netz_dev,
# lightning_flash, flare, wind_norm, bz_norm, social_rate, pose_alive,
# pose_count, width, height, viz_mode, _pad0, _pad1).
UNIFORM_FLOATS = 24 # +4 floats : hand_l_x/y, hand_r_x/y + hand_height, hand_openness, hand_speed, hand_dist
UNIFORM_SIZE = UNIFORM_FLOATS * 4 # 96 octets, aligne 16
class MetalRenderer(NSObject):
"""Delegate de MTKView, drive l'integralite du rendu."""
def initWithState_(self, state: State): # noqa: N802
self = objc.super(MetalRenderer, self).init()
if self is None:
return None
self._state = state
self._device = MTLCreateSystemDefaultDevice()
if self._device is None:
raise RuntimeError("Metal non disponible sur ce systeme")
LOG.info("device: %s", self._device.name())
self._queue = self._device.newCommandQueue()
self._uniforms_buf = self._device.newBufferWithLength_options_(
UNIFORM_SIZE, MTLResourceStorageModeShared)
# Skeleton : N segs × 2 verts × 5 floats (xyz + conf + pid)
self._skel_buf = self._device.newBufferWithLength_options_(
SKEL_MAX_SEGS * 2 * SKEL_VERT_FLOATS * 4, MTLResourceStorageModeShared)
# Mesh buffer : triangles face/hand/body
self._mesh_buf = self._device.newBufferWithLength_options_(
MESH_MAX_TRIS * 3 * MESH_VERT_FLOATS * 4, MTLResourceStorageModeShared)
self._mp_bones = _mediapipe_bones() # None si pas dispo
from .config import VizConfig as _VizConfig
_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, 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.
self._hand_swap_lr = _cfg.hand_swap_lr
self._arkit_full = _cfg.arkit_full_skeleton
self._init_skel_cpu_buffer()
self._init_mesh_cpu_buffer()
self._build_pipelines()
self._last_lightning_emit = 0.0
return self
# ---- Pipelines -------------------------------------------------
def _build_pipelines(self):
src_path = Path(__file__).parent / "shaders" / "scene.metal"
source = src_path.read_text()
opts = MTLCompileOptions.alloc().init()
lib, err = self._device.newLibraryWithSource_options_error_(
source, opts, None)
if lib is None:
raise RuntimeError(f"shader compile failed: {err}")
self._lib = lib
# --- Background pipeline (no vertex buffer) ---
bg = MTLRenderPipelineDescriptor.alloc().init()
bg.setVertexFunction_(lib.newFunctionWithName_("bg_vertex"))
bg.setFragmentFunction_(lib.newFunctionWithName_("bg_fragment"))
bg.colorAttachments().objectAtIndexedSubscript_(0).setPixelFormat_(80) # BGRA8Unorm
p, err = self._device.newRenderPipelineStateWithDescriptor_error_(bg, None)
if p is None:
raise RuntimeError(f"bg pipeline failed: {err}")
self._bg_pipe = p
# --- Skeleton pipeline (vertex buffer pos+conf) ---
# Skeleton vertex layout : pos (float3) + conf (float) + pid (float)
# Stride = 5 floats × 4 bytes = 20 bytes
vd = MTLVertexDescriptor.vertexDescriptor()
a0 = vd.attributes().objectAtIndexedSubscript_(0)
a0.setFormat_(MTLVertexFormatFloat3); a0.setOffset_(0); a0.setBufferIndex_(0)
a1 = vd.attributes().objectAtIndexedSubscript_(1)
a1.setFormat_(MTLVertexFormatFloat); a1.setOffset_(12); a1.setBufferIndex_(0)
a2 = vd.attributes().objectAtIndexedSubscript_(2)
a2.setFormat_(MTLVertexFormatFloat); a2.setOffset_(16); a2.setBufferIndex_(0)
ld = vd.layouts().objectAtIndexedSubscript_(0)
ld.setStride_(20); ld.setStepFunction_(MTLVertexStepFunctionPerVertex)
sk = MTLRenderPipelineDescriptor.alloc().init()
sk.setVertexFunction_(lib.newFunctionWithName_("skel_vertex"))
sk.setFragmentFunction_(lib.newFunctionWithName_("skel_fragment"))
sk.setVertexDescriptor_(vd)
ca = sk.colorAttachments().objectAtIndexedSubscript_(0)
ca.setPixelFormat_(80) # BGRA8Unorm
ca.setBlendingEnabled_(True)
# SrcAlpha, OneMinusSrcAlpha (= 4, 5 dans MTLBlendFactor)
ca.setSourceRGBBlendFactor_(4)
ca.setDestinationRGBBlendFactor_(5)
ca.setSourceAlphaBlendFactor_(4)
ca.setDestinationAlphaBlendFactor_(5)
p, err = self._device.newRenderPipelineStateWithDescriptor_error_(sk, None)
if p is None:
raise RuntimeError(f"skel pipeline failed: {err}")
self._skel_pipe = p
# --- Mesh pipeline (triangles face/hand/body) ---
# Vertex layout strictement identique au skel (reutilise vd).
vd_mesh = MTLVertexDescriptor.vertexDescriptor()
a0 = vd_mesh.attributes().objectAtIndexedSubscript_(0)
a0.setFormat_(MTLVertexFormatFloat3); a0.setOffset_(0); a0.setBufferIndex_(0)
a1 = vd_mesh.attributes().objectAtIndexedSubscript_(1)
a1.setFormat_(MTLVertexFormatFloat); a1.setOffset_(12); a1.setBufferIndex_(0)
a2 = vd_mesh.attributes().objectAtIndexedSubscript_(2)
a2.setFormat_(MTLVertexFormatFloat); a2.setOffset_(16); a2.setBufferIndex_(0)
ld2 = vd_mesh.layouts().objectAtIndexedSubscript_(0)
ld2.setStride_(20); ld2.setStepFunction_(MTLVertexStepFunctionPerVertex)
mp = MTLRenderPipelineDescriptor.alloc().init()
mp.setVertexFunction_(lib.newFunctionWithName_("mesh_vertex"))
mp.setFragmentFunction_(lib.newFunctionWithName_("mesh_fragment"))
mp.setVertexDescriptor_(vd_mesh)
cm = mp.colorAttachments().objectAtIndexedSubscript_(0)
cm.setPixelFormat_(80)
cm.setBlendingEnabled_(True)
# SrcAlpha, OneMinusSrcAlpha — alpha classique pour voile mesh
cm.setSourceRGBBlendFactor_(4)
cm.setDestinationRGBBlendFactor_(5)
cm.setSourceAlphaBlendFactor_(4)
cm.setDestinationAlphaBlendFactor_(5)
p, err = self._device.newRenderPipelineStateWithDescriptor_error_(mp, None)
if p is None:
raise RuntimeError(f"mesh pipeline failed: {err}")
self._mesh_pipe = p
# ---- CPU staging buffers --------------------------------------
def _init_skel_cpu_buffer(self) -> None:
"""Preallocate the CPU staging buffer for skeleton segments.
SKEL_MAX_SEGS * 2 * SKEL_VERT_FLOATS floats : each segment = 2 verts × 5 floats
(x, y, z, conf, pid). Idempotent — no-op if already allocated.
"""
if getattr(self, "_skel_cpu_buf", None) is None:
self._skel_cpu_buf = np.zeros(SKEL_MAX_SEGS * 2 * SKEL_VERT_FLOATS, dtype=np.float32)
def _init_mesh_cpu_buffer(self) -> None:
if getattr(self, "_mesh_cpu_buf", None) is None:
self._mesh_cpu_buf = np.zeros(
MESH_MAX_VERTS * MESH_VERT_FLOATS, dtype=np.float32,
)
# ---- Uniforms helpers ------------------------------------------
def _update_uniforms(self) -> int:
s = self._state
now = time.monotonic()
with s.lock():
# Flash de foudre : decay exponentiel ~600 ms
dt_flash = now - s.last_lightning_t
flash = max(0.0, 1.0 - dt_flash * 1.7) if dt_flash < 1.0 else 0.0
# Positions des mains (point 0 = poignet) — pour mode hands3d
lh_wrist = s.left_hand_kp[0] if s.hands_present else None
rh_wrist = s.right_hand_kp[0] if s.hands_present else None
hlx = (lh_wrist.x if lh_wrist else 0.5) * 2 - 1
hly = 1 - (lh_wrist.y if lh_wrist else 0.5) * 2
hrx = (rh_wrist.x if rh_wrist else 0.5) * 2 - 1
hry = 1 - (rh_wrist.y if rh_wrist else 0.5) * 2
# Expressive hand features from HandFeatureExtractor
hf = getattr(s, "hand_feats", None) or {}
hl, hr = hf.get("L"), hf.get("R")
ys = [h["cy"] for h in (hl, hr) if h]
hand_height = (1.0 - min(ys)) if ys else 0.0
hand_open = max([h["openness"] for h in (hl, hr) if h] or [0.0])
hand_speed = max([h["speed"] for h in (hl, hr) if h] or [0.0])
hand_dist = float(hf.get("dist", 0.0))
uniforms = struct.pack(
f"{UNIFORM_FLOATS}f",
s.elapsed(), # 1
min(1.0, s.rms * 3.0), # 2
min(1.0, s.swpc_kp / 9.0), # 3
max(-0.1, min(0.1, s.netz_dev)), # 4
flash, # 5
s.swpc_flare_norm, # 6
max(0.0, min(1.0, (s.swpc_wind_speed - 280.0) / 600.0)), # 7
max(-1.0, min(1.0, s.swpc_bz / 15.0)), # 8
min(1.0, s.social_rate / 50.0), # 9
1.0 if s.pose_alive() else 0.0, # 10
float(s.pose_count), # 11
float(s.width), # 12
float(s.height), # 13
float(s.viz_mode), # 14
hlx, hly, hrx, hry, # 15-18 wrist xy (kept)
hand_height, hand_open, # 19-20
hand_speed, hand_dist, # 21-22
0.0, 0.0, # 23-24 pad (16-byte align)
)
n_segs = self._update_skeleton(s)
n_tris = self._update_mesh(s)
# Copie via memoryview (pyobjc API : varlist.as_buffer(N))
mv = self._uniforms_buf.contents().as_buffer(UNIFORM_SIZE)
mv[:] = uniforms
# Log debug toutes les 120 frames (~2s) — confirme que mesh draw
if not hasattr(self, "_dbg_n"):
self._dbg_n = 0
self._dbg_n += 1
if self._dbg_n % 120 == 0:
LOG.info("render: %d segs, %d tris (face=%d hand=%d body=%d)",
n_segs, n_tris,
len(self._state.persons_face),
len(self._state.persons_hands),
len(self._state.persons_body))
return n_segs, n_tris
def _update_skeleton(self, s: State) -> int:
"""Remplit self._skel_buf avec les segments visibles. Retourne le
nombre de segments (2 verts chacun).
Priorise MediaPipe (body 33 + face 478 + 2 mains 21) si disponible
et present ; sinon fallback COCO 17 keypoints YOLO."""
_arkit_now = time.perf_counter()
use_arkit = (getattr(self, '_arkit_full', ARKIT_FULL) and bool(s.arkit_parents)
and arkit_2d_fresh(s.persons_arkit_2d_t, _arkit_now))
if not use_arkit and not s.pose_alive():
return 0
buf = self._skel_cpu_buf
segs = 0
# Mirror overlays in X to match the (already mirrored) iPhone video
# background. Display-only: gesture data is untouched.
mirror = bool(getattr(s, "mirror_2d", False))
def push(A, B, conf, pid):
"""Empile un segment (2 verts) dans le buffer CPU prealloque."""
nonlocal segs
if segs >= SKEL_MAX_SEGS:
return False
ax = A.x * 2.0 - 1.0; ay = 1.0 - A.y * 2.0
bx = B.x * 2.0 - 1.0; by = 1.0 - B.y * 2.0
if mirror:
ax = -ax; bx = -bx
i = segs * 10
buf[i+0] = ax; buf[i+1] = ay; buf[i+2] = float(A.z); buf[i+3] = conf; buf[i+4] = float(pid)
buf[i+5] = bx; buf[i+6] = by; buf[i+7] = float(B.z); buf[i+8] = conf; buf[i+9] = float(pid)
segs += 1
return True
def push_seg(ax, ay, bx, by, conf, pid):
"""Like push() but takes raw normalized [0,1] coords."""
nonlocal segs
if segs >= SKEL_MAX_SEGS:
return False
cax = ax * 2.0 - 1.0; cay = 1.0 - ay * 2.0
cbx = bx * 2.0 - 1.0; cby = 1.0 - by * 2.0
if mirror:
cax = -cax; cbx = -cbx
i = segs * 10
buf[i+0] = cax; buf[i+1] = cay; buf[i+2] = 0.0
buf[i+3] = conf; buf[i+4] = float(pid)
buf[i+5] = cbx; buf[i+6] = cby; buf[i+7] = 0.0
buf[i+8] = conf; buf[i+9] = float(pid)
segs += 1
return True
def push_panel(ax, ay, bx, by, conf, pid):
"""Like push_seg but NEVER applies mirror.
Panel coords are final screen coords [0,1] — they must not be
flipped again even when the video background is mirrored.
"""
nonlocal segs
if segs >= SKEL_MAX_SEGS:
return False
cax = ax * 2.0 - 1.0; cay = 1.0 - ay * 2.0
cbx = bx * 2.0 - 1.0; cby = 1.0 - by * 2.0
i = segs * 10
buf[i+0] = cax; buf[i+1] = cay; buf[i+2] = 0.0
buf[i+3] = conf; buf[i+4] = float(pid)
buf[i+5] = cbx; buf[i+6] = cby; buf[i+7] = 0.0
buf[i+8] = conf; buf[i+9] = float(pid)
segs += 1
return True
# Gate called once per frame on the full persons_hands list.
# Used for both the overlay loop and the side panels below.
_hand_draw_flags = self._hand_gate.step(s.persons_hands)
if use_arkit:
parents = s.arkit_parents
# Hide ARKit's per-finger joints ("false hands"): the display
# shows the filtered Vision hands instead. Mask cached until the
# topology changes.
fmask = getattr(self, "_finger_mask", None)
if fmask is None or len(fmask) != len(s.arkit_joint_names):
fmask = np.asarray(
finger_joint_mask(s.arkit_joint_names), dtype=bool)
self._finger_mask = fmask
for pid, arr2d in s.persons_arkit_2d.items():
ts = s.persons_arkit_2d_t.get(pid)
if ts is None or (_arkit_now - ts) >= 1.0:
continue # skip stale or unknown pid
valid = s.persons_arkit_2d_valid.get(pid)
if len(fmask):
if valid is None:
valid = ~fmask
elif len(valid) == len(fmask):
valid = valid & ~fmask
for (ax, ay, bx, by) in arkit_segments(
arr2d, valid, parents, max_len=self._arkit_bone_max
):
if not push_seg(ax, ay, bx, by, 1.0, pid):
break
if self._mp_bones is not None and (
s.persons_body or s.persons_face or s.persons_hands or
s.body_present or s.face_present or s.hands_present
):
body_bones, face_bones, lhand_bones, _ = self._mp_bones
# ----- MULTI-PERSONNE : pid = ID stable du tracker -----
# (track_id persiste entre frames, palette se stabilise)
ids_b = s.persons_body_ids or list(range(len(s.persons_body)))
ids_f = s.persons_face_ids or list(range(len(s.persons_face)))
ids_h = s.persons_hands_ids or list(range(len(s.persons_hands)))
for i, body_kp in enumerate([] if use_arkit else s.persons_body):
pid = ids_b[i] if i < len(ids_b) else i
for a, b in body_bones:
if a >= len(body_kp) or b >= len(body_kp): continue
A = body_kp[a]; B = body_kp[b]
if A.c < 0.15 or B.c < 0.15: continue
if not push(A, B, min(A.c, B.c), pid): break
for i, face_kp in enumerate(s.persons_face):
pid = ids_f[i] if i < len(ids_f) else i
for a, b in face_bones:
if a >= len(face_kp) or b >= len(face_kp): continue
if not push(face_kp[a], face_kp[b], 1.0, pid): break
if segs >= SKEL_MAX_SEGS: break
for i, hand_kp in enumerate(s.persons_hands):
if not _hand_draw_flags[i]:
continue
pid = ids_h[i] if i < len(ids_h) else i
if not hand_plausible(hand_kp, conf_min=self._hand_conf_min):
continue
size = hand_size(hand_kp)
for a, b in lhand_bones:
if a >= len(hand_kp) or b >= len(hand_kp): continue
A = hand_kp[a]; B = hand_kp[b]
if not segment_ok(A, B, size, conf_min=self._hand_conf_min): continue
# Decalage palette mains (+5) pour les distinguer
if not push(A, B, min(A.c, B.c), pid + 5): break
# ----- FALLBACK single-person si persons_* vides -----
if not (s.persons_body or s.persons_face or s.persons_hands):
if s.body_present and not use_arkit:
for a, b in body_bones:
if a >= len(s.body_kp) or b >= len(s.body_kp): continue
A = s.body_kp[a]; B = s.body_kp[b]
if A.c < 0.15 or B.c < 0.15: continue
if not push(A, B, min(A.c, B.c), 0): break
if s.face_present:
for a, b in face_bones:
if a >= len(s.face_kp) or b >= len(s.face_kp): continue
if not push(s.face_kp[a], s.face_kp[b], 1.0, 0): break
if s.hands_present:
for kp_list in (s.left_hand_kp, s.right_hand_kp):
if not any(p.x != 0.0 or p.y != 0.0 for p in kp_list):
continue
if not hand_plausible(kp_list, conf_min=self._hand_conf_min):
continue
size = hand_size(kp_list)
for a, b in lhand_bones:
if a >= len(kp_list) or b >= len(kp_list): continue
A = kp_list[a]; B = kp_list[b]
if not segment_ok(A, B, size, conf_min=self._hand_conf_min): continue
if not push(A, B, min(A.c, B.c), 0): break
else:
# Fallback COCO 17 (YOLO legacy)
for a, b in COCO_BONES:
A = s.pose_kp[a]; B = s.pose_kp[b]
if A.c < 0.2 or B.c < 0.2: continue
if not push(A, B, min(A.c, B.c), 0): break
# ---- SIDE PANELS: left/right hand front-view zoomed wireframe ----
# Gated on self._mp_bones (panels need _mp_bones for hand bone topology,
# even when use_arkit is True). Hands from s.persons_hands only.
if self._mp_bones is not None:
_body_b, _face_b, lhand_bones_p, _ = self._mp_bones
# Filter hands by persistence gate + plausibility before routing
# to panels (a ghost hand must not steal a slot from a real one).
keep = [
ok and hand_plausible(h, conf_min=self._hand_conf_min)
for h, ok in zip(s.persons_hands, _hand_draw_flags)
]
gated_hands = [h for h, k in zip(s.persons_hands, keep) if k]
chir_src = getattr(s, "persons_hands_chirality", None) or []
# Keep chirality aligned with the gated hands (same filter mask).
if chir_src and len(chir_src) == len(s.persons_hands):
gated_chir = [c for c, k in zip(chir_src, keep) if k]
else:
gated_chir = None
asp = float(s.width) / float(s.height) if s.width and s.height else 1.0
# Route to L/R panels. mirror= passed for cx fallback path;
# no near_min — panels show far hands as user proximity feedback.
slotted = route_hands(
gated_hands, gated_chir,
mirror=mirror,
swap=self._hand_swap_lr,
)
left_kp, right_kp = slotted
# Panel frame: status drives hue (pid 7/8/9), quality drives
# brightness (0.25+0.75*q) and thickness (1..3 stroke passes).
# 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)
# pid 9 conf=1.0 (q=1.0) → status 3: pinch engaged
# Thickness: pass 1 always; pass 2 at q≥0.50; pass 3 at q≥0.85 or status==3.
_slot_status = getattr(s, "gesture_slot_status", [0, 0])
_slot_quality = getattr(s, "gesture_slot_quality", [0.0, 0.0])
_hf_all = getattr(s, "hand_feats", None) or {}
for _si, (side_name, h_kp, pid_s) in enumerate((
("left", left_kp, 5),
("right", right_kp, 6),
)):
_st = _slot_status[_si] if _si < len(_slot_status) else 0
_q = float(_slot_quality[_si]) if _si < len(_slot_quality) else 0.0
_fconf = 0.25 + 0.75 * _q
# Status drives hue (pid selection)
_fpid = 9 if _st == 3 else (8 if _st == 2 else 7)
_frame_segs = panel_frame(side_name, asp)
# Pass 1: always
for seg in _frame_segs:
push_panel(*seg, _fconf, _fpid)
# Pass 2: thicker when quality ≥ 0.50
if _q >= 0.5:
for ax, ay, bx, by in _frame_segs:
push_panel(ax + 0.001, ay, bx + 0.001, by, _fconf, _fpid)
# Pass 3: boldest when quality ≥ 0.85 or pinch engaged
if _q >= 0.85 or _st == 3:
for ax, ay, bx, by in _frame_segs:
push_panel(ax + 0.002, ay, bx + 0.002, by, _fconf, _fpid)
# Position gauges (X below / Y outer-side)
_slot_key = "L" if side_name == "left" else "R"
_slot_hf = _hf_all.get(_slot_key)
_g_cx = _slot_hf["cx"] if _slot_hf else None
_g_cy = _slot_hf["cy"] if _slot_hf else None
for ax, ay, bx, by, gconf, gpid in gauge_segments(
_g_cx, _g_cy, side_name, asp, mirror,
content_pid=pid_s,
):
push_panel(ax, ay, bx, by, gconf, gpid)
if h_kp is not None:
for seg in panel_segments(
h_kp, side_name, lhand_bones_p, asp,
mirror=mirror, conf_min=self._hand_conf_min,
):
push_panel(*seg, 1.0, pid_s)
if segs == 0:
return 0
data = self._skel_cpu_buf[: segs * 2 * SKEL_VERT_FLOATS].tobytes()
mv = self._skel_buf.contents().as_buffer(len(data))
mv[:] = data
return segs
def _update_mesh(self, s: State) -> int:
"""Remplit self._mesh_buf avec des triangles face/body.
Retourne le nombre de triangles ecrits (chacun = 3 vertices).
Filtre les triangles dont au moins un sommet a confiance < 0.3.
"""
if not s.pose_alive():
return 0
if not (s.persons_face or s.persons_hands or s.persons_body):
return 0
n_verts = 0
def push_tri(kp_list, i, j, k, pid: int) -> bool:
"""Pousse un triangle (3 verts). Retourne False si buffer plein
ou triangle invalide (confiance basse)."""
nonlocal n_verts
tris = n_verts // 3
if tris >= MESH_MAX_TRIS or n_verts + 3 > MESH_MAX_VERTS:
return False
if i >= len(kp_list) or j >= len(kp_list) or k >= len(kp_list):
return True # skip mais continue
A = kp_list[i]; B = kp_list[j]; C = kp_list[k]
if A.c < 0.15 or B.c < 0.15 or C.c < 0.15:
return True
ax = A.x * 2.0 - 1.0; ay = 1.0 - A.y * 2.0
bx = B.x * 2.0 - 1.0; by = 1.0 - B.y * 2.0
cx = C.x * 2.0 - 1.0; cy = 1.0 - C.y * 2.0
conf = min(A.c, B.c, C.c)
fpid = float(pid)
base = n_verts * 5
self._mesh_cpu_buf[base + 0] = ax
self._mesh_cpu_buf[base + 1] = ay
self._mesh_cpu_buf[base + 2] = float(A.z)
self._mesh_cpu_buf[base + 3] = conf
self._mesh_cpu_buf[base + 4] = fpid
self._mesh_cpu_buf[base + 5] = bx
self._mesh_cpu_buf[base + 6] = by
self._mesh_cpu_buf[base + 7] = float(B.z)
self._mesh_cpu_buf[base + 8] = conf
self._mesh_cpu_buf[base + 9] = fpid
self._mesh_cpu_buf[base + 10] = cx
self._mesh_cpu_buf[base + 11] = cy
self._mesh_cpu_buf[base + 12] = float(C.z)
self._mesh_cpu_buf[base + 13] = conf
self._mesh_cpu_buf[base + 14] = fpid
n_verts += 3
return True
ids_b = s.persons_body_ids or list(range(len(s.persons_body)))
ids_f = s.persons_face_ids or list(range(len(s.persons_face)))
# Body — SKIPPED whenever ARKit is the body source (iphone-usb: the
# ARKit topology arrived). persons_body is then the ARKit->MP33
# conversion with c forced to 1.0, so garbage/stale joints would pass
# the confidence filter and paint solid triangles anywhere on screen
# (the "blue blob"). The body is the gated ARKit wireframe instead.
arkit_body = (getattr(self, "_arkit_full", ARKIT_FULL)
and bool(s.arkit_parents))
for i, body_kp in enumerate([] if arkit_body else s.persons_body):
pid = ids_b[i] if i < len(ids_b) else i
for a, b, c in BODY_TRIANGLES:
if not push_tri(body_kp, a, b, c, pid):
break
if n_verts >= MESH_MAX_VERTS:
break
# Face — utilise triangulation Delaunay dynamique sur les XY,
# fallback sur FACE_TRIANGLES statique si Delaunay echoue.
for i, face_kp in enumerate(s.persons_face):
pid = ids_f[i] if i < len(ids_f) else i
pts_xy = [(kp.x, kp.y) for kp in face_kp]
tri_list = build_face_triangles_dynamic(pts_xy) or FACE_TRIANGLES
for a, b, c in tri_list:
if not push_tri(face_kp, a, b, c, pid):
break
if n_verts >= MESH_MAX_VERTS:
break
if n_verts == 0:
return 0
# Slice is exact — stale floats beyond n_verts*MESH_VERT_FLOATS never reach the GPU.
data = self._mesh_cpu_buf[: n_verts * MESH_VERT_FLOATS].tobytes()
mv = self._mesh_buf.contents().as_buffer(len(data))
mv[:] = data
return n_verts // 3
# ---- MTKViewDelegate ------------------------------------------
def mtkView_drawableSizeWillChange_(self, view, size): # noqa: N802
with self._state.lock():
self._state.width = int(size.width)
self._state.height = int(size.height)
def drawInMTKView_(self, view): # noqa: N802
n_segs, n_tris = self._update_uniforms()
rpd = view.currentRenderPassDescriptor()
drawable = view.currentDrawable()
if rpd is None or drawable is None:
return
cb = self._queue.commandBuffer()
enc = cb.renderCommandEncoderWithDescriptor_(rpd)
# 1) background fullscreen tri
enc.setRenderPipelineState_(self._bg_pipe)
enc.setFragmentBuffer_offset_atIndex_(self._uniforms_buf, 0, 0)
enc.drawPrimitives_vertexStart_vertexCount_(MTLPrimitiveTypeTriangle, 0, 3)
# 2) mesh overlay (triangles face/hand/body) — DESSOUS le skel
if n_tris > 0:
enc.setRenderPipelineState_(self._mesh_pipe)
enc.setVertexBuffer_offset_atIndex_(self._mesh_buf, 0, 0)
enc.setVertexBuffer_offset_atIndex_(self._uniforms_buf, 0, 1)
enc.drawPrimitives_vertexStart_vertexCount_(
MTL_PRIMITIVE_TRIANGLE, 0, n_tris * 3)
# 3) skeleton overlay (lignes par-dessus le mesh)
if n_segs > 0:
enc.setRenderPipelineState_(self._skel_pipe)
enc.setVertexBuffer_offset_atIndex_(self._skel_buf, 0, 0)
# SceneUniforms a buffer(1) du vertex pour acceder a U.rms etc
enc.setVertexBuffer_offset_atIndex_(self._uniforms_buf, 0, 1)
enc.drawPrimitives_vertexStart_vertexCount_(
MTLPrimitiveTypeLine, 0, n_segs * 2)
enc.endEncoding()
cb.presentDrawable_(drawable)
cb.commit()
def device(self):
return self._device