Skip to content

Commit d639c74

Browse files
authored
Merge pull request ruvnet#1114 from ruvnet/examples/through-wall-tools
examples(through-wall): ESP32 sensor auto-detection + WiFlow analysis tools
2 parents db02956 + 42c7646 commit d639c74

7 files changed

Lines changed: 747 additions & 6 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,3 +277,6 @@ aether-arena/staging/
277277
# MM-Fi benchmark dataset archives — large data, fetch separately, never commit
278278
assets/MM-Fi/E0*.zip
279279
assets/MM-Fi/*.zip
280+
281+
# through-wall demo: regenerable trained model artifact
282+
examples/through-wall/model/

examples/through-wall/pose.html

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8"/>
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
6+
<title>WiFlow · live WiFi-inferred pose</title>
7+
<style>
8+
:root{--bg:#0a0c10;--panel:#11151c;--amber:#ffb840;--green:#46e08a;--red:#ff5a5a;--mute:#7d8796;--line:#1d2430}
9+
*{box-sizing:border-box}
10+
body{margin:0;background:var(--bg);color:#dfe6ee;font:14px/1.5 'JetBrains Mono',ui-monospace,Menlo,monospace}
11+
header{padding:14px 18px;border-bottom:1px solid var(--line);display:flex;align-items:center;gap:14px;flex-wrap:wrap}
12+
h1{font-size:15px;margin:0;letter-spacing:1px;text-transform:uppercase;font-weight:600}
13+
h1 span{color:var(--amber)}
14+
#banner{margin-left:auto;padding:5px 12px;border-radius:5px;font-weight:600;font-size:12px;letter-spacing:.5px}
15+
.live{background:rgba(70,224,138,.15);color:var(--green);border:1px solid var(--green)}
16+
.sim{background:rgba(255,184,64,.15);color:var(--amber);border:1px solid var(--amber)}
17+
.down{background:rgba(255,90,90,.15);color:var(--red);border:1px solid var(--red)}
18+
main{display:flex;gap:18px;padding:18px;flex-wrap:wrap}
19+
.card{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:14px}
20+
canvas{background:#070a0e;border-radius:8px;display:block}
21+
.label{font-size:11px;text-transform:uppercase;letter-spacing:1.5px;color:var(--mute);margin-bottom:8px}
22+
.stats{min-width:240px}
23+
.row{display:flex;justify-content:space-between;padding:3px 0;border-bottom:1px dashed var(--line)}
24+
.row .k{color:var(--mute)} .row .v{color:var(--amber);font-variant-numeric:tabular-nums}
25+
.v.green{color:var(--green)}
26+
.note{margin-top:12px;font-size:11px;color:var(--mute);line-height:1.6;max-width:300px}
27+
.note b{color:#dfe6ee}
28+
</style>
29+
</head>
30+
<body>
31+
<header>
32+
<h1>WiFlow · <span>live WiFi-inferred pose</span></h1>
33+
<div id="banner" class="down">CONNECTING…</div>
34+
</header>
35+
<main>
36+
<div class="card">
37+
<div class="label">CSI → pose (skeleton) overlaid on your laptop camera</div>
38+
<div id="stage" style="width:420px;height:560px;border-radius:8px;overflow:hidden;background:#070a0e">
39+
<video id="cam" autoplay muted playsinline style="position:absolute;width:2px;height:2px;opacity:0;pointer-events:none"></video>
40+
<canvas id="cv" width="420" height="560"></canvas>
41+
</div>
42+
<div style="margin-top:10px;display:flex;gap:8px;align-items:center;flex-wrap:wrap">
43+
<button id="camBtn" style="background:var(--amber);color:#0a0c10;border:0;border-radius:6px;padding:7px 14px;font:inherit;font-weight:600;cursor:pointer">enable laptop camera</button>
44+
<select id="camSel" style="display:none;background:var(--panel);color:#dfe6ee;border:1px solid var(--line);border-radius:6px;padding:6px;font:inherit;max-width:220px"></select>
45+
</div>
46+
<div id="camStatus" style="margin-top:6px;font-size:11px;color:var(--mute)">camera: off</div>
47+
<div class="note" style="margin-top:8px">Camera is a <b>visual reference only</b> — it is NOT fed to the model. Overlay alignment is approximate (model trained in a different camera's frame).</div>
48+
</div>
49+
<div class="card stats">
50+
<div class="label">live</div>
51+
<div class="row"><span class="k">CSI source</span><span class="v" id="src"></span></div>
52+
<div class="row"><span class="k">nodes</span><span class="v" id="nodes"></span></div>
53+
<div class="row"><span class="k">presence</span><span class="v" id="pres"></span></div>
54+
<div class="row"><span class="k">motion</span><span class="v" id="motion"></span></div>
55+
<div class="row"><span class="k">pose fps</span><span class="v" id="fps"></span></div>
56+
<div class="note">
57+
This skeleton is inferred <b>from WiFi CSI only</b> — no camera in the loop here. A model was
58+
trained on paired (camera-pose, CSI) data in this room (ADR-079/180).
59+
<br/><br/>
60+
<b>Honest accuracy:</b> ~<b>59.5% PCK@0.10</b> on held-out data (vs a 50% mean-pose baseline →
61+
<b>+9.4 pp real signal</b>). It captures <b>coarse</b> pose; fine detail is weak (PCK@0.05 ≈ 24%).
62+
Same person / room / session — not validated cross-day or through-wall.
63+
</div>
64+
</div>
65+
</main>
66+
<script>
67+
const POSE_WS = (new URLSearchParams(location.search)).get('ws') || `ws://${location.hostname||'localhost'}:8770/pose`;
68+
const cv = document.getElementById('cv'), ctx = cv.getContext('2d');
69+
const $ = id => document.getElementById(id);
70+
let 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]];
71+
let last = null, frames = 0, t0 = performance.now();
72+
73+
function banner(state, txt){ const b=$('banner'); b.className=state; b.textContent=txt; }
74+
75+
// per-joint smoothing (EMA) so dropped/jittery CSI frames render fluidly (ADR-180 dead-reckoning, lite)
76+
let sm = null;
77+
function smooth(kps){
78+
if(!sm){ sm = kps.map(p=>[p[0],p[1]]); return sm; }
79+
const a=0.35; for(let i=0;i<kps.length;i++){ sm[i][0]+=a*(kps[i][0]-sm[i][0]); sm[i][1]+=a*(kps[i][1]-sm[i][1]); }
80+
return sm;
81+
}
82+
const camEl=document.getElementById('cam');
83+
function draw(p){
84+
const W=cv.width, H=cv.height;
85+
// paint the live camera frame onto the canvas (robust — no z-index/overlay tricks)
86+
if(camEl && camEl.videoWidth>0){
87+
ctx.save(); ctx.globalAlpha=0.9;
88+
// cover-fit the camera frame into the canvas
89+
const vr=camEl.videoWidth/camEl.videoHeight, cr=W/H;
90+
let dw=W, dh=H, dx=0, dy=0;
91+
if(vr>cr){ dh=H; dw=H*vr; dx=(W-dw)/2; } else { dw=W; dh=W/vr; dy=(H-dh)/2; }
92+
ctx.drawImage(camEl, dx, dy, dw, dh); ctx.restore();
93+
} else {
94+
ctx.fillStyle='#070a0e'; ctx.fillRect(0,0,W,H);
95+
}
96+
if(!p || !p.kps){ return; }
97+
const s = smooth(p.kps);
98+
const k = s.map(([x,y])=>[x*W, y*H]);
99+
ctx.lineWidth=5; ctx.strokeStyle=p.presence?'rgba(70,224,138,.95)':'rgba(125,135,150,.8)'; ctx.lineCap='round';
100+
ctx.shadowColor='rgba(70,224,138,.6)'; ctx.shadowBlur=8;
101+
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(); }
102+
ctx.shadowBlur=0;
103+
for(const [x,y] of k){ ctx.beginPath(); ctx.arc(x,y,5,0,7); ctx.fillStyle=p.presence?'#ffb840':'#667'; ctx.fill(); }
104+
}
105+
106+
// ---- laptop webcam (visual reference only; NOT fed to the model) ----
107+
let camStream=null;
108+
async function startCam(deviceId){
109+
if(camStream){ camStream.getTracks().forEach(t=>t.stop()); }
110+
const constraints = deviceId ? {video:{deviceId:{exact:deviceId}}} : {video:true};
111+
const st=document.getElementById('camStatus');
112+
try{
113+
st.textContent='camera: requesting…';
114+
camStream = await navigator.mediaDevices.getUserMedia(constraints);
115+
const v=document.getElementById('cam'); v.muted=true; v.srcObject=camStream;
116+
v.onloadedmetadata=()=>{ v.play().catch(err=>st.textContent='camera: play() blocked '+err.name); };
117+
await v.play().catch(()=>{});
118+
const tr=camStream.getVideoTracks()[0]; const ss=tr.getSettings();
119+
// live readout: shows if real frames are flowing (videoWidth>0) and which device
120+
const tick=()=>{ st.textContent = `camera: "${tr.label}" ${v.videoWidth}x${v.videoHeight} ${tr.readyState} ${v.paused?'PAUSED':'playing'}`; };
121+
tick(); setInterval(tick, 1000);
122+
document.getElementById('camBtn').textContent='switch camera ↻';
123+
// populate the picker now that we have permission (labels need permission)
124+
const devs = (await navigator.mediaDevices.enumerateDevices()).filter(d=>d.kind==='videoinput');
125+
const sel=document.getElementById('camSel'); sel.style.display = devs.length>1?'inline-block':'none';
126+
sel.innerHTML = devs.map((d,i)=>`<option value="${d.deviceId}">${d.label||('camera '+(i+1))}</option>`).join('');
127+
const cur = camStream.getVideoTracks()[0].getSettings().deviceId; if(cur) sel.value=cur;
128+
}catch(e){
129+
document.getElementById('camBtn').textContent = 'camera error: '+e.name+(e.name==='NotReadableError'?' (in use by Zoom/Teams?)':'');
130+
console.error('getUserMedia', e);
131+
}
132+
}
133+
document.getElementById('camBtn').addEventListener('click', ()=>startCam());
134+
document.getElementById('camSel').addEventListener('change', e=>startCam(e.target.value));
135+
136+
function connect(){
137+
banner('down','CONNECTING…');
138+
const ws = new WebSocket(POSE_WS);
139+
ws.onopen = ()=> banner('sim','WAITING FOR POSE…');
140+
ws.onmessage = ev => {
141+
const d = JSON.parse(ev.data);
142+
if(d.type==='meta'){ edges = d.edges; return; }
143+
if(d.type!=='pose') return;
144+
last=d; frames++;
145+
if(d.src==='esp32') banner('live','LIVE — WiFi-inferred pose (real ESP32 CSI)');
146+
else banner('sim','SIMULATED CSI — not real ('+d.src+')');
147+
$('src').textContent=d.src; $('src').className = d.src==='esp32'?'v green':'v';
148+
$('nodes').textContent=(d.nodes||[]).join(', ')||'—';
149+
$('pres').textContent=d.presence?'PRESENT':'—';
150+
$('motion').textContent=(d.motion!=null?Math.round(d.motion):'—');
151+
};
152+
ws.onclose = ()=>{ banner('down','NO BRIDGE — start wiflow_infer.py'); setTimeout(connect,1500); };
153+
ws.onerror = ()=> ws.close();
154+
}
155+
function loop(){ draw(last); const now=performance.now(); if(now-t0>1000){ $('fps').textContent=frames; frames=0; t0=now; } requestAnimationFrame(loop); }
156+
connect(); loop();
157+
</script>
158+
</body>
159+
</html>

examples/through-wall/wiflow_ab.py

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
#!/usr/bin/env python3
2+
"""Rigorous A/B for WiFlow CSI->pose: is the held-out PCK real signal or split leakage?
3+
4+
For a dataset of {csi:[D], kps:17x[x,y,vis]} pairs, train the SAME small MLP under
5+
several train/val SPLITS and report held-out PCK@0.10 vs the mean-pose baseline:
6+
7+
- chronological_80_20 : last 20% in time (val temporally ADJACENT to train -> leaks
8+
via CSI/pose autocorrelation; this is what gave us +9.4)
9+
- random_80_20 : shuffled (val frames interleaved with train -> MAX leak)
10+
- blocked_gap : hold out a contiguous MIDDLE block with a time GAP buffer on
11+
each side so val is NOT adjacent to any train frame -> the
12+
honest, leakage-controlled test
13+
14+
If the model beats baseline on chronological/random but COLLAPSES to ~baseline on
15+
blocked_gap, the apparent signal was temporal leakage, not generalizable CSI->pose.
16+
17+
Usage (ruvultra venv): python wiflow_ab.py --data ~/wiflow-room/dataset.jsonl
18+
"""
19+
import argparse, json, sys
20+
import numpy as np, torch, torch.nn as nn
21+
22+
def _rec(r, X, Y, V, B):
23+
X.append(r["csi"]); kp=r["kps"]
24+
if kp and isinstance(kp[0], (list,tuple)): # 17 x [x,y(,vis)]
25+
Y.append([c for k in kp for c in (k[0],k[1])]); V.append([(k[2] if len(k)>2 else 1.0) for k in kp])
26+
else: # flat 34 (browser export, no vis)
27+
Y.append(list(kp)); V.append([1.0]*17)
28+
B.append(r.get("bucket"))
29+
30+
def load(path):
31+
X,Y,V,B=[],[],[],[]
32+
txt=open(path).read().strip()
33+
if txt[:1] in "[{": # JSON (browser export: dict{samples:[]} or bare array)
34+
d=json.loads(txt)
35+
rows = d if isinstance(d,list) else d.get("samples", d.get("data", []))
36+
for r in rows: _rec(r,X,Y,V,B)
37+
else: # JSONL (python capture)
38+
for line in txt.splitlines():
39+
if line.strip(): _rec(json.loads(line),X,Y,V,B)
40+
return np.array(X,np.float32), np.array(Y,np.float32), np.array(V,np.float32), B
41+
42+
class Net(nn.Module):
43+
def __init__(s,din,dout):
44+
super().__init__()
45+
s.n=nn.Sequential(nn.Linear(din,384),nn.ReLU(),nn.Dropout(.35),
46+
nn.Linear(384,192),nn.ReLU(),nn.Dropout(.35),
47+
nn.Linear(192,96),nn.ReLU(),nn.Linear(96,dout),nn.Sigmoid())
48+
def forward(s,x): return s.n(x)
49+
50+
def pck(pred,gt,vis,thr=0.10):
51+
p=pred.reshape(-1,17,2); g=gt.reshape(-1,17,2)
52+
d=np.linalg.norm(p-g,axis=2); m=vis>0.5
53+
return float((d[m]<thr).mean()) if m.any() else 0.0
54+
55+
def split_idx(n, kind, B=None):
56+
idx=np.arange(n)
57+
if kind=="chronological_80_20":
58+
c=int(n*.8); return idx[:c], idx[c:]
59+
if kind=="random_80_20":
60+
rng=np.random.default_rng(0); p=rng.permutation(n); c=int(n*.8); return p[:c], p[c:]
61+
if kind=="blocked_gap":
62+
# val = contiguous middle 20%; a WIDE 10% time gap each side guarantees no train
63+
# frame is temporally adjacent to a val frame (kills frame-autocorrelation leakage).
64+
v0=int(n*.4); v1=int(n*.6); gap=int(n*.10)
65+
val=idx[v0:v1]; train=np.concatenate([idx[:max(0,v0-gap)], idx[min(n,v1+gap):]])
66+
return train, val
67+
if kind=="grouped_bucket":
68+
# hold out ENTIRE activity buckets -> val poses/activities never seen in train.
69+
# the strictest leakage-free test (only when bucket labels exist).
70+
b=np.array([x if x is not None else -1 for x in B])
71+
uniq=[u for u in sorted(set(b.tolist())) if u!=-1]
72+
if len(uniq)<3: raise ValueError("too few buckets")
73+
hold=set(uniq[::max(1,len(uniq)//3)][:max(1,len(uniq)//3)]) # ~1/3 of activities held out
74+
val=idx[np.isin(b,list(hold))]; train=idx[~np.isin(b,list(hold))]
75+
return train, val
76+
raise ValueError(kind)
77+
78+
def run(X,Y,V,tr,va,epochs=250,seed=0):
79+
torch.manual_seed(seed); np.random.seed(seed) # seed weight init + batch shuffle
80+
dev="cuda" if torch.cuda.is_available() else "cpu"
81+
mu,sd=X[tr].mean(0),X[tr].std(0)+1e-6
82+
Xtr=torch.tensor((X[tr]-mu)/sd).to(dev); Ytr=torch.tensor(Y[tr]).to(dev)
83+
Xva=torch.tensor((X[va]-mu)/sd).to(dev)
84+
net=Net(X.shape[1],Y.shape[1]).to(dev)
85+
opt=torch.optim.Adam(net.parameters(),lr=1e-3,weight_decay=1e-4); lf=nn.MSELoss()
86+
best=(1e9,None)
87+
for ep in range(epochs):
88+
net.train(); perm=torch.randperm(len(Xtr),device=dev)
89+
for i in range(0,len(Xtr),64):
90+
j=perm[i:i+64]; opt.zero_grad(); loss=lf(net(Xtr[j]),Ytr[j]); loss.backward(); opt.step()
91+
net.eval()
92+
with torch.no_grad(): pv=net(Xva).cpu().numpy()
93+
vl=float(((pv-Y[va])**2).mean())
94+
if vl<best[0]: best=(vl,pv)
95+
base=np.tile(Y[tr].mean(0),(len(va),1))
96+
return pck(best[1],Y[va],V[va]), pck(base,Y[va],V[va])
97+
98+
def main():
99+
ap=argparse.ArgumentParser(); ap.add_argument("--data",required=True)
100+
ap.add_argument("--epochs",type=int,default=250); ap.add_argument("--seeds",type=int,default=3)
101+
a=ap.parse_args()
102+
X,Y,V,B=load(a.data); n=len(X)
103+
has_buckets=any(x is not None for x in B)
104+
print(f"[ab] {n} samples, X={X.shape}, buckets={'yes' if has_buckets else 'no'}, "
105+
f"seeds={a.seeds}, epochs={a.epochs}\n")
106+
print(f"{'split':<22}{'model PCK@0.10':>16}{'baseline':>11}{'delta (mean±sd)':>20} verdict")
107+
print("-"*86)
108+
splits=["chronological_80_20","random_80_20","blocked_gap"]+(["grouped_bucket"] if has_buckets else [])
109+
for kind in splits:
110+
try:
111+
tr,va=split_idx(n,kind,B)
112+
ms=[]; bs=[]
113+
for s in range(a.seeds):
114+
m,b=run(X,Y,V,tr,va,a.epochs,seed=s); ms.append(m); bs.append(b)
115+
ms=np.array(ms)*100; bs=np.array(bs)*100; ds=ms-bs
116+
dm,dsd=ds.mean(),ds.std()
117+
# REAL only if the mean delta minus 1 sd still clears the 1.5pp threshold (robust to seed variance)
118+
verdict = "REAL signal" if dm-dsd>1.5 else ("weak/uncertain" if dm>1.5 else "no signal (==baseline)")
119+
print(f"{kind:<22}{ms.mean():>13.1f}±{ms.std():>3.1f}{bs.mean():>10.1f}%{dm:>+12.1f}±{dsd:>4.1f}pp {verdict}")
120+
except Exception as e:
121+
print(f"{kind:<22} skipped: {e}")
122+
print(f"\nmean±sd over {a.seeds} seeds (weight init + batch order). blocked_gap = 10% time gap each")
123+
print("side; grouped_bucket holds out ENTIRE activities (strictest). If only the LEAKY splits")
124+
print("(chronological/random) beat baseline, the apparent signal is leakage, not generalizable pose.")
125+
126+
if __name__=="__main__": main()

0 commit comments

Comments
 (0)