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>
100 lines
4.0 KiB
Swift
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)
|
|
}
|
|
}
|