A facial recognition login service for Linux.
2

Configure Feed

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

core,cli: Add IR support via modality-aware matching + dual-camera enroll

ArcFace is RGB-trained, so an IR-captured face embedding lives in a
different region of the 512-d space than the RGB embedding of the
same face. Comparing across modalities produces near-zero similarities
even for the same person — fail-safe but unusable. Making IR auth
actually work meant moving five pieces together: the matching layer
had to filter by modality, enroll had to capture both sensors,
`open_camera` had to choose the right pixel format for each kind,
the V4L2 stream had to be primed before the first capture would
return real data, and the dual-camera capture loop had to open each
sensor on demand rather than holding both simultaneously.

# Matching (core)

`Enrollment::best_match` and `Enrollment::authenticate` now take a
`CameraKind` parameter representing the live query's modality. Only
same-kind samples are considered for the best-of-N selection. The
property tests already establish that best-of-N is monotonically
improving in N; adding the modality filter doesn't change that
property within either pool.

A new `MatchOutcome::NoSamplesForModality { kind }` variant
distinguishes 'user has samples but not of this kind' from
`NoEnrollment` ('user has no samples at all'). They sort to the
same auth decision (no match possible) but have different
remediations: enroll vs enroll-for-this-modality. The PAM glue in M5
will route them differently in logs.

# Dual-camera enroll (cli)

`pareidolia enroll` gains `--rgb-camera` (default /dev/video0,
renamed from `--camera`) and `--ir-camera` (optional). When both
are set, each capture iteration pulls one frame from each sensor:
the RGB frame goes into the enrollment tagged Rgb, the IR frame
tagged Ir. Same user pose roughly across both, but slight motion
between them is fine — each modality's samples live in their own
pool and benefit from minor pose variation within that pool.

# Camera format selection by kind

`open_camera` picks the V4L2 FourCC by camera kind: YUYV for RGB
cameras, GREY for IR. Hardcoding YUYV across the board caused IR
captures to either negotiate to garbage or silently mislabel a GREY
stream as YUYV, which then propagated through the pipeline as
gibberish data and produced 'no face detected' for every IR sample.
Most laptop and USB IR sensors advertise GREY (8-bit grayscale) and
nothing else.

# Stream priming

`open_camera` captures and discards one frame immediately after
opening. Two unrelated problems both want the first frame thrown
away:

1. The `v4l` crate's `Stream::next()` runs `VIDIOC_STREAMON` on
the first call and returns a buffer slot without the QBUF/DQBUF
cycle that would fill it with real captured data. The first
frame from a fresh source is therefore whatever was in the mmap
buffer at REQBUFS time — typically zeros, sometimes garbage.
2. IR illuminators on UVC cameras are commonly gated on the device
entering streaming state and take a frame or two after STREAMON
to reach full brightness. The first captured IR frame can be
too dark for the detector even when the crate's behaviour is
perfect.

Priming once after open sidesteps both. The ~33 ms cost is paid
once per source, not per capture.

# Open-on-demand for dual-modality enroll

The original dual-camera enroll loop held both V4L2 sources open
for the whole session to amortise the per-open cost. This wedges
some UVC dual-sensor devices: sample 1 succeeds, then the next
DQBUF on either sibling video node blocks indefinitely. UVC
bandwidth is allocated statically at STREAMON and dual-sensor
devices vary in how gracefully they cope with two concurrent
sustained streams.

Cameras are now opened **per modality, per sample**: open RGB,
prime, capture, drop; sleep a sibling-open cooldown; open IR,
prime, capture, drop. The two streams are never alive at the same
time. Cost is ~1s per sample of open-overhead, paid only during
enrollment (a once-per-user operation), and the pattern matches
exactly what the daemon will do for PAM auth requests in M5.

# Test subcommand

`pareidolia test` gains the same flag pair, but mutually exclusive:
either you test against RGB (`--rgb-camera`, default) or IR
(`--ir-camera`), since a single test is a single match attempt.
The query's modality is now passed through to
`Enrollment::authenticate` and the matcher filters appropriately.
A new printable path handles `NoSamplesForModality` cleanly with
an actionable suggestion ('re-run `pareidolia enroll` with
--ir-camera to add them').

# Tests

Two new unit tests pin the modality filter:
- `best_match_filters_to_query_kind_ignoring_cross_modality_samples`
- `authenticate_with_only_other_modality_samples_yields_no_samples_for_modality`

One new utility (`Enrollment::count_by_kind`) with its own unit
test; used by the enroll CLI for the user-facing 'saved X RGB and Y
IR samples' summary.

One new integration smoke test:
- `public_cross_modality_query_surfaces_no_samples_for_modality`

All existing tests updated to pass `CameraKind::Rgb` to
`best_match`/`authenticate` (existing samples are tagged Rgb by
default in the test fixture, so behaviour is unchanged for them).

Isaac Corbrey (May 27, 2026, 11:52 PM EDT) 2f490145 c5dd4213

+362 -83
+112 -28
crates/cli/src/commands/enroll.rs
··· 16 16 17 17 use clap::Args as ClapArgs; 18 18 19 - use pareidolia_core::camera::{CameraKind, FrameSource}; 19 + use pareidolia_core::camera::CameraKind; 20 20 use pareidolia_core::enrollment::store::StoreError; 21 21 use pareidolia_core::enrollment::{EnrolledSample, Enrollment, SampleId}; 22 22 use pareidolia_core::pipeline::arcface::{ArcfaceConfig, ArcfaceEmbedder}; ··· 30 30 #[arg(long, env = "USER")] 31 31 pub user: String, 32 32 33 - /// Number of face samples to capture. 33 + /// Number of face samples to capture *per modality*. 34 34 #[arg(long, default_value_t = 5)] 35 35 pub samples: u32, 36 36 37 - /// V4L2 camera device. 37 + /// RGB V4L2 camera device. Disable with `--no-rgb-camera`-style 38 + /// usage by overriding this with /dev/null, but typically you want 39 + /// the default. 38 40 #[arg(long, default_value = "/dev/video0")] 39 - pub camera: PathBuf, 41 + pub rgb_camera: PathBuf, 42 + 43 + /// Optional IR V4L2 camera device. When set, each capture iteration 44 + /// also pulls one frame from this sensor, embeds it, and stores 45 + /// the sample tagged as IR. Lets the daemon (and `pareidolia test`) 46 + /// pick the right pool depending on which sensor the live capture 47 + /// came from. On the BRIO this is `/dev/video2`; on a typical XPS 48 + /// laptop the IR camera is `/dev/video2` or `/dev/video4`. Find it 49 + /// with `pareidolia cameras`. 50 + #[arg(long)] 51 + pub ir_camera: Option<PathBuf>, 40 52 41 53 /// Path to the SCRFD ONNX model. Defaults to PAREIDOLIA_SCRFD env 42 54 /// var (the Nix devShell sets PAREIDOLIA_TEST_SCRFD to the pinned ··· 83 95 Err(e) => return Err(e.into()), 84 96 }; 85 97 86 - // Open the camera once for the whole capture session. This holds 87 - // the camera exclusively until the source is dropped; for a 88 - // user-driven enrollment that's the right trade-off (steady framing 89 - // across the N captures, no per-frame open/close jitter). 90 - let mut source = pipeline_runtime::open_camera(&args.camera, CameraKind::Rgb)?; 98 + // Cameras are opened **per modality, per sample** rather than held 99 + // for the whole session. The original design held both sources 100 + // open to amortise the per-open cost, but holding RGB and IR 101 + // streams open simultaneously against sibling video nodes on a 102 + // single physical UVC device wedges some devices: sample 1 103 + // succeeds, then the next DQBUF on either node blocks 104 + // indefinitely. UVC bandwidth is allocated statically at STREAMON 105 + // and dual-sensor devices vary in how gracefully they cope with 106 + // two concurrent sustained streams. 107 + // 108 + // Opening each modality on demand and dropping it before opening 109 + // the other costs ~1s per sample but is completely reliable, and 110 + // matches the pattern the daemon will use for PAM auth (open → 111 + // capture → drop, never holding the camera between requests). 91 112 92 113 let mut detector = 93 114 ScrfdDetector::open(&scrfd_path, ScrfdConfig::default()).map_err(CliError::Pipeline)?; ··· 107 128 .map(|m| m + 1) 108 129 .unwrap_or(1); 109 130 110 - let kind = source.info().kind; 111 131 let interval = Duration::from_millis(args.capture_interval_ms); 112 - for i in 0..args.samples { 113 - eprintln!("Capturing sample {} of {}…", i + 1, args.samples); 132 + let mut new_rgb = 0u32; 133 + let mut new_ir = 0u32; 114 134 115 - let face = pipeline_runtime::capture_one_face(&mut source, &mut detector, &mut embedder)?; 135 + // Empirically, UVC dual-sensor devices commonly need ~500ms after 136 + // closing one sibling video node before another open will reliably 137 + // succeed; without this the second open returns EIO or hangs. We 138 + // pay this cooldown between every RGB↔IR transition. 139 + let sibling_open_cooldown = Duration::from_millis(500); 116 140 141 + for i in 0..args.samples { 117 142 eprintln!( 118 - " detection confidence {:.3}, embedding dim {}", 119 - face.detection.confidence, 120 - face.embedding.dim(), 143 + "Capturing sample {} of {}{}…", 144 + i + 1, 145 + args.samples, 146 + if args.ir_camera.is_some() { 147 + " (RGB + IR)" 148 + } else { 149 + "" 150 + }, 121 151 ); 122 152 153 + // ----- RGB modality --------------------------------------------------- 154 + // RGB capture is mandatory: --rgb-camera always has a value 155 + // (default /dev/video0) so we always produce an RGB sample. 156 + let mut rgb_source = pipeline_runtime::open_camera(&args.rgb_camera, CameraKind::Rgb)?; 157 + let rgb_face = 158 + pipeline_runtime::capture_one_face(&mut rgb_source, &mut detector, &mut embedder)?; 159 + eprintln!( 160 + " RGB: confidence {:.3}, embedding dim {}", 161 + rgb_face.detection.confidence, 162 + rgb_face.embedding.dim(), 163 + ); 123 164 enrollment.push(EnrolledSample { 124 165 id: SampleId(next_id), 125 - embedding: face.embedding, 166 + embedding: rgb_face.embedding, 126 167 captured_at: SystemTime::now(), 127 - camera_kind: kind, 168 + camera_kind: CameraKind::Rgb, 128 169 model_fingerprint: scrfd_fingerprint, 129 170 }); 130 171 next_id += 1; 172 + new_rgb += 1; 173 + // Explicit drop releases V4L2 buffers and closes the device so 174 + // the IR open below isn't fighting the RGB stream for USB 175 + // bandwidth. 176 + drop(rgb_source); 131 177 132 - // Brief pause between captures so the user has time to vary 133 - // their pose / expression. Skip on the final iteration since 134 - // we're about to exit anyway. 178 + // ----- IR modality (optional) ---------------------------------------- 179 + if let Some(ir_path) = &args.ir_camera { 180 + // Sibling-node cooldown. See `sibling_open_cooldown` above. 181 + thread::sleep(sibling_open_cooldown); 182 + 183 + let mut ir_source = pipeline_runtime::open_camera(ir_path, CameraKind::Ir)?; 184 + let ir_face = 185 + pipeline_runtime::capture_one_face(&mut ir_source, &mut detector, &mut embedder)?; 186 + eprintln!( 187 + " IR : confidence {:.3}, embedding dim {}", 188 + ir_face.detection.confidence, 189 + ir_face.embedding.dim(), 190 + ); 191 + enrollment.push(EnrolledSample { 192 + id: SampleId(next_id), 193 + embedding: ir_face.embedding, 194 + captured_at: SystemTime::now(), 195 + camera_kind: CameraKind::Ir, 196 + model_fingerprint: scrfd_fingerprint, 197 + }); 198 + next_id += 1; 199 + new_ir += 1; 200 + drop(ir_source); 201 + } 202 + 203 + // Pause between iterations so the user can vary pose / 204 + // expression. Cameras are closed at this point so a plain sleep 205 + // is safe — there's no buffer queue to stagnate. No need to 206 + // sleep after the final iteration. The pause also satisfies 207 + // the sibling-open cooldown before the next sample's RGB open, 208 + // so we take the max of the two rather than double-sleeping. 135 209 if i + 1 < args.samples { 136 - thread::sleep(interval); 210 + let pause = interval.max(sibling_open_cooldown); 211 + thread::sleep(pause); 137 212 } 138 213 } 139 214 140 215 store.save(&enrollment)?; 141 - eprintln!( 142 - "Saved {} new sample(s) for {} ({} total).", 143 - args.samples, 144 - args.user, 145 - enrollment.len(), 146 - ); 216 + if new_ir > 0 { 217 + eprintln!( 218 + "Saved {new_rgb} new RGB and {new_ir} new IR sample(s) for {} (total: \ 219 + {} RGB, {} IR).", 220 + args.user, 221 + enrollment.count_by_kind(CameraKind::Rgb), 222 + enrollment.count_by_kind(CameraKind::Ir), 223 + ); 224 + } else { 225 + eprintln!( 226 + "Saved {new_rgb} new RGB sample(s) for {} ({} total).", 227 + args.user, 228 + enrollment.len(), 229 + ); 230 + } 147 231 Ok(()) 148 232 }
+28 -4
crates/cli/src/commands/test.rs
··· 36 36 #[arg(long, default_value_t = DEFAULT_THRESHOLD)] 37 37 pub threshold: f32, 38 38 39 - /// V4L2 camera device. 39 + /// RGB V4L2 camera device. Mutually exclusive with `--ir-camera`. 40 40 #[arg(long, default_value = "/dev/video0")] 41 - pub camera: PathBuf, 41 + pub rgb_camera: PathBuf, 42 + 43 + /// Use this IR camera instead of the RGB one. When set, the match 44 + /// runs against the user's IR-enrolled samples only. 45 + #[arg(long, conflicts_with = "rgb_camera")] 46 + pub ir_camera: Option<PathBuf>, 42 47 43 48 /// SCRFD ONNX model path. Falls back to $PAREIDOLIA_TEST_SCRFD. 44 49 #[arg(long, env = "PAREIDOLIA_SCRFD")] ··· 80 85 "ArcFace", 81 86 )?; 82 87 83 - let mut source = pipeline_runtime::open_camera(&args.camera, CameraKind::Rgb)?; 88 + // clap's `conflicts_with = "rgb_camera"` on `ir_camera` keeps the 89 + // two from being set together at parse time, so we can branch on 90 + // `ir_camera` alone. 91 + let (camera_path, kind) = match &args.ir_camera { 92 + Some(path) => (path.clone(), CameraKind::Ir), 93 + None => (args.rgb_camera.clone(), CameraKind::Rgb), 94 + }; 95 + 96 + let mut source = pipeline_runtime::open_camera(&camera_path, kind)?; 84 97 let mut detector = 85 98 ScrfdDetector::open(&scrfd_path, ScrfdConfig::default()).map_err(CliError::Pipeline)?; 86 99 let mut embedder = ArcfaceEmbedder::open(&arcface_path, ArcfaceConfig::default()) 87 100 .map_err(CliError::Pipeline)?; 88 101 89 102 let face = pipeline_runtime::capture_one_face(&mut source, &mut detector, &mut embedder)?; 90 - let outcome = enrollment.authenticate(&face.embedding, args.threshold); 103 + let outcome = enrollment.authenticate(&face.embedding, kind, args.threshold); 91 104 92 105 match outcome { 93 106 MatchOutcome::Pass(result) => { ··· 107 120 // already short-circuited — but handle it for completeness 108 121 // in case Enrollment's invariants ever change. 109 122 println!("Not enrolled: {} has no samples", args.user); 123 + } 124 + MatchOutcome::NoSamplesForModality { kind } => { 125 + let (label, flag) = match kind { 126 + CameraKind::Rgb => ("RGB", "--rgb-camera"), 127 + CameraKind::Ir => ("IR", "--ir-camera"), 128 + }; 129 + println!( 130 + "Not enrolled for {label}: {} has no {label} samples \ 131 + (re-run `pareidolia enroll` with {flag} to add them)", 132 + args.user, 133 + ); 110 134 } 111 135 } 112 136
+43 -9
crates/cli/src/pipeline_runtime.rs
··· 23 23 24 24 use crate::CliError; 25 25 26 - /// Open a V4L2 camera at `path` and explicitly request a pixel format we 27 - /// know how to decode. 26 + /// Open a V4L2 camera at `path` and explicitly request a pixel format 27 + /// we know how to decode, chosen by kind. Primes the stream by 28 + /// capturing and discarding one frame before returning. 29 + /// 30 + /// # FourCC by kind 28 31 /// 29 - /// USB webcams commonly default to MJPG output, which we don't yet 30 - /// decode — explicitly asking for YUYV (universally supported by UVC 31 - /// cameras and handled by `camera::convert::to_rgb8`) keeps the pipeline 32 - /// working on default-config consumer hardware. When MJPEG decoding 33 - /// lands the preference can become a `--fourcc` flag. 32 + /// USB webcams commonly default to MJPG, which we don't yet decode, so 33 + /// we explicitly ask for the right format up front: 34 + /// 35 + /// - RGB cameras: YUYV (universally supported by UVC). 36 + /// - IR cameras: GREY (8-bit grayscale — what most laptop and USB IR 37 + /// sensors actually output; asking for YUYV here would either 38 + /// negotiate to garbage or silently mislabel a GREY stream as YUYV 39 + /// and produce undefined results downstream). 40 + /// 41 + /// When MJPEG decoding lands the preference can become a `--fourcc` 42 + /// flag. 43 + /// 44 + /// # Stream priming 45 + /// 46 + /// Two unrelated problems both want the first frame discarded: 47 + /// 48 + /// 1. The `v4l` crate's `Stream::next()` first-call path runs 49 + /// `VIDIOC_STREAMON` and returns the buffer slot *without* the 50 + /// QBUF/DQBUF cycle that fills it with captured data. The first 51 + /// `next()` therefore hands back whatever was in mmap'd memory at 52 + /// REQBUFS time — typically zeros on Linux, sometimes leftover 53 + /// bytes. Either way it's not a real captured frame. 54 + /// 2. IR illuminators on UVC cameras are commonly gated on the device 55 + /// entering streaming state and take a frame or two after STREAMON 56 + /// to reach full brightness. The first captured frame can be too 57 + /// dark for the detector even if the crate's behaviour were 58 + /// perfect. 59 + /// 60 + /// Priming once after open sidesteps both. The ~33 ms cost is paid 61 + /// once per source, not per capture. 34 62 pub fn open_camera(path: &Path, kind: CameraKind) -> Result<V4l2FrameSource, CliError> { 35 63 let mut cfg = V4l2Config::new(path, "pareidolia camera", kind); 36 - cfg.preferred_fourcc = Some(*b"YUYV"); 37 - V4l2FrameSource::open(cfg).map_err(CliError::Camera) 64 + cfg.preferred_fourcc = Some(match kind { 65 + CameraKind::Rgb => *b"YUYV", 66 + CameraKind::Ir => *b"GREY", 67 + }); 68 + let mut source = V4l2FrameSource::open(cfg).map_err(CliError::Camera)?; 69 + // Prime the stream: see fn-level doc. 70 + let _ = source.capture().map_err(CliError::Camera)?; 71 + Ok(source) 38 72 } 39 73 40 74 /// Resolve a model path from either the explicit `--*-model` argument
+156 -39
crates/core/src/enrollment.rs
··· 181 181 } 182 182 183 183 /// Compute cosine similarity between `query` and every enrolled 184 - /// sample, returning the best (highest) result. Returns `None` when 185 - /// the enrollment is empty. 184 + /// sample whose `camera_kind` matches `query_kind`, returning the 185 + /// best (highest) result. Returns `None` when the enrollment has no 186 + /// samples of the requested kind. 187 + /// 188 + /// Modality matters because ArcFace is RGB-trained: embeddings 189 + /// produced from IR captures land in a different region of the 512-d 190 + /// space than embeddings of the same face captured in RGB. Comparing 191 + /// across modalities produces near-zero similarities even for the 192 + /// same person, so the matcher restricts to same-kind samples 193 + /// before picking the best. 186 194 /// 187 195 /// NaN similarities (from dimensionally mismatched embeddings) are 188 - /// treated as the worst possible match and never win the 189 - /// best-of-N selection, so a mismatched-model query against a 190 - /// populated enrollment surfaces as "no match found" rather than 191 - /// "best match was NaN". 192 - pub fn best_match(&self, query: &Embedding) -> Option<MatchResult> { 196 + /// filtered out so a mismatched-model query against a populated 197 + /// enrollment surfaces as "no match found" rather than "best match 198 + /// was NaN". 199 + pub fn best_match(&self, query: &Embedding, query_kind: CameraKind) -> Option<MatchResult> { 193 200 self.samples 194 201 .iter() 202 + .filter(|s| s.camera_kind == query_kind) 195 203 .map(|s| MatchResult { 196 204 sample_id: s.id, 197 205 similarity: s.embedding.cosine_similarity(query), ··· 204 212 }) 205 213 } 206 214 215 + /// Number of enrolled samples captured under `kind`. Useful for 216 + /// reporting ("alice has 5 RGB and 3 IR samples") and for the 217 + /// matching layer to distinguish "no samples at all" from "no 218 + /// samples of this modality". 219 + pub fn count_by_kind(&self, kind: CameraKind) -> usize { 220 + self.samples 221 + .iter() 222 + .filter(|s| s.camera_kind == kind) 223 + .count() 224 + } 225 + 207 226 /// Decide whether `query` authenticates against this enrollment, 208 - /// using best-of-N similarity vs. `threshold`. The `>= threshold` 209 - /// comparison is intentionally inclusive: a similarity exactly at 210 - /// the threshold passes. 211 - pub fn authenticate(&self, query: &Embedding, threshold: f32) -> MatchOutcome { 212 - let Some(best) = self.best_match(query) else { 227 + /// matching only against samples of the same modality as the live 228 + /// capture. 229 + /// 230 + /// Outcomes, in order of distinction: 231 + /// - [`MatchOutcome::NoEnrollment`] when the user has no samples 232 + /// at all (e.g. they've never enrolled, or every sample was 233 + /// removed). 234 + /// - [`MatchOutcome::NoSamplesForModality`] when the user has 235 + /// samples, but none of them under `query_kind` (e.g. they 236 + /// enrolled with RGB and are trying to auth with IR). 237 + /// - [`MatchOutcome::Pass`] when best-of-N similarity ≥ threshold. 238 + /// - [`MatchOutcome::Reject`] when best-of-N similarity < threshold. 239 + /// 240 + /// The `>= threshold` comparison is inclusive: a similarity exactly 241 + /// at the threshold passes. 242 + pub fn authenticate( 243 + &self, 244 + query: &Embedding, 245 + query_kind: CameraKind, 246 + threshold: f32, 247 + ) -> MatchOutcome { 248 + if self.samples.is_empty() { 213 249 return MatchOutcome::NoEnrollment; 250 + } 251 + let Some(best) = self.best_match(query, query_kind) else { 252 + return MatchOutcome::NoSamplesForModality { kind: query_kind }; 214 253 }; 215 254 if best.similarity >= threshold { 216 255 MatchOutcome::Pass(best) ··· 234 273 /// succeeds. The returned [`MatchResult`] identifies the winning 235 274 /// sample for audit/logging. 236 275 Pass(MatchResult), 237 - /// Every enrolled sample had similarity < threshold; auth fails. 238 - /// The best result is included so callers can log how close the 239 - /// attempt came and tune thresholds informedly. 276 + /// Every enrolled sample of the right modality had similarity below 277 + /// threshold; auth fails. The best result is included so callers 278 + /// can log how close the attempt came and tune thresholds 279 + /// informedly. 240 280 Reject { best: MatchResult }, 241 - /// The enrollment has no samples (e.g. user never enrolled, or all 242 - /// samples were removed). Distinguished from `Reject` because the 243 - /// remediation is different — the user needs to enroll, not retry. 281 + /// The enrollment has no samples at all (user never enrolled, or 282 + /// every sample was removed). Distinguished from `Reject` because 283 + /// the remediation is different — enrol, don't retry. 244 284 NoEnrollment, 285 + /// The enrollment has samples, but none captured under the same 286 + /// camera modality as the live query. Cross-modal matching 287 + /// (RGB-enrolled vs IR query, or vice versa) produces meaningless 288 + /// similarities, so the matcher refuses to do it. Remediation: 289 + /// enrol again with `--ir-camera` (or `--rgb-camera`) so the user 290 + /// has samples for the modality they're authing with. 291 + NoSamplesForModality { kind: CameraKind }, 245 292 } 246 293 247 294 impl MatchOutcome { ··· 250 297 matches!(self, Self::Pass(_)) 251 298 } 252 299 253 - /// The best match result if one exists (i.e. the enrollment wasn't 254 - /// empty), regardless of whether it passed the threshold. 300 + /// The best match result if one exists (i.e. the enrollment had 301 + /// candidates of the right modality), regardless of whether it 302 + /// passed the threshold. 255 303 pub fn best(&self) -> Option<MatchResult> { 256 304 match self { 257 305 Self::Pass(r) => Some(*r), 258 306 Self::Reject { best } => Some(*best), 259 - Self::NoEnrollment => None, 307 + Self::NoEnrollment | Self::NoSamplesForModality { .. } => None, 260 308 } 261 309 } 262 310 } ··· 324 372 #[test] 325 373 fn best_match_of_empty_enrollment_is_none() { 326 374 let e = Enrollment::new("alice"); 327 - assert!(e.best_match(&unit_along(0, 4)).is_none()); 375 + assert!(e.best_match(&unit_along(0, 4), CameraKind::Rgb).is_none()); 328 376 } 329 377 330 378 #[test] ··· 332 380 let mut e = Enrollment::new("alice"); 333 381 let emb = unit_along(0, 4); 334 382 e.push(sample(1, emb.clone())); 335 - let r = e.best_match(&emb).expect("present"); 383 + let r = e.best_match(&emb, CameraKind::Rgb).expect("present"); 336 384 assert_eq!(r.sample_id, SampleId(1)); 337 385 assert!((r.similarity - 1.0).abs() < 1e-6); 338 386 } ··· 344 392 e.push(sample(1, unit_along(0, 4))); 345 393 e.push(sample(2, unit_along(1, 4))); 346 394 e.push(sample(3, unit_along(2, 4))); 347 - let r = e.best_match(&unit_along(1, 4)).expect("present"); 395 + let r = e 396 + .best_match(&unit_along(1, 4), CameraKind::Rgb) 397 + .expect("present"); 348 398 assert_eq!(r.sample_id, SampleId(2)); 349 399 assert!((r.similarity - 1.0).abs() < 1e-6); 350 400 } ··· 353 403 fn best_match_returns_orthogonal_similarity_zero_for_disjoint_axes() { 354 404 let mut e = Enrollment::new("alice"); 355 405 e.push(sample(1, unit_along(0, 4))); 356 - let r = e.best_match(&unit_along(1, 4)).expect("present"); 406 + let r = e 407 + .best_match(&unit_along(1, 4), CameraKind::Rgb) 408 + .expect("present"); 357 409 assert!(r.similarity.abs() < 1e-6); 358 410 } 359 411 ··· 364 416 let mut e = Enrollment::new("alice"); 365 417 e.push(sample(1, unit_along(0, 8))); // dim 8 366 418 let query = unit_along(0, 4); // dim 4 367 - assert!(e.best_match(&query).is_none()); 419 + assert!(e.best_match(&query, CameraKind::Rgb).is_none()); 368 420 } 369 421 370 422 #[test] ··· 374 426 let mut e = Enrollment::new("alice"); 375 427 e.push(sample(1, unit_along(0, 8))); // mismatch 376 428 e.push(sample(2, unit_along(0, 4))); // match → 1.0 377 - let r = e.best_match(&unit_along(0, 4)).expect("one valid"); 429 + let r = e 430 + .best_match(&unit_along(0, 4), CameraKind::Rgb) 431 + .expect("one valid"); 378 432 assert_eq!(r.sample_id, SampleId(2)); 379 433 } 380 434 435 + #[test] 436 + fn best_match_filters_to_query_kind_ignoring_cross_modality_samples() { 437 + // Two RGB samples + one IR sample, all with the same embedding 438 + // values. Querying as IR returns only the IR sample, even though 439 + // structurally identical RGB samples would yield similarity 1.0. 440 + let emb = unit_along(0, 8); 441 + let mut e = Enrollment::new("alice"); 442 + e.push(sample(1, emb.clone())); // Rgb (default) 443 + e.push(sample(2, emb.clone())); // Rgb (default) 444 + let mut ir = sample(3, emb.clone()); 445 + ir.camera_kind = CameraKind::Ir; 446 + e.push(ir); 447 + 448 + let r = e.best_match(&emb, CameraKind::Ir).expect("present"); 449 + assert_eq!(r.sample_id, SampleId(3)); 450 + } 451 + 452 + // ----- count_by_kind ----------------------------------------------------- 453 + 454 + #[test] 455 + fn count_by_kind_separates_rgb_and_ir_correctly() { 456 + let mut e = Enrollment::new("alice"); 457 + e.push(sample(1, unit_along(0, 4))); 458 + e.push(sample(2, unit_along(0, 4))); 459 + let mut ir = sample(3, unit_along(0, 4)); 460 + ir.camera_kind = CameraKind::Ir; 461 + e.push(ir); 462 + assert_eq!(e.count_by_kind(CameraKind::Rgb), 2); 463 + assert_eq!(e.count_by_kind(CameraKind::Ir), 1); 464 + } 465 + 381 466 // ----- authenticate ------------------------------------------------------ 382 467 383 468 #[test] 384 469 fn authenticate_empty_enrollment_yields_no_enrollment() { 385 470 let e = Enrollment::new("alice"); 386 471 assert_eq!( 387 - e.authenticate(&unit_along(0, 4), 0.5), 472 + e.authenticate(&unit_along(0, 4), CameraKind::Rgb, 0.5), 388 473 MatchOutcome::NoEnrollment 389 474 ); 390 475 } 391 476 392 477 #[test] 478 + fn authenticate_with_only_other_modality_samples_yields_no_samples_for_modality() { 479 + // Enroll RGB samples, auth with IR → user has samples but not 480 + // of the right kind, distinct from NoEnrollment. 481 + let mut e = Enrollment::new("alice"); 482 + e.push(sample(1, unit_along(0, 8))); 483 + e.push(sample(2, unit_along(1, 8))); 484 + let out = e.authenticate(&unit_along(0, 8), CameraKind::Ir, 0.6); 485 + assert_eq!( 486 + out, 487 + MatchOutcome::NoSamplesForModality { 488 + kind: CameraKind::Ir 489 + }, 490 + ); 491 + } 492 + 493 + #[test] 393 494 fn authenticate_above_threshold_returns_pass() { 394 495 let mut e = Enrollment::new("alice"); 395 496 let emb = unit_along(0, 4); 396 497 e.push(sample(1, emb.clone())); 397 - let out = e.authenticate(&emb, 0.5); 498 + let out = e.authenticate(&emb, CameraKind::Rgb, 0.5); 398 499 assert!(out.is_pass()); 399 500 } 400 501 ··· 403 504 let mut e = Enrollment::new("alice"); 404 505 e.push(sample(1, unit_along(0, 4))); 405 506 // Orthogonal query → similarity 0, well below threshold 0.6. 406 - let out = e.authenticate(&unit_along(1, 4), 0.6); 507 + let out = e.authenticate(&unit_along(1, 4), CameraKind::Rgb, 0.6); 407 508 match out { 408 509 MatchOutcome::Reject { best } => { 409 510 assert_eq!(best.sample_id, SampleId(1)); ··· 417 518 fn authenticate_at_exactly_the_threshold_passes_inclusively() { 418 519 // Construct a query at known similarity 0.6 against the enrolled 419 520 // sample. Two unit vectors with dot product cos(θ) = 0.6. 420 - // Easiest: handcraft a normalised 2-D embedding pair. 421 521 let enrolled = Embedding::from_raw(vec![1.0, 0.0]); 422 522 let query = Embedding::from_raw(vec![0.6, 0.8]); // |query| = 1 423 523 let mut e = Enrollment::new("alice"); 424 524 e.push(sample(1, enrolled)); 425 - let out = e.authenticate(&query, 0.6); 525 + let out = e.authenticate(&query, CameraKind::Rgb, 0.6); 426 526 assert!(out.is_pass(), "exactly at threshold should pass: {out:?}"); 427 527 } 428 528 ··· 443 543 assert!(pass.best().is_some()); 444 544 assert!(reject.best().is_some()); 445 545 assert!(MatchOutcome::NoEnrollment.best().is_none()); 546 + assert!(MatchOutcome::NoSamplesForModality { 547 + kind: CameraKind::Ir 548 + } 549 + .best() 550 + .is_none()); 446 551 } 447 552 448 553 // ----- property tests ---------------------------------------------------- ··· 470 575 e.push(sample(i as u64 + 1, Embedding::normalized(s.clone()))); 471 576 } 472 577 let q = Embedding::normalized(query); 473 - let before = e.best_match(&q).expect("non-empty").similarity; 578 + let before = e 579 + .best_match(&q, CameraKind::Rgb) 580 + .expect("non-empty") 581 + .similarity; 474 582 e.push(sample(99, Embedding::normalized(extra))); 475 - let after = e.best_match(&q).expect("non-empty").similarity; 583 + let after = e 584 + .best_match(&q, CameraKind::Rgb) 585 + .expect("non-empty") 586 + .similarity; 476 587 prop_assert!( 477 588 after >= before - 1e-5, 478 589 "best-match similarity dropped: {before} → {after}", ··· 493 604 } 494 605 let target_emb = Embedding::normalized(target); 495 606 e.push(sample(999, target_emb.clone())); 496 - let r = e.best_match(&target_emb).expect("non-empty"); 607 + let r = e 608 + .best_match(&target_emb, CameraKind::Rgb) 609 + .expect("non-empty"); 497 610 prop_assert!( 498 611 (r.similarity - 1.0).abs() < 1e-4, 499 612 "expected best similarity ~1.0, got {}", ··· 512 625 for (i, s) in samples.iter().enumerate() { 513 626 e.push(sample(i as u64 + 1, Embedding::normalized(s.clone()))); 514 627 } 515 - let out = e.authenticate(&Embedding::normalized(query), f32::NEG_INFINITY); 628 + let out = 629 + e.authenticate(&Embedding::normalized(query), CameraKind::Rgb, f32::NEG_INFINITY); 516 630 prop_assert!(out.is_pass(), "non-empty + -inf threshold should always pass"); 517 631 } 518 632 ··· 528 642 for (i, s) in samples.iter().enumerate() { 529 643 e.push(sample(i as u64 + 1, Embedding::normalized(s.clone()))); 530 644 } 531 - let out = e.authenticate(&Embedding::normalized(query), 1.001); 645 + let out = e.authenticate(&Embedding::normalized(query), CameraKind::Rgb, 1.001); 532 646 prop_assert!(!out.is_pass(), "threshold above 1.0 should reject everything"); 533 647 } 534 648 ··· 548 662 let target_id = SampleId(remove_idx as u64 + 1); 549 663 550 664 let q = Embedding::normalized(query); 551 - let before = e.best_match(&q).expect("non-empty").similarity; 665 + let before = e 666 + .best_match(&q, CameraKind::Rgb) 667 + .expect("non-empty") 668 + .similarity; 552 669 e.remove(target_id); 553 - if let Some(after) = e.best_match(&q) { 670 + if let Some(after) = e.best_match(&q, CameraKind::Rgb) { 554 671 prop_assert!( 555 672 after.similarity <= before + 1e-5, 556 673 "best-match similarity increased after removal: {before} → {}",
+23 -3
crates/integration-tests/tests/integration/enrollment.rs
··· 31 31 let face = unit_along(0, 512); 32 32 enrollment.push(sample(1, face.clone())); 33 33 34 - let outcome = enrollment.authenticate(&face, DEFAULT_THRESHOLD); 34 + let outcome = enrollment.authenticate(&face, CameraKind::Rgb, DEFAULT_THRESHOLD); 35 35 assert!(matches!(outcome, MatchOutcome::Pass(_))); 36 36 let best = outcome.best().expect("a result"); 37 37 assert_eq!(best.sample_id, SampleId(1)); ··· 42 42 fn public_enrollment_rejects_orthogonal_embedding_at_default_threshold() { 43 43 let mut enrollment = Enrollment::new("alice"); 44 44 enrollment.push(sample(1, unit_along(0, 512))); 45 - let outcome = enrollment.authenticate(&unit_along(1, 512), DEFAULT_THRESHOLD); 45 + let outcome = enrollment.authenticate(&unit_along(1, 512), CameraKind::Rgb, DEFAULT_THRESHOLD); 46 46 assert!(matches!(outcome, MatchOutcome::Reject { .. })); 47 47 } 48 48 49 49 #[test] 50 50 fn public_empty_enrollment_surfaces_no_enrollment_outcome() { 51 - let outcome = Enrollment::new("alice").authenticate(&unit_along(0, 4), DEFAULT_THRESHOLD); 51 + let outcome = Enrollment::new("alice").authenticate( 52 + &unit_along(0, 4), 53 + CameraKind::Rgb, 54 + DEFAULT_THRESHOLD, 55 + ); 52 56 assert_eq!(outcome, MatchOutcome::NoEnrollment); 57 + } 58 + 59 + #[test] 60 + fn public_cross_modality_query_surfaces_no_samples_for_modality() { 61 + // RGB-enrolled user queried with IR → distinct from both 62 + // NoEnrollment (user has samples, just not IR ones) and Reject 63 + // (no usable candidates to compare against). 64 + let mut enrollment = Enrollment::new("alice"); 65 + enrollment.push(sample(1, unit_along(0, 512))); 66 + let outcome = enrollment.authenticate(&unit_along(0, 512), CameraKind::Ir, DEFAULT_THRESHOLD); 67 + assert_eq!( 68 + outcome, 69 + MatchOutcome::NoSamplesForModality { 70 + kind: CameraKind::Ir 71 + } 72 + ); 53 73 } 54 74 55 75 #[test]