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