简介:这份交通标志物图像分类数据集面向计算机视觉初学者、图像分类算法练习者及需要快速验证模型的研究人员,解决从零收集与整理交通标志样本耗时的问题。数据按文件夹组织,可直接用ImageFolder加载,无需额外预处理,适合作为YOLOv5分类任务或常规CNN分类实验的数据源。压缩包共2000个文件,以1999张jpg图像为主,另附1个json字典文件,整体约58.4MB,涵盖红绿灯、限速、左右转等43个类别。目录下分train、val、test三个子集,训练集31,374张、验证集7,835张、测试集7,835张,划分清晰,便于直接开展训练与评估。json文件提供43种交通标志物的中文对照,方便结果解读与类别映射。已有333人学习下载,适合需要快速搭建分类基线、验证数据增强策略或进行迁移学习练习的读者参考使用。
1. 交通标志分类数据集:从拿到压缩包到跑通第一个baseline
你有没有遇到过这种情况:想练手图像分类,翻遍公开数据集要么太大下载半天,要么格式乱七八糟,光写数据加载脚本就耗掉一晚上。我最近拿到一份交通标志物图像分类数据集,解压后直接扔给ImageFolder就能用,43 个类别、训练集三万多张、验证和测试各七千多张,总共才 60MB。这个体量对想快速验证模型结构、调参或者做课程作业的人来说刚刚好。它适合谁?刚入门图像分类想找个干净数据练手的开发者,或者需要一个小型多类别数据集来验证迁移学习效果的熟手。下面我从目录结构、加载方式、训练脚本到二次开发,把这份资源完整拆一遍。
2. 数据集结构与加载机制:为什么ImageFolder能直接打开
2.1 目录布局与类别映射逻辑
拿到压缩包解压后,根目录下是一个data文件夹,里面分三个子目录:train、val、test。每个子目录下按类别名建文件夹,同类图片放在对应文件夹里。这种结构是 PyTorchImageFolder的标准输入格式——它不关心图片文件名,只认「父文件夹名即类别标签」这个规则。
我实际翻了一下,训练集 31,374 张,验证集 7,835 张,测试集 7,835 张。验证集和测试集数量一致,说明划分时做了等量切分,这对复现实验比较友好,不会出现验证集太小导致指标抖动的情况。
43 个类别覆盖了常见的交通标志类型:红绿灯、限速标志、左右转指示、禁止通行等。类别名用的是英文或数字编码,具体映射关系在随数据集附带的 JSON 文件里,是一份 43 种交通标志物的中文字典。这个文件很关键,后面做可视化预测结果时要用它把类别索引翻译成中文。
注意:
ImageFolder要求每个类别至少有一张图片,且所有图片格式能被 PIL 正常读取。这份数据集已经满足,不需要额外清洗。
2.2 用ImageFolder三行代码完成加载
先看最基础的加载方式,不涉及任何增强:
import torch from torchvision import datasets, transforms # 定义基础变换:统一尺寸 + 转Tensor base_transform = transforms.Compose([ transforms.Resize((224, 224)), # 统一缩放到224x224,适配主流骨干网络 transforms.ToTensor(), # 转成Tensor,像素值归一化到[0,1] ]) # 分别加载三个子集 train_dataset = datasets.ImageFolder( root='data/train', # 指向训练集根目录 transform=base_transform ) val_dataset = datasets.ImageFolder( root='data/val', transform=base_transform ) test_dataset = datasets.ImageFolder( root='data/test', transform=base_transform ) # 查看类别数和类别名 print(f"类别数: {len(train_dataset.classes)}") print(f"类别列表: {train_dataset.classes}") print(f"训练集样本数: {len(train_dataset)}")这段代码的逻辑很直白:ImageFolder扫描root下所有子文件夹,把文件夹名排序后映射成 0 到 42 的整数标签。transform参数决定每张图被读进来后做什么处理。Resize((224, 224))是因为后面要接 ResNet 之类的骨干网络,它们通常要求固定输入尺寸。ToTensor()把 PIL 图像转成 PyTorch 张量,同时把像素值从 0-255 缩放到 0-1。
跑完这段,你会看到输出类别数: 43,以及一个包含 43 个类别名的列表。如果类别数不对,大概率是某个类别文件夹为空,或者文件夹命名有特殊字符导致排序异常。
2.3 类别字典JSON的读取与映射
数据集附带的 JSON 文件存的是类别索引到中文名的映射。我一般这样读:
import json # 读取中文字典文件 with open('data/class_dict.json', 'r', encoding='utf-8') as f: class_dict = json.load(f) # class_dict 的键是字符串形式的索引,值是中文名 # 转成列表方便按索引取用 idx_to_chinese = [class_dict[str(i)] for i in range(len(class_dict))] print(idx_to_chinese[:5]) # 打印前5个看看这里有个细节:JSON 里的键通常是字符串"0"、"1"这种,而ImageFolder给的标签是整数。所以用列表推导式按整数索引重建一个列表,后面预测时直接idx_to_chinese[pred_idx]就能拿到中文名。如果 JSON 键不是从 0 连续编号,需要先排序再映射,但这份数据集是连续的,直接按范围取就行。
2.4 验证集与测试集的划分合理性
验证集和测试集各 7,835 张,数量一致。我一开始以为是复制了一份,后来对比了文件名,发现是不同图片。这种等量划分在小型数据集里比较少见,好处是验证集足够大,调参时指标波动小;坏处是如果原始数据分布有偏,两个集合可能都继承了同样的偏差。
实际用的时候,我建议先跑一遍类别分布统计:
from collections import Counter # 统计训练集每个类别的样本数 train_labels = [label for _, label in train_dataset] label_counts = Counter(train_labels) print(f"最多类别样本数: {max(label_counts.values())}") print(f"最少类别样本数: {min(label_counts.values())}")如果最多和最少差了好几倍,训练时就要考虑用加权采样或者 Focal Loss。这份数据集我跑下来分布还算均匀,没有极端不平衡的情况,直接按默认方式训练问题不大。
3. 训练脚本搭建:从DataLoader到第一个epoch跑通
3.1 DataLoader参数配置与批处理
数据集加载好之后,下一步是包成DataLoader。这里有几个参数直接影响训练效率和显存占用:
from torch.utils.data import DataLoader # 训练集DataLoader:打乱顺序 + 多进程加载 train_loader = DataLoader( train_dataset, batch_size=64, # 批大小,根据显存调整 shuffle=True, # 训练集必须打乱 num_workers=4, # 子进程数,Windows下建议设为0 pin_memory=True # 锁页内存,加速GPU传输 ) val_loader = DataLoader( val_dataset, batch_size=64, shuffle=False, # 验证集不需要打乱 num_workers=4, pin_memory=True ) test_loader = DataLoader( test_dataset, batch_size=64, shuffle=False, num_workers=4, pin_memory=True )batch_size=64是我在 8GB 显存下的常用值,跑 ResNet18 刚好。如果显存小,降到 32 或 16。num_workers在 Linux 下设 4 能明显加快数据读取,Windows 下有时会报错,改成 0 最稳。pin_memory=True配合 GPU 训练能减少数据传输开销,但如果用的是 CPU 训练,这个参数没意义。
3.2 模型选择与迁移学习策略
43 个类别的分类任务,从头训练一个小 CNN 也能跑,但收敛慢且精度一般。我一般直接用预训练模型做迁移学习:
import torch.nn as nn from torchvision import models # 加载预训练ResNet18 model = models.resnet18(pretrained=True) # 替换最后的全连接层,输出维度改为43 num_features = model.fc.in_features model.fc = nn.Linear(num_features, 43) # 冻结前面的卷积层,只训练最后的分类头 for param in model.parameters(): param.requires_grad = False for param in model.fc.parameters(): param.requires_grad = True # 移到GPU device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = model.to(device)这里做了两件事:一是把 ResNet18 原本 1000 类的输出层换成 43 类;二是冻结卷积层参数,只更新全连接层。这样做的好处是训练快、不容易过拟合,适合数据集只有几万张的情况。等分类头收敛后,如果还想提升精度,可以解冻后面几个卷积块做微调,学习率调小一个数量级。
提示:
pretrained=True会下载预训练权重,第一次运行需要联网。如果环境不能联网,提前把权重文件放到~/.cache/torch/hub/checkpoints/下。
3.3 训练循环与验证指标记录
训练循环的骨架很标准,但有几个地方容易写错:
import torch.optim as optim from tqdm import tqdm criterion = nn.CrossEntropyLoss() optimizer = optim.Adam(model.fc.parameters(), lr=1e-3) num_epochs = 10 best_acc = 0.0 for epoch in range(num_epochs): # 训练阶段 model.train() running_loss = 0.0 correct = 0 total = 0 for images, labels in tqdm(train_loader, desc=f'Epoch {epoch+1} Train'): images, labels = images.to(device), labels.to(device) optimizer.zero_grad() outputs = model(images) loss = criterion(outputs, labels) loss.backward() optimizer.step() running_loss += loss.item() _, predicted = outputs.max(1) total += labels.size(0) correct += predicted.eq(labels).sum().item() train_acc = 100. * correct / total train_loss = running_loss / len(train_loader) # 验证阶段 model.eval() val_correct = 0 val_total = 0 with torch.no_grad(): for images, labels in val_loader: images, labels = images.to(device), labels.to(device) outputs = model(images) _, predicted = outputs.max(1) val_total += labels.size(0) val_correct += predicted.eq(labels).sum().item() val_acc = 100. * val_correct / val_total print(f'Epoch {epoch+1}: Train Loss={train_loss:.4f}, Train Acc={train_acc:.2f}%, Val Acc={val_acc:.2f}%') # 保存最佳模型 if val_acc > best_acc: best_acc = val_acc torch.save(model.state_dict(), 'best_model.pth') print(f' -> 最佳模型已保存 (Val Acc: {val_acc:.2f}%)')几个关键点:model.train()和model.eval()必须成对出现,前者启用 BatchNorm 和 Dropout,后者关闭它们。验证阶段用torch.no_grad()包住,省显存也加速。outputs.max(1)返回每行最大值和对应索引,索引就是预测类别。最后按验证集准确率保存最佳模型,避免用测试集调参导致过拟合。
跑完 10 个 epoch,ResNet18 在这份数据集上验证集准确率通常能到 95% 以上。如果低于 90%,检查一下Resize尺寸是否和预训练模型匹配,以及标签有没有错位。
3.4 测试集评估与混淆矩阵输出
训练完拿测试集做最终评估,同时输出混淆矩阵看哪些类别容易混:
from sklearn.metrics import confusion_matrix, classification_report import numpy as np model.load_state_dict(torch.load('best_model.pth')) model.eval() all_preds = [] all_labels = [] with torch.no_grad(): for images, labels in test_loader: images, labels = images.to(device), labels.to(device) outputs = model(images) _, predicted = outputs.max(1) all_preds.extend(predicted.cpu().numpy()) all_labels.extend(labels.cpu().numpy()) # 分类报告 print(classification_report(all_labels, all_preds, target_names=train_dataset.classes)) # 混淆矩阵 cm = confusion_matrix(all_labels, all_preds) print(f"混淆矩阵形状: {cm.shape}")classification_report会输出每个类别的精确率、召回率和 F1 分数。交通标志里有些类别长得像,比如「限速 50」和「限速 60」,混淆矩阵里这两类交叉位置数值偏高是正常的。如果某个类别 F1 特别低,可以单独把它的图片抽出来看看,大概率是标注噪声或者样本太少。
4. 避坑与常见问题:数据加载和训练中的血泪经验
4.1 图片损坏导致DataLoader崩溃
现象:训练跑了几百个 batch 后突然报PIL.UnidentifiedImageError,整个进程挂掉。
原因:数据集中混入了损坏的 JPEG 文件,可能是下载或解压时出的问题。ImageFolder默认在__getitem__里直接打开图片,遇到坏文件就抛异常。
解决:写个脚本提前扫描一遍,把坏图找出来删掉或替换:
from PIL import Image import os def check_images(root_dir): bad_files = [] for dirpath, _, filenames in os.walk(root_dir): for fname in filenames: fpath = os.path.join(dirpath, fname) try: with Image.open(fpath) as img: img.verify() # 验证文件完整性 except Exception as e: bad_files.append((fpath, str(e))) return bad_files bad = check_images('data/train') print(f"发现 {len(bad)} 个损坏文件") for f, err in bad[:10]: print(f" {f}: {err}")img.verify()会检查文件头和数据完整性,但不解码像素。跑完把bad_files里的文件删掉或重新下载替换即可。
4.2 Windows下num_workers报错
现象:在 Windows 上设num_workers=4,程序卡死或报RuntimeError: DataLoader worker exited unexpectedly。
原因:Windows 的多进程启动方式和 Linux 不同,DataLoader的子进程需要重新导入主模块,如果训练脚本没有放在if __name__ == '__main__':保护块里,就会递归创建进程导致崩溃。
解决:两个办法。一是把num_workers设为 0,用主进程加载数据,速度慢一点但稳定。二是把训练代码包进if __name__ == '__main__':里:
if __name__ == '__main__': # 所有训练代码放这里 train_loader = DataLoader(..., num_workers=4) # ...我一般在 Windows 上直接设 0,省事。Linux 服务器上再开多进程。
4.3 验证集准确率震荡大
现象:训练时验证集准确率忽高忽低,相邻 epoch 能差 5 个百分点。
原因:验证集只有 7,835 张,分到 43 个类别,平均每类 180 张左右。如果某些类别样本少,batch 采样时波动就大。另外 BatchNorm 在eval()模式下用的是训练时累积的均值和方差,如果训练不充分,统计量不准也会导致验证波动。
解决:一是把验证集 batch_size 调大,比如 128 或 256,减少批次间波动。二是训练前几个 epoch 先不冻结卷积层,让 BatchNorm 统计量更新充分。三是用指数移动平均(EMA)平滑验证指标:
ema_val_acc = 0.9 * ema_val_acc + 0.1 * val_acc4.4 类别索引与中文名对不上
现象:预测结果可视化时,明明图片是「限速 50」,显示的中文名却是「禁止通行」。
原因:ImageFolder按文件夹名排序生成类别索引,而 JSON 字典的键可能不是按同样顺序排列的。如果文件夹名是00001、00002这种,排序没问题;但如果混了英文和数字,排序结果可能和预期不同。
解决:不要假设 JSON 键的顺序和ImageFolder.classes一致。直接用train_dataset.classes作为基准,去 JSON 里查对应的中文名:
# 假设JSON结构是 {"类别名": "中文名"} with open('data/class_dict.json', 'r', encoding='utf-8') as f: name_to_chinese = json.load(f) # 按ImageFolder的类别顺序生成中文名列表 chinese_names = [name_to_chinese[cls] for cls in train_dataset.classes]这样无论ImageFolder怎么排序,中文名都能对上。
4.5 测试集被反复用于调参
现象:测试集准确率很高,但换一批新图片预测效果很差。
原因:训练过程中反复用测试集评估,根据测试集结果调超参数,导致模型间接过拟合了测试集。测试集失去了「最终评估」的意义。
解决:严格区分验证集和测试集。调参、选模型、早停都用验证集。测试集只在最后跑一次,跑完就封存。如果发现测试集效果不好,回去改模型结构或数据增强,改完重新划分验证集,但不要反复看测试集结果。
5. 二次开发与改造:从baseline到可部署模型
5.1 数据增强策略升级
基础版只做了Resize和ToTensor,对交通标志来说,可以加一些针对性增强:
train_transform = transforms.Compose([ transforms.Resize((256, 256)), transforms.RandomCrop(224), # 随机裁剪,模拟不同距离 transforms.RandomHorizontalFlip(p=0.5), # 水平翻转,左右转标志翻转后语义不变 transforms.ColorJitter(brightness=0.3, # 亮度扰动,模拟不同光照 contrast=0.3, saturation=0.3), transforms.RandomRotation(15), # 小角度旋转,模拟拍摄角度偏差 transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], # ImageNet统计量 std=[0.229, 0.224, 0.225]) ])注意RandomHorizontalFlip对「左转」和「右转」标志会改变语义,如果这两类在数据集中占比高,翻转增强反而有害。我一般先统计一下左右转类别的样本数,如果不多就关掉水平翻转,或者只对非方向类做翻转。
Normalize用的 ImageNet 统计量,因为骨干网络是在 ImageNet 上预训练的,输入分布要匹配。如果从头训练,可以自己算数据集的均值和方差。
5.2 导出ONNX模型用于部署
训练完的 PyTorch 模型要部署到生产环境,通常先转 ONNX:
import torch.onnx # 加载最佳模型 model.load_state_dict(torch.load('best_model.pth')) model.eval() # 构造示例输入 dummy_input = torch.randn(1, 3, 224, 224).to(device) # 导出ONNX torch.onnx.export( model, dummy_input, 'traffic_sign_model.onnx', input_names=['input'], output_names=['output'], dynamic_axes={'input': {0: 'batch_size'}, # 支持动态batch 'output': {0: 'batch_size'}}, opset_version=11 ) print("ONNX模型已导出")dynamic_axes让导出的模型支持可变 batch size,部署时不用固定为 1。opset_version=11兼容性较好,主流推理引擎都支持。导出后用onnxruntime验证一下:
import onnxruntime as ort import numpy as np sess = ort.InferenceSession('traffic_sign_model.onnx') input_name = sess.get_inputs()[0].name # 用一张测试图跑推理 test_img = np.random.randn(1, 3, 224, 224).astype(np.float32) outputs = sess.run(None, {input_name: test_img}) print(f"ONNX输出形状: {outputs[0].shape}")如果输出形状是(1, 43),说明导出成功。
5.3 用Grad-CAM可视化模型关注区域
想确认模型是不是真的在看交通标志本身,而不是背景,可以用 Grad-CAM 生成热力图:
from pytorch_grad_cam import GradCAM from pytorch_grad_cam.utils.image import show_cam_on_image # 选择最后一个卷积层作为目标层 target_layers = [model.layer4[-1]] cam = GradCAM(model=model, target_layers=target_layers) # 取一张验证集图片 img_tensor, label = val_dataset[0] input_tensor = img_tensor.unsqueeze(0).to(device) # 生成热力图 grayscale_cam = cam(input_tensor=input_tensor, targets=None) grayscale_cam = grayscale_cam[0, :] # 叠加到原图 import matplotlib.pyplot as plt img_np = img_tensor.permute(1, 2, 0).numpy() img_np = (img_np - img_np.min()) / (img_np.max() - img_np.min()) visualization = show_cam_on_image(img_np, grayscale_cam, use_rgb=True) plt.imshow(visualization) plt.axis('off') plt.show()热力图红色区域是模型最关注的地方。如果红色集中在标志牌上,说明模型学到了正确特征;如果集中在背景或边角,可能需要加更多数据增强或换更强的骨干网络。
5.4 类别不平衡时的加权采样
虽然这份数据集分布还算均匀,但如果你自己往里面加了新类别,或者发现某些类别 F1 明显偏低,可以用加权采样:
from torch.utils.data import WeightedRandomSampler # 计算每个类别的权重 class_counts = np.bincount(train_labels) class_weights = 1.0 / class_counts sample_weights = class_weights[train_labels] # 创建加权采样器 sampler = WeightedRandomSampler( weights=sample_weights, num_samples=len(sample_weights), replacement=True ) # DataLoader使用sampler时不能再设shuffle=True train_loader = DataLoader( train_dataset, batch_size=64, sampler=sampler, # 替代shuffle num_workers=4, pin_memory=True )WeightedRandomSampler让样本少的类别被采到的概率更高,每个 epoch 里各类别大致均衡。注意用了sampler就不能再设shuffle=True,否则会报错。
5.5 一个容易忽略的细节:图片EXIF方向
有些手机拍的图片带 EXIF 旋转信息,PIL 默认不自动旋转,导致读进来是横着的。交通标志数据集如果混了这类图片,模型会学到错误的方向特征。我一般在transform最前面加一步:
from PIL import Image, ImageOps class ExifOrientationFix: def __call__(self, img): return ImageOps.exif_transpose(img) train_transform = transforms.Compose([ ExifOrientationFix(), # 先修正EXIF方向 transforms.Resize((224, 224)), transforms.ToTensor(), ])ImageOps.exif_transpose会根据 EXIF 信息自动旋转图片,保证方向一致。这个坑我在另一个项目里踩过,模型训练时准确率死活上不去,排查半天才发现是图片方向问题。从那以后我每次拿到新数据集,都强制先跑一遍 EXIF 检查,确认所有图片方向一致再开始训练。
希望这份拆解能帮你把这份交通标志数据集跑起来,少走点弯路。
本文还有配套的精品资源,点击获取