
简介面向深度学习与图像处理方向的初学者和研究者这份基于卷积神经网络的图像去噪资源包围绕去除图像噪声、恢复清晰画质这一核心任务提供了从数据准备、模型设计到训练评估的完整闭环。压缩包共428个文件约60.97MB其中412张png图片构成训练集和测试集5个h5文件保存了DnCNN10、DnCNN15、DnCNN25及不同网络结构的预训练权重配合3个py训练脚本、4个xml配置、txt说明和pyc辅助模块可直接在TensorFlow/Keras等框架下复现去噪实验。已有374人学习下载适合刚入门CNN的读者对照模型架构理解卷积层、残差学习与噪声去除的关系也方便有经验者在此基础上调整网络深度或损失函数做进一步优化。借助峰值信噪比等评估指标可直观比较不同噪声等级的复原效果无论是课程设计、论文复现还是工程预研都能从中获得完整参考。1. 基于CNN的图像去噪这个压缩包能帮你跑通什么一拿到“基于卷积神经网络的图像去噪模型包括训练集和测试集.rar”这样的压缩包多数人的真实诉求不是凑热闹而是手里已经攒了一批待处理的图像——老照片翻新、监控视频去噪、医学图像预处理。这类任务最烦人的不是算法看不懂而是数据集和训练代码对不上。这个标题把三块硬通货打包了卷积神经网络模型结构、划分好的训练集、用来验收效果的测试集。它的价值在于你能沿着“数据准备 → 模型搭建 → 训练 → 测试集验证”整条链路把它完整复现而不是看完一篇博客之后还是不知道从哪启动。适合的人群是已经会写 PyTorch、但还没完整跑通过一篇图像去噪实验的工程师和研究生。先说一个反直觉的结论决定这个项目成败的往往不是网络层数有多深而是训练集与测试集划分得干不干净、评估指标算得对不对。2. 为什么图像去噪选卷积神经网络从滑动窗口滤波到可学习滤波器2.1 噪声模型与卷积核的可学习性图像去噪的标准建模是观测图像 y x n其中 x 是干净图n 是噪声。工程里最常见的是高斯白噪声n 服从均值为 0、方差为 σ² 的正态分布σ 以像素灰度值计。当图像按 0255 量化时σ25 属于中等强度噪声σ50 已经接近“照片被雨淋过”的观感。去噪的目标是从 y 恢复出 x也就是估计一个映射 x̂ f(y)。设计去噪模型必须回应噪声的性质。传统的滑动窗口滤波例如均值滤波、高斯滤波、中值滤波本质上是把一个固定形状的窗口在图像上滑过去窗口中心的输出是窗口内像素的加权和或排序统计量。其中高斯滤波对高斯噪声有一定压制能力但它的核函数是固定的不会因为图像内容是边缘还是平缓区域而改变中值滤波对付椒盐噪声很有效但在高斯噪声下也会把细纹理磨掉。它们的共同问题在于窗口形状和权值不随内容自适应遇到纹理区域就一刀切抹平。卷积神经网络把“窗口”变成了可学习的卷积核。每一个卷积核都可以理解为一个滑动窗口但它的系数是从训练集里学出来的。更重要的是多层卷积核堆叠之后输出位置覆盖的感受野会显著扩大。以 17 层 3×3 卷积为例感受野尺寸是 (17-1)×2 3 35 像素。也就是说模型判断中心像素是噪声还是细节时看到的不再是周围 3×3 的小窗口而是一块 35×35 的上下文区域。这种“可学习的滑动窗口 大范围上下文”的组合是 CNN 在图像去噪上能明显超过传统滤波器的核心原因。还有一个容易忽略的点非线性激活。去掉 ReLU 之后多层卷积在数学上仍然等价于一个大卷积核的线性运算模型能表达的空间非常有限。噪声叠加在边缘和高频纹理上时哪些高频成分要保留、哪些要抹掉本身就是非线性决策所以卷积层之间必须用 ReLU 这类激活函数把非线性加进去。这也是为什么很多去噪网络看起来结构简单但直接加深卷积层数并不能无限提升效果——真正的收益来自感受野扩大与非线性表达能力的平衡。2.2 结构选型去噪网络为什么刻意不用汇聚层很多入门教程反复强调卷积神经网络的汇聚层但图像去噪恰恰是最不该为了提速而随便加汇聚层的任务。汇聚层池化层会压缩特征图空间尺寸而图像去噪的最终输出必须和输入同分辨率。一旦下采样像素级的高频细节就很难无损恢复。如果非要用带下采样的结构常见做法是走 U-Net 路线用跳连接把编码器阶段的边缘信息直接传给解码器代价是显存占用和训练时间都会上升。在实际复现里我一般先按两种结构方向做取舍结构方向核心机制优点主要代价DnCNN 风格全分辨率网络3×3 卷积 BN ReLU 堆叠输出与输入同尺寸的残差图实现简单、可复现性强、不丢空间分辨率、显存可控感受野要靠加深网络网络太浅时对大范围噪声模式覆盖有限U-Net 风格编解码结构编码器下采样 跳连接 解码器上采样感受野大处理条纹、污渍这类大块噪声更有效参数量和显存明显增加编解码过程容易磨掉轻微纹理浅层 3 层 CNN仅 3 层左右卷积直接回归干净图训练最快数据量很小时不容易欠拟合感受野只有 7 像素左右只适合 σ 很小的轻量去噪标题里的“基于卷积神经网络”我默认先按 DnCNN 风格落地。它是去噪领域公认的基线结构结构透明参数容易调显存占用也友好。这个风格的关键设计是残差学习模型输出的不是干净图而是噪声残差图 R(y)最终去噪结果用 x̂ y - R(y) 得到。残差学习的好处需要细说。干净图的高频细节非常复杂直接回归干净图模型容易把高频细节学“糊”而噪声图是零均值、统计结构相对简单的目标方差小、模式集中训练收敛明显更快。这直接决定了后面构造训练标签的方式——监督目标不是干净图而是“干净图 - 含噪图”的差值。把这个想清楚后面看训练代码就不会觉得别扭了。3. 把训练集和测试集整理成可训练数据切patch、归一化与划分脚本3.1 数据来源与目录盘点解压压缩包之后我一般先不急着打开模型代码而是盘点数据目录。如果自带测试集常见来源是 Set12、BSD68、Kodak24 这类公开基准图训练集常见的是 BSD400 这类老牌数据集。如果压缩包里只有模型没有数据也可以自己按这些公开基准组织。无论来源如何我都会整理成统一的三段式目录data/ raw/train/ # 原始干净图png 或 jpg processed/ train/clean/ train/noisy/ val/clean/ val/noisy/ test/clean/ test/noisy/先做一遍粗筛分辨率小于 40×40 的图直接丢弃带水印、文字标注的图也建议移出训练目录否则它们会成为训练样本里的异常值拉低测试集上的 PSNR。区分 train / val / test 三个子集的逻辑要明确训练集负责学参数验证集负责决定“什么时候停、保存哪一版模型”测试集只做最终验收。这里最重要的原则是测试集只能碰一次。你在测试集上反复试参数最终分数就是虚高的模型换到真实新图上会翻车。这个坑后面第 5 章专门展开。3.2 切patch、加噪与数据增强为什么不整图训练两个原因显存和梯度稳定性。一张 1080P 图像直接进网络一张就把显存吃穿而且 batch_size 等于 1每个 batch 的梯度方向受单张图内容影响很大训练过程会来回震荡。切 patch 是标准解法也是这个标题里“训练集”最常见的组织方式。参数上我一般这样设patch 尺寸 40 或 50 像素stride 与 patch 尺寸相同时是不重叠切块stride 取 10 到 20 时是重叠切块。不重叠切块数据量小、训练快重叠切块每个 local 区域都有多个样本相当于隐式数据增强但磁盘占用和训练时间都会增加。加噪时用高斯噪声 σ25对应 [0,255] 像素强度生成公式是 noisy clean σ/255 * randn。数据增强只做几何变换随机水平翻转、垂直翻转、旋转 90 度。去噪任务里这些变换不改变噪声分布也不改变图像语义能比较安全地把训练样本量放大 8 倍。不要对训练图做颜色抖动这类增强去噪任务希望输入分布的统计特征保持稳定。加噪有两种做法训练时在线加或者离线生成后存成目录。我推荐离线生成。原因很实际离线加噪后的目录一旦固定本次实验的输入就是完全可复现的训练时每次读取都会用同一份像素数据调参排错时少一个变量。代价是磁盘占用多一份但 40×40 的 uint8 patch 占用非常小完全可以接受。3.3 统一的划分与加噪脚本下面这个脚本把“按原图划分 → 切 patch → 加高斯噪声 → 存成 train/val 两套目录”一次做完import os import numpy as np import cv2 from glob import glob SRC_TRAIN data/raw/train OUT data/processed PATCH_SIZE 40 STRIDE 40 SIGMA 25.0 VAL_RATIO 0.1 os.makedirs(f{OUT}/train/clean, exist_okTrue) os.makedirs(f{OUT}/train/noisy, exist_okTrue) os.makedirs(f{OUT}/val/clean, exist_okTrue) os.makedirs(f{OUT}/val/noisy, exist_okTrue) paths sorted(glob(f{SRC_TRAIN}/*.png)) sorted(glob(f{SRC_TRAIN}/*.jpg)) # 先按原图文件划分避免同一张图同时进 train 和 val np.random.seed(123) perm np.random.permutation(len(paths)) val_n max(1, int(len(paths) * VAL_RATIO)) val_ids set(perm[:val_n].tolist()) idx 0 for i, p in enumerate(paths): img cv2.imread(p, cv2.IMREAD_GRAYSCALE).astype(np.float32) / 255.0 h, w img.shape for y in range(0, h - PATCH_SIZE 1, STRIDE): for x in range(0, w - PATCH_SIZE 1, STRIDE): clean img[y:y PATCH_SIZE, x:x PATCH_SIZE] noise np.random.randn(PATCH_SIZE, PATCH_SIZE) * (SIGMA / 255.0) noisy np.clip(clean noise, 0.0, 1.0) sub val if i in val_ids else train cv2.imwrite(f{OUT}/{sub}/clean/{idx:06d}.png, (clean * 255.0).astype(np.uint8)) cv2.imwrite(f{OUT}/{sub}/noisy/{idx:06d}.png, (noisy * 255.0).astype(np.uint8)) idx 1 print(fgenerated {idx} patch pairs, val ratio {VAL_RATIO * 100:.0f}%)代码里的关键点有三个。第一划分发生在切 patch 之前依据是原图文件编号这样同一张原图的 patch 永远不会同时出现在训练集和验证集里如果反过来先切 patch 再随机分就会造成数据泄漏。第二SIGMA / 255.0 的换算是必须的因为图像先归一化到了 [0,1]σ25 也要对应换算到 0.098。第三保存成 uint8 是为了节约磁盘训练加载时再除 255 还原成 [0,1]前后必须保持一致。注意划分必须发生在切 patch 之前否则同一张原图的 patch 可能同时进入训练集和测试集这就是数据泄漏。泄漏会让测试集 PSNR 虚高 23 dB但换到真实照片上立刻现原形。4. 用PyTorch搭一个能跑通的图像去噪模型结构与训练参数怎么设4.1 残差网络结构定义DnCNN 风格的结构实现很紧凑下面是按 17 层深度写的模型定义import torch import torch.nn as nn class DenoiseCNN(nn.Module): def __init__(self, depth17, mid_channels64, image_channels1): super().__init__() layers [] # 第一层Conv ReLU不加 BN layers.append(nn.Conv2d(image_channels, mid_channels, kernel_size3, padding1, biasFalse)) layers.append(nn.ReLU(inplaceTrue)) # 中间层Conv BN ReLU for _ in range(depth - 2): layers.append(nn.Conv2d(mid_channels, mid_channels, kernel_size3, padding1, biasFalse)) layers.append(nn.BatchNorm2d(mid_channels)) layers.append(nn.ReLU(inplaceTrue)) # 最后一层Conv输出残差噪声图 layers.append(nn.Conv2d(mid_channels, image_channels, kernel_size3, padding1, biasFalse)) self.net nn.Sequential(*layers) def forward(self, noisy): residual self.net(noisy) return residual, noisy - residualforward 返回两个张量第一个是残差图也就是模型估计的噪声第二个是去噪结果由含噪图减去残差得到。这个设计的核心是训练目标从“整张干净图”变成了“零均值噪声”分布更集中收敛更快也避免模型把干净图的高频细节直接照抄到输出里。参数选择上depth17 时感受野 35 像素已经是中等噪声去噪的合理配置depth20 时感受野 41 像素更平滑但显存和训练时间增加。mid_channels64 是显存与表达能力的折中显存紧张时可以先降到 32 跑通流程。image_channels 在灰度任务里是 1彩色任务里如果是三通道直接训练就是 3但更推荐的做法是只做亮度通道第 6 章会说。4.2 训练主循环与参数基准训练阶段的数据集类直接读取上一章生成的两个目录注意 Dataset 返回的目标是“干净图 - 含噪图”不是干净图本身import torch import torch.nn.functional as F from torch.utils.data import Dataset, DataLoader import cv2 import numpy as np from glob import glob class NoisyCleanDataset(Dataset): def __init__(self, root): self.clean sorted(glob(f{root}/clean/*.png)) self.noisy sorted(glob(f{root}/noisy/*.png)) def __len__(self): return len(self.clean) def __getitem__(self, i): clean cv2.imread(self.clean[i], cv2.IMREAD_GRAYSCALE).astype(np.float32) / 255.0 noisy cv2.imread(self.noisy[i], cv2.IMREAD_GRAYSCALE).astype(np.float32) / 255.0 # 随机水平翻转作为数据增强 if np.random.rand() 0.5: clean np.fliplr(clean).copy() noisy np.fliplr(noisy).copy() return torch.from_numpy(noisy)[None], torch.from_numpy(clean - noisy)[None] device torch.device(cuda if torch.cuda.is_available() else cpu) model DenoiseCNN(depth17, mid_channels64, image_channels1).to(device) opt torch.optim.Adam(model.parameters(), lr1e-3) scheduler torch.optim.lr_scheduler.StepLR(opt, step_size30, gamma0.1) train_loader DataLoader(NoisyCleanDataset(data/processed/train), batch_size16, shuffleTrue, num_workers4, pin_memoryTrue) val_loader DataLoader(NoisyCleanDataset(data/processed/val), batch_size16, shuffleFalse, num_workers2) best_val_mse float(inf) EPOCHS 50 for epoch in range(EPOCHS): model.train() for noisy, noise_target in train_loader: noisy noisy.to(device) noise_target noise_target.to(device) residual, _ model(noisy) loss F.mse_loss(residual, noise_target) opt.zero_grad() loss.backward() opt.step() scheduler.step() # 每 5 个 epoch 在 val 集上算一次 MSE保存最优模型 # 最终验收看测试集 PSNR/SSIM评估脚本在第 5 章 if (epoch 1) % 5 0: model.eval() val_mse 0.0 with torch.no_grad(): for noisy, noise_target in val_loader: noisy noisy.to(device) noise_target noise_target.to(device) residual, _ model(noisy) val_mse F.mse_loss(residual, noise_target).item() * len(noise_target) val_mse / len(val_loader.dataset) if val_mse best_val_mse: best_val_mse val_mse torch.save(model.state_dict(), best_denoise.pt) model.train()训练循环里最容易理解错的是 loss 的计算F.mse_loss(residual, noise_target)residual 是模型输出noise_target 是 Dataset 里构造的 clean - noisy。也就是说模型在学着预测“这张图里混入了多少噪声”而不是直接学着画一张干净图。这一点踩坑的人非常多。参数基准按这张表来调基本不会出大问题参数推荐值说明patch_size40 或 50小于 32 时感受野覆盖不全batch_size1632显存不够就用 batch8 加梯度累积learning_rate1e-3Adam 默认学习率30 个 epoch 后降到 1e-4depth / mid_channels17 / 64超过 20 层收益递减显存却涨得明显epochs50配合 StepLR4550 epoch 基本进入平台期损失函数默认用 MSE原因很直接高斯噪声假设下MSE 对应最大似然估计而且 PSNR 本身就是按 MSE 定义的训练目标与评估指标一致。如果测试时发现噪声更接近椒盐噪声再把 loss 改成 L1不需要改网络结构。还有一个实用技巧如果想用一个模型覆盖多种噪声强度训练时对每个 patch 随机采样一个 σ范围 1550 均匀取值加噪时就用这个随机 σ。这样推理时不需要事先估计噪声强度。相比之下训练 σ15 / 25 / 50 三个模型再做模型融合只适合对效果有极致要求、且能容忍三倍推理时间的情况工程上我很少这么干。5. 训练与测试集验证的常见问题PSNR算不准和数据泄漏怎么排查5.1 用测试集算 PSNR / SSIM训练结束后的正式验收要在完整测试图上进行不是在 40×40 的 patch 上逐块算完再取平均。整图评估更接近真实使用场景也更容易和公开论文的数字对比。评估脚本按“目录组织一致”的原则写import numpy as np import torch import cv2 from glob import glob from skimage.metrics import peak_signal_noise_ratio, structural_similarity def evaluate_on_test(model, test_root, device): model.eval() psnr_list, ssim_list [], [] with torch.no_grad(): for clean_path in sorted(glob(f{test_root}/clean/*.png)): noisy_path clean_path.replace(/clean/, /noisy/) clean cv2.imread(clean_path, cv2.IMREAD_GRAYSCALE).astype(np.float32) / 255.0 noisy cv2.imread(noisy_path, cv2.IMREAD_GRAYSCALE).astype(np.float32) / 255.0 noisy_t torch.from_numpy(noisy)[None, None].to(device) _, denoised model(noisy_t) denoised_np denoised[0, 0].cpu().numpy() psnr_list.append(peak_signal_noise_ratio(clean, denoised_np, data_range1.0)) ssim_list.append(structural_similarity(clean, denoised_np, data_range1.0)) return float(np.mean(psnr_list)), float(np.mean(ssim_list))这段代码有三个容易被忽略的细节。第一data_range1.0 对应归一化到 [0,1] 的 float 输入如果你用 uint8 的 0255 输入data_range 必须写成 255否则 PSNR 会整体偏移 48 dB 左右。第二peak_signal_noise_ratio 的第一个参数是干净图第二个是去噪图顺序反了不影响数值但 SSIM 的两个输入最好也统一。第三SSIM 对输入尺寸敏感win_size 默认是 7小于 7 像素的图像会直接报错这也是为什么要整图评估而不是用 patch 评估。PSNR 的绝对数值怎么判断σ25 的高斯噪声下30 dB 是及格线3335 dB 属于正常水平超过 38 dB 就要怀疑评估流程里有没有数据泄漏了。SSIM 接近 1 表示结构保持得好通常去噪后的 SSIM 在 0.95 以上才算肉眼可接受的修复效果。PSNR 并不完美它对轻微几何失真不敏感有时候去噪把纹理磨平了PSNR 反而可能上升。所以评估时 PSNR 和 SSIM 两个指标都要看PSNR 反映像素误差的总体水平SSIM 反映人眼感知的结构相似度。只盯 PSNR 调参很容易调出一个“指标好看、观感发糊”的模型。5.2 复现时最容易翻车的四个高频问题第一个高频问题训练 loss 降得很漂亮测试集 PSNR 却来回震荡。这通常不是模型问题而是数据归一化范围不一致。训练时代码里写了 /255.0测试时直接读了 uint8 就送进网络输入分布整体偏移了 255 倍模型输出自然乱套。解决方法是把读取图像和归一化封装成同一个预处理函数训练和测试共用不要各写一份。第二个高频问题测试图上出现规律的方块边界像拼图痕迹。常见原因是推理时没有切换模型状态。训练阶段 BatchNorm 用的是当前 batch 的统计量测试阶段如果不调用 model.eval()BN 层还会继续按 batch 统计量计算而测试时 batch 里图片内容差异大统计量不稳定就会在输出图上留下和分块位置相关的伪影。解决方法是推理前显式调用 model.eval()同时用 torch.no_grad() 关闭梯度计算。第三个高频问题训练 PSNR 虚高得离谱测试集一塌糊涂。这是数据泄漏。最常见的泄漏路径有两种一是切 patch 时把同一张原图的 patch 同时分进了训练集和测试集导致测试集里出现了训练样本的近似副本二是把数据增强后的图像也放进了测试集。解决方法是严格按原图文件编号划分先划分再切 patch并把划分结果保存成清单 txt每次实验加载同一个清单。第四个高频问题同一个模型在 σ25 的测试集上达到 33 dB换到 σ50 直接掉到 25 dB。这是噪声强度失配。固定 σ 训练出来的模型只学会了那一档噪声的分布遇到更高噪声时输入分布完全不在训练覆盖范围内。解决方法是训练时随机采样 σ覆盖 1550 的范围如果压缩包里的测试集是固定 σ 生成的那就先确认测试噪声强度再按同一强度训练。6. 进阶把去噪模型接到照片修复流程里的两个技巧6.1 彩色照片只做亮度通道去噪照片修复模型落地时我一般不会直接对 RGB 三个通道都跑一遍 CNN。常见做法是先把彩色图转到 YCbCr 颜色空间只对 Y 亮度通道做 CNN 去噪Cb/Cr 色度通道用轻量滤波处理。理由是人的视觉系统对亮度细节最敏感而色度通道的低频特性让噪声表现为色斑用双边滤波就能压下去import cv2 import numpy as np import torch bgr cv2.imread(old_photo.png) ycbcr cv2.cvtColor(bgr, cv2.COLOR_BGR2YCrCb) y, cb, cr cv2.split(ycbcr) # 亮度通道归一化后送入去噪模型 y_f y.astype(np.float32) / 255.0 _, y_denoised model(torch.from_numpy(y_f)[None, None].to(device)) y_denoised np.clip(y_denoised[0, 0].cpu().numpy(), 0.0, 1.0) # 色度通道做轻量双边滤波 cb cv2.bilateralFilter(cb, d5, sigmaColor10, sigmaSpace5) cr cv2.bilateralFilter(cr, d5, sigmaColor10, sigmaSpace5) merged cv2.merge([(y_denoised * 255.0).astype(np.uint8), cb, cr]) result cv2.cvtColor(merged, cv2.COLOR_YCrCb2BGR)这样做的直接收益是显存占用降到三分之一推理速度也快三倍而视觉质量几乎没有可感知的损失。对于灰度老照片就完全不需要这一层转换直接单通道训练和推理。6.2 低显存下的分块推理如果测试图是几千像素的大图即使单通道整图推理也会爆显存。分块推理时要注意拼接缝块与块直接拼接会留下可见的接缝常见做法是块之间重叠 16 像素重叠区域按像素位置做线性加权融合靠近块中心权重高靠近块边缘权重低。配合 model.eval() 和 torch.no_grad()一块 256×256 的区域在 1080Ti 上只需要几毫秒。这套流程跑通之后把同样的数据准备脚本改一改就能迁移到其他图像复原任务上。我栽过的大跟头是习惯拿同一批图反复验证、把测试集用成了调参集最后交付时模型在真正老照片上原形毕露。从那以后解压一个项目我第一件事是先跑评估脚本、把测试集“锁死”再谈任何训练参数。这是血泪经验希望帮到你。本文还有配套的精品资源点击获取