上周有个做路口监控的朋友丢过来一段素材,画面里的雾浓到连近处车辆的轮廓都糊成一片。他问我,能不能在既有的行人车辆检测流程前面加一层"跟着画面自己调"的增强,让检测器在雾天别掉点。我给的答复很直接:先别急着改网络、别急着标数据,把 Image-Adaptive-YOLO 的预训练权重跑起来看一眼再说——也就是项目里那个 Quick Test。这是雾天行人车辆检测这类复现工作的第一步,也是唯一一步能让你在半小时内判断"这套东西值不值得继续投入"的环节。这篇就围绕这件事展开:怎么在本地把这套模型的第一版推理跑通,每个参数为什么这么设,中间会遇到什么,以及结果出来之后怎么判断它不是"假跑通"。适合刚拿到代码、还没摸清结构的人,也适合已经跑过一遍但效果看着别扭的人。
1. 先跑通推理再谈训练:Quick Test 到底在验证什么
1.1 这个模型没法"随机初始化直接训"
Image-Adaptive-YOLO 和普通检测模型最大的区别,是它在检测头前面挂了一个可微图像处理模块。这个模块里塞了几个传统图像增强算子——白平衡、Gamma、对比度、色调、锐化,每个算子的强度参数不是人工写死的,而是由一个小卷积网络从当前输入图像里预测出来的。换句话说,网络自己看着这张图决定"该提多亮、该压多少色偏"。
这个设计带来一个很现实的后果:模块的参数是训练出来的,随机初始化的时候它预测出来的增强参数基本没有物理意义。我见过最极端的情况,Gamma 被预测到一个极小的值,整张图直接压成全黑,后面的检测器什么都看不到,梯度也没法正常回传。所以拿一个没训过的权重去做训练,等于在一个随机变化的图像变换上叠加检测损失,收敛会非常痛苦,甚至完全不收敛。
这也是为什么官方流程里第一步是 Quick Test,而不是直接train.py。先把已经训好的权重接上,确认增强模块输出的图像是"人眼看着合理"的,检测框是"位置和类别都对得上"的,你才有资格谈后面的事。
1.2 三个验收标准,缺一不可
我把 Quick Test 的成功标准拆成三条,跑完对着看:
| 验收项 | 具体表现 | 不达标说明什么 |
|---|---|---|
| 权重加载 | 加载时没有大面积 missing keys,或者仅有可解释的少量缺失 | 权重和配置文件对不上,或者下载不完整 |
| 增强效果 | 增强前后的对比图肉眼能看出色偏被拉回、暗部被提起 | 参数预测分支没生效,或者前处理归一化错了 |
| 检测结果 | 图上的行人、车辆有框,类别合理,置信度分布正常 | 后处理、坐标还原、阈值设置有问题 |
三条里面,第二条最容易被忽略。很多人跑完只盯着检测框,看到有框就以为通了。实际上如果增强模块是坏的,检测框可能只是靠检测器本身的鲁棒性硬撑出来的,后面一换场景立刻崩。所以务必把增强图单独存下来看一眼。
1.3 权重、配置、样例图必须是一套
这三样东西是绑定的。权重文件里存的张量形状,是由配置文件决定的;配置文件里的层数、通道数、锚框设置,又决定了检测头输出的维度。常见的错配是这样的:拿着一份为 A 版本结构训的权重,去加载改过结构的 B 版本配置,加载时不报错(因为 PyTorch 默认对多余的键只警告不中断),但推理出来的框完全是乱的。
样例图也有讲究。Quick Test 一定要用带雾的图。拿一张晴天通透的街景去测,增强模块大概率预测出一组接近"恒等变换"的参数,你从对比图上什么也看不出来,等于白测。项目里通常会附带几张测试图,如果没附带,自己找几张不同雾浓度的路口图,从薄雾到浓雾各一张,这样能观察到参数随雾浓度变化的趋势,这个观察比检测框本身更有价值。
2. 环境搭建里最容易翻车的几个地方
2.1 版本不是越新越好,是要"配套"
这类 2022 年前后开源的项目,代码里大量用到了当时框架的写法。你拿最新版的框架去跑,很容易撞上几个经典的坑:
第一个是torch.load的行为变化。新版框架对反序列化的安全策略收紧了,老项目保存的权重文件里如果塞了非张量对象(比如某些自定义类的实例、numpy 数组),直接加载会抛异常。解决办法有两个:一是临时把weights_only参数显式设成兼容模式,二是老老实实把框架版本降到项目说明里写的那个区间。我个人倾向前者,因为降版本会连带影响别的环境。
第二个是 numpy 的 2.x 兼容。很多老代码里写的是np.float、np.int这类已经被移除的别名,import 的时候就报AttributeError。这个好办,全局搜一遍替换成np.float64、np.int32就行。
第三个是 OpenCV 的版本。部分增强算子用到了特定版本的色彩空间转换接口,版本跨太大可能出现通道顺序不一致,表现出来就是增强图整体偏蓝或偏红。
我的建议是给这个项目单独建一个虚拟环境,Python 版本锁在 3.8 或 3.9,框架版本按项目说明走,不要和别的项目共用。这一步看着笨,但能省掉后面一半的排查时间。
2.2 依赖清单里那些"看着可有可无"的包
列一下跑 Quick Test 实际会碰到的依赖,以及它们各自在哪个环节起作用:
| 依赖 | 作用环节 | 漏装的典型症状 |
|---|---|---|
| 深度学习框架及视觉扩展 | 模型构建、权重加载、张量运算 | 直接 import 失败 |
| OpenCV | 读图、画框、写结果、部分增强算子 | 读图为空、保存的图画不出框 |
| Pillow | 部分预处理路径的图像读取与缩放 | 尺寸对齐阶段报类型错误 |
| NumPy | 数组运算、坐标解码 | 各种莫名其妙的类型不支持 |
| PyYAML | 解析配置文件 | 加载配置时报找不到解析器 |
| tqdm | 进度条 | 不影响功能,但看不到进度很难受 |
这里有个细节值得说:如果你跑的是无图形界面的环境(容器、远程机器),装opencv-python可能因为缺系统图形库而报错,换成opencv-python-headless更稳。这个包功能一样,只是不带你用不上的界面模块。
2.3 先用 CPU 跑通一遍,这一步别省
很多人上来就指定 GPU,结果报一堆 CUDA 相关的错,分不清是代码问题还是环境问题。我的习惯是先--device cpu跑一张图。
CPU 上跑一张 512 尺寸的图,慢的话几十秒,快的话十几秒,等得起。这一步的价值在于把所有和显卡驱动、算力架构、显存相关的变量全部排除掉。如果 CPU 上跑出来的结果是对的,切到 GPU 之后结果不对或者报错,那问题一定在设备迁移环节,范围瞬间缩小。
还有一个隐性好处:CPU 模式下如果出现"张量不在同一设备上"的报错,说明代码里某处硬编码了设备,这种 bug 在 GPU 模式下会被掩盖成别的问题,反而更难找。
3. 把 IA-YOLO 的网络拆开看:增强模块与检测头是怎么串起来的
3.1 五个可微滤波器分别在调什么
增强模块里的算子都是传统图像处理里用了几十年的东西,只不过这里把它们写成了可微的形式——每一步都是张量运算,梯度能一路回传到参数预测网络。逐个说:
白平衡调的是通道增益。雾天图像往往整体偏灰偏蓝,白平衡把这层色偏拉回来,让后续检测器看到的三通道分布更接近训练分布。它的参数通常是一组 RGB 增益系数。
Gamma 校正调的是亮度映射曲线的形状,不是简单地加减亮度。雾天图像的直方图往往挤在中间一段,暗部细节被雾的散射光"抬"起来了但对比度没了。Gamma 拉一拉,能把有效动态范围重新铺开。
对比度调的是围绕均值的拉伸强度。这个和 Gamma 互补,Gamma 调曲线形状,对比度调斜率。
色调调的是整体色相偏移,处理雾天里那种发黄的、发青的色偏。
锐化用一个小的高斯核做非锐化掩蔽,把被雾糊掉的边缘重新"提"一点出来。注意这个算子对噪声也敏感,强度过大会把噪点放大成伪边缘,检测器反而更容易误检。
| 滤波器 | 调节对象 | 雾天场景下的实际作用 | 过强的副作用 |
|---|---|---|---|
| 白平衡 | RGB 通道增益 | 修正整体偏蓝/偏灰 | 色彩失真,红色物体发橙 |
| Gamma | 亮度映射曲线 | 拉开被压缩的动态范围 | 暗部噪声被整体提亮 |
| 对比度 | 均值附近的拉伸 | 恢复被雾抹平的层次 | 高光过曝,天空一片死白 |
| 色调 | 色相偏移 | 修正发黄发青的色偏 | 语义色错乱,影响类别判断 |
| 锐化 | 边缘高频增强 | 找回被雾模糊的轮廓 | 噪声放大,伪边缘增多 |
这五个算子串联起来,就构成了"自适应"的部分。之所以要可微,是因为如果不可以求导,参数预测网络就没法通过检测损失来学习,只能靠人工调参或者额外监督——那就退回传统方法了。
3.2 参数预测网络为什么用双分支
参数预测网络(代码里通常叫 CNNP 或者类似的缩写)的任务是:看一张图,输出一组滤波器参数。听起来简单,但有个矛盾——判断"这张图偏色多少"需要全局统计量,而判断"这张图纹理糊到什么程度"需要局部细节。
只用全局池化,会被画面里的大面积高光或者大片天空带偏,预测出的参数对局部区域完全不适配。只用局部卷积,感受野不够,估不准整体色偏。所以实现上一般是两个分支并联:一路在较低分辨率上做全局统计,一路保留较高分辨率看局部纹理,最后把两路的特征拼起来过全连接,输出一个参数向量。
这个向量会按固定顺序切分成若干段,分别对应五个滤波器的参数。切分的段长由每个滤波器需要几个参数决定。这里我要提醒一句:不同版本的代码里这个向量维度和切分顺序可能不一样,别照抄网上看到的数字。最靠谱的做法是在forward里打一行打印,看实际输出的 shape,再对照滤波器类的初始化参数,两边一对就清楚了。
# 示意:确认参数预测网络的输出维度 with torch.no_grad(): dummy = torch.randn(1, 3, 256, 256).to(device) out = cnnp(dummy) print("CNNP output shape:", out.shape) print("输出向量:", out[0][:16]) # 看前若干维的取值范围打印出来还有个额外收获:能看出参数的取值范围是否合理。比如 Gamma 相关的维度如果全是负数或者接近零,那基本可以判定权重没加载对。
3.3 增强图回灌检测器时的两个硬约束
增强模块输出的图要送进检测主干,有两个约束必须满足,否则跑到一半就报形状错。
第一是空间尺寸。检测主干是多尺度下采样的结构,最深的一层 stride 通常是 32,所以输入的高和宽必须能被 32 整除,最好是 32 的整数倍再留一点余量。你用 512×512 没问题,但如果原图是 1920×1080 直接塞进去,就会在下采样拼接的时候对不上。标准做法是先做保持长宽比的缩放加灰边填充,把所有图统一到目标尺寸。
第二是数值区间。增强算子里的 Gamma、对比度这些都默认输入在 0 到 1 之间。如果你把 0 到 255 的原始像素直接喂进去,Gamma 校正会把整张图推到饱和,出来一片白。这个坑极其常见,因为检测器本身的预处理里往往也有一次归一化,两处叠加就容易搞混。我的做法是在增强模块的入口处显式打印一次张量的 min/max,确认是 0 到 1 再往下走。
# 示意:进增强模块前确认数值区间 print("input range:", x.min().item(), x.max().item()) assert x.max() <= 1.001, "输入未归一化到 0-1,检查前处理顺序"4. 一行命令背后的完整流程:推理脚本逐段走读
4.1 参数怎么设,每个都有理由
推理脚本的参数不多,但每个都影响结果。列一下我在 Quick Test 阶段的常用配置:
| 参数 | 建议值 | 为什么这么设 |
|---|---|---|
| 权重路径 | 项目提供的预训练权重 | 先别用自己训的,避免引入第二个变量 |
| 输入源 | 单张雾天图或一个小目录 | 先单图跑通,再批量 |
| 输入尺寸 | 512 | 训练时用较小尺寸裁剪,测试放大能提升小目标召回 |
| 置信度阈值 | 0.3 | 起步值,用来观察召回情况;太高会看不到框 |
| IoU 阈值 | 0.5 | 常规值,密集行人场景可以调到 0.45 抑制重叠 |
| 设备 | 先 cpu 后 0 | 分两步排除环境问题 |
| 输出目录 | 单独指定 | 方便对比多次运行的结果 |
关于输入尺寸,这里展开说一句。这类模型训练时通常用 256×256 的随机裁剪,因为增强滤波器对分辨率敏感——同一组参数作用在 256 的图和 1024 的图上,视觉强度是不一样的。测试时用 512 是实践中的折中:比训练尺寸大一倍,小目标(远处行人)在经过下采样后还能保留几个像素的特征;再往上加,显存占用和耗时会明显上升,收益却递减。这个 512 不是铁律,如果你的场景里全是近景大目标,256 也够用。
置信度阈值 0.3 也是起步值。我见过有人直接用 0.5,然后抱怨"雾天检测不出来"——其实不是模型不行,是雾天场景下模型给出的置信度整体偏低,阈值卡太死就把真阳性滤掉了。Quick Test 阶段先放宽,看清模型到底能找出多少东西,再决定正式用的时候卡在哪。
4.2 从读图到出框的完整数据流
整个推理链路可以拆成六步,每一步的中间张量形状都值得心里有数:
- 读图。用图像库读进来,通常是 BGR 或 RGB 的整型数组,形状是 高×宽×3。
- 尺寸对齐。按长宽比缩放到目标尺寸,不足的部分用固定灰度填充,同时记下缩放比例和填充偏移量。这一步的返回值一定要保存好,后面还原坐标全靠它。
- 归一化与转张量。除以 255 转到 0 到 1,通道顺序调整成 通道×高×宽,增加 batch 维度。
- 增强模块前向。参数预测网络先跑一遍,得到滤波器参数,然后按顺序把这些滤波器作用到图上,输出增强后的图。这一步可以额外把图存下来。
- 检测头前向。增强图进主干,多尺度特征融合,输出每个尺度的预测张量。
- 解码与非极大值抑制。把预测张量还原成 中心点+宽高 或者 左上右下 坐标,乘上先验框尺度,再用置信度过滤、按类别做重叠框抑制,最后用第 2 步记下的比例把坐标还原回原图尺寸。
# 示意:推理主循环的骨架 for path in img_paths: img0 = read_image(path) # 原图 img, ratio, pad = letterbox(img0, img_size) # 对齐 + 记录变换参数 x = to_tensor(img).unsqueeze(0).to(device) # 归一化 + 加 batch x = adaptive_module(x) # 可微增强 save_tensor_as_image(x, out_dir, path) # 存增强图,关键! pred = detector(x) # 检测头 det = non_max_suppression(pred, conf_thres, iou_thres) det = scale_coords(det, ratio, pad, img0.shape) # 还原到原图坐标 draw_and_save(img0, det, out_dir, path)第 5 步里有个容易被忽略的细节:增强图的保存。很多人只存最终画了框的图,结果排查问题时完全不知道增强模块干了什么。我的习惯是每次 Quick Test 都把增强图单独存一份,命名上加个后缀区分。后面一旦发现检测效果不对,第一件事就是翻出增强图看——如果增强图本身就糊了或者过曝了,那问题在增强模块,不用去查检测头。
4.3 结果落盘与命名规范
输出目录建议按"日期-权重版本-输入尺寸"的格式建,比如runs/quicktest_512_v1/。里面分两个子目录,一个存增强图,一个存画框图。这么做的原因很简单:Quick Test 你大概率会跑很多次,参数微调、换权重、换图,没有规范的命名,三天后你自己都分不清哪张图是哪次跑的。
批量跑的时候还要注意一点:脚本通常是顺序处理的,遇到一张读不进来的图(格式怪、路径有中文、文件损坏)会整个中断。稳妥的做法是在循环里加一层异常捕获,把出错的路径打印出来跳过,别让一张图毁掉一整批。
5. 第一次运行必然遇到的报错清单与定位顺序
5.1 权重加载的三类报错,对应三种病
第一类:大量 missing keys。加载时打印一长串"缺少某些键"。如果缺的是主干网络的键,说明权重文件和配置文件根本不是一套;如果缺的是增强模块相关的键,可能是权重保存时分成了两个文件(检测器一个、增强模块一个),你只加载了一个。
第二类:unexpected keys。权重里有配置里没有的层。常见于权重是用更复杂的结构训的,比如训练时带了辅助分支,推理时不需要。这种情况通常可以忽略,但要确认被忽略的确实是不影响推理的辅助层。
第三类:size mismatch。某个键名字对上了但形状不一致。这是最麻烦的,通常意味着类别数不同(比如训练时是 20 类,你的配置是 80 类),检测头最后一个卷积的输出通道自然对不上。处理办法只有两个:改配置匹配权重,或者只加载主干部分权重、检测头随机初始化。
我一般会在加载代码后面加一段统计,把三类情况的数量都打出来:
# 示意:加载后统计权重匹配情况 missing, unexpected = model.load_state_dict(ckpt, strict=False) print(f"missing: {len(missing)}, unexpected: {len(unexpected)}") for k in missing[:10]: print(" missing ->", k)strict=False是关键,它让加载不中断,你能看到全貌再决定怎么处理。等确认没问题了,再改回strict=True做一次严格校验。
5.2 形状与设备相关的硬报错
矩阵乘法的形状不匹配,报错信息里会出现两个对不上的维度数字。如果这个错发生在参数预测网络后面,基本可以断定是输出向量维度和滤波器参数总数不一致——可能是配置文件里滤波器数量被改过,也可能是权重版本和代码版本不配套。定位方法就是前面说的,打印实际 shape,和滤波器类的参数个数对一遍。
张量不在同一设备上。报错说某个张量在 CPU 上、另一个在 GPU 上。这类问题多半出在硬编码的地方,比如某处直接写了.cuda()而不是用统一的设备变量。快速定位的办法是全局搜一下.cuda()和torch.cuda,改成跟随主设备变量。
输入尺寸不能被整除。报错出现在特征拼接的地方,提示两个张量的空间维度对不上。这就是前面说的 stride 约束,检查你的输入尺寸是不是 32 的倍数,以及填充逻辑有没有把尺寸搞成奇数。
5.3 能跑完但结果不对的软故障
这类问题不报错,但结果一眼假,排查起来反而费劲。我遇到过三种:
| 现象 | 最可能的原因 | 验证方法 |
|---|---|---|
| 一个框都没有 | 置信度阈值太高,或输入未归一化 | 把阈值降到 0.05 看有没有框冒出来 |
| 框的位置整体偏移 | 坐标还原时漏了填充偏移量 | 用一张目标居中的图,看框是否也居中 |
| 框的尺寸整体偏大/偏小 | 缩放比例用错(用了缩放后尺寸而不是原图尺寸) | 打印 ratio 和 pad,手工算一遍 |
| 增强图全黑或全白 | 增强参数异常,或输入区间错了 | 打印增强参数的数值范围 |
| 同一张图两次结果不同 | 有随机性没关掉 | 检查是否处于评估模式、是否有随机数据增强残留 |
最后一条值得单独说。推理阶段模型必须处于评估模式,否则批归一化层会用当前批次的统计量而不是训练时累积的滑动统计量,同一张图跑两次结果可能不一样。如果批量跑的时候发现某张图的结果和单独跑的时候不同,八成就是这个原因。
6. 怎么判断这次 Quick Test 是真跑通了
6.1 增强图要"温和",不是"越猛越好"
拿到增强前后的对比图,判断标准不是"变亮了多少",而是是否往合理方向修正。具体看三点:
色偏有没有被拉回。雾天图整体偏灰蓝,增强后天空和路面的颜色应该更接近中性,而不是变成暖黄或者艳蓝。
暗部细节有没有被提起来,但高光有没有被压住。好的增强是两边兼顾的,如果天空部分变成一片纯白,说明对比度或 Gamma 调过头了。
噪声有没有被放大。锐化强度大的时候,平坦区域(比如路面、墙面)会出现明显的颗粒感。如果增强图的路面上噪点比原图重很多,说明参数预测偏激进,这种增强喂给检测器,误检率会上去。
我一般的判断是:增强图看起来应该"像是同一场景在能见度稍好的天气下拍的",而不是"像是加了滤镜的"。如果一眼看出是修过的图,那大概率过了。
6.2 多次运行结果必须完全一致
推理是确定性的,同一张图、同一份权重、同样的参数,跑十次结果应该一模一样。如果不一样,说明有随机源没关掉。除了前面说的评估模式,还要检查有没有残留的随机裁剪、随机翻转之类的数据增强逻辑被误打开。
验证方法很简单,跑两次,把两次输出的检测框坐标逐行比对,或者直接对输出的文本结果做一次哈希比较。不一致就去查随机种子和评估模式。这一步看着较真,但它是后面做量化对比的前提——如果推理本身不稳定,你后面比较不同参数的效果就没有意义。
6.3 建一份属于自己的基线记录
Quick Test 跑通之后,别急着往下走,先建一份基线记录。表格长这样:
| 图片 | 雾浓度(主观) | 检测框数 | 最高置信度 | 类别分布 | 单图耗时 |
|---|---|---|---|---|---|
| 图 A | 薄雾 | 12 | 0.87 | 人 5 / 车 7 | 0.4s |
| 图 B | 中雾 | 8 | 0.72 | 人 3 / 车 5 | 0.4s |
| 图 C | 浓雾 | 3 | 0.51 | 人 1 / 车 2 | 0.4s |
这张表的价值在你后续做任何改动的时候会体现出来。换权重、改参数、加数据增强,跑完一对比,是提升还是倒退一目了然。没有基线,你只能凭感觉说"好像好了一点",这种判断在工程上没有任何意义。
从这张表里还能读出一个趋势:随着雾浓度上升,检测框数量和最高置信度都在下降。这个下降幅度,就是这个模型在你这个场景下的能力边界。如果浓雾图只剩 3 个框,而实际画面里有十几个目标,那说明单靠这个模型还不够,后面得考虑补充数据或者做多帧融合。这个结论,比你训完一整个模型再发现要早得多。
7. 从 Quick Test 往下走:真正开始训练前的三件事
跑通推理只是拿到了入场券,接下来要动真格的时候,有三件事必须先想清楚。
第一件是训练尺寸和裁剪策略。前面提过,训练用较小的裁剪尺寸,因为增强滤波器对分辨率敏感,用大图训练会让参数预测网络学到的强度和实际推理时对不上。如果你后续要迁移到自己的数据,裁剪尺寸、裁剪位置分布这些细节都要跟原设计保持一致,否则增强模块的行为会飘。
第二件是合成雾的方式。雾天数据不好采,常规做法是按大气散射的物理模型合成:最终像素等于清晰图像乘以透射率,加上大气光乘以(1 减透射率),透射率随景深指数衰减。这里面有三个可调的量——大气光值、散射系数、景深估计。合成数据的质量几乎完全取决于这三个量调得对不对。我的经验是别只用一组固定参数,按几个档位分别合成,让模型见到不同浓度的雾,泛化会好很多。另外合成的时候记得保留一份无雾的原图,后面做对照实验用得上。
第三件是迁移到自建数据时的两处改动。一处是标注格式,得转成项目要求的格式,转换完一定要抽样检查,尤其检查坐标是归一化后的还是绝对像素值,这个搞反了模型永远学不会。另一处是类别数,改了类别数之后检测头的输出通道会变,预训练权重里检测头那部分就没法直接用了,只能加载主干和增强模块,检测头重新初始化。这时候学习率要调小一点,让主干慢慢适应,别一上来就把预训练特征冲掉。
最后再分享一个小技巧。如果你要在多台机器上复现同一份结果,把环境依赖、权重文件的校验值、推理参数全部记在一个文本文件里,跟代码放在一起。我吃过太多次"在我机器上是好的"的亏,后来养成这个习惯,换机器的时候照着文件一步步来,基本不会再出岔子。
我个人在实际操作中的体会是,Quick Test 这个环节最容易被当成走过场,但它其实是整个复现过程中信息密度最高的一步。你在这一步花两个小时把增强图看明白、把基线记下来、把每个报错的原因搞清楚,后面训练阶段能省掉好几天。反过来,如果这里糊弄过去,等训练完发现效果不对,你要面对的是"到底是数据问题、增强模块问题、还是检测头问题"这种没法拆解的困局。先把这一层拆干净,后面每一步才有依据。