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