简介:这是一份面向图像去雾任务的人工智能与机器学习课程期末作业,作者基于大气散射模型引入物理约束项,同时采用SE通道注意力、残差结构、多尺度特征提取与非局部注意力机制,并结合预训练VGG16感知损失、数据增强和随机Dropout训练策略,能明显改善纹理细节恢复和主观视觉质量。压缩包内共41个文件,主要包括Python源码(训练/测试脚本)、pth模型权重、png可视化结果以及README说明,整体仅14.3MB,目录按模型定义、训练数据、结果输出等模块组织,易于查阅。资源目前已有64人学习下载。学习者可以获得完整的去雾模型实现与训练细节,既能用于课程项目复现与答辩展示,也可作为后续开展图像增强、物理引导深度学习研究的参考基线。
1. 去雾模型为什么把纹理恢复和主观质量当作核心指标
图像去雾这个任务,如果只看 PSNR 和 SSIM,往往会得出一个反直觉的结论:客观指标刷得很高,视觉效果却仍然“发闷”——雾是去了,但纹理像被磨皮一样糊掉了,边缘发虚、细节纹理丢失,观感上甚至不如带一点薄雾的原图。这正是去雾模型区别于超分辨率、去噪任务的地方:去雾的难点不在“把雾移除”,而在“移除雾的同时把被散射削弱的纹理重新立起来”。第六组期末作业这个题目把“纹理细节恢复”和“主观视觉质量”并列提出,实际上是在强调:去雾模型的评估标准已经从像素误差的数值游戏,转向了人类视觉系统真正感知到的质量。
背后的原因不难理解。大气散射模型把带雾图像拆解为场景辐射和大气光两部分,但真实的雾天环境远比这个模型复杂——不均匀的雾浓度、大气光的空间变化、传感器噪声,都会让模型在恢复时产生歧义。模型为了压低 L2 损失,倾向于生成平滑的平均解,这就是纹理丢失的根源。本文按照“物理模型 → 网络结构 → 损失函数 → 训练与评估”这条线展开,把我做去雾项目时验证过的一套方案完整梳理出来,代码和参数都可以直接借鉴。
2. 去雾模型的物理基础与两类技术路线的取舍
2.1 大气散射模型:去雾问题的数学起点
去雾问题能被建模,几乎都绕不开大气散射方程。它的退化表达式为:
I(x) = J(x) t(x) + A (1 - t(x))其中I(x)是观测到的带雾图像,J(x)是待恢复的清晰场景辐射,t(x)是介质传输率(取值范围 0 到 1),A是全局大气光。重写一下就是:
J(x) = \frac{I(x) - A}{t(x)} + A这个式子清晰地揭示了病态性:已知I(x),但t(x)和A都是未知量,一个方程两个未知数,必须附加先验或约束才能求解。传统算法中的暗通道先验(Dark Channel Prior)就是人工设定的约束——统计发现户外无雾图像的局部块中,至少一个颜色通道的强度趋近于零。但这个先验在天空区域、白色物体和强光源区域不成立,处理不好就会出现色偏和伪影,纹理细节自然无从谈起。
深度学习路线绕开了显式先验的局限。网络端到端地从数据中学习I(x)到J(x)的映射,不再依赖手工先验,潜在的好处就是模型能隐式学习纹理结构的分布规律。常见做法是让网络直接回归J(x),不单独预测t(x)和A,因为级联估计误差会逐级放大,最终影响纹理重现的准确性。
2.2 为什么端到端回归比先估计传输率再复原更稳
早期基于 CNN 的去雾方案延续了传统思路:第一步用网络预测传输率图t(x),第二步用大气散射公式做像素级还原。但这里有一个工程上很棘手的累积误差问题——传输率估计的一个小偏差,在J(x)的计算中会因为除法而被放大,尤其在t(x)趋近于零的区域,数值不稳定直接导致纹理拉花。
业内更倾向的做法是采用一体化网络。以 AOD-Net 为代表的结构通过变量代换,把大气散射公式合并进网络前向计算中,网络只输出一个中间结果K(x):
# K(x) 的构造方式(参考 AOD-Net 思路的简化实现) def reformulation(input_image, t_est, a_est): # t_est: 估计的传输率图, a_est: 估计的大气光 k = (1.0 - t_est) / t_est # 重新参数化,融合 t 和 A 的影响 # 将带雾图与 K 组合,直接输出去雾结果 return k * input_image - k * a_est + a_est这种重参数化的优点很实际:网络不再需要高精度地单独预测t(x)和A(x),只需要让最终重建图像误差最小即可。我做对比实验时发现,两阶段模型在合成数据上表现尚可,但迁移到真实雾天图像时,纹理区域(树叶、织物纹路、建筑边缘)经常出现伪纹理或结构断裂,而端到端一体化的网络在这一项上明显更稳。改参数时注意K(x)的通道数要和输入保持一致,否则逐像素计算时维度不匹配。
2.3 合成雾与真实雾的分布差异,决定了模型的天花板
网络训练需要成对的清晰/带雾图像,但真实雾天几乎无法采集到严格的配对样本,所以业界主要还是靠合成数据。合成雾的做法通常是用清晰图像加随机深度估计,生成随机的传输率图,再按大气散射模型叠加大气光:
# 合成带雾数据的常用流程(伪代码级操作示意) # 1. 用单目深度估计模型(如 MiDaS)为清晰图生成深度图 # 2. 由深度图按指数衰减生成传输率 t = exp(-beta * d) # 3. 全局大气光 A 在 [0.7, 1.0] 区间内随机采样 # 4. 按 I = J * t + A * (1 - t) 合成带雾图像 # 5. 对 t 做局部平滑扰动,模拟真实雾浓度不均匀的情况这里有一个关键陷阱:合成雾的t(x)通常是空间平滑的,而真实雾因为大气湍流和光源变化,传输率存在局部剧烈波动。模型在平滑的合成传输率上训练,学到的是低频信息的主导映射,对真实雾图中的高频纹理衰减缺乏适应能力。我一般会在训练时对传输率图做随机块状扰动,相当于一种数据增强,能够在某些样本上模拟出更接近真实雾的纹理损失效果。
3. 面向纹理恢复的模型结构设计与关键模块选择
3.1 编码器-解码器的偏置:为什么直接堆卷积不行
在去雾任务上,感受野大小直接决定了模型能否利用全局上下文来消除雾的影响。雾对图像的影响是低频的——大气光散射本质上是全局效应,一片区域的颜色偏移往往由远处的大气状况决定。如果感受野太小,模型只能看到局部对比度,容易把景深不同的区域搞混,纹理恢复就成了空中楼阁。
编码器-解码器结构的价值就在这里:编码器逐步下采样扩大感受野,让网络感知全局雾浓度;解码器逐步恢复空间分辨率,在语义信息的指导下重建纹理细节。需要注意的是,单纯的下采样会丢失高频信息,所以跳跃连接(skip connection)几乎是必需的设计。U-Net 风格的编解码结构是这个任务里最常见的骨架,当然实际操作中有多种变体可供选择。
# 一个用于去雾的编码器-解码器核心结构示例(PyTorch 风格) class DehazeBlock(nn.Module): def __init__(self, in_ch, out_ch): super().__init__() self.conv1 = nn.Conv2d(in_ch, out_ch, 3, padding=1) self.conv2 = nn.Conv2d(out_ch, out_ch, 3, padding=1) self.relu = nn.ReLU(inplace=True) def forward(self, x): out = self.relu(self.conv1(x)) out = self.relu(self.conv2(out)) return out class EncoderDecoder(nn.Module): def __init__(self): super().__init__() self.enc1 = DehazeBlock(3, 64) self.pool1 = nn.MaxPool2d(2) self.enc2 = DehazeBlock(64, 128) self.pool2 = nn.MaxPool2d(2) self.mid = DehazeBlock(128, 256) self.up2 = nn.Upsample(scale_factor=2, mode='bilinear', align_corners=False) self.dec2 = DehazeBlock(128 + 128, 128) self.up1 = nn.Upsample(scale_factor=2, mode='bilinear', align_corners=False) self.dec1 = DehazeBlock(64 + 64, 64) self.output = nn.Conv2d(64, 3, 3, padding=1) def forward(self, x): e1 = self.enc1(x) e2 = self.enc2(self.pool1(e1)) m = self.mid(self.pool2(e2)) d2 = self.dec2(torch.cat([self.up2(m), e2], dim=1)) d1 = self.dec1(torch.cat([self.up1(d2), e1], dim=1)) return self.output(d1)代码里dec2和dec1的上采样结果与对应编码器层在通道维度上拼接,Upsample 用的是双线性插值,align_corners=False能避免像素中心点对齐误差带来的网格伪影。实际操作中,我会把MaxPool2d替换为步长为 2 的卷积,下采样过程可学习,纹理信息保留得更多,训练也相对稳定。另外,上采样操作在解码器中起到了关键作用——如果用转置卷积,容易出现棋盘格伪影,这对纹理细节的恢复是致命的;双线性插值虽然简单,但没有可学习参数,不会引入这个风险。
3.2 注意力机制在雾浓度不均匀场景中的必要性
真实雾天图像的一大特点是雾浓度随景深变化,远景雾浓、近景雾淡。全局共享的卷积核天然不擅长处理这种空间异质性。注意力机制能让模型按像素位置动态调整特征权重:浓雾区域增强特征响应,薄雾区域抑制过度增强。比较常用的方案有两类。通道注意力(如 SENet 的 SE 模块)在通道维度做重标定,保留对去雾有用的高频纹理通道。而空间注意力生成一个二维权重图,逐像素调整特征强度,在空间维度上让模型更关注浓雾区域。
# 结合通道与空间注意力的残差块(FFA-Net 风格的核心思想) class AttentionBlock(nn.Module): def __init__(self, channels): super().__init__() self.channel_avg = nn.AdaptiveAvgPool2d(1) self.channel_max = nn.AdaptiveMaxPool2d(1) self.fc = nn.Sequential( nn.Conv2d(channels * 2, channels // 8, 1), nn.ReLU(inplace=True), nn.Conv2d(channels // 8, channels, 1) ) self.spatial = nn.Sequential( nn.Conv2d(2, 1, 7, padding=3), nn.Sigmoid() ) def forward(self, x): # 通道注意力:平均池化捕获全局信息,最大池化捕获显著纹理 avg_out = self.fc(torch.cat([self.channel_avg(x), self.channel_max(x)], dim=1)) channel_weight = torch.sigmoid(avg_out) x = x * channel_weight # 空间注意力:拼接平均/最大池化特征图,7x7 卷积学习空间权重 avg_spatial = torch.mean(x, dim=1, keepdim=True) max_spatial, _ = torch.max(x, dim=1, keepdim=True) spatial_cat = torch.cat([avg_spatial, max_spatial], dim=1) spatial_weight = self.spatial(spatial_cat) return x * spatial_weight参数上的两个细节值得注意:通道注意力的瓶颈层压缩比设channels // 8,压缩比太狠会丢信息,太浅则参数膨胀;空间注意力部分用 7×7 卷积核是因为需要较大的感受野来感知空间结构,3×3 在这个任务上偏弱。我把这个模块放在编码器的每个 Block 之后,而不是所有位置统一加,因为浅层特征是纹理高频信息的主要载体,深层语义特征的注意力加权收益不高但计算开销实在。
3.3 多尺度特征融合的落地方式
雾对图像的影响在不同尺度上表现不同——浓雾区域需要更大感受野来估计大气光,纹理细节却依赖高分辨率特征。单尺度模型很难两头兼顾。多尺度融合的常见做法是把不同分辨率的特征图上采样到同一尺寸后沿通道拼接,再通过 1×1 卷积降维融合。这个操作很简单,但要注意拼接顺序和上采样方式要保持一致,否则不同尺度的空间对齐误差会被后续卷积放大。
更轻量级的替代方案是空洞卷积(dilated convolution),在不降低分辨率的前提下扩大感受野。设计时保持空洞率递增的序列(如 1→2→4→8),避免出现网格伪影。我用空洞卷积替换部分下采样层后,模型参数量没有增加,但纹理边缘的连续性有明显改善。需要留意的是空洞卷积对大分辨率输入显存消耗较高,如果显存不够,回到多尺度拼接更现实。
4. 损失函数组合,纹理恢复的关键所在
4.1 L1 损失的局限性,以及为什么不只盯像素误差
很多同学做去雾课程作业,第一反应是用 L2(MSE)损失优化网络,因为实现最直接。但 L2 损失对应高斯噪声假设下的最大似然估计,优化它倾向于生成所有可能结果的均值——在去雾问题上就是纹理被抹平。L1 损失对离群点的惩罚更温和,能保留更多边缘信息,但单独使用仍然缺乏对感知结构的约束。
# 像素损失推荐用 L1 而非 L2,训练更稳且留住的纹理更多 criterion_pixel = nn.L1Loss() # 训练循环中的核心损失计算片段 for batch in train_loader: hazy_img, clean_img = batch output = model(hazy_img) loss_l1 = criterion_pixel(output, clean_img) loss = loss_l1 # 后续加上感知损失和对抗损失 optimizer.zero_grad() loss.backward() optimizer.step()原因在于 L1 的梯度是常数,不会像 L2 那样对大误差区域产生过强的梯度信号,避免了模型为了少数困难像素而牺牲大范围纹理结构。实际训练中 L2 收敛后的 PSNR 可能不低,但放大看纹理区域会有明显的涂抹感,L1 在主观细节上领先是普遍的结论。参数上,初始学习率建议2e-4,使用 Adam 优化器,这两个设置在大多数去雾模型上都适用。
4.2 感知损失:让网络输出与人类视觉对齐
感知损失的基本思路是让输出图像和真实图像在预训练网络的深层特征空间上接近,而不是在像素空间上接近。深层特征经过大量语义信息编码,像素位置的微小偏移被抑制,结构性和纹理模式被强调。去雾任务里我一般用 VGG16 的relu3_3层作为特征提取器:
import torchvision.models as models class PerceptualLoss(nn.Module): def __init__(self): super().__init__() vgg = models.vgg16(pretrained=True).features[:16] # 到 relu3_3 self.vgg = vgg.eval() for param in self.vgg.parameters(): param.requires_grad = False def forward(self, pred, target): # 输入需归一化到 VGG 期望的分布 pred_feat = self.vgg(pred) target_feat = self.vgg(target) return nn.functional.l1_loss(pred_feat, target_feat)冻结预训练权重是必须的,否则感知损失会退化成另一个像素损失。选relu3_3的考量在于它包含足够的纹理信息又不过度依赖语义分类结果;选太深的relu5_3容易丢失高频纹理。实际训练时感知损失的权重设在0.01到0.1之间,太高会让网络过拟合 VGG 的特征分布,反而损害真实图像上的纹理自然度。
4.3 对抗损失与纹理细节的逼真感
Gan 类的对抗损失对纹理恢复的贡献比较明显,它迫使生成图像的纹理分布接近真实图像的纹理分布,而不仅仅是靠近某种均值。PatchGAN 判别器是稳的选择,输出一个 N×N 的矩阵,每个位置的值是该局部区域真假的置信度——强制生成器在每个局部块上都保真,适合纹理细节的逼真感。
class PatchDiscriminator(nn.Module): def __init__(self, in_ch=3): super().__init__() self.conv = nn.Sequential( nn.Conv2d(in_ch, 64, 4, stride=2, padding=1), nn.LeakyReLU(0.2, inplace=True), nn.Conv2d(64, 128, 4, stride=2, padding=1), nn.BatchNorm2d(128), nn.LeakyReLU(0.2, inplace=True), nn.Conv2d(128, 256, 4, stride=2, padding=1), nn.BatchNorm2d(256), nn.LeakyReLU(0.2, inplace=True), nn.Conv2d(256, 1, 4, padding=1) # 输出为 N x N 的置信度图 ) def forward(self, x): return self.conv(x)训练时生成器和判别器的更新比例很关键。我通常每个 step 更新一次生成器、更新两次判别器,判别器训练得更充分后能为生成器提供更强的梯度信号。WGAN-GP 的梯度惩罚项也能稳定训练,但实现成本略高。课程作业时间有限的话,常规 GAN 损失加较小的权重(0.01左右)配合感知损失是最稳妥的搭配。
5. 训练调参与评估的完整闭环
5.1 数据预处理与增强的实操方案
训练去雾模型时图像的尺寸和处理顺序直接影响收敛速度。一般做法是随机裁剪成 256×256 的块,配合水平翻转和旋转 90 度做数据增强,同时保持清晰图和带雾图的变换严格一致。这里有一个容易忽略的点:预处理中的归一化参数不能随意改,用 ImageNet 的均值和标准差是多数预训练骨架的默认输入分布,自己统计数据集算出的均值反而会让预训练权重失去意义。
from torchvision import transforms # 对输入和标签做相同的增强,随机翻转和旋转必须在拼接维度上保持同步 transform_hazy = transforms.Compose([ transforms.RandomCrop(256), transforms.RandomHorizontalFlip(p=0.5), transforms.ColorJitter(brightness=0.2, contrast=0.2), ]) # 清晰图只做几何变换,不做颜色抖动 transform_clean = transforms.Compose([ transforms.RandomCrop(256), transforms.RandomHorizontalFlip(p=0.5), ])合成数据时雾浓度参数beta的取值区间直接影响模型的泛化能力。beta太小(小于 0.5)雾太薄模型学不到去雾能力,太大图像几乎被雾完全掩盖,模型会学习到过度增强的偏置,容易在薄雾图上产生过曝伪影。区间放在[0.6, 1.8]比较均衡,每隔若干 epoch 重采样一次雾浓度,让模型在训练过程中不断见到新的退化组合。
5.2 学习率计划与训练停止时机的判断
去雾网络常见的训练曲线特征很鲜明:前 20 个 epoch,PSNR 涨得很快;之后进入平台期,像素损失下降变慢但感知质量还在缓慢改善。如果在验证集上 PSNR 已经不再上升,不代表该停止训练——这时候往往需要切换损失权重或降低学习率继续精调,纹理细节会在这时逐渐浮出来。
| 阶段 | 学习率 | epoch 范围 | 损失权重(感知:对抗:L1) |
|---|---|---|---|
| 前期粗训练 | 2e-4 | 0–30 | 0.01 : 0 : 1 |
| 中期精调 | 5e-5 | 30–60 | 0.05 : 0.005 : 1 |
| 后期感知优化 | 1e-5 | 60–100 | 0.1 : 0.01 : 1 |
学习率我习惯用余弦退火而不是按 epoch 阶梯下降。阶梯下降的问题是每个台阶开始时损失会剧烈震荡,去雾网络的纹理恢复对这种震荡非常敏感,震荡后往往回不到之前的视觉质量水平。余弦退火会平滑地过渡,纹理细节微调时不容易被破坏。
5.3 客观指标 PSNR/SSIM 与主观质量的矛盾
评估时最常遇到的问题就是 PSNR 和 SSIM 与主观感受不一致。举例来说,一个把纹理完全磨平但大气光估计准确的模型,PSNR 可能比一个纹理生动但像素位置略有偏移的模型高出 1-2 个 dB。原因是 PSNR(本质上就是 MSE 的对数变换)对像素级精确匹配敏感,而人类视觉对结构相似度更敏感。
SSIM 虽然考虑了亮度、对比度和结构三个维度,但在纹理复杂区域仍不够敏感——它能反映某个局部窗口的统计相似性,却无法判断纹理是否被“磨平”还是“伪影叠加”。所以课程作业如果只报 PSNR/SSIM,并不能充分证明纹理细节恢复得好。
我的做法还会额外算一个指标:边缘保持指数(EPI,Edge Preservation Index)。计算方式是用 Sobel 算子提取输出图和真实图的边缘强度图,然后做两者的相关性分析。注意一个大坑:用不同厂家的学术测试集和真实场景测试集评估,模型表现可能完全相反。学术测试集(如 RESIDE)是合成雾,模型学到的纹理模式高度匹配,但换到真实雾天照片,性能会明显下降。报告里我会把合成集和真实图分开列指标,防止对模型能力给出过于乐观的评价。
6. 轻量化部署与验证技巧:把去雾模型推到真实场景
6.1 轻量化改造的优先级排序
课程作业的单模型方案如果只在测试集上跑个指标,就浪费了这套流程的复用价值。真实场景中图像去雾可能部署到移动端或边缘设备,轻量化改造会让模型实用性显著提升。优先做三件事:把普通卷积替换为深度可分离卷积(Depthwise Separable Convolution);剪掉解码器中贡献度低的通道;如果精度有余量,用 INT8 量化替换 FP32。
# 以 PyTorch 为例,导出 TorchScript 并量化(操作示意) model.eval() example_input = torch.randn(1, 3, 256, 256) traced_model = torch.jit.trace(model, example_input) traced_model.save("dehaze_model.pt") # 量化的前提是模型已经收敛,量化后需重新跑一遍指标 quantized_model = torch.quantization.quantize_dynamic( traced_model, {nn.Conv2d, nn.Linear}, dtype=torch.qint8 ) torch.jit.save(quantized_model, "dehaze_model_int8.pt")深度可分离卷积能把参数量降到原来的 1/8 到 1/9,计算量也有同量级的下降。代价是通道间信息交互变弱,纹理恢复精度会掉一些,通过加宽通道数可以部分补偿。量化在去雾模型上效果不错,因为卷积权重分布相对集中,INT8 量化后 PSNR 损失通常控制在 0.3 dB 以内,对主观质量几乎无感。
6.2 网格状伪影与色偏的排查方法
训练结束如果发现输出图有规则的网格伪影,第一步检查上采样方式。转置卷积是最常见的元凶,尤其当卷积核大小不能被步长整除时。把ConvTranspose2d替换成Upsample(mode='bilinear') + Conv2d,伪影会立刻消失。还有一个隐蔽来源是数据预处理时对图像做了不等比缩放,比如宽高比不是整数倍时发生的插值,会在输出图上留下沿某个方向的规则条纹。
色偏问题通常指向大气光估计环节。端到端模型内部不会显式输出大气光,但如果训练数据中含大量天空区域,模型容易学会把亮区过度压暗。缓解手段是在损失函数里加一个颜色一致性惩罚项,约束输出图像的 RGB 三个通道的均值比例与清晰图接近:
# 颜色一致性损失:约束输出与标签在通道均值比例上接近 def color_consistency_loss(pred, target): pred_mean = pred.mean(dim=[2, 3]) # [B, C] target_mean = target.mean(dim=[2, 3]) # 计算通道均值之间的余弦相似度,越接近 1 越好 cos_sim = nn.functional.cosine_similarity(pred_mean, target_mean, dim=1) return (1.0 - cos_sim).mean()6.3 一个可落地的验证流程
模型训练完成后,建议按固定的三步流程做最终验证:先跑合成测试集的指标,确认没有回归;再跑真实雾天图片做目测对比,重点看边缘轮廓和精细纹理区域;最后做一次用户调研式的主观对比,找同学盲评输出去雾图和原图,把偏好结果和 SSIM 做对照。主观评价收集 10 人以上的意见就具备参考意义,比只看 PSNR 数字有说服力得多。这三个验证步骤都能通过脚本半自动完成,核心是确认一个事实:网络确实恢复出了雾后面的纹理结构,而不只是把图像的对比度拉高而已。
本文还有配套的精品资源,点击获取