CNN图像去噪实战:从训练集测试集构建到DnCNN模型调优
简介这份资源是面向深度学习与图像处理初学者及开发者的CNN图像去噪完整实践包针对传感器噪声、传输干扰等导致图像质量下降的问题提供从训练到评估的一站式解决方案。压缩包共428个文件约60.97MB以412张png图像构成训练与测试样本另含5个h5模型权重、3个py训练脚本、4个xml配置及txt说明等覆盖数据、模型与代码各环节。资源包含训练集与测试集、CNN模型架构、损失函数与优化器选择、训练脚本及PSNR、SSIM等评估指标可帮助读者理解卷积层、池化层、激活函数与全连接层如何协同完成去噪并掌握模型训练、验证与调参流程。目前已有374人学习适合作为入门练手项目也可作为进一步优化网络结构与学习率策略的复用基础。1. 图像去噪这件事为什么卷积神经网络成了默认选项如果你手头有一批在暗光下拍的、或者高 ISO 硬拉出来的照片放大一看全是彩色噪点和块状伪影传统做法无非是高斯滤波、中值滤波、非局部均值NLM或者 BM3D。这些方法我都用过BM3D 在标准测试图上确实能打但一到真实场景就露馅纹理被抹平、边缘发糊参数还得逐张调。卷积神经网络CNN做图像去噪核心思路是让网络从「带噪图 → 干净图」的成对数据里自己学一个映射函数而不是靠人写死的滤波核。这个标题里的「包括训练集和测试集」说明它不是一个只给推理脚本的 Demo而是把数据准备、训练、评估这条链路都摆出来了——这对想复现的人来说才是真正有用的部分。适合谁适合已经会写 PyTorch 基础训练循环、想搞明白去噪模型从数据到指标怎么闭环的工程师也适合手里有自己拍的噪声图像、想训一个专用去噪器的人。下面我按「数据怎么造 → 网络怎么搭 → 训练怎么调 → 坑在哪」的顺序把这条链路拆开讲。2. 训练集和测试集怎么造从干净图到成对样本2.1 为什么去噪任务的数据必须是「成对」的CNN 去噪是有监督学习网络输入是带噪图监督信号是同一场景的干净图。没有干净图就只能走无监督或自监督路线比如 Noise2Noise、Noise2Void那是另一个话题。标题里明确带了训练集和测试集说明走的是成对监督路线。成对数据的构造方式直接决定模型上限如果你用合成噪声训练模型学到的是你合成噪声的分布拿它去处理真实相机噪声效果会掉一截。常见做法是两条腿走路——合成噪声用来做大规模预训练和消融实验真实成对数据同一场景固定机位、低 ISO 长曝光当干净图、高 ISO 短曝光当带噪图用来做微调。合成噪声里最常用的是加性高斯白噪声AWGN因为它的数学性质干净、可控性强。但真实传感器噪声不是纯加性的它包含泊松分量光子散粒噪声和读出噪声所以更接近的模型是异方差高斯噪声方差随像素亮度变化。我一般会同时准备 AWGN 和异方差高斯两套数据前者验证网络结构后者逼近真实。2.2 用 Python 生成 AWGN 成对数据的最小脚本import numpy as np import cv2 import os def add_awgn(img, sigma, rng): img: float32 [0,1] 的干净图; sigma: 噪声标准差(0-255尺度) noise rng.normal(0, sigma / 255.0, img.shape).astype(np.float32) noisy np.clip(img noise, 0.0, 1.0) return noisy def build_pairs(clean_dir, out_clean, out_noisy, sigma_list, patch256, stride128): rng np.random.default_rng(42) os.makedirs(out_clean, exist_okTrue) os.makedirs(out_noisy, exist_okTrue) idx 0 for name in os.listdir(clean_dir): path os.path.join(clean_dir, name) img cv2.imread(path, cv2.IMREAD_COLOR) if img is None: continue img cv2.cvtColor(img, cv2.COLOR_BGR2RGB).astype(np.float32) / 255.0 h, w, _ img.shape # 滑窗切 patch边缘不足则丢弃 for y in range(0, h - patch 1, stride): for x in range(0, w - patch 1, stride): clean_patch img[y:ypatch, x:xpatch] sigma float(rng.choice(sigma_list)) noisy_patch add_awgn(clean_patch, sigma, rng) cv2.imwrite(os.path.join(out_clean, f{idx:06d}.png), cv2.cvtColor((clean_patch*255).astype(np.uint8), cv2.COLOR_RGB2BGR)) cv2.imwrite(os.path.join(out_noisy, f{idx:06d}.png), cv2.cvtColor((noisy_patch*255).astype(np.uint8), cv2.COLOR_RGB2BGR)) idx 1 print(f生成 {idx} 对样本) if __name__ __main__: build_pairs(./clean_images, ./dataset/train/clean, ./dataset/train/noisy, sigma_list[15, 25, 50], patch256, stride128)这段脚本做了三件事读干净图并归一化到 [0,1]按固定步长切 256×256 的 patch对每个 patch 随机抽一个 sigma 加噪后分别存盘。参数说明sigma_list控制噪声强度范围训练时随机采样能让模型适应多个噪声水平这叫盲去噪blind denoisingpatch取 256 是显存和感受野的折中太小则上下文不足太大则 batch size 上不去stride取 patch 的一半保证相邻 patch 有重叠增加样本量同时减少边界效应。注意存盘时用 PNG 而不是 JPEGJPEG 的有损压缩会引入额外伪影污染监督信号。2.3 训练集和测试集的划分原则划分不能随机打散 patch否则同一张原图的 patch 会同时出现在训练和测试里造成数据泄漏指标虚高。正确做法是按「原图」划分先把干净图分成 train/val/test 三个不相交的集合再各自切 patch。测试集我一般留 510 张有代表性的图覆盖平滑区域、强纹理、暗部、高光四类场景。如果做真实噪声测试集必须是真实成对数据不能用合成噪声凑数否则评估结果没有参考价值。提示合成噪声的 sigma 建议覆盖 15、25、50 三档这是去噪文献里最常对比的噪声水平方便和已有方法横向比较。3. 网络结构选型从 DnCNN 到残差密集连接3.1 为什么去噪网络普遍预测「残差」而不是直接输出干净图直接让网络输出干净图等于让它从零重建每一个像素任务难度大、收敛慢。去噪领域一个被反复验证的结论是网络预测噪声残差noisy - clean推理时用输入减去预测残差得到干净图效果更好。原因是噪声相对于图像内容是稀疏且高频的残差映射的学习目标更简单梯度也更稳定。DnCNN 就是靠这个思路加上 batch normalization 和残差学习在 AWGN 去噪上打出了当时的最好成绩。我自己的经验是哪怕换成更深的网络残差学习这个设计也基本不要动。3.2 一个可复现的 DnCNN 变体实现import torch import torch.nn as nn class DnCNN(nn.Module): def __init__(self, channels3, num_layers17, features64): super().__init__() layers [] # 第一层无 BN保留原始噪声统计 layers.append(nn.Conv2d(channels, features, 3, padding1, biasFalse)) layers.append(nn.ReLU(inplaceTrue)) # 中间层Conv BN ReLU for _ in range(num_layers - 2): layers.append(nn.Conv2d(features, features, 3, padding1, biasFalse)) layers.append(nn.BatchNorm2d(features)) layers.append(nn.ReLU(inplaceTrue)) # 最后一层输出残差无激活 layers.append(nn.Conv2d(features, channels, 3, padding1, biasFalse)) self.body nn.Sequential(*layers) def forward(self, x): residual self.body(x) return x - residual # 推理时直接得到干净图结构上有几个点值得说清楚。第一层不加 BN是因为 BN 会归一化输入破坏噪声的绝对强度信息而噪声强度恰恰是去噪需要感知的关键线索。中间层加 BN 是为了加速收敛、稳定训练。最后一层不加激活函数因为残差可正可负ReLU 会把负残差截断。num_layers17、features64是 DnCNN 的经典配置参数量约 55 万单张 256×256 图在主流显卡上推理只要几毫秒。如果你的数据是灰度图把channels改成 1 即可。3.3 什么时候该上更复杂的结构DnCNN 在 AWGN 上够用但遇到真实噪声、大尺寸图像或者需要保留极细纹理时它的感受野和特征复用能力就不够了。这时候可以考虑三类改进一是加宽加深比如 20 层以上、128 通道代价是显存和推理时间二是引入密集连接或残差密集块RDB让浅层特征反复被复用对纹理恢复帮助明显三是用编码器-解码器结构类似 U-Net通过下采样扩大感受野、上采样恢复分辨率适合噪声空间相关性强的场景。选型原则很简单先跑通 DnCNN 拿到 baseline 指标再逐项加改进每次只动一个变量看验证集 PSNR 有没有实质提升。盲目堆结构是最容易翻车的地方。4. 训练循环与关键参数把 PSNR 从 28 推到 324.1 损失函数选什么去噪最常用的损失是 L2MSE和 L1。L2 对大误差惩罚重优化稳定但容易产生过度平滑的结果L1 对异常值更鲁棒恢复的纹理更锐利但收敛慢一些。我的习惯是先用 L2 训到收敛再用 L1 微调若干轮兼顾稳定性和锐度。如果追求感知质量可以加一个感知损失用预训练分类网络提特征算距离但要注意感知损失会拉低 PSNR因为 PSNR 衡量的是像素级误差和人的主观感受不完全一致。4.2 一个完整的训练脚本骨架import torch from torch.utils.data import DataLoader from dataset import PairedDenoiseDataset # 自定义 Dataset返回 (noisy, clean) device torch.device(cuda if torch.cuda.is_available() else cpu) model DnCNN(channels3, num_layers17, features64).to(device) optimizer torch.optim.Adam(model.parameters(), lr1e-3) scheduler torch.optim.lr_scheduler.StepLR(optimizer, step_size30, gamma0.5) criterion torch.nn.MSELoss() train_loader DataLoader(PairedDenoiseDataset(./dataset/train), batch_size16, shuffleTrue, num_workers4) val_loader DataLoader(PairedDenoiseDataset(./dataset/val), batch_size1, shuffleFalse) best_psnr 0.0 for epoch in range(80): model.train() for noisy, clean in train_loader: noisy, clean noisy.to(device), clean.to(device) pred model(noisy) loss criterion(pred, clean) optimizer.zero_grad() loss.backward() optimizer.step() scheduler.step() # 验证 model.eval() psnr_sum, count 0.0, 0 with torch.no_grad(): for noisy, clean in val_loader: noisy, clean noisy.to(device), clean.to(device) pred model(noisy).clamp(0, 1) mse torch.mean((pred - clean) ** 2).item() psnr 10 * torch.log10(torch.tensor(1.0 / max(mse, 1e-10))).item() psnr_sum psnr count 1 avg_psnr psnr_sum / count print(fEpoch {epoch}: val PSNR {avg_psnr:.2f} dB) if avg_psnr best_psnr: best_psnr avg_psnr torch.save(model.state_dict(), best_dncnn.pth)几个参数需要重点解释。lr1e-3配 Adam 是去噪任务的常见起点如果训练后期 loss 震荡降到 1e-4 再跑。StepLR每 30 轮衰减一半目的是让模型在后期精细收敛。batch_size16在 256×256 patch、64 通道的 DnCNN 上大约占 68 GB 显存显存不够就降到 8 或 4同时把学习率按比例调小。验证时用clamp(0,1)把输出截断到合法像素范围否则 PSNR 计算会被异常值拉偏。保存策略用「验证集最优才存」避免存下过拟合的最后一轮。4.3 学习率、batch size 和 patch 大小的联动关系这三个参数不是独立的。patch 变大单样本显存占用上升batch size 就得降batch size 降了梯度估计噪声变大学习率通常也要跟着降否则训练不稳定。我一般按这个顺序定先根据显存定 patch 和 batch 的组合再在这个组合下扫学习率1e-3、5e-4、1e-4 三档选验证集收敛最快的那档。另外如果开了混合精度训练AMP显存能省 30%40%可以适当把 batch size 提上去但要注意 AMP 下 BN 的数值稳定性必要时把 BN 换成 GroupNorm。5. 避坑与排查那些让 PSNR 不升反降的细节5.1 训练 loss 一直降验证 PSNR 却不动甚至下降现象训练集 MSE 从 0.01 降到 0.001验证集 PSNR 卡在 28 dB 上不去。原因通常是数据泄漏或过拟合。先查划分如果 patch 是随机打散的同一张原图的 patch 会跨训练和验证集验证指标虚高且不再反映泛化。再查模型容量DnCNN 17 层对几千张 patch 的数据量来说偏大容易记住训练集。解决办法是按原图重新划分数据集并加数据增强随机翻转、旋转 90 度把有效样本量翻几倍。5.2 推理结果出现棋盘格伪影现象输出图上有规律的网格状纹理。原因多半是转置卷积deconvolution的步长和卷积核尺寸不匹配导致上采样时像素覆盖不均匀。如果你用的是编码器-解码器结构把转置卷积换成「最近邻上采样 普通卷积」或者把步长设为 2、核尺寸设为 4能被步长整除棋盘格基本就消失了。DnCNN 这种全卷积结构不会出现这个问题因为它没有下采样。5.3 真实噪声图上效果远差于合成噪声现象合成 AWGN 上 PSNR 32 dB换真实高 ISO 照片只有 26 dB。原因是噪声分布不匹配。合成噪声是空间独立的高斯白噪声真实噪声在暗部强、亮部弱还带空间相关性。解决办法是用异方差高斯模型重新合成训练数据或者直接用真实成对数据微调最后几层。我一般会保留一个在合成数据上预训练的权重再用少量真实成对数据以 1e-4 的学习率微调 1020 轮效果提升比从头训真实数据更稳。5.4 显存溢出OOM的排查顺序现象训练到一半报 CUDA out of memory。按这个顺序查先看 batch size 和 patch 是不是设大了降一半试再看 DataLoader 的num_workers是不是开太高导致内存 pinned 占用过多降到 24然后确认验证阶段有没有用torch.no_grad()忘了加会导致计算图累积最后检查是不是在训练循环里无意中保留了中间变量比如把 loss 存进列表却没 detach。这四步走完九成 OOM 都能定位。5.5 保存的模型加载后输出全黑或全白现象训练时正常重新加载权重推理却输出异常。原因通常是保存和加载的键名不匹配比如训练时用了nn.DataParallel保存的 state_dict 带module.前缀加载时没去掉。解决办法是保存时用model.module.state_dict()如果是 DataParallel或者加载时用{k.replace(module., ): v for k, v in state.items()}处理键名。另一个可能是推理时忘了把模型切到eval()模式BN 还在用 batch 统计量导致输出不稳定。6. 评估与进阶PSNR 之外你该看的指标和一个小技巧PSNR 是最常用的去噪指标但它有个明显缺陷对过度平滑不敏感。一个把纹理全抹平、只剩大色块的输出PSNR 可能很高但人眼看着很假。所以我评估时一定同时看 SSIM结构相似性它从亮度、对比度、结构三个维度衡量对纹理保留更敏感。如果做的是真实照片去噪我还会加一个人工盲评把原图、带噪图、去噪图并排让不参与项目的人挑哪张最自然。指标是给论文看的观感是给用户看的两者都要顾。进阶方向有两个值得投入。一是把单一噪声水平的模型扩展成盲去噪模型做法是在训练时随机采样 sigma让网络自己估计噪声强度DnCNN 的盲版本就是这么做的代价是同 sigma 下的 PSNR 会比专用模型低 0.20.5 dB。二是引入注意力机制比如在残差块里加通道注意力SE 模块让网络自适应地给不同通道分配权重对彩色噪声的抑制更精准。我试过在 DnCNN 的每个残差块后加 SE参数量增加不到 5%验证集 PSNR 涨了约 0.3 dB性价比可以。最后分享一个我踩过坑才养成的习惯每次改完网络结构或数据管线先拿 100 对样本跑 5 个 epoch看 loss 有没有正常下降、输出图有没有明显异常确认链路通了再上全量数据。这个「小样本快跑」的习惯帮我省下了大量等训练的时间也避免过好几次跑了一整夜才发现数据路径写错、模型根本没学到东西的翻车。希望帮到你。本文还有配套的精品资源点击获取