4. 中值滤波与椒盐噪声去除:算法原理、自适应窗口、快速中值算法(O(1)复杂度)
椒盐噪声,说白了就是图像上随机出现的黑白点。你想想看,传感器坏点、传输误码,都容易搞出这种东西。我最早接触这个是在一个工业质检项目里,摄像头采集的电路板图像上全是白点,当时我第一反应是用均值滤波,结果噪声没去掉,边缘倒糊成一团了。
后来我才意识到,对付椒盐噪声,中值滤波才是正解。它不像均值滤波那样取平均,而是取邻域内所有像素的中间值。这个操作天然就能把那些极端亮或暗的噪声点给剔除掉。
4.1 算法原理:为什么中值能去椒盐?
中值滤波的核心思想很简单:
- 选定一个窗口(比如 3×3)。
- 把窗口内所有像素值排序。
- 取排序后的中间值,替换中心像素。
举个例子,一个 3×3 窗口里有 9 个像素,其中一个是 255(白噪声),其他都是正常值。排序后,255 会跑到最边上,中间那个值大概率是正常像素。这样一来,噪声就被“挤”出去了。
我在项目中遇到过一种情况:椒盐噪声密度高达 60%,普通 3×3 中值滤波已经扛不住了。这时候就得加大窗口,或者用自适应方案。
4.2 自适应窗口:动态调整核大小
固定窗口有个问题:窗口太小,去不掉密集噪声;窗口太大,图像细节全没了。自适应窗口的思路是——根据局部噪声密度动态调整窗口大小。
| 噪声密度 | 推荐窗口 | 效果 |
|---|---|---|
| < 20% | 3×3 | 细节保留好,速度快 |
| 20% ~ 40% | 5×5 | 平衡去噪与细节 |
| 40% ~ 60% | 7×7 或更大 | 去噪强,但边缘会模糊 |
自适应窗口的算法流程大致是这样:
- 统计当前窗口内极值像素(0 或 255)的比例。
- 如果比例超过阈值(比如 30%),就扩大窗口。
- 重复直到极值比例降到安全范围,或者窗口达到上限。
4.3 快速中值算法:从 O(r²) 到 O(1)
传统中值滤波为什么慢?因为每个像素都要排序。窗口半径 r,排序复杂度 O(r² log r)。对于 3×3 窗口还好,但 7×7 窗口就要排 49 个数,一帧 1080p 图像算下来,慢得让人崩溃。
我记得第一次在嵌入式设备上跑中值滤波,帧率直接掉到个位数。后来我换成了快速中值算法,才把实时性救回来。
快速中值算法的核心思想是直方图 + 滑动窗口:
- 维护一个直方图,记录当前窗口内每个灰度值的出现次数。
- 窗口向右滑动时,移除左列像素,加入右列像素,只更新直方图。
- 从直方图中累加计数,找到中值位置。
这样做的好处是:每个像素的更新操作是 O(1) 的,不再依赖窗口大小。
4.3.1 算法步骤详解
- 初始化:对第一列窗口建立直方图,统计 0~255 每个灰度值的像素个数。
- 找中值:从 0 开始累加计数,直到累加和超过窗口像素数的一半,那个灰度值就是中值。
- 滑动:窗口右移一列,直方图减去左列像素,加上右列像素。
- 重复:对新直方图重新找中值。
代码实现大致如下:
// 快速中值滤波 - 单通道灰度图
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;
}
}
}
4.4 中值滤波的变体与选型
实际项目中,我很少直接用标准中值滤波。根据场景不同,我会选这些变体:
| 变体名称 | 适用场景 | 优缺点 |
|---|---|---|
| 加权中值滤波 | 需要保留边缘细节 | 中心像素权重更高,边缘更清晰 |
| 多级中值滤波 | 高密度噪声 | 多次迭代,去噪彻底但耗时 |
| 开关中值滤波 | 混合噪声(椒盐+高斯) | 先检测噪声点,只对噪声点滤波 |
| 矢量中值滤波 | 彩色图像 | 在 RGB 向量空间找中值,避免颜色失真 |
我曾经在一个监控项目里用过开关中值滤波。场景是夜间摄像头,既有椒盐噪声又有高斯噪声。先做一个噪声检测,只对检测到的噪声点应用中值滤波,正常像素不动。这样既去了噪,又保住了细节。
4.5 知识结构图:中值滤波核心逻辑
下面这张图帮你理清中值滤波的完整知识体系:
4.6 避坑指南
最后分享几个我踩过的坑:
- 窗口大小选错:我曾经在一个项目里直接用 7×7 窗口处理低噪声图像,结果图像糊得像油画。后来改成自适应窗口,问题才解决。
- 边界处理不当:图像边缘的像素窗口会越界。我习惯用镜像填充,效果比补零好很多。
- 彩色图像直接滤波:在 RGB 三个通道分别做中值滤波,容易产生颜色偏移。建议转 YUV 只处理 Y 通道,或者用矢量中值滤波。
- 直方图溢出:快速中值算法里,直方图更新时要注意像素值范围。我遇到过 uint8_t 溢出导致直方图计数错误,排查了半天才发现。
嗯,中值滤波这部分就讲到这里。记住一句话:对付椒盐噪声,中值滤波是首选;要实时,就用快速中值算法。