简介:这是一份基于YOLOv3与DarkNet-53的道路红绿灯检测识别综合项目实践报告,面向深度学习初学者、计算机视觉学习者及智能驾驶相关研究人员。报告围绕小目标检测难点展开,完整呈现了从实验调研、环境配置、开源交通标志数据集预处理,到模型训练、参数调优、评估与应用的全过程,并具体说明了输入416×416、3类目标、200个epochs等关键参数。内容还包含FPN多尺度融合策略分析、单目标与多目标红绿灯检测效果展示,以及动态场景适用性讨论。压缩包仅1个doc文档,大小438KB,适合作为课程设计、综合实践项目或毕业设计的参考模板。目前已有648人学习下载,对希望快速了解YOLOv3在交通场景落地流程的读者具有直接参考价值。
1. 为什么“红路灯”检测总被抄作业,却总在一落地就翻车
“红路灯”这三个字其实是很多项目的报告里默认会写错的名字,正确叫法是红绿灯、交通信号灯。基于yolov3的道路红绿灯检测识别实验报告,在自动驾驶、辅助驾驶、车路协同项目里几乎是入门必做的一个方向。问题在于,网上能找到的成熟源码和实验报告很多,照着跑一遍也能出图、能画框、能报个mAP,但一旦把模型放到真实的十字路口、阴雨天、夜间、逆光场景里,漏检和误检会突然变多,很多人这时候才开始怀疑数据集、怀疑Anchor、怀疑Darknet是不是有问题。
这个方向真正值得做的事不是“用yolov3跑通一个demo”,而是搞明白红绿灯检测和其他目标检测任务在数据设计上的本质差异:目标小、状态多、颜色语义强、时序依赖高,而yolov3作为一套老但清晰、可控性强、部署成本低的检测框架,特别适合用来说明这些问题并落成一套可复现的流程。这篇笔记适合正在做课程实验、毕设或小型路侧项目的工程师,目标是让读者能从一个只有标注图片和一台本地GPU的环境出发,把数据整理、训练、调参、离线评估、实路验证整个链路做扎实,顺带绕开那些最常坑人的隐性bug。
2. yolov3信号灯检测的原理和选型理由:为什么用Darknet-53这个骨架而不是直接上Transformer
2.1 信号灯检测对网络结构的三个硬性要求
先把需求搞清楚。红绿灯检测不是“在一张图上找到目标”这么简单,它对网络有三个硬性要求:第一,小目标敏感度要高,路口画面里一个灯通常只占整张图的几十个像素,尤其是远距离的灯头,比例可能小于0.5%;第二,对颜色信息要敏感,红灯绿灯黄灯的区别几乎完全靠HSV空间的色相来区分,如果特征的通道注意力机制太强,很容易把颜色细节压掉;第三,推理速度要能跑在边缘设备上,车端或路侧盒子的算力往往很有限,不能一上来就上多阶段检测器。
yolov3的骨架Darknet-53由一系列残差块组成,在ImageNet上做过预训练。它没有像ResNet那样在最后靠全局平均池化直接输出分类结果,而是保留了不同分辨率的特征层用于多尺度预测。这对信号灯这种“大背景下的小目标”特别重要:高分辨率的浅层特征保留空间信息和边缘信息,而深层的语义特征提供类别判断能力,二者通过类似FPN的路径拼接在一起。相比之下,yolov5的CSP结构确实更快,但yolov3代码更透明、超参数更少,在实验报告里更便于逐层分析和画特征图。
需要注意的是红绿灯并不只是一个“有就是有”的存在。一个完整的信号灯集合包含灯头位置、灯色状态、箭头方向三个语义维度,很多实验只拿“红绿黄”当三类做,这实际上是丢掉了方向灯的维度。后面在数据标注部分会专门讨论这个问题,这里先记住一个结论:如果只按颜色分三类去做检测,遇到左转箭头灯或黄闪灯时基本必炸。
2.2 多尺度预测与Anchor设计的取舍
yolov3在三个尺度上做预测,分别对应输入尺寸的32倍、16倍和8倍下采样特征图。以416乘416的输入为例,三个输出层的特征图分辨率是13乘13、26乘26、52乘52。小目标主要由52乘52那层负责,因为每个网格对应的原图区域只有8乘8像素,足够捕捉小灯头。但很多时候大家直接用COCO预训练的anchor,这对红绿灯来说并不合理,COCO里小目标占比高,但形状比例和信号灯的矩形轮廓差异很大。
我在实际项目中更常用K-means在训练集上重新聚类anchor。常见做法是对标注框做归一化,然后用IoU作为距离度量做聚类。要注意的是yolov3的anchor是宽高比例,不包含坐标信息。聚类得到9组anchor后,按面积从小到大排序,分配给三个尺度层:小尺度层对应大目标,大尺度层对应小目标。不要把这个顺序搞反,否则训练会异常慢且mAP极低。
# 用darknet自带的kmeans脚本重新聚类anchor python scripts/reorg_anchors.py -dataset cfg/traffic_light.data -num_clusters 9 -out_file cfg/anchors.txt这个脚本并不存在于darknet官方仓库,常见做法是自己写一个基于numpy的kmeans聚类,输入是标签文件里所有的宽高值。聚类后得到的9个anchor要手动填进yolov3的cfg文件中,每个yolo层前的3个filters个数也对应要改:filters = 3 * (classes + 5),如果类别数是4,那filters就是27。这里最容易翻车的地方是改最后一个卷积层时把filters算错,或者改完yolo层却忘了改它前面的conv层。
2.3 为什么不建议直接上yolov5或RT-DETR做实验
yolov5、yolov8乃至RT-DETR在小目标和精度上的表现确实优于v3,但是它们不适合“做实验报告”这个场景。原因不在精度而在可解释性:v3的每个输出层在做什么、loss怎么算、mAP怎么统计都很容易从代码里读出来,而且训练所需的显存更小,一张1080Ti甚至2060就能完成;v5的autoanchor、EMA、mixup这些结构在消融实验里很难拆干净,论文里的对比表格会很难写。如果目标是工程落地,v3的推理权重也只有不到250MB,TensorRT转换后FP16能压到120MB左右,在Jetson Xavier NX上能跑到30ms以内。
当然,如果项目对检测率的要求大于对可解释性的要求,直接上yolov5s是合理的选择。但本标题既然写的是yolov3,那我们就以v3为主干,把整个链路做严谨,最后再给出一个结论:v3在红绿灯场景的mAP大约能到60到75之间,真正限制上限的往往不是网络,而是数据里小目标的标注质量和类别定义。
3. 数据是红绿灯检测的地基:从COCO筛选到自建小目标数据集的完整流程
3.1 先分清两类可用数据源:开源数据集与自采数据
红绿灯检测的公开数据集主要有三个来源:COCO的traffic light类别、Bosch Small Traffic Lights Dataset(简称BSTLD)以及某国内自动驾驶公司的路测数据集。COCO里traffic light的标注有接近2000张图,小目标占比高,但标注框存在大量“连体灯”问题,一个框把红黄绿三个灯头同时框住,这作为检测任务还算可接受,但作为分类任务就有麻烦。BSTLD是专门针对小目标信号灯的数据集,在德国道路上采集,图像分辨率1280乘720,很多灯头小于20像素,非常适合用来检验模型的小目标能力。
最稳的数据路线是“公开集预训练+自采数据微调”。常见做法是用脚本把COCO里的traffic light类单独筛出来转成YOLO格式,再混合BSTLD一起做预训练,最后加入自己在路口采集的数据做微调。自采数据不需要太多,500到1000张覆盖不同距离、天气、时段的图片就已经能把模型拉回本地路口的分布区间。采集时最好用1080p以上的摄像头,固定机位和移动机位各取一部分,不要全从视频里抽帧,否则相邻帧的相似度过高会造成训练集冗余。
# 从COCO JSON里筛选traffic_light类并输出YOLO格式标签 import json, os from pathlib import Path coco_path = "instances_train2017.json" out_dir = Path("yolo_traffic_light") out_dir.mkdir(exist_ok=True) img_dir = Path("train2017") with open(coco_path, "r") as f: coco = json.load(f) # 先找到traffic_light的category_id cat_id = None for cat in coco["categories"]: if cat["name"] == "traffic light": cat_id = cat["id"] # 按image_id聚合annotations img_anns = {} for ann in coco["annotations"]: if ann["category_id"] != cat_id: continue img_anns.setdefault(ann["image_id"], []).append(ann) # 写YOLO标签文件 for img_info in coco["images"]: img_id = img_info["id"] if img_id not in img_anns: continue w, h = img_info["width"], img_info["height"] txt = [] for ann in img_anns[img_id]: x, y, bw, bh = ann["bbox"] # COCO的bbox是[x,y,w,h] cx = (x + bw / 2) / w cy = (y + bh / 2) / h nw = bw / w nh = bh / h txt.append(f"0 {cx:.6f} {cy:.6f} {nw:.6f} {nh:.6f}") label_file = out_dir / (img_info["file_name"].replace(".jpg", ".txt")) label_file.write_text("\n".join(txt))这里要特别提醒一个经典问题:COCO的bbox坐标是左上角加宽高,而YOLO格式是中心点加宽高,转换时中间的除法和归一化一步都不能错。另外筛选后图片文件的复制不要漏掉没有标注的图片,最好是写一个脚本遍历images,没有对应标签就跳过拷贝,而不是只拷贝有标注的,否则训练时数据路径会报错。
写完转换脚本后,要抽查图片和标签的对应关系。一个快速检查方法是写一个OpenCV脚本把标注框画回原图上,用肉眼检查一组不同场景的图片,确认框的边界没有明显偏移、没有整框标到灯杆上。
3.2 类别体系设计:按“灯色+方向”组合定类而不是只定红绿黄
这是实验报告里最容易被忽视但最影响上限的决策点。信号灯检测的类别设计有两种常见做法:第一种是只分red、green、yellow三类,把方向灯和圆盘灯归为一类;第二种是分red_left、red_straight、green_left、green_straight、yellow等组合类。第二种对下游决策系统更友好,因为下游系统关心的不仅是“有没有红灯”,而是“哪个方向能走”。
我一般会定义这样一组类别:red_circle, green_circle, red_left, green_left, red_right, green_right, yellow, off,一共8类。这里“off”代表熄灭状态,很多城市夜间会关掉一部分方向的信号灯,如果不加这一类,模型会把熄灭的灯头强行归类成红或黄,导致下游逻辑混乱。加了“off”类之后,检测到的灯头但无法判断状态的情况也有了明确出口。
类别设计的决策会直接影响训练样本数量。如果只有一个2000张的公开集,硬分8类的结果就是每类只有一两百个实例,小目标检测基本学不出来。所以务实策略是:首先用COCO和BSTLD训练出一个只分红绿黄三类的基座模型,然后另外收集方向灯图片做数据扩充,最后用“冻结骨干+只训练头部”的方式做一次针对方向灯的微调。这种方式既保证了基础检测率,又把方向语义逐步注入。
3.3 增强策略:Mosaic、HSV扰动与几何变换的合理组合
红绿灯检测的增强策略和通用目标检测不完全一样。通用的随机裁剪、缩放确实能提升泛化性,但信号灯是强颜色依赖目标,如果HSV扰动幅度太大,红灯会被扰动成橙色或粉色,模型反而学不到“红色”这个稳定的语义。所以颜色增强要保守:H通道的扰动范围控制在正负5度以内,S和V在正负20%左右,且H的扰动不适合用在夜间图片上。
Mosaic增强对红绿灯是有效的,因为它把四张图拼在一起,迫使模型学会在更小的目标尺度上检测,这正是小目标场景需要的。同时随机缩放比例里多加几个小于0.5的档位,进一步模拟远距离灯头效果。注意Mosaic增强在训练末期应该关闭,否则模型持续看到拼接后的异常上下文,在真实全图上反而可能掉点。
几何增强里的随机翻转需要特别小心:如果训练集里没有专门补充左右方向灯的对称样本,盲目做水平翻转会让左转箭头灯和右转箭头灯的语义发生冲突。常见做法是把翻转增强关掉,或者在数据集里手动扩充反转后的标注副本,保证两个方向的箭头数量基本均衡。
4. yolov3训练实操:从零开始跑通红绿灯检测的最小命令集合
4.1 准备文件结构与预训练权重
训练前的目录结构必须保持简单清晰,避免路径错误浪费一整天。常见做法是建立一个项目根目录,下面放darknet源码、数据集文件夹和输出文件夹三块。darknet的编译依赖OpenCV和CUDA,建议直接用官方Makefile编译而不是用cmake,因为cmake分支在新版darknet里维护较弱。
# 编译darknet(CUDA+OpenCV版本) cd darknet sed -i 's/GPU=0/GPU=1/' Makefile sed -i 's/CUDNN=0/CUDNN=1/' Makefile sed -i 's/OPENCV=0/OPENCV=1/' Makefile make -j8编译成功后会生成一个darknet二进制文件。接着需要准备三个文件:一个.data文件指明训练集、验证集的路径和类别数;一个.names文件写类别名;一个.cfg文件定义网络结构。很多实验报告会让人直接改cfg里的classes和filters,但忽略掉.data和.names会导致训练时读不到标签类名。
预训练权重建议从Darknet官网下载darknet53.conv.74,这是基于ImageNet分类预训练的骨干权重,不含检测头。使用预训练权重能显著减少训练时间,尤其在小数据集上效果明显。下载后把它放在项目根目录下,准备开始训练。
4.2 修改cfg文件和训练启动参数
以yolov3.cfg为模板,把里面的classes改成你实际的类别数,同时把每个yolo层前面的filters改为3乘以类别数加5。类别数如果是3类,filters为24;如果是8类,filters为39。对红绿灯这类小目标任务,推荐把输入分辨率设为608乘608而不是416乘416,这会直接增加小目标的像素数。显存不够时也可以通过减小subdivisions来缓解。
# 训练命令(使用darknet53.conv.74预训练权重) ./darknet detector train \ cfg/traffic_light.data \ cfg/yolov3_traffic_light.cfg \ darknet53.conv.74 \ -gpus 0,1 \ -dont_show \ -map训练命令里有几个关键参数需要解读。-dont_show表示关闭实时可视化窗口,在远程服务器上很有用。-map会在每个epoch结束后在验证集上计算mAP并输出到终端,这是监控训练效果最重要的指标,loss只能用来判断是否发散,不能用来判断模型好坏。-gpus后跟多个GPU编号用逗号分隔,单卡训练时不加这个参数或只写一个序号。
训练过程中的典型表现是:前100个iteration的loss值在几十甚至上百是正常的,因为随机初始化的检测头输出值很大;如果loss一直不下降,先检查learning_rate是否过大,yolov3默认的初始学习率是0.001,如果batch size改大了而学习率没降,容易出现震荡。红绿灯场景的epoch一般不需要跑太长,30000次iteration以内基本就能收敛,前提是数据量在几千张这个量级。
4.3 训练过程中的关键日志解读:loss、avg loss与mAP
darknet每100个iteration会打印一次loss和avg loss。avg loss是平滑后的值,更值得关注。如果avg loss在前500个iteration内从几十降到十以内,说明网络在正常学习。这时需要重点观察mAP曲线的变化,mAP从0开始爬升是正常的,但如果到了20000次iteration mAP还不超过10%,几乎可以确定是数据标签有问题或anchor分配有问题。
另一个细节是iteration和epoch的关系。一个epoch等于完整跑完一遍训练集,iteration是batch的数量。如果训练集有5000张图,batch为64,subdivisions为8,那么一个epoch大约需要5000除以64约78次iteration。训练日志里会显示当前iteration对应的epoch位置,确保在判断“训练是否偏了”时参考的是epoch而不是iteration。最后在训练结束时,备份backup目录下生成的yolov3_traffic_light_final.weights,这个就是最终模型。
5. 红绿灯检测常见问题排查:从数据集翻车到夜间失效的五个血泪记录
5.1 训练loss持续不降,甚至上涨
现象:loss在几百个iteration里完全不下降,avg loss还在往上爬。
原因:最常见的是学习率设置偏大。很多人把yolov3.cfg原版的learning_rate=0.001直接套用在自己很小的数据集上,但小数据集的梯度方差大,过大的学习率会让loss在陡峭区域反复横跳。另一个常见原因是类别数改了但filters没改,导致输出维度不匹配,实际训练时网络结构报错但被darknet的容错机制忽略了。
解决:先把learning_rate调到0.0005或0.0003,同时把burn_in从默认的1000改成2000,让前2000个iteration用更小的速率预热。确认cfg文件中所有yolo层的classes都改了,所有yolo层前面的filters都等于3乘以(classes加5)。改完后重新训练,前2000个iteration的loss应当稳定下降到原始值的50%左右。
5.2 小目标灯头几乎全部漏检,但大目标灯头检测正常
现象:近距离的灯头能检测出来,距离超过30米的灯头在测试图片上一个都检不到。
原因:输入分辨率太小,或者直接把416乘416的分辨率用在包含大量远距离小目标的测试图上。另外如果anchor聚类时没有把较小的anchor包含进来,小目标就没有合适的先验框。
解决:把训练和测试的输入分辨率都调整为608乘608。重新运行kmeans聚类时,把聚类中心数设为9后检查最小的三个anchor,如果宽度或高度小于3个像素,说明聚类结果不适合小目标场景,需要增加采样或重新缩放标注框。还可以在增强中提高随机缩放的占比例,让小目标样本占总样本的比例达到20%以上。
5.3 夜间图片大量误检,路灯被识别成红灯
现象:白天测试效果尚可,夜间图片里路灯、刹车灯、霓虹灯被大量识别成红色信号灯。
原因:夜间场景在训练集里占比太少,模型没有见过灯头周围是暗色背景的样子,把“红色圆形发光体”当成了充分特征。实际上真实信号灯在夜间有明显的灯头外壳和黑边框,而刹车灯和路灯没有。
解决:在训练集中补充至少30%的夜间图片,并配合暗度增强。OpenCV里可以用cv2.convertScaleAbs把亮度整体降低,也可以直接用darknet自带的曝光扰动参数,在cfg文件的data_augmentation部分设置jitter和hue的合理值。注意夜间增强的重点是保持灯头的色彩饱和度和外壳边缘纹理,不要把整张图压得太暗导致灯头自身也看不清。
5.4 红灯绿灯互混,特别是远处的小尺寸灯头
现象:验证集里red的mAP有80%,但green的mAP只有40%,或者对同一组图反复测试,两次分类结果不一致。
原因:绿色信号灯在远处因为镜头色差或雾霾,呈现出蓝绿色或灰白色,模型只能靠低层纹理的微妙差异来区分,而yolov3对低层特征的分类权重往往不足。另一个原因是绿色类别在数据集中数量少于红色类别,数据不平衡导致分类边界偏移。
解决:先统计训练集中红绿两类的实例数量,如果绿色不足,用数据集扩充工具生成绿色的变换版本。另外可以在分类头后面加一个小型的颜色校正分支,用平均值池化提取灯头区域的颜色直方图,辅助分类决策。这种方法不算复杂,但对红绿灯这种强颜色语义目标往往有奇效。
5.5 在固定路口的测试效果不错,一到新城市就失灵
现象:在采集数据的城市测试mAP稳定,但在另一个城市的同风格路口测试,误检和漏检明显增多。
原因:不同城市的信号灯款式、排列方式、灯头大小差异很大,有的城市用竖排三灯,有的用横排,还有的带数字倒计时面板,这些变化都会影响检测。训练集只覆盖了一种固定样式,模型过拟合到这种样式上。
解决:在数据准备阶段就有意识地加入多城市的公开数据集,或者通过风格迁移增强来模拟不同灯头样式。更实际的方案是收集至少三个不同城市来源的路口图片混合训练,并把倒计时面板出现的情况单独检查一下,看模型是否误把数字区域当成了灯头。验证时不要只在采集地验证,用另一城市的少量图片做跨域验证,这是衡量模型泛化能力最直接的方法。
6. 把检测结果变成可用的信号灯状态输出:序列决策与置信度校准
检测模型输出的是一堆带类别和置信度的框,但下游系统真正需要的是“当前路口是否允许直行、左转、右转”这样的结构化决策。这个过程需要两步后处理:第一步是把单帧检测结果按时间序列做投票和滤波,解决单帧误检闪烁的问题;第二步是把信号灯状态与路口几何信息关联,输出车道级通行指令。
单帧置信度往往不够稳定,一个红灯在连续30帧里可能有一两帧突然置信度降到0.3,直接被过滤掉。常见做法是维护一个长度为10帧的滑动窗口,每个候选目标框按位置做IoU关联,然后对类别进行多数投票。如果投票结果里绿灯占多数,哪怕当前帧检测缺失,也仍然输出绿灯状态。这个机制能有效提升时序稳定性,在实验报告里也是可以量化对比的指标:一般能把帧级准确率从92%提升到97%以上。
置信度校准是另一个值得做的优化。yolov3输出的置信度并不是真实概率,尤其是在小目标上,高置信度的框都集中在近距离灯头,远距离灯头的置信度天然偏低。一个实用的校准方法是等频分箱:把验证集上所有检测框的置信度区间分成10份,统计每一份中真实正例的比例,然后用这个比例替代原始置信度输出。这样下游逻辑就不需要针对不同相机距离设置多个置信度阈值了。实现上只需要几十行Python代码,建议直接在实验结果里加入校准前后的对比数据。
最后讨论一下部署与落地的边界。用TensorRT对yolov3做FP16加速后,在Jetson NX上的推理时间约为25毫秒,足够支撑25帧每秒的实时检测。但要注意TensorRT对自定义anchor和yolo层有兼容性问题,需要手动编写yolo层插件或改用自带yolo解析的版本。我的经验是先在darknet上做离线验证,产品化时再转TensorRT,不要在训练阶段就直接用TensorRT调试,否则迭代效率太低。少走弯路的做法是:在保存最终权重之前,把验证集的结果可视化一遍,随机挑几组白天、夜间、逆光、雨天的图片,确认没有明显的系统性问题再进入部署环节,这一步能省掉后面几乎所有的排查时间。希望这篇笔记中关于数据处理、类别设计、训练调参和排查思路的内容能帮到你,让红绿灯检测这个看似简单的小项目真正经得住真实路口的考验。
本文还有配套的精品资源,点击获取