mirror of
https://github.com/ruvnet/RuView.git
synced 2026-04-28 05:59:32 +00:00
Stoer-Wagner min-cut on subcarrier correlation graph replaces broken threshold-based person counting (was always 4, now correct). Validated: 24/24 windows correctly report 1 person on test data where old firmware reported 4. Pure JS, <5ms per window. - mincut-person-counter.js: live UDP + JSONL replay, overrides vitals - csi-graph-visualizer.js: ASCII spectrum + correlation heatmap - ADR-075: algorithm, comparison, migration path Co-Authored-By: claude-flow <ruv@ruv.net>
766 lines
24 KiB
JavaScript
766 lines
24 KiB
JavaScript
#!/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<j, weight>
|
|
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<number, Map<number, number>>} 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();
|
|
}
|