简介:这是一份基于YOLOv8的路口交通信号灯通行规则识别项目,面向计算机、人工智能、自动化等专业的学生、教师及从业者,可支撑毕业设计、课程大作业或工程实践。项目包含完整算法源码与文档说明,代码结构按功能拆分:入口主程序、识别与预测模块负责核心逻辑,配置管理、数据预处理、可视化等模块辅助流程衔接,层次清晰,易于定位和修改。资源包共17个文件,以11个Python脚本为主,另有YAML参数配置、Markdown文档、示例图片等项目必需内容,整体仅705KB,轻量而完整。代码均经过调试测试,确保可直接运行,作者以此完成毕业设计并获98分答辩成绩,具备较高的学习与借鉴价值。目前已有117人学习下载,适合希望深入理解YOLOv8在真实交通场景中应用、并快速搭建同类模型的读者参考。
1. 为什么路口信号灯识别比普通目标检测更难:YOLOv8方案的价值
一个直观的错觉是:红绿灯识别嘛,框出来、认颜色就行。可真正把模型送进路口测试时,你会遇到红灯被路灯照成橙黄色、绿灯和旁边广告牌撞色、车在左转车道却看着全屏绿灯直接放行——这种“检测对了、判断错了”的情况,才是路口交通信号灯通行规则识别的核心难点。Python基于YOLOv8的路口交通信号灯通行规则识别模型及算法源码,价值不在“用YOLOv8框灯”这一层,而是把目标检测、灯色判定、车道方向约束和帧间状态机串成一条完整链路,最后能回答“当前车道能不能走”这个业务问题。它适合正在做辅助驾驶、车路协同、路口监控的工程师,也适合想拿YOLOv8做一个真实落地项目而不是只跑官方示例的同学。下面按我自己的调试顺序,从数据准备一直讲到RK3588部署验证。
2. 训练准备:信号灯数据集、标注策略与YOLOv8环境配置
拿到一个带源码和文档说明的信号灯识别项目,我习惯先不急着跑模型,而是把文档说明里的数据集格式、类别顺序、Python版本要求看一遍。很多源码包训练翻车,不是模型写错,而是标签文件、类别索引和环境版本三者对不上。先把地基打稳,后面所有操作才可复现。
2.1 数据集来源与选型原则:公开数据、自采数据与负样本
路口信号灯识别能用的公开数据集不算少,常见的有LISA交通灯数据集、Bosch Small Traffic Lights这类专为小目标检测设计的红绿灯数据,网上也能找到一些路口监控脱敏数据。公开数据的问题是场景固定、摄像头角度单一,直接拿来做你的路口场景,泛化往往不够。我一般会以公开数据做预训练或联合训练,再自采当前路口的视频抽帧补一批数据,数量不用贪多,关键是覆盖不同时段。
标注策略上,类别定义直接决定后面规则层的复杂度。最简单也最稳的一套类别设计是:
red:红色全屏灯亮起green:绿色全屏灯亮起yellow:黄色全屏灯亮起- 有箭头灯的路口,再拆成
red_left、green_left、red_forward、green_forward等
不用把“灯灭”也标成一个类,灭灯只会在判定阶段造成干扰,模型把它框出来反而增加误检。我见过有人把整个灯壳标成一个框,结果红灯、绿灯两种状态下框内的视觉特征几乎一样,模型怎么训都分不清颜色,这就是标注策略的问题。
负样本一定要加。路口场景里红色刹车灯、红色广告牌、LED倒计时数字、对向车灯都可能被误检成信号灯。我通常会在训练集里掺20%左右的负样本图片,也就是完全不包含信号灯的路口背景图,标签文件写成空文件。这个习惯能明显压低红灯误报率,因为YOLOv8会从负样本里学到“这不是目标”的背景特征。
2.2 最小环境配置:用conda装好torch和ultralytics
YOLOv8本身的依赖不复杂,核心就是PyTorch和ultralytics包。最容易踩的坑是装了CPU版PyTorch,训练时才发现GPU用不上,所以我第一步总是先用conda建一个干净环境,再按需装对应版本的torch:
conda create -n yolo python=3.10 -y conda activate yolo pip install torch torchvision --index-url https://download.pytorch.org/whl/cu118 pip install ultralytics python -c "import torch; print(torch.cuda.is_available())"这段命令里,cu118对应CUDA 11.8运行时,如果你的显卡驱动支持更高CUDA版本,可以把索引地址里的cu118换成cu121或cu124。最后一行torch.cuda.is_available()输出True才算环境正常,输出False就回头检查GPU驱动和torch版本是否匹配。
很多入门机器的显卡是GTX 1660Ti,6G显存,跑YOLOv8是有压力的。我的经验是yolov8n模型、640分辨率、batch设16能勉强跑起来,但训练后期显存容易吃紧;稳妥一点用yolov8s.pt预训练权重配batch 8。如果训练中没有GPU,CPU也能跑小数据集,但速度慢几十倍,建议先把样本量压到几百张验证流程,再上GPU训全量。
2.3 训练参数和自定义yaml:imgsz、batch、epochs的取舍
YOLOv8训练前要准备一个数据集描述文件,文件名随意,内容是指向图片和标签的路径映射。路口信号灯项目里,我的traffic_light.yaml长这样:
path: ./traffic_light_data train: images/train val: images/val nc: 3 names: 0: red 1: green 2: yellowpath是数据集根目录,train和val是相对于根目录的图片文件夹路径,YOLOv8会自动去对应目录下找同名的.txt标签文件。nc是类别数量,names里的索引顺序必须和标签文件里第一列的数字一一对应,否则模型会把红灯当绿灯训。
环境没问题后,我一般这样启动训练:
yolo detect train \ data=traffic_light.yaml \ model=yolov8s.pt \ epochs=120 \ imgsz=640 \ batch=16 \ patience=20 \ device=0参数含义和调整策略可以看这张表:
| 参数 | 我的常用值 | 调整思路 |
|---|---|---|
| model | yolov8n.pt / yolov8s.pt | 显存小选n,精度要求高选s或m |
| imgsz | 640,小目标密集时可试1280 | 信号灯占图比例小,分辨率高对召回有帮助,但训练和推理都变慢 |
| batch | 16或8 | OOM时减半,不要硬扛 |
| epochs | 120~300 | 配合patience自动早停 |
| patience | 20 | 连续20轮val loss不下降就停,省时间 |
| device | 0 | 指定第一张GPU,CPU训练就填cpu |
imgsz=1280不是越高越好。训练时用1280,部署到RK3588这类NPU上往往也要求同样的分辨率,推理耗时直接翻倍。我建议先用640跑一轮,统计一下小目标漏检情况,如果远端信号灯完全框不出来,再考虑升级分辨率。
3. 训练自己的数据集:从VOC转换到损失曲线判读与模型评估
环境就绪、数据也整理好之后,进入训练正题。这一章最容易翻车的是数据格式转换和训练过程判读。YOLOv8不是黑匣子,它把每个epoch的损失和指标都写进了CSV,关键在于你会不会看。
3.1 把VOC/COCO标注转成YOLO格式:转换脚本与四个边界坑
很多公开红绿灯数据集的标注是VOC XML格式,而YOLOv8要的是txt格式,每行一个目标,内容为类别编号 中心点x 中心点y 框宽 框高,数值都归一化到0~1。我写过一个最简转换脚本,可以照着改成自己的路径:
import xml.etree.ElementTree as ET import os def voc_to_yolo(xml_dir, txt_dir, classes): os.makedirs(txt_dir, exist_ok=True) for xml_name in os.listdir(xml_dir): if not xml_name.endswith('.xml'): continue xml_path = os.path.join(xml_dir, xml_name) tree = ET.parse(xml_path) root = tree.getroot() img_w = int(root.find('size/width').text) img_h = int(root.find('size/height').text) lines = [] for obj in root.iter('object'): name = obj.find('name').text if name not in classes: continue cls_id = classes.index(name) box = obj.find('bndbox') x1 = float(box.find('xmin').text) y1 = float(box.find('ymin').text) x2 = float(box.find('xmax').text) y2 = float(box.find('ymax').text) x_center = (x1 + x2) / 2 / img_w y_center = (y1 + y2) / 2 / img_h w = (x2 - x1) / img_w h = (y2 - y1) / img_h lines.append(f"{cls_id} {x_center:.6f} {y_center:.6f} {w:.6f} {h:.6f}") txt_name = os.path.splitext(xml_name)[0] + '.txt' with open(os.path.join(txt_dir, txt_name), 'w', encoding='utf-8') as f: f.write('\n'.join(lines)) classes = ['red', 'green', 'yellow'] voc_to_yolo('path/to/xml', 'path/to/txt', classes)这段脚本的逻辑是:解析XML里的bndbox四角坐标,除以图片真实宽高得到归一化坐标,再按类别索引 + 中心点 + 宽高的格式写入txt。代码里的classes列表顺序就是模型里的类别索引,我在2.3节强调过,这个顺序一旦确定就不要改,否则重新训练等于从零开始。
转换时有四个边界坑值得标记:
- 图片宽高必须读原图信息,不能写死640之类,否则归一化坐标全错。
- XML里如果存在不在
classes列表里的类别,直接跳过,不要报错中断。 - txt文件必须和图片同名,YOLOv8按文件名匹配,后缀差异会导致标签丢失还不报错。
- 如果一张图里完全没有目标,txt文件要保留为空文件,不能删除,否则该图会被跳过或报警告。
3.2 启动训练并用results.csv画损失函数曲线图
训练命令在2.3已经给出。训练开始后,终端会滚动输出每个epoch的box_loss、cls_loss、mAP50等指标,同时训练结果会写到runs/detect/train目录下。我每次都会等训练结束后,用下面这段代码把损失曲线画成一张图,判断模型是否收敛:
import pandas as pd import matplotlib.pyplot as plt df = pd.read_csv('runs/detect/train/results.csv') df.columns = [c.strip() for c in df.columns] fig, axes = plt.subplots(1, 2, figsize=(12, 4)) axes[0].plot(df['epoch'], df['train/box_loss'], label='train_box_loss') axes[0].plot(df['epoch'], df['val/box_loss'], label='val_box_loss') axes[0].set_title('Box Loss') axes[0].legend() axes[1].plot(df['epoch'], df['metrics/mAP50(B)'], label='mAP50') axes[1].plot(df['epoch'], df['metrics/mAP50-95(B)'], label='mAP50-95') axes[1].set_title('mAP') axes[1].legend() plt.savefig('training_curve.png')注意,results.csv的列名在不同版本的ultralytics里略有差异,比如旧版本可能是train/box_loss,新版本可能是train/box_loss加后缀。运行前先print(df.columns)看清列名再画图。
判读曲线时,我的习惯是看三件事:第一,train/box_loss是否持续下降且最终平滑;第二,val/box_loss有没有在某个epoch后掉头上升,如果上升说明过拟合,应该提高patience或者增加数据;第三,mAP50有没有冲到0.9附近。如果val/box_loss一直高位震荡,那大概率不是参数问题,而是标签错乱或者数据增强把红绿灯状态搞反了,后面避坑章节会专门讲。
3.3 评估指标:用混淆矩阵和PR曲线定位红灯漏检
训练完不能只看mAP50。对路口信号灯这种安全敏感场景,红灯漏检比绿灯误判严重得多,而mAP是一个综合量,它无法告诉你“红灯这一类到底漏掉多少”。我每次训练完都会补一条验证命令:
yolo detect val \ model=runs/detect/train/weights/best.pt \ data=traffic_light.yaml \ split=val验证结束后,runs/detect/val目录下会生成confusion_matrix.png和PR_curve.png。混淆矩阵里最需要关注的是red这一类被预测成background的比例,以及red和green之间有没有系统性互认。如果红灯和绿灯大量混淆,先回头查标注类别顺序和数据增强配置,而不是急着换模型。
信号灯小目标居多,mAP50-95通常比常见的大目标数据集低一些,这属于正常现象。你可以把更多权重放在mAP50和召回率上,部署阶段再用下游规则层兜底。我见过不少团队把时间和算力耗在把mAP50-95从0.45刷到0.5,但实际路口测试并没有变好,不如把同样的精力拿去补夜间的负样本和灯色判定逻辑。
4. 通行规则识别:灯色判定、车道方向与帧间状态机
模型把信号灯框出来,这只是第一步。真正的通行规则识别,要做的是把“检测框”变成“当前车道可通行”这个布尔结果。这一章我会讲三个环节:检测框内的灯色判定、车道方向的规则映射、以及连续帧的防抖状态机。
4.1 用OpenCV做灯色判定:HSV阈值法的参数细节
模型输出的类别如果已经区分了红灯、绿灯和黄灯,理论上可以不依赖颜色判定。但实际路口场景中,模型分类远没有人类对颜色的直觉稳定,同一盏红灯在清晨、傍晚、夜间色温变化很大,分类置信度也会波动。所以我会在检测框内再做一次HSV颜色判定,作为分类结果的二次确认:
import cv2 import numpy as np COLOR_HSV = { 'red': [(0, 100, 100), (10, 255, 255), (170, 100, 100), (180, 255, 255)], 'green': [(35, 80, 80), (85, 255, 255)], 'yellow': [(15, 80, 80), (35, 255, 255)], } def judge_color(crop_bgr): hsv = cv2.cvtColor(crop_bgr, cv2.COLOR_BGR2HSV) scores = {} for name, ranges in COLOR_HSV.items(): mask = np.zeros(hsv.shape[:2], dtype=np.uint8) for i in range(0, len(ranges), 2): lower = np.array(ranges[i], dtype=np.uint8) upper = np.array(ranges[i+1], dtype=np.uint8) mask |= cv2.inRange(hsv, lower, upper) bright = float((mask > 0).sum()) scores[name] = bright / max(crop_bgr.size, 1) return max(scores, key=scores.get), scores这段代码把检测框从BGR转到HSV色彩空间,然后分别统计红、绿、黄三色的像素占比。红色需要两个H区间,因为HSV里红色的Hue在0附近是一个环形区间,0~10和170~180都是红色,只取一个区间会漏掉一半。crop_bgr.size是裁剪图的总像素数,用亮色像素占比做归一化,可以避免灯壳本身偏灰导致的误判。
参数上,V即亮度通道我设了最低100,低于这个值的像素不算亮色,可以有效排除灰暗的灯壳。如果你用的是偏色严重的监控摄像头,可能需要把色相区间的上下界放宽,例如绿色从35~85改成30~90,但放宽后误判也会增加,建议用一组实际抓拍图做离线测试后再定。
4.2 全屏灯和箭头灯的规则映射:从检测框到能否通行
灯色判定的输出是“这个框是什么颜色”,但路口通行规则要回答“当前车道可不可以走”。两者之间差了一个车道映射。最简单的做法是把画面按车道区域预划分,用检测框中心点落在哪个区域来判断车辆当前所在车道。
规则层我一般用一个配置加一个判定函数来写。配置里定义每种信号灯状态对应的通行结果:
# 简化版规则:direction表示当前车辆目标方向 # 全屏灯状态和箭头灯状态分开判断 def can_pass(signal_status, direction): # 箭头灯优先:如果对应方向有绿色箭头,直接放行 if signal_status.get(f'{direction}_green'): return True if signal_status.get(f'{direction}_red'): return False # 没有箭头灯时,看全屏灯 if signal_status.get('green'): return True if signal_status.get('yellow'): return 'wait' # 黄灯进入等待观察 return False这里direction只取left、forward、right三种,signal_status是字典,对应全屏灯和箭头灯的检测结果。箭头灯优先于全屏灯的规则必须写进代码里,因为很多路口同时存在全屏灯和左转箭头灯,如果全屏是红灯但左转箭头是绿灯,左转车道应该放行。
车道和信号灯的映射关系依赖摄像头安装位置,每个路口都不一样。常见做法是在配置文件里为每个检测框ROI指定它管辖的车道方向,例如画面左侧1/4区域内的灯属于左转车道,中间属于直行车道。这个先验知识用肉眼标一次就行,不需要训练。
4.3 帧间状态机:连续判决与超时兜底
单帧的灯色判定结果抖动很严重,尤其是LED交通灯在摄像头下会出现频闪,黄色箭头在临界帧可能被识别成红色。如果直接把单帧结果送进下游逻辑,通行指令会来回跳变,这是最危险的。我在项目中用了一个简单的状态机来平滑输出:
class LightStateMachine: def __init__(self, window=5, change_frames=3, timeout=75): self.window = window self.change_frames = change_frames self.timeout = timeout self.history = [] self.state = None self.since_change = 0 def update(self, raw_state): self.history.append(raw_state) self.history = self.history[-self.window:] candidate = max(set(self.history), key=self.history.count) if candidate == self.state: self.since_change = 0 return self.state self.since_change += 1 if self.since_change >= self.change_frames and self.history.count(candidate) >= self.window - 1: self.state = candidate self.since_change = 0 if len(self.history) == self.window and candidate == 'unknown': self.state = 'red' # 连续无法判定时,保守进入红灯状态 return self.state这个状态机的逻辑是:维护最近5帧的灯色判定结果,只有连续3帧以上出现同一个新状态,才切换输出状态。timeout用来处理长时间无法判定的情况——比如摄像头被卡车挡住,连续75帧拿不到状态,干脆输出red,让车辆进入保守停车模式。
我给这种“连续3帧一致才切换”的做法取了个外号叫通行规则后悔药,因为如果没有这个缓冲,视频里一个瞬间的绿色反光就会让系统在红灯时误放行。宁可晚一拍切换状态,也不能频繁跳变。
5. 路口信号灯识别部署避坑指南:5个真实翻车现场
这一章是血泪经验。YOLOv8本身不难跑通,但红绿灯识别项目的坑都藏在数据、增强、色彩和部署转换这些环节里。下面5条都是我在实际调试中遇到过、也定位过根因的问题,按“现象、原因、解决”三条写清楚。
5.1 红灯和绿灯在训练时被翻转:数据增强把标注搞反了
现象:训练loss降得很低,验证集mAP也不差,但视频实测时红灯和绿灯经常互换,而且互换规律像是左右镜像。
原因:YOLOv8默认开启左右翻转增强fliplr。对一般目标来说,翻转前后类别不变,但红绿灯是左右对称的实物,翻转后红灯还在红灯位置,语义上应该不变。问题出在标注框和类别没有同步翻转,或者你用了带方向属性的箭头灯类别,red_left翻转后变成了red_right,而你的类别表里只有red_left没有red_right,模型只能硬把它认成别的类。
解决:训练命令里加一行关闭水平翻转:fliplr=0.0。如果是箭头灯方向问题,要么补全左右类别,要么也关闭翻转。红绿灯场景对翻转增强的收益本来就小,关掉后模型更稳。
5.2 标注框范围玄学:框太大mAP不稳,框太小训练不收敛
现象:同一份数据,标注人员有的框住整个灯壳,有的只框发光圆点,模型训练后mAP在0.7和0.85之间大幅波动,怎么调参数都压不下来。
原因:灯壳框里包含了外壳、黑色背景和反光,不同状态下视觉特征差异变小;只框发光点又会让目标尺寸过小,YOLOv8的anchor匹配难度剧增。信号灯在整幅图中往往只有20×20像素,标注口径稍微不统一,损失就一直在震荡。
解决:统一标注规范:以发光灯芯的椭圆外切矩形为准,略微外扩2~3个像素,让框内主要是发光区域而不是灯壳。最好由一个人标注所有数据,并在转换脚本里做一次框尺寸统计,异常大的框单独排查。
5.3 摄像头偏色导致HSV误判:白灯被认成黄灯
现象:现场测试时,明明是白色LED倒计时数字,HSV判定结果却是黄色,车辆在路口跟着倒计时乱走。
原因:监控摄像头自动白平衡在夜间会偏向暖色,白色灯珠色温漂移后落入黄色HSV区间。HSV阈值是固定的,而真实世界的色温不是固定值,用单一阈值覆盖所有时段必然翻车。
解决:几种方案叠加使用。先把模型分类置信度和HSV判定结果做加权融合,两者一致才输出;再把HSV阈值改成动态校准,取检测框周围天空或路面的灰度值做白平衡参考;最省事的办法是固定摄像头曝光和白平衡参数,禁止自动调节。如果在低成本项目里,我会优先用模型分类结果,把HSV降级为辅助角色。
5.4 导出ONNX后输出维度算错:84个通道不是80类
现象:用yolo export format=onnx导出模型后,自己写Python推理,发现输出形状是[1, 84, 8400],按COCO的80类去解析,得到的坐标全乱。
原因:YOLOv8的输出通道数是4 + 类别数。COCO预训练模型是4 + 80 = 84,但你的红绿灯模型只有3类,正确输出应该是4 + 3 = 7个通道。如果你直接套网上的COCO后处理代码,类别数对不上,坐标和置信度解析自然全错。
解决:导出后先打印模型输出形状,确认类别维度。解析时按4个坐标 + 你的类别数切分,不要写死84。还有个更省事的做法是直接用ultralytics的model.predict跑ONNX,内部后处理已经替你处理了维度变化。
5.5 RK3588部署时精度骤降:量化校准集不能只放白天
现象:同一个模型在PC上FP32推理mAP正常,转成RKNN放到RK3588上之后,夜间红灯漏检率明显上升,白天的结果倒是还行。
原因:RKNN默认做INT8量化,需要一组校准图片来统计激活值分布。如果校准集全是白天的路口图,模型对夜间暗光、车灯光晕、路灯偏色的激活范围没有覆盖,量化后这些场景的特征被截断,精度就掉了。
解决:准备200~500张覆盖白天、夜晚、逆光、雨天的路口图作为量化校准集。校准集不需要标注,但必须贴近真实部署场景。转模型时把校准集路径分好,转换后先在PC上对比量化前后每张测试图的输出,差异超过预期就扩大校准集或者改用混合量化。
6. 进阶与最终验证:在RK3588上跑通并统计FPS和漏检率
最后一公里是部署验证。如果你只是做PC端离线分析,模型在GPU上跑就够了;但要做路侧或车载实时检测,RK3588的NPU是很常见的目标。标题里提到的源码和文档,落到部署这一步,我建议重点验证两点:帧率和红灯漏检率。
6.1 RK3588部署路径:YOLOv8转ONNX再转RKNN
先把训练好的best.pt导出成ONNX:
yolo export model=runs/detect/train/weights/best.pt format=onnx opset=12 imgsz=640然后在PC上使用RKNN-Toolkit2,把ONNX转成RK3588用的rknn模型。转换脚本的核心部分大概是这样:
from rknn.api import RKNN rknn = RKNN() rknn.config(mean_values=[[0, 0, 0]], std_values=[[255, 255, 255]], target_platform='rk3588') rknn.load_onnx(model='best.onnx') rknn.build(do_quantization=True, dataset='calibration.txt') rknn.export_rknn('traffic_light_rk3588.rknn')calibration.txt里放的是量化校准图片路径,每行一张,内容建议按5.5的要求准备。mean_values和std_values要和你训练时的预处理保持一致,YOLOv8默认是除以255,所以均值0、标准差255没问题。
板端推理代码可以用RKNN的Python接口,推理前把图像resize到640×640,输出后按检测框格式解析。验证时拿一段真实路口视频循环跑,统计每秒帧数FPS,并记录红灯漏检和绿灯误放行两个指标:
| 验证维度 | 测试方法 | 通过标准 |
|---|---|---|
| FPS | 连续推理1000帧取平均 | RK3588上建议大于25 |
| 红灯漏检率 | 统计标注红灯帧中被漏检的比例 | 小于1% |
| 绿灯误放行 | 统计红灯状态下输出可通行的帧数 | 必须为0 |
| 判定抖动 | 统计3秒内状态翻转次数 | 小于2次 |
6.2 验证之后我养成的调优习惯
每次部署完,我都会把低置信度检测框和HSV判定不一致的帧单独存成图片,积累一周后拿去做二次标注,补进训练集。这个习惯看起来笨,但效果比调任何超参数都明显。我也学会了先怀疑规则层,再怀疑模型本身——很多“模型识别错了”的结论,最后发现是车道方向映射写反了。希望这篇笔记能帮你少走几段弯路,也希望你的路口信号灯项目一次跑通。
本文还有配套的精品资源,点击获取