init commit
This commit is contained in:
commit
0423d83311
16 changed files with 2581 additions and 0 deletions
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
/target
|
||||
/models/*.onnx
|
||||
*.onnx
|
||||
1437
Cargo.lock
generated
Normal file
1437
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
30
Cargo.toml
Normal file
30
Cargo.toml
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
[package]
|
||||
name = "crustd"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Tiered, power-efficient presence detection daemon for Bread"
|
||||
license = "MIT"
|
||||
|
||||
[[bin]]
|
||||
name = "crustd"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "net", "time", "macros", "signal", "sync", "io-util"] }
|
||||
v4l = "0.14"
|
||||
ort = { version = "2.0.0-rc.12", default-features = false, features = ["std", "download-binaries", "tls-native", "copy-dylibs"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
toml = "0.8"
|
||||
anyhow = "1"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
directories = "5"
|
||||
wayland-client = "0.31"
|
||||
wayland-protocols = { version = "0.32", features = ["client", "staging"] }
|
||||
|
||||
[profile.release]
|
||||
opt-level = 2
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
strip = true
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2026 Breadway Contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
17
README.md
Normal file
17
README.md
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
# crustd
|
||||
|
||||
Tiered, power-efficient presence detection daemon for [Bread](https://github.com/Breadway/bread).
|
||||
|
||||
## Building
|
||||
|
||||
```bash
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
## Running
|
||||
|
||||
```bash
|
||||
./target/release/crustd
|
||||
```
|
||||
|
||||
See `packaging/` for distribution packaging.
|
||||
10
models/fetch.sh
Executable file
10
models/fetch.sh
Executable file
|
|
@ -0,0 +1,10 @@
|
|||
#!/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.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
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"
|
||||
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"
|
||||
19
packaging/systemd/crustd.service
Normal file
19
packaging/systemd/crustd.service
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
[Unit]
|
||||
Description=crustd - tiered presence detection for Bread
|
||||
After=graphical-session.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=%h/.local/bin/crustd
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
# Power/hardening: no network, no writes outside its own state dir, camera access only.
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ReadWritePaths=%h/.local/share/crustd %h/.config/crustd
|
||||
NoNewPrivileges=true
|
||||
SupplementaryGroups=video render
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
69
src/bread.rs
Normal file
69
src/bread.rs
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde_json::json;
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::UnixStream;
|
||||
use tracing::warn;
|
||||
|
||||
/// 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
|
||||
/// between them.
|
||||
pub struct BreadClient {
|
||||
socket_path: PathBuf,
|
||||
event_prefix: String,
|
||||
}
|
||||
|
||||
impl BreadClient {
|
||||
pub fn new(configured_path: &str, event_prefix: String) -> Self {
|
||||
let socket_path = if configured_path.is_empty() {
|
||||
default_socket_path()
|
||||
} else {
|
||||
PathBuf::from(configured_path)
|
||||
};
|
||||
Self {
|
||||
socket_path,
|
||||
event_prefix,
|
||||
}
|
||||
}
|
||||
|
||||
/// Emits `<event_prefix>.<suffix>` 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.
|
||||
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)?);
|
||||
stream
|
||||
.write_all(line.as_bytes())
|
||||
.await
|
||||
.context("writing emit request")?;
|
||||
|
||||
let mut reader = BufReader::new(stream);
|
||||
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(())
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
149
src/camera.rs
Normal file
149
src/camera.rs
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use v4l::buffer::Type;
|
||||
use v4l::io::mmap::Stream as MmapStream;
|
||||
use v4l::io::traits::CaptureStream;
|
||||
use v4l::video::Capture;
|
||||
use v4l::FourCC;
|
||||
|
||||
/// A single grayscale frame: raw luma bytes plus the dimensions they came from.
|
||||
pub struct GrayFrame {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub luma: Vec<u8>,
|
||||
}
|
||||
|
||||
impl GrayFrame {
|
||||
/// Mean luma across the whole frame — used both to normalize the RGB motion
|
||||
/// diff against global brightness changes, and as a cheap non-ML occupancy
|
||||
/// signal on IR frames (something close to the sensor reflects a lot more IR
|
||||
/// light back than an empty room, independent of whether a face is detected).
|
||||
pub fn mean_luma(&self) -> f32 {
|
||||
if self.luma.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
self.luma.iter().map(|&b| b as u64).sum::<u64>() as f32 / self.luma.len() as f32
|
||||
}
|
||||
}
|
||||
|
||||
/// Which physical sensor a device should be: used to auto-resolve a stable path
|
||||
/// instead of trusting a possibly-renumbered `/dev/videoN` node.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DeviceKind {
|
||||
Rgb,
|
||||
Ir,
|
||||
}
|
||||
|
||||
/// Resolves a configured device string to an actual path.
|
||||
///
|
||||
/// A value of `"auto:rgb"` / `"auto:ir"` probes the stable `/dev/v4l/by-path/*`
|
||||
/// symlinks and picks by capture capability (supports MJPG/YUYV color formats =
|
||||
/// RGB sensor; GREY-only = IR sensor) rather than trusting `/dev/videoN` numbering,
|
||||
/// which isn't stable across reboots or external camera plug events. Any other
|
||||
/// value is used as a literal path, for explicit overrides.
|
||||
pub fn resolve_device(configured: &str, want: DeviceKind) -> Result<PathBuf> {
|
||||
if configured != "auto:rgb" && configured != "auto:ir" {
|
||||
return Ok(PathBuf::from(configured));
|
||||
}
|
||||
|
||||
let by_path = Path::new("/dev/v4l/by-path");
|
||||
let entries = std::fs::read_dir(by_path)
|
||||
.with_context(|| format!("listing {by_path:?} for camera auto-detection"))?;
|
||||
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for entry in entries.flatten() {
|
||||
let symlink_path = entry.path();
|
||||
let Ok(resolved) = std::fs::canonicalize(&symlink_path) else {
|
||||
continue;
|
||||
};
|
||||
if !seen.insert(resolved.clone()) {
|
||||
continue; // by-path lists each physical device under multiple aliases
|
||||
}
|
||||
|
||||
let Ok(dev) = v4l::Device::with_path(&resolved) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(formats) = Capture::enum_formats(&dev) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let has_color = formats
|
||||
.iter()
|
||||
.any(|f| f.fourcc == FourCC::new(b"MJPG") || f.fourcc == FourCC::new(b"YUYV"));
|
||||
let has_grey = formats.iter().any(|f| f.fourcc == FourCC::new(b"GREY"));
|
||||
|
||||
let matches = match want {
|
||||
DeviceKind::Rgb => has_color,
|
||||
DeviceKind::Ir => has_grey && !has_color,
|
||||
};
|
||||
if matches {
|
||||
return Ok(resolved);
|
||||
}
|
||||
}
|
||||
|
||||
anyhow::bail!("no {want:?} camera found under {by_path:?}; set camera.rgb_device / camera.ir_device explicitly in crustd.toml")
|
||||
}
|
||||
|
||||
/// Grabs one YUYV frame from the RGB camera and returns just the luma (Y) plane.
|
||||
///
|
||||
/// Opens the device, captures, and drops it before returning — nothing about this
|
||||
/// camera is left streaming between polls.
|
||||
pub fn grab_rgb_luma(device: &str, width: u32, height: u32) -> Result<GrayFrame> {
|
||||
let mut dev = v4l::Device::with_path(device)
|
||||
.with_context(|| format!("opening rgb camera {device}"))?;
|
||||
|
||||
let mut fmt = Capture::format(&dev).context("reading current rgb format")?;
|
||||
fmt.width = width;
|
||||
fmt.height = height;
|
||||
fmt.fourcc = FourCC::new(b"YUYV");
|
||||
Capture::set_format(&dev, &fmt).context("setting rgb format")?;
|
||||
let fmt = Capture::format(&dev).context("reading confirmed rgb format")?;
|
||||
|
||||
let mut stream = MmapStream::with_buffers(&mut dev, Type::VideoCapture, 2)
|
||||
.context("starting rgb capture stream")?;
|
||||
|
||||
// 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 mut luma = Vec::with_capacity((fmt.width * fmt.height) as usize);
|
||||
for chunk in buf.chunks_exact(2) {
|
||||
luma.push(chunk[0]);
|
||||
}
|
||||
|
||||
Ok(GrayFrame {
|
||||
width: fmt.width,
|
||||
height: fmt.height,
|
||||
luma,
|
||||
})
|
||||
}
|
||||
|
||||
/// Grabs `count` frames from the IR camera in a single short burst and returns them.
|
||||
///
|
||||
/// 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<Vec<GrayFrame>> {
|
||||
let mut dev =
|
||||
v4l::Device::with_path(device).with_context(|| format!("opening ir camera {device}"))?;
|
||||
|
||||
let fmt = Capture::format(&dev).context("reading ir format")?;
|
||||
let mut stream = MmapStream::with_buffers(&mut dev, Type::VideoCapture, 2)
|
||||
.context("starting ir capture stream")?;
|
||||
|
||||
// Discard the first frame post-open the same way as the RGB path.
|
||||
let _ = stream.next().context("priming ir stream")?;
|
||||
|
||||
let mut frames = Vec::with_capacity(count as usize);
|
||||
for _ in 0..count {
|
||||
let (buf, _meta) = stream.next().context("capturing ir frame")?;
|
||||
frames.push(GrayFrame {
|
||||
width: fmt.width,
|
||||
height: fmt.height,
|
||||
luma: buf.to_vec(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(frames)
|
||||
}
|
||||
179
src/config.rs
Normal file
179
src/config.rs
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::Result;
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Config {
|
||||
pub camera: CameraConfig,
|
||||
pub motion: MotionConfig,
|
||||
pub presence: PresenceConfig,
|
||||
pub bread: BreadConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct CameraConfig {
|
||||
/// Visible-light camera used for the always-on motion gate. Never opens the IR emitter.
|
||||
pub rgb_device: String,
|
||||
/// IR camera used only in short bursts for face detection/recognition.
|
||||
pub ir_device: String,
|
||||
}
|
||||
|
||||
impl Default for CameraConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
// Auto-resolved by capture capability via /dev/v4l/by-path at startup —
|
||||
// see camera::resolve_device. Set to a literal /dev/videoN to override.
|
||||
rgb_device: "auto:rgb".to_string(),
|
||||
ir_device: "auto:ir".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct MotionConfig {
|
||||
/// Poll interval when nothing has moved recently.
|
||||
pub idle_poll_ms: u64,
|
||||
/// Poll interval right after motion was seen, before it settles back down.
|
||||
pub active_poll_ms: u64,
|
||||
/// Ceiling for backoff when the room has been still for a long time.
|
||||
pub max_idle_poll_ms: u64,
|
||||
/// RGB poll interval while presence is already confirmed. This is the retention
|
||||
/// signal — typing, mouse movement, posture shifts — and it's cheap (no IR, no
|
||||
/// inference), so it can run continuously without a power or annoyance cost.
|
||||
pub present_poll_ms: u64,
|
||||
/// How long RGB has to be continuously still while present before we bother
|
||||
/// spending an IR burst to double-check someone's still there. A person actively
|
||||
/// working is essentially never RGB-still for this long, so in normal use this
|
||||
/// keeps the IR camera dark for the entire session.
|
||||
pub present_stillness_timeout_ms: u64,
|
||||
/// After an IR retention check comes back with no face, how soon to retry — much
|
||||
/// faster than `present_stillness_timeout_ms` so a real departure is confirmed
|
||||
/// (and presence released) within `release_frames` short retries instead of
|
||||
/// waiting a full stillness timeout each time. A positive check at any point
|
||||
/// resets back to the slow cadence.
|
||||
pub present_retry_after_miss_ms: u64,
|
||||
/// How long the compositor's seat (keyboard/mouse) must be idle, via
|
||||
/// ext-idle-notify-v1, before Present-mode falls back to the camera-based
|
||||
/// retention cascade at all. While input is active, no camera is opened —
|
||||
/// typing/mousing is treated as sufficient proof of presence on its own.
|
||||
pub input_idle_timeout_ms: u32,
|
||||
/// Per-pixel luma delta (0-255) that counts as "changed".
|
||||
pub diff_threshold: u8,
|
||||
/// Minimum count of changed pixels (out of 320x180) to call it motion.
|
||||
pub blob_min_pixels: usize,
|
||||
}
|
||||
|
||||
impl Default for MotionConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
idle_poll_ms: 2_000,
|
||||
active_poll_ms: 250,
|
||||
max_idle_poll_ms: 15_000,
|
||||
present_poll_ms: 1_000,
|
||||
present_stillness_timeout_ms: 45_000,
|
||||
present_retry_after_miss_ms: 8_000,
|
||||
input_idle_timeout_ms: 20_000,
|
||||
diff_threshold: 24,
|
||||
blob_min_pixels: 200,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct PresenceConfig {
|
||||
/// Path to the ONNX face-detection model used for the presence tier.
|
||||
pub face_detect_model: String,
|
||||
/// Consecutive positive IR bursts required before presence flips on.
|
||||
pub confirm_frames: u32,
|
||||
/// Consecutive negative IR bursts required before presence flips off.
|
||||
pub release_frames: u32,
|
||||
/// Minimum detector confidence to count a frame as "face seen".
|
||||
pub min_confidence: f32,
|
||||
/// Frames grabbed per IR burst (averaged/voted, then the camera is closed).
|
||||
pub ir_burst_frames: u32,
|
||||
/// Consecutive retention misses required before the recheck cadence escalates
|
||||
/// from the slow stillness-timeout interval to the fast retry interval. Kept
|
||||
/// above 1 so a single missed frontal-face check (person glanced down, turned to
|
||||
/// talk to someone) gets a second slow-cadence check before anything urgent
|
||||
/// happens, rather than immediately starting a fast countdown toward auto-lock.
|
||||
///
|
||||
/// (An earlier version of this tried a "mean IR brightness = still occupied"
|
||||
/// fallback signal instead, on the theory that a nearby person reflects
|
||||
/// noticeably more IR than an empty room. Measured on real hardware, empty-room
|
||||
/// and occupied-room mean luma overlapped too much to use — the frame is
|
||||
/// dominated by background reflectance, not near-field subject reflectance.
|
||||
/// Discarded rather than shipped; this grace-period approach doesn't depend on
|
||||
/// that assumption.)
|
||||
pub retention_grace_misses: u32,
|
||||
}
|
||||
|
||||
impl Default for PresenceConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
face_detect_model: "~/.local/share/crustd/models/ultraface-rfb-320.onnx".to_string(),
|
||||
confirm_frames: 2,
|
||||
release_frames: 3,
|
||||
min_confidence: 0.7,
|
||||
ir_burst_frames: 3,
|
||||
retention_grace_misses: 2,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct BreadConfig {
|
||||
/// Empty = resolve $XDG_RUNTIME_DIR/bread/breadd.sock at runtime, matching breadd's default.
|
||||
pub socket_path: String,
|
||||
pub event_prefix: String,
|
||||
}
|
||||
|
||||
impl Default for BreadConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
socket_path: String::new(),
|
||||
event_prefix: "bread.presence".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
camera: CameraConfig::default(),
|
||||
motion: MotionConfig::default(),
|
||||
presence: PresenceConfig::default(),
|
||||
bread: BreadConfig::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn load(path: &PathBuf) -> Result<Self> {
|
||||
if !path.exists() {
|
||||
return Ok(Self::default());
|
||||
}
|
||||
let raw = std::fs::read_to_string(path)?;
|
||||
Ok(toml::from_str(&raw)?)
|
||||
}
|
||||
|
||||
pub fn default_path() -> PathBuf {
|
||||
directories::ProjectDirs::from("dev", "breadway", "crustd")
|
||||
.map(|d| d.config_dir().join("crustd.toml"))
|
||||
.unwrap_or_else(|| PathBuf::from("crustd.toml"))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn expand_home(path: &str) -> PathBuf {
|
||||
if let Some(rest) = path.strip_prefix("~/") {
|
||||
if let Some(home) = std::env::var_os("HOME") {
|
||||
return PathBuf::from(home).join(rest);
|
||||
}
|
||||
}
|
||||
PathBuf::from(path)
|
||||
}
|
||||
126
src/idle.rs
Normal file
126
src/idle.rs
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::mpsc;
|
||||
use std::sync::Arc;
|
||||
|
||||
use wayland_client::globals::{registry_queue_init, GlobalListContents};
|
||||
use wayland_client::protocol::{wl_registry, wl_seat};
|
||||
use wayland_client::protocol::wl_seat::WlSeat;
|
||||
use wayland_client::{Connection, Dispatch, QueueHandle};
|
||||
use wayland_protocols::ext::idle_notify::v1::client::ext_idle_notification_v1::{
|
||||
self, ExtIdleNotificationV1,
|
||||
};
|
||||
use wayland_protocols::ext::idle_notify::v1::client::ext_idle_notifier_v1::ExtIdleNotifierV1;
|
||||
|
||||
/// Tracks whether the compositor considers the seat's input (keyboard/mouse/etc.)
|
||||
/// idle, via the standard `ext-idle-notify-v1` protocol (the same one `hypridle`
|
||||
/// itself relies on). Runs its own connection on a dedicated thread since the
|
||||
/// wayland-client event loop is blocking.
|
||||
pub struct IdleWatcher {
|
||||
idle: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl IdleWatcher {
|
||||
/// Returns `None` if the compositor doesn't support ext-idle-notify-v1 (or the
|
||||
/// connection otherwise fails) — callers should treat that as "can't use this
|
||||
/// signal" and fall back to the camera-only behavior rather than erroring out.
|
||||
pub fn spawn(timeout_ms: u32) -> Option<Self> {
|
||||
let idle = Arc::new(AtomicBool::new(false));
|
||||
let idle_for_thread = Arc::clone(&idle);
|
||||
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))
|
||||
.ok()?;
|
||||
|
||||
ready_rx.recv().ok()?.then_some(Self { idle })
|
||||
}
|
||||
|
||||
pub fn is_idle(&self) -> bool {
|
||||
self.idle.load(Ordering::Relaxed)
|
||||
}
|
||||
}
|
||||
|
||||
struct State {
|
||||
idle: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
fn run(timeout_ms: u32, idle: Arc<AtomicBool>, ready_tx: mpsc::Sender<bool>) {
|
||||
let Ok(conn) = Connection::connect_to_env() else {
|
||||
let _ = ready_tx.send(false);
|
||||
return;
|
||||
};
|
||||
|
||||
let Ok((globals, mut queue)) = registry_queue_init::<State>(&conn) else {
|
||||
let _ = ready_tx.send(false);
|
||||
return;
|
||||
};
|
||||
let qh = queue.handle();
|
||||
|
||||
let seat = globals.bind::<WlSeat, _, _>(&qh, 1..=8, ());
|
||||
let notifier = globals.bind::<ExtIdleNotifierV1, _, _>(&qh, 1..=1, ());
|
||||
|
||||
let (Ok(seat), Ok(notifier)) = (seat, notifier) else {
|
||||
let _ = ready_tx.send(false);
|
||||
return;
|
||||
};
|
||||
|
||||
let mut state = State { idle };
|
||||
let _notification = notifier.get_idle_notification(timeout_ms, &seat, &qh, ());
|
||||
|
||||
let _ = ready_tx.send(true);
|
||||
|
||||
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.
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Dispatch<ExtIdleNotificationV1, ()> for State {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
_proxy: &ExtIdleNotificationV1,
|
||||
event: ext_idle_notification_v1::Event,
|
||||
_data: &(),
|
||||
_conn: &Connection,
|
||||
_qh: &QueueHandle<Self>,
|
||||
) {
|
||||
match event {
|
||||
ext_idle_notification_v1::Event::Idled => state.idle.store(true, Ordering::Relaxed),
|
||||
ext_idle_notification_v1::Event::Resumed => state.idle.store(false, Ordering::Relaxed),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No-op dispatch impls for objects whose events we don't care about.
|
||||
impl Dispatch<WlSeat, ()> for State {
|
||||
fn event(_: &mut Self, _: &WlSeat, _: wl_seat::Event, _: &(), _: &Connection, _: &QueueHandle<Self>) {}
|
||||
}
|
||||
|
||||
impl Dispatch<ExtIdleNotifierV1, ()> for State {
|
||||
fn event(
|
||||
_: &mut Self,
|
||||
_: &ExtIdleNotifierV1,
|
||||
_: <ExtIdleNotifierV1 as wayland_client::Proxy>::Event,
|
||||
_: &(),
|
||||
_: &Connection,
|
||||
_: &QueueHandle<Self>,
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
impl Dispatch<wl_registry::WlRegistry, GlobalListContents> for State {
|
||||
fn event(
|
||||
_: &mut Self,
|
||||
_: &wl_registry::WlRegistry,
|
||||
_: wl_registry::Event,
|
||||
_: &GlobalListContents,
|
||||
_: &Connection,
|
||||
_: &QueueHandle<Self>,
|
||||
) {
|
||||
}
|
||||
}
|
||||
28
src/lid.rs
Normal file
28
src/lid.rs
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
use std::path::Path;
|
||||
|
||||
/// Best-effort lid state via the ACPI button interface. Cameras obviously can't see
|
||||
/// anything useful with the lid shut, so callers should skip capture entirely rather
|
||||
/// than burn power opening devices against a blocked sensor.
|
||||
///
|
||||
/// Returns `false` (assume open) if there's no ACPI lid button on this machine —
|
||||
/// desktops and some laptops won't have one, and that's not an error condition.
|
||||
pub fn is_closed() -> bool {
|
||||
let Ok(entries) = std::fs::read_dir("/proc/acpi/button/lid") else {
|
||||
return false;
|
||||
};
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let state_path = entry.path().join("state");
|
||||
if let Some(state) = read_state(&state_path) {
|
||||
if state.contains("closed") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
fn read_state(path: &Path) -> Option<String> {
|
||||
std::fs::read_to_string(path).ok()
|
||||
}
|
||||
259
src/main.rs
Normal file
259
src/main.rs
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
mod bread;
|
||||
mod camera;
|
||||
mod config;
|
||||
mod idle;
|
||||
mod lid;
|
||||
mod motion;
|
||||
mod presence;
|
||||
mod vision;
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use tracing::{info, warn};
|
||||
|
||||
use bread::BreadClient;
|
||||
use camera::DeviceKind;
|
||||
use config::{Config, PresenceConfig};
|
||||
use idle::IdleWatcher;
|
||||
use motion::MotionGate;
|
||||
use presence::{PresenceState, PresenceTracker};
|
||||
use vision::{Backend, FaceDetector};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("crustd=info")),
|
||||
)
|
||||
.init();
|
||||
|
||||
let config_path = Config::default_path();
|
||||
let cfg = Config::load(&config_path)?;
|
||||
info!(?config_path, "loaded config");
|
||||
|
||||
let model_path = config::expand_home(&cfg.presence.face_detect_model);
|
||||
if !model_path.exists() {
|
||||
anyhow::bail!(
|
||||
"face detector model not found at {model_path:?}; place ultraface-rfb-320.onnx \
|
||||
there or point presence.face_detect_model at it in crustd.toml"
|
||||
);
|
||||
}
|
||||
|
||||
let mut detector = FaceDetector::load(&model_path, Backend::Cpu)?;
|
||||
let mut motion_gate = MotionGate::new();
|
||||
let mut presence = PresenceTracker::new(&cfg.presence);
|
||||
let bread = BreadClient::new(&cfg.bread.socket_path, cfg.bread.event_prefix.clone());
|
||||
|
||||
let rgb_device = camera::resolve_device(&cfg.camera.rgb_device, DeviceKind::Rgb)
|
||||
.context("resolving rgb camera device")?
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
let ir_device = camera::resolve_device(&cfg.camera.ir_device, DeviceKind::Ir)
|
||||
.context("resolving ir camera device")?
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
|
||||
let idle_watcher = IdleWatcher::spawn(cfg.motion.input_idle_timeout_ms);
|
||||
if idle_watcher.is_none() {
|
||||
warn!(
|
||||
"compositor doesn't support ext-idle-notify-v1 (or connection failed); \
|
||||
falling back to camera-only retention, no compositor idle fast-path"
|
||||
);
|
||||
}
|
||||
|
||||
let mut idle_interval = cfg.motion.idle_poll_ms;
|
||||
|
||||
// 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.
|
||||
let mut last_rgb_motion_at = Instant::now();
|
||||
|
||||
info!(
|
||||
rgb_device,
|
||||
ir_device,
|
||||
compositor_idle_signal = idle_watcher.is_some(),
|
||||
"crustd started; ir camera is opened only in short bursts, never streamed continuously"
|
||||
);
|
||||
|
||||
let mut shutdown = std::pin::pin!(shutdown_signal());
|
||||
|
||||
loop {
|
||||
let sleep_ms = if presence.state() == PresenceState::Present {
|
||||
cfg.motion.present_poll_ms
|
||||
} else {
|
||||
idle_interval
|
||||
};
|
||||
|
||||
tokio::select! {
|
||||
_ = &mut shutdown => {
|
||||
info!("shutting down");
|
||||
break;
|
||||
}
|
||||
_ = tokio::time::sleep(Duration::from_millis(sleep_ms)) => {}
|
||||
}
|
||||
|
||||
if lid::is_closed() {
|
||||
// Nothing a camera can see with the lid shut; don't bother opening one.
|
||||
tracing::trace!("lid closed, skipping this tick");
|
||||
continue;
|
||||
}
|
||||
|
||||
if presence.state() == PresenceState::Present {
|
||||
if let Some(watcher) = &idle_watcher {
|
||||
if !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();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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 frame = match capture {
|
||||
Ok(Ok(frame)) => frame,
|
||||
Ok(Err(err)) => {
|
||||
warn!(error = %err, "rgb capture failed");
|
||||
continue;
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(error = %err, "rgb capture task join error");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let motion = motion_gate.observe(frame, &cfg.motion);
|
||||
tracing::trace!(changed_pixels = motion.changed_pixels, "rgb motion sample");
|
||||
|
||||
if presence.state() == PresenceState::Present {
|
||||
if motion.is_motion {
|
||||
last_rgb_motion_at = Instant::now();
|
||||
continue;
|
||||
}
|
||||
|
||||
// RGB (and, per above, compositor input) has been still; only escalate to
|
||||
// an IR retention check once it's been still long enough to matter. The
|
||||
// first `retention_grace_misses` misses still use the slow stillness
|
||||
// cadence — a single missed frontal-face check (glanced down, turned to
|
||||
// talk to someone) shouldn't immediately start an urgent countdown toward
|
||||
// auto-lock. Only after that grace period does it retry at the fast
|
||||
// cadence, and any positive check resets back to the slow cadence.
|
||||
let due_after = if presence.negative_streak() >= cfg.presence.retention_grace_misses {
|
||||
cfg.motion.present_retry_after_miss_ms
|
||||
} else {
|
||||
cfg.motion.present_stillness_timeout_ms
|
||||
};
|
||||
|
||||
if last_rgb_motion_at.elapsed() < Duration::from_millis(due_after) {
|
||||
continue;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
handle_observation(&mut presence, &bread, face_seen).await;
|
||||
}
|
||||
Err(err) => warn!(error = %err, "ir retention check failed"),
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Absent: escalate to an IR acquisition burst as soon as RGB sees motion.
|
||||
// Acquisition deliberately requires an actual frontal face match (not the
|
||||
// looser occupancy signal) — a hand or mug near the sensor shouldn't count
|
||||
// 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();
|
||||
}
|
||||
handle_observation(&mut presence, &bread, face_seen).await;
|
||||
}
|
||||
Err(err) => warn!(error = %err, "ir burst check failed"),
|
||||
}
|
||||
} else {
|
||||
idle_interval = (idle_interval * 3 / 2).min(cfg.motion.max_idle_poll_ms);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct IrCheckResult {
|
||||
max_confidence: f32,
|
||||
max_mean_luma: f32,
|
||||
}
|
||||
|
||||
/// Opens the IR camera for exactly one short burst, runs the tier-1 detector over
|
||||
/// each frame, and closes it again before returning.
|
||||
async fn run_ir_check(ir_device: &str, cfg: &PresenceConfig, detector: &mut FaceDetector) -> Result<IrCheckResult> {
|
||||
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 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"
|
||||
);
|
||||
|
||||
Ok(IrCheckResult { max_confidence, max_mean_luma })
|
||||
}
|
||||
|
||||
async fn handle_observation(presence: &mut PresenceTracker, bread: &BreadClient, positive: bool) {
|
||||
if let Some(new_state) = presence.observe(positive) {
|
||||
info!(state = new_state.as_str(), "presence state changed");
|
||||
let data = serde_json::json!({ "state": new_state.as_str() });
|
||||
if let Err(err) = bread.emit("changed", data).await {
|
||||
warn!(error = %err, "failed to emit presence event to bread");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn shutdown_signal() {
|
||||
let ctrl_c = async {
|
||||
let _ = tokio::signal::ctrl_c().await;
|
||||
};
|
||||
|
||||
#[cfg(unix)]
|
||||
let terminate = async {
|
||||
use tokio::signal::unix::{signal, SignalKind};
|
||||
if let Ok(mut sig) = signal(SignalKind::terminate()) {
|
||||
sig.recv().await;
|
||||
}
|
||||
};
|
||||
#[cfg(not(unix))]
|
||||
let terminate = std::future::pending::<()>();
|
||||
|
||||
tokio::select! {
|
||||
_ = ctrl_c => {},
|
||||
_ = terminate => {},
|
||||
}
|
||||
}
|
||||
49
src/motion.rs
Normal file
49
src/motion.rs
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
use crate::camera::GrayFrame;
|
||||
use crate::config::MotionConfig;
|
||||
|
||||
/// Cheap frame-differencing gate over the RGB camera. This is the only thing that
|
||||
/// runs continuously — it never touches the IR camera, so nothing flashes while
|
||||
/// the room is empty or still.
|
||||
pub struct MotionGate {
|
||||
previous: Option<GrayFrame>,
|
||||
}
|
||||
|
||||
pub struct MotionResult {
|
||||
pub changed_pixels: usize,
|
||||
pub is_motion: bool,
|
||||
}
|
||||
|
||||
impl MotionGate {
|
||||
pub fn new() -> Self {
|
||||
Self { previous: None }
|
||||
}
|
||||
|
||||
pub fn observe(&mut self, frame: GrayFrame, cfg: &MotionConfig) -> MotionResult {
|
||||
let changed_pixels = match &self.previous {
|
||||
None => 0,
|
||||
Some(prev) if prev.luma.len() != frame.luma.len() => 0,
|
||||
Some(prev) => {
|
||||
// Subtract the frame-wide brightness shift before thresholding, so a
|
||||
// light turning on/off (which moves every pixel by roughly the same
|
||||
// amount) doesn't register as motion — only localized change does.
|
||||
let mean_shift = frame.mean_luma() - prev.mean_luma();
|
||||
prev.luma
|
||||
.iter()
|
||||
.zip(frame.luma.iter())
|
||||
.filter(|(&a, &b)| {
|
||||
let adjusted = b as f32 - mean_shift;
|
||||
(adjusted - a as f32).abs() >= cfg.diff_threshold as f32
|
||||
})
|
||||
.count()
|
||||
}
|
||||
};
|
||||
|
||||
let is_motion = changed_pixels >= cfg.blob_min_pixels;
|
||||
self.previous = Some(frame);
|
||||
|
||||
MotionResult {
|
||||
changed_pixels,
|
||||
is_motion,
|
||||
}
|
||||
}
|
||||
}
|
||||
75
src/presence.rs
Normal file
75
src/presence.rs
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
use crate::config::PresenceConfig;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PresenceState {
|
||||
Absent,
|
||||
Present,
|
||||
}
|
||||
|
||||
impl PresenceState {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
PresenceState::Absent => "absent",
|
||||
PresenceState::Present => "present",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Debounced presence state machine. Feed it one boolean per IR burst ("was a face
|
||||
/// seen above threshold") and it reports state transitions only after `confirm_frames`
|
||||
/// consecutive positives (to flip on) or `release_frames` consecutive negatives (to
|
||||
/// flip off) — this is what keeps a single flickery frame from toggling the state.
|
||||
pub struct PresenceTracker {
|
||||
state: PresenceState,
|
||||
positive_streak: u32,
|
||||
negative_streak: u32,
|
||||
confirm_frames: u32,
|
||||
release_frames: u32,
|
||||
}
|
||||
|
||||
impl PresenceTracker {
|
||||
pub fn new(cfg: &PresenceConfig) -> Self {
|
||||
Self {
|
||||
state: PresenceState::Absent,
|
||||
positive_streak: 0,
|
||||
negative_streak: 0,
|
||||
confirm_frames: cfg.confirm_frames.max(1),
|
||||
release_frames: cfg.release_frames.max(1),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn state(&self) -> PresenceState {
|
||||
self.state
|
||||
}
|
||||
|
||||
/// Count of consecutive failed retention checks since presence was last
|
||||
/// confirmed. Zero means the last check (if any) was positive, or none has run
|
||||
/// yet — callers use this to decide whether to use the slow stillness-timeout
|
||||
/// cadence or the fast retry-after-miss cadence for the next IR check.
|
||||
pub fn negative_streak(&self) -> u32 {
|
||||
self.negative_streak
|
||||
}
|
||||
|
||||
/// Returns `Some(new_state)` only when the debounced state actually changes.
|
||||
pub fn observe(&mut self, face_seen: bool) -> Option<PresenceState> {
|
||||
if face_seen {
|
||||
self.positive_streak += 1;
|
||||
self.negative_streak = 0;
|
||||
} else {
|
||||
self.negative_streak += 1;
|
||||
self.positive_streak = 0;
|
||||
}
|
||||
|
||||
match self.state {
|
||||
PresenceState::Absent if self.positive_streak >= self.confirm_frames => {
|
||||
self.state = PresenceState::Present;
|
||||
Some(self.state)
|
||||
}
|
||||
PresenceState::Present if self.negative_streak >= self.release_frames => {
|
||||
self.state = PresenceState::Absent;
|
||||
Some(self.state)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
110
src/vision.rs
Normal file
110
src/vision.rs
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
use anyhow::Result;
|
||||
use ort::session::builder::GraphOptimizationLevel;
|
||||
use ort::session::Session;
|
||||
use ort::value::Value;
|
||||
|
||||
use crate::camera::GrayFrame;
|
||||
|
||||
const MODEL_WIDTH: usize = 320;
|
||||
const MODEL_HEIGHT: usize = 240;
|
||||
|
||||
/// Which execution provider a `FaceDetector` was built against.
|
||||
///
|
||||
/// `Npu` is intentionally unimplemented: this hardware (RyzenAI-npu6 / Krackan) has
|
||||
/// no matching xclbin overlay in the installed Vitis AI SDK (it only ships Phoenix
|
||||
/// overlays), so there's nothing for the VitisAI EP to load yet. The seam is here so
|
||||
/// that dropping in a Krackan xclbin later is a config change, not a rewrite: swap
|
||||
/// this match arm to call `SessionBuilder::with_execution_providers` with the VitisAI
|
||||
/// EP once `vaip_config.json` points at a compatible overlay.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Backend {
|
||||
Cpu,
|
||||
Npu,
|
||||
}
|
||||
|
||||
/// Tier-1 presence face detector (UltraFace-RFB-320 class model). Runs only against
|
||||
/// short IR bursts handed to it by the presence loop — it never touches the camera
|
||||
/// itself.
|
||||
pub struct FaceDetector {
|
||||
session: Session,
|
||||
}
|
||||
|
||||
impl FaceDetector {
|
||||
pub fn load(model_path: &std::path::Path, backend: Backend) -> Result<Self> {
|
||||
if backend == Backend::Npu {
|
||||
anyhow::bail!(
|
||||
"NPU backend requested but no Krackan/Strix xclbin is available for the \
|
||||
VitisAI execution provider on this system (only Phoenix overlays are \
|
||||
installed under ryzenai-env). Falling back is the caller's job; this \
|
||||
constructor refuses rather than silently running on CPU under a false name."
|
||||
);
|
||||
}
|
||||
|
||||
let session = Session::builder()
|
||||
.map_err(|e| anyhow::anyhow!("creating onnxruntime session builder: {e}"))?
|
||||
.with_optimization_level(GraphOptimizationLevel::Level3)
|
||||
.map_err(|e| anyhow::anyhow!("setting graph optimization level: {e}"))?
|
||||
.with_intra_threads(1)
|
||||
.map_err(|e| anyhow::anyhow!("setting intra-op thread count: {e}"))?
|
||||
.commit_from_file(model_path)
|
||||
.map_err(|e| anyhow::anyhow!("loading face detector model from {model_path:?}: {e}"))?;
|
||||
|
||||
Ok(Self { session })
|
||||
}
|
||||
|
||||
/// Resizes/normalizes the frame to the model's expected input and returns the
|
||||
/// highest face-class probability across all anchor boxes. Callers decide the
|
||||
/// confidence threshold; this just reports the peak score.
|
||||
pub fn max_face_confidence(&mut self, frame: &GrayFrame) -> Result<f32> {
|
||||
let tensor = preprocess(frame);
|
||||
|
||||
let input = Value::from_array(([1usize, 3, MODEL_HEIGHT, MODEL_WIDTH], tensor))
|
||||
.map_err(|e| anyhow::anyhow!("building input tensor: {e}"))?;
|
||||
|
||||
let outputs = self
|
||||
.session
|
||||
.run(ort::inputs!["input" => input])
|
||||
.map_err(|e| anyhow::anyhow!("running face detector session: {e}"))?;
|
||||
|
||||
let scores = outputs["scores"]
|
||||
.try_extract_tensor::<f32>()
|
||||
.map_err(|e| anyhow::anyhow!("extracting scores output: {e}"))?;
|
||||
|
||||
// scores is [1, N, 2]: column 0 = background prob, column 1 = face prob.
|
||||
// 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])
|
||||
.fold(0.0f32, f32::max);
|
||||
|
||||
Ok(max_face_prob)
|
||||
}
|
||||
}
|
||||
|
||||
/// Nearest-neighbor stretch-resize to 320x240 + grayscale-replicated-to-RGB +
|
||||
/// `(x - 127) / 128` normalization, matching the reference UltraFace preprocessing.
|
||||
/// Nearest-neighbor is a deliberate v1 simplification (no extra image/resize
|
||||
/// dependency) — a proper area-average downscale would be a bit more accurate but
|
||||
/// isn't needed for a presence gate.
|
||||
fn preprocess(frame: &GrayFrame) -> Vec<f32> {
|
||||
let mut out = vec![0f32; 3 * MODEL_HEIGHT * MODEL_WIDTH];
|
||||
let plane_len = MODEL_HEIGHT * MODEL_WIDTH;
|
||||
|
||||
for y in 0..MODEL_HEIGHT {
|
||||
let src_y = y * frame.height as usize / MODEL_HEIGHT;
|
||||
for x in 0..MODEL_WIDTH {
|
||||
let src_x = x * frame.width as usize / MODEL_WIDTH;
|
||||
let src_idx = src_y * frame.width as usize + src_x;
|
||||
let luma = *frame.luma.get(src_idx).unwrap_or(&0) as f32;
|
||||
let normalized = (luma - 127.0) / 128.0;
|
||||
|
||||
let dst_idx = y * MODEL_WIDTH + x;
|
||||
out[dst_idx] = normalized; // R
|
||||
out[plane_len + dst_idx] = normalized; // G
|
||||
out[2 * plane_len + dst_idx] = normalized; // B
|
||||
}
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue