基于Pytorch的对偶生成对抗网络图像去雾实践
简介:本资源是基于PyTorch实现的对偶生成对抗网络(Dual GAN)图像去雾项目,面向计算机视觉方向本科生及研究生,特别适用于毕业设计、课程设计与深度学习实战训练。项目完整复现了端到端雾霾图像恢复流程,涵盖数据加载、双路径生成器与判别器构建、对抗损失与循环一致性损失联合优化等核心模块,代码含详细中文注释,配套README.md说明文档清晰阐述原理、环境配置与运行步骤。压缩包共25个文件,包含10个Python源码(如Generator.py、Discriminator.py、train.py、predict.py)、6张训练/测试效果可视化图(PNG)、5张实测去雾结果图(JPG)、2个预训练模型权重(.pkl)及辅助工具脚本,整体大小为21.24MB。目前已有223人学习下载,项目经导师指导并高分通过毕业答辩,所有模块均严格调试可直接运行,提供从数据预处理、模型训练到单图推理的全流程实践支撑。 雾天拍出来的照片总是灰蒙蒙的,对比度低、细节丢失,更麻烦的是很多后续视觉任务——目标检测、语义分割、无人驾驶感知——在这种输入下都会明显掉点。传统去雾方法里暗通道先验(DCP)最出名,但它对天空区域容易过曝,在真实复杂场景下鲁棒性不够。这几年GAN被大量用在图像翻译任务上,于是就有了一个很自然的思路:把“有雾图像”和“清晰图像”当成两个视觉域,训练一个生成对抗网络做跨域转换,这正好是Pytorch项目“基于Pytorch实现对偶生成对抗网络来实现图像去雾”在做的事。
这个项目最核心的亮点,是它不依赖成对的雾图/清晰图训练数据。真实世界里我们很难拍到同一场景“有雾”和“无雾”两张完全对齐的照片,而对偶生成对抗网络(DualGAN/CycleGAN类结构)正是为这种非成对场景设计的。项目里包含了完整的Python源码、项目说明文档和详细的代码注释,对想入门GAN图像翻译、或者需要把去雾模块集成到自己的视觉流程里的同学来说,是一份很适合拿来跑通、改造和二次开发的参考实现。我在复现过程中把模型结构、损失函数、训练策略和踩过的坑都梳理了一遍,这篇文章就按这个思路来拆解整个项目。
1. 项目核心思路与整体设计
1.1 对偶生成对抗网络到底在做什么
如果你已经接触过普通GAN,那对偶GAN的理解门槛很低。常规GAN只有一个生成器和一个判别器,生成器负责伪造样本,判别器负责区分真假,两者对抗训练,最后生成器学会生成以假乱真的图片。但这种方式只能做“生成”,做不了两个图像域之间的转换。
对偶GAN的“对偶”两个字,体现在它用两对生成器-判别器同时训练:
- 生成器G_A_B:把A域图像(有雾图)转成B域风格(清晰图)
- 生成器G_B_A:把B域图像(清晰图)转成A域风格(有雾图)
- 判别器D_A:判断输入是不是真实的A域图像
- 判别器D_B:判断输入是不是真实的B域图像
我用一个翻译器的类比来解释:G_A_B相当于一个中译英翻译员,G_B_A相当于英译中翻译员,D_A和D_B分别是两边的“母语评审”。光靠“中译英”和“英译中”分别训练,两边很容易乱来——翻译员可能随便编内容,只要骗过评审就行。于是对偶GAN加了一个关键约束:把一篇中文翻译成英文,再把英文翻回中文,结果必须和原中文尽量一致。这就是循环一致性约束,它保证了转换过程不丢失原图的内容和结构。对去雾任务来说,就是去雾之后的图片再“加雾”回去,应该变回原来的雾图。
1.2 去雾问题如何用图像转换建模
图像去雾为什么要套用图像翻译框架?这得从雾图的物理退化模型说起。在计算机视觉里,雾图常被描述成大气散射模型:
I(x) = J(x)·t(x) + A·(1 - t(x))
其中I是有雾图,J是清晰无雾图,t是传输率(透射率),A是大气光。雾越浓,t越低,I越接近纯大气光A,图像看起来就是白茫茫一片。去雾的本质,就是从I反推出J、t和A。
这个模型虽然经典,但真实场景的t和A很难准确估计,传统方法大多在估计t上做文章,一旦遇到复杂光照就翻车。而用GAN来做去雾,思路完全换了一个角度:不显式估计t和A,直接把“有雾”和“无雾”看成两个视觉域,用数据驱动的方式学习从有雾域到无雾域的映射。网络自己隐式地学习去雾规律,这种方式对复杂的真实雾天更加鲁棒。
在这个项目里,A域是雾图,B域是清晰图。训练时只需要两个文件夹:trainA放一堆雾图(可以是合成的也可以是真实的),trainB放一堆清晰图,不要求一一对应。模型通过学习A到B的映射完成去雾,同时利用G_B_A把清晰图转回雾图来保证内容一致性。
1.3 项目源码结构与代码组织
拿到这个zip包解压之后,第一件事是看目录结构。标准CycleGAN类项目都会按功能模块划分代码,这个项目也不例外,主要目录如下:
. ├── models/ │ ├── __init__.py │ ├── networks.py # 生成器、判别器、权重初始化等网络定义 │ └── cycle_gan_model.py # 对偶GAN的完整模型封装(包含前向、损失、优化) ├── datasets/ │ ├── __init__.py │ ├── image_folder.py # 读取A/B域图片的数据集类 │ └── transforms.py # 图片缩放、裁剪、翻转等预处理 ├── utils/ │ ├── __init__.py │ ├── image_pool.py # 假图缓存池,提升判别器稳定性 │ └── visualizer.py # 训练过程日志与图片可视化 ├── options/ │ ├── __init__.py │ ├── base_options.py # 公共参数定义 │ └── train_options.py # 训练专用参数 ├── train.py # 训练入口 ├── test.py # 测试入口 └── 项目说明.md # 项目部署、运行、原理说明我的建议是先把项目说明文档通读一遍,再对照models/cycle_gan_model.py看主流程。项目代码注释写得很全,从网络结构到损失函数都有说明,很适合作为课程设计、毕业设计或者入门GAN图像翻译的参考基座。
2. 网络结构与Pytorch实现细节
2.1 生成器网络:残差卷积与下采样-上采样结构
先看生成器,这个项目使用的生成器是典型的Encoder-Residual-Decoder结构,从Pytorch代码里可以看到大致的网络骨架:
- 一个卷积层把输入3通道升到64通道;
- 两个步长为2的卷积层做下采样,逐步把特征图缩小、通道数扩大,相当于压缩图像信息;
- 中间接9个残差块(ResidualBlock),每个残差块包含两个卷积层,并带有跳跃连接,用于学习图像内容和结构的精细调整;
- 两个转置卷积(反卷积)做上采样,把特征图恢复到原始分辨率;
- 最后一层用Tanh激活,把输出值映射到[-1, 1]区间,与输入图像的归一化范围保持一致。
为什么中间用残差块而不是单纯堆卷积?因为去雾任务中,浅层和高层的特征都很重要,残差结构通过跳跃连接让梯度能顺畅传到前层,同时让网络只需要学习“残余的雾”而不是从零重建整张图,训练更容易收敛。
Pytorch里残差块的实现大致如下:
class ResidualBlock(nn.Module): def __init__(self, in_channels): super(ResidualBlock, self).__init__() self.block = nn.Sequential( nn.ReflectionPad2d(1), nn.Conv2d(in_channels, in_channels, kernel_size=3, padding=0), nn.InstanceNorm2d(in_channels), nn.ReLU(inplace=True), nn.ReflectionPad2d(1), nn.Conv2d(in_channels, in_channels, kernel_size=3, padding=0), nn.InstanceNorm2d(in_channels) ) def forward(self, x): return x + self.block(x)这里有两个细节值得注意。第一,卷积层用的是ReflectionPad2d而不是普通ZeroPad,反射填充可以避免图像边缘出现伪影,去雾结果边缘更干净。第二,归一化用的是InstanceNorm而不是BatchNorm,这个问题后文会详细展开,但在生成器里它对小Batch训练非常关键。
2.2 判别器网络:PatchGAN的妙处
判别器D_A和D_B用的是PatchGAN。普通的判别器输入整张图片,输出一个0到1之间的标量,表示“整张图真/假”的概率。而PatchGAN不是输出一个值,而是输出一个N×N的特征图:
class PatchDiscriminator(nn.Module): def __init__(self, in_channels=3, base_channels=64): super(PatchDiscriminator, self).__init__() self.model = nn.Sequential( nn.Conv2d(in_channels, base_channels, kernel_size=4, stride=2, padding=1), nn.LeakyReLU(0.2, inplace=True), nn.Conv2d(base_channels, base_channels * 2, kernel_size=4, stride=2, padding=1), nn.InstanceNorm2d(base_channels * 2), nn.LeakyReLU(0.2, inplace=True), nn.Conv2d(base_channels * 2, base_channels * 4, kernel_size=4, stride=2, padding=1), nn.InstanceNorm2d(base_channels * 4), nn.LeakyReLU(0.2, inplace=True), nn.Conv2d(base_channels * 4, 1, kernel_size=4, stride=1, padding=1) ) def forward(self, x): return self.model(x)输出的每个像素对应输入图像上一个局部区域(感受野),相当于判别器对图片的每一小块分别判断真假。为什么用PatchGAN呢?因为去雾最关心的是局部纹理是否真实、雾气是否残留、轮廓是否清晰,这些都属于高频局部特征。PatchGAN能把这些局部细节“盯死”,而全局判别器往往被整体颜色分布带偏,忽略局部失真。
另外PatchGAN参数更少,训练速度更快,也更稳。在去雾任务里,它比整图二分类判别器实用得多。我在实际测试中也发现,把判别器改成输出单值后,生成图像虽然整体颜色差不多,但放大看细边缘会有很多奇怪的伪影,这就是判别器没有约束到局部纹理造成的。
2.3 网络初始化与训练稳定性的关联
GAN网络对初始化非常敏感。这个项目在networks.py里实现了统一的权重初始化函数,核心是用均值为0、标准差0.02的正态分布初始化卷积层权重,偏置初始化为0。初始化代码大致是这样的:
def weights_init_normal(m): classname = m.__class__.__name__ if hasattr(m, 'weight') and ('Conv' in classname or 'Linear' in classname): nn.init.normal_(m.weight.data, 0.0, 0.02) if m.bias is not None: nn.init.constant_(m.bias.data, 0.0)标准差0.02这个数值是GAN社区经过大量实验验证的经验值。它让网络初始时参数在小范围内扰动,避免一开始就出现梯度爆炸或者判别器过早碾压生成器的情况。很多人训练GAN一上来就崩溃,很可能就是初始化不规范。
这里还有一个容易踩的坑:如果用nn.Sequential搭网络,最后一定要确认每个层的bias是否合理。尤其是生成器的最后一层卷积之后接了Tanh,这层卷积的初始化也走同一个函数,但如果某些项目里忘记初始化生成器,训练初期输出全是黑色或者灰色,会严重拖慢收敛。
3. 损失函数设计与训练策略
3.1 对抗损失:LSGAN为什么比原始GAN更稳
去雾本质上是一个图像生成任务,对抗损失决定了生成图像是否“像清晰图”。这个项目采用LSGAN(最小二乘GAN)形式的对抗损失。原始GAN用的是二分类交叉熵损失,当判别器过于自信时,生成器拿到的梯度会迅速消失,训练很不稳定。而LSGAN把判别器的输出视为回归问题,目标是最小二乘误差:
- 判别器要把真实图预测为1,把生成图预测为0;
- 生成器要让判别器把自己生成的图预测为1。
最小二乘损失的梯度在误差大的时候更大,在误差小的时候更小,梯度信息比交叉熵要充裕,生成器在训练初期能更快学习。从实践结果看,使用LSGAN后训练曲线更平滑,而且很少出现判别器loss瞬间归零、生成器直接摆烂的情况。
在Pytorch里实现的时候,需要注意Pytorch的BCEWithLogitsLoss和MSELoss的输入输出范围。LSGAN直接对判别器的输出做MSE,所以判别器最后一层不要加Sigmoid,保持线性输出。如果你在使用这个项目时发现判别器输出是0~1的概率,说明代码里的判别器被改过,不是标准的LSGAN设定。
3.2 循环一致性损失:保证去雾不丢内容
光靠对抗损失,生成器完全可以“放飞自我”——把雾图变成一张看起来清晰但内容完全不同的图,只要骗过判别器就行。这就是GAN常见的模式坍塌。对偶GAN的解决方案是循环一致性损失:
对每个雾图x,先经G_A_B去雾得到fake_B,再经G_B_A加雾得到rec_A,要求rec_A与x尽量一致:
L_cycle(x) = || G_B_A(G_A_B(x)) - x ||₁
同理,对清晰图y也有对应的循环。循环损失通常用L1范数而不是L2,因为L1对边缘和细节的惩罚更友好,生成结果更锐利,不会像L2那样把图像拉得过于平滑。
在去雾任务里,循环一致性损失的作用是约束内容保留:去雾后的图片还要能够“变回”原来的雾图,这强迫G_A_B在去掉雾气的同时,保留原图的场景结构、物体位置和整体构图。如果循环损失权重太小,去雾结果可能内容漂移;如果权重太大,生成器会趋于保守,雾去不干净。在项目里,循环损失的权重λ一般设为10,这是一个平衡效果较好的默认值。
3.3 身份映射损失:解决颜色偏色问题
这是很多新手容易忽略的一个损失。对偶GAN还有一个可选的identity loss,在去雾项目里非常有用:
L_identity = || G_A_B(y) - y ||₁ + || G_B_A(x) - x ||₁
简单解释:把一张清晰的图y直接输入G_A_B,理论上应该还是清晰图,因为y已经“无雾”了,不必再转换;同理把雾图x直接输入G_B_A,也应该还是雾图。身份损失的作用是约束生成器不要随意改变输入的颜色分布。
为什么要加这个?因为纯对抗+循环一致性训练出来的去雾模型,经常把输出颜色改得怪怪的,比如整体偏灰、偏蓝,或者对比度过度拉伸。加身份损失后,生成器会尽量保留原图的色彩风格,只去雾、不乱改色。但身份损失权重不宜太高,太高会让生成器觉得“什么都不做就是最好的选择”,导致去雾力度不足。我的经验是权重设在0.5左右,训练十几轮后观察生成图,如果没有偏色再适当降低。
整个训练过程中,最终损失是三项损失的加权和:
| 损失项 | 公式简述 | 默认权重 |
|---|---|---|
| 对抗损失(LSGAN) | 生成器让判别器认为输出为真 | 1.0 |
| 循环一致性损失 | 去雾后再加雾,与原图一致 | 10.0 |
| 身份映射损失 | 清晰图去雾后仍为清晰图 | 0.5 |
3.4 训练超参数与优化器选择
项目训练配置对GAN来说非常标准,但我还是建议你调参前先理解为什么这么设:
- 优化器:Adam,学习率2e-4,betas=(0.5, 0.999)。beta1取0.5是GAN训练的常见做法,相比默认0.9,让优化器更看重当前梯度,有助于稳定对抗训练;
- 学习率策略:前100个epoch保持2e-4,后100个epoch线性衰减到0,让网络在后期慢慢收敛,避免在损失曲面震荡;
- Batch size:建议1。小batch配合InstanceNorm,是这类图像翻译任务的标准用法;
- 判别器更新时使用假图缓存池:生成器生成的图不直接用于更新判别器,而是放入一个最多存50张图的队列,每批次随机取一张历史假图和一张当前假图混合使用。这个技巧能防止判别器“记住”最新的生成图,从而减少训练波动。
4. 数据准备与完整复现流程
4.1 环境准备与Pytorch安装
在动手跑代码之前,先把环境配置好。这个项目基于Pytorch,建议Python 3.8及以上版本。Pytorch的安装建议直接从官网获取与CUDA版本匹配的命令,比如CUDA 11.8对应的命令大致是:
pip install torch torchvision --index-url https://download.pytorch.org/whl/cu118如果只是CPU环境测试,直接pip install torch torchvision即可,但训练GAN还是强烈建议用NVIDIA GPU,哪怕是入门级显卡也比CPU快一个数量级。安装完成后在Python里验证:
import torch print(torch.__version__) print(torch.cuda.is_available())如果cuda.is_available()返回True,说明GPU环境可用。
4.2 数据集的制作与目录组织
图像去雾的数据集来源有两类,一类是公开的合成雾图数据集RESIDE,另一类是自己用大气散射模型合成。我推荐先用RESIDE的OTS子集跑通流程,数据量大、场景丰富,训练效果容易出来。
如果你想自己合成雾图,公式就是前面提的大气散射模型。写一个批量处理脚本,对每张清晰图随机生成一个传输率t(比如0.4~1.0之间的随机值)和大气光A(比如0.8~1.0),代入公式就能得到雾图。我用这种方法做过一个小规模数据集,几十张清晰图就能合成几百张雾图,非常适合做实验快速迭代。
数据目录按项目要求组织成如下结构:
datasets/defog/ ├── trainA/ # 有雾图像 ├── trainB/ # 清晰图像 ├── testA/ # 测试用有雾图像 └── testB/ # 测试用清晰图像(用于计算指标)注意trainA和trainB里的图像不需要一一对应,数量也不需要相等,这正是对偶GAN的优势。
数据集读取代码通常用torchvision的ImageFolder实现,同时配合transform做数据增强。训练时图片处理成286×286输入,随机裁剪出256×256区域,再随机水平翻转,最后归一化到[-1, 1]区间。这些预处理在datasets/transforms.py里都能找到对应实现。
4.3 训练命令与过程监控
环境配置好后,训练入口脚本是train.py,核心参数包括:
python train.py \ --dataroot ./datasets/defog \ --name defog_experiment \ --model cycle_gan \ --batch_size 1 \ --n_epochs 100 \ --n_epochs_decay 100--name参数决定实验输出目录,所有训练日志、checkpoint和可视化结果会存放在checkpoints/defog_experiment/下。模型权重默认每5个epoch保存一次,同时也会保存最近一次的latest权重,方便中断后续训。
训练过程中建议开启visdom或者TensorBoard来观察loss曲线和生成结果。只看loss值是看不出效果的,一定要每隔几十个iteration把当前生成器G_A_B的输出图片保存下来,亲眼确认去雾效果。我看到过很多次loss非常漂亮但输出完全不能看的情况,所以可视化监督非常必要。
4.4 测试与评估指标
训练完成后用test.py做推理:
python test.py \ --dataroot ./datasets/defog \ --name defog_experiment \ --model cycle_gan \ --epoch 100测试结果保存在results/defog_experiment/目录。如果项目里有参考清晰图(testB),还可以进一步计算量化指标。去雾任务最常用的是PSNR和SSIM:
from skimage.metrics import peak_signal_noise_ratio, structural_similarity psnr = peak_signal_noise_ratio(output, target, data_range=1.0) ssim = structural_similarity(output, target, channel_axis=-1)PSNR越大越好,SSIM越接近1越好。但我必须提醒一句:在去雾任务里,PSNR和SSIM提升幅度往往有限,甚至有时候视觉上更自然的图像PSNR反而低。因为去雾本质是“主观感知任务”,所以最终效果评估一定要结合人眼观察,重点看边缘是否锐利、整体颜色是否真实、天空区域有没有过曝、远处细节是否恢复。
5. 训练过程中踩过的坑与排查实录
5.1 训练几轮后Loss变成NaN
这是最常见的崩溃方式。排查顺序是这样的:先检查学习率是否过大,2e-4对这个项目一般不会出事,但如果你手滑改成2e-3,大概率NaN;然后检查输入数据范围,生成器输出用Tanh在[-1,1]区间,如果输入数据忘了归一化到[-1,1]而是在[0,255],网络很容易发散;最后检查有没有除零操作,特别是InstanceNorm对单通道输入会算均值/方差,数值稳定性一般没问题,但如果某个batch里全是同一张纯色图片,方差接近0,也会出问题。
我的建议是先用很小的数据集、只跑10个epoch测试,代码能正常跑通再上完整数据。遇到NaN不要慌,先定位是哪个损失变成NaN,缩小排查范围。
5.2 去雾结果偏灰、偏暗或者颜色失真
如果去雾后的图像看起来清晰了,但整体发灰,最可能的原因是循环一致性损失的权重太高,生成器为了满足“加雾回去与原图一致”,会倾向于输出保守的中间色调。解决办法是适度调低λ,或者提高身份损失的权重来约束颜色方向。
颜色失真严重时,优先检查身份损失是否加入。我一开始训练时把身份损失权重设为0,训练到第50轮左右,生成的图像天空变成奇怪的紫色。后来加入身份损失并设权重为0.5,这个问题基本消失。记住,身份损失在去雾任务里不是可选优化项,而是保颜色的关键正则项。
5.3 雾气去不干净,残留白色薄雾
如果生成图看起来“有点清但没清透”,通常是以下原因:
- 训练epoch不足,生成器还没完全学会强去雾;
- PatchGAN的感受野不够大,对大面积雾气的整体分布不敏感;
- 判别器太弱,无法有效区分“微雾图”和“清晰图”。
针对最后一个原因,可以尝试把判别器的卷积核从4改成稍微大一些,或者增加判别器的层数,让它的辨别能力更强,从而反过来督促生成器下狠手去雾。但这也可能带来训练不稳定,需要同时留意判别器loss和生成器loss是否保持在合理范围。
5.4 加载预训练权重时报错“module”前缀不匹配
如果你用DataParallel在多卡环境训练,保存下来的权重文件名里所有键名会带上module.前缀。单卡测试加载时会报错,提示size mismatch或者key不存在。解决办法是在加载时做一次字符串替换:
state_dict = torch.load(checkpoint_path, map_location='cpu') new_state_dict = {k.replace('module.', ''): v for k, v in state_dict.items()} model.load_state_dict(new_state_dict)5.5 如何判断模型是否过拟合
GAN过拟合的表现和普通分类网络不一样,它通常不是accuracy下降,而是生成图像变得单一、重复,或者对训练集里的某几张雾图去雾效果极好,但对新图完全不行。判断方法很简单:测试时把训练集里的图和全新的图分别放进同一模型里跑,如果训练集图效果远好于新图,说明过拟合了。对策是增加数据多样性、采用更强的数据增强,或适当引入Dropout。对GAN来说,过拟合没有分类任务那么普遍,但小数据集上依然会出现,值得警惕。
这个项目我前前后后跑了很多个版本,最大的体会是:GAN做图像去雾,网络结构反而不是重点,真正决定效果上限的是损失函数的配比和数据质量。训练过程中你会看到一个很有意思的现象——当循环一致性和对抗损失达到某种平衡时,生成器会突然从“只会把图像变灰”变成“真正能看清远处山峦的轮廓”,这个临界点需要一点耐心去调参。建议你拿到项目后,先不要动模型结构,用默认参数跑通一遍,再用小数据集做几组对照实验,分别调整循环损失权重、身份损失权重和训练epoch数,记录每组实验的输出图片。等你亲手把颜色漂移、雾残留这些问题一个个压下去之后,你对对偶GAN的理解会比读十篇论文都深。
本文还有配套的精品资源,点击获取
