简介:一份面向目标检测与计算机视觉实践的交通信号灯数据集,覆盖红、绿、黄三类信号灯实例,采用COCO格式标注,适合用于模型训练、验证与算法评测。资源包含2000个文件,其中1995张jpg图像提供真实道路及复杂背景下的信号灯样本,3个json文件对应COCO标准标注信息,可直接接入主流检测框架,另有2个txt文件用于类别说明或数据划分;压缩包总大小223.94MB,目录结构清晰,便于按需解压使用。目前已有594人学习下载。该数据集可支撑交通场景感知、智能驾驶辅助等方向的研究与教学,省去自行采集和标注的繁琐流程,帮助开发者快速搭建实验数据,聚焦模型设计与调优。对于需要规范标注数据的初学者和研究人员,是一份较为实用的起步资源。
1. 交通信号灯数据集:三色识别为什么没有想象中简单
「交通信号灯数据集」听起来是感知任务里最没门槛的一类——红灯停、绿灯行、黄灯等一等,三分类而已。可真把模型放到夜间路口、逆光、小雨天和强反射路面跑一遍,你会发现这三个颜色的识别准确率往往连白天的一半都不到。问题大多不在模型结构,而在数据本身:灯体小、亮度高、颜色受曝光和白平衡影响大,COCO格式的标注一旦在类别映射、边框范围这些细节上含糊,训练出来的检测器就只能在测试集上自欺欺人。这套zip提供的就是带红绿黄三类标签、按COCO JSON组织的信号灯数据,解压后通常能看到 images 目录和 annotations 目录,适合自动驾驶感知、智慧交通路侧巡检、端侧信号灯识别这类场景。
我按自己处理信号灯项目的习惯,把这份数据从验货到训练、再到评估的完整路径拆开讲。新手可以照着每一步操作,熟手可以重点看避坑章节里的参数边界。
2. 拆开COCO标注:信号灯数据的JSON结构、字段含义与验货手段
2.1 从instances_train.json读懂三类标记:categories、images、annotations
拿到COCO格式的数据集,第一步不是急着训练,而是先把JSON结构摸清楚。一个标准的COCO标注文件包含五个顶层字段:info(数据集说明)、licenses(许可)、images(图片列表)、annotations(标注列表)、categories(类别列表)。其中信号灯项目真正关心的是后三个。打开文件前先跑一段统计脚本,确认图片数、框数和类别名,避免后面在错误的映射关系上浪费半天。
import json import collections with open("annotations/instances_train.json", "r", encoding="utf-8") as f: coco = json.load(f) images = coco["images"] annotations = coco["annotations"] categories = coco["categories"] print("图片数:", len(images)) print("标注框数:", len(annotations)) # 建立 category_id 到类别名的索引,这就是后面转格式时用的“标记名字典” cat_id_to_name = {cat["id"]: cat["name"] for cat in categories} print("类别定义:", cat_id_to_name) # 统计每个类别的样本数量,信号灯数据很容易出现黄灯样本远少于红灯、绿灯的情况 cls_counter = collections.Counter(ann["category_id"] for ann in annotations) for cat_id, cnt in cls_counter.items(): print(f"类别 {cat_id} ({cat_id_to_name[cat_id]}): {cnt} 个标注")这段脚本重点看三件事。第一,categories里每个类别的id通常从 1 开始连续编号,但有些标注工具生成的id不连续甚至从 0 开始,这直接决定后面转换脚本要不要做偏移。第二,annotations里的bbox字段是[x, y, width, height]格式,不是[x1, y1, x2, y2],很多第一次接触COCO的人在这里看走眼。第三,类别名要统一成英文小写(red、green、yellow),如果数据集里混入了 amber、red_light 这类别名,后面训练时类别数会莫名变多。
2.2 信号灯数据集是怎么标注出来的:工具的选用、灯体框法与版本管理
COCO格式本身只是一种组织方式,真正决定信号灯数据集好不好用的是标注时的框选策略。我的习惯是:优先按「单个灯体」框选,而不是框整个黑底灯组。框住一盏亮着的红灯,bbox 是紧凑的小目标;框住整个红绿灯组,bbox 变成中大型目标,检测器学到的其实是「灯组区域」而不是「灯的颜色」。这两种策略在mAP数字上差别不大,但接下游(比如根据灯体位置估算距离、判断倒计时)时不紧凑的bbox会带来明显误差。
常见的标注工具是Labelme或X-AnyLabeling。Labelme的默认导出格式是单个JSON文件,之后需要再从JSON合成COCO;X-AnyLabeling可以直接导出COCO格式。处理信号灯这种小物体,建议画矩形框而不是多边形,因为绿灯和黄灯的发光区域边界模糊,多边形标注的主观性会比矩形更大。多人协作时,原始标注文件常用SVN这类版本管理工具维护,这里有个血泪教训:SVN按行合并文本,多人同时改同一个标注JSON必然产生冲突,而且冲突后留下的往往是一份语法损坏的JSON,解析到一半就报错。我一般让每个人负责不同的图片ID段,各自提交,最后合并时统一重排 annotation id 和 image_id,而不是直接让SVN自动合并。
标注策略上还有一条需要提前决定:信号灯的黄灯闪烁和红绿灯切换瞬间,同一盏灯可能拍到半亮半暗的过渡态。这种帧要么删掉,要么标记为单独的 blinking 类别,不要硬塞进 yellow 类里,否则模型会把闪烁状态和稳定状态学到两套特征。
2.3 拿到数据集先验货:可视化检查与目标尺寸分布统计
JSON结构没问题、标注数量合理,并不代表数据可以直接训练。我每次必做两件事:抽样可视化、统计目标尺寸分布。抽样可视化可以在图上画出bbox并保存到本地目录,人工排查有没有错标、漏标、框得离谱的样本。OpenCV的putText不支持中文,所以输出标签直接用类别ID或英文名。
import json import os from PIL import Image, ImageDraw, ImageFont with open("annotations/instances_train.json", "r", encoding="utf-8") as f: coco = json.load(f) images_by_id = {img["id"]: img for img in coco["images"]} cat_id_to_name = {cat["id"]: cat["name"] for cat in coco["categories"]} os.makedirs("check", exist_ok=True) # 只画前200个标注,防止一次生成太多图片 count = 0 for ann in coco["annotations"]: if count >= 200: break img_info = images_by_id[ann["image_id"]] img_path = os.path.join("images", img_info["file_name"]) if not os.path.exists(img_path): continue img = Image.open(img_path).convert("RGB") draw = ImageDraw.Draw(img) x, y, w, h = [int(v) for v in ann["bbox"]] draw.rectangle([x, y, x + w, y + h], outline="red", width=3) label = cat_id_to_name.get(ann["category_id"], str(ann["category_id"])) draw.text((x, max(0, y - 15)), label, fill="red") out_path = os.path.join("check", f"img{img_info['id']}_ann{ann['id']}.jpg") img.save(out_path) count += 1 print("已生成可视化样本到 check/ 目录")花几分钟看完这些图,能发现很多JSON统计暴露不了的问题:有没有把车尾灯当红灯框进去、有没有把倒计时数字当成整盏灯、黄灯和红灯在视觉上是否真的可分。另外要统计一下目标尺寸——信号灯在路口画面里通常是几十个像素的小目标,知道这个分布后才能决定训练时的输入分辨率。统计方法很简单:把所有bbox的宽高分别取出来算分位数,如果中位数宽高小于32像素,那训练时imgsz=640大概率是不够的,后面第3章我会给出参数建议。
3. 让标注跑起来:COCO转YOLO、数据集划分与信号灯训练参数
3.1 先划分数据集而不是先转格式:按视频片段分组的划分脚本
很多人拿到数据先写转换脚本,把COCO转成YOLO txt,再考虑划分训练验证集。我习惯反过来,先划分图片再做格式转换。原因是信号灯数据通常来自连续视频抽帧,相邻帧之间高度相似。如果用随机划分,同一段视频的前一帧进了训练集、后一帧进了验证集,验证集mAP会虚高,等模型部署到新路口立刻打回原形。
import json import random random.seed(42) with open("annotations/instances_train.json", "r", encoding="utf-8") as f: coco = json.load(f) images = coco["images"] image_ids = [img["id"] for img in images] # 按 80/20 做随机划分(仅适用于非视频连续帧的数据) train_ids = set(random.sample(image_ids, int(len(image_ids) * 0.8))) val_ids = set(image_ids) - train_ids print("训练集图片数:", len(train_ids)) print("验证集图片数:", len(val_ids)) # 在文件系统里按 train/val 子目录复制或移动图片 import shutil import os os.makedirs("images/train", exist_ok=True) os.makedirs("images/val", exist_ok=True) for img in images: src = os.path.join("images", img["file_name"]) if not os.path.exists(src): continue dst_dir = "images/train" if img["id"] in train_ids else "images/val" shutil.copy(src, os.path.join(dst_dir, img["file_name"]))如果数据来自多个路段或者多段视频,简单随机划分依然不靠谱。正确做法是按「视频片段ID」或「拍摄场景ID」分组划分,保证同一个场景的帧只在训练集或只在验证集。可以用 scikit-learn 的GroupShuffleSplit:groups数组里每个元素对应一张图片所属的视频片段编号,test_size=0.2表示抽出20%的片段做验证。参数上要注意random_state必须固定,否则每次运行划分结果不同,调参时对比评估就没意义了。
如果 zip 里已经自带了 train/val 的 JSON 文件,说明作者已经做了这一步,你就没必要再划分,直接进入转换阶段就行。
3.2 COCO转YOLO txt的转换脚本:类别映射、归一化与越界处理
YOLO系列训练框架读不了COCO的JSON,它需要每张图片对应一个同名txt文件,每行五个数字:类别ID、归一化中心点x、归一化中心点y、归一化框宽、归一化框高。转换逻辑不复杂,但有两个坑。第一是类别ID偏移:COCO的category_id从1开始,YOLO要求的类别ID从0开始,两者差1。第二是bbox越界:标注工具偶尔会把框画出图片边界,转换时不做clip会让模型学到错误的目标位置。
import json import os def coco_to_yolo(coco_path, images_dir, out_dir, cat_offset=1, valid_cat_ids=None): with open(coco_path, "r", encoding="utf-8") as f: coco = json.load(f) images_by_id = {img["id"]: img for img in coco["images"]} os.makedirs(out_dir, exist_ok=True) for ann in coco["annotations"]: cat_id = ann["category_id"] # 如果指定了有效类别集合,跳过不在集合内的类别 if valid_cat_ids and cat_id not in valid_cat_ids: continue img = images_by_id[ann["image_id"]] img_w, img_h = img["width"], img["height"] x, y, w, h = ann["bbox"] # 处理越界框:中心点被截断时重新计算有效宽高 x2 = min(x + w, img_w) y2 = min(y + h, img_h) x = max(x, 0) y = max(y, 0) w = x2 - x h = y2 - y if w <= 0 or h <= 0: continue # 类别ID按偏移量转换并做合法性校验 cls_id = cat_id - cat_offset if cls_id < 0: continue # 转为YOLO归一化的中心点坐标 cx = (x + w / 2) / img_w cy = (y + h / 2) / img_h nw = w / img_w nh = h / img_h txt_name = os.path.splitext(img["file_name"])[0] + ".txt" txt_path = os.path.join(out_dir, txt_name) with open(txt_path, "a", encoding="utf-8") as f: f.write(f"{cls_id} {cx:.6f} {cy:.6f} {nw:.6f} {nh:.6f}\n") print("转换完成,标注文件输出到:", out_dir) coco_to_yolo( coco_path="annotations/instances_train.json", images_dir="images/train", out_dir="labels/train", cat_offset=1, valid_cat_ids={1, 2, 3} # 假设1=red, 2=green, 3=yellow )脚本里cat_offset是给类别ID偏移用的,如果数据集的category_id恰好是0、1、2,这里就传0。valid_cat_ids用于过滤掉噪声类别,比如某些信号灯数据集里混入了「行人过街按钮」这类附属目标。转换完成后,检查几个关键数字:labels/train里txt文件的数量应该约等于图片数量;随便打开一个txt文件,确认五列数值都在0到1之间;再数一下每种类别出现的总行数,和COCO JSON里的分布对得上才对。
3.3 最小训练配置:img尺寸、色彩增强、翻转与mosaic的取舍
转换完成后进入训练环节。信号灯检测器和常规目标检测有个显著区别:颜色本身就是最强的类别特征,所以一切可能破坏颜色语义的数据增强都要谨慎。我在Ultralytics YOLO配置里一般这样做。
# signal_light.yaml path: ./ train: images/train val: images/val nc: 3 names: 0: red 1: green 2: yellowyolo detect train \ model=yolov8s.pt \ data=signal_light.yaml \ imgsz=1280 \ epochs=120 \ hsv_h=0 \ hsv_s=0.2 \ hsv_v=0.2 \ fliplr=0 \ flipud=0.3 \ mosaic=0.5 \ batch=-1几个关键参数说明。
hsv_h=0是我做信号灯检测必改的参数。HSV色调扰动会把红色变成橙色、把黄色变成绿色,这种增强在普通数据集上能提升泛化能力,在信号灯数据上等于往类别标签里撒盐。HSV饱和度和亮度扰动可以留一点,用来模拟早晚光线变化,但幅度控制在0.2以内。
fliplr=0同样是信号灯特有问题。圆饼灯水平翻转不影响语义,但数据里如果有箭头灯(左转箭头、右转箭头),水平翻转后箭头方向颠倒,类别标签就错了。如果确定数据里全是圆饼灯,开fliplr=0.5可以增加样本多样性;我一般宁愿不开,因为信号灯在路口的物理位置左右分布本就固定,翻转带来的增益有限。
mosaic=0.5是一个值得反复试验的参数。Mosaic把四张图拼成一张,小目标在拼接过程中很容易被裁掉一半,变成半个红灯。我通常从0.5起步,如果验证集小目标AP不涨,就降到0.3或直接关闭。
imgsz=1280是所有参数里对检测效果影响最大的一个。信号灯在1080p画面里往往只有20到50像素,imgsz=640意味着目标缩到十几像素,特征几乎消失。显存够就上1280,不够就退到960,再不够就640配合SAHI切片推理,但后者会增加部署复杂度。
训练参数和硬件对应关系可以参考这个表:
| 模型 | imgsz | 典型显存 | 适用场景 |
|---|---|---|---|
| YOLOv8n | 640 | 6GB以内 | 端侧实时推理 |
| YOLOv8s | 1280 | 8-12GB | 大多数路侧场景 |
| YOLOv8m | 1280 | 16GB以上 | 夜间小目标较多的场景 |
4. 信号灯数据集避坑:5个让模型翻车的常见问题与排查
4.1 黄灯被模型当成了红灯
现象:白天测试效果不错,傍晚和夜间黄灯识别准确率明显下降,混淆矩阵里 red 和 yellow 两类互串严重。
原因有两层。第一层是标注问题:标注者主观上对黄灯和红灯的色相边界把握不一致,或者标注工具里有人写了amber、yellow_light这种别名,导致标记名字典不统一,类别数量从3个变成4个。第二层是数据分布问题:黄灯在路口的点亮时间最短,稳定黄灯样本可能只有红灯样本的三分之一,模型天然倾向把不确定的样本分到多数类。
解决:先回到第2章的统计脚本,检查categories定义和类别分布。把标记名字典统一成red、green、yellow三个ID。如果类别确实不平衡,优先给黄灯类加权重,或者用数据增强提高黄灯帧的出现频率;不要简单粗暴地复制黄灯样本到训练集,容易过拟合。
4.2 夜间过曝让红灯变成白色灯片
现象:夜间测试时红灯漏检率高,有时候检测器把红灯当成白灯或黄灯输出低置信度结果。
原因:信号灯LED亮度很高,相机ISP自动曝光为了平衡整体画面,会把亮着的红灯过曝成一片白。这种样本里红色通道接近饱和,RGB特征已经丢失,模型在白天学到的「红色=红灯」规则失效。
解决:先把过曝样本抽样画出来看看,确认是不是这个问题。如果过曝比例高,第一个手段是做数据清洗,把红通道饱和面积超过一定比例的样本单独挑出来,人工确认还能不能辨认;第二个手段是训练时对图像做亮度增强,模拟不同曝光条件下的灯体颜色变化;第三个手段是专门采集夜间和黄昏数据,让训练集覆盖从欠曝到过曝的完整区间。这三种手段按成本从低到高排列,先做清洗,再看效果决定要不要增加数据。
4.3 验证集mAP虚高:随机划分把同一段视频拆散了
现象:训练集和验证集mAP都很高,达到0.85以上,但换一个没见过的路口视频测试,mAP掉到0.4。
原因:数据来自视频抽帧时,相邻两帧的画面几乎一样。随机划分数据集时,第10帧进了训练集、第12帧进了验证集,模型等于「提前见过」验证集的内容。
解决:用第3章的GroupShuffleSplit按视频片段划分,保证验证集里出现的路口和场景在训练阶段完全没出现过。如果zip数据里没有提供视频片段ID,可以用文件名里的时间戳或地点字段猜测分组。验证结果以新场景测试为准,不要只看验证集曲线。
4.4 COCO evaluator的area字段陷阱
现象:用COCO API评估时发现 AP_small 异常低、AP_medium 异常高,但人工看图检测效果还行。
原因:COCO评估里的小目标、中目标、大目标划分依赖annotations里的area字段。很多转换脚本把area直接赋值成bbox的宽乘高(像素面积),如果原图分辨率是1920×1080,而评估代码里用默认的32×32、96×96像素阈值来分桶,大量信号灯会被归错桶,导致小目标统计失真。
解决:确认area和bbox使用的单位一致。如果只有bbox,area = w * h,评估时也按这个单位算。另外要检查area有没有被写成归一化值(0到1之间的小数),这个错误会让所有目标都被分到small类里。排查方法很简单:随机抽20个标注,手工计算一下bbox面积,和JSON里的area字段对比。
4.5 命令行参数与配置文件打架
现象:照着网上的命令训练时,终端报您使用的是不受支持的命令行标记,或者明明传了hsv_h=0,训练日志里增强参数还是默认值。
原因:新版本的YOLO训练脚本对命令行参数做了白名单校验,任何未定义的参数都会被当成非法标记拒绝。另一部分参数如果同时出现在配置文件和命令行里,命令行覆盖规则和配置文件覆盖规则在不同版本里不一致。
解决:把训练超参数统一写进data.yaml或独立的args.yaml,命令行只保留模型路径和数据路径这种最简单的内容。改配置后先打印一遍完整配置确认生效,再启动训练。这个习惯能省下大量反复启动训练的等待时间。
5. 验证不止mAP:分距离评估与信号灯状态平滑
mAP是及格线,不是终点。信号灯检测接下游任务时,至少要回答两个问题:近处能不能稳定识别、远处能不能及时识别。所以我建议把目标按像素高度分桶评估,比如h < 20px、20 <= h < 60px、h >= 60px三档,分别看各档的查全率。原因是信号灯越小,检测难度越高,三档指标能直接告诉你模型的实际可用距离范围,而这个信息mAP是给不出来的。分析脚本不需要额外依赖,直接从验证集结果里过滤出不同高度的目标框,分别算precision和recall。
第二种验证思路是检测器输出之后加状态平滑。信号灯本身有严格的时序状态链——红灯、绿灯、黄灯按固定顺序切换,单帧检测结果却经常出现跳变:红灯结束时某一帧被误判成黄灯,下一帧又变回红灯。这种噪声对下游决策系统很不友好。一个成本最低的平滑方法是用滑窗投票,取当前帧前N帧的检测结果做众数统计,只有某个类别占比超过阈值才输出。
from collections import deque, Counter # 维护最近5帧的检测类别状态 state_buf = deque(maxlen=5) def smooth_state(current_state, threshold=0.6): if current_state is not None: state_buf.append(current_state) if len(state_buf) < 3: return current_state counter = Counter(state_buf) main_state, count = counter.most_common(1)[0] if count / len(state_buf) >= threshold: return main_state return None参数上maxlen=5表示滑窗5帧,threshold=0.6意味着5帧里要有3帧以上赞成同一状态才输出。这套逻辑对单帧误检有抑制作用,对持续一两帧的漏检也能补回来。如果你想做得更细,可以做带时长的状态机,信号灯的每一种状态至少持续若干秒,实践中我给每帧打上时间戳和周期序号,用这类「日期和周期标记」约束状态切换不允许逆序发生,模型输出和先验状态冲突时直接按先验状态返回。
我处理信号灯项目的习惯是:先把第2章的验货脚本跑一遍,确认数据没有大类错标;再按第3章的流程划分、转换、训练;最后用第5章的分距离评估验证部署边界。这套流程看着朴素,但能避开大多数靠玄学调参的弯路。希望帮到你。
本文还有配套的精品资源,点击获取