From c3126c39a3999bc592ac621769ac19ac58527169 Mon Sep 17 00:00:00 2001 From: arsen Date: Sun, 17 May 2026 01:01:10 +0700 Subject: [PATCH] feat(raw.html): per-node classification badges (ADR-101) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surfaces the raw-amplitude classifier's per-node decision in node_features[].classification so the UI can show which sensor is actually seeing motion at any moment. Lets the operator visually find the best sensor placement without physically moving things — just walk around and watch which badge lights up. Server side: adds amp_node_level() pure helper + amp_node_snapshot() that reads AMP_LATEST, then plugs it into build_node_features so the existing PerNodeFeatureInfo.classification carries the new labels. UI: adds a global badge in the top bar and a per-node badge inline in each h2, color-coded (grey/absent, blue/present_still, green/moving, red/active) plus the live per-node CV %. --- .../wifi-densepose-sensing-server/src/main.rs | 42 +++++++++++++++++-- .../static/raw.html | 32 ++++++++++++++ 2 files changed, 70 insertions(+), 4 deletions(-) diff --git a/v2/crates/wifi-densepose-sensing-server/src/main.rs b/v2/crates/wifi-densepose-sensing-server/src/main.rs index 77eaae57..ab0bf7e9 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/main.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/main.rs @@ -268,6 +268,29 @@ fn amp_presence_override(node_id: u8, amplitudes: &[f64]) -> Option<(String, boo amp_classify_from_latest() } +/// Classify a single node's recent state — used both inside the global +/// fusion and from `build_node_features` so the UI can show per-node +/// labels. No hysteresis is applied here; that's a global property. +fn amp_node_level(cv: f64, mean_short: f64, baseline: Option) -> (&'static str, bool) { + if cv >= 0.30 { + ("active", true) + } else if cv >= 0.15 { + ("present_moving", true) + } else if matches!(baseline, Some(b) if b > 0.0 && (mean_short / b) < 0.75) { + ("present_still", true) + } else { + ("absent", false) + } +} + +/// Per-node snapshot exposed to `build_node_features`. +fn amp_node_snapshot(node_id: u8) -> Option<(String, bool, f64)> { + let latest = amp_latest_init().lock().unwrap(); + let (cv, mean_short, baseline) = latest.get(&node_id).copied()?; + let (lvl, pres) = amp_node_level(cv, mean_short, baseline); + Some((lvl.to_string(), pres, cv)) +} + /// Read-only classifier: returns `(level, presence, confidence)` based on /// whatever `amp_presence_override` has stashed for the active nodes. /// Returns None until at least one node has reported. @@ -864,14 +887,25 @@ fn build_node_features( change_points: 0, spectral_power: 0.0, }); - PerNodeFeatureInfo { - node_id, - features, - classification: ClassificationInfo { + // ADR-101: prefer the raw-amplitude classifier per node when + // available. Falls back to legacy current_motion_level for + // older paths that haven't reported amplitudes yet. + let classification = match amp_node_snapshot(node_id) { + Some((level, presence, conf)) => ClassificationInfo { + motion_level: level, + presence, + confidence: conf, + }, + None => ClassificationInfo { motion_level: ns.current_motion_level.clone(), presence: !matches!(ns.current_motion_level.as_str(), "absent"), confidence: ns.smoothed_person_score.clamp(0.0, 1.0), }, + }; + PerNodeFeatureInfo { + node_id, + features, + classification, rssi_dbm: ns.rssi_history.back().copied().unwrap_or(0.0), last_seen_ms, frame_rate_hz: 0.0, // Computed elsewhere; not yet plumbed here. diff --git a/v2/crates/wifi-densepose-sensing-server/static/raw.html b/v2/crates/wifi-densepose-sensing-server/static/raw.html index 33dc92f2..da14a653 100644 --- a/v2/crates/wifi-densepose-sensing-server/static/raw.html +++ b/v2/crates/wifi-densepose-sensing-server/static/raw.html @@ -22,6 +22,11 @@ font-family:JetBrains Mono,monospace; display:flex; gap:14px; align-items:baseline; } .node h2 .stat { color:#888; font-weight:normal; font-size:11px; } .node h2 .stat b { color:#e6edf3; font-weight:600; } + .badge { font-family:JetBrains Mono,monospace; font-size:11px; padding:2px 8px; border-radius:3px; } + .badge.absent { background:#21262d; color:#888; } + .badge.present_still { background:#1c3a55; color:#7cb6ff; } + .badge.present_moving{ background:#3a5520; color:#90d36b; } + .badge.active { background:#552020; color:#ff7a7a; } .row { display:grid; grid-template-columns: 1fr 360px; gap:10px; } @media (max-width: 900px) { .row { grid-template-columns: 1fr; } } canvas { display:block; width:100%; background:#0a0e13; border-radius:3px; } @@ -40,6 +45,8 @@ disconnected 0 fps last: -- + absent + CV 0%
@@ -90,6 +97,8 @@ function ensureNodeBlock(nodeId) { wrap.innerHTML = `

Node ${nodeId} + absent + CV 0% subc 0 rssi -- dBm mean A 0 @@ -246,6 +255,29 @@ function handleSensingUpdate(d) { } document.getElementById('lastTs').textContent = 'last: ' + new Date(ts * 1000).toLocaleTimeString(); + + // Global classification badge (ADR-101 fused). + const gcl = d.classification || {}; + const glvl = gcl.motion_level || 'absent'; + const gb = document.getElementById('globalBadge'); + if (gb) { gb.textContent = glvl; gb.className = 'badge ' + glvl; gb.style.fontSize = '13px'; gb.style.padding = '4px 12px'; } + const gcv = document.getElementById('globalCV'); + if (gcv) gcv.textContent = 'CV ' + ((gcl.confidence || 0) * 100).toFixed(1) + '%'; + + // Per-node level badge from node_features[i].classification (ADR-101). + const nf = d.node_features || []; + for (const f of nf) { + const id = f.node_id; + const cls = f.classification || {}; + const lvl = cls.motion_level || 'absent'; + const badge = document.getElementById(`n${id}-badge`); + if (badge) { + badge.textContent = lvl; + badge.className = 'badge ' + lvl; + } + const cvEl = document.getElementById(`n${id}-cv`); + if (cvEl) cvEl.textContent = ((cls.confidence || 0) * 100).toFixed(1) + '%'; + } frameCount++; }