多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

路标检测数据集实战:VOC/COCO/YOLO格式转换与YOLO训练全流程

路标检测数据集实战:VOC/COCO/YOLO格式转换与YOLO训练全流程 简介本资源为面向目标检测初学者与算法工程师的YOLO路标检测数据集聚焦真实道路场景下的交通标志识别任务可直接用于YOLO系列模型的训练与验证。数据集包含5000张高质量实景图片场景丰富经labelimg精细标注同时提供vocxml、cocojson与yolotxt三种格式标签分别存放于独立文件夹便于不同框架直接读取。压缩包共约2000个文件以1986个xml标注文件为主另含划分脚本、说明文档与训练教程等整体约82.41MB。资源附赠YOLO环境搭建、训练案例教程及数据集划分脚本可按需生成训练集、验证集与测试集帮助读者快速跑通从数据准备到模型训练的全流程。目前已有336人学习适合需要快速上手路标检测项目、补充实战数据的中高级学习者。1. 路标检测数据集到底解决了什么麻烦做交通视觉项目的工程师十有八九在数据准备阶段翻过车。模型结构抄得再漂亮anchor 调得再细只要标注格式对不上、类别映射错位、训练集里混进了重复帧mAP 就会莫名其妙地趴在地上。路标目标检测尤其如此同一块限速牌在白天、逆光、雨雾、夜间补光下呈现完全不同的纹理小目标占比高类别又细碎。你手里如果只有一堆散图没有统一格式的标签和可复现的划分脚本后面所有训练都是玄学。这个数据集的价值就在这儿5000 张路标图片配 VOC、COCO、YOLO 三种格式标签外加划分脚本和训练教程。它解决的不是“有没有数据”而是“数据能不能直接进训练管线”。适合三类人刚入门 YOLO 想跑通第一个检测任务的新手做交通/自动驾驶感知预研的算法工程师以及需要快速搭一个路标 baseline 再往上叠改进模块的人。下面我按实际落地顺序把格式转换、划分、训练、排错一条线讲透。2. 三种标签格式的差异与转换逻辑2.1 VOC、COCO、YOLO 到底差在哪很多人拿到数据集第一反应是“三种格式随便挑一个用”结果训练脚本读 VOC 的 XML评估脚本却按 COCO 的 json 解析直接报 key error。先把三者的结构差异钉死。VOC 格式是每张图一个 XML 文件核心节点是object里面name是类别名bndbox存xmin/ymin/xmax/ymax坐标是绝对像素值原点在左上角。它的优点是类别名可读、单文件自包含缺点是文件数量等于图片数量IO 碎片化。COCO 格式是一个大 jsonimages、annotations、categories三个主键。bbox是[x, y, width, height]注意是左上角坐标加宽高不是右下角。category_id通常从 1 开始0 一般留给背景。它的优点是单文件、支持实例分割和多任务缺点是 json 一旦损坏整批数据都读不了。YOLO 格式是每张图一个 txt每行class_id x_center y_center width height全部是归一化到 0~1 的相对值。class_id从 0 开始和data.yaml里names列表的下标严格对应。它的优点是轻量、读取快缺点是类别名不在文件里必须靠外部 yaml 维护。维度VOCCOCOYOLO单文件形式每图一个 XML单个 json每图一个 txt坐标类型绝对像素绝对像素归一化相对值框表示xmin,ymin,xmax,ymaxx,y,w,hxc,yc,w,h类别起始名称字符串通常 1 起0 起类别名位置XML 内json 内外部 yaml2.2 用脚本把 VOC 转成 YOLO 的最小实现实际项目里最常见的起点是 VOC因为很多标注工具默认导出 XML。下面这段脚本把 VOC 转 YOLO关键点是坐标归一化和类别索引映射。import os import xml.etree.ElementTree as ET # 类别列表必须和后续 data.yaml 的 names 顺序完全一致 CLASSES [speed_limit, stop, yield, no_entry, warning] cls_to_id {c: i for i, c in enumerate(CLASSES)} def voc_to_yolo(xml_dir, img_dir, out_dir): os.makedirs(out_dir, exist_okTrue) for xml_file in os.listdir(xml_dir): if not xml_file.endswith(.xml): continue tree ET.parse(os.path.join(xml_dir, xml_file)) root tree.getroot() # 用图片实际尺寸做归一化不能写死 640 size root.find(size) w int(size.find(width).text) h int(size.find(height).text) lines [] for obj in root.iter(object): name obj.find(name).text if name not in cls_to_id: continue # 未登记类别直接跳过避免索引错位 bbox obj.find(bndbox) xmin float(bbox.find(xmin).text) ymin float(bbox.find(ymin).text) xmax float(bbox.find(xmax).text) ymax float(bbox.find(ymax).text) # 转成中心点加宽高再除以图像尺寸 xc (xmin xmax) / 2.0 / w yc (ymin ymax) / 2.0 / h bw (xmax - xmin) / w bh (ymax - ymin) / h lines.append(f{cls_to_id[name]} {xc:.6f} {yc:.6f} {bw:.6f} {bh:.6f}) out_name os.path.splitext(xml_file)[0] .txt with open(os.path.join(out_dir, out_name), w) as f: f.write(\n.join(lines)) voc_to_yolo(Annotations, JPEGImages, labels)逻辑说明先读 XML 里的size拿到真实宽高这是归一化的分母写死 640 是新手最常犯的错遇到非 640 分辨率的图框会整体偏移。类别用字典映射到从 0 开始的整数遇到不在CLASSES里的类别直接跳过而不是报错保证批量转换不中断。坐标先算中心点和宽高再除以宽高归一化保留 6 位小数足够精度。参数说明CLASSES必须和训练时data.yaml的names顺序一字不差顺序错了模型学到的类别就是乱的。xml_dir、img_dir、out_dir三个路径按实际目录改。如果数据集里存在宽或高为 0 的脏图归一化会除零建议转换前先过滤。2.3 COCO 转 YOLO 时最容易错的两处COCO 转 YOLO 的坑集中在两点。第一COCO 的bbox是[x, y, w, h]其中x, y是左上角转换时中心点是x w/2不是x w。第二COCO 的category_id往往不连续比如只有 1、3、7直接拿它当 YOLO 的class_id会越界。正确做法是先把categories按id排序重建一个从 0 开始的连续映射。import json with open(annotations.json) as f: coco json.load(f) # 建立 category_id 到连续 0 基索引的映射 cats sorted(coco[categories], keylambda c: c[id]) cat_map {c[id]: i for i, c in enumerate(cats)} img_map {img[id]: img for img in coco[images]} from collections import defaultdict per_img defaultdict(list) for ann in coco[annotations]: img img_map[ann[image_id]] W, H img[width], img[height] x, y, w, h ann[bbox] xc (x w / 2) / W yc (y h / 2) / H per_img[ann[image_id]].append( f{cat_map[ann[category_id]]} {xc:.6f} {yc:.6f} {w/W:.6f} {h/H:.6f} ) for img_id, lines in per_img.items(): name img_map[img_id][file_name].rsplit(., 1)[0] .txt with open(flabels/{name}, w) as f: f.write(\n.join(lines))逻辑说明cat_map把原始不连续的category_id压成 0 基连续索引这是避免类别越界的关键。img_map用图片 id 反查宽高因为 COCO 的宽高存在images里而不是标注里。per_img按图片聚合标注保证一张图的所有框写进同一个 txt。参数说明annotations.json换成实际文件名。如果数据集有iscrowd标记为 1 的框通常要过滤掉因为拥挤区域的框在检测任务里会干扰训练加一句if ann.get(iscrowd, 0): continue即可。3. 数据集划分脚本与训练集验证3.1 划分脚本为什么要固定随机种子5000 张图按 8:1:1 划分训练、验证、测试看起来简单但如果不固定随机种子每次跑划分脚本得到的子集都不一样实验就没法复现。更隐蔽的问题是类别分布路标数据里某些稀有类别可能只有几十张随机划分后验证集里一张都没有评估指标直接失真。所以划分脚本要做两件事固定种子以及尽量保持类别比例。import os import random from collections import defaultdict random.seed(42) # 固定种子保证每次划分结果一致 img_dir images labels_dir labels all_imgs [f for f in os.listdir(img_dir) if f.endswith((.jpg, .png))] # 按主类别分组做分层抽样 def main_class(label_path): if not os.path.exists(label_path): return -1 with open(label_path) as f: first f.readline().strip() return int(first.split()[0]) if first else -1 groups defaultdict(list) for img in all_imgs: stem os.path.splitext(img)[0] groups[main_class(os.path.join(labels_dir, stem .txt))].append(img) train, val, test [], [], [] for cls, imgs in groups.items(): random.shuffle(imgs) n len(imgs) n_val max(1, int(n * 0.1)) n_test max(1, int(n * 0.1)) val imgs[:n_val] test imgs[n_val:n_val n_test] train imgs[n_val n_test:] for name, subset in [(train, train), (val, val), (test, test)]: with open(f{name}.txt, w) as f: for img in subset: f.write(os.path.join(img_dir, img) \n)逻辑说明random.seed(42)保证可复现。按每张图第一个标注框的类别做分组实现粗粒度分层避免稀有类别在验证集里消失。max(1, ...)保证即使某类只有几张图验证和测试也至少各有一张。输出的是图片路径列表YOLO 训练时直接读这个 txt。参数说明种子 42 可以换成任意整数但一旦定了就别改。0.1是验证和测试比例数据量小的时候可以调成 0.15。如果某类图片少于 3 张分层会失效这种类别建议直接合并或剔除。3.2 训练前必须做的三项数据体检划分完别急着开训先做体检否则训练中途报错更浪费时间。第一项图片和标签是否一一对应有图无标签会被当成纯背景有标签无图直接报错。第二项坐标是否越界归一化后任何值小于 0 或大于 1 都是脏标注。第三项类别 id 是否超出names长度。import os names_len 5 # 和 data.yaml 的 names 数量一致 img_dir, lbl_dir images, labels img_stems {os.path.splitext(f)[0] for f in os.listdir(img_dir)} lbl_stems {os.path.splitext(f)[0] for f in os.listdir(lbl_dir)} print(有图无标签:, len(img_stems - lbl_stems)) print(有标签无图:, len(lbl_stems - img_stems)) for lbl in os.listdir(lbl_dir): with open(os.path.join(lbl_dir, lbl)) as f: for i, line in enumerate(f): parts line.split() if len(parts) ! 5: print(f{lbl} 第{i}行字段数异常) continue cid int(parts[0]) coords list(map(float, parts[1:])) if cid names_len: print(f{lbl} 类别越界: {cid}) if any(v 0 or v 1 for v in coords): print(f{lbl} 坐标越界: {coords})逻辑说明用集合差集快速定位图片和标签的错配。逐行检查字段数、类别 id 上限、坐标范围这三类是训练报错的高频来源。打印而不是抛异常方便一次性看完所有问题。参数说明names_len必须和data.yaml里names列表长度一致。坐标检查用 0 到 1 的闭区间实际中允许极小误差如果出现 1.0001 这种可以手动截断。3.3 用 data.yaml 串起训练配置YOLO 训练靠一个 yaml 文件把路径、类别数、类别名绑在一起。这个文件写错模型要么找不到图要么类别数对不上导致最后一层输出维度错误。path: /data/road_sign train: train.txt val: val.txt test: test.txt nc: 5 names: 0: speed_limit 1: stop 2: yield 3: no_entry 4: warning逻辑说明path是数据集根目录train/val/test是相对路径的 txt 列表。nc是类别数必须等于names的条目数也等于转换脚本里CLASSES的长度。names的键从 0 开始连续和 YOLO 标签里的class_id一一对应。参数说明nc写错是最致命的写小了越界类别被忽略写大了模型多出无用输出。names的顺序必须和转换时的CLASSES完全一致顺序错了模型学到的语义就是错的。4. 训练路标检测模型的参数与排错4.1 从预训练权重起步的关键参数路标数据 5000 张不算大从零训练容易过拟合常规做法是加载 COCO 预训练权重做迁移学习。以 YOLOv8 为例最小训练命令如下。yolo detect train \ datadata.yaml \ modelyolov8n.pt \ epochs100 \ imgsz640 \ batch16 \ lr00.01 \ patience20 \ projectruns/road_sign逻辑说明modelyolov8n.pt加载预训练权重n 是最小模型路标这种中等难度任务够用显存紧张时优先选它。epochs100配合patience2020 轮验证指标不升就早停省时间也防过拟合。imgsz640是通用起点路标里小目标多的话可以提到 960。参数说明batch16按显存调8G 显存跑 640 大概能到 16爆显存就减半。lr00.01是初始学习率迁移学习时如果 loss 震荡厉害可以降到 0.001。project指定输出目录方便多次实验对比。4.2 loss 不降时先看这四个地方训练启动后 loss 不降或者 mAP 一直是 0别急着改模型结构先按顺序排查。第一data.yaml的路径对不对路径错的话模型读的是空数据集loss 会异常低或者 NaN。第二标签里的class_id是否越界越界的框会被静默丢弃。第三图片和标签文件名是否严格对应差一个字符就匹配不上。第四预训练权重和模型结构是否匹配用 v8 的权重加载 v5 的结构会报维度错误。我一般会在训练命令后加--verbose看数据加载日志确认读到的图片数量和标签数量对得上。如果日志显示0 labels基本就是路径或文件名问题。4.3 小目标路标漏检的调参方向路标里限速牌、禁令标志往往只占几十个像素默认配置容易漏。三个方向可以试。提高输入分辨率imgsz从 640 提到 960 或 1280小目标特征保留更多。调整 anchor 或使用无 anchor 的检测头YOLOv8 本身是 anchor-free但可以调reg_max影响回归范围。增加小目标样本的权重或者用 mosaic、copy-paste 增强提升小目标出现频率。yolo detect train \ datadata.yaml \ modelyolov8s.pt \ imgsz960 \ batch8 \ mosaic1.0 \ scale0.5 \ projectruns/road_sign_small逻辑说明imgsz960提升小目标分辨率代价是显存和耗时增加batch相应减到 8。mosaic1.0开启马赛克增强把四张图拼一张变相增加小目标数量和场景多样性。scale0.5允许随机缩放增强尺度鲁棒性。参数说明imgsz必须是 32 的倍数960 合法。mosaic取值 0 到 11 表示全程开启训练后期可以关掉让模型适应真实分布。scale是缩放幅度0.5 表示在 0.5 到 1.5 倍之间随机。5. 避坑与常见问题排查5.1 类别顺序不一致导致 mAP 虚低现象训练 loss 正常下降但验证 mAP 始终在 0.1 以下预测框位置看着挺准。原因转换脚本里的CLASSES顺序和data.yaml的names顺序不一致模型学到的类别索引和评估时的类别对不上框对了但类别全错。解决把两处的类别列表打印出来逐行比对确保完全一致改完重新转换标签再训练。5.2 归一化用了错误的分母现象部分图片的框整体偏移或大小异常尤其是非 640 分辨率的图。原因转换时把归一化分母写死成 640而实际图片是 1280 或 1920 宽。解决归一化必须用每张图自己的宽高从 XML 的size或 COCO 的images里读不能写常量。5.3 划分后验证集为空现象训练报验证集没有图片或者评估时除零。原因划分脚本按类别分层时某类图片太少int(n * 0.1)算出来是 0验证集里一张都没有。解决用max(1, ...)保证每类至少一张进验证集类别样本少于 3 张的考虑合并或剔除。5.4 图片和标签文件名不匹配现象训练日志显示读到的标签数为 0或者大量图片被跳过。原因图片是001.jpg标签是001.txt但放在不同目录且脚本没做路径拼接或者文件名有大小写、空格差异。解决训练前跑一遍 3.2 的体检脚本用集合差集定位错配统一命名规范。5.5 显存溢出与 batch 设置现象训练刚开始就报 CUDA out of memory。原因batch或imgsz超过显存容量或者同时开了多个训练进程。解决先把batch减半还不行就降imgsz或者用梯度累积模拟大 batch。8G 显存跑 640 一般 batch 16 是上限960 的话 batch 8 比较稳。6. 把路标数据集用出复利验证与进阶技巧数据集跑通只是起点真正拉开差距的是怎么验证和复用。我习惯在训练完成后做两件事一是用测试集跑一遍混淆矩阵看哪些类别之间容易混路标里圆形禁令标志和限速标志经常互混发现后针对性补样本。二是把模型导出 ONNX 做推理速度测试确认部署端能接受。yolo detect val modelruns/road_sign/weights/best.pt datadata.yaml splittest yolo export modelruns/road_sign/weights/best.pt formatonnx imgsz640逻辑说明val命令在测试集上算 mAP 和混淆矩阵splittest指定用测试集。export把 PyTorch 权重转成 ONNX方便后续用 TensorRT 或其他推理引擎加速。参数说明format可选 onnx、engine、openvino 等按部署环境选。imgsz导出时的输入尺寸要和训练一致否则精度会掉。进阶用法上这个数据集可以当 baseline 往上叠改进模块。比如换更高效的检测头、加注意力机制、试 NWD 这类新的回归损失或者做知识蒸馏用小模型逼近大模型精度。验证改进是否有效固定随机种子和划分只改一个变量对比 mAP 和推理速度别一次改一堆否则说不清是谁的功劳。我踩过最深的坑是早期不固定划分种子两次实验数据子集不同改进模块的收益被数据波动淹没白折腾两周。后来养成习惯任何对比实验先锁种子、锁划分、锁超参只动要验证的那一处。数据准备这步偷懒后面全是后悔药。希望帮到你。本文还有配套的精品资源点击获取
返回列表