Most face-recognition demos start with an API call.
Upload an image.
Wait for a response.
Receive a face ID.
That is convenient, but it also means the most sensitive part of the image leaves the machine.
I wanted to explore the opposite design:
Camera frame
β
Apple Vision landmarks
β
Rust tracking and geometry
β
Optional on-device face embedding
β
Local SQLite identity gallery
That experiment became FaceML. The Rust crate and executables are named facefeature.
It is a local-first face geometry, tracking, and optional identity pipeline for Apple Silicon. It calls Apple’s Vision framework directly from Rust, uses a native AppKit camera window, and can run an SFace ONNX model through Core ML. There is no Swift bridge project, WebView, cloud API, or image upload in the pipeline.
The source code is available in the FaceML repository.
This post is about how the pieces fit together, and the problems that appeared once I moved beyond detecting a rectangle around a face.
The Pipeline
The system has two related but deliberately separate paths.
The geometry path runs continuously. It detects faces and landmarks, associates detections across frames, smooths movement, predicts through display latency, and draws the overlay.
The identity path is optional. It waits for a usable face, aligns a small crop, creates several embeddings, checks whether those embeddings agree, and only then queries or updates the local gallery.
The live pipeline is callback-driven. Rust configures one AVCaptureSession, then AVFoundation sends each CMSampleBuffer to FrameDelegate::did_output_frame() on a serial queue. That callback extracts the CVPixelBuffer, runs Apple Vision, updates the Rust tracker, and optionally submits identity work. Only the final layer update crosses back to the main dispatch queue.
At the same time, AVCaptureVideoPreviewLayer renders the camera session directly. The final window is a composition of that unmodified preview plus the CAShapeLayer and CATextLayer annotations produced by Rust.
The layer stack is created explicitly. The preview becomes the base image and the transparent shape layer sits above it:
let preview = unsafe { AVCaptureVideoPreviewLayer::layerWithSession(&session) };
let overlay = CAShapeLayer::layer();
preview.setFrame(content_view.bounds());
overlay.setFrame(content_view.bounds());
root_layer.addSublayer(&preview);
root_layer.addSublayer(&overlay);
AVFoundation owns frame delivery, but Rust chooses the callback queue and delegate:
let frame_delegate =
FrameDelegate::new(&preview, &overlay, self.ivars().face_id.clone());
let frame_queue =
DispatchQueue::new("dev.facefeature.camera.frames", DispatchQueueAttr::SERIAL);
unsafe {
output.setSampleBufferDelegate_queue(
Some(ProtocolObject::from_ref(&*frame_delegate)),
Some(&frame_queue),
);
}
Keeping these paths separate matters. A tracker answers:
Is this detection the same moving face as a moment ago?
Face recognition answers:
Does this face match a person stored from an earlier session?
Those are different questions with different failure modes.
Calling Apple Vision Directly From Rust
The first layer is intentionally small: a backend-neutral detector trait and a macOS implementation.
pub trait FaceGeometryDetector {
fn name(&self) -> &'static str;
fn detect_path(&self, image_path: &Path) -> Result<Detection, DetectorError>;
}
On macOS, AppleVisionDetector creates a VNDetectFaceLandmarksRequest, runs it through a VNImageRequestHandler, and translates every VNFaceObservation into portable Rust data.
For a live camera frame, the detector wraps the existing Core Video buffer instead of first encoding or copying it into another image format:
pub fn detect_pixel_buffer(
&self,
pixel_buffer: &CVPixelBuffer,
) -> Result<Detection, DetectorError> {
autoreleasepool(|_| {
let options = empty_options();
let handler = unsafe {
VNImageRequestHandler::initWithCVPixelBuffer_options(
VNImageRequestHandler::alloc(),
pixel_buffer,
&options,
)
};
detect_with_handler(&handler)
})
}
The shared handler path constructs and performs the actual Vision request:
let request = unsafe { VNDetectFaceLandmarksRequest::new() };
let request_for_handler: Retained<VNRequest> =
request.clone().into_super().into_super();
let requests = NSArray::from_retained_slice(&[request_for_handler]);
handler
.performRequests_error(&requests)
.map_err(|error| DetectorError::Backend(error.to_string()))?;
let observations = unsafe { request.results() }
.ok_or_else(|| DetectorError::Backend("Vision returned no results array".to_owned()))?;
The result includes:
- face and landmark confidence;
- normalized bounding boxes;
- roll, yaw, and pitch when Vision exposes them;
- named regions such as the eyes, eyebrows, pupils, nose, lips, and face contour;
- scale-independent measurements such as inter-eye distance divided by face width.
Vision returns landmark points relative to the face box. The rest of the application should not need to remember that detail, so the backend converts them immediately into normalized full-image coordinates:
let points = normalized_points
.iter()
.map(|point| Point {
x: bounds.x + point.x * bounds.width,
y: bounds.y + point.y * bounds.height,
})
.collect();
The public coordinate system is therefore always [0, 1] with the origin at the lower-left, matching Vision. Rendering code performs the final conversion into window pixels.
This boundary makes the model data portable even though the current detector backend is macOS-specific.
A Camera Window Without a WebView
The live executable is a native AppKit application assembled from Rust.
AVFoundation captures 1280x720 BGRA frames. An AVCaptureVideoPreviewLayer displays the camera, while Core Animation layers draw face boxes, landmarks, pose values, confidence, and identity labels.
The delegate method is intentionally tiny. It hands the sample buffer to the Rust processing pipeline:
unsafe fn did_output_frame(
&self,
_output: &AVCaptureOutput,
sample_buffer: &CMSampleBuffer,
_connection: &AVCaptureConnection,
) {
self.process_frame(sample_buffer);
}
Inside process_frame(), the data types make each boundary visible:
let Some(pixel_buffer) = (unsafe { sample_buffer.image_buffer() }) else {
return;
};
let detector = AppleVisionDetector;
let detection = match detector.detect_pixel_buffer(&pixel_buffer) {
Ok(detection) => detection,
Err(_) => return,
};
let observed_faces = match self.ivars().tracker.lock() {
Ok(mut tracker) => tracker.update_at(detection.faces, observed_at),
Err(_) => return,
};
let tracked_faces = observed_faces
.iter()
.map(|face| face.predicted(inference_seconds + PRESENTATION_DELAY_SECONDS))
.collect::<Vec<_>>();
The real function handles errors and mutex access explicitly; the shortened excerpt above highlights the transformation from CMSampleBuffer to Detection to TrackedFace.
The frame callback is deliberately backpressure-aware:
- incoming frames are accepted at up to 30 FPS;
- late video frames are discarded;
- face-ID work uses a bounded queue;
- if the embedding worker is busy, the current sample is skipped instead of building a growing latency backlog;
- diagnostic labels refresh at 5 FPS even though geometry can update more often.
A live vision UI feels wrong when it faithfully draws an old result. Keeping the newest useful frame is more important than processing every frame.
Rendering is the only part dispatched back to the main queue:
DispatchQueue::main().exec_async(move || {
let preview = unsafe { &*(preview_layer as *const AVCaptureVideoPreviewLayer) };
let overlay = unsafe { &*(overlay_layer as *const CAShapeLayer) };
update_overlay(
preview,
overlay,
&tracked_faces,
&face_id_matches,
capture_status.as_ref(),
refresh_labels,
inference_seconds * 1_000.0,
);
});
update_overlay() builds one Core Graphics path for all visible faces, then swaps it into the layer without implicit animation:
CATransaction::begin();
CATransaction::setDisableActions(true);
overlay.setPath(Some(&path));
if refresh_labels {
replace_face_labels(/* preview, tracks, identity results, timing */);
}
CATransaction::commit();
Detection Indexes Are Not Tracking IDs
Vision may return the same people in a different order from one frame to the next. Treating the array index as an identity makes labels jump between faces.
FaceML assigns each active face a tracking ID. For every update it constructs a cost matrix between existing tracks and new detections. The match cost combines:
60% bounding-box overlap
30% relative center distance
10% normalized landmark-shape distance
That weighting appears directly in the association cost:
fn match_cost(track: &Track, detection: &FaceGeometry, observed_at: Instant) -> f64 {
let predicted = predicted_bounds(track, observed_at);
let observed = detection.bounding_box;
let overlap = intersection_over_union(predicted, observed);
let average_diagonal = ((diagonal(predicted) + diagonal(observed)) / 2.0).max(0.05);
let center_distance = center(predicted).distance(center(observed));
let relative_center_distance = center_distance / average_diagonal;
if overlap < 0.05 && relative_center_distance > 1.25 {
return INVALID_MATCH_COST;
}
let landmarks = landmark_shape_distance(&track.geometry, detection).unwrap_or(0.5);
0.6 * (1.0 - overlap)
+ 0.3 * relative_center_distance.min(1.0)
+ 0.1 * landmarks.min(1.0)
}
The Hungarian algorithm solves the assignment globally. This is important when multiple faces cross or stand close together: choosing the nearest detection independently for every track can produce a locally reasonable but globally inconsistent assignment.
The tracker also keeps an unmatched option. A detection that is too far from a predicted track is allowed to become a new track instead of being forced into a bad match.
After association, geometry is smoothed and velocity is updated. A track remains eligible for reassociation for 15 missed detection updates, which gives it a short grace period through occlusion or a temporarily missed face.
Compensating for Inference Delay
Smoothing removes jitter, but it adds lag. Inference and presentation add more.
The result is a common webcam-overlay problem: when the subject moves, the box follows behind the face.
FaceML measures the detection time and predicts the smoothed geometry forward using the track velocity:
pub fn predicted(&self, pipeline_delay_seconds: f64) -> Self {
let prediction_seconds =
(pipeline_delay_seconds.max(0.0) + self.smoothing_lag_seconds).min(0.25);
// Translate the box and every landmark using the measured velocity.
}
The prediction horizon includes both measured pipeline delay and the estimated lag introduced by exponential smoothing. It is capped at 250 ms so a stale velocity cannot launch an overlay across the screen.
This is a small detail, but it changes the result from a diagnostic drawing into something that feels attached to the face.
Tracking Is Not Recognition
A tracking ID is temporary by design. Once its grace period expires, the next detection gets a new ID even if the same person walked away and returned.
Persistent recognition lives in a separate layer:
This separation also keeps the default mode lightweight. Plain camera mode never loads the recognition model or opens a biometric gallery.
To enable automatic local matching and enrollment:
cargo run --release --bin facefeature-camera -- --face-id
To match without changing the gallery:
cargo run --release --bin facefeature-camera -- --read-only
In read-only mode, a match keeps its stored person ID and name. An unmatched face is shown as Unknown, and no identity, centroid, counter, name, or timestamp is written.
Aligning a Face Before Embedding It
The SFace model expects an aligned 112x112 face, not an arbitrary camera crop.
FaceML derives five anchor points from Vision’s landmark regions:
left eye
right eye
nose
left mouth corner
right mouth corner
It solves a similarity transform from those points to the standard SFace template, then inverse-maps the destination pixels into the camera frame with bilinear sampling. The output is an RGB/NCHW tensor.
The alignment loop works backward from every destination pixel into the source frame:
let source = five_landmarks(face, width as f32, height as f32)?;
let transform = similarity_transform(&source, &SFACE_TEMPLATE)?;
let mut tensor = vec![0.0; 3 * SFACE_WIDTH * SFACE_HEIGHT];
let plane = SFACE_WIDTH * SFACE_HEIGHT;
for destination_y in 0..SFACE_HEIGHT {
for destination_x in 0..SFACE_WIDTH {
let (source_x, source_y) =
transform.inverse(destination_x as f32 + 0.5, destination_y as f32 + 0.5);
let [red, green, blue] = sample_bgra(
bgra,
width,
height,
bytes_per_row,
source_x - 0.5,
source_y - 0.5,
);
let offset = destination_y * SFACE_WIDTH + destination_x;
tensor[offset] = red;
tensor[plane + offset] = green;
tensor[2 * plane + offset] = blue;
}
}
Alignment reduces variation caused by face position, rotation, and scale before the model sees the image. Without it, the embedding can describe the crop as much as it describes the person.
The bundled SFace model runs through ONNX Runtime with the Core ML execution provider. Compute units are set to all, so Core ML can schedule compatible operations on the M1 CPU, GPU, and Neural Engine. Unsupported operations may still fall back to the CPU.
Apple Vision makes its own execution-device decisions. Apple does not expose a guarantee that the built-in face-landmark request runs on the Neural Engine, so the application does not claim that it does.
One Frame Is Weak Evidence
A technically valid crop can still be poor recognition evidence. The subject may be blinking, blurred, underexposed, too small, or turning too far away.
Before inference, the identity pipeline rejects samples with weak geometry. It checks detection confidence, landmark confidence, face area, roll, yaw, and the required landmark groups.
The aligned tensor then receives additional checks for exposure, contrast, texture, and eye openness.
For each new tracking ID, FaceML gathers observations at 90 ms intervals. Three samples are enough when they agree. If they do not, it collects up to five observations and searches for the most mutually consistent group of three.
The selection score is mostly embedding agreement, with a smaller contribution from image quality:
score = mean_pairwise_similarity Γ 0.9 + quality Γ 0.1
For each possible trio, the weakest pair becomes the consistency value. This makes the selection sensitive to an outlier instead of hiding it inside an average:
let similarities = [
cosine_similarity(&samples[first].embedding, &samples[second].embedding),
cosine_similarity(&samples[first].embedding, &samples[third].embedding),
cosine_similarity(&samples[second].embedding, &samples[third].embedding),
];
let consistency = similarities.into_iter().fold(f32::INFINITY, f32::min);
let mean_similarity = similarities.into_iter().sum::<f32>() / 3.0;
let score = mean_similarity * 0.9 + quality * 0.1;
if best.is_none_or(|(_, best_score, _, _)| score > best_score) {
best = Some((indices, score, consistency, quality));
}
The minimum pairwise similarity inside the chosen trio becomes the consistency value. This stops one attractive but inconsistent frame from dominating the result.
The important design choice is that ordinary matches never rewrite a stored centroid. Gallery updates happen only through deliberate guided capture or consensus-backed enrollment of a new identity. A borderline match therefore cannot slowly drag a known identity toward the wrong person.
Guided Multi-Pose Enrollment
Automatic enrollment is convenient, but a deliberate capture session produces better templates.
cargo run --release --bin facefeature-camera -- \
--capture --name "Radit"
The overlay guides the subject through five poses:
straight β left β right β up β down
Each pose must be held steadily for 350 ms. The pipeline captures three embeddings per pose, for 15 samples in total.
Nothing is committed while the capture is incomplete. When all poses succeed, the centroid and pose samples are written together in a SQLite transaction. A failed or cancelled session does not leave a partially enrolled identity behind.
At recognition time, FaceML classifies the query as center, horizontal, vertical, or unknown. It compares against the main centroid plus only the pose-template group relevant to that query. A horizontally turned face does not need to compete with unrelated up/down templates.
The matcher expresses that rule by chaining the centroid with only accepted pose templates and keeping the best cosine similarity:
fn identity_similarity(
identity: &Identity,
embedding: &[f32],
query_pose: FaceQueryPose,
) -> f32 {
std::iter::once(identity.centroid.as_slice())
.chain(
identity
.pose_templates
.iter()
.filter(|template| query_pose.accepts(template.pose))
.map(|template| template.embedding.as_slice()),
)
.map(|template| cosine_similarity(template, embedding))
.max_by(f32::total_cmp)
.unwrap_or(f32::NEG_INFINITY)
}
Some Vision revisions do not provide native pitch. In that case, FaceML estimates vertical pose from the proportions between the eyes, nose, and mouth. The UI marks the value with ~ to make it clear that it is an approximation rather than a native Vision measurement.
The Local Identity Gallery
The SQLite gallery stores:
- a numeric person ID;
- an optional editable name;
- a compact fingerprint used as a human-readable gallery label;
- a normalized embedding centroid;
- enrollment sample counts;
- pose-specific templates;
- schema, embedding-format, and model-checksum metadata.
The model checksum matters. Embeddings produced by different models should not silently share the same vector space. FaceML rejects a gallery when its stored model checksum does not match the active model.
The fingerprint is not a raw face hash and should not be treated as a universal identifier. It is generated from a quantized embedding and is useful only as a compact label for a gallery entry.
Most importantly, normalized embeddings are still biometric data.
Local-first does not mean harmless.
The database and its WAL files are excluded from Git, but the user still needs to protect, relocate, or delete them like any other sensitive local data. FaceML stores no face image in the gallery and uploads neither images nor embeddings.
Image Mode and JSON Output
The native camera is only one interface. The small CLI can inspect a still image and serialize portable geometry as JSON:
cargo run --release -- detect /path/to/photo.jpg --pretty
The response reports its backend and coordinate system explicitly:
{
"backend": "apple_vision",
"coordinate_system": "normalized_lower_left",
"faces": [
{
"confidence": 1.0,
"bounding_box": {
"x": 0.24,
"y": 0.52,
"width": 0.10,
"height": 0.18
},
"yaw_radians": 0.0,
"landmarks": []
}
]
}
The shortened example omits most landmark points, but the real response keeps them grouped by named region.
Project Shape
The code is divided by responsibility:
src/model.rs portable geometry types
src/detector/mod.rs backend-neutral detector interface
src/detector/apple_vision.rs direct Apple Vision implementation
src/tracker.rs assignment, smoothing, and prediction
src/face_id.rs alignment, embeddings, and SQLite gallery
src/main.rs image-to-JSON CLI
src/bin/facefeature-camera.rs native camera application and overlay
The portable tracker and model types can be tested without a camera. The current suite covers global assignment, detector reordering, brief occlusion, track expiry, latency prediction, jitter smoothing, face alignment, sample consensus, pose-aware matching, non-adaptive ordinary matches, SQLite reopening, schema migration, and atomic guided capture.
What I Learned
The model was not the hardest part.
The difficult work lived around it:
- converting coordinate systems once and keeping the contract explicit;
- preventing detection order from becoming identity;
- handling backpressure so old frames do not accumulate;
- smoothing geometry without making the overlay feel delayed;
- rejecting weak evidence before inference;
- separating temporary tracking state from persistent biometric identity;
- making gallery writes intentional and transactional;
- stating hardware-acceleration limits accurately.
A face demo can look convincing with one good frame. A usable local pipeline has to behave sensibly across movement, missed detections, changing poses, bad lighting, application restarts, and an overloaded worker.
That is the direction of FaceML: not a cloud face API rebuilt locally, but a small native vision system whose data flow, persistence, and failure modes remain visible.
Loading Comments ...