☰
BM3D降噪代码详解:从块匹配到维纳滤波的经典图像去噪方案
2026/10/16 15:01:15 网站建设 项目流程

简介:BM3D(Block-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 倍 sigma,sigma 是噪声标准差。这个动作是噪声去除的关键,置零的那部分系数本来就是噪声主导的。

第四,逆变换回空间域,把每个块的估计值放回原图位置。因为每个像素会被多个块覆盖,最后用加权平均聚合,权重取该块经过硬阈值后系数非零的个数。非零系数多表示这个块含有更多结构信号,权重就应该大一点。

伪代码如下。

# BM3D 第一步:硬阈值基础估计(流程示意) def step1_hard_threshold(noisy, sigma, block_size=8, search_window=16, max_matches=8): # 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, h=1.0, templateWindowSize=8, searchWindowSize=16) # 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_arg=bm3d.BM3DStages.HARD_THRESHOLD) # 4. 跑完整两步:硬阈值 + 维纳滤波 denoised = bm3d.bm3d(img, sigma_psd, stage_arg=bm3d.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_range=1.0) s = ssim(clean, denoised, data_range=1.0) print(f'PSNR: {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(f'estimated sigma = {estimate_sigma(noisy_f):.4f}')

这段代码的问题是,拉普拉斯响应在纹理强的区域会产生大的真实结构响应,把 sigma 估大。解决办法有两个:先对图像做一次极轻的均值滤波再去算 MAD;或者只挑图像里最平坦的一块区域来算,比如天空、墙面。如果对着一张全是树叶的照片算,估计值可能偏高一倍。别太迷信这个数字,它只是调参起点,最终还要按效果微调。

4.2 块大小与搜索窗口:细节保留和计算量的直接权衡

块大小决定了算法感知“局部结构”的尺度。8x8 最常用。块太小时,比如 4x4,能容纳的细节太少,堆出来的三维栈统计性不足,降噪偏弱;块太大时,比如 16x16,一个块里可能既有边缘又有平坦区,硬阈值一刀切很容易把边缘抹掉。

搜索窗口决定去哪里找相似块。窗口大,找到的相似块多且稳,方差更小,但计算量是平方级增长,而且窗口太大时,远处“相似但空间位置不对齐”的块会被错误纳入,引起振铃。经验值:灰度图一般 16 起步,最多 32;彩色图或高分辨率图建议 16,再往上单张图的耗时就不太能接受了。下面这张表可以照着用。

参数常见值影响方向建议
templateWindowSize8块越大越平滑,但边缘越容易糊固定 8,不要动
searchWindowSize16 / 32窗口越大匹配越丰富,耗时平方上升灰度图 16 起步,高分辨率图用 16
sigma0.02~0.15直接影响阈值和收缩强度用 MAD 法估算后再微调
h0.8~1.5滤波强度,越大越平滑从 1.0 开始,每档微调 0.1

4.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_luma=0.08): # 1. BGR 转 YCrCb,OpenCV 默认彩色通道顺序是 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_arg=bm3d.BM3DStages.ALL_STAGES) # 3. 色度通道只做轻度降噪,sigma 减半 cr_den = bm3d.bm3d(cr_f, sigma_luma * 0.4, stage_arg=bm3d.BM3DStages.HARD_THRESHOLD) cb_den = bm3d.bm3d(cb_f, sigma_luma * 0.4, stage_arg=bm3d.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 输错了一位小数。希望这篇笔记能让你少走一段弯路,帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询