WiFi 感知人体呼吸与心率:CSI 原理、可行做法与真实局限

作者 Wu Jun · 更新于 2026-11-02 · 约 8 分钟

呼吸与心率检测的基本原理是:胸腔起伏和心跳会改变无线多径,这些微小变化可能出现在 CSI 的幅度或相位里 [1]。但"频谱里有一个峰"不等于测到了生理信号。风扇、 空调、走路、设备自动换链和相位漂移都可能制造一个看起来合理的数字。

本文给出一条可复现的工程路线:如何正确计算独立测量率、选出相对稳定的信号、按时间戳重采样、 做 30 秒滑窗、设置可用性门控,以及用外部参考设备做标定。本文不是医疗建议,也不声称当前 开源数据集已经达到某个临床精度。

0. 先看结论

项目 相对可行性 关键原因
呼吸率 中等 变化较慢、幅度通常较大,但仍会被动作和 HVAC 周期干扰
心率 低到中等 胸腔位移更小,容易混入呼吸谐波、相位噪声和多径变化
多人同时测量 低 多人的反射叠加,单峰频率通常无法可靠分配给某个人
穿墙或跨房间 不可默认假设 墙体和距离会显著改变信噪比,必须重新实测
医疗诊断 不适用 本项目和消费级 CSI 都不能替代合规医疗设备

可行做法不是"每个窗口强行输出一个 bpm",而是同时输出测量值、质量指标和 Unavailable 状态。精度只能相对于同步的呼吸带、胸阻抗、脉搏血氧仪或人工计数来评估。

1. 采样率:记录率不是独立测量率

本项目 10 分钟抓包有 57,293 条链记录,平均约 95.5 条记录/秒。但一次 CSI 测量通常 包含 4 条或 8 条链记录,它们共享同一个测量时刻。按 chain_info bit15 切分后得到 7,609 个完整测量组,实际独立测量率约为:

7,609 / 599.924 s ≈ 12.68 measurements/s

分析脚本进一步剔除 25 个零值过多的异常组,使用 7,583 个测量,得到约 12.64 Hz。这个 速率高于 2 Hz 心率上限所需的 4 Hz Nyquist 下限,但余量并不夸张。频率分辨率、丢包、 时间戳抖动和窗口长度仍然决定结果是否可信。

因此第一件事不是画频谱,而是:

  1. 按 chain_info bit15 得到完整测量组;
  2. 每个测量组只选择一条有效链,或构造双天线比值;
  3. 用实际时间戳排序,不要拿"记录序号 ÷ 常数帧率"伪造时间轴;
  4. 检查缺失链、64 子载波记录、全零 I/Q 和长于 1 秒的空档。

2. 选什么信号

幅度容易使用,但会被自动增益、自动速率和大幅动作共同影响。相位包含更细的位移信息, 但商用网卡的原始相位通常混有载波频偏、采样频偏和硬件漂移。单独对一条链"去卷绕"并不能 自动消除这些误差。

PhaseBeat 使用 CSI 相位信息做呼吸和心率监测 [2];FarSense 则强调用两根 天线的 CSI ratio 抑制共模噪声并扩大呼吸检测距离 [3]。工程上可以按以下 优先级试验:

  1. 同一测量中的双天线相位差或复数比值;
  2. 相邻子载波相位差;
  3. 多子载波幅度变化的第一主成分;
  4. 单链幅度,只作为最简基线。

无论选哪一种,都要先用静止空房、有人静止、有人走动三类数据检查:信号是否真的对呼吸周期 敏感,还是只对运动敏感。

3. 一个不夸张的估计流程

下面的函数假设输入已经是每次测量选一条链后得到的实值信号。它完成线性去趋势、Hann 窗、频带功率谱、峰值频率和两个质量指标。先按时间戳插值到统一采样率:

import numpy as np
from numpy.fft import rfft, rfftfreq

def resample_by_time(x, t, fs=20.0):
    x = np.asarray(x, dtype=float)
    t = np.asarray(t, dtype=float)
    order = np.argsort(t)
    t, x = t[order], x[order]
    tu = np.arange(t[0], t[-1], 1.0 / fs)
    xu = np.interp(tu, t, x)
    return xu, tu

def estimate_bpm(x, fs, band):
    x = np.asarray(x, dtype=float)
    n = len(x)
    if n < int(fs * 20):
        return None

    # Remove a linear trend before windowing.
    idx = np.arange(n, dtype=float)
    slope, intercept = np.polyfit(idx, x, 1)
    y = x - (slope * idx + intercept)
    y = (y - y.mean()) * np.hanning(n)

    power = np.abs(rfft(y)) ** 2
    freqs = rfftfreq(n, 1.0 / fs)
    mask = (freqs >= band[0]) & (freqs <= band[1])
    if not mask.any():
        return None

    band_power = power[mask]
    band_freqs = freqs[mask]
    k = int(np.argmax(band_power))
    peak_freq = band_freqs[k]

    floor = float(np.median(band_power)) + 1e-12
    prominence_db = 10.0 * np.log10((band_power[k] + 1e-12) / floor)
    band_ratio = float(band_power.sum() / (power.sum() + 1e-12))

    return {
        "bpm": float(peak_freq * 60.0),
        "prominence_db": float(prominence_db),
        "band_power_ratio": band_ratio,
    }

示例频带:

# 先统一到 20 Hz 时间网格;插值不会增加真实信息。
xu, tu = resample_by_time(signal, timestamps, fs=20.0)

breath = estimate_bpm(xu, fs=20.0, band=(0.10, 0.50))  # 6–30 bpm
heart  = estimate_bpm(xu, fs=20.0, band=(0.80, 2.00))  # 48–120 bpm

30 秒窗口的理论频率间隔约为 1 / 30 = 0.033 Hz,也就是约 2 bpm。它不代表误差一定是 2 bpm;峰的位置还受信噪比、窗函数和信号非平稳性影响。

4. 30 秒滑窗与可用性门控

推荐从 30 秒窗口、5 秒步长开始。呼吸至少覆盖 3 个周期,心率覆盖更多周期;代价是首次数值 至少延迟约 30 秒。对每个窗口保存:

window = {
    "start_s": start,
    "end_s": end,
    "valid_sample_ratio": valid_count / expected_count,
    "max_gap_s": max_gap,
    "motion_score": motion_score,
    "band_power_ratio": result["band_power_ratio"],
    "prominence_db": result["prominence_db"],
    "bpm": result["bpm"],
}

门控示例:

def vitals_status(w, limits):
    if w["valid_sample_ratio"] < limits["min_valid_ratio"]:
        return "Unavailable: data coverage"
    if w["max_gap_s"] > limits["max_gap_s"]:
        return "Unavailable: gap"
    if w["motion_score"] > limits["max_motion"]:
        return "Unavailable: motion"
    if w["prominence_db"] < limits["min_prominence_db"]:
        return "Unavailable: weak peak"
    if w["band_power_ratio"] < limits["min_band_ratio"]:
        return "Unavailable: broadband noise"
    return "OK"

这些阈值不能照抄成全世界通用的常数。正确做法是收集有参考标签的"空房、静止、说话、走动、 翻身"数据,在自己设备的固件、信道、链路和安装位置下选取阈值,并同时报告误报率和可用率。

5. 一套可复现的标定协议

本节是实验设计建议,不是已经完成的精度结论。受试者应知情同意,并明确这只是研究用途。

5.1 设备与场景

5.2 采集顺序

  1. 空房 2 分钟;
  2. 受试者静坐 2 分钟,不讲话、不改变姿势;
  3. 按节拍器进行 10、15、20 次/分的呼吸段,每段至少 1 分钟;
  4. 正常呼吸 2 分钟,并用参考设备同步记录;
  5. 原地小幅动作 30 秒,再恢复静止 60 秒;
  6. 改变距离或朝向,重复至少 3 次。

心率部分不要用自报"我感觉是 70 bpm"作真值。使用带时间同步的脉搏血氧仪或其他经 合规验证的设备,只用于研究对照,不用于诊断。

5.3 每次实验至少保存

字段 目的
UTC 起始时间与单调时钟 对齐 CSI 与参考设备
设备、固件、信道、带宽 判断结果是否可复现
距离、朝向、姿势 解释多径变化
参考 bpm 与更新时间 计算误差,而不是只看波形漂亮
RSSI、SNR、链选择 发现弱信号或频繁换链
丢包、最大空档、有效样本率 解释 Unavailable
空房、静止、动作标签 计算漏报与误报

6. 指标怎么报

只报一个有参考值时最准确的窗口,会系统性高估算法。至少同时报告:

如果比较两种算法,应使用同一批窗口、同一参考时间轴和同一门控规则。若一个算法只输出 20% 的窗口却获得更低 MAE,必须把覆盖率一并展示。

7. 最常见的失败模式

现象 常见原因 处理
空房一直显示 15 bpm 风扇、HVAC、路由器周期性负载 采空房基线,做周期源排查,不要删除失败样本
人一说话结果跳到高频 语音和下颌运动远强于胸廓微动 质量门控,状态标记为动作期间不可用
心率总等于呼吸的 3–4 倍 呼吸谐波或非线性混频 检查峰的可分性,不把最高谐波硬标为心率
换一个窗口就换链 某些链为空或自动选链 固定有效链或使用双天线 ratio
时间轴看起来均匀但频谱有假峰 用记录序号代替时间戳 按时间戳重采样并检查长空档
换房间后全部失准 多径、距离、遮挡和增益改变 重新标定,不沿用旧阈值
深度学习精度很高但无法复现 同一受试者数据泄漏或窗口重叠 按受试者和日期划分训练/测试集

8. 这个数据集能证明什么

本项目公开的 10 分钟数据可用于练习:

它不包含同步呼吸带或心率参考值,所以不能拿它宣称"呼吸误差 2 bpm"或"心率误差 5 bpm"。 这类结论必须来自新的、经过知情同意并带参考设备的采集。

9. 最小实施清单

10. 参考实现与下一步

参考与数据来源

本文的每条外部结论都标注来源与证据等级(分级标准);标注 E 级的是本项目可复现的实测数据。

  1. A 级 · 标准/综述 WiFi Sensing with Channel State Information: A Survey
    W. Ma, W. Zhou, X. Wang · ACM Computing Surveys · 2019
    doi:10.1145/3310194 · 被引 857 · 核实: CrossRef API, 2026-10-11 · 领域内最常被引用的 CSI 感知综述(被引 857 次,核实于 CrossRef)。
  2. B 级 · 同行评审 PhaseBeat: Exploiting CSI Phase Data for Vital Sign Monitoring with Commodity WiFi Devices
    X. Wang, C. Yang, S. Mao · IEEE ICDCS 2017 · 2017
    doi:10.1109/icdcs.2017.206 · 被引 325 · 核实: CrossRef API, 2026-10-11 · 用 CSI 相位做呼吸/心率监测的代表性工作(被引 325 次)。
  3. B 级 · 同行评审 FarSense: Pushing the Range Limit of WiFi-based Respiration Sensing with CSI Ratio of Two Antennas
    Y. Zeng, D. Wu, J. Xiong, et al. · Proceedings of the ACM on Interactive, Mobile, Wearable and Ubiquitous Technologies (IMWUT) · 2019
    doi:10.1145/3351279 · 被引 461 · 核实: CrossRef API, 2026-10-11 · 呼吸感知的距离上限与 CSI ratio 方法(被引 461 次)。