4. 中值滤波与椒盐噪声去除:算法原理、自适应窗口、快速中值算法(O(1)复杂度)

椒盐噪声,说白了就是图像上随机出现的黑白点。你想想看,传感器坏点、传输误码,都容易搞出这种东西。我最早接触这个是在一个工业质检项目里,摄像头采集的电路板图像上全是白点,当时我第一反应是用均值滤波,结果噪声没去掉,边缘倒糊成一团了。

后来我才意识到,对付椒盐噪声,中值滤波才是正解。它不像均值滤波那样取平均,而是取邻域内所有像素的中间值。这个操作天然就能把那些极端亮或暗的噪声点给剔除掉。

4.1 算法原理:为什么中值能去椒盐?

中值滤波的核心思想很简单:

  1. 选定一个窗口(比如 3×3)。
  2. 把窗口内所有像素值排序。
  3. 取排序后的中间值,替换中心像素。

举个例子,一个 3×3 窗口里有 9 个像素,其中一个是 255(白噪声),其他都是正常值。排序后,255 会跑到最边上,中间那个值大概率是正常像素。这样一来,噪声就被“挤”出去了。

关键区别:均值滤波会被噪声“带偏”,中值滤波则完全忽略极端值。对于椒盐噪声密度低于 50% 的情况,中值滤波几乎可以完美恢复。

我在项目中遇到过一种情况:椒盐噪声密度高达 60%,普通 3×3 中值滤波已经扛不住了。这时候就得加大窗口,或者用自适应方案。

4.2 自适应窗口:动态调整核大小

固定窗口有个问题:窗口太小,去不掉密集噪声;窗口太大,图像细节全没了。自适应窗口的思路是——根据局部噪声密度动态调整窗口大小。

噪声密度 推荐窗口 效果
< 20% 3×3 细节保留好,速度快
20% ~ 40% 5×5 平衡去噪与细节
40% ~ 60% 7×7 或更大 去噪强,但边缘会模糊

自适应窗口的算法流程大致是这样:

  1. 统计当前窗口内极值像素(0 或 255)的比例。
  2. 如果比例超过阈值(比如 30%),就扩大窗口。
  3. 重复直到极值比例降到安全范围,或者窗口达到上限。
我的经验:自适应窗口虽然效果好,但循环扩窗的过程很耗时。我一般会设一个最大窗口上限(比如 9×9),防止极端情况把性能拖垮。

4.3 快速中值算法:从 O(r²) 到 O(1)

传统中值滤波为什么慢?因为每个像素都要排序。窗口半径 r,排序复杂度 O(r² log r)。对于 3×3 窗口还好,但 7×7 窗口就要排 49 个数,一帧 1080p 图像算下来,慢得让人崩溃。

我记得第一次在嵌入式设备上跑中值滤波,帧率直接掉到个位数。后来我换成了快速中值算法,才把实时性救回来。

快速中值算法的核心思想是直方图 + 滑动窗口:

  1. 维护一个直方图,记录当前窗口内每个灰度值的出现次数。
  2. 窗口向右滑动时,移除左列像素,加入右列像素,只更新直方图。
  3. 从直方图中累加计数,找到中值位置。

这样做的好处是:每个像素的更新操作是 O(1) 的,不再依赖窗口大小。

注意:直方图法只适用于灰度图。如果是彩色图像,需要对每个通道分别处理,或者转换到 YUV 空间只处理 Y 通道。

4.3.1 算法步骤详解

  1. 初始化:对第一列窗口建立直方图,统计 0~255 每个灰度值的像素个数。
  2. 找中值:从 0 开始累加计数,直到累加和超过窗口像素数的一半,那个灰度值就是中值。
  3. 滑动:窗口右移一列,直方图减去左列像素,加上右列像素。
  4. 重复:对新直方图重新找中值。

代码实现大致如下:

// 快速中值滤波 - 单通道灰度图
void fastMedianFilter(uint8_t* src, uint8_t* dst, int width, int height, int radius) {
    int hist[256] = {0};
    int windowSize = 2 * radius + 1;
    int halfPixels = windowSize * windowSize / 2;

    for (int y = 0; y < height; y++) {
        // 重置直方图
        memset(hist, 0, sizeof(hist));
        
        // 初始化第一列窗口
        for (int j = 0; j < windowSize; j++) {
            for (int i = 0; i < windowSize; i++) {
                int px = src[(y + j) * width + i];
                hist[px]++;
            }
        }
        
        // 找中值
        int sum = 0;
        int median = 0;
        for (int k = 0; k < 256; k++) {
            sum += hist[k];
            if (sum > halfPixels) {
                median = k;
                break;
            }
        }
        dst[y * width + radius] = median;

        // 滑动窗口
        for (int x = radius + 1; x < width - radius; x++) {
            // 移除左列
            for (int j = 0; j < windowSize; j++) {
                int px = src[(y + j) * width + (x - radius - 1)];
                hist[px]--;
            }
            // 加入右列
            for (int j = 0; j < windowSize; j++) {
                int px = src[(y + j) * width + (x + radius)];
                hist[px]++;
            }
            // 重新找中值
            sum = 0;
            for (int k = 0; k < 256; k++) {
                sum += hist[k];
                if (sum > halfPixels) {
                    median = k;
                    break;
                }
            }
            dst[y * width + x] = median;
        }
    }
}
优化技巧:找中值时不需要每次都从 0 开始累加。可以记住上一次的中值位置,从那里开始累加,平均能减少一半的查找时间。

4.4 中值滤波的变体与选型

实际项目中,我很少直接用标准中值滤波。根据场景不同,我会选这些变体:

变体名称 适用场景 优缺点
加权中值滤波 需要保留边缘细节 中心像素权重更高,边缘更清晰
多级中值滤波 高密度噪声 多次迭代,去噪彻底但耗时
开关中值滤波 混合噪声(椒盐+高斯) 先检测噪声点,只对噪声点滤波
矢量中值滤波 彩色图像 在 RGB 向量空间找中值,避免颜色失真

我曾经在一个监控项目里用过开关中值滤波。场景是夜间摄像头,既有椒盐噪声又有高斯噪声。先做一个噪声检测,只对检测到的噪声点应用中值滤波,正常像素不动。这样既去了噪,又保住了细节。

4.5 知识结构图:中值滤波核心逻辑

下面这张图帮你理清中值滤波的完整知识体系:

中值滤波知识结构图 中值滤波 算法原理 排序取中值 剔除极端值 自适应窗口 统计极值比例 动态扩大窗口 快速中值 O(1) 直方图维护 滑动窗口更新 应用场景:工业质检 / 监控视频 / 医学图像 💡 核心要点 • 中值滤波对椒盐噪声天然免疫,但对高斯噪声效果一般 • 快速中值算法通过直方图+滑动窗口实现 O(1) 复杂度,是实时系统的首选 • 自适应窗口在噪声密度变化大的场景下效果远好于固定窗口

4.6 避坑指南

最后分享几个我踩过的坑:

  • 窗口大小选错:我曾经在一个项目里直接用 7×7 窗口处理低噪声图像,结果图像糊得像油画。后来改成自适应窗口,问题才解决。
  • 边界处理不当:图像边缘的像素窗口会越界。我习惯用镜像填充,效果比补零好很多。
  • 彩色图像直接滤波:在 RGB 三个通道分别做中值滤波,容易产生颜色偏移。建议转 YUV 只处理 Y 通道,或者用矢量中值滤波。
  • 直方图溢出:快速中值算法里,直方图更新时要注意像素值范围。我遇到过 uint8_t 溢出导致直方图计数错误,排查了半天才发现。
性能警告:快速中值算法虽然 O(1),但直方图查找中值仍然需要遍历 256 个灰度级。如果图像位深是 12bit 或 16bit,直方图会膨胀到 4096 或 65536 个 bin,查找开销会显著增加。这时候可以考虑用二分查找优化。

嗯,中值滤波这部分就讲到这里。记住一句话:对付椒盐噪声,中值滤波是首选;要实时,就用快速中值算法。