mirror of
https://github.com/ruvnet/RuView
synced 2026-07-25 17:51:48 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 71e2d0003c |
@@ -40,7 +40,7 @@ In practice this means ordinary environments gain a new kind of spatial awarenes
|
||||
|
||||
[](https://www.rust-lang.org/)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://github.com/ruvnet/RuView)
|
||||
[](https://github.com/ruvnet/RuView)
|
||||
[](https://hub.docker.com/r/ruvnet/wifi-densepose)
|
||||
[](#vital-sign-detection)
|
||||
[](#esp32-s3-hardware-pipeline)
|
||||
@@ -49,14 +49,11 @@ In practice this means ordinary environments gain a new kind of spatial awarenes
|
||||
|
||||
> | What | How | Speed |
|
||||
> |------|-----|-------|
|
||||
> | **Pose estimation** | CSI subcarrier amplitude/phase → 17 COCO keypoints | 171K emb/s (M4 Pro) |
|
||||
> | **Breathing detection** | Bandpass 0.1-0.5 Hz → zero-crossing BPM | 6-30 BPM |
|
||||
> | **Heart rate** | Bandpass 0.8-2.0 Hz → zero-crossing BPM | 40-120 BPM |
|
||||
> | **Presence sensing** | Trained model + PIR fusion — 100% accuracy | 0.012 ms latency |
|
||||
> | **Pose estimation** | CSI subcarrier amplitude/phase → DensePose UV maps | 54K fps (Rust) |
|
||||
> | **Breathing detection** | Bandpass 0.1-0.5 Hz → FFT peak | 6-30 BPM |
|
||||
> | **Heart rate** | Bandpass 0.8-2.0 Hz → FFT peak | 40-120 BPM |
|
||||
> | **Presence sensing** | RSSI variance + motion band power | < 1ms latency |
|
||||
> | **Through-wall** | Fresnel zone geometry + multipath modeling | Up to 5m depth |
|
||||
> | **Edge intelligence** | 8-dim feature vectors + RVF store on Cognitum Seed | $27 total BOM |
|
||||
> | **Camera-free training** | 10 sensor signals, no labels needed | 84s on M4 Pro |
|
||||
> | **Multi-frequency mesh** | Channel hopping across 6 bands, neighbor APs as illuminators | 3x sensing bandwidth |
|
||||
|
||||
```bash
|
||||
# 30 seconds to live sensing — no toolchain required
|
||||
@@ -81,63 +78,6 @@ docker run -p 3000:3000 ruvnet/wifi-densepose:latest
|
||||
>
|
||||
---
|
||||
|
||||
### What's New in v0.5.4
|
||||
|
||||
<details open>
|
||||
<summary><strong>Cognitum Seed Integration + Camera-Free Pose Training</strong></summary>
|
||||
|
||||
**v0.5.4 transforms RuView from a real-time sensing tool into a persistent edge AI system.** Your ESP32 now remembers what it senses, learns without cameras, and proves its data cryptographically.
|
||||
|
||||
| Capability | Details | Hardware |
|
||||
|-----------|---------|----------|
|
||||
| **Persistent vector store** | Every sensing event stored as searchable 8-dim vector in RVF format | ESP32 + [Cognitum Seed](https://cognitum.one) ($27) |
|
||||
| **kNN similarity search** | "Find the 10 most similar states to right now" — anomaly detection, fingerprinting | Cognitum Seed |
|
||||
| **Witness chain** | SHA-256 tamper-evident audit trail for every measurement (1,747 entries validated) | Cognitum Seed |
|
||||
| **Camera-free pose training** | 17 COCO keypoints from 10 sensor signals — PIR, RSSI triangulation, subcarrier asymmetry, vibration, BME280 | 2x ESP32 + Seed |
|
||||
| **Pre-trained model** | 82.8 KB (8 KB at 4-bit quantization), 100% presence accuracy, 0 skeleton violations | Download from release |
|
||||
| **Sub-ms inference** | 0.012 ms latency, 171,472 embeddings/sec on M4 Pro | Any machine with Node.js |
|
||||
| **SONA adaptation** | Adapts to new rooms in <1ms without retraining | ruvllm runtime |
|
||||
| **LoRA room adapters** | Per-node fine-tuning with 2,048 parameters per adapter | Automatic |
|
||||
| **114-tool MCP proxy** | AI assistants (Claude, GPT) query sensors directly via JSON-RPC | Cognitum Seed |
|
||||
| **Multi-frequency mesh** | Channel hopping across ch 1/3/5/6/9/11 — neighbor WiFi as passive radar | 2x ESP32 ($18) |
|
||||
| **RF room scanner** | Real-time spectrum visualization: nulls, reflectors, movement, multipath | `node scripts/rf-scan.js` |
|
||||
| **Security hardened** | Bearer tokens, TLS, source IP filtering, NaN rejection, credential rotation | All components |
|
||||
|
||||
**Training pipeline (ruvllm, no PyTorch needed):**
|
||||
|
||||
```bash
|
||||
# Collect data (2 min, ESP32s must be streaming)
|
||||
python scripts/collect-training-data.py --port 5006 --duration 120
|
||||
|
||||
# Train — contrastive pretraining + task heads + LoRA + quantization + EWC
|
||||
node scripts/train-ruvllm.js --data data/recordings/pretrain-*.csi.jsonl
|
||||
|
||||
# Camera-free 17-keypoint pose (uses PIR + RSSI + vibration + subcarrier asymmetry)
|
||||
node scripts/train-camera-free.js --data data/recordings/pretrain-*.csi.jsonl
|
||||
|
||||
# Benchmark
|
||||
node scripts/benchmark-ruvllm.js --model models/csi-ruvllm
|
||||
```
|
||||
|
||||
**Validated benchmarks (M4 Pro):**
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| Training time | 84.4s (2,360 augmented samples) |
|
||||
| Contrastive improvement | 33.9% |
|
||||
| Presence accuracy | 100% |
|
||||
| Inference latency | 0.012 ms |
|
||||
| Throughput | 171,472 emb/s |
|
||||
| Model size (4-bit) | 8 KB |
|
||||
| Skeleton violations | 0 / 100 frames |
|
||||
| Rust tests | 1,463 passed |
|
||||
|
||||
See [ADR-069](docs/adr/ADR-069-cognitum-seed-csi-pipeline.md), [ADR-071](docs/adr/ADR-071-ruvllm-training-pipeline.md), and the [Cognitum Seed tutorial](docs/tutorials/cognitum-seed-pretraining.md) for full details.
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
## 📖 Documentation
|
||||
|
||||
| Document | Description |
|
||||
|
||||
@@ -1,203 +0,0 @@
|
||||
# ADR-070: Self-Supervised Pretraining from Live ESP32 CSI + Cognitum Seed
|
||||
|
||||
| Field | Value |
|
||||
|------------|----------------------------------------------------------|
|
||||
| Status | Accepted |
|
||||
| Date | 2026-04-02 |
|
||||
| Authors | rUv, claude-flow |
|
||||
| Drivers | README limitation "No pre-trained model weights provided"|
|
||||
| Related | ADR-069 (Cognitum Seed pipeline), ADR-027 (MERIDIAN), ADR-024 (AETHER contrastive), ADR-015 (MM-Fi dataset) |
|
||||
|
||||
## Context
|
||||
|
||||
The README lists "No pre-trained model weights are provided; training from scratch is required" as a known limitation. Users must collect their own CSI dataset and train from scratch, which is a significant barrier to adoption.
|
||||
|
||||
We now have the infrastructure to generate pre-trained weights directly from live hardware:
|
||||
|
||||
- **2 ESP32-S3 nodes** (COM8 node_id=2 at 192.168.1.104, COM9 node_id=1 at 192.168.1.105) streaming CSI + vitals + 8-dim feature vectors at 1 Hz each
|
||||
- **Cognitum Seed** (Pi Zero 2 W) with RVF vector store, kNN search, witness chain, and environmental sensors (BME280, PIR, vibration)
|
||||
- **Recording API** in sensing-server (`POST /api/v1/recording/start`) that saves CSI frames to `.csi.jsonl`
|
||||
- **Self-supervised training** via `rapid_adapt.rs` (contrastive TTT + entropy minimization)
|
||||
- **AETHER contrastive embeddings** (ADR-024) for environment-independent representations
|
||||
|
||||
### Why Self-Supervised?
|
||||
|
||||
No cameras or labels are needed. The system learns from:
|
||||
|
||||
1. **Temporal coherence** — Frames close in time should have similar embeddings (positive pairs), frames far apart should differ (negative pairs)
|
||||
2. **Multi-node consistency** — The same person seen from 2 nodes should produce correlated features, different people should produce decorrelated features
|
||||
3. **Cognitum Seed ground truth** — PIR sensor, BME280 environment changes, and kNN cluster transitions provide weak supervision without human labeling
|
||||
4. **Physical constraints** — Breathing 6-30 BPM, heart rate 40-150 BPM, person count 0-4, RSSI physics
|
||||
|
||||
## Decision
|
||||
|
||||
Implement a 4-phase pretraining pipeline that collects CSI from 2 ESP32 nodes, stores feature vectors in the Cognitum Seed, and produces distributable pre-trained weights.
|
||||
|
||||
### Phase 1: Data Collection (30 min)
|
||||
|
||||
Capture labeled scenarios using the sensing-server recording API and Cognitum Seed:
|
||||
|
||||
| Scenario | Duration | Label | Activity |
|
||||
|----------|----------|-------|----------|
|
||||
| Empty room | 5 min | `empty` | No one present, establish baseline |
|
||||
| 1 person stationary | 5 min | `1p-still` | Sit at desk, normal breathing |
|
||||
| 1 person walking | 5 min | `1p-walk` | Walk around room, varied paths |
|
||||
| 1 person varied | 5 min | `1p-varied` | Stand, sit, wave arms, turn |
|
||||
| 2 people | 5 min | `2p` | Both moving in room |
|
||||
| Transitions | 5 min | `transitions` | Enter/exit room, appear/disappear |
|
||||
|
||||
**Data rate per scenario:**
|
||||
- 2 nodes × 100 Hz CSI = 200 frames/sec = 60,000 frames per 5 min
|
||||
- 2 nodes × 1 Hz features = 2 vectors/sec = 600 vectors per 5 min
|
||||
- Total: 360,000 CSI frames + 3,600 feature vectors per collection run
|
||||
|
||||
**Cognitum Seed role:**
|
||||
- Stores all feature vectors with witness chain attestation
|
||||
- PIR sensor provides binary presence ground truth
|
||||
- BME280 tracks environmental conditions during collection
|
||||
- kNN graph clusters naturally emerge from the vector distribution
|
||||
|
||||
### Phase 2: Contrastive Pretraining
|
||||
|
||||
Train a contrastive encoder on the collected CSI data:
|
||||
|
||||
```
|
||||
Input: Raw CSI frame (128 subcarriers × 2 I/Q = 256 features)
|
||||
↓
|
||||
TCN temporal encoder (3 layers, kernel=7)
|
||||
↓
|
||||
Projection head → 128-dim embedding
|
||||
↓
|
||||
Contrastive loss (InfoNCE):
|
||||
positive: frames within 0.5s window from same node
|
||||
negative: frames >5s apart or from different scenario
|
||||
cross-node positive: same timestamp, different node
|
||||
```
|
||||
|
||||
**Self-supervised signals:**
|
||||
- Temporal adjacency (frames within 500ms = positive pair)
|
||||
- Cross-node agreement (same person seen from 2 viewpoints)
|
||||
- PIR consistency (embedding should cluster by PIR state)
|
||||
- Scenario boundary (embeddings should shift at label transitions)
|
||||
|
||||
### Phase 3: Downstream Head Training
|
||||
|
||||
Attach lightweight heads for each task:
|
||||
|
||||
| Head | Architecture | Output | Supervision |
|
||||
|------|-------------|--------|-------------|
|
||||
| Presence | Linear(128→1) + sigmoid | 0.0-1.0 | PIR sensor (free) |
|
||||
| Person count | Linear(128→4) + softmax | 0-3 people | Scenario labels |
|
||||
| Activity | Linear(128→4) + softmax | still/walk/varied/empty | Scenario labels |
|
||||
| Vital signs | Linear(128→2) | BR, HR (BPM) | ESP32 edge vitals |
|
||||
|
||||
### Phase 4: Package & Distribute
|
||||
|
||||
Produce distributable artifacts:
|
||||
|
||||
| Artifact | Format | Size | Description |
|
||||
|----------|--------|------|-------------|
|
||||
| `pretrained-encoder.onnx` | ONNX | ~2 MB | Contrastive encoder (TCN backbone) |
|
||||
| `pretrained-heads.onnx` | ONNX | ~100 KB | Task-specific heads |
|
||||
| `pretrained.rvf` | RVF | ~500 KB | RuVector format with metadata |
|
||||
| `room-profiles.json` | JSON | ~10 KB | Environment calibration profiles |
|
||||
| `collection-witness.json` | JSON | ~5 KB | Seed witness chain attestation proving data provenance |
|
||||
|
||||
Include in GitHub release alongside firmware binaries. Users download and run:
|
||||
|
||||
```bash
|
||||
# Use pre-trained model (no training needed)
|
||||
cargo run -p wifi-densepose-sensing-server -- --model pretrained.rvf --http-port 3000
|
||||
```
|
||||
|
||||
## Hardware Setup
|
||||
|
||||
```
|
||||
192.168.1.20 (Host laptop)
|
||||
┌──────────────────────────┐
|
||||
│ sensing-server │
|
||||
│ Recording API │
|
||||
│ Training pipeline │
|
||||
│ │
|
||||
│ seed_csi_bridge.py │
|
||||
│ Feature → Seed ingest │
|
||||
└────┬──────────┬───────────┘
|
||||
│ │
|
||||
UDP:5006 │ │ HTTPS:8443
|
||||
┌───────────────────┤ ├───────────────┐
|
||||
│ │ │ │
|
||||
▼ ▼ ▼ │
|
||||
┌──────────┐ ┌──────────┐ ┌──────────────┐ │
|
||||
│ ESP32 #1 │ │ ESP32 #2 │ │Cognitum Seed │◄───┘
|
||||
│ COM9 │ │ COM8 │ │ Pi Zero 2W │
|
||||
│ node=1 │ │ node=2 │ │ USB │
|
||||
│ .1.105 │ │ .1.104 │ │ .42.1/8443 │
|
||||
│ v0.5.4 │ │ v0.5.4 │ │ v0.8.1 │
|
||||
└──────────┘ └──────────┘ │ PIR, BME280 │
|
||||
│ RVF store │
|
||||
│ Witness chain│
|
||||
└──────────────┘
|
||||
```
|
||||
|
||||
## Data Collection Protocol
|
||||
|
||||
### Step 1: Start Seed ingest (background)
|
||||
|
||||
```bash
|
||||
export SEED_TOKEN="your-token"
|
||||
python scripts/seed_csi_bridge.py \
|
||||
--seed-url https://169.254.42.1:8443 --token "$SEED_TOKEN" \
|
||||
--udp-port 5006 --batch-size 10 --validate &
|
||||
```
|
||||
|
||||
### Step 2: Start sensing-server with recording
|
||||
|
||||
```bash
|
||||
cargo run -p wifi-densepose-sensing-server -- \
|
||||
--source esp32 --udp-port 5006 --http-port 3000
|
||||
```
|
||||
|
||||
### Step 3: Record each scenario
|
||||
|
||||
```bash
|
||||
# Empty room (leave room for 5 min)
|
||||
curl -X POST http://localhost:3000/api/v1/recording/start \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"session_name":"pretrain-empty","label":"empty","duration_secs":300}'
|
||||
|
||||
# 1 person stationary (sit at desk for 5 min)
|
||||
curl -X POST http://localhost:3000/api/v1/recording/start \
|
||||
-d '{"session_name":"pretrain-1p-still","label":"1p-still","duration_secs":300}'
|
||||
|
||||
# ... repeat for each scenario
|
||||
```
|
||||
|
||||
### Step 4: Verify with Seed
|
||||
|
||||
```bash
|
||||
python scripts/seed_csi_bridge.py --token "$SEED_TOKEN" --stats
|
||||
# Should show 3,600+ vectors from the collection run
|
||||
```
|
||||
|
||||
## Risks
|
||||
|
||||
| Risk | Likelihood | Impact | Mitigation |
|
||||
|------|-----------|--------|------------|
|
||||
| 2 nodes insufficient for spatial diversity | Medium | Lower pretraining quality | Place nodes 3-5m apart at different heights |
|
||||
| PIR sensor has limited range | Low | Weak presence labels | BME280 temp changes + kNN clusters as backup |
|
||||
| Contrastive pretraining collapses | Low | Useless embeddings | Temperature scheduling, hard negative mining |
|
||||
| Model too large for ESP32 inference | N/A | N/A | Inference on host/Seed, not on ESP32 |
|
||||
| Room-specific overfitting | Medium | Poor generalization | MERIDIAN domain randomization (ADR-027), LoRA adaptation |
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
- Users get working model out of the box — no training needed
|
||||
- Witness chain proves data provenance (when/where/which hardware)
|
||||
- Pre-trained encoder transfers to new environments via LoRA fine-tuning
|
||||
- Removes the #1 adoption barrier from the README
|
||||
|
||||
### Negative
|
||||
- 30 min of manual data collection per pretraining run
|
||||
- Pre-trained weights are room-specific without adaptation
|
||||
- ONNX runtime dependency for inference
|
||||
@@ -1,408 +0,0 @@
|
||||
# ADR-071: ruvllm Training Pipeline for CSI Sensing Models
|
||||
|
||||
- **Status**: Proposed
|
||||
- **Date**: 2026-04-02
|
||||
- **Deciders**: ruv
|
||||
- **Relates to**: ADR-069 (Cognitum Seed CSI Pipeline), ADR-070 (Self-Supervised Pretraining), ADR-024 (Contrastive CSI Embedding / AETHER), ADR-016 (RuVector Training Pipeline)
|
||||
|
||||
## Context
|
||||
|
||||
The WiFi-DensePose project needs a training pipeline to convert collected CSI data
|
||||
(`.csi.jsonl` frames from ESP32 nodes) into deployable models for presence detection,
|
||||
activity classification, and vital sign estimation.
|
||||
|
||||
Previous ADRs established the data collection protocol (ADR-070) and Cognitum Seed
|
||||
inference target (ADR-069). What was missing was the actual training, refinement,
|
||||
quantization, and export pipeline connecting raw CSI recordings to deployable models.
|
||||
|
||||
### Why ruvllm instead of PyTorch
|
||||
|
||||
| Criterion | ruvllm | PyTorch | ONNX Runtime |
|
||||
|-----------|--------|---------|--------------|
|
||||
| Runtime dependency | Node.js only | Python + CUDA + pip | C++ runtime |
|
||||
| Install size | ~5 MB (npm) | ~2 GB (torch+cuda) | ~50 MB |
|
||||
| SONA adaptation | <1ms native | N/A | N/A |
|
||||
| Quantization | 2/4/8-bit TurboQuant | INT8/FP16 (separate tool) | INT8 only |
|
||||
| LoRA fine-tuning | Built-in LoraAdapter | Requires PEFT library | N/A |
|
||||
| EWC protection | Built-in EwcManager | Manual implementation | N/A |
|
||||
| SafeTensors export | Native SafeTensorsWriter | Via safetensors library | N/A |
|
||||
| Contrastive training | Built-in ContrastiveTrainer | Manual triplet loss | N/A |
|
||||
| Edge deployment | ESP32, Pi Zero, browser | GPU servers only | ARM (limited) |
|
||||
| M4 Pro performance | 88-135 tok/s native | ~30 tok/s (MPS) | ~50 tok/s |
|
||||
| Ecosystem integration | RuVector, Cognitum Seed | Standalone | Standalone |
|
||||
|
||||
The ruvllm package (`@ruvector/ruvllm` v2.5.4) provides the complete training
|
||||
lifecycle in a single dependency: contrastive pretraining, task head training,
|
||||
LoRA refinement, EWC consolidation, quantization, and SafeTensors/RVF export.
|
||||
No Python dependency means the entire pipeline runs on the same Node.js runtime
|
||||
as the Cognitum Seed inference engine.
|
||||
|
||||
## Decision
|
||||
|
||||
Use ruvllm's `ContrastiveTrainer`, `TrainingPipeline`, `LoraAdapter`, `EwcManager`,
|
||||
`SafeTensorsWriter`, and `ModelExporter` for the complete CSI model training lifecycle.
|
||||
|
||||
### Training Phases
|
||||
|
||||
The pipeline executes five sequential phases:
|
||||
|
||||
#### Phase 1: Contrastive Pretraining
|
||||
|
||||
Learns an embedding space where temporally and spatially similar CSI states are close
|
||||
and dissimilar states are far apart.
|
||||
|
||||
- **Encoder architecture**: 8-dim CSI feature vector -> 64-dim hidden (ReLU) -> 128-dim embedding (L2-normalized)
|
||||
- **Loss functions**: Triplet loss (margin=0.3) + InfoNCE (temperature=0.07)
|
||||
- **Triplet strategies**:
|
||||
- Temporal positive: frames within 1 second (same environment state)
|
||||
- Temporal negative: frames >30 seconds apart (different state)
|
||||
- Cross-node positive: same timestamp from different ESP32 nodes (same person, different viewpoint)
|
||||
- Cross-node negative: different timestamp + different node
|
||||
- Hard negatives: frames near motion energy transition boundaries
|
||||
- **Hyperparameters**: 20 epochs, batch size 32, hard negative ratio 0.7
|
||||
- **Implementation**: `ContrastiveTrainer.addTriplet()` + `.train()`
|
||||
|
||||
#### Phase 2: Task Head Training
|
||||
|
||||
Trains supervised heads on top of the frozen embedding for specific sensing tasks.
|
||||
|
||||
- **Presence head**: 128 -> 1 (sigmoid), threshold at presence_score > 0.3
|
||||
- **Activity head**: 128 -> 3 (softmax: still/moving/empty), derived from motion_energy thresholds
|
||||
- **Vitals head**: 128 -> 2 (linear: breathing BPM, heart rate BPM), normalized targets
|
||||
- **Implementation**: `TrainingPipeline.addData()` + `.train()` with cosine LR scheduler,
|
||||
early stopping (patience=5), and quality-weighted MSE loss
|
||||
|
||||
#### Phase 3: LoRA Refinement
|
||||
|
||||
Per-node LoRA adapters for room-specific adaptation without forgetting the base model.
|
||||
|
||||
- **Configuration**: rank=4, alpha=8, dropout=0.1
|
||||
- **Per-node training**: Each ESP32 node gets its own LoRA adapter trained on
|
||||
node-specific data with reduced learning rate (0.5x base)
|
||||
- **Implementation**: `LoraManager.create()` for each node, `TrainingPipeline` with
|
||||
`LoraAdapter` passed to constructor
|
||||
|
||||
#### Phase 4: Quantization (TurboQuant)
|
||||
|
||||
Reduces model size for edge deployment with minimal quality loss.
|
||||
|
||||
| Bit Width | Compression | Typical RMSE | Target Device |
|
||||
|-----------|-------------|-------------|---------------|
|
||||
| 8-bit | 4x | <0.001 | Cognitum Seed (Pi Zero) |
|
||||
| 4-bit | 8x | <0.01 | Standard edge inference |
|
||||
| 2-bit | 16x | <0.05 | ESP32-S3 feature extraction |
|
||||
|
||||
- **Method**: Uniform affine quantization with scale/zero-point per tensor
|
||||
- **Quality validation**: RMSE between original fp32 and dequantized weights
|
||||
|
||||
#### Phase 5: EWC Consolidation
|
||||
|
||||
Elastic Weight Consolidation prevents catastrophic forgetting when the model
|
||||
is later fine-tuned on new room data or updated CSI conditions.
|
||||
|
||||
- **Fisher information**: Computed from training data gradients
|
||||
- **Lambda**: 2000 (base), 3000 (per-node)
|
||||
- **Tasks registered**: Base pretraining + one per ESP32 node
|
||||
- **Implementation**: `EwcManager.registerTask()` for each training phase
|
||||
|
||||
### Data Pipeline
|
||||
|
||||
```
|
||||
.csi.jsonl files
|
||||
|
|
||||
v
|
||||
Parse frames: feature (8-dim), vitals, raw CSI
|
||||
|
|
||||
v
|
||||
Generate contrastive triplets (temporal, cross-node, hard negatives)
|
||||
|
|
||||
v
|
||||
Encode through CsiEncoder (8 -> 64 -> 128)
|
||||
|
|
||||
v
|
||||
Phase 1: ContrastiveTrainer (triplet + InfoNCE loss)
|
||||
|
|
||||
v
|
||||
Phase 2: TrainingPipeline (presence + activity + vitals heads)
|
||||
|
|
||||
v
|
||||
Phase 3: LoRA per-node refinement
|
||||
|
|
||||
v
|
||||
Phase 4: TurboQuant (2/4/8-bit quantization)
|
||||
|
|
||||
v
|
||||
Phase 5: EWC consolidation
|
||||
|
|
||||
v
|
||||
Export: SafeTensors, JSON config, RVF manifest, per-node LoRA adapters
|
||||
```
|
||||
|
||||
### Export Formats
|
||||
|
||||
| Format | File | Consumer |
|
||||
|--------|------|----------|
|
||||
| SafeTensors | `model.safetensors` | HuggingFace ecosystem, general inference |
|
||||
| JSON config | `config.json` | Model loading metadata |
|
||||
| JSON model | `model.json` | Full model state for Node.js loading |
|
||||
| Quantized binaries | `quantized/model-q{2,4,8}.bin` | Edge deployment |
|
||||
| Per-node LoRA | `lora/node-{id}.json` | Room-specific adaptation |
|
||||
| RVF manifest | `model.rvf.jsonl` | Cognitum Seed ingest (ADR-069) |
|
||||
| Training metrics | `training-metrics.json` | Dashboards, CI validation |
|
||||
|
||||
### Hardware Targets
|
||||
|
||||
| Device | Role | Quantization | Expected Latency |
|
||||
|--------|------|-------------|-----------------|
|
||||
| Mac Mini M4 Pro | Training (primary) | fp32 | <5 min total |
|
||||
| Cognitum Seed Pi Zero | Inference | 4-bit / 8-bit | <10 ms per frame |
|
||||
| ESP32-S3 | Feature extraction only | 2-bit (encoder weights) | <5 ms per frame |
|
||||
| Browser (WASM) | Visualization | 4-bit | <20 ms per frame |
|
||||
|
||||
### Performance Targets
|
||||
|
||||
| Metric | Target | Measured |
|
||||
|--------|--------|----------|
|
||||
| Training time (5,783 frames, M4 Pro) | <5 min | TBD |
|
||||
| Inference latency (M4 Pro) | <1 ms | TBD |
|
||||
| Inference latency (Pi Zero) | <10 ms | TBD |
|
||||
| SONA adaptation | <1 ms | <0.05 ms (ruvllm spec) |
|
||||
| Presence detection accuracy | >85% | TBD |
|
||||
| 4-bit quality loss (RMSE) | <0.01 | TBD |
|
||||
| 2-bit quality loss (RMSE) | <0.05 | TBD |
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
|
||||
- **Zero Python dependency**: The entire training and inference pipeline runs on
|
||||
Node.js, eliminating Python/CUDA/pip dependency management on training and
|
||||
deployment targets.
|
||||
- **Integrated lifecycle**: Contrastive pretraining, task heads, LoRA refinement,
|
||||
EWC consolidation, and quantization in a single script using one library.
|
||||
- **Edge-first**: 2-bit quantization enables running the encoder on ESP32-S3.
|
||||
4-bit quantization fits comfortably on Cognitum Seed Pi Zero.
|
||||
- **Continual learning**: EWC protection means the model can be updated with new
|
||||
room data without losing previously learned patterns.
|
||||
- **Per-node adaptation**: LoRA adapters allow room-specific fine-tuning with
|
||||
minimal storage overhead (rank-4 adapter ~2KB per node).
|
||||
- **HuggingFace compatibility**: SafeTensors export enables sharing models on the
|
||||
HuggingFace Hub and loading in other frameworks.
|
||||
- **Reproducibility**: Seeded encoder initialization and deterministic data pipeline
|
||||
ensure reproducible training runs.
|
||||
|
||||
### Negative
|
||||
|
||||
- **No GPU acceleration**: ruvllm's JS training loop does not use GPU compute.
|
||||
For the small model sizes in CSI sensing (8->64->128), this is acceptable
|
||||
(~seconds on M4 Pro), but would not scale to large vision models.
|
||||
- **Simplified backpropagation**: The LoRA backward pass and contrastive training
|
||||
use approximate gradient updates rather than full automatic differentiation.
|
||||
Sufficient for the target model sizes but not equivalent to PyTorch autograd.
|
||||
- **Quantization is post-training only**: No quantization-aware training (QAT).
|
||||
For 4-bit and 8-bit this produces acceptable quality loss; 2-bit may need
|
||||
QAT in future if quality degrades.
|
||||
|
||||
### Risks
|
||||
|
||||
- **Quality ceiling**: The simplified training may produce lower accuracy than a
|
||||
PyTorch-trained equivalent. Mitigated by: (a) the model is small enough that
|
||||
the training loop converges quickly, (b) SONA adaptation can compensate at
|
||||
inference time, (c) we can switch to PyTorch for training only if needed
|
||||
while keeping ruvllm for inference.
|
||||
- **ruvllm API stability**: The library is at v2.5.4 with active development.
|
||||
Mitigated by vendoring the package in `vendor/ruvector/npm/packages/ruvllm/`.
|
||||
|
||||
## Implementation
|
||||
|
||||
### Scripts
|
||||
|
||||
| Script | Purpose |
|
||||
|--------|---------|
|
||||
| `scripts/train-ruvllm.js` | Full 5-phase training pipeline |
|
||||
| `scripts/benchmark-ruvllm.js` | Model benchmarking (latency, quality, accuracy) |
|
||||
|
||||
### Usage
|
||||
|
||||
```bash
|
||||
# Train on collected CSI data
|
||||
node scripts/train-ruvllm.js \
|
||||
--data data/recordings/pretrain-1775182186.csi.jsonl \
|
||||
--output models/csi-v1 \
|
||||
--epochs 20
|
||||
|
||||
# Train with benchmark
|
||||
node scripts/train-ruvllm.js \
|
||||
--data data/recordings/pretrain-*.csi.jsonl \
|
||||
--output models/csi-v1 \
|
||||
--benchmark
|
||||
|
||||
# Standalone benchmark
|
||||
node scripts/benchmark-ruvllm.js \
|
||||
--model models/csi-v1 \
|
||||
--data data/recordings/pretrain-*.csi.jsonl \
|
||||
--samples 5000 \
|
||||
--json
|
||||
```
|
||||
|
||||
### Output Structure
|
||||
|
||||
```
|
||||
models/csi-v1/
|
||||
model.safetensors # SafeTensors (HuggingFace compatible)
|
||||
config.json # Model configuration
|
||||
model.json # Full JSON model state
|
||||
model.rvf.jsonl # RVF manifest for Cognitum Seed
|
||||
training-metrics.json # Training loss curves, timing, config
|
||||
contrastive/
|
||||
triplets.jsonl # Contrastive training pairs
|
||||
triplets.csv # CSV format for analysis
|
||||
embeddings.json # Embedding matrices
|
||||
quantized/
|
||||
model-q2.bin # 2-bit quantized (ESP32 edge)
|
||||
model-q4.bin # 4-bit quantized (Pi Zero default)
|
||||
model-q8.bin # 8-bit quantized (high quality)
|
||||
lora/
|
||||
node-1.json # LoRA adapter for ESP32 node 1
|
||||
node-2.json # LoRA adapter for ESP32 node 2
|
||||
```
|
||||
|
||||
## Camera-Free Supervision
|
||||
|
||||
### Motivation
|
||||
|
||||
Traditional WiFi-based pose estimation (WiFlow, Person-in-WiFi) requires camera-supervised
|
||||
training: a camera captures ground-truth poses during CSI collection, and the model learns
|
||||
to map CSI to those poses. This creates a deployment paradox — the camera is needed for
|
||||
training but the whole point of WiFi sensing is to avoid cameras.
|
||||
|
||||
The camera-free pipeline (`scripts/train-camera-free.js`) replaces camera supervision with
|
||||
10 sensor signals from the Cognitum Seed and 2 ESP32 nodes, generating weak labels through
|
||||
sensor fusion.
|
||||
|
||||
### 10 Supervision Signals (No Camera)
|
||||
|
||||
| # | Signal | Source | Provides |
|
||||
|---|--------|--------|----------|
|
||||
| 1 | PIR sensor | Seed GPIO 6 | Binary presence ground truth |
|
||||
| 2 | BME280 temperature | Seed I2C 0x76 | Occupancy proxy (temp rises with people) |
|
||||
| 3 | BME280 humidity | Seed I2C 0x76 | Breathing confirmation / zone |
|
||||
| 4 | Cross-node RSSI | 2 ESP32 nodes | Rough XY position (differential triangulation) |
|
||||
| 5 | Vitals stability | ESP32 CSI | HR/BR variance indicates activity level |
|
||||
| 6 | Temporal CSI patterns | ESP32 CSI | Periodic=walking, stable=sitting, flat=empty |
|
||||
| 7 | kNN cluster labels | Seed vector store | Natural groupings in embedding space |
|
||||
| 8 | Boundary fragility | Seed Stoer-Wagner | Regime change detection (entry/exit/activity) |
|
||||
| 9 | Reed switch | Seed GPIO 5 | Door open/close events |
|
||||
| 10 | Vibration sensor | Seed GPIO 13 | Footstep detection |
|
||||
|
||||
### Camera-Free Training Phases
|
||||
|
||||
The pipeline extends the base 5 phases with camera-free-specific phases:
|
||||
|
||||
```
|
||||
Phase 0: Multi-Modal Data Collection
|
||||
├── UDP port 5006 → ESP32 CSI features + vitals
|
||||
├── HTTPS → Seed sensor embeddings (45-dim, every 100ms)
|
||||
├── HTTPS → Seed boundary/coherence (every 10s)
|
||||
└── Build synchronized MultiModalFrame timeline
|
||||
|
||||
Phase 1: Weak Label Generation
|
||||
├── Presence: PIR || CSI_presence > 0.3 || temp_rising > 0.1°C/min
|
||||
├── Position: RSSI differential → 5×5 grid (25 zones)
|
||||
├── Activity: CSI variance + FFT periodicity → stationary/walking/gesture/empty
|
||||
├── Occupancy: max(node1_persons, node2_persons) validated by temp
|
||||
├── Body region: upper/lower subcarrier groups → which body part moves
|
||||
├── Entry/exit: reed_switch + PIR transition + boundary fragility spike
|
||||
├── Breathing zone: humidity change rate → person location
|
||||
└── Pose proxy: 5-keypoint coarse pose from RSSI + subcarrier asymmetry + vibration
|
||||
|
||||
Phase 2: Enhanced Contrastive Pretraining
|
||||
├── Base triplets (temporal, cross-node, transition, scenario boundary)
|
||||
├── Sensor-verified negatives: PIR=0 vs PIR=1 must differ
|
||||
├── Activity boundary: before/after fragility spike must differ
|
||||
└── Cross-modal: CSI embedding ≈ Seed embedding for same state
|
||||
|
||||
Phase 3: Pose Proxy Training (5-keypoint)
|
||||
├── Head: RSSI centroid between 2 nodes
|
||||
├── Hands: per-subcarrier variance asymmetry (left/right from 2 nodes)
|
||||
├── Feet: vibration sensor + RSSI ground reflection
|
||||
└── Skeleton physics constraints (anthropometric bone length limits)
|
||||
|
||||
Phase 4: 17-Keypoint Interpolation
|
||||
├── Shoulders = 0.3 × head + 0.7 × hands
|
||||
├── Elbows = midpoint(shoulder, hand)
|
||||
├── Hips = midpoint(head, feet)
|
||||
├── Knees = midpoint(hip, foot)
|
||||
├── Face = derived from head position
|
||||
└── Iterative bone length constraint projection (3 iterations)
|
||||
|
||||
Phase 5: Self-Refinement Loop (3 rounds)
|
||||
├── Run inference on all collected data
|
||||
├── Keep predictions where temporal consistency confidence > 0.8
|
||||
├── Use as pseudo-labels for next training round
|
||||
└── Decaying learning rate per round (diminishing returns)
|
||||
```
|
||||
|
||||
### Seed API Endpoints Used
|
||||
|
||||
| Endpoint | Data | Collection Rate |
|
||||
|----------|------|----------------|
|
||||
| `GET /api/v1/sensor/stream` | SSE sensor readings | Continuous (100ms) |
|
||||
| `GET /api/v1/sensor/embedding/latest` | 45-dim sensor embedding | Per-frame |
|
||||
| `GET /api/v1/boundary` | Fragility score | Every 10s |
|
||||
| `GET /api/v1/coherence/profile` | Temporal phase boundaries | Every 10s |
|
||||
| `GET /api/v1/store/query` | kNN similarity search | On demand |
|
||||
| `POST /api/v1/boundary/recompute` | Trigger analysis | On regime change |
|
||||
|
||||
### Graceful Degradation
|
||||
|
||||
The pipeline works with or without the Cognitum Seed:
|
||||
|
||||
| Mode | Signals | Pose Quality |
|
||||
|------|---------|-------------|
|
||||
| Full (Seed + 2 ESP32) | 10 signals | 5-keypoint trained, 17-keypoint interpolated |
|
||||
| CSI-only (2 ESP32) | 3 signals (RSSI, vitals, temporal) | Coarser position/activity only |
|
||||
| Single node | 2 signals (vitals, temporal) | Presence + activity only |
|
||||
|
||||
When the Seed API is unreachable, the pipeline automatically falls back to
|
||||
CSI-only training, producing the same output format (SafeTensors, HuggingFace,
|
||||
quantized) with reduced label quality.
|
||||
|
||||
### Output Format
|
||||
|
||||
Same as the base pipeline (SafeTensors + HuggingFace compatible), plus:
|
||||
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `pose-decoder.json` | 5-keypoint pose decoder weights |
|
||||
| `model.rvf.jsonl` | Extended with `camera_free_supervision` record |
|
||||
| `training-metrics.json` | Includes weak label stats and multi-modal triplet counts |
|
||||
|
||||
### Usage
|
||||
|
||||
```bash
|
||||
# Full pipeline with Seed
|
||||
node scripts/train-camera-free.js \
|
||||
--data data/recordings/pretrain-*.csi.jsonl \
|
||||
--seed-url https://169.254.42.1:8443 \
|
||||
--output models/csi-camerafree-v1
|
||||
|
||||
# CSI-only (no Seed)
|
||||
node scripts/train-camera-free.js \
|
||||
--data data/recordings/pretrain-*.csi.jsonl \
|
||||
--no-seed \
|
||||
--output models/csi-camerafree-v1
|
||||
|
||||
# With benchmark
|
||||
node scripts/train-camera-free.js \
|
||||
--data data/recordings/*.csi.jsonl \
|
||||
--benchmark
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
- [ruvllm source](vendor/ruvector/npm/packages/ruvllm/) — v2.5.4
|
||||
- [ADR-069](ADR-069-cognitum-seed-csi-pipeline.md) — Cognitum Seed CSI Pipeline
|
||||
- [ADR-070](ADR-070-self-supervised-pretraining.md) — Self-Supervised Pretraining Protocol
|
||||
- [ADR-024](ADR-024-contrastive-csi-embedding.md) — Contrastive CSI Embedding / AETHER
|
||||
- [ADR-016](ADR-016-ruvector-training-pipeline.md) — RuVector Training Pipeline Integration
|
||||
@@ -1,238 +0,0 @@
|
||||
# ADR-072: WiFlow Pose Estimation Architecture
|
||||
|
||||
- **Status**: Proposed
|
||||
- **Date**: 2026-04-02
|
||||
- **Deciders**: ruv
|
||||
- **Relates to**: ADR-071 (ruvllm Training Pipeline), ADR-070 (Self-Supervised Pretraining), ADR-024 (Contrastive CSI Embedding / AETHER), ADR-069 (Cognitum Seed CSI Pipeline)
|
||||
|
||||
## Context
|
||||
|
||||
The WiFi-DensePose project needs a neural architecture that can convert raw CSI amplitude
|
||||
data into 17-keypoint COCO pose estimates. The existing `train-ruvllm.js` pipeline uses a
|
||||
simple 2-layer FC encoder (8 -> 64 -> 128) that produces contrastive embeddings for
|
||||
presence detection but cannot output spatial keypoint coordinates.
|
||||
|
||||
We evaluated published WiFi-based pose estimation architectures:
|
||||
|
||||
| Architecture | Params | Input | Key Innovation | Publication |
|
||||
|-------------|--------|-------|---------------|-------------|
|
||||
| **WiFlow** | 4.82M | 540x20 | TCN + AsymConv + Axial Attention | arXiv:2602.08661 |
|
||||
| WiPose | 11.2M | 3x3x30x20 | 3D CNN + heatmap regression | CVPR 2021 |
|
||||
| MetaFi++ | 8.6M | 114x30x20 | Transformer + meta-learning | NeurIPS 2023 |
|
||||
| Person-in-WiFi 3D | 15.3M | Multi-antenna | Deformable attention + 3D | CVPR 2024 |
|
||||
|
||||
WiFlow is the lightest published SOTA architecture, designed specifically for commercial
|
||||
WiFi hardware. Its key advantage is operating on CSI amplitude only (no phase), which
|
||||
is critical for ESP32-S3 where phase calibration is unreliable.
|
||||
|
||||
### Why WiFlow
|
||||
|
||||
1. **Lightest SOTA**: 4.82M parameters at original scale; our adaptation targets ~2.5M
|
||||
2. **Amplitude-only**: Discards phase, which is noisy on consumer hardware
|
||||
3. **Published architecture**: Fully specified in arXiv:2602.08661, reproducible
|
||||
4. **Temporal modeling**: TCN with dilated causal convolutions captures motion dynamics
|
||||
5. **Efficient attention**: Axial attention reduces O(H^2W^2) to O(H^2W + HW^2)
|
||||
6. **Proven on commercial WiFi**: Validated on commodity Intel 5300 and Atheros hardware
|
||||
|
||||
## Decision
|
||||
|
||||
Implement the WiFlow architecture in pure JavaScript (ruvllm native) with the following
|
||||
adaptations for our ESP32 single TX/RX deployment.
|
||||
|
||||
### Architecture Overview
|
||||
|
||||
```
|
||||
CSI Amplitude [128, 20]
|
||||
|
|
||||
Stage 1: TCN (Dilated Causal Conv)
|
||||
dilation = (1, 2, 4, 8), kernel = 7
|
||||
128 -> 256 -> 192 -> 128 channels
|
||||
|
|
||||
Stage 2: Asymmetric Conv Encoder
|
||||
1xk conv (k=3), stride (1,2)
|
||||
[1, 128, 20] -> [256, 8, 20]
|
||||
|
|
||||
Stage 3: Axial Self-Attention
|
||||
Width (temporal): 8 heads
|
||||
Height (feature): 8 heads
|
||||
|
|
||||
Decoder: Adaptive Avg Pool + Linear
|
||||
[256, 8, 20] -> pool -> [2048] -> [17, 2]
|
||||
|
|
||||
17 COCO Keypoints [x, y] in [0, 1]
|
||||
```
|
||||
|
||||
### Our Adaptation vs Original WiFlow
|
||||
|
||||
| Aspect | WiFlow Original | Our Adaptation | Reason |
|
||||
|--------|----------------|----------------|--------|
|
||||
| Input channels | 540 (18 links x 30 SC) | 128 (1 TX x 1 RX x 128 SC) | Single ESP32 link |
|
||||
| Time steps | 20 | 20 | Same |
|
||||
| TCN channels | 540 -> 256 -> 128 -> 64 | 128 -> 256 -> 192 -> 128 | Proportional reduction |
|
||||
| Spatial blocks | 4 (stride 2) | 4 (stride 2) | Same |
|
||||
| Attention heads | 8 | 8 | Same |
|
||||
| Parameters | 4.82M | ~1.8M | Fewer input channels |
|
||||
| Input type | Amplitude only | Amplitude only | Same |
|
||||
| Output | 17 x 2 | 17 x 2 | Same |
|
||||
|
||||
### Parameter Budget Breakdown
|
||||
|
||||
| Stage | Parameters | % of Total |
|
||||
|-------|-----------|------------|
|
||||
| TCN (4 blocks, k=7, d=1,2,4,8) | ~969K | 54% |
|
||||
| Asymmetric Conv (4 blocks, 1x3, stride 2) | ~174K | 10% |
|
||||
| Axial Attention (width + height, 8 heads) | ~592K | 33% |
|
||||
| Pose Decoder (pool + linear -> 17x2) | ~70K | 4% |
|
||||
| **Total** | **~1.8M** | **100%** |
|
||||
|
||||
### Loss Function
|
||||
|
||||
```
|
||||
L = L_H + 0.2 * L_B
|
||||
|
||||
L_H = SmoothL1(predicted, target, beta=0.1)
|
||||
L_B = (1/14) * sum_b (bone_length_b - prior_b)^2
|
||||
```
|
||||
|
||||
14 bone connections enforce anatomical constraints:
|
||||
- Nose-eye (x2): 0.06
|
||||
- Eye-ear (x2): 0.06
|
||||
- Shoulder-elbow (x2): 0.15
|
||||
- Elbow-wrist (x2): 0.13
|
||||
- Shoulder-hip (x2): 0.26
|
||||
- Hip-knee (x2): 0.25
|
||||
- Knee-ankle (x2): 0.25
|
||||
- Shoulder width: 0.20
|
||||
|
||||
All lengths normalized to person height.
|
||||
|
||||
### Training Strategy (Camera-Free Pipeline)
|
||||
|
||||
Since we have no ground-truth pose labels from cameras, training proceeds in three phases:
|
||||
|
||||
#### Phase 1: Contrastive Pretraining
|
||||
- Temporal triplets: adjacent windows are positive pairs, distant windows are negative
|
||||
- Cross-node triplets: same-time windows from different ESP32 nodes are positive
|
||||
- Uses ruvllm `ContrastiveTrainer` with triplet + InfoNCE loss
|
||||
- Learns a representation where similar CSI states cluster together
|
||||
|
||||
#### Phase 2: Pose Proxy Training
|
||||
- Generate coarse pose proxies from vitals data:
|
||||
- Person detected (presence > 0.3): place standing skeleton at center
|
||||
- High motion: perturb limb positions proportional to motion energy
|
||||
- Breathing: add micro-oscillation to torso keypoints
|
||||
- Train with SmoothL1 + bone constraint loss
|
||||
- Confidence-weighted updates (higher presence = stronger gradient)
|
||||
|
||||
#### Phase 3: Self-Refinement (Future)
|
||||
- Multi-node consistency: same person seen from different nodes should produce
|
||||
consistent pose after geometric transform
|
||||
- Temporal smoothness: adjacent frames should produce similar poses
|
||||
- Bone constraint tightening: gradually reduce tolerance
|
||||
|
||||
### Integration with Existing Pipeline
|
||||
|
||||
```
|
||||
train-ruvllm.js (ADR-071) train-wiflow.js (ADR-072)
|
||||
| |
|
||||
| 8-dim features | 128-dim raw CSI amplitude
|
||||
| -> 128-dim embedding | -> 17x2 keypoint coordinates
|
||||
| -> presence/activity/vitals | -> bone-constrained pose
|
||||
| |
|
||||
+-- ContrastiveTrainer -----+------+
|
||||
+-- TrainingPipeline -------+------+
|
||||
+-- LoRA per-node ----------+------+
|
||||
+-- TurboQuant quantize ----+------+
|
||||
+-- SafeTensors export -----+------+
|
||||
```
|
||||
|
||||
Both pipelines share the ruvllm infrastructure; WiFlow adds the deeper architecture
|
||||
for direct pose regression while the simple encoder handles embedding tasks.
|
||||
|
||||
### Performance Targets
|
||||
|
||||
| Metric | Target | Notes |
|
||||
|--------|--------|-------|
|
||||
| PCK@20 | > 80% | On lab data with 2+ nodes |
|
||||
| Forward latency | < 50ms | Pi Zero 2W at INT8 |
|
||||
| Model size (INT8) | < 2 MB | TurboQuant |
|
||||
| Bone violation rate | < 10% | 50% tolerance |
|
||||
| Temporal jitter | < 3cm | Exponential smoothing |
|
||||
|
||||
### Risk Assessment
|
||||
|
||||
| Risk | Severity | Mitigation |
|
||||
|------|----------|------------|
|
||||
| Single TX/RX has less spatial info than 18 links | High | 2-node multi-static compensates; cross-node fusion from ADR-029 |
|
||||
| Camera-free labels are coarse | Medium | Bone constraints enforce anatomy; contrastive pretrain provides structure |
|
||||
| Pure JS too slow for real-time | Medium | INT8 quantization; axial attention is O(H^2W+HW^2) not O(H^2W^2) |
|
||||
| Overfitting with ~5K frames | Medium | Temporal augmentation + noise + cross-node interpolation |
|
||||
| Phase not available (amplitude-only) | Low | WiFlow was designed amplitude-only; not a limitation |
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive
|
||||
- Proven SOTA architecture adapted to our hardware constraints
|
||||
- Pure JavaScript implementation runs everywhere ruvllm runs (Node.js, browser WASM)
|
||||
- Bone constraints enforce physically plausible outputs even with noisy inputs
|
||||
- Shares training infrastructure with existing ruvllm pipeline
|
||||
- Modular: each stage (TCN, AsymConv, Axial, Decoder) is independently testable
|
||||
|
||||
### Negative
|
||||
- ~1.8M parameters is 193x larger than simple CsiEncoder (9,344 params)
|
||||
- Forward pass is slower (~50ms vs <1ms for simple encoder)
|
||||
- Camera-free training will produce lower accuracy than supervised WiFlow
|
||||
- No ground-truth PCK evaluation possible without camera labels
|
||||
- Axial attention is O(N^2) within each axis, limiting scalability
|
||||
|
||||
### Neutral
|
||||
- FLOPs dominated by TCN (~48%) due to dilated convolutions
|
||||
- INT8 quantization brings model to ~1.7MB, viable for edge deployment
|
||||
- Architecture is fixed (no NAS); future work could explore lighter variants
|
||||
|
||||
## Implementation
|
||||
|
||||
### Files Created
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `scripts/wiflow-model.js` | WiFlow architecture (all stages, loss, metrics) |
|
||||
| `scripts/train-wiflow.js` | Training pipeline (contrastive + pose proxy + LoRA + quant) |
|
||||
| `scripts/benchmark-wiflow.js` | Benchmarking (latency, params, FLOPs, memory, quality) |
|
||||
| `docs/adr/ADR-072-wiflow-architecture.md` | This document |
|
||||
|
||||
### Usage
|
||||
|
||||
```bash
|
||||
# Train on collected data
|
||||
node scripts/train-wiflow.js --data data/recordings/pretrain-*.csi.jsonl
|
||||
|
||||
# Train with more epochs and custom output
|
||||
node scripts/train-wiflow.js --data data/recordings/*.csi.jsonl --epochs 50 --output models/wiflow-v2
|
||||
|
||||
# Contrastive pretraining only (no labels needed)
|
||||
node scripts/train-wiflow.js --data data/recordings/*.csi.jsonl --contrastive-only
|
||||
|
||||
# Benchmark
|
||||
node scripts/benchmark-wiflow.js
|
||||
|
||||
# Benchmark with trained model
|
||||
node scripts/benchmark-wiflow.js --model models/wiflow-v1
|
||||
```
|
||||
|
||||
### Dependencies
|
||||
|
||||
- ruvllm (vendored at `vendor/ruvector/npm/packages/ruvllm/src/`)
|
||||
- `ContrastiveTrainer`, `tripletLoss`, `infoNCELoss`, `computeGradient`
|
||||
- `TrainingPipeline`
|
||||
- `LoraAdapter`, `LoraManager`
|
||||
- `EwcManager`
|
||||
- `ModelExporter`, `SafeTensorsWriter`
|
||||
- No external ML frameworks (no PyTorch, no TensorFlow, no ONNX Runtime)
|
||||
|
||||
## References
|
||||
|
||||
- WiFlow: arXiv:2602.08661
|
||||
- COCO Keypoints: https://cocodataset.org/#keypoints-2020
|
||||
- Axial Attention: Wang et al., "Axial-DeepLab", ECCV 2020
|
||||
- TCN: Bai et al., "An Empirical Evaluation of Generic Convolutional and Recurrent Networks for Sequence Modeling", 2018
|
||||
@@ -1,187 +0,0 @@
|
||||
# ADR-073: Multi-Frequency Mesh Scanning
|
||||
|
||||
| Field | Value |
|
||||
|-------------|--------------------------------------------|
|
||||
| **Status** | Proposed |
|
||||
| **Date** | 2026-04-02 |
|
||||
| **Authors** | ruv |
|
||||
| **Depends** | ADR-018 (binary frame), ADR-029 (channel hopping), ADR-039 (edge processing), ADR-060 (channel override) |
|
||||
|
||||
## Context
|
||||
|
||||
The current WiFi-DensePose deployment uses 2 ESP32-S3 nodes operating on a single WiFi channel (channel 5, 2432 MHz). A scan of the office environment reveals 9 WiFi networks across 6 distinct channels (1, 3, 5, 6, 9, 11), each broadcasting continuously. These neighbor networks are free RF illuminators whose signals pass through the room and interact with objects, people, and walls.
|
||||
|
||||
**Current single-channel limitations:**
|
||||
|
||||
1. **19% null subcarriers** — metal objects (desk, monitor frame, filing cabinet) create frequency-selective fading that blocks specific subcarriers on channel 5. These nulls are permanent blind spots in the RF map.
|
||||
|
||||
2. **No frequency diversity** — objects that are transparent at 2432 MHz may be opaque at 2412 MHz or 2462 MHz, and vice versa. A metal mesh that blocks one wavelength (122.5 mm at 2432 MHz) may pass another (124.0 mm at 2412 MHz) due to the mesh aperture-to-wavelength ratio.
|
||||
|
||||
3. **Single-perspective CSI** — both nodes see the same 52-64 subcarriers on the same channel. The subcarrier indices map to the same frequency bins, providing no spectral diversity.
|
||||
|
||||
4. **Neighbor illuminator waste** — 6 other APs broadcast continuously in the room. Their signals pass through walls, furniture, and people, creating CSI-measurable reflections that we currently ignore because we only listen on channel 5.
|
||||
|
||||
## Decision
|
||||
|
||||
Implement interleaved multi-frequency channel hopping across the 2 ESP32-S3 nodes, scanning 6 WiFi channels to build a wideband RF map of the room.
|
||||
|
||||
### Channel Allocation Strategy
|
||||
|
||||
The 2.4 GHz ISM band has 3 non-overlapping 20 MHz channels (1, 6, 11) and several partially-overlapping channels between them. We allocate channels to maximize both spectral coverage and illuminator exploitation:
|
||||
|
||||
```
|
||||
Node 1: ch 1, 6, 11 (non-overlapping, full band coverage)
|
||||
Node 2: ch 3, 5, 9 (interleaved, near neighbor APs)
|
||||
```
|
||||
|
||||
**Rationale for this split:**
|
||||
|
||||
| Channel | Freq (MHz) | Node | Neighbor Illuminators | Purpose |
|
||||
|---------|------------|------|----------------------------------------------|-----------------------------------|
|
||||
| 1 | 2412 | 1 | (none visible, but lower freq = better penetration) | Low-frequency penetration |
|
||||
| 3 | 2422 | 2 | conclusion mesh (signal 44) | Exploit neighbor AP as illuminator |
|
||||
| 5 | 2432 | 2 | ruv.net (100), Cohen-Guest (100), HP LaserJet (94) | Primary channel, strongest illuminators |
|
||||
| 6 | 2437 | 1 | Innanen (signal 19) | Center band, non-overlapping |
|
||||
| 9 | 2452 | 2 | NETGEAR72 (42), NETGEAR72-Guest (42) | Exploit dual NETGEAR illuminators |
|
||||
| 11 | 2462 | 1 | COGECO-21B20 (100), COGECO-4321 (30) | High-frequency, strong illuminators |
|
||||
|
||||
Each node dwells on a channel for 250 ms (configurable), collects 3-4 CSI frames, then hops to the next. The 3-channel rotation completes in 750 ms, giving ~1.3 full rotations per second.
|
||||
|
||||
### Physics Basis
|
||||
|
||||
At 2.4 GHz, WiFi wavelength ranges from 122.0 mm (ch 14, 2484 MHz) to 124.0 mm (ch 1, 2412 MHz). While this is a narrow range (~2%), the effect on multipath is significant:
|
||||
|
||||
1. **Frequency-selective fading**: multipath reflections create constructive/destructive interference patterns that vary with frequency. A 2 cm path length difference produces a null at 2432 MHz but constructive interference at 2412 MHz.
|
||||
|
||||
2. **Diffraction around objects**: Huygens-Fresnel diffraction depends on wavelength. Objects smaller than ~lambda/2 (61 mm) scatter differently across the band. Common office objects (monitor bezels, chair legs, cable bundles) are in this range.
|
||||
|
||||
3. **Material transparency**: some materials (wire mesh, perforated metal, PCB ground planes) have frequency-dependent transmission. A monitor's EMI shielding mesh with 5 mm apertures blocks 2.4 GHz signals but the exact attenuation varies with frequency due to slot antenna effects.
|
||||
|
||||
4. **Subcarrier orthogonality**: OFDM subcarriers on different channels are in different frequency bins. A null on subcarrier 15 of channel 5 does not imply a null on subcarrier 15 of channel 1, because they map to different absolute frequencies.
|
||||
|
||||
### Null Diversity Mechanism
|
||||
|
||||
```
|
||||
Channel 5 subcarriers: ▅▆█▇▅▃▁_▁▃▅▆█▇▅▃▁_▁▃▅▆█▇▅▃
|
||||
^ null (metal desk)
|
||||
Channel 1 subcarriers: ▃▅▆█▇▅▃▅▆█▇▅▃▅▆█▇▅▃▅▆█▇▅▃▅▃
|
||||
^ resolved! Different freq = different null pattern
|
||||
|
||||
Channel 11 subcarriers: ▅▃▁_▁▃▅▆█▇▅▃▅▆▅▃▁_▁▃▅▆█▇▅▃▅
|
||||
^ null here instead (shifted by frequency offset)
|
||||
```
|
||||
|
||||
By fusing subcarrier data across channels, nulls that exist on one channel are filled by non-null data from other channels. The remaining nulls (present on ALL channels) represent truly opaque objects — large metal surfaces that block all 2.4 GHz frequencies.
|
||||
|
||||
### Wideband View
|
||||
|
||||
Single channel: ~52-64 subcarriers (20 MHz bandwidth)
|
||||
Multi-channel (6 channels): ~312-384 effective subcarrier observations (120 MHz coverage)
|
||||
|
||||
This is not simply 6x the resolution (the subcarrier spacing within each channel is the same), but it provides:
|
||||
- 6x the spectral diversity for null mitigation
|
||||
- 6x the illuminator variety (different APs = different signal paths)
|
||||
- Frequency-dependent scattering signatures for material classification
|
||||
|
||||
## Integration
|
||||
|
||||
### Firmware (already supported)
|
||||
|
||||
The channel hopping infrastructure is already implemented in the ESP32 firmware (ADR-029):
|
||||
|
||||
```c
|
||||
// csi_collector.h — already exists
|
||||
void csi_collector_set_hop_table(const uint8_t *channels, uint8_t hop_count, uint32_t dwell_ms);
|
||||
void csi_collector_start_hop_timer(void);
|
||||
```
|
||||
|
||||
The ADR-018 binary frame header already includes the channel/frequency field at bytes [8..11], so the server-side parser can distinguish frames from different channels without any firmware changes.
|
||||
|
||||
### Provisioning Commands
|
||||
|
||||
```bash
|
||||
# Node 1 (COM7): non-overlapping channels 1, 6, 11
|
||||
python firmware/esp32-csi-node/provision.py --port COM7 \
|
||||
--ssid "ruv.net" --password "..." --target-ip 192.168.1.20 \
|
||||
--hop-channels 1,6,11 --hop-dwell-ms 250
|
||||
|
||||
# Node 2 (COM_): interleaved channels 3, 5, 9
|
||||
python firmware/esp32-csi-node/provision.py --port COM_ \
|
||||
--ssid "ruv.net" --password "..." --target-ip 192.168.1.20 \
|
||||
--hop-channels 3,5,9 --hop-dwell-ms 250
|
||||
```
|
||||
|
||||
Note: `--hop-channels` and `--hop-dwell-ms` require provision.py support for writing these values to NVS. If not yet implemented, the firmware's `csi_collector_set_hop_table()` can be called directly from the main init code with compile-time constants.
|
||||
|
||||
### Server-Side Processing
|
||||
|
||||
Three new Node.js scripts consume the multi-channel CSI data:
|
||||
|
||||
| Script | Purpose |
|
||||
|--------|---------|
|
||||
| `scripts/rf-scan.js` | Single-channel live RF room scanner with ASCII spectrum |
|
||||
| `scripts/rf-scan-multifreq.js` | Multi-channel scanner with null diversity analysis |
|
||||
| `scripts/benchmark-rf-scan.js` | Quantitative benchmark of multi-channel performance |
|
||||
|
||||
All scripts parse the ADR-018 binary UDP format and use the frequency field to separate frames by channel.
|
||||
|
||||
### Cognitum Seed Integration
|
||||
|
||||
The Cognitum Seed vector store (ADR-069) currently stores 1,605 vectors from single-channel CSI. With multi-frequency scanning:
|
||||
|
||||
1. **Per-channel feature vectors**: store separate 8-dim feature vectors for each channel, tagged with channel number. This increases the vector count to ~9,630 (6 channels x 1,605).
|
||||
|
||||
2. **Wideband feature vector**: concatenate or average per-channel features into a 48-dim wideband vector for richer kNN search. Objects that are ambiguous on one channel may be clearly distinguishable in the wideband representation.
|
||||
|
||||
3. **Null-aware embeddings**: encode null subcarrier patterns as part of the feature vector. The null pattern itself is informative — a consistent null at subcarrier 15 across all channels indicates a large metal object, while a null only on channel 5 indicates a frequency-dependent scatterer.
|
||||
|
||||
## Performance Targets
|
||||
|
||||
| Metric | Single-Channel Baseline | Multi-Channel Target | Method |
|
||||
|--------|------------------------|---------------------|--------|
|
||||
| Subcarrier count | ~52-64 | ~312-384 (6x) | 6 channels x 52-64 subcarriers |
|
||||
| Null gap | 19% | <5% | Null diversity across channels |
|
||||
| Position resolution | ~30 cm | ~15 cm | sqrt(6) improvement from independent observations |
|
||||
| Per-channel FPS | 12 fps | ~4 fps | 250 ms dwell x 3 channels = 750 ms rotation |
|
||||
| Total FPS (all channels) | 12 fps | ~12 fps per node (4 fps x 3 channels) |
|
||||
| Wideband rotation | N/A | ~1.3 Hz | Full 3-channel rotation in 750 ms |
|
||||
|
||||
## Risks
|
||||
|
||||
### Per-Channel Sample Rate Reduction
|
||||
|
||||
Channel hopping reduces the per-channel sample rate from 12 fps (single channel) to approximately 4 fps per channel (250 ms dwell, 3 channels). This affects:
|
||||
|
||||
- **Vitals extraction**: breathing rate (0.1-0.5 Hz) requires at least 2 fps (Nyquist). At 4 fps per channel, this is met. Heart rate (0.8-2.0 Hz) requires at least 4 fps, which is marginal. Mitigation: keep one channel as "primary" with longer dwell for vitals, or fuse phase data across channels.
|
||||
|
||||
- **Motion tracking**: 4 fps is sufficient for walking speed (<2 m/s) but insufficient for fast gestures. If gesture recognition is needed, reduce to 2-channel hopping or increase dwell rate.
|
||||
|
||||
### Channel Hopping Latency
|
||||
|
||||
`esp_wifi_set_channel()` takes ~1-5 ms on ESP32-S3. During the transition, no CSI frames are captured. At 250 ms dwell, this is <2% overhead.
|
||||
|
||||
### AP Disconnection
|
||||
|
||||
Channel hopping may cause the ESP32 to lose connection to the home AP (ruv.net on channel 5) when dwelling on other channels. The STA reconnects automatically, but there may be brief UDP packet loss. Mitigation: the firmware already handles this gracefully — CSI collection works in promiscuous mode regardless of STA connection state.
|
||||
|
||||
### Increased Server Load
|
||||
|
||||
2 nodes x 3 channels x 4 fps = 24 frames/second total UDP traffic. Each frame is ~150-200 bytes (20-byte header + 64 subcarriers x 2 bytes I/Q). Total: ~4.8 KB/s — negligible.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
1. **5 GHz channels**: ESP32-S3 supports 5 GHz CSI, and the shorter wavelength (60 mm) provides better spatial resolution. Rejected because: (a) no 5 GHz APs visible in the current environment, so no free illuminators; (b) 5 GHz has worse wall penetration, reducing the effective sensing volume.
|
||||
|
||||
2. **More nodes**: adding a 3rd or 4th ESP32 node would increase spatial diversity without channel hopping. Rejected for now due to cost, but this is complementary — more nodes + channel hopping would give both spatial and spectral diversity.
|
||||
|
||||
3. **Wider bandwidth (HT40)**: using 40 MHz channels doubles subcarrier count per channel. Rejected because: (a) HT40 requires a secondary channel, reducing available channels for hopping; (b) many neighbor APs use HT20, so their illumination only covers 20 MHz.
|
||||
|
||||
## References
|
||||
|
||||
- ADR-018: CSI binary frame format
|
||||
- ADR-029: Channel hopping infrastructure
|
||||
- ADR-039: Edge processing pipeline
|
||||
- ADR-060: Channel override provisioning
|
||||
- ADR-069: Cognitum Seed CSI pipeline
|
||||
- IEEE 802.11-2020, Section 21 (OFDM PHY)
|
||||
- ESP-IDF CSI Guide: https://docs.espressif.com/projects/esp-idf/en/v5.4/esp32s3/api-guides/wifi.html#wi-fi-channel-state-information
|
||||
@@ -1,336 +0,0 @@
|
||||
---
|
||||
license: mit
|
||||
tags:
|
||||
- wifi-sensing
|
||||
- pose-estimation
|
||||
- vital-signs
|
||||
- edge-ai
|
||||
- esp32
|
||||
- onnx
|
||||
- self-supervised
|
||||
- cognitum
|
||||
- csi
|
||||
- through-wall
|
||||
- privacy-preserving
|
||||
language:
|
||||
- en
|
||||
library_name: onnxruntime
|
||||
pipeline_tag: other
|
||||
---
|
||||
|
||||
# WiFi-DensePose: See Through Walls with WiFi + AI
|
||||
|
||||
**Detect people, track movement, and measure breathing -- through walls, without cameras, using a $27 sensor kit.**
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **License** | MIT |
|
||||
| **Framework** | ONNX Runtime |
|
||||
| **Hardware** | ESP32-S3 ($9) + optional Cognitum Seed ($15) |
|
||||
| **Training** | Self-supervised contrastive learning (no labels needed) |
|
||||
| **Privacy** | No cameras, no images, no personally identifiable data |
|
||||
|
||||
---
|
||||
|
||||
## What is this?
|
||||
|
||||
This model turns ordinary WiFi signals into a human sensing system. It can detect whether someone is in a room, count how many people are present, classify what they are doing, and even measure their breathing rate -- all without any cameras.
|
||||
|
||||
**How does it work?** Every WiFi router constantly sends signals that bounce off walls, furniture, and people. When a person moves -- or even just breathes -- those bouncing signals change in tiny but measurable ways. WiFi chips can capture these changes as numbers called *Channel State Information* (CSI). Think of it like ripples in a pond: drop a stone and the ripples tell you something happened, even if you cannot see the stone.
|
||||
|
||||
This model learned to read those "WiFi ripples" and figure out what is happening in the room. It was trained using a technique called *contrastive learning*, which means it taught itself by comparing thousands of WiFi signal snapshots -- no human had to manually label anything.
|
||||
|
||||
The result is a small, fast model that runs on a $9 microcontroller and preserves complete privacy because it never captures images or audio.
|
||||
|
||||
---
|
||||
|
||||
## What can it do?
|
||||
|
||||
| Capability | Accuracy | What you need | Notes |
|
||||
|---|---|---|---|
|
||||
| **Presence detection** | >95% | 1x ESP32-S3 ($9) | Is anyone in the room? |
|
||||
| **Motion classification** | >90% | 1x ESP32-S3 ($9) | Still, walking, exercising, fallen |
|
||||
| **Breathing rate** | +/- 2 BPM | 1x ESP32-S3 ($9) | Best when person is sitting or lying still |
|
||||
| **Heart rate estimate** | +/- 5 BPM | 1x ESP32-S3 ($9) | Experimental -- less accurate during movement |
|
||||
| **Person counting** | 1-4 people | 2x ESP32-S3 ($18) | Uses cross-node signal fusion |
|
||||
| **Pose estimation** | 17 COCO keypoints | 2x ESP32-S3 + Seed ($27) | Full skeleton: head, shoulders, elbows, etc. |
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Install
|
||||
|
||||
```bash
|
||||
pip install onnxruntime numpy
|
||||
```
|
||||
|
||||
### Run inference
|
||||
|
||||
```python
|
||||
import onnxruntime as ort
|
||||
import numpy as np
|
||||
|
||||
# Load the encoder model
|
||||
session = ort.InferenceSession("pretrained-encoder.onnx")
|
||||
|
||||
# Simulated 8-dim CSI feature vector from ESP32-S3
|
||||
# Dimensions: [amplitude_mean, amplitude_std, phase_slope, doppler_energy,
|
||||
# subcarrier_variance, temporal_stability, csi_ratio, spectral_entropy]
|
||||
features = np.array(
|
||||
[[0.45, 0.30, 0.69, 0.75, 0.50, 0.25, 0.00, 0.54]],
|
||||
dtype=np.float32,
|
||||
)
|
||||
|
||||
# Encode into 128-dim embedding
|
||||
result = session.run(None, {"input": features})
|
||||
embedding = result[0] # shape: (1, 128)
|
||||
print(f"Embedding shape: {embedding.shape}")
|
||||
print(f"First 8 values: {embedding[0][:8]}")
|
||||
```
|
||||
|
||||
### Run task heads
|
||||
|
||||
```python
|
||||
# Load the task heads model
|
||||
heads = ort.InferenceSession("pretrained-heads.onnx")
|
||||
|
||||
# Feed the embedding from the encoder
|
||||
predictions = heads.run(None, {"embedding": embedding})
|
||||
|
||||
presence_score = predictions[0] # 0.0 = empty, 1.0 = occupied
|
||||
person_count = predictions[1] # estimated count (float, round to int)
|
||||
activity_class = predictions[2] # [still, walking, exercise, fallen]
|
||||
vitals = predictions[3] # [breathing_bpm, heart_bpm]
|
||||
|
||||
print(f"Presence: {presence_score[0]:.2f}")
|
||||
print(f"People: {int(round(person_count[0]))}")
|
||||
print(f"Activity: {['still', 'walking', 'exercise', 'fallen'][activity_class.argmax()]}")
|
||||
print(f"Breathing: {vitals[0][0]:.1f} BPM")
|
||||
print(f"Heart: {vitals[0][1]:.1f} BPM")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Model Architecture
|
||||
|
||||
```
|
||||
+-- Presence (binary)
|
||||
|
|
||||
WiFi signals --> ESP32-S3 --> 8-dim features --> Encoder (TCN) --> 128-dim embedding --> Task Heads --+-- Person Count
|
||||
(CSI) (on-device) (~2.5M params) (~100K) |
|
||||
+-- Activity (4 classes)
|
||||
|
|
||||
+-- Vitals (BR + HR)
|
||||
```
|
||||
|
||||
### Encoder
|
||||
|
||||
- **Type:** Temporal Convolutional Network (TCN)
|
||||
- **Input:** 8-dimensional feature vector extracted from raw CSI
|
||||
- **Output:** 128-dimensional embedding
|
||||
- **Parameters:** ~2.5M
|
||||
- **Format:** ONNX (runs on any platform with ONNX Runtime)
|
||||
|
||||
### Task Heads
|
||||
|
||||
- **Type:** Small MLPs (multi-layer perceptrons), one per task
|
||||
- **Input:** 128-dim embedding from the encoder
|
||||
- **Output:** Task-specific predictions (presence, count, activity, vitals)
|
||||
- **Parameters:** ~100K total across all heads
|
||||
- **Format:** ONNX
|
||||
|
||||
### Feature extraction (runs on ESP32-S3)
|
||||
|
||||
The ESP32-S3 captures raw CSI frames at ~100 Hz and computes 8 summary features per window:
|
||||
|
||||
| Feature | Description |
|
||||
|---|---|
|
||||
| `amplitude_mean` | Average signal strength across subcarriers |
|
||||
| `amplitude_std` | Variation in signal strength (movement indicator) |
|
||||
| `phase_slope` | Rate of phase change across subcarriers |
|
||||
| `doppler_energy` | Energy in the Doppler spectrum (velocity indicator) |
|
||||
| `subcarrier_variance` | How much individual subcarriers differ |
|
||||
| `temporal_stability` | Consistency of signal over time (stillness indicator) |
|
||||
| `csi_ratio` | Ratio between antenna pairs (direction indicator) |
|
||||
| `spectral_entropy` | Randomness of the frequency spectrum |
|
||||
|
||||
---
|
||||
|
||||
## Training Data
|
||||
|
||||
### How it was trained
|
||||
|
||||
This model was trained using **self-supervised contrastive learning**, which means it learned entirely from unlabeled WiFi signals. No cameras, no manual annotations, and no privacy-invasive data collection were needed.
|
||||
|
||||
The training process works like this:
|
||||
|
||||
1. **Collect** raw CSI frames from ESP32-S3 nodes placed in a room
|
||||
2. **Extract** 8-dimensional feature vectors from sliding windows of CSI data
|
||||
3. **Contrast** -- the model learns that features from nearby time windows should produce similar embeddings, while features from different scenarios should produce different embeddings
|
||||
4. **Fine-tune** task heads using weak labels from environmental sensors (PIR motion, temperature, pressure) on the Cognitum Seed companion device
|
||||
|
||||
### Data provenance
|
||||
|
||||
- **Source:** Live CSI from 2x ESP32-S3 nodes (802.11n, HT40, 114 subcarriers)
|
||||
- **Volume:** ~360,000 CSI frames (~3,600 feature vectors) per collection run
|
||||
- **Environment:** Residential room, ~4x5 meters
|
||||
- **Ground truth:** Environmental sensors on Cognitum Seed (PIR, BME280, light)
|
||||
- **Attestation:** Every collection run produces a cryptographic witness chain (`collection-witness.json`) that proves data provenance and integrity
|
||||
|
||||
### Witness chain
|
||||
|
||||
The `collection-witness.json` file contains a chain of SHA-256 hashes linking every step from raw CSI capture through feature extraction to model training. This allows anyone to verify that the published model was trained on data collected by specific hardware at a specific time.
|
||||
|
||||
---
|
||||
|
||||
## Hardware Requirements
|
||||
|
||||
### Minimum: single-node sensing ($9)
|
||||
|
||||
| Component | What it does | Cost | Where to get it |
|
||||
|---|---|---|---|
|
||||
| ESP32-S3 (8MB flash) | Captures WiFi CSI + runs feature extraction | ~$9 | Amazon, AliExpress, Adafruit |
|
||||
| USB-C cable | Power + data | ~$3 | Any electronics store |
|
||||
|
||||
This gets you: presence detection, motion classification, breathing rate.
|
||||
|
||||
### Recommended: dual-node sensing ($18)
|
||||
|
||||
Add a second ESP32-S3 to enable cross-node signal fusion for better accuracy and person counting.
|
||||
|
||||
### Full setup: sensing + ground truth ($27)
|
||||
|
||||
| Component | What it does | Cost |
|
||||
|---|---|---|
|
||||
| 2x ESP32-S3 (8MB) | WiFi CSI sensing nodes | ~$18 |
|
||||
| Cognitum Seed (Pi Zero 2W) | Runs inference + collects ground truth | ~$15 |
|
||||
| USB-C cables (x3) | Power + data | ~$9 |
|
||||
| **Total** | | **~$27** |
|
||||
|
||||
The Cognitum Seed runs the ONNX models on-device, orchestrates the ESP32 nodes over USB serial, and provides environmental ground truth via its onboard PIR and BME280 sensors.
|
||||
|
||||
---
|
||||
|
||||
## Files in this repo
|
||||
|
||||
| File | Size | Description |
|
||||
|---|---|---|
|
||||
| `pretrained-encoder.onnx` | ~2 MB | Contrastive encoder (TCN backbone, 8-dim input, 128-dim output) |
|
||||
| `pretrained-heads.onnx` | ~100 KB | Task heads (presence, count, activity, vitals) |
|
||||
| `pretrained.rvf` | ~500 KB | RuVector format embeddings for advanced fusion pipelines |
|
||||
| `room-profiles.json` | ~10 KB | Environment calibration profiles (room geometry, baseline noise) |
|
||||
| `collection-witness.json` | ~5 KB | Cryptographic witness chain proving data provenance |
|
||||
| `config.json` | ~2 KB | Training configuration (hyperparameters, feature schema, versions) |
|
||||
| `README.md` | -- | This file |
|
||||
|
||||
### RuVector format (.rvf)
|
||||
|
||||
The `.rvf` file contains pre-computed embeddings in RuVector format, used by the RuView application for advanced multi-node fusion and cross-viewpoint pose estimation. You only need this if you are using the full RuView pipeline. For basic inference, the ONNX files are sufficient.
|
||||
|
||||
---
|
||||
|
||||
## How to use with RuView
|
||||
|
||||
[RuView](https://github.com/ruvnet/RuView) is the open-source application that ties everything together: firmware flashing, real-time sensing, and a browser-based dashboard.
|
||||
|
||||
### 1. Flash firmware to ESP32-S3
|
||||
|
||||
```bash
|
||||
git clone https://github.com/ruvnet/RuView.git
|
||||
cd RuView
|
||||
|
||||
# Flash firmware (requires ESP-IDF v5.4 or use pre-built binaries from Releases)
|
||||
# See the repo README for platform-specific instructions
|
||||
```
|
||||
|
||||
### 2. Download models
|
||||
|
||||
```bash
|
||||
pip install huggingface_hub
|
||||
huggingface-cli download ruvnet/wifi-densepose-pretrained --local-dir models/
|
||||
```
|
||||
|
||||
### 3. Run inference
|
||||
|
||||
```bash
|
||||
# Start the CSI bridge (connects ESP32 serial output to the inference pipeline)
|
||||
python scripts/seed_csi_bridge.py --port COM7 --model models/pretrained-encoder.onnx
|
||||
|
||||
# Or run the full sensing server with web dashboard
|
||||
cargo run -p wifi-densepose-sensing-server
|
||||
```
|
||||
|
||||
### 4. Adapt to your room
|
||||
|
||||
The model works best after a brief calibration period (~60 seconds of no movement) to learn the baseline signal characteristics of your specific room. The `room-profiles.json` file contains example profiles; the system will create one for your environment automatically.
|
||||
|
||||
---
|
||||
|
||||
## Limitations
|
||||
|
||||
Be honest about what this technology can and cannot do:
|
||||
|
||||
- **Room-specific.** The model needs a short calibration period in each new environment. A model calibrated in a living room will not work as well in a warehouse without re-adaptation.
|
||||
- **Single room only.** There is no cross-room tracking. Each room needs its own sensing node(s).
|
||||
- **Person count accuracy degrades above 4.** Counting works well for 1-3 people, becomes unreliable above 4 in a single room.
|
||||
- **Vitals require stillness.** Breathing and heart rate estimation work best when the person is sitting or lying down. Accuracy drops significantly during walking or exercise.
|
||||
- **Heart rate is experimental.** The +/- 5 BPM accuracy is a best-case figure. In practice, cardiac sensing via WiFi is still a research-stage capability.
|
||||
- **Wall materials matter.** Metal walls, concrete reinforced with rebar, or foil-backed insulation will significantly attenuate the signal and reduce range.
|
||||
- **WiFi interference.** Heavy WiFi traffic from other devices can add noise. The system works best on a dedicated or lightly-used WiFi channel.
|
||||
- **Not a medical device.** Vital sign estimates are for informational and research purposes only. Do not use them for medical decisions.
|
||||
|
||||
---
|
||||
|
||||
## Use Cases
|
||||
|
||||
- **Elder care:** Non-invasive fall detection and activity monitoring without cameras
|
||||
- **Smart home:** Presence-based lighting and HVAC control
|
||||
- **Security:** Occupancy detection through walls
|
||||
- **Sleep monitoring:** Breathing rate tracking overnight
|
||||
- **Research:** Low-cost human sensing for academic experiments
|
||||
- **Disaster response:** The MAT (Mass Casualty Assessment Tool) uses this model to detect survivors through rubble via WiFi signal reflections
|
||||
|
||||
---
|
||||
|
||||
## Ethical Considerations
|
||||
|
||||
WiFi sensing is a privacy-preserving alternative to cameras, but it still detects human presence and activity. Consider these points:
|
||||
|
||||
- **Consent:** Always inform people that WiFi sensing is active in a space.
|
||||
- **No biometric identification:** This model cannot identify *who* someone is -- only that someone is present and what they are doing.
|
||||
- **Data minimization:** Raw CSI data is processed on-device and only summary features or embeddings leave the sensor. No images, audio, or video are ever captured.
|
||||
- **Dual use:** Like any sensing technology, this can be misused for surveillance. We encourage transparent deployment and clear signage.
|
||||
|
||||
---
|
||||
|
||||
## Citation
|
||||
|
||||
If you use this model in your research, please cite:
|
||||
|
||||
```bibtex
|
||||
@software{wifi_densepose_2026,
|
||||
title = {WiFi-DensePose: Human Pose Estimation from WiFi Channel State Information},
|
||||
author = {ruvnet},
|
||||
year = {2026},
|
||||
url = {https://github.com/ruvnet/RuView},
|
||||
license = {MIT},
|
||||
note = {Self-supervised contrastive learning on ESP32-S3 CSI data}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
MIT License. See [LICENSE](https://github.com/ruvnet/RuView/blob/main/LICENSE) for details.
|
||||
|
||||
You are free to use, modify, and distribute this model for any purpose, including commercial applications.
|
||||
|
||||
---
|
||||
|
||||
## Links
|
||||
|
||||
- **GitHub:** [github.com/ruvnet/RuView](https://github.com/ruvnet/RuView)
|
||||
- **Hardware:** [ESP32-S3 DevKit](https://www.espressif.com/en/products/devkits) | [Cognitum Seed](https://cognitum.one)
|
||||
- **ONNX Runtime:** [onnxruntime.ai](https://onnxruntime.ai)
|
||||
@@ -1,917 +0,0 @@
|
||||
# ESP32 CSI to Cognitum Seed Pretraining Pipeline
|
||||
|
||||
A beginner-friendly tutorial for collecting WiFi CSI data with ESP32 nodes
|
||||
and building a pre-trained model using the Cognitum Seed edge intelligence appliance.
|
||||
|
||||
**Estimated time:** 1 hour (setup 20 min, data collection 30 min, verification 10 min)
|
||||
|
||||
**What you will build:** A self-supervised pretraining dataset stored on a
|
||||
Cognitum Seed, containing 8-dimensional feature vectors extracted from live
|
||||
WiFi Channel State Information. The Seed's RVF vector store, kNN search, and
|
||||
witness chain turn raw radio signals into a searchable, cryptographically
|
||||
attested knowledge base -- no cameras or manual labeling required.
|
||||
|
||||
**Who this is for:** Makers, embedded engineers, and ML practitioners who want
|
||||
to experiment with WiFi-based human sensing. No Rust knowledge is needed; the
|
||||
entire workflow uses Python and pre-built firmware binaries.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Prerequisites](#1-prerequisites)
|
||||
2. [Hardware Setup](#2-hardware-setup)
|
||||
3. [Running the Bridge](#3-running-the-bridge)
|
||||
4. [Data Collection Protocol](#4-data-collection-protocol)
|
||||
5. [Monitoring Progress](#5-monitoring-progress)
|
||||
6. [Understanding the Feature Vectors](#6-understanding-the-feature-vectors)
|
||||
7. [Using the Pre-trained Data](#7-using-the-pre-trained-data)
|
||||
8. [Troubleshooting](#8-troubleshooting)
|
||||
9. [Next Steps](#9-next-steps)
|
||||
|
||||
---
|
||||
|
||||
## 1. Prerequisites
|
||||
|
||||
### Hardware
|
||||
|
||||
| Item | Quantity | Approx. Cost | Notes |
|
||||
|------|----------|-------------|-------|
|
||||
| ESP32-S3 (8MB flash) | 2 | ~$9 each | Must be S3 variant -- original ESP32 and C3 are not supported (single-core, cannot run CSI DSP) |
|
||||
| Cognitum Seed (Pi Zero 2 W) | 1 | ~$15 | Available at [cognitum.one](https://cognitum.one) |
|
||||
| USB-C data cables | 3 | ~$3 each | Must be **data** cables, not charge-only |
|
||||
|
||||
**Total cost: ~$36**
|
||||
|
||||
### Software
|
||||
|
||||
Install these on your host laptop/desktop (Windows, macOS, or Linux):
|
||||
|
||||
```bash
|
||||
# Python 3.10 or later
|
||||
python --version
|
||||
# Expected: Python 3.10.x or later
|
||||
|
||||
# esptool for flashing firmware
|
||||
pip install esptool
|
||||
|
||||
# pyserial for serial monitoring (optional but useful)
|
||||
pip install pyserial
|
||||
```
|
||||
|
||||
> **Tip:** You do not need the Rust toolchain for this tutorial. The ESP32
|
||||
> firmware is distributed as pre-built binaries, and the bridge script is
|
||||
> pure Python.
|
||||
|
||||
### Firmware
|
||||
|
||||
Download the v0.5.4 firmware binaries from the GitHub releases page:
|
||||
|
||||
```
|
||||
esp32-csi-node.bin -- Main firmware (8MB flash)
|
||||
bootloader.bin -- Bootloader
|
||||
partition-table.bin -- Partition table
|
||||
ota_data_initial.bin -- OTA data
|
||||
```
|
||||
|
||||
### Network
|
||||
|
||||
All devices must be on the same WiFi network. You will need:
|
||||
|
||||
- Your WiFi SSID and password
|
||||
- Your host laptop's local IP address (e.g., `192.168.1.20`)
|
||||
|
||||
Find your host IP:
|
||||
|
||||
```bash
|
||||
# Windows
|
||||
ipconfig | findstr "IPv4"
|
||||
|
||||
# macOS / Linux
|
||||
ip addr show | grep "inet " | grep -v 127.0.0.1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Hardware Setup
|
||||
|
||||
### Physical Layout
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ Room │
|
||||
│ │
|
||||
│ [ESP32 #1] [ESP32 #2] │
|
||||
│ node_id=1 node_id=2 │
|
||||
│ on shelf on desk │
|
||||
│ ~1.5m high ~0.8m high │
|
||||
│ │
|
||||
│ 3-5 meters apart │
|
||||
│ │
|
||||
│ [Cognitum Seed] │
|
||||
│ on table, USB to laptop │
|
||||
│ │
|
||||
│ [Host Laptop] │
|
||||
│ running bridge script │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
> **Tip:** Place the two ESP32 nodes 3-5 meters apart at different heights.
|
||||
> This gives the multi-node pipeline spatial diversity, which improves the
|
||||
> quality of cross-viewpoint features.
|
||||
|
||||
### Step 2.1: Connect and Verify the Cognitum Seed
|
||||
|
||||
Plug the Cognitum Seed into your laptop using a USB **data** cable.
|
||||
|
||||
Wait 30-60 seconds for it to boot. Then verify connectivity:
|
||||
|
||||
```bash
|
||||
curl -sk https://169.254.42.1:8443/api/v1/status
|
||||
```
|
||||
|
||||
Expected output (abbreviated):
|
||||
|
||||
```json
|
||||
{
|
||||
"device_id": "ecaf97dd-fc90-4b0e-b0e7-e9f896b9fbb6",
|
||||
"total_vectors": 0,
|
||||
"epoch": 1,
|
||||
"dimension": 8,
|
||||
"uptime_secs": 45
|
||||
}
|
||||
```
|
||||
|
||||
> **Note:** The `-sk` flags tell curl to use HTTPS (`-s` silent, `-k` skip
|
||||
> TLS certificate verification). The Seed uses a self-signed certificate.
|
||||
|
||||
You can also open `https://169.254.42.1:8443/guide` in a browser (accept
|
||||
the self-signed certificate warning) to see the Seed's setup guide.
|
||||
|
||||
### Step 2.2: Pair the Seed
|
||||
|
||||
Pairing generates a bearer token that authorizes write access. Pairing can
|
||||
only be initiated from the USB interface (169.254.42.1), not from WiFi -- this
|
||||
is a security feature.
|
||||
|
||||
```bash
|
||||
curl -sk -X POST https://169.254.42.1:8443/api/v1/pair \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"client_name": "wifi-densepose-tutorial"}'
|
||||
```
|
||||
|
||||
Expected output:
|
||||
|
||||
```json
|
||||
{
|
||||
"token": "seed_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
||||
"expires": null,
|
||||
"permissions": ["read", "write", "admin"]
|
||||
}
|
||||
```
|
||||
|
||||
Save this token -- you will need it for every bridge command:
|
||||
|
||||
```bash
|
||||
export SEED_TOKEN="seed_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
||||
```
|
||||
|
||||
> **Warning:** Treat the token like a password. Do not commit it to git or
|
||||
> share it publicly.
|
||||
|
||||
### Step 2.3: Flash ESP32 #1
|
||||
|
||||
Connect the first ESP32-S3 to your laptop via USB. Identify its serial port:
|
||||
|
||||
```bash
|
||||
# Windows -- look for "Silicon Labs" or "CP210x" in Device Manager
|
||||
# or run:
|
||||
python -m serial.tools.list_ports
|
||||
|
||||
# macOS
|
||||
ls /dev/tty.usb*
|
||||
|
||||
# Linux
|
||||
ls /dev/ttyUSB* /dev/ttyACM*
|
||||
```
|
||||
|
||||
Flash the firmware (replace `COM9` with your port):
|
||||
|
||||
```bash
|
||||
esptool.py --chip esp32s3 --port COM9 --baud 460800 \
|
||||
write_flash \
|
||||
0x0 bootloader.bin \
|
||||
0x8000 partition-table.bin \
|
||||
0xd000 ota_data_initial.bin \
|
||||
0x10000 esp32-csi-node.bin
|
||||
```
|
||||
|
||||
Expected output (last lines):
|
||||
|
||||
```
|
||||
Writing at 0x000f4000... (100 %)
|
||||
Wrote 978432 bytes (...)
|
||||
Hash of data verified.
|
||||
Leaving...
|
||||
Hard resetting via RTS pin...
|
||||
```
|
||||
|
||||
### Step 2.4: Provision ESP32 #1
|
||||
|
||||
Tell the ESP32 which WiFi network to join and where to send data:
|
||||
|
||||
```bash
|
||||
python firmware/esp32-csi-node/provision.py \
|
||||
--port COM9 \
|
||||
--ssid "YourWiFi" \
|
||||
--password "YourPassword" \
|
||||
--target-ip 192.168.1.20 \
|
||||
--target-port 5006 \
|
||||
--node-id 1
|
||||
```
|
||||
|
||||
Replace:
|
||||
- `COM9` with your actual serial port
|
||||
- `YourWiFi` / `YourPassword` with your WiFi credentials
|
||||
- `192.168.1.20` with your host laptop's IP address
|
||||
|
||||
Expected output:
|
||||
|
||||
```
|
||||
Writing NVS partition (24576 bytes) at offset 0x9000...
|
||||
Provisioning complete. Reset the device to apply.
|
||||
```
|
||||
|
||||
> **Important:** The `--target-ip` is your **host laptop**, not the Seed.
|
||||
> The bridge script runs on your laptop and forwards vectors to the Seed
|
||||
> via HTTPS.
|
||||
|
||||
### Step 2.5: Verify ESP32 #1 Is Streaming
|
||||
|
||||
After provisioning, the ESP32 resets and begins streaming. Verify with a
|
||||
quick UDP listener:
|
||||
|
||||
```bash
|
||||
python -c "
|
||||
import socket, struct
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
sock.bind(('0.0.0.0', 5006))
|
||||
sock.settimeout(10)
|
||||
print('Listening on UDP 5006 for 10 seconds...')
|
||||
count = 0
|
||||
try:
|
||||
while True:
|
||||
data, addr = sock.recvfrom(2048)
|
||||
magic = struct.unpack_from('<I', data)[0]
|
||||
names = {0xC5110001: 'CSI_RAW', 0xC5110002: 'VITALS', 0xC5110003: 'FEATURES'}
|
||||
name = names.get(magic, f'UNKNOWN(0x{magic:08X})')
|
||||
count += 1
|
||||
if count <= 5:
|
||||
print(f' Packet {count}: {name} from {addr[0]} ({len(data)} bytes)')
|
||||
except socket.timeout:
|
||||
pass
|
||||
sock.close()
|
||||
print(f'Received {count} packets total')
|
||||
"
|
||||
```
|
||||
|
||||
Expected output:
|
||||
|
||||
```
|
||||
Listening on UDP 5006 for 10 seconds...
|
||||
Packet 1: VITALS from 192.168.1.105 (32 bytes)
|
||||
Packet 2: FEATURES from 192.168.1.105 (48 bytes)
|
||||
Packet 3: VITALS from 192.168.1.105 (32 bytes)
|
||||
Packet 4: FEATURES from 192.168.1.105 (48 bytes)
|
||||
Packet 5: VITALS from 192.168.1.105 (32 bytes)
|
||||
Received 20 packets total
|
||||
```
|
||||
|
||||
If you see 0 packets, check the [Troubleshooting](#8-troubleshooting) section.
|
||||
|
||||
### Step 2.6: Flash and Provision ESP32 #2
|
||||
|
||||
Repeat steps 2.3-2.5 for the second ESP32, using `--node-id 2`:
|
||||
|
||||
```bash
|
||||
# Flash (replace COM8 with your port)
|
||||
esptool.py --chip esp32s3 --port COM8 --baud 460800 \
|
||||
write_flash \
|
||||
0x0 bootloader.bin \
|
||||
0x8000 partition-table.bin \
|
||||
0xd000 ota_data_initial.bin \
|
||||
0x10000 esp32-csi-node.bin
|
||||
|
||||
# Provision
|
||||
python firmware/esp32-csi-node/provision.py \
|
||||
--port COM8 \
|
||||
--ssid "YourWiFi" \
|
||||
--password "YourPassword" \
|
||||
--target-ip 192.168.1.20 \
|
||||
--target-port 5006 \
|
||||
--node-id 2
|
||||
```
|
||||
|
||||
### Step 2.7: Verify Both Nodes
|
||||
|
||||
Run the UDP listener again. You should see packets from two different IPs:
|
||||
|
||||
```
|
||||
Packet 1: FEATURES from 192.168.1.105 (48 bytes) <-- node 1
|
||||
Packet 2: FEATURES from 192.168.1.104 (48 bytes) <-- node 2
|
||||
Packet 3: VITALS from 192.168.1.105 (32 bytes)
|
||||
Packet 4: VITALS from 192.168.1.104 (32 bytes)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Running the Bridge
|
||||
|
||||
The bridge script (`scripts/seed_csi_bridge.py`) listens for UDP packets
|
||||
from the ESP32 nodes, batches them, and ingests them into the Seed's RVF
|
||||
vector store via HTTPS.
|
||||
|
||||
### Basic Start
|
||||
|
||||
```bash
|
||||
python scripts/seed_csi_bridge.py \
|
||||
--seed-url https://169.254.42.1:8443 \
|
||||
--token "$SEED_TOKEN" \
|
||||
--udp-port 5006 \
|
||||
--batch-size 10
|
||||
```
|
||||
|
||||
Expected output:
|
||||
|
||||
```
|
||||
12:00:01 [INFO] Connected to Seed ecaf97dd — 0 vectors, epoch 1, dim 8
|
||||
12:00:01 [INFO] Listening on UDP port 5006 (batch size: 10, flush interval: 10s)
|
||||
12:00:11 [INFO] Ingested 10 vectors (epoch=2, witness=a3b7c9d2e4f6...)
|
||||
12:00:21 [INFO] Ingested 10 vectors (epoch=3, witness=f1e2d3c4b5a6...)
|
||||
```
|
||||
|
||||
### Bridge Flags Explained
|
||||
|
||||
| Flag | Default | Description |
|
||||
|------|---------|-------------|
|
||||
| `--seed-url` | `https://169.254.42.1:8443` | Seed HTTPS endpoint (USB link-local) |
|
||||
| `--token` | `$SEED_TOKEN` env var | Bearer token from pairing step |
|
||||
| `--udp-port` | `5006` | UDP port to listen for ESP32 packets |
|
||||
| `--batch-size` | `10` | Number of vectors per ingest call |
|
||||
| `--flush-interval` | `10` | Maximum seconds between flushes (time-based batching) |
|
||||
| `--validate` | off | After each batch, run kNN query + PIR comparison |
|
||||
| `--stats` | off | Print Seed stats and exit (no bridge loop) |
|
||||
| `--compact` | off | Trigger store compaction and exit |
|
||||
| `--allowed-sources` | none | Comma-separated IPs to accept (anti-spoofing) |
|
||||
| `-v` / `--verbose` | off | Log every received packet |
|
||||
|
||||
### Recommended: Validation Mode
|
||||
|
||||
For your first data collection, enable `--validate` so the bridge verifies
|
||||
each batch against the Seed's kNN index:
|
||||
|
||||
```bash
|
||||
python scripts/seed_csi_bridge.py \
|
||||
--seed-url https://169.254.42.1:8443 \
|
||||
--token "$SEED_TOKEN" \
|
||||
--udp-port 5006 \
|
||||
--batch-size 10 \
|
||||
--validate
|
||||
```
|
||||
|
||||
With validation enabled, you will see additional output after each batch:
|
||||
|
||||
```
|
||||
12:00:11 [INFO] Ingested 10 vectors (epoch=2, witness=a3b7c9d2...)
|
||||
12:00:11 [INFO] Validation: kNN distance=0.000000 (exact match)
|
||||
12:00:11 [INFO] PIR=LOW CSI_presence=0.14 (absent) -- agreement 100.0% (1/1)
|
||||
```
|
||||
|
||||
### Recommended: Source IP Filtering
|
||||
|
||||
If you are on a shared network, restrict the bridge to only accept packets
|
||||
from your ESP32 nodes:
|
||||
|
||||
```bash
|
||||
python scripts/seed_csi_bridge.py \
|
||||
--token "$SEED_TOKEN" \
|
||||
--udp-port 5006 \
|
||||
--batch-size 10 \
|
||||
--allowed-sources "192.168.1.104,192.168.1.105"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Data Collection Protocol
|
||||
|
||||
Collect 6 scenarios, 5 minutes each, for a total of 30 minutes of data.
|
||||
With 2 nodes at 1 Hz each, each scenario produces ~600 feature vectors.
|
||||
|
||||
> **Before you begin:** Make sure the bridge is running (Section 3). Leave
|
||||
> the terminal open and start a new terminal for the commands below.
|
||||
|
||||
### Scenario 1: Empty Room (5 min)
|
||||
|
||||
This establishes the baseline -- what the room looks like with no one in it.
|
||||
|
||||
```bash
|
||||
echo "=== SCENARIO 1: EMPTY ROOM ==="
|
||||
echo "Leave the room now. Data collection starts in 10 seconds."
|
||||
sleep 10
|
||||
echo "Recording for 5 minutes... ($(date))"
|
||||
sleep 300
|
||||
echo "Done. You may re-enter the room."
|
||||
```
|
||||
|
||||
**What to do:** Leave the room. Close the door if possible. Stay out for
|
||||
the full 5 minutes.
|
||||
|
||||
### Scenario 2: One Person Stationary (5 min)
|
||||
|
||||
```bash
|
||||
echo "=== SCENARIO 2: 1 PERSON STATIONARY ==="
|
||||
echo "Sit at a desk or chair. Stay still. Breathe normally."
|
||||
sleep 300
|
||||
echo "Done."
|
||||
```
|
||||
|
||||
**What to do:** Sit at a desk roughly between the two ESP32 nodes. Stay
|
||||
still. Breathe normally. Do not use your phone (arm movement adds noise).
|
||||
|
||||
### Scenario 3: One Person Walking (5 min)
|
||||
|
||||
```bash
|
||||
echo "=== SCENARIO 3: 1 PERSON WALKING ==="
|
||||
echo "Walk around the room at a normal pace."
|
||||
sleep 300
|
||||
echo "Done."
|
||||
```
|
||||
|
||||
**What to do:** Walk around the room in varied paths. Go near each ESP32
|
||||
node at least once. Walk at a normal pace -- not too fast, not too slow.
|
||||
|
||||
### Scenario 4: One Person Varied Activity (5 min)
|
||||
|
||||
```bash
|
||||
echo "=== SCENARIO 4: 1 PERSON VARIED ==="
|
||||
echo "Move around: stand, sit, wave arms, turn in place."
|
||||
sleep 300
|
||||
echo "Done."
|
||||
```
|
||||
|
||||
**What to do:** Mix activities. Stand up, sit down, wave your arms, turn
|
||||
around, reach for a shelf, crouch down. The goal is to capture a variety of
|
||||
body positions and motions.
|
||||
|
||||
### Scenario 5: Two People (5 min)
|
||||
|
||||
```bash
|
||||
echo "=== SCENARIO 5: TWO PEOPLE ==="
|
||||
echo "Two people in the room, both moving around."
|
||||
sleep 300
|
||||
echo "Done."
|
||||
```
|
||||
|
||||
**What to do:** Have a second person enter the room. Both people should
|
||||
move around naturally -- walking, sitting, standing at different positions.
|
||||
|
||||
### Scenario 6: Transitions (5 min)
|
||||
|
||||
```bash
|
||||
echo "=== SCENARIO 6: TRANSITIONS ==="
|
||||
echo "Enter and exit the room repeatedly."
|
||||
sleep 300
|
||||
echo "Done."
|
||||
```
|
||||
|
||||
**What to do:** Walk in and out of the room several times. Pause for
|
||||
30-60 seconds inside, then leave for 30-60 seconds. This teaches the model
|
||||
what state transitions look like.
|
||||
|
||||
### Expected Data Volume
|
||||
|
||||
After all 6 scenarios:
|
||||
|
||||
| Metric | Expected |
|
||||
|--------|----------|
|
||||
| Total time | 30 minutes |
|
||||
| Vectors per node | ~1,800 |
|
||||
| Total vectors (2 nodes) | ~3,600 |
|
||||
| RVF store size | ~150 KB |
|
||||
| Witness chain entries | ~360+ |
|
||||
|
||||
---
|
||||
|
||||
## 5. Monitoring Progress
|
||||
|
||||
### Check Seed Stats
|
||||
|
||||
At any time, open a new terminal and run:
|
||||
|
||||
```bash
|
||||
python scripts/seed_csi_bridge.py --token "$SEED_TOKEN" --stats
|
||||
```
|
||||
|
||||
Expected output (after completing all 6 scenarios):
|
||||
|
||||
```
|
||||
=== Seed Status ===
|
||||
Device ID: ecaf97dd-fc90-4b0e-b0e7-e9f896b9fbb6
|
||||
Total vectors: 3612
|
||||
Epoch: 362
|
||||
Dimension: 8
|
||||
Uptime: 3845s
|
||||
|
||||
=== Witness Chain ===
|
||||
Valid: True
|
||||
Chain length: 1747
|
||||
Head: a3b7c9d2e4f6g8h1i2j3k4l5m6n7...
|
||||
|
||||
=== Boundary Analysis ===
|
||||
Fragility score: 0.42
|
||||
Boundary count: 6
|
||||
|
||||
=== Coherence Profile ===
|
||||
phase_count: 6
|
||||
current_phase: 5
|
||||
coherence: 0.87
|
||||
|
||||
=== kNN Graph Stats ===
|
||||
nodes: 3612
|
||||
edges: 18060
|
||||
avg_degree: 5.0
|
||||
```
|
||||
|
||||
> **What to look for:**
|
||||
> - `Total vectors` should grow by ~2 per second (1 per node per second)
|
||||
> - `Valid: True` on the witness chain means no data tampering
|
||||
> - `Fragility score` rises during transitions and drops during stable
|
||||
> scenarios -- this is normal and expected
|
||||
> - `phase_count` should roughly correspond to the number of distinct
|
||||
> scenarios the Seed has observed
|
||||
|
||||
### Verify kNN Quality
|
||||
|
||||
Query the Seed for the 5 nearest neighbors to a "someone present" vector:
|
||||
|
||||
```bash
|
||||
curl -sk -X POST https://169.254.42.1:8443/api/v1/store/query \
|
||||
-H "Authorization: Bearer $SEED_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"vector": [0.8, 0.5, 0.5, 0.6, 0.5, 0.25, 0.0, 0.6], "k": 5}'
|
||||
```
|
||||
|
||||
Expected output:
|
||||
|
||||
```json
|
||||
{
|
||||
"results": [
|
||||
{"id": 2847193655, "distance": 0.023},
|
||||
{"id": 1038476291, "distance": 0.031},
|
||||
{"id": 3719284651, "distance": 0.045},
|
||||
{"id": 928374651, "distance": 0.052},
|
||||
{"id": 1847293746, "distance": 0.068}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Low distances (< 0.1) indicate the query vector is similar to stored
|
||||
vectors -- the store contains meaningful data.
|
||||
|
||||
### Verify Witness Chain
|
||||
|
||||
The witness chain is a SHA-256 hash chain that proves no vectors were
|
||||
tampered with after ingestion:
|
||||
|
||||
```bash
|
||||
curl -sk -X POST https://169.254.42.1:8443/api/v1/witness/verify \
|
||||
-H "Authorization: Bearer $SEED_TOKEN"
|
||||
```
|
||||
|
||||
Expected output:
|
||||
|
||||
```json
|
||||
{
|
||||
"valid": true,
|
||||
"chain_length": 1747,
|
||||
"head": "a3b7c9d2e4f6..."
|
||||
}
|
||||
```
|
||||
|
||||
> **Warning:** If `valid` is `false`, the witness chain has been broken.
|
||||
> This means data was modified outside the normal ingest path. Discard
|
||||
> the dataset and re-collect.
|
||||
|
||||
---
|
||||
|
||||
## 6. Understanding the Feature Vectors
|
||||
|
||||
Each ESP32 node extracts an 8-dimensional feature vector once per second
|
||||
from the 100 Hz CSI processing pipeline. Every dimension is normalized to
|
||||
the range 0.0 to 1.0.
|
||||
|
||||
### Feature Dimension Table
|
||||
|
||||
| Dim | Name | Raw Source | Normalization | Range | Example Values |
|
||||
|-----|------|-----------|---------------|-------|----------------|
|
||||
| 0 | Presence score | `presence_score` | `/ 15.0`, clamped | 0.0 -- 1.0 | Empty: 0.01-0.05, Occupied: 0.19-1.0 |
|
||||
| 1 | Motion energy | `motion_energy` | `/ 10.0`, clamped | 0.0 -- 1.0 | Still: 0.05-0.15, Walking: 0.3-0.8 |
|
||||
| 2 | Breathing rate | `breathing_bpm` | `/ 30.0`, clamped | 0.0 -- 1.0 | Normal: 0.5-0.8 (15-24 BPM), At rest: 0.67-1.0 (20-34 BPM observed) |
|
||||
| 3 | Heart rate | `heartrate_bpm` | `/ 120.0`, clamped | 0.0 -- 1.0 | Resting: 0.50-0.67 (60-80 BPM), Active: 0.63-0.83 (75-99 BPM observed) |
|
||||
| 4 | Phase variance | Welford variance | Mean of top-K subcarriers | 0.0 -- 1.0 | Stable: 0.1-0.3, Disturbed: 0.5-0.9 |
|
||||
| 5 | Person count | `n_persons / 4.0` | Clamped to [0, 1] | 0.0 -- 1.0 | 0 people: 0.0, 1 person: 0.25, 2 people: 0.5 |
|
||||
| 6 | Fall detected | Binary flag | 1.0 if fall, else 0.0 | 0.0 or 1.0 | Normal: 0.0, Fall event: 1.0 |
|
||||
| 7 | RSSI | `(rssi + 100) / 100` | Clamped to [0, 1] | 0.0 -- 1.0 | Close: 0.57-0.66 (-43 to -34 dBm), Far: 0.28-0.40 (-72 to -60 dBm) |
|
||||
|
||||
### How to Read a Feature Vector
|
||||
|
||||
Example vector from live validation:
|
||||
|
||||
```
|
||||
[0.99, 0.47, 0.67, 0.63, 0.50, 0.25, 0.00, 0.57]
|
||||
```
|
||||
|
||||
Reading this:
|
||||
|
||||
- **0.99** (dim 0, presence) -- Strong presence detected
|
||||
- **0.47** (dim 1, motion) -- Moderate motion (slow walking or fidgeting)
|
||||
- **0.67** (dim 2, breathing) -- 20.1 BPM (0.67 x 30), normal at-rest breathing
|
||||
- **0.63** (dim 3, heart rate) -- 75.6 BPM (0.63 x 120), normal resting heart rate
|
||||
- **0.50** (dim 4, phase variance) -- Placeholder (future use)
|
||||
- **0.25** (dim 5, person count) -- 1 person (0.25 x 4 = 1)
|
||||
- **0.00** (dim 6, fall) -- No fall detected
|
||||
- **0.57** (dim 7, RSSI) -- RSSI of -43 dBm ((0.57 x 100) - 100), strong signal
|
||||
|
||||
### Packet Format
|
||||
|
||||
The feature vector is transmitted as a 48-byte binary packet with magic
|
||||
number `0xC5110003`:
|
||||
|
||||
```
|
||||
Offset Size Type Field
|
||||
------ ---- ------- ----------------
|
||||
0 4 uint32 magic (0xC5110003)
|
||||
4 1 uint8 node_id
|
||||
5 1 uint8 reserved
|
||||
6 2 uint16 sequence number
|
||||
8 8 int64 timestamp (microseconds since boot)
|
||||
16 32 float[8] feature vector (8 x 4 bytes)
|
||||
------ ----
|
||||
Total: 48 bytes
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Using the Pre-trained Data
|
||||
|
||||
After collecting 30 minutes of data, the Seed holds ~3,600 feature vectors
|
||||
organized as a kNN graph with witness chain attestation.
|
||||
|
||||
### Query for Similar States
|
||||
|
||||
Find vectors similar to "one person sitting quietly":
|
||||
|
||||
```bash
|
||||
curl -sk -X POST https://169.254.42.1:8443/api/v1/store/query \
|
||||
-H "Authorization: Bearer $SEED_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"vector": [0.8, 0.1, 0.6, 0.6, 0.5, 0.25, 0.0, 0.5], "k": 10}'
|
||||
```
|
||||
|
||||
Find vectors similar to "empty room":
|
||||
|
||||
```bash
|
||||
curl -sk -X POST https://169.254.42.1:8443/api/v1/store/query \
|
||||
-H "Authorization: Bearer $SEED_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"vector": [0.05, 0.02, 0.0, 0.0, 0.3, 0.0, 0.0, 0.5], "k": 10}'
|
||||
```
|
||||
|
||||
### Environment Fingerprinting
|
||||
|
||||
The Seed's boundary analysis detects regime changes in the vector space.
|
||||
When someone enters or leaves the room, the fragility score spikes:
|
||||
|
||||
```bash
|
||||
curl -sk https://169.254.42.1:8443/api/v1/boundary
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"fragility_score": 0.42,
|
||||
"boundary_count": 6
|
||||
}
|
||||
```
|
||||
|
||||
A `fragility_score` above 0.3 indicates the environment is in or near a
|
||||
transition state. The `boundary_count` roughly corresponds to the number
|
||||
of distinct "states" (scenarios) the Seed has observed.
|
||||
|
||||
### Export Vectors
|
||||
|
||||
To export all vectors for offline analysis or training:
|
||||
|
||||
```bash
|
||||
curl -sk https://169.254.42.1:8443/api/v1/store/export \
|
||||
-H "Authorization: Bearer $SEED_TOKEN" \
|
||||
-o pretrain-vectors.rvf
|
||||
```
|
||||
|
||||
The exported `.rvf` file contains the raw vector data and can be loaded
|
||||
by the Rust training pipeline (`wifi-densepose-train` crate) or converted
|
||||
to NumPy arrays for Python-based training.
|
||||
|
||||
### Compact the Store
|
||||
|
||||
For long-running deployments, run compaction daily to keep the store
|
||||
within the Seed's memory budget:
|
||||
|
||||
```bash
|
||||
python scripts/seed_csi_bridge.py --token "$SEED_TOKEN" --compact
|
||||
```
|
||||
|
||||
```
|
||||
Triggering store compaction...
|
||||
Compaction result: {
|
||||
"vectors_before": 3612,
|
||||
"vectors_after": 3200,
|
||||
"bytes_freed": 16544
|
||||
}
|
||||
```
|
||||
|
||||
### Use with the Sensing Server
|
||||
|
||||
Start a recording session to capture the raw CSI frames alongside the
|
||||
feature vectors (the sensing-server provides the recording API):
|
||||
|
||||
```bash
|
||||
# Start the recording (5 minutes)
|
||||
curl -X POST http://localhost:3000/api/v1/recording/start \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"session_name":"pretrain-1p-still","label":"1p-still","duration_secs":300}'
|
||||
```
|
||||
|
||||
The recording saves `.csi.jsonl` files that the `wifi-densepose-train`
|
||||
crate can load for full contrastive pretraining (see ADR-070).
|
||||
|
||||
---
|
||||
|
||||
## 8. Troubleshooting
|
||||
|
||||
### ESP32 Won't Connect to WiFi
|
||||
|
||||
**Symptoms:** No packets received, ESP32 serial output shows repeated
|
||||
"WiFi: Connecting..." messages.
|
||||
|
||||
**Fixes:**
|
||||
1. Verify SSID and password are correct (re-provision if needed)
|
||||
2. Make sure you are on a 2.4 GHz network (ESP32 does not support 5 GHz)
|
||||
3. Move the ESP32 closer to the access point
|
||||
4. Check the serial output for the exact error:
|
||||
|
||||
```bash
|
||||
python -m serial.tools.miniterm COM9 115200
|
||||
```
|
||||
|
||||
Look for lines like `wifi:connected` or `wifi:reason 201` (wrong password).
|
||||
|
||||
### Bridge Shows 0 Packets
|
||||
|
||||
**Symptoms:** Bridge starts but never logs "Ingested" messages.
|
||||
|
||||
**Fixes:**
|
||||
1. Make sure the ESP32's `--target-ip` matches your laptop's IP
|
||||
2. Check that `--target-port` matches `--udp-port` on the bridge (default: 5006)
|
||||
3. Check your firewall -- UDP port 5006 must be open for inbound traffic
|
||||
4. Run the UDP listener test from Section 2.5 to confirm raw packets arrive
|
||||
5. If using `--allowed-sources`, make sure the ESP32 IP addresses are listed
|
||||
|
||||
### Seed Returns 401 Unauthorized
|
||||
|
||||
**Symptoms:** Bridge logs `HTTP Error 401` on ingest.
|
||||
|
||||
**Fixes:**
|
||||
1. Make sure `$SEED_TOKEN` is set correctly: `echo $SEED_TOKEN`
|
||||
2. Re-pair the Seed if the token was lost (Section 2.2)
|
||||
3. Verify the token works with a status query:
|
||||
|
||||
```bash
|
||||
curl -sk -H "Authorization: Bearer $SEED_TOKEN" \
|
||||
https://169.254.42.1:8443/api/v1/store/graph/stats
|
||||
```
|
||||
|
||||
### NaN Values in Features
|
||||
|
||||
**Symptoms:** Bridge logs `Dropping feature packet: features[X]=nan (NaN/inf)`.
|
||||
|
||||
**Fixes:**
|
||||
- This is expected during the first few seconds after ESP32 boot while the
|
||||
DSP pipeline initializes. The bridge automatically drops NaN/inf packets.
|
||||
- If NaN persists beyond 10 seconds, reflash the firmware -- the DSP state
|
||||
may be corrupted.
|
||||
|
||||
### ENOMEM on ESP32 Boot
|
||||
|
||||
**Symptoms:** Serial output shows `E (xxx) heap: alloc failed` or
|
||||
`ENOMEM` errors.
|
||||
|
||||
**Fixes:**
|
||||
1. If using a 4MB flash ESP32-S3, use the 4MB partition table and
|
||||
sdkconfig (see `sdkconfig.defaults.4mb`)
|
||||
2. Reduce buffer sizes by setting edge tier to 1 during provisioning:
|
||||
|
||||
```bash
|
||||
python firmware/esp32-csi-node/provision.py \
|
||||
--port COM9 --edge-tier 1 \
|
||||
--ssid "YourWiFi" --password "YourPassword" \
|
||||
--target-ip 192.168.1.20 --node-id 1
|
||||
```
|
||||
|
||||
### Seed Not Reachable at 169.254.42.1
|
||||
|
||||
**Symptoms:** `curl` to `169.254.42.1:8443` times out.
|
||||
|
||||
**Fixes:**
|
||||
1. Ensure you are using a **data** USB cable (charge-only cables lack data pins)
|
||||
2. Wait 60 seconds after plugging in for the Seed to fully boot
|
||||
3. Check the USB network interface appeared on your host:
|
||||
|
||||
```bash
|
||||
# Windows
|
||||
ipconfig | findstr "169.254"
|
||||
|
||||
# macOS / Linux
|
||||
ip addr show | grep "169.254"
|
||||
```
|
||||
|
||||
4. If the Seed is on WiFi instead, use its WiFi IP (e.g., `192.168.1.109`):
|
||||
|
||||
```bash
|
||||
python scripts/seed_csi_bridge.py \
|
||||
--seed-url https://192.168.1.109:8443 \
|
||||
--token "$SEED_TOKEN"
|
||||
```
|
||||
|
||||
### Bridge Ingest Failures (Connection Reset)
|
||||
|
||||
**Symptoms:** Periodic `Ingest failed` messages, then recovery.
|
||||
|
||||
**Fixes:**
|
||||
- The bridge retries once automatically (2-second delay). Occasional failures
|
||||
are normal when the Seed is rebuilding its kNN graph.
|
||||
- If failures are frequent (>10% of batches), increase `--batch-size` to
|
||||
reduce the number of HTTPS calls:
|
||||
|
||||
```bash
|
||||
python scripts/seed_csi_bridge.py --token "$SEED_TOKEN" --batch-size 20
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Next Steps
|
||||
|
||||
### Full Contrastive Pretraining (ADR-070)
|
||||
|
||||
This tutorial covers Phase 1 (data collection) of the pretraining pipeline
|
||||
defined in [ADR-070](../adr/ADR-070-self-supervised-pretraining.md). The
|
||||
remaining phases are:
|
||||
|
||||
- **Phase 2: Contrastive pretraining** -- Train a TCN encoder using temporal
|
||||
coherence and multi-node consistency as self-supervised signals
|
||||
- **Phase 3: Downstream heads** -- Attach task-specific heads (presence,
|
||||
person count, activity, vital signs) using weak labels from the Seed's
|
||||
PIR sensor and scenario boundaries
|
||||
- **Phase 4: Package and distribute** -- Export as ONNX model weights for
|
||||
distribution in GitHub releases
|
||||
|
||||
### Architecture Documentation
|
||||
|
||||
- [ADR-069: ESP32 CSI to Cognitum Seed Pipeline](../adr/ADR-069-cognitum-seed-csi-pipeline.md) --
|
||||
Full architecture of the bridge pipeline
|
||||
- [ADR-070: Self-Supervised Pretraining](../adr/ADR-070-self-supervised-pretraining.md) --
|
||||
Complete pretraining pipeline design
|
||||
|
||||
### Multi-Node Mesh
|
||||
|
||||
Scale to 3-4 ESP32 nodes for better spatial coverage. Each node gets a
|
||||
unique `--node-id` and all target the same host laptop. The Seed's kNN
|
||||
graph naturally clusters vectors by node and sensing state.
|
||||
|
||||
### Cognitum Seed Resources
|
||||
|
||||
- [cognitum.one](https://cognitum.one) -- Hardware and firmware information
|
||||
- Seed API: 98 HTTPS endpoints with bearer token authentication
|
||||
- MCP proxy: 114 tools accessible via JSON-RPC 2.0 for AI assistant integration
|
||||
|
||||
### Rust Training Pipeline
|
||||
|
||||
For users with the Rust toolchain, the `wifi-densepose-train` crate
|
||||
provides the full training pipeline with RuVector integration:
|
||||
|
||||
```bash
|
||||
cd rust-port/wifi-densepose-rs
|
||||
cargo run -p wifi-densepose-train -- \
|
||||
--data pretrain-vectors.rvf \
|
||||
--epochs 50 \
|
||||
--output pretrained-encoder.onnx
|
||||
```
|
||||
+1
-89
@@ -38,9 +38,7 @@ WiFi DensePose turns commodity WiFi signals into real-time human pose estimation
|
||||
14. [Hardware Setup](#hardware-setup)
|
||||
- [ESP32-S3 Mesh](#esp32-s3-mesh)
|
||||
- [Intel 5300 / Atheros NIC](#intel-5300--atheros-nic)
|
||||
15. [Camera-Free Pose Training](#camera-free-pose-training)
|
||||
16. [ruvllm Training Pipeline](#ruvllm-training-pipeline)
|
||||
17. [Docker Compose (Multi-Service)](#docker-compose-multi-service)
|
||||
15. [Docker Compose (Multi-Service)](#docker-compose-multi-service)
|
||||
16. [Testing Firmware Without Hardware (QEMU)](#testing-firmware-without-hardware-qemu)
|
||||
- [What You Need](#what-you-need)
|
||||
- [Your First Test Run](#your-first-test-run)
|
||||
@@ -1010,92 +1008,6 @@ These are advanced setups. See the respective driver documentation for installat
|
||||
|
||||
---
|
||||
|
||||
## Camera-Free Pose Training
|
||||
|
||||
RuView can train a 17-keypoint COCO pose model **without any camera** by fusing 10 sensor signals from the ESP32 nodes and Cognitum Seed:
|
||||
|
||||
| Signal | Source | What it provides |
|
||||
|--------|--------|-----------------|
|
||||
| PIR sensor | Seed GPIO 6 | Binary presence ground truth |
|
||||
| BME280 temperature | Seed I2C | Occupancy proxy (temp rises with people) |
|
||||
| BME280 humidity | Seed I2C | Breathing confirmation |
|
||||
| Cross-node RSSI | 2x ESP32 | Rough XY position (triangulation) |
|
||||
| Vitals stability | ESP32 DSP | Activity level (stable HR = stationary) |
|
||||
| Temporal CSI patterns | ESP32 DSP | Walk (periodic), sit (stable), empty (flat) |
|
||||
| kNN clusters | Seed vector store | Natural state groupings |
|
||||
| Boundary fragility | Seed graph analysis | Regime changes (enter/exit) |
|
||||
| Reed switch | Seed GPIO 5 | Door open/close events |
|
||||
| Vibration sensor | Seed GPIO 13 | Footstep detection |
|
||||
|
||||
### How It Works
|
||||
|
||||
The pipeline generates weak labels from sensor fusion, then trains in 5 phases:
|
||||
|
||||
1. **Multi-modal collection** — Syncs CSI frames with Seed sensor events
|
||||
2. **Weak label generation** — RSSI triangulation for head position, subcarrier asymmetry for hands, vibration for feet
|
||||
3. **5-keypoint pose proxy** — Trains head/hands/feet positions from fused signals
|
||||
4. **17-keypoint interpolation** — Derives full COCO skeleton using bone length constraints
|
||||
5. **Self-refinement** — Bootstraps from confident predictions (3 rounds)
|
||||
|
||||
```bash
|
||||
# With Cognitum Seed connected (all 10 signals):
|
||||
node scripts/train-camera-free.js \
|
||||
--data data/recordings/pretrain-*.csi.jsonl \
|
||||
--seed-url https://169.254.42.1:8443 \
|
||||
--seed-token "$SEED_TOKEN"
|
||||
|
||||
# Without Seed (CSI-only, 3 signals — still works):
|
||||
node scripts/train-camera-free.js \
|
||||
--data data/recordings/pretrain-*.csi.jsonl --no-seed
|
||||
```
|
||||
|
||||
**Output:** 82.8 KB model (8 KB at 4-bit) with 17-keypoint predictions, 0 skeleton violations, LoRA per-node adapters, and EWC protection against forgetting.
|
||||
|
||||
See [ADR-071](adr/ADR-071-ruvllm-training-pipeline.md) and the [pretraining tutorial](tutorials/cognitum-seed-pretraining.md) for the full walkthrough.
|
||||
|
||||
---
|
||||
|
||||
## ruvllm Training Pipeline
|
||||
|
||||
All training uses **ruvllm** — a Rust-native ML runtime. No Python, no PyTorch, no GPU drivers required. Runs on any machine with Node.js.
|
||||
|
||||
### 5-Phase Training
|
||||
|
||||
| Phase | What | Duration (M4 Pro) |
|
||||
|-------|------|--------------------|
|
||||
| Contrastive pretraining | Triplet + InfoNCE loss on CSI embeddings | ~5s |
|
||||
| Task head training | Presence, activity, vitals classifiers | ~10s |
|
||||
| LoRA refinement | Per-node room adaptation (rank-4) | ~4s |
|
||||
| TurboQuant quantization | 2/4/8-bit with <0.5% quality loss | <1s |
|
||||
| EWC consolidation | Prevent catastrophic forgetting | <1s |
|
||||
|
||||
```bash
|
||||
# Basic training
|
||||
node scripts/train-ruvllm.js --data data/recordings/pretrain-*.csi.jsonl
|
||||
|
||||
# Benchmark
|
||||
node scripts/benchmark-ruvllm.js --model models/csi-ruvllm
|
||||
```
|
||||
|
||||
### Quantization Options
|
||||
|
||||
| Bits | Size | Compression | Quality Loss | Use Case |
|
||||
|------|------|-------------|-------------|----------|
|
||||
| fp32 | 48 KB | 1x | 0% | Development |
|
||||
| 8-bit | 16 KB | 4x | <0.01% | Cognitum Seed inference |
|
||||
| 4-bit | 8 KB | 8x | <0.1% | Recommended for deployment |
|
||||
| 2-bit | 4 KB | 16x | <1% | ESP32-S3 SRAM (edge inference) |
|
||||
|
||||
### Key Features
|
||||
|
||||
- **SONA adaptation** — Adapts to new rooms in <1ms without retraining
|
||||
- **LoRA adapters** — 2,048 parameters per room, hot-swappable
|
||||
- **EWC protection** — Learns new rooms without forgetting previous ones
|
||||
- **Deterministic** — Same seed always produces same model (reproducible)
|
||||
- **10x data augmentation** — Temporal interpolation, noise injection, cross-node blending
|
||||
|
||||
---
|
||||
|
||||
## Docker Compose (Multi-Service)
|
||||
|
||||
For production deployments with both Rust and Python services:
|
||||
|
||||
@@ -167,17 +167,6 @@ void app_main(void)
|
||||
}
|
||||
#else
|
||||
csi_collector_init();
|
||||
|
||||
/* ADR-073: Start multi-frequency channel hopping if configured in NVS. */
|
||||
if (g_nvs_config.channel_hop_count > 1) {
|
||||
ESP_LOGI(TAG, "Starting channel hopping: %u channels, dwell=%lu ms",
|
||||
(unsigned)g_nvs_config.channel_hop_count,
|
||||
(unsigned long)g_nvs_config.dwell_ms);
|
||||
csi_collector_set_hop_table(
|
||||
g_nvs_config.channel_list,
|
||||
g_nvs_config.channel_hop_count,
|
||||
g_nvs_config.dwell_ms);
|
||||
}
|
||||
#endif
|
||||
|
||||
/* ADR-039: Initialize edge processing pipeline. */
|
||||
|
||||
@@ -71,14 +71,6 @@ def build_nvs_csv(args):
|
||||
mac_bytes = bytes(int(b, 16) for b in args.filter_mac.split(":"))
|
||||
# NVS blob: write as hex-encoded string for CSV compatibility
|
||||
writer.writerow(["filter_mac", "data", "hex2bin", mac_bytes.hex()])
|
||||
# ADR-073: Multi-frequency channel hopping
|
||||
if args.hop_channels is not None:
|
||||
channels = [int(c.strip()) for c in args.hop_channels.split(",")]
|
||||
writer.writerow(["hop_count", "data", "u8", str(len(channels))])
|
||||
# Store as NVS blob (firmware reads "chan_list" as uint8 blob)
|
||||
chan_bytes = bytes(channels)
|
||||
writer.writerow(["chan_list", "data", "hex2bin", chan_bytes.hex()])
|
||||
writer.writerow(["dwell_ms", "data", "u32", str(args.hop_dwell)])
|
||||
# ADR-066: Swarm bridge configuration
|
||||
if args.seed_url is not None:
|
||||
writer.writerow(["seed_url", "data", "string", args.seed_url])
|
||||
@@ -189,9 +181,6 @@ def main():
|
||||
parser.add_argument("--channel", type=int, help="CSI channel (1-14 for 2.4GHz, 36-177 for 5GHz). "
|
||||
"Overrides auto-detection from connected AP.")
|
||||
parser.add_argument("--filter-mac", type=str, help="MAC address to filter CSI frames (AA:BB:CC:DD:EE:FF)")
|
||||
# ADR-073: Multi-frequency channel hopping
|
||||
parser.add_argument("--hop-channels", type=str, help="Comma-separated channel list for hopping (e.g. '1,6,11')")
|
||||
parser.add_argument("--hop-dwell", type=int, default=200, help="Dwell time per channel in ms (default: 200)")
|
||||
# ADR-066: Swarm bridge
|
||||
parser.add_argument("--seed-url", type=str, help="Cognitum Seed base URL (e.g. http://10.1.10.236)")
|
||||
parser.add_argument("--seed-token", type=str, help="Seed Bearer token (from pairing)")
|
||||
|
||||
@@ -1,550 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
WiFi-DensePose Model Benchmarking
|
||||
|
||||
Loads trained ONNX models, runs inference on test data, and reports
|
||||
performance metrics: latency, throughput, PCK@0.2, model size, and
|
||||
estimated FLOPs.
|
||||
|
||||
Can compare multiple models from a hyperparameter sweep.
|
||||
|
||||
Usage:
|
||||
# Benchmark a single model
|
||||
python scripts/benchmark-model.py --model checkpoints/best.onnx
|
||||
|
||||
# Benchmark with recorded test data
|
||||
python scripts/benchmark-model.py --model best.onnx --test-data data/recordings/test.csi.jsonl
|
||||
|
||||
# Compare models from a sweep
|
||||
python scripts/benchmark-model.py --sweep-dir training-results/wdp-train-a100-*/checkpoints/
|
||||
|
||||
# Benchmark with synthetic data (no recordings needed)
|
||||
python scripts/benchmark-model.py --model best.onnx --synthetic --num-samples 200
|
||||
|
||||
# Export results as JSON
|
||||
python scripts/benchmark-model.py --model best.onnx --output results.json
|
||||
|
||||
Prerequisites:
|
||||
pip install onnxruntime numpy
|
||||
Optional: pip install onnx (for FLOPs estimation)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
try:
|
||||
import onnxruntime as ort
|
||||
except ImportError:
|
||||
print("ERROR: onnxruntime not installed. Run: pip install onnxruntime")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# ── Configuration ────────────────────────────────────────────────────────────
|
||||
|
||||
# Default model input shape (must match TrainingConfig defaults)
|
||||
NUM_SUBCARRIERS = 56
|
||||
NUM_ANTENNAS_TX = 3
|
||||
NUM_ANTENNAS_RX = 3
|
||||
WINDOW_FRAMES = 100
|
||||
NUM_KEYPOINTS = 17
|
||||
HEATMAP_SIZE = 56
|
||||
|
||||
# PCK threshold
|
||||
PCK_THRESHOLD = 0.2
|
||||
|
||||
|
||||
# ── Data classes ─────────────────────────────────────────────────────────────
|
||||
|
||||
@dataclass
|
||||
class BenchmarkResult:
|
||||
model_path: str
|
||||
model_size_mb: float
|
||||
num_parameters: Optional[int] = None
|
||||
estimated_flops: Optional[int] = None
|
||||
|
||||
# Latency
|
||||
warmup_runs: int = 10
|
||||
benchmark_runs: int = 100
|
||||
latency_mean_ms: float = 0.0
|
||||
latency_std_ms: float = 0.0
|
||||
latency_p50_ms: float = 0.0
|
||||
latency_p95_ms: float = 0.0
|
||||
latency_p99_ms: float = 0.0
|
||||
throughput_fps: float = 0.0
|
||||
|
||||
# Accuracy (if ground truth available)
|
||||
pck_at_02: Optional[float] = None
|
||||
mean_per_joint_error: Optional[float] = None
|
||||
num_test_samples: int = 0
|
||||
|
||||
# Input shape
|
||||
input_shape: list = field(default_factory=list)
|
||||
provider: str = ""
|
||||
|
||||
|
||||
# ── ONNX model loading ──────────────────────────────────────────────────────
|
||||
|
||||
def load_model(model_path: str) -> ort.InferenceSession:
|
||||
"""Load an ONNX model with the best available execution provider."""
|
||||
providers = []
|
||||
if "CUDAExecutionProvider" in ort.get_available_providers():
|
||||
providers.append("CUDAExecutionProvider")
|
||||
providers.append("CPUExecutionProvider")
|
||||
|
||||
sess_opts = ort.SessionOptions()
|
||||
sess_opts.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
|
||||
sess_opts.intra_op_num_threads = os.cpu_count() or 4
|
||||
|
||||
session = ort.InferenceSession(model_path, sess_opts, providers=providers)
|
||||
return session
|
||||
|
||||
|
||||
def get_model_info(model_path: str) -> dict:
|
||||
"""Extract model metadata: size, parameter count, FLOPs estimate."""
|
||||
path = Path(model_path)
|
||||
size_mb = path.stat().st_size / (1024 * 1024)
|
||||
|
||||
info = {
|
||||
"size_mb": round(size_mb, 2),
|
||||
"num_parameters": None,
|
||||
"estimated_flops": None,
|
||||
}
|
||||
|
||||
# Try to count parameters via onnx
|
||||
try:
|
||||
import onnx
|
||||
model = onnx.load(model_path)
|
||||
total_params = 0
|
||||
for initializer in model.graph.initializer:
|
||||
shape = list(initializer.dims)
|
||||
if shape:
|
||||
total_params += int(np.prod(shape))
|
||||
info["num_parameters"] = total_params
|
||||
|
||||
# Rough FLOPs estimate: ~2 * params (multiply-accumulate)
|
||||
info["estimated_flops"] = total_params * 2
|
||||
except ImportError:
|
||||
pass
|
||||
except Exception as e:
|
||||
print(f" Warning: Could not extract parameter count: {e}")
|
||||
|
||||
return info
|
||||
|
||||
|
||||
# ── Synthetic data generation ────────────────────────────────────────────────
|
||||
|
||||
def generate_synthetic_input(
|
||||
batch_size: int = 1,
|
||||
num_subcarriers: int = NUM_SUBCARRIERS,
|
||||
num_tx: int = NUM_ANTENNAS_TX,
|
||||
num_rx: int = NUM_ANTENNAS_RX,
|
||||
window_frames: int = WINDOW_FRAMES,
|
||||
) -> np.ndarray:
|
||||
"""Generate synthetic CSI input tensor matching the model's expected shape.
|
||||
|
||||
The WiFi-DensePose model expects input shape:
|
||||
[batch, channels, height, width]
|
||||
where channels = num_tx * num_rx, height = window_frames, width = num_subcarriers.
|
||||
"""
|
||||
channels = num_tx * num_rx # 3x3 = 9 MIMO streams
|
||||
# Simulate CSI amplitude data with realistic distribution
|
||||
rng = np.random.default_rng(42)
|
||||
data = rng.normal(loc=0.0, scale=1.0, size=(batch_size, channels, window_frames, num_subcarriers))
|
||||
return data.astype(np.float32)
|
||||
|
||||
|
||||
def generate_synthetic_keypoints(
|
||||
num_samples: int,
|
||||
num_keypoints: int = NUM_KEYPOINTS,
|
||||
heatmap_size: int = HEATMAP_SIZE,
|
||||
) -> np.ndarray:
|
||||
"""Generate synthetic ground truth keypoint coordinates for PCK evaluation."""
|
||||
rng = np.random.default_rng(123)
|
||||
# Keypoints as (x, y) in [0, heatmap_size) range
|
||||
return rng.uniform(0, heatmap_size, size=(num_samples, num_keypoints, 2)).astype(np.float32)
|
||||
|
||||
|
||||
# ── Load test data from .csi.jsonl ──────────────────────────────────────────
|
||||
|
||||
def load_test_data(
|
||||
jsonl_path: str,
|
||||
window_frames: int = WINDOW_FRAMES,
|
||||
num_subcarriers: int = NUM_SUBCARRIERS,
|
||||
max_samples: int = 500,
|
||||
) -> np.ndarray:
|
||||
"""Load CSI frames from a .csi.jsonl file and window them into model inputs."""
|
||||
frames = []
|
||||
path = Path(jsonl_path)
|
||||
|
||||
with open(path, "r") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
record = json.loads(line)
|
||||
subs = record.get("subcarriers", [])
|
||||
if len(subs) > 0:
|
||||
frames.append(subs)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
if len(frames) < window_frames:
|
||||
print(f" Warning: Only {len(frames)} frames, need {window_frames}. Padding with zeros.")
|
||||
while len(frames) < window_frames:
|
||||
frames.append([0.0] * num_subcarriers)
|
||||
|
||||
# Normalize subcarrier count
|
||||
normalized = []
|
||||
for frame in frames:
|
||||
if len(frame) < num_subcarriers:
|
||||
frame = frame + [0.0] * (num_subcarriers - len(frame))
|
||||
elif len(frame) > num_subcarriers:
|
||||
# Downsample via linear interpolation
|
||||
indices = np.linspace(0, len(frame) - 1, num_subcarriers)
|
||||
frame = np.interp(indices, range(len(frame)), frame).tolist()
|
||||
normalized.append(frame)
|
||||
|
||||
frames = normalized
|
||||
|
||||
# Create sliding windows
|
||||
samples = []
|
||||
stride = max(1, window_frames // 2)
|
||||
for i in range(0, len(frames) - window_frames + 1, stride):
|
||||
window = frames[i : i + window_frames]
|
||||
# Shape: [channels=1, window_frames, num_subcarriers]
|
||||
# Expand single stream to 9 channels (repeat for MIMO)
|
||||
arr = np.array(window, dtype=np.float32)
|
||||
arr = np.expand_dims(arr, axis=0) # [1, window_frames, num_subcarriers]
|
||||
arr = np.repeat(arr, NUM_ANTENNAS_TX * NUM_ANTENNAS_RX, axis=0) # [9, window, subs]
|
||||
samples.append(arr)
|
||||
|
||||
if len(samples) >= max_samples:
|
||||
break
|
||||
|
||||
if not samples:
|
||||
return generate_synthetic_input(1)
|
||||
|
||||
return np.stack(samples, axis=0) # [N, 9, window_frames, num_subcarriers]
|
||||
|
||||
|
||||
# ── Benchmarking ─────────────────────────────────────────────────────────────
|
||||
|
||||
def benchmark_latency(
|
||||
session: ort.InferenceSession,
|
||||
input_data: np.ndarray,
|
||||
warmup: int = 10,
|
||||
runs: int = 100,
|
||||
) -> dict:
|
||||
"""Measure inference latency over multiple runs."""
|
||||
input_name = session.get_inputs()[0].name
|
||||
|
||||
# Warmup
|
||||
for _ in range(warmup):
|
||||
session.run(None, {input_name: input_data[:1]})
|
||||
|
||||
# Timed runs
|
||||
latencies = []
|
||||
for _ in range(runs):
|
||||
start = time.perf_counter()
|
||||
session.run(None, {input_name: input_data[:1]})
|
||||
end = time.perf_counter()
|
||||
latencies.append((end - start) * 1000) # ms
|
||||
|
||||
latencies = np.array(latencies)
|
||||
return {
|
||||
"mean_ms": float(np.mean(latencies)),
|
||||
"std_ms": float(np.std(latencies)),
|
||||
"p50_ms": float(np.percentile(latencies, 50)),
|
||||
"p95_ms": float(np.percentile(latencies, 95)),
|
||||
"p99_ms": float(np.percentile(latencies, 99)),
|
||||
"throughput_fps": 1000.0 / float(np.mean(latencies)),
|
||||
}
|
||||
|
||||
|
||||
def compute_pck(
|
||||
predictions: np.ndarray,
|
||||
ground_truth: np.ndarray,
|
||||
threshold: float = PCK_THRESHOLD,
|
||||
normalize_by: float = HEATMAP_SIZE,
|
||||
) -> float:
|
||||
"""Compute Percentage of Correct Keypoints at a given threshold.
|
||||
|
||||
PCK@t = fraction of predicted keypoints within t * normalize_by of ground truth.
|
||||
"""
|
||||
if predictions.shape != ground_truth.shape:
|
||||
return 0.0
|
||||
|
||||
# Euclidean distance per keypoint
|
||||
distances = np.linalg.norm(predictions - ground_truth, axis=-1) # [N, K]
|
||||
threshold_pixels = threshold * normalize_by
|
||||
correct = (distances < threshold_pixels).astype(float)
|
||||
return float(np.mean(correct))
|
||||
|
||||
|
||||
def extract_keypoints_from_heatmaps(heatmaps: np.ndarray) -> np.ndarray:
|
||||
"""Convert heatmap outputs [N, K, H, W] to keypoint coordinates [N, K, 2]."""
|
||||
n, k, h, w = heatmaps.shape
|
||||
flat = heatmaps.reshape(n, k, -1)
|
||||
max_idx = np.argmax(flat, axis=-1) # [N, K]
|
||||
y = max_idx // w
|
||||
x = max_idx % w
|
||||
return np.stack([x, y], axis=-1).astype(np.float32)
|
||||
|
||||
|
||||
def benchmark_model(
|
||||
model_path: str,
|
||||
test_data: Optional[np.ndarray] = None,
|
||||
gt_keypoints: Optional[np.ndarray] = None,
|
||||
warmup: int = 10,
|
||||
runs: int = 100,
|
||||
) -> BenchmarkResult:
|
||||
"""Run full benchmark on a single model."""
|
||||
print(f"\nBenchmarking: {model_path}")
|
||||
|
||||
# Load model
|
||||
session = load_model(model_path)
|
||||
provider = session.get_providers()[0]
|
||||
print(f" Provider: {provider}")
|
||||
|
||||
# Model info
|
||||
model_info = get_model_info(model_path)
|
||||
print(f" Size: {model_info['size_mb']} MB")
|
||||
if model_info["num_parameters"]:
|
||||
print(f" Parameters: {model_info['num_parameters']:,}")
|
||||
if model_info["estimated_flops"]:
|
||||
print(f" Estimated FLOPs: {model_info['estimated_flops']:,}")
|
||||
|
||||
# Input shape
|
||||
input_meta = session.get_inputs()[0]
|
||||
input_shape = input_meta.shape
|
||||
print(f" Input: {input_meta.name} {input_shape} ({input_meta.type})")
|
||||
|
||||
# Output shapes
|
||||
for out in session.get_outputs():
|
||||
print(f" Output: {out.name} {out.shape}")
|
||||
|
||||
# Generate or use provided test data
|
||||
if test_data is None:
|
||||
# Infer shape from model
|
||||
if input_shape and all(isinstance(d, int) for d in input_shape):
|
||||
batch = max(1, input_shape[0] if input_shape[0] > 0 else 1)
|
||||
test_data = np.random.randn(*[batch if d <= 0 else d for d in input_shape]).astype(np.float32)
|
||||
else:
|
||||
test_data = generate_synthetic_input(1)
|
||||
|
||||
# Latency benchmark
|
||||
print(f" Running {warmup} warmup + {runs} benchmark iterations...")
|
||||
latency = benchmark_latency(session, test_data, warmup=warmup, runs=runs)
|
||||
print(f" Latency: {latency['mean_ms']:.2f} +/- {latency['std_ms']:.2f} ms")
|
||||
print(f" P50/P95/P99: {latency['p50_ms']:.2f} / {latency['p95_ms']:.2f} / {latency['p99_ms']:.2f} ms")
|
||||
print(f" Throughput: {latency['throughput_fps']:.1f} fps")
|
||||
|
||||
# Accuracy (if ground truth provided or we can do synthetic evaluation)
|
||||
pck = None
|
||||
mpjpe = None
|
||||
num_samples = 0
|
||||
|
||||
if gt_keypoints is not None and test_data is not None:
|
||||
input_name = session.get_inputs()[0].name
|
||||
all_preds = []
|
||||
|
||||
for i in range(len(test_data)):
|
||||
outputs = session.run(None, {input_name: test_data[i : i + 1]})
|
||||
# Assume first output is keypoint heatmaps [1, K, H, W]
|
||||
heatmaps = outputs[0]
|
||||
if heatmaps.ndim == 4:
|
||||
kp = extract_keypoints_from_heatmaps(heatmaps)
|
||||
all_preds.append(kp[0])
|
||||
|
||||
if all_preds:
|
||||
predictions = np.stack(all_preds)
|
||||
gt = gt_keypoints[: len(predictions)]
|
||||
pck = compute_pck(predictions, gt)
|
||||
distances = np.linalg.norm(predictions - gt, axis=-1)
|
||||
mpjpe = float(np.mean(distances))
|
||||
num_samples = len(predictions)
|
||||
print(f" PCK@{PCK_THRESHOLD}: {pck:.4f}")
|
||||
print(f" MPJPE: {mpjpe:.2f} px")
|
||||
print(f" Samples: {num_samples}")
|
||||
|
||||
result = BenchmarkResult(
|
||||
model_path=model_path,
|
||||
model_size_mb=model_info["size_mb"],
|
||||
num_parameters=model_info["num_parameters"],
|
||||
estimated_flops=model_info["estimated_flops"],
|
||||
warmup_runs=warmup,
|
||||
benchmark_runs=runs,
|
||||
latency_mean_ms=round(latency["mean_ms"], 3),
|
||||
latency_std_ms=round(latency["std_ms"], 3),
|
||||
latency_p50_ms=round(latency["p50_ms"], 3),
|
||||
latency_p95_ms=round(latency["p95_ms"], 3),
|
||||
latency_p99_ms=round(latency["p99_ms"], 3),
|
||||
throughput_fps=round(latency["throughput_fps"], 1),
|
||||
pck_at_02=round(pck, 4) if pck is not None else None,
|
||||
mean_per_joint_error=round(mpjpe, 2) if mpjpe is not None else None,
|
||||
num_test_samples=num_samples,
|
||||
input_shape=list(input_shape) if input_shape else [],
|
||||
provider=provider,
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ── Comparison table ─────────────────────────────────────────────────────────
|
||||
|
||||
def print_comparison_table(results: list[BenchmarkResult]):
|
||||
"""Print a formatted comparison table of multiple models."""
|
||||
if not results:
|
||||
return
|
||||
|
||||
print("\n" + "=" * 100)
|
||||
print(" Model Comparison")
|
||||
print("=" * 100)
|
||||
|
||||
# Header
|
||||
print(
|
||||
f"{'Model':<35} {'Size(MB)':>8} {'Params':>10} "
|
||||
f"{'Lat(ms)':>8} {'P95(ms)':>8} {'FPS':>7} {'PCK@0.2':>8}"
|
||||
)
|
||||
print("-" * 100)
|
||||
|
||||
for r in results:
|
||||
name = Path(r.model_path).stem[:33]
|
||||
params = f"{r.num_parameters:,}" if r.num_parameters else "?"
|
||||
pck = f"{r.pck_at_02:.4f}" if r.pck_at_02 is not None else "N/A"
|
||||
|
||||
print(
|
||||
f"{name:<35} {r.model_size_mb:>8.2f} {params:>10} "
|
||||
f"{r.latency_mean_ms:>8.2f} {r.latency_p95_ms:>8.2f} "
|
||||
f"{r.throughput_fps:>7.1f} {pck:>8}"
|
||||
)
|
||||
|
||||
print("=" * 100)
|
||||
|
||||
# Best model by latency
|
||||
best_latency = min(results, key=lambda r: r.latency_mean_ms)
|
||||
print(f"\n Fastest: {Path(best_latency.model_path).stem} ({best_latency.latency_mean_ms:.2f} ms)")
|
||||
|
||||
# Best by PCK (if available)
|
||||
pck_results = [r for r in results if r.pck_at_02 is not None]
|
||||
if pck_results:
|
||||
best_pck = max(pck_results, key=lambda r: r.pck_at_02)
|
||||
print(f" Best accuracy: {Path(best_pck.model_path).stem} (PCK@0.2={best_pck.pck_at_02:.4f})")
|
||||
|
||||
# Smallest model
|
||||
smallest = min(results, key=lambda r: r.model_size_mb)
|
||||
print(f" Smallest: {Path(smallest.model_path).stem} ({smallest.model_size_mb:.2f} MB)")
|
||||
|
||||
|
||||
# ── Main ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Benchmark WiFi-DensePose ONNX models",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
|
||||
parser.add_argument("--model", type=str, help="Path to a single ONNX model")
|
||||
parser.add_argument("--sweep-dir", type=str, help="Directory containing multiple ONNX models to compare")
|
||||
parser.add_argument("--test-data", type=str, help="Path to .csi.jsonl test data file")
|
||||
parser.add_argument("--synthetic", action="store_true", help="Use synthetic test data")
|
||||
parser.add_argument("--num-samples", type=int, default=100, help="Number of synthetic samples (default: 100)")
|
||||
parser.add_argument("--warmup", type=int, default=10, help="Warmup iterations (default: 10)")
|
||||
parser.add_argument("--runs", type=int, default=100, help="Benchmark iterations (default: 100)")
|
||||
parser.add_argument("--output", type=str, help="Save results to JSON file")
|
||||
parser.add_argument("--gpu", action="store_true", help="Force GPU execution provider")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.model and not args.sweep_dir:
|
||||
parser.error("Specify --model or --sweep-dir")
|
||||
|
||||
# Prepare test data
|
||||
test_data = None
|
||||
gt_keypoints = None
|
||||
|
||||
if args.test_data:
|
||||
print(f"Loading test data from: {args.test_data}")
|
||||
test_data = load_test_data(args.test_data)
|
||||
print(f" Loaded {len(test_data)} windowed samples")
|
||||
elif args.synthetic:
|
||||
print(f"Generating {args.num_samples} synthetic samples...")
|
||||
test_data = generate_synthetic_input(args.num_samples)
|
||||
gt_keypoints = generate_synthetic_keypoints(args.num_samples)
|
||||
print(f" Input shape: {test_data.shape}")
|
||||
|
||||
# Collect models
|
||||
model_paths = []
|
||||
if args.model:
|
||||
model_paths.append(args.model)
|
||||
if args.sweep_dir:
|
||||
sweep = Path(args.sweep_dir)
|
||||
if sweep.is_dir():
|
||||
model_paths.extend(sorted(str(p) for p in sweep.glob("**/*.onnx")))
|
||||
else:
|
||||
# Glob pattern
|
||||
from glob import glob
|
||||
model_paths.extend(sorted(glob(str(sweep))))
|
||||
|
||||
if not model_paths:
|
||||
print("ERROR: No ONNX models found.")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Found {len(model_paths)} model(s) to benchmark.")
|
||||
|
||||
# Benchmark each model
|
||||
results = []
|
||||
for path in model_paths:
|
||||
if not Path(path).exists():
|
||||
print(f" Skipping (not found): {path}")
|
||||
continue
|
||||
try:
|
||||
result = benchmark_model(
|
||||
path,
|
||||
test_data=test_data,
|
||||
gt_keypoints=gt_keypoints,
|
||||
warmup=args.warmup,
|
||||
runs=args.runs,
|
||||
)
|
||||
results.append(result)
|
||||
except Exception as e:
|
||||
print(f" ERROR benchmarking {path}: {e}")
|
||||
|
||||
# Comparison table
|
||||
if len(results) > 1:
|
||||
print_comparison_table(results)
|
||||
|
||||
# Save results
|
||||
if args.output:
|
||||
output_path = Path(args.output)
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(output_path, "w") as f:
|
||||
json.dump(
|
||||
{
|
||||
"benchmark_results": [asdict(r) for r in results],
|
||||
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
"num_models": len(results),
|
||||
},
|
||||
f,
|
||||
indent=2,
|
||||
)
|
||||
print(f"\nResults saved to: {output_path}")
|
||||
|
||||
if not results:
|
||||
print("No models were successfully benchmarked.")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,533 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* RuView RF Scan Benchmark
|
||||
*
|
||||
* Collects CSI frames from ESP32 nodes and computes quantitative metrics
|
||||
* for single-channel and multi-channel scanning performance:
|
||||
*
|
||||
* - Frames per second per node per channel
|
||||
* - Null subcarrier count per channel
|
||||
* - Cross-channel null diversity (how many nulls are filled by other channels)
|
||||
* - Subcarrier correlation across channels
|
||||
* - Position accuracy improvement estimate
|
||||
* - Spectrum flatness (lower = more objects)
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/benchmark-rf-scan.js --port 5006 --duration 30
|
||||
* node scripts/benchmark-rf-scan.js --duration 60 --json
|
||||
*
|
||||
* ADR: docs/adr/ADR-073-multifrequency-mesh-scan.md
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const dgram = require('dgram');
|
||||
const { parseArgs } = require('util');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CLI
|
||||
// ---------------------------------------------------------------------------
|
||||
const { values: args } = parseArgs({
|
||||
options: {
|
||||
port: { type: 'string', short: 'p', default: '5006' },
|
||||
duration: { type: 'string', short: 'd', default: '30' },
|
||||
json: { type: 'boolean', default: false },
|
||||
},
|
||||
strict: true,
|
||||
});
|
||||
|
||||
const PORT = parseInt(args.port, 10);
|
||||
const DURATION_S = parseInt(args.duration, 10);
|
||||
const JSON_OUTPUT = args.json;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
const CSI_MAGIC = 0xC5110001;
|
||||
const HEADER_SIZE = 20;
|
||||
const NULL_THRESHOLD = 2.0;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Data collection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Per-channel frame collector. Accumulates amplitude snapshots for analysis.
|
||||
*/
|
||||
class ChannelCollector {
|
||||
constructor(channel) {
|
||||
this.channel = channel;
|
||||
this.freqMhz = 0;
|
||||
this.frames = []; // array of { amplitudes, phases, rssi, timestamp }
|
||||
this.nSubcarriers = 0;
|
||||
}
|
||||
|
||||
add(amplitudes, phases, rssi, freqMhz) {
|
||||
this.freqMhz = freqMhz;
|
||||
this.nSubcarriers = amplitudes.length;
|
||||
this.frames.push({
|
||||
amplitudes: Float64Array.from(amplitudes),
|
||||
phases: Float64Array.from(phases),
|
||||
rssi,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class NodeCollector {
|
||||
constructor(nodeId) {
|
||||
this.nodeId = nodeId;
|
||||
this.address = null;
|
||||
this.channels = new Map(); // channel -> ChannelCollector
|
||||
this.totalFrames = 0;
|
||||
this.firstFrameMs = 0;
|
||||
this.lastFrameMs = 0;
|
||||
}
|
||||
|
||||
getOrCreate(channel) {
|
||||
if (!this.channels.has(channel)) {
|
||||
this.channels.set(channel, new ChannelCollector(channel));
|
||||
}
|
||||
return this.channels.get(channel);
|
||||
}
|
||||
}
|
||||
|
||||
const nodes = new Map();
|
||||
let totalFrames = 0;
|
||||
const startTime = Date.now();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Packet parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
function parseCSIFrame(buf) {
|
||||
if (buf.length < HEADER_SIZE) return null;
|
||||
if (buf.readUInt32LE(0) !== CSI_MAGIC) return null;
|
||||
|
||||
const nodeId = buf.readUInt8(4);
|
||||
const nAntennas = buf.readUInt8(5) || 1;
|
||||
const nSubcarriers = buf.readUInt16LE(6);
|
||||
const freqMhz = buf.readUInt32LE(8);
|
||||
const rssi = buf.readInt8(16);
|
||||
|
||||
const iqLen = nSubcarriers * nAntennas * 2;
|
||||
if (buf.length < HEADER_SIZE + iqLen) return null;
|
||||
|
||||
const amplitudes = new Float64Array(nSubcarriers);
|
||||
const phases = new Float64Array(nSubcarriers);
|
||||
|
||||
for (let sc = 0; sc < nSubcarriers; sc++) {
|
||||
const offset = HEADER_SIZE + sc * 2;
|
||||
const I = buf.readInt8(offset);
|
||||
const Q = buf.readInt8(offset + 1);
|
||||
amplitudes[sc] = Math.sqrt(I * I + Q * Q);
|
||||
phases[sc] = Math.atan2(Q, I);
|
||||
}
|
||||
|
||||
let channel = 0;
|
||||
if (freqMhz >= 2412 && freqMhz <= 2484) {
|
||||
channel = freqMhz === 2484 ? 14 : Math.round((freqMhz - 2412) / 5) + 1;
|
||||
} else if (freqMhz >= 5180) {
|
||||
channel = Math.round((freqMhz - 5000) / 5);
|
||||
}
|
||||
|
||||
return { nodeId, nSubcarriers, freqMhz, rssi, amplitudes, phases, channel };
|
||||
}
|
||||
|
||||
function handlePacket(buf, rinfo) {
|
||||
if (buf.length < 4 || buf.readUInt32LE(0) !== CSI_MAGIC) return;
|
||||
|
||||
const frame = parseCSIFrame(buf);
|
||||
if (!frame) return;
|
||||
|
||||
totalFrames++;
|
||||
let node = nodes.get(frame.nodeId);
|
||||
if (!node) {
|
||||
node = new NodeCollector(frame.nodeId);
|
||||
nodes.set(frame.nodeId, node);
|
||||
}
|
||||
|
||||
node.address = rinfo.address;
|
||||
node.totalFrames++;
|
||||
const now = Date.now();
|
||||
if (node.firstFrameMs === 0) node.firstFrameMs = now;
|
||||
node.lastFrameMs = now;
|
||||
|
||||
const cc = node.getOrCreate(frame.channel);
|
||||
cc.add(frame.amplitudes, frame.phases, frame.rssi, frame.freqMhz);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Analysis
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function computeMetrics() {
|
||||
const results = {
|
||||
duration_s: DURATION_S,
|
||||
totalFrames,
|
||||
nodes: [],
|
||||
crossChannel: null,
|
||||
summary: null,
|
||||
};
|
||||
|
||||
for (const node of nodes.values()) {
|
||||
const elapsed = (node.lastFrameMs - node.firstFrameMs) / 1000;
|
||||
const nodeFps = elapsed > 0 ? node.totalFrames / elapsed : 0;
|
||||
|
||||
const channelMetrics = [];
|
||||
|
||||
for (const [ch, cc] of node.channels.entries()) {
|
||||
if (cc.frames.length === 0) continue;
|
||||
|
||||
const n = cc.nSubcarriers;
|
||||
const nFrames = cc.frames.length;
|
||||
|
||||
// FPS for this channel
|
||||
let chFps = 0;
|
||||
if (nFrames >= 2) {
|
||||
const first = cc.frames[0].timestamp;
|
||||
const last = cc.frames[nFrames - 1].timestamp;
|
||||
const chElapsed = (last - first) / 1000;
|
||||
chFps = chElapsed > 0 ? nFrames / chElapsed : 0;
|
||||
}
|
||||
|
||||
// Average null count across frames
|
||||
let totalNulls = 0;
|
||||
for (const f of cc.frames) {
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (f.amplitudes[i] < NULL_THRESHOLD) totalNulls++;
|
||||
}
|
||||
}
|
||||
const avgNulls = totalNulls / nFrames;
|
||||
const nullPct = n > 0 ? (avgNulls / n) * 100 : 0;
|
||||
|
||||
// Mean RSSI
|
||||
const meanRssi = cc.frames.reduce((s, f) => s + f.rssi, 0) / nFrames;
|
||||
|
||||
// Spectrum flatness: geometric mean / arithmetic mean of last frame
|
||||
const lastFrame = cc.frames[nFrames - 1];
|
||||
let logSum = 0, ampSum = 0, count = 0;
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (lastFrame.amplitudes[i] > 0) {
|
||||
logSum += Math.log(lastFrame.amplitudes[i]);
|
||||
count++;
|
||||
}
|
||||
ampSum += lastFrame.amplitudes[i];
|
||||
}
|
||||
const geoMean = count > 0 ? Math.exp(logSum / count) : 0;
|
||||
const ariMean = n > 0 ? ampSum / n : 0;
|
||||
const flatness = ariMean > 0 ? geoMean / ariMean : 0;
|
||||
|
||||
// Amplitude variance per subcarrier (average across subcarriers)
|
||||
const means = new Float64Array(n);
|
||||
const vars = new Float64Array(n);
|
||||
for (const f of cc.frames) {
|
||||
for (let i = 0; i < n; i++) means[i] += f.amplitudes[i];
|
||||
}
|
||||
for (let i = 0; i < n; i++) means[i] /= nFrames;
|
||||
for (const f of cc.frames) {
|
||||
for (let i = 0; i < n; i++) {
|
||||
const d = f.amplitudes[i] - means[i];
|
||||
vars[i] += d * d;
|
||||
}
|
||||
}
|
||||
let avgVar = 0;
|
||||
for (let i = 0; i < n; i++) {
|
||||
vars[i] /= Math.max(1, nFrames - 1);
|
||||
avgVar += vars[i];
|
||||
}
|
||||
avgVar /= Math.max(1, n);
|
||||
|
||||
// Null subcarrier indices (from last frame)
|
||||
const nullIndices = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (lastFrame.amplitudes[i] < NULL_THRESHOLD) nullIndices.push(i);
|
||||
}
|
||||
|
||||
channelMetrics.push({
|
||||
channel: ch,
|
||||
freqMhz: cc.freqMhz,
|
||||
nSubcarriers: n,
|
||||
frameCount: nFrames,
|
||||
fps: parseFloat(chFps.toFixed(2)),
|
||||
avgNullCount: parseFloat(avgNulls.toFixed(1)),
|
||||
nullPercent: parseFloat(nullPct.toFixed(1)),
|
||||
meanRssi: parseFloat(meanRssi.toFixed(1)),
|
||||
spectrumFlatness: parseFloat(flatness.toFixed(4)),
|
||||
avgAmplitudeVariance: parseFloat(avgVar.toFixed(4)),
|
||||
nullIndices,
|
||||
});
|
||||
}
|
||||
|
||||
results.nodes.push({
|
||||
nodeId: node.nodeId,
|
||||
address: node.address,
|
||||
totalFrames: node.totalFrames,
|
||||
fps: parseFloat(nodeFps.toFixed(2)),
|
||||
channels: channelMetrics,
|
||||
});
|
||||
}
|
||||
|
||||
// Cross-channel null diversity
|
||||
const allChannelData = [];
|
||||
for (const node of nodes.values()) {
|
||||
for (const [ch, cc] of node.channels.entries()) {
|
||||
if (cc.frames.length === 0) continue;
|
||||
const n = cc.nSubcarriers;
|
||||
const lastFrame = cc.frames[cc.frames.length - 1];
|
||||
const nullSet = new Set();
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (lastFrame.amplitudes[i] < NULL_THRESHOLD) nullSet.add(i);
|
||||
}
|
||||
allChannelData.push({ channel: ch, nodeId: node.nodeId, nullSet, n });
|
||||
}
|
||||
}
|
||||
|
||||
if (allChannelData.length >= 2) {
|
||||
// Union and intersection of null sets
|
||||
const allNullSets = allChannelData.map(d => d.nullSet);
|
||||
const union = new Set();
|
||||
for (const s of allNullSets) for (const idx of s) union.add(idx);
|
||||
|
||||
let intersectionCount = 0;
|
||||
for (const idx of union) {
|
||||
if (allNullSets.every(s => s.has(idx))) intersectionCount++;
|
||||
}
|
||||
|
||||
const singleNulls = allNullSets[0].size;
|
||||
const maxSub = Math.max(...allChannelData.map(d => d.n));
|
||||
|
||||
// Cross-channel correlation (pairwise)
|
||||
const correlations = [];
|
||||
for (let i = 0; i < allChannelData.length; i++) {
|
||||
for (let j = i + 1; j < allChannelData.length; j++) {
|
||||
const d1 = allChannelData[i];
|
||||
const d2 = allChannelData[j];
|
||||
const cc1 = [...nodes.values()].find(n => n.nodeId === d1.nodeId)?.channels.get(d1.channel);
|
||||
const cc2 = [...nodes.values()].find(n => n.nodeId === d2.nodeId)?.channels.get(d2.channel);
|
||||
if (!cc1 || !cc2) continue;
|
||||
|
||||
const f1 = cc1.frames[cc1.frames.length - 1];
|
||||
const f2 = cc2.frames[cc2.frames.length - 1];
|
||||
const len = Math.min(f1.amplitudes.length, f2.amplitudes.length);
|
||||
|
||||
let sumXY = 0, sumX = 0, sumY = 0, sumX2 = 0, sumY2 = 0;
|
||||
for (let k = 0; k < len; k++) {
|
||||
sumX += f1.amplitudes[k]; sumY += f2.amplitudes[k];
|
||||
sumXY += f1.amplitudes[k] * f2.amplitudes[k];
|
||||
sumX2 += f1.amplitudes[k] ** 2;
|
||||
sumY2 += f2.amplitudes[k] ** 2;
|
||||
}
|
||||
const denom = Math.sqrt((len * sumX2 - sumX * sumX) * (len * sumY2 - sumY * sumY));
|
||||
const corr = denom > 0 ? (len * sumXY - sumX * sumY) / denom : 0;
|
||||
|
||||
correlations.push({
|
||||
node1: d1.nodeId, ch1: d1.channel,
|
||||
node2: d2.nodeId, ch2: d2.channel,
|
||||
correlation: parseFloat(corr.toFixed(4)),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
results.crossChannel = {
|
||||
totalChannels: allChannelData.length,
|
||||
singleChannelNulls: singleNulls,
|
||||
fusedNulls: intersectionCount,
|
||||
unionNulls: union.size,
|
||||
maxSubcarriers: maxSub,
|
||||
singleNullPct: parseFloat(maxSub > 0 ? ((singleNulls / maxSub) * 100).toFixed(1) : '0'),
|
||||
fusedNullPct: parseFloat(maxSub > 0 ? ((intersectionCount / maxSub) * 100).toFixed(1) : '0'),
|
||||
diversityGainPct: parseFloat(singleNulls > 0
|
||||
? ((1 - intersectionCount / singleNulls) * 100).toFixed(1)
|
||||
: '0'),
|
||||
correlations,
|
||||
};
|
||||
}
|
||||
|
||||
// Position accuracy estimate
|
||||
// With N independent channel observations, accuracy improves by sqrt(N)
|
||||
// Baseline: single channel ~30 cm resolution at 2.4 GHz
|
||||
const nChannels = allChannelData.length;
|
||||
const baselineResolutionCm = 30;
|
||||
const estimatedResolutionCm = nChannels > 0
|
||||
? baselineResolutionCm / Math.sqrt(nChannels)
|
||||
: baselineResolutionCm;
|
||||
|
||||
results.summary = {
|
||||
totalNodes: nodes.size,
|
||||
totalChannels: nChannels,
|
||||
totalFrames,
|
||||
durationS: DURATION_S,
|
||||
avgFps: parseFloat((totalFrames / DURATION_S).toFixed(1)),
|
||||
baselineResolutionCm,
|
||||
estimatedResolutionCm: parseFloat(estimatedResolutionCm.toFixed(1)),
|
||||
resolutionImprovement: nChannels > 1 ? `${Math.sqrt(nChannels).toFixed(2)}x` : '1x (single channel)',
|
||||
totalSubcarriers: allChannelData.reduce((s, d) => s + d.n, 0),
|
||||
subcarrierMultiplier: nChannels > 0
|
||||
? parseFloat((allChannelData.reduce((s, d) => s + d.n, 0) / Math.max(1, allChannelData[0]?.n || 1)).toFixed(1))
|
||||
: 1,
|
||||
};
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reporting
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function printReport(metrics) {
|
||||
console.log('');
|
||||
console.log('=== RUVIEW RF SCAN BENCHMARK ===');
|
||||
console.log(`Duration: ${metrics.duration_s}s | Total frames: ${metrics.totalFrames}`);
|
||||
console.log('');
|
||||
|
||||
// Per-node per-channel table
|
||||
console.log('--- Frames Per Second ---');
|
||||
console.log('Node Channel Freq FPS Frames Subcarriers RSSI');
|
||||
for (const node of metrics.nodes) {
|
||||
for (const ch of node.channels) {
|
||||
console.log(` ${node.nodeId} ch${String(ch.channel).padStart(2)} ${ch.freqMhz} MHz ${String(ch.fps).padStart(5)} ${String(ch.frameCount).padStart(6)} ${String(ch.nSubcarriers).padStart(11)} ${ch.meanRssi} dBm`);
|
||||
}
|
||||
console.log(` ${node.nodeId} TOTAL ${String(node.fps).padStart(5)} ${String(node.totalFrames).padStart(6)}`);
|
||||
}
|
||||
console.log('');
|
||||
|
||||
// Null subcarriers
|
||||
console.log('--- Null Subcarriers Per Channel ---');
|
||||
console.log('Node Channel Nulls Null% Flatness AvgVariance');
|
||||
for (const node of metrics.nodes) {
|
||||
for (const ch of node.channels) {
|
||||
console.log(` ${node.nodeId} ch${String(ch.channel).padStart(2)} ${String(ch.avgNullCount.toFixed(0)).padStart(5)} ${String(ch.nullPercent.toFixed(1)).padStart(5)}% ${String(ch.spectrumFlatness.toFixed(4)).padStart(8)} ${ch.avgAmplitudeVariance.toFixed(4)}`);
|
||||
}
|
||||
}
|
||||
console.log('');
|
||||
|
||||
// Cross-channel diversity
|
||||
if (metrics.crossChannel) {
|
||||
const cc = metrics.crossChannel;
|
||||
console.log('--- Cross-Channel Null Diversity ---');
|
||||
console.log(` Channels scanned: ${cc.totalChannels}`);
|
||||
console.log(` Single-channel nulls: ${cc.singleChannelNulls} (${cc.singleNullPct}%)`);
|
||||
console.log(` Fused nulls (all ch): ${cc.fusedNulls} (${cc.fusedNullPct}%)`);
|
||||
console.log(` Diversity gain: ${cc.diversityGainPct}%`);
|
||||
console.log('');
|
||||
|
||||
if (cc.correlations.length > 0) {
|
||||
console.log('--- Cross-Channel Correlation ---');
|
||||
for (const c of cc.correlations) {
|
||||
const label = c.node1 === c.node2
|
||||
? `node${c.node1} ch${c.ch1}<->ch${c.ch2}`
|
||||
: `node${c.node1}/ch${c.ch1}<->node${c.node2}/ch${c.ch2}`;
|
||||
console.log(` ${label}: ${c.correlation.toFixed(4)}`);
|
||||
}
|
||||
console.log('');
|
||||
}
|
||||
}
|
||||
|
||||
// Summary
|
||||
if (metrics.summary) {
|
||||
const s = metrics.summary;
|
||||
console.log('--- Summary ---');
|
||||
console.log(` Nodes: ${s.totalNodes}`);
|
||||
console.log(` Channels: ${s.totalChannels}`);
|
||||
console.log(` Total subcarriers: ${s.totalSubcarriers} (${s.subcarrierMultiplier}x single-channel)`);
|
||||
console.log(` Average FPS: ${s.avgFps}`);
|
||||
console.log(` Baseline resolution: ${s.baselineResolutionCm} cm (single channel)`);
|
||||
console.log(` Estimated resolution: ${s.estimatedResolutionCm} cm (${s.resolutionImprovement})`);
|
||||
console.log('');
|
||||
}
|
||||
|
||||
// Pass/fail targets (from ADR-073)
|
||||
console.log('--- ADR-073 Targets ---');
|
||||
const s = metrics.summary || {};
|
||||
const cc = metrics.crossChannel || {};
|
||||
|
||||
const targets = [
|
||||
{ name: 'Subcarrier multiplier >= 3x', pass: (s.subcarrierMultiplier || 0) >= 3,
|
||||
actual: `${s.subcarrierMultiplier || 0}x` },
|
||||
{ name: 'Null gap < 5%', pass: (cc.fusedNullPct || 100) < 5,
|
||||
actual: `${cc.fusedNullPct || '?'}%` },
|
||||
{ name: 'Resolution <= 15 cm', pass: (s.estimatedResolutionCm || 999) <= 15,
|
||||
actual: `${s.estimatedResolutionCm || '?'} cm` },
|
||||
];
|
||||
|
||||
for (const t of targets) {
|
||||
const status = t.pass ? 'PASS' : 'FAIL';
|
||||
console.log(` [${status}] ${t.name} (actual: ${t.actual})`);
|
||||
}
|
||||
|
||||
console.log('');
|
||||
console.log('Note: Targets require multi-channel hopping enabled on both ESP32 nodes.');
|
||||
console.log('Single-channel mode will show FAIL for multi-channel targets.');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
function main() {
|
||||
const server = dgram.createSocket('udp4');
|
||||
|
||||
server.on('error', (err) => {
|
||||
console.error(`UDP error: ${err.message}`);
|
||||
server.close();
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
server.on('message', (msg, rinfo) => {
|
||||
handlePacket(msg, rinfo);
|
||||
});
|
||||
|
||||
server.on('listening', () => {
|
||||
const addr = server.address();
|
||||
if (!JSON_OUTPUT) {
|
||||
console.log(`RuView RF Scan Benchmark`);
|
||||
console.log(`Listening on ${addr.address}:${addr.port} for ${DURATION_S}s...`);
|
||||
console.log('Collecting CSI frames from ESP32 nodes...\n');
|
||||
}
|
||||
});
|
||||
|
||||
server.bind(PORT);
|
||||
|
||||
// Progress indicator (non-JSON mode)
|
||||
let progressTimer;
|
||||
if (!JSON_OUTPUT) {
|
||||
let dots = 0;
|
||||
progressTimer = setInterval(() => {
|
||||
dots++;
|
||||
const elapsed = ((Date.now() - startTime) / 1000).toFixed(0);
|
||||
process.stdout.write(`\r ${elapsed}s / ${DURATION_S}s | ${totalFrames} frames | ${nodes.size} nodes ${'.' .repeat(dots % 4)} `);
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if (progressTimer) clearInterval(progressTimer);
|
||||
if (!JSON_OUTPUT) process.stdout.write('\r' + ' '.repeat(60) + '\r');
|
||||
|
||||
const metrics = computeMetrics();
|
||||
|
||||
if (JSON_OUTPUT) {
|
||||
process.stdout.write(JSON.stringify(metrics, null, 2) + '\n');
|
||||
} else {
|
||||
printReport(metrics);
|
||||
}
|
||||
|
||||
server.close();
|
||||
process.exit(0);
|
||||
}, DURATION_S * 1000);
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
if (progressTimer) clearInterval(progressTimer);
|
||||
if (!JSON_OUTPUT) console.log('\nInterrupted — computing metrics with collected data...\n');
|
||||
|
||||
const metrics = computeMetrics();
|
||||
if (JSON_OUTPUT) {
|
||||
process.stdout.write(JSON.stringify(metrics, null, 2) + '\n');
|
||||
} else {
|
||||
printReport(metrics);
|
||||
}
|
||||
|
||||
server.close();
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -1,627 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* WiFi-DensePose CSI Model Benchmark using ruvllm
|
||||
*
|
||||
* Benchmarks a trained ruvllm CSI model across multiple dimensions:
|
||||
* - Inference latency (mean, P50, P95, P99)
|
||||
* - Throughput (embeddings/sec)
|
||||
* - Memory usage per quantization level (2-bit, 4-bit, 8-bit, fp32)
|
||||
* - Embedding quality (cosine similarity on temporal pairs)
|
||||
* - Task head accuracy (presence detection)
|
||||
* - Comparison table output
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/benchmark-ruvllm.js --model models/csi-ruvllm --data data/recordings/pretrain-*.csi.jsonl
|
||||
* node scripts/benchmark-ruvllm.js --model models/csi-ruvllm --data data/recordings/pretrain-*.csi.jsonl --samples 5000
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { parseArgs } = require('util');
|
||||
|
||||
// Resolve ruvllm from vendor tree
|
||||
const RUVLLM_PATH = path.resolve(__dirname, '..', 'vendor', 'ruvector', 'npm', 'packages', 'ruvllm', 'src');
|
||||
|
||||
const { cosineSimilarity } = require(path.join(RUVLLM_PATH, 'contrastive.js'));
|
||||
const { LoraAdapter } = require(path.join(RUVLLM_PATH, 'lora.js'));
|
||||
const { SafeTensorsReader } = require(path.join(RUVLLM_PATH, 'export.js'));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CLI
|
||||
// ---------------------------------------------------------------------------
|
||||
const { values: args } = parseArgs({
|
||||
options: {
|
||||
model: { type: 'string', short: 'm' },
|
||||
data: { type: 'string', short: 'd' },
|
||||
samples: { type: 'string', short: 'n', default: '1000' },
|
||||
warmup: { type: 'string', default: '100' },
|
||||
json: { type: 'boolean', default: false },
|
||||
},
|
||||
strict: true,
|
||||
});
|
||||
|
||||
if (!args.model || !args.data) {
|
||||
console.error('Usage: node scripts/benchmark-ruvllm.js --model <model-dir> --data <csi-jsonl>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const N_SAMPLES = parseInt(args.samples, 10);
|
||||
const N_WARMUP = parseInt(args.warmup, 10);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Data loading (reused from train-ruvllm.js)
|
||||
// ---------------------------------------------------------------------------
|
||||
function loadCsiData(filePath) {
|
||||
const features = [];
|
||||
const vitals = [];
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
for (const line of content.split('\n').filter(l => l.trim())) {
|
||||
try {
|
||||
const frame = JSON.parse(line);
|
||||
if (frame.type === 'feature') {
|
||||
features.push({ timestamp: frame.timestamp, nodeId: frame.node_id, features: frame.features });
|
||||
} else if (frame.type === 'vitals') {
|
||||
vitals.push({
|
||||
timestamp: frame.timestamp, nodeId: frame.node_id,
|
||||
presenceScore: frame.presence_score, motionEnergy: frame.motion_energy,
|
||||
breathingBpm: frame.breathing_bpm, heartrateBpm: frame.heartrate_bpm,
|
||||
});
|
||||
}
|
||||
} catch (_) { /* skip */ }
|
||||
}
|
||||
return { features, vitals };
|
||||
}
|
||||
|
||||
function resolveGlob(pattern) {
|
||||
if (!pattern.includes('*')) return fs.existsSync(pattern) ? [pattern] : [];
|
||||
const dir = path.dirname(pattern);
|
||||
const base = path.basename(pattern);
|
||||
const regex = new RegExp('^' + base.replace(/\*/g, '.*') + '$');
|
||||
if (!fs.existsSync(dir)) return [];
|
||||
return fs.readdirSync(dir).filter(f => regex.test(f)).map(f => path.join(dir, f));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CsiEncoder (same as training script — with BN and Xavier init)
|
||||
// ---------------------------------------------------------------------------
|
||||
class CsiEncoder {
|
||||
constructor(inputDim, hiddenDim, outputDim, seed = 42) {
|
||||
this.inputDim = inputDim;
|
||||
this.hiddenDim = hiddenDim;
|
||||
this.outputDim = outputDim;
|
||||
const rng = this._createRng(seed);
|
||||
this.w1 = this._initXavier(inputDim, hiddenDim, rng);
|
||||
this.b1 = new Float64Array(hiddenDim);
|
||||
this.w2 = this._initXavier(hiddenDim, outputDim, rng);
|
||||
this.b2 = new Float64Array(outputDim);
|
||||
|
||||
// Batch norm parameters
|
||||
this.bn1_gamma = new Float64Array(hiddenDim).fill(1.0);
|
||||
this.bn1_beta = new Float64Array(hiddenDim);
|
||||
this.bn1_runMean = new Float64Array(hiddenDim);
|
||||
this.bn1_runVar = new Float64Array(hiddenDim).fill(1.0);
|
||||
this.bn2_gamma = new Float64Array(outputDim).fill(1.0);
|
||||
this.bn2_beta = new Float64Array(outputDim);
|
||||
this.bn2_runMean = new Float64Array(outputDim);
|
||||
this.bn2_runVar = new Float64Array(outputDim).fill(1.0);
|
||||
this._bnEps = 1e-5;
|
||||
}
|
||||
|
||||
encode(input) {
|
||||
const hidden = new Float64Array(this.hiddenDim);
|
||||
for (let j = 0; j < this.hiddenDim; j++) {
|
||||
let sum = this.b1[j];
|
||||
for (let i = 0; i < this.inputDim; i++) sum += (input[i] || 0) * this.w1[i * this.hiddenDim + j];
|
||||
hidden[j] = sum;
|
||||
}
|
||||
// BN1 + ReLU
|
||||
for (let j = 0; j < this.hiddenDim; j++) {
|
||||
const normed = (hidden[j] - this.bn1_runMean[j]) / Math.sqrt(this.bn1_runVar[j] + this._bnEps);
|
||||
hidden[j] = Math.max(0, this.bn1_gamma[j] * normed + this.bn1_beta[j]);
|
||||
}
|
||||
const output = new Float64Array(this.outputDim);
|
||||
for (let j = 0; j < this.outputDim; j++) {
|
||||
let sum = this.b2[j];
|
||||
for (let i = 0; i < this.hiddenDim; i++) sum += hidden[i] * this.w2[i * this.outputDim + j];
|
||||
output[j] = sum;
|
||||
}
|
||||
// BN2
|
||||
for (let j = 0; j < this.outputDim; j++) {
|
||||
const normed = (output[j] - this.bn2_runMean[j]) / Math.sqrt(this.bn2_runVar[j] + this._bnEps);
|
||||
output[j] = this.bn2_gamma[j] * normed + this.bn2_beta[j];
|
||||
}
|
||||
// L2 normalize
|
||||
let norm = 0;
|
||||
for (let i = 0; i < output.length; i++) norm += output[i] * output[i];
|
||||
norm = Math.sqrt(norm) || 1;
|
||||
const result = new Array(this.outputDim);
|
||||
for (let i = 0; i < this.outputDim; i++) result[i] = output[i] / norm;
|
||||
return result;
|
||||
}
|
||||
|
||||
_createRng(seed) {
|
||||
let s = seed;
|
||||
return () => { s ^= s << 13; s ^= s >> 17; s ^= s << 5; return ((s >>> 0) / 4294967296) - 0.5; };
|
||||
}
|
||||
|
||||
_initXavier(rows, cols, rng) {
|
||||
const scale = Math.sqrt(2.0 / (rows + cols));
|
||||
const arr = new Float64Array(rows * cols);
|
||||
for (let i = 0; i < arr.length; i++) arr[i] = rng() * 2 * scale;
|
||||
return arr;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PresenceHead (same as training script)
|
||||
// ---------------------------------------------------------------------------
|
||||
class PresenceHead {
|
||||
constructor(inputDim, seed = 123) {
|
||||
this.inputDim = inputDim;
|
||||
const scale = Math.sqrt(2.0 / (inputDim + 1));
|
||||
this.weights = new Float64Array(inputDim);
|
||||
let s = seed;
|
||||
const nextRng = () => { s ^= s << 13; s ^= s >> 17; s ^= s << 5; return ((s >>> 0) / 4294967296) - 0.5; };
|
||||
for (let i = 0; i < inputDim; i++) this.weights[i] = nextRng() * 2 * scale;
|
||||
this.bias = 0;
|
||||
}
|
||||
|
||||
forward(embedding) {
|
||||
let z = this.bias;
|
||||
for (let i = 0; i < this.inputDim; i++) z += this.weights[i] * (embedding[i] || 0);
|
||||
return 1.0 / (1.0 + Math.exp(-z));
|
||||
}
|
||||
|
||||
loadWeights(saved) {
|
||||
if (saved.weights) this.weights = new Float64Array(saved.weights);
|
||||
if (typeof saved.bias === 'number') this.bias = saved.bias;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Quantization helpers (bit-packed — matches training script)
|
||||
// ---------------------------------------------------------------------------
|
||||
function quantizeWeights(weights, bits) {
|
||||
const maxVal = 2 ** bits - 1;
|
||||
let wMin = Infinity, wMax = -Infinity;
|
||||
for (let i = 0; i < weights.length; i++) {
|
||||
if (weights[i] < wMin) wMin = weights[i];
|
||||
if (weights[i] > wMax) wMax = weights[i];
|
||||
}
|
||||
const range = wMax - wMin || 1e-10;
|
||||
const scale = range / maxVal;
|
||||
const zeroPoint = Math.round(-wMin / scale);
|
||||
|
||||
const qValues = new Uint8Array(weights.length);
|
||||
for (let i = 0; i < weights.length; i++) {
|
||||
let q = Math.round((weights[i] - wMin) / scale);
|
||||
qValues[i] = Math.max(0, Math.min(maxVal, q));
|
||||
}
|
||||
|
||||
let packed;
|
||||
if (bits === 8) {
|
||||
packed = new Uint8Array(weights.length);
|
||||
for (let i = 0; i < weights.length; i++) packed[i] = qValues[i];
|
||||
} else if (bits === 4) {
|
||||
packed = new Uint8Array(Math.ceil(weights.length / 2));
|
||||
for (let i = 0; i < weights.length; i += 2) {
|
||||
const hi = qValues[i] & 0x0F;
|
||||
const lo = (i + 1 < weights.length) ? (qValues[i + 1] & 0x0F) : 0;
|
||||
packed[i >> 1] = (hi << 4) | lo;
|
||||
}
|
||||
} else if (bits === 2) {
|
||||
packed = new Uint8Array(Math.ceil(weights.length / 4));
|
||||
for (let i = 0; i < weights.length; i += 4) {
|
||||
let byte = 0;
|
||||
for (let k = 0; k < 4; k++) {
|
||||
const val = (i + k < weights.length) ? (qValues[i + k] & 0x03) : 0;
|
||||
byte |= val << (6 - k * 2);
|
||||
}
|
||||
packed[Math.floor(i / 4)] = byte;
|
||||
}
|
||||
} else {
|
||||
packed = new Uint8Array(weights.length);
|
||||
for (let i = 0; i < weights.length; i++) packed[i] = qValues[i];
|
||||
}
|
||||
|
||||
return { quantized: packed, scale, zeroPoint, bits, numWeights: weights.length,
|
||||
originalSize: weights.length * 4, quantizedSize: packed.length };
|
||||
}
|
||||
|
||||
function dequantizeWeights(packed, scale, zeroPoint, bits, numWeights) {
|
||||
const result = new Float32Array(numWeights);
|
||||
if (bits === 8) {
|
||||
for (let i = 0; i < numWeights; i++) result[i] = (packed[i] - zeroPoint) * scale;
|
||||
} else if (bits === 4) {
|
||||
for (let i = 0; i < numWeights; i++) {
|
||||
const byteIdx = i >> 1;
|
||||
const nibble = (i % 2 === 0) ? (packed[byteIdx] >> 4) & 0x0F : packed[byteIdx] & 0x0F;
|
||||
result[i] = (nibble - zeroPoint) * scale;
|
||||
}
|
||||
} else if (bits === 2) {
|
||||
for (let i = 0; i < numWeights; i++) {
|
||||
const byteIdx = Math.floor(i / 4);
|
||||
const shift = 6 - (i % 4) * 2;
|
||||
const val = (packed[byteIdx] >> shift) & 0x03;
|
||||
result[i] = (val - zeroPoint) * scale;
|
||||
}
|
||||
} else {
|
||||
for (let i = 0; i < numWeights; i++) result[i] = (packed[i] - zeroPoint) * scale;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Statistics helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
function percentile(arr, p) {
|
||||
const sorted = [...arr].sort((a, b) => a - b);
|
||||
const idx = Math.floor(sorted.length * p);
|
||||
return sorted[Math.min(idx, sorted.length - 1)];
|
||||
}
|
||||
|
||||
function mean(arr) {
|
||||
return arr.length > 0 ? arr.reduce((a, b) => a + b, 0) / arr.length : 0;
|
||||
}
|
||||
|
||||
function stddev(arr) {
|
||||
const m = mean(arr);
|
||||
return Math.sqrt(arr.reduce((s, x) => s + (x - m) ** 2, 0) / arr.length);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main benchmark
|
||||
// ---------------------------------------------------------------------------
|
||||
async function main() {
|
||||
console.log('=== WiFi-DensePose CSI Model Benchmark (ruvllm) ===\n');
|
||||
|
||||
// Load model
|
||||
const modelDir = args.model;
|
||||
const configPath = path.join(modelDir, 'config.json');
|
||||
const modelJsonPath = path.join(modelDir, 'model.json');
|
||||
|
||||
let modelConfig = {};
|
||||
if (fs.existsSync(configPath)) {
|
||||
modelConfig = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
||||
}
|
||||
console.log(`Model: ${modelConfig.name || 'unknown'} v${modelConfig.version || '?'}`);
|
||||
console.log(`Architecture: ${modelConfig.architecture || 'csi-encoder-8-64-128'}\n`);
|
||||
|
||||
// Determine dimensions from config or defaults
|
||||
const inputDim = modelConfig.custom?.inputDim || 8;
|
||||
const hiddenDim = modelConfig.custom?.hiddenDim || 64;
|
||||
const embeddingDim = modelConfig.custom?.embeddingDim || 128;
|
||||
|
||||
// Load encoder
|
||||
const encoder = new CsiEncoder(inputDim, hiddenDim, embeddingDim);
|
||||
|
||||
// Load SafeTensors if available — overwrite encoder weights
|
||||
// Load PresenceHead
|
||||
const presenceHead = new PresenceHead(embeddingDim);
|
||||
const presenceHeadPath = path.join(modelDir, 'presence-head.json');
|
||||
if (fs.existsSync(presenceHeadPath)) {
|
||||
try {
|
||||
presenceHead.loadWeights(JSON.parse(fs.readFileSync(presenceHeadPath, 'utf-8')));
|
||||
console.log('Loaded presence head weights.');
|
||||
} catch (e) {
|
||||
console.log(`WARN: Could not load presence head: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const safetensorsPath = path.join(modelDir, 'model.safetensors');
|
||||
if (fs.existsSync(safetensorsPath)) {
|
||||
try {
|
||||
const stBuffer = new Uint8Array(fs.readFileSync(safetensorsPath));
|
||||
const reader = new SafeTensorsReader(stBuffer);
|
||||
const w1 = reader.getTensor('encoder.w1');
|
||||
const b1 = reader.getTensor('encoder.b1');
|
||||
const w2 = reader.getTensor('encoder.w2');
|
||||
const b2 = reader.getTensor('encoder.b2');
|
||||
if (w1) encoder.w1 = new Float64Array(w1.data);
|
||||
if (b1) encoder.b1 = new Float64Array(b1.data);
|
||||
if (w2) encoder.w2 = new Float64Array(w2.data);
|
||||
if (b2) encoder.b2 = new Float64Array(b2.data);
|
||||
|
||||
// Load batch norm parameters
|
||||
const bn1g = reader.getTensor('encoder.bn1_gamma');
|
||||
const bn1b = reader.getTensor('encoder.bn1_beta');
|
||||
const bn1m = reader.getTensor('encoder.bn1_runMean');
|
||||
const bn1v = reader.getTensor('encoder.bn1_runVar');
|
||||
const bn2g = reader.getTensor('encoder.bn2_gamma');
|
||||
const bn2b = reader.getTensor('encoder.bn2_beta');
|
||||
const bn2m = reader.getTensor('encoder.bn2_runMean');
|
||||
const bn2v = reader.getTensor('encoder.bn2_runVar');
|
||||
if (bn1g) encoder.bn1_gamma = new Float64Array(bn1g.data);
|
||||
if (bn1b) encoder.bn1_beta = new Float64Array(bn1b.data);
|
||||
if (bn1m) encoder.bn1_runMean = new Float64Array(bn1m.data);
|
||||
if (bn1v) encoder.bn1_runVar = new Float64Array(bn1v.data);
|
||||
if (bn2g) encoder.bn2_gamma = new Float64Array(bn2g.data);
|
||||
if (bn2b) encoder.bn2_beta = new Float64Array(bn2b.data);
|
||||
if (bn2m) encoder.bn2_runMean = new Float64Array(bn2m.data);
|
||||
if (bn2v) encoder.bn2_runVar = new Float64Array(bn2v.data);
|
||||
|
||||
// Load presence head from SafeTensors if available
|
||||
const phW = reader.getTensor('presence_head.weights');
|
||||
const phB = reader.getTensor('presence_head.bias');
|
||||
if (phW) presenceHead.weights = new Float64Array(phW.data);
|
||||
if (phB) presenceHead.bias = phB.data[0];
|
||||
|
||||
console.log('Loaded encoder weights from SafeTensors.');
|
||||
} catch (e) {
|
||||
console.log(`WARN: Could not load SafeTensors: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Load LoRA adapter
|
||||
let adapter = new LoraAdapter({ rank: 4, alpha: 8, dropout: 0.0 }, embeddingDim, embeddingDim);
|
||||
const loraDir = path.join(modelDir, 'lora');
|
||||
if (fs.existsSync(loraDir)) {
|
||||
const loraFiles = fs.readdirSync(loraDir).filter(f => f.endsWith('.json'));
|
||||
if (loraFiles.length > 0) {
|
||||
try {
|
||||
adapter = LoraAdapter.fromJSON(fs.readFileSync(path.join(loraDir, loraFiles[0]), 'utf-8'));
|
||||
console.log(`Loaded LoRA adapter: ${loraFiles[0]}`);
|
||||
} catch (e) {
|
||||
console.log(`WARN: Could not load LoRA: ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load test data
|
||||
console.log('\nLoading test data...');
|
||||
const files = resolveGlob(args.data);
|
||||
if (files.length === 0) {
|
||||
console.error(`No data files found: ${args.data}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let features = [];
|
||||
let vitals = [];
|
||||
for (const file of files) {
|
||||
const d = loadCsiData(file);
|
||||
features = features.concat(d.features);
|
||||
vitals = vitals.concat(d.vitals);
|
||||
}
|
||||
console.log(`Loaded ${features.length} feature frames, ${vitals.length} vitals frames.\n`);
|
||||
|
||||
const testFeatures = features.slice(0, N_SAMPLES);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Benchmark 1: Inference latency
|
||||
// -----------------------------------------------------------------------
|
||||
console.log('--- Inference Latency ---');
|
||||
|
||||
// Warmup
|
||||
for (let i = 0; i < N_WARMUP && i < testFeatures.length; i++) {
|
||||
const emb = encoder.encode(testFeatures[i].features);
|
||||
adapter.forward(emb);
|
||||
}
|
||||
|
||||
const latencies = [];
|
||||
for (const f of testFeatures) {
|
||||
const start = process.hrtime.bigint();
|
||||
const emb = encoder.encode(f.features);
|
||||
adapter.forward(emb);
|
||||
const elapsed = Number(process.hrtime.bigint() - start) / 1e6;
|
||||
latencies.push(elapsed);
|
||||
}
|
||||
|
||||
const latMean = mean(latencies);
|
||||
const latStd = stddev(latencies);
|
||||
const latP50 = percentile(latencies, 0.50);
|
||||
const latP95 = percentile(latencies, 0.95);
|
||||
const latP99 = percentile(latencies, 0.99);
|
||||
const throughput = 1000 / latMean;
|
||||
|
||||
console.log(` Samples: ${latencies.length}`);
|
||||
console.log(` Mean: ${latMean.toFixed(3)} ms (+/- ${latStd.toFixed(3)})`);
|
||||
console.log(` P50: ${latP50.toFixed(3)} ms`);
|
||||
console.log(` P95: ${latP95.toFixed(3)} ms`);
|
||||
console.log(` P99: ${latP99.toFixed(3)} ms`);
|
||||
console.log(` Throughput: ${throughput.toFixed(0)} embeddings/sec`);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Benchmark 2: Batch throughput
|
||||
// -----------------------------------------------------------------------
|
||||
console.log('\n--- Batch Throughput ---');
|
||||
for (const batchSize of [1, 8, 32, 64]) {
|
||||
const batches = Math.min(50, Math.floor(testFeatures.length / batchSize));
|
||||
if (batches === 0) continue;
|
||||
|
||||
const batchStart = process.hrtime.bigint();
|
||||
for (let b = 0; b < batches; b++) {
|
||||
for (let i = 0; i < batchSize; i++) {
|
||||
const f = testFeatures[b * batchSize + i];
|
||||
const emb = encoder.encode(f.features);
|
||||
adapter.forward(emb);
|
||||
}
|
||||
}
|
||||
const batchElapsed = Number(process.hrtime.bigint() - batchStart) / 1e6;
|
||||
const batchThroughput = (batches * batchSize) / (batchElapsed / 1000);
|
||||
console.log(` Batch ${String(batchSize).padStart(3)}: ${batchThroughput.toFixed(0)} emb/sec (${batches} batches, ${batchElapsed.toFixed(1)}ms total)`);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Benchmark 3: Memory usage per quantization level
|
||||
// -----------------------------------------------------------------------
|
||||
console.log('\n--- Memory Usage by Quantization Level ---');
|
||||
const mergedWeights = adapter.merge();
|
||||
const flatWeights = new Float32Array(mergedWeights.flat());
|
||||
|
||||
console.log(' Bits | Size (KB) | Compression | RMSE | Quality Loss');
|
||||
console.log(' -----|-----------|-------------|----------|-------------');
|
||||
|
||||
const fp32Size = flatWeights.length * 4;
|
||||
console.log(` fp32 | ${(fp32Size / 1024).toFixed(1).padStart(9)} | ${' '.padStart(11)}1x | 0.000000 | 0.000%`);
|
||||
|
||||
for (const bits of [8, 4, 2]) {
|
||||
const qr = quantizeWeights(flatWeights, bits);
|
||||
const deq = dequantizeWeights(qr.quantized, qr.scale, qr.zeroPoint, bits, qr.numWeights);
|
||||
|
||||
let sumSqErr = 0;
|
||||
for (let i = 0; i < flatWeights.length; i++) {
|
||||
const diff = flatWeights[i] - deq[i];
|
||||
sumSqErr += diff * diff;
|
||||
}
|
||||
const rmse = Math.sqrt(sumSqErr / flatWeights.length);
|
||||
const compressionRatio = fp32Size / qr.quantizedSize;
|
||||
|
||||
// Measure quality loss via inference divergence on 100 samples
|
||||
let qualityDelta = 0;
|
||||
const qAdapter = adapter.clone();
|
||||
// Approximate: use the original adapter output as reference
|
||||
const nQual = Math.min(100, testFeatures.length);
|
||||
for (let i = 0; i < nQual; i++) {
|
||||
const emb = encoder.encode(testFeatures[i].features);
|
||||
const refOut = adapter.forward(emb);
|
||||
const qOut = qAdapter.forward(emb); // Same weights in JS, but rmse indicates real-world delta
|
||||
const sim = cosineSimilarity(refOut, qOut);
|
||||
qualityDelta += 1 - sim;
|
||||
}
|
||||
const avgQualityLoss = (qualityDelta / nQual) * 100;
|
||||
|
||||
console.log(` ${String(bits).padStart(4)} | ${(qr.quantizedSize / 1024).toFixed(1).padStart(9)} | ${compressionRatio.toFixed(1).padStart(11)}x | ${rmse.toFixed(6)} | ${avgQualityLoss.toFixed(3)}%`);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Benchmark 4: Embedding quality (cosine similarity on temporal pairs)
|
||||
// -----------------------------------------------------------------------
|
||||
console.log('\n--- Embedding Quality (Temporal Pairs) ---');
|
||||
const positivePairs = [];
|
||||
const negativePairs = [];
|
||||
|
||||
for (let i = 0; i < Math.min(features.length - 1, 500); i++) {
|
||||
const f1 = features[i];
|
||||
const f2 = features[i + 1];
|
||||
const timeDiff = Math.abs(f2.timestamp - f1.timestamp);
|
||||
|
||||
const emb1 = encoder.encode(f1.features);
|
||||
const out1 = adapter.forward(emb1);
|
||||
const emb2 = encoder.encode(f2.features);
|
||||
const out2 = adapter.forward(emb2);
|
||||
const sim = cosineSimilarity(out1, out2);
|
||||
|
||||
if (timeDiff <= 1.0 && f1.nodeId === f2.nodeId) {
|
||||
positivePairs.push(sim);
|
||||
} else if (timeDiff >= 10.0) { // Reduced from 30s to match training threshold
|
||||
negativePairs.push(sim);
|
||||
}
|
||||
}
|
||||
|
||||
// Also test cross-node pairs
|
||||
const crossNodePos = [];
|
||||
const node1 = features.filter(f => f.nodeId === 1);
|
||||
const node2 = features.filter(f => f.nodeId === 2);
|
||||
for (let i = 0; i < Math.min(node1.length, node2.length, 200); i++) {
|
||||
const f1 = node1[i];
|
||||
// Find closest node2 frame in time
|
||||
let best = null, bestDist = Infinity;
|
||||
for (const f2 of node2) {
|
||||
const dist = Math.abs(f2.timestamp - f1.timestamp);
|
||||
if (dist < bestDist) { bestDist = dist; best = f2; }
|
||||
}
|
||||
if (best && bestDist < 1.0) {
|
||||
const emb1 = encoder.encode(f1.features);
|
||||
const emb2 = encoder.encode(best.features);
|
||||
crossNodePos.push(cosineSimilarity(adapter.forward(emb1), adapter.forward(emb2)));
|
||||
}
|
||||
}
|
||||
|
||||
console.log(` Same-node temporal positive (dt < 1s): mean=${mean(positivePairs).toFixed(4)}, std=${stddev(positivePairs).toFixed(4)}, n=${positivePairs.length}`);
|
||||
console.log(` Temporal negative (dt > 30s): mean=${mean(negativePairs).toFixed(4)}, std=${stddev(negativePairs).toFixed(4)}, n=${negativePairs.length}`);
|
||||
console.log(` Cross-node positive (dt < 1s): mean=${mean(crossNodePos).toFixed(4)}, std=${stddev(crossNodePos).toFixed(4)}, n=${crossNodePos.length}`);
|
||||
|
||||
if (positivePairs.length > 0 && negativePairs.length > 0) {
|
||||
const margin = mean(positivePairs) - mean(negativePairs);
|
||||
console.log(` Separation margin (pos - neg): ${margin.toFixed(4)} ${margin > 0.1 ? '(GOOD)' : margin > 0 ? '(OK)' : '(POOR)'}`);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Benchmark 5: Task head accuracy (presence detection)
|
||||
// -----------------------------------------------------------------------
|
||||
console.log('\n--- Task Head Accuracy (Presence Detection) ---');
|
||||
let tp = 0, fp = 0, tn = 0, fn = 0;
|
||||
|
||||
for (const f of testFeatures) {
|
||||
let nearestVitals = null;
|
||||
let bestDist = Infinity;
|
||||
for (const v of vitals) {
|
||||
if (v.nodeId !== f.nodeId) continue;
|
||||
const dist = Math.abs(v.timestamp - f.timestamp);
|
||||
if (dist < bestDist) { bestDist = dist; nearestVitals = v; }
|
||||
}
|
||||
if (!nearestVitals || bestDist > 2.0) continue;
|
||||
|
||||
const groundTruth = nearestVitals.presenceScore > 0.3 ? 1 : 0;
|
||||
const emb = encoder.encode(f.features);
|
||||
// Use trained PresenceHead for presence prediction instead of raw embedding[0]
|
||||
const presScore = presenceHead.forward(emb);
|
||||
const predicted = presScore > 0.5 ? 1 : 0;
|
||||
|
||||
if (predicted === 1 && groundTruth === 1) tp++;
|
||||
else if (predicted === 1 && groundTruth === 0) fp++;
|
||||
else if (predicted === 0 && groundTruth === 0) tn++;
|
||||
else fn++;
|
||||
}
|
||||
|
||||
const total = tp + fp + tn + fn;
|
||||
if (total > 0) {
|
||||
const accuracy = (tp + tn) / total;
|
||||
const precision = tp + fp > 0 ? tp / (tp + fp) : 0;
|
||||
const recall = tp + fn > 0 ? tp / (tp + fn) : 0;
|
||||
const f1 = precision + recall > 0 ? 2 * precision * recall / (precision + recall) : 0;
|
||||
console.log(` Samples: ${total}`);
|
||||
console.log(` Accuracy: ${(accuracy * 100).toFixed(1)}%`);
|
||||
console.log(` Precision: ${(precision * 100).toFixed(1)}%`);
|
||||
console.log(` Recall: ${(recall * 100).toFixed(1)}%`);
|
||||
console.log(` F1 Score: ${(f1 * 100).toFixed(1)}%`);
|
||||
console.log(` Confusion: TP=${tp} FP=${fp} TN=${tn} FN=${fn}`);
|
||||
} else {
|
||||
console.log(' No labeled data available for accuracy measurement.');
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Comparison table
|
||||
// -----------------------------------------------------------------------
|
||||
console.log('\n--- Comparison Table: ruvllm vs Alternatives ---');
|
||||
console.log('');
|
||||
console.log(' Framework | Inference (ms) | Throughput | Dependencies | Quantization | Edge Deploy');
|
||||
console.log(' ---------------|----------------|------------|--------------|--------------|------------');
|
||||
console.log(` ruvllm (this) | ${latMean.toFixed(3).padStart(14)} | ${throughput.toFixed(0).padStart(7)} e/s | Node.js only | 2/4/8-bit | ESP32, Pi`);
|
||||
console.log(` PyTorch | ${(latMean * 3).toFixed(3).padStart(14)} | ${(throughput / 3).toFixed(0).padStart(7)} e/s | Python+CUDA | INT8/FP16 | No`);
|
||||
console.log(` ONNX Runtime | ${(latMean * 1.5).toFixed(3).padStart(14)} | ${(throughput / 1.5).toFixed(0).padStart(7)} e/s | C++ runtime | INT8 | ARM`);
|
||||
console.log(` TensorFlow Lite| ${(latMean * 2).toFixed(3).padStart(14)} | ${(throughput / 2).toFixed(0).padStart(7)} e/s | C++ runtime | INT8/FP16 | ARM, ESP`);
|
||||
console.log('');
|
||||
console.log(' Note: PyTorch/ONNX/TFLite figures are estimated relative to ruvllm measured results.');
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// JSON output
|
||||
// -----------------------------------------------------------------------
|
||||
if (args.json) {
|
||||
const results = {
|
||||
model: modelConfig.name || 'unknown',
|
||||
timestamp: new Date().toISOString(),
|
||||
latency: { mean: latMean, std: latStd, p50: latP50, p95: latP95, p99: latP99 },
|
||||
throughput: { embeddingsPerSec: throughput },
|
||||
quality: {
|
||||
positiveSimMean: mean(positivePairs),
|
||||
negativeSimMean: mean(negativePairs),
|
||||
crossNodeSimMean: mean(crossNodePos),
|
||||
separationMargin: mean(positivePairs) - mean(negativePairs),
|
||||
},
|
||||
accuracy: total > 0 ? { accuracy: (tp + tn) / total, precision: tp / (tp + fp || 1), recall: tp / (tp + fn || 1) } : null,
|
||||
};
|
||||
const jsonPath = path.join(modelDir, 'benchmark-results.json');
|
||||
fs.writeFileSync(jsonPath, JSON.stringify(results, null, 2));
|
||||
console.log(`\nJSON results saved to: ${jsonPath}`);
|
||||
}
|
||||
|
||||
console.log('\n=== Benchmark Complete ===');
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error('Benchmark failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,305 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* WiFlow Pose Estimation Benchmark
|
||||
*
|
||||
* Measures performance of the WiFlow architecture across dimensions:
|
||||
* - Forward pass latency (mean, P50, P95, P99) per batch size
|
||||
* - Parameter count per stage
|
||||
* - FLOPs estimate per stage
|
||||
* - Memory usage (fp32, int8, int4, int2)
|
||||
* - PCK@20 on test data (if labeled data available)
|
||||
* - Bone length violation rate
|
||||
* - Comparison with simple CsiEncoder from train-ruvllm.js
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/benchmark-wiflow.js
|
||||
* node scripts/benchmark-wiflow.js --model models/wiflow-v1
|
||||
* node scripts/benchmark-wiflow.js --data data/recordings/pretrain-*.csi.jsonl --samples 500
|
||||
*
|
||||
* ADR: docs/adr/ADR-072-wiflow-architecture.md
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { parseArgs } = require('util');
|
||||
|
||||
const {
|
||||
WiFlowModel,
|
||||
COCO_KEYPOINTS,
|
||||
BONE_CONNECTIONS,
|
||||
BONE_LENGTH_PRIORS,
|
||||
createRng,
|
||||
gaussianRng,
|
||||
estimateFLOPs,
|
||||
} = require(path.join(__dirname, 'wiflow-model.js'));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CLI
|
||||
// ---------------------------------------------------------------------------
|
||||
const { values: args } = parseArgs({
|
||||
options: {
|
||||
model: { type: 'string', short: 'm' },
|
||||
data: { type: 'string', short: 'd' },
|
||||
samples: { type: 'string', short: 'n', default: '200' },
|
||||
warmup: { type: 'string', default: '20' },
|
||||
json: { type: 'boolean', default: false },
|
||||
'subcarriers': { type: 'string', default: '128' },
|
||||
'time-steps': { type: 'string', default: '20' },
|
||||
},
|
||||
strict: true,
|
||||
});
|
||||
|
||||
const N_SAMPLES = parseInt(args.samples, 10);
|
||||
const N_WARMUP = parseInt(args.warmup, 10);
|
||||
const SUBCARRIERS = parseInt(args['subcarriers'], 10);
|
||||
const TIME_STEPS = parseInt(args['time-steps'], 10);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Statistics helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
function percentile(arr, p) {
|
||||
const sorted = [...arr].sort((a, b) => a - b);
|
||||
const idx = Math.floor(sorted.length * p);
|
||||
return sorted[Math.min(idx, sorted.length - 1)];
|
||||
}
|
||||
function mean(arr) { return arr.length > 0 ? arr.reduce((a, b) => a + b, 0) / arr.length : 0; }
|
||||
function stddev(arr) { const m = mean(arr); return Math.sqrt(arr.reduce((s, x) => s + (x - m) ** 2, 0) / arr.length); }
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main benchmark
|
||||
// ---------------------------------------------------------------------------
|
||||
async function main() {
|
||||
console.log('=== WiFlow Pose Estimation Benchmark ===\n');
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 1. Model initialization
|
||||
// -----------------------------------------------------------------------
|
||||
console.log('[1/6] Initializing model...');
|
||||
const model = new WiFlowModel({
|
||||
inputChannels: SUBCARRIERS,
|
||||
timeSteps: TIME_STEPS,
|
||||
numKeypoints: 17,
|
||||
numHeads: 8,
|
||||
seed: 42,
|
||||
});
|
||||
|
||||
// Load trained weights if available
|
||||
if (args.model) {
|
||||
const safetensorsPath = path.join(args.model, 'model.safetensors');
|
||||
if (fs.existsSync(safetensorsPath)) {
|
||||
console.log(` Loading weights from: ${args.model}`);
|
||||
// Load from JSON export (easier than parsing safetensors in pure JS)
|
||||
const jsonPath = path.join(args.model, 'model.json');
|
||||
if (fs.existsSync(jsonPath)) {
|
||||
console.log(' (Loaded from JSON export)');
|
||||
}
|
||||
} else {
|
||||
console.log(` No trained model at ${args.model}, using random initialization.`);
|
||||
}
|
||||
}
|
||||
|
||||
model.setTraining(false);
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 2. Parameter count
|
||||
// -----------------------------------------------------------------------
|
||||
console.log('\n[2/6] Parameter count by stage:');
|
||||
const breakdown = model.paramBreakdown();
|
||||
const stages = [
|
||||
['TCN (Temporal Conv)', breakdown.tcn],
|
||||
['Spatial Encoder (Asymmetric Conv)', breakdown.spatialEncoder],
|
||||
['Axial Self-Attention', breakdown.axialAttention],
|
||||
['Pose Decoder', breakdown.decoder],
|
||||
['TOTAL', breakdown.total],
|
||||
];
|
||||
|
||||
console.log(' ' + '-'.repeat(55));
|
||||
console.log(' ' + 'Stage'.padEnd(38) + 'Parameters'.padStart(15));
|
||||
console.log(' ' + '-'.repeat(55));
|
||||
for (const [name, count] of stages) {
|
||||
const pct = name === 'TOTAL' ? '' : ` (${(count / breakdown.total * 100).toFixed(1)}%)`;
|
||||
console.log(` ${name.padEnd(38)}${count.toLocaleString().padStart(15)}${pct}`);
|
||||
}
|
||||
console.log(' ' + '-'.repeat(55));
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 3. FLOPs estimate
|
||||
// -----------------------------------------------------------------------
|
||||
console.log('\n[3/6] FLOPs estimate per stage:');
|
||||
const flops = estimateFLOPs({ inputChannels: SUBCARRIERS, timeSteps: TIME_STEPS });
|
||||
const flopStages = [
|
||||
['TCN', flops.tcn],
|
||||
['Spatial Encoder', flops.spatialEncoder],
|
||||
['Axial Attention', flops.axialAttention],
|
||||
['Decoder', flops.decoder],
|
||||
['TOTAL', flops.total],
|
||||
];
|
||||
|
||||
console.log(' ' + '-'.repeat(55));
|
||||
console.log(' ' + 'Stage'.padEnd(38) + 'FLOPs'.padStart(15));
|
||||
console.log(' ' + '-'.repeat(55));
|
||||
for (const [name, count] of flopStages) {
|
||||
const formatted = count > 1e6 ? `${(count / 1e6).toFixed(1)}M` : `${(count / 1e3).toFixed(1)}K`;
|
||||
const pct = name === 'TOTAL' ? '' : ` (${(count / flops.total * 100).toFixed(1)}%)`;
|
||||
console.log(` ${name.padEnd(38)}${formatted.padStart(15)}${pct}`);
|
||||
}
|
||||
console.log(' ' + '-'.repeat(55));
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 4. Memory usage
|
||||
// -----------------------------------------------------------------------
|
||||
console.log('\n[4/6] Memory usage by quantization level:');
|
||||
const totalParams = breakdown.total;
|
||||
const memoryTable = [
|
||||
['fp32', totalParams * 4],
|
||||
['fp16', totalParams * 2],
|
||||
['int8', totalParams],
|
||||
['int4', Math.ceil(totalParams / 2)],
|
||||
['int2', Math.ceil(totalParams / 4)],
|
||||
];
|
||||
|
||||
console.log(' ' + '-'.repeat(45));
|
||||
console.log(' ' + 'Format'.padEnd(15) + 'Size (KB)'.padStart(15) + 'Size (MB)'.padStart(15));
|
||||
console.log(' ' + '-'.repeat(45));
|
||||
for (const [fmt, bytes] of memoryTable) {
|
||||
const kb = (bytes / 1024).toFixed(1);
|
||||
const mb = (bytes / 1024 / 1024).toFixed(2);
|
||||
console.log(` ${fmt.padEnd(15)}${kb.padStart(15)}${mb.padStart(15)}`);
|
||||
}
|
||||
console.log(' ' + '-'.repeat(45));
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 5. Forward pass latency
|
||||
// -----------------------------------------------------------------------
|
||||
console.log('\n[5/6] Forward pass latency:');
|
||||
const rng = createRng(42);
|
||||
const inputSize = SUBCARRIERS * TIME_STEPS;
|
||||
|
||||
for (const batchSize of [1, 4, 8]) {
|
||||
// Generate random inputs
|
||||
const inputs = [];
|
||||
for (let b = 0; b < batchSize; b++) {
|
||||
const input = new Float32Array(inputSize);
|
||||
for (let i = 0; i < inputSize; i++) input[i] = (rng() - 0.5) * 2;
|
||||
inputs.push(input);
|
||||
}
|
||||
|
||||
// Warmup
|
||||
for (let i = 0; i < N_WARMUP; i++) {
|
||||
for (const inp of inputs) model.forward(inp);
|
||||
}
|
||||
|
||||
// Measure
|
||||
const latencies = [];
|
||||
for (let i = 0; i < N_SAMPLES; i++) {
|
||||
const t0 = performance.now();
|
||||
for (const inp of inputs) model.forward(inp);
|
||||
latencies.push(performance.now() - t0);
|
||||
}
|
||||
|
||||
const meanLat = mean(latencies);
|
||||
const p50 = percentile(latencies, 0.5);
|
||||
const p95 = percentile(latencies, 0.95);
|
||||
const p99 = percentile(latencies, 0.99);
|
||||
const throughput = (batchSize * 1000 / meanLat).toFixed(1);
|
||||
|
||||
console.log(` Batch size ${batchSize}:`);
|
||||
console.log(` Mean: ${meanLat.toFixed(2)}ms P50: ${p50.toFixed(2)}ms P95: ${p95.toFixed(2)}ms P99: ${p99.toFixed(2)}ms`);
|
||||
console.log(` Throughput: ${throughput} inferences/sec`);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// 6. Output quality analysis
|
||||
// -----------------------------------------------------------------------
|
||||
console.log('\n[6/6] Output quality analysis:');
|
||||
|
||||
// Test with random inputs and check output properties
|
||||
const outputs = [];
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const input = new Float32Array(inputSize);
|
||||
for (let j = 0; j < inputSize; j++) input[j] = (rng() - 0.5) * 2;
|
||||
outputs.push(model.forward(input));
|
||||
}
|
||||
|
||||
// Check output range [0, 1]
|
||||
let outOfRange = 0;
|
||||
for (const out of outputs) {
|
||||
for (let i = 0; i < out.length; i++) {
|
||||
if (out[i] < 0 || out[i] > 1) outOfRange++;
|
||||
}
|
||||
}
|
||||
console.log(` Output range violations: ${outOfRange} / ${outputs.length * 34} (${(outOfRange / (outputs.length * 34) * 100).toFixed(1)}%)`);
|
||||
|
||||
// Bone violation rate
|
||||
let totalViolations = 0;
|
||||
for (const out of outputs) {
|
||||
const { violationRate } = WiFlowModel.boneViolations(out, 0.5);
|
||||
totalViolations += violationRate;
|
||||
}
|
||||
console.log(` Mean bone violation rate (50% tolerance): ${(totalViolations / outputs.length * 100).toFixed(1)}%`);
|
||||
|
||||
// Output variance (should be non-zero for different inputs)
|
||||
const varPerKeypoint = new Float32Array(34);
|
||||
const meanPerKeypoint = new Float32Array(34);
|
||||
for (const out of outputs) {
|
||||
for (let i = 0; i < 34; i++) meanPerKeypoint[i] += out[i];
|
||||
}
|
||||
for (let i = 0; i < 34; i++) meanPerKeypoint[i] /= outputs.length;
|
||||
for (const out of outputs) {
|
||||
for (let i = 0; i < 34; i++) varPerKeypoint[i] += (out[i] - meanPerKeypoint[i]) ** 2;
|
||||
}
|
||||
for (let i = 0; i < 34; i++) varPerKeypoint[i] /= outputs.length;
|
||||
|
||||
const meanVar = mean(Array.from(varPerKeypoint));
|
||||
console.log(` Mean output variance: ${meanVar.toFixed(6)} (should be > 0 for discriminative model)`);
|
||||
|
||||
// Keypoint spatial distribution
|
||||
console.log('\n Mean keypoint positions (across 100 random inputs):');
|
||||
for (let k = 0; k < 17; k++) {
|
||||
const x = meanPerKeypoint[k * 2].toFixed(3);
|
||||
const y = meanPerKeypoint[k * 2 + 1].toFixed(3);
|
||||
console.log(` ${COCO_KEYPOINTS[k].padEnd(18)} x=${x} y=${y}`);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Comparison with simple encoder
|
||||
// -----------------------------------------------------------------------
|
||||
console.log('\n--- Comparison: WiFlow vs Simple CsiEncoder ---');
|
||||
console.log(' ' + '-'.repeat(55));
|
||||
console.log(' ' + 'Metric'.padEnd(30) + 'WiFlow'.padStart(12) + 'CsiEncoder'.padStart(12));
|
||||
console.log(' ' + '-'.repeat(55));
|
||||
console.log(` ${'Parameters'.padEnd(30)}${breakdown.total.toLocaleString().padStart(12)}${'9,344'.padStart(12)}`);
|
||||
console.log(` ${'Input dimension'.padEnd(30)}${`${SUBCARRIERS}x${TIME_STEPS}`.padStart(12)}${'8'.padStart(12)}`);
|
||||
console.log(` ${'Output'.padEnd(30)}${'17x2 pose'.padStart(12)}${'128-d emb'.padStart(12)}`);
|
||||
console.log(` ${'Temporal modeling'.padEnd(30)}${'TCN (d1-8)'.padStart(12)}${'None'.padStart(12)}`);
|
||||
console.log(` ${'Spatial modeling'.padEnd(30)}${'AsymConv'.padStart(12)}${'None'.padStart(12)}`);
|
||||
console.log(` ${'Attention'.padEnd(30)}${'Axial 8-head'.padStart(12)}${'None'.padStart(12)}`);
|
||||
console.log(` ${'Bone constraints'.padEnd(30)}${'Yes (14)'.padStart(12)}${'N/A'.padStart(12)}`);
|
||||
console.log(` ${'FP32 size (MB)'.padEnd(30)}${(totalParams * 4 / 1024 / 1024).toFixed(2).padStart(12)}${'0.04'.padStart(12)}`);
|
||||
console.log(` ${'INT8 size (MB)'.padEnd(30)}${(totalParams / 1024 / 1024).toFixed(2).padStart(12)}${'0.01'.padStart(12)}`);
|
||||
console.log(' ' + '-'.repeat(55));
|
||||
|
||||
// JSON output
|
||||
if (args.json) {
|
||||
const results = {
|
||||
model: 'wiflow',
|
||||
params: breakdown,
|
||||
flops,
|
||||
memory: Object.fromEntries(memoryTable),
|
||||
comparison: {
|
||||
wiflow_params: breakdown.total,
|
||||
csiencoder_params: 9344,
|
||||
},
|
||||
};
|
||||
console.log('\n' + JSON.stringify(results, null, 2));
|
||||
}
|
||||
|
||||
console.log('\n=== Benchmark complete ===');
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error('Benchmark failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,483 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
WiFi-DensePose Training Data Collector
|
||||
|
||||
Listens on UDP for CSI data from ESP32 nodes and records to .csi.jsonl
|
||||
files compatible with the Rust training pipeline (MmFiDataset / CsiDataset).
|
||||
|
||||
Supports two packet formats:
|
||||
- ADR-069 feature vectors (magic 0xC5110003, 48 bytes) — 8-dim pre-extracted
|
||||
- ADR-018 raw CSI frames (magic 0xC5110001, variable) — full subcarrier data
|
||||
|
||||
Usage:
|
||||
# Interactive — prompts for scenario labels
|
||||
python scripts/collect-training-data.py --port 5006
|
||||
|
||||
# Scripted — fixed label, 60s per recording
|
||||
python scripts/collect-training-data.py --port 5006 --label walking --duration 60
|
||||
|
||||
# Multiple scenarios in sequence
|
||||
python scripts/collect-training-data.py --port 5006 --scenarios walking,standing,sitting --duration 30
|
||||
|
||||
# Dual-node collection (two ESP32s on different ports)
|
||||
python scripts/collect-training-data.py --port 5005 --port2 5006 --label walking
|
||||
|
||||
# Generate manifest only from existing recordings
|
||||
python scripts/collect-training-data.py --manifest-only --output-dir data/recordings
|
||||
|
||||
Prerequisites:
|
||||
- ESP32 nodes streaming CSI on UDP (see firmware/esp32-csi-node)
|
||||
- Python 3.9+
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import socket
|
||||
import struct
|
||||
import sys
|
||||
import time
|
||||
import signal
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||
datefmt="%H:%M:%S",
|
||||
)
|
||||
log = logging.getLogger("collect-data")
|
||||
|
||||
# ── Packet formats (must match firmware) ─────────────────────────────────────
|
||||
|
||||
# ADR-018 raw CSI frame header
|
||||
MAGIC_CSI_RAW = 0xC5110001
|
||||
# ADR-069 feature vector packet
|
||||
MAGIC_FEATURES = 0xC5110003
|
||||
FEATURE_PKT_FMT = "<IBBHq8f"
|
||||
FEATURE_PKT_SIZE = struct.calcsize(FEATURE_PKT_FMT) # 48 bytes
|
||||
|
||||
# Raw CSI header: magic(4) + node_id(1) + antenna_cfg(1) + n_sub(2) + rssi(1) + noise(1) + channel(1) + reserved(1) + timestamp_ms(4)
|
||||
RAW_CSI_HDR_FMT = "<IBBHbbBxI"
|
||||
RAW_CSI_HDR_SIZE = struct.calcsize(RAW_CSI_HDR_FMT) # 16 bytes
|
||||
|
||||
|
||||
# ── Packet parsing ───────────────────────────────────────────────────────────
|
||||
|
||||
def parse_packet(data: bytes) -> Optional[dict]:
|
||||
"""Parse a UDP packet into a frame dict, or None if unrecognized."""
|
||||
if len(data) < 4:
|
||||
return None
|
||||
|
||||
magic = struct.unpack_from("<I", data)[0]
|
||||
|
||||
if magic == MAGIC_FEATURES and len(data) >= FEATURE_PKT_SIZE:
|
||||
return _parse_feature_packet(data)
|
||||
elif magic == MAGIC_CSI_RAW and len(data) >= RAW_CSI_HDR_SIZE:
|
||||
return _parse_raw_csi_packet(data)
|
||||
else:
|
||||
return None
|
||||
|
||||
|
||||
def _parse_feature_packet(data: bytes) -> Optional[dict]:
|
||||
"""Parse ADR-069 feature vector packet (48 bytes)."""
|
||||
try:
|
||||
magic, node_id, _, seq, ts_us, *features = struct.unpack_from(FEATURE_PKT_FMT, data)
|
||||
except struct.error:
|
||||
return None
|
||||
|
||||
if magic != MAGIC_FEATURES:
|
||||
return None
|
||||
|
||||
# Reject NaN/inf
|
||||
import math
|
||||
if any(math.isnan(f) or math.isinf(f) for f in features):
|
||||
return None
|
||||
|
||||
return {
|
||||
"type": "features",
|
||||
"node_id": node_id,
|
||||
"seq": seq,
|
||||
"timestamp_us": ts_us,
|
||||
"timestamp": ts_us / 1_000_000.0,
|
||||
"features": features,
|
||||
"subcarriers": features, # Use features as subcarrier proxy for training
|
||||
"rssi": 0.0,
|
||||
"noise_floor": 0.0,
|
||||
}
|
||||
|
||||
|
||||
def _parse_raw_csi_packet(data: bytes) -> Optional[dict]:
|
||||
"""Parse ADR-018 raw CSI frame with full subcarrier data."""
|
||||
try:
|
||||
magic, node_id, ant_cfg, n_sub, rssi, noise, channel, ts_ms = struct.unpack_from(
|
||||
RAW_CSI_HDR_FMT, data
|
||||
)
|
||||
except struct.error:
|
||||
return None
|
||||
|
||||
if magic != MAGIC_CSI_RAW:
|
||||
return None
|
||||
|
||||
# Subcarrier data follows header as int16 I/Q pairs
|
||||
payload_offset = RAW_CSI_HDR_SIZE
|
||||
expected_bytes = n_sub * 2 * 2 # n_sub * (I + Q) * int16
|
||||
if len(data) < payload_offset + expected_bytes:
|
||||
return None
|
||||
|
||||
iq_data = struct.unpack_from(f"<{n_sub * 2}h", data, payload_offset)
|
||||
# Convert I/Q pairs to amplitude
|
||||
subcarriers = []
|
||||
for i in range(0, len(iq_data), 2):
|
||||
real, imag = iq_data[i], iq_data[i + 1]
|
||||
amplitude = (real ** 2 + imag ** 2) ** 0.5
|
||||
subcarriers.append(amplitude)
|
||||
|
||||
return {
|
||||
"type": "raw_csi",
|
||||
"node_id": node_id,
|
||||
"antenna_config": ant_cfg,
|
||||
"n_subcarriers": n_sub,
|
||||
"channel": channel,
|
||||
"timestamp": ts_ms / 1000.0,
|
||||
"subcarriers": subcarriers,
|
||||
"rssi": float(rssi),
|
||||
"noise_floor": float(noise),
|
||||
}
|
||||
|
||||
|
||||
# ── JSONL recording ──────────────────────────────────────────────────────────
|
||||
|
||||
class CsiRecorder:
|
||||
"""Records CSI frames to .csi.jsonl files compatible with the Rust pipeline."""
|
||||
|
||||
def __init__(self, output_dir: str, session_name: str, label: Optional[str] = None):
|
||||
self.output_dir = Path(output_dir)
|
||||
self.output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
ts = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
|
||||
safe_name = session_name.replace(" ", "_").replace("/", "_")
|
||||
self.session_id = f"{safe_name}-{ts}"
|
||||
self.label = label
|
||||
self.file_path = self.output_dir / f"{self.session_id}.csi.jsonl"
|
||||
self.meta_path = self.output_dir / f"{self.session_id}.csi.meta.json"
|
||||
self.frame_count = 0
|
||||
self.start_time = time.time()
|
||||
self.started_at = datetime.now(timezone.utc).isoformat()
|
||||
self._file = None
|
||||
|
||||
def open(self):
|
||||
self._file = open(self.file_path, "a", encoding="utf-8")
|
||||
log.info(f"Recording to: {self.file_path}")
|
||||
|
||||
def write_frame(self, frame: dict):
|
||||
"""Write a single frame as a JSONL line."""
|
||||
if self._file is None:
|
||||
return
|
||||
|
||||
record = {
|
||||
"timestamp": frame.get("timestamp", time.time()),
|
||||
"subcarriers": frame.get("subcarriers", []),
|
||||
"rssi": frame.get("rssi", 0.0),
|
||||
"noise_floor": frame.get("noise_floor", 0.0),
|
||||
"features": {
|
||||
k: v for k, v in frame.items()
|
||||
if k not in ("timestamp", "subcarriers", "rssi", "noise_floor", "type")
|
||||
},
|
||||
}
|
||||
|
||||
line = json.dumps(record, separators=(",", ":"))
|
||||
self._file.write(line + "\n")
|
||||
self.frame_count += 1
|
||||
|
||||
if self.frame_count % 500 == 0:
|
||||
self._file.flush()
|
||||
|
||||
def close(self) -> dict:
|
||||
"""Close the recording and write metadata. Returns session info."""
|
||||
if self._file:
|
||||
self._file.flush()
|
||||
self._file.close()
|
||||
self._file = None
|
||||
|
||||
ended_at = datetime.now(timezone.utc).isoformat()
|
||||
elapsed = time.time() - self.start_time
|
||||
file_size = self.file_path.stat().st_size if self.file_path.exists() else 0
|
||||
|
||||
meta = {
|
||||
"id": self.session_id,
|
||||
"name": self.session_id,
|
||||
"label": self.label,
|
||||
"started_at": self.started_at,
|
||||
"ended_at": ended_at,
|
||||
"duration_secs": round(elapsed, 2),
|
||||
"frame_count": self.frame_count,
|
||||
"file_size_bytes": file_size,
|
||||
"file_path": str(self.file_path),
|
||||
"fps": round(self.frame_count / elapsed, 1) if elapsed > 0 else 0,
|
||||
}
|
||||
|
||||
with open(self.meta_path, "w", encoding="utf-8") as f:
|
||||
json.dump(meta, f, indent=2)
|
||||
|
||||
log.info(
|
||||
f"Recording stopped: {self.frame_count} frames in {elapsed:.1f}s "
|
||||
f"({meta['fps']} fps, {file_size / 1024:.1f} KB)"
|
||||
)
|
||||
return meta
|
||||
|
||||
|
||||
# ── Manifest generation ──────────────────────────────────────────────────────
|
||||
|
||||
def generate_manifest(output_dir: str) -> dict:
|
||||
"""Scan recordings directory and generate a dataset manifest JSON."""
|
||||
rec_dir = Path(output_dir)
|
||||
sessions = []
|
||||
|
||||
for meta_file in sorted(rec_dir.glob("*.csi.meta.json")):
|
||||
try:
|
||||
with open(meta_file, "r") as f:
|
||||
meta = json.load(f)
|
||||
sessions.append(meta)
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
log.warning(f"Skipping {meta_file}: {e}")
|
||||
|
||||
# Aggregate stats
|
||||
total_frames = sum(s.get("frame_count", 0) for s in sessions)
|
||||
total_bytes = sum(s.get("file_size_bytes", 0) for s in sessions)
|
||||
labels = sorted(set(s.get("label", "unlabeled") or "unlabeled" for s in sessions))
|
||||
|
||||
manifest = {
|
||||
"dataset": "wifi-densepose-csi",
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||
"directory": str(rec_dir),
|
||||
"num_sessions": len(sessions),
|
||||
"total_frames": total_frames,
|
||||
"total_size_bytes": total_bytes,
|
||||
"total_size_mb": round(total_bytes / (1024 * 1024), 2),
|
||||
"labels": labels,
|
||||
"sessions": sessions,
|
||||
}
|
||||
|
||||
manifest_path = rec_dir / "manifest.json"
|
||||
with open(manifest_path, "w", encoding="utf-8") as f:
|
||||
json.dump(manifest, f, indent=2)
|
||||
|
||||
log.info(
|
||||
f"Manifest: {len(sessions)} sessions, {total_frames} frames, "
|
||||
f"{manifest['total_size_mb']} MB, labels={labels}"
|
||||
)
|
||||
log.info(f"Written to: {manifest_path}")
|
||||
return manifest
|
||||
|
||||
|
||||
# ── UDP listener ─────────────────────────────────────────────────────────────
|
||||
|
||||
def collect_session(
|
||||
port: int,
|
||||
port2: Optional[int],
|
||||
output_dir: str,
|
||||
label: str,
|
||||
duration: float,
|
||||
session_name: Optional[str] = None,
|
||||
) -> dict:
|
||||
"""Run a single collection session. Returns session metadata."""
|
||||
name = session_name or label or "session"
|
||||
recorder = CsiRecorder(output_dir, name, label)
|
||||
recorder.open()
|
||||
|
||||
# Bind primary socket
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
sock.bind(("0.0.0.0", port))
|
||||
sock.settimeout(1.0)
|
||||
sockets = [sock]
|
||||
|
||||
# Bind secondary socket if specified
|
||||
if port2:
|
||||
sock2 = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
sock2.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
sock2.bind(("0.0.0.0", port2))
|
||||
sock2.settimeout(0.1)
|
||||
sockets.append(sock2)
|
||||
|
||||
log.info(
|
||||
f"Collecting '{label}' for {duration}s on port(s) "
|
||||
f"{port}{f', {port2}' if port2 else ''}"
|
||||
)
|
||||
|
||||
start = time.time()
|
||||
dropped = 0
|
||||
|
||||
try:
|
||||
while time.time() - start < duration:
|
||||
for s in sockets:
|
||||
try:
|
||||
data, addr = s.recvfrom(4096)
|
||||
except socket.timeout:
|
||||
continue
|
||||
|
||||
frame = parse_packet(data)
|
||||
if frame:
|
||||
recorder.write_frame(frame)
|
||||
else:
|
||||
dropped += 1
|
||||
|
||||
# Progress update every 5s
|
||||
elapsed = time.time() - start
|
||||
if recorder.frame_count > 0 and int(elapsed) % 5 == 0 and int(elapsed) > 0:
|
||||
remaining = duration - elapsed
|
||||
if remaining > 0 and int(elapsed * 10) % 50 == 0:
|
||||
log.info(
|
||||
f" {recorder.frame_count} frames collected, "
|
||||
f"{remaining:.0f}s remaining..."
|
||||
)
|
||||
except KeyboardInterrupt:
|
||||
log.info("Interrupted by user.")
|
||||
finally:
|
||||
for s in sockets:
|
||||
s.close()
|
||||
|
||||
if dropped > 0:
|
||||
log.warning(f" {dropped} unrecognized packets dropped")
|
||||
|
||||
return recorder.close()
|
||||
|
||||
|
||||
# ── Main ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Collect CSI training data from ESP32 nodes via UDP",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
# Interactive label input
|
||||
python scripts/collect-training-data.py --port 5006
|
||||
|
||||
# Fixed label, 60 seconds
|
||||
python scripts/collect-training-data.py --port 5006 --label walking --duration 60
|
||||
|
||||
# Multiple scenarios
|
||||
python scripts/collect-training-data.py --port 5006 --scenarios walking,standing,sitting --duration 30
|
||||
|
||||
# Dual ESP32 nodes
|
||||
python scripts/collect-training-data.py --port 5005 --port2 5006 --label test
|
||||
|
||||
# Generate manifest from existing recordings
|
||||
python scripts/collect-training-data.py --manifest-only
|
||||
""",
|
||||
)
|
||||
|
||||
parser.add_argument("--port", type=int, default=5006, help="Primary UDP port (default: 5006)")
|
||||
parser.add_argument("--port2", type=int, default=None, help="Secondary UDP port for dual-node")
|
||||
parser.add_argument("--output-dir", default="data/recordings", help="Output directory (default: data/recordings)")
|
||||
parser.add_argument("--label", default=None, help="Activity label for the recording")
|
||||
parser.add_argument("--duration", type=float, default=30.0, help="Recording duration in seconds (default: 30)")
|
||||
parser.add_argument("--scenarios", default=None, help="Comma-separated list of scenarios to record sequentially")
|
||||
parser.add_argument("--pause", type=float, default=5.0, help="Pause between scenarios in seconds (default: 5)")
|
||||
parser.add_argument("--manifest-only", action="store_true", help="Only generate manifest from existing recordings")
|
||||
parser.add_argument("--repeats", type=int, default=1, help="Number of repeats per scenario (default: 1)")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Manifest-only mode
|
||||
if args.manifest_only:
|
||||
generate_manifest(args.output_dir)
|
||||
return
|
||||
|
||||
# Collect scenarios
|
||||
all_sessions = []
|
||||
|
||||
if args.scenarios:
|
||||
# Multi-scenario sequential collection
|
||||
scenarios = [s.strip() for s in args.scenarios.split(",") if s.strip()]
|
||||
total = len(scenarios) * args.repeats
|
||||
idx = 0
|
||||
|
||||
for repeat in range(args.repeats):
|
||||
for scenario in scenarios:
|
||||
idx += 1
|
||||
print(f"\n{'='*60}")
|
||||
print(f" Scenario {idx}/{total}: '{scenario}' (repeat {repeat+1}/{args.repeats})")
|
||||
print(f" Duration: {args.duration}s")
|
||||
print(f"{'='*60}")
|
||||
|
||||
if idx > 1:
|
||||
print(f" Starting in {args.pause}s... (get into position)")
|
||||
time.sleep(args.pause)
|
||||
|
||||
meta = collect_session(
|
||||
port=args.port,
|
||||
port2=args.port2,
|
||||
output_dir=args.output_dir,
|
||||
label=scenario,
|
||||
duration=args.duration,
|
||||
session_name=f"{scenario}_r{repeat+1:02d}",
|
||||
)
|
||||
all_sessions.append(meta)
|
||||
|
||||
elif args.label:
|
||||
# Single labeled recording
|
||||
meta = collect_session(
|
||||
port=args.port,
|
||||
port2=args.port2,
|
||||
output_dir=args.output_dir,
|
||||
label=args.label,
|
||||
duration=args.duration,
|
||||
)
|
||||
all_sessions.append(meta)
|
||||
|
||||
else:
|
||||
# Interactive mode — prompt for labels
|
||||
print("\nInteractive data collection mode.")
|
||||
print("Type a label for each recording, or 'q' to quit.\n")
|
||||
|
||||
while True:
|
||||
label = input("Label (or 'q' to quit): ").strip()
|
||||
if label.lower() in ("q", "quit", "exit"):
|
||||
break
|
||||
if not label:
|
||||
print(" Empty label. Try again.")
|
||||
continue
|
||||
|
||||
duration = args.duration
|
||||
try:
|
||||
dur_input = input(f"Duration in seconds [{duration}]: ").strip()
|
||||
if dur_input:
|
||||
duration = float(dur_input)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
print(f" Recording '{label}' for {duration}s — starting now...")
|
||||
meta = collect_session(
|
||||
port=args.port,
|
||||
port2=args.port2,
|
||||
output_dir=args.output_dir,
|
||||
label=label,
|
||||
duration=duration,
|
||||
)
|
||||
all_sessions.append(meta)
|
||||
print()
|
||||
|
||||
# Generate manifest
|
||||
if all_sessions:
|
||||
print(f"\nCollected {len(all_sessions)} session(s).")
|
||||
manifest = generate_manifest(args.output_dir)
|
||||
|
||||
total_frames = sum(s.get("frame_count", 0) for s in all_sessions)
|
||||
print(f"\nSummary:")
|
||||
print(f" Sessions: {len(all_sessions)}")
|
||||
print(f" Total frames: {total_frames}")
|
||||
print(f" Output: {args.output_dir}/")
|
||||
print(f" Manifest: {args.output_dir}/manifest.json")
|
||||
else:
|
||||
print("No sessions recorded.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,469 +0,0 @@
|
||||
#!/bin/bash
|
||||
# ==============================================================================
|
||||
# GCloud GPU Training Script for WiFi-DensePose
|
||||
# ==============================================================================
|
||||
#
|
||||
# Creates a GCloud VM with GPU, runs the Rust training pipeline, downloads
|
||||
# the trained model artifacts, and tears down the VM to avoid ongoing costs.
|
||||
#
|
||||
# Usage:
|
||||
# bash scripts/gcloud-train.sh [OPTIONS]
|
||||
#
|
||||
# Options:
|
||||
# --gpu l4|a100|h100 GPU type (default: l4)
|
||||
# --zone ZONE GCloud zone (default: us-central1-a)
|
||||
# --hours N Max VM lifetime in hours (default: 2)
|
||||
# --config FILE Training config JSON (default: scripts/training-config-sweep.json entry 0)
|
||||
# --data-dir DIR Local data directory to upload (default: data/recordings)
|
||||
# --dry-run Run smoke test with synthetic data
|
||||
# --sweep Run full hyperparameter sweep (all configs)
|
||||
# --keep-vm Do not delete VM after training
|
||||
# --instance NAME Custom VM instance name
|
||||
#
|
||||
# Prerequisites:
|
||||
# - gcloud CLI authenticated: gcloud auth login
|
||||
# - Project set: gcloud config set project cognitum-20260110
|
||||
# - Quota for GPUs in the selected zone
|
||||
#
|
||||
# Cost estimates:
|
||||
# L4 (~$0.80/hr) — good for prototyping and small sweeps
|
||||
# A100 40GB (~$3.60/hr) — full training runs
|
||||
# H100 80GB (~$11.00/hr) — large batch / fast iteration
|
||||
# ==============================================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ── Defaults ──────────────────────────────────────────────────────────────────
|
||||
|
||||
PROJECT="cognitum-20260110"
|
||||
GPU_TYPE="l4"
|
||||
ZONE="us-central1-a"
|
||||
MAX_HOURS=2
|
||||
CONFIG_FILE=""
|
||||
DATA_DIR="data/recordings"
|
||||
DRY_RUN=false
|
||||
SWEEP=false
|
||||
KEEP_VM=false
|
||||
INSTANCE_NAME=""
|
||||
REPO_URL="https://github.com/ruvnet/wifi-densepose.git"
|
||||
BRANCH="main"
|
||||
|
||||
# ── Parse arguments ───────────────────────────────────────────────────────────
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--gpu) GPU_TYPE="$2"; shift 2 ;;
|
||||
--zone) ZONE="$2"; shift 2 ;;
|
||||
--hours) MAX_HOURS="$2"; shift 2 ;;
|
||||
--config) CONFIG_FILE="$2"; shift 2 ;;
|
||||
--data-dir) DATA_DIR="$2"; shift 2 ;;
|
||||
--dry-run) DRY_RUN=true; shift ;;
|
||||
--sweep) SWEEP=true; shift ;;
|
||||
--keep-vm) KEEP_VM=true; shift ;;
|
||||
--instance) INSTANCE_NAME="$2"; shift 2 ;;
|
||||
--branch) BRANCH="$2"; shift 2 ;;
|
||||
-h|--help)
|
||||
head -35 "$0" | tail -30
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "ERROR: Unknown option: $1"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ── GPU configuration map ────────────────────────────────────────────────────
|
||||
|
||||
declare -A GPU_ACCELERATOR=(
|
||||
[l4]="nvidia-l4"
|
||||
[a100]="nvidia-tesla-a100"
|
||||
[h100]="nvidia-h100-80gb"
|
||||
)
|
||||
|
||||
declare -A GPU_MACHINE_TYPE=(
|
||||
[l4]="g2-standard-8"
|
||||
[a100]="a2-highgpu-1g"
|
||||
[h100]="a3-highgpu-1g"
|
||||
)
|
||||
|
||||
declare -A GPU_BOOT_DISK=(
|
||||
[l4]="200"
|
||||
[a100]="300"
|
||||
[h100]="300"
|
||||
)
|
||||
|
||||
if [[ -z "${GPU_ACCELERATOR[$GPU_TYPE]+x}" ]]; then
|
||||
echo "ERROR: Unknown GPU type '$GPU_TYPE'. Choose: l4, a100, h100"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ACCELERATOR="${GPU_ACCELERATOR[$GPU_TYPE]}"
|
||||
MACHINE_TYPE="${GPU_MACHINE_TYPE[$GPU_TYPE]}"
|
||||
BOOT_DISK_GB="${GPU_BOOT_DISK[$GPU_TYPE]}"
|
||||
|
||||
# ── Instance naming ──────────────────────────────────────────────────────────
|
||||
|
||||
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
|
||||
if [[ -z "$INSTANCE_NAME" ]]; then
|
||||
INSTANCE_NAME="wdp-train-${GPU_TYPE}-${TIMESTAMP}"
|
||||
fi
|
||||
|
||||
# ── Announce plan ────────────────────────────────────────────────────────────
|
||||
|
||||
echo "============================================================"
|
||||
echo " WiFi-DensePose GCloud GPU Training"
|
||||
echo "============================================================"
|
||||
echo " Project: $PROJECT"
|
||||
echo " Instance: $INSTANCE_NAME"
|
||||
echo " Zone: $ZONE"
|
||||
echo " GPU: $GPU_TYPE ($ACCELERATOR)"
|
||||
echo " Machine: $MACHINE_TYPE"
|
||||
echo " Boot disk: ${BOOT_DISK_GB}GB"
|
||||
echo " Max runtime: ${MAX_HOURS}h"
|
||||
echo " Data dir: $DATA_DIR"
|
||||
echo " Dry run: $DRY_RUN"
|
||||
echo " Sweep: $SWEEP"
|
||||
echo " Branch: $BRANCH"
|
||||
echo "============================================================"
|
||||
echo ""
|
||||
|
||||
# ── Verify gcloud auth ──────────────────────────────────────────────────────
|
||||
|
||||
if ! gcloud auth list --filter=status:ACTIVE --format="value(account)" 2>/dev/null | head -1 | grep -q '@'; then
|
||||
echo "ERROR: No active gcloud account. Run: gcloud auth login"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
gcloud config set project "$PROJECT" --quiet
|
||||
|
||||
# ── Build startup script ─────────────────────────────────────────────────────
|
||||
|
||||
STARTUP_SCRIPT=$(cat <<'STARTUP_EOF'
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
exec > /var/log/wdp-setup.log 2>&1
|
||||
|
||||
echo "=== WiFi-DensePose GPU VM Setup ==="
|
||||
echo "Started: $(date)"
|
||||
|
||||
# Wait for GPU driver
|
||||
echo "Waiting for NVIDIA driver..."
|
||||
for i in $(seq 1 60); do
|
||||
if nvidia-smi &>/dev/null; then
|
||||
echo "GPU ready after ${i}s"
|
||||
nvidia-smi
|
||||
break
|
||||
fi
|
||||
sleep 5
|
||||
done
|
||||
|
||||
if ! nvidia-smi &>/dev/null; then
|
||||
echo "ERROR: GPU driver not available after 300s"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Install Rust toolchain
|
||||
echo "Installing Rust toolchain..."
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
|
||||
source "$HOME/.cargo/env"
|
||||
rustc --version
|
||||
cargo --version
|
||||
|
||||
# Install system dependencies
|
||||
echo "Installing system dependencies..."
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq pkg-config libssl-dev cmake clang
|
||||
|
||||
# Find libtorch from the Deep Learning VM's PyTorch installation
|
||||
echo "Locating libtorch..."
|
||||
PYTORCH_LIB=$(python3 -c "import torch; print(torch.__path__[0] + '/lib')" 2>/dev/null || echo "")
|
||||
if [[ -n "$PYTORCH_LIB" && -d "$PYTORCH_LIB" ]]; then
|
||||
export LIBTORCH="$PYTORCH_LIB"
|
||||
export LD_LIBRARY_PATH="${LIBTORCH}:${LD_LIBRARY_PATH:-}"
|
||||
echo "Found libtorch at: $LIBTORCH"
|
||||
else
|
||||
echo "WARNING: PyTorch not found in system Python. Installing via pip..."
|
||||
pip3 install torch --index-url https://download.pytorch.org/whl/cu121
|
||||
PYTORCH_LIB=$(python3 -c "import torch; print(torch.__path__[0] + '/lib')")
|
||||
export LIBTORCH="$PYTORCH_LIB"
|
||||
export LD_LIBRARY_PATH="${LIBTORCH}:${LD_LIBRARY_PATH:-}"
|
||||
fi
|
||||
|
||||
# Persist env vars
|
||||
cat >> /etc/environment <<ENV_VARS
|
||||
LIBTORCH=$LIBTORCH
|
||||
LD_LIBRARY_PATH=$LIBTORCH:\$LD_LIBRARY_PATH
|
||||
PATH=$HOME/.cargo/bin:\$PATH
|
||||
ENV_VARS
|
||||
|
||||
echo "=== Setup complete: $(date) ==="
|
||||
touch /tmp/wdp-setup-done
|
||||
STARTUP_EOF
|
||||
)
|
||||
|
||||
# ── Step 1: Create the VM ────────────────────────────────────────────────────
|
||||
|
||||
echo "[1/7] Creating VM instance: $INSTANCE_NAME ..."
|
||||
|
||||
gcloud compute instances create "$INSTANCE_NAME" \
|
||||
--project="$PROJECT" \
|
||||
--zone="$ZONE" \
|
||||
--machine-type="$MACHINE_TYPE" \
|
||||
--accelerator="type=$ACCELERATOR,count=1" \
|
||||
--image-family="common-cu121-ubuntu-2204" \
|
||||
--image-project="deeplearning-platform-release" \
|
||||
--boot-disk-size="${BOOT_DISK_GB}GB" \
|
||||
--boot-disk-type="pd-ssd" \
|
||||
--maintenance-policy=TERMINATE \
|
||||
--metadata="install-nvidia-driver=True" \
|
||||
--metadata-from-file="startup-script=<(echo "$STARTUP_SCRIPT")" \
|
||||
--scopes="default,storage-rw" \
|
||||
--labels="purpose=wdp-training,gpu=${GPU_TYPE}" \
|
||||
--quiet
|
||||
|
||||
echo " VM created. Waiting for startup script to complete..."
|
||||
|
||||
# ── Step 2: Wait for setup ───────────────────────────────────────────────────
|
||||
|
||||
echo "[2/7] Waiting for setup to complete (GPU driver + Rust toolchain)..."
|
||||
|
||||
for i in $(seq 1 60); do
|
||||
if gcloud compute ssh "$INSTANCE_NAME" --zone="$ZONE" --command="test -f /tmp/wdp-setup-done" --quiet 2>/dev/null; then
|
||||
echo " Setup complete after $((i * 15))s"
|
||||
break
|
||||
fi
|
||||
if [[ $i -eq 60 ]]; then
|
||||
echo "ERROR: Setup timed out after 15 minutes."
|
||||
echo "Check logs: gcloud compute ssh $INSTANCE_NAME --zone=$ZONE --command='cat /var/log/wdp-setup.log'"
|
||||
if [[ "$KEEP_VM" == "false" ]]; then
|
||||
echo "Cleaning up VM..."
|
||||
gcloud compute instances delete "$INSTANCE_NAME" --zone="$ZONE" --quiet
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
sleep 15
|
||||
done
|
||||
|
||||
# ── Step 3: Clone repo and build ─────────────────────────────────────────────
|
||||
|
||||
echo "[3/7] Cloning repository and building training binary..."
|
||||
|
||||
gcloud compute ssh "$INSTANCE_NAME" --zone="$ZONE" --command="$(cat <<CLONE_EOF
|
||||
set -euo pipefail
|
||||
source \$HOME/.cargo/env
|
||||
|
||||
# Clone the repo
|
||||
if [[ ! -d ~/wifi-densepose ]]; then
|
||||
git clone --depth 1 --branch "$BRANCH" "$REPO_URL" ~/wifi-densepose
|
||||
fi
|
||||
|
||||
# Set libtorch environment
|
||||
export LIBTORCH=\$(python3 -c "import torch; print(torch.__path__[0] + '/lib')")
|
||||
export LD_LIBRARY_PATH="\${LIBTORCH}:\${LD_LIBRARY_PATH:-}"
|
||||
|
||||
# Build the training binary with tch-backend
|
||||
cd ~/wifi-densepose/rust-port/wifi-densepose-rs
|
||||
echo "Building with LIBTORCH=\$LIBTORCH ..."
|
||||
cargo build --release --features tch-backend --bin train 2>&1 | tail -5
|
||||
|
||||
echo "Build complete."
|
||||
ls -lh target/release/train
|
||||
CLONE_EOF
|
||||
)"
|
||||
|
||||
# ── Step 4: Upload training data ─────────────────────────────────────────────
|
||||
|
||||
echo "[4/7] Uploading training data..."
|
||||
|
||||
if [[ -d "$DATA_DIR" ]] && [[ "$(ls -A "$DATA_DIR" 2>/dev/null)" ]]; then
|
||||
# Create a tarball of the data directory
|
||||
DATA_TAR="/tmp/wdp-training-data-${TIMESTAMP}.tar.gz"
|
||||
tar czf "$DATA_TAR" -C "$(dirname "$DATA_DIR")" "$(basename "$DATA_DIR")"
|
||||
DATA_SIZE=$(du -h "$DATA_TAR" | cut -f1)
|
||||
echo " Uploading ${DATA_SIZE} of training data..."
|
||||
|
||||
gcloud compute scp "$DATA_TAR" "${INSTANCE_NAME}:~/training-data.tar.gz" --zone="$ZONE" --quiet
|
||||
gcloud compute ssh "$INSTANCE_NAME" --zone="$ZONE" --command="
|
||||
mkdir -p ~/wifi-densepose/data
|
||||
tar xzf ~/training-data.tar.gz -C ~/wifi-densepose/data/
|
||||
echo 'Data extracted:'
|
||||
find ~/wifi-densepose/data -name '*.jsonl' -o -name '*.csi.jsonl' | head -20
|
||||
"
|
||||
rm -f "$DATA_TAR"
|
||||
else
|
||||
echo " No local data at '$DATA_DIR'. Training will use --dry-run or MM-Fi."
|
||||
if [[ "$DRY_RUN" == "false" && "$SWEEP" == "false" ]]; then
|
||||
echo " WARNING: No data and --dry-run not set. Forcing --dry-run."
|
||||
DRY_RUN=true
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Step 5: Upload config and run training ────────────────────────────────────
|
||||
|
||||
echo "[5/7] Running training..."
|
||||
|
||||
# Upload sweep config if doing a sweep
|
||||
if [[ "$SWEEP" == "true" ]]; then
|
||||
SWEEP_FILE="scripts/training-config-sweep.json"
|
||||
if [[ -f "$SWEEP_FILE" ]]; then
|
||||
gcloud compute scp "$SWEEP_FILE" "${INSTANCE_NAME}:~/sweep-configs.json" --zone="$ZONE" --quiet
|
||||
else
|
||||
echo "ERROR: Sweep config not found at $SWEEP_FILE"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Upload single config if specified
|
||||
if [[ -n "$CONFIG_FILE" ]]; then
|
||||
gcloud compute scp "$CONFIG_FILE" "${INSTANCE_NAME}:~/train-config.json" --zone="$ZONE" --quiet
|
||||
fi
|
||||
|
||||
# Build the training command
|
||||
TRAIN_CMD_BASE="
|
||||
set -euo pipefail
|
||||
source \$HOME/.cargo/env
|
||||
export LIBTORCH=\$(python3 -c \"import torch; print(torch.__path__[0] + '/lib')\")
|
||||
export LD_LIBRARY_PATH=\"\${LIBTORCH}:\${LD_LIBRARY_PATH:-}\"
|
||||
cd ~/wifi-densepose/rust-port/wifi-densepose-rs
|
||||
|
||||
# Set auto-shutdown timer (safety net)
|
||||
sudo shutdown -P +$((MAX_HOURS * 60)) &
|
||||
|
||||
TRAIN_BIN=./target/release/train
|
||||
"
|
||||
|
||||
if [[ "$SWEEP" == "true" ]]; then
|
||||
# Run all configs in the sweep file
|
||||
gcloud compute ssh "$INSTANCE_NAME" --zone="$ZONE" --command="$(cat <<SWEEP_EOF
|
||||
$TRAIN_CMD_BASE
|
||||
|
||||
echo "=== Hyperparameter Sweep ==="
|
||||
SWEEP_FILE=~/sweep-configs.json
|
||||
NUM_CONFIGS=\$(python3 -c "import json; print(len(json.load(open('\$SWEEP_FILE'))['configs']))")
|
||||
echo "Running \$NUM_CONFIGS configurations..."
|
||||
|
||||
mkdir -p ~/results
|
||||
|
||||
for i in \$(seq 0 \$((NUM_CONFIGS - 1))); do
|
||||
echo ""
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo " Config \$((i+1)) / \$NUM_CONFIGS"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
|
||||
# Extract single config to temp file
|
||||
python3 -c "
|
||||
import json, sys
|
||||
sweep = json.load(open('\$SWEEP_FILE'))
|
||||
cfg = sweep['configs'][\$i]
|
||||
# Merge with base config
|
||||
base = sweep.get('base', {})
|
||||
merged = {**base, **cfg}
|
||||
# Set checkpoint dir per config
|
||||
merged['checkpoint_dir'] = f'checkpoints/sweep_{i:02d}'
|
||||
merged['log_dir'] = f'logs/sweep_{i:02d}'
|
||||
json.dump(merged, open('/tmp/sweep_config_\${i}.json', 'w'), indent=2)
|
||||
print(f\"Config \${i}: lr={merged.get('learning_rate', '?')}, bs={merged.get('batch_size', '?')}, bb={merged.get('backbone_channels', '?')}\")
|
||||
"
|
||||
|
||||
START_TIME=\$(date +%s)
|
||||
|
||||
\$TRAIN_BIN --config /tmp/sweep_config_\${i}.json --cuda $( [[ "$DRY_RUN" == "true" ]] && echo "--dry-run" ) 2>&1 | tee ~/results/sweep_\${i}.log || true
|
||||
|
||||
END_TIME=\$(date +%s)
|
||||
ELAPSED=\$(( END_TIME - START_TIME ))
|
||||
echo " Completed in \${ELAPSED}s"
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "=== Sweep Complete ==="
|
||||
echo "Results in ~/results/"
|
||||
ls -lh ~/results/
|
||||
SWEEP_EOF
|
||||
)"
|
||||
elif [[ -n "$CONFIG_FILE" ]]; then
|
||||
# Single config run
|
||||
gcloud compute ssh "$INSTANCE_NAME" --zone="$ZONE" --command="$(cat <<SINGLE_EOF
|
||||
$TRAIN_CMD_BASE
|
||||
echo "=== Training with custom config ==="
|
||||
\$TRAIN_BIN --config ~/train-config.json --cuda $( [[ "$DRY_RUN" == "true" ]] && echo "--dry-run" ) 2>&1 | tee ~/train.log
|
||||
SINGLE_EOF
|
||||
)"
|
||||
else
|
||||
# Default config run
|
||||
gcloud compute ssh "$INSTANCE_NAME" --zone="$ZONE" --command="$(cat <<DEFAULT_EOF
|
||||
$TRAIN_CMD_BASE
|
||||
echo "=== Training with default config ==="
|
||||
\$TRAIN_BIN --cuda $( [[ "$DRY_RUN" == "true" ]] && echo "--dry-run --dry-run-samples 256" ) 2>&1 | tee ~/train.log
|
||||
DEFAULT_EOF
|
||||
)"
|
||||
fi
|
||||
|
||||
# ── Step 6: Download results ─────────────────────────────────────────────────
|
||||
|
||||
echo "[6/7] Downloading trained model artifacts..."
|
||||
|
||||
LOCAL_RESULTS="training-results/${INSTANCE_NAME}"
|
||||
mkdir -p "$LOCAL_RESULTS"
|
||||
|
||||
# Package results on the VM
|
||||
gcloud compute ssh "$INSTANCE_NAME" --zone="$ZONE" --command="
|
||||
cd ~/wifi-densepose/rust-port/wifi-densepose-rs
|
||||
tar czf ~/training-artifacts.tar.gz \
|
||||
checkpoints/ \
|
||||
logs/ \
|
||||
2>/dev/null || true
|
||||
|
||||
# Also grab sweep results if they exist
|
||||
if [[ -d ~/results ]]; then
|
||||
tar czf ~/sweep-results.tar.gz -C ~ results/ 2>/dev/null || true
|
||||
fi
|
||||
|
||||
ls -lh ~/training-artifacts.tar.gz ~/sweep-results.tar.gz 2>/dev/null || true
|
||||
"
|
||||
|
||||
# Download artifacts
|
||||
gcloud compute scp "${INSTANCE_NAME}:~/training-artifacts.tar.gz" \
|
||||
"${LOCAL_RESULTS}/training-artifacts.tar.gz" --zone="$ZONE" --quiet 2>/dev/null || true
|
||||
|
||||
if [[ "$SWEEP" == "true" ]]; then
|
||||
gcloud compute scp "${INSTANCE_NAME}:~/sweep-results.tar.gz" \
|
||||
"${LOCAL_RESULTS}/sweep-results.tar.gz" --zone="$ZONE" --quiet 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Download training log
|
||||
gcloud compute scp "${INSTANCE_NAME}:~/train.log" \
|
||||
"${LOCAL_RESULTS}/train.log" --zone="$ZONE" --quiet 2>/dev/null || true
|
||||
|
||||
# Extract locally
|
||||
if [[ -f "${LOCAL_RESULTS}/training-artifacts.tar.gz" ]]; then
|
||||
tar xzf "${LOCAL_RESULTS}/training-artifacts.tar.gz" -C "$LOCAL_RESULTS/"
|
||||
echo " Artifacts extracted to: $LOCAL_RESULTS/"
|
||||
find "$LOCAL_RESULTS" -name "*.pt" -o -name "*.onnx" -o -name "*.rvf" 2>/dev/null | head -20
|
||||
fi
|
||||
|
||||
# ── Step 7: Cleanup ──────────────────────────────────────────────────────────
|
||||
|
||||
if [[ "$KEEP_VM" == "true" ]]; then
|
||||
echo "[7/7] Keeping VM alive (--keep-vm). Remember to delete it manually:"
|
||||
echo " gcloud compute instances delete $INSTANCE_NAME --zone=$ZONE --quiet"
|
||||
echo " SSH: gcloud compute ssh $INSTANCE_NAME --zone=$ZONE"
|
||||
else
|
||||
echo "[7/7] Deleting VM to avoid ongoing costs..."
|
||||
gcloud compute instances delete "$INSTANCE_NAME" --zone="$ZONE" --quiet
|
||||
echo " VM deleted."
|
||||
fi
|
||||
|
||||
# ── Summary ──────────────────────────────────────────────────────────────────
|
||||
|
||||
echo ""
|
||||
echo "============================================================"
|
||||
echo " Training Complete"
|
||||
echo "============================================================"
|
||||
echo " Results: $LOCAL_RESULTS/"
|
||||
echo " GPU: $GPU_TYPE ($ZONE)"
|
||||
echo " Instance: $INSTANCE_NAME"
|
||||
if [[ "$KEEP_VM" == "true" ]]; then
|
||||
echo " VM: STILL RUNNING (delete manually!)"
|
||||
fi
|
||||
echo "============================================================"
|
||||
@@ -1,82 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
echo "=== WiFi-DensePose Mac Mini M4 Pro Training Pipeline ==="
|
||||
echo "Host: $(hostname) | $(sysctl -n hw.ncpu 2>/dev/null || nproc) cores | $(sysctl -n hw.memsize 2>/dev/null | awk '{printf "%.0f GB", $1/1073741824}' || free -h | awk '/Mem:/{print $2}')"
|
||||
echo ""
|
||||
|
||||
REPO_DIR="${HOME}/Projects/wifi-densepose"
|
||||
WINDOWS_HOST="100.102.238.73" # Tailscale IP of Windows machine
|
||||
|
||||
# Step 1: Clone or update repo
|
||||
echo "[1/7] Setting up repository..."
|
||||
if [ -d "$REPO_DIR/.git" ]; then
|
||||
cd "$REPO_DIR" && git pull origin main
|
||||
else
|
||||
git clone https://github.com/ruvnet/RuView.git "$REPO_DIR"
|
||||
cd "$REPO_DIR"
|
||||
fi
|
||||
|
||||
# Step 2: Install Node.js if needed
|
||||
echo "[2/7] Checking Node.js..."
|
||||
if ! command -v node &>/dev/null; then
|
||||
echo "Installing Node.js via Homebrew..."
|
||||
brew install node
|
||||
fi
|
||||
echo "Node $(node --version)"
|
||||
|
||||
# Step 3: Copy training data from Windows via Tailscale
|
||||
echo "[3/7] Copying training data from Windows machine..."
|
||||
mkdir -p data/recordings
|
||||
scp -o ConnectTimeout=5 "ruv@${WINDOWS_HOST}:Projects/wifi-densepose/data/recordings/pretrain-*.csi.jsonl" data/recordings/ 2>/dev/null || {
|
||||
echo " Could not reach Windows machine. Checking for local data..."
|
||||
if ls data/recordings/pretrain-*.csi.jsonl &>/dev/null; then
|
||||
echo " Found local training data."
|
||||
else
|
||||
echo " ERROR: No training data found. Run collect-training-data.py on Windows first."
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
echo " Data: $(wc -l data/recordings/pretrain-*.csi.jsonl | tail -1)"
|
||||
|
||||
# Step 4: Run enhanced training (larger model, more epochs)
|
||||
echo "[4/7] Training (enhanced config for M4 Pro)..."
|
||||
time node scripts/train-ruvllm.js \
|
||||
--data data/recordings/pretrain-*.csi.jsonl \
|
||||
2>&1 | tee models/csi-ruvllm/training.log
|
||||
|
||||
# Step 5: Benchmark
|
||||
echo "[5/7] Benchmarking..."
|
||||
node scripts/benchmark-ruvllm.js \
|
||||
--model models/csi-ruvllm \
|
||||
--data data/recordings/pretrain-*.csi.jsonl \
|
||||
2>&1 | tee models/csi-ruvllm/benchmark.log
|
||||
|
||||
# Step 6: Copy results back to Windows
|
||||
echo "[6/7] Syncing results back to Windows..."
|
||||
scp -r -o ConnectTimeout=5 models/csi-ruvllm/ "ruv@${WINDOWS_HOST}:Projects/wifi-densepose/models/csi-ruvllm-m4pro/" 2>/dev/null || {
|
||||
echo " Could not reach Windows. Results are in: $REPO_DIR/models/csi-ruvllm/"
|
||||
}
|
||||
|
||||
# Step 7: Publish to HuggingFace
|
||||
echo "[7/7] Publishing to HuggingFace..."
|
||||
if command -v gcloud &>/dev/null; then
|
||||
mkdir -p dist/models
|
||||
cp models/csi-ruvllm/model.safetensors dist/models/
|
||||
cp models/csi-ruvllm/config.json dist/models/
|
||||
cp models/csi-ruvllm/presence-head.json dist/models/
|
||||
cp models/csi-ruvllm/quantized/* dist/models/ 2>/dev/null || true
|
||||
cp models/csi-ruvllm/lora/* dist/models/ 2>/dev/null || true
|
||||
cp models/csi-ruvllm/model.rvf.jsonl dist/models/ 2>/dev/null || true
|
||||
cp models/csi-ruvllm/training-metrics.json dist/models/ 2>/dev/null || true
|
||||
cp docs/huggingface/MODEL_CARD.md dist/models/README.md 2>/dev/null || true
|
||||
bash scripts/publish-huggingface.sh --version v0.5.4 2>&1 || echo " HF publish skipped (check gcloud auth)"
|
||||
else
|
||||
echo " gcloud not installed — skipping HF publish. Run manually:"
|
||||
echo " bash scripts/publish-huggingface.sh --version v0.5.4"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== Complete ==="
|
||||
echo "Models: $REPO_DIR/models/csi-ruvllm/"
|
||||
echo "Logs: training.log, benchmark.log"
|
||||
@@ -1,271 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Publish WiFi-DensePose pre-trained models to HuggingFace Hub.
|
||||
|
||||
Retrieves the HuggingFace API token from Google Cloud Secrets,
|
||||
then uploads model files from dist/models/ to a HuggingFace repo.
|
||||
|
||||
Prerequisites:
|
||||
- gcloud CLI authenticated with access to cognitum-20260110
|
||||
- pip install huggingface_hub google-cloud-secret-manager
|
||||
|
||||
Usage:
|
||||
python scripts/publish-huggingface.py
|
||||
python scripts/publish-huggingface.py --repo ruvnet/wifi-densepose-pretrained --version v0.5.4
|
||||
python scripts/publish-huggingface.py --dry-run
|
||||
python scripts/publish-huggingface.py --token hf_xxxxx # skip GCloud lookup
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
EXPECTED_FILES = [
|
||||
"pretrained-encoder.onnx",
|
||||
"pretrained-heads.onnx",
|
||||
"pretrained.rvf",
|
||||
"room-profiles.json",
|
||||
"collection-witness.json",
|
||||
"config.json",
|
||||
"README.md",
|
||||
]
|
||||
|
||||
|
||||
def get_token_from_gcloud(
|
||||
project: str = "cognitum-20260110",
|
||||
secret: str = "HUGGINGFACE_API_KEY",
|
||||
) -> str:
|
||||
"""Retrieve HuggingFace token from Google Cloud Secret Manager."""
|
||||
# Try the gcloud CLI first (simpler, no extra deps)
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"gcloud", "secrets", "versions", "access", "latest",
|
||||
f"--secret={secret}",
|
||||
f"--project={project}",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
return result.stdout.strip()
|
||||
except FileNotFoundError:
|
||||
pass # gcloud not installed, try Python SDK
|
||||
|
||||
# Fall back to the Python SDK
|
||||
try:
|
||||
from google.cloud import secretmanager
|
||||
|
||||
client = secretmanager.SecretManagerServiceClient()
|
||||
name = f"projects/{project}/secrets/{secret}/versions/latest"
|
||||
response = client.access_secret_version(request={"name": name})
|
||||
return response.payload.data.decode("utf-8").strip()
|
||||
except ImportError:
|
||||
print(
|
||||
"ERROR: Neither gcloud CLI nor google-cloud-secret-manager is available.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print("Install: pip install google-cloud-secret-manager", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
except Exception as exc:
|
||||
print(f"ERROR: Failed to retrieve secret: {exc}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def auto_version() -> str:
|
||||
"""Detect version from git describe."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "describe", "--tags", "--always"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return result.stdout.strip()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
return "dev"
|
||||
|
||||
|
||||
def validate_model_dir(model_dir: Path) -> list[Path]:
|
||||
"""List available files and warn about missing expected files."""
|
||||
found: list[Path] = []
|
||||
missing: list[str] = []
|
||||
|
||||
for fname in EXPECTED_FILES:
|
||||
path = model_dir / fname
|
||||
if path.is_file():
|
||||
size = path.stat().st_size
|
||||
print(f" [OK] {fname} ({size:,} bytes)")
|
||||
found.append(path)
|
||||
else:
|
||||
print(f" [MISSING] {fname}")
|
||||
missing.append(fname)
|
||||
|
||||
# Also pick up any extra files not in the expected list
|
||||
for path in sorted(model_dir.iterdir()):
|
||||
if path.is_file() and path.name not in EXPECTED_FILES:
|
||||
size = path.stat().st_size
|
||||
print(f" [EXTRA] {path.name} ({size:,} bytes)")
|
||||
found.append(path)
|
||||
|
||||
if missing:
|
||||
print(f"\nWARNING: {len(missing)} expected file(s) missing.")
|
||||
print("Upload will proceed with available files.\n")
|
||||
|
||||
return found
|
||||
|
||||
|
||||
def publish(
|
||||
repo_id: str,
|
||||
model_dir: Path,
|
||||
version: str,
|
||||
token: str,
|
||||
dry_run: bool = False,
|
||||
) -> None:
|
||||
"""Upload model files to HuggingFace Hub."""
|
||||
try:
|
||||
from huggingface_hub import HfApi, login
|
||||
except ImportError:
|
||||
print("Installing huggingface_hub...")
|
||||
subprocess.check_call(
|
||||
[sys.executable, "-m", "pip", "install", "--quiet", "huggingface_hub"]
|
||||
)
|
||||
from huggingface_hub import HfApi, login
|
||||
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f"Repo: https://huggingface.co/{repo_id}")
|
||||
print(f"Version: {version}")
|
||||
print(f"Model dir: {model_dir}")
|
||||
print(f"{'=' * 60}\n")
|
||||
|
||||
print("Validating model files...")
|
||||
files = validate_model_dir(model_dir)
|
||||
|
||||
if not files:
|
||||
print("ERROR: No files to upload.")
|
||||
sys.exit(1)
|
||||
|
||||
if dry_run:
|
||||
print(f"\n[DRY RUN] Would upload {len(files)} file(s) to {repo_id}")
|
||||
for f in files:
|
||||
print(f" - {f.name}")
|
||||
print(f"[DRY RUN] Version tag: {version}")
|
||||
return
|
||||
|
||||
print("Authenticating with HuggingFace...")
|
||||
login(token=token, add_to_git_credential=False)
|
||||
api = HfApi()
|
||||
|
||||
print("Creating repo (if needed)...")
|
||||
api.create_repo(
|
||||
repo_id=repo_id,
|
||||
repo_type="model",
|
||||
exist_ok=True,
|
||||
private=False,
|
||||
)
|
||||
|
||||
print("Uploading files...")
|
||||
commit_info = api.upload_folder(
|
||||
folder_path=str(model_dir),
|
||||
repo_id=repo_id,
|
||||
repo_type="model",
|
||||
commit_message=f"Upload WiFi-DensePose pretrained models ({version})",
|
||||
)
|
||||
|
||||
# Tag
|
||||
try:
|
||||
api.create_tag(
|
||||
repo_id=repo_id,
|
||||
repo_type="model",
|
||||
tag=version,
|
||||
tag_message=f"WiFi-DensePose pretrained models {version}",
|
||||
)
|
||||
print(f"Tagged as: {version}")
|
||||
except Exception as exc:
|
||||
print(f"Tag '{version}' may already exist: {exc}")
|
||||
|
||||
print(f"\n{'=' * 60}")
|
||||
print("Published successfully!")
|
||||
print(f"URL: https://huggingface.co/{repo_id}")
|
||||
print(f"Version: {version}")
|
||||
print(f"Commit: {commit_info.commit_url}")
|
||||
print(f"{'=' * 60}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Publish WiFi-DensePose models to HuggingFace Hub",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--repo",
|
||||
default="ruvnet/wifi-densepose-pretrained",
|
||||
help="HuggingFace repo ID (default: ruvnet/wifi-densepose-pretrained)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--version",
|
||||
default="",
|
||||
help="Version tag (default: auto from git describe)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--model-dir",
|
||||
default="dist/models",
|
||||
help="Directory containing model files (default: dist/models)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--project",
|
||||
default="cognitum-20260110",
|
||||
help="GCloud project ID (default: cognitum-20260110)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--secret",
|
||||
default="HUGGINGFACE_API_KEY",
|
||||
help="GCloud secret name (default: HUGGINGFACE_API_KEY)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--token",
|
||||
default="",
|
||||
help="HuggingFace token (skip GCloud lookup if provided)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Preview upload without actually uploading",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
model_dir = Path(args.model_dir)
|
||||
version = args.version or auto_version()
|
||||
|
||||
if not model_dir.is_dir():
|
||||
print(f"ERROR: Model directory does not exist: {model_dir}")
|
||||
print("Create it and populate with model files first.")
|
||||
sys.exit(1)
|
||||
|
||||
# Get token
|
||||
if args.dry_run:
|
||||
token = "dry-run-no-token-needed"
|
||||
elif args.token:
|
||||
token = args.token
|
||||
else:
|
||||
print(f"Retrieving HuggingFace token from GCloud ({args.project})...")
|
||||
token = get_token_from_gcloud(project=args.project, secret=args.secret)
|
||||
print("Token retrieved.")
|
||||
|
||||
publish(
|
||||
repo_id=args.repo,
|
||||
model_dir=model_dir,
|
||||
version=version,
|
||||
token=token,
|
||||
dry_run=args.dry_run,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,190 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Publish WiFi-DensePose pre-trained models to HuggingFace Hub
|
||||
#
|
||||
# Retrieves the HuggingFace API token from Google Cloud Secrets,
|
||||
# then uploads model files from dist/models/ to a HuggingFace repo.
|
||||
#
|
||||
# Prerequisites:
|
||||
# - gcloud CLI authenticated with access to cognitum-20260110
|
||||
# - Python 3.8+ with pip
|
||||
# - Model files present in dist/models/
|
||||
#
|
||||
# Usage:
|
||||
# bash scripts/publish-huggingface.sh
|
||||
# bash scripts/publish-huggingface.sh --repo ruvnet/wifi-densepose-pretrained --version v0.5.4
|
||||
# bash scripts/publish-huggingface.sh --dry-run
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ---------- defaults ----------
|
||||
REPO="ruvnet/wifi-densepose-pretrained"
|
||||
VERSION=""
|
||||
GCLOUD_PROJECT="cognitum-20260110"
|
||||
SECRET_NAME="HUGGINGFACE_API_KEY"
|
||||
MODEL_DIR="dist/models"
|
||||
DRY_RUN=false
|
||||
|
||||
# ---------- parse args ----------
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--repo) REPO="$2"; shift 2 ;;
|
||||
--version) VERSION="$2"; shift 2 ;;
|
||||
--model-dir) MODEL_DIR="$2"; shift 2 ;;
|
||||
--project) GCLOUD_PROJECT="$2"; shift 2 ;;
|
||||
--secret) SECRET_NAME="$2"; shift 2 ;;
|
||||
--dry-run) DRY_RUN=true; shift ;;
|
||||
-h|--help)
|
||||
echo "Usage: bash scripts/publish-huggingface.sh [OPTIONS]"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " --repo REPO HuggingFace repo (default: ruvnet/wifi-densepose-pretrained)"
|
||||
echo " --version VERSION Version tag (default: auto from git describe)"
|
||||
echo " --model-dir DIR Model directory (default: dist/models)"
|
||||
echo " --project PROJECT GCloud project (default: cognitum-20260110)"
|
||||
echo " --secret SECRET GCloud secret name (default: HUGGINGFACE_API_KEY)"
|
||||
echo " --dry-run Show what would be uploaded without uploading"
|
||||
echo " -h, --help Show this help"
|
||||
exit 0
|
||||
;;
|
||||
*) echo "Unknown option: $1"; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ---------- auto-detect version ----------
|
||||
if [ -z "$VERSION" ]; then
|
||||
VERSION=$(git describe --tags --always 2>/dev/null || echo "dev")
|
||||
echo "Auto-detected version: ${VERSION}"
|
||||
fi
|
||||
|
||||
# ---------- validate model files ----------
|
||||
EXPECTED_FILES=(
|
||||
"pretrained-encoder.onnx"
|
||||
"pretrained-heads.onnx"
|
||||
"pretrained.rvf"
|
||||
"room-profiles.json"
|
||||
"collection-witness.json"
|
||||
"config.json"
|
||||
"README.md"
|
||||
)
|
||||
|
||||
echo "=== WiFi-DensePose HuggingFace Publisher ==="
|
||||
echo "Repo: ${REPO}"
|
||||
echo "Version: ${VERSION}"
|
||||
echo "Model dir: ${MODEL_DIR}"
|
||||
echo ""
|
||||
|
||||
MISSING=0
|
||||
for f in "${EXPECTED_FILES[@]}"; do
|
||||
if [ -f "${MODEL_DIR}/${f}" ]; then
|
||||
SIZE=$(stat --printf="%s" "${MODEL_DIR}/${f}" 2>/dev/null || stat -f "%z" "${MODEL_DIR}/${f}" 2>/dev/null || echo "?")
|
||||
echo " [OK] ${f} (${SIZE} bytes)"
|
||||
else
|
||||
echo " [MISSING] ${f}"
|
||||
MISSING=$((MISSING + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$MISSING" -gt 0 ]; then
|
||||
echo ""
|
||||
echo "WARNING: ${MISSING} expected file(s) missing from ${MODEL_DIR}/"
|
||||
echo "The upload will proceed with available files only."
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Count actual files to upload
|
||||
FILE_COUNT=$(find "${MODEL_DIR}" -maxdepth 1 -type f | wc -l)
|
||||
if [ "$FILE_COUNT" -eq 0 ]; then
|
||||
echo "ERROR: No files found in ${MODEL_DIR}/. Nothing to upload."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ---------- dry run ----------
|
||||
if [ "$DRY_RUN" = true ]; then
|
||||
echo ""
|
||||
echo "[DRY RUN] Would upload ${FILE_COUNT} files to https://huggingface.co/${REPO}"
|
||||
echo "[DRY RUN] Files:"
|
||||
find "${MODEL_DIR}" -maxdepth 1 -type f -exec basename {} \; | sort | while read -r fname; do
|
||||
echo " - ${fname}"
|
||||
done
|
||||
echo "[DRY RUN] Version tag: ${VERSION}"
|
||||
echo ""
|
||||
echo "Run without --dry-run to actually upload."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ---------- retrieve HuggingFace token ----------
|
||||
echo ""
|
||||
echo "Retrieving HuggingFace token from GCloud Secrets..."
|
||||
HF_TOKEN=$(gcloud secrets versions access latest \
|
||||
--secret="${SECRET_NAME}" \
|
||||
--project="${GCLOUD_PROJECT}" 2>/dev/null)
|
||||
|
||||
if [ -z "$HF_TOKEN" ]; then
|
||||
echo "ERROR: Failed to retrieve secret '${SECRET_NAME}' from project '${GCLOUD_PROJECT}'."
|
||||
echo "Make sure you are authenticated: gcloud auth login"
|
||||
echo "And have access to the secret: gcloud secrets list --project=${GCLOUD_PROJECT}"
|
||||
exit 1
|
||||
fi
|
||||
echo "Token retrieved successfully."
|
||||
|
||||
# ---------- install huggingface_hub if needed ----------
|
||||
if ! python3 -c "import huggingface_hub" 2>/dev/null; then
|
||||
echo "Installing huggingface_hub..."
|
||||
pip3 install --quiet huggingface_hub
|
||||
fi
|
||||
|
||||
# ---------- upload via Python ----------
|
||||
echo ""
|
||||
echo "Uploading to https://huggingface.co/${REPO} ..."
|
||||
|
||||
python3 - <<PYEOF
|
||||
import os
|
||||
from huggingface_hub import HfApi, login
|
||||
|
||||
token = os.environ.get("HF_TOKEN_OVERRIDE") or """${HF_TOKEN}"""
|
||||
repo_id = "${REPO}"
|
||||
model_dir = "${MODEL_DIR}"
|
||||
version = "${VERSION}"
|
||||
|
||||
login(token=token, add_to_git_credential=False)
|
||||
api = HfApi()
|
||||
|
||||
# Create repo if it doesn't exist
|
||||
api.create_repo(
|
||||
repo_id=repo_id,
|
||||
repo_type="model",
|
||||
exist_ok=True,
|
||||
private=False,
|
||||
)
|
||||
|
||||
# Upload the entire folder
|
||||
commit_info = api.upload_folder(
|
||||
folder_path=model_dir,
|
||||
repo_id=repo_id,
|
||||
repo_type="model",
|
||||
commit_message=f"Upload WiFi-DensePose pretrained models ({version})",
|
||||
)
|
||||
|
||||
# Create a tag for this version
|
||||
try:
|
||||
api.create_tag(
|
||||
repo_id=repo_id,
|
||||
repo_type="model",
|
||||
tag=version,
|
||||
tag_message=f"WiFi-DensePose pretrained models {version}",
|
||||
)
|
||||
print(f"Tagged as: {version}")
|
||||
except Exception as e:
|
||||
print(f"Tag '{version}' may already exist: {e}")
|
||||
|
||||
print()
|
||||
print("=" * 60)
|
||||
print(f"Published successfully!")
|
||||
print(f"URL: https://huggingface.co/{repo_id}")
|
||||
print(f"Version: {version}")
|
||||
print(f"Commit: {commit_info.commit_url}")
|
||||
print("=" * 60)
|
||||
PYEOF
|
||||
|
||||
echo ""
|
||||
echo "Done."
|
||||
@@ -1,844 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* RuView Multi-Frequency RF Room Scanner
|
||||
*
|
||||
* Extended version of rf-scan.js that tracks CSI data per WiFi channel and
|
||||
* merges multi-channel data into a wideband view. Works when channel hopping
|
||||
* is enabled on ESP32 nodes via provision.py --hop-channels.
|
||||
*
|
||||
* Key capabilities:
|
||||
* - Per-channel subcarrier tracking across 6 WiFi channels
|
||||
* - Wideband merged spectrum (up to 6x subcarrier count)
|
||||
* - Null diversity analysis (what one channel misses, another may see)
|
||||
* - Frequency-dependent scattering identification
|
||||
* - Neighbor network illuminator tracking
|
||||
* - Per-channel penetration quality scoring
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/rf-scan-multifreq.js
|
||||
* node scripts/rf-scan-multifreq.js --port 5006 --duration 60
|
||||
* node scripts/rf-scan-multifreq.js --json
|
||||
*
|
||||
* ADR: docs/adr/ADR-073-multifrequency-mesh-scan.md
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const dgram = require('dgram');
|
||||
const { parseArgs } = require('util');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CLI
|
||||
// ---------------------------------------------------------------------------
|
||||
const { values: args } = parseArgs({
|
||||
options: {
|
||||
port: { type: 'string', short: 'p', default: '5006' },
|
||||
duration: { type: 'string', short: 'd' },
|
||||
json: { type: 'boolean', default: false },
|
||||
interval: { type: 'string', short: 'i', default: '2000' },
|
||||
},
|
||||
strict: true,
|
||||
});
|
||||
|
||||
const PORT = parseInt(args.port, 10);
|
||||
const DURATION_MS = args.duration ? parseInt(args.duration, 10) * 1000 : null;
|
||||
const INTERVAL_MS = parseInt(args.interval, 10);
|
||||
const JSON_OUTPUT = args.json;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
const CSI_MAGIC = 0xC5110001;
|
||||
const VITALS_MAGIC = 0xC5110002;
|
||||
const FEATURE_MAGIC = 0xC5110003;
|
||||
const FUSED_MAGIC = 0xC5110004;
|
||||
const HEADER_SIZE = 20;
|
||||
|
||||
const BARS = ['\u2581', '\u2582', '\u2583', '\u2584', '\u2585', '\u2586', '\u2587', '\u2588'];
|
||||
|
||||
const NULL_THRESHOLD = 2.0;
|
||||
const DYNAMIC_VAR_THRESH = 0.15;
|
||||
const STRONG_AMP_THRESH = 0.85;
|
||||
|
||||
// WiFi 2.4 GHz channel -> center frequency
|
||||
const CHANNEL_FREQ = {};
|
||||
for (let ch = 1; ch <= 13; ch++) CHANNEL_FREQ[ch] = 2412 + (ch - 1) * 5;
|
||||
CHANNEL_FREQ[14] = 2484;
|
||||
|
||||
// Non-overlapping channel sets for 2-node mesh
|
||||
const NODE1_CHANNELS = [1, 6, 11]; // non-overlapping
|
||||
const NODE2_CHANNELS = [3, 5, 9]; // interleaved, near neighbor APs
|
||||
|
||||
// Known neighbor networks (from WiFi scan, used as illuminators)
|
||||
const KNOWN_ILLUMINATORS = [
|
||||
{ ssid: 'ruv.net', channel: 5, freq: 2432, signal: 100 },
|
||||
{ ssid: 'Cohen-Guest', channel: 5, freq: 2432, signal: 100 },
|
||||
{ ssid: 'COGECO-21B20', channel: 11, freq: 2462, signal: 100 },
|
||||
{ ssid: 'DIRECT-fa-HP M255 LaserJet', channel: 5, freq: 2432, signal: 94 },
|
||||
{ ssid: 'conclusion mesh', channel: 3, freq: 2422, signal: 44 },
|
||||
{ ssid: 'NETGEAR72', channel: 9, freq: 2452, signal: 42 },
|
||||
{ ssid: 'NETGEAR72-Guest', channel: 9, freq: 2452, signal: 42 },
|
||||
{ ssid: 'COGECO-4321', channel: 11, freq: 2462, signal: 30 },
|
||||
{ ssid: 'Innanen', channel: 6, freq: 2437, signal: 19 },
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-channel state within a node
|
||||
// ---------------------------------------------------------------------------
|
||||
class ChannelState {
|
||||
constructor(channel) {
|
||||
this.channel = channel;
|
||||
this.freqMhz = CHANNEL_FREQ[channel] || 0;
|
||||
this.nSubcarriers = 0;
|
||||
this.frameCount = 0;
|
||||
this.firstFrameMs = 0;
|
||||
this.lastFrameMs = 0;
|
||||
|
||||
this.amplitudes = new Float64Array(256);
|
||||
this.phases = new Float64Array(256);
|
||||
|
||||
// Welford variance per subcarrier
|
||||
this.ampMean = new Float64Array(256);
|
||||
this.ampM2 = new Float64Array(256);
|
||||
this.ampCount = new Uint32Array(256);
|
||||
|
||||
// Illuminators active on this channel
|
||||
this.illuminators = KNOWN_ILLUMINATORS.filter(n => n.channel === channel);
|
||||
}
|
||||
|
||||
get fps() {
|
||||
if (this.firstFrameMs === 0) return 0;
|
||||
const elapsed = (this.lastFrameMs - this.firstFrameMs) / 1000;
|
||||
return elapsed > 0 ? this.frameCount / elapsed : 0;
|
||||
}
|
||||
|
||||
update(amplitudes, phases) {
|
||||
const n = amplitudes.length;
|
||||
this.nSubcarriers = n;
|
||||
this.frameCount++;
|
||||
const now = Date.now();
|
||||
if (this.firstFrameMs === 0) this.firstFrameMs = now;
|
||||
this.lastFrameMs = now;
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
this.amplitudes[i] = amplitudes[i];
|
||||
this.phases[i] = phases[i];
|
||||
|
||||
this.ampCount[i]++;
|
||||
const delta = amplitudes[i] - this.ampMean[i];
|
||||
this.ampMean[i] += delta / this.ampCount[i];
|
||||
const delta2 = amplitudes[i] - this.ampMean[i];
|
||||
this.ampM2[i] += delta * delta2;
|
||||
}
|
||||
}
|
||||
|
||||
getVariance(i) {
|
||||
return this.ampCount[i] > 1 ? this.ampM2[i] / (this.ampCount[i] - 1) : 0;
|
||||
}
|
||||
|
||||
getNulls() {
|
||||
const nulls = [];
|
||||
for (let i = 0; i < this.nSubcarriers; i++) {
|
||||
if (this.amplitudes[i] < NULL_THRESHOLD) nulls.push(i);
|
||||
}
|
||||
return nulls;
|
||||
}
|
||||
|
||||
getNullPercent() {
|
||||
if (this.nSubcarriers === 0) return 0;
|
||||
return (this.getNulls().length / this.nSubcarriers) * 100;
|
||||
}
|
||||
|
||||
classify() {
|
||||
const n = this.nSubcarriers;
|
||||
if (n === 0) return { nulls: [], dynamic: [], reflectors: [], walls: [] };
|
||||
|
||||
let maxAmp = 0;
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (this.amplitudes[i] > maxAmp) maxAmp = this.amplitudes[i];
|
||||
}
|
||||
if (maxAmp === 0) maxAmp = 1;
|
||||
|
||||
const nulls = [], dynamic = [], reflectors = [], walls = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
const normAmp = this.amplitudes[i] / maxAmp;
|
||||
const variance = this.getVariance(i);
|
||||
|
||||
if (this.amplitudes[i] < NULL_THRESHOLD) nulls.push(i);
|
||||
else if (variance > DYNAMIC_VAR_THRESH) dynamic.push(i);
|
||||
else if (normAmp > STRONG_AMP_THRESH) reflectors.push(i);
|
||||
else walls.push(i);
|
||||
}
|
||||
|
||||
return { nulls, dynamic, reflectors, walls };
|
||||
}
|
||||
|
||||
getSpectrumBar() {
|
||||
const n = this.nSubcarriers;
|
||||
if (n === 0) return '';
|
||||
|
||||
let maxAmp = 0;
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (this.amplitudes[i] > maxAmp) maxAmp = this.amplitudes[i];
|
||||
}
|
||||
if (maxAmp === 0) maxAmp = 1;
|
||||
|
||||
let bar = '';
|
||||
for (let i = 0; i < n; i++) {
|
||||
const level = Math.floor((this.amplitudes[i] / maxAmp) * 7.99);
|
||||
bar += BARS[Math.max(0, Math.min(7, level))];
|
||||
}
|
||||
return bar;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-node state (multi-channel)
|
||||
// ---------------------------------------------------------------------------
|
||||
class NodeState {
|
||||
constructor(nodeId) {
|
||||
this.nodeId = nodeId;
|
||||
this.address = null;
|
||||
this.channels = new Map(); // channel number -> ChannelState
|
||||
this.totalFrames = 0;
|
||||
this.firstFrameMs = Date.now();
|
||||
this.lastFrameMs = Date.now();
|
||||
this.rssi = 0;
|
||||
this.vitals = null;
|
||||
this.features = null;
|
||||
}
|
||||
|
||||
get fps() {
|
||||
const elapsed = (this.lastFrameMs - this.firstFrameMs) / 1000;
|
||||
return elapsed > 0 ? this.totalFrames / elapsed : 0;
|
||||
}
|
||||
|
||||
getOrCreateChannel(channel) {
|
||||
if (!this.channels.has(channel)) {
|
||||
this.channels.set(channel, new ChannelState(channel));
|
||||
}
|
||||
return this.channels.get(channel);
|
||||
}
|
||||
|
||||
getActiveChannels() {
|
||||
return [...this.channels.values()]
|
||||
.filter(cs => cs.frameCount > 0)
|
||||
.sort((a, b) => a.channel - b.channel);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Global state
|
||||
// ---------------------------------------------------------------------------
|
||||
const nodes = new Map();
|
||||
const startTime = Date.now();
|
||||
let totalFrames = 0;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Packet parsing (same as rf-scan.js)
|
||||
// ---------------------------------------------------------------------------
|
||||
function parseCSIFrame(buf) {
|
||||
if (buf.length < HEADER_SIZE) return null;
|
||||
const magic = buf.readUInt32LE(0);
|
||||
if (magic !== CSI_MAGIC) return null;
|
||||
|
||||
const nodeId = buf.readUInt8(4);
|
||||
const nAntennas = buf.readUInt8(5) || 1;
|
||||
const nSubcarriers = buf.readUInt16LE(6);
|
||||
const freqMhz = buf.readUInt32LE(8);
|
||||
const seq = buf.readUInt32LE(12);
|
||||
const rssi = buf.readInt8(16);
|
||||
const noiseFloor = buf.readInt8(17);
|
||||
|
||||
const iqLen = nSubcarriers * nAntennas * 2;
|
||||
if (buf.length < HEADER_SIZE + iqLen) return null;
|
||||
|
||||
const amplitudes = new Float64Array(nSubcarriers);
|
||||
const phases = new Float64Array(nSubcarriers);
|
||||
|
||||
for (let sc = 0; sc < nSubcarriers; sc++) {
|
||||
const offset = HEADER_SIZE + sc * 2;
|
||||
const I = buf.readInt8(offset);
|
||||
const Q = buf.readInt8(offset + 1);
|
||||
amplitudes[sc] = Math.sqrt(I * I + Q * Q);
|
||||
phases[sc] = Math.atan2(Q, I);
|
||||
}
|
||||
|
||||
// Derive channel from frequency
|
||||
let channel = 0;
|
||||
if (freqMhz >= 2412 && freqMhz <= 2484) {
|
||||
channel = freqMhz === 2484 ? 14 : Math.round((freqMhz - 2412) / 5) + 1;
|
||||
} else if (freqMhz >= 5180) {
|
||||
channel = Math.round((freqMhz - 5000) / 5);
|
||||
}
|
||||
|
||||
return {
|
||||
nodeId, nAntennas, nSubcarriers, freqMhz, seq, rssi, noiseFloor,
|
||||
amplitudes, phases, channel,
|
||||
};
|
||||
}
|
||||
|
||||
function parseVitalsPacket(buf) {
|
||||
if (buf.length < 32) return null;
|
||||
const magic = buf.readUInt32LE(0);
|
||||
if (magic !== VITALS_MAGIC && magic !== FUSED_MAGIC) return null;
|
||||
|
||||
return {
|
||||
nodeId: buf.readUInt8(4),
|
||||
flags: buf.readUInt8(5),
|
||||
presence: !!(buf.readUInt8(5) & 0x01),
|
||||
fall: !!(buf.readUInt8(5) & 0x02),
|
||||
motion: !!(buf.readUInt8(5) & 0x04),
|
||||
breathingRate: buf.readUInt16LE(6) / 100,
|
||||
heartrate: buf.readUInt32LE(8) / 10000,
|
||||
rssi: buf.readInt8(12),
|
||||
nPersons: buf.readUInt8(13),
|
||||
motionEnergy: buf.readFloatLE(16),
|
||||
presenceScore: buf.readFloatLE(20),
|
||||
timestampMs: buf.readUInt32LE(24),
|
||||
};
|
||||
}
|
||||
|
||||
function parseFeaturePacket(buf) {
|
||||
if (buf.length < 48) return null;
|
||||
const magic = buf.readUInt32LE(0);
|
||||
if (magic !== FEATURE_MAGIC) return null;
|
||||
|
||||
const features = [];
|
||||
for (let i = 0; i < 8; i++) features.push(buf.readFloatLE(12 + i * 4));
|
||||
return { nodeId: buf.readUInt8(4), seq: buf.readUInt16LE(6), features };
|
||||
}
|
||||
|
||||
function handlePacket(buf, rinfo) {
|
||||
if (buf.length < 4) return;
|
||||
const magic = buf.readUInt32LE(0);
|
||||
|
||||
if (magic === CSI_MAGIC) {
|
||||
const frame = parseCSIFrame(buf);
|
||||
if (!frame) return;
|
||||
|
||||
totalFrames++;
|
||||
let node = nodes.get(frame.nodeId);
|
||||
if (!node) {
|
||||
node = new NodeState(frame.nodeId);
|
||||
nodes.set(frame.nodeId, node);
|
||||
}
|
||||
|
||||
node.address = rinfo.address;
|
||||
node.rssi = frame.rssi;
|
||||
node.totalFrames++;
|
||||
node.lastFrameMs = Date.now();
|
||||
|
||||
const cs = node.getOrCreateChannel(frame.channel);
|
||||
cs.update(frame.amplitudes, frame.phases);
|
||||
return;
|
||||
}
|
||||
|
||||
if (magic === VITALS_MAGIC || magic === FUSED_MAGIC) {
|
||||
const vitals = parseVitalsPacket(buf);
|
||||
if (!vitals) return;
|
||||
let node = nodes.get(vitals.nodeId);
|
||||
if (!node) { node = new NodeState(vitals.nodeId); nodes.set(vitals.nodeId, node); }
|
||||
node.vitals = vitals;
|
||||
return;
|
||||
}
|
||||
|
||||
if (magic === FEATURE_MAGIC) {
|
||||
const feat = parseFeaturePacket(buf);
|
||||
if (!feat) return;
|
||||
let node = nodes.get(feat.nodeId);
|
||||
if (!node) { node = new NodeState(feat.nodeId); nodes.set(feat.nodeId, node); }
|
||||
node.features = feat;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Multi-frequency analysis
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Compute null diversity: how many null subcarriers on one channel are
|
||||
* resolved (non-null) on another channel. This is the core benefit of
|
||||
* multi-frequency scanning.
|
||||
*/
|
||||
function computeNullDiversity() {
|
||||
// Collect all channel states across all nodes
|
||||
const allChannelStates = [];
|
||||
for (const node of nodes.values()) {
|
||||
for (const cs of node.channels.values()) {
|
||||
if (cs.frameCount > 0) allChannelStates.push(cs);
|
||||
}
|
||||
}
|
||||
|
||||
if (allChannelStates.length < 2) return null;
|
||||
|
||||
// For each channel, get its null set
|
||||
const channelNulls = new Map();
|
||||
for (const cs of allChannelStates) {
|
||||
const key = cs.channel;
|
||||
if (!channelNulls.has(key)) {
|
||||
channelNulls.set(key, { channel: key, nulls: new Set(cs.getNulls()), nSub: cs.nSubcarriers });
|
||||
}
|
||||
}
|
||||
|
||||
if (channelNulls.size < 2) return null;
|
||||
|
||||
const channels = [...channelNulls.keys()].sort((a, b) => a - b);
|
||||
|
||||
// Compute pairwise null diversity
|
||||
const pairwise = [];
|
||||
for (let i = 0; i < channels.length; i++) {
|
||||
for (let j = i + 1; j < channels.length; j++) {
|
||||
const c1 = channelNulls.get(channels[i]);
|
||||
const c2 = channelNulls.get(channels[j]);
|
||||
|
||||
// Nulls on c1 that c2 resolves (non-null on c2)
|
||||
let c1ResolvedByC2 = 0;
|
||||
let c2ResolvedByC1 = 0;
|
||||
let sharedNulls = 0;
|
||||
|
||||
for (const idx of c1.nulls) {
|
||||
if (!c2.nulls.has(idx)) c1ResolvedByC2++;
|
||||
else sharedNulls++;
|
||||
}
|
||||
for (const idx of c2.nulls) {
|
||||
if (!c1.nulls.has(idx)) c2ResolvedByC1++;
|
||||
}
|
||||
|
||||
pairwise.push({
|
||||
ch1: channels[i], ch2: channels[j],
|
||||
ch1Nulls: c1.nulls.size, ch2Nulls: c2.nulls.size,
|
||||
sharedNulls,
|
||||
ch1ResolvedByC2: c1ResolvedByC2,
|
||||
ch2ResolvedByC1: c2ResolvedByC1,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Global: union of all nulls vs intersection
|
||||
const allNullSets = [...channelNulls.values()].map(c => c.nulls);
|
||||
const unionNulls = new Set();
|
||||
for (const s of allNullSets) for (const idx of s) unionNulls.add(idx);
|
||||
|
||||
let intersectionCount = 0;
|
||||
for (const idx of unionNulls) {
|
||||
if (allNullSets.every(s => s.has(idx))) intersectionCount++;
|
||||
}
|
||||
|
||||
// Effective null rate after multi-channel fusion
|
||||
const maxSub = Math.max(...[...channelNulls.values()].map(c => c.nSub));
|
||||
const singleChannelNulls = allNullSets[0].size;
|
||||
const fusedNulls = intersectionCount; // only nulls present on ALL channels
|
||||
|
||||
return {
|
||||
channels,
|
||||
pairwise,
|
||||
singleChannelNulls,
|
||||
fusedNulls,
|
||||
unionNulls: unionNulls.size,
|
||||
maxSubcarriers: maxSub,
|
||||
singleNullPct: maxSub > 0 ? ((singleChannelNulls / maxSub) * 100).toFixed(1) : '0',
|
||||
fusedNullPct: maxSub > 0 ? ((fusedNulls / maxSub) * 100).toFixed(1) : '0',
|
||||
diversityGain: singleChannelNulls > 0
|
||||
? ((1 - fusedNulls / singleChannelNulls) * 100).toFixed(1)
|
||||
: '0',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Find objects visible on some channels but not others.
|
||||
* These are frequency-dependent scatterers (interesting for material classification).
|
||||
*/
|
||||
function findFrequencyDependentObjects() {
|
||||
const allChannelStates = [];
|
||||
for (const node of nodes.values()) {
|
||||
for (const cs of node.channels.values()) {
|
||||
if (cs.frameCount > 0 && cs.nSubcarriers > 0) allChannelStates.push(cs);
|
||||
}
|
||||
}
|
||||
|
||||
if (allChannelStates.length < 2) return [];
|
||||
|
||||
const results = [];
|
||||
const nSub = Math.min(...allChannelStates.map(cs => cs.nSubcarriers));
|
||||
|
||||
for (let i = 0; i < nSub; i++) {
|
||||
const amps = allChannelStates.map(cs => cs.amplitudes[i]);
|
||||
const vars = allChannelStates.map(cs => cs.getVariance(i));
|
||||
const maxAmp = Math.max(...amps);
|
||||
const minAmp = Math.min(...amps);
|
||||
|
||||
// Large amplitude spread across channels = frequency-dependent scatterer
|
||||
if (maxAmp > 0 && (maxAmp - minAmp) / maxAmp > 0.5) {
|
||||
const bestCh = allChannelStates[amps.indexOf(maxAmp)].channel;
|
||||
const worstCh = allChannelStates[amps.indexOf(minAmp)].channel;
|
||||
results.push({
|
||||
subcarrier: i,
|
||||
maxAmp: maxAmp.toFixed(1),
|
||||
minAmp: minAmp.toFixed(1),
|
||||
bestChannel: bestCh,
|
||||
worstChannel: worstCh,
|
||||
spread: ((maxAmp - minAmp) / maxAmp * 100).toFixed(0),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results.slice(0, 20); // top 20
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute per-channel penetration quality score.
|
||||
* Lower frequency channels (ch 1 = 2412 MHz) have slightly longer wavelength
|
||||
* and better penetration through some materials.
|
||||
*/
|
||||
function computePenetrationScores() {
|
||||
const scores = [];
|
||||
|
||||
for (const node of nodes.values()) {
|
||||
for (const cs of node.channels.values()) {
|
||||
if (cs.frameCount === 0 || cs.nSubcarriers === 0) continue;
|
||||
|
||||
// Mean amplitude (higher = better penetration)
|
||||
let sumAmp = 0;
|
||||
for (let i = 0; i < cs.nSubcarriers; i++) sumAmp += cs.amplitudes[i];
|
||||
const meanAmp = sumAmp / cs.nSubcarriers;
|
||||
|
||||
// Null rate (lower = better)
|
||||
const nullPct = cs.getNullPercent();
|
||||
|
||||
// Spectrum flatness = geometric mean / arithmetic mean
|
||||
// Flatter spectrum = more uniform penetration
|
||||
let logSum = 0;
|
||||
let count = 0;
|
||||
for (let i = 0; i < cs.nSubcarriers; i++) {
|
||||
if (cs.amplitudes[i] > 0) {
|
||||
logSum += Math.log(cs.amplitudes[i]);
|
||||
count++;
|
||||
}
|
||||
}
|
||||
const geoMean = count > 0 ? Math.exp(logSum / count) : 0;
|
||||
const flatness = sumAmp > 0 ? geoMean / meanAmp : 0;
|
||||
|
||||
// Quality score: weighted combination
|
||||
const quality = (meanAmp / 20) * 0.4 + (1 - nullPct / 100) * 0.3 + flatness * 0.3;
|
||||
|
||||
scores.push({
|
||||
nodeId: node.nodeId,
|
||||
channel: cs.channel,
|
||||
freqMhz: cs.freqMhz,
|
||||
fps: cs.fps.toFixed(1),
|
||||
meanAmp: meanAmp.toFixed(1),
|
||||
nullPct: nullPct.toFixed(1),
|
||||
flatness: flatness.toFixed(3),
|
||||
quality: quality.toFixed(3),
|
||||
illuminators: cs.illuminators.map(il => il.ssid),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return scores.sort((a, b) => parseFloat(b.quality) - parseFloat(a.quality));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Wideband merged view
|
||||
// ---------------------------------------------------------------------------
|
||||
function buildWidebandSpectrum() {
|
||||
// Collect all channel amplitudes into one wide view
|
||||
const allChannels = [];
|
||||
for (const node of nodes.values()) {
|
||||
for (const cs of node.getActiveChannels()) {
|
||||
allChannels.push(cs);
|
||||
}
|
||||
}
|
||||
|
||||
if (allChannels.length === 0) return { bar: '', channels: 0, totalSubcarriers: 0 };
|
||||
|
||||
// Sort by frequency
|
||||
allChannels.sort((a, b) => a.freqMhz - b.freqMhz);
|
||||
|
||||
let totalSub = 0;
|
||||
for (const cs of allChannels) totalSub += cs.nSubcarriers;
|
||||
|
||||
// Find global max amplitude for normalization
|
||||
let globalMax = 0;
|
||||
for (const cs of allChannels) {
|
||||
for (let i = 0; i < cs.nSubcarriers; i++) {
|
||||
if (cs.amplitudes[i] > globalMax) globalMax = cs.amplitudes[i];
|
||||
}
|
||||
}
|
||||
if (globalMax === 0) globalMax = 1;
|
||||
|
||||
// Build wideband bar with channel separators
|
||||
let bar = '';
|
||||
let labels = '';
|
||||
for (let c = 0; c < allChannels.length; c++) {
|
||||
const cs = allChannels[c];
|
||||
if (c > 0) {
|
||||
bar += '|';
|
||||
labels += '|';
|
||||
}
|
||||
|
||||
const chLabel = `ch${cs.channel}`;
|
||||
labels += chLabel + ' '.repeat(Math.max(0, cs.nSubcarriers - chLabel.length));
|
||||
|
||||
for (let i = 0; i < cs.nSubcarriers; i++) {
|
||||
const level = Math.floor((cs.amplitudes[i] / globalMax) * 7.99);
|
||||
bar += BARS[Math.max(0, Math.min(7, level))];
|
||||
}
|
||||
}
|
||||
|
||||
return { bar, labels, channels: allChannels.length, totalSubcarriers: totalSub };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Display
|
||||
// ---------------------------------------------------------------------------
|
||||
function buildProgressBar(value, max, width) {
|
||||
const filled = Math.round((value / max) * width);
|
||||
return '\u2588'.repeat(Math.min(filled, width)) +
|
||||
'\u2591'.repeat(Math.max(0, width - filled));
|
||||
}
|
||||
|
||||
function renderASCII() {
|
||||
const lines = [];
|
||||
const nodeList = [...nodes.values()];
|
||||
const activeNodes = nodeList.filter(n => n.totalFrames > 0);
|
||||
|
||||
if (activeNodes.length === 0) {
|
||||
lines.push(`=== RUVIEW MULTI-FREQ RF SCAN === Listening on UDP :${PORT}`);
|
||||
lines.push('Waiting for CSI frames from ESP32 nodes...');
|
||||
lines.push('Enable channel hopping: python provision.py --port COMx --hop-channels 1,6,11');
|
||||
lines.push(`Elapsed: ${((Date.now() - startTime) / 1000).toFixed(0)}s | Frames: ${totalFrames}`);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
lines.push('=== RUVIEW MULTI-FREQUENCY RF SCAN ===');
|
||||
lines.push('');
|
||||
|
||||
// Per-node, per-channel view
|
||||
for (const node of activeNodes) {
|
||||
lines.push(`--- Node ${node.nodeId} (${node.address || '?'}) | ${node.fps.toFixed(1)} fps total | RSSI ${node.rssi} dBm ---`);
|
||||
|
||||
const activeChannels = node.getActiveChannels();
|
||||
if (activeChannels.length === 0) {
|
||||
lines.push(' (no channel data yet)');
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const cs of activeChannels) {
|
||||
const cls = cs.classify();
|
||||
const spectrum = cs.getSpectrumBar();
|
||||
const nullPct = cs.getNullPercent().toFixed(0);
|
||||
const ilNames = cs.illuminators.length > 0
|
||||
? cs.illuminators.map(il => il.ssid).join(', ')
|
||||
: 'none';
|
||||
|
||||
lines.push(` Ch ${String(cs.channel).padStart(2)} (${cs.freqMhz} MHz) | ${cs.fps.toFixed(1)} fps | nulls: ${nullPct}% | illuminators: ${ilNames}`);
|
||||
if (spectrum.length > 0) {
|
||||
// Truncate spectrum to terminal width (approx)
|
||||
const maxWidth = 80;
|
||||
const truncated = spectrum.length > maxWidth
|
||||
? spectrum.slice(0, maxWidth) + '...'
|
||||
: spectrum;
|
||||
lines.push(` ${truncated}`);
|
||||
}
|
||||
lines.push(` ${cls.nulls.length} null | ${cls.dynamic.length} dynamic | ${cls.reflectors.length} reflector | ${cls.walls.length} static`);
|
||||
}
|
||||
|
||||
// Vitals
|
||||
if (node.vitals) {
|
||||
const v = node.vitals;
|
||||
lines.push(` Vitals: BR ${v.breathingRate.toFixed(0)} BPM | HR ${v.heartrate.toFixed(0)} BPM | presence ${v.presenceScore.toFixed(2)} | ${v.nPersons} person(s)`);
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
// Wideband merged view
|
||||
const wideband = buildWidebandSpectrum();
|
||||
if (wideband.channels > 1) {
|
||||
lines.push('--- Wideband Merged Spectrum ---');
|
||||
const maxWidth = 100;
|
||||
const truncBar = wideband.bar.length > maxWidth
|
||||
? wideband.bar.slice(0, maxWidth) + '...'
|
||||
: wideband.bar;
|
||||
lines.push(` ${truncBar}`);
|
||||
lines.push(` ${wideband.channels} channels | ${wideband.totalSubcarriers} total subcarriers`);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
// Null diversity analysis
|
||||
const diversity = computeNullDiversity();
|
||||
if (diversity) {
|
||||
lines.push('--- Null Diversity Analysis ---');
|
||||
lines.push(` Single-channel nulls: ${diversity.singleChannelNulls} (${diversity.singleNullPct}%)`);
|
||||
lines.push(` Multi-channel fused: ${diversity.fusedNulls} (${diversity.fusedNullPct}%) -- only nulls on ALL channels`);
|
||||
lines.push(` Diversity gain: ${diversity.diversityGain}% of nulls resolved by other channels`);
|
||||
|
||||
if (diversity.pairwise.length > 0) {
|
||||
lines.push(' Pairwise:');
|
||||
for (const p of diversity.pairwise) {
|
||||
lines.push(` ch${p.ch1}<->ch${p.ch2}: ${p.sharedNulls} shared | ch${p.ch1} resolves ${p.ch2ResolvedByC1} of ch${p.ch2}'s nulls | ch${p.ch2} resolves ${p.ch1ResolvedByC2} of ch${p.ch1}'s nulls`);
|
||||
}
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
// Penetration scores
|
||||
const penScores = computePenetrationScores();
|
||||
if (penScores.length > 0) {
|
||||
lines.push('--- Per-Channel Penetration Quality ---');
|
||||
lines.push(' Ch Freq FPS MeanAmp Null% Flat Quality Illuminators');
|
||||
for (const s of penScores) {
|
||||
const ilStr = s.illuminators.length > 0 ? s.illuminators.slice(0, 2).join(', ') : '-';
|
||||
lines.push(` ${String(s.channel).padStart(2)} ${s.freqMhz} MHz ${String(s.fps).padStart(5)} ${String(s.meanAmp).padStart(7)} ${String(s.nullPct).padStart(5)} ${s.flatness} ${s.quality} ${ilStr}`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
// Frequency-dependent scatterers
|
||||
const scatterers = findFrequencyDependentObjects();
|
||||
if (scatterers.length > 0) {
|
||||
lines.push(`--- Frequency-Dependent Scatterers (${scatterers.length} found) ---`);
|
||||
lines.push(' Sub# Best Ch Worst Ch Spread MaxAmp MinAmp');
|
||||
for (const s of scatterers.slice(0, 10)) {
|
||||
lines.push(` ${String(s.subcarrier).padStart(4)} ch${String(s.bestChannel).padStart(2)} ch${String(s.worstChannel).padStart(2)} ${String(s.spread).padStart(3)}% ${String(s.maxAmp).padStart(6)} ${String(s.minAmp).padStart(6)}`);
|
||||
}
|
||||
lines.push(' (Objects visible on some frequencies but not others -- different materials)');
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
// Summary
|
||||
const elapsed = ((Date.now() - startTime) / 1000).toFixed(0);
|
||||
lines.push(`Elapsed: ${elapsed}s | Total frames: ${totalFrames} | Nodes: ${activeNodes.length}`);
|
||||
if (DURATION_MS) {
|
||||
const remaining = Math.max(0, (DURATION_MS - (Date.now() - startTime)) / 1000).toFixed(0);
|
||||
lines.push(`Remaining: ${remaining}s`);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function buildJsonOutput() {
|
||||
const activeNodes = [...nodes.values()].filter(n => n.totalFrames > 0);
|
||||
|
||||
return {
|
||||
timestamp: new Date().toISOString(),
|
||||
elapsedMs: Date.now() - startTime,
|
||||
totalFrames,
|
||||
nodes: activeNodes.map(node => ({
|
||||
nodeId: node.nodeId,
|
||||
address: node.address,
|
||||
fps: parseFloat(node.fps.toFixed(2)),
|
||||
totalFrames: node.totalFrames,
|
||||
channels: node.getActiveChannels().map(cs => {
|
||||
const cls = cs.classify();
|
||||
return {
|
||||
channel: cs.channel,
|
||||
freqMhz: cs.freqMhz,
|
||||
fps: parseFloat(cs.fps.toFixed(2)),
|
||||
nSubcarriers: cs.nSubcarriers,
|
||||
frameCount: cs.frameCount,
|
||||
classification: {
|
||||
nullCount: cls.nulls.length,
|
||||
dynamicCount: cls.dynamic.length,
|
||||
reflectorCount: cls.reflectors.length,
|
||||
staticCount: cls.walls.length,
|
||||
nullPercent: parseFloat(cs.getNullPercent().toFixed(1)),
|
||||
},
|
||||
illuminators: cs.illuminators.map(il => il.ssid),
|
||||
amplitudes: Array.from(cs.amplitudes.subarray(0, cs.nSubcarriers)),
|
||||
phases: Array.from(cs.phases.subarray(0, cs.nSubcarriers)),
|
||||
};
|
||||
}),
|
||||
vitals: node.vitals,
|
||||
features: node.features ? node.features.features : null,
|
||||
})),
|
||||
nullDiversity: computeNullDiversity(),
|
||||
penetrationScores: computePenetrationScores(),
|
||||
frequencyDependentScatterers: findFrequencyDependentObjects(),
|
||||
wideband: (() => {
|
||||
const wb = buildWidebandSpectrum();
|
||||
return { channels: wb.channels, totalSubcarriers: wb.totalSubcarriers };
|
||||
})(),
|
||||
};
|
||||
}
|
||||
|
||||
function display() {
|
||||
if (JSON_OUTPUT) {
|
||||
process.stdout.write(JSON.stringify(buildJsonOutput()) + '\n');
|
||||
} else {
|
||||
process.stdout.write('\x1B[2J\x1B[H');
|
||||
process.stdout.write(renderASCII() + '\n');
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
function main() {
|
||||
const server = dgram.createSocket('udp4');
|
||||
|
||||
server.on('error', (err) => {
|
||||
console.error(`UDP error: ${err.message}`);
|
||||
server.close();
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
server.on('message', (msg, rinfo) => {
|
||||
handlePacket(msg, rinfo);
|
||||
});
|
||||
|
||||
server.on('listening', () => {
|
||||
const addr = server.address();
|
||||
if (!JSON_OUTPUT) {
|
||||
console.log(`RuView Multi-Frequency RF Scanner listening on ${addr.address}:${addr.port}`);
|
||||
console.log('Waiting for CSI frames from ESP32 nodes...');
|
||||
console.log('Tip: Enable channel hopping with provision.py --hop-channels 1,6,11\n');
|
||||
}
|
||||
});
|
||||
|
||||
server.bind(PORT);
|
||||
|
||||
const displayTimer = setInterval(display, INTERVAL_MS);
|
||||
|
||||
if (DURATION_MS) {
|
||||
setTimeout(() => {
|
||||
clearInterval(displayTimer);
|
||||
|
||||
if (JSON_OUTPUT) {
|
||||
const summary = buildJsonOutput();
|
||||
summary.final = true;
|
||||
process.stdout.write(JSON.stringify(summary) + '\n');
|
||||
} else {
|
||||
display();
|
||||
console.log('\n--- Multi-frequency scan complete ---');
|
||||
|
||||
const diversity = computeNullDiversity();
|
||||
if (diversity) {
|
||||
console.log(`Null diversity gain: ${diversity.diversityGain}% (${diversity.singleNullPct}% -> ${diversity.fusedNullPct}%)`);
|
||||
}
|
||||
|
||||
console.log(`Total frames: ${totalFrames}`);
|
||||
console.log(`Nodes: ${nodes.size}`);
|
||||
|
||||
for (const node of nodes.values()) {
|
||||
const chList = node.getActiveChannels().map(cs => `ch${cs.channel}`).join(', ');
|
||||
console.log(` Node ${node.nodeId}: ${node.totalFrames} frames, channels: [${chList}]`);
|
||||
}
|
||||
}
|
||||
|
||||
server.close();
|
||||
process.exit(0);
|
||||
}, DURATION_MS);
|
||||
}
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
clearInterval(displayTimer);
|
||||
if (!JSON_OUTPUT) console.log('\nShutting down...');
|
||||
server.close();
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -1,622 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* RuView RF Room Scanner — Live CSI spectrum analyzer
|
||||
*
|
||||
* Listens on UDP for ADR-018 CSI frames from ESP32 nodes and builds a
|
||||
* real-time RF map of the room showing null zones (metal), static reflectors,
|
||||
* dynamic subcarriers (people), and cross-node correlation.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/rf-scan.js
|
||||
* node scripts/rf-scan.js --port 5006 --duration 30
|
||||
* node scripts/rf-scan.js --json
|
||||
*
|
||||
* ADR: docs/adr/ADR-073-multifrequency-mesh-scan.md
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const dgram = require('dgram');
|
||||
const { parseArgs } = require('util');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CLI
|
||||
// ---------------------------------------------------------------------------
|
||||
const { values: args } = parseArgs({
|
||||
options: {
|
||||
port: { type: 'string', short: 'p', default: '5006' },
|
||||
duration: { type: 'string', short: 'd' },
|
||||
json: { type: 'boolean', default: false },
|
||||
interval: { type: 'string', short: 'i', default: '2000' },
|
||||
},
|
||||
strict: true,
|
||||
});
|
||||
|
||||
const PORT = parseInt(args.port, 10);
|
||||
const DURATION_MS = args.duration ? parseInt(args.duration, 10) * 1000 : null;
|
||||
const INTERVAL_MS = parseInt(args.interval, 10);
|
||||
const JSON_OUTPUT = args.json;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ADR-018 packet constants
|
||||
// ---------------------------------------------------------------------------
|
||||
const CSI_MAGIC = 0xC5110001;
|
||||
const VITALS_MAGIC = 0xC5110002;
|
||||
const FEATURE_MAGIC = 0xC5110003;
|
||||
const FUSED_MAGIC = 0xC5110004;
|
||||
const HEADER_SIZE = 20;
|
||||
|
||||
// Spectrum visualization characters (8 levels)
|
||||
const BARS = ['\u2581', '\u2582', '\u2583', '\u2584', '\u2585', '\u2586', '\u2587', '\u2588'];
|
||||
|
||||
// Subcarrier type markers
|
||||
const TYPE_WALL = '.';
|
||||
const TYPE_PERSON = '^';
|
||||
const TYPE_REFLECTOR = '#';
|
||||
const TYPE_NULL = '_';
|
||||
const TYPE_UNKNOWN = ' ';
|
||||
|
||||
// Thresholds
|
||||
const NULL_THRESHOLD = 2.0; // Amplitude below this = null subcarrier
|
||||
const DYNAMIC_VAR_THRESH = 0.15; // Variance above this = dynamic (person/motion)
|
||||
const STRONG_AMP_THRESH = 0.85; // Normalized amplitude above this = strong reflector
|
||||
const COHERENCE_THRESH = 0.7; // Phase coherence above this = line-of-sight
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-node state
|
||||
// ---------------------------------------------------------------------------
|
||||
class NodeState {
|
||||
constructor(nodeId) {
|
||||
this.nodeId = nodeId;
|
||||
this.address = null;
|
||||
this.channel = 0;
|
||||
this.freqMhz = 0;
|
||||
this.rssi = 0;
|
||||
this.noiseFloor = 0;
|
||||
this.nSubcarriers = 0;
|
||||
this.frameCount = 0;
|
||||
this.firstFrameMs = Date.now();
|
||||
this.lastFrameMs = Date.now();
|
||||
|
||||
// Per-subcarrier rolling state
|
||||
this.amplitudes = new Float64Array(256);
|
||||
this.phases = new Float64Array(256);
|
||||
this.ampHistory = []; // circular buffer of amplitude snapshots
|
||||
this.phaseHistory = []; // circular buffer of phase snapshots
|
||||
this.historyMaxLen = 50; // ~10 seconds at 5 fps
|
||||
|
||||
// Welford variance per subcarrier
|
||||
this.ampMean = new Float64Array(256);
|
||||
this.ampM2 = new Float64Array(256);
|
||||
this.ampCount = new Uint32Array(256);
|
||||
|
||||
// Latest vitals
|
||||
this.vitals = null;
|
||||
this.features = null;
|
||||
}
|
||||
|
||||
get fps() {
|
||||
const elapsed = (this.lastFrameMs - this.firstFrameMs) / 1000;
|
||||
return elapsed > 0 ? this.frameCount / elapsed : 0;
|
||||
}
|
||||
|
||||
channelFromFreq() {
|
||||
if (this.freqMhz >= 2412 && this.freqMhz <= 2484) {
|
||||
if (this.freqMhz === 2484) return 14;
|
||||
return Math.round((this.freqMhz - 2412) / 5) + 1;
|
||||
}
|
||||
if (this.freqMhz >= 5180) {
|
||||
return Math.round((this.freqMhz - 5000) / 5);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
updateAmplitudes(amplitudes, phases) {
|
||||
const n = amplitudes.length;
|
||||
this.nSubcarriers = n;
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
this.amplitudes[i] = amplitudes[i];
|
||||
this.phases[i] = phases[i];
|
||||
|
||||
// Welford online variance
|
||||
this.ampCount[i]++;
|
||||
const delta = amplitudes[i] - this.ampMean[i];
|
||||
this.ampMean[i] += delta / this.ampCount[i];
|
||||
const delta2 = amplitudes[i] - this.ampMean[i];
|
||||
this.ampM2[i] += delta * delta2;
|
||||
}
|
||||
|
||||
// Store history snapshot
|
||||
this.ampHistory.push(Float64Array.from(amplitudes));
|
||||
this.phaseHistory.push(Float64Array.from(phases));
|
||||
if (this.ampHistory.length > this.historyMaxLen) {
|
||||
this.ampHistory.shift();
|
||||
this.phaseHistory.shift();
|
||||
}
|
||||
}
|
||||
|
||||
getVariance(i) {
|
||||
return this.ampCount[i] > 1 ? this.ampM2[i] / (this.ampCount[i] - 1) : 0;
|
||||
}
|
||||
|
||||
classify() {
|
||||
const n = this.nSubcarriers;
|
||||
if (n === 0) return { nulls: [], dynamic: [], reflectors: [], walls: [] };
|
||||
|
||||
// Find max amplitude for normalization
|
||||
let maxAmp = 0;
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (this.amplitudes[i] > maxAmp) maxAmp = this.amplitudes[i];
|
||||
}
|
||||
if (maxAmp === 0) maxAmp = 1;
|
||||
|
||||
const nulls = [];
|
||||
const dynamic = [];
|
||||
const reflectors = [];
|
||||
const walls = [];
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const normAmp = this.amplitudes[i] / maxAmp;
|
||||
const variance = this.getVariance(i);
|
||||
|
||||
if (this.amplitudes[i] < NULL_THRESHOLD) {
|
||||
nulls.push(i);
|
||||
} else if (variance > DYNAMIC_VAR_THRESH) {
|
||||
dynamic.push(i);
|
||||
} else if (normAmp > STRONG_AMP_THRESH) {
|
||||
reflectors.push(i);
|
||||
} else {
|
||||
walls.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
return { nulls, dynamic, reflectors, walls };
|
||||
}
|
||||
|
||||
getTypeMap() {
|
||||
const n = this.nSubcarriers;
|
||||
const types = new Array(n).fill(TYPE_UNKNOWN);
|
||||
const { nulls, dynamic, reflectors, walls } = this.classify();
|
||||
|
||||
for (const i of nulls) types[i] = TYPE_NULL;
|
||||
for (const i of dynamic) types[i] = TYPE_PERSON;
|
||||
for (const i of reflectors) types[i] = TYPE_REFLECTOR;
|
||||
for (const i of walls) types[i] = TYPE_WALL;
|
||||
|
||||
return types;
|
||||
}
|
||||
|
||||
getSpectrumBar() {
|
||||
const n = this.nSubcarriers;
|
||||
if (n === 0) return '';
|
||||
|
||||
let maxAmp = 0;
|
||||
for (let i = 0; i < n; i++) {
|
||||
if (this.amplitudes[i] > maxAmp) maxAmp = this.amplitudes[i];
|
||||
}
|
||||
if (maxAmp === 0) maxAmp = 1;
|
||||
|
||||
let bar = '';
|
||||
for (let i = 0; i < n; i++) {
|
||||
const level = Math.floor((this.amplitudes[i] / maxAmp) * 7.99);
|
||||
bar += BARS[Math.max(0, Math.min(7, level))];
|
||||
}
|
||||
return bar;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Global state
|
||||
// ---------------------------------------------------------------------------
|
||||
const nodes = new Map(); // nodeId -> NodeState
|
||||
const startTime = Date.now();
|
||||
let totalFrames = 0;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Packet parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
function parseCSIFrame(buf) {
|
||||
if (buf.length < HEADER_SIZE) return null;
|
||||
|
||||
const magic = buf.readUInt32LE(0);
|
||||
if (magic !== CSI_MAGIC) return null;
|
||||
|
||||
const nodeId = buf.readUInt8(4);
|
||||
const nAntennas = buf.readUInt8(5) || 1;
|
||||
const nSubcarriers = buf.readUInt16LE(6);
|
||||
const freqMhz = buf.readUInt32LE(8);
|
||||
const seq = buf.readUInt32LE(12);
|
||||
const rssi = buf.readInt8(16);
|
||||
const noiseFloor = buf.readInt8(17);
|
||||
|
||||
const iqLen = nSubcarriers * nAntennas * 2;
|
||||
if (buf.length < HEADER_SIZE + iqLen) return null;
|
||||
|
||||
// Extract amplitude and phase from I/Q pairs
|
||||
const amplitudes = new Float64Array(nSubcarriers);
|
||||
const phases = new Float64Array(nSubcarriers);
|
||||
|
||||
for (let sc = 0; sc < nSubcarriers; sc++) {
|
||||
// Use first antenna for primary analysis
|
||||
const offset = HEADER_SIZE + sc * 2;
|
||||
const I = buf.readInt8(offset);
|
||||
const Q = buf.readInt8(offset + 1);
|
||||
amplitudes[sc] = Math.sqrt(I * I + Q * Q);
|
||||
phases[sc] = Math.atan2(Q, I);
|
||||
}
|
||||
|
||||
return {
|
||||
nodeId, nAntennas, nSubcarriers, freqMhz, seq, rssi, noiseFloor,
|
||||
amplitudes, phases,
|
||||
};
|
||||
}
|
||||
|
||||
function parseVitalsPacket(buf) {
|
||||
if (buf.length < 32) return null;
|
||||
|
||||
const magic = buf.readUInt32LE(0);
|
||||
if (magic !== VITALS_MAGIC && magic !== FUSED_MAGIC) return null;
|
||||
|
||||
const nodeId = buf.readUInt8(4);
|
||||
const flags = buf.readUInt8(5);
|
||||
const breathingRate = buf.readUInt16LE(6) / 100;
|
||||
const heartrate = buf.readUInt32LE(8) / 10000;
|
||||
const rssi = buf.readInt8(12);
|
||||
const nPersons = buf.readUInt8(13);
|
||||
const motionEnergy = buf.readFloatLE(16);
|
||||
const presenceScore = buf.readFloatLE(20);
|
||||
const timestampMs = buf.readUInt32LE(24);
|
||||
|
||||
return {
|
||||
nodeId, flags,
|
||||
presence: !!(flags & 0x01),
|
||||
fall: !!(flags & 0x02),
|
||||
motion: !!(flags & 0x04),
|
||||
breathingRate, heartrate, rssi, nPersons,
|
||||
motionEnergy, presenceScore, timestampMs,
|
||||
isFused: magic === FUSED_MAGIC,
|
||||
};
|
||||
}
|
||||
|
||||
function parseFeaturePacket(buf) {
|
||||
if (buf.length < 48) return null;
|
||||
|
||||
const magic = buf.readUInt32LE(0);
|
||||
if (magic !== FEATURE_MAGIC) return null;
|
||||
|
||||
const nodeId = buf.readUInt8(4);
|
||||
const seq = buf.readUInt16LE(6);
|
||||
const features = [];
|
||||
for (let i = 0; i < 8; i++) {
|
||||
features.push(buf.readFloatLE(12 + i * 4));
|
||||
}
|
||||
|
||||
return { nodeId, seq, features };
|
||||
}
|
||||
|
||||
function handlePacket(buf, rinfo) {
|
||||
// Try CSI frame first (most common)
|
||||
if (buf.length >= 4) {
|
||||
const magic = buf.readUInt32LE(0);
|
||||
|
||||
if (magic === CSI_MAGIC) {
|
||||
const frame = parseCSIFrame(buf);
|
||||
if (!frame) return;
|
||||
|
||||
totalFrames++;
|
||||
let node = nodes.get(frame.nodeId);
|
||||
if (!node) {
|
||||
node = new NodeState(frame.nodeId);
|
||||
nodes.set(frame.nodeId, node);
|
||||
}
|
||||
|
||||
node.address = rinfo.address;
|
||||
node.freqMhz = frame.freqMhz;
|
||||
node.channel = node.channelFromFreq();
|
||||
node.rssi = frame.rssi;
|
||||
node.noiseFloor = frame.noiseFloor;
|
||||
node.frameCount++;
|
||||
node.lastFrameMs = Date.now();
|
||||
node.updateAmplitudes(frame.amplitudes, frame.phases);
|
||||
return;
|
||||
}
|
||||
|
||||
if (magic === VITALS_MAGIC || magic === FUSED_MAGIC) {
|
||||
const vitals = parseVitalsPacket(buf);
|
||||
if (!vitals) return;
|
||||
|
||||
let node = nodes.get(vitals.nodeId);
|
||||
if (!node) {
|
||||
node = new NodeState(vitals.nodeId);
|
||||
nodes.set(vitals.nodeId, node);
|
||||
}
|
||||
node.vitals = vitals;
|
||||
return;
|
||||
}
|
||||
|
||||
if (magic === FEATURE_MAGIC) {
|
||||
const feat = parseFeaturePacket(buf);
|
||||
if (!feat) return;
|
||||
|
||||
let node = nodes.get(feat.nodeId);
|
||||
if (!node) {
|
||||
node = new NodeState(feat.nodeId);
|
||||
nodes.set(feat.nodeId, node);
|
||||
}
|
||||
node.features = feat;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cross-node analysis
|
||||
// ---------------------------------------------------------------------------
|
||||
function computeCrossNodeCorrelation() {
|
||||
const nodeList = [...nodes.values()].filter(n => n.nSubcarriers > 0);
|
||||
if (nodeList.length < 2) return null;
|
||||
|
||||
const n0 = nodeList[0];
|
||||
const n1 = nodeList[1];
|
||||
const len = Math.min(n0.nSubcarriers, n1.nSubcarriers);
|
||||
|
||||
// Pearson correlation of amplitude vectors
|
||||
let sumXY = 0, sumX = 0, sumY = 0, sumX2 = 0, sumY2 = 0;
|
||||
for (let i = 0; i < len; i++) {
|
||||
const x = n0.amplitudes[i];
|
||||
const y = n1.amplitudes[i];
|
||||
sumX += x; sumY += y;
|
||||
sumXY += x * y;
|
||||
sumX2 += x * x;
|
||||
sumY2 += y * y;
|
||||
}
|
||||
|
||||
const denom = Math.sqrt((len * sumX2 - sumX * sumX) * (len * sumY2 - sumY * sumY));
|
||||
const correlation = denom > 0 ? (len * sumXY - sumX * sumY) / denom : 0;
|
||||
|
||||
// Phase coherence between nodes
|
||||
let coherenceSum = 0;
|
||||
for (let i = 0; i < len; i++) {
|
||||
const phaseDiff = n0.phases[i] - n1.phases[i];
|
||||
coherenceSum += Math.cos(phaseDiff);
|
||||
}
|
||||
const phaseCoherence = len > 0 ? coherenceSum / len : 0;
|
||||
|
||||
// Count matching nulls
|
||||
const c0 = n0.classify();
|
||||
const c1 = n1.classify();
|
||||
const nullSet0 = new Set(c0.nulls);
|
||||
const sharedNulls = c1.nulls.filter(i => nullSet0.has(i));
|
||||
|
||||
return {
|
||||
correlation: correlation.toFixed(3),
|
||||
phaseCoherence: phaseCoherence.toFixed(3),
|
||||
los: phaseCoherence > COHERENCE_THRESH ? 'LINE-OF-SIGHT' : 'MULTIPATH',
|
||||
sharedNulls: sharedNulls.length,
|
||||
uniqueNulls0: c0.nulls.length - sharedNulls.length,
|
||||
uniqueNulls1: c1.nulls.length - sharedNulls.length,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Display
|
||||
// ---------------------------------------------------------------------------
|
||||
function buildProgressBar(value, max, width) {
|
||||
const filled = Math.round((value / max) * width);
|
||||
return '\u2588'.repeat(Math.min(filled, width)) +
|
||||
'\u2591'.repeat(Math.max(0, width - filled));
|
||||
}
|
||||
|
||||
function renderASCII() {
|
||||
const lines = [];
|
||||
const nodeList = [...nodes.values()].filter(n => n.nSubcarriers > 0);
|
||||
|
||||
if (nodeList.length === 0) {
|
||||
lines.push(`=== RUVIEW RF SCAN === Listening on UDP :${PORT} ... no data yet`);
|
||||
lines.push('Waiting for CSI frames from ESP32 nodes...');
|
||||
lines.push(`Elapsed: ${((Date.now() - startTime) / 1000).toFixed(0)}s | Frames: ${totalFrames}`);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
for (const node of nodeList) {
|
||||
const ch = node.channel || '?';
|
||||
const freq = node.freqMhz || '?';
|
||||
lines.push(`=== RUVIEW RF SCAN -- Channel ${ch} (${freq} MHz) ===`);
|
||||
lines.push(`Node ${node.nodeId} (${node.address || '?'}) | ${node.fps.toFixed(1)} fps | RSSI ${node.rssi} dBm | Noise ${node.noiseFloor} dBm`);
|
||||
|
||||
// Spectrum bar
|
||||
const spectrum = node.getSpectrumBar();
|
||||
if (spectrum.length > 0) {
|
||||
lines.push(`Spectrum: ${spectrum}`);
|
||||
|
||||
// Type map
|
||||
const types = node.getTypeMap();
|
||||
lines.push(`Type: ${types.join('')}`);
|
||||
lines.push(` ${TYPE_WALL} wall ${TYPE_PERSON} person ${TYPE_REFLECTOR} reflector ${TYPE_NULL} null(metal)`);
|
||||
}
|
||||
|
||||
// Classification summary
|
||||
const cls = node.classify();
|
||||
lines.push('');
|
||||
lines.push(`Objects: ${cls.nulls.length} null zones (metal) | ${cls.dynamic.length} dynamic (person/motion) | ${cls.reflectors.length} strong reflectors | ${cls.walls.length} static`);
|
||||
|
||||
const nullPct = node.nSubcarriers > 0
|
||||
? ((cls.nulls.length / node.nSubcarriers) * 100).toFixed(0)
|
||||
: '0';
|
||||
lines.push(`Nulls: ${nullPct}% of subcarriers blocked`);
|
||||
|
||||
// Vitals
|
||||
if (node.vitals) {
|
||||
const v = node.vitals;
|
||||
const presenceBar = buildProgressBar(v.presenceScore, 1, 10);
|
||||
const motionBar = buildProgressBar(Math.min(v.motionEnergy, 1), 1, 10);
|
||||
const position = v.presenceScore > 0.5 ? 'CENTERED' : v.presenceScore > 0.2 ? 'PERIPHERAL' : 'EMPTY';
|
||||
|
||||
lines.push(`Person: ${position} | BR ${v.breathingRate.toFixed(0)} BPM | HR ${v.heartrate.toFixed(0)} BPM | Motion ${v.motion ? 'HIGH' : 'LOW'}${v.fall ? ' | !! FALL !!' : ''}`);
|
||||
lines.push(`Vitals: ${presenceBar} ${v.presenceScore.toFixed(2)} presence | ${motionBar} ${v.motionEnergy.toFixed(2)} motion | ${v.nPersons} person(s)`);
|
||||
} else {
|
||||
lines.push('Person: (awaiting vitals packet)');
|
||||
}
|
||||
|
||||
// Feature vector
|
||||
if (node.features) {
|
||||
const fv = node.features.features.map(f => f.toFixed(3)).join(', ');
|
||||
lines.push(`Feature: [${fv}]`);
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
// Cross-node analysis
|
||||
if (nodeList.length >= 2) {
|
||||
const cross = computeCrossNodeCorrelation();
|
||||
if (cross) {
|
||||
lines.push('--- Cross-Node Analysis ---');
|
||||
lines.push(`Correlation: ${cross.correlation} | Phase coherence: ${cross.phaseCoherence} | ${cross.los}`);
|
||||
lines.push(`Nulls: ${cross.sharedNulls} shared | ${cross.uniqueNulls0} node-0-only | ${cross.uniqueNulls1} node-1-only`);
|
||||
lines.push('');
|
||||
}
|
||||
}
|
||||
|
||||
// Summary line
|
||||
const elapsed = ((Date.now() - startTime) / 1000).toFixed(0);
|
||||
lines.push(`Elapsed: ${elapsed}s | Total frames: ${totalFrames} | Nodes: ${nodeList.length}`);
|
||||
if (DURATION_MS) {
|
||||
const remaining = Math.max(0, (DURATION_MS - (Date.now() - startTime)) / 1000).toFixed(0);
|
||||
lines.push(`Remaining: ${remaining}s`);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function buildJsonOutput() {
|
||||
const nodeList = [...nodes.values()].filter(n => n.nSubcarriers > 0);
|
||||
|
||||
const result = {
|
||||
timestamp: new Date().toISOString(),
|
||||
elapsedMs: Date.now() - startTime,
|
||||
totalFrames,
|
||||
nodes: nodeList.map(node => {
|
||||
const cls = node.classify();
|
||||
return {
|
||||
nodeId: node.nodeId,
|
||||
address: node.address,
|
||||
channel: node.channel,
|
||||
freqMhz: node.freqMhz,
|
||||
rssi: node.rssi,
|
||||
noiseFloor: node.noiseFloor,
|
||||
fps: parseFloat(node.fps.toFixed(2)),
|
||||
nSubcarriers: node.nSubcarriers,
|
||||
frameCount: node.frameCount,
|
||||
classification: {
|
||||
nullCount: cls.nulls.length,
|
||||
dynamicCount: cls.dynamic.length,
|
||||
reflectorCount: cls.reflectors.length,
|
||||
staticCount: cls.walls.length,
|
||||
nullPercent: node.nSubcarriers > 0
|
||||
? parseFloat(((cls.nulls.length / node.nSubcarriers) * 100).toFixed(1))
|
||||
: 0,
|
||||
},
|
||||
vitals: node.vitals ? {
|
||||
presence: node.vitals.presence,
|
||||
presenceScore: node.vitals.presenceScore,
|
||||
motionEnergy: node.vitals.motionEnergy,
|
||||
breathingRate: node.vitals.breathingRate,
|
||||
heartrate: node.vitals.heartrate,
|
||||
nPersons: node.vitals.nPersons,
|
||||
fall: node.vitals.fall,
|
||||
} : null,
|
||||
features: node.features ? node.features.features : null,
|
||||
amplitudes: Array.from(node.amplitudes.subarray(0, node.nSubcarriers)),
|
||||
phases: Array.from(node.phases.subarray(0, node.nSubcarriers)),
|
||||
};
|
||||
}),
|
||||
crossNode: computeCrossNodeCorrelation(),
|
||||
};
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function display() {
|
||||
if (JSON_OUTPUT) {
|
||||
const data = buildJsonOutput();
|
||||
process.stdout.write(JSON.stringify(data) + '\n');
|
||||
} else {
|
||||
// Clear screen and move cursor to top
|
||||
process.stdout.write('\x1B[2J\x1B[H');
|
||||
process.stdout.write(renderASCII() + '\n');
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
function main() {
|
||||
const server = dgram.createSocket('udp4');
|
||||
|
||||
server.on('error', (err) => {
|
||||
console.error(`UDP error: ${err.message}`);
|
||||
server.close();
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
server.on('message', (msg, rinfo) => {
|
||||
handlePacket(msg, rinfo);
|
||||
});
|
||||
|
||||
server.on('listening', () => {
|
||||
const addr = server.address();
|
||||
if (!JSON_OUTPUT) {
|
||||
console.log(`RuView RF Scanner listening on ${addr.address}:${addr.port}`);
|
||||
console.log('Waiting for CSI frames from ESP32 nodes...\n');
|
||||
}
|
||||
});
|
||||
|
||||
server.bind(PORT);
|
||||
|
||||
// Periodic display update
|
||||
const displayTimer = setInterval(display, INTERVAL_MS);
|
||||
|
||||
// Duration timeout
|
||||
if (DURATION_MS) {
|
||||
setTimeout(() => {
|
||||
clearInterval(displayTimer);
|
||||
|
||||
if (JSON_OUTPUT) {
|
||||
// Final JSON summary
|
||||
const summary = buildJsonOutput();
|
||||
summary.final = true;
|
||||
process.stdout.write(JSON.stringify(summary) + '\n');
|
||||
} else {
|
||||
display();
|
||||
console.log('\n--- Scan complete ---');
|
||||
|
||||
const nodeList = [...nodes.values()].filter(n => n.nSubcarriers > 0);
|
||||
console.log(`Duration: ${(DURATION_MS / 1000).toFixed(0)}s`);
|
||||
console.log(`Total frames: ${totalFrames}`);
|
||||
console.log(`Nodes detected: ${nodeList.length}`);
|
||||
|
||||
for (const node of nodeList) {
|
||||
const cls = node.classify();
|
||||
console.log(` Node ${node.nodeId}: ${node.frameCount} frames, ${node.fps.toFixed(1)} fps, ch ${node.channel}, ${cls.nulls.length} nulls (${((cls.nulls.length / Math.max(1, node.nSubcarriers)) * 100).toFixed(0)}%)`);
|
||||
}
|
||||
}
|
||||
|
||||
server.close();
|
||||
process.exit(0);
|
||||
}, DURATION_MS);
|
||||
}
|
||||
|
||||
// Graceful shutdown
|
||||
process.on('SIGINT', () => {
|
||||
clearInterval(displayTimer);
|
||||
if (!JSON_OUTPUT) {
|
||||
console.log('\nShutting down...');
|
||||
}
|
||||
server.close();
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
main();
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,155 +0,0 @@
|
||||
{
|
||||
"description": "WiFi-DensePose hyperparameter sweep — 10 configurations exploring learning rate, batch size, backbone width, window length, loss ratios, and warmup schedules.",
|
||||
"base": {
|
||||
"num_subcarriers": 56,
|
||||
"native_subcarriers": 114,
|
||||
"num_antennas_tx": 3,
|
||||
"num_antennas_rx": 3,
|
||||
"heatmap_size": 56,
|
||||
"num_keypoints": 17,
|
||||
"num_body_parts": 24,
|
||||
"weight_decay": 1e-4,
|
||||
"num_epochs": 50,
|
||||
"lr_gamma": 0.1,
|
||||
"grad_clip_norm": 1.0,
|
||||
"val_every_epochs": 1,
|
||||
"early_stopping_patience": 10,
|
||||
"save_top_k": 3,
|
||||
"use_gpu": true,
|
||||
"gpu_device_id": 0,
|
||||
"num_workers": 4,
|
||||
"seed": 42
|
||||
},
|
||||
"configs": [
|
||||
{
|
||||
"_name": "baseline",
|
||||
"_description": "Default config — reference baseline",
|
||||
"learning_rate": 1e-3,
|
||||
"batch_size": 8,
|
||||
"backbone_channels": 256,
|
||||
"window_frames": 100,
|
||||
"warmup_epochs": 5,
|
||||
"lr_milestones": [30, 45],
|
||||
"lambda_kp": 0.3,
|
||||
"lambda_dp": 0.6,
|
||||
"lambda_tr": 0.1
|
||||
},
|
||||
{
|
||||
"_name": "low_lr_large_batch",
|
||||
"_description": "Lower LR with larger batch — stable convergence",
|
||||
"learning_rate": 1e-4,
|
||||
"batch_size": 16,
|
||||
"backbone_channels": 256,
|
||||
"window_frames": 100,
|
||||
"warmup_epochs": 10,
|
||||
"lr_milestones": [30, 45],
|
||||
"lambda_kp": 0.3,
|
||||
"lambda_dp": 0.6,
|
||||
"lambda_tr": 0.1
|
||||
},
|
||||
{
|
||||
"_name": "high_lr_small_batch",
|
||||
"_description": "Higher LR with small batch — fast exploration",
|
||||
"learning_rate": 2e-3,
|
||||
"batch_size": 4,
|
||||
"backbone_channels": 256,
|
||||
"window_frames": 100,
|
||||
"warmup_epochs": 3,
|
||||
"lr_milestones": [20, 40],
|
||||
"lambda_kp": 0.3,
|
||||
"lambda_dp": 0.6,
|
||||
"lambda_tr": 0.1
|
||||
},
|
||||
{
|
||||
"_name": "narrow_backbone",
|
||||
"_description": "128-channel backbone — faster training, lower VRAM",
|
||||
"learning_rate": 1e-3,
|
||||
"batch_size": 16,
|
||||
"backbone_channels": 128,
|
||||
"window_frames": 100,
|
||||
"warmup_epochs": 5,
|
||||
"lr_milestones": [30, 45],
|
||||
"lambda_kp": 0.3,
|
||||
"lambda_dp": 0.6,
|
||||
"lambda_tr": 0.1
|
||||
},
|
||||
{
|
||||
"_name": "short_window",
|
||||
"_description": "50-frame window — lower latency, tests temporal sensitivity",
|
||||
"learning_rate": 5e-4,
|
||||
"batch_size": 16,
|
||||
"backbone_channels": 256,
|
||||
"window_frames": 50,
|
||||
"warmup_epochs": 5,
|
||||
"lr_milestones": [30, 45],
|
||||
"lambda_kp": 0.3,
|
||||
"lambda_dp": 0.6,
|
||||
"lambda_tr": 0.1
|
||||
},
|
||||
{
|
||||
"_name": "keypoint_heavy",
|
||||
"_description": "Heavier keypoint loss — prioritize skeleton accuracy",
|
||||
"learning_rate": 5e-4,
|
||||
"batch_size": 8,
|
||||
"backbone_channels": 256,
|
||||
"window_frames": 100,
|
||||
"warmup_epochs": 5,
|
||||
"lr_milestones": [30, 45],
|
||||
"lambda_kp": 0.5,
|
||||
"lambda_dp": 0.4,
|
||||
"lambda_tr": 0.1
|
||||
},
|
||||
{
|
||||
"_name": "contrastive_heavy",
|
||||
"_description": "Strong contrastive/transfer loss — self-supervised pretraining focus",
|
||||
"learning_rate": 5e-4,
|
||||
"batch_size": 8,
|
||||
"backbone_channels": 256,
|
||||
"window_frames": 100,
|
||||
"warmup_epochs": 10,
|
||||
"lr_milestones": [30, 45],
|
||||
"lambda_kp": 0.2,
|
||||
"lambda_dp": 0.3,
|
||||
"lambda_tr": 0.5
|
||||
},
|
||||
{
|
||||
"_name": "wide_backbone_long_warmup",
|
||||
"_description": "256-ch backbone + long warmup + moderate LR",
|
||||
"learning_rate": 5e-4,
|
||||
"batch_size": 8,
|
||||
"backbone_channels": 256,
|
||||
"window_frames": 100,
|
||||
"warmup_epochs": 10,
|
||||
"lr_milestones": [35, 48],
|
||||
"lambda_kp": 0.3,
|
||||
"lambda_dp": 0.6,
|
||||
"lambda_tr": 0.1
|
||||
},
|
||||
{
|
||||
"_name": "narrow_short_aggressive",
|
||||
"_description": "128-ch + 50-frame + high LR — fast cheap exploration",
|
||||
"learning_rate": 2e-3,
|
||||
"batch_size": 16,
|
||||
"backbone_channels": 128,
|
||||
"window_frames": 50,
|
||||
"warmup_epochs": 3,
|
||||
"lr_milestones": [20, 40],
|
||||
"lambda_kp": 0.4,
|
||||
"lambda_dp": 0.5,
|
||||
"lambda_tr": 0.1
|
||||
},
|
||||
{
|
||||
"_name": "balanced_medium",
|
||||
"_description": "Balanced loss, medium LR, medium batch — robust default",
|
||||
"learning_rate": 5e-4,
|
||||
"batch_size": 8,
|
||||
"backbone_channels": 256,
|
||||
"window_frames": 100,
|
||||
"warmup_epochs": 5,
|
||||
"lr_milestones": [25, 40],
|
||||
"lambda_kp": 0.35,
|
||||
"lambda_dp": 0.45,
|
||||
"lambda_tr": 0.2
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user