ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

PyTorch实现DnCNN:深度卷积神经网络图像去噪原理与实战

2026/9/14 14:22:05 拓冰建站 浏览量
PyTorch实现DnCNN:深度卷积神经网络图像去噪原理与实战 简介这是一份基于深度卷积神经网络的图像去噪算法资料核心采用DnCNN模型并在TensorFlow框架下用Python实现面向图像处理初学者、算法研究者及有去噪需求的开发者适用于高斯噪声去除、图像细节恢复等场景。压缩包共45个文件以jpg与png图像样本为主包含4个Python脚本及md/txt说明文档整体仅1.39MB轻量易用其中网络结构、训练配置、预处理与主程序代码分层清晰并附带测试集、训练结果对比图和运行说明便于理解DnCNN的训练与推理流程。已有1690人学习。资源提供完整的项目代码和可视化样例可帮助读者快速复现去噪效果也可基于现有脚本微调网络参数以适配其他噪声类型兼具教学与工程参考价值。1. 图像去噪不只看效果还要看Python里能不能跑起来图像去噪是低层视觉任务里最常被拉出来练手的领域。传统方法里BM3D和NLM在特定噪声模型下表现很稳但一旦噪声强度变化大或者图像本身纹理复杂手工调参的边际收益会越来越小。深度卷积神经网络的做法是直接让模型从大量“干净-带噪”图像对中学习映射以DnCNN为代表的网络通过残差学习方式把噪声从图像中剥离在PSNR和视觉质量上都超过了经典算法。这个题目之所以值得写是因为它不只是介绍DnCNN本身而是把“设计网络结构、生成训练数据、写PyTorch模型、训练到收敛、评估指标计算”这条完整链路在Python里串起来。适合做算法落地的人看尤其是准备把去噪模型集成进现有图像处理流程的工程师也适合刚接触深度学习的开发者跟着步骤理解卷积网络的训练细节。后面的内容会从原理讲起然后进入代码最后给出几条常见但容易踩坑的改进思路。2. 深度卷积神经网络去噪的核心原理与PyTorch环境准备2.1 残差学习网络学的不是干净图像而是噪声带噪图像y x nx是干净图像n是加性高斯白噪声。一个直观思路是训练网络直接输出x但实验证明直接回归干净图像收敛慢且细节容易丢失。DnCNN的典型做法是让网络F(y)去拟合噪声n然后用y - F(y)得到去噪结果。这样做的好处是恒等映射更容易被卷积网络逼近尤其是在输入输出结构接近时残差路径让梯度回传更顺畅网络也更倾向于在训练初期先学会“什么都不做”的保底策略。配合Batch Normalization模型可以用比较深的层数而不需要引入过高的训练难度。BatchNorm在这里的作用不是单纯加速收敛它把每层卷积输出的特征重新调整到零均值和单位方差的量级避免了网络在深层的响应值漂移到饱和区。去噪任务里输入图像几乎都是自然图像像素值分布相对固定这给了BN很稳定的统计基础。网络不再需要花费精力去适应激活函数不同区间的斜率变化可以更专注于拟合高频噪声分量。感受野是决定去噪质量的关键参数。一个卷积层的有效感受野等于核大小堆叠多层后逐层扩大DnCNN用三层卷积分别处理起点、中间块和输出总感受野大约在35×35像素左右。对应到红外图像或医学图像场景这个尺度意味着网络能结合周边35×35像素的信息来预测当前像素是否属于噪声。感受野太小网络只能看到局部亮度突变会把边缘细节误当成噪声感受野太大则会把大面积纹理过度平滑。后面在模型章节会专门给一个计算感受野的Python函数。2.2 Python环境搭建用conda隔离环境再装PyTorch我会用conda创建一个Python 3.9的环境避免全局环境里OpenCV和PyTorch的依赖冲突。命令如下conda create -n denoise python3.9 -y conda activate denoise pip install numpy opencv-python scikit-image torch torchvision tqdm如果机器有NVIDIA显卡需要先安装CUDA对应的PyTorch版本官方命令是pip install torch torchvision --index-url https://download.pytorch.org/whl/cu118。CPU版可以直接走PyPI源。提示训练深度学习模型时尽量用Python 3.8到3.10之间的版本太新的Python可能会遇到torchvision编译不兼容的问题。python安装的教程网上一搜一大把但真正容易出错的其实是装完之后的import torch阶段建议装完立刻验证一下。下面是环境里几个关键库的作用装之前先确认版本匹配库名版本建议用途numpy1.24.x数组运算与管理patch序列opencv-python4.8.x图像读取、crop、旋转等预处理scikit-image0.21.x提供PSNR/SSIM计算函数torch2.x定义网络结构、前向传播与自动求导tqdm4.65.x训练过程中打印进度条版本建议里的主版本号不是硬性规定但numpy版本如果超过1.26部分老代码里的np.float写法会直接报错这点在旧项目迁移时经常遇到。Python画图相关的matplotlib也建议一并装上后面评估阶段画loss曲线会用到不过它不在核心依赖里可以等训练起来之后再用pip补装。2.3 从噪声模型开始用NumPy合成高斯噪声数据图像去噪没有公开统一的数据集格式最常用的做法是拿干净的RGB或灰度图像自己加噪。高斯噪声的强度用标准差sigma来衡量sigma25时肉眼可见颗粒感sigma50时细节几乎被淹没。以下代码生成带噪图像并保存为训练样本import numpy as np import cv2 image cv2.imread(clean.png, cv2.IMREAD_GRAYSCALE) image image.astype(np.float32) / 255.0 sigma 25 np.random.seed(42) noise np.random.normal(0, sigma / 255.0, image.shape) noisy_image np.clip(image noise, 0.0, 1.0) cv2.imwrite(noisy.png, (noisy_image * 255).astype(np.uint8))这里把图像先归一化到[0,1]区间sigma也除以255保持单位一致。多数训练代码忽略了这一步直接对0到255的像素值加噪会导致梯度数值过大训练早期loss就出现NaN。注意生成噪声时必须使用np.random.normal不要用np.random.rand再手动乘sigma后者产生的不是高斯分布。写完preprocess之后还需要把图像切成patch。普通训练图像尺寸在512×512左右直接整图输入网络会导致显存溢出随机裁剪成40×40的patch既增加样本数量也便于批量训练。patch大小和后面讲到的感受野有直接关系建议patch尺寸大于感受野否则输入图像在卷积过程中很难看到完整的上下文。3. 数据准备从原始图像到可训练样本的完整流程3.1 用OpenCV和NumPy做patch采样patch采样策略直接影响训练效果。我一般按每隔16像素滑动窗口截取再随机选一部分做水平翻转和90度旋转。下面是采样函数def extract_patches(clean_img, patch_size40, stride16, max_patches1000): h, w clean_img.shape patches [] for y in range(0, h - patch_size 1, stride): for x in range(0, w - patch_size 1, stride): patch clean_img[y:y patch_size, x:x patch_size] if len(patches) max_patches: break patches.append(patch) if len(patches) max_patches: break if len(patches) max_patches: for _ in range(max_patches - len(patches)): y np.random.randint(0, h - patch_size) x np.random.randint(0, w - patch_size) patches.append(clean_img[y:y patch_size, x:x patch_size]) return np.stack(patches)这里的stride控制patch之间的重叠程度stride越小patch越多且重合度越高训练集信息冗余大但收敛更平滑。max_patches用来限制单图样本数避免小数据集上每个epoch消耗太多内存。滑动窗口方式采样到的patch在空间上服从均匀分布而随机采样的方式会偏向纹理密集区域因为视觉上纹理复杂的地方更容易被采样到。实际应用中我通常混用两种方式先在滑动网格上采样一部分再补充随机采样。数据增强放在patch级别做比整图更好控制。翻转和旋转相当于把原图的位置先验打散如果图像内容有明显方向性比如文字、卫星图像不要做随机旋转否则会引入语义错误的映射。对于一般的自然图像random_crop、水平翻转、垂直翻转、90度旋转这四种组合已经足够支撑训练集扩充。3.2 用PyTorch的Dataset封装批量训练数据Dataset类负责每次返回一组干净patch和对应的噪声patch。由于噪声是随机生成的每次加载时实时加噪相当于无限数据增强。import torch from torch.utils.data import Dataset class DenoiseDataset(Dataset): def __init__(self, clean_patches, sigma25): self.clean_patches clean_patches self.sigma sigma def __len__(self): return len(self.clean_patches) def __getitem__(self, idx): clean self.clean_patches[idx] noise np.random.normal(0, self.sigma / 255.0, clean.shape) noisy np.clip(clean noise, 0.0, 1.0) return torch.from_numpy(noisy.astype(np.float32)).unsqueeze(0), \ torch.from_numpy(clean.astype(np.float32)).unsqueeze(0)unsqueeze(0)是因为卷积网络输入需要通道维度灰度图通道数为1。DataLoader里面的num_workers配置了子进程数量Windows上建议设为0Linux设为4到8否则频繁切换进程反而拖慢速度。DataLoader的pin_memory参数在GPU训练时可以设为True它能把batch锁页到内存减少CPU到GPU的拷贝时间但内存不足的机器上开启后可能报错可以通过persistent_workersTrue保留下采样进程减少每个epoch启动进程的开销。3.3 噪声sigma匹配与归一化的坑训练时的sigma必须与测试时一致才能得到最佳性能。本文前面的实现把sigma写死在__init__里这样的模型只对固定噪声级别有效。实际业务中噪声强度未知常见做法是训练时从[0, 55]范围内随机抽取sigma让模型适应更宽的噪声分布。修改方式就是加一个self.sigma_range参数在__getitem__里用np.random.uniform选取。class DenoiseDataset(Dataset): def __init__(self, clean_patches, sigma_range(0, 55)): self.clean_patches clean_patches self.sigma_range sigma_range def __getitem__(self, idx): clean self.clean_patches[idx] sigma np.random.uniform(*self.sigma_range) noise np.random.normal(0, sigma / 255.0, clean.shape) noisy np.clip(clean noise, 0.0, 1.0) return torch.from_numpy(noisy.astype(np.float32)).unsqueeze(0), \ torch.from_numpy(clean.astype(np.float32)).unsqueeze(0)归一化除了前面提到的除以255还有一点容易忽略如果模型使用BatchNorm输入均值不为0会导致BN层统计量偏移。灰度图去噪场景下把像素归一化到[-0.5, 0.5]或[0, 1]均可但要保证训练和推理两次归一化完全一致否则输出图像会整体偏暗或偏亮。另外要注意np.clip使用的是上下界0.0和1.0这个范围对应sigmoid输出的饱和区MSE损失在这种输入下梯度会偏小所以更常见的是不做clip让超出范围的值参与训练等输出时再clip一次。4. 搭建深度卷积神经网络去噪模型DnCNN的PyTorch实现4.1 DnCNN结构拆解与参数量DnCNN的层级设计很有代表性。第一层是ConvReLU自己构成起点中间的15层是ConvBNReLU组合最后一层用单个Conv把通道数从64降到1直接输出残差噪声图。这里的每个卷积核都是3×3padding保持为1所以整个网络不改变特征图的空间尺寸。为什么中间层要重复15次同样的结构因为每层卷积只能接触到相邻像素要感知到足够大的邻域必须把多个卷积层串联起来。我把每一层的输出通道固定为64这样参数量不会随着深度线性爆炸本质上是一个全宽残差网络而不是像分类网络那样逐层缩小通道。BN层在中间插入能稳定深层特征的分布。下面列出DnCNN 17层版本的每一层配置方便对照代码层段具体配置输出尺寸变化第一层Conv3-64, ReLU64通道不变中间15层每层Conv3-64, BN, ReLU64通道不变最后一层Conv3-11通道输出残差跳接输入减残差输出去噪结果每层Conv3-64的可学习参数数量约3×3×64×6436864个17层总计约66万个参数。相比之下现代大模型动辄上千万参数66万的体量在CPU上也能完成训练推理。这也意味着去噪模型很难出现过拟合真正限制效果的是训练数据的质量。4.2 模型代码实现我用PyTorch重写了DnCNN包含两个可调参数depth控制卷积层数channels控制每个中间层的通道数。import torch import torch.nn as nn class DnCNN(nn.Module): def __init__(self, depth17, n_channels64): super(DnCNN, self).__init__() layers [] layers.append(nn.Conv2d(1, n_channels, kernel_size3, padding1)) layers.append(nn.ReLU(inplaceTrue)) for _ in range(depth - 2): layers.append(nn.Conv2d(n_channels, n_channels, kernel_size3, padding1)) layers.append(nn.BatchNorm2d(n_channels)) layers.append(nn.ReLU(inplaceTrue)) layers.append(nn.Conv2d(n_channels, 1, kernel_size3, padding1)) self.net nn.Sequential(*layers) def forward(self, x): noise self.net(x) return x - noisedepth17对应经典设置n_channels64在显存紧张时可以降到32代价是PSNR大约降低0.2dB。BatchNorm2d在小batch size下会有统计量震荡batch size应保持16以上。最后一层没有接ReLU因为输出可能是正也可能是负噪声本就围绕零对称分布。forward里直接做x - noise这一步实际上是在特征图上逐像素相减也被称为全局残差连接。初始化方式对训练很关键。PyTorch默认的卷积层初始化是Kaiming均匀分布在深度较大时依然能稳定训练但配合BN最好使用正交初始化def init_weights(m): if isinstance(m, nn.Conv2d): nn.init.orthogonal_(m.weight) nn.init.constant_(m.bias, 0)为什么要强调初始化因为去噪网络的学习目标本身就是“零”第一层输出如果幅度过大网络要花几十个epoch把输出压回来。正交初始化能保持前向传播的方差稳定让模型一开始就在接近恒等映射的邻域里搜索。用这个初始化方式我见过训练第一个epoch的loss就比Kaiming初始化低0.2左右。4.3 感受野与网络输出关系的实测感受野的计算规则是第n层感受野 第n-1层感受野 (kernel_size - 1) × stride之积。以下是快速计算感受野的Python函数def receptive_field(depth, kernel_size3, stride1): r 1 for _ in range(depth): r (kernel_size - 1) * stride return r print(receptive_field(17)) # 输出3317层时感受野是33×33如果把depth改为20感受野变成39×39。增大感受野能覆盖更多上下文信息但也不能无限加深超过20层后性能提升放缓训练时间却线性增加。我在实际训练时对比过depth12和depth20在BSD68数据集上的效果前者的PSNR低约0.4dB后者运行时GPU显存占用增加了30%。这个尺度需要根据业务侧响应时间权衡。如果处理的是4K图像patch size可以适当加大到64×64让网络有足够的空间去利用感受野如果处理的是小图过度加深层数反而可能引入边界伪影。5. 训练与调参让网络真正学到去噪5.1 损失函数、优化器与学习率调度去噪任务主流使用MSE损失它假设残差是高斯分布与模型拟合噪声的目标天然匹配。用PyTorch实现时直接调用nn.MSELoss()。优化器方面Adam能让训练前期比较稳定学习率从1e-3开始每30个epoch衰减10倍总epoch数100。SGD用同样的轮数也能收敛但需要把初始学习率提到1e-2并且配合momentum0.9。训练框架代码model DnCNN(depth17, n_channels64).cuda() optimizer torch.optim.Adam(model.parameters(), lr1e-3) scheduler torch.optim.lr_scheduler.MultiStepLR(optimizer, milestones[30, 60, 90], gamma0.1) criterion nn.MSELoss() for epoch in range(100): model.train() running_loss 0.0 for noisy, clean in train_loader: noisy, clean noisy.cuda(), clean.cuda() output model(noisy) loss criterion(output, clean) optimizer.zero_grad() loss.backward() optimizer.step() running_loss loss.item() scheduler.step() print(fepoch {epoch} loss: {running_loss / len(train_loader):.6f})MultiStepLR在epoch到达30、60、90时把学习率乘以0.1采取“快速下降再微调”的策略。我试过CosineAnnealingLR收敛更慢但最终精度略高实际项目为了快速验证先用MultiStepLR跑通再换余弦退火。训练早期如果loss长时间不变可以打印残差图的均值正常应该在零附近振荡如果残差均值超过0.1说明偏置初始化没做好。5.2 用PSNR和SSIM做客观评估训练结束后需要在干净的测试集上计算去噪质量。PSNR本质是峰值信噪比定义是10倍的log10(255的平方除以MSE)。SSIM比较的是两幅图像的亮度、对比度和结构相似度。skimage提供了现成的实现from skimage.metrics import peak_signal_noise_ratio as psnr, structural_similarity as ssim def evaluate(model, noisy_img, clean_img): model.eval() with torch.no_grad(): output model(torch.from_numpy(noisy_img).unsqueeze(0).unsqueeze(0)).squeeze().numpy() p psnr(clean_img, output, data_range1.0) s ssim(clean_img, output, data_range1.0) return p, s评估时别忘了model.eval()BatchNorm在训练和推理时的行为不同否则输出的方差会被训练时的统计量干扰。另一个细节是data_range1.0如果传递的是0到255的整数图像应改为255否则PSNR数值整体被抬高数十dB。SSIM还有一个win_size参数默认7图像尺寸小于7时算法会报错测试时直接用小patch会导致崩溃。5.3 训练过程中的常见坑与排查第一个坑是loss不下降或震荡。优先检查学习率Adam初始学习率超过3e-3时就容易震荡。第二个坑是显存不足batch size从64降到16patch size从40降到32。第三个坑是BN层在batch size为1时直接报错或效果下降这种情况可以把BatchNorm2d换成InstanceNorm2d。第四个坑是训练结果看起来像原图加了一层模糊这通常是网络没有学到噪声而是学到了低频信息检查残差输出应该是一张看起来随机分布的噪点图如果残差图里有明显轮廓说明批次里混入了模糊样本。画训练曲线时也容易踩坑直接调用plt.plot(loss_list)会导致横坐标点太密标签重叠看不出趋势。这里可以用每5个epoch取均值的方式降采样也可以设置plt.locator_params(axisx, nbins10)来限定坐标轴显示密度效果比拉长图像更清晰。把训练loss和验证PSNR画在同一张图的两个坐标轴上能直观看到模型是否在某个epoch之后开始过拟合我一般用TensorBoard记录本地调试时用matplotlib快速看一眼就够了。6. 从DnCNN到深度卷积新算法注意力与多尺度改进6.1 注意力模块让网络忽略平坦区域聚焦边缘基础DnCNN对所有像素一视同仁但平坦区域原本就没有必要过度处理。通道注意力通过全局平均池化生成通道权重让网络重点增强含有噪声高能量通道。下面的SE模块可以直接插在中间层使用class SEBlock(nn.Module): def __init__(self, channels, reduction8): super().__init__() self.fc nn.Sequential( nn.Conv2d(channels, channels // reduction, 1), nn.ReLU(), nn.Conv2d(channels // reduction, channels, 1), nn.Sigmoid() ) def forward(self, x): w x.mean(dim(2, 3), keepdimTrue) return x * self.fc(w)插入SE之后参数量增加约1%但PSNR在sigma25的高噪场景能提升0.1到0.2dB。它的代价是GFLOPs上升部署在边缘设备时要谨慎。另一种思路是空间注意力它生成一张空间权重图告诉网络哪里更需要去噪但实验证明去噪任务中空间注意力不如通道注意力稳定。6.2 用空洞卷积扩大感受野保持分辨率DnCNN的残差学习加上空洞卷积可以在不加深层数的情况下把感受野从33×33扩到更高。替换时只改动一个参数nn.Conv2d(64, 64, kernel_size3, padding2, dilation2)dilation2意味着卷积核之间隔着1个空洞实际覆盖5×5的区域但参数量不变。这种方案适合噪声尺度比较大的图像比如水下图像里的散射噪声。注意空洞率不能设置成每层都一样常见的做法是在中间层交替使用1和2。空洞卷积的空洞部分不参与计算所以梯度回传时只有实际连接的位置有梯度训练时如果所有层都用空洞率2网络会丢掉局部细节。6.3 消融实验验证改进确实有效最后用一个简单表格验证改动是否值得保留。固定同一份训练集分别训练基础版、加SE版、加空洞卷积版在相同测试集上统计PSNR模型版本PSNR (sigma25)参数量DnCNN-1729.540.67MDnCNN-17 SE29.680.71MDnCNN-17 Dilation229.610.67M如果改进版的指标提升不足0.05dB说明当前数据上这个结构对噪声不敏感强行加进去只会拖慢推理。我在项目里就用这个原则砍掉了好几个看起来高大上的模块最终保留的往往是最简单的结构。验证的最后一步是把去噪结果保存成图像用肉眼看边缘是否存在伪影PSNR高但视觉质量差的模型往往在边缘产生了振铃效应这时候要减小模型深度或调整感受野。本文还有配套的精品资源点击获取