645 lines
32 KiB
HTML
645 lines
32 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
<title>RuView · Through-Wall WiFi Sensing · LIVE CSI (no skeleton, no simulation)</title>
|
|
<!--
|
|
THROUGH-WALL WiFi-CSI SENSING DEMO — honest, real-data-only.
|
|
|
|
Renders ONLY what the running sensing-server actually streams over
|
|
ws://localhost:8765/ws/sensing :
|
|
- the 20x20 `signal_field` floor heatmap (real values)
|
|
- a coarse RF-localization puck from persons[0].position (NOT pose)
|
|
- live motion / presence / rssi / confidence meters
|
|
- the real `source` ("esp32" = LIVE) verbatim in the banner
|
|
|
|
It deliberately does NOT draw a skeleton. The server's
|
|
persons[].keypoints carry confidence:0.0 (image-pixel garbage, not
|
|
real 3D joints) so we never render them. WiFi CSI gives
|
|
motion/presence/coarse-position — that is the honest wow, and it
|
|
penetrates drywall. See README.md.
|
|
-->
|
|
<style>
|
|
:root {
|
|
--bg: #050507; --bg-panel: rgba(8,10,14,0.80);
|
|
--amber: #ffb840; --amber-hot: #ffe09f;
|
|
--cyan: #4cf; --magenta: #ff4cc8;
|
|
--text: #d8c69a; --text-mute: #6b6155;
|
|
--green: #4f4; --red: #f64;
|
|
--border: rgba(255,184,64,0.18);
|
|
}
|
|
* { box-sizing: border-box; }
|
|
body {
|
|
margin: 0; background: var(--bg); color: var(--text); overflow: hidden;
|
|
font-family: 'SF Mono', 'Cascadia Code', Consolas, monospace;
|
|
-webkit-font-smoothing: antialiased; font-size: 12px;
|
|
}
|
|
canvas { display: block; }
|
|
.overlay-frame {
|
|
position: fixed; inset: 0; pointer-events: none; z-index: 5;
|
|
background:
|
|
radial-gradient(ellipse at center, transparent 55%, rgba(0,0,0,0.55) 100%),
|
|
linear-gradient(180deg, rgba(0,0,0,0.32) 0%, transparent 18%, transparent 82%, rgba(0,0,0,0.38) 100%);
|
|
}
|
|
.scanlines {
|
|
position: fixed; inset: 0; pointer-events: none; z-index: 6;
|
|
background: repeating-linear-gradient(0deg, rgba(0,0,0,0.04) 0px, rgba(0,0,0,0.04) 1px, transparent 1px, transparent 3px);
|
|
mix-blend-mode: overlay; opacity: 0.5;
|
|
}
|
|
.panel {
|
|
position: absolute; background: var(--bg-panel); border: 1px solid var(--border);
|
|
border-radius: 4px; padding: 12px 14px; backdrop-filter: blur(8px);
|
|
box-shadow: 0 1px 0 rgba(255,184,64,0.04), 0 8px 32px rgba(0,0,0,0.55); z-index: 10;
|
|
}
|
|
.panel h2 {
|
|
margin: 0 0 8px 0; font-size: 10px; text-transform: uppercase; letter-spacing: 2px;
|
|
color: var(--amber); font-weight: 600; border-bottom: 1px solid var(--border); padding-bottom: 6px;
|
|
}
|
|
|
|
/* ---- Honest status banner (top-center, mutually exclusive states) ---- */
|
|
#banner {
|
|
position: fixed; top: 0; left: 0; right: 0; z-index: 30;
|
|
text-align: center; padding: 7px 12px; font-size: 12px; letter-spacing: 1px;
|
|
font-weight: 600; border-bottom: 1px solid rgba(0,0,0,0.4);
|
|
transition: background 0.3s, color 0.3s;
|
|
}
|
|
#banner.live { background: rgba(40,255,80,0.12); color: var(--green); border-bottom-color: rgba(80,255,120,0.4); }
|
|
#banner.sim { background: rgba(255,120,40,0.16); color: #ffae5a; border-bottom-color: rgba(255,140,60,0.5); }
|
|
#banner.noserver { background: rgba(255,80,80,0.16); color: var(--red); border-bottom-color: rgba(255,90,90,0.5); }
|
|
#banner .src { opacity: 0.8; font-weight: 400; }
|
|
#banner-caption {
|
|
position: fixed; top: 30px; left: 0; right: 0; z-index: 29;
|
|
text-align: center; font-size: 10px; color: var(--text-mute); letter-spacing: 0.5px;
|
|
pointer-events: none; padding-top: 2px;
|
|
}
|
|
|
|
#info { top: 64px; left: 20px; min-width: 270px; }
|
|
#info h1 { margin: 0 0 1px 0; font-size: 13px; letter-spacing: 1px; color: var(--amber-hot); font-weight: 600; }
|
|
#info .sub { font-size: 10px; color: var(--text-mute); letter-spacing: 0.5px; margin-bottom: 10px; padding-bottom: 8px; border-bottom: 1px solid var(--border); }
|
|
#info .row { display: flex; justify-content: space-between; gap: 12px; padding: 2px 0; }
|
|
#info .row .k { color: var(--text-mute); font-size: 11px; }
|
|
#info .row .v { color: var(--text); font-variant-numeric: tabular-nums; font-size: 11px; }
|
|
#info .row .v.amber { color: var(--amber); }
|
|
#info .row .v.cyan { color: var(--cyan); }
|
|
#info .row .v.green { color: var(--green); }
|
|
#info .row .v.red { color: var(--red); }
|
|
#info .row .v.mag { color: var(--magenta); }
|
|
#info .row .v.mute { color: var(--text-mute); }
|
|
|
|
#csi { top: 64px; right: 20px; min-width: 270px; }
|
|
#csi .bar-row { display: flex; align-items: center; gap: 8px; padding: 3px 0; font-size: 10px; }
|
|
#csi .bar-row .label { width: 86px; color: var(--text-mute); }
|
|
#csi .bar-row .bar-track { flex: 1; height: 6px; background: rgba(255,184,64,0.08); border-radius: 2px; overflow: hidden; }
|
|
#csi .bar-row .bar-fill {
|
|
height: 100%; background: linear-gradient(90deg, var(--amber-hot), var(--amber));
|
|
box-shadow: 0 0 6px var(--amber); transition: width 0.1s linear;
|
|
}
|
|
#csi .bar-row .val { width: 44px; text-align: right; color: var(--amber); font-variant-numeric: tabular-nums; }
|
|
#csi .spark { margin-top: 8px; }
|
|
#csi canvas { width: 100%; height: 38px; display: block; border: 1px solid var(--border); border-radius: 3px; background: rgba(0,0,0,0.3); }
|
|
#csi .legend { margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--border); font-size: 10px; color: var(--text-mute); line-height: 1.5; }
|
|
|
|
/* ---- waiting / no-server overlay ---- */
|
|
#waiting {
|
|
position: fixed; inset: 0; z-index: 25; display: none;
|
|
flex-direction: column; align-items: center; justify-content: center;
|
|
background: rgba(5,5,7,0.94); color: var(--amber); text-align: center; padding: 24px;
|
|
}
|
|
#waiting.show { display: flex; }
|
|
#waiting .big { font-size: 22px; letter-spacing: 2px; color: var(--red); margin-bottom: 16px; text-transform: uppercase; }
|
|
#waiting code {
|
|
display: block; text-align: left; max-width: 640px; margin: 8px auto;
|
|
background: rgba(255,184,64,0.06); border: 1px solid var(--border); border-radius: 4px;
|
|
padding: 10px 14px; color: var(--amber-hot); font-size: 12px; white-space: pre-wrap;
|
|
}
|
|
#waiting .pulse { animation: pulse 1.4s ease-in-out infinite; }
|
|
@keyframes pulse { 0%,100% { opacity: 0.55; } 50% { opacity: 1; } }
|
|
|
|
/* ---- optional webcam ground-truth tile ---- */
|
|
#cam-tile {
|
|
position: absolute; bottom: 20px; right: 20px; width: 240px; z-index: 12;
|
|
background: var(--bg-panel); border: 1px solid var(--border); border-radius: 4px;
|
|
padding: 8px; backdrop-filter: blur(8px);
|
|
}
|
|
#cam-tile h2 { margin: 0 0 6px 0; font-size: 9px; text-transform: uppercase; letter-spacing: 1.5px;
|
|
color: var(--cyan); font-weight: 600; }
|
|
#cam-tile .gt-note { font-size: 9px; color: var(--text-mute); margin-top: 4px; line-height: 1.4; }
|
|
#cam-video { width: 100%; border-radius: 3px; display: none; background: #000; }
|
|
#cam-tile button {
|
|
width: 100%; margin-top: 6px; padding: 5px 8px; font-family: inherit; font-size: 11px;
|
|
background: transparent; color: var(--cyan); border: 1px solid var(--cyan); border-radius: 3px; cursor: pointer;
|
|
}
|
|
#cam-tile button:hover { background: rgba(68,204,255,0.12); }
|
|
#cam-tile button:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
|
|
#legend-nodes {
|
|
position: absolute; bottom: 20px; left: 20px; min-width: 220px;
|
|
background: var(--bg-panel); border: 1px solid var(--border); border-radius: 4px;
|
|
padding: 12px 14px; backdrop-filter: blur(8px); z-index: 10;
|
|
}
|
|
#legend-nodes h2 { margin: 0 0 8px 0; font-size: 10px; text-transform: uppercase; letter-spacing: 2px;
|
|
color: var(--amber); font-weight: 600; border-bottom: 1px solid var(--border); padding-bottom: 6px; }
|
|
#legend-nodes .lr { display: flex; align-items: center; gap: 8px; padding: 2px 0; font-size: 11px; }
|
|
#legend-nodes .dot { width: 9px; height: 9px; border-radius: 50%; box-shadow: 0 0 6px currentColor; flex: 0 0 auto; }
|
|
#legend-nodes .muted { color: var(--text-mute); }
|
|
</style>
|
|
|
|
<!-- three.js r128 + addons (same CDN set as examples/three.js/demos/05) -->
|
|
<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/postprocessing/EffectComposer.js"></script>
|
|
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/RenderPass.js"></script>
|
|
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/ShaderPass.js"></script>
|
|
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/postprocessing/UnrealBloomPass.js"></script>
|
|
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/shaders/CopyShader.js"></script>
|
|
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/shaders/LuminosityHighPassShader.js"></script>
|
|
</head>
|
|
<body>
|
|
<div id="banner" class="noserver">NO SERVER — start the sensing-server <span class="src"></span></div>
|
|
<div id="banner-caption">Real WiFi CSI motion / presence / coarse-localization — penetrates drywall. Not skeletal pose.</div>
|
|
|
|
<div class="overlay-frame"></div>
|
|
<div class="scanlines"></div>
|
|
|
|
<div class="panel" id="info">
|
|
<h1>THROUGH-WALL WiFi SENSING</h1>
|
|
<div class="sub">Live CSI · ws://localhost:8765/ws/sensing</div>
|
|
<div class="row"><span class="k">source</span><span class="v amber" id="m-source">—</span></div>
|
|
<div class="row"><span class="k">presence</span><span class="v" id="m-presence">—</span></div>
|
|
<div class="row"><span class="k">motion level</span><span class="v" id="m-motion">—</span></div>
|
|
<div class="row"><span class="k">confidence</span><span class="v cyan" id="m-conf">—</span></div>
|
|
<div class="row"><span class="k">est. persons</span><span class="v amber" id="m-persons">—</span></div>
|
|
<div class="row"><span class="k">active nodes</span><span class="v" id="m-nodes">—</span></div>
|
|
<div class="row"><span class="k">tick</span><span class="v" id="m-tick">—</span></div>
|
|
<div class="row"><span class="k">update rate</span><span class="v cyan" id="m-fps">—</span></div>
|
|
</div>
|
|
|
|
<div class="panel" id="csi">
|
|
<h2>Live RF features</h2>
|
|
<div class="bar-row"><span class="label">motion</span><div class="bar-track"><div class="bar-fill" id="bar-motion"></div></div><span class="val" id="v-motion">—</span></div>
|
|
<div class="bar-row"><span class="label">breathing</span><div class="bar-track"><div class="bar-fill" id="bar-breath"></div></div><span class="val" id="v-breath">—</span></div>
|
|
<div class="bar-row"><span class="label">variance</span><div class="bar-track"><div class="bar-fill" id="bar-var"></div></div><span class="val" id="v-var">—</span></div>
|
|
<div class="bar-row"><span class="label">mean rssi</span><div class="bar-track"><div class="bar-fill" id="bar-rssi"></div></div><span class="val" id="v-rssi">—</span></div>
|
|
<div class="spark"><canvas id="spark" width="252" height="38"></canvas></div>
|
|
<div class="legend">motion sparkline (last ~6s of real motion_band_power)</div>
|
|
</div>
|
|
|
|
<div id="legend-nodes">
|
|
<h2>Sensor nodes</h2>
|
|
<div class="lr"><span class="dot" style="color:#4cf"></span><span>ESP32-S3 office <span class="muted">(node 9)</span></span></div>
|
|
<div class="lr"><span class="dot" style="color:#ff4cc8"></span><span>ESP32-S3 hallway <span class="muted">(node 13)</span></span></div>
|
|
<div class="lr" style="margin-top:6px"><span class="dot" style="color:#4f4"></span><span>RF localization <span class="muted">(coarse)</span></span></div>
|
|
<div class="lr"><span class="muted" style="font-size:10px;line-height:1.4">Office & hallway split by a wall + doorway. WiFi motion still shows through drywall.</span></div>
|
|
</div>
|
|
|
|
<div id="cam-tile">
|
|
<h2>camera — ground truth when visible</h2>
|
|
<video id="cam-video" autoplay muted playsinline></video>
|
|
<button id="cam-btn">▶ enable webcam (optional)</button>
|
|
<div class="gt-note">Independent of the CSI sensing. The WiFi works in the dark and through walls; the camera does not.</div>
|
|
</div>
|
|
|
|
<div id="waiting" class="show">
|
|
<div class="big pulse">Waiting for live sensing-server</div>
|
|
<div>No connection to <b>ws://localhost:8765/ws/sensing</b>. Start the real server, then this page connects automatically.</div>
|
|
<code>cd v2
|
|
cargo build -p wifi-densepose-sensing-server
|
|
./target/debug/sensing-server.exe --ws-port 8765 --udp-port 5005</code>
|
|
<div style="margin-top:10px; color:var(--text-mute); font-size:11px;">This demo renders ONLY real data. It never invents frames.</div>
|
|
</div>
|
|
|
|
<script>
|
|
"use strict";
|
|
// =====================================================================
|
|
// Config + WS endpoint (allow ?ws= override)
|
|
// =====================================================================
|
|
const params = new URLSearchParams(location.search);
|
|
const WS_URL = params.get('ws') || 'ws://localhost:8765/ws/sensing';
|
|
const ROOM_HALF = 5; // half-extent of the floor plane in metres
|
|
const GRID_N = 20; // signal_field is 20 x 20
|
|
|
|
// Known node anchor positions (server sends node 9 @ [2,0,1.5]; node 13
|
|
// joins later from the hallway side once its firmware is flashed). These
|
|
// are anchors for the room model + labels, NOT fabricated sensing data.
|
|
const NODE_ANCHORS = {
|
|
9: { pos: [ 2.0, 0.0, 1.5], color: 0x44ccff, label: 'office (node 9)' },
|
|
13: { pos: [-2.0, 0.0, -3.0], color: 0xff4cc8, label: 'hallway (node 13)' },
|
|
};
|
|
|
|
// =====================================================================
|
|
// Three.js scene (reused pattern from demos/05-skinned-realtime.html)
|
|
// =====================================================================
|
|
const scene = new THREE.Scene();
|
|
scene.background = new THREE.Color(0x050507);
|
|
scene.fog = new THREE.FogExp2(0x050507, 0.045);
|
|
|
|
const camera = new THREE.PerspectiveCamera(50, window.innerWidth/window.innerHeight, 0.05, 100);
|
|
camera.position.set(4.5, 4.2, 6.0);
|
|
|
|
const renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: 'high-performance' });
|
|
renderer.setPixelRatio(Math.min(2, window.devicePixelRatio));
|
|
renderer.setSize(window.innerWidth, window.innerHeight);
|
|
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
|
renderer.toneMappingExposure = 0.85;
|
|
renderer.outputEncoding = THREE.sRGBEncoding;
|
|
document.body.appendChild(renderer.domElement);
|
|
|
|
const controls = new THREE.OrbitControls(camera, renderer.domElement);
|
|
controls.target.set(0, 0.4, -0.5);
|
|
controls.enableDamping = true; controls.dampingFactor = 0.06;
|
|
controls.minDistance = 3; controls.maxDistance = 18;
|
|
controls.maxPolarAngle = Math.PI * 0.49;
|
|
|
|
scene.add(new THREE.HemisphereLight(0x553a18, 0x080606, 0.7));
|
|
const keyLight = new THREE.DirectionalLight(0xffc070, 0.9);
|
|
keyLight.position.set(3, 6, 4);
|
|
scene.add(keyLight);
|
|
|
|
// Post-processing — gentle bloom so the heatmap + puck glow.
|
|
const composer = new THREE.EffectComposer(renderer);
|
|
composer.addPass(new THREE.RenderPass(scene, camera));
|
|
const bloom = new THREE.UnrealBloomPass(
|
|
new THREE.Vector2(window.innerWidth, window.innerHeight), 0.55, 0.45, 0.82);
|
|
composer.addPass(bloom);
|
|
|
|
// =====================================================================
|
|
// Room: floor grid + wall + doorway dividing office / hallway
|
|
// =====================================================================
|
|
const gridHelper = new THREE.GridHelper(2*ROOM_HALF, GRID_N, 0x554a32, 0x2a2418);
|
|
gridHelper.position.y = 0.002;
|
|
scene.add(gridHelper);
|
|
|
|
// Dividing wall runs along world X near z = -1 (office z>-1, hallway z<-1),
|
|
// with a doorway gap. Two wall segments leave a gap in the middle.
|
|
const wallMat = new THREE.MeshStandardMaterial({
|
|
color: 0x1b2330, transparent: true, opacity: 0.55, roughness: 0.9,
|
|
side: THREE.DoubleSide,
|
|
});
|
|
const wallH = 1.4, wallZ = -1.0;
|
|
function addWallSeg(cx, w) {
|
|
const m = new THREE.Mesh(new THREE.BoxGeometry(w, wallH, 0.08), wallMat);
|
|
m.position.set(cx, wallH/2, wallZ);
|
|
scene.add(m);
|
|
// top edge highlight
|
|
const edge = new THREE.Mesh(new THREE.BoxGeometry(w, 0.02, 0.10),
|
|
new THREE.MeshBasicMaterial({ color: 0x4cf, transparent: true, opacity: 0.5 }));
|
|
edge.position.set(cx, wallH, wallZ);
|
|
scene.add(edge);
|
|
}
|
|
// left segment, doorway gap (-0.7..0.7), right segment
|
|
addWallSeg(-3.15, 3.7);
|
|
addWallSeg( 3.15, 3.7);
|
|
|
|
// Room labels (sprite text) for OFFICE / HALLWAY
|
|
function makeLabel(text, color) {
|
|
const c = document.createElement('canvas'); c.width = 256; c.height = 64;
|
|
const ctx = c.getContext('2d');
|
|
ctx.fillStyle = color; ctx.font = 'bold 30px Consolas, monospace';
|
|
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
|
ctx.fillText(text, 128, 34);
|
|
const tex = new THREE.CanvasTexture(c);
|
|
const spr = new THREE.Sprite(new THREE.SpriteMaterial({ map: tex, transparent: true, depthTest: false }));
|
|
spr.scale.set(2.0, 0.5, 1);
|
|
return spr;
|
|
}
|
|
const officeLbl = makeLabel('OFFICE', '#ffb840'); officeLbl.position.set(2.6, 0.06, 2.6); scene.add(officeLbl);
|
|
const hallLbl = makeLabel('HALLWAY', '#ff4cc8'); hallLbl.position.set(-2.6, 0.06, -3.2); scene.add(hallLbl);
|
|
|
|
// =====================================================================
|
|
// Node markers (office / hallway). The hallway node is dimmed until it
|
|
// actually appears in the live `nodes` list.
|
|
// =====================================================================
|
|
const nodeMeshes = {};
|
|
function buildNode(id) {
|
|
const a = NODE_ANCHORS[id];
|
|
const g = new THREE.Group();
|
|
const post = new THREE.Mesh(
|
|
new THREE.CylinderGeometry(0.05, 0.07, 0.9, 12),
|
|
new THREE.MeshStandardMaterial({ color: a.color, emissive: a.color, emissiveIntensity: 0.4, roughness: 0.4 }));
|
|
post.position.y = 0.45; g.add(post);
|
|
const orb = new THREE.Mesh(
|
|
new THREE.SphereGeometry(0.12, 20, 16),
|
|
new THREE.MeshBasicMaterial({ color: a.color }));
|
|
orb.position.y = 0.95; g.add(orb);
|
|
const ring = new THREE.Mesh(
|
|
new THREE.RingGeometry(0.18, 0.24, 32),
|
|
new THREE.MeshBasicMaterial({ color: a.color, transparent: true, opacity: 0.6, side: THREE.DoubleSide }));
|
|
ring.rotation.x = -Math.PI/2; ring.position.y = 0.01; g.add(ring);
|
|
const lbl = makeLabel('ESP32-S3 ' + a.label, '#' + a.color.toString(16).padStart(6,'0'));
|
|
lbl.scale.set(2.6, 0.65, 1); lbl.position.set(0, 1.25, 0); g.add(lbl);
|
|
g.position.set(a.pos[0], 0, a.pos[2]);
|
|
g.userData.parts = { post, orb, ring };
|
|
scene.add(g);
|
|
return g;
|
|
}
|
|
Object.keys(NODE_ANCHORS).forEach(id => { nodeMeshes[id] = buildNode(+id); });
|
|
function setNodeActive(id, active) {
|
|
const g = nodeMeshes[id]; if (!g) return;
|
|
const o = active ? 1.0 : 0.22;
|
|
const parts = g.userData.parts;
|
|
parts.orb.material.opacity = o; parts.orb.material.transparent = true;
|
|
parts.ring.material.opacity = 0.6 * o;
|
|
parts.post.material.emissiveIntensity = active ? 0.5 : 0.12;
|
|
}
|
|
setNodeActive(9, false); setNodeActive(13, false);
|
|
|
|
// =====================================================================
|
|
// signal_field 20x20 floor heatmap — instanced colored tiles.
|
|
// Driven ONLY by real `signal_field.values` (400 floats ~0..1).
|
|
// =====================================================================
|
|
const TILE = (2*ROOM_HALF) / GRID_N;
|
|
const heatGeo = new THREE.PlaneGeometry(TILE * 0.96, TILE * 0.96);
|
|
const heatMat = new THREE.MeshBasicMaterial({ vertexColors: true, transparent: true, opacity: 0.85, side: THREE.DoubleSide });
|
|
const heatMesh = new THREE.InstancedMesh(heatGeo, heatMat, GRID_N * GRID_N);
|
|
heatMesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
|
|
const heatColor = new THREE.InstancedBufferAttribute(new Float32Array(GRID_N * GRID_N * 3), 3);
|
|
heatMesh.instanceColor = heatColor;
|
|
const _m = new THREE.Matrix4();
|
|
const _q = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(1,0,0), -Math.PI/2);
|
|
const _s = new THREE.Vector3(1,1,1);
|
|
const _p = new THREE.Vector3();
|
|
// gridCell (gx,gz) -> world (x,z). gx,gz in [0,GRID_N).
|
|
function cellToWorld(gx, gz) {
|
|
return [ (gx + 0.5) * TILE - ROOM_HALF, (gz + 0.5) * TILE - ROOM_HALF ];
|
|
}
|
|
for (let gz = 0; gz < GRID_N; gz++) {
|
|
for (let gx = 0; gx < GRID_N; gx++) {
|
|
const i = gz * GRID_N + gx;
|
|
const [wx, wz] = cellToWorld(gx, gz);
|
|
_p.set(wx, 0.012, wz);
|
|
_m.compose(_p, _q, _s);
|
|
heatMesh.setMatrixAt(i, _m);
|
|
heatColor.setXYZ(i, 0.02, 0.02, 0.03);
|
|
}
|
|
}
|
|
heatMesh.instanceMatrix.needsUpdate = true;
|
|
scene.add(heatMesh);
|
|
|
|
// amber→white heat ramp for a value in [0,1]
|
|
function heatRamp(v, out) {
|
|
v = Math.max(0, Math.min(1, v));
|
|
// dark -> amber -> hot white
|
|
const r = Math.min(1, 0.05 + 1.6 * v);
|
|
const g = Math.min(1, 0.02 + 1.1 * v * v);
|
|
const b = Math.min(1, 0.04 + 0.9 * Math.pow(v, 3));
|
|
out.set(r, g, b);
|
|
return out;
|
|
}
|
|
const _c = new THREE.Color();
|
|
let lastFieldPeak = { gx: GRID_N/2|0, gz: GRID_N/2|0, v: 0 };
|
|
function updateHeatmap(field) {
|
|
if (!field || !Array.isArray(field.values)) return;
|
|
const vals = field.values;
|
|
// grid_size is [20,1,20]; values are row-major 400 floats.
|
|
let peakV = -1, peakGx = lastFieldPeak.gx, peakGz = lastFieldPeak.gz;
|
|
const n = Math.min(vals.length, GRID_N * GRID_N);
|
|
for (let i = 0; i < n; i++) {
|
|
const v = vals[i];
|
|
heatRamp(v, _c);
|
|
heatColor.setXYZ(i, _c.r, _c.g, _c.b);
|
|
if (v > peakV) { peakV = v; peakGx = i % GRID_N; peakGz = (i / GRID_N) | 0; }
|
|
}
|
|
heatColor.needsUpdate = true;
|
|
lastFieldPeak = { gx: peakGx, gz: peakGz, v: peakV };
|
|
}
|
|
|
|
// =====================================================================
|
|
// RF-localization puck — from persons[0].position (coarse, NOT pose).
|
|
// Falls back to the signal_field peak cell when no person is present.
|
|
// =====================================================================
|
|
const puck = new THREE.Group();
|
|
const puckCore = new THREE.Mesh(
|
|
new THREE.SphereGeometry(0.16, 24, 18),
|
|
new THREE.MeshBasicMaterial({ color: 0x66ff88 }));
|
|
puckCore.position.y = 0.16; puck.add(puckCore);
|
|
const puckRing = new THREE.Mesh(
|
|
new THREE.RingGeometry(0.28, 0.36, 40),
|
|
new THREE.MeshBasicMaterial({ color: 0x66ff88, transparent: true, opacity: 0.7, side: THREE.DoubleSide }));
|
|
puckRing.rotation.x = -Math.PI/2; puckRing.position.y = 0.02; puck.add(puckRing);
|
|
const puckBeam = new THREE.Mesh(
|
|
new THREE.CylinderGeometry(0.03, 0.03, 1.2, 8),
|
|
new THREE.MeshBasicMaterial({ color: 0x66ff88, transparent: true, opacity: 0.35 }));
|
|
puckBeam.position.y = 0.6; puck.add(puckBeam);
|
|
puck.visible = false;
|
|
scene.add(puck);
|
|
const puckTarget = new THREE.Vector3(0, 0, 0);
|
|
|
|
function updatePuck(frame) {
|
|
let wx = null, wz = null, present = false;
|
|
const persons = frame.persons || [];
|
|
if (persons.length && Array.isArray(persons[0].position)) {
|
|
// server position is [x, 0, z] in metres, origin at room centre
|
|
wx = persons[0].position[0];
|
|
wz = persons[0].position[2];
|
|
present = true;
|
|
}
|
|
// If no person but the field has clear energy, show the peak cell
|
|
// (coarse) so the puck honestly tracks "where the RF energy is".
|
|
if (!present && lastFieldPeak.v > 0.55) {
|
|
const peak = cellToWorld(lastFieldPeak.gx, lastFieldPeak.gz);
|
|
wx = peak[0]; wz = peak[1]; present = true;
|
|
}
|
|
if (present && wx !== null) {
|
|
// clamp into the room so it never flies off the floor
|
|
wx = Math.max(-ROOM_HALF+0.3, Math.min(ROOM_HALF-0.3, wx));
|
|
wz = Math.max(-ROOM_HALF+0.3, Math.min(ROOM_HALF-0.3, wz));
|
|
puckTarget.set(wx, 0, wz);
|
|
puck.visible = true;
|
|
} else {
|
|
puck.visible = false;
|
|
}
|
|
}
|
|
|
|
// =====================================================================
|
|
// HUD updates
|
|
// =====================================================================
|
|
const $ = id => document.getElementById(id);
|
|
function clamp01(x){ return Math.max(0, Math.min(1, x)); }
|
|
function setBar(barId, valId, frac, text) {
|
|
$(barId).style.width = (clamp01(frac) * 100).toFixed(0) + '%';
|
|
$(valId).textContent = text;
|
|
}
|
|
|
|
// motion sparkline ring buffer
|
|
const sparkCtx = $('spark').getContext('2d');
|
|
const SPARK_N = 120;
|
|
const sparkBuf = new Array(SPARK_N).fill(0);
|
|
function pushSpark(v) {
|
|
sparkBuf.push(v); if (sparkBuf.length > SPARK_N) sparkBuf.shift();
|
|
const w = sparkCtx.canvas.width, h = sparkCtx.canvas.height;
|
|
sparkCtx.clearRect(0,0,w,h);
|
|
let maxV = 40; for (const x of sparkBuf) if (x > maxV) maxV = x;
|
|
sparkCtx.strokeStyle = '#ffb840'; sparkCtx.lineWidth = 1.5; sparkCtx.beginPath();
|
|
for (let i = 0; i < sparkBuf.length; i++) {
|
|
const x = (i / (SPARK_N-1)) * w;
|
|
const y = h - (sparkBuf[i] / maxV) * (h - 3) - 1.5;
|
|
i === 0 ? sparkCtx.moveTo(x, y) : sparkCtx.lineTo(x, y);
|
|
}
|
|
sparkCtx.stroke();
|
|
}
|
|
|
|
// =====================================================================
|
|
// Honest status banner (strict, mutually exclusive)
|
|
// =====================================================================
|
|
const banner = $('banner');
|
|
function setBannerLive(source, nodeCount) {
|
|
if (source === 'esp32') {
|
|
banner.className = 'live';
|
|
banner.innerHTML = 'LIVE — real ESP32 CSI <span class="src">(source=' + source + ', ' + nodeCount + ' node' + (nodeCount === 1 ? '' : 's') + ')</span>';
|
|
} else {
|
|
// anything not esp32 = explicitly NOT real, badged
|
|
banner.className = 'sim';
|
|
banner.innerHTML = 'SIMULATED — not real <span class="src">(source=' + source + ' — start an ESP32 for live CSI)</span>';
|
|
}
|
|
}
|
|
function setBannerNoServer() {
|
|
banner.className = 'noserver';
|
|
banner.innerHTML = 'NO SERVER — start the sensing-server <span class="src">(ws://localhost:8765/ws/sensing unreachable)</span>';
|
|
}
|
|
|
|
// =====================================================================
|
|
// WebSocket — render ONLY real frames. Reconnect; never fabricate.
|
|
// =====================================================================
|
|
let ws = null, gotFrame = false;
|
|
let frameTimes = []; // for measured update rate (fps)
|
|
let lastFrame = null; // most recent real frame (render loop interpolates puck)
|
|
|
|
function connect() {
|
|
setBannerNoServer();
|
|
try { ws = new WebSocket(WS_URL); }
|
|
catch (e) { scheduleReconnect(); return; }
|
|
|
|
ws.onopen = () => { /* wait for first frame before claiming LIVE */ };
|
|
ws.onmessage = (ev) => {
|
|
let d; try { d = JSON.parse(ev.data); } catch (e) { return; }
|
|
if (!d || d.type !== 'sensing_update') return;
|
|
onFrame(d);
|
|
};
|
|
ws.onclose = () => { gotFrame = false; $('waiting').classList.add('show'); setBannerNoServer(); scheduleReconnect(); };
|
|
ws.onerror = () => { try { ws.close(); } catch (e) {} };
|
|
}
|
|
let reconnectT = null;
|
|
function scheduleReconnect() {
|
|
if (reconnectT) return;
|
|
reconnectT = setTimeout(() => { reconnectT = null; connect(); }, 1500);
|
|
}
|
|
|
|
function onFrame(d) {
|
|
gotFrame = true;
|
|
lastFrame = d;
|
|
$('waiting').classList.remove('show');
|
|
|
|
const source = d.source || 'unknown';
|
|
const nodes = Array.isArray(d.nodes) ? d.nodes : [];
|
|
setBannerLive(source, nodes.length);
|
|
|
|
// measured update rate
|
|
const now = performance.now();
|
|
frameTimes.push(now);
|
|
while (frameTimes.length && now - frameTimes[0] > 2000) frameTimes.shift();
|
|
const fps = frameTimes.length > 1 ? (frameTimes.length - 1) / ((frameTimes[frameTimes.length-1] - frameTimes[0]) / 1000) : 0;
|
|
|
|
const cls = d.classification || {};
|
|
const feat = d.features || {};
|
|
|
|
// info panel
|
|
$('m-source').textContent = source.toUpperCase();
|
|
$('m-source').className = 'v ' + (source === 'esp32' ? 'green' : 'red');
|
|
const presence = !!cls.presence;
|
|
$('m-presence').textContent = presence ? (cls.motion_level === 'present_moving' ? 'PRESENT · MOVING' : 'PRESENT') : 'CLEAR';
|
|
$('m-presence').className = 'v ' + (presence ? 'green' : 'mute');
|
|
$('m-motion').textContent = cls.motion_level || '—';
|
|
$('m-conf').textContent = (cls.confidence != null) ? cls.confidence.toFixed(2) : '—';
|
|
$('m-persons').textContent = (d.estimated_persons != null) ? d.estimated_persons : '—';
|
|
$('m-nodes').textContent = nodes.length + ' (' + nodes.map(n => n.node_id).join(', ') + ')';
|
|
$('m-tick').textContent = (d.tick != null) ? d.tick : '—';
|
|
$('m-fps').textContent = fps ? fps.toFixed(1) + ' Hz' : '—';
|
|
|
|
// feature bars (real values, scaled into 0..1 for the bar width only)
|
|
const motion = feat.motion_band_power || 0;
|
|
const breath = feat.breathing_band_power || 0;
|
|
const variance = feat.variance || 0;
|
|
const rssi = feat.mean_rssi != null ? feat.mean_rssi : -100;
|
|
setBar('bar-motion', 'v-motion', motion / 100, motion.toFixed(1));
|
|
setBar('bar-breath', 'v-breath', breath / 100, breath.toFixed(1));
|
|
setBar('bar-var', 'v-var', variance / 80, variance.toFixed(1));
|
|
// rssi: map -90..-30 dBm -> 0..1
|
|
setBar('bar-rssi', 'v-rssi', (rssi + 90) / 60, rssi.toFixed(0));
|
|
pushSpark(motion);
|
|
|
|
// node activity
|
|
const activeIds = new Set(nodes.map(n => n.node_id));
|
|
[9, 13].forEach(id => setNodeActive(id, activeIds.has(id)));
|
|
|
|
// heatmap + puck
|
|
updateHeatmap(d.signal_field);
|
|
updatePuck(d);
|
|
}
|
|
|
|
// =====================================================================
|
|
// Optional webcam ground-truth tile (reused from demos/05). Camera is
|
|
// separate from CSI sensing — labeled "ground truth when visible".
|
|
// =====================================================================
|
|
let camStream = null;
|
|
$('cam-btn').addEventListener('click', async () => {
|
|
const btn = $('cam-btn');
|
|
if (camStream) { // toggle off
|
|
camStream.getTracks().forEach(t => t.stop());
|
|
$('cam-video').style.display = 'none'; camStream = null;
|
|
btn.textContent = '▶ enable webcam (optional)';
|
|
return;
|
|
}
|
|
btn.disabled = true; btn.textContent = 'requesting camera…';
|
|
try {
|
|
camStream = await navigator.mediaDevices.getUserMedia({
|
|
video: { width: { ideal: 640 }, height: { ideal: 480 }, facingMode: 'user' }, audio: false,
|
|
});
|
|
const v = $('cam-video'); v.srcObject = camStream; v.style.display = 'block';
|
|
btn.textContent = '■ stop webcam'; btn.disabled = false;
|
|
} catch (e) {
|
|
btn.textContent = '✗ camera unavailable'; btn.disabled = false; console.error(e);
|
|
setTimeout(() => { if (!camStream) btn.textContent = '▶ enable webcam (optional)'; }, 2000);
|
|
}
|
|
});
|
|
|
|
// =====================================================================
|
|
// Render loop — smooth the puck toward its real target; pulse rings.
|
|
// =====================================================================
|
|
const clock = new THREE.Clock();
|
|
function animate() {
|
|
requestAnimationFrame(animate);
|
|
const t = clock.getElapsedTime();
|
|
controls.update();
|
|
|
|
if (puck.visible) {
|
|
puck.position.lerp(puckTarget, 0.18);
|
|
const pulse = 0.8 + 0.25 * Math.sin(t * 3.0);
|
|
puckRing.scale.set(pulse, pulse, pulse);
|
|
puckRing.material.opacity = 0.5 + 0.25 * Math.sin(t * 3.0);
|
|
}
|
|
// node rings breathe when active
|
|
[9,13].forEach(id => {
|
|
const g = nodeMeshes[id]; if (!g) return;
|
|
const r = g.userData.parts.ring;
|
|
const s = 1 + 0.08 * Math.sin(t * 2 + id);
|
|
r.scale.set(s, s, s);
|
|
});
|
|
|
|
composer.render();
|
|
}
|
|
animate();
|
|
|
|
window.addEventListener('resize', () => {
|
|
camera.aspect = window.innerWidth / window.innerHeight;
|
|
camera.updateProjectionMatrix();
|
|
renderer.setSize(window.innerWidth, window.innerHeight);
|
|
composer.setSize(window.innerWidth, window.innerHeight);
|
|
});
|
|
|
|
// kick off
|
|
connect();
|
|
</script>
|
|
</body>
|
|
</html>
|