ARTICLE DETAIL

资讯详情

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

GTSRB交通标志识别实战:CNN训练、避坑与轻量化部署

GTSRB交通标志识别实战:CNN训练、避坑与轻量化部署 简介这是一套面向智慧交通场景的卷积神经网络交通标志识别实践项目适合具备一定图像处理与深度学习基础、希望系统完成分类任务的学习者。项目基于GTSRB公开数据集覆盖43类德国交通标志完整包含数据预处理、模型搭建、训练验证与测试评估等主要环节可帮助读者快速掌握卷积神经网络在交通场景中的应用流程。压缩包共8个文件包括5个Python脚本分别负责数据读取、网络定义、训练、评估等、2个CSV标注文件以及1个XML配置信息整体仅310KB轻量且易于直接运行和二次开发。代码从数据输入到结果评估形成完整链路目录结构清晰借助TensorFlow、Keras或PyTorch均可快速适配。目前已有623人学习使用适合作为课程设计、毕业设计或入门深度学习的实战参考。1. 用CNN识别交通标志为什么GTSRB是入门智慧交通的首选数据集很多第一次做智慧交通项目的人都会从车牌识别开始但车牌类别少、背景干净而交通标志多达43类不少类别只差一个红斜杠或数字对CNN的特征提取能力是更真实的考验。我建议直接拿GTSRB这个公开数据集做一遍它是德国交通标志识别基准包含5万多张真实街景图像正好覆盖了智慧交通场景里目标小、光照乱、类别细的典型痛点。这份记录不堆概念就是一套能从零跑通的做法从解压数据集、预处理、搭建CNN、训练调参到评估避坑和轻量化部署每一步都写到能直接复现的程度。不管你是准备人工智能大作业还是想往深度学习CNN方向转行的工程师这条路都值得完整走一遍。2. 认识GTSRB.zip数据集的目录结构、标签映射与预处理要点2.1 解压后先别急着训练GTSRB的目录结构与样本分布标题里的GTSRB.zip是打包后的数据集从官方渠道解压后常见的结构是Training和Testing两大部分。Training目录下按类别编号分文件夹从00000到00042一共43类Testing下是和训练集独立的街景图像另外还有一份CSV格式的标注文件记录了每张测试图对应的类别和标志区域的外接框。先别急着写训练循环第一件该做的事是统计每个类目录下的图片数量GTSRB有一个反直觉的特点类别不平衡很明显样本最多的类超过2000张少的只有两百张左右。如果你直接用原始数据训练CNN会天然偏向样本多的类别少数类在验证集上几乎会被忽略。import os from collections import Counter train_dir GTSRB/Training counts Counter() for class_id in os.listdir(train_dir): path os.path.join(train_dir, class_id) if os.path.isdir(path): counts[class_id] len([f for f in os.listdir(path) if f.endswith(.ppm)]) print(样本最多的5类, counts.most_common()[:5]) print(样本最少的5类, counts.most_common()[-5:])这里用os.listdir遍历目录名就是类别编号直接转成int就是标签不需要额外解析。.ppm是GTSRB的原始图像格式OpenCV可以直接读。跑完这个统计你心里就有数了后面必须处理类别不平衡否则模型会偷懒学成永远猜多数类。GTSRB还有一个容易被忽略的问题原始图像尺寸从15乘15到250乘250不等因为标志在街景中的距离不同近的标志占画面大远的标志只有一小块。CNN输入要求固定尺寸所以统一resize是绕不开的一步。但直接resize会把小图放大造成模糊这里我一般会先看标注文件里的ROI区域也就是标志的外接框用它把标志先裁出来再调整尺寸。很多人图省事直接压全图结果模型学到的是标志周围的环境比如行道树、汽车、天空的纹理这在真实场景里一换背景就翻车。2.2 图像尺寸、亮度不均与类别不平衡预处理三件套预处理第一件是统一尺寸和颜色通道。我常用的做法是读取ROI后留一点边距再缩放到48乘48。OpenCV读图默认是BGR通道顺序训练时用RGB还是BGR本身不影响精度但一定要在推理阶段保持一致否则模型在测试集上会表现得很迷惑。import cv2 import numpy as np import pandas as pd def crop_roi(img, row, margin0.1): x1, y1, x2, y2 row[ROI.X1], row[ROI.Y1], row[ROI.X2], row[ROI.Y2] h, w y2 - y1, x2 - x1 mx, my int(w * margin), int(h * margin) x1, y1 max(0, x1 - mx), max(0, y1 - my) x2, y2 min(img.shape[1], x2 mx), min(img.shape[0], y2 my) return img[y1:y2, x1:x2] def load_gtsrb(data_dir, target_size(48, 48)): images, labels [], [] for class_id in sorted(os.listdir(data_dir)): class_path os.path.join(data_dir, class_id) if not os.path.isdir(class_path): continue for fname in os.listdir(class_path): if not fname.endswith(.ppm): continue img cv2.imread(os.path.join(class_path, fname)) img cv2.cvtColor(img, cv2.COLOR_BGR2RGB) img cv2.resize(img, target_size) images.append(img) labels.append(int(class_id)) return np.array(images), np.array(labels)crop_roi里的margin参数是给标志四周留一点上下文防止裁剪时把标志边缘的边框切破。target_size(48, 48)是我在GTSRB上常用的折中32乘32训练更快但小标志上的数字笔画会被糊掉64乘64信息更足但模型参数量变大训练时间也变长。48乘48在准确率和资源之间比较平衡。第二件是归一化。图像像素范围是0到255直接喂给网络会让梯度尺度偏大训练初期loss容易震荡。我一般直接除以255把范围压到0到1。对GTSRB来说减均值除方差这种标准化收益不大因为这是自然图像不像医学影像那样有固定的灰度范围简单归一化就够了。第三件是数据增强。GTSRB很多图像是逆光、过曝或者欠曝的真实场景里标志也会因视角产生轻微变形所以增强要重点模拟这些变化。from tensorflow.keras.preprocessing.image import ImageDataGenerator datagen ImageDataGenerator( rotation_range15, width_shift_range0.1, height_shift_range0.1, brightness_range[0.7, 1.3], shear_range0.05, zoom_range0.1, fill_modenearest )这里最关键的一条是不要开horizontal_flip。交通标志有方向性向左转弯的标志水平翻转后变成向右转弯但标签没变等于给模型喂错样本。ImageNet分类任务里水平翻转是标配但在GTSRB场景下必须关掉这是很多人踩过的坑。类别不平衡的处理可以在训练时给损失函数加权也可以在数据上做重采样。我习惯用sklearn直接算每个类的权重传到model.fit里这样不需要额外改动数据分布。from sklearn.utils.class_weight import compute_class_weight class_weights compute_class_weight( balanced, classesnp.unique(y_train), yy_train ) class_weight_dict dict(enumerate(class_weights))balanced模式会按总样本数除以类别数再除以该类样本数计算权重少数类的loss被放大模型就不会只顾着猜多数类了。3. 搭建CNN识别交通标志从LeNet到残差块的选型与实现3.1 为什么不用现成预训练模型先看GTSRB图像尺寸很多用深度学习CNN的新手上来就想选ResNet50或者EfficientNet因为它们在ImageNet上表现好迁移学习好像只要换一下最后一层就行。但在GTSRB场景里这个选择并不明智。GTSRB训练图像只有5万张类别43个从零训练一个小网络在普通GPU上几分钟就收敛而预训练模型要求输入224乘224GTSRB很多原始标志只有二三十像素放大到224乘224之后边缘全是锯齿预训练模型学到的纹理特征反而对不上。数据量也不支持太深的网络。5万张图去训练一个ResNet50即使加载预训练权重也有大量参数需要微调很容易在小样本类别上过拟合。我做这个项目实践的原则是先让一个几层的CNN在验证集上跑出95%以上的准确率再考虑要不要加深。GTSRB的价值恰恰在于它能让一个3层卷积的小网络达到接近可用的水平非常适合用来理解卷积层、批归一化、Dropout这些组件的实际作用也符合人工智能项目实践的核心目的。另一个容易忽略的选型因素是感受野。输入48乘48的图像三层3乘3卷积配合池化最后一层卷积输出的特征已经覆盖了整个标志区域足够捕捉红斜杠数字箭头这类局部结构。如果第一层就用7乘7大卷积核参数多不说小目标的细节反而会被过早压缩掉。3.2 一个能跑通的最小CNN结构与Keras实现下面这个结构是我在GTSRB上反复用过的基准网络准确率能到97%左右结构简单每层作用也很清楚from tensorflow.keras.models import Sequential from tensorflow.keras.layers import Conv2D, MaxPooling2D, Flatten, Dense, Dropout, BatchNormalization model Sequential([ Conv2D(32, (3, 3), activationrelu, input_shape(48, 48, 3), use_biasFalse), BatchNormalization(), MaxPooling2D((2, 2)), Conv2D(64, (3, 3), activationrelu, use_biasFalse), BatchNormalization(), MaxPooling2D((2, 2)), Conv2D(128, (3, 3), activationrelu, use_biasFalse), BatchNormalization(), MaxPooling2D((2, 2)), Flatten(), Dense(256, activationrelu), Dropout(0.5), Dense(43, activationsoftmax) ])我把BatchNormalization放在卷积之后、激活之前的写法更常见但上面代码里用的是先激活再归一化实际效果在GTSRB上也稳定因为BN的目的就是稳定分布放在激活前后都可以只是收敛速度略有差异。use_biasFalse是因为BN层自带偏置卷积层再保留bias就冗余了参数能省则省。卷积核数量从32增加到128是因为特征图尺寸逐步减小通道数增加能让网络组合更多高层特征。三层池化之后48乘48的输入变成6乘6Flatten后有128乘以6乘6共4608个特征接256维全连接层这一层的容量决定了模型能不能记住类别之间的细微差别。Dropout 0.5是为了防止全连接层过拟合训练集上表现太好但验证集掉点的时候这个参数最值得先调。这层配置可以整理成一张参数表训练时心里会清楚每一层的形状变化层输出尺寸作用Conv2D 3248x48x32提取边缘、角点、颜色块BatchNormalization48x48x32稳定中间分布MaxPooling2D24x24x32降低分辨率Conv2D 6424x24x64组合局部纹理BatchNormalization24x24x64稳定中间分布MaxPooling2D12x12x64降低分辨率Conv2D 12812x12x128提取形状级特征BatchNormalization12x12x128稳定中间分布MaxPooling2D6x6x128降低分辨率Flatten4608展平Dense 256256全局特征组合Dropout 0.5256防过拟合Dense 4343分类输出3.3 训练参数学习率、batch size、epochs怎么设网络结构只是第一步训练参数才是新手最容易玄学的地方。GTSRB不是大型数据集我一般用Adam优化器初始学习率1e-3batch size设64epochs设30同时用早停和学习率衰减兜底from tensorflow.keras.optimizers import Adam from tensorflow.keras.callbacks import EarlyStopping, ReduceLROnPlateau model.compile( optimizerAdam(learning_rate1e-3), losscategorical_crossentropy, metrics[accuracy] ) early_stop EarlyStopping(monitorval_loss, patience5, restore_best_weightsTrue) reduce_lr ReduceLROnPlateau(monitorval_loss, factor0.5, patience2, min_lr1e-5) history model.fit( datagen.flow(X_train, y_train_onehot, batch_size64), validation_data(X_val, y_val_onehot), epochs30, callbacks[early_stop, reduce_lr], class_weightclass_weight_dict )学习率是最先要检查的参数。直接用Adam默认的1e-3在GTSRB上基本能收敛但如果验证集loss在某个值附近来回震荡先把学习率降到1e-4比调任何网络结构都管用。batch size 64对48乘48的输入很稳定显存不够就减到32太大反而容易收敛到尖锐的极小值。patience5的意思是5个epoch验证集loss没有改善就停ReduceLROnPlateau则是2个epoch没改善就把学习率减半两者配合实际跑到15到20个epoch通常就停了。class_weight的作用前文提过这里再强调GTSRB样本最多的类是样本最少类的近10倍如果不加权少数类即使被全错loss也只贡献很小一部分模型完全不会关心它们。加上class_weight_dict之后少数类每个样本的loss被放大梯度更新时就不会被多数类淹没。这个参数是GTSRB训练和一般图像分类最大的区别之一。4. 从训练到评估验证集划分、准确率陷阱与混淆矩阵4.1 官方划分之外的验证集策略GTSRB官方虽然给了Training和Testing两个集合但Testing是考场只能用来做最终考试不能边调参边看分数。很多人会直接把整个Training当训练集Testing当验证集每个epoch跑完都去测试集上看一下这其实是给模型漏题。测试集的分布和训练集不完全一样它是不同时间、不同地点采集的反复用测试集调参模型会间接记住测试集的噪声特征最后看起来分数很高一到真正部署在别的路段就掉链子。我的做法是在Training内部切出一部分做验证集调参只认验证集Testing只在所有实验做完后跑一次。from sklearn.model_selection import train_test_split X_train, X_val, y_train, y_val train_test_split( X, y, test_size0.2, stratifyy, random_state42 )stratifyy是必须的它保证切分后的验证集和训练集里每个类别的比例和原始数据一致。GTSRB类别不平衡如果不分层有可能某一个少数类的样本全都跑到了验证集训练时那个类一个样本都见不到验证时这个类的准确率必然是0。random_state42是为了让切分可复现换机器或换环境后结果还能对齐这对后期对比实验非常重要。还有一点容易被忽略train_test_split默认会先shuffle再切分保留这个默认行为。如果你数据是按目录顺序读进来的前几个类的样本集中在文件头部不shuffle的话验证集就会全是前几个类模型在剩下类别上基本没验证过。4.2 准确率虚高按类别看召回率训练结束后很多人只看model.evaluate打出来的总体准确率比如99.5%就觉得模型完美了。但在GTSRB这样的细粒度分类任务里总体准确率会掩盖大量问题。正确做法是打印每个类别的精确率、召回率和F1分数from sklearn.metrics import classification_report, confusion_matrix import numpy as np y_pred model.predict(X_val, batch_size64) y_pred_cls np.argmax(y_pred, axis1) print(classification_report(y_val, y_pred_cls, digits3))你会立刻看到大部分类别F1分数在0.98以上但个别类别可能只有0.7左右而这些往往就是样本数少或者形状相近的标志。GTSRB里最经典的混淆对是限速50和限速80区别只在数字笔画的位置放大到48乘48后数字区域只有几个像素宽模型稍微丢掉一点细节就会认错。还有一种坑是优先通行和停车让行这类形状相似的标志虽然一个倒三角一个正八边形但在远距离小目标图里轮廓容易被池化层抹平。想快速定位最严重的混淆组合用混淆矩阵扫一遍cm confusion_matrix(y_val, y_pred_cls) for i in range(43): for j in range(43): if i ! j and cm[i, j] 20: print(f真实类别{i}被预测成{j}: {cm[i, j]}次)这里cm[i, j]表示真实类别i被模型预测成j的次数。你会在输出里看到那些易混淆的对比如限速标志之间、禁止超车和解除禁止超车之间。解决思路不是盲目换网络而是针对这些难分类别做数据增强或者把它们单独抽出来做一个二分类细分类器。这一步做完你才真正知道模型学到了什么。5. GTSRB实战避坑5个让模型翻车的常见问题5.1 现象验证集准确率很高测试集却崩了这是GTSRB项目最常见的翻车现场。训练集上98%验证集96%一跑官方测试集只有85%。原因几乎总是把测试集当验证集使用或者验证集划分不随机。测试集是从不同街景、不同光照条件下采集的和训练集存在明显的分布差异行业里管这个叫domain shift。反复拿测试集调参模型的无形中就在拟合测试集的背景噪声一旦换到新路段准确率立刻现原形。解决方法是给数据划分立规矩训练集内部切出验证集调参只允许看验证集测试集只在所有实验结束后跑一次跑完记录结果就封存。如果测试集分数不理想你也只能当场改完再用验证集重实验不能再碰测试集哪怕心里痒也要忍住。这算是我的一点血泪经验。5.2 现象loss震荡不收敛准确率卡在90%训练到十几个epoch后loss像过山车一样上下跳准确率停在90%附近怎么都上不去。最常见的原因是学习率太高或者像素没有归一化。0到255的像素值直接输入梯度范数偏大Adam虽然能自适应但很容易在损失面比较陡的区域震荡。少数类样本出现次数少还会带来梯度波动让loss在局部极小点附近来回穿越。解决分两步。先确认X_train X_train.astype(float32) / 255.0这一步没落下然后把学习率从1e-3降到1e-4配合ReduceLROnPlateau让loss到平台后自动减速。如果还在震荡给损失函数加一点平滑也有用from tensorflow.keras.losses import CategoricalCrossentropy model.compile( optimizerAdam(learning_rate1e-4), lossCategoricalCrossentropy(label_smoothing0.1), metrics[accuracy] )label_smoothing让softmax输出的目标不再是非0即1的极端分布模型对噪声标注的容忍度更高GTSRB测试集精度通常能再提升一点。5.3 现象误把图片边缘的黑色边框当特征有些模型在验证集上不错但一旦把标志从街景里单独截出来测试准确率暴跌原因是它学会的是图片边缘的黑色区域而不是标志本身。GTSRB原始图像里很多带相机边框、车窗边缘或者暗角如果resize时直接把全图压缩这些黑色边缘会被稳定地变成固定形状的暗边CNN发现只要看到这个暗边就能猜对类别自然不去学标志内部结构。解决办法是预处理时利用ROI信息把标注框内的标志裁出来再缩放代码在2.2节已经给了。如果不想解析CSV也可以先做中心裁剪去掉靠近图像边缘的8%到10%像素大多数边框影响就能消除。更彻底的做法是训练时的随机增强加入random_crop让标志在画面里的位置每秒都变模型就没办法靠固定边框位置作弊了。5.4 现象类别不均衡导致少数类全错GTSRB样本最多的类2000多张最少的只有两百张训练出来的模型对少数类几乎不敏感。最严重时某些类别在classification_report里精确率和召回率都是0而总体准确率仍然有96%因为它没怎么把样本分到那些少数类里去。解决除了class_weight还可以对少数类做重采样。把少数类图片复制几份凑到接近多数类样本量但直接复制会过拟合所以复制的同时要配合轻度旋转和亮度变化# 简化版少数类样本重复到与多数类接近 target_count max(Counter(y_train).values()) for cls, cnt in Counter(y_train).items(): if cnt target_count * 0.5: indices np.where(y_train cls)[0] repeats int(target_count / cnt) # 对indices中的样本做随机增强后加入训练集这个思路比单纯复制更稳因为增强后的图不完全一样。如果追求更好效果可以用Focal Loss让模型把注意力放在难分类的少数类上但实现复杂度高一些对新手不友好。我的建议是先用class_weight如果少数类F1还是不达标再上重采样。5.5 现象模型太大显存不够不少人改网络结构时习惯从ResNet50或者VGG16的配置里抄通道数第一层卷积就128个通道激活函数输出占大量显存batch_size稍微大一点就OOM。GTSRB图像只有48乘48不需要那种容量强行上大网络只会让训练变慢准确率也不会更高。解决方案直接又简单把输入尺寸降到32乘32batch size降到32或16第一层卷积核从32降到16其他层按比例减半。如果这样还想用更大的batch可以用梯度累积累积4个batch的梯度再更新一次权重等效batch size放大4倍显存占用却不变。实际上在GTSRB这个数据规模下把网络缩到原来的四分之一准确率通常只掉零点几个点训练时间却能缩短一大半性价比很高。6. 让模型在智慧交通场景里真正可用轻量化与鲁棒性验证6.1 把模型压到能跑在嵌入式设备上GTSRB训练出的模型如果只在电脑上跑那还停留在作业阶段。智慧交通场景里的落地位置一般是路侧边缘设备或车载计算单元算力和内存都有限。常见做法是把训练好的Keras模型转成TensorFlow Lite并量化让权重从float32变成uint8体积缩小到原来四分之一精度通常只掉1到2个百分点import tensorflow as tf converter tf.lite.TFLiteConverter.from_keras_model(model) converter.optimizations [tf.lite.Optimize.DEFAULT] tflite_model converter.convert() with open(gtsrb_cnn.tflite, wb) as f: f.write(tflite_model)转换完成之后一定要做一件事用真实图片验证TFLite输出和原来Keras模型的输出是否一致。量化后的模型对输入数据的均值和方差更敏感如果原来预处理是除以255推理代码里也必须做同样的除法一个像素差距就会让输出概率分布完全变样。我见过很多次模型在电脑上97%放到板子上只有70%最后发现是预处理不一致。6.2 用GTSRB训练后的模型做真实场景雨雾鲁棒性验证智慧交通设备不会总在晴天运行雨雾、夜间逆光都会让交通标志模糊。GTSRB本身有光照变化但不足以模拟雨雾天气。我习惯用一张测试图叠加噪声和亮度扰动快速验证模型的鲁棒性def simulate_rain(img, strength0.3): noise np.random.normal(0, 255 * strength, img.shape).astype(float32) return np.clip(img.astype(float32) noise, 0, 255).astype(uint8) rain_images np.array([simulate_rain(x, 0.3) for x in X_test]) rain_pred model.predict(rain_images / 255.0)strength0.3相当于给每个像素叠加标准差为76的随机噪声接近暴雨中摄像头的噪声水平。跑完之后你会发现轻雾下准确率还能保持90%重度噪声下会跌到70%左右这时候就需要在训练增强里加入高斯噪声而不是去加深网络。深度CNN最怕的不是参数量少而是训练分布和测试分布不一致这项验证能帮你提前暴露这个问题。最后一件事是认清GTSRB的边界它是德国交通标志样式和国内标志有差异比如速度标志的数字字体不同部分禁令标志的样式也不一样。如果要在国内智慧交通项目里用正确做法是保留模型卷积层把最后的输出层换成国内标志类别数用国内数据微调。这正是人工智能正从尝鲜工具变日常帮手的典型路径小样本起步微调落地。我自己之前偷懒跳过了轻量化校验结果到现场板子上才翻车后来每次换平台都会先跑一遍同一张测试图对比输出。这个习惯希望帮到你。本文还有配套的精品资源点击获取
返回列表