简介:本资源为基于PyTorch实现的对偶生成对抗网络图像去雾项目,面向计算机相关专业正在做毕业设计的学生,以及需要项目实战练习的学习者,也可作为课程设计或期末大作业参考。项目包含完整Python源码、训练好的模型权重与文档说明,代码经导师指导并认可,评审分99分,确保可运行,零基础也能上手。压缩包共25个文件,约21.23MB,其中10个py文件涵盖生成器、判别器、训练与预测等核心模块,2个pkl为已训练模型权重,另有png、jpg示例图片及md说明文档,便于快速理解网络结构与实验流程。资源围绕对偶生成对抗网络展开,涉及数据加载、参数解析、日志记录与结果可视化等环节,读者可据此掌握图像去雾的完整实现思路,并在此基础上进行二次开发或对比实验。目前已有144人学习,适合作为深度学习入门与毕设实战的参考案例。
1. 图像去雾为什么总在天空区域翻车:从对偶生成对抗网络说起
做过去雾的同行大概都有过这种体验:模型在合成数据集上跑出来的指标漂漂亮亮,一换到真实雾天照片,天空区域就开始出现色块、光晕,甚至整片糊成灰白。这不是调参能救的问题,而是多数去雾网络把「去雾」当成了一个单向的映射任务——输入雾图,输出清晰图,学一个端到端的回归。问题在于,雾的成因本身是物理的:大气散射模型里,清晰图像经过透射率和大气光两个变量耦合之后才变成雾图。单向回归丢掉了这个约束,网络只能靠数据分布去「猜」,遇到训练集里没见过的雾浓度或天空亮度,猜错就是必然。
对偶生成对抗网络(DualGAN)的思路正好切中这个痛点。它不学单向映射,而是同时学两个方向的变换:一个生成器负责去雾,另一个生成器负责加雾,两个判别器分别在清晰域和雾域上判断真假。去雾生成器输出的结果,会被加雾生成器重新「打回」雾图,再和原始雾图比对循环一致性。这样一来,去雾不再是自由发挥,而是被加雾过程反向约束——你去的雾,得能被重新加回来,且加回来的和原来那张对得上。这个约束在天空这种高频低对比度区域尤其管用,因为天空的透射率接近无穷、大气光占比高,单向模型容易在这里过拟合出灰白色块,而对偶结构会强迫生成器保留可逆的物理关系。
这套方案适合谁?如果你手头有一批配对或非配对的雾图-清晰图数据,想在自己的场景(比如监控、无人机航拍、户外巡检)上做去雾,又不想从头设计物理模型,那基于 PyTorch 的对偶 GAN 去雾是一个能落地、可复现、且对数据配对要求相对宽松的选择。下面从网络结构、训练流程、参数设置到避坑,一步步拆开讲。
2. 对偶生成对抗网络去雾的骨架:两个生成器、两个判别器怎么搭
2.1 为什么去雾和加雾要共用一套循环结构
对偶 GAN 的核心不是「一个生成器多强」,而是「两个生成器互相制约」。去雾生成器 G_A 把雾图 x 映射到清晰图 y_hat,加雾生成器 G_B 把清晰图 y 映射回雾图 x_hat。判别器 D_A 判断输入是真实清晰图还是 G_A 生成的,D_B 判断输入是真实雾图还是 G_B 生成的。训练时,G_A 的损失由三部分组成:对抗损失(骗过 D_A)、循环一致性损失(G_B(G_A(x)) 要接近 x)、以及可选的感知损失。G_B 同理。
这个结构的关键在于:循环一致性不是可选项,而是去雾质量的主要来源。没有它,G_A 可以输出任意「看起来清晰」的图,哪怕内容已经偏离原图。有了它,G_A 必须保留足够的信息,让 G_B 能重建出原始雾图。这相当于给去雾过程加了一个信息瓶颈,逼着网络只去掉雾,而不是改内容。
2.2 生成器选型:U-Net 还是 ResNet 堆叠
在 PyTorch 里实现时,生成器常见两种选择。U-Net 结构适合去雾,因为雾的分布是全局的,但细节恢复需要跳跃连接把浅层特征传到深层。ResNet 堆叠(比如 9 个残差块)参数更少,训练更稳,但在浓雾区域容易丢失纹理。我一般用「浅层 U-Net + 残差块」的混合结构:编码器 3 层下采样,中间接 6 个残差块,解码器 3 层上采样,跳跃连接保留边缘信息。
判别器用 PatchGAN,输出 70x70 的感受野,而不是整图一个标量。这样能更好地捕捉局部雾的分布,训练也更稳定。下面是一个可直接复现的生成器定义:
import torch import torch.nn as nn class ResidualBlock(nn.Module): def __init__(self, channels): super().__init__() self.block = nn.Sequential( nn.Conv2d(channels, channels, 3, 1, 1), nn.InstanceNorm2d(channels), nn.ReLU(inplace=True), nn.Conv2d(channels, channels, 3, 1, 1), nn.InstanceNorm2d(channels) ) def forward(self, x): return x + self.block(x) # 残差连接,避免深层梯度消失 class DehazeGenerator(nn.Module): def __init__(self, base=64, res_blocks=6): super().__init__() # 编码器:3层下采样,通道数 64->128->256 self.enc = nn.Sequential( nn.Conv2d(3, base, 7, 1, 3), nn.InstanceNorm2d(base), nn.ReLU(inplace=True), nn.Conv2d(base, base*2, 3, 2, 1), nn.InstanceNorm2d(base*2), nn.ReLU(inplace=True), nn.Conv2d(base*2, base*4, 3, 2, 1), nn.InstanceNorm2d(base*4), nn.ReLU(inplace=True) ) # 中间残差块 self.res = nn.Sequential(*[ResidualBlock(base*4) for _ in range(res_blocks)]) # 解码器:上采样 + 跳跃连接 self.dec = nn.Sequential( nn.ConvTranspose2d(base*4, base*2, 3, 2, 1, output_padding=1), nn.InstanceNorm2d(base*2), nn.ReLU(inplace=True), nn.ConvTranspose2d(base*2, base, 3, 2, 1, output_padding=1), nn.InstanceNorm2d(base), nn.ReLU(inplace=True), nn.Conv2d(base, 3, 7, 1, 3), nn.Tanh() # 输出归一化到 [-1,1] ) def forward(self, x): e = self.enc(x) r = self.res(e) return self.dec(r)这段代码里,InstanceNorm2d比BatchNorm2d更适合去雾任务,因为去雾的雾浓度和图像内容强相关,批统计量会引入不稳定。Tanh输出配合训练时把图像归一化到 [-1,1],能加速收敛。残差块数量res_blocks=6是我在 256x256 输入下的常用值,再大容易过拟合,再小浓雾去不干净。
2.3 判别器与损失函数:PatchGAN 和循环一致性的权重怎么定
判别器用 3 层卷积的 PatchGAN,每层接 LeakyReLU,最后输出一个特征图而不是标量。损失函数用最小二乘 GAN(LSGAN)比原始 GAN 更稳,梯度不会消失。循环一致性损失用 L1,权重一般设 10。对抗损失权重设 1。如果加感知损失(用预训练 VGG 提特征),权重设 0.1 左右,但会拖慢训练速度,建议先不加,等基础模型跑通再试。
class PatchDiscriminator(nn.Module): def __init__(self, in_ch=3, base=64): super().__init__() self.net = nn.Sequential( nn.Conv2d(in_ch, base, 4, 2, 1), nn.LeakyReLU(0.2, inplace=True), nn.Conv2d(base, base*2, 4, 2, 1), nn.InstanceNorm2d(base*2), nn.LeakyReLU(0.2, inplace=True), nn.Conv2d(base*2, base*4, 4, 2, 1), nn.InstanceNorm2d(base*4), nn.LeakyReLU(0.2, inplace=True), nn.Conv2d(base*4, 1, 4, 1, 1) # 输出 Patch 得分 ) def forward(self, x): return self.net(x)训练时,判别器每步更新一次,生成器也每步更新一次。如果判别器 loss 掉到 0.1 以下,说明它太强了,生成器学不动,这时候要把判别器的学习率调低,或者给判别器输入加噪声。这是 GAN 训练里的经典平衡问题,后面避坑章节会细说。
3. 在 PyTorch 里跑通训练:数据准备、训练循环与参数设置
3.1 雾图数据集怎么组织:配对与非配对的取舍
对偶 GAN 理论上支持非配对数据,但去雾任务里,如果有配对数据(同一场景的雾图和清晰图),训练会快很多,循环一致性也更容易收敛。常见做法是用合成雾图:拿清晰图,按大气散射模型加雾,透射率 t 在 [0.5, 1.0] 之间随机,大气光 A 取全局最亮 0.1% 像素的均值。这样能生成大量配对数据。真实雾图没有清晰参考,只能做非配对训练,但收敛慢,且需要更仔细地调循环损失权重。
数据加载用torch.utils.data.Dataset,把雾图和清晰图分别放在两个文件夹,文件名一一对应。预处理只做随机裁剪到 256x256 和随机水平翻转,不做颜色抖动,因为颜色偏移会破坏大气散射的物理关系。
from torch.utils.data import Dataset, DataLoader from PIL import Image import os import torchvision.transforms as T class DehazeDataset(Dataset): def __init__(self, hazy_dir, clear_dir, size=256): self.hazy_dir = hazy_dir self.clear_dir = clear_dir self.names = sorted(os.listdir(hazy_dir)) self.transform = T.Compose([ T.RandomCrop(size), T.RandomHorizontalFlip(), T.ToTensor(), T.Normalize(mean=[0.5]*3, std=[0.5]*3) # 归一化到 [-1,1] ]) def __len__(self): return len(self.names) def __getitem__(self, idx): name = self.names[idx] hazy = Image.open(os.path.join(self.hazy_dir, name)).convert('RGB') clear = Image.open(os.path.join(self.clear_dir, name)).convert('RGB') # 对雾图和清晰图做相同的随机变换 seed = torch.randint(0, 2**32, (1,)).item() torch.manual_seed(seed) hazy = self.transform(hazy) torch.manual_seed(seed) clear = self.transform(clear) return hazy, clear这里用torch.manual_seed保证雾图和清晰图做相同的随机裁剪和翻转,否则配对就错位了。Normalize到 [-1,1] 是为了配合生成器的Tanh输出。批量大小设 4 或 8,取决于显存,256x256 输入下 8GB 显存跑 batch=4 没问题。
3.2 训练循环:两个生成器、两个判别器怎么交替更新
训练循环里,每一步先更新判别器 D_A 和 D_B,再更新生成器 G_A 和 G_B。判别器的损失是真实样本和生成样本的 LSGAN 损失之和。生成器的损失包括对抗损失、循环一致性损失和身份损失(可选)。身份损失是让 G_A 对清晰图输出清晰图,G_B 对雾图输出雾图,权重设 5 左右,能加速早期收敛。
import torch.optim as optim # 初始化 G_A = DehazeGenerator().cuda() # 去雾 G_B = DehazeGenerator().cuda() # 加雾 D_A = PatchDiscriminator().cuda() D_B = PatchDiscriminator().cuda() opt_G = optim.Adam(list(G_A.parameters()) + list(G_B.parameters()), lr=2e-4, betas=(0.5, 0.999)) opt_D = optim.Adam(list(D_A.parameters()) + list(D_B.parameters()), lr=2e-4, betas=(0.5, 0.999)) criterion_GAN = nn.MSELoss() # LSGAN criterion_cycle = nn.L1Loss() criterion_identity = nn.L1Loss() lambda_cycle = 10.0 lambda_identity = 5.0 for epoch in range(200): for hazy, clear in dataloader: hazy, clear = hazy.cuda(), clear.cuda() # 更新判别器 opt_D.zero_grad() fake_clear = G_A(hazy) pred_real_A = D_A(clear) pred_fake_A = D_A(fake_clear.detach()) loss_D_A = 0.5 * (criterion_GAN(pred_real_A, torch.ones_like(pred_real_A)) + criterion_GAN(pred_fake_A, torch.zeros_like(pred_fake_A))) fake_hazy = G_B(clear) pred_real_B = D_B(hazy) pred_fake_B = D_B(fake_hazy.detach()) loss_D_B = 0.5 * (criterion_GAN(pred_real_B, torch.ones_like(pred_real_B)) + criterion_GAN(pred_fake_B, torch.zeros_like(pred_fake_B))) loss_D = loss_D_A + loss_D_B loss_D.backward() opt_D.step() # 更新生成器 opt_G.zero_grad() fake_clear = G_A(hazy) pred_fake_A = D_A(fake_clear) loss_GAN_A = criterion_GAN(pred_fake_A, torch.ones_like(pred_fake_A)) rec_hazy = G_B(fake_clear) loss_cycle_A = criterion_cycle(rec_hazy, hazy) loss_id_A = criterion_identity(G_A(clear), clear) fake_hazy = G_B(clear) pred_fake_B = D_B(fake_hazy) loss_GAN_B = criterion_GAN(pred_fake_B, torch.ones_like(pred_fake_B)) rec_clear = G_A(fake_hazy) loss_cycle_B = criterion_cycle(rec_clear, clear) loss_id_B = criterion_identity(G_B(hazy), hazy) loss_G = (loss_GAN_A + loss_GAN_B + lambda_cycle * (loss_cycle_A + loss_cycle_B) + lambda_identity * (loss_id_A + loss_id_B)) loss_G.backward() opt_G.step()这段循环里,判别器更新时对生成样本用了.detach(),防止梯度传回生成器。生成器更新时,对抗损失、循环损失、身份损失一起回传。lambda_cycle=10是 CycleGAN 论文里的经典值,去雾任务里可以调到 15 让循环约束更强,但太高会导致去雾不彻底。lambda_identity=5在配对数据下有用,非配对数据下可以设 0。
3.3 学习率与迭代次数:什么时候该停
学习率用 2e-4,前 100 个 epoch 保持不变,后 100 个 epoch 线性衰减到 0。这是 CycleGAN 的标配。迭代次数取决于数据集大小,一般 200 个 epoch 足够。判断什么时候停,不要只看 loss,要看验证集上的 PSNR 和 SSIM。如果 PSNR 连续 20 个 epoch 不升,就可以停了。另外,生成器输出的图如果开始出现网格状伪影,说明判别器太强,要提前停或者调低判别器学习率。
4. 去雾效果上不去的排查清单:从颜色偏移到模式崩溃
4.1 输出图整体偏灰或偏蓝
现象:去雾结果整体蒙了一层灰,或者天空区域偏蓝。原因通常是循环一致性损失权重太低,生成器没有保留足够的颜色信息,或者训练数据里雾图的颜色分布和清晰图差异太大。解决:把lambda_cycle从 10 提到 15,同时在数据预处理里检查雾图是否做了和白平衡相关的增强,如果有,去掉。另外,判别器的感受野如果太小,会忽略全局颜色分布,可以把 PatchGAN 最后一层改成 5x5 卷积。
4.2 浓雾区域去不干净,残留白色斑块
现象:近处浓雾区域去雾后仍有白色雾团。原因是对偶 GAN 的循环一致性在浓雾区域容易失效,因为加雾生成器 G_B 很难从清晰图重建出浓雾,导致 G_A 在浓雾区域缺乏约束。解决:在损失里加一个暗通道先验损失,对去雾结果的暗通道做约束,权重设 0.5。或者用多尺度训练,把浓雾区域裁剪出来单独训练一个子模型。
4.3 训练中期判别器 loss 骤降,生成器输出崩坏
现象:训练到 50 个 epoch 左右,判别器 loss 掉到 0.01,生成器输出变成纯色或重复纹理。原因是判别器太强,生成器梯度消失。解决:把判别器学习率降到 1e-4,或者给判别器输入加高斯噪声(标准差 0.1)。另一个办法是使用谱归一化(Spectral Normalization),在判别器每层卷积后加nn.utils.spectral_norm,能显著稳定训练。
4.4 验证集 PSNR 高但视觉质量差
现象:PSNR 到 28dB 以上,但人眼看还是有雾感或细节模糊。原因是 PSNR 对亮度敏感,对结构不敏感。解决:加感知损失,用预训练 VGG16 的 relu3_3 层提特征,权重 0.1。同时看 SSIM,如果 SSIM 低于 0.85,说明结构恢复不够,要增加生成器的残差块数量或跳跃连接。
4.5 显存溢出或训练速度过慢
现象:batch=4 就 OOM,或者一个 epoch 跑半小时。原因是生成器通道数太大或输入分辨率太高。解决:把base从 64 降到 32,残差块从 6 降到 4,输入从 256 降到 192。如果还慢,把InstanceNorm2d换成nn.BatchNorm2d并冻结统计量,能省一点显存但会牺牲稳定性。另外,用torch.cuda.amp混合精度训练,显存能省 30%,速度提升 20%。
5. 从能跑到好用:推理部署与效果验证的实操技巧
训练完的模型要落地,推理阶段有几个细节决定最终效果。第一,推理时不要用训练时的随机裁剪,而是整图输入,但如果显存不够,用滑动窗口加重叠拼接,重叠区域取平均。第二,生成器的Tanh输出要反归一化回 [0,255],再转成 uint8 保存。第三,如果部署到边缘设备,把生成器导出成 ONNX 或 TorchScript,用torch.jit.trace时注意输入尺寸固定,动态尺寸会失败。
验证去雾效果,除了 PSNR 和 SSIM,我习惯加一个「雾浓度估计」指标:用暗通道先验算去雾前后图像的暗通道均值,均值越低说明雾越少。这个指标和视觉感受更一致。下面是一个推理和验证的脚本片段:
import torch from PIL import Image import numpy as np def infer(model, img_path, size=256): model.eval() img = Image.open(img_path).convert('RGB') w, h = img.size # 填充到 size 的倍数 pad_w = (size - w % size) % size pad_h = (size - h % size) % size img_pad = Image.new('RGB', (w+pad_w, h+pad_h), (0,0,0)) img_pad.paste(img, (0,0)) tensor = T.ToTensor()(img_pad).unsqueeze(0).cuda() tensor = (tensor - 0.5) / 0.5 with torch.no_grad(): out = model(tensor) out = (out * 0.5 + 0.5).clamp(0,1) out = out.squeeze(0).cpu().permute(1,2,0).numpy() out = (out * 255).astype(np.uint8) return Image.fromarray(out).crop((0,0,w,h)) def dark_channel(img, size=15): # 暗通道先验,用于估计雾浓度 img_np = np.array(img).astype(np.float32) / 255.0 min_ch = np.min(img_np, axis=2) kernel = np.ones((size, size)) / (size*size) from scipy.ndimage import minimum_filter dark = minimum_filter(min_ch, size=size) return dark.mean()推理时,model.eval()和torch.no_grad()是必须的,否则InstanceNorm2d会用当前批的统计量,单张图推理会出错。暗通道均值在去雾后应该明显下降,如果没降,说明模型没学到去雾,要回去检查训练数据或损失权重。
最后说一个我踩过的坑:有次训练集里清晰图是 JPEG 压缩过的,雾图是 PNG,结果模型学到了 JPEG 的块效应,去雾后出现方块伪影。后来统一用 PNG 无损格式,问题消失。数据格式的一致性比想象中重要。希望帮到你。
本文还有配套的精品资源,点击获取