3、信号预处理:滑动平均滤波、中值滤波、卡尔曼滤波入门
各位同学,欢迎来到信号预处理这一讲。
说实话,传感器采集回来的原始数据,基本没法直接用。为什么?因为噪声太严重了。你想想看,一个光敏传感器,哪怕环境光纹丝不动,它输出的数值也会上下跳动。这就是噪声,来自电源纹波、热噪声、量化误差等等。
我刚开始做智能灯具项目时,就吃过这个亏。传感器数据直接拿来控制PWM调光,结果灯光像呼吸一样忽明忽暗,用户投诉说“这灯是不是坏了”。从那以后,我养成了一个习惯:任何传感器数据,必须先过预处理这一关。
3.1 滑动平均滤波:最简单也最常用
滑动平均滤波的原理,说白了就是取最近N个采样值的平均值。每来一个新数据,就丢掉最老的那个,保持窗口长度不变。
为什么它有效?因为噪声通常是高频的、随机的,而真实信号变化相对缓慢。平均一下,噪声就被“抹平”了。
核心公式:
y[n] = (x[n] + x[n-1] + ... + x[n-N+1]) / N
这里N是窗口长度。N越大,滤波效果越平滑,但延迟也越大。我个人习惯,对于光照传感器,N取4到8就够用了。如果用在人体红外传感器上,N可以取大一点,比如16,因为人体动作本身就不需要那么快响应。
看代码实现:
// 滑动平均滤波器
#define WINDOW_SIZE 8
typedef struct {
float buffer[WINDOW_SIZE];
uint8_t index;
float sum;
} MovingAverage;
float moving_average_filter(MovingAverage* ma, float new_sample) {
// 减去最老的数据
ma->sum -= ma->buffer[ma->index];
// 存入新数据
ma->buffer[ma->index] = new_sample;
// 累加新数据
ma->sum += new_sample;
// 更新索引
ma->index = (ma->index + 1) % WINDOW_SIZE;
// 返回平均值
return ma->sum / WINDOW_SIZE;
}
嗯,这里要注意:初始化时要把buffer全部清零,sum也要清零。否则第一次滤波会得到一个错误值。
避坑指南:我曾经在项目里忘记初始化sum,结果前几次滤波输出全是垃圾数据,排查了半天才发现。初始化一定要做彻底。
3.2 中值滤波:对付脉冲噪声的利器
滑动平均滤波有个致命弱点:它对脉冲噪声非常敏感。什么叫脉冲噪声?就是突然冒出一个离谱的数值,比如光照传感器突然读到65535(满量程)。
为什么会这样?因为平均滤波会把那个异常值也平均进去,导致输出瞬间跳变。这时候,中值滤波就派上用场了。
中值滤波的做法:取窗口内的所有数据,排序后取中间那个值。这样一来,哪怕有一个异常值,只要它不在中间位置,就不会影响输出。
我举个例子:假设窗口大小是5,数据是[100, 102, 101, 65535, 103]。排序后是[100, 101, 102, 103, 65535],中值是102。你看,那个65535被完美过滤掉了。
代码实现:
// 中值滤波器
#define MEDIAN_WINDOW 5
float median_filter(float* buffer, float new_sample) {
float temp[MEDIAN_WINDOW];
// 移动窗口
for(int i = 0; i < MEDIAN_WINDOW - 1; i++) {
buffer[i] = buffer[i+1];
}
buffer[MEDIAN_WINDOW - 1] = new_sample;
// 拷贝到临时数组并排序
memcpy(temp, buffer, sizeof(float) * MEDIAN_WINDOW);
// 冒泡排序(窗口小,够用)
for(int i = 0; i < MEDIAN_WINDOW - 1; i++) {
for(int j = 0; j < MEDIAN_WINDOW - 1 - i; j++) {
if(temp[j] > temp[j+1]) {
float t = temp[j];
temp[j] = temp[j+1];
temp[j+1] = t;
}
}
}
// 返回中值
return temp[MEDIAN_WINDOW / 2];
}
注意:中值滤波的窗口大小必须是奇数,否则中值不好取。另外,排序算法在窗口很小时用冒泡没问题,窗口大了就得换快速排序。
我个人习惯,在智能灯具中,先做一次中值滤波剔除脉冲噪声,再做滑动平均滤波平滑数据。两者配合使用,效果非常好。
3.3 卡尔曼滤波入门:从“猜”到“准”
前面两种滤波方法,说白了都是“事后诸葛亮”——它们只根据历史数据做处理。卡尔曼滤波不一样,它有一个“预测-更新”的闭环过程。
我尽量用大白话讲清楚:卡尔曼滤波认为,系统的状态是随时间变化的,而且我们只能通过观测来估计它。它有两个步骤:
- 预测:根据上一时刻的状态,猜一下当前时刻的状态大概是多少。
- 更新:拿到当前时刻的观测值后,结合预测值和观测值,算出一个更准确的估计值。
你想想看,这就像你闭着眼睛走路。你根据上一秒的位置和速度,猜自己现在在哪(预测)。然后你睁开眼看一下实际位置(观测),把猜测和观测结合起来,得到更准确的位置估计。
对于智能灯具,我们通常用一维卡尔曼滤波就够了。所谓一维,就是只处理一个变量,比如光照强度。
核心公式如下:
| 步骤 | 公式 | 说明 |
|---|---|---|
| 预测 | x_pred = x_est | 状态不变(假设静止) |
| 预测 | P_pred = P_est + Q | 预测误差协方差 |
| 更新 | K = P_pred / (P_pred + R) | 卡尔曼增益 |
| 更新 | x_est = x_pred + K * (z - x_pred) | 状态更新 |
| 更新 | P_est = (1 - K) * P_pred | 误差协方差更新 |
这里Q是过程噪声协方差,R是测量噪声协方差。Q越大,说明系统本身变化快;R越大,说明传感器噪声大。
看代码:
// 一维卡尔曼滤波器
typedef struct {
float x_est; // 估计值
float P_est; // 估计误差协方差
float Q; // 过程噪声
float R; // 测量噪声
} KalmanFilter1D;
void kalman_init(KalmanFilter1D* kf, float init_value) {
kf->x_est = init_value;
kf->P_est = 1.0f; // 初始误差设大一点
kf->Q = 0.01f; // 根据实际情况调整
kf->R = 0.1f; // 根据传感器手册或实测调整
}
float kalman_update(KalmanFilter1D* kf, float measurement) {
// 预测
float x_pred = kf->x_est;
float P_pred = kf->P_est + kf->Q;
// 更新
float K = P_pred / (P_pred + kf->R);
kf->x_est = x_pred + K * (measurement - x_pred);
kf->P_est = (1.0f - K) * P_pred;
return kf->x_est;
}
关键点:Q和R的调参非常关键。Q设太大,滤波结果会跟着噪声跑;Q设太小,响应太慢。R设太大,滤波结果不信任传感器;R设太小,噪声滤不干净。
我记得第一次调卡尔曼滤波参数时,花了整整一个下午。后来总结出一个经验:先固定R(根据传感器数据手册的噪声指标),然后调Q,直到滤波结果既平滑又能跟上真实变化。
3.4 三种滤波器的对比与选择
我把三种滤波器的特点整理成了一张表,方便你对比:
| 滤波器 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 滑动平均 | 简单、计算量小 | 对脉冲噪声敏感、有延迟 | 光照、温度等缓慢变化信号 |
| 中值滤波 | 抗脉冲噪声能力强 | 计算量大(需排序)、平滑效果一般 | 有突发干扰的场景 |
| 卡尔曼滤波 | 自适应、精度高、延迟可控 | 需要调参、计算量稍大 | 动态变化、要求高精度的场景 |
我个人建议:
- 如果MCU资源紧张,用滑动平均就够了。
- 如果传感器经常受到电磁干扰(比如靠近电机),加上中值滤波。
- 如果要做高精度调光或色温控制,上卡尔曼滤波。
实战经验:我在一个智能台灯项目中,用了“中值滤波+卡尔曼滤波”的组合。先中值滤波剔除异常值,再卡尔曼滤波平滑输出。最终调光效果非常顺滑,用户完全感觉不到灯光在变化。
好了,信号预处理的内容就讲到这里。下一章我们会进入传感器融合的核心——如何把多个传感器的数据结合起来,得到更准确的环境感知结果。
公众号:蓝海资料掘金营,微信deep3321