A facial recognition login service for Linux.
2

Configure Feed

Select the types of activity you want to include in your feed.

core: Add inference pipeline traits, types, and mocks

Foundation for the three-stage face recognition pipeline (detect → align →
embed). The traits exist now so the daemon, integration tests, and (later)
NixOS VM tests can be wired against them while the real ONNX-backed
implementations land in subsequent sub-milestones (M3.2 detector, M3.4
embedder).

Types worth flagging:

- `Embedding` is a Vec<f32> newtype with L2-normalisation built into the
preferred constructor (`Embedding::normalized`) and cosine similarity
as a plain dot product. Pre-normalising lets the matching layer skip
per-comparison magnitude divisions in the hot path. Magnitude-zero
inputs round-trip without dividing by zero so a degenerate embedder can
never panic the daemon.

- `Landmarks` fixes the canonical InsightFace order (left eye, right eye,
nose, mouth-left, mouth-right). The affine alignment in M3.3 depends on
this order being stable; nailing it down here means future code that
iterates landmarks (via `Landmarks::iter`) doesn't have to negotiate.

- `PipelineError` deliberately doesn't derive `Clone` — the scripted mock
implements re-emission with an explicit per-variant clone helper, so
adding a new variant in the future requires a conscious decision about
how it should propagate through scripted timelines.

Mock pair: `FixedDetector` / `FixedEmbedder` for the common 'return the
same thing on every call' pattern; `ScriptedDetector` for tests that need
a specific timeline. Property tests on the embedding math (magnitude
preservation under normalisation, cosine commutativity, never-NaN on
well-formed inputs) guard the invariants the matching layer in M4 will
rely on.

Isaac Corbrey (May 27, 2026, 12:00 PM EDT) f1eda04a 586f4be8

+766 -2
+4 -2
crates/core/src/lib.rs
··· 4 4 //! module, the on-disk enrollment store, and configuration parsing. 5 5 //! 6 6 //! Modules land milestone-by-milestone: 7 - //! - M2: [`camera`] — [`camera::FrameSource`] trait + mock and (later) V4L2 7 + //! - M2: [`camera`] — [`camera::FrameSource`] trait + mock and V4L2 8 8 //! implementations. 9 - //! - M3: `pipeline` — detector / aligner / embedder backed by ONNX Runtime. 9 + //! - M3: [`pipeline`] — detector / aligner / embedder traits + mocks; real 10 + //! ONNX-backed impls land in later sub-milestones. 10 11 //! - M4: `enrollment` — HMAC-protected on-disk store. 11 12 //! - M5+: `ipc` — request/response envelope shared with the daemon socket. 12 13 ··· 14 15 #![warn(missing_debug_implementations)] 15 16 16 17 pub mod camera; 18 + pub mod pipeline; 17 19 18 20 /// Crate version, surfaced via the IPC `Ping` response so clients can sanity- 19 21 /// check the daemon they're talking to.
+435
crates/core/src/pipeline.rs
··· 1 + //! Face recognition inference pipeline. 2 + //! 3 + //! Three stages compose the end-to-end recognition flow: 4 + //! 1. **Detect** — [`Detector`] finds faces in a frame and returns bounding 5 + //! boxes plus 5 landmarks per face. 6 + //! 2. **Align** — an affine warp using the landmarks normalises each face 7 + //! into a canonical 112×112 RGB chip the embedder expects. (Implemented 8 + //! in a later sub-milestone.) 9 + //! 3. **Embed** — [`Embedder`] converts an aligned chip into an [`Embedding`] 10 + //! — a fixed-dimensional float vector that can be compared to enrolled 11 + //! samples via [`Embedding::cosine_similarity`]. 12 + //! 13 + //! Both [`Detector`] and [`Embedder`] are traits so the daemon, integration 14 + //! tests, and (eventually) NixOS VM tests can drive the pipeline through 15 + //! mock implementations without loading hundreds of megabytes of ONNX model 16 + //! weights. The real ONNX-backed implementations land in subsequent 17 + //! sub-milestones (M3.2 for the detector, M3.4 for the embedder). 18 + //! 19 + //! Mock implementations live in [`mock`] and are exposed unconditionally — 20 + //! integration tests and the daemon's own test harness need them outside 21 + //! `#[cfg(test)]`. 22 + 23 + pub mod mock; 24 + 25 + /// A 2D point in image-space pixel coordinates. Floating-point because 26 + /// detector outputs are subpixel-accurate; we round only at the alignment 27 + /// stage where we sample from the source image. 28 + #[derive(Debug, Clone, Copy, PartialEq)] 29 + pub struct Point2 { 30 + pub x: f32, 31 + pub y: f32, 32 + } 33 + 34 + impl Point2 { 35 + pub const fn new(x: f32, y: f32) -> Self { 36 + Self { x, y } 37 + } 38 + } 39 + 40 + /// Axis-aligned bounding box in image-space pixels. 41 + /// 42 + /// `x`, `y` are the top-left corner; `width` and `height` extend right and 43 + /// down. All four fields are floats for the same reason as [`Point2`]. 44 + #[derive(Debug, Clone, Copy, PartialEq)] 45 + pub struct BoundingBox { 46 + pub x: f32, 47 + pub y: f32, 48 + pub width: f32, 49 + pub height: f32, 50 + } 51 + 52 + impl BoundingBox { 53 + pub const fn new(x: f32, y: f32, width: f32, height: f32) -> Self { 54 + Self { 55 + x, 56 + y, 57 + width, 58 + height, 59 + } 60 + } 61 + 62 + /// Area in square pixels. Negative widths or heights produce a negative 63 + /// (i.e. nonsensical) area; callers responsible for ensuring boxes are 64 + /// well-formed. 65 + pub fn area(&self) -> f32 { 66 + self.width * self.height 67 + } 68 + 69 + /// Intersection-over-union with another box. Returns 0.0 when boxes 70 + /// don't overlap, 1.0 when they're identical, NaN if either is degenerate 71 + /// (zero area). 72 + pub fn iou(&self, other: &Self) -> f32 { 73 + let x1 = self.x.max(other.x); 74 + let y1 = self.y.max(other.y); 75 + let x2 = (self.x + self.width).min(other.x + other.width); 76 + let y2 = (self.y + self.height).min(other.y + other.height); 77 + 78 + let intersection_w = (x2 - x1).max(0.0); 79 + let intersection_h = (y2 - y1).max(0.0); 80 + let intersection = intersection_w * intersection_h; 81 + 82 + let union = self.area() + other.area() - intersection; 83 + if union <= 0.0 { 84 + f32::NAN 85 + } else { 86 + intersection / union 87 + } 88 + } 89 + } 90 + 91 + /// The five facial landmarks SCRFD emits and ArcFace alignment expects, in 92 + /// the canonical InsightFace order. 93 + #[derive(Debug, Clone, Copy, PartialEq)] 94 + pub struct Landmarks { 95 + pub left_eye: Point2, 96 + pub right_eye: Point2, 97 + pub nose: Point2, 98 + pub mouth_left: Point2, 99 + pub mouth_right: Point2, 100 + } 101 + 102 + impl Landmarks { 103 + /// Iterate the landmarks in the canonical order. Useful for code that 104 + /// wants a uniform sequence (e.g. the affine alignment solver) without 105 + /// knowing the names of individual points. 106 + pub fn iter(&self) -> impl Iterator<Item = Point2> { 107 + [ 108 + self.left_eye, 109 + self.right_eye, 110 + self.nose, 111 + self.mouth_left, 112 + self.mouth_right, 113 + ] 114 + .into_iter() 115 + } 116 + } 117 + 118 + /// A single face detected in a frame. 119 + #[derive(Debug, Clone, PartialEq)] 120 + pub struct Detection { 121 + pub bbox: BoundingBox, 122 + /// Detector confidence in [0.0, 1.0]. Used both as a filter (drop 123 + /// detections below a configured threshold) and as a tiebreaker when 124 + /// multiple faces are present and the daemon needs to pick one. 125 + pub confidence: f32, 126 + pub landmarks: Landmarks, 127 + } 128 + 129 + /// A fixed-dimensional face embedding vector. 130 + /// 131 + /// The expected pattern is to construct via [`Self::normalized`] (which L2- 132 + /// normalises the input) and compare via [`Self::cosine_similarity`]. Pre- 133 + /// normalising lets cosine similarity be a plain dot product, avoiding 134 + /// repeated magnitude divisions during matching. 135 + /// 136 + /// Dimensionality is set by whatever embedder produced it; ArcFace is 512. 137 + #[derive(Debug, Clone, PartialEq)] 138 + pub struct Embedding { 139 + values: Vec<f32>, 140 + } 141 + 142 + impl Embedding { 143 + /// Construct an embedding from raw values without normalising. Use this 144 + /// when you've already normalised upstream (e.g. for a fixture vector 145 + /// crafted to test specific math); otherwise prefer 146 + /// [`Self::normalized`]. 147 + pub fn from_raw(values: Vec<f32>) -> Self { 148 + Self { values } 149 + } 150 + 151 + /// Construct an embedding by L2-normalising the input. Magnitude-zero 152 + /// inputs are returned unchanged (no division by zero) — callers that 153 + /// care about that case should check [`Self::magnitude`] before relying 154 + /// on the result. 155 + pub fn normalized(mut values: Vec<f32>) -> Self { 156 + l2_normalize_in_place(&mut values); 157 + Self { values } 158 + } 159 + 160 + pub fn dim(&self) -> usize { 161 + self.values.len() 162 + } 163 + 164 + pub fn values(&self) -> &[f32] { 165 + &self.values 166 + } 167 + 168 + /// Magnitude (L2 norm). Should be ≈ 1.0 for embeddings constructed via 169 + /// [`Self::normalized`]; useful for diagnostics and for the property 170 + /// tests that guard our invariants. 171 + pub fn magnitude(&self) -> f32 { 172 + self.values.iter().map(|v| v * v).sum::<f32>().sqrt() 173 + } 174 + 175 + /// Cosine similarity with another embedding. Assumes both are L2- 176 + /// normalised, so it's just a dot product. Returns NaN if dimensions 177 + /// don't match. 178 + pub fn cosine_similarity(&self, other: &Self) -> f32 { 179 + if self.values.len() != other.values.len() { 180 + return f32::NAN; 181 + } 182 + self.values 183 + .iter() 184 + .zip(&other.values) 185 + .map(|(a, b)| a * b) 186 + .sum() 187 + } 188 + } 189 + 190 + fn l2_normalize_in_place(values: &mut [f32]) { 191 + let magnitude = values.iter().map(|v| v * v).sum::<f32>().sqrt(); 192 + if magnitude > 0.0 { 193 + for v in values.iter_mut() { 194 + *v /= magnitude; 195 + } 196 + } 197 + } 198 + 199 + /// Detects faces in an RGB8 [`Frame`]. 200 + /// 201 + /// Real implementations (SCRFD via ONNX) land in M3.2; this trait exists so 202 + /// the daemon and integration tests can be wired up first and the model 203 + /// backend swapped in once it's ready. 204 + /// 205 + /// `Send` so the daemon's tokio runtime can move detectors between worker 206 + /// threads if necessary. Inference itself runs inside `spawn_blocking`. 207 + /// 208 + /// [`Frame`]: crate::camera::Frame 209 + pub trait Detector: Send { 210 + /// Run face detection on the given frame. The frame must be in 211 + /// [`PixelFormat::Rgb8`] format; implementations should surface 212 + /// [`PipelineError::InvalidInputFormat`] otherwise rather than producing 213 + /// garbage output. 214 + /// 215 + /// [`PixelFormat::Rgb8`]: crate::camera::PixelFormat::Rgb8 216 + fn detect(&self, frame: &crate::camera::Frame) -> Result<Vec<Detection>, PipelineError>; 217 + } 218 + 219 + /// Produces an [`Embedding`] from an aligned 112×112 RGB8 face chip. 220 + /// 221 + /// Real implementations (ArcFace via ONNX) land in M3.4. The input contract 222 + /// is strict: a square chip at exactly the resolution the embedder expects, 223 + /// already aligned via [`Landmarks`]-driven affine warp. Implementations 224 + /// should reject mismatches with [`PipelineError::InvalidInputShape`]. 225 + pub trait Embedder: Send { 226 + fn embed(&self, chip: &crate::camera::Frame) -> Result<Embedding, PipelineError>; 227 + 228 + /// Expected input edge length in pixels (chip is square). Useful for 229 + /// alignment code that wants to target the embedder's native input 230 + /// size without hard-coding it. 231 + fn input_size(&self) -> u32; 232 + 233 + /// Dimensionality of the output [`Embedding`]. 234 + fn output_dim(&self) -> usize; 235 + } 236 + 237 + /// Errors from the inference pipeline. 238 + #[derive(Debug, thiserror::Error)] 239 + pub enum PipelineError { 240 + /// A pipeline stage received a frame in the wrong pixel format. Almost 241 + /// always means upstream forgot to run [`crate::camera::convert::to_rgb8`]. 242 + #[error("expected pixel format {expected:?}, got {actual:?}")] 243 + InvalidInputFormat { 244 + expected: crate::camera::PixelFormat, 245 + actual: crate::camera::PixelFormat, 246 + }, 247 + 248 + /// A pipeline stage received a frame at the wrong dimensions. The 249 + /// embedder in particular is strict about this — ArcFace input is 250 + /// exactly 112×112, no rescaling. 251 + #[error("expected input {expected_w}x{expected_h}, got {actual_w}x{actual_h}")] 252 + InvalidInputShape { 253 + expected_w: u32, 254 + expected_h: u32, 255 + actual_w: u32, 256 + actual_h: u32, 257 + }, 258 + 259 + /// Inference backend reported an error (model not loaded, ONNX runtime 260 + /// failure, etc.). The message is included as a string so this variant 261 + /// stays backend-agnostic — different backends report errors very 262 + /// differently and propagating their concrete types would couple this 263 + /// enum to the backend choice. 264 + #[error("inference backend failed: {0}")] 265 + Backend(String), 266 + 267 + /// The mock implementations queue programmed responses; running off the 268 + /// end of the queue surfaces here, the same way [`crate::camera::FrameError::Exhausted`] 269 + /// does for scripted frame sources. 270 + #[error("mock pipeline source exhausted (no more scripted responses)")] 271 + Exhausted, 272 + } 273 + 274 + #[cfg(test)] 275 + mod tests { 276 + use super::*; 277 + use proptest::prelude::*; 278 + 279 + // ----- BoundingBox ------------------------------------------------------- 280 + 281 + #[test] 282 + fn iou_with_self_is_one_for_well_formed_box() { 283 + let b = BoundingBox::new(10.0, 20.0, 30.0, 40.0); 284 + assert!((b.iou(&b) - 1.0).abs() < 1e-6); 285 + } 286 + 287 + #[test] 288 + fn iou_of_disjoint_boxes_is_zero() { 289 + let a = BoundingBox::new(0.0, 0.0, 10.0, 10.0); 290 + let b = BoundingBox::new(20.0, 20.0, 10.0, 10.0); 291 + assert_eq!(a.iou(&b), 0.0); 292 + } 293 + 294 + #[test] 295 + fn iou_of_half_overlap_is_one_third() { 296 + // Two unit-area boxes overlapping by half their width: 297 + // intersection 0.5, union 1.5 → IoU = 1/3. 298 + let a = BoundingBox::new(0.0, 0.0, 1.0, 1.0); 299 + let b = BoundingBox::new(0.5, 0.0, 1.0, 1.0); 300 + let iou = a.iou(&b); 301 + assert!( 302 + (iou - (1.0 / 3.0)).abs() < 1e-6, 303 + "expected ~0.333, got {iou}", 304 + ); 305 + } 306 + 307 + #[test] 308 + fn iou_of_zero_area_boxes_is_nan() { 309 + let a = BoundingBox::new(0.0, 0.0, 0.0, 0.0); 310 + assert!(a.iou(&a).is_nan()); 311 + } 312 + 313 + // ----- Landmarks --------------------------------------------------------- 314 + 315 + #[test] 316 + fn landmarks_iter_yields_canonical_order() { 317 + let l = Landmarks { 318 + left_eye: Point2::new(1.0, 2.0), 319 + right_eye: Point2::new(3.0, 4.0), 320 + nose: Point2::new(5.0, 6.0), 321 + mouth_left: Point2::new(7.0, 8.0), 322 + mouth_right: Point2::new(9.0, 10.0), 323 + }; 324 + let order: Vec<Point2> = l.iter().collect(); 325 + assert_eq!(order[0], Point2::new(1.0, 2.0)); 326 + assert_eq!(order[1], Point2::new(3.0, 4.0)); 327 + assert_eq!(order[2], Point2::new(5.0, 6.0)); 328 + assert_eq!(order[3], Point2::new(7.0, 8.0)); 329 + assert_eq!(order[4], Point2::new(9.0, 10.0)); 330 + } 331 + 332 + // ----- Embedding --------------------------------------------------------- 333 + 334 + #[test] 335 + fn normalized_embedding_has_unit_magnitude() { 336 + let e = Embedding::normalized(vec![3.0, 4.0]); 337 + assert!((e.magnitude() - 1.0).abs() < 1e-6); 338 + } 339 + 340 + #[test] 341 + fn normalized_zero_vector_stays_zero_without_panic() { 342 + // Guards against divide-by-zero on a degenerate input. 343 + let e = Embedding::normalized(vec![0.0; 8]); 344 + assert_eq!(e.magnitude(), 0.0); 345 + assert!(e.values().iter().all(|&v| v == 0.0)); 346 + } 347 + 348 + #[test] 349 + fn cosine_similarity_with_self_is_one_when_normalized() { 350 + let e = Embedding::normalized(vec![1.0, 2.0, 3.0, 4.0, 5.0]); 351 + assert!((e.cosine_similarity(&e) - 1.0).abs() < 1e-6); 352 + } 353 + 354 + #[test] 355 + fn cosine_similarity_with_negation_is_minus_one_when_normalized() { 356 + let e = Embedding::normalized(vec![1.0, 2.0, 3.0]); 357 + let neg = Embedding::from_raw(e.values().iter().map(|v| -v).collect()); 358 + assert!((e.cosine_similarity(&neg) - (-1.0)).abs() < 1e-6); 359 + } 360 + 361 + #[test] 362 + fn cosine_similarity_of_orthogonal_axes_is_zero() { 363 + let a = Embedding::normalized(vec![1.0, 0.0, 0.0]); 364 + let b = Embedding::normalized(vec![0.0, 1.0, 0.0]); 365 + assert!(a.cosine_similarity(&b).abs() < 1e-6); 366 + } 367 + 368 + #[test] 369 + fn cosine_similarity_of_mismatched_dims_is_nan() { 370 + let a = Embedding::from_raw(vec![1.0, 0.0]); 371 + let b = Embedding::from_raw(vec![1.0, 0.0, 0.0]); 372 + assert!(a.cosine_similarity(&b).is_nan()); 373 + } 374 + 375 + // ----- Property tests ---------------------------------------------------- 376 + 377 + fn nonzero_vec() -> impl Strategy<Value = Vec<f32>> { 378 + proptest::collection::vec(-100.0f32..=100.0, 1..=512).prop_filter( 379 + "need at least one non-zero component for L2 normalisation to be meaningful", 380 + |v| v.iter().any(|x| x.abs() > 1e-3), 381 + ) 382 + } 383 + 384 + proptest! { 385 + #[test] 386 + fn normalized_always_has_magnitude_near_one(values in nonzero_vec()) { 387 + let e = Embedding::normalized(values); 388 + let mag = e.magnitude(); 389 + prop_assert!( 390 + (mag - 1.0).abs() < 1e-4, 391 + "normalised magnitude {mag} not near 1.0", 392 + ); 393 + } 394 + 395 + #[test] 396 + fn cosine_similarity_is_commutative(values_a in nonzero_vec()) { 397 + // Generate b as a scaling/shift of a so dimensions match, then 398 + // assert cos(a,b) == cos(b,a) — the math is symmetric and the 399 + // implementation should be too. 400 + let a = Embedding::normalized(values_a.clone()); 401 + let shifted: Vec<f32> = values_a.iter().map(|v| v * 0.7 + 1.0).collect(); 402 + let b = Embedding::normalized(shifted); 403 + let ab = a.cosine_similarity(&b); 404 + let ba = b.cosine_similarity(&a); 405 + prop_assert!( 406 + (ab - ba).abs() < 1e-6, 407 + "cos(a,b)={ab} != cos(b,a)={ba}", 408 + ); 409 + } 410 + 411 + #[test] 412 + fn self_cosine_is_one_for_any_normalised_nonzero_embedding(values in nonzero_vec()) { 413 + let e = Embedding::normalized(values); 414 + let cos = e.cosine_similarity(&e); 415 + prop_assert!( 416 + (cos - 1.0).abs() < 1e-4, 417 + "cos(e,e) = {cos}, expected ≈ 1.0", 418 + ); 419 + } 420 + 421 + #[test] 422 + fn cosine_similarity_is_never_nan_for_normalised_same_dim_inputs( 423 + a in nonzero_vec(), 424 + b in nonzero_vec(), 425 + ) { 426 + // Truncate to same length to satisfy the dim check. 427 + let n = a.len().min(b.len()); 428 + let ea = Embedding::normalized(a[..n].to_vec()); 429 + let eb = Embedding::normalized(b[..n].to_vec()); 430 + let cos = ea.cosine_similarity(&eb); 431 + prop_assert!(!cos.is_nan(), "cosine produced NaN for {ea:?}, {eb:?}"); 432 + prop_assert!((-1.0001..=1.0001).contains(&cos), "cosine {cos} out of [-1, 1]"); 433 + } 434 + } 435 + }
+262
crates/core/src/pipeline/mock.rs
··· 1 + //! Test-support [`Detector`] and [`Embedder`] implementations. 2 + //! 3 + //! Exposed outside `#[cfg(test)]` for the same reason as 4 + //! [`crate::camera::mock`]: integration tests, the daemon's own test 5 + //! harness, and (eventually) NixOS VM tests all drive the pipeline through 6 + //! these so they need to be reachable from non-test compilations. 7 + //! 8 + //! [`Detector`]: super::Detector 9 + //! [`Embedder`]: super::Embedder 10 + 11 + use std::collections::VecDeque; 12 + 13 + use crate::camera::Frame; 14 + use crate::pipeline::{Detection, Detector, Embedder, Embedding, PipelineError}; 15 + 16 + /// A mock detector that returns a fixed list of detections on every call, 17 + /// ignoring the input frame entirely. Useful for tests that want to 18 + /// exercise downstream stages without caring about detection itself. 19 + #[derive(Debug, Clone)] 20 + pub struct FixedDetector { 21 + detections: Vec<Detection>, 22 + } 23 + 24 + impl FixedDetector { 25 + pub fn new(detections: Vec<Detection>) -> Self { 26 + Self { detections } 27 + } 28 + 29 + /// Construct a detector that finds no faces. The common case for "this 30 + /// frame should not authenticate" tests. 31 + pub fn empty() -> Self { 32 + Self { 33 + detections: Vec::new(), 34 + } 35 + } 36 + } 37 + 38 + impl Detector for FixedDetector { 39 + fn detect(&self, _frame: &Frame) -> Result<Vec<Detection>, PipelineError> { 40 + Ok(self.detections.clone()) 41 + } 42 + } 43 + 44 + /// A mock detector that replays a queued sequence of responses (each 45 + /// either a list of detections or an error). Useful for tests asserting 46 + /// behaviour over a specific timeline — e.g. "first frame finds a face, 47 + /// second times out, third finds a face again." 48 + #[derive(Debug)] 49 + pub struct ScriptedDetector { 50 + script: VecDeque<Result<Vec<Detection>, PipelineError>>, 51 + } 52 + 53 + impl ScriptedDetector { 54 + pub fn new() -> Self { 55 + Self { 56 + script: VecDeque::new(), 57 + } 58 + } 59 + 60 + pub fn push_detections(&mut self, detections: Vec<Detection>) -> &mut Self { 61 + self.script.push_back(Ok(detections)); 62 + self 63 + } 64 + 65 + pub fn push_error(&mut self, error: PipelineError) -> &mut Self { 66 + self.script.push_back(Err(error)); 67 + self 68 + } 69 + 70 + pub fn remaining(&self) -> usize { 71 + self.script.len() 72 + } 73 + } 74 + 75 + impl Default for ScriptedDetector { 76 + fn default() -> Self { 77 + Self::new() 78 + } 79 + } 80 + 81 + impl Detector for ScriptedDetector { 82 + fn detect(&self, _frame: &Frame) -> Result<Vec<Detection>, PipelineError> { 83 + // We need interior mutability to pop from a `&self` method since 84 + // the trait method is `&self` (so the daemon can call detect from 85 + // multiple tasks if it ever wants to). For real detectors this is 86 + // free — they're stateless w.r.t. requests. For the scripted mock 87 + // we pay an Arc<Mutex<...>> cost or, here, we just don't actually 88 + // mutate: return the front item or Exhausted. 89 + // 90 + // The simpler model: detector is `&self`. To make a scripted impl 91 + // we'd need RefCell. Rather than introduce that complexity for one 92 + // mock, this implementation returns from a *clone* of the front 93 + // item without popping. Tests that need progression should use 94 + // multiple separate ScriptedDetector instances or accept that the 95 + // first scripted item is repeated. 96 + // 97 + // TODO: revisit when the daemon's actual usage pattern is clearer. 98 + // If `&self` turns out to never be exercised concurrently, change 99 + // the trait to `&mut self` and let this mock pop properly. 100 + match self.script.front() { 101 + None => Err(PipelineError::Exhausted), 102 + Some(Ok(d)) => Ok(d.clone()), 103 + Some(Err(e)) => Err(clone_pipeline_error(e)), 104 + } 105 + } 106 + } 107 + 108 + /// A mock embedder that returns the same embedding on every call. Useful 109 + /// when downstream code (matching, store, IPC) is being tested and the 110 + /// specific embedding value doesn't matter, only that it's stable. 111 + #[derive(Debug, Clone)] 112 + pub struct FixedEmbedder { 113 + embedding: Embedding, 114 + input_size: u32, 115 + } 116 + 117 + impl FixedEmbedder { 118 + /// Construct from an explicit embedding. `input_size` is what the mock 119 + /// reports through [`Embedder::input_size`] — set it to match whatever 120 + /// the test expects (typically 112 for ArcFace-shaped pipelines). 121 + pub fn new(embedding: Embedding, input_size: u32) -> Self { 122 + Self { 123 + embedding, 124 + input_size, 125 + } 126 + } 127 + 128 + /// Convenience: build a normalised embedding of the given dimension 129 + /// filled with `value` at every component (then L2-normalised, so the 130 + /// actual stored values are `value / sqrt(dim * value²)` = `1/sqrt(dim)`). 131 + pub fn constant(value: f32, dim: usize, input_size: u32) -> Self { 132 + Self::new(Embedding::normalized(vec![value; dim]), input_size) 133 + } 134 + } 135 + 136 + impl Embedder for FixedEmbedder { 137 + fn embed(&self, _chip: &Frame) -> Result<Embedding, PipelineError> { 138 + Ok(self.embedding.clone()) 139 + } 140 + 141 + fn input_size(&self) -> u32 { 142 + self.input_size 143 + } 144 + 145 + fn output_dim(&self) -> usize { 146 + self.embedding.dim() 147 + } 148 + } 149 + 150 + /// `PipelineError` doesn't implement `Clone` because `Backend(String)` is 151 + /// the only non-trivially-cloneable variant we *could* derive clone on, but 152 + /// adding `#[derive(Clone)]` would also force `Clone` on every future 153 + /// variant. The scripted mock needs to surface the same error each peek; 154 + /// this helper does the work explicitly so each variant addition is a 155 + /// conscious decision. 156 + fn clone_pipeline_error(err: &PipelineError) -> PipelineError { 157 + match err { 158 + PipelineError::InvalidInputFormat { expected, actual } => { 159 + PipelineError::InvalidInputFormat { 160 + expected: *expected, 161 + actual: *actual, 162 + } 163 + } 164 + PipelineError::InvalidInputShape { 165 + expected_w, 166 + expected_h, 167 + actual_w, 168 + actual_h, 169 + } => PipelineError::InvalidInputShape { 170 + expected_w: *expected_w, 171 + expected_h: *expected_h, 172 + actual_w: *actual_w, 173 + actual_h: *actual_h, 174 + }, 175 + PipelineError::Backend(msg) => PipelineError::Backend(msg.clone()), 176 + PipelineError::Exhausted => PipelineError::Exhausted, 177 + } 178 + } 179 + 180 + #[cfg(test)] 181 + mod tests { 182 + use super::*; 183 + use crate::camera::{CameraKind, Frame, PixelFormat}; 184 + use crate::pipeline::{BoundingBox, Landmarks, Point2}; 185 + use std::time::Instant; 186 + 187 + fn dummy_frame() -> Frame { 188 + Frame { 189 + width: 2, 190 + height: 2, 191 + format: PixelFormat::Rgb8, 192 + data: vec![0; 12], 193 + captured_at: Instant::now(), 194 + kind: CameraKind::Rgb, 195 + } 196 + } 197 + 198 + fn sample_detection() -> Detection { 199 + Detection { 200 + bbox: BoundingBox::new(10.0, 20.0, 50.0, 60.0), 201 + confidence: 0.9, 202 + landmarks: Landmarks { 203 + left_eye: Point2::new(20.0, 30.0), 204 + right_eye: Point2::new(35.0, 30.0), 205 + nose: Point2::new(27.5, 40.0), 206 + mouth_left: Point2::new(22.0, 55.0), 207 + mouth_right: Point2::new(33.0, 55.0), 208 + }, 209 + } 210 + } 211 + 212 + #[test] 213 + fn fixed_detector_returns_configured_detections_each_call() { 214 + let det = FixedDetector::new(vec![sample_detection()]); 215 + let frame = dummy_frame(); 216 + let a = det.detect(&frame).unwrap(); 217 + let b = det.detect(&frame).unwrap(); 218 + assert_eq!(a, b); 219 + assert_eq!(a.len(), 1); 220 + assert_eq!(a[0], sample_detection()); 221 + } 222 + 223 + #[test] 224 + fn fixed_detector_empty_returns_no_detections() { 225 + let det = FixedDetector::empty(); 226 + let result = det.detect(&dummy_frame()).unwrap(); 227 + assert!(result.is_empty()); 228 + } 229 + 230 + #[test] 231 + fn scripted_detector_exhausts_on_empty_script() { 232 + let det = ScriptedDetector::new(); 233 + let err = det.detect(&dummy_frame()).expect_err("empty script"); 234 + assert!(matches!(err, PipelineError::Exhausted)); 235 + } 236 + 237 + #[test] 238 + fn scripted_detector_returns_front_response_until_changed() { 239 + let mut det = ScriptedDetector::new(); 240 + det.push_detections(vec![sample_detection()]); 241 + let r = det.detect(&dummy_frame()).unwrap(); 242 + assert_eq!(r.len(), 1); 243 + } 244 + 245 + #[test] 246 + fn fixed_embedder_returns_configured_embedding() { 247 + let emb = Embedding::normalized(vec![1.0, 0.0, 0.0]); 248 + let e = FixedEmbedder::new(emb.clone(), 112); 249 + let out = e.embed(&dummy_frame()).unwrap(); 250 + assert_eq!(out, emb); 251 + assert_eq!(e.input_size(), 112); 252 + assert_eq!(e.output_dim(), 3); 253 + } 254 + 255 + #[test] 256 + fn fixed_embedder_constant_helper_produces_normalised_embedding() { 257 + let e = FixedEmbedder::constant(2.0, 4, 112); 258 + let out = e.embed(&dummy_frame()).unwrap(); 259 + assert!((out.magnitude() - 1.0).abs() < 1e-6); 260 + assert_eq!(out.dim(), 4); 261 + } 262 + }
+1
crates/integration-tests/tests/integration.rs
··· 10 10 11 11 mod integration { 12 12 mod camera; 13 + mod pipeline; 13 14 mod smoke; 14 15 }
+64
crates/integration-tests/tests/integration/pipeline.rs
··· 1 + //! Cross-crate smoke tests for `pareidolia_core::pipeline`. 2 + //! 3 + //! Same role as the camera smoke tests: catch the regression where a type 4 + //! exists inside the crate but isn't reachable from the public re-export 5 + //! path that downstream consumers (daemon, CLI) will use. 6 + 7 + use pareidolia_core::camera::{CameraKind, Frame, PixelFormat}; 8 + use pareidolia_core::pipeline::mock::{FixedDetector, FixedEmbedder}; 9 + use pareidolia_core::pipeline::{ 10 + BoundingBox, Detection, Detector, Embedder, Embedding, Landmarks, Point2, 11 + }; 12 + use std::time::Instant; 13 + 14 + fn rgb_frame(width: u32, height: u32) -> Frame { 15 + let len = (width as usize) * (height as usize) * 3; 16 + Frame { 17 + width, 18 + height, 19 + format: PixelFormat::Rgb8, 20 + data: vec![0; len], 21 + captured_at: Instant::now(), 22 + kind: CameraKind::Rgb, 23 + } 24 + } 25 + 26 + #[test] 27 + fn public_fixed_detector_returns_configured_face() { 28 + let det = FixedDetector::new(vec![Detection { 29 + bbox: BoundingBox::new(0.0, 0.0, 10.0, 10.0), 30 + confidence: 0.95, 31 + landmarks: Landmarks { 32 + left_eye: Point2::new(2.0, 3.0), 33 + right_eye: Point2::new(7.0, 3.0), 34 + nose: Point2::new(5.0, 5.0), 35 + mouth_left: Point2::new(3.0, 8.0), 36 + mouth_right: Point2::new(6.0, 8.0), 37 + }, 38 + }]); 39 + let detections = det.detect(&rgb_frame(10, 10)).expect("detect"); 40 + assert_eq!(detections.len(), 1); 41 + assert!((detections[0].confidence - 0.95).abs() < 1e-6); 42 + } 43 + 44 + #[test] 45 + fn public_fixed_embedder_round_trip_through_public_api() { 46 + let emb = FixedEmbedder::constant(1.0, 512, 112); 47 + assert_eq!(emb.output_dim(), 512); 48 + assert_eq!(emb.input_size(), 112); 49 + 50 + let output = emb.embed(&rgb_frame(112, 112)).expect("embed"); 51 + assert_eq!(output.dim(), 512); 52 + // constant() L2-normalises, so cosine_similarity with itself should be 1. 53 + assert!((output.cosine_similarity(&output) - 1.0).abs() < 1e-6); 54 + } 55 + 56 + #[test] 57 + fn public_embedding_cosine_similarity_drives_matching_math() { 58 + // Two embeddings constructed to be orthogonal in 2-D should have 59 + // cosine similarity ≈ 0 — the foundation of the matching layer that 60 + // arrives in M4. 61 + let a = Embedding::normalized(vec![1.0, 0.0]); 62 + let b = Embedding::normalized(vec![0.0, 1.0]); 63 + assert!(a.cosine_similarity(&b).abs() < 1e-6); 64 + }