面向卡口场景的YOLOv3危化品车辆小目标识别实践
简介:这份PDF文档是发表于《山西交通科技》2020年第4期的技术论文,面向交通管理、智能交通与计算机视觉方向的研究人员和工程实践者,聚焦高速公路场景下危化品运输车辆货物类型标志的自动识别问题。全文围绕YOLOv3单阶段目标检测模型展开,介绍基于机器视觉的车辆抓拍系统、包含10000张车辆背面图像与23类标注的危化品货物类型数据集构建流程,以及模型训练参数、损失函数和不同光照天气条件下的实验结果。压缩包仅含1个PDF文件,大小约2.41MB,便于直接阅读、检索与引用。已有107人学习,适合需要了解目标检测落地交通监管、数据集标注和实时识别方案的读者参考,也可为危化品运输安全管理和后续智能交通系统研究提供方法借鉴。
1. 一个反直觉的事实:这类识别最难的地方不是"分清是第几类"
危化品车辆货物类型识别这套系统,真正难的地方不是"分辨是第几类危险品",而是在多数时间里根本看不清。一辆车以八十公里时速通过卡口,从触发抓拍到驶离画面不到三百毫秒;车尾那块菱形标志牌在四千像素宽的原图里也许只有一百多个像素,等比缩到检测网络常用的四百一十六见方输入上,往往不足十个像素——比一个卷积核还小。所以这个任务的核心矛盾从来不是分类头够不够强,而是极小目标与硬实时同时压在同一张卡上。把 YOLOv3 这类单阶段检测器放进高速公路场景做危化品车辆货物类型识别,本质上是在这个矛盾里做取舍:让出一部分定位精度,换回每一帧都能在边缘侧算完的时间预算。搞懂这套取舍怎么落地,比记住模型名字有用得多。
2. 单阶段回归与两阶段流水线:卡口场景为什么等不起 R-CNN 那条路
深度学习做目标检测有两条主干路线,这不是风格差异,而是两种完全不同的时间预算分配方式。两阶段检测器把"哪里有东西"和"这是什么东西"拆成两道工序,单阶段检测器把它们压成一次前向。危化品车辆货物类型识别这个任务,恰好卡在必须选边站的位置上:标志牌本身特征稳定、外观规范,但它在画面里极小,而且系统必须在车辆通过卡口的瞬间给出结果,晚一秒这条数据就只剩存档价值。
2.1 两阶段检测器的时间究竟花在哪
以 R-CNN 系列为代表的两阶段方案,结构上分三块:第一块生成基于类别的候选区域,也就是先把图里"可能是目标"的位置粗筛出来,通常几千个;第二块用深度卷积网络对每个候选区域抽一个固定长度的特征向量;第三块用线性分类器(早期是 SVM)判定类别并回归边框。这个设计的出发点很合理——先粗后精,把算力集中在少量候选上,背景区域只过一遍轻量筛选。
问题出在第二块。候选区域是任意尺寸的矩形,要送进全卷积网络抽特征,就得先做形变对齐或者用 RoI Pooling 之类的操作把它们拉成统一尺寸,这一步在早期实现里是串行的,几千个候选就是几千次前向。即便后续的改进版本把特征抽取改成在共享特征图上裁剪,候选区域的数量级依然决定了它比单阶段慢一个档次。放到卡口场景里,一帧 4K 图要跑几百毫秒甚至上秒级,多路摄像头汇到一台边缘设备上,队列立刻堆爆。
我在别的项目里踩过一次类似的坑:把一套两阶段方案直接搬到实时流上,单帧延迟看着还能接受,但吞吐量完全撑不住,因为候选区域生成这步吃的是 GPU 的并行度,而不是单纯的计算量。降低候选数量能提速度,但小目标的召回会跟着掉——这正好是最不能牺牲的部分。
2.2 单阶段把检测写成一次回归,赌的是什么
单阶段检测器的思路是把检测直接当回归问题处理:预定义一组锚点,密集覆盖整张图的空间位置,网络对每个位置直接输出"这里有没有目标、框多大、是什么类",不再有独立的区域建议网络。YOLO 系列、SSD、RetinaNet 都属于这一类。
这个赌注的核心是:卷积网络在提取特征时,其实已经隐含地知道"哪个位置有东西"了,再单独搞一个建议网络去显式生成候选,是重复劳动。把分类和定位塞进同一个输出张量,一次前向就能拿到全部结果。代价是正负样本极度不平衡——一张 416×416 的图上,十几万个预测位置里可能只有两三个是真的目标,其余全是背景。这也是后来 RetinaNet 提出 Focal Loss 想解决的问题。
危化品车辆货物类型识别选的正是这条路。理由不复杂:标志牌外观规范、类别定义清晰,网络不需要太强的"这是什么"语义推理能力,反而对"快"极其敏感。判断一个技术选型是否合理,看它牺牲了什么更值钱——这里牺牲的是定位精度上限,换的是每帧几十毫秒的确定性延迟。
2.3 网格、锚框与三尺度特征图:标志牌由哪个格子负责
YOLOv3 的具体做法是把特征图划分为 S×S 个网格,每个网格生成 B 个边界框。如果只看一层特征图,这个划分很粗暴:一个格子只能负责一个目标,两个标志牌挤在同一个格子里就必然丢一个。YOLOv3 的解法是同时在三个尺度上做预测,特征图分别来自主干网络的不同深度,下采样倍率通常是 32、16、8,对应 13×13、26×26、52×52(以 416 输入为例)。浅层特征图分辨率高、感受野小,负责小目标;深层特征图分辨率低、感受野大,负责大目标。
每个尺度分配 3 个锚框,九个锚框的宽高是先验值,训练前用 k-means 在数据集上聚类得到,而不是拍脑袋定。COCO 上那组经典数值是 (10,13)、(16,30)、(33,23)、(30,61)、(62,45)、(59,119)、(116,90)、(156,198)、(373,326),但直接拿来用会翻车——危化品标志牌的形状分布和 COCO 里的行人车辆完全不是一回事。
import numpy as np # 三个预测尺度对应的锚框(宽, 高),按特征图从粗到细排列 ANCHORS = [ [(116, 90), (156, 198), (373, 326)], # 13x13, stride 32,管大目标 [(30, 61), (62, 45), (59, 119)], # 26x26, stride 16,管中目标 [(10, 13), (16, 30), (33, 23)], # 52x52, stride 8, 管小目标 ] def assign_anchor(gt_wh): """把真值框分给 IOU 最大的锚框,返回该锚框在全部 9 个里的全局索引""" gt_w, gt_h = gt_wh best_iou, best_idx = -1.0, -1 for scale_id, scale_anchors in enumerate(ANCHORS): for a_id, (aw, ah) in enumerate(scale_anchors): # 左上角对齐后求交并比,避免中心点偏移干扰形状匹配 inter = min(gt_w, aw) * min(gt_h, ah) union = gt_w * ah + aw * gt_h - inter iou = inter / union if iou > best_iou: best_iou, best_idx = iou, scale_id * 3 + a_id return best_idx, best_iou # 用法:统计数据集里每个真值框落到哪个锚框,形状差太远就该重新聚类 for wh in [(24, 26), (18, 40), (60, 22)]: print(wh, assign_anchor(wh))这段代码做的是"责任划分":训练时每个真值框只交给形状最接近的那个锚框去拟合,其余的锚框在这个目标上不产生定位损失。参数说明上,gt_wh是标注框的宽高(像素单位或归一化后乘输入尺寸都行,只要和锚框同量纲);inter用min(w)相乘是一种简化写法,前提是两个框左上角对齐,对形状匹配来说够用;best_idx除以 3 取整就是尺度编号,取余就是该尺度内的锚框编号。
危险货物标志牌的形状差异很大:主标志是菱形(正方形旋转 45 度,外接框仍是正方形), subsidiary 标志有长方形、三角形,还有文字说明牌是细长条。如果锚框全是接近正方形的,那些细长条标志牌的匹配 IOU 会低到 0.2 以下,训练时定位损失怎么都降不下去。常见的实现思路是在自己数据集上跑一遍 k-means,把九组宽高重新算出来,这一步的收益往往比换主干网络更明显。
2.4 前向解码:从张量到屏幕上的框
网络输出的是原始张量,要把它们还原成图上的坐标,中间有一层解码。以某一尺度为例,输出通道数是3 × (4 + 1 + 类别数),3 是锚框数,4 是中心点和宽高,1 是置信度,剩下是类别概率。中心点用 sigmoid 压到 0 到 1 之间,再叠加上所在网格的左上角坐标;宽高用指数变换乘上锚框先验值。
import numpy as np def sigmoid(x): return 1.0 / (1.0 + np.exp(-x)) def decode(pred, anchors, stride, img_size=416, conf_thres=0.5): """把单尺度原始输出解码成 [x1, y1, x2, y2, conf, cls] 形式的框""" grid = img_size // stride pred = pred.reshape(grid, grid, 3, 5 + NUM_CLASSES) # 网格左上角坐标,形状 (grid, grid, 1) grid_y, grid_x = np.meshgrid(np.arange(grid), np.arange(grid), indexing='ij') grid_x = grid_x[..., None] grid_y = grid_y[..., None] # 中心点:sigmoid 后加上网格偏移,再乘 stride 回到原图尺度 cx = (sigmoid(pred[..., 0:1]) + grid_x) * stride cy = (sigmoid(pred[..., 1:2]) + grid_y) * stride # 宽高:exp 保证为正,再乘锚框先验 bw = np.exp(pred[..., 2:3]) * np.array(anchors)[:, 0].reshape(1, 1, 3, 1) * stride bh = np.exp(pred[..., 3:4]) * np.array(anchors)[:, 1].reshape(1, 1, 3, 1) * stride conf = sigmoid(pred[..., 4:5]) cls_prob = sigmoid(pred[..., 5:]) # 置信度过滤:低于阈值的格子直接扔掉,这是速度的第一道闸门 keep = conf[..., 0] > conf_thres boxes = np.concatenate([(cx - bw / 2)[keep], (cy - bh / 2)[keep], (cx + bw / 2)[keep], (cy + bh / 2)[keep]], axis=-1) return boxes解码里有几个容易写错的地方。grid_x和grid_y的索引顺序必须和 reshape 的张量布局一致,否则框会整体转置九十度——这种错误在可视化时一眼能看出来,但在指标上只表现为 mAP 掉了几个点,很容易被误判成模型能力不足。stride是特征图相对原图的降采样倍率,13×13 那层是 32,52×52 那层是 8,三个尺度必须分别传对。置信度阈值在边缘侧通常设在 0.5 左右,设太高小目标直接被滤掉,设太低则 NMS 前候选框数量爆炸,后处理耗时反超网络前向。
三个尺度的解码结果拼在一起之后才做 NMS,这一步的顺序不能反。按类别分别做 NMS 还是全局做,也会影响结果:如果先全局 NMS 再分类,两个不同类别但空间重叠的标志牌会互相压制;先按类别分组再各自 NMS,代价是后处理循环次数变多。危化品车辆车尾经常同时贴主标志和副标志,这个问题必须提前想清楚。
3. 损失函数四项拆解:定位、置信度、分类为什么要分开算
YOLOv3 的总损失是四项相加,形式上看是简单的加法,实际上每一项的量纲、样本范围、梯度尺度都不一样,混在一起很容易训崩。理解这四项各自负责什么,比调参本身更重要。
3.1 四项误差各自的职责与量纲冲突
第一项是中心点误差,衡量预测框中心和真值框中心的距离,衡量的是"位置对不对"。第二项是宽高误差,衡量的是"框大小对不对"。第三项是置信度误差,衡量"这个位置有没有目标",这一项在正样本和负样本上都要算,是整个损失里样本量最大的一项。第四项是分类误差,只管正样本里"类别对不对"。
量纲冲突在这里非常具体。中心点坐标归一化后在 0 到 1 之间,误差数量级很小;宽高是经过 log 变换再和锚框比,误差可能到几;置信度和分类都是概率,误差在 0 到 1 之间。如果不做权重平衡,训练早期会明显偏向把置信度压下去——因为负样本数量占绝对多数,把置信度全预测成 0 就能让总损失降一大截,网络会躺平在这个局部最优里。
常见的实现思路是给定位项加权,通常中心点和宽高各乘一个系数(比如 5),置信度的负样本项乘一个较小的系数(比如 0.5),分类项给 1。这组数字不是理论推导出来的,是实践里试出来的,换数据集需要重新看曲线。
def yolo_loss(pred, target, obj_mask, noobj_mask, cls_weight=1.0): """四项损失分开算,逐项打日志比只看总 loss 有用得多""" # 1) 中心点:sigmoid 输出,用二元交叉熵,只在正样本格子上生效 loss_xy = BCE(pred[..., 0:2], target[..., 0:2]).sum() * obj_mask / N # 2) 宽高:对 log 尺度做平方误差,同样只算正样本 loss_wh = MSE(pred[..., 2:4], target[..., 2:4]).sum() * obj_mask / N # 3) 置信度:正负样本都算,但 IOU 落在忽略区间的框两边都不惩罚 loss_conf = (BCE(pred[..., 4], target[..., 4]) * (obj_mask + 0.5 * noobj_mask)).sum() / N # 4) 分类:只算正样本,多标签用 sigmoid 而非 softmax loss_cls = (BCE(pred[..., 5:], target[..., 5:]) * obj_mask).sum() / N * cls_weight return loss_xy + loss_wh + loss_conf + loss_cls这段代码有两个设计点值得说。一是分类用的是 sigmoid 而不是 softmax,也就是说每个类别独立判断,不是互斥的。这对危化品标志牌识别是必要的:一块标志牌完全可能同时表达"易燃"和"有毒"两种属性,softmax 会强迫网络在两者之间选一个,直接限制了表达能力。二是忽略区间的处理——预测框和真值框的 IOU 高于阈值但又不是最大匹配的那个,既不当作正样本也不当作负样本,noobj_mask里把它置零,这一项是防止训练早期"差不多对"的框被反复惩罚导致定位震荡。
3.2 宽高为什么不能直接回归像素值
一个容易被忽略的细节:宽高项回归的是尺度因子,不是像素绝对值。如果网络直接预测像素宽高,那么同一个标志牌在近处拍可能是 200 像素、远处拍可能只有 30 像素,网络要拟合的数值跨度差了近一个数量级,梯度极不稳定。改成预测log(真值宽 / 锚框宽),等于让网络只学"这个框比先验大多少倍",数值集中在 0 附近,训练就平稳了。
解码时对应的就是exp(预测值) × 锚框宽 × stride。这个exp还有一个好处:保证输出恒正,永远不会出现负宽高的退化框。
对小目标来说,这个设计有个隐含代价。远处车辆上的标志牌,真值宽可能只有 12 像素,锚框宽是 16 像素,比值 0.75,log 后约 -0.29;网络预测值稍微偏一点,比如偏 0.3,exp之后就是 1.35 倍,宽变成 21 像素。也就是说相对误差被指数放大了,小目标的定位精度天然吃亏。这是为什么提高输入分辨率对小目标检测的收益远大于换更大的主干网络。
3.3 训练节奏:动量 0.9、权重衰减 0.0005、300 轮与两次衰减
这套训练的配置是:动量 0.9,权重衰减 0.0005,训练 300 轮,在第 70% 和第 90% 处把学习率乘 0.1 衰减两次。这几个数字放在一起看,能读出一些设计意图。
动量 0.9 是 SGD 类优化器的常用值,作用是把历史梯度累积起来,抑制批次间的抖动。目标检测的损失曲面在正负样本极不平衡时特别抖,纯粹靠降低学习率来稳定训练,收敛会很慢;加动量等于给优化过程加了一点惯性,能穿过那些浅的局部坑。
权重衰减 0.0005 是 L2 正则系数,管的是防止权重数值无限膨胀。检测网络的分类头在长尾类别上特别容易过拟合,稀有类的样本可能只有几十个,不加正则的话分类头会把权重全推到高频类上。0.0005 这个量级不会明显损害拟合能力,但能压住一部分过拟合。
300 轮配两次衰减的节奏,本质上是"粗调—精调—微调"三段式。前 210 轮用大学习率把权重从随机初始化拉到大致合理的位置,中间 60 轮把学习率降一个数量级,让网络在局部细化,最后 30 轮再降一个数量级做收尾。这个安排有个前提:数据集规模不大,300 轮在大数据集上可能连第一段都跑不完。10000 张图、22174 个框的规模,300 轮正好是一个能让 loss 走平又不至于过拟合的区间。
提示:衰减点的选择要看 loss 曲线,不看固定比例。如果 loss 在第 60% 就已经开始震荡,衰减点前移往往比继续硬训有效。
曲线判读上有个经验:训练到第一次衰减之前,如果分类项和定位项的 loss 比例始终在 1:10 以上,说明权重系数没调对,大概率是定位项权重给高了,网络把全部容量花在对齐框上,分类头学不动。反过来,如果置信度项在训练早期就迅速降到接近 0 并且不再变化,说明负样本权重压得太狠,网络学会了"全部猜没有目标"这个捷径。
4. 数据集工程:10000 张车尾图与 22174 个标注框是怎么攒出来的
模型结构再合理,数据不过关,一切都是空转。这套识别系统最终能跑通,一半功劳在标注这一侧。
4.1 抓拍链路:4K、红外补光与边缘侧算力
采集端是卡口上的抓拍单元,输出 4096×2160 的背面图像,配红外闪光灯做弱光补光。这两个参数合起来决定了整个系统的上限:分辨率越高,小目标的原始像素越多;红外补光保证夜间也能拿到结构清晰的反光标志牌纹理,而不是一团噪点。
抓拍单元和识别算力是分开的。抓拍端负责触发、曝光、压缩、上传,识别跑在边缘侧的嵌入式计算设备上,结果和部分原始图通过专有网络回传到监测中心。这个拆分很重要:抓拍是硬触发,不能等识别结果;识别是软实时,允许排队但队列深度必须可控。把两者耦合在一台设备上,一旦识别模型跑飞,抓拍也跟着丢帧,整个卡口就瘫了。
从工程角度看,抓拍端的参数比模型参数更值得磨。曝光时间太长,八十公里时速的车尾会拖影,标志牌边缘糊成一片;曝光太短,夜间信噪比不够,标志牌的颜色信息直接丢失——而危险货物标志牌的底色(红、橙、黄、蓝、白)本身就是最强的分类线索之一。红外补光解决的是照度问题,解决不了运动模糊,这两件事得分开调。
4.2 23 类标注体系与长尾分布
数据集是 10000 张背面图,标注了 23 个类别、22174 个框。这 23 类拆成两组:5 种危险化学品属性分类共 8419 个框,18 种危险化学品分类共 13755 个框。两组加起来正好是 22174。
这个拆分方式反映了业务上的两层需求。粗粒度那一层(5 类属性)对应的是应急处置场景——消防或者路政到场后,最先要知道的是"这东西是易燃、易爆、有毒、腐蚀还是放射",这决定了穿什么防护、用什么灭火剂、警戒半径多大。细粒度那一层(18 类)对应的是监管和溯源,需要精确到具体危险性分类。两套标签共用一个检测头,等于让网络同时学粗分和细分,细分学好了粗分自然就有了,反过来不成立。
平均每张图 2.2 个框,这个数字说明多数危化品车辆尾部不止一块标志牌。实际场景里,运输多类危险品的车辆会挂主标志加副标志,或者主标志加一块文字说明牌。这对检测器提出了一个具体要求:同一位置附近必须能同时输出多个重叠框,NMS 阈值不能太激进。
长尾问题在这组数据里非常明显。18 类细分里,常见的易燃液体、易燃固体样本量可能上千,而某些放射性或者感染性物质的样本可能只有个位数——现实中这类运输本来就少。直接训练的结果是分类头严重偏向高频类,稀有类的召回接近零,但整体 mAP 看着还行,因为高频类把平均值拉上去了。这是最危险的一种"看起来没问题"。
处理长尾有几种常见思路:按类别频率做损失加权、对稀有类做过采样、把稀有类合并到粗粒度层级。第三种在业务上往往最实用——如果某类物质一个月才出现一次,识别成它的父类属性,对应急处置来说已经够用,硬要区分到子类反而增加了误报。
4.3 XML 标注文件的结构与一致性校验
标注用 labelImg 完成,每张图生成一个同名 xml,里面记录每个框的位置和类别。格式是 PASCAL VOC 那套:size节点存原图宽高,object节点下是name和bndbox(xmin、ymin、xmax、ymax)。
import xml.etree.ElementTree as ET from collections import Counter def parse_voc(xml_path): """解析单张标注,返回归一化后的框和类别""" root = ET.parse(xml_path).getroot() w = int(root.find('size/width').text) h = int(root.find('size/height').text) boxes = [] for obj in root.findall('object'): name = obj.find('name').text.strip() bb = obj.find('bndbox') # VOC 坐标是 1-based 且是浮点字符串,必须转 int 再减 1 x1 = int(float(bb.find('xmin').text)) - 1 y1 = int(float(bb.find('ymin').text)) - 1 x2 = int(float(bb.find('xmax').text)) - 1 y2 = int(float(bb.find('ymax').text)) - 1 # 越界裁剪,防止归一化后出现负数或大于 1 x1, y1 = max(0, x1), max(0, y1) x2, y2 = min(w - 1, x2), min(h - 1, y2) if x2 <= x1 or y2 <= y1: continue # 退化框直接丢弃,不要让它进训练集 boxes.append((name, (x1 + x2) / 2 / w, (y1 + y2) / 2 / h, (x2 - x1) / w, (y2 - y1) / h)) return boxes, w, h # 全量扫描:统计类别分布和异常框 stat = Counter() for p in xml_files: boxes, _, _ = parse_voc(p) for name, *_ in boxes: stat[name] += 1 print(stat.most_common())这段解析代码里,int(float(...))这个写法不是啰嗦。标注工具导出的坐标有时带小数点,直接int()会在 Python 里报错;减去 1 是因为 VOC 用 1-based 索引,不减的话每个框整体偏移一个像素,在 4K 图上无所谓,但在缩放后的小图上会累积成可见误差。退化框过滤同样必要:标注时手抖画出一个宽 0 高 0 的框,归一化后宽高都是 0,训练时log(0)直接产生 NaN,而且这种 NaN 会污染整个 batch 的梯度。
扫描完类别分布之后,还有两件事要做。一是检查同一张图里框的重叠情况,发现大量 IOU 大于 0.9 的重复标注,说明标注规范没统一,有人重复画了同一个牌子。二是检查尺寸分布,把框面积画成直方图,如果绝大部分框都落在极小的一侧,说明输入分辨率设小了,得往上调。
5. 避坑与排查:五条从训练日志和误检框里挖出来的经验
5.1 召回死活上不去,但分类置信度都很高
现象:验证集上检出的框分类都很准,但漏检率明显偏高,尤其是远处车辆上的小标志牌。
原因:训练和推理的输入尺寸在缩放时把标志牌压得太小了。一张 4096 宽的图缩到 416,缩放比接近 10:1,原本 80 像素的标志牌只剩 8 像素,而最细那层特征图的 stride 是 8,等于一个目标只占一个格子。网络再强也没法从一个格子里分辨形状和配色。
解决:优先提高输入分辨率,而不是加深网络。把输入从 416 提到 608 或 832,小目标的像素占比能翻倍;如果显存或延迟不允许,改用切片推理——把 4K 图切成四块分别检测再合并,代价是后处理要多做一次跨块 NMS。另一个成本更低的办法是只对车辆区域做二次裁切放大,因为卡口场景里车辆位置基本可预测。
5.2 同一块区域的两个标志牌被合并成一个框
现象:车尾同时贴主标志和副标志时,只检出一个框,另一个消失,而且框的宽高明显异常。
原因:NMS 阈值设得太高(比如 0.45 以上),两个空间上相邻、类别相同或者不同的标志牌互相压制。危化品车辆车尾标志牌排布密集,重叠度天然就高。
解决:把 NMS 阈值降到 0.3 到 0.4 之间,并且改成按类别分组做 NMS,不同类别的框不互相抑制。如果业务上允许,还可以加一条规则:同一车辆区域内,置信度最高的框如果宽高比接近 1(菱形主标志),强制保留一个相邻的副标志框。
5.3 稀有类完全检不出来,但训练 loss 一直在降
现象:训练日志里总 loss 稳定下降,验证集整体 mAP 看着还行,但把类别拆开看,18 类里有一半的 AP 接近 0。
原因:类别极度不平衡,高频类的样本量是稀有类的上百倍,分类头的梯度被高频类主导。总 loss 下降主要来自高频类和置信度项,稀有类的贡献被淹没了。
解决:训练时按类别频率做损失加权(权重取频率倒数的平方根比较稳),或者对稀有类做数据增强式的过采样。更务实的做法是把稀有类合并到粗粒度的属性层,业务上够用,指标也好看。评估时必须逐类看 AP,只看总 mAP 会掩盖这个问题。
5.4 训练到几十轮突然出现 NaN
现象:loss 曲线前几十轮正常下降,某一轮突然变成 NaN,之后再也不恢复。
原因:标注里有退化框或者越界框,归一化后宽高出现 0 或负数,宽高项做了log运算直接炸掉。另一种可能是学习率在衰减点之前太大,某一批梯度的范数爆掉。
解决:训练前跑一遍全量标注校验,把宽高小于 2 像素的框、越界框、重复框清掉。训练时加梯度裁剪,把范数上限定在 10 左右。另外,学习率衰减点不要设得太晚,在 loss 已经开始震荡的时候还不降,很容易翻车。
5.5 白天效果好,傍晚和雨天大幅退化
现象:晴天白天的样本召回和精度都不错,换成逆光、雨天、夜间,指标掉得很明显,而且错的模式很一致——漏检多于误检。
原因:训练集里的光照和天气分布不均衡,绝大多数样本来自正常光照。标志牌表面是反光材质,逆光时整块高光过曝,颜色特征完全丢失,网络只能靠形状猜,小目标的形状信息本来就少。
解决:按光照和天气做分层采样,保证每个子集在训练集里都有足够占比。数据增强上,不要只做随机亮度调整,那模拟不出高光过曝的效果,更有效的是随机遮挡加局部对比度扰动。评估也得分层做,把白天的指标和夜间的指标分开报,混在一起算平均值没有意义。
6. 设计取舍:为什么"够快"比"最准"更适合这条路
把整套方案摊开看,最值得琢磨的不是 YOLOv3 的结构,而是它在这个场景里被迫做出的三个让步。
第一个让步是精度上限。单阶段检测器在极小目标上的定位精度天然不如两阶段,这是结构性差异,不是调参能抹平的。接受这个让步的前提是业务对"框得多准"要求不高——应急处置要知道的是"这车装了第几类危险品",框稍微偏一点不影响判断。如果换成需要精确测量标志牌尺寸的任务,这个方案就不合适了。
第二个让步是分辨率。4K 原图缩到几百像素的输入,信息损失是实打实的。替代方案是切片推理或者车辆区域二次裁切,但两者都会增加延迟和后处理复杂度。选缩图,本质上是选了一个确定的延迟预算,把不确定性留给精度。
第三个让步是类别粒度。18 类细分里有相当一部分类别的样本量撑不起一个可靠的分类头,把它们合并到 5 类属性层,是向数据现实低头。这个让步在工程上完全合理,但必须写进文档,否则下游系统会以为拿到的是全粒度结果。
这三个让步有一个共同点:都是在"数据不够、算力有限、时间很紧"的约束下做出的,而不是技术偏好。判断一个检测方案好不好,不看它用了多新的结构,看它的每一个取舍能不能对应到一个真实的约束条件上。对不上,就是过度设计或者设计不足。
注意:这套方案在原研究里报告的是"在不同照度和天气下都有较好鲁棒性",但没有给出分层量化的指标。复现时不能把这句话当成验收标准,必须自己按光照、天气、距离分桶测一遍。
验证这套系统是否真的能落地,我一般会走三步:先用固定的一批历史卡口图跑离线评估,逐类看 AP 和漏检率;再把模型放到边缘设备上测端到端延迟,注意是端到端——包含解码、NMS、上传,不是只测网络前向;最后拿真实的实时流跑一周,看队列深度和丢帧率。前两步能过、第三步过不了的方案,我见过太多次。
在那之后,我每接一个类似的小目标实时检测任务,都会先算一笔账:目标在原图里有多少像素、缩到网络输入后还剩多少、最细那层特征图的 stride 是多少。这三个数一算完,方案能不能成基本就有数了,剩下的都是工程细节。希望这套思路能帮到你。
本文还有配套的精品资源,点击获取