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
13 changes: 12 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
71 changes: 71 additions & 0 deletions crates/visage-hw/src/camera.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Frame, CameraError> {
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}"))
Expand Down Expand Up @@ -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<Frame>, usize), CameraError> {
self.reassert_format()?;
let max_attempts = count * 3;
let mut good_frames = Vec::with_capacity(count);
let mut dark_count = 0usize;
Expand Down
92 changes: 89 additions & 3 deletions crates/visaged/src/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand All @@ -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<T>(result: &Result<T, EngineError>) -> bool {
matches!(
result,
Err(EngineError::NoUsableFrames) | Err(EngineError::Camera(_))
)
}

/// Result of an enrollment operation.
pub struct EnrollResult {
pub embedding: Embedding,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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");
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -371,7 +424,7 @@ fn run_verify(
);

if frames.is_empty() {
return Err(EngineError::NoFaceDetected);
return Err(EngineError::NoUsableFrames);
}

let matcher = CosineMatcher;
Expand Down Expand Up @@ -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(())));
}
}