4、数据预处理之去噪:移动平均滤波、中值滤波、高斯滤波、卡尔曼滤波入门

各位同学,欢迎来到第四讲。

传感器数据,说白了就是一堆带毛刺的原始信号。你拿到的数据,十有八九是「脏」的。噪声、尖峰、漂移,什么妖魔鬼怪都有。我刚开始做嵌入式项目时,就吃过这个亏——传感器读数直接拿去用,结果控制逻辑乱跳,电机抖得像筛糠。

所以,去噪是数据预处理的第一道关。今天咱们就聊聊四种最常用的滤波方法:移动平均、中值、高斯,还有卡尔曼。别被名字吓到,其实都不难。

4.1 移动平均滤波:最简单,也最常用

移动平均滤波,思路很直白:取最近 N 个采样点的平均值,作为当前输出。

举个例子,你测温度,每秒读一次。最近5秒的读数是 [25.1, 25.3, 25.0, 25.2, 25.4],那当前输出就是它们的平均值 25.2。每来一个新数据,就踢掉最老的那个,重新算一遍。

核心公式:
y[n] = (x[n] + x[n-1] + ... + x[n-N+1]) / N

代码实现也很简单:

// 移动平均滤波,窗口大小 N=5
#define N 5
float buffer[N] = {0};
int index = 0;

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

嗯,这里要注意:窗口 N 越大,平滑效果越好,但延迟也越大。我做过一个手势识别项目,用了 N=10 的移动平均,结果手都挥完了,输出才跟上。后来改成 N=4,实时性就好了很多。

我的经验: 对于采样率 100Hz 以下的传感器,N 取 3~5 比较稳妥。采样率越高,N 可以适当增大。

4.2 中值滤波:专治「野值」

移动平均有个致命弱点——它对所有数据一视同仁。如果某个采样点突然跳出一个离谱的值(比如温度传感器被手摸了一下),平均值会被严重拉偏。

中值滤波就是来治这个毛病的。它取窗口内所有数据的中位数,而不是平均值。你想想看,就算混进来一个 100 度的异常值,只要其他数据正常,中位数基本不受影响。

// 中值滤波,窗口大小 N=5
#define N 5
float buffer[N] = {0};
int index = 0;

float median_filter(float new_sample) {
    float temp[N];
    buffer[index] = new_sample;
    index = (index + 1) % N;
    
    // 拷贝并排序
    for (int i = 0; i < N; i++) temp[i] = buffer[i];
    // 冒泡排序(简单示意,实际可用更高效算法)
    for (int i = 0; i < N-1; i++) {
        for (int j = 0; j < N-i-1; j++) {
            if (temp[j] > temp[j+1]) {
                float t = temp[j];
                temp[j] = temp[j+1];
                temp[j+1] = t;
            }
        }
    }
    return temp[N/2];  // 取中位数
}

我曾经在一个超声波测距项目里,遇到强烈的电机干扰,每隔几十个点就蹦出一个 0 值。移动平均根本压不住,换成中值滤波后,数据瞬间干净了。说白了,中值滤波就是「去噪界的清道夫」。

注意: 中值滤波对高斯噪声(随机小波动)效果一般,它更适合处理脉冲噪声(突然的尖峰)。另外,排序操作比较耗时,在低算力 MCU 上要注意窗口大小。

4.3 高斯滤波:更平滑,更自然

移动平均给每个数据点相同的权重,这其实不太合理。离当前时刻越近的数据,应该越重要才对。高斯滤波就是基于这个想法——它用高斯函数给数据分配权重,越近的权重越大。

说白了,高斯滤波是「加权移动平均」的一种特殊形式。权重系数由高斯分布决定:

权重公式:
w[i] = exp(-i² / (2σ²))
其中 i 是距离当前点的偏移量,σ 控制平滑程度。

代码实现:

// 高斯滤波,窗口大小 N=5,sigma=1.0
#define N 5
float kernel[N] = {0.061, 0.242, 0.383, 0.242, 0.061};  // 预计算的高斯权重
float buffer[N] = {0};
int index = 0;

float gaussian_filter(float new_sample) {
    buffer[index] = new_sample;
    index = (index + 1) % N;
    
    float result = 0;
    int pos = index;
    for (int i = 0; i < N; i++) {
        result += buffer[pos] * kernel[i];
        pos = (pos + 1) % N;
    }
    return result;
}

我个人的习惯是,在需要保留信号边缘特征时(比如加速度计检测步数),用高斯滤波比移动平均好很多。因为高斯滤波的权重分布更自然,不会把陡峭的上升沿磨得太平。

小技巧: σ 越大,平滑效果越强,但边缘保留越差。σ=1.0 是个不错的起点。你可以把权重系数提前算好存成数组,省去每次计算的开销。

4.4 卡尔曼滤波入门:状态估计的「黄金标准」

前面三种滤波,本质上都是「数据平滑」。但卡尔曼滤波不一样——它做的是「状态估计」。什么意思呢?它不只考虑当前观测值,还结合了系统的运动模型,来预测下一个状态。

举个例子,你用一个噪声很大的超声波传感器测距离。卡尔曼滤波会这样工作:

  1. 预测: 根据上一时刻的距离和速度,估算当前距离应该是多少。
  2. 更新: 把预测值和实际测量值加权平均,得到最优估计。

这个「加权」的权重,就是卡尔曼增益。它会根据噪声大小自动调整——测量噪声大,就多相信预测值;预测噪声大,就多相信测量值。

核心公式(一维情况):

// 一维卡尔曼滤波
typedef struct {
    float x;      // 状态估计值
    float P;      // 估计误差协方差
    float Q;      // 过程噪声协方差
    float R;      // 测量噪声协方差
} KalmanFilter;

void kalman_init(KalmanFilter *kf, float init_x, float init_P, float Q, float R) {
    kf->x = init_x;
    kf->P = init_P;
    kf->Q = Q;
    kf->R = R;
}

float kalman_update(KalmanFilter *kf, float measurement) {
    // 预测
    float P_pred = kf->P + kf->Q;
    
    // 卡尔曼增益
    float K = P_pred / (P_pred + kf->R);
    
    // 更新
    kf->x = kf->x + K * (measurement - kf->x);
    kf->P = (1 - K) * P_pred;
    
    return kf->x;
}

说实话,卡尔曼滤波刚接触时有点绕。我记得第一次调参,Q 和 R 设得不对,滤波结果比原始数据还乱。后来才明白,Q 和 R 的比值决定了滤波器的「性格」:

参数关系 效果 适用场景
Q 大,R 小 更相信测量值,响应快,但噪声多 传感器精度高,系统变化快
Q 小,R 大 更相信预测值,平滑好,但延迟大 传感器噪声大,系统变化慢
Q ≈ R 折中,兼顾平滑和响应 大多数通用场景
避坑指南: 我曾经在 STM32F103 上跑卡尔曼滤波,忘了初始化 P 值,结果前几十个点全是错的。P 的初始值一般设成 R 的 10 倍左右,让滤波器先「学习」一段时间。

4.5 怎么选?一张表说清楚

滤波方法 优点 缺点 典型场景
移动平均 简单、计算快 延迟大、对野值敏感 温度、湿度等慢变信号
中值滤波 抗脉冲噪声强 对高斯噪声效果一般 超声波、红外测距
高斯滤波 平滑自然、边缘保留好 需要预计算权重 加速度计、陀螺仪
卡尔曼滤波 最优估计、自适应 调参复杂、计算量大 GPS+IMU融合、目标跟踪

最后说一句:没有万能的滤波器。我一般会先在 PC 上用 Python 快速验证,确定哪种方法合适,再移植到嵌入式平台。毕竟 MCU 的资源有限,能省则省。

好了,这一讲就到这里。下一讲咱们聊聊「特征提取」——从干净的数据里挖出真正有用的信息。


公众号:蓝海资料掘金营,微信deep3321