基于CNN的GTSRB交通标志识别:从数据预处理到模型训练全解析
2026/9/24 19:48:22 网站建设 项目流程

简介:这是一份面向智慧交通场景的交通标志识别项目资源,基于卷积神经网络(CNN)对GTSRB德国交通标志数据集进行分类,适合深度学习初学者或计算机视觉方向学习者动手实践。压缩包共8个文件,以Python脚本为主,辅以CSV数据文件和XML配置;Python脚本覆盖数据预处理、CNN网络搭建、模型训练与评估等关键环节,CSV文件提供标注好的训练/测试数据,整体大小仅310KB,轻量但流程完整。已有623人学习。通过该项目可系统掌握图像分类任务从数据加载、模型构建到训练调参的完整链路,并学会在真实基准数据集上优化准确率,为后续智慧交通项目落地打下基础。GTSRB数据集覆盖43类交通标志,约5万张图像,能有效检验模型的泛化能力;代码结构简洁,适合作为课程设计或毕业设计的参考基线。

1. 交通标志识别为什么拿CNN做:这份GTSRB工程能直接给你什么

把交通标志识别跑通,是智慧交通方向里最典型的深度学习落地练习。原因很简单:数据公开、任务封闭、效果立即可见。这个项目正是围绕CNN(卷积神经网络)和GTSRB数据集搭起来的一套完整Python工程,目录里TSR-master下同时放了数据预处理、数据加载、模型定义、训练和评估五个脚本,你要做的不是从零写算法,而是把这条链路完整跑一遍,并理解每个脚本在干什么。GTSRB是德国交通标志识别基准数据集,包含43类、约5万张真实道路场景图片,里面光照不均、遮挡、模糊、类别样本不均衡全都有,跟你在网上随手下的玩具数据集完全是两个量级。这份资源特别适合人工智能课程需要交大作业、或者毕业设计想快速出一个视觉项目的学生,也适合想从CNN理论跳到实际工程的从业者。下文我按照项目里脚本的实际顺序,把每个环节的参数、逻辑和踩过的坑拆开讲。

2. 数据准备与预处理:GTSRB目录、CSV格式和Preprocessing.py的四个关键操作

2.1 先认清GTSRB的目录结构和CSV字段

打开data3(项目里存放数据的根目录),你会看到内部按 Train 和 Test 组织。Train 下面有43个子文件夹,文件夹名从0到42,每个编号对应一类交通标志,比如0是限速20,1是限速30,直到42类结束。Test 目录则是所有测试图片混在一起,没有按子文件夹分类。这也是GTSRB最早的坑:测试集图片全靠CSV文件里的标注来区分类别,如果你直接把Test图片塞进模型训练,那就等于提前偷看了答案。

项目里的 train_data.csv 和 test_data.csv 就是帮你干这个事的。我建议你先用文本编辑器打开看一眼,不要直接上代码。里面每行是一条样本,核心字段就三个:

字段示例说明
FilenameTrain/1/00005_00000_00025.png相对于data3根目录的图片路径
ClassId1类别编号,0到42之间的整数
SignNameSpeed limit (30km/h)类别文字描述,方便人看

这个CSV相当于项目的"后悔药"。原始GTSRB自带CSV字段很乱,包含ROI坐标、是否遮挡、是否模糊等一堆附加列,实际训练根本用不上。项目里已经抽成了最直白的三列,你只需要关心Filename和ClassId。我自己处理这类数据时的习惯是,拿到CSV先打印行列数、再随机抽5行看看路径是否真实存在,因为路径错了后面所有代码都会白跑。

2.2 预处理管线:灰度、直方图均衡、统一尺寸、归一化

GTSRB的图片不是整齐划一的,原始分辨率从15x15到250x250都有,而且很多是在行车记录仪视角下拍的,逆光、暗光很常见。如果直接拿原始图丢给CNN,模型一半的容量都要浪费在适应光照变化上。Preprocessing.py 就是解决这个问题的,我把核心逻辑拆出来:

import cv2 import numpy as np IMG_SIZE = 32 # 统一缩放到 32x32 def load_and_preprocess(row, data_root="data3"): # row 是 CSV 里的一行,data_root 是数据根目录 path = f"{data_root}/{row['Filename'].replace(chr(92), '/')}" img = cv2.imread(path) # 转灰度:交通标志的颜色信息受光照影响太大,先降维 img = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # 直方图均衡化:把对比度拉开,逆光图也能看清边缘 img = cv2.equalizeHist(img) # 统一尺寸,缩小用 INTER_AREA,避免出现摩尔纹 img = cv2.resize(img, (IMG_SIZE, IMG_SIZE), interpolation=cv2.INTER_AREA) # 归一化到 [0,1],让梯度更新更平稳 img = img.astype(np.float32) / 255.0 # 增加 channel 维度,变为 (1, H, W),对应 PyTorch 的 NCHW 输入 return img.reshape(1, IMG_SIZE, IMG_SIZE)

这段代码里有三个容易被忽略的地方。第一,路径分隔符用row['Filename'].replace(chr(92), '/')处理,是因为原始CSV在Windows下生成,路径是反斜杠,Linux下直接读会报文件不存在,这个细节我后面还会专门讲。第二,转灰度不是拍脑袋的决定,GTSRB里很多标志的内外圈颜色在弱光下会糊成一片,但形状和图案仍然可辨,灰度图能把模型注意力逼到结构特征上。第三,INTER_AREA是缩小图片时的正确选择,如果用默认的线性插值,小目标上的文字和数字会被插值糊掉。

2.3 数据加载器:TSRInput.py 如何把CSV和图片配对

预处理只是单张图片的函数,真正喂给模型的是TSRInput.py里的数据加载器。这个脚本做的事情说白了就是三件:读CSV、调预处理、按批次打包。常见做法是用PyTorch的DatasetDataLoader封装:

import torch from torch.utils.data import Dataset, DataLoader class TSRDataset(Dataset): def __init__(self, csv_path, data_root="data3", labels=True): self.df = pd.read_csv(csv_path) self.data_root = data_root self.labels = labels def __len__(self): return len(self.df) def __getitem__(self, idx): row = self.df.iloc[idx] img = load_and_preprocess(row, self.data_root) if self.labels: label = int(row["ClassId"]) return torch.tensor(img), torch.tensor(label) return torch.tensor(img) # 使用时这样创建迭代器 train_loader = DataLoader(TSRDataset("train_data.csv"), batch_size=32, shuffle=True)

这里batch_size是训练时的关键参数。GTSRB训练集约39000张,batch设为32时一个epoch大概1220步,跑起来不会等太久;如果你显存够大,调到64会更稳。shuffle=True只对训练集打开,测试集和验证集必须保持顺序,否则评估结果会乱。另外注意,__getitem__里每次重新读图再预处理,速度偏慢,但胜在省内存,GTSRB全量图如果一次性读入内存会吃掉好几个GB,学生机不一定扛得住。

3. CNN模型设计:TSRCnn.py的网络结构、参数量与输入输出形状

3.1 为什么全连接网络在这里必翻车

把一张32x32的灰度图拉直,是1024个像素值。如果直接用全连接层,第一层到第二层的权重矩阵就是1024x1024,约100万个参数,这还只是第一层。到了原图分辨率250x250,单层参数量直接爆炸。更本质的问题是,全连接层没有"局部性"——它把每个像素孤立对待,而交通标志的识别依赖的是边缘、角点、圆环组合这些局部结构。CNN卷积层通过滑动窗口提取局部特征,参数在不同位置共享,一个3x3卷积核只有9个权重,却能在整张图上滑动。这也是"cnn卷积神经网络"能成为图像分类默认选择的核心原因。

3.2 TSRCnn.py里的网络结构解析

项目里的TSRCnn.py定义的就是一个经典的小型CNN,结构是"卷积块+池化+卷积块+池化+全连接"。我见过不少项目直接堆VGG16,但对GTSRB这种43类、单通道、32x32输入的任务来说,深度网络是杀鸡用牛刀,训练慢且容易过拟合。这份工程里的结构反而更合理:

import torch.nn as nn class TSRNet(nn.Module): def __init__(self, num_classes=43): super().__init__() self.features = nn.Sequential( # 第一卷积块:1通道 -> 32通道 nn.Conv2d(1, 32, kernel_size=3, padding=1), nn.ReLU(), nn.Conv2d(32, 32, kernel_size=3, padding=1), nn.ReLU(), nn.MaxPool2d(2), # 32x32 -> 16x16 # 第二卷积块:32通道 -> 64通道 nn.Conv2d(64, 64, kernel_size=3, padding=1) if False else nn.Conv2d(32, 64, kernel_size=3, padding=1), nn.ReLU(), nn.Conv2d(64, 64, kernel_size=3, padding=1), nn.ReLU(), nn.MaxPool2d(2), # 16x16 -> 8x8 ) self.classifier = nn.Sequential( nn.Flatten(), nn.Linear(64 * 8 * 8, 256), nn.ReLU(), nn.Dropout(0.5), nn.Linear(256, num_classes), ) def forward(self, x): return self.classifier(self.features(x))

其中第二卷积块第一层我修正为nn.Conv2d(32, 64, ...),保证通道数衔接正确,原始工程里如果有笔误,训练时也会报尺寸不匹配错误。整个网络用参数表看更清楚:

层级输出形状参数量作用
Conv2d(1, 32, 3)32x32x32320提取低级边缘特征
Conv2d(32, 32, 3)32x32x329248叠加感受野,提取局部纹理
MaxPool2d(2)16x16x320下采样,扩大感受野
Conv2d(32, 64, 3)16x16x6418496提取形状组合特征
Conv2d(64, 64, 3)16x16x6436928加深抽象层次
MaxPool2d(2)8x8x640下采样
Linear(4096, 256)2561048832全连接分类
Linear(256, 43)4311051输出43类得分

总参数量约112万,绝大多数集中在第一个全连接层。这也是为什么不能在中间层把特征图做得太大,否则全连接层参数量会失控。Dropout放在最后一个全连接前,概率0.5,这是防止过拟合的关键。

3.3 输出层与损失函数的配套关系

TSRCnn.py最后输出的是43个实数,每个数字代表模型认为该图属于某个类别的"得分"。这里有一个新手常踩的坑:不要在模型最后一层手动加Softmax。PyTorch的nn.CrossEntropyLoss内部已经包含了Softmax运算,你只要把原始得分(logits)直接传给损失函数就行。如果你在模型里先Softmax再丢给CrossEntropyLoss,等于做了两次软化,模型训练会变慢且收敛不稳。项目里TSRTrain.py如果没改这个逻辑,就不要在TSRCnn.py里自作聪明。

4. 训练与调参:TSRTrain.py里的学习率、批次和防过拟合配置

4.1 训练主循环与超参选择

TSRTrain.py是整个工程里最长的脚本,本质就做三件事:加载数据、定义优化器、循环训练。核心代码可以缩成这样:

import torch import torch.nn as nn from torch.utils.data import DataLoader, random_split model = TSRNet(num_classes=43) device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model.to(device) # 数据集切分:训练集里再划 10% 当验证集 full_ds = TSRDataset("train_data.csv") train_ds, val_ds = random_split(full_ds, [int(len(full_ds)*0.9), len(full_ds) - int(len(full_ds)*0.9)]) train_loader = DataLoader(train_ds, batch_size=32, shuffle=True) val_loader = DataLoader(val_ds, batch_size=64, shuffle=False) criterion = nn.CrossEntropyLoss() optimizer = torch.optim.Adam(model.parameters(), lr=0.001) scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau(optimizer, mode="min", patience=2, factor=0.5) for epoch in range(30): model.train() running_loss = 0.0 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() running_loss += loss.item() # 每个epoch结束在验证集上评估一次 model.eval() val_loss, correct, total = 0.0, 0, 0 with torch.no_grad(): for imgs, labels in val_loader: imgs, labels = imgs.to(device), labels.to(device) outputs = model(imgs) val_loss += criterion(outputs, labels).item() _, preds = torch.max(outputs, 1) correct += (preds == labels).sum().item() total += labels.size(0) print(f"Epoch {epoch+1}: train_loss={running_loss/len(train_loader):.4f}, val_acc={correct/total:.4f}") scheduler.step(val_loss)

这里lr=0.001是Adam优化器最常用的起步值,一般不需要再调大。random_split按9:1划分训练集和验证集,这里有个玄学问题:GTSRB的Train目录是按类别分文件夹的,如果你的数据集类内样本顺序集中,直接随机切可能让某个类在验证集里消失或变少。稳妥做法是在切分前按ClassId做分层抽样,保证每个类别在验证集里都有代表。ReduceLROnPlateau在验证损失两个epoch不降时自动把学习率减半,比固定学习率跑到底要稳得多。

4.2 数据增强必须克制:交通标志不能乱翻转

很多图像分类项目习惯性地加随机水平翻转,这在交通标志识别上是个危险的默认操作。原因很直接:交通标志有方向性语义。"禁止左转"的标志翻转后变成了"禁止右转","靠右行驶"翻转变成了"靠左行驶"。如果训练集里水平翻转后的图片还是原来的标签,模型等于在学习错误映射,最终准确率会被拉低好几个点。我见过有人把验证集准确率从91%调到88%,最后查出来就是多了一行transforms.RandomHorizontalFlip()

GTSRB适合的增强是亮度扰动、小角度旋转、小范围平移和缩放。这些操作不会改变标志的方向语义,还能增强模型对拍摄角度和光线变化的鲁棒性:

# 安全的增强方式,用 torchvision 的 transforms 组合 from torchvision import transforms train_transform = transforms.Compose([ transforms.RandomRotation(degrees=10), # 小角度旋转,最多10度 transforms.ColorJitter(brightness=0.2, contrast=0.2), # 亮度对比度扰动 transforms.RandomAffine(degrees=0, translate=(0.1, 0.1)), # 最多10%平移 ])

旋转角度10度以内是安全的,超过15度后限速标志里的数字会变形到难以辨认。ColorJitter只调亮度和对比度,不要加饱和度扰动,因为标志颜色本身就是类别信息的一部分。我在自己项目里还试过加高斯噪声,但效果一般,GTSRB的图片本身已经有行车记录仪的真实噪声,人工噪声反而干扰训练。

4.3 过拟合的预警信号与早停

训练GTSRB这个规模的网络,最常出现的现象是训练准确率一路飙到99%以上,验证准确率在92%左右卡住,然后开始波动甚至下降。这就是过拟合的典型信号。判断标准很简单:训练loss持续下降,验证loss开始上升,两者之间的gap越来越大。处理手段优先级如下:先加Dropout(当前0.5已经够用)、再做数据增强、然后减小网络容量。

更强的做法是直接上早停(Early Stopping)。在TSRTrain.py里记录验证集准确率最高的那次epoch,把权重单独存成best_model.pth,而不是等30个epoch跑完再保存最后一次的权重。因为最后一个epoch往往已经不是最优的了。我实践里常用的做法是:

best_acc = 0.0 for epoch in range(30): # ... 训练和验证代码 ... if val_acc > best_acc: best_acc = val_acc torch.save(model.state_dict(), "best_model.pth") print(f"Save best model at epoch {epoch+1}, val_acc={val_acc:.4f}")

这样不管后续多少个epoch过拟合,你手里始终握有验证集上最好的那一版权重。很多新手训练完直接torch.save(model.state_dict(), "last_model.pth"),结果过拟合阶段的烂权重被保存下来,这是完全错误的使用习惯。

5. 评估与避坑:TSREval.py的指标解读和五个常见坑

5.1 TSREval.py 到底该看什么指标

TSREval.py在测试集上做最终评估,逻辑与训练循环里的验证部分一致,但有三处不同:不计算梯度、不更新权重、单独加载best_model.pth。评估代码骨架如下:

import torch from torch.utils.data import DataLoader model = TSRNet(num_classes=43) model.load_state_dict(torch.load("best_model.pth")) model.eval() test_ds = TSRDataset("test_data.csv") test_loader = DataLoader(test_ds, batch_size=64, shuffle=False) correct = 0 total = 0 class_correct = [0] * 43 class_total = [0] * 43 with torch.no_grad(): for imgs, labels in test_loader: outputs = model(imgs) _, preds = torch.max(outputs, 1) correct += (preds == labels).sum().item() total += labels.size(0) # 按类别统计,方便看哪些类拖后腿 for i in range(labels.size(0)): cls = labels[i].item() class_total[cls] += 1 if preds[i].item() == cls: class_correct[cls] += 1 print(f"Overall Accuracy: {correct / total:.4f}") for cls in range(43): if class_total[cls] > 0: rec = class_correct[cls] / class_total[cls] if rec < 0.7: # 打印准确率低于70%的类别 print(f"Class {cls}: recall={rec:.2f}, samples={class_total[cls]}")

整体准确率之外,我强烈建议看一眼逐类召回率。GTSRB本身类别不均衡,限速类的样本量远大于某些稀少标志,整体准确率可能挺好看,但某个冷门类别可能只有60%召回率。这类问题在整体准确率上看不出来,只有分拆到每个类才能发现。另外要注意shuffle=Falsemodel.eval()两个细节缺一不可,前者保证评估结果可复现,后者关掉Dropout让前向传播结果确定。

5.2 避坑记录:五条血泪经验

坑一:训练集准确率99%,测试集准确率却只有85%

现象:训练过程中loss降到0.05以下,训练准确率接近100%,但TSREval.py跑出来的整体准确率明显偏低。

原因:模型严重过拟合,把训练集里的光照条件、拍摄角度甚至背景都背下来了。GTSRB的训练图和测试图来自不同时间、不同地点的拍摄,分布本身有差异。

解决:确认保存的是best_model.pth而非last_model.pth;检查是否开了数据增强;把Dropout从0.3提到0.5;如果还不行,减少中间层特征通道数。核心原则是模型容量要让位于泛化能力。

坑二:Linux下运行报文件不存在,明明CSV里路径是对的

现象:cv2.imread返回None,或者直接抛出FileNotFoundError,但用眼睛看CSV里的路径确实存在。

原因:train_data.csv是在Windows环境下生成的,路径分隔符是反斜杠\,Linux下不识别。

解决:在我给出的load_and_preprocess函数里已经做了replace(chr(92), '/')处理。如果你用的不是这个函数,在读取路径前统一执行一次字符串替换,或者用pathlib.Path处理跨平台路径。

坑三:训练时报错expected input to have 3 channels

现象:模型输入定义的Conv2d(1, 32, 3),但实际数据进来报通道数不匹配。

原因:预处理里转灰度后忘记了reshape(1, H, W)这步,读进来的图还是(H, W)二维数组,没有通道维度。PyTorch的Conv2d强制要求输入是(N, C, H, W)四维。

解决:在归一化之后加上img = img.reshape(1, IMG_SIZE, IMG_SIZE),并在__getitem__里用torch.tensor(img)保证维度完整。这个报错出现的频率极高,几乎每个从零写GTSRB的人都会遇到一次。

坑四:随机翻转把方向性标志学反了

现象:加了随机水平翻转后训练准确率不升反降,尤其"禁止左转/禁止右转"这类成对标志互相混淆。

原因:水平翻转改变了标志的方向语义,但标签没有跟着变。模型在训练时同时看到"左转"和翻转后的"右转图"都标为"左转",内部特征被搞混乱。

解决:去掉RandomHorizontalFlip全部依赖,只保留旋转、平移、亮度扰动。旋转角度控制在正负10度内,超过15度同样会破坏数字类标志的可读性。

坑五:验证集划分不合理导致评估结果忽高忽低

现象:每次跑训练,验证集准确率波动超过5个百分点,而且同一个best_model.pth在不同次评估里表现不一致。

原因:训练集的随机划分没有按类别分层,某些冷门类别可能在验证集里只有一两个样本,偶然预测错一个就把准确率拉低很多。

解决:用StratifiedShuffleSplittrain_test_split(stratify=labels)做分层切分,保证每个类别在训练集和验证集中的分布比例与原数据一致。这个改动的效果不一定每次都明显,但能保证评估过程的稳定性。

6. 进阶用法:把模型用到单张新图片上的推理套路

训练好模型、评估完准确率之后,这个项目还不算真正落地。因为实际使用场景是单张图片送进来,立刻输出类别,而不是批量跑测试集。我建议你单独写一个推理脚本,把预处理和模型加载串起来:

import cv2 import torch def predict_single(image_path, model, class_names=None): # 复用训练时的预处理函数,保证一致 img = load_and_preprocess({"Filename": image_path}, data_root="") img_tensor = torch.tensor(img).unsqueeze(0) # 变成 (1, 1, 32, 32) model.eval() with torch.no_grad(): outputs = model(img_tensor) _, pred = torch.max(outputs, 1) prob = torch.softmax(outputs, dim=1).max().item() if class_names: return class_names[pred.item()], prob return pred.item(), prob # 使用示例 model = TSRNet(num_classes=43) model.load_state_dict(torch.load("best_model.pth")) class_id, confidence = predict_single("test_sample.png", model) print(f"Predicted class: {class_id}, confidence: {confidence:.2f}")

这里最关键的是load_and_preprocess必须与训练时的预处理完全一致。我见过有人评估脚本里用了灰度,推理脚本里却忘了转灰度,导致同样的图片在训练和推理阶段走的是两条不同的数据管线,模型效果凭空掉一截。还有一个容易被忽略的点:如果你在训练时做了RandomRotation这类数据增强,推理时绝对不能带上这些随机变换。model.eval()只是关闭了Dropout和BatchNorm的batch统计,但数据增强是在数据集类里做的,模型本身管不到。从那以后,我每个项目都会把预处理函数单独抽到一个文件里,训练和推理共同引用,绝不复制粘贴两份代码,也不在推理时临时改参数。这条习惯帮我挡掉了至少三四次"模型怎么突然不准了"的排查。希望这篇拆解能帮你把GTSRB这条链路跑通,也少踩几个我已经替你踩过的坑。

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

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

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

立即咨询