☰
毫米波雷达生命体征检测实战:从FMCW原理到呼吸心率提取
2026/10/8 3:01:59 网站建设 项目流程

去年第一次实跑毫米波雷达生命体征检测的时候,我盯着一根从算法代码里画出来的相位曲线看了很久。它不是点云图,也不是雷达视频,只有一条上上下下缓慢摆动的线——但这条线里既“装”着呼吸,又“装”着心跳。同事看了眼屏幕问我:“这波形到底是呼吸还是心跳?”我说:“大波浪是呼吸,波浪上那些细小的纹理是心跳。”毫米波雷达做心率、呼吸检测,本质上不是“看见”人体,而是用电磁波去量胸腔表面零点几毫米的起伏。这篇文章是《毫米波雷达心率、呼吸原理实现》系列的第一篇,侧重点放在“实例”两个字上:从原理怎么落地成一套能实际跑通的采集和处理流程,涉及24GHz/60GHz/77GHz常见雷达模块的选择、C++下的FFT信号处理路径、实测中的参数计算和踩坑记录。想动手验证生命体征雷达方案的嵌入式工程师、读研做雷达信号处理的同学,或者只是好奇“雷达怎么测心跳”的开发者,都能在里边找到能直接抄作业的部分。

  1. 核心原理拆解:毫米波雷达为什么能测心率和呼吸 1.1 FMCW雷达的差频信号与“快时间/慢时间”概念

要讲清楚实例,得先把基础原理捋一遍。这里我用最常见的FMCW体制来讲,也就是调频连续波雷达——24GHz生命体征模块、60GHz的英飞凌BGT60系列、77GHz的TI IWR/AWR系列,基本都是这个路子。

FMCW雷达发射的是一段频率线性变化的连续波,业内叫chirp。信号遇到胸腔反射回来之后,雷达本振信号和回波信号混频,得到一个中频信号。这个中频信号的频率叫差频,它和目标距离成正比:

其中S是chirp斜率,R是目标距离,c是光速。所以只要对单个chirp的中频信号做一次FFT,就能在频谱上看到不同距离处的反射峰值,这就是距离FFT。工程上我们把单个chirp看作“快时间”维,把连续发射的多根chirp看作“慢时间”维。每一根chirp做完距离FFT后,在某个距离bin上取复数值,按chirp顺序排列,就得到了一串慢时间信号。生命体征检测的关键信息,全部藏在这串慢时间信号里。

1.2 相位灵敏度:亚毫米位移怎么被“看见”

有人可能会问:距离FFT的距离分辨率一般只有几厘米到十几厘米,心跳只有零点几毫米,怎么可能分辨出来?答案是:我们不用幅度,用相位。

雷达做距离FFT后,每个距离bin上的值是一个复数,这个复数的相位和目标的精确距离存在对应关系。目标发生微小位移时,相位变化量为:

其中是电磁波波长。24GHz雷达波长约12.5mm,胸腔位移1mm对应约1rad相位变化;77GHz雷达波长约3.9mm,同样的1mm位移对应约3.22rad相位变化。心跳让人体表面产生的位移大约在0.1到0.5mm,即便在24GHz雷达下,也会产生0.16到0.8rad的相位变化,用现代ADC和处理器完全能稳定捕捉。

这也就解释了为什么生命体征雷达普遍倾向用更高频段:波长越短,相同的微小位移产生的相位变化越大,检测灵敏度越高。在这个应用里,“高频率”不是营销卖点,是物理决定的硬道理。

1.3 呼吸和心跳在频率域的天然分离

获取到胸腔微动的时间序列之后,怎么把呼吸和心跳分开?答案藏在频率上。呼吸频率通常在0.1到0.5Hz之间,对应每分钟6到30次;心跳频率通常在0.8到2.5Hz之间,对应每分钟48到150次。两个频段天然分开,所以最直接的办法就是两个带通滤波器:一个盯0.1到0.5Hz,另一个盯0.8到2.5Hz。滤波之后分别做频谱分析,取峰值对应的频率,乘60就得到每分钟呼吸次数和心率。

这个思路听起来很顺,实际跑起来却有不少问题——呼吸波形不是干净正弦波,谐波容易串进心率频段;体动一来,相位直接跳变,滤波结果立刻废掉。这些我在第4章会单独展开。

  1. 方案选型与整体架构:从雷达前端到BPM结果 2.1 三种雷达前端选型对比

做这类项目,最纠结的不是算法而是硬件选型。市面上能买到的雷达模块大致分三类,各自适合不同阶段的验证需求。

我用一张表把关键差异列出来:

方案典型模块体制频率距离分辨能力相位灵敏度特点
A. 24GHz多普勒雷达HB100类多普勒24GHz无距离分辨中等便宜、接口简单、易受环境干扰
B. 24GHz FMCW雷达各类24GHz FMCW模块FMCW24GHz0.75m左右中等能测距离但不精细,入门可选
C. 60/77GHz FMCW雷达TI IWR1443、AWR1642、BGT60TR13CFMCW60/77GHz3.5到5cm高距离上能准确定位胸部,适合正式开发

如果是纯粹论证“雷达能不能测心跳”,买块HB100再配一个高增益放大电路,成本几十块钱就能拿到呼吸波形。但HB100是单通道多普勒输出,没有距离分辨能力,人周围任何轻微动作都会叠加进信号里,稍微动一下曲线就乱套。我的建议是:入门玩HB100可以,做正式验证直接用60GHz或77GHz的FMCW模块。别在24GHz多普勒模块上花太多时间,后期还得重新做距离门,绕远路。

2.2 从ADC到BPM的完整信号链路

无论用什么模块,生命体征检测的软件链路高度一致。全流程可以概括成:

ADC时域中频采样 → 单chirp加窗和距离FFT → 在目标距离范围内选bin → 按慢时间维提取该bin的复数值 → atan2提取相位 → 相位解卷绕 → 去趋势/高通滤波 → 呼吸带通滤波和心率带通滤波 → 频谱峰值检测 → 输出呼吸率和心率。

这里面每一步都有坑:加什么窗、目标bin怎么选、相位为什么需要解卷绕、带通滤波系数怎么来,我都会在第3章结合一个实际配置的雷达参数逐一说清楚。先记住一个结论:整条链路里,目标bin选择错误是新手最容易犯的错误——选错了,后面所有处理全白费。

  1. 实例参数计算与核心代码实现 3.1 先算雷达参数:一个可落地的配置示例

让我用一个77GHz FMCW雷达的实际参数做推演。这套参数我实测过多次,稳定性和复现性都不错。

假设chirp起点频率,带宽,chirp时长,ADC采样率。先算出chirp斜率:

然后算距离分辨率:

再看最大无模糊距离。中频信号频率不能超过ADC采样率,于是:

目标放在0.8m处,对应的中频频率为:

66.7kHz远低于4MHz采样率,完全没问题。单个chirp的采样点数为:

也就是说,每个chirp做320点距离FFT(实际常补零到512点),距离bin间隔约15cm。人的胸腔可以看成一个面目标,占1到2个距离bin,在0.8m附近选bin时,选0.75m或0.9m的bin都有可能,但相位提取基本不受影响。

慢时间维度上,我们让每帧包含256个chirp,chirp重复周期设为1ms。实际上ADC处理和网络传输会有额外延迟,但关键是慢时间采样率约1000Hz,远高于生命体征信号最高频率2.5Hz,不存在混叠风险。

这里必须提醒一个新手最容易理解错的地方:做完256点慢时间FFT,频率分辨率约3.9Hz,压根看不出0.2Hz的呼吸和1.2Hz的心跳。所以实际工程里不会拿单帧结果直接出数,而是把连续帧的相位序列拼接成30到60秒的滑动窗口,再做带通滤波和FFT。我常用的配置是30秒窗、每次滑动1秒更新一次输出,频率分辨率约0.033Hz,足够把呼吸和心跳峰分开了。

3.2 C++实现:距离FFT、相位提取与解卷绕

实际工程中,我在PC端用C++做离线验证,使用KissFFT库做FFT,ADC数据从雷达SDK的实时线程里落到本地文件。核心逻辑分三步。

第一步,对每个chirp做距离FFT,并划定0.2m到2m的搜索范围找目标bin。代码结构如下:

const int M = 320; // 单chirp采样点数 const int N = 256; // 单帧chirp数 std::vector<std::complex<float>> chirpData(M); // 省略:从ADC缓冲填充chirpData // 距离维加汉明窗,抑制旁瓣 applyHammingWindow(chirpData, M); fftRadix2(chirpData, M, false); // 0.2m到2m范围,按bin间隔0.15m换算为bin索引 int startBin = 2; int endBin = 13; int targetBin = startBin; float maxMag = 0.0f; for (int i = startBin; i <= endBin; i++) { float mag = std::abs(chirpData[i]); if (mag > maxMag) { maxMag = mag; targetBin = i; } }

这里的搜索范围限制很重要。如果你让程序在整个距离轴上找最大峰值,往往会把墙、桌子腿或者天花板的强反射选进来,相位序列全乱。

第二步,把连续chirp中目标bin的复值取出来,组成慢时间信号:

std::vector<std::complex<float>> slowTime(N); for (int i = 0; i < N; i++) { slowTime[i] = chirpFrames[i][targetBin]; } std::vector<float> phase(N); for (int i = 0; i < N; i++) { phase[i] = std::arg(slowTime[i]); } // 相位解卷绕 for (int i = 1; i < N; i++) { float delta = phase[i] - phase[i - 1]; if (delta > M_PI) { phase[i] -= 2.0f * M_PI; } else if (delta < -M_PI) { phase[i] += 2.0f * M_PI; } }

相位解卷绕的原因是atan2返回的相位范围被截断到[-π, π],而真实胸腔位移对应的相位可能跨越多圈。心跳位移0.2mm在77GHz下对应0.64rad相位变化,小于π,所以正常情况下不会跳变;但呼吸大幅运动、身体微调时,相邻chirp相位差可能超过π,不处理就会出现剧烈的锯齿跳。解卷绕之后,相位曲线就是连续、平滑的胸腔微动轨迹。

第三步,对解卷绕后的相位序列做工频趋势去除。最简单有效的方法是减去滑动均值。我用的窗口长度是2秒,也就是约2000个慢时间点。这一步能滤掉雷达硬件直流偏置和极低频漂移,让后续带通滤波器不至于被直流分量拽偏。

3.3 呼吸/心率分离滤波与频谱峰值检测

滤波部分,我直接用二阶巴特沃斯带通滤波器,一个通道负责0.1到0.5Hz,另一个负责0.8到2.5Hz。滤波器系数不需要手推,用Python的SciPy生成后写到C++数组里就行。C++侧用Direct Form II结构:

// 以心率带通为例,系数由scipy.signal.butter生成后填入 float b[3] = {0.0201f, 0.0f, -0.0201f}; float a[3] = {1.0f, -1.9391f, 0.9597f}; // 注意:这里的系数仅为示意,实际系数应与你选择的采样率和截止频率一致 float x1 = 0.f, x2 = 0.f, y1 = 0.f, y2 = 0.f; float bandpassFilter(float x) { float y = b[0] * x + b[1] * x1 + b[2] * x2 - a[1] * y1 - a[2] * y2; x2 = x1; x1 = x; y2 = y1; y1 = y; return y; }

滤波完成后,对30秒窗内的输出做FFT,在对应频段内取峰值:

// 心率频段峰值检测,假设FFT输出存放在heartSpec中 // 频率分辨率 = 1/windowLen = 1/30 ≈ 0.033Hz float maxAmp = 0.0f; int maxIdx = 0; for (int i = 24; i <= 75; i++) { // 0.8Hz到2.5Hz if (heartSpec[i] > maxAmp) { maxAmp = heartSpec[i]; maxIdx = i; } } float heartRateBpm = maxIdx * 0.033f * 60.0f;

呼吸检测和心跳检测结构完全一样,只是把频段搜索范围换成0.1到0.5Hz。实际项目里我还会在频域加一点平滑,不然峰旁边的毛刺会让数值跳来跳去。更聪明一点的做法是拿前几次检测结果做个中值滤波,这样输出就会稳定很多,不会突然蹦出一个完全不合常理的数值。

3.4 实测流程与结果判读

硬件调试这一步,强烈建议先把雷达摆到桌面上,用支架固定,让雷达天线面正对测试者胸口,距离控制在0.5到1米。测试者坐姿放松,正常呼吸,双手自然放在膝盖上,减少上身晃动。别站着测——站立时身体自带的微小平衡调整会污染微动信号。

首次验证时,我习惯把距离FFT幅度谱直接画出来,确认在0.5到1米范围内确实有一个稳定的峰。如果峰值一直跳,可能是多径反射导致,试着让测试者换个位置,或者稍微偏转雷达角度,让峰稳定下来再继续。

数据跑通后,在一个4秒片段里你就能看到:相位曲线的整体大波浪频率约0.2到0.3Hz,对应呼吸;波形上叠加的细小毛刺频率约1到1.3Hz,对应心跳。用30秒滑动窗做频谱时,呼吸峰和心跳峰应当互不重叠。对照真实心率,我用智能手环的心电显示做参考,误差稳定在±2bpm以内,这个精度在工程上完全够用。

  1. 常见问题与排查技巧实录 4.1 呼吸谐波为什么总来捣乱

呼吸波形并不是数学上的纯正弦波,而是类似上升慢、下降快的类三角波,这种波形天生带着高次谐波。谐波频率是呼吸基频的整数倍,比如呼吸频率0.25Hz,二次谐波0.5Hz,三次谐波0.75Hz,四次谐波1.0Hz,正好能落在心率频段里。所以你会看到心跳检测结果突然稳定在某个值,但那个值其实是呼吸的四次谐波,不是真正的心跳。

我处理这个问题有两个思路。第一个思路是在心率带通之前,先精确估计呼吸基频,然后在心率频段里把呼吸基频整数倍附近的频谱线剔除掉;第二个思路更工程化——用一致性校验。连续5次滑窗检测出的心率相差不超过3bpm,才对外输出;如果峰值在呼吸基频整数倍附近浮动,通过一致性校验的概率很低,自然被过滤掉。实测下来,第二种思路实现简单,维护成本低,推荐先做。

4.2 体动瞬间:相位跳变了怎么办

测试者打个喷嚏、伸手拿水杯、手机震了一下,紧凑的相位序列就会直接断掉。这个在曲线上非常明显:相位瞬间跳了几十rad,然后滤波结果出现一个形似“振铃”的衰减振荡,持续时间能有好几秒。如果不处理,这一大段心率输出全部失真。

我做了两件事。第一,在相位解卷绕之前,计算相邻慢时间点的相位差分绝对值,一旦连续3个点的差分都超过π/2,就认为发生了体动,把这30秒窗直接判定为无效;第二,在带通滤波后计算短时能量,如果短时能量突然超过正常水平5倍以上,同样判定为无效。在PC端处理时,把这些无效段标注出来不参与统计就行。如果你做的是实时系统,可以在这段检测到体动时暂时不更新输出,等相位序列重新稳定后再恢复。

4.3 目标距离bin选错:信号就在那里,程序却找不到

这是新手最容易卡壳的地方。测试者明明坐在0.8m处,范围FFT幅度谱上0.8m附近也有峰,但程序选中的却是1.05m处的墙。原因很简单:墙面反射面积大,目标恒定,幅度反而更高。所以我不建议直接全范围取最大峰,而是应该先限定搜索范围0.2到2m,然后在范围内选峰。如果雷达正好装在某些场景里,被测位置不是固定的,可以像跟踪算法一样用上一帧的目标位置做邻域搜索,每次只在上一帧bin附近±2个bin内找峰。这样既有连续性,又能避免范围外强反射的干扰。

另外一个细节是加窗。很多例程不做距离FFT加窗,旁瓣会把弱目标淹没掉。我通常在距离维加汉明窗,抑制旁瓣约40dB,成本很低,效果立竿见影。代价是主瓣会略微变宽,但对生命体征这种面目标来说无所谓。

4.4 电源和环境引起的低频漂移

雷达板对供电非常敏感,尤其是DCDC电源直接给雷达供电时,相位序列上能看到明显的低频波浪。这些波浪频率集中在0.05到0.2Hz,和呼吸频段有一部分重叠,会污染呼吸检测结果。排查方法很简单:用示波器看雷达供电引脚上的纹波,如果超过几十毫伏,就该考虑换线性稳压电源或者加LC滤波。另外,USB线靠近雷达天线也会导致频率牵引,测量时USB线尽量离天线远一点,雷达板用支架固定而不是用手扶着。

4.5 参考真值怎么选:别拿数脉搏当真理

最后这条心得特别想分享给刚接触生命体征检测的朋友。第一次实测时,我一边看着算法输出,一边拿手指按着脉搏数“1、2、3、4……”,结果算法值和手数出来的总是差5到10bpm。后来发现根本不是算法错了,而是数脉搏本身就是个不太靠谱的参考——球没停稳之前,人的心率一直在波动。建议买一块有ECG心电显示的运动手环,或者用医用血氧夹做参考真值,误差能控制在±1bpm以内。有了稳定参考值,你调算法参数的信心完全不一样。

现象常见原因排查方向
心率峰一直出不来带通范围太窄 / 呼吸谐波干扰放宽到0.6到3Hz,加谐波校验
相位曲线不断向上或向下飘未做解卷绕 / 慢时间存在直流偏置做解卷绕,加去趋势处理
距离FFT在目标处无峰距离增益不够 / 目标太近在盲区增大接收增益,目标放到0.3m以外
算法值每分钟跳好几次体动污染 / 频谱峰选择不连续加体动检测,输出前做中值滤波
呼吸波形频率显示过高低频漂移串入呼吸频段检查供电纹波,加0.05Hz高通
  1. 后续扩展:从单目标到4D雷达点云

这个单目标生命体征实例跑通之后,后续有两个明显扩展方向。一个是多人场景:一台雷达要同时检测多个人的呼吸和心率,就不能只取一个距离bin,而是要在距离FFT上做恒虚警检测,找出多个候选目标,再对每个目标独立走一遍相位提取流程,配合角度FFT区分不同方位的人。另一个方向是结合4D毫米波雷达的点云输出做目标跟踪——4D雷达能提供距离、方位、俯仰和速度信息,点云可以锁定人体躯干位置,再把微动提取限制在目标跟踪框内,抗干扰能力比单目标强得多。这两个方向我用实际数据验证了部分流程,后面会在系列第二、第三篇里详细写。

最后再分享一个我自己反复踩过的经验。做这类生命体征验证时,别急着写漂亮的滤波器和复杂的跟踪算法,先把“相位曲线”画出来,肉眼看一段时间。你看到一条平滑的大波浪,上面叠着细密的小纹理,那就说明信号链路全通了;如果看不到,那就回到距离FFT里检查目标bin是不是选对了。这一条检查完,后面所有算法的成功率都会高很多。这个系列会继续更新,欢迎带着你的雷达型号和测试数据来交流。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询