不用摄像头,我用 WiFi 看见了房间里的人

· 2026-05-27 00:27 · 2 阅读

原创 黑屋科技站 2026-05-27 00:27 上海

不用摄像头,也能感知房间里有没有人;真正难的不是硬件,而是工具链、固件和信号处理。

我本来以为,做 WiFi 人体感知最难的是信号处理。结果真正让我卡最久的,是一个多输了几位的 WiFi 密码。

最近折腾了一套基于 WiFi CSI 的室内人体感知系统:四块 ESP32-S3,不用摄像头,不用红外,纯靠 WiFi 信号的变化来判断房间里有没有人、人在哪个区域、是走动还是静止。最后系统确实跑起来了,但大部分时间并不是花在“算法调优”上,而是在跟工具链、固件、NVS 和数据解析搏斗。

这篇文章不是一篇论文式介绍,更像是一份实战记录:原理讲到够用,重点放在我踩过的坑、怎么定位、最后怎么绕过去。

图 1:四块 ESP32-S3 节点,分别放在房间四个角落

CSI 是什么,为什么能感知人体

可以先把一个 WiFi 信道想象成几十条很窄的小路。每条“小路”上的信号,都会被墙面、家具和人体反射、散射或遮挡。接收端看到的这些变化,就是 CSI。

更正式一点说,WiFi 使用 OFDM(正交频分复用)调制,一个信道会被分成几十个子载波。ESP32-S3 在 HT20 模式下有 52 个有效子载波。每个子载波携带的信号在空间中传播时,会经历反射、散射和衍射,到达接收端时,幅度和相位都会发生变化。这个变化可以用一个复数来描述,叫 CSI:Channel State Information,信道状态信息。

H(k) = |H(k)| × e^(j∠H(k))

其中 |H(k)| 是幅度,∠H(k) 是相位。52 个子载波就对应 52 组这样的复数值,组成一个 CSI 向量。

房间里没人的时候,多径环境相对稳定,这个向量也比较稳定。有人走进来之后,人体,尤其是含水量高的躯干,会对 2.4GHz 信号产生明显散射,原有的多径传播路径被改变,CSI 向量就会发生变化。

人在走动时,CSI 的变化比较剧烈;人静坐时,变化小得多,但并不是完全没有。呼吸会带来胸腔约 1-2 厘米的周期性运动,这些微小位移会让 CSI 相位产生周期性扰动,频率通常落在 0.1-0.5Hz 的呼吸频段。心跳引起的体表微动更小,通常落在 0.8-2Hz 附近,但信噪比低得多,检测难度也大得多。

所以,我这套系统里的基本链路可以概括为:

原始 I/Q 数据 → 幅度/相位提取 → 帧间方差计算 → 阈值判定运动/静止 → 带通滤波提取呼吸/心率

ESP32-S3 在 ESP-IDF 里开放了 CSI 回调接口。设备连上 WiFi 后注册一个回调函数,每收到一帧数据帧,就能拿到对应的 I/Q 数据。

系统架构

最终系统的结构并不复杂,核心是把固件端采集到的数据发到本机服务端,再由服务端做解析、特征提取和可视化推送。

ESP32-S3 节点
↓ UDP
Go sensing server

特征提取 / 阈值判断 / 跨节点去重
↓ WebSocket
Web UI / 3D 可视化

硬件上是四块 ESP32-S3,分别放在房间四个角落。服务端用 Go 写,负责 UDP 监听、CSI/Vitals 包解析、运动检测、存在判断、人数估计和 WebSocket 推送。前端则分成一个普通监控界面和一个 Three.js 的 3D 空间视图。

第一个坑:CSI 数据为零

我用的是开源项目 RuView 的固件。clone 下来、编译、烧录,串口日志看起来一切正常:WiFi 连上了,CSI 回调也注册了。但是输出只有:

yield=0pps

零。一帧 CSI 数据都没有。

查了半天,发现问题出在混杂模式的帧过滤器。固件默认只开了 WIFI_PROMIS_FILTER_MASK_MGMT,也就是只抓管理帧。但 beacon 帧用的是 DSSS 调制,本身不携带 CSI。必须加上 WIFI_PROMIS_FILTER_MASK_DATA,捕获 OFDM 调制的数据帧,才有 CSI 输出。

一行代码的事,找了大半天。

第二个坑:WiFi 死活连不上

改完帧过滤器,重新编译烧录。结果 WiFi 又连不上了,串口不停循环:

auth -> assoc -> run -> init (0x2a0)

0x2a0 是握手超时。我一开始怀疑是 WPA3 的 PMF(Protected Management Frames)配置问题,于是反复改 authmode,从 WPA3 改到 WPA2,再改到混合模式。后来又怀疑是 ESP-IDF 版本兼容性问题:项目用的是 5.3.2,但我本地环境下各种编译问题,于是又换到 5.2.7,重新配环境,install.ps1、export.ps1、set-target,中间还碰到 Python UTF-8 编码错误。

折腾了几个小时,最后发现原因极其简单:NVS 里存了一个错误的 WiFi 密码。之前调试时手滑多输了几个字符,密码被写进 NVS。后面我改 sdkconfig 里的默认密码并没有用,因为 NVS 里缓存的凭据优先级更高。

擦除 NVS 分区之后,WiFi 立刻连上了。几个小时的血压升高,最后只是因为一个密码多了几位。

第三个坑:数据收到了,但解析不顺手

WiFi 终于连上,四块 ESP32 也陆续烧录完毕,本机 UDP 监听能收到数据了。但 RuView 自带的 sensing-server 是 Rust 写的,编译依赖比较多;自带 Web UI 又是英文界面,还带了不少 demo 功能。

我干脆不用它的前端,自己用 Go 重写一个轻量版本。Go 的好处是编译出来就是一个 exe,不依赖运行时。UDP 监听、WebSocket 推送、内嵌 HTML,几个文件就能跑起来:

·udp.go - 解析 ESP32 发来的两种 UDP 包:CSI 原始帧和 Vitals 生命体征包

·detector.go - 运动检测、存在判断、人数估计

·web.go - 内嵌中文 Web 界面

·main.go - 入口,拉起所有服务

Vitals 包是小端序 32 字节,magic number 为 0xC5110002,里面包含 RSSI、运动能量、存在分数、呼吸频率、心率等字段。CSI 原始帧的 magic number 是 0xC5110001,后面跟 I/Q 数据对,每个子载波两个 int8。

第四个坑:人数检测全是 4

界面跑起来之后,四个节点都在线,CSI 帧率大约 13-15fps。但人数显示 4。家里明明就我一个人,哪来的 4 个?

继续查固件源码,发现它的 n_persons 字段只是一个启发式估计:top_k_count / 2,也就是把方差最大的子载波数量除以 2。四块 ESP32 在同一个 WiFi 网络里互相通信,每个节点都能收到其他几个节点的数据帧,子载波活跃度一直很高,所以每个节点都会报多人。

这不是人数检测,这是子载波计数器。

最后我分两步处理。第一步,不再使用固件里的 n_persons 字段,改用 presenceScore(存在分数)阈值判断。因为 ESP32 之间互相干扰,基线偏移比较严重,阈值需要调到 12 以上,才能过滤掉设备间信号造成的误报。

第二步,加跨节点时序关联去重。如果两个节点的运动事件在 800 毫秒窗口内高度重合,重合度超过 60%,就认为它们看到的是同一个人经过。同时再计算能量时序的皮尔逊相关系数,相关性超过 0.7 的归为一组。直觉上,同一个人经过时,多个节点会在接近时间内看到同一段能量波动;如果只是设备间互相干扰,虽然能量可能也高,但时序形状不一定一致。

调完之后,系统终于能稳定显示 1 人。

图 2:主界面,显示四个节点状态、呼吸心率、运动检测和活动日志

踩坑小结

问题

表现

原因

解决方式

CSI 为零

yield=0pps

只抓管理帧,beacon 不携带   CSI

加 DATA 帧过滤

WiFi 连不上

auth/assoc 循环,0x2a0

NVS 缓存了错误密码

擦除 NVS 分区

服务端不顺手

Rust 依赖多,UI 不适配

原项目偏 demo

Go 重写轻量服务端

人数全是 4

单人环境误报多人

启发式子载波计数不可靠

presenceScore 阈值   + 跨节点关联去重

3D 空间可视化

普通平面界面能看数据,但不够直观。我又加了一个 Three.js 的 3D 视图,单独做成一个页面。

房间模型很简单:8×6 米的矩形空间,四个角各放一个节点模型,由盒子、天线和底座组成。有人的区域会出现火柴人模型;运动状态下,火柴人会播放简单的行走动画,手臂和腿交替摆动。火柴人会根据信号最强的节点位置缓动过去,方向也跟着转。

这里也碰到一个小问题:Three.js 从 r150 版本开始,OrbitControls 不能再用全局 script 标签加载,必须用 ES6 模块和 import map。页面一直显示“连接中”,看起来像 WebSocket 没连上,实际上是 OrbitControls 加载失败,后面的 WebSocket 代码根本没有执行。

图 3:3D 空间视图,火柴人实时显示在信号最强的节点区域

目前状态

四块 ESP32-S3 目前可以稳定运行,实测状态大致如下:

指标

数值

CSI 帧率

12-15 fps/节点

运动检测延迟

<500ms

存在检测

基于 presenceScore   阈值

呼吸频率

10-20 bpm,带通滤波   0.1-0.5Hz

心率

40-50 bpm,实验性估计,精度有限

人数估计

跨节点关联去重

心率估计偏低且不太稳定,这基本是 CSI 方案在低成本硬件上的固有限制。心跳引起的胸腔微动在 WiFi 信号里的能量非常小,信噪比不够,因此这个结果只能作为实验性参考,不能当作有效生理测量。呼吸检测相对靠谱一些,因为呼吸运动幅度比心跳大一个数量级。

人数检测目前能区分“有人/没人”和大致区域,但在没有机器学习模型的情况下,精确计数仍然很难。固件端所谓的高级处理,本质上还是一组信号处理启发式,不是训练出来的分类器。

几点体会

1. NVS 是个隐形杀手。 ESP32 的 NVS 会缓存 WiFi 凭据,改代码里的默认值不一定生效。遇到 WiFi 连接问题,先擦 NVS。

2. ESP-IDF 版本很重要。 5.2 和 5.3 的组件结构有差异,例如 esp_driver_uart 在 5.2 里还是合并在 driver 里的。sdkconfig 不要随便跨版本迁移,选一个能用的版本就别频繁换。

3. WiFi CSI 的门槛在信号处理,不在硬件。ESP32 的 CSI 接口很好用,数据也不难拿到。真正难的是把原始 I/Q 数据变成“房间里有 1 个人在客厅走动”这种稳定判断。

4. 多节点部署必须考虑互相干扰。 四块 ESP32 在同一个 WiFi 网络里互相通信,每个节点都能收到其他节点的数据帧。这些帧会被混杂模式捕获,导致 CSI 基线偏移。presenceScore 的阈值必须根据部署环境实测调整。

结尾

整个项目的 Go 代码不到 700 行,编译出来一个 exe 就能直接运行。如果你也有几块 ESP32-S3 闲置,可以试试玩 WiFi 感知,挺有意思。

它不一定能替代专业传感器,也不适合拿来做严肃的生命体征测量。但作为一个不用摄像头、不接触人体、只靠环境无线信号来感知空间变化的实验,它确实能让人重新认识每天都在用的 WiFi。

技术栈:ESP32-S3 + ESP-IDF 5.2.7 + Go 1.22 + Three.js + WebSocket

代码:csi-detector(UDP 监听 → 特征提取 → WebSocket 推送 → Web UI)

跳转微信打开