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(())));
+ }
+}