feat(av-live): wireframe skel + face/hand filter
Skeleton3DRenderer now renders a wireframe: joint radius 1 mm (quasi-invisible), bone radius 3 mm (line-like). Replaces the chunky bead armature with a clean filaire silhouette covering body 33 joints + face 68 dlib + hands 21x2, all 3D. FaceHandOverlay 2D Canvas removed from ContentView -- face and hand landmarks now live in the same 3D RealityKit armature as the body skeleton (Skeleton3DRenderer.applyFace / applyHands, anchored on nose joint 0 + wrist joints 15/16). pose_filter.py extended with FaceFilterChain (alpha-beta + 30 ms lookahead) and HandFilterChain. multi.py wires them after the 2D smoothers, plus ghost rejection (POSE_GHOST_MIN_VISIBLE), bbox NMS (POSE_NMS_IOU), and pid hysteresis. 10 new tests, all green. CoreML perf audit (bench_multihmr_coreml.py): predict() = 99% of wall-time on FP32. ANE catastrophic for DINOv2 (1300 ms), INT8 weight quant = no live gain (GPU compute-bound). 6.4-6.8 fps live is the hardware ceiling on this model. quantize_multihmr_int8.py left in scripts/ for future trials.
This commit is contained in:
@@ -87,12 +87,9 @@ struct ContentView: View {
|
||||
if let n = note.object as? Int { settings.vizMode = n }
|
||||
}
|
||||
|
||||
// Face + hand overlay 2D Canvas (68 dlib landmarks dont
|
||||
// bouche slots 48-67 outerLips + 60-67 innerLips, plus 21
|
||||
// landmarks par main, gauche=cyan droite=magenta). Source :
|
||||
// /face/kp et /hand/kp depuis MediaPipe Holistic.
|
||||
FaceHandOverlay(poseListener: poseListener)
|
||||
.allowsHitTesting(false)
|
||||
// Face + hand overlay 2D Canvas retire : les landmarks sont
|
||||
// maintenant integres au squelette 3D RealityKit (cf.
|
||||
// Skeleton3DRenderer.applyFace/applyHands).
|
||||
|
||||
// HUD coin haut-gauche : mode + touches + pose
|
||||
HUDOverlay(settings: settings, poseListener: poseListener)
|
||||
|
||||
@@ -53,8 +53,14 @@ final class Skeleton3DRenderer: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
private static let jointRadius: Float = 0.045 // 4.5 cm — visible 3D depth
|
||||
private static let boneRadius: Float = 0.022 // 2.2 cm — chunky bones
|
||||
// Wireframe pur : joints micro (1 mm, quasi invisibles), bones
|
||||
// 2 mm = lignes fines. radius=0 fait planter MeshResource.generate.
|
||||
private static let jointRadius: Float = 0.001 // 1 mm — quasi nul
|
||||
private static let boneRadius: Float = 0.003 // 3 mm — line-like
|
||||
private static let faceJointRadius: Float = 0.001
|
||||
private static let handJointRadius: Float = 0.001
|
||||
private static let handScale3D: Float = 0.18 // typical hand size (m)
|
||||
private static let faceForwardOffset: Float = 0.05 // push face in front of nose
|
||||
private static let minConfidence: Float = 0.3
|
||||
private static let retainSec: TimeInterval = 1.0
|
||||
|
||||
@@ -66,12 +72,19 @@ final class Skeleton3DRenderer: ObservableObject {
|
||||
var root: Entity
|
||||
var joints: [ModelEntity] // 33 spheres
|
||||
var bones: [ModelEntity] // 32 bone entities, same order as POSE_CONNECTIONS
|
||||
var faceJoints: [ModelEntity] // 68 dlib face landmarks
|
||||
var leftHandJoints: [ModelEntity] // 21 cyan
|
||||
var rightHandJoints: [ModelEntity] // 21 magenta
|
||||
}
|
||||
|
||||
private var persons: [Int: PersonEntities] = [:]
|
||||
private var lastSeenAt: [Int: TimeInterval] = [:]
|
||||
private var lastFace: [Int: PoseOSCListener.FaceFrame] = [:]
|
||||
private var lastHands: [Int: PoseOSCListener.HandFrame] = [:]
|
||||
private weak var rootAnchor: Entity?
|
||||
private var poseSub: AnyCancellable?
|
||||
private var faceSub: AnyCancellable?
|
||||
private var handSub: AnyCancellable?
|
||||
private var lastUpdateAt: TimeInterval = 0
|
||||
/// Optional per-pid offset to align the skeleton with another
|
||||
/// renderer's coordinate space (typically MeshRenderer's pelvis).
|
||||
@@ -102,14 +115,30 @@ final class Skeleton3DRenderer: ObservableObject {
|
||||
.sink { [weak self] frames in
|
||||
Task { @MainActor in self?.update(frames: frames) }
|
||||
}
|
||||
faceSub = listener.$faces
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] frames in
|
||||
Task { @MainActor in self?.lastFace = frames }
|
||||
}
|
||||
handSub = listener.$hands
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] frames in
|
||||
Task { @MainActor in self?.lastHands = frames }
|
||||
}
|
||||
}
|
||||
|
||||
func detach() {
|
||||
poseSub?.cancel()
|
||||
poseSub = nil
|
||||
faceSub?.cancel()
|
||||
faceSub = nil
|
||||
handSub?.cancel()
|
||||
handSub = nil
|
||||
for (_, p) in persons { p.root.removeFromParent() }
|
||||
persons.removeAll()
|
||||
lastSeenAt.removeAll()
|
||||
lastFace.removeAll()
|
||||
lastHands.removeAll()
|
||||
}
|
||||
|
||||
// MARK: - Update
|
||||
@@ -134,11 +163,12 @@ final class Skeleton3DRenderer: ObservableObject {
|
||||
for (pid, frame) in frames {
|
||||
let entities = persons[pid] ?? makePerson(pid: pid, parent: anchor)
|
||||
persons[pid] = entities
|
||||
apply(frame: frame, to: entities)
|
||||
apply(frame: frame, pid: pid, to: entities)
|
||||
}
|
||||
}
|
||||
|
||||
private func apply(frame: PoseOSCListener.Pose3DFrame,
|
||||
pid: Int,
|
||||
to entities: PersonEntities) {
|
||||
// Convert all 33 keypoints to RealityKit space once.
|
||||
var rk = [SIMD3<Float>](repeating: .zero, count: 33)
|
||||
@@ -199,6 +229,108 @@ final class Skeleton3DRenderer: ObservableObject {
|
||||
bone.transform.scale = SIMD3<Float>(1, len, 1)
|
||||
bone.isEnabled = true
|
||||
}
|
||||
|
||||
// --- Face landmarks (68) anchored on nose joint rk[0] ---
|
||||
applyFace(pid: pid, rk: rk, valid: valid, to: entities)
|
||||
|
||||
// --- Hand landmarks (21 left + 21 right) anchored on wrists ---
|
||||
applyHands(rk: rk, valid: valid, to: entities)
|
||||
}
|
||||
|
||||
private func applyFace(pid: Int,
|
||||
rk: [SIMD3<Float>],
|
||||
valid: [Bool],
|
||||
to entities: PersonEntities) {
|
||||
guard let face = lastFace[pid] else {
|
||||
for j in entities.faceJoints { j.isEnabled = false }
|
||||
return
|
||||
}
|
||||
// Head width in 3D : distance between ears (rk[7] left ear,
|
||||
// rk[8] right ear). Fall back to a sane default if missing.
|
||||
let headWidth3D: Float
|
||||
if valid[7] && valid[8] {
|
||||
headWidth3D = max(0.10, simd_length(rk[7] - rk[8]))
|
||||
} else {
|
||||
headWidth3D = 0.18
|
||||
}
|
||||
// Compute 2D bbox width of face points + centroid.
|
||||
var minX: Float = .infinity, maxX: Float = -.infinity
|
||||
var sumX: Float = 0, sumY: Float = 0
|
||||
var n: Float = 0
|
||||
for i in 0..<68 where face.hasPoint[i] {
|
||||
let p = face.points[i]
|
||||
if p.x < minX { minX = p.x }
|
||||
if p.x > maxX { maxX = p.x }
|
||||
sumX += p.x
|
||||
sumY += p.y
|
||||
n += 1
|
||||
}
|
||||
let nose = valid[0] ? rk[0] : SIMD3<Float>(0, 0, 0)
|
||||
guard n > 4, maxX > minX else {
|
||||
for j in entities.faceJoints { j.isEnabled = false }
|
||||
return
|
||||
}
|
||||
let face2DWidth = max(maxX - minX, 1e-4)
|
||||
let scale = headWidth3D / face2DWidth
|
||||
let cx = sumX / n
|
||||
let cy = sumY / n
|
||||
for i in 0..<68 {
|
||||
let j = entities.faceJoints[i]
|
||||
if face.hasPoint[i] {
|
||||
let dx = face.points[i].x - cx
|
||||
let dy = face.points[i].y - cy
|
||||
// Flip y : image y down -> RK y up. +z to push in front of nose.
|
||||
j.transform.translation = nose + SIMD3<Float>(
|
||||
dx * scale, -dy * scale, Self.faceForwardOffset)
|
||||
j.isEnabled = true
|
||||
} else {
|
||||
j.isEnabled = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func applyHands(rk: [SIMD3<Float>],
|
||||
valid: [Bool],
|
||||
to entities: PersonEntities) {
|
||||
// Disable by default ; re-enable per side if a matching frame found.
|
||||
for j in entities.leftHandJoints { j.isEnabled = false }
|
||||
for j in entities.rightHandJoints { j.isEnabled = false }
|
||||
|
||||
// Iterate cached hand frames (keyed by pid in OSC ; here we route
|
||||
// purely by `side` since the python emits side=0/1 explicitly).
|
||||
for (_, hand) in lastHands {
|
||||
let isLeft = (hand.side == 0)
|
||||
let wristIdx = isLeft ? 15 : 16
|
||||
guard valid[wristIdx] else { continue }
|
||||
let wrist = rk[wristIdx]
|
||||
let target = isLeft
|
||||
? entities.leftHandJoints
|
||||
: entities.rightHandJoints
|
||||
|
||||
// Centroid of valid hand points.
|
||||
var sumX: Float = 0, sumY: Float = 0, n: Float = 0
|
||||
for i in 0..<21 where hand.hasPoint[i] {
|
||||
sumX += hand.points[i].x
|
||||
sumY += hand.points[i].y
|
||||
n += 1
|
||||
}
|
||||
guard n > 2 else { continue }
|
||||
let cx = sumX / n
|
||||
let cy = sumY / n
|
||||
let scale = Self.handScale3D
|
||||
for i in 0..<21 {
|
||||
let j = target[i]
|
||||
if hand.hasPoint[i] {
|
||||
let dx = hand.points[i].x - cx
|
||||
let dy = hand.points[i].y - cy
|
||||
j.transform.translation = wrist + SIMD3<Float>(
|
||||
dx * scale, -dy * scale, 0)
|
||||
j.isEnabled = true
|
||||
} else {
|
||||
j.isEnabled = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Construction
|
||||
@@ -234,8 +366,49 @@ final class Skeleton3DRenderer: ObservableObject {
|
||||
root.addChild(e)
|
||||
bones.append(e)
|
||||
}
|
||||
NSLog("Skeleton3DRenderer: spawned pid=%d (33 joints, %d bones)",
|
||||
// Face sub-spheres (68 dlib landmarks, neutral light grey).
|
||||
let faceMesh = MeshResource.generateSphere(
|
||||
radius: Self.faceJointRadius)
|
||||
let faceMat = SimpleMaterial(
|
||||
color: NSColor(white: 0.85, alpha: 1.0),
|
||||
roughness: 0.7, isMetallic: false)
|
||||
var faceJoints: [ModelEntity] = []
|
||||
faceJoints.reserveCapacity(68)
|
||||
for _ in 0..<68 {
|
||||
let e = ModelEntity(mesh: faceMesh, materials: [faceMat])
|
||||
e.isEnabled = false
|
||||
root.addChild(e)
|
||||
faceJoints.append(e)
|
||||
}
|
||||
|
||||
// Hand sub-spheres : left=cyan, right=magenta, 21 each.
|
||||
let handMesh = MeshResource.generateSphere(
|
||||
radius: Self.handJointRadius)
|
||||
let leftMat = SimpleMaterial(
|
||||
color: .systemCyan, roughness: 0.6, isMetallic: false)
|
||||
let rightMat = SimpleMaterial(
|
||||
color: .systemPink, roughness: 0.6, isMetallic: false)
|
||||
var leftHand: [ModelEntity] = []
|
||||
var rightHand: [ModelEntity] = []
|
||||
leftHand.reserveCapacity(21)
|
||||
rightHand.reserveCapacity(21)
|
||||
for _ in 0..<21 {
|
||||
let el = ModelEntity(mesh: handMesh, materials: [leftMat])
|
||||
el.isEnabled = false
|
||||
root.addChild(el)
|
||||
leftHand.append(el)
|
||||
let er = ModelEntity(mesh: handMesh, materials: [rightMat])
|
||||
er.isEnabled = false
|
||||
root.addChild(er)
|
||||
rightHand.append(er)
|
||||
}
|
||||
NSLog("Skeleton3DRenderer: spawned pid=%d (33 joints, %d bones, 68 face, 21+21 hands)",
|
||||
pid, bones.count)
|
||||
return PersonEntities(root: root, joints: joints, bones: bones)
|
||||
return PersonEntities(root: root,
|
||||
joints: joints,
|
||||
bones: bones,
|
||||
faceJoints: faceJoints,
|
||||
leftHandJoints: leftHand,
|
||||
rightHandJoints: rightHand)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user