feat(examples): in-browser WiFlow trainer + camera-supervised pipeline + ADR-180/181/181A
Tonight's real WiFlow work, all honest: - examples/through-wall/: live 2-node CSI demo (index.html), the WiFlow camera-supervised pipeline (wiflow_capture/train/infer.py — proven +9.4pp over mean-pose baseline on ruvultra), the live pose viewer (pose.html), and the COMPLETE in-browser trainer (wiflow_browser.html): 4-stage calibrate->capture->train->infer, TF.js WebGPU/WASM/WebGL, MediaPipe camera supervision, IndexedDB persistence, mean-pose-baseline honesty. - ADR-180 (through-wall hand-off demo), ADR-181 (full browser WiFlow, WASM+WebGPU, calibration phase, mobile/secure-context matrix), ADR-181A (binary CSI framing protocol). Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
parent
ebe217569b
commit
a4d5ea88f3
|
|
@ -1,62 +1,69 @@
|
|||
# Through-Wall WiFi Sensing Demo (LIVE CSI — no simulation, no fake skeleton)
|
||||
# WiFlow Browser Trainer (`wiflow_browser.html`)
|
||||
|
||||
A self-contained 3D demo that renders **only real data** streamed from the
|
||||
running `wifi-densepose-sensing-server`, which ingests genuine WiFi Channel
|
||||
State Information (CSI) from a live ESP32-S3 node over UDP.
|
||||
A **single self-contained HTML page** that does the entire camera-supervised
|
||||
WiFi-pose loop **in your browser, in your laptop camera's coordinate frame**, as
|
||||
a **4-stage gated flow** with a progress stepper (each stage unlocks the next):
|
||||
|
||||
It honestly shows what WiFi CSI sensing actually delivers:
|
||||
0. **CALIBRATE** *(ADR-151 empty-room baseline)* — you step OUT of the space; the
|
||||
page captures ~10 s of the quiescent CSI and computes a per-feature running
|
||||
**mean + std (Welford)** over the 410-d vector. Every CSI vector afterwards is
|
||||
expressed as **deviation from baseline**
|
||||
(`x_norm = (x − base_mean) / (base_std + ε)`), so a body's perturbation stands
|
||||
out from the static channel. Persisted to IndexedDB. *Can't capture without it.*
|
||||
1. **CAPTURE** — MediaPipe Pose runs on your laptop camera → 17 COCO keypoints
|
||||
(the *label*), paired with the **baseline-normalized** 410-d ESP32 CSI vector
|
||||
(the *input*). A **guided, balanced routine** cycles big on-screen prompts
|
||||
(stand / turn / walk / arms / crouch / sit / reach) with a countdown, and a
|
||||
**per-pose coverage meter** so you build a balanced dataset, not 2 000 frames
|
||||
of standing.
|
||||
2. **TRAIN** — a TensorFlow.js MLP learns `CSI → pose` in-browser. Honest
|
||||
held-out PCK@0.10 / PCK@0.05 / MPJPE, plus a **mean-pose baseline** the model
|
||||
must beat (the project's whole ethos — no baseline-beating signal, it says so).
|
||||
*Can't train with <200 samples.*
|
||||
3. **INFER** — the trained model drives a skeleton **from WiFi CSI only**
|
||||
(baseline-normalized → standardized → model), drawn over the **same** camera
|
||||
frame it trained in — so the inferred skeleton **aligns** with the camera
|
||||
image. That alignment is the entire point of doing this in-browser instead of
|
||||
with a separate Python camera. *Can't infer without a model.*
|
||||
|
||||
- **motion** and **presence** — does the RF field say someone is here and moving?
|
||||
- a **coarse RF localization** marker — roughly *where* the energy is, in metres.
|
||||
- a **20×20 signal-field heatmap** on the floor — the live "where is the motion" map.
|
||||
## Why in-browser
|
||||
|
||||
…and it shows all of this **through drywall**. That is the real wow of WiFi
|
||||
sensing — not skeletal pose.
|
||||
The Python pipeline (`wiflow_capture.py` → `wiflow_train.py` → `wiflow_infer.py`)
|
||||
proved the signal is real (held-out PCK@0.10 ≈ 59.5% vs a 50% mean-pose baseline
|
||||
= +9.4 pp). But it trained in a *different* camera's frame, so the inferred
|
||||
skeleton never lined up with the laptop camera. Doing capture + train + infer all
|
||||
in the browser with the **same** camera makes the training frame and the
|
||||
inference frame identical → the skeleton aligns.
|
||||
|
||||
## What this is NOT
|
||||
## Compute backends (WebGPU / WASM / WebGL)
|
||||
|
||||
- **Not a skeleton / pose.** The sensing-server's `persons[].keypoints` carry
|
||||
`confidence: 0.0` (they are image-pixel placeholders, not real 3D joints), so
|
||||
this demo never draws them. WiFi CSI here gives motion / presence / coarse
|
||||
position — that is the honest output, and we render exactly that.
|
||||
- **Not a simulation.** If the server is sending `source: "simulated"`, the
|
||||
banner says **SIMULATED — not real** in orange. If the server is unreachable,
|
||||
the page shows **NO SERVER** with start instructions. It never invents frames.
|
||||
Training and inference run on TensorFlow.js. The page selects the backend at
|
||||
startup, preferring the fastest available:
|
||||
|
||||
## What it renders (all driven by real `/ws/sensing` frames)
|
||||
- **WebGPU** (Chrome / Edge, secure context — `localhost` qualifies) — GPU compute.
|
||||
- **WASM-SIMD** fallback (`tfjs-backend-wasm`, SIMD enabled, `.wasm` from the CDN).
|
||||
- **WebGL** last-resort fallback (ships inside tfjs core).
|
||||
|
||||
| Element | Real field used |
|
||||
|---|---|
|
||||
| Floor heatmap (20×20 tiles) | `signal_field.values` (400 floats ~0..1) |
|
||||
| Coarse localization puck | `persons[0].position` `[x,0,z]` (peak cell as fallback) |
|
||||
| Motion / breathing / variance / RSSI bars | `features.*` |
|
||||
| Presence / motion level / confidence | `classification.*` |
|
||||
| Estimated persons | `estimated_persons` |
|
||||
| Active node markers | `nodes[].node_id` (node 9 = office, node 13 = hallway) |
|
||||
| Update rate (Hz) | measured from frame arrival times |
|
||||
| Status banner | `source` verbatim ("esp32" = LIVE) |
|
||||
The **active backend is shown as a badge in the header** (`compute: WebGPU` /
|
||||
`WASM-SIMD` / `WebGL`) so it's honest about what's actually running. The model
|
||||
code is backend-agnostic — tf.js abstracts the device.
|
||||
|
||||
The 3D room is split by a **wall + doorway** into **OFFICE** (node 9) and
|
||||
**HALLWAY** (node 13). Node markers light up only when that node actually
|
||||
appears in the live `nodes` list.
|
||||
## Honesty (baked in)
|
||||
|
||||
## The through-wall story
|
||||
|
||||
WiFi (2.4/5 GHz) penetrates interior drywall. When you walk from the office
|
||||
into the hallway — *behind the wall* — node 9's `signal_field` and
|
||||
`motion_band_power` **still register the motion** even though there is a wall
|
||||
between you and the antenna. That is real through-wall motion sensing on a
|
||||
single node.
|
||||
|
||||
Once a **second ESP32-S3 is flashed and placed in the hallway** (node 13, the
|
||||
`esp32-csi-node` firmware), the server fuses both nodes (multistatic) and the
|
||||
hallway node localizes you on its side of the wall — true two-room through-wall
|
||||
localization. With one node today you already get through-wall *motion*; the
|
||||
second node adds *where*.
|
||||
- The **CAPTURE** skeleton (blue) is the camera = ground truth, labeled as such.
|
||||
- The **INFER** skeleton (green) is **CSI-only**, labeled, and **coarse** — the
|
||||
real measured held-out PCK is shown, not a marketing number.
|
||||
- The **mean-pose baseline** is always computed and shown in TRAIN; the verdict
|
||||
states plainly whether the model **beats** it (real signal) or **does not**
|
||||
(no usable signal). This guards against the project's retracted 92.9% that
|
||||
failed exactly this check.
|
||||
- Status banner is strict and mutually exclusive:
|
||||
**LIVE** (real `source: "esp32"`) / **SIMULATED — not real** (any other source)
|
||||
/ **NO-CSI-SERVER**. The page never invents frames.
|
||||
|
||||
## How to run
|
||||
|
||||
### 1. Start the REAL sensing-server
|
||||
### 1. Start the real sensing-server (provides the CSI WebSocket on :8765)
|
||||
|
||||
```bash
|
||||
cd v2
|
||||
|
|
@ -64,55 +71,65 @@ cargo build -p wifi-densepose-sensing-server
|
|||
./target/debug/sensing-server.exe --ws-port 8765 --udp-port 5005
|
||||
```
|
||||
|
||||
This is the process that ingests real CSI from the ESP32-S3 (UDP on 5005) and
|
||||
serves the live WebSocket on `ws://localhost:8765/ws/sensing`. A real ESP32-S3
|
||||
must be provisioned and streaming for `source` to read `esp32` (see the repo's
|
||||
ESP32 firmware build/provision steps in `CLAUDE.local.md`).
|
||||
A real ESP32-S3 must be provisioned and streaming for `source` to read `esp32`
|
||||
(see `CLAUDE.local.md` for the firmware build/provision steps). The page expects
|
||||
the verified live endpoint **`ws://localhost:8765/ws/sensing`** with
|
||||
`source:"esp32"`, nodes `[9, 13]`, `features.*`, `node_features[].features.*`,
|
||||
and `signal_field.values` (400 floats).
|
||||
|
||||
### 2. Start the static server for this page
|
||||
### 2. Serve this page over localhost (camera + WebGPU need a localhost/secure origin)
|
||||
|
||||
Any static localhost server works. For example:
|
||||
|
||||
```bash
|
||||
python examples/through-wall/serve.py
|
||||
python -m http.server 8099
|
||||
# then open: http://localhost:8099/examples/through-wall/wiflow_browser.html
|
||||
```
|
||||
|
||||
(Serves on **port 8080** — 8765 is the WebSocket, a different process.)
|
||||
(8099 is just the static file server — 8765 is a separate process, the CSI
|
||||
WebSocket.) Allow camera access when the browser prompts.
|
||||
|
||||
### 3. Open the page
|
||||
Point at a CSI server on another host with `?ws=`:
|
||||
|
||||
```
|
||||
http://localhost:8080/examples/through-wall/index.html
|
||||
http://localhost:8099/examples/through-wall/wiflow_browser.html?ws=ws://192.168.1.20:8765/ws/sensing
|
||||
```
|
||||
|
||||
The page connects automatically. If you want to point at a server on another
|
||||
host (e.g. an ESP32 streaming to a Pi), override the endpoint:
|
||||
### 3. Use it
|
||||
|
||||
1. **CAPTURE** tab → *enable laptop camera* → *start recording*. Follow the guided
|
||||
routine (stand / turn / walk / arms / crouch / sit). A pair is stored only when
|
||||
a confident pose AND a fresh live `esp32` CSI frame coexist. Aim for a few
|
||||
thousand samples. Samples persist in IndexedDB across refreshes.
|
||||
2. **TRAIN** tab → *train model*. Watch the live loss curve, held-out PCK, and the
|
||||
baseline verdict. The model saves to IndexedDB.
|
||||
3. **INFER** tab → the green skeleton is now driven by WiFi CSI only, aligned over
|
||||
your camera. Toggle *hide camera* to see the CSI-only skeleton on black.
|
||||
|
||||
## The 410-d CSI vector (matches the Python pipeline exactly)
|
||||
|
||||
```
|
||||
http://localhost:8080/examples/through-wall/index.html?ws=ws://192.168.1.20:8765/ws/sensing
|
||||
[ mean_rssi, variance, motion_band_power, breathing_band_power ] # 4 (features.*)
|
||||
+ for node 9 then node 13: [ mean_rssi, variance, motion_band_power ] # 6 (node_features[].features.*)
|
||||
+ signal_field.values, padded / truncated to 400 # 400
|
||||
= 410-d
|
||||
```
|
||||
|
||||
## Optional: webcam ground-truth tile
|
||||
Verified against a real live frame: the in-browser `csiVector()` produces the
|
||||
identical 410 vector as `wiflow_capture.py`'s `csi_vector()` (node 9 first, then
|
||||
node 13; field zero-padded).
|
||||
|
||||
The bottom-right tile can enable your webcam ("camera — ground truth when
|
||||
visible"). This is **separate** from the CSI sensing — it is only there to let a
|
||||
viewer confirm with their eyes what the WiFi is detecting. The WiFi works in the
|
||||
dark and through walls; the camera does not. The sensing itself is the CSI.
|
||||
## Libraries (CDN only, no bundler)
|
||||
|
||||
## Honest scope
|
||||
| Library | CDN |
|
||||
|---|---|
|
||||
| TensorFlow.js core | `@tensorflow/tfjs@4.22.0/dist/tf.min.js` |
|
||||
| TF.js WebGPU backend | `@tensorflow/tfjs-backend-webgpu@4.22.0/dist/tf-backend-webgpu.min.js` |
|
||||
| TF.js WASM backend | `@tensorflow/tfjs-backend-wasm@4.22.0/dist/tf-backend-wasm.min.js` |
|
||||
| MediaPipe Pose 0.5 (legacy solutions) | `@mediapipe/pose@0.5/pose.js` |
|
||||
|
||||
- Real: motion, presence, coarse position (incl. through drywall on the office
|
||||
node), the live signal-field heatmap, RSSI, and a measured update rate.
|
||||
- The coarse-localization puck is labeled **"RF localization (coarse)"** — it is
|
||||
metre-scale, not centimetre pose. It uses `persons[0].position` when a person
|
||||
is tracked, otherwise the peak cell of the live `signal_field`.
|
||||
- Two-room (office + hallway) through-wall *localization* needs the hallway node
|
||||
(node 13) flashed and placed; until then node 13 stays dimmed and the demo
|
||||
shows single-node through-wall *motion*.
|
||||
## Scope / honesty caveats
|
||||
|
||||
## Reused from existing examples
|
||||
|
||||
- 3D scene setup, lights, fog, post-processing bloom, dark amber CSS, and the
|
||||
optional webcam path — from `examples/three.js/demos/05-skinned-realtime.html`.
|
||||
- Floor-heatmap-on-the-grid idea and presence/field rendering — from
|
||||
`ui/observatory/js/` (`presence-cartography.js`, `subcarrier-manifold.js`).
|
||||
- Threaded no-cache static server — from
|
||||
`examples/three.js/server/serve-demo.py`.
|
||||
Same person, same room, same session. **Not** validated cross-day, cross-room, or
|
||||
through-wall. The inferred pose is coarse (PCK@0.05 is typically weak). If the
|
||||
model does not beat the mean-pose baseline, the page says so — that is a feature.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,926 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>WiFlow Browser Trainer · calibrate → capture → train → infer, in your camera's frame</title>
|
||||
<!--
|
||||
WiFlow in-browser trainer (ADR-079 / ADR-180 + ADR-151 empty-room baseline).
|
||||
A 4-STAGE GATED FLOW, all in the LAPTOP camera's coordinate frame:
|
||||
0. CALIBRATE — empty-room baseline (Welford mean+std over the 410-d CSI vector).
|
||||
Every CSI vector afterwards is expressed as deviation-from-baseline,
|
||||
so a body's perturbation stands out from the static channel.
|
||||
1. CAPTURE — MediaPipe Pose on the laptop camera = 17 COCO keypoints (the LABEL),
|
||||
paired with the baseline-normalized live ESP32 CSI vector (the INPUT).
|
||||
Guided, balanced routine with a per-pose coverage meter.
|
||||
2. TRAIN — a TF.js MLP (WebGPU/WASM/WebGL) learns CSI -> pose in-browser. Honest
|
||||
held-out PCK + a mean-pose baseline it must beat.
|
||||
3. INFER — the trained model drives a skeleton FROM WiFi CSI ONLY, drawn over the
|
||||
same camera frame, so it ALIGNS (the whole point of doing it in-browser).
|
||||
Self-contained. CDN libs only. No bundler. Real data only — CSI source must read "esp32".
|
||||
-->
|
||||
<style>
|
||||
:root{--bg:#0a0c10;--panel:#11151c;--panel2:#0d1117;--amber:#ffb840;--green:#46e08a;
|
||||
--red:#ff5a5a;--blue:#5aa9ff;--mute:#7d8796;--line:#1d2430;--txt:#dfe6ee}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;background:var(--bg);color:var(--txt);
|
||||
font:14px/1.5 'JetBrains Mono',ui-monospace,Menlo,Consolas,monospace}
|
||||
header{padding:14px 18px;border-bottom:1px solid var(--line);display:flex;align-items:center;gap:14px;flex-wrap:wrap}
|
||||
h1{font-size:15px;margin:0;letter-spacing:1px;text-transform:uppercase;font-weight:600}
|
||||
h1 span{color:var(--amber)}
|
||||
#compute{padding:4px 10px;border-radius:5px;font-weight:600;font-size:11px;letter-spacing:.5px;
|
||||
background:rgba(90,169,255,.12);color:var(--blue);border:1px solid var(--blue)}
|
||||
#banner{margin-left:auto;padding:5px 12px;border-radius:5px;font-weight:600;font-size:12px;letter-spacing:.5px}
|
||||
.live{background:rgba(70,224,138,.15);color:var(--green);border:1px solid var(--green)}
|
||||
.sim{background:rgba(255,184,64,.15);color:var(--amber);border:1px solid var(--amber)}
|
||||
.down{background:rgba(255,90,90,.15);color:var(--red);border:1px solid var(--red)}
|
||||
/* progress stepper */
|
||||
.steps{display:flex;gap:6px;padding:14px 18px 0;flex-wrap:wrap;align-items:center}
|
||||
.step{display:flex;align-items:center;gap:8px;background:var(--panel);color:var(--mute);
|
||||
border:1px solid var(--line);border-radius:8px;padding:8px 16px;cursor:pointer;font-weight:600;letter-spacing:.5px}
|
||||
.step .num{display:inline-flex;width:20px;height:20px;border-radius:50%;background:var(--line);color:var(--txt);
|
||||
align-items:center;justify-content:center;font-size:11px}
|
||||
.step.on{color:var(--amber);border-color:var(--amber)}
|
||||
.step.on .num{background:var(--amber);color:#0a0c10}
|
||||
.step.done .num{background:var(--green);color:#0a0c10}
|
||||
.step.locked{opacity:.45;cursor:not-allowed}
|
||||
.arrow{color:var(--mute)}
|
||||
main{padding:14px 18px 24px}
|
||||
.panel{display:none;background:var(--panel2);border:1px solid var(--line);border-radius:10px;padding:18px}
|
||||
.panel.on{display:block}
|
||||
.cols{display:flex;gap:18px;flex-wrap:wrap}
|
||||
.card{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:14px}
|
||||
canvas{background:#070a0e;border-radius:8px;display:block}
|
||||
.label{font-size:11px;text-transform:uppercase;letter-spacing:1.5px;color:var(--mute);margin-bottom:8px}
|
||||
.stats{min-width:260px;flex:1}
|
||||
.row{display:flex;justify-content:space-between;padding:3px 0;border-bottom:1px dashed var(--line)}
|
||||
.row .k{color:var(--mute)} .row .v{color:var(--amber);font-variant-numeric:tabular-nums;text-align:right}
|
||||
.v.green{color:var(--green)} .v.red{color:var(--red)} .v.blue{color:var(--blue)}
|
||||
.note{margin-top:12px;font-size:11px;color:var(--mute);line-height:1.6}
|
||||
.note b{color:var(--txt)}
|
||||
button.btn{background:var(--amber);color:#0a0c10;border:0;border-radius:6px;padding:8px 16px;
|
||||
font:inherit;font-weight:600;cursor:pointer}
|
||||
button.btn:disabled{opacity:.4;cursor:not-allowed}
|
||||
button.ghost{background:transparent;color:var(--txt);border:1px solid var(--line)}
|
||||
select,input{background:var(--panel);color:var(--txt);border:1px solid var(--line);border-radius:6px;
|
||||
padding:7px;font:inherit;max-width:260px}
|
||||
.bar{height:8px;background:var(--line);border-radius:5px;overflow:hidden;margin-top:4px}
|
||||
.bar>i{display:block;height:100%;background:var(--green);width:0%}
|
||||
.verdict{padding:10px 14px;border-radius:8px;margin-top:12px;font-weight:600;font-size:13px}
|
||||
.verdict.good{background:rgba(70,224,138,.12);color:var(--green);border:1px solid var(--green)}
|
||||
.verdict.bad{background:rgba(255,90,90,.12);color:var(--red);border:1px solid var(--red)}
|
||||
.verdict.idle{background:rgba(125,135,150,.1);color:var(--mute);border:1px solid var(--line)}
|
||||
.pill{display:inline-block;padding:2px 8px;border-radius:10px;font-size:11px;font-weight:600;margin-left:6px}
|
||||
.pill.gt{background:rgba(90,169,255,.15);color:var(--blue);border:1px solid var(--blue)}
|
||||
.pill.csi{background:rgba(70,224,138,.15);color:var(--green);border:1px solid var(--green)}
|
||||
code{background:#0a0c10;border:1px solid var(--line);border-radius:4px;padding:1px 5px;color:var(--amber)}
|
||||
a{color:var(--blue)}
|
||||
/* big guided prompt */
|
||||
#prompt{font-size:30px;font-weight:700;color:var(--amber);letter-spacing:1px;text-align:center;margin:6px 0}
|
||||
#countdown{font-size:13px;color:var(--mute);text-align:center}
|
||||
/* coverage meter */
|
||||
.cov{display:flex;flex-direction:column;gap:5px;margin-top:8px}
|
||||
.covrow{display:flex;align-items:center;gap:8px;font-size:11px}
|
||||
.covrow .nm{width:90px;color:var(--mute);text-transform:capitalize}
|
||||
.covrow .bar{flex:1;margin:0}
|
||||
.covrow .ct{width:42px;text-align:right;color:var(--txt);font-variant-numeric:tabular-nums}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>WiFlow <span>Browser Trainer</span> — calibrate · capture · train · infer</h1>
|
||||
<div id="compute">compute: …</div>
|
||||
<div id="banner" class="down">CONNECTING…</div>
|
||||
</header>
|
||||
|
||||
<!-- progress stepper, each gated on the previous -->
|
||||
<div class="steps">
|
||||
<div class="step on" data-stage="calibrate"><span class="num">0</span> CALIBRATE</div>
|
||||
<span class="arrow">→</span>
|
||||
<div class="step locked" data-stage="capture"><span class="num">1</span> CAPTURE</div>
|
||||
<span class="arrow">→</span>
|
||||
<div class="step locked" data-stage="train"><span class="num">2</span> TRAIN</div>
|
||||
<span class="arrow">→</span>
|
||||
<div class="step locked" data-stage="infer"><span class="num">3</span> INFER</div>
|
||||
</div>
|
||||
|
||||
<main>
|
||||
<!-- ============================ STAGE 0 · CALIBRATE ============================ -->
|
||||
<section id="stage-calibrate" class="panel on">
|
||||
<div class="cols">
|
||||
<div class="card">
|
||||
<div class="label">empty-room baseline (ADR-151) — step OUT of the space</div>
|
||||
<canvas id="calCv" width="420" height="300"></canvas>
|
||||
<div style="margin-top:10px;display:flex;gap:8px;align-items:center;flex-wrap:wrap">
|
||||
<button id="calBtn" class="btn">calibrate baseline (10 s)</button>
|
||||
<button id="recalBtn" class="ghost btn">recalibrate</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card stats">
|
||||
<div class="label">baseline</div>
|
||||
<div class="row"><span class="k">CSI source</span><span class="v" id="calSrc">—</span></div>
|
||||
<div class="row"><span class="k">status</span><span class="v" id="calStatus">NOT CALIBRATED</span></div>
|
||||
<div class="row"><span class="k">frames in baseline</span><span class="v" id="calN">0</span></div>
|
||||
<div class="row"><span class="k">age</span><span class="v" id="calAge">—</span></div>
|
||||
<div style="margin-top:8px"><div class="bar"><i id="calBar"></i></div></div>
|
||||
<div class="note">
|
||||
The room's static WiFi channel is mostly constant. We capture ~10 s of the
|
||||
<b>quiescent</b> field (you OUT of the space) and compute a per-feature running
|
||||
<b>mean + std</b> (Welford) over the 410-d CSI vector. Afterwards every CSI vector
|
||||
is expressed as <b>deviation from baseline</b>:
|
||||
<code>x_norm = (x − base_mean) / (base_std + ε)</code> — applied consistently in
|
||||
capture, train, and infer. This makes a <b>body's perturbation</b> stand out from
|
||||
the static channel. You must calibrate before capturing.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================ STAGE 1 · CAPTURE ============================ -->
|
||||
<section id="stage-capture" class="panel">
|
||||
<div class="cols">
|
||||
<div class="card">
|
||||
<div class="label">laptop camera <span class="pill gt">MediaPipe skeleton = GROUND TRUTH (the label)</span></div>
|
||||
<canvas id="capCv" width="420" height="480"></canvas>
|
||||
<div id="prompt">stand still</div>
|
||||
<div id="countdown">—</div>
|
||||
<div style="margin-top:8px;display:flex;gap:8px;align-items:center;flex-wrap:wrap">
|
||||
<button id="camBtn" class="btn">enable laptop camera</button>
|
||||
<select id="camSel" style="display:none"></select>
|
||||
</div>
|
||||
<div id="camStatus" class="note" style="margin-top:6px">camera: off</div>
|
||||
</div>
|
||||
<div class="card stats">
|
||||
<div class="label">guided capture</div>
|
||||
<div class="row"><span class="k">CSI source</span><span class="v" id="capSrc">—</span></div>
|
||||
<div class="row"><span class="k">CSI nodes</span><span class="v" id="capNodes">—</span></div>
|
||||
<div class="row"><span class="k">pose visibility</span><span class="v" id="capVis">—</span></div>
|
||||
<div class="row"><span class="k">total samples</span><span class="v green" id="capN">0</span></div>
|
||||
<div class="row"><span class="k">last skip reason</span><span class="v" id="capSkip">—</span></div>
|
||||
<div style="margin-top:12px;display:flex;gap:8px;flex-wrap:wrap">
|
||||
<button id="recBtn" class="btn" disabled>● start guided recording</button>
|
||||
<button id="clrBtn" class="ghost btn">clear dataset</button>
|
||||
</div>
|
||||
<div class="label" style="margin-top:16px">per-pose coverage (balance the dataset)</div>
|
||||
<div id="cov" class="cov"></div>
|
||||
<div class="note">
|
||||
A pair is recorded <b>only</b> when BOTH (a) a confident MediaPipe pose
|
||||
(mean visibility > 0.5) AND (b) a fresh <b>live</b> CSI frame (<code>source==esp32</code>)
|
||||
exist. We store the <b>baseline-normalized</b> CSI + the 17 keypoints, mirrored to
|
||||
IndexedDB so a refresh keeps them. Follow the prompt so every pose bucket fills up —
|
||||
a balanced set beats 2 000 frames of standing.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================ STAGE 2 · TRAIN ============================ -->
|
||||
<section id="stage-train" class="panel">
|
||||
<div class="cols">
|
||||
<div class="card stats">
|
||||
<div class="label">train (TensorFlow.js)</div>
|
||||
<div class="row"><span class="k">total samples</span><span class="v" id="trN">0</span></div>
|
||||
<div class="row"><span class="k">train / val split</span><span class="v" id="trSplit">— / — (chronological 80/20)</span></div>
|
||||
<div class="row"><span class="k">epoch</span><span class="v" id="trEpoch">0</span></div>
|
||||
<div class="row"><span class="k">train MSE</span><span class="v" id="trLoss">—</span></div>
|
||||
<div class="row"><span class="k">val MSE</span><span class="v" id="trVal">—</span></div>
|
||||
<div class="row"><span class="k">held-out PCK@0.10</span><span class="v green" id="trP10">—</span></div>
|
||||
<div class="row"><span class="k">held-out PCK@0.05</span><span class="v" id="trP05">—</span></div>
|
||||
<div class="row"><span class="k">held-out MPJPE</span><span class="v" id="trMpj">—</span></div>
|
||||
<div class="row"><span class="k">mean-pose baseline PCK@0.10</span><span class="v red" id="trBase">—</span></div>
|
||||
<div style="margin-top:8px"><div class="bar"><i id="trBar"></i></div></div>
|
||||
<div style="margin-top:12px;display:flex;gap:8px;flex-wrap:wrap;align-items:center">
|
||||
<label class="note" style="margin:0">epochs <input id="trEpochs" type="number" value="200" min="20" max="600" style="width:80px"></label>
|
||||
<button id="trainBtn" class="btn" disabled>train model</button>
|
||||
<button id="trStop" class="ghost btn" disabled>stop</button>
|
||||
</div>
|
||||
<div id="verdict" class="verdict idle">no model yet — calibrate, capture, then train.</div>
|
||||
<div class="note">
|
||||
<b>The bar to beat</b> is the mean-pose baseline (predict the train-mean pose for
|
||||
everything). A model that doesn't clear it has learned <b>no usable CSI→pose signal</b> —
|
||||
this page says so plainly. Inputs are standardized on the <b>train split only</b>
|
||||
(after baseline-normalization); the val split is the chronological last 20%, never trained on.
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="label">loss curve — train (amber) vs val (blue)</div>
|
||||
<canvas id="lossCv" width="460" height="300"></canvas>
|
||||
<div class="note" id="trMsg">Idle.</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================ STAGE 3 · INFER ============================ -->
|
||||
<section id="stage-infer" class="panel">
|
||||
<div class="cols">
|
||||
<div class="card">
|
||||
<div class="label">WiFi-inferred pose <span class="pill csi">CSI ONLY — no camera in the loop</span></div>
|
||||
<canvas id="infCv" width="420" height="560"></canvas>
|
||||
<div style="margin-top:10px;display:flex;gap:8px;align-items:center;flex-wrap:wrap">
|
||||
<label class="note" style="margin:0"><input type="checkbox" id="hideCam"> hide camera (skeleton on black)</label>
|
||||
<span class="note" id="infModelState" style="margin:0">no model loaded</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card stats">
|
||||
<div class="label">live inference</div>
|
||||
<div class="row"><span class="k">CSI source</span><span class="v" id="infSrc">—</span></div>
|
||||
<div class="row"><span class="k">CSI nodes</span><span class="v" id="infNodes">—</span></div>
|
||||
<div class="row"><span class="k">presence</span><span class="v" id="infPres">—</span></div>
|
||||
<div class="row"><span class="k">infer fps</span><span class="v" id="infFps">—</span></div>
|
||||
<div class="row"><span class="k">measured held-out PCK@0.10</span><span class="v green" id="infPck">—</span></div>
|
||||
<div class="note">
|
||||
This skeleton is inferred <b>from WiFi CSI only</b> (baseline-normalized, then through
|
||||
the model). It is <b>coarse</b> — the held-out PCK above is the real number. It is drawn
|
||||
over the <b>same</b> laptop-camera frame it trained in, so it <b>aligns</b> with the image.
|
||||
Same person / room / session — not validated cross-day or through-wall.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<!-- TensorFlow.js core + WebGPU/WASM backends (WebGL ships inside core as the final fallback) -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/@tensorflow/tfjs@4.22.0/dist/tf.min.js" crossorigin="anonymous"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@tensorflow/tfjs-backend-webgpu@4.22.0/dist/tf-backend-webgpu.min.js" crossorigin="anonymous"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@tensorflow/tfjs-backend-wasm@4.22.0/dist/tf-backend-wasm.min.js" crossorigin="anonymous"></script>
|
||||
<!-- MediaPipe Pose 0.5 (legacy solutions API — same CDN the 05-skinned-realtime demo uses) -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/@mediapipe/pose@0.5/pose.js" crossorigin="anonymous"></script>
|
||||
|
||||
<script>
|
||||
"use strict";
|
||||
// ============================================================================
|
||||
// Constants & shared state
|
||||
// ============================================================================
|
||||
const CSI_WS = (new URLSearchParams(location.search)).get('ws')
|
||||
|| `ws://${location.hostname || 'localhost'}:8765/ws/sensing`;
|
||||
const NODE_IDS = [9, 13]; // per-node features in this fixed order (matches Python pipeline)
|
||||
const FIELD_LEN = 400; // signal_field.values padded/truncated to 400
|
||||
const CSI_DIM = 4 + NODE_IDS.length * 3 + FIELD_LEN; // 4 + 6 + 400 = 410
|
||||
const N_KP = 17, OUT_DIM = N_KP * 2; // 17 COCO keypoints -> 34 coords
|
||||
const BASELINE_SECONDS = 10; // empty-room calibration window
|
||||
const EPS = 1e-6;
|
||||
|
||||
// MediaPipe BlazePose (33) -> 17 COCO keypoints (identical to wiflow_capture.py / ADR-079)
|
||||
const COCO_FROM_MP = [0, 2, 5, 7, 8, 11, 12, 13, 14, 15, 16, 23, 24, 25, 26, 27, 28];
|
||||
const EDGES = [[5,7],[7,9],[6,8],[8,10],[5,6],[11,12],[5,11],[6,12],
|
||||
[11,13],[13,15],[12,14],[14,16],[0,1],[0,2],[1,3],[2,4],[0,5],[0,6]];
|
||||
|
||||
const $ = id => document.getElementById(id);
|
||||
function banner(state, txt){ const b=$('banner'); b.className=state; b.textContent=txt; }
|
||||
|
||||
// In-memory dataset of {csi:Float32Array(410, baseline-normalized), kps:Float32Array(34), bucket:int}
|
||||
let SAMPLES = [];
|
||||
// Latest live CSI frame + RAW 410-vector (baseline-normalization applied at use sites)
|
||||
let latestCSI = { t: 0, frame: null, vec: null, source: null, nodes: [] };
|
||||
// Empty-room baseline: per-feature mean + std (ADR-151)
|
||||
let baseline = null; // { mean:Float32Array(410), std:Float32Array(410), n:int, ts:number }
|
||||
|
||||
// ============================================================================
|
||||
// TF.js backend selection — WebGPU primary, WASM-SIMD fallback, WebGL last.
|
||||
// ============================================================================
|
||||
const BACKEND_LABEL = { webgpu:'WebGPU', wasm:'WASM-SIMD', webgl:'WebGL', cpu:'CPU (slow)' };
|
||||
let activeBackend = null;
|
||||
async function selectBackend(){
|
||||
try{ if (tf.wasm && tf.wasm.setWasmPaths)
|
||||
tf.wasm.setWasmPaths('https://cdn.jsdelivr.net/npm/@tensorflow/tfjs-backend-wasm@4.22.0/dist/'); }catch(e){}
|
||||
const tryBackend = async (name)=>{
|
||||
try{ const ok = await tf.setBackend(name); if (!ok) return false; await tf.ready();
|
||||
return tf.getBackend() === name; }
|
||||
catch(e){ console.warn('backend '+name+' unavailable:', e.message); return false; }
|
||||
};
|
||||
if (await tryBackend('webgpu')) activeBackend = 'webgpu';
|
||||
else if (await tryBackend('wasm')) activeBackend = 'wasm';
|
||||
else if (await tryBackend('webgl')) activeBackend = 'webgl';
|
||||
else { await tf.ready(); activeBackend = tf.getBackend(); }
|
||||
const badge = $('compute');
|
||||
badge.textContent = 'compute: ' + (BACKEND_LABEL[activeBackend] || activeBackend);
|
||||
badge.title = 'TensorFlow.js backend actually running (WebGPU → WASM-SIMD → WebGL)';
|
||||
return activeBackend;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CSI vector construction — MUST match wiflow_capture.py csi_vector() exactly.
|
||||
// [mean_rssi, variance, motion_band_power, breathing_band_power] (4 global)
|
||||
// + for node 9 then node 13: [mean_rssi, variance, motion_band_power] (6 per-node)
|
||||
// + signal_field.values padded/truncated to 400 (400 field)
|
||||
// = 410-d (RAW — baseline-normalization applied separately, see baselineNorm)
|
||||
// ============================================================================
|
||||
function csiVector(frame){
|
||||
const f = frame.features || {};
|
||||
const out = new Float32Array(CSI_DIM);
|
||||
let o = 0;
|
||||
out[o++] = +f.mean_rssi || 0;
|
||||
out[o++] = +f.variance || 0;
|
||||
out[o++] = +f.motion_band_power || 0;
|
||||
out[o++] = +f.breathing_band_power || 0;
|
||||
const perNode = {};
|
||||
for (const nf of (frame.node_features || [])) perNode[nf.node_id] = (nf.features || {});
|
||||
for (const nid of NODE_IDS){
|
||||
const nf = perNode[nid] || {};
|
||||
out[o++] = +nf.mean_rssi || 0;
|
||||
out[o++] = +nf.variance || 0;
|
||||
out[o++] = +nf.motion_band_power || 0;
|
||||
}
|
||||
const field = ((frame.signal_field || {}).values) || [];
|
||||
for (let i = 0; i < FIELD_LEN; i++) out[o++] = +field[i] || 0;
|
||||
return out;
|
||||
}
|
||||
|
||||
// ADR-151 baseline-deviation normalization: x_norm = (x - base_mean) / (base_std + eps).
|
||||
// Applied BEFORE the model's own input standardization, consistently everywhere.
|
||||
function baselineNorm(vecRaw){
|
||||
if (!baseline) return null;
|
||||
const out = new Float32Array(CSI_DIM);
|
||||
for (let j = 0; j < CSI_DIM; j++)
|
||||
out[j] = (vecRaw[j] - baseline.mean[j]) / (baseline.std[j] + EPS);
|
||||
return out;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CSI WebSocket
|
||||
// ============================================================================
|
||||
function connectCSI(){
|
||||
banner('down','CONNECTING…');
|
||||
let ws;
|
||||
try { ws = new WebSocket(CSI_WS); }
|
||||
catch(e){ banner('down','NO-CSI-SERVER — start sensing-server :8765'); setTimeout(connectCSI, 1500); return; }
|
||||
ws.onopen = ()=> banner('sim','WAITING FOR CSI…');
|
||||
ws.onmessage = ev => {
|
||||
let d; try { d = JSON.parse(ev.data); } catch(e){ return; }
|
||||
if (!d.features && !d.signal_field) return;
|
||||
const src = d.source || 'unknown';
|
||||
latestCSI = {
|
||||
t: performance.now(),
|
||||
frame: d,
|
||||
vec: csiVector(d), // RAW
|
||||
source: src,
|
||||
nodes: (d.nodes || []).map(n => n.node_id).filter(x => x != null).sort((a,b)=>a-b)
|
||||
};
|
||||
if (src === 'esp32') banner('live','LIVE — real ESP32 CSI');
|
||||
else banner('sim',`SIMULATED — not real (source=${src})`);
|
||||
};
|
||||
ws.onerror = ()=>{ try{ws.close();}catch(e){} };
|
||||
ws.onclose = ()=>{ banner('down','NO-CSI-SERVER — start sensing-server :8765'); setTimeout(connectCSI, 1500); };
|
||||
}
|
||||
function freshLiveCSI(){
|
||||
return latestCSI.frame && latestCSI.source === 'esp32' && (performance.now() - latestCSI.t) < 400;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Camera + MediaPipe Pose
|
||||
// ============================================================================
|
||||
let camStream = null;
|
||||
const camEl = document.createElement('video');
|
||||
camEl.autoplay = true; camEl.muted = true; camEl.playsInline = true;
|
||||
let mpPose = null, mpReady = false, mpBusy = false;
|
||||
let latestKps = null, latestVis = 0;
|
||||
|
||||
function initPose(){
|
||||
if (mpPose || typeof Pose === 'undefined') return;
|
||||
mpPose = new Pose({ locateFile: f => `https://cdn.jsdelivr.net/npm/@mediapipe/pose@0.5/${f}` });
|
||||
mpPose.setOptions({ modelComplexity:1, smoothLandmarks:true, enableSegmentation:false,
|
||||
minDetectionConfidence:0.5, minTrackingConfidence:0.5 });
|
||||
mpPose.onResults(onPoseResults);
|
||||
mpReady = true;
|
||||
}
|
||||
function onPoseResults(res){
|
||||
mpBusy = false;
|
||||
if (!res.poseLandmarks){ latestKps = null; latestVis = 0; return; }
|
||||
const lm = res.poseLandmarks;
|
||||
const kps = new Float32Array(OUT_DIM);
|
||||
let visSum = 0;
|
||||
for (let i = 0; i < N_KP; i++){
|
||||
const p = lm[COCO_FROM_MP[i]];
|
||||
kps[i*2] = p.x; kps[i*2+1] = p.y;
|
||||
visSum += (p.visibility != null ? p.visibility : 0);
|
||||
}
|
||||
latestKps = kps; latestVis = visSum / N_KP;
|
||||
}
|
||||
|
||||
async function startCam(deviceId){
|
||||
if (camStream) camStream.getTracks().forEach(t => t.stop());
|
||||
const constraints = deviceId ? { video:{ deviceId:{ exact:deviceId } } } : { video:true };
|
||||
const st = $('camStatus');
|
||||
try{
|
||||
st.textContent = 'camera: requesting…';
|
||||
camStream = await navigator.mediaDevices.getUserMedia(constraints);
|
||||
camEl.srcObject = camStream;
|
||||
await camEl.play().catch(()=>{});
|
||||
const tr = camStream.getVideoTracks()[0];
|
||||
const tick = ()=>{ st.textContent =
|
||||
`camera: "${tr.label}" ${camEl.videoWidth}x${camEl.videoHeight} ${tr.readyState} ${camEl.paused?'PAUSED':'playing'}`; };
|
||||
tick(); setInterval(tick, 1000);
|
||||
$('camBtn').textContent = 'switch camera ↻';
|
||||
$('recBtn').disabled = !baseline; // still gated on a baseline
|
||||
const devs = (await navigator.mediaDevices.enumerateDevices()).filter(d => d.kind === 'videoinput');
|
||||
const sel = $('camSel'); sel.style.display = devs.length > 1 ? 'inline-block' : 'none';
|
||||
sel.innerHTML = devs.map((d,i)=>`<option value="${d.deviceId}">${d.label || ('camera '+(i+1))}</option>`).join('');
|
||||
const cur = tr.getSettings().deviceId; if (cur) sel.value = cur;
|
||||
initPose();
|
||||
}catch(e){
|
||||
$('camBtn').textContent = 'camera error: ' + e.name +
|
||||
(e.name === 'NotReadableError' ? ' (in use by Zoom/Teams?)' : '');
|
||||
console.error('getUserMedia', e);
|
||||
}
|
||||
}
|
||||
$('camBtn').addEventListener('click', ()=> startCam());
|
||||
$('camSel').addEventListener('change', e => startCam(e.target.value));
|
||||
|
||||
// ============================================================================
|
||||
// Drawing helpers
|
||||
// ============================================================================
|
||||
function drawCameraFrame(ctx, W, H, alpha){
|
||||
if (camEl && camEl.videoWidth > 0){
|
||||
ctx.save(); ctx.globalAlpha = alpha;
|
||||
const vr = camEl.videoWidth / camEl.videoHeight, cr = W / H;
|
||||
let dw=W, dh=H, dx=0, dy=0;
|
||||
if (vr > cr){ dh=H; dw=H*vr; dx=(W-dw)/2; } else { dw=W; dh=W/vr; dy=(H-dh)/2; }
|
||||
ctx.drawImage(camEl, dx, dy, dw, dh); ctx.restore();
|
||||
return true;
|
||||
}
|
||||
ctx.fillStyle = '#070a0e'; ctx.fillRect(0,0,W,H);
|
||||
return false;
|
||||
}
|
||||
function drawSkeleton(ctx, kps, W, H, color, glow){
|
||||
const k = [];
|
||||
for (let i = 0; i < N_KP; i++) k.push([kps[i*2]*W, kps[i*2+1]*H]);
|
||||
ctx.lineWidth = 5; ctx.strokeStyle = color; ctx.lineCap = 'round';
|
||||
ctx.shadowColor = glow; ctx.shadowBlur = 8;
|
||||
for (const [a,b] of EDGES){ ctx.beginPath(); ctx.moveTo(k[a][0],k[a][1]); ctx.lineTo(k[b][0],k[b][1]); ctx.stroke(); }
|
||||
ctx.shadowBlur = 0;
|
||||
for (const [x,y] of k){ ctx.beginPath(); ctx.arc(x,y,5,0,7); ctx.fillStyle = color; ctx.fill(); }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Stage navigation + gating
|
||||
// ============================================================================
|
||||
const STAGES = ['calibrate','capture','train','infer'];
|
||||
let stageDone = { calibrate:false, capture:false, train:false };
|
||||
function stageUnlocked(name){
|
||||
if (name === 'calibrate') return true;
|
||||
if (name === 'capture') return stageDone.calibrate;
|
||||
if (name === 'train') return stageDone.calibrate && SAMPLES.length >= 200;
|
||||
if (name === 'infer') return !!model;
|
||||
return false;
|
||||
}
|
||||
function gotoStage(name){
|
||||
if (!stageUnlocked(name)) return;
|
||||
document.querySelectorAll('.step').forEach(s => s.classList.remove('on'));
|
||||
document.querySelectorAll('.panel').forEach(p => p.classList.remove('on'));
|
||||
document.querySelector(`.step[data-stage="${name}"]`).classList.add('on');
|
||||
$('stage-' + name).classList.add('on');
|
||||
}
|
||||
function refreshGates(){
|
||||
document.querySelectorAll('.step').forEach(s=>{
|
||||
const name = s.dataset.stage;
|
||||
s.classList.toggle('locked', !stageUnlocked(name));
|
||||
s.classList.toggle('done', !!stageDone[name]);
|
||||
});
|
||||
$('recBtn').disabled = !(baseline && camStream);
|
||||
refreshTrainAvail();
|
||||
}
|
||||
document.querySelectorAll('.step').forEach(s => s.addEventListener('click', ()=> gotoStage(s.dataset.stage)));
|
||||
|
||||
// ============================================================================
|
||||
// STAGE 0 · CALIBRATE — Welford running mean+std over the 410-d CSI vector
|
||||
// ============================================================================
|
||||
const calCtx = $('calCv').getContext('2d');
|
||||
let calibrating = false;
|
||||
let cw = null; // welford accumulators { n, mean:Float64Array, m2:Float64Array, t0 }
|
||||
|
||||
function startCalibration(){
|
||||
if (calibrating) return;
|
||||
cw = { n:0, mean:new Float64Array(CSI_DIM), m2:new Float64Array(CSI_DIM), t0:performance.now() };
|
||||
calibrating = true;
|
||||
$('calStatus').textContent = 'CALIBRATING…'; $('calStatus').className = 'v';
|
||||
$('calBtn').disabled = true;
|
||||
}
|
||||
function welfordUpdate(vec){
|
||||
cw.n++;
|
||||
for (let j = 0; j < CSI_DIM; j++){
|
||||
const d = vec[j] - cw.mean[j];
|
||||
cw.mean[j] += d / cw.n;
|
||||
cw.m2[j] += d * (vec[j] - cw.mean[j]);
|
||||
}
|
||||
}
|
||||
function finishCalibration(){
|
||||
calibrating = false;
|
||||
const mean = new Float32Array(CSI_DIM), std = new Float32Array(CSI_DIM);
|
||||
for (let j = 0; j < CSI_DIM; j++){
|
||||
mean[j] = cw.mean[j];
|
||||
std[j] = cw.n > 1 ? Math.sqrt(cw.m2[j] / (cw.n - 1)) : 0;
|
||||
}
|
||||
baseline = { mean, std, n: cw.n, ts: Date.now() };
|
||||
stageDone.calibrate = true;
|
||||
$('calStatus').textContent = 'CALIBRATED'; $('calStatus').className = 'v green';
|
||||
$('calN').textContent = cw.n; $('calBtn').disabled = false;
|
||||
$('calBar').style.width = '100%';
|
||||
saveBaseline();
|
||||
refreshGates();
|
||||
}
|
||||
$('calBtn').addEventListener('click', startCalibration);
|
||||
$('recalBtn').addEventListener('click', ()=>{ baseline = null; stageDone.calibrate = false;
|
||||
$('calStatus').textContent = 'NOT CALIBRATED'; $('calStatus').className = 'v';
|
||||
$('calBar').style.width = '0%'; $('calN').textContent = '0'; idbDel('baseline'); refreshGates(); startCalibration(); });
|
||||
|
||||
function calibrateLoop(){
|
||||
const W = $('calCv').width, H = $('calCv').height;
|
||||
calCtx.fillStyle = '#070a0e'; calCtx.fillRect(0,0,W,H);
|
||||
// little live trace of motion_band_power to show the channel is quiescent
|
||||
$('calSrc').textContent = latestCSI.source || '—';
|
||||
$('calSrc').className = latestCSI.source === 'esp32' ? 'v green' : 'v';
|
||||
if (baseline){
|
||||
const ageS = Math.round((Date.now() - baseline.ts)/1000);
|
||||
$('calAge').textContent = ageS < 60 ? ageS+' s ago' : Math.round(ageS/60)+' min ago';
|
||||
}
|
||||
if (calibrating){
|
||||
const el = (performance.now() - cw.t0) / 1000;
|
||||
$('calBar').style.width = Math.min(100, 100*el/BASELINE_SECONDS) + '%';
|
||||
// accumulate only fresh live frames; ignore sim so the baseline is real
|
||||
if (freshLiveCSI() && latestCSI.vec){ welfordUpdate(latestCSI.vec); $('calN').textContent = cw.n; }
|
||||
// draw a centered "STEP OUT" reminder + countdown
|
||||
calCtx.fillStyle = '#ffb840'; calCtx.font = 'bold 22px monospace'; calCtx.textAlign='center';
|
||||
calCtx.fillText('STEP OUT OF THE SPACE', W/2, H/2-10);
|
||||
calCtx.fillStyle = '#7d8796'; calCtx.font = '14px monospace';
|
||||
calCtx.fillText('baseline: '+Math.max(0,Math.ceil(BASELINE_SECONDS-el))+' s · '+cw.n+' frames', W/2, H/2+18);
|
||||
calCtx.textAlign='start';
|
||||
if (el >= BASELINE_SECONDS && cw.n > 0) finishCalibration();
|
||||
else if (el >= BASELINE_SECONDS*2){ // safety: timed out with no live frames
|
||||
calibrating = false; $('calStatus').textContent = 'NO LIVE CSI — check esp32'; $('calStatus').className='v red';
|
||||
$('calBtn').disabled = false;
|
||||
}
|
||||
} else {
|
||||
calCtx.fillStyle = baseline ? '#46e08a' : '#7d8796'; calCtx.font = '14px monospace'; calCtx.textAlign='center';
|
||||
calCtx.fillText(baseline ? 'baseline ready ('+baseline.n+' frames)' : 'click “calibrate baseline”', W/2, H/2);
|
||||
calCtx.textAlign='start';
|
||||
}
|
||||
requestAnimationFrame(calibrateLoop);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// STAGE 1 · GUIDED CAPTURE — balanced buckets + coverage meter
|
||||
// ============================================================================
|
||||
const capCtx = $('capCv').getContext('2d');
|
||||
let recording = false;
|
||||
// pose buckets (the guided routine cycles through these)
|
||||
const BUCKETS = ['stand still','turn left','turn right','walk left','walk right',
|
||||
'arms up','arms down','crouch','sit','reach'];
|
||||
const SECS_PER_BUCKET = 12;
|
||||
let bucketIx = 0, bucketT0 = performance.now();
|
||||
let covCounts = new Array(BUCKETS.length).fill(0);
|
||||
|
||||
function renderCoverage(){
|
||||
const max = Math.max(1, ...covCounts);
|
||||
$('cov').innerHTML = BUCKETS.map((b,i)=>
|
||||
`<div class="covrow"><span class="nm">${b}</span>`+
|
||||
`<span class="bar"><i style="width:${Math.round(100*covCounts[i]/max)}%"></i></span>`+
|
||||
`<span class="ct">${covCounts[i]}</span></div>`).join('');
|
||||
}
|
||||
$('recBtn').addEventListener('click', ()=>{
|
||||
if (!baseline || !camStream) return;
|
||||
recording = !recording;
|
||||
$('recBtn').textContent = recording ? '◼ stop recording' : '● start guided recording';
|
||||
$('recBtn').classList.toggle('ghost', recording);
|
||||
if (recording){ bucketT0 = performance.now(); }
|
||||
});
|
||||
$('clrBtn').addEventListener('click', async ()=>{
|
||||
SAMPLES = []; covCounts = new Array(BUCKETS.length).fill(0);
|
||||
await idbPut('samples', []);
|
||||
$('capN').textContent = '0'; $('trN').textContent = '0'; renderCoverage(); refreshGates();
|
||||
});
|
||||
|
||||
function captureLoop(){
|
||||
const W = $('capCv').width, H = $('capCv').height;
|
||||
drawCameraFrame(capCtx, W, H, 0.9);
|
||||
if (mpReady && !mpBusy && camEl.videoWidth > 0){
|
||||
mpBusy = true; mpPose.send({ image: camEl }).catch(()=>{ mpBusy = false; });
|
||||
}
|
||||
if (latestKps) drawSkeleton(capCtx, latestKps, W, H, 'rgba(90,169,255,.95)', 'rgba(90,169,255,.6)');
|
||||
|
||||
$('capSrc').textContent = latestCSI.source || '—';
|
||||
$('capSrc').className = latestCSI.source === 'esp32' ? 'v green' : 'v';
|
||||
$('capNodes').textContent = latestCSI.nodes.length ? latestCSI.nodes.join(', ') : '—';
|
||||
$('capVis').textContent = latestKps ? latestVis.toFixed(2) : '—';
|
||||
|
||||
if (recording){
|
||||
// advance the guided bucket
|
||||
const el = (performance.now() - bucketT0)/1000;
|
||||
if (el >= SECS_PER_BUCKET){ bucketIx = (bucketIx+1) % BUCKETS.length; bucketT0 = performance.now(); }
|
||||
$('prompt').textContent = BUCKETS[bucketIx];
|
||||
$('countdown').textContent = `${Math.max(0,Math.ceil(SECS_PER_BUCKET - el))} s · bucket ${bucketIx+1}/${BUCKETS.length}`;
|
||||
|
||||
let skip = null;
|
||||
if (!latestKps || latestVis <= 0.5) skip = 'no confident pose';
|
||||
else if (!freshLiveCSI()) skip = (latestCSI.source && latestCSI.source!=='esp32') ? 'CSI not esp32 (sim)' : 'no fresh CSI';
|
||||
else if (!baseline) skip = 'no baseline';
|
||||
if (skip){ $('capSkip').textContent = skip; }
|
||||
else {
|
||||
const norm = baselineNorm(latestCSI.vec); // baseline-deviation normalized
|
||||
SAMPLES.push({ csi: norm, kps: latestKps.slice(), bucket: bucketIx });
|
||||
covCounts[bucketIx]++;
|
||||
$('capSkip').textContent = '—';
|
||||
const n = SAMPLES.length; $('capN').textContent = n; $('trN').textContent = n;
|
||||
if (n % 20 === 0){ renderCoverage(); idbSave(); refreshGates(); }
|
||||
}
|
||||
} else {
|
||||
$('prompt').textContent = baseline ? 'ready — press start' : 'calibrate baseline first';
|
||||
$('countdown').textContent = '—';
|
||||
}
|
||||
requestAnimationFrame(captureLoop);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// IndexedDB persistence
|
||||
// ============================================================================
|
||||
const IDB_NAME = 'wiflow-browser', IDB_STORE = 'kv';
|
||||
function idbOpen(){
|
||||
return new Promise((res, rej)=>{
|
||||
const r = indexedDB.open(IDB_NAME, 1);
|
||||
r.onupgradeneeded = ()=> r.result.createObjectStore(IDB_STORE);
|
||||
r.onsuccess = ()=> res(r.result); r.onerror = ()=> rej(r.error);
|
||||
});
|
||||
}
|
||||
async function idbPut(key, val){
|
||||
const db = await idbOpen();
|
||||
return new Promise((res, rej)=>{
|
||||
const tx = db.transaction(IDB_STORE, 'readwrite');
|
||||
tx.objectStore(IDB_STORE).put(val, key); tx.oncomplete = res; tx.onerror = ()=> rej(tx.error);
|
||||
});
|
||||
}
|
||||
async function idbGet(key){
|
||||
const db = await idbOpen();
|
||||
return new Promise((res, rej)=>{
|
||||
const tx = db.transaction(IDB_STORE, 'readonly');
|
||||
const r = tx.objectStore(IDB_STORE).get(key);
|
||||
r.onsuccess = ()=> res(r.result); r.onerror = ()=> rej(r.error);
|
||||
});
|
||||
}
|
||||
async function idbDel(key){
|
||||
const db = await idbOpen();
|
||||
return new Promise((res, rej)=>{
|
||||
const tx = db.transaction(IDB_STORE, 'readwrite');
|
||||
tx.objectStore(IDB_STORE).delete(key); tx.oncomplete = res; tx.onerror = ()=> rej(tx.error);
|
||||
});
|
||||
}
|
||||
async function idbSave(){
|
||||
try{
|
||||
const flat = SAMPLES.map(s => ({ csi: Array.from(s.csi), kps: Array.from(s.kps), bucket: s.bucket }));
|
||||
await idbPut('samples', flat);
|
||||
}catch(e){ console.warn('idbSave', e); }
|
||||
}
|
||||
async function idbLoad(){
|
||||
try{
|
||||
const flat = await idbGet('samples');
|
||||
if (Array.isArray(flat) && flat.length){
|
||||
SAMPLES = flat.map(s => ({ csi: Float32Array.from(s.csi), kps: Float32Array.from(s.kps), bucket: s.bucket||0 }));
|
||||
covCounts = new Array(BUCKETS.length).fill(0);
|
||||
for (const s of SAMPLES) if (s.bucket < BUCKETS.length) covCounts[s.bucket]++;
|
||||
$('capN').textContent = SAMPLES.length; $('trN').textContent = SAMPLES.length;
|
||||
}
|
||||
}catch(e){ console.warn('idbLoad', e); }
|
||||
}
|
||||
async function saveBaseline(){
|
||||
try{ await idbPut('baseline', { mean: Array.from(baseline.mean), std: Array.from(baseline.std), n: baseline.n, ts: baseline.ts }); }
|
||||
catch(e){ console.warn('saveBaseline', e); }
|
||||
}
|
||||
async function loadBaseline(){
|
||||
try{
|
||||
const b = await idbGet('baseline');
|
||||
if (b && b.mean){
|
||||
baseline = { mean: Float32Array.from(b.mean), std: Float32Array.from(b.std), n: b.n, ts: b.ts };
|
||||
stageDone.calibrate = true;
|
||||
$('calStatus').textContent = 'CALIBRATED (restored)'; $('calStatus').className = 'v green';
|
||||
$('calN').textContent = baseline.n; $('calBar').style.width = '100%';
|
||||
}
|
||||
}catch(e){ /* none yet */ }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// STAGE 2 · TRAIN (TensorFlow.js)
|
||||
// ============================================================================
|
||||
let model = null, normMu = null, normSd = null, trainedPck10 = null, trainStop = false;
|
||||
const lossCtx = $('lossCv').getContext('2d');
|
||||
let lossHist = [];
|
||||
|
||||
function refreshTrainAvail(){
|
||||
const ok = !!baseline && SAMPLES.length >= 200;
|
||||
$('trainBtn').disabled = !ok;
|
||||
if (!baseline) $('trMsg').innerHTML = 'Calibrate a baseline first (stage 0).';
|
||||
else $('trMsg').innerHTML = SAMPLES.length >= 200
|
||||
? `Ready: ${SAMPLES.length} samples. Click <b>train model</b>.`
|
||||
: `Need ≥200 samples to train (have ${SAMPLES.length}). Capture more in stage 1.`;
|
||||
}
|
||||
|
||||
function buildMatrices(){
|
||||
const n = SAMPLES.length;
|
||||
const X = new Float32Array(n * CSI_DIM), Y = new Float32Array(n * OUT_DIM);
|
||||
for (let i = 0; i < n; i++){ X.set(SAMPLES[i].csi, i*CSI_DIM); Y.set(SAMPLES[i].kps, i*OUT_DIM); }
|
||||
return { X, Y, n };
|
||||
}
|
||||
function pckMpjpe(predArr, gtArr, m, thr){
|
||||
let hit = 0, tot = 0, dsum = 0;
|
||||
for (let i = 0; i < m; i++) for (let j = 0; j < N_KP; j++){
|
||||
const dx = predArr[i*OUT_DIM+j*2]-gtArr[i*OUT_DIM+j*2];
|
||||
const dy = predArr[i*OUT_DIM+j*2+1]-gtArr[i*OUT_DIM+j*2+1];
|
||||
const d = Math.hypot(dx, dy);
|
||||
if (d < thr) hit++; dsum += d; tot++;
|
||||
}
|
||||
return { pck: tot?hit/tot:0, mpjpe: tot?dsum/tot:NaN };
|
||||
}
|
||||
function drawLoss(){
|
||||
const W = $('lossCv').width, H = $('lossCv').height;
|
||||
lossCtx.fillStyle = '#070a0e'; lossCtx.fillRect(0,0,W,H);
|
||||
if (lossHist.length < 2) return;
|
||||
let mx = 0; for (const p of lossHist) mx = Math.max(mx, p.tr, p.va||0); mx = mx||1;
|
||||
const X = i => 8 + (W-16)*i/(lossHist.length-1);
|
||||
const Yv = v => H-8 - (H-16)*Math.min(v/mx,1);
|
||||
const line = (key,color)=>{
|
||||
lossCtx.strokeStyle=color; lossCtx.lineWidth=2; lossCtx.beginPath(); let st=false;
|
||||
lossHist.forEach((p,i)=>{ const v=p[key]; if(v==null) return;
|
||||
const x=X(i), y=Yv(v); st?lossCtx.lineTo(x,y):lossCtx.moveTo(x,y); st=true; });
|
||||
lossCtx.stroke();
|
||||
};
|
||||
line('tr','#ffb840'); line('va','#5aa9ff');
|
||||
}
|
||||
|
||||
async function trainModel(){
|
||||
if (!baseline || SAMPLES.length < 200) return;
|
||||
trainStop = false;
|
||||
$('trainBtn').disabled = true; $('trStop').disabled = false; lossHist = [];
|
||||
const epochs = Math.max(20, Math.min(600, parseInt($('trEpochs').value)||200));
|
||||
const { X, Y, n } = buildMatrices(); // X is already baseline-normalized
|
||||
const cut = Math.floor(n*0.8);
|
||||
$('trSplit').textContent = `${cut} / ${n-cut} (chronological 80/20)`;
|
||||
|
||||
// input standardization on TRAIN split only (on top of baseline-normalization)
|
||||
normMu = new Float32Array(CSI_DIM); normSd = new Float32Array(CSI_DIM);
|
||||
for (let j = 0; j < CSI_DIM; j++){
|
||||
let s=0; for (let i=0;i<cut;i++) s += X[i*CSI_DIM+j];
|
||||
const mu=s/cut; normMu[j]=mu;
|
||||
let v=0; for (let i=0;i<cut;i++){ const d=X[i*CSI_DIM+j]-mu; v+=d*d; }
|
||||
normSd[j]=Math.sqrt(v/cut)+EPS;
|
||||
}
|
||||
const Xn = new Float32Array(n*CSI_DIM);
|
||||
for (let i=0;i<n;i++) for (let j=0;j<CSI_DIM;j++) Xn[i*CSI_DIM+j]=(X[i*CSI_DIM+j]-normMu[j])/normSd[j];
|
||||
|
||||
// mean-pose baseline — the bar to beat
|
||||
const meanPose = new Float32Array(OUT_DIM);
|
||||
for (let i=0;i<cut;i++) for (let j=0;j<OUT_DIM;j++) meanPose[j]+=Y[i*OUT_DIM+j];
|
||||
for (let j=0;j<OUT_DIM;j++) meanPose[j]/=cut;
|
||||
const mVal = n-cut;
|
||||
const basePred = new Float32Array(mVal*OUT_DIM);
|
||||
for (let i=0;i<mVal;i++) basePred.set(meanPose, i*OUT_DIM);
|
||||
const gtVal = Y.slice(cut*OUT_DIM);
|
||||
const base = pckMpjpe(basePred, gtVal, mVal, 0.10);
|
||||
$('trBase').textContent = (base.pck*100).toFixed(1)+'%';
|
||||
|
||||
const xtr = tf.tensor2d(Xn.slice(0,cut*CSI_DIM),[cut,CSI_DIM]);
|
||||
const ytr = tf.tensor2d(Y.slice(0,cut*OUT_DIM),[cut,OUT_DIM]);
|
||||
const xva = tf.tensor2d(Xn.slice(cut*CSI_DIM),[mVal,CSI_DIM]);
|
||||
|
||||
if (model){ model.dispose(); }
|
||||
model = tf.sequential();
|
||||
model.add(tf.layers.dense({ inputShape:[CSI_DIM], units:512, activation:'relu' }));
|
||||
model.add(tf.layers.dropout({ rate:0.3 }));
|
||||
model.add(tf.layers.dense({ units:256, activation:'relu' }));
|
||||
model.add(tf.layers.dropout({ rate:0.3 }));
|
||||
model.add(tf.layers.dense({ units:128, activation:'relu' }));
|
||||
model.add(tf.layers.dense({ units:OUT_DIM, activation:'sigmoid' }));
|
||||
model.compile({ optimizer: tf.train.adam(1e-3), loss:'meanSquaredError' });
|
||||
|
||||
let bestP10 = 0, bestVal = 1e9;
|
||||
$('trMsg').innerHTML = 'Training… on <code>'+(BACKEND_LABEL[tf.getBackend()]||tf.getBackend())+'</code>';
|
||||
await model.fit(xtr, ytr, {
|
||||
epochs, batchSize:64, shuffle:true, verbose:0,
|
||||
callbacks:{ onEpochEnd: async (ep, logs)=>{
|
||||
let va=null,p10=null,p05=null,mpj=null;
|
||||
if (ep % 5 === 0 || ep === epochs-1){
|
||||
const pv = model.predict(xva); const pvArr = await pv.data(); pv.dispose();
|
||||
let vsum=0; for (let i=0;i<pvArr.length;i++){ const d=pvArr[i]-gtVal[i]; vsum+=d*d; }
|
||||
va = vsum/pvArr.length;
|
||||
const r10=pckMpjpe(pvArr,gtVal,mVal,0.10), r05=pckMpjpe(pvArr,gtVal,mVal,0.05);
|
||||
p10=r10.pck; p05=r05.pck; mpj=r10.mpjpe;
|
||||
$('trP10').textContent=(p10*100).toFixed(1)+'%'; $('trP05').textContent=(p05*100).toFixed(1)+'%';
|
||||
$('trMpj').textContent=mpj.toFixed(4); $('trVal').textContent=va.toFixed(4);
|
||||
if (va<bestVal){ bestVal=va; bestP10=p10; }
|
||||
}
|
||||
$('trEpoch').textContent=(ep+1); $('trLoss').textContent=logs.loss.toFixed(4);
|
||||
$('trBar').style.width=(100*(ep+1)/epochs)+'%';
|
||||
lossHist.push({ ep, tr:logs.loss, va }); drawLoss();
|
||||
if (trainStop) model.stopTraining = true;
|
||||
await tf.nextFrame();
|
||||
}}
|
||||
});
|
||||
|
||||
const pvF = model.predict(xva); const pvFArr = await pvF.data(); pvF.dispose();
|
||||
const fin10 = pckMpjpe(pvFArr,gtVal,mVal,0.10), fin05 = pckMpjpe(pvFArr,gtVal,mVal,0.05);
|
||||
const finPck = Math.max(bestP10, fin10.pck); trainedPck10 = finPck;
|
||||
$('trP10').textContent=(fin10.pck*100).toFixed(1)+'%'; $('trP05').textContent=(fin05.pck*100).toFixed(1)+'%';
|
||||
$('trMpj').textContent=fin10.mpjpe.toFixed(4); $('infPck').textContent=(finPck*100).toFixed(1)+'%';
|
||||
|
||||
const delta = (finPck - base.pck)*100;
|
||||
const v = $('verdict');
|
||||
if (delta > 1){
|
||||
v.className='verdict good';
|
||||
v.innerHTML = `model <b>BEATS</b> mean-pose baseline by <b>+${delta.toFixed(1)} pp</b> → real CSI→pose signal.`;
|
||||
} else {
|
||||
v.className='verdict bad';
|
||||
v.innerHTML = `model does <b>NOT</b> beat baseline (Δ ${delta.toFixed(1)} pp) → <b>no usable signal (honest)</b>. Capture more / more varied data.`;
|
||||
}
|
||||
stageDone.train = true;
|
||||
$('infModelState').textContent = `model ready · held-out PCK@0.10 ${(finPck*100).toFixed(1)}%`;
|
||||
$('trMsg').innerHTML = 'Done. Saving model to IndexedDB…';
|
||||
xtr.dispose(); ytr.dispose(); xva.dispose();
|
||||
await saveModel();
|
||||
$('trMsg').innerHTML = 'Saved. Go to <b>3 · INFER</b> to see WiFi drive the skeleton.';
|
||||
$('trainBtn').disabled = false; $('trStop').disabled = true;
|
||||
refreshGates();
|
||||
}
|
||||
$('trainBtn').addEventListener('click', trainModel);
|
||||
$('trStop').addEventListener('click', ()=>{ trainStop = true; });
|
||||
|
||||
async function saveModel(){
|
||||
if (!model) return;
|
||||
try{
|
||||
await model.save('indexeddb://wiflow-model');
|
||||
await idbPut('norm', { mu:Array.from(normMu), sd:Array.from(normSd), pck10:trainedPck10 });
|
||||
}catch(e){ console.warn('saveModel', e); }
|
||||
}
|
||||
async function loadModel(){
|
||||
try{
|
||||
const m = await tf.loadLayersModel('indexeddb://wiflow-model');
|
||||
const norm = await idbGet('norm');
|
||||
if (m && norm){
|
||||
model = m; normMu = Float32Array.from(norm.mu); normSd = Float32Array.from(norm.sd);
|
||||
trainedPck10 = norm.pck10; stageDone.train = true;
|
||||
$('infPck').textContent = trainedPck10!=null ? (trainedPck10*100).toFixed(1)+'%' : '—';
|
||||
$('infModelState').textContent = `model loaded · held-out PCK@0.10 ${trainedPck10!=null?(trainedPck10*100).toFixed(1)+'%':'?'}`;
|
||||
}
|
||||
}catch(e){ /* none yet */ }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// STAGE 3 · INFER — live CSI → baseline-normalize → standardize → model
|
||||
// ============================================================================
|
||||
const infCtx = $('infCv').getContext('2d');
|
||||
let infSm = null, infFrames = 0, infT0 = performance.now();
|
||||
function inferSmooth(kps){
|
||||
if (!infSm){ infSm = Float32Array.from(kps); return infSm; }
|
||||
const a = 0.35; for (let i=0;i<kps.length;i++) infSm[i]+=a*(kps[i]-infSm[i]);
|
||||
return infSm;
|
||||
}
|
||||
function inferLoop(){
|
||||
const W = $('infCv').width, H = $('infCv').height;
|
||||
const showCam = !$('hideCam').checked;
|
||||
if (showCam) drawCameraFrame(infCtx, W, H, 0.85);
|
||||
else { infCtx.fillStyle='#070a0e'; infCtx.fillRect(0,0,W,H); }
|
||||
|
||||
$('infSrc').textContent = latestCSI.source || '—';
|
||||
$('infSrc').className = latestCSI.source === 'esp32' ? 'v green' : 'v';
|
||||
$('infNodes').textContent = latestCSI.nodes.length ? latestCSI.nodes.join(', ') : '—';
|
||||
const cls = (latestCSI.frame && latestCSI.frame.classification) || {};
|
||||
$('infPres').textContent = cls.presence ? 'PRESENT' : '—';
|
||||
|
||||
if (model && normMu && baseline && latestCSI.vec){
|
||||
const out = tf.tidy(()=>{
|
||||
const xn = new Float32Array(CSI_DIM);
|
||||
for (let j=0;j<CSI_DIM;j++){
|
||||
const bn = (latestCSI.vec[j]-baseline.mean[j])/(baseline.std[j]+EPS); // baseline-normalize
|
||||
xn[j] = (bn - normMu[j])/normSd[j]; // then standardize
|
||||
}
|
||||
return model.predict(tf.tensor2d(xn,[1,CSI_DIM]));
|
||||
});
|
||||
out.data().then(arr=>{
|
||||
const sm = inferSmooth(arr); const present = !!cls.presence;
|
||||
drawSkeleton(infCtx, sm, W, H,
|
||||
present?'rgba(70,224,138,.95)':'rgba(125,135,150,.85)','rgba(70,224,138,.6)');
|
||||
out.dispose();
|
||||
}).catch(()=> out.dispose());
|
||||
infFrames++;
|
||||
} else {
|
||||
infCtx.fillStyle='#7d8796'; infCtx.font='13px monospace';
|
||||
infCtx.fillText(model?'waiting for CSI…':'train a model first (stage 2)', 20, 30);
|
||||
}
|
||||
const now = performance.now();
|
||||
if (now-infT0 > 1000){ $('infFps').textContent = infFrames; infFrames = 0; infT0 = now; }
|
||||
requestAnimationFrame(inferLoop);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Boot
|
||||
// ============================================================================
|
||||
(async function boot(){
|
||||
connectCSI();
|
||||
await selectBackend();
|
||||
await loadBaseline();
|
||||
await idbLoad();
|
||||
await loadModel();
|
||||
renderCoverage();
|
||||
refreshGates();
|
||||
requestAnimationFrame(calibrateLoop);
|
||||
requestAnimationFrame(captureLoop);
|
||||
requestAnimationFrame(inferLoop);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Reference in New Issue