ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

基于CNN的交通标志识别:GTSRB数据集实战与PyTorch实现

基于CNN的交通标志识别:GTSRB数据集实战与PyTorch实现 简介这份资源面向计算机、人工智能、电子信息等专业的学生及企业员工提供基于卷积神经网络识别交通标志的完整Python项目源码与说明文档可用于课程设计、毕业设计、大作业或初期项目立项演示。压缩包共9个文件约310KB包含5个py脚本、2个csv数据文件、1个xml配置和1个md说明文档其中py文件分别承担数据预处理、模型构建、训练与评估等核心环节csv文件对应训练集与测试集数据索引md文档则给出项目说明与运行指引。项目以GTSRB交通标志数据集为基础覆盖从数据读取、图像预处理到CNN建模、训练与性能评估的完整流程目录结构清晰便于读者理解各模块职责并快速上手复现。目前已有171人学习下载适合希望掌握深度学习图像分类实战、积累项目经验或作为毕设参考的读者借鉴使用。1. 从一张模糊的路牌说起CNN 识别交通标志到底难在哪限速 40 的圆牌被树影切掉一半远处看只剩个红圈施工区的三角警示牌被前车挡了一角夜里逆光拍出来蓝色指示牌糊成一团。这些场景里人眼还能靠上下文猜模型却只能靠像素硬扛。基于 CNN 识别交通标志要解决的就是把摄像头拍到的路牌稳定地映射到 GTSRB 那 43 个类别上。GTSRB 全称 German Traffic Sign Recognition Benchmark是交通标志识别里最常被拿来练手的公开数据集图像尺寸不一、光照跨度大、部分类别样本极少。这篇笔记面向想用 Python 从零跑通一套交通标志识别的人不管你是刚装完 Python 想找个完整项目练手还是已经会写卷积网络但没碰过真实路牌数据都能顺着往下走把数据、模型、训练、推理这条链路走完并且知道每一步的坑在哪。2. GTSRB 数据集的真实长相与预处理决策2.1 先搞清楚 GTSRB 的目录结构和类别分布GTSRB 不是那种规规矩矩的 ImageFolder 结构。它按类别分文件夹每个文件夹里是若干 ppm 或 png 图像外加一个 CSV 标注文件记录每张图的文件名、尺寸、ROI 坐标和类别 ID。类别 ID 从 0 到 42共 43 类。真正上手前先跑一段脚本把分布摸清楚否则后面训练时某几类一直学不会你会怀疑模型其实是数据在作怪。import os import pandas as pd from collections import Counter # 假设解压后根目录为 GTSRB训练集在 GTSRB/Train train_root GTSRB/Train class_counts {} for cls in sorted(os.listdir(train_root)): cls_dir os.path.join(train_root, cls) if os.path.isdir(cls_dir): imgs [f for f in os.listdir(cls_dir) if f.lower().endswith((.ppm, .png, .jpg))] class_counts[int(cls)] len(imgs) # 打印每类数量按数量升序看谁最缺 for cid, cnt in sorted(class_counts.items(), keylambda x: x[1]): print(fclass {cid:2}: {cnt}) print(总类别数:, len(class_counts)) print(总样本数:, sum(class_counts.values()))这段代码只做一件事统计每个类别的图像数量。逻辑很直白遍历类别目录、数文件。参数上唯一要注意的是扩展名GTSRB 原始格式是 ppm有些二次分发的版本已经转成 png所以扩展名判断要写全。跑完你会看到类别间差距可能达到十倍以上比如某些常见限速牌上千张个别稀有标志只有一两百张。这个分布直接决定了后面要不要做重采样或类别加权。2.2 尺寸归一化和 ROI 裁剪别把背景当特征GTSRB 图像尺寸从十几像素到几百像素不等直接送进网络前必须统一。常见做法是缩放到 32×32 或 48×48。但这里有个容易被忽略的点标注文件里给了 ROI 坐标也就是标志牌在图像中的实际位置。如果你不裁剪 ROI直接整图缩放网络会学到大量背景信息——天空、路面、车身这些在训练集里和类别有虚假相关换到真实场景就翻车。import cv2 import numpy as np import pandas as pd IMG_SIZE 32 def load_and_preprocess(img_path, roiNone, sizeIMG_SIZE): img cv2.imread(img_path) if img is None: return None if roi is not None: x1, y1, x2, y2 roi img img[y1:y2, x1:x2] # 先裁 ROI去掉背景干扰 img cv2.resize(img, (size, size)) # 再统一尺寸 img cv2.cvtColor(img, cv2.COLOR_BGR2RGB) # OpenCV 默认 BGR转成 RGB return img # 读取标注逐张处理 df pd.read_csv(GTSRB/Train.csv) # 列名通常含 Width/Height/ROI.X1 等 samples [] for _, row in df.iterrows(): roi (row[ROI.X1], row[ROI.Y1], row[ROI.X2], row[ROI.Y2]) img load_and_preprocess(os.path.join(GTSRB, row[Path]), roi) if img is not None: samples.append((img, row[ClassId])) print(有效样本:, len(samples))逻辑顺序是裁剪在前、缩放在后这个顺序不能反。先缩放再裁剪ROI 坐标就对不上了。参数上 IMG_SIZE 选 32 是经典 LeNet 级别的输入选 48 能保留更多细节但显存和计算量上升。ROI 坐标来自 CSV不同版本列名可能略有差异常见是 ROI.X1 这种带点号的写法读进来后确认一下列名再取。裁剪时如果坐标越界cv2 会返回空或报错稳妥做法是加一层边界检查。2.3 数据增强GTSRB 上哪些增强真正有用交通标志的拍摄条件变化集中在光照、角度、模糊和遮挡。对应到增强手段亮度对比度扰动、小角度旋转、轻微平移缩放是有效的而水平翻转要慎用因为部分标志翻转后语义会变比如左转箭头翻成右转类别标签就错了。垂直翻转基本不用考虑。import random import cv2 import numpy as np def augment(img): # 亮度扰动 if random.random() 0.5: factor random.uniform(0.7, 1.3) img np.clip(img.astype(np.float32) * factor, 0, 255).astype(np.uint8) # 小角度旋转 if random.random() 0.5: angle random.uniform(-12, 12) h, w img.shape[:2] M cv2.getRotationMatrix2D((w / 2, h / 2), angle, 1.0) img cv2.warpAffine(img, M, (w, h), borderModecv2.BORDER_REFLECT) # 随机缩放平移 if random.random() 0.3: scale random.uniform(0.9, 1.1) h, w img.shape[:2] M cv2.getRotationMatrix2D((w / 2, h / 2), 0, scale) img cv2.warpAffine(img, M, (w, h), borderModecv2.BORDER_REFLECT) return img每个增强都带概率开关不是每张都做全套。亮度因子控制在 0.7 到 1.3再大就失真。旋转角度控制在正负 12 度交通标志在画面里通常不会大幅倾斜。边界填充用 BORDER_REFLECT 比填黑边更自然黑边会被网络当成一种固定模式学进去。增强只在训练时做验证和测试阶段保持原图否则评估结果不可信。3. 用 PyTorch 搭一个能跑通的 CNN 分类器3.1 网络结构从 LeNet 改到适配 43 类交通标志识别不需要动辄 ResNet 级别的网络GTSRB 图像小、类别少一个三四层卷积加两层全连接的 CNN 就够。结构设计的关键是卷积核数量递增、池化逐步降维、最后全连接到 43 维输出。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(), nn.Conv2d(32, 32, 3, padding1), nn.BatchNorm2d(32), nn.ReLU(), nn.MaxPool2d(2), # 32x32 - 16x16 nn.Conv2d(32, 64, 3, padding1), nn.BatchNorm2d(64), nn.ReLU(), nn.Conv2d(64, 64, 3, padding1), nn.BatchNorm2d(64), nn.ReLU(), nn.MaxPool2d(2), # 16x16 - 8x8 nn.Conv2d(64, 128, 3, padding1), nn.BatchNorm2d(128), nn.ReLU(), nn.MaxPool2d(2), # 8x8 - 4x4 ) self.classifier nn.Sequential( nn.Flatten(), nn.Linear(128 * 4 * 4, 256), nn.ReLU(), nn.Dropout(0.5), nn.Linear(256, num_classes) ) def forward(self, x): return self.classifier(self.features(x))结构逻辑是两组卷积后池化再一组卷积后池化最后展平接全连接。BatchNorm 放在卷积和 ReLU 之间作用是稳定训练、加快收敛。Dropout 只加在全连接层比例 0.5防止过拟合。输入假设是 32×32如果你改成 48×48最后的特征图尺寸会变全连接层的输入维度要跟着算别照抄 128×4×4。3.2 训练循环损失函数、优化器和学习率43 类分类用交叉熵损失优化器选 Adam 起步学习率 1e-3。如果类别不均衡严重给 CrossEntropyLoss 传 weight 参数权重按类别样本数的倒数来算。from torch.utils.data import DataLoader, TensorDataset import torch.optim as optim device torch.device(cuda if torch.cuda.is_available() else cpu) model TrafficSignCNN(num_classes43).to(device) # 假设 X_train, y_train 已经是从前面预处理得到的 numpy 数组 X_tensor torch.tensor(X_train, dtypetorch.float32).permute(0, 3, 1, 2) / 255.0 y_tensor torch.tensor(y_train, dtypetorch.long) loader DataLoader(TensorDataset(X_tensor, y_tensor), batch_size64, shuffleTrue) criterion nn.CrossEntropyLoss() optimizer optim.Adam(model.parameters(), lr1e-3) for epoch in range(30): model.train() total_loss 0 for xb, yb in loader: xb, yb xb.to(device), yb.to(device) optimizer.zero_grad() out model(xb) loss criterion(out, yb) loss.backward() optimizer.step() total_loss loss.item() print(fepoch {epoch1}, loss {total_loss/len(loader):.4f})几个参数要盯住。batch_size 设 64 是显存和梯度稳定性的折中显存小就降到 32。permute 是把 numpy 的 HWC 格式转成 PyTorch 需要的 CHW顺序别搞反。除以 255 是做归一化让输入落在 0 到 1。学习率 1e-3 配 Adam 是常见起点如果 loss 震荡厉害就降到 1e-4。训练轮数 30 是参考值实际看验证集准确率什么时候不再涨。3.3 验证集划分与早停别等过拟合了才后悔GTSRB 官方有单独的测试集但训练过程中你需要自己的验证集来判断什么时候停。从训练集里按类别分层抽 10% 到 15% 做验证保证每个类别都有代表。from sklearn.model_selection import train_test_split X_tr, X_val, y_tr, y_val train_test_split( X_train, y_train, test_size0.15, stratifyy_train, random_state42 ) # 验证阶段 model.eval() with torch.no_grad(): val_tensor torch.tensor(X_val, dtypetorch.float32).permute(0, 3, 1, 2).to(device) / 255.0 val_label torch.tensor(y_val, dtypetorch.long).to(device) pred model(val_tensor).argmax(dim1) acc (pred val_label).float().mean().item() print(fval acc: {acc:.4f})stratify 参数保证划分后各类别比例一致random_state 固定住让结果可复现。验证时 model.eval() 和 torch.no_grad() 都要加前者关掉 Dropout 和 BatchNorm 的训练行为后者省显存。如果验证准确率连续几轮不涨甚至下降就该停这就是早停的朴素实现。别硬训到 loss 降到零那基本是记住了训练集。4. 训练过程中的避坑与排查清单4.1 准确率卡在某个值上不去现象训练 loss 在降验证准确率停在 60% 到 70% 不动。原因通常是学习率偏大导致在最优解附近震荡或者数据增强过猛让训练分布和验证分布差太远。解决先把学习率降一个数量级试再把增强概率调低尤其是旋转和缩放确认是不是增强把标志牌转出了可识别范围。4.2 某几个类别始终识别错现象混淆矩阵里某两类互相误判比如限速 30 和限速 50。原因这两类视觉差异本来就小加上图像分辨率低数字细节丢失。解决把输入尺寸从 32 提到 48或者对这两类单独做更强的增强也可以在损失里给这两类更高权重。别指望网络自己学会区分模糊的数字。4.3 训练集准确率接近 100% 但测试集很差现象训练集上几乎全对换到测试集掉到 70% 以下。原因过拟合模型记住了训练样本的背景和噪声。解决加 Dropout、加权重衰减、减少全连接层宽度最直接的是加数据增强和早停。如果训练集本身就不大考虑用预训练模型做迁移学习。4.4 显存不够或训练速度异常慢现象报 CUDA out of memory或者一个 epoch 跑十几分钟。原因batch_size 太大或者数据加载没开多进程或者图像没提前转成 tensor 每次都在做 CPU 转换。解决降 batch_sizeDataLoader 加 num_workers 参数预处理阶段就把图像转成固定尺寸的 numpy 数组存好别在训练循环里读文件。4.5 推理时单张图片预测结果不稳定现象同一张图多次预测结果不一样。原因模型还在 train 模式Dropout 在起作用。解决推理前调 model.eval()并且用 torch.no_grad() 包住。这个坑很隐蔽因为训练时一切正常只有部署时才暴露。5. 从训练脚本到能用的推理接口5.1 保存和加载模型别只存 state_dict 就完事训练完把权重存下来但只存 state_dict 的话加载时还得手动重建网络结构容易对不上。稳妥做法是连结构配置一起存。# 保存 torch.save({ model_state: model.state_dict(), num_classes: 43, img_size: 32 }, traffic_sign_cnn.pth) # 加载 ckpt torch.load(traffic_sign_cnn.pth, map_locationcpu) net TrafficSignCNN(num_classesckpt[num_classes]) net.load_state_dict(ckpt[model_state]) net.eval()存的时候把 num_classes 和 img_size 一起写进去加载时先读配置再建网络避免结构不匹配。map_location 在只有 CPU 的机器上加载 GPU 训练的权重时必须加。5.2 写一个接收图片路径返回类别和置信度的函数推理接口要做的就是把前面预处理、前向传播、取最大值这几步串起来返回类别 ID 和置信度。def predict(img_path, model, class_namesNone): img cv2.imread(img_path) img cv2.resize(img, (32, 32)) img cv2.cvtColor(img, cv2.COLOR_BGR2RGB) tensor torch.tensor(img, dtypetorch.float32).permute(2, 0, 1).unsqueeze(0) / 255.0 with torch.no_grad(): logits model(tensor) prob torch.softmax(logits, dim1) conf, pred prob.max(dim1) cid pred.item() name class_names[cid] if class_names else str(cid) return name, conf.item()unsqueeze(0) 是加 batch 维度因为模型 forward 期望四维输入。softmax 把 logits 转成概率max 同时拿到置信度和类别。置信度低于某个阈值时实际部署里应该拒绝输出而不是硬给一个类别这个阈值可以在验证集上根据准确率和召回率的权衡来定。5.3 用测试集做一次完整评估GTSRB 有官方测试集和对应的标注文件跑一遍拿到整体准确率和每类准确率才算真正验证了这套方案。from sklearn.metrics import classification_report model.eval() all_preds, all_labels [], [] for xb, yb in test_loader: xb xb.to(device) with torch.no_grad(): pred model(xb).argmax(dim1).cpu().numpy() all_preds.extend(pred) all_labels.extend(yb.numpy()) print(classification_report(all_labels, all_preds, digits4))classification_report 会给出每类的精确率、召回率和 F1比一个总体准确率有用得多。看哪些类 F1 低回头查这些类的样本量和图像质量往往能找到具体原因。6. 把准确率再往上推的几个实操技巧模型能跑通之后想从 90% 出头推到 97% 以上靠的不是换更大的网络而是几个细节。第一个是输入分辨率32×32 对数字类标志确实不够改成 48×48 通常能涨两三个点代价是训练慢一些。第二个是学习率调度用 CosineAnnealingLR 或者 ReduceLROnPlateau让学习率随训练进程衰减比固定学习率收敛得更稳。第三个是测试时增强对同一张测试图做几次轻微变换把预测概率平均能压掉一部分随机误差。from torch.optim.lr_scheduler import CosineAnnealingLR optimizer optim.Adam(model.parameters(), lr1e-3) scheduler CosineAnnealingLR(optimizer, T_max30) for epoch in range(30): # ... 训练循环 ... scheduler.step() # 每个 epoch 后更新学习率CosineAnnealingLR 的 T_max 设成总 epoch 数学习率会按余弦曲线从初始值降到接近零。这个调度在训练后期特别有用能让模型在最优解附近精细调整而不是一直大步跳。还有一个容易被忽视的点是类别权重。GTSRB 里样本少的类如果不加权模型会倾向于忽略它们。用 sklearn 的 compute_class_weight 算出权重传给损失函数少数类的召回率会明显改善。from sklearn.utils.class_weight import compute_class_weight import numpy as np weights compute_class_weight(balanced, classesnp.unique(y_train), yy_train) weight_tensor torch.tensor(weights, dtypetorch.float32).to(device) criterion nn.CrossEntropyLoss(weightweight_tensor)balanced 模式会自动按样本数倒数算权重样本越少权重越高。这个改动对总体准确率可能只涨零点几个点但对少数类的识别率提升是实打实的实际部署里少数类往往才是安全相关的关键标志。我自己做这类项目最大的教训是别一上来就调网络结构。先把数据看一遍把预处理和增强做对把验证集划分做扎实这三件事做到位一个普通 CNN 就能到 95% 以上。反过来数据没弄干净就换模型换多少个都是白费。希望帮到你。本文还有配套的精品资源点击获取
返回列表