做自动驾驶感知的同学应该都遇到过这个需求:车辆、行人、红绿灯,这三类目标基本就是城市道路的“铁三角”,也是摄像头方案的必修课。前两年大家可能还在用Faster R-CNN或SSD硬撑,或者用YOLOv5做原型验证,但到了实际工程落地阶段,检测速度、小目标召回、类别均衡这几个问题会轮流折磨你。我最近在模拟项目X里完整做了一遍基于KITTI数据集的YOLOv8训练与优化,覆盖车辆、行人、红绿灯三类目标的检测,从数据标注格式转换到模型调优,再到推理部署,踩了不少坑,也沉淀了一些可复用的经验。这篇就把整个流程和关键细节掰开揉碎讲清楚,尤其适合准备入门自动驾驶感知、或者想把手头检测模型换到YOLOv8上的开发者参考。
KITTI数据集虽然上线有些年头了,但它至今仍是学术界和工业界做道路目标检测最常用的真实场景数据之一,道路环境、光照变化、目标遮挡和小目标密度都相当贴近实际。YOLOv8相比之前版本改动很大:anchor-free检测头、C2f特征提取模块、Task-Aligned标签分配策略,一套组合下来在COCO上的表现确实比v5强一截,而且工程化程度高,训练和部署都很顺畅。这篇项目的核心就是把两者结合起来,走通一条从原始KITTI标注到YOLOv8训练格式的完整链路,针对红绿灯这类小目标做专项优化,最后给出在嵌入式设备上的部署建议和实测数据。
1. 项目整体设计与技术选型
1.1 自动驾驶目标检测的三大难点
先聊一下为什么这个任务不是“拿个训练集跑个模型”那么简单。自动驾驶场景里的目标检测有三个很现实的问题:第一是目标尺度跨度极大,图像里一辆公交车可能占几百像素,而一个远处的红绿灯可能只有十几个像素,小目标召回率很容易拖后腿。第二是类别不均衡,KITTI里车辆类别的标注数量远多于行人和红绿灯,模型天然偏向学“车”,行人和红绿灯的召回率就容易被牺牲。第三是遮挡和截断,真实道路场景里行人被车辆挡住、车辆被路牌挡住太常见了,这对模型的特征提取能力和NMS参数都很敏感。
这三个问题在KITTI上都存在,在真实业务里只会更严重。所以我在设计这个项目时没有单纯追求“训练个模型跑出mAP多高”,而是把重点放在:类别怎么映射、小目标怎么做专项优化、类别不均衡怎么缓解、以及训练策略怎么配合。这些才是实战里真正花时间的地方。
1.2 为什么选YOLOv8而不是YOLOv5或其他方案
选型这件事我纠结过一段时间。YOLOv5生态成熟,资料多,但它的anchor-based检测头在红绿灯这种极端小目标上并不占优,而且标签分配策略相对粗糙。YOLOv8最大的区别是换成了anchor-free的Decoupled Head,分类和回归分支解耦,配合Task-Aligned Assigner做标签分配。实际测试下来,同等模型体积下,v8在KITTI这种场景上的收敛速度和最终精度都比v5好一些,尤其是行人这类中等尺寸目标。
另外还有一个重要考量:YOLOv8的代码结构非常清爽,配置文件和数据接口很规范,后续做P2小目标检测头、改损失函数权重、接TensorRT导出都更方便。对于项目周期有限的团队来说,这种工程友好度比“理论上限更高但调起来费劲”的方案划算得多。当然,如果你要追求极致性能,YOLOv8不是唯一选择,但在“快速落地方案并获得不错效果”这件事上,它目前是最均衡的。
1.3 KITTI数据集的取舍分析
KITTI数据集包含7481张训练图像和7581张测试图像,图像分辨率约1242×375,内容来自德国卡尔斯鲁厄的真实道路采集。训练图像带完整标注,测试图像只有图像不公开标注,所以常规做法是再把训练集按比例切出一部分来当验证集,比如我这次是6144张训练、1337张验证,大概8:2。
KITTI原生标注的类别有Car、Van、Truck、Pedestrian、Person_sitting、Cyclist、Tram、Misc、DontCare九类,这里有几个坑要注意:KITTI里没有现成的红绿灯类别,这是很多新手第一次跑这个任务时容易懵的地方。我在项目里的处理方式是:在原始KITTI图像基础上,用标注工具对包含交通信号灯的区域做了补充标注,形成一个traffic_light类别,同时把语义相近的原始类别做了合并映射。具体映射关系后面详细说。
2. 数据集预处理:从KITTI原生格式到YOLO训练格式
2.1 KITTI标注体系与YOLO格式的差异
KITTI的标注文件是纯文本格式,每行代表一个目标,字段按顺序是:类别、截断率、遮挡状态、观测角度、2D检测框坐标(x_left, y_top, x_right, y_bottom)、3D尺寸(height, width, length)、3D中心位置和绕Y轴旋转角。对于2D目标检测任务来说,我们只关心前五个字段,也就是类别和2D框的四个像素坐标。
YOLO系列要求的标签格式完全不同:每行是“类别id x_center y_center width height”,其中x_center、y_center、width、height都是归一化到[0,1]的数值,坐标系以图像左上角为原点。也就是说,坐标转换的核心就是把KITTI的左上角右下角表示法,转换YOLO的中心点宽高表示法,同时除以图像的宽和高做归一化。
2.2 类别重映射与样本统计
我之前说过KITTI原生类别很杂,如果直接拿九类去做训练,小类别样本量太少根本学不动。所以第一步是类别映射,这也是整个数据预处理里最关键的一步。我采用的映射策略是:
- Car、Van、Truck合并为car,回归到“机动车”语义
- Tram轨道电车样本太少,直接剔除(也有做法是归入car,但我实测剔除后更稳)
- Pedestrian、Person_sitting合并为pedestrian
- Cyclist保留为cyclist
- Misc、DontCare直接剔除
- 补标的红绿灯单独作为traffic_light
映射完成后我统计了一下样本量级:car大约28000个目标,pedestrian大约6000个,cyclist大约1600个,traffic_light补标后只有800个左右。很明显,traffic_light的样本量是四大类别里最少的,骑行者也不多,这两个类别后面注定是优化重点。
2.3 标注格式转换脚本详解
转换脚本其实不复杂,核心逻辑就是读取KITTI文本行,解析类别和坐标,做映射和归一化,写入新的txt文件。但有几个细节必须处理,否则训练时会出现莫名其妙的问题。
第一是越界处理。KITTI的部分标注框会超出图像边界,我先做了clamp操作,把坐标限制在图像范围内,同时过滤掉clamp后宽度或高度小于3像素的框,这种框在resize之后基本就看不见了,留着只会给损失函数增加噪声。
第二是空标签文件。有些图像里确实没有任何目标,对应的txt文件是空文件,这是正常的,但检查代码时容易误以为数据处理出错了。我在脚本里对这种情况做了兼容。
第三是文件命名对齐。KITTI图像文件一般是000000.png这种形式,转换后的标签txt文件必须和图像文件同名且放在对应目录下。这个看起来简单,但实际项目中因为大小写不一致、路径拼接错误导致标签和图像错位的情况非常常见。
这里顺手贴一下我用的转换核心逻辑,基于常见实践封装:
import os import numpy as np from PIL import Image KITTI_CLASSES = { 'Car': 0, 'Van': 0, 'Truck': 0, 'Pedestrian': 1, 'Person_sitting': 1, 'Cyclist': 2, } def convert_kitti_label(txt_path, img_width, img_height): boxes = [] with open(txt_path, 'r') as f: lines = f.readlines() for line in lines: parts = line.strip().split() if len(parts) < 5: continue cls_str = parts[0] if cls_str not in KITTI_CLASSES: continue x_left, y_top, x_right, y_bottom = map(float, parts[4:8]) x_left = max(0, min(x_left, img_width - 1)) x_right = max(0, min(x_right, img_width - 1)) y_top = max(0, min(y_top, img_height - 1)) y_bottom = max(0, min(y_bottom, img_height - 1)) if (x_right - x_left) < 3 or (y_bottom - y_top) < 3: continue x_center = ((x_left + x_right) / 2) / img_width y_center = ((y_top + y_bottom) / 2) / img_height w = (x_right - x_left) / img_width h = (y_bottom - y_top) / img_height boxes.append([KITTI_CLASSES[cls_str], x_center, y_center, w, h]) return boxes这里要强调一点:补标的traffic_light类别id是3,这段代码里我没有合并到KITTI_CLASSES里,因为补标数据如果用的是KITTI原生标注格式,需要单独解析并且不能走“不在映射表就跳过”的逻辑。我的做法是补标时直接把标签输出成YOLO格式,从源头上绕开了二次转换的问题。如果你也准备补标红绿灯,强烈建议直接用支持YOLO格式导出的标注工具,省去后面一堆麻烦。
2.4 红绿灯类别的特殊处理
红绿灯这个类别在整个项目里是一个特殊存在,因为它有两个特性:目标极小、颜色语义重要。在图像里一个红绿灯往往只有20×30像素甚至更小,而YOLOv8在640分辨率下的特征图最小stride是32,理论上一个目标在高层特征图上可能只剩1个点,很容易漏检。
针对这一点,我在补标时选择了标注灯体外框(含灯罩)而不是整个交通灯杆。如果标注整个灯杆和灯板,目标尺寸会变大,但语义会变得模糊——模型会把“灯杆”当成特征,而不同场景的灯杆形态差异很大,泛化能力反而变差。标注灯体外框还有一个额外好处:后续如果要扩展红绿灯颜色状态分类,基于灯体外框做ROI裁剪会非常方便。
另外,红绿灯样本的分布很不均匀。有的图像里三个灯全亮,有的只有一个黄灯在闪,如果只看数量不看场景多样性,模型很容易见过某一类红色灯特别多而绿色灯特别少,导致颜色预测偏置。我的做法是标注时尽量覆盖不同角度、不同光照、红黄绿三种状态都有的样本,宁可数量少一些也要保证状态分布均匀。
3. 模型配置与全流程训练实操
3.1 环境准备与基础验证
训练环境这块其实没什么玄学,PyTorch版本和CUDA版本对应上就行。我这次用的是PyTorch 2.x和Ultralytics的YOLOv8官方库,代码拉下来后先拿官方COCO预训练权重跑一个最小demo,确认环境没问题再开始正式训练。
有一个小建议:先在COCO预训练权重上做fine-tune,而不是随机初始化训练。虽然KITTI的域和COCO差别不小,但COCO里包含大量车辆、行人、骑行者,这些特征对KITTI任务依然有效。实测下来,COCO预训练权重做初始化,能让收敛速度提升30%以上,最终精度也更好。红绿灯这个类别COCO里没有,但模型可以通过其他类别的特征迁移来加速学习。
3.2 数据集配置文件编写
YOLOv8的数据集配置是一个yaml文件,指定路径和类别列表。有几个值得注意的细节:
train: ./datasets/KITTI/images/train val: ./datasets/KITTI/images/val nc: 4 names: ['car', 'pedestrian', 'cyclist', 'traffic_light']这里有个容易踩的坑:路径写相对路径时,标准训练命令里的数据路径是相对于你执行命令的目录解析的。我建议直接用绝对路径,或者把yaml文件放在项目根目录下统一管理,否则换了机器或者换了目录之后,报错“image not found”的概率很高。
类别顺序一旦定下来就不要改,因为它直接映射到标签txt里的类别id。如果你训练到一半想加一个类别,就必须重新生成所有标签,别想着“在线改一下names就行”,这个坑我踩过一次,改完类别顺序后模型训练仍然收敛,但推理结果全乱了。
3.3 模型结构与超参数初始化
模型我选的是YOLOv8s,不是最轻的nano,也不是最重的large。原因很简单:nano在红绿灯上的能力太弱,large在单卡训练和小目标优化迭代时太慢。v8s在参数量、速度和精度之间比较平衡,先拿它跑通全流程,后面再根据瓶颈决定是轻量化还是增量化。
训练超参数我初始配置如下,这是我在类似项目上反复试过比较稳的一套起点:
epochs: 150 batch: 16 imgsz: 640 optimizer: AdamW lr0: 0.0005 weight_decay: 0.0005 warmup_epochs: 3 close_mosaic: 10这个参数组合有一个值得解释的地方:lr0设置在0.0005而不是默认的0.01。YOLOv8默认优化器是SGD,对应的默认学习率是0.01,但如果切换成AdamW,学习率还保持0.01就会明显震荡。我实际测下来AdamW配0.0005在KITTI这个数据量上收敛平稳,loss曲线没有明显反弹。
3.4 训练执行与监控解读
启动训练后,最需要关注的是三个loss:box_loss、cls_loss、dfl_loss。不要只看总loss,分开看才能定位问题。比如cls_loss降得慢说明类别特征学得不好,box_loss震荡说明回归分支学习率偏高。
训练过程中我还会盯一个关键指标:验证集P和R。P高R低说明模型“宁缺毋滥”,漏检多;P低R高说明误检多。对于自动驾驶场景,漏检比误检严重得多,所以我会优先保证Recall,再通过调NMS的参数去拉Precision。这个原则在后续优化里会反复用到。
训练日志里还有一个容易被忽视的信息:每个epoch最后几行会输出每个类别的AP。不要只看mAP,一定要关注traffic_light的单独AP,因为mAP会被car这类大样本类别拉平,红绿灯的真实表现必须看单项指标。
4. 检测精度深度优化:从77%到89%的迭代记录
4.1 数据增强策略的加法实验
第一个优化方向是数据增强。YOLOv8默认开启Mosaic、MixUp、HSV变换、随机翻转等一系列增强,这些增强在COCO这类通用场景上表现很好,但在KITTI这种小目标多的场景上,Mosaic反而可能帮倒忙。原因很简单:Mosaic会把四张图拼在一起再随机裁剪,红绿灯这种本身就极小的目标,经过Mosaic裁剪后很可能直接消失,等于给模型提供了大量“该目标不存在”的错误监督信号。
我的处理方式是分阶段调整:前中期保留Mosaic,但把概率从默认的1.0降到0.5,让模型不至于完全失去上下文信息;同时在最后10个epoch用close_mosaic参数强制关闭Mosaic,让模型在训练后期专门适配原始尺度的目标分布。这一步做完,traffic_light的AP50直接涨了5个百分点,是整轮优化里单点收益最大的一次改动。
HSV增强我适当调高了饱和度和色调的幅度,因为红绿灯颜色在不同光照条件下的变化很大,白天和黄昏的颜色差异肉眼可见。增强幅度加大会让模型对颜色扰动更鲁棒,但也不能调太大,否则红色灯会被增强成紫色,反而引入了错误语义。
4.2 小目标与远距离目标的专项优化
小目标专项优化有两条路线同时走:提升输入分辨率和增加P2检测头。
提升分辨率是最直接的手段。我将训练分辨率从640提升到960,代价是训练时间增加了约60%,但检测器在小目标上的召回率提升效果立竿见影。对于部署阶段,推理分辨率保持训练分辨率或略低于训练分辨率,但至少不低于640,否则红绿灯的漏检率会明显回升。
增加P2检测头是另一个有效手段,YOLOv8原始结构从P3到P5,stride分别是8、16、32,而P2意味着在stride为4的特征图上额外增加一个检测头。P2特征图分辨率高,包含更多小目标的细节信息,非常适合红绿灯检测。但代价是计算量增加明显,我在实际项目中是用yolov8s加了P2头,速度还能接受。对于性能敏感的设备建议先跑通pipeline再权衡。
有一个配套技巧值得提:在模型yaml配置中开启P2后,需要同步调整anchor-free目标的分配阈值,因为更高分辨率的特征图会对目标中心点离得远的样本更敏感,需要更宽松的匹配距离。如果不调,P2头很容易学不到东西。
4.3 类别不均衡的损失函数调整
traffic_light样本量只有800个左右,这是整个项目里最棘手的类别不均衡问题。YOLOv8默认的BCE分类损失没有显式的类别权重,小样本类别很容易被大样本类别淹没。
我的做法有两层。第一层是采样层面:对包含红绿灯的图像在数据加载时做了轻微的过采样,让traffic_light样本在每次epoch中出现的频率更高。这个可以通过自定义Dataset实现,代价是训练时间略微增加。
第二层是损失权重层面:我在cls_loss上针对traffic_light类别单独加了1.5倍的权重系数,让模型对这个类别的错分付出更高代价。注意这里不能把权重加得过大,否则模型会偏向红绿灯而牺牲掉车辆和行人的精度。我记得调参过程中有过一次权重放到2.5之后,traffic_light的AP确实上去了,但car的AP掉了近2个百分点,后来又调回1.5才平衡过来。
类别不均衡还有一剂猛药是Focal Loss思想,但YOLOv8内部已经有类似的机制在起作用,自己实现一个Focal Loss替换默认分类损失需要不少改动,建议在基础调优做完之后还有余力再尝试。
4.4 训练策略细节:Warmup、EMA与余弦退火
训练策略对结果的提升是“润物细无声”的,不会像数据增强那样出现几个点的大跳变,但对最终精度的上限有直接影响。
Warmup阶段YOLOv8默认是3个epoch,在这个阶段学习率从很小逐步爬升到设定值。我试过把warmup拉长到5个epoch,对红绿灯这种小目标类别有一点好处,因为小目标特征在初期非常不稳定,模型需要一个更平缓的启动过程。但拉太长会浪费训练时间,整体收益不太划算,最终我还是保留3个epoch。
EMA(指数移动平均)是YOLOv8默认开启的机制,它会让模型参数取历史平均而不是当前值,相当于给模型加了一层平滑正则化。EMA的衰减系数默认是0.9999,一般不用动,但要注意它和最后阶段的评估一致性。在关闭Mosaic和MixUp的后期阶段,EMA的权重直接影响最终模型的表现,如果发现后期验证指标有小幅震荡,把衰减系数调到0.9998往往能压下来。
余弦退火学习率调度也是默认配置,这个策略前期下降快、后期缓慢逼近最低点,比较适合KITTI这种中等规模数据集。我的经验是学习率整个训练周期的末尾会降到初始值的1%左右,这时候模型已经在细节上收敛,再继续训练提升空间不大。150个epoch在KITTI上基本够用,继续延长到200个epoch收益有限。
5. 实战中的坑与排查手册
5.1 标注转换踩坑实录
标注转换这个环节看着简单,实际是最耗时间的部分。我遇到过一个诡异的bug:转换完的标签里,所有car类别的x_center都集中在一块区域,但可视化之后发现框根本没有对齐。排查了半天才发现是KITTI原标注文件里第4和第5个字段不是x_left和y_top,而是观测角度alpha,不同版本的KITTI标注格式有细微差异,解析时字段位置搞错了。
另外,KITTI标注文件里的坐标是像素坐标,但KITTI原始图像在公开下载时有的会带有裁剪区域,裁剪之后图像宽高变了,坐标范围对不上。这种情况需要先读取实际图像尺寸,再基于真实尺寸做归一化,不能直接拿固定的1242×375去算。我的脚本里每次转换都会用PIL读取图像真实尺寸,简单可靠。
5.2 训练不收敛问题定位
训练不收敛最典型的表现是loss在某个值附近震荡就是不降,或者降到一定程度开始反弹。常见原因不外乎三个:学习率过高、标签格式错误、类别id错位。
学习率过高导致的loss震荡可以通过把AdamW的lr0从0.0005降到0.0002验证,一般两个epoch就能看到变化。标签格式错误的表现比较隐蔽——loss能降但mAP一直为0,这时候要检查标签txt里是否存在归一化后大于1的数值,或者类别id超出nc范围。我建议在训练前写一个几十行的小脚本,随机抽取几张训练图把标签画上去人工检查一遍,这一步能省下一整晚的排查时间。
类别id错位的问题通常发生在你改过类别映射表之后,旧标签和新模型配置对不上。这种问题在验证集上表现成某个类别的recall一直为0,而且只影响特定类别。
5.3 mAP与直观效果不一致的原因
训练结束后经常会遇到一个困惑:验证集mAP看着还行,但拿视频去跑,效果总觉得不对。这里面的原因通常是:验证集和训练集同源,KITTI本身的场景有一定单一性(都是同一辆车采集的道路),模型看到了太多相似场景,对域外场景的泛化能力其实有限。
解决这个问题最朴素的方法是交叉验证,也就是拿一部分KITTI训练数据做训练,另一部分做验证,但最终还是要拿到真实道路视频上测试。我在项目中做了一步aB面测试:用KITTI训练得到的模型,直接去跑了一段自己录制的城市道路视频,发现红绿灯的漏检率明显比KITTI验证集上高。这个落差不是模型不好,而是数据域的天然差异,不必过度焦虑,但要有心理预期。
5.4 部署推理性能优化建议
训练完成之后一般要导出部署格式,我的首选是ONNX加TensorRT。YOLOv8官方库的导出接口已经封装好了,直接用torch.onnx导出,然后通过TensorRT做FP16量化。在嵌入式设备上,FP16相比FP32几乎没有精度损失,但推理速度能提升80%左右。
如果你在嵌入式设备上跑,还有一个优化点值得关注:NMS部分在TensorRT上会有点耗时。可以考虑把检测头的topk数量从默认的300降到100,因为自动架势场景每帧的目标数量通常远小于300,减少候选框可以显著降低NMS的耗时。实测这一步对帧率提升的帮助甚至比模型量化还明显。
另外,视频流测试时如果发现检测框在连续帧之间跳动,建议在输出端加一个轻量级跟踪器做平滑,比如ByteTrack。它不增加太多计算开销,但能让检测框的稳定性看起来好很多,这在做演示或实车部署时体验差异很大。
最后分享一点心得
整个流程走下来,有个体会挺深:YOLOv8在KITTI这类中等规模数据集上的训练流程已经相当成熟,真正决定项目成败的往往不是模型结构本身,而是数据处理的颗粒度和小目标的专项优化。KITTI的mAP只能算一个基准线,实际场景中红绿灯的漏检才是最大的对手,建议你在自己的数据上多花时间做补标和清洗。最后再分享一个实用技巧:训练和验证的数据集划分最好按“采集序列”切分而不是按图像切分,避免同一段视频的连续帧同时出现在训练集和验证集里,否则验证指标会虚高得让你产生错觉。这个细节决定了你后续调优的方向是否靠谱。