Files
VisionPOC/Sources/Process/GestureRecognizer.swift
james.maesandClaude Opus 4.7 958da3bb95 Event bus + greeter + gestures + activity + spatial + history + recording + tests + CI + CLAUDE.md
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>
2026-05-16 17:20:54 -05:00

171 lines
7.2 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 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))
}
}