☰
GAN图像去模糊实战:原理、代码与落地避坑指南
2026/10/11 11:14:21 网站建设 项目流程

简介:基于生成对抗网络的图像去模糊项目,面向计算机视觉研究者和中级以上深度学习开发者,针对运动模糊、失焦模糊等常见图像退化问题,提供完整算法实现与指导文档。资源包共65个文件,以36个Python脚本为核心,覆盖数据加载、网络定义、训练与评估流程,另含2个预训练权重、示例图片、演示动图、备份文件及技术文档,压缩包约34.3MB,目录按data、options、models等模块划分,便于对照源码快速定位。目前已有70人学习下载。算法以DCGAN为基础,生成器采用编码器-解码器与跳跃连接,判别器使用PatchGAN,并引入感知损失和注意力机制;数据预处理通过动态模糊核构造训练对,训练采用渐进式策略,在GoPro等数据集上PSNR达28.7dB、SSIM为0.89。项目附带训练好的权重和复原效果图,可直接加载测试或继续训练;技术文档还给出环境配置、参数调整与效果优化方法,适合学术研究与工程落地参考。

1. 基于生成对抗网络的图像去模糊:它解决的到底是哪种模糊?

你手上有一张糊掉的图——可能是手抖拍糊的夜景、老照片扫描件、或者监控视频里截出来的一帧。传统的去模糊方法(比如维纳滤波、Richardson-Lucy 算法)依赖对模糊核的估计,一旦模糊核估不准,结果就出现振铃效应,边缘黑一圈白一圈,看着更假。生成对抗网络(GAN)的思路完全不同:它不显式求模糊核,而是让生成器直接学习「模糊图到清晰图」的映射,再由判别器去逼生成器输出足够自然的细节。这就是 DeblurGAN 这类模型能流行起来的原因——不需要知道模糊是怎么造成的,只要你喂足够多的「模糊-清晰」图对,它就能把纹理和边缘大致恢复出来。

这篇笔记面向正在做图像复原、安防监控、文档扫描或者老照片修复的工程师。你会看到一个可用于训练的完整代码骨架、关键参数怎么调、以及那些论文里不会写的坑——比如训练到一半损失变成 NaN、判别器把生成器压死、还有测试时单张图比批量图更容易翻车的玄学。我们先从原理讲清楚,再给你能跑起来的脚本,最后用实际踩坑记录收尾。

2. 生成对抗网络去模糊的原理与选型:DeblurGAN 还是 DeblurGAN-v2?

2.1 生成器必须解决的两类模糊:运动模糊与失焦模糊

图像去模糊在数学上是一个反卷积问题。理想情况下,模糊图 B 可以写成清晰图 S 与点扩散函数(PSF,也称模糊核)k 的卷积再加噪声:B = S ⊗ k + n。传统方法要先估计 k,再去反卷积。问题是运动模糊的 k 随像素位置变化,失焦模糊的 k 又与景深相关,估计一个全局的 k 在复杂场景下几乎不可能。

GAN 把这个问题重新定义成一个条件生成任务。生成器 G 接收模糊图 B,输出预测的清晰图 S';判别器 D 负责区分「真实的清晰图 S」和「G 生成的 S'」。两者对抗训练的结果是:G 学会的映射不只去除模糊,还要让输出在纹理、边缘、颜色分布上接近真实清晰图。这比单纯的最小二乘回归更强——L2 损失会让输出变得平滑,而对抗损失会逼生成器去还原高频细节。

常见的去模糊 GAN 模型有三种技术路线。第一种是 DeblurGAN,使用 ResNet 风格的生成器和 PatchGAN 判别器,损失是感知损失加 WGAN-GP。第二种是 DeblurGAN-v2,特征是引入特征金字塔网络(FPN)作为生成器主干,backbone 可以换 MobileNet、Inception-ResNet 等,速度和效果都有提升。第三种是结合光流估计的模型(比如用于视频去模糊),需要额外的光流标注或预训练光流网络,工程复杂度更高。如果你只做单张图像去模糊,我建议优先 DeblurGAN-v2——它在保持效果的同时,推理速度比原始 DeblurGAN 快一倍以上。

2.2 感知损失、对抗损失与 L1 损失的配比

训练一个去模糊 GAN,损失函数直接决定输出风格。只有对抗损失,生成器会「骗过」判别器但输出可能出现伪纹理;只有像素级 L1/L2 损失,输出会偏模糊,因为多个清晰解平均下来就是模糊的。所以实际工程里必须混合多种损失。

我用得最多的一组配比是:

  • L1 损失(像素空间):权重 100,保证重建的全局结构正确。
  • 感知损失(Perceptual Loss):使用 VGG19 的 relu1_2、relu2_2、relu3_2、relu4_2 层,权重 10,保证特征空间相似。
  • 对抗损失:WGAN-GP 或 LSGAN,权重 1,保证细节纹理真实。
  • 梯度惩罚(GP):λ=10,用于稳定 WGAN 训练。

这个配比的意思很直白:L1 让画面不漂色,感知损失让边缘和语义结构像真的,对抗损失负责补上最后的高频细节。如果你发现输出图发虚,通常是感知损失权重偏低;如果出现彩色噪点,多半是对抗损失权重偏高或者判别器学习太快。不要照搬论文参数,彩色噪声图、灰度图、文档扫描图三种场景下,感知损失权重可能需要从 10 改到 5 或 20。

2.3 为什么选 PatchGAN 而不是普通判别器

普通判别器输出一个标量,表示整张图真或假。这对全局风格有效,但去模糊是一个局部问题——图的一小块区域恢复得好不好,需要逐块判断。PatchGAN 的判别器输出的是一个 N×N 的特征图,每个输出像素对应输入图像的一个感受野(patch),相当于对图中每个区域分别判真假。这样生成器会被迫把每一块都恢复好,而不是靠「整体平均」蒙混过关。

实际实现时,DeblurGAN 的判别器是 70×70 PatchGAN,也就是输出对 70×70 的有效感受野做判定。你可以在代码里调整 patch 大小:patch 越小,越关注高频纹理;patch 越大,越关注整体结构。我一般保持 70 不变,除非输入图分辨率特别小(比如 256 以下才改成 34)。

提示:如果训练时发现生成器输出的图「整体清晰但细节很假」,试着把 patch 调小;如果「细节死板但全局结构乱」,就调大。这个 trade-off 比调学习率更直接影响肉眼观感。

2.4 预训练权重与 backbone 选择

不要从零训练一个大型生成器。ResNet 和 FPN 的 backbone 用 ImageNet 预训练权重初始化,收敛速度至少快三倍。DeblurGAN-v2 的官方实现里,backbone 可以是 MobileNet(轻量)、Inception-ResNet-v2(高精度)。我自己在 CPU 跑推理时用 MobileNet,在 GPU 上训练和推理用 Inception-ResNet。

选 backbone 的准则就三条:显存够不够(Inception-ResNet 在 256×256 下训练要吃约 8GB 显存)、速度要求(实时监控场景下 MobileNet 更现实)、精度要求(离线修复老照片可以接受慢)。后面第 3 章给的代码骨架可以自由替换 backbone,只需要保证输出特征图的通道数对齐。

3. 最小可复现方案:数据准备与生成器-判别器代码骨架

3.1 数据集从哪来:合成模糊对与真实模糊对

训练 GAN 去模糊需要「模糊-清晰」图对。公开数据集首选 GoPro(用于运动去模糊,含 3214 对图像),里面是高速摄影合成的模糊图与对应清晰帧。另一个常用的是 Kohler 数据集,但样本数量小,不适合训练。如果你做文档或人脸去模糊,可以自行合成:用清晰图,随机生成运动模糊核(线性、圆形、曲线),再用 PIL 或 OpenCV 做卷积,加高斯噪声。

合成模糊核的代码我一般这样写:

import cv2 import numpy as np from numpy import random def make_motion_kernel(size=15, angle=30): # size: 核大小;angle: 运动方向角度 kernel = np.zeros((size, size), dtype=np.float32) center = size // 2 x = int(center + size * np.cos(np.deg2rad(angle)) / 2) y = int(center + size * np.sin(np.deg2rad(angle)) / 2) cv2.line(kernel, (center, center), (x, y), 1.0, thickness=2) kernel /= kernel.sum() # 归一化,保证亮度不变 return kernel def blur_image(img, kernel): return cv2.filter2D(img, -1, kernel)

逻辑说明:cv2.line在空白矩阵上画一条线段,线段的方向由 angle 控制,线宽 thickness 模拟曝光时间内物体位移的轨迹;归一化防止卷积后整体亮度下降。核大小 size 越大,模糊越强,一般 9 到 21 之间随机。如果你的图片本身分辨率高,核可以取 21;低分辨率图用 15 以下,否则模糊过头,生成器学不到细节。

真实模糊对的获取则要苦一些。常见做法是拍摄一段视频,用高速运动拍出模糊帧,再用相邻几帧的锐利帧做参考;或者直接采集监控中的模糊图,配合同一机位静止时拍摄的清晰图。注意对齐:哪怕是轻微抖动,像素没对齐,训练时判别器会很快发现生成器输出和清晰图位置偏差,导致损失震荡。

3.2 生成器代码:ResNet-blocks 与跳跃连接的取舍

去模糊生成器不需要像超分那样堆很多残差块。DeblurGAN 原版用了 9 个 ResNet block,输入输出都是 256×256×3。下面这个生成器是可以在单卡 8GB 下跑起来的最小版本:

import torch import torch.nn as nn class ResBlock(nn.Module): def __init__(self, channels): super().__init__() self.conv1 = nn.Conv2d(channels, channels, 3, 1, 1) self.in1 = nn.InstanceNorm2d(channels) self.conv2 = nn.Conv2d(channels, channels, 3, 1, 1) self.in2 = nn.InstanceNorm2d(channels) self.relu = nn.ReLU(inplace=True) def forward(self, x): id = x x = self.relu(self.in1(self.conv1(x))) x = self.in2(self.conv2(x)) return x + id class Generator(nn.Module): def __init__(self, in_ch=3, base=64): super().__init__() # 下采样:把空间信息压缩到通道 self.down1 = nn.Sequential( nn.Conv2d(in_ch, base, 7, 1, 3), nn.InstanceNorm2d(base), nn.ReLU(True)) self.down2 = nn.Sequential( nn.Conv2d(base, base*2, 3, 2, 1), nn.InstanceNorm2d(base*2), nn.ReLU(True)) self.down3 = nn.Sequential( nn.Conv2d(base*2, base*4, 3, 2, 1), nn.InstanceNorm2d(base*4), nn.ReLU(True)) # 残差块堆叠 self.resblocks = nn.Sequential(*[ResBlock(base*4) for _ in range(6)]) # 上采样:恢复分辨率 self.up1 = self._upsample(base*4, base*2) self.up2 = self._upsample(base*2, base) self.out = nn.Sequential( nn.Conv2d(base, 3, 7, 1, 3), nn.Tanh()) def _upsample(self, in_c, out_c): # 转置卷积 + 反射填充,减少棋盘效应 return nn.Sequential( nn.ConvTranspose2d(in_c, out_c, 3, 2, 1, output_padding=1), nn.InstanceNorm2d(out_c), nn.ReLU(True)) def forward(self, x): x = self.down1(x) x = self.down2(x) x = self.down3(x) x = self.resblocks(x) x = self.up1(x) x = self.up2(x) x = self.out(x) return x

逻辑说明:生成器采用「下采样 → 残差块 → 上采样」的 U 形结构。下采样让网络在低分辨率空间学到全局上下文,残差块负责恢复高频,上采样再用转置卷积还原尺寸。这里没用 U-Net 的跳跃连接,因为跳跃连接会把模糊图像的低频信息直接带到输出,导致去模糊不彻底。如果你训练的图模糊很轻微,可以试试点对点的 skip connection;但运动模糊强烈时,不要加,生成器会「偷懒」。

参数说明:base=64表示卷积通道数;我试过 32 通道,训练速度提升约 30%,但边缘恢复质量可见下降。6个残差块是内存和效果的折中,DeblurGAN 原版用 9 个块,在 256×256 输入下多占 1.5GB 显存。如果你的显卡只有 6GB,减到 4 个块,并用torch.cuda.amp自动混合精度训练。

3.3 判别器代码:PatchGAN 与梯度惩罚

判别器要能逐 patch 判断真伪。下面的 PatchGAN 实现输出 16×16 的判定图,对应输入图上一块约 70×70 的感受野:

class Discriminator(nn.Module): def __init__(self, in_ch=3, base=64): super().__init__() # 五层卷积,不使用归一化,遵循 PatchGAN 惯例 self.layers = nn.Sequential( nn.Conv2d(in_ch, base, 4, 2, 1), nn.LeakyReLU(0.2, True), nn.Conv2d(base, base*2, 4, 2, 1), nn.InstanceNorm2d(base*2), nn.LeakyReLU(0.2, True), nn.Conv2d(base*2, base*4, 4, 2, 1), nn.InstanceNorm2d(base*4), nn.LeakyReLU(0.2, True), nn.Conv2d(base*4, base*8, 4, 1, 1), nn.InstanceNorm2d(base*8), nn.LeakyReLU(0.2, True), nn.Conv2d(base*8, 1, 4, 1, 1) ) def forward(self, x): return self.layers(x)

逻辑说明:判别器没有用 BatchNorm,原因有两层——一是 BatchNorm 在 batch size 小时统计量不稳定,二是 WGAN-GP 训练时对每个样本做梯度惩罚,BatchNorm 的统计量会干扰梯度计算。这里改用了 InstanceNorm,对每个样本独立归一化,训练更稳。最后一层不接 Sigmoid,因为配合 WGAN-GP 的损失函数,直接输出 logit。

梯度惩罚的代码通常是这样的:

def gradient_penalty(disc, real, fake, device): # 在真实和生成样本之间随机插值,要求判别器输出对插值输入的梯度范数接近 1 alpha = torch.rand(real.size(0), 1, 1, 1).to(device) interp = alpha * real + (1 - alpha) * fake interp.requires_grad_(True) d_interp = disc(interp) grads = torch.autograd.grad( outputs=d_interp, inputs=interp, grad_outputs=torch.ones_like(d_interp), create_graph=True, retain_graph=True)[0] grads = grads.view(grads.size(0), -1) penalty = ((grads.norm(2, dim=1) - 1) ** 2).mean() return penalty

参数说明:alpha 是从 0 到 1 均匀采样,插值点在真实和生成样本连线上。梯度惩罚的 λ 取 10 是 WGAN-GP 论文里的默认值,我实际测试在去模糊任务上 10 到 20 之间都稳。如果惩罚太大,判别器更新变慢;太小,判别器会震荡,生成器训练曲线会出现锯齿状。

3.4 训练循环:生成器与判别器的交替更新

GAN 训练的节奏比普通网络讲究。生成器不能每步都更新,否则前期会直接把判别器损失打到 0,然后梯度消失。我用的是每步更新 1 次判别器、1 次生成器,但判别器累加 5 个真实样本的梯度再更新,相当于有效 batch size 放大 5 倍:

def train_step(gen, disc, real_s, blur_b, opt_g, opt_d, device, lambda_gp=10): # real_s: 清晰图;blur_b: 模糊图 # 先更新判别器 opt_d.zero_grad() fake_s = gen(blur_b).detach() # 分离,避免梯度传到生成器 d_real = disc(real_s) d_fake = disc(fake_s) gp = gradient_penalty(disc, real_s, fake_s, device) d_loss = -d_real.mean() + d_fake.mean() + lambda_gp * gp d_loss.backward() opt_d.step() # 再更新生成器(包括 L1 和感知损失) opt_g.zero_grad() fake_s = gen(blur_b) g_adv = -disc(fake_s).mean() # WGAN 的生成器损失 g_l1 = torch.mean(torch.abs(fake_s - real_s)) g_percep = perceptual_loss(fake_s, real_s) # 使用 VGG19 特征 g_loss = g_adv + 100.0 * g_l1 + 10.0 * g_percep g_loss.backward() opt_g.step() return d_loss.item(), g_loss.item()

逻辑说明:判别器先用 detach 的生成图计算损失,避免判别器的梯度影响生成器;然后单独更新生成器,用的是当前最新判别器给的反向梯度。WGAN 的生成器目标是最小化-d_fake.mean(),直观理解是让判别器对生成图输出尽可能大的评分。L1 和感知损失直接和对抗损失相加,权重前面已经说过。

参数说明:训练时 batch size 我设为 4,输入分辨率 256×256。学习率生成器 1e-4,判别器 1e-4,使用 Adam(beta1=0.5, beta2=0.999)。GAN 训练里 beta1 不要用默认的 0.9,0.5 能显著降低震荡。每 5 个 epoch 把学习率降到原来的 0.5,总共训练 300 epoch(大约 20 万步)。批大小、学习率、beta1 是你动手改得最频繁的三个参数。

4. 把去模糊模型跑起来:训练参数、评估指标与核心命令

4.1 训练前的文件结构与数据加载

动手之前,先把工程目录整理清楚。我通常这样组织:

deblurgan/ data/ train/ # GoPro 或自建训练集 blurs/ # 模糊图 sharp/ # 清晰图 val/ checkpoints/ # 模型权重 logs/ # TensorBoard 日志 scripts/ train.py data_loader.py models.py # 生成器与判别器 losses.py # 感知损失、梯度惩罚 evaluate.py # PSNR/SSIM 计算

数据加载用 PyTorch 的DataLoader,注意把模糊图和清晰图配对。最简单的方法是在data_loader.py里按同名文件读取:

class BlurDataset(torch.utils.data.Dataset): def __init__(self, blur_dir, sharp_dir, img_size=256): self.blur_paths = sorted(glob.glob(f"{blur_dir}/*.png") + glob.glob(f"{blur_dir}/*.jpg")) self.sharp_paths = [p.replace(blur_dir, sharp_dir) for p in self.blur_paths] self.img_size = img_size def __len__(self): return len(self.blur_paths) def __getitem__(self, idx): blur = cv2.imread(self.blur_paths[idx]) sharp = cv2.imread(self.sharp_paths[idx]) blur = cv2.cvtColor(blur, cv2.COLOR_BGR2RGB) sharp = cv2.cvtColor(sharp, cv2.COLOR_BGR2RGB) # 随机裁剪到固定尺寸,同时做水平翻转增强 y, x = random.randint(0, blur.shape[0]-self.img_size), random.randint(0, blur.shape[1]-self.img_size) blur = blur[y:y+self.img_size, x:x+self.img_size] sharp = sharp[y:y+self.img_size, x:x+self.img_size] if random.random() > 0.5: blur = blur[:, ::-1]; sharp = sharp[:, ::-1] blur = torch.from_numpy(blur.copy()).permute(2,0,1).float() / 127.5 - 1.0 sharp = torch.from_numpy(sharp.copy()).permute(2,0,1).float() / 127.5 - 1.0 return blur, sharp

这里的细节值得说:随机裁剪相当于数据增强,让模型没见过同一位置两次;水平翻转再增一倍数据量。归一化到 [-1,1] 是为了匹配生成器输出的 Tanh 激活函数范围。如果你读图时用了 OpenCV 的 BGR 顺序,最后训练时会把颜色通道搞错,输出图的红蓝会互换。这是一个常见翻车点,后面避坑章节会展开。

4.2 训练启动命令与显存配置

下面是我跑过一次完整训练的命令,脚本简化为关键参数:

python scripts/train.py \ --data_dir data/train \ --val_dir data/val \ --img_size 256 \ --batch_size 4 \ --lr_g 2e-4 \ --lr_d 2e-4 \ --beta1 0.5 \ --num_epochs 300 \ --ckpt_dir checkpoints \ --log_dir logs \ --save_interval 10 \ --sample_interval 200 \ --use_amp \ --workers 4

参数说明:--use_amp开启自动混合精度,在 RTX 30 系及更新的显卡上能把显存占用降一半,训练速度提升约 40%。--save_interval 10表示每 10 个 epoch 存一次权重,防止中途断电丢失。--sample_interval 200表示每 200 步存一张验证集去模糊效果对比图,你需要用肉眼监视训练过程,而不是只盯损失曲线。

如果你的显卡显存小于 8GB,把--img_size降到 192 或 224,batch_size 降到 2。注意:分辨率降低后模型容易产生块状伪影,因为 PatchGAN 的感受野在绝对像素上没变,覆盖信息的比例变了,这时应该同时调小判别器步长并在推理时用更大 patch。

4.3 评估指标:PSNR 虚高,SSIM 片面,还要看主观分

去模糊领域最常用的两个量化指标是 PSNR(峰值信噪比)和 SSIM(结构相似性)。但我必须提醒你:这两个指标在 GAN 去模糊模型上都不太好用。GAN 生成的图像本来就是「细节重构」而不是「像素精确复原」,PSNR 会低于传统方法,但人眼看着更舒服。没有哪篇论文敢只贴 PSNR,通常还要配视觉对比图。

评估命令和指标计算建议这样写脚本:

# evaluate.py 核心逻辑 def evaluate(model, val_loader, device): model.eval() psnr_list, ssim_list = [], [] with torch.no_grad(): for blur, sharp in val_loader: blur, sharp = blur.to(device), sharp.to(device) fake = model(blur) # 把 [-1,1] 转回 [0,255] fake = (fake + 1) / 2 * 255 sharp = (sharp + 1) / 2 * 255 fake = fake.clamp(0, 255).round() # 计算 PSNR 时需要的是灰度或 RGB 各通道 mse = torch.mean((fake - sharp) ** 2) psnr = 20 * torch.log10(255.0 / torch.sqrt(mse + 1e-8)) ssim_val = ssim(fake.permute(0,2,3,1).cpu().numpy(), sharp.permute(0,2,3,1).cpu().numpy(), multichannel=True) psnr_list.append(psnr.item()); ssim_list.append(ssim_val) print(f"PSNR: {np.mean(psnr_list):.2f}, SSIM: {np.mean(ssim_list):.4f}")

逻辑说明:fake.clamp(0,255).round()把生成器的输出量化到整数像素,与清晰图做比较才公平。如果不做 clamp,个别超出 [0,255] 的像素会让 MSE 偏大,PSNR 一口气跌 5 个点。ssim 函数可从skimage.metrics导入,多通道图计算时记得设multichannel=True,否则会默认把 RGB 当灰度。

主观评估我强烈建议你用「双盲对比」:把原图、传统去模糊结果、GAN 去模糊结果打乱顺序,让 5 个同事投票选出「看起来最真实」的那张。GAN 的目标本来就是感知质量,只要大部分人选你,就说明这个模型在场景里是有效的。我见过有的项目 PSNR 比 DeblurGAN-v2 低 2dB,但盲测得分反超,就是因为锐化的过度噪声在主观上更讨喜。

4.4 推理:单张测试与批量测试的差异处理

训练完模型,测试时很多人直接拿单张图跑,发现效果翻车,以为是模型没训练好。实际上这是因为生物特征差异:训练时的数据增强包含随机裁剪和翻转,模型见到的总是局部区域;测试时整张图输入,感受野和统计分布都变了。所以我推理时会先做一个预对齐:

def infer_single(model, img_path, device, tile_size=256, overlap=32): img = cv2.imread(img_path) h, w = img.shape[:2] # 转成 RGB,归一化 img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB).astype(np.float32) / 127.5 - 1.0 # 把图切成块,块间重叠 result = np.zeros((h, w, 3), dtype=np.float32) weight_map = np.zeros((h, w, 1), dtype=np.float32) for y in range(0, h - tile_size + 1, tile_size - overlap): for x in range(0, w - tile_size + 1, tile_size - overlap): patch = img_rgb[y:y+tile_size, x:x+tile_size] patch_t = torch.from_numpy(patch.transpose(2,0,1)).unsqueeze(0).to(device) out = model(patch_t).squeeze(0).cpu().detach().numpy().transpose(1,2,0) result[y:y+tile_size, x:x+tile_size] += out weight_map[y:y+tile_size, x:x+tile_size] += 1.0 out_img = result / weight_map out_img = (out_img + 1) * 127.5 out_img = out_img.clip(0, 255).astype(np.uint8) return cv2.cvtColor(out_img, cv2.COLOR_RGB2BGR)

逻辑说明:切块推理时,边缘的 patch 因为缺少上下文,生成器输出会比中间差。重叠区域用加权平均(weight_map 统一权重即可)能消掉块边界。overlap=32 表示每个块与相邻块重叠 32 像素,重叠越多,块效应越弱,但计算量也越大。

最后一章我会专门讲三个落地技巧,这里先记住结论:超过 1024×1024 的图不要直接整张喂给生成器,必炸显存,也必出棋盘伪影。

5. 去模糊模型落地避坑:常见问题、排查顺序与性能边界

5.1 训练到一半损失变 NaN,生成器输出纯色图

现象:训练进行到第 20 个 epoch 左右,loss 曲线突然变成 NaN,之后生成器输出的是一张纯灰色或者带彩条的图,再也不恢复。

原因:最常见的是梯度爆炸。WGAN-GP 虽然用梯度惩罚抑制了判别器的梯度,但生成器的残差块堆叠起来后,深层梯度可能爆炸;另一个常见元凶是学习率过大,尤其 Adam 的 beta2 接近 1 时,二阶动量累积导致更新步长失控。

解决:先检查损失打印里的数值,如果在 NaN 之前看到d_loss有几千的大数,立即调低学习率到 1e-5,并把beta2从 0.999 改成 0.99。更稳妥的做法是开启混合精度时加上 grad scaler,它会自动减小梯度;如果已经 NaN,需要回滚到最近一次正常的 checkpoint,把学习率减半再继续。不要在 NaN 之后继续训练,否则权重里会出现大量inf,需要整个重来。

我的建议是在训练脚本里加一个保护:每 100 步检查 loss 是否为 NaN,如果是则自动保存当前权重并从上一个 checkpoint 重新加载,同时学习率乘以 0.5。这个「后悔药」机制帮我省过至少三次整晚重训。

5.2 判别器把生成器压死,输出越来越糊

现象:训练前期还能看到去模糊效果,到第 50 个 epoch 后生成器的输出变得越来越平滑,就像加了高斯模糊一样。判别器的准确率一直保持在 95% 以上,而生成器的对抗损失一路走低但图像质量未提升。

原因:判别器收敛太快,生成器无论怎么生成,判别器都能瞬间分辨出来。梯度失去了引导作用,生成器只好依赖 L1 损失,输出趋于平均值(就是模糊图)。这种情况在数据集较小或者清晰图内容过于单一时特别容易发生。

解决:降低判别器学习率,让它比生成器慢一拍。把--lr_d设为生成器的 0.5 倍,或者把判别器每更新 2 次才让生成器更新 1 次。另一个办法是给判别器输入加噪声——将真实和生成图像都加上相同的标准差为 0.1 的高斯噪声,让判别器无法轻易找到伪造痕迹。我用这两个方法后,对抗损失能保持一个健康的下降斜率,生成器输出的纹理也明显变多。

注意:判别器压死不等于训练失败。有时生成器先达到不错的局部最优,对抗损失低但图像不糊,这时不要强行继续对抗,直接保存当前权重做推理即可。追求对抗损失降为 0 是没有意义的。

5.3 训练集的清晰图其实不清晰,模型学了个寂寞

现象:模型在验证集上的 PSNR 不升反降,测试时甚至把本来清晰的边缘给磨平了。查看训练数据发现,有一部分「清晰图」来自手机拍摄,本身就有一点噪声和轻微失焦。

原因:这是数据质量问题。GAN 的生成器会模仿训练数据的退化分布。如果清晰图里混入模糊图,生成器会学成「保持原样」或「加重模糊」。判别器也无法有效区分——因为它看到真实清晰图也可能是模糊的,判断标准被拉低了。

解决:清洗数据时严格筛选。用拉普拉斯算子计算图像的方差,方差过小的图视为模糊图,直接从清晰图集合中剔除。具体阈值按数据集调整,通常方差低于 50(基于 256×256 灰度图)就删掉。另外,输入模糊图和清晰图不要用同一张图做了轻微变换来凑对,那种「伪对」会让模型偷懒,直接学一个恒等变换。

5.4 验证集效果好,一到实际场景就崩

现象:GoPro 测试集上 PSNR 35+,拿真实监控截图去跑,出现大面积伪影,墙面纹理变成螺旋状,人脸五官变形。

原因:数据分布偏移。GoPro 的训练模糊是高速运动造成的,是全局统一运动模糊;真实监控中的模糊来自手持抖动、景深、遮光等多种混合退化,单一类型的合成模糊没有覆盖这种复杂情况。

解决:在合成数据里引入多样性。运动角度随机取 0 到 360 度,核大小在 9 到 21 之间随机,同时叠加随机方向的高斯模糊和轻微噪声。更好的是采用课程学习:先用简单合成模糊训练 100 个 epoch,再用复杂混合模糊微调 50 个 epoch。模型先在简单域收敛,再把决策边界扩展,比直接混合训练稳定得多。

5.5 显卡显存不够:OOM 发生在训练第 3 个 batch

现象:torch.cuda.OutOfMemoryError在训练刚开始几次迭代就出现,但显存监视器显示使用率只有 80%。

原因:PyTorch 的缓存分配器会预分配显存,不一定在 OOM 前释放;也可能是计算图的中间变量累积了过多(比如梯度惩罚里create_graph=True导致额外的反向图)。

解决:三步走。第一步,把 batch_size 改成 1,确认能跑通,再逐步加大。第二步,加入torch.cuda.empty_cache()并在每个 epoch 结束后调用,但不要在主循环里频繁调,否则影响速度。第三步,把生成器的残差块从 9 个减少到 6 个,输入尺寸从 256 降到 224。如果还 OOM,就是模型结构本身太胖,backbone 换成 MobileNet 版,参数量减少 60%,显存占用降 30%。

还有一个很多人忽略的点:梯度惩罚里的torch.autograd.grad(create_graph=True)构建了二阶导图,这会大量占用显存。如果你只求稳不追求最新效果,可以每 3 步才计算一次梯度惩罚,其他步跳过惩罚,显存占用立刻下降。

6. 从复现到可用:三招提升清晰度与泛化能力

第一招,推理后处理用「自引导锐化」。GAN 生成的图有时边缘偏软,这是对抗损失没完全收敛的特征。我的做法是生成结果与输入模糊图做一个边缘融合:用 Sobel 算子提取输入图中的强边缘,在边缘区域将生成图和高通滤波后的版本按 7:3 混合。这样能补上一些高频,同时不会放大模糊区域里的噪声。计算量小,在 CPU 上每帧也就多花 2 毫秒。

第二招,做一次「两阶段微调」。先在合成数据上按第 3 章流程训练到 200 个 epoch,然后拿出你的真实业务数据(哪怕只有 200 对模糊-清晰图),用 1e-4 的学习率微调 30 个 epoch。微调时冻结生成器的前两层下采样卷积,只更新残差块和上采样层。这样既保留合成数据学到的泛化能力,又能适应真实场景退化特征。我实测在监控图像上 PSNR 能再提 1.8dB。

第三招,测试时增强(TTA)。对同一张模糊图做 4 种变换——原图、水平翻转、上下翻转、旋转 90 度——分别推理后变换回原方向取平均。平均后的图像噪声更小,边缘更稳定。代价是推理时间变成 4 倍,如果你的场景允许离线处理,建议开启。TTA 之后 SSIM 通常能提升 0.01 到 0.02,视觉上最明显的变化是文字边缘的断裂感消失。

最后说个我自己的教训:我第一次做去模糊项目时,为了追求 PSNR 数字好看,把感知损失权重降到 1,结果指标确实高了,但输出图像像塑料一样,老板一眼就否了。之后我养成了习惯——每次训练完先不跑指标,直接看 10 张验证集对比图,觉得舒服了再量化。现在做任何 GAN 图像复原任务,我都是先用肉眼做 gate,再让指标说话。

去模糊是个上限很高、门槛也不低的方向,GAN 让它在实际场景里终于能落地。你可以先按第 3 章的代码把最小模型跑通,然后在第 5 章那几个坑上多做几个实验,数据清洗和损失配比才是真正拉开效果差距的地方。希望这些能帮你在自己的数据集上少走几轮弯路。

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

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

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

立即咨询