BM3D降噪代码详解:从块匹配到维纳滤波的经典图像去噪方案

发布时间:2026/10/16 15:01:23
BM3D降噪代码详解:从块匹配到维纳滤波的经典图像去噪方案
简介BM3DBlock-Matching and 3D filtering是一种经典的图像去噪算法这份C语言源码实现面向图像处理学习者与研究人员可用于去除高斯噪声和椒盐噪声并理解块匹配、三维滤波、硬阈值/软阈值及反变换等核心流程。资源包共13个文件包含5个头文件、4个C源文件、2个C源文件、1个Makefile和1个说明文档其中bm3d.cpp实现主体算法main.cpp负责读图与结果保存utilities与lib_transforms模块提供辅助函数和DCT/DWT变换io_png.c用于PNG格式读写结构完整、便于编译调试。已有494人学习下载。通过研读这份代码读者可掌握BM3D从相似块匹配到三维协同滤波的完整实现细节并了解图像处理程序的设计组织方式在此基础上还可借助OpenMP或CUDA进行并行加速或针对不同噪声水平调整块大小与滤波强度为后续算法改进和工程落地打下基础。1. 为什么到了现在你还会搜“bm3d降噪代码”你搜“bm3d降噪代码”大概率不是想追新算法而是手头正卡着一张满是噪点的图晚上拍的室内照片、扫描的老照片、监控视频抽出来的一帧。深度学习降噪模型这些年确实猛但要找一套能离线跑、不吃显卡、不依赖训练数据的方法BM3D 几乎永远是首选。它能在没有干净参考图的情况下把高斯白噪声压下去几个量级同时保住边缘和纹理这是普通均值滤波、中值滤波、甚至双边滤波都做不到的。适合谁做图像预处理的人、做传统视觉流水线的开发者、需要用经典基线给深度学习方法做对比的研究者。直接给结论它不是最快的也不是最时髦的但它是“参数从零开始调整时最靠谱的基线”。2. 把 BM3D 拆开看块匹配和协同滤波背后的核心逻辑2.1 单张图降噪的瓶颈在于“没地方借信息”单张图像降噪最难的不是“怎么把噪点抹掉”而是“怎么区分真实结构和随机噪声”。均值滤波的做法是把一个像素周围 3x3 或 5x5 的像素平均优点是快代价是边缘也被平均了中值滤波能保护边缘但对高斯噪声的效果远不如对椒盐噪声双边滤波把空间距离和灰度差都考虑进去终于保住边缘了但噪声稍强一点它就会把噪点误判成边缘结果该滤的没滤掉。BM3D 换了个思路噪点是独立随机量真实图像里却充满重复结构。树叶、砖墙、布料、皮肤纹理都是重复图案。如果把一整幅图上“长得像”的小块找出来堆成一个三维栈再拿到频域里一起滤波等于拿几十次独立观测去平均同一个信号。真实结构在每个块里都有变换域里留下的是大系数保留噪声在每个块里独立跳动变换域里系数小清掉。这就是“块匹配 协同滤波”的基本逻辑。和它思路最像的前身是 NLM非局部均值但 NLM 是直接在空间域加权平均BM3D 多做了“变换域协同收缩”这一步所以对噪声的压制效率高出一截。2.2 第一步硬阈值怎么找相似块、怎么堆叠、怎么滤波第一步在 BM3D 里叫基础估计也叫硬阈值阶段。整个流程可以简化成四步。第一对图像里的每一个参考块常见 8x8在它周围一个搜索窗口里找距离最近的若干个块。距离用块间欧氏距离同时加一个正则项避免距离太近的块被重复选中。假设搜索窗口是 16x16在这个窗口内滑窗找最接近的 8 到 16 个块。第二把这些相似的块堆成一个三维数组。三个维度分别是块的高度、宽度、块的数量。注意这里堆叠的不是像素值而是“同一空间位置的相似块”所以每个块的相对位置必须对齐匹配时要做平移对齐。第三对这个三维数组做 3D 变换。常见组合是块内做二维 DCT、块间做一维 Haar 变换。然后在变换系数上做硬阈值绝对值小于阈值的系数直接置零。阈值一般取 2.7 倍 sigmasigma 是噪声标准差。这个动作是噪声去除的关键置零的那部分系数本来就是噪声主导的。第四逆变换回空间域把每个块的估计值放回原图位置。因为每个像素会被多个块覆盖最后用加权平均聚合权重取该块经过硬阈值后系数非零的个数。非零系数多表示这个块含有更多结构信号权重就应该大一点。伪代码如下。# BM3D 第一步硬阈值基础估计流程示意 def step1_hard_threshold(noisy, sigma, block_size8, search_window16, max_matches8): # 1. 遍历参考块 for each reference_patch in noisy_patches: # 2. 在 search_window 内找欧氏距离最近的 max_matches 个相似块 matches find_similar_patches(reference_patch, search_window, max_matches) # 3. 堆叠成 3D 数组 stack_3d np.stack([reference_patch] matches, axis-1) # 4. 3D 变换2D DCT 1D Haar coef dct_2d_then_haar_1d(stack_3d) # 5. 硬阈值阈值取 2.7 * sigma小于该值直接置零 coef[abs(coef) 2.7 * sigma] 0 # 6. 逆变换、按非零系数数量加权、写回原图 filtered_patch invert_transform(coef) write_aggregated(filtered_patch) return basic_estimate这段伪代码不是某个库的源码是帮你理解内部流程。真正落地时你会发现块匹配基本会在整张图范围做不会真的逐块单独跑还要维护一个权重矩阵记录每个像素被多少块覆盖。这些细节在后面的代码章节再展开。这一步做完你会得到一个比原图干净、但看起来有点“软”的基础估计。它的问题是边缘和细纹理有一些损失轻微的振铃也可能出现。真正的质量提升在第二步。2.3 第二步维纳滤波为什么说它是 BM3D 的精华第二步叫最终估计用的滤波器是维纳滤波。它把第一步得到的基础估计当成一张“干净的引导图”重新做一次块匹配。因为基础估计已经很干净这次匹配到的块会比第一次更准数量也可以选得更多堆出来的三维栈更有统计学意义。接着分别在噪声图和基础估计上做同样的三维变换。用基础估计的变换系数计算一个维纳收缩系数每个系数的平方除以“系数的平方加上噪声方差”。这个系数接近 1 就保留接近 0 就压掉。再把这个系数乘到噪声图的变换系数上做逆变换。这一步比硬阈值聪明的地方在于它不是一刀切地“小于阈值就归零”而是按信噪比连续收缩强结构基本保留弱结构部分保留噪声几乎归零。我见过不少人说“BM3D 不就是跑个硬阈值嘛”其实这是误解。单独跑第一步和跑完整两步在 PSNR 上通常能差 0.2 到 0.5 dB视觉上则是“干净但有点糊”和“干净且纹理自然”的差别。BM3D 能在经典算法里站十几年很大程度上靠的是第二步的维纳滤波而不是第一步的硬阈值。理解这一点你后面调参数时就会明白为什么有时候第一步阈值可以温和一点把细纹理留到第二步去修复。3. 跑通 BM3D 降噪代码两个方案十几行命令搞定3.1 方案一用 OpenCV 的内置接口一条命令跑完如果你只是想先把流程跑通OpenCV 的 xphoto 模块里就有现成的 BM3D 接口。它把两步全封装好了输入一张浮点图像和噪声标准差输出降噪图。先装依赖。这里有一个高频翻车点opencv-python 这个包不带 xphoto 模块必须装 opencv-contrib-python否则 import cv2.xphoto 会直接报错。pip install opencv-contrib-python numpy下面是能直接跑的最小代码。import cv2 import numpy as np # 1. 读图转成灰度 img cv2.imread(noisy_demo.png, cv2.IMREAD_GRAYSCALE) if img is None: raise ValueError(noisy_demo.png 路径不对或文件不存在) # 2. 转成 float64 并归一化到 0~1后续处理更稳 img_f img.astype(np.float64) / 255.0 # 3. 噪声标准差先用固定值演示实际需要估算 sigma 0.05 # 4. 调用 BM3D。h 是滤波强度与 sigma 联动调整 denoised cv2.xphoto.bm3dDenoising(img_f, h1.0, templateWindowSize8, searchWindowSize16) # 5. 转回 8-bit 存盘 denoised_u8 np.clip(denoised * 255.0, 0, 255).astype(np.uint8) cv2.imwrite(denoised_opencv.png, denoised_u8) print(done)这段代码的逻辑说明h 控制整体滤波强度值越大越平滑工程上通常从 1.0 开始在 0.8 到 1.5 之间微调。templateWindowSize 是参考块边长固定 8 是常见选择searchWindowSize 是搜索窗口16 和 32 都有人用窗口越大匹配块的选择越多耗时也越久。如果你只传图像本身接口会跑默认参数但默认参数只对中等噪声管用。跑通之后建议立刻改成上面的显式写法方便后面调参有个基准。3.2 方案二用 bm3d Python 包把两步拆开看效果OpenCV 接口封装得太干净你甚至看不到它跑的到底是几步。想分别看硬阈值和维纳滤波的效果可以用 Python 生态里常见的 bm3d 包。它的底层实现比较贴近原论文安装命令如下。pip install bm3d调用代码看数据。import bm3d import cv2 import numpy as np # 1. 读图转 float64 / 255 img cv2.imread(noisy_demo.png, cv2.IMREAD_GRAYSCALE) img img.astype(np.float64) / 255.0 # 2. 噪声标准差先按已知的 0.08 演示 sigma_psd 0.08 # 3. 只跑第一步硬阈值估计 basic bm3d.bm3d(img, sigma_psd, stage_argbm3d.BM3DStages.HARD_THRESHOLD) # 4. 跑完整两步硬阈值 维纳滤波 denoised bm3d.bm3d(img, sigma_psd, stage_argbm3d.BM3DStages.ALL_STAGES) # 5. 保存对比 cv2.imwrite(basic_only.png, np.clip(basic * 255, 0, 255).astype(np.uint8)) cv2.imwrite(all_stages.png, np.clip(denoised * 255, 0, 255).astype(np.uint8))这段代码的逻辑说明bm3d.bm3d 的第一个参数是图像支持灰度图、彩色图也支持带通道数的三维数组。sigma_psd 可以传一个标量代表白噪声标准差也可以传一个与图像同尺寸的噪声功率谱工程上先传标量就够用。stage_arg 是这个包最值得试的参数传 HARD_THRESHOLD 只看第一步传 ALL_STAGES 看完整效果。对比两张输出图你就能直观感受到维纳滤波到底带来了多少增益。3.3 效果别靠肉眼PSNR 和 SSIM 怎么算调参只靠截图判断“好像干净了”非常不靠谱。建议先把客观指标搭起来再根据指标方向去调参。PSNR 看像素级误差SSIM 看结构相似度两个一起用才不容易被单一指标骗了。from skimage.metrics import peak_signal_noise_ratio as psnr from skimage.metrics import structural_similarity as ssim # 假设有干净图 clean.png 和降噪图 denoised_all.png clean cv2.imread(clean_demo.png, cv2.IMREAD_GRAYSCALE).astype(np.float64) / 255.0 denoised cv2.imread(denoised_all.png, cv2.IMREAD_GRAYSCALE).astype(np.float64) / 255.0 # data_range 必须和输入范围一致这里是 0~1 p psnr(clean, denoised, data_range1.0) s ssim(clean, denoised, data_range1.0) print(fPSNR: {p:.2f} dB, SSIM: {s:.4f})这两个指标要配合着看。同一组参数如果 PSNR 涨了 0.3 dB 但 SSIM 掉了 0.01说明算法为了压噪把纹理细节洗掉了这时候应该把 sigma 调小一点。反过来SSIM 涨了不少但 PSNR 没动说明纹理保住了可以试着加大 sigma 压得更狠。还有一个容易被忽略的点对比不同算法或不同参数时噪声图必须用同一个随机种子生成否则噪声本身的随机波动会直接干扰指标排名。4. 关键参数详解sigma、块大小、搜索窗口、步长怎么调才不糊4.1 噪声标准差 sigma把这个估准调参就成功了一半BM3D 内部几乎所有阈值都和 sigma 挂钩。你传 0.08它就按 0.08 的倍数定阈值实际噪声只有 0.03它就会把真实细节当噪声清掉图像变成塑料。反过来实际噪声是 0.2你给的阈值不够降噪就只降了皮毛。所以第一步永远是估计 sigma。工程上最常用的是 MAD中值绝对偏差法核心代码如下。import numpy as np from scipy import ndimage def estimate_sigma(img_gray): # img_gray: float64, 范围 0~1 # 用 3x3 拉普拉斯核对图像做卷积响应大部分来自噪声 lap ndimage.laplace(img_gray) # MAD median(|lap - median(lap)|)除以 0.6745 做无偏校正 sigma np.median(np.abs(lap - np.median(lap))) / 0.6745 return sigma noisy_f cv2.imread(noisy_demo.png, cv2.IMREAD_GRAYSCALE).astype(np.float64) / 255.0 print(festimated sigma {estimate_sigma(noisy_f):.4f})这段代码的问题是拉普拉斯响应在纹理强的区域会产生大的真实结构响应把 sigma 估大。解决办法有两个先对图像做一次极轻的均值滤波再去算 MAD或者只挑图像里最平坦的一块区域来算比如天空、墙面。如果对着一张全是树叶的照片算估计值可能偏高一倍。别太迷信这个数字它只是调参起点最终还要按效果微调。4.2 块大小与搜索窗口细节保留和计算量的直接权衡块大小决定了算法感知“局部结构”的尺度。8x8 最常用。块太小时比如 4x4能容纳的细节太少堆出来的三维栈统计性不足降噪偏弱块太大时比如 16x16一个块里可能既有边缘又有平坦区硬阈值一刀切很容易把边缘抹掉。搜索窗口决定去哪里找相似块。窗口大找到的相似块多且稳方差更小但计算量是平方级增长而且窗口太大时远处“相似但空间位置不对齐”的块会被错误纳入引起振铃。经验值灰度图一般 16 起步最多 32彩色图或高分辨率图建议 16再往上单张图的耗时就不太能接受了。下面这张表可以照着用。参数常见值影响方向建议templateWindowSize8块越大越平滑但边缘越容易糊固定 8不要动searchWindowSize16 / 32窗口越大匹配越丰富耗时平方上升灰度图 16 起步高分辨率图用 16sigma0.02~0.15直接影响阈值和收缩强度用 MAD 法估算后再微调h0.8~1.5滤波强度越大越平滑从 1.0 开始每档微调 0.14.3 步长与阈值振铃和模糊到底是谁引起的步长是块匹配时参考块滑动的步长。步长为 1 覆盖最全但慢步长为 8 就是每 8 个像素放一个参考块快很多。原论文里的经典做法是先大步长粗匹配再小步长精聚合。你用的现成库不一定暴露这个参数但可以通过调整搜索窗口和最大匹配块数间接控制它。硬阈值这一步的阈值常见取 2.7 倍 sigma决定了系数置零的尺度。阈值太小噪声压不干净阈值太大真实结构被当噪声置零。还有一个工程上的隐性规则如果只跑第一步、不跑维纳滤波阈值要稍微放大一点因为后面没有第二步来补救了。两步全开时第一步可以温和一点细纹理留给第二步去修复。这些参数在库接口里未必全部暴露。比如 OpenCV 的 bm3dDenoising 就只暴露少数几个参数。我的建议是先用默认参数跑通再按“sigma 优先然后搜索窗口最后块大小”的顺序调每次只动一个变量同时记录 PSNR 和 SSIM。千万别同时动三个参数否则你根本不知道是哪一个动作把效果弄坏的。5. BM3D 降噪代码避坑手册5 个让效果翻车的常见问题5.1 现象一降噪后图像变“塑料感”边缘糊成一片现象边缘像被橡皮擦擦过纹理像水彩画整张图显得不自然。原因sigma 传得比实际噪声大或者 h 调太高。BM3D 内部的硬阈值和维纳滤波都直接受 sigma 驱动sigma 每放大一倍被保留的细节就明显少一块。解决先用 MAD 法粗估 sigma再把它乘以 0.8 到 1.0 作为下限、乘以 1.2 到 1.5 作为上限分别跑一遍对比边缘区域局部放大图。另外注意不要把降噪结果反复跑两遍第二次会把第一次残留的细节当噪声清掉。5.2 现象二彩色图像出现彩色斑点通道没分开处理现象降噪完的彩图平滑区域出现红红绿绿的色块单独看亮度还行但整体颜色很花。原因RGB 三个通道直接塞进 BM3D 一起处理。块匹配是在欧氏距离上做的RGB 三通道的联合距离会把颜色噪声也匹配进来导致色度噪声没有被单独处理。解决转到 YCrCb 或 Lab 空间只对亮度通道做完整 BM3D色度通道做很轻的降噪或者干脆只做硬阈值阶段。人眼对亮度噪声敏感对色度噪声不敏感所以色度通道没必要用重参数。标准配方见第 6 章。5.3 现象三8-bit 图直接喂进去结果偏暗或偏亮现象降噪后图像整体偏暗或者直方图整体偏移边缘出现台阶状伪影。原因输入还是 uint8 类型值域是 0 到 255。库内部有的版本会先转 float 再除以 255有的版本默认当成 0 到 1 的浮点直接算来回转换时精度丢了一次。解决养成习惯进 BM3D 之前自己统一做 img.astype(np.float64) / 255.0出来之后 clip 到 0~1 再乘 255 转回 uint8。不要依赖库的隐式转换不同版本的行为不一致。5.4 现象四降噪反而引入横条纹或 8x8 块状网格现象降噪后的图里能看出明显的网格边界或者像扫描线的横纹。原因搜索窗口开太大匹配到空间上不连续但灰度相似的块聚合时权重分配不均或者步长与块大小设置不合理导致部分像素没有被任何块覆盖、部分被反复覆盖。解决把搜索窗口从 32 降到 16 试一次。同时检查是否把两个不同来源的库的结果拼在一起。另外一个经验用 float64 而不是 float32可以减少累积误差对网格伪影的“贡献”。5.5 现象五运行速度太慢一张图跑十几秒现象一条命令下去等了十几秒才出结果批处理完全没法用。原因搜索窗口开太大匹配块数太多图像分辨率又高。BM3D 是 CPU 密集算法计算量基本正比于搜索窗口面积乘以参考块数量。两步全开时耗时接近翻倍。解决把搜索窗口先压到 16块大小保持 8。如果还慢可以先对图像做一次 2 倍下采样降噪完再上采样回来适用于大图工程。快速验证流程时可以先用 HARD_THRESHOLD 跑确认流程没问题再切到 ALL_STAGES。6. 最后一步把 BM3D 接进真实项目的三个进阶技巧6.1 技巧一用第一步做“引导图”再用第二步精修如果你用的库支持分步调用可以先用 HARD_THRESHOLD 跑出基础估计再把它作为引导图传给维纳滤波阶段。这样做的原因是引导图越干净第二次块匹配选出来的块越准。在噪声较强、sigma 超过 0.1 的场景下这个技巧通常比自动流程再拉开一点差距。6.2 技巧二彩色图标准配方YCbCr 拆分后亮度主降噪import cv2 import numpy as np import bm3d def denoise_color_bm3d(img_bgr, sigma_luma0.08): # 1. BGR 转 YCrCbOpenCV 默认彩色通道顺序是 BGR ycrcb cv2.cvtColor(img_bgr, cv2.COLOR_BGR2YCrCb) y, cr, cb cv2.split(ycrcb) y_f y.astype(np.float64) / 255.0 cr_f cr.astype(np.float64) / 255.0 cb_f cb.astype(np.float64) / 255.0 # 2. 亮度通道做完整 BM3D y_den bm3d.bm3d(y_f, sigma_luma, stage_argbm3d.BM3DStages.ALL_STAGES) # 3. 色度通道只做轻度降噪sigma 减半 cr_den bm3d.bm3d(cr_f, sigma_luma * 0.4, stage_argbm3d.BM3DStages.HARD_THRESHOLD) cb_den bm3d.bm3d(cb_f, sigma_luma * 0.4, stage_argbm3d.BM3DStages.HARD_THRESHOLD) # 4. 拼回三通道转回 BGR ycrcb_den cv2.merge([ np.clip(y_den * 255, 0, 255).astype(np.uint8), np.clip(cr_den * 255, 0, 255).astype(np.uint8), np.clip(cb_den * 255, 0, 255).astype(np.uint8) ]) return cv2.cvtColor(ycrcb_den, cv2.COLOR_YCrCb2BGR)这个配方我一直在用总结起来就是“亮度吃满色度轻碰”。色度通道即使有轻微噪声人眼也不敏感没必要把细节也洗掉。它适合大多数自然图像包括手机照片和监控截图。6.3 技巧三分清场景再决定要不要上 BM3D最后一条经验如果你的噪声不是高斯白噪声比如是椒盐噪声、条纹噪声、压缩伪影BM3D 不是最优解别死磕它。BM3D 整套假设建立在加性高斯白噪声上遇到这几种情况表现会一般甚至不如更简单的中值滤波或防闪烁滤波。判断一个降噪方案值不值得用先看噪声类型再看计算资源最后才谈参数调优。我记得第一次跑通这段代码时对着满屏参数调了一下午最后发现只是 sigma 输错了一位小数。希望这篇笔记能让你少走一段弯路帮到你。本文还有配套的精品资源点击获取