diff --git a/CHANGELOG.md b/CHANGELOG.md index af8d3a3..bd4384f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,18 @@ A follow-up can drop the C TLS dependency entirely by building `ort` with `default-features = false, features = ["load-dynamic"]` against `pkgs.onnxruntime`. - +- **Camera capture no longer degrades over long sessions on a shared webcam** + (issue #48). `visaged` negotiated the V4L2 capture format only once, at + `Camera::open`, and never re-asserted it. On a webcam shared with other + applications (e.g. a video-conferencing app), another process could change the + device's format via `VIDIOC_S_FMT` and leave it there; `visaged` then captured + wrong-format frames it decoded as garbage through its stale format cache — + surfacing as "no face detected" until a manual `systemctl restart`. The daemon + now re-asserts its format before each capture (a cheap `VIDIOC_G_FMT`; `S_FMT` + only fires when the device actually drifted) and, as a safety net, re-opens the + camera in-process after repeated capture failures instead of requiring a + restart. The per-capture stream is retained, so the camera is still released + between verifies and remains usable by other applications. ## v0.3.3 — 2026-05-28 ### Added diff --git a/crates/visage-hw/src/camera.rs b/crates/visage-hw/src/camera.rs index c4d62a0..41cae9c 100644 --- a/crates/visage-hw/src/camera.rs +++ b/crates/visage-hw/src/camera.rs @@ -131,8 +131,78 @@ impl Camera { }) } + /// Re-assert visage's negotiated capture format on the (possibly shared) device. + /// + /// The daemon holds one persistent fd but negotiates the format only once, at + /// [`Camera::open`]. On a regular webcam shared with other applications (e.g. a + /// video-conferencing app), another process can open the same node, change the + /// streaming format via `VIDIOC_S_FMT`, and close it — leaving the device in a + /// format that no longer matches our cached `(fourcc, width, height)`. Our next + /// capture would then stream at the other app's format and hand back buffers we + /// misinterpret through the stale cache, which the detector reads as "no face" + /// until a manual restart (issue #48). + /// + /// Cheap: one `VIDIOC_G_FMT`; `VIDIOC_S_FMT` fires only when the device drifted, + /// so this is a no-op in the common, uncontended case. Runs before the + /// `MmapStream` is created (before `REQBUFS`/`STREAMON`), where `S_FMT` is legal. + fn reassert_format(&self) -> Result<(), CameraError> { + let current = self.device.format().map_err(|e| { + CameraError::CaptureFailed(format!("failed to query current format: {e}")) + })?; + + // Fast path: device is still in our negotiated format. + if current.fourcc == self.fourcc + && current.width == self.width + && current.height == self.height + { + return Ok(()); + } + + tracing::warn!( + got_fourcc = ?current.fourcc, + got_width = current.width, + got_height = current.height, + want_fourcc = ?self.fourcc, + want_width = self.width, + want_height = self.height, + "device format drifted (another application changed it); re-asserting" + ); + + let mut fmt = current; + fmt.fourcc = self.fourcc; + fmt.width = self.width; + fmt.height = self.height; + + let negotiated = self.device.set_format(&fmt).map_err(|e| { + // Another app is actively streaming (owns the device): surface as busy, + // not as a bogus format error. + if e.to_string().contains("busy") || e.to_string().contains("EBUSY") { + CameraError::DeviceBusy + } else { + CameraError::FormatNegotiationFailed(format!("failed to re-assert format: {e}")) + } + })?; + + if negotiated.fourcc != self.fourcc + || negotiated.width != self.width + || negotiated.height != self.height + { + return Err(CameraError::FormatNegotiationFailed(format!( + "re-assert negotiated {:?} {}x{}, expected {:?} {}x{}", + negotiated.fourcc, + negotiated.width, + negotiated.height, + self.fourcc, + self.width, + self.height + ))); + } + Ok(()) + } + /// Capture a single frame, converting to grayscale if needed. pub fn capture_frame(&self) -> Result { + self.reassert_format()?; let mut stream = MmapStream::with_buffers(&self.device, BufType::VideoCapture, 4).map_err(|e| { CameraError::CaptureFailed(format!("failed to create mmap stream: {e}")) @@ -197,6 +267,7 @@ impl Camera { /// Attempts up to `count * 3` raw captures to find `count` non-dark frames. /// Each non-dark frame gets CLAHE contrast enhancement applied. pub fn capture_frames(&self, count: usize) -> Result<(Vec, usize), CameraError> { + self.reassert_format()?; let max_attempts = count * 3; let mut good_frames = Vec::with_capacity(count); let mut dark_count = 0usize; diff --git a/crates/visaged/src/engine.rs b/crates/visaged/src/engine.rs index 908b4b5..2e5c098 100644 --- a/crates/visaged/src/engine.rs +++ b/crates/visaged/src/engine.rs @@ -15,6 +15,8 @@ pub enum EngineError { Recognizer(#[from] visage_core::recognizer::RecognizerError), #[error("no face detected in any captured frame")] NoFaceDetected, + #[error("no usable frames captured (camera returned only dark or unreadable frames)")] + NoUsableFrames, #[error("liveness check failed: landmark displacement {displacement:.3} px < threshold {threshold:.3} px")] LivenessCheckFailed { displacement: f32, threshold: f32 }, #[error("verification timed out")] @@ -23,6 +25,19 @@ pub enum EngineError { ChannelClosed, } +/// Consecutive "camera-broken" captures before the engine re-opens the device. +const MAX_CONSECUTIVE_CAPTURE_FAILURES: u32 = 3; + +/// True only when a result indicates the *camera* is broken — dark/unreadable +/// frames or a capture error — never an absent/unrecognised user, a verify +/// timeout, or a liveness rejection. Only these arm the self-heal re-open (#48). +fn capture_looks_broken(result: &Result) -> bool { + matches!( + result, + Err(EngineError::NoUsableFrames) | Err(EngineError::Camera(_)) + ) +} + /// Result of an enrollment operation. pub struct EnrollResult { pub embedding: Embedding, @@ -162,9 +177,15 @@ pub fn spawn_engine( std::thread::Builder::new() .name("visage-engine".into()) .spawn(move || { + // `camera` must be reassignable so the engine can re-open the device + // in-process (self-heal) rather than requiring a daemon restart (#48). + let mut camera = camera; + let device_path = camera.device_path.clone(); + let mut consecutive_failures: u32 = 0; + tracing::info!("engine thread started"); while let Some(req) = rx.blocking_recv() { - match req { + let broken = match req { EngineRequest::Enroll { frames_count, reply, @@ -176,7 +197,9 @@ pub fn spawn_engine( &mut recognizer, frames_count, ); + let broken = capture_looks_broken(&result); let _ = reply.send(result); + broken } EngineRequest::Verify { gallery, @@ -200,8 +223,38 @@ pub fn spawn_engine( liveness_enabled, liveness_min_displacement, ); + let broken = capture_looks_broken(&result); let _ = reply.send(result); + broken + } + }; + + // --- Self-heal: re-open the camera after repeated broken captures --- + // This replicates what a manual `systemctl restart` does — re-run + // `Camera::open` (fresh fd + `S_FMT`) — catching any residual desync + // that per-capture format re-assertion alone does not reset. + if broken { + consecutive_failures += 1; + if consecutive_failures >= MAX_CONSECUTIVE_CAPTURE_FAILURES { + tracing::warn!( + consecutive_failures, + "repeated camera-broken captures — re-initializing camera (self-heal)" + ); + match Camera::open(&device_path) { + Ok(fresh) => { + camera = fresh; + consecutive_failures = 0; + tracing::info!(device = %device_path, "camera re-opened after failures"); + } + Err(e) => { + // Keep the old handle and retry on the next failure; + // never let the engine thread die. + tracing::error!(error = %e, "camera re-open failed; will retry"); + } + } } + } else { + consecutive_failures = 0; } } tracing::info!("engine thread exiting"); @@ -255,7 +308,7 @@ fn run_enroll( ); if frames.is_empty() { - return Err(EngineError::NoFaceDetected); + return Err(EngineError::NoUsableFrames); } let mut embeddings: Vec<(Embedding, f32)> = Vec::new(); @@ -371,7 +424,7 @@ fn run_verify( ); if frames.is_empty() { - return Err(EngineError::NoFaceDetected); + return Err(EngineError::NoUsableFrames); } let matcher = CosineMatcher; @@ -450,3 +503,36 @@ fn run_verify( best_quality, }) } + +#[cfg(test)] +mod tests { + use super::*; + + /// The self-heal re-open must arm ONLY on camera-broken outcomes — never on a + /// genuine no-face / unknown-user, a verify timeout, a liveness rejection, or a + /// success. Guards the false-positive property in CI (no hardware needed). + #[test] + fn self_heal_only_arms_on_camera_broken() { + // Camera-broken → arm. + assert!(capture_looks_broken::<()>(&Err( + EngineError::NoUsableFrames + ))); + assert!(capture_looks_broken::<()>(&Err(EngineError::Camera( + visage_hw::CameraError::DeviceBusy + )))); + // Everything else → do NOT re-open. + assert!(!capture_looks_broken::<()>(&Err( + EngineError::NoFaceDetected + ))); + assert!(!capture_looks_broken::<()>(&Err( + EngineError::VerifyTimeout + ))); + assert!(!capture_looks_broken::<()>(&Err( + EngineError::LivenessCheckFailed { + displacement: 0.0, + threshold: 1.0, + } + ))); + assert!(!capture_looks_broken::<()>(&Ok(()))); + } +}