
简介这份资源面向人工智能与智慧交通方向的初学者及项目实践者聚焦使用卷积神经网络完成交通标志分类任务适合作为课程设计、竞赛入门或自学练手的完整代码与数据素材。压缩包共2000个文件以1994张png交通标志图像为主体另含3个xml标注文件与3个py脚本整体约201.95MB图像按类别目录组织便于直接用于训练与验证。训练环节可通过train.py指定训练集、测试集与模型保存路径单张图片推理则由predict.py加载模型并输出预测结果脚本与数据配套齐全省去自行采集与整理数据的时间。目前已有78人学习下载。读者可据此掌握数据读取、模型搭建、训练调参与单图预测的完整流程并在此基础上替换网络结构或扩充类别快速搭建属于自己的交通标志识别方案。1. 从一张模糊的路牌说起CNN 交通标志分类到底在做什么城市道路上的限速牌、禁止掉头、注意行人这些标志在车载摄像头里往往只有几十个像素光照一变、角度一偏传统模板匹配就开始翻车。智慧交通里最基础也最容易被低估的一环就是把这张小图准确归到 43 个类别中的某一个——德国交通标志数据集 GTSRB 就是干这个的。用 CNN 做交通标志分类本质是让卷积核自动学出「红圈白底」「蓝底白箭头」这类局部纹理再逐层组合成可判别的特征最后接一个 softmax 输出类别概率。它适合谁做人工智能大作业的学生、想入门深度学习 CNN 的工程师、以及需要给智慧交通项目搭一个可复现 baseline 的人。这篇笔记不讲空泛概念只讲怎么把数据读进来、网络怎么搭、参数怎么调、哪里最容易踩坑。2. 数据先行GTSRB 的读取、划分与增强策略2.1 为什么交通标志分类必须先把数据管道做对很多人拿到压缩包第一件事是解压看图片然后直接ImageFolder一把梭结果训练准确率卡在 60% 上不去还以为是网络不够深。GTSRB 的原始结构不是按类别分文件夹的它用Train.csv和Test.csv记录每张图的路径和类别 ID图片散落在Train/0/到Train/42/这样的子目录里。如果你不按 CSV 读而是靠目录名猜类别很容易把类别 0 和类别 42 的索引对错导致模型学出来的东西和标签完全错位。常见做法是写一个自定义Dataset从 CSV 里读Width、Height、Roi.X1这些字段把图片裁剪到感兴趣区域再送进网络。这一步不做后面调参全是玄学。2.2 用 pandas 读 CSV 并构建 Dataset 的最小代码import os import pandas as pd from PIL import Image from torch.utils.data import Dataset, DataLoader from torchvision import transforms class GTSRBDataset(Dataset): def __init__(self, csv_path, img_root, transformNone): self.df pd.read_csv(csv_path) self.img_root img_root self.transform transform def __len__(self): return len(self.df) def __getitem__(self, idx): row self.df.iloc[idx] img_path os.path.join(self.img_root, row[Path]) img Image.open(img_path).convert(RGB) # 按 ROI 裁剪去掉黑边和无关背景 x1, y1, x2, y2 row[Roi.X1], row[Roi.Y1], row[Roi.X2], row[Roi.Y2] img img.crop((x1, y1, x2, y2)) label int(row[ClassId]) if self.transform: img self.transform(img) return img, label train_transform transforms.Compose([ transforms.Resize((32, 32)), transforms.RandomRotation(10), transforms.ColorJitter(brightness0.3, contrast0.3), transforms.ToTensor(), transforms.Normalize(mean[0.34, 0.31, 0.32], std[0.27, 0.26, 0.28]) ]) train_set GTSRBDataset(Train.csv, ./Train, transformtrain_transform) train_loader DataLoader(train_set, batch_size64, shuffleTrue, num_workers4)这段代码里Roi.X1到Roi.Y2是 GTSRB 官方标注的感兴趣区域裁剪后能去掉大量无效背景。Resize((32, 32))是常见起点但注意 GTSRB 原图尺寸从 15×15 到 250×250 不等直接缩放到 32×32 会让小图更糊大图丢细节。我一般会先统计一下尺寸分布如果小图占比高就把输入改成 48×48 或 64×64。Normalize的均值和方差不是随便写的是 GTSRB 训练集统计出来的经验值用 ImageNet 的均值方差也能跑但收敛会慢一点。RandomRotation(10)和ColorJitter是交通标志分类里性价比最高的增强因为真实场景里摄像头角度和光照变化就是主要干扰源。2.3 训练集、验证集怎么切以及类别不平衡怎么处理GTSRB 训练集有 39209 张图43 个类别但每类数量差异很大——限速 30 有 2000 多张而某些稀有标志只有 200 张左右。如果你直接按 8:2 随机切验证集里稀有类别可能只有几十张评估结果波动极大。我一般用StratifiedShuffleSplit按类别分层抽样保证每个类别在验证集里的比例和训练集一致。另外类别不平衡会让模型偏向多数类解决办法有两个一是用WeightedRandomSampler给少数类更高采样权重二是在损失函数里给CrossEntropyLoss传weight参数。实测下来采样器加数据增强的组合比单纯调 loss weight 更稳因为增强本身就能扩充少数类的样本多样性。from sklearn.model_selection import StratifiedShuffleSplit from torch.utils.data import WeightedRandomSampler import numpy as np labels train_set.df[ClassId].values sss StratifiedShuffleSplit(n_splits1, test_size0.2, random_state42) train_idx, val_idx next(sss.split(np.zeros(len(labels)), labels)) # 计算每个类别的采样权重 class_counts np.bincount(labels) class_weights 1.0 / class_counts sample_weights class_weights[labels[train_idx]] sampler WeightedRandomSampler(sample_weights, len(sample_weights), replacementTrue)这里class_weights取倒数是最简单的平衡策略也可以取1 / sqrt(count)让权重差距不那么极端。WeightedRandomSampler的replacementTrue表示有放回采样少数类会被反复抽到一个 epoch 里实际看到的样本分布就均匀了。注意验证集不要用采样器保持原始分布才能真实反映模型在真实场景下的表现。3. 网络结构选型从 LeNet 到轻量 ResNet 的取舍3.1 交通标志分类为什么不需要动辄 ResNet-50GTSRB 的输入分辨率通常只有 32×32 或 48×48图像内容高度结构化——红圈、蓝底、白箭头这些特征的判别不需要上百层卷积。我见过不少人直接上 ResNet-50结果训练慢、显存吃紧准确率还未必比一个精心调过的 6 层 CNN 高。原因很简单深层网络的优势在于提取高层语义但交通标志的语义层级很浅颜色和形状就是决定性特征。常见做法是搭一个 3 个卷积块加 2 个全连接层的网络每个卷积块用Conv2d BatchNorm ReLU MaxPool的组合通道数从 32 起步逐块翻倍到 128。这个规模在 32×32 输入下参数量大约 50 万训练一个 epoch 在单卡上只要十几秒调参迭代非常快。3.2 一个可复现的 6 层 CNN 定义与参数说明import torch.nn as nn class TrafficSignCNN(nn.Module): def __init__(self, num_classes43): super().__init__() self.features nn.Sequential( # 块1: 32x32 - 16x16 nn.Conv2d(3, 32, kernel_size3, padding1), nn.BatchNorm2d(32), nn.ReLU(inplaceTrue), nn.Conv2d(32, 32, kernel_size3, padding1), nn.BatchNorm2d(32), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), # 块2: 16x16 - 8x8 nn.Conv2d(32, 64, kernel_size3, padding1), nn.BatchNorm2d(64), nn.ReLU(inplaceTrue), nn.Conv2d(64, 64, kernel_size3, padding1), nn.BatchNorm2d(64), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), # 块3: 8x8 - 4x4 nn.Conv2d(64, 128, kernel_size3, padding1), nn.BatchNorm2d(128), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), ) self.classifier nn.Sequential( nn.Dropout(0.5), nn.Linear(128 * 4 * 4, 256), nn.ReLU(inplaceTrue), nn.Dropout(0.3), nn.Linear(256, num_classes) ) def forward(self, x): x self.features(x) x x.view(x.size(0), -1) return self.classifier(x)这个结构里每个卷积块都是「两层卷积 一次池化」比常见的「一层卷积 池化」多了一层非线性变换在交通标志这种细粒度分类上能多榨出 2 到 3 个点的准确率。BatchNorm2d放在卷积之后、ReLU 之前是标准做法能加速收敛并允许更大的学习率。Dropout(0.5)放在全连接层之前是防止过拟合的关键因为 GTSRB 训练集只有不到 4 万张图不加 dropout 的话训练准确率能到 99% 但验证集卡在 95% 上不去。padding1配合kernel_size3保持特征图尺寸不变只在池化时降采样这样每个卷积块的空间变化很清晰调试时不容易搞错维度。3.3 训练循环里学习率、优化器和早停怎么设优化器我一般用Adam起步学习率设1e-3配合ReduceLROnPlateau在验证损失不降时把学习率砍半。Adam的好处是不用太纠结初始学习率但缺点是后期收敛不如SGD momentum精细。如果你追求最后那一两个点的准确率可以在Adam跑 20 个 epoch 后切到SGD(lr1e-4, momentum0.9)再 fine-tune 10 个 epoch。早停的 patience 设 7 到 10 比较合适GTSRB 上模型通常在 25 到 35 个 epoch 之间达到最佳验证准确率。注意保存模型时不要只看最后一个 epoch要保存验证集准确率最高的那个 checkpoint否则很容易拿到一个过拟合的版本。import torch from torch.optim import Adam from torch.optim.lr_scheduler import ReduceLROnPlateau device torch.device(cuda if torch.cuda.is_available() else cpu) model TrafficSignCNN(num_classes43).to(device) criterion nn.CrossEntropyLoss() optimizer Adam(model.parameters(), lr1e-3, weight_decay1e-4) scheduler ReduceLROnPlateau(optimizer, modemin, factor0.5, patience3) best_acc 0.0 for epoch in range(40): model.train() for imgs, labels in train_loader: imgs, labels imgs.to(device), labels.to(device) optimizer.zero_grad() outputs model(imgs) loss criterion(outputs, labels) loss.backward() optimizer.step() # 验证阶段省略核心是记录 val_loss 和 val_acc scheduler.step(val_loss) if val_acc best_acc: best_acc val_acc torch.save(model.state_dict(), best_model.pth)weight_decay1e-4是 L2 正则和 dropout 一起用能进一步压过拟合。ReduceLROnPlateau的patience3表示验证损失连续 3 个 epoch 不降就砍学习率这个值设太小会导致学习率过早衰减设太大又浪费 epoch。我一般会先跑一次不砍学习率的 baseline看看验证损失在第几个 epoch 开始 plateau再决定 patience。4. 避坑与排查交通标志分类里最容易翻车的 5 个地方4.1 准确率虚高但实际预测全错——标签映射对不上现象训练时 loss 正常下降验证准确率能到 95%但拿单张图预测时输出的类别和肉眼判断完全不符。原因GTSRB 的ClassId从 0 到 42但Train.csv里的ClassId和文件夹编号不一定一一对应如果你用ImageFolder按文件夹名排序类别索引会和 CSV 里的ClassId错位。解决始终以 CSV 里的ClassId为准在 Dataset 里显式返回这个字段不要依赖文件夹名。预测时把输出的索引直接映射回ClassId中间不要做任何排序或重编号。4.2 验证集准确率波动超过 5 个点——数据划分没分层现象每次重新切分训练集和验证集验证准确率在 90% 到 96% 之间跳。原因随机切分导致稀有类别在验证集里只有几张图模型对这几张图的预测结果直接决定整体准确率。解决用StratifiedShuffleSplit按类别分层抽样保证每个类别在验证集里的数量不少于 30 张。如果某个类别总数就不够 150 张考虑用交叉验证代替单次划分。4.3 训练损失降到 0.01 但验证损失开始上升——过拟合的典型信号现象训练准确率 99.8%验证准确率停在 94% 不再涨验证损失从第 15 个 epoch 开始持续上升。原因模型参数量相对训练数据过大或者数据增强太弱。解决先加Dropout和weight_decay如果还不够就加数据增强的强度——把RandomRotation从 10 度提到 15 度加RandomAffine做平移和缩放再加RandomErasing随机遮挡一小块区域。实测RandomErasing对交通标志分类特别有效因为真实场景里标志被部分遮挡是常态。4.4 GPU 利用率只有 30%——数据加载成了瓶颈现象nvidia-smi显示 GPU 利用率在 20% 到 40% 之间波动训练一个 epoch 要几分钟。原因num_workers设得太小或者__getitem__里做了太重的预处理。解决把num_workers设成 CPU 核心数的一半到全部pin_memoryTrue加速 CPU 到 GPU 的传输。如果图片解码慢考虑预先把所有图缩放到统一尺寸存成 numpy 数组或 LMDB训练时直接读内存。注意num_workers不是越大越好超过 CPU 核心数反而会因为进程切换开销变慢。4.5 测试集提交格式不对——路径和列名踩坑现象本地验证准确率 96%但提交到评测系统后报格式错误或得分极低。原因GTSRB 的Test.csv里Path字段的路径格式和Train.csv可能不同有的版本带./前缀有的不带。另外提交文件的列名必须是ClassId不能写成label或class。解决读Test.csv后先打印前几行确认路径格式用os.path.join拼接时注意不要重复斜杠。提交前用pandas读回来检查列名和行数确保和样例提交文件一致。5. 把准确率从 96% 推到 99%三个我反复验证过的技巧第一个技巧是测试时增强TTA。训练完之后不要直接拿原图预测而是对每张测试图做 5 次变换——原图、水平翻转、轻微旋转正负 5 度、轻微缩放 0.95 和 1.05——把 5 次 softmax 输出平均后取 argmax。这个操作不需要重新训练推理时间乘以 5但在 GTSRB 上能稳定涨 1 到 1.5 个点。注意水平翻转对交通标志要谨慎因为左转和右转标志翻转后语义就反了我一般只对非方向性标志做翻转增强或者干脆不做翻转只做旋转和缩放。第二个技巧是余弦退火学习率配合 warmup。前 3 个 epoch 把学习率从1e-5线性升到1e-3然后按余弦曲线降到1e-6。这个策略比ReduceLROnPlateau更平滑模型在后期能跳出局部最优。代码上就是在每个 epoch 开始时手动设置optimizer.param_groups[0][lr]用math.cos算当前值。实测在 40 个 epoch 的预算下余弦退火比阶梯式衰减的最终验证准确率高 0.8 个点左右。第三个技巧是模型集成。单独训练 3 个结构相同但随机种子不同的模型预测时把三个模型的 softmax 输出平均。这个操作能把准确率再推 0.5 到 1 个点代价是训练时间和推理时间都乘以 3。如果不想训三个模型也可以在同一模型的不同 checkpoint 上做快照集成——保存验证准确率最高的 3 个 epoch 的权重预测时平均。我一般会在项目时间充裕时做快照集成时间紧就只做 TTA。import math def adjust_lr(optimizer, epoch, warmup_epochs3, total_epochs40, base_lr1e-3): if epoch warmup_epochs: lr base_lr * (epoch 1) / warmup_epochs else: progress (epoch - warmup_epochs) / (total_epochs - warmup_epochs) lr 1e-6 0.5 * (base_lr - 1e-6) * (1 math.cos(math.pi * progress)) for param_group in optimizer.param_groups: param_group[lr] lr return lr这个adjust_lr函数在每个 epoch 训练开始前调用warmup 阶段线性升到base_lr之后余弦降到1e-6。注意base_lr不要设太大1e-3对 Adam 是安全上限再大容易在 warmup 结束后 loss 爆炸。如果发现 loss 在 warmup 结束时突然飙升把base_lr降到5e-4再试。最后说一个我自己的习惯每次跑完实验不管结果好坏都把配置文件、随机种子、验证集准确率和混淆矩阵存到一个带时间戳的文件夹里。交通标志分类的调参空间不大但不同随机种子之间的波动能有 1 个点不记录的话很容易把随机波动当成调参效果。这个习惯帮我省了很多后悔药也希望帮到你。本文还有配套的精品资源点击获取