简介:面向交管部门与深度学习开发者的黑烟车自动识别系统完整方案,针对道路黑烟车人工监管费时费力的问题,运用主流目标检测方法实现自动识别。作者自建黑烟车图像数据集,配合图像增广、以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", encoding="utf-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_range=10, s_range=0.2, v_range=0.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 ±10,S/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(pretrained=True) 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 的 SGD,momentum 设 0.9,weight decay 设在 1e-4 附近。这里不建议一上来就用 Adam,Adam 在小数据集上收敛快但容易过拟合,后期泛化表现不如 SGD 微调稳定。
4.3 训练配置与 mAP 复现
整个训练过程的关键参数如下表,初始配置可以直接沿用:
| 参数 | 数值 | 说明 |
|---|---|---|
| 优化器 | SGD momentum=0.9 | 检测头主流选择 |
| 基础学习率 | 1e-4 | 检测头初始 lr |
| backbone 学习率 | 1e-5 | 迁移学习微调专用 |
| batch size | 8~16 | 视显存调整 |
| 学习率衰减 | step decay,epoch 60% 和 80% 处降 0.1 倍 | 后期收敛更稳 |
| 评价指标 | mAP @ IoU=0.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 导出再判断能不能验收,这套从数据标注到推理验证的闭环流程,比任何调参技巧都实在。资源包里的源码、数据集、使用文档和论文刚好把这条链路完整串起来了,希望帮到你。
本文还有配套的精品资源,点击获取