Vision Transformer图像去雾:从物理建模到工程落地 简介本资源是一套基于Vision TransformerViT的图像去雾算法完整实现方案面向计算机视觉方向的研究者、深度学习初学者及图像处理工程实践者解决雾霾天气下图像对比度低、细节模糊等退化问题。压缩包共340个文件包含204个Python源码含模型定义、训练/测试脚本、数据预处理模块、39张效果对比图与可视化结果png/gif、16个配置文件yaml、12个评估指标CSV、9个Jupyter Notebook实验记录及8份Markdown项目说明文档整体大小为156.38MB。已有1445人学习下载资源结构清晰涵盖从环境配置、数据加载、ViT主干网络改造、损失函数设计到模型微调全流程特别提供option.py参数详解与预训练权重加载路径说明并附带多组CIFAR-10/100上的Loss Landscape分析数据便于理解模型优化过程与泛化能力。1. Vision Transformer 做图像去雾真不是“把 ViT 拿来套个 UNet 就完事”它解决的是雾天场景下全局语义与局部细节的双重崩塌问题你有没有试过在高速公路上用手机拍远处的广告牌或者凌晨五点拍城市天际线——画面不是简单“发灰”而是出现一种诡异的“失焦式清晰”近处栏杆边缘尚可辨认远处楼宇却像被一层半透明毛玻璃罩住连轮廓都开始融化。传统去雾算法如暗通道先验在这种强散射、非均匀雾场下会集体失效要么把天空抠成惨白一片要么在建筑边缘留下紫边伪影更糟的是它根本无法理解“这是玻璃幕墙反射了雾光”还是“这是真实雾气遮挡”。而 Vision TransformerViT的全局注意力机制恰恰能建模这种跨尺度、跨区域的光散射关联——它不靠手工设计透射率先验而是让模型自己学会“雾在哪里浓、哪里薄、哪里是反射干扰”。本项目正是基于这一逻辑用纯 Python 实现了一个轻量级 ViT-based 去雾网络Dehaze-ViT不依赖预训练 ImageNet 权重从零训练即可在单张 512×512 图像上达到 PSNR 28.3、SSIM 0.91 的实测指标。适合刚接触低层视觉任务的 Python 工程师快速复现也适合想验证 ViT 在非分类任务中泛化能力的研究者——它不是 ViT 的玩具 demo而是把注意力机制真正焊进物理成像模型的一次落地尝试。2. 为什么必须用 Vision Transformer 而不是 CNN从雾的物理模型倒推网络结构选型2.1 雾的成像本质不是“加噪声”而是“光路被篡改”的退化过程图像去雾不是图像增强而是逆向求解大气散射方程$$ I(x) J(x)t(x) A(1-t(x)) $$其中 $I$ 是观测雾图$J$ 是待恢复的无雾图$t$ 是透射率图反映光线到达相机的比例$A$ 是全局大气光。关键在于$t(x)$ 具有强空间相关性但非局部平滑——山腰雾浓、山顶透亮同一栋楼玻璃窗和混凝土墙的透射率差异可能高达 0.4。CNN 的卷积核受限于感受野3×3 或 5×5 卷积根本无法建模这种跨楼层、跨天空-地面的长程依赖而 ViT 的自注意力机制天然支持任意两点间的权重计算哪怕像素相距 500 像素也能通过 attention score 直接建立关联。我们实测过在 RESIDE-SOTS 测试集上同等参数量下ViT backbone 比 ResNet-50 backbone 在透射率图预测的 MAE 降低 37%尤其在雾浓度突变区域如山体交界线误差下降达 62%。2.2 Dehaze-ViT 架构设计三层解耦每层解决一个物理约束本项目采用“Encoder-Decoder with Skip Attention”结构而非简单套用标准 ViT模块输入尺寸核心设计物理意义雾感知编码器Fog-Aware Encoder512×512×3Patch size16嵌入维度384仅 8 层 Transformer block强制前 4 层聚焦低频雾浓度分布后 4 层学习高频透射率边界跨尺度注意力桥Cross-Scale Attention Bridge32×32×384 → 64×64×192在 encoder 输出特征图上对不同 patch size8/16/32做 multi-scale attention fusion解决雾浓度随距离衰减的尺度特性避免单一 patch size 丢失远距离信息物理约束解码器Physics-Guided Decoder多尺度特征拼接解码器每层输出强制约束$t_{pred} \in [0.01, 0.99]$$A_{pred}$ 用全局平均池化 sigmoid 限幅防止网络输出违反大气散射方程的物理边界提示不要直接用torchvision.models.vit_b_16它的 class token 和 position embedding 是为分类任务设计的没有 spatial resolution 保持能力。本项目 encoder 使用vit_tiny_patch16_224的 backbone 变体但移除了 class token改用nn.UnfoldLinear实现可变分辨率 patch embedding。2.3 数据准备RESIDE 数据集的三个致命陷阱及绕过方案RESIDE 是当前最主流的去雾数据集但直接下载官方 zip 会踩坑# ❌ 错误做法直接解压所有子文件夹 unzip RESIDE.zip -d ./data/ # → 导致 train/val/test 混乱且 SOTS 室内/室外子集路径不统一✅ 正确做法已验证# 1. 创建标准化目录结构 mkdir -p data/reside/sots_indoor/ \ data/reside/sots_outdoor/ \ data/reside/its_train/ \ data/reside/ots_train/ # 2. 下载并解压时指定路径以 SOTS 室外为例 wget https://github.com/zhilin007/RESIDE/releases/download/v1.0/SOTS-outdoor.zip unzip SOTS-outdoor.zip -d data/reside/sots_outdoor/ # 注意官方 SOTS-outdoor 包含 haze/ and haze-free/ 两个文件夹需重命名 mv data/reside/sots_outdoor/haze/ data/reside/sots_outdoor/input/ mv data/reside/sots_outdoor/haze-free/ data/reside/sots_outdoor/target/ # 3. ITS 训练集需手动划分官方未提供 train/val 划分 # 我们采用固定随机种子划分85% train, 15% val python -c import os, random, shutil from pathlib import Path root Path(data/reside/its_train) imgs list((root / hazy).glob(*.png)) random.seed(42) random.shuffle(imgs) n_train int(len(imgs) * 0.85) for i, img in enumerate(imgs): dst_dir root / (train if i n_train else val) / input dst_dir.mkdir(parentsTrue, exist_okTrue) shutil.copy(img, dst_dir / img.name) # 同步复制对应 clear 图 clear_path root / clear / img.name.replace(hazy_, ) (dst_dir.parent / target).mkdir(exist_okTrue) shutil.copy(clear_path, dst_dir.parent / target / clear_path.name) 关键点说明SOTS 室外测试集必须用input/和target/统一命名否则 DataLoader 会报错找不到 targetITS 训练集的 haze 图命名含hazy_前缀clear 图无前缀且 clear 图数量少 1 张因部分 haze 图无对应 clear脚本已自动跳过缺失项所有图像统一 resize 到 512×512非 224×224ViT 做 dense prediction 必须保持 spatial resolution。3. 用 12 行核心代码跑通 Dehaze-ViT最小可运行训练脚本拆解3.1 模型定义只保留关键模块去掉所有冗余装饰# model/dehaze_vit.py import torch import torch.nn as nn from timm.models.vision_transformer import Block class FogAwareEncoder(nn.Module): def __init__(self, img_size512, patch_size16, in_chans3, embed_dim384, depth8): super().__init__() self.patch_embed nn.Conv2d(in_chans, embed_dim, kernel_sizepatch_size, stridepatch_size) # 移除 class token改用 2D position embedding self.pos_embed nn.Parameter(torch.zeros(1, embed_dim, img_size//patch_size, img_size//patch_size)) self.blocks nn.Sequential(*[Block(embed_dim, num_heads6, mlp_ratio4.) for _ in range(depth)]) self.norm nn.LayerNorm(embed_dim) def forward(self, x): x self.patch_embed(x) # [B, C, H, W] - [B, C, H, W] x x self.pos_embed # 加位置编码 x x.flatten(2).transpose(1, 2) # [B, C, H, W] - [B, H*W, C] for blk in self.blocks: x blk(x) x self.norm(x) return x.transpose(1, 2).view(x.shape[0], -1, x.shape[1]//16, x.shape[1]//16) # 恢复 2D 形状 class PhysicsGuidedDecoder(nn.Module): def __init__(self, in_chans384): super().__init__() self.conv1 nn.Conv2d(in_chans, 192, 3, padding1) self.conv2 nn.Conv2d(192, 96, 3, padding1) self.t_head nn.Conv2d(96, 1, 1) # 透射率图 self.a_head nn.Sequential( nn.AdaptiveAvgPool2d(1), nn.Flatten(), nn.Linear(96, 3), # 大气光 A 是 3 通道值 nn.Sigmoid() ) def forward(self, x): x torch.relu(self.conv1(x)) x torch.relu(self.conv2(x)) t_pred torch.sigmoid(self.t_head(x)) * 0.98 0.01 # clamp to [0.01, 0.99] a_pred self.a_head(x) # [B, 3] return t_pred, a_pred # 组装完整模型 class DehazeViT(nn.Module): def __init__(self): super().__init__() self.encoder FogAwareEncoder() self.decoder PhysicsGuidedDecoder() def forward(self, x): feat self.encoder(x) t, a self.decoder(feat) # 根据大气散射方程重建无雾图 j (x - a.view(-1, 3, 1, 1)) / torch.clamp(t, min1e-3) a.view(-1, 3, 1, 1) return j, t, a逻辑说明FogAwareEncoder中patch_embed用Conv2d替代Linear保证 spatial resolution 可控pos_embed是 2D 参数非 1D 序列避免插值导致的位置偏移PhysicsGuidedDecoder的t_head输出后做sigmoid * 0.98 0.01硬性约束透射率物理范围最终重建j时a.view(-1,3,1,1)确保大气光广播到每个像素torch.clamp(t, min1e-3)防止除零。3.2 训练循环带物理损失的三阶段优化策略# train.py import torch import torch.optim as optim from torch.utils.data import DataLoader from model.dehaze_vit import DehazeViT from dataset.reside_dataset import RESIDEDataset from utils.losses import PerceptualLoss, AtmosphericLoss model DehazeViT().cuda() optimizer optim.AdamW(model.parameters(), lr2e-4, weight_decay1e-5) scheduler optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max100) # 三阶段损失初期重感知中期重物理后期微调边界 percep_loss PerceptualLoss().cuda() # VGG16 feature loss atmo_loss AtmosphericLoss().cuda() # t 和 A 的 L1 方程残差 loss train_dataset RESIDEDataset(rootdata/reside/its_train, modetrain) train_loader DataLoader(train_dataset, batch_size4, shuffleTrue, num_workers4) for epoch in range(100): model.train() for batch_idx, (haze, clear) in enumerate(train_loader): haze, clear haze.cuda(), clear.cuda() j_pred, t_pred, a_pred model(haze) # 阶段性损失权重epoch 0-30: 感知主导31-70: 物理主导71-100: 边界微调 w_percep 0.8 if epoch 30 else (0.3 if epoch 70 else 0.1) w_atmo 0.2 if epoch 30 else (0.7 if epoch 70 else 0.9) loss w_percep * percep_loss(j_pred, clear) \ w_atmo * atmo_loss(j_pred, haze, t_pred, a_pred, clear) optimizer.zero_grad() loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) optimizer.step() scheduler.step() if epoch % 10 0: torch.save(model.state_dict(), fcheckpoints/dehaze_vit_epoch_{epoch}.pth)参数说明batch_size4是 24G 显存下的安全值若显存 ≥ 40G 可升至 8weight_decay1e-5防止 ViT 过拟合实测比 1e-4 更稳定clip_grad_norm_1.0是 ViT 训练的后悔药——注意力权重梯度爆炸是常态不 clip 会在 epoch 5 左右突然 nan三阶段损失权重不是玄学前期靠感知损失拉起整体结构中期用物理损失校准 t/A 的数值精度后期微调边界防止伪影。4. Dehaze-ViT 训练与推理的 5 个血泪避坑指南4.1 现象训练 loss 前 5 个 epoch 突然飙升 10 倍随后震荡不收敛原因ViT 的 LayerNorm 初始化对输入 scale 敏感。原始 haze 图像素值为[0,255]直接输入会导致 patch embedding 后特征方差过大attention softmax 输出饱和。解决在 DataLoader 中强制归一化# dataset/reside_dataset.py def __getitem__(self, idx): haze self._load_img(self.haze_paths[idx]) # [0,255] uint8 clear self._load_img(self.clear_paths[idx]) # 关键转 float32 后除以 255.0而非 /127.5-1 haze haze.float() / 255.0 # → [0.0, 1.0] clear clear.float() / 255.0 return haze, clear注意ViT 训练必须用[0,1]归一化[-1,1]会导致 position embedding 失效。4.2 现象推理时 GPU 显存暴涨至 30GB单图耗时超 2 分钟原因默认torch.compile()或torch.jit.trace()会错误捕获整个大气散射方程重建过程生成超大计算图。解决禁用自动编译手动分步执行# inference.py with torch.no_grad(): j_pred, t_pred, a_pred model(haze_tensor) # 此步显存可控 # 将重建分离为 CPU 操作避免 GPU 上复杂广播 j_pred j_pred.cpu().numpy() haze_np haze_tensor.cpu().numpy() a_np a_pred.cpu().numpy() t_np t_pred.cpu().numpy() # 在 numpy 中重建速度更快显存恒定 j_final (haze_np - a_np[:, :, None, None]) / np.clip(t_np, 1e-3, None) a_np[:, :, None, None]4.3 现象SOTS 测试集 PSNR 稳定在 25.0但肉眼观感明显发灰、对比度不足原因ViT 对低频信息建模弱encoder 输出的 coarse feature 缺乏全局对比度先验。解决在 decoder 输入端注入 global contrast map# PhysicsGuidedDecoder.forward() 中插入 def forward(self, x): # x: [B, C, H, W] global_contrast torch.std(x, dim[2,3], keepdimTrue) # [B, C, 1, 1] x torch.cat([x, global_contrast.expand_as(x)], dim1) # 拼接对比度特征 ...4.4 现象训练 50 epoch 后t_pred 图出现规则网格状伪影与 patch size 一致原因position embedding 是固定分辨率512×512训练的但实际 batch 内图像经 resize 后尺寸不一如 480×640导致位置编码错位。解决改用 relative position bias已在 timm 0.9.0 支持# 在 FogAwareEncoder.__init__() 中替换 # self.pos_embed ... → 删除 # 改用 timm 内置的 relative bias from timm.models.vision_transformer import VisionTransformer self.encoder VisionTransformer( img_size512, patch_size16, embed_dim384, depth8, num_heads6, mlp_ratio4, qkv_biasTrue, init_values1e-5, # 启用 layer scale use_rel_pos_biasTrue, # 关键启用相对位置编码 )4.5 现象导出 ONNX 后推理结果全黑或出现大量 NaN原因ONNX 不支持torch.clamp(t, min1e-3)的动态 min/max且a.view(-1,3,1,1)广播在 ONNX 中行为不一致。解决重写重建逻辑为 ONNX 友好形式# model/dehaze_vit.py 中新增 export_forward def export_forward(self, x): j_pred, t_pred, a_pred self.forward(x) # 替换 clamp 为 ONNX 兼容的 torch.where t_safe torch.where(t_pred 1e-3, t_pred, torch.tensor(1e-3).to(t_pred.device)) # 替换 view 广播为 expand a_expanded a_pred.unsqueeze(-1).unsqueeze(-1) # [B,3] → [B,3,1,1] j_final (x - a_expanded) / t_safe a_expanded return j_final然后导出时指定torch.onnx.export(model, dummy_input, dehaze_vit.onnx, opset_version14, input_names[input], output_names[output], dynamic_axes{input: {0: batch}, output: {0: batch}})5. 如何验证你的 Dehaze-ViT 真正“懂雾”三个不可绕过的物理一致性检验法5.1 透射率图t-map的物理合理性诊断不只是看 PSNR透射率图 $t(x)$ 是去雾模型的“黑匣子”但它必须满足三个物理约束单调衰减性沿视线方向从相机到物体$t(x)$ 应随距离增大而减小雾浓度正相关雾越浓区域$t(x)$ 值越低天空区域趋近 1晴朗天空处 $t(x) \approx 1$。我们开发了一个轻量级诊断脚本tmap_diagnostic.py输入 t_pred 和原图输出三项指标# utils/tmap_diagnostic.py def diagnose_tmap(t_pred, haze_img, threshold_sky0.95): t_pred: [1, 1, H, W] tensor, haze_img: [1, 3, H, W] tensor Returns: dict with three scores (0-1, higher is better) t t_pred[0,0].cpu().numpy() h haze_img[0].permute(1,2,0).cpu().numpy() # H,W,3 # 1. 单调衰减得分计算水平方向 t 值的负梯度均值 grad_x np.abs(np.gradient(t, axis1)) monotonic_score 1.0 - np.mean(grad_x) / (np.max(grad_x) 1e-6) # 2. 雾浓度相关得分取最暗 10% 像素区域检查其 t 值是否低于全局均值 dark_mask h.mean(axis2) np.percentile(h.mean(axis2), 10) dark_t_mean t[dark_mask].mean() global_t_mean t.mean() correlation_score 1.0 if dark_t_mean global_t_mean else 0.3 # 3. 天空区域得分检测天空高亮度低饱和度检查 t 是否 threshold_sky hsv cv2.cvtColor((h*255).astype(np.uint8), cv2.COLOR_RGB2HSV) sky_mask (hsv[:,:,2] 200) (hsv[:,:,1] 50) # 亮且不饱和 sky_t_mean t[sky_mask].mean() if sky_mask.sum() 0 else 0 sky_score min(sky_t_mean / threshold_sky, 1.0) return { monotonic: monotonic_score, correlation: correlation_score, sky: sky_score, overall: (monotonic_score correlation_score sky_score) / 3 } # 使用示例 t_pred, _, _ model(haze_tensor) diag diagnose_tmap(t_pred, haze_tensor) print(ft-map 物理一致性总分: {diag[overall]:.3f}) # ✅ 健康模型应 ≥ 0.750.6 说明模型未学到雾的物理本质血泪经验曾有一个 PSNR 29.1 的模型t-map 诊断分仅 0.42——人工检查发现它把所有暗部都判为高雾区完全违背“雾浓处亮度高”的基本事实。这个诊断法比 PSNR 更早暴露模型缺陷。5.2 大气光 A 的跨图像稳定性检验拒绝“每张图都瞎猜”大气光 $A$ 应具有场景一致性同一拍摄条件下如阴天室外多张图的 $A$ 值应在 RGB 空间聚类。若模型对每张图输出完全随机的 A则说明它没学会全局光照建模。我们构建了一个 mini-batch 稳定性测试# test_atmospheric_stability.py def test_a_stability(model, image_list, devicecuda): image_list: list of [C,H,W] tensors (uint8, [0,255]) model.eval() a_preds [] with torch.no_grad(): for img in image_list: x img.float().unsqueeze(0).to(device) / 255.0 _, _, a model(x) a_preds.append(a.cpu().numpy()) # [1,3] a_stack np.vstack(a_preds) # [N,3] # 计算 RGB 各通道 std越小越稳定 stability 1.0 - np.std(a_stack, axis0).mean() # [0,1] # 可视化聚类可选 plt.scatter(a_stack[:,0], a_stack[:,1], ca_stack[:,2], cmapviridis) plt.xlabel(R); plt.ylabel(G); plt.title(fA-stability: {stability:.3f}) plt.colorbar() plt.show() return stability # 示例取 SOTS-outdoor 前 20 张图测试 stability_score test_a_stability(model, sots_images[:20]) print(fA 光稳定性得分: {stability_score:.3f}) # ✅ 健康值 0.855.3 雾浓度敏感度分析给模型“出难题”看它是否真懂雾构造一个 controlled fog test对同一 clear 图用不同 $\beta$雾浓度系数合成 haze 图观察模型输出的 $t$ 均值变化是否与 $\beta$ 负相关。# utils/fog_sensitivity.py def fog_sensitivity_test(model, clear_img, betas[0.1, 0.5, 1.0, 2.0], A[0.85,0.85,0.85]): clear_img: [3,H,W] tensor, [0,255] model.eval() t_means [] with torch.no_grad(): for beta in betas: # 合成 haze 图I J*exp(-beta*d) A*(1-exp(-beta*d)) # 这里用简化版t exp(-beta*0.1) 作为参考透射率 t_ref np.exp(-beta * 0.1) haze clear_img.float() * t_ref torch.tensor(A).view(3,1,1) * (1 - t_ref) haze torch.clamp(haze, 0, 255).byte() x haze.float().unsqueeze(0).cuda() / 255.0 _, t_pred, _ model(x) t_means.append(t_pred.mean().item()) # 计算 sensitivityt_mean 随 beta 增加的下降斜率 betas np.array(betas) t_means np.array(t_means) slope, _ np.polyfit(betas, t_means, 1) # 线性拟合 return slope # ✅ 健康模型 slope 应 -0.1负得越狠越敏感 # 运行测试 slope fog_sensitivity_test(model, clear_sample) print(f雾浓度敏感度斜率: {slope:.3f}) # 若 -0.05说明模型对雾浓度不敏感需调整 loss 权重我坚持在每次模型迭代后跑这三项检验——它们不提供 flashy 的 PSNR 数字但能告诉我“这个模型是真学会了雾的物理还是只是 memorized 训练集”。过去三年我所有上线的去雾模型都经过这三关没有一个翻车。希望帮到你。本文还有配套的精品资源点击获取