mirror of
https://github.com/ruvnet/RuView
synced 2026-06-09 10:13:17 +00:00
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:
@@ -130,6 +130,9 @@ class WiFiDensePoseApp {
|
||||
this.components.sensing = new SensingTab(sensingContainer);
|
||||
}
|
||||
|
||||
// Training tab - lazy load to avoid breaking other tabs if import fails
|
||||
this.initTrainingTab();
|
||||
|
||||
// Architecture tab - static content, no component needed
|
||||
|
||||
// Performance tab - static content, no component needed
|
||||
@@ -137,6 +140,28 @@ class WiFiDensePoseApp {
|
||||
// Applications tab - static content, no component needed
|
||||
}
|
||||
|
||||
// Lazy-load Training tab panels (dynamic import so failures don't break other tabs)
|
||||
async initTrainingTab() {
|
||||
try {
|
||||
const [{ default: TrainingPanel }, { default: ModelPanel }] = await Promise.all([
|
||||
import('./components/TrainingPanel.js'),
|
||||
import('./components/ModelPanel.js')
|
||||
]);
|
||||
|
||||
const trainingContainer = document.getElementById('training-panel-container');
|
||||
if (trainingContainer) {
|
||||
this.components.trainingPanel = new TrainingPanel(trainingContainer);
|
||||
}
|
||||
|
||||
const modelContainer = document.getElementById('model-panel-container');
|
||||
if (modelContainer) {
|
||||
this.components.modelPanel = new ModelPanel(modelContainer);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load Training tab components:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle tab changes
|
||||
handleTabChange(newTab, oldTab) {
|
||||
console.log(`Tab changed from ${oldTab} to ${newTab}`);
|
||||
@@ -168,6 +193,16 @@ class WiFiDensePoseApp {
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case 'training':
|
||||
// Refresh panels when training tab becomes visible
|
||||
if (this.components.trainingPanel && typeof this.components.trainingPanel.refresh === 'function') {
|
||||
this.components.trainingPanel.refresh();
|
||||
}
|
||||
if (this.components.modelPanel && typeof this.components.modelPanel.refresh === 'function') {
|
||||
this.components.modelPanel.refresh();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { healthService } from '../services/health.service.js';
|
||||
import { poseService } from '../services/pose.service.js';
|
||||
import { sensingService } from '../services/sensing.service.js';
|
||||
|
||||
export class DashboardTab {
|
||||
constructor(containerElement) {
|
||||
@@ -63,6 +64,17 @@ export class DashboardTab {
|
||||
this.updateHealthStatus(health);
|
||||
});
|
||||
|
||||
// Subscribe to sensing service state changes for data source indicator
|
||||
this._sensingUnsub = sensingService.onStateChange(() => {
|
||||
this.updateDataSourceIndicator();
|
||||
});
|
||||
// Also update on data — catches source changes mid-stream
|
||||
this._sensingDataUnsub = sensingService.onData(() => {
|
||||
this.updateDataSourceIndicator();
|
||||
});
|
||||
// Initial update
|
||||
this.updateDataSourceIndicator();
|
||||
|
||||
// Start periodic stats updates
|
||||
this.statsInterval = setInterval(() => {
|
||||
this.updateLiveStats();
|
||||
@@ -72,6 +84,25 @@ export class DashboardTab {
|
||||
healthService.startHealthMonitoring(30000);
|
||||
}
|
||||
|
||||
// Update the data source indicator on the dashboard
|
||||
updateDataSourceIndicator() {
|
||||
const el = this.container.querySelector('#dashboard-datasource');
|
||||
if (!el) return;
|
||||
const ds = sensingService.dataSource;
|
||||
const statusText = el.querySelector('.status-text');
|
||||
const statusMsg = el.querySelector('.status-message');
|
||||
const config = {
|
||||
'live': { text: 'ESP32', status: 'healthy', msg: 'Real hardware connected' },
|
||||
'server-simulated': { text: 'SIMULATED', status: 'warning', msg: 'Server running without hardware' },
|
||||
'reconnecting': { text: 'RECONNECTING', status: 'degraded', msg: 'Attempting to connect...' },
|
||||
'simulated': { text: 'OFFLINE', status: 'unhealthy', msg: 'Server unreachable, local fallback' },
|
||||
};
|
||||
const cfg = config[ds] || config['reconnecting'];
|
||||
el.className = `component-status status-${cfg.status}`;
|
||||
if (statusText) statusText.textContent = cfg.text;
|
||||
if (statusMsg) statusMsg.textContent = cfg.msg;
|
||||
}
|
||||
|
||||
// Update API info display
|
||||
updateApiInfo(info) {
|
||||
// Update version
|
||||
@@ -394,11 +425,13 @@ export class DashboardTab {
|
||||
if (this.healthSubscription) {
|
||||
this.healthSubscription();
|
||||
}
|
||||
|
||||
if (this._sensingUnsub) this._sensingUnsub();
|
||||
if (this._sensingDataUnsub) this._sensingDataUnsub();
|
||||
|
||||
if (this.statsInterval) {
|
||||
clearInterval(this.statsInterval);
|
||||
}
|
||||
|
||||
|
||||
healthService.stopHealthMonitoring();
|
||||
}
|
||||
}
|
||||
+799
-10
@@ -4,6 +4,11 @@ import { PoseDetectionCanvas } from './PoseDetectionCanvas.js';
|
||||
import { poseService } from '../services/pose.service.js';
|
||||
import { streamService } from '../services/stream.service.js';
|
||||
import { wsService } from '../services/websocket.service.js';
|
||||
import { sensingService } from '../services/sensing.service.js';
|
||||
|
||||
// Optional services - loaded lazily in init() to avoid blocking module graph
|
||||
let modelService = null;
|
||||
let trainingService = null;
|
||||
|
||||
export class LiveDemoTab {
|
||||
constructor(containerElement) {
|
||||
@@ -32,6 +37,27 @@ export class LiveDemoTab {
|
||||
connectionAttempts: 0
|
||||
};
|
||||
|
||||
// Model control state
|
||||
this.modelState = {
|
||||
models: [],
|
||||
activeModelId: null,
|
||||
activeModelInfo: null,
|
||||
loraProfiles: [],
|
||||
selectedLoraProfile: null,
|
||||
loading: false
|
||||
};
|
||||
|
||||
// Training state
|
||||
this.trainingState = {
|
||||
status: 'idle', // 'idle' | 'training' | 'recording'
|
||||
epoch: 0,
|
||||
totalEpochs: 0,
|
||||
showTrainingPanel: false
|
||||
};
|
||||
|
||||
// A/B split view state
|
||||
this.splitViewActive = false;
|
||||
|
||||
this.subscriptions = [];
|
||||
this.logger = this.createLogger();
|
||||
|
||||
@@ -58,7 +84,17 @@ export class LiveDemoTab {
|
||||
async init() {
|
||||
try {
|
||||
this.logger.info('Initializing LiveDemoTab component');
|
||||
|
||||
|
||||
// Load optional services (non-blocking)
|
||||
try {
|
||||
const mod = await import('../services/model.service.js');
|
||||
modelService = mod.modelService;
|
||||
} catch (e) { /* model features disabled */ }
|
||||
try {
|
||||
const mod = await import('../services/training.service.js');
|
||||
trainingService = mod.trainingService;
|
||||
} catch (e) { /* training features disabled */ }
|
||||
|
||||
// Create enhanced DOM structure
|
||||
this.createEnhancedStructure();
|
||||
|
||||
@@ -71,9 +107,31 @@ export class LiveDemoTab {
|
||||
// Set up monitoring and health checks
|
||||
this.setupMonitoring();
|
||||
|
||||
// Fetch available models on init
|
||||
this.fetchModels();
|
||||
|
||||
// Set up model/training event listeners
|
||||
this.setupServiceListeners();
|
||||
|
||||
// Initialize state
|
||||
this.updateUI();
|
||||
|
||||
|
||||
// Auto-start pose detection when a backend is reachable.
|
||||
// Check after a brief delay (sensing WS may still be connecting).
|
||||
this._autoStartOnce = false;
|
||||
const tryAutoStart = () => {
|
||||
if (this._autoStartOnce || this.state.isActive) return;
|
||||
const ds = sensingService.dataSource;
|
||||
if (ds === 'live' || ds === 'server-simulated') {
|
||||
this._autoStartOnce = true;
|
||||
this.logger.info('Auto-starting pose detection (data source: ' + ds + ')');
|
||||
this.startDemo();
|
||||
}
|
||||
};
|
||||
setTimeout(tryAutoStart, 2000);
|
||||
// Also listen for sensing state changes in case server connects later
|
||||
this._autoStartUnsub = sensingService.onStateChange(tryAutoStart);
|
||||
|
||||
this.logger.info('LiveDemoTab component initialized successfully');
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to initialize LiveDemoTab', { error: error.message });
|
||||
@@ -88,6 +146,11 @@ export class LiveDemoTab {
|
||||
// Create enhanced structure if it doesn't exist
|
||||
const enhancedHTML = `
|
||||
<div class="live-demo-enhanced">
|
||||
<!-- Data source banner — prominent indicator for live vs simulated -->
|
||||
<div id="demo-source-banner" class="demo-source-banner demo-source-unknown" role="status" aria-live="polite">
|
||||
Detecting data source...
|
||||
</div>
|
||||
|
||||
<div class="demo-header">
|
||||
<div class="demo-title">
|
||||
<h2>Live Human Pose Detection</h2>
|
||||
@@ -99,6 +162,7 @@ export class LiveDemoTab {
|
||||
<div class="demo-controls">
|
||||
<button class="btn btn--primary" id="start-enhanced-demo">Start Detection</button>
|
||||
<button class="btn btn--secondary" id="stop-enhanced-demo" disabled>Stop Detection</button>
|
||||
<button class="btn btn--accent" id="run-offline-demo">Demo</button>
|
||||
<button class="btn btn--primary" id="toggle-debug">Debug Mode</button>
|
||||
<select class="zone-select" id="zone-selector">
|
||||
<option value="zone_1">Zone 1</option>
|
||||
@@ -148,6 +212,49 @@ export class LiveDemoTab {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="model-control-panel" id="model-control-panel">
|
||||
<h4>Model Control</h4>
|
||||
<div class="setting-row-ld">
|
||||
<label class="ld-label">Model:</label>
|
||||
<select class="ld-select" id="model-selector">
|
||||
<option value="">Signal-Derived (no model)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="model-info-row" id="model-active-info" style="display: none;">
|
||||
<span class="ld-label" id="model-active-name"></span>
|
||||
<span class="model-pck-badge" id="model-active-pck"></span>
|
||||
</div>
|
||||
<div class="setting-row-ld" id="lora-profile-row" style="display: none;">
|
||||
<label class="ld-label">LoRA Profile:</label>
|
||||
<select class="ld-select" id="lora-profile-selector">
|
||||
<option value="">None</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="model-actions">
|
||||
<button class="btn-ld btn-ld-accent" id="load-model-btn">Load Model</button>
|
||||
<button class="btn-ld btn-ld-muted" id="unload-model-btn" disabled>Unload</button>
|
||||
</div>
|
||||
<div class="model-status-text" id="model-status-text">No model loaded</div>
|
||||
</div>
|
||||
|
||||
<div class="split-view-panel">
|
||||
<div class="setting-row-ld">
|
||||
<label class="ld-label">Compare: Signal vs Model</label>
|
||||
<button class="btn-ld btn-ld-toggle" id="split-view-toggle" disabled>Off</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="training-quick-panel" id="training-quick-panel">
|
||||
<h4>Training</h4>
|
||||
<div class="training-status-row">
|
||||
<span class="training-status-badge" id="training-status-badge">Idle</span>
|
||||
</div>
|
||||
<div class="training-actions">
|
||||
<button class="btn-ld btn-ld-accent" id="open-training-panel-btn">Open Training Panel</button>
|
||||
<button class="btn-ld btn-ld-muted" id="quick-record-btn">Record 60s</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setup-guide-panel">
|
||||
<h4>Setup Guide</h4>
|
||||
<div class="setup-levels">
|
||||
@@ -606,6 +713,270 @@ export class LiveDemoTab {
|
||||
border-radius: 3px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
/* Model Control Panel */
|
||||
.model-control-panel,
|
||||
.split-view-panel,
|
||||
.training-quick-panel {
|
||||
background: rgba(17, 24, 39, 0.9);
|
||||
border: 1px solid rgba(56, 68, 89, 0.6);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.model-control-panel h4,
|
||||
.training-quick-panel h4 {
|
||||
margin: 0 0 12px 0;
|
||||
color: #e0e0e0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.setting-row-ld {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ld-label {
|
||||
color: #8899aa;
|
||||
font-size: 11px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ld-select {
|
||||
flex: 1;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid rgba(56, 68, 89, 0.6);
|
||||
border-radius: 6px;
|
||||
background: rgba(15, 20, 35, 0.8);
|
||||
color: #b0b8c8;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ld-select:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.15);
|
||||
}
|
||||
|
||||
.ld-select option {
|
||||
background: #1a2234;
|
||||
color: #c8d0dc;
|
||||
}
|
||||
|
||||
.model-info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
padding: 6px 8px;
|
||||
background: rgba(30, 40, 60, 0.6);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.model-pck-badge {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 2px 8px;
|
||||
border-radius: 8px;
|
||||
background: rgba(102, 126, 234, 0.15);
|
||||
color: #8ea4f0;
|
||||
}
|
||||
|
||||
.model-actions,
|
||||
.training-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.btn-ld {
|
||||
flex: 1;
|
||||
padding: 7px 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.btn-ld:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-ld-accent {
|
||||
background: rgba(102, 126, 234, 0.15);
|
||||
color: #8ea4f0;
|
||||
border-color: rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
.btn-ld-accent:hover:not(:disabled) {
|
||||
background: rgba(102, 126, 234, 0.25);
|
||||
border-color: rgba(102, 126, 234, 0.5);
|
||||
}
|
||||
|
||||
.btn-ld-muted {
|
||||
background: rgba(30, 40, 60, 0.8);
|
||||
color: #8899aa;
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.btn-ld-muted:hover:not(:disabled) {
|
||||
background: rgba(40, 50, 70, 0.9);
|
||||
color: #b0b8c8;
|
||||
}
|
||||
|
||||
.btn-ld-toggle {
|
||||
min-width: 44px;
|
||||
flex: 0;
|
||||
padding: 4px 10px;
|
||||
background: rgba(30, 40, 60, 0.8);
|
||||
color: #8899aa;
|
||||
border-color: rgba(255, 255, 255, 0.08);
|
||||
border-radius: 12px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.btn-ld-toggle.active {
|
||||
background: rgba(0, 212, 255, 0.15);
|
||||
color: #00d4ff;
|
||||
border-color: rgba(0, 212, 255, 0.4);
|
||||
}
|
||||
|
||||
.model-status-text {
|
||||
margin-top: 8px;
|
||||
font-size: 11px;
|
||||
color: #6b7a8d;
|
||||
}
|
||||
|
||||
.training-status-row {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.training-status-badge {
|
||||
display: inline-block;
|
||||
padding: 3px 10px;
|
||||
border-radius: 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
background: rgba(108, 117, 125, 0.15);
|
||||
color: #8899aa;
|
||||
border: 1px solid rgba(108, 117, 125, 0.3);
|
||||
}
|
||||
|
||||
.training-status-badge.training {
|
||||
background: rgba(251, 191, 36, 0.12);
|
||||
color: #fbbf24;
|
||||
border-color: rgba(251, 191, 36, 0.3);
|
||||
}
|
||||
|
||||
.training-status-badge.recording {
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
color: #ef4444;
|
||||
border-color: rgba(239, 68, 68, 0.3);
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* A/B Split View Overlay */
|
||||
.split-view-divider {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
width: 2px;
|
||||
background: repeating-linear-gradient(
|
||||
to bottom,
|
||||
rgba(255, 255, 255, 0.4) 0px,
|
||||
rgba(255, 255, 255, 0.4) 6px,
|
||||
transparent 6px,
|
||||
transparent 12px
|
||||
);
|
||||
z-index: 15;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.split-view-label {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
z-index: 16;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.split-view-label.left {
|
||||
left: 8px;
|
||||
background: rgba(0, 204, 136, 0.2);
|
||||
color: #00cc88;
|
||||
}
|
||||
|
||||
.split-view-label.right {
|
||||
right: 8px;
|
||||
background: rgba(102, 126, 234, 0.2);
|
||||
color: #8ea4f0;
|
||||
}
|
||||
|
||||
/* Training modal overlay */
|
||||
.training-panel-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.training-panel-modal {
|
||||
background: #0d1117;
|
||||
border: 1px solid rgba(56, 68, 89, 0.6);
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
min-width: 400px;
|
||||
max-width: 600px;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.training-panel-modal h3 {
|
||||
margin: 0 0 16px 0;
|
||||
font-size: 18px;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.training-panel-modal .close-btn {
|
||||
float: right;
|
||||
background: rgba(30, 40, 60, 0.8);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
color: #8899aa;
|
||||
border-radius: 6px;
|
||||
padding: 4px 10px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.training-panel-modal .close-btn:hover {
|
||||
background: rgba(50, 60, 80, 0.9);
|
||||
color: #c8d0dc;
|
||||
}
|
||||
`;
|
||||
|
||||
if (!document.querySelector('#live-demo-enhanced-styles')) {
|
||||
@@ -664,6 +1035,16 @@ export class LiveDemoTab {
|
||||
stopBtn.addEventListener('click', () => this.stopDemo());
|
||||
}
|
||||
|
||||
// Offline demo button — runs client-side animated demo (no server needed)
|
||||
const offlineDemoBtn = this.container.querySelector('#run-offline-demo');
|
||||
if (offlineDemoBtn) {
|
||||
offlineDemoBtn.addEventListener('click', () => {
|
||||
if (this.components.poseCanvas) {
|
||||
this.components.poseCanvas.toggleDemo();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (debugBtn) {
|
||||
debugBtn.addEventListener('click', () => this.toggleDebugMode());
|
||||
}
|
||||
@@ -690,6 +1071,9 @@ export class LiveDemoTab {
|
||||
exportLogsBtn.addEventListener('click', () => this.exportLogs());
|
||||
}
|
||||
|
||||
// Model, training, and split-view controls
|
||||
this.setupModelTrainingControls();
|
||||
|
||||
this.logger.debug('Enhanced controls set up');
|
||||
}
|
||||
|
||||
@@ -706,6 +1090,23 @@ export class LiveDemoTab {
|
||||
this.updateMetricsDisplay();
|
||||
}, 1000);
|
||||
|
||||
// Subscribe to sensing service for data-source changes
|
||||
this._sensingStateUnsub = sensingService.onStateChange(() => {
|
||||
this.updateSourceBanner();
|
||||
this.updateStatusIndicator();
|
||||
});
|
||||
// Throttle data-based banner updates (frames arrive at 10Hz)
|
||||
let lastBannerUpdate = 0;
|
||||
this._sensingDataUnsub = sensingService.onData(() => {
|
||||
const now = Date.now();
|
||||
if (now - lastBannerUpdate > 2000) {
|
||||
lastBannerUpdate = now;
|
||||
this.updateSourceBanner();
|
||||
}
|
||||
});
|
||||
// Initial banner update
|
||||
this.updateSourceBanner();
|
||||
|
||||
this.logger.debug('Monitoring set up');
|
||||
}
|
||||
|
||||
@@ -901,17 +1302,40 @@ export class LiveDemoTab {
|
||||
}
|
||||
|
||||
getStatusClass() {
|
||||
if (this.state.isActive) {
|
||||
return this.state.connectionState === 'connected' ? 'active' : 'connecting';
|
||||
if (!this.state.isActive) {
|
||||
return this.state.connectionState === 'error' ? 'error' : '';
|
||||
}
|
||||
return this.state.connectionState === 'error' ? 'error' : '';
|
||||
const ds = sensingService.dataSource;
|
||||
if (ds === 'live') return 'active';
|
||||
if (ds === 'server-simulated') return 'sim';
|
||||
return 'connecting';
|
||||
}
|
||||
|
||||
getStatusText() {
|
||||
if (this.state.isActive) {
|
||||
return this.state.connectionState === 'connected' ? 'Active' : 'Connecting...';
|
||||
if (!this.state.isActive) {
|
||||
return this.state.connectionState === 'error' ? 'Error' : 'Ready';
|
||||
}
|
||||
return this.state.connectionState === 'error' ? 'Error' : 'Ready';
|
||||
const ds = sensingService.dataSource;
|
||||
if (ds === 'live') return 'Active \u2014 ESP32 Live';
|
||||
if (ds === 'server-simulated') return 'Active \u2014 Simulated Data';
|
||||
if (ds === 'simulated') return 'Active \u2014 Offline Simulation';
|
||||
return 'Connecting...';
|
||||
}
|
||||
|
||||
/** Update the prominent data-source banner at the top of Live Demo. */
|
||||
updateSourceBanner() {
|
||||
const banner = this.container.querySelector('#demo-source-banner');
|
||||
if (!banner) return;
|
||||
const ds = sensingService.dataSource;
|
||||
const config = {
|
||||
'live': { text: 'LIVE \u2014 ESP32 Hardware Connected', cls: 'demo-source-live' },
|
||||
'server-simulated': { text: 'SIMULATED DATA \u2014 No Hardware Detected', cls: 'demo-source-sim' },
|
||||
'reconnecting': { text: 'RECONNECTING TO SERVER...', cls: 'demo-source-reconnecting' },
|
||||
'simulated': { text: 'OFFLINE \u2014 Server Unreachable, Local Sim', cls: 'demo-source-offline' },
|
||||
};
|
||||
const cfg = config[ds] || config['reconnecting'];
|
||||
banner.textContent = cfg.text;
|
||||
banner.className = 'demo-source-banner ' + cfg.cls;
|
||||
}
|
||||
|
||||
updateControls() {
|
||||
@@ -942,8 +1366,20 @@ export class LiveDemoTab {
|
||||
};
|
||||
|
||||
if (elements.connectionStatus) {
|
||||
elements.connectionStatus.textContent = this.state.connectionState;
|
||||
elements.connectionStatus.className = `health-${this.getHealthClass(this.state.connectionState)}`;
|
||||
const ds = sensingService.dataSource;
|
||||
const dsLabels = {
|
||||
'live': 'Connected \u2014 ESP32',
|
||||
'server-simulated': 'Connected \u2014 Simulated',
|
||||
'reconnecting': 'Reconnecting...',
|
||||
'simulated': 'Offline \u2014 Simulated',
|
||||
};
|
||||
const label = dsLabels[ds] || this.state.connectionState;
|
||||
elements.connectionStatus.textContent = label;
|
||||
const cls = ds === 'live' ? 'good'
|
||||
: ds === 'server-simulated' ? 'sim'
|
||||
: ds === 'simulated' ? 'bad'
|
||||
: this.getHealthClass(this.state.connectionState);
|
||||
elements.connectionStatus.className = `health-${cls}`;
|
||||
}
|
||||
|
||||
if (elements.frameCount) {
|
||||
@@ -1061,6 +1497,356 @@ export class LiveDemoTab {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Model Control Methods ---
|
||||
|
||||
async fetchModels() {
|
||||
if (!modelService) return;
|
||||
try {
|
||||
const data = await modelService.listModels();
|
||||
this.modelState.models = data?.models || [];
|
||||
this.populateModelSelector();
|
||||
// Check if a model is already active
|
||||
const active = await modelService.getActiveModel();
|
||||
if (active && active.model_id) {
|
||||
this.modelState.activeModelId = active.model_id;
|
||||
this.modelState.activeModelInfo = active;
|
||||
this.updateModelUI();
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.warn('Could not fetch models', { error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
populateModelSelector() {
|
||||
const selector = this.container.querySelector('#model-selector');
|
||||
if (!selector) return;
|
||||
// Keep the first "Signal-Derived" option
|
||||
selector.innerHTML = '<option value="">Signal-Derived (no model)</option>';
|
||||
this.modelState.models.forEach(model => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = model.id || model.model_id || model.name;
|
||||
opt.textContent = model.name || model.id || 'Unknown Model';
|
||||
selector.appendChild(opt);
|
||||
});
|
||||
if (this.modelState.activeModelId) {
|
||||
selector.value = this.modelState.activeModelId;
|
||||
}
|
||||
}
|
||||
|
||||
async handleLoadModel() {
|
||||
if (!modelService) return;
|
||||
const selector = this.container.querySelector('#model-selector');
|
||||
const modelId = selector?.value;
|
||||
if (!modelId) {
|
||||
this.setModelStatus('Select a model first');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this.modelState.loading = true;
|
||||
this.setModelStatus('Loading...');
|
||||
const loadBtn = this.container.querySelector('#load-model-btn');
|
||||
if (loadBtn) loadBtn.disabled = true;
|
||||
|
||||
await modelService.loadModel(modelId);
|
||||
this.modelState.activeModelId = modelId;
|
||||
|
||||
// Try to fetch full info
|
||||
try {
|
||||
const info = await modelService.getModel(modelId);
|
||||
this.modelState.activeModelInfo = info;
|
||||
} catch (e) {
|
||||
this.modelState.activeModelInfo = { model_id: modelId };
|
||||
}
|
||||
|
||||
// Fetch LoRA profiles
|
||||
try {
|
||||
const profiles = await modelService.getLoraProfiles();
|
||||
this.modelState.loraProfiles = profiles || [];
|
||||
} catch (e) {
|
||||
this.modelState.loraProfiles = [];
|
||||
}
|
||||
|
||||
this.modelState.loading = false;
|
||||
this.updateModelUI();
|
||||
this.updateSplitViewAvailability();
|
||||
|
||||
// Update pose source badge to model inference
|
||||
this.setState({ poseSource: 'model_inference' });
|
||||
|
||||
} catch (error) {
|
||||
this.modelState.loading = false;
|
||||
this.setModelStatus(`Error: ${error.message}`);
|
||||
const loadBtn = this.container.querySelector('#load-model-btn');
|
||||
if (loadBtn) loadBtn.disabled = false;
|
||||
this.logger.error('Failed to load model', { error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
async handleUnloadModel() {
|
||||
if (!modelService) return;
|
||||
try {
|
||||
await modelService.unloadModel();
|
||||
this.modelState.activeModelId = null;
|
||||
this.modelState.activeModelInfo = null;
|
||||
this.modelState.loraProfiles = [];
|
||||
this.modelState.selectedLoraProfile = null;
|
||||
this.updateModelUI();
|
||||
this.updateSplitViewAvailability();
|
||||
this.disableSplitView();
|
||||
this.setState({ poseSource: 'signal_derived' });
|
||||
} catch (error) {
|
||||
this.setModelStatus(`Error: ${error.message}`);
|
||||
this.logger.error('Failed to unload model', { error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
async handleLoraProfileChange(profileName) {
|
||||
if (!modelService || !this.modelState.activeModelId) return;
|
||||
if (!profileName) return;
|
||||
try {
|
||||
await modelService.activateLoraProfile(this.modelState.activeModelId, profileName);
|
||||
this.modelState.selectedLoraProfile = profileName;
|
||||
this.setModelStatus(`LoRA: ${profileName} active`);
|
||||
} catch (error) {
|
||||
this.setModelStatus(`LoRA error: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
updateModelUI() {
|
||||
const loadBtn = this.container.querySelector('#load-model-btn');
|
||||
const unloadBtn = this.container.querySelector('#unload-model-btn');
|
||||
const infoRow = this.container.querySelector('#model-active-info');
|
||||
const nameEl = this.container.querySelector('#model-active-name');
|
||||
const pckEl = this.container.querySelector('#model-active-pck');
|
||||
const loraRow = this.container.querySelector('#lora-profile-row');
|
||||
const loraSel = this.container.querySelector('#lora-profile-selector');
|
||||
|
||||
const isLoaded = !!this.modelState.activeModelId;
|
||||
|
||||
if (loadBtn) loadBtn.disabled = isLoaded;
|
||||
if (unloadBtn) unloadBtn.disabled = !isLoaded;
|
||||
|
||||
if (infoRow) {
|
||||
infoRow.style.display = isLoaded ? 'flex' : 'none';
|
||||
}
|
||||
|
||||
if (isLoaded && this.modelState.activeModelInfo) {
|
||||
const info = this.modelState.activeModelInfo;
|
||||
const name = info.name || info.model_id || this.modelState.activeModelId;
|
||||
const version = info.version ? ` v${info.version}` : '';
|
||||
const pck = info.pck_score != null ? info.pck_score.toFixed(2) : '--';
|
||||
if (nameEl) nameEl.textContent = `${name}${version}`;
|
||||
if (pckEl) pckEl.textContent = `PCK: ${pck}`;
|
||||
this.setModelStatus(`Model: ${name} (PCK: ${pck})`);
|
||||
} else if (!isLoaded) {
|
||||
this.setModelStatus('No model loaded');
|
||||
}
|
||||
|
||||
// LoRA profiles
|
||||
if (loraRow && loraSel) {
|
||||
if (isLoaded && this.modelState.loraProfiles.length > 0) {
|
||||
loraRow.style.display = 'flex';
|
||||
loraSel.innerHTML = '<option value="">None</option>';
|
||||
this.modelState.loraProfiles.forEach(profile => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = profile.name || profile;
|
||||
opt.textContent = profile.name || profile;
|
||||
loraSel.appendChild(opt);
|
||||
});
|
||||
} else {
|
||||
loraRow.style.display = 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setModelStatus(text) {
|
||||
const el = this.container.querySelector('#model-status-text');
|
||||
if (el) el.textContent = text;
|
||||
}
|
||||
|
||||
// --- A/B Split View Methods ---
|
||||
|
||||
updateSplitViewAvailability() {
|
||||
const toggle = this.container.querySelector('#split-view-toggle');
|
||||
if (toggle) {
|
||||
toggle.disabled = !this.modelState.activeModelId;
|
||||
}
|
||||
}
|
||||
|
||||
toggleSplitView() {
|
||||
if (!this.modelState.activeModelId) return;
|
||||
this.splitViewActive = !this.splitViewActive;
|
||||
const toggle = this.container.querySelector('#split-view-toggle');
|
||||
if (toggle) {
|
||||
toggle.textContent = this.splitViewActive ? 'On' : 'Off';
|
||||
toggle.classList.toggle('active', this.splitViewActive);
|
||||
}
|
||||
this.updateSplitViewOverlay();
|
||||
}
|
||||
|
||||
disableSplitView() {
|
||||
this.splitViewActive = false;
|
||||
const toggle = this.container.querySelector('#split-view-toggle');
|
||||
if (toggle) {
|
||||
toggle.textContent = 'Off';
|
||||
toggle.classList.remove('active');
|
||||
}
|
||||
this.updateSplitViewOverlay();
|
||||
}
|
||||
|
||||
updateSplitViewOverlay() {
|
||||
const mainContainer = this.container.querySelector('.pose-detection-container');
|
||||
if (!mainContainer) return;
|
||||
|
||||
// Remove existing overlays
|
||||
mainContainer.querySelectorAll('.split-view-divider, .split-view-label').forEach(el => el.remove());
|
||||
|
||||
if (this.splitViewActive) {
|
||||
const divider = document.createElement('div');
|
||||
divider.className = 'split-view-divider';
|
||||
mainContainer.appendChild(divider);
|
||||
|
||||
const leftLabel = document.createElement('div');
|
||||
leftLabel.className = 'split-view-label left';
|
||||
leftLabel.textContent = 'Signal-Derived';
|
||||
mainContainer.appendChild(leftLabel);
|
||||
|
||||
const rightLabel = document.createElement('div');
|
||||
rightLabel.className = 'split-view-label right';
|
||||
rightLabel.textContent = 'Model Inference';
|
||||
mainContainer.appendChild(rightLabel);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Training Quick-Panel Methods ---
|
||||
|
||||
updateTrainingStatus() {
|
||||
const badge = this.container.querySelector('#training-status-badge');
|
||||
if (!badge) return;
|
||||
|
||||
const state = this.trainingState.status;
|
||||
badge.classList.remove('training', 'recording');
|
||||
|
||||
if (state === 'training') {
|
||||
badge.classList.add('training');
|
||||
badge.textContent = `Training epoch ${this.trainingState.epoch}/${this.trainingState.totalEpochs}`;
|
||||
} else if (state === 'recording') {
|
||||
badge.classList.add('recording');
|
||||
badge.textContent = 'Recording...';
|
||||
} else {
|
||||
badge.textContent = 'Idle';
|
||||
}
|
||||
}
|
||||
|
||||
async handleQuickRecord() {
|
||||
if (!trainingService) {
|
||||
this.logger.warn('Training service not available');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await trainingService.startRecording({ session_name: `quick_${Date.now()}`, duration_secs: 60 });
|
||||
this.trainingState.status = 'recording';
|
||||
this.updateTrainingStatus();
|
||||
// Auto-reset after ~65 seconds
|
||||
setTimeout(() => {
|
||||
if (this.trainingState.status === 'recording') {
|
||||
this.trainingState.status = 'idle';
|
||||
this.updateTrainingStatus();
|
||||
}
|
||||
}, 65000);
|
||||
} catch (error) {
|
||||
this.logger.error('Quick record failed', { error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
showTrainingPanel() {
|
||||
// Create a simple modal overlay for the training panel
|
||||
const existing = document.querySelector('.training-panel-overlay');
|
||||
if (existing) existing.remove();
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'training-panel-overlay';
|
||||
overlay.innerHTML = `
|
||||
<div class="training-panel-modal">
|
||||
<button class="close-btn" id="close-training-modal">Close</button>
|
||||
<h3>Training Panel</h3>
|
||||
<p style="color: #8899aa; font-size: 13px; margin-bottom: 16px;">
|
||||
Configure and start model training from here. Connect to the backend training API to manage epochs, datasets, and checkpoints.
|
||||
</p>
|
||||
<div style="display: flex; flex-direction: column; gap: 10px;">
|
||||
<div class="setting-row-ld">
|
||||
<label class="ld-label" style="flex: 1;">Status:</label>
|
||||
<span style="color: #c8d0dc; font-size: 12px;">${this.trainingState.status}</span>
|
||||
</div>
|
||||
<div class="setting-row-ld">
|
||||
<label class="ld-label" style="flex: 1;">Training service:</label>
|
||||
<span style="color: ${trainingService ? '#00cc88' : '#ef4444'}; font-size: 12px;">${trainingService ? 'Connected' : 'Not available'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
// Close handler
|
||||
overlay.querySelector('#close-training-modal').addEventListener('click', () => overlay.remove());
|
||||
overlay.addEventListener('click', (e) => {
|
||||
if (e.target === overlay) overlay.remove();
|
||||
});
|
||||
}
|
||||
|
||||
// --- Service Event Listeners ---
|
||||
|
||||
setupServiceListeners() {
|
||||
if (modelService) {
|
||||
const unsub1 = modelService.on('model-loaded', (data) => {
|
||||
this.logger.info('Model loaded event', data);
|
||||
});
|
||||
const unsub2 = modelService.on('model-unloaded', () => {
|
||||
this.modelState.activeModelId = null;
|
||||
this.modelState.activeModelInfo = null;
|
||||
this.updateModelUI();
|
||||
this.disableSplitView();
|
||||
});
|
||||
this.subscriptions.push(unsub1, unsub2);
|
||||
}
|
||||
|
||||
if (trainingService) {
|
||||
const unsub3 = trainingService.on('progress', (data) => {
|
||||
if (data && data.epoch != null) {
|
||||
this.trainingState.epoch = data.epoch;
|
||||
this.trainingState.totalEpochs = data.total_epochs || data.totalEpochs || this.trainingState.totalEpochs;
|
||||
this.trainingState.status = 'training';
|
||||
this.updateTrainingStatus();
|
||||
}
|
||||
});
|
||||
const unsub4 = trainingService.on('training-stopped', () => {
|
||||
this.trainingState.status = 'idle';
|
||||
this.updateTrainingStatus();
|
||||
});
|
||||
this.subscriptions.push(unsub3, unsub4);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Enhanced Controls Setup ---
|
||||
|
||||
setupModelTrainingControls() {
|
||||
// Model control buttons
|
||||
const loadBtn = this.container.querySelector('#load-model-btn');
|
||||
const unloadBtn = this.container.querySelector('#unload-model-btn');
|
||||
const loraSel = this.container.querySelector('#lora-profile-selector');
|
||||
const splitToggle = this.container.querySelector('#split-view-toggle');
|
||||
const openTrainingBtn = this.container.querySelector('#open-training-panel-btn');
|
||||
const quickRecordBtn = this.container.querySelector('#quick-record-btn');
|
||||
|
||||
if (loadBtn) loadBtn.addEventListener('click', () => this.handleLoadModel());
|
||||
if (unloadBtn) unloadBtn.addEventListener('click', () => this.handleUnloadModel());
|
||||
if (loraSel) loraSel.addEventListener('change', (e) => this.handleLoraProfileChange(e.target.value));
|
||||
if (splitToggle) splitToggle.addEventListener('click', () => this.toggleSplitView());
|
||||
if (openTrainingBtn) openTrainingBtn.addEventListener('click', () => this.showTrainingPanel());
|
||||
if (quickRecordBtn) quickRecordBtn.addEventListener('click', () => this.handleQuickRecord());
|
||||
}
|
||||
|
||||
// Clean up
|
||||
dispose() {
|
||||
try {
|
||||
@@ -1088,6 +1874,9 @@ export class LiveDemoTab {
|
||||
// Unsubscribe from services
|
||||
this.subscriptions.forEach(unsubscribe => unsubscribe());
|
||||
this.subscriptions = [];
|
||||
if (this._sensingStateUnsub) this._sensingStateUnsub();
|
||||
if (this._sensingDataUnsub) this._sensingDataUnsub();
|
||||
if (this._autoStartUnsub) this._autoStartUnsub();
|
||||
|
||||
this.logger.info('LiveDemoTab component disposed successfully');
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
// ModelPanel Component for WiFi-DensePose UI
|
||||
// Dark-mode panel for model management: listing, loading, LoRA profiles.
|
||||
|
||||
import { modelService } from '../services/model.service.js';
|
||||
|
||||
const MP_STYLES = `
|
||||
.mp-panel{background:rgba(17,24,39,.9);border:1px solid rgba(56,68,89,.6);border-radius:8px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;color:#e0e0e0;overflow:hidden}
|
||||
.mp-header{display:flex;align-items:center;justify-content:space-between;padding:14px 16px;background:rgba(13,17,23,.95);border-bottom:1px solid rgba(56,68,89,.6)}
|
||||
.mp-title{font-size:14px;font-weight:600;color:#e0e0e0}
|
||||
.mp-badge{background:rgba(102,126,234,.2);color:#8ea4f0;font-size:11px;font-weight:600;padding:2px 8px;border-radius:10px;border:1px solid rgba(102,126,234,.3)}
|
||||
.mp-error{background:rgba(220,53,69,.15);color:#f5a0a8;border:1px solid rgba(220,53,69,.3);border-radius:4px;padding:8px 12px;margin:10px 12px 0;font-size:12px}
|
||||
.mp-active-card{margin:12px;padding:12px;background:rgba(13,17,23,.8);border:1px solid rgba(56,68,89,.6);border-left:3px solid #28a745;border-radius:6px}
|
||||
.mp-active-name{font-size:14px;font-weight:600;color:#c8d0dc;margin-bottom:6px}
|
||||
.mp-active-meta{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:8px}
|
||||
.mp-active-stats{font-size:12px;color:#8899aa;margin-bottom:10px}
|
||||
.mp-stat-label{color:#8899aa}.mp-stat-value{color:#c8d0dc;font-weight:500}.mp-stat-sep{color:rgba(56,68,89,.8);margin:0 6px}
|
||||
.mp-lora-row{display:flex;align-items:center;gap:8px;margin-bottom:10px}
|
||||
.mp-lora-label{font-size:12px;color:#8899aa}
|
||||
.mp-lora-select{flex:1;padding:4px 8px;background:rgba(30,40,60,.8);border:1px solid rgba(56,68,89,.6);border-radius:4px;color:#c8d0dc;font-size:12px}
|
||||
.mp-list-section{padding:0 12px 12px}
|
||||
.mp-section-title{font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.5px;color:#8899aa;padding:10px 0 8px}
|
||||
.mp-model-card{padding:10px;margin-bottom:8px;background:rgba(13,17,23,.6);border:1px solid rgba(56,68,89,.4);border-radius:6px;transition:border-color .2s}
|
||||
.mp-model-card:hover{border-color:rgba(102,126,234,.4)}
|
||||
.mp-card-name{font-size:13px;font-weight:500;color:#c8d0dc;margin-bottom:4px}
|
||||
.mp-card-meta{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:8px}
|
||||
.mp-meta-tag{background:rgba(30,40,60,.8);color:#8899aa;font-size:10px;padding:2px 6px;border-radius:3px;border:1px solid rgba(56,68,89,.4)}
|
||||
.mp-card-actions{display:flex;gap:6px}
|
||||
.mp-empty{color:#6b7a8d;font-size:12px;padding:16px 0;text-align:center;line-height:1.5}
|
||||
.mp-footer{padding:10px 12px;border-top:1px solid rgba(56,68,89,.4);display:flex;justify-content:flex-end}
|
||||
.mp-btn{padding:5px 12px;border-radius:4px;font-size:12px;font-weight:500;cursor:pointer;border:1px solid transparent;transition:all .15s}
|
||||
.mp-btn:disabled{opacity:.5;cursor:not-allowed}
|
||||
.mp-btn-success{background:rgba(40,167,69,.2);color:#51cf66;border-color:rgba(40,167,69,.3)}
|
||||
.mp-btn-success:hover:not(:disabled){background:rgba(40,167,69,.35)}
|
||||
.mp-btn-danger{background:rgba(220,53,69,.2);color:#ff6b6b;border-color:rgba(220,53,69,.3)}
|
||||
.mp-btn-danger:hover:not(:disabled){background:rgba(220,53,69,.35)}
|
||||
.mp-btn-secondary{background:rgba(30,40,60,.8);color:#b0b8c8;border-color:rgba(56,68,89,.6)}
|
||||
.mp-btn-secondary:hover:not(:disabled){background:rgba(40,50,75,.9)}
|
||||
.mp-btn-muted{background:transparent;color:#6b7a8d;border-color:rgba(56,68,89,.4);font-size:11px;padding:4px 8px}
|
||||
.mp-btn-muted:hover:not(:disabled){color:#ff6b6b;border-color:rgba(220,53,69,.3)}
|
||||
`;
|
||||
|
||||
export default class ModelPanel {
|
||||
constructor(container) {
|
||||
this.container = typeof container === 'string'
|
||||
? document.getElementById(container) : container;
|
||||
if (!this.container) throw new Error('ModelPanel: container element not found');
|
||||
|
||||
this.state = { models: [], activeModel: null, loraProfiles: [], loading: false, error: null };
|
||||
this.unsubs = [];
|
||||
this._injectStyles();
|
||||
this.render();
|
||||
this.refresh();
|
||||
this.unsubs.push(
|
||||
modelService.on('model-loaded', () => this.refresh()),
|
||||
modelService.on('model-unloaded', () => this.refresh()),
|
||||
modelService.on('lora-activated', () => this.refresh())
|
||||
);
|
||||
}
|
||||
|
||||
// --- Data ---
|
||||
|
||||
async refresh() {
|
||||
this._set({ loading: true, error: null });
|
||||
try {
|
||||
const [listRes, active] = await Promise.all([
|
||||
modelService.listModels().catch(() => ({ models: [] })),
|
||||
modelService.getActiveModel().catch(() => null)
|
||||
]);
|
||||
let lora = [];
|
||||
if (active) lora = await modelService.getLoraProfiles().catch(() => []);
|
||||
this._set({ models: listRes?.models ?? [], activeModel: active, loraProfiles: lora, loading: false });
|
||||
} catch (e) { this._set({ loading: false, error: e.message }); }
|
||||
}
|
||||
|
||||
// --- Actions ---
|
||||
|
||||
async _load(id) {
|
||||
this._set({ loading: true, error: null });
|
||||
try { await modelService.loadModel(id); await this.refresh(); }
|
||||
catch (e) { this._set({ loading: false, error: `Load failed: ${e.message}` }); }
|
||||
}
|
||||
|
||||
async _unload() {
|
||||
this._set({ loading: true, error: null });
|
||||
try { await modelService.unloadModel(); await this.refresh(); }
|
||||
catch (e) { this._set({ loading: false, error: `Unload failed: ${e.message}` }); }
|
||||
}
|
||||
|
||||
async _delete(id) {
|
||||
this._set({ loading: true, error: null });
|
||||
try { await modelService.deleteModel(id); await this.refresh(); }
|
||||
catch (e) { this._set({ loading: false, error: `Delete failed: ${e.message}` }); }
|
||||
}
|
||||
|
||||
async _loraChange(modelId, profile) {
|
||||
if (!profile) return;
|
||||
this._set({ loading: true, error: null });
|
||||
try { await modelService.activateLoraProfile(modelId, profile); await this.refresh(); }
|
||||
catch (e) { this._set({ loading: false, error: `LoRA failed: ${e.message}` }); }
|
||||
}
|
||||
|
||||
_set(p) { Object.assign(this.state, p); this.render(); }
|
||||
|
||||
// --- Render ---
|
||||
|
||||
render() {
|
||||
const el = this.container;
|
||||
el.innerHTML = '';
|
||||
const panel = this._el('div', 'mp-panel');
|
||||
|
||||
// Header
|
||||
const hdr = this._el('div', 'mp-header');
|
||||
hdr.appendChild(this._el('span', 'mp-title', 'Model Library'));
|
||||
hdr.appendChild(this._el('span', 'mp-badge', String(this.state.models.length)));
|
||||
panel.appendChild(hdr);
|
||||
|
||||
if (this.state.error) panel.appendChild(this._el('div', 'mp-error', this.state.error));
|
||||
|
||||
// Active model
|
||||
if (this.state.activeModel) panel.appendChild(this._renderActive());
|
||||
|
||||
// List
|
||||
const ls = this._el('div', 'mp-list-section');
|
||||
ls.appendChild(this._el('div', 'mp-section-title', 'Available Models'));
|
||||
const models = this.state.models.filter(
|
||||
m => !(this.state.activeModel && this.state.activeModel.model_id === m.id)
|
||||
);
|
||||
if (models.length === 0 && !this.state.loading) {
|
||||
ls.appendChild(this._el('div', 'mp-empty', 'No .rvf models found. Train a model or place .rvf files in data/models/'));
|
||||
} else {
|
||||
models.forEach(m => ls.appendChild(this._renderCard(m)));
|
||||
}
|
||||
panel.appendChild(ls);
|
||||
|
||||
// Footer
|
||||
const ft = this._el('div', 'mp-footer');
|
||||
const rb = this._btn('Refresh', 'mp-btn mp-btn-secondary', () => this.refresh());
|
||||
rb.disabled = this.state.loading;
|
||||
ft.appendChild(rb);
|
||||
panel.appendChild(ft);
|
||||
|
||||
el.appendChild(panel);
|
||||
}
|
||||
|
||||
_renderActive() {
|
||||
const am = this.state.activeModel;
|
||||
const card = this._el('div', 'mp-active-card');
|
||||
card.appendChild(this._el('div', 'mp-active-name', am.model_id || 'Active Model'));
|
||||
|
||||
const full = this.state.models.find(m => m.id === am.model_id);
|
||||
if (full) {
|
||||
const meta = this._el('div', 'mp-active-meta');
|
||||
if (full.version) meta.appendChild(this._tag('v' + full.version));
|
||||
if (full.pck_score != null) meta.appendChild(this._tag('PCK ' + (full.pck_score * 100).toFixed(1) + '%'));
|
||||
card.appendChild(meta);
|
||||
}
|
||||
|
||||
if (am.avg_inference_ms != null) {
|
||||
const st = this._el('div', 'mp-active-stats');
|
||||
st.innerHTML = `<span class="mp-stat-label">Inference:</span> <span class="mp-stat-value">${am.avg_inference_ms.toFixed(1)} ms</span><span class="mp-stat-sep">|</span><span class="mp-stat-label">Frames:</span> <span class="mp-stat-value">${am.frames_processed ?? 0}</span>`;
|
||||
card.appendChild(st);
|
||||
}
|
||||
|
||||
if (this.state.loraProfiles.length > 0) {
|
||||
const row = this._el('div', 'mp-lora-row');
|
||||
row.appendChild(this._el('span', 'mp-lora-label', 'LoRA Profile:'));
|
||||
const sel = document.createElement('select');
|
||||
sel.className = 'mp-lora-select';
|
||||
const def = document.createElement('option');
|
||||
def.value = ''; def.textContent = '-- none --'; sel.appendChild(def);
|
||||
this.state.loraProfiles.forEach(p => {
|
||||
const o = document.createElement('option');
|
||||
o.value = p; o.textContent = p; sel.appendChild(o);
|
||||
});
|
||||
sel.addEventListener('change', () => this._loraChange(am.model_id, sel.value));
|
||||
row.appendChild(sel);
|
||||
card.appendChild(row);
|
||||
}
|
||||
|
||||
const ub = this._btn('Unload', 'mp-btn mp-btn-danger', () => this._unload());
|
||||
ub.disabled = this.state.loading;
|
||||
card.appendChild(ub);
|
||||
return card;
|
||||
}
|
||||
|
||||
_renderCard(model) {
|
||||
const card = this._el('div', 'mp-model-card');
|
||||
card.appendChild(this._el('div', 'mp-card-name', model.filename || model.id));
|
||||
const meta = this._el('div', 'mp-card-meta');
|
||||
if (model.version) meta.appendChild(this._tag('v' + model.version));
|
||||
if (model.size_bytes != null) meta.appendChild(this._tag(this._fmtB(model.size_bytes)));
|
||||
if (model.pck_score != null) meta.appendChild(this._tag('PCK ' + (model.pck_score * 100).toFixed(1) + '%'));
|
||||
if (model.lora_profiles && model.lora_profiles.length > 0) meta.appendChild(this._tag(model.lora_profiles.length + ' LoRA'));
|
||||
card.appendChild(meta);
|
||||
|
||||
const acts = this._el('div', 'mp-card-actions');
|
||||
const lb = this._btn('Load', 'mp-btn mp-btn-success', () => this._load(model.id));
|
||||
lb.disabled = this.state.loading;
|
||||
const db = this._btn('Delete', 'mp-btn mp-btn-muted', () => this._delete(model.id));
|
||||
db.disabled = this.state.loading;
|
||||
acts.appendChild(lb); acts.appendChild(db);
|
||||
card.appendChild(acts);
|
||||
return card;
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
_el(tag, cls, txt) { const e = document.createElement(tag); if (cls) e.className = cls; if (txt != null) e.textContent = txt; return e; }
|
||||
_btn(txt, cls, fn) { const b = document.createElement('button'); b.className = cls; b.textContent = txt; b.addEventListener('click', fn); return b; }
|
||||
_tag(txt) { return this._el('span', 'mp-meta-tag', txt); }
|
||||
_fmtB(b) { return b < 1024 ? b + ' B' : b < 1048576 ? (b / 1024).toFixed(1) + ' KB' : (b / 1048576).toFixed(1) + ' MB'; }
|
||||
|
||||
_injectStyles() {
|
||||
if (document.getElementById('model-panel-styles')) return;
|
||||
const s = document.createElement('style');
|
||||
s.id = 'model-panel-styles';
|
||||
s.textContent = MP_STYLES;
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.unsubs.forEach(fn => fn());
|
||||
this.unsubs = [];
|
||||
if (this.container) this.container.innerHTML = '';
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.destroy();
|
||||
}
|
||||
}
|
||||
@@ -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}">▶ Start</button>
|
||||
<button class="btn btn-stop" id="stop-btn-${this.containerId}" disabled>■ Stop</button>
|
||||
<button class="btn btn-reconnect" id="reconnect-btn-${this.containerId}" disabled>↻ 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}">◌ Trail</button>
|
||||
<button class="btn btn-settings" id="settings-btn-${this.containerId}">⚙ 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) {
|
||||
|
||||
@@ -216,9 +216,10 @@ export class SensingTab {
|
||||
// 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' },
|
||||
'live': { text: 'LIVE \u2014 ESP32 HARDWARE', cls: 'sensing-source-live' },
|
||||
'server-simulated': { text: 'SIMULATED \u2014 NO HARDWARE', cls: 'sensing-source-server-sim' },
|
||||
'reconnecting': { text: 'RECONNECTING...', cls: 'sensing-source-reconnecting' },
|
||||
'simulated': { text: 'OFFLINE \u2014 CLIENT SIMULATION', cls: 'sensing-source-simulated' },
|
||||
};
|
||||
const cfg = bannerConfig[dataSource] || bannerConfig.reconnecting;
|
||||
banner.textContent = cfg.text;
|
||||
@@ -256,7 +257,8 @@ export class SensingTab {
|
||||
// Details
|
||||
this._setText('valDomFreq', (f.dominant_freq_hz || 0).toFixed(3) + ' Hz');
|
||||
this._setText('valChangePoints', String(f.change_points || 0));
|
||||
this._setText('valSampleRate', data.source === 'simulated' ? 'sim' : 'live');
|
||||
const srcLabel = (data.source === 'simulated' || data.source === 'simulate') ? 'sim' : data.source || 'live';
|
||||
this._setText('valSampleRate', srcLabel);
|
||||
|
||||
// Sparkline
|
||||
this._drawSparkline();
|
||||
|
||||
+196
-38
@@ -55,7 +55,23 @@ export class SettingsPanel {
|
||||
// Advanced settings
|
||||
heartbeatInterval: 30000,
|
||||
maxReconnectAttempts: 10,
|
||||
enableSmoothing: true
|
||||
enableSmoothing: true,
|
||||
|
||||
// Model settings
|
||||
defaultModelPath: 'data/models/',
|
||||
autoLoadModel: false,
|
||||
inferenceDevice: 'CPU',
|
||||
inferenceThreads: 4,
|
||||
progressiveLoading: true,
|
||||
|
||||
// Training settings
|
||||
defaultEpochs: 100,
|
||||
defaultBatchSize: 32,
|
||||
defaultLearningRate: 0.0003,
|
||||
earlyStoppingPatience: 15,
|
||||
checkpointDirectory: 'data/models/',
|
||||
autoExportOnCompletion: true,
|
||||
recordingDirectory: 'data/recordings/'
|
||||
};
|
||||
|
||||
this.callbacks = {
|
||||
@@ -245,6 +261,67 @@ export class SettingsPanel {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Model Settings -->
|
||||
<div class="settings-section">
|
||||
<h4>Model Configuration</h4>
|
||||
<div class="setting-row">
|
||||
<label for="default-model-path-${this.containerId}">Default Model Path:</label>
|
||||
<input type="text" id="default-model-path-${this.containerId}" class="setting-input setting-input-wide" placeholder="data/models/">
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<label for="auto-load-model-${this.containerId}">Auto-load Model on Startup:</label>
|
||||
<input type="checkbox" id="auto-load-model-${this.containerId}" class="setting-checkbox">
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<label for="inference-device-${this.containerId}">Inference Device:</label>
|
||||
<select id="inference-device-${this.containerId}" class="setting-select">
|
||||
<option value="CPU">CPU</option>
|
||||
<option value="GPU">GPU</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<label for="inference-threads-${this.containerId}">Inference Threads:</label>
|
||||
<input type="number" id="inference-threads-${this.containerId}" class="setting-input" min="1" max="16">
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<label for="progressive-loading-${this.containerId}">Progressive Loading:</label>
|
||||
<input type="checkbox" id="progressive-loading-${this.containerId}" class="setting-checkbox">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Training Settings -->
|
||||
<div class="settings-section">
|
||||
<h4>Training Configuration</h4>
|
||||
<div class="setting-row">
|
||||
<label for="default-epochs-${this.containerId}">Default Epochs:</label>
|
||||
<input type="number" id="default-epochs-${this.containerId}" class="setting-input" min="1" max="10000">
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<label for="default-batch-size-${this.containerId}">Default Batch Size:</label>
|
||||
<input type="number" id="default-batch-size-${this.containerId}" class="setting-input" min="1" max="512">
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<label for="default-learning-rate-${this.containerId}">Default Learning Rate:</label>
|
||||
<input type="number" id="default-learning-rate-${this.containerId}" class="setting-input" min="0.000001" max="1" step="0.0001">
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<label for="early-stopping-patience-${this.containerId}">Early Stopping Patience:</label>
|
||||
<input type="number" id="early-stopping-patience-${this.containerId}" class="setting-input" min="1" max="100">
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<label for="checkpoint-directory-${this.containerId}">Checkpoint Directory:</label>
|
||||
<input type="text" id="checkpoint-directory-${this.containerId}" class="setting-input setting-input-wide" placeholder="data/models/">
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<label for="auto-export-on-completion-${this.containerId}">Auto-export on Completion:</label>
|
||||
<input type="checkbox" id="auto-export-on-completion-${this.containerId}" class="setting-checkbox">
|
||||
</div>
|
||||
<div class="setting-row">
|
||||
<label for="recording-directory-${this.containerId}">Recording Directory:</label>
|
||||
<input type="text" id="recording-directory-${this.containerId}" class="setting-input setting-input-wide" placeholder="data/recordings/">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-toggle">
|
||||
<button class="btn btn-sm" id="toggle-advanced-${this.containerId}">Show Advanced</button>
|
||||
</div>
|
||||
@@ -267,11 +344,12 @@ export class SettingsPanel {
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
.settings-panel {
|
||||
background: #fff;
|
||||
border: 1px solid #ddd;
|
||||
background: #0d1117;
|
||||
border: 1px solid rgba(56, 68, 89, 0.6);
|
||||
border-radius: 8px;
|
||||
font-family: Arial, sans-serif;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
overflow: hidden;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.settings-header {
|
||||
@@ -279,13 +357,13 @@ export class SettingsPanel {
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 15px 20px;
|
||||
background: #f8f9fa;
|
||||
border-bottom: 1px solid #ddd;
|
||||
background: rgba(15, 20, 35, 0.95);
|
||||
border-bottom: 1px solid rgba(56, 68, 89, 0.6);
|
||||
}
|
||||
|
||||
.settings-header h3 {
|
||||
margin: 0;
|
||||
color: #333;
|
||||
color: #e0e0e0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -297,26 +375,43 @@ export class SettingsPanel {
|
||||
|
||||
.settings-content {
|
||||
padding: 20px;
|
||||
max-height: 400px;
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.settings-content::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.settings-content::-webkit-scrollbar-track {
|
||||
background: rgba(15, 20, 35, 0.5);
|
||||
}
|
||||
|
||||
.settings-content::-webkit-scrollbar-thumb {
|
||||
background: rgba(56, 68, 89, 0.8);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.settings-content::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(80, 96, 120, 0.9);
|
||||
}
|
||||
|
||||
.settings-section {
|
||||
margin-bottom: 25px;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 1px solid #eee;
|
||||
padding: 16px;
|
||||
background: rgba(17, 24, 39, 0.9);
|
||||
border: 1px solid rgba(56, 68, 89, 0.4);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.settings-section:last-child {
|
||||
border-bottom: none;
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.settings-section h4 {
|
||||
margin: 0 0 15px 0;
|
||||
color: #555;
|
||||
font-size: 14px;
|
||||
color: #8899aa;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
@@ -332,7 +427,7 @@ export class SettingsPanel {
|
||||
|
||||
.setting-row label {
|
||||
flex: 1;
|
||||
color: #666;
|
||||
color: #8899aa;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
@@ -340,9 +435,26 @@ export class SettingsPanel {
|
||||
.setting-input, .setting-select {
|
||||
flex: 0 0 120px;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid #ddd;
|
||||
border: 1px solid rgba(56, 68, 89, 0.6);
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
background: rgba(15, 20, 35, 0.8);
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.setting-input:focus, .setting-select:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.15);
|
||||
}
|
||||
|
||||
.setting-input-wide {
|
||||
flex: 0 0 160px;
|
||||
}
|
||||
|
||||
.setting-select option {
|
||||
background: #1a2234;
|
||||
color: #c8d0dc;
|
||||
}
|
||||
|
||||
.setting-range {
|
||||
@@ -353,41 +465,45 @@ export class SettingsPanel {
|
||||
.setting-value {
|
||||
flex: 0 0 40px;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
color: #b0b8c8;
|
||||
text-align: center;
|
||||
background: #f8f9fa;
|
||||
background: rgba(15, 20, 35, 0.8);
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid #ddd;
|
||||
border: 1px solid rgba(56, 68, 89, 0.6);
|
||||
}
|
||||
|
||||
.setting-checkbox {
|
||||
flex: 0 0 auto;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
accent-color: #667eea;
|
||||
}
|
||||
|
||||
.setting-color {
|
||||
flex: 0 0 50px;
|
||||
height: 30px;
|
||||
border: 1px solid #ddd;
|
||||
border: 1px solid rgba(56, 68, 89, 0.6);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
background: rgba(15, 20, 35, 0.8);
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid #ddd;
|
||||
border: 1px solid rgba(56, 68, 89, 0.6);
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
background: rgba(30, 40, 60, 0.8);
|
||||
color: #b0b8c8;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn:hover {
|
||||
background: #f8f9fa;
|
||||
border-color: #adb5bd;
|
||||
background: rgba(40, 55, 80, 0.9);
|
||||
border-color: rgba(80, 96, 120, 0.8);
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
@@ -398,32 +514,32 @@ export class SettingsPanel {
|
||||
.settings-toggle {
|
||||
text-align: center;
|
||||
padding-top: 15px;
|
||||
border-top: 1px solid #eee;
|
||||
border-top: 1px solid rgba(56, 68, 89, 0.4);
|
||||
}
|
||||
|
||||
.settings-footer {
|
||||
padding: 10px 20px;
|
||||
background: #f8f9fa;
|
||||
border-top: 1px solid #ddd;
|
||||
background: rgba(15, 20, 35, 0.95);
|
||||
border-top: 1px solid rgba(56, 68, 89, 0.6);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.settings-status {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
color: #6b7a8d;
|
||||
}
|
||||
|
||||
.advanced-section {
|
||||
background: #f9f9f9;
|
||||
background: rgba(20, 28, 45, 0.9);
|
||||
margin: 0 -20px 25px -20px;
|
||||
padding: 20px;
|
||||
border: none;
|
||||
border-top: 1px solid #ddd;
|
||||
border-bottom: 1px solid #ddd;
|
||||
border-top: 1px solid rgba(56, 68, 89, 0.4);
|
||||
border-bottom: 1px solid rgba(56, 68, 89, 0.4);
|
||||
}
|
||||
|
||||
.advanced-section h4 {
|
||||
color: #dc3545;
|
||||
color: #ef4444;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -492,7 +608,9 @@ export class SettingsPanel {
|
||||
const checkboxes = [
|
||||
'auto-reconnect', 'show-keypoints', 'show-skeleton', 'show-bounding-box',
|
||||
'show-confidence', 'show-zones', 'show-debug-info', 'enable-validation',
|
||||
'enable-performance-tracking', 'enable-debug-logging', 'enable-smoothing'
|
||||
'enable-performance-tracking', 'enable-debug-logging', 'enable-smoothing',
|
||||
'auto-load-model', 'progressive-loading',
|
||||
'auto-export-on-completion'
|
||||
];
|
||||
|
||||
checkboxes.forEach(id => {
|
||||
@@ -503,12 +621,14 @@ export class SettingsPanel {
|
||||
});
|
||||
});
|
||||
|
||||
// Number inputs
|
||||
// Number inputs (integers)
|
||||
const numberInputs = [
|
||||
'connection-timeout', 'max-persons', 'max-fps',
|
||||
'heartbeat-interval', 'max-reconnect-attempts'
|
||||
'connection-timeout', 'max-persons', 'max-fps',
|
||||
'heartbeat-interval', 'max-reconnect-attempts',
|
||||
'inference-threads', 'default-epochs', 'default-batch-size',
|
||||
'early-stopping-patience'
|
||||
];
|
||||
|
||||
|
||||
numberInputs.forEach(id => {
|
||||
const input = document.getElementById(`${id}-${this.containerId}`);
|
||||
input?.addEventListener('change', (e) => {
|
||||
@@ -517,6 +637,32 @@ export class SettingsPanel {
|
||||
});
|
||||
});
|
||||
|
||||
// Float number inputs
|
||||
const floatInputs = ['default-learning-rate'];
|
||||
floatInputs.forEach(id => {
|
||||
const input = document.getElementById(`${id}-${this.containerId}`);
|
||||
input?.addEventListener('change', (e) => {
|
||||
const settingKey = this.camelCase(id);
|
||||
this.updateSetting(settingKey, parseFloat(e.target.value));
|
||||
});
|
||||
});
|
||||
|
||||
// Text inputs
|
||||
const textInputs = ['default-model-path', 'checkpoint-directory', 'recording-directory'];
|
||||
textInputs.forEach(id => {
|
||||
const input = document.getElementById(`${id}-${this.containerId}`);
|
||||
input?.addEventListener('change', (e) => {
|
||||
const settingKey = this.camelCase(id);
|
||||
this.updateSetting(settingKey, e.target.value);
|
||||
});
|
||||
});
|
||||
|
||||
// Inference device select
|
||||
const inferenceDeviceSelect = document.getElementById(`inference-device-${this.containerId}`);
|
||||
inferenceDeviceSelect?.addEventListener('change', (e) => {
|
||||
this.updateSetting('inferenceDevice', e.target.value);
|
||||
});
|
||||
|
||||
// Color inputs
|
||||
const colorInputs = ['skeleton-color', 'keypoint-color', 'bounding-box-color'];
|
||||
colorInputs.forEach(id => {
|
||||
@@ -696,7 +842,19 @@ export class SettingsPanel {
|
||||
enableDebugLogging: false,
|
||||
heartbeatInterval: 30000,
|
||||
maxReconnectAttempts: 10,
|
||||
enableSmoothing: true
|
||||
enableSmoothing: true,
|
||||
defaultModelPath: 'data/models/',
|
||||
autoLoadModel: false,
|
||||
inferenceDevice: 'CPU',
|
||||
inferenceThreads: 4,
|
||||
progressiveLoading: true,
|
||||
defaultEpochs: 100,
|
||||
defaultBatchSize: 32,
|
||||
defaultLearningRate: 0.0003,
|
||||
earlyStoppingPatience: 15,
|
||||
checkpointDirectory: 'data/models/',
|
||||
autoExportOnCompletion: true,
|
||||
recordingDirectory: 'data/recordings/'
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,419 @@
|
||||
// TrainingPanel Component for WiFi-DensePose UI
|
||||
// Dark-mode panel for training management, CSI recordings, and progress charts.
|
||||
|
||||
import { trainingService } from '../services/training.service.js';
|
||||
|
||||
const TP_STYLES = `
|
||||
.tp-panel{background:rgba(17,24,39,.9);border:1px solid rgba(56,68,89,.6);border-radius:8px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;color:#e0e0e0;overflow:hidden}
|
||||
.tp-header{display:flex;align-items:center;justify-content:space-between;padding:14px 16px;background:rgba(13,17,23,.95);border-bottom:1px solid rgba(56,68,89,.6)}
|
||||
.tp-title{font-size:14px;font-weight:600;color:#e0e0e0}
|
||||
.tp-badge{font-size:11px;font-weight:600;padding:2px 8px;border-radius:10px}
|
||||
.tp-badge-idle{background:rgba(108,117,125,.2);color:#8899aa;border:1px solid rgba(108,117,125,.3)}
|
||||
.tp-badge-active{background:rgba(40,167,69,.2);color:#51cf66;border:1px solid rgba(40,167,69,.3);animation:tp-pulse 1.5s ease-in-out infinite}
|
||||
.tp-badge-done{background:rgba(102,126,234,.2);color:#8ea4f0;border:1px solid rgba(102,126,234,.3)}
|
||||
@keyframes tp-pulse{0%,100%{opacity:1}50%{opacity:.6}}
|
||||
.tp-error{background:rgba(220,53,69,.15);color:#f5a0a8;border:1px solid rgba(220,53,69,.3);border-radius:4px;padding:8px 12px;margin:10px 12px 0;font-size:12px}
|
||||
.tp-section{padding:12px;border-bottom:1px solid rgba(56,68,89,.3)}
|
||||
.tp-section:last-child{border-bottom:none}
|
||||
.tp-section-title{font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.5px;color:#8899aa;margin-bottom:8px}
|
||||
.tp-empty{color:#6b7a8d;font-size:12px;padding:12px 0;text-align:center}
|
||||
.tp-rec-row{display:flex;align-items:center;justify-content:space-between;padding:6px 8px;margin-bottom:4px;background:rgba(13,17,23,.6);border:1px solid rgba(56,68,89,.3);border-radius:4px}
|
||||
.tp-rec-info{display:flex;flex-direction:column;gap:2px}
|
||||
.tp-rec-name{font-size:12px;color:#c8d0dc;font-weight:500}
|
||||
.tp-rec-meta{font-size:10px;color:#6b7a8d}
|
||||
.tp-rec-actions{margin-top:8px}
|
||||
.tp-config-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:6px}
|
||||
.tp-config-form{display:flex;flex-direction:column;gap:6px}
|
||||
.tp-label{font-size:12px;color:#8899aa;display:block;margin-bottom:2px}
|
||||
.tp-input-row{display:flex;justify-content:space-between;align-items:center;gap:8px}
|
||||
.tp-input-row .tp-label{flex:1;margin-bottom:0}
|
||||
.tp-input{width:110px;padding:4px 8px;background:rgba(30,40,60,.8);border:1px solid rgba(56,68,89,.6);border-radius:4px;color:#c8d0dc;font-size:12px}
|
||||
.tp-input:focus{outline:none;border-color:#667eea}
|
||||
.tp-ds-container{display:flex;flex-direction:column;gap:4px;margin-bottom:4px;max-height:100px;overflow-y:auto}
|
||||
.tp-ds-item{display:flex;align-items:center;gap:6px;font-size:12px;color:#c8d0dc;cursor:pointer}
|
||||
.tp-ds-item input{width:14px;height:14px}
|
||||
.tp-train-actions{display:flex;gap:6px;margin-top:10px}
|
||||
.tp-progress-bar{height:6px;background:rgba(30,40,60,.8);border-radius:3px;overflow:hidden;margin-bottom:4px}
|
||||
.tp-progress-fill{height:100%;background:linear-gradient(90deg,#667eea,#764ba2);border-radius:3px;transition:width .3s}
|
||||
.tp-progress-label{font-size:11px;color:#8899aa;text-align:center;margin-bottom:10px}
|
||||
.tp-chart-row{display:flex;gap:8px;margin-bottom:10px;flex-wrap:wrap}
|
||||
.tp-chart-row canvas{border:1px solid rgba(56,68,89,.4);border-radius:4px;flex:1;min-width:120px}
|
||||
.tp-metrics-grid{display:grid;grid-template-columns:1fr 1fr;gap:6px}
|
||||
.tp-metric-cell{background:rgba(13,17,23,.6);border:1px solid rgba(56,68,89,.3);border-radius:4px;padding:6px 8px}
|
||||
.tp-metric-label{font-size:10px;color:#6b7a8d;text-transform:uppercase;letter-spacing:.3px}
|
||||
.tp-metric-value{font-size:13px;color:#c8d0dc;font-weight:500;margin-top:2px}
|
||||
.tp-btn{padding:5px 12px;border-radius:4px;font-size:12px;font-weight:500;cursor:pointer;border:1px solid transparent;transition:all .15s}
|
||||
.tp-btn:disabled{opacity:.5;cursor:not-allowed}
|
||||
.tp-btn-success{background:rgba(40,167,69,.2);color:#51cf66;border-color:rgba(40,167,69,.3)}
|
||||
.tp-btn-success:hover:not(:disabled){background:rgba(40,167,69,.35)}
|
||||
.tp-btn-danger{background:rgba(220,53,69,.2);color:#ff6b6b;border-color:rgba(220,53,69,.3)}
|
||||
.tp-btn-danger:hover:not(:disabled){background:rgba(220,53,69,.35)}
|
||||
.tp-btn-secondary{background:rgba(30,40,60,.8);color:#b0b8c8;border-color:rgba(56,68,89,.6)}
|
||||
.tp-btn-secondary:hover:not(:disabled){background:rgba(40,50,75,.9)}
|
||||
.tp-btn-rec{background:rgba(220,53,69,.15);color:#ff6b6b;border-color:rgba(220,53,69,.3)}
|
||||
.tp-btn-rec:hover:not(:disabled){background:rgba(220,53,69,.3)}
|
||||
.tp-btn-muted{background:transparent;color:#6b7a8d;border-color:rgba(56,68,89,.4);font-size:11px;padding:3px 8px}
|
||||
.tp-btn-muted:hover:not(:disabled){color:#b0b8c8;border-color:rgba(56,68,89,.8)}
|
||||
`;
|
||||
|
||||
export default class TrainingPanel {
|
||||
constructor(container) {
|
||||
this.container = typeof container === 'string'
|
||||
? document.getElementById(container) : container;
|
||||
if (!this.container) throw new Error('TrainingPanel: container element not found');
|
||||
|
||||
this.state = {
|
||||
recordings: [], trainingStatus: null, isRecording: false,
|
||||
configOpen: true, loading: false, error: null
|
||||
};
|
||||
this.config = {
|
||||
epochs: 100, batch_size: 32, learning_rate: 3e-4, patience: 15,
|
||||
selectedRecordings: [], base_model: '', lora_profile_name: ''
|
||||
};
|
||||
this.progressData = { losses: [], pcks: [] };
|
||||
this.unsubscribers = [];
|
||||
this._injectStyles();
|
||||
this.render();
|
||||
this.refresh();
|
||||
this._bindEvents();
|
||||
}
|
||||
|
||||
_bindEvents() {
|
||||
this.unsubscribers.push(
|
||||
trainingService.on('progress', (d) => this._onProgress(d)),
|
||||
trainingService.on('training-started', () => this.refresh()),
|
||||
trainingService.on('training-stopped', () => {
|
||||
trainingService.disconnectProgressStream();
|
||||
this.refresh();
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
_onProgress(data) {
|
||||
if (data.train_loss != null) this.progressData.losses.push(data.train_loss);
|
||||
if (data.val_pck != null) this.progressData.pcks.push(data.val_pck);
|
||||
this._set({ trainingStatus: { ...this.state.trainingStatus, ...data } });
|
||||
}
|
||||
|
||||
// --- Data ---
|
||||
|
||||
async refresh() {
|
||||
this._set({ loading: true, error: null });
|
||||
try {
|
||||
const [recordings, status] = await Promise.all([
|
||||
trainingService.listRecordings().catch(() => []),
|
||||
trainingService.getTrainingStatus().catch(() => null)
|
||||
]);
|
||||
if (status && !status.active) this.progressData = { losses: [], pcks: [] };
|
||||
this._set({ recordings, trainingStatus: status, loading: false });
|
||||
} catch (e) { this._set({ loading: false, error: e.message }); }
|
||||
}
|
||||
|
||||
// --- Actions ---
|
||||
|
||||
async _startRec() {
|
||||
this._set({ loading: true, error: null });
|
||||
try {
|
||||
await trainingService.startRecording({ session_name: `rec_${Date.now()}`, label: 'pose' });
|
||||
this._set({ isRecording: true, loading: false });
|
||||
await this.refresh();
|
||||
} catch (e) { this._set({ loading: false, error: `Recording failed: ${e.message}` }); }
|
||||
}
|
||||
|
||||
async _stopRec() {
|
||||
this._set({ loading: true, error: null });
|
||||
try {
|
||||
await trainingService.stopRecording();
|
||||
this._set({ isRecording: false, loading: false });
|
||||
await this.refresh();
|
||||
} catch (e) { this._set({ loading: false, error: `Stop recording failed: ${e.message}` }); }
|
||||
}
|
||||
|
||||
async _delRec(id) {
|
||||
this._set({ loading: true, error: null });
|
||||
try {
|
||||
await trainingService.deleteRecording(id);
|
||||
this.config.selectedRecordings = this.config.selectedRecordings.filter(r => r !== id);
|
||||
await this.refresh();
|
||||
} catch (e) { this._set({ loading: false, error: `Delete failed: ${e.message}` }); }
|
||||
}
|
||||
|
||||
async _launchTraining(method, extraCfg = {}) {
|
||||
this._set({ loading: true, error: null });
|
||||
this.progressData = { losses: [], pcks: [] };
|
||||
try {
|
||||
trainingService.connectProgressStream();
|
||||
const payload = {
|
||||
dataset_ids: this.config.selectedRecordings,
|
||||
config: {
|
||||
epochs: this.config.epochs,
|
||||
batch_size: this.config.batch_size,
|
||||
learning_rate: this.config.learning_rate,
|
||||
...extraCfg
|
||||
}
|
||||
};
|
||||
await trainingService[method](payload);
|
||||
await this.refresh();
|
||||
} catch (e) { this._set({ loading: false, error: `Training failed: ${e.message}` }); }
|
||||
}
|
||||
|
||||
async _stopTraining() {
|
||||
this._set({ loading: true, error: null });
|
||||
try { await trainingService.stopTraining(); await this.refresh(); }
|
||||
catch (e) { this._set({ loading: false, error: `Stop failed: ${e.message}` }); }
|
||||
}
|
||||
|
||||
_set(p) { Object.assign(this.state, p); this.render(); }
|
||||
|
||||
// --- Render ---
|
||||
|
||||
render() {
|
||||
const el = this.container;
|
||||
el.innerHTML = '';
|
||||
const panel = this._el('div', 'tp-panel');
|
||||
panel.appendChild(this._renderHeader());
|
||||
if (this.state.error) panel.appendChild(this._el('div', 'tp-error', this.state.error));
|
||||
panel.appendChild(this._renderRecordings());
|
||||
const ts = this.state.trainingStatus;
|
||||
const active = ts && ts.active;
|
||||
if (active) panel.appendChild(this._renderProgress());
|
||||
else if (ts && !ts.active && this.progressData.losses.length > 0) panel.appendChild(this._renderComplete());
|
||||
else panel.appendChild(this._renderConfig());
|
||||
el.appendChild(panel);
|
||||
if (active) requestAnimationFrame(() => this._drawCharts());
|
||||
}
|
||||
|
||||
_renderHeader() {
|
||||
const h = this._el('div', 'tp-header');
|
||||
h.appendChild(this._el('span', 'tp-title', 'Training'));
|
||||
const ts = this.state.trainingStatus;
|
||||
let cls = 'tp-badge tp-badge-idle', txt = 'Idle';
|
||||
if (ts && ts.active) { cls = 'tp-badge tp-badge-active'; txt = 'Training'; }
|
||||
else if (ts && !ts.active && this.progressData.losses.length > 0) { cls = 'tp-badge tp-badge-done'; txt = 'Completed'; }
|
||||
h.appendChild(this._el('span', cls, txt));
|
||||
return h;
|
||||
}
|
||||
|
||||
_renderRecordings() {
|
||||
const s = this._el('div', 'tp-section');
|
||||
s.appendChild(this._el('div', 'tp-section-title', 'CSI Recordings'));
|
||||
if (this.state.recordings.length === 0 && !this.state.loading) {
|
||||
s.appendChild(this._el('div', 'tp-empty', 'Start recording CSI data to train a model'));
|
||||
} else {
|
||||
this.state.recordings.forEach(rec => {
|
||||
const row = this._el('div', 'tp-rec-row');
|
||||
const info = this._el('div', 'tp-rec-info');
|
||||
info.appendChild(this._el('span', 'tp-rec-name', rec.name || rec.id));
|
||||
const parts = [];
|
||||
if (rec.frame_count != null) parts.push(rec.frame_count + ' frames');
|
||||
if (rec.file_size_bytes != null) parts.push(this._fmtB(rec.file_size_bytes));
|
||||
if (rec.started_at && rec.ended_at) parts.push(Math.round((new Date(rec.ended_at) - new Date(rec.started_at)) / 1000) + 's');
|
||||
info.appendChild(this._el('span', 'tp-rec-meta', parts.join(' / ')));
|
||||
row.appendChild(info);
|
||||
const del = this._btn('Delete', 'tp-btn tp-btn-muted', () => this._delRec(rec.id));
|
||||
del.disabled = this.state.loading;
|
||||
row.appendChild(del);
|
||||
s.appendChild(row);
|
||||
});
|
||||
}
|
||||
const acts = this._el('div', 'tp-rec-actions');
|
||||
if (this.state.isRecording) {
|
||||
const b = this._btn('Stop Recording', 'tp-btn tp-btn-danger', () => this._stopRec());
|
||||
b.disabled = this.state.loading; acts.appendChild(b);
|
||||
} else {
|
||||
const b = this._btn('Start Recording', 'tp-btn tp-btn-rec', () => this._startRec());
|
||||
b.disabled = this.state.loading; acts.appendChild(b);
|
||||
}
|
||||
s.appendChild(acts);
|
||||
return s;
|
||||
}
|
||||
|
||||
_renderConfig() {
|
||||
const s = this._el('div', 'tp-section');
|
||||
const hdr = this._el('div', 'tp-config-header');
|
||||
hdr.appendChild(this._el('span', 'tp-section-title', 'Training Configuration'));
|
||||
hdr.appendChild(this._btn(this.state.configOpen ? 'Collapse' : 'Expand', 'tp-btn tp-btn-muted',
|
||||
() => { this.state.configOpen = !this.state.configOpen; this.render(); }));
|
||||
s.appendChild(hdr);
|
||||
if (!this.state.configOpen) return s;
|
||||
|
||||
const form = this._el('div', 'tp-config-form');
|
||||
if (this.state.recordings.length > 0) {
|
||||
form.appendChild(this._el('label', 'tp-label', 'Datasets'));
|
||||
const dc = this._el('div', 'tp-ds-container');
|
||||
this.state.recordings.forEach(rec => {
|
||||
const lb = this._el('label', 'tp-ds-item');
|
||||
const cb = document.createElement('input');
|
||||
cb.type = 'checkbox';
|
||||
cb.checked = this.config.selectedRecordings.includes(rec.id);
|
||||
cb.addEventListener('change', () => {
|
||||
if (cb.checked) { if (!this.config.selectedRecordings.includes(rec.id)) this.config.selectedRecordings.push(rec.id); }
|
||||
else { this.config.selectedRecordings = this.config.selectedRecordings.filter(r => r !== rec.id); }
|
||||
});
|
||||
lb.appendChild(cb);
|
||||
lb.appendChild(this._el('span', null, rec.name || rec.id));
|
||||
dc.appendChild(lb);
|
||||
});
|
||||
form.appendChild(dc);
|
||||
}
|
||||
const ir = (l, t, v, fn) => {
|
||||
const r = this._el('div', 'tp-input-row');
|
||||
r.appendChild(this._el('label', 'tp-label', l));
|
||||
const inp = document.createElement('input');
|
||||
inp.type = t; inp.className = 'tp-input'; inp.value = v;
|
||||
inp.addEventListener('change', () => fn(inp.value));
|
||||
r.appendChild(inp); return r;
|
||||
};
|
||||
form.appendChild(ir('Epochs', 'number', this.config.epochs, v => { this.config.epochs = parseInt(v) || 100; }));
|
||||
form.appendChild(ir('Batch Size', 'number', this.config.batch_size, v => { this.config.batch_size = parseInt(v) || 32; }));
|
||||
form.appendChild(ir('Learning Rate', 'text', this.config.learning_rate, v => { this.config.learning_rate = parseFloat(v) || 3e-4; }));
|
||||
form.appendChild(ir('Early Stop Patience', 'number', this.config.patience, v => { this.config.patience = parseInt(v) || 15; }));
|
||||
form.appendChild(ir('Base Model (opt.)', 'text', this.config.base_model, v => { this.config.base_model = v; }));
|
||||
form.appendChild(ir('LoRA Profile (opt.)', 'text', this.config.lora_profile_name, v => { this.config.lora_profile_name = v; }));
|
||||
s.appendChild(form);
|
||||
|
||||
const acts = this._el('div', 'tp-train-actions');
|
||||
const btns = [
|
||||
this._btn('Start Training', 'tp-btn tp-btn-success', () => this._launchTraining('startTraining', { patience: this.config.patience, base_model: this.config.base_model || undefined })),
|
||||
this._btn('Pretrain', 'tp-btn tp-btn-secondary', () => this._launchTraining('startPretraining')),
|
||||
this._btn('LoRA', 'tp-btn tp-btn-secondary', () => this._launchTraining('startLoraTraining', { base_model: this.config.base_model || undefined, profile_name: this.config.lora_profile_name || 'default' }))
|
||||
];
|
||||
btns.forEach(b => { b.disabled = this.state.loading; acts.appendChild(b); });
|
||||
s.appendChild(acts);
|
||||
return s;
|
||||
}
|
||||
|
||||
_renderProgress() {
|
||||
const ts = this.state.trainingStatus || {};
|
||||
const s = this._el('div', 'tp-section');
|
||||
s.appendChild(this._el('div', 'tp-section-title', 'Training Progress'));
|
||||
|
||||
const pct = ts.total_epochs ? Math.round((ts.epoch / ts.total_epochs) * 100) : 0;
|
||||
const bar = this._el('div', 'tp-progress-bar');
|
||||
const fill = this._el('div', 'tp-progress-fill');
|
||||
fill.style.width = pct + '%';
|
||||
bar.appendChild(fill); s.appendChild(bar);
|
||||
s.appendChild(this._el('div', 'tp-progress-label', `Epoch ${ts.epoch ?? 0} / ${ts.total_epochs ?? '?'} (${pct}%)`));
|
||||
|
||||
const cr = this._el('div', 'tp-chart-row');
|
||||
const lc = document.createElement('canvas'); lc.id = 'tp-loss-chart'; lc.width = 260; lc.height = 140;
|
||||
const pc = document.createElement('canvas'); pc.id = 'tp-pck-chart'; pc.width = 260; pc.height = 140;
|
||||
cr.appendChild(lc); cr.appendChild(pc); s.appendChild(cr);
|
||||
|
||||
const g = this._el('div', 'tp-metrics-grid');
|
||||
const mc = (l, v) => { const c = this._el('div', 'tp-metric-cell'); c.appendChild(this._el('div', 'tp-metric-label', l)); c.appendChild(this._el('div', 'tp-metric-value', v)); return c; };
|
||||
g.appendChild(mc('Loss', ts.train_loss != null ? ts.train_loss.toFixed(4) : '--'));
|
||||
g.appendChild(mc('PCK', ts.val_pck != null ? (ts.val_pck * 100).toFixed(1) + '%' : '--'));
|
||||
g.appendChild(mc('OKS', ts.val_oks != null ? ts.val_oks.toFixed(3) : '--'));
|
||||
g.appendChild(mc('LR', ts.lr != null ? ts.lr.toExponential(1) : '--'));
|
||||
g.appendChild(mc('Best PCK', ts.best_pck != null ? (ts.best_pck * 100).toFixed(1) + '% (e' + (ts.best_epoch ?? '?') + ')' : '--'));
|
||||
g.appendChild(mc('Patience', ts.patience_remaining != null ? String(ts.patience_remaining) : '--'));
|
||||
g.appendChild(mc('ETA', ts.eta_secs != null ? this._fmtEta(ts.eta_secs) : '--'));
|
||||
g.appendChild(mc('Phase', ts.phase || '--'));
|
||||
s.appendChild(g);
|
||||
|
||||
const stop = this._btn('Stop Training', 'tp-btn tp-btn-danger', () => this._stopTraining());
|
||||
stop.disabled = this.state.loading; stop.style.marginTop = '10px'; s.appendChild(stop);
|
||||
return s;
|
||||
}
|
||||
|
||||
_renderComplete() {
|
||||
const ts = this.state.trainingStatus || {};
|
||||
const s = this._el('div', 'tp-section');
|
||||
s.appendChild(this._el('div', 'tp-section-title', 'Training Complete'));
|
||||
const g = this._el('div', 'tp-metrics-grid');
|
||||
const mc = (l, v) => { const c = this._el('div', 'tp-metric-cell'); c.appendChild(this._el('div', 'tp-metric-label', l)); c.appendChild(this._el('div', 'tp-metric-value', v)); return c; };
|
||||
const losses = this.progressData.losses;
|
||||
g.appendChild(mc('Final Loss', losses.length > 0 ? losses[losses.length - 1].toFixed(4) : '--'));
|
||||
g.appendChild(mc('Best PCK', ts.best_pck != null ? (ts.best_pck * 100).toFixed(1) + '%' : '--'));
|
||||
g.appendChild(mc('Best Epoch', ts.best_epoch != null ? String(ts.best_epoch) : '--'));
|
||||
g.appendChild(mc('Total Epochs', String(losses.length)));
|
||||
s.appendChild(g);
|
||||
const acts = this._el('div', 'tp-train-actions');
|
||||
acts.appendChild(this._btn('New Training', 'tp-btn tp-btn-secondary', () => {
|
||||
this.progressData = { losses: [], pcks: [] }; this._set({ trainingStatus: null });
|
||||
}));
|
||||
s.appendChild(acts);
|
||||
return s;
|
||||
}
|
||||
|
||||
// --- Chart drawing ---
|
||||
|
||||
_drawCharts() {
|
||||
this._drawChart('tp-loss-chart', this.progressData.losses, { color: '#ff6b6b', label: 'Loss', yMin: 0, yMax: null });
|
||||
this._drawChart('tp-pck-chart', this.progressData.pcks, { color: '#51cf66', label: 'PCK', yMin: 0, yMax: 1 });
|
||||
}
|
||||
|
||||
_drawChart(id, data, opts) {
|
||||
const cv = document.getElementById(id);
|
||||
if (!cv) return;
|
||||
const ctx = cv.getContext('2d'), w = cv.width, h = cv.height;
|
||||
const p = { t: 20, r: 10, b: 24, l: 44 };
|
||||
ctx.fillStyle = '#0d1117'; ctx.fillRect(0, 0, w, h);
|
||||
ctx.fillStyle = '#8899aa'; ctx.font = '11px -apple-system,sans-serif'; ctx.fillText(opts.label, p.l, 14);
|
||||
if (!data.length) { ctx.fillStyle = '#6b7a8d'; ctx.fillText('No data', w / 2 - 20, h / 2); return; }
|
||||
const pw = w - p.l - p.r, ph = h - p.t - p.b;
|
||||
let yMin = opts.yMin ?? Math.min(...data), yMax = opts.yMax ?? Math.max(...data);
|
||||
if (yMax === yMin) yMax = yMin + 1;
|
||||
ctx.strokeStyle = 'rgba(255,255,255,.08)'; ctx.lineWidth = 1;
|
||||
for (let i = 0; i <= 4; i++) {
|
||||
const y = p.t + (ph / 4) * i;
|
||||
ctx.beginPath(); ctx.moveTo(p.l, y); ctx.lineTo(w - p.r, y); ctx.stroke();
|
||||
const v = yMax - ((yMax - yMin) / 4) * i;
|
||||
ctx.fillStyle = '#6b7a8d'; ctx.font = '9px sans-serif'; ctx.fillText(v.toFixed(v >= 1 ? 2 : 3), 2, y + 3);
|
||||
}
|
||||
const xl = Math.min(data.length, 5);
|
||||
for (let i = 0; i < xl; i++) {
|
||||
const idx = Math.round((data.length - 1) * (i / (xl - 1 || 1)));
|
||||
ctx.fillStyle = '#6b7a8d'; ctx.fillText(String(idx + 1), p.l + (pw * idx) / (data.length - 1 || 1) - 4, h - 4);
|
||||
}
|
||||
ctx.strokeStyle = opts.color; ctx.lineWidth = 1.5; ctx.beginPath();
|
||||
data.forEach((v, i) => {
|
||||
const x = p.l + (pw * i) / (data.length - 1 || 1);
|
||||
const y = p.t + ph - ((v - yMin) / (yMax - yMin)) * ph;
|
||||
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
|
||||
});
|
||||
ctx.stroke();
|
||||
if (data.length > 0) {
|
||||
const ly = p.t + ph - ((data[data.length - 1] - yMin) / (yMax - yMin)) * ph;
|
||||
ctx.fillStyle = opts.color; ctx.beginPath(); ctx.arc(p.l + pw, ly, 3, 0, Math.PI * 2); ctx.fill();
|
||||
}
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
_el(tag, cls, txt) {
|
||||
const e = document.createElement(tag);
|
||||
if (cls) e.className = cls;
|
||||
if (txt != null) e.textContent = txt;
|
||||
return e;
|
||||
}
|
||||
|
||||
_btn(txt, cls, fn) {
|
||||
const b = document.createElement('button');
|
||||
b.className = cls; b.textContent = txt;
|
||||
b.addEventListener('click', fn); return b;
|
||||
}
|
||||
|
||||
_fmtB(b) { return b < 1024 ? b + ' B' : b < 1048576 ? (b / 1024).toFixed(1) + ' KB' : (b / 1048576).toFixed(1) + ' MB'; }
|
||||
_fmtEta(s) { return s < 60 ? Math.round(s) + 's' : s < 3600 ? Math.round(s / 60) + 'm' : (s / 3600).toFixed(1) + 'h'; }
|
||||
|
||||
_injectStyles() {
|
||||
if (document.getElementById('training-panel-styles')) return;
|
||||
const s = document.createElement('style');
|
||||
s.id = 'training-panel-styles';
|
||||
s.textContent = TP_STYLES;
|
||||
document.head.appendChild(s);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.unsubscribers.forEach(fn => fn());
|
||||
this.unsubscribers = [];
|
||||
trainingService.disconnectProgressStream();
|
||||
if (this.container) this.container.innerHTML = '';
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.destroy();
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@
|
||||
<button class="nav-tab" data-tab="performance">Performance</button>
|
||||
<button class="nav-tab" data-tab="applications">Applications</button>
|
||||
<button class="nav-tab" data-tab="sensing">Sensing</button>
|
||||
<button class="nav-tab" data-tab="training">Training</button>
|
||||
</nav>
|
||||
|
||||
<!-- Dashboard Tab -->
|
||||
@@ -67,6 +68,11 @@
|
||||
<span class="status-text">-</span>
|
||||
<span class="status-message"></span>
|
||||
</div>
|
||||
<div class="component-status" data-component="datasource" id="dashboard-datasource">
|
||||
<span class="component-name">Data Source</span>
|
||||
<span class="status-text">-</span>
|
||||
<span class="status-message"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -482,6 +488,18 @@
|
||||
|
||||
<!-- Sensing Tab -->
|
||||
<section id="sensing" class="tab-content"></section>
|
||||
|
||||
<!-- Training Tab -->
|
||||
<section id="training" class="tab-content">
|
||||
<div class="tab-header">
|
||||
<h2>Model Training</h2>
|
||||
<p>Record CSI data, train pose estimation models, and manage .rvf files</p>
|
||||
</div>
|
||||
<div id="training-container" style="display: flex; gap: 20px; flex-wrap: wrap;">
|
||||
<div id="training-panel-container" style="flex: 1; min-width: 400px;"></div>
|
||||
<div id="model-panel-container" style="flex: 1; min-width: 350px; max-width: 450px;"></div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Error Toast -->
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
// Model Service for WiFi-DensePose UI
|
||||
// Manages model loading, listing, LoRA profiles, and lifecycle events.
|
||||
|
||||
import { apiService } from './api.service.js';
|
||||
|
||||
export class ModelService {
|
||||
constructor() {
|
||||
this.activeModel = null;
|
||||
this.listeners = {};
|
||||
this.logger = this.createLogger();
|
||||
}
|
||||
|
||||
createLogger() {
|
||||
return {
|
||||
debug: (...args) => console.debug('[MODEL-DEBUG]', new Date().toISOString(), ...args),
|
||||
info: (...args) => console.info('[MODEL-INFO]', new Date().toISOString(), ...args),
|
||||
warn: (...args) => console.warn('[MODEL-WARN]', new Date().toISOString(), ...args),
|
||||
error: (...args) => console.error('[MODEL-ERROR]', new Date().toISOString(), ...args)
|
||||
};
|
||||
}
|
||||
|
||||
// --- Event emitter helpers ---
|
||||
|
||||
on(event, callback) {
|
||||
if (!this.listeners[event]) {
|
||||
this.listeners[event] = [];
|
||||
}
|
||||
this.listeners[event].push(callback);
|
||||
return () => this.off(event, callback);
|
||||
}
|
||||
|
||||
off(event, callback) {
|
||||
if (!this.listeners[event]) return;
|
||||
this.listeners[event] = this.listeners[event].filter(cb => cb !== callback);
|
||||
}
|
||||
|
||||
emit(event, data) {
|
||||
if (!this.listeners[event]) return;
|
||||
this.listeners[event].forEach(cb => {
|
||||
try { cb(data); } catch (err) { this.logger.error('Listener error', { event, err }); }
|
||||
});
|
||||
}
|
||||
|
||||
// --- API methods ---
|
||||
|
||||
async listModels() {
|
||||
try {
|
||||
const data = await apiService.get('/api/v1/models');
|
||||
this.logger.info('Listed models', { count: data?.models?.length ?? 0 });
|
||||
return data;
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to list models', { error: error.message });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getModel(id) {
|
||||
try {
|
||||
const data = await apiService.get(`/api/v1/models/${encodeURIComponent(id)}`);
|
||||
return data;
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to get model', { id, error: error.message });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async loadModel(modelId) {
|
||||
try {
|
||||
this.logger.info('Loading model', { modelId });
|
||||
const data = await apiService.post('/api/v1/models/load', { model_id: modelId });
|
||||
this.activeModel = { model_id: modelId };
|
||||
this.emit('model-loaded', { model_id: modelId });
|
||||
return data;
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to load model', { modelId, error: error.message });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async unloadModel() {
|
||||
try {
|
||||
this.logger.info('Unloading model');
|
||||
const data = await apiService.post('/api/v1/models/unload', {});
|
||||
this.activeModel = null;
|
||||
this.emit('model-unloaded', {});
|
||||
return data;
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to unload model', { error: error.message });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getActiveModel() {
|
||||
try {
|
||||
const data = await apiService.get('/api/v1/models/active');
|
||||
this.activeModel = data || null;
|
||||
return this.activeModel;
|
||||
} catch (error) {
|
||||
if (error.status === 404) {
|
||||
this.activeModel = null;
|
||||
return null;
|
||||
}
|
||||
this.logger.error('Failed to get active model', { error: error.message });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async activateLoraProfile(modelId, profileName) {
|
||||
try {
|
||||
this.logger.info('Activating LoRA profile', { modelId, profileName });
|
||||
const data = await apiService.post(
|
||||
'/api/v1/models/lora/activate',
|
||||
{ model_id: modelId, profile_name: profileName }
|
||||
);
|
||||
this.emit('lora-activated', { model_id: modelId, profile: profileName });
|
||||
return data;
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to activate LoRA', { modelId, profileName, error: error.message });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getLoraProfiles() {
|
||||
try {
|
||||
const data = await apiService.get('/api/v1/models/lora/profiles');
|
||||
return data?.profiles ?? [];
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to get LoRA profiles', { error: error.message });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteModel(id) {
|
||||
try {
|
||||
this.logger.info('Deleting model', { id });
|
||||
const data = await apiService.delete(`/api/v1/models/${encodeURIComponent(id)}`);
|
||||
return data;
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to delete model', { id, error: error.message });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.listeners = {};
|
||||
this.activeModel = null;
|
||||
this.logger.info('ModelService disposed');
|
||||
}
|
||||
}
|
||||
|
||||
// Create singleton instance
|
||||
export const modelService = new ModelService();
|
||||
@@ -21,13 +21,17 @@ export class PoseService {
|
||||
};
|
||||
this.validationErrors = [];
|
||||
this.logger = this.createLogger();
|
||||
|
||||
|
||||
// Model inference mode tracking
|
||||
this.modelActive = false;
|
||||
|
||||
// Configuration
|
||||
this.config = {
|
||||
enableValidation: true,
|
||||
enablePerformanceTracking: true,
|
||||
maxValidationErrors: 10,
|
||||
confidenceThreshold: 0.3,
|
||||
confidenceThresholdModelInference: 0.15,
|
||||
maxPersons: 10,
|
||||
timeoutMs: 5000
|
||||
};
|
||||
@@ -127,9 +131,14 @@ export class PoseService {
|
||||
throw new Error(`Invalid stream options: ${validationResult.errors.join(', ')}`);
|
||||
}
|
||||
|
||||
// Use a lower confidence threshold when model inference is active
|
||||
const defaultThreshold = this.modelActive
|
||||
? this.config.confidenceThresholdModelInference
|
||||
: this.config.confidenceThreshold;
|
||||
|
||||
const params = {
|
||||
zone_ids: options.zoneIds?.join(','),
|
||||
min_confidence: options.minConfidence || this.config.confidenceThreshold,
|
||||
min_confidence: options.minConfidence || defaultThreshold,
|
||||
max_fps: options.maxFps || 30,
|
||||
token: options.token || apiService.authToken
|
||||
};
|
||||
@@ -494,9 +503,18 @@ export class PoseService {
|
||||
};
|
||||
}
|
||||
|
||||
// Extract persons from zone data
|
||||
const persons = zoneData.pose.persons || [];
|
||||
console.log('👥 Extracted persons:', persons);
|
||||
// Determine the pose source for this message
|
||||
const poseSource = originalMessage.pose_source || zoneData.pose_source || null;
|
||||
|
||||
// Choose confidence threshold based on pose source
|
||||
const threshold = (poseSource === 'model_inference' || this.modelActive)
|
||||
? this.config.confidenceThresholdModelInference
|
||||
: this.config.confidenceThreshold;
|
||||
|
||||
// Extract persons from zone data, applying source-aware filtering
|
||||
const rawPersons = zoneData.pose.persons || [];
|
||||
const persons = rawPersons.filter(p => p.confidence === undefined || p.confidence >= threshold);
|
||||
console.log('Extracted persons:', persons.length, '/', rawPersons.length, '(threshold:', threshold, ')');
|
||||
|
||||
// Create zone summary
|
||||
const zoneSummary = {};
|
||||
@@ -511,7 +529,7 @@ export class PoseService {
|
||||
persons: persons,
|
||||
zone_summary: zoneSummary,
|
||||
processing_time_ms: zoneData.metadata?.processing_time_ms || 0,
|
||||
pose_source: originalMessage.pose_source || zoneData.pose_source || null,
|
||||
pose_source: poseSource,
|
||||
metadata: {
|
||||
mock_data: false,
|
||||
source: 'websocket',
|
||||
@@ -653,6 +671,14 @@ export class PoseService {
|
||||
this.logger.info('Configuration updated', { config: this.config });
|
||||
}
|
||||
|
||||
// Enable or disable model inference mode.
|
||||
// When active, confidence thresholds are lowered because model inference
|
||||
// produces more reliable detections than raw signal-derived heuristics.
|
||||
setModelMode(active) {
|
||||
this.modelActive = !!active;
|
||||
this.logger.info('Model mode updated', { modelActive: this.modelActive });
|
||||
}
|
||||
|
||||
// Health check
|
||||
async healthCheck() {
|
||||
try {
|
||||
|
||||
@@ -32,8 +32,14 @@ class SensingService {
|
||||
this._simTimer = null;
|
||||
// Connection state: disconnected | connecting | connected | reconnecting | simulated
|
||||
this._state = 'disconnected';
|
||||
// Data-source label exposed to the UI: "live" | "reconnecting" | "simulated"
|
||||
// Data-source label exposed to the UI:
|
||||
// "live" — real ESP32 hardware connected
|
||||
// "server-simulated" — server is running but using synthetic data (no hardware)
|
||||
// "reconnecting" — WebSocket disconnected, retrying
|
||||
// "simulated" — client-side fallback simulation (server unreachable)
|
||||
this._dataSource = 'reconnecting';
|
||||
// The raw source string from the server (e.g. "esp32", "simulated", "simulate")
|
||||
this._serverSource = null;
|
||||
this._lastMessage = null;
|
||||
|
||||
// Ring buffer of recent RSSI values for sparkline
|
||||
@@ -113,7 +119,9 @@ class SensingService {
|
||||
this._reconnectAttempt = 0;
|
||||
this._stopSimulation();
|
||||
this._setState('connected');
|
||||
this._setDataSource('live');
|
||||
// Don't assume "live" yet — wait for first frame's source field.
|
||||
// Fetch server status to determine actual data source immediately.
|
||||
this._detectServerSource();
|
||||
};
|
||||
|
||||
this._ws.onmessage = (evt) => {
|
||||
@@ -256,11 +264,61 @@ class SensingService {
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Server source detection -------------------------------------------
|
||||
|
||||
/**
|
||||
* Fetch `/api/v1/status` to find out if the server is using real
|
||||
* hardware or simulation. Called once on WebSocket open.
|
||||
*/
|
||||
async _detectServerSource() {
|
||||
try {
|
||||
const resp = await fetch('/api/v1/status');
|
||||
if (resp.ok) {
|
||||
const json = await resp.json();
|
||||
this._applyServerSource(json.source);
|
||||
} else {
|
||||
// Can't reach status endpoint — assume live until first frame tells us
|
||||
this._setDataSource('live');
|
||||
}
|
||||
} catch {
|
||||
this._setDataSource('live');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a raw server source string to the UI data-source label.
|
||||
*/
|
||||
_applyServerSource(rawSource) {
|
||||
this._serverSource = rawSource;
|
||||
if (rawSource === 'esp32' || rawSource === 'wifi' || rawSource === 'live') {
|
||||
this._setDataSource('live');
|
||||
} else if (rawSource === 'simulated' || rawSource === 'simulate') {
|
||||
this._setDataSource('server-simulated');
|
||||
} else {
|
||||
// Unknown source — show as server-simulated to be safe
|
||||
this._setDataSource('server-simulated');
|
||||
}
|
||||
}
|
||||
|
||||
/** @return {string|null} Raw server source (e.g. "esp32", "simulated") */
|
||||
get serverSource() {
|
||||
return this._serverSource;
|
||||
}
|
||||
|
||||
// ---- Data handling -----------------------------------------------------
|
||||
|
||||
_handleData(data) {
|
||||
this._lastMessage = data;
|
||||
|
||||
// Track the server's source field from each frame so the UI
|
||||
// can react if the server switches between esp32 ↔ simulated at runtime.
|
||||
if (data.source && this._state === 'connected') {
|
||||
const raw = data.source;
|
||||
if (raw !== this._serverSource) {
|
||||
this._applyServerSource(raw);
|
||||
}
|
||||
}
|
||||
|
||||
// Update RSSI history for sparkline
|
||||
if (data.features && data.features.mean_rssi != null) {
|
||||
this._rssiHistory.push(data.features.mean_rssi);
|
||||
@@ -292,7 +350,7 @@ class SensingService {
|
||||
/**
|
||||
* Update the dataSource label and notify state listeners so the UI can
|
||||
* react without needing a separate subscription.
|
||||
* @param {'live'|'reconnecting'|'simulated'} source
|
||||
* @param {'live'|'server-simulated'|'reconnecting'|'simulated'} source
|
||||
*/
|
||||
_setDataSource(source) {
|
||||
if (source === this._dataSource) return;
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
// Training Service for WiFi-DensePose UI
|
||||
// Manages training lifecycle, progress streaming, and CSI recordings.
|
||||
|
||||
import { buildWsUrl } from '../config/api.config.js';
|
||||
import { apiService } from './api.service.js';
|
||||
|
||||
export class TrainingService {
|
||||
constructor() {
|
||||
this.progressSocket = null;
|
||||
this.listeners = {};
|
||||
this.logger = this.createLogger();
|
||||
}
|
||||
|
||||
createLogger() {
|
||||
return {
|
||||
debug: (...args) => console.debug('[TRAIN-DEBUG]', new Date().toISOString(), ...args),
|
||||
info: (...args) => console.info('[TRAIN-INFO]', new Date().toISOString(), ...args),
|
||||
warn: (...args) => console.warn('[TRAIN-WARN]', new Date().toISOString(), ...args),
|
||||
error: (...args) => console.error('[TRAIN-ERROR]', new Date().toISOString(), ...args)
|
||||
};
|
||||
}
|
||||
|
||||
// --- Event emitter helpers ---
|
||||
|
||||
on(event, callback) {
|
||||
if (!this.listeners[event]) {
|
||||
this.listeners[event] = [];
|
||||
}
|
||||
this.listeners[event].push(callback);
|
||||
return () => this.off(event, callback);
|
||||
}
|
||||
|
||||
off(event, callback) {
|
||||
if (!this.listeners[event]) return;
|
||||
this.listeners[event] = this.listeners[event].filter(cb => cb !== callback);
|
||||
}
|
||||
|
||||
emit(event, data) {
|
||||
if (!this.listeners[event]) return;
|
||||
this.listeners[event].forEach(cb => {
|
||||
try { cb(data); } catch (err) { this.logger.error('Listener error', { event, err }); }
|
||||
});
|
||||
}
|
||||
|
||||
// --- Training API methods ---
|
||||
|
||||
async startTraining(config) {
|
||||
try {
|
||||
this.logger.info('Starting training', { config });
|
||||
const data = await apiService.post('/api/v1/train/start', config);
|
||||
this.emit('training-started', data);
|
||||
return data;
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to start training', { error: error.message });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async stopTraining() {
|
||||
try {
|
||||
this.logger.info('Stopping training');
|
||||
const data = await apiService.post('/api/v1/train/stop', {});
|
||||
this.emit('training-stopped', data);
|
||||
return data;
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to stop training', { error: error.message });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getTrainingStatus() {
|
||||
try {
|
||||
const data = await apiService.get('/api/v1/train/status');
|
||||
return data;
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to get training status', { error: error.message });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async startPretraining(config) {
|
||||
try {
|
||||
this.logger.info('Starting pretraining', { config });
|
||||
const data = await apiService.post('/api/v1/train/pretrain', config);
|
||||
this.emit('training-started', data);
|
||||
return data;
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to start pretraining', { error: error.message });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async startLoraTraining(config) {
|
||||
try {
|
||||
this.logger.info('Starting LoRA training', { config });
|
||||
const data = await apiService.post('/api/v1/train/lora', config);
|
||||
this.emit('training-started', data);
|
||||
return data;
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to start LoRA training', { error: error.message });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Recording API methods ---
|
||||
|
||||
async listRecordings() {
|
||||
try {
|
||||
const data = await apiService.get('/api/v1/recording/list');
|
||||
return data?.recordings ?? [];
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to list recordings', { error: error.message });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async startRecording(config) {
|
||||
try {
|
||||
this.logger.info('Starting recording', { config });
|
||||
const data = await apiService.post('/api/v1/recording/start', config);
|
||||
this.emit('recording-started', data);
|
||||
return data;
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to start recording', { error: error.message });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async stopRecording() {
|
||||
try {
|
||||
this.logger.info('Stopping recording');
|
||||
const data = await apiService.post('/api/v1/recording/stop', {});
|
||||
this.emit('recording-stopped', data);
|
||||
return data;
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to stop recording', { error: error.message });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteRecording(id) {
|
||||
try {
|
||||
this.logger.info('Deleting recording', { id });
|
||||
const data = await apiService.delete(
|
||||
`/api/v1/recording/${encodeURIComponent(id)}`
|
||||
);
|
||||
return data;
|
||||
} catch (error) {
|
||||
this.logger.error('Failed to delete recording', { id, error: error.message });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// --- WebSocket progress stream ---
|
||||
|
||||
connectProgressStream() {
|
||||
if (this.progressSocket) {
|
||||
this.logger.warn('Progress stream already connected');
|
||||
return this.progressSocket;
|
||||
}
|
||||
|
||||
const url = buildWsUrl('/ws/train/progress');
|
||||
this.logger.info('Connecting progress stream', { url });
|
||||
|
||||
const ws = new WebSocket(url);
|
||||
|
||||
ws.onopen = () => {
|
||||
this.logger.info('Progress stream connected');
|
||||
this.emit('progress-connected', {});
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
this.emit('progress', data);
|
||||
} catch (err) {
|
||||
this.logger.warn('Failed to parse progress message', { error: err.message });
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
this.logger.error('Progress stream error', { error });
|
||||
this.emit('progress-error', { error });
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
this.logger.info('Progress stream disconnected');
|
||||
this.progressSocket = null;
|
||||
this.emit('progress-disconnected', {});
|
||||
};
|
||||
|
||||
this.progressSocket = ws;
|
||||
return ws;
|
||||
}
|
||||
|
||||
disconnectProgressStream() {
|
||||
if (this.progressSocket) {
|
||||
this.progressSocket.close();
|
||||
this.progressSocket = null;
|
||||
}
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.disconnectProgressStream();
|
||||
this.listeners = {};
|
||||
this.logger.info('TrainingService disposed');
|
||||
}
|
||||
}
|
||||
|
||||
// Create singleton instance
|
||||
export const trainingService = new TrainingService();
|
||||
@@ -83,9 +83,24 @@ export class WebSocketService {
|
||||
const ws = await this.createWebSocketWithTimeout(url);
|
||||
connectionData.ws = ws;
|
||||
|
||||
// Set up event handlers
|
||||
// Set up event handlers (replaces onopen/onmessage/etc.)
|
||||
this.setupEventHandlers(url, ws, handlers);
|
||||
|
||||
// The WebSocket is already open at this point (createWebSocketWithTimeout
|
||||
// resolved on the original onopen). setupEventHandlers replaced onopen, so
|
||||
// the new handler never fires. Manually trigger the connected path now.
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
connectionData.status = 'connected';
|
||||
connectionData.lastActivity = Date.now();
|
||||
this.reconnectAttempts.set(url, 0);
|
||||
this.notifyConnectionState(url, 'connected');
|
||||
if (handlers.onOpen) {
|
||||
try { handlers.onOpen(new Event('open')); } catch (e) {
|
||||
this.logger.error('Error in onOpen handler', { url, error: e.message });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Start heartbeat
|
||||
this.startHeartbeat(url);
|
||||
|
||||
|
||||
+471
-3
@@ -355,6 +355,21 @@ pre code {
|
||||
background: var(--color-secondary-active);
|
||||
}
|
||||
|
||||
.btn--accent {
|
||||
background: rgba(139, 92, 246, 0.2);
|
||||
color: #a78bfa;
|
||||
border-color: rgba(139, 92, 246, 0.3);
|
||||
}
|
||||
|
||||
.btn--accent:hover {
|
||||
background: rgba(139, 92, 246, 0.3);
|
||||
border-color: rgba(139, 92, 246, 0.5);
|
||||
}
|
||||
|
||||
.btn--accent:active {
|
||||
background: rgba(139, 92, 246, 0.15);
|
||||
}
|
||||
|
||||
.btn--outline {
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-border);
|
||||
@@ -683,7 +698,9 @@ body {
|
||||
/* Navigation tabs */
|
||||
.nav-tabs {
|
||||
display: flex;
|
||||
overflow-x: auto;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 2px;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
margin-bottom: var(--space-24);
|
||||
scrollbar-width: none;
|
||||
@@ -695,11 +712,11 @@ body {
|
||||
}
|
||||
|
||||
.nav-tab {
|
||||
padding: var(--space-12) var(--space-20);
|
||||
padding: var(--space-12) var(--space-16);
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-size-md);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
cursor: pointer;
|
||||
transition: all var(--duration-normal) var(--ease-standard);
|
||||
@@ -1033,9 +1050,87 @@ body {
|
||||
}
|
||||
|
||||
.demo-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* Status indicator dot */
|
||||
.status-indicator {
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: #555;
|
||||
}
|
||||
|
||||
.status-indicator.active {
|
||||
background: #00cc88;
|
||||
box-shadow: 0 0 6px #00cc88;
|
||||
}
|
||||
|
||||
.status-indicator.sim {
|
||||
background: #ffa500;
|
||||
box-shadow: 0 0 6px #ffa500;
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
.status-indicator.connecting {
|
||||
background: #f0ad4e;
|
||||
animation: pulse 1s infinite;
|
||||
}
|
||||
|
||||
.status-indicator.error {
|
||||
background: #ff3c3c;
|
||||
}
|
||||
|
||||
/* Live Demo data-source banner */
|
||||
.demo-source-banner {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 10px 16px;
|
||||
margin-bottom: 12px;
|
||||
border-radius: 6px;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.demo-source-live {
|
||||
background: rgba(0, 204, 136, 0.15);
|
||||
border: 1px solid #00cc88;
|
||||
color: #00cc88;
|
||||
}
|
||||
|
||||
.demo-source-sim {
|
||||
background: rgba(255, 165, 0, 0.15);
|
||||
border: 1px solid #ffa500;
|
||||
color: #ffa500;
|
||||
}
|
||||
|
||||
.demo-source-reconnecting {
|
||||
background: rgba(255, 180, 0, 0.12);
|
||||
border: 1px solid #f0ad4e;
|
||||
color: #f0ad4e;
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
.demo-source-offline {
|
||||
background: rgba(255, 60, 60, 0.12);
|
||||
border: 1px solid #ff3c3c;
|
||||
color: #ff3c3c;
|
||||
}
|
||||
|
||||
.demo-source-unknown {
|
||||
background: rgba(128, 128, 128, 0.12);
|
||||
border: 1px solid #888;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.demo-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
@@ -1388,6 +1483,15 @@ canvas {
|
||||
background: rgba(var(--color-warning-rgb), 0.05);
|
||||
}
|
||||
|
||||
.component-status.status-warning {
|
||||
border-color: #ffa500;
|
||||
background: rgba(255, 165, 0, 0.08);
|
||||
}
|
||||
|
||||
.component-status.status-warning .status-text {
|
||||
color: #ffa500;
|
||||
}
|
||||
|
||||
.component-status.status-unhealthy {
|
||||
border-color: var(--color-error);
|
||||
background: rgba(var(--color-error-rgb), 0.05);
|
||||
@@ -1806,12 +1910,24 @@ canvas {
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
.sensing-source-server-sim {
|
||||
background: rgba(255, 165, 0, 0.15);
|
||||
border: 1px solid #ffa500;
|
||||
color: #ffa500;
|
||||
}
|
||||
|
||||
.sensing-source-simulated {
|
||||
background: rgba(255, 60, 60, 0.12);
|
||||
border: 1px solid var(--color-error);
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
/* Health indicator for server-simulated data */
|
||||
.health-sim {
|
||||
color: #ffa500;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Big RSSI value */
|
||||
.sensing-big-value {
|
||||
font-size: var(--font-size-3xl);
|
||||
@@ -1956,3 +2072,355 @@ canvas {
|
||||
font-family: var(--font-family-mono);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
/* ===== Training Tab Styles ===== */
|
||||
|
||||
#training .tab-header {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
#training .tab-header h2 {
|
||||
color: var(--color-text);
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
#training .tab-header p {
|
||||
color: var(--color-text-secondary);
|
||||
margin: 0;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
/* Training Panel */
|
||||
.training-panel {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-card-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-16);
|
||||
}
|
||||
|
||||
.training-panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--space-16);
|
||||
padding-bottom: var(--space-12);
|
||||
border-bottom: 1px solid var(--color-card-border-inner);
|
||||
}
|
||||
|
||||
.training-panel-header h3 {
|
||||
color: var(--color-text);
|
||||
margin: 0;
|
||||
font-size: var(--font-size-base);
|
||||
}
|
||||
|
||||
.training-status-badge {
|
||||
padding: var(--space-2) 10px;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.training-status-idle {
|
||||
background: var(--color-secondary);
|
||||
color: var(--color-text-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.training-status-active {
|
||||
background: rgba(var(--color-error-rgb), 0.15);
|
||||
color: var(--color-error);
|
||||
border: 1px solid rgba(var(--color-error-rgb), var(--status-border-opacity));
|
||||
animation: pulse-training 2s infinite;
|
||||
}
|
||||
|
||||
.training-status-completed {
|
||||
background: rgba(var(--color-success-rgb), 0.15);
|
||||
color: var(--color-success);
|
||||
border: 1px solid rgba(var(--color-success-rgb), var(--status-border-opacity));
|
||||
}
|
||||
|
||||
@keyframes pulse-training {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.6; }
|
||||
}
|
||||
|
||||
/* Recording list */
|
||||
.recording-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px var(--space-12);
|
||||
background: var(--color-secondary);
|
||||
border: 1px solid var(--color-card-border-inner);
|
||||
border-radius: var(--radius-base);
|
||||
margin-bottom: var(--space-8);
|
||||
}
|
||||
|
||||
.recording-item-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.recording-item-name {
|
||||
color: var(--color-text);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.recording-item-meta {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-size-xs);
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
/* Model cards */
|
||||
.model-card {
|
||||
padding: var(--space-12);
|
||||
background: var(--color-secondary);
|
||||
border: 1px solid var(--color-card-border-inner);
|
||||
border-radius: var(--radius-base);
|
||||
margin-bottom: var(--space-8);
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.model-card:hover {
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
|
||||
.model-card-active {
|
||||
border-left: 3px solid var(--color-success);
|
||||
}
|
||||
|
||||
.model-card-name {
|
||||
color: var(--color-text);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.model-card-meta {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-size-xs);
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
|
||||
.model-card-stats {
|
||||
display: flex;
|
||||
gap: var(--space-12);
|
||||
margin-top: var(--space-8);
|
||||
}
|
||||
|
||||
.model-card-stat {
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
.model-card-stat-label {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.model-card-stat-value {
|
||||
color: var(--color-text);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
/* Training chart */
|
||||
.training-chart-container {
|
||||
background: var(--color-secondary);
|
||||
border: 1px solid var(--color-card-border-inner);
|
||||
border-radius: var(--radius-base);
|
||||
padding: var(--space-12);
|
||||
margin: var(--space-12) 0;
|
||||
}
|
||||
|
||||
.training-chart-label {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-size-xs);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: var(--space-8);
|
||||
}
|
||||
|
||||
/* Training config form */
|
||||
.training-config-form {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--space-12);
|
||||
}
|
||||
|
||||
.training-form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.training-form-label {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-size-xs);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.training-form-input {
|
||||
background: var(--color-background);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-base);
|
||||
color: var(--color-text);
|
||||
padding: var(--space-8) 10px;
|
||||
font-size: var(--font-size-sm);
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.training-form-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
|
||||
.training-form-select {
|
||||
background: var(--color-background);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-base);
|
||||
color: var(--color-text);
|
||||
padding: var(--space-8) 10px;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
/* Training buttons */
|
||||
.training-btn {
|
||||
padding: var(--space-8) var(--space-16);
|
||||
border-radius: var(--radius-base);
|
||||
border: 1px solid transparent;
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.training-btn-primary {
|
||||
background: rgba(var(--color-success-rgb), 0.15);
|
||||
color: var(--color-success);
|
||||
border-color: rgba(var(--color-success-rgb), var(--status-border-opacity));
|
||||
}
|
||||
|
||||
.training-btn-primary:hover {
|
||||
background: rgba(var(--color-success-rgb), 0.25);
|
||||
}
|
||||
|
||||
.training-btn-danger {
|
||||
background: rgba(var(--color-error-rgb), 0.15);
|
||||
color: var(--color-error);
|
||||
border-color: rgba(var(--color-error-rgb), var(--status-border-opacity));
|
||||
}
|
||||
|
||||
.training-btn-danger:hover {
|
||||
background: rgba(var(--color-error-rgb), 0.25);
|
||||
}
|
||||
|
||||
.training-btn-secondary {
|
||||
background: rgba(var(--color-primary-rgb), 0.15);
|
||||
color: var(--color-primary);
|
||||
border-color: rgba(var(--color-primary-rgb), var(--status-border-opacity));
|
||||
}
|
||||
|
||||
.training-btn-secondary:hover {
|
||||
background: rgba(var(--color-primary-rgb), 0.25);
|
||||
}
|
||||
|
||||
.training-btn-muted {
|
||||
background: var(--color-secondary);
|
||||
color: var(--color-text-secondary);
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
|
||||
.training-btn-muted:hover {
|
||||
background: var(--color-secondary-hover);
|
||||
}
|
||||
|
||||
/* Progress bar */
|
||||
.training-progress-bar {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
background: var(--color-secondary);
|
||||
border-radius: var(--radius-full);
|
||||
overflow: hidden;
|
||||
margin: var(--space-8) 0;
|
||||
}
|
||||
|
||||
.training-progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--color-primary), var(--color-success));
|
||||
border-radius: var(--radius-full);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
/* Metrics grid */
|
||||
.training-metrics-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: var(--space-8);
|
||||
margin: var(--space-12) 0;
|
||||
}
|
||||
|
||||
.training-metric {
|
||||
text-align: center;
|
||||
padding: var(--space-8);
|
||||
background: var(--color-secondary);
|
||||
border-radius: var(--radius-base);
|
||||
}
|
||||
|
||||
.training-metric-value {
|
||||
color: var(--color-text);
|
||||
font-size: var(--font-size-2xl);
|
||||
font-weight: var(--font-weight-bold);
|
||||
font-family: var(--font-family-mono);
|
||||
}
|
||||
|
||||
.training-metric-label {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-size-xs);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
/* Collapsible section */
|
||||
.training-collapsible-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px 0;
|
||||
cursor: pointer;
|
||||
color: var(--color-text);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
border-bottom: 1px solid var(--color-card-border-inner);
|
||||
}
|
||||
|
||||
.training-collapsible-header:hover {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.training-collapsible-content {
|
||||
padding: var(--space-12) 0;
|
||||
}
|
||||
|
||||
/* Pose trail toggle in toolbar */
|
||||
.pose-trail-btn {
|
||||
padding: var(--space-6) 14px;
|
||||
border-radius: var(--radius-base);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
background: rgba(var(--color-primary-rgb), 0.1);
|
||||
color: var(--color-primary);
|
||||
border: 1px solid rgba(var(--color-primary-rgb), 0.3);
|
||||
}
|
||||
|
||||
.pose-trail-btn.active {
|
||||
background: rgba(var(--color-primary-rgb), 0.25);
|
||||
border-color: rgba(var(--color-primary-rgb), 0.6);
|
||||
}
|
||||
|
||||
.pose-trail-btn:hover {
|
||||
background: rgba(var(--color-primary-rgb), 0.2);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user