fix: live demo static pose & inaccurate sensing data (issue #86)

- Docker default changed from --source simulated to --source auto
  (auto-detects ESP32 on UDP 5005, falls back to simulation)
- Pose derivation now driven by real sensing features: motion_band_power,
  breathing_band_power, variance, dominant_freq_hz, change_points
- Temporal feature extraction: 100-frame circular buffer, Goertzel
  breathing rate estimation (0.1-0.5 Hz), frame-to-frame L2 motion
  detection, SNR-based signal quality metric
- Signal field driven by subcarrier variance spatial mapping instead
  of fixed animation circle
- UI data source indicators: LIVE/RECONNECTING/SIMULATED banner on
  sensing tab, estimation mode badge on live demo tab
- Setup guide panel explaining ESP32 count requirements for each
  capability level (1x: presence, 3x: localization, 4x+: full pose)
- Tick rate improved from 500ms to 100ms (2fps to 10fps)
- Fixed Option<f64> division bug from PR #83
- ADR-035 documents all decisions

Closes #86

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
ruv
2026-03-02 10:54:07 -05:00
parent fdc7142dfa
commit 8166d8d822
11 changed files with 1647 additions and 336 deletions
+205 -2
View File
@@ -14,7 +14,9 @@ export class LiveDemoTab {
currentZone: 'zone_1',
debugMode: false,
autoReconnect: true,
renderMode: 'skeleton'
renderMode: 'skeleton',
// 'unknown' | 'signal_derived' | 'model_inference'
poseSource: 'unknown'
};
this.components = {
@@ -136,6 +138,48 @@ export class LiveDemoTab {
</div>
</div>
<div class="pose-source-panel">
<h4>Estimation Mode</h4>
<div class="pose-source-indicator" id="pose-source-indicator">
<span class="pose-source-badge pose-source-unknown" id="pose-source-badge">Unknown</span>
<p class="pose-source-description" id="pose-source-description">
Waiting for first frame...
</p>
</div>
</div>
<div class="setup-guide-panel">
<h4>Setup Guide</h4>
<div class="setup-levels">
<div class="setup-level">
<span class="setup-level-icon">1x</span>
<div class="setup-level-info">
<strong>1 ESP32 + 1 AP</strong>
<p>Presence, breathing, gross motion</p>
</div>
</div>
<div class="setup-level">
<span class="setup-level-icon">3x</span>
<div class="setup-level-info">
<strong>2-3 ESP32s</strong>
<p>Body localization, motion direction</p>
</div>
</div>
<div class="setup-level">
<span class="setup-level-icon">4x+</span>
<div class="setup-level-info">
<strong>4+ ESP32s + trained model</strong>
<p>Individual limb tracking, full pose</p>
</div>
</div>
</div>
<p class="setup-note">
Signal-Derived mode uses aggregate CSI features.
For per-limb tracking, load a trained <code>.rvf</code> model
with <code>--model path.rvf</code> and use 4+ sensors.
</p>
</div>
<div class="health-panel">
<h4>System Health</h4>
<div class="health-check">
@@ -432,6 +476,133 @@ export class LiveDemoTab {
.health-good { color: #28a745; }
.health-poor { color: #ffc107; }
.health-bad { color: #dc3545; }
/* Pose estimation mode indicator */
.pose-source-panel {
background: #fff;
border: 1px solid #ddd;
border-radius: 8px;
padding: 15px;
}
.pose-source-panel h4 {
margin: 0 0 12px 0;
color: #333;
font-size: 14px;
font-weight: 600;
}
.pose-source-indicator {
display: flex;
flex-direction: column;
gap: 8px;
}
.pose-source-badge {
display: inline-block;
padding: 4px 12px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
width: fit-content;
}
.pose-source-unknown {
background: #f0f0f0;
color: #6c757d;
border: 1px solid #dee2e6;
}
.pose-source-signal {
background: #e8f5e9;
color: #2e7d32;
border: 1px solid #a5d6a7;
}
.pose-source-model {
background: #e3f2fd;
color: #1565c0;
border: 1px solid #90caf9;
}
.pose-source-description {
margin: 0;
font-size: 11px;
color: #666;
line-height: 1.4;
}
.setup-guide-panel {
background: #fff;
border: 1px solid #ddd;
border-radius: 8px;
padding: 15px;
}
.setup-guide-panel h4 {
margin: 0 0 12px 0;
color: #333;
font-size: 14px;
font-weight: 600;
}
.setup-levels {
display: flex;
flex-direction: column;
gap: 10px;
}
.setup-level {
display: flex;
align-items: center;
gap: 10px;
padding: 8px;
border-radius: 6px;
background: #f8f9fa;
border: 1px solid #e9ecef;
}
.setup-level-icon {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
font-size: 11px;
font-weight: 700;
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.setup-level-info strong {
font-size: 12px;
color: #333;
display: block;
}
.setup-level-info p {
margin: 2px 0 0;
font-size: 11px;
color: #666;
}
.setup-note {
margin: 10px 0 0;
font-size: 11px;
color: #888;
line-height: 1.5;
}
.setup-note code {
background: #f0f0f0;
padding: 1px 4px;
border-radius: 3px;
font-size: 10px;
}
`;
if (!document.querySelector('#live-demo-enhanced-styles')) {
@@ -545,7 +716,11 @@ export class LiveDemoTab {
handlePoseUpdate(data) {
this.metrics.frameCount++;
this.metrics.lastUpdate = Date.now();
this.updateDebugOutput(`Pose update: ${data.persons?.length || 0} persons detected`);
// Update pose source indicator if the backend supplies it
if (data.pose_source && data.pose_source !== this.state.poseSource) {
this.setState({ poseSource: data.pose_source });
}
this.updateDebugOutput(`Pose update: ${data.persons?.length || 0} persons detected (${data.pose_source || 'unknown'})`);
}
handleCanvasError(error) {
@@ -706,6 +881,7 @@ export class LiveDemoTab {
this.updateStatusIndicator();
this.updateControls();
this.updateMetricsDisplay();
this.updatePoseSourceIndicator();
}
updateStatusIndicator() {
@@ -789,6 +965,33 @@ export class LiveDemoTab {
}
}
updatePoseSourceIndicator() {
const badge = this.container.querySelector('#pose-source-badge');
const description = this.container.querySelector('#pose-source-description');
if (!badge || !description) return;
const source = this.state.poseSource;
if (source === 'model_inference') {
badge.className = 'pose-source-badge pose-source-model';
badge.textContent = 'Model Inference';
description.textContent =
'Pose is estimated by a trained neural network ' +
'loaded from an RVF container.';
} else if (source === 'signal_derived') {
badge.className = 'pose-source-badge pose-source-signal';
badge.textContent = 'Signal-Derived';
description.textContent =
'Keypoints are derived from live CSI signal features ' +
'(motion power, breathing rate, variance).';
} else {
badge.className = 'pose-source-badge pose-source-unknown';
badge.textContent = 'Unknown';
description.textContent = 'Waiting for first frame...';
}
}
getHealthClass(status) {
switch (status) {
case 'connected': return 'good';
+44 -11
View File
@@ -33,6 +33,13 @@ export class SensingTab {
_buildDOM() {
this.container.innerHTML = `
<h2>Live WiFi Sensing</h2>
<!-- Data-source status banner — updated by _onStateChange -->
<div id="sensingSourceBanner" class="sensing-source-banner sensing-source-reconnecting"
role="status" aria-live="polite">
RECONNECTING...
</div>
<div class="sensing-layout">
<!-- 3D viewport -->
<div class="sensing-viewport" id="sensingViewport">
@@ -98,6 +105,17 @@ export class SensingTab {
</div>
</div>
<!-- Setup info -->
<div class="sensing-card">
<div class="sensing-card-title">About This Data</div>
<p class="sensing-about-text">
Metrics are computed from WiFi Channel State Information (CSI).
With <strong>1 ESP32</strong> you get presence detection, breathing
estimation, and gross motion. Add <strong>3-4+ ESP32 nodes</strong>
around the room for spatial resolution and limb-level tracking.
</p>
</div>
<!-- Extra info -->
<div class="sensing-card">
<div class="sensing-card-title">Details</div>
@@ -178,19 +196,34 @@ export class SensingTab {
}
_onStateChange(state) {
const dot = this.container.querySelector('#sensingDot');
const text = this.container.querySelector('#sensingState');
if (!dot || !text) return;
const dot = this.container.querySelector('#sensingDot');
const text = this.container.querySelector('#sensingState');
const banner = this.container.querySelector('#sensingSourceBanner');
const labels = {
disconnected: 'Disconnected',
connecting: 'Connecting...',
connected: 'Connected',
simulated: 'Simulated',
};
if (dot && text) {
const stateLabels = {
disconnected: 'Disconnected',
connecting: 'Connecting...',
connected: 'Connected',
reconnecting: 'Reconnecting...',
simulated: 'Simulated',
};
dot.className = 'sensing-dot ' + state;
text.textContent = stateLabels[state] || state;
}
dot.className = 'sensing-dot ' + state;
text.textContent = labels[state] || state;
if (banner) {
// Map the service's dataSource to banner text and CSS modifier class.
const dataSource = sensingService.dataSource;
const bannerConfig = {
live: { text: 'LIVE - ESP32', cls: 'sensing-source-live' },
reconnecting: { text: 'RECONNECTING...', cls: 'sensing-source-reconnecting' },
simulated: { text: 'SIMULATED DATA', cls: 'sensing-source-simulated' },
};
const cfg = bannerConfig[dataSource] || bannerConfig.reconnecting;
banner.textContent = cfg.text;
banner.className = 'sensing-source-banner ' + cfg.cls;
}
}
// ---- HUD update --------------------------------------------------------