4、PPG信号预处理:带通滤波器设计(巴特沃斯、切比雪夫)、自适应滤波(LMS、RLS)
好,咱们接着聊PPG信号的预处理。上一节我们讲了去噪的基本思路,这一节我重点说说滤波器设计。说实话,在血氧仪这个领域,滤波器选型直接决定了你后面算出的血氧值准不准。我自己踩过不少坑,今天把这些经验都抖出来。
4.1 为什么需要带通滤波器?
PPG信号里,真正有用的信息集中在0.5Hz到10Hz之间。你想想看,人的心率一般在60到100次/分钟,换算过来就是1Hz到1.67Hz左右。但实际采集时,呼吸运动会引入0.2Hz左右的低频干扰,而工频噪声和肌肉抖动会带来高频噪声。
所以,带通滤波器的任务很明确:把0.5Hz以下和10Hz以上的成分统统干掉。我刚开始做的时候,觉得滤波器随便选一个就行,结果发现不同滤波器对信号相位的影响差别很大,直接导致后面特征点检测出错。
核心要点:PPG信号的带通滤波通常选择通带范围0.5Hz~10Hz,过渡带宽度根据采样率调整,阻带衰减至少40dB以上。
4.2 巴特沃斯滤波器设计
巴特沃斯滤波器最大的特点就是通带内最平坦。什么意思呢?就是在通带范围内,幅频响应几乎没有波动。这对于PPG信号来说很友好,因为你不希望不同频率的心跳分量被不均匀地放大或衰减。
我个人习惯用巴特沃斯做离线分析。它的缺点是过渡带比较宽,阶数低了效果不好,阶数高了计算量大。嗯,这里要注意,嵌入式系统里阶数一般不超过4阶,否则实时性扛不住。
下面是一个4阶巴特沃斯带通滤波器的设计示例(采样率100Hz):
import numpy as np
from scipy.signal import butter, filtfilt
def butter_bandpass(lowcut, highcut, fs, order=4):
nyq = 0.5 * fs
low = lowcut / nyq
high = highcut / nyq
b, a = butter(order, [low, high], btype='band')
return b, a
fs = 100.0
lowcut = 0.5
highcut = 10.0
b, a = butter_bandpass(lowcut, highcut, fs, order=4)
# 使用filtfilt实现零相位滤波
filtered_signal = filtfilt(b, a, raw_ppg)
这里我用的是filtfilt而不是lfilter。为什么?因为filtfilt是双向滤波,能消除相位偏移。我在项目中遇到过,用lfilter处理后的PPG信号,波峰位置偏移了好几个采样点,导致心率计算偏差了3次/分钟。这个教训挺深刻的。
小技巧:如果实时性要求高,可以用lfilter配合初始条件处理,或者改用IIR滤波器直接实现。但离线分析时,filtfilt是首选。
4.3 切比雪夫滤波器设计
切比雪夫滤波器分两种:I型(通带内等波纹)和II型(阻带内等波纹)。对于PPG信号,我更常用切比雪夫I型。它的优点是过渡带更陡,同样的阶数下,比巴特沃斯能更干净地滤除噪声。
但代价是什么?通带内有波纹。你想想看,如果波纹幅度太大,PPG信号的波形会失真,尤其是波峰和波谷的幅度会变得不均匀。我建议通带波纹控制在0.5dB以内,这样对后续的特征提取影响较小。
来看一个切比雪夫I型的设计:
from scipy.signal import cheby1
def cheby_bandpass(lowcut, highcut, fs, order=4, rp=0.5):
nyq = 0.5 * fs
low = lowcut / nyq
high = highcut / nyq
b, a = cheby1(order, rp, [low, high], btype='band')
return b, a
b, a = cheby_bandpass(lowcut, highcut, fs, order=4, rp=0.5)
filtered_signal = filtfilt(b, a, raw_ppg)
我曾经在一个低功耗项目中,因为处理器资源有限,只能用3阶切比雪夫。当时担心波纹会影响血氧计算,实测后发现只要波纹控制在0.3dB以内,效果和4阶巴特沃斯差不多。所以,选型没有绝对的好坏,关键看你的硬件资源。
| 滤波器类型 | 通带平坦度 | 过渡带陡峭度 | 推荐阶数 | 适用场景 |
|---|---|---|---|---|
| 巴特沃斯 | 最平坦 | 较缓 | 4~6阶 | 离线分析、波形保真要求高 |
| 切比雪夫I型 | 有波纹(可控) | 较陡 | 3~4阶 | 资源受限、实时处理 |
| 切比雪夫II型 | 通带平坦 | 阻带波纹 | 4~5阶 | 阻带衰减要求高 |
4.4 自适应滤波:LMS算法
固定系数的滤波器有个硬伤:它不知道噪声的统计特性在变化。比如运动伪迹,它的频率和幅度随时在变,固定滤波器根本招架不住。这时候就得请出自适应滤波了。
LMS(最小均方)算法是最经典的自适应滤波方法。它的核心思想很简单:根据误差信号不断调整滤波器系数,让输出尽量接近期望信号。
在PPG处理中,我通常把加速度计信号作为参考输入,把含噪PPG作为主输入。LMS算法会自适应地消除与加速度相关的运动噪声。
def lms_filter(primary, reference, mu=0.01, order=32):
n = len(primary)
w = np.zeros(order)
output = np.zeros(n)
error = np.zeros(n)
for i in range(order, n):
ref_block = reference[i-order:i][::-1]
output[i] = np.dot(w, ref_block)
error[i] = primary[i] - output[i]
w = w + 2 * mu * error[i] * ref_block
return error, w
这里mu是步长因子,取值很关键。我刚开始用的时候,把mu设成0.1,结果滤波器直接发散,输出全是乱码。后来我总结了一个经验:步长一般取0.001到0.01之间,具体要看信号的功率。你可以先算一下参考信号的功率,再反推合适的步长。
避坑指南:我曾经在一个腕式血氧仪项目里,LMS滤波器的阶数设成了64阶,结果MCU跑不动,每秒钟只能处理20个点。后来降到16阶,效果虽然差一点,但实时性达标了。记住,嵌入式系统里,阶数和步长都要根据实际算力来调。
4.5 自适应滤波:RLS算法
RLS(递归最小二乘)算法比LMS收敛快得多。说白了,LMS是慢慢调整,RLS是快速逼近。在PPG信号处理中,如果运动伪迹变化剧烈,LMS可能跟不上,RLS就能派上用场。
但RLS的代价是计算量大。每次迭代都要更新一个协方差矩阵,复杂度是O(N²),而LMS只有O(N)。所以,RLS适合在性能较强的处理器上使用,比如ARM Cortex-M4以上的芯片。
来看一个RLS的实现片段:
def rls_filter(primary, reference, lam=0.99, order=16):
n = len(primary)
delta = 0.1
w = np.zeros(order)
P = np.eye(order) / delta
output = np.zeros(n)
error = np.zeros(n)
for i in range(order, n):
ref_block = reference[i-order:i][::-1]
pi = np.dot(P, ref_block)
k = pi / (lam + np.dot(ref_block, pi))
output[i] = np.dot(w, ref_block)
error[i] = primary[i] - output[i]
w = w + k * error[i]
P = (P - np.outer(k, pi)) / lam
return error, w
这里lam是遗忘因子,一般取0.98到0.999之间。值越接近1,算法对历史数据的记忆越长,收敛越慢但更稳定。我习惯取0.99,在大多数PPG场景下表现都不错。
你可能会问:LMS和RLS到底选哪个?我的建议是:先试LMS,如果收敛速度不够再换RLS。因为LMS实现简单,调试方便,而且大部分运动伪迹场景下,LMS配合合适的步长已经够用了。
总结一下:固定滤波器(巴特沃斯、切比雪夫)负责去除固定频段的噪声,自适应滤波器(LMS、RLS)负责对付时变的运动伪迹。两者配合使用,才能把PPG信号处理干净。我个人习惯的流程是:先做带通滤波,再做自适应滤波,最后做基线漂移校正。这个顺序不要搞反了,否则效果会大打折扣。
好了,这一节的内容就到这里。下一节我们聊聊PPG信号的特征点检测,那可是血氧计算的关键一步。