A facial recognition login service for Linux.
2

Configure Feed

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

cli: Add `pareidolia calibrate` command

Measures an IR camera's illuminator emission mode once and persists it,
so subsequent IR captures can size their burst from the known mode
instead of always over-capturing. Gated on `v4l2` (it captures frames)
but not `inference` — it only measures per-frame brightness, never runs
detection.

Resolves the IR camera with the same precedence as `enroll` (explicit
flag > opt-out > auto-detect) so the two can't pick different cameras,
captures a 30-frame burst, classifies the brightness sequence via
`classify_ir_mode`, and upserts an `IrCalibration` record keyed by the
camera's card+bus identity into the calibration store (alongside the
enrollment store root). Uses `replace_by_card` so re-calibrating after a
port move doesn't leave a stale-bus record behind.

Prints the detected mode (Constant, or Strobed with its period) and the
observed brightness range so the result is legible and the measurement
auditable.

Isaac Corbrey (May 29, 2026, 7:34 AM EDT) e161b502 0f1ec2a8

+198
+29
crates/cli/src/commands.rs
··· 23 23 } 24 24 } 25 25 26 + #[cfg(feature = "v4l2")] 27 + pub mod calibrate; 28 + 29 + #[cfg(not(feature = "v4l2"))] 30 + pub mod calibrate { 31 + //! Fallback when `v4l2` is disabled: calibration needs camera 32 + //! capture, so surface the build-time choice as a clean error. 33 + 34 + use std::path::Path; 35 + 36 + use clap::Args as ClapArgs; 37 + 38 + use crate::CliError; 39 + 40 + #[derive(Debug, ClapArgs)] 41 + pub struct Args { 42 + /// IR V4L2 camera device to calibrate. 43 + #[arg(long)] 44 + pub ir_camera: Option<std::path::PathBuf>, 45 + /// Refuse to auto-detect. 46 + #[arg(long, conflicts_with = "ir_camera")] 47 + pub no_ir: bool, 48 + } 49 + 50 + pub fn run(_args: Args, _store_root: Option<&Path>) -> Result<(), CliError> { 51 + Err(CliError::FeatureDisabled("v4l2")) 52 + } 53 + } 54 + 26 55 #[cfg(all(feature = "v4l2", feature = "inference"))] 27 56 pub mod enroll; 28 57
+159
crates/cli/src/commands/calibrate.rs
··· 1 + //! `pareidolia calibrate` — measure an IR camera's illuminator emission 2 + //! mode once and persist it, so subsequent enroll/test/auth captures can 3 + //! size their IR burst optimally instead of always over-capturing. 4 + //! 5 + //! This needs camera capture (`v4l2`) but not the inference models: it 6 + //! only measures per-frame brightness, never runs detection. So it's 7 + //! gated on `v4l2` alone. 8 + //! 9 + //! Flow: resolve the IR camera (same precedence as `enroll`), capture a 10 + //! burst of raw frames with warm-up disabled-equivalent (a single 11 + //! priming discard), classify the brightness sequence into an 12 + //! [`IrCaptureMode`], and upsert a calibration record keyed by the 13 + //! camera's card + bus identity. 14 + 15 + use std::path::{Path, PathBuf}; 16 + 17 + use clap::Args as ClapArgs; 18 + 19 + use pareidolia_core::camera::calibration::{CalibrationStore, CameraIdentity, IrCalibration}; 20 + use pareidolia_core::camera::select::{resolve_ir_camera, IrCameraResolution}; 21 + use pareidolia_core::camera::v4l2::{classify_ir_mode, enumerate, frame_brightness, IrCaptureMode}; 22 + use pareidolia_core::camera::{CameraInfo, CameraKind}; 23 + 24 + use crate::{pipeline_runtime, store_config, CliError}; 25 + 26 + /// Number of consecutive frames to sample when classifying the emission 27 + /// mode. Must comfortably span several strobe periods so the classifier 28 + /// sees the full lit/dark cycle; 30 (~1s at 30fps) is ample for the 29 + /// period-2 hardware seen so far and still catches longer periods. 30 + const CALIBRATION_FRAMES: u32 = 30; 31 + 32 + /// Minimum (max − min) brightness spread for a stream to count as 33 + /// strobed rather than constant. Sits well above sensor noise and well 34 + /// below the dark↔lit gap (~12 vs ~80) measured on real hardware. 35 + const CALIBRATION_MIN_CONTRAST: f32 = 20.0; 36 + 37 + #[derive(Debug, ClapArgs)] 38 + pub struct Args { 39 + /// IR V4L2 camera device to calibrate. When unset (and `--no-ir` 40 + /// isn't given) the camera is auto-detected the same way `enroll` 41 + /// does. 42 + #[arg(long)] 43 + pub ir_camera: Option<PathBuf>, 44 + 45 + /// Refuse to auto-detect; used only to make the "no IR camera" 46 + /// outcome explicit in scripts. 47 + #[arg(long, conflicts_with = "ir_camera")] 48 + pub no_ir: bool, 49 + } 50 + 51 + pub fn run(args: Args, store_root: Option<&Path>) -> Result<(), CliError> { 52 + // Resolve which IR camera to calibrate. Reuse the enroll precedence 53 + // (explicit flag > opt-out > auto-detect) so "the camera enroll 54 + // would use" and "the camera calibrate measures" can't drift apart. 55 + let cameras = enumerate(); 56 + let resolution = resolve_ir_camera(args.ir_camera.as_deref(), args.no_ir, &cameras); 57 + let ir_path = match &resolution { 58 + IrCameraResolution::Explicit(path) => path.clone(), 59 + IrCameraResolution::AutoDetected { path, info } => { 60 + eprintln!("Auto-detected IR camera at {} ({}).", path.display(), info.name); 61 + path.clone() 62 + } 63 + IrCameraResolution::OptedOut => { 64 + return Err(CliError::Config( 65 + "--no-ir given: nothing to calibrate".into(), 66 + )); 67 + } 68 + IrCameraResolution::NoHardware => { 69 + return Err(CliError::Config( 70 + "no IR camera detected to calibrate".into(), 71 + )); 72 + } 73 + IrCameraResolution::Ambiguous(candidates) => { 74 + let paths: Vec<String> = candidates 75 + .iter() 76 + .filter_map(|c| c.path.as_ref()) 77 + .map(|p| p.display().to_string()) 78 + .collect(); 79 + return Err(CliError::Config(format!( 80 + "{} IR cameras detected ({}); pass --ir-camera <PATH> to pick one", 81 + candidates.len(), 82 + paths.join(", "), 83 + ))); 84 + } 85 + }; 86 + 87 + // Find the enumerated CameraInfo for the resolved path so we can key 88 + // the record by its stable card+bus identity (the path itself isn't 89 + // a stable key — see calibration module docs). 90 + let info = cameras 91 + .iter() 92 + .find(|c| c.path.as_deref() == Some(ir_path.as_path())) 93 + .cloned() 94 + .unwrap_or_else(|| CameraInfo { 95 + name: ir_path.display().to_string(), 96 + path: Some(ir_path.clone()), 97 + kind: CameraKind::Ir, 98 + bus: None, 99 + }); 100 + 101 + eprintln!( 102 + "Calibrating {} (card {:?}, bus {:?}) — capturing {CALIBRATION_FRAMES} frames…", 103 + ir_path.display(), 104 + info.name, 105 + info.bus, 106 + ); 107 + 108 + // Capture the burst and measure per-frame brightness. 109 + let mut source = pipeline_runtime::open_camera(&ir_path, CameraKind::Ir)?; 110 + let mut brightnesses = Vec::with_capacity(CALIBRATION_FRAMES as usize); 111 + for _ in 0..CALIBRATION_FRAMES { 112 + let frame = pipeline_runtime::capture_rgb8(&mut source)?; 113 + brightnesses.push(frame_brightness(&frame.data)); 114 + } 115 + drop(source); 116 + 117 + let mode = classify_ir_mode(&brightnesses, CALIBRATION_MIN_CONTRAST); 118 + report_measurement(&brightnesses, mode); 119 + 120 + // Persist, keyed by identity, in the same directory as the 121 + // enrollment store. `replace_by_card` drops any stale-bus record for 122 + // the same physical camera (e.g. after a port move). 123 + let root = store_config::resolve_store_root(store_root)?; 124 + let store = CalibrationStore::in_dir(&root); 125 + let record = IrCalibration { 126 + identity: CameraIdentity::from_info(&info), 127 + mode, 128 + calibrated_at_unix: now_unix(), 129 + }; 130 + store.replace_by_card(record)?; 131 + 132 + eprintln!("Saved calibration to {}.", store.path().display()); 133 + Ok(()) 134 + } 135 + 136 + /// Print a human-readable summary of what was measured and decided. 137 + fn report_measurement(brightnesses: &[f32], mode: IrCaptureMode) { 138 + let max = brightnesses.iter().copied().fold(f32::MIN, f32::max); 139 + let min = brightnesses.iter().copied().fold(f32::MAX, f32::min); 140 + eprintln!(" brightness range: {min:.1}..{max:.1} (over {} frames)", brightnesses.len()); 141 + match mode { 142 + IrCaptureMode::Constant => { 143 + println!("Detected mode: Constant (every frame illuminated; burst length 1)."); 144 + } 145 + IrCaptureMode::Strobed { period } => { 146 + println!( 147 + "Detected mode: Strobed, period {period} (illuminator pulses every \ 148 + {period} frames; capture burst length {period}).", 149 + ); 150 + } 151 + } 152 + } 153 + 154 + fn now_unix() -> u64 { 155 + std::time::SystemTime::now() 156 + .duration_since(std::time::UNIX_EPOCH) 157 + .map(|d| d.as_secs()) 158 + .unwrap_or(0) 159 + }
+10
crates/cli/src/lib.rs
··· 65 65 pub enum Command { 66 66 /// List V4L2 cameras Pareidolia can see. 67 67 Cameras, 68 + /// Measure and persist an IR camera's illuminator emission mode. 69 + Calibrate(commands::calibrate::Args), 68 70 /// Enroll a user by capturing N face samples. 69 71 Enroll(commands::enroll::Args), 70 72 /// List enrolled users. ··· 95 97 96 98 match cli.command { 97 99 Command::Cameras => commands::cameras::run(), 100 + Command::Calibrate(args) => commands::calibrate::run(args, cli.store_root.as_deref()), 98 101 Command::Enroll(args) => { 99 102 commands::enroll::run(args, cli.store_root.as_deref(), cli.key_file.as_deref()) 100 103 } ··· 141 144 /// Error from the camera layer (V4L2 backend, format conversion). 142 145 #[error("camera error: {0}")] 143 146 Camera(#[from] pareidolia_core::camera::FrameError), 147 + 148 + /// Error from the calibration store (read/write of the calibration 149 + /// file). Gated on `v4l2` since the store type lives behind that 150 + /// feature. 151 + #[cfg(feature = "v4l2")] 152 + #[error("calibration store error: {0}")] 153 + Calibration(#[from] pareidolia_core::camera::calibration::CalibrationStoreError), 144 154 145 155 /// A subcommand that needs an opt-in feature was invoked in a build 146 156 /// that doesn't have it enabled.