mirror of
https://github.com/ruvnet/RuView
synced 2026-06-09 10:13:17 +00:00
7c1351fd5d
* feat: dual-modal WASM browser pose estimation demo (ADR-058) Live webcam video + WiFi CSI fusion for real-time pose estimation. Two parallel CNN pipelines (ruvector-cnn-wasm) with attention-weighted fusion and dynamic confidence gating. Three modes: Dual, Video-only, CSI-only. Includes pre-built WASM package (~52KB) for browser deployment. - ADR-058: Dual-modal architecture design - ui/pose-fusion.html: Main demo page with dark theme UI - 7 JS modules: video-capture, csi-simulator, cnn-embedder, fusion-engine, pose-decoder, canvas-renderer, main orchestrator - Pre-built ruvector-cnn-wasm WASM package for browser - CSI heatmap, embedding space visualization, latency metrics - WebSocket support for live ESP32 CSI data - Navigation link added to main dashboard Co-Authored-By: claude-flow <ruv@ruv.net> * fix: motion-responsive skeleton + through-wall CSI tracking - Pose decoder now uses per-cell motion grid to track actual arm/head positions — raising arms moves the skeleton's arms, head follows lateral movement - Motion grid (10x8 cells) tracks intensity per body zone: head, left/right arm upper/mid, legs - Through-wall mode: when person exits frame, CSI maintains presence with slow decay (~10s) and skeleton drifts in exit direction - CSI simulator persists sensing after video loss, ghost pose renders with decreasing confidence - Reduced temporal smoothing (0.45) for faster response to movement Co-Authored-By: claude-flow <ruv@ruv.net> * fix: video fills available space + correct WASM path resolution - Remove fixed aspect-ratio and max-height from video panel so it fills the available viewport space without scrolling - Grid uses 1fr row for content area, overflow:hidden on main grid - Fix WASM path: resolve relative to JS module file using import.meta.url instead of hardcoded ./pkg/ which resolved incorrectly on gh-pages - Responsive: mobile still gets aspect-ratio constraint Co-Authored-By: claude-flow <ruv@ruv.net> * feat: live ESP32 CSI pipeline + auto-connect WebSocket - Add auto-connect to local sensing server WebSocket (ws://localhost:8765) - Demo shows "Live ESP32" when connected to real CSI data - Add build_firmware.ps1 for native Windows ESP-IDF builds (no Docker) - Add read_serial.ps1 for ESP32 serial monitor Pipeline: ESP32 → UDP:5005 → sensing-server → WS:8765 → browser demo Co-Authored-By: claude-flow <ruv@ruv.net> * docs: add ADR-059 live ESP32 CSI pipeline + update README with demo links - ADR-059: Documents end-to-end ESP32 → sensing server → browser pipeline - README: Add dual-modal pose fusion demo link, update ADR count to 49 - References issue #245 Co-Authored-By: claude-flow <ruv@ruv.net> * feat: RSSI visualization, RuVector attention WASM, cache-bust fixes - Add animated RSSI Signal Strength panel with sparkline history - Fix RuVector WasmMultiHeadAttention retptr calling convention - Wire up RuVector Multi-Head + Flash Attention in CNN embedder - Add ambient temporal drift to CSI simulator for visible heatmap animation - Fix embedding space projection (sparse projection replaces cancelling sum) - Add auto-scaling to embedding space renderer - Add cache busters (?v=4) to all ES module imports to prevent stale caches - Add diagnostic logging for module version verification - Add RSSI tracking with quality labels and color-coded dBm display - Includes ruvector-attention-wasm v2.0.5 browser ESM wrapper Co-Authored-By: claude-flow <ruv@ruv.net> * feat: 26-keypoint dexterous pose + full RuVector attention pipeline Pose Decoder (17 → 26 keypoints): - Add finger approximations: thumb, index, pinky per hand (6 new) - Add toe tips: left/right foot index (2 new) - Add neck keypoint (1 new) - Hand openness driven by arm motion intensity - Finger positions computed from wrist-elbow axis angles CNN Embedder (full RuVector WASM pipeline): - Stage 1: Multi-Head Attention (global spatial reasoning) - Stage 2: Hyperbolic Attention (hierarchical body-part tree) - Stage 3: MoE Attention (3 experts: upper/lower/extremities, top-2) - Blended 40/30/30 weighting → final embedding projection Canvas Renderer: - Magenta finger joints with distinct glow - Cyan toe tips - White neck keypoint - Thinner limb lines for hand/foot connections - Joint count shown in overlay label CSI Simulator: - Skip synthetic person state when live ESP32 connected - Only simulate CSI data in demo mode (was already correct) Embedding Space: - Fixed projection: sparse 8-dim projection replaces cancelling sum - Auto-scaling normalizes point spread to fill canvas Cache busters bumped to v=5 on all imports. Co-Authored-By: claude-flow <ruv@ruv.net> * fix: centroid-based pose tracking for responsive limb movement Rewrites pose decoder from intensity-based to position-based tracking: - Arms now track toward motion centroid in each body zone - Elbow/wrist positions computed along shoulder→centroid vector - Legs track toward lower-body zone centroids - Smoothing reduced from 0.45 to 0.25 for responsiveness - Zone centroids blend 30% old / 70% new each frame 6 body zones with overlapping coverage: - Head (top 20%, center cols) - Left/Right Arm (rows 10-60%, outer cols) - Torso (rows 15-55%, center cols) - Left/Right Leg (rows 50-100%, half cols each) Hand openness now driven by arm spread distance + raise amount. Cache busters v=6. Co-Authored-By: claude-flow <ruv@ruv.net> * fix: remove duplicate lAnkleX/rAnkleX declarations in pose-decoder Stale code block from old intensity-based tracking was left behind, re-declaring variables already defined by centroid-based tracking. Co-Authored-By: claude-flow <ruv@ruv.net> * feat(demo): wire all 6 RuVector WASM attention mechanisms into pose fusion - Add WasmLinearAttention and WasmLocalGlobalAttention to browser ESM wrapper - Add 6 WASM utility functions (batch_normalize, pairwise_distances, etc.) - Extend CnnEmbedder to 6-stage pipeline: Flash → MHA → Hyperbolic → Linear → MoE → L+G - Use log-energy softmax blending across all 6 stages - Wire WASM cosine_similarity and normalize into FusionEngine - Add RuVector pipeline stats panel to UI (energy, refinement, pose impact) - Compute embedding-to-joint mapping stats without modifying joint positions - Center camera prompt with flexbox layout - Add cache busters v=12 Co-Authored-By: claude-flow <ruv@ruv.net>
308 lines
10 KiB
JavaScript
308 lines
10 KiB
JavaScript
/**
|
|
* 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;
|
|
|
|
// Extended keypoint styling
|
|
const fingerColor = '#ff6ef0'; // Magenta for finger tips
|
|
const fingerGlow = 'rgba(255,110,240,0.4)';
|
|
const fingerLimb = 'rgba(255,110,240,0.5)';
|
|
const toeColor = '#6ef0ff'; // Cyan for toes
|
|
const neckColor = '#ffffff'; // White for neck
|
|
|
|
ctx.clearRect(0, 0, width, height);
|
|
|
|
if (!keypoints || keypoints.length === 0) return;
|
|
|
|
// Draw limbs first (behind joints)
|
|
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;
|
|
|
|
// Is this a hand/finger connection? (indices 17-22)
|
|
const isFingerLink = i >= 17 && i <= 22 || j >= 17 && j <= 22;
|
|
const isToeLink = i >= 23 && i <= 24 || j >= 23 && j <= 24;
|
|
|
|
// Glow
|
|
ctx.strokeStyle = isFingerLink ? fingerLimb : this.colors.limbGlow;
|
|
ctx.lineWidth = isFingerLink ? 4 : 8;
|
|
ctx.globalAlpha = avgConf * (isFingerLink ? 0.3 : 0.4);
|
|
ctx.beginPath();
|
|
ctx.moveTo(ax, ay);
|
|
ctx.lineTo(bx, by);
|
|
ctx.stroke();
|
|
|
|
// Main line
|
|
ctx.strokeStyle = isFingerLink ? fingerColor : isToeLink ? toeColor : limbColor;
|
|
ctx.lineWidth = isFingerLink || isToeLink ? 1.5 : 2.5;
|
|
ctx.globalAlpha = avgConf;
|
|
ctx.beginPath();
|
|
ctx.moveTo(ax, ay);
|
|
ctx.lineTo(bx, by);
|
|
ctx.stroke();
|
|
}
|
|
|
|
// Draw joints
|
|
ctx.globalAlpha = 1;
|
|
for (let idx = 0; idx < keypoints.length; idx++) {
|
|
const kp = keypoints[idx];
|
|
if (!kp || kp.confidence < minConf) continue;
|
|
|
|
const x = kp.x * width;
|
|
const y = kp.y * height;
|
|
const isFinger = idx >= 17 && idx <= 22;
|
|
const isToe = idx >= 23 && idx <= 24;
|
|
const isNeck = idx === 25;
|
|
const r = isFinger ? 2 + kp.confidence * 2 : isToe ? 2 : 3 + kp.confidence * 3;
|
|
const jColor = isFinger ? fingerColor : isToe ? toeColor : isNeck ? neckColor : jointColor;
|
|
const gColor = isFinger ? fingerGlow : glowColor;
|
|
|
|
// Glow
|
|
ctx.beginPath();
|
|
ctx.arc(x, y, r + (isFinger ? 3 : 4), 0, Math.PI * 2);
|
|
ctx.fillStyle = gColor;
|
|
ctx.globalAlpha = kp.confidence * (isFinger ? 0.5 : 0.6);
|
|
ctx.fill();
|
|
|
|
// Joint dot
|
|
ctx.beginPath();
|
|
ctx.arc(x, y, r, 0, Math.PI * 2);
|
|
ctx.fillStyle = jColor;
|
|
ctx.globalAlpha = kp.confidence;
|
|
ctx.fill();
|
|
|
|
// White center (body joints only)
|
|
if (!isFinger && !isToe) {
|
|
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 + keypoint count
|
|
if (opts.label) {
|
|
const visCount = keypoints.filter(kp => kp && kp.confidence >= minConf).length;
|
|
ctx.font = '11px "JetBrains Mono", monospace';
|
|
ctx.fillStyle = jointColor;
|
|
ctx.globalAlpha = 0.8;
|
|
ctx.fillText(`${opts.label} · ${visCount} joints`, 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();
|
|
|
|
// Auto-scale: find max extent across all point sets
|
|
let maxExtent = 0.01;
|
|
for (const pts of [points.video, points.csi, points.fused]) {
|
|
if (!pts) continue;
|
|
for (const p of pts) {
|
|
if (!p) continue;
|
|
maxExtent = Math.max(maxExtent, Math.abs(p[0]), Math.abs(p[1]));
|
|
}
|
|
}
|
|
const scale = 0.42 / maxExtent; // Fill ~84% of half-width
|
|
|
|
const drawPoints = (pts, color, size) => {
|
|
if (!pts || pts.length === 0) return;
|
|
const len = pts.length;
|
|
|
|
// Draw trail line connecting recent points
|
|
if (len >= 2) {
|
|
ctx.beginPath();
|
|
let started = false;
|
|
for (let i = 0; i < len; i++) {
|
|
const p = pts[i];
|
|
if (!p) continue;
|
|
const px = w / 2 + p[0] * scale * w;
|
|
const py = h / 2 + p[1] * scale * h;
|
|
if (px < -10 || px > w + 10 || py < -10 || py > h + 10) continue;
|
|
if (!started) { ctx.moveTo(px, py); started = true; }
|
|
else ctx.lineTo(px, py);
|
|
}
|
|
ctx.strokeStyle = color;
|
|
ctx.globalAlpha = 0.2;
|
|
ctx.lineWidth = 1;
|
|
ctx.stroke();
|
|
}
|
|
|
|
// Draw dots with glow on newest
|
|
for (let i = 0; i < len; i++) {
|
|
const p = pts[i];
|
|
if (!p) continue;
|
|
const age = 1 - (i / len) * 0.7;
|
|
const px = w / 2 + p[0] * scale * w;
|
|
const py = h / 2 + p[1] * scale * h;
|
|
|
|
if (px < -10 || px > w + 10 || py < -10 || py > h + 10) continue;
|
|
|
|
// Glow on newest point
|
|
if (i === len - 1) {
|
|
ctx.beginPath();
|
|
ctx.arc(px, py, size + 4, 0, Math.PI * 2);
|
|
ctx.fillStyle = color;
|
|
ctx.globalAlpha = 0.3;
|
|
ctx.fill();
|
|
}
|
|
|
|
ctx.beginPath();
|
|
ctx.arc(px, py, i === len - 1 ? size + 1 : size, 0, Math.PI * 2);
|
|
ctx.fillStyle = color;
|
|
ctx.globalAlpha = age * 0.8;
|
|
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)})`;
|
|
}
|
|
}
|
|
}
|