先说结论:做交通信号检测,最大的瓶颈不是模型,是数据集
去年年中,我接手了一个路侧感知的项目,核心需求很朴素:在十字路口用摄像头识别红绿灯状态,辅助车辆决策。模型方案我几乎没有犹豫就选了YOLO——单阶段检测器,速度足够跑实时,精度经过这么多次迭代早就够用。真正让我耗了两个多月的,是数据集。网上能下载的公开数据集基本都是欧美路口的场景,红绿灯挂在横杆上、箭头灯排列整齐、背景干净,拿到国内实际路口一试,问题全冒出来:信号灯装在路边立杆上、和树木电线杆重叠、倒计时数字和灯体粘连、黄灯闪起来能连闪好几帧……模型在公开集上mAP再高,落到自家路口就是漏检。
所以我今天想聊的重点,不是怎么调参,而是从数据集角度完整拆一遍“国内交通信号检测”这个项目从0到1的落地过程。包括数据是怎么采的、怎么标注才能少返工、YOLO的配置和训练参数怎么定、以及小目标、夜间曝光、灯色混淆这些坑是怎么一个一个踩平的。
如果你正准备做类似的视觉检测项目,或者手里已有YOLO模型但想换到国内交通场景,这篇应该能帮你省下几周试错时间。
1. 项目整体设计:为什么YOLO+国内自建数据集是必须的组合
1.1 国内信号灯场景到底特殊在哪
先泼一盆冷水:现有公开数据集和国内真实路口的差异,大到能直接毁掉一个模型。国外常见路口是悬臂式红绿灯,灯组整体悬挂在路口正上方,视野开阔;国内城市路口则是五花八门——灯杆在右侧人行道边、左转灯放在对向位置、灯组周围有广告牌和绿植遮挡,更别提有些老旧路口还是老的“横式”排列,第一眼根本分不清哪个是左转。
这种差异意味着什么?数据分布不匹配。你用美国街道上拍的照片训练模型,模型学的特征是“大块亮色圆形区域挂在横杆上”;到了国内路口,灯体小、位置偏、背景杂,模型自然判不对。统计学里这叫训练集与测试集分布不一致,放到工程上就是线上召回率断崖下跌。
所以要解决国内场景,就必须有国内道路数据的“地基”。这也是我优先把工程量压在数据侧的原因——模型是算法,数据是粮草,粮草不对,算法再先进也只是在错误的方向上加速。
1.2 YOLO在这里解决了什么问题
交通信号检测有两个硬指标:实时性和准确性。红绿灯状态是连续变化的,车辆决策系统需要拿到当前帧的状态判断“能不能走”,延迟每增加100毫秒都可能带来隐患。YOLO这类单阶段检测器把目标分类和框回归统一在一个网络里一次前向完成,以我用的YOLOv8s为例,TensorRT FP16下推理延迟能压到10ms级别,完全满足实时要求。
当然,两阶段检测器比如Faster R-CNN精度可能更高,但推理速度通常慢一倍以上。在路侧摄像头这种场景里,检测目标数量不多、类别也少(红、绿、黄、左转红,基本就这几个),YOLO的精度劣势已经被数据量和训练技巧补回来了,速度优势却是两阶段方案给不了的。这就是选型的核心逻辑:在特定任务里,工程最佳不一定是学术最优。
1.3 项目链路总览
梳理一下整个项目的流转路径:
- 数据采集:行车记录仪视频、路侧摄像头视频、手机手持拍摄,覆盖多个时段路段。
- 数据清洗与抽帧:去重、去模糊、平衡场景分布。
- 标注:使用开源标注工具,规范类别和框选规则。
- 预处理与增强:统一尺寸、色彩增强、模拟夜间场景。
- 训练与调优:划分训练验证集,配置YOLO训练参数,迭代评估。
- 部署:导出ONNX/TensorRT,接入实际路口测试。
在这个链路里,数据采集和标注占了我大约七成的时间。这一点必须开宗明义讲清楚:不要指望模型能弥补数据的缺陷,前期多花的时间,后期都会在精度上赚回来。
2. 数据采集与预处理:从零攒出一套能用的国内交通信号集
2.1 采集渠道怎么找性价比最高
数据来源不一定要自己扛着相机去路口蹲一天。我实际用的组合是三个方向并行铺开。
第一,公开行车记录仪视频。B站、抖音上大量长距离路跑视频,城市道路全景多、镜头稳定,能一次性覆盖几十个路口。下载后按路段切分,作为基础底料。
第二,自己架设拍摄。找几个典型的十字路口,用手机固定在三脚架上,分别在工作日早晚高峰、夜间、阴雨天各拍30分钟。手机需要设置成固定焦点和固定曝光,避免自动对焦乱跳导致连续帧画面抖动,后期还得裁剪。
第三,第三方开源数据集。一些高校公开的交通目标检测数据集里会附带国内信号灯图片,可以作为补充。但要注意两点:一是图像版权授权范围,不能超出研究用途;二是旧数据集的分辨率普遍偏低,通常仅640x480,而现代YOLO在640输入下需要目标占据足够像素。旧图里的灯体可能只有十几个像素,模型很难学到有效特征,这类数据只能少量混入,不能当主力。
我的最终配比大概是视频抽帧60%、自行拍摄30%、旧开源集10%。这个配比不是拍脑袋定的,而是根据训练后各类样本的AP值调整过——旧图补进来的那10%主要是为了增加亮度方差,让模型对老旧信号灯泛化更好。
2.2 抽帧与清洗:别把同一帧扔进去训练十次
视频抽帧很容易犯一个错误:连续帧之间几乎一样,模型看到的是同一场景的重复变体。训练集和验证集如果时间上重叠,会出现“验证集泄漏”,最终指标虚高,一上真实路口马上现原形。
我的做法是:抽帧间隔不小于15帧(0.5秒@30fps),并且把同一段视频的帧全部归入同一数据子集——要么训练集,要么验证集,不能混。具体操作我写了段小脚本辅助处理:
import cv2, os, random random.seed(42) def extract_frames(video_path, out_dir, interval=15, target_size=1280): cap = cv2.VideoCapture(video_path) frame_id = 0 saved = 0 while True: ret, frame = cap.read() if not ret: break if frame_id % interval == 0: h, w = frame.shape[:2] if w >= target_size: scale = target_size / w new_size = (target_size, int(h * scale)) frame = cv2.resize(frame, new_size, interpolation=cv2.INTER_AREA) # 剔除过亮或过暗帧 gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) brightness = gray.mean() if 30 < brightness < 220: cv2.imwrite(os.path.join(out_dir, f"frame_{frame_id:06d}.jpg"), frame, [cv2.IMWRITE_JPEG_QUALITY, 90]) saved += 1 frame_id += 1 cap.release() print(f"saved {saved} frames from {video_path}")上述脚本做了三件事:间隔抽帧、统一长边到1280、剔除亮度异常帧。1280是一个权衡——太大会让标注和训练显存开销倍增,太小则小目标信号灯像素不足。1280保证灯体在图像中至少占20-30像素,给后续缩放到640训练留出余量。
2.3 数据清洗的血泪经验
抽完帧之后必须人工过一遍。我统计过,原始帧里大概12%是彻底不能用的,主要集中在三种情况:
- 对焦模糊,灯体边缘像棉花一样晕开,这种框出来只会教坏模型。
- 遮挡超过50%的灯体,比如正好被一辆大货车完全挡住。如果页面上同时有另一个同类灯可见,可以保留做难度样本;只有一个灯且被完全遮挡,直接删。
- 镜头污渍反光,画面中心一团光晕盖在灯位上。
清洗别嫌枯燥,这一步决定了下游标注的人力是否白费。我的做法是写一个快速筛查界面,按左右方向键翻页,按D删除坏图,每天过5000张,三天内清完一个基础数据集,效率比用图片浏览器高很多。
3. 标注规范与增强策略:数据质量的小细节才是精度的大变量
3.1 类别的设计:比你想象得更讲究
交通信号检测不是简单标“红、绿、黄”就行,得结合下游需求。比如自动驾驶决策需要区分“直行红绿灯”和“左转箭头灯”,因为它们的意义不同——直行红灯不能右转(除非另有规定),左转箭头红灯却允许直行?这里不严谨不行。
我的项目只做状态识别,所以类别这样定义:
- red:圆形/整体红灯
- green:圆形/整体绿灯
- yellow:黄灯(闪烁或常亮)
- arrow_red:红色箭头灯(左转/右转)
- arrow_green:绿色箭头灯
为什么要单独拆出箭头灯?因为箭头灯的视觉特征和圆形灯差别很大,尤其黄灯亮起时,箭头的黄色条纹是斜向的,和圆形黄色区域完全不同。如果不拆分,模型会认为它们必须长得一样,结果两头都学不好。
如果你还要做倒计时识别,建议单独加一个countdown类别,把数字区域也框出来。倒计时的数字是七段码或点阵,特征独立,需要单独的数据量。
3.2 标注框怎么打才标准
我们用的是YOLO格式(类别编号 + 归一化中心坐标 + 归一化宽高)。标注工具有很多,我试过LabelImg和X-AnyLabeling,最后长期用X-AnyLabeling。原因是它支持自动保存、预标注模型辅助、图像增强预览,效率比LabelImg高一截。
标注框的边界规则是:
- 框体紧贴灯体外边缘,不要包含灯杆或遮阳板。遮阳板会和灯体粘连,框进去会引入大量背景噪声。
- 如果同一根灯杆上有多个灯组,每个灯组单独框;如果一组灯里既有圆形红灯又有箭头红灯,按实际类别分别框。
- 当目标小于10像素时,宁可删除也不要硬标。10像素以下的灯在640分辨率输入下只有1-2个像素,学习不到任何特征,还会成为矛盾梯度来源。
一个容易忽略的点:如果你用矩形框,在红绿灯轮廓是圆形或箭头形的目标上,矩形框会包含一些背景区域。这些背景区域里如果有树枝或灯光反射,会让模型误以为颜色块应该出现在框内某个区域。所以标注时尽量让框裁剪得紧凑,减少不必要的背景。
3.3 数据增强:模拟恶劣天气的“平行宇宙”
国内交通的特点就是天气复杂。雾天、雨天、逆光、夜晚,每种场景的视觉特征差异极大。数据增强是补充这些场景性价比最高的办法。
YOLO自带的增强里,我开了这些:
- hsv_h: 0.015,色调扰动,小幅度避免色彩偏移。
- hsv_s: 0.5,饱和度扰动,模拟褪色信号灯。
- hsv_v: 0.4,明度扰动,模拟过曝和欠曝。
- degrees: 5.0,旋转小角度,模拟摄像头安装不水平。
- translate: 0.1,平移扰动,模拟车辆抖动。
- scale: 0.5,缩放扰动,让模型适应不同距离的灯体大小。
- fliplr: 0.5,水平翻转。
但我额外加了自己写的一组“夜间增强”策略。做法是:从原始夜间帧里单独分出一部分,用Gamma校正把图像压暗1.2档,然后叠加高斯噪声,让信号灯的红色或绿色在图像中的对比更强。这比全局加噪声更符合人眼看到的夜间情景——背景暗,但灯体亮。
增强不是越多越好。开花开着开着会改变灯体颜色分布,黄色灯在强HSV扰动下可能变成橙色或绿色,反而造成标签噪声。我最终把hsv_v控制在0.4以内,只在白天数据上开高饱和度扰动,夜间数据单独做亮度增强。
4. 模型选型与训练配置:用YOLOv8s在单卡上跑出可用精度
4.1 为什么是YOLOv8s
训练前需要定模型尺寸。YOLO系列从v5到v8到v11一路迭代,各有拥趸。我的判断标准很简单:社区生态成熟度、部署易用性、精度与速度平衡。
v8s参数量约11M,在RTX 3090上训练一个epoch只要三分钟(batch=16,640输入),推理速度在TensorRT下能到100 FPS以上。v8官方预训练权重在COCO上做过充分调优,迁移到红绿灯场景只需要微调最后几层检测头,收敛极快。
不用v5的原因不是它不好,而是Ultralytics对v5的更新维护已经放慢,新出的性能优化基本都在v8/v11上。v11我后来也试过,精度略高,但官方配套的部署工具链还不算完善,导出TensorRT时有一些版本兼容问题。工程上稳定第一,所以我留在了v8s。如果你手头只有老款显卡,v8n也是一个不错的轻量选择,精度少掉两三个点但速度更快。
4.2 数据集的目录结构与划分
YOLO训练要求固定的目录结构。我记得最清晰的建议是:把训练集和验证集放在同一个父目录下,并用数据集yaml文件指定路径。
datasets/ ├── traffic_signal/ │ ├── images/ │ │ ├── train/ │ │ │ ├── 000001.jpg │ │ │ ├── ... │ │ └── val/ │ │ ├── 100001.jpg │ │ └── ... │ ├── labels/ │ │ ├── train/ │ │ │ ├── 000001.txt │ │ │ └── ... │ │ └── val/ │ │ ├── 100001.txt │ │ └── ... │ └── data.yamldata.yaml内容:
path: ../datasets/traffic_signal train: images/train val: images/val names: 0: red 1: green 2: yellow 3: arrow_red 4: arrow_green生成label文件时,我用了一个小脚本来从标注工具导出的JSON格式转成YOLO txt,并在转换时做了归一化和坐标检查:
def convert(img_w, img_h, box): x_center = (box[0] + box[2] / 2) / img_w y_center = (box[1] + box[3] / 2) / img_h width = box[2] / img_w height = box[3] / img_h if not (0 <= x_center <= 1 and 0 <= y_center <= 1 and width > 0 and height > 0): return None # 异常框 return x_center, y_center, width, height注意,超过图像边界的框不能直接截断,最好是把框中心坐标限定在[0,1]内,宽高同理。如果框完全在画外,这帧就应该删掉。
4.3 训练参数与多显卡注意事项
我拿单张RTX 3090 24G显存做训练,batch size可以稳妥开16。如果显存小,就降batch同时按比例提高学习率?不,学习率不宜直接十倍增大以追求速度,而是应保持相对稳定。以下是我最终用的胶水参数:
yolo detect train \ model=yolov8s.pt \ data=traffic_signal.yaml \ imgsz=640 \ batch=16 \ epochs=100 \ optimizer=AdamW \ lr0=0.001 \ lrf=0.01 \ warmup_epochs=3 \ cos_lr=True \ patience=10 \ save_period=10 \ seed=42 \ project=results/sign_det \ name=exp1几个参数的解释,都是过来人的心酸:
- 预训练权重选择yolov8s.pt而不是从零训练。COCO预训练模型已经学会了通用物体边缘和纹理特征,用它的权重做初始化,红绿灯训练相当于在巨石上雕刻,而不是从泥巴里捏人,收敛快而且不容易过拟合。
- warmup_epochs设成3。前期学习率太低,如果warmup太短,模型参数更新幅度过大,容易把预训练学好的特征冲掉。
- cos_lr=True配合lrf=0.01做余弦退火,可以让末期学习率平稳降到初始的1%,在细粒度调优阶段帮助稳定收敛。
- patience=10做早停。数据集不大的情况下,通常40-60个epoch就已经收敛,后面全是过拟合震荡,留着等待没有意义。
训练过程中需要盯两条曲线:val_loss和mAP。如果train_loss持续下降而val_loss在30轮后反弹,说明过拟合,早停生效;如果是mAP@0.5一直提升但mAP@0.5:0.95停滞,这是小目标的问题,得回到数据增强或分辨率上找办法,单纯的训练跑不下去。
4.4 评估指标怎么解读
YOLO训练结束后会输出一堆指标,重点看这些:
- mAP@0.5:表示IoU阈值0.5下的平均精度,红绿灯任务我要求0.95以上。
- mAP@0.5:0.95:IoU从0.5到0.95取均值,更能体现框定位质量。交通信号灯一旦框偏移半个灯体,状态判断就会错。所以这个指标不能低于0.75。
- precision/recall:平衡状态。信号灯状态判断宁漏检(recall低)不误检(precision低?),具体要看决策逻辑。我的场景是路侧感知,要求尽量不误伤,所以precision优先。
你还会看到per-class AP列表。如果某个类别明显低于平均线,比如arrow_green只有0.8,多半是样本量不够或数据增强把它扭曲了。回去给这类多补充些真实图片,比盲目加epoch管用。
5. 常见问题与排查技巧实录:小目标、夜间、颜色混淆的实战解法
5.1 小目标信号灯漏检:换切片推理比换模型更有用
信号灯在路口画面中通常是很小的目标。1280长边图像里,灯体大约长20-60像素;缩放到640输入后,只剩10-30像素。在YOLO的检测头上,深层的特征图下采样32倍,10像素的目标在最后几层可能只有不到1个像素的特征响应,自然容易被漏掉。
第一反应是切图推理。把原始1280图像切成四块960x960(重叠50像素),分别推理再合并框,小目标在子图中的相对尺寸可以翻倍。我用SAHI这个框架做过切片,配合YOLOv8s,夜间远距离的召回率从0.72提到了0.88,代价是推理耗时x3。在路侧场景,硬件有富余,这个代价可以接受。
但要注意,切片后可能出现同一灯被两个切片各检一半的情况,需要使用NMS合并重叠框,IOU阈值调到0.3。
第二种方案是提高输入分辨率。直援imgsz=960,YOLO本身在推理时只需修改imgsz,但训练时也要用相同尺寸才不损失。我试过960训练,显存占用翻翻,但精度提升有限。切图效果更直接,因为保持了目标自身上下文。
5.2 夜间过曝与低光的信号灯检测
夜间信号灯检测反而好办——灯体本身是高亮自发光体,背景全黑反而是极佳的对比度。真正的问题是镜头反光、路灯干扰、以及暗部噪点被误检成信号灯。
我的处理是三步:
- 数据集里夜间图片占比尽量到30%。如果拍摄条件不允许,就用灰度变换把白天图片压暗模拟夜间。
- 推理端如果摄像头支持,开启了HDR模式,有效扩大动态范围,让灯体轮廓更清晰,避免全黑背景里红绿饱和度过高而溢出屏幕。
- 在训练时给夜间图片单独开一组增强,只做亮度轻微下调和高斯噪声叠加,不做饱和度扰动,防止绿色在低照度下被抹成灰色。
实测下来,夜间检测精度比白天更稳——因为背景干扰更少。真正难的反而是傍晚逆光:太阳斜射在镜头上产生光晕,灯体被罩住。这种场景通过手动增加“光晕模拟”增强:在灯体附近叠加半透明白色光晕块,让模型学会在光晕下辨认边缘。
5.3 红灯和车尾灯混淆:负样本是最有效的药
红灯是纯圆形红色发光体,汽车尾灯也是。除了位置不同,两者在图像上长得非常像。如果训练数据里没有大量尾灯负样本,模型就会把红色刹车灯当成红灯。
负样本采集有一个野路子:从夜间车流视频里截取大量包含车灯但无信号灯的帧,直接作为“空标签”图片加入训练集。YOLO允许图片中没有任何目标对应标签,它可以从这些反向样本中学习到“这里不该出框”。
我在数据集中特意加了8%的无目标图片,其中一半是深夜车流、一半是白天有红色广告牌的路口。加入负样本后,误检率从每帧3.2个降到0.4个,效果极其显著。
5.4 倒计时和频闪干扰
国内信号灯常伴有倒计时数字,数字区域和灯体紧贴。模型有时候会把数字“9”识别成黄色信号灯。这是因为倒计时的数字是黄色LED,小尺寸下和黄色圆形信号灯相似。
我的策略是:检测框紧贴灯体不包含数字区域,标注时严格避开数字,让模型学到的特征是“圆形区域”而不是“黄色点亮块”。另外,在推理端加一个简单规则:同一灯位在连续10帧内状态不能突变超过两次(红直接跳绿除外),用时序滤波清除频闪误判。
5.5 模型剪枝与部署注意
训练完成后导出TensorRT,有一个坑:默认fp16精度会导致红色信号灯在暗背景下颜色略微失真。模型在OpenCV和PyTorch里能正确识别,到TensorRT中却漏检三个百分点。
解决方法是把模型的输入均值方差归一化层融合到网络外部预处理里,不要让TensorRT在数学优化时把颜色通道算歪。具体做法是关闭模型的归一化,改用预处理代码传入归一化后的float张量。这个细节我在部署调试时花了整整一天才定位,写出来给大家避坑。
6. 最后说两句实在话
这个项目做下来,我最深的体会是:数据工程不是给算法打下手,它本身就是决定项目成败的主战场。YOLO模型现在人人能跑,但能跑和能落地之间有巨大的鸿沟,鸿沟里填的就是数据采集的脏活累活、标注规范的较真、增强策略的拿捏。
如果你正打算启动类似项目,我会建议按这个顺序投入精力:先花两周把数据集基础版本做扎实,类别定义、标注边界和场景覆盖都定义清楚;再花一周调YOLO参数,基本就能拿到90分效果。剩下的10分靠负样本和夜间增强一点一点抠。顺序反了就麻烦了——模型改来改去,最后发现是数据里没有夜间样本,纯属白费显卡电费。
最后分享一个我后来一直在用的小技巧:给每一个真实路口场景单独建一个小文件架,里面只放三张典型帧(白天、夜间、雨天),每轮模型迭代后先用这三张图看输出。模型参数更新的好坏,肉眼扫一眼就知道,根本不用等完整验证集跑完。这做法就像老厨师尝咸淡,一勺就知道整锅味道,省事。