☰
CCTSDB到YOLO:交通标志检测的格式转换与训练实战
2026/10/18 14:08:37 网站建设 项目流程

简介: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', encoding='utf-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, encoding="utf-8", xml_declaration=True)

这段代码的注意点在类别映射上。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", encoding="utf-8") as f: json.dump(coco_data, f, ensure_ascii=False)

这段代码里最重要的一行是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", encoding="utf-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_ratio=0.7, val_ratio=0.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_label

5. 训练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 20,20个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(i=3;i<=NF;i++) if($i<0 || $i>1) 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_output

conf-thres调成0.3、0.5、0.7分别跑一遍,对比同一张图上哪些窗框消失了,能直观判断模型对目标的置信度分布是否健康。如果0.3能框住但0.5就丢了,说明这个类别整体置信度偏低,另外开一个批量脚本把所有预测框的置信度做个直方图,你会看到两个峰:一个集中在0.9以上,另一个在0.3到0.5之间。第二堆里往往就藏着模糊、遮挡、远处小标志这类难例。后续迭代方向就是围绕这些难例做数据补充。

还有一个容易忽略的习惯:在CCTSDB这类小规模数据上训练迭代时,每轮训练后把验证集里预测错的图片单独存成一个文件夹,比如那些漏检了标志、框偏了半个身位、或者把红圈标成了蓝圈的样本。跑下一轮训练时先翻一遍这个文件夹,你会比自己预期的更快定位是标注问题还是模型能力不足。我在这套流程上吃过不少亏,最明显的一次是某类别mAP始终上不去,查了两天才发现是转换脚本里坐标减错了一像素,导致所有框整体向右下角偏移。后来我每轮跑完必做可视化抽查,从此类似的低级错误再没浪费过时间。

如果你是从零接触目标检测,这套数据准备到训练的流程走完,相当于把数据工程、格式转换、训练调试、验证评测全部摸了一遍。之后再做更大规模的数据集,你会发现本质还是一样,只是数据多了要写更多自动化脚本。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询