ARTICLE DETAIL

资讯详情

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

基于CNN的GTSRB交通标志识别:从数据预处理到99%准确率的实战指南

基于CNN的GTSRB交通标志识别:从数据预处理到99%准确率的实战指南 简介这是一份面向计算机、人工智能及相关专业学生与开发者的交通标志识别项目源码基于Python与卷积神经网络实现采用GTSRB数据集完成训练与评估适合作为毕业设计、课程设计、作业或项目初期立项演示也适合希望入门深度学习图像分类的小白进阶学习。压缩包共9个文件约311KB包含5个py脚本、2个csv数据文件、1个xml配置和1个md说明文档分别承担模型构建、训练、评估、数据预处理与数据索引等职责结构清晰便于按模块阅读。目前已有224人学习下载。项目代码经过完整测试运行成功后才上传答辩评审平均分达到96分读者可据此掌握从数据预处理、CNN模型搭建到训练与评估的完整流程并可在现有代码基础上修改以扩展其他功能下载后建议先阅读README.md了解整体说明。1. 从一张模糊的路牌说起GTSRB 上的 CNN 识别到底难在哪城市道路上的限速牌、禁令牌、指示牌在车速 60km/h、侧向偏移、逆光、雨雾、部分遮挡的条件下拍下来分辨率可能只有 30×30 像素颜色还会被白平衡拉偏。德国交通标志识别基准数据集 GTSRBGerman Traffic Sign Recognition Benchmark正是为这类真实工况准备的约 5 万张训练图、1.2 万余张测试图覆盖 43 个类别图像尺寸从 15×15 到 222×162 不等类别分布严重不均衡。用 Python CNN 做交通标志识别核心要解决的不是「能不能跑通一个卷积网络」而是「在尺寸不一、光照畸变、类别长尾的真实数据上把准确率稳定推到 95% 以上并且知道每一步为什么这么做」。这篇笔记面向已经会写 Python、装过 PyTorch 或 TensorFlow、想拿 GTSRB 做第一个完整视觉项目的从业者也面向想复现一个能写进简历的深度学习 CNN 项目的入门者。我会把数据读取、预处理、网络结构、训练策略、评估与排错按可复现的顺序讲清楚参数给到能直接抄的程度。2. GTSRB 数据集的读取、清洗与尺寸统一2.1 先搞清楚 GTSRB 的目录结构和标注格式GTSRB 官方压缩包解压后通常长这样GTSRB/Final_Training/Images/00000/到00042/共 43 个文件夹每个文件夹里是该类别的 PNG 图片命名如00000_00000.png每个类别目录下有一个GT-00000.csv字段是Filename;Width;Height;ROI.x1;ROI.y1;ROI.x2;ROI.y2;ClassId分号分隔。测试集是GTSRB/Final_Test/Images/加一个GT-final_test.csv。很多人第一次翻车就翻在这里直接pd.read_csv默认逗号分隔读出来只有一列后面全乱。正确做法是显式指定sep;。另一个容易忽略的点是 ROI 字段。GTSRB 的图片四周有约 10% 的边框是无关背景官方建议按 ROI 裁剪后再送入网络。裁剪能显著减少背景干扰尤其是那些把天空、树木、车身一起拍进去的样本。我一般会在读取阶段就把 ROI 裁掉而不是留到增强阶段因为 ROI 是标注信息属于「已知的干净区域」先裁掉最省事。import os import pandas as pd from PIL import Image def load_gtsrb_split(root, csv_name): 读取 GTSRB 某个 split 的标注返回 (图片路径, 类别, ROI) 列表 records [] # 训练集每个类别目录下都有独立 csv测试集是单个 csv if os.path.isdir(os.path.join(root, Final_Training)): base os.path.join(root, Final_Training, Images) for cls_dir in sorted(os.listdir(base)): cls_path os.path.join(base, cls_dir) csv_path os.path.join(cls_path, fGT-{cls_dir}.csv) # 关键GTSRB 用分号分隔不是逗号 df pd.read_csv(csv_path, sep;) for _, row in df.iterrows(): img_path os.path.join(cls_path, row[Filename]) roi (row[ROI.x1], row[ROI.y1], row[ROI.x2], row[ROI.y2]) records.append((img_path, int(row[ClassId]), roi)) else: base os.path.join(root, Final_Test, Images) df pd.read_csv(os.path.join(root, csv_name), sep;) for _, row in df.iterrows(): img_path os.path.join(base, row[Filename]) roi (row[ROI.x1], row[ROI.y1], row[ROI.x2], row[ROI.y2]) records.append((img_path, int(row[ClassId]), roi)) return records这段代码的逻辑是训练集按类别目录遍历每个目录读自己的 CSV测试集读单个 CSV。sep;是必须的ClassId转 int 是为了后面做 one-hot 或交叉熵。ROI 用四元组保存裁剪时直接img.crop(roi)。参数上root指向解压后的 GTSRB 根目录csv_name测试集传GT-final_test.csv。2.2 尺寸统一为什么是 32×32以及怎么裁GTSRB 原图尺寸差异极大CNN 的全连接层要求固定输入所以必须统一。业界最常用的两个尺寸是 32×32 和 48×48。32×32 是 LeNet 风格训练快、显存小配合好的增强能到 97% 左右48×48 保留更多细节适合更深的网络但训练成本翻倍。我一般先用 32×32 跑通 baseline确认流程无误后再上 48×48 调精度。统一尺寸有两种做法直接resize会拉伸变形因为原图长宽比不同先按 ROI 裁剪再resize能减少变形但 ROI 本身长宽比也不一致。更稳的做法是「保持长宽比缩放 居中填充」把 ROI 区域按长边缩放到目标尺寸短边用灰色或黑色填充。交通标志本身接近正方形这个策略对形状的保真度最好。import numpy as np def preprocess_image(img_path, roi, size32, pad_color(128, 128, 128)): img Image.open(img_path).convert(RGB) img img.crop(roi) # 先裁掉无关边框 w, h img.size scale size / max(w, h) new_w, new_h int(round(w * scale)), int(round(h * scale)) img img.resize((new_w, new_h), Image.BILINEAR) # 居中填充到正方形避免拉伸变形 canvas Image.new(RGB, (size, size), pad_color) canvas.paste(img, ((size - new_w) // 2, (size - new_h) // 2)) arr np.asarray(canvas, dtypenp.float32) / 255.0 return arr逻辑说明crop(roi)去掉背景scale按长边算保证不超出目标框BILINEAR在缩小场景下比NEAREST平滑、比BICUBIC快。填充色选 128 灰是因为交通标志背景多为灰白灰色填充对模型干扰最小。归一化到 [0,1] 是常规操作后面如果要用 ImageNet 预训练权重需要改成按均值方差标准化。2.3 类别不均衡GTSRB 的长尾怎么处理GTSRB 43 类里样本最多的类有 2000 多张最少的类只有 200 多张比例接近 10:1。直接训练会让模型偏向多数类少数类召回率惨不忍睹。常见做法有三种过采样少数类、给损失函数加类别权重、用数据增强扩充少数类。我一般组合使用训练时用WeightedRandomSampler让每个 batch 的类别分布接近均匀同时损失函数里再给少数类更高权重。两者叠加不会冲突反而更稳。from torch.utils.data import WeightedRandomSampler import numpy as np def make_sampler(labels): labels np.array(labels) class_counts np.bincount(labels, minlength43) # 每个样本的权重 该类样本数的倒数 class_weights 1.0 / np.maximum(class_counts, 1) sample_weights class_weights[labels] sampler WeightedRandomSampler( weightssample_weights, num_sampleslen(sample_weights), replacementTrue ) return sampler参数说明class_counts统计每类数量class_weights取倒数让少数类权重高replacementTrue表示有放回采样保证少数类能被反复抽到。num_samples设成训练集大小这样一个 epoch 的样本量和原来一致只是分布被拉平了。注意验证集和测试集绝对不能用这个 sampler否则评估结果失真。3. 用 PyTorch 搭一个能打的 CNN结构、训练与调参3.1 网络结构从 LeNet 到 VGG 风格的取舍交通标志识别不需要 ResNet 那么深43 类、几万张图一个 5 到 8 层的卷积网络足够。我常用的结构是 VGG 风格的堆叠两组Conv-BN-ReLU-Conv-BN-ReLU-MaxPool再接三组Conv-BN-ReLU加池化最后全局平均池化或展平接全连接。BN 是必须的GTSRB 光照差异大BN 能显著稳定训练。Dropout 放在全连接前比例 0.5。import torch import torch.nn as nn class TrafficSignCNN(nn.Module): def __init__(self, num_classes43): super().__init__() def 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) ) self.features nn.Sequential( block(3, 32), # 32x32 - 16x16 block(32, 64), # 16x16 - 8x8 block(64, 128), # 8x8 - 4x4 ) self.classifier nn.Sequential( nn.AdaptiveAvgPool2d(1), nn.Flatten(), nn.Dropout(0.5), nn.Linear(128, num_classes) ) def forward(self, x): return self.classifier(self.features(x))逻辑说明每个 block 里两个 3×3 卷积加 BN 加 ReLU再池化这是 VGG 的核心思想——用小卷积核堆深度参数量比 5×5、7×7 少非线性更强。AdaptiveAvgPool2d(1)把 4×4×128 压成 1×1×128替代大展平层参数量从几百万降到 128×43过拟合风险大幅下降。num_classes43对应 GTSRB 类别数。如果输入改成 48×48这个结构不用动池化三次后是 6×6自适应池化照样工作。3.2 训练循环学习率、优化器和早停优化器用 AdamW学习率 1e-3权重衰减 1e-4。AdamW 比 Adam 的权重衰减更规范泛化略好。学习率调度用CosineAnnealingLR从 1e-3 余弦降到 1e-5比阶梯下降平滑。batch size 用 128显存不够就 64。训练 30 到 50 个 epoch配合早停验证集准确率 10 个 epoch 不升就停。from torch.optim import AdamW from torch.optim.lr_scheduler import CosineAnnealingLR from torch.utils.data import DataLoader def train_one_epoch(model, loader, optimizer, criterion, device): model.train() total_loss, correct, total 0.0, 0, 0 for imgs, labels in 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 imgs.size(0) return total_loss / total, correct / total # 组装 device torch.device(cuda if torch.cuda.is_available() else cpu) model TrafficSignCNN(43).to(device) criterion nn.CrossEntropyLoss(label_smoothing0.1) optimizer AdamW(model.parameters(), lr1e-3, weight_decay1e-4) scheduler CosineAnnealingLR(optimizer, T_max40)参数说明label_smoothing0.1是防止模型对训练标签过度自信GTSRB 有些标注本身有歧义比如不同限速牌在低分辨率下难分平滑能提升泛化。T_max40要和总 epoch 数一致否则余弦曲线不完整。CosineAnnealingLR每个 epoch 调一次不是每个 batch。如果验证 loss 开始上升而训练 loss 还在降就是过拟合该早停或加增强。3.3 数据增强哪些增强对交通标志有效哪些是负作用交通标志的增强要贴合真实工况轻微旋转±15°、平移±10%、缩放0.9~1.1、亮度对比度扰动、小角度透视变换。水平翻转要慎用因为部分标志左右翻转后语义变了比如某些方向指示牌但 GTSRB 里大部分是限速和禁令牌翻转影响不大我一般不开翻转避免引入噪声。颜色抖动幅度别太大交通标志的红蓝黄是核心特征抖过头反而掉点。import torchvision.transforms as T train_tf T.Compose([ T.ToPILImage(), T.RandomAffine(degrees15, translate(0.1, 0.1), scale(0.9, 1.1)), T.ColorJitter(brightness0.3, contrast0.3, saturation0.2), T.ToTensor(), ])逻辑说明RandomAffine一次性做旋转、平移、缩放比分开写效率高。ColorJitter的 brightness 和 contrast 给 0.3saturation 给 0.2是经验值再大就会把红色限速牌洗成粉色。注意增强只在训练集用验证和测试用纯 resize 归一化。如果用了 ImageNet 预训练权重这里要换成Normalize(mean, std)。4. 评估、可视化与踩坑排查4.1 别只看总体准确率混淆矩阵和每类召回GTSRB 测试集跑完总体准确率 97% 听起来不错但可能某几个类召回只有 80%。必须打印混淆矩阵重点看哪些类互相混淆。常见混淆对是「限速 30 和限速 50」「限速 80 和限速 100」因为低分辨率下数字形状接近。还有「禁止超车」和「禁止机动车」这类形状相似的。看混淆矩阵能直接告诉你该补哪类数据、该不该上更大输入尺寸。from sklearn.metrics import confusion_matrix, classification_report def evaluate(model, loader, device): model.eval() all_preds, all_labels [], [] with torch.no_grad(): for imgs, labels in loader: imgs imgs.to(device) preds model(imgs).argmax(1).cpu().numpy() all_preds.extend(preds) all_labels.extend(labels.numpy()) print(classification_report(all_labels, all_preds, digits4)) cm confusion_matrix(all_labels, all_preds) return cmclassification_report给出每类的 precision、recall、f1digits4保留四位小数方便对比。混淆矩阵cm[i][j]表示真实类 i 被预测成类 j 的次数对角线是正确数。如果某类 recall 低于 0.9优先查该类训练样本数是不是太少其次查增强是不是过强。4.2 训练不收敛、loss 震荡、准确率卡住三类高频故障现象一loss 一直是 2.5 左右不降。原因通常是数据标签没对上或者归一化没做。GTSRB 的 CSV 里ClassId从 0 开始如果你自己映射错了标签全乱。解决随机抽 10 张图打印标签和图片肉眼确认。另一个原因是学习率太大1e-3 对某些初始化会炸降到 1e-4 试。现象二训练准确率涨但验证不涨。典型过拟合。解决加 Dropout、加权重衰减、加数据增强、减网络宽度。GTSRB 训练集 5 万张如果模型参数量超过 500 万很容易过拟合。我一般把参数量控制在 100 万到 300 万之间。现象三准确率卡在 90% 上不去。多半是输入尺寸太小或增强不够。32×32 的天花板大概 97%如果卡在 90%先检查是不是没裁 ROI背景干扰太大再检查类别权重是不是没加少数类拖后腿。换成 48×48 输入通常能再涨 1 到 2 个点。4.3 推理部署时的三个注意点第一训练时的预处理必须和推理时完全一致。训练用了 ROI 裁剪 居中填充推理时也要一样否则分布偏移准确率断崖。第二模型保存要用state_dict而不是整个模型方便跨设备加载。第三如果部署到 CPU 推理记得model.eval()和torch.no_grad()否则 BN 和 Dropout 行为不对结果随机。# 保存 torch.save(model.state_dict(), gtsrb_cnn.pth) # 加载 model TrafficSignCNN(43) model.load_state_dict(torch.load(gtsrb_cnn.pth, map_locationcpu)) model.eval()map_locationcpu保证在没 GPU 的机器上也能加载。eval()切换 BN 用滑动统计量、关闭 Dropout。这两步漏一个推理结果就会飘。5. 把准确率从 97% 推到 99%几个我反复验证过的技巧第一个技巧是测试时增强TTA。对同一张测试图做几个轻微变换比如原图、旋转 5°、旋转 -5°、亮度 10%分别推理后把 softmax 概率平均取 argmax。这个操作不增加训练成本推理慢几倍但通常能涨 0.5 到 1 个点。GTSRB 测试集只有 1.2 万张TTA 完全跑得动。def predict_tta(model, img_tensor, device): model.eval() augs [ img_tensor, T.functional.rotate(img_tensor, 5), T.functional.rotate(img_tensor, -5), T.functional.adjust_brightness(img_tensor, 1.1), ] probs [] with torch.no_grad(): for a in augs: logits model(a.unsqueeze(0).to(device)) probs.append(torch.softmax(logits, dim1)) return torch.stack(probs).mean(0).argmax(1)逻辑是每个增强版本出一个概率分布平均后更稳。注意旋转用T.functional.rotate要传 tensor且角度别超过 10°否则标志变形反而掉点。第二个技巧是用 48×48 输入 更深网络。32×32 的信息量对「限速 30 vs 50」这种细粒度分类已经接近极限。换成 48×48网络再加一个 block128→256准确率通常能到 98.5% 以上。代价是训练时间翻倍显存占用增加但 GTSRB 规模不大单卡 8G 显存足够。第三个技巧是类别权重和采样器二选一别同时拉满。我早期两个都开到最大结果少数类过采样太狠模型对少数类过拟合验证集反而降了。后来改成采样器拉平到 1:3 以内损失函数权重只给 2 倍效果最稳。这个度需要根据你的验证集表现微调。配置输入尺寸参数量测试准确率训练时长单卡baseline32×32约 120 万96.8%15 分钟 采样器 标签平滑32×32约 120 万97.5%15 分钟 TTA32×32约 120 万98.1%推理 4 倍48×48 加深48×48约 280 万98.9%40 分钟48×48 加深 TTA48×48约 280 万99.2%推理 4 倍这张表是我在不同阶段实测的汇总硬件是单张消费级显卡。可以看到从 96.8% 到 99.2%每一步都有明确代价没有免费的午餐。如果你的项目对实时性要求高32×32 TTA 是性价比最高的组合如果追求极致精度且不赶时间48×48 加深网络是正路。最后说个血泪经验别在测试集上调参。我见过有人反复在 GTSRB 测试集上试不同增强、不同学习率最后报了个 99.5%但换个数据集就崩。正确做法是从训练集里切 10% 做验证集所有调参在验证集上做测试集只在最后跑一次。GTSRB 官方测试集就 1.2 万张跑一次几分钟忍住别多跑。这个习惯比任何技巧都值钱。希望帮到你。本文还有配套的精品资源点击获取
返回列表