
简介面向智慧交通场景下交通标志自动识别需求这份项目实践资源围绕GTSRB数据集完整演示用卷积神经网络CNN完成图像分类任务的全流程包括数据预处理、网络设计、训练调试和评估验证。适用于有一定Python与深度学习基础、希望动手复现图像分类项目的高校学生与算法工程师也可作为人工智能实验或课设的参考蓝本。压缩包为ZIP格式共8个文件其中5个Python脚本分别负责数据读取、数据集加载、CNN模型构建、训练与评估2个CSV文件存放训练集和测试集标签信息另有1个XML配置文件描述工程结构整体仅310KB轻量易读。目前已有623人学习/下载在交通标志识别入门内容中具备较高热度。通过研读这套代码可以掌握GTSRB数据集的读取与增广方式、CNN卷积层/池化层/全连接层的搭建技巧、训练超参数调节与准确率评估方法并能够将代码迁移到其他分类任务或进一步封装成实时识别模块是理论与实践结合紧密的动手型资源。1. 用CNN识别交通标志GTSRB.zip 这个数据集为什么适合做智慧交通方向的第一个项目人工智能项目实践里最容易翻车的地方往往不是模型不够新而是连数据长什么样都没搞清楚。用CNN识别交通标志、数据集是 GTSRB.zip这件事恰好把数据和模型都压在了合理的门槛上GTSRB 是德国交通标志识别基准包含 43 类真实街景样本训练集接近四万张难度比 MNIST 高、比 ImageNet 低很多非常适合做智慧交通方向的课程设计、大作业或者当作你第一个认真调参的 cnn 卷积神经网络项目。这篇笔记按我自己的实操顺序讲解压数据集、写数据加载、搭网络、训练排错、最后把准确率推上去。新手能照着复现熟手可以直接抄参数。2. 解压 GTSRB.zip 后先做什么目录结构、PPM 读取与 ROI 裁剪2.1 解压后的目录长这样训练集、测试集与 43 个类文件夹拿到 GTSRB.zip 先别急着写代码用unzip解压后先花五分钟把目录看明白。我见过不少人跳过这步后面加载数据时路径全写错。unzip GTSRB.zip -d gtsrb tree -L 3 gtsrb解压后顶层一般是两个目录训练集目录类似GTSRB_Final_Training_Images和测试集目录类似GTSRB_Final_Test_Images。训练集目录里继续往下两层才是真正放图片的Images文件夹下面按类别分了 0 到 42 共 43 个子目录每个子目录里全是.ppm文件。关键是这层的含义0 到 42 就是 43 个交通标志类别的标签 id而不是随便的文件组织。其中 0-8 是一组限速标志20、30、50、60、70、80、80、100、1209-14 是禁止超车之类的禁令标志后面还有解除限制、强制方向、危险警告、让行停车等。子目录的名字就是监督信号读数据时直接拿目录名当 label比解析 CSV 更省事也不容易错。2.2 最省事的读取方式PIL 直接读 PPM目录名当标签GTSRB 的图片是 PPM 格式P6 二进制PIL 原生支持不需要额外装库。很多人的 cnn 代码模板里写的是cv2.imread配 jpg直接套过来会读到空数组还不报错这是第一个要改的地方。from torch.utils.data import Dataset from PIL import Image from pathlib import Path class GTSRBDataset(Dataset): 按目录结构读取 GTSRB 训练集目录名即类别 id。 def __init__(self, images_dir, transformNone): self.samples [] for class_dir in sorted(images_dir.iterdir(), keylambda p: int(p.name)): if not class_dir.is_dir(): continue label int(class_dir.name) for ppm_file in sorted(class_dir.glob(*.ppm)): self.samples.append((ppm_file, label)) self.transform transform def __len__(self): return len(self.samples) def __getitem__(self, idx): path, label self.samples[idx] img Image.open(path).convert(RGB) if self.transform: img self.transform(img) return img, labelImage.open(path).convert(RGB)会把 PPM 统一转成三通道 RGB 张量后面网络输入就不用再操心通道数。还有一个容易被忽视的点这里用sorted(images_dir.iterdir(), keylambda p: int(p.name))而不是默认的字符串排序因为字符串排序会把10排在2前面视觉上很乱但实际不影响标签正确性真正的影响是如果你要固定数据顺序做复现不排干净就是给自己埋雷。2.3 用 CSV 里的 ROI 坐标裁剪把标志本体从背景里切出来GTSRB 原图的标志并不居中占满整张图很多样本里标志只占画面的一小块四周是大面积的天空、路面、树和车辆。如果你直接把整张图 resize 成 48x48 喂给网络网络会花大量卷积核去拟合背景纹理这是验证集准确率上不去的常见原因之一。GTSRB 在训练集里附带一份Train.csv里面每行记录了一张图片文件名和标志的ROI框坐标。按这个框裁剪之后再 resize能把标志本体干净地切出来。import csv def load_roi_map(csv_path): 解析 Train.csv返回 文件名 - (x1, y1, x2, y2) 的映射。 roi_map {} with open(csv_path, newline, encodingutf-8) as f: reader csv.DictReader(f, delimiter;) for row in reader: filename row[Filename] roi_map[filename] ( int(row[Roi.X1]), int(row[Roi.Y1]), int(row[Roi.X2]), int(row[Roi.Y2]) ) return roi_map注意delimiter;GTSRB 的 CSV 用分号分隔不是逗号。如果你用默认的逗号解析表头和每行都会被读成单列row[Filename]直接 KeyError。这是这个数据集非常典型的一个坑搜索框里一半的GTSRB.zip报错帖都是它。拿到 ROI 之后读图时先裁再用roi_map load_roi_map(GTSRB_Final_Training_Images/GTSRB/Train.csv) def __getitem__(self, idx): path, label self.samples[idx] img Image.open(path) x1, y1, x2, y2 roi_map[path.name] img img.crop((x1, y1, x2, y2)) img img.convert(RGB) if self.transform: img self.transform(img) return img, label我自己做的时候对比过同一套网络整图 resize 训练出来验证集大概 88%套上 ROI 裁剪后直接跳到 93%。背景噪声对分类的干扰就是这么明显。2.4 统一尺寸到 48x48缩放插值与增强顺序GTSRB 样本原始尺寸从十几像素到两百多像素都有而神经网络要求输入尺寸固定。业界常见做法是把所有图 resize 到一个统一边长我一般定 48x48分辨率足以分辨限速标志里的数字又不会让网络参数爆炸。如果要迁就 LeNet 经典结构32x32 也能跑但对 20 像素以下的模糊小图损失太多细节。from PIL import ImageResampling img img.resize((48, 48), ImageResampling.LANCZOS)缩放插值这块Pillow 新版本里Image.ANTIALIAS已经弃用用ImageResampling.LANCZOS。Image.BILINEAR也能用但缩小到 48x48 时 LANCZOS 的重采样质量更好边缘更干净对标志上的数字笔画影响更小。3. 给 GTSRB 选 CNN 结构从 LeNet 到 3x3 堆叠输入尺寸为什么定 48x483.1 LeNet-5 改一版就够用浅层网络在 GTSRB 上的定位GTSRB 的图像是真实街景有光照变化、运动模糊、遮挡比 MNIST 那种规整手写数字难一个档次但它的分类任务本质还是小图 语义明显所以经典 LeNet-5 改一改就能当基线。改动只有一处把第一个卷积层的输入通道数从 1 改成 3接受 RGB 输入。import torch.nn as nn class GTSRBLeNet(nn.Module): def __init__(self, num_classes43): super().__init__() self.features nn.Sequential( nn.Conv2d(3, 6, kernel_size5, padding2), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(6, 16, kernel_size5), nn.ReLU(), nn.MaxPool2d(2), ) self.classifier nn.Sequential( nn.Flatten(), nn.Linear(16 * 12 * 12, 120), nn.ReLU(), nn.Dropout(0.5), nn.Linear(120, 84), nn.ReLU(), nn.Linear(84, num_classes), ) def forward(self, x): x self.features(x) x self.classifier(x) return x这个结构里padding2是为了保持 48x48 输入经过第一个卷积后尺寸不变体验上就是数据管线不用因为 padding 问题改来改去。48x48 经过两次 MaxPool 变成 12x12展平后是16 * 12 * 12 2304接 120 个神经元的全连接参数规模很小单卡 CPU 都能几分钟训完。LeNet 在 GTSRB 上的定位是验证数据管线有没有毛病的试金石如果这个网络都跑不出 90% 以上问题大概率不在模型复杂度而在数据加载、标签对齐或预处理上先别急着换大模型。3.2 堆 3x3 卷积的类 VGG 方案什么时候值得换更深的结构如果你的大作业要求体现工作量或者你想真正逼近这个数据集的 SOTA 区间LeNet 不够看用 3x3 卷积堆叠的类 VGG 结构更合适。3x3 卷积核堆两层能获得相当于 5x5 的感受野参数量却更小而且每层后面夹 BatchNorm对 GTSRB 这种光照差异大的真实图像收敛更快、更稳。def conv_block(in_c, out_c): return nn.Sequential( nn.Conv2d(in_c, out_c, 3, padding1), nn.BatchNorm2d(out_c), nn.ReLU(inplaceTrue), nn.Conv2d(out_c, out_c, 3, padding1), nn.BatchNorm2d(out_c), nn.ReLU(inplaceTrue), nn.MaxPool2d(2), ) class GTSRBVGG(nn.Module): def __init__(self, num_classes43): super().__init__() self.features nn.Sequential( conv_block(3, 32), conv_block(32, 64), conv_block(64, 128), ) self.classifier nn.Sequential( nn.Flatten(), nn.Dropout(0.5), nn.Linear(128 * 6 * 6, 256), nn.ReLU(inplaceTrue), nn.Dropout(0.3), nn.Linear(256, num_classes), ) def forward(self, x): return self.classifier(self.features(x))48x48 输入经过两个卷积加一个池化尺寸从 48 到 24 到 12 再到 6展平后是128 * 6 * 6 4608接 256 个神经元的全连接。这个量级在 GTX 1660 上训练 30 个 epoch 只要十几分钟。相比 LeNet它的优势是每层感受野更丰富、学到的是更细的边缘和纹理组合代价是 BatchNorm 对 batch size 有下限要求batch size 小于 16 时效果会打折。3.3 Dropout 放哪层、概率多少全连接层与过拟合的关系GTSRB 训练集四万张对一个小网络来说不算少但真实街景图里同类标志的外观差异非常大模型很容易把某个背景和某个类别绑定这就是过拟合。Dropout 放在全连接层前面最有效我的习惯是第一个全连接之前放Dropout(0.5)后面如果还有全连接再接一个Dropout(0.3)。为什么是这个概率0.5 是实践里最稳的起点接近最大熵正则0.3 是为了保留已学到的组合特征不把它也抹掉。还有一个常见套路只用 Dropout不加 L2 正则因为 Dropout 和 weight decay 同时用会把有效学习率压得太低在 GTSRB 这种规模的数据集上容易欠拟合。3.4 彩色还是灰度信息取舍与输入通道的连带改动GTSRB 交通标志本身就是靠颜色编码的红色禁令、蓝色强制、黄色警告。很多入门教程会把图像convert(L)转成灰度来降低计算量但放到 GTSRB 上这是明显的负优化——标志的颜色是先验信息丢掉它等于让模型只靠形状硬猜。我的建议是保留 RGB 三通道模型第一个卷积层的in_channels3。如果你确实想压计算量可以做 PCA 降维到单通道或者用颜色增强替代灰度化但 GTSRB 的图片本身只有几十像素见方三通道的计算开销很小不值当为了省这点算力损失 2-3 个百分点的准确率。4. 用 PyTorch 训练识别模型完整训练循环与关键参数设置4.1 一个能直接跑的训练脚本从数据加载到保存权重前面数据类和模型类都定义好了剩下的是把它们拼起来。下面这版训练脚本是完整可运行的注意它是针对前面 GTSRBVGG 写的换成 GTSRBLeNet 也只需要改一行模型实例化。import torch import torch.nn as nn from torch.utils.data import DataLoader from torchvision import transforms from torch.optim import AdamW from torch.optim.lr_scheduler import CosineAnnealingLR device torch.device(cuda if torch.cuda.is_available() else cpu) train_transform transforms.Compose([ transforms.Resize((48, 48), transforms.InterpolationMode.LANCZOS), transforms.RandomAffine(degrees10, translate(0.1, 0.1)), transforms.ToTensor(), transforms.Normalize([0.5, 0.5, 0.5], [0.5, 0.5, 0.5]), ]) train_ds GTSRBDataset(GTSRB_Final_Training_Images/Images, transformtrain_transform) train_loader DataLoader(train_ds, batch_size64, shuffleTrue, num_workers4, pin_memoryTrue) model GTSRBVGG(num_classes43).to(device) criterion nn.CrossEntropyLoss() optimizer AdamW(model.parameters(), lr1e-3, weight_decay1e-4) scheduler CosineAnnealingLR(optimizer, T_max30) for epoch in range(30): model.train() total_loss, correct, total 0.0, 0, 0 for imgs, labels in train_loader: imgs, labels imgs.to(device), labels.to(device) optimizer.zero_grad() logits model(imgs) loss criterion(logits, labels) loss.backward() optimizer.step() total_loss loss.item() * imgs.size(0) correct (logits.argmax(1) labels).sum().item() total labels.size(0) scheduler.step() print(fepoch {epoch:02d} | loss {total_loss / total:.4f} | acc {correct / total:.4f}) torch.save(model.state_dict(), gtsrb_cnn.pt)几个关键点model.train()必须放在训练循环开头它决定 Dropout 和 BatchNorm 的行为验证或推理时反过来要调model.eval()否则 BatchNorm 会用 batch 内统计量预测结果会莫名其妙变差。optimizer.zero_grad()每步都要清空上次梯度漏了会造成梯度累加loss 曲线会像锯齿一样跳动。4.2 参数这样设学习率、batch、epoch 的取舍我直接给一组在 GTSRB 上验证过的参数然后说为什么。参数推荐值说明优化器AdamW相比 Adam对权重衰减处理更干净初始学习率1e-3大于 1e-2 会震荡不收敛小于 1e-4 收敛太慢weight_decay1e-4轻微正则防过拟合batch_size64显存允许的话 128 也行别小于 32训练轮数30-50配合余弦退火在 30 轮左右收敛损失函数CrossEntropyLoss43 类多分类标准选择学习率是这里面最值得盯的。1e-3 是 Adam 系优化器在 GTSRB 这种中等规模数据集上的常见起点。如果 loss 在前几个 epoch 不降反而升高先调成 3e-4 试不要直接降一个数量级否则模型学不动。CosineAnnealingLR的T_max30表示学习率在 30 个 epoch 内按余弦曲线从初始值衰减到接近 0后半程通过降低学习率帮助 loss 稳定落地。4.3 验证集划分按类分层抽样避免某一类在验证集里缺席GTSRB 不是均衡数据集有的类别有两千多张有的类别只有两百多张。如果你简单random_split样本少的类在验证集里可能一张都没有验证准确率会虚高或虚低还看不出问题。正确的做法是分层抽样保证 43 类在验证集里都有代表。from sklearn.model_selection import train_test_split from torch.utils.data import Subset all_paths [s[0] for s in train_ds.samples] all_labels [s[1] for s in train_ds.samples] train_idx, val_idx train_test_split( range(len(all_labels)), test_size0.2, stratifyall_labels, random_state42, ) train_subset Subset(train_ds, train_idx) val_subset Subset(train_ds, val_idx)这里有个容易被忽略的坑Subset会直接共享train_ds里的 transform也就是说验证集会跟着做RandomAffine随机增强。验证集不应该有任何随机变换否则每次跑验证集结果都在变。解决办法是单独建一个只带 Resize、ToTensor、Normalize 的val_ds再对val_ds做同样的分层划分。4.4 类别不均衡的兜底方案加权损失与重采样分层划分解决的是验证集有没有某一类的问题但训练时模型依旧更偏向样本多的类少数类准确率可能是个位数。两个常用兜底方案一个是给损失函数加类别权重一个是给 DataLoader 换成加权采样器。from torch.utils.data import WeightedRandomSampler from collections import Counter counts Counter(all_labels) weights [1.0 / counts[label] for _, label in train_ds.samples] sampler WeightedRandomSampler(weights, num_sampleslen(weights), replacementTrue) train_loader DataLoader(train_ds, batch_size64, samplersampler)1.0 / counts[label]让样本少的类别拥有更高的被采样概率。replacementTrue表示可以重复抽到同一样本这样小类别的样本在训练里会被重复看到模型就不会彻底忽略它们。我个人的习惯是先试加权损失直接在CrossEntropyLoss里传weight张量它不改变数据分布、不容易过拟合加权采样器适合被加权损失压不住的时候再用。5. GTSRB 训练避坑5 个让模型翻车的常见问题和排查方法5.1 损失掉不下去还震荡学习率过大余弦退火救援现象训练 loss 在 3.0 到 4.0 之间来回震几十个 epoch 都降不下来。很多新手以为模型结构错了其实是学习率太大。GTSRB 有 43 类随机猜测的交叉熵损失是ln(43) ≈ 3.76如果你的 loss 一直在这个值附近不动说明模型根本没学到东西。原因AdamW 的默认学习率 1e-3 在大多数结构上没问题但有些随机初始化会让早期梯度很大1e-3 直接冲过了收敛区。解决先把学习率降到 3e-4 或 1e-4 看 loss 是否开始稳步下降再把学习率调回 1e-3 配CosineAnnealingLR。余弦退火在前几个 epoch 会保持较高学习率快速探索后期平滑下降正好治这种震荡。5.2 训练集 95%、验证集 60%过拟合的三个来源现象训练准确率高得吓人验证集准确率差一大截。原因基本就三个。第一没有裁剪 ROI模型在学背景而不是标志。第二全连接层参数太多GTSRB 四万张图对一个大全连接层来说不够约束。第三训练时没有数据增强同一批图来回看记忆特征。解决按 2.3 节裁剪 ROI把 Dropout 概率提到 0.5训练 transform 里加RandomAffine(degrees10, translate(0.1, 0.1))。改完这三处过拟合差距通常会从 30 多个百分点缩到 5 个百分点以内。5.3 PPM 图片读出来是黑的或者翻转的用错了解码库现象用matplotlib.image.imread读 PPM图像不是全黑就是颜色错乱。原因PPM 的 P6 格式是二进制 RGB 存储不带压缩信息matplotlib 的 imread 对它的支持不完整读出来把文件头当成像素数据了。解决用 PIL 的Image.open或者 OpenCV 的cv2.imread这两个库对 PPM 的兼容性都很好。另外注意 OpenCV 读出来是 BGR 通道顺序要cv2.cvtColor(img, cv2.COLOR_BGR2RGB)再转 PyTorch 张量否则模型看到的颜色全部错位准确率会掉 10 个点以上。5.4 少数类几乎全错类别不均衡不是网络的错现象查看分类报告样本多的限速类准确率 95% 以上样本少的危险警告类只有 40%。原因GTSRB 各类样本数差异接近十倍普通交叉熵损失对所有类别一视同仁模型当然优先拟合数据多的类。解决按 4.4 节的做法给损失函数加weight参数权重设为各类样本数的倒数再归一化然后看样本少的类别准确率有没有抬起来。如果加了权重以后少数类上来了但多数类掉了再用WeightedRandomSampler做数据层面的重采样两个方案可以叠加。5.5 推理时准确率骤降训练和推理的预处理管线不一致现象训练时验证集 93%把模型部署到单张图片上预测结果经常识别错。原因训练走了 Resize、RandomAffine、Normalize推理时只做了 Resize 就喂进去输入的像素分布和训练时不匹配另一个常见原因是推理时忘了model.eval()BatchNorm 还在用当前 batch 的统计量。解决把推理的预处理固定成和验证集完全相同的管线单独定义一个val_transform推理时也用它。这个坑最隐蔽因为它不报错只是准确率悄悄变差。6. 把准确率从 85% 推到 97%数据增强、迁移学习与混淆矩阵验证6.1 数据增强按这个顺序加平移、旋转、亮度别一上来就上 Cutout先用最简单有效的三件套train_transform transforms.Compose([ transforms.Resize((48, 48), transforms.InterpolationMode.LANCZOS), transforms.RandomAffine(degrees10, translate(0.1, 0.1)), transforms.ColorJitter(brightness0.3, contrast0.3), transforms.ToTensor(), transforms.Normalize([0.5, 0.5, 0.5], [0.5, 0.5, 0.5]), ])RandomAffine的degrees10是经过考量的GTSRB 里有一部分标志是表示方向箭头的旋转超过 15 度会改变语义所以别加到 30 度。ColorJitter(brightness0.3, contrast0.3)模拟白天不同光照对真实街景样本非常有效。Cutout 之类的结构化遮挡虽然能提升鲁棒性但它可能抹掉限速标志中央的数字初始阶段不建议用。我自己踩过这个坑一上来就加 Cutout限速 50 和限速 80 的验证准确率掉了一截。6.2 迁移学习直接用 ResNet18输入尺寸与归一化参数一起换如果你想把准确率推到 97% 以上用预训练 ResNet18 微调是最省时间的路径。注意两个连带改动输入要 resize 到 224x224归一化要用 ImageNet 的均值和标准差。from torchvision import models model models.resnet18(weightsmodels.ResNet18_Weights.IMAGENET1K_V1) model.fc nn.Linear(model.fc.in_features, 43) for p in model.parameters(): p.requires_grad False for p in model.fc.parameters(): p.requires_grad True先把除全连接层外的参数全部冻结只训练分类头用 1e-3 的学习率跑 5 个 epoch。然后解冻全部参数学习率降到 1e-4再训练 10-15 个 epoch。这个两步策略比直接微调更快也更稳因为预训练特征在 ImageNet 上已经学好通用边缘和纹理GTSRB 的图不用从零学起。注意 GTSRB 原始小图放大到 224x224 会模糊但预训练网络对这种模糊并不敏感效果依然显著。6.3 用混淆矩阵看互撞类别限速组之间的教训准确率达到 94% 以后光看总准确率已经看不出瓶颈在哪。用验证集跑一遍预测输出混淆矩阵from sklearn.metrics import confusion_matrix, classification_report cm confusion_matrix(y_true, y_pred) print(classification_report(y_true, y_pred, digits3))我拿 GTSRB 跑过的经验是最容易互撞的几组限速 30 和限速 50、限速 80 和限速 100以及危险警告系列那几个三角形标志。限速组互撞的本质是数字在 48x48 下笔画太细Resize 时被插值糊掉了。针对这种情况最有效的不是加大模型而是单独对这几个类别多裁剪一圈更紧的 ROI或者把输入尺寸从 48x48 提到 64x64 重训一遍。我的教训是数据管线永远值得先检查模型结构反而是最容易的那一环。GTSRB.zip 这个数据集真正磨人的地方在 CSV 分隔符、ROI 坐标、类别不均衡和预处理一致性这些地方折腾明白了换什么模型都不会太差。希望这篇能帮你少走几步弯路。本文还有配套的精品资源点击获取