简介:这是一份可用于目标检测实验的交通标志与信号灯数据集,由作者使用LabelImg软件原创标注整理,覆盖限速牌、交通警告牌、红灯、绿灯、黄灯等常见类别,适合在自动驾驶、智能交通、辅助驾驶等场景下训练和评测标志识别模型,也适合初学者学习图像标注、标注格式转换与YOLO训练流程。原始XML标注中保存了每个目标的边界框坐标,同时已转换为YOLO所需的txt格式并附带类别编号,可以直接接入YOLO系列训练流程,省去自行整理标签的时间。压缩包内共1641个文件,包括877张PNG高清图片、761个XML标注文件、2个txt文本文件与1个Python脚本,整体大小约217.98MB,文件命名对应关系明确,便于按图查找标注。目前已有475人浏览学习,适合需要自带标注数据进行模型训练、迁移学习或格式转换练习的目标检测开发者,也可作为交通标志识别实验的配套数据集使用。整套资源结构完整,拿来即可开展实验。
1. YOLO 交通标志与信号灯检测数据集:878 张图到底能拿来做什么
做目标检测实验最耗时间的不是调模型,是凑数据。这是我从这套 YOLO 交通标志与红绿灯检测数据集里拆包后最直接的感受:878 张高清道路图,限速牌、交通警告牌、红灯、绿灯、黄灯五类目标,先用 labelimg 标注成 VOC 格式的 xml,再转成 YOLO 训练直接能用的 txt 标签,图片和标签文件按同名配对排好。对赶课设、做毕设,或者只是想快速验证一个检测思路的人来说,省掉的是两周的标注工时,拿到手就能进 yolov8 的训练流程。这套数据也适合做数据增强对比、预训练权重微调和红黄绿相似类别的区分实验;但边界同样清楚——它是实验级规模,不是量产自动驾驶数据集,想验证小目标和颜色相近类别时刚好对口。后面几章我按实际拆包顺序来讲:先看懂两种标注格式,再把 xml 转 txt 的脚本和参数讲透,最后给训练配置和踩坑记录。
2. 两种标注格式:VOC xml 的四角坐标与 YOLO txt 的归一化
2.1 xml 里到底存了什么:先学会读一个标签文件
labelimg 默认导出的是 PASCAL VOC 格式,每张图对应一个同名 xml。第一次拿到这种文件的人容易直接打开就懵,其实结构很固定:根节点是 annotation,下面有 folder、filename、size 和若干个 object 节点。size 里存图片宽高,object 里的 name 是类别名,bndbox 是矩形框的四个角坐标。一个常见 xml 长这样:
<annotation> <folder>road</folder> <filename>road7.png</filename> <size> <width>1280</width> <height>720</height> <depth>3</depth> </size> <object> <name>red_light</name> <bndbox> <xmin>345</xmin> <ymin>210</ymin> <xmax>402</xmax> <ymax>268</ymax> </bndbox> </object> </annotation>上面这段是结构示例,不是某一帧的真实坐标。要注意的是 xmin、ymin 是框左上角,xmax、ymax 是框右下角,单位是像素,全部基于原图尺寸,没有做归一化。一张图里有几个目标,就会出现几个 object 节点,所以同一张 road7.png 里既可能有红灯也可能有限速牌。我一般拿到新数据集会先写两行代码统计一下每个类别的框数量,确认类别分布再谈训练,这一步能避免后面所有类别不均衡带来的连锁问题。
import xml.etree.ElementTree as ET from collections import Counter cnt = Counter() for i in range(878): tree = ET.parse(f"labels_xml/road{i}.xml") for obj in tree.getroot().findall("object"): cnt[obj.find("name").text] += 1 print(cnt)这段脚本的作用是遍历全部 xml,统计每个 name 出现的次数。运行前把路径改成你自己的 xml 目录,如果打印出来的分布里红灯只有几十个、限速牌有几百个,训练时就要考虑要不要对少数类做增广。xml 的 name 字段是字符串,容易写错大小写或空格,比如red light和red_light是两个完全不同的值,统计时就能暴露这种问题。
2.2 YOLO txt 存的是归一化中心点,不是四角坐标
很多人拿到 txt 后第一反应是「这四个数是什么坐标」,其实 YOLO 格式每行只有五个数:类别编号、中心点 x、中心点 y、宽、高,全部除以图片宽高做了归一化,所以取值范围在 0 到 1 之间。这套数据集在发布前已经用 labelimg 标注 xml,再批量转成了 txt,但自己动手转换时,最容易错的就是这一步——直接把 xmin、ymax 除以宽高当中心点用。
举个具体例子:图片宽 1280、高 720,某个红灯框 xmin=345、ymin=210、xmax=402、ymax=268。先算框宽和高:402-345=57,268-210=58;中心点 x 是 (345+402)/2=373.5,再除以 1280 得到 0.2918;中心点 y 是 (210+268)/2=239,再除以 720 得到 0.3319;最后宽高各除以图片宽高,得到 0.0445 和 0.0806。这一行 txt 就是:
2 0.2918 0.3319 0.0445 0.0806开头那个 2 是类别编号,对应类别列表里的第 3 个类。这套数据的类别编号通常是 speed_limit=0、warning_sign=1、red_light=2、green_light=3、yellow_light=4,但编号本身没意义,意义在于和训练配置文件里的 names 顺序严格一致。txt 里只存编号不存名字,所以编号一旦错位,训练出来的模型就会把红灯学成绿灯,而且从数据文件里肉眼根本看不出来。
2.3 类别编号是顺序敏感的:classes 数组绝不能乱排
新手最常见的做法是拿到数据集先改一遍类别名,把 red_light 改成红灯、green_light 改成绿灯,然后直接训练。这是典型的翻车操作:txt 第一列的数字不会跟着名字走。你改了训练配置里的 names 顺序,但标签文件里的 2 还是原来的 red_light,两个文件对不上,训练过程不会报错,只是模型学的是错的东西。
所以第一原则是:转换脚本里的 CLASSES 列表、data.yaml 里的 names、验证脚本里用来对照的类别表,三处必须保持一致。我一般习惯把类别列表单独抽到一个文本文件或脚本常量里,任何一处要改,三处一起改,绝不留一处。后面会讲到具体怎么写这个映射。理解了这个前提,再看 xml 转 txt 的脚本就顺了,本质上就是读四个角坐标,按上面的公式算归一化,再查表换成编号。
3. 把 xml 批量转成 txt:转换脚本、数据划分与格式自检
3.1 批量转换脚本:解析 xml 计算归一化坐标
这套数据集发布时已经转好 txt,但下载后你很可能想自己调整类别、过滤小目标,或者往里面加自己的图片,所以转换脚本必须会写。核心逻辑很简单:遍历 xml 目录,读 size 拿宽高,遍历每个 object 的 bndbox,按中心点和宽高除以图片尺寸,拼成一行写入同名 txt。
import os import xml.etree.ElementTree as ET src_dir = "labels_xml" # labelimg 导出的 xml 目录 dst_dir = "labels_txt" # 转换后的 yolo 标签目录 os.makedirs(dst_dir, exist_ok=True) CLASSES = ["speed_limit", "warning_sign", "red_light", "green_light", "yellow_light"] for xml_name in os.listdir(src_dir): if not xml_name.endswith(".xml"): continue xml_path = os.path.join(src_dir, xml_name) txt_path = os.path.join(dst_dir, xml_name.replace(".xml", ".txt")) tree = ET.parse(xml_path) root = tree.getroot() # 图片宽高直接取自 xml 的 size 节点 w = int(root.find("size/width").text) h = int(root.find("size/height").text) lines = [] for obj in root.findall("object"): name = obj.find("name").text if name not in CLASSES: print(f"skip unknown class: {name} in {xml_name}") continue cls_id = CLASSES.index(name) box = obj.find("bndbox") xmin = float(box.find("xmin").text) ymin = float(box.find("ymin").text) xmax = float(box.find("xmax").text) ymax = float(box.find("ymax").text) cx = ((xmin + xmax) / 2.0) / w cy = ((ymin + ymax) / 2.0) / h bw = (xmax - xmin) / w bh = (ymax - ymin) / h lines.append(f"{cls_id} {cx:.6f} {cy:.6f} {bw:.6f} {bh:.6f}") with open(txt_path, "w", encoding="utf-8") as f: f.write("\n".join(lines)) print(f"done: {xml_name} -> {len(lines)} objects")脚本里值得说几个点。一是root.find("size/width")直接支持斜杠路径,不用先 find("size") 再 find("width"),省一层嵌套;二是未知类别不中断整个转换,而是打印一行跳过,这样一张图里有新类别时不会拖垮全部 878 张;三是 f-string 里保留 6 位小数,YOLO 对精度要求不高,6 位足够,文件也不会太大。CLASSES 这个列表的顺序就是 txt 第一列的编号,想调整类别顺序必须在转换这一步定,训练那边只能跟着它走。
还需要提醒一点:xml 里的 size 字段偶尔会和图片实际尺寸不一致,有可能是图片后来被裁剪或压缩过但 xml 没重新保存。最稳妥的做法是转换前用 PIL 读一次图片,直接拿实际宽高当分母:
from PIL import Image img = Image.open(img_path) w, h = img.size如果发现某张图的 xml 和图片对不上,以图片实际尺寸为准,否则归一化坐标会整体偏移,训练时框会错位。这也是格式自检环节要查的一项。
3.2 数据划分:按 8:2 分 train 和 val,并保证图片标签配对
878 张图不算多,划分比例我一般用 8:2,约 700 张训练、178 张验证。7:3 会让训练样本太少,9:1 又会让验证集太单薄,小数据集上 8:2 是性价比比较高的选择。划分时必须按图片粒度来,不能把同一张图的框拆到两个集合里,否则验证集数据泄露,指标虚高。
import os import random from shutil import copyfile random.seed(42) images = [f for f in os.listdir("images") if f.endswith(".png")] random.shuffle(images) split = int(len(images) * 0.8) train_imgs, val_imgs = images[:split], images[split:] # 生成 train 和 val 两个目录,图片和 txt 同步拷贝 os.makedirs("dataset/train/images", exist_ok=True) os.makedirs("dataset/train/labels", exist_ok=True) os.makedirs("dataset/val/images", exist_ok=True) os.makedirs("dataset/val/labels", exist_ok=True) for img in train_imgs: base = img.rsplit(".", 1)[0] copyfile(f"images/{img}", f"dataset/train/images/{img}") copyfile(f"labels_txt/{base}.txt", f"dataset/train/labels/{base}.txt") for img in val_imgs: base = img.rsplit(".", 1)[0] copyfile(f"images/{img}", f"dataset/val/images/{img}") copyfile(f"labels_txt/{base}.txt", f"dataset/val/labels/{base}.txt")random.seed(42) 的作用是让随机过程可复现,不管跑多少次,划分结果都一样。这一点在实验里很重要,否则每次重新划分后训练集不同,前后两次训练结果没法对比。图片和标签同步拷贝,是为了保证 train 目录里不会出现有图片没标签、或者有标签没图片的情况。如果你的类别分布很不均匀,比如红灯框特别少,8:2 随机划分可能让红灯都堆到验证集里,这时可以用分层抽样,按类别比例 shuffle,不过 878 张的规模通常随机划分就够了。
3.3 格式自检四件套:越界、类别、配对、空文件
训练跑起来之前花五分钟做格式自检,能省掉后面在日志里排查的几小时。我自己固定跑的脚本是四件事:坐标有没有越界、类别编号在不在范围内、图片和 txt 文件是否配对、有没有空标签文件。空标签文件不是错误,0 字节 txt 代表这张图没有目标,作为负样本加入训练是被允许的,但你要知道哪些图是空的。
import os import glob def check_all(txt_dir, img_dir, class_num): problems = [] empty_txts = 0 for txt in glob.glob(os.path.join(txt_dir, "*.txt")): base = os.path.basename(txt)[:-4] img_candidates = glob.glob(os.path.join(img_dir, base + ".*")) if not img_candidates: problems.append(f"missing image: {base}") continue with open(txt, "r") as f: lines = f.readlines() if len(lines) == 0: empty_txts += 1 continue for idx, line in enumerate(lines, 1): parts = line.strip().split() if len(parts) != 5: problems.append(f"{os.path.basename(txt)} line {idx}: not 5 cols") continue cid = int(parts[0]) if not (0 <= cid < class_num): problems.append(f"{os.path.basename(txt)} line {idx}: class id out of range") for v in parts[1:]: if not (0 <= float(v) <= 1): problems.append(f"{os.path.basename(txt)} line {idx}: coord out of [0,1]") print(f"problem count: {len(problems)}, empty txt: {empty_txts}") for p in problems[:20]: print(p) check_all("labels_txt", "images", 5)这段脚本有两个细节值得注意。一是用glob.glob(img_dir + base + ".*")而不是直接拼base.png,目的是匹配图片后缀,避免图片明明是 png 但代码里写 jpg 导致误报缺失;二是float(v)越界检查能挡住坐标系写反的情况,比如有人把四角坐标直接写了进去,第一列之后的值经常会大于 1,一眼就能查出来。自检跑完只输出 problem count,没问题的文件不刷屏,empty txt 单独计数,因为空标签文件要区分对待。
4. 训练前先避坑:五条容易翻车的标注与配置问题
4.1 现象:训练能跑通,但红灯被识别成绿灯
原因:这是类别映射错位。txt 第一列的编号 2 对应转换脚本里的 red_light,但 data.yaml 里的 names 可能被改成了0: green_light, 1: yellow_light, 2: red_light之外的顺序,或者训练框架重新排列了类别列表。txt 里只有编号没有名字,框架按 names 顺序解释编号,两边一对不上,模型就把红灯的特征学进了编号 2,而编号 2 在你配置里恰好是绿灯。
解决:训练前跑一遍逆向对照脚本,把每个 txt 第一列的编号和对应 xml 里的 name 拉出来比对,看映射是否符合预期。
import os import xml.etree.ElementTree as ET CLASSES = ["speed_limit", "warning_sign", "red_light", "green_light", "yellow_light"] for txt in os.listdir("labels_txt"): base = txt[:-4] xml_path = f"labels_xml/{base}.xml" txt_ids = [line.split()[0] for line in open(f"labels_txt/{txt}") if line.strip()] xml_names = [obj.find("name").text for obj in ET.parse(xml_path).getroot().findall("object")] mapped = [CLASSES[int(i)] if int(i) < len(CLASSES) else "??" for i in txt_ids] if mapped != xml_names: print(f"{base}: txt={mapped}, xml={xml_names}")这段脚本只输出不一致的文件。如果一条都不输出,说明转换时映射是对的。这条检查我每次都跑,因为数据量一大,肉眼根本扫不出来谁错位了。
4.2 现象:loss 刚起步就变成 nan,或者几个 epoch 后剧烈抖动
原因:标签里有越界坐标。比如标注时框拖出了图片边界,xmax 大于图片宽,归一化后宽大于 1;或者某条 txt 的长度写成 0,宽高为 0,损失函数里除以 0 直接 nan。clip 能救越界,但救不了宽高为 0 的框。
解决:转换时给坐标做一次裁剪,同时对宽高过小的框直接丢弃。
cx = min(max(cx, 0.0), 1.0) cy = min(max(cy, 0.0), 1.0) bw = min(max(bw, 0.0), 1.0) bh = min(max(bh, 0.0), 1.0) if bw < 0.001 or bh < 0.001: continue # 丢弃这个框clip 到 [0,1] 是物理上合理的操作,因为框越界的部分本来就不在图片里,损失函数只关心可见部分。0.001 这个阈值按图片尺寸折算,1280 宽的图大约是 1.28 像素,比这还小的框没有任何训练意义。这一条属于血泪经验,nan 出现时先查标签,别急着调学习率。
4.3 现象:批量转换在第 87 张中断,报 xmin not found
原因:某张图的 xml 结构不完整,可能是 labelimg 保存时漏了字段,也可能是有人手工改过 xml 导致标签节点缺失。没做异常处理时整个转换脚本就地崩溃,前面的成果都在,但你不知道断在哪一张。
解决:转换脚本里用 try/except 包住单张处理,失败时打印文件名,把坏文件汇总到 error_list 里,转换完统一修。
try: tree = ET.parse(xml_path) root = tree.getroot() # 转换逻辑... except Exception as e: print(f"ERROR: {xml_name}: {e}") with open("error_list.txt", "a") as f: f.write(xml_name + "\n") continue这样做的价值是:脚本跑完你能拿到一份坏文件清单,而不是守着一个报错屏幕干瞪眼。实际遇到过的坏文件,大部分是 bndbox 少了一个坐标,或者 depth 字段写成了非数字,拿到清单后逐个打开修就行,通常不超过三五张。
4.4 现象:训练日志大量出现 Image not found 或 label not found 警告
原因:文件没配对,或者路径口径不一致。图片是 road7.png,标签文件却叫 road7.txt,这套没问题;但如果你的图片是 .png 而训练配置里写的是 .jpg,或者 data.yaml 里用了相对路径而训练命令的工作目录不在项目根目录,就会出现这种警告。这类警告很容易被忽略,因为训练还能继续,只是某些图被跳过,最后指标偏低还找不到原因。
解决:统一扩展名,data.yaml 里用绝对路径,并在训练前跑一遍配对检查。重命名可以这样处理:
cd images for f in *.png; do mv "$f" "${f%.png}.jpg"; done cd ..注意这行命令只是演示统一后缀的批量操作,实际执行前先确认你图片原始格式,别把本来就正确的后缀强行改掉导致格式损坏。我更推荐的做法是保留原始格式,让配对检查脚本用base.*去匹配,从根本上绕开扩展名不一致的问题。
4.5 现象:mAP50 看起来还行,但红灯绿灯几乎没有召回
原因:红绿灯在远处是小目标,一张 1280x720 的图里,灯可能只占 30x80 像素,imgsz 降到 320 之后,下采样几次目标就剩几个像素,特征全丢。这类小目标对输入分辨率和数据增强策略都敏感。
解决:把 imgsz 提到 640 或 768,训练时开 mosaic 和 mixup,让小目标的上下文信息更丰富。显存不够就先用 640,同时缩小 batch。命令层面可以这样调:
yolo detect train data=data.yaml model=yolov8n.pt epochs=100 imgsz=768 batch=16 mosaic=1.0 mixup=0.2imgsz=768 会让训练变慢,但对小目标检测的提升往往是压倒性的。如果显存只有 8G,batch 要降到 8,或者先用 imgsz=640 跑通,观察红灯绿灯的召回率再决定要不要上 768。这一条说到底是数据场景决定的,交通信号灯天然是小目标居多,不能用通用检测的思路糊弄过去。
5. 用 YOLOv8 跑通训练:data.yaml、训练命令与日志解读
5.1 目录约定与 data.yaml 写法
训练前先把数据集整理成 YOLO 框架能直接读的目录结构。常见做法是把 train 和 val 分开,各自下面放 images 和 labels,标签文件和图片同名,只是后缀从 .png/.jpg 换成 .txt。
dataset/ ├── train/ │ ├── images/ │ │ ├── road7.png │ │ └── ... │ └── labels/ │ ├── road7.txt │ └── ... └── val/ ├── images/ └── labels/data.yaml 指向这个目录,重点是 names 的顺序必须和转换脚本里的 CLASSES 完全一致。我在第 2 章强调过三条命脉:转换脚本、data.yaml、验证脚本共用同一份类别表。以下是一个可用的配置:
path: /home/you/dataset train: train/images val: val/images names: 0: speed_limit 1: warning_sign 2: red_light 3: green_light 4: yellow_lightpath 建议写绝对路径,train 和 val 是相对 path 的子目录。这里最容易踩的坑是:names 里把 green_light 放到了 0,但标签文件里第一列的 0 本来对应 speed_limit,结果就是模型把所有限速牌都学成绿灯的特征。改 names 之前先问一句,txt 是重新生成的还是原来那份,原来那份就绝对不能动类别顺序。
5.2 从零还是预训练:模型选型与训练参数
878 张图的数据规模,不建议从零训练,用 COCO 预训练权重做 fine-tune 收敛快得多。YOLOv8n 是最轻量的版本,显存占用小,跑通整个流程不费劲;如果追求精度再往上升 s 或 m。预训练权重不需要手动找,训练命令会自动下载到本地缓存,这也是我推荐直接敲命令行而不是去网上找权重文件的原因。
cd dataset yolo detect train data=data.yaml model=yolov8n.pt epochs=100 imgsz=640 batch=16epochs 对 878 张图来说,100 是上限,实际上 40 到 60 epoch 就能看到曲线收敛。训练框会自动保存最好的权重,所以哪怕训到 100 epoch 过拟合了,best.pt 依然可用。参数表如下,方便你按自己机器调整:
| 参数 | 推荐值 | 说明 |
|---|---|---|
| model | yolov8n.pt | 显存小先跑 n,再换 s/m |
| epochs | 100 | 878 张图 40~60 足够观察趋势 |
| imgsz | 640 | 小目标多可升 768 |
| batch | 16 | 显存 8G 以下降到 8 |
| mosaic | 1.0 | 小数据集提升明显 |
| mixup | 0.1~0.2 | 防止过拟合,但拖慢收敛 |
| patience | 50 | 验证集不涨时早停 |
这套标签是标准 YOLO 格式,YOLOv5、YOLOv7 也能直接用,只要把 data.yaml 路径改一下,命令换成对应框架的写法即可。yolov8 是当前上手最顺的,文档全,报错信息也友好。
5.3 训练日志怎么读:loss 曲线、验证指标和混淆矩阵
训练完成后,runs/detect/train 目录下会生成 weights/best.pt 和 weights/last.pt,还有 results.csv、results.png、confusion_matrix.png 这些文件。results.csv 每一行是一个 epoch 的指标,我最常看的是 train 的 box_loss、cls_loss 和 val 的对应指标,以及 metrics/mAP50。
tail -n 10 runs/detect/train/results.csv看 csv 的顺序是:train loss 降、val loss 也降,健康;train loss 降、val loss 升,过拟合,878 张图训 100 epoch 很容易过拟合,这时要回看 best.pt 对应的 epoch,而不是用 last.pt。混淆矩阵要注意一点:矩阵里的数值总和不一定等于样本总数,因为一个真值框可能被多个预测框命中,匹配统计口径不同,别拿它当计数工具。看矩阵主要看对角线,红色通道那个框里的数字大不大,红灯有没有串到绿灯那一格去。mAP50 在 0.5 以上算基本可用的实验模型,mAP50-95 则更严格,反映了框定位质量,交通信号灯这种小目标通常 mAP50-95 会明显低于 mAP50,不用慌,升 imgsz 是最直接的改善手段。
验证训练效果最直接的方式是用 best.pt 跑预测:
yolo detect predict model=runs/detect/train/weights/best.pt source=test.jpg结果会保存到 runs/detect/predict,预测图上能看到框和类别名。这一步能直观看出模型学到的框位置准不准,比只看指标更有体感。
6. 验证效果与进阶习惯:混淆矩阵、类别均衡与负样本补强
6.1 三个进阶习惯:类别均衡、负样本和指标复核
训练结束后先统计各类别的框数量,用来判断指标背后的偏差。878 张图里如果红灯只有 60 个框,限速牌有 400 个,模型天然偏向限速牌,红灯的召回率再高也有限。可以写个计数器快速确认:
from collections import Counter import os cnt = Counter() for txt in os.listdir("labels_txt"): for line in open(f"labels_txt/{txt}"): cnt[int(line.split()[0])] += 1 print(cnt)统计结果按编号打印,和 data.yaml 的 names 对应着看。哪个类别框少,优先补数据而不是盲目调参。花两小时从视频里截几十帧红灯多的图,往往比折腾一周模型结构有效。补进来的图记得重新走一遍 xml 转 txt,再合并进 train 目录。
负样本是很多新手忽略的点。收集一些没有目标的路段图丢进 train/images,对应的 labels 放 0 字节空文件,模型能学会「没有灯的时候别乱框」。这招对压假阳性特别有效,尤其是路牌、车尾灯和红灯颜色接近的场景。负样本数量不用多,50 到 100 张就够。
最后一步是看混淆矩阵里红、绿、黄三个通道。这三类只有颜色差异,框的位置几乎一样,是典型的容易混淆组合。如果红灯那一格被黄灯吃掉不少,先确认标注时是否把红灯标成了黄灯,这是 labelimg 标注时最常见的疲劳错误;标注没错再考虑模型和增广。从那以后我每次拿到新数据集,都先在第一个 epoch 之前把格式自检四件套跑一遍——查重复、查越界、查类别映射、查文件配对,流程再快也强制等它跑完。这套习惯帮我挡住了太多把时间浪费在训练日志上的冤枉路,希望帮到你。
本文还有配套的精品资源,点击获取