0497a8951a
New standalone visualizer: OSC listener, pose bridge, Apple Vision / CoreML / YOLO pose backends, Euro filter, fine analysis, mesh topology, holistic renderer. Metal shader (scene.metal) for GPU-accelerated drawing.
584 lines
23 KiB
Python
584 lines
23 KiB
Python
"""Pipeline pose 100% natif M5 : AVFoundation + Vision + CoreML (ANE).
|
|
|
|
Architecture (zero-copy, ANE-first) :
|
|
|
|
AVCaptureSession (live webcam)
|
|
| delegate Python (Obj-C protocol via pyobjc)
|
|
v
|
|
CMSampleBuffer -> CVPixelBuffer BGRA
|
|
| VNImageRequestHandler
|
|
v
|
|
VNCoreMLRequest (YOLO11n-pose .mlpackage, ANE)
|
|
| parse keypoints
|
|
v
|
|
IoUTracker (Hungarian) + SkeletonFilter (One Euro)
|
|
|
|
|
v
|
|
state.persons_body / persons_body_ids / last_webcam_jpeg
|
|
|
|
Pourquoi cette stack vs MediaPipe / DETRPose ?
|
|
|
|
- Decodage video AVFoundation : zero-copy, hardware-accelerated.
|
|
- Vision wrappe le CVPixelBuffer en MLFeatureValue sans realloc.
|
|
- YOLO11n-pose tient sur l'Apple Neural Engine M5 (<8 ms / frame
|
|
en FP16) ; CPU/GPU sont laisses libres pour Metal/SuperCollider.
|
|
- Pas de cv2 dans le hot path : encodage JPEG via CIImage +
|
|
CGImageDestination, qui passe aussi par les codecs hardware.
|
|
|
|
API publique :
|
|
CoreMLPoseWorker(state).start() # thread daemon
|
|
CoreMLPoseWorker(state).stop()
|
|
CoreMLPoseWorker.is_available() # @staticmethod, check .mlpackage
|
|
|
|
Frictions Python 3.14 :
|
|
- `pyobjc-framework-Vision` n'est PAS publie sur PyPI au moment de
|
|
cette ecriture. On contourne via `objc.loadBundle()` qui charge
|
|
Vision.framework directement depuis /System/Library/Frameworks.
|
|
- Idem pour CoreML : on utilise `objc.loadBundle()`. Les classes
|
|
MLModel / VNCoreMLRequest deviennent disponibles dans le namespace
|
|
global du module.
|
|
- Le delegate AVCaptureVideoDataOutputSampleBufferDelegate est un
|
|
PROTOCOLE Objective-C ; pyobjc accepte qu'on l'implemente en
|
|
declarant le selector `captureOutput:didOutputSampleBuffer:fromConnection:`
|
|
sur une NSObject — il sera resolu par duck typing au runtime.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
import threading
|
|
import time
|
|
from pathlib import Path
|
|
from typing import TYPE_CHECKING
|
|
|
|
import objc
|
|
from Foundation import NSObject, NSURL
|
|
|
|
from .euro_filter import SkeletonFilter
|
|
from .state import PoseKp, State
|
|
from .tracker import IoUTracker
|
|
|
|
if TYPE_CHECKING:
|
|
pass
|
|
|
|
LOG = logging.getLogger("coreml_pose")
|
|
|
|
CACHE_DIR = Path.home() / ".cache" / "av-live-coreml"
|
|
YOLO_MLPACKAGE = CACHE_DIR / "yolo11n-pose.mlpackage"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Chargement Vision / CoreML / AVFoundation via objc.loadBundle
|
|
# ---------------------------------------------------------------------------
|
|
_FRAMEWORKS_LOADED = False
|
|
_NS: dict = {}
|
|
|
|
|
|
def _load_frameworks() -> dict:
|
|
"""Charge Vision + CoreML + AVFoundation dans un namespace partage.
|
|
|
|
On le fait une seule fois ; les classes Obj-C sont enregistrees
|
|
globalement par le runtime, mais on garde un dict pour acceder
|
|
aux symboles sans polluer le module."""
|
|
global _FRAMEWORKS_LOADED
|
|
if _FRAMEWORKS_LOADED:
|
|
return _NS
|
|
objc.loadBundle("Vision", _NS,
|
|
"/System/Library/Frameworks/Vision.framework")
|
|
objc.loadBundle("CoreML", _NS,
|
|
"/System/Library/Frameworks/CoreML.framework")
|
|
objc.loadBundle("AVFoundation", _NS,
|
|
"/System/Library/Frameworks/AVFoundation.framework")
|
|
objc.loadBundle("CoreMedia", _NS,
|
|
"/System/Library/Frameworks/CoreMedia.framework")
|
|
_FRAMEWORKS_LOADED = True
|
|
return _NS
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers : encodage JPEG via Quartz (CIImage -> CGImageDestination)
|
|
# ---------------------------------------------------------------------------
|
|
def _pixelbuffer_to_jpeg(pixel_buffer, quality: float = 0.7) -> bytes | None:
|
|
"""Encode un CVPixelBuffer en JPEG via le pipeline hardware Quartz."""
|
|
try:
|
|
from Quartz import CIImage, CIContext
|
|
from Foundation import NSMutableData
|
|
from CoreFoundation import CFDictionaryCreate, kCFTypeDictionaryKeyCallBacks, kCFTypeDictionaryValueCallBacks # noqa: F401
|
|
except Exception: # noqa: BLE001
|
|
return None
|
|
try:
|
|
ci = CIImage.imageWithCVPixelBuffer_(pixel_buffer)
|
|
if ci is None:
|
|
return None
|
|
ctx = CIContext.context()
|
|
# On utilise jpegRepresentationOfImage:colorSpace:options: (macOS 10.12+)
|
|
from Quartz import CGColorSpaceCreateDeviceRGB
|
|
cs = CGColorSpaceCreateDeviceRGB()
|
|
data = ctx.JPEGRepresentationOfImage_colorSpace_options_(
|
|
ci, cs, {"kCGImageDestinationLossyCompressionQuality": quality})
|
|
if data is None:
|
|
return None
|
|
return bytes(data)
|
|
except Exception: # noqa: BLE001
|
|
return None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Delegate AVCaptureVideoDataOutput
|
|
# ---------------------------------------------------------------------------
|
|
class _CaptureDelegate(NSObject):
|
|
"""Delegate Obj-C qui recoit chaque frame webcam.
|
|
|
|
Implementation du protocole AVCaptureVideoDataOutputSampleBufferDelegate :
|
|
pyobjc resout le selector par signature, pas besoin de declarer
|
|
formellement le protocole."""
|
|
|
|
def initWithWorker_(self, worker): # noqa: N802
|
|
self = objc.super(_CaptureDelegate, self).init()
|
|
if self is None:
|
|
return None
|
|
self._worker = worker
|
|
return self
|
|
|
|
# Signature: -(void)captureOutput:(AVCaptureOutput*)output
|
|
# didOutputSampleBuffer:(CMSampleBufferRef)sampleBuffer
|
|
# fromConnection:(AVCaptureConnection*)connection
|
|
def captureOutput_didOutputSampleBuffer_fromConnection_( # noqa: N802
|
|
self, output, sample_buffer, connection):
|
|
self._worker._on_frame(sample_buffer)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Queue GCD serielle pour le delegate AVFoundation
|
|
# ---------------------------------------------------------------------------
|
|
def _make_serial_queue(label: str):
|
|
"""Cree une dispatch_queue_create(label, DISPATCH_QUEUE_SERIAL) via objc.
|
|
|
|
pyobjc expose les fonctions GCD via le module `objc`. La signature
|
|
Python est : dispatch_queue_create(label_bytes, None) -> dispatch_queue_t.
|
|
"""
|
|
try:
|
|
from libdispatch import dispatch_queue_create # type: ignore
|
|
return dispatch_queue_create(label.encode("utf-8"), None)
|
|
except ImportError:
|
|
pass
|
|
# Fallback : utiliser le runtime Obj-C via ctypes pour appeler
|
|
# dispatch_queue_create. Toutefois pyobjc 11+ expose ces fonctions
|
|
# via Foundation / objc directement.
|
|
try:
|
|
import Foundation # noqa: F401
|
|
# pyobjc enregistre dispatch_queue_create dans `objc` namespace
|
|
from objc import _objc # type: ignore # noqa: F401
|
|
except Exception: # noqa: BLE001
|
|
pass
|
|
# Dernier recours : ctypes wrapper sur libdispatch (toujours present sur macOS)
|
|
import ctypes
|
|
libdispatch = ctypes.CDLL("/usr/lib/system/libdispatch.dylib")
|
|
libdispatch.dispatch_queue_create.restype = ctypes.c_void_p
|
|
libdispatch.dispatch_queue_create.argtypes = [ctypes.c_char_p, ctypes.c_void_p]
|
|
q = libdispatch.dispatch_queue_create(label.encode("utf-8"), None)
|
|
return q
|
|
|
|
|
|
# ===========================================================================
|
|
# Worker principal
|
|
# ===========================================================================
|
|
class CoreMLPoseWorker:
|
|
"""Worker pose natif M5 — AVFoundation + Vision + CoreML (YOLO11n-pose).
|
|
|
|
start() peut etre appele depuis n'importe quel thread tant que la run
|
|
loop NSApplication tourne (le delegate AVFoundation est dispatche sur
|
|
une queue serielle GCD dediee, pas sur le main thread)."""
|
|
|
|
@staticmethod
|
|
def is_available() -> bool:
|
|
if not YOLO_MLPACKAGE.exists():
|
|
return False
|
|
try:
|
|
_load_frameworks()
|
|
return True
|
|
except Exception: # noqa: BLE001
|
|
return False
|
|
|
|
def __init__(
|
|
self,
|
|
state: State,
|
|
target_fps: float = 30.0,
|
|
num_persons: int = 4,
|
|
score_thresh: float = 0.45,
|
|
) -> None:
|
|
self.state = state
|
|
self.target_fps = float(target_fps)
|
|
self.num_persons = int(num_persons)
|
|
self.score_thresh = float(score_thresh)
|
|
self._frame_period = 1.0 / max(1.0, self.target_fps)
|
|
self._last_emit = 0.0
|
|
self._tracker = IoUTracker(iou_threshold=0.20, max_miss=10)
|
|
self._smooth = SkeletonFilter(min_cutoff=1.2, beta=0.06)
|
|
# Refs Obj-C
|
|
self._session = None
|
|
self._input = None
|
|
self._output = None
|
|
self._delegate = None
|
|
self._queue = None
|
|
self._vn_model = None
|
|
self._frame_size: tuple[int, int] = (640, 480)
|
|
# Metriques
|
|
self._n_frames = 0
|
|
self._n_emitted = 0
|
|
self._sum_infer_ms = 0.0
|
|
self._started = False
|
|
|
|
# ------------------------------------------------------------------
|
|
def start(self) -> None:
|
|
if self._started:
|
|
return
|
|
try:
|
|
self._setup_pipeline()
|
|
except Exception as e: # noqa: BLE001
|
|
LOG.error("setup pipeline echoue : %s", e)
|
|
return
|
|
self._started = True
|
|
# startRunning peut bloquer brievement — on le fait dans un thread
|
|
# daemon pour ne pas geler le main thread AppKit.
|
|
threading.Thread(
|
|
target=self._start_session, name="coreml-session-start",
|
|
daemon=True).start()
|
|
|
|
def stop(self) -> None:
|
|
self._started = False
|
|
try:
|
|
if self._session is not None:
|
|
self._session.stopRunning()
|
|
except Exception: # noqa: BLE001
|
|
pass
|
|
LOG.info("coreml-pose stop — %d frames, %d emises, %.1f ms moy inference",
|
|
self._n_frames, self._n_emitted,
|
|
(self._sum_infer_ms / max(1, self._n_emitted)))
|
|
|
|
# ------------------------------------------------------------------
|
|
def _setup_pipeline(self) -> None:
|
|
ns = _load_frameworks()
|
|
AVCaptureSession = ns["AVCaptureSession"]
|
|
AVCaptureDevice = ns["AVCaptureDevice"]
|
|
AVCaptureDeviceInput = ns["AVCaptureDeviceInput"]
|
|
AVCaptureVideoDataOutput = ns["AVCaptureVideoDataOutput"]
|
|
AVMediaTypeVideo = ns["AVMediaTypeVideo"]
|
|
MLModel = ns["MLModel"]
|
|
MLModelConfiguration = ns["MLModelConfiguration"]
|
|
VNCoreMLModel = ns["VNCoreMLModel"]
|
|
|
|
# 1) Charger le modele
|
|
cfg = MLModelConfiguration.alloc().init()
|
|
try:
|
|
cfg.setComputeUnits_(0) # MLComputeUnitsAll
|
|
except Exception: # noqa: BLE001
|
|
pass
|
|
url = NSURL.fileURLWithPath_(str(YOLO_MLPACKAGE))
|
|
ml_model, err = MLModel.modelWithContentsOfURL_configuration_error_(
|
|
url, cfg, None)
|
|
if ml_model is None:
|
|
raise RuntimeError(f"MLModel load: {err}")
|
|
vn_model, err = VNCoreMLModel.modelForMLModel_error_(ml_model, None)
|
|
if vn_model is None:
|
|
raise RuntimeError(f"VNCoreMLModel wrap: {err}")
|
|
self._vn_model = vn_model
|
|
LOG.info("CoreML pose worker — modele %s charge (computeUnits=all=ANE+GPU+CPU)",
|
|
YOLO_MLPACKAGE.name)
|
|
|
|
# 2) Session capture
|
|
session = AVCaptureSession.alloc().init()
|
|
try:
|
|
session.setSessionPreset_("AVCaptureSessionPreset640x480")
|
|
except Exception: # noqa: BLE001
|
|
pass
|
|
|
|
device = AVCaptureDevice.defaultDeviceWithMediaType_(AVMediaTypeVideo)
|
|
if device is None:
|
|
raise RuntimeError("aucune camera (AVCaptureDevice defaultDevice)")
|
|
input_, err = AVCaptureDeviceInput.deviceInputWithDevice_error_(
|
|
device, None)
|
|
if input_ is None:
|
|
raise RuntimeError(f"AVCaptureDeviceInput: {err}")
|
|
if not session.canAddInput_(input_):
|
|
raise RuntimeError("session.canAddInput == False")
|
|
session.addInput_(input_)
|
|
self._input = input_
|
|
|
|
# 3) Output BGRA
|
|
output = AVCaptureVideoDataOutput.alloc().init()
|
|
BGRA = 0x42475241 # kCVPixelFormatType_32BGRA
|
|
try:
|
|
# cle officielle = kCVPixelBufferPixelFormatTypeKey
|
|
from Quartz import kCVPixelBufferPixelFormatTypeKey
|
|
output.setVideoSettings_({kCVPixelBufferPixelFormatTypeKey: BGRA})
|
|
except Exception: # noqa: BLE001
|
|
# Fallback : nom de cle litteral
|
|
output.setVideoSettings_({"PixelFormatType": BGRA})
|
|
output.setAlwaysDiscardsLateVideoFrames_(True)
|
|
if not session.canAddOutput_(output):
|
|
raise RuntimeError("session.canAddOutput == False")
|
|
session.addOutput_(output)
|
|
self._output = output
|
|
|
|
# 4) Delegate + queue serielle GCD
|
|
delegate = _CaptureDelegate.alloc().initWithWorker_(self)
|
|
queue = _make_serial_queue("av-live.coreml.pose")
|
|
output.setSampleBufferDelegate_queue_(delegate, queue)
|
|
self._delegate = delegate
|
|
self._queue = queue
|
|
self._session = session
|
|
|
|
def _start_session(self) -> None:
|
|
if self._session is None:
|
|
return
|
|
LOG.info("AVCaptureSession.startRunning ...")
|
|
self._session.startRunning()
|
|
LOG.info("session running — fps_target=%.0f num_persons=%d thresh=%.2f",
|
|
self.target_fps, self.num_persons, self.score_thresh)
|
|
|
|
# ------------------------------------------------------------------
|
|
# Callback delegate — appele par GCD sur la queue serielle "coreml.pose"
|
|
# ------------------------------------------------------------------
|
|
def _on_frame(self, sample_buffer) -> None:
|
|
self._n_frames += 1
|
|
now = time.monotonic()
|
|
if now - self._last_emit < self._frame_period:
|
|
return
|
|
self._last_emit = now
|
|
t0 = time.monotonic()
|
|
try:
|
|
self._process_frame(sample_buffer)
|
|
except Exception as e: # noqa: BLE001
|
|
LOG.warning("process_frame: %s", e)
|
|
return
|
|
self._sum_infer_ms += (time.monotonic() - t0) * 1000.0
|
|
self._n_emitted += 1
|
|
|
|
def _process_frame(self, sample_buffer) -> None:
|
|
ns = _NS
|
|
VNImageRequestHandler = ns["VNImageRequestHandler"]
|
|
VNCoreMLRequest = ns["VNCoreMLRequest"]
|
|
# CMSampleBufferGetImageBuffer renvoie un CVPixelBuffer
|
|
from Quartz import (
|
|
CMSampleBufferGetImageBuffer,
|
|
CVPixelBufferGetWidth,
|
|
CVPixelBufferGetHeight,
|
|
)
|
|
pixel_buffer = CMSampleBufferGetImageBuffer(sample_buffer)
|
|
if pixel_buffer is None:
|
|
return
|
|
w = CVPixelBufferGetWidth(pixel_buffer)
|
|
h = CVPixelBufferGetHeight(pixel_buffer)
|
|
self._frame_size = (w, h)
|
|
|
|
# Construction du request synchrone : on capture les resultats
|
|
# dans une closure puis on perform().
|
|
results_box: list = []
|
|
|
|
def _handler(request, error):
|
|
r = request.results()
|
|
if r is not None:
|
|
# On copie les pointeurs Obj-C immediatement
|
|
for obs in r:
|
|
results_box.append(obs)
|
|
|
|
request = VNCoreMLRequest.alloc().initWithModel_completionHandler_(
|
|
self._vn_model, _handler)
|
|
try:
|
|
# Image crop & scale : on laisse Vision faire le scaleFit ;
|
|
# YOLO11n-pose attend 640x640.
|
|
request.setImageCropAndScaleOption_(1) # VNImageCropAndScaleOptionScaleFit
|
|
except Exception: # noqa: BLE001
|
|
pass
|
|
|
|
handler = VNImageRequestHandler.alloc().initWithCVPixelBuffer_options_(
|
|
pixel_buffer, {})
|
|
ok, err = handler.performRequests_error_([request], None)
|
|
if not ok:
|
|
LOG.debug("perform request error: %s", err)
|
|
return
|
|
|
|
# Parsing : Vision retourne soit VNRecognizedPointsObservation
|
|
# (modele "pose" classique), soit VNCoreMLFeatureValueObservation
|
|
# (modele YOLO converti par ultralytics — tenseur brut).
|
|
bodies = self._parse_results(results_box, w, h)
|
|
|
|
# Tracking + lissage
|
|
ids = self._tracker.update(bodies)
|
|
t_now = time.monotonic()
|
|
bodies_smooth = []
|
|
for i, kps in enumerate(bodies):
|
|
pid = ids[i] if i < len(ids) else -1
|
|
if pid < 0:
|
|
bodies_smooth.append(kps)
|
|
continue
|
|
out = []
|
|
for k, kp in enumerate(kps):
|
|
sx, sy, sz = self._smooth.smooth(pid, k, kp.x, kp.y, kp.z, t_now)
|
|
out.append(PoseKp(x=sx, y=sy, z=sz, c=kp.c))
|
|
bodies_smooth.append(out)
|
|
|
|
# JPEG webcam (best effort)
|
|
jpg = _pixelbuffer_to_jpeg(pixel_buffer, quality=0.65)
|
|
|
|
with self.state.lock():
|
|
self.state.persons_body = bodies_smooth
|
|
self.state.persons_body_ids = ids
|
|
# On vide face/hands : YOLO11n-pose ne les fournit pas.
|
|
self.state.persons_face = []
|
|
self.state.persons_hands = []
|
|
self.state.face_present = False
|
|
self.state.hands_present = False
|
|
if bodies_smooth:
|
|
self.state.body_present = True
|
|
# Compat single-person : 17 kp dans body_kp[0..17],
|
|
# le reste reste a zero.
|
|
first = bodies_smooth[0]
|
|
for k in range(33):
|
|
self.state.body_kp[k] = (
|
|
first[k] if k < 17 and k < len(first) else PoseKp())
|
|
for k in range(17):
|
|
self.state.pose_kp[k] = (
|
|
first[k] if k < len(first) else PoseKp())
|
|
else:
|
|
self.state.body_present = False
|
|
self.state.pose_count = len(bodies_smooth)
|
|
self.state.pose_last_t = t_now
|
|
if jpg:
|
|
self.state.last_webcam_jpeg = jpg
|
|
|
|
# ------------------------------------------------------------------
|
|
# Parsing des observations Vision -> list[list[PoseKp]]
|
|
# ------------------------------------------------------------------
|
|
def _parse_results(self, results, w: int, h: int) -> list[list[PoseKp]]:
|
|
"""Convertit les VN*Observation en keypoints normalises 0..1.
|
|
|
|
Deux formats possibles selon la conversion CoreML :
|
|
(a) VNHumanBodyPoseObservation : API Vision native, 17 kp pre-parses
|
|
avec joint names (CGPoint normalises + confidence).
|
|
(b) VNCoreMLFeatureValueObservation : tenseur brut YOLO output
|
|
(post-NMS si nms=True dans l'export ultralytics) — format
|
|
(N, 56) = [cx, cy, bw, bh, conf, kp_x1, kp_y1, kp_v1, ..., x17, y17, v17]
|
|
en pixels du modele (640x640).
|
|
"""
|
|
bodies: list[list[PoseKp]] = []
|
|
if not results:
|
|
return bodies
|
|
|
|
# Cas (a) : Vision pre-parse
|
|
first = results[0]
|
|
cls_name = first.className() if hasattr(first, "className") else ""
|
|
if "BodyPose" in cls_name or "HumanBodyPose" in cls_name:
|
|
for obs in results[: self.num_persons * 2]:
|
|
conf = float(obs.confidence()) if hasattr(obs, "confidence") else 1.0
|
|
if conf < self.score_thresh:
|
|
continue
|
|
kps = self._extract_body_pose_obs(obs)
|
|
if kps:
|
|
bodies.append(kps)
|
|
return bodies[: self.num_persons]
|
|
|
|
# Cas (b) : tenseur YOLO brut
|
|
for obs in results:
|
|
try:
|
|
fv = obs.featureValue()
|
|
except Exception: # noqa: BLE001
|
|
continue
|
|
arr = fv.multiArrayValue() if fv is not None else None
|
|
if arr is None:
|
|
continue
|
|
parsed = self._parse_yolo_tensor(arr)
|
|
bodies.extend(parsed)
|
|
# tri par conf decroissant + cap
|
|
bodies.sort(key=lambda kps: -max((k.c for k in kps), default=0.0))
|
|
return bodies[: self.num_persons]
|
|
|
|
def _extract_body_pose_obs(self, obs) -> list[PoseKp]:
|
|
"""Extrait 17 kp d'un VNHumanBodyPoseObservation (ordre COCO).
|
|
|
|
L'API Vision retourne les points via recognizedPointsForGroupKey:error:
|
|
OU recognizedPointForJointName:error:. On utilise l'ordre COCO :
|
|
nose, leye, reye, lear, rear, lsh, rsh, lel, rel, lwr, rwr,
|
|
lhi, rhi, lkn, rkn, lan, ran.
|
|
"""
|
|
joint_names = [
|
|
"nose", "left_eye", "right_eye", "left_ear", "right_ear",
|
|
"left_shoulder", "right_shoulder",
|
|
"left_elbow", "right_elbow",
|
|
"left_wrist", "right_wrist",
|
|
"left_hip", "right_hip",
|
|
"left_knee", "right_knee",
|
|
"left_ankle", "right_ankle",
|
|
]
|
|
out: list[PoseKp] = []
|
|
for name in joint_names:
|
|
try:
|
|
pt, _err = obs.recognizedPointForJointName_error_(name, None)
|
|
except Exception: # noqa: BLE001
|
|
pt = None
|
|
if pt is None:
|
|
out.append(PoseKp())
|
|
continue
|
|
try:
|
|
loc = pt.location()
|
|
cf = float(pt.confidence())
|
|
# Vision : y origin en bas, on flip pour aligner avec image y-down
|
|
out.append(PoseKp(x=float(loc.x), y=1.0 - float(loc.y),
|
|
z=0.0, c=cf))
|
|
except Exception: # noqa: BLE001
|
|
out.append(PoseKp())
|
|
return out
|
|
|
|
def _parse_yolo_tensor(self, ml_array) -> list[list[PoseKp]]:
|
|
"""Parse un MLMultiArray YOLO11n-pose post-NMS -> bodies.
|
|
|
|
Shape attendu apres export ultralytics nms=True : (1, N, 56)
|
|
avec 56 = box(4) + conf(1) + 17 * (x,y,v) = 4+1+51.
|
|
Sans NMS : (1, 56, M) transpose. On gere les deux."""
|
|
try:
|
|
shape = list(ml_array.shape)
|
|
dims = [int(s) for s in shape]
|
|
except Exception: # noqa: BLE001
|
|
return []
|
|
# Acceder aux donnees via dataPointer() est risque ; on passe par
|
|
# itemAtIndexedSubscript: ou la conversion en numpy via .float32 view.
|
|
try:
|
|
import numpy as np
|
|
# MLMultiArray expose 'dataPointer' (raw void*) — on prefere
|
|
# construire un buffer Python via getBytes:length: indirect.
|
|
count = 1
|
|
for d in dims:
|
|
count *= d
|
|
buf = ml_array.dataPointer()
|
|
# pyobjc renvoie un objc.varlist ou un voidp — on tente numpy.frombuffer
|
|
arr = np.frombuffer(buf, dtype=np.float32, count=count).reshape(dims)
|
|
except Exception: # noqa: BLE001
|
|
return []
|
|
|
|
# Squeeze batch
|
|
while arr.ndim > 2 and arr.shape[0] == 1:
|
|
arr = arr[0]
|
|
if arr.ndim != 2:
|
|
return []
|
|
# Si shape (56, M) au lieu de (M, 56) -> transpose
|
|
if arr.shape[0] == 56 and arr.shape[1] != 56:
|
|
arr = arr.T
|
|
elif arr.shape[1] != 56 and arr.shape[0] != 56:
|
|
return []
|
|
|
|
bodies: list[list[PoseKp]] = []
|
|
for row in arr:
|
|
conf = float(row[4])
|
|
if conf < self.score_thresh:
|
|
continue
|
|
kps: list[PoseKp] = []
|
|
for k in range(17):
|
|
kx = float(row[5 + k * 3 + 0])
|
|
ky = float(row[5 + k * 3 + 1])
|
|
kv = float(row[5 + k * 3 + 2])
|
|
# Coords en pixels du modele 640x640 -> normaliser
|
|
kps.append(PoseKp(x=kx / 640.0, y=ky / 640.0, z=0.0, c=kv))
|
|
bodies.append(kps)
|
|
return bodies
|