Four parallel agents shipped Tier 1+2+3 of the punch list, coordinated through a fixed DetectionEventBus contract created upfront. ## Detection event bus (shared contract) - Sources/Process/DetectionEvents.swift — `DetectionEvent.Kind` enum covering objectAppeared/Refreshed/Disappeared, faceRecognized, faceSeenUnknown, textRecognized, gestureDetected, activityDetected, spatialRelation. - Sources/Process/DetectionEventBus.swift — thread-safe pub/sub, Token pattern with auto-unsubscribe on deinit, synchronous emit on caller's queue. Subscribers hop to their own queue as needed. ## ObjectDetector additions (Agent A) - Publishes events for every track-add, track-loss, face-match, unmatched-face, recognized-text — with per-event throttles (text 2s per string, face name 5s, etc.) so the bus doesn't drown in repeats. - New `DetectorStageTiming` struct + `lastStageTiming` accessor exposes the visionBundleMs / featurePrintMs / trackerMs / totalMs breakdown. Logged once a second at DEBUG level for visibility. - Track UUID persistence via Sources/Process/TrackStore.swift — label→UUID at ~/Library/Application Support/VisionPOC/tracks.json, MRU-pruned to 50 entries, debounced 500ms saves. Same object across launches gets the same trackId → consistent per-track color. - YOLO warmup at init (dispatched on detector queue): one dummy inference against a 416×416 mid-gray image so the first user-visible detection doesn't pay model-compile latency. ## Event consumers (Agent A) - Sources/Process/Greeter.swift — AVSpeechSynthesizer-driven "Hello, <name>" on faceRecognized events. Honors Theme.greeterMuted and the Privacy Mode toggle. Throttle: 30s per name on top of the bus-level 5s throttle. - Sources/Process/GestureRecognizer.swift — five gesture heuristics (thumbs_up, open_hand, fist, pointing, peace) over hand-pose PoseDetections. Throttled 2s per gesture name. - Sources/Process/ActivityRecognizer.swift — standing/sitting/ crouching/hand_raised over body-pose PoseDetections. Throttled 5s per activity. - Sources/Process/SpatialReasoner.swift — near/above/inside/holding relations between Detection pairs. Throttled 10s per triple. ## Video recording (Agent B) - Sources/Render/Recorder.swift — @MainActor AVAssetWriter pipeline writing H.264 / 12 Mbps / 60fps to ~/Desktop/VisionPOC-...mov. Captures the rendered MTKView output via a new frame-tap callback on Renderer. Synchronous blit→staging-texture→CVPixelBuffer→ AVAssetWriterInputPixelBufferAdaptor pattern; ~5-15ms per frame at 1080p, drops dynamically when active. Format-guarded for bgra8Unorm drawables and disabled when not recording (early return). - Renderer.swift gained `var frameTap` callback; invoked inside draw(in:) just before present, when set. ## History window (Agent C) - Sources/App/HistoryEntry.swift, HistoryStore.swift, HistoryView.swift, HistoryWindowController.swift — singleton ring buffer of 500 recent events translated into human-readable summaries with category tags (object/face-known/face-unknown/ocr/gesture/activity/spatial). - Open via ⌘H from the Faces menu. Polls the store at 1Hz. - Window position persists under VPOC.HistoryWindowFrame. ## Privacy mode (Agent C) - Theme.Performance.faceRecognitionDisabled — new live tunable. - Camera menu toggle (⇧⌘P) flips both faceRecognitionDisabled and greeterMuted. The History viewer also filters out face entries when the flag is set. - Documented limitation: bounding-box labels may still show enrolled names if the detector doesn't consult faceRecognitionDisabled (this round it doesn't; flag is for History + TTS only). ## Recording menu wiring (Agent C) - App menu gains "Start Recording" / "Stop Recording" (⇧⌘R). Title flips based on the Recorder's isRecording state. - Start: instantiates the recorder, calls startRecording at the current drawable size, hooks renderer.frameTap. - Stop: clears frameTap first (no more frames queue) then awaits the asset writer's finalize. On completion (MainActor hop), shows an NSAlert with the saved-path or failure. ## Settings panel additions (Agent C) - New "Faces" subsection: "Mute greeter (TTS)" toggle bound to TunableSettings.greeterMuted (writes through to Theme). - New "Diagnostics" section: per-stage detector timing readout polled at 2Hz. Decoupled from ObjectDetector via a closure-based timing provider so the App layer doesn't depend on the Process module's DetectorStageTiming type directly. ## Hygiene (Agent D) - Tests/IoUTests.swift, AspectFitTests.swift, LogStreamTests.swift, DetectionEventBusTests.swift, FaceRegistryDistanceTests.swift — 24 tests covering pure-logic algorithms. Private methods are mirrored verbatim into the test files with a "keep in sync" comment. - project.yml gains a `VisionPOCTests` target of type `bundle.unit-test`, depending on the VisionPOC application target. - .github/workflows/build.yml — macos-15 runner, brew install xcodegen, generate, build, test. LFS skipped to save bandwidth (the build tolerates a missing model since ObjectDetector falls back to Vision built-ins). - CLAUDE.md — onboarding doc covering project layout, theme tunables, pipelines, event vocabulary, observability, build idiosyncrasies, and "how to add a new detector / overlay" recipes. ## Integration fix - Recorder.swift's `stopRecording` completion path crossed actor isolation boundaries — captured a `(URL?) -> Void` closure and the AVAssetWriter instance into a @Sendable closure. Fixed by extracting `writer.status` / `writer.error?.localizedDescription` on the writer's queue before hopping, and wrapping the completion in a `nonisolated(unsafe)` shuttle (it's called from MainActor only, so the unsafe annotation is honest). ## Build status - `xcodebuild build` — clean - `xcodebuild test` — 24 / 24 passing Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
171 lines
7.2 KiB
Swift
171 lines
7.2 KiB
Swift
import Foundation
|
||
import Vision
|
||
import CoreGraphics
|
||
|
||
/// Inspects hand `PoseDetection`s and emits discrete gesture events on the
|
||
/// shared DetectionEventBus. Heuristics-based — no ML model — using the
|
||
/// joint-name keys produced by `ObjectDetector.makeHandPose(...)`.
|
||
///
|
||
/// All gesture rules operate in Vision's normalized [0..1] image space, origin
|
||
/// bottom-left. "Above" therefore means a larger y value.
|
||
///
|
||
/// Throttle: one emit per gesture name per 2 seconds, even if the rule keeps
|
||
/// firing on consecutive frames.
|
||
final class GestureRecognizer: @unchecked Sendable {
|
||
private let bus: DetectionEventBus
|
||
|
||
private var lock = os_unfair_lock_s()
|
||
private var lastEmittedAt: [String: Date] = [:]
|
||
private let throttleInterval: TimeInterval = 2.0
|
||
|
||
init(bus: DetectionEventBus = .shared) {
|
||
self.bus = bus
|
||
}
|
||
|
||
/// Called by ObjectDetector at the end of each detect-and-bootstrap pass
|
||
/// with the freshly-computed hand-pose snapshots.
|
||
func process(handPoses: [PoseDetection]) {
|
||
for pose in handPoses where pose.kind == .hand {
|
||
evaluate(pose: pose)
|
||
}
|
||
}
|
||
|
||
// MARK: - Rule evaluation
|
||
|
||
private func evaluate(pose: PoseDetection) {
|
||
// The pose builder discards segments but keeps joints in
|
||
// `points` — there's no joint-name-keyed dict on PoseDetection.
|
||
// Reconstruct an approximate joint dict by clustering points to the
|
||
// nearest expected fingertip/MCP relative ordering. To keep this
|
||
// robust (without re-running Vision), we instead read from
|
||
// `pose.points` and apply rules that don't require named joints —
|
||
// looking at the *distribution* of y-values and palm-center proximity.
|
||
//
|
||
// For most rules we only need:
|
||
// - tip set: the 5 highest-y points (assumes hand is roughly upright)
|
||
// - base set: the 5 lowest-y points among the keypoints
|
||
// - palm center: average of the 5 lowest-y points
|
||
//
|
||
// This is approximate but sufficient for the gesture vocabulary —
|
||
// discrete classification with 2s throttling absorbs occasional
|
||
// misfires.
|
||
|
||
let points = pose.points
|
||
// Need at least a few joints to reason about. A typical hand pose
|
||
// produces 15–21 keypoints; below 8 we don't bother.
|
||
guard points.count >= 8 else { return }
|
||
|
||
let sortedByY = points.sorted { $0.y < $1.y }
|
||
let lowerHalf = Array(sortedByY.prefix(points.count / 2))
|
||
let upperHalf = Array(sortedByY.suffix(points.count / 2))
|
||
|
||
let palmCenter = centroid(of: lowerHalf)
|
||
|
||
// Heuristic "fingertip set": the 5 highest-y points. Heuristic "base
|
||
// set": the 5 lowest-y points.
|
||
let tipCount = min(5, upperHalf.count)
|
||
let baseCount = min(5, lowerHalf.count)
|
||
let tips = Array(upperHalf.suffix(tipCount))
|
||
let bases = Array(lowerHalf.prefix(baseCount))
|
||
guard tips.count >= 4, bases.count >= 4 else { return }
|
||
|
||
let tipYs = tips.map { Float($0.y) }
|
||
let baseYs = bases.map { Float($0.y) }
|
||
|
||
// ---- fist: all tips close to palm center ----
|
||
let distancesFromPalm = tips.map { CGFloat(hypot(Double($0.x - palmCenter.x), Double($0.y - palmCenter.y))) }
|
||
let maxTipPalmDist = distancesFromPalm.max() ?? .greatestFiniteMagnitude
|
||
if maxTipPalmDist < 0.08 {
|
||
let strength = Float(0.08 - maxTipPalmDist) / 0.08
|
||
emit("fist", confidence: clamp01(strength))
|
||
}
|
||
|
||
// ---- open_hand: every "tip" is above every "base" ----
|
||
let minTipY = tipYs.min() ?? 0
|
||
let maxBaseY = baseYs.max() ?? 1
|
||
let spread = minTipY - maxBaseY
|
||
if spread > 0.05 && maxTipPalmDist > 0.10 {
|
||
let strength = min(1.0, spread / 0.25)
|
||
emit("open_hand", confidence: clamp01(strength))
|
||
}
|
||
|
||
// ---- thumbs_up: a single tip well above all other tips ----
|
||
let sortedTipsByY = tipYs.sorted()
|
||
if sortedTipsByY.count >= 2 {
|
||
let topTip = sortedTipsByY[sortedTipsByY.count - 1]
|
||
let nextTip = sortedTipsByY[sortedTipsByY.count - 2]
|
||
let gap = topTip - nextTip
|
||
// Plus the other 3 (or 4) tips should be bunched (not spread): if
|
||
// 3 tips are within 0.05 of each other and the top tip is +0.05
|
||
// above them, call it thumbs_up.
|
||
let others = Array(sortedTipsByY.prefix(sortedTipsByY.count - 1))
|
||
let othersSpread = (others.max() ?? 0) - (others.min() ?? 0)
|
||
if gap > 0.05 && othersSpread < 0.05 {
|
||
let strength = min(1.0, gap / 0.20)
|
||
emit("thumbs_up", confidence: clamp01(strength))
|
||
}
|
||
}
|
||
|
||
// ---- pointing: exactly one tip stands tall above all others ----
|
||
// Similar to thumbs_up but the "extended" tip should be far from the
|
||
// palm center, and the other tips should be near it.
|
||
if sortedTipsByY.count >= 4 {
|
||
let topTip = sortedTipsByY[sortedTipsByY.count - 1]
|
||
let others = Array(sortedTipsByY.prefix(sortedTipsByY.count - 1))
|
||
let othersMax = others.max() ?? 0
|
||
let extension_ = topTip - othersMax
|
||
// Also require: the tip farthest from palm is much farther than
|
||
// the other tips.
|
||
let distSorted = distancesFromPalm.sorted()
|
||
if distSorted.count >= 2 {
|
||
let topDist = distSorted[distSorted.count - 1]
|
||
let nextDist = distSorted[distSorted.count - 2]
|
||
if extension_ > 0.07 && topDist > 0.12 && (topDist - nextDist) > 0.04 {
|
||
let strength = min(1.0, Float(topDist - nextDist) / 0.15)
|
||
emit("pointing", confidence: clamp01(strength))
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---- peace: two tips extended, others curled ----
|
||
if tipYs.count >= 5 {
|
||
let s = tipYs.sorted()
|
||
let top2 = Array(s.suffix(2))
|
||
let bot3 = Array(s.prefix(3))
|
||
let top2Min = top2.min() ?? 0
|
||
let bot3Max = bot3.max() ?? 0
|
||
let separation = top2Min - bot3Max
|
||
// Also: the top 2 should be near each other (parallel fingers).
|
||
let top2Spread = (top2.max() ?? 0) - (top2.min() ?? 0)
|
||
if separation > 0.05 && top2Spread < 0.05 {
|
||
let strength = min(1.0, separation / 0.15)
|
||
emit("peace", confidence: clamp01(strength))
|
||
}
|
||
}
|
||
}
|
||
|
||
private func emit(_ name: String, confidence: Float) {
|
||
let now = Date()
|
||
os_unfair_lock_lock(&lock)
|
||
if let prev = lastEmittedAt[name], now.timeIntervalSince(prev) < throttleInterval {
|
||
os_unfair_lock_unlock(&lock)
|
||
return
|
||
}
|
||
lastEmittedAt[name] = now
|
||
os_unfair_lock_unlock(&lock)
|
||
bus.emit(.gestureDetected(gesture: name, confidence: confidence))
|
||
}
|
||
|
||
private func centroid(of pts: [CGPoint]) -> CGPoint {
|
||
guard !pts.isEmpty else { return .zero }
|
||
var sx: CGFloat = 0
|
||
var sy: CGFloat = 0
|
||
for p in pts { sx += p.x; sy += p.y }
|
||
return CGPoint(x: sx / CGFloat(pts.count), y: sy / CGFloat(pts.count))
|
||
}
|
||
|
||
private func clamp01(_ x: Float) -> Float {
|
||
return max(0, min(1, x))
|
||
}
|
||
}
|