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

1273 lines
66 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="detBtn" class="btn">① detect ESP32 sensors</button>
<span id="detNodes" class="v">not detected</span>
</div>
<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>
<label class="note" style="margin:0">get-ready countdown
<input id="calReady" type="number" value="5" min="3" max="15" style="width:64px"> s</label>
</div>
<div style="margin-top:8px;display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<button id="calCamBtn" class="ghost btn">enable camera (to confirm room empty)</button>
<select id="calCamSel" style="display:none"></select>
</div>
<div id="calCamStatus" class="note" style="margin-top:6px">camera: off</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:6px"><div class="bar"><i id="capProg"></i></div></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">pass / activity</span><span class="v" id="capPass">— / —</span></div>
<div class="row"><span class="k">this activity samples</span><span class="v" id="capActN">0</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">est. time / samples</span><span class="v" id="capEst"></span></div>
<div class="row"><span class="k">last skip reason</span><span class="v" id="capSkip"></span></div>
<div style="margin-top:10px;display:flex;gap:10px;align-items:center;flex-wrap:wrap">
<label class="note" style="margin:0">passes
<input id="capPasses" type="number" value="3" min="1" max="8" style="width:56px"></label>
<label class="note" style="margin:0">sec / activity
<input id="capSecs" type="number" value="12" min="4" max="40" style="width:56px"></label>
<label class="note" style="margin:0">get-ready
<input id="capReady" type="number" value="3" min="0" max="10" style="width:56px"> s</label>
</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="pauseBtn" class="ghost btn" disabled>pause</button>
<button id="skipBtn" class="ghost btn" disabled>skip activity ⏭</button>
<button id="clrBtn" class="ghost btn">clear dataset</button>
</div>
<div style="margin-top:10px;display:flex;gap:8px;flex-wrap:wrap">
<button id="expBtn" class="ghost btn">export dataset (JSON)</button>
<button id="impBtn" class="ghost btn">import dataset</button>
<input id="impFile" type="file" accept="application/json,.json" style="display:none">
</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. The routine runs <b>multiple interleaved passes</b>
through every activity, so a chronological held-out split contains the same activity mix
as training (fixes the OOD split). Follow the prompt so every pose bucket fills up.
</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:74px"></label>
<label class="note" style="margin:0">patience <input id="trPatience" type="number" value="30" min="5" max="200" style="width:64px"></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>
<button id="infCamBtn" class="ghost btn">enable camera</button>
<select id="infCamSel" style="display:none"></select>
</div>
<div id="infCamStatus" class="note" style="margin-top:6px">camera: off</div>
<div style="margin-top:6px"><span class="note" id="infModelState">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
// ============================================================================
// wss when served over https (mobile/secure-context safe), else ws; ?ws= overrides
const CSI_WS = (new URLSearchParams(location.search)).get('ws')
|| `${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.hostname || 'localhost'}:8765/ws/sensing`;
// Per-node feature schema — AUTO-DETECTED from the live stream (see detectSensors).
// [9,13] is only the fallback until detection runs. ORDER is fixed (sorted ascending)
// so the model's input layout is stable across capture / train / infer.
let NODE_IDS = [9, 13];
const FIELD_LEN = 400; // signal_field.values padded/truncated to 400
let CSI_DIM = 4 + NODE_IDS.length * 3 + FIELD_LEN; // 4 global + 3/node + 400 field
function recomputeCsiDim(){ CSI_DIM = 4 + NODE_IDS.length * 3 + FIELD_LEN; }
let sensorsDetected = false; // true once a detect (auto/manual/restored) has locked the node set
let autoDetectStarted = false; // one-shot guard for the auto-detect on first live frame
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 each node in NODE_IDS order: [mean_rssi, variance, motion_band_power] (3 per-node)
// + signal_field.values padded/truncated to 400 (400 field)
// = CSI_DIM-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;
}
// ============================================================================
// ESP32 sensor auto-detection
// Sniff the live /ws/sensing stream, find which node_ids are actually present
// and healthy, and lock that ordered set as the per-node schema (NODE_IDS/CSI_DIM).
// The node set defines the model's input dimension, so detection must run BEFORE
// calibration + capture; changing it invalidates a baseline/dataset built on a
// different set (we confirm, then reset, on a manual re-detect).
// ============================================================================
async function detectSensors(ms = 3000){
const tally = {}; // node_id -> { seen, fps, rssi }
let frames = 0;
const t0 = performance.now();
const el = $('detNodes'); if (el){ el.textContent = 'scanning…'; el.className = 'v'; }
while (performance.now() - t0 < ms){
if (latestCSI.frame && latestCSI.source === 'esp32'){
frames++;
for (const nf of (latestCSI.frame.node_features || [])){
const id = nf.node_id; if (id == null) continue;
const f = nf.features || {};
const t = (tally[id] || (tally[id] = { seen:0, fps:0, rssi:0 }));
t.seen++; t.fps += (+nf.frame_rate_hz || 0);
t.rssi += (+f.mean_rssi || +nf.rssi_dbm || 0);
}
}
await new Promise(r => setTimeout(r, 100));
}
// healthy = seen in >40% of sampled frames (filters transient / duplicate ids)
const healthy = Object.keys(tally).map(k => ({
id:+k, seen:tally[k].seen, fps:tally[k].fps/tally[k].seen, rssi:tally[k].rssi/tally[k].seen }))
.filter(n => n.seen >= Math.max(2, frames * 0.4))
.sort((a,b)=> a.id - b.id);
return { healthy, frames };
}
function renderDetectedSensors(list){
const el = $('detNodes'); if (!el) return;
el.textContent = list.length
? list.map(n => `#${n.id} (${Math.round(n.fps)}fps, ${Math.round(n.rssi)}dB)`).join(' · ')
: 'none found';
el.className = list.length ? 'v green' : 'v red';
}
async function runDetect(manual){
const { healthy, frames } = await detectSensors(manual ? 4000 : 3000);
if (!healthy.length){
const el = $('detNodes');
if (el){ el.textContent = frames ? 'no healthy nodes' : 'no live CSI (start sensing-server / esp32)';
el.className = 'v red'; }
return;
}
const ids = healthy.map(n => n.id);
const changed = ids.length !== NODE_IDS.length || ids.some((v,i)=> v !== NODE_IDS[i]);
if (changed && (baseline || SAMPLES.length)){
const ok = confirm(
`Detected sensors [${ids.join(', ')}] differ from the current set [${NODE_IDS.join(', ')}].\n\n` +
`The node set defines the model input, so switching invalidates the existing baseline` +
(SAMPLES.length ? ` and ${SAMPLES.length} captured samples` : ``) +
`. Reset and use the detected set?`);
if (!ok){ renderDetectedSensors(healthy); return; }
if (baseline){ baseline = null; stageDone.calibrate = false; idbDel('baseline');
$('calStatus').textContent = 'NOT CALIBRATED'; $('calStatus').className = 'v'; $('calBar').style.width = '0%'; }
if (SAMPLES.length){ SAMPLES = []; covCounts = new Array(BUCKETS.length).fill(0);
idbPut('samples', []); $('capN').textContent = '0'; $('trN').textContent = '0'; renderCoverage(); }
}
NODE_IDS = ids; recomputeCsiDim(); sensorsDetected = true;
idbPut('nodeIds', NODE_IDS);
renderDetectedSensors(healthy);
refreshGates();
}
async function restoreNodeIds(){
try{
const ids = await idbGet('nodeIds');
if (Array.isArray(ids) && ids.length){
NODE_IDS = ids.slice(); recomputeCsiDim(); sensorsDetected = true;
const el = $('detNodes');
if (el){ el.textContent = 'restored: ' + NODE_IDS.map(i => '#' + i).join(' '); el.className = 'v'; }
}
}catch(e){ /* ignore */ }
}
// ============================================================================
// 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)
};
// auto-detect the sensor set once, on the first live frame, only when starting fresh
// (no baseline / no samples) so we never silently change a schema work is built on.
if (src === 'esp32' && !sensorsDetected && !autoDetectStarted && !baseline && SAMPLES.length === 0){
autoDetectStarted = true; runDetect(false);
}
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;
}
// ONE camera shared across all stages. Each stage has its own <select> + status line,
// but they all drive the same hidden <video> (camEl) that captureLoop/inferLoop drawImage from.
// Selected deviceId is persisted in localStorage and restored on load.
const CAM_KEY = 'wiflow-cam-device';
let camStatusTimer = null;
const CAM_SELECTORS = ['camSel','calCamSel','infCamSel']; // ids of every stage's camera <select>
const CAM_STATUSES = ['camStatus','calCamStatus','infCamStatus'];
const CAM_BTNS = ['camBtn','calCamBtn','infCamBtn'];
function setAllCamStatus(txt){
for (const id of CAM_STATUSES){ const el = $(id); if (el) el.textContent = txt; }
}
function populateCamSelectors(devs, currentId){
const opts = devs.map((d,i)=>`<option value="${d.deviceId}">${d.label || ('camera '+(i+1))}</option>`).join('');
for (const id of CAM_SELECTORS){
const sel = $(id); if (!sel) continue;
sel.style.display = devs.length > 1 ? 'inline-block' : 'none';
sel.innerHTML = opts;
if (currentId) sel.value = currentId;
}
}
async function startCam(deviceId){
// remember an explicit choice (so it survives reloads + applies to every stage)
if (deviceId){ try{ localStorage.setItem(CAM_KEY, deviceId); }catch(e){} }
if (camStream) camStream.getTracks().forEach(t => t.stop());
const constraints = deviceId ? { video:{ deviceId:{ exact:deviceId } } } : { video:true };
try{
setAllCamStatus('camera: requesting…');
camStream = await navigator.mediaDevices.getUserMedia(constraints);
camEl.srcObject = camStream;
await camEl.play().catch(()=>{});
const tr = camStream.getVideoTracks()[0];
const tick = ()=>{ setAllCamStatus(
`camera: "${tr.label || '?'}" ${camEl.videoWidth}x${camEl.videoHeight} ${tr.readyState} ${camEl.paused?'PAUSED':'playing'}`); };
tick();
if (camStatusTimer) clearInterval(camStatusTimer);
camStatusTimer = setInterval(tick, 1000);
for (const id of CAM_BTNS){ const b = $(id); if (b) b.textContent = 'switch camera ↻'; }
$('recBtn').disabled = !baseline; // still gated on a baseline
// labels are only populated AFTER permission is granted — enumerate now
const devs = (await navigator.mediaDevices.enumerateDevices()).filter(d => d.kind === 'videoinput');
const cur = (tr.getSettings && tr.getSettings().deviceId) || deviceId || null;
if (cur){ try{ localStorage.setItem(CAM_KEY, cur); }catch(e){} }
populateCamSelectors(devs, cur);
initPose();
refreshGates();
}catch(e){
// NotReadableError = camera held by Zoom/Teams/IR cam; let the user pick another in any stage
const msg = 'camera error: ' + e.name +
(e.name === 'NotReadableError' ? ' (in use by Zoom/Teams? pick another)' : '');
for (const id of CAM_BTNS){ const b = $(id); if (b) b.textContent = msg; }
setAllCamStatus(msg);
console.error('getUserMedia', e);
// still try to list devices so the user can switch (labels may be blank without a live stream)
try{
const devs = (await navigator.mediaDevices.enumerateDevices()).filter(d => d.kind === 'videoinput');
populateCamSelectors(devs, null);
}catch(_){}
}
}
// every stage's enable button + selector drives the same camera
for (const id of CAM_BTNS){ const b = $(id); if (b) b.addEventListener('click', ()=> startCam(localStorage.getItem(CAM_KEY) || undefined)); }
for (const id of CAM_SELECTORS){ const s = $(id); if (s) s.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 calReadyUntil = 0; // wall-clock (performance.now) when the get-ready countdown ends; 0 = not in get-ready
let cw = null; // welford accumulators { n, mean:Float64Array, m2:Float64Array, t0 }
function calReadySecs(){ return Math.max(0, Math.min(15, parseInt($('calReady').value) || 5)); }
// Click → "STEP OUT" + a get-ready countdown so the user can leave the field,
// THEN the actual baseline recording begins (cw allocated at that moment).
function startCalibration(){
if (calibrating || calReadyUntil) return;
$('calBtn').disabled = true;
const ready = calReadySecs();
$('calStatus').textContent = 'GET READY — STEP OUT…'; $('calStatus').className = 'v';
if (ready <= 0){ beginBaselineRecording(); return; }
calReadyUntil = performance.now() + ready * 1000;
}
function beginBaselineRecording(){
calReadyUntil = 0;
cw = { n:0, mean:new Float64Array(CSI_DIM), m2:new Float64Array(CSI_DIM), t0:performance.now() };
calibrating = true;
$('calStatus').textContent = 'CALIBRATING…'; $('calStatus').className = 'v';
}
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);
$('detBtn').addEventListener('click', ()=> runDetect(true));
$('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 (calReadyUntil){
// GET-READY phase: big "STEP OUT" prompt + countdown BEFORE recording starts
const remain = Math.max(0, (calReadyUntil - performance.now())/1000);
$('calBar').style.width = '0%';
calCtx.fillStyle = '#ffb840'; calCtx.font = 'bold 24px monospace'; calCtx.textAlign='center';
calCtx.fillText('STEP OUT OF THE ROOM', W/2, H/2-16);
calCtx.fillStyle = '#46e08a'; calCtx.font = 'bold 40px monospace';
calCtx.fillText(Math.ceil(remain)+'', W/2, H/2+26);
calCtx.fillStyle = '#7d8796'; calCtx.font = '13px monospace';
calCtx.fillText('baseline recording starts after the countdown', W/2, H/2+54);
calCtx.textAlign='start';
if (remain <= 0) beginBaselineRecording();
} else 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 + progress
calCtx.fillStyle = '#ffb840'; calCtx.font = 'bold 22px monospace'; calCtx.textAlign='center';
calCtx.fillText('STAY OUT — CAPTURING BASELINE', 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, paused = false;
// pose buckets — each activity is its own labeled bucket
const BUCKETS = ['stand still','turn','walk left','walk right',
'arms up','arms down','crouch','sit','reach'];
// guided routine state. The routine runs `passes` full INTERLEAVED passes through the
// activity list: pass1[all activities] → pass2[all activities] → … This spreads every
// activity across the WHOLE time-ordered session, so the chronological 80/20 held-out split
// contains the same activity mix as training (the fix for the -6pp OOD held-out).
let passIx = 0, actIx = 0; // current pass index, current activity index within the pass
let segReadyUntil = 0; // get-ready countdown end for the current segment (0 = recording)
let segT0 = performance.now(); // start of the recording portion of the current segment
let actSampleN = 0; // samples recorded in the current activity segment
let covCounts = new Array(BUCKETS.length).fill(0);
function capPasses(){ return Math.max(1, Math.min(8, parseInt($('capPasses').value) || 3)); }
function capSecs(){ return Math.max(4, Math.min(40, parseInt($('capSecs').value) || 12)); }
function capReadySecs(){ return Math.max(0, Math.min(10, parseInt($('capReady').value) || 3)); }
function totalSegments(){ return capPasses() * BUCKETS.length; }
// begin the get-ready countdown for the segment at (passIx, actIx)
function startSegment(){
const ready = capReadySecs();
actSampleN = 0; $('capActN').textContent = '0';
if (ready <= 0){ segReadyUntil = 0; segT0 = performance.now(); }
else { segReadyUntil = performance.now() + ready * 1000; }
}
// advance to the next segment in the interleaved routine; returns false when the routine is done
function nextSegment(){
actIx++;
if (actIx >= BUCKETS.length){ actIx = 0; passIx++; }
if (passIx >= capPasses()) return false;
startSegment();
return true;
}
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('');
}
let pausedAt = 0; // performance.now() captured at the moment of pause (used to rebase clocks on resume)
function setCapControls(){
const live = recording;
$('recBtn').textContent = live ? '◼ stop recording' : '● start guided recording';
$('recBtn').classList.toggle('ghost', live);
$('pauseBtn').disabled = !live; $('skipBtn').disabled = !live;
$('pauseBtn').textContent = paused ? 'resume' : 'pause';
}
$('recBtn').addEventListener('click', ()=>{
if (!baseline || !camStream){ return; }
recording = !recording;
if (recording){
paused = false; passIx = 0; actIx = 0;
startSegment(); // begins with a get-ready countdown for pass1/activity1
}
setCapControls();
});
$('pauseBtn').addEventListener('click', ()=>{
if (!recording) return;
paused = !paused;
// when pausing mid-segment, freeze the clocks so no time is lost; on resume, rebase them
if (!paused){
if (segReadyUntil) segReadyUntil = performance.now() + (segReadyUntil - pausedAt);
else segT0 = performance.now() - (pausedAt - segT0);
} else { pausedAt = performance.now(); }
setCapControls();
});
$('skipBtn').addEventListener('click', ()=>{
if (!recording) return;
if (!nextSegment()){ recording = false; paused = false; setCapControls(); }
});
$('clrBtn').addEventListener('click', async ()=>{
SAMPLES = []; covCounts = new Array(BUCKETS.length).fill(0);
await idbPut('samples', []);
$('capN').textContent = '0'; $('trN').textContent = '0'; $('capActN').textContent = '0';
renderCoverage(); refreshGates();
});
// ---- Export / Import dataset (so multiple sessions accumulate, and data can be analyzed offline) ----
$('expBtn').addEventListener('click', ()=>{
const out = {
format: 'wiflow-browser-dataset', version: 1, exported: new Date().toISOString(),
csi_dim: CSI_DIM, out_dim: OUT_DIM, buckets: BUCKETS, nodes: NODE_IDS.slice(),
note: 'csi is baseline-normalized (ADR-151 deviation-from-baseline); kps are 17 COCO keypoints in [0,1] image coords',
samples: SAMPLES.map((s,i)=>({ csi: Array.from(s.csi), kps: Array.from(s.kps), bucket: s.bucket, t: (s.t!=null?s.t:i) }))
};
const blob = new Blob([JSON.stringify(out)], { type:'application/json' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `wiflow-dataset-${SAMPLES.length}samples-${Date.now()}.json`;
a.click(); setTimeout(()=>URL.revokeObjectURL(a.href), 2000);
});
$('impBtn').addEventListener('click', ()=> $('impFile').click());
$('impFile').addEventListener('change', async (e)=>{
const file = e.target.files && e.target.files[0]; if (!file) return;
try{
const txt = await file.text();
const data = JSON.parse(txt);
const arr = Array.isArray(data) ? data : (data.samples || []);
let merged = 0;
for (const s of arr){
if (!s || !s.csi || !s.kps) continue;
if (s.csi.length !== CSI_DIM || s.kps.length !== OUT_DIM) continue; // dim guard — never fabricate
SAMPLES.push({ csi: Float32Array.from(s.csi), kps: Float32Array.from(s.kps),
bucket: (s.bucket|0), t: (s.t!=null?s.t:Date.now()) });
if ((s.bucket|0) < BUCKETS.length) covCounts[s.bucket|0]++;
merged++;
}
$('capN').textContent = SAMPLES.length; $('trN').textContent = SAMPLES.length;
$('capSkip').textContent = `imported ${merged} samples (merged)`;
renderCoverage(); await idbSave(); refreshGates();
}catch(err){
$('capSkip').textContent = 'import failed: ' + err.message;
console.error('import', err);
} finally { e.target.value = ''; }
});
// MediaPipe throttle: one-in-flight (no backlog) + ~18 Hz cap so the UI never blocks.
let mpLastSend = 0;
const MP_MIN_INTERVAL_MS = 55; // ~18 Hz
function captureLoop(){
const W = $('capCv').width, H = $('capCv').height;
drawCameraFrame(capCtx, W, H, 0.9);
const nowMs = performance.now();
if (mpReady && !mpBusy && camEl.videoWidth > 0 && (nowMs - mpLastSend) >= MP_MIN_INTERVAL_MS){
mpBusy = true; mpLastSend = nowMs;
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) : '—';
// estimate (always shown): segments × seconds × ~recorded-fps
const segs = totalSegments();
$('capEst').textContent = `~${Math.round(segs*capSecs()/60*10)/10} min · ${segs} segments`;
if (recording && paused){
$('prompt').textContent = '⏸ PAUSED — ' + BUCKETS[actIx];
$('countdown').textContent = `pass ${passIx+1}/${capPasses()} · activity ${actIx+1}/${BUCKETS.length}`;
} else if (recording){
const done = passIx*BUCKETS.length + actIx; // segments completed so far
$('capPass').textContent = `${passIx+1}/${capPasses()} · ${actIx+1}/${BUCKETS.length}`;
$('capProg').style.width = Math.round(100*done/segs) + '%';
if (segReadyUntil){
// GET-READY: "GET INTO FRAME" + countdown, NO samples recorded yet
const remain = Math.max(0, (segReadyUntil - nowMs)/1000);
$('prompt').textContent = 'GET INTO FRAME — ' + BUCKETS[actIx];
$('countdown').textContent = `starting in ${Math.ceil(remain)} s · pass ${passIx+1}/${capPasses()} · activity ${actIx+1}/${BUCKETS.length}`;
capCtx.fillStyle='#ffb840'; capCtx.font='bold 20px monospace'; capCtx.textAlign='center';
capCtx.fillText(Math.ceil(remain)+'', W/2, 40); capCtx.textAlign='start';
if (remain <= 0){ segReadyUntil = 0; segT0 = nowMs; }
} else {
// RECORDING this activity
const el = (nowMs - segT0)/1000;
const secs = capSecs();
$('prompt').textContent = BUCKETS[actIx];
$('countdown').textContent = `${Math.max(0,Math.ceil(secs - el))} s · pass ${passIx+1}/${capPasses()} · activity ${actIx+1}/${BUCKETS.length}`;
if (el >= secs){
if (!nextSegment()){ recording = false; paused = false; setCapControls();
$('prompt').textContent = 'routine complete ✓'; $('countdown').textContent = `${SAMPLES.length} samples`; }
} else {
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: actIx, t: Date.now() });
covCounts[actIx]++; actSampleN++;
$('capActN').textContent = actSampleN;
$('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 = '—';
$('capProg').style.width = '0%';
}
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, t: s.t }));
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,i) => ({ csi: Float32Array.from(s.csi), kps: Float32Array.from(s.kps),
bucket: s.bucket||0, t: (s.t!=null?s.t:i) }));
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(); }
// Reduce overfit (train MSE 0.072 vs val 0.161 in the honest-negative run):
// - L2 weight decay on every Dense layer
// - slightly smaller capacity (384/192/96 vs 512/256/128)
// - keep dropout (raised to 0.35 in the wider layers)
const L2 = tf.regularizers.l2({ l2: 1e-4 });
model = tf.sequential();
model.add(tf.layers.dense({ inputShape:[CSI_DIM], units:384, activation:'relu', kernelRegularizer:L2 }));
model.add(tf.layers.dropout({ rate:0.35 }));
model.add(tf.layers.dense({ units:192, activation:'relu', kernelRegularizer:tf.regularizers.l2({ l2:1e-4 }) }));
model.add(tf.layers.dropout({ rate:0.35 }));
model.add(tf.layers.dense({ units:96, activation:'relu', kernelRegularizer:tf.regularizers.l2({ l2:1e-4 }) }));
model.add(tf.layers.dense({ units:OUT_DIM, activation:'sigmoid', kernelRegularizer:tf.regularizers.l2({ l2:1e-4 }) }));
model.compile({ optimizer: tf.train.adam(1e-3), loss:'meanSquaredError' });
// Early stopping on val MSE: stop after `patience` epochs with no improvement, restore best weights.
const patience = Math.max(5, Math.min(200, parseInt($('trPatience').value)||30));
let bestP10 = 0, bestVal = 1e9, bestWeights = null, sinceImprove = 0, stoppedEarly = false;
const disposeBest = ()=>{ if (bestWeights){ bestWeights.forEach(w=>w.dispose()); bestWeights = null; } };
$('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)=>{
// evaluate val EVERY epoch so early stopping/best-weight tracking is precise
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; }
const va = vsum/pvArr.length;
const r10=pckMpjpe(pvArr,gtVal,mVal,0.10), r05=pckMpjpe(pvArr,gtVal,mVal,0.05);
const 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 - 1e-6){
// new best — snapshot weights (clone, since model weights mutate in place)
bestVal = va; bestP10 = p10; sinceImprove = 0;
disposeBest();
bestWeights = model.getWeights().map(w => w.clone());
} else {
sinceImprove++;
}
$('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 (sinceImprove >= patience){
stoppedEarly = true; model.stopTraining = true;
$('trMsg').innerHTML = `Early stop at epoch ${ep+1}: val MSE hasn't improved for ${patience} epochs. Restoring best weights (val ${bestVal.toFixed(4)}).`;
}
if (trainStop) model.stopTraining = true;
await tf.nextFrame();
}}
});
// restore the best-val weights (early stopping semantics: report the best model, not the last)
if (bestWeights){ model.setWeights(bestWeights); disposeBest(); }
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)}%`;
const stopNote = stoppedEarly ? ' (early-stopped, best weights restored)' : '';
$('trMsg').innerHTML = 'Done'+stopNote+'. Saving model to IndexedDB…';
xtr.dispose(); ytr.dispose(); xva.dispose();
await saveModel();
$('trMsg').innerHTML = 'Saved'+stopNote+'. 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 restoreNodeIds(); // restore a previously-detected sensor set (fixes CSI_DIM before baseline)
await loadBaseline();
await idbLoad();
await loadModel();
renderCoverage();
setCapControls(); // pause/skip start disabled until recording
// if a camera was chosen in a previous session, surface it in the selectors (labels need permission,
// so this only fully populates after the user clicks "enable camera" in any stage)
try{
const saved = localStorage.getItem(CAM_KEY);
const devs = (await navigator.mediaDevices.enumerateDevices()).filter(d => d.kind === 'videoinput');
if (devs.length) populateCamSelectors(devs, saved);
}catch(e){}
refreshGates();
requestAnimationFrame(calibrateLoop);
requestAnimationFrame(captureLoop);
requestAnimationFrame(inferLoop);
})();
</script>
</body>
</html>