Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ jobs:
run: cargo build --release

- name: Run unit tests
run: cargo test
run: cargo test --lib

- name: Clippy
run: cargo clippy -- -D warnings
Expand Down
11 changes: 9 additions & 2 deletions src/bin/camera_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,10 @@ fn main() {
}
};

eprintln!("[camera-test] Resolution: {}x{}", camera.width, camera.height);
eprintln!(
"[camera-test] Resolution: {}x{}",
camera.width, camera.height
);
eprintln!("[camera-test] Capturing frame (3 warm-up frames)...");

let frame: image::GrayImage = match camera.capture_warmed_frame(3) {
Expand All @@ -32,7 +35,11 @@ fn main() {
}
};

eprintln!("[camera-test] Frame captured: {}x{}", frame.width(), frame.height());
eprintln!(
"[camera-test] Frame captured: {}x{}",
frame.width(),
frame.height()
);

if let Err(e) = frame.save(&output_path) {
eprintln!("[camera-test] ERROR — failed to save PNG: {e}");
Expand Down
48 changes: 29 additions & 19 deletions src/bin/enroll.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
/// - /etc/rustface/faces/ must exist with root:root 700 permissions
/// - /etc/rustface/models/seeta_fd_frontal_v1.0.bin
/// - /etc/rustface/models/arcface.onnx

use std::path::PathBuf;

use pam_rustface::{
Expand All @@ -31,15 +30,13 @@ fn main() {
std::process::exit(1);
}

let username = std::env::args()
.nth(1)
.unwrap_or_else(|| {
// Fall back to SUDO_USER env var
std::env::var("SUDO_USER").unwrap_or_else(|_| {
eprintln!("[enroll] ERROR: specify a username: sudo rustface-enroll <username>");
std::process::exit(1);
})
});
let username = std::env::args().nth(1).unwrap_or_else(|| {
// Fall back to SUDO_USER env var
std::env::var("SUDO_USER").unwrap_or_else(|_| {
eprintln!("[enroll] ERROR: specify a username: sudo rustface-enroll <username>");
std::process::exit(1);
})
});

let model_dir = std::env::args()
.nth(2)
Expand All @@ -66,18 +63,25 @@ fn main() {

// Load detector and embedder
eprintln!("[enroll] Loading models...");
let mut detector = FaceDetector::load(face_model_path.to_str().unwrap())
.unwrap_or_else(|e| { eprintln!("[enroll] ERROR loading detector: {e}"); std::process::exit(1); });
let mut detector = FaceDetector::load(face_model_path.to_str().unwrap()).unwrap_or_else(|e| {
eprintln!("[enroll] ERROR loading detector: {e}");
std::process::exit(1);
});

let mut embedder = FaceEmbedder::load(embed_model_path.to_str().unwrap())
.unwrap_or_else(|e| { eprintln!("[enroll] ERROR loading embedder: {e}"); std::process::exit(1); });
let mut embedder = FaceEmbedder::load(embed_model_path.to_str().unwrap()).unwrap_or_else(|e| {
eprintln!("[enroll] ERROR loading embedder: {e}");
std::process::exit(1);
});

// Open camera — auto-detect IR camera if not specified
let device = std::env::args().nth(3)
let device = std::env::args()
.nth(3)
.unwrap_or_else(|| pam_rustface::camera::auto_detect_ir_camera());
eprintln!("[enroll] Camera: {device}");
let camera = IrCamera::open(&device)
.unwrap_or_else(|e| { eprintln!("[enroll] ERROR opening camera: {e}"); std::process::exit(1); });
let camera = IrCamera::open(&device).unwrap_or_else(|e| {
eprintln!("[enroll] ERROR opening camera: {e}");
std::process::exit(1);
});

eprintln!("[enroll] Camera ready: {}x{}", camera.width, camera.height);
eprintln!("[enroll] Look at the camera... ({DETECT_RETRIES} attempts)");
Expand All @@ -89,7 +93,10 @@ fn main() {

let frame = match camera.capture_warmed_frame(WARMUP_FRAMES) {
Ok(f) => f,
Err(e) => { eprintln!("[enroll] Frame error: {e}"); continue; }
Err(e) => {
eprintln!("[enroll] Frame error: {e}");
continue;
}
};

let Some(region) = detector.detect(&frame) else {
Expand All @@ -107,7 +114,10 @@ fn main() {
eprintln!("[enroll] Embedding extracted — dim: {}", emb.dim());
break 'detect emb;
}
Err(e) => { eprintln!("[enroll] Embedding error: {e}"); continue; }
Err(e) => {
eprintln!("[enroll] Embedding error: {e}");
continue;
}
}
}

Expand Down
40 changes: 25 additions & 15 deletions src/bin/sim_test.rs
Original file line number Diff line number Diff line change
@@ -1,43 +1,49 @@
use pam_rustface::{
camera::IrCamera,
face::{cosine_similarity, FaceDetector, FaceEmbedder},
storage::FaceStore,
};
/// rustface-sim-test — show live cosine similarity score against enrolled embedding
///
/// Usage: sudo rustface-sim-test [username]
///
/// Useful for tuning the threshold and verifying that face recognition works.

use std::path::PathBuf;
use pam_rustface::{
camera::IrCamera,
face::{cosine_similarity, FaceDetector, FaceEmbedder},
storage::FaceStore,
};

const THRESHOLD: f32 = 0.8;

fn main() {
let username = std::env::args().nth(1).unwrap_or_else(|| "pervane".to_owned());
let username = std::env::args()
.nth(1)
.unwrap_or_else(|| "pervane".to_owned());
let model_dir = "/etc/rustface/models";

let face_model = PathBuf::from(model_dir).join("seeta_fd_frontal_v1.0.bin");
let embed_model = PathBuf::from(model_dir).join("arcface.onnx");

eprintln!("[sim-test] Loading models...");
let mut detector = FaceDetector::load(face_model.to_str().unwrap())
.expect("Failed to load face detector");
let mut embedder = FaceEmbedder::load(embed_model.to_str().unwrap())
.expect("Failed to load embedder");
let mut detector =
FaceDetector::load(face_model.to_str().unwrap()).expect("Failed to load face detector");
let mut embedder =
FaceEmbedder::load(embed_model.to_str().unwrap()).expect("Failed to load embedder");

eprintln!("[sim-test] Loading enrolled embedding for '{username}'...");
let store = FaceStore::default();
let stored = store.load(&username).expect("No enrolled face found — run rustface-enroll first");
let stored = store
.load(&username)
.expect("No enrolled face found — run rustface-enroll first");

let device = std::env::args().nth(2)
let device = std::env::args()
.nth(2)
.unwrap_or_else(|| pam_rustface::camera::auto_detect_ir_camera());
eprintln!("[sim-test] Camera: {device}");
let camera = IrCamera::open(&device).expect("Failed to open camera");

eprintln!("[sim-test] Look at the camera...");
for attempt in 1..=5 {
let frame = camera.capture_warmed_frame(3).expect("Failed to capture frame");
let frame = camera
.capture_warmed_frame(3)
.expect("Failed to capture frame");
let Some(region) = detector.detect(&frame) else {
eprintln!("[sim-test] Attempt {attempt}/5: no face detected");
continue;
Expand All @@ -46,7 +52,11 @@ fn main() {
let sim = cosine_similarity(&live, &stored);
println!(
"[sim-test] Attempt {attempt}/5: similarity = {sim:.4} (threshold={THRESHOLD} → {})",
if sim >= THRESHOLD { "PASS ✓" } else { "REJECT ✗" }
if sim >= THRESHOLD {
"PASS ✓"
} else {
"REJECT ✗"
}
);
}
}
23 changes: 13 additions & 10 deletions src/camera/capture.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,12 @@ pub fn auto_detect_ir_camera() -> String {
if let Ok(dev) = Device::with_path(path) {
if let Ok(fmt) = dev.format() {
if is_grayscale8(&fmt.fourcc) {
log::info!("Auto-detected IR camera: {path} ({:?} {}x{})", fmt.fourcc, fmt.width, fmt.height);
log::info!(
"Auto-detected IR camera: {path} ({:?} {}x{})",
fmt.fourcc,
fmt.width,
fmt.height
);
return path.clone();
}
}
Expand Down Expand Up @@ -84,13 +89,13 @@ impl IrCamera {
///
/// Stream is opened and closed per call — sufficient for PAM auth flow.
pub fn capture_frame(&self) -> Result<GrayImage, CameraError> {
let mut stream =
Stream::with_buffers(&self.device, Type::VideoCapture, 4).map_err(|e| {
CameraError::StreamStart(e.to_string())
})?;
let mut stream = Stream::with_buffers(&self.device, Type::VideoCapture, 4)
.map_err(|e| CameraError::StreamStart(e.to_string()))?;

// Skip the first frame — camera needs a warm-up; second frame is cleaner
let _ = stream.next().map_err(|e| CameraError::FrameCapture(e.to_string()))?;
let _ = stream
.next()
.map_err(|e| CameraError::FrameCapture(e.to_string()))?;
let (buf, _meta) = stream
.next()
.map_err(|e| CameraError::FrameCapture(e.to_string()))?;
Expand All @@ -102,10 +107,8 @@ impl IrCamera {
/// Capture the specified number of frames, return the last one.
/// Skips `skip` frames for camera warm-up.
pub fn capture_warmed_frame(&self, skip: u32) -> Result<GrayImage, CameraError> {
let mut stream =
Stream::with_buffers(&self.device, Type::VideoCapture, 4).map_err(|e| {
CameraError::StreamStart(e.to_string())
})?;
let mut stream = Stream::with_buffers(&self.device, Type::VideoCapture, 4)
.map_err(|e| CameraError::StreamStart(e.to_string()))?;

let total = skip + 1;
let mut last_buf: Option<Vec<u8>> = None;
Expand Down
2 changes: 1 addition & 1 deletion src/error.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use thiserror::Error;
use crate::camera::CameraError;
use thiserror::Error;

#[derive(Debug, Error)]
pub enum RustfaceError {
Expand Down
19 changes: 11 additions & 8 deletions src/face/detect.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,14 @@ impl FaceDetector {
/// Load from a SeetaFace model file
/// Model path: /etc/rustface/models/seeta_fd_frontal_v1.0.bin
pub fn load(model_path: &str) -> Result<Self, RustfaceError> {
let mut detector = create_detector(model_path).map_err(|e| {
RustfaceError::OnnxError(format!("Failed to load rustface model: {e}"))
})?;
let mut detector = create_detector(model_path)
.map_err(|e| RustfaceError::OnnxError(format!("Failed to load rustface model: {e}")))?;

// Settings tuned for IR camera input
detector.set_min_face_size(40); // minimum face size in px (smaller = more sensitive)
detector.set_score_thresh(1.5); // confidence threshold (lower for IR)
detector.set_pyramid_scale_factor(0.8); // pyramid scale step
detector.set_slide_window_step(4, 4); // sliding window step
detector.set_min_face_size(40); // minimum face size in px (smaller = more sensitive)
detector.set_score_thresh(1.5); // confidence threshold (lower for IR)
detector.set_pyramid_scale_factor(0.8); // pyramid scale step
detector.set_slide_window_step(4, 4); // sliding window step

Ok(Self { inner: detector })
}
Expand All @@ -47,7 +46,11 @@ impl FaceDetector {
// Select the highest-scoring face
faces
.into_iter()
.max_by(|a, b| a.score().partial_cmp(&b.score()).unwrap_or(std::cmp::Ordering::Equal))
.max_by(|a, b| {
a.score()
.partial_cmp(&b.score())
.unwrap_or(std::cmp::Ordering::Equal)
})
.map(|face| {
let bbox = face.bbox();
// bbox.x/y may be negative near camera edges — clamp to zero
Expand Down
9 changes: 6 additions & 3 deletions src/face/embed.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,10 @@ impl FaceEmbedder {

log::debug!("FaceEmbedder loaded — device: {device:?}, input: {input_name}");

Ok(Self { session, input_name })
Ok(Self {
session,
input_name,
})
}

/// Crop the face region from the image, normalize, and extract the embedding
Expand All @@ -61,8 +64,8 @@ impl FaceEmbedder {
region: &FaceRegion,
) -> Result<Embedding, RustfaceError> {
// 1. Crop face region
let crop = imageops::crop_imm(image, region.x, region.y, region.width, region.height)
.to_image();
let crop =
imageops::crop_imm(image, region.x, region.y, region.width, region.height).to_image();

// 2. Resize to 112x112
let resized = imageops::resize(&crop, FACE_SIZE, FACE_SIZE, imageops::FilterType::Lanczos3);
Expand Down
Loading
Loading