/** * 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)})`; } } }