wifi-densepose/examples/through-wall/wiflow_browser.html

927 lines
46 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>WiFlow Browser Trainer · calibrate → capture → train → infer, in your camera's frame</title>
<!--
WiFlow in-browser trainer (ADR-079 / ADR-180 + ADR-151 empty-room baseline).
A 4-STAGE GATED FLOW, all in the LAPTOP camera's coordinate frame:
0. CALIBRATE — empty-room baseline (Welford mean+std over the 410-d CSI vector).
Every CSI vector afterwards is expressed as deviation-from-baseline,
so a body's perturbation stands out from the static channel.
1. CAPTURE — MediaPipe Pose on the laptop camera = 17 COCO keypoints (the LABEL),
paired with the baseline-normalized live ESP32 CSI vector (the INPUT).
Guided, balanced routine with a per-pose coverage meter.
2. TRAIN — a TF.js MLP (WebGPU/WASM/WebGL) learns CSI -> pose in-browser. Honest
held-out PCK + a mean-pose baseline it must beat.
3. INFER — the trained model drives a skeleton FROM WiFi CSI ONLY, drawn over the
same camera frame, so it ALIGNS (the whole point of doing it in-browser).
Self-contained. CDN libs only. No bundler. Real data only — CSI source must read "esp32".
-->
<style>
:root{--bg:#0a0c10;--panel:#11151c;--panel2:#0d1117;--amber:#ffb840;--green:#46e08a;
--red:#ff5a5a;--blue:#5aa9ff;--mute:#7d8796;--line:#1d2430;--txt:#dfe6ee}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--txt);
font:14px/1.5 'JetBrains Mono',ui-monospace,Menlo,Consolas,monospace}
header{padding:14px 18px;border-bottom:1px solid var(--line);display:flex;align-items:center;gap:14px;flex-wrap:wrap}
h1{font-size:15px;margin:0;letter-spacing:1px;text-transform:uppercase;font-weight:600}
h1 span{color:var(--amber)}
#compute{padding:4px 10px;border-radius:5px;font-weight:600;font-size:11px;letter-spacing:.5px;
background:rgba(90,169,255,.12);color:var(--blue);border:1px solid var(--blue)}
#banner{margin-left:auto;padding:5px 12px;border-radius:5px;font-weight:600;font-size:12px;letter-spacing:.5px}
.live{background:rgba(70,224,138,.15);color:var(--green);border:1px solid var(--green)}
.sim{background:rgba(255,184,64,.15);color:var(--amber);border:1px solid var(--amber)}
.down{background:rgba(255,90,90,.15);color:var(--red);border:1px solid var(--red)}
/* progress stepper */
.steps{display:flex;gap:6px;padding:14px 18px 0;flex-wrap:wrap;align-items:center}
.step{display:flex;align-items:center;gap:8px;background:var(--panel);color:var(--mute);
border:1px solid var(--line);border-radius:8px;padding:8px 16px;cursor:pointer;font-weight:600;letter-spacing:.5px}
.step .num{display:inline-flex;width:20px;height:20px;border-radius:50%;background:var(--line);color:var(--txt);
align-items:center;justify-content:center;font-size:11px}
.step.on{color:var(--amber);border-color:var(--amber)}
.step.on .num{background:var(--amber);color:#0a0c10}
.step.done .num{background:var(--green);color:#0a0c10}
.step.locked{opacity:.45;cursor:not-allowed}
.arrow{color:var(--mute)}
main{padding:14px 18px 24px}
.panel{display:none;background:var(--panel2);border:1px solid var(--line);border-radius:10px;padding:18px}
.panel.on{display:block}
.cols{display:flex;gap:18px;flex-wrap:wrap}
.card{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:14px}
canvas{background:#070a0e;border-radius:8px;display:block}
.label{font-size:11px;text-transform:uppercase;letter-spacing:1.5px;color:var(--mute);margin-bottom:8px}
.stats{min-width:260px;flex:1}
.row{display:flex;justify-content:space-between;padding:3px 0;border-bottom:1px dashed var(--line)}
.row .k{color:var(--mute)} .row .v{color:var(--amber);font-variant-numeric:tabular-nums;text-align:right}
.v.green{color:var(--green)} .v.red{color:var(--red)} .v.blue{color:var(--blue)}
.note{margin-top:12px;font-size:11px;color:var(--mute);line-height:1.6}
.note b{color:var(--txt)}
button.btn{background:var(--amber);color:#0a0c10;border:0;border-radius:6px;padding:8px 16px;
font:inherit;font-weight:600;cursor:pointer}
button.btn:disabled{opacity:.4;cursor:not-allowed}
button.ghost{background:transparent;color:var(--txt);border:1px solid var(--line)}
select,input{background:var(--panel);color:var(--txt);border:1px solid var(--line);border-radius:6px;
padding:7px;font:inherit;max-width:260px}
.bar{height:8px;background:var(--line);border-radius:5px;overflow:hidden;margin-top:4px}
.bar>i{display:block;height:100%;background:var(--green);width:0%}
.verdict{padding:10px 14px;border-radius:8px;margin-top:12px;font-weight:600;font-size:13px}
.verdict.good{background:rgba(70,224,138,.12);color:var(--green);border:1px solid var(--green)}
.verdict.bad{background:rgba(255,90,90,.12);color:var(--red);border:1px solid var(--red)}
.verdict.idle{background:rgba(125,135,150,.1);color:var(--mute);border:1px solid var(--line)}
.pill{display:inline-block;padding:2px 8px;border-radius:10px;font-size:11px;font-weight:600;margin-left:6px}
.pill.gt{background:rgba(90,169,255,.15);color:var(--blue);border:1px solid var(--blue)}
.pill.csi{background:rgba(70,224,138,.15);color:var(--green);border:1px solid var(--green)}
code{background:#0a0c10;border:1px solid var(--line);border-radius:4px;padding:1px 5px;color:var(--amber)}
a{color:var(--blue)}
/* big guided prompt */
#prompt{font-size:30px;font-weight:700;color:var(--amber);letter-spacing:1px;text-align:center;margin:6px 0}
#countdown{font-size:13px;color:var(--mute);text-align:center}
/* coverage meter */
.cov{display:flex;flex-direction:column;gap:5px;margin-top:8px}
.covrow{display:flex;align-items:center;gap:8px;font-size:11px}
.covrow .nm{width:90px;color:var(--mute);text-transform:capitalize}
.covrow .bar{flex:1;margin:0}
.covrow .ct{width:42px;text-align:right;color:var(--txt);font-variant-numeric:tabular-nums}
</style>
</head>
<body>
<header>
<h1>WiFlow <span>Browser Trainer</span> — calibrate · capture · train · infer</h1>
<div id="compute">compute: …</div>
<div id="banner" class="down">CONNECTING…</div>
</header>
<!-- progress stepper, each gated on the previous -->
<div class="steps">
<div class="step on" data-stage="calibrate"><span class="num">0</span> CALIBRATE</div>
<span class="arrow"></span>
<div class="step locked" data-stage="capture"><span class="num">1</span> CAPTURE</div>
<span class="arrow"></span>
<div class="step locked" data-stage="train"><span class="num">2</span> TRAIN</div>
<span class="arrow"></span>
<div class="step locked" data-stage="infer"><span class="num">3</span> INFER</div>
</div>
<main>
<!-- ============================ STAGE 0 · CALIBRATE ============================ -->
<section id="stage-calibrate" class="panel on">
<div class="cols">
<div class="card">
<div class="label">empty-room baseline (ADR-151) — step OUT of the space</div>
<canvas id="calCv" width="420" height="300"></canvas>
<div style="margin-top:10px;display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<button id="calBtn" class="btn">calibrate baseline (10 s)</button>
<button id="recalBtn" class="ghost btn">recalibrate</button>
</div>
</div>
<div class="card stats">
<div class="label">baseline</div>
<div class="row"><span class="k">CSI source</span><span class="v" id="calSrc"></span></div>
<div class="row"><span class="k">status</span><span class="v" id="calStatus">NOT CALIBRATED</span></div>
<div class="row"><span class="k">frames in baseline</span><span class="v" id="calN">0</span></div>
<div class="row"><span class="k">age</span><span class="v" id="calAge"></span></div>
<div style="margin-top:8px"><div class="bar"><i id="calBar"></i></div></div>
<div class="note">
The room's static WiFi channel is mostly constant. We capture ~10 s of the
<b>quiescent</b> field (you OUT of the space) and compute a per-feature running
<b>mean + std</b> (Welford) over the 410-d CSI vector. Afterwards every CSI vector
is expressed as <b>deviation from baseline</b>:
<code>x_norm = (x base_mean) / (base_std + ε)</code> — applied consistently in
capture, train, and infer. This makes a <b>body's perturbation</b> stand out from
the static channel. You must calibrate before capturing.
</div>
</div>
</div>
</section>
<!-- ============================ STAGE 1 · CAPTURE ============================ -->
<section id="stage-capture" class="panel">
<div class="cols">
<div class="card">
<div class="label">laptop camera <span class="pill gt">MediaPipe skeleton = GROUND TRUTH (the label)</span></div>
<canvas id="capCv" width="420" height="480"></canvas>
<div id="prompt">stand still</div>
<div id="countdown"></div>
<div style="margin-top:8px;display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<button id="camBtn" class="btn">enable laptop camera</button>
<select id="camSel" style="display:none"></select>
</div>
<div id="camStatus" class="note" style="margin-top:6px">camera: off</div>
</div>
<div class="card stats">
<div class="label">guided capture</div>
<div class="row"><span class="k">CSI source</span><span class="v" id="capSrc"></span></div>
<div class="row"><span class="k">CSI nodes</span><span class="v" id="capNodes"></span></div>
<div class="row"><span class="k">pose visibility</span><span class="v" id="capVis"></span></div>
<div class="row"><span class="k">total samples</span><span class="v green" id="capN">0</span></div>
<div class="row"><span class="k">last skip reason</span><span class="v" id="capSkip"></span></div>
<div style="margin-top:12px;display:flex;gap:8px;flex-wrap:wrap">
<button id="recBtn" class="btn" disabled>● start guided recording</button>
<button id="clrBtn" class="ghost btn">clear dataset</button>
</div>
<div class="label" style="margin-top:16px">per-pose coverage (balance the dataset)</div>
<div id="cov" class="cov"></div>
<div class="note">
A pair is recorded <b>only</b> when BOTH (a) a confident MediaPipe pose
(mean visibility &gt; 0.5) AND (b) a fresh <b>live</b> CSI frame (<code>source==esp32</code>)
exist. We store the <b>baseline-normalized</b> CSI + the 17 keypoints, mirrored to
IndexedDB so a refresh keeps them. Follow the prompt so every pose bucket fills up —
a balanced set beats 2 000 frames of standing.
</div>
</div>
</div>
</section>
<!-- ============================ STAGE 2 · TRAIN ============================ -->
<section id="stage-train" class="panel">
<div class="cols">
<div class="card stats">
<div class="label">train (TensorFlow.js)</div>
<div class="row"><span class="k">total samples</span><span class="v" id="trN">0</span></div>
<div class="row"><span class="k">train / val split</span><span class="v" id="trSplit">— / — (chronological 80/20)</span></div>
<div class="row"><span class="k">epoch</span><span class="v" id="trEpoch">0</span></div>
<div class="row"><span class="k">train MSE</span><span class="v" id="trLoss"></span></div>
<div class="row"><span class="k">val MSE</span><span class="v" id="trVal"></span></div>
<div class="row"><span class="k">held-out PCK@0.10</span><span class="v green" id="trP10"></span></div>
<div class="row"><span class="k">held-out PCK@0.05</span><span class="v" id="trP05"></span></div>
<div class="row"><span class="k">held-out MPJPE</span><span class="v" id="trMpj"></span></div>
<div class="row"><span class="k">mean-pose baseline PCK@0.10</span><span class="v red" id="trBase"></span></div>
<div style="margin-top:8px"><div class="bar"><i id="trBar"></i></div></div>
<div style="margin-top:12px;display:flex;gap:8px;flex-wrap:wrap;align-items:center">
<label class="note" style="margin:0">epochs <input id="trEpochs" type="number" value="200" min="20" max="600" style="width:80px"></label>
<button id="trainBtn" class="btn" disabled>train model</button>
<button id="trStop" class="ghost btn" disabled>stop</button>
</div>
<div id="verdict" class="verdict idle">no model yet — calibrate, capture, then train.</div>
<div class="note">
<b>The bar to beat</b> is the mean-pose baseline (predict the train-mean pose for
everything). A model that doesn't clear it has learned <b>no usable CSI→pose signal</b>
this page says so plainly. Inputs are standardized on the <b>train split only</b>
(after baseline-normalization); the val split is the chronological last 20%, never trained on.
</div>
</div>
<div class="card">
<div class="label">loss curve — train (amber) vs val (blue)</div>
<canvas id="lossCv" width="460" height="300"></canvas>
<div class="note" id="trMsg">Idle.</div>
</div>
</div>
</section>
<!-- ============================ STAGE 3 · INFER ============================ -->
<section id="stage-infer" class="panel">
<div class="cols">
<div class="card">
<div class="label">WiFi-inferred pose <span class="pill csi">CSI ONLY — no camera in the loop</span></div>
<canvas id="infCv" width="420" height="560"></canvas>
<div style="margin-top:10px;display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<label class="note" style="margin:0"><input type="checkbox" id="hideCam"> hide camera (skeleton on black)</label>
<span class="note" id="infModelState" style="margin:0">no model loaded</span>
</div>
</div>
<div class="card stats">
<div class="label">live inference</div>
<div class="row"><span class="k">CSI source</span><span class="v" id="infSrc"></span></div>
<div class="row"><span class="k">CSI nodes</span><span class="v" id="infNodes"></span></div>
<div class="row"><span class="k">presence</span><span class="v" id="infPres"></span></div>
<div class="row"><span class="k">infer fps</span><span class="v" id="infFps"></span></div>
<div class="row"><span class="k">measured held-out PCK@0.10</span><span class="v green" id="infPck"></span></div>
<div class="note">
This skeleton is inferred <b>from WiFi CSI only</b> (baseline-normalized, then through
the model). It is <b>coarse</b> — the held-out PCK above is the real number. It is drawn
over the <b>same</b> laptop-camera frame it trained in, so it <b>aligns</b> with the image.
Same person / room / session — not validated cross-day or through-wall.
</div>
</div>
</div>
</section>
</main>
<!-- TensorFlow.js core + WebGPU/WASM backends (WebGL ships inside core as the final fallback) -->
<script src="https://cdn.jsdelivr.net/npm/@tensorflow/tfjs@4.22.0/dist/tf.min.js" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/@tensorflow/tfjs-backend-webgpu@4.22.0/dist/tf-backend-webgpu.min.js" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/@tensorflow/tfjs-backend-wasm@4.22.0/dist/tf-backend-wasm.min.js" crossorigin="anonymous"></script>
<!-- MediaPipe Pose 0.5 (legacy solutions API — same CDN the 05-skinned-realtime demo uses) -->
<script src="https://cdn.jsdelivr.net/npm/@mediapipe/pose@0.5/pose.js" crossorigin="anonymous"></script>
<script>
"use strict";
// ============================================================================
// Constants & shared state
// ============================================================================
const CSI_WS = (new URLSearchParams(location.search)).get('ws')
|| `ws://${location.hostname || 'localhost'}:8765/ws/sensing`;
const NODE_IDS = [9, 13]; // per-node features in this fixed order (matches Python pipeline)
const FIELD_LEN = 400; // signal_field.values padded/truncated to 400
const CSI_DIM = 4 + NODE_IDS.length * 3 + FIELD_LEN; // 4 + 6 + 400 = 410
const N_KP = 17, OUT_DIM = N_KP * 2; // 17 COCO keypoints -> 34 coords
const BASELINE_SECONDS = 10; // empty-room calibration window
const EPS = 1e-6;
// MediaPipe BlazePose (33) -> 17 COCO keypoints (identical to wiflow_capture.py / ADR-079)
const COCO_FROM_MP = [0, 2, 5, 7, 8, 11, 12, 13, 14, 15, 16, 23, 24, 25, 26, 27, 28];
const EDGES = [[5,7],[7,9],[6,8],[8,10],[5,6],[11,12],[5,11],[6,12],
[11,13],[13,15],[12,14],[14,16],[0,1],[0,2],[1,3],[2,4],[0,5],[0,6]];
const $ = id => document.getElementById(id);
function banner(state, txt){ const b=$('banner'); b.className=state; b.textContent=txt; }
// In-memory dataset of {csi:Float32Array(410, baseline-normalized), kps:Float32Array(34), bucket:int}
let SAMPLES = [];
// Latest live CSI frame + RAW 410-vector (baseline-normalization applied at use sites)
let latestCSI = { t: 0, frame: null, vec: null, source: null, nodes: [] };
// Empty-room baseline: per-feature mean + std (ADR-151)
let baseline = null; // { mean:Float32Array(410), std:Float32Array(410), n:int, ts:number }
// ============================================================================
// TF.js backend selection — WebGPU primary, WASM-SIMD fallback, WebGL last.
// ============================================================================
const BACKEND_LABEL = { webgpu:'WebGPU', wasm:'WASM-SIMD', webgl:'WebGL', cpu:'CPU (slow)' };
let activeBackend = null;
async function selectBackend(){
try{ if (tf.wasm && tf.wasm.setWasmPaths)
tf.wasm.setWasmPaths('https://cdn.jsdelivr.net/npm/@tensorflow/tfjs-backend-wasm@4.22.0/dist/'); }catch(e){}
const tryBackend = async (name)=>{
try{ const ok = await tf.setBackend(name); if (!ok) return false; await tf.ready();
return tf.getBackend() === name; }
catch(e){ console.warn('backend '+name+' unavailable:', e.message); return false; }
};
if (await tryBackend('webgpu')) activeBackend = 'webgpu';
else if (await tryBackend('wasm')) activeBackend = 'wasm';
else if (await tryBackend('webgl')) activeBackend = 'webgl';
else { await tf.ready(); activeBackend = tf.getBackend(); }
const badge = $('compute');
badge.textContent = 'compute: ' + (BACKEND_LABEL[activeBackend] || activeBackend);
badge.title = 'TensorFlow.js backend actually running (WebGPU → WASM-SIMD → WebGL)';
return activeBackend;
}
// ============================================================================
// CSI vector construction — MUST match wiflow_capture.py csi_vector() exactly.
// [mean_rssi, variance, motion_band_power, breathing_band_power] (4 global)
// + for node 9 then node 13: [mean_rssi, variance, motion_band_power] (6 per-node)
// + signal_field.values padded/truncated to 400 (400 field)
// = 410-d (RAW — baseline-normalization applied separately, see baselineNorm)
// ============================================================================
function csiVector(frame){
const f = frame.features || {};
const out = new Float32Array(CSI_DIM);
let o = 0;
out[o++] = +f.mean_rssi || 0;
out[o++] = +f.variance || 0;
out[o++] = +f.motion_band_power || 0;
out[o++] = +f.breathing_band_power || 0;
const perNode = {};
for (const nf of (frame.node_features || [])) perNode[nf.node_id] = (nf.features || {});
for (const nid of NODE_IDS){
const nf = perNode[nid] || {};
out[o++] = +nf.mean_rssi || 0;
out[o++] = +nf.variance || 0;
out[o++] = +nf.motion_band_power || 0;
}
const field = ((frame.signal_field || {}).values) || [];
for (let i = 0; i < FIELD_LEN; i++) out[o++] = +field[i] || 0;
return out;
}
// ADR-151 baseline-deviation normalization: x_norm = (x - base_mean) / (base_std + eps).
// Applied BEFORE the model's own input standardization, consistently everywhere.
function baselineNorm(vecRaw){
if (!baseline) return null;
const out = new Float32Array(CSI_DIM);
for (let j = 0; j < CSI_DIM; j++)
out[j] = (vecRaw[j] - baseline.mean[j]) / (baseline.std[j] + EPS);
return out;
}
// ============================================================================
// CSI WebSocket
// ============================================================================
function connectCSI(){
banner('down','CONNECTING…');
let ws;
try { ws = new WebSocket(CSI_WS); }
catch(e){ banner('down','NO-CSI-SERVER — start sensing-server :8765'); setTimeout(connectCSI, 1500); return; }
ws.onopen = ()=> banner('sim','WAITING FOR CSI…');
ws.onmessage = ev => {
let d; try { d = JSON.parse(ev.data); } catch(e){ return; }
if (!d.features && !d.signal_field) return;
const src = d.source || 'unknown';
latestCSI = {
t: performance.now(),
frame: d,
vec: csiVector(d), // RAW
source: src,
nodes: (d.nodes || []).map(n => n.node_id).filter(x => x != null).sort((a,b)=>a-b)
};
if (src === 'esp32') banner('live','LIVE — real ESP32 CSI');
else banner('sim',`SIMULATED — not real (source=${src})`);
};
ws.onerror = ()=>{ try{ws.close();}catch(e){} };
ws.onclose = ()=>{ banner('down','NO-CSI-SERVER — start sensing-server :8765'); setTimeout(connectCSI, 1500); };
}
function freshLiveCSI(){
return latestCSI.frame && latestCSI.source === 'esp32' && (performance.now() - latestCSI.t) < 400;
}
// ============================================================================
// Camera + MediaPipe Pose
// ============================================================================
let camStream = null;
const camEl = document.createElement('video');
camEl.autoplay = true; camEl.muted = true; camEl.playsInline = true;
let mpPose = null, mpReady = false, mpBusy = false;
let latestKps = null, latestVis = 0;
function initPose(){
if (mpPose || typeof Pose === 'undefined') return;
mpPose = new Pose({ locateFile: f => `https://cdn.jsdelivr.net/npm/@mediapipe/pose@0.5/${f}` });
mpPose.setOptions({ modelComplexity:1, smoothLandmarks:true, enableSegmentation:false,
minDetectionConfidence:0.5, minTrackingConfidence:0.5 });
mpPose.onResults(onPoseResults);
mpReady = true;
}
function onPoseResults(res){
mpBusy = false;
if (!res.poseLandmarks){ latestKps = null; latestVis = 0; return; }
const lm = res.poseLandmarks;
const kps = new Float32Array(OUT_DIM);
let visSum = 0;
for (let i = 0; i < N_KP; i++){
const p = lm[COCO_FROM_MP[i]];
kps[i*2] = p.x; kps[i*2+1] = p.y;
visSum += (p.visibility != null ? p.visibility : 0);
}
latestKps = kps; latestVis = visSum / N_KP;
}
async function startCam(deviceId){
if (camStream) camStream.getTracks().forEach(t => t.stop());
const constraints = deviceId ? { video:{ deviceId:{ exact:deviceId } } } : { video:true };
const st = $('camStatus');
try{
st.textContent = 'camera: requesting…';
camStream = await navigator.mediaDevices.getUserMedia(constraints);
camEl.srcObject = camStream;
await camEl.play().catch(()=>{});
const tr = camStream.getVideoTracks()[0];
const tick = ()=>{ st.textContent =
`camera: "${tr.label}" ${camEl.videoWidth}x${camEl.videoHeight} ${tr.readyState} ${camEl.paused?'PAUSED':'playing'}`; };
tick(); setInterval(tick, 1000);
$('camBtn').textContent = 'switch camera ↻';
$('recBtn').disabled = !baseline; // still gated on a baseline
const devs = (await navigator.mediaDevices.enumerateDevices()).filter(d => d.kind === 'videoinput');
const sel = $('camSel'); sel.style.display = devs.length > 1 ? 'inline-block' : 'none';
sel.innerHTML = devs.map((d,i)=>`<option value="${d.deviceId}">${d.label || ('camera '+(i+1))}</option>`).join('');
const cur = tr.getSettings().deviceId; if (cur) sel.value = cur;
initPose();
}catch(e){
$('camBtn').textContent = 'camera error: ' + e.name +
(e.name === 'NotReadableError' ? ' (in use by Zoom/Teams?)' : '');
console.error('getUserMedia', e);
}
}
$('camBtn').addEventListener('click', ()=> startCam());
$('camSel').addEventListener('change', e => startCam(e.target.value));
// ============================================================================
// Drawing helpers
// ============================================================================
function drawCameraFrame(ctx, W, H, alpha){
if (camEl && camEl.videoWidth > 0){
ctx.save(); ctx.globalAlpha = alpha;
const vr = camEl.videoWidth / camEl.videoHeight, cr = W / H;
let dw=W, dh=H, dx=0, dy=0;
if (vr > cr){ dh=H; dw=H*vr; dx=(W-dw)/2; } else { dw=W; dh=W/vr; dy=(H-dh)/2; }
ctx.drawImage(camEl, dx, dy, dw, dh); ctx.restore();
return true;
}
ctx.fillStyle = '#070a0e'; ctx.fillRect(0,0,W,H);
return false;
}
function drawSkeleton(ctx, kps, W, H, color, glow){
const k = [];
for (let i = 0; i < N_KP; i++) k.push([kps[i*2]*W, kps[i*2+1]*H]);
ctx.lineWidth = 5; ctx.strokeStyle = color; ctx.lineCap = 'round';
ctx.shadowColor = glow; ctx.shadowBlur = 8;
for (const [a,b] of EDGES){ ctx.beginPath(); ctx.moveTo(k[a][0],k[a][1]); ctx.lineTo(k[b][0],k[b][1]); ctx.stroke(); }
ctx.shadowBlur = 0;
for (const [x,y] of k){ ctx.beginPath(); ctx.arc(x,y,5,0,7); ctx.fillStyle = color; ctx.fill(); }
}
// ============================================================================
// Stage navigation + gating
// ============================================================================
const STAGES = ['calibrate','capture','train','infer'];
let stageDone = { calibrate:false, capture:false, train:false };
function stageUnlocked(name){
if (name === 'calibrate') return true;
if (name === 'capture') return stageDone.calibrate;
if (name === 'train') return stageDone.calibrate && SAMPLES.length >= 200;
if (name === 'infer') return !!model;
return false;
}
function gotoStage(name){
if (!stageUnlocked(name)) return;
document.querySelectorAll('.step').forEach(s => s.classList.remove('on'));
document.querySelectorAll('.panel').forEach(p => p.classList.remove('on'));
document.querySelector(`.step[data-stage="${name}"]`).classList.add('on');
$('stage-' + name).classList.add('on');
}
function refreshGates(){
document.querySelectorAll('.step').forEach(s=>{
const name = s.dataset.stage;
s.classList.toggle('locked', !stageUnlocked(name));
s.classList.toggle('done', !!stageDone[name]);
});
$('recBtn').disabled = !(baseline && camStream);
refreshTrainAvail();
}
document.querySelectorAll('.step').forEach(s => s.addEventListener('click', ()=> gotoStage(s.dataset.stage)));
// ============================================================================
// STAGE 0 · CALIBRATE — Welford running mean+std over the 410-d CSI vector
// ============================================================================
const calCtx = $('calCv').getContext('2d');
let calibrating = false;
let cw = null; // welford accumulators { n, mean:Float64Array, m2:Float64Array, t0 }
function startCalibration(){
if (calibrating) return;
cw = { n:0, mean:new Float64Array(CSI_DIM), m2:new Float64Array(CSI_DIM), t0:performance.now() };
calibrating = true;
$('calStatus').textContent = 'CALIBRATING…'; $('calStatus').className = 'v';
$('calBtn').disabled = true;
}
function welfordUpdate(vec){
cw.n++;
for (let j = 0; j < CSI_DIM; j++){
const d = vec[j] - cw.mean[j];
cw.mean[j] += d / cw.n;
cw.m2[j] += d * (vec[j] - cw.mean[j]);
}
}
function finishCalibration(){
calibrating = false;
const mean = new Float32Array(CSI_DIM), std = new Float32Array(CSI_DIM);
for (let j = 0; j < CSI_DIM; j++){
mean[j] = cw.mean[j];
std[j] = cw.n > 1 ? Math.sqrt(cw.m2[j] / (cw.n - 1)) : 0;
}
baseline = { mean, std, n: cw.n, ts: Date.now() };
stageDone.calibrate = true;
$('calStatus').textContent = 'CALIBRATED'; $('calStatus').className = 'v green';
$('calN').textContent = cw.n; $('calBtn').disabled = false;
$('calBar').style.width = '100%';
saveBaseline();
refreshGates();
}
$('calBtn').addEventListener('click', startCalibration);
$('recalBtn').addEventListener('click', ()=>{ baseline = null; stageDone.calibrate = false;
$('calStatus').textContent = 'NOT CALIBRATED'; $('calStatus').className = 'v';
$('calBar').style.width = '0%'; $('calN').textContent = '0'; idbDel('baseline'); refreshGates(); startCalibration(); });
function calibrateLoop(){
const W = $('calCv').width, H = $('calCv').height;
calCtx.fillStyle = '#070a0e'; calCtx.fillRect(0,0,W,H);
// little live trace of motion_band_power to show the channel is quiescent
$('calSrc').textContent = latestCSI.source || '—';
$('calSrc').className = latestCSI.source === 'esp32' ? 'v green' : 'v';
if (baseline){
const ageS = Math.round((Date.now() - baseline.ts)/1000);
$('calAge').textContent = ageS < 60 ? ageS+' s ago' : Math.round(ageS/60)+' min ago';
}
if (calibrating){
const el = (performance.now() - cw.t0) / 1000;
$('calBar').style.width = Math.min(100, 100*el/BASELINE_SECONDS) + '%';
// accumulate only fresh live frames; ignore sim so the baseline is real
if (freshLiveCSI() && latestCSI.vec){ welfordUpdate(latestCSI.vec); $('calN').textContent = cw.n; }
// draw a centered "STEP OUT" reminder + countdown
calCtx.fillStyle = '#ffb840'; calCtx.font = 'bold 22px monospace'; calCtx.textAlign='center';
calCtx.fillText('STEP OUT OF THE SPACE', W/2, H/2-10);
calCtx.fillStyle = '#7d8796'; calCtx.font = '14px monospace';
calCtx.fillText('baseline: '+Math.max(0,Math.ceil(BASELINE_SECONDS-el))+' s · '+cw.n+' frames', W/2, H/2+18);
calCtx.textAlign='start';
if (el >= BASELINE_SECONDS && cw.n > 0) finishCalibration();
else if (el >= BASELINE_SECONDS*2){ // safety: timed out with no live frames
calibrating = false; $('calStatus').textContent = 'NO LIVE CSI — check esp32'; $('calStatus').className='v red';
$('calBtn').disabled = false;
}
} else {
calCtx.fillStyle = baseline ? '#46e08a' : '#7d8796'; calCtx.font = '14px monospace'; calCtx.textAlign='center';
calCtx.fillText(baseline ? 'baseline ready ('+baseline.n+' frames)' : 'click “calibrate baseline”', W/2, H/2);
calCtx.textAlign='start';
}
requestAnimationFrame(calibrateLoop);
}
// ============================================================================
// STAGE 1 · GUIDED CAPTURE — balanced buckets + coverage meter
// ============================================================================
const capCtx = $('capCv').getContext('2d');
let recording = false;
// pose buckets (the guided routine cycles through these)
const BUCKETS = ['stand still','turn left','turn right','walk left','walk right',
'arms up','arms down','crouch','sit','reach'];
const SECS_PER_BUCKET = 12;
let bucketIx = 0, bucketT0 = performance.now();
let covCounts = new Array(BUCKETS.length).fill(0);
function renderCoverage(){
const max = Math.max(1, ...covCounts);
$('cov').innerHTML = BUCKETS.map((b,i)=>
`<div class="covrow"><span class="nm">${b}</span>`+
`<span class="bar"><i style="width:${Math.round(100*covCounts[i]/max)}%"></i></span>`+
`<span class="ct">${covCounts[i]}</span></div>`).join('');
}
$('recBtn').addEventListener('click', ()=>{
if (!baseline || !camStream) return;
recording = !recording;
$('recBtn').textContent = recording ? '◼ stop recording' : '● start guided recording';
$('recBtn').classList.toggle('ghost', recording);
if (recording){ bucketT0 = performance.now(); }
});
$('clrBtn').addEventListener('click', async ()=>{
SAMPLES = []; covCounts = new Array(BUCKETS.length).fill(0);
await idbPut('samples', []);
$('capN').textContent = '0'; $('trN').textContent = '0'; renderCoverage(); refreshGates();
});
function captureLoop(){
const W = $('capCv').width, H = $('capCv').height;
drawCameraFrame(capCtx, W, H, 0.9);
if (mpReady && !mpBusy && camEl.videoWidth > 0){
mpBusy = true; mpPose.send({ image: camEl }).catch(()=>{ mpBusy = false; });
}
if (latestKps) drawSkeleton(capCtx, latestKps, W, H, 'rgba(90,169,255,.95)', 'rgba(90,169,255,.6)');
$('capSrc').textContent = latestCSI.source || '—';
$('capSrc').className = latestCSI.source === 'esp32' ? 'v green' : 'v';
$('capNodes').textContent = latestCSI.nodes.length ? latestCSI.nodes.join(', ') : '—';
$('capVis').textContent = latestKps ? latestVis.toFixed(2) : '—';
if (recording){
// advance the guided bucket
const el = (performance.now() - bucketT0)/1000;
if (el >= SECS_PER_BUCKET){ bucketIx = (bucketIx+1) % BUCKETS.length; bucketT0 = performance.now(); }
$('prompt').textContent = BUCKETS[bucketIx];
$('countdown').textContent = `${Math.max(0,Math.ceil(SECS_PER_BUCKET - el))} s · bucket ${bucketIx+1}/${BUCKETS.length}`;
let skip = null;
if (!latestKps || latestVis <= 0.5) skip = 'no confident pose';
else if (!freshLiveCSI()) skip = (latestCSI.source && latestCSI.source!=='esp32') ? 'CSI not esp32 (sim)' : 'no fresh CSI';
else if (!baseline) skip = 'no baseline';
if (skip){ $('capSkip').textContent = skip; }
else {
const norm = baselineNorm(latestCSI.vec); // baseline-deviation normalized
SAMPLES.push({ csi: norm, kps: latestKps.slice(), bucket: bucketIx });
covCounts[bucketIx]++;
$('capSkip').textContent = '—';
const n = SAMPLES.length; $('capN').textContent = n; $('trN').textContent = n;
if (n % 20 === 0){ renderCoverage(); idbSave(); refreshGates(); }
}
} else {
$('prompt').textContent = baseline ? 'ready — press start' : 'calibrate baseline first';
$('countdown').textContent = '—';
}
requestAnimationFrame(captureLoop);
}
// ============================================================================
// IndexedDB persistence
// ============================================================================
const IDB_NAME = 'wiflow-browser', IDB_STORE = 'kv';
function idbOpen(){
return new Promise((res, rej)=>{
const r = indexedDB.open(IDB_NAME, 1);
r.onupgradeneeded = ()=> r.result.createObjectStore(IDB_STORE);
r.onsuccess = ()=> res(r.result); r.onerror = ()=> rej(r.error);
});
}
async function idbPut(key, val){
const db = await idbOpen();
return new Promise((res, rej)=>{
const tx = db.transaction(IDB_STORE, 'readwrite');
tx.objectStore(IDB_STORE).put(val, key); tx.oncomplete = res; tx.onerror = ()=> rej(tx.error);
});
}
async function idbGet(key){
const db = await idbOpen();
return new Promise((res, rej)=>{
const tx = db.transaction(IDB_STORE, 'readonly');
const r = tx.objectStore(IDB_STORE).get(key);
r.onsuccess = ()=> res(r.result); r.onerror = ()=> rej(r.error);
});
}
async function idbDel(key){
const db = await idbOpen();
return new Promise((res, rej)=>{
const tx = db.transaction(IDB_STORE, 'readwrite');
tx.objectStore(IDB_STORE).delete(key); tx.oncomplete = res; tx.onerror = ()=> rej(tx.error);
});
}
async function idbSave(){
try{
const flat = SAMPLES.map(s => ({ csi: Array.from(s.csi), kps: Array.from(s.kps), bucket: s.bucket }));
await idbPut('samples', flat);
}catch(e){ console.warn('idbSave', e); }
}
async function idbLoad(){
try{
const flat = await idbGet('samples');
if (Array.isArray(flat) && flat.length){
SAMPLES = flat.map(s => ({ csi: Float32Array.from(s.csi), kps: Float32Array.from(s.kps), bucket: s.bucket||0 }));
covCounts = new Array(BUCKETS.length).fill(0);
for (const s of SAMPLES) if (s.bucket < BUCKETS.length) covCounts[s.bucket]++;
$('capN').textContent = SAMPLES.length; $('trN').textContent = SAMPLES.length;
}
}catch(e){ console.warn('idbLoad', e); }
}
async function saveBaseline(){
try{ await idbPut('baseline', { mean: Array.from(baseline.mean), std: Array.from(baseline.std), n: baseline.n, ts: baseline.ts }); }
catch(e){ console.warn('saveBaseline', e); }
}
async function loadBaseline(){
try{
const b = await idbGet('baseline');
if (b && b.mean){
baseline = { mean: Float32Array.from(b.mean), std: Float32Array.from(b.std), n: b.n, ts: b.ts };
stageDone.calibrate = true;
$('calStatus').textContent = 'CALIBRATED (restored)'; $('calStatus').className = 'v green';
$('calN').textContent = baseline.n; $('calBar').style.width = '100%';
}
}catch(e){ /* none yet */ }
}
// ============================================================================
// STAGE 2 · TRAIN (TensorFlow.js)
// ============================================================================
let model = null, normMu = null, normSd = null, trainedPck10 = null, trainStop = false;
const lossCtx = $('lossCv').getContext('2d');
let lossHist = [];
function refreshTrainAvail(){
const ok = !!baseline && SAMPLES.length >= 200;
$('trainBtn').disabled = !ok;
if (!baseline) $('trMsg').innerHTML = 'Calibrate a baseline first (stage 0).';
else $('trMsg').innerHTML = SAMPLES.length >= 200
? `Ready: ${SAMPLES.length} samples. Click <b>train model</b>.`
: `Need ≥200 samples to train (have ${SAMPLES.length}). Capture more in stage 1.`;
}
function buildMatrices(){
const n = SAMPLES.length;
const X = new Float32Array(n * CSI_DIM), Y = new Float32Array(n * OUT_DIM);
for (let i = 0; i < n; i++){ X.set(SAMPLES[i].csi, i*CSI_DIM); Y.set(SAMPLES[i].kps, i*OUT_DIM); }
return { X, Y, n };
}
function pckMpjpe(predArr, gtArr, m, thr){
let hit = 0, tot = 0, dsum = 0;
for (let i = 0; i < m; i++) for (let j = 0; j < N_KP; j++){
const dx = predArr[i*OUT_DIM+j*2]-gtArr[i*OUT_DIM+j*2];
const dy = predArr[i*OUT_DIM+j*2+1]-gtArr[i*OUT_DIM+j*2+1];
const d = Math.hypot(dx, dy);
if (d < thr) hit++; dsum += d; tot++;
}
return { pck: tot?hit/tot:0, mpjpe: tot?dsum/tot:NaN };
}
function drawLoss(){
const W = $('lossCv').width, H = $('lossCv').height;
lossCtx.fillStyle = '#070a0e'; lossCtx.fillRect(0,0,W,H);
if (lossHist.length < 2) return;
let mx = 0; for (const p of lossHist) mx = Math.max(mx, p.tr, p.va||0); mx = mx||1;
const X = i => 8 + (W-16)*i/(lossHist.length-1);
const Yv = v => H-8 - (H-16)*Math.min(v/mx,1);
const line = (key,color)=>{
lossCtx.strokeStyle=color; lossCtx.lineWidth=2; lossCtx.beginPath(); let st=false;
lossHist.forEach((p,i)=>{ const v=p[key]; if(v==null) return;
const x=X(i), y=Yv(v); st?lossCtx.lineTo(x,y):lossCtx.moveTo(x,y); st=true; });
lossCtx.stroke();
};
line('tr','#ffb840'); line('va','#5aa9ff');
}
async function trainModel(){
if (!baseline || SAMPLES.length < 200) return;
trainStop = false;
$('trainBtn').disabled = true; $('trStop').disabled = false; lossHist = [];
const epochs = Math.max(20, Math.min(600, parseInt($('trEpochs').value)||200));
const { X, Y, n } = buildMatrices(); // X is already baseline-normalized
const cut = Math.floor(n*0.8);
$('trSplit').textContent = `${cut} / ${n-cut} (chronological 80/20)`;
// input standardization on TRAIN split only (on top of baseline-normalization)
normMu = new Float32Array(CSI_DIM); normSd = new Float32Array(CSI_DIM);
for (let j = 0; j < CSI_DIM; j++){
let s=0; for (let i=0;i<cut;i++) s += X[i*CSI_DIM+j];
const mu=s/cut; normMu[j]=mu;
let v=0; for (let i=0;i<cut;i++){ const d=X[i*CSI_DIM+j]-mu; v+=d*d; }
normSd[j]=Math.sqrt(v/cut)+EPS;
}
const Xn = new Float32Array(n*CSI_DIM);
for (let i=0;i<n;i++) for (let j=0;j<CSI_DIM;j++) Xn[i*CSI_DIM+j]=(X[i*CSI_DIM+j]-normMu[j])/normSd[j];
// mean-pose baseline — the bar to beat
const meanPose = new Float32Array(OUT_DIM);
for (let i=0;i<cut;i++) for (let j=0;j<OUT_DIM;j++) meanPose[j]+=Y[i*OUT_DIM+j];
for (let j=0;j<OUT_DIM;j++) meanPose[j]/=cut;
const mVal = n-cut;
const basePred = new Float32Array(mVal*OUT_DIM);
for (let i=0;i<mVal;i++) basePred.set(meanPose, i*OUT_DIM);
const gtVal = Y.slice(cut*OUT_DIM);
const base = pckMpjpe(basePred, gtVal, mVal, 0.10);
$('trBase').textContent = (base.pck*100).toFixed(1)+'%';
const xtr = tf.tensor2d(Xn.slice(0,cut*CSI_DIM),[cut,CSI_DIM]);
const ytr = tf.tensor2d(Y.slice(0,cut*OUT_DIM),[cut,OUT_DIM]);
const xva = tf.tensor2d(Xn.slice(cut*CSI_DIM),[mVal,CSI_DIM]);
if (model){ model.dispose(); }
model = tf.sequential();
model.add(tf.layers.dense({ inputShape:[CSI_DIM], units:512, activation:'relu' }));
model.add(tf.layers.dropout({ rate:0.3 }));
model.add(tf.layers.dense({ units:256, activation:'relu' }));
model.add(tf.layers.dropout({ rate:0.3 }));
model.add(tf.layers.dense({ units:128, activation:'relu' }));
model.add(tf.layers.dense({ units:OUT_DIM, activation:'sigmoid' }));
model.compile({ optimizer: tf.train.adam(1e-3), loss:'meanSquaredError' });
let bestP10 = 0, bestVal = 1e9;
$('trMsg').innerHTML = 'Training… on <code>'+(BACKEND_LABEL[tf.getBackend()]||tf.getBackend())+'</code>';
await model.fit(xtr, ytr, {
epochs, batchSize:64, shuffle:true, verbose:0,
callbacks:{ onEpochEnd: async (ep, logs)=>{
let va=null,p10=null,p05=null,mpj=null;
if (ep % 5 === 0 || ep === epochs-1){
const pv = model.predict(xva); const pvArr = await pv.data(); pv.dispose();
let vsum=0; for (let i=0;i<pvArr.length;i++){ const d=pvArr[i]-gtVal[i]; vsum+=d*d; }
va = vsum/pvArr.length;
const r10=pckMpjpe(pvArr,gtVal,mVal,0.10), r05=pckMpjpe(pvArr,gtVal,mVal,0.05);
p10=r10.pck; p05=r05.pck; mpj=r10.mpjpe;
$('trP10').textContent=(p10*100).toFixed(1)+'%'; $('trP05').textContent=(p05*100).toFixed(1)+'%';
$('trMpj').textContent=mpj.toFixed(4); $('trVal').textContent=va.toFixed(4);
if (va<bestVal){ bestVal=va; bestP10=p10; }
}
$('trEpoch').textContent=(ep+1); $('trLoss').textContent=logs.loss.toFixed(4);
$('trBar').style.width=(100*(ep+1)/epochs)+'%';
lossHist.push({ ep, tr:logs.loss, va }); drawLoss();
if (trainStop) model.stopTraining = true;
await tf.nextFrame();
}}
});
const pvF = model.predict(xva); const pvFArr = await pvF.data(); pvF.dispose();
const fin10 = pckMpjpe(pvFArr,gtVal,mVal,0.10), fin05 = pckMpjpe(pvFArr,gtVal,mVal,0.05);
const finPck = Math.max(bestP10, fin10.pck); trainedPck10 = finPck;
$('trP10').textContent=(fin10.pck*100).toFixed(1)+'%'; $('trP05').textContent=(fin05.pck*100).toFixed(1)+'%';
$('trMpj').textContent=fin10.mpjpe.toFixed(4); $('infPck').textContent=(finPck*100).toFixed(1)+'%';
const delta = (finPck - base.pck)*100;
const v = $('verdict');
if (delta > 1){
v.className='verdict good';
v.innerHTML = `model <b>BEATS</b> mean-pose baseline by <b>+${delta.toFixed(1)} pp</b> → real CSI→pose signal.`;
} else {
v.className='verdict bad';
v.innerHTML = `model does <b>NOT</b> beat baseline (Δ ${delta.toFixed(1)} pp) → <b>no usable signal (honest)</b>. Capture more / more varied data.`;
}
stageDone.train = true;
$('infModelState').textContent = `model ready · held-out PCK@0.10 ${(finPck*100).toFixed(1)}%`;
$('trMsg').innerHTML = 'Done. Saving model to IndexedDB…';
xtr.dispose(); ytr.dispose(); xva.dispose();
await saveModel();
$('trMsg').innerHTML = 'Saved. Go to <b>3 · INFER</b> to see WiFi drive the skeleton.';
$('trainBtn').disabled = false; $('trStop').disabled = true;
refreshGates();
}
$('trainBtn').addEventListener('click', trainModel);
$('trStop').addEventListener('click', ()=>{ trainStop = true; });
async function saveModel(){
if (!model) return;
try{
await model.save('indexeddb://wiflow-model');
await idbPut('norm', { mu:Array.from(normMu), sd:Array.from(normSd), pck10:trainedPck10 });
}catch(e){ console.warn('saveModel', e); }
}
async function loadModel(){
try{
const m = await tf.loadLayersModel('indexeddb://wiflow-model');
const norm = await idbGet('norm');
if (m && norm){
model = m; normMu = Float32Array.from(norm.mu); normSd = Float32Array.from(norm.sd);
trainedPck10 = norm.pck10; stageDone.train = true;
$('infPck').textContent = trainedPck10!=null ? (trainedPck10*100).toFixed(1)+'%' : '—';
$('infModelState').textContent = `model loaded · held-out PCK@0.10 ${trainedPck10!=null?(trainedPck10*100).toFixed(1)+'%':'?'}`;
}
}catch(e){ /* none yet */ }
}
// ============================================================================
// STAGE 3 · INFER — live CSI → baseline-normalize → standardize → model
// ============================================================================
const infCtx = $('infCv').getContext('2d');
let infSm = null, infFrames = 0, infT0 = performance.now();
function inferSmooth(kps){
if (!infSm){ infSm = Float32Array.from(kps); return infSm; }
const a = 0.35; for (let i=0;i<kps.length;i++) infSm[i]+=a*(kps[i]-infSm[i]);
return infSm;
}
function inferLoop(){
const W = $('infCv').width, H = $('infCv').height;
const showCam = !$('hideCam').checked;
if (showCam) drawCameraFrame(infCtx, W, H, 0.85);
else { infCtx.fillStyle='#070a0e'; infCtx.fillRect(0,0,W,H); }
$('infSrc').textContent = latestCSI.source || '—';
$('infSrc').className = latestCSI.source === 'esp32' ? 'v green' : 'v';
$('infNodes').textContent = latestCSI.nodes.length ? latestCSI.nodes.join(', ') : '—';
const cls = (latestCSI.frame && latestCSI.frame.classification) || {};
$('infPres').textContent = cls.presence ? 'PRESENT' : '—';
if (model && normMu && baseline && latestCSI.vec){
const out = tf.tidy(()=>{
const xn = new Float32Array(CSI_DIM);
for (let j=0;j<CSI_DIM;j++){
const bn = (latestCSI.vec[j]-baseline.mean[j])/(baseline.std[j]+EPS); // baseline-normalize
xn[j] = (bn - normMu[j])/normSd[j]; // then standardize
}
return model.predict(tf.tensor2d(xn,[1,CSI_DIM]));
});
out.data().then(arr=>{
const sm = inferSmooth(arr); const present = !!cls.presence;
drawSkeleton(infCtx, sm, W, H,
present?'rgba(70,224,138,.95)':'rgba(125,135,150,.85)','rgba(70,224,138,.6)');
out.dispose();
}).catch(()=> out.dispose());
infFrames++;
} else {
infCtx.fillStyle='#7d8796'; infCtx.font='13px monospace';
infCtx.fillText(model?'waiting for CSI…':'train a model first (stage 2)', 20, 30);
}
const now = performance.now();
if (now-infT0 > 1000){ $('infFps').textContent = infFrames; infFrames = 0; infT0 = now; }
requestAnimationFrame(inferLoop);
}
// ============================================================================
// Boot
// ============================================================================
(async function boot(){
connectCSI();
await selectBackend();
await loadBaseline();
await idbLoad();
await loadModel();
renderCoverage();
refreshGates();
requestAnimationFrame(calibrateLoop);
requestAnimationFrame(captureLoop);
requestAnimationFrame(inferLoop);
})();
</script>
</body>
</html>