简介:面向智慧交通场景中的交通标志检测与识别项目实战资源,基于Python 3.5与TensorFlow框架搭建卷积神经网络,并借助Numpy完成图像归一化、数据增强等预处理操作,利用easydict简化JSON配置读取,覆盖数据准备、模型设计、训练评估、权重保存等完整流程,适合正在学习深度学习与计算机视觉的开发者作为综合练习。资源共247个文件,主体为63组模型权重文件(meta/data/index格式)与10个checkpoint记录点,配合29个Python脚本实现数据预处理、模型训练与测试,另有5张示例图片和4个说明文档辅助理解,压缩包约54.95MB,目录结构清晰,便于按模块查找所需内容。目前已有181人学习,读者可借助该资源包快速复现交通标志多分类识别流程,理解卷积神经网络在自动驾驶辅助、智能交通管理等场景中的落地方式,并迁移到其他图像识别项目,具有较强的参考与实战价值。
1. 交通标志检测与识别:难点不在识别,在“看得见”
交通标志检测与识别这个方向,表面看就是个标准目标检测问题,真正做完一遍才会发现,难点全在“让模型看得见那些小目标”上。智慧交通里的限速预警、违章取证、道路资产盘查,第一步都是把路牌从画面里可靠地找出来、读准含义。对准备人工智能大作业、毕设或者想从通用检测转向场景落地的开发者来说,这是一道性价比很高的实践题:数据公开、任务边界清晰、效果可量化,做完还能直接接到视频流里演示。我按模型选型、数据准备、训练调参、避坑、评估部署这条线往下写,命令都是能直接复现的。
2. 模型选型与数据准备:为什么是YOLOv8,TT100K和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更合适,因为模型最后要部署到“从全图找标志”的任务上,而不是“对裁剪图分类”。
| 对比项 | TT100K | GTSRB |
|---|---|---|
| 任务类型 | 目标检测(全图+框) | 分类(已裁剪) |
| 场景 | 中国城市街景,腾讯地图采集 | 德国道路,欧洲标志 |
| 类别数 | 上百类,但可用高频类约几十类 | 43类 |
| 标注质量 | JSON标注,含score和is_hard字段 | 官方已划分训练测试集 |
| 坑 | 街景图带水印,类别极不均衡 | 需要自己转格式,裁剪图无法练检测 |
如果只做分类演示,GTSRB够用;但凡项目标题里写了“检测”,就得用TT100K。我的常见做法是TT100K练检测主模型,GTSRB留着做二阶段分类兜底,这个后面会讲。TT100K从官网申请下载后会得到一个压缩包,里面是images目录和annotations.json,没有现成的YOLO格式标签,这一步必须自己处理。
2.3 标注格式转换:把JSON转成YOLO能吃的txt
YOLO系列训练需要的标签是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", encoding="utf-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_ok=True) 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", encoding="utf-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和torch,pip一条命令的事,显卡驱动和CUDA版本对照好就行,CPU也能训但速度慢一个数量级。模型用官方预训练权重yolov8s.pt启动迁移学习,比随机初始化收敛快很多,对项目实践来说是常规操作。
yolo detect train \ model=yolov8s.pt \ data=datasets/tt100k/tt100k.yaml \ epochs=120 \ imgsz=640 \ batch=16 \ lr0=0.01 \ patience=30 \ project=runs/traffic_sign参数逐个说。epochs=120针对TT100K几万张图、几十个类别的规模够用,如果只保留高频类别训练,80到100轮也能收敛。imgsz=640是默认值,对交通标志这种小目标偏小,我后面会单开一节讲怎么提到960或1280,那是小目标召回的关键手段。batch=16对应12G显存,6G显存降到8,显存不足时优先减batch而不是减imgsz。lr0=0.01是迁移学习常见初始值,如果训练loss前几个epoch不降反升,把它降到0.005重来。patience=30表示连续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 \ model=runs/traffic_sign/weights/best.pt \ source=test_images/ \ save=True \ conf=0.35conf这个参数直接影响演示效果。0.35是交通标志场景比较稳的起点,调低到0.2召回会更高但误检变多,调高到0.5误检少但小目标漏检明显。项目实践里我一般备两组权重,演示用conf=0.35,离线批量处理用conf=0.2。
第二件事看PR曲线,在runs/traffic_sign/目录下找PR_curve.png。曲线越靠近右上角越好,重点关注曲线尾部是否翘起——如果尾部突然下坠,说明存在模型极不自信但确实正确的目标,这类目标多半是极小或遮挡严重的标志。第三件事跑一次官方test集做完整评估:
yolo val \ model=runs/traffic_sign/weights/best.pt \ data=datasets/tt100k/tt100k.yaml \ split=val \ conf=0.001 \ iou=0.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_ok=True) 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集里裁不含标志的街景区域,混进训练集当背景。负样本不需要生成txt,YOLO会自动把无标签图片当背景。我一般把负样本比例控制在总样本的10%到15%,比例太高会压掉正样本导致漏检。
4.5 中文路径和图片格式让训练直接中断
现象:训练刚开始或者跑到一半突然报FileNotFoundError或“can not find label file”,甚至有的图片被跳过,样本数明显变少。原因:数据集目录放在带中文的路径下,Windows上OpenCV读图失败;GTSRB的ppm格式在部分增强管线里解码异常;还有的是图片存在但对应的txt丢失,配不上对。解决:路径全部用英文,不要有任何中文目录名;用第2.3节脚本时统一把图片转成jpg,ppm和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
训练完很多人只看mAP50,0.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_classes=43): super().__init__() self.features = nn.Sequential( nn.Conv2d(3, 32, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(32, 64, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(64, 128, 3, padding=1), 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 \ model=runs/traffic_sign/weights/best.pt \ format=onnx \ imgsz=640 \ opset=12导出后注意输入输出节点名,用onnxruntime跑推理时要用它打印出来的实际节点名,不同版本YOLO的节点命名规则不一样,写代码前先用onnxruntime的session对象看一眼输出shape。
接视频流时别傻乎乎逐帧检测。常见做法是每两帧或三帧做一次推理,中间帧沿用上一帧的结果,这样在普通显卡上能把实时性从十几帧拉到二十几帧。如果延时要求不高,跳帧系数可以调到5,画面里标志不会跳得太突兀。夜间场景是另一个坑,白天训练好的模型在夜间路灯反光下经常认错限速标志,靠数据增强硬扛效果有限,最实用的办法是找夜间街景数据补训,哪怕只有几百张,对夜间鲁棒性的提升远大于加几十轮白天数据。
我自己的固定流程是:训练前先花十分钟做一次训练集随机抽样可视化,看标注框有没有错位、图片有没有水印污染、有没有明显的类别缺失,这十分钟能避开后面一整天的排错。等到项目交付时再回看,那些被验证集指标掩盖的坑——水印、负样本、小目标混淆——才是真正决定模型能不能用的关键。希望帮到你。
本文还有配套的精品资源,点击获取