YOLOv8实战交通标志检测:从TT100K数据到小目标优化

发布时间:2026/10/4 15:10:48
YOLOv8实战交通标志检测:从TT100K数据到小目标优化
简介面向智慧交通场景中的交通标志检测与识别项目实战资源基于Python 3.5与TensorFlow框架搭建卷积神经网络并借助Numpy完成图像归一化、数据增强等预处理操作利用easydict简化JSON配置读取覆盖数据准备、模型设计、训练评估、权重保存等完整流程适合正在学习深度学习与计算机视觉的开发者作为综合练习。资源共247个文件主体为63组模型权重文件meta/data/index格式与10个checkpoint记录点配合29个Python脚本实现数据预处理、模型训练与测试另有5张示例图片和4个说明文档辅助理解压缩包约54.95MB目录结构清晰便于按模块查找所需内容。目前已有181人学习读者可借助该资源包快速复现交通标志多分类识别流程理解卷积神经网络在自动驾驶辅助、智能交通管理等场景中的落地方式并迁移到其他图像识别项目具有较强的参考与实战价值。1. 交通标志检测与识别难点不在识别在“看得见”交通标志检测与识别这个方向表面看就是个标准目标检测问题真正做完一遍才会发现难点全在“让模型看得见那些小目标”上。智慧交通里的限速预警、违章取证、道路资产盘查第一步都是把路牌从画面里可靠地找出来、读准含义。对准备人工智能大作业、毕设或者想从通用检测转向场景落地的开发者来说这是一道性价比很高的实践题数据公开、任务边界清晰、效果可量化做完还能直接接到视频流里演示。我按模型选型、数据准备、训练调参、避坑、评估部署这条线往下写命令都是能直接复现的。2. 模型选型与数据准备为什么是YOLOv8TT100K和GTSRB怎么选2.1 方案对比传统CV、二阶段检测、单阶段检测各自能干什么拿到交通标志检测需求第一反应可能是用颜色分割加形状匹配去做标志无非红蓝黄三种主色圆形三角形矩形三种形状。这个思路在理想光照下能跑但一进真实街景就崩——黄昏时红色标志和刹车灯颜色接近逆光时蓝色标志发灰树影打在标志上形状直接断裂。我见过不少课程设计用OpenCV颜色阈值做交出来的演示视频里漏检率超过一半。传统CV适合做标志的粗定位或者预处理不适合作为主方案。分类网络解决的只是“这个裁剪图是什么”但标志在画面里的位置没人告诉你。两阶段检测Faster R-CNN先提候选框再分类精度确实好但TT100K原图2048x2048一帧推理几百毫秒放到视频流里体验很差。单阶段检测器把定位和分类一步做完YOLO系列在速度和精度之间最平衡生态也最成熟预训练权重好找、训练命令统一、可视化工具齐全。做项目实践而不是发论文YOLOv8是性价比最高的起点显存紧张就换yolov8n精度不够就升yolov8s或yolov8m。2.2 TT100K与GTSRB两个常用数据集差在哪怎么选交通标志领域有两个绕不开的公开数据集。GTSRB是德国交通标志识别基准43类图片已经按标志裁剪好是纯分类任务PPM格式居多。TT100K是清华-腾讯百类交通标志数据集中国街景截图带完整场景和框标注类别用i4、p3、pl40这种编码表示禁令、警告、指示等大类通过前缀区分。做检测项目用TT100K更合适因为模型最后要部署到“从全图找标志”的任务上而不是“对裁剪图分类”。对比项TT100KGTSRB任务类型目标检测全图框分类已裁剪场景中国城市街景腾讯地图采集德国道路欧洲标志类别数上百类但可用高频类约几十类43类标注质量JSON标注含score和is_hard字段官方已划分训练测试集坑街景图带水印类别极不均衡需要自己转格式裁剪图无法练检测如果只做分类演示GTSRB够用但凡项目标题里写了“检测”就得用TT100K。我的常见做法是TT100K练检测主模型GTSRB留着做二阶段分类兜底这个后面会讲。TT100K从官网申请下载后会得到一个压缩包里面是images目录和annotations.json没有现成的YOLO格式标签这一步必须自己处理。2.3 标注格式转换把JSON转成YOLO能吃的txtYOLO系列训练需要的标签是txt文件每行一个目标格式为“类别id x_center y_center width height”坐标全部归一化到0到1之间。TT100K的annotations.json存的是bbox左上角右下角像素坐标和类别字符串第一步先把类别字符串映射成数字id。import json import os from collections import defaultdict json_path annotations.json with open(json_path, r, encodingutf-8) as f: data json.load(f) # TT100K的标注按图片id组织常见结构是 data[imgs][图片id][annotations] imgs data[imgs] category_count defaultdict(int) for img_id, info in imgs.items(): for obj in info.get(annotations, []): category_count[obj[category]] 1 # 打印每个类别和样本数低于30的类别建议直接丢掉 for cat, cnt in sorted(category_count.items()): print(cat, cnt) cat2idx {cat: idx for idx, cat in enumerate(sorted(category_count.keys()))} print(类别映射:, cat2idx)这段代码先做类别统计。TT100K类别分布很不均匀有的类别上千张有的只有个位数。我一般把样本数少于30的类别直接丢弃否则模型会花大量参数去记几个样本训练出来的权重对这类标志没有任何泛化能力。注意输出类别映射后要保存下来后面训练配置文件的names列表必须和这里的cat2idx完全一致顺序错一个全盘皆输。import cv2 import os img_root images out_root labels os.makedirs(out_root, exist_okTrue) for img_id, info in imgs.items(): img_path os.path.join(img_root, info.get(path, img_id .jpg)) img cv2.imread(img_path) if img is None: print(f图片读取失败跳过{img_path}) continue h, w img.shape[:2] # 宽高一律以实际读图为准别用JSON里可能存在的字段 lines [] for obj in info.get(annotations, []): score obj.get(score, 1.0) if score 0.8: # 低置信度标注多数是模糊目标过滤掉减少噪音 continue x1, y1, x2, y2 obj[bbox] bw x2 - x1 bh y2 - y1 if bw 0 or bh 0: continue cx ((x1 x2) / 2) / w cy ((y1 y2) / 2) / h bw_norm bw / w bh_norm bh / h cls_id cat2idx[obj[category]] lines.append(f{cls_id} {cx:.6f} {cy:.6f} {bw_norm:.6f} {bh_norm:.6f}) if lines: # 没有有效标注的图片不生成txt模型会把它当背景 txt_path os.path.join(out_root, img_id .txt) with open(txt_path, w, encodingutf-8) as f: f.write(\n.join(lines))这里有几个决定后面训练能不能顺利进行的细节。第一图片尺寸必须用cv2读出来的实际值不要用JSON里可能带的宽高字段一旦两者不一致所有归一化坐标全错。第二score过滤阈值别设太高0.8是个合理值设到0.95会丢掉大量有效样本TT100K的低分标注不代表错标很多是目标本身模糊但确实存在的难例。第三原图里没有标注的区域会自动变成背景这是YOLO默认行为后面负样本坑我还会细说。3. 用YOLOv8在本地跑通交通标志检测目录结构、训练命令与参数说明3.1 数据目录结构与data.yaml先让路径和类别对得上ultralytics的训练框架对数据目录有一定约定不遵守会报各种路径错误。目录结构我习惯这样组织images和labels同级train和val在两者内部对应同名子目录。注意TT100K官方只给了train和test划分没有val训练时不能拿官方test当验证集去调参否则最后评估结果虚高必须自己从train里切一部分出来。cd ~/traffic-sign mkdir -p datasets/tt100k/images/train mkdir -p datasets/tt100k/images/val mkdir -p datasets/tt100k/labels/train mkdir -p datasets/tt100k/labels/valpython -c import os, random, shutil random.seed(42) img_train datasets/tt100k/images/train img_val datasets/tt100k/images/val lab_train datasets/tt100k/labels/train lab_val datasets/tt100k/labels/val files [f for f in os.listdir(img_train) if f.endswith((.jpg, .png, .ppm))] random.shuffle(files) val_size max(1, int(len(files) * 0.12)) for f in files[:val_size]: shutil.move(os.path.join(img_train, f), os.path.join(img_val, f)) label f.rsplit(., 1)[0] .txt if os.path.exists(os.path.join(lab_train, label)): shutil.move(os.path.join(lab_train, label), os.path.join(lab_val, label)) 切分数据时必须图片和标签一起挪只挪图片不挪标签会导致训练时报“标签文件不存在”的警告而且这个警告不会中断训练只是被自动忽略结果就是val的mAP计算缺了一部分样本指标不准。随机种子固定为42保证每次切分结果一致方便复现。接着写data.yaml。utlralytics会读取这个文件定位数据集和类别名。# tt100k.yaml path: /home/your_name/traffic-sign/datasets/tt100k # 改成你的绝对路径 train: images/train val: images/val nc: 45 # 与前面cat2idx的实际类别数一致 names: [i4, i5, p3, p10, p19, pl40, pl80, pl100, pm20, pn40] # 按cat2idx顺序填全names的顺序是这里最容易翻车的地方。cat2idx生成时用的是sorted排序data.yaml里的names必须和那个排序完全一致否则类别id错位训练出来的模型预测结果全是“指鹿为马”。我每次写完yaml都会跑一行代码核对python -c import yaml cfg yaml.safe_load(open(datasets/tt100k/tt100k.yaml)) print(len(cfg[names]), cfg[nc]) 如果nc和names长度不一致训练会直接报错长度一致但顺序错则不报错但结果全错务必用上一节保存的cat2idx对照检查。3.2 训练命令与关键参数epochs、imgsz、batch到底怎么定环境准备就两步安装ultralytics和torchpip一条命令的事显卡驱动和CUDA版本对照好就行CPU也能训但速度慢一个数量级。模型用官方预训练权重yolov8s.pt启动迁移学习比随机初始化收敛快很多对项目实践来说是常规操作。yolo detect train \ modelyolov8s.pt \ datadatasets/tt100k/tt100k.yaml \ epochs120 \ imgsz640 \ batch16 \ lr00.01 \ patience30 \ projectruns/traffic_sign参数逐个说。epochs120针对TT100K几万张图、几十个类别的规模够用如果只保留高频类别训练80到100轮也能收敛。imgsz640是默认值对交通标志这种小目标偏小我后面会单开一节讲怎么提到960或1280那是小目标召回的关键手段。batch16对应12G显存6G显存降到8显存不足时优先减batch而不是减imgsz。lr00.01是迁移学习常见初始值如果训练loss前几个epoch不降反升把它降到0.005重来。patience30表示连续30个epoch验证集指标没有改善就自动停止这是省算力的关键设置不用死等120轮跑完。训练跑起来之后不要只盯着终端里的loss数字。每个epoch结束打开runs/traffic_sign目录下的results.png重点看三件事train和val的box_loss曲线是否同步下降mAP50曲线是否平稳上升mAP50-95是否跟着mAP50走。如果val loss先降后升而mAP还在涨说明模型开始过拟合这时候调回去加大数据增强比继续训练划算。3.3 训练完先做三件事权重文件、PR曲线、单图推理训练正常结束后runs/traffic_sign/weights目录下会有best.pt和last.pt。best.pt是验证集指标最优的权重后续所有推理都用它last.pt是最后一轮权重只在还想续训时用。第一件事先跑单图推理确认基本行为正常yolo predict \ modelruns/traffic_sign/weights/best.pt \ sourcetest_images/ \ saveTrue \ conf0.35conf这个参数直接影响演示效果。0.35是交通标志场景比较稳的起点调低到0.2召回会更高但误检变多调高到0.5误检少但小目标漏检明显。项目实践里我一般备两组权重演示用conf0.35离线批量处理用conf0.2。第二件事看PR曲线在runs/traffic_sign/目录下找PR_curve.png。曲线越靠近右上角越好重点关注曲线尾部是否翘起——如果尾部突然下坠说明存在模型极不自信但确实正确的目标这类目标多半是极小或遮挡严重的标志。第三件事跑一次官方test集做完整评估yolo val \ modelruns/traffic_sign/weights/best.pt \ datadatasets/tt100k/tt100k.yaml \ splitval \ conf0.001 \ iou0.6val时conf设0.001是为了让模型把所有候选框都吐出来mAP计算本来就会遍历所有置信度阈值所以不会影响指标但能让你拿到完整的混淆矩阵。4. 交通标志检测的常见翻车现场五条踩坑记录与修复方案4.1 训练集里全是水印图模型学到的是腾讯地图现象训练时loss正常下降mAP50也到了0.8以上一测没有水印的图就漏检框得又偏又抖。原因TT100K是从腾讯街景截的原始图片角落几乎都带“腾讯地图”文字水印模型把水印当成标志出现的强相关特征学到了权重里等于在识别“有没有水印”而不是“有没有标志”。解决训练前统一处理水印区域。最简单粗暴的做法是把图片四角固定区域用纯色矩形遮掉因为水印位置相对固定在左上角或右下角。import cv2 import os img_dir images processed_dir images_clean os.makedirs(processed_dir, exist_okTrue) for fname in os.listdir(img_dir): if not fname.endswith(.jpg): continue img cv2.imread(os.path.join(img_dir, fname)) h, w img.shape[:2] # 水印集中在右下角遮掉右下角6%宽高的矩形区域 cv2.rectangle(img, (int(w * 0.94), int(h * 0.94)), (w, h), (0, 0, 0), -1) cv2.imwrite(os.path.join(processed_dir, fname), img)遮掉水印后必须重新生成标签txt因为图片尺寸没变、坐标没变、只是像素被改了所以第2.3节的转换脚本可以原样跑一遍只是img_root换成images_clean。遮罩区域如果正好压到标志那些样本直接放弃数量很少不影响大局。4.2 限速80和限速120互相认错小目标分辨率不够现象单图测试时pl80和pl120频繁串pl40也偶尔混进来mAP50-95明显比mAP50低一截。原因这类标志视觉特征高度相似数字密集原图2048x2048里标志可能只有40x40像素缩到640x640后只剩12x12数字特征在卷积下采样中基本被抹平。解决把训练输入尺寸提到960或1280让小目标在输入图里保留更多像素。前提是显存够batch从16降到8或4抵消分辨率提升带来的显存占用。另一个有效手段是数据增强里加hsv_h和hsv_s扰动模拟不同光照下的颜色偏移让模型不依赖颜色而依赖形状。4.3 忘记划分验证集mAP曲线像心电图现象训练loss一路下降但val的mAP曲线剧烈抖动每个epoch之间跳10个点以上。原因没做训练集/验证集切分直接用全部图片训练或者val里只包含少量图片且类别不均衡某些类别一张都没进val。模型在训练数据上记了不少“图片特有特征”切换验证集时表现忽高忽低。解决切分时先按类别分层抽样保证val里每个类至少1到2张然后再随机补足到12%比例。类别特别少的类训练时既不会对mAP贡献多少还容易让val结果波动这也是前面建议直接过滤低频类别的原因。4.4 负样本缺失模型把红绿灯当标志框出来现象训练和val指标都好看但一跑真实视频流红绿灯、路牌、广告牌被成串框出来conf调低后更严重。原因TT100K每一张图都包含至少一个标志模型在整个训练过程中几乎没有见过“完全没有标志”的画面它学到的是“画面里这种纹理区域就是目标”而不是“符合标志先验的目标才是”。解决从原数据集里挑出标注为空的图片或者从官方test集里裁不含标志的街景区域混进训练集当背景。负样本不需要生成txtYOLO会自动把无标签图片当背景。我一般把负样本比例控制在总样本的10%到15%比例太高会压掉正样本导致漏检。4.5 中文路径和图片格式让训练直接中断现象训练刚开始或者跑到一半突然报FileNotFoundError或“can not find label file”甚至有的图片被跳过样本数明显变少。原因数据集目录放在带中文的路径下Windows上OpenCV读图失败GTSRB的ppm格式在部分增强管线里解码异常还有的是图片存在但对应的txt丢失配不上对。解决路径全部用英文不要有任何中文目录名用第2.3节脚本时统一把图片转成jpgppm和png都顺手转掉训练前跑一遍配对校验。python -c import os img_dir datasets/tt100k/images/train lab_dir datasets/tt100k/labels/train missing [f for f in os.listdir(img_dir) if os.path.exists(os.path.join(lab_dir, f.rsplit(.,1)[0] .txt)) False] print(缺失标签的图片数量:, len(missing)) 如果missing数量不为0检查这些图片是不是本身没有标注。TT100K里有部分图片确实没有对应标注要么直接删掉这些图片要么保留但接受它作为背景样本。最怕的是部分有txt部分没有而且你没排查到时候val mAP计算结果会少算目标指标虚低。5. 评估与调优mAP50-95才是交通标志项目的照妖镜5.1 看混淆矩阵而不是只看mAP训练完很多人只看mAP500.85就觉得完事了这是个典型误区。mAP50对框的位置要求很宽松IoU大于0.5就算命中框偏了半个身位照样算对。交通标志场景对定位精度要求其实不高真正要命的是分类错误——把限速80认成限速120在超速预警系统里是致命的。所以评估重点看混淆矩阵在runs/traffic_sign目录下的confusion_matrix.png里对角线的亮度代表该类识别正确率非对角线亮点代表类别间的系统性混淆。我遇到过最典型的pl80限速80和pl120限速120在混淆矩阵里互相串得厉害pl40也偶尔掺和进来。解决思路不是盲目加数据而是先看这两类在val里的样本量差距如果pl120样本数只是pl80的三分之一那结果偏向pl80是正常的需要补充pl120的数据而不是调loss权重。还有一种情况是标志外观确实接近低分辨率下数字糊成一团这时候训练层面的调整已经到了极限应该考虑第5.3节的两阶段方案。5.2 小目标召回的两招输入分辨率提上去切片推理跟上TT100K原图是2048x2048标志尺寸普遍在30到60像素之间。按YOLO的640输入换算目标只占十几像素骨干网络下采样到20x20特征图时特征早没了。第一招是把imgsz从640提到960显存有余量可以试1280。这一步通常能把mAP50-95提升3到5个点代价是训练时间和显存翻倍batch要相应减半。第二招是推理阶段用SAHI做切片推理把大图切成若干小patch分别检测再合并结果相当于对每个区域做局部放大。pip install sahi sahi predict \ --model_type yolov8 \ --model_path runs/traffic_sign/weights/best.pt \ --source test_images/ \ --slice_width 640 \ --slice_height 640 \ --overlap_width_ratio 0.2 \ --overlap_height_ratio 0.2slice_width和slice_height是切片尺寸一般跟训练imgsz一致。overlap_ratio设为0.2是防止目标恰好被切在patch边缘被截断重叠区域的目标会在相邻两个patch里各检一次最后合并时NMS会去掉重复框。这套做法对离线批量识别特别有效但推理时间乘以切块数量视频流实时场景用不了只能吃训练时的分辨率红利。5.3 检测加分类的两阶段方案小目标误分类的兜底手段单阶段模型在小目标分类上到了极限怎么办我常用的保底方案是检测和分类分离。第一步用yolov8n把所有标志类别合并成一类只负责把标志框出来不关心它是限速还是禁令。框出来之后把区域裁剪缩放丢给一个专门训练的小分类网络去读具体类别。分类网络吃的是一张居中的标志图不需要自己找目标特征利用率高得多同等条件下分类准确率比端到端检测高出不少。import torch.nn as nn class SignClassifier(nn.Module): def __init__(self, num_classes43): super().__init__() self.features nn.Sequential( nn.Conv2d(3, 32, 3, padding1), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(32, 64, 3, padding1), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(64, 128, 3, padding1), nn.ReLU(), nn.MaxPool2d(2), ) self.classifier nn.Linear(128 * 4 * 4, num_classes) def forward(self, x): x self.features(x) x x.view(x.size(0), -1) return self.classifier(x)这个分类网络输入是裁剪后的标志图用GTSRB预训练或者直接用TT100K里裁出来的标志图训练都行。两阶段方案的缺点是pipeline变长单帧延迟增加十几毫秒但对离线的交通标志识别任务来说精度收益远大于这点延迟成本。我自己的经验是端到端模型对易混类判别困难时二阶段方案几乎立竿见影。6. 部署前的最后一公里导出ONNX、接视频流与持续迭代模型验证完不等于项目做完要落地演示还得过部署这一关。先把权重导出成ONNX格式方便后续转TensorRT或者用OpenCV DNN加载yolo export \ modelruns/traffic_sign/weights/best.pt \ formatonnx \ imgsz640 \ opset12导出后注意输入输出节点名用onnxruntime跑推理时要用它打印出来的实际节点名不同版本YOLO的节点命名规则不一样写代码前先用onnxruntime的session对象看一眼输出shape。接视频流时别傻乎乎逐帧检测。常见做法是每两帧或三帧做一次推理中间帧沿用上一帧的结果这样在普通显卡上能把实时性从十几帧拉到二十几帧。如果延时要求不高跳帧系数可以调到5画面里标志不会跳得太突兀。夜间场景是另一个坑白天训练好的模型在夜间路灯反光下经常认错限速标志靠数据增强硬扛效果有限最实用的办法是找夜间街景数据补训哪怕只有几百张对夜间鲁棒性的提升远大于加几十轮白天数据。我自己的固定流程是训练前先花十分钟做一次训练集随机抽样可视化看标注框有没有错位、图片有没有水印污染、有没有明显的类别缺失这十分钟能避开后面一整天的排错。等到项目交付时再回看那些被验证集指标掩盖的坑——水印、负样本、小目标混淆——才是真正决定模型能不能用的关键。希望帮到你。本文还有配套的精品资源点击获取