黑烟车自动识别系统:从数据集到ResNet迁移学习的目标检测实战 简介面向交管部门与深度学习开发者的黑烟车自动识别系统完整方案针对道路黑烟车人工监管费时费力的问题运用主流目标检测方法实现自动识别。作者自建黑烟车图像数据集配合图像增广、以ResNet为骨架的深度神经网络模型、迁移学习与参数调优最终在测试集上达到0.9752 mAP。压缩包共2000个文件以988个xml标注文件与902个jpg图像样本为主体另含55个png、18个py脚本、14个txt、6个doc及若干pptx、pdf文档可用于模型训练、数据标注与结果展示整体约74.53MB。目前已有227人学习浏览。资料涵盖完整源码、自建数据集、使用文档、毕业论文及答辩PPT等从数据准备到模型训练调参均有说明适合相关专业学生、算法工程师及环保监测项目参考可直接复现实验并扩展应用。1. 黑烟车自动识别一个把深度学习落到环保监管场景的完整工程第一次看到“黑烟车自动识别系统”这个项目时我其实不是先看论文而是先翻它数据集的构成和评估口径。因为这类基于深度学习的计算机视觉项目最容易出现的情况是训练集和测试集同源mAP 虚高一上真实监控就露馅。这套资源是武汉大学遥感信息工程学院一个完整的本科毕设工程自建黑烟车数据集、图像增广、以 ResNet 为骨架的目标检测网络、迁移学习和参数调优测试集上做到了 0.9752 的 mAP。压缩包里包含源码、数据集、论文和使用文档适合正在做目标检测毕设的学生、接触环保或交通监管项目的工程师以及想完整走一遍“数据到部署”流程的算法入门者。2. 自建黑烟车数据集标注口径、VOC 转 YOLO 与样本平衡策略2.1 监控视频截帧与场景覆盖黑烟车检测本质上是一个单类目标检测问题在输入图像中找到柴油车排气口附近喷出的黑烟烟羽用一个边界框把它框出来。和通用目标检测不一样的地方在于黑烟不是一个“实体”它是半透明的、边缘模糊的烟雾团外观受光照、风速、车辆负载影响极大这给数据构建带来了不少麻烦。这套资源里的数据集来自道路监控视频抽帧项目文档里的记录是 1 到 2 秒抽一帧避免连续帧之间画面过于相似导致训练集和测试集重叠。场景覆盖了晴天、阴天、傍晚三个时段车型集中在柴油货车、渣土车和部分老式柴油公交车。这里我特别提醒一句黑烟车的“黑烟”和车辆自身的颜色、路面的沥青色、建筑物的阴影在视觉上非常接近如果只靠颜色特征去识别模型大概率会把黑色物体全部当成目标。所以建数据集时除了正样本负样本无烟车流、空车道、阴影密集路段一定要单独攒一批否则后面误检会多到怀疑人生。2.2 标注格式与 VOC 转 YOLO 脚本项目用的标注工具是 LabelImg输出的原始标注是 Pascal VOC 格式也就是每个图像对应一个 XML 文件目录结构是这样的dataset/ ├── JPEGImages/ # 原图 ├── Annotations/ # VOC XML 标注 └── ImageSets/ └── Main/ # train.txt / val.txt / test.txt这里有一个容易被新手忽略的关键点标注框到底框什么。项目里明确要求只框“烟羽区域”不要框整辆车。因为模型的任务是定位烟雾而不是定位车框整辆车会把大量车身背景带进正样本导致特征不纯。这也是后面第五章节要讲的第一个大坑。如果你拿到源码后想用 YOLO 系列去复现需要把 VOC 的 XML 转成 YOLO 的 txt 格式。项目本身用的是 TensorFlow 系的检测管线但标注格式是可迁移的我通常会写一个这样的转换脚本import xml.etree.ElementTree as ET import os # 黑烟烟羽是唯一检测类别class id 固定为 0 CLASS_MAP {smoke: 0} def convert_annotation(xml_path, out_txt, img_w, img_h): tree ET.parse(xml_path) root tree.getroot() lines [] for obj in root.findall(object): name obj.find(name).text if name not in CLASS_MAP: continue bndbox obj.find(bndbox) xmin float(bndbox.find(xmin).text) ymin float(bndbox.find(ymin).text) xmax float(bndbox.find(xmax).text) ymax float(bndbox.find(ymax).text) # YOLO 格式要求中心点坐标和宽高全部归一化到 [0,1] x_center (xmin xmax) / 2.0 / img_w y_center (ymin ymax) / 2.0 / img_h w (xmax - xmin) / img_w h (ymax - ymin) / img_h lines.append(f{CLASS_MAP[name]} {x_center:.6f} {y_center:.6f} {w:.6f} {h:.6f}) with open(out_txt, w, encodingutf-8) as f: f.write(\n.join(lines))这个脚本的逻辑很直接从 XML 里读出 bndbox 的四角坐标换算成中心点坐标和宽高再除以图像的原始宽高做归一化。这里有一个脚本里体现不出来的潜在问题img_w 和 img_h 必须是原始图像的像素尺寸而不是经过预处理 resize 之后的尺寸。如果你在送进网络之前把图缩放到固定尺寸标注框对应的归一化坐标也要基于 resize 后的尺寸重新计算否则框会全部偏移。项目源码里这部分的处理放在了数据读取管道里转换脚本只是最原始的一层。2.3 正负样本比例与难例挖掘模型最后能跑到 0.9752 mAP样本比例的设计起了很大作用。项目里的经验数据是有烟帧和无烟帧的比例控制在 1:3 到 1:4 之间负样本不能太少否则模型会把注意力全部放在“识别烟雾”上完全丧失对“没有烟雾”的判断能力。实际训练中如果负样本占比低于 20%训练集 loss 下降得很快但验证集上误检率会明显偏高因为模型学会了“见到深色区域就兴奋”。难例挖掘也是绕不开的一步。第一轮训练完把验证集上预测置信度在 0.3 到 0.5 之间的“灰色地带”样本全部翻出来观察哪些是误检路灯杆与树影交叉处、隧道出口的光斑、黑色车顶反光这些图挑出来追加到训练集里再做一轮微调误检率能肉眼可见地降下来。提示判断标注口径是否一致可以把同一个目标在不同帧里的标注框做一次 IoU 统计框间 IoU 低于 0.5 的样本说明不同时间段的标注标准漂移了需要复查。3. 图像增广实操把千级样本撑到可训练规模3.1 为什么增广在黑烟车场景比调网络更出效果黑烟车数据集本身不大自建数据通常只有几千张而目标检测网络动辄上千万参数直接训练过拟合是无法避免的。增广在这里不只是“锦上添花”而是决定能不能训起来的关键。监控画面有它的特殊性同一摄像头角度固定、背景固定、光照变化连续如果不做增广模型很容易把“这条路、这棵树”记下来而不是学会识别烟羽本身。项目里的做法是围绕三个维度做增广光照变化模拟早中晚不同时段、几何变化模拟不同视角和车流位置、画质退化模拟监控压缩噪声。做完一轮增广后训练样本规模能扩大 3 到 5 倍模型在这个数据规模上才勉强达到“值得训练”的量级。另外提醒一句增广不是越猛越好。黑烟烟羽是有物理形态的烟雾总是向上飘散尾部拖得比较长。如果你做超过 15 度的旋转或者大幅度的透视拉伸烟羽形态会变得失真模型学到的就是变形的烟而不是真实的烟这在测试集上反而掉点。3.2 对图像和 bbox 同步变换的增广 pipeline增广里最大的坑是图像变换了标注框也得跟着变。很多人第一次写增广代码只处理了图像结果训练时模型看到的图像和标签对不上loss 居高不下。项目里用的是和 bbox 同步变换的 pipeline核心逻辑如下import cv2 import numpy as np def random_horizontal_flip(image, boxes): 水平翻转bbox 的 x 坐标要同步镜像 if np.random.random() 0.5: h, w image.shape[:2] image cv2.flip(image, 1) # 翻转后 x_center 变成 1 - x_center boxes[:, 0] 1.0 - boxes[:, 0] return image, boxes def random_scale_crop(image, boxes, scale_range(0.7, 1.0)): 随机缩放裁剪box 边界超出新图的部分直接丢弃 h, w image.shape[:2] scale np.random.uniform(*scale_range) nw, nh int(w * scale), int(h * scale) x0 np.random.randint(0, w - nw 1) y0 np.random.randint(0, h - nh 1) image image[y0:y0 nh, x0:x0 nw] # 坐标整体平移 boxes[:, 0] (boxes[:, 0] * w - x0) / nw boxes[:, 1] (boxes[:, 1] * h - y0) / nh boxes[:, 2] boxes[:, 2] * w / nw boxes[:, 3] boxes[:, 3] * h / nh # 过滤掉被裁剪到边界外的框 keep (boxes[:, 0] 0) (boxes[:, 1] 0) (boxes[:, 0] 1) (boxes[:, 1] 1) return image, boxes[keep] def hsv_jitter(image, h_range10, s_range0.2, v_range0.2): HSV 抖动模拟不同光照条件 hsv cv2.cvtColor(image, cv2.COLOR_BGR2HSV).astype(np.float32) hsv[:, :, 0] np.random.uniform(-h_range, h_range) hsv[:, :, 1] * np.random.uniform(1 - s_range, 1 s_range) hsv[:, :, 2] * np.random.uniform(1 - v_range, 1 v_range) hsv np.clip(hsv, 0, 255).astype(np.uint8) return cv2.cvtColor(hsv, cv2.COLOR_HSV2BGR)这几个函数对应增广的三个核心操作。random_horizontal_flip 里 boxes[:, 0] 是归一化后的 x_center翻转后中心点 x 坐标变成 1 - x_center这是最容易漏的一步。random_scale_crop 里随机裁剪后box 的坐标要减去裁剪偏移再除以新尺寸同时要把被裁剪掉的框过滤掉否则标签里会出现大量越界的框。hsv_jitter 只改颜色不改几何所以不需要动 bbox。项目里推荐的增广参数如下这个表格可以直接作为初始配置增广操作参数设置说明水平翻转概率 0.5模拟对向车道车辆烟流方向镜像随机裁剪缩放0.7~1.0裁剪后 resize 回原尺寸HSV 抖动H ±10S/V ±20%模拟早晚光照变化高斯噪声sigma 0.05模拟监控压缩画质对比度/亮度±15%配合晴阴天切换3.3 增广后如何确认没标歪增广代码写完第一个动作不是急着训练而是做可视化验证。把增广后的图像和对应的 bbox 画出来存成一张对比图肉眼扫一遍。我习惯的做法是把图像和框一起叠加绘制后再保存看帧数超过 200 张就基本能判断坐标系有没有对齐。这个环节再懒也不能跳因为一旦坐标偏移没有被发现后面整个训练过程都在学错误标签返工代价极大。还有一个操作上的细节验证集和测试集不要做增广用原始图像评估。否则你评估出来的 mAP 是“增广后的分布”上的成绩和你真正部署时的表现对不上这也是这个项目评估口径里的一个严谨之处。4. ResNet 骨架与迁移学习模型搭建和训练参数细节4.1 骨架选择ResNet-50 是折中解模型的 backbone 用的是 ResNet。选择 ResNet 而不是 VGG 或 MobileNet有三个实际考量残差结构让梯度跨层传播更顺畅训练深网络时不容易出现梯度消失ImageNet 上的预训练权重成熟且稳定迁移学习效果好后续接检测头时ResNet 的中间层特征可以直接被 FPN 或 SSD 结构复用灵活性高。在 ResNet-50 和 ResNet-101 之间项目最终选了 ResNet-50。从训练成本看101 层网络在自建小数据集上训练时间几乎翻倍从精度收益看在烟羽这种语义简单的目标上101 比 50 的提升往往不到 1 个点性价比比较低。如果你的机器是单卡而且显存不大我更推荐直接从 ResNet-50 起步。MobileNet 不是不能选但黑烟目标本身尺度和对比度差异很大轻量网络的特征表达力在小数据集上明显吃亏。4.2 迁移学习两阶段冻结策略数据集是自建的规模不大从零训练一个 ResNet 级别的检测网络基本不现实必须用迁移学习。项目里的做法是两阶段训练第一阶段冻结 backbone 的前几个 stage只训练检测头第二阶段解冻全部网络用更小的学习率微调。这个策略背后的道理是预训练权重已经学会了通用的边缘、纹理、颜色特征这些特征对“烟羽识别”依然有效早期阶段大幅更新 backbone 只会把预训练知识冲刷掉检测头是随机初始化的需要较高的学习率快速收敛所以第一阶段单独训练它更稳定。常见的配置如下# 两阶段迁移学习示意第一阶段冻结 backbone只训练检测头 backbone resnet50(pretrainedTrue) for param in backbone.parameters(): param.requires_grad False # 第一阶段全部冻结 detector_head DetectionHead(...) # 检测头 optimizer SGD([ {params: detector_head.parameters(), lr: 1e-4}, ]) # 第一阶段训练 6~10 个 epoch 后进入第二阶段 for param in backbone.parameters(): param.requires_grad True # 解冻 backbone optimizer SGD([ {params: backbone.parameters(), lr: 1e-5}, # 骨干网络 lr 小 {params: detector_head.parameters(), lr: 1e-4}, # 检测头 lr 大 ])参数分组的逻辑是backbone 用的是预训练权重学习率要低一个数量级取 1e-5检测头是从零训练取 1e-4。优化器用带 momentum 的 SGDmomentum 设 0.9weight decay 设在 1e-4 附近。这里不建议一上来就用 AdamAdam 在小数据集上收敛快但容易过拟合后期泛化表现不如 SGD 微调稳定。4.3 训练配置与 mAP 复现整个训练过程的关键参数如下表初始配置可以直接沿用参数数值说明优化器SGD momentum0.9检测头主流选择基础学习率1e-4检测头初始 lrbackbone 学习率1e-5迁移学习微调专用batch size8~16视显存调整学习率衰减step decayepoch 60% 和 80% 处降 0.1 倍后期收敛更稳评价指标mAP IoU0.5目标检测通用标准训练轮数25~30 epoch含第一阶段和微调0.9752 这个 mAP 是在自建测试集上得到的代表着模型在这个数据分布下的表现。我建议你看这个数字时清醒一点它证明了管线是通的、数据质量是合格的但不等于随便一个路口摄像头装上去都能跑出这个数。监控视角变了、光照条件变了、车型分布变了性能都会波动。真正要部署需要按项目里的思路扩场景、补数据、做难例挖掘而不是拿着这个权重直接上线。5. 黑烟车检测常见问题排查从漏检到误检的四个真实坑5.1 标注框框得太“大”mAP 虚高现象训练时 loss 能正常下降验证集 mAP 也不低但渲染出来的预测框比实际烟羽大一圈框内包含了大片车顶和背景。原因标注时把“整辆车”或“排气口附近一大片区域”框了进去。烟羽是半透明目标如果标注框里有 50% 以上的面积是车身或天空背景正样本特征就被“稀释”了模型实际学的是“车尾区域”而不是“烟羽形状”。解决统一标注口径只框烟羽最浓密的区域宁可框小一点不要框大。标完一轮后做一次 IoU 自检同一目标在不同帧中的标注框宽高比应基本一致如果分散度太大说明标注标准漂移需要返工。我在类似项目里一般会抽 10% 的样本人工复核低于 90% 通过率就全量重标。5.2 黑色车顶被当作烟羽现象推理阶段频繁把黑色集装箱卡车、黑色轿车车顶框出来置信度还不低。原因黑烟在视觉上就是一团黑色和深色车身、沥青路面在颜色特征上高度重合。如果训练数据里深色车身样本占比高且增广时的 HSV 抖动把亮度压得太低模型会倾向于“颜色越深越像烟”。解决负样本里刻意增加深色车辆的画面让模型知道“深色车顶不是烟”。同时降低 HSV 抖动的亮度扰动幅度V 通道扰动从 ±20% 收窄到 ±10%避免把烟羽的颜色分布拉到跟黑色车身完全重合。5.3 mAP 挺高换到真实视频却漏检严重现象自建测试集上 mAP 0.9 以上把模型接到一段新的监控视频上漏检率明显上升甚至某些有烟帧一个框都出不来。原因训练集和测试集同源都是同一批摄像头、同一时段的画面。模型实际上记住了场景背景而不是真正泛化到“识别烟羽”。这是自建数据集项目最容易踩的坑也是评估口径最大的陷阱。解决数据采集阶段就要刻意跨场景。至少找 3 个以上不同路段、不同高度的摄像头截帧测试集独立保留一个完全没参与训练的路口画面。如果项目资源包里的视频素材不够就先按当前模型能用的标准做但论文里务必写清楚这个局限性。5.4 解冻骨干网络后 loss 震荡现象第二阶段解冻 backbone 后loss 不降反升或者出现周期性震荡训练曲线像锯齿一样。原因解冻后 backbone 的学习率仍然沿用检测头的 1e-4对 pre-trained 权重来说步子太大预训练特征被快速改写网络进入不稳定区间。解决backbone 层学习率必须单独设我一般用检测头的十分之一也就是 1e-5。如果震荡还出现就再加一个 warmup前 500 步从 1e-6 线性升到 1e-5。这个操作短时间内可能看不出来但对后期收敛的影响非常明显。5.5 增广过猛推理置信度整体偏低现象加了增广后训练精度不错但推理时所有预测框的置信度都在 0.4 左右徘徊达不到阈值。原因增广强度超出真实数据分布。比如随机裁剪比例拉到 0.5裁出来的图里烟羽占比被过度放大或缩小旋转角度超过 15 度烟羽形态扭曲模型对“真实烟羽”的响应反而被削弱了。解决做增广消融实验逐项开关增广操作对比验证集 mAP 和置信度分布的中位数。增广的目的是模拟真实变化不是创造不存在的数据形态。黑烟烟羽是向上飘的这个物理约束决定了翻转可以、旋转有限、拉伸要克制。6. 推理验证的收尾工作badcase 导出与阈值固定6.1 一键验证脚本与 badcase 渲染训练收尾阶段最容易犯的错就是只看 mAP 数字就宣布收工。mAP 是一个聚合指标它掩盖了所有细节哪些户外光线条件下漏检了、哪些深色背景误报了、哪些目标尺寸过小没被检出全都被平均进了 0.9752 里。我在这类检测项目里的习惯是跑验证集时顺手把预测框和真实框同时渲染到图上输出到一个 badcase 目录专门存放预测和标注偏差比较大的帧。# 跑一遍验证输出 mAP badcase 图片目录 python eval.py \ --weights checkpoints/final.h5 \ --test_set data/val.txt \ --score_threshold 0.3 \ --nms_threshold 0.45 \ --badcase_dir runs/badcase这里的逻辑是score_threshold 决定什么样的预测框会被保留用于渲染0.3 是一个比较低的门槛目的是把“模型认为疑似但不太确定”的框也画出来暴露潜在误检nms_threshold 0.45 是 NMS 的 IoU 阈值低于它不会合并两个框用来观察模型是否产生了大量重叠框。两个参数故意设得比实际部署严格这样能多暴露问题。真正部署时 score 阈值可以提到 0.5 左右以减少误报但调优阶段一定要放宽看全貌。badcase 目录里每一张图都是一次改进机会预测框和真实框 IoU 过低的去看是标注歪了还是模型位置预测不准置信度高的误检框收集起来补进训练集。我从前交结果前只看一张 mAP 曲线图结果被复盘时一张漏检图当众打脸。从那以后我每次训练完都强制走一遍 badcase 导出再判断能不能验收这套从数据标注到推理验证的闭环流程比任何调参技巧都实在。资源包里的源码、数据集、使用文档和论文刚好把这条链路完整串起来了希望帮到你。本文还有配套的精品资源点击获取