资讯动态

CCTSDB到YOLO:交通标志检测的格式转换与训练实战

发布时间:2026/10/18 14:08:39 来源:尧图企业网站定制
简介YOLO交通标志检测CCTSDB数据集专为目标检测实战与算法训练场景设计适合刚接触YOLO系列或需要高质量交通标志数据的开发者。资源基于真实道路场景采集并使用LabelImg标注标注框质量高同时提供VOC(xml)、COCO(json)、YOLO(txt)三种格式标签分别存放于不同文件夹可直接接入YOLO系列模型训练。压缩包共2000个文件以xml标签文件、txt标注文件为主另含6个html教程页面、3个Python划分脚本及1个yaml配置文件整体约237.45MB。随包附赠YOLO环境搭建与训练教程区分Linux和Windows版本以及训练集/验证集/测试集划分脚本可按需自由划分数据。目前已有603人学习下载。同时提供数据集详情展示与更多资源入口让使用者能快速上手训练自己的检测模型省去数据整理与格式转换的时间。1. 从CCTSDB到YOLO交通标志检测为什么值得用这套组合跑通交通标志检测是目标检测里最“看起来简单、做起来处处是坑”的方向之一。城市道路场景下标志尺寸小、光照变化大还有遮挡和模糊模型很容易在小物体上翻车漏检率居高不下。很多入门者一上来就扎进大规模数据集训练却忽略了从标注格式到数据划分这些基础环节结果模型结构再花哨也训不出稳定效果。CCTSDB数据集恰恰是个合适的切入点——它针对中国交通标志场景包含真实道路环境下的图片配合VOC、COCO、YOLO三种标注格式能让你的训练链路从数据准备开始就是通的。这个标题的价值在于它不是只给你一套数据而是给了你从数据到训练的一条完整闭环适合正在学习目标检测、想用真实场景验证YOLO效果的同学。2. CCTSDB数据集与三种标注格式先搞清楚你手里有什么牌2.1 CCTSDB数据集的构成与类别映射策略CCTSDB是面向中国交通标志检测的公开数据集图片大多来自真实街景和道路拍摄场景里有大量的树木、路灯、车辆、行人干扰比那种白底纯标志的合成图片要难得多。标题里说的1000张图片实际展开后因为一张图中往往存在多个交通标志总标注框数量会明显超过图片数。我一般会先把这些图片全部过一遍统计每个类别的标注框数量再决定是直接做原始分类还是合并成大类。CCTSDB的类别定义在不同版本的公开资料里存在差异有的按危险警告、禁止、指示三类划分有的细分到几十种具体标志比如“限速”、“转弯”、“禁止停车”等等。做YOLO训练时我通常采用三分类策略warning、prohibition、instruction。原因很简单1000张图片的规模撑不起几十个细分类别每个类别分不到多少样本训练出来的模型几乎没有泛化能力。如果你手里的原始标注是细分类可以先映射到这三个大类后续等数据量大了再逐步细化。转换之前先把数据组织好我按下面的目录结构摆放后面所有脚本都依赖这个约定dataset/ ├── images/ # 所有jpg图片 │ ├── 00001.jpg │ └── ... ├── annotations/ # 原始标注txt或xml取决于你下载的版本 ├── labels/ # YOLO格式标注脚本生成 ├── coco/ # COCO格式标注脚本生成 └── voc/ # VOC格式标注脚本生成2.2 VOC、COCO、YOLO三种标注格式的根本差异很多初学者从没认真对比过这三种格式以为只是文件后缀不同实际上它们的组织逻辑差异非常大。VOC格式是一个图片对应一个xml文件里面记录文件名、图像尺寸和每个目标的类别名、边界框左上角右下角坐标。COCO格式则不同所有图片的标注集中在同一个json文件里分成images、annotations、categories三个数组通过id互相引用。而YOLO格式最精简一张图片对应一个同名txt文件每行一条标注内容是“类别id 归一化中心x 归一化中心y 归一化宽w 归一化高h”。下表是我经常用到的一张格式对照表方便在转换时自查格式存储方式坐标体系类别表达常用工具链VOC每图一个xml像素绝对坐标xmin/ymin/xmax/ymax类别名称字符串很多检测框架内置读取COCO全部图片一个json像素绝对坐标x/y/w/h数字id经categories映射Detectron2、部分API接口YOLO每图一个txt归一化相对坐标cx/cy/w/h数字id靠文件目录判别YOLO系列原生支持同一个框在不同格式里差得最多的是坐标表达VOC里是左上角和右下角两个点COCO里是左上角加宽高YOLO里是中心点加宽高。格式转换的本质就是坐标系和文件组织的双重变换变换逻辑不写清楚出来的标注就是乱的。我习惯先把这三套坐标定义写在脚本注释里保证自己下次看还明白。2.3 既然要训练YOLO为什么还要保留VOC和COCO两份标注标题给了三种格式不少人会想“我只用YOLO格式就够了另外两种纯属多余”。这个想法放在只跑一次训练的demo里没什么问题但实际做项目时你会发现自己经常在多个工具链之间切换。比如你要用一些预训练检测框架做对比实验有些老框架只支持VOC格式输入你要调用某个API平台的模型评测接口它只收COCO格式你回过头来想用数据增强工具或可视化工具那些工具只认自己惯用的那一种格式。所以我建议把这三种格式都生成好当作数据资产存下来。以后做模型对比、换框架、接评估接口直接取用对应格式即可不用每次重写转换脚本。转换脚本本身也值得保留它是数据流转的枢纽。下面给出我常用的转换流程先维护一套“标准中间格式”再向三种目标格式分发。3. 把原始标注转成VOC、COCO、YOLO三种格式一份可复用的转换脚本3.1 先写一个统一的中间读取层这一步很多教程会跳过直接告诉你“运行convert.py就生成了”。但CCTSDB的原始标注来源复杂有的版本是txt纯坐标有的版本是类似VOC的xml甚至同一份数据里可能同时存在两种标注文件。如果脚本里直接硬编码读取某一种格式下次换个来源的数据就得重写。我先定义一个中间结构把所有来源都解析成统一的Python字典列表。# parse_annotations.py import xml.etree.ElementTree as ET import os def parse_txt_annotation(txt_path, img_width, img_height): 解析简单txt标注每行 class_id xmin ymin xmax ymax boxes [] with open(txt_path, r, encodingutf-8) as f: for line in f: parts line.strip().split() if len(parts) 5: continue cls_id int(parts[0]) xmin, ymin, xmax, ymax map(float, parts[1:5]) boxes.append({ category_id: cls_id, bbox: [xmin, ymin, xmax, ymax] # 像素坐标 }) return boxes def parse_voc_xml(xml_path): 解析VOC xml标注得到absolute bbox列表 tree ET.parse(xml_path) root tree.getroot() boxes [] for obj in root.findall(object): name obj.find(name).text bndbox obj.find(bndbox) xmin float(bndbox.find(xmin).text) ymin float(bndbox.find(ymin).text) xmax float(bndbox.find(xmax).text) ymax float(bndbox.find(ymax).text) boxes.append({ category_id: name, # 这里先用字符串 bbox: [xmin, ymin, xmax, ymax] }) return boxes这个中间层把两类来源都收口成统一的box结构类别字段先用原始表达后面统一做映射。参数说明一下txt解析里做了防御性判断行长度不足5就直接跳过这类坏标注在真实数据里经常出现VOC解析里读的是bndbox子节点的四个坐标如果你手上的xml结构有点差异先打印一下根节点的子标签再调。3.2 生成VOC格式xml文件VOC标注的核心是xmin、ymin、xmax、ymax四个绝对像素坐标类别用字符串表示。转换时注意一点xml的编码统一用UTF-8中文类别名在部分老工具里会出现编码问题所以即使原始标注里有中文名也建议先映射成英文类别或纯数字id对应的名称。# generate_voc.py import os import xml.etree.ElementTree as ET from PIL import Image def write_voc_xml(img_path, boxes, cls_id_to_name, save_dir): 根据中间box列表生成VOC xml文件 img_name os.path.basename(img_path) with Image.open(img_path) as im: w, h im.size root ET.Element(annotation) folder ET.SubElement(root, folder) folder.text CCTSDB filename ET.SubElement(root, filename) filename.text img_name size ET.SubElement(root, size) width ET.SubElement(size, width); width.text str(w) height ET.SubElement(size, height); height.text str(h) depth ET.SubElement(size, depth); depth.text 3 for box in boxes: obj ET.SubElement(root, object) name ET.SubElement(obj, name) cls_id box[category_id] name.text cls_id_to_name.get(cls_id, str(cls_id)) bndbox ET.SubElement(obj, bndbox) xmin, ymin, xmax, ymax box[bbox] ET.SubElement(bndbox, xmin).text str(int(xmin)) ET.SubElement(bndbox, ymin).text str(int(ymin)) ET.SubElement(bndbox, xmax).text str(int(xmax)) ET.SubElement(bndbox, ymax).text str(int(ymax)) out_path os.path.join(save_dir, os.path.splitext(img_name)[0] .xml) tree ET.ElementTree(root) tree.write(out_path, encodingutf-8, xml_declarationTrue)这段代码的注意点在类别映射上。cls_id_to_name是一个手动维护的dict比如{0:warning, 1:prohibition, 2:instruction}如果映射表里查不到就直接退回原始id避免转换中断。坐标强制转int是因为VOC里不允许小数坐标但floor还是round视场景而定检测框大一点小一点差别不大我更倾向直接舍去小数部分。3.3 生成COCO格式json文件COCO格式的核心是两个id体系每张图片有一个唯一的image_id每个标注对象有一个annotation_id。图片信息、类别信息、标注信息分三块存block之间靠id关联。这跟VOC那种一个文件自带全部信息的做法完全不同刚上手时容易忘记往annotations里塞image_id字段。# generate_coco.py import json, os from PIL import Image def build_coco_json(img_dir, all_boxes, cls_name_to_id, save_path): 构建COCO格式标注json images [] annotations [] categories [ {id: cls_name_to_id[name], name: name} for name in cls_name_to_id ] ann_id 1 for img_name in sorted(os.listdir(img_dir)): if not img_name.lower().endswith((.jpg, .jpeg, .png)): continue img_id int(os.path.splitext(img_name)[0]) # 用文件名作为image_id with Image.open(os.path.join(img_dir, img_name)) as im: w, h im.size images.append({ id: img_id, file_name: img_name, width: w, height: h }) for box in all_boxes.get(img_name, []): xmin, ymin, xmax, ymax box[bbox] coco_bbox [xmin, ymin, xmax - xmin, ymax - ymin] area coco_bbox[2] * coco_bbox[3] annotations.append({ id: ann_id, image_id: img_id, category_id: cls_name_to_id[box[category_id]], bbox: coco_bbox, area: area, iscrowd: 0 }) ann_id 1 coco_data { images: images, annotations: annotations, categories: categories } with open(save_path, w, encodingutf-8) as f: json.dump(coco_data, f, ensure_asciiFalse)这段代码里最重要的一行是coco_bbox的生成VOC的xmax、ymax要减去xmin、ymin转换成宽高。坐标转换写反是这步最常见的问题转换完随便挑几张图把框画出来核实一下能省下后面大量排错时间。image_id这里用了文件名数字作为id如果你的文件名不规律建议改成从0开始计数。3.4 生成YOLO格式txt文件YOLO格式相对简单但有个关键陷阱坐标必须归一化到0到1之间类别id必须从0开始连续编号。很多人在这一步出错是因为忘记除以图片宽高导致训练时代入大量超出图像范围的框模型要么loss不降要么收敛到错误的特征位置。# generate_yolo.py import os from PIL import Image def write_yolo_txt(img_path, boxes, cls_name_to_id, save_dir): 按YOLO格式写入txt标注 img_name os.path.basename(img_path) with Image.open(img_path) as im: w, h im.size out_path os.path.join(save_dir, os.path.splitext(img_name)[0] .txt) lines [] for box in boxes: xmin, ymin, xmax, ymax box[bbox] # 转中心点加宽高再归一化 cx (xmin xmax) / 2.0 / w cy (ymin ymax) / 2.0 / h bw (xmax - xmin) / w bh (ymax - ymin) / h cls_id cls_name_to_id[box[category_id]] lines.append(f{cls_id} {cx:.6f} {cy:.6f} {bw:.6f} {bh:.6f}) with open(out_path, w, encodingutf-8) as f: f.write(\n.join(lines))对于某些越界的框比如xmin小于0或xmax大于图片宽度我建议在脚本里加一个过滤或clip操作。常见做法是把坐标裁到图像边界内而不是直接丢弃整条标注因为边界标志本身可能就是紧贴画面边缘的小物体。3.5 转换后必须做的一步可视化抽查脚本写完不校验就开训是大忌。转完格式后我会立刻写一个简单的可视化脚本把YOLO txt标注画回到原图上每张图保存成带框版本然后人工扫一遍。这个环节看着笨重却能同时检查坐标单位错误、类别映射颠倒了、以及VOC转COCO时宽高写成了右下角坐标这类低级问题。瑕疵标注不会让loss报错但会让模型学得又慢又差而且很难从训练曲线里定位到是数据问题。# draw_boxes.py import cv2 def draw_yolo_boxes(image_path, txt_path, class_names, out_path): img cv2.imread(image_path) h, w img.shape[:2] with open(txt_path, r) as f: for line in f: parts line.strip().split() if len(parts) ! 5: continue cls_id, cx, cy, bw, bh parts cx, cy, bw, bh map(float, (cx, cy, bw, bh)) x1 int((cx - bw / 2) * w) y1 int((cy - bh / 2) * h) x2 int((cx bw / 2) * w) y2 int((cy bh / 2) * h) color (0, 255, 0) cv2.rectangle(img, (x1, y1), (x2, y2), color, 2) cv2.putText(img, class_names[int(cls_id)], (x1, max(y1 - 5, 0)), cv2.FONT_HERSHEY_SIMPLEX, 0.5, color, 1) cv2.imwrite(out_path, img)4. 数据划分别让验证集成为你的盲区4.1 为什么划分策略直接影响模型可信度交通标志数据集的划分比一般目标检测更敏感。因为小物体在训练时本来就容易学不到位如果验证集里恰好只有一些训练集中出现过的视角得到的mAP会虚高反过来如果某个类别全被划进验证集mAP就会异常低。CCTSDB这1000张图片随机划分时完全可能出现极端分布所以训练集、验证集的划分必须可复现且要按类别分布做到近似分层。我采用的划分比例是训练集70%、验证集15%、测试集15%。不要把所有数据都用来训练留出test目录做最终评测。另外划分脚本里必须固定随机种子否则两次运行得到的结果不同后续对比实验就没有意义了。4.2 按类别分层划分的脚本写法纯随机划分在样本类别不均衡时有风险。比如某个冷门标志在1000张图里只出现了三张如果你把它全部划到训练集验证集就没有该类别的评估数据划分到验证集则模型完全没学过它。借用分层采样的思路在图片级别统计类别分布尽量保证每个类别在三份数据里的比例接近全局比例。# split_dataset.py import os import random from collections import defaultdict import numpy as np random.seed(42) np.random.seed(42) def count_class_per_image(label_dir, image_list): 统计每张图片包含的类别id集合 img_classes {} for img_name in image_list: txt os.path.join(label_dir, os.path.splitext(img_name)[0] .txt) cats set() if os.path.exists(txt): with open(txt, r) as f: for line in f: cats.add(int(line.strip().split()[0])) img_classes[img_name] cats return img_classes def stratified_split(image_list, img_classes, train_ratio0.7, val_ratio0.15): 先对类别做分层再在层内随机划分 class_to_imgs defaultdict(list) for img_name in image_list: for c in img_classes[img_name]: class_to_imgs[c].append(img_name) train, val, test set(), set(), set() # 每个类别单独按比例划分最后合并 for c, imgs in class_to_imgs.items(): random.shuffle(imgs) n_train int(len(imgs) * train_ratio) n_val int(len(imgs) * val_ratio) train.update(imgs[:n_train]) val.update(imgs[n_train:n_train n_val]) test.update(imgs[n_train n_val:]) # 合并后可能有重复归属做去重和补充 train - val train - test return list(train), list(val), list(test)这段代码的思路是先统计每个类别都出现在哪些图片中再按类别逐个划分最后把冲突样本做一次去重。实现上有个可以优化的点多类别图片可能同时出现在多个类别的划分里上面的示例为了可读性做了简化实际可以用一个score字典记录每张图被重复分配的次数按分数高低决定最终归属。你如果只是快速上手这个版本也够用。4.3 划分后的自查清单划分完不要直接进训练先用一个脚本检查三件事每个类别的图片数在三份数据里的占比是否太悬殊训练集和验证集中是否存在文件名完全相同的图片指标注内容相同但文件名不同会导致数据泄漏每张图片的标注文件是否存在且非空。第三点尤其容易被忽略有些图片本身就是没标志的负样本YOLO训练可以接受但大多数新手你会被各种教程影响以为所有图片都该有框。# check_split.py import os def verify_split(img_dir, label_dir, split_file): missing_label [] empty_label [] with open(split_file, r) as f: for line in f: img_name line.strip() if not img_name: continue txt os.path.join(label_dir, os.path.splitext(img_name)[0] .txt) if not os.path.exists(txt): missing_label.append(img_name) elif os.path.getsize(txt) 0: empty_label.append(img_name) print(f缺少标注: {len(missing_label)}, 空标注: {len(empty_label)}) return missing_label, empty_label5. 训练YOLO的常见问题与排查样本少、标注错、loss不降怎么办5.1 训练集只有1000张epoch数、batch size、预训练权重怎么选很多人拿到数据就开训跑完发现mAP只有0.2然后开始怀疑模型能力。实际上1000张图片的训练规模非常小YOLOv5s这种基础模型都很容易在训练集上迅速过拟合。我的做法是开启预训练权重并冻结骨干网络的前几层先用较小的学习率训练等到loss趋于平稳再解冻微调。python train.py \ --data cctsdb.yaml \ --weights yolov5s.pt \ --img 640 \ --batch 16 \ --epochs 100 \ --cache \ --patience 20参数含义说一下img 640是训练分辨率交通标志普遍小图片缩太小会直接丢失小目标batch 16在显存受限时是一个稳妥起点如果你的显卡能塞下batch 32训练会更稳epochs 100配合early stopping也就是patience 2020个epoch内验证集没有提升就自动停cache参数把图片预加载到内存里1000张图的规模完全放得下能明显加快每次epoch的读取速度。如果你用colab或云GPU内存不够cache时也可以去掉。5.2 现象loss降但mAP不涨——典型的过拟合信号训练前期loss下降很明显到第40个epoch以后验证集mAP开始原地踏步训练集loss继续下降这说明模型在死记训练样本。交通标志的纹理相对简单过拟合后模型记住了背景里的树叶阴影、路灯杆形状而不是标志本身的语义。解决方向有三个一是加大数据增强YOLOv5默认的mosaic和色彩增强可以打开但不建议用太强的旋转因为交通标志本身是规整的矩形过度旋转反而学歪二是减小模型复杂度yolov5s不行就换yolov5n三是增加CCTSDB里冷门类别的过采样让模型在每个epoch里看到更多不同背景下的同一标志。5.3 现象训练时警告标注文件不存在或输出大量nan坐标这类提示基本可以断定是格式转换或路径拼接出了问题。YOLO训练读取标注时是按图片文件名找同名txt如果你把图片放在images/train里、标注放在labels/train里中间的目录层级、后缀名不匹配都会导致找不到文件。另一种情况是txt文件本身存在但内容坐标没有归一化出现了大于1的数值训练时计算loss直接得到nan。# 快速排查 find . -name *.txt | wc -l find . -name *.jpg | wc -l # 检查有没有坐标为负或大于1的标注 awk {for(i3;iNF;i) if($i0 || $i1) print FILENAME: $0} labels/train/*.txt | head -20第一行统计标注文件数量第二行统计图片数量两边对不上说明有图片漏生成标注。第三行的awk检查所有归一化坐标是否在0到1之间发现异常的文件直接重新生成对应标注。5.4 现象验证集的mAP很高但真实道路图片一测全是漏检这种翻车属于数据分布偏移问题。CCTSDB图片虽然来自真实场景但经过你的随机划分后测试集和训练集本身来自同一个分布内部测试分数天然会偏高。一旦你拿手机在路边拍几张新图去测光照、拍摄角度、分辨率都变了小标志直接消失。遇到这种情况我的建议是往训练集里掺入来自其他公开交通标志数据集的样本或者用图像模拟手段增强模糊、雨雾、低光照这些恶劣条件。没有数据增强技巧的话至少把YOLOv5的hsv_h、hsv_s、degrees参数调高一点彩色交通标志对颜色敏感hsv增强有意想不到的泛化效果。5.5 现象有的类别mAP总是0某个类别的mAP纹丝不动先别急着调模型去数一数这个类别到底有多少个框。如果样本数少于15个mAP为0是正常的统计结果。CCTSDB的1000张里指示类的某些细分标志非常稀少三分类合并后这种情况会好很多但如果你的类别映射只合并了两个大类剩下一类比一个稀有那就继续合并或者干脆去掉那一类。不要试图用focal loss或调anchor去解决数据不足补数据才是治本的路。6. 验证与模型迭代用test目录回答你的检测器到底行不行训练结束后大多数人直接看mAP就收工了。train/val/test如果按前面的脚本严格划分现在test目录就是你的“真相考场”。我一般会写一个批量推理脚本让模型对test目录里每一张图输出带置信度的框然后把预测结果可视化出来逐张过一遍。这么做会看到mAP掩盖的很多问题一个mAP 0.68的模型可能在无遮挡标志上表现很好但在小尺寸标志上框全部缩小了一点导致IoU差零点零几就判为漏检。下面是我常用的一条验证命令python detect.py \ --weights runs/train/exp/weights/best.pt \ --source test/images \ --conf-thres 0.3 \ --iou-thres 0.45 \ --save-txt \ --save-conf \ --project test_outputconf-thres调成0.3、0.5、0.7分别跑一遍对比同一张图上哪些窗框消失了能直观判断模型对目标的置信度分布是否健康。如果0.3能框住但0.5就丢了说明这个类别整体置信度偏低另外开一个批量脚本把所有预测框的置信度做个直方图你会看到两个峰一个集中在0.9以上另一个在0.3到0.5之间。第二堆里往往就藏着模糊、遮挡、远处小标志这类难例。后续迭代方向就是围绕这些难例做数据补充。还有一个容易忽略的习惯在CCTSDB这类小规模数据上训练迭代时每轮训练后把验证集里预测错的图片单独存成一个文件夹比如那些漏检了标志、框偏了半个身位、或者把红圈标成了蓝圈的样本。跑下一轮训练时先翻一遍这个文件夹你会比自己预期的更快定位是标注问题还是模型能力不足。我在这套流程上吃过不少亏最明显的一次是某类别mAP始终上不去查了两天才发现是转换脚本里坐标减错了一像素导致所有框整体向右下角偏移。后来我每轮跑完必做可视化抽查从此类似的低级错误再没浪费过时间。如果你是从零接触目标检测这套数据准备到训练的流程走完相当于把数据工程、格式转换、训练调试、验证评测全部摸了一遍。之后再做更大规模的数据集你会发现本质还是一样只是数据多了要写更多自动化脚本。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑