WiFi 感知革命:RuView 如何用 9 美元的 ESP32 实现「透视眼」——CSI 空间智能完全指南(2026)
当所有人都在卷摄像头分辨率和雷达芯片时,有人已经把手伸向了你的路由器——用它当「透视眼」。本文深度解析 GitHub 狂揽 68K Star 的开源项目 RuView,从物理层原理到 Rust 工程实现,从 ESP32 固件到端侧 AI 推理,彻底搞清楚这套「无摄像头空间智能」系统的全貌。
一、背景:为什么 WiFi 感知突然成了 2026 年最火的技术方向
过去十年,「智能感知」这个赛道基本被两条路线垄断:
路线一:摄像头 + CV ——视觉信息最丰富,但隐私问题无解,暗光环境下几乎废掉,还要用户装设备、调试角度。
路线二:毫米波雷达 / ToF 传感器 ——精度高,但成本居高不下(一个毫米波雷达模组少说几十美元),而且 RF 芯片不是家家户户都有。
而我们身边其实早就有一种「免费雷达」——每台 WiFi 路由器都在不断发射无线电波。这些波碰到人体会发生反射、散射、衍射,传统的通信系统把这些称为「干扰」并努力消除它们。
但如果我们反过来想:这些被人体扰动的无线电波里,藏着大量关于「人」的信息。
这就是 WiFi CSI(Channel State Information,信道状态信息)感知技术的核心洞察。2026 年,一个叫 RuView 的开源项目把这个方向做到了令人惊叹的程度:
- 用 9 美元的 ESP32 芯片替代几十美元的毫米波雷达
- 实现 穿墙感知、呼吸监测、心率检测、姿态估计 ——全程无摄像头
- 模型仅 8KB(4-bit 量化),在树莓派上微秒级推理
- 支持 Home Assistant、Apple Home、Google Home、Alexa 四大生态一键接入
- GitHub 狂收 68K+ Star,是 2026 年现象级的开源项目
本文从物理原理、信号处理、Rust 工程实现、端侧 AI 推理四个维度,彻底拆解 RuView 的技术全貌。
二、物理层原理:CSI 为何能「看见」你
2.1 从 RSSI 到 CSI:感知的进化之路
在 WiFi CSI 之前,「WiFi 感知」最常见的方案是 RSSI(Received Signal Strength Indicator,接收信号强度指示)——也就是手机上那个 WiFi 图标旁边的一格一格的信号强度。
RSSI 很简单:信号强 = 人在附近,信号弱 = 人远了。但它的缺点也是致命的:
RSSI 的局限:
- 粗粒度:只有单一数值,丢失了所有相位信息
- 多径效应无法区分:反射信号和直达信号混在一起
- 采样率低:通常 1Hz 左右,无法捕捉精细动作
- 精度差:距离估计误差在米级
而 CSI(Channel State Information,信道状态信息) 则提供了完全不同的视角。
2.2 什么是 CSI?OFDM 系统里的「透视眼」
现代 WiFi(802.11n/ac/ax/be)普遍采用 OFDM(正交频分复用) 调制技术。简单理解:WiFi 把要传输的数据分散到几十甚至上百个子载波上并行发送,每个子载波占用极窄的频段。
当无线信号从发射端(路由器)到接收端(ESP32)的传播过程中,会经历:
① 路径衰减(Path Loss):信号强度随距离衰减
② 多径效应(Multipath):信号从发射端出发,经过墙壁、家具、人体等反射后,以多条不同路径到达接收端,各路径长度不同,相位也不同
③ 人体扰动:当人在空间中移动时,其反射特性变化,导致各路径的幅度和相位都发生变化
CSI 正是记录每个子载波在当前时刻的信道状态的数据。具体来说,它是一个复数:
CSI[i] = A[i] × exp(j × φ[i])
其中:
A[i]:第 i 个子载波的幅度衰减φ[i]:第 i 个子载波的相位偏移i:子载波索引(802.11n 在 20MHz 信道下有 52 个子载波)
相比 RSSI 的单一标量,CSI 提供了 52 个复数 = 52 个幅度 + 52 个相位 = 104 维信息,而且采样率可达 100Hz 以上。
这就是 CSI 能「感知」精细动作的物理基础:人的呼吸会让胸腔微微起伏,这个微小的位置变化会以特定频率调制某些子载波的相位——而这个频率恰好是 0.10.5Hz(每分钟 630 次呼吸)。
2.3 ESP32 的 CSI 采集能力
ESP32 是乐鑫科技出品的超低成本 WiFi+BT SoC,售价约 9 元人民币。它内置的 WiFi 模块支持监听模式(Monitor Mode),可以在不连接网络的情况下捕获空中的 802.11 数据帧。
ESP32-S3 配合修改后的固件(如 esp-idf 框架下的 wifi-sniffer 示例),可以提取每帧的 CSI 数据:
// ESP32 WiFi CSI 采集核心代码(伪代码)
#include "esp_wifi.h"
#include "esp_log.h"
static const char *TAG = "CSI_SNIFFER";
static void wifi_sniffer_packet_handler(void *buff, wifi_promiscuous_pkt_type_t type)
{
// 强制类型转换为通用包
const wifi_pkt_rx_ctrl_t *rx_ctrl = (wifi_pkt_rx_ctrl_t *)buff;
uint8_t *payload = (uint8_t *)buff + sizeof(wifi_pkt_rx_ctrl_t);
// 判断是否为数据帧
if (type != WIFI_PKT_MISC) return;
if (rx_ctrl->sig_len == 0) return;
// 提取 CSI 数据(ESP32 仅在 Station 模式下支持)
// CSI 数据从 payload 的特定偏移开始
// 每个子载波的幅度和相位被打包为连续字节
int8_t *csi_data = (int8_t *)(payload + SOME_OFFSET);
int csi_len = rx_ctrl->sig_len - SOME_OFFSET;
// 处理 CSI 数据
process_csi(csi_data, csi_len, rx_ctrl->rssi, rx_ctrl->timestamp);
}
void app_main(void)
{
// 初始化 WiFi 为 Station 模式(CSI 仅此模式支持)
wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT();
esp_wifi_init(&cfg);
esp_wifi_set_mode(WIFI_MODE_STA);
// 配置为混杂模式,捕获所有管理帧
wifi_promiscuous_filter_t filter = {
.filter_mask = WIFI_PROMIS_FILTER_MASK_DATA
};
esp_wifi_set_promiscuous_filter(&filter);
esp_wifi_set_promiscuous_rx_cb(wifi_sniffer_packet_handler);
esp_wifi_set_promiscuous(true);
ESP_LOGI(TAG, "CSI Sniffer started on channel %d", channel);
}
关键约束:ESP32 的 CSI 采集只能在 Station 模式下工作(而非 AP 或 Monitor 模式),意味着需要一台路由器作为 CSI 的发送端,ESP32 作为接收端监听。
2.4 穿墙感知的物理极限
RuView 标称支持 穿墙感知(Through-wall sensing),最深可达 约 5 米。这背后依赖的是 Fresnel 衍射区模型。
当无线电波遇到墙壁这类障碍物时,会发生衍射:
Fresnel 衍射区几何关系(简化模型):
路由器 ──→ 墙体 ──→ ESP32(接收)
↑
人体干扰区
衍射损失 ≈ 20 × log₁₀(λ / (4 × π × d))
其中:
λ = 信号波长(2.4GHz WiFi → λ ≈ 12.5cm)
d = 绕射距离(由墙体厚度和入射角决定)
Fresnel 区模型让我们可以估算:在 2.4GHz 频段,一堵普通石膏板墙(厚度约 10cm)的信号衰减约 6~10dB,而混凝土墙(20cm)可能衰减 15~25dB。ESP32 的接收灵敏度约 -98dBm,即使衰减 20dB,只要路由器发射功率足够(通常 +20dBm),仍有充足裕量。
这也是为什么 5GHz WiFi 穿墙效果不如 2.4GHz——频率越高,波长越短,Fresnel 衍射损失越大。
三、系统架构:从 CSI 信号到空间智能的完整流水线
3.1 整体架构图
┌─────────────────────────────────────────────────────┐
│ RuView 系统架构 │
├─────────────────────────────────────────────────────┤
│ │
│ [路由器 AP] │
│ 802.11n/ac 发射CSI数据流 ↓(无线) │
│ │
│ [ESP32-S3 节点] $9/片 │
│ ┌─────────────────────────────────────┐ │
│ │ WiFi Station + CSI Sniffer │ │
│ │ 52个子载波 × 100Hz采样 │ │
│ │ → CSI Raw Stream (UDP/TCP) │ │
│ └──────────────┬──────────────────────┘ │
│ │ UART / WiFi / BLE │
│ ↓ │
│ [Cognitum Seed 边缘网关] $140(含) │
│ ┌─────────────────────────────────────┐ │
│ │ Rust + Candle (ML推理) │ │
│ │ RuVector (向量数据库 + GNN) │ │
│ │ Ed25519 见证链 (加密证明) │ │
│ │ MQTT / Home Assistant 集成 │ │
│ └──────────────┬──────────────────────┘ │
│ │ │
│ ┌────────┴────────┐ │
│ ↓ ↓ │
│ [Home Assistant] [Apple Home] │
│ [Google Home] [Amazon Alexa] │
│ [Matter Bridge] 智能家居│
└─────────────────────────────────────────────────────┘
3.2 信号处理流水线(Rust 实现)
这是整个系统最核心的部分。原始 CSI 数据从 ESP32 传输到边缘网关后,需要经过一系列信号处理才能提取有用信息。RuView 的信号处理完全用 Rust 实现,主要依赖以下 crate:
# RuView Rust 信号处理依赖(关键 crates)
[dependencies]
# 1. 核心信号处理
ndarray = "0.16" # 多维数组,类似 NumPy
rustdsp = "0.6" # 数字信号处理:滤波、FFT
rustfft = "6.2" # 快速傅里叶变换
num-complex = "0.4" # 复数运算
# 2. 机器学习推理
candle-core = "0.6" # Candle — 轻量级 ML 框架(纯 Rust,无 PyTorch 依赖)
candle-nn = "0.6" # 神经网络层
safetensors = "0.45" # 模型格式(HuggingFace 标准)
# 3. 性能优化
rayon = "1.10" # 数据并行
tokio = "1" # 异步运行时
第一步:CSI 解析与清洗
// CSI 信号处理 - 第一步:解析原始字节流
use ndarray::Array2;
use num_complex::Complex;
#[derive(Debug, Clone)]
pub struct CSIFrame {
pub timestamp: u64,
pub rssi: i8,
pub channel: u8,
// shape: (num_subcarriers, 2) → [amplitude, phase] per subcarrier
pub data: Array2<f32>,
pub mac_addr: [u8; 6],
}
impl CSIFrame {
/// 从 ESP32 UDP 包解析 CSI 数据
/// ESP32 发来的原始数据格式:
/// [rssi: i8][channel: u8][num_sc: u8][csi_data: i8[2*num_sc]]
/// csi_data 中每两个字节 = [real, imag] 的 8-bit 量化复数
pub fn parse_from_udp_payload(payload: &[u8]) -> Option<Self> {
if payload.len() < 3 {
return None;
}
let rssi = payload[0] as i8;
let channel = payload[1];
let num_sc = payload[2] as usize;
let expected_len = 3 + num_sc * 2;
if payload.len() < expected_len {
return None;
}
let mut data = Array2::<f32>::zeros((num_sc, 2));
for i in 0..num_sc {
let real = payload[3 + i * 2] as i8 as f32;
let imag = payload[3 + i * 2 + 1] as i8 as f32;
let complex = Complex::new(real, imag);
// 幅度归一化(量化误差补偿)
let amplitude = complex.norm() / 127.0; // 8-bit 量化
// 相位解缠绕
let phase = complex.arg();
data[[i, 0]] = amplitude;
data[[i, 1]] = phase;
}
Some(CSIFrame {
timestamp: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis() as u64,
rssi,
channel,
data,
mac_addr: [0u8; 6], // 从帧头解析
})
}
}
第二步:带通滤波提取生理信号
这是呼吸和心率检测的核心。人体呼吸频率约 0.1~0.5Hz(每分钟 630 次),心率约 **0.82.0Hz**(每分钟 48~120 次)。
// CSI 信号处理 - 第二步:带通滤波提取生理信号
use rustdsp::filter::biquad::{BiquadFilter, BiquadParams};
use rustdsp::filter::Filter;
use rustdsp::window::hann;
pub struct VitalSignExtractor {
// 呼吸带通:0.1~0.5 Hz(采样率 100Hz → 归一化频率 0.001~0.005)
breath_filter: BiquadFilter<f32>,
// 心率带通:0.8~2.0 Hz(归一化频率 0.008~0.020)
heart_filter: BiquadFilter<f32>,
sample_rate: f32,
}
impl VitalSignExtractor {
pub fn new(sample_rate: f32) -> Self {
// 设计 IIR 巴特沃斯带通滤波器
let breath_params = BiquadParams::bandpass(
0.3, // 中心频率 0.3 Hz = 每分钟 18 次(呼吸中值)
0.1, // Q 值
sample_rate,
);
let heart_params = BiquadParams::bandpass(
1.2, // 中心频率 1.2 Hz = 每分钟 72 次(心率中值)
0.3,
sample_rate,
);
Self {
breath_filter: BiquadFilter::new(breath_params),
heart_filter: BiquadFilter::new(heart_params),
sample_rate,
}
}
/// 从 CSI 相位时间序列提取呼吸和心率
/// 核心算法:利用带相位缠绕(phase wrapping)校正的 CSI 相位数据
pub fn extract_vitals(&mut self, phase_sequence: &[f32]) -> VitalSigns {
// Step 1: 相位解缠绕
// CSI 相位值域为 [-π, π],但真实相位可能是任意值
// 需要通过解缠绕还原连续相位变化
let unwrapped = self.unwrap_phase(phase_sequence);
// Step 2: 去除载波频率偏移(CFO)和采样频率偏移(SFO)
// 这两种偏移会在频谱上造成一个大的直流分量,需要减掉
let detrended = self.remove_cfo_sfo(&unwrapped);
// Step 3: 带通滤波 - 提取呼吸成分
let breath_signal: Vec<f32> = detrended
.iter()
.copied()
.map(|x| self.breath_filter.run(x))
.collect();
// Step 4: 带通滤波 - 提取心率成分
let heart_signal: Vec<f32> = detrended
.iter()
.copied()
.map(|x| self.heart_filter.run(x))
.collect();
// Step 5: 零交叉检测 BPM
let breath_bpm = self.zero_crossing_bpm(&breath_signal, 6.0, 30.0);
let heart_bpm = self.zero_crossing_bpm(&heart_signal, 40.0, 120.0);
VitalSigns {
breath_rate: breath_bpm,
heart_rate: heart_bpm,
signal_quality: self.estimate_snr(&breath_signal),
}
}
/// 相位解缠绕(C++实现思路转Rust)
fn unwrap_phase(&self, wrapped: &[f32]) -> Vec<f32> {
let mut unwrapped = Vec::with_capacity(wrapped.len());
unwrapped.push(wrapped[0]);
for i in 1..wrapped.len() {
let delta = wrapped[i] - wrapped[i - 1];
// 如果跳变超过 π,说明发生了相位缠绕
let delta_adj = if delta > std::f32::consts::PI {
delta - 2.0 * std::f32::consts::PI
} else if delta < -std::f32::consts::PI {
delta + 2.0 * std::f32::consts::PI
} else {
delta
};
unwrapped.push(unwrapped[i - 1] + delta_adj);
}
unwrapped
}
/// 通过零交叉法计算 BPM
fn zero_crossing_bpm(&self, signal: &[f32], min_bpm: f32, max_bpm: f32) -> f32 {
let min_interval = (60.0 * self.sample_rate / max_bpm) as usize;
let max_interval = (60.0 * self.sample_rate / min_bpm) as usize;
let threshold = {
// 取信号 RMS 值的 30% 作为阈值
let rms: f32 = (signal.iter().map(|x| x * x).sum::<f32>() / signal.len() as f32).sqrt();
rms * 0.3
};
let mut crossings: Vec<usize> = Vec::new();
let mut last_above = false;
for (i, &val) in signal.iter().enumerate() {
let above = val > threshold;
if above && !last_above {
// 从负变正 → 零交叉
if let Some(&last) = crossings.last() {
let interval = i - last;
if interval >= min_interval && interval <= max_interval {
crossings.push(i);
}
} else {
crossings.push(i);
}
}
last_above = above;
}
if crossings.len() < 2 {
return 0.0;
}
// 计算平均间隔
let intervals: Vec<usize> = crossings.windows(2).map(|w| w[1] - w[0]).collect();
let avg_interval: f32 = intervals.iter().sum::<usize>() as f32 / intervals.len() as f32;
60.0 * self.sample_rate / avg_interval
}
fn remove_cfo_sfo(&self, phase: &[f32]) -> Vec<f32> {
// 线性拟合相位时间序列,斜率 = CFO + SFO
let n = phase.len() as f32;
let sum_x: f32 = (0..phase.len()).map(|x| x as f32).sum();
let sum_y: f32 = phase.iter().sum();
let sum_xy: f32 = phase.iter().enumerate().map(|(i, &y)| i as f32 * y).sum();
let sum_xx: f32 = (0..phase.len()).map(|i| (i as f32).powi(2)).sum();
let slope = (n * sum_xy - sum_x * sum_y) / (n * sum_xx - sum_x * sum_x);
let offset = (sum_y - slope * sum_x) / n;
phase.iter()
.enumerate()
.map(|(i, &p)| p - slope * i as f32 - offset)
.collect()
}
fn estimate_snr(&self, signal: &[f32]) -> f32 {
let rms: f32 = (signal.iter().map(|x| x * x).sum::<f32>() / signal.len() as f32).sqrt();
// 噪声估计:高频成分(去除主瓣后的残余)
rms.max(1e-6)
}
}
#[derive(Debug, Clone)]
pub struct VitalSigns {
pub breath_rate: f32, // 呼吸频率(BPM)
pub heart_rate: f32, // 心率(BPM)
pub signal_quality: f32, // 信号质量比
}
上述代码清晰展示了从 CSI 原始相位数据到生理指标的完整信号处理链路。关键技巧:
为什么用相位而不是幅度? 幅度受距离衰减影响大,而相位对人体的微小位移更敏感。呼吸导致的位移约 1~12mm,这个量级在幅度上几乎无变化,但在相位上(尤其是高频子载波)会产生明显偏移。
为什么需要 30 秒环境校准? 因为每个房间的多径特性不同,需要在「无人」状态下建立基准线,减去静态多径成分后,才能更干净地提取动态的人体信号。
第三步:存在检测与人数统计
呼吸和心率解决的是「有没有人」和「人怎么样」,但人数统计和轨迹追踪需要更复杂的感知能力。
RuView 采用了两种人数统计策略:
策略一:无模型相位方差法(无 ML,适合低资源场景):
// 人数统计:自适应 P95 归一化法
pub struct OccupancyCounter {
dedup_factor: f32, // 运行时可调的去重因子
baseline_noise: Vec<f32>, // 校准期间的无人物理噪声基线
}
impl OccupancyCounter {
pub fn new() -> Self {
Self {
dedup_factor: 1.0,
baseline_noise: Vec::new(),
}
}
/// 在无人状态下调用,建立环境基线
pub fn calibrate(&mut self, no_occupancy_samples: &[CSIFrame]) {
// 收集无人在场时各子载波的相位方差
self.baseline_noise = self
.compute_subcarrier_variance(no_occupancy_samples)
.unwrap_or_default();
}
/// 估算当前人数
/// 核心思路:人体越多,多径反射越复杂,CSI 方差越大
/// 但这种关系是非线性的,需要自适应归一化
pub fn estimate_count(&self, current: &CSIFrame) -> usize {
let current_variance = self.compute_frame_variance(current);
// P95 归一化:取校准期方差分布的 95 分位点作为「1 人」阈值
let p95_baseline = percentile(&self.baseline_noise, 0.95);
if p95_baseline < 1e-6 {
return 0; // 未校准或基线过小
}
let normalized = current_variance / p95_baseline;
let raw_count = (normalized * self.dedup_factor).round() as usize;
raw_count.max(0).min(10) // 上限保护
}
fn compute_frame_variance(&self, frame: &CSIFrame) -> f32 {
// 沿时间轴(需要多帧累积)或子载波维度计算相位方差
// 这里用子载波维度的相位方差作为活跃度指标
let phases: Vec<f32> = frame.data.column(1).to_vec(); // 第1列=相位
let mean: f32 = phases.iter().sum::<f32>() / phases.len() as f32;
phases.iter().map(|&p| (p - mean).powi(2)).sum::<f32>() / phases.len() as f32
}
}
策略二:CSI Embedding + 轻量分类头(有 ML,高精度):
// 17.6M 参数的 CSI Embedding 模型(Candle 实现)
use candle_core::{Tensor, Device, Result as CandleResult};
use candle_nn::{Module, Linear, Conv1d, Activation};
pub struct CSIEncoder {
// 128 维对比学习编码器
// 输入:52子载波 × 100Hz采样 × 1秒窗口 = 5200维张量
// 输出:128维 embedding
conv1: Conv1d,
conv2: Conv1d,
conv3: Conv1d,
fc1: Linear,
fc2: Linear,
}
impl CSIEncoder {
pub fn new(embedding_dim: usize) -> CandleResult<Self> {
// Conv1d(in_channels, out_channels, kernel_size)
let conv1 = Conv1d::default(104, 256, 5); // 52子载波×2(幅度+相位)
let conv2 = Conv1d::default(256, 128, 3);
let conv3 = Conv1d::default(128, 64, 3);
let fc1 = Linear::default(64 * 40, 256); // Flatten 后
let fc2 = Linear::default(256, embedding_dim);
Ok(Self { conv1, conv2, conv3, fc1, fc2 })
}
pub fn forward(&self, x: &Tensor) -> CandleResult<Tensor> {
// x shape: (batch, 104, time_steps) → (batch, 104, 100)
let x = self.conv1.forward(x)?;
let x = Activation::relu(&x);
let x = self.conv2.forward(&x)?;
let x = Activation::relu(&x);
let x = self.conv3.forward(&x)?;
let x = Activation::relu(&x);
// Flatten
let x = x.flatten_from(1)?;
let x = self.fc1.forward(&x)?;
let x = Activation::relu(&x);
let x = self.fc2.forward(&x)?;
// L2 归一化(对比学习标准操作)
let norm = (x.square()?.sum_keepdim(1)? + 1e-8)?.sqrt()?;
x.broadcast_div(&norm)
}
}
// 4-bit 量化:8KB 模型的秘密
pub fn quantize_4bit(weights: &[f32]) -> Vec<u8> {
// 4-bit 量化:将 32-bit float 压缩到 4-bit
// 使用绝对值最大值的 absmax 量化方法
let absmax = weights.iter().map(|w| w.abs()).fold(0.0f32, f32::max);
let scale = absmax / 7.5; // 4-bit 有符号范围 [-7.5, 7.5]
weights
.chunks(2) // 2个4-bit打包成1字节
.map(|chunk| {
let a = (chunk[0] / scale).round().clamp(-7.5, 7.5) as i8;
let b = if chunk.len() > 1 {
(chunk[1] / scale).round().clamp(-7.5, 7.5) as i8
} else { 0 };
((a & 0xF) as u8) | ((b as u8 & 0xF) << 4)
})
.collect()
}
这就是 8KB 模型的来源:17.6M 参数的 FP32 模型 = 70.4MB,4-bit 量化后 ≈ 8.8MB,再去掉冗余后约 8KB。量化误差通过精心设计的训练后微调(PTQ,Post-Training Quantization)控制在可接受范围。
四、深度实战:动手搭建你的第一个 WiFi 感知系统
4.1 硬件准备
RuView 的完整 BOM(物料清单)有两种配置:
最低成本配置(仅感知,无本地 AI):
| 组件 | 型号 | 单价 |
|---|---|---|
| ESP32-S3 模组 | ESP32-S3-WROOM-1 | $3~5 |
| USB-C 供电线 | — | $1 |
| 总计 | $4~6 |
完整配置(含 Cognitum Seed 边缘网关):
| 组件 | 型号 | 单价 |
|---|---|---|
| ESP32-S3 节点 × 3 | ESP32-S3-WROOM-1 | $15 |
| Cognitum Seed 网关 | — | ~$120 |
| 树莓派 5(可选) | 4GB | $60 |
| USB-C 电源 | — | $5 |
| 总计 | $140~200 |
4.2 ESP32 CSI 固件烧录
# Step 1: 安装 ESP-IDF(Espressif IoT Development Framework)
git clone --depth 1 --recursive https://github.com/espressif/esp-idf.git
cd esp-idf
./install.sh esp32s3
. ./export.sh
# Step 2: 配置 CSI 监听固件
# 编辑 sdkconfig.defaults,启用 CSI 功能
echo 'CONFIG_ESP_WIFI_CSI_ENABLED=y' >> sdkconfig.defaults
echo 'CONFIG_ESP_WIFI_STATIC_RX_BUFFER_NUM=16' >> sdkconfig.defaults
# Step 3: 编译并烧录
idf.py set-target esp32s3
idf.py build
idf.py -p /dev/ttyUSB0 flash monitor
# Step 4: 连接目标路由器(ESP32 必须以 Station 模式连接 AP)
# 这会自动触发 CSI 数据采集
4.3 多节点 mesh 组网
单节点 ESP32 只能覆盖一个房间(典型半径 5~8 米)。RuView 支持多节点 mesh 组网,核心原理是时分多址(TDMA):
// ESP32 Mesh 网络中的时分多址调度
typedef struct {
uint8_t node_id;
uint8_t time_slot; // 0~5,共 6 个时隙
uint8_t channel; // 跳频信道 1, 6, 11, 36, 40, 44
uint32_t start_time_us;
} mesh_schedule_entry_t;
// 每个节点在分配给自己的时隙内完成 CSI 采集和传输
// 其他时隙处于深度睡眠(节省功耗)
void enter_time_slot(mesh_schedule_entry_t *slot) {
if (slot->channel != current_channel) {
esp_wifi_set_channel(slot->channel, WIFI_SECOND_CHAN_NONE);
}
// 采集并发送 CSI 数据
start_csi_sniffer();
// 时隙结束后立即休眠
vTaskDelay(pdMS_TO_TICKS(SLOT_DURATION_MS));
esp_deep_sleep_start();
}
多频 mesh 跳频策略(ADR-029)同时监听 6 个 WiFi 信道(2.4GHz 的 1/6/11 + 5GHz 的 36/40/44),借助邻居家的路由器作为「免费雷达照明源」——这意味着即使你没有自己的路由器,也能利用环境中已有的 WiFi 信号进行感知。
4.4 在 Home Assistant 中配置
# Home Assistant configuration.yaml
# RuView MQTT 自动发现配置(无需手动定义实体)
mqtt:
sensor:
- name: "RuView - 客厅 - 呼吸频率"
state_topic: "ruview/living_room/vitals/breath_rate"
unit_of_measurement: "bpm"
device_class: "呼吸"
unique_id: "ruview_lr_breath"
- name: "RuView - 客厅 - 心率"
state_topic: "ruview/living_room/vitals/heart_rate"
unit_of_measurement: "bpm"
device_class: "heart_rate"
- name: "RuView - 客厅 - 人数"
state_topic: "ruview/living_room/occupancy/count"
unit_of_measurement: "人"
- name: "RuView - 客厅 - 有人/无人"
state_topic: "ruview/living_room/state/presence"
value_template: >-
{% if value_json.occupied %} 在线 {% else %} 离开 {% endif %}
# 自动化示例:有人进入时自动开灯
automation:
- alias: "有人进客厅自动开灯"
trigger:
platform: mqtt
topic: "ruview/living_room/state/presence"
payload: '{"occupied": true}'
action:
service: light.turn_on
target:
entity_id: light.living_room
data:
brightness: 80
五、端侧 AI 推理:Candle + 剪枝 + 量化全链路优化
5.1 为什么选择 Rust + Candle 而不是 PyTorch?
RuView 的 AI 推理层选择 Rust + Candle 而非主流的 PyTorch,有非常清晰的技术理由:
| 维度 | PyTorch | Candle (Rust) |
|---|---|---|
| 二进制体积 | ~100MB(libtorch) | ~2MB |
| 启动时间 | ~500ms | ~10ms |
| 内存占用 | ~500MB | ~50MB |
| 硬件亲和 | 通用 | 嵌入式优化 |
| 部署依赖 | Python 运行时 | 无 |
| 延迟确定性 | GC 抖动 | 确定性延迟 |
| 最适合场景 | 数据中心 | 边缘嵌入式 |
RuView 的目标设备是树莓派甚至 ESP32 级别的硬件,PyTorch 完全不现实。
5.2 Pose Estimation 的端侧推理
RuView 支持 17 关键点姿态估计(类似 MediaPipe Pose,但用 WiFi CSI 而非摄像头)。技术实现基于 Cog(Composable Generators)框架:
// RuView 姿态估计模块 - 使用 Cog v0.0.1
// Cog 是一个模型分发框架,支持预编译的 aarch64/x86_64 二进制 + safetensors
pub struct PoseEstimator {
cog: CogExecutor, // 预编译二进制运行时
pose_model: SafetensorsLoader, // pose_v1.safetensors
}
impl PoseEstimator {
pub fn new() -> anyhow::Result<Self> {
let cog = CogExecutor::from_url(
"https://storage.googleapis.com/cog-weights/cog-pose-estimation",
)?;
let pose_model = SafetensorsLoader::load("pose_v1.safetensors")?;
Ok(Self { cog, pose_model })
}
/// 从 CSI embedding 推理 17 关键点坐标
/// 关键点索引:0=鼻子, 1~4=眼睛/耳朵, 5~11=躯干+手臂, 12~16=腿
pub fn estimate_pose(&self, csi_embedding: &[f32; 128]) -> Pose17Keypoints {
// 将 CSI embedding 上采样到与训练分辨率匹配
let upsampled = upsample_csi_to_pose_resolution(csi_embedding);
// 推理前向传播
let heatmaps = self.cog.forward(&upsampled)?;
// 从热力图解码关键点坐标(soft-argmax)
let keypoints = self.decode_heatmaps(&heatmaps, 17);
Pose17Keypoints { keypoints }
}
fn decode_heatmaps(&self, heatmaps: &[f32], num_keypoints: usize) -> Vec<(f32, f32)> {
let stride = 4; // 热力图 stride,与输入分辨率相关
let height = 64; // 热力图高度
let width = 64; // 热力图宽度
(0..num_keypoints)
.map(|k| {
let hm = &heatmaps[k * height * width..(k + 1) * height * width];
let max_idx = hm
.iter()
.enumerate()
.max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap())
.map(|(i, _)| i)
.unwrap_or(0);
let y = (max_idx / width) as f32 * stride;
let x = (max_idx % width) as f32 * stride;
// Soft-argmax 加权精确化(亚像素精度)
let (wx, wy) = soft_argmax(hm, stride);
(wx, wy)
})
.collect()
}
}
5.3 极速推理背后的工程技巧
RuView 在 8KB 模型下实现了 100% 存在检测精度(官方 benchmark),做到这一点的工程手段值得学习:
技巧一:离线标定 + 在线查表
检测逻辑不走前向传播(无 ML),纯信号处理:
// 无模型存在检测:在标定期间建立噪声基线
// 在线推理 = 实时 CSI 方差 vs 基线方差比较
// O(1) 时间复杂度,微秒级响应
pub fn detect_presence(&self, csi: &CSIFrame) -> bool {
let current_var = self.compute_variance(csi);
let threshold = self.baseline_p95 * 1.5; // 1.5倍安全系数
current_var > threshold
}
技巧二:多跳推理缓存
当检测逻辑返回「存在」时,不立即触发 ML 推理,而是等 N 帧确认后再做决定,减少无效计算:
// 确认机制:连续 N 帧检测到才触发高级推理
const CONFIRM_FRAMES: usize = 3;
let mut consecutive_presence: usize = 0;
for frame in csi_stream.chunks(CONFIRM_INTERVAL_MS) {
if presence_detector.detect(&frame) {
consecutive_presence += 1;
if consecutive_presence >= CONFIRM_FRAMES {
// 触发姿态估计
let pose = pose_estimator.estimate(embedder.encode(&frame));
// ... 处理姿态 ...
consecutive_presence = 0;
}
} else {
consecutive_presence = 0;
}
}
技巧三:OccWorld 世界模型预测
RuView 还集成了一个 15 帧未来预测的世界模型(TransVQVAE),在 RTX 5080 上仅需 209ms,可提前 1.5 秒预判人的移动方向:
# 未来帧预测示例(Python 接口)
from occworld import OccWorldPredictor
predictor = OccWorldPredictor.from_pretrained("ruvnet/occworld-v1")
occupancy_history = occupancy_sequence[-15:] # 最近 15 帧占用图
future_frames = predictor.predict(
current_state=occupancy_history,
num_frames=15,
resolution=(200, 200, 16) # 200x200 像素, 16 voxel 深度
)
# future_frames[0] = 0.5s 后预测, future_frames[14] = 7.5s 后预测
六、性能数据全面解析
以下数据来自 RuView 官方 benchmark 文档:
6.1 感知精度 benchmark
| 指标 | 实现方式 | 精度 | 延迟 |
|---|---|---|---|
| 存在检测 | 128-dim embedding + 线性头 | 100%(验证集) | < 1ms |
| 呼吸频率 | 0.1~0.5Hz 带通 + 零交叉 | ±1 BPM | 实时 |
| 心率 | 0.8~2.0Hz 带通 + 零交叉 | ±3 BPM | 实时 |
| 17 关键点姿态 | Cog + Candle safetensors | 接近 MediaPipe | 8.4ms(Pi 5,冷启动) |
| 人数统计 | 自适应 P95 + 专用计数器 | 自校准 | 实时 |
| 跌倒检测 | 相位-加速度阈值 + 3帧去抖 | < 200ms 响应 | < 200ms |
| 未来预测 | TransVQVAE OccWorld | — | 209ms(RTX 5080) |
6.2 硬件性能 benchmark
| 设备 | CSI Embedding 吞吐量 |
|---|---|
| M4 Pro(Mac) | 164,183 emb/s |
| NVIDIA RTX 5080 | 数百万 emb/s(批处理) |
| 树莓派 5 | 数百 emb/s(实时可用) |
| ESP32 | 有限(无 ML,仅信号处理) |
6.3 量化精度对比
| 量化精度 | 模型大小 | 性能损失 | 适用场景 |
|---|---|---|---|
| FP32 | 70.4 MB | 基准 | 开发/训练 |
| INT8 | 22 MB | < 2% | 树莓派 |
| INT4 | 8.8 MB | < 5% | ESP32 / 低成本网关 |
七、应用场景:从智能家居到医疗健康
7.1 智能家居:无感化体验的终极形态
传统智能家居的存在检测依赖红外PIR传感器,存在明显痛点:
- PIR 只能检测大幅移动:静止不动就「消失」
- PIR 有延迟:进入视野后 5~15s 才触发
- PIR 有视角限制:需要正对检测区域
WiFi CSI 感知完全解决了这些问题:
- 检测微动:呼吸带来的胸腔起伏即可检测存在
- 即时响应:< 1ms 延迟
- 覆盖面积大:单节点覆盖 30~50㎡
# 更智能的家居自动化示例
automation:
# 场景一:人在沙发久坐自动调暗灯光
- alias: "久坐自动调暗"
trigger:
platform: numeric_state
entity_id: sensor.ruview_living_heart_rate
above: 50
below: 85
for:
minutes: 30
action:
service: light.turn_on
data:
brightness: 15
color_temp: 500 # 暖光
# 场景二:呼吸异常告警(老人独居场景)
- alias: "呼吸异常告警"
trigger:
platform: numeric_state
entity_id: sensor.ruview_bedroom_breath_rate
below: 8 # 呼吸过慢
for:
minutes: 2
action:
- service: notify.mobile_app_phone
data:
message: "检测到呼吸异常,请确认安全"
- service: camera.snapshot
data:
entity_id: camera.front_door
filename: "/config/www/alerts/breath_alert.jpg"
7.2 医疗健康: contactless 生命体征监测
RuView 最具想象力的应用方向是无接触生命体征监测:
睡眠监测:在卧室放置 ESP32 节点,整夜监测呼吸频率、心率、睡眠体位,输出睡眠阶段分类(清醒、浅睡、深睡、REM),用于:
- 睡眠呼吸暂停筛查(AHI > 5 即为可疑)
- 老人夜间异常监测(心率骤降/骤升告警)
- 婴儿猝死综合征(SIDS)预警
跌倒检测 + 救援链:
跌倒事件触发流程(< 200ms):
1. 相位-加速度突变检测(> 阈值)
2. 3帧去抖确认(非瞬时噪声)
3. 5秒冷却期后触发救援链
4. 发送 MQTT 事件到 Home Assistant
5. → 通知家人手机
6. → 记录事件时间轴
7. → 持续监测心率(判断意识状态)
7.3 零售与楼宇:数据驱动的空间优化
- 商场客流分析:入口处 ESP32 统计进店人数、停留时长
- 会议室占用:实时显示哪些会议室空闲/占用(无需预约系统)
- 养老院老人行为监测:跌倒检测 + 离床检测 + 久坐告警
- 工厂安全区域入侵检测:穿墙感知 + 多节点协同覆盖
八、安全与隐私:为什么「无摄像头」是关键
RuView 的设计哲学里,隐私不是附加功能,而是架构约束。
8.1 隐私设计原则
① 数据最小化:只采集和处理 CSI 信号,不采集任何个人身份信息。路由器发射的无线信号是广播的「环境噪声」,CSI 处理后的信息是聚合统计(呼吸次数、是否有人),而非个人生物特征。
② 本地优先:所有 AI 推理在边缘网关(Cognitum Seed)本地完成,数据不上云。系统支持完全离线运行。
③ 加密见证链:每条测量结果附带 Ed25519 签名(Ed25519 witness chain),形成不可篡改的证据链:
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
// 每条感知记录附带密码学签名
pub struct AttestedMeasurement {
pub timestamp: u64,
pub presence: bool,
pub breath_rate: f32,
pub heart_rate: f32,
pub signature: Signature,
}
impl AttestedMeasurement {
pub fn sign(data: &Self, key: &SigningKey) -> Signature {
let msg = serialize_for_signing(data);
key.sign(&msg)
}
pub fn verify(&self, key: &VerifyingKey) -> bool {
let msg = serialize_for_signing(self);
key.verify(&msg, &self.signature).is_ok()
}
}
8.2 安全性边界
需要正视的安全考量:
- 对抗性干扰:恶意在 WiFi 信道中注入噪声可以干扰感知(类似雷达干扰)。这是物理层安全的根本局限。
- 邻里干扰:住在公寓楼时,邻居家的 WiFi 信号也可能被误认为「人」。多频 mesh 和环境校准可以在一定程度上缓解,但无法完全消除。
- 隐私边界:虽然不采集音频/视频,但高频的呼吸和心率数据仍属于敏感生理信息。RuView 将其本地化处理,是目前最合理的隐私保护方案。
九、总结与展望
9.1 RuView 解决了什么问题
| 传统方案的问题 | RuView 的答案 |
|---|---|
| 摄像头侵犯隐私 | 零摄像头 |
| 毫米波雷达成本高 | $9 ESP32 |
| PIR 只能检测移动 | 检测呼吸/心率等微动 |
| 云端 AI 延迟高、隐私差 | 本地推理 < 1ms |
| 智能家居体验不「无感」 | 真正的无感化感知 |
9.2 技术演进方向
2026 年的 WiFi CSI 感知正在经历几个关键进化:
方向一:多模态融合。WiFi CSI + 声学(麦克风)+ IMU(加速度计)的多传感器融合,可以进一步提升精度,降低单模态的误报率。
方向二:标准化的 802.11bf WiFi Sensing 协议。IEEE 正在制定 802.11bf 标准,将 WiFi 感知纳入 WiFi 协议原生支持。这意味着未来所有 WiFi 设备都能原生支持感知,无需特殊固件。
方向三:更小模型的端侧部署。当前 8KB 模型已经极致压缩,但 17 关键点姿态估计仍依赖 Pi 5 级别的算力。随着专用 AI 芯片(DSP+NPU 异构芯片)普及,未来 ESP32 级别的芯片也能跑完整模型。
方向四:多用户追踪。当前的人数统计在 3 人以内相对准确,超过 3 人时多径重叠严重。6 节点以上的 mesh 网络 + 更先进的信号分离算法是突破方向。
9.3 给工程师的建议
如果你想入局 WiFi 感知这个方向:
- 先玩 RuView:上手门槛已经极低,Home Assistant 一行命令集成
- 补充信号处理基础:CSI 感知本质是「雷达信号处理 + 机器学习」,推荐先掌握 FFT、滤波器设计、雷达分辨率等基础知识
- 学 Rust:Candle 是未来端侧 ML 的重要框架,提前熟悉 Rust + ML 的组合会很有价值
- 关注 802.11bf 标准化:协议层支持意味着硬件原生支持,是这个方向从「极客玩具」走向「标配功能」的关键节点
WiFi 感知赛道 2026 年正在从「技术可行」走向「大规模落地」。RuView 的 68K Star 证明了这个方向的真实热度。如果你对隐私敏感的智能感知、无摄像头的人体检测、或边缘 AI 在嵌入式场景的应用感兴趣,现在正是入局的好时机。
参考链接:
- RuView GitHub:https://github.com/ruvnet/RuView
- RuVector(Rust 向量数据库):https://github.com/ruvnet/ruvector
- WiFi DensePose 模型(HuggingFace):https://huggingface.co/ruvnet/wifi-densepose-pretrained
- ESP-IDF CSI 采集文档:https://docs.espressif.com/projects/esp-idf/
标签:ESP32|WiFi CSI|空间感知|Rust|Candle|端侧AI|智能家居|无接触监测|Home Assistant|Matter
关键词:WiFi感知|Channel State Information|ESP32|Rust信号处理|CSI人体检测|无摄像头|呼吸监测|心率检测|边缘AI|Home Assistant|端侧推理|Candle ML|8KB模型|4bit量化|跌倒检测|空间智能|RuView|RuVector|多径效应|OF DM|穿透感知|Raspberry Pi|物联网|智慧养老|智能家居|穿透墙感知