stream: extract viewer HTML to viewer.html, default bind to loopback
Strong concern #7 (PR #405): default HTTP bind leaked camera/CSI/vitals to the LAN. The `serve` fn now takes a single `bind` arg and prints a loud WARNING when bound outside loopback. Strong concern #10 (PR #405): embedded HTML+JS was ~220 LOC of the 418 LOC stream.rs. Moved the markup verbatim into viewer.html and inlined via `include_str!("viewer.html")`. Also: - Drop the #![allow(dead_code)] crate-level silencing (reviewer point #11). Remove the now-unused AppState.csi_pipeline field. - capture_camera_cloud_with_luminance returns the mean luminance of the captured frame; the background loop feeds that to CsiPipelineState::set_light_level so the night-mode flag actually toggles at runtime (previously it could only be set from tests). Net effect on file size: stream.rs 418 → 232 LOC. Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
parent
770788fc85
commit
d2b2cbfc69
|
|
@ -1,5 +1,4 @@
|
||||||
//! HTTP server — live camera + ESP32 CSI + fusion → real-time point cloud.
|
//! HTTP server — live camera + ESP32 CSI + fusion → real-time point cloud.
|
||||||
#![allow(dead_code)]
|
|
||||||
|
|
||||||
use crate::brain_bridge;
|
use crate::brain_bridge;
|
||||||
use crate::camera;
|
use crate::camera;
|
||||||
|
|
@ -21,13 +20,19 @@ struct AppState {
|
||||||
latest_pipeline: Mutex<Option<csi_pipeline::PipelineOutput>>,
|
latest_pipeline: Mutex<Option<csi_pipeline::PipelineOutput>>,
|
||||||
frame_count: Mutex<u64>,
|
frame_count: Mutex<u64>,
|
||||||
use_camera: bool,
|
use_camera: bool,
|
||||||
csi_pipeline: Option<Arc<Mutex<csi_pipeline::CsiPipelineState>>>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn serve(host: &str, port: u16, _wifi_source: Option<&str>) -> anyhow::Result<()> {
|
/// Start the HTTP/viewer server bound to `bind` (e.g.
|
||||||
|
/// `"127.0.0.1:9880"` — the safe default — or `"0.0.0.0:9880"` to expose
|
||||||
|
/// the viewer to the LAN).
|
||||||
|
///
|
||||||
|
/// **Security**: the viewer streams live camera/CSI/vitals data. Bind to
|
||||||
|
/// `127.0.0.1` unless you intentionally want remote viewers.
|
||||||
|
pub async fn serve(bind: &str, _brain: Option<&str>) -> anyhow::Result<()> {
|
||||||
let has_camera = camera::camera_available();
|
let has_camera = camera::camera_available();
|
||||||
|
|
||||||
// Start CSI pipeline — listens for UDP CSI data from ESP32 nodes
|
// Start CSI pipeline — listens for UDP CSI data from ESP32 nodes.
|
||||||
|
// Kept on 0.0.0.0 because ESP32 nodes are remote devices on the LAN.
|
||||||
let csi_pipeline_state = csi_pipeline::start_pipeline("0.0.0.0:3333");
|
let csi_pipeline_state = csi_pipeline::start_pipeline("0.0.0.0:3333");
|
||||||
eprintln!(" CSI pipeline: UDP port 3333 (ADR-018 binary frames)");
|
eprintln!(" CSI pipeline: UDP port 3333 (ADR-018 binary frames)");
|
||||||
|
|
||||||
|
|
@ -44,18 +49,17 @@ pub async fn serve(host: &str, port: u16, _wifi_source: Option<&str>) -> anyhow:
|
||||||
latest_pipeline: Mutex::new(None),
|
latest_pipeline: Mutex::new(None),
|
||||||
frame_count: Mutex::new(0),
|
frame_count: Mutex::new(0),
|
||||||
use_camera: has_camera,
|
use_camera: has_camera,
|
||||||
csi_pipeline: Some(csi_pipeline_state.clone()),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Background: capture + fuse every 500ms (motion-adaptive)
|
// Background: capture + fuse every 500ms (motion-adaptive)
|
||||||
let bg = state.clone();
|
let bg = state.clone();
|
||||||
let bg_csi = Some(csi_pipeline_state.clone());
|
let bg_csi = csi_pipeline_state.clone();
|
||||||
let bg_cam = has_camera;
|
let bg_cam = has_camera;
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let mut skip_depth = false;
|
let mut skip_depth = false;
|
||||||
loop {
|
loop {
|
||||||
// Motion-adaptive: check CSI motion score
|
// Motion-adaptive: check CSI motion score
|
||||||
let pipeline_out = bg_csi.as_ref().map(|c| csi_pipeline::get_pipeline_output(c));
|
let pipeline_out = Some(csi_pipeline::get_pipeline_output(&bg_csi));
|
||||||
if let Some(ref out) = pipeline_out {
|
if let Some(ref out) = pipeline_out {
|
||||||
// Only run expensive depth when motion detected or every 5th frame
|
// Only run expensive depth when motion detected or every 5th frame
|
||||||
let frame_num = *bg.frame_count.lock().unwrap();
|
let frame_num = *bg.frame_count.lock().unwrap();
|
||||||
|
|
@ -68,13 +72,21 @@ pub async fn serve(host: &str, port: u16, _wifi_source: Option<&str>) -> anyhow:
|
||||||
let interval = if skip_depth { 1000 } else { 500 }; // slower when no motion
|
let interval = if skip_depth { 1000 } else { 500 }; // slower when no motion
|
||||||
tokio::time::sleep(std::time::Duration::from_millis(interval)).await;
|
tokio::time::sleep(std::time::Duration::from_millis(interval)).await;
|
||||||
|
|
||||||
let cloud = if bg_cam && !skip_depth {
|
let (cloud, luminance) = if bg_cam && !skip_depth {
|
||||||
tokio::task::spawn_blocking(capture_camera_cloud)
|
tokio::task::spawn_blocking(capture_camera_cloud_with_luminance)
|
||||||
.await.unwrap_or_else(|_| demo_cloud())
|
.await.unwrap_or_else(|_| (demo_cloud(), None))
|
||||||
} else {
|
} else {
|
||||||
// Reuse previous cloud when no motion
|
// Reuse previous cloud when no motion
|
||||||
bg.latest_cloud.lock().unwrap().clone()
|
(bg.latest_cloud.lock().unwrap().clone(), None)
|
||||||
};
|
};
|
||||||
|
// Feed luminance into the CSI pipeline so is_dark toggles for the
|
||||||
|
// viewer. The lock is held briefly here — the UDP thread never
|
||||||
|
// touches it (messages go through the mpsc channel).
|
||||||
|
if let Some(lum) = luminance {
|
||||||
|
if let Ok(mut st) = bg_csi.lock() {
|
||||||
|
st.set_light_level(lum);
|
||||||
|
}
|
||||||
|
}
|
||||||
let splats = pointcloud::to_gaussian_splats(&cloud);
|
let splats = pointcloud::to_gaussian_splats(&cloud);
|
||||||
*bg.latest_cloud.lock().unwrap() = cloud;
|
*bg.latest_cloud.lock().unwrap() = cloud;
|
||||||
*bg.latest_splats.lock().unwrap() = splats;
|
*bg.latest_splats.lock().unwrap() = splats;
|
||||||
|
|
@ -104,30 +116,54 @@ pub async fn serve(host: &str, port: u16, _wifi_source: Option<&str>) -> anyhow:
|
||||||
.route("/health", get(api_health))
|
.route("/health", get(api_health))
|
||||||
.with_state(state);
|
.with_state(state);
|
||||||
|
|
||||||
let addr = format!("{host}:{port}");
|
|
||||||
println!("╔══════════════════════════════════════════════╗");
|
println!("╔══════════════════════════════════════════════╗");
|
||||||
println!("║ RuView Dense Point Cloud — ALL SENSORS ║");
|
println!("║ RuView Dense Point Cloud — ALL SENSORS ║");
|
||||||
println!("╚══════════════════════════════════════════════╝");
|
println!("╚══════════════════════════════════════════════╝");
|
||||||
println!(" Viewer: http://{addr}/");
|
println!(" Viewer: http://{bind}/");
|
||||||
|
if bind.starts_with("0.0.0.0") || bind.starts_with("::") {
|
||||||
|
eprintln!(
|
||||||
|
" WARNING: bound to {bind} — camera/CSI/vitals are exposed \
|
||||||
|
to the network. Use --bind 127.0.0.1:9880 to restrict to loopback."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let listener = tokio::net::TcpListener::bind(&addr).await?;
|
let listener = tokio::net::TcpListener::bind(bind).await?;
|
||||||
axum::serve(listener, app).await?;
|
axum::serve(listener, app).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn capture_camera_cloud() -> pointcloud::PointCloud {
|
fn capture_camera_cloud() -> pointcloud::PointCloud {
|
||||||
|
capture_camera_cloud_with_luminance().0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Grab one camera frame, backproject it to a point cloud, and return the
|
||||||
|
/// mean luminance alongside (used to drive `set_light_level` for night mode).
|
||||||
|
fn capture_camera_cloud_with_luminance() -> (pointcloud::PointCloud, Option<f32>) {
|
||||||
let config = camera::CameraConfig::default();
|
let config = camera::CameraConfig::default();
|
||||||
match camera::capture_frame(&config) {
|
match camera::capture_frame(&config) {
|
||||||
Ok(frame) => {
|
Ok(frame) => {
|
||||||
match depth::estimate_depth(&frame.rgb, frame.width, frame.height) {
|
// Mean luminance across the RGB frame (BT.601 coefficients).
|
||||||
|
let pixels = (frame.width as usize) * (frame.height as usize);
|
||||||
|
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;
|
||||||
|
n += 1;
|
||||||
|
}
|
||||||
|
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) => {
|
Ok(dm) => {
|
||||||
let intr = depth::CameraIntrinsics::default();
|
let intr = depth::CameraIntrinsics::default();
|
||||||
depth::backproject_depth(&dm, &intr, Some(&frame.rgb), 2)
|
depth::backproject_depth(&dm, &intr, Some(&frame.rgb), 2)
|
||||||
}
|
}
|
||||||
Err(_) => depth::demo_depth_cloud(),
|
Err(_) => depth::demo_depth_cloud(),
|
||||||
}
|
};
|
||||||
|
(cloud, lum)
|
||||||
}
|
}
|
||||||
Err(_) => depth::demo_depth_cloud(),
|
Err(_) => (depth::demo_depth_cloud(), None),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -185,234 +221,12 @@ async fn api_health() -> Json<serde_json::Value> {
|
||||||
Json(serde_json::json!({"status": "ok"}))
|
Json(serde_json::json!({"status": "ok"}))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn index() -> Html<String> {
|
/// Viewer HTML/JS, compiled into the binary at build time. Keep the
|
||||||
Html(r#"<!DOCTYPE html>
|
/// markup in `viewer.html` to keep this file under the 500-LOC limit and
|
||||||
<html>
|
/// to make it trivially editable (no Rust rebuild when tweaking JS).
|
||||||
<head>
|
static VIEWER_HTML: &str = include_str!("viewer.html");
|
||||||
<title>RuView — Camera + WiFi CSI Point Cloud</title>
|
|
||||||
<style>
|
|
||||||
body { margin: 0; background: #0a0a0a; color: #e8a634; font-family: monospace; }
|
|
||||||
canvas { display: block; }
|
|
||||||
#info { position: absolute; top: 10px; left: 10px; padding: 12px; background: rgba(0,0,0,0.85); border: 1px solid #e8a634; border-radius: 6px; min-width: 240px; font-size: 13px; line-height: 1.5; }
|
|
||||||
.live { color: #4f4; } .demo { color: #f44; }
|
|
||||||
.section { margin-top: 6px; padding-top: 6px; border-top: 1px solid #333; }
|
|
||||||
.label { color: #888; }
|
|
||||||
</style>
|
|
||||||
<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>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="info">
|
|
||||||
<h3 style="margin:0 0 8px 0">RuView Point Cloud</h3>
|
|
||||||
<div id="stats">Loading...</div>
|
|
||||||
</div>
|
|
||||||
<script>
|
|
||||||
var scene = new THREE.Scene();
|
|
||||||
scene.background = new THREE.Color(0x0a0a0a);
|
|
||||||
var camera = new THREE.PerspectiveCamera(75, window.innerWidth/window.innerHeight, 0.1, 100);
|
|
||||||
camera.position.set(0, 2, -4);
|
|
||||||
camera.lookAt(0, 0, 2);
|
|
||||||
|
|
||||||
var renderer = new THREE.WebGLRenderer({ antialias: true });
|
async fn index() -> Html<&'static str> {
|
||||||
renderer.setSize(window.innerWidth, window.innerHeight);
|
Html(VIEWER_HTML)
|
||||||
document.body.appendChild(renderer.domElement);
|
|
||||||
|
|
||||||
var controls = new THREE.OrbitControls(camera, renderer.domElement);
|
|
||||||
controls.enableDamping = true;
|
|
||||||
controls.target.set(0, 0, 2);
|
|
||||||
|
|
||||||
var pointsMesh = null;
|
|
||||||
var lastFrame = -1;
|
|
||||||
var skeletonGroup = null;
|
|
||||||
var prevTimestamp = 0;
|
|
||||||
var frameRateVal = 0;
|
|
||||||
|
|
||||||
// COCO skeleton connections: pairs of keypoint indices
|
|
||||||
// 0=nose 1=leftEye 2=rightEye 3=leftEar 4=rightEar
|
|
||||||
// 5=leftShoulder 6=rightShoulder 7=leftElbow 8=rightElbow
|
|
||||||
// 9=leftWrist 10=rightWrist 11=leftHip 12=rightHip
|
|
||||||
// 13=leftKnee 14=rightKnee 15=leftAnkle 16=rightAnkle
|
|
||||||
var COCO_BONES = [
|
|
||||||
[0,1],[0,2],[1,3],[2,4],
|
|
||||||
[5,6],[5,7],[7,9],[6,8],[8,10],
|
|
||||||
[5,11],[6,12],[11,12],
|
|
||||||
[11,13],[13,15],[12,14],[14,16]
|
|
||||||
];
|
|
||||||
|
|
||||||
function clearSkeleton() {
|
|
||||||
if (skeletonGroup) {
|
|
||||||
scene.remove(skeletonGroup);
|
|
||||||
skeletonGroup.traverse(function(obj) {
|
|
||||||
if (obj.geometry) obj.geometry.dispose();
|
|
||||||
if (obj.material) obj.material.dispose();
|
|
||||||
});
|
|
||||||
skeletonGroup = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function drawSkeleton(keypoints) {
|
|
||||||
clearSkeleton();
|
|
||||||
if (!keypoints || keypoints.length < 17) return;
|
|
||||||
skeletonGroup = new THREE.Group();
|
|
||||||
|
|
||||||
// Map keypoints from [0,1] to scene coords
|
|
||||||
// x: [-2, 2], y: [2, -2] (flip y), z: fixed at 2
|
|
||||||
var sphereGeo = new THREE.SphereGeometry(0.04, 8, 8);
|
|
||||||
var sphereMat = new THREE.MeshBasicMaterial({ color: 0xffff00 });
|
|
||||||
var positions3D = [];
|
|
||||||
var i, kp, sx, sy;
|
|
||||||
for (i = 0; i < 17; i++) {
|
|
||||||
kp = keypoints[i];
|
|
||||||
if (!kp) { positions3D.push(null); continue; }
|
|
||||||
sx = (kp[0] - 0.5) * 4;
|
|
||||||
sy = (0.5 - kp[1]) * 4;
|
|
||||||
positions3D.push([sx, sy, 2]);
|
|
||||||
var sphere = new THREE.Mesh(sphereGeo, sphereMat);
|
|
||||||
sphere.position.set(sx, sy, 2);
|
|
||||||
skeletonGroup.add(sphere);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Draw bones as white lines
|
|
||||||
var lineMat = new THREE.LineBasicMaterial({ color: 0xffffff, linewidth: 2 });
|
|
||||||
var b, a, bIdx;
|
|
||||||
for (b = 0; b < COCO_BONES.length; b++) {
|
|
||||||
a = COCO_BONES[b][0];
|
|
||||||
bIdx = COCO_BONES[b][1];
|
|
||||||
if (!positions3D[a] || !positions3D[bIdx]) continue;
|
|
||||||
var lineGeo = new THREE.BufferGeometry();
|
|
||||||
var verts = new Float32Array([
|
|
||||||
positions3D[a][0], positions3D[a][1], positions3D[a][2],
|
|
||||||
positions3D[bIdx][0], positions3D[bIdx][1], positions3D[bIdx][2]
|
|
||||||
]);
|
|
||||||
lineGeo.setAttribute("position", new THREE.BufferAttribute(verts, 3));
|
|
||||||
var line = new THREE.Line(lineGeo, lineMat);
|
|
||||||
skeletonGroup.add(line);
|
|
||||||
}
|
|
||||||
|
|
||||||
scene.add(skeletonGroup);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchCloud() {
|
|
||||||
try {
|
|
||||||
var resp = await fetch("/api/splats");
|
|
||||||
var data = await resp.json();
|
|
||||||
if (data.splats && data.frame !== lastFrame) {
|
|
||||||
// Compute CSI frame rate
|
|
||||||
var now = Date.now();
|
|
||||||
if (prevTimestamp > 0) {
|
|
||||||
var dt = (now - prevTimestamp) / 1000.0;
|
|
||||||
if (dt > 0) frameRateVal = (1.0 / dt).toFixed(1);
|
|
||||||
}
|
|
||||||
prevTimestamp = now;
|
|
||||||
lastFrame = data.frame;
|
|
||||||
updateSplats(data.splats);
|
|
||||||
|
|
||||||
// Draw skeleton if available
|
|
||||||
var pipe = data.pipeline;
|
|
||||||
if (pipe && pipe.skeleton && pipe.skeleton.keypoints) {
|
|
||||||
drawSkeleton(pipe.skeleton.keypoints);
|
|
||||||
} else {
|
|
||||||
clearSkeleton();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build info panel
|
|
||||||
var mode = data.live
|
|
||||||
? '<span class="live">● LIVE</span>'
|
|
||||||
: '<span class="demo">● DEMO</span>';
|
|
||||||
var html = mode + " Camera + CSI<br>"
|
|
||||||
+ "Splats: " + data.count + "<br>"
|
|
||||||
+ "Frame: " + data.frame;
|
|
||||||
|
|
||||||
// CSI frame rate
|
|
||||||
html += '<div class="section">'
|
|
||||||
+ '<span class="label">CSI Rate:</span> '
|
|
||||||
+ frameRateVal + " fps</div>";
|
|
||||||
|
|
||||||
// Skeleton confidence
|
|
||||||
if (pipe && pipe.skeleton && pipe.skeleton.confidence !== undefined) {
|
|
||||||
var conf = (pipe.skeleton.confidence * 100).toFixed(0);
|
|
||||||
html += '<div class="section">'
|
|
||||||
+ '<span class="label">Skeleton:</span> '
|
|
||||||
+ conf + "% confidence</div>";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Weather data
|
|
||||||
if (pipe && pipe.weather) {
|
|
||||||
var w = pipe.weather;
|
|
||||||
html += '<div class="section">'
|
|
||||||
+ '<span class="label">Weather:</span> ';
|
|
||||||
if (w.temperature !== undefined) {
|
|
||||||
html += w.temperature + "°C";
|
|
||||||
}
|
|
||||||
if (w.conditions) {
|
|
||||||
html += " " + w.conditions;
|
|
||||||
}
|
|
||||||
html += "</div>";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Building count from geo
|
|
||||||
if (pipe && pipe.geo && pipe.geo.building_count !== undefined) {
|
|
||||||
html += '<div class="section">'
|
|
||||||
+ '<span class="label">Buildings:</span> '
|
|
||||||
+ pipe.geo.building_count + "</div>";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Vitals
|
|
||||||
if (pipe && pipe.vitals) {
|
|
||||||
var v = pipe.vitals;
|
|
||||||
html += '<div class="section">'
|
|
||||||
+ '<span class="label">Vitals:</span> ';
|
|
||||||
if (v.breathing_rate !== undefined) {
|
|
||||||
html += "BR " + v.breathing_rate + "/min";
|
|
||||||
}
|
|
||||||
if (v.motion_score !== undefined) {
|
|
||||||
html += " Motion " + (v.motion_score * 100).toFixed(0) + "%";
|
|
||||||
}
|
|
||||||
html += "</div>";
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById("stats").innerHTML = html;
|
|
||||||
}
|
|
||||||
} catch(e) {}
|
|
||||||
}
|
|
||||||
fetchCloud();
|
|
||||||
setInterval(fetchCloud, 500);
|
|
||||||
|
|
||||||
function updateSplats(splats) {
|
|
||||||
if (pointsMesh) scene.remove(pointsMesh);
|
|
||||||
var geometry = new THREE.BufferGeometry();
|
|
||||||
var positions = new Float32Array(splats.length * 3);
|
|
||||||
var colors = new Float32Array(splats.length * 3);
|
|
||||||
var i, s;
|
|
||||||
for (i = 0; i < splats.length; i++) {
|
|
||||||
s = splats[i];
|
|
||||||
positions[i*3] = s.center[0];
|
|
||||||
positions[i*3+1] = -s.center[1];
|
|
||||||
positions[i*3+2] = s.center[2];
|
|
||||||
colors[i*3] = s.color[0];
|
|
||||||
colors[i*3+1] = s.color[1];
|
|
||||||
colors[i*3+2] = s.color[2];
|
|
||||||
}
|
|
||||||
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
|
|
||||||
geometry.setAttribute("color", new THREE.BufferAttribute(colors, 3));
|
|
||||||
pointsMesh = new THREE.Points(geometry, new THREE.PointsMaterial({
|
|
||||||
size: 0.02, vertexColors: true, sizeAttenuation: true
|
|
||||||
}));
|
|
||||||
scene.add(pointsMesh);
|
|
||||||
}
|
|
||||||
|
|
||||||
function animate() {
|
|
||||||
requestAnimationFrame(animate);
|
|
||||||
controls.update();
|
|
||||||
renderer.render(scene, camera);
|
|
||||||
}
|
|
||||||
animate();
|
|
||||||
window.addEventListener("resize", function() {
|
|
||||||
camera.aspect = window.innerWidth / window.innerHeight;
|
|
||||||
camera.updateProjectionMatrix();
|
|
||||||
renderer.setSize(window.innerWidth, window.innerHeight);
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>"#.to_string())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,229 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>RuView — Camera + WiFi CSI Point Cloud</title>
|
||||||
|
<style>
|
||||||
|
body { margin: 0; background: #0a0a0a; color: #e8a634; font-family: monospace; }
|
||||||
|
canvas { display: block; }
|
||||||
|
#info { position: absolute; top: 10px; left: 10px; padding: 12px; background: rgba(0,0,0,0.85); border: 1px solid #e8a634; border-radius: 6px; min-width: 240px; font-size: 13px; line-height: 1.5; }
|
||||||
|
.live { color: #4f4; } .demo { color: #f44; }
|
||||||
|
.section { margin-top: 6px; padding-top: 6px; border-top: 1px solid #333; }
|
||||||
|
.label { color: #888; }
|
||||||
|
</style>
|
||||||
|
<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>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="info">
|
||||||
|
<h3 style="margin:0 0 8px 0">RuView Point Cloud</h3>
|
||||||
|
<div id="stats">Loading...</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
var scene = new THREE.Scene();
|
||||||
|
scene.background = new THREE.Color(0x0a0a0a);
|
||||||
|
var camera = new THREE.PerspectiveCamera(75, window.innerWidth/window.innerHeight, 0.1, 100);
|
||||||
|
camera.position.set(0, 2, -4);
|
||||||
|
camera.lookAt(0, 0, 2);
|
||||||
|
|
||||||
|
var renderer = new THREE.WebGLRenderer({ antialias: true });
|
||||||
|
renderer.setSize(window.innerWidth, window.innerHeight);
|
||||||
|
document.body.appendChild(renderer.domElement);
|
||||||
|
|
||||||
|
var controls = new THREE.OrbitControls(camera, renderer.domElement);
|
||||||
|
controls.enableDamping = true;
|
||||||
|
controls.target.set(0, 0, 2);
|
||||||
|
|
||||||
|
var pointsMesh = null;
|
||||||
|
var lastFrame = -1;
|
||||||
|
var skeletonGroup = null;
|
||||||
|
var prevTimestamp = 0;
|
||||||
|
var frameRateVal = 0;
|
||||||
|
|
||||||
|
// COCO skeleton connections: pairs of keypoint indices
|
||||||
|
// 0=nose 1=leftEye 2=rightEye 3=leftEar 4=rightEar
|
||||||
|
// 5=leftShoulder 6=rightShoulder 7=leftElbow 8=rightElbow
|
||||||
|
// 9=leftWrist 10=rightWrist 11=leftHip 12=rightHip
|
||||||
|
// 13=leftKnee 14=rightKnee 15=leftAnkle 16=rightAnkle
|
||||||
|
var COCO_BONES = [
|
||||||
|
[0,1],[0,2],[1,3],[2,4],
|
||||||
|
[5,6],[5,7],[7,9],[6,8],[8,10],
|
||||||
|
[5,11],[6,12],[11,12],
|
||||||
|
[11,13],[13,15],[12,14],[14,16]
|
||||||
|
];
|
||||||
|
|
||||||
|
function clearSkeleton() {
|
||||||
|
if (skeletonGroup) {
|
||||||
|
scene.remove(skeletonGroup);
|
||||||
|
skeletonGroup.traverse(function(obj) {
|
||||||
|
if (obj.geometry) obj.geometry.dispose();
|
||||||
|
if (obj.material) obj.material.dispose();
|
||||||
|
});
|
||||||
|
skeletonGroup = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawSkeleton(keypoints) {
|
||||||
|
clearSkeleton();
|
||||||
|
if (!keypoints || keypoints.length < 17) return;
|
||||||
|
skeletonGroup = new THREE.Group();
|
||||||
|
|
||||||
|
// Map keypoints from [0,1] to scene coords
|
||||||
|
// x: [-2, 2], y: [2, -2] (flip y), z: fixed at 2
|
||||||
|
var sphereGeo = new THREE.SphereGeometry(0.04, 8, 8);
|
||||||
|
var sphereMat = new THREE.MeshBasicMaterial({ color: 0xffff00 });
|
||||||
|
var positions3D = [];
|
||||||
|
var i, kp, sx, sy;
|
||||||
|
for (i = 0; i < 17; i++) {
|
||||||
|
kp = keypoints[i];
|
||||||
|
if (!kp) { positions3D.push(null); continue; }
|
||||||
|
sx = (kp[0] - 0.5) * 4;
|
||||||
|
sy = (0.5 - kp[1]) * 4;
|
||||||
|
positions3D.push([sx, sy, 2]);
|
||||||
|
var sphere = new THREE.Mesh(sphereGeo, sphereMat);
|
||||||
|
sphere.position.set(sx, sy, 2);
|
||||||
|
skeletonGroup.add(sphere);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw bones as white lines
|
||||||
|
var lineMat = new THREE.LineBasicMaterial({ color: 0xffffff, linewidth: 2 });
|
||||||
|
var b, a, bIdx;
|
||||||
|
for (b = 0; b < COCO_BONES.length; b++) {
|
||||||
|
a = COCO_BONES[b][0];
|
||||||
|
bIdx = COCO_BONES[b][1];
|
||||||
|
if (!positions3D[a] || !positions3D[bIdx]) continue;
|
||||||
|
var lineGeo = new THREE.BufferGeometry();
|
||||||
|
var verts = new Float32Array([
|
||||||
|
positions3D[a][0], positions3D[a][1], positions3D[a][2],
|
||||||
|
positions3D[bIdx][0], positions3D[bIdx][1], positions3D[bIdx][2]
|
||||||
|
]);
|
||||||
|
lineGeo.setAttribute("position", new THREE.BufferAttribute(verts, 3));
|
||||||
|
var line = new THREE.Line(lineGeo, lineMat);
|
||||||
|
skeletonGroup.add(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
scene.add(skeletonGroup);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchCloud() {
|
||||||
|
try {
|
||||||
|
var resp = await fetch("/api/splats");
|
||||||
|
var data = await resp.json();
|
||||||
|
if (data.splats && data.frame !== lastFrame) {
|
||||||
|
// Compute CSI frame rate
|
||||||
|
var now = Date.now();
|
||||||
|
if (prevTimestamp > 0) {
|
||||||
|
var dt = (now - prevTimestamp) / 1000.0;
|
||||||
|
if (dt > 0) frameRateVal = (1.0 / dt).toFixed(1);
|
||||||
|
}
|
||||||
|
prevTimestamp = now;
|
||||||
|
lastFrame = data.frame;
|
||||||
|
updateSplats(data.splats);
|
||||||
|
|
||||||
|
// Draw skeleton if available
|
||||||
|
var pipe = data.pipeline;
|
||||||
|
if (pipe && pipe.skeleton && pipe.skeleton.keypoints) {
|
||||||
|
drawSkeleton(pipe.skeleton.keypoints);
|
||||||
|
} else {
|
||||||
|
clearSkeleton();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build info panel
|
||||||
|
var mode = data.live
|
||||||
|
? '<span class="live">● LIVE</span>'
|
||||||
|
: '<span class="demo">● DEMO</span>';
|
||||||
|
var html = mode + " Camera + CSI<br>"
|
||||||
|
+ "Splats: " + data.count + "<br>"
|
||||||
|
+ "Frame: " + data.frame;
|
||||||
|
|
||||||
|
// CSI frame rate
|
||||||
|
html += '<div class="section">'
|
||||||
|
+ '<span class="label">CSI Rate:</span> '
|
||||||
|
+ frameRateVal + " fps</div>";
|
||||||
|
|
||||||
|
// Skeleton confidence
|
||||||
|
if (pipe && pipe.skeleton && pipe.skeleton.confidence !== undefined) {
|
||||||
|
var conf = (pipe.skeleton.confidence * 100).toFixed(0);
|
||||||
|
html += '<div class="section">'
|
||||||
|
+ '<span class="label">Skeleton:</span> '
|
||||||
|
+ conf + "% confidence</div>";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Weather data
|
||||||
|
if (pipe && pipe.weather) {
|
||||||
|
var w = pipe.weather;
|
||||||
|
html += '<div class="section">'
|
||||||
|
+ '<span class="label">Weather:</span> ';
|
||||||
|
if (w.temperature !== undefined) {
|
||||||
|
html += w.temperature + "°C";
|
||||||
|
}
|
||||||
|
if (w.conditions) {
|
||||||
|
html += " " + w.conditions;
|
||||||
|
}
|
||||||
|
html += "</div>";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Building count from geo
|
||||||
|
if (pipe && pipe.geo && pipe.geo.building_count !== undefined) {
|
||||||
|
html += '<div class="section">'
|
||||||
|
+ '<span class="label">Buildings:</span> '
|
||||||
|
+ pipe.geo.building_count + "</div>";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vitals
|
||||||
|
if (pipe && pipe.vitals) {
|
||||||
|
var v = pipe.vitals;
|
||||||
|
html += '<div class="section">'
|
||||||
|
+ '<span class="label">Vitals:</span> ';
|
||||||
|
if (v.breathing_rate !== undefined) {
|
||||||
|
html += "BR " + v.breathing_rate + "/min";
|
||||||
|
}
|
||||||
|
if (v.motion_score !== undefined) {
|
||||||
|
html += " Motion " + (v.motion_score * 100).toFixed(0) + "%";
|
||||||
|
}
|
||||||
|
html += "</div>";
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("stats").innerHTML = html;
|
||||||
|
}
|
||||||
|
} catch(e) {}
|
||||||
|
}
|
||||||
|
fetchCloud();
|
||||||
|
setInterval(fetchCloud, 500);
|
||||||
|
|
||||||
|
function updateSplats(splats) {
|
||||||
|
if (pointsMesh) scene.remove(pointsMesh);
|
||||||
|
var geometry = new THREE.BufferGeometry();
|
||||||
|
var positions = new Float32Array(splats.length * 3);
|
||||||
|
var colors = new Float32Array(splats.length * 3);
|
||||||
|
var i, s;
|
||||||
|
for (i = 0; i < splats.length; i++) {
|
||||||
|
s = splats[i];
|
||||||
|
positions[i*3] = s.center[0];
|
||||||
|
positions[i*3+1] = -s.center[1];
|
||||||
|
positions[i*3+2] = s.center[2];
|
||||||
|
colors[i*3] = s.color[0];
|
||||||
|
colors[i*3+1] = s.color[1];
|
||||||
|
colors[i*3+2] = s.color[2];
|
||||||
|
}
|
||||||
|
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
|
||||||
|
geometry.setAttribute("color", new THREE.BufferAttribute(colors, 3));
|
||||||
|
pointsMesh = new THREE.Points(geometry, new THREE.PointsMaterial({
|
||||||
|
size: 0.02, vertexColors: true, sizeAttenuation: true
|
||||||
|
}));
|
||||||
|
scene.add(pointsMesh);
|
||||||
|
}
|
||||||
|
|
||||||
|
function animate() {
|
||||||
|
requestAnimationFrame(animate);
|
||||||
|
controls.update();
|
||||||
|
renderer.render(scene, camera);
|
||||||
|
}
|
||||||
|
animate();
|
||||||
|
window.addEventListener("resize", function() {
|
||||||
|
camera.aspect = window.innerWidth / window.innerHeight;
|
||||||
|
camera.updateProjectionMatrix();
|
||||||
|
renderer.setSize(window.innerWidth, window.innerHeight);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Loading…
Reference in New Issue