3. 中值滤波:基本原理、一维中值滤波、二维中值滤波、边缘保持特性

各位同学,咱们今天聊聊中值滤波。说实话,这玩意儿在我刚入行那会儿,总觉得它就是个「排序取中间值」的简单操作,没啥技术含量。直到有一次在工业现场处理一个压力传感器的数据,被噪声折磨得够呛,才真正体会到它的妙处。

中值滤波的核心思想,说白了就是一句话:用邻域内所有数值的中位数,来代替当前点的值。你想想看,如果某个数据点是个野值(比如突然跳变),它要么极大要么极小,排序之后肯定被挤到边上,根本影响不到中间那个数。这就是它抗脉冲噪声的底气所在。

3.1 基本原理

咱们先抛开代码,用大白话理解一下。假设你有一串数字:

原始数据: [3, 2, 8, 5, 4]

现在要对第3个数(也就是8)做中值滤波,窗口大小取3。那么窗口覆盖的是 [2, 8, 5],排序后变成 [2, 5, 8],中位数是5。所以8就被替换成了5。你看,那个突兀的8就被「拉」回来了。

我个人习惯把中值滤波理解成一种「投票机制」——邻居们投票决定当前点该是多少,而那个最极端的意见(野值)永远拿不到多数票。嗯,这个比喻虽然不太严谨,但初学者一听就懂。

关键区别:均值滤波是「平均主义」,所有邻居一起分摊噪声;中值滤波是「少数服从多数」,直接忽略极端值。所以中值滤波对椒盐噪声(也就是那种黑白点噪声)效果极好,但对高斯噪声(那种均匀分布的随机噪声)效果一般。

3.2 一维中值滤波

一维中值滤波最常见的地方,就是处理各种时序信号。比如温度传感器、压力传感器、加速度计的数据流。我在做电机振动监测项目时,就经常用它来剔除那些因为电磁干扰产生的毛刺。

实现起来其实很简单,核心步骤就三步:

  1. 开窗:以当前点为中心,左右各取N个点(窗口大小通常为奇数,比如3、5、7)
  2. 排序:把这2N+1个数排个序
  3. 替换:用排序后的中间值替换当前点

下面是一个C语言实现的示例,我在嵌入式项目里经常这么写:

// 一维中值滤波,窗口大小固定为5
uint16_t median_filter_1d(uint16_t *buffer, uint8_t len) {
    uint16_t temp[5];
    uint8_t i, j;
    uint16_t tmp;

    // 拷贝数据到临时数组
    for(i = 0; i < 5; i++) {
        temp[i] = buffer[i];
    }

    // 冒泡排序(窗口小,够用)
    for(i = 0; i < 4; i++) {
        for(j = 0; j < 4 - i; j++) {
            if(temp[j] > temp[j+1]) {
                tmp = temp[j];
                temp[j] = temp[j+1];
                temp[j+1] = tmp;
            }
        }
    }

    // 返回中位数(索引2)
    return temp[2];
}

小技巧:在实际项目中,我一般不会每次都重新排序整个窗口。因为窗口滑动时,只有新进来的数据和被踢出去的数据发生了变化。用「插入排序」或者「双堆维护」的方式,可以把计算量从O(n log n)降到O(log n)。不过对于窗口小于9的情况,直接排序反而更简单,代码也更容易维护。

我曾经在一个电池管理系统中遇到过一个问题:电流采样值偶尔会跳变到正常值的10倍以上,导致SOC(荷电状态)估算直接崩掉。后来加了一级中值滤波,窗口取5,所有毛刺都被完美过滤。嗯,这里要注意:窗口不能太大,否则真实信号的变化也会被抹平。

3.3 二维中值滤波

二维中值滤波,说白了就是把一维的思路搬到图像上。每个像素点不再只看左右邻居,而是看它周围一个矩形区域(比如3x3、5x5)内的所有像素。排序后取中位数,替换当前像素。

你想想看,一张照片上如果有个坏点(比如CCD传感器上的死像素),它在图像上就是一个孤立的亮点或暗点。二维中值滤波扫过去,这个坏点就会被周围正常的像素值「淹没」掉。

下面是一个简单的二维中值滤波实现,窗口大小为3x3:

// 二维中值滤波,3x3窗口
void median_filter_2d(uint8_t *src, uint8_t *dst, uint16_t width, uint16_t height) {
    uint16_t x, y, i, j, k;
    uint16_t temp[9];
    uint16_t tmp;

    for(y = 1; y < height - 1; y++) {
        for(x = 1; x < width - 1; x++) {
            // 收集3x3邻域的像素值
            k = 0;
            for(j = -1; j <= 1; j++) {
                for(i = -1; i <= 1; i++) {
                    temp[k++] = src[(y + j) * width + (x + i)];
                }
            }

            // 排序(简单冒泡)
            for(i = 0; i < 8; i++) {
                for(j = 0; j < 8 - i; j++) {
                    if(temp[j] > temp[j+1]) {
                        tmp = temp[j];
                        temp[j] = temp[j+1];
                        temp[j+1] = tmp;
                    }
                }
            }

            // 中位数赋值给目标像素
            dst[y * width + x] = temp[4];
        }
    }
}

边界处理:图像边缘的像素没有完整的3x3邻域。常见的做法有:不处理边缘(保持原值)、镜像扩展、或者复制边缘像素。我个人习惯用镜像扩展,因为它不会引入额外的边界伪影。你想想看,如果直接复制边缘像素,相当于在边界处人为制造了重复纹理,滤波后容易出现「接缝」。

3.4 边缘保持特性

这是中值滤波最让我佩服的地方。均值滤波会把边缘模糊掉——因为边缘两侧的像素值差异大,一平均就把锐利的边界变成了平滑过渡。但中值滤波不会。

为什么会这样?

咱们来做个思想实验。假设图像上有一条垂直的边缘:左边全是黑色(0),右边全是白色(255)。现在取一个3x3的窗口,窗口正好跨在边缘上。窗口内的像素值可能是:

0 0 255
0 0 255
0 0 255

排序后:0, 0, 0, 0, 0, 0, 255, 255, 255。中位数是0(第5个数)。所以边缘上的像素被替换成了0(黑色),边缘被保留了下来。而均值滤波会把0和255平均,得到85左右的灰色,边缘就模糊了。

核心结论:中值滤波在去除噪声的同时,能很好地保持图像的边缘细节。这是它区别于均值滤波的最大优势。我在做工业视觉检测时,经常先用中值滤波去噪,再用Canny算子提取边缘,效果比均值滤波+边缘检测好得多。

不过,凡事都有两面性。中值滤波对「细线」和「尖角」这类细节不太友好。比如图像上有一根1像素宽的线条,中值滤波可能会把它当成噪声给抹掉。我记得有一次处理PCB板上的划痕检测,中值滤波把细小的划痕也给滤掉了,后来不得不改用自适应中值滤波,根据局部方差动态调整窗口大小。

最后总结一下:

特性 中值滤波 均值滤波
脉冲噪声抑制 优秀 一般
高斯噪声抑制 一般 良好
边缘保持 优秀
计算复杂度 较高(需排序)
适用场景 椒盐噪声、传感器毛刺 平滑、降噪

嗯,中值滤波就聊到这儿。下一节咱们讲讲更高级的滤波方法——卡尔曼滤波,那玩意儿才是真正能「预测未来」的滤波器。