☰
Image-Adaptive-YOLO雾天检测Quick Test复现与排错指南
2026/10/27 6:19:43 网站建设 项目流程

雾天行人车辆检测这个方向,只要在路口相机上跑过检测的人都有体会:晴天能到 0.8 的 mAP,一起雾掉到 0.3 出头是常事,远景的小车和撑伞的行人成片消失。我最近在复现 Image-Adaptive-YOLO 这套方案,第一步没有急着改数据、也没急着训模型,而是先花了大半天把它的 Quick Test 跑通——用预训练权重对单张雾天图完成"参数预测—可微滤波—检测出框"的完整链路。这半天花得非常值,后面训练阶段的绝大多数报错,都是靠这次 Quick Test 建立起来的基准快速定位的。这篇就围绕 Quick Test 展开:为什么这套模型要用"图像自适应"的思路做雾天增强、环境该怎么搭、推理脚本的每一段在干什么、结果怎么判断跑没跑对、以及我实际踩过的那些坑。适合有一定 PyTorch 和检测基础、准备复现这篇工作但不想一上来就掉进训练坑里的朋友;如果你只是想知道一套"先增强再检测"的流水线该怎么验证,里面的判断方法同样能用。

1. 雾天场景的检测困局与"先跑通一次推理"的价值

1.1 雾把图像变成了什么,检测器又因此丢掉了什么

先把这个问题的物理成因说清楚,后面的设计取舍才好理解。成像上的大气散射模型大致可以写成 I = J·t + A·(1 - t),其中 J 是无雾场景的真实辐射,A 是大气光(雾天里那个"白茫茫"的基底亮度),t 是透射率,一般按 t = exp(-β·d) 随距离 d 指数衰减,β 和雾浓度相关。这个公式拆开看就两件事:一是对比度按距离被压缩,越远的目标越糊在白雾里;二是整体亮度被抬到大气光附近,暗部细节被"淹没",同时散射对不同波长的作用不一致,画面整体偏灰蓝。

对检测器来说这是双重打击。浅层卷积干的活本质上就是提取边缘、纹理、角点,雾把这些高频信息抹平了,浅层就提不出有效特征;深层虽然感受野大,但依赖的仍是浅层堆上来的响应。再往深一层说,是训练分布和测试分布错位——模型在清晰图上学会的"行人 = 竖直的轮廓 + 特定的部件纹理组合"这套模式,在雾天图上根本不成立,于是置信度断崖式下滑。实际表现有很明显的规律:近处大目标还能撑住,中远距离、小像素占比的目标先崩,行人的腿部和自行车轮这类细长结构最先丢失。

还有一个容易被忽视的点:雾不是均匀的。真实场景里雾浓度随距离变化,近处淡、远处浓,同一张图里不同区域的信噪比差别很大。这意味着一个全局统一的增强参数(比如全图拉同样的对比度)大概率会把近处过增强、远处欠增强。我在做数据观察时截了一批图对比,同一辆车在近处和远处的可辨识度差距能到肉眼可见的程度,这种空间不均匀性是后面必须考虑的设计前提。

1.2 图像自适应增强的核心:让检测 loss 决定怎么洗图

常规做法是两阶段:先挂一个去雾网络(暗通道先验、或者基于生成式模型的去雾),把图洗干净了再送检测器。这条路我早期试过,问题很实在。第一,去雾网络的优化目标是"人眼看着像清晰图",PSNR、SSIM 这些指标涨了,但检测指标不一定涨——人眼觉得好看的去雾结果,可能把一些低对比度但判别性很强的纹理一起抹掉了。第二,去雾网络本身会引入伪影,尤其是天空区域和大面积平坦区域,容易出色块和光晕,这些伪影对检测器来说是纯噪声。第三,两个网络分开训,去雾输出和检测器输入之间存在分布鸿沟,没法联合优化。

Image-Adaptive-YOLO 的思路换了个方向:不训练一个"去雾大网络",而是准备一组参数化的经典图像处理滤波器——去雾、Gamma、对比度、色调、锐化、白平衡,可能还有裁剪类操作——每个滤波器只有很少的几个参数。关键设计是这些滤波器的参数不由人工设定,也不是全局固定值,而是由一个轻量 CNN(参数预测器)看过输入图之后实时预测出来。整条链路——预测参数、执行滤波、送检测——都是可微的,所以检测损失可以一路反传回参数预测器。

这个设计的精妙之处在于"优化目标对齐"。网络学到的不是"怎么让图看起来更清晰",而是"怎么让这组滤波参数使得检测损失最小"。极端情况下,如果输入本身就是清晰图,理想的参数预测器应该输出接近恒等变换的参数,什么都不改。这就把增强模块从一个独立的预处理工具,变成了检测网络的一个可学习部件。

为什么用一堆小滤波器而不是一个大增强网络?我在拆结构时想过这个问题,结论有三点。一是参数空间小、约束强,不容易训出离谱的结果,也不会像生成式模型那样凭空"脑补"出不存在的纹理。二是每个滤波器都有明确的物理或视觉含义,Gamma 调亮度分布、对比度调动态范围、锐化调高频,调试时可以逐个看参数是不是在合理区间,可解释性远好于黑盒网络。三是算力开销可控,参数预测器本身很轻,滤波操作都是逐像素运算,这让整套方案有了往边缘设备部署的可能——这个动机在路口相机场景里非常现实。

1.3 Quick Test 在整条复现链路里负责锁死哪个变量

复现这类"增强 + 检测"的双模块模型,变量太多了:数据集和标注格式、增强模块和检测模块的耦合方式、检测头的解码逻辑、后处理参数、训练策略。一上来就训,一旦不收敛或者指标很低,你根本不知道该怀疑哪一环。

Quick Test 的作用就是分离变量。用作者公开的权重跑一次单图推理,把"环境是否可用、推理链路是否正确"这个变量先锁死,得到一个可复现的基准结果。后面训练出问题时,你至少能确定:模型结构没接错、权重能正常加载、前处理和后处理逻辑是对的、坐标还原是正确的。剩下的嫌疑就集中在训练侧——数据、学习率、损失权重、增强策略。

Quick Test 还能顺手验证四个复现高频坑,全都在推理链路上:颜色通道顺序(RGB 还是 BGR)、归一化方式(0-1 还是 0-255、有没有减均值除方差)、letterbox 的填充方式和缩放比例、检测输出的解码和坐标还原。这四点在训练时出错表现为"指标上不去",很难查;但在 Quick Test 里出错,表现往往非常明显,一眼就能看出来。

最后一个好处是反馈快。单张图推理几秒钟就出结果,改一行代码跑一次,迭代成本极低;训练一轮动辄几十分钟,试错成本完全不是一个量级。所以我个人的习惯是:任何两阶段或多模块的模型,复现第一步永远是先跑通单样本推理,把它当作后续所有工作的参照系。

2. 环境搭建:版本选择背后的取舍逻辑

2.1 显卡、CUDA 与 PyTorch 的三层对齐原则

环境这块我踩过的最大的坑是"为了 Quick Test 装了个轻量环境,结果要训练时整套重装"。所以我的建议是:直接按训练的目标配环境,Quick Test 阶段无非是浪费一点显存,不会出问题。

版本选择有个顺序,反了会很痛苦。第一个原则是 PyTorch 版本优先,CUDA 版本跟着 PyTorch 的官方预编译包走,不要反过来"我机器是 CUDA 11.3,所以必须装支持 11.3 的 torch"。预编译 wheel 里已经打包了对应的 CUDA 运行时,本机驱动只要满足最低版本要求就行,不需要单独装 CUDA Toolkit(除非你要编译自定义算子)。第二个原则是别追新,这套实现里往往会用到一些较老的 API,PyTorch 跨大版本升级后行为变化不少,1.10 到 1.13 这个区间是比较稳的。第三个原则是 cuDNN 不用手动装,跟着 torch 走就行,手动装多版本反而容易冲突。

我手上这套跑通的组合大致是这样,供参考:

组件建议版本选择理由与注意事项
Python3.8 / 3.93.10 以上部分老依赖 wheel 缺失,编译麻烦
PyTorch1.10 ~ 1.13API 稳定,社区实现兼容性最好
torchvision与 torch 严格对应版本错配会直接 import 失败
CUDA11.3 / 11.6跟随 torch 官方预编译包,不必本机另装
numpy小于 1.241.24 起删除了 np.int / np.float 等别名
opencv-python4.x 任一稳定版注意 BGR 通道顺序
Pillow / tqdm / pyyaml任意稳定版读图、进度条、配置解析

显存方面,单张图、batch=1 的 Quick Test 其实很轻,4G 显存足够;但训练阶段因为增强模块和检测网络要一起驻留显存,反向传播还要存中间激活,建议 11G 以上起步,想开大分辨率(608 输入)或者较大 batch 的话 16G 更稳。这一条我建议你在动手前就想清楚,避免装完发现训不了。

2.2 依赖安装的完整命令与踩坑预警

环境隔离一定要做,用 conda 建独立环境,别在 base 里折腾。大致流程:

conda create -n iayolo python=3.9 -y conda activate iayolo # 先装与显卡匹配的 PyTorch,建议从官方提供的预编译源安装 pip install torch==1.12.1 torchvision==0.13.1 # 再装其余依赖 pip install "numpy<1.24" opencv-python pillow tqdm pyyaml matplotlib

装完必须做一次自检,别急着跑模型:

import torch, numpy, cv2 print("torch:", torch.__version__) print("cuda available:", torch.cuda.is_available()) print("cuda version:", torch.version.cuda) print("device count:", torch.cuda.device_count()) print("numpy:", numpy.__version__) print("opencv:", cv2.__version__) # 做一次最小张量运算,确认 CUDA 真的能用,而不只是 is_available 返回 True x = torch.randn(4, 4).cuda() print((x @ x.T).sum().item())

最后那一步很关键。我遇到过is_available()返回 True,但一实际运算就报 cuDNN 或驱动不匹配的情况,原因是驱动版本低于运行时要求。这一步能把问题提前暴露,不至于跑到模型里才发现。

numpy 版本这个坑单独说一下。这套实现里如果出现AttributeError: module 'numpy' has no attribute 'int',就是 numpy 装到了 1.24 以上,老代码里np.int、np.float、np.bool这些别名被删了。最省事的办法是降到 1.23.x,而不是去改源码里几十处类型标注,改源码容易漏、也容易引入新 bug。

提示:如果你在无图形界面的服务器上跑,务必确认代码里没有直接用 cv2.imshow;改成 cv2.imwrite 存盘再看。这个报错信息很不直观,常见的是cv2.error: ... The function is not implemented。

2.3 目录约定与权重、素材的准备

目录结构我习惯一开始就规划好,后面加脚本不用改路径:

ia-yolo-repro/ ├── configs/ # 模型与数据配置 ├── models/ # 参数预测器、滤波模块、检测网络 ├── weights/ │ └── ia_yolo_best.pth # 预训练权重 ├── samples/ │ ├── fog_real_01.jpg # 真实雾天图 │ ├── fog_syn_01.jpg # 合成雾图 │ └── clear_01.jpg # 清晰图,做对照实验用 ├── outputs/ # 增强图、可视化结果、日志 └── quick_test.py

权重来源这里不展开具体渠道,从项目公开仓库或论文补充材料里取即可。重点说两点获取时就要确认的事。第一,权重和代码必须是同一个版本,如果仓库在更新、你手上的权重是一个较早的 commit 产出的,很可能出现 key 对不上或者结构细节不一致。判断方法后面 3.1 会讲。第二,权重文件往往包含多个部分——参数预测器的、滤波模块的、检测网络的——加载后要确认这三段都在,别只加载了检测部分就以为齐了。

素材准备上,我建议至少准备三张图,这个组合是后面判断"跑对了没有"的关键。一张真实雾天图,最好包含近处大目标和远处小目标,能在一次推理里同时看到两种难度;一张合成雾图,用来确认模型对人工雾也有效;一张完全清晰的图,用来做对照实验——一张好的自适应增强模块在清晰图上应该输出接近恒等的参数,检测结果不应该比原图差。第三张图是我在整个复现过程中用得最多的诊断工具,强烈建议你也加进去。

3. Quick Test 实操:从权重加载到出框的完整链路

3.1 先把权重和模型结构对上

别急着写推理逻辑,先花十分钟把权重的"长相"摸清楚,这一步能省掉后面大量的猜测。加载并打印 state_dict:

import torch ckpt = torch.load("weights/ia_yolo_best.pth", map_location="cpu") # 权重文件的外层可能包了一层,先看清楚结构 print("ckpt type:", type(ckpt)) if isinstance(ckpt, dict): print("top keys:", list(ckpt.keys())[:10]) sd = ckpt.get("model", ckpt.get("state_dict", ckpt)) else: sd = ckpt print("param count:", len(sd)) for i, (k, v) in enumerate(sd.items()): print(f"{i:03d}", k, tuple(v.shape)) if i > 20: break

看什么?第一看 key 前缀,如果大量 key 以module.开头,说明权重是用多卡并行包装保存的,需要去掉这个前缀才能和单卡模型对上。第二看 key 的分布,参数预测器部分、滤波部分、检测主干部分的 key 应该有明显的命名分组,确认三段都存在。第三看形状,把预测器最后那层全连接层的输出维度记下来,这个数字就是滤波器参数向量的总长度,后面解释输出时要对上。

去前缀这种操作我建议写成可控的:

def strip_prefix(sd, prefix="module."): if not any(k.startswith(prefix) for k in sd): return sd return {k[len(prefix):]: v for k, v in sd.items() if k.startswith(prefix)} sd = strip_prefix(sd) model.load_state_dict(sd, strict=False)

用strict=False先不报错地加载,然后接一段诊断代码把缺失和多余的 key 打出来:

missing, unexpected = model.load_state_dict(sd, strict=False) print("missing:", len(missing), missing[:10]) print("unexpected:", len(unexpected), unexpected[:10])

正常情况下这两个列表都应该是空的。如果 missing 全是检测头相关的几层,常见原因是权重里那部分 key 命名有差异,得对着模型定义一个个核;如果 missing 是参数预测器的一整段,大概率是权重版本和代码版本对不上,别硬凑,回去找匹配的那份权重更省时间。我一开始就是硬凑的,改了半小时 key 名字,最后发现是版本问题,白折腾。

3.2 前处理:颜色通道、归一化与 letterbox

前处理是复现里最容易出错、又最难自查的一环,因为出错了不会报错,只是结果悄悄变差。我把它拆成三件事。

第一件是颜色通道。训练时如果用 PIL 读图,得到的是 RGB;推理时如果用 cv2 的imread,拿到的是 BGR。这两个顺序反了,增强模块看到的图像统计会整体偏掉——红蓝通道互换后,"雾天偏蓝"这种色彩特征完全变了,预测出的白平衡和去雾参数会明显异常;检测头那边也会因为通道顺序不对而认错目标类别。这种错误在可视化上很隐蔽,图还是那张图,只是颜色怪一点。最稳的做法是统一口径:img = cv2.imread(path)之后立刻img = img[:, :, ::-1]转成 RGB,全文只在这一处做转换,别在中间任何环节再转回去。

第二件是归一化。不同实现差别很大,有的是img.astype(np.float32) / 255.0,有的还要再减均值除方差。这个必须以训练时的配置为准,不能凭感觉。有个简单的自查方法:看增强模块输出的参数是否在合理区间,如果归一化方式错了,参数会明显跑偏(比如去雾强度直接顶到上限)。

第三件是 letterbox。检测网络要求输入是固定尺寸(比如 416 或 608),而且必须是 32 的整数倍——这是下采样倍数的要求,网络里有 5 次 stride=2 的下采样,尺寸不整除会导致特征图对不齐。直接 resize 会拉伸目标,破坏长宽比,小目标变形后更难检。标准的 letterbox 做法是等比缩放后用固定灰度(常用 114)填充到目标尺寸:

import cv2 import numpy as np def letterbox(img, new_shape=416, color=(114, 114, 114)): h, w = img.shape[:2] r = min(new_shape / h, new_shape / w) nw, nh = int(round(w * r)), int(round(h * r)) dw, dh = (new_shape - nw) // 2, (new_shape - nh) // 2 img = cv2.resize(img, (nw, nh), interpolation=cv2.INTER_LINEAR) top, bottom = dh, new_shape - nh - dh left, right = dw, new_shape - nw - dw img = cv2.copyMakeBorder(img, top, bottom, left, right, cv2.BORDER_CONSTANT, value=color) return img, r, (left, top)

这里返回的r(缩放比)和(left, top)(左上角填充量)必须一路传到后处理,用来把检测框还原回原图坐标,公式是x_orig = (x_pad - left) / r、y_orig = (y_pad - top) / r。这个细节我后面还要单独说,因为框错位十有八九是这里出的问题。

还有一点关于增强图的输出处理。滤波模块处理的是网络里的张量,输出范围可能在 0 到 1 之间,也可能超出一些(比如锐化或对比度拉伸后会有负值和大于 1 的值)。保存成图片前必须做裁剪np.clip(x, 0, 1)再乘 255 转 uint8,不裁剪的话超出部分会回绕,白的地方变成黑点,也就是俗称的"溢出"。这个现象如果你在增强图上看到莫名其妙的黑斑或者彩色噪点,先查这一处。

3.3 前向与后处理:参数预测、可微滤波、解码与 NMS

整个前向链路我拆成了四段,每段都能单独打日志,出问题时一眼能定位到哪一段。

第一段是参数预测器。它接收预处理后的图,输出一个长度固定的向量,向量被切分成若干组,分别对应各个滤波器的参数。这里有个非常重要的细节:训练时每个滤波器的参数都经过了一个固定的映射(比如先 sigmoid 到 0 到 1,再线性拉伸到该滤波器的合法区间,Gamma 可能是映射到 0.3 到 3.0,去雾强度映射到 0 到 1)。推理时必须复用完全相同的映射函数,绝对不能自己手写截断。我一开始图省事用np.clip直接裁,结果去雾强度经常顶格,整张图被压成一团黑。正确的做法是把训练代码里那个映射函数原封不动搬过来。

with torch.no_grad(): params = model.dip(x) # 参数预测器输出 params = model.map_params(params) # 复用训练时的映射逻辑,不要自己写 enhanced = model.dfp(x, params) # 可微滤波,得到增强图 preds = model.detector(enhanced) # 检测网络

打印参数时建议按滤波器分组打,看起来直观:

groups = ["defog", "gamma", "contrast", "tone", "sharpen", "wb"] p = params.squeeze(0).cpu().numpy() idx = 0 for name in groups: n = GROUP_DIM[name] print(f"{name:>9}:", np.round(p[idx:idx + n], 4)) idx += n

分组维度和名称要以你仓库里的配置为准,我这里是示意。看到参数就能判断很多事:如果是雾天图,去雾强度应该明显大于 0,对比度应该往上调;如果是清晰图,去雾强度应该接近 0,Gamma 应该接近 1。这个判据后面 4.2 还会用到。

第二段是可微滤波。执行的是逐像素的映射和局部滤波,注意这一步是对整个 batch 逐元素运算,不需要转 numpy,全在张量上做。有个小坑:如果滤波过程里用到了torch.max或torch.min这类算子做通道间聚合(去雾里常用),在推理时它们是可用的,但训练时会因为没有梯度而断流,所以实现里通常做了软化近似。这意味着推理和训练走的可能是两条略有差异的路径,如果代码里做了分支判断,要确认推理时走的是哪一条,并且和作者评测时保持一致。

第三段是检测解码。网络的原始输出是特征图上的偏移量、置信度和类别分数,需要按 anchor 解码成绝对坐标。这一段建议直接用仓库里的解码函数,不要自己重写,因为 anchor 尺寸、网格中心偏移的约定、宽高的编码方式(是指数形式还是直接回归)各家实现不一样,重写极易出错。

第四段是后处理:置信度过滤加 NMS。这里的阈值选择很有讲究,我在 4.3 详细说。代码大致是:

conf_thres = 0.3 iou_thres = 0.5 boxes, scores, classes = decode(preds, anchors, num_classes) keep = scores > conf_thres boxes, scores, classes = boxes[keep], scores[keep], classes[keep] keep_idx = nms(boxes, scores, iou_thres) boxes, scores, classes = boxes[keep_idx], scores[keep_idx], classes[keep_idx]

最后是坐标还原和可视化,把框映射回原图坐标再画。画的时候建议同时保存三张图:原图、增强图、带框的原图。对比着看,能一下子判断出增强模块到底做了什么。

3.4 运行与耗时拆解

跑通之后别急着关掉,加一段耗时统计。这套方案能不能落地,很大程度取决于参数预测器和滤波这两个额外模块的开销。我的做法是用torch.cuda.synchronize()卡时间,前几次作为预热丢弃,取后面十次的均值:

import time def bench(fn, warmup=3, iters=10): for _ in range(warmup): fn() torch.cuda.synchronize() t0 = time.time() for _ in range(iters): fn() torch.cuda.synchronize() return (time.time() - t0) / iters * 1000

单张 416 输入、中端显卡上的参考量级大概是:预处理几毫秒,参数预测器十毫秒以内,滤波几毫秒,检测前向二十毫秒上下,后处理一两毫秒。整体在几十毫秒这个量级,具体数字以你实测为准,我这里只给个数量级参考,方便你判断自己的结果是不是异常——比如参数预测器跑到了一百多毫秒,那很可能是某个环节没用no_grad,或者误算了多次。

这张表是我实际跑完之后整理的,你可以照着填自己机器上的数:

阶段参考量级需要关注什么
读图与预处理几毫秒letterbox 的插值方式和填充色是否与训练一致
参数预测器前向十毫秒内是否被 no_grad 包裹,是否重复计算
可微滤波几毫秒是否在 CPU 上算的,张量有没有来回搬
检测网络前向二十毫秒上下输入尺寸、是否 eval 模式
后处理解码与 NMS一两毫秒阈值设置、NMS 实现是不是纯 Python 循环

一个很常见的性能坑是张量在 CPU 和 GPU 之间来回搬。比如滤波算完转成 numpy 存图,下一段又要送进检测网络,又搬回 GPU。这样单张图可能要多花十几毫秒。我的处理办法是整个前向链路只在最后可视化时才转 numpy,中间全程留在显存里。

4. 如何判断"跑对了":三层验证法

4.1 数值层与视觉层

Quick Test 最怕的情况是"跑完了,有框,看起来像那么回事",然后带着一堆隐性问题进入训练阶段。我总结了三个层次的检查,逐层过一遍。

数值层看的是硬指标。增强图输出后立刻打印像素的最小值、最大值、均值、标准差。健康的增强图通常是:最小值接近 0 但不大量堆积在 0(说明没有大面积压黑),最大值接近 1 但不过曝,均值落在 0.4 到 0.6 之间,标准差相比原图明显提升(对比度拉起来了)。如果最小值大量卡在 0、最大值大量卡在 1,说明参数映射出界了,回去查上一节的映射函数。如果标准差反而比原图低,说明增强把细节抹平了,多半是参数符号错了或者滤波器执行顺序不对。

同时打印检测输出:有效框的数量、置信度分布、类别分布。一张包含若干行人车辆的路口图,在 0.3 阈值下出十几个到几十个框是正常的;只有一个框或者上百个框都不正常,前者说明置信度整体偏低(前处理有问题),后者说明 NMS 没生效或者阈值用错了。

视觉层看的是观感。重点看四件事:远处目标的轮廓有没有被拉出来,这是去雾的核心价值;天空或大面积平坦区域有没有出现块状伪影和光晕,这是过度锐化的典型表现;整体色调有没有明显偏色,偏绿偏紫说明白平衡参数跑偏;近处高对比区域有没有出现噪点放大,这在浓雾图上很常见——雾天原本噪点被糊住了,一增强反而全冒出来。

4.2 清晰图对照实验

这一条是我用得最多、也最推荐的诊断手段。拿一张完全清晰的图,走完全一样的推理链路,然后看三件事。

第一,参数预测器输出的去雾强度应该接近 0。这是自适应设计的核心逻辑——没雾就别去雾。如果你的模型在清晰图上输出的去雾强度依然很高,说明要么权重加载的层错位了(部分层用了随机初始化),要么输入的颜色通道或归一化方式不对,让模型误判成了雾图。

第二,增强图和原图应该非常接近。可以算一下两者的平均绝对误差,如果差异很小,说明接近恒等变换,链路健康;如果差异很大,图明显变了,那就要顺着参数往下查。

第三,检测结果不应该比直接检测原图差多少。注意这里要单独跑一次"不经过增强模块、直接把原图送检测网络"作为对照,两次的框数量和置信度应该在同一个量级。如果经过增强模块后反而差了一大截,说明增强模块在帮倒忙,问题一定在参数上。

这个实验的价值在于它是"已知答案的题目"。雾天图上你没法判断"这个增强结果算好还是不好",但清晰图上你有标准答案。我在调试阶段反复用这一招,好几次都是靠它发现通道顺序搞反了——雾天图上只是颜色怪,说不清对不对,清晰图上一对照,偏色立刻暴露。

4.3 阈值与 NMS 参数怎么定

后处理参数这块有个特别容易混淆的点:评测用的阈值和可视化用的阈值完全不是一回事。计算 mAP 的时候,为了拿到完整的召回-精度曲线,置信度阈值要设得极低(0.001 这个量级),把所有可能的框都留下来;而画图看效果时,0.001 会画得满屏都是框,完全没法看。我第一次跑的时候直接沿用了评测脚本里的阈值,看到几百个框还以为模型坏了,浪费了不少时间。

我实际用下来的一组参数:

用途置信度阈值NMS IoU说明
快速看效果、截图0.3 ~ 0.40.5框干净,能看清主要目标
详查召回、找漏检0.1 ~ 0.20.5框变多,便于分析远处的漏检原因
评测指标0.0010.5 上下必须给足召回空间
密集行人口0.30.45路口密集场景,IoU 调低减重复框

NMS 的 IoU 阈值在行人密集场景里比较敏感。路口一群人等红灯的时候,人挨得很近,框本身就重叠,IoU 设 0.5 偶尔会把两个挨着的行人合成一个;设 0.6 又会出重复框。我的经验是先按 0.5 跑,看可视化结果里有没有"一个人两个框"或者"两个人一个框",再微调。这个没有普适最优值,得看你实际场景的目标密度。

还有个输入分辨率的取舍。416 快但远景小目标召回差,608 慢一些但对小目标友好很多。雾天场景的痛点恰恰在远景,所以我倾向于 Quick Test 阶段就用 608 看一眼上限效果,再决定后面训练用哪个尺寸。

5. 问题排查:从报错到结果异常的速查路径

5.1 环境与显存类

这类问题最好查,因为有明确报错。我整理了一下实际遇到过的:

报错或现象根本原因处理办法
module 'numpy' has no attribute 'int'numpy 版本过高,别名被移除降到 1.23.x,不要改源码
is_available()为真但运算报错驱动版本低于运行时要求核对驱动版本,或用预编译包重装 torch
推理阶段 CUDA out of memory增强模块与检测网络同时在显存,中间激活未释放加torch.no_grad(),减小输入尺寸,显式del中间变量
cv2.error: The function is not implemented无图形界面环境调用了 imshow改为 imwrite 存盘
首次运行卡在编译 kernel 上很久正常现象,与输入尺寸和显卡相关等待即可,第二次会快很多

显存那条要单独提醒。torch.no_grad()是必须加在推理外层的,不只是省显存,还防止中间张量一直挂着引用。我见过的情况是:单张推理看着没问题,循环跑几十张就慢慢涨到 OOM,原因就是某段代码把张量存进了列表或者全局变量。所以如果你打算做批量 Quick Test,更要留意这一点。

5.2 权重与结构类

Missing key(s)和Unexpected key(s)这两条报错信息本身已经指明了问题方向。

如果是 missing 集中在检测头最后几层,通常是类别数不一致——你手上的权重是在某个类别集合上训的,你的模型配置里的类别数不同,导致最后的分类层形状不匹配。处理办法是改配置里的类别数去对齐权重,而不是硬加载。如果是 all missing 之类的情况,说明外层包装没剥干净,ckpt.get("model", ckpt)那一步取错了层。

size mismatch一般是结构细节有差异,比如参数预测器最后那个全连接层的维度不同,也就是滤波器参数向量的长度不同。这种情况基本可以断定权重和代码版本不匹配,回去找对应版本比改代码划算。

还有一种静默失败:加载时用了strict=False而且没看 missing 列表,结果模型大部分层是随机初始化的,跑起来有框但框乱飞。所以我强烈建议至少在前几次跑的时候把 missing 和 unexpected 打印出来看,确认都是空列表之后再关掉。

5.3 结果异常类

这类最难查,因为不报错。我列几个典型现象和我的定位路径。

框整体偏移。首选怀疑 letterbox 的坐标还原。做一次定量验证:在 3.2 的 letterbox 函数里,如果原图是 1920×1080,目标 416,算一下 r = min(416/1080, 416/1920) = 0.2167,缩放后是 416×234,纵向要补 182 像素,上下各 91。那么还原时 x 要用(x - 0) / 0.2167,y 要用(y - 91) / 0.2167。如果 y 忘了减 91,框会整体上移一大截。这种偏移在画面上看是"框都在目标上方",非常有辨识度。

框出现在增强图上是对的,但画在原图上错了。这说明增强图的尺寸和原图不一致,你在增强图上算的坐标直接用到原图上了。必须严格按 letterbox 的 r 和 pad 还原到原图尺寸,中间不能跳步。

增强图偏色。查颜色通道。前面说过,用 OpenCV 读图不转 RGB 就会这样。判断方法很简单:看增强图整体是不是红蓝互换的观感,比如天空偏橙、树叶偏洋红。

增强图全黑或者过曝成一片白。查参数映射和数值裁剪两处。先把参数打印出来,看去雾强度和对比度是不是顶到了极值;如果是,查映射函数;如果参数正常但图仍然异常,查是不是滤波执行了两次,或者滤波后的张量范围没有做clip。

同一个输入每次结果略有不同。查model.eval()。忘了切 eval 模式,批归一化层会用当前 batch 的统计量,单张推理时这个统计量极不稳定,输出会抖动。这个坑很隐蔽,因为结果"看起来是对的",只是不太稳定,容易在比较不同方案时得出错误结论。

框数量异常多。查置信度阈值,八成是沿用了评测脚本的 0.001。

如果一口气改了好几处还是不对,我的建议是回到最小可验证状态:把增强模块整个跳过,原图直接进检测网络,先确认检测这条链路本身是通的、框是准的,然后再把增强模块接回来。分而治之比同时怀疑五个地方高效得多。

6. 实操心得与往训练阶段的过渡

6.1 几个反复踩到的坑与技巧

说几个我在这次 Quick Test 里印象最深的点,都是文档里不会写、但实际会绊你一脚的。

第一个是"别信文件名,信 key"。我一开始拿到权重,文件名里的版本号看着和代码对得上,就直接加载了,结果 missing 了一大片。后来老老实实打印 state_dict 的 key 才对上。这件事之后我养成了一个习惯:拿到任何权重,第一件事是torch.load之后打印前二十个 key 和形状,十分钟的事,能省掉半天。

第二个是分类别打印检测结果。只看总框数不够,行人、车辆这些类别在不同雾浓度下的表现差别很大。我做过一个统计,同样一张浓雾图,行人类别的置信度下降幅度比车辆类别大得多,原因是行人的判别特征更依赖细节纹理(四肢、衣物轮廓),车辆有大面积的车身色块撑着。这个观察直接影响了我后面类别相关的阈值设置。

第三个是保存中间产物。增强图一定要存下来,哪怕你只看带框的原图。后期分析"为什么这个目标漏检了",你需要判断到底是增强没把它拉出来(增强模块的锅),还是拉出来了但检测没识别(检测模块的锅)。没有增强图这一步,就只能猜。

第四个是固定随机种子并把输入标准化。Quick Test 的目的是建立一个可复现的基准,所以输入尺寸、预处理方式、后处理参数都要写死在一个配置文件里,后面任何一次跑都用同一套。不然你改着改着就不知道是哪次的结果了。

6.2 从 Quick Test 走向完整训练的顺序建议

Quick Test 跑通之后,往训练过渡不要一步到位,我建议按这个顺序推。

第一步,冻结增强模块,只用原图训练检测头。这一阶段的目标是把检测这条链路在你要用的数据集上跑出正常指标,确认数据加载、标注解析、损失计算、评估脚本都是对的。这一步如果指标正常,说明你的检测基础是可靠的,后面出问题一定出在增强模块和联合训练上。

第二步,冻结检测头,只训参数预测器。用一个已经训好的检测网络当"评委",让参数预测器学着预测出能让这个评委打高分的滤波参数。这一步收敛通常比较快,因为搜索空间小。训完之后重跑一次 Quick Test 的流程,看增强图是不是比预训练权重的结果更对你的数据集——这一步能验证你的联合训练方向是对的。

第三步,解冻两端做联合微调,学习率要显著降低。增强模块和检测网络一起动的时候,很容易出现"增强往一个方向走、检测往另一个方向走"的来回拉扯,表现为损失震荡不降。我的经验是联合阶段的学习率降到单模块训练时的十分之一甚至更低,先跑几个 epoch 看损失是不是平稳下降,再决定要不要加。

第四步,数据层面做混合。纯合成雾训练出来的模型在真实雾上往往偏弱,原因是合成雾的浓度分布、色偏倾向、噪声特性都和真实雾不一样。我的做法是合成雾和真实雾按比例混合,同时留一部分清晰图——清晰图的存在很重要,它能让参数预测器学会"没雾的时候不要乱动",避免模型对雾过拟合到见谁都去雾的程度。

评估阶段也要分场景看,别只看总体 mAP。我习惯把测试集按雾浓度分成几档(淡雾、中雾、浓雾),分开看指标。总体指标掩盖的东西太多了:有可能是淡雾图涨了很多、浓雾图反而掉了,平均下来看着还行,实际上方案在最难的场景里失效了。按浓度分档之后,你才知道模型的能力边界在哪,也才知道下一步该往哪个方向补。

最后留一句个人体会:这套方案最容易被误解的地方,是把增强模块当成一个独立的去雾工具去评价。如果你盯着增强图的观感挑毛病,会觉得它不如专门的去雾算法"干净";但它的目标从来不是好看,而是让后面的检测网络更省力。所以评价它的时候,永远要把增强图和检测结果放在一起看——有时候一张看起来有点怪、对比度偏高的增强图,检测结果反而是最好的。想通这一点,后面的训练和调参会顺畅很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询