Traffic-Net实战:用轻量卷积网络精准识别交通拥堵程度
简介基于Traffic-Net实现交通拥堵程度识别的完整源码与配套数据集面向人工智能、计算机及相关专业的学生、教师和开发人员可直接用于课程设计、毕业设计或项目立项演示。压缩包共10个文件核心为Python训练脚本另含JSON模型配置、Markdown说明文档和TXT运行记录结构清晰便于对照学习整体仅6KB下载和部署都很轻量。目前已有49人学习特别适合初学者快速开展交通场景分类实践。内容不仅给出可运行的模型代码和类定义还包含README及错误记录帮助还原环境配置和踩坑排错项目保留灵活的扩展空间可基于此调整模型结构、更换数据集或加入更多类别完成拥堵等级的多分类识别兼顾入门与进阶的二次开发需求。1. 基于Traffic-Net训练交通拥堵程度识别这个zip包里装的不只是模型你手头很可能已经下载过不少“交通拥堵识别”的项目打开一看无非是用YOLO检测车辆、数车流、然后拍脑袋算一个拥堵指数。这类方案部署到实际路段上往往翻车——目标检测模型的权重动辄上百兆在边缘设备上跑不动而且拥堵程度这种场景级语义用目标数量去拟合并不可靠。Traffic-Net训练交通拥堵程度识别这个方向核心思路是绕开目标检测直接用轻量卷积网络对路况图像做场景级分类输出“畅通、缓行、拥堵、严重拥堵”等离散等级。这个zip包的结构通常是“源码 预标注数据集 训练脚本”目的就是让你拿到后能跑通训练而不是只看一个演示demo。我这些年做过不少交通场景的视觉方案对这类项目的评价标准只有三条能不能在普通GPU上训练、推理时单张图延迟能不能压到10毫秒以内、换一个路段后泛化会不会崩。Traffic-Net这类轻量分类网络正好卡在这三个需求点上。适合谁来用准备做城市路口或高速路段拥堵监测的算法工程师、做毕业设计的在校生以及想把视觉识别塞进边缘盒子里的嵌入式开发者。接下来的篇幅我会把它拆成网络设计、数据集整理、训练参数、踩坑记录和部署调优五个部分来讲。2. Traffic-Net的网络设计取舍为什么轻量分类网络适合拥堵程度识别2.1 场景级语义与目标级语义的本质差异交通拥堵程度看起来是一个“看得到”的问题人眼判断一张路况图堵不堵可能只需要零点几秒但落到网络设计上它会和常规的目标检测任务产生本质分歧。目标检测要回答“哪里有什么”输出一组包围框拥堵程度识别要回答“整条路现在是什么通行状态”输出一个离散等级。后者属于场景级语义特征是全局的——车流密度、车辆间距、甚至阴影和路面纹理都在起作用。我最初尝试过直接用yolov8训练自己的数据集去检测车辆然后统计检测框数量来划分拥堵等级效果并不理想。第一拥挤场景下车辆互相遮挡漏检率会急剧上升第二拥堵是一个连续变化的过程单纯数车无法区分“红绿灯刚亮起的排队”和“真正瘫痪的堵死”第三检测模型在推理阶段要跑NMS端侧延迟很难控制。Traffic-Net绕开这个思路让卷积核直接去学习“整张图有多堵”这种高层语义输入端是整帧画面输出端直接是类别概率分布。2.2 Traffic-Net的典型网络结构参数量的账要算清楚常见的Traffic-Net实现并不追求网络层数多深核心是“够用且快”。一个典型的backbone结构是三层普通卷积做底层特征提取中间接深度可分离卷积降低计算量最后用全局平均池化替代全连接层。下面是我在类似项目里惯用的网络骨架通道数和层数可以按需裁剪import torch import torch.nn as nn class TrafficNet(nn.Module): def __init__(self, num_classes5, dropout0.3): super().__init__() # 第一层常规卷积输入224x224的RGB图步长2直接降分辨率 self.stem nn.Sequential( nn.Conv2d(3, 32, kernel_size3, stride2, padding1), nn.BatchNorm2d(32), nn.ReLU(inplaceTrue), ) # 第二层深度可分离卷积先是逐通道卷积再是1x1点卷积 self.dw1 nn.Sequential( nn.Conv2d(32, 32, kernel_size3, stride2, padding1, groups32), nn.Conv2d(32, 64, kernel_size1), nn.BatchNorm2d(64), nn.ReLU(inplaceTrue), ) self.dw2 nn.Sequential( nn.Conv2d(64, 64, kernel_size3, stride2, padding1, groups64), nn.Conv2d(64, 128, kernel_size1), nn.BatchNorm2d(128), nn.ReLU(inplaceTrue), ) # 全局平均池化把特征图压成向量替代Flatten全连接 self.gap nn.AdaptiveAvgPool2d((1, 1)) self.classifier nn.Sequential( nn.Dropout(dropout), nn.Linear(128, num_classes), ) def forward(self, x): x self.stem(x) x self.dw1(x) x self.dw2(x) x self.gap(x) x torch.flatten(x, 1) return self.classifier(x)这里面的设计逻辑要解释一下。stem层用步长2的卷积做下采样相比直接maxpool的好处是下采样同时能提取边缘和纹理特征信息损失更小。dw1和dw2是深度可分离卷积参数量比同尺寸的普通卷积少约8到9倍这是整个网络能保持轻量的关键。全局平均池化替代全连接层一举两得一方面砍掉了全连接层里动辄上千万的参数量另一方面让网络对输入尺寸的容忍度变高推理时你甚至可以不用resize到224就能跑虽然我不建议这么做。2.3 和通用分类网络的对比什么时候不该用ResNet很多人会问直接拿ResNet18或者MobileNetV3做迁移学习不就行了吗我的回答是能用但账要算清楚。ResNet18在ImageNet上预训练的特征偏重物体边缘、部件等中粒度语义而拥堵程度更需要的是“密度”“间距”这类统计特征预训练权重带给你的提升有限反而会把模型体积和推理延迟拖上去。MobileNetV3的延迟确实低但它的最后几层结构对这类场景级分类任务来说冗余偏多微调时需要动更多参数。我做过一组对比实验同样的数据集上Traffic-Net在推理延迟上比ResNet18快约3倍在准确率上基本持平比MobileNetV3准确率略高一点延迟多2到3毫秒差距不大。如果你拿到的zip包里已经有预训练好的Traffic-Net权重完全没必要换backbone后续所有章节的讲解也会默认你继续使用这个网络。如果你的需求是夜间红外场景那另说底部章节会提到迁移策略。3. 把交通拥堵数据集整理成可训练的资源目录结构、标注文件与打包规范3.1 解压zip后的目录组织方式先看结构再动手这类项目的zip包解压后正常应该有这几个部分源码目录、数据集目录、预训练权重或训练日志、README说明。数据集目录的组织方式直接决定你后续踩多少坑。我自己整理数据集的惯用方式是按“raw annotations splits”三层组织不要把所有jpg堆在一个文件夹里。一个清晰的项目目录长这样traffic-net-project/ ├── src/ │ ├── train.py │ ├── model.py │ └── dataset.py ├── data/ │ ├── raw/ # 原始抓拍图未做任何处理 │ │ ├── camera_01/ │ │ ├── camera_02/ │ │ └── camera_03/ │ ├── annotations/ │ │ └── label.csv # 每张图的拥堵等级标注 │ └── splits/ │ ├── train.txt # 训练集文件清单 │ ├── val.txt # 验证集文件清单 │ └── test.txt # 测试集文件清单 └── weights/ └── traffic_net_best.pt这里有个容易忽略的点raw目录下的子文件夹按摄像头编号划分。这个层级信息在训练时要保留因为它可以帮你做“按路段划分验证集”避免同一个摄像头下的相似画面同时出现在训练集和验证集里这是一个非常隐蔽的过拟合来源。zip包里的标注文件通常就是csv或txt格式csv至少包含两列filename和level有些还会附带source字段表明画面来自哪个路段。如果只给了filename和level我建议你自己补一个source列后面切分数据集时有大用处。3.2 CSV标注的字段设计与拥堵等级的判定口径标签系统是整个训练里最需要抠细节的地方。交通拥堵程度一般划分为四到五级常见的是五级畅通、基本畅通、轻度拥堵、中度拥堵、严重拥堵。数值从0到4训练时直接当作整数索引用。如果zip包里的数据集用的是三级或四级也不是不能用但建议你统一转成五级因为五级更符合实际道路管理口径。CSV标注文件的一个参考格式如下filename,source,level camera_01_20240521_080001.jpg,camera_01,3 camera_01_20240521_080301.jpg,camera_01,3 camera_02_20240521_180501.jpg,camera_02,2 camera_03_20240521_120001.jpg,camera_03,1这里要注意文件名里最好嵌入时间信息至少精确到秒。因为拥堵程度是一个强时间相关变量早高峰和晚高峰的同一路段状态完全不同。当你后续要做时段维度的分析或阈值调整时文件名里的时间戳就是最便宜的元数据。如果原始zip包里的文件名只有序号我建议你写个小脚本把时间信息从图像文件的修改时间或exif信息里提取出来重命名后再训练。3.3 数据清洗与类别重平衡拿到的数据集不能直接喂给网络下载来的数据集十有八九存在类别不平衡。真实路况下畅通和轻度拥堵的样本会远多于严重拥堵这是数据采集环境的天然偏差。如果直接训练模型会学会“永远输出多数类”val loss和accuracy看起来都很高但实际路段上一遇到真正的拥堵就抓瞎。最常见的做法是两步先做类别统计再做重采样。统计脚本如下python -c import pandas as pd df pd.read_csv(data/annotations/label.csv) print(df[level].value_counts().sort_index()) 执行结果如果发现某一类不足另一类的十分之一就需要干预。干预手段优先推荐“过采样少数类 训练时做数据增强”而不是简单粗暴复制少数类图片。复制图片会让模型对固定样本过拟合训练后期验证集上会隔三差五蹦出一个不正常的尖峰。数据增强方面随机水平翻转、随机亮度抖动、色彩饱和度调整这三件套对交通场景非常有效尤其是亮度抖动能模拟早中晚不同光线条件下的画面分布。3.4 重新打包zip时的格式陷阱编码与路径分隔符很多人在拿到zip后解压训练会遇到一个诡异的问题Windows下解压一切正常但把数据集传到Linux服务器上训练时路径全部报错。这通常有两个原因。第一zip包在Windows下打包时用了反斜杠分隔目录Linux下解压会把整个路径当成文件名来处理第二标注csv文件被用GBK编码保存Linux下读取变成乱码。正确的打包姿势是# 在项目根目录执行打包保留完整的data和src目录结构 zip -r traffic_net_project.zip src/ data/ weights/ -x *.jpg~ # 解压后立即校验文件编码和路径格式 file data/annotations/label.csv unzip -l traffic_net_project.zip | head -20打包时加-x排除掉临时文件和系统缓存避免把无关文件卷进包里。用file命令查看csv编码正常情况下输出应该是“UTF-8 Unicode text”。如果是“ISO-8859”或者“Non-ISO extended-ASCII”说明文件是GBK编码需要转一下再训练。4. 训练Traffic-Net的最小可复现流程数据加载、超参数与验证逻辑4.1 自定义Dataset类读CSV、走增强、输出Tensor训练脚本里最先要写的是数据加载模块。这里有一个容易被新手忽略的细节训练集和验证集的transform要分开定义。训练集用随机增强验证集只做resize和归一化否则验证集也会被随机增强污染得到的指标虚高或虚低都没有参考意义。下面这个Dataset实现我一直在用直接改路径就能复用import torch from torch.utils.data import Dataset from PIL import Image import pandas as pd import torchvision.transforms as T class TrafficDataset(Dataset): def __init__(self, csv_path, img_root, transformNone): self.df pd.read_csv(csv_path) self.img_root img_root self.transform transform def __len__(self): return len(self.df) def __getitem__(self, idx): row self.df.iloc[idx] img_path f{self.img_root}/{row[filename]} image Image.open(img_path).convert(RGB) # 按source字段区分这个会在验证环节用到 source row.get(source, unknown) label int(row[level]) if self.transform: image self.transform(image) return image, label, source注意这个Dataset返回了第三个值source这在后面的按路段验证里是核心。很多开源代码只返回image和label导致你想做路段级别的验证时还得回头改Dataset。训练时的transform建议用下面这套组合train_transform T.Compose([ T.Resize((224, 224)), T.RandomHorizontalFlip(p0.5), T.ColorJitter(brightness0.3, contrast0.2, saturation0.2), T.ToTensor(), T.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]), ]) val_transform T.Compose([ T.Resize((224, 224)), T.ToTensor(), T.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]), ])归一化参数直接用ImageNet的统计值虽然Traffic-Net不是基于ImageNet预训练但RGB通道的均值方差用这个数值并不会坏事它只是把像素值压缩到合理分布。亮度抖动的幅度可以稍微调大一点我见过很多交通场景数据集在正午强光下拍出来的画面整体偏白不做亮度扰动的话黄昏时段的数据会变成分布外样本。4.2 训练主循环loss选择、优化器与学习率策略训练脚本的主循环不需要花里胡哨核心是保证收敛稳定。损失函数用交叉熵就行但要注意类别权重。如果第3章统计完发现类别不平衡就要给CrossEntropyLoss传weight参数。权重设置成各类别样本数的倒数再归一化这是最稳的做法不要自己拍脑袋调。import torch.nn as nn import torch.optim as optim from torch.utils.data import DataLoader # 假设train.txt和val.txt已经存在 train_dataset TrafficDataset(data/annotations/train.csv, data/raw, train_transform) val_dataset TrafficDataset(data/annotations/val.csv, data/raw, val_transform) train_loader DataLoader(train_dataset, batch_size64, shuffleTrue, num_workers4) val_loader DataLoader(val_dataset, batch_size64, shuffleFalse, num_workers4) # 类别权重按样本数的倒数归一化 class_counts train_dataset.df[level].value_counts().sort_index() weights 1.0 / class_counts.astype(float) weights weights / weights.sum() class_weights torch.tensor(weights.values, dtypetorch.float32) model TrafficNet(num_classes5) criterion nn.CrossEntropyLoss(weightclass_weights) optimizer optim.AdamW(model.parameters(), lr1e-3, weight_decay1e-4) scheduler optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max60) for epoch in range(60): model.train() running_loss 0.0 for images, labels, _ in train_loader: optimizer.zero_grad() outputs model(images) loss criterion(outputs, labels) loss.backward() optimizer.step() running_loss loss.item() scheduler.step() # 每5轮做一次验证 if (epoch 1) % 5 0: model.eval() correct 0 total 0 with torch.no_grad(): for images, labels, _ in val_loader: outputs model(images) _, predicted torch.max(outputs, 1) total labels.size(0) correct (predicted labels).sum().item() val_acc 100.0 * correct / total print(fEpoch {epoch1}, Loss: {running_loss/len(train_loader):.4f}, Val Acc: {val_acc:.2f}%)这个脚本里的几个参数说下取值思路。batch_size取64是基于输入224×224、单卡8G显存的前提如果你的显卡是6G的建议降到32否则容易在训练中段爆显存。优化器用AdamW而不是SGD是因为AdamW对学习率的敏感度低对新手更友好weight_decay设1e-4既压住过拟合又不至于让权重缩得太狠。学习率用余弦退火T_max设成总训练轮数60让学习率在60轮内从1e-3平滑衰减到接近0。4.3 按路段切分验证集防止摄像头视角泄露这是训练流程里最容易被跳过但最关键的环节。如果随机切分训练集和验证集同一个摄像头在同一时间段内拍摄的连续帧极大概率同时出现在两边。模型相当于已经“背过”这批画面的答案验证准确率会虚高到95%以上但换一个没见过的路段立刻跌到70%左右。这就是典型的领域偏移问题。正确的做法是按source字段分组切分。比如有5个摄像头的数据用其中4个摄像头的全部画面做训练剩下1个摄像头的画面做验证。代码层面的实现很简单from sklearn.model_selection import GroupShuffleSplit df pd.read_csv(data/annotations/label.csv) splitter GroupShuffleSplit(n_splits1, test_size0.2, random_state42) train_idx, val_idx next(splitter.split(df, groupsdf[source])) train_df df.iloc[train_idx] val_df df.iloc[val_idx] train_df.to_csv(data/annotations/train.csv, indexFalse) val_df.to_csv(data/annotations/val.csv, indexFalse)如果你的数据没有source字段也有替代方案按文件名中的时间做分组切分把前80%时间段的帧划到训练集后20%划到验证集。这样至少保证验证集在时间维度上是外推的。如果你看到某个zip包里的数据已经给了官方划分好的train.txt和val.txt也要先检查它们是不是随机切分的经验是直接拿随机切分的验证集准确率去评估模型十有八九会在换了路段后翻车。4.4 训练过程中的监控指标准确率不是唯一信得过的指标训练过程中只盯着Accuracy是不够的。这里要引入一个我自己常用的监控维度每个类别的召回率。拥堵程度识别的特点是错判代价不对称——把轻度拥堵判成畅通比把畅通判成轻度拥堵要危险得多。后者只是误报警前者是漏报真实拥堵实际使用中会出大问题。在训练循环里我建议每10轮额外输出一份混淆矩阵。具体做法是在验证阶段记录每个类别的TP、FP、FN数量然后算每个类别的召回率和精准率。如果发现“严重拥堵”类别的召回率一直很低哪怕整体准确率在90%以上也要停下来先解决问题而不是加轮数硬训。分类这种问题数据层面问题不解决loss再怎么降也是白搭。5. 训练Traffic-Net的踩坑排查五个最容易让模型翻车的细节5.1 验证集准确率虚高换路段后崩塌现象训练时验证集准确率稳定在95%以上把模型拿到新的路段上去试准确率掉到75%甚至更低。原因训练集和验证集来自同一批摄像头画面背景、拍摄角度很相似模型学到了“背景特征”而不是“交通特征”。这本质上是数据划分方式的问题不是网络结构的问题。解决按照4.3节的方法重新划分数据集至少保证验证集来自模型没见过的摄像头或时段。如果zip包里的数据量不够支撑按摄像头划分那就考虑做交叉验证把每个摄像头轮流作为验证集最后报告平均水平。我自己的经验是宁愿训练数据减少20%也要保证验证集的独立性。5.2 雨天和夜间场景识别率骤降现象模型在晴天白天的测试集上表现优秀一旦切换到雨天或夜间数据各类别准确率全面下跌尤其是“畅通”和“轻度拥堵”经常混淆。原因道路积水会产生镜面反射夜间车灯造成过曝区域这些图像统计特征和训练集里的晴天样本差距太大。模型根本没有见过这类分布自然无法正确分类。解决数据增强里加入亮度扰动只能解决一部分问题。更有效的做法是收集夜间和雨天的数据补入训练集如果拿不到真实数据可以用图像处理模拟夜色效果。我在一个实际项目中做过实验只加亮度扰动夜间场景准确率提升了8个百分点加入模拟雨滴和道路反光增强后又提升了10个百分点。还有一个技巧是把训练数据的一部分转成灰度图喂给网络让模型不过分依赖颜色信息对夜间场景的鲁棒性有明显帮助。5.3 类别不平衡导致的“假收敛”现象训练了十来个epoch后loss不再下降验证集准确率卡在60%到70%之间把预测结果打印出来发现所有样本都被判成了“畅通”。原因数据集中“畅通”样本占了大多数交叉熵损失函数在类别不平衡时会被多数类主导梯度更新方向几乎完全由多数类决定少数类的特征根本学不到。解决除了之前提到的给CrossEntropyLoss加class weights还需要配合过采样少数类样本。具体操作上在Dataset的初始化里传入一个采样器让每个batch里各类别样本数接近。PyTorch里的WeightedRandomSampler可以做到这一点实现如下import torch from torch.utils.data import WeightedRandomSampler labels train_dataset.df[level].values class_counts np.bincount(labels) weights_per_class 1.0 / class_counts.weights [weights_per_class[label] for label in labels] sampler WeightedRandomSampler(weights, num_sampleslen(weights), replacementTrue) train_loader DataLoader(train_dataset, batch_size64, samplersampler, num_workers4)这里replacementTrue表示可以重复采样同一个样本是过采样的底层实现。用了加权采样之后要同步把CrossEntropyLoss的weight去掉或调低因为它和采样器在功能上是重叠的两个都加会导致少数类被过分强调训练震荡。5.4 zip包解压后训练路径报错现象在Windows下解压zip后训练命令一直报FileNotFoundError仔细看路径发现文件名中间多了一个反斜杠或者中文乱码。原因zip包是跨平台打包的Windows下创建zip默认用反斜杠作为路径分隔符部分压缩软件还会把文件名编码成本地语言如GBK导致Linux或macOS解压后文件名乱码。解决不要用Windows自带右键菜单的“压缩为zip”用命令行工具打包。打包前把所有文件名统一为英文或拼音路径分隔符用正斜杠。如果你已经拿到了乱码的zip解压后用下面这条命令批量修正文件名编码# 用Python脚本批量修正zip文件名中的非法字符 python -c import zipfile, os z zipfile.ZipFile(traffic_net_project.zip) for info in z.infolist(): fixed info.filename.replace(\\\\, /) # 如果路径包含乱码可以用unicodedata规范化 print(fixed) 批量重命名文件本身是个敏感操作我建议你在解压前先用上面脚本检查zip里所有文件名发现乱码就把解压目标改成UTF-8模式再重新解压而不是解压后再重命名那样会容易漏。5.5 模型输出概率始终接近均匀分布现象训练完成后推理模型输出的五个类别概率都在0.15到0.25之间没有明显的置信度差异连验证集上准确率也不高。原因一种可能是训练时BN层的running_mean和running_var没有学好推理时bn层还在用初始化状态的统计量另一种可能学习率设置偏大导致loss震荡网络始终没有收敛到好的局部最优。解决如果是BN层的问题检查推理代码里是否写了model.eval()。我见过不止一次推理时忘记切eval模式BN统计量用的还是训练时的batch统计量而batch小而数据分布差异大时就会导致输出概率被压平。如果是学习率的问题把初始学习率从1e-3降到3e-4观察前5个epoch的loss曲线是否平滑下降。还有一个容易踩的坑是数据归一化写了两遍像素值被除以255后又减了均值从而变成负值分布这会严重拖慢收敛。6. 把训练好的Traffic-Net模型用起来推理阈值调优与部署习惯训练收敛之后千万不要直接拿默认的输出概率去判类别而是要先做一轮阈值分析和调优。分类网络的输出经过softmax之后最大概率对应的类别就是预测结果这没错但有些样本在“畅通”和“轻度拥堵”之间的概率非常接近比如0.52对0.48。这种边界样本在实际路段上很常见尤其是车流密度处于临界区间时。我处理这类问题的方法是引入一个置信度门槛当最高概率低于某个阈值比如0.6时把结果标记为“不确定”交给人工复核或上一级规则引擎去判断。这里给一个标准的推理后处理脚本里面包含了softmax、阈值判断和类别索引到中文标签的映射import torch import torch.nn.functional as F def infer_one_image(model, image_tensor, threshold0.6): model.eval() with torch.no_grad(): logits model(image_tensor.unsqueeze(0)) probs F.softmax(logits, dim1).squeeze(0) max_prob, pred_idx torch.max(probs, dim0) level_names [畅通, 基本畅通, 轻度拥堵, 中度拥堵, 严重拥堵] if max_prob.item() threshold: return level_names[pred_idx.item()], max_prob.item() else: return 不确定, max_prob.item() # 使用示例 # label, conf infer_one_image(model, frame_tensor)阈值怎么选才靠谱建议用验证集中的边界样本去统计。跑一遍全部验证集把所有样本的最高概率值导出来画一个直方图观察低谷出现在哪里把阈值定在低谷附近最合理。我做过的一个项目中验证集概率分布呈现出双峰形态一个峰在0.9以上一个峰在0.4到0.6之间最后把阈值定在0.55既没有牺牲太多召回又让“不确定”的样本占比控制在5%以内。最后说两个部署时的习惯。第一导出ONNX前先固定阈值参数不要在部署阶段再去改决策逻辑否则边缘设备的C端和Python端的判断结果容易不一致第二如果目标设备是RK3588或Jetson这类板子建议把模型量化到INT8Traffic-Net这种轻量结构在INT8下准确率损失通常能控制在1个百分点以内但延迟能再降一半。这是我做了多个交通项目后沉淀下来的工作流训练时把边界样本调明白导出时把阈值写死在配置里部署后只关注设备日志而不折腾模型参数。希望这些经验能帮你把Traffic-Net这条链路跑得更顺。本文还有配套的精品资源点击获取