diff --git a/README.md b/README.md index a231791..474bc3e 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,17 @@ # WiFi DensePose +> **Hardware Required:** This system processes real WiFi Channel State Information (CSI) data. To capture live CSI you need one of: +> +> | Option | Hardware | Cost | Capabilities | +> |--------|----------|------|-------------| +> | **ESP32 Mesh** (recommended) | 3-6x ESP32-S3 boards + consumer WiFi router | ~$54 | Presence, motion, respiration detection | +> | **Research NIC** | Intel 5300 or Atheros AR9580 (discontinued) | ~$50-100 | Full CSI with 3x3 MIMO | +> | **Commodity WiFi** | Any Linux laptop with WiFi | $0 | Presence and coarse motion only (RSSI-based) | +> +> Without CSI-capable hardware, you can verify the signal processing pipeline using the included deterministic reference signal: `python v1/data/proof/verify.py` +> +> See [docs/adr/ADR-012-esp32-csi-sensor-mesh.md](docs/adr/ADR-012-esp32-csi-sensor-mesh.md) for the ESP32 setup guide and [docs/adr/ADR-013-feature-level-sensing-commodity-gear.md](docs/adr/ADR-013-feature-level-sensing-commodity-gear.md) for the zero-cost RSSI path. + [![Python 3.8+](https://img.shields.io/badge/python-3.8+-blue.svg)](https://www.python.org/downloads/) [![FastAPI](https://img.shields.io/badge/FastAPI-0.95+-green.svg)](https://fastapi.tiangolo.com/) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) diff --git a/ui/components/dashboard-hud.js b/ui/components/dashboard-hud.js new file mode 100644 index 0000000..8349a37 --- /dev/null +++ b/ui/components/dashboard-hud.js @@ -0,0 +1,429 @@ +// Dashboard HUD Overlay - WiFi DensePose 3D Visualization +// Connection status, FPS counter, detection confidence, person count, sensing mode + +export class DashboardHUD { + constructor(container) { + this.container = typeof container === 'string' + ? document.getElementById(container) + : container; + + // State + this.state = { + connectionStatus: 'disconnected', // connected, disconnected, connecting, error + isRealData: false, + fps: 0, + confidence: 0, + personCount: 0, + sensingMode: 'Mock', // CSI, RSSI, Mock + latency: 0, + messageCount: 0, + uptime: 0 + }; + + this._fpsFrames = []; + this._lastFpsUpdate = 0; + + this._build(); + } + + _build() { + // Create HUD overlay container + this.hudElement = document.createElement('div'); + this.hudElement.id = 'viz-hud'; + this.hudElement.innerHTML = ` + + + +
MOCK DATA
+ + +
+
+
+
+ + +
+
+ + Disconnected +
+
+ Latency + -- ms +
+
+ Messages + 0 +
+
+ Uptime + 0s +
+
+ + +
+
-- FPS
+
+ Frame + -- ms +
+
+ + +
+
+ Persons + 0 +
+
+ Confidence + 0% +
+
+
+
+
+ + +
+
MOCK
+
+ WiFi DensePose +
+
+ + +
+ Drag to orbit | Scroll to zoom | Right-click to pan +
+ `; + + this.container.style.position = 'relative'; + this.container.appendChild(this.hudElement); + + // Cache DOM references + this._els = { + banner: this.hudElement.querySelector('#hud-banner'), + statusDot: this.hudElement.querySelector('#hud-status-dot'), + connStatus: this.hudElement.querySelector('#hud-conn-status'), + latency: this.hudElement.querySelector('#hud-latency'), + msgCount: this.hudElement.querySelector('#hud-msg-count'), + uptime: this.hudElement.querySelector('#hud-uptime'), + fps: this.hudElement.querySelector('#hud-fps'), + frameTime: this.hudElement.querySelector('#hud-frame-time'), + personCount: this.hudElement.querySelector('#hud-person-count'), + confidence: this.hudElement.querySelector('#hud-confidence'), + confidenceFill: this.hudElement.querySelector('#hud-confidence-fill'), + modeBadge: this.hudElement.querySelector('#hud-mode-badge') + }; + } + + // Update state from external data + updateState(newState) { + Object.assign(this.state, newState); + this._render(); + } + + // Track FPS - call each frame + tickFPS() { + const now = performance.now(); + this._fpsFrames.push(now); + + // Keep only last second of frames + while (this._fpsFrames.length > 0 && this._fpsFrames[0] < now - 1000) { + this._fpsFrames.shift(); + } + + // Update FPS display at most 4 times per second + if (now - this._lastFpsUpdate > 250) { + this.state.fps = this._fpsFrames.length; + const frameTime = this._fpsFrames.length > 1 + ? (now - this._fpsFrames[0]) / (this._fpsFrames.length - 1) + : 0; + this._lastFpsUpdate = now; + + // Update FPS elements + this._els.fps.textContent = `${this.state.fps} FPS`; + this._els.fps.className = 'hud-fps ' + ( + this.state.fps >= 50 ? 'high' : this.state.fps >= 25 ? 'mid' : 'low' + ); + this._els.frameTime.textContent = `${frameTime.toFixed(1)} ms`; + } + } + + _render() { + const { state } = this; + + // Banner + if (state.isRealData) { + this._els.banner.textContent = 'REAL DATA - LIVE STREAM'; + this._els.banner.className = 'hud-banner real'; + } else { + this._els.banner.textContent = 'MOCK DATA - DEMO MODE'; + this._els.banner.className = 'hud-banner mock'; + } + + // Connection status + this._els.statusDot.className = `hud-status-dot ${state.connectionStatus}`; + const statusText = { + connected: 'Connected', + disconnected: 'Disconnected', + connecting: 'Connecting...', + error: 'Error' + }; + this._els.connStatus.textContent = statusText[state.connectionStatus] || 'Unknown'; + + // Latency + this._els.latency.textContent = state.latency > 0 ? `${state.latency.toFixed(0)} ms` : '-- ms'; + + // Messages + this._els.msgCount.textContent = state.messageCount.toLocaleString(); + + // Uptime + const uptimeSec = Math.floor(state.uptime); + if (uptimeSec < 60) { + this._els.uptime.textContent = `${uptimeSec}s`; + } else if (uptimeSec < 3600) { + this._els.uptime.textContent = `${Math.floor(uptimeSec / 60)}m ${uptimeSec % 60}s`; + } else { + const h = Math.floor(uptimeSec / 3600); + const m = Math.floor((uptimeSec % 3600) / 60); + this._els.uptime.textContent = `${h}h ${m}m`; + } + + // Person count + this._els.personCount.textContent = state.personCount; + this._els.personCount.style.color = state.personCount > 0 ? '#00ff88' : '#556677'; + + // Confidence + const confPct = (state.confidence * 100).toFixed(1); + this._els.confidence.textContent = `${confPct}%`; + this._els.confidenceFill.style.width = `${state.confidence * 100}%`; + // Color temperature: red (low) -> yellow (mid) -> green (high) + const confHue = state.confidence * 120; // 0=red, 60=yellow, 120=green + this._els.confidenceFill.style.background = `hsl(${confHue}, 100%, 45%)`; + + // Sensing mode + const modeLower = (state.sensingMode || 'Mock').toLowerCase(); + this._els.modeBadge.textContent = state.sensingMode.toUpperCase(); + this._els.modeBadge.className = `hud-mode-badge ${modeLower}`; + } + + dispose() { + if (this.hudElement && this.hudElement.parentNode) { + this.hudElement.parentNode.removeChild(this.hudElement); + } + } +} diff --git a/ui/components/environment.js b/ui/components/environment.js new file mode 100644 index 0000000..116b218 --- /dev/null +++ b/ui/components/environment.js @@ -0,0 +1,476 @@ +// Room Environment - WiFi DensePose 3D Visualization +// Grid floor, AP/receiver markers, detection zones, confidence heatmap + +export class Environment { + constructor(scene) { + this.scene = scene; + this.group = new THREE.Group(); + this.group.name = 'environment'; + + // Room dimensions (meters) + this.roomWidth = 8; + this.roomDepth = 6; + this.roomHeight = 3; + + // AP and receiver positions + this.accessPoints = [ + { id: 'TX1', pos: [-3.5, 2.5, -2.8], type: 'transmitter' }, + { id: 'TX2', pos: [0, 2.5, -2.8], type: 'transmitter' }, + { id: 'TX3', pos: [3.5, 2.5, -2.8], type: 'transmitter' } + ]; + this.receivers = [ + { id: 'RX1', pos: [-3.5, 2.5, 2.8], type: 'receiver' }, + { id: 'RX2', pos: [0, 2.5, 2.8], type: 'receiver' }, + { id: 'RX3', pos: [3.5, 2.5, 2.8], type: 'receiver' } + ]; + + // Detection zones + this.zones = [ + { id: 'zone_1', center: [-2, 0, 0], radius: 2, color: 0x0066ff, label: 'Zone 1' }, + { id: 'zone_2', center: [0, 0, 0], radius: 2, color: 0x00cc66, label: 'Zone 2' }, + { id: 'zone_3', center: [2, 0, 0], radius: 2, color: 0xff6600, label: 'Zone 3' } + ]; + + // Confidence heatmap state + this._heatmapData = new Float32Array(20 * 15); // 20x15 grid + this._heatmapCells = []; + + // Build everything + this._buildFloor(); + this._buildGrid(); + this._buildWalls(); + this._buildAPMarkers(); + this._buildSignalPaths(); + this._buildDetectionZones(); + this._buildConfidenceHeatmap(); + + this.scene.add(this.group); + } + + _buildFloor() { + // Dark reflective floor + const floorGeom = new THREE.PlaneGeometry(this.roomWidth, this.roomDepth); + const floorMat = new THREE.MeshPhongMaterial({ + color: 0x0a0a15, + emissive: 0x050510, + shininess: 60, + specular: 0x111122, + transparent: true, + opacity: 0.95, + side: THREE.DoubleSide + }); + const floor = new THREE.Mesh(floorGeom, floorMat); + floor.rotation.x = -Math.PI / 2; + floor.position.y = 0; + floor.receiveShadow = true; + this.group.add(floor); + } + + _buildGrid() { + // Grid lines on the floor + const gridGroup = new THREE.Group(); + const gridMat = new THREE.LineBasicMaterial({ + color: 0x1a1a3a, + transparent: true, + opacity: 0.4 + }); + + const halfW = this.roomWidth / 2; + const halfD = this.roomDepth / 2; + const step = 0.5; + + // Lines along X + for (let z = -halfD; z <= halfD; z += step) { + const geom = new THREE.BufferGeometry(); + const positions = new Float32Array([-halfW, 0.005, z, halfW, 0.005, z]); + geom.setAttribute('position', new THREE.BufferAttribute(positions, 3)); + gridGroup.add(new THREE.Line(geom, gridMat)); + } + + // Lines along Z + for (let x = -halfW; x <= halfW; x += step) { + const geom = new THREE.BufferGeometry(); + const positions = new Float32Array([x, 0.005, -halfD, x, 0.005, halfD]); + geom.setAttribute('position', new THREE.BufferAttribute(positions, 3)); + gridGroup.add(new THREE.Line(geom, gridMat)); + } + + // Brighter center lines + const centerMat = new THREE.LineBasicMaterial({ + color: 0x2233aa, + transparent: true, + opacity: 0.25 + }); + const centerX = new THREE.BufferGeometry(); + centerX.setAttribute('position', new THREE.BufferAttribute( + new Float32Array([-halfW, 0.006, 0, halfW, 0.006, 0]), 3)); + gridGroup.add(new THREE.Line(centerX, centerMat)); + + const centerZ = new THREE.BufferGeometry(); + centerZ.setAttribute('position', new THREE.BufferAttribute( + new Float32Array([0, 0.006, -halfD, 0, 0.006, halfD]), 3)); + gridGroup.add(new THREE.Line(centerZ, centerMat)); + + this.group.add(gridGroup); + } + + _buildWalls() { + // Subtle transparent walls to define the room boundary + const wallMat = new THREE.MeshBasicMaterial({ + color: 0x112244, + transparent: true, + opacity: 0.06, + side: THREE.DoubleSide, + depthWrite: false + }); + + const halfW = this.roomWidth / 2; + const halfD = this.roomDepth / 2; + const h = this.roomHeight; + + // Back wall + const backWall = new THREE.Mesh(new THREE.PlaneGeometry(this.roomWidth, h), wallMat); + backWall.position.set(0, h / 2, -halfD); + this.group.add(backWall); + + // Front wall (more transparent) + const frontMat = wallMat.clone(); + frontMat.opacity = 0.03; + const frontWall = new THREE.Mesh(new THREE.PlaneGeometry(this.roomWidth, h), frontMat); + frontWall.position.set(0, h / 2, halfD); + this.group.add(frontWall); + + // Side walls + const leftWall = new THREE.Mesh(new THREE.PlaneGeometry(this.roomDepth, h), wallMat); + leftWall.rotation.y = Math.PI / 2; + leftWall.position.set(-halfW, h / 2, 0); + this.group.add(leftWall); + + const rightWall = new THREE.Mesh(new THREE.PlaneGeometry(this.roomDepth, h), wallMat); + rightWall.rotation.y = -Math.PI / 2; + rightWall.position.set(halfW, h / 2, 0); + this.group.add(rightWall); + + // Wall edge lines + const edgeMat = new THREE.LineBasicMaterial({ + color: 0x334466, + transparent: true, + opacity: 0.3 + }); + const edges = [ + // Floor edges + [-halfW, 0, -halfD, halfW, 0, -halfD], + [halfW, 0, -halfD, halfW, 0, halfD], + [halfW, 0, halfD, -halfW, 0, halfD], + [-halfW, 0, halfD, -halfW, 0, -halfD], + // Ceiling edges + [-halfW, h, -halfD, halfW, h, -halfD], + [halfW, h, -halfD, halfW, h, halfD], + [-halfW, h, halfD, -halfW, h, -halfD], + // Vertical edges + [-halfW, 0, -halfD, -halfW, h, -halfD], + [halfW, 0, -halfD, halfW, h, -halfD], + [-halfW, 0, halfD, -halfW, h, halfD], + [halfW, 0, halfD, halfW, h, halfD] + ]; + + for (const e of edges) { + const geom = new THREE.BufferGeometry(); + geom.setAttribute('position', new THREE.BufferAttribute(new Float32Array(e), 3)); + this.group.add(new THREE.Line(geom, edgeMat)); + } + } + + _buildAPMarkers() { + this._apMeshes = []; + this._rxMeshes = []; + + // Transmitter markers: small pyramid/cone shape, blue + const txGeom = new THREE.ConeGeometry(0.12, 0.25, 4); + const txMat = new THREE.MeshPhongMaterial({ + color: 0x0088ff, + emissive: 0x003366, + emissiveIntensity: 0.5, + transparent: true, + opacity: 0.9 + }); + + for (const ap of this.accessPoints) { + const mesh = new THREE.Mesh(txGeom, txMat.clone()); + mesh.position.set(...ap.pos); + mesh.rotation.z = Math.PI; // Point downward + mesh.castShadow = true; + mesh.name = `ap-${ap.id}`; + this.group.add(mesh); + this._apMeshes.push(mesh); + + // Small point light at each AP + const light = new THREE.PointLight(0x0066ff, 0.3, 4); + light.position.set(...ap.pos); + this.group.add(light); + + // Label + const label = this._createLabel(ap.id, 0x0088ff); + label.position.set(ap.pos[0], ap.pos[1] + 0.3, ap.pos[2]); + this.group.add(label); + } + + // Receiver markers: inverted cone, green + const rxGeom = new THREE.ConeGeometry(0.12, 0.25, 4); + const rxMat = new THREE.MeshPhongMaterial({ + color: 0x00cc44, + emissive: 0x004422, + emissiveIntensity: 0.5, + transparent: true, + opacity: 0.9 + }); + + for (const rx of this.receivers) { + const mesh = new THREE.Mesh(rxGeom, rxMat.clone()); + mesh.position.set(...rx.pos); + mesh.castShadow = true; + mesh.name = `rx-${rx.id}`; + this.group.add(mesh); + this._rxMeshes.push(mesh); + + // Small point light + const light = new THREE.PointLight(0x00cc44, 0.2, 3); + light.position.set(...rx.pos); + this.group.add(light); + + // Label + const label = this._createLabel(rx.id, 0x00cc44); + label.position.set(rx.pos[0], rx.pos[1] + 0.3, rx.pos[2]); + this.group.add(label); + } + } + + _buildSignalPaths() { + // Dashed lines from each TX to each RX showing WiFi signal paths + this._signalLines = []; + const lineMat = new THREE.LineDashedMaterial({ + color: 0x1133aa, + transparent: true, + opacity: 0.15, + dashSize: 0.15, + gapSize: 0.1, + linewidth: 1 + }); + + for (const tx of this.accessPoints) { + for (const rx of this.receivers) { + const geom = new THREE.BufferGeometry(); + const positions = new Float32Array([...tx.pos, ...rx.pos]); + geom.setAttribute('position', new THREE.BufferAttribute(positions, 3)); + const line = new THREE.Line(geom, lineMat.clone()); + line.computeLineDistances(); + this.group.add(line); + this._signalLines.push(line); + } + } + } + + _buildDetectionZones() { + this._zoneMeshes = {}; + + for (const zone of this.zones) { + const zoneGroup = new THREE.Group(); + zoneGroup.name = `zone-${zone.id}`; + + // Zone circle on floor + const circleGeom = new THREE.RingGeometry(zone.radius * 0.95, zone.radius, 48); + const circleMat = new THREE.MeshBasicMaterial({ + color: zone.color, + transparent: true, + opacity: 0.12, + side: THREE.DoubleSide, + depthWrite: false + }); + const circle = new THREE.Mesh(circleGeom, circleMat); + circle.rotation.x = -Math.PI / 2; + circle.position.set(zone.center[0], 0.01, zone.center[2]); + zoneGroup.add(circle); + + // Zone fill + const fillGeom = new THREE.CircleGeometry(zone.radius * 0.95, 48); + const fillMat = new THREE.MeshBasicMaterial({ + color: zone.color, + transparent: true, + opacity: 0.04, + side: THREE.DoubleSide, + depthWrite: false + }); + const fill = new THREE.Mesh(fillGeom, fillMat); + fill.rotation.x = -Math.PI / 2; + fill.position.set(zone.center[0], 0.008, zone.center[2]); + zoneGroup.add(fill); + + // Zone label + const label = this._createLabel(zone.label, zone.color); + label.position.set(zone.center[0], 0.15, zone.center[2] + zone.radius + 0.2); + label.scale.set(1.0, 0.25, 1); + zoneGroup.add(label); + + this.group.add(zoneGroup); + this._zoneMeshes[zone.id] = { group: zoneGroup, circle, fill, circleMat, fillMat }; + } + } + + _buildConfidenceHeatmap() { + // Ground-level heatmap showing detection confidence across the room + const cols = 20; + const rows = 15; + const cellW = this.roomWidth / cols; + const cellD = this.roomDepth / rows; + const cellGeom = new THREE.PlaneGeometry(cellW * 0.95, cellD * 0.95); + + this._heatmapGroup = new THREE.Group(); + this._heatmapGroup.position.y = 0.003; + + for (let r = 0; r < rows; r++) { + const rowCells = []; + for (let c = 0; c < cols; c++) { + const mat = new THREE.MeshBasicMaterial({ + color: 0x000000, + transparent: true, + opacity: 0, + side: THREE.DoubleSide, + depthWrite: false + }); + const cell = new THREE.Mesh(cellGeom, mat); + cell.rotation.x = -Math.PI / 2; + cell.position.set( + (c + 0.5) * cellW - this.roomWidth / 2, + 0, + (r + 0.5) * cellD - this.roomDepth / 2 + ); + this._heatmapGroup.add(cell); + rowCells.push(cell); + } + this._heatmapCells.push(rowCells); + } + + this.group.add(this._heatmapGroup); + } + + _createLabel(text, color) { + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + canvas.width = 128; + canvas.height = 32; + + ctx.font = 'bold 14px monospace'; + ctx.fillStyle = '#' + new THREE.Color(color).getHexString(); + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(text, canvas.width / 2, canvas.height / 2); + + const texture = new THREE.CanvasTexture(canvas); + const mat = new THREE.SpriteMaterial({ + map: texture, + transparent: true, + depthWrite: false + }); + return new THREE.Sprite(mat); + } + + // Update zone occupancy display + // zoneOccupancy: { zone_1: count, zone_2: count, ... } + updateZoneOccupancy(zoneOccupancy) { + if (!zoneOccupancy) return; + + for (const [zoneId, meshes] of Object.entries(this._zoneMeshes)) { + const count = zoneOccupancy[zoneId] || 0; + const isOccupied = count > 0; + + // Brighten occupied zones + meshes.circleMat.opacity = isOccupied ? 0.25 : 0.08; + meshes.fillMat.opacity = isOccupied ? 0.10 : 0.03; + } + } + + // Update confidence heatmap from detection data + // confidenceMap: 2D array or flat array of confidence values [0,1] + updateConfidenceHeatmap(confidenceMap) { + if (!confidenceMap) return; + const rows = this._heatmapCells.length; + const cols = this._heatmapCells[0]?.length || 0; + + for (let r = 0; r < rows; r++) { + for (let c = 0; c < cols; c++) { + const idx = r * cols + c; + const val = Array.isArray(confidenceMap) + ? (Array.isArray(confidenceMap[r]) ? confidenceMap[r][c] : confidenceMap[idx]) + : (confidenceMap[idx] || 0); + + const cell = this._heatmapCells[r][c]; + if (val > 0.01) { + // Color temperature: blue (low) -> green (mid) -> red (high) + cell.material.color.setHSL(0.6 - val * 0.6, 1.0, 0.3 + val * 0.3); + cell.material.opacity = val * 0.3; + } else { + cell.material.opacity = 0; + } + } + } + } + + // Generate a demo confidence heatmap centered on given positions + static generateDemoHeatmap(personPositions, cols, rows, roomWidth, roomDepth) { + const map = new Float32Array(cols * rows); + const cellW = roomWidth / cols; + const cellD = roomDepth / rows; + + for (const pos of (personPositions || [])) { + for (let r = 0; r < rows; r++) { + for (let c = 0; c < cols; c++) { + const cx = (c + 0.5) * cellW - roomWidth / 2; + const cz = (r + 0.5) * cellD - roomDepth / 2; + const dx = cx - (pos.x || 0); + const dz = cz - (pos.z || 0); + const dist = Math.sqrt(dx * dx + dz * dz); + const conf = Math.exp(-dist * dist * 0.5) * (pos.confidence || 0.8); + map[r * cols + c] = Math.max(map[r * cols + c], conf); + } + } + } + return map; + } + + // Animate AP and RX markers (subtle pulse) + update(delta, elapsed) { + // Pulse AP markers + for (const mesh of this._apMeshes) { + const pulse = 0.9 + Math.sin(elapsed * 2) * 0.1; + mesh.scale.setScalar(pulse); + mesh.material.emissiveIntensity = 0.3 + Math.sin(elapsed * 3) * 0.15; + } + + // Pulse RX markers + for (const mesh of this._rxMeshes) { + const pulse = 0.9 + Math.sin(elapsed * 2 + Math.PI) * 0.1; + mesh.scale.setScalar(pulse); + mesh.material.emissiveIntensity = 0.3 + Math.sin(elapsed * 3 + Math.PI) * 0.15; + } + + // Animate signal paths subtly + for (const line of this._signalLines) { + line.material.opacity = 0.08 + Math.sin(elapsed * 1.5) * 0.05; + } + } + + getGroup() { + return this.group; + } + + dispose() { + this.group.traverse((child) => { + if (child.geometry) child.geometry.dispose(); + if (child.material) { + if (child.material.map) child.material.map.dispose(); + child.material.dispose(); + } + }); + this.scene.remove(this.group); + } +} diff --git a/ui/components/signal-viz.js b/ui/components/signal-viz.js new file mode 100644 index 0000000..af60f68 --- /dev/null +++ b/ui/components/signal-viz.js @@ -0,0 +1,467 @@ +// Real-time CSI Signal Visualization - WiFi DensePose +// Amplitude heatmap, Phase plot, Doppler spectrum, Motion energy + +export class SignalVisualization { + constructor(scene) { + this.scene = scene; + this.group = new THREE.Group(); + this.group.name = 'signal-visualization'; + this.group.position.set(-5.5, 0, -3); + + // Configuration + this.config = { + subcarriers: 30, + timeSlots: 40, + heatmapWidth: 3.0, + heatmapHeight: 1.5, + phaseWidth: 3.0, + phaseHeight: 1.0, + dopplerBars: 16, + dopplerWidth: 2.0, + dopplerHeight: 1.0 + }; + + // Data buffers + this.amplitudeHistory = []; + this.phaseData = new Float32Array(this.config.subcarriers); + this.dopplerData = new Float32Array(this.config.dopplerBars); + this.motionEnergy = 0; + this.targetMotionEnergy = 0; + + // Initialize for timeSlots rows of subcarrier data + for (let i = 0; i < this.config.timeSlots; i++) { + this.amplitudeHistory.push(new Float32Array(this.config.subcarriers)); + } + + // Build visualizations + this._buildAmplitudeHeatmap(); + this._buildPhasePlot(); + this._buildDopplerSpectrum(); + this._buildMotionIndicator(); + this._buildLabels(); + + this.scene.add(this.group); + } + + _buildAmplitudeHeatmap() { + // Create a grid of colored cells for CSI amplitude across subcarriers over time + const { subcarriers, timeSlots, heatmapWidth, heatmapHeight } = this.config; + const cellW = heatmapWidth / subcarriers; + const cellH = heatmapHeight / timeSlots; + + this._heatmapCells = []; + this._heatmapGroup = new THREE.Group(); + this._heatmapGroup.position.set(0, 3.5, 0); + + const cellGeom = new THREE.PlaneGeometry(cellW * 0.9, cellH * 0.9); + + for (let t = 0; t < timeSlots; t++) { + const row = []; + for (let s = 0; s < subcarriers; s++) { + const mat = new THREE.MeshBasicMaterial({ + color: 0x000022, + transparent: true, + opacity: 0.85, + side: THREE.DoubleSide + }); + const cell = new THREE.Mesh(cellGeom, mat); + cell.position.set( + s * cellW - heatmapWidth / 2 + cellW / 2, + t * cellH, + 0 + ); + this._heatmapGroup.add(cell); + row.push(cell); + } + this._heatmapCells.push(row); + } + + // Border frame + const frameGeom = new THREE.EdgesGeometry( + new THREE.PlaneGeometry(heatmapWidth + 0.1, heatmapHeight + 0.1) + ); + const frameMat = new THREE.LineBasicMaterial({ color: 0x335577, opacity: 0.5, transparent: true }); + const frame = new THREE.LineSegments(frameGeom, frameMat); + frame.position.set(0, heatmapHeight / 2, -0.01); + this._heatmapGroup.add(frame); + + this.group.add(this._heatmapGroup); + } + + _buildPhasePlot() { + // Line chart showing phase across subcarriers in 3D space + const { subcarriers, phaseWidth, phaseHeight } = this.config; + + this._phaseGroup = new THREE.Group(); + this._phaseGroup.position.set(0, 2.0, 0); + + // Create the phase line + const positions = new Float32Array(subcarriers * 3); + for (let i = 0; i < subcarriers; i++) { + positions[i * 3] = (i / (subcarriers - 1)) * phaseWidth - phaseWidth / 2; + positions[i * 3 + 1] = 0; + positions[i * 3 + 2] = 0; + } + + const phaseGeom = new THREE.BufferGeometry(); + phaseGeom.setAttribute('position', new THREE.BufferAttribute(positions, 3)); + + const phaseMat = new THREE.LineBasicMaterial({ + color: 0x00ff88, + transparent: true, + opacity: 0.8, + linewidth: 2 + }); + + this._phaseLine = new THREE.Line(phaseGeom, phaseMat); + this._phaseGroup.add(this._phaseLine); + + // Phase reference line (zero line) + const refPositions = new Float32Array(6); + refPositions[0] = -phaseWidth / 2; refPositions[1] = 0; refPositions[2] = 0; + refPositions[3] = phaseWidth / 2; refPositions[4] = 0; refPositions[5] = 0; + const refGeom = new THREE.BufferGeometry(); + refGeom.setAttribute('position', new THREE.BufferAttribute(refPositions, 3)); + const refMat = new THREE.LineBasicMaterial({ color: 0x224433, opacity: 0.3, transparent: true }); + this._phaseGroup.add(new THREE.LineSegments(refGeom, refMat)); + + // Vertical axis lines + const axisPositions = new Float32Array(12); + // Left axis + axisPositions[0] = -phaseWidth / 2; axisPositions[1] = -phaseHeight / 2; axisPositions[2] = 0; + axisPositions[3] = -phaseWidth / 2; axisPositions[4] = phaseHeight / 2; axisPositions[5] = 0; + // Right axis + axisPositions[6] = phaseWidth / 2; axisPositions[7] = -phaseHeight / 2; axisPositions[8] = 0; + axisPositions[9] = phaseWidth / 2; axisPositions[10] = phaseHeight / 2; axisPositions[11] = 0; + const axisGeom = new THREE.BufferGeometry(); + axisGeom.setAttribute('position', new THREE.BufferAttribute(axisPositions, 3)); + this._phaseGroup.add(new THREE.LineSegments(axisGeom, refMat)); + + this.group.add(this._phaseGroup); + } + + _buildDopplerSpectrum() { + // Bar chart for Doppler frequency spectrum + const { dopplerBars, dopplerWidth, dopplerHeight } = this.config; + const barWidth = (dopplerWidth / dopplerBars) * 0.8; + const gap = (dopplerWidth / dopplerBars) * 0.2; + + this._dopplerGroup = new THREE.Group(); + this._dopplerGroup.position.set(0, 0.8, 0); + this._dopplerBars = []; + + const barGeom = new THREE.BoxGeometry(barWidth, 1, 0.05); + + for (let i = 0; i < dopplerBars; i++) { + const mat = new THREE.MeshBasicMaterial({ + color: 0x0044aa, + transparent: true, + opacity: 0.75 + }); + const bar = new THREE.Mesh(barGeom, mat); + const x = (i / (dopplerBars - 1)) * dopplerWidth - dopplerWidth / 2; + bar.position.set(x, 0, 0); + bar.scale.y = 0.01; // Start flat + this._dopplerGroup.add(bar); + this._dopplerBars.push(bar); + } + + // Base line + const basePositions = new Float32Array(6); + basePositions[0] = -dopplerWidth / 2 - 0.1; basePositions[1] = 0; basePositions[2] = 0; + basePositions[3] = dopplerWidth / 2 + 0.1; basePositions[4] = 0; basePositions[5] = 0; + const baseGeom = new THREE.BufferGeometry(); + baseGeom.setAttribute('position', new THREE.BufferAttribute(basePositions, 3)); + const baseMat = new THREE.LineBasicMaterial({ color: 0x335577, opacity: 0.5, transparent: true }); + this._dopplerGroup.add(new THREE.LineSegments(baseGeom, baseMat)); + + this.group.add(this._dopplerGroup); + } + + _buildMotionIndicator() { + // Pulsating sphere that grows/brightens with motion energy + this._motionGroup = new THREE.Group(); + this._motionGroup.position.set(2.0, 1.5, 0); + + // Outer glow ring + const ringGeom = new THREE.RingGeometry(0.25, 0.3, 32); + const ringMat = new THREE.MeshBasicMaterial({ + color: 0x00ff44, + transparent: true, + opacity: 0.3, + side: THREE.DoubleSide + }); + this._motionRing = new THREE.Mesh(ringGeom, ringMat); + this._motionGroup.add(this._motionRing); + + // Inner core + const coreGeom = new THREE.SphereGeometry(0.15, 16, 16); + const coreMat = new THREE.MeshBasicMaterial({ + color: 0x004422, + transparent: true, + opacity: 0.6 + }); + this._motionCore = new THREE.Mesh(coreGeom, coreMat); + this._motionGroup.add(this._motionCore); + + // Surrounding pulse rings + this._pulseRings = []; + for (let i = 0; i < 3; i++) { + const pulseGeom = new THREE.RingGeometry(0.3, 0.32, 32); + const pulseMat = new THREE.MeshBasicMaterial({ + color: 0x00ff88, + transparent: true, + opacity: 0, + side: THREE.DoubleSide + }); + const ring = new THREE.Mesh(pulseGeom, pulseMat); + ring.userData.phase = (i / 3) * Math.PI * 2; + this._motionGroup.add(ring); + this._pulseRings.push(ring); + } + + this.group.add(this._motionGroup); + } + + _buildLabels() { + // Create text labels using canvas textures + const labels = [ + { text: 'CSI AMPLITUDE', pos: [0, 5.2, 0], parent: this._heatmapGroup }, + { text: 'PHASE', pos: [0, 0.7, 0], parent: this._phaseGroup }, + { text: 'DOPPLER SPECTRUM', pos: [0, 0.8, 0], parent: this._dopplerGroup }, + { text: 'MOTION', pos: [0, 0.55, 0], parent: this._motionGroup } + ]; + + for (const label of labels) { + const sprite = this._createTextSprite(label.text, { + fontSize: 14, + color: '#5588aa', + bgColor: 'transparent' + }); + sprite.position.set(...label.pos); + sprite.scale.set(1.2, 0.3, 1); + if (label.parent) { + label.parent.add(sprite); + } else { + this.group.add(sprite); + } + } + } + + _createTextSprite(text, opts = {}) { + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + canvas.width = 256; + canvas.height = 64; + + if (opts.bgColor && opts.bgColor !== 'transparent') { + ctx.fillStyle = opts.bgColor; + ctx.fillRect(0, 0, canvas.width, canvas.height); + } + + ctx.font = `${opts.fontSize || 14}px monospace`; + ctx.fillStyle = opts.color || '#88aacc'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(text, canvas.width / 2, canvas.height / 2); + + const texture = new THREE.CanvasTexture(canvas); + texture.needsUpdate = true; + + const mat = new THREE.SpriteMaterial({ + map: texture, + transparent: true, + depthWrite: false + }); + return new THREE.Sprite(mat); + } + + // Feed new CSI data + // data: { amplitude: Float32Array(30), phase: Float32Array(30), doppler: Float32Array(16), motionEnergy: number } + updateSignalData(data) { + if (!data) return; + + // Amplitude: shift history and add new row + if (data.amplitude) { + this.amplitudeHistory.shift(); + this.amplitudeHistory.push(new Float32Array(data.amplitude)); + } + + // Phase + if (data.phase) { + this.phaseData = new Float32Array(data.phase); + } + + // Doppler + if (data.doppler) { + for (let i = 0; i < Math.min(data.doppler.length, this.config.dopplerBars); i++) { + this.dopplerData[i] = data.doppler[i]; + } + } + + // Motion energy + if (data.motionEnergy !== undefined) { + this.targetMotionEnergy = Math.max(0, Math.min(1, data.motionEnergy)); + } + } + + // Call each frame + update(delta, elapsed) { + this._updateHeatmap(); + this._updatePhasePlot(); + this._updateDoppler(delta); + this._updateMotionIndicator(delta, elapsed); + } + + _updateHeatmap() { + const { subcarriers, timeSlots } = this.config; + for (let t = 0; t < timeSlots; t++) { + const row = this.amplitudeHistory[t]; + for (let s = 0; s < subcarriers; s++) { + const cell = this._heatmapCells[t][s]; + const val = row[s] || 0; + // Color: dark blue (0) -> cyan (0.5) -> yellow (0.8) -> red (1.0) + cell.material.color.setHSL( + 0.6 - val * 0.6, // hue: 0.6 (blue) -> 0 (red) + 0.9, // saturation + 0.1 + val * 0.5 // lightness: dim to bright + ); + } + } + } + + _updatePhasePlot() { + const posAttr = this._phaseLine.geometry.getAttribute('position'); + const arr = posAttr.array; + const { subcarriers, phaseWidth, phaseHeight } = this.config; + + for (let i = 0; i < subcarriers; i++) { + const x = (i / (subcarriers - 1)) * phaseWidth - phaseWidth / 2; + // Phase is in radians, normalize to [-1, 1] range then scale to height + const phase = this.phaseData[i] || 0; + const y = (phase / Math.PI) * (phaseHeight / 2); + arr[i * 3] = x; + arr[i * 3 + 1] = y; + arr[i * 3 + 2] = 0; + } + posAttr.needsUpdate = true; + + // Color based on phase variance (more variance = more activity = greener/brighter) + let variance = 0; + let mean = 0; + for (let i = 0; i < subcarriers; i++) mean += this.phaseData[i] || 0; + mean /= subcarriers; + for (let i = 0; i < subcarriers; i++) { + const diff = (this.phaseData[i] || 0) - mean; + variance += diff * diff; + } + variance /= subcarriers; + const activity = Math.min(1, variance / 2); + this._phaseLine.material.color.setHSL(0.3 - activity * 0.15, 1.0, 0.35 + activity * 0.3); + } + + _updateDoppler(delta) { + for (let i = 0; i < this._dopplerBars.length; i++) { + const bar = this._dopplerBars[i]; + const target = this.dopplerData[i] || 0; + // Smooth bar height + const currentH = bar.scale.y; + bar.scale.y += (target * this.config.dopplerHeight - currentH) * Math.min(1, delta * 8); + bar.scale.y = Math.max(0.01, bar.scale.y); + + // Position bar bottom at y=0 + bar.position.y = bar.scale.y / 2; + + // Color: blue (low) -> purple (mid) -> magenta (high) + const val = target; + bar.material.color.setHSL( + 0.7 - val * 0.3, // blue to magenta + 0.8, + 0.25 + val * 0.35 + ); + } + } + + _updateMotionIndicator(delta, elapsed) { + // Smooth motion energy + this.motionEnergy += (this.targetMotionEnergy - this.motionEnergy) * Math.min(1, delta * 5); + + const energy = this.motionEnergy; + + // Core: grows and brightens with motion + const coreScale = 0.8 + energy * 0.7; + this._motionCore.scale.setScalar(coreScale); + this._motionCore.material.color.setHSL( + 0.3 - energy * 0.2, // green -> yellow-green + 1.0, + 0.15 + energy * 0.4 + ); + this._motionCore.material.opacity = 0.4 + energy * 0.5; + + // Ring + this._motionRing.material.opacity = 0.15 + energy * 0.5; + this._motionRing.material.color.setHSL(0.3 - energy * 0.15, 1.0, 0.4 + energy * 0.3); + + // Pulse rings + for (const ring of this._pulseRings) { + const phase = ring.userData.phase + elapsed * (1 + energy * 3); + const t = (Math.sin(phase) + 1) / 2; + const scale = 1 + t * energy * 2; + ring.scale.setScalar(scale); + ring.material.opacity = (1 - t) * energy * 0.4; + } + } + + // Generate synthetic demo signal data + static generateDemoData(elapsed) { + const subcarriers = 30; + const dopplerBars = 16; + + // Amplitude: sinusoidal pattern with noise simulating human movement + const amplitude = new Float32Array(subcarriers); + for (let i = 0; i < subcarriers; i++) { + const baseFreq = Math.sin(elapsed * 2 + i * 0.3) * 0.3; + const bodyEffect = Math.sin(elapsed * 0.8 + i * 0.15) * 0.25; + const noise = (Math.random() - 0.5) * 0.1; + amplitude[i] = Math.max(0, Math.min(1, 0.4 + baseFreq + bodyEffect + noise)); + } + + // Phase: linear with perturbations from movement + const phase = new Float32Array(subcarriers); + for (let i = 0; i < subcarriers; i++) { + const linearPhase = (i / subcarriers) * Math.PI * 2; + const bodyPhase = Math.sin(elapsed * 1.5 + i * 0.2) * 0.8; + phase[i] = linearPhase + bodyPhase; + } + + // Doppler: spectral peaks from movement velocity + const doppler = new Float32Array(dopplerBars); + const centerBin = dopplerBars / 2 + Math.sin(elapsed * 0.7) * 3; + for (let i = 0; i < dopplerBars; i++) { + const dist = Math.abs(i - centerBin); + doppler[i] = Math.max(0, Math.exp(-dist * dist * 0.15) * (0.6 + Math.sin(elapsed * 1.2) * 0.3)); + doppler[i] += (Math.random() - 0.5) * 0.05; + doppler[i] = Math.max(0, Math.min(1, doppler[i])); + } + + // Motion energy: pulsating + const motionEnergy = (Math.sin(elapsed * 0.5) + 1) / 2 * 0.7 + 0.15; + + return { amplitude, phase, doppler, motionEnergy }; + } + + getGroup() { + return this.group; + } + + dispose() { + this.group.traverse((child) => { + if (child.geometry) child.geometry.dispose(); + if (child.material) { + if (child.material.map) child.material.map.dispose(); + child.material.dispose(); + } + }); + this.scene.remove(this.group); + } +} diff --git a/ui/services/websocket-client.js b/ui/services/websocket-client.js new file mode 100644 index 0000000..93428b8 --- /dev/null +++ b/ui/services/websocket-client.js @@ -0,0 +1,258 @@ +// WebSocket Client for Three.js Visualization - WiFi DensePose +// Connects to ws://localhost:8000/ws/pose and manages real-time data flow + +export class WebSocketClient { + constructor(options = {}) { + this.url = options.url || 'ws://localhost:8000/ws/pose'; + this.ws = null; + this.state = 'disconnected'; // disconnected, connecting, connected, error + this.isRealData = false; + + // Reconnection settings + this.reconnectAttempts = 0; + this.maxReconnectAttempts = options.maxReconnectAttempts || 15; + this.reconnectDelays = [500, 1000, 2000, 4000, 8000, 15000, 30000]; + this.reconnectTimer = null; + this.autoReconnect = options.autoReconnect !== false; + + // Heartbeat + this.heartbeatInterval = null; + this.heartbeatFrequency = options.heartbeatFrequency || 25000; + this.lastPong = 0; + + // Metrics + this.metrics = { + messageCount: 0, + errorCount: 0, + connectTime: null, + lastMessageTime: null, + latency: 0, + bytesReceived: 0 + }; + + // Callbacks + this._onMessage = options.onMessage || (() => {}); + this._onStateChange = options.onStateChange || (() => {}); + this._onError = options.onError || (() => {}); + } + + // Attempt to connect + connect() { + if (this.state === 'connecting' || this.state === 'connected') { + console.warn('[WS-VIZ] Already connected or connecting'); + return; + } + + this._setState('connecting'); + console.log(`[WS-VIZ] Connecting to ${this.url}`); + + try { + this.ws = new WebSocket(this.url); + this.ws.binaryType = 'arraybuffer'; + + this.ws.onopen = () => this._handleOpen(); + this.ws.onmessage = (event) => this._handleMessage(event); + this.ws.onerror = (event) => this._handleError(event); + this.ws.onclose = (event) => this._handleClose(event); + + // Connection timeout + this._connectTimeout = setTimeout(() => { + if (this.state === 'connecting') { + console.warn('[WS-VIZ] Connection timeout'); + this.ws.close(); + this._setState('error'); + this._scheduleReconnect(); + } + }, 8000); + + } catch (err) { + console.error('[WS-VIZ] Failed to create WebSocket:', err); + this._setState('error'); + this._onError(err); + this._scheduleReconnect(); + } + } + + disconnect() { + this.autoReconnect = false; + this._clearTimers(); + + if (this.ws) { + this.ws.onclose = null; // Prevent reconnect on intentional close + if (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING) { + this.ws.close(1000, 'Client disconnect'); + } + this.ws = null; + } + + this._setState('disconnected'); + this.isRealData = false; + console.log('[WS-VIZ] Disconnected'); + } + + // Send a message + send(data) { + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { + console.warn('[WS-VIZ] Cannot send - not connected'); + return false; + } + + const msg = typeof data === 'string' ? data : JSON.stringify(data); + this.ws.send(msg); + return true; + } + + _handleOpen() { + clearTimeout(this._connectTimeout); + this.reconnectAttempts = 0; + this.metrics.connectTime = Date.now(); + this._setState('connected'); + console.log('[WS-VIZ] Connected successfully'); + + // Start heartbeat + this._startHeartbeat(); + + // Request initial state + this.send({ type: 'get_status', timestamp: Date.now() }); + } + + _handleMessage(event) { + this.metrics.messageCount++; + this.metrics.lastMessageTime = Date.now(); + + const rawSize = typeof event.data === 'string' ? event.data.length : event.data.byteLength; + this.metrics.bytesReceived += rawSize; + + try { + const data = typeof event.data === 'string' ? JSON.parse(event.data) : event.data; + + // Handle pong + if (data.type === 'pong') { + this.lastPong = Date.now(); + if (data.timestamp) { + this.metrics.latency = Date.now() - data.timestamp; + } + return; + } + + // Handle connection_established + if (data.type === 'connection_established') { + console.log('[WS-VIZ] Server confirmed connection:', data.payload); + return; + } + + // Detect real vs mock data from metadata + if (data.data && data.data.metadata) { + this.isRealData = data.data.metadata.mock_data === false && data.data.metadata.source !== 'mock'; + } else if (data.metadata) { + this.isRealData = data.metadata.mock_data === false; + } + + // Calculate latency from message timestamp + if (data.timestamp) { + const msgTime = new Date(data.timestamp).getTime(); + if (!isNaN(msgTime)) { + this.metrics.latency = Date.now() - msgTime; + } + } + + // Forward to callback + this._onMessage(data); + + } catch (err) { + this.metrics.errorCount++; + console.error('[WS-VIZ] Failed to parse message:', err); + } + } + + _handleError(event) { + this.metrics.errorCount++; + console.error('[WS-VIZ] WebSocket error:', event); + this._onError(event); + } + + _handleClose(event) { + clearTimeout(this._connectTimeout); + this._stopHeartbeat(); + this.ws = null; + + const wasConnected = this.state === 'connected'; + console.log(`[WS-VIZ] Connection closed: code=${event.code}, reason=${event.reason}, clean=${event.wasClean}`); + + if (event.wasClean || !this.autoReconnect) { + this._setState('disconnected'); + } else { + this._setState('error'); + this._scheduleReconnect(); + } + } + + _setState(newState) { + if (this.state === newState) return; + const oldState = this.state; + this.state = newState; + this._onStateChange(newState, oldState); + } + + _startHeartbeat() { + this._stopHeartbeat(); + this.heartbeatInterval = setInterval(() => { + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + this.send({ type: 'ping', timestamp: Date.now() }); + } + }, this.heartbeatFrequency); + } + + _stopHeartbeat() { + if (this.heartbeatInterval) { + clearInterval(this.heartbeatInterval); + this.heartbeatInterval = null; + } + } + + _scheduleReconnect() { + if (!this.autoReconnect) return; + if (this.reconnectAttempts >= this.maxReconnectAttempts) { + console.error('[WS-VIZ] Max reconnect attempts reached'); + this._setState('error'); + return; + } + + const delayIdx = Math.min(this.reconnectAttempts, this.reconnectDelays.length - 1); + const delay = this.reconnectDelays[delayIdx]; + this.reconnectAttempts++; + + console.log(`[WS-VIZ] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`); + + this.reconnectTimer = setTimeout(() => { + this.connect(); + }, delay); + } + + _clearTimers() { + clearTimeout(this._connectTimeout); + clearTimeout(this.reconnectTimer); + this._stopHeartbeat(); + } + + getMetrics() { + return { + ...this.metrics, + state: this.state, + isRealData: this.isRealData, + reconnectAttempts: this.reconnectAttempts, + uptime: this.metrics.connectTime ? (Date.now() - this.metrics.connectTime) / 1000 : 0 + }; + } + + isConnected() { + return this.state === 'connected'; + } + + dispose() { + this.disconnect(); + this._onMessage = () => {}; + this._onStateChange = () => {}; + this._onError = () => {}; + } +}