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信号的特征点检测,那可是血氧计算的关键一步。