1. 锥桶识别这件事,难在哪儿
第一次接锥桶识别需求的时候,我以为这是个送分题。橙色、圆锥形、纹理单一、背景通常是路面,按直觉应该比行人车辆好检。真跑起来才发现,这个目标物几乎集合了所有让检测模型难受的特性:远距离下只有十几个像素、密集摆放时互相遮挡、逆光和隧道口进出时颜色剧烈跳变、被压扁的、倒地的、只剩半截的、贴着广告贴纸的,全都会出现在真实路面上。更麻烦的是,业务侧对它的要求从来不是"能框出来就行",而是要在几十米开外稳定给出位置,还要保证连续帧不闪烁。
YOLOv5 是这套任务里性价比最高的起点。它属于单阶段检测器,一次前向就出框和类别,结构上由 Backbone(CSPDarknet)、Neck(PANet 结构的特征融合)和 Head(三个尺度的检测头)组成,输入默认 640×640,下采样总步长 32,也就是说特征图上最小的格子对应原图 32 像素。这个数字决定了它对小目标的天然上限——一个 20 像素高的锥桶,在最小特征图上连一个格子都占不满,模型只能靠浅层特征勉强记住它。
我把这篇文章写给三类人:准备用 YOLOv5 做交通场景目标检测的算法同学、需要在边缘设备上跑实时推理的工程同学、以及手上有数据但训练结果总是不理想、想搞清楚每一步为什么这么做的实践者。下面提到的所有流程、参数和踩坑记录,都来自我自己反复跑过的几轮迭代,其中数据集是自建的,场景以城市道路、施工路段、停车场和园区内部路为主。文中出现的设备和环境都用代称,参数则是实测下来比较稳的取值,你可以直接拿去改。
2. 整体方案设计与技术选型拆解
2.1 为什么是 YOLOv5,而不是两阶段检测器或更新的版本
选型这件事,我一般看三个维度:精度够不够、延迟能不能接受、工程链路成不成熟。锥桶识别属于典型的实时感知任务,下游通常还要做车道关联、可行驶区域判断,单帧留给检测的时间预算往往只有 20~30 毫秒。
两阶段检测器(Faster R-CNN 那一类)在精度上确实有优势,尤其是小目标和密集遮挡场景,但它需要先出候选区域再逐个分类回归,推理耗时通常是单阶段的两到三倍,而且部署时后处理逻辑复杂,边缘设备上很难压到实时。锥桶这个类别本身语义单一,没有细粒度分类需求,两阶段那点精度增益在实际场景里换不回延迟代价。
至于更新的版本,理论上精度更好,但如果你的团队已经有 YOLOv5 的训练脚本、导出流程和部署代码,换版本意味着整条链路重新验证一遍,包括数据格式、增强策略、导出算子兼容性、后处理参数全部要重调。我在一个项目里试过中途换版本,结果 ONNX 导出后某个算子在某款推理框架上不支持,回退折腾了两天。所以我的建议是:新项目可以评估新版,已有链路稳定的情况下,把精力放在数据和后处理上,收益远大于换模型。
YOLOv5 本身按宽度和深度分成 n/s/m/l/x 五档。锥桶是单类别、形状规整的目标,我实测 s 档(约 720 万参数)在自建数据集上就能到 mAP@0.5 0.93 左右,m 档大概再高 1.5 个点,但推理耗时增加约 80%。如果部署在算力紧张的边缘盒子上,s 甚至 n 都够用;如果只跑在服务器端做离线分析,直接上 m 更省心。
| 模型档位 | 参数量(约) | 640 输入单帧延迟(桌面 GPU,FP16) | 适用场景 |
|---|---|---|---|
| n | 1.9M | 4~6 ms | 边缘设备、多路视频并行 |
| s | 7.2M | 7~10 ms | 主流选择,性价比最高 |
| m | 21.2M | 14~18 ms | 服务器端、精度优先 |
| l | 46.5M | 25~30 ms | 离线分析、远距离小目标 |
提示:上表延迟是桌面级 GPU 上 batch=1、FP16 的实测区间,具体数值受驱动、TensorRT 版本、显存带宽影响很大,不要直接当成选型依据,一定要在自己的硬件上跑一遍。
2.2 整条链路的四个环节与各自的验收标准
很多人做这类项目,习惯先把模型训出来再看效果,结果发现指标挺好但现场不能用。我的做法是先定验收标准,再倒推每个环节要做到什么程度。
环节一:数据采集与标注。验收标准不是"标了多少张",而是"覆盖了多少种真实工况"。我一般按场景维度列表格:白天顺光、白天逆光、黄昏、夜间有路灯、夜间无路灯、雨天、隧道出入口、密集摆放、单桶远距离、倒地桶、部分遮挡。每个格子至少 200 个实例,缺哪个补哪个。
环节二:训练与验证。验收标准是验证集 mAP@0.5 ≥ 0.90,同时召回率在置信度 0.25 阈值下 ≥ 0.92。只看 mAP 容易被高分框拉高,实际漏检才是现场最痛的问题。
环节三:部署与性能。验收标准是端到端延迟(含预处理和后处理)≤ 30 ms,连续 1000 帧无内存增长,FP16 量化后精度掉点 ≤ 1 个 mAP 点。
环节四:现场回归。拿一段没参与训练的真实视频跑一遍,人工数漏检和误检。这一步最容易被省略,但恰恰是最能暴露问题的。我遇到过验证集 0.94、现场视频里隧道口连续漏检的情况,原因是训练集里隧道场景只有几十个实例,模型根本没学会那种低对比度下的橙色。
注意:四个环节的验收标准要在动手前和需求方对齐,尤其是"多远算远""漏检多少次算不可接受"。这类指标一旦模糊,后期会陷入无休止的返工。
2.3 数据流和目录结构:先把地基打平
YOLOv5 对数据组织方式有固定约定,早期把目录搞乱,后面每次换数据集都要重新整理。我的习惯是固定一套结构,所有项目共用:
cone_dataset/ ├── images/ │ ├── train/ │ ├── val/ │ └── test/ ├── labels/ │ ├── train/ │ ├── val/ │ └── test/ └── cone.yaml划分比例我一般用 8:1:1,但如果场景种类多、单场景样本少,会改成按场景分层抽样,保证验证集里每个场景都有实例。随机划分在这类任务里是个坑:同一段视频抽出来的相邻帧高度相似,随机分到训练集和验证集会造成"数据泄漏",验证指标虚高好几倍。正确做法是按视频片段或按采集批次划分,同一段连续视频只能整体进一个集合。
cone.yaml的写法很直接:
path: ../cone_dataset train: images/train val: images/val test: images/test nc: 1 names: ['cone']单类别时nc: 1,标签文件里每行是0 x_center y_center width height,全部归一化到 0~1。这里有个细节:如果后续想区分"直立锥桶"和"倒地锥桶",不要急着加类别,先看业务是否真的需要区分。加一个类别意味着模型要在特征空间里多分一瓣,小目标本来就特征弱,分成两类后每一类的样本量减半,精度通常会掉。我一般先用单类别跑通,确有需要再做多类别。
3. 数据集构建与标注规范
3.1 采集策略:场景覆盖比数量更重要
我见过太多"两万张图训不出效果"的案例,翻开一看,八成是从同一段路上连续抽帧,画面几乎一模一样。这种数据集对模型的价值极低,因为信息冗余度太高。
我的采集原则是"三三制":三分之一来自固定机位长时间录制(覆盖光照变化),三分之一来自移动采集(覆盖视角和距离变化),三分之一来自主动构造的极端场景(近距离密集摆放、远距离单桶、倒地、遮挡)。每类场景我至少保证 1500 个标注实例,低于这个数量模型很难稳定学到特征。
具体到锥桶,有几个必须覆盖的工况:
- 距离梯度:从 5 米到 60 米,每隔 5 米都要有样本。远距离小目标是精度的主要瓶颈,如果训练集里 80% 的框都是大框,模型会偏向预测大目标。
- 光照极端值:正午强光下的高光溢出、逆光剪影、夜间车灯直射、隧道出入口的明暗突变。逆光时锥桶的橙色会变成接近黑褐色,和路面几乎同色,这类样本不补,现场必漏。
- 遮挡形态:被前车挡住下半部分、被护栏挡住一半、两个锥桶前后重叠。遮挡样本的标注要框住可见部分还是推断完整范围?我的做法是框住可见部分,让模型学会"看到多少框多少",因为推断完整范围会让框的位置在遮挡时严重偏移,下游做距离估计会出错。
- 形态变异:倒地的、被压扁的、套了反光膜的、贴了广告的、颜色褪成粉白的。这些在施工路段非常常见。
3.2 标注规范:那些只有踩过才知道的细节
标注质量对最终精度的影响,我个人的体感是超过模型选型的。同一个数据集,标注规范不统一和规范统一,mAP 能差 5 个点以上。
框的边界怎么定。锥桶有个底座,底座通常比桶身宽。很多标注同学会把底座一起框进去,也有人只框桶身。两种都能训,但必须统一。我选的是"框住可见的完整轮廓,包括底座",理由是底座是锥桶的一部分,框进去后框的高宽比更稳定,有利于后续用长宽比做误检过滤。
长宽比先验。标准锥桶的高宽比大约在 1:2.5 到 1:3 之间(高:宽),倒地后接近 3:1。这个先验在部署阶段非常有用,可以在后处理里过滤掉明显不符合比例的可疑框,比如模型把一块橙色广告牌框成了锥桶,比例一看就不对。所以我要求标注时严格贴合,不要为了"好看"把框画得方正。
最小标注尺寸。低于 8×8 像素的实例我建议直接不标。原因很实际:这种目标在 640 输入下经过 letterbox 缩放后只剩 2~3 个像素,模型学不到有效特征,标了反而引入噪声,让损失函数在这些样本上反复震荡。如果业务要求必须检出这类极远目标,正确做法是提高输入分辨率,而不是硬标。
模糊样本的处理。运动模糊到看不清轮廓的、雨幕严重遮挡的,我的原则是"能确定是锥桶就标,不能确定就不标"。存疑的样本宁可丢弃,也不要猜。模型对错误标签的容忍度远低于你的想象。
提示:标注完成后一定要做一轮交叉复核,随机抽 10% 让另一个人重新标,然后算一下 IoU 一致性。如果一致率低于 0.85,说明规范写得不够细,回去补规范再重标,别急着训练。
3.3 数据增强的参数取舍:默认值不是万能钥匙
YOLOv5 的增强参数都在hyp.yaml里,默认值是为通用数据集调的,直接套到锥桶场景会有问题。我逐个说。
颜色增强(hsv_h / hsv_s / hsv_v)。默认 0.015 / 0.7 / 0.4。锥桶的颜色是强判别特征,但真实场景里颜色变化极大——夜间偏暗红、逆光偏黑、褪色后偏粉白。所以饱和度增强我保留 0.7,亮度增强保留 0.4,但色相增强我降到 0.008,因为色相大幅偏移会把橙色推成黄色或红色,模型可能学到错误的颜色先验,遇到真橙色反而犹豫。
几何增强(degrees / translate / scale / shear / perspective)。默认 scale=0.5,这个幅度对锥桶偏大。锥桶是小目标,缩放 0.5 倍后再经过 mosaic 拼接,很容易变成几个像素的噪点。我通常把 scale 降到 0.3,translate 保持 0.1,degrees 设为 0(车载摄像头基本没有旋转),perspective 保留 0.0005 用于模拟视角变化。
Mosaic 增强。默认 1.0,四张图拼一张,是小目标检测的利器,因为它让模型在一张图里看到更多的小目标实例。但 mosaid 有个副作用:拼接边界处会出现不自然的截断,模型可能学到"边缘有锥桶"这种虚假关联。我在训练后期(最后 10 个 epoch)会把 mosaic 关掉,让模型在真实分布上做最后的收敛,这个技巧对小目标任务提升明显,一般能涨 0.5~1 个点。
mixup 和 copy_paste。默认都是 0。mixup 对锥桶这种单类别任务收益不大,反而会让小目标更模糊,我保持关闭。copy_paste 是分割任务用的,检测任务不用管。
# hyp.yaml 关键项修改示例 hsv_h: 0.008 # 色相降幅,保护橙色先验 hsv_s: 0.7 hsv_v: 0.4 degrees: 0.0 # 车载视角无明显旋转 translate: 0.1 scale: 0.3 # 缩放幅度降低,保护小目标 shear: 0.0 perspective: 0.0005 flipud: 0.0 # 上下翻转在道路场景不成立 fliplr: 0.5 # 左右翻转合理 mosaic: 1.0 mixup: 0.0注意:
flipud一定要设 0。上下翻转后的锥桶是"倒挂"的,现实中不存在,模型学到的特征会跑偏。这个参数在通用数据集上默认是 0,但有人为了增加数据量随手改成 0.5,这是在给自己挖坑。
4. 模型训练与超参配置
4.1 环境与版本:先把地基锁死
复现性在这类项目里比想象中重要。我踩过最亏的一次坑,是同一个脚本在另一台机器上训练结果差了 3 个点,查了半天发现是驱动版本不同导致的非确定性算子行为。所以现在我的习惯是把环境写成文件,锁死版本。
# 建议用虚拟环境隔离 python -m venv cone_env source cone_env/bin/activate pip install torch==1.13.1 torchvision==0.14.1 pip install -r requirements.txt关键版本组合我实测稳定的是 PyTorch 1.13 + CUDA 11.7 这一套,往上的 2.x 版本也能跑,但某些增强算子的行为有细微差异,换版本后建议先跑 5 个 epoch 对比 loss 曲线,确认没有异常再全量训练。
另外,训练前务必确认这几件事:数据路径没写错(cone.yaml里的path是相对路径还是绝对路径)、标签文件数量和图片数量一致、没有空的 label 文件(空文件会被当成负样本,如果本意不是加入背景图,会造成类别不均衡)。我写了个两行的小检查脚本,每次换数据集都跑一遍:
import glob, os imgs = set(os.path.splitext(os.path.basename(p))[0] for p in glob.glob('images/train/*')) lbls = set(os.path.splitext(os.path.basename(p))[0] for p in glob.glob('labels/train/*')) print('图片数', len(imgs), '标签数', len(lbls)) print('缺标签', list(imgs - lbls)[:10]) print('多余标签', list(lbls - imgs)[:10])这段代码很短,但帮我省过好几次事故——有一次某个子目录的标签漏拷,模型训练时自动跳过了那些图,指标看着还行,实际训练集少了两千张。
4.2 训练命令与超参计算过程
基础训练命令:
python train.py \ --img 640 \ --batch 16 \ --epochs 150 \ --data cone_dataset/cone.yaml \ --weights yolov5s.pt \ --hyp data/hyps/hyp.cone.yaml \ --project runs/train --name cone_v1 \ --cache每个参数都有讲究,我一个个拆。
--img 640。这是输入分辨率。前面说过,YOLOv5 的下采样总步长是 32,640 输入对应最小特征图 20×20,每个格子覆盖原图 32×32 的区域。远距离的锥桶如果只占 15 个像素高,在最小特征图上根本无法表达。如果你的业务对 40 米外的锥桶有硬性要求,两个选择:把输入提到 960 或 1280(耗时按面积增长,960 约是 640 的 2.25 倍),或者改用带 P2 检测头的结构(在 stride=4 的特征图上加检测头,代价是显存和耗时)。我用 960 输入在远距离场景上实测召回率提升了约 6 个点,代价是延迟从 9 ms 涨到 20 ms,需要看你的预算。
--batch 16。批大小和显存直接相关。8G 显存的卡,640 输入、s 档模型、开启 mosaic,batch 16 大概占用 6.5G,是比较稳的。如果爆显存,先降 batch 再考虑降 img,不要反过来,因为输入分辨率对精度的影响远大于批大小。
学习率缩放。默认lr0=0.01是针对 batch 64 左右调的。如果我用 batch 16,按线性缩放规则应该是 0.01 × 16/64 = 0.0025。但实践中完全线性缩放在小 batch 下偏保守,收敛慢。我的经验值是 batch 16 用 0.005~0.008,batch 32 用 0.01。判断标准看前 5 个 epoch 的 loss 曲线:如果 loss 下降过慢且震荡小,说明学习率偏小;如果 loss 剧烈震荡甚至上升出现 NaN,说明偏大。
训练轮次。单类别、一万张量级的数据集,150 epoch 基本够。但我更看重的是"什么时候停",而不是固定轮次。判断依据是验证集 mAP 连续 20 个 epoch 没有提升,就认为收敛了。YOLOv5 自带--patience参数可以设置早停,我用 30。
--cache。把图片缓存到内存或磁盘,能显著加速数据加载。但如果数据集超过内存容量,加这个参数会拖慢甚至崩溃。我的判断是:数据集体积低于内存的 60% 就加,否则用--cache disk或者干脆不加。曾经在一个 40G 数据集上加了--cache,机器直接卡死,排查了半天才想到是这个参数。
4.3 训练过程中的监控:从曲线里读出问题
训练启动后,runs/train/cone_v1/下会生成 loss 曲线、mAP 曲线、PR 曲线和一排验证集预测图。我一般重点看三张图,而且只看它们的前 30 个 epoch,因为后期曲线趋于平缓,看不出问题。
第一张:三个 loss 分量的相对关系。YOLOv5 的损失由box_loss(定位)、obj_loss(置信度)、cls_loss(分类)组成。单类别任务里cls_loss应该很快降到很低,如果它一直居高不下,说明标签有问题——最常见的是类别索引写错,或者某批数据的标签格式不对。
box_loss和obj_loss的相对关系更能说明问题。如果obj_loss明显偏高而box_loss正常,通常意味着背景误检多,模型对"什么不是锥桶"学得不好,这时候应该增加纯背景图(不含任何锥桶的路面图)。我一般会往训练集里掺 2%~5% 的背景图,比例太高会让模型过于保守,召回率下降。
第二张:mAP@0.5 和 mAP@0.5:0.95 的差距。这两个指标的差值反映了定位精度。如果 mAP@0.5 有 0.92 但 mAP@0.5:0.95 只有 0.55,说明模型能大致找到位置但框不准。原因通常是标注框不一致,或者目标尺度分布太集中。我遇到过一次,查下来是两位标注同学对底座的处理不一致,一半框了底座一半没框,统一之后 mAP@0.5:0.95 直接涨了 8 个点。
第三张:验证集的预测可视化图。这张图的信息量最大。我一般会重点看三类样本的预测:远距离小目标、密集摆放、逆光场景。如果远距离目标普遍漏检,看框的置信度是不是集中在 0.1~0.3 之间——如果是,说明模型"隐约感觉到了但不敢确认",这时候降低推理时的置信度阈值能救回来一部分,但根本解决还是要提升输入分辨率或补远距离样本。
4.4 一个容易被忽略的点:验证集要定期换血
我有个习惯,每轮迭代结束后,从新采集的数据里抽一批加入验证集,同时把模型在旧验证集上表现最好的那部分样本移出。原因是模型会"记住"验证集,尤其是当验证集只有几百张图时,反复调参很容易过拟合到验证集上。
判断是否过拟合验证集的方法很朴素:拿一段完全没碰过的新视频跑一遍,人工统计漏检率。如果新视频上的表现比验证集差 10 个点以上,说明验证集已经失去代表性了。我一般每完成一轮调参就做一次这样的"盲测",虽然费时间,但能避免上线后翻车。
5. 指标评估与定向调优
5.1 mAP 之外,业务真正关心的指标
mAP 是学术指标,业务侧关心的是另外几个数字,我通常在评估阶段就把它们算出来。
不同距离段的召回率。把验证集按标注框的高度分成四档:大于 80 像素(近距)、40~80 像素(中距)、20~40 像素(远距)、小于 20 像素(超远距),分别统计召回率。这个拆解能立刻暴露模型的短板。我最近一轮的结果是近距 0.97、中距 0.94、远距 0.81、超远距 0.42,很明显短板在远距离,后续优化就围绕这个展开,而不是盲目调全局参数。
误检率。统计每千帧的误检次数,并且要分类:是把路面反光误检了,还是把橙色衣物误检了,还是把其他橙色物体误检了。分类之后才能针对性解决。
连续帧稳定性。这个指标很少有人测,但对下游影响巨大。方法是跑一段视频,统计同一个锥桶在连续帧里被检出的比例。如果模型是"三帧检出两帧漏",即使单帧 mAP 很高,下游的跟踪和距离估计也会疯狂抖动。我的经验是,稳定性差往往不是模型问题,而是推理时的置信度阈值卡在了模型的"犹豫区间",把阈值从 0.25 降到 0.2 通常能显著改善。
| 评估维度 | 我的验收线 | 测量方法 |
|---|---|---|
| 近距召回(框高>80px) | ≥ 0.95 | 按框高分段统计 |
| 远距召回(框高20~40px) | ≥ 0.80 | 同上 |
| 误检率 | ≤ 3 次/千帧 | 人工复核 |
| 连续帧检出率 | ≥ 0.90 | 视频逐帧统计 |
| 端到端延迟 | ≤ 30 ms | 含预处理与 NMS |
5.2 漏检和误检的定向优化手段
漏检为主时,按这个顺序排查。第一,看漏检样本的尺度分布,如果集中在远距离,优先提高输入分辨率或补远距样本,不要动模型结构。第二,看漏检样本的置信度分布,如果大量落在 0.1~0.3,说明模型其实检测到了,只是分数低,把推理阈值调到 0.2 并配合后续的跟踪平滑,就能救回来。第三,检查是否有系统性偏差,比如所有隧道口的样本都漏,那就是训练集覆盖不足,回去补数据。第四,才是考虑换更大的模型档位。
误检为主时,我常用的三个手段。一是加背景图,把不含锥桶的路面、橙色广告牌、施工围挡单独截出来作为负样本,比例 3%~5%。二是加长宽比过滤,在 NMS 之后加一道规则:框的高宽比不在 1.2~4.0 范围内的直接丢弃,这一条能干掉大部分把平面橙色物体误检成锥桶的情况。三是加颜色校验,在框内统计橙色像素占比,低于阈值就丢弃,但这招在夜间会误杀,需要配合亮度判断,实现复杂度高,我一般放到最后才用。
一个具体的调优案例。早期版本在隧道出口处误检严重,模型把出口处的一块橙色施工标志牌反复检出。我先补了 800 张含该标志牌的负样本,误检下降了一半但没根除。后来分析发现,标志牌的框高宽比接近 1:1,而锥桶是 1:2.8 左右,于是加了长宽比过滤:
def filter_by_aspect(boxes, min_ratio=1.2, max_ratio=4.0): keep = [] for i, (x1, y1, x2, y2) in enumerate(boxes): w = max(x2 - x1, 1e-6) h = max(y2 - y1, 1e-6) ratio = h / w if min_ratio <= ratio <= max_ratio: keep.append(i) return keep加上这道过滤后,该场景误检基本清零,同时因为锥桶本身比例稳定,召回率没有任何损失。这类"利用目标形状先验"的后处理,是工业落地里性价比最高的手段之一。
6. 推理部署与性能压榨
6.1 导出 ONNX 与 TensorRT:那些报错背后的原因
训练完的best.pt不能直接上生产,一般要转成 ONNX 再转 TensorRT 引擎。导出命令:
python export.py \ --weights runs/train/cone_v1/weights/best.pt \ --include onnx \ --img 640 640 \ --batch 1 \ --simplify \ --opset 12几个参数的经验:--simplify一定要加,它会跑一遍 onnx-simplifier,把冗余算子合并掉,某些推理框架对未简化的图支持不好。--opset 12是我实测兼容性最好的版本,opset 13 以上在某些老版本 TensorRT 上会遇到Resize算子不支持的问题。--batch 1是给实时推理用的,如果做离线批处理再设大。
转 TensorRT 时最常见的两个坑:一是动态 shape 没设对,导出时用了固定 640×640,推理时送入 1280×720 的图就会报错。要么在导出时指定--dynamic,要么在推理前自己做 letterbox 到 640×640。我选后者,因为动态 shape 会让 TensorRT 无法充分优化,延迟反而更高。二是 FP16 精度掉点,量化后如果 mAP 掉了超过 1 个点,检查一下是不是某些层的数值范围超出了 FP16 表示能力,可以在转 TensorRT 时对这些层保持 FP32。
6.2 预处理和后处理:最容易出错的地方
letterbox 的坐标还原。YOLOv5 推理时会把原图等比缩放到 640×640,不足的部分用灰色填充。这意味着输出的框坐标是在填充后的图上的,必须还原回原图坐标,否则框会整体偏移。公式不复杂:
# r 为缩放比例,dw/dh 为左右/上下填充量 r = min(640 / w0, 640 / h0) new_w, new_h = round(w0 * r), round(h0 * r) dw, dh = (640 - new_w) / 2, (640 - new_h) / 2 # 还原:先减填充,再除以缩放比 x1 = (x1_padded - dw) / r y1 = (y1_padded - dh) / r x2 = (x2_padded - dw) / r y2 = (y2_padded - dh) / r这个还原我见过至少三个项目写错过,症状是框整体往左上或右下偏,偏移量随图片宽高比变化。写完之后一定要拿一张已知目标位置的图验证,比如手动在图上画个标记,看还原后的框是否对齐。
NMS 的阈值取舍。锥桶经常密集摆放,前后重叠严重。iou_thres设得太低(比如 0.3),相邻的两个锥桶会被合并成一个,造成漏检;设得太高(比如 0.7),同一个锥桶可能出现多个重叠框。我的经验值是 0.45~0.5,密集场景取 0.5。另外 YOLOv5 默认用的是 class-agnostic NMS,单类别任务无所谓,多类别时要注意会不会跨类别抑制。
后处理耗时。很多人只关注模型前向耗时,忽略了 NMS。在检测框数量多的场景(比如一帧里 50 个锥桶),Python 实现的 NMS 可能要 3~5 ms,占总延迟的 20%。优化办法是用torchvision.ops.nms的 GPU 版本,或者把 NMS 也一起导出到 TensorRT 里(YOLOv5 的export.py支持--include engine时把 NMS 融进去,但需要特定版本配合)。
6.3 边缘设备上的实测数据与取舍
不同硬件的实测数据差别很大,我列一组自己测过的参考值,都是 640 输入、YOLOv5s、FP16、包含前后处理的端到端延迟:
| 平台类型 | 端到端延迟 | 可稳定运行的视频路数 | 备注 |
|---|---|---|---|
| 桌面级独立显卡 | 12~15 ms | 8 路以上 | 显存充足,可开更大 batch |
| 嵌入式模组 A(中端) | 28~35 ms | 2~3 路 | 需降分辨率到 512 |
| 嵌入式模组 B(入门) | 55~70 ms | 1 路 | 建议换 n 档模型 |
| 纯 CPU(服务器级) | 180~250 ms | 不推荐 | 仅适合离线分析 |
入门级模组上跑不动 s 档是常态,我的处理办法是换 n 档 + 输入降到 512,实测延迟能压到 35 ms 左右,精度掉约 3 个点。这个取舍要看业务能不能接受,如果只是做锥桶计数或者区域入侵判断,3 个点完全可以接受;如果要做精确的距离估计,就得考虑加一路专门的算力。
提示:边缘设备上一定要做长时间稳定性测试。我遇到过跑 20 分钟就内存缓慢增长的情况,原因是每次推理都新建了一个 tensor 没释放,短时间测试根本发现不了。测试方法很简单:循环推理 5000 次,每 500 次打印一次内存占用,看曲线是否平直。
7. 常见问题排查速查表
7.1 训练阶段的问题与排查路径
| 现象 | 最可能的原因 | 排查动作 |
|---|---|---|
| loss 出现 NaN | 学习率过大或标注坐标越界 | 检查标签是否有 >1 或 <0 的值,lr 降到 0.003 |
| mAP 一直不动 | 数据路径错、标签全空、类别数不匹配 | 打印一个 batch 的标签看看 |
| 训练集 mAP 高、验证集低 | 数据泄漏或过拟合 | 检查是否按视频片段划分,加增强 |
| 只有大目标能检出 | 小目标样本不足或输入分辨率低 | 统计框高分布,考虑提到 960 |
| 误检集中在某一类物体 | 缺负样本 | 针对性补背景图 |
| 训练后期指标突然下降 | 学习率衰减策略或增强过强 | 关掉 mosaic 观察最后 10 轮 |
这里重点说"标签坐标越界"这一条。归一化坐标理论上在 0~1 之间,但标注工具在边界处可能导出 1.0001 这样的值,YOLOv5 在某些版本里不会报错,而是直接算出异常的 loss,跑几十轮后才崩。我的做法是训练前跑一遍校验:
import glob bad = [] for f in glob.glob('labels/train/*.txt'): for line in open(f): vals = list(map(float, line.split()[1:])) if any(v < 0 or v > 1 for v in vals): bad.append(f); break print('越界标签数', len(bad))7.2 部署阶段的问题与排查路径
| 现象 | 最可能的原因 | 排查动作 |
|---|---|---|
| 框整体偏移 | letterbox 还原公式错 | 用已知位置的图验证 |
| 推理结果全是同一类 | 类别映射表读错 | 核对 names 顺序 |
| ONNX 转 TensorRT 失败 | 算子不支持或 opset 不兼容 | 加 --simplify,opset 降到 11/12 |
| 精度比 PyTorch 掉很多 | 预处理归一化不一致 | 检查是否除以 255,通道顺序是否 BGR/RGB |
| 延迟波动大 | 显存不足导致换页 | 降 batch,检查是否有其他进程占卡 |
| 长时间运行崩溃 | 内存泄漏 | 循环推理 5000 次观察内存曲线 |
"预处理不一致"这个坑我必须单独说。YOLOv5 训练时输入是 RGB、除以 255 归一化到 0~1。但很多图像采集库默认给的是 BGR,如果部署时忘了转,模型看到的是通道颠倒的图,橙色会变成蓝色,精度直接崩到接近随机。这个错误的表现很迷惑:模型能检出一些框,但位置和置信度都很奇怪。排查方法很简单,拿同一张图分别用 PyTorch 原版推理和部署版推理,对比输出的框,差异大就是预处理问题。
7.3 现场落地阶段的问题
锥桶被检出但 ID 频繁跳变。这是检测后面的跟踪环节的问题,不是检测本身的锅。但如果检测的置信度在阈值附近抖动,跟踪必然不稳定。解决办法是给检测输出做一点时间上的平滑,比如连续三帧都检出才确认,或者用简单的滑动平均平滑框的位置。
夜间误检率飙升。夜间图像噪声大,橙色在路灯下会偏黄,模型容易把车灯反光、路面标线误检成锥桶。我的处理是夜间场景单独补数据,同时在部署时加一道亮度判断:图像整体亮度低于某阈值时,把置信度阈值从 0.25 提到 0.4,宁可漏检也不误检,因为夜间误检对下游的干扰远大于漏检。
雨天性能下降。雨幕会让图像整体模糊,小目标直接消失。这个靠模型解决不了,只能靠补雨天数据 + 适当提高输入分辨率。我在雨天数据上实测,960 输入比 640 输入的召回率高 9 个点,提升非常明显,因为高分辨率保留了更多细节。
8. 我个人在这几轮迭代里的几点体会
数据集的价值被严重低估了。我前后三轮迭代,模型结构没动过,光是把标注规范统一、补了远距离和逆光样本、加了 4% 的负样本,mAP@0.5 就从 0.87 涨到 0.94,远距离召回从 0.68 涨到 0.83。相比之下,我从 s 换到 m 只涨了 1.5 个点,延迟却翻倍。如果只能改一件事,改数据。
输入分辨率的取舍要早点想清楚。很多人一开始用 640 跑通,等上线才发现远距离不行,这时候改分辨率意味着所有后处理参数、部署引擎、性能预算都要重来。我的建议是项目启动时就按业务的"最远检测距离"倒推需要多少像素,然后一次性定死输入尺寸。
后处理的规则比模型更"聪明"。长宽比过滤、颜色校验、亮度自适应阈值这些规则,加起来能解决 60% 以上的误检问题,而且零延迟成本。这些规则不需要多高深的技术,需要的是对场景的理解——你得知道锥桶长什么样、在什么场景下会出问题。这也是我觉得做这类项目最有意思的地方:模型只是中间产物,真正的功夫在数据和工程细节里。
最后一件事,别迷信验证集指标。我现在的习惯是,每次提交前都拿一段完全没参与训练的真实视频,从头到尾看一遍,手动数漏检和误检。这个过程很枯燥,但它抓到的问题,比任何曲线都真实。