From 4d541e02fff598ebe4e6297ce43a70cf81b3993f Mon Sep 17 00:00:00 2001 From: ruv Date: Thu, 12 Mar 2026 16:16:57 -0400 Subject: [PATCH] fix: responsive video layout + WASM path + motion tracking Co-Authored-By: claude-flow --- pose-fusion/css/style.css | 16 +- pose-fusion/js/main.js | 6 +- ui/pose-fusion/css/style.css | 405 +++++++++++++++++++++++++++ ui/pose-fusion/js/canvas-renderer.js | 247 ++++++++++++++++ ui/pose-fusion/js/cnn-embedder.js | 226 +++++++++++++++ ui/pose-fusion/js/fusion-engine.js | 166 +++++++++++ ui/pose-fusion/js/main.js | 6 +- 7 files changed, 1061 insertions(+), 11 deletions(-) create mode 100644 ui/pose-fusion/css/style.css create mode 100644 ui/pose-fusion/js/canvas-renderer.js create mode 100644 ui/pose-fusion/js/cnn-embedder.js create mode 100644 ui/pose-fusion/js/fusion-engine.js diff --git a/pose-fusion/css/style.css b/pose-fusion/css/style.css index 0cbefe19..1bf5dd89 100644 --- a/pose-fusion/css/style.css +++ b/pose-fusion/css/style.css @@ -129,10 +129,11 @@ body { .main-grid { display: grid; grid-template-columns: 1fr 360px; - grid-template-rows: auto auto; + grid-template-rows: 1fr auto; gap: 16px; padding: 16px 24px; - max-height: calc(100vh - 72px); + height: calc(100vh - 72px); + overflow: hidden; } /* === Video Panel === */ @@ -142,8 +143,7 @@ body { border-radius: var(--radius); border: 1px solid var(--bg-panel-border); overflow: hidden; - aspect-ratio: 4/3; - max-height: 60vh; + min-height: 0; } .video-panel video { @@ -204,7 +204,7 @@ body { flex-direction: column; gap: 12px; overflow-y: auto; - max-height: calc(100vh - 88px); + min-height: 0; } .panel { @@ -397,7 +397,9 @@ body { @media (max-width: 900px) { .main-grid { grid-template-columns: 1fr; + height: auto; + overflow: auto; } - .video-panel { aspect-ratio: 16/9; max-height: 40vh; } - .side-panels { max-height: none; } + .video-panel { aspect-ratio: 16/9; max-height: 50vh; } + .side-panels { max-height: none; overflow: visible; } } diff --git a/pose-fusion/js/main.js b/pose-fusion/js/main.js index f18c650f..29f283f4 100644 --- a/pose-fusion/js/main.js +++ b/pose-fusion/js/main.js @@ -111,8 +111,10 @@ function init() { }); // Try to load WASM embedders (non-blocking) - visualCnn.tryLoadWasm('./pkg/ruvector_cnn_wasm'); - csiCnn.tryLoadWasm('./pkg/ruvector_cnn_wasm'); + // Resolve relative to this JS module file (in pose-fusion/js/) → ../pkg/ + const wasmBase = new URL('../pkg/ruvector_cnn_wasm', import.meta.url).href; + visualCnn.tryLoadWasm(wasmBase); + csiCnn.tryLoadWasm(wasmBase); // Auto-start camera for video/dual modes updateModeUI(); diff --git a/ui/pose-fusion/css/style.css b/ui/pose-fusion/css/style.css new file mode 100644 index 00000000..1bf5dd89 --- /dev/null +++ b/ui/pose-fusion/css/style.css @@ -0,0 +1,405 @@ +/* WiFi-DensePose — Dual-Modal Pose Fusion Demo + Dark theme matching Observatory */ + +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600;700&family=JetBrains+Mono:wght@400;600&display=swap'); + +:root { + --bg-deep: #080c14; + --bg-panel: rgba(8, 16, 28, 0.92); + --bg-panel-border: rgba(0, 210, 120, 0.25); + --green-glow: #00d878; + --green-bright:#3eff8a; + --green-dim: #0a6b3a; + --amber: #ffb020; + --amber-dim: #a06800; + --blue-signal: #2090ff; + --blue-dim: #0a3060; + --red-alert: #ff3040; + --cyan: #00e5ff; + --text-primary: #e8ece0; + --text-secondary: rgba(232,236,224, 0.55); + --text-label: rgba(232,236,224, 0.35); + --radius: 8px; +} + +* { margin: 0; padding: 0; box-sizing: border-box; } + +body { + background: var(--bg-deep); + font-family: 'Inter', -apple-system, sans-serif; + color: var(--text-primary); + -webkit-font-smoothing: antialiased; + overflow-x: hidden; + min-height: 100vh; +} + +/* === Header === */ +.header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 24px; + border-bottom: 1px solid var(--bg-panel-border); + background: var(--bg-panel); + backdrop-filter: blur(12px); +} + +.header-left { + display: flex; + align-items: center; + gap: 16px; +} + +.logo { + font-weight: 700; + font-size: 24px; + color: var(--green-glow); +} + +.logo .pi { font-style: normal; } + +.header-title { + font-size: 14px; + color: var(--text-secondary); + font-weight: 300; +} + +.header-right { + display: flex; + align-items: center; + gap: 16px; +} + +.mode-select { + background: rgba(0,210,120,0.1); + border: 1px solid var(--bg-panel-border); + color: var(--text-primary); + padding: 6px 12px; + border-radius: var(--radius); + font-family: inherit; + font-size: 13px; + cursor: pointer; +} + +.mode-select option { background: #0c1420; } + +.status-badge { + display: flex; + align-items: center; + gap: 6px; + font-family: 'JetBrains Mono', monospace; + font-size: 12px; + padding: 4px 10px; + border-radius: 12px; + background: rgba(0,210,120,0.1); + border: 1px solid var(--bg-panel-border); +} + +.status-dot { + width: 8px; height: 8px; + border-radius: 50%; + background: var(--green-glow); + box-shadow: 0 0 8px var(--green-glow); + animation: pulse-dot 2s ease infinite; +} + +.status-dot.offline { background: #555; box-shadow: none; animation: none; } +.status-dot.warning { background: var(--amber); box-shadow: 0 0 8px var(--amber); } + +@keyframes pulse-dot { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} + +.fps-badge { + font-family: 'JetBrains Mono', monospace; + font-size: 12px; + color: var(--green-glow); +} + +.back-link { + color: var(--text-secondary); + text-decoration: none; + font-size: 13px; + transition: color 0.2s; +} +.back-link:hover { color: var(--green-glow); } + +/* === Main Layout === */ +.main-grid { + display: grid; + grid-template-columns: 1fr 360px; + grid-template-rows: 1fr auto; + gap: 16px; + padding: 16px 24px; + height: calc(100vh - 72px); + overflow: hidden; +} + +/* === Video Panel === */ +.video-panel { + position: relative; + background: #000; + border-radius: var(--radius); + border: 1px solid var(--bg-panel-border); + overflow: hidden; + min-height: 0; +} + +.video-panel video { + width: 100%; + height: 100%; + object-fit: cover; + transform: scaleX(-1); +} + +.video-panel canvas { + position: absolute; + top: 0; left: 0; + width: 100%; + height: 100%; + transform: scaleX(-1); +} + +.video-overlay-label { + position: absolute; + top: 12px; left: 12px; + font-family: 'JetBrains Mono', monospace; + font-size: 11px; + padding: 4px 8px; + background: rgba(0,0,0,0.7); + border-radius: 4px; + color: var(--green-glow); + z-index: 5; + transform: scaleX(-1); +} + +.camera-prompt { + position: absolute; + top: 50%; left: 50%; + transform: translate(-50%, -50%); + text-align: center; + color: var(--text-secondary); +} + +.camera-prompt button { + margin-top: 12px; + padding: 10px 24px; + background: var(--green-glow); + color: #000; + border: none; + border-radius: var(--radius); + font-family: inherit; + font-weight: 600; + font-size: 14px; + cursor: pointer; + transition: background 0.2s; +} + +.camera-prompt button:hover { background: var(--green-bright); } + +/* === Side Panels === */ +.side-panels { + display: flex; + flex-direction: column; + gap: 12px; + overflow-y: auto; + min-height: 0; +} + +.panel { + background: var(--bg-panel); + border: 1px solid var(--bg-panel-border); + border-radius: var(--radius); + padding: 14px; +} + +.panel-title { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 1.2px; + color: var(--text-label); + margin-bottom: 10px; + display: flex; + align-items: center; + gap: 6px; +} + +/* === CSI Heatmap === */ +.csi-canvas-wrapper { + position: relative; + border-radius: 4px; + overflow: hidden; + background: #000; +} + +.csi-canvas-wrapper canvas { + width: 100%; + display: block; +} + +/* === Fusion Bars === */ +.fusion-bars { + display: flex; + flex-direction: column; + gap: 8px; +} + +.bar-row { + display: flex; + align-items: center; + gap: 8px; +} + +.bar-label { + font-family: 'JetBrains Mono', monospace; + font-size: 11px; + color: var(--text-secondary); + width: 55px; + text-align: right; +} + +.bar-track { + flex: 1; + height: 6px; + background: rgba(255,255,255,0.06); + border-radius: 3px; + overflow: hidden; +} + +.bar-fill { + height: 100%; + border-radius: 3px; + transition: width 0.3s ease; +} + +.bar-fill.video { background: var(--cyan); } +.bar-fill.csi { background: var(--amber); } +.bar-fill.fused { background: var(--green-glow); box-shadow: 0 0 8px var(--green-glow); } + +.bar-value { + font-family: 'JetBrains Mono', monospace; + font-size: 11px; + color: var(--text-primary); + width: 36px; +} + +/* === Embedding Space === */ +.embedding-canvas-wrapper { + position: relative; + background: #000; + border-radius: 4px; + overflow: hidden; +} +.embedding-canvas-wrapper canvas { + width: 100%; + display: block; +} + +/* === Latency Panel === */ +.latency-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 6px; +} + +.latency-item { + text-align: center; + padding: 6px 0; +} + +.latency-value { + font-family: 'JetBrains Mono', monospace; + font-size: 16px; + font-weight: 600; + color: var(--green-glow); +} + +.latency-label { + font-size: 10px; + color: var(--text-label); + margin-top: 2px; +} + +/* === Controls === */ +.controls-row { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.btn { + padding: 6px 14px; + border: 1px solid var(--bg-panel-border); + background: rgba(0,210,120,0.08); + color: var(--text-primary); + border-radius: var(--radius); + font-family: inherit; + font-size: 12px; + cursor: pointer; + transition: all 0.2s; +} +.btn:hover { background: rgba(0,210,120,0.2); } +.btn.active { background: var(--green-glow); color: #000; font-weight: 600; } + +.slider-row { + display: flex; + align-items: center; + gap: 8px; + margin-top: 8px; +} + +.slider-row label { + font-size: 11px; + color: var(--text-secondary); + white-space: nowrap; +} + +.slider-row input[type=range] { + flex: 1; + accent-color: var(--green-glow); +} + +.slider-row .slider-val { + font-family: 'JetBrains Mono', monospace; + font-size: 11px; + width: 32px; + color: var(--green-glow); +} + +/* === Bottom Bar === */ +.bottom-bar { + grid-column: 1 / -1; + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 16px; + background: var(--bg-panel); + border: 1px solid var(--bg-panel-border); + border-radius: var(--radius); + font-family: 'JetBrains Mono', monospace; + font-size: 11px; + color: var(--text-secondary); +} + +.bottom-bar a { + color: var(--green-glow); + text-decoration: none; +} + +/* === Skeleton colors === */ +.skeleton-joint { fill: var(--green-glow); } +.skeleton-limb { stroke: var(--green-bright); } +.skeleton-joint-csi { fill: var(--amber); } +.skeleton-limb-csi { stroke: var(--amber); } + +/* === Responsive === */ +@media (max-width: 900px) { + .main-grid { + grid-template-columns: 1fr; + height: auto; + overflow: auto; + } + .video-panel { aspect-ratio: 16/9; max-height: 50vh; } + .side-panels { max-height: none; overflow: visible; } +} diff --git a/ui/pose-fusion/js/canvas-renderer.js b/ui/pose-fusion/js/canvas-renderer.js new file mode 100644 index 00000000..8ac169d9 --- /dev/null +++ b/ui/pose-fusion/js/canvas-renderer.js @@ -0,0 +1,247 @@ +/** + * CanvasRenderer — Renders skeleton overlay on video, CSI heatmap, + * embedding space visualization, and fusion confidence bars. + */ + +import { SKELETON_CONNECTIONS } from './pose-decoder.js'; + +export class CanvasRenderer { + constructor() { + this.colors = { + joint: '#00d878', + jointGlow: 'rgba(0, 216, 120, 0.4)', + limb: '#3eff8a', + limbGlow: 'rgba(62, 255, 138, 0.15)', + csiJoint: '#ffb020', + csiLimb: '#ffc850', + fused: '#00e5ff', + confidence: 'rgba(255,255,255,0.3)', + videoEmb: '#00e5ff', + csiEmb: '#ffb020', + fusedEmb: '#00d878', + }; + } + + /** + * Draw skeleton overlay on the video canvas + * @param {CanvasRenderingContext2D} ctx + * @param {Array<{x,y,confidence}>} keypoints - Normalized [0,1] coordinates + * @param {number} width - Canvas width + * @param {number} height - Canvas height + * @param {object} opts + */ + drawSkeleton(ctx, keypoints, width, height, opts = {}) { + const minConf = opts.minConfidence || 0.3; + const color = opts.color || 'green'; + const jointColor = color === 'amber' ? this.colors.csiJoint : this.colors.joint; + const limbColor = color === 'amber' ? this.colors.csiLimb : this.colors.limb; + const glowColor = color === 'amber' ? 'rgba(255,176,32,0.4)' : this.colors.jointGlow; + + ctx.clearRect(0, 0, width, height); + + if (!keypoints || keypoints.length === 0) return; + + // Draw limbs first (behind joints) + ctx.lineWidth = 3; + ctx.lineCap = 'round'; + + for (const [i, j] of SKELETON_CONNECTIONS) { + const kpA = keypoints[i]; + const kpB = keypoints[j]; + if (!kpA || !kpB || kpA.confidence < minConf || kpB.confidence < minConf) continue; + + const ax = kpA.x * width, ay = kpA.y * height; + const bx = kpB.x * width, by = kpB.y * height; + const avgConf = (kpA.confidence + kpB.confidence) / 2; + + // Glow + ctx.strokeStyle = this.colors.limbGlow; + ctx.lineWidth = 8; + ctx.globalAlpha = avgConf * 0.4; + ctx.beginPath(); + ctx.moveTo(ax, ay); + ctx.lineTo(bx, by); + ctx.stroke(); + + // Main line + ctx.strokeStyle = limbColor; + ctx.lineWidth = 2.5; + ctx.globalAlpha = avgConf; + ctx.beginPath(); + ctx.moveTo(ax, ay); + ctx.lineTo(bx, by); + ctx.stroke(); + } + + // Draw joints + ctx.globalAlpha = 1; + for (const kp of keypoints) { + if (!kp || kp.confidence < minConf) continue; + + const x = kp.x * width; + const y = kp.y * height; + const r = 3 + kp.confidence * 3; + + // Glow + ctx.beginPath(); + ctx.arc(x, y, r + 4, 0, Math.PI * 2); + ctx.fillStyle = glowColor; + ctx.globalAlpha = kp.confidence * 0.6; + ctx.fill(); + + // Joint dot + ctx.beginPath(); + ctx.arc(x, y, r, 0, Math.PI * 2); + ctx.fillStyle = jointColor; + ctx.globalAlpha = kp.confidence; + ctx.fill(); + + // White center + ctx.beginPath(); + ctx.arc(x, y, r * 0.4, 0, Math.PI * 2); + ctx.fillStyle = '#fff'; + ctx.globalAlpha = kp.confidence * 0.8; + ctx.fill(); + } + + ctx.globalAlpha = 1; + + // Confidence label + if (opts.label) { + ctx.font = '11px "JetBrains Mono", monospace'; + ctx.fillStyle = jointColor; + ctx.globalAlpha = 0.8; + ctx.fillText(opts.label, 8, height - 8); + ctx.globalAlpha = 1; + } + } + + /** + * Draw CSI amplitude heatmap + * @param {CanvasRenderingContext2D} ctx + * @param {{ data: Float32Array, width: number, height: number }} heatmap + * @param {number} canvasW + * @param {number} canvasH + */ + drawCsiHeatmap(ctx, heatmap, canvasW, canvasH) { + ctx.clearRect(0, 0, canvasW, canvasH); + + if (!heatmap || !heatmap.data || heatmap.height < 2) { + ctx.fillStyle = '#0a0e18'; + ctx.fillRect(0, 0, canvasW, canvasH); + ctx.font = '11px "JetBrains Mono", monospace'; + ctx.fillStyle = 'rgba(255,255,255,0.3)'; + ctx.fillText('Waiting for CSI data...', 8, canvasH / 2); + return; + } + + const { data, width: dw, height: dh } = heatmap; + const cellW = canvasW / dw; + const cellH = canvasH / dh; + + for (let y = 0; y < dh; y++) { + for (let x = 0; x < dw; x++) { + const val = Math.min(1, Math.max(0, data[y * dw + x])); + ctx.fillStyle = this._heatmapColor(val); + ctx.fillRect(x * cellW, y * cellH, cellW + 0.5, cellH + 0.5); + } + } + + // Axis labels + ctx.font = '9px "JetBrains Mono", monospace'; + ctx.fillStyle = 'rgba(255,255,255,0.4)'; + ctx.fillText('Subcarrier →', 4, canvasH - 4); + ctx.save(); + ctx.translate(canvasW - 4, canvasH - 4); + ctx.rotate(-Math.PI / 2); + ctx.fillText('Time ↑', 0, 0); + ctx.restore(); + } + + /** + * Draw embedding space 2D projection + * @param {CanvasRenderingContext2D} ctx + * @param {{ video: Array, csi: Array, fused: Array }} points + * @param {number} w + * @param {number} h + */ + drawEmbeddingSpace(ctx, points, w, h) { + ctx.fillStyle = '#050810'; + ctx.fillRect(0, 0, w, h); + + // Grid + ctx.strokeStyle = 'rgba(255,255,255,0.05)'; + ctx.lineWidth = 0.5; + for (let i = 0; i <= 4; i++) { + const x = (i / 4) * w; + ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h); ctx.stroke(); + const y = (i / 4) * h; + ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke(); + } + + // Axes + ctx.strokeStyle = 'rgba(255,255,255,0.1)'; + ctx.lineWidth = 1; + ctx.beginPath(); ctx.moveTo(w / 2, 0); ctx.lineTo(w / 2, h); ctx.stroke(); + ctx.beginPath(); ctx.moveTo(0, h / 2); ctx.lineTo(w, h / 2); ctx.stroke(); + + const drawPoints = (pts, color, size) => { + if (!pts || pts.length === 0) return; + const len = pts.length; + for (let i = 0; i < len; i++) { + const p = pts[i]; + if (!p) continue; + const age = 1 - (i / len) * 0.7; // Fade older points + const px = w / 2 + p[0] * w * 0.35; + const py = h / 2 + p[1] * h * 0.35; + + if (px < 0 || px > w || py < 0 || py > h) continue; + + ctx.beginPath(); + ctx.arc(px, py, size, 0, Math.PI * 2); + ctx.fillStyle = color; + ctx.globalAlpha = age * 0.7; + ctx.fill(); + } + }; + + drawPoints(points.video, this.colors.videoEmb, 3); + drawPoints(points.csi, this.colors.csiEmb, 3); + drawPoints(points.fused, this.colors.fusedEmb, 4); + ctx.globalAlpha = 1; + + // Legend + ctx.font = '9px "JetBrains Mono", monospace'; + const legends = [ + { color: this.colors.videoEmb, label: 'Video' }, + { color: this.colors.csiEmb, label: 'CSI' }, + { color: this.colors.fusedEmb, label: 'Fused' }, + ]; + legends.forEach((l, i) => { + const ly = 12 + i * 14; + ctx.fillStyle = l.color; + ctx.beginPath(); + ctx.arc(10, ly - 3, 3, 0, Math.PI * 2); + ctx.fill(); + ctx.fillStyle = 'rgba(255,255,255,0.5)'; + ctx.fillText(l.label, 18, ly); + }); + } + + _heatmapColor(val) { + // Dark blue → cyan → green → yellow → red + if (val < 0.25) { + const t = val / 0.25; + return `rgb(${Math.floor(t * 20)}, ${Math.floor(20 + t * 60)}, ${Math.floor(60 + t * 100)})`; + } else if (val < 0.5) { + const t = (val - 0.25) / 0.25; + return `rgb(${Math.floor(20 + t * 20)}, ${Math.floor(80 + t * 100)}, ${Math.floor(160 - t * 60)})`; + } else if (val < 0.75) { + const t = (val - 0.5) / 0.25; + return `rgb(${Math.floor(40 + t * 180)}, ${Math.floor(180 + t * 75)}, ${Math.floor(100 - t * 80)})`; + } else { + const t = (val - 0.75) / 0.25; + return `rgb(${Math.floor(220 + t * 35)}, ${Math.floor(255 - t * 120)}, ${Math.floor(20 - t * 20)})`; + } + } +} diff --git a/ui/pose-fusion/js/cnn-embedder.js b/ui/pose-fusion/js/cnn-embedder.js new file mode 100644 index 00000000..5000b9d3 --- /dev/null +++ b/ui/pose-fusion/js/cnn-embedder.js @@ -0,0 +1,226 @@ +/** + * CNN Embedder — Lightweight MobileNet-V3-style feature extractor. + * + * Architecture mirrors ruvector-cnn: Conv2D → BatchNorm → ReLU → Pool → Project → L2 Normalize + * Uses pre-seeded random weights (deterministic). When ruvector-cnn-wasm is available, + * transparently delegates to the WASM implementation. + * + * Two instances are created: one for video frames, one for CSI pseudo-images. + */ + +// Seeded PRNG for deterministic weight initialization +function mulberry32(seed) { + return function() { + let t = (seed += 0x6D2B79F5); + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +export class CnnEmbedder { + /** + * @param {object} opts + * @param {number} opts.inputSize - Square input dimension (default 56 for speed) + * @param {number} opts.embeddingDim - Output embedding dimension (default 128) + * @param {boolean} opts.normalize - L2 normalize output + * @param {number} opts.seed - PRNG seed for weight init + */ + constructor(opts = {}) { + this.inputSize = opts.inputSize || 56; + this.embeddingDim = opts.embeddingDim || 128; + this.normalize = opts.normalize !== false; + this.wasmEmbedder = null; + + // Initialize weights with deterministic PRNG + const rng = mulberry32(opts.seed || 42); + const randRange = (lo, hi) => lo + rng() * (hi - lo); + + // Conv 3x3: 3 input channels → 16 output channels + this.convWeights = new Float32Array(3 * 3 * 3 * 16); + for (let i = 0; i < this.convWeights.length; i++) { + this.convWeights[i] = randRange(-0.15, 0.15); + } + + // BatchNorm params (16 channels) + this.bnGamma = new Float32Array(16).fill(1.0); + this.bnBeta = new Float32Array(16).fill(0.0); + this.bnMean = new Float32Array(16).fill(0.0); + this.bnVar = new Float32Array(16).fill(1.0); + + // Projection: 16 → embeddingDim + this.projWeights = new Float32Array(16 * this.embeddingDim); + for (let i = 0; i < this.projWeights.length; i++) { + this.projWeights[i] = randRange(-0.1, 0.1); + } + } + + /** + * Try to load WASM embedder from ruvector-cnn-wasm package + * @param {string} wasmPath - Path to the WASM package directory + */ + async tryLoadWasm(wasmPath) { + try { + const mod = await import(`${wasmPath}/ruvector_cnn_wasm.js`); + await mod.default(); + const config = new mod.EmbedderConfig(); + config.input_size = this.inputSize; + config.embedding_dim = this.embeddingDim; + config.normalize = this.normalize; + this.wasmEmbedder = new mod.WasmCnnEmbedder(config); + console.log('[CNN] WASM embedder loaded successfully'); + return true; + } catch (e) { + console.log('[CNN] WASM not available, using JS fallback:', e.message); + return false; + } + } + + /** + * Extract embedding from RGB image data + * @param {Uint8Array} rgbData - RGB pixel data (H*W*3) + * @param {number} width + * @param {number} height + * @returns {Float32Array} embedding vector + */ + extract(rgbData, width, height) { + if (this.wasmEmbedder) { + try { + const result = this.wasmEmbedder.extract(rgbData, width, height); + return new Float32Array(result); + } catch (_) { /* fallback to JS */ } + } + return this._extractJS(rgbData, width, height); + } + + _extractJS(rgbData, width, height) { + // 1. Resize to inputSize × inputSize if needed + const sz = this.inputSize; + let input; + if (width === sz && height === sz) { + input = new Float32Array(rgbData.length); + for (let i = 0; i < rgbData.length; i++) input[i] = rgbData[i] / 255.0; + } else { + input = this._resize(rgbData, width, height, sz, sz); + } + + // 2. ImageNet normalization + const mean = [0.485, 0.456, 0.406]; + const std = [0.229, 0.224, 0.225]; + const pixels = sz * sz; + for (let i = 0; i < pixels; i++) { + input[i * 3] = (input[i * 3] - mean[0]) / std[0]; + input[i * 3 + 1] = (input[i * 3 + 1] - mean[1]) / std[1]; + input[i * 3 + 2] = (input[i * 3 + 2] - mean[2]) / std[2]; + } + + // 3. Conv2D 3x3 (3 → 16 channels) + const convOut = this._conv2d3x3(input, sz, sz, 3, 16); + + // 4. BatchNorm + this._batchNorm(convOut, 16); + + // 5. ReLU + for (let i = 0; i < convOut.length; i++) { + if (convOut[i] < 0) convOut[i] = 0; + } + + // 6. Global average pooling → 16-dim + const outH = sz - 2, outW = sz - 2; + const pooled = new Float32Array(16); + const spatial = outH * outW; + for (let i = 0; i < spatial; i++) { + for (let c = 0; c < 16; c++) { + pooled[c] += convOut[i * 16 + c]; + } + } + for (let c = 0; c < 16; c++) pooled[c] /= spatial; + + // 7. Linear projection → embeddingDim + const emb = new Float32Array(this.embeddingDim); + for (let o = 0; o < this.embeddingDim; o++) { + let sum = 0; + for (let i = 0; i < 16; i++) { + sum += pooled[i] * this.projWeights[i * this.embeddingDim + o]; + } + emb[o] = sum; + } + + // 8. L2 normalize + if (this.normalize) { + let norm = 0; + for (let i = 0; i < emb.length; i++) norm += emb[i] * emb[i]; + norm = Math.sqrt(norm); + if (norm > 1e-8) { + for (let i = 0; i < emb.length; i++) emb[i] /= norm; + } + } + + return emb; + } + + _conv2d3x3(input, H, W, Cin, Cout) { + const outH = H - 2, outW = W - 2; + const output = new Float32Array(outH * outW * Cout); + for (let y = 0; y < outH; y++) { + for (let x = 0; x < outW; x++) { + for (let co = 0; co < Cout; co++) { + let sum = 0; + for (let ky = 0; ky < 3; ky++) { + for (let kx = 0; kx < 3; kx++) { + for (let ci = 0; ci < Cin; ci++) { + const px = ((y + ky) * W + (x + kx)) * Cin + ci; + const wt = (((ky * 3 + kx) * Cin) + ci) * Cout + co; + sum += input[px] * this.convWeights[wt]; + } + } + } + output[(y * outW + x) * Cout + co] = sum; + } + } + } + return output; + } + + _batchNorm(data, channels) { + const spatial = data.length / channels; + for (let i = 0; i < spatial; i++) { + for (let c = 0; c < channels; c++) { + const idx = i * channels + c; + data[idx] = this.bnGamma[c] * (data[idx] - this.bnMean[c]) / Math.sqrt(this.bnVar[c] + 1e-5) + this.bnBeta[c]; + } + } + } + + _resize(rgbData, srcW, srcH, dstW, dstH) { + const output = new Float32Array(dstW * dstH * 3); + const xRatio = srcW / dstW; + const yRatio = srcH / dstH; + for (let y = 0; y < dstH; y++) { + for (let x = 0; x < dstW; x++) { + const sx = Math.min(Math.floor(x * xRatio), srcW - 1); + const sy = Math.min(Math.floor(y * yRatio), srcH - 1); + const srcIdx = (sy * srcW + sx) * 3; + const dstIdx = (y * dstW + x) * 3; + output[dstIdx] = rgbData[srcIdx] / 255.0; + output[dstIdx + 1] = rgbData[srcIdx + 1] / 255.0; + output[dstIdx + 2] = rgbData[srcIdx + 2] / 255.0; + } + } + return output; + } + + /** Cosine similarity between two embeddings */ + static cosineSimilarity(a, b) { + let dot = 0, normA = 0, normB = 0; + for (let i = 0; i < a.length; i++) { + dot += a[i] * b[i]; + normA += a[i] * a[i]; + normB += b[i] * b[i]; + } + normA = Math.sqrt(normA); + normB = Math.sqrt(normB); + if (normA < 1e-8 || normB < 1e-8) return 0; + return dot / (normA * normB); + } +} diff --git a/ui/pose-fusion/js/fusion-engine.js b/ui/pose-fusion/js/fusion-engine.js new file mode 100644 index 00000000..8ded2e8a --- /dev/null +++ b/ui/pose-fusion/js/fusion-engine.js @@ -0,0 +1,166 @@ +/** + * FusionEngine — Attention-weighted dual-modal embedding fusion. + * + * Combines visual (camera) and CSI (WiFi) embeddings with dynamic + * confidence gating based on signal quality. + */ + +export class FusionEngine { + /** + * @param {number} embeddingDim + */ + constructor(embeddingDim = 128) { + this.embeddingDim = embeddingDim; + + // Learnable attention weights (initialized to balanced 0.5) + // In production, these would be loaded from trained JSON + this.attentionWeights = new Float32Array(embeddingDim).fill(0.5); + + // Dynamic modality confidence [0, 1] + this.videoConfidence = 1.0; + this.csiConfidence = 0.0; + this.fusedConfidence = 0.5; + + // Smoothing for confidence transitions + this._smoothAlpha = 0.85; + + // Embedding history for visualization + this.recentVideoEmbeddings = []; + this.recentCsiEmbeddings = []; + this.recentFusedEmbeddings = []; + this.maxHistory = 50; + } + + /** + * Update quality-based confidence scores + * @param {number} videoBrightness - [0,1] video brightness quality + * @param {number} videoMotion - [0,1] motion detected + * @param {number} csiSnr - CSI signal-to-noise ratio in dB + * @param {boolean} csiActive - Whether CSI source is connected + */ + updateConfidence(videoBrightness, videoMotion, csiSnr, csiActive) { + // Video confidence: drops with low brightness, boosted by motion + let vc = 0; + if (videoBrightness > 0.05) { + vc = Math.min(1, videoBrightness * 1.5) * 0.7 + Math.min(1, videoMotion * 3) * 0.3; + } + + // CSI confidence: based on SNR and connection status + let cc = 0; + if (csiActive) { + cc = Math.min(1, csiSnr / 25); // 25dB = full confidence + } + + // Smooth transitions + this.videoConfidence = this._smoothAlpha * this.videoConfidence + (1 - this._smoothAlpha) * vc; + this.csiConfidence = this._smoothAlpha * this.csiConfidence + (1 - this._smoothAlpha) * cc; + + // Fused confidence is the max of either (fusion can only help) + this.fusedConfidence = Math.min(1, Math.sqrt( + this.videoConfidence * this.videoConfidence + this.csiConfidence * this.csiConfidence + )); + } + + /** + * Fuse video and CSI embeddings + * @param {Float32Array|null} videoEmb - Visual embedding (or null if video-off) + * @param {Float32Array|null} csiEmb - CSI embedding (or null if CSI-off) + * @param {string} mode - 'dual' | 'video' | 'csi' + * @returns {Float32Array} Fused embedding + */ + fuse(videoEmb, csiEmb, mode = 'dual') { + const dim = this.embeddingDim; + const fused = new Float32Array(dim); + + if (mode === 'video' || !csiEmb) { + if (videoEmb) fused.set(videoEmb); + this._recordEmbedding(videoEmb, null, fused); + return fused; + } + + if (mode === 'csi' || !videoEmb) { + if (csiEmb) fused.set(csiEmb); + this._recordEmbedding(null, csiEmb, fused); + return fused; + } + + // Dual mode: attention-weighted fusion with confidence gating + const totalConf = this.videoConfidence + this.csiConfidence; + const videoWeight = totalConf > 0 ? this.videoConfidence / totalConf : 0.5; + + for (let i = 0; i < dim; i++) { + const alpha = this.attentionWeights[i] * videoWeight + + (1 - this.attentionWeights[i]) * (1 - videoWeight); + fused[i] = alpha * videoEmb[i] + (1 - alpha) * csiEmb[i]; + } + + // Re-normalize + let norm = 0; + for (let i = 0; i < dim; i++) norm += fused[i] * fused[i]; + norm = Math.sqrt(norm); + if (norm > 1e-8) { + for (let i = 0; i < dim; i++) fused[i] /= norm; + } + + this._recordEmbedding(videoEmb, csiEmb, fused); + return fused; + } + + /** + * Get embedding pairs for 2D visualization (PCA projection) + * @returns {{ video: Array, csi: Array, fused: Array }} + */ + getEmbeddingPoints() { + // Simple 2D projection using first two principal components (approximated) + const project = (emb) => { + if (!emb || emb.length < 4) return null; + // Use pairs of dimensions as crude 2D projection + let x = 0, y = 0; + for (let i = 0; i < emb.length; i += 2) { + x += emb[i] * (i % 4 < 2 ? 1 : -1); + if (i + 1 < emb.length) { + y += emb[i + 1] * (i % 4 < 2 ? 1 : -1); + } + } + return [x * 2, y * 2]; // Scale for visibility + }; + + return { + video: this.recentVideoEmbeddings.map(project).filter(Boolean), + csi: this.recentCsiEmbeddings.map(project).filter(Boolean), + fused: this.recentFusedEmbeddings.map(project).filter(Boolean) + }; + } + + /** + * Cross-modal similarity score + * @returns {number} Cosine similarity between latest video and CSI embeddings + */ + getCrossModalSimilarity() { + const v = this.recentVideoEmbeddings[this.recentVideoEmbeddings.length - 1]; + const c = this.recentCsiEmbeddings[this.recentCsiEmbeddings.length - 1]; + if (!v || !c) return 0; + + let dot = 0, na = 0, nb = 0; + for (let i = 0; i < v.length; i++) { + dot += v[i] * c[i]; + na += v[i] * v[i]; + nb += c[i] * c[i]; + } + na = Math.sqrt(na); nb = Math.sqrt(nb); + return (na > 1e-8 && nb > 1e-8) ? dot / (na * nb) : 0; + } + + _recordEmbedding(video, csi, fused) { + if (video) { + this.recentVideoEmbeddings.push(new Float32Array(video)); + if (this.recentVideoEmbeddings.length > this.maxHistory) this.recentVideoEmbeddings.shift(); + } + if (csi) { + this.recentCsiEmbeddings.push(new Float32Array(csi)); + if (this.recentCsiEmbeddings.length > this.maxHistory) this.recentCsiEmbeddings.shift(); + } + this.recentFusedEmbeddings.push(new Float32Array(fused)); + if (this.recentFusedEmbeddings.length > this.maxHistory) this.recentFusedEmbeddings.shift(); + } +} diff --git a/ui/pose-fusion/js/main.js b/ui/pose-fusion/js/main.js index f18c650f..29f283f4 100644 --- a/ui/pose-fusion/js/main.js +++ b/ui/pose-fusion/js/main.js @@ -111,8 +111,10 @@ function init() { }); // Try to load WASM embedders (non-blocking) - visualCnn.tryLoadWasm('./pkg/ruvector_cnn_wasm'); - csiCnn.tryLoadWasm('./pkg/ruvector_cnn_wasm'); + // Resolve relative to this JS module file (in pose-fusion/js/) → ../pkg/ + const wasmBase = new URL('../pkg/ruvector_cnn_wasm', import.meta.url).href; + visualCnn.tryLoadWasm(wasmBase); + csiCnn.tryLoadWasm(wasmBase); // Auto-start camera for video/dual modes updateModeUI();