☰
CNN交通标志分类实战:从数据预处理到模型部署全解析
2026/10/5 11:31:38 网站建设 项目流程

简介:面向智慧交通与交通物流场景的交通标志识别实战项目,聚焦CNN在图像分类任务中的落地应用,适合具备一定Python基础、正在学习深度学习的算法工程师、高校学生及竞赛参与者。压缩包共2000个文件,包含1994张PNG格式的交通标志图像作为训练与测试数据、3个XML文件以及3个Python脚本,整体大小约201.95MB。已有78人学习浏览,资源以项目实践为导向,可直接通过train.py --data_train、--data_test等参数启动训练,也可用predict.py对单张图片进行推理,覆盖数据组织、模型构建、训练验证、单图测试等关键环节。目录内图片文件按类别分目录存放,便于扩展或迁移到其他分类任务;同时保留脚本入口与模型输出参数,适合作为课程设计、毕业设计或交通标志分类方向的项目基线,帮助读者快速跑通流程并深入理解CNN识别原理。

1. 用 cNN 给交通标志分类:这个包为什么值得拆开看一遍

交通标志分类,是把“智慧交通”落地的第一站。别把它当入门级玩具任务——真动手的时候,30km/h 和 50km/h 两块限速牌远看几乎一样,红圈白底黑字,像素一压缩连边缘都糊在一起;模型结构或者预处理稍微差一点,准确率就卡在 85% 上下不动,改哪都像在碰玄学。这个包走的是一条完整的工程闭环:train.py做训练,predict.py做单张预测,数据集按 train/test 拆好,关键路径全部通过命令行参数暴露出来,不写死、不藏着。特别适合正在做人工智能课设、想找 CNN 实践抓手、或者想用最短路径理解“从图片到模型再回到单张图片”的从业者。它解决的不是“神经网络能不能认出路标”,而是“这一整套链路怎么搭才不翻车”。

2. 数据布局与命令行传参:先把路径和文件名的逻辑吃透,后面才不玄学

我拆项目有个习惯:先看文件和目录,不看代码。因为数据集的组织方式直接决定代码怎么写,而这个包的数据组织方式藏在 predict 命令里,不在 README 里。先把这块吃透,后面训练和推理的每一步才有依据。

2.1 文件名拆开看:标签藏在父目录里,不在文件名里

打开压缩包,第一眼看到的是01639_00000.png、01146_00000.png、01600_00000.png这类“数字_数字”的图片名。如果只看文件名,很容易以为前面那串数字就是类别:01639就是第 1639 类?不是的,别这么干。

真正决定标签的是父目录。predict 命令里写得很清楚:

python predict.py --model traffic_sign.model -i ./test/00000/00017_00000.png.png -s

这一行的结构是./test(测试集根目录)→00000(类别子目录)→00017_00000.png(图片文件)。也就是说,这张图属于第 0 类。类别信息写在父目录名上,文件名后半段(00000)只是这张图在原始采集流程里的编号或帧号,和分类任务无关。

所以训练时读数据的标准做法,是把父目录名转成 int 当标签:

import os import cv2 import numpy as np def load_images_from_class_dirs(base_dir, target_size=(32, 32)): X, y = [], [] for cls_name in sorted(os.listdir(base_dir)): cls_path = os.path.join(base_dir, cls_name) if not os.path.isdir(cls_path): continue for img_name in sorted(os.listdir(cls_path)): if not img_name.lower().endswith(".png"): continue img_path = os.path.join(cls_path, img_name) img = cv2.imread(img_path) if img is None: continue img = cv2.resize(img, target_size) X.append(img) y.append(int(cls_name)) # 类别ID来自父目录名 return np.array(X), np.array(y)

这段代码的逻辑很直白:os.listdir拿到的是字符"00000",转成int就是标签 0;图片全部 resize 到统一尺寸。关键点在于if img is None: continue——cv2.imread遇到损坏文件不会报错,只会返回None,不跳过的话下一步cv2.resize直接崩溃。

参数说明:base_dir传入./train或./test;target_size取(32, 32)是交通标志这类小目标的常见选择,再大就浪费算力,再小边缘细节就丢了。如果目录里混入非数字文件夹,建议顺手加一句if not cls_name.isdigit(): continue,防止把别的目录当类别扫进来。

2.2 命令行参数暴露路径:为什么 --data_train、--data_test 不写死更好

train.py 和 predict.py 都用了命令行参数而不是硬编码路径,这对复现非常友好。你在笔记本上调通以后,换台机器只需要改命令,不用翻开源码改路径。

train.py 的参数按这个套路组织:

import argparse ap = argparse.ArgumentParser(description="Traffic Sign Train Script") ap.add_argument("--data_train", required=True, help="训练集根目录,按类别分子目录存放") ap.add_argument("--data_test", required=True, help="验证集根目录,结构与训练集一致") ap.add_argument("--model", default="traffic_sign.model", help="模型保存路径") args = vars(ap.parse_args()) print("train dir:", args["data_train"]) print("test dir:", args["data_test"]) print("model :", args["model"])

--model给了默认值traffic_sign.model,所以只传数据集也能跑;--data_train和--data_test设成required=True,防止忘传以后在代码深处报一个莫名其妙的错。

predict.py 的参数设计也值得抄:

ap.add_argument("--model", required=True, help="模型文件路径") ap.add_argument("-i", "--image", required=True, help="单张图片路径") ap.add_argument("-s", "--show", action="store_true", help="是否弹窗显示结果图片")

-s是布尔开关:命令行里加了就是 True,不加就是 False。这种“开关型参数”在推理脚本里比传字符串--show True干净得多。

2.3 预处理三件套:尺寸、归一化、通道顺序,三处必须一致

训练和推理的预处理如果对不上,前面所有努力全部白费。我见过的项目里,最容易在这三个地方翻车:

第一,resize 尺寸必须一致。训练时缩到 32×32,推理时哪怕缩到 33×33,模型输入维度就报错。第二,归一化必须一致。训练时除以 255 缩放到 0-1,推理时忘了除,像素值就是 0-255,预测结果基本是乱的。第三,通道顺序必须想清楚。cv2.imread读进来是 BGR,matplotlib显示期望 RGB,颜色错乱不一定影响准确率,但中间一旦做了通道相关的数据增强,误差就会滚雪球。

一个典型预处理函数长这样:

def preprocess_image(image, target_size=(32, 32)): # cv2.imread 读出来的原始图,BGR 通道,0-255 image = cv2.resize(image, target_size) image = image.astype("float32") / 255.0 return image

逻辑说明:先astype("float32")再除 255,这两个顺序不能换。如果直接用 uint8 除以 255,Python 会做整数除法,所有结果不是 0 就是 1,等于把图片二值化,模型永远收敛不了。这个错很隐蔽,因为代码不报异常,只是 loss 掉得奇慢。

2.4 训练前必做的检查:类别分布和图片尺寸统计

很多人在训练脚本跑到一半才想起来数据集有问题,不如上来就打印一段统计信息。我一般在主流程前加三行:

from collections import Counter print("训练集类别分布:", Counter(y_train)) print("训练集图片尺寸:", X_train.shape, "像素范围:", X_train.min(), X_train.max())

Counter(y_train)直接告诉你每个类别多少张图,一眼看出有没有类别不平衡;像素范围打印出来,能确认前期预处理是否已经归一化。这一步只要 10 秒钟,能省掉后面至少半小时的玄学排查。

3. 训练链路全拆解:数据划分、CNN 结构选型与模型保存格式

train.py 的核心链路是:读数据 → 切验证集 → 搭模型 → 训练 → 保存。这五步每一步都有讲究,尤其最后一步模型保存格式,稍不注意就会变成坑。

3.1 数据划分:train_test_split 和 stratify=labels 的含义

--data_train 指向的是完整训练集,但训练时不能把所有图都拿去拟合,必须留出一部分当验证集,否则你无法判断模型是“真学会了”还是“背下来了”。这里建议直接用 scikit-learn 的切分函数:

from sklearn.model_selection import train_test_split X_train, X_val, y_train, y_val = train_test_split( images, labels, test_size=0.2, random_state=42, stratify=labels )

参数说明:test_size=0.2表示留 20% 当验证集;random_state=42固定随机种子,保证每次跑出来的划分一致,方便对比实验;stratify=labels是最容易被忽略的一个参数,它让划分后的训练集和验证集里,每个类别的比例和原始数据集保持一致。如果不加,类别少的标志可能在验证集里一张都不出现,准确率指标就失真了。

3.2 CNN 结构选型:小数据集别迷恋深网络,两层卷积加批归一化优先

这个场景下数据集不大,图片又是标志这类结构清晰的目标,我一般不推荐直接抄 ResNet 或者 VGG,参数多、训练慢、还容易过拟合。更稳妥的是一套轻量 CNN:两层卷积 + 池化 + 批归一化 + 全连接。

from tensorflow.keras.models import Sequential from tensorflow.keras.layers import ( Conv2D, MaxPooling2D, Flatten, Dense, Dropout, BatchNormalization ) def build_model(input_shape=(32, 32, 3), num_classes=43): model = Sequential([ Conv2D(32, (3, 3), activation="relu", input_shape=input_shape), BatchNormalization(), MaxPooling2D(pool_size=(2, 2)), Conv2D(64, (3, 3), activation="relu"), BatchNormalization(), MaxPooling2D(pool_size=(2, 2)), Flatten(), Dense(128, activation="relu"), Dropout(0.5), Dense(num_classes, activation="softmax") ]) return model

选型理由:第一层卷积 32 个核,提取边缘、颜色、纹理这类低级特征;第二层卷积升到 64 个核,在中级特征层面做组合。两层足够,因为交通标志的判别信息集中在外圈形状和中心符号上,不需要深度的语义抽象。BatchNormalization放在卷积后面激活函数前面,是稳定训练的关键——我见过太多不加 BN 的项目,loss 曲线像心电图。Dropout(0.5)在全连接层防过拟合,小数据集尤其需要。

如果你不确定类别数量,可以用len(set(y_train))动态算,num_classes别写死。否则类别数不对,最后一层 softmax 输出维度会和标签对不上。

3.3 训练参数:epoch、batch_size、学习率和早停

模型编译和训练的常见写法如下:

model.compile( optimizer="adam", loss="sparse_categorical_crossentropy", metrics=["accuracy"] ) history = model.fit( X_train, y_train, validation_data=(X_val, y_val), epochs=15, batch_size=32, verbose=1 )

参数说明:sparse_categorical_crossentropy配合整数标签用,省去 one-hot 编码的步骤;adam的默认学习率是 0.001,对这个小数据集基本够用。epochs=15看起来不多,但对几千张图、两层卷积的规模,15 个 epoch 足够收敛;batch_size=32是内存和梯度稳定性的平衡点。如果训练到后面发现验证准确率停滞,常见做法是把 epochs 加到 30 并加上早停:

from tensorflow.keras.callbacks import EarlyStopping early_stop = EarlyStopping( monitor="val_accuracy", patience=3, restore_best_weights=True ) model.fit(X_train, y_train, validation_data=(X_val, y_val), epochs=30, batch_size=32, callbacks=[early_stop])

patience=3表示验证准确率连续 3 个 epoch 不涨就停,restore_best_weights=True会把权重回滚到最优的那一个 epoch,避免保存一个过拟合的中后段模型。

3.4 模型保存格式:traffic_sign.model 里到底存了什么

训练结束后的保存命令是:

model.save(args["model"])

这个.model后缀是自定义的。Keras 模型默认保存格式可能是 HDF5 或 SavedModel,但换成.model后缀不会改变内部结构。也就是说,traffic_sign.model本质上就是 Keras 保存的模型文件,加载时依然要用load_model,而不是pickle.load。

从 3.3 的代码来理解,save 保存的是三样东西:模型结构(各层配置)、训练好的权重、以及编译信息(优化器状态、loss 配置)。这也是为什么加载后可以直接model.predict,不用重新 compile。

这里隐含着第一个坑:跨机器复现时,Keras 版本不一致最容易让.model文件加载失败。后面避坑章节我会专门展开讲。

4. 单张预测链路:predict.py 从读图到输出置信度的四个环节

推理脚本看起来比训练简单,但它的容错设计更考验经验。predict.py 的几个关键环节,每个都不能跳过。

4.1 先加载模型,再读图片:顺序和错误处理都有讲究

import cv2 import numpy as np from tensorflow.keras.models import load_model model = load_model(args["model"]) image_path = args["image"] if not os.path.exists(image_path): print(f"[错误] 图片不存在: {image_path}") exit(1) image = cv2.imread(image_path) if image is None: print(f"[错误] 图片读取失败,可能是文件损坏: {image_path}") exit(1)

这里先加载模型,是为了让模型加载报错提前暴露。如果图片路径错了,模型加载成功但图片读取失败,错误信息会清楚很多。cv2.imread对不存在的路径不会抛异常,只会静默返回None,所以os.path.exists和is None两道检查都值得写。真实项目里,用户最容易错的就是路径少写一个斜杠、或者把类别目录写错。

4.2 推理预处理必须和训练严格对齐

模型加载好了,图片也读进来了,接下来这一步是整个 predict.py 的灵魂——预处理必须和训练时完全一致:

image = cv2.resize(image, (32, 32)) image = image.astype("float32") / 255.0 image = np.expand_dims(image, axis=0) # 增加 batch 维度 preds = model.predict(image)[0] idx = int(np.argmax(preds)) confidence = float(preds[idx]) print("预测类别:", idx, "置信度:", round(confidence, 4))

代码逻辑拆开看:resize 成 32×32,和训练时保持一致;astype("float32") / 255.0做归一化;np.expand_dims(image, axis=0)把形状从(32, 32, 3)变成(1, 32, 32, 3),因为模型接受的是“一批图片”的输入,单张图也要凑一个 batch 维度。model.predict(image)返回的是二维数组,[0]取第一张图的结果,np.argmax拿到概率最高的类别下标,preds[idx]就是模型对该类的置信度。

这是新手最容易漏的一步:忘了加 batch 维度,Keras 会报维度错误,但报错信息比较绕,不像 NumPy 那样直白。如果你看到 “expected 4 dimensions” 之类的提示,第一反应就应该是expand_dims没加。

4.3 -s 可视化开关:显示图片和标注,别把通道顺序搞乱

predict.py 的-s参数表示显示图片。常见的实现是在预测完成后把图片放大、写上预测结果再弹窗:

if args["show"]: show_img = cv2.resize(image[0], (200, 200)) cv2.putText(show_img, f"class: {idx}", (10, 25), cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 0, 255), 2) cv2.imshow("prediction", show_img) cv2.waitKey(0) cv2.destroyAllWindows()

这里有个小细节:image[0]在 resize 之后是float32且归一化到 0-1,直接cv2.imshow显示出来会是一片黑或者泛白。正确做法是先乘回 255 并转回uint8,再显示。代码里这一步容易漏,我习惯这样处理:

show_img = cv2.resize(image[0], (200, 200)) show_img = np.clip(show_img * 255.0, 0, 255).astype("uint8") # 还原显示范围

clip是为了防止浮点误差把像素值顶到 255 以上,显示出现白斑。

4.4 批量推理:predict 天然支持多张图,不用写循环

单张测试没问题后,如果你想验证整个 test 目录的准确率,不需要一张张调用 predict.py。Keras 的predict本身支持批量输入,直接把多张图堆成一个 batch:

X_batch = np.array([preprocess_image(cv2.imread(p)) for p in test_paths]) preds = model.predict(X_batch, batch_size=32) labels = np.argmax(preds, axis=1)

一行代码跑完整批测试。注意batch_size=32只是控制内存占用,不改变结果。这个批量用法在后面做混淆矩阵时也会用到。

5. 避坑地图:五个常见翻车点,照着排查省一下午

这一章是血泪经验的集中区。以下五个坑,每一个我都见过不止一次,也是这个项目复现过程中最常把人气到摔键盘的地方。

5.1 图片读取返回 None,崩在 resize 上

现象:训练或预测脚本跑到cv2.resize时突然抛出异常,提示cv2.error或者NoneType对象没有大小属性。

原因:cv2.imread读取失败的图片路径,返回的不是异常而是None。路径写错、图片文件损坏、文件名大小写对不上(.PNG和.png),都会引发这个结果。更隐蔽的是,有些解压工具在解压时把文件路径里的中文转成了乱码,导致程序找不到文件。

解决:在imread之后立刻判空。我在 2.1 和 4.1 里都写了if img is None: continue或exit(1),这不是防御性编程的洁癖,是真能救命。另外,读取之前用os.path.exists先做一次路径确认,能明显缩小排查范围。

5.2 类别不平衡:少数类怎么训练都认不出来

现象:训练集整体准确率到了 90%,但某个类别(比如某种不常见的警告标志)在验证集里一张都没认对;看训练日志,loss 还在下降,但验证集准确率波动很大。

原因:不同类别的样本数量差异悬殊。这个包的数据集本身不是完全均衡的,有的类别只有几十张,有的类别几百张,模型把大多数参数都用来拟合大数据类了,小数据类的梯度信号被淹没。

解决:第一步先把 2.4 的Counter(y_train)打出来,看看每类样本量。如果差异超过 5 倍,常见处理办法有两个:一是对少数类做数据增强(旋转、平移、亮度扰动)补足数量;二是在model.fit里传class_weight,让少数类的loss惩罚更大:

from sklearn.utils.class_weight import compute_class_weight weights = compute_class_weight("balanced", classes=np.unique(y_train), y=y_train) class_weight = dict(enumerate(weights)) model.fit(X_train, y_train, epochs=15, batch_size=32, class_weight=class_weight)

compute_class_weight("balanced", ...)会自动根据样本量反比计算权重,多数类权重小于 1,少数类权重大于 1,这样模型会更认真地对待小类别。

5.3 traffic_sign.model 加载失败:版本不一致和后缀名迷惑

现象:在自己电脑上训练好的traffic_sign.model,换台机器加载时报错,常见的有Unknown layer、Unrecognized data type,或者直接提示不是有效的 HDF5 文件。

原因:.model后缀是自定义的,Keras 或者 TensorFlow 根本不认识这个后缀,它内部仍然是标准格式。报错真正的原因多数是两台机器的 TensorFlow/Keras 版本不一致,旧版本保存的模型权重在新版本里需要做兼容转换。

解决:第一,加载时统一用tf.keras.models.load_model,不要混用原生 Keras 和 tf.keras——两个库的内部实现不兼容。第二,如果版本差异过大,就用h5py先打开文件看内部结构,确认它到底是 HDF5 还是 SavedModel 目录,再选择对应加载方式。第三,以后保存模型时,可以明确后缀:

model.save("traffic_sign.keras") # Keras 3.x 新格式 # 或者 model.save("traffic_sign.h5") # HDF5 格式

用 Keras 认识的后缀,比自定义.model少一层迷惑。这算是这个项目本身留给使用者的一个隐形坑。

5.4 BGR/RGB 通道顺序混用,显示全蓝但准确率没事

现象:用cv2.imshow显示预测图正常,但用matplotlib.pyplot.imshow显示同一张图,颜色明显偏蓝;如果训练阶段用了色彩扰动,测试集上准确率突然崩掉。

原因:cv2.imread读进来是 BGR 顺序,matplotlib默认按 RGB 显示,两者不转换就会出现“红蓝互换”。如果训练时用的是 OpenCV 读图,推理时也用 OpenCV,模型本身不受影响;但一旦中间混用了 PIL(Pillow 读图是 RGB),两张图在模型眼里就是完全不同的内容,准确率直接跳水。

解决:统一入口。整个项目从预处理到训练再到预测,只用cv2.imread读图;需要显示的时候再转 RGB:

rgb_img = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) plt.imshow(rgb_img)

这个转换只影响显示,不影响模型输入。如果你打算用 PIL 读图,那就要保证训练和推理全部换成 PIL,绝不能混用。

5.5 训练 loss 在降,验证准确率却纹丝不动

现象:训练日志里loss从 1.2 降到 0.4,但val_accuracy一直卡在 60% 左右,像被焊死了。

原因:这是三个问题叠加时的典型症状。最常见的是学习率太大,模型在最优解附近来回震荡,loss 整体在降但精度上不去;其次是验证集和训练集分布不一致,验证集里有些类别的样本长得和训练集差异太大;最后是过拟合信号——训练集 loss 降得很快,验证集 loss 却开始反弹,准确率自然停滞。

解决:先看history.history["val_loss"],如果它先降后升,立即把epochs调小或启用早停(3.3 里写的EarlyStopping)。如果 val_loss 还在降,那就是学习率和模型容量的问题,把 Adam 学习率从 0.001 降到 0.0003,同时给卷积层增加一个 3×3 的过滤器数量:

from tensorflow.keras.optimizers import Adam model.compile( optimizer=Adam(learning_rate=0.0003), loss="sparse_categorical_crossentropy", metrics=["accuracy"] )

再把Conv2D(32, ...)改成Conv2D(64, ...),给模型多一点拟合能力。这两步改完,大多数“loss 降但 accuracy 不动”的情况都能缓解。

6. 把准确率再往上顶:混淆矩阵、真实场景测试与迁移学习路线

训练完、单张预测也通了,准确率到了 90% 上下,很多人就收工了。但实际部署前,还有三道检查值得做,它们决定这个模型是“作业能交”还是“真能用”。

第一道检查:混淆矩阵。准确率只告诉你整体比例,不告诉你哪个类别在互相混淆。交通标志里最典型的是限速标志之间,30km/h 和 50km/h、70km/h 和 80km/h,红圈里数字不同,其余全部一样,模型很容易认差。用 scikit-learn 打印分类报告:

from sklearn.metrics import classification_report y_pred_labels = np.argmax(model.predict(X_val), axis=1) print(classification_report(y_val, y_pred_labels))

classification_report每一类一行,精确率、召回率、F1 分数一目了然。我看到这个报表才知道,原来项目里最烂的不是样本最少的类别,而是长得最像的两个限速牌——召回率 0.55 的那个类,就是模型最该重点补强的地方。

第二道检查:真实场景测试。测试集里的图是经过裁剪、对齐、亮度和训练集一致的。真实场景里,手机随手拍一张,角度歪的、反光的、树荫挡住半边的,模型能不能认出来是另一回事。我建议训练完用手机在屏幕前拍几张(或者拍真的路牌)丢给 predict.py 跑一遍。如果真实图效果差,别慌,这是正常现象,试着在训练的数据增强里加一点随机旋转和亮度扰动,能明显提升鲁棒性。

第三道检查:迁移学习。如果准确率卡在 92% 上不去,而你又想把项目做得再深一点,可以尝试用预训练模型替换前面的卷积部分。注意预训练模型(比如 ResNet50)要求输入分辨率至少 48×48,甚至 224×224,所以需要先把数据改成更大的尺寸:

import tensorflow as tf base_model = tf.keras.applications.MobileNetV2( include_top=False, weights="imagenet", input_shape=(64, 64, 3) ) base_model.trainable = False model = tf.keras.Sequential([ base_model, tf.keras.layers.GlobalAveragePooling2D(), tf.keras.layers.Dense(num_classes, activation="softmax") ])

include_top=False丢掉原来的分类头,trainable=False冻结预训练权重,只训练最后新增的全连接层。这样训练速度快,而且前面几层用的是在百万级 ImageNet 上学到的通用特征,比从零训练的两层卷积表达能力更强。

从那以后,我每次拿到这类分类资源,都会强制走一遍固定流程:先统计类别分布,确认预处理函数是同一个入口,训练完立刻跑 predict.py 做单张测试,最后打印 classification_report 看类别级的混淆情况。这套流程看着不起眼,但帮我避掉了三分之二的翻车现场。希望帮到你。

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

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

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

立即咨询