编程 WiFi 感知革命:RuView 如何用 9 美元的 ESP32 实现「透视眼」——CSI 空间智能完全指南(2026)

2026-05-30 17:44:37 +0800 CST views 11

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 节点 × 3ESP32-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,有非常清晰的技术理由:

维度PyTorchCandle (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接近 MediaPipe8.4ms(Pi 5,冷启动)
人数统计自适应 P95 + 专用计数器自校准实时
跌倒检测相位-加速度阈值 + 3帧去抖< 200ms 响应< 200ms
未来预测TransVQVAE OccWorld209ms(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 量化精度对比

量化精度模型大小性能损失适用场景
FP3270.4 MB基准开发/训练
INT822 MB< 2%树莓派
INT48.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 感知这个方向:

  1. 先玩 RuView:上手门槛已经极低,Home Assistant 一行命令集成
  2. 补充信号处理基础:CSI 感知本质是「雷达信号处理 + 机器学习」,推荐先掌握 FFT、滤波器设计、雷达分辨率等基础知识
  3. 学 Rust:Candle 是未来端侧 ML 的重要框架,提前熟悉 Rust + ML 的组合会很有价值
  4. 关注 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|物联网|智慧养老|智能家居|穿透墙感知

推荐文章

内网穿透技术详解与工具对比
2025-04-01 22:12:02 +0800 CST
PHP如何进行MySQL数据备份?
2024-11-18 20:40:25 +0800 CST
Python Invoke:强大的自动化任务库
2024-11-18 14:05:40 +0800 CST
Vue3中的Scoped Slots有什么改变?
2024-11-17 13:50:01 +0800 CST
OpenCV 检测与跟踪移动物体
2024-11-18 15:27:01 +0800 CST
Vue3中如何处理SEO优化?
2024-11-17 08:01:47 +0800 CST
Vue3的虚拟DOM是如何提高性能的?
2024-11-18 22:12:20 +0800 CST
Vue3结合Driver.js实现新手指引功能
2024-11-19 08:46:50 +0800 CST
前端如何给页面添加水印
2024-11-19 07:12:56 +0800 CST
程序员茄子在线接单