mirror of
https://github.com/ruvnet/RuView
synced 2026-08-10 20:31:42 +00:00
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:
@@ -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(¤t, &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");
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user