做可穿戴设备心率算法这么久,我最大的感受是:真正难的不是公式,而是手里没有一套完全可控的数据。真实PPG生物信号里混着运动伪迹、呼吸分量、传感器压力变化带来的基线漂移,算法算错了,你都分不清是模型问题还是数据本身太脏。所以我每次搭心率提取链路,都会先模拟一段PPG信号,用短时傅里叶变换(STFT)做时频分析和滤波,先把心率干净稳定地拉出来。这套流程跑通之后,再拿真实数据去验证,你才有底气判断问题是噪声环境还是算法缺陷。这篇就把信号怎么仿、STFT参数怎么调、掩膜怎么设计、心率怎么算,一条线全摊开讲,适合刚开始接触PPG心率算法,或者正被真实数据折磨的同学参考。
1. 为什么先搭一套仿真PPG再谈心率提取
1.1 真实PPG数据看着简单,用起来全是坑
直接上真实PPG数据做算法,第一反应是“波形挺有规律的”。但你真正处理起来会发现,血压计的袖带晃一下、手表的表带松一格、手腕皮肤出汗,波形就能变一个样。光电容积脉搏波传感器对光的吸收极其敏感,肤色、毛发、汗液、温度都会叠加进去,而这些东西根本不在你的算法模型里。
更麻烦的是没有真值。你拿到一段60秒的真实PPG,想提心率,你知道答案是多少吗?除非当时同步戴着一套心电图参考设备,不然你算出一个72bpm,根本无法确认它是准还是不准。我见过不少同学卡在这里:算法在仿真数据上表现很好,一换真实数据就乱跳,然后开始疯狂调阈值、调滤波器的截止频率,最后一晚上过去,也没搞清楚到底是数据问题还是代码问题。
所以我的习惯是:仿真先行。先用已知心率生成PPG信号,验证STFT滤波链路每一个环节的输出都符合预期,再上真实数据。这样任何一步出了问题,你都有一个没有歧义的基准能对照。
1.2 PPG信号怎么来,为什么能反映心率
PPG全称光电容积脉搏波描记,原理说穿了并不复杂:心脏每次泵血时,血管里的血容量会周期性变化,而组织对光的吸收能力跟血容量直接相关。传感器一侧发光,另一侧接收透射光或反射光,光强就会跟着心跳周期波动,把这束光强信号转成电信号,就是PPG波形。
典型PPG波形有一个快速上升的主波,对应心脏收缩泵血,随后是降中峡和重搏波,对应动脉瓣关闭和血液回流。虽然波形形态上有这些细节,但从频域看,它的本质是一个以心率为基频的周期性信号。正常静息心率60次/分钟,基频就是1Hz左右,波形里的重搏波、谐波成分大多落在基频的整数倍上。所以心率提取的核心任务,说到底是找出这个信号里最强的周期性频率成分,然后乘60换算成bpm。
这也解释了为什么模拟PPG可以用谐波叠加:因为只要你把基频、二次谐波、三次谐波按一定幅度和相位叠加起来,频谱结构和真实PPG已经非常接近了,完全够用来验证STFT滤波的每一个环节。
1.3 三种仿真建模思路,选哪种
我自己试过三种PPG仿真方式,各有各的适用场景。
第一种是谐波叠加法,用正弦函数在心率基频及其倍频上叠加。优点是频率成分完全可控、代码量极少、适合验证频域算法;缺点是时域波形跟真实PPG差距大,主波和重搏波的形态不够精细。
第二种是高斯脉冲序列法,在每个心动周期放一个高斯形状的脉冲,脉冲宽度模拟心脏泵血的时相。这种波形看起来比正弦更像PPG,而且可以通过调节脉冲宽度来控制谐波的衰减速度,适合做时域峰值检测类的验证。
第三种是物理光学仿真,通过建立皮肤、血管、光的相互作用模型生成信号。精度最高但计算量大、参数多,做心率算法开发一般都杀鸡用牛刀。
做STFT滤波验证,我用的是第一种,再额外叠加上呼吸分量、低频基线漂移、高斯白噪声。很多同学仿真时只生成一个干净正弦波,滤波结果自然很漂亮,但真实场景里呼吸的起伏大约在0.2到0.4Hz,基线漂移在0.1Hz以下,还有传感器本身的白噪声,这些背景成分不加上,验证出来的算法到了真实数据上会立刻原形毕露。
2. 短时傅里叶变换对PPG滤波的三个关键优势
2.1 FFT看全局,STFT看局部:非平稳信号的心率提取必须时频分析
单纯用FFT做PPG频谱分析,最大的问题是它只有一个时间段的整体统计。比如采集了60秒信号,这60秒里心率从72bpm缓慢降到了65bpm,FFT算出来只是一个宽带后的峰值,你可能得到67bpm,但完全看不出心率在变化。
心率本质上是非平稳信号。人在说话、呼吸、姿势调整时,心率会连续波动;运动时还会在几秒内从80bpm冲上150bpm。FFT那种“全局平均”的思路,天生不适合跟踪这种变化。而STFT做的事情很简单:把信号切成一段一段,对每一小段加窗后做FFT,然后把这些短时频谱按时间拼成一个二维矩阵。横轴是时间,纵轴是频率,颜色深浅是能量强度,你会看到一条随时间变化的能量亮带,那就是心率基频的轨迹。
类比一下:FFT相当于你把整场电影的所有帧亮度做一个平均,只能知道电影总体是亮还是暗;STFT相当于按帧播放,你能看到每一帧里哪个人在哪、灯是什么颜色。心率提取要的是后者,因为心率本身随时在变。
2.2 窗长、窗型、重叠率:这三个参数决定滤波成败
STFT的参数选择直接决定滤波效果,这是我踩了最多坑的地方。先说频率分辨率,公式是:
Δf = fs / N
fs是采样率,N是每一帧FFT的点数。假设PPG信号采样率100Hz,窗口取1024点,那么频率分辨率就是100/1024≈0.098Hz。心率是频率乘60,所以对应的心率分辨率就是0.098×60≈5.9bpm。也就是说,这个配置下你理论上能分辨72bpm和78bpm的心率。想要更高分辨率,就把N加大,比如2048点,分辨率能到0.049Hz,对应2.9bpm,但窗口时间从10.24秒拉长到20.48秒,时间分辨率变差,心率快速变化时会看不清。
窗型同样关键。矩形窗对信号没有幅度加权,但它旁瓣非常高,频谱泄漏严重,会让一个本来很干净的1.2Hz峰值周围全是杂散能量,时频图上看起来就是一条粗粗的糊带。汉宁窗的旁瓣明显更低,虽然主瓣稍宽一点,但整体频谱干净很多。做PPG滤波我一般默认汉宁窗。
重叠率解决的是帧与帧之间的连贯性。相邻两帧如果完全不重叠,心率轨迹会出现不自然的阶梯感。75%重叠意味着每帧移动N/4个采样点,帧际平滑性会好很多,STFT矩阵的时间分辨率也更密。
2.3 先STFT滤波再做峰值检测:为什么顺序不能反
有人问我,能不能直接用带通滤波器滤完,然后再找峰值?当然可以,但实际调试中,你在时域做滤波时很难直观看到“我滤掉的是什么”。而STFT掩膜滤波相当于构造了一个时变带通滤波器:你在时频图上画一个掩膜,把心率频带以外的所有时频点置零,再做逆变换重建时域信号。这个过程的好处是,你每一步都能看到时频图上的效果。
真实PPG里,二次谐波有时候比基频还亮,运动伪迹会在某个时间段突然涌进宽频段。如果你直接在时域做固定带通滤波,一个频率带从头到尾生效,遇到伪迹时段往往无能为力。而STFT掩膜可以做成时变的,比如某几帧伪迹很重,可以把掩膜在那些时间点压得更窄。它本质上是一种“看你需要保留什么”的透明滤波,比闭着眼睛调IIR滤波器系数直观得多。
3. 从仿真信号到心率数值的完整实操流程
3.1 搭好仿真信号:把噪声加足,别太小
这是完整的仿真信号生成代码,我用的是Python的numpy和scipy。
import numpy as np from scipy import signal import matplotlib.pyplot as plt fs = 100 # 采样率,100Hz duration = 60 # 60秒信号 t = np.arange(duration * fs) / fs # 心率基频,设为1.2Hz,也就是72bpm f_hr = 1.2 # 谐波叠加主体:基波 + 二次谐波 + 三次谐波 ppg = ( 1.0 * np.sin(2 * np.pi * f_hr * t) + 0.4 * np.sin(2 * np.pi * 2 * f_hr * t + 0.5) + 0.2 * np.sin(2 * np.pi * 3 * f_hr * t + 1.2) ) # 呼吸分量,通常0.2~0.4Hz f_resp = 0.25 resp = 0.15 * np.sin(2 * np.pi * f_resp * t) # 基线漂移,模拟传感器低频干扰 baseline = 0.3 * np.sin(2 * np.pi * 0.05 * t) # 高斯白噪声 rng = np.random.default_rng(42) noise = 0.1 * rng.standard_normal(t.size) ppg_noisy = ppg + resp + baseline + noise为什么噪声幅度选0.1而不是0.01?因为真实PPG传感器信号的信噪比并不高。0.1的噪声幅度会让波形看起来已经开始毛糙,但还不至于淹没心率基频,这是最贴近实际调试场景的强度。基线漂移幅度0.3比心率波形还大,低频段在频谱上会非常醒目,这样刚好检验STFT滤波能不能把它挡住。
3.2 STFT时频分析:先看谱,再动手
对仿真信号做STFT,我用scipy封装好的函数。
f, t_stft, Zxx = signal.stft( ppg_noisy, fs=fs, window='hann', nperseg=1024, noverlap=896 ) power = np.abs(Zxx) # 只画0~5Hz,呼吸和心率能量集中在这个范围 plt.figure(figsize=(12, 4)) plt.pcolormesh(t_stft, f, power, shading='gouraud', cmap='jet') plt.ylim(0, 5) plt.colorbar(label='Magnitude') plt.xlabel('Time [s]') plt.ylabel('Frequency [Hz]') plt.title('STFT of Simulated PPG') plt.show()我在这段配置下,窗口点数1024,重叠896,帧移是128个采样点,相当于时间分辨率约1.28秒。频率分辨率约0.098Hz,既能看到心率基频的细微变化,又不会丢太多时间细节。
时频图上你应该能清楚看到:0附近有一条很亮的低频横带,那是基线漂移;0.25Hz附近有另一条间或可见的细带,那是呼吸分量;1.2Hz附近是心率基频亮带,而且它在整个60秒里保持稳定,2.4Hz和3.6Hz处有两条暗一些的带,那是二次和三次谐波。整个过程清晰直观。
3.3 掩膜滤波与ISTFT重建:怎么只留心率成分
现在做核心的STFT域滤波。目标是把0.9到1.9Hz以外的所有时频成分压掉,这个频带对应心率54到114bpm。
fmin, fmax = 0.9, 1.9 half_width = 0.1 mask = np.ones_like(Zxx, dtype=float) mask[f < fmin - half_width, :] = 0 mask[f > fmax + half_width, :] = 0 # 边缘斜坡,避免硬截止引起振铃 in_low = (f >= fmin - half_width) & (f < fmin) in_high = (f > fmax) & (f <= fmax + half_width) mask[in_low, :] = (f[in_low, np.newaxis] - (fmin - half_width)) / (2 * half_width) mask[in_high, :] = (1 - (f[in_high, np.newaxis] - fmax) / (2 * half_width)) _, ppg_filtered = signal.istft(Zxx * mask, fs=fs)掩膜边缘为什么要做斜坡而不是直接置零?如果你在频域用一个矩形窗硬切,相当于给频谱乘了一个矩形函数,对应到时域会产生振铃,重建出来的信号在波形首尾会出现可观的Gibbs现象。用0.1Hz宽的线性过渡带,重建信号会平滑得多。
逆变换之后,ppg_filtered里的波形会变得非常干净:低频漂移没了,呼吸分量没了,高频噪声也没了,剩下的几乎是1.2Hz附近一个标准的正弦信号,叠加了少量二次谐波成分。这条信号的包络能直观反映心率搏动节奏。
3.4 两种心率计算法:频谱峰值法和时域峰间隔法
滤波之后有两种方式把心率算出来,我都推荐试一下,因为它们在真实场景下有不同的用处。
方法一,在STFT谱上找峰值。对每一帧的时间切片,在0.9到1.9Hz频带里找幅度最大的频率点,这个频率就是该帧的瞬时心率频率。
idx_band = (f >= fmin) & (f <= fmax) peak_bin = np.argmax(power[idx_band, :], axis=0) hr_track = f[idx_band][peak_bin] * 60 # 用中值滤波压掉毛刺 hr_median = signal.medfilt(hr_track, kernel_size=7) print(f"Estimated HR: {np.median(hr_median):.1f} bpm")方法二,对ISTFT重建后的时域信号做峰值检测。
peaks, _ = signal.find_peaks( ppg_filtered, distance=int(fs * 60 / 200), # 200bpm对应最小间隔 height=0.3 ) hr_beat = 60 / np.diff(t[peaks]) print(f"Beat-to-beat HR: {np.median(hr_beat):.1f} bpm")两种方式各有特点。谱峰值法在心率变化连续时很稳,适合看心率随时间的宏观趋势。时域峰间隔法更精细,能得到每一次心跳的间隔,用来算心率变异性(HRV)最合适。我的习惯是两个都跑,如果结果相差超过1bpm,就去查滤波链路有没有问题。
4. 常见问题与排查技巧实录
4.1 时频图上心率带“糊”成一片
新上手最容易踩的坑,是用矩形窗做STFT。我一开始就是图省事,window默认起了个矩形窗,结果时频图上1.2Hz附近的主峰两侧全是旁瓣泄漏,看起来不是一条细线,而是一条宽宽的亮带。你从谱上找峰值,每帧找出来的频率都差好几Hz,心率跟踪结果一跳一跳的。
解决方法是把窗换成汉宁窗,绝大多数情况下心率带变细立竿见影。如果换完还是糊,检查nperseg是不是太小。比如nperseg=256,频率分辨率是0.39Hz,对应心率精度约23bpm,那你也就能区分45bpm和68bpm左右,根本没法精确提取。把nperseg拉到1024以上,情况立刻好很多。
4.2 二次谐波被当心率
模拟PPG里,二次谐波幅度是基波的一半,如果掩膜频带设到2.4Hz及以上,2.4Hz这条谱线很容易被选成每帧峰值,心率会算成144bpm,正好是真实心率的2倍。
排查的时候先看时频图:如果亮带在1.2Hz和2.4Hz各有一条,而你算出的心率稳定在144bpm,那基本就是谐波误选。解决办法是收窄掩膜频带,让上边截止频率低于二次谐波。比如把fmax设为1.9Hz,二次谐波2.4Hz被全部滤掉。这也是为什么我前面频带取0.9到1.9而不是0到5Hz的原因。
4.3 低频呼吸分量和基线漂移影响
呼吸分量0.25Hz,基线漂移0.05Hz,这些低频成分幅度不小,直接做时域峰值检测时,波形上的鼓包和下沉可能让检测器把噪声峰当成心跳峰,或漏检掉真实的心跳峰。
STFT滤波对解决这个问题非常有效:只要把掩膜的fmin抬到0.9Hz以上,所有低频成分都会消失干净。但要注意一个边界场景:如果测试者的心率极低,比如50bpm对应0.83Hz,而你把下限设成0.9Hz,就会把真实心率也滤掉。这种时候把fmin往下调到0.7Hz,不要机械沿用通用参数。
4.4 参数速查表
下面是我在实际项目中不同场景下常用的STFT配置,直接抄作业也问题不大。
| 应用场景 | 采样率 | nperseg | noverlap | 窗函数 | 频率带 |
|---|---|---|---|---|---|
| 离线心率复核 | 100Hz | 2048 | 1792 | Hann | 0.7~2.5Hz |
| 常规心率提取 | 100Hz | 1024 | 896 | Hann | 0.9~1.9Hz |
| 运动伪迹较多 | 100Hz | 512 | 384 | Hamming | 1.0~3.0Hz |
| 实时低延迟估算 | 100Hz | 256 | 192 | Hann | 0.8~2.0Hz |
运动场景把通带上限拉到3.0Hz,对应180bpm,是因为运动心率会冲到160甚至180,带外衰减太早会限制测算范围。实时低延迟场景窗口短,分辨率低,心率数值可以再配合抛物线插值提高精度。
4.5 调试顺序与个人心得
最后分享一个我自己调试这套流程的顺序,希望能让你少走弯路。
第一步永远是把原始信号的时频图画出来,肉眼看清楚哪些是心率带、哪些是噪声再谈滤波。第二步先把掩膜频带设宽一点,确认逆变换之后波形还在,再把频带慢慢收窄去找最优边界。第三步跑心率计算之前,先确认时频图亮带和滤波后波形峰值节奏对得上,这一步能过滤掉80%的调试烦恼。
还有一个细节是ISTFT重建信号的首尾几十个采样点通常不可靠,因为加窗重建在边界处数据不足。做峰值检测时,我习惯把前0.5秒和后0.5秒的数据直接裁掉,避免边缘效应对心率统计造成干扰。
还有一点想多说一句:仿真信号虽然是正弦叠加出来的,和真实PPG形态不完全一致,但它是验证STFT链路是否正确的有效基准。只要仿真这一步不动摇,换到真实数据上遇到问题时,你至少能确定问题出在“传感器数据比仿真信号复杂得多”这一层,而不是算法框架本身。