A facial recognition login service for Linux.
2

Configure Feed

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

core: Add liveness calibration profile model and analysis

The data side of guided liveness calibration: an accumulating profile of
labeled samples plus pure analysis over it. No hardware — a sample is a
named feature vector (the CheckEvaluation `detail` a check produces) with
a class label, so the caller supplies samples (CLI from V4L2, a future
GUI from frames it owns). This keeps the camera-ownership boundary clean:
core never opens a device.

- `SampleClass`: Live / PrintedPhoto / Screen / Other(label) — flexible
per-attack-type labeling for ISO-30107-3-style reporting.
- `LivenessSample`: { class, features: Vec<(name, value)>, timestamp }.
Stores computed features, not raw frames — privacy (no face images in
a non-HMAC file), size, and check-agnostic extensibility. Format
versioned so raw-frame retention could be added later.
- `LivenessProfile`: accumulate via `replace_class` (default re-capture)
or `append_batch` (--append), plus `clear_class`, `class_counts`,
`feature_names`. Carries a free-form `conditions` note since a profile
is only valid for similar capture conditions.
- Analysis: `FeatureStats`, `separation` (d-prime-like class separation
per feature), and `suggest_threshold` (sweeps cut points to minimise
live-reject + spoof-accept, orienting correctly whether live reads
higher or lower than the spoof). Suggestions only — never auto-applied.

Unit-tested: accumulation semantics, stats, separation magnitude, and
threshold suggestion on cleanly-separable / overlapping / inverted-sense
data.

Isaac Corbrey (May 29, 2026, 7:34 AM EDT) 4303ffd4 c9e0fbf7

+502
+1
crates/core/src/liveness/mod.rs
··· 122 122 use crate::camera::Frame; 123 123 124 124 pub mod nir_reflectance; 125 + pub mod profile; 125 126 126 127 pub use nir_reflectance::NirReflectanceGate; 127 128
+501
crates/core/src/liveness/profile.rs
··· 1 + //! Guided liveness calibration: the persisted profile of labeled 2 + //! samples, and the pure analysis over it (per-feature separation + 3 + //! threshold suggestion). 4 + //! 5 + //! # What this is for 6 + //! 7 + //! The liveness checks' thresholds are guesses until measured against 8 + //! real live-vs-spoof captures *on the actual sensor*. This module is 9 + //! the data side of the guided calibration workflow: it accumulates 10 + //! labeled feature samples (a genuine-face batch, a printed-photo batch, 11 + //! a screen batch, …) and reports how separable the classes are per 12 + //! feature, so a defensible threshold can be chosen from data instead of 13 + //! invented. 14 + //! 15 + //! # Frame/camera independence (GUI-ready) 16 + //! 17 + //! Nothing here opens a camera or touches hardware. A sample is a *named 18 + //! feature vector* with a class label — exactly the 19 + //! [`crate::liveness::CheckEvaluation::detail`] a check already produces. 20 + //! The *caller* supplies samples: the CLI computes them by opening the 21 + //! V4L2 camera and running the policy; a future GUI (which owns the 22 + //! camera for live preview, and so can't share the device with a 23 + //! separate capture process) would run the same frame-based checks on 24 + //! frames *it* captured and ingest the resulting feature vectors here. 25 + //! Same core, different frame provider. 26 + //! 27 + //! # What's stored (and what isn't) 28 + //! 29 + //! Per sample: its class, a `(feature_name, value)` vector, and a 30 + //! capture timestamp — *not* raw frames. Storing face/spoof images would 31 + //! be a privacy escalation (real images, in a non-HMAC file) and bloat 32 + //! the profile, for the weak benefit of re-scoring a future check against 33 + //! old captures — which you'd re-validate under current conditions 34 + //! anyway. The named-feature-vector shape is check-agnostic: whatever 35 + //! features the active checks emit are what's stored, so adding a new 36 + //! check just means re-capturing with it active. The file format is 37 + //! versioned so raw-frame retention could be added later behind a flag 38 + //! without breaking existing profiles. 39 + 40 + use serde::{Deserialize, Serialize}; 41 + 42 + /// The kind of subject a sample batch was captured from. `Live` is the 43 + /// genuine user; the rest are presentation attacks. `Other` carries a 44 + /// free label so a novel attack can be profiled without a code change. 45 + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] 46 + pub enum SampleClass { 47 + /// A genuine, live face. 48 + Live, 49 + /// A printed photo of the face. 50 + PrintedPhoto, 51 + /// The face shown on an electronic screen (phone/laptop/monitor). 52 + Screen, 53 + /// Any other attack type, with a free-form label. 54 + Other(String), 55 + } 56 + 57 + impl SampleClass { 58 + /// Whether this class is a presentation attack (anything but 59 + /// [`Self::Live`]). 60 + pub fn is_spoof(&self) -> bool { 61 + !matches!(self, Self::Live) 62 + } 63 + 64 + /// A stable display label. 65 + pub fn label(&self) -> String { 66 + match self { 67 + Self::Live => "live".to_string(), 68 + Self::PrintedPhoto => "printed-photo".to_string(), 69 + Self::Screen => "screen".to_string(), 70 + Self::Other(s) => s.clone(), 71 + } 72 + } 73 + } 74 + 75 + /// One labeled sample: the named feature vector a check (or checks) 76 + /// produced for a single capture, plus its class and capture time. 77 + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] 78 + pub struct LivenessSample { 79 + /// What the sample was captured from. 80 + pub class: SampleClass, 81 + /// `(feature_name, value)` pairs — the check `detail`. 82 + pub features: Vec<(String, f32)>, 83 + /// Unix-epoch seconds of capture. 84 + pub captured_at_unix: u64, 85 + } 86 + 87 + impl LivenessSample { 88 + /// Look up a feature value by name. 89 + pub fn feature(&self, name: &str) -> Option<f32> { 90 + self.features 91 + .iter() 92 + .find(|(n, _)| n == name) 93 + .map(|(_, v)| *v) 94 + } 95 + } 96 + 97 + /// An accumulating profile of labeled samples for one device, built up 98 + /// over one or more guided-calibration captures. 99 + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] 100 + pub struct LivenessProfile { 101 + /// Schema version, so the format can evolve (and a raw-frame 102 + /// retention option could be added later) without misparsing old 103 + /// files. 104 + pub version: u8, 105 + /// Free-form note on the conditions the samples were captured under 106 + /// (e.g. "indoor, evening, no daylight"). A profile is only valid 107 + /// for similar conditions; recorded so a stale profile is 108 + /// recognisable. 109 + #[serde(default)] 110 + pub conditions: String, 111 + /// All samples, across classes and capture sessions. 112 + pub samples: Vec<LivenessSample>, 113 + } 114 + 115 + /// Current profile schema version. 116 + pub const PROFILE_VERSION: u8 = 1; 117 + 118 + impl Default for LivenessProfile { 119 + fn default() -> Self { 120 + Self { 121 + version: PROFILE_VERSION, 122 + conditions: String::new(), 123 + samples: Vec::new(), 124 + } 125 + } 126 + } 127 + 128 + impl LivenessProfile { 129 + /// A fresh empty profile. 130 + pub fn new() -> Self { 131 + Self::default() 132 + } 133 + 134 + /// Ingest a batch of samples for `class`, *replacing* any existing 135 + /// samples of that class. The predictable default: re-capturing a 136 + /// class redoes it cleanly (matches `--capture` without `--append`). 137 + pub fn replace_class(&mut self, class: &SampleClass, batch: Vec<LivenessSample>) { 138 + self.samples.retain(|s| &s.class != class); 139 + self.samples.extend(batch); 140 + } 141 + 142 + /// Ingest a batch for `class`, *appending* to any existing samples of 143 + /// that class (matches `--capture --append`). Useful to build a 144 + /// distribution across sessions/conditions — at the risk of mixing 145 + /// conditions, which the caller is responsible for. 146 + pub fn append_batch(&mut self, batch: Vec<LivenessSample>) { 147 + self.samples.extend(batch); 148 + } 149 + 150 + /// Remove all samples of `class` (or do nothing if none). 151 + pub fn clear_class(&mut self, class: &SampleClass) { 152 + self.samples.retain(|s| &s.class != class); 153 + } 154 + 155 + /// Per-class sample counts, for a status view. 156 + pub fn class_counts(&self) -> Vec<(SampleClass, usize)> { 157 + let mut out: Vec<(SampleClass, usize)> = Vec::new(); 158 + for s in &self.samples { 159 + if let Some(entry) = out.iter_mut().find(|(c, _)| c == &s.class) { 160 + entry.1 += 1; 161 + } else { 162 + out.push((s.class.clone(), 1)); 163 + } 164 + } 165 + out 166 + } 167 + 168 + /// The distinct feature names present across all samples, in first- 169 + /// seen order. 170 + pub fn feature_names(&self) -> Vec<String> { 171 + let mut names: Vec<String> = Vec::new(); 172 + for s in &self.samples { 173 + for (n, _) in &s.features { 174 + if !names.iter().any(|x| x == n) { 175 + names.push(n.clone()); 176 + } 177 + } 178 + } 179 + names 180 + } 181 + } 182 + 183 + /// Summary statistics of one feature within one class. 184 + #[derive(Debug, Clone, PartialEq)] 185 + pub struct FeatureStats { 186 + pub count: usize, 187 + pub min: f32, 188 + pub max: f32, 189 + pub mean: f32, 190 + pub std: f32, 191 + } 192 + 193 + impl FeatureStats { 194 + /// Compute stats over `values`. Returns `None` for an empty slice. 195 + pub fn of(values: &[f32]) -> Option<Self> { 196 + if values.is_empty() { 197 + return None; 198 + } 199 + let n = values.len() as f32; 200 + let mean = values.iter().sum::<f32>() / n; 201 + let var = values.iter().map(|v| (v - mean).powi(2)).sum::<f32>() / n; 202 + Some(Self { 203 + count: values.len(), 204 + min: values.iter().copied().fold(f32::INFINITY, f32::min), 205 + max: values.iter().copied().fold(f32::NEG_INFINITY, f32::max), 206 + mean, 207 + std: var.sqrt(), 208 + }) 209 + } 210 + } 211 + 212 + /// Collect the values of `feature` for samples of `class`. 213 + pub fn feature_values(profile: &LivenessProfile, class: &SampleClass, feature: &str) -> Vec<f32> { 214 + profile 215 + .samples 216 + .iter() 217 + .filter(|s| &s.class == class) 218 + .filter_map(|s| s.feature(feature)) 219 + .collect() 220 + } 221 + 222 + /// How separable two classes are on one feature. 223 + /// 224 + /// `separation` is the gap between the class means measured in pooled 225 + /// standard deviations (a d-prime-like statistic): large magnitude means 226 + /// the classes barely overlap on this feature, near-zero means they're 227 + /// indistinguishable. Sign follows `a.mean - b.mean`. 228 + #[derive(Debug, Clone, PartialEq)] 229 + pub struct FeatureSeparation { 230 + pub feature: String, 231 + pub a: FeatureStats, 232 + pub b: FeatureStats, 233 + /// (mean_a − mean_b) / pooled_std. `None` if pooled std is ~0. 234 + pub separation: Option<f32>, 235 + } 236 + 237 + /// Separation of `feature` between class `a` and class `b`. `None` if 238 + /// either class has no samples for the feature. 239 + pub fn separation( 240 + profile: &LivenessProfile, 241 + feature: &str, 242 + a: &SampleClass, 243 + b: &SampleClass, 244 + ) -> Option<FeatureSeparation> { 245 + let av = feature_values(profile, a, feature); 246 + let bv = feature_values(profile, b, feature); 247 + let sa = FeatureStats::of(&av)?; 248 + let sb = FeatureStats::of(&bv)?; 249 + // Pooled standard deviation. 250 + let pooled_var = (sa.std.powi(2) + sb.std.powi(2)) / 2.0; 251 + let pooled_std = pooled_var.sqrt(); 252 + let sep = if pooled_std > 1e-6 { 253 + Some((sa.mean - sb.mean) / pooled_std) 254 + } else { 255 + None 256 + }; 257 + Some(FeatureSeparation { 258 + feature: feature.to_string(), 259 + a: sa, 260 + b: sb, 261 + separation: sep, 262 + }) 263 + } 264 + 265 + /// A suggested threshold on one feature separating live from a spoof 266 + /// class, with the error rates it would incur on the collected samples. 267 + #[derive(Debug, Clone, PartialEq)] 268 + pub struct ThresholdSuggestion { 269 + pub feature: String, 270 + /// The suggested threshold value. 271 + pub threshold: f32, 272 + /// `true` if live samples are expected *above* the threshold (live 273 + /// has the higher mean), `false` if below. 274 + pub live_is_higher: bool, 275 + /// Fraction of live samples that would be wrongly rejected 276 + /// (false-reject / BPCER) at this threshold. 277 + pub live_reject_rate: f32, 278 + /// Fraction of spoof samples that would be wrongly accepted 279 + /// (false-accept / APCER) at this threshold. 280 + pub spoof_accept_rate: f32, 281 + } 282 + 283 + /// Suggest a threshold on `feature` separating [`SampleClass::Live`] from 284 + /// `spoof`, by sweeping candidate cut points between the observed values 285 + /// and picking the one that minimises total error (live-reject + 286 + /// spoof-accept) on the collected samples. 287 + /// 288 + /// Returns `None` if either class lacks samples for the feature. The 289 + /// result is a *suggestion* from the current data only — it is never 290 + /// applied automatically, and is only as good as the (possibly small, 291 + /// single-condition) sample set behind it. 292 + pub fn suggest_threshold(profile: &LivenessProfile, feature: &str, spoof: &SampleClass) -> Option<ThresholdSuggestion> { 293 + let live = feature_values(profile, &SampleClass::Live, feature); 294 + let spoof_vals = feature_values(profile, spoof, feature); 295 + if live.is_empty() || spoof_vals.is_empty() { 296 + return None; 297 + } 298 + let live_mean = live.iter().sum::<f32>() / live.len() as f32; 299 + let spoof_mean = spoof_vals.iter().sum::<f32>() / spoof_vals.len() as f32; 300 + let live_is_higher = live_mean >= spoof_mean; 301 + 302 + // Candidate thresholds: midpoints between all observed values, sorted. 303 + let mut all: Vec<f32> = live.iter().chain(spoof_vals.iter()).copied().collect(); 304 + all.sort_by(|x, y| x.partial_cmp(y).unwrap_or(std::cmp::Ordering::Equal)); 305 + let mut candidates: Vec<f32> = all.windows(2).map(|w| (w[0] + w[1]) / 2.0).collect(); 306 + if candidates.is_empty() { 307 + candidates.push(all[0]); 308 + } 309 + 310 + let error_at = |t: f32| -> (f32, f32) { 311 + // With live_is_higher, live should be >= t and spoof < t. 312 + let (live_reject, spoof_accept) = if live_is_higher { 313 + ( 314 + live.iter().filter(|&&v| v < t).count(), 315 + spoof_vals.iter().filter(|&&v| v >= t).count(), 316 + ) 317 + } else { 318 + ( 319 + live.iter().filter(|&&v| v >= t).count(), 320 + spoof_vals.iter().filter(|&&v| v < t).count(), 321 + ) 322 + }; 323 + ( 324 + live_reject as f32 / live.len() as f32, 325 + spoof_accept as f32 / spoof_vals.len() as f32, 326 + ) 327 + }; 328 + 329 + let best = candidates 330 + .iter() 331 + .map(|&t| { 332 + let (lr, sa) = error_at(t); 333 + (t, lr, sa, lr + sa) 334 + }) 335 + .min_by(|x, y| x.3.partial_cmp(&y.3).unwrap_or(std::cmp::Ordering::Equal))?; 336 + 337 + Some(ThresholdSuggestion { 338 + feature: feature.to_string(), 339 + threshold: best.0, 340 + live_is_higher, 341 + live_reject_rate: best.1, 342 + spoof_accept_rate: best.2, 343 + }) 344 + } 345 + 346 + #[cfg(test)] 347 + mod tests { 348 + use super::*; 349 + 350 + fn sample(class: SampleClass, feature: &str, value: f32) -> LivenessSample { 351 + LivenessSample { 352 + class, 353 + features: vec![(feature.to_string(), value)], 354 + captured_at_unix: 0, 355 + } 356 + } 357 + 358 + // ----- profile accumulation ---------------------------------------------- 359 + 360 + #[test] 361 + fn replace_class_swaps_only_that_class() { 362 + let mut p = LivenessProfile::new(); 363 + p.append_batch(vec![ 364 + sample(SampleClass::Live, "b", 0.9), 365 + sample(SampleClass::Screen, "b", 0.1), 366 + ]); 367 + p.replace_class(&SampleClass::Live, vec![sample(SampleClass::Live, "b", 0.95)]); 368 + let counts = p.class_counts(); 369 + // Live replaced (still 1), Screen untouched. 370 + assert_eq!(p.samples.iter().filter(|s| s.class == SampleClass::Live).count(), 1); 371 + assert_eq!(p.samples.iter().filter(|s| s.class == SampleClass::Screen).count(), 1); 372 + assert_eq!(counts.len(), 2); 373 + // The replacement sample uses feature "b" with a new value; the 374 + // old live "b" value is gone (replaced, not appended). 375 + assert_eq!(feature_values(&p, &SampleClass::Live, "b"), vec![0.95]); 376 + } 377 + 378 + #[test] 379 + fn append_accumulates() { 380 + let mut p = LivenessProfile::new(); 381 + p.append_batch(vec![sample(SampleClass::Live, "b", 0.9)]); 382 + p.append_batch(vec![sample(SampleClass::Live, "b", 0.8)]); 383 + assert_eq!(feature_values(&p, &SampleClass::Live, "b").len(), 2); 384 + } 385 + 386 + #[test] 387 + fn clear_class_removes_only_that_class() { 388 + let mut p = LivenessProfile::new(); 389 + p.append_batch(vec![ 390 + sample(SampleClass::Live, "b", 0.9), 391 + sample(SampleClass::Screen, "b", 0.1), 392 + ]); 393 + p.clear_class(&SampleClass::Screen); 394 + assert_eq!(p.class_counts(), vec![(SampleClass::Live, 1)]); 395 + } 396 + 397 + #[test] 398 + fn feature_names_dedups_in_first_seen_order() { 399 + let mut p = LivenessProfile::new(); 400 + p.append_batch(vec![LivenessSample { 401 + class: SampleClass::Live, 402 + features: vec![("brightness".into(), 1.0), ("structure".into(), 1.0)], 403 + captured_at_unix: 0, 404 + }]); 405 + assert_eq!(p.feature_names(), vec!["brightness", "structure"]); 406 + } 407 + 408 + // ----- stats + separation ------------------------------------------------- 409 + 410 + #[test] 411 + fn feature_stats_of_empty_is_none() { 412 + assert!(FeatureStats::of(&[]).is_none()); 413 + } 414 + 415 + #[test] 416 + fn feature_stats_basic() { 417 + let s = FeatureStats::of(&[1.0, 2.0, 3.0]).unwrap(); 418 + assert_eq!(s.count, 3); 419 + assert_eq!(s.min, 1.0); 420 + assert_eq!(s.max, 3.0); 421 + assert!((s.mean - 2.0).abs() < 1e-6); 422 + } 423 + 424 + #[test] 425 + fn separation_large_for_well_separated_classes() { 426 + let mut p = LivenessProfile::new(); 427 + for v in [0.90, 0.92, 0.95, 0.93] { 428 + p.append_batch(vec![sample(SampleClass::Live, "brightness", v)]); 429 + } 430 + for v in [0.10, 0.12, 0.08, 0.11] { 431 + p.append_batch(vec![sample(SampleClass::Screen, "brightness", v)]); 432 + } 433 + let sep = separation(&p, "brightness", &SampleClass::Live, &SampleClass::Screen).unwrap(); 434 + // Means ~0.925 vs ~0.10, tiny stds → large separation. 435 + assert!(sep.separation.unwrap() > 5.0, "expected strong separation, got {:?}", sep.separation); 436 + } 437 + 438 + #[test] 439 + fn separation_none_when_class_missing() { 440 + let mut p = LivenessProfile::new(); 441 + p.append_batch(vec![sample(SampleClass::Live, "b", 0.9)]); 442 + assert!(separation(&p, "b", &SampleClass::Live, &SampleClass::Screen).is_none()); 443 + } 444 + 445 + // ----- threshold suggestion ----------------------------------------------- 446 + 447 + #[test] 448 + fn suggest_threshold_separates_cleanly_separable_data() { 449 + let mut p = LivenessProfile::new(); 450 + for v in [0.90, 0.92, 0.95] { 451 + p.append_batch(vec![sample(SampleClass::Live, "brightness", v)]); 452 + } 453 + for v in [0.10, 0.12, 0.08] { 454 + p.append_batch(vec![sample(SampleClass::Screen, "brightness", v)]); 455 + } 456 + let s = suggest_threshold(&p, "brightness", &SampleClass::Screen).unwrap(); 457 + assert!(s.live_is_higher); 458 + // A cut between 0.12 and 0.90 perfectly separates → zero errors. 459 + assert!(s.live_reject_rate.abs() < 1e-6); 460 + assert!(s.spoof_accept_rate.abs() < 1e-6); 461 + assert!(s.threshold > 0.12 && s.threshold < 0.90); 462 + } 463 + 464 + #[test] 465 + fn suggest_threshold_reports_error_on_overlap() { 466 + // Overlapping distributions → some unavoidable error. 467 + let mut p = LivenessProfile::new(); 468 + for v in [0.4, 0.5, 0.6] { 469 + p.append_batch(vec![sample(SampleClass::Live, "b", v)]); 470 + } 471 + for v in [0.45, 0.55, 0.65] { 472 + p.append_batch(vec![sample(SampleClass::PrintedPhoto, "b", v)]); 473 + } 474 + let s = suggest_threshold(&p, "b", &SampleClass::PrintedPhoto).unwrap(); 475 + // Can't perfectly separate overlapping data. 476 + assert!(s.live_reject_rate + s.spoof_accept_rate > 0.0); 477 + } 478 + 479 + #[test] 480 + fn suggest_threshold_none_without_both_classes() { 481 + let mut p = LivenessProfile::new(); 482 + p.append_batch(vec![sample(SampleClass::Live, "b", 0.9)]); 483 + assert!(suggest_threshold(&p, "b", &SampleClass::Screen).is_none()); 484 + } 485 + 486 + #[test] 487 + fn suggest_handles_live_lower_than_spoof() { 488 + // A feature where live reads LOWER than spoof (e.g. roughness): 489 + // the suggester must orient correctly. 490 + let mut p = LivenessProfile::new(); 491 + for v in [0.05, 0.06, 0.07] { 492 + p.append_batch(vec![sample(SampleClass::Live, "roughness", v)]); 493 + } 494 + for v in [0.40, 0.45, 0.50] { 495 + p.append_batch(vec![sample(SampleClass::PrintedPhoto, "roughness", v)]); 496 + } 497 + let s = suggest_threshold(&p, "roughness", &SampleClass::PrintedPhoto).unwrap(); 498 + assert!(!s.live_is_higher, "live roughness is lower than spoof"); 499 + assert!(s.live_reject_rate.abs() < 1e-6 && s.spoof_accept_rate.abs() < 1e-6); 500 + } 501 + }