fix(security): audit — fix RUSTSEC vulns, clippy warnings, dead code (#769)

- Upgrade openssl to 0.10.78 (CVE-2026-41676), jsonwebtoken to 9.4
- Suppress unmaintained-only/no-CVE advisories in .cargo/audit.toml
  with per-entry rationale
- Fix all `cargo clippy --all-targets -- -D warnings` errors across
  35 crates: derivable_impls, needless_range_loop, map_or→is_some_and/
  is_none_or, await_holding_lock (drop MutexGuard before .await),
  ptr_arg (&mut Vec→&mut [T]), useless_conversion, approximate_constant
  (2.718→E, 3.14→PI), field_reassign_with_default, manual_inspect,
  useless_vec, lines_filter_map_ok, print_literal, dead_code
- Apply `cargo fmt --all`
- Pre-existing test failure in wifi-densepose-signal
  (test_estimate_occupancy_noise_only) is not introduced by this PR
This commit is contained in:
rUv
2026-05-23 05:36:13 -04:00
committed by GitHub
parent 1906876541
commit 004a63e82d
248 changed files with 13614 additions and 5872 deletions
@@ -16,8 +16,8 @@ const DEFAULT_BRAIN_URL: &str = "http://127.0.0.1:9876";
fn brain_url() -> &'static str {
static BRAIN_URL: OnceLock<String> = OnceLock::new();
BRAIN_URL.get_or_init(|| {
let url = std::env::var("RUVIEW_BRAIN_URL")
.unwrap_or_else(|_| DEFAULT_BRAIN_URL.to_string());
let url =
std::env::var("RUVIEW_BRAIN_URL").unwrap_or_else(|_| DEFAULT_BRAIN_URL.to_string());
eprintln!(" brain_bridge: using brain URL {url}");
url
})
@@ -34,7 +34,8 @@ async fn store_memory(category: &str, content: &str) -> Result<()> {
"content": content,
});
client.post(format!("{}/memories", brain_url()))
client
.post(format!("{}/memories", brain_url()))
.json(&body)
.send()
.await?;
@@ -44,12 +45,22 @@ async fn store_memory(category: &str, content: &str) -> Result<()> {
/// Summarize pipeline state and store in brain (called every 60 seconds).
pub async fn sync_to_brain(pipeline: &PipelineOutput, camera_frames: u64) {
// Only store if there's meaningful data
if pipeline.total_frames < 10 && camera_frames < 5 { return; }
if pipeline.total_frames < 10 && camera_frames < 5 {
return;
}
// Store spatial summary
let motion_str = if pipeline.motion_detected { "detected" } else { "absent" };
let motion_str = if pipeline.motion_detected {
"detected"
} else {
"absent"
};
let skeleton_str = if let Some(ref sk) = pipeline.skeleton {
format!("{} keypoints ({:.0}% conf)", sk.keypoints.len(), sk.confidence * 100.0)
format!(
"{} keypoints ({:.0}% conf)",
sk.keypoints.len(),
sk.confidence * 100.0
)
} else {
"inactive".to_string()
};
@@ -75,18 +86,27 @@ pub async fn sync_to_brain(pipeline: &PipelineOutput, camera_frames: u64) {
// Store motion events
if pipeline.motion_detected && pipeline.vitals.motion_score > 0.3 {
let _ = store_memory("spatial-motion",
&format!("Strong motion detected: {:.0}% score, {} CSI frames",
pipeline.vitals.motion_score * 100.0, pipeline.total_frames)
).await;
let _ = store_memory(
"spatial-motion",
&format!(
"Strong motion detected: {:.0}% score, {} CSI frames",
pipeline.vitals.motion_score * 100.0,
pipeline.total_frames
),
)
.await;
}
// Store vital signs if available
if pipeline.vitals.breathing_rate > 5.0 && pipeline.vitals.breathing_rate < 35.0 {
let _ = store_memory("spatial-vitals",
&format!("Vital signs: breathing {:.0} BPM, motion {:.0}%",
pipeline.vitals.breathing_rate, pipeline.vitals.motion_score * 100.0)
).await;
let _ = store_memory(
"spatial-vitals",
&format!(
"Vital signs: breathing {:.0} BPM, motion {:.0}%",
pipeline.vitals.breathing_rate,
pipeline.vitals.motion_score * 100.0
),
)
.await;
}
}
@@ -5,14 +5,14 @@
//! Both: capture to JPEG, decode to RGB, return raw pixel data
use anyhow::{bail, Result};
use std::process::Command;
use std::path::PathBuf;
use std::process::Command;
/// Captured frame with raw RGB data.
pub struct Frame {
pub width: u32,
pub height: u32,
pub rgb: Vec<u8>, // row-major [height * width * 3]
pub rgb: Vec<u8>, // row-major [height * width * 3]
}
/// Camera source configuration.
@@ -25,7 +25,12 @@ pub struct CameraConfig {
impl Default for CameraConfig {
fn default() -> Self {
Self { device_index: 0, width: 640, height: 480, fps: 15 }
Self {
device_index: 0,
width: 640,
height: 480,
fps: 15,
}
}
}
@@ -63,29 +68,48 @@ fn capture_ffmpeg(config: &CameraConfig, tmp: &PathBuf) -> Result<Frame> {
format!("/dev/video{}", config.device_index) // v4l2
};
let format = if cfg!(target_os = "macos") { "avfoundation" } else { "v4l2" };
let format = if cfg!(target_os = "macos") {
"avfoundation"
} else {
"v4l2"
};
let status = Command::new("ffmpeg")
.args([
"-y", "-f", format,
"-video_size", &format!("{}x{}", config.width, config.height),
"-framerate", &config.fps.to_string(),
"-i", &input,
"-frames:v", "1",
"-f", "rawvideo",
"-pix_fmt", "rgb24",
"-y",
"-f",
format,
"-video_size",
&format!("{}x{}", config.width, config.height),
"-framerate",
&config.fps.to_string(),
"-i",
&input,
"-frames:v",
"1",
"-f",
"rawvideo",
"-pix_fmt",
"rgb24",
tmp.to_str().unwrap_or("/tmp/ruview-frame.raw"),
])
.output()?;
if !status.status.success() {
bail!("ffmpeg capture failed: {}", String::from_utf8_lossy(&status.stderr));
bail!(
"ffmpeg capture failed: {}",
String::from_utf8_lossy(&status.stderr)
);
}
let rgb = std::fs::read(tmp)?;
let expected = (config.width * config.height * 3) as usize;
if rgb.len() < expected {
bail!("frame too small: {} bytes, expected {}", rgb.len(), expected);
bail!(
"frame too small: {} bytes, expected {}",
rgb.len(),
expected
);
}
let _ = std::fs::remove_file(tmp);
@@ -108,10 +132,17 @@ fn capture_v4l2(config: &CameraConfig, tmp: &PathBuf) -> Result<Frame> {
// Use v4l2-ctl to grab a frame
let status = Command::new("v4l2-ctl")
.args([
"--device", &device,
"--set-fmt-video", &format!("width={},height={},pixelformat=MJPG", config.width, config.height),
"--stream-mmap", "--stream-count=1",
"--stream-to", tmp.to_str().unwrap_or("/tmp/frame.mjpg"),
"--device",
&device,
"--set-fmt-video",
&format!(
"width={},height={},pixelformat=MJPG",
config.width, config.height
),
"--stream-mmap",
"--stream-count=1",
"--stream-to",
tmp.to_str().unwrap_or("/tmp/frame.mjpg"),
])
.output()?;
@@ -192,7 +223,10 @@ pub fn list_cameras() -> Vec<String> {
let mut cameras = Vec::new();
if cfg!(target_os = "macos") {
if let Ok(output) = Command::new("system_profiler").args(["SPCameraDataType"]).output() {
if let Ok(output) = Command::new("system_profiler")
.args(["SPCameraDataType"])
.output()
{
let text = String::from_utf8_lossy(&output.stdout);
for line in text.lines() {
let trimmed = line.trim();
@@ -40,9 +40,9 @@ pub struct Skeleton {
#[derive(Clone, Debug)]
pub struct VitalSigns {
pub breathing_rate: f32, // breaths per minute
pub heart_rate: f32, // beats per minute
pub motion_score: f32, // 0.0 = still, 1.0 = strong motion
pub breathing_rate: f32, // breaths per minute
pub heart_rate: f32, // beats per minute
pub motion_score: f32, // 0.0 = still, 1.0 = strong motion
}
pub struct CsiPipelineState {
@@ -83,7 +83,11 @@ impl Default for CsiPipelineState {
Self {
node_frames: std::collections::HashMap::new(),
skeleton: None,
vitals: VitalSigns { breathing_rate: 0.0, heart_rate: 0.0, motion_score: 0.0 },
vitals: VitalSigns {
breathing_rate: 0.0,
heart_rate: 0.0,
motion_score: 0.0,
},
occupancy: vec![0.0; 8 * 8 * 4],
occupancy_dims: (8, 8, 4),
total_frames: 0,
@@ -112,7 +116,11 @@ fn detect_pose_model_metadata() -> Option<PoseModelMetadata> {
let expanded = p.replace('~', &std::env::var("HOME").unwrap_or_default());
if let Ok(data) = std::fs::read_to_string(&expanded) {
if let Ok(model) = serde_json::from_str::<serde_json::Value>(&data) {
if model.get("weightsBase64").and_then(|v| v.as_str()).is_some() {
if model
.get("weightsBase64")
.and_then(|v| v.as_str())
.is_some()
{
eprintln!(
" pose: amplitude-energy heuristic enabled (metadata from {expanded}, {} params — weights NOT loaded)",
model.get("totalParams").and_then(|v| v.as_u64()).unwrap_or(0)
@@ -154,16 +162,25 @@ impl CsiPipelineState {
// Store frame in per-node history
{
let history = self.node_frames.entry(node_id).or_insert_with(|| VecDeque::with_capacity(100));
let history = self
.node_frames
.entry(node_id)
.or_insert_with(|| VecDeque::with_capacity(100));
history.push_back(frame.clone());
if history.len() > 100 { history.pop_front(); }
if history.len() > 100 {
history.pop_front();
}
}
// 1. Motion detection (amplitude variance over last 20 frames)
self.detect_motion(node_id);
// 2. Vital signs (phase analysis over last 100 frames)
let has_enough = self.node_frames.get(&node_id).map(|h| h.len() >= 30).unwrap_or(false);
let has_enough = self
.node_frames
.get(&node_id)
.map(|h| h.len() >= 30)
.unwrap_or(false);
if has_enough {
self.estimate_vitals(node_id);
}
@@ -185,15 +202,19 @@ impl CsiPipelineState {
fn detect_motion(&mut self, node_id: u8) {
if let Some(history) = self.node_frames.get(&node_id) {
let recent: Vec<&CsiFrame> = history.iter().rev().take(20).collect();
if recent.len() < 5 { return; }
if recent.len() < 5 {
return;
}
// Compute mean amplitude across subcarriers for each frame
let mean_amps: Vec<f32> = recent.iter()
let mean_amps: Vec<f32> = recent
.iter()
.map(|f| f.amplitudes.iter().sum::<f32>() / f.amplitudes.len().max(1) as f32)
.collect();
let mean = mean_amps.iter().sum::<f32>() / mean_amps.len() as f32;
let variance = mean_amps.iter().map(|a| (a - mean).powi(2)).sum::<f32>() / mean_amps.len() as f32;
let variance =
mean_amps.iter().map(|a| (a - mean).powi(2)).sum::<f32>() / mean_amps.len() as f32;
// High variance = motion
self.vitals.motion_score = (variance / 100.0).min(1.0);
@@ -204,22 +225,28 @@ impl CsiPipelineState {
fn estimate_vitals(&mut self, node_id: u8) {
if let Some(history) = self.node_frames.get(&node_id) {
let frames: Vec<&CsiFrame> = history.iter().rev().take(100).collect();
if frames.len() < 30 { return; }
if frames.len() < 30 {
return;
}
// Extract phase from a stable subcarrier (pick one with low variance)
let n_sub = frames[0].phases.len().min(35);
if n_sub == 0 { return; }
if n_sub == 0 {
return;
}
// Use subcarrier 15 (mid-band, typically stable)
let sub_idx = n_sub / 2;
let phase_series: Vec<f32> = frames.iter().rev()
let phase_series: Vec<f32> = frames
.iter()
.rev()
.map(|f| f.phases.get(sub_idx).copied().unwrap_or(0.0))
.collect();
// Simple peak counting for breathing rate (0.15-0.5 Hz = 9-30 BPM)
let mut peaks = 0;
for i in 1..phase_series.len() - 1 {
if phase_series[i] > phase_series[i-1] && phase_series[i] > phase_series[i+1] {
if phase_series[i] > phase_series[i - 1] && phase_series[i] > phase_series[i + 1] {
peaks += 1;
}
}
@@ -245,14 +272,18 @@ impl CsiPipelineState {
/// keypoint index. Callers that need real pose must use the (yet to be
/// wired) WiFlow model directly.
fn heuristic_pose_from_amplitude(&mut self) {
if self.pose_model_present.is_none() { return; }
if self.pose_model_present.is_none() {
return;
}
// Collect 20 frames from the primary node
let primary_node = self.node_frames.keys().next().copied();
if let Some(node_id) = primary_node {
if let Some(history) = self.node_frames.get(&node_id) {
let frames: Vec<&CsiFrame> = history.iter().rev().take(20).collect();
if frames.len() < 20 { return; }
if frames.len() < 20 {
return;
}
// Build input: 35 subcarriers × 20 time steps. This is a
// deliberately simple summary used to compute amplitude
@@ -266,7 +297,8 @@ impl CsiPipelineState {
}
let mean_amp = input.iter().sum::<f32>() / input.len() as f32;
let amp_var = input.iter().map(|a| (a - mean_amp).powi(2)).sum::<f32>() / input.len() as f32;
let amp_var =
input.iter().map(|a| (a - mean_amp).powi(2)).sum::<f32>() / input.len() as f32;
// If motion detected, emit a placeholder skeleton derived from
// signal characteristics. NOT a real pose.
@@ -274,7 +306,8 @@ impl CsiPipelineState {
let mut keypoints = vec![[0.5f32; 2]; 17];
for (i, kp) in keypoints.iter_mut().enumerate() {
let sub_range = (i * n_sub / 17)..((i + 1) * n_sub / 17).min(n_sub);
let energy: f32 = sub_range.clone()
let energy: f32 = sub_range
.clone()
.filter_map(|s| frames.last().and_then(|f| f.amplitudes.get(s)))
.sum();
let norm_energy = energy / (sub_range.len().max(1) as f32 * 128.0);
@@ -334,9 +367,11 @@ impl CsiPipelineState {
// RSSI statistics
let rssi_mean = rssi_values.iter().sum::<f32>() / rssi_values.len() as f32;
let rssi_var = rssi_values.iter()
let rssi_var = rssi_values
.iter()
.map(|r| (r - rssi_mean).powi(2))
.sum::<f32>() / rssi_values.len() as f32;
.sum::<f32>()
/ rssi_values.len() as f32;
let rssi_std = rssi_var.sqrt();
let fingerprint = CsiFingerprint {
@@ -397,10 +432,8 @@ impl CsiPipelineState {
let mut best: Option<(String, f32)> = None;
for fp in &self.fingerprints {
let sim = cosine_similarity(&current, &fp.mean_amplitudes);
if sim > 0.7 {
if best.as_ref().map_or(true, |(_, s)| sim > *s) {
best = Some((fp.name.clone(), sim));
}
if sim > 0.7 && best.as_ref().is_none_or(|(_, s)| sim > *s) {
best = Some((fp.name.clone(), sim));
}
}
best
@@ -451,12 +484,14 @@ impl CsiPipelineState {
// Normalize
let max = new_occ.iter().cloned().fold(0.0f64, f64::max);
if max > 0.0 {
for d in &mut new_occ { *d /= max; }
for d in &mut new_occ {
*d /= max;
}
}
// Exponential moving average with previous occupancy
for i in 0..total {
self.occupancy[i] = self.occupancy[i] * 0.7 + new_occ[i] * 0.3;
for (occ, &new) in self.occupancy.iter_mut().zip(new_occ.iter()).take(total) {
*occ = *occ * 0.7 + new * 0.3;
}
}
}
@@ -519,7 +554,9 @@ pub fn start_pipeline(bind_addr: &str) -> Arc<Mutex<CsiPipelineState>> {
return;
}
};
socket.set_read_timeout(Some(std::time::Duration::from_secs(1))).unwrap();
socket
.set_read_timeout(Some(std::time::Duration::from_secs(1)))
.unwrap();
eprintln!(" CSI pipeline: listening on {addr}");
let mut buf = [0u8; 2048];
@@ -654,7 +691,10 @@ mod tests {
assert_eq!(s.fingerprints[0].name, "lab");
// Identify against its own fingerprint should succeed.
let found = s.identify_location();
assert!(found.is_some(), "should identify the just-recorded location");
assert!(
found.is_some(),
"should identify the just-recorded location"
);
if let Some((name, conf)) = found {
assert_eq!(name, "lab");
assert!(conf > 0.7, "self-similarity should exceed match threshold");
@@ -1,15 +1,15 @@
//! Monocular depth estimation via MiDaS ONNX + backprojection to 3D points.
#![allow(dead_code)]
use crate::pointcloud::{PointCloud, ColorPoint};
use crate::pointcloud::{ColorPoint, PointCloud};
use anyhow::Result;
/// Default camera intrinsics (approximate for HD webcam)
pub struct CameraIntrinsics {
pub fx: f32, // focal length x (pixels)
pub fy: f32, // focal length y (pixels)
pub cx: f32, // principal point x
pub cy: f32, // principal point y
pub fx: f32, // focal length x (pixels)
pub fy: f32, // focal length y (pixels)
pub cx: f32, // principal point x
pub cy: f32, // principal point y
pub width: u32,
pub height: u32,
}
@@ -17,9 +17,12 @@ pub struct CameraIntrinsics {
impl Default for CameraIntrinsics {
fn default() -> Self {
Self {
fx: 525.0, fy: 525.0, // typical webcam focal length
cx: 320.0, cy: 240.0, // center of 640x480
width: 640, height: 480,
fx: 525.0,
fy: 525.0, // typical webcam focal length
cx: 320.0,
cy: 240.0, // center of 640x480
width: 640,
height: 480,
}
}
}
@@ -45,7 +48,9 @@ pub fn backproject_depth(
let z = depth_map[idx];
// Skip invalid depths
if z <= 0.01 || z > 10.0 || z.is_nan() { continue; }
if z <= 0.01 || z > 10.0 || z.is_nan() {
continue;
}
// Backproject: (u, v, z) → (X, Y, Z)
let px = (x as f32 - intrinsics.cx) * z / intrinsics.fx;
@@ -61,10 +66,22 @@ pub fn backproject_depth(
} else {
// Color by depth (blue=near, red=far)
let t = ((z - 0.5) / 4.0).clamp(0.0, 1.0);
((t * 255.0) as u8, ((1.0 - t) * 128.0) as u8, ((1.0 - t) * 255.0) as u8)
(
(t * 255.0) as u8,
((1.0 - t) * 128.0) as u8,
((1.0 - t) * 255.0) as u8,
)
};
cloud.points.push(ColorPoint { x: px, y: py, z, r, g, b, intensity: 1.0 });
cloud.points.push(ColorPoint {
x: px,
y: py,
z,
r,
g,
b,
intensity: 1.0,
});
}
}
cloud
@@ -73,11 +90,7 @@ pub fn backproject_depth(
/// Run depth estimation on an image.
///
/// Tries MiDaS GPU server (127.0.0.1:9885) first, falls back to luminance+edges.
pub fn estimate_depth(
image_data: &[u8],
width: u32,
height: u32,
) -> Result<Vec<f32>> {
pub fn estimate_depth(image_data: &[u8], width: u32, height: u32) -> Result<Vec<f32>> {
// Try MiDaS GPU server
if let Ok(depth) = estimate_depth_midas_server(image_data, width, height) {
return Ok(depth);
@@ -87,22 +100,28 @@ pub fn estimate_depth(
let w = width as usize;
let h = height as usize;
let mut lum = vec![0.0f32; w * h];
for i in 0..w * h {
for (i, lum_i) in lum.iter_mut().enumerate() {
let ri = i * 3;
if ri + 2 < image_data.len() {
lum[i] = (0.299 * image_data[ri] as f32
+ 0.587 * image_data[ri + 1] as f32
+ 0.114 * image_data[ri + 2] as f32) / 255.0;
*lum_i = (0.299 * image_data[ri] as f32
+ 0.587 * image_data[ri + 1] as f32
+ 0.114 * image_data[ri + 2] as f32)
/ 255.0;
}
}
let mut edges = vec![0.0f32; w * h];
for y in 1..h - 1 {
for x in 1..w - 1 {
let gx = -lum[(y-1)*w+x-1] + lum[(y-1)*w+x+1]
- 2.0*lum[y*w+x-1] + 2.0*lum[y*w+x+1]
- lum[(y+1)*w+x-1] + lum[(y+1)*w+x+1];
let gy = -lum[(y-1)*w+x-1] - 2.0*lum[(y-1)*w+x] - lum[(y-1)*w+x+1]
+ lum[(y+1)*w+x-1] + 2.0*lum[(y+1)*w+x] + lum[(y+1)*w+x+1];
let gx = -lum[(y - 1) * w + x - 1] + lum[(y - 1) * w + x + 1]
- 2.0 * lum[y * w + x - 1]
+ 2.0 * lum[y * w + x + 1]
- lum[(y + 1) * w + x - 1]
+ lum[(y + 1) * w + x + 1];
let gy =
-lum[(y - 1) * w + x - 1] - 2.0 * lum[(y - 1) * w + x] - lum[(y - 1) * w + x + 1]
+ lum[(y + 1) * w + x - 1]
+ 2.0 * lum[(y + 1) * w + x]
+ lum[(y + 1) * w + x + 1];
edges[y * w + x] = (gx * gx + gy * gy).sqrt().min(1.0);
}
}
@@ -118,7 +137,9 @@ pub fn estimate_depth(
/// Call MiDaS depth server running on GPU (127.0.0.1:9885).
fn estimate_depth_midas_server(rgb: &[u8], width: u32, height: u32) -> Result<Vec<f32>> {
let expected = (width * height * 3) as usize;
if rgb.len() < expected { anyhow::bail!("rgb too small"); }
if rgb.len() < expected {
anyhow::bail!("rgb too small");
}
// Send RGB as JSON array to depth server
let rgb_list: Vec<u8> = rgb[..expected].to_vec();
@@ -130,7 +151,8 @@ fn estimate_depth_midas_server(rgb: &[u8], width: u32, height: u32) -> Result<Ve
let body_bytes = serde_json::to_vec(&body)?;
let client = std::net::TcpStream::connect_timeout(
&"127.0.0.1:9885".parse()?, std::time::Duration::from_millis(500)
&"127.0.0.1:9885".parse()?,
std::time::Duration::from_millis(500),
)?;
client.set_read_timeout(Some(std::time::Duration::from_secs(5)))?;
client.set_write_timeout(Some(std::time::Duration::from_secs(2)))?;
@@ -149,14 +171,20 @@ fn estimate_depth_midas_server(rgb: &[u8], width: u32, height: u32) -> Result<Ve
stream.read_to_end(&mut resp)?;
// Skip HTTP headers
let body_start = resp.windows(4).position(|w| w == b"\r\n\r\n")
.map(|p| p + 4).unwrap_or(0);
let body_start = resp
.windows(4)
.position(|w| w == b"\r\n\r\n")
.map(|p| p + 4)
.unwrap_or(0);
let depth_bytes = &resp[body_start..];
let n = (width * height) as usize;
if depth_bytes.len() < n * 4 { anyhow::bail!("depth response too small"); }
if depth_bytes.len() < n * 4 {
anyhow::bail!("depth response too small");
}
let depth: Vec<f32> = depth_bytes[..n * 4].chunks_exact(4)
let depth: Vec<f32> = depth_bytes[..n * 4]
.chunks_exact(4)
.map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]]))
.collect();
@@ -176,7 +204,7 @@ pub fn demo_depth_cloud() -> PointCloud {
let intrinsics = CameraIntrinsics::default();
// Simulate a depth map: room with walls at 3m, floor, and a person at 2m
let w = 160; // downsampled
let w = 160; // downsampled
let h = 120;
let mut depth = vec![3.0f32; w * h];
@@ -218,8 +246,12 @@ mod tests {
fn backproject_2x2_depth_yields_four_points() {
// 2x2 image, depth=1m everywhere; trivial intrinsics.
let intr = CameraIntrinsics {
fx: 1.0, fy: 1.0, cx: 0.5, cy: 0.5,
width: 2, height: 2,
fx: 1.0,
fy: 1.0,
cx: 0.5,
cy: 0.5,
width: 2,
height: 2,
};
let depth = vec![1.0f32; 4];
let cloud = backproject_depth(&depth, &intr, None, 1);
@@ -239,8 +271,12 @@ mod tests {
#[test]
fn backproject_rejects_invalid_depth() {
let intr = CameraIntrinsics {
fx: 1.0, fy: 1.0, cx: 0.5, cy: 0.5,
width: 2, height: 2,
fx: 1.0,
fy: 1.0,
cx: 0.5,
cy: 0.5,
width: 2,
height: 2,
};
// All pixels NaN → no points.
let depth = vec![f32::NAN; 4];
@@ -248,16 +284,3 @@ mod tests {
assert_eq!(cloud.points.len(), 0);
}
}
#[allow(dead_code)]
fn find_midas_model() -> Result<String> {
let paths = [
dirs::home_dir().unwrap_or_default().join(".local/share/ruview/midas_v21_small_256.onnx"),
dirs::home_dir().unwrap_or_default().join(".cache/ruview/midas_v21_small_256.onnx"),
std::path::PathBuf::from("/usr/local/share/ruview/midas_v21_small_256.onnx"),
];
for p in &paths {
if p.exists() { return Ok(p.to_string_lossy().to_string()); }
}
anyhow::bail!("MiDaS ONNX model not found. Download:\n wget https://github.com/isl-org/MiDaS/releases/download/v3_1/midas_v21_small_256.onnx -O ~/.local/share/ruview/midas_v21_small_256.onnx")
}
@@ -1,16 +1,16 @@
//! Multi-modal fusion: camera depth + WiFi RF tomography → unified point cloud.
use crate::pointcloud::{PointCloud, ColorPoint};
use crate::pointcloud::{ColorPoint, PointCloud};
use std::collections::HashMap;
/// Occupancy volume from WiFi RF tomography (mirrors RuView's OccupancyVolume).
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct OccupancyVolume {
pub densities: Vec<f64>, // [nz][ny][nx] voxel densities
pub densities: Vec<f64>, // [nz][ny][nx] voxel densities
pub nx: usize,
pub ny: usize,
pub nz: usize,
pub bounds: [f64; 6], // [x_min, y_min, z_min, x_max, y_max, z_max]
pub bounds: [f64; 6], // [x_min, y_min, z_min, x_max, y_max, z_max]
pub occupied_count: usize,
}
@@ -44,7 +44,9 @@ pub fn occupancy_to_pointcloud(vol: &OccupancyVolume) -> PointCloud {
x: x as f32,
y: y as f32,
z: z as f32,
r, g, b: 50,
r,
g,
b: 50,
intensity: density as f32,
});
}
@@ -58,9 +60,11 @@ pub fn occupancy_to_pointcloud(vol: &OccupancyVolume) -> PointCloud {
///
/// Points from all clouds are binned into voxels of the given size.
/// Each voxel produces one averaged point (position, color, max intensity).
/// Per-voxel accumulator: (sum_x, sum_y, sum_z, sum_r, sum_g, sum_b, max_intensity, count).
type VoxelAccum = (f32, f32, f32, f32, f32, f32, f32, u32);
pub fn fuse_clouds(clouds: &[&PointCloud], voxel_size: f32) -> PointCloud {
let mut cells: HashMap<(i32, i32, i32), (f32, f32, f32, f32, f32, f32, f32, u32)> = HashMap::new();
// (sum_x, sum_y, sum_z, sum_r, sum_g, sum_b, max_intensity, count)
let mut cells: HashMap<(i32, i32, i32), VoxelAccum> = HashMap::new();
for cloud in clouds {
for p in &cloud.points {
@@ -69,7 +73,9 @@ pub fn fuse_clouds(clouds: &[&PointCloud], voxel_size: f32) -> PointCloud {
(p.y / voxel_size).floor() as i32,
(p.z / voxel_size).floor() as i32,
);
let entry = cells.entry(key).or_insert((0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0));
let entry = cells
.entry(key)
.or_insert((0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0));
entry.0 += p.x;
entry.1 += p.y;
entry.2 += p.z;
@@ -82,11 +88,15 @@ pub fn fuse_clouds(clouds: &[&PointCloud], voxel_size: f32) -> PointCloud {
}
let mut fused = PointCloud::new("fused");
for (_, (sx, sy, sz, sr, sg, sb, mi, n)) in &cells {
for (sx, sy, sz, sr, sg, sb, mi, n) in cells.values() {
let n = *n as f32;
fused.points.push(ColorPoint {
x: sx / n, y: sy / n, z: sz / n,
r: (sr / n) as u8, g: (sg / n) as u8, b: (sb / n) as u8,
x: sx / n,
y: sy / n,
z: sz / n,
r: (sr / n) as u8,
g: (sg / n) as u8,
b: (sb / n) as u8,
intensity: *mi,
});
}
@@ -123,7 +133,10 @@ pub fn demo_occupancy() -> OccupancyVolume {
let occupied_count = densities.iter().filter(|&&d| d > 0.3).count();
OccupancyVolume {
densities, nx, ny, nz,
densities,
nx,
ny,
nz,
bounds: [0.0, 0.0, 0.0, 5.0, 5.0, 3.0],
occupied_count,
}
@@ -136,7 +149,15 @@ mod tests {
fn cloud_with(name: &str, pts: &[(f32, f32, f32)]) -> PointCloud {
let mut c = PointCloud::new(name);
for &(x, y, z) in pts {
c.points.push(ColorPoint { x, y, z, r: 10, g: 20, b: 30, intensity: 0.5 });
c.points.push(ColorPoint {
x,
y,
z,
r: 10,
g: 20,
b: 30,
intensity: 0.5,
});
}
c
}
@@ -146,17 +167,20 @@ mod tests {
let a = cloud_with("a", &[(0.0, 0.0, 0.0)]);
let b = cloud_with("b", &[(5.0, 5.0, 5.0)]);
let fused = fuse_clouds(&[&a, &b], 0.1);
assert_eq!(fused.points.len(), 2, "two far-apart points should yield two voxels");
assert_eq!(
fused.points.len(),
2,
"two far-apart points should yield two voxels"
);
}
#[test]
fn fuse_clouds_voxel_dedup() {
// Points all within one voxel must collapse to a single averaged point.
let a = cloud_with("a", &[
(0.01, 0.02, 0.03),
(0.04, 0.01, 0.02),
(0.03, 0.03, 0.01),
]);
let a = cloud_with(
"a",
&[(0.01, 0.02, 0.03), (0.04, 0.01, 0.02), (0.03, 0.03, 0.01)],
);
let fused = fuse_clouds(&[&a], 0.5);
assert_eq!(fused.points.len(), 1, "three close points → one voxel");
}
+35 -17
View File
@@ -107,7 +107,10 @@ async fn main() -> Result<()> {
} else {
let cloud = depth::demo_depth_cloud();
pointcloud::write_ply(&cloud, &output)?;
println!("No camera — wrote {} demo points to {output}", cloud.points.len());
println!(
"No camera — wrote {} demo points to {output}",
cloud.points.len()
);
}
}
Commands::Demo => {
@@ -161,8 +164,13 @@ async fn demo() -> Result<()> {
let occupancy = fusion::demo_occupancy();
let wifi_cloud = fusion::occupancy_to_pointcloud(&occupancy);
println!("WiFi occupancy: {}x{}x{} voxels → {} points",
occupancy.nx, occupancy.ny, occupancy.nz, wifi_cloud.points.len());
println!(
"WiFi occupancy: {}x{}x{} voxels → {} points",
occupancy.nx,
occupancy.ny,
occupancy.nz,
wifi_cloud.points.len()
);
let depth_cloud = depth::demo_depth_cloud();
println!("Camera depth: {} points", depth_cloud.points.len());
@@ -207,13 +215,11 @@ async fn train(data_dir: &str, brain_url: Option<&str>) -> Result<()> {
let depth = depth::estimate_depth(&frame.rgb, frame.width, frame.height)?;
// Score based on depth variance (good frames have varied depth)
let mean: f32 = depth.iter().sum::<f32>() / depth.len() as f32;
let variance: f32 = depth.iter().map(|d| (d - mean).powi(2)).sum::<f32>() / depth.len() as f32;
let variance: f32 =
depth.iter().map(|d| (d - mean).powi(2)).sum::<f32>() / depth.len() as f32;
let quality = (variance / 2.0).min(1.0);
session.add_sample(
Some(depth), frame.width, frame.height,
None, None, quality,
);
session.add_sample(Some(depth), frame.width, frame.height, None, None, quality);
println!(" Frame {}: quality={:.2}", i, quality);
}
std::thread::sleep(std::time::Duration::from_millis(500));
@@ -223,16 +229,23 @@ async fn train(data_dir: &str, brain_url: Option<&str>) -> Result<()> {
for i in 0..10 {
let w = 160u32;
let h = 120u32;
let depth: Vec<f32> = (0..w * h).map(|j| 1.0 + (j as f32 / (w * h) as f32) * 4.0 + (i as f32 * 0.1)).collect();
let depth: Vec<f32> = (0..w * h)
.map(|j| 1.0 + (j as f32 / (w * h) as f32) * 4.0 + (i as f32 * 0.1))
.collect();
let quality = if i < 7 { 0.8 } else { 0.2 };
let gt = if i % 3 == 0 {
Some(training::GroundTruth {
reference_distances: vec![
training::ReferencePoint { name: "wall".into(), x_pixel: 80, y_pixel: 60, true_distance_m: 3.0 },
],
reference_distances: vec![training::ReferencePoint {
name: "wall".into(),
x_pixel: 80,
y_pixel: 60,
true_distance_m: 3.0,
}],
occupancy_label: Some(if i < 5 { "occupied" } else { "empty" }.into()),
})
} else { None };
} else {
None
};
session.add_sample(Some(depth), w, h, None, gt, quality);
}
}
@@ -242,14 +255,19 @@ async fn train(data_dir: &str, brain_url: Option<&str>) -> Result<()> {
// Calibrate depth
println!("\n==> Calibrating depth estimation...");
let cal = session.calibrate_depth()?;
println!(" Result: scale={:.2} offset={:.2} gamma={:.2} RMSE={:.4}m",
cal.scale, cal.offset, cal.gamma, cal.rmse);
println!(
" Result: scale={:.2} offset={:.2} gamma={:.2} RMSE={:.4}m",
cal.scale, cal.offset, cal.gamma, cal.rmse
);
// Train occupancy
println!("\n==> Training occupancy model...");
let occ_cal = session.train_occupancy()?;
println!(" Result: threshold={:.2} accuracy={:.1}%",
occ_cal.density_threshold, occ_cal.accuracy * 100.0);
println!(
" Result: threshold={:.2} accuracy={:.1}%",
occ_cal.density_threshold,
occ_cal.accuracy * 100.0
);
// Export preference pairs
println!("\n==> Exporting preference pairs...");
@@ -43,10 +43,14 @@ pub struct CsiFrame {
/// - the magic does not match either accepted value
/// - the declared I/Q payload is truncated
pub fn parse_adr018(data: &[u8]) -> Option<CsiFrame> {
if data.len() < CSI_HEADER_SIZE { return None; }
if data.len() < CSI_HEADER_SIZE {
return None;
}
let magic = u32::from_le_bytes([data[0], data[1], data[2], data[3]]);
if magic != CSI_MAGIC_V6 && magic != CSI_MAGIC_V1 { return None; }
if magic != CSI_MAGIC_V6 && magic != CSI_MAGIC_V1 {
return None;
}
let node_id = data[4];
let n_antennas = data[5].max(1);
@@ -57,10 +61,14 @@ pub fn parse_adr018(data: &[u8]) -> Option<CsiFrame> {
let timestamp_us = u32::from_le_bytes([data[16], data[17], data[18], data[19]]);
let iq_len = (n_subcarriers as usize) * 2 * (n_antennas as usize);
if data.len() < CSI_HEADER_SIZE + iq_len { return None; }
if data.len() < CSI_HEADER_SIZE + iq_len {
return None;
}
let iq_data: Vec<i8> = data[CSI_HEADER_SIZE..CSI_HEADER_SIZE + iq_len]
.iter().map(|&b| b as i8).collect();
.iter()
.map(|&b| b as i8)
.collect();
// Compute amplitude and phase per subcarrier (first antenna).
let mut amplitudes = Vec::with_capacity(n_subcarriers as usize);
@@ -76,8 +84,16 @@ pub fn parse_adr018(data: &[u8]) -> Option<CsiFrame> {
}
Some(CsiFrame {
node_id, n_antennas, n_subcarriers, channel, rssi, noise_floor,
timestamp_us, iq_data, amplitudes, phases,
node_id,
n_antennas,
n_subcarriers,
channel,
rssi,
noise_floor,
timestamp_us,
iq_data,
amplitudes,
phases,
})
}
@@ -85,15 +101,15 @@ pub fn parse_adr018(data: &[u8]) -> Option<CsiFrame> {
/// subcommand and by the unit tests in this module.
pub fn build_test_frame(magic: u32, node_id: u8, n_subcarriers: u16, i: usize) -> Vec<u8> {
let mut buf = Vec::with_capacity(CSI_HEADER_SIZE + (n_subcarriers as usize) * 2);
buf.extend_from_slice(&magic.to_le_bytes()); // magic (0..4)
buf.push(node_id); // node_id (4)
buf.push(1u8); // n_antennas (5)
buf.extend_from_slice(&n_subcarriers.to_le_bytes()); // n_subcarriers (6..8)
buf.push(6u8); // channel (8)
buf.push((-40i8 - (i % 30) as i8) as u8); // rssi (9)
buf.push((-90i8) as u8); // noise_floor (10)
buf.extend_from_slice(&[0u8; 5]); // reserved (11..16)
buf.extend_from_slice(&(i as u32).to_le_bytes()); // timestamp_us (16..20)
buf.extend_from_slice(&magic.to_le_bytes()); // magic (0..4)
buf.push(node_id); // node_id (4)
buf.push(1u8); // n_antennas (5)
buf.extend_from_slice(&n_subcarriers.to_le_bytes()); // n_subcarriers (6..8)
buf.push(6u8); // channel (8)
buf.push((-40i8 - (i % 30) as i8) as u8); // rssi (9)
buf.push((-90i8) as u8); // noise_floor (10)
buf.extend_from_slice(&[0u8; 5]); // reserved (11..16)
buf.extend_from_slice(&(i as u32).to_le_bytes()); // timestamp_us (16..20)
for j in 0..(n_subcarriers as usize) {
buf.push(((i + j) as i8).wrapping_mul(3) as u8);
buf.push(((i + j) as i8).wrapping_mul(5) as u8);
@@ -150,7 +166,10 @@ mod tests {
#[test]
fn parse_rejects_truncated_header() {
let short = vec![0u8; CSI_HEADER_SIZE - 1];
assert!(parse_adr018(&short).is_none(), "truncated header must not parse");
assert!(
parse_adr018(&short).is_none(),
"truncated header must not parse"
);
}
#[test]
@@ -158,6 +177,9 @@ mod tests {
let mut frame = build_test_frame(MAGIC_V1, 0, 32, 0);
// Drop half the declared payload.
frame.truncate(CSI_HEADER_SIZE + 20);
assert!(parse_adr018(&frame).is_none(), "truncated payload must not parse");
assert!(
parse_adr018(&frame).is_none(),
"truncated payload must not parse"
);
}
}
@@ -38,8 +38,17 @@ impl PointCloud {
}
}
#[allow(clippy::too_many_arguments)]
pub fn add(&mut self, x: f32, y: f32, z: f32, r: u8, g: u8, b: u8, intensity: f32) {
self.points.push(ColorPoint { x, y, z, r, g, b, intensity });
self.points.push(ColorPoint {
x,
y,
z,
r,
g,
b,
intensity,
});
}
pub fn bounds(&self) -> ([f32; 3], [f32; 3]) {
@@ -49,8 +58,12 @@ impl PointCloud {
let mut min = [f32::MAX; 3];
let mut max = [f32::MIN; 3];
for p in &self.points {
min[0] = min[0].min(p.x); min[1] = min[1].min(p.y); min[2] = min[2].min(p.z);
max[0] = max[0].max(p.x); max[1] = max[1].max(p.y); max[2] = max[2].max(p.z);
min[0] = min[0].min(p.x);
min[1] = min[1].min(p.y);
min[2] = min[2].min(p.z);
max[0] = max[0].max(p.x);
max[1] = max[1].max(p.y);
max[2] = max[2].max(p.z);
}
(min, max)
}
@@ -74,7 +87,11 @@ pub fn write_ply(cloud: &PointCloud, path: &str) -> anyhow::Result<()> {
writeln!(f, "property float intensity")?;
writeln!(f, "end_header")?;
for p in &cloud.points {
writeln!(f, "{:.4} {:.4} {:.4} {} {} {} {:.4}", p.x, p.y, p.z, p.r, p.g, p.b, p.intensity)?;
writeln!(
f,
"{:.4} {:.4} {:.4} {} {} {} {:.4}",
p.x, p.y, p.z, p.r, p.g, p.b, p.intensity
)?;
}
Ok(())
}
@@ -90,8 +107,9 @@ pub struct GaussianSplat {
pub fn to_gaussian_splats(cloud: &PointCloud) -> Vec<GaussianSplat> {
// Cluster points into voxels and create one Gaussian per cluster
let voxel_size = 0.08; // smaller voxels = more detail = visible movement
let mut cells: std::collections::HashMap<(i32, i32, i32), Vec<&ColorPoint>> = std::collections::HashMap::new();
let voxel_size = 0.08; // smaller voxels = more detail = visible movement
let mut cells: std::collections::HashMap<(i32, i32, i32), Vec<&ColorPoint>> =
std::collections::HashMap::new();
for p in &cloud.points {
let key = (
@@ -102,25 +120,28 @@ pub fn to_gaussian_splats(cloud: &PointCloud) -> Vec<GaussianSplat> {
cells.entry(key).or_default().push(p);
}
cells.values().map(|pts| {
let n = pts.len() as f32;
let cx = pts.iter().map(|p| p.x).sum::<f32>() / n;
let cy = pts.iter().map(|p| p.y).sum::<f32>() / n;
let cz = pts.iter().map(|p| p.z).sum::<f32>() / n;
let cr = pts.iter().map(|p| p.r as f32).sum::<f32>() / n / 255.0;
let cg = pts.iter().map(|p| p.g as f32).sum::<f32>() / n / 255.0;
let cb = pts.iter().map(|p| p.b as f32).sum::<f32>() / n / 255.0;
cells
.values()
.map(|pts| {
let n = pts.len() as f32;
let cx = pts.iter().map(|p| p.x).sum::<f32>() / n;
let cy = pts.iter().map(|p| p.y).sum::<f32>() / n;
let cz = pts.iter().map(|p| p.z).sum::<f32>() / n;
let cr = pts.iter().map(|p| p.r as f32).sum::<f32>() / n / 255.0;
let cg = pts.iter().map(|p| p.g as f32).sum::<f32>() / n / 255.0;
let cb = pts.iter().map(|p| p.b as f32).sum::<f32>() / n / 255.0;
// Scale based on point spread
let sx = pts.iter().map(|p| (p.x - cx).abs()).sum::<f32>() / n + 0.01;
let sy = pts.iter().map(|p| (p.y - cy).abs()).sum::<f32>() / n + 0.01;
let sz = pts.iter().map(|p| (p.z - cz).abs()).sum::<f32>() / n + 0.01;
// Scale based on point spread
let sx = pts.iter().map(|p| (p.x - cx).abs()).sum::<f32>() / n + 0.01;
let sy = pts.iter().map(|p| (p.y - cy).abs()).sum::<f32>() / n + 0.01;
let sz = pts.iter().map(|p| (p.z - cz).abs()).sum::<f32>() / n + 0.01;
GaussianSplat {
center: [cx, cy, cz],
color: [cr, cg, cb],
opacity: (n / 10.0).min(1.0),
scale: [sx, sy, sz],
}
}).collect()
GaussianSplat {
center: [cx, cy, cz],
color: [cr, cg, cb],
opacity: (n / 10.0).min(1.0),
scale: [sx, sy, sz],
}
})
.collect()
}
@@ -76,7 +76,8 @@ pub async fn serve(bind: &str, _brain: Option<&str>) -> anyhow::Result<()> {
let (cloud, luminance) = if bg_cam && !skip_depth {
tokio::task::spawn_blocking(capture_camera_cloud_with_luminance)
.await.unwrap_or_else(|_| (demo_cloud(), None))
.await
.unwrap_or_else(|_| (demo_cloud(), None))
} else {
// Reuse previous cloud when no motion
(bg.latest_cloud.lock().unwrap().clone(), None)
@@ -107,8 +108,11 @@ pub async fn serve(bind: &str, _brain: Option<&str>) -> anyhow::Result<()> {
}
});
if has_camera { eprintln!(" Camera: LIVE (/dev/video0)"); }
else { eprintln!(" Camera: DEMO"); }
if has_camera {
eprintln!(" Camera: LIVE (/dev/video0)");
} else {
eprintln!(" Camera: DEMO");
}
// CORS — allow the hosted GitHub Pages viewer to fetch /api/splats from a
// locally-running instance of this server. Modern browsers treat
@@ -173,12 +177,14 @@ fn capture_camera_cloud_with_luminance() -> (pointcloud::PointCloud, Option<f32>
let mut sum = 0.0f64;
let mut n = 0usize;
for chunk in frame.rgb.chunks_exact(3).take(pixels) {
sum += 0.299 * chunk[0] as f64
+ 0.587 * chunk[1] as f64
+ 0.114 * chunk[2] as f64;
sum += 0.299 * chunk[0] as f64 + 0.587 * chunk[1] as f64 + 0.114 * chunk[2] as f64;
n += 1;
}
let lum = if n > 0 { Some((sum / n as f64) as f32) } else { None };
let lum = if n > 0 {
Some((sum / n as f64) as f32)
} else {
None
};
let cloud = match depth::estimate_depth(&frame.rgb, frame.width, frame.height) {
Ok(dm) => {
@@ -255,4 +261,3 @@ static VIEWER_HTML: &str = include_str!("viewer.html");
async fn index() -> Html<&'static str> {
Html(VIEWER_HTML)
}
@@ -48,7 +48,8 @@ fn safe_join(base: &Path, child: &str) -> Result<PathBuf> {
let joined = base.join(child_path);
// Canonicalise base (must exist) and verify joined starts with it. If the
// joined file doesn't exist yet we canonicalise the parent.
let canonical_base = base.canonicalize()
let canonical_base = base
.canonicalize()
.map_err(|e| anyhow!("data_dir not accessible {}: {e}", base.display()))?;
let canonical_parent = joined
.parent()
@@ -63,7 +64,9 @@ fn safe_join(base: &Path, child: &str) -> Result<PathBuf> {
));
}
Ok(canonical_parent.join(
joined.file_name().ok_or_else(|| anyhow!("no filename for {}", joined.display()))?,
joined
.file_name()
.ok_or_else(|| anyhow!("no filename for {}", joined.display()))?,
))
}
@@ -96,7 +99,9 @@ impl From<&OccupancyVolume> for OccupancyData {
fn from(vol: &OccupancyVolume) -> Self {
Self {
densities: vol.densities.clone(),
nx: vol.nx, ny: vol.ny, nz: vol.nz,
nx: vol.nx,
ny: vol.ny,
nz: vol.nz,
}
}
}
@@ -127,13 +132,13 @@ pub struct TrainingSession {
/// Depth calibration parameters — maps luminance to real depth.
#[derive(Clone, Serialize, Deserialize)]
pub struct DepthCalibration {
pub scale: f32, // multiplier for depth values
pub offset: f32, // additive offset
pub near_clip: f32, // minimum valid depth
pub far_clip: f32, // maximum valid depth
pub gamma: f32, // nonlinear correction (luminance^gamma → depth)
pub scale: f32, // multiplier for depth values
pub offset: f32, // additive offset
pub near_clip: f32, // minimum valid depth
pub far_clip: f32, // maximum valid depth
pub gamma: f32, // nonlinear correction (luminance^gamma → depth)
pub samples_used: u32,
pub rmse: f32, // root mean square error against ground truth
pub rmse: f32, // root mean square error against ground truth
}
impl Default for DepthCalibration {
@@ -215,14 +220,21 @@ impl TrainingSession {
let mut best_rmse = f32::MAX;
// Collect all reference points across samples
let refs: Vec<(f32, f32)> = self.samples.iter()
let refs: Vec<(f32, f32)> = self
.samples
.iter()
.filter_map(|s| {
let gt = s.ground_truth.as_ref()?;
let dm = s.depth_map.as_ref()?;
Some(gt.reference_distances.iter().filter_map(|rp| {
let idx = (rp.y_pixel * s.depth_width + rp.x_pixel) as usize;
dm.get(idx).map(|&est| (est, rp.true_distance_m))
}).collect::<Vec<_>>())
Some(
gt.reference_distances
.iter()
.filter_map(|rp| {
let idx = (rp.y_pixel * s.depth_width + rp.x_pixel) as usize;
dm.get(idx).map(|&est| (est, rp.true_distance_m))
})
.collect::<Vec<_>>(),
)
})
.flatten()
.collect();
@@ -242,19 +254,24 @@ impl TrainingSession {
for gamma_i in 5..15 {
let gamma = gamma_i as f32 * 0.2;
let rmse = refs.iter()
let rmse = refs
.iter()
.map(|&(est, truth)| {
let calibrated = offset + est.powf(gamma) * scale;
(calibrated - truth).powi(2)
})
.sum::<f32>() / refs.len() as f32;
.sum::<f32>()
/ refs.len() as f32;
let rmse = rmse.sqrt();
if rmse < best_rmse {
best_rmse = rmse;
best = DepthCalibration {
scale, offset, gamma,
near_clip: 0.3, far_clip: 8.0,
scale,
offset,
gamma,
near_clip: 0.3,
far_clip: 8.0,
samples_used: refs.len() as u32,
rmse,
};
@@ -263,8 +280,10 @@ impl TrainingSession {
}
}
eprintln!(" Best calibration: scale={:.2} offset={:.2} gamma={:.2} RMSE={:.4}m",
best.scale, best.offset, best.gamma, best.rmse);
eprintln!(
" Best calibration: scale={:.2} offset={:.2} gamma={:.2} RMSE={:.4}m",
best.scale, best.offset, best.gamma, best.rmse
);
self.calibration = best.clone();
self.save_calibration()?;
@@ -276,8 +295,15 @@ impl TrainingSession {
/// Uses samples with known occupancy labels to optimize the
/// attenuation-to-density mapping.
pub fn train_occupancy(&self) -> Result<OccupancyCalibration> {
let labeled: Vec<&TrainingSample> = self.samples.iter()
.filter(|s| s.ground_truth.as_ref().and_then(|g| g.occupancy_label.as_ref()).is_some())
let labeled: Vec<&TrainingSample> = self
.samples
.iter()
.filter(|s| {
s.ground_truth
.as_ref()
.and_then(|g| g.occupancy_label.as_ref())
.is_some()
})
.collect();
if labeled.is_empty() {
@@ -285,7 +311,10 @@ impl TrainingSession {
return Ok(OccupancyCalibration::default());
}
eprintln!(" Training occupancy model with {} samples...", labeled.len());
eprintln!(
" Training occupancy model with {} samples...",
labeled.len()
);
// Simple threshold optimization — find the density threshold
// that best separates occupied vs unoccupied
@@ -299,11 +328,18 @@ impl TrainingSession {
for sample in &labeled {
if let Some(ref occ) = sample.occupancy {
let label = sample.ground_truth.as_ref().unwrap()
.occupancy_label.as_ref().unwrap();
let label = sample
.ground_truth
.as_ref()
.unwrap()
.occupancy_label
.as_ref()
.unwrap();
let is_occupied = label == "occupied" || label == "present";
let detected = occ.densities.iter().any(|&d| d > threshold);
if detected == is_occupied { correct += 1; }
if detected == is_occupied {
correct += 1;
}
total += 1;
}
}
@@ -321,7 +357,11 @@ impl TrainingSession {
samples_used: labeled.len() as u32,
};
eprintln!(" Occupancy threshold={:.2} accuracy={:.1}%", cal.density_threshold, cal.accuracy * 100.0);
eprintln!(
" Occupancy threshold={:.2} accuracy={:.1}%",
cal.density_threshold,
cal.accuracy * 100.0
);
// Save (path-traversal safe: constant filename under canonical data_dir)
let path = safe_join(&self.data_dir, "occupancy_calibration.json")?;
@@ -337,12 +377,8 @@ impl TrainingSession {
pub fn export_preference_pairs(&self) -> Result<Vec<PreferencePair>> {
let mut pairs = Vec::new();
let good: Vec<&TrainingSample> = self.samples.iter()
.filter(|s| s.quality > 0.7)
.collect();
let bad: Vec<&TrainingSample> = self.samples.iter()
.filter(|s| s.quality < 0.3)
.collect();
let good: Vec<&TrainingSample> = self.samples.iter().filter(|s| s.quality > 0.7).collect();
let bad: Vec<&TrainingSample> = self.samples.iter().filter(|s| s.quality < 0.3).collect();
for (g, b) in good.iter().zip(bad.iter()) {
pairs.push(PreferencePair {
@@ -369,7 +405,11 @@ impl TrainingSession {
writeln!(f, "{}", serde_json::to_string(pair)?)?;
}
eprintln!(" Exported {} preference pairs to {}", pairs.len(), path.display());
eprintln!(
" Exported {} preference pairs to {}",
pairs.len(),
path.display()
);
Ok(pairs)
}
@@ -389,8 +429,13 @@ impl TrainingSession {
self.calibration.scale, self.calibration.offset, self.calibration.gamma,
self.calibration.rmse, self.calibration.samples_used),
});
if client.post(format!("{brain_url}/memories"))
.json(&body).send().await.is_ok() {
if client
.post(format!("{brain_url}/memories"))
.json(&body)
.send()
.await
.is_ok()
{
stored += 1;
}
@@ -403,8 +448,13 @@ impl TrainingSession {
sample.quality,
sample.occupancy.as_ref().map(|o| format!("{}x{}x{}", o.nx, o.ny, o.nz)).unwrap_or("none".into())),
});
if client.post(format!("{brain_url}/memories"))
.json(&body).send().await.is_ok() {
if client
.post(format!("{brain_url}/memories"))
.json(&body)
.send()
.await
.is_ok()
{
stored += 1;
}
}
@@ -424,7 +474,11 @@ impl TrainingSession {
pub fn save_samples(&self) -> Result<()> {
let path = safe_join(&self.data_dir, "samples.json")?;
std::fs::write(&path, serde_json::to_string_pretty(&self.samples)?)?;
eprintln!(" Saved {} samples to {}", self.samples.len(), path.display());
eprintln!(
" Saved {} samples to {}",
self.samples.len(),
path.display()
);
Ok(())
}
@@ -449,7 +503,11 @@ pub struct OccupancyCalibration {
impl Default for OccupancyCalibration {
fn default() -> Self {
Self { density_threshold: 0.3, accuracy: 0.0, samples_used: 0 }
Self {
density_threshold: 0.3,
accuracy: 0.0,
samples_used: 0,
}
}
}
@@ -467,7 +525,10 @@ mod tests {
fn sanitize_rejects_parent_dir_traversal() {
assert!(sanitize_data_path("../etc/passwd").is_err());
assert!(sanitize_data_path("foo/../bar").is_err());
assert!(sanitize_data_path("/tmp/.. /evil").is_ok(), "`.. ` is not ParentDir");
assert!(
sanitize_data_path("/tmp/.. /evil").is_ok(),
"`.. ` is not ParentDir"
);
}
#[test]