简介:基于Vision Transformer的图像去雾研究资源包,面向深度学习与计算机视觉方向的科研人员、算法工程师及图像处理学习者。资源围绕真实雾霾场景下的去雾模型训练与测试展开,覆盖NH-HAZE、NTIRE2019、I-HAZE、O-HAZE四种公开数据集,可用于复现实验、对比算法或进一步扩展。压缩包内共342个文件,以204个Python脚本为主,辅以yaml配置、csv结果记录、ipynb实验笔记、png/gif可视化输出及md说明文档,整体约156.42MB,结构清晰便于按流程查阅。已有125人学习下载,对于希望快速上手Vision Transformer去雾研究的读者,可直接获取训练测试代码、参数配置、数据预处理与评估脚本,以及文献说明和结果展示,节省整理时间并降低复现门槛。
1. 基于Vision_Transformer的深度学习图像去雾:真实雾霾数据集为什么让老模型集体翻车
你花两个月在合成雾数据上把去雾模型训到 PSNR 接近 28dB,兴冲冲拿到 NH-HAZE 上一测,直接掉到 19dB,图像还带着一层擦不掉的白灰色。这不是你代码写错了,而是真实雾霾数据集和合成雾本质不是一个分布。这个标题做的事很简单:用 Vision_Transformer(ViT)架构替换传统 CNN 去雾主干,在 NH-HAZE、NTIRE2019、I-HAZE、O-HAZE 四类真实雾霾数据集上完成训练与测试,让模型真正扛得住非均匀雾。
适合的人群很明确:做图像去雾方向研究或毕设的学生、要复现 SOTA 指标的竞赛党,以及手头有真实有雾图像想落地恢复的工程师。后面所有内容围绕一个目标展开——让一套 ViT 去雾模型在这四个数据集上跑通、跑稳、跑出可信指标,并且你知道每一类数据集为什么难、难在哪。
2. 先搞懂 ViT 去雾的原理与选型:注意力全局建模凭什么适合真实雾
2.1 大气散射模型与真实雾之间的鸿沟:非均匀透射率是最大的坑
图像去雾的理论起点是大气散射模型,几乎所有去雾算法都假设有雾图像 I 可以写成:
I(x) = J(x) * t(x) + A * (1 - t(x))
其中 J(x) 是干净的无雾场景,t(x) 是透射率,A 是全局大气光。合成雾数据集通常是给一张干净图,人为设定一个均匀的 t(x) 或简单渐变,再叠加一个固定 A 去生成有雾图。这种数据的问题在于:雾是“贴”上去的,场景深度 d(x) 和大气光 A 几乎不变,模型只要学会一个整体提亮 + 去灰的操作就能刷高 PSNR。
真实雾霾完全不是这样。以 NH-HAZE 为例,它不是均匀薄雾,而是户外的非均匀雾(non-homogeneous haze),不同空间位置的雾浓度差异极大,有的区域几乎无雾、有的区域白茫茫一片。对应到模型公式里,透射率 t(x) 和大气光 A 在整幅图像上是空间变化的,甚至大气光本身不能用一个全局常数近似。CNN 去雾模型的感受野有限,虽然可以通过堆叠卷积扩大,但底层的局部卷积核天然受限于固定窗口;而 ViT 的 self-attention 一开始就把所有 patch 拉进全局关系建模,每个 patch 都能直接“看”到全图其他位置的雾浓度和场景深度,这让透射率估计在逻辑上更合理。
我用一个直白的比喻来理解这件事:CNN 像是一个人拿着放大镜在一片片看雾,最后拼出全貌;ViT 则是看一眼全景照片,马上知道哪块雾浓哪块雾淡。对于 I-HAZE 这类室内均匀雾,放大镜够用了;但 NH-HAZE 这种雾浓度变化剧烈的场景,全景视野的优势是结构性的。
2.2 ViT 去雾的常见架构:把分类头换成像素回归头,不是直接拿来就用
Vision Transformer 最初是为图像分类设计的:图像被切成 patch,每个 patch 展平成 token 后加位置编码,经过若干层 Transformer Encoder 后取 CLS token 做分类。去雾是像素级回归任务,直接拿去用肯定不行,常见的改造路线有三处:
第一,去掉或者忽略 CLS token,保留全部 patch token 的输出特征,因为去雾需要为每一个像素位置生成恢复结果,而不是整图一个类别。第二,patch size 要从 16 降到 8,甚至 4。原始 ViT 用 16x16 patch 是为了减少 token 数量、降低分类计算量;但去雾任务是逐像素输出,patch 太大导致最终恢复结果出现明显的块状伪影,放大看像马赛克。第三,Decoder 部分要找回空间分辨率,常见做法是:把 token 序列 reshape 回 2D 特征图,用 PixelShuffle 亚像素卷积一次上采样,或者用 ConvTranspose2d 逐步上采样,最后输出 3 通道的残差图。
关于残差学习,我建议输出端学的是“有雾图和无雾图的差”,而不是直接回归无雾图。原因很简单:真实雾霾图像的大部分区域本来就清晰,直接回归整幅干净图像会让网络把大量参数浪费在“复制”上;学残差让网络只关注要改的部分,训练更稳定,收敛也更快。推理时把残差加回输入有雾图即可。还有一个容易踩的细节:位置编码。如果训练时固定输入分辨率,测试时遇到分辨率不一致的图像,位置编码会被插值或裁剪,模型效果可能明显下降,这一点在第四章细说。
2.3 四个真实雾霾数据集:NH-HAZE、NTIRE2019、I-HAZE、O-HAZE 各自是什么、怎么用
很多下载到的压缩包把数据按目录分成四类,NTIRE2019 其实是 NTIRE 去雾挑战赛的名称,比赛当年提供的数据主要来自 O-HAZE 的竞赛版本,所以解压后你可能会看到 NTIRE2019 目录里的图像和 O-HAZE 有重叠。四个数据集可以按“难度”排一个序,这样训练策略能有的放矢:
| 数据集 | 场景类型 | 雾的特性 | 大致规模 | 相对难度 |
|---|---|---|---|---|
| I-HAZE | 室内 | 均匀雾,光照可控 | 训练对较少,几十对级别 | 较低 |
| O-HAZE | 室外 | 雾浓度较均匀,但景深复杂 | 训练对几十到一百对左右 | 中等 |
| NH-HAZE | 室内外混合 | 非均匀雾,局部浓度差异大 | 训练对较少,几十对左右 | 最高 |
| NTIRE2019 | 室外 | 基本等价于 O-HAZE 竞赛版 | 和 O-HAZE 有重叠 | 中等 |
从公开竞赛结果的大致水平看,I-HAZE 上 PSNR 可以做到 27dB 上下,O-HAZE 在 24dB 左右,而 NH-HAZE 能把 PSNR 做到 21dB 已经算不错的结果。差距不是模型实现问题,而是数据集本身难度决定的。我一般建议的训练路线是:先在 I-HAZE 和 O-HAZE 上把模型和代码跑通,确认 loss 能降、指标能涨,再切到 NH-HAZE 调参。如果你一上来就在最难的数据集上训练,很难判断是模型问题还是数据问题。
还有一个数据归档方面的注意事项:当你把 NTIRE2019 和 O-HAZE 同时放入训练列表时,要先去重,避免同一张图既在训练集又被当作验证集,否则指标虚高而不自知。这是压缩包按名称归档时最容易踩的坑。
3. 用 PyTorch 搭建 ViT 去雾模型:数据加载、网络结构、训练脚本三块可跑代码
3.1 四类雾霾数据集的 Dataset 加载与预处理代码
先把数据加载这个地基打牢。我常用的做法是用 txt 文件记录每一对训练数据的路径,每行两列:有雾图路径和对应的无雾真值路径,中间用空格隔开。四个数据集目录不同,但最终都汇总成这样的列表文件,训练时通过列表文件读取,逻辑清晰也不容易搞混目录。
# haze_dataset.py import os import cv2 import torch from torch.utils.data import Dataset import numpy as np class HazeDataset(Dataset): def __init__(self, list_path, root_dir=None, crop_size=256, flip=True, normalize=True): self.pairs = [] with open(list_path, "r") as f: for line in f: line = line.strip() if not line or line.startswith("#"): continue haze_path, gt_path = line.split() if root_dir is not None: haze_path = os.path.join(root_dir, haze_path) gt_path = os.path.join(root_dir, gt_path) self.pairs.append((haze_path, gt_path)) self.crop_size = crop_size self.flip = flip self.normalize = normalize def __len__(self): return len(self.pairs) def __getitem__(self, idx): haze_path, gt_path = self.pairs[idx] haze = cv2.imread(haze_path).astype(np.float32) / 255.0 gt = cv2.imread(gt_path).astype(np.float32) / 255.0 if self.crop_size is not None: h, w = haze.shape[:2] if h < self.crop_size or w < self.crop_size: haze = cv2.resize(haze, (self.crop_size, self.crop_size)) gt = cv2.resize(gt, (self.crop_size, self.crop_size)) h, w = self.crop_size, self.crop_size x = np.random.randint(0, w - self.crop_size + 1) y = np.random.randint(0, h - self.crop_size + 1) haze = haze[y:y + self.crop_size, x:x + self.crop_size] gt = gt[y:y + self.crop_size, x:x + self.crop_size] if self.flip and np.random.rand() > 0.5: haze = haze[:, ::-1].copy() gt = gt[:, ::-1].copy() # BGR -> RGB -> CHW haze = torch.from_numpy(haze[:, :, ::-1].transpose(2, 0, 1).copy()) gt = torch.from_numpy(gt[:, :, ::-1].transpose(2, 0, 1).copy()) if self.normalize: haze = haze * 2.0 - 1.0 gt = gt * 2.0 - 1.0 return haze, gt这段代码里 crop 是训练的关键操作。真实雾霾数据集的原图分辨率通常在 1600x1200 左右,直接整图送进 ViT 会导致显存爆炸,所以训练阶段用随机裁剪到 256x256 的子块。predict 的时候不裁剪,整图推理,代价是 GPU 显存占用高,但避免裁剪破坏雾的连续性。归一化到 [-1, 1] 是我的习惯,配合模型输出端用 Tanh 激活,输出范围天然落在 [-1, 1],不容易出现颜色溢出。要注意的是 BGR 到 RGB 的通道转换必须在 toTensor 之前完成,否则训练出的模型在测试时颜色通道对不上。
3.2 一个能出效果的 ViT 去雾网络结构:PatchEmbed + TransformerEncoder + PixelShuffle
现在写网络。我下面给出的是一个完整可跑的结构,拆成三块:PatchEmbed 负责把图像切成 patch 序列,TransformerEncoder 负责全局建模,DecoderHead 负责把特征图恢复到原分辨率并输出残差。这个结构刻意避免了自定义多头注意力,直接复用 PyTorch 官方的 nn.TransformerEncoderLayer,工程上稳定、不容易写错。
# vit_dehaze.py import torch import torch.nn as nn import math class PatchEmbed(nn.Module): def __init__(self, in_ch=3, embed_dim=256, patch_size=8): super().__init__() self.patch_size = patch_size self.proj = nn.Conv2d(in_ch, embed_dim, kernel_size=patch_size, stride=patch_size) self.norm = nn.LayerNorm(embed_dim) def forward(self, x): x = self.proj(x) # (B, embed_dim, H/p, W/p) B, C, H, W = x.shape x = x.flatten(2).transpose(1, 2) # (B, L, C) x = self.norm(x) return x, H, W class ViTDehaze(nn.Module): def __init__(self, embed_dim=256, depth=8, num_heads=8, patch_size=8, dim_feedforward=512): super().__init__() self.patch_size = patch_size self.patch_embed = PatchEmbed(in_ch=3, embed_dim=embed_dim, patch_size=patch_size) # 可学习位置编码 self.pos_embed = nn.Parameter(torch.zeros(1, 1024, embed_dim)) nn.init.trunc_normal_(self.pos_embed, std=0.02) encoder_layer = nn.TransformerEncoderLayer( d_model=embed_dim, nhead=num_heads, dim_feedforward=dim_feedforward, dropout=0.1, activation="gelu", batch_first=True) self.encoder = nn.TransformerEncoder( encoder_layer, num_layers=depth) # PixelShuffle 上采样回原分辨率 self.decoder = nn.Sequential( nn.Conv2d(embed_dim, embed_dim, 3, padding=1), nn.GELU(), nn.Conv2d(embed_dim, 3 * patch_size * patch_size, kernel_size=3, padding=1), nn.PixelShuffle(patch_size), nn.Tanh() ) def forward(self, x): # 记录残差连接用到的输入 identity = x token, H, W = self.patch_embed(x) L = token.shape[1] token = token + self.pos_embed[:, :L, :] token = self.encoder(token) B, _, C = token.shape feat = token.transpose(1, 2).reshape(B, C, H, W) residual = self.decoder(feat) return identity + residual代码逻辑不复杂:图像经过 PatchEmbed 得到 token 序列,加上位置编码后过 8 层 TransformerEncoder,再把 token 重排成特征图,最后用 PixelShuffle 一次性上采样回原尺寸。PixelShuffle 是超分领域常用的亚像素卷积:把通道维重组到空间维,Per-patch 的 3patch_sizepatch_size 通道正好拼成一个 patch 的 RGB 像素,所以一次就能恢复分辨率。
参数上需要注意三个地方。patch_size=8 是平衡计算量和输出质量的选择:patch=16 时 token 少、速度快,但去雾结果的细节恢复明显变粗糙;patch=4 时输出细节好,但 1600x1200 的图像会产生 300x400=120000 个 token,显存直接爆炸。embed_dim=256、depth=8 是复现很多真实雾去雾结果常用配置,如果显存紧张可以把 depth 降到 4。位置编码我把它初始化成 0,然后用 truncated_normal 加一点噪声,训练中它会自己学习;注意训练图像尺寸必须能被 patch_size 整除,否则需要做 padding,这个细节放到避坑章。
3.3 训练主循环和关键超参数:loss 怎么选、学习率怎么给
有了数据和模型,训练主循环只需要处理 batch、loss、反向传播三个环节。去雾任务的 loss 我一般用 L1 损失加一个可选的感知损失,下面是一个能直接跑的最小训练脚本:
# train.py import torch import torch.nn as nn from torch.utils.data import DataLoader from vit_dehaze import ViTDehaze from haze_dataset import HazeDataset device = "cuda" if torch.cuda.is_available() else "cpu" model = ViTDehaze(embed_dim=256, depth=8, patch_size=8).to(device) criterion = nn.L1Loss() optimizer = torch.optim.AdamW(model.parameters(), lr=1e-4, weight_decay=1e-4) train_ds = HazeDataset("train_list.txt", crop_size=256) train_dl = DataLoader(train_ds, batch_size=8, shuffle=True, num_workers=4, drop_last=True) # 线性学习率预热 + 余弦退火 warmup_epochs = 5 total_epochs = 100 def lr_lambda(epoch): if epoch < warmup_epochs: return (epoch + 1) / warmup_epochs return 0.5 * (1 + torch.cos( torch.pi * (epoch - warmup_epochs) / (total_epochs - warmup_epochs))) scheduler = torch.optim.lr_scheduler.LambdaLR(optimizer, lr_lambda) for epoch in range(total_epochs): model.train() total_loss = 0.0 for haze, gt in train_dl: haze, gt = haze.to(device), gt.to(device) pred = model(haze) loss = criterion(pred, gt) optimizer.zero_grad() loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=5.0) optimizer.step() total_loss += loss.item() scheduler.step() print(f"epoch [{epoch + 1}/{total_epochs}] loss: {total_loss / len(train_dl):.4f}")loss 选 L1 而不是 L2 的原因很实际:L2 损失在真实雾霾数据上会把模型推向“求平均”,恢复出的图像偏模糊、边缘发灰,L1 对异常值不敏感,边缘更锐利。如果你想更接近论文效果,可以加一个权重为 0.1 的 VGG 感知损失,但要注意感知损失两个关键坑:输入要归一化到 ImageNet 的 mean/std 范围,而不是 [-1, 1];VGG 的 BatchNorm 层在训练模式下用 batch 统计量,计算感知 loss 时建议把 model.eval() 打开。
学习率给的是 AdamW 配 1e-4,这个量级在 ViT 去雾上是合理起点。Transformer 结构对学习率比 CNN 敏感得多,lr 给到 3e-4 以上就可能出现 loss 波动甚至不收敛。warmup 前 5 个 epoch 是为了稳住位置编码和 LayerNorm 参数的更新,跳过 warmup 直接大学习率训练,ViT 很容易在第一个 epoch 就“跳飞”。grad clip 设 5.0 是最后的保险,真实雾数据分布复杂,偶尔会出现梯度异常,clip 一下训练就稳定很多。
4. 训练与测试全流程:四个数据集如何逐一遍历、评测与对比
4.1 训练/测试数据划分:避免同场景泄漏,用文件列表控制
四个数据集的原始目录结构不统一,有的官方已经给了 train/test 划分,有的只有一个 pair 列表。我一般不管原目录长什么样,直接自己生成 train_list.txt 和 test_list.txt 两个文件:
# 生成训练与测试列表的示例逻辑(bash + awk) # 假设每个数据集目录下 haze 和 gt 子目录,文件名一一对应 for dset in I-HAZE O-HAZE NH-HAZE NTIRE2019; do for f in $dset/haze/*.png; do base=$(basename $f) echo "$dset/haze/$base $dset/gt/$base" >> all_pairs.txt done done # 按数据集随机抽 20% 作为测试,其余训练 awk 'NR % 5 != 0 {print > "train_list.txt"} NR % 5 == 0 {print > "test_list.txt"}' all_pairs.txt注意顺序问题:四个数据集最好各自内部随机划分,再合并,不要把 O-HAZE 全部留在训练集、NH-HAZE 全部进测试集,否则你最后报告的指标同时包含数据集难度差异,没法判断模型好坏。一个更严格的做法是保证同一场景的不同视角不跨训练和测试,这需要看原始数据的场景编号,逐条确认。
划分完成后,可以用一个脚本快速检查交集,避免 NTIRE2019 和 O-HAZE 里相同文件名被分到两个集合:
# 检查测试集是否泄漏到训练集 comm -12 <(awk '{print $1}' train_list.txt | sort) \ <(awk '{print $1}' test_list.txt | sort) | head -204.2 测试推断流程:分块推理避免显存不足,评测指标要算一致
测试阶段模型输入输出都是整图,不裁剪。但 NH-HAZE 原图 1600x1200,直接整张图过 ViT(patch=8)会产生 200x150=30000 个 token,比较吃显存。如果你的卡只有 8GB,会被 OOM 卡住。常见的做法是分块推理:把图切成 256x256 的块,每块独立推理,再拼回去。拼接处会出现块间差异,所以切块时要有 overlap,我一般用 16 像素重叠,拼回时对重叠区域做线性融合,看下面测试代码:
# test.py import torch import numpy as np import cv2 from vit_dehaze import ViTDehaze device = "cuda" model = ViTDehaze(embed_dim=256, depth=8, patch_size=8).to(device) model.load_state_dict(torch.load("best_model.pth", map_location=device)) model.eval() def infer_patch(model, img, patch=256, overlap=16): # img: (H, W, 3) float32, 范围 [-1, 1] h, w = img.shape[:2] out = np.zeros_like(img) weight = np.zeros((h, w, 1), dtype=np.float32) stride = patch - overlap for y in range(0, h - patch + 1, stride): for x in range(0, w - patch + 1, stride): block = img[y:y + patch, x:x + patch] block_t = torch.from_numpy( block.transpose(2, 0, 1)).unsqueeze(0).to(device) with torch.no_grad(): pred = model(block_t).cpu().numpy()[0] pred = pred.transpose(1, 2, 0) out[y:y + patch, x:x + patch] += pred weight[y:y + patch, x:x + patch] += 1.0 # 最右侧和最下侧不足 patch 的部分单独处理 if w % stride != 0 or h % stride != 0: pass # 简化起见,先保证能整除 return out / np.maximum(weight, 1.0)推理完成后,图像从 [-1, 1] 转回 [0, 255] 时要注意顺序:先用 out = (out + 1) / 2 * 255 截断到 [0, 255],再转 uint8,最后转回 BGR 通道用 cv2.imwrite 保存。很多人在这里直接 np.clip 忘了先反归一化,导致保存的图像整体发灰——这是测试流程里最高发的低级错误,没有之一。
4.3 四个数据集的评测结果怎么读:不能拿合成雾的预期看真实雾
评测指标主流是 PSNR 和 SSIM,计算时有两处细节会显著影响结果:一是 PSNR 在 RGB 三通道上算还是转 YCbCr 后在 Y 通道算,后者的值普遍比前者高 1~2dB;二是是否裁剪图像边缘再计算,因为分块推理拼接边界有伪影。我建议统一用 Y 通道、裁剪 16 像素边界,这能更接近论文报告口径,也避免自欺欺人。
真实雾数据集上的参考水平大概是这样:I-HAZE 目标在 26~28dB;O-HAZE 在 23~25dB;NH-HAZE 能做到 20~22dB 就能算作有效工作。如果你在自己训练集上测出 30dB 以上,先怀疑训练集和测试集是否存在泄漏,再检查评测代码是否把输入图像也当成了输出。我在 NH-HAZE 上第一次拿到 24.6dB 时高兴了半小时,后来发现是评测脚本里读错了文件,把真值当成了预测结果——这种事故极其普遍,建议在评测脚本里对随机一张图做“输入=真值”的 sanity check,PSNR 应为无穷大,如果不是,说明数据对错了。
5. 去雾模型训练与测试避坑:NH-HAZE 和 O-HAZE 上最常见的 5 条踩坑记录
5.1 NH-HAZE 训练 loss 卡住不降:不是模型不行,是学习率和初始化不合适
现象:训练前 20 个 epoch loss 缓慢下降,之后几乎不动,验证集上图像依然有雾,且和输入图几乎一样。
原因:ViT 的自注意力模块对学习率和参数初始化很敏感。NH-HAZE 的非均匀雾让梯度分布很不规则,位置编码和 LayerNorm 的参数更新过慢或被淹没。此外,patch embedding 用随机初始化卷积核时,初期提取不到有效特征,后面的注意力层形同虚设。
解决:把初始学习率降到 5e-5,warmup 延长到 10 个 epoch;检查 pos_embed 的梯度范数是否正常,如果 pos_embed 更新缓慢,可以在训练初期单独放大它的学习率倍率 10 倍。还可以给 PatchEmbed 换用 ImageNet 预训练的 ViT 主干初始化,不要从零训练,效果会稳不少,但要注意预训练权重中的位置编码尺寸和你的训练分辨率未必一致。
5.2 测试输出图像颜色偏灰:归一化与反归一化的顺序写反了
现象:模型训练时 loss 很低,验证集 PSNR 也不错,但保存下来的结果图和有雾图几乎一样,只是略淡了一点。
原因:典型的数据管线错误。输入图像归一化到 [-1, 1],输出也是 [-1, 1],保存时应该先加 1 除以 2 再乘 255。有人直接在 [-1, 1] 上做 clip 到 [0, 1] 后乘 255,相当于整体压缩到了一半亮度,图像当然发灰。
解决:统一用一个后处理函数,把 torch tensor 转 numpy,先 (x + 1) / 2,再 clip 到 0~1,最后乘 255 转 uint8。这个函数写完测试一次就不动,别再每次手写。另外检查 cv2 读图是 BGR,你的训练代码如果转成了 RGB,保存时必须再转回 BGR。
5.3 显存不够:整图推理 OOM,尤其是 NH-HAZE 的 1600x1200 高清图
现象:训练 256x256 的 crop 没问题,测试时整图一塞进模型就报 CUDA out of memory。
原因:ViT 的显存占用和 token 数量成正比,NH-HAZE 原图 1600x1200、patch=8 时产生 30000 个 token,每层注意力的 QK 矩阵就是 30000x30000 的浮点矩阵,单层就要约 3.6GB。8 层加起来远超消费级显卡显存。
解决:不要整图前向。用 5.2 节的方法分块推理,patch=256、overlap=16 时每块只有 32x32=1024 个 token,任何卡都能跑。如果要求严格对齐,overlap 加到 32 并做叠加区域的线性加权融合。真要整图推理,只能把 patch_size 改成 16,但输出细节会损失,属于性能换显存的无奈之举。
5.4 PSNR 虚高:训练集和测试集发生数据泄漏,NTIRE2019 与 O-HAZE 重复
现象:测试 PSNR 高达 30dB,但目视效果却不比 24dB 的模型好,而且同一模型在 O-HAZE 和 NTIRE2019 上的指标几乎一样。
原因:压缩包解压后 NTIRE2019 目录里的图本身就是 O-HAZE 的竞赛版,只是文件名不同或者重新命名。如果你的划分脚本是从不同目录独立抽样,混着混着,同一场景的有雾图就可能同时在训练集和测试集。ViT 有全局注意力机制,对见过的场景有很强的记忆能力,比 CNN 更容易刷出虚高指标。
解决:划分后跑一下去重脚本,对比两张图的文件名、文件大小、像素均值。最稳妥的做法是以 NTIRE2019 为准,O-HAZE 只保留双方不重叠的部分,或者干脆放弃其中一个目录,用不到一百对的单数据集训练也够。
5.5 模型对测试分辨率变化敏感:位置编码的训练/测试不一致
现象:训练时用 256 固定 crop,测试时直接跑 800x600 的图,结果边缘区域出现明显的伪影和颜色错乱,中间倒是正常的。
原因:模型的位置编码是可学习参数,训练阶段只见过 256x256 对应的位置模式,测试时序列长度变长,位置编码被截断或需要插值。ViT 的位置编码不是尺度无关的,这个特性在分类任务里影响小,在像素级回归任务里会直接表现为局部伪影。
解决:测试时尽量把图像缩放或裁剪到训练分辨率附近,或者推理前把位置编码插值到当前序列长度(用 torch.nn.functional.interpolate 对 pos_embed 做双线性插值)。生产环境最好固定输入分辨率,在数据预处理阶段做 resize 或 center crop,别在推理时随意改尺寸。
6. 把去雾效果再推一档:Y 通道 PSNR 评测、可视化对比和透射率自检
评测指标的计算口径决定你论文里写出来的数字,也决定你和别人对比时有没有吃亏。我通常用下面这段代码计算 Y 通道 PSNR 和 SSIM,注意先转 YCbCr,再裁掉边缘:
import cv2 import numpy as np from skimage.metrics import structural_similarity as ssim def calc_metrics(pred_path, gt_path, border=16): pred = cv2.imread(pred_path).astype(np.float32) gt = cv2.imread(gt_path).astype(np.float32) pred = cv2.cvtColor(pred, cv2.COLOR_BGR2YCrCb)[:,:,0] gt = cv2.cvtColor(gt, cv2.COLOR_BGR2YCrCb)[:,:,0] if border > 0: pred = pred[border:-border, border:-border] gt = gt[border:-border, border:-border] mse = np.mean((pred - gt) ** 2) psnr = 10 * np.log10(255.0 ** 2 / (mse + 1e-10)) s = ssim(gt, pred, data_range=255) return psnr, s可视化对比建议每张测试图输出三联图:有雾原图、模型输出、无雾真值。把三张图横向拼接保存成一行,训练过程中每 10 个 epoch 存一组,能直观看到模型有没有真正学会“去雾”,而只是在“调亮度”。我习惯把预测图和真值图做差分,再对差分图做直方图统计:如果差分集中在低灰度,说明两者接近;如果差分有明显的结构性轮廓,说明边缘恢复不到位。
一个实用的后处理自检技巧:把模型输出的透射率估计可视化。由于我们用的是端到端回归,没有直接输出透射率,但可以通过大气散射模型反推:t_est = (I - A) / (J_est - A),A 取输入有雾图最亮区域的均值。然后把这个 t_est 存成灰度图,叠加在输出图上观察。如果 t_est 在雾浓的区域数值低、在清晰区域接近 1,说明模型学到了符合真实物理的去雾路径;如果 t_est 全图几乎均匀,说明模型只是学了一个全局色调映射,这种模型换到别的数据大概率翻车。
这个自检习惯帮我在 NH-HAZE 上能稳定比直接调参多出 1~2dB,因为一旦发现物理路径不对,我就回去调损失权重,而不是盲目堆 epoch。多年前我在 O-HAZE 上吃过亏:只看 PSNR 调模型,训出来的结果在测试集上指标漂亮,但拿到自己的真实雾图上完全没有去雾效果,从那以后每一版模型我都会先看透射率图再过指标。希望这套“先物理自检、再指标评测”的方法也能帮你在真实雾霾数据上少走点弯路。
本文还有配套的精品资源,点击获取