291 lines
11 KiB
Rust
291 lines
11 KiB
Rust
//! ruview-pointcloud — real-time dense point cloud from camera + WiFi CSI
|
|
//!
|
|
//! Pipeline: Camera → Depth → Backproject → Fuse with WiFi occupancy → Stream
|
|
//!
|
|
//! Usage:
|
|
//! ruview-pointcloud serve # HTTP + Three.js viewer
|
|
//! ruview-pointcloud capture --frames 1 # capture to PLY
|
|
//! ruview-pointcloud demo # synthetic demo
|
|
//! ruview-pointcloud train # calibration training
|
|
//! ruview-pointcloud csi-test # send test CSI frames (ADR-018 binary)
|
|
|
|
mod brain_bridge;
|
|
mod camera;
|
|
mod csi_pipeline;
|
|
mod depth;
|
|
mod fusion;
|
|
mod parser;
|
|
mod pointcloud;
|
|
mod stream;
|
|
mod training;
|
|
|
|
use anyhow::Result;
|
|
use clap::{Parser, Subcommand};
|
|
|
|
const VERSION: &str = env!("CARGO_PKG_VERSION");
|
|
|
|
#[derive(Parser)]
|
|
#[command(name = "ruview-pointcloud", version = VERSION)]
|
|
struct Cli {
|
|
#[command(subcommand)]
|
|
command: Commands,
|
|
}
|
|
|
|
#[derive(Subcommand)]
|
|
enum Commands {
|
|
/// Start real-time point cloud server.
|
|
///
|
|
/// By default the HTTP server binds to `127.0.0.1:9880` — exposing it on
|
|
/// `0.0.0.0` leaks live camera/CSI/vitals data to the network and must
|
|
/// be an explicit opt-in via `--bind 0.0.0.0:9880`.
|
|
Serve {
|
|
/// Bind address for the HTTP/viewer server. Default
|
|
/// `127.0.0.1:9880` (loopback only — safe by default).
|
|
#[arg(long, default_value = "127.0.0.1:9880")]
|
|
bind: String,
|
|
/// Brain URL for storing observations
|
|
#[arg(long)]
|
|
brain: Option<String>,
|
|
},
|
|
/// Capture frames to PLY file
|
|
Capture {
|
|
#[arg(long, default_value = "1")]
|
|
frames: usize,
|
|
#[arg(long, default_value = "output.ply")]
|
|
output: String,
|
|
},
|
|
/// Generate demo point cloud
|
|
Demo,
|
|
/// List available cameras
|
|
Cameras,
|
|
/// Training and calibration
|
|
Train {
|
|
#[arg(long, default_value = "~/.local/share/ruview/training")]
|
|
data_dir: String,
|
|
/// Brain URL for submitting results
|
|
#[arg(long)]
|
|
brain: Option<String>,
|
|
},
|
|
/// Send synthetic ADR-018 binary CSI frames (for local testing without ESP32).
|
|
CsiTest {
|
|
#[arg(long, default_value = "127.0.0.1:3333")]
|
|
target: String,
|
|
#[arg(long, default_value = "100")]
|
|
count: usize,
|
|
},
|
|
/// Record a CSI fingerprint for the current location.
|
|
///
|
|
/// Listens on UDP 3333 for `--seconds` seconds, accumulates CSI frames,
|
|
/// and stores a named fingerprint that future sessions can match
|
|
/// against to identify the room.
|
|
Fingerprint {
|
|
/// Human-readable name for the fingerprint (e.g. "office", "lab").
|
|
name: String,
|
|
/// How long to listen before recording (default 5 s).
|
|
#[arg(long, default_value = "5")]
|
|
seconds: u64,
|
|
},
|
|
}
|
|
|
|
#[tokio::main]
|
|
async fn main() -> Result<()> {
|
|
let cli = Cli::parse();
|
|
|
|
match cli.command {
|
|
Commands::Serve { bind, brain } => {
|
|
stream::serve(&bind, brain.as_deref()).await?;
|
|
}
|
|
Commands::Capture { frames: _, output } => {
|
|
if camera::camera_available() {
|
|
let config = camera::CameraConfig::default();
|
|
let frame = camera::capture_frame(&config)?;
|
|
let depth = depth::estimate_depth(&frame.rgb, frame.width, frame.height)?;
|
|
let intrinsics = depth::CameraIntrinsics::default();
|
|
let cloud = depth::backproject_depth(&depth, &intrinsics, Some(&frame.rgb), 2);
|
|
pointcloud::write_ply(&cloud, &output)?;
|
|
println!("Captured {} points to {output}", cloud.points.len());
|
|
} else {
|
|
let cloud = depth::demo_depth_cloud();
|
|
pointcloud::write_ply(&cloud, &output)?;
|
|
println!(
|
|
"No camera — wrote {} demo points to {output}",
|
|
cloud.points.len()
|
|
);
|
|
}
|
|
}
|
|
Commands::Demo => {
|
|
demo().await?;
|
|
}
|
|
Commands::Cameras => {
|
|
let cams = camera::list_cameras();
|
|
if cams.is_empty() {
|
|
println!("No cameras found");
|
|
} else {
|
|
println!("Available cameras:");
|
|
for (i, c) in cams.iter().enumerate() {
|
|
println!(" [{i}] {c}");
|
|
}
|
|
}
|
|
}
|
|
Commands::Train { data_dir, brain } => {
|
|
train(&data_dir, brain.as_deref()).await?;
|
|
}
|
|
Commands::CsiTest { target, count } => {
|
|
println!("Sending {count} synthetic ADR-018 CSI frames to {target}...");
|
|
csi_pipeline::send_test_frames(&target, count)?;
|
|
println!("Done");
|
|
}
|
|
Commands::Fingerprint { name, seconds } => {
|
|
println!("Recording CSI fingerprint '{name}' for {seconds} s on UDP 3333...");
|
|
let state = csi_pipeline::start_pipeline("0.0.0.0:3333");
|
|
std::thread::sleep(std::time::Duration::from_secs(seconds));
|
|
// record_fingerprint takes a brief lock on the shared state to
|
|
// read the last N frames from every node's history.
|
|
{
|
|
let mut st = state.lock().expect("pipeline state lock poisoned");
|
|
st.record_fingerprint(&name);
|
|
println!(
|
|
" Stored: {} fingerprint(s) total, {} total CSI frames received",
|
|
st.fingerprints.len(),
|
|
st.total_frames
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn demo() -> Result<()> {
|
|
println!("╔══════════════════════════════════════════════╗");
|
|
println!("║ RuView Dense Point Cloud — Demo ║");
|
|
println!("╚══════════════════════════════════════════════╝");
|
|
println!();
|
|
|
|
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()
|
|
);
|
|
|
|
let depth_cloud = depth::demo_depth_cloud();
|
|
println!("Camera depth: {} points", depth_cloud.points.len());
|
|
|
|
let fused = fusion::fuse_clouds(&[&wifi_cloud, &depth_cloud], 0.05);
|
|
println!("Fused: {} points (voxel size=0.05m)", fused.points.len());
|
|
|
|
pointcloud::write_ply(&fused, "demo_pointcloud.ply")?;
|
|
println!("\nWrote: demo_pointcloud.ply");
|
|
|
|
let splats = pointcloud::to_gaussian_splats(&fused);
|
|
let json = serde_json::to_string_pretty(&splats)?;
|
|
std::fs::write("demo_splats.json", &json)?;
|
|
println!("Wrote: demo_splats.json ({} splats)", splats.len());
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn train(data_dir: &str, brain_url: Option<&str>) -> Result<()> {
|
|
println!("╔══════════════════════════════════════════════╗");
|
|
println!("║ RuView Point Cloud — Training ║");
|
|
println!("╚══════════════════════════════════════════════╝");
|
|
println!();
|
|
|
|
let expanded = data_dir.replace('~', &dirs::home_dir().unwrap_or_default().to_string_lossy());
|
|
// Defence-in-depth: reject path-traversal in the CLI argument before we
|
|
// hand it to TrainingSession (which also checks). This catches malicious
|
|
// CLI input early, before any I/O.
|
|
let _sanitised = training::sanitize_data_path(&expanded)?;
|
|
let mut session = training::TrainingSession::new(&expanded)?;
|
|
session.load_samples()?;
|
|
|
|
// Capture training samples
|
|
println!("==> Capturing training samples...");
|
|
|
|
// Camera samples
|
|
if camera::camera_available() {
|
|
println!(" Camera detected — capturing depth frames...");
|
|
let config = camera::CameraConfig::default();
|
|
for i in 0..5 {
|
|
if let Ok(frame) = camera::capture_frame(&config) {
|
|
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 quality = (variance / 2.0).min(1.0);
|
|
|
|
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));
|
|
}
|
|
} else {
|
|
println!(" No camera — using synthetic samples for calibration demo");
|
|
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 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,
|
|
}],
|
|
occupancy_label: Some(if i < 5 { "occupied" } else { "empty" }.into()),
|
|
})
|
|
} else {
|
|
None
|
|
};
|
|
session.add_sample(Some(depth), w, h, None, gt, quality);
|
|
}
|
|
}
|
|
|
|
session.save_samples()?;
|
|
|
|
// 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
|
|
);
|
|
|
|
// 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
|
|
);
|
|
|
|
// Export preference pairs
|
|
println!("\n==> Exporting preference pairs...");
|
|
let pairs = session.export_preference_pairs()?;
|
|
println!(" Exported: {} pairs", pairs.len());
|
|
|
|
// Submit to brain if available
|
|
if let Some(url) = brain_url {
|
|
println!("\n==> Submitting to brain at {url}...");
|
|
let stored = session.submit_to_brain(url).await?;
|
|
println!(" Stored: {} observations", stored);
|
|
}
|
|
|
|
println!("\n==> Training complete!");
|
|
println!(" Data dir: {expanded}");
|
|
println!(" Samples: {}", session.samples.len());
|
|
println!(" Calibration: {expanded}/calibration.json");
|
|
|
|
Ok(())
|
|
}
|