fix: responsive video layout + WASM path + motion tracking
Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
parent
aeea8b2124
commit
4d541e02ff
|
|
@ -129,10 +129,11 @@ body {
|
||||||
.main-grid {
|
.main-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 360px;
|
grid-template-columns: 1fr 360px;
|
||||||
grid-template-rows: auto auto;
|
grid-template-rows: 1fr auto;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
padding: 16px 24px;
|
padding: 16px 24px;
|
||||||
max-height: calc(100vh - 72px);
|
height: calc(100vh - 72px);
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* === Video Panel === */
|
/* === Video Panel === */
|
||||||
|
|
@ -142,8 +143,7 @@ body {
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
border: 1px solid var(--bg-panel-border);
|
border: 1px solid var(--bg-panel-border);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
aspect-ratio: 4/3;
|
min-height: 0;
|
||||||
max-height: 60vh;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.video-panel video {
|
.video-panel video {
|
||||||
|
|
@ -204,7 +204,7 @@ body {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
max-height: calc(100vh - 88px);
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel {
|
.panel {
|
||||||
|
|
@ -397,7 +397,9 @@ body {
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.main-grid {
|
.main-grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
height: auto;
|
||||||
|
overflow: auto;
|
||||||
}
|
}
|
||||||
.video-panel { aspect-ratio: 16/9; max-height: 40vh; }
|
.video-panel { aspect-ratio: 16/9; max-height: 50vh; }
|
||||||
.side-panels { max-height: none; }
|
.side-panels { max-height: none; overflow: visible; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -111,8 +111,10 @@ function init() {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Try to load WASM embedders (non-blocking)
|
// Try to load WASM embedders (non-blocking)
|
||||||
visualCnn.tryLoadWasm('./pkg/ruvector_cnn_wasm');
|
// Resolve relative to this JS module file (in pose-fusion/js/) → ../pkg/
|
||||||
csiCnn.tryLoadWasm('./pkg/ruvector_cnn_wasm');
|
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
|
// Auto-start camera for video/dual modes
|
||||||
updateModeUI();
|
updateModeUI();
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
}
|
||||||
|
|
@ -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)})`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -111,8 +111,10 @@ function init() {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Try to load WASM embedders (non-blocking)
|
// Try to load WASM embedders (non-blocking)
|
||||||
visualCnn.tryLoadWasm('./pkg/ruvector_cnn_wasm');
|
// Resolve relative to this JS module file (in pose-fusion/js/) → ../pkg/
|
||||||
csiCnn.tryLoadWasm('./pkg/ruvector_cnn_wasm');
|
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
|
// Auto-start camera for video/dual modes
|
||||||
updateModeUI();
|
updateModeUI();
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue