Files
james.maesandClaude Opus 4.7 79ecd0184a Tier 1 + Tier 2 polish: 3 parallel agents — overlays, snapshots, menus
Coordinated three agents working in parallel on non-overlapping files,
with a shared `ProcessedFrame.swift` contract locking the API surface
between detector and renderer. All build clean on first integrate.

## Detector (ObjectDetector.swift)
- `VNRecognizeTextRequest` runs alongside YOLO at ~5 Hz; recognized
  strings + boxes published via `currentTextDetections()`
- `VNDetectHumanBodyPoseRequest` produces a 14-segment skeleton (nose-
  neck-shoulders-arms, neck-root-hips-legs); confidence-gated at 0.3
- `VNDetectHumanHandPoseRequest` produces 5 finger chains + palm ring
  per hand, up to 2 hands; published as kind=.hand poses
- Body + hand published as `[PoseDetection]` via `currentPoses()`
- New `lastDetectMode: DetectMode { .yolo | .track }` so the footer can
  distinguish the two latency regimes
- YOLO warmup runs one dummy inference at startup on the detector
  queue — first user-visible detection no longer pays the ~200ms
  model-compile latency

## Renderer (Renderer.swift + Render/ColorHash.swift)
- Box labels include 10%-bucketed confidence: "CUP 80%", "MOBILE PHONE
  60%". Bucket keeps the label texture cache from exploding.
- Per-track stable color via UUID-byte → golden-ratio hash → HSV(h,
  0.8, 1.0). Face labels stay cyan (calmer when JARVIS pane has
  multiple labeled people in frame).
- Detection labels clamp to pane edges so boxes near the right margin
  don't overflow into the next pane.
- Footer now reads "DET 28.4ms (yolo)" or "DET 2.1ms (track)" — the
  flicker between the two values is informative, not a bug.
- LRU bound on `labelTextureCache` (128 entries, parallel access-order
  array, drop-head-on-insert when at capacity).
- OCR text rendered as magenta boxes with truncated text labels (32
  chars).
- Body pose drawn in mint, hand pose in amber; axis-aligned segment
  rects with a 0.003 minimum thickness so horizontal/vertical lines
  don't vanish at small viewport sizes. Joint dots at each unique
  point.
- New `snapshotPNG() -> Data?` public method. Render loop piggybacks
  one extra blit pass + waitUntilCompleted + CGImage encoding. <50ms
  in practice, 1-second semaphore timeout if rendering stalls.

## App / Window / Capture
- Camera menu with a dynamic Devices submenu (rebuilt via
  NSMenuDelegate.menuNeedsUpdate). Active device gets a checkmark,
  Refresh List forces re-enumeration.
- CameraCapture.switchToDevice(_:) stops the session, swaps the input,
  restarts. activeDeviceUniqueID exposed so the menu can match.
- Save Snapshot (⌘S) writes the 4-pane composite to
  ~/Desktop/VisionPOC-YYYY-MM-DD-HHMMSS.png
- Window-frame persistence for all three windows via UserDefaults
  keys VPOC.MainWindowFrame / VPOC.SettingsWindowFrame /
  VPOC.LogWindowFrame
- AVCaptureDevice.wasDisconnectedNotification → session stop + flag
  set; permissionDenied returns true so the existing
  "WAITING FOR CAMERA…" status path triggers without renderer changes

## Shared contract (new file)
- Sources/Process/ProcessedFrame.swift — TextDetection, PoseDetection,
  PoseDetection.Segment, PoseKind (.body | .hand), DetectMode
  (.yolo | .track). Sendable, value types, normalized [0..1] Vision
  coords throughout.

## Theme
- Palette gains textStroke (magenta), bodyPose (mint), handPose (amber)
  for the new overlays.

Note: Orbitron font and app icon (also Tier 2 items) deferred — no
clean source available for either without manual work.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 16:39:43 -05:00

320 lines
11 KiB
Swift
Raw Permalink 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.
import AVFoundation
import CoreVideo
import Metal
import os
/// Owns the AVCaptureSession and vends the most recent frame as both an MTLTexture
/// (zero-copy via CVMetalTextureCache) and the originating CVPixelBuffer.
///
/// The latest texture and pixel buffer must be held together: the MTLTexture only
/// remains valid while its backing CVPixelBuffer is retained (otherwise the IOSurface
/// gets recycled).
final class CameraCapture: NSObject, AVCaptureVideoDataOutputSampleBufferDelegate, @unchecked Sendable {
private struct Frame {
let pixelBuffer: CVPixelBuffer
let texture: MTLTexture
let width: Int
let height: Int
}
private let device: MTLDevice
private let session = AVCaptureSession()
private let sessionQueue = DispatchQueue(label: "com.dmdbrands.VisionPOC.capture.session")
private let sampleQueue = DispatchQueue(label: "com.dmdbrands.VisionPOC.capture.samples")
private var textureCache: CVMetalTextureCache?
private var currentInput: AVCaptureDeviceInput?
private var output: AVCaptureVideoDataOutput?
private var lock = os_unfair_lock_s()
private var latest: Frame?
private var _permissionDenied = false
private var _deviceDisconnected = false
private var _width: Int = 1920
private var _height: Int = 1080
private var _activeDeviceUniqueID: String?
init(device: MTLDevice) {
self.device = device
super.init()
var cache: CVMetalTextureCache?
let status = CVMetalTextureCacheCreate(kCFAllocatorDefault, nil, device, nil, &cache)
if status != kCVReturnSuccess {
NSLog("CVMetalTextureCacheCreate failed: \(status)")
}
self.textureCache = cache
NotificationCenter.default.addObserver(
self,
selector: #selector(handleDeviceDisconnected(_:)),
name: .AVCaptureDeviceWasDisconnected,
object: nil
)
}
deinit {
NotificationCenter.default.removeObserver(self)
}
var permissionDenied: Bool {
os_unfair_lock_lock(&lock)
defer { os_unfair_lock_unlock(&lock) }
// Treat disconnect as "permission denied" so renderer falls back to its
// existing "WAITING FOR CAMERA…" status path without modification.
return _permissionDenied || _deviceDisconnected
}
var deviceDisconnected: Bool {
os_unfair_lock_lock(&lock)
defer { os_unfair_lock_unlock(&lock) }
return _deviceDisconnected
}
var width: Int {
os_unfair_lock_lock(&lock)
defer { os_unfair_lock_unlock(&lock) }
return _width
}
var height: Int {
os_unfair_lock_lock(&lock)
defer { os_unfair_lock_unlock(&lock) }
return _height
}
var activeDeviceUniqueID: String? {
os_unfair_lock_lock(&lock)
defer { os_unfair_lock_unlock(&lock) }
return _activeDeviceUniqueID
}
func latestTexture() -> MTLTexture? {
os_unfair_lock_lock(&lock)
defer { os_unfair_lock_unlock(&lock) }
return latest?.texture
}
func latestPixelBuffer() -> CVPixelBuffer? {
os_unfair_lock_lock(&lock)
defer { os_unfair_lock_unlock(&lock) }
return latest?.pixelBuffer
}
func start() async {
let status = AVCaptureDevice.authorizationStatus(for: .video)
switch status {
case .authorized:
configureAndStart()
case .notDetermined:
let granted = await AVCaptureDevice.requestAccess(for: .video)
if granted {
configureAndStart()
} else {
setPermissionDenied()
}
case .denied, .restricted:
setPermissionDenied()
@unknown default:
setPermissionDenied()
}
}
private func setPermissionDenied() {
os_unfair_lock_lock(&lock)
_permissionDenied = true
os_unfair_lock_unlock(&lock)
LogStream.shared.log("camera permission denied — grant access in System Settings › Privacy & Security › Camera",
level: .error, source: .camera)
}
private func configureAndStart() {
sessionQueue.async { [weak self] in
guard let self else { return }
self.session.beginConfiguration()
let preset = AVCaptureSession.Preset(rawValue: Theme.Performance.capturePreset.rawValue)
if self.session.canSetSessionPreset(preset) {
self.session.sessionPreset = preset
}
let discovery = AVCaptureDevice.DiscoverySession(
deviceTypes: [.builtInWideAngleCamera, .external, .continuityCamera],
mediaType: .video,
position: .unspecified
)
let camera = discovery.devices.first(where: { $0.position == .front })
?? AVCaptureDevice.default(for: .video)
?? discovery.devices.first
guard let camera else {
self.session.commitConfiguration()
LogStream.shared.log("no video capture device available", level: .error, source: .camera)
return
}
LogStream.shared.log("using camera \"\(camera.localizedName)\" (position \(camera.position.rawValue))",
level: .info, source: .camera)
do {
let input = try AVCaptureDeviceInput(device: camera)
if self.session.canAddInput(input) {
self.session.addInput(input)
self.currentInput = input
}
} catch {
self.session.commitConfiguration()
LogStream.shared.log("failed to create AVCaptureDeviceInput: \(error)", level: .error, source: .camera)
return
}
let output = AVCaptureVideoDataOutput()
output.videoSettings = [
kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA,
kCVPixelBufferMetalCompatibilityKey as String: true
]
output.alwaysDiscardsLateVideoFrames = true
output.setSampleBufferDelegate(self, queue: self.sampleQueue)
if self.session.canAddOutput(output) {
self.session.addOutput(output)
}
self.output = output
os_unfair_lock_lock(&self.lock)
self._activeDeviceUniqueID = camera.uniqueID
os_unfair_lock_unlock(&self.lock)
self.session.commitConfiguration()
self.session.startRunning()
LogStream.shared.log("session running, preset \(Theme.Performance.capturePreset.rawValue)",
level: .info, source: .camera)
}
}
/// Switch the running session over to a different camera device. Safe to call
/// from the main thread — actual session reconfiguration happens on sessionQueue.
func switchToDevice(_ device: AVCaptureDevice) {
sessionQueue.async { [weak self] in
guard let self else { return }
if self.session.isRunning {
self.session.stopRunning()
}
self.session.beginConfiguration()
if let existing = self.currentInput {
self.session.removeInput(existing)
self.currentInput = nil
}
do {
let input = try AVCaptureDeviceInput(device: device)
if self.session.canAddInput(input) {
self.session.addInput(input)
self.currentInput = input
} else {
self.session.commitConfiguration()
LogStream.shared.log("cannot add input for \(device.localizedName)", level: .error, source: .camera)
return
}
} catch {
self.session.commitConfiguration()
LogStream.shared.log("failed to create input for \(device.localizedName): \(error)",
level: .error, source: .camera)
return
}
// If the output got dropped (e.g., never created yet), re-add it.
if self.output == nil {
let output = AVCaptureVideoDataOutput()
output.videoSettings = [
kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA,
kCVPixelBufferMetalCompatibilityKey as String: true
]
output.alwaysDiscardsLateVideoFrames = true
output.setSampleBufferDelegate(self, queue: self.sampleQueue)
if self.session.canAddOutput(output) {
self.session.addOutput(output)
}
self.output = output
}
os_unfair_lock_lock(&self.lock)
self._activeDeviceUniqueID = device.uniqueID
self._deviceDisconnected = false
// Best-effort default; real dimensions arrive with the first sample buffer.
self._width = 1920
self._height = 1080
os_unfair_lock_unlock(&self.lock)
self.session.commitConfiguration()
self.session.startRunning()
LogStream.shared.log("switched to \(device.localizedName)", level: .info, source: .camera)
}
}
@objc private func handleDeviceDisconnected(_ note: Notification) {
guard let disconnected = note.object as? AVCaptureDevice else { return }
os_unfair_lock_lock(&lock)
let activeID = _activeDeviceUniqueID
os_unfair_lock_unlock(&lock)
guard disconnected.uniqueID == activeID else { return }
let name = disconnected.localizedName
sessionQueue.async { [weak self] in
guard let self else { return }
if self.session.isRunning {
self.session.stopRunning()
}
os_unfair_lock_lock(&self.lock)
self._deviceDisconnected = true
self.latest = nil
os_unfair_lock_unlock(&self.lock)
LogStream.shared.log("camera \(name) disconnected", level: .warn, source: .camera)
}
}
// MARK: - AVCaptureVideoDataOutputSampleBufferDelegate
func captureOutput(
_ output: AVCaptureOutput,
didOutput sampleBuffer: CMSampleBuffer,
from connection: AVCaptureConnection
) {
guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer),
let cache = textureCache else { return }
let width = CVPixelBufferGetWidth(pixelBuffer)
let height = CVPixelBufferGetHeight(pixelBuffer)
var cvTexture: CVMetalTexture?
let result = CVMetalTextureCacheCreateTextureFromImage(
kCFAllocatorDefault,
cache,
pixelBuffer,
nil,
.bgra8Unorm,
width,
height,
0,
&cvTexture
)
guard result == kCVReturnSuccess,
let cvTexture,
let metalTexture = CVMetalTextureGetTexture(cvTexture) else {
return
}
let frame = Frame(pixelBuffer: pixelBuffer, texture: metalTexture, width: width, height: height)
os_unfair_lock_lock(&lock)
latest = frame
_width = width
_height = height
os_unfair_lock_unlock(&lock)
CVMetalTextureCacheFlush(cache, 0)
}
}