4、信号预处理:滑动平均滤波、中值滤波、去除基线漂移、工频干扰抑制

好,咱们进入正题。传感器采集回来的原始信号,说白了就是一堆「脏数据」。你想想看,手指稍微动一下,或者周围有电器干扰,波形立马就变形了。我刚开始做可穿戴项目时,就吃过这个亏——采集到的PPG信号看着挺漂亮,一算血氧饱和度,数值跳得像心电图。后来才明白,预处理这步没做好,后面全是白费功夫。

4.1 滑动平均滤波:最简单的平滑利器

滑动平均滤波,原理特别简单。就是取当前采样点前后N个点的平均值,替代当前点的值。我习惯叫它「移动平均」,说白了就是给信号做了一次局部平均。

为什么需要它?因为PPG信号里有很多高频噪声,比如环境光抖动、皮肤接触噪声。滑动平均能把这些毛刺抹平,让波形看起来更干净。

核心公式:

y[n] = (x[n-M] + x[n-M+1] + ... + x[n] + ... + x[n+M]) / (2M+1)

其中M是窗口半径,窗口长度为2M+1。

我在项目中遇到过一个问题:窗口大小怎么选?选太小了,噪声滤不干净;选太大了,信号细节被抹掉,波峰波谷都变平了。我个人习惯是,对于100Hz采样率的PPG信号,窗口长度取5~11比较合适。心率大概0.5~4Hz,窗口太大会把心率波形也滤掉。

实战建议:

  • 采样率100Hz时,窗口长度建议5~9
  • 采样率200Hz时,窗口长度建议9~15
  • 先试小窗口,不够再加大
// 滑动平均滤波示例代码(C语言)
#define WINDOW_SIZE 7
float buffer[WINDOW_SIZE];
int index = 0;

float moving_average(float new_sample) {
    float sum = 0;
    buffer[index] = new_sample;
    index = (index + 1) % WINDOW_SIZE;
    
    for (int i = 0; i < WINDOW_SIZE; i++) {
        sum += buffer[i];
    }
    return sum / WINDOW_SIZE;
}

4.2 中值滤波:对付脉冲噪声的利器

滑动平均滤波有个致命弱点——它对脉冲噪声(比如突然的尖峰)特别敏感。你想想看,一个尖峰被平均到周围点,虽然幅度降低了,但依然会影响波形。这时候中值滤波就派上用场了。

中值滤波的原理更简单:取窗口内所有点的中位数,替代当前点。说白了就是排序后取中间那个值。脉冲噪声在排序后会被挤到两端,根本影响不到中位数。

核心思想:

排序 → 取中间值 → 替代原值

我记得有一次调试血氧仪,发现波形上每隔几秒就出现一个尖峰。排查了半天,发现是手指轻微抖动造成的。用滑动平均滤不掉,改用中值滤波,窗口长度取5,尖峰瞬间消失。嗯,这里要注意:中值滤波的计算量比滑动平均大,因为要排序。在低功耗MCU上,窗口别太大,5~7就够了。

避坑指南:

我曾经在STM32上用过窗口长度15的中值滤波,结果每秒钟只能处理20个点,完全跟不上采样率。后来改成窗口长度5,速度就上来了。嵌入式设备上,计算量和内存占用都要精打细算。

// 中值滤波示例代码(C语言)
#define MEDIAN_WINDOW 5

int compare(const void *a, const void *b) {
    return (*(int*)a - *(int*)b);
}

float median_filter(float *window, int size) {
    float temp[size];
    memcpy(temp, window, size * sizeof(float));
    qsort(temp, size, sizeof(float), compare);
    return temp[size / 2];
}

4.3 去除基线漂移:让波形回到水平线

基线漂移是什么?就是信号的整体水平线在缓慢上下移动。比如你戴着设备深呼吸,或者手臂慢慢抬起,PPG信号的基线就会跟着漂。如果不处理,血氧饱和度的计算会出错——因为算法会把基线变化误判为信号变化。

去除基线漂移,我常用的方法有两种:

方法 原理 适用场景
高通滤波 设置截止频率0.5Hz,滤除低频漂移 实时处理,计算量小
多项式拟合 用多项式拟合基线,然后减去 离线分析,精度高

我个人习惯用高通滤波。因为简单、实时、不占内存。截止频率设在0.5Hz左右,因为心率最低也就0.5Hz(30次/分钟),再低的频率就是漂移了。一阶高通滤波器的代码很简单:

// 一阶高通滤波器,截止频率0.5Hz,采样率100Hz
float alpha = 0.969; // 根据公式计算得到
float prev_input = 0, prev_output = 0;

float highpass_filter(float input) {
    float output = alpha * prev_output + alpha * (input - prev_input);
    prev_input = input;
    prev_output = output;
    return output;
}

小技巧:

如果你发现高通滤波后波形幅度变小了,说明截止频率设得太高了。可以试着降低到0.3Hz。我一般会先看原始信号的频谱,找到漂移的频率范围,再决定截止频率。

4.4 工频干扰抑制:干掉50Hz/60Hz的嗡嗡声

工频干扰,说白了就是市电带来的噪声。在中国是50Hz,在美国是60Hz。你想想看,设备插着USB充电,或者靠近电源插座,PPG信号上就会叠加一个正弦波。这个频率刚好在心率和血氧计算的频带内,必须处理掉。

抑制工频干扰,我常用的方法有:

  • 陷波滤波器:专门干掉50Hz或60Hz,对其他频率影响小
  • 自适应滤波器:能自动跟踪工频频率的微小变化
  • 硬件屏蔽:在电路板上做好屏蔽和接地

在嵌入式设备上,我推荐用陷波滤波器。计算量小,效果稳定。二阶IIR陷波器的设计公式如下:

// 50Hz陷波滤波器,采样率100Hz,Q=30
float b0 = 0.989, b1 = -1.978, b2 = 0.989;
float a1 = -1.978, a2 = 0.978;
float x1 = 0, x2 = 0, y1 = 0, y2 = 0;

float notch_filter(float input) {
    float output = b0 * input + b1 * x1 + b2 * x2 - a1 * y1 - a2 * y2;
    x2 = x1;
    x1 = input;
    y2 = y1;
    y1 = output;
    return output;
}

重要提醒:

我曾经在项目中直接用50Hz陷波器,结果发现信号里还有49.5Hz的噪声。后来才知道,电网频率不是绝对稳定的,会有±0.5Hz的波动。所以建议用Q值低一点的陷波器(Q=20~30),带宽宽一些,能覆盖频率波动。

4.5 预处理流程总结

好了,四种方法都讲完了。在实际项目中,我不会一股脑全用上。而是根据信号质量,按顺序处理:

  1. 先中值滤波:干掉脉冲噪声(窗口长度5)
  2. 再滑动平均:平滑高频毛刺(窗口长度7)
  3. 然后高通滤波:去除基线漂移(截止频率0.5Hz)
  4. 最后陷波滤波:抑制工频干扰(50Hz或60Hz)

这个顺序是我反复试出来的。为什么中值滤波放前面?因为脉冲噪声如果不先处理,滑动平均会把它们扩散到周围点,反而更难去除。你想想看,是不是这个道理?

一句话总结:

信号预处理不是越复杂越好,而是越合适越好。先看波形,再选方法,最后调参数。我见过太多人一上来就上卡尔曼滤波,结果把信号滤没了。

下一章,咱们聊聊特征提取——怎么从干净的PPG信号里找到波峰波谷,计算心率和血氧饱和度。到时候你会发现,预处理做得好,后面全是顺风顺水。