Files
VisionPOC/Sources/Process/FacialExpressionAnalyzer.swift
james.maesandClaude Opus 4.7 813c6cc750 Add finger counting and facial expression (smile/frown/neutral) detection
Both build on Vision's existing requests — no new model files, no
Python conversion. The FingerCounter reads named hand-joint positions
from the existing VNDetectHumanHandPoseRequest output; the
FacialExpressionAnalyzer reads outer-lip landmarks from a swapped-in
VNDetectFaceLandmarksRequest (a superset of the existing rectangles
request — same bbox, plus the landmarks).

## FingerCounter

For each detected hand:
- Index / middle / ring / pinky are "extended" when their tip is
  farther from the wrist than the MCP base joint by >1.25× (tolerates
  partial curl while rejecting fully-tucked fingers)
- Thumb is special — measures perpendicular distance from the wrist→
  middleMCP axis; if the thumb tip is >0.045 normalized units lateral
  to that line, it's extended
- Emits `gestureDetected("fingers_<N>", confidence)` on the event bus
  where confidence is the average joint confidence
- Throttled to 1.5s per N — so showing 3 fingers, opening to 5,
  closing to 3 will emit cleanly but holding 3 fingers up steady won't
  flood the bus

## FacialExpressionAnalyzer

For each face with landmarks:
- Read `landmarks.outerLips.normalizedPoints` — closed contour around
  the mouth
- Mouth corners = leftmost + rightmost x in the contour
- Centroid y = average of all contour y values
- Compare corner_avg_y vs centroid_y with a dead-band scaled to the
  face's bounding-box height (default 1%):
  - Corners above centroid → smile
  - Corners below centroid → frown
  - Within dead-band → neutral_face
- Emits `gestureDetected("smile" | "frown" | "neutral_face", confidence)`
- Throttled to 4s per expression name

## ObjectDetector wiring

- `VNDetectFaceRectanglesRequest` → `VNDetectFaceLandmarksRequest`
  (still returns VNFaceObservation with .boundingBox for tracking +
  face recognition, but now also populates .landmarks for expression)
- Two new private members: `fingerCounter` + `facialExpressionAnalyzer`
- After the face-result loop in `detectAndBootstrap`, both analyzers
  receive the raw Vision observations (PoseDetection.points are
  unnamed, so the analyzers go straight to the source)

History and Log Stream both display the new events automatically since
they reuse the existing `gestureDetected` case in DetectionEvent.Kind.
No event-vocabulary changes, no UI changes needed.

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

100 lines
4.0 KiB
Swift

import Foundation
import Vision
import CoreGraphics
import os
/// Classifies face observations into one of three expressions — `smile`,
/// `frown`, `neutral_face` — based on the geometry of the outer-lip
/// landmark contour. Emits `gestureDetected(...)` events on the bus.
///
/// Algorithm
/// ---------
/// `VNFaceObservation.landmarks.outerLips` returns a closed contour around
/// the mouth. The mouth corners are the points with the most extreme x;
/// the "Cupid's bow / chin midline" is captured by the contour's vertical
/// centroid. By comparing the average y of the two corners against the
/// centroid y (Vision coords are origin-bottom-left, so larger y = higher
/// on the face):
///
/// - corners above the centroid → mouth pulls up at the edges → smile
/// - corners below the centroid → mouth pulls down at the edges → frown
/// - within a small dead-band → neutral
///
/// Throttle: same expression not emitted more than once per 4 seconds.
final class FacialExpressionAnalyzer: @unchecked Sendable {
private let bus: DetectionEventBus
private var lock = os_unfair_lock_s()
private var lastEmit: [String: Date] = [:]
private let throttle: TimeInterval = 4.0
/// Dead-band: the corner-to-centroid delta has to exceed this fraction of
/// the face's bounding-box height before we commit to smile/frown.
private let deadBandFraction: CGFloat = 0.01
init(bus: DetectionEventBus = .shared) {
self.bus = bus
}
func analyze(_ faces: [VNFaceObservation]) {
for face in faces {
guard let lips = face.landmarks?.outerLips else { continue }
// VNFaceLandmarkRegion2D exposes points either as
// `pointsInImage(imageSize:)` (pixel coords) or as
// `normalizedPoints` (in [0,1] within the face's bounding box).
// We use normalized so we can compare in a unit-coordinate frame.
let points = lips.normalizedPoints
guard !points.isEmpty else { continue }
let summary = classify(points: points, faceHeight: face.boundingBox.height)
emitIfFresh(expression: summary.name, confidence: summary.confidence)
}
}
private struct Summary {
let name: String
let confidence: Float
}
private func classify(points: [CGPoint], faceHeight: CGFloat) -> Summary {
// Corners: leftmost and rightmost x.
let left = points.min(by: { $0.x < $1.x }) ?? points[0]
let right = points.max(by: { $0.x < $1.x }) ?? points[0]
let cornerAvgY = (left.y + right.y) / 2.0
// Centroid (vertical) of the contour.
let avgY = points.reduce(CGFloat(0)) { $0 + $1.y } / CGFloat(points.count)
let delta = cornerAvgY - avgY
// Scale the dead-band to the face's bounding-box height so it stays
// sensible regardless of how big the face is in frame.
let deadBand = max(0.005, faceHeight * deadBandFraction)
// Confidence reflects how strongly the corner offset exceeds the
// dead-band. Clamp to [0, 1].
let magnitude = min(CGFloat(1), abs(delta) / max(deadBand * 4, 0.001))
let confidence = Float(magnitude)
if delta > deadBand {
return Summary(name: "smile", confidence: confidence)
} else if delta < -deadBand {
return Summary(name: "frown", confidence: confidence)
} else {
return Summary(name: "neutral_face", confidence: 0.5)
}
}
private func emitIfFresh(expression: String, confidence: Float) {
let now = Date()
os_unfair_lock_lock(&lock)
if let last = lastEmit[expression], now.timeIntervalSince(last) < throttle {
os_unfair_lock_unlock(&lock)
return
}
lastEmit[expression] = now
os_unfair_lock_unlock(&lock)
bus.emit(.gestureDetected(gesture: expression, confidence: confidence))
LogStream.shared.log("expression: \(expression) (conf \(String(format: "%.2f", confidence)))",
level: .debug, source: .detect)
}
}