简介:这是一份基于扩散模型的图像恢复完整可运行代码,面向深度学习与计算机视觉方向的研究者、学生及算法工程师,覆盖去雨、去雾、去雪等多个典型恢复任务;用户仅需调整数据集路径,即可加载预训练权重直接训练或测试。资源包共三十个文件,主体为十三个Python脚本,负责模型定义、训练与评估、采样以及PSNR/SSIM指标计算;另含YAML参数配置、XML工程配置、缓存文件和Markdown说明,整体打包为29KB的RAR压缩包。目前已有14979人学习查看,适合作为图像恢复任务的参考实现。代码训练和测试完整可运行,附详细实验操作流程,参数与数据集路径的修改方法均有说明;模型、UNet骨干、扩散采样等模块分层清晰,关键逻辑带注释,配套博客还可进一步辅助理解,便于直接迁移到自己的任务中。
1. 扩散模型做图像恢复:为什么大家都从 GAN 转向了 DDPM
图像恢复这件事,从前是 GAN 的天下。SRGAN、ESRGAN、DeblurGAN 这些名字,做过超分和去噪的应该都不陌生,生成器加判别器对抗训练,出来的图确实锐利,但代价是容易产生伪纹理——明明是一块平滑的皮肤,GAN 能给你画出皮革纹路。你在测试集上 PSNR 刷得再高,拿到真实 noisy 图上跑一遍,翻车率不低。扩散模型(Diffusion Model)的出现,把这个局面彻底改掉了。
扩散模型做图像恢复的基本思路,用一句话概括就是:把还原图的过程,建模成从纯噪声开始、逐步去噪的逆向过程,同时把低质量图作为条件,引导每一次去噪都朝着“更接近真实高清图”的方向走。它不像 GAN 那样需要判别器来“骗”生成器,而是直接把每一步去噪的误差作为监督信号,训练稳定得多,生成结果也更少出现幻觉纹理。这个思路在去噪、去雨、超分、修复场景都能用,而且同一份代码改改条件注入方式就能复现多个任务。
这篇文章拆的是一份 DDPM 架构的扩散模型图像恢复完整代码,附带详细实验操作流程。我按自己的复现习惯,先讲清楚前向过程、逆向过程、损失函数、采样器这些关键参数背后的原理,然后给出一条能直接跑通的操作链路,最后把我复现时踩过的坑和排查经验列出来。不管你是刚接触扩散模型,还是已经从理论视频里看得一头雾水、手里缺一份能跑的参考代码,这篇笔记都能让你少走一大段弯路。
2. 从 DDPM 到条件扩散模型:先搞清楚去噪过程的物理意义
2.1 前向过程为什么是固定添加噪声,而不是学出来的
DDPM 最核心的设计,是前向过程完全不需要学习。给定一张高清图 $x_0$,我们按照一个预设的噪声调度表(noise schedule),在 T 步内逐步对它添加高斯噪声。每一步的公式是:
# beta schedule: 线性从 1e-4 到 0.02,T 步扩散 T = 1000 betas = torch.linspace(1e-4, 0.02, T) alphas = 1.0 - betas alphas_cumprod = torch.cumprod(alphas, dim=0) # 累计乘积 sqrt_alphas_cumprod = torch.sqrt(alphas_cumprod) sqrt_one_minus_alphas_cumprod = torch.sqrt(1.0 - alphas_cumprod)这里alphas_cumprod是前 t 步的 α 连乘,物理意义是“原始信号残留比例”。当 t 越大,sqrt_alphas_cumprod越小,代表原图信息被保留得越少,噪声占比越多。到 t = 1000 时,图基本接近纯高斯噪声。这个过程是马尔可夫链,每一步只依赖前一步状态,而且因为高斯分布的叠加性质,我们可以直接从 $x_0$ 一步采样出任意第 t 步的加噪结果,不需要真的迭代 1000 次。
这就是重参数化的妙处:训练扩散模型不需要跑完整条马尔可夫链,只需要随机抽一个 t,然后让网络预测“我加的这步噪声是什么”,把预测误差当作梯度。实际训练的时候,t是从均匀分布里随机采样的,这意味着模型在训练期间见过各个退化程度的图,最终学会的是“给我一张任意噪声程度的图,我能估计出从干净图到这张图所加的噪声”。这个能力,就是逆向过程的基础。
2.2 逆向过程和 U-Net 的条件注入:低质量图从哪里进去
逆向过程是扩散模型用于图像恢复的落地关键。它的目标是从随机高斯噪声 $x_T$ 出发,逐步还原出 $x_0$,但在每一步去噪时,我们手上除了当前噪声图,还有一张低质量观测图 y(比如带噪图、低分辨率图或带雨纹图)。这张 y 必须参与到去噪决策中,否则模型只是“凭空生成一张高清图”,而不是“恢复出与 y 对应的高清图”。
常见做法是把 y 做 channel concat,拼接到噪声图上,再一起送入 U-Net。我这份代码里采用的就是这种方式:
# 训练时: 把低质图 y 和加噪的 x_t 在通道维拼接 x_t = q_sample(x_start, t, noise) # x_start = y + 残差 或 x_start = 高清图 x_cat = torch.cat([x_t, y_cond], dim=1) # 输入 U-Net 的通道数 = 3 + 3 = 6 pred_noise = model(x_cat, t)这里有个细节你要分清。在纯 DDPM 生成任务里,x_start是真实干净图,条件变量不存在;在图像恢复任务里,通常两种建模方式:
- 一种是
x_start = y,模型学的是从 y 的任意加噪版本还原出 y,全程只优化去噪能力,最后采样时以 y 为起点、只做少量去噪步; - 另一种是
x_start = high_res_target(即真实高清参考图),低质图 y 只作为条件拼进去,模型学的是“在 y 的信息约束下,从噪声还原出高清版本”。
我这份代码用的是第二种,符合 diffusion model 图像恢复的主流范式。U-Net 的 channel 数设置我直接沿用 DDPM 默认配置:base_channels = 128,每个 stage 翻倍,attention 从 16x16 feature map 那一层开始加。训练参数量在 114M 左右,单张 3090 上 batch size 8 跑 256x256 没有压力。
2.3 损失函数为什么选 L1 而不是 L2
扩散模型的损失函数可以选 L1 或 L2,两种在数学期望上等价,但 L1 的梯度在残差较大时更稳定,不容易被 outlier 像素主导。我复现时先用了 L2,发现训练到中后期容易出现局部震荡,loss 曲线在 0.18 附近反复横跳不下降;切到 L1 之后收敛明显更平滑。如果你习惯用 MSE,也问题不大,只是建议配合 lower learning rate(1e-5 级别)来压震荡。我最终使用的损失是:
loss = F.l1_loss(model(x_cat, t), noise) # 预测噪声与真实噪声的 L1 距离这里预测的目标具体是噪声,而不是直接预测图像。这是 DDPM 的原始设计——网络不学习“如何直接生成图”,而是学习“如何去除已知噪声”。后文采样时,根据网络预测的噪声反推出前一时刻的图像分布,这个过程叫 Langevin 采样。
3. 完整代码布局与训练流程:一份可复现的 DDPM 图像恢复实现
3.1 项目文件结构与数据准备
拿到这份代码之后,第一件事不是直接跑 train.py,而是先把目录结构过一遍。这是我整理后的标准布局:
diffusion_restore/ ├── configs/ │ └── base.yaml # 训练/采样/数据全局配置 ├── data/ │ └── dataset.py # 低质图/高清图配对加载 ├── models/ │ ├── unet.py # U-Net 骨干 │ └── diffusion.py # DDPM 前向/逆向采样 ├── utils/ │ ├── metrics.py # PSNR / SSIM 计算 │ └── checkpoint.py # 模型保存与恢复 ├── train.py # 训练入口 ├── sample.py # 验证/生成入口 └── test.py # 测试集指标评估数据集部分要准备成配对格式:同一张场景下,一张清晰图作为 GT(ground truth),另一张是它的退化版本——可以是自行加高斯噪声、下采样、加雨纹的合成数据。代码里定义了一个PairedDataset,读取方式比较直接:
# data/dataset.py class PairedDataset(Dataset): def __init__(self, gt_dir, lq_dir, image_size=256): self.gt_paths = sorted(glob.glob(f"{gt_dir}/*.png")) self.lq_paths = sorted(glob.glob(f"{lq_dir}/*.png")) self.image_size = image_size def __getitem__(self, idx): gt = Image.open(self.gt_paths[idx]).convert("RGB") lq = Image.open(self.lq_paths[idx]).convert("RGB") # 统一缩放到 image_size,并做随机 crop 增强 transform = T.Compose([ T.RandomCrop(self.image_size), T.RandomHorizontalFlip(), T.ToTensor(), T.Normalize((0.5, 0.5, 0.5), (0.5, 0.5, 0.5)) ]) return transform(gt), transform(lq)归一化用 (0.5, 0.5, 0.5) 做均值和方差,这会把像素值压到 [-1, 1] 区间。扩散模型里这个区间是默认操作空间,因为噪声添加公式假设数据对称分布在 0 附近;如果你直接用 [0, 1] 区间,前向过程会在 t 较小时出现明显分布偏移,恢复结果偏暗。
3.2 训练入口:从加载 checkpoint 到 loss 收敛判定
train.py 的核心循环我做了最小化封装。先贴主训练逻辑的关键代码:
# train.py for epoch in range(start_epoch, epochs): for batch_idx, (gt, lq) in enumerate(train_loader): gt, lq = gt.cuda(), lq.cuda() t = torch.randint(0, T, (gt.size(0),), device="cuda").long() noise = torch.randn_like(gt) x_t = q_sample(gt, t, noise) # 对 GT 加噪 x_cat = torch.cat([x_t, lq], dim=1) # 和低质图拼接 pred_noise = model(x_cat, t) loss = F.l1_loss(pred_noise, noise) optimizer.zero_grad() loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) optimizer.step() ema.update(model) # 指数滑动平均更新几个参数值得重点说明:
t是每个样本独立随机采的,不是 batch 统一的。这样同一个 batch 内部不同样本的退化程度不同,等价于隐式做数据增强,避免模型只对某一段噪声水平熟练。clip_grad_norm_(1.0)很关键。扩散模型在 t 较大时,噪声估计的误差梯度会异常大,不加梯度裁剪我会频繁遇到 loss 突然变成 NaN。ema.update(model)使用指数滑动平均,decay 我设 0.995。EMA 权重下的模型在采样阶段通常比原始权重稳定得多,开头阶段可能看不出差别,训练 50 epoch 后对比就很明显。
训练过程中我一般每 500 步存一次 checkpoint,命名带上 step 数。同时把 loss 曲线落在 tensorboard 里。我复现时用的是 AdamW,初始学习率 2e-4,配合 cosine schedule 衰减到 1e-6。batch size 8 对应 256x256 输入,显存占用约 18GB,如果你是 3090/4090 这种 24GB 卡可以正常跑;显存紧的话把 batch size 降到 4,学习率同步降到 1e-4,不要硬撑。
3.3 采样与验证:DDIM 采样器如何加速推理
训练完成后进入采样环节。原始 DDPM 逆向采样需要完整跑 T 步,也就是 1000 次网络前向推理,这在图像恢复场景里难以接受——一张图要等几十秒。代码里实现了 DDIM 采样器,可以在 30~50 步内完成高质量采样。核心逻辑如下:
# models/diffusion.py DDIM 采样 @torch.no_grad() def sample_ddim(model, lq, steps=50, eta=0.0): model.eval() x = torch.randn_like(lq) # 随机高斯噪声起点 step_seq = torch.linspace(T-1, 0, steps).long() for i in range(steps-1): t = step_seq[i] t_next = step_seq[i+1] x_cat = torch.cat([x, lq], dim=1) pred_noise = model(x_cat, t) # DDIM 更新公式 alpha_t = alphas_cumprod[t] alpha_next = alphas_cumprod[t_next] x0_pred = (x - torch.sqrt(1 - alpha_t) * pred_noise) / torch.sqrt(alpha_t) sigma = eta * torch.sqrt((1 - alpha_next) / (1 - alpha_t)) * torch.sqrt(1 - alpha_t / alpha_next) c1 = torch.sqrt(alpha_next) c2 = torch.sqrt(1 - alpha_next - sigma**2) x = c1 * x0_pred + c2 * pred_noise + sigma * torch.randn_like(x) return xeta=0.0时 DDIM 变成确定性采样,即给定同一个随机起点和同一个 lq,输出严格相同,这对实验对比是可贵的性质——你可以放心对比不同 checkpoint 的输出差异,不会被随机性干扰。eta=1.0则退化成类似 DDPM 的随机采样,多样性更强,但单张结果的稳定性弱一些。图像恢复任务我建议固定eta=0.0,既不引入额外随机性,恢复质量反而更高。
4. 避坑指南:我复现这个项目时遇到的五个典型问题
4.1 loss 曲线在 0.2 附近震荡不下去
现象:训练了 300 epoch,L1 loss 一直稳定在 0.2 附近,上下振幅不超过 0.01,无论调学习率还是优化器都不动。
原因:我排查后定位到数据归一化不一致。低质图 lq 在 dataset 里做了 Normalize(0.5),但生成 lq 图时保存的不是 [-1,1] 而是 [0,1],加载后再归一化等于做了两次偏移,导致条件信息分布和网络预期不一致。
解决:把数据管道的归一化统一成一套,GT 和 lq 都用 (0.5, 0.5, 0.5),在保存 lq 图时先把像素乘 2 减 1 再存。这之后 loss 在 30 epoch 内降到 0.15 以下。
4.2 生成结果整体偏灰,对比度明显不足
现象:采样出来的图轮廓清晰,细节也能看,但颜色饱和度低、暗部不够暗,像蒙了一层灰纱。
原因:起始噪声 x_t 的采样分布和训练时不一致。我在采样代码里初始化x = torch.randn_like(lq),但训练时noise = torch.randn_like(gt),两者 shape 一致所以没报错,可如果 lq 是经过 Resize 的,它的分辨率跟 GT 不同,噪声的频段分布就会有偏差。
解决:采样前强制把 lq resize 到和 GT 相同的尺寸再进网络。或者在数据加载阶段把 GT 和 lq 的尺寸设定为相同值,不要 test 时单独用任意尺寸输入。U-Net 虽然能接受任意分辨率,但扩散模型对输入分布极其敏感,尺寸不匹配就是暗病。
4.3 显存溢出,batch size 调到 2 还是 OOM
现象:训练刚开始,step 0 就报 CUDA out of memory,直接中断。
原因:不是 U-Net 本身太占显存,而是 attention 层在 256x256 分辨率下产生了巨大的中间特征。我的 base_channels=128,第四个 stage 是在 32x32 feature map 上做 attention,这部分 memory 占用接近 8GB。
解决:把 attention 分辨率改成只在 16x16 层启用,即 attention_resolution = [16],会损失一点点高频细节但显存直降。另一个有效办法是开 gradient checkpointing,用时间换显存,代价是迭代变慢约 20%。
4.4 恢复图产生重复纹理,像原图被复印了好几份
现象:在真实低质图上测试时,某些区域出现周期性重复结构,尤其是纹理密集区域,比如树丛、头发、草地。
原因:训练数据里低质图是由同一张 GT 加噪声合成的,模型学会了“根据自身高频模式联想补充”,也就是所谓的高频幻觉。真实退化图像的高频分布和合成噪声差异很大,模型就开始了自由发挥。
解决:一是训练数据中混入多种退化类型,不只高斯噪声,加一部分下采样模糊、雨纹、JPEG 压缩噪声,模型泛化性会明显提升;二是推理时可以试试增加 DDIM steps 到 100,更多步数意味着每一步恢复的幅度更小,幻觉被逐步抑制。
4.5 多卡训练 loss 降了但效果不如单卡
现象:用 DataParallel 跑双卡,训练 loss 比单卡降得更快,但最终采样效果无论是 PSNR 还是主观视觉,都明显弱于单卡。
原因:BN 层在 DataParallel 里每个卡独立统计,batch 变小导致统计量不稳定。扩散模型的 U-Net 里虽然没有显式 BN,但 GroupNorm 在数据并行下有类似问题——不同卡的 normalization 统计不一致,梯度更新互相干扰。
解决:直接用 DistributedDataParallel 替代 DataParallel,每个进程独立维护完整的 batch 统计量。或者简单点,放弃多卡,单卡 3090 跑 300 epoch 大约 30 小时,完全可接受。
5. 实验流程与指标评估:如何判断一组参数是否真的有效
5.1 指标计算:PSNR 和 SSIM 怎么算才是对的
图像恢复领域最常规的客观指标是 PSNR 和 SSIM。但这里有个容易翻车的细节:扩散模型输出的像素范围是 [-1,1],你和 GT 比较之前必须转回 [0,255] 的 uint8 空间,如果直接在 float 张量上算 PSNR,结果会整体偏高 2~4 dB,这在论文里是绝对不允许的。这是我用的计算代码:
# utils/metrics.py def calculate_psnr_ssim(pred, gt, max_val=255.0): pred = pred.clamp(-1, 1) * 127.5 + 127.5 gt = gt.clamp(-1, 1) * 127.5 + 127.5 pred = pred.cpu().numpy().round().astype(np.uint8) gt = gt.cpu().numpy().round().astype(np.uint8) psnr = skimage.metrics.peak_signal_noise_ratio(gt, pred, data_range=max_val) ssim = skimage.metrics.structural_similarity( gt, pred, channel_axis=-1, data_range=max_val) return psnr, ssim注意structural_similarity的channel_axis参数,在不同版本的 skimage 里这个位置参数有变化,旧版是multichannel=True,新版是channel_axis=-1。我一开始用旧参数,在 scikit-image 0.22 上直接报错,改成channel_axis=-1就好了。
PSNR 和 SSIM 不能只看绝对值,要和你训练的 baseline 对比。比如你的输入 lq 本身 PSNR 是 22 dB,恢复后到 28 dB,这是有效提升;如果只从 22 到 23.5,那说明模型基本没学到东西,只是在做轻度平滑。
5.2 实验分组设计:什么样的对比能说明模型真的有效
我做扩散模型图像恢复实验时,通常分成三组对比:一组是输入 lq 直接双三次插值的结果,作为最弱 baseline;一组是用经典 U-Net 做回归预测的结果,代表传统监督学习的上限;一组是当前扩散模型的结果。同一份测试集上,三组跑完再比较 PSNR / SSIM 和主观视觉。
如果扩散模型的 PSNR 反而不如普通 U-Net 回归,不要慌张。这是常见现象,因为扩散模型的目标分布更接近真实高清图的流形,而 PSNR 是逐像素误差,GAN 和扩散模型这一类生成式方法在 PSNR 上通常拼不过直接回归方法,但 SSIM 和主观效果更占优势。实际落地时,我会同时报两组指标,在论文或评审材料里写清楚:扩散模型的优势在感知质量,不在像素级误差。
5.3 采样步数多少合适:50 步与 250 步之间的权衡
DDIM 采样步数直接决定推理耗时。我测试过同一 checkpoint 在不同 steps 下的表现:10 步时图像有明显块状噪声,30 步以上逐步改善,50 步时和 100 步的 PSNR 差异在 0.1 dB 以内。考虑到 100 步推理耗时是 50 步的两倍,实际部署我一般建议 50 步为上限,对画质要求极高的场景再尝试 100 步。
时间开销方面,单张 3090 上,50 步 DDIM 采样一张 256x256 图大约 2.5 秒,50 步耗时约 1.2 秒。如果你的场景是离线批量处理,这个速度可以接受;如果是实时性要求高的场景,得配合知识蒸馏或 latent diffusion 类方法进一步压缩步数,那就超出这份代码的范围了。
6. 进阶技巧:用这个 DDPM 代码做超分和去雨的小改造
这套代码最实用的点,是它不需要伤筋动骨,改动条件数据流就能迁移到超分和去雨任务。这里分享我实际改过的两条路径,你拿到代码后可以照着切。
6.1 从去噪切到超分:只需要改数据配对和条件尺寸
去噪任务的输入 lq 和输出 GT 天然同尺寸,代码零改动。但超分任务里输入是下采样的小图,输出是高清大图,直接拼接会尺寸不匹配。我的做法是先把低分辨率图上采样到目标尺寸,再拼接到噪声图上:
# 超分场景的输入处理 lq_resized = F.interpolate(lq, size=(256, 256), mode='bicubic', align_corners=False) x_cat = torch.cat([x_t, lq_resized], dim=1)这个做法本质上是让模型自己学会“如何补全上采样造成的模糊和缺失”。要注意mode='bicubic'的选择,不同插值方式影响模型接收到的条件信息质量。双三次是通用选择,对 2x 超分效果好;4x 超分时可以考虑mode='nearest',保留边缘锐度,模糊连带的信息反而更少。你可以在配置里调这个参数,对比两组实验的 PSNR 差异。
6.2 切到去雨任务:控制合成雨纹的多样性
去雨任务的退化模型是clean + rain_streak,雨纹的形状、方向、密度都影响模型的泛化。我合成训练数据时用了三条随机雨纹生成路径:直线雨、弯曲雨、混合雨,每张图随机选择 1~3 种叠加。重点在于,不要让模型见到特定方向过硬的特征——雨纹方向最好在 [−60°, 60°] 之间随机旋转,否则测试集换个方向雨纹就容易失效。
训练超分和去雨时,我一般把steps保持默认 1000,eta=0,损失函数依旧 L1,不需要额外改结构。唯一要调整的是数据集image_size,如果你要处理 512x512 高清图,显存不够就把 batch size 再降一档,同时把随机 crop 改成从大图上采 256x256 的 patch 来训练,推理时用滑窗拼接。这个思路在 video 领域叫 patch-based inference,是验证大图效果最常用的手段。
6.3 两分钟验证你的改法是否有效
每次改完数据流程,我都会做一个 5-epoch 的冒烟测试,不跑完整训练。具体操作是:在base.yaml里把epochs改成 5,log_interval改成 10,数据量限定在 200 张图,训练完成后直接跑test.py看指标趋势:
python train.py --config configs/base.yaml --smoke_test python test.py --checkpoint ./checkpoints/model_step_2500.pth --test_dir ./data/test冒烟测试的关注点不是最终指标,而是 loss 是否在下降、log 里的时间和显存是否符合预期,以及采样输出的图像结构是否已经能看出内容轮廓。如果 5 epoch 后采样图还是一团噪声,要么是学习率设置不合理,要么是条件注入环节有 bug,先别急着跑长训练,把问题定位在源头。
要说我自己的习惯,从那以后我每次拿到一个新的扩散模型项目,都强制自己先跑一遍冒烟测试再动完整训练。这个动作帮我挡掉了至少五六次因为数据集路径配错、归一化不一致导致的十几个小时白跑。希望这份代码和踩坑笔记,也能帮你把第一个扩散模型图像恢复实验顺利跑通。
本文还有配套的精品资源,点击获取