简介:面向目标检测研究者和算法工程师,这套CCTSDB交通标志数据集包含1000张真实道路场景图片,全部经LabelImg人工精细标注,标注框质量高。数据同时提供VOC(xml)、COCO(json)、YOLO(txt)三种格式标签,分目录存放,可直接导入YOLO系列模型训练,适用于交通标志识别、自动驾驶感知等场景。资源包共2000个文件,除图片与标签外,还包含环境搭建与训练教程的HTML文档、数据集划分的Python脚本以及YAML配置文件,整体约237MB。目前已有602人学习下载。配套教程区分Linux和Windows版本,覆盖环境配置、数据划分到模型训练全流程,划分脚本可灵活生成训练集、验证集和测试集,适合快速落地交通标志检测项目。
1. CCTSDB交通标志三格式数据集:1000张图跑通YOLO全流程
对想做交通标志检测的开发者来说,CCTSDB是绕不开的公开数据集,这个版本把1000张图片和VOC、COCO、YOLO三种标签格式一次配齐,旁边还带了划分脚本和训练教程,等于把"准备数据→切分数据→训练YOLO"这条链路包装成了一份可复现的实操包。你能用它解决什么?两件事:一是快速验证YOLO在交通标志这个垂直场景上的效果,二是作为数据预处理和格式转换的练习样本。1000张图不多,正好够跑通全部流程,别指望一次练出商用水准。适合刚入门目标检测、想完整走一遍YOLO流程的开发者,也适合负责数据标注转格式的算法工程师拿来对照自己的脚本哪里有坑。
2. CCTSDB三种标签格式拆解:VOC、COCO、YOLO各自的坐标系和转换要点
这一章我先把三种格式的东西讲透,后面写训练脚本的时候就不会因为坐标搞错而翻车。
2.1 CCTSDB原始标注长什么样:一份txt带来的三个坑
CCTSDB原始标注不是每张图配一个标签文件,而是把所有标注汇总到一份txt里,每个目标一行,格式大致是:图片名 + 一个逗号分隔的 xmin,ymin,xmax,ymax,class_id。第一次拿到这份标注,大多数人会犯三个错。第一个错是没注意类别编号从几开始,有的版本0、1、2对应警告/禁令/指示,有的版本从1开始,直接套到YOLO里会让所有类别错位;第二个错是没做框过滤,原始标注里存在少量框宽高只有几个像素的噪声目标,转进COCO和YOLO后模型会去拟合这些根本没意义的框;第三个错是拿文件名去匹配时没考虑后缀差异,导致一张图对不上标签。
我的习惯是:拿到标注先写三行代码做统计,看最大类别号、框宽高分布、文件名匹配率,再决定后续怎么处理。这三个统计能挡掉后面训练时八成以上的玄学问题。
# 统计原始CCTSDB标注的基本情况,再决定怎么转格式 import os SRC_TXT = "cctsdb_annotations.txt" IMG_DIR = "images" max_cls = 0 tiny_box = 0 matched = 0 total_box = 0 with open(SRC_TXT) as f: for line in f: parts = line.strip().split() if len(parts) < 2: continue img_name = parts[0] x1, y1, x2, y2, cls = [float(v) for v in parts[1].split(",")] total_box += 1 max_cls = max(max_cls, int(cls)) if x2 - x1 < 10 or y2 - y1 < 10: tiny_box += 1 if os.path.exists(os.path.join(IMG_DIR, img_name)): matched += 1 print(f"总框数: {total_box}") print(f"最大类别号: {max_cls} -> 如果从0开始,类别数应为 {max_cls + 1}") print(f"宽或高小于10像素的噪声框占比: {tiny_box / total_box:.2%}") print(f"图片名能匹配上的标注占比: {matched / total_box:.2%}")这段脚本的输出直接决定你后面怎么改:最大类别号是2就说明原标注从0开始,是3就说明从1开始必须整体减一;噪声框占比超过1%就值得在转换时加一道过滤。比例很小的话过滤不过滤无所谓,但我建议保留这道,因为CCTSDB这类从视频抽帧的数据集里,远距离小标志经常标出宽高不到10像素的框,对模型只有坏影响。
2.2 VOC用左上右下、COCO用左上宽高、YOLO用归一化中心:一个框三种写法
三种标签格式描述的是同一个矩形框,但坐标系完全不一样,这是整个转换过程里最容易出错的地方。VOC格式每张图一个XML,目标框写成xmin、ymin、xmax、ymax,单位是像素,类别名放在 标签里,这就是典型的"左上角+右下角"写法。COCO格式是把整个数据集合成一个JSON,目标框写成[x, y, width, height],单位也是像素,但语义变成了"左上角坐标+宽高",而且类别id从1开始,0要预留给background。YOLO格式每张图一个txt,每一行是class_id、x_center、y_center、width、height,前四个数全部除以图片宽高做了归一化,类别id从0开始。
三个格式放在一起看,最隐蔽的坑是两个:一是VOC到COCO的变换不是把xmax、ymax直接当成宽度高度,而是要用xmax减xmin;二是YOLO归一化时必须用读取图片后的实际宽高,不能用文件名猜出来的宽高。很多人转完格式训练时发现框偏移半个身位,十有八九是这两个地方出了问题。我的原则是:别搞一堆脚本去互相转换,先在内存里把所有框统一成绝对像素坐标[x1, y1, x2, y2]这一份中间态,再从这一份中间态分别生成三种格式,这样三个输出天然一致。
2.3 原始txt转VOC/COCO/YOLO:一次性生成脚本与参数说明
下面这个脚本是我整理过的版本,输入一份原始txt和图片目录,输出VOC XML目录、COCO JSON、YOLO txt目录三份产物。脚本里先读原始标注,过滤掉宽或高小于10像素的框,再用同一份中间态写三个输出。
# cctsdb_to_three.py —— 把CCTSDB原始txt转成VOC/COCO/YOLO三份标签 # 原始标注格式:每个目标一行,内容为 "img_name xmin,ymin,xmax,ymax,class_id" import os import cv2 import json import xml.etree.ElementTree as ET SRC_TXT = "cctsdb_annotations.txt" IMG_DIR = "images" OUT_VOC = "labels/voc" OUT_YOLO = "labels/yolo" OUT_COCO = "labels/coco/annotations.json" # 类别映射按实际标注来,这里假设原标注类别id是0/1/2 CLS_NAMES = {0: "warning", 1: "prohibitory", 2: "mandatory"} boxes_by_img = {} with open(SRC_TXT) as f: for line in f: parts = line.strip().split() if len(parts) < 2: continue img_name = parts[0] coords = [float(v) for v in parts[1].split(",")] x1, y1, x2, y2, cls = coords if x2 - x1 < 10 or y2 - y1 < 10: continue # 丢掉噪声小框 boxes_by_img.setdefault(img_name, []).append((x1, y1, x2, y2, int(cls))) os.makedirs(OUT_VOC, exist_ok=True) os.makedirs(OUT_YOLO, exist_ok=True) os.makedirs(os.path.dirname(OUT_COCO), exist_ok=True) def img_size(img_name): return cv2.imread(os.path.join(IMG_DIR, img_name)).shape[:2] # 1) VOC: 每张图一个xml,框是左下/右上绝对像素坐标 for img_name, boxes in boxes_by_img.items(): h, w = img_size(img_name) root = ET.Element("annotation") ET.SubElement(root, "filename").text = img_name size = ET.SubElement(root, "size") ET.SubElement(size, "width").text = str(w) ET.SubElement(size, "height").text = str(h) for x1, y1, x2, y2, cls in boxes: obj = ET.SubElement(root, "object") ET.SubElement(obj, "name").text = CLS_NAMES[cls] bndbox = ET.SubElement(obj, "bndbox") ET.SubElement(bndbox, "xmin").text = str(int(x1)) ET.SubElement(bndbox, "ymin").text = str(int(y1)) ET.SubElement(bndbox, "xmax").text = str(int(x2)) ET.SubElement(bndbox, "ymax").text = str(int(y2)) ET.ElementTree(root).write( os.path.join(OUT_VOC, img_name.rsplit(".", 1)[0] + ".xml"), encoding="utf-8") # 2) YOLO: 每张图一个txt,内容为归一化中心坐标+宽高 for img_name, boxes in boxes_by_img.items(): h, w = img_size(img_name) with open(os.path.join(OUT_YOLO, img_name.rsplit(".", 1)[0] + ".txt"), "w") as f: for x1, y1, x2, y2, cls in boxes: cx = (x1 + x2) / 2 / w cy = (y1 + y2) / 2 / h bw = (x2 - x1) / w bh = (y2 - y1) / h f.write(f"{cls} {cx:.6f} {cy:.6f} {bw:.6f} {bh:.6f}\n") # 3) COCO: 整个数据集合成一个json,注意category_id比原始类号大1 coco = {"images": [], "annotations": [], "categories": []} for cid, cname in CLS_NAMES.items(): coco["categories"].append({"id": cid + 1, "name": cname}) ann_id = 1 for img_id, (img_name, boxes) in enumerate(boxes_by_img.items()): h, w = img_size(img_name) coco["images"].append({"id": img_id, "file_name": img_name, "width": w, "height": h}) for x1, y1, x2, y2, cls in boxes: coco["annotations"].append({ "id": ann_id, "image_id": img_id, "category_id": cls + 1, "bbox": [x1, y1, x2 - x1, y2 - y1], "area": (x2 - x1) * (y2 - y1), "iscrowd": 0, }) ann_id += 1 with open(OUT_COCO, "w") as f: json.dump(coco, f, indent=2)逻辑说明:脚本先读原始txt并过滤小框,得到统一的绝对坐标中间态。COCO部分注意category_id比原始类别号大了1,这是COCO规范里0被保留给background导致的;YOLO部分的归一化用的是cv2.imread读出来的实际宽高,不是文件名后缀推断的。参数说明:CLS_NAMES里的键值要按你这份CCTSDB标注的真实类别编号改,如果原始标注从1开始,就把键改成1、2、3并在写入时整体减一;脚本用cv2.imread读图取尺寸,图片路径必须和IMG_DIR对得上,遇到cv2读不了的图片格式要提前转好。
注意:不同来源的CCTSDB原始标注,类别编号有从0和从1两种。转换前先跑一遍2.1的统计脚本,确认最大类别号再写死CLS_NAMES,这一步省掉后面大量返工。
3. 划分脚本:train/val/test怎么切才不翻车
数据切分看起来简单,实际是数据集正式进入训练前最容易埋雷的一步。
3.1 为什么不能直接random.shuffle:视频帧数据集的"同帧泄漏"问题
CCTSDB是从视频里抽帧标注的,同一个交通标志会在连续好几帧里反复出现,这些帧在视觉上非常接近,只是在画面里稍稍平移了几个像素。如果你直接把图片列表shuffle后按比例切分,完全可能出现同一个标志同时出现在训练集和验证集里。这种"同帧泄漏"会让验证集的mAP虚高,因为在验证集里出现过的目标在训练时已经被模型见过近似版本了。模型上线后在真实视频上表现会明显差于你的验证数字,这是很多人"离线指标好、上线就翻车"的重要原因之一。
正确做法是按"视频段"分桶,同一个视频段抽出来的帧必须进同一个集合。怎么判断哪些图片属于同一视频段?最简单的方法是看文件名前缀,一个视频段的帧通常共用同一个前缀,比如video1_0001.jpg到video1_1200.jpg。如果你拿到的是CCTSDB这类帧序列数据集,第一步先统计文件名结构,找出这个前缀规律再用它分桶。如果实在找不到规律,就退一步用"按文件名前几位归档"的办法,把相同首几位字符归到同一个桶,宁可比random.shuffle保守,不要冒泄漏风险。
3.2 按视频段分桶的划分脚本:可复用的split_dataset.py
下面的脚本把分桶逻辑封装成一个参数,--bucket-by-prefix 打开时就按文件名前缀分桶,关闭时就退化成随机划分。默认比例是6:2:2,适合1000张这种小规模数据集。
# split_dataset.py —— 按视频段分桶切分train/val/test # 用法: # python split_dataset.py --img-dir split/images --yolo-dir split/labels \ # --out-dir data --train-ratio 0.6 --val-ratio 0.2 --bucket-by-prefix import os import random import shutil import argparse from collections import defaultdict def get_bucket(fname): # 前缀相同视为同一视频段;文件名没有下划线时退化为取前4字符 return fname.split("_")[0] if "_" in fname else fname[:4] def main(): ap = argparse.ArgumentParser() ap.add_argument("--img-dir", required=True, help="图片目录") ap.add_argument("--yolo-dir", required=True, help="YOLO标签目录") ap.add_argument("--out-dir", default="data", help="输出根目录") ap.add_argument("--train-ratio", type=float, default=0.6) ap.add_argument("--val-ratio", type=float, default=0.2) ap.add_argument("--bucket-by-prefix", action="store_true", help="按文件名前缀分桶,防止同帧泄漏") args = ap.parse_args() random.seed(42) imgs = [f for f in os.listdir(args.img_dir) if f.lower().endswith((".jpg", ".png"))] if args.bucket_by_prefix: buckets = defaultdict(list) for img in imgs: buckets[get_bucket(img)].append(img) keys = list(buckets.keys()) random.shuffle(keys) n_train = int(len(keys) * args.train_ratio) n_val = int(len(keys) * args.val_ratio) split_keys = { "train": keys[:n_train], "val": keys[n_train:n_train + n_val], "test": keys[n_train + n_val:], } split_imgs = { name: [img for k in ks for img in buckets[k]] for name, ks in split_keys.items() } print("分桶明细:", {k: len(v) for k, v in buckets.items()}) else: random.shuffle(imgs) n = len(imgs) n_train = int(n * args.train_ratio) n_val = int(n * args.val_ratio) split_imgs = { "train": imgs[:n_train], "val": imgs[n_train:n_train + n_val], "test": imgs[n_train + n_val:], } for split_name, imgs in split_imgs.items(): out_img = os.path.join(args.out_dir, split_name, "images") out_lbl = os.path.join(args.out_dir, split_name, "labels") os.makedirs(out_img, exist_ok=True) os.makedirs(out_lbl, exist_ok=True) for img in imgs: shutil.copy(os.path.join(args.img_dir, img), os.path.join(out_img, img)) txt = img.rsplit(".", 1)[0] + ".txt" src_txt = os.path.join(args.yolo_dir, txt) if os.path.exists(src_txt): # 防止个别图没有标注导致训练崩 shutil.copy(src_txt, os.path.join(out_lbl, txt)) print(f"{split_name}: {len(imgs)} 张图片") if __name__ == "__main__": main()逻辑说明:分桶模式下先打印每个桶的明细,检查是否有某个桶大得离谱,一个正常的视频段桶应该在几十到一两百帧左右。切分时对桶列表做shuffle而不是对图片做shuffle,这样才能保证同桶帧不跨集合。注意脚本只复制了YOLO格式的标签,后面如果还要用COCO做验证,需要把COCO的json按同样方式拆分,或直接把labels目录用软链接链到data下。
参数说明:--train-ratio和--val-ratio加起来别超过0.9,我强制留至少10%做test,因为CCTSDB这套标签没有官方test划分,你得自己留一份将来给模型做最终验收。--out-dir下会生成train、val、test三个目录,每个目录里再分images和labels两层。这里的训练集/验证集/测试集是按"桶"粒度算的,所以实际图片数比例会和6:2:2有出入,属于正常现象。
3.3 分类别覆盖检查:切完验证每个集合里三类标志都在
脚本跑完后不要急着训练,先做一次类别覆盖检查。CCTSDB三类里mandatory本身样本就少,如果随机划分恰好把mandatory都分到了train,val里一类标志都没有,那验证集mAP就会直接缺一类。我用下面这段代码统计每个集合里包含每个类别的图片数量,发现某个类别在val或test里一张都没有,就回到上一步重新设置随机种子或手动调整分桶。
# check_coverage.py —— 检查每个集合的类别覆盖情况 import os from collections import Counter for split in ["train", "val", "test"]: lbl_dir = f"data/{split}/labels" cls_counter = Counter() for txt in os.listdir(lbl_dir): with open(os.path.join(lbl_dir, txt)) as f: cls_ids = set(line.split()[0] for line in f if line.strip()) for c in cls_ids: cls_counter[c] += 1 print(split, dict(cls_counter))这段脚本输出三行,train那行正常应该三类都有几百个,val和test每类至少要有几十个。如果val里缺了某个类别,最简单的方法是把随机种子改一下重新划分,或者手动把包含该类别的桶挪几个到val里。总的原则一句话:宁可train少几帧,也别让val缺类别,你后面所有调参决策都依赖val的反馈,val不全等于白练。
4. YOLOv5/YOLOv8训练CCTSDB:配置文件与最小跑通命令
格式转换和切分都做完了,这一章讲实际的训练。
4.1 选v5还是选v8:1000张这个小规模数据集的决定因素
YOLOv5和YOLOv8在CCTSDB这种1000张的小规模数据集上都能收敛,选哪个主要看你的后续用途。如果你之后要把模型部署到TensorRT或者用C++的推理框架,我更推荐v5,anchor-based的模型在边界框回归上对小目标相对友好,社区里针对v5的部署方案最多,踩坑资料也全。如果你只是为了快速出一个验证结果、后面还要反复调结构,v8的命令行入口更简洁,而且anchor-free设计免去了聚类anchor这一步,写实验脚本的时候会省不少心。
我的做法是先用v5s跑一版基线,因为v5s更小、显存占用低,1000张图在单张V100上几分钟一个epoch,100轮下来大概一两个小时就能出一版可分析的结果。这里要提醒一点:预训练权重直接用官方yolov5s.pt,第一次运行train.py会自动下载到权重缓存目录,不需要自己单独找下载地址。如果下载太慢,可以手动把yolov5s.pt放到项目根目录,train.py检测到本地文件后会优先使用。不要用随机初始化从头训练,CCTSDB只有1000张图,随机初始化很难在这么少的数据上学到通用的纹理特征,而coco预训练权重里模型已经见过大量小目标,迁移到交通标志这种小目标场景收敛会快得多。
4.2 data.yaml和train命令:写到目录级别的配置细节
先把数据配置写好。这个文件决定训练时去哪找图、找标签、有哪些类别,路径写绝对路径最省事,可以避开"相对路径在某个目录下跑不通"的坑。
# data.yaml —— 放在你项目的根目录 # path 指向上面划分脚本生成的 data 目录 path: /home/you/cctsdb_split train: train/images val: val/images test: test/images # 类别名顺序严格对应YOLO标签里的class_id names: 0: warning 1: prohibitory 2: mandatoryYOLOv5的训练命令是train.py加参数,YOLOv8是yolo train加参数。两个命令都要到各自项目目录下执行,第一次跑之前先装依赖。v5的依赖在requirements.txt里,v8由ultralytics包统一管理。
# YOLOv5 最小跑通命令 # 先准备一份YOLOv5项目代码,官方仓库clone或本地已有都行 cd yolov5 pip install -r requirements.txt python train.py \ --data /home/you/cctsdb_split/data.yaml \ --weights yolov5s.pt \ --img 640 \ --batch 16 \ --epochs 100 \ --cache \ --workers 4 \ --project runs/cctsdb \ --name exp1 # YOLOv8 等价命令 pip install ultralytics yolo train \ data=/home/you/cctsdb_split/data.yaml \ model=yolov8s.pt \ imgsz=640 \ batch=16 \ epochs=100 \ project=runs/cctsdb \ name=exp1逻辑说明:这两条命令都是标准训练入口,差别只在v8把参数从"--"风格改成了key=value风格,本质参数一致。--cache参数把1000张图全部缓存进内存,可以显著减少数据读取的I/O时间,这个规模下很划算;如果机器内存小于16G就把--cache去掉,或者改用--cache disk缓存到磁盘,这样速度略慢但不会撑爆内存。
4.3 img/batch/cache/autoanchor:这几个参数直接决定训练能不能收敛
第一个关键参数是--img。CCTSDB原始图片分辨率一般在1280x960左右,目标标志占画面比例不大,属于典型的小目标场景。直接用640输入,小标志会被压缩到十几个像素,模型很难学。我的建议是先试640,如果val的AP卡在0.4以下上不去,再试一次960,显存吃得下的话,960往往比640在交通标志上高3到5个点mAP。代价是训练时间翻倍,1000张图的规模下完全可接受。
第二个参数是--batch。它和显存直接相关,别盲目往大了调。V100或A100上可以用64,8G显存用16,4G显存降到8或4。batch太小会导致batchnorm统计不稳定,train的loss曲线会抖得很难看;batch太大在这么少的数据上容易过拟合。一个参考原则:让每个batch里的目标数稳定在50个以上,1000张图、平均每张图两个目标,batch 16已经满足这个条件。
第三个参数是anchor的调整。v5的预训练anchor是基于coco 80类统计的,对交通标志这种小目标不一定适配。训练前跑一次v5自带的autoanchor工具,它会基于你的标注重新聚类得到一批新anchor,具体命令是python utils/autoanchor.py --data data.yaml --img 640,输出的新anchor会建议更新到模型yaml里。这件事在CCTSDB上收益很明显,因为交通标志的宽高比集中在1:1附近,和coco里各类物体的分布差异不小。v8是anchor-free,不需要做这一步。
5. CCTSDB训练避坑指南:五个高频翻车现场与修复方法
这一章来自我自己和身边同事在这些三格式数据集上反复踩过的坑,每条都按现象、原因、解决三步写。
5.1 现象:loss正常但val_mAP只有0.2 —— 标签坐标系的隐藏不一致
训练loss一路下降,精准率和召回率曲线也正常,但val的mAP@0.5只有0.2左右。原因大概率是YOLO标签归一化时除的图片宽高和训练时实际读取到的宽高不一致。CCTSDB从视频抽帧,部分jpg带EXIF方向信息,你如果用PIL读图取shape,PIL会按EXIF自动旋转,而cv2.imread不处理EXIF,两张图shape可能恰好宽高互换。解决:转换脚本里统一用cv2.imread读图再取shape,训练前抽20张图做一次标签可视化,把YOLO标签画到原图上人工看一眼框是否对齐。这一步花五分钟,能挡住后面所有排查时间。
5.2 现象:第二个epoch开始loss=nan —— 空标签图与学习率过大的叠加
loss在第一个epoch正常下降,到第二个epoch直接变成nan。最常见的原因是两个叠加:一是某个batch里混进了没有标签的图片,YOLO的loss对空标签会除零;二是默认学习率0.01对这个1000张的小数据集偏大,超过模型能承受的更新幅度。解决:转换格式时把所有没有目标的图片清单删掉,同时把lr从0.01降到0.001,训练命令里加--lr 0.001。这两个动作做完,nan基本不会再出现。
5.3 现象:mandatory类的AP比另外两类低一截 —— 小目标与样本数双重挤压
训练完看per-class AP,warning和prohibitory有0.7,mandatory只有0.4。原因很简单,mandatory的样本数少,而且这类标志的平均框尺寸比其他两类小,模型在小目标上学到的特征不够。解决:先跑autoanchor把anchor改成适配小目标的尺寸,再看增强参数里的mosaic有没有打开。在1000张的规模下,mosaic能等效扩充训练样本,建议保持默认的1.0。如果还不行,在val.py结果里看mandatory的recall是不是明显低于其他类,是的话单独给mandatory类加权,v5里可以改hyp.yaml的cls系数,或者干脆复制mandatory的图片做两次重采样,让它的样本数和其他类对齐。
5.4 现象:val集指标好看,实拍视频里框乱跳 —— 域差异与增强参数
离线验证mAP有0.7,但你拿着训练好的模型去测一段实拍视频,发现框的置信度忽高忽低,位置抖动严重。这是典型的数据域差异:CCTSDB是固定视角的相机拍的,背景和你实拍的场景相差很大。解决:在训练时把增强打开而不是用默认的弱增强。v5的hyp.scratch-low.yaml里默认hsv_h、hsv_s、degrees这些参数很低,对小数据集容易过拟合到CCTSDB的特定光照和角度上。我会把degrees从0调到10,translate调到0.1,hsv的饱和度扰动可以加大一点。增强会让离线指标先掉几个点,但上线后的稳定性会好很多。
5.5 现象:预测框比实际标志大一圈 —— 原始标注本身框就画得松
模型精度没问题,就是输出的框永远比标志实际边界大一圈。这个原因不在训练,而在CCTSDB原始标注上:人工标注时框画得比较松,没有紧贴目标的边缘。如果你把VOC框画出来看,会发现上边缘和左边框普遍离标志有3到5个像素的余量。解决:可以在转换时整体收缩框,比如把xmin加3像素、xmax减3像素,y方向同样处理,让模型学习更紧的框。或者不去动标注,在推理时把输出的框缩小5%再交给下游业务。我一般选前者,因为训练时拟合紧框,部署时不用额外加后处理逻辑。
6. 用val.py验证每类AP,并给CCTSDB做小目标增强
6.1 验证命令与结果解读:per-class AP和mAP@0.5:0.95
训练结束后跑一遍验证,不要只看train的loss曲线,那说明不了泛化能力。
python val.py \ --data /home/you/cctsdb_split/data.yaml \ --weights runs/cctsdb/exp1/weights/best.pt \ --img 640 \ --conf-thres 0.001 \ --iou-thres 0.6 \ --save-json命令里把conf-thres降到0.001是为了算mAP时覆盖更多的低置信度预测,这样mAP才接近论文里的标准口径。输出文件里会给出每类的mAP@0.5和mAP@0.5:0.95,重点关注margin比较小的那两个类别。如果只有0.4,就说明该类的召回不足,加样本或调anchor;如果AP高但precision低,则说明误检多,可以考虑把置信度阈值提高到0.3再部署。v8的话用yolo val命令,参数名变成data=...、model=...,输出结构和v5基本一致。
6.2 想要更高精度?两个后续动作:切片推理与难例挖掘
CCTSDB这套流程跑到这里已经算完整闭环,但如果1000张的精度不满足业务需求,我一般做两个后续动作。第一个是切片推理,把960或1280的输入图切成几块重叠的小图分别检测再合成结果,对交通标志这种小目标能再涨3到5个点mAP,代价是推理时间成倍增加,适合离线场景。第二个是难例挖掘,把验证集上误检最多的背景图收集起来,比如那些在颜色上和交通标志接近的道路牌、广告牌,单独作为一类负样本加入训练。这两个动作做完,CCTSDB模型的召回和精度都会上一档。
我自己的习惯是:每次训练前先抽20张图确认三种格式的标注可视化都对齐了再开跑,这个习惯已经帮我躲过了至少十次标签错位的坑。这个方案本身不复杂,但每一步的坑都藏在格式切换和划分细节里,把基础打扎实后面调起来才顺。希望帮到你。
本文还有配套的精品资源,点击获取