4、传统去噪方法(二):中值滤波、双边滤波的原理与Python实现
上一节我们聊了均值滤波和高斯滤波,说白了它们都是线性滤波。线性滤波有个天生的毛病——它会把边缘也一起模糊掉。你想想看,一张照片里,噪点和边缘其实都是高频信号,线性滤波器分不清谁是谁,一视同仁地给你抹平了。
那怎么办?我个人的经验是,遇到这种情况,就该请出非线性滤波了。今天咱们重点聊两个:中值滤波和双边滤波。这两个家伙,一个靠排序,一个靠权重组合,都能在去噪的同时保住边缘。
核心区别一句话总结:
- 中值滤波:用邻域像素的中位数代替中心像素。对椒盐噪声效果极好。
- 双边滤波:同时考虑空间距离和像素值差异。对高斯噪声有效,且能保边。
4.1 中值滤波:简单粗暴但有效
中值滤波的原理特别简单。你把一个像素周围的所有像素值排个序,取中间那个值,替换掉原来的像素。就这么简单。
为什么它能去噪?因为噪点通常是孤立的异常值,排序之后它要么在最左边,要么在最右边,根本轮不到它当中间值。而边缘呢?边缘两侧的像素值虽然不同,但各自区域内是连续的,排序后中间值大概率还是属于同一边的像素,所以边缘保住了。
我记得有一次做工业检测项目,摄像头采集的图片全是椒盐噪声——就是那种黑白点乱蹦的。用高斯滤波试了半天,边缘糊得一塌糊涂。换成中值滤波,3x3的窗口一跑,噪点全没了,边缘还清清楚楚。嗯,从那以后我遇到椒盐噪声,第一反应就是中值滤波。
避坑指南:我曾经犯过一个低级错误——对彩色图像直接做中值滤波。彩色图像有三个通道,每个通道单独做中值滤波,结果颜色全乱了。正确的做法是先把RGB转成灰度,或者对每个通道分别处理后再合并。更专业的做法是用OpenCV的cv2.medianBlur,它内部已经处理好了多通道的情况。
4.1.1 中值滤波的Python实现
OpenCV里直接有现成的函数,但我还是建议你手写一遍,理解它的内部逻辑。
import cv2
import numpy as np
def median_filter(image, kernel_size=3):
"""
手动实现中值滤波
:param image: 输入图像 (灰度图)
:param kernel_size: 窗口大小,必须是奇数
:return: 滤波后的图像
"""
# 边界填充,防止边缘像素处理不到
pad = kernel_size // 2
padded = cv2.copyMakeBorder(image, pad, pad, pad, pad, cv2.BORDER_REFLECT)
# 初始化输出图像
output = np.zeros_like(image)
# 遍历每个像素
for i in range(image.shape[0]):
for j in range(image.shape[1]):
# 提取窗口内的像素值
window = padded[i:i+kernel_size, j:j+kernel_size]
# 排序取中位数
median_val = np.median(window)
output[i, j] = median_val
return output
# 读取图像并添加椒盐噪声
img = cv2.imread('lena.jpg', 0) # 灰度图
# 添加椒盐噪声(模拟)
noise = np.random.randint(0, 255, img.shape)
salt_pepper = np.where(noise < 10, 0, np.where(noise > 245, 255, img))
# 应用中值滤波
filtered = median_filter(salt_pepper, kernel_size=3)
# 对比OpenCV内置函数
opencv_filtered = cv2.medianBlur(salt_pepper, 3)
# 显示结果
cv2.imshow('Original', img)
cv2.imshow('Noisy', salt_pepper)
cv2.imshow('Manual Median', filtered)
cv2.imshow('OpenCV Median', opencv_filtered)
cv2.waitKey(0)
cv2.destroyAllWindows()
注意:中值滤波的窗口大小必须是奇数。为什么?因为奇数个数的中位数是唯一的。如果是偶数,中位数需要取中间两个数的平均值,虽然也能算,但效果不如奇数窗口稳定。我一般用3x3或5x5,再大就容易把细节也抹掉了。
4.2 双边滤波:保边去噪的利器
双边滤波就比中值滤波复杂多了。它本质上还是加权平均,但权重由两部分组成:
- 空间权重:离中心像素越近,权重越大。这和高斯滤波一样。
- 值域权重:像素值越接近中心像素,权重越大。这是双边滤波的精髓。
说白了,双边滤波在做平滑的时候,会先看看周围的像素跟中心像素像不像。如果像,就多参考;如果不像(比如边缘两侧),就少参考甚至不参考。这样一来,平坦区域的噪点被平滑掉了,边缘却被保留了下来。
我刚开始学双边滤波时,总觉得它参数太多,调起来麻烦。后来做夜景照片去噪,高斯滤波把星星都模糊成一片了,双边滤波却能保留星星的轮廓。那一刻我才真正理解它的价值。
4.2.1 双边滤波的数学原理
公式其实不复杂,但理解起来需要一点耐心:
输出像素值 = (1/W) * Σ( 空间权重 * 值域权重 * 邻域像素值 )
其中:
W = Σ( 空间权重 * 值域权重 ) # 归一化系数
空间权重 = exp( - (距离²) / (2 * σ_space²) )
值域权重 = exp( - (像素差²) / (2 * σ_range²) )
你看,空间权重只跟距离有关,值域权重只跟像素值差异有关。两者相乘,就是最终的权重。当像素值差异很大时(比如边缘),值域权重趋近于0,即使空间上离得很近,也不会参与平滑。这就是保边的秘密。
4.2.2 双边滤波的Python实现
OpenCV提供了cv2.bilateralFilter,但为了理解原理,我们还是手写一个简化版。
import cv2
import numpy as np
def bilateral_filter(image, d=9, sigma_color=75, sigma_space=75):
"""
手动实现双边滤波
:param image: 输入图像 (灰度图)
:param d: 窗口直径
:param sigma_color: 值域标准差
:param sigma_space: 空间标准差
:return: 滤波后的图像
"""
# 边界填充
pad = d // 2
padded = cv2.copyMakeBorder(image, pad, pad, pad, pad, cv2.BORDER_REFLECT)
# 初始化输出
output = np.zeros_like(image, dtype=np.float32)
# 预计算空间权重(只跟距离有关,可以提前算好)
x, y = np.meshgrid(np.arange(-pad, pad+1), np.arange(-pad, pad+1))
spatial_weights = np.exp(-(x**2 + y**2) / (2 * sigma_space**2))
# 遍历每个像素
for i in range(image.shape[0]):
for j in range(image.shape[1]):
# 提取窗口
window = padded[i:i+d, j:j+d].astype(np.float32)
center_val = window[pad, pad]
# 计算值域权重
range_weights = np.exp(-((window - center_val)**2) / (2 * sigma_color**2))
# 总权重 = 空间权重 * 值域权重
total_weights = spatial_weights * range_weights
# 归一化并计算输出
output[i, j] = np.sum(window * total_weights) / np.sum(total_weights)
return output.astype(np.uint8)
# 使用示例
img = cv2.imread('lena.jpg', 0)
# 添加高斯噪声
noise = np.random.normal(0, 25, img.shape).astype(np.uint8)
gaussian_noisy = cv2.add(img, noise)
# 应用双边滤波
filtered = bilateral_filter(gaussian_noisy, d=9, sigma_color=75, sigma_space=75)
# OpenCV内置版本
opencv_filtered = cv2.bilateralFilter(gaussian_noisy, 9, 75, 75)
cv2.imshow('Original', img)
cv2.imshow('Noisy', gaussian_noisy)
cv2.imshow('Manual Bilateral', filtered)
cv2.imshow('OpenCV Bilateral', opencv_filtered)
cv2.waitKey(0)
cv2.destroyAllWindows()
参数调优建议:
- d:窗口直径。一般取5-15之间。d越大,计算越慢,但平滑效果越强。
- sigma_color:值域标准差。值越大,值域权重衰减越慢,保边效果越弱。我一般取50-100。
- sigma_space:空间标准差。值越大,空间权重衰减越慢,平滑范围越大。通常跟d成正比,取d/2左右。
我个人习惯是先固定sigma_space,然后调整sigma_color。sigma_color太小,去噪不彻底;太大,边缘就保不住了。找到一个平衡点,需要多试几次。
4.3 中值滤波 vs 双边滤波:怎么选?
这两个方法各有千秋,我整理了一个对比表格,方便你快速决策:
| 对比维度 | 中值滤波 | 双边滤波 |
|---|---|---|
| 噪声类型 | 椒盐噪声(极佳) | 高斯噪声(良好) |
| 保边能力 | 中等(边缘会轻微锯齿) | 优秀(边缘清晰) |
| 计算速度 | 快(O(n²)) | 慢(O(n² * d²)) |
| 参数数量 | 1个(窗口大小) | 3个(d, sigma_color, sigma_space) |
| 适用场景 | 扫描文档、工业检测 | 人像美化、夜景照片 |
我的建议是:如果噪声是明显的黑白点(椒盐噪声),无脑选中值滤波。如果噪声是均匀的颗粒感(高斯噪声),而且你特别在意边缘细节,那就用双边滤波。当然,双边滤波计算量比较大,实时处理时要注意性能。
4.4 知识体系结构图
下面这张图帮你理清今天的内容脉络:
总结一下今天的内容:
- 中值滤波:排序取中位数,简单高效,专治椒盐噪声。
- 双边滤波:空间+值域双重权重,保边去噪,适合高斯噪声。
- 两者都是非线性滤波,都能在一定程度上保留边缘。
- 实际项目中,我经常把两者结合使用:先用中值滤波去掉明显的椒盐噪声,再用双边滤波做精细平滑。
好了,今天的内容就到这里。这两种方法虽然经典,但面对复杂噪声时还是力不从心。下一节我们会进入深度学习去噪的世界,那才是真正的大杀器。不过,打好传统方法的基础,你才能理解为什么深度学习能做得更好。
公众号:蓝海资料掘金营,微信deep3321