fix: WebSocket race condition, data source indicators, auto-start pose detection (#96)

* feat: RVF training pipeline & UI integration (ADR-036)

Implement full model training, management, and inference pipeline:

Backend (Rust):
- recording.rs: CSI recording API (start/stop/list/download/delete)
- model_manager.rs: RVF model loading, LoRA profile switching, model library
- training_api.rs: Training API with WebSocket progress streaming, simulated
  training mode with realistic loss curves, auto-RVF export on completion
- main.rs: Wire new modules, recording hooks in all CSI paths, data dirs

UI (new components):
- ModelPanel.js: Dark-mode model library with load/unload, LoRA dropdown
- TrainingPanel.js: Recording controls, training config, live Canvas charts
- model.service.js: Model REST API client with events
- training.service.js: Training + recording API client with WebSocket progress

UI (enhancements):
- LiveDemoTab: Model selector, LoRA profile switcher, A/B split view toggle,
  training quick-panel with 60s recording shortcut
- SettingsPanel: Full dark mode conversion (issue #92), model configuration
  (device, threads, auto-load), training configuration (epochs, LR, patience)
- PoseDetectionCanvas: 10-frame pose trail with ghost keypoints and motion
  trajectory lines, cyan trail toggle button
- pose.service.js: Model-inference confidence thresholds

UI (plumbing):
- index.html: Training tab (8th tab)
- app.js: Panel initialization and tab routing
- style.css: ~250 lines of training/model panel dark-mode styles

191 Rust tests pass, 0 failures. Closes #92.

Refs: ADR-036, #93

Co-Authored-By: claude-flow <ruv@ruv.net>

* fix: real RuVector training pipeline + UI service fixes

Training pipeline (training_api.rs):
- Replace simulated training with real signal-based training loop
- Load actual CSI data from .csi.jsonl recordings or live frame history
- Extract 180 features per frame: subcarrier amplitudes, temporal variance,
  Goertzel frequency analysis (9 bands), motion gradients, global stats
- Train calibrated linear CSI-to-pose mapping via mini-batch gradient descent
  with L2 regularization (ridge regression), Xavier init, cosine LR decay
- Self-supervised: teacher targets from derive_pose_from_sensing() heuristics
- Real validation metrics: MSE and PCK@0.2 on 80/20 train/val split
- Export trained .rvf with real weights, feature normalization stats, witness
- Add infer_pose_from_model() for live inference from trained model
- 16 new tests covering features, training, inference, serialization

UI fixes:
- Fix double-URL bug in model.service.js and training.service.js
  (buildApiUrl was called twice — once in service, once in apiService)
- Fix route paths to match Rust backend (/api/v1/train/*, /api/v1/recording/*)
- Fix request body formats (session_name, nested config object)
- Fix top-level await in LiveDemoTab.js blocking module graph
- Dynamic imports for ModelPanel/TrainingPanel in app.js
- Center nav tabs with flex-wrap for 8-tab layout

Co-Authored-By: claude-flow <ruv@ruv.net>

* fix: WebSocket onOpen race condition, data source indicators, auto-start pose detection

- Fix WebSocket onOpen race condition in websocket.service.js where
  setupEventHandlers replaced onopen after socket was already open,
  preventing pose service from receiving connection signal
- Add 4-state data source indicator (LIVE/SIMULATED/RECONNECTING/OFFLINE)
  across Dashboard, Sensing, and Live Demo tabs via sensing.service.js
- Add hot-plug ESP32 auto-detection in sensing server (auto mode runs
  both UDP listener and simulation, switches on ESP32_TIMEOUT)
- Auto-start pose detection when backend is reachable
- Hide duplicate PoseDetectionCanvas controls when enableControls=false
- Add standalone Demo button in LiveDemoTab for offline animated demo
- Add data source banner and status styling

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
rUv
2026-03-02 13:47:49 -05:00
committed by GitHub
parent c193cd4299
commit 113011e704
20 changed files with 6124 additions and 83 deletions
+159 -5
View File
@@ -45,7 +45,12 @@ export class PoseDetectionCanvas {
// Initialize settings panel
this.settingsPanel = null;
// Pose trail state
this.poseTrail = [];
this.showTrail = false;
this.maxTrailLength = 10;
// Initialize component
this.initializeComponent();
}
@@ -88,7 +93,7 @@ export class PoseDetectionCanvas {
<span class="status-text" id="status-text-${this.containerId}">Disconnected</span>
</div>
</div>
<div class="pose-canvas-controls" id="controls-${this.containerId}">
<div class="pose-canvas-controls" id="controls-${this.containerId}" ${!this.config.enableControls ? 'style="display:none"' : ''}>
<button class="btn btn-start" id="start-btn-${this.containerId}">&#9654; Start</button>
<button class="btn btn-stop" id="stop-btn-${this.containerId}" disabled>&#9632; Stop</button>
<button class="btn btn-reconnect" id="reconnect-btn-${this.containerId}" disabled>&#8635; Reconnect</button>
@@ -99,6 +104,7 @@ export class PoseDetectionCanvas {
<option value="heatmap">Heatmap</option>
<option value="dense">Dense</option>
</select>
<button class="btn btn-trail" id="trail-btn-${this.containerId}">&#9676; Trail</button>
<button class="btn btn-settings" id="settings-btn-${this.containerId}">&#9881; Settings</button>
</div>
</div>
@@ -285,6 +291,25 @@ export class PoseDetectionCanvas {
border-color: rgba(100, 116, 139, 0.5);
}
.btn-trail {
background: rgba(0, 212, 255, 0.1);
color: #5ec4d4;
border-color: rgba(0, 212, 255, 0.25);
}
.btn-trail:hover:not(:disabled) {
background: rgba(0, 212, 255, 0.2);
border-color: rgba(0, 212, 255, 0.45);
box-shadow: 0 4px 12px rgba(0, 212, 255, 0.15);
}
.btn-trail.active {
background: rgba(0, 212, 255, 0.2);
color: #00d4ff;
border-color: rgba(0, 212, 255, 0.5);
box-shadow: 0 0 8px rgba(0, 212, 255, 0.2);
}
.mode-select {
padding: 8px 12px;
border: 1px solid rgba(255, 255, 255, 0.1);
@@ -416,6 +441,10 @@ export class PoseDetectionCanvas {
const demoBtn = document.getElementById(`demo-btn-${this.containerId}`);
demoBtn.addEventListener('click', () => this.toggleDemo());
// Trail toggle button
const trailBtn = document.getElementById(`trail-btn-${this.containerId}`);
trailBtn.addEventListener('click', () => this.toggleTrail());
// Settings button
const settingsBtn = document.getElementById(`settings-btn-${this.containerId}`);
settingsBtn.addEventListener('click', () => this.showSettings());
@@ -445,6 +474,7 @@ export class PoseDetectionCanvas {
case 'pose_update':
this.state.lastPoseData = update.data;
this.state.frameCount++;
this.updateTrail(update.data);
this.renderPoseData(update.data);
this.updateStats();
this.notifyCallback('onPoseUpdate', update.data);
@@ -487,14 +517,40 @@ export class PoseDetectionCanvas {
return;
}
try {
// Render trail before the current frame if enabled
if (this.showTrail && this.poseTrail.length > 1) {
// The renderer.render() clears the canvas, so we render trail
// by hooking into the renderer's canvas context after clear.
// We override the render flow: clear, trail, then current.
this.renderer.clearCanvas();
this.renderTrail(this.renderer.ctx);
// Now render current frame without clearing again
this.renderCurrentFrameNoClean(poseData);
} else {
this.renderer.render(poseData, {
frameCount: this.state.frameCount,
connectionState: this.state.connectionState
});
}
} catch (error) {
this.logger.error('Render error', { error: error.message });
this.showError(`Render error: ${error.message}`);
}
}
renderCurrentFrameNoClean(poseData) {
// Call the renderer's render logic without clearing the canvas.
// We temporarily stub clearCanvas, render, then restore.
const origClear = this.renderer.clearCanvas.bind(this.renderer);
this.renderer.clearCanvas = () => {}; // no-op
try {
this.renderer.render(poseData, {
frameCount: this.state.frameCount,
connectionState: this.state.connectionState
});
} catch (error) {
this.logger.error('Render error', { error: error.message });
this.showError(`Render error: ${error.message}`);
} finally {
this.renderer.clearCanvas = origClear;
}
}
@@ -650,6 +706,104 @@ export class PoseDetectionCanvas {
}
}
// --- Pose Trail Methods ---
toggleTrail() {
this.showTrail = !this.showTrail;
const trailBtn = document.getElementById(`trail-btn-${this.containerId}`);
if (trailBtn) {
trailBtn.classList.toggle('active', this.showTrail);
trailBtn.textContent = this.showTrail ? '\u25CB Trail On' : '\u25CB Trail';
}
if (!this.showTrail) {
this.poseTrail = [];
}
this.logger.info('Trail toggled', { showTrail: this.showTrail });
}
updateTrail(poseData) {
if (!this.showTrail) return;
if (!poseData || !poseData.persons || poseData.persons.length === 0) return;
// Deep clone the keypoints from all persons for this frame
const frameKeypoints = poseData.persons.map(person => {
if (!person.keypoints) return null;
return person.keypoints.map(kp => ({
x: kp.x,
y: kp.y,
confidence: kp.confidence
}));
}).filter(Boolean);
if (frameKeypoints.length > 0) {
this.poseTrail.push(frameKeypoints);
if (this.poseTrail.length > this.maxTrailLength) {
this.poseTrail.shift();
}
}
}
renderTrail(ctx) {
if (!this.poseTrail || this.poseTrail.length < 2) return;
const totalFrames = this.poseTrail.length;
// Keypoint color palette (same as renderer's body part colors)
const kpColors = [
'#ff0000', '#ff4500', '#ffa500', '#ffff00', '#adff2f',
'#00ff00', '#00ff7f', '#00ffff', '#0080ff', '#0000ff',
'#4000ff', '#8000ff', '#ff00ff', '#ff0080', '#ff0040',
'#ff8080', '#ffb380'
];
// Render ghosted keypoints and trajectory lines for each frame in the trail
// (skip the last frame since it's the current one rendered by the normal pipeline)
for (let frameIdx = 0; frameIdx < totalFrames - 1; frameIdx++) {
const alpha = 0.1 + (frameIdx / totalFrames) * 0.7;
const framePersons = this.poseTrail[frameIdx];
const nextFramePersons = this.poseTrail[frameIdx + 1];
framePersons.forEach((personKeypoints, personIdx) => {
if (!personKeypoints) return;
personKeypoints.forEach((kp, kpIdx) => {
if (kp.confidence <= 0.1) return;
const x = this.renderer.scaleX(kp.x);
const y = this.renderer.scaleY(kp.y);
const color = kpColors[kpIdx % kpColors.length];
// Draw ghosted keypoint dot
ctx.globalAlpha = alpha * 0.6;
ctx.fillStyle = color;
ctx.beginPath();
ctx.arc(x, y, 2.5, 0, Math.PI * 2);
ctx.fill();
// Draw trajectory line to same keypoint in next frame
if (nextFramePersons && nextFramePersons[personIdx]) {
const nextKp = nextFramePersons[personIdx][kpIdx];
if (nextKp && nextKp.confidence > 0.1) {
const nx = this.renderer.scaleX(nextKp.x);
const ny = this.renderer.scaleY(nextKp.y);
ctx.globalAlpha = alpha * 0.4;
ctx.strokeStyle = color;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(nx, ny);
ctx.stroke();
}
}
});
});
}
// Reset alpha
ctx.globalAlpha = 1.0;
}
// Toggle demo mode
toggleDemo() {
if (this.demoState && this.demoState.isRunning) {