2026/8/26 9:14:23

基于Pytorch对偶生成对抗网络的图像去雾实战拆解

基于Pytorch对偶生成对抗网络的图像去雾实战拆解 简介图像去雾是计算机视觉中的经典难题传统方法依赖大气散射模型和物理先验在天空区域或浓雾场景下容易失效。随着生成对抗网络GAN的发展数据驱动的去雾方案逐渐成为研究热点。其中对偶生成对抗网络DualGAN通过双向映射结构和循环一致性约束能够在无需成对样本的条件下学习有雾与无雾图像间的风格迁移这种非监督学习机制大大降低了数据准备门槛具备较强的工程实用价值。本文基于Pytorch框架从模型结构、训练闭环到调参经验全面拆解DualGAN在图像去雾任务中的实现路径并给出可复现的代码设计和踩坑记录为相关方向的开发者提供一条低成本的落地参考。该方法还可推广至去雨、去噪、低光增强等图像翻译任务适用面广。 我就是冲着“能用”才来写这篇东西的。做图像去雾这个方向也有一阵子了市面上大量现成方案要么是论文复现之后完全跑不动要么是效果只能活在作者的README里。这次这个项目基于Pytorch实现对偶生成对抗网络来做图像去雾倒是一个值得展开聊聊的实现路径。我先说结论这套代码能跑通能出效果而且对硬件要求没那么苛刻。如果你正打算入坑图像去雾、或者想找一个GAN项目练手这篇拆解应该能帮你省掉不少弯路。整条路线的工作逻辑其实不复杂利用对偶生成对抗网络DualGAN在图像风格迁移上的能力把“有雾图像”和“无雾图像”看成两种风格域训练一个双向映射模型让网络在翻译过程中学会去雾。这个思路和传统去雾算法不一样不是先去估计透射率和大气光再反推清晰图像而是直接端到端学习映射关系。好处是省掉了物理模型的约束坏处是如果没有足够的约束模型很容易放飞自我。我把整个项目的源码结构、模型设计、训练细节、踩坑记录都过了一遍下面直接上干货。1. 雾天图像为什么难处理从物理模型到网络设计的取舍1.1 大气散射模型与去雾难度图像去雾的核心困难不是“看不清”三个字这么简单。传统方法大多建立在大气散射模型上这个模型把成像过程描述成两个部分叠加一部分是场景反射光经过大气衰减后到达相机的直接透射分量另一部分是大气光受悬浮粒子散射形成的环境光分量。公式大概是I(x) J(x) * t(x) A * (1 - t(x))其中I是有雾图像J是清晰图像t是透射率A是全局大气光。去雾任务说白了就是已知I反推J、t、A。这个方程本身是欠约束的——一个方程里有三个未知数每个像素都得求怎么解都不唯一。暗通道先验这类方法之所以曾经流行是因为它用统计先验来约束这个方程。但现实场景里暗通道先验经常失效比如天空区域、白色物体、雾特别浓的情况估计出来的透射率图会带明显光晕和色偏。我早年拿暗通道先验跑一批夜景雾图效果一言难尽天空部分直接变成了灰蓝色块这就是物理先验的局限。1.2 从物理先验到数据驱动的思路转变数据驱动的思路是另一条路。既然物理模型不好建那就让网络自己学映射关系。最初的一批监督学习方法需要成对的有雾/无雾图像用L2损失或者感知损失训练一个CNN网络。问题是真实的成对雾图非常难获取。虽然可以用深度图大气散射模型合成雾图但合成数据和真实雾图之间的domain gap导致模型泛化到真实场景时效果明显变差。那如果不要求成对数据呢DualGAN就是一个典型的非成对图像翻译方案。它能同时学两个方向的映射有雾图到无雾图以及无雾图到有雾图然后通过循环一致性约束来保证内容结构不被破坏。这样一来训练数据只需要两类图片不需要一张一张去做像素级配对数据获取门槛一下就降下来了。这也是这个项目选择DualGAN而不是传统CNN或CycleGAN变体的核心原因。2. 对偶生成对抗网络的机制拆解双向映射为什么能去雾2.1 生成器和判别器的任务划分DualGAN的结构是经典的GAN框架但它的特别之处在于有两个生成器和两个判别器。通常情况下一个生成器负责从A域映射到B域另一个负责从B域映射回A域。两个生成器形成对偶结构。两个判别器分别判断生成的图片是否属于对应域的真实分布。在图像去雾场景里A域就是有雾图像B域就是清晰无雾图像。生成器G_AB负责去雾生成器G_BA负责加雾。判别器D_B负责判断一张图是真实清晰图还是G_AB生成的伪清晰图判别器D_A则判断一张图是真实有雾图还是G_BA生成的伪有雾图。这个分工本身就是为了解决非成对数据下的训练不稳定问题。如果没有循环一致性约束G_AB完全可以生成一张和输入完全无关的清晰图——只要骗过了D_B就行内容早就跑偏了。但加上了反向生成器之后G_AB生成的去雾图还得能被G_BA变回原图这就逼着G_AB去保留原图的结构信息只在“雾”这个维度做修改。2.2 循环一致性损失的具体意义循环一致性损失是整个DualGAN的定海神针。简单说就是一张有雾图A先经过G_AB得到去雾图B_hat再经过G_BA得到重建有雾图A_recA_rec要尽量接近A。反过来清晰图B经过G_BA加雾得到A_hat再经过G_AB去雾得到B_recB_rec也要尽量接近B。这看起来只是在加了一个重建约束实际作用远不止于此。它把生成器学习的目标从“骗过判别器”变成了“在骗过判别器的同时不能丢掉内容信息”。雾是天气属性而图像内容里的物体轮廓、边缘结构、纹理细节都与雾无关这些信息必须在双向循环中保留下来。训练过程中我观察过循环重建误差曲线一旦这个误差下降生成图像的视觉结构就开始变得稳定。2.3 对抗损失和循环损失的组合权衡DualGAN的总损失函数通常是三部分加权正向GAN损失、反向GAN损失、循环一致性损失。实际调参的时候循环一致性损失的权重非常关键。权重太大模型会偏向保守去雾不彻底生成的图像还是灰蒙蒙的权重太小模型会放飞自我颜色失真甚至生成一些幻觉纹理。我在这个项目里的经验是初始设置λ10是合理的如果发现去雾不彻底可以稍微降低到5-8如果发现颜色偏得太厉害就往回调到15-20。这个数字不是拍脑袋定的不同数据集分布的差异会对权重很敏感。建议每次调整不要超过2倍然后观察训练趋势再做下一步调整。3. Pytorch工程实现从模型定义到训练闭环的重难点3.1 网络结构选型与定义这个项目的基础框架用的是Pytorch版本不挑1.8到2.x都能跑。生成器选了带跳跃连接的U-Net结构UnetGenerator而不是ResNet结构。原因在于去雾任务比较讲究细节保留U-Net的跳跃连接能把编码器提取的浅层特征直接送到解码器信息损失小细节更清晰。一个小建议如果你的显存不够第一版可以先用小channel数起步比如16或32跑通整套流程后再逐步加宽。不要一上来就上64显存容易直接爆掉。下面是生成器定义的核心结构示意import torch import torch.nn as nn class UnetBlock(nn.Module): def __init__(self, in_channels, out_channels, downTrue): super().__init__() if down: self.block nn.Sequential( nn.Conv2d(in_channels, out_channels, 4, 2, 1, biasFalse), nn.BatchNorm2d(out_channels), nn.LeakyReLU(0.2, inplaceTrue) ) else: self.block nn.Sequential( nn.ConvTranspose2d(in_channels, out_channels, 4, 2, 1, biasFalse), nn.BatchNorm2d(out_channels), nn.ReLU(inplaceTrue) ) def forward(self, x): return self.block(x) class UnetGenerator(nn.Module): def __init__(self, in_channels3, out_channels3): super().__init__() # 编码器下采样路径 self.down1 UnetBlock(in_channels, 32, downTrue) self.down2 UnetBlock(32, 64, downTrue) self.down3 UnetBlock(64, 128, downTrue) self.down4 UnetBlock(128, 256, downTrue) self.down5 UnetBlock(256, 256, downTrue) self.down6 UnetBlock(256, 256, downTrue) self.down7 UnetBlock(256, 256, downTrue) # 最底层 self.bottom UnetBlock(256, 256, downTrue) # 解码器上采样路径 self.up1 UnetBlock(256 256, 256, downFalse) self.up2 UnetBlock(256 256, 256, downFalse) self.up3 UnetBlock(256 256, 256, downFalse) self.up4 UnetBlock(256 256, 128, downFalse) self.up5 UnetBlock(128 128, 64, downFalse) self.up6 UnetBlock(64 64, 32, downFalse) self.up7 UnetBlock(32 32, out_channels, downFalse) def forward(self, x): d1 self.down1(x) d2 self.down2(d1) d3 self.down3(d2) d4 self.down4(d3) d5 self.down5(d4) d6 self.down6(d5) d7 self.down7(d6) bottom self.bottom(d7) u1 self.up1(torch.cat([bottom, d7], dim1)) u2 self.up2(torch.cat([u1, d6], dim1)) u3 self.up3(torch.cat([u2, d5], dim1)) u4 self.up4(torch.cat([u3, d4], dim1)) u5 self.up5(torch.cat([u4, d3], dim1)) u6 self.up6(torch.cat([u5, d2], dim1)) u7 self.up7(torch.cat([u6, d1], dim1)) return torch.tanh(u7)生成器输出层用tanh把输出范围限制在[-1, 1]和输入图像的归一化范围保持一致。这个细节容易忽略但影响不小。如果输入输出范围不一致模型训练初期很容易出现loss震荡。判别器使用了PatchGAN结构输出的不是一个标量而是一个N x N的特征图每个元素判断图像上一个局部区域的真假。这个设计的核心是强迫判别器关注局部纹理细节而不是只判断整体风格对去雾任务比较友好。class PatchDiscriminator(nn.Module): def __init__(self, in_channels3): super().__init__() self.model nn.Sequential( nn.Conv2d(in_channels, 32, 4, 2, 1, biasFalse), nn.LeakyReLU(0.2, inplaceTrue), nn.Conv2d(32, 64, 4, 2, 1, biasFalse), nn.BatchNorm2d(64), nn.LeakyReLU(0.2, inplaceTrue), nn.Conv2d(64, 128, 4, 2, 1, biasFalse), nn.BatchNorm2d(128), nn.LeakyReLU(0.2, inplaceTrue), nn.Conv2d(128, 256, 4, 1, 1, biasFalse), nn.BatchNorm2d(256), nn.LeakyReLU(0.2, inplaceTrue), nn.Conv2d(256, 1, 4, 1, 1) ) def forward(self, x): return self.model(x)3.2 图像去雾的训练闭环训练循环需要仔细设计。核心流程是从有雾域A和无雾域B分别采样一个batch计算正向生成B_hat G_AB(A)重建有雾图像 A_rec G_BA(B_hat)计算反向生成A_hat G_BA(B)重建清晰图像 B_rec G_AB(A_hat)计算循环一致性损失L_cycle ||A_rec - A||1 ||B_rec - B||1计算判别器损失和生成器对抗损失更新判别器和生成器参数# 训练核心伪代码完整结构和优化器设置见源码 for epoch in range(epochs): for batch_a, batch_b in dataloader: batch_a batch_a.to(device) batch_b batch_b.to(device) # 正向有雾 - 去雾 fake_b netG_AB(batch_a) rec_a netG_BA(fake_b) # 反向无雾 - 加雾 fake_a netG_BA(batch_b) rec_b netG_AB(fake_a) # 循环一致性损失 loss_cycle L1_loss(rec_a, batch_a) * lambda_cycle \ L1_loss(rec_b, batch_b) * lambda_cycle # 判别器对生成图的判断 pred_fake_b netD_B(fake_b) loss_gan_AB MSELoss(pred_fake_b, real_label) pred_fake_a netD_A(fake_a) loss_gan_BA MSELoss(pred_fake_a, real_label) loss_G loss_gan_AB loss_gan_BA loss_cycle loss_G.backward() optimizer_G.step() # 更新判别器 pred_real_b netD_B(batch_b) loss_D_real_b MSELoss(pred_real_b, real_label) pred_fake_b netD_B(fake_b.detach()) loss_D_fake_b MSELoss(pred_fake_b, fake_label) loss_D_B (loss_D_real_b loss_D_fake_b) * 0.5 loss_D_B.backward() optimizer_D_B.step()判别器更新时一定要用fake_b.detach()把生成器的梯度截断否则反向传播会把梯度同时传到生成器和判别器训练必然不稳定。这是新手最常踩的坑。3.3 优化器与学习率设定两个生成器共用一个优化器两个判别器共用一个优化器。优化器用Adam学习率初始2e-4beta10.5beta20.999。beta1用0.5而不是默认的0.9这是GAN训练的经验值。默认值0.9会让梯度一阶矩估计衰减太慢导致训练早期震荡明显。关于学习率的设置还有一个小技巧前一半epoch保持初始学习率后一半epoch线性衰减到0。这种策略能帮助模型在训练后期稳定收敛避免在loss landscape的狭窄区域来回震荡。Pytorch里可以用LambdaLR实现不需要额外装库。4. 数据集准备与训练实验参数为什么这么设4.1 非成对数据集的构建这个项目的训练数据不需要配对。A域收集有雾图像B域收集清晰图像两个域独立整理。我从公开数据集比如RESIDE的OTS子集、Dense-Haze里整理了大概3000张有雾图和3000张清晰图。实际训练用了约2000对batch迭代了120个epoch单卡RTX 3090大概跑了一个晚上。数据预处理环节有几个容易忽略的点图像统一resize到256x256然后随机crop到224x224增加数据多样性随机水平翻转做数据增强归一化到[-1, 1]和生成器输出tanh的范围对齐有雾域和无雾域的图像数量不要差距太大否则训练会偏向样本多的一方4.2 超参数配置与显存占用我整理了一个常用的参数配置表你可以直接参考参数推荐值说明输入尺寸256x256过大显存不够过小细节丢失batch_size4RTX 3090 24G可跑小显存降到1或2初始学习率2e-4Adam默认配合这个值比较稳beta10.5GAN训练常用设置循环一致性权重10根据去雾效果和色彩保真度调整epoch100-150数据集小可以适当增加优化器Adam生成器和判别器分别两个优化器如果你的显卡是6G或者8G显存把batch_size降到1同时把生成器第一个卷积层的通道数从32降到16就能跑起来。代价是训练时间变长效果可能稍差一些。4.3 训练过程监控训练过程中我重点关注三个指标生成器总损失整体应该缓慢下降但会有波动循环一致性损失这个是最重要的它持续下降说明双向映射在建立判别器损失理想状态是判别器损失保持在0.5-1.0附近太低了说明生成器太弱骗不过判别器太高了说明生成器太强判别器跟不上还有一个实用技巧每隔固定的迭代次数把当前生成的去雾图存下来看一眼。损失曲线再漂亮不如直接看图来得直观。我发现有些阶段虽然loss在降但输出的图颜色已经偏了这种时候光看loss是发现不了的。5. 训练过程中的典型问题与我的排查经验5.1 模式崩溃与损失震荡我这次训练中途就遇到了典型的GAN训练问题。大约在第20个epoch左右生成器损失突然跳高然后生成的图像开始出现大量伪纹理尤其是天空区域出现一些诡异的水波纹。排查下来发现问题出在初始学习率设置得太高以及判别器的更新次数和生成器不匹配。解决方案是把学习率从2e-4降到1e-4同时让判别器每更新两次生成器才更新一次。这样能让判别器更有“主见”不至于被生成器带偏。调整之后训练明显稳定了很多。5.2 去雾不彻底与颜色偏暗另一个常见问题是训练到后期去雾效果出来了但整张图偏暗像是蒙了一层灰色。这是生成器偷懒倾向于把输出往均值方向压缩这样循环重建损失会比较低但视觉上就是不通透。我的处理办法是在生成器的输出后加一个全局直方图拉伸作为后处理同时在损失函数里加少量感知损失perceptual loss用VGG16的某个中间层特征做约束。感知损失对颜色和结构的约束比像素级L1损失更符合人眼感知。增加这个约束之后生成图的色彩饱和度有了明显改善。5.3 内存不足与batch size调整如果你在训练时遇到OOM不要急着换显卡先按这个顺序排查当前图像分辨率是多少如果超过512x512直接降到256x256batch size是不是设得太大了先降到1试试检查反向传播前有没有把上一轮的梯度清零有没有显存碎片累积设置torch.cuda.empty_cache()定期清理如果这些都做了还是OOM就需要换更轻量的生成器结构比如去掉几层下采样或者把channel数减半。6. 结果对比与适用边界什么场景下推荐用DualGAN6.1 主观效果与客观指标从实际测试结果来看DualGAN对轻度到中度的均匀雾效果最好能比较干净地去除雾感同时保持图像色彩自然。对浓雾场景模型也能去一部分雾但边缘细节会有些损失整体感觉是“雾淡了但清晰度没有完全恢复”。客观指标上PSNR大概能到20-22dBSSIM在0.85-0.90之间。这个数值相比一些专门设计的监督学习方法确实不算突出比如某些用成对数据训练的去雾网络PSNR能到25以上。但考虑到DualGAN只用非成对数据就能达到这个水平实用性已经不错了。方法数据要求PSNR (dB)SSIM泛化能力暗通道先验无16-180.75-0.80一般天空区域易失效监督CNN去雾成对数据24-260.90好但依赖数据DualGAN本项目非成对数据20-220.85-0.90较好源域迁移能力强6.2 什么场景用DualGAN最划算根据这些实验结果我觉得DualGAN在下面这些场景下最值得尝试你没有成对的雾图/清晰图数据但又想获得比传统方法更好的效果你手里的有雾图像和清晰图像来自不同拍摄设备风格差异较大需要用非监督方式对齐你想快速搭一个去雾demo不想花太多时间在数据标注和配对清洗上你希望一个模型既能去雾又能加雾数据增强场景双向生成器直接省了一个模型反过来如果已经有大量成对数据并且追求极致的指标那用监督学习方法会是更直接的选择。DualGAN更适合作为非成对数据场景下的解决方案。我个人在实际操作中的体会是图像去雾这个任务没有一套方案能通吃所有场景。DualGAN给我的最大价值并不是指标上碾压谁而是提供了一条低门槛、可落地的非监督路线。你不需要花大量时间做配对数据清洗只需要把有雾和无雾的图分别归档就能训练出效果可用的模型。对于入门GAN、或者想在图像增强方向积累工程经验的人来说这个项目确实值得自己动手跑一遍。调试过程中踩过的那些坑学会之后转换到其他生成任务也会非常受用。如果你决定复现这个项目建议先跑通默认配置再逐步调整。理解每一层结构为什么这样设计、每个损失权重为什么这样设比直接跑出结果更有价值。代码拿过去之后改一改也能迁移到其他图像翻译任务例如去雨、去噪、低光增强思路是完全一致的。本文还有配套的精品资源点击获取