ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

CCTSDB交通标志数据集+YOLO训练全流程:标签转换、数据划分与避坑指南

2026/9/28 15:19:03 拓冰建站 浏览量
CCTSDB交通标志数据集+YOLO训练全流程:标签转换、数据划分与避坑指南 简介面向YOLO交通标志检测任务的CCTSDB数据集收录真实道路场景中的高质量交通标志图片标注框质量可靠适合目标检测初学者及算法工程师直接用于模型训练、对比实验与效果评估。资源包共2000个文件其中1000个xml标签对应VOC格式、990个txt标签对应YOLO格式另含yaml配置文件、Python划分脚本和多份HTML教程整体压缩包约237MB目录划分清晰便于按需调用。除标注数据外还提供Linux/Windows双系统的YOLO环境搭建教程、训练案例教程以及训练集/验证集/测试集划分脚本可在不同平台快速跑通完整流程并根据实际需求灵活调整数据划分比例。目前已有602人学习下载这套多格式标签与配套脚本、教程组合能显著降低数据准备门槛让使用者更专注于交通标志检测模型的迭代与优化。1. CCTSDB 交通标志数据集从 1000 张图里跑通 YOLO 检测管线拿到一份标好格式的公开数据集做训练最怕什么不是模型跑不起来而是标签格式、类别索引、数据划分这些“准备工作”吃掉大半天真正调参的时间反而没剩多少。CCTSDB 交通标志数据集这几年经常被用来练手原因很直接场景真实、目标明确、标志的视觉差异肉眼可辨规模又不大——标题里这个版本包含 1000 张图片正好可以在单张消费级显卡上把整套流程跑完。但交通标志检测的坑也相当具体标志在画面里占的像素经常很小禁止类和警告类外观相似训练集与验证集如果划分不当指标会虚高到让后续部署完全没有参考意义。所以这套资源真正的价值不在于“多了一个数据集”而是给了你一个能照着做的最小闭环1000 张图VOC、COCO、YOLO 三种格式标签齐全附带划分脚本和训练教程。对刚配好 YOLO 环境的新手这是验证环境、验证数据管线是否真的通畅的样本对已经做过一两个数据集的老手这也是测试数据增强策略、对比不同模型配置的低成本试验场。后面几个章节就按“标签格式怎么选、划分脚本怎么写、训练怎么跑、坑在哪”的顺序展开。2. VOC/COCO/YOLO 三种标签格式坐标差异与互转脚本2.1 三种格式各自的坐标体系三种格式的标签为什么值得单独拿出来讲因为 CCTSDB 这类数据集在网上流传时同一批图片经常被不同人转换后二次打包xml、json、txt 三种格式都有但坐标定义和类别编号很容易不一致。你只有先搞清楚这一点训练时才不会把格式问题误当成网络结构问题去调参。格式载体坐标定义类别表示最容易错的地方VOCxmlxmin / ymin / xmax / ymax绝对像素字符串标签名标签名的大小写、中英文不一致COCOjson[x, y, width, height]左上角坐标 宽高绝对像素category_id从 1 开始和数组下标 0 开头的习惯混用YOLOtxt每行一个目标class_id, cx, cy, w, h全部归一化到 0~1class_id从 0 开始归一化时除以图片宽高写反从 VOC 推到另外两种是最好理解的COCO 的 bbox 就是 xmin、ymin、xmax - xmin、ymax - yminYOLO 则是先算中心点坐标再整体除以图片宽和高。CCTSDB 图片原始尺寸有大有小VOC 和 COCO 里都是像素值你打开 XML 能直接看到数字合不合理YOLO 因为做了归一化脱离了图片尺寸就看不出来对错所以转换时一定要拿着图片一起算。2.2 用 Python 把 VOC XML 转成 YOLO txt数据包里如果只有 VOC 一种原始标注转 YOLO 是训练前必做的一步。下面这段代码把 XML 里的每个 object 读出来转成 YOLO 需要的归一化 txt。import os import xml.etree.ElementTree as ET from PIL import Image # class_names 的顺序必须与后续训练时 data.yaml 里的 names 完全一致 class_names [mandatory, prohibitory, warning] def voc_xml_to_yolo(xml_path, img_path, out_txt_path): with open(img_path, rb) as f: img Image.open(f) img_w, img_h img.size tree ET.parse(xml_path) root tree.getroot() lines [] for obj in root.findall(object): name obj.find(name).text.strip() if name not in class_names: continue class_id class_names.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) # 坐标越界的框先裁回图片范围避免训练时出 nan 或无效框 xmin max(0, min(xmin, img_w - 1)) xmax max(0, min(xmax, img_w - 1)) ymin max(0, min(ymin, img_h - 1)) ymax max(0, min(ymax, img_h - 1)) cx (xmin xmax) / 2.0 / img_w cy (ymin ymax) / 2.0 / img_h w (xmax - xmin) / img_w h (ymax - ymin) / img_h lines.append(f{class_id} {cx:.6f} {cy:.6f} {w:.6f} {h:.6f}\n) with open(out_txt_path, w) as f: f.writelines(lines)逻辑说明这段代码先读取图片真实宽高再遍历 XML 里的 object 节点。每个 object 的 bndbox 给出像素坐标中心点坐标是 xmin 和 xmax 的均值归一化时再除以图片宽高。这样 YOLO txt 里的每个数字都落在 0 到 1 之间。参数说明里最关键的是class_names这个列表它既是 XML 标签名到数字 id 的映射表也是后续训练配置里 names 的蓝本。列表顺序一旦变了所有 txt 里的 class_id 含义都会变训练不会报错但模型学的东西全错。2.3 用 Python 把 VOC XML 转成 COCO JSONCOCO 格式在评估阶段用得最多pycocotools的 mAP 计算只认这种 json。转换时有一个特别容易踩的细节COCO 的 category_id 从 1 开始而 YOLO 的 class_id 从 0 开始两边差一个 1。import json import os import xml.etree.ElementTree as ET from PIL import Image def voc_xml_to_coco(xml_path, img_path, image_id, image_info_list, annotation_list, class_names): with open(img_path, rb) as f: img Image.open(f) img_w, img_h img.size image_info_list.append({ id: image_id, file_name: os.path.basename(img_path), width: img_w, height: img_h }) tree ET.parse(xml_path) root tree.getroot() ann_id 1 for obj in root.findall(object): name obj.find(name).text.strip() if name not in class_names: continue category_id class_names.index(name) 1 # COCO 从 1 开始 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) w xmax - xmin h ymax - ymin annotation_list.append({ id: ann_id, image_id: image_id, category_id: category_id, bbox: [xmin, ymin, w, h], area: w * h, iscrowd: 0 }) ann_id 1逻辑说明这段代码把每张图片的宽高信息记录到 images 段再把每个目标记录到 annotations 段。bbox 字段是左上角坐标加宽高不是中心点格式也不要顺手归一化。参数说明category_id class_names.index(name) 1这一行就是坑所在如果你直接套用 YOLO 转换脚本里的class_names.index(name)COCO 的 id 就从 0 开始pycocotools算 mAP 时结果会错得莫名其妙。另外area字段在 COCO 评估里也参与计算不要省略。2.4 转换后如何自查转换完不要急着训练先抽三张图做可视化。把 YOLO txt 的归一化坐标乘回原图宽高再画框和原 XML 里的框叠加对比肉眼能看出是否一致。from PIL import Image, ImageDraw def check_yolo_label(img_path, txt_path): img Image.open(img_path).convert(RGB) img_w, img_h img.size draw ImageDraw.Draw(img) with open(txt_path, r) as f: for line in f.readlines(): parts line.strip().split() if len(parts) ! 5: continue cx, cy, w, h map(float, parts[1:]) xmin (cx - w / 2) * img_w ymin (cy - h / 2) * img_h xmax (cx w / 2) * img_w ymax (cy h / 2) * img_h draw.rectangle([xmin, ymin, xmax, ymax], outlinered, width3) img.show()逻辑说明这段只做一件事把 txt 里的归一化中心点和宽高还原成像素坐标并画框。如果不能清晰框住交通标志或者框的位置明显偏移那就是转换代码里有问题。注意 CCTSDB 里有些图的标志很小画出来的框可能在视觉上只有拳头大小这是正常的真正要警惕的是框整体落在标志旁边、或者宽高被拉成细长条的情况。3. 划分脚本要解决的真问题数据泄漏、类别均衡与随机种子3.1 为什么划分脚本比想象中更重要很多人觉得划分脚本不就是 random.shuffle 一下再按比例切三份吗CCTSDB 的数据来源是真实道路视频帧同一块标志在连续几帧里反复出现画面内容高度相似。如果只做简单随机打乱同一个标志的连续帧很可能同时落进训练集和验证集验证集 mAP 会虚高到让后续部署完全失去参考价值。这种问题不会让训练报错只会让你误以为模型效果很好上线后直接被真实场景打脸。另外还有个隐蔽问题1000 张图里三个类别的数量往往不均衡。随机划分后某个类别可能恰好大量集中到测试集训练集里只剩零星几张。所以划分脚本要做的不是“把文件分成三堆”而是先做脏数据检查再按类别分布做切分这也是我会在划分前先回答“每个类有多少样本”的原因。3.2 一个可以直接落地的划分脚本下面这个脚本按比例把图片和 YOLO txt 标签一起复制到 train/val/test 目录同时打印每个划分的类别统计。它默认输入是“图片目录 同名 txt 标签目录”也是 YOLO 训练最常用的目录组织方式。import os import random import shutil from collections import Counter def count_labels(label_dir, file_list): counter Counter() for name in file_list: txt_path os.path.join(label_dir, os.path.splitext(name)[0] .txt) if not os.path.exists(txt_path): continue with open(txt_path, r) as f: for line in f.readlines(): cls_id int(line.strip().split()[0]) counter[cls_id] 1 return counter def split_dataset(img_dir, label_dir, out_dir, train_ratio0.7, val_ratio0.2, seed42): # 固定随机种子保证每次运行结果一致 random.seed(seed) images [f for f in os.listdir(img_dir) if f.endswith((.jpg, .png, .jpeg))] images.sort() random.shuffle(images) n_train int(len(images) * train_ratio) n_val int(len(images) * val_ratio) train_images images[:n_train] val_images images[n_train:n_train n_val] test_images images[n_train n_val:] for split_name, split_images in zip( (train, val, test), (train_images, val_images, test_images) ): out_img_dir os.path.join(out_dir, images, split_name) out_lbl_dir os.path.join(out_dir, labels, split_name) os.makedirs(out_img_dir, exist_okTrue) os.makedirs(out_lbl_dir, exist_okTrue) for img_name in split_images: shutil.copy( os.path.join(img_dir, img_name), os.path.join(out_img_dir, img_name) ) label_name os.path.splitext(img_name)[0] .txt src_label os.path.join(label_dir, label_name) if os.path.exists(src_label): shutil.copy(src_label, os.path.join(out_lbl_dir, label_name)) counter count_labels(out_lbl_dir, split_images) print(f{split_name}: {len(split_images)} images, labels: {dict(counter)})参数说明train_ratio0.7、val_ratio0.2对应 7:2:1 的经典划分测试集占 10%。seed42是随机种子改掉它划分结果就变了对于要复现实验结果的场景建议固定住。脚本输出的train/val/test目录结构直接能被 YOLO 的 data.yaml 识别省去再写路径列表的麻烦。如果你只有 1000 张图且某个类别只有几十张我建议把 test 去掉只做 train/val 两份划分把更多样本留给训练。3.3 划分前先做脏数据检查划分脚本跑之前强烈建议先扫一遍标签文件。CCTSDB 流传版本多常见的脏数据有空 txt 文件、坐标越界、类别 id 超出范围。空 txt 不会让训练直接崩但会拉低该图的训练权重坐标越界则可能让 loss 变成 nan。def check_labels(label_dir, img_dir): bad_files [] for label_name in os.listdir(label_dir): if not label_name.endswith(.txt): continue txt_path os.path.join(label_dir, label_name) img_path os.path.join(img_dir, os.path.splitext(label_name)[0] .jpg) if not os.path.exists(img_path): bad_files.append((label_name, missing image)) continue with open(txt_path, r) as f: lines f.readlines() if len(lines) 0: bad_files.append((label_name, empty txt)) continue for line in lines: parts line.strip().split() if len(parts) ! 5: bad_files.append((label_name, bad line format)) continue cls_id int(parts[0]) cx, cy, w, h map(float, parts[1:]) if cls_id 0 or cx 0 or cy 0 or w 0 or h 0: bad_files.append((label_name, invalid value)) continue if cx w / 2 1.0 or cy h / 2 1.0: bad_files.append((label_name, out of range)) return bad_files这段脚本的逻辑很简单逐个 txt 检查格式和数值范围最后把问题文件列出来。我在实际项目里的做法是先把这些坏文件踢出数据集而不是尝试修复因为 CCTSDB 这种规模的数据集少几张图影响不大修复反而可能引入人为错误。检查完再做划分顺序不要颠倒。4. 从零跑通 YOLO 训练data.yaml、训练命令与关键超参数4.1 训练前的目录整理与 data.yaml 配置划分脚本跑完后目录结构应该是这样datasets/cctsdb/ ├── images/ │ ├── train/ │ ├── val/ │ └── test/ └── labels/ ├── train/ ├── val/ └── test/YOLO 训练前还要写一个 data.yaml它告诉训练程序去哪里找图片、有几个类别、类别名叫什么。下面这个配置可以直接用# data.yaml path: /your/absolute/path/datasets/cctsdb train: images/train val: images/val test: images/test nc: 3 names: [mandatory, prohibitory, warning]这里path写的是数据集根目录的绝对路径train和val是相对 path 的子目录。nc是类别数这个必须和标签里实际出现的最大 class_id 1 一致。names的顺序就是类别 id 到名字的映射关键要求和第 2 章转换脚本里的class_names完全一致。如果转换脚本里第一个元素是 mandatory训练配置里第一个也必须写 mandatory哪怕字母顺序看起来不合理。4.2 最小训练命令与关键超参数在终端里执行下面这条命令就能开始训练yolo detect train datacctsdb.yaml modelyolov8n.pt epochs100 imgsz640 batch16 patience20参数含义modelyolov8n.pt表示用 YOLOv8 的 nano 版本这是参数量最小的版本CCTSDB 只有 1000 张图nano 已经能把 pipeline 验证完整想刷精度可以换yolov8s.pt。epochs100是最大训练轮数交通标志检测任务一般不用跑满配合patience20意味着验证集指标连续 20 轮不提升就提前停止。imgsz640是训练输入尺寸CCTSDB 原图有各种分辨率640 是速度与精度的平衡点。训练时注意一点如果显存不够优先把 batch 从 16 降到 8不要贸然调小 imgsz。另外 ultralytics 在启动时会自动下载预训练权重yolov8n.pt到缓存目录网络受限时这个下载会卡住提前把 pt 文件放到~/.cache/ultralytics/weights/对应位置可以省掉每次启动都试图拉取权重的等待。训练日志里会同时出现cls_loss、box_loss、dfl_loss三项不要只看总 loss分类和边框损失应该同步下降才算正常。4.3 评估、导出与首次推理训练结束后用验证集评估、导出 ONNX、跑一张测试图这三步分别对应三条命令yolo detect val datacctsdb.yaml modelruns/detect/train/weights/best.pt yolo export modelruns/detect/train/weights/best.pt formatonnx imgsz640 yolo predict modelruns/detect/train/weights/best.pt sourcetest.jpg第一条命令会输出 mAP50、mAP50-95 以及每个类别的 AP。CCTSDB 这类标志检测任务mAP50 比 mAP50-95 更有参考意义因为标志框通常很小IoU 阈值提高到 0.95 后会偏严苛。第二条命令导出的 ONNX 文件可以直接丢给 ONNXRuntime 或 TensorRT 做部署。第三条命令会在 test.jpg 上画出预测框这是最直观的 sanity check。5. CCTSDB YOLO 避坑指南5 个让新手反复翻车的常见问题5.1 标签越界坐标训练正常但 mAP 一直是 0现象loss 正常下降训练日志也正常打印但 val 阶段 mAP50 始终是 0一个正确预测都没有。原因这是 CCTSDB 标签转换里最高频的问题。部分图片的 XML 里 bndbox 坐标超出了图片边界转换时如果没有裁回图片范围归一化后 cx w/2 会大于 1。YOLO 训练时会把这些框当作异常目标处理但验证阶段 GT 和预测框的 IoU 永远对不上最终 mAP 就是 0。解决把第 3.3 节的检查脚本跑一遍把所有越界的 txt 行打出来。修正方式有两种一是回到 XML 重新计算时把坐标 clamp 到图片尺寸内二是直接剔除这些异常目标。我的建议是剔除因为越界往往意味着标注本身不可靠硬修只会把错误框硬挪进画面边缘。5.2 类别 id 错位混淆矩阵总和永远对不上现象训练结束后看 confusion_matrix.png每一行加起来的数量和训练集里该类别的真实目标数量对不上甚至有类别完全没有出现在混淆矩阵里。原因转换脚本里的class_names列表顺序和 data.yaml 里的names顺序不一致。比如转换时按[warning, prohibitory, mandatory]排训练配置里写成[mandatory, prohibitory, warning]模型学到的 0 号类别实际是 warning但验证时 0 号被解释成 mandatory所有指标都会错乱。这种问题在公开数据集上特别隐蔽因为不会报错。解决训练前写一行 python 检查两个列表是否一致更直接的办法是拿一张图片做推理把预测框的类别名打出来和图片里的标志语义对比。如果模型把“禁止左转”识别成“警告左转”说明类别顺序大概率有问题。5.3 训练集里缺类别AP 曲线直接少一条现象验证结果里某个类别的 AP 是 0或者整个训练曲线里某类 AP 一直不出现。原因随机划分时样本量少的类别恰好全部被分到了测试集。CCTSDB 三个类别的数量不是平均的警告标志往往比指示标志多随机打乱后小类别在训练集里可能只剩几张甚至没有。解决划分前先统计每个类别的样本图片数。数量级差异大时不要按图片随机划分改成按类别分层抽样先把每个类别的图片分别打乱再按比例从每个桶里取。注意一张图可能同时含多个类别分层时要按图片的类别组合去分配避免一张多标签图被重复放进 train 和 val。5.4 loss 变成 nan先查标签再怀疑模型现象训练进行到第几轮后 cls_loss 变成 nan进度条卡住不动过一会儿进程退出。原因常见原因有三个。一是 txt 标签文件存在但内容为空训练时读到空文件产生无效梯度二是图片本身损坏或极端曝光CCTSDB 来自真实道路视频帧偶尔会有全黑全白的坏帧三是标签里有 inf 值比如 XML 里某个坐标是空字符串被 float() 转成异常值。解决先跑脏数据检查脚本把空 txt、坏图片过滤掉再训练。如果问题还出现把训练命令加上ampFalse排除混合精度的兼容性问题。大多数情况下问题出在数据端模型出问题的概率很低不要一看到 nan 就去改学习率。5.5 推理时检测框整体偏移训练和部署的预处理不一致现象训练时 mAP 正常导出 ONNX 后自己写脚本做推理同一张图检测框位置和训练时可视化结果不一致尤其在图片被缩放过的情况下偏移更明显。原因训练时 YOLO 默认用 letterbox 等比缩放加灰边填充到 640推理时如果直接用普通 resize 把图片压到 640宽高比被拉伸坐标还原到原图自然偏移。这个坑在你用 ONNXRuntime 或 TensorRT 部署时最容易踩因为框架本身不替你做预处理。解决部署代码里把预处理写成和训练一致的 letterbox 逻辑预测后再把归一化坐标逆变换回原图。不要用普通 resize 代替 letterbox如果一定要用 resize必须在训练时也把rectTrue关掉并保持同样缩放策略。一个项目里最好只维护一份预处理代码训练和部署共用避免两边各自实现导致细微差异。6. 验证你的训练管线冒烟训练与混淆矩阵检查6.1 用 5 轮冒烟训练快速验证数据管线拿到新数据集我的第一个动作不是直接跑 100 轮而是先跑一个 5 轮的冒烟训练yolo detect train datacctsdb.yaml modelyolov8n.pt epochs5 imgsz640 batch16这轮训练只看三件事loss 是否在下降、val 阶段能否正常跑完、best.pt 文件是否生成。任何一步异常都说明数据准备有硬伤先去处理数据而不是调超参数。5 轮在消费级显卡上通常几分钟就结束这是整个工作流里性价比最高的一次投入。6.2 看混淆矩阵而不是只盯 mAP正式训练完val 命令会在 runs/detect/val 下生成 confusion_matrix.png。我一般先看禁止类和警告类之间是否互相串这两类在 CCTSDB 里都是圆形红底白边区别只在内部图案模型很容易混。如果这两类混淆明显优先检查训练集中这两类的样本量是否足够而不是急着换更大的 backbone。有一个血泪经验加数据增强往往解决不了相似类别混淆只有补样本或加类别权重才有用。6.3 交通标志场景里的增强取舍CCTSDB 场景下左右翻转这个增强要慎用。部分交通标志镜像后会变成不同的语义比如靠右行驶标志翻转变成了靠左行驶模型会把这种错误学进去。ultralytics 默认开启的增强包含 fliplr在调参时可以把翻转关闭或降低概率同时把 hsv 色系增强保持默认因为真实道路光照变化确实需要颜色扰动。我现在的习惯是拿到任何新数据集先 5 轮冒烟定位问题再看混淆矩阵决定要不要动增强和类别权重最后才考虑加大训练轮数。这个顺序帮我避开了大量无意义的 full 训练也把那些看似玄学的翻车问题都提前拦截在数据准备环节。希望帮到你。本文还有配套的精品资源点击获取