diff --git a/models/fetch.sh b/models/fetch.sh index 2e0eaee..36aa68c 100755 --- a/models/fetch.sh +++ b/models/fetch.sh @@ -1,10 +1,27 @@ #!/usr/bin/env bash # Downloads the tier-1 presence face detector (UltraFace-RFB-320, ~1.2MB, MIT-licensed) # from the upstream Ultra-Light-Fast-Generic-Face-Detector-1MB repo. +# +# Pinned to a commit (the last one that touched this file, as of writing) rather +# than `master` — a mutable branch ref means a compromised/force-pushed upstream +# repo could silently substitute a different model, which crustd then loads and +# executes as an ONNX computation graph with no other integrity check anywhere in +# the chain. The sha256 check below is the actual guard; the pinned commit just +# keeps re-runs of this script reproducible. +# +# To update: bump MODEL_COMMIT to the new commit that changed the model, download +# once, verify the new file is what you expect, then update MODEL_SHA256 to match. set -euo pipefail cd "$(dirname "$0")" + +MODEL_COMMIT="0f9ca4a9fc80170fd505168fd1132b837141f7df" +MODEL_SHA256="34cd7e60aeff28744c657de7a3dc64e872d506741de66987f3426f2b79f88017" + curl -fsSL -o ultraface-rfb-320.onnx \ - "https://raw.githubusercontent.com/Linzaer/Ultra-Light-Fast-Generic-Face-Detector-1MB/master/models/onnx/version-RFB-320.onnx" + "https://raw.githubusercontent.com/Linzaer/Ultra-Light-Fast-Generic-Face-Detector-1MB/${MODEL_COMMIT}/models/onnx/version-RFB-320.onnx" + +echo "${MODEL_SHA256} ultraface-rfb-320.onnx" | sha256sum -c - + mkdir -p ~/.local/share/crustd/models cp ultraface-rfb-320.onnx ~/.local/share/crustd/models/ultraface-rfb-320.onnx echo "installed to ~/.local/share/crustd/models/ultraface-rfb-320.onnx" diff --git a/packaging/systemd/crustd.service b/packaging/systemd/crustd.service index 1c2292e..48b3a81 100644 --- a/packaging/systemd/crustd.service +++ b/packaging/systemd/crustd.service @@ -1,6 +1,7 @@ [Unit] Description=crustd - tiered presence detection for Bread After=graphical-session.target +PartOf=graphical-session.target [Service] Type=simple @@ -13,7 +14,26 @@ PrivateTmp=true ProtectSystem=strict ReadWritePaths=%h/.local/share/crustd %h/.config/crustd NoNewPrivileges=true -SupplementaryGroups=video render +SupplementaryGroups=video +DevicePolicy=closed +DeviceAllow=char-video4linux rw +PrivateNetwork=true +RestrictAddressFamilies=AF_UNIX +ProtectKernelTunables=true +ProtectKernelModules=true +ProtectControlGroups=true +RestrictNamespaces=true +RestrictSUIDSGID=true +LockPersonality=true +SystemCallFilter=@system-service +SystemCallArchitectures=native +UMask=0077 +# ProtectProc is deliberately omitted here rather than enabled unverified: it +# restricts visibility into *other* processes' /proc/ entries (not /proc/acpi +# itself, which src/lid.rs reads), so it likely doesn't conflict with lid detection +# — but that's an inference, not a test against real hardware/kernel behavior. Worth +# enabling (e.g. ProtectProc=invisible) once someone can confirm `bread lid` still +# works with it on target hardware. [Install] -WantedBy=default.target +WantedBy=graphical-session.target diff --git a/src/bread.rs b/src/bread.rs index 28aea4c..234a795 100644 --- a/src/bread.rs +++ b/src/bread.rs @@ -1,11 +1,23 @@ use std::path::PathBuf; +use std::time::Duration; use anyhow::{Context, Result}; use serde_json::json; -use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; use tokio::net::UnixStream; use tracing::warn; +/// Upper bound on connect + write + read for a single emit. Without this, a bread +/// daemon that accepts the connection but then hangs (deadlocked module, blocked on +/// its own IPC) would stall the presence loop forever, since none of these calls +/// have a timeout of their own. +const IPC_TIMEOUT: Duration = Duration::from_secs(3); + +/// Caps how much a single response line can grow before giving up — the peer is +/// local and trusted, but there's no reason to let a misbehaving daemon that never +/// sends a newline grow this buffer without bound. +const MAX_RESPONSE_BYTES: u64 = 4096; + /// Thin client for breadd's line-delimited JSON-RPC IPC socket. Connects fresh for /// each emit rather than holding a persistent connection — presence changes are rare /// (debounced, seconds-to-minutes apart), so there's no reason to keep a socket open @@ -30,39 +42,59 @@ impl BreadClient { /// Emits `.` with the given JSON payload. Errors are logged /// and swallowed by the caller's choice — a missing/unreachable bread daemon - /// shouldn't crash the presence loop. + /// shouldn't crash the presence loop. Bounded by `IPC_TIMEOUT` end-to-end so a + /// daemon that accepts the connection but never responds can't hang the caller. pub async fn emit(&self, suffix: &str, data: serde_json::Value) -> Result<()> { let event = format!("{}.{}", self.event_prefix, suffix); - let mut stream = UnixStream::connect(&self.socket_path) - .await - .with_context(|| format!("connecting to bread socket at {:?}", self.socket_path))?; - let request = json!({ "id": "crustd", "method": "emit", "params": { "event": event, "data": data }, }); let line = format!("{}\n", serde_json::to_string(&request)?); + + let response = tokio::time::timeout(IPC_TIMEOUT, self.send_and_wait(&line)) + .await + .context("bread emit timed out")??; + + if !response_confirms_emitted(&response) { + warn!(response = %response.trim(), "bread did not confirm emit"); + } + + Ok(()) + } + + async fn send_and_wait(&self, line: &str) -> Result { + let mut stream = UnixStream::connect(&self.socket_path) + .await + .with_context(|| format!("connecting to bread socket at {:?}", self.socket_path))?; + stream .write_all(line.as_bytes()) .await .context("writing emit request")?; - let mut reader = BufReader::new(stream); + let mut reader = BufReader::new(stream.take(MAX_RESPONSE_BYTES)); let mut response = String::new(); reader .read_line(&mut response) .await .context("reading emit response")?; - if !response.contains("\"emitted\":true") { - warn!(response = %response.trim(), "bread did not confirm emit"); - } - - Ok(()) + Ok(response) } } +/// Parses the emit response as JSON and checks `emitted == true`, rather than doing +/// a raw substring match — a differently-formatted (but still correct) response, +/// e.g. with extra whitespace or reordered fields, shouldn't be misread as a failure. +fn response_confirms_emitted(response: &str) -> bool { + serde_json::from_str::(response) + .ok() + .and_then(|v| v.get("emitted").and_then(|e| e.as_bool())) + .unwrap_or(false) +} + fn default_socket_path() -> PathBuf { let runtime_dir = std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/tmp".to_string()); PathBuf::from(runtime_dir).join("bread").join("breadd.sock") diff --git a/src/camera.rs b/src/camera.rs index d2af957..fab8e82 100644 --- a/src/camera.rs +++ b/src/camera.rs @@ -1,4 +1,5 @@ use std::path::{Path, PathBuf}; +use std::time::Duration; use anyhow::{Context, Result}; use v4l::buffer::Type; @@ -7,6 +8,13 @@ use v4l::io::traits::CaptureStream; use v4l::video::Capture; use v4l::FourCC; +/// Upper bound on how long a single `stream.next()` call may block waiting for a +/// frame. Without this, a wedged or contended device (another process holding the +/// sensor, a stalled UVC controller) blocks the calling thread forever — since +/// captures run inside `spawn_blocking`, that leaks a blocking-pool thread and can +/// stall the whole presence loop indefinitely. +const CAPTURE_TIMEOUT: Duration = Duration::from_millis(2000); + /// A single grayscale frame: raw luma bytes plus the dimensions they came from. pub struct GrayFrame { pub width: u32, @@ -93,19 +101,52 @@ pub fn grab_rgb_luma(device: &str, width: u32, height: u32) -> Result let mut dev = v4l::Device::with_path(device) .with_context(|| format!("opening rgb camera {device}"))?; + let requested_fourcc = FourCC::new(b"YUYV"); let mut fmt = Capture::format(&dev).context("reading current rgb format")?; fmt.width = width; fmt.height = height; - fmt.fourcc = FourCC::new(b"YUYV"); + fmt.fourcc = requested_fourcc; Capture::set_format(&dev, &fmt).context("setting rgb format")?; let fmt = Capture::format(&dev).context("reading confirmed rgb format")?; + // set_format is best-effort: the driver may silently substitute the closest + // format/resolution it actually supports. Treating a substituted format as if + // it were YUYV at the requested size would corrupt every downstream pixel + // computation (or, for a resolution change, blow the motion-blob threshold out + // of proportion) with nothing ever logging why. Fail loudly instead. + if fmt.fourcc != requested_fourcc || fmt.width != width || fmt.height != height { + anyhow::bail!( + "rgb camera {device} does not support {width}x{height} {requested_fourcc}; \ + driver reported {}x{} {} instead", + fmt.width, + fmt.height, + fmt.fourcc + ); + } + let mut stream = MmapStream::with_buffers(&mut dev, Type::VideoCapture, 2) .context("starting rgb capture stream")?; + stream.set_timeout(CAPTURE_TIMEOUT); // First frame after (re)starting a stream is often stale/partially exposed; take the second. let _ = stream.next().context("priming rgb stream")?; - let (buf, _meta) = stream.next().context("capturing rgb frame")?; + let (buf, meta) = stream.next().context("capturing rgb frame")?; + + // The mmap buffer is sized for the driver's worst-case allocation, not the + // actual frame — only `bytesused` bytes at its start are real pixel data for + // this frame. Using the whole buffer would treat leftover memory from a prior + // (possibly larger) allocation as image content. + let expected = (fmt.width * fmt.height * 2) as usize; + let bytesused = meta.bytesused as usize; + if bytesused < expected { + anyhow::bail!( + "rgb frame from {device} truncated: got {bytesused} bytes, expected {expected} \ + for {}x{} YUYV", + fmt.width, + fmt.height + ); + } + let buf = &buf[..expected]; let mut luma = Vec::with_capacity((fmt.width * fmt.height) as usize); for chunk in buf.chunks_exact(2) { @@ -124,24 +165,56 @@ pub fn grab_rgb_luma(device: &str, width: u32, height: u32) -> Result /// The device is opened once for the whole burst and dropped immediately after — /// callers must not hold the IR camera open outside of this function, since that's /// what keeps the IR illuminator from flashing continuously. -pub fn grab_ir_burst(device: &str, count: u32) -> Result> { +pub fn grab_ir_burst(device: &str, width: u32, height: u32, count: u32) -> Result> { let mut dev = v4l::Device::with_path(device).with_context(|| format!("opening ir camera {device}"))?; - let fmt = Capture::format(&dev).context("reading ir format")?; + // V4L2 format is persistent device state — without explicitly setting it here, + // this capture would silently inherit whatever pixel format/resolution the + // previous consumer (another app, or a prior crustd run) last configured on the + // node, and every downstream byte would be misinterpreted with no error raised. + let requested_fourcc = FourCC::new(b"GREY"); + let mut fmt = Capture::format(&dev).context("reading current ir format")?; + fmt.width = width; + fmt.height = height; + fmt.fourcc = requested_fourcc; + Capture::set_format(&dev, &fmt).context("setting ir format")?; + let fmt = Capture::format(&dev).context("reading confirmed ir format")?; + + if fmt.fourcc != requested_fourcc || fmt.width != width || fmt.height != height { + anyhow::bail!( + "ir camera {device} does not support {width}x{height} {requested_fourcc}; \ + driver reported {}x{} {} instead", + fmt.width, + fmt.height, + fmt.fourcc + ); + } + let mut stream = MmapStream::with_buffers(&mut dev, Type::VideoCapture, 2) .context("starting ir capture stream")?; + stream.set_timeout(CAPTURE_TIMEOUT); // Discard the first frame post-open the same way as the RGB path. let _ = stream.next().context("priming ir stream")?; + let expected = (fmt.width * fmt.height) as usize; let mut frames = Vec::with_capacity(count as usize); for _ in 0..count { - let (buf, _meta) = stream.next().context("capturing ir frame")?; + let (buf, meta) = stream.next().context("capturing ir frame")?; + let bytesused = meta.bytesused as usize; + if bytesused < expected { + anyhow::bail!( + "ir frame from {device} truncated: got {bytesused} bytes, expected {expected} \ + for {}x{} GREY", + fmt.width, + fmt.height + ); + } frames.push(GrayFrame { width: fmt.width, height: fmt.height, - luma: buf.to_vec(), + luma: buf[..expected].to_vec(), }); } diff --git a/src/config.rs b/src/config.rs index 9de2498..8e95ccf 100644 --- a/src/config.rs +++ b/src/config.rs @@ -65,6 +65,11 @@ pub struct MotionConfig { pub diff_threshold: u8, /// Minimum count of changed pixels (out of 320x180) to call it motion. pub blob_min_pixels: usize, + /// Minimum time between IR acquisition bursts while Absent and RGB motion is + /// present but no face has been confirmed yet. Without a floor here, sustained + /// non-face motion (a TV, a pet, trees through a window) would retrigger a burst + /// on every `active_poll_ms` tick indefinitely. + pub ir_burst_cooldown_ms: u64, } impl Default for MotionConfig { @@ -79,6 +84,7 @@ impl Default for MotionConfig { input_idle_timeout_ms: 20_000, diff_threshold: 24, blob_min_pixels: 200, + ir_burst_cooldown_ms: 3_000, } } } @@ -155,11 +161,61 @@ impl Default for Config { impl Config { pub fn load(path: &PathBuf) -> Result { - if !path.exists() { - return Ok(Self::default()); + let cfg = if !path.exists() { + Self::default() + } else { + let raw = std::fs::read_to_string(path)?; + toml::from_str(&raw)? + }; + cfg.validate()?; + Ok(cfg) + } + + /// Rejects config values that would otherwise produce a permanent hot-spin poll + /// loop or a presence state that can never change (e.g. a zero poll interval, or + /// a confidence floor of 0.0 that makes every frame "a face"). Called once after + /// load so a bad config fails fast with a clear message instead of the daemon + /// running in a silently broken mode. + pub fn validate(&self) -> Result<()> { + const MIN_POLL_MS: u64 = 50; + + let polls = [ + ("motion.idle_poll_ms", self.motion.idle_poll_ms), + ("motion.active_poll_ms", self.motion.active_poll_ms), + ("motion.max_idle_poll_ms", self.motion.max_idle_poll_ms), + ("motion.present_poll_ms", self.motion.present_poll_ms), + ("motion.present_stillness_timeout_ms", self.motion.present_stillness_timeout_ms), + ("motion.present_retry_after_miss_ms", self.motion.present_retry_after_miss_ms), + ("motion.ir_burst_cooldown_ms", self.motion.ir_burst_cooldown_ms), + ]; + for (name, value) in polls { + if value < MIN_POLL_MS { + anyhow::bail!("{name} must be at least {MIN_POLL_MS}ms, got {value}ms"); + } } - let raw = std::fs::read_to_string(path)?; - Ok(toml::from_str(&raw)?) + + if self.presence.ir_burst_frames == 0 { + anyhow::bail!("presence.ir_burst_frames must be at least 1"); + } + + if !(self.presence.min_confidence > 0.0 && self.presence.min_confidence <= 1.0) { + anyhow::bail!( + "presence.min_confidence must be in (0.0, 1.0], got {}", + self.presence.min_confidence + ); + } + + if self.motion.present_retry_after_miss_ms > self.motion.present_stillness_timeout_ms { + anyhow::bail!( + "motion.present_retry_after_miss_ms ({}) must not exceed \ + motion.present_stillness_timeout_ms ({}) — the retry cadence is meant \ + to be faster than the initial stillness timeout, not slower", + self.motion.present_retry_after_miss_ms, + self.motion.present_stillness_timeout_ms + ); + } + + Ok(()) } pub fn default_path() -> PathBuf { diff --git a/src/idle.rs b/src/idle.rs index 1ec3dd4..ade8a6f 100644 --- a/src/idle.rs +++ b/src/idle.rs @@ -17,6 +17,7 @@ use wayland_protocols::ext::idle_notify::v1::client::ext_idle_notifier_v1::ExtId /// wayland-client event loop is blocking. pub struct IdleWatcher { idle: Arc, + alive: Arc, } impl IdleWatcher { @@ -25,27 +26,40 @@ impl IdleWatcher { /// signal" and fall back to the camera-only behavior rather than erroring out. pub fn spawn(timeout_ms: u32) -> Option { let idle = Arc::new(AtomicBool::new(false)); + let alive = Arc::new(AtomicBool::new(true)); let idle_for_thread = Arc::clone(&idle); + let alive_for_thread = Arc::clone(&alive); let (ready_tx, ready_rx) = mpsc::channel(); std::thread::Builder::new() .name("crustd-idle-notify".into()) - .spawn(move || run(timeout_ms, idle_for_thread, ready_tx)) + .spawn(move || run(timeout_ms, idle_for_thread, alive_for_thread, ready_tx)) .ok()?; - ready_rx.recv().ok()?.then_some(Self { idle }) + ready_rx.recv().ok()?.then_some(Self { idle, alive }) } + /// Whether the compositor last reported the seat as idle. Only meaningful while + /// [`Self::is_alive`] is `true` — once the watcher thread has exited, this value + /// is frozen at whatever it last was and must not be trusted either way. pub fn is_idle(&self) -> bool { self.idle.load(Ordering::Relaxed) } + + /// `false` once the compositor connection has dropped and the background thread + /// has exited. Callers must check this before trusting `is_idle() == false` as + /// proof of active input — otherwise a dead watcher whose last known state was + /// "not idle" permanently short-circuits the camera-based retention checks. + pub fn is_alive(&self) -> bool { + self.alive.load(Ordering::Relaxed) + } } struct State { idle: Arc, } -fn run(timeout_ms: u32, idle: Arc, ready_tx: mpsc::Sender) { +fn run(timeout_ms: u32, idle: Arc, alive: Arc, ready_tx: mpsc::Sender) { let Ok(conn) = Connection::connect_to_env() else { let _ = ready_tx.send(false); return; @@ -72,8 +86,9 @@ fn run(timeout_ms: u32, idle: Arc, ready_tx: mpsc::Sender) { loop { if queue.blocking_dispatch(&mut state).is_err() { - // Compositor connection dropped; stick with the last known idle state - // rather than spinning a busy loop. + // Compositor connection dropped. Mark the watcher dead so callers stop + // trusting the frozen idle flag, rather than spinning a busy loop. + alive.store(false, Ordering::Relaxed); break; } } diff --git a/src/main.rs b/src/main.rs index 6e1df77..b61c3fb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -20,6 +20,12 @@ use motion::MotionGate; use presence::{PresenceState, PresenceTracker}; use vision::{Backend, FaceDetector}; +/// IR frames are captured at the face detector's native input size — nearest- +/// neighbor resizing from anything larger only throws away detail, and the sensor +/// doesn't need to match the RGB motion-gate resolution. +const IR_CAPTURE_WIDTH: u32 = 320; +const IR_CAPTURE_HEIGHT: u32 = 240; + #[tokio::main] async fn main() -> Result<()> { tracing_subscriber::fmt() @@ -67,10 +73,17 @@ async fn main() -> Result<()> { // While present, RGB motion (typing, mouse, posture shifts) — or, when available, // the compositor directly reporting active input — is the retention signal that - // resets this clock. Only once things have been still for a while do we spend an - // IR burst to double-check someone's actually still there. + // resets this clock. It's also reset after every retention IR check (positive or + // negative) so the check cadence is measured from "time since we last looked", + // not "time since the room was last active" — otherwise, once stillness crosses + // the threshold once, it stays crossed and every following tick re-fires the check. let mut last_rgb_motion_at = Instant::now(); + // Rate-limits IR bursts while Absent: without this, sustained non-face RGB + // motion (a TV, a pet, trees through a window) would open the IR camera and run + // inference on every single active-poll tick, forever. + let mut last_ir_burst_at: Option = None; + info!( rgb_device, ir_device, @@ -103,7 +116,11 @@ async fn main() -> Result<()> { if presence.state() == PresenceState::Present { if let Some(watcher) = &idle_watcher { - if !watcher.is_idle() { + // Only trust "not idle" as proof of activity while the watcher is + // still alive — a dead watcher's idle flag is frozen at its last + // value and must not be allowed to permanently disable the + // camera-based retention checks below. + if watcher.is_alive() && !watcher.is_idle() { // Compositor confirms the seat has active input right now — that's // sufficient proof of presence on its own. No camera opened at all. last_rgb_motion_at = Instant::now(); @@ -115,9 +132,15 @@ async fn main() -> Result<()> { // Both states share the same cheap RGB capture from here — IR is never // touched just to decide whether to look. let rgb_device_owned = rgb_device.clone(); - let capture = tokio::task::spawn_blocking(move || camera::grab_rgb_luma(&rgb_device_owned, 320, 180)) - .await - .context("rgb capture task panicked"); + let capture_task = + tokio::task::spawn_blocking(move || camera::grab_rgb_luma(&rgb_device_owned, 320, 180)); + let capture = tokio::select! { + _ = &mut shutdown => { + info!("shutting down"); + break; + } + res = capture_task => res.context("rgb capture task panicked"), + }; let frame = match capture { Ok(Ok(frame)) => frame, @@ -157,13 +180,30 @@ async fn main() -> Result<()> { continue; } - match run_ir_check(&ir_device, &cfg.presence, &mut detector).await { + let ir_result = tokio::select! { + _ = &mut shutdown => { + info!("shutting down"); + break; + } + res = run_ir_check(&ir_device, &cfg.presence, &mut detector) => res, + }; + + // Reset the reference clock now that a check has actually happened, + // regardless of outcome — otherwise `elapsed()` stays past `due_after` on + // every following tick and the cadence collapses to the poll interval + // instead of the slow/fast retry cadence above. + last_rgb_motion_at = Instant::now(); + + match ir_result { Ok(result) => { let face_seen = result.max_confidence >= cfg.presence.min_confidence; - if face_seen { - last_rgb_motion_at = Instant::now(); + tokio::select! { + _ = &mut shutdown => { + info!("shutting down"); + break; + } + _ = handle_observation(&mut presence, &bread, face_seen) => {} } - handle_observation(&mut presence, &bread, face_seen).await; } Err(err) => warn!(error = %err, "ir retention check failed"), } @@ -176,15 +216,39 @@ async fn main() -> Result<()> { // as someone arriving. IR camera stays closed and dark the rest of the time. if motion.is_motion { idle_interval = cfg.motion.active_poll_ms; - match run_ir_check(&ir_device, &cfg.presence, &mut detector).await { - Ok(result) => { - let face_seen = result.max_confidence >= cfg.presence.min_confidence; - if face_seen { - last_rgb_motion_at = Instant::now(); + + // Sustained non-face motion (a TV, a pet, trees through a window) would + // otherwise re-trigger a burst on every active-poll tick forever — cap + // how often a burst can actually fire while nothing is confirming a face. + let burst_due = last_ir_burst_at + .map(|at| at.elapsed() >= Duration::from_millis(cfg.motion.ir_burst_cooldown_ms)) + .unwrap_or(true); + + if burst_due { + last_ir_burst_at = Some(Instant::now()); + let ir_result = tokio::select! { + _ = &mut shutdown => { + info!("shutting down"); + break; } - handle_observation(&mut presence, &bread, face_seen).await; + res = run_ir_check(&ir_device, &cfg.presence, &mut detector) => res, + }; + match ir_result { + Ok(result) => { + let face_seen = result.max_confidence >= cfg.presence.min_confidence; + if face_seen { + last_rgb_motion_at = Instant::now(); + } + tokio::select! { + _ = &mut shutdown => { + info!("shutting down"); + break; + } + _ = handle_observation(&mut presence, &bread, face_seen) => {} + } + } + Err(err) => warn!(error = %err, "ir burst check failed"), } - Err(err) => warn!(error = %err, "ir burst check failed"), } } else { idle_interval = (idle_interval * 3 / 2).min(cfg.motion.max_idle_poll_ms); @@ -196,7 +260,6 @@ async fn main() -> Result<()> { struct IrCheckResult { max_confidence: f32, - max_mean_luma: f32, } /// Opens the IR camera for exactly one short burst, runs the tier-1 detector over @@ -205,26 +268,21 @@ async fn run_ir_check(ir_device: &str, cfg: &PresenceConfig, detector: &mut Face let device = ir_device.to_string(); let burst_frames = cfg.ir_burst_frames; - let frames = tokio::task::spawn_blocking(move || camera::grab_ir_burst(&device, burst_frames)) - .await - .context("ir capture task panicked")??; + let frames = tokio::task::spawn_blocking(move || { + camera::grab_ir_burst(&device, IR_CAPTURE_WIDTH, IR_CAPTURE_HEIGHT, burst_frames) + }) + .await + .context("ir capture task panicked")??; let mut max_confidence = 0.0f32; - let mut max_mean_luma = 0.0f32; for frame in &frames { let confidence = detector.max_face_confidence(frame)?; max_confidence = max_confidence.max(confidence); - max_mean_luma = max_mean_luma.max(frame.mean_luma()); } - tracing::debug!( - max_confidence, - max_mean_luma, - threshold = cfg.min_confidence, - "ir burst result" - ); + tracing::debug!(max_confidence, threshold = cfg.min_confidence, "ir burst result"); - Ok(IrCheckResult { max_confidence, max_mean_luma }) + Ok(IrCheckResult { max_confidence }) } async fn handle_observation(presence: &mut PresenceTracker, bread: &BreadClient, positive: bool) { diff --git a/src/vision.rs b/src/vision.rs index ea13697..ff33982 100644 --- a/src/vision.rs +++ b/src/vision.rs @@ -66,13 +66,28 @@ impl FaceDetector { .run(ort::inputs!["input" => input]) .map_err(|e| anyhow::anyhow!("running face detector session: {e}"))?; - let scores = outputs["scores"] + // Indexing outputs by name (`outputs["scores"]`) panics if the loaded model + // doesn't have an output by that name — a real possibility since the model + // path is user/config-controlled. Look it up fallibly instead so a + // mismatched model produces a normal error the caller can log and recover + // from, rather than aborting the whole daemon. + let scores_value = outputs + .get("scores") + .ok_or_else(|| anyhow::anyhow!("face detector model has no \"scores\" output"))?; + + let (shape, data) = scores_value .try_extract_tensor::() .map_err(|e| anyhow::anyhow!("extracting scores output: {e}"))?; - // scores is [1, N, 2]: column 0 = background prob, column 1 = face prob. + // scores is expected to be [1, N, 2]: column 0 = background prob, column 1 = + // face prob. Verify the layout before treating pair[1] as the face score — + // a differently-shaped export (e.g. channel-first [1, 2, N]) would otherwise + // silently read the wrong values with no error. + if shape.len() != 3 || shape[0] != 1 || shape[2] != 2 { + anyhow::bail!("face detector model output has unexpected shape {shape:?}, expected [1, N, 2]"); + } + // The exported graph already applies softmax, so these are usable directly. - let (_shape, data) = scores; let max_face_prob = data .chunks_exact(2) .map(|pair| pair[1])