diff --git a/docs/adr/ADR-075-mincut-person-separation.md b/docs/adr/ADR-075-mincut-person-separation.md new file mode 100644 index 00000000..2166d16d --- /dev/null +++ b/docs/adr/ADR-075-mincut-person-separation.md @@ -0,0 +1,195 @@ +# ADR-075: Min-Cut Based Person Separation from Subcarrier Correlation + +- **Status:** Proposed +- **Date:** 2026-04-02 +- **Issue:** #348 — `n_persons` always reports 4 regardless of actual occupancy +- **Depends on:** ADR-016 (RuVector integration), ADR-041 (person tracking), ADR-073 (multifrequency mesh scan) + +## Context + +### The Bug + +Issue #348 reports that the ESP32 firmware's multi-person counting always reports +`n_persons = 4`. The root cause is in the WASM edge module +`sig_mincut_person_match.rs`, which uses a fixed `MAX_PERSONS = 4` constant and a +threshold-based variance classifier to populate person slots. The classifier bins +subcarriers into "dynamic" vs "static" using a single fixed variance threshold +(`DYNAMIC_VAR_THRESH = 0.15`). In practice: + +1. The threshold is miscalibrated for real-world CSI data — almost any room with + multipath reflections pushes a majority of subcarriers above 0.15 variance. +2. The subcarrier-to-person assignment uses a greedy Hungarian-lite matcher that + fills all 4 slots once there are >= 4 dynamic subcarriers (which is nearly + always the case). +3. There is no mechanism to determine how many independent movers exist — the + algorithm assumes all 4 slots should be filled. + +### Prior Art + +The Rust crate `ruvector-mincut` (vendored at `vendor/ruvector/crates/ruvector-mincut/`) +implements a full dynamic min-cut algorithm with O(n^{o(1)}) amortized update time, +Stoer-Wagner exact min-cut, and online edge insert/delete. It is already integrated +in the training pipeline (`wifi-densepose-train/src/metrics.rs`) via +`DynamicPersonMatcher`. + +### WiFi Sensing Insight + +When a person moves through a room, they perturb the Fresnel zones of specific +subcarrier frequencies. Subcarriers whose Fresnel zones overlap the person's body +change **together** — their amplitudes are temporally correlated. When two people +move independently, they create two **separate** groups of correlated subcarriers. +This correlation structure forms a natural graph partitioning problem. + +## Decision + +Replace the fixed-threshold person counter with a spectral min-cut algorithm +operating on the subcarrier temporal correlation graph. This runs in the bridge +script (`scripts/mincut-person-counter.js`) or on Cognitum Seed, and feeds the +corrected person count back to the feature vector before ingest. + +### Algorithm + +1. **Sliding window accumulation**: Maintain the last 2 seconds of subcarrier + amplitude data (~40 frames at 20 fps). Each frame provides a 64-element + amplitude vector (one per subcarrier). + +2. **Pairwise Pearson correlation**: For all subcarrier pairs (i, j), compute + the Pearson correlation coefficient over the sliding window: + + ``` + r(i,j) = cov(amp_i, amp_j) / (std(amp_i) * std(amp_j)) + ``` + + This produces a 64x64 correlation matrix. + +3. **Graph construction**: Build a weighted undirected graph: + - **Nodes** = subcarriers (64 for single-antenna ESP32-S3, up to 128 for dual) + - **Edges** = pairs with |r(i,j)| > 0.3 (correlation threshold) + - **Weight** = |r(i,j)| (correlation strength) + - Discard null subcarriers (amplitude consistently near zero) + - Expected: ~1500-2500 edges for 64 active subcarriers + +4. **Iterative Stoer-Wagner min-cut**: Apply the Stoer-Wagner algorithm to find + the global minimum cut. If the min-cut weight is below a separation threshold + (empirically 2.0), the cut represents a real boundary between independent + movers. Split the graph at the cut and recurse on each partition. + +5. **Person count**: The number of partitions after all valid cuts = number of + independent movers = person count. A single connected component with high + internal correlation and no low-weight cut = 1 person (or 0 if variance is + also low). + +6. **Empty room detection**: If the total variance across all subcarriers is + below a noise floor threshold, report 0 persons regardless of graph structure. + +### Stoer-Wagner Algorithm + +Stoer-Wagner finds the exact global minimum cut of an undirected weighted graph +in O(V * E) time using a sequence of "minimum cut phases": + +``` +function stoerWagner(G): + best_cut = infinity + while |V(G)| > 1: + (s, t, cut_of_phase) = minimumCutPhase(G) + if cut_of_phase < best_cut: + best_cut = cut_of_phase + best_partition = partition induced by t + merge(s, t) // contract vertices s and t + return best_cut, best_partition + +function minimumCutPhase(G): + A = {arbitrary start vertex} + while A != V(G): + z = vertex most tightly connected to A + // "most tightly connected" = max sum of edge weights to A + add z to A + s = second-to-last vertex added + t = last vertex added (most tightly connected) + cut_of_phase = sum of weights of edges incident to t + return (s, t, cut_of_phase) +``` + +For V=64 subcarriers and E~2000 edges, this runs in ~8 million operations, +well under 1ms on modern hardware and under 10ms even on ESP32-S3. + +### Integration Points + +``` +ESP32 Node 1 ──UDP 5006──┐ + ├──> mincut-person-counter.js ──> corrected n_persons +ESP32 Node 2 ──UDP 5006──┘ │ + ├──> seed_csi_bridge.py (feature dim 5 override) + └──> csi-graph-visualizer.js (debug view) +``` + +The person counter runs as a standalone Node.js process alongside the existing +`rf-scan.js` and `seed_csi_bridge.py` bridge scripts. It can also replay +recorded `.csi.jsonl` files for offline analysis. + +## Alternatives Considered + +### 1. Threshold-based peak counting (current, broken) + +Count subcarriers with variance above a threshold, then cluster by proximity. +**Problem:** threshold is environment-dependent, miscalibrates easily, and +cannot distinguish correlated from independent motion. + +### 2. PCA / spectral clustering on correlation matrix + +Compute eigenvectors of the correlation matrix; the number of large eigenvalues +indicates the number of independent sources. **Problem:** requires choosing an +eigenvalue gap threshold, which is as fragile as the current variance threshold. +Also does not give per-person subcarrier assignments. + +### 3. Min-cut on correlation graph (this ADR) + +**Advantages:** +- Directly models the physical structure (Fresnel zone groupings) +- Threshold-free person counting (cut weight is a natural separation metric) +- Produces per-person subcarrier groups as a side effect +- Stoer-Wagner is simple to implement (~100 lines) and runs in polynomial time +- Already validated in Rust via `ruvector-mincut` integration + +## Performance + +| Metric | Value | +|--------|-------| +| Graph size | V=64, E~2000 | +| Stoer-Wagner complexity | O(V * E) = O(128,000) per cut | +| Iterative cuts (max 4) | O(512,000) total | +| Wall time (Node.js) | < 5 ms per 2-second window | +| Wall time (Rust/WASM) | < 0.5 ms | +| Memory | ~32 KB for correlation matrix + graph | +| Sliding window | 2 seconds = ~40 frames * 64 subcarriers * 8 bytes = 20 KB | + +## Consequences + +### Positive + +- Fixes #348: person count now reflects actual independent movers +- Robust across environments (no per-room threshold calibration) +- Per-person subcarrier groups enable per-person feature extraction +- Graph visualization aids debugging and room mapping +- Algorithm is well-understood (Stoer-Wagner, 1997) + +### Negative + +- Adds a new process to the sensing pipeline +- 2-second latency for person count changes (sliding window) +- Correlation-based: cannot detect stationary persons (no motion = no signal) +- Assumes independent motion — two people walking in sync may be counted as one + +### Migration + +1. Deploy `scripts/mincut-person-counter.js` alongside existing bridge +2. Override feature vector dimension 5 (`n_persons`) with corrected count +3. Once validated, port Stoer-Wagner to C for direct ESP32-S3 firmware integration +4. Deprecate the fixed-threshold `PersonMatcher` in `sig_mincut_person_match.rs` + +## References + +- Stoer, M. & Wagner, F. (1997). "A Simple Min-Cut Algorithm." JACM 44(4). +- `vendor/ruvector/crates/ruvector-mincut/src/algorithm/mod.rs` — DynamicMinCut API +- `rust-port/.../sig_mincut_person_match.rs` — current (broken) WASM edge matcher +- `scripts/rf-scan.js` — CSI packet parsing and subcarrier classification diff --git a/scripts/csi-graph-visualizer.js b/scripts/csi-graph-visualizer.js new file mode 100644 index 00000000..bae374ee --- /dev/null +++ b/scripts/csi-graph-visualizer.js @@ -0,0 +1,674 @@ +#!/usr/bin/env node +/** + * ADR-075: CSI Subcarrier Correlation Graph Visualizer + * + * ASCII visualization of the subcarrier correlation graph used by the + * min-cut person counter. Shows per-person subcarrier clusters, graph + * connectivity, and correlation heatmap in real-time. + * + * Usage: + * # Live from ESP32 nodes via UDP + * node scripts/csi-graph-visualizer.js --port 5006 + * + * # Replay from recorded CSI data + * node scripts/csi-graph-visualizer.js --replay data/recordings/pretrain-1775182186.csi.jsonl + * + * # Show correlation heatmap only + * node scripts/csi-graph-visualizer.js --replay FILE --mode heatmap + * + * ADR: docs/adr/ADR-075-mincut-person-separation.md + */ + +'use strict'; + +const dgram = require('dgram'); +const fs = require('fs'); +const readline = require('readline'); +const { parseArgs } = require('util'); + +// --------------------------------------------------------------------------- +// CLI +// --------------------------------------------------------------------------- +const { values: args } = parseArgs({ + options: { + port: { type: 'string', short: 'p', default: '5006' }, + replay: { type: 'string', short: 'r' }, + interval: { type: 'string', short: 'i', default: '2000' }, + window: { type: 'string', short: 'w', default: '2000' }, + mode: { type: 'string', short: 'm', default: 'all' }, + node: { type: 'string', short: 'n', default: '0' }, + 'corr-threshold': { type: 'string', default: '0.3' }, + 'cut-threshold': { type: 'string', default: '2.0' }, + 'var-floor': { type: 'string', default: '0.5' }, + width: { type: 'string', default: '80' }, + }, + strict: true, +}); + +const PORT = parseInt(args.port, 10); +const INTERVAL_MS = parseInt(args.interval, 10); +const WINDOW_MS = parseInt(args.window, 10); +const CORR_THRESHOLD = parseFloat(args['corr-threshold']); +const CUT_THRESHOLD = parseFloat(args['cut-threshold']); +const VAR_FLOOR = parseFloat(args['var-floor']); +const MODE = args.mode; // 'all', 'heatmap', 'clusters', 'spectrum' +const TARGET_NODE = parseInt(args.node, 10); +const WIDTH = parseInt(args.width, 10); + +const CSI_MAGIC = 0xC5110001; +const HEADER_SIZE = 20; + +// Color palette for person clusters (ANSI 256) +const PERSON_COLORS = [ + '\x1b[31m', // red + '\x1b[32m', // green + '\x1b[34m', // blue + '\x1b[33m', // yellow + '\x1b[35m', // magenta + '\x1b[36m', // cyan + '\x1b[91m', // bright red + '\x1b[92m', // bright green +]; +const RESET = '\x1b[0m'; +const DIM = '\x1b[2m'; +const BOLD = '\x1b[1m'; + +// Heatmap characters (11 levels of intensity) +const HEAT = [' ', '\u2591', '\u2591', '\u2592', '\u2592', '\u2593', '\u2593', '\u2588', '\u2588', '\u2588', '\u2588']; + +// Bar chart characters +const BARS = ['\u2581', '\u2582', '\u2583', '\u2584', '\u2585', '\u2586', '\u2587', '\u2588']; + +// --------------------------------------------------------------------------- +// Sliding window (same as mincut-person-counter.js) +// --------------------------------------------------------------------------- +class SubcarrierWindow { + constructor(maxAgeMs) { + this.maxAgeMs = maxAgeMs; + this.frames = []; + this.nSubcarriers = 0; + } + + push(timestamp, amplitudes) { + this.nSubcarriers = amplitudes.length; + this.frames.push({ timestamp, amplitudes: Float64Array.from(amplitudes) }); + const cutoff = timestamp - this.maxAgeMs; + while (this.frames.length > 0 && this.frames[0].timestamp < cutoff) { + this.frames.shift(); + } + } + + get length() { return this.frames.length; } + + correlationMatrix() { + const nFrames = this.frames.length; + const nSc = this.nSubcarriers; + if (nFrames < 5 || nSc === 0) return null; + + const mean = new Float64Array(nSc); + const std = new Float64Array(nSc); + + for (let f = 0; f < nFrames; f++) { + const amp = this.frames[f].amplitudes; + for (let i = 0; i < nSc; i++) mean[i] += amp[i]; + } + for (let i = 0; i < nSc; i++) mean[i] /= nFrames; + + for (let f = 0; f < nFrames; f++) { + const amp = this.frames[f].amplitudes; + for (let i = 0; i < nSc; i++) { + const d = amp[i] - mean[i]; + std[i] += d * d; + } + } + for (let i = 0; i < nSc; i++) std[i] = Math.sqrt(std[i] / (nFrames - 1)); + + const activeIndices = []; + for (let i = 0; i < nSc; i++) { + if (std[i] > VAR_FLOOR) activeIndices.push(i); + } + + const n = activeIndices.length; + if (n < 2) return { matrix: null, n: 0, activeIndices, mean, std }; + + const matrix = new Float64Array(n * n); + for (let ai = 0; ai < n; ai++) { + matrix[ai * n + ai] = 1.0; + const si = activeIndices[ai]; + for (let aj = ai + 1; aj < n; aj++) { + const sj = activeIndices[aj]; + let cov = 0; + for (let f = 0; f < nFrames; f++) { + const amp = this.frames[f].amplitudes; + cov += (amp[si] - mean[si]) * (amp[sj] - mean[sj]); + } + cov /= (nFrames - 1); + const denom = std[si] * std[sj]; + const r = denom > 1e-10 ? cov / denom : 0; + matrix[ai * n + aj] = r; + matrix[aj * n + ai] = r; + } + } + + return { matrix, n, activeIndices, mean, std }; + } + + /** Get latest amplitudes */ + latestAmplitudes() { + if (this.frames.length === 0) return null; + return this.frames[this.frames.length - 1].amplitudes; + } +} + +// --------------------------------------------------------------------------- +// Graph + Stoer-Wagner (minimal copy from mincut-person-counter.js) +// --------------------------------------------------------------------------- +class WeightedGraph { + constructor(n) { + this.n = n; + this.adj = new Array(n); + for (let i = 0; i < n; i++) this.adj[i] = new Map(); + this.edgeCount = 0; + } + addEdge(u, v, w) { + if (u === v) return; + if (!this.adj[u].has(v)) this.edgeCount++; + this.adj[u].set(v, w); + this.adj[v].set(u, w); + } + static fromCorrelation(matrix, n, threshold) { + const g = new WeightedGraph(n); + for (let i = 0; i < n; i++) { + for (let j = i + 1; j < n; j++) { + const r = Math.abs(matrix[i * n + j]); + if (r > threshold) g.addEdge(i, j, r); + } + } + return g; + } + connectedComponents() { + const visited = new Uint8Array(this.n); + const components = []; + for (let start = 0; start < this.n; start++) { + if (visited[start]) continue; + const comp = []; + const queue = [start]; + visited[start] = 1; + while (queue.length > 0) { + const u = queue.shift(); + comp.push(u); + for (const [v] of this.adj[u]) { + if (!visited[v]) { visited[v] = 1; queue.push(v); } + } + } + components.push(comp); + } + return components; + } + subgraph(vertices) { + const newIdx = new Map(); + vertices.forEach((v, i) => newIdx.set(v, i)); + const sub = new WeightedGraph(vertices.length); + for (const u of vertices) { + for (const [v, w] of this.adj[u]) { + if (newIdx.has(v) && u < v) sub.addEdge(newIdx.get(u), newIdx.get(v), w); + } + } + return { graph: sub, mapping: vertices }; + } +} + +function stoerWagner(graph) { + const n = graph.n; + if (n <= 1) return { minCutValue: Infinity, partition: [Array.from({length: n}, (_, i) => i), []] }; + + const adj = new Array(n); + for (let i = 0; i < n; i++) adj[i] = new Map(graph.adj[i]); + const groups = new Array(n); + for (let i = 0; i < n; i++) groups[i] = [i]; + + let activeVertices = Array.from({length: n}, (_, i) => i); + let bestCut = Infinity; + let bestPartitionSide = null; + + while (activeVertices.length > 1) { + const key = new Float64Array(n); + const inA = new Uint8Array(n); + let s = -1, t = -1; + + for (let iter = 0; iter < activeVertices.length; iter++) { + let best = -1, bestKey = -Infinity; + for (const v of activeVertices) { + if (!inA[v] && key[v] > bestKey) { bestKey = key[v]; best = v; } + } + if (best === -1) { + for (const v of activeVertices) { if (!inA[v]) { best = v; break; } } + } + s = t; t = best; inA[best] = 1; + if (adj[best]) { + for (const [nb, w] of adj[best]) { + if (activeVertices.includes(nb) && !inA[nb]) key[nb] += w; + } + } + } + + let cutOfPhase = 0; + if (adj[t]) { + for (const [nb, w] of adj[t]) { + if (activeVertices.includes(nb) && nb !== t) cutOfPhase += w; + } + } + + if (s === -1 || t === -1) break; + if (cutOfPhase < bestCut) { bestCut = cutOfPhase; bestPartitionSide = [...groups[t]]; } + + if (adj[t]) { + for (const [nb, w] of adj[t]) { + if (nb === s) continue; + const ex = adj[s].get(nb) || 0; + adj[s].set(nb, ex + w); + adj[nb].delete(t); + adj[nb].set(s, ex + w); + } + } + adj[s].delete(t); + groups[s] = groups[s].concat(groups[t]); + groups[t] = []; + activeVertices = activeVertices.filter(v => v !== t); + } + + if (!bestPartitionSide || bestPartitionSide.length === 0) { + return { minCutValue: Infinity, partition: [Array.from({length: n}, (_, i) => i), []] }; + } + const sideSet = new Set(bestPartitionSide); + const sideA = [], sideB = []; + for (let i = 0; i < n; i++) { (sideSet.has(i) ? sideA : sideB).push(i); } + return { minCutValue: bestCut, partition: [sideA, sideB] }; +} + +function separatePersons(graph, cutThreshold, maxPersons) { + const components = graph.connectedComponents(); + const personGroups = []; + for (const comp of components) { + if (comp.length < 2) continue; + _split(graph, comp, cutThreshold, maxPersons, personGroups); + } + return personGroups; +} + +function _split(graph, vertices, cutThreshold, maxPersons, result) { + if (vertices.length < 2 || result.length >= maxPersons) { + if (vertices.length >= 2) result.push(vertices); + return; + } + const { graph: sub, mapping } = graph.subgraph(vertices); + const { minCutValue, partition } = stoerWagner(sub); + if (minCutValue >= cutThreshold || partition[0].length === 0 || partition[1].length === 0) { + result.push(vertices); + return; + } + _split(graph, partition[0].map(i => mapping[i]), cutThreshold, maxPersons, result); + _split(graph, partition[1].map(i => mapping[i]), cutThreshold, maxPersons, result); +} + +// --------------------------------------------------------------------------- +// Visualization renderers +// --------------------------------------------------------------------------- + +/** + * Render correlation heatmap (downsampled to fit terminal width). + * Rows and columns = active subcarrier indices. + */ +function renderHeatmap(corr, width) { + if (!corr || !corr.matrix) return [' (insufficient data for heatmap)']; + const { matrix, n, activeIndices } = corr; + + const lines = []; + lines.push(`${BOLD}Correlation Heatmap${RESET} (${n} active subcarriers, threshold=${CORR_THRESHOLD})`); + + // Downsample if needed + const maxCols = Math.min(n, width - 8); + const step = Math.max(1, Math.ceil(n / maxCols)); + const displayN = Math.ceil(n / step); + + // Header row: subcarrier indices + let header = ' '; + for (let j = 0; j < displayN; j++) { + const sc = activeIndices[j * step]; + header += (sc < 10 ? `${sc} ` : `${sc}`).slice(0, 2); + } + lines.push(DIM + header + RESET); + + for (let i = 0; i < displayN; i++) { + const sc = activeIndices[i * step]; + let row = ` ${String(sc).padStart(3)} `; + + for (let j = 0; j < displayN; j++) { + const ii = i * step, jj = j * step; + const val = Math.abs(matrix[ii * n + jj]); + const level = Math.min(10, Math.floor(val * 10)); + + if (val > CORR_THRESHOLD) { + row += `\x1b[33m${HEAT[level]}${RESET} `; + } else { + row += `${DIM}${HEAT[level]}${RESET} `; + } + } + lines.push(row); + } + + return lines; +} + +/** + * Render subcarrier spectrum bar with person cluster coloring. + */ +function renderSpectrum(window, personGroups, activeIndices) { + const amp = window.latestAmplitudes(); + if (!amp) return [' (no data)']; + + const lines = []; + const nSc = window.nSubcarriers; + + // Build subcarrier-to-person mapping + const scToPerson = new Int8Array(nSc).fill(-1); + if (personGroups && activeIndices) { + for (let p = 0; p < personGroups.length; p++) { + for (const graphIdx of personGroups[p]) { + if (graphIdx < activeIndices.length) { + scToPerson[activeIndices[graphIdx]] = p; + } + } + } + } + + // Find max amplitude for normalization + let maxAmp = 0; + for (let i = 0; i < nSc; i++) { + if (amp[i] > maxAmp) maxAmp = amp[i]; + } + if (maxAmp === 0) maxAmp = 1; + + lines.push(`${BOLD}Spectrum${RESET} (${nSc} subcarriers, colored by person cluster)`); + + // Render bar + let bar = ' '; + for (let i = 0; i < nSc; i++) { + const level = Math.floor((amp[i] / maxAmp) * 7.99); + const ch = BARS[Math.max(0, Math.min(7, level))]; + const personIdx = scToPerson[i]; + if (personIdx >= 0 && personIdx < PERSON_COLORS.length) { + bar += PERSON_COLORS[personIdx] + ch + RESET; + } else { + bar += DIM + ch + RESET; + } + } + lines.push(bar); + + // Legend + let legend = ' '; + for (let i = 0; i < nSc; i++) { + const p = scToPerson[i]; + if (p >= 0 && p < PERSON_COLORS.length) { + legend += PERSON_COLORS[p] + (p + 1) + RESET; + } else { + legend += DIM + '.' + RESET; + } + } + lines.push(legend); + + return lines; +} + +/** + * Render cluster summary with per-person statistics. + */ +function renderClusters(personGroups, activeIndices, corr) { + if (!personGroups || personGroups.length === 0) { + return [' No person clusters detected']; + } + + const lines = []; + lines.push(`${BOLD}Person Clusters${RESET} (${personGroups.length} detected)`); + + for (let p = 0; p < personGroups.length; p++) { + const group = personGroups[p]; + const color = p < PERSON_COLORS.length ? PERSON_COLORS[p] : ''; + + // Map back to subcarrier indices + const scIds = group.map(i => activeIndices[i]); + const scStr = scIds.length <= 16 + ? scIds.join(', ') + : scIds.slice(0, 14).join(', ') + `, ...+${scIds.length - 14}`; + + // Compute intra-cluster average correlation + let avgCorr = 0, count = 0; + if (corr && corr.matrix) { + for (let i = 0; i < group.length; i++) { + for (let j = i + 1; j < group.length; j++) { + avgCorr += Math.abs(corr.matrix[group[i] * corr.n + group[j]]); + count++; + } + } + if (count > 0) avgCorr /= count; + } + + lines.push(` ${color}Person ${p + 1}${RESET}: ${group.length} subcarriers, avg intra-corr=${avgCorr.toFixed(3)}`); + lines.push(` ${DIM}SC: [${scStr}]${RESET}`); + } + + return lines; +} + +/** + * Render graph connectivity summary. + */ +function renderGraphStats(graph, corr) { + if (!graph) return [' (no graph)']; + + const lines = []; + const components = graph.connectedComponents(); + const density = graph.n > 1 ? (2 * graph.edgeCount) / (graph.n * (graph.n - 1)) : 0; + + lines.push(`${BOLD}Graph${RESET}: ${graph.n} nodes, ${graph.edgeCount} edges, density=${density.toFixed(3)}, components=${components.length}`); + + // Degree distribution summary + const degrees = new Array(graph.n); + let minDeg = Infinity, maxDeg = 0, sumDeg = 0; + for (let i = 0; i < graph.n; i++) { + degrees[i] = graph.adj[i].size; + if (degrees[i] < minDeg) minDeg = degrees[i]; + if (degrees[i] > maxDeg) maxDeg = degrees[i]; + sumDeg += degrees[i]; + } + const avgDeg = graph.n > 0 ? sumDeg / graph.n : 0; + lines.push(` Degree: min=${minDeg} max=${maxDeg} avg=${avgDeg.toFixed(1)}`); + + return lines; +} + +// --------------------------------------------------------------------------- +// Full render +// --------------------------------------------------------------------------- +function render(window, nodeId) { + const corr = window.correlationMatrix(); + const lines = []; + + const ts = new Date().toISOString().slice(11, 19); + lines.push(`${BOLD}ADR-075 CSI Graph Visualizer${RESET} [${ts}] Node ${nodeId} | ${window.length} frames`); + lines.push('═'.repeat(WIDTH)); + + let graph = null; + let personGroups = null; + let activeIndices = corr ? corr.activeIndices : []; + + if (corr && corr.matrix && corr.n >= 2) { + graph = WeightedGraph.fromCorrelation(corr.matrix, corr.n, CORR_THRESHOLD); + personGroups = separatePersons(graph, CUT_THRESHOLD, 8); + } + + const personCount = personGroups ? personGroups.length : 0; + lines.push(`${BOLD}Persons: ${personCount}${RESET} | Active subcarriers: ${activeIndices.length}/${window.nSubcarriers}`); + lines.push(''); + + if (MODE === 'all' || MODE === 'spectrum') { + lines.push(...renderSpectrum(window, personGroups, activeIndices)); + lines.push(''); + } + + if (MODE === 'all' || MODE === 'clusters') { + lines.push(...renderClusters(personGroups, activeIndices, corr)); + lines.push(''); + } + + if (MODE === 'all' || MODE === 'heatmap') { + lines.push(...renderHeatmap(corr, WIDTH)); + lines.push(''); + } + + if (graph) { + lines.push(...renderGraphStats(graph, corr)); + } + + lines.push('═'.repeat(WIDTH)); + lines.push(`${DIM}Thresholds: corr=${CORR_THRESHOLD} cut=${CUT_THRESHOLD} var-floor=${VAR_FLOOR}${RESET}`); + + return lines.join('\n'); +} + +// --------------------------------------------------------------------------- +// Packet parsing +// --------------------------------------------------------------------------- +function parseIqHex(iqHex, nSubcarriers) { + const bytes = Buffer.from(iqHex, 'hex'); + const amplitudes = new Float64Array(nSubcarriers); + for (let sc = 0; sc < nSubcarriers; sc++) { + const offset = 2 + sc * 2; + if (offset + 1 >= bytes.length) break; + let I = bytes[offset]; let Q = bytes[offset + 1]; + if (I > 127) I -= 256; + if (Q > 127) Q -= 256; + amplitudes[sc] = Math.sqrt(I * I + Q * Q); + } + return amplitudes; +} + +function parseUdpPacket(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 nSubcarriers = buf.readUInt16LE(6); + const amplitudes = new Float64Array(nSubcarriers); + for (let sc = 0; sc < nSubcarriers; sc++) { + const offset = HEADER_SIZE + sc * 2; + if (offset + 1 >= buf.length) break; + const I = buf.readInt8(offset); + const Q = buf.readInt8(offset + 1); + amplitudes[sc] = Math.sqrt(I * I + Q * Q); + } + return { nodeId, nSubcarriers, amplitudes, timestamp: Date.now() }; +} + +// --------------------------------------------------------------------------- +// Main: live mode +// --------------------------------------------------------------------------- +function startLive() { + const windows = new Map(); + const server = dgram.createSocket('udp4'); + + server.on('message', (buf) => { + const frame = parseUdpPacket(buf); + if (!frame) return; + if (!windows.has(frame.nodeId)) { + windows.set(frame.nodeId, new SubcarrierWindow(WINDOW_MS)); + } + windows.get(frame.nodeId).push(frame.timestamp, frame.amplitudes); + }); + + setInterval(() => { + process.stdout.write('\x1b[2J\x1b[H'); + for (const [nodeId, window] of windows) { + if (TARGET_NODE !== 0 && nodeId !== TARGET_NODE) continue; + console.log(render(window, nodeId)); + console.log(); + } + if (windows.size === 0) { + console.log('Waiting for CSI frames on UDP port ' + PORT + '...'); + } + }, INTERVAL_MS); + + server.bind(PORT, () => { + console.log(`CSI Graph Visualizer listening on UDP port ${PORT}`); + }); +} + +// --------------------------------------------------------------------------- +// Main: replay mode +// --------------------------------------------------------------------------- +async function startReplay(filePath) { + if (!fs.existsSync(filePath)) { + console.error(`File not found: ${filePath}`); + process.exit(1); + } + + const windows = new Map(); + const rl = readline.createInterface({ + input: fs.createReadStream(filePath), + crlfDelay: Infinity, + }); + + let lastRenderTs = 0; + let frameCount = 0; + + for await (const line of rl) { + if (!line.trim()) continue; + let record; + try { record = JSON.parse(line); } catch { continue; } + if (record.type !== 'raw_csi' || !record.iq_hex) continue; + + const nSc = record.subcarriers || 64; + const amplitudes = parseIqHex(record.iq_hex, nSc); + const nodeId = record.node_id; + const tsMs = record.timestamp * 1000; + + if (!windows.has(nodeId)) { + windows.set(nodeId, new SubcarrierWindow(WINDOW_MS)); + } + windows.get(nodeId).push(tsMs, amplitudes); + frameCount++; + + if (lastRenderTs === 0) lastRenderTs = tsMs; + if (tsMs - lastRenderTs >= INTERVAL_MS) { + process.stdout.write('\x1b[2J\x1b[H'); + for (const [nid, window] of windows) { + if (TARGET_NODE !== 0 && nid !== TARGET_NODE) continue; + console.log(render(window, nid)); + console.log(); + } + lastRenderTs = tsMs; + + // Small delay for visual effect during replay + await new Promise(r => setTimeout(r, 100)); + } + } + + // Final render + console.log(); + console.log('═'.repeat(WIDTH)); + console.log(`${BOLD}Replay complete${RESET}: ${frameCount} frames`); + for (const [nodeId, window] of windows) { + if (TARGET_NODE !== 0 && nodeId !== TARGET_NODE) continue; + console.log(); + console.log(render(window, nodeId)); + } +} + +// --------------------------------------------------------------------------- +// Entry point +// --------------------------------------------------------------------------- +if (args.replay) { + startReplay(args.replay); +} else { + startLive(); +} diff --git a/scripts/mincut-person-counter.js b/scripts/mincut-person-counter.js new file mode 100644 index 00000000..e123f3fb --- /dev/null +++ b/scripts/mincut-person-counter.js @@ -0,0 +1,766 @@ +#!/usr/bin/env node +/** + * ADR-075: Min-Cut Person Counter — Subcarrier correlation graph partitioning + * + * Fixes issue #348: n_persons always shows 4. Instead of threshold-based + * counting, builds a subcarrier correlation graph and uses Stoer-Wagner + * min-cut to find naturally independent groups of correlated subcarriers. + * Each group = one person's Fresnel zone perturbation. + * + * Usage: + * # Live from ESP32 nodes via UDP + * node scripts/mincut-person-counter.js --port 5006 + * + * # Replay from recorded CSI data + * node scripts/mincut-person-counter.js --replay data/recordings/pretrain-1775182186.csi.jsonl + * + * # JSON output for piping to seed bridge + * node scripts/mincut-person-counter.js --replay FILE --json + * + * # Override feature vector dim 5 and forward to seed bridge + * node scripts/mincut-person-counter.js --port 5006 --forward 5007 + * + * ADR: docs/adr/ADR-075-mincut-person-separation.md + */ + +'use strict'; + +const dgram = require('dgram'); +const fs = require('fs'); +const readline = require('readline'); +const { parseArgs } = require('util'); + +// --------------------------------------------------------------------------- +// CLI +// --------------------------------------------------------------------------- +const { values: args } = parseArgs({ + options: { + port: { type: 'string', short: 'p', default: '5006' }, + replay: { type: 'string', short: 'r' }, + json: { type: 'boolean', default: false }, + forward: { type: 'string', short: 'f' }, + interval: { type: 'string', short: 'i', default: '2000' }, + window: { type: 'string', short: 'w', default: '2000' }, + 'corr-threshold': { type: 'string', default: '0.3' }, + 'cut-threshold': { type: 'string', default: '2.0' }, + 'var-floor': { type: 'string', default: '0.5' }, + 'max-persons': { type: 'string', default: '8' }, + }, + strict: true, +}); + +const PORT = parseInt(args.port, 10); +const INTERVAL_MS = parseInt(args.interval, 10); +const WINDOW_MS = parseInt(args.window, 10); +const CORR_THRESHOLD = parseFloat(args['corr-threshold']); +const CUT_THRESHOLD = parseFloat(args['cut-threshold']); +const VAR_FLOOR = parseFloat(args['var-floor']); +const MAX_PERSONS = parseInt(args['max-persons'], 10); +const JSON_OUTPUT = args.json; +const FORWARD_PORT = args.forward ? parseInt(args.forward, 10) : null; + +// --------------------------------------------------------------------------- +// ADR-018 packet constants +// --------------------------------------------------------------------------- +const CSI_MAGIC = 0xC5110001; +const HEADER_SIZE = 20; + +// --------------------------------------------------------------------------- +// Per-node sliding window of subcarrier amplitudes +// --------------------------------------------------------------------------- +class SubcarrierWindow { + constructor(maxAgeMs) { + this.maxAgeMs = maxAgeMs; + this.frames = []; // { timestamp, amplitudes: Float64Array } + this.nSubcarriers = 0; + } + + push(timestamp, amplitudes) { + this.nSubcarriers = amplitudes.length; + this.frames.push({ timestamp, amplitudes: Float64Array.from(amplitudes) }); + this._prune(timestamp); + } + + _prune(now) { + const cutoff = now - this.maxAgeMs; + while (this.frames.length > 0 && this.frames[0].timestamp < cutoff) { + this.frames.shift(); + } + } + + get length() { return this.frames.length; } + + /** + * Compute pairwise Pearson correlation matrix for all subcarrier pairs. + * Returns { matrix: Float64Array (n*n row-major), n, activeIndices } + */ + correlationMatrix() { + const nFrames = this.frames.length; + const nSc = this.nSubcarriers; + if (nFrames < 5 || nSc === 0) return null; + + // Compute mean and std for each subcarrier + const mean = new Float64Array(nSc); + const std = new Float64Array(nSc); + + for (let f = 0; f < nFrames; f++) { + const amp = this.frames[f].amplitudes; + for (let i = 0; i < nSc; i++) { + mean[i] += amp[i]; + } + } + for (let i = 0; i < nSc; i++) mean[i] /= nFrames; + + for (let f = 0; f < nFrames; f++) { + const amp = this.frames[f].amplitudes; + for (let i = 0; i < nSc; i++) { + const d = amp[i] - mean[i]; + std[i] += d * d; + } + } + for (let i = 0; i < nSc; i++) { + std[i] = Math.sqrt(std[i] / (nFrames - 1)); + } + + // Filter out null/static subcarriers (std below noise floor) + const activeIndices = []; + for (let i = 0; i < nSc; i++) { + if (std[i] > VAR_FLOOR) { + activeIndices.push(i); + } + } + + const n = activeIndices.length; + if (n < 2) return { matrix: null, n: 0, activeIndices }; + + // Compute Pearson correlation for active pairs + const matrix = new Float64Array(n * n); + + for (let ai = 0; ai < n; ai++) { + matrix[ai * n + ai] = 1.0; // self-correlation + const si = activeIndices[ai]; + + for (let aj = ai + 1; aj < n; aj++) { + const sj = activeIndices[aj]; + + let cov = 0; + for (let f = 0; f < nFrames; f++) { + const amp = this.frames[f].amplitudes; + cov += (amp[si] - mean[si]) * (amp[sj] - mean[sj]); + } + cov /= (nFrames - 1); + + const denom = std[si] * std[sj]; + const r = denom > 1e-10 ? cov / denom : 0; + + matrix[ai * n + aj] = r; + matrix[aj * n + ai] = r; + } + } + + return { matrix, n, activeIndices }; + } +} + +// --------------------------------------------------------------------------- +// Weighted undirected graph (adjacency list) +// --------------------------------------------------------------------------- +class WeightedGraph { + constructor(n) { + this.n = n; + // adj[i] = Map + this.adj = new Array(n); + for (let i = 0; i < n; i++) this.adj[i] = new Map(); + this.edgeCount = 0; + } + + addEdge(u, v, w) { + if (u === v) return; + if (!this.adj[u].has(v)) this.edgeCount++; + this.adj[u].set(v, w); + this.adj[v].set(u, w); + } + + /** Build graph from correlation matrix, keeping edges above threshold */ + static fromCorrelation(matrix, n, threshold) { + const g = new WeightedGraph(n); + for (let i = 0; i < n; i++) { + for (let j = i + 1; j < n; j++) { + const r = Math.abs(matrix[i * n + j]); + if (r > threshold) { + g.addEdge(i, j, r); + } + } + } + return g; + } + + /** + * Find connected components via BFS. + * Returns array of arrays: each inner array = vertex indices in component. + */ + connectedComponents() { + const visited = new Uint8Array(this.n); + const components = []; + + for (let start = 0; start < this.n; start++) { + if (visited[start]) continue; + const comp = []; + const queue = [start]; + visited[start] = 1; + + while (queue.length > 0) { + const u = queue.shift(); + comp.push(u); + for (const [v] of this.adj[u]) { + if (!visited[v]) { + visited[v] = 1; + queue.push(v); + } + } + } + components.push(comp); + } + return components; + } + + /** + * Extract a subgraph containing only the specified vertices. + * Returns a new WeightedGraph with vertices relabeled 0..vertices.length-1, + * plus a mapping array from new index to original index. + */ + subgraph(vertices) { + const newIdx = new Map(); + vertices.forEach((v, i) => newIdx.set(v, i)); + + const sub = new WeightedGraph(vertices.length); + for (const u of vertices) { + for (const [v, w] of this.adj[u]) { + if (newIdx.has(v) && u < v) { + sub.addEdge(newIdx.get(u), newIdx.get(v), w); + } + } + } + return { graph: sub, mapping: vertices }; + } +} + +// --------------------------------------------------------------------------- +// Stoer-Wagner minimum cut algorithm +// +// Finds the global minimum s-t cut of an undirected weighted graph. +// Complexity: O(V * E) using adjacency list with priority tracking. +// +// Reference: Stoer & Wagner (1997), "A Simple Min-Cut Algorithm", JACM. +// --------------------------------------------------------------------------- + +/** + * Run one "minimum cut phase" of Stoer-Wagner. + * + * Starting from an arbitrary vertex, greedily add the most tightly connected + * vertex to the growing set A until all vertices are absorbed. + * + * @param {number} n - Number of active vertices + * @param {Map>} adj - Adjacency: adj[u].get(v) = weight + * @param {number[]} activeVertices - List of active vertex IDs + * @returns {{ s: number, t: number, cutOfPhase: number }} + */ +function minimumCutPhase(n, adj, activeVertices) { + // key[v] = sum of edge weights from v to vertices already in A + const key = new Float64Array(n); + const inA = new Uint8Array(n); + const active = new Uint8Array(n); + for (const v of activeVertices) active[v] = 1; + + let s = -1, t = -1; + + for (let iter = 0; iter < activeVertices.length; iter++) { + // Find vertex not in A with maximum key value + let best = -1, bestKey = -Infinity; + for (const v of activeVertices) { + if (!inA[v] && key[v] > bestKey) { + bestKey = key[v]; + best = v; + } + } + + // On first iteration when all keys are 0, just pick the first active vertex + if (best === -1) { + for (const v of activeVertices) { + if (!inA[v]) { best = v; break; } + } + } + + s = t; + t = best; + inA[best] = 1; + + // Update keys: for each neighbor of best, increase key + if (adj[best]) { + for (const [neighbor, weight] of adj[best]) { + if (active[neighbor] && !inA[neighbor]) { + key[neighbor] += weight; + } + } + } + } + + // Cut of the phase = sum of edges from t to all other active vertices + let cutOfPhase = 0; + if (adj[t]) { + for (const [neighbor, weight] of adj[t]) { + if (active[neighbor] && neighbor !== t) { + cutOfPhase += weight; + } + } + } + + return { s, t, cutOfPhase }; +} + +/** + * Stoer-Wagner global minimum cut. + * + * @param {WeightedGraph} graph + * @returns {{ minCutValue: number, partition: [number[], number[]] }} + * partition[0] = vertices on one side, partition[1] = vertices on the other side + */ +function stoerWagner(graph) { + const n = graph.n; + if (n <= 1) return { minCutValue: Infinity, partition: [Array.from({length: n}, (_, i) => i), []] }; + + // Build mutable adjacency (Map-based for efficient merge) + const adj = new Array(n); + for (let i = 0; i < n; i++) adj[i] = new Map(graph.adj[i]); + + // Track which original vertices each super-vertex contains + const groups = new Array(n); + for (let i = 0; i < n; i++) groups[i] = [i]; + + let activeVertices = Array.from({length: n}, (_, i) => i); + let bestCut = Infinity; + let bestPartitionSide = null; // group of vertices on the "t" side of the best cut + + while (activeVertices.length > 1) { + const { s, t, cutOfPhase } = minimumCutPhase(n, adj, activeVertices); + + if (s === -1 || t === -1) break; + + if (cutOfPhase < bestCut) { + bestCut = cutOfPhase; + bestPartitionSide = [...groups[t]]; + } + + // Merge t into s: move all edges from t to s + if (adj[t]) { + for (const [neighbor, weight] of adj[t]) { + if (neighbor === s) continue; + const existing = adj[s].get(neighbor) || 0; + adj[s].set(neighbor, existing + weight); + // Update neighbor's adjacency + adj[neighbor].delete(t); + adj[neighbor].set(s, existing + weight); + } + } + adj[s].delete(t); + + // Merge group membership + groups[s] = groups[s].concat(groups[t]); + groups[t] = []; + + // Remove t from active vertices + activeVertices = activeVertices.filter(v => v !== t); + } + + // Build full partition + if (!bestPartitionSide || bestPartitionSide.length === 0) { + return { minCutValue: Infinity, partition: [Array.from({length: n}, (_, i) => i), []] }; + } + + const sideSet = new Set(bestPartitionSide); + const sideA = [], sideB = []; + for (let i = 0; i < n; i++) { + if (sideSet.has(i)) sideA.push(i); + else sideB.push(i); + } + + return { minCutValue: bestCut, partition: [sideA, sideB] }; +} + +// --------------------------------------------------------------------------- +// Recursive min-cut person separator +// +// Recursively applies Stoer-Wagner to split the correlation graph into +// independent clusters. Each cluster = one person's Fresnel zone group. +// --------------------------------------------------------------------------- + +/** + * @param {WeightedGraph} graph + * @param {number} cutThreshold - min-cut below this = real person boundary + * @param {number} maxPersons - stop splitting after this many partitions + * @returns {number[][]} - array of vertex groups (each = one person's subcarriers) + */ +function separatePersons(graph, cutThreshold, maxPersons) { + // Start with connected components (disconnected groups are trivially separate) + const components = graph.connectedComponents(); + const personGroups = []; + + for (const comp of components) { + if (comp.length < 2) { + // Single vertex — not enough for a person + continue; + } + _splitComponent(graph, comp, cutThreshold, maxPersons, personGroups); + } + + return personGroups; +} + +function _splitComponent(graph, vertices, cutThreshold, maxPersons, result) { + if (vertices.length < 2 || result.length >= maxPersons) { + if (vertices.length >= 2) result.push(vertices); + return; + } + + // Extract subgraph + const { graph: sub, mapping } = graph.subgraph(vertices); + + // Run Stoer-Wagner on the subgraph + const { minCutValue, partition } = stoerWagner(sub); + + // If the min-cut is above threshold, this is one coherent group (one person) + if (minCutValue >= cutThreshold || partition[0].length === 0 || partition[1].length === 0) { + result.push(vertices); + return; + } + + // Map partition indices back to original vertex IDs + const groupA = partition[0].map(i => mapping[i]); + const groupB = partition[1].map(i => mapping[i]); + + // Recurse on each side + _splitComponent(graph, groupA, cutThreshold, maxPersons, result); + _splitComponent(graph, groupB, cutThreshold, maxPersons, result); +} + +// --------------------------------------------------------------------------- +// CSI frame parsing (from JSONL recording or UDP) +// --------------------------------------------------------------------------- + +/** Parse IQ hex string into amplitude array */ +function parseIqHex(iqHex, nSubcarriers) { + const bytes = Buffer.from(iqHex, 'hex'); + const amplitudes = new Float64Array(nSubcarriers); + + // IQ data: pairs of signed int8 (I, Q) for each subcarrier + // First 2 bytes are header/padding, then I/Q pairs + for (let sc = 0; sc < nSubcarriers; sc++) { + const offset = 2 + sc * 2; // skip 2-byte header + if (offset + 1 >= bytes.length) break; + + // Read as signed int8 + let I = bytes[offset]; + let Q = bytes[offset + 1]; + if (I > 127) I -= 256; + if (Q > 127) Q -= 256; + + amplitudes[sc] = Math.sqrt(I * I + Q * Q); + } + + return amplitudes; +} + +/** Parse binary UDP CSI packet (ADR-018 format) */ +function parseUdpPacket(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 rssi = buf.readInt8(16); + + const iqLen = nSubcarriers * nAntennas * 2; + if (buf.length < HEADER_SIZE + iqLen) return null; + + const amplitudes = 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); + } + + return { nodeId, nSubcarriers, freqMhz, rssi, amplitudes, timestamp: Date.now() / 1000 }; +} + +// --------------------------------------------------------------------------- +// Analysis engine +// --------------------------------------------------------------------------- +class PersonCounter { + constructor(opts) { + this.windowMs = opts.windowMs; + this.corrThreshold = opts.corrThreshold; + this.cutThreshold = opts.cutThreshold; + this.maxPersons = opts.maxPersons; + + // Per-node sliding windows + this.windows = new Map(); // nodeId -> SubcarrierWindow + + // Latest result + this.lastResult = null; + this.analysisCount = 0; + } + + ingestFrame(nodeId, timestamp, amplitudes) { + if (!this.windows.has(nodeId)) { + this.windows.set(nodeId, new SubcarrierWindow(this.windowMs)); + } + this.windows.get(nodeId).push(timestamp * 1000, amplitudes); + } + + /** + * Run the min-cut analysis on accumulated data. + * Merges subcarrier data from all nodes into a single correlation graph. + * + * @returns {{ personCount, groups, graphStats, perNode }} + */ + analyze() { + this.analysisCount++; + const perNode = {}; + const allGroups = []; + let totalPersons = 0; + + for (const [nodeId, window] of this.windows) { + const corr = window.correlationMatrix(); + if (!corr || !corr.matrix || corr.n < 2) { + perNode[nodeId] = { personCount: 0, activeSubcarriers: corr ? corr.n : 0, groups: [], edges: 0 }; + continue; + } + + // Build correlation graph + const graph = WeightedGraph.fromCorrelation(corr.matrix, corr.n, this.corrThreshold); + + // Separate persons via recursive min-cut + const groups = separatePersons(graph, this.cutThreshold, this.maxPersons); + + // Map group indices back to original subcarrier indices + const mappedGroups = groups.map(g => g.map(i => corr.activeIndices[i])); + + const nodeResult = { + personCount: groups.length, + activeSubcarriers: corr.n, + totalSubcarriers: window.nSubcarriers, + groups: mappedGroups, + edges: graph.edgeCount, + frames: window.length, + }; + + perNode[nodeId] = nodeResult; + totalPersons = Math.max(totalPersons, groups.length); + allGroups.push(...mappedGroups); + } + + this.lastResult = { + personCount: totalPersons, + groups: allGroups, + perNode, + timestamp: Date.now() / 1000, + analysisIndex: this.analysisCount, + }; + + return this.lastResult; + } +} + +// --------------------------------------------------------------------------- +// ASCII output +// --------------------------------------------------------------------------- +function formatResult(result) { + const lines = []; + const ts = new Date(result.timestamp * 1000).toISOString().slice(11, 19); + + lines.push(`\x1b[1m[${ts}] Persons: ${result.personCount}\x1b[0m (analysis #${result.analysisIndex})`); + + for (const [nodeId, nodeResult] of Object.entries(result.perNode)) { + const { personCount, activeSubcarriers, totalSubcarriers, groups, edges, frames } = nodeResult; + lines.push(` Node ${nodeId}: ${personCount} person(s) | ${activeSubcarriers}/${totalSubcarriers} active subcarriers | ${edges} edges | ${frames} frames`); + + for (let i = 0; i < groups.length; i++) { + const g = groups[i]; + const scList = g.length <= 12 ? g.join(',') : g.slice(0, 10).join(',') + `...+${g.length - 10}`; + lines.push(` Person ${i + 1}: subcarriers [${scList}] (${g.length} sc)`); + } + } + + return lines.join('\n'); +} + +function formatJson(result) { + return JSON.stringify(result); +} + +// --------------------------------------------------------------------------- +// UDP forwarding (override person count in feature vector) +// --------------------------------------------------------------------------- +let forwardSocket = null; +function forwardWithCorrectedCount(buf, personCount) { + if (!FORWARD_PORT || !forwardSocket) return; + // If it's a vitals packet (magic 0xC5110002), override byte 13 (nPersons) + const magic = buf.readUInt32LE(0); + if (magic === 0xC5110002 && buf.length >= 14) { + const copy = Buffer.from(buf); + copy.writeUInt8(Math.min(personCount, 255), 13); + forwardSocket.send(copy, FORWARD_PORT, '127.0.0.1'); + } else { + // Forward as-is + forwardSocket.send(buf, FORWARD_PORT, '127.0.0.1'); + } +} + +// --------------------------------------------------------------------------- +// Main: live UDP mode +// --------------------------------------------------------------------------- +function startLive() { + const counter = new PersonCounter({ + windowMs: WINDOW_MS, + corrThreshold: CORR_THRESHOLD, + cutThreshold: CUT_THRESHOLD, + maxPersons: MAX_PERSONS, + }); + + const server = dgram.createSocket('udp4'); + + if (FORWARD_PORT) { + forwardSocket = dgram.createSocket('udp4'); + } + + server.on('message', (buf, rinfo) => { + const frame = parseUdpPacket(buf); + if (frame) { + counter.ingestFrame(frame.nodeId, frame.timestamp, frame.amplitudes); + } + + // Forward all packets with corrected person count + if (counter.lastResult) { + forwardWithCorrectedCount(buf, counter.lastResult.personCount); + } + }); + + // Periodic analysis + setInterval(() => { + const result = counter.analyze(); + if (JSON_OUTPUT) { + console.log(formatJson(result)); + } else { + process.stdout.write('\x1b[2J\x1b[H'); // clear screen + console.log('ADR-075 Min-Cut Person Counter (live UDP)'); + console.log('─'.repeat(60)); + console.log(formatResult(result)); + console.log('─'.repeat(60)); + console.log(`Thresholds: corr=${CORR_THRESHOLD} cut=${CUT_THRESHOLD} var-floor=${VAR_FLOOR}`); + } + }, INTERVAL_MS); + + server.bind(PORT, () => { + if (!JSON_OUTPUT) { + console.log(`Listening on UDP port ${PORT} (analysis every ${INTERVAL_MS}ms, window ${WINDOW_MS}ms)`); + if (FORWARD_PORT) console.log(`Forwarding corrected packets to UDP port ${FORWARD_PORT}`); + } + }); +} + +// --------------------------------------------------------------------------- +// Main: replay mode (from .csi.jsonl recording) +// --------------------------------------------------------------------------- +async function startReplay(filePath) { + const counter = new PersonCounter({ + windowMs: WINDOW_MS, + corrThreshold: CORR_THRESHOLD, + cutThreshold: CUT_THRESHOLD, + maxPersons: MAX_PERSONS, + }); + + if (!fs.existsSync(filePath)) { + console.error(`File not found: ${filePath}`); + process.exit(1); + } + + const rl = readline.createInterface({ + input: fs.createReadStream(filePath), + crlfDelay: Infinity, + }); + + let frameCount = 0; + let lastAnalysisTs = 0; + let analysisResults = []; + + for await (const line of rl) { + if (!line.trim()) continue; + + let record; + try { + record = JSON.parse(line); + } catch { + continue; + } + + if (record.type !== 'raw_csi' || !record.iq_hex) continue; + + const amplitudes = parseIqHex(record.iq_hex, record.subcarriers || 64); + counter.ingestFrame(record.node_id, record.timestamp, amplitudes); + frameCount++; + + // Run analysis every INTERVAL_MS worth of frames + const tsMs = record.timestamp * 1000; + if (lastAnalysisTs === 0) lastAnalysisTs = tsMs; + + if (tsMs - lastAnalysisTs >= INTERVAL_MS) { + const result = counter.analyze(); + analysisResults.push(result); + + if (JSON_OUTPUT) { + console.log(formatJson(result)); + } else { + console.log(formatResult(result)); + console.log(); + } + + lastAnalysisTs = tsMs; + } + } + + // Final analysis + const result = counter.analyze(); + analysisResults.push(result); + + if (!JSON_OUTPUT) { + console.log('─'.repeat(60)); + console.log('FINAL ANALYSIS'); + console.log('─'.repeat(60)); + console.log(formatResult(result)); + console.log(); + console.log(`Processed ${frameCount} frames, ${analysisResults.length} analysis windows`); + + // Summary statistics + const counts = analysisResults.map(r => r.personCount); + const avg = counts.reduce((a, b) => a + b, 0) / counts.length; + const max = Math.max(...counts); + const min = Math.min(...counts); + console.log(`Person count: min=${min} max=${max} avg=${avg.toFixed(1)}`); + console.log(`Thresholds: corr=${CORR_THRESHOLD} cut=${CUT_THRESHOLD} var-floor=${VAR_FLOOR}`); + } else { + console.log(formatJson(result)); + } +} + +// --------------------------------------------------------------------------- +// Entry point +// --------------------------------------------------------------------------- +if (args.replay) { + startReplay(args.replay); +} else { + startLive(); +}