wifi-densepose/pointcloud/index.html

507 lines
22 KiB
HTML

<!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; z-index: 10; }
#cam-cta { position: absolute; bottom: 16px; left: 50%; transform: translateX(-50%); padding: 10px 18px; background: #e8a634; color: #0a0a0a; border: none; border-radius: 4px; font-family: monospace; font-size: 14px; font-weight: bold; cursor: pointer; z-index: 10; }
#cam-cta:hover { background: #ffc04d; }
#cam-cta.hidden { display: none; }
.live { color: #4f4; } .demo { color: #f44; }
.face { color: #4cf; }
.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>
<!-- MediaPipe Face Mesh — runs in demo mode so each visitor sees their own face as a point cloud -->
<script src="https://cdn.jsdelivr.net/npm/@mediapipe/face_mesh@0.4/face_mesh.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@mediapipe/camera_utils@0.3/camera_utils.js"></script>
</head>
<body>
<div id="info">
<h3 style="margin:0 0 8px 0">RuView Point Cloud</h3>
<div id="stats">Loading...</div>
</div>
<button id="cam-cta">▶ Enable camera — render your face as a point cloud</button>
<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, 0.5, -1.5);
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);
}
// ----- Transport configuration -----
// ?backend=<url> → fetch splats from <url>/api/splats (CORS-permitting host)
// ?backend=auto → try /api/splats, fall back to synthetic demo on failure (default)
// ?backend=demo → always render synthetic demo (no network)
// ?live=1 → require live; show error instead of demo fallback
var urlParams = new URLSearchParams(window.location.search);
var backendArg = urlParams.get("backend") || "auto";
var requireLive = urlParams.get("live") === "1";
var transportMode = "demo"; // resolved at first fetch: "live" | "remote" | "demo"
var demoStartMs = Date.now();
var demoFrameNum = 0;
var latestFaceLandmarks = null; // populated by MediaPipe when camera enabled
var faceMeshState = "idle"; // "idle" | "starting" | "running" | "denied" | "unavailable"
// ----- MediaPipe Face Mesh (browser equivalent of camera-depth backprojection) -----
// Locally, ruview-pointcloud serve fuses real camera depth + WiFi CSI. In the
// browser we don't have depth from a webcam, but Face Mesh produces 468
// 3D landmarks (x,y in [0,1], z roughly in [-0.5,0.5]) at ~30 fps — enough to
// reproduce the "I can see the outline of my face in points" experience. The
// landmarks feed into the same splat render path as live /api/splats data.
async function startFaceMesh() {
if (faceMeshState !== "idle") return;
if (!window.FaceMesh || !window.Camera) {
faceMeshState = "unavailable";
return;
}
faceMeshState = "starting";
try {
var videoEl = document.createElement("video");
videoEl.style.display = "none";
videoEl.autoplay = true;
videoEl.playsInline = true;
videoEl.muted = true;
document.body.appendChild(videoEl);
var fm = new FaceMesh({
locateFile: function(file) {
return "https://cdn.jsdelivr.net/npm/@mediapipe/face_mesh@0.4/" + file;
}
});
fm.setOptions({
maxNumFaces: 1,
refineLandmarks: true,
minDetectionConfidence: 0.5,
minTrackingConfidence: 0.5
});
fm.onResults(function(results) {
if (results.multiFaceLandmarks && results.multiFaceLandmarks[0]) {
latestFaceLandmarks = results.multiFaceLandmarks[0];
}
});
var mpCamera = new Camera(videoEl, {
onFrame: async function() { await fm.send({ image: videoEl }); },
width: 640,
height: 480
});
await mpCamera.start();
faceMeshState = "running";
var btn = document.getElementById("cam-cta");
if (btn) btn.classList.add("hidden");
} catch (err) {
faceMeshState = "denied";
console.warn("Face mesh unavailable:", err);
}
}
function faceMeshFrame() {
if (faceMeshState !== "running" || !latestFaceLandmarks) return null;
var lms = latestFaceLandmarks;
var splats = [];
var i, lm, x, y, z;
// 468 (or 478 with refined landmarks) face points → splats. MediaPipe's
// selfie convention has x mirrored; we mirror back so left-of-screen = your
// left side. z is depth-relative-to-face-center, ~[-0.1,+0.1] in practice.
for (i = 0; i < lms.length; i++) {
lm = lms[i];
x = (0.5 - lm.x) * 4.0;
y = (0.5 - lm.y) * 3.0;
z = 2.0 + lm.z * 4.0;
splats.push({
center: [x, y, z],
color: [0.95, 0.65, 0.20],
opacity: 1.0,
scale: [0.012, 0.012, 0.012]
});
}
// Procedural floor + back wall for spatial context — same density as the
// local demo's room scaffold.
var gx, gz;
for (gx = -4; gx <= 4; gx++) {
for (gz = 1; gz <= 8; gz++) {
splats.push({
center: [gx * 0.4, -1.4, gz * 0.4],
color: [0.15, 0.18, 0.22],
opacity: 1.0,
scale: [0.05, 0.05, 0.05]
});
}
}
for (gx = -4; gx <= 4; gx += 2) {
for (var wy = -1; wy <= 2; wy++) {
splats.push({
center: [gx * 0.4, wy * 0.5, 4.0],
color: [0.12, 0.20, 0.28],
opacity: 1.0,
scale: [0.05, 0.05, 0.05]
});
}
}
demoFrameNum += 1;
return {
splats: splats,
count: splats.length,
frame: demoFrameNum,
live: false,
source: "face-mesh",
pipeline: {
skeleton: null,
vitals: { breathing_rate: 14, motion_score: 0.15 }
}
};
}
function buildSplatsUrl() {
if (backendArg === "demo") return null;
if (backendArg === "auto") return "/api/splats";
// User-supplied URL — strip trailing slash and append /api/splats.
var base = backendArg.replace(/\/+$/, "");
return base + "/api/splats";
}
function syntheticFrame() {
// Deterministic synthetic point cloud: floor grid, two walls, and
// a standing figure that breathes/sways. Resembles the live API
// payload so the same render path drives both modes.
var t = (Date.now() - demoStartMs) / 1000.0;
var sway = Math.sin(t * 0.8) * 0.05;
var breath = Math.sin(t * 1.2) * 0.015;
var splats = [];
// Floor — 12x12 grid at y=-1
var gx, gz;
for (gx = -6; gx <= 6; gx++) {
for (gz = 0; gz <= 12; gz++) {
splats.push({
center: [gx * 0.4, -1.0, gz * 0.4],
color: [0.15, 0.18, 0.22],
opacity: 1.0,
scale: [0.05, 0.05, 0.05]
});
}
}
// Back wall + side walls — sparse vertical strips
var wy, wx;
for (wy = -1; wy <= 2; wy++) {
for (wx = -6; wx <= 6; wx += 2) {
splats.push({
center: [wx * 0.4, wy * 0.5, 4.8],
color: [0.12, 0.20, 0.28],
opacity: 1.0,
scale: [0.05, 0.05, 0.05]
});
}
splats.push({ center: [-2.4, wy * 0.5, 0.5 + (wy + 1) * 0.8], color: [0.12, 0.20, 0.28], opacity: 1.0, scale: [0.05, 0.05, 0.05] });
splats.push({ center: [ 2.4, wy * 0.5, 0.5 + (wy + 1) * 0.8], color: [0.12, 0.20, 0.28], opacity: 1.0, scale: [0.05, 0.05, 0.05] });
}
// Standing figure — 60 points in a vertical cylinder
var i, theta, r, py;
for (i = 0; i < 60; i++) {
theta = (i / 60) * Math.PI * 2;
py = -0.6 + (i / 60) * 1.6;
r = 0.18 + breath * (py > 0 ? 1 : 0);
splats.push({
center: [sway + Math.cos(theta) * r, py, 2.3 + Math.sin(theta) * r],
color: [0.91, 0.65, 0.20],
opacity: 1.0,
scale: [0.04, 0.04, 0.04]
});
}
// 17 COCO keypoints in normalized [0,1] image coords (matches live shape)
var headY = 0.18;
var keypoints = [
[0.50 + sway * 0.05, headY, 0.95], // 0 nose
[0.52 + sway * 0.05, headY - 0.01, 0.92], // 1 leftEye
[0.48 + sway * 0.05, headY - 0.01, 0.92], // 2 rightEye
[0.54 + sway * 0.05, headY, 0.85], // 3 leftEar
[0.46 + sway * 0.05, headY, 0.85], // 4 rightEar
[0.60 + sway * 0.04, 0.32, 0.93], // 5 leftShoulder
[0.40 + sway * 0.04, 0.32, 0.93], // 6 rightShoulder
[0.65 + sway * 0.03, 0.46, 0.90], // 7 leftElbow
[0.35 + sway * 0.03, 0.46, 0.90], // 8 rightElbow
[0.68, 0.60 + Math.sin(t * 1.4) * 0.02, 0.86], // 9 leftWrist
[0.32, 0.60 - Math.sin(t * 1.4) * 0.02, 0.86], // 10 rightWrist
[0.57, 0.58, 0.94], // 11 leftHip
[0.43, 0.58, 0.94], // 12 rightHip
[0.58, 0.74, 0.90], // 13 leftKnee
[0.42, 0.74, 0.90], // 14 rightKnee
[0.59, 0.92, 0.88], // 15 leftAnkle
[0.41, 0.92, 0.88] // 16 rightAnkle
];
demoFrameNum += 1;
return {
splats: splats,
count: splats.length,
frame: demoFrameNum,
live: false,
pipeline: {
skeleton: { keypoints: keypoints, confidence: 0.86 },
vitals: {
breathing_rate: 14 + Math.round(Math.sin(t * 0.05) * 2),
motion_score: 0.18 + Math.abs(sway) * 2
}
}
};
}
function pickDemoFrame() {
// Prefer real face-mesh data when the camera is running; else procedural.
return faceMeshFrame() || syntheticFrame();
}
async function fetchCloud() {
// Demo-only mode: never hit the network.
if (backendArg === "demo") {
transportMode = "demo";
handleData(pickDemoFrame());
return;
}
try {
var resp = await fetch(buildSplatsUrl(), { cache: "no-store" });
if (!resp.ok) throw new Error("HTTP " + resp.status);
var data = await resp.json();
transportMode = (backendArg === "auto") ? "live" : "remote";
handleData(data);
} catch (err) {
if (requireLive) {
document.getElementById("stats").innerHTML =
'<span class="demo">&#9679; OFFLINE</span><br>Live backend required (?live=1) but unreachable.<br><span class="label">' + (err && err.message ? err.message : err) + '</span>';
return;
}
transportMode = "demo";
handleData(pickDemoFrame());
}
}
function handleData(data) {
try {
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 — badge reflects active transport
var mode;
if (transportMode === "live") {
mode = '<span class="live">&#9679; LIVE</span> Local Backend';
} else if (transportMode === "remote") {
mode = '<span class="live">&#9679; REMOTE</span> ' + backendArg;
} else if (data.source === "face-mesh") {
mode = '<span class="face">&#9679; DEMO</span> Your Face (MediaPipe)';
} else {
mode = '<span class="demo">&#9679; DEMO</span> Synthetic';
}
var html = mode + "<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 + "&deg;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) {}
}
// Wire the camera CTA: shown only when we'll be rendering the demo path
// (auto-with-no-backend or explicit ?backend=demo). Hidden in live/remote.
(function wireCamCta() {
var btn = document.getElementById("cam-cta");
if (!btn) return;
// Hide CTA when user explicitly required live data.
if (requireLive || backendArg.startsWith("http")) {
btn.classList.add("hidden");
return;
}
btn.addEventListener("click", function() {
btn.textContent = "Starting camera…";
startFaceMesh();
});
})();
fetchCloud();
setInterval(fetchCloud, 250); // 4 Hz — enough for face mesh, light on the network
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>