//! 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, }, /// 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, }, /// 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::() / depth.len() as f32; let variance: f32 = depth.iter().map(|d| (d - mean).powi(2)).sum::() / 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 = (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(()) }