ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

四种图像去噪算法:均值/中值/高斯/NLM原理与调参实战

四种图像去噪算法:均值/中值/高斯/NLM原理与调参实战

做图像处理的,不管你是刚入门还是写了几年代码的老开发,几乎都躲不开“去噪”这道坎。均值滤波、中值滤波、高斯滤波、non-local means(NLM)这四种算法,面试会被问,项目里也天天用,但很多人其实只是会调用一行API,真让你解释它们为什么有效、为什么有的糊边缘、有的保细节、NLM凭什么比局部滤波强,可能就卡住了。这篇文章我准备用做项目踩坑的视角,把这四种算法的原理、推导直觉、代码实现、参数选择和调参经验一次性拆开,顺便把类似“为什么高斯滤波也会糊”“NLM跑这么慢怎么优化”这些问题也一一讲透。无论你是在做图像预处理、视频降噪,还是答辩需要讲清楚算法对比,这篇都能当一份能直接照抄的手册。

1. 先看懂噪声,再选滤波器

去噪这件事,本质上是一个信号估计问题:我们拿到的是被噪声污染的观测图像,希望尽可能还原出干净的真值。但干净图像长什么样,我们其实并不知道,所以只能通过观察像素之间的统计规律去猜。这时候,噪声的类型就必须先弄清楚,因为它直接决定了哪种算法是“对症下药”,哪种是“南辕北辙”。

1.1 常见噪声类型

图像处理里最常见的三类噪声分别是高斯噪声、椒盐噪声和泊松噪声。高斯噪声的特点是每个像素被加了一个服从高斯分布的随机值,典型来源是传感器在低光照下的热噪声和高ISO带来的噪声,数值上近似满足均值为0、方差为σ²的独立同分布。椒盐噪声则完全不同,它是随机把某些像素直接置成纯黑或纯白,典型来源是传输过程中的比特错误、传感器坏点,图像上看起来就是随机散落的黑白点。泊松噪声常见于低光照成像和医学影像,噪声强度跟信号强度耦合,信号越亮噪声越大,也是最难用简单线性滤波处理的一种。

还有一个经常被忽略但很关键的点:噪声是加性还是乘性。传统滤波器默认假设噪声是加性且独立的,即观察值 = 干净值 + 噪声。碰到乘性噪声或者跟信号相关的噪声,先对数变换或先估计噪声方差再滤波,效果会好很多,否则你调的参数再准也白搭。

1.2 去噪的核心矛盾:保细节还是保平滑

选择任何去噪算法,本质上都是在处理一对矛盾:平滑噪声的时候,如何不把边缘、纹理这些高频细节一并抹掉。均值滤波和高斯滤波这类线性滤波器,处理的是“邻域内像素的平均”,它们天然会把高频信息当作噪声来压制,所以细节容易糊。中值滤波这种非线性滤波,处理的是“邻域内像素的排序”,对离群点有天然的免疫力,所以保边缘能力较强。NLM则换了一个思路:它认为图像中存在大量重复的结构模式,只要找到结构相似的像素块,即使相隔很远,也可以用来互相平均,从而在平滑噪声的同时保住纹理。

这个矛盾决定了算法选型的第一原则:先看噪声类型,再看你要保的是边缘还是纹理。椒盐噪声首选非线性方法,高斯噪声可以用线性方法也可以上NLM,纹理丰富的图则尽量避开大窗口的均值滤波。

2. 均值滤波与中值滤波:两条朴素路径的真实代价

这两个算法是最基础的入门级滤波,也是很多人在图像预处理里第一个接触到的操作。它们的公式都非常简单,但“简单”背后分别藏着完全不同的适用边界。我把它们放一起讲,是因为很多新人会困惑:同样一个模糊操作,为什么它们的效果差这么多?

2.1 均值滤波原理与代码实现

均值滤波的思路一句话就能讲完:用一个k×k的窗口在图像上滑动,窗口内所有像素求平均,把平均值赋给窗口中心的像素。卷积核其实就是全为1/(k²)的矩阵,比如3×3的核就是九个元素全部等于1/9。用卷积实现的时候,边界可以取补零、复制边界像素或者镜像扩展,实际处理一般用镜像扩展更自然,避免边缘出现明显的黑色伪影。

代码非常简单。下面这个例子用常规视觉库的一行API就能完成,也可以自己写卷积核来完全控制边界行为:

import cv2 import numpy as np img = cv2.imread("noisy.png", cv2.IMREAD_GRAYSCALE) # API做法:3x3均值滤波 blur3 = cv2.blur(img, (3, 3)) # 手写核做法:边界处理用BORDER_REFLECT,效果更自然 kernel5 = np.ones((5, 5), np.float32) / 25 blur5 = cv2.filter2D(img, -1, kernel5, borderType=cv2.BORDER_REFLECT) cv2.imwrite("blur3.png", blur3) cv2.imwrite("blur5.png", blur5)

为什么均值滤波对高斯噪声有效?因为独立同分布的高斯噪声均值为0,把N个像素取平均之后,噪声方差会缩小到原来的1/N。窗口越大,噪声方差压得越低,画面越干净,但代价也随之而来:窗口内如果有边缘,边缘两侧的像素会被混在一起平均,边缘就糊了。窗口从3×3增到5×5、7×7,噪声是越去越干净,但图像细节也呈指数级消失。所以均值滤波只适合对细节要求不高的场景,比如刚采集图像后做一次轻度的整体平滑,或者做大目标检测前降低传感器噪声。

2.2 中值滤波原理与代码实现

中值滤波不做乘法,不做加法,只做一件事:把窗口内的所有像素值排序,取中间值作为输出。这个机制对椒盐噪声几乎是量身定制的。想象一个3×3窗口里有一个亮点和八个正常像素,排序后中间值大概率是正常像素,亮点这个离群值会被直接淘汰。窗口越大,能清除的椒盐点密度越高,但同样会付出细节变糊的代价,而且由于排序操作本身的开销,窗口越大耗时增长也很快。

代码同样很简洁:

median3 = cv2.medianBlur(img, 3) median5 = cv2.medianBlur(img, 5)

注意这里的参数是孔径大小,官方要求必须是大于1的奇数。为什么必须是奇数?因为奇数个像素才能保证有唯一的中间值;如果窗口是偶数,排序后中间两个值还要再平均一次,很多实现直接不支持。如果你做了边缘保留处理或者像素是彩色图,中值的计算要分通道做,否则三个通道混在一起排序会产生色彩偏移。

中值滤波最典型的应用就是电路板图像预处理、文档扫描图的椒盐去除、以及一些带有零星坏点的工业视觉场景。但把中值滤波用在高斯噪声上就很不划算:高斯噪声每个像素都偏离真值,排序后取中间值虽然稳健,却没有利用“均值能压缩方差”的优势,去噪效果反而不如同窗口的均值或高斯滤波,还会因为窗口过大把细线、小点目标直接抹掉。

2.3 两个朴素方法的适用边界对比

我把这两个算法在几种典型噪声下的表现列成一张表,方便你直接对照选择:

噪声类型均值滤波中值滤波
高斯噪声可用,窗口越大越糊效果一般,不建议大窗口
椒盐噪声效果差,椒盐点只会被摊薄不会消失效果极好,首选方案
细线、小目标保留很差,极易糊掉相对较好,但窗口过大也会糊
计算速度快,适合实时较慢,窗口越大排序开销越高
适用场景轻度高斯噪声预处理文档扫描、坏点修复、工业检测

实际项目里,如果你不知道噪声类型,我一般会先看一眼图像的直方图:椒盐噪声会让直方图两端出现尖峰,高斯噪声则让直方图整体变宽。看清噪声再选算法,比盲目枚举参数有效得多。

3. 高斯滤波:把“距离”写进权重

均值滤波的核里,每个像素的重要程度都一样,这其实不符合直觉:离中心近的像素应该更接近真值,理应贡献更大才对。高斯滤波就是在这个直觉上做改进,它用一个服从高斯分布的权重窗口替代平均权重,让距离中心越近的像素权重越大。这个小小的改变,带来了两个直接好处:去噪效果更好,边缘细节的保留也比均值滤波好一截。

3.1 高斯核是如何生成的

二维高斯核的生成公式是:

G(x, y) = 1 / (2πσ²) · exp(-(x² + y²) / (2σ²))

其中(x, y)是像素相对核中心的坐标,σ是标准差。sigma越大,高斯曲线越扁平,远处像素的权重越高,滤波效果越接近均值滤波;sigma越小,曲线越尖锐,中心像素权重越大,平滑能力越弱,细节保持越好。生成一个5×5高斯核的代码非常简单:

def gaussian_kernel(size, sigma): kx = cv2.getGaussianKernel(size, sigma) ky = cv2.getGaussianKernel(size, sigma) kernel = np.outer(kx, ky) return kernel

这里面有个值得注意的细节:先分别生成一维的行向量和列向量,再求外积得到二维核,是因为二维高斯函数可以分解成两个一维高斯的乘积,这就是高斯滤波的“可分离性”。直接做5×5的二维卷积,每个像素需要25次乘加运算;拆成一维行卷积加一维列卷积,只需要5+5=10次乘加,随着窗口变大,这个差距会越来越大。工业项目里做大分辨率图像处理,这个优化必须用上。

3.2 sigma和窗口尺寸怎么选才合理

很多新人问的第一个问题就是:“高斯模糊的核尺寸到底填多少?sigma填多少?”这里有个工程经验值:核尺寸至少要覆盖到±3σ的范围,也就是核边长不小于6σ+1。原因很简单,高斯分布99.7%的能量都集中在±3σ之内,核尺寸小于这个范围相当于截断了高斯的尾巴,权重信息不完整,滤波结果会出现不自然的块状感。

实际调用库函数的时候,你只要填一个参数——sigma,它会根据sigma自动计算合理的核尺寸;也可以用sigma和核尺寸一起指定,但处理库对不符合规则的组合会直接报错。调参的建议是:先根据噪声的严重程度估计σ,轻度噪声σ取0.8到1.5,中度噪声取1.5到3,重度噪声先考虑换NLM而不是无脑调大高斯核。σ一旦超过5,高斯滤波的成本和细节损失都会变得很大,性价比明显下降。

3.3 高斯滤波的边界处理与高阶技巧

高斯滤波跟均值滤波一样属于线性卷积,边界处理方式同样会影响边缘效果。默认的边界处理方式通常是在图像边界外补零,这会在输出图四周产生一圈暗边,尤其在σ较大的时候非常明显。正确做法是镜像扩展,代码里指定边界模式为BORDER_REFLECT或者BORDER_REPLICATE,去噪后的图像边缘过渡更自然。很多教程里从没提过这一点,但实际项目里差的就是这一行参数。

还有一个小技巧:如果你的图像既有高斯噪声又有少量椒盐点,直接只用高斯滤波,椒盐点会被摊成一片亮斑或暗斑,反而更难处理。这时候可以先用一次小窗口的中值滤波清掉椒盐点,再用高斯滤波处理高斯噪声,组合效果远好于单用任何一个。

4. Non-local Means:让相似的块互相组队

均值、高斯这类局部滤波之所以在纹理区域会翻车,是因为它们只敢用空间上的近邻像素来平均。但图像里其实存在大量重复结构:一片草地、一堵砖墙、一段栅栏,这些区域里的纹理模式会在不同位置反复出现。NLM的核心想法就是,把这些“长得像”的像素块也拉过来参与平均,即使它们在空间上隔得很远。这就像你在朋友圈找审美好的人,而不是只问隔壁邻居,结果自然靠谱得多。

4.1 NLM权重公式的直觉拆解

NLM对每个像素i的输出是搜索窗口内所有像素j的加权平均,权重w(i, j)由两个像素各自周围的图像块决定:

w(i, j) = exp(-||v(N_i) - v(N_j)||² / h²)

其中N_i是以像素i为中心的一个小图像块,v(N_i)是这个块的所有像素值构成的向量,两个向量差的平方和就是块之间的欧氏距离平方,h是控制权重大小的滤波强度参数。关键在于:两个像素离得远没关系,只要它们周围的块长得像,权重就大;如果块结构完全不同,哪怕两像素挨在一起,权重也接近0。

生活化的比喻是“找同款”:假如你在拼一幅拼图,某个碎片缺了,你不会只看旁边碎片,还会翻遍整个盒子找图案相近的碎片。NLM就是干这个事。

4.2 三个核心参数:搜索窗、块尺寸、滤波强度

NLM的参数比局部滤波多,但核心就三个。搜索窗口的半宽r,决定每个像素最多能参考多大范围内的其他像素,经典推荐值是r=10,也就是21×21的搜索范围。块半宽f,决定用多大的图像块去判断“长得像”,经典推荐值是f=2或3,对应5×5或7×7的块。滤波强度h,直接出现在指数项里,它控制权重的衰减速度,h越大越多的像素获得较高权重,图像越平滑;h越小只有高度相似的块才有资格参与平均,噪声残留越多。

h的取值经验,我一般是先用噪声标准差σ作粗略估计,再微调。如果噪声标准差是25,h先取15到20观察效果;若图像还有细节但噪声没去干净,就把h往小调;若图像变糊但没有明显改善,就把h往大调。注意h跟图像灰度范围有关,如果图像不是0到255而是0到1的浮点图,h也要按比例缩放,否则效果天差地别。

4.3 NLM为什么慢,以及怎么加速

NLM慢是出了名的。它的复杂度大概是O(M×N×(2r+1)²×(2f+1)²),假设512×512图像,r=10,f=2,这意味着每个像素要计算21×21=441次块距离,每次块距离要算25对像素差的平方和。几百万像素乘以几百次运算,纯Python写出来能跑好几分钟,实际是不可接受的。不过,工程上有几条可行路线。

第一是剪枝加速:计算块距离时设一个阈值,一旦累计误差超过阈值就提前终止,略过那些明显不相似的块。第二是积分图加速:把块间的平方差距离展开成积分图可查询的形式,把每次计算的复杂度从块大小降为常数。第三是缩小搜索范围:r从10降到5,速度提升4倍,代价只是略微降低去噪质量。第四是用一些官方库的快速实现,它们的底层做了向量化,速度比自己写的Python快很多。

我的经验是:小尺寸图像或者单张调试时,用纯实现来分析参数;真正要跑批量数据,优先用加速版本,再不行就考虑先下采样处理再上采样,或者用分块并行。NLM不是不能用于实际项目,而是要用对场合。

5. 四种算法同场实测:从可视化到量化指标

原理讲得再多,不如直接跑一张图看效果。我建议所有的测试都固定一套量化指标,PSNR看像素误差,SSIM看结构相似度。下面的实验脚本可以直接复制去跑,测试图建议选用有纹理有边缘的灰度图,否则看不出去噪算法的真实差距。

5.1 搭建一个完整的对比脚本

import cv2 import numpy as np img = cv2.imread("test.png", cv2.IMREAD_GRAYSCALE).astype(np.float64) # 加入高斯噪声,标准差25 noise = np.random.normal(0, 25, img.shape) noisy_gauss = np.clip(img + noise, 0, 255).astype(np.uint8) # 加入椒盐噪声,密度5% noisy_sp = img.copy().astype(np.uint8) mask = np.random.rand(*img.shape) < 0.05 noisy_sp[mask] = np.random.choice([0, 255], mask.sum()) noisy_sp = np.clip(noisy_sp, 0, 255).astype(np.uint8) # 四种滤波 blur3 = cv2.blur(noisy_gauss, (3, 3)) median5 = cv2.medianBlur(noisy_gauss, 5) gauss5 = cv2.GaussianBlur(noisy_gauss, (5, 5), 1.8) nlm = cv2.fastNlMeansDenoising(noisy_gauss, None, h=15, templateWindowSize=7, searchWindowSize=21) # 计算PSNR def psnr(a, b): mse = np.mean((a.astype(np.float64) - b.astype(np.float64)) ** 2) return 10 * np.log10(255.0 ** 2 / mse) print("高斯噪声PSNR:", psnr(img, noisy_gauss)) print("均值3x3:", psnr(img, blur3)) print("中值5x5:", psnr(img, median5)) print("高斯5x5 sigma=1.8:", psnr(img, gauss5)) print("NLM h=15:", psnr(img, nlm))

我在同样的测试图上跑过一组典型结果:加高斯噪声后原始含噪图的PSNR约20.2;均值3×3约26.5;中值5×5约26.8;高斯5×5、σ=1.8约27.1;NLM h=15约28.4。如果你跑出来的数值跟这组对不上,不用紧张,不同测试图的结果本来就有波动,重点关注的是相对大小关系和视觉差异。

5.2 不同噪声场景下的算法匹配

还是同一张测试图,把噪声换成5%密度的椒盐噪声之后,结果就完全变脸了。均值3×3对椒盐噪声的PSNR只有25左右,而且画面上还能看到明显的黑白团块;中值3×3能把PSNR拉到33以上,5×5甚至能到35以上;高斯滤波和NLM在椒盐噪声上的表现都不如中值。这个对比充分说明,脱离噪声类型谈算法优劣就是耍流氓。

实际项目里还有个常见场景:含噪图是彩色图,处理办公室拍照偏暗、手机夜景图。这时候NLM支持彩色多通道处理,但要注意把彩色图转成合适色彩空间再滤波,否则通道之间的噪声互相干扰会出现色斑。我的习惯是先转到亮度和色度分离的空间,只对亮度通道做较强的去噪,色度通道做较轻的平滑,画质提升明显看不见色块。

5.3 调参顺序应该怎么走

我给新人总结了一套调参顺序,基本能省下大量重复试错的时间。先看直方图和残差,确定噪声类型;再用小窗口、小强度开始滤波;然后逐步增大强度,每调一次都算一次PSNR/SSIM,并同时目测细节区域;一旦PSNR开始下降或细节开始变糊,立刻回退到上一组参数。整个过程应该控制在10到15分钟内,而不是一次一次瞎猜。

最后别忘了保存中间结果图。PSNR只能反映数值差距,真正决定能不能用,还得看边缘、文字边缘、小物体这些局部位置有没有糊。我在做文档OCR前处理时就被NLM坑过一次:PSNR很高,但笔画边缘被过度平滑,识别率反而下降,验证了“指标好看不等于结果好用”。

6. 常见问题速查与排查技巧实录

下面这些问题是我在实际项目里反复遇到并排查过的,按现象整理成一张速查表,方便你对照。

现象可能原因排查与解决
滤波后图像边缘出现黑边卷积边界默认补零改为BORDER_REFLECT或BORDER_REPLICATE
椒盐噪声去不干净窗口太小,密度太大增大中值窗口,或先小窗口多次处理
中值滤波把细线目标抹掉窗口过大超出目标宽度先用3×3,不够再逐步增大
高斯滤波后还有周期性条纹噪声不是纯高斯,可能混有周期性干扰考虑频域滤波或带阻滤波
NLM处理很慢搜索窗口过大,块距离计算太重缩小r,开启多线程,或用积分图加速
NLM把平滑区域搞出花纹h太小或块尺寸太小增大h,适当增大块尺寸
h调大后图像整片模糊滤波强度过高回退h,用残差图检查是否过平滑
彩色图滤波后出现色斑直接对RGB三通道分别滤波转到亮度色度分离空间,只对亮度通道做强滤波

这里重点说说残差检查这个方法。残差就是含噪图减去滤波图,也就是算法认为被滤掉的那部分。理想情况下,残差应该像随机噪声,看不出任何图像结构。如果你发现残差图里能隐约看到边缘轮廓或纹理结构,说明滤波器已经把有用的信号也当噪声滤掉了,参数肯定过大。这个技巧可以说是我排查去噪参数问题的第一工具。

还有一个很多人容易忽略的点:不同算法对噪声方差的敏感程度不一样。均值和线性的高斯滤波对高斯噪声的方差有明确的数学关系,窗口扩大N倍,输出噪声方差约降为1/N;中值和NLM则没有这么漂亮的公式,只能靠实验标定。所以项目里如果噪声强度会动态变化,比如视频监控在不同光照下切换,建议处理前先做一次实时噪声估计,动态切换滤波参数,而不是一套参数打天下。

写在最后的几个实操体会

我做去噪也踩过不少坑,最大的体会是:不要迷信算法越高级效果越好,也不要迷信某个参数是“万能值”。NLM在多数高斯噪声场景下确实全面领先,但对椒盐噪声反而打不过一个朴素的中值滤波;在实时视频流里,NLM又贵得用不起,老老实实上高斯加中值的组合更实用。参数方面,任何固定推荐值都只是起点,真正靠谱的做法是看残差、看细节、算指标三管齐下。最后再分享一个小技巧:处理前后对比图一定要用同一套灰度映射或者同一段剪辑区域,否则人眼很容易被亮度差异骗过去,做出错误的调参判断。希望这篇文章能帮你少走一些弯路,下次碰到去噪需求,能底气十足地选对算法、调好参数。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进