Files
AV-Live/iphone-arbody/ARBodyTracker.swiftpm/Sources/ARBodyTracker/ARBodySession.swift
T
2026-06-30 21:58:35 +02:00

364 lines
14 KiB
Swift

import ARKit
import AVLiveWire
import Combine
import Foundation
import SwiftUI
import Vision
/// Drives the ARKit body-tracking session and streams the 91-joint
/// skeleton plus HEVC-encoded camera video to the tethered Mac over
/// USB (AVLiveWire frames via usbmuxd). No network involved.
///
/// Lightweight 2D snapshot of the tracked skeleton, ready for SwiftUI
/// Canvas. Joint indices follow `ARSkeletonDefinition.defaultBody3D`.
struct SkeletonSnapshot: Equatable {
/// Projected joint positions in viewport coordinates, or nil if the
/// joint falls outside the view / is not finite.
let points: [CGPoint?]
/// Per-joint tracking flag (`ARSkeleton.isJointTracked`).
let tracked: [Bool]
}
@MainActor
final class ARBodySession: NSObject, ObservableObject, ARSessionDelegate {
@Published var running: Bool = false
@Published var status: String = "idle"
@Published var framesSent: Int = 0
@Published var jointsPerSec: Double = 0
@Published var bodyCount: Int = 0
@Published var skeleton2D: SkeletonSnapshot?
@Published var usbState: USBServer.State = .idle
@Published var arkitFps: Double = 0
@Published var visionMs: Double = 0
@Published var visionDropRate: Double = 0
@Published var thermalLabel: String = "nominal"
/// Set by the SwiftUI view via GeometryReader so the projection
/// matches the on-screen ARView size.
var viewportSize: CGSize = .zero
private var sendEnvMesh: Bool = false
private let session = ARSession()
private let usb = USBServer()
private let videoEncoder = VideoEncoder()
private var videoStarted = false
private var lastFrameTime: TimeInterval = 0
private let visionQueue = DispatchQueue(
label: "cc.saillant.arbody.vision", qos: .userInitiated)
private var visionBusy = false
private var lastVisionTime: TimeInterval = 0
private var visionAttempts: Int = 0
private var visionDrops: Int = 0
private var jointsInSecond: Int = 0
private var lastSecond: TimeInterval = 0
private let bodyParents: [Int] =
ARSkeletonDefinition.defaultBody3D.parentIndices
nonisolated static func thermalString(
_ s: ProcessInfo.ThermalState) -> String {
switch s {
case .nominal: return "nominal"
case .fair: return "fair"
case .serious: return "serious"
case .critical: return "critical"
@unknown default: return "?"
}
}
override init() {
super.init()
session.delegate = self
usb.onState = { [weak self] s in
Task { @MainActor in
guard let self else { return }
let was = self.usbState
self.usbState = s
if s == .connected && was != .connected {
self.publishTopology()
}
}
}
videoEncoder.onPayload = { [weak self] payload in
Task { @MainActor in
guard let self, self.usbState == .connected else {
return
}
self.usb.send(tag: .video, pid: -1,
timestamp: self.lastFrameTime,
payload: payload.encoded())
}
}
thermalLabel = Self.thermalString(
ProcessInfo.processInfo.thermalState)
NotificationCenter.default.addObserver(
forName: ProcessInfo.thermalStateDidChangeNotification,
object: nil, queue: .main) { [weak self] _ in
Task { @MainActor in
self?.thermalLabel = Self.thermalString(
ProcessInfo.processInfo.thermalState)
}
}
}
func configure(sendEnvMesh: Bool) {
self.sendEnvMesh = sendEnvMesh
}
func start() {
guard ARBodyTrackingConfiguration.isSupported else {
status = "ARBodyTracking unsupported (need A12+, iPhone XR/XS+)"
return
}
let cfg = ARBodyTrackingConfiguration()
var feats: [String] = []
// No extra frame semantics: `.sceneDepth` is reserved to
// ARWorldTracking, and `.personSegmentationWithDepth` is
// rejected per-frame by ABPKPersonIDTracker in this config
// (spams the console without producing usable depth).
// NOTE: ARBodyTrackingConfiguration does not expose
// sceneReconstruction (that's ARWorldTrackingConfiguration
// territory). Env mesh capture requires a separate ARSession
// with body tracking off out of scope for this scaffold.
if sendEnvMesh {
feats.append("env-mesh: requires separate session (TODO)")
}
cfg.automaticImageScaleEstimationEnabled = true
usb.start()
session.run(cfg, options: [.resetTracking, .removeExistingAnchors])
status = feats.isEmpty
? "running (RGB only)"
: "running (\(feats.joined(separator: ", ")))"
running = true
}
func stop() {
session.pause()
usb.stop()
videoEncoder.stop()
videoStarted = false
running = false
status = "stopped"
}
// MARK: - ARSessionDelegate
nonisolated func session(_ s: ARSession, didUpdate frame: ARFrame) {
let t = frame.timestamp
Task { @MainActor in
// Throttle to 30 fps max.
let dt = t - self.lastFrameTime
if dt < 1.0 / 30.0 { return }
self.lastFrameTime = t
if dt > 0, dt < 1.0 {
let inst = 1.0 / dt
self.arkitFps = self.arkitFps == 0
? inst : self.arkitFps * 0.9 + inst * 0.1
}
// Encode the camera frame to HEVC and stream it over USB.
let img = frame.capturedImage
let w = Int32(CVPixelBufferGetWidth(img))
let h = Int32(CVPixelBufferGetHeight(img))
if !self.videoStarted, w > 0, h > 0 {
self.videoEncoder.start(width: w, height: h)
self.videoStarted = true
}
if self.videoStarted {
self.videoEncoder.encode(img, pts: t)
}
// Vision hands at ~15 fps, off-main, drop if busy.
if t - self.lastVisionTime >= 1.0 / 15.0 {
self.visionAttempts += 1
if self.visionBusy {
self.visionDrops += 1
} else {
self.lastVisionTime = t
self.visionBusy = true
// CVPixelBuffer is non-Sendable; this is a deliberate
// single-owner handoff to the Vision worker queue.
nonisolated(unsafe) let buf = img
self.visionQueue.async {
self.runVision(on: buf, timestamp: t)
Task { @MainActor in self.visionBusy = false }
}
}
if self.visionAttempts >= 60 {
self.visionDropRate =
Double(self.visionDrops) / Double(self.visionAttempts)
self.visionAttempts = 0
self.visionDrops = 0
}
}
var count: Int = 0
var firstBody: ARBodyAnchor?
for anchor in frame.anchors {
guard let body = anchor as? ARBodyAnchor else { continue }
self.publishUSB(pid: count, timestamp: t, body: body,
camera: frame.camera)
if count == 0 { firstBody = body }
count += 1
}
self.framesSent &+= 1
self.bodyCount = count
self.updateSkeleton2D(body: firstBody, camera: frame.camera)
let now = Date().timeIntervalSinceReferenceDate
self.jointsInSecond &+= count * 91
if now - self.lastSecond >= 1.0 {
self.jointsPerSec = Double(self.jointsInSecond)
/ max(0.001, now - self.lastSecond)
self.jointsInSecond = 0
self.lastSecond = now
}
}
}
private func currentInterfaceOrientation() -> UIInterfaceOrientation {
for scene in UIApplication.shared.connectedScenes {
if let ws = scene as? UIWindowScene {
return ws.interfaceOrientation
}
}
return .portrait
}
private func updateSkeleton2D(body: ARBodyAnchor?, camera: ARCamera) {
guard let body, viewportSize.width > 1, viewportSize.height > 1
else {
if skeleton2D != nil { skeleton2D = nil }
return
}
let xforms = body.skeleton.jointModelTransforms
let root = body.transform
let orient = currentInterfaceOrientation()
var pts: [CGPoint?] = Array(repeating: nil, count: xforms.count)
var tracked: [Bool] = Array(repeating: false, count: xforms.count)
for (i, m) in xforms.enumerated() {
let w = root * m
let p3 = simd_make_float3(w.columns.3.x,
w.columns.3.y,
w.columns.3.z)
let p2 = camera.projectPoint(p3,
orientation: orient,
viewportSize: viewportSize)
if p2.x.isFinite && p2.y.isFinite { pts[i] = p2 }
tracked[i] = body.skeleton.isJointTracked(i)
}
skeleton2D = SkeletonSnapshot(points: pts, tracked: tracked)
}
/// Exposed for SwiftUI overlays that need to wire bone parent
/// indices without re-reading the ARKit skeleton definition.
var bodyParentIndices: [Int] { bodyParents }
// MARK: - Vision hand capture
nonisolated func runVision(on pixelBuffer: CVPixelBuffer,
timestamp t: TimeInterval) {
let handReq = VNDetectHumanHandPoseRequest()
handReq.maximumHandCount = 2
let handler = VNImageRequestHandler(
cvPixelBuffer: pixelBuffer, orientation: .right, options: [:])
let t0 = Date()
try? handler.perform([handReq])
let ms = Date().timeIntervalSince(t0) * 1000.0
Task { @MainActor in
self.visionMs = self.visionMs == 0
? ms : self.visionMs * 0.8 + ms * 0.2
}
// ---- hands ----
// Fixed 21-joint order matching MediaPipe-style indexing.
let order: [VNHumanHandPoseObservation.JointName] = [
.wrist,
.thumbCMC, .thumbMP, .thumbIP, .thumbTip,
.indexMCP, .indexPIP, .indexDIP, .indexTip,
.middleMCP, .middlePIP, .middleDIP, .middleTip,
.ringMCP, .ringPIP, .ringDIP, .ringTip,
.littleMCP, .littlePIP, .littleDIP, .littleTip,
]
var handsOut: [HandsPayload.Hand] = []
for obs in (handReq.results ?? []).prefix(2) {
guard let pts = try? obs.recognizedPoints(.all) else { continue }
let p = order.map { name -> SIMD3<Float> in
if let rp = pts[name] {
// Vision: normalized, bottom-left origin -> flip y.
return SIMD3(Float(rp.location.x),
Float(1.0 - rp.location.y),
Float(rp.confidence))
}
return .zero
}
handsOut.append(HandsPayload.Hand(
isRight: obs.chirality == .right, points: p))
}
let handsData = HandsPayload(hands: handsOut).encoded()
Task { @MainActor in
guard self.usbState == .connected else { return }
self.usb.send(tag: .hands, pid: -1,
timestamp: t, payload: handsData)
}
}
// MARK: - USB skeleton
private func publishUSB(pid: Int, timestamp: TimeInterval,
body: ARBodyAnchor, camera: ARCamera) {
guard usbState == .connected else { return }
let skeleton = body.skeleton
let transforms = skeleton.jointModelTransforms
let root = body.transform
var payload = SkeletonPayload()
let n = min(SkeletonPayload.jointCount, transforms.count)
for i in 0..<n {
let w = root * transforms[i]
payload.joints[i] = SIMD3(w.columns.3.x,
w.columns.3.y,
w.columns.3.z)
payload.valid[i] = skeleton.isJointTracked(i)
}
usb.send(tag: .skeleton,
pid: Int16(clamping: pid),
timestamp: timestamp,
payload: payload.encoded())
// 2D projected skeleton: viewport-normalized [0,1] coords per joint.
var p2 = Skeleton2DPayload()
let orient = currentInterfaceOrientation()
for i in 0..<min(Skeleton2DPayload.jointCount, transforms.count) {
let w = root * transforms[i]
let p3 = SIMD3<Float>(w.columns.3.x, w.columns.3.y, w.columns.3.z)
let proj = camera.projectPoint(p3,
orientation: orient,
viewportSize: viewportSize)
let nx = Float(proj.x / max(viewportSize.width, 1))
let ny = Float(proj.y / max(viewportSize.height, 1))
if nx.isFinite && ny.isFinite {
p2.points[i] = SIMD2<Float>(nx, ny)
p2.valid[i] = skeleton.isJointTracked(i)
}
}
usb.send(tag: .skeleton2D,
pid: Int16(clamping: pid),
timestamp: timestamp,
payload: p2.encoded())
}
/// Send the ARKit body joint names + parent indices once, so the Mac
/// renders bones generically. Static data available before any body
/// is tracked, so it is safe to emit right after connect.
@MainActor
private func publishTopology() {
guard usbState == .connected else { return }
let def = ARSkeletonDefinition.defaultBody3D
let names = def.jointNames
let parents = def.parentIndices.map { Int16(clamping: $0) }
let payload = TopologyPayload(jointNames: names, parents: parents)
usb.send(tag: .topology, pid: -1,
timestamp: lastFrameTime,
payload: payload.encoded())
}
}