☰
交通标志分类实战:从CNN模型搭建到部署的完整指南
2026/10/5 3:55:52 网站建设 项目流程

简介:这是一套面向智慧交通场景的计算机视觉项目实践资源,核心任务基于卷积神经网络完成交通标志图像分类,适合初学深度学习的开发者,也可作为高校课程设计或毕业设计的实战参考。压缩包共2000个文件,以1994张PNG格式的交通标志图片为主,并有3个XML标注文件用于辅助整理图像类别信息,另有3个Python脚本实现模型训练与单张图片预测,整体大小约201.95MB,结构上能按训练、测试、预测等功能快速定位内容。资源目前已有78人学习下载。借助提供的训练脚本,可通过命令行指定训练集与测试集路径并输出模型文件;预测脚本支持对指定图片进行识别,配合包内近两千张图像样本,能够快速复现从数据准备到模型推理的完整流程,帮助理解CNN分类项目的代码组织方式、参数传递与目录划分思路,是一份实用且可直接上手操作的参考资料。

1. 交通标志分类:为什么说这是入门 CNN 最值得复现的智慧交通项目

交通标志分类是 CNN 卷积神经网络项目实践里最经典的一道题:数据集公开、类别定义清晰、训练完的效果肉眼可见,特别适合当人工智能大作业或入门深度学习的第一个完整项目,也常被写进各种人工智能学习路径的推荐清单。它对应的真实场景是智慧交通里的路侧感知与辅助驾驶——摄像头拍到一块标志牌,系统要在几十毫秒内告诉驾驶员这是限速 60 还是解除限速。看起来只是图像分类,但真正做进去会发现光照、遮挡、相似类别都在给模型挖坑。这篇笔记按我自己的实践路径,从数据准备、模型搭建、训练排错到部署验证讲一遍,新手能照着复现,跑过类似项目的人也能对一对排查思路。

2. 数据准备:GTSRB 数据集与自定义 Dataset

2.1 数据集选型:GTSRB 还是 TT100K

做交通标志分类,业界最常用的公开数据集是德国交通标志数据集 GTSRB(German Traffic Sign Recognition Benchmark),它也是这类人工智能项目实践里出现频率最高的基准。GTSRB 覆盖 43 类标志,训练集 39209 张、测试集 12630 张,图像全部来自真实道路场景,光照变化、运动模糊、部分遮挡都包含在内,比那种实验室拍的干净图更接近智慧交通路侧摄像头的实际画面,用来训练出来的模型也更经得起现场考验。

国内也有 TT100K 这类百类交通标志数据集,但 TT100K 的标注是 bounding box,更适合做目标检测而不是分类。做"对交通标志进行分类"这个任务,我建议直接用 GTSRB,省去大量清洗和整理标注的时间。而且 GTSRB 的类别 Id 在 CSV 里写得清清楚楚,ClassId 从 0 到 42,测试集有独立的 GT-final_test.csv,评估结果可以直接跟论文里的准确率对比,省心。

这里有一个新手容易忽略的细节:GTSRB 原始图像尺寸从 15×15 到 250×250 不等,存储格式是 PPM,不是常见的 JPG。很多现成的样板代码按 JPG 方式读会直接翻车,要么颜色不对,要么尺寸不统一导致 DataLoader 里 batch 拼不起来,报错信息还特别抽象。所以做这个项目,第一步不是搭网络,而是先把数据读对。

2.2 自定义 Dataset:从 CSV 映射标签到 PPM 图像

常见做法是先把图片按类别复制到 train/0、train/1 这样的子目录,然后交给 torchvision 的 ImageFolder。但 GTSRB 有近 4 万张训练图,复制一遍又慢又占磁盘,所以我习惯直接写一个自定义 Dataset,标签从 CSV 里读,图片按原路径取。这样后面算类别权重、做难例分析也都方便,同一个 DataFrame 贯穿整个流程。

import os import pandas as pd from PIL import Image from torch.utils.data import Dataset class GTSRBDataset(Dataset): def __init__(self, csv_path, img_dir, transform=None): # GTSRB 的 train.csv / GT-final_test.csv 里 # Filename 列形如 "00000/00000_00000.ppm" self.df = pd.read_csv(csv_path) self.img_dir = img_dir self.transform = transform def __len__(self): return len(self.df) def __getitem__(self, idx): row = self.df.iloc[idx] # PIL 能直接打开 PPM,但一定记得 convert("RGB") # 否则返回的可能是 "P" 模式,后面 Normalize 会出问题 img = Image.open(os.path.join(self.img_dir, row["Filename"])).convert("RGB") label = int(row["ClassId"]) if self.transform: img = self.transform(img) return img, label

这段代码里最容易踩的坑是 convert("RGB")。GTSRB 的 PPM 图像用 PIL 读进来,mode 可能是 "P" 也可能已经是 "RGB",如果不统一转成 RGB,后面 BatchNorm 和 Normalize 的通道假设就全乱了,训练时经常会出现 loss 突然跳高又立刻回来的诡异现象。另外注意 CSV 的 Filename 列带子目录前缀,如果你用的是 Kaggle 上被重新打包过的版本,Filename 可能只有文件名,那就要先遍历子目录拼出完整路径,否则 FileNotFoundError 会一直缠着你。

还有一个取舍要说明:GTSRB 的 CSV 里给了 ROI.x1/y1/x2/y2,按 ROI 裁掉背景理论上能让模型更聚焦标志本身。但我在实际项目里发现,保留一点周围环境反而让模型对遮挡和视角变化更鲁棒,因为真实路侧相机拍到的就是"标志加背景"。所以基线阶段先不裁 ROI,等准确率卡住了,再把 ROI 裁剪作为优化项加进去,对比效果。

2.3 预处理与数据增强:尺寸、颜色和旋转的取舍

GTSRB 图像尺寸不统一,训练前必须 Resize 到固定大小。我的基线用 64×64,而不是 32×32。原因很朴素:限速标志上的数字是强细节,32×32 会把"30"和"80"糊成一团,模型再强也分不出来;直接上 224×224 又会让三层卷积基线训练明显变慢,显存也紧张。64×64 是计算量和精度的平衡点,后面迁移学习阶段再换 224×224 不迟。

颜色通道上,基线直接用 RGB,不要转灰度,为什么后面避坑章会展开。归一化用 ImageNet 的 mean/std 就可以,因为 ToTensor 已经把 0-255 缩到 0-1,Normalize 只是做白化,对 GTSRB 这种自然拍摄图像有不错的通用性。增强策略我推荐轻量起步,不要第一版就上随机擦除、MixUp 这些重增强,否则训练曲线会很难看,新手容易误判成模型问题。

from torchvision import transforms train_transform = transforms.Compose([ transforms.Resize((64, 64)), transforms.RandomAffine(degrees=15, translate=(0.1, 0.1)), transforms.ColorJitter(brightness=0.2), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ]) test_transform = transforms.Compose([ transforms.Resize((64, 64)), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ])

RandomAffine 放在 Resize 之后,旋转和平移都不会改变输出尺寸;ColorJitter 只调亮度不动色相,是因为色相偏移会把红色禁令标志变成奇怪的粉紫色,破坏交通标志的语义。如果你有时间,可以自己在训练集上算一组 mean/std 替代 ImageNet 的默认值,通常能带来零点几个点的提升,但基线阶段没必要。先跑通再优化,这是做这个项目最省心的人工智能项目实践路径。

3. 从零搭建 CNN 分类模型:三层卷积基线与训练参数

3.1 网络结构:三层卷积 + BN + Dropout 的基线设计

不借助预训练模型,自己搭一个 CNN 卷积神经网络,是理解这个项目的关键一步。常见做法是三层卷积块,每块由"卷积 + BN + ReLU + MaxPool"组成,最后接全局平均池化和全连接层。为什么是三层而不是五层?对于 GTSRB 的 43 类分类,三层卷积已经能提取边缘、颜色、纹理和局部形状这些关键区分特征,网络再深就需要预训练权重和更强的数据增强来兜底,否则 4 万张图很容易欠拟合。

import torch import torch.nn as nn class TrafficSignCNN(nn.Module): def __init__(self, num_classes=43): super().__init__() self.features = nn.Sequential( nn.Conv2d(3, 32, 3, padding=1), nn.BatchNorm2d(32), nn.ReLU(inplace=True), nn.MaxPool2d(2), nn.Conv2d(32, 64, 3, padding=1), nn.BatchNorm2d(64), nn.ReLU(inplace=True), nn.MaxPool2d(2), nn.Conv2d(64, 128, 3, padding=1), nn.BatchNorm2d(128), nn.ReLU(inplace=True), nn.MaxPool2d(2), ) self.classifier = nn.Sequential( nn.AdaptiveAvgPool2d(1), nn.Flatten(), nn.Dropout(0.5), nn.Linear(128, 43), ) def forward(self, x): return self.classifier(self.features(x))

结构里有两个容易被忽略的点。第一,所有卷积核都用 3×3,padding=1,卷积不改变特征图尺寸,每次缩小分辨率全靠 MaxPool2d,特征图从 64×64 依次变成 32×32、16×16、8×8,最后一层只有 8×8,信息保留足够。第二,分类器用 AdaptiveAvgPool2d(1) 而不是直接 Flatten,这样就算以后把输入尺寸从 64×64 换成 128×128,全连接层的输入维度也不用改,省去重算 Flatten 长度的麻烦。Dropout 放在全连接前,系数 0.5 是经验值,如果训练集小或者过拟合明显,可以提到 0.6。

3.2 训练参数:优化器、学习率与类别不均衡处理

GTSRB 并不是每类样本均匀分布,最多的类别有 2250 张,最少的只有 210 张,差了十倍。直接在原始分布上训,模型会对样本多的类别过拟合,少样本类别的准确率会很低。处理办法有两个:一是给 DataLoader 加 WeightedRandomSampler 做重采样,让每个类别每个 epoch 被抽到的概率接近;二是给损失函数传 class_weight。我更推荐前者,因为它不改变损失函数的梯度尺度,训练曲线更稳,调参的时候少一个变量。

import numpy as np from torch.utils.data import WeightedRandomSampler labels = train_ds.df["ClassId"].values class_counts = np.bincount(labels, minlength=43) class_weights = 1.0 / class_counts.astype(np.float32) # 归一化到均值 1,避免某些类权重过大 class_weights = class_weights / class_weights.mean() sample_weights = class_weights[labels] sampler = WeightedRandomSampler( sample_weights, num_samples=len(sample_weights), replacement=True, )

注意 WeightedRandomSampler 的 replacement=True 表示有放回抽样,样本少的类别会在每个 epoch 里被重复抽到,这样能保证每个 epoch 都见过全部类别。使用 sampler 之后,DataLoader 的 shuffle 参数必须设为 False,因为打乱工作已经由 sampler 做了,两个同时开会引入无法预期的顺序偏差,训练曲线会变得很怪。优化器我用 AdamW,学习率 1e-3,weight_decay 1e-4,这个组合对学习率不像 SGD 那么敏感,适合当基线。训练轮数 30 轮,学习率调度可以直接用余弦退火,比手动在第 15 轮和第 25 轮乘 0.1 省事,效果也更平滑。

3.3 完整训练脚本:从 DataLoader 到验证集评估

把前面的数据集和网络拼到一起,一个最小可复现的训练脚本长这样。我按自己的工程习惯把它拆成训练函数和验证函数,每轮结束后打印验证准确率,方便看着曲线判断下一步怎么调。以下代码直接用在你的训练脚本里,只需要提前把 train_ds、test_ds 按第 2 章方式构造好。

import torch import torch.nn as nn from torch.utils.data import DataLoader device = torch.device("cuda" if torch.cuda.is_available() else "cpu") def train_one_epoch(model, loader, optimizer, criterion, device): model.train() total_loss, correct, total = 0.0, 0, 0 for images, labels in loader: images, labels = images.to(device), labels.to(device) optimizer.zero_grad() outputs = model(images) loss = criterion(outputs, labels) loss.backward() # 梯度裁剪能防止训练早期 lr 偏大导致的 loss 爆炸 nn.utils.clip_grad_norm_(model.parameters(), max_norm=5.0) optimizer.step() total_loss += loss.item() * images.size(0) correct += (outputs.argmax(1) == labels).sum().item() total += labels.size(0) return total_loss / total, correct / total def evaluate(model, loader, device): model.eval() correct, total = 0, 0 with torch.no_grad(): for images, labels in loader: images, labels = images.to(device), labels.to(device) outputs = model(images) correct += (outputs.argmax(1) == labels).sum().item() total += labels.size(0) return correct / total train_loader = DataLoader(train_ds, batch_size=64, sampler=sampler, num_workers=4) test_loader = DataLoader(test_ds, batch_size=128, shuffle=False, num_workers=4) criterion = nn.CrossEntropyLoss() model = TrafficSignCNN().to(device) optimizer = torch.optim.AdamW(model.parameters(), lr=1e-3, weight_decay=1e-4) for epoch in range(30): train_loss, train_acc = train_one_epoch(model, train_loader, optimizer, criterion, device) test_acc = evaluate(model, test_loader, device) print(f"epoch {epoch+1:02d} | loss {train_loss:.4f} | train_acc {train_acc:.4f} | test_acc {test_acc:.4f}")

这段脚本里有几个关键点值得解释。训练函数里 optimizer.zero_grad() 必须在 loss.backward() 之前,否则梯度会跨 batch 累加,loss 曲线看起来就像随机游走,你会在 TensorBoard 上困惑很久。梯度裁剪 max_norm=5.0 不是必须的,但加上之后能防止训练前期偶发的梯度过大造成 loss 跳到 NaN,后面避坑章会讲这是最常遇到的翻车现场。评估阶段必须包在 torch.no_grad() 里,并且调用 model.eval() 把 Dropout 和 BN 切到推理模式,漏掉这一步,验证准确率会忽高忽低,很多人在这里被坑过还以为是随机种子的问题。

4. 训练避坑:损失不降、过拟合、易混淆类别的排查

这一章写的是我在跑这个项目时踩过的高频坑。每条按现象、原因、解决三个角度拆,你可以对照自己的训练日志逐条排查。如果你的问题和这里的现象对不上,优先检查数据预处理链路,数据错了后面的模型再对也是白搭。

4.1 loss 震荡不降:先查学习率,再看数据预处理

现象:训练 loss 在前几个 epoch 正常下降,第 5 轮左右开始震荡,准确率停在 60% 上下不动。原因是学习率偏大,AdamW 对学习率不敏感不等于没有上限,1e-2 的初始学习率在后期会让权重更新步长跨过最优区域;另一种可能是数据没有做 Normalize,输入数值范围不一致导致梯度尺度不稳定。解决:把学习率降到 1e-3 或 3e-4,同时检查 transform 里是否漏了 ToTensor 之后的 Normalize。如果 loss 曲线是"下降—平台—下降"的阶梯状,说明学习率调度太激进,换成余弦退火就能缓解。我一般会同时记录每个 epoch 的 loss 和准确率,只看准确率容易把过拟合误判成学习率问题。

4.2 过拟合:训练集 99%,验证集卡在 80%

现象:训练准确率一路涨到 99%,验证准确率却始终在 80% 附近波动,两个曲线差距越拉越大。原因是网络容量对 4 万张训练图来说偏大,模型开始"背"训练样本而不是学泛化特征。GTSRB 里同一种标志在不同光照、不同角度下长得差别很大,模型在训练集上记住特定光线条件后,遇到验证集的新拍摄角度就露馅。解决顺序很重要:先把 Dropout 从 0.5 提到 0.6,把 weight_decay 从 1e-4 提到 5e-4,这两步是正则化,代价小;如果验证集还卡着,再考虑加随机遮挡或随机擦除。重增强会改变数据分布,训练曲线会变难看,新手容易误判成网络问题,所以放在最后。

4.3 限速标志互相混淆:数据增强与输入分辨率

现象:验证集里限速 80 和限速 100 这两类错误率特别高,混淆矩阵里对应位置的数字明显比周围大。原因是这两类标志都是白色圆形红圈、黑色数字,结构高度相似,唯一区别就是数字本身。输入分辨率 32×32 时,数字笔画在缩放后糊成一团,模型只能靠轮廓猜;旋转角度过大时,数字的倾斜让"80"和"100"更难分。解决:第一步把输入分辨率从 32×32 提到 64×64,数字笔画清晰度立刻改善;第二步把 RandomAffine 的 degrees 从 15 降到 8,减少数字形变;第三步如果还不够,按 2.2 节提到的 ROI 裁剪把标志区域裁出来再 Resize,去掉背景干扰。我的习惯是先看混淆矩阵里哪两类在打架,再针对性调,不盲目全局调参。

4.4 灰度图陷阱:为什么颜色通道不能丢

现象:看到网上有人用灰度图训练 LeNet 拿到 98% 准确率,于是把输入改成单通道复现,结果验证集准确率掉了 5 个百分点。原因是 LeNet 的灰度方案来源于 MNIST,而交通标志分类的核心区分特征就是颜色:红色表示禁令、蓝色表示指示、黄色表示警告。转成灰度后,红蓝黄三类的形状可能相近,模型失去最强的判别线索。解决:训练输入直接用 RGB 三通道,不要转灰度。如果你想压计算量,可以在 HSV 或 YUV 空间里保留色彩通道后再降,但基线阶段不要做这种优化。RGB 加 Normalize 得到的准确率上限比灰度图高不少,这是我在同一份数据集上做消融实验验证过的结论,省得你再踩一次。

4.5 loss 变 NaN:PPM 的归一化与数值稳定性

现象:训练到第 3 个 epoch 时 loss 突然变成 NaN,之后所有参数更新都无效,模型直接废掉,只能重新开始。原因是输入数值范围不对,PPM 图像读到的是 0-255 的整数,如果忘了经过 ToTensor 就进入网络,大数值经过几层卷积后激活值爆炸;另一种可能是学习率过大,权重更新到数值溢出。解决:检查 transform 链里是否包含 ToTensor,确认数据喂进网络前是 0-1 范围;同时在优化器更新前加梯度裁剪做兜底。还有一种隐蔽情况是 label 里有超出 0-42 的非法值,CrossEntropyLoss 在 CUDA 上会直接产出 NaN,建议在 Dataset 的getitem里加一行 assert 检查标签范围,避免在 4 万张图上白跑半天。

5. 从"能跑"到"好用":迁移学习与 ONNX 部署

5.1 用 ResNet18 做迁移学习:冻结 backbone 还是全量微调

自己搭的三层卷积基线在 GTSRB 上能跑到 95% 左右,但想冲 98% 以上,常见做法是换预训练模型。ResNet18 是性价比很高的选择,torchvision 里加载预训练权重只要一行,输出层换成 43 类就行。迁移学习对相似类别的改善尤其明显:预训练模型见过海量自然图像,对纹理和形状的表示比随机初始化的三层卷积强得多,限速 80 和 100 这类细粒度差异在高层特征里会被放大。

import torch.nn as nn import torchvision.models as models model = models.resnet18(weights=models.ResNet18_Weights.IMAGENET1K_V1) model.fc = nn.Linear(model.fc.in_features, 43)

这里有个版本细节:新版 torchvision 推荐用 weights= 传预训练权重,旧的 pretrained=True 已经标记为 deprecated,照老教程写会看到 FutureWarning,但结果一样。数据规模不大时,我一般先冻结 backbone,只训练最后的全连接层,等全连接层收敛后再解冻所有层做微调,学习率降到 1e-4。冻结期间要把 BN 层保持在 eval 模式,因为 GTSRB 的 batch size 不够大时,BN 的统计量会被小 batch 带偏,导致预训练特征崩塌。全量微调时再把 BN 切回 train 模式,同时把 batch size 提到 128 以上,这样 BN 统计量才能稳定下来。

还有一点要提醒:不是所有项目都适合迁移学习。如果你的任务类别和 ImageNet 差异很大,比如医学影像、SAR 图像,预训练特征反而可能成为负担。但交通标志是自然图像,和 ImageNet 分布接近,迁移学习基本只会涨点不会掉点。做毕设或者人工智能大作业时,把基线模型和迁移学习模型都跑一遍,对比准确率和混淆矩阵,本身就是很好的实验结果。

5.2 导出 ONNX:为路侧设备和车载推理做准备

训练好的 PyTorch 模型不能直接塞进路侧摄像头或者车载盒子,常见做法是导出成 ONNX,再用 TensorRT、OpenVINO 或 NCNN 转成目标平台的推理格式。导出这一步有几个参数必须设置正确,否则部署时会踩坑。导出前记得 model.eval(),否则 ONNX 里的 BatchNorm 和 Dropout 还是训练行为,推理时每个 batch 结果都不一样。

model.eval() dummy = torch.randn(1, 3, 64, 64) torch.onnx.export( model, dummy, "traffic_sign.onnx", input_names=["input"], output_names=["output"], dynamic_axes={"input": {0: "batch"}, "output": {0: "batch"}}, opset_version=17, )

dynamic_axes 把 batch 维度设为动态,这样部署时既能单张推理,也能拼 batch 提高吞吐。opset_version=17 对应较新的 ONNX 算子集,如果你部署用的推理引擎版本比较老,可能需要降到 13 或 14,这个要跟你目标平台的文档对齐,不是越新越好。如果导出时报算子不支持,那就是模型里用了太新的 API,换成基础卷积和池化就能解决。导出后我习惯用 onnxruntime 先验证一遍数值一致性:

import onnxruntime as ort import torch sess = ort.InferenceSession("traffic_sign.onnx") dummy = torch.randn(1, 3, 64, 64) ort_out = sess.run(None, {"input": dummy.numpy()})[0] torch_out = model(dummy).detach().cpu().numpy() print("max diff:", abs(ort_out - torch_out).max())

如果 max diff 在 1e-5 量级,说明导出没问题。如果差得大,先检查是不是忘了 eval 模式,再检查输入尺寸是否和训练时一致。这个验证脚本我每次导出都会跑一遍,省掉很多部署现场的排查时间。智慧交通场景里,模型通常跑在嵌入式设备上,输入分辨率、推理延迟、功耗都要重新评估,ONNX 只是中间格式,要不要继续做 INT8 量化、剪枝,得看实际设备的算力和内存,不是精度越高越好。

6. 验证模型的真实水平:混淆矩阵与难例测试

6.1 混淆矩阵与 Top-5 诊断:判断模型能不能上车

训练准确率和验证准确率只是两个数字,部署前还要回答一个问题:模型到底错在哪。我每次训练完都会先画混淆矩阵,按类别看哪些错误是系统性的,而不是随机分布。

from sklearn.metrics import confusion_matrix import matplotlib.pyplot as plt cm = confusion_matrix(all_labels, all_preds, labels=range(43)) plt.figure(figsize=(14, 12)) plt.imshow(cm, cmap="Blues") plt.colorbar() plt.xlabel("Predicted Class") plt.ylabel("True Class") plt.tight_layout() plt.savefig("confusion_matrix.png", dpi=150)

画完混淆矩阵,重点看两个地方。第一,对角线上哪几类数值偏低,比如限速 80 和限速 100 这两类互相错,就去调数据增强和分辨率,这个前面避坑章已经讲过。第二,有没有"单向错"的类别,比如某个类别被大量预测成另一类,这通常是训练样本量差距过大导致的,把 WeightedRandomSampler 的权重调得更激进一点就能缓解。做完这步,我还会从测试集里挑出模型置信度最低的 20 张图,打印真值和 Top-5 预测,肉眼看到底是图像本身模糊、遮挡,还是模型真正误判。前者说明模型其实是对的,是数据质量的问题;后者才需要针对性补增强。

说来惭愧,有一回我把 RandomAffine 的旋转角度从 15 度调到 30 度想提升泛化能力,结果限速 20 被模型识别成限速 80,盯着屏幕看了半天才反应过来:圆形标志里的"20"旋转 30 度后,笔画形态和"80"高度重合了。从那以后我给自己定了个习惯,数据增强参数的每次改动,都要先用一组固定难例图做回归测试,而不是只看验证集准确率这一条曲线。希望这个习惯对你有用,也希望这份笔记能帮你把交通标志分类这个人工智能项目实践从"能跑"推到"能解释、能部署、能写进简历"的程度。

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

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

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

立即咨询