A facial recognition login service for Linux.
2

Configure Feed

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

core: Add micro-motion liveness check (universal)

The first real liveness signal: scores involuntary frame-to-frame change
across the capture burst. A live subject always moves a little over the
~100-200ms a burst spans (head sway, micro-expression, blink); a printed
photo or a screen held up is rigid, differing only by sensor noise. So
mean per-pixel change is a model-free liveness signal — and it works on
*any* IR sensor, which is why it's the first check.

# Same-illumination comparison

The one subtlety is strobed sensors: their burst alternates lit/dark
frames, so diffing consecutive raw frames would read the strobe as huge
bogus "motion". The check therefore compares only same-illumination-phase
frames — those within `phase_band` of the lit frame's brightness. On a
constant sensor every frame qualifies (the grouping collapses to "use
all"), which is what makes the check universal. A test pins that the
strobe's dark frame is excluded.

# Applicability

Needs >= 2 same-phase frames; with fewer it returns `NotApplicable`, not
a bogus score. A minimal period-2 matching burst (one lit frame) is
therefore NotApplicable — liveness-aware burst sizing (next commit) is
what gives it enough same-phase frames. The additive model means this
just contributes nothing until then, never breaks anything.

# Scoring

Normalised mean-absolute per-pixel difference between consecutive
same-phase frames, saturating to 1.0 at `MicroMotionConfig::saturation`.
The constants are first-cut estimates to be tuned from real
live-vs-spoof captures (guided calibration), which is why liveness stays
observe-only (threshold 0.0) until then.

Proptested: total + bounded over arbitrary frames, and monotonic in
motion (more per-pixel change never lowers the score). Also records the
planned complementary checks (embedding-agreement, texture, differencing)
in the module docs.

Isaac Corbrey (May 29, 2026, 7:34 AM EDT) 49f85f3e 651deff5

+348
+7
crates/core/proptest-regressions/liveness/micro_motion.txt
··· 1 + # Seeds for failure cases proptest has generated in the past. It is 2 + # automatically read and these particular cases re-run before any 3 + # novel cases are generated. 4 + # 5 + # It is recommended to check this file in to source control so that 6 + # everyone who runs the test benefits from these saved cases. 7 + cc 28ffddb190502045b7499f3a156a0d610183b6e15a95292a7233793b8af69584 # shrinks to d1 = 16, extra = 9
+28
crates/core/src/liveness.rs crates/core/src/liveness/mod.rs
··· 59 59 //! project's security docs. The value of this framework is that such a 60 60 //! model drops in later as just another [`LivenessCheck`] with no change 61 61 //! to the policy or callers. 62 + //! 63 + //! # Checks 64 + //! 65 + //! - [`micro_motion::MicroMotionCheck`] — universal (any IR sensor): 66 + //! scores involuntary frame-to-frame change across same-illumination 67 + //! frames. A live face is never perfectly still; a rigid photo is. 68 + //! 69 + //! # Planned checks (not yet built) 70 + //! 71 + //! - **Embedding agreement across the burst** — embed each lit frame and 72 + //! score on inter-embedding cosine similarity. *Complements* 73 + //! micro-motion: micro-motion wants small *variation* (alive), this 74 + //! wants stable *identity* (one coherent real face, not noise or a 75 + //! glitching capture). Together: a live face shows small pose 76 + //! variation but stable identity; noise shows variation with unstable 77 + //! identity; a photo shows neither. Costs extra ArcFace embeds per 78 + //! auth, so it's a separate, opt-in check; also doubles as a 79 + //! matching-robustness signal (reject captures whose frames disagree 80 + //! on identity). Universal (works on constant and strobed sensors). 81 + //! - **IR texture / reflectance** — single-frame skin-vs-print/screen 82 + //! heuristic. Universal. 83 + //! - **Active-illumination differencing** — `lit − dark` structure on a 84 + //! strobed sensor's pair. Strobed-only; the strongest model-free 85 + //! signal, purely additive. 62 86 63 87 use crate::camera::Frame; 88 + 89 + pub mod micro_motion; 90 + 91 + pub use micro_motion::MicroMotionCheck; 64 92 65 93 /// A single liveness signal's verdict on a set of captured frames. 66 94 #[derive(Debug, Clone, Copy, PartialEq)]
+313
crates/core/src/liveness/micro_motion.rs
··· 1 + //! Micro-motion liveness check: scores involuntary movement across the 2 + //! burst. 3 + //! 4 + //! A live subject is never perfectly still over the ~100–200ms a capture 5 + //! burst spans — there is always involuntary head sway, micro-expression, 6 + //! or a blink. A printed photo or a phone/laptop screen held up to the 7 + //! camera is, by comparison, rigid: consecutive frames differ only by 8 + //! sensor noise. So the mean per-pixel change between frames is a 9 + //! model-free liveness signal that works on **any** IR sensor. 10 + //! 11 + //! # Same-illumination comparison 12 + //! 13 + //! The one subtlety is strobed sensors. Their burst alternates lit/dark 14 + //! frames, and consecutive raw frames differ enormously due to the 15 + //! *illuminator*, not motion — diffing across the strobe would read as 16 + //! huge "motion" on every capture and measure nothing useful. So this 17 + //! check compares only frames of the **same illumination phase**: those 18 + //! whose brightness is close to the chosen lit frame's. On a 19 + //! constant-brightness sensor every frame qualifies, so all frames are 20 + //! compared. This is why the check is universal: the phase-grouping 21 + //! collapses to "use everything" when there's no strobe. 22 + //! 23 + //! # Applicability 24 + //! 25 + //! Needs at least two same-phase frames to measure a difference; with 26 + //! fewer it returns [`CheckOutcome::NotApplicable`] rather than a bogus 27 + //! score. (A minimally-sized matching burst — e.g. period-2 strobe, 28 + //! burst of 2 — has only one lit frame; liveness-aware capture sizing is 29 + //! what gives this check enough same-phase frames to run. See the CLI's 30 + //! burst sizing.) 31 + //! 32 + //! # Scoring 33 + //! 34 + //! Score is the mean absolute per-pixel difference between consecutive 35 + //! same-phase frames, normalised against [`MicroMotionConfig::saturation`] 36 + //! and clamped to `0.0..=1.0`. Zero motion (a rigid spoof at the noise 37 + //! floor) → near 0.0; natural live motion → toward 1.0 once it reaches 38 + //! the saturation point. 39 + //! 40 + //! These constants are first-cut estimates. The intent is to tune them 41 + //! from real live-vs-spoof captures (see the guided calibration flow), 42 + //! not to trust them blindly — which is exactly why liveness defaults to 43 + //! observe-only (threshold 0.0) until profiled. 44 + 45 + use crate::camera::Frame; 46 + use crate::liveness::{CheckOutcome, LivenessCheck, LivenessInput}; 47 + 48 + /// Tunables for [`MicroMotionCheck`]. 49 + #[derive(Debug, Clone, Copy)] 50 + pub struct MicroMotionConfig { 51 + /// A frame counts as "same illumination phase" as the lit frame when 52 + /// its mean brightness is within this many levels (0–255) of the lit 53 + /// frame's. Wide enough to absorb natural lighting jitter, narrow 54 + /// enough to exclude a strobe's dark frames (which sit ~25+ below). 55 + pub phase_band: f32, 56 + /// Mean-absolute-difference value at which the motion score 57 + /// saturates to 1.0. Differences at or above this are "definitely 58 + /// moving"; below scale linearly. In raw 0–255 units. 59 + pub saturation: f32, 60 + } 61 + 62 + impl Default for MicroMotionConfig { 63 + fn default() -> Self { 64 + Self { 65 + // Comfortably below the ~25+ lit/dark strobe gap so dark 66 + // frames are excluded, well above per-frame lighting noise. 67 + phase_band: 12.0, 68 + // A few levels of mean per-pixel change is plenty of motion 69 + // for a live subject; a rigid photo sits near the sub-1.0 70 + // sensor noise floor. First-cut; tune from profiling. 71 + saturation: 4.0, 72 + } 73 + } 74 + } 75 + 76 + /// Universal micro-motion liveness check. See module docs. 77 + #[derive(Debug, Clone, Copy, Default)] 78 + pub struct MicroMotionCheck { 79 + config: MicroMotionConfig, 80 + } 81 + 82 + impl MicroMotionCheck { 83 + /// Construct with explicit config. 84 + pub fn new(config: MicroMotionConfig) -> Self { 85 + Self { config } 86 + } 87 + } 88 + 89 + impl LivenessCheck for MicroMotionCheck { 90 + fn name(&self) -> &'static str { 91 + "micro_motion" 92 + } 93 + 94 + fn evaluate(&self, input: &LivenessInput<'_>) -> CheckOutcome { 95 + let Some(lit) = input.lit() else { 96 + // Malformed capture (lit_index out of range): nothing to 97 + // measure against, so abstain rather than score. 98 + return CheckOutcome::NotApplicable; 99 + }; 100 + let lit_brightness = mean(&lit.data); 101 + 102 + // Gather frames of the same illumination phase as the lit frame. 103 + // On a constant sensor this is every frame; on a strobed one it 104 + // excludes the dark frames (which sit a band below). 105 + let same_phase: Vec<&Frame> = input 106 + .burst 107 + .iter() 108 + .filter(|f| (mean(&f.data) - lit_brightness).abs() <= self.config.phase_band) 109 + .collect(); 110 + 111 + if same_phase.len() < 2 { 112 + return CheckOutcome::NotApplicable; 113 + } 114 + 115 + // Mean absolute per-pixel difference between consecutive 116 + // same-phase frames, averaged over all consecutive pairs. 117 + // Frames of differing length (shouldn't happen within one stream, 118 + // but be defensive) compare over their overlap. 119 + let mut total = 0.0_f64; 120 + let mut pairs = 0u32; 121 + for w in same_phase.windows(2) { 122 + if let Some(d) = mean_abs_diff(&w[0].data, &w[1].data) { 123 + total += d as f64; 124 + pairs += 1; 125 + } 126 + } 127 + if pairs == 0 { 128 + return CheckOutcome::NotApplicable; 129 + } 130 + let motion = (total / pairs as f64) as f32; 131 + let score = (motion / self.config.saturation).clamp(0.0, 1.0); 132 + CheckOutcome::applicable(score) 133 + } 134 + } 135 + 136 + /// Mean of a byte buffer (0–255). Empty → 0.0. 137 + fn mean(data: &[u8]) -> f32 { 138 + if data.is_empty() { 139 + return 0.0; 140 + } 141 + let sum: u64 = data.iter().map(|&b| b as u64).sum(); 142 + sum as f32 / data.len() as f32 143 + } 144 + 145 + /// Mean absolute difference between two byte buffers over their common 146 + /// length. `None` if either is empty (nothing to compare). 147 + fn mean_abs_diff(a: &[u8], b: &[u8]) -> Option<f32> { 148 + let n = a.len().min(b.len()); 149 + if n == 0 { 150 + return None; 151 + } 152 + let sum: u64 = a 153 + .iter() 154 + .zip(b.iter()) 155 + .take(n) 156 + .map(|(&x, &y)| x.abs_diff(y) as u64) 157 + .sum(); 158 + Some(sum as f32 / n as f32) 159 + } 160 + 161 + #[cfg(test)] 162 + mod tests { 163 + use super::*; 164 + use crate::camera::{CameraKind, PixelFormat}; 165 + use proptest::prelude::*; 166 + use std::time::Instant; 167 + 168 + /// Gray8 frame whose mean is ≈ `brightness`, with `motion_delta` 169 + /// added to even pixels and subtracted from odd pixels — injecting 170 + /// controllable per-pixel variation (motion) while *preserving the 171 + /// mean*, so motion doesn't accidentally shift the frame out of the 172 + /// illumination-phase band. This models real micro-motion, which 173 + /// rearranges pixels without changing overall brightness. 174 + fn frame(brightness: u8, motion_delta: u8, len: usize) -> Frame { 175 + let data: Vec<u8> = (0..len) 176 + .map(|i| { 177 + if i % 2 == 0 { 178 + brightness.saturating_add(motion_delta) 179 + } else { 180 + brightness.saturating_sub(motion_delta) 181 + } 182 + }) 183 + .collect(); 184 + Frame { 185 + width: 1, 186 + height: len as u32, 187 + format: PixelFormat::Gray8, 188 + data, 189 + captured_at: Instant::now(), 190 + kind: CameraKind::Ir, 191 + } 192 + } 193 + 194 + fn input_all_lit<'a>(burst: &'a [Frame]) -> LivenessInput<'a> { 195 + LivenessInput { 196 + burst, 197 + lit_index: 0, 198 + dark_index: None, 199 + } 200 + } 201 + 202 + #[test] 203 + fn single_frame_is_not_applicable() { 204 + let burst = [frame(80, 0, 16)]; 205 + assert_eq!( 206 + MicroMotionCheck::default().evaluate(&input_all_lit(&burst)), 207 + CheckOutcome::NotApplicable, 208 + ); 209 + } 210 + 211 + #[test] 212 + fn rigid_identical_frames_score_zero() { 213 + // Two identical frames: zero motion → score 0.0 (a photo). 214 + let burst = [frame(80, 0, 64), frame(80, 0, 64)]; 215 + match MicroMotionCheck::default().evaluate(&input_all_lit(&burst)) { 216 + CheckOutcome::Applicable(s) => assert!(s.abs() < 1e-6, "expected ~0, got {s}"), 217 + other => panic!("expected Applicable(0), got {other:?}"), 218 + } 219 + } 220 + 221 + #[test] 222 + fn moving_frames_score_above_zero() { 223 + // Frames that differ pixel-to-pixel → nonzero motion. 224 + let a = frame(80, 0, 64); 225 + let b = frame(80, 8, 64); // every other pixel shifted by 8 226 + let burst = [a, b]; 227 + match MicroMotionCheck::default().evaluate(&input_all_lit(&burst)) { 228 + CheckOutcome::Applicable(s) => assert!(s > 0.0, "expected motion, got {s}"), 229 + other => panic!("expected Applicable, got {other:?}"), 230 + } 231 + } 232 + 233 + #[test] 234 + fn strobe_dark_frames_are_excluded_from_comparison() { 235 + // Burst: lit(80), dark(11), lit(80'). Only the two lit frames are 236 + // same-phase; the dark frame must not be diffed against them 237 + // (which would read as enormous bogus "motion"). The two lit 238 + // frames here are identical → score ~0, proving the dark frame 239 + // was excluded. 240 + let lit_a = frame(80, 0, 64); 241 + let dark = frame(11, 0, 64); 242 + let lit_b = frame(80, 0, 64); 243 + let burst = [lit_a, dark, lit_b]; 244 + let input = LivenessInput { 245 + burst: &burst, 246 + lit_index: 0, 247 + dark_index: Some(1), 248 + }; 249 + match MicroMotionCheck::default().evaluate(&input) { 250 + CheckOutcome::Applicable(s) => assert!( 251 + s.abs() < 1e-6, 252 + "dark frame leaked into the diff: score {s} should be ~0", 253 + ), 254 + other => panic!("expected Applicable(~0), got {other:?}"), 255 + } 256 + } 257 + 258 + #[test] 259 + fn only_one_lit_frame_among_strobe_is_not_applicable() { 260 + // Minimal period-2 burst: one lit, one dark. Only one same-phase 261 + // frame → can't measure motion → NotApplicable. 262 + let lit = frame(80, 0, 64); 263 + let dark = frame(11, 0, 64); 264 + let burst = [lit, dark]; 265 + let input = LivenessInput { 266 + burst: &burst, 267 + lit_index: 0, 268 + dark_index: Some(1), 269 + }; 270 + assert_eq!( 271 + MicroMotionCheck::default().evaluate(&input), 272 + CheckOutcome::NotApplicable, 273 + ); 274 + } 275 + 276 + proptest! { 277 + /// Never panics, and any score is a valid confidence in [0,1]. 278 + #[test] 279 + fn micro_motion_total_and_bounded( 280 + brights in proptest::collection::vec(0u8..=255, 0..=8), 281 + len in 1usize..=64, 282 + ) { 283 + let burst: Vec<Frame> = brights.iter().map(|&b| frame(b, 0, len)).collect(); 284 + let input = LivenessInput { 285 + burst: &burst, 286 + lit_index: 0, 287 + dark_index: None, 288 + }; 289 + match MicroMotionCheck::default().evaluate(&input) { 290 + CheckOutcome::Applicable(s) => prop_assert!((0.0..=1.0).contains(&s)), 291 + CheckOutcome::NotApplicable => {} 292 + } 293 + } 294 + 295 + /// More per-pixel change never yields a lower score (monotonic in 296 + /// motion), holding everything else fixed. 297 + #[test] 298 + fn more_motion_never_scores_lower( 299 + d1 in 0u8..=20, 300 + extra in 0u8..=20, 301 + ) { 302 + let d2 = d1.saturating_add(extra); 303 + let score = |delta: u8| { 304 + let burst = [frame(80, 0, 64), frame(80, delta, 64)]; 305 + match MicroMotionCheck::default().evaluate(&input_all_lit(&burst)) { 306 + CheckOutcome::Applicable(s) => s, 307 + CheckOutcome::NotApplicable => -1.0, 308 + } 309 + }; 310 + prop_assert!(score(d2) >= score(d1) - 1e-6); 311 + } 312 + } 313 + }