A facial recognition login service for Linux.
2

Configure Feed

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

cli: Assess and report liveness in dual-modality test

`test` now runs the liveness policy against the IR capture and reports
it. The IR modality captures inline (via the new `capture_ir` helper)
instead of through the RGB retry path, so the full burst is retained and
handed to the policy as a `LivenessInput`. The burst is sized with
`liveness_burst` so the micro-motion check has enough same-phase frames.

Output gains a `Liveness:` line with the combined score, the threshold,
the applicable/total check counts, and a per-check breakdown (including
`NotApplicable` lines, so it's visible why a sensor's ceiling is what it
is). A new `--liveness-threshold` flag (default 0.0) gates the verdict:
a passing identity match is downgraded to `Reject (liveness)` if the IR
capture didn't clear the threshold. At the default 0.0 this never
fires — liveness is observe-only until profiled, exactly the posture for
untuned heuristics.

Also simplifies the old `try_capture_face` into an RGB-only
`capture_rgb_face`: the IR branch and its `kind`/`ir_burst` params became
dead once IR moved to the inline burst-retaining path, so they're
removed rather than left as misleading cruft.

Liveness is wired on IR here, but the framework is modality-agnostic;
RGB liveness (retain RGB frames, run the same policy) is a documented
future step.

Isaac Corbrey (May 29, 2026, 7:34 AM EDT) b5199370 f7d3f40b

+142 -61
+126 -61
crates/cli/src/commands/test.rs
··· 29 29 use pareidolia_core::camera::select::{resolve_ir_camera, IrCameraResolution}; 30 30 use pareidolia_core::camera::v4l2::enumerate; 31 31 use pareidolia_core::camera::{CameraInfo, CameraKind}; 32 + use pareidolia_core::liveness::{ 33 + CheckOutcome, LivenessAssessment, LivenessPolicy, MicroMotionCheck, 34 + }; 32 35 use pareidolia_core::enrollment::{ 33 36 AuthMode, DualMatchOutcome, EnrollmentReadiness, MatchOutcome, MatchResult, SingleVerdict, 34 37 DEFAULT_THRESHOLD, ··· 50 53 /// stricter (more false-rejects, fewer false-accepts). 51 54 #[arg(long, default_value_t = DEFAULT_THRESHOLD)] 52 55 pub threshold: f32, 56 + 57 + /// Minimum combined liveness score (0.0–1.0) the IR capture must 58 + /// reach to pass. Defaults to 0.0 — i.e. liveness is computed and 59 + /// logged but never rejects, the right posture while the heuristics 60 + /// are untuned. Raise it once you've profiled live-vs-spoof scores 61 + /// on your hardware (see `pareidolia calibrate`). Only applies to the 62 + /// IR modality, where the multi-frame burst lives. 63 + #[arg(long, default_value_t = 0.0)] 64 + pub liveness_threshold: f32, 53 65 54 66 /// RGB V4L2 camera device. Overrides auto-detection. When unset, the 55 67 /// single RGB-classified device is auto-detected (erroring if zero ··· 189 201 kind: CameraKind::Ir, 190 202 bus: None, 191 203 }); 192 - let ir_burst = pipeline_runtime::resolve_ir_burst(&ir_info, store.root(), &cameras); 204 + // Matching burst from calibration, grown so liveness checks 205 + // have several same-phase frames to work with. 206 + let matching_burst = pipeline_runtime::resolve_ir_burst(&ir_info, store.root(), &cameras); 207 + let ir_burst = 208 + pipeline_runtime::liveness_burst(matching_burst, pipeline_runtime::DEFAULT_LIVENESS_DEPTH); 193 209 194 210 // Dual mode: capture from each sensor serially (never both 195 211 // open at once — see `cli/src/commands/enroll.rs` for the 196 212 // rationale) and let `authenticate_dual` decide. 197 - let rgb_face = try_capture_face( 213 + let rgb_face = capture_rgb_face( 198 214 &rgb_camera, 199 - CameraKind::Rgb, 200 215 &mut detector, 201 216 &mut embedder, 202 - pipeline_runtime::DEFAULT_IR_BURST, 203 217 args.debug_dump_dir.as_deref(), 204 218 )?; 205 219 // Match the enroll-loop cooldown so consecutive opens of 206 220 // sibling video nodes on the same physical UVC device 207 221 // don't fight each other. 208 222 thread::sleep(Duration::from_millis(500)); 209 - let ir_face = try_capture_face( 223 + 224 + // IR capture is done inline (not via `try_capture_face`) so 225 + // we retain the whole burst for liveness assessment. 226 + let ir_capture = capture_ir( 210 227 &ir_camera, 211 - CameraKind::Ir, 228 + ir_burst, 212 229 &mut detector, 213 230 &mut embedder, 214 - ir_burst, 215 231 args.debug_dump_dir.as_deref(), 216 232 )?; 233 + let ir_face = match &ir_capture.outcome { 234 + pipeline_runtime::RetryCaptureOutcome::Found { face, .. } => Some((**face).clone()), 235 + pipeline_runtime::RetryCaptureOutcome::NoFace { attempts, brightest } => { 236 + if *brightest < pipeline_runtime::DARK_FRAME_MEAN_THRESHOLD { 237 + eprintln!( 238 + " IR : frames stayed dark (brightest mean {brightest:.1}/255 over \ 239 + {attempts} frames) — the IR illuminator likely did not turn on.", 240 + ); 241 + } 242 + None 243 + } 244 + }; 245 + 246 + // Assess liveness from the retained IR burst and report it. 247 + // With the default threshold 0.0 this only logs; a positive 248 + // threshold can reject. 249 + let liveness = liveness_policy().assess(&ir_capture.liveness_input()); 250 + print_liveness(&liveness, args.liveness_threshold); 217 251 218 252 let outcome = enrollment.authenticate_dual( 219 253 rgb_face.as_ref().map(|f| &f.embedding), 220 254 ir_face.as_ref().map(|f| &f.embedding), 221 255 args.threshold, 222 256 ); 223 - print_dual_outcome(&outcome, args.threshold, &args.user); 257 + 258 + // Liveness gates the final verdict: even a passing identity 259 + // match is rejected if the IR capture didn't clear the 260 + // liveness threshold. (At the default 0.0 this never fires.) 261 + if matches!(outcome, DualMatchOutcome::Pass { .. }) 262 + && !liveness.passes(args.liveness_threshold) 263 + { 264 + println!( 265 + "Reject (liveness): identity matched but liveness score {:.3} < threshold {:.3}", 266 + liveness.combined, args.liveness_threshold, 267 + ); 268 + } else { 269 + print_dual_outcome(&outcome, args.threshold, &args.user); 270 + } 224 271 } 225 272 AuthMode::Single { rgb_camera } => { 226 - // Single RGB mode (default). Burst length is irrelevant for 227 - // RGB (it uses the retry path), so pass the default. 228 - let face = try_capture_face( 273 + // Single RGB mode (default). 274 + let face = capture_rgb_face( 229 275 &rgb_camera, 230 - CameraKind::Rgb, 231 276 &mut detector, 232 277 &mut embedder, 233 - pipeline_runtime::DEFAULT_IR_BURST, 234 278 args.debug_dump_dir.as_deref(), 235 279 )?; 236 280 match face { ··· 247 291 Ok(()) 248 292 } 249 293 294 + /// The liveness policy `test` runs against the IR capture. 295 + /// 296 + /// Currently just the universal micro-motion check. Additional checks 297 + /// (embedding-agreement, texture, differencing) will join here as they 298 + /// land; the noisy-OR policy combines whatever is applicable. 299 + fn liveness_policy() -> LivenessPolicy { 300 + LivenessPolicy::new().with_check(Box::new(MicroMotionCheck::default())) 301 + } 302 + 303 + /// Open the IR camera, capture a liveness-sized burst, and return the 304 + /// full [`IrCapture`] (burst retained for liveness). Unlike 305 + /// [`capture_rgb_face`], this keeps the burst rather than collapsing to 306 + /// the matching outcome. 307 + fn capture_ir( 308 + path: &Path, 309 + burst: u32, 310 + detector: &mut ScrfdDetector, 311 + embedder: &mut ArcfaceEmbedder, 312 + debug_dump_dir: Option<&Path>, 313 + ) -> Result<pipeline_runtime::IrCapture, CliError> { 314 + let mut source = pipeline_runtime::open_camera(path, CameraKind::Ir)?; 315 + pipeline_runtime::capture_ir_face(&mut source, detector, embedder, burst, |frame, idx| { 316 + if let Some(dir) = debug_dump_dir { 317 + let p = dir.join(format!("ir-attempt{idx}.ppm")); 318 + pipeline_runtime::write_frame_as_ppm(&p, frame)?; 319 + eprintln!(" IR : dumped frame to {}", p.display()); 320 + } 321 + Ok(()) 322 + }) 323 + } 324 + 325 + /// Render a liveness assessment: the combined score versus the 326 + /// threshold, plus a per-check breakdown (including `NotApplicable` 327 + /// lines, so it's visible *why* a sensor's ceiling is what it is). 328 + fn print_liveness(assessment: &LivenessAssessment, threshold: f32) { 329 + println!( 330 + "Liveness: combined {:.3} (threshold {:.3}, {} of {} checks applicable)", 331 + assessment.combined, 332 + threshold, 333 + assessment.applicable_count, 334 + assessment.checks.len(), 335 + ); 336 + for c in &assessment.checks { 337 + let detail = match c.outcome { 338 + CheckOutcome::Applicable(s) => format!("{s:.3}"), 339 + CheckOutcome::NotApplicable => "n/a".to_string(), 340 + }; 341 + println!(" - {}: {}", c.name, detail); 342 + } 343 + } 344 + 250 345 /// Open `path` and capture a face, run detect/embed. 251 346 /// 252 347 /// Capture strategy is per modality. RGB uses ··· 258 353 /// emitter. Both are *bounded* rather than "loop until a face appears" — 259 354 /// see those helpers for why that matters on the login path. 260 355 /// 261 - /// `ir_burst` is the IR capture burst length (from calibration); it's 262 - /// ignored for RGB, which uses the retry path. 356 + /// "No face detected" is a *verdict* (the modality produced no 357 + /// embedding), not an error to abort on, so it's folded into `Ok(None)`. 358 + /// All other errors still bubble. 263 359 /// 264 - /// "No face detected" is a *verdict* (the modality produced no 265 - /// embedding), not an error to abort on, so it's folded into `Ok(None)`; 266 - /// for an IR sensor whose frames stayed black, we note that the 267 - /// illuminator likely never fired. All other errors still bubble. 268 - fn try_capture_face( 360 + /// RGB-only: the IR path captures inline (see [`capture_ir`]) so it can 361 + /// retain the burst for liveness, which this helper deliberately 362 + /// discards. 363 + fn capture_rgb_face( 269 364 path: &Path, 270 - kind: CameraKind, 271 365 detector: &mut ScrfdDetector, 272 366 embedder: &mut ArcfaceEmbedder, 273 - ir_burst: u32, 274 367 debug_dump_dir: Option<&Path>, 275 368 ) -> Result<Option<FaceResult>, CliError> { 276 - let mut source = pipeline_runtime::open_camera(path, kind)?; 277 - let label = match kind { 278 - CameraKind::Rgb => "rgb", 279 - CameraKind::Ir => "ir", 280 - }; 369 + let mut source = pipeline_runtime::open_camera(path, CameraKind::Rgb)?; 281 370 let dump = |frame: &_, idx: u32| -> Result<(), CliError> { 282 371 if let Some(dir) = debug_dump_dir { 283 - let p = dir.join(format!("{label}-attempt{idx}.ppm")); 372 + let p = dir.join(format!("rgb-attempt{idx}.ppm")); 284 373 pipeline_runtime::write_frame_as_ppm(&p, frame)?; 285 - eprintln!(" {}: dumped frame to {}", label.to_uppercase(), p.display()); 374 + eprintln!(" RGB: dumped frame to {}", p.display()); 286 375 } 287 376 Ok(()) 288 377 }; 289 - let outcome = match kind { 290 - CameraKind::Rgb => pipeline_runtime::capture_one_face_retrying( 291 - &mut source, 292 - detector, 293 - embedder, 294 - pipeline_runtime::DEFAULT_CAPTURE_ATTEMPTS, 295 - dump, 296 - )?, 297 - // The IR capture retains its burst for liveness assessment; 298 - // this function only forwards the matching outcome for now. 299 - // Liveness wiring consumes the burst at the call site in a 300 - // later commit. 301 - CameraKind::Ir => { 302 - pipeline_runtime::capture_ir_face(&mut source, detector, embedder, ir_burst, dump)? 303 - .outcome 304 - } 305 - }; 378 + let outcome = pipeline_runtime::capture_one_face_retrying( 379 + &mut source, 380 + detector, 381 + embedder, 382 + pipeline_runtime::DEFAULT_CAPTURE_ATTEMPTS, 383 + dump, 384 + )?; 306 385 307 386 match outcome { 308 387 RetryCaptureOutcome::Found { face, .. } => Ok(Some(*face)), 309 - RetryCaptureOutcome::NoFace { 310 - attempts, 311 - brightest, 312 - } => { 313 - // An IR sensor whose best frame was still near-black points 314 - // at the illuminator, not the user — say so rather than a 315 - // bare "no face". 316 - if kind == CameraKind::Ir && brightest < pipeline_runtime::DARK_FRAME_MEAN_THRESHOLD { 317 - eprintln!( 318 - " IR : frames stayed dark (brightest mean {brightest:.1}/255 over \ 319 - {attempts} attempts) — the IR illuminator likely did not turn on.", 320 - ); 321 - } 322 - Ok(None) 323 - } 388 + RetryCaptureOutcome::NoFace { .. } => Ok(None), 324 389 } 325 390 } 326 391
+16
crates/core/src/liveness/mod.rs
··· 83 83 //! - **Active-illumination differencing** — `lit − dark` structure on a 84 84 //! strobed sensor's pair. Strobed-only; the strongest model-free 85 85 //! signal, purely additive. 86 + //! 87 + //! # Modality scope (IR vs RGB) 88 + //! 89 + //! This framework is modality-agnostic: a [`LivenessCheck`] sees `Frame`s 90 + //! and never asks whether they came from IR or RGB. Today only the IR 91 + //! capture path retains its burst and runs assessment, so liveness is 92 + //! wired on IR in practice — but that's a wiring choice, not a 93 + //! limitation of the design. The universal checks (micro-motion, 94 + //! texture, embedding-agreement) apply equally to RGB, and some are 95 + //! *stronger* there (RGB shows screen pixel grids and print halftones 96 + //! more clearly than IR). RGB liveness matters most on RGB-only setups, 97 + //! where there's no IR sensor and thus no dual-modality anti-spoof at 98 + //! all. Enabling it is a future step: retain the RGB capture's frames 99 + //! (as the IR path already does) and run the same policy over them. 100 + //! Differencing stays IR-only and gates off on RGB via `NotApplicable`, 101 + //! exactly the additive model. 86 102 87 103 use crate::camera::Frame; 88 104