ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

交通标志分类实战:从CNN模型搭建到部署的完整指南

交通标志分类实战:从CNN模型搭建到部署的完整指南 简介这是一套面向智慧交通场景的计算机视觉项目实践资源核心任务基于卷积神经网络完成交通标志图像分类适合初学深度学习的开发者也可作为高校课程设计或毕业设计的实战参考。压缩包共2000个文件以1994张PNG格式的交通标志图片为主并有3个XML标注文件用于辅助整理图像类别信息另有3个Python脚本实现模型训练与单张图片预测整体大小约201.95MB结构上能按训练、测试、预测等功能快速定位内容。资源目前已有78人学习下载。借助提供的训练脚本可通过命令行指定训练集与测试集路径并输出模型文件预测脚本支持对指定图片进行识别配合包内近两千张图像样本能够快速复现从数据准备到模型推理的完整流程帮助理解CNN分类项目的代码组织方式、参数传递与目录划分思路是一份实用且可直接上手操作的参考资料。1. 交通标志分类为什么说这是入门 CNN 最值得复现的智慧交通项目交通标志分类是 CNN 卷积神经网络项目实践里最经典的一道题数据集公开、类别定义清晰、训练完的效果肉眼可见特别适合当人工智能大作业或入门深度学习的第一个完整项目也常被写进各种人工智能学习路径的推荐清单。它对应的真实场景是智慧交通里的路侧感知与辅助驾驶——摄像头拍到一块标志牌系统要在几十毫秒内告诉驾驶员这是限速 60 还是解除限速。看起来只是图像分类但真正做进去会发现光照、遮挡、相似类别都在给模型挖坑。这篇笔记按我自己的实践路径从数据准备、模型搭建、训练排错到部署验证讲一遍新手能照着复现跑过类似项目的人也能对一对排查思路。2. 数据准备GTSRB 数据集与自定义 Dataset2.1 数据集选型GTSRB 还是 TT100K做交通标志分类业界最常用的公开数据集是德国交通标志数据集 GTSRBGerman 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, transformNone): # 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-1Normalize 只是做白化对 GTSRB 这种自然拍摄图像有不错的通用性。增强策略我推荐轻量起步不要第一版就上随机擦除、MixUp 这些重增强否则训练曲线会很难看新手容易误判成模型问题。from torchvision import transforms train_transform transforms.Compose([ transforms.Resize((64, 64)), transforms.RandomAffine(degrees15, translate(0.1, 0.1)), transforms.ColorJitter(brightness0.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_classes43): super().__init__() self.features nn.Sequential( nn.Conv2d(3, 32, 3, padding1), nn.BatchNorm2d(32), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), nn.Conv2d(32, 64, 3, padding1), nn.BatchNorm2d(64), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), nn.Conv2d(64, 128, 3, padding1), nn.BatchNorm2d(128), nn.ReLU(inplaceTrue), 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×3padding1卷积不改变特征图尺寸每次缩小分辨率全靠 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, minlength43) 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_sampleslen(sample_weights), replacementTrue, )注意 WeightedRandomSampler 的 replacementTrue 表示有放回抽样样本少的类别会在每个 epoch 里被重复抽到这样能保证每个 epoch 都见过全部类别。使用 sampler 之后DataLoader 的 shuffle 参数必须设为 False因为打乱工作已经由 sampler 做了两个同时开会引入无法预期的顺序偏差训练曲线会变得很怪。优化器我用 AdamW学习率 1e-3weight_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_norm5.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_size64, samplersampler, num_workers4) test_loader DataLoader(test_ds, batch_size128, shuffleFalse, num_workers4) criterion nn.CrossEntropyLoss() model TrafficSignCNN().to(device) optimizer torch.optim.AdamW(model.parameters(), lr1e-3, weight_decay1e-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(fepoch {epoch1:02d} | loss {train_loss:.4f} | train_acc {train_acc:.4f} | test_acc {test_acc:.4f})这段脚本里有几个关键点值得解释。训练函数里 optimizer.zero_grad() 必须在 loss.backward() 之前否则梯度会跨 batch 累加loss 曲线看起来就像随机游走你会在 TensorBoard 上困惑很久。梯度裁剪 max_norm5.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 变 NaNPPM 的归一化与数值稳定性现象训练到第 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(weightsmodels.ResNet18_Weights.IMAGENET1K_V1) model.fc nn.Linear(model.fc.in_features, 43)这里有个版本细节新版 torchvision 推荐用 weights 传预训练权重旧的 pretrainedTrue 已经标记为 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_version17, )dynamic_axes 把 batch 维度设为动态这样部署时既能单张推理也能拼 batch 提高吞吐。opset_version17 对应较新的 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, labelsrange(43)) plt.figure(figsize(14, 12)) plt.imshow(cm, cmapBlues) plt.colorbar() plt.xlabel(Predicted Class) plt.ylabel(True Class) plt.tight_layout() plt.savefig(confusion_matrix.png, dpi150)画完混淆矩阵重点看两个地方。第一对角线上哪几类数值偏低比如限速 80 和限速 100 这两类互相错就去调数据增强和分辨率这个前面避坑章已经讲过。第二有没有单向错的类别比如某个类别被大量预测成另一类这通常是训练样本量差距过大导致的把 WeightedRandomSampler 的权重调得更激进一点就能缓解。做完这步我还会从测试集里挑出模型置信度最低的 20 张图打印真值和 Top-5 预测肉眼看到底是图像本身模糊、遮挡还是模型真正误判。前者说明模型其实是对的是数据质量的问题后者才需要针对性补增强。说来惭愧有一回我把 RandomAffine 的旋转角度从 15 度调到 30 度想提升泛化能力结果限速 20 被模型识别成限速 80盯着屏幕看了半天才反应过来圆形标志里的20旋转 30 度后笔画形态和80高度重合了。从那以后我给自己定了个习惯数据增强参数的每次改动都要先用一组固定难例图做回归测试而不是只看验证集准确率这一条曲线。希望这个习惯对你有用也希望这份笔记能帮你把交通标志分类这个人工智能项目实践从能跑推到能解释、能部署、能写进简历的程度。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进