|
| 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> |
0 commit comments