简介:交通标志物图像分类数据集涵盖43个常见类别,包括红绿灯、限速、左右转等标志,适合计算机视觉模型训练与算法验证,也可作为YOLOv5分类任务的数据输入。数据已按训练集、验证集、测试集拆分,train含31,374张图片,val与test各7,835张,所有样本按文件夹保存,可直接通过ImageFolder读取,无需额外清洗或格式转换。压缩包共2000个文件,含1999张jpg图像和1个json中文字典,整体大小约58.4MB,便于本地下载与快速试验。json字典提供了43类交通标志的中文标签对照,能提升实验记录和结果展示的便利性。目前已有333人学习,适合入门级视觉项目、课程设计及工程实践,可帮助开发者省去数据整理环节,直接聚焦模型调优与效果提升。
1. 交通标志物图像分类数据集:为什么你的模型总在红绿灯上翻车
交通标志物图像分类数据集,就是把限速牌、禁令标志这类标志物从路拍图里截出来后,按图像分类的格式组织成训练集、验证集、测试集三部分,解决自动驾驶和路侧感知里一个前置问题:感知系统框出标志候选区域后,判断这一小块图到底属于哪类。这个标题的看点不在模型多花哨,而在划分与评估口径——训练集喂权重,验证集调超参、选模型,测试集出最终判定。适合做课程设计和竞赛的学生、刚接手感知算法岗的工程师,以及想验证 ResNet 或 ViT 泛化能力的算法同学。反直觉的是,这类任务的难点不是把训练准确率刷到 99%,而是测试集分数稳定可复现;换一个城市、换一个拍摄角度,模型就可能翻车。下面按拿到数据集后的实际操作顺序走。
2. 拿到交通标志数据集先做三件事:目录结构摸底、平衡性检查、划分文件核对
拿到这样一个数据集,第一件事不是写训练代码,而是把目录当成黑匣子拆开看。常见做法是,发布者把数据按 train、val、test 三个目录放好,每个目录内部再按类别建子文件夹,因为 PyTorch 的torchvision.datasets.ImageFolder可以直接按这个结构读。但现实里总有例外:有的把验证集和测试集合并发布,有的测试集不给你标注,只给一份 id 清单,少数数据集还会把类别文件夹命名为“1”“2”而不是从 0 开始。这些差异不会在训练时报错,但会在你画混淆矩阵、报测试集分数的时候变成莫名其妙的偏差。
2.1 目录结构长什么样:train/val/test 三件套的约定与例外
先看一个典型目录结构,后面所有代码都假设数据集长这样:
data/ train/ 0/ p0001.jpg p0002.jpg 1/ ... val/ 0/ 1/ test/ 0/ 1/类别数字对应的语义标签由发布方提供一份 csv,比如 0 表示限速 40,1 表示禁止左转,2 表示解除限速。如果你的数据集把标注写进 CSV、图片全部平铺在一个文件夹里,我会先用一小段脚本把它们重排成上面这个目录,而不是去改训练代码,因为 ImageFolder 真的省事。重排时有一个关键点:类别编号必须从 0 开始且连续,中间缺了某个编号,交叉熵损失的类别数就对不上。
这套 train/val/test 划分不是交通标志领域独有的。像 CCPD 车牌数据集、HRSC2016 这类遥感目标检测数据集也都遵守同样的三件套约定,只是它们的标签从分类类别变成了检测框。如果你以前在这些数据集上做过目标检测,会发现思路完全一致:先摸清划分结构,再检查样本数量和标注质量,最后才选网络。我的习惯是拿到任何一个新数据集,先花半小时把目录结构、类别总数、每类样本数填进一张纸上,再考虑模型怎么选。
2.2 五张图看数据:类别分布、样本尺寸与格式的快速体检脚本
体检脚本我一般写成一个独立的小文件inspect.py,不放进训练工程里,避免污染主流程。下面这段代码会统计每个类别的样本数、图片尺寸和文件格式,并输出最小类和最大类的样本数:
import os from collections import Counter from PIL import Image root = "data/train" class_count = Counter() size_set = set() fmt_count = Counter() for cls in sorted(os.listdir(root)): cls_path = os.path.join(root, cls) if not os.path.isdir(cls_path): continue n = 0 for name in os.listdir(cls_path): if not name.endswith((".jpg", ".png", ".jpeg")): continue img = Image.open(os.path.join(cls_path, name)) size_set.add(img.size) fmt_count[img.format] += 1 n += 1 class_count[int(cls)] = n print("类别数:", len(class_count)) print("每类样本数:", dict(sorted(class_count.items()))) print("最小类样本数:", min(class_count.values())) print("最大类样本数:", max(class_count.values())) print("出现过的尺寸:", size_set) print("图片格式:", fmt_count)这段脚本会直接告诉你三个关键信息:类别是否平衡、图片尺寸是否统一、格式是否混杂。如果最小类样本只有几十张而最大类有几千张,说明类别不平衡严重,后面要用加权采样或针对少数类做增强。如果size_set里出现了多种尺寸,比如 90x90、100x100、120x120 混在一起,你需要统一 resize,而不是让 DataLoader 报错后才发现。如果 png 和 jpg 混用,注意 PNG 可能带透明通道,读成 RGB 时背景会变成黑色,这种样本对分类的干扰比想象中大。
尺寸这块有一个容易被忽略的点:交通标志原始图片的目标占比差异很大。有的样本是从远距离路拍图里截出来的,标志只占画面中心一小块,周围是天空和路面;有的样本是近距离侧拍,标志几乎撑满整张图。两种图在 resize 到 224x224 后语义完全不同。所以我一般会在体检脚本里顺手记录宽高比分布,如果过于分散,预处理里就补一个居中的 resize 裁剪,而不是简单粗暴地整体拉伸。
2.3 划分文件别乱动:训练集、验证集、测试集之间的三个边界条件
目录结构摸清楚之后要管住自己,不要随手把三个目录里的文件互相拷来拷去。训练集、验证集、测试集之间存在三个边界条件,任何一个被打破都会让最终结果失真。
第一个边界条件是数据不泄露。视频抽帧型数据集尤其容易出问题:同一块标志牌在连续几帧里被截出来,长得几乎一样,如果前一帧分进训练集、后一帧分进验证集,模型在验证集上的分数会虚高。检查方法很简单,对图片做感知哈希,看验证集里是否出现过与训练集高度相似的样本。我见过一个项目因为没查这一步,验证集准确率比测试集高出 12 个百分点,整个调参过程都被这个虚高分数带偏了。
第二个边界条件是测试集只能碰一次。测试集应该只在训练完全结束后跑一遍,用来对外报数字。如果有人反复拿测试集结果去调学习率和增强参数,那测试集的评估作用就退化成了第二个验证集,泛化结论不可信。我一般会在工程里把 test_loader 单独放一个脚本,训练代码里不加载测试集,从物理上避免手滑。
第三个边界条件是预处理必须完全一致。训练、验证、测试三套数据走同一个 transforms 流水线,尤其是归一化用的均值和标准差必须固定为同一组数。很多人训练时做了随机裁剪,验证和测试时忘了改成中心裁剪,导致的后果是验证集分数上下浮动很大,让你误以为模型不稳定,其实只是预处理口径对不上。这三条边界守住,后面调模型才有意义。
3. 用 ResNet18 在本地跑通最小训练:从 PyTorch Dataset 到验证集选模型
目录结构摸清后,选一个轻量 backbone 把闭环跑通。我推荐 ResNet18,不是因为它最强,而是它的预训练权重好找、单卡显存占用低、在交通标志这类小图分类上很稳。有一个常见误区要提一下:很多人习惯拿着 YOLOv8 去练图像分类,那是目标检测的思路;这个任务属于图像分类,用 ResNet 一类的分类 backbone 更直接。如果后续真要去框标志位置再做检测,那时候再上 YOLO 或 DEIM 这类检测模型不迟。先把分类这件事做扎实。
3.1 自定义 Dataset 的写法:把目录里的图片读成标签与路径
目录结构是 ImageFolder 格式时,最省事的方式是直接用torchvision.datasets.ImageFolder。但实际项目里我更愿意写一个自定义 Dataset,原因有两个:一是后面要加样本级别的调试逻辑,二是数据集的标注格式可能从目录变成 CSV,自定义类改动起来更可控。下面是一个极简版本:
import os import torch from PIL import Image from torch.utils.data import Dataset class SignDataset(Dataset): def __init__(self, root_dir, transform=None): self.samples = [] # 每个元素是 (图片路径, 类别id) self.transform = transform for cls_id in sorted(os.listdir(root_dir)): cls_path = os.path.join(root_dir, cls_id) if not os.path.isdir(cls_path): continue for name in os.listdir(cls_path): if name.endswith((".jpg", ".png", ".jpeg")): self.samples.append( (os.path.join(cls_path, name), int(cls_id)) ) def __len__(self): return len(self.samples) def __getitem__(self, idx): path, label = self.samples[idx] img = Image.open(path).convert("RGB") if self.transform: img = self.transform(img) return img, label注意__getitem__里加了convert("RGB"),这一步不是可有可无。交通标志数据集里经常混着灰度图或带透明通道的 PNG,不加这个转换,训练到一半会因为通道数不一致抛出形状不匹配的报错,而且这种报错通常发生在第一个 epoch 结束之后,浪费不少时间。类别 id 这里直接取文件夹名转 int,所以 2.1 节里说的“编号必须连续且从 0 开始”如果没守好,在这里就会提前暴露。
3.2 数据加载器与预处理:归一化参数不要自己瞎猜
Dataset 定义好之后,transforms 和 DataLoader 是下一个关键点。交通标志图片尺寸不一,我统一缩放到 224x224,这是 ResNet 的标准输入。训练时用随机缩放裁剪加水平翻转,验证和测试用中心裁剪:
from torchvision import transforms from torch.utils.data import DataLoader train_tf = transforms.Compose([ transforms.RandomResizedCrop(224, scale=(0.6, 1.0)), # 目标占比较小时别把裁剪比例调太低 transforms.RandomHorizontalFlip(), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ]) val_tf = transforms.Compose([ transforms.Resize(256), transforms.CenterCrop(224), # 验证/测试必须用确定性预处理 transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ]) train_loader = DataLoader( SignDataset("data/train", train_tf), batch_size=64, shuffle=True, num_workers=4, drop_last=True ) val_loader = DataLoader( SignDataset("data/val", val_tf), batch_size=64, shuffle=False, num_workers=4 )归一化的均值和标准差我直接沿用 ImageNet 的统计值,不自己算。原因是预训练模型是在 ImageNet 的数值分布上调出来的,如果换一套均值和标准差,相当于改变了输入分布,微调时收敛速度会明显变慢。很多实现里验证也用 RandomResizedCrop,这是错的:验证集要的是稳定评估,引入随机性会让每个 epoch 的验证准确率上下抖动,你很难判断模型是真的在变好还是随机波动。
这里RandomResizedCrop的 scale 参数选了 0.6 到 1.0。交通标志是目标占据画面中心的结构化物体,裁剪比例太小时,标志主体容易切掉,反而模拟出了现实中不存在的遮挡。如果你的数据来自行车记录仪,目标大小变化很大,可以把下限放到 0.4;如果样本都是近景特写,0.7 到 1.0 更合理。这个参数属于典型的“看着不起眼、影响很大”的增强项。
注意:训练集可以用随机增强,验证集和测试集必须用确定性预处理(中心裁剪 + 同一组归一化参数),否则验证分数上下抖动,你会误判模型收敛情况。
batch size 取 64 是折中。数据量小时,batch 太大让每个 step 的梯度方向过于平缓,BN 层的均值和方差也不稳定;batch 太小又让训练变慢。如果显存只放得下 32,那就用 32,同时把学习率从 0.001 降到 0.0005。有效 batch 减半时学习率通常要跟着调,这是新手最容易忽略的换算关系。
3.3 训练主循环:学习率、batch size、epoch 的起步值怎么定
训练主循环看起来千篇一律,但里面三个参数决定了整个训练的质量。学习率我习惯从 0.001 起步,优化器用 SGD 加 momentum 0.9,权重衰减 1e-4。Adam 虽然收敛快,但在小数据集上经常出现过早收敛的问题,SGD 配合余弦退火反而更稳。epoch 数不用一上来就 100,30 个 epoch 足够判断趋势。保存策略上,不要只保存最后一个 epoch 的权重,要保存验证集准确率最高的那个。
import torch import torch.nn as nn from torchvision import models model = models.resnet18(weights=models.ResNet18_Weights.IMAGENET1K_V1) model.fc = nn.Linear(model.fc.in_features, num_classes) # 输出维度=类别数 model = model.cuda() criterion = nn.CrossEntropyLoss() optimizer = torch.optim.SGD(model.parameters(), lr=0.001, momentum=0.9, weight_decay=1e-4) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR( optimizer, T_max=30) for epoch in range(30): model.train() total, correct = 0, 0 for images, labels in train_loader: images, labels = images.cuda(), labels.cuda() optimizer.zero_grad() outputs = model(images) loss = criterion(outputs, labels) loss.backward() optimizer.step() _, preds = outputs.max(1) correct += preds.eq(labels).sum().item() total += labels.size(0) scheduler.step() print(f"epoch {epoch} train acc {correct / total:.4f}")把最后一层 fc 替换成自己的分类头时,输出维度必须等于数据集类别数,如果数据集有 43 类,这里就写 43。Conv 层的预训练权重会被保留,因为交通标志的底层纹理、边缘特征和 ImageNet 通用特征高度相关,迁移过来的起点比随机初始化好。学习率这块多少带点玄学,但余弦退火的优点是 T_max 设为总 epoch 数后,学习率会在最后收敛到接近 0,你不用手动去调衰减节点。
如果你中途发现验证集准确率在某几个 epoch 后不再上升,不要急着中断,先确认是不是学习率区间还没走到。余弦退火早期的学习率变化很小,真正的主力衰减集中在中后段。也可以改用ReduceLROnPlateau按验证损失触发衰减,两种调度器在小数据集上都能用,只是余弦退火省去手动设阈值的步骤。
3.4 验证集用来选模型:每个 epoch 后计算 val 准确率并保存最优权重
训练过程中验证集的最大作用就是选模型。下面是每个 epoch 结束时跑一遍验证,并且只在验证准确率创历史新高时保存权重:
def evaluate(loader, model): model.eval() total, correct = 0, 0 with torch.no_grad(): for images, labels in loader: images, labels = images.cuda(), labels.cuda() outputs = model(images) _, preds = outputs.max(1) correct += preds.eq(labels).sum().item() total += labels.size(0) return correct / total val_acc = evaluate(val_loader, model) if val_acc > best_acc: best_acc = val_acc torch.save({"state_dict": model.state_dict(), "val_acc": val_acc}, "best.pth") print(f"epoch {epoch} val acc {val_acc:.4f}")这里的评价口径是 Top-1 准确率,因为交通标志分类的类别之间是互斥的,一张图只能属于一个类别。保存权重时把 val_acc 也一起存进 checkpoint,这样事后你能看出这个权重是在哪个 epoch、以哪个验证分数入选的。我一般不会只存 state_dict,会把类别数、预处理配置、best_acc 一起存成 dict,方便重新加载时不用靠猜。
为什么用验证集选模型而不是直接用训练的最后一个 epoch?因为最后一个 epoch 训练时间最长,学习率已经退火到很小,此时模型在训练集上容易过拟合,泛化能力往往不如几个 epoch 之前的状态。验证集准确率最高的点,就是模型在这个数据分布下的最优折中点。这也是整个工程里最容易实践却最容易被忽略的环节,很多人跑完 30 个 epoch 直接拿最后的权重去测测试集,结果比中途的 best 差两个点,再回头重训很浪费。
4. 测试集里才见真章:Top-1 准确率、混淆矩阵与类别召回率
训练和验证都做完后,会得到一个在验证集上表现不错的 best.pth。但对外汇报、和别的方案对比时,用的必须是测试集上的数字。测试集的意义在于,它提供了一组模型从未见过的样本,给出一个无偏的泛化估计。这一章从三个粒度评估模型:总体的 Top-1,类与类之间的混淆,以及少数类的召回情况。
4.1 测试集只准碰一次:加载最优 checkpoint 输出总体 Top-1 准确率
测试脚本我会单独放,和训练脚本分开。加载模型后先载入最优 checkpoint,再在测试集上跑一次推理,输出总体准确率。最关键的是模型结构必须和训练时一模一样,尤其是最后一层 fc 的输出维度。
checkpoint = torch.load("best.pth", map_location="cuda") model.fc = nn.Linear(model.fc.in_features, num_classes) model.load_state_dict(checkpoint["state_dict"]) model.eval() test_acc = evaluate(test_loader, model) print(f"test top-1 acc: {test_acc:.4f}")evaluate 函数直接复用 3.4 里那一段,评估逻辑完全一致。要注意load_state_dict前先把 fc 层重新定义出来,否则权重形状对不上会直接报错。如果你在评估时发现测试集分数不升反降,先核对加载的 checkpoint 是不是验证集最优版,而不是最后一个 epoch 的权重。这个问题我见过多次,属于评估阶段的典型乌龙。
4.2 混淆矩阵告诉你谁和谁分不清:限速牌与解除限速牌的典型混淆
总体准确率 90% 看起来不错,但交通标志分类里真正的风险是某些高危类别被系统性误判。混淆矩阵是定位这些问题的直接工具:
from sklearn.metrics import confusion_matrix import numpy as np all_preds, all_labels = [], [] for images, labels in test_loader: images = images.cuda() with torch.no_grad(): outputs = model(images) _, preds = outputs.max(1) all_preds.extend(preds.cpu().tolist()) all_labels.extend(labels.tolist()) cm = confusion_matrix(all_labels, all_preds) np.set_printoptions(precision=2, suppress=True) print(cm)拿到矩阵后,重点看非对角线的大数值。在交通标志数据集里,最常见的混淆组合是外观接近的禁令标志:圆形红边框的限速 30 和限速 80 容易被互相搞错,禁止停车和禁止长时间停车的区别只在斜杠数量,夜间图像里更明显。如果你发现两个类别互相分不清,先确认增强里有没有加入角度旋转和亮度扰动,再看这两个类别的训练样本是否明显偏少。混淆不混淆不用猜,直接在矩阵里找数值最大的两处,顺着去翻对应类别的原始图片,多数时候一眼就能看出特征差异确实太小,需要额外采集或做专门增强。
4.3 类别召回率与加权 F1:从总体 90% 到每个类别都 90%
混淆矩阵只能看个大概,量化每个类别表现的最好方式是打印分类报告,包含每个类别的精确率、召回率、F1:
from sklearn.metrics import classification_report report = classification_report( all_labels, all_preds, target_names=[f"class_{i}" for i in range(num_classes)], digits=3 ) print(report)这里最需要注意的是召回率:某个类别的召回率低,意味着这个类别的真实样本大量被分到了其他类。比如“解除限速”的现实含义是道路从限速状态恢复到自由行驶,如果这个类别召回率只有 60%,城市道路上的连续限速牌会被系统误判,下游的车辆控制逻辑就会收到错误信号。交通标志分类里,少数类往往是某些冷门标志,样本少、外观又与其他标志接近,它们的召回率通常显著低于总体准确率。看到这种情况不要慌,删除数据另想办法反而是最常见的浪费行为,合理的做法是先做类别加权或采样重训,再补充增强,这两步通常能救回 5 到 10 个点的召回率。
我会把classification_report的结果连同 checkpoint 的 val_acc、测试集总体准确率一起归档成 txt。下次换了增强或采样策略重新训练时,直接对比两份 txt 里的召回率,而不是靠记忆里的总准确率。这个习惯帮我避免了很多次“觉得模型变好了但其实只是某类样本被碰巧猜对”的误判。
5. 交通标志分类的五个避坑记录:从数据泄露到类别不平衡
前面几章把正常流程走完了,这一章专门写坑。这些坑我基本都踩过,有的在项目上线前才被发现,有的直接导致测试集分数被质疑。每一条按现象、原因、解决三个层次写,你遇到类似问题可以直接照方抓药。
5.1 训练准确率 99% 但验证集只有 70%:八成是数据泄露
现象是训练集准确率一路冲到 99%,验证集却只有 70% 上下,而且无论怎么调模型都拉不开差距。先怀疑训练代码写错了,检查好几轮才发现是划分文件出了问题。
原因是数据泄露。数据集从行车记录仪视频逐帧截取时,连续帧之间内容高度重复。发布者按文件名哈希划分,同一块标志牌的相邻帧很容易被同时分进训练集和验证集。模型在训练时记住了这些特定帧的特征,验证集里出现相似图像自然得分虚高;可一旦换成真正的测试集或野外数据,分数立刻掉回 70%。这种情况在训练集和验证集同时高分、测试集偏低时尤其明显。
解决方法是先从文件层面校验重复帧。对每张图片算一个感知哈希,找到训练集和验证集之间哈希相同的样本,把它们从验证集移除。如果发布方没有提供去重工具,自己写一个批量匹配脚本,几十万张图也能在几分钟内跑完。更稳妥的做法是,训练前就按视频片段 ID 划分数据,同一段视频的所有帧必须进同一个集合,而不是按帧随机划分,这样能从根源上堵住这个坑。
5.2 夜间和逆光样本全错:光照分布没进训练集
现象是模型在白天的测试集上准确率正常,一到夜间或者逆光场景准确率断崖式下跌,个别类别几乎全军覆没。看表面是模型没见过夜间数据,但深挖之后会发现,降级的不只是亮度,还有对比度、色偏和运动模糊。
原因是光照分布偏移。很多公开交通标志数据集的训练集白天样本占比超过 90%,夜间、阴天、逆光样本稀疏。模型学到的特征被白天的纹理主导,一旦输入分布移动到夜间的暗光区间,前几层卷积的输出统计量全部偏离,后续分类自然失效。这不是分类器的锅,是训练集的分布没有覆盖部署场景。
解决思路分两步。第一步是检查原始数据集本身是否包含夜间样本,如果几乎没有,就要靠增强来补。第二步是增强不能只做亮度调整,要把对比度、色温、甚至模拟雾天都加进去。我常用的做法是把亮度扰动范围从 0.8 到 1.2 拉到 0.5 到 1.5,同时加随机 Gamma 矫正。代价是白天场景的准确率可能掉零点几个点,但夜间鲁棒性能换回来 5 个点以上。
5.3 验证集比测试集高一大截:预处理流程不一致
现象是验证集准确率比测试集高 5 到 10 个百分点,换哪个模型都是这个规律。这通常不是模型问题,是预处理流水线在训练、验证、测试三个阶段不一致。
原因最常见的出在归一化参数和 resize 方式上。训练代码里写了 Normalize,测试脚本里忘了写,或者测试时用了和训练不一致的 mean 和 std;也有人训练时用 RandomResizedCrop,验证和测试忘了换 CenterCrop。这类问题隐蔽在“看起来都是 224x224”,但像素值分布完全不同:模型在验证集上看到的输入统计量和训练时一致,在测试集上就被打乱。
解决方法是把 transforms 定义抽成一个公共函数,训练、验证、测试全部调用同一个定义,而不是在三个文件里各自复制一份。更稳妥的做法是用一个配置文件统一保存 mean、std、resize 尺寸和 crop 方式,训练脚本和评估脚本都从配置文件读取。我在项目里加了这套配置之后,再也没出现过验证集和测试集之间莫名差几个点的情况,这就是个工程规范问题。
5.4 少数类被模型无视:加权采样与类别权重怎么配
现象是训练完看分类报告,多数类别的召回率都在 90% 以上,某些少数类只有 40% 甚至更低。原因是交叉熵损失对样本多的类别倾斜,模型只要把输出固定偏向高频类别就能把总体损失压得很低,少数类几乎没有贡献。
解决方向的常见做法有两个:一个是给交叉熵加类别权重,一个是使用WeightedRandomSampler做加权采样。我一般优先用加权采样,因为它直接改变模型每个 epoch 看到的样本构成,实现上也简单:
from torch.utils.data import WeightedRandomSampler class_counts = [len(os.listdir(os.path.join("data/train", str(i)))) for i in range(num_classes)] total = sum(class_counts) weights = [total / (num_classes * c) for c in class_counts] sample_weights = [] for path, label in train_dataset.samples: sample_weights.append(weights[label]) sampler = WeightedRandomSampler(sample_weights, num_samples=len(sample_weights)) train_loader = DataLoader( SignDataset("data/train", train_tf), batch_size=64, sampler=sampler, num_workers=4 )注意加了 sampler 之后,DataLoader 里不能再同时设置shuffle=True,否则会直接抛异常。采样器的num_samples一般保留为训练集总样本数,这样每个 epoch 的迭代步数和之前相当。如果少数类样本实在太少,加权采样会让模型反复看到同一批少数类图片,这时候就要回到数据增强去做更大力度的变换,比如旋转、平移和颜色抖动。靠采样和增强双管齐下,少数类召回率才有救。
5.5 视频帧上预测跳变:单帧分类的时序平滑
现象是模型在单张测试图上表现稳定,但接到真实视频流里,同一个标志在连续 30 帧里被判定为三个不同的类别,上一帧是限速 40,下一帧变成解除限速,再下一帧又跳回来。原因在于视频帧之间存在运动模糊、遮挡和视角变化,单帧分类天然不稳定,模型每一帧都在独立做决策。
解决的方法是引入时序投票。常见做法是维护一个长度为 K 的滑动窗口,每一帧把最新的预测结果推进队列,对窗口内所有预测做多数投票,输出投票后的类别。K 一般选 5 到 10,K 太小平滑效果不明显,K 太大会让标志切换时的响应变慢。这个方案不是模型层面的改动,而是后处理策略,实现成本低但效果显著。
如果把问题定位得更深一层,帧间跳变的根因还是分类器对模糊样本的置信度太低,这时候要在训练集里添加运动模糊增强,模拟车辆运动带来的拖影。我一般把这两件事一起做:训练时加模糊增强,推理时做时序投票,两者结合能把跳变频率降低一个数量级。
6. 让模型在小数据集上也能打的三个进阶技巧
如果你的数据集只有几千张图,前面几步跑完分数可能离上线还有差距。下面三个技巧不需要换 backbone,也不需要堆算力,是我在小数据集上最常用的提升手段。
6.1 迁移学习:冻结前几层还是全量微调
加载预训练权重后,常见做法是冻结前几个 stage,只微调后面几层和分类头,防止小数据集把底层通用特征带偏。对交通标志这类背景相对简单的数据,我通常只冻结 layer1 之前,从 layer2 放开。如果数据和 ImageNet 分布差异大,全量微调更合适,学习率放到 0.0003。判断标准很简单:第一个 epoch 验证集分数就高,说明预训练特征够用,少放开点;分数缓慢爬升,就多放开几层。
6.2 数据增强:在亮度扰动和随机擦除上多花时间
交通标志增强的重点不在翻转裁剪,而在光照和遮挡。我会在 transforms 里加随机亮度对比度扰动和 RandomErasing。前者解决夜间逆光,后者模拟路牌被树叶或车辆遮挡的实况。叠加后训练准确率会掉几个点,但测试集分数通常上涨,模型不再依赖某一小片纹理做决策。注意 RandomErasing 的概率不要超过 0.3,遮挡比例控制在 0.1 到 0.25 之间,太大反而毁掉标志特征。
6.3 最后一公里:用未标注的路侧截图试跑并手动检查错误
测试集跑完不算完。我习惯再找一批没标注过的路侧截图跑一遍推理,把置信度最高、最低和预测错误的样本各抽几十张人工过一遍。这个动作能暴露测试集覆盖不到的印刷变体、贴纸遮挡、极端天气褪色等问题。测试集 99% 的模型在真实截图里依然可能错得离谱,早发现比晚上线好。我的习惯是把这些错误图攒成一个 mini 坏样本集,每轮迭代后补充进训练集,这样比反复调网络结构提高得更快。希望帮到你。
本文还有配套的精品资源,点击获取