Files
james.maesandClaude Opus 4.7 3cd94535c8 History view uniform with Log Stream + proper line shader + Status panel (⌘I)
Three asks landing together.

## History view — now identical controls to Log Stream
- Oldest-first order with auto-scroll-to-bottom on append (was newest-
  first, no auto-scroll). ScrollViewReader + onChange pattern lifted
  from LogStreamView for behavioral parity.
- Toolbar now matches the Log Stream layout exactly: Category filter,
  Auto-scroll toggle, Pause toggle, filtered/total count, Clear button.
- Poll rate bumped 1 Hz → 0.10 s so new events appear instantly. Pause
  toggle freezes refresh without dropping the running event collection.

## Proper line shader (much better hand + body rendering)
- New `line_vertex` / `line_fragment` in Boxes.metal alongside the
  existing box shader. `LineUniforms` carries endpoints + thickness +
  soft-edge falloff + a dot-mode flag.
- Line mode: emits a rotated thin quad oriented along (end - start)
  with width = thickness. Soft-edge antialiasing via smoothstep on the
  signed perpendicular UV makes diagonal skeleton segments read as
  actual strokes instead of axis-aligned bounding boxes.
- Dot mode: emits a square around `start` and the fragment discards
  outside the unit-circle — produces filled disks for joint markers
  with sub-pixel-clean soft edges.
- Pipelines gains a `lines` blended pipeline.
- Renderer.drawPoses() rewritten to use the line pipeline — one draw
  per segment, one draw per joint, all visually consistent regardless
  of segment angle. Theme.Palette.bodyPose (mint) for body, handPose
  (amber) for hand.

## Status panel (⌘I)
- New `CurrentStateStore` singleton subscribes to the event bus and
  retains the most recent value per concept: expression (smile / frown /
  neutral_face), activity (standing / sitting / etc.), per-chirality
  finger counts, plus a small ring of recent non-finger non-expression
  gestures.
- New StatusView (SwiftUI) reads three sources at 4 Hz: the live
  ObjectDetector for FPS + DET timing + objects + faces, the
  CurrentStateStore for hands/expression/activity/recent-gestures, and
  the HistoryStore for an 8-entry tail of recent events.
- New StatusWindowController hosts the SwiftUI view as a dark-aqua
  floating window with frame persistence (VPOC.StatusWindowFrame).
- AppDelegate `openStatus(_:)` builds a DetectorSnapshot from the live
  windowController + detector each tick, partitioning detections into
  face entries (anything labeled FACE or matching an enrolled name)
  vs object entries. Object entries carry the same per-track color the
  JARVIS pane uses (via ColorHash.colorFor) so the panel's dots match
  the on-screen box colors.
- Renderer exposes `smoothedFPSForReadout` so the panel can show the
  same FPS the footer does without reaching into private state.
- FingerCounter now records its count per chirality via
  CurrentStateStore.recordFingers (in addition to the existing bus
  emit), using VNHumanHandPoseObservation.chirality. The panel
  displays "Left: 3 fingers / Right: 5 fingers" correctly.

Menu structure now:
- App menu: Settings (⌘,) · Log Stream (⌘L) · Status (⌘I) · Save
  Snapshot (⌘S) · Start/Stop Recording (⇧⌘R) · Quit
- Faces menu: Enroll (⌘E) · Forget (⇧⌘E) · History (⌘H)
- Camera menu: Devices ▶ · Refresh · Privacy Mode (⇧⌘P)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 17:46:27 -05:00

871 lines
37 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 MetalKit
import AppKit
import simd
import os
import ImageIO
import UniformTypeIdentifiers
/// Camera textures coming from AVFoundation are stored origin-top-left. The MSL
/// `quad_vertex` shader emits UVs with `v = 1.0 - v` so image samples appear right-side-up
/// on screen. Vision boundingBoxes are normalized [0..1] with origin bottom-left,
/// matching Metal NDC y-up after the same flip — so detection rects line up with the
/// LIVE/JARVIS panes without further adjustment.
///
/// Shader-side contracts (must match Sources/Shaders/*.metal):
/// - `quad_vertex(vertex_id)` emits a fullscreen NDC quad with UVs that flip v.
/// - `box_vertex` reads BoxUniforms { float4 rect (NDC xy origin + zw size); float4 color }
/// at vertex buffer index 0 and emits 8 line vertices for vertex_ids 0..7 forming
/// a rectangle outline, OR 6 triangle vertices for vertex_ids 0..5 forming a filled quad.
/// We use the line interpretation here (drawPrimitives(.line, vertexCount: 8)) for boxes.
/// - `text_fragment` samples texture index 0 (BGRA premultiplied) and multiplies by
/// the tint at fragment buffer index 0 (single float4).
final class Renderer: NSObject, MTKViewDelegate {
private struct BoxUniforms {
var rect: SIMD4<Float>
var color: SIMD4<Float>
var params: SIMD4<Float> // x = mode (0=hollow stroke, 1=solid fill), y/z/w reserved
}
private let device: MTLDevice
private let commandQueue: MTLCommandQueue
private let library: MTLLibrary
private let pipelines: Pipelines
private let capture: CameraCapture
private let detector: ObjectDetector
private let edgePass: EdgePass
private let jarvisPass: JarvisStylePass
private let asciiPass: AsciiPass
private let textRasterizer: TextRasterizer
private var labelTextureCache: [String: MTLTexture] = [:]
/// Parallel array tracking access order for `labelTextureCache`. Head is
/// LRU, tail is MRU. On cache hit, move-to-end. On cache miss with the
/// cache at capacity, drop head before inserting.
private var labelTextureCacheKeys: [String] = []
private let labelTextureCacheCapacity = 128
private let nearestSampler: MTLSamplerState
private let linearSampler: MTLSamplerState
/// Set by `snapshotPNG()` on a non-render thread. The next `draw(in:)`
/// pass renders normally, then copies the drawable's texture into a
/// CPU-readable MTLTexture, encodes it as PNG, fires this completion,
/// and clears the flag.
private var pendingSnapshotCompletion: ((Data?) -> Void)?
/// Guards `pendingSnapshotCompletion` so the snapshot caller (background)
/// and the render loop (MainActor) don't race on read/write.
private var snapshotLock = os_unfair_lock_s()
private let startTime: CFAbsoluteTime = CFAbsoluteTimeGetCurrent()
private var lastFrameTime: CFAbsoluteTime
private var frameCounter: Int = 0
private var smoothedFPS: Double = 60
/// MainActor-readable view of the smoothed FPS. The renderer's
/// `draw(in:)` already runs on MainActor (MTKViewDelegate contract), so
/// reading this from MainActor is race-free.
var smoothedFPSForReadout: Double { smoothedFPS }
private var paneLabelTextures: [MTLTexture?] = [nil, nil, nil, nil]
private var footerTexture: MTLTexture?
private var statusTexture: MTLTexture?
private var statusText: String = ""
private var backingScale: CGFloat = 2.0
/// Called from `draw(in:)` AFTER the regular render pass commits, BEFORE
/// `commandBuffer.present(drawable)`. The handler receives the drawable's
/// texture so a recorder can read pixels from it. The handler runs on the
/// MainActor (the MTKView delegate thread). Set to nil to disable.
var frameTap: (@MainActor (MTLTexture, CFTimeInterval) -> Void)?
init?(
view: MTKView,
device: MTLDevice,
capture: CameraCapture,
detector: ObjectDetector,
edgePass: EdgePass,
jarvisPass: JarvisStylePass,
asciiPass: AsciiPass
) {
guard let queue = device.makeCommandQueue(),
let library = device.makeDefaultLibrary() else {
return nil
}
do {
self.pipelines = try Pipelines(device: device, library: library)
} catch {
NSLog("Failed to build pipelines: \(error)")
return nil
}
self.device = device
self.commandQueue = queue
self.library = library
self.capture = capture
self.detector = detector
self.edgePass = edgePass
self.jarvisPass = jarvisPass
self.asciiPass = asciiPass
self.textRasterizer = TextRasterizer(device: device)
self.lastFrameTime = CFAbsoluteTimeGetCurrent()
let nearestDesc = MTLSamplerDescriptor()
nearestDesc.minFilter = .nearest
nearestDesc.magFilter = .nearest
nearestDesc.sAddressMode = .clampToEdge
nearestDesc.tAddressMode = .clampToEdge
guard let nearest = device.makeSamplerState(descriptor: nearestDesc) else { return nil }
self.nearestSampler = nearest
let linearDesc = MTLSamplerDescriptor()
linearDesc.minFilter = .linear
linearDesc.magFilter = .linear
linearDesc.sAddressMode = .clampToEdge
linearDesc.tAddressMode = .clampToEdge
guard let linear = device.makeSamplerState(descriptor: linearDesc) else { return nil }
self.linearSampler = linear
super.init()
}
func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) {
// No-op: everything is normalized.
}
func draw(in view: MTKView) {
let now = CFAbsoluteTimeGetCurrent()
let deltaTime = Float(now - lastFrameTime)
lastFrameTime = now
let elapsed = Float(now - startTime)
frameCounter &+= 1
if deltaTime > 0 {
let instant = 1.0 / Double(deltaTime)
smoothedFPS = smoothedFPS * 0.92 + instant * 0.08
}
backingScale = view.window?.backingScaleFactor ?? 2.0
let drawableSize = view.drawableSize
guard let drawable = view.currentDrawable,
let descriptor = view.currentRenderPassDescriptor,
let commandBuffer = commandQueue.makeCommandBuffer() else {
return
}
descriptor.colorAttachments[0].loadAction = .clear
let bg = Theme.Palette.background
descriptor.colorAttachments[0].clearColor = MTLClearColor(
red: Double(bg.x), green: Double(bg.y), blue: Double(bg.z), alpha: Double(bg.w)
)
guard let source = capture.latestTexture() else {
drawStatusOnly(
commandBuffer: commandBuffer,
descriptor: descriptor,
drawableSize: drawableSize
)
commandBuffer.present(drawable)
commandBuffer.commit()
return
}
if let pb = capture.latestPixelBuffer() {
detector.submit(pixelBuffer: pb)
}
let edgesTexture = edgePass.encode(source: source, commandBuffer: commandBuffer)
guard let encoder = commandBuffer.makeRenderCommandEncoder(descriptor: descriptor) else {
commandBuffer.commit()
return
}
let panes = Theme.Layout.panes
let detections = detector.currentDetections()
let sourceAspect: CGFloat = {
let w = max(1, capture.width)
let h = max(1, capture.height)
return CGFloat(w) / CGFloat(h)
}()
// Per-pane aspect-fit inner rect. Letterboxes the video content so the
// image keeps its source aspect ratio even when the pane is a
// different shape; brackets and detection overlays follow the inner
// rect so the HUD frames the video itself, not the bounding cell.
var innerRects: [CGRect] = []
innerRects.reserveCapacity(panes.count)
for index in 0..<panes.count {
let paneRect = pixelRect(panes[index], in: drawableSize)
let inner = aspectFitRect(into: paneRect, sourceAspect: sourceAspect)
innerRects.append(inner)
setViewport(encoder: encoder, rect: inner)
switch index {
case 0:
drawImage(encoder: encoder, pipeline: pipelines.live, texture: source, sampler: linearSampler)
case 1:
drawJarvis(encoder: encoder, texture: source, time: elapsed, paneSize: inner.size)
drawDetections(encoder: encoder, detections: detections)
drawDetectionLabels(encoder: encoder, detections: detections, paneRect: inner)
let texts = detector.currentTextDetections()
drawTextDetections(encoder: encoder, texts: texts, paneRect: inner)
let poses = detector.currentPoses()
drawPoses(encoder: encoder, poses: poses, paneRect: inner)
case 2:
drawImage(encoder: encoder, pipeline: pipelines.edges, texture: edgesTexture, sampler: nearestSampler)
case 3:
drawAscii(encoder: encoder, texture: source, paneSize: inner.size)
default:
break
}
}
drawCornerBrackets(encoder: encoder, drawableSize: drawableSize, innerRects: innerRects)
drawPaneLabels(encoder: encoder, drawableSize: drawableSize, innerRects: innerRects)
drawFooter(encoder: encoder, drawableSize: drawableSize)
encoder.endEncoding()
// Snapshot path — piggyback on this frame's command buffer. We blit
// the drawable's texture into a CPU-readable shared-storage texture,
// then synchronously wait for the GPU to finish before reading back
// and encoding as PNG. ~10-30ms typical (dominated by waitUntilCompleted).
let snapshotCompletion = takePendingSnapshotCompletion()
if let completion = snapshotCompletion {
let readback = makeReadbackTexture(matching: drawable.texture)
if let readback,
let blit = commandBuffer.makeBlitCommandEncoder() {
blit.copy(from: drawable.texture, to: readback)
blit.endEncoding()
commandBuffer.present(drawable)
commandBuffer.commit()
commandBuffer.waitUntilCompleted()
let data = encodePNG(from: readback)
completion(data)
} else {
// Couldn't allocate readback; fail gracefully.
commandBuffer.present(drawable)
commandBuffer.commit()
completion(nil)
}
} else {
// Frame tap: invoked after the regular render pass is encoded but
// before the drawable is presented. The recorder reads pixels via
// its own command buffer + synchronous blit, so the drawable's
// texture must still be valid at the time of the call.
if let tap = frameTap {
tap(drawable.texture, CACurrentMediaTime())
}
commandBuffer.present(drawable)
commandBuffer.commit()
}
}
// MARK: - Snapshot
/// Public synchronous API. Sets the pending-completion flag, waits via a
/// semaphore for the next render loop to fulfill it, returns the PNG
/// data (or nil on failure). Safe to call off the MainActor; the wait is
/// at most ~30ms per shot.
func snapshotPNG() -> Data? {
let semaphore = DispatchSemaphore(value: 0)
// Capture into a class-level Sendable box so the completion can write
// it from whatever thread fires the completion.
let box = SnapshotBox()
let completion: (Data?) -> Void = { data in
box.data = data
semaphore.signal()
}
os_unfair_lock_lock(&snapshotLock)
// If a previous snapshot is still in flight, fail this one rather
// than stomp on it. The caller can retry.
if pendingSnapshotCompletion != nil {
os_unfair_lock_unlock(&snapshotLock)
return nil
}
pendingSnapshotCompletion = completion
os_unfair_lock_unlock(&snapshotLock)
// 1 second is generous; if the render loop is stalled for longer than
// that, returning nil and letting the user retry is better than
// hanging the menu thread forever.
let result = semaphore.wait(timeout: .now() + .seconds(1))
if result == .timedOut {
os_unfair_lock_lock(&snapshotLock)
pendingSnapshotCompletion = nil
os_unfair_lock_unlock(&snapshotLock)
return nil
}
return box.data
}
private final class SnapshotBox: @unchecked Sendable {
var data: Data?
}
private func takePendingSnapshotCompletion() -> ((Data?) -> Void)? {
os_unfair_lock_lock(&snapshotLock)
let c = pendingSnapshotCompletion
pendingSnapshotCompletion = nil
os_unfair_lock_unlock(&snapshotLock)
return c
}
/// Allocates a shared-storage texture matching the given drawable texture's
/// dimensions and pixel format, so its bytes are CPU-addressable after the
/// blit completes. The drawable's own texture has private storage and
/// cannot be read back directly.
private func makeReadbackTexture(matching source: MTLTexture) -> MTLTexture? {
let descriptor = MTLTextureDescriptor.texture2DDescriptor(
pixelFormat: source.pixelFormat,
width: source.width,
height: source.height,
mipmapped: false
)
descriptor.usage = [.shaderRead]
descriptor.storageMode = .shared
return device.makeTexture(descriptor: descriptor)
}
private func encodePNG(from texture: MTLTexture) -> Data? {
let width = texture.width
let height = texture.height
let bytesPerRow = width * 4
var bytes = [UInt8](repeating: 0, count: bytesPerRow * height)
let region = MTLRegionMake2D(0, 0, width, height)
bytes.withUnsafeMutableBytes { ptr in
if let base = ptr.baseAddress {
texture.getBytes(base, bytesPerRow: bytesPerRow, from: region, mipmapLevel: 0)
}
}
// Drawable is bgra8Unorm. CGBitmapContext wants the byte order spelled
// out via CGBitmapInfo: byteOrder32Little + premultipliedFirst means
// memory layout BGRA, which matches the texture exactly — no swizzle.
let colorSpace = CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB()
let bitmapInfo: CGBitmapInfo = [
CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedFirst.rawValue),
.byteOrder32Little
]
guard let context = bytes.withUnsafeMutableBytes({ ptr -> CGContext? in
CGContext(
data: ptr.baseAddress,
width: width,
height: height,
bitsPerComponent: 8,
bytesPerRow: bytesPerRow,
space: colorSpace,
bitmapInfo: bitmapInfo.rawValue
)
}) else {
return nil
}
guard let cgImage = context.makeImage() else { return nil }
let nsData = NSMutableData()
guard let destination = CGImageDestinationCreateWithData(
nsData as CFMutableData,
UTType.png.identifier as CFString,
1,
nil
) else {
return nil
}
CGImageDestinationAddImage(destination, cgImage, nil)
guard CGImageDestinationFinalize(destination) else { return nil }
return nsData as Data
}
// MARK: - No-source path
private func drawStatusOnly(
commandBuffer: MTLCommandBuffer,
descriptor: MTLRenderPassDescriptor,
drawableSize: CGSize
) {
guard let encoder = commandBuffer.makeRenderCommandEncoder(descriptor: descriptor) else { return }
let message = capture.permissionDenied
? "CAMERA PERMISSION DENIED — grant access in System Settings › Privacy & Security › Camera"
: "WAITING FOR CAMERA…"
if statusTexture == nil || message != statusText {
statusText = message
statusTexture = makeLabelTexture(message, font: Theme.Font.body(Theme.Font.paneLabelSize), wide: true)
}
if let texture = statusTexture {
let widthPx = CGFloat(texture.width) / backingScale * backingScale
let heightPx = CGFloat(texture.height) / backingScale * backingScale
let rect = CGRect(
x: (drawableSize.width - widthPx) * 0.5,
y: (drawableSize.height - heightPx) * 0.5,
width: widthPx,
height: heightPx
)
setViewport(encoder: encoder, rect: rect)
drawTextQuad(encoder: encoder, texture: texture, tint: Theme.Palette.label)
}
encoder.endEncoding()
}
// MARK: - Drawing helpers
/// Returns the largest rect that fits inside `pane` while preserving
/// `sourceAspect`. Centers the result horizontally and vertically so the
/// letterbox bars are symmetric. Used to keep camera video square with the
/// source even when the pane cell isn't.
private func aspectFitRect(into pane: CGRect, sourceAspect: CGFloat) -> CGRect {
guard pane.width > 0, pane.height > 0, sourceAspect > 0 else { return pane }
let paneAspect = pane.width / pane.height
if abs(paneAspect - sourceAspect) < 0.001 {
return pane
}
var width = pane.width
var height = pane.height
if paneAspect > sourceAspect {
// Pane is wider than the source — shrink width, keep height.
width = pane.height * sourceAspect
} else {
// Pane is taller than the source — shrink height, keep width.
height = pane.width / sourceAspect
}
let originX = pane.origin.x + (pane.width - width) * 0.5
let originY = pane.origin.y + (pane.height - height) * 0.5
return CGRect(x: originX, y: originY, width: width, height: height)
}
private func pixelRect(_ normalized: CGRect, in size: CGSize) -> CGRect {
// Theme rects are origin bottom-left. MTLViewport originY is top-left in pixels.
let originX = normalized.origin.x * size.width
let originY = (1.0 - normalized.origin.y - normalized.height) * size.height
return CGRect(
x: originX,
y: originY,
width: normalized.width * size.width,
height: normalized.height * size.height
)
}
private func setViewport(encoder: MTLRenderCommandEncoder, rect: CGRect) {
let viewport = MTLViewport(
originX: Double(rect.origin.x),
originY: Double(rect.origin.y),
width: Double(rect.width),
height: Double(rect.height),
znear: 0, zfar: 1
)
encoder.setViewport(viewport)
}
private func drawImage(
encoder: MTLRenderCommandEncoder,
pipeline: MTLRenderPipelineState,
texture: MTLTexture,
sampler: MTLSamplerState
) {
encoder.setRenderPipelineState(pipeline)
encoder.setFragmentTexture(texture, index: 0)
encoder.setFragmentSamplerState(sampler, index: 0)
encoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 6)
}
private func drawJarvis(
encoder: MTLRenderCommandEncoder,
texture: MTLTexture,
time: Float,
paneSize: CGSize
) {
encoder.setRenderPipelineState(pipelines.jarvis)
encoder.setFragmentTexture(texture, index: 0)
encoder.setFragmentSamplerState(linearSampler, index: 0)
var uniforms = jarvisPass.uniforms(time: time, viewportSize: paneSize)
encoder.setFragmentBytes(&uniforms, length: MemoryLayout<JarvisUniforms>.stride, index: 0)
encoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 6)
}
private func drawDetections(encoder: MTLRenderCommandEncoder, detections: [Detection]) {
encoder.setRenderPipelineState(pipelines.boxes)
for detection in detections {
// Vision boundingBox: normalized [0..1] origin bottom-left.
// Viewport NDC: origin bottom-left of the viewport, range [-1..1].
let r = detection.rect
let originNDC = SIMD2<Float>(Float(r.origin.x) * 2 - 1, Float(r.origin.y) * 2 - 1)
let sizeNDC = SIMD2<Float>(Float(r.width) * 2, Float(r.height) * 2)
var uniforms = BoxUniforms(
rect: SIMD4<Float>(originNDC.x, originNDC.y, sizeNDC.x, sizeNDC.y),
color: boxColor(for: detection),
params: SIMD4<Float>(0, 0, 0, 0) // mode 0 = hollow stroke
)
encoder.setVertexBytes(&uniforms, length: MemoryLayout<BoxUniforms>.stride, index: 0)
encoder.setFragmentBytes(&uniforms, length: MemoryLayout<BoxUniforms>.stride, index: 0)
encoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 6)
}
}
/// Cyan for face/identity labels, per-track hue for everything else.
/// Face labels are recognizable enough on their own that a stable cyan
/// makes the UI feel less chaotic than randomized hues.
private func boxColor(for detection: Detection) -> SIMD4<Float> {
if isFaceLabel(detection.label) {
return Theme.Palette.boxStroke
}
return ColorHash.colorFor(trackId: detection.trackId)
}
private func isFaceLabel(_ label: String) -> Bool {
if label == "FACE" { return true }
// FaceRegistry names are uppercased before being assigned as a label
// (see ObjectDetector.detectAndBootstrap). Anything in the registry
// should keep the cyan stroke. Falling back to "all caps single word"
// would accidentally include YOLO labels like CUP; check the registry
// instead. The enrolledNames() call is cheap (dictionary keys snapshot
// behind a small lock); we run it at most once per detection per frame.
let names = detector.faceRegistry.enrolledNames()
return names.contains(where: { $0.uppercased() == label })
}
private func drawCornerBrackets(encoder: MTLRenderCommandEncoder, drawableSize: CGSize, innerRects: [CGRect]) {
let bracketLen = Theme.HUD.cornerBracketLength * backingScale
let thickness = Theme.HUD.cornerBracketThickness * backingScale
let color = Theme.Palette.cyan
encoder.setRenderPipelineState(pipelines.boxes)
for paneIndex in 0..<innerRects.count {
// Hug the aspect-fitted video rect instead of the outer pane cell
// so the brackets visually frame the picture, not the letterbox.
let rect = innerRects[paneIndex]
let inset = Theme.HUD.paneFrameInset * backingScale
let x0 = rect.origin.x + inset
let y0 = rect.origin.y + inset
let x1 = rect.origin.x + rect.width - inset
let y1 = rect.origin.y + rect.height - inset
// Bracket "L" segments — thin filled rectangles drawn via viewport + fullscreen quad.
// We use the boxes pipeline because it can fill from NDC uniforms via vertex_ids 0..5
// (the shader treats indices 0..5 as a triangle quad covering the uniform rect).
// For pixel-perfect placement, switch to viewport-based draws instead:
let segs: [(CGFloat, CGFloat, CGFloat, CGFloat)] = [
(x0, y0, x0 + bracketLen, y0 + thickness),
(x0, y0, x0 + thickness, y0 + bracketLen),
(x1 - bracketLen, y0, x1, y0 + thickness),
(x1 - thickness, y0, x1, y0 + bracketLen),
(x0, y1 - thickness, x0 + bracketLen, y1),
(x0, y1 - bracketLen, x0 + thickness, y1),
(x1 - bracketLen, y1 - thickness, x1, y1),
(x1 - thickness, y1 - bracketLen, x1, y1)
]
for seg in segs {
let segRect = CGRect(x: seg.0, y: seg.1, width: seg.2 - seg.0, height: seg.3 - seg.1)
setViewport(encoder: encoder, rect: segRect)
var uniforms = BoxUniforms(
rect: SIMD4<Float>(-1, -1, 2, 2),
color: color,
params: SIMD4<Float>(1, 0, 0, 0) // mode 1 = solid fill
)
encoder.setVertexBytes(&uniforms, length: MemoryLayout<BoxUniforms>.stride, index: 0)
encoder.setFragmentBytes(&uniforms, length: MemoryLayout<BoxUniforms>.stride, index: 0)
encoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 6)
}
}
}
private func drawPaneLabels(encoder: MTLRenderCommandEncoder, drawableSize: CGSize, innerRects: [CGRect]) {
let labels = Theme.Layout.paneLabels
let inset = Theme.HUD.paneFrameInset * backingScale + 8 * backingScale
let labelHeightPx: CGFloat = 24 * backingScale
for index in 0..<min(labels.count, innerRects.count) {
if paneLabelTextures[index] == nil {
paneLabelTextures[index] = makeLabelTexture(
labels[index],
font: Theme.Font.title(Theme.Font.paneLabelSize)
)
}
guard let texture = paneLabelTextures[index] else { continue }
// Anchor labels to the inner (aspect-fit) rect so they sit inside
// the visible video, not floating on a letterbox bar.
let inner = innerRects[index]
let widthPx = CGFloat(texture.width)
let xPx = inner.origin.x + inset
let yPx = inner.origin.y + inset
let rect = CGRect(x: xPx, y: yPx, width: widthPx, height: labelHeightPx)
setViewport(encoder: encoder, rect: rect)
drawTextQuad(encoder: encoder, texture: texture, tint: Theme.Palette.label)
}
}
private func drawFooter(encoder: MTLRenderCommandEncoder, drawableSize: CGSize) {
if frameCounter % 10 == 0 || footerTexture == nil {
let profile: String
switch Theme.Performance.profile {
case .balanced: profile = "BALANCED"
case .quality: profile = "QUALITY"
}
let mode = detector.lastDetectMode.rawValue
let text = String(
format: "VISIONPOC · FPS %d · DET %.1fms (%@) · %@",
Int(smoothedFPS.rounded()),
detector.lastInferenceMs,
mode,
profile
)
footerTexture = makeLabelTexture(text, font: Theme.Font.body(Theme.Font.footerSize), wide: true)
}
guard let texture = footerTexture else { return }
let widthPx = CGFloat(texture.width)
let heightPx = Theme.HUD.footerHeight * backingScale
let inset = (Theme.HUD.paneFrameInset + 4) * backingScale
// Anchor the footer to the BOTTOM of the drawable. MTLViewport y is
// measured from the top of the drawable, so larger y means lower on
// screen — subtract the footer height + a small margin from the full
// drawable height to sit just above the bottom edge.
let rect = CGRect(
x: inset,
y: drawableSize.height - heightPx - 2 * backingScale,
width: widthPx,
height: heightPx
)
setViewport(encoder: encoder, rect: rect)
drawTextQuad(encoder: encoder, texture: texture, tint: Theme.Palette.micro)
}
// MARK: - Detection labels & ASCII art
/// LRU-bounded label texture cache. Capacity capped at
/// `labelTextureCacheCapacity`. On hit, the key is moved to the tail
/// (most-recently-used). On miss with the cache full, the head (LRU) is
/// evicted before insertion.
private func labelTexture(for text: String) -> MTLTexture? {
if let cached = labelTextureCache[text] {
// Move-to-end on hit. removeAll(where:) is O(n) for a 128-element
// array; trivially fast and avoids pulling in a real LRU library.
labelTextureCacheKeys.removeAll(where: { $0 == text })
labelTextureCacheKeys.append(text)
return cached
}
guard let tex = makeLabelTexture(text, font: Theme.Font.body(Theme.Font.boxLabelSize)) else {
return nil
}
if labelTextureCacheKeys.count >= labelTextureCacheCapacity,
let oldest = labelTextureCacheKeys.first {
labelTextureCacheKeys.removeFirst()
labelTextureCache.removeValue(forKey: oldest)
}
labelTextureCache[text] = tex
labelTextureCacheKeys.append(text)
return tex
}
private func drawDetectionLabels(
encoder: MTLRenderCommandEncoder,
detections: [Detection],
paneRect: CGRect
) {
let labelHeight: CGFloat = 18 * backingScale
let inset: CGFloat = 4 * backingScale
for det in detections {
// Confidence quantized to 10% buckets to keep label cache small.
// A CUP at 80% and a CUP at 90% get separate cached textures,
// but two CUPs at 85% share one (both round to 90%).
let bucket = Int((det.confidence * 10).rounded()) * 10
let displayed = "\(det.label) \(bucket)%"
guard let texture = labelTexture(for: displayed) else { continue }
let r = det.rect
let boxLeftPx = paneRect.origin.x + CGFloat(r.origin.x) * paneRect.width
// Box top edge in screen pixels (y-down). Vision y is origin
// bottom-left, so the top edge of the box in image-y-down is
// `1 - (y + h)`.
let boxTopPx = paneRect.origin.y
+ (1.0 - (CGFloat(r.origin.y) + CGFloat(r.height))) * paneRect.height
let labelWidth = CGFloat(texture.width)
// Clamp to pane edges so the label doesn't extend past the
// drawable when a box is hugging the right or top edge.
let rawX = boxLeftPx + inset
let rawY = boxTopPx + inset
let clampedX = min(rawX, paneRect.maxX - labelWidth - inset)
let clampedY = max(rawY, paneRect.minY + inset)
let rect = CGRect(
x: clampedX,
y: clampedY,
width: labelWidth,
height: labelHeight
)
setViewport(encoder: encoder, rect: rect)
drawTextQuad(encoder: encoder, texture: texture, tint: boxColor(for: det))
}
}
// MARK: - OCR overlay
private func drawTextDetections(
encoder: MTLRenderCommandEncoder,
texts: [TextDetection],
paneRect: CGRect
) {
guard !texts.isEmpty else { return }
// Hollow magenta box per recognized text region.
encoder.setRenderPipelineState(pipelines.boxes)
for t in texts {
let r = t.rect
let originNDC = SIMD2<Float>(Float(r.origin.x) * 2 - 1, Float(r.origin.y) * 2 - 1)
let sizeNDC = SIMD2<Float>(Float(r.width) * 2, Float(r.height) * 2)
var uniforms = BoxUniforms(
rect: SIMD4<Float>(originNDC.x, originNDC.y, sizeNDC.x, sizeNDC.y),
color: Theme.Palette.textStroke,
params: SIMD4<Float>(0, 0, 0, 0)
)
encoder.setVertexBytes(&uniforms, length: MemoryLayout<BoxUniforms>.stride, index: 0)
encoder.setFragmentBytes(&uniforms, length: MemoryLayout<BoxUniforms>.stride, index: 0)
encoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 6)
}
// Magenta label INSIDE the top-left of each text rect, clamped to pane.
let labelHeight: CGFloat = 18 * backingScale
let inset: CGFloat = 4 * backingScale
for t in texts {
// OCR strings can be arbitrarily long; truncate to a sane prefix
// before caching so cache key cardinality stays bounded. The LRU
// cap also kicks in here.
let key = t.text.count > 32
? String(t.text.prefix(32))
: t.text
guard let texture = labelTexture(for: key) else { continue }
let r = t.rect
let boxLeftPx = paneRect.origin.x + CGFloat(r.origin.x) * paneRect.width
let boxTopPx = paneRect.origin.y
+ (1.0 - (CGFloat(r.origin.y) + CGFloat(r.height))) * paneRect.height
let labelWidth = CGFloat(texture.width)
let rawX = boxLeftPx + inset
let rawY = boxTopPx + inset
let clampedX = min(rawX, paneRect.maxX - labelWidth - inset)
let clampedY = max(rawY, paneRect.minY + inset)
let rect = CGRect(
x: clampedX,
y: clampedY,
width: labelWidth,
height: labelHeight
)
setViewport(encoder: encoder, rect: rect)
drawTextQuad(encoder: encoder, texture: texture, tint: Theme.Palette.textStroke)
}
}
// MARK: - Pose overlay
/// Mirrors the MSL `LineUniforms` struct.
private struct LineUniforms {
var endpoints: SIMD4<Float> // xy = start NDC, zw = end NDC
var color: SIMD4<Float>
var params: SIMD4<Float> // x = thickness in NDC, y = soft-edge falloff, z = dot mode flag, w reserved
}
private func drawPoses(
encoder: MTLRenderCommandEncoder,
poses: [PoseDetection],
paneRect: CGRect
) {
guard !poses.isEmpty else { return }
encoder.setRenderPipelineState(pipelines.lines)
// Compute thickness in NDC. We want a visually consistent stroke,
// ~3.5 px on screen. Convert px → NDC using the *smaller* pane
// dimension so circles stay round.
let thicknessPx: CGFloat = 3.5 * backingScale
let dotPx: CGFloat = 5.0 * backingScale
let lineThickNDC = Float(thicknessPx / max(min(paneRect.width, paneRect.height), 1)) * 2
let dotThickNDC = Float(dotPx / max(min(paneRect.width, paneRect.height), 1)) * 2
for pose in poses {
let color: SIMD4<Float>
switch pose.kind {
case .body: color = Theme.Palette.bodyPose
case .hand: color = Theme.Palette.handPose
}
// ---- Skeleton segments ----
// Use the rotated-quad line shader so diagonals look like actual
// strokes instead of axis-aligned bounding boxes.
for seg in pose.segments {
let sxNDC = Float(seg.start.x) * 2 - 1
let syNDC = Float(seg.start.y) * 2 - 1
let exNDC = Float(seg.end.x) * 2 - 1
let eyNDC = Float(seg.end.y) * 2 - 1
var uniforms = LineUniforms(
endpoints: SIMD4<Float>(sxNDC, syNDC, exNDC, eyNDC),
color: color,
params: SIMD4<Float>(lineThickNDC, 0.35, 0, 0)
)
encoder.setVertexBytes(&uniforms, length: MemoryLayout<LineUniforms>.stride, index: 0)
encoder.setFragmentBytes(&uniforms, length: MemoryLayout<LineUniforms>.stride, index: 0)
encoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 6)
}
// ---- Joint dots (filled circles via the dot-mode line shader) ----
for p in pose.points {
let pxNDC = Float(p.x) * 2 - 1
let pyNDC = Float(p.y) * 2 - 1
var uniforms = LineUniforms(
endpoints: SIMD4<Float>(pxNDC, pyNDC, pxNDC, pyNDC),
color: color,
params: SIMD4<Float>(dotThickNDC, 0.0, 1.0, 0) // dot mode
)
encoder.setVertexBytes(&uniforms, length: MemoryLayout<LineUniforms>.stride, index: 0)
encoder.setFragmentBytes(&uniforms, length: MemoryLayout<LineUniforms>.stride, index: 0)
encoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 6)
}
}
}
private func drawAscii(
encoder: MTLRenderCommandEncoder,
texture: MTLTexture,
paneSize: CGSize
) {
encoder.setRenderPipelineState(pipelines.ascii)
encoder.setFragmentTexture(texture, index: 0)
encoder.setFragmentTexture(asciiPass.atlasTexture, index: 1)
encoder.setFragmentSamplerState(linearSampler, index: 0)
var uniforms = asciiPass.uniforms(paneSize: paneSize)
encoder.setFragmentBytes(&uniforms, length: MemoryLayout<AsciiUniforms>.stride, index: 0)
encoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 6)
}
private func drawTextQuad(
encoder: MTLRenderCommandEncoder,
texture: MTLTexture,
tint: SIMD4<Float>
) {
encoder.setRenderPipelineState(pipelines.hudText)
encoder.setFragmentTexture(texture, index: 0)
encoder.setFragmentSamplerState(linearSampler, index: 0)
var color = tint
encoder.setFragmentBytes(&color, length: MemoryLayout<SIMD4<Float>>.stride, index: 0)
encoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 6)
}
private func makeLabelTexture(_ text: String, font: NSFont, wide: Bool = false) -> MTLTexture? {
let maxWidth: CGFloat = wide ? 800 : 220
let maxHeight: CGFloat = 30
let color = NSColor(
deviceRed: CGFloat(Theme.Palette.label.x),
green: CGFloat(Theme.Palette.label.y),
blue: CGFloat(Theme.Palette.label.z),
alpha: CGFloat(Theme.Palette.label.w)
)
return textRasterizer.rasterize(
text,
font: font,
color: color,
maxSize: CGSize(width: maxWidth, height: maxHeight),
scale: backingScale
)
}
}