项目文件夹

文件
wehub-resource-sync 9740bc64c9
Bench Regression Guard / bench compile-verify (--no-run) (push) Failing after 0s
Continuous Deployment / Pre-deployment Checks (push) Has been skipped
Bench Regression Guard / bench fast-run (informational, non-gating) (push) Has been skipped
Firmware CI / Verify version.txt matches release tag (push) Has been skipped
Dashboard a11y + cross-browser / a11y (push) Failing after 0s
nvsim Dashboard → GitHub Pages / build-and-deploy (push) Failing after 2s
Firmware CI / Build firmware (esp32s3 / 4mb) (push) Failing after 15s
Firmware CI / Build firmware (esp32c6 / c6-4mb) (push) Failing after 15s
Firmware QEMU Tests (ADR-061) / Build Espressif QEMU (push) Failing after 1s
Firmware QEMU Tests (ADR-061) / Fuzz Testing (ADR-061 Layer 6) (push) Failing after 1s
Firmware QEMU Tests (ADR-061) / QEMU Test (boundary-max) (push) Has been skipped
Firmware CI / Build firmware (esp32s3 / 8mb) (push) Failing after 15s
Firmware QEMU Tests (ADR-061) / QEMU Test (boundary-min) (push) Has been skipped
Firmware QEMU Tests (ADR-061) / QEMU Test (default) (push) Has been skipped
Firmware QEMU Tests (ADR-061) / QEMU Test (edge-tier0) (push) Has been skipped
Firmware QEMU Tests (ADR-061) / QEMU Test (edge-tier1) (push) Has been skipped
Firmware QEMU Tests (ADR-061) / QEMU Test (full-adr060) (push) Has been skipped
Firmware QEMU Tests (ADR-061) / QEMU Test (tdm-3node) (push) Has been skipped
Firmware QEMU Tests (ADR-061) / Swarm Test (ADR-062) (push) Has been skipped
Firmware QEMU Tests (ADR-061) / NVS Matrix Generation (push) Failing after 1s
Fix-Marker Regression Guard / Verify fix markers (push) Failing after 1s
ADR-115 MQTT integration tests / mqtt-integration (push) Failing after 1s
npm packages / harness/ruview (node 20) (push) Failing after 1s
npm packages / tools/ruview-mcp (node 20) (push) Failing after 1s
npm packages / tools/ruview-cli (node 20) (push) Failing after 1s
npm packages / tools/ruview-cli (node 22) (push) Failing after 1s
npm packages / tools/ruview-mcp (node 22) (push) Failing after 1s
nvsim-server → ghcr.io / build-and-publish (push) Failing after 1s
ruview-swarm CI guard / tests (full+train) (push) Failing after 2s
ruview-swarm CI guard / tests (ruflo) (push) Failing after 1s
ruview-swarm CI guard / tests (train) (push) Failing after 2s
BFLD MQTT Integration / cargo test --features mqtt (live mosquitto) (push) Failing after 29s
ruview-swarm CI guard / tests (default) (push) Failing after 2s
Point Cloud Viewer → GitHub Pages / build-and-deploy (push) Failing after 8s
ruview-swarm CI guard / ITAR / publish guard (push) Failing after 0s
ruview-swarm CI guard / build train_marl bin (push) Failing after 2s
ruview-swarm CI guard / clippy (-D warnings, --no-deps) (push) Failing after 3s
Security Scanning / Dependency Vulnerability Scan (push) Failing after 0s
Security Scanning / Static Application Security Testing (push) Failing after 1s
Security Scanning / Infrastructure Security Scan (push) Failing after 1s
Security Scanning / Secret Scanning (push) Failing after 1s
npm packages / harness/ruview (node 22) (push) Failing after 17s
Security Scanning / License Compliance Scan (push) Failing after 1s
Security Scanning / Container Security Scan (push) Failing after 4s
Security Scanning / Security Policy Compliance (push) Failing after 0s
wifi-densepose sensing-server → Docker Hub + ghcr.io / build · push · smoke-test (push) Failing after 1s
three.js demos → GitHub Pages / build-and-deploy (push) Failing after 1s
Verify Pipeline Determinism / Verify Pipeline Determinism (3.11) (push) Failing after 1s
Continuous Deployment / Deploy to Production (push) Has been cancelled
Continuous Deployment / Rollback Deployment (push) Has been cancelled
Continuous Deployment / Post-deployment Monitoring (push) Has been cancelled
Continuous Deployment / Notify Deployment Status (push) Has been cancelled
Continuous Deployment / Deploy to Staging (push) Has been cancelled
Security Scanning / Security Report (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 11:59:54 +08:00

1273 行
66 KiB
HTML

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
<!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>