简介这份代码包围绕非局部均值图像去噪方法展开适合数字图像处理学习者、算法研究人员以及需要为红外图像或含噪图片降噪的工程师。压缩包共8个文件包含4个M函数与脚本、2张PNG效果图、2张BMP灰度测试图整体约572KB。代码与测试图像分离运行后可直接对比原图与去噪结果便于观察算法效果。资源覆盖块匹配、相似度度量、加权平均等关键环节提供多个实现版本并可通过调整搜索窗口、块大小及滤波参数体会去噪强度与细节保留之间的平衡理解全局相似性权重计算原理及其加速策略。目前已有267人学习下载适合希望从原理到代码完整掌握非局部均值去噪的入门与进阶用户也可作为相关课程设计与工程应用的参考实现。 写博文这件事我最怕两种内容一种是贴一堆公式把人劝退另一种是把参数说得像魔法。非局部均值Non-Local MeansNLM图像去噪恰好夹在中间——它有一个特别优雅的直觉但又藏着不少实现上的细节坑。最近看到图像去噪又成了热门词大家讨论的是像LANLearning to Adapt Noise这类让模型自适应噪声水平的新方法。这类方法上限确实高但我还是想先把NLM讲透因为不管你是入门图像处理还是手头只有一台CPU机器想快速做个去噪基线NLM都是绕不开的经典。这篇文章我会从我自己的复现经历出发把NLM的原理、代码、参数调优和踩坑过程一起聊一遍最后再聊聊它和学习类方法怎么选。1. 先搞清楚非局部均值解决的是去噪里的哪个问题1.1 为什么均值滤波会糊我们先把最朴素的问题摆出来一幅图像被高斯白噪声污染之后最直觉的恢复思路是什么加权平均。既然每个像素的真实值被噪声干扰了那我把周围几个像素的值平均一下噪声正负抵消真实信号保留听起来很合理。这就是均值滤波的全部逻辑。但均值滤波有个致命问题它只认空间距离近的像素不认内容。边缘两侧的像素明明属于完全不同的物体仅仅因为挨得近就被强行拉到一个平均值里。所以均值滤波跑完噪声是轻了边缘也花了。双边滤波意识到了这一点给空间邻近的像素增加了一个强度相似度的约束效果好了很多但它比较的只是单个像素的灰度差。单像素值受噪声影响波动非常大一个像素被噪声一打从100变成了140那它在双边滤波里就和旁边的120像素完全不相似了这导致双边滤波在高噪声下保边能力也明显下降。1.2 非局部三个字的分量NLM这个名字里最关键的词是非局部。之前的滤波方法都只敢在像素周围的一个小邻域里找帮手而NLM的观察是自然图像里充满了重复结构。一面砖墙的纹理一片草地的细节一块皮肤表面的明暗变化这些东西在图像的不同位置反复出现相距可能几十上百个像素。既然如此为什么非要找邻居在整个图里找长得像的块不就行了这就是非局部均值的核心动作对每个像素不是看它附近的像素而是在一大片搜索区域内寻找与它所在图像块相似的块然后用这些相似块的中心像素值做加权平均。也就是说远处的相似砖块也可以帮你把这个像素的噪声平均掉而不会抹掉边缘和纹理。我第一次理解到这个思路的时候确实有眼前一亮的感觉——它把去噪从局部平滑问题重新定义成了一个相似性搜索加权平均的问题。2. 自相似假设下的权重公式拆解2.1 从一块一块找相似说起要判断两个像素是否同属一类不能只看灰度值要看它们周围的上下文。NLM的做法是以像素i为中心取一个小邻域块比如5x5同样以像素j为中心取一个5x5块然后计算这两个块之间的加权欧氏距离。距离小说明这两个像素处于相似的局部结构中j可以信任距离大说明它们是两码事那j的像素值对i没有参考价值。这就像你在人群里找人只看脸可能认错但是看衣服、背包、体型这些组合特征认错概率就低很多。单个像素是脸邻域块就是整体特征。2.2 权重公式与直觉NLM的权重公式长这样w(i, j) (1 / Z(i)) * exp(-|| v(N_i) - v(N_j) ||² / h²)其中v(N_i)表示以i为中心的块内像素值向量||·||²表示两个向量之间的高斯加权欧氏距离平方h是控制权重衰减速度的参数Z(i)是对所有j归一化的系数。高斯加权欧氏距离的意思就是离块中心越近的像素对相似度判断的影响越大这比均匀加权更贴近视觉直觉。这个公式展开来看很直观。距离越小指数项越接近1权重越大距离越大指数项快速趋近0权重越小。h则是这个衰减曲线的尺度h越大曲线越平缓远处不那么相似的块也能分到一点权重h越小曲线越陡峭只有高度相似的块才会参与平均。2.3 为什么块距离优于像素距离这里我多说一句为什么不是直接比较像素。单个像素的差值里噪声占了很大一部分。一个真实差值为5的像素对在噪声标准差为10的情况下观测差值可能变成25导致算法误判它们不相似。但块距离对噪声是鲁棒的因为块内有多达25个像素每个像素上的噪声起伏在求和过程中部分抵消真实结构差异才能显露出来。而且块距离的计算天然做了结构匹配一个平坦区域的块和另一个平坦区域的块距离很小即使它们相隔很远一个边缘块只会和同方向的边缘块距离小。这就是NLM能保边的底层原因——它匹配的是结构不是孤立的强度值。3. 一份可以直接跑的NLM实现3.1 教学版思路与代码原理讲完必须上手。我这里先给一个最容易看懂的版本它的思路和公式一一对应适合做学习理解。import numpy as np def nlm_denoise_simple(image, patch_radius2, search_radius10, h10.0): h_img, w_img image.shape pad patch_radius search_radius padded np.pad(image, pad, modereflect).astype(np.float64) denoised np.zeros_like(image) weight_sum np.zeros_like(image) for i in range(h_img): for j in range(w_img): # 当前块 center_block padded[i:i 2*patch_radius 1, j:j 2*patch_radius 1] weights np.zeros((2*search_radius 1, 2*search_radius 1)) for dx in range(-search_radius, search_radius 1): for dy in range(-search_radius, search_radius 1): # 搜索窗口内的候选块 ni i pad dx nj j pad dy cand_block padded[ni:ni 2*patch_radius 1, nj:nj 2*patch_radius 1] # 高斯加权块距离 diff (center_block - cand_block) ** 2 dist np.sum(diff) weights[dx search_radius, dy search_radius] np.exp(-dist / (h ** 2)) # 归一化权重加权平均 weights / np.sum(weights) for dx in range(-search_radius, search_radius 1): for dy in range(-search_radius, search_radius 1): denoised[i, j] weights[dx search_radius, dy search_radius] * \ padded[i pad dx, j pad dy] return denoised这段代码很容易读懂但跑一张512x512的图会等到怀疑人生。三层循环嵌套中间还有两个内部循环计算量在数十亿次级别属于只可意会不可运行的教学代码。3.2 向量化优化后的可用版本实际问题还得靠向量化。NLM的块距离可以这样拆解对搜索区域内的每个偏移(dx, dy)计算整幅图所有像素与它偏移后像素的差的平方再用一次box滤波得到以每个像素为中心的块内距离均值最后统一做指数加权。这是一个标准的平移-差平方-局部累加流程numpy和scipy可以直接接住。import numpy as np from scipy.ndimage import uniform_filter def nlm_denoise_fast(image, patch_radius2, search_radius10, h10.0): h_img, w_img image.shape patch_size 2 * patch_radius 1 pad patch_radius search_radius padded np.pad(image, pad, modereflect).astype(np.float64) denoised np.zeros_like(image) weight_sum np.zeros_like(image) for dx in range(-search_radius, search_radius 1): for dy in range(-search_radius, search_radius 1): shifted padded[pad dx:pad dx h_img, pad dy:pad dy w_img] diff (image - shifted) ** 2 # 块内距离平方的均值再乘块面积还原为求和 block_dist uniform_filter(diff, sizepatch_size) * (patch_size ** 2) w np.exp(-block_dist / (h ** 2)) denoised w * shifted weight_sum w denoised / np.maximum(weight_sum, 1e-12) return denoised这个版本和前面教学版的数学意义基本一致只是把逐像素处理换成了整幅图矩阵运算。512x512的图像search_radius取10时总共441次偏移每次做一次uniform_filter在我的机器上大约两三秒跑完实用价值是够的。注意这里我用的是box滤波近似均匀加权没有上高斯权重核。实际使用中这种近似带来的差异很小但性能提升非常明显。如果你追求严格的高斯加权可以用scipy.ndimage.gaussian_filter代替uniform_filter效果会更贴近原始论文速度略有下降。3.3 彩色图像怎么处理彩色图的处理有个容易被忽略的坑。很多新手直接把RGB三个通道分别跑NLM结果出来的图经常有颜色斑块看起来脏脏的。正确做法是转换到YCbCr颜色空间只对亮度通道Y做NLM去噪色度通道Cb和Cr保留原样或者做极轻的平滑。人眼对亮度细节最敏感对色度细节不敏感这样做既保住了主要细节又避免了彩通道串扰产生的伪彩速度还快了将近三倍。from skimage.color import rgb2ycbcr, ycbcr2rgb def denoise_color_rgb(image_rgb, patch_radius2, search_radius10, h10.0): ycbcr rgb2ycbcr(image_rgb) y ycbcr[:, :, 0] y_denoised nlm_denoise_fast(y, patch_radius, search_radius, h) ycbcr[:, :, 0] y_denoised return ycbcr2rgb(ycbcr)如果你的输入是0-255范围的uint8记得先转成float64再处理输出时再clip回0-255。很多奇怪的人工痕迹其实都是类型转换或者越界截断造成的。4. 三个参数其实是联动的一套体系4.1 h唯一的强度旋钮NLM最核心的参数就是h它直接对应噪声水平。经验公式是h约等于10到15倍的噪声标准差sigma。假设噪声标准差是10那h取100到150看似合理但注意我这里说的是像素值范围0-255的情况。如果图像被归一化到0-1范围同样的噪声场景h应该缩小255倍变成0.4到0.6。很多人参数怎么调都不对就是因为没注意这个量纲问题。h太小时指数衰减过快除了像素自己和极少几个极其相似的块其他加权全是0结果几乎没去噪。h太大会让那些结构并不相似的块也参与平均图像会出现一种塑料感——细节被平均掉纹理糊成一片。我通常的调法是先估计噪声sigma可以用Laplacian算子做简单估计然后从0.6倍sigma对应像素范围开始试往大调到细节刚好不糊为止。4.2 search_radius范围与时间的直尺search_radius决定了每个像素往多远去找相似块。原始论文里用21即搜索窗口为43x43像素覆盖1849个候选位置。范围越大找到高相似块的机会越多但计算量按平方上升。search_radius从10增加到20时间不是翻倍而是约四倍。实际使用中search_radius10到15就能覆盖大多数自然图像的自相似结构。砖墙、草地、织物这类重复纹理10像素半径能找到足够的相似块而一些只在大范围内重复的结构比如远处的栅栏才需要更大的搜索范围。如果只是为了快速出一个效果图或跑一个基线我建议从10开始。4.3 patch_radius细节与鲁棒的平衡patch_radius是块半径常用取2即5x5的块。块越大对噪声的鲁棒性越强因为参与距离计算的像素更多、噪声平均效应更明显但代价是容易把精细结构匹配错位导致细节被抹平。块太小比如3x3甚至1x1距离判别就退化成接近像素级比较抗噪能力下降。我的经验是低噪声sigma 15用patch_radius1或2保持细节高噪声sigma 30用patch_radius3更稳。纹理丰富的区域宁可小块别大块否则会把纹理整体磨成模糊的一片。4.4 一张参数效果速查表这里我把一些关键取值的效果倾向整理成表方便对照参数变化效果倾向性能影响h偏小去噪不足噪声残留无h偏大过度平滑细节丢失出现塑料感无search_radius偏小平滑不足有块状噪声残留明显加速search_radius偏大平滑更充分重复纹理利用更好明显变慢patch_radius偏小细节保留好但抗噪弱轻微提速patch_radius偏大抗噪强但易模糊细节轻微变慢这表格看着简单背后的逻辑是h决定敢不敢信远距离块search_radius决定去哪里找块patch_radius决定判断相似的精度标准。调参的时候不要单独拧一个旋钮先定patch_radius再按噪声定h最后根据耗时压力和效果加search_radius。5. 性能瓶颈与几个容易被坑的细节5.1 复杂度到底有多高NLM被诟病最多的就是慢。它的理论复杂度是O(N * S * P)N是像素数S是搜索窗口面积P是块面积。512x512图search_radius10patch_radius2N是26万S是441P是25这还没有算上Gaussian权重原始朴素实现的运算量高达28亿次左右纯Python压根扛不住。出路有三条一是向量化就像前面代码里那样把像素层循环合并成矩阵运算二是缩小搜索窗口用时间和质量的tradeoff换速度三是硬加速工具比如用numba把朴素循环编译成机器码或者用积分图预计算块间距离能把耗时从分钟级压到秒级。工程上还有更狠的做法只对图像的一部分像素做全搜索其余像素用邻域随机采样替代效果损失很小速度翻几倍。5.2 边界处理和NaNNLM处理图像边界时搜索窗口会越界。新手最常犯的错误是直接裁掉边界像素导致去噪后图像四周有一圈明显的未处理区域。正确做法是用padding把图像向外扩一圈扩的尺寸至少是patch_radius加search_radius。padding模式我推荐reflect反射填充在纹理接续上最自然constant零填充会在边界形成一条黑边wrap则适合周期性内容。另一个隐蔽的坑是权重之和为零。在均匀区域中这几乎不可能但在图像四角或者内容非常特殊的局部区域如果所有候选块的距离都极大指数项全部下溢为0归一化时除以0就会出现NaN。代码里我用了np.maximum(weight_sum, 1e-12)作为保护这是必须的不是锦上添花。5.3 像素取值范围不一致导致的白白翻车这个坑很典型。同一张图有人用plt.imread读出来是0到1的float有人用cv2.imread读出来是0到255的uint8。如果你的h是照着0到255范围调好的输入变成0到1之后同一个h值相当于被缩小了255倍指数项几乎全是exp(0)等于1去噪直接退化成全图均值滤波。我调试时翻过好几次车最后养成了习惯处理前先打印图像数组的dtype和最大最小值确认像素范围后再决定h的量级。这是所有图像算法调试的基本功。6. NLM与学习类去噪的选型思考6.1 热词里的方向变化最近图像去噪的热词里频繁出现噪声自适应这类方向比如LANLearning to Adapt Noise核心思路是让网络根据输入图像预估当前噪声水平并动态调整去噪策略。这类方法的优势在于真实世界的噪声往往不是单纯的高斯白噪声而是传感器噪声、处理噪声、压缩噪声的混合传统方法很难用一个固定sigma模型覆盖这类复杂噪声而学习到的噪声适配模型在实测中确实更抗打。学习类方法的优势还体现在推理速度和效果上限。DnCNN、FFDNet、RIDNet这些模型训练好之后用GPU推理一张图只需要几十毫秒比NLM快几个数量级而且PSNR和主观质量都明显占优。但代价是你需要数据集需要训练需要GPU还需要在已知噪声类型和真实噪声之间反复试错。没有这些条件的场景比如刚入门的学生、离线环境、嵌入式设备学习类方法并不友好。6.2 什么场景继续用NLM我的观点是NLM现在最宝贵的价值不是去噪效果最强而是数学透明、行为可预期、零训练开销。当我想快速验证一个图像处理pipeline里去噪模块的作用时NLM是最快的基线当我在只有CPU的环境里处理中尺寸图像时NLM是一个性价比很高的选择当我想给学生讲清楚相似块搜索这个概念时NLM是比任何深度学习模型都好的教材。如果你面对的是真实摄影噪声、手机夜景、视频监控这类场景我更推荐直接上学习类方法比如LAN或者DnCNN系列的预训练模型。但即便用这些方法理解NLM的自相似性假设依然有用因为很多学习类方法的内核仍然在隐式地寻找并融合相似结构只是把手工找相似变成了网络学找相似。回到实际选择我给自己定了一个很简单的标准能拿到干净训练数据且时间充裕用学习类只想快速得到一个可解释的基线结果或者身边没有GPU用NLM。两者不是取代关系而是不同阶段的工具。最后分享一个我自己的小习惯不管用哪种方法去噪我都会保留一张去噪前后的差异图看看被滤掉的是不是真的只有噪声。如果差异图里出现了明显的边缘或纹理轮廓说明去噪强度过大原始细节被误伤了——这个验证方法对NLM和深度模型都适用。本文还有配套的精品资源点击获取
