Wire live camera into server — real-time updating point cloud

- Server captures from /dev/video0 at 2fps via ffmpeg
- Background tokio task refreshes cloud + splats every 500ms
- Viewer polls /api/splats every 500ms, only updates on new frame
- Shows 🟢 LIVE / 🔴 DEMO indicator
- Camera position set for first-person view (looking forward into scene)
- Downsample 4x for performance (19,200 points per frame)
- Graceful fallback to demo data if camera capture fails

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
ruv 2026-04-19 18:20:00 -04:00
parent de5dc9a151
commit f39d88e711
1 changed files with 99 additions and 48 deletions

View File

@ -1,5 +1,6 @@
//! WebSocket + HTTP server for real-time point cloud streaming. //! HTTP server for real-time point cloud streaming with live camera + CSI.
use crate::camera;
use crate::depth; use crate::depth;
use crate::fusion; use crate::fusion;
use crate::pointcloud; use crate::pointcloud;
@ -9,17 +10,57 @@ use axum::{
routing::get, routing::get,
Json, Router, Json, Router,
}; };
use std::sync::Arc; use std::sync::{Arc, Mutex};
struct AppState { struct AppState {
wifi_source: Option<String>, /// Cached latest point cloud (refreshed by background task)
latest_cloud: Mutex<pointcloud::PointCloud>,
latest_splats: Mutex<Vec<pointcloud::GaussianSplat>>,
frame_count: Mutex<u64>,
use_camera: bool,
} }
pub async fn serve(host: &str, port: u16, wifi_source: Option<&str>) -> anyhow::Result<()> { pub async fn serve(host: &str, port: u16, _wifi_source: Option<&str>) -> anyhow::Result<()> {
let has_camera = camera::camera_available();
let initial_cloud = if has_camera {
capture_live_cloud()
} else {
let occ = fusion::demo_occupancy();
let wc = fusion::occupancy_to_pointcloud(&occ);
let dc = depth::demo_depth_cloud();
fusion::fuse_clouds(&[&wc, &dc], 0.05)
};
let initial_splats = pointcloud::to_gaussian_splats(&initial_cloud);
let state = Arc::new(AppState { let state = Arc::new(AppState {
wifi_source: wifi_source.map(|s| s.to_string()), latest_cloud: Mutex::new(initial_cloud),
latest_splats: Mutex::new(initial_splats),
frame_count: Mutex::new(0),
use_camera: has_camera,
}); });
// Background: capture frames every 500ms
if has_camera {
let bg = state.clone();
tokio::spawn(async move {
loop {
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
let cloud = tokio::task::spawn_blocking(capture_live_cloud).await.unwrap_or_else(|_| {
let occ = fusion::demo_occupancy();
let dc = depth::demo_depth_cloud();
fusion::fuse_clouds(&[&fusion::occupancy_to_pointcloud(&occ), &dc], 0.05)
});
let splats = pointcloud::to_gaussian_splats(&cloud);
*bg.latest_cloud.lock().unwrap() = cloud;
*bg.latest_splats.lock().unwrap() = splats;
*bg.frame_count.lock().unwrap() += 1;
}
});
eprintln!(" Camera: LIVE (/dev/video0, 2 fps capture)");
} else {
eprintln!(" Camera: DEMO (no /dev/video0)");
}
let app = Router::new() let app = Router::new()
.route("/", get(index)) .route("/", get(index))
.route("/api/cloud", get(api_cloud)) .route("/api/cloud", get(api_cloud))
@ -32,52 +73,66 @@ pub async fn serve(host: &str, port: u16, wifi_source: Option<&str>) -> anyhow::
println!("╔══════════════════════════════════════════════╗"); println!("╔══════════════════════════════════════════════╗");
println!("║ RuView Dense Point Cloud Server ║"); println!("║ RuView Dense Point Cloud Server ║");
println!("╚══════════════════════════════════════════════╝"); println!("╚══════════════════════════════════════════════╝");
println!(" HTTP: http://{addr}"); println!(" HTTP: http://{addr}");
println!(" WebSocket: ws://{addr}/ws"); println!(" Viewer: http://{addr}/");
println!(" API: http://{addr}/api/cloud");
println!(" Viewer: http://{addr}/");
let listener = tokio::net::TcpListener::bind(&addr).await?; let listener = tokio::net::TcpListener::bind(&addr).await?;
axum::serve(listener, app).await?; axum::serve(listener, app).await?;
Ok(()) Ok(())
} }
async fn api_cloud() -> Json<serde_json::Value> { /// Capture a live frame from the camera and generate a depth point cloud.
let occupancy = fusion::demo_occupancy(); fn capture_live_cloud() -> pointcloud::PointCloud {
let wifi_cloud = fusion::occupancy_to_pointcloud(&occupancy); let config = camera::CameraConfig::default();
let depth_cloud = depth::demo_depth_cloud(); match camera::capture_frame(&config) {
let fused = fusion::fuse_clouds(&[&wifi_cloud, &depth_cloud], 0.05); Ok(frame) => {
let (min, max) = fused.bounds(); match depth::estimate_depth(&frame.rgb, frame.width, frame.height) {
Ok(depth_map) => {
let intrinsics = depth::CameraIntrinsics::default();
depth::backproject_depth(&depth_map, &intrinsics, Some(&frame.rgb), 4) // downsample 4x
}
Err(_) => depth::demo_depth_cloud(),
}
}
Err(_) => depth::demo_depth_cloud(),
}
}
async fn api_cloud(State(state): State<Arc<AppState>>) -> Json<serde_json::Value> {
let cloud = state.latest_cloud.lock().unwrap();
let (min, max) = cloud.bounds();
let frames = *state.frame_count.lock().unwrap();
Json(serde_json::json!({ Json(serde_json::json!({
"points": fused.points.len(), "points": cloud.points.len(),
"bounds_min": min, "bounds_min": min,
"bounds_max": max, "bounds_max": max,
"sources": ["camera_depth", "wifi_occupancy"], "live": state.use_camera,
"cloud": fused.points.iter().take(1000).collect::<Vec<_>>(), "frame": frames,
"cloud": cloud.points.iter().take(1000).collect::<Vec<_>>(),
})) }))
} }
async fn api_splats() -> Json<serde_json::Value> { async fn api_splats(State(state): State<Arc<AppState>>) -> Json<serde_json::Value> {
let occupancy = fusion::demo_occupancy(); let splats = state.latest_splats.lock().unwrap();
let wifi_cloud = fusion::occupancy_to_pointcloud(&occupancy); let frames = *state.frame_count.lock().unwrap();
let depth_cloud = depth::demo_depth_cloud();
let fused = fusion::fuse_clouds(&[&wifi_cloud, &depth_cloud], 0.05);
let splats = pointcloud::to_gaussian_splats(&fused);
Json(serde_json::json!({ Json(serde_json::json!({
"splats": splats, "splats": &*splats,
"count": splats.len(), "count": splats.len(),
"live": state.use_camera,
"frame": frames,
"timestamp": chrono::Utc::now().timestamp_millis(), "timestamp": chrono::Utc::now().timestamp_millis(),
})) }))
} }
async fn api_status() -> Json<serde_json::Value> { async fn api_status(State(state): State<Arc<AppState>>) -> Json<serde_json::Value> {
let frames = *state.frame_count.lock().unwrap();
Json(serde_json::json!({ Json(serde_json::json!({
"status": "ok", "status": "ok",
"version": env!("CARGO_PKG_VERSION"), "version": env!("CARGO_PKG_VERSION"),
"pipeline": "camera_depth + wifi_occupancy → fused → gaussian_splats", "live": state.use_camera,
"fps": 10, "frames_captured": frames,
"camera": if state.use_camera { "/dev/video0" } else { "demo" },
"fps": 2,
})) }))
} }
@ -93,21 +148,22 @@ async fn index() -> Html<String> {
<style> <style>
body { margin: 0; background: #111; color: #e8a634; font-family: monospace; } body { margin: 0; background: #111; color: #e8a634; font-family: monospace; }
canvas { display: block; } canvas { display: block; }
#info { position: absolute; top: 10px; left: 10px; padding: 10px; background: rgba(0,0,0,0.7); border: 1px solid #e8a634; } #info { position: absolute; top: 10px; left: 10px; padding: 10px; background: rgba(0,0,0,0.8); border: 1px solid #e8a634; border-radius: 4px; }
</style> </style>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/OrbitControls.js"></script> <script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/OrbitControls.js"></script>
</head> </head>
<body> <body>
<div id="info"> <div id="info">
<h3>RuView Dense Point Cloud</h3> <h3 style="margin:0 0 5px 0">RuView Point Cloud</h3>
<div id="stats">Connecting...</div> <div id="stats">Loading...</div>
</div> </div>
<script> <script>
const scene = new THREE.Scene(); const scene = new THREE.Scene();
scene.background = new THREE.Color(0x111111); scene.background = new THREE.Color(0x111111);
const camera = new THREE.PerspectiveCamera(75, window.innerWidth/window.innerHeight, 0.1, 100); const camera = new THREE.PerspectiveCamera(75, window.innerWidth/window.innerHeight, 0.1, 100);
camera.position.set(3, 3, 3); camera.position.set(0, 0, -3);
camera.lookAt(0, 0, 3);
const renderer = new THREE.WebGLRenderer({ antialias: true }); const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight); renderer.setSize(window.innerWidth, window.innerHeight);
@ -115,30 +171,30 @@ async fn index() -> Html<String> {
const controls = new THREE.OrbitControls(camera, renderer.domElement); const controls = new THREE.OrbitControls(camera, renderer.domElement);
controls.enableDamping = true; controls.enableDamping = true;
controls.target.set(0, 0, 3);
// Grid
scene.add(new THREE.GridHelper(10, 20, 0x333333, 0x222222)); scene.add(new THREE.GridHelper(10, 20, 0x333333, 0x222222));
scene.add(new THREE.AxesHelper(2));
let pointsMesh = null; let pointsMesh = null;
let lastFrame = -1;
// Poll API for updates (no WebSocket needed)
async function fetchCloud() { async function fetchCloud() {
try { try {
const resp = await fetch('/api/splats'); const resp = await fetch('/api/splats');
const data = await resp.json(); const data = await resp.json();
if (data.splats) { if (data.splats && data.frame !== lastFrame) {
lastFrame = data.frame;
updateSplats(data.splats); updateSplats(data.splats);
const mode = data.live ? '🟢 LIVE' : '🔴 DEMO';
document.getElementById('stats').innerHTML = document.getElementById('stats').innerHTML =
`Splats: ${data.count}<br>Timestamp: ${new Date(data.timestamp).toLocaleTimeString()}`; `${mode}<br>Splats: ${data.count}<br>Frame: ${data.frame}`;
} }
} catch(e) { } catch(e) {
document.getElementById('stats').innerHTML = 'Error: ' + e.message; document.getElementById('stats').innerHTML = 'Error: ' + e.message;
} }
} }
fetchCloud(); fetchCloud();
setInterval(fetchCloud, 1000); // refresh every second setInterval(fetchCloud, 500);
document.getElementById('stats').innerHTML = 'Loading...';
function updateSplats(splats) { function updateSplats(splats) {
if (pointsMesh) scene.remove(pointsMesh); if (pointsMesh) scene.remove(pointsMesh);
@ -146,28 +202,23 @@ async fn index() -> Html<String> {
const geometry = new THREE.BufferGeometry(); const geometry = new THREE.BufferGeometry();
const positions = new Float32Array(splats.length * 3); const positions = new Float32Array(splats.length * 3);
const colors = new Float32Array(splats.length * 3); const colors = new Float32Array(splats.length * 3);
const sizes = new Float32Array(splats.length);
splats.forEach((s, i) => { splats.forEach((s, i) => {
positions[i*3] = s.center[0]; positions[i*3] = s.center[0];
positions[i*3+1] = s.center[2]; // swap Y/Z for Three.js positions[i*3+1] = -s.center[1];
positions[i*3+2] = s.center[1]; positions[i*3+2] = s.center[2];
colors[i*3] = s.color[0]; colors[i*3] = s.color[0];
colors[i*3+1] = s.color[1]; colors[i*3+1] = s.color[1];
colors[i*3+2] = s.color[2]; colors[i*3+2] = s.color[2];
sizes[i] = (s.scale[0] + s.scale[1] + s.scale[2]) * 50;
}); });
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)); geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3)); geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));
geometry.setAttribute('size', new THREE.BufferAttribute(sizes, 1));
const material = new THREE.PointsMaterial({ const material = new THREE.PointsMaterial({
size: 0.05, size: 0.03,
vertexColors: true, vertexColors: true,
sizeAttenuation: true, sizeAttenuation: true,
transparent: true,
opacity: 0.8,
}); });
pointsMesh = new THREE.Points(geometry, material); pointsMesh = new THREE.Points(geometry, material);