A facial recognition login service for Linux.
2

Configure Feed

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

cli: Add --debug-dump-dir for capture diagnostics

Diagnosing camera or face-detection failures from a hardware-only
trace ('confidence 0.231' or 'no face detected') is painful because
the actual frame data has already been thrown away by the time the
user sees the error. There's no way to tell whether the camera
captured a black frame, a wrong exposure, a frame of someone else's
face, or a perfectly fine frame that SCRFD just couldn't handle.

When `pareidolia enroll --debug-dump-dir <PATH>` is set, every
captured (post-conversion, pre-detection) frame is written to that
directory as a binary PPM file before the detector runs. The dump
fires regardless of detection outcome, so failed captures still
leave the frame on disk for inspection. Files are named
`rgb-{i}.ppm` and `ir-{i}.ppm` with 1-based sample indices.

PPM (P6) was chosen instead of PNG because the goal is debug
output, not distribution: no compression, no extra dependencies,
universal viewer support (feh, eog, ImageMagick, GIMP), and a hex
dump of the file is still meaningful if a viewer isn't handy. The
format is a three-line text header followed by raw RGB bytes.

# Pipeline runtime split

To dump the frame *before* face detection runs (so failed
detections still leave a frame), `pipeline_runtime::capture_one_face`
is decomposed into:

- `capture_rgb8(source)` — capture + RGB8 conversion only
- `embed_best_face(frame, detector, embedder)` — detect, align,
embed, pick highest-confidence face

`capture_one_face` itself remains as a thin wrapper composing the
two, so the `test` subcommand and any future single-shot callers
don't need to change. Enrollment switches to the two-step form so
the optional dump can sit between capture and detection.

# Scope

Behaviour is unchanged when the flag is absent — no extra disk
writes, no extra log lines, no change to enrollment storage. The
flag is documented as a debugging tool, not a production knob; the
daemon path (M5) won't expose it.

Isaac Corbrey (May 27, 2026, 11:52 PM EDT) ef93e5bf 5b2317e2

+83 -16
+32 -3
crates/cli/src/commands/enroll.rs
··· 63 63 /// Override the default 500ms pause between captures. 64 64 #[arg(long, default_value_t = 500)] 65 65 pub capture_interval_ms: u64, 66 + 67 + /// If set, write every captured (post-conversion, pre-detection) 68 + /// frame to this directory as a PPM file. Frames are written 69 + /// regardless of whether SCRFD later finds a face in them, so this 70 + /// is the right tool for diagnosing "no face detected" failures. 71 + /// Files are named `rgb-{i}.ppm` / `ir-{i}.ppm` where `i` is the 72 + /// 1-based sample index. The directory is created if it doesn't 73 + /// already exist. 74 + #[arg(long)] 75 + pub debug_dump_dir: Option<PathBuf>, 66 76 } 67 77 68 78 pub fn run(args: Args, store_root: Option<&Path>, key_file: Option<&Path>) -> Result<(), CliError> { ··· 127 137 .max() 128 138 .map(|m| m + 1) 129 139 .unwrap_or(1); 140 + 141 + // Create the dump directory eagerly so any error surfaces before 142 + // we've spent a few seconds capturing frames the user expected to 143 + // be saved. 144 + if let Some(dir) = &args.debug_dump_dir { 145 + std::fs::create_dir_all(dir)?; 146 + eprintln!("Debug dump enabled: writing frames to {}", dir.display()); 147 + } 130 148 131 149 let interval = Duration::from_millis(args.capture_interval_ms); 132 150 let mut new_rgb = 0u32; ··· 154 172 // RGB capture is mandatory: --rgb-camera always has a value 155 173 // (default /dev/video0) so we always produce an RGB sample. 156 174 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)?; 175 + let rgb_frame = pipeline_runtime::capture_rgb8(&mut rgb_source)?; 176 + if let Some(dir) = &args.debug_dump_dir { 177 + let path = dir.join(format!("rgb-{}.ppm", i + 1)); 178 + pipeline_runtime::write_frame_as_ppm(&path, &rgb_frame)?; 179 + eprintln!(" RGB: dumped frame to {}", path.display()); 180 + } 181 + let rgb_face = pipeline_runtime::embed_best_face(&rgb_frame, &mut detector, &mut embedder)?; 159 182 eprintln!( 160 183 " RGB: confidence {:.3}, embedding dim {}", 161 184 rgb_face.detection.confidence, ··· 181 204 thread::sleep(sibling_open_cooldown); 182 205 183 206 let mut ir_source = pipeline_runtime::open_camera(ir_path, CameraKind::Ir)?; 207 + let ir_frame = pipeline_runtime::capture_rgb8(&mut ir_source)?; 208 + if let Some(dir) = &args.debug_dump_dir { 209 + let path = dir.join(format!("ir-{}.ppm", i + 1)); 210 + pipeline_runtime::write_frame_as_ppm(&path, &ir_frame)?; 211 + eprintln!(" IR : dumped frame to {}", path.display()); 212 + } 184 213 let ir_face = 185 - pipeline_runtime::capture_one_face(&mut ir_source, &mut detector, &mut embedder)?; 214 + pipeline_runtime::embed_best_face(&ir_frame, &mut detector, &mut embedder)?; 186 215 eprintln!( 187 216 " IR : confidence {:.3}, embedding dim {}", 188 217 ir_face.detection.confidence,
+51 -13
crates/cli/src/pipeline_runtime.rs
··· 10 10 //! Feature-gated: only built when both `v4l2` and `inference` are 11 11 //! enabled, since both functions touch hardware and ort. 12 12 13 - use std::io::Read; 13 + use std::fs; 14 + use std::io::{Read, Write}; 14 15 use std::path::{Path, PathBuf}; 15 16 16 17 use pareidolia_core::camera::convert::to_rgb8; 17 18 use pareidolia_core::camera::v4l2::{V4l2Config, V4l2FrameSource}; 18 - use pareidolia_core::camera::{CameraKind, FrameSource}; 19 + use pareidolia_core::camera::{CameraKind, Frame, FrameSource}; 19 20 use pareidolia_core::enrollment::ModelFingerprint; 20 21 use pareidolia_core::pipeline::arcface::ArcfaceEmbedder; 21 22 use pareidolia_core::pipeline::recognise::{recognise, FaceResult}; ··· 93 94 ))) 94 95 } 95 96 96 - /// Capture one frame from `source`, run the full detect → align → embed 97 - /// pipeline, and return the highest-confidence face. 97 + /// Capture one frame from `source` and convert it to RGB8. 98 98 /// 99 - /// Returns [`CliError::NoFaceDetected`] if the detector found no face in 100 - /// the captured frame — distinguished from other pipeline errors so the 101 - /// user-visible message can be helpful ("hold still, look at the 102 - /// camera") rather than the generic pipeline-error wording. 103 - pub fn capture_one_face( 104 - source: &mut V4l2FrameSource, 99 + /// Split out from [`embed_best_face`] so callers that want to dump the 100 + /// raw frame to disk for debugging (regardless of whether detection 101 + /// later succeeds) can do so between these two steps. 102 + pub fn capture_rgb8(source: &mut V4l2FrameSource) -> Result<Frame, CliError> { 103 + let raw = source.capture().map_err(CliError::Camera)?; 104 + to_rgb8(&raw).map_err(CliError::Camera) 105 + } 106 + 107 + /// Run the full detect → align → embed pipeline on `frame` and return 108 + /// the highest-confidence face. 109 + /// 110 + /// Returns [`CliError::NoFaceDetected`] if the detector found no face — 111 + /// distinguished from other pipeline errors so the user-visible message 112 + /// can be helpful ("hold still, look at the camera") rather than the 113 + /// generic pipeline-error wording. 114 + pub fn embed_best_face( 115 + frame: &Frame, 105 116 detector: &mut ScrfdDetector, 106 117 embedder: &mut ArcfaceEmbedder, 107 118 ) -> Result<FaceResult, CliError> { 108 - let raw = source.capture().map_err(CliError::Camera)?; 109 - let frame = to_rgb8(&raw).map_err(CliError::Camera)?; 110 - let results = recognise(&frame, detector, embedder)?; 119 + let results = recognise(frame, detector, embedder)?; 111 120 results 112 121 .into_iter() 113 122 .max_by(|a, b| { ··· 117 126 .unwrap_or(std::cmp::Ordering::Equal) 118 127 }) 119 128 .ok_or(CliError::NoFaceDetected) 129 + } 130 + 131 + /// Convenience wrapper: capture + embed in one call. Kept for callers 132 + /// that don't need to inspect the intermediate frame (currently just 133 + /// `test`). 134 + pub fn capture_one_face( 135 + source: &mut V4l2FrameSource, 136 + detector: &mut ScrfdDetector, 137 + embedder: &mut ArcfaceEmbedder, 138 + ) -> Result<FaceResult, CliError> { 139 + let frame = capture_rgb8(source)?; 140 + embed_best_face(&frame, detector, embedder) 141 + } 142 + 143 + /// Write `frame` to `path` as a binary PPM (P6) file. 144 + /// 145 + /// PPM is the simplest "just dump RGB bytes after a tiny text header" 146 + /// image format; every image viewer worth using (feh, eog, ImageMagick, 147 + /// GIMP) opens it without fuss. We use it instead of PNG here because 148 + /// the goal is *debug output*: no compression, no dependencies, and a 149 + /// hex dump of the file is still meaningful if a viewer isn't handy. 150 + /// 151 + /// Assumes `frame` is RGB8. The caller is responsible for converting 152 + /// non-RGB frames first — [`capture_rgb8`] always returns RGB8. 153 + pub fn write_frame_as_ppm(path: &Path, frame: &Frame) -> Result<(), CliError> { 154 + let mut f = fs::File::create(path)?; 155 + write!(f, "P6\n{} {}\n255\n", frame.width, frame.height)?; 156 + f.write_all(&frame.data)?; 157 + Ok(()) 120 158 } 121 159 122 160 /// SHA-256 of a model file's bytes. Stored alongside each enrolled