☰
黑烟车自动识别系统:基于深度学习的CV毕设全流程实践
2026/10/1 4:11:40 网站建设 项目流程

简介:面向计算机视觉方向毕业设计或目标检测入门学习者,这套黑烟车自动识别深度学习方案完整覆盖了从数据准备、模型训练到结果评估与论文撰写的全流程。包内代码基于ResNet骨架搭建检测模型,配合自建带标注数据集、大量图像增广、迁移学习和参数调优,最终在测试集上取得0.9752的mAP,能够有效缓解交管部门长期以来对黑烟车人工监管费时费力的问题。压缩包共2000个文件,约75.27MB,以902个jpg车辆图像、988个xml标注文件、18个Python源码文件为主体,另含答辩PPT、论文文档、使用说明、实验记录及多种格式的辅助资料,目录结构清晰,便于按需查阅。目前已有358人学习/下载。整套材料强调从零搭建到结果复现的完整链路,既可作为毕业设计参考范本,也适合在此基础上扩展算法思路。

1. 黑烟车自动识别系统:把视频变成环保证据的一个 CV 毕设方向

路边固定摄像头拍到一辆柴油货车的屁股冒出一团黑烟,环保监测平台上就该自动弹出一条带时间戳、带车牌的抓拍证据。这套基于深度学习的黑烟车自动识别系统,就是干这个的:它把“人盯几十路监控”的苦差事,换成“模型先找出车辆尾部,再判定有没有冒黑烟”。做过的人都知道,难的不是找到车,而是烟这个东西本身——半透明、边缘模糊、随风飘散,还时常和阴影、雨雾混在一起。对毕设来说,这个题目好处很明显:任务边界清晰、可解释性强、有真实社会价值,数据、模型、部署、论文四件套能形成完整闭环。适合想认真做一次落地的同学,也适合拿来扩展成环保监测类的作品。

2. 数据先行:用合成烟羽把样本量做上去,这一关躲不掉

2.1 为什么黑烟样本只能靠“自己造”

做黑烟车识别第一个卡住你的不是模型,是数据。公开数据集里能找到车辆检测、车牌识别、烟雾火灾的数据集,但“柴油车尾气黑烟”这种垂直场景几乎没人专门整理过。你能拿到的通常只有两类东西:一是环保部门零星的一段路测视频,二是网上搜到的新闻片段。这些素材数量少、分辨率参差、拍摄角度不统一,直接拿去训练,模型连“烟该出现在车尾哪个位置”都学不牢。

常见做法是搭建一条“真实视频抽帧 + 合成烟羽叠加”的数据生产线。先找几段不同天气、不同时段的车尾监控视频,抽帧得到干净背景;再把黑烟纹理以半透明方式叠加到车尾排气管附近。这么做的好处是:每张真实背景图可以生成几十种不同形态的烟,样本量立刻上去,而且标注位置天然是已知的。

2.2 用透明纹理叠加生成烟羽:一个能直接跑的增强脚本

合成黑烟的思路是准备一张灰度烟羽纹理图,把它的亮度值转成透明度,再贴到车尾区域。下面这段代码基于 OpenCV 完成叠加:

import cv2 import numpy as np def overlay_smoke(frame, smoke_tex, center, max_size=160): """ 在车尾附近叠加一块半透明黑烟。 frame: 背景帧, BGR smoke_tex: 灰度烟羽纹理, 值越大表示烟越浓 center: 排气管大致位置 (x, y) max_size: 烟羽最大边长 """ h, w = smoke_tex.shape[:2] # 随机缩放烟羽,模拟烟团扩散程度 scale = np.random.uniform(0.6, 1.2) new_w = int(w * scale * max_size / 100) new_h = int(h * scale * max_size / 100) smoke = cv2.resize(smoke_tex, (new_w, new_h)) # 把灰度图归一化到 0~1,作为不透明度 alpha = (smoke.astype(np.float32) / 255.0) * 0.75 alpha = alpha[..., np.newaxis] x0 = int(center[0] - new_w // 2) y0 = int(center[1] - new_h // 2) x1 = min(x0 + new_w, frame.shape[1]) y1 = min(y0 + new_h, frame.shape[0]) x0 = max(0, x0) y0 = max(0, y0) roi = frame[y0:y1, x0:x1].astype(np.float32) a = alpha[:y1-y0, :x1-x0] # 黑烟颜色偏向深灰黑,以 BGR 形式直接压低亮度 dark = np.zeros_like(roi) blended = roi * (1 - a) + dark * a frame[y0:y1, x0:x1] = blended.astype(np.uint8) return frame

这段代码的核心是alpha通道:烟羽纹理越亮的位置透明度越高,叠加后原图被压暗得越明显。scale控制烟团大小,这里取 0.6~1.2 的随机值,模拟刚起步时的小烟团和行驶中的大烟团。0.75是最大不透明度,设太高会把背景完全盖死,失去“半透明烟羽”的真实感;设太低烟又不显眼,分类器学不到特征。实际调试时建议对同一张背景生成 30 张不同形态的样本,扫一眼确认烟的浓度梯度是渐变的,而不是一块生硬的灰斑。

2.3 三类标签与标注格式:轻烟、浓烟、无烟怎么划分

训练时标签不能只分“有烟/无烟”,你得把烟的浓度等级拆出来。我的分类标准是:无烟、轻烟(烟团面积小于车尾一半,透明度高,能看到车尾轮廓),浓烟(烟团覆盖车尾大部分区域,车尾细节基本不可见)。三类样本的比例控制在 2:3:5 左右,其中浓烟多一点,让模型先学会“明确的黑烟长什么样”,再用轻烟样本去拉低漏检。

合成样本的标注框信息不用手动打,因为烟羽叠加的位置是已知的,代码里直接生成 YOLO 格式的 txt 即可。但要注意:检测框的标签名不要叫 “smoke”,而是叫 “black_smoke” 配合一个浓度属性字段,比如black_smoke light和black_smoke heavy。后面训练时如果你用的是 YOLOv8 这种自带分类头的检测器,可以把浓度直接放进类别;如果走两阶段路线——先检车尾再判烟的方案,浓度就作为分类模型的标签,和检测模型解耦。

3. 模型选型与训练:两阶段为什么比端到端稳

3.1 检测加分类分离,抗背景干扰能力更强

端到端方案是输入整帧图像直接输出黑烟框和置信度。听起来很省事,但实际效果并不稳定:烟是整个画面里占比很小、对比度又低的目标,让一个检测模型同时完成“找车”和“找烟”两件事,它很容易被车身上的深色图案、挡泥板阴影带偏,训练时 loss 来回震荡。毕设场景里评审看的是“能不能稳定抓拍”,而不是“有没有 fancy 的端到端结构”,所以我一般推荐两阶段方案。

第一阶段用 YOLOv8 检测车辆尾部区域;第二阶段把裁剪出来的车尾图喂给一个轻量分类器,输出无烟/轻烟/浓烟。这样做有三个好处:分类器看到的全是车尾局部,背景被裁掉了,不容易被路面颜色干扰;两阶段可以分别调优,检测模型 mAP 掉了不用动分类模型;推理时可以只在检测框置信度高于阈值时才跑第二段,省算力。

3.2 用 YOLOv8 在自建黑烟数据集上跑通训练

训练数据准备好之后,按 YOLOv8 的标准目录结构组织数据集:

smoke_dataset/ ├── images/ │ ├── train/ # 约 6000 张 │ └── val/ # 约 800 张 ├── labels/ │ ├── train/ # 对应 txt 标注 │ └── val/ └── smoke.yaml # 类别配置

数据目录里每张图对应一个同名 txt,内容是class x_center y_center width height,坐标全部归一化到 0~1。smoke.yaml里写入类别名和路径:

path: ./smoke_dataset train: images/train val: images/val names: 0: vehicle_tail 1: black_smoke

训练命令按 YOLOv8 的写法执行即可:

yolo detect train \ data=smoke_dataset/smoke.yaml \ model=yolov8n.pt \ epochs=120 \ imgsz=640 \ batch=16 \ lr0=0.005 \ mosaic=0.5

参数里有三个值得注意的地方。mosaic=0.5要调低:黑烟车数据里车尾和烟的位置关系很重要,mosaic 拼接会把一辆车的车尾和另一辆车的烟拼在一起,产生大量“车不对烟”的假样本,前几轮训练会把模型带偏。imgsz=640不需要加太大,烟羽是小目标没错,但检测阶段只需要框住车尾和烟团的大致范围,真正的精细区分交给分类器,图像再大收益有限,显存倒是吃得很猛。lr0=0.005对yolov8n这种小模型比较稳,如果你换成yolov8s以上,建议降到 0.002,不然前 10 个 epoch 的 loss 容易出现平台期。

3.3 第二段分类模型:ResNet18 就够,别一上来就上大网络

当检测框画出车尾之后,裁剪出来的图像尺寸其实很小——大多在 96×96 到 160×160 之间。这种分辨率下用 ResNet18 已经绰绰有余,往大了换 ResNet50 不仅训练慢,还容易在少量真实样本上过拟合,验证集准确率反而不如小模型。

import torch import torch.nn as nn import torchvision.models as models model = models.resnet18(pretrained=True) model.fc = nn.Linear(model.fc.in_features, 3) # 无烟/轻烟/浓烟 # 冻结前 4 层,只微调最后两层和全连接,避免在小数据集上把底层特征带偏 for name, param in model.named_parameters(): if name.startswith("layer4") or name.startswith("fc"): param.requires_grad = True else: param.requires_grad = False

冻结策略是这类小数据分类任务里容易被忽略但很有用的细节。烟和普通物体的视觉差异主要在高频纹理和局部明暗上,底层卷积学到的通用边缘、颜色特征本来就够用,动了反而可能被标注噪声带飞。只放开layer4和fc,让模型在“表层语义”上去适应烟的浓度差异,训练更稳。优化器用 SGD 加动量,学习率设 0.001,比 Adam 在小数据集上收敛更平滑。训练时把裁剪图做随机平移和亮度抖动,不要做水平翻转,因为排气管位置在图像里是有物理含义的,翻转之后烟的方向就反了。

4. 时序判定与部署兜底:单帧检测不能当证据用

4.1 为什么单帧结果靠不住

模型训练完,很多人直接把单帧检测结果当输出,结果拿路测视频一跑,发现同一辆车在 2 秒内被报了三次,其中一次是车尾阴影被当成黑烟。这个问题的根源在于:单帧图像的信息量是有限的,黑烟和深色车厢、车辆起步时扬起的尘土、雨天轮胎带起的水雾,在单张图上都表现为“车尾区域变暗”,模型很容易混淆。但真实场景里,黑烟的浓度是波动的,烟团会持续变化,阴影不会在几百毫秒内突然变浓又变淡。

所以部署时一定要加时序判定:连续若干帧里,如果车尾区域持续被判定为黑烟状态,才触发抓拍证据链。这样既能滤掉单帧误报,又能把“烟在生长”的过程记录下来,作为后续人工复核的依据。

4.2 连续 N 帧投票判定:一个带缓冲队列的实现

每天多长时间的判定窗口需要根据摄像头帧率来定。主流监控摄像头帧率在 12~25 FPS,我一般取 5 帧里至少 3 帧命中黑烟作为触发条件,既保证抗抖动,又不会因为烟团在帧间短暂消失而漏掉真实冒烟事件。直接上代码:

from collections import deque class SmokeVoter: def __init__(self, window=5, hit_thresh=3): self.window = window self.hit_thresh = hit_thresh self.buffer = deque(maxlen=window) def add_frame(self, smoke_label, confidence): # smoke_label: 0 无烟 / 1 轻烟 / 2 浓烟 # confidence: 分类器输出的置信度 is_smoke = 1 if smoke_label >= 1 and confidence > 0.6 else 0 self.buffer.append(is_smoke) if len(self.buffer) < self.window: return False return sum(self.buffer) >= self.hit_thresh def reset(self): self.buffer.clear()

这里把轻烟和浓烟都计为命中,但置信度阈值不一样会更合理。比如浓烟要求置信度大于 0.5 就算数,轻烟要大于 0.75 才算数,否则轻烟误报会直接拉高触发率。这个投票机制的问题在于缓冲队列是无状态的,如果一辆车在画面里停了 10 秒持续冒烟,队列会不停地触发;所以实际应用里还要配合车辆跟踪 ID,同一个跟踪 ID 在 3 秒内只允许触发一次。

4.3 抓拍与回放存储设计:关键帧加前后 3 秒

触发判定之后,真正要存下来的不是一张图,而是一小段证据视频。我的做法是:触发时保存当前帧作为“关键证据帧”,同时从环形缓冲区里取出触发前 3 秒的视频帧、以及触发后 2 秒的视频帧,合成一个 5 秒的短视频片段。这样人工复核时能看到烟从无到有再到消散的完整过程,比看一张静态图好判断得多。

这段存储逻辑在工程上不复杂,但有一点要注意:监控画面里通常还有一个气体流动方向的问题。排气管喷出的烟会往斜后方飘,所以你在叠加合成数据时,烟羽中心位置不能始终固定在车尾中心,否则视频判定阶段模型对“烟飘出车尾框”的那几帧会突然丢检测。我在部署时一般会预留一个路线上固定的 ROI(感兴趣区域),只统计 ROI 内的黑烟比例,不在全图上做检测,这样能大幅减少公交车、行人等非目标对象的干扰。

5. 黑烟车识别的 5 个常见坑:从弱烟漏检到逆光误报的排错记录

5.1 轻烟样本太少,导致弱烟几乎全部漏检

现象:模型对浓烟抓得特别准,置信度能到 0.9 以上;但视频里一辆车只冒淡淡一缕烟,模型完全没反应。检查分类阈值降到 0.3 之后,能检出一些,但伴随大量误检。

原因:合成数据时为了效果明显,把烟羽叠加浓度都往浓了调,轻烟样本占比不到 20%,模型学到的本质是“大片暗色区域”,对透明感强的弱烟没有特征响应。

解决:把轻烟样本比例提到 30% 以上,叠加时把 alpha 最大值从 0.75 降到 0.35~0.5,并增加烟羽透明度渐变的模拟。另外在分类模型的训练集里轻烟图片不要只裁取烟团局部,要保留一部分车尾边缘,让模型学会“透过烟能看到车尾轮廓但局部对比度下降”这种弱特征。

5.2 深色货箱和地面阴影被当成黑烟

现象:晴天下午,一辆深蓝色货车的车尾在阳光照射下形成明暗分界,分类器输出轻烟置信度 0.82,触发了误报。

原因:黑烟和阴影的视觉特征高度相似,都表现为图像局部变暗、饱和度降低。两阶段方案裁剪车尾区域后,阴影和烟在局部图像里更难区分,模型只能靠“颜色暗”这个粗特征做判断。

解决:强制分类器的训练数据里加入“深色车尾 + 无烟”“车尾带阴影 + 无烟”类别,这是真实的硬负样本。我一般在相同时段去采集 30 辆不同颜色货车的车尾图,只标成无烟,加权到训练集里。另一个技巧是给分类器加一个motion_feature输入——连续两帧之间的差分图拼接在 RGB 三通道后面,烟是动态的,差分特征明显,阴影是静态的,差分接近零。

5.3 阴雨天和雾天整条路都在误报

现象:雨天模型触发率暴增,主干道上每十几秒就报一次。打开触发视频一看,车尾轮胎带起的水雾被模型识别成了黑烟。

原因:水雾和黑烟在单帧视觉上确实相似,都是半透明灰白色或灰黑色区域。但水雾集中在轮胎下方,且范围会快速扩散、消散,位置不稳定;黑烟从排气管喷出后有明显的持续浓度。

解决:把检测区域约束在排气管高度范围内,水雾因为位置偏下,会落到 ROI 外面。同时缩短投票窗口,从 5 帧命中 3 帧改成 6 帧命中 4 帧,让水雾那种“散得快”的干扰更容易被过滤。如果雨势太大,可以加一个天气状态开关:连续多帧整帧平均亮度低于某个阈值时,降低触发灵敏度。

5.4 帧率不匹配导致同一辆车重复触发

现象:模型明明已经触发过一次并保存了证据,但同一辆车在后续 20 秒里又被触发三次,存储里出现大量重复片段。

原因:视频流在隔帧检测时,跟踪 ID 没有建立;或者说你是按照“每 3 秒重置一次投票状态”来做的,而一辆车从进入画面到驶出画面可能持续 15 秒,中途只要有一次投票积攒了 2 帧又被打断,下一个 3 秒窗口重新累计,等于同一辆车的事件在时间上被切成了多段。

解决:用 IoU 跟踪给每辆车的检测框分配固定 ID,触发时以 ID 为粒度去重。更简单的做法是给触发事件加冷却时间,比如触发后 8 秒内同一个位置的检测框不再重复触发。冷却时间的长度取决于车速和画面宽度,一般取车辆从出现到驶出画面的 1/3 时长,我的经验值是 6~10 秒。

5.5 训练数据里使用了水平翻转,导致大量虚影干扰

现象:模型在训练集上 AP 有 0.85,放到路测视频里却对车尾左上角的一团虚影反复报烟。检查那辆车的实际车尾,干净无烟。

原因:合成烟羽时考虑了排气管位置,但训练检测模型时开启了 YOLO 默认的fliplr=0.5,水平翻转后烟羽被翻到车身另一侧,产生了 200 多张“烟在左侧、排气管在右侧”的伪样本。模型学到的位置特征被稀释了。

解决:训练配置里关掉fliplr,改成 0。烟羽方向在监控场景里是物理确定的,不需要翻转增强;如果觉得样本多样性不够,改用随机旋转 15 度以内或者随机遮挡车尾局部再让分类器判断,效果更好。

6. 交作业前的验证技巧:拿一段没进过训练集的路测视频测事件级召回

6.1 用事件级召回替代 mAP 讲故事

毕设答辩时,模型 mAP 只是一个数字,评审更关心的是“这套系统在路上能不能把冒烟车抓出来”。mAP 是逐帧计算的平均精度,而实际使用中你关心的是事件级结果:某辆车冒烟了,系统有没有至少触发一次报警。所以我在交付前会准备一段 30 分钟、包含约 10 次真实冒烟事件的路测视频,手工标记出每次冒烟事件起止时间,再跑一遍系统统计触发事件,用匹配逻辑计算召回率。

# 简化的事件级召回统计 def eval_event_recall(gt_events, det_events, iou_t=0.5): """ gt_events: [(start_frame, end_frame, vehicle_id), ...] det_events: [(start_frame, end_frame, vehicle_id), ...] """ matched_gt = set() for det_s, det_e, det_id in det_events: for gt_idx, (gt_s, gt_e, gt_id) in enumerate(gt_events): if gt_idx in matched_gt: continue if det_id != gt_id: continue overlap = min(det_e, gt_e) - max(det_s, gt_s) if overlap > 0 and overlap >= iou_t * min(det_e - det_s, gt_e - gt_s): matched_gt.add(gt_idx) break recall = len(matched_gt) / len(gt_events) fp_rate = (len(det_events) - len(matched_gt)) / max(len(det_events), 1) return recall, fp_rate

这个脚本里的匹配逻辑是按跟踪 ID 和时间窗口双重对齐:如果检测到的事件和真实事件是同一辆车,且两者时间窗口的重叠长度超过其中较短窗口的一半,就算命中。把误报次数也一并算出来,答辩时你能直接说“30 分钟路测视频里 10 次冒烟事件命中 9 次,误报 1 次”,这个说服力比任何训练曲线都强。

6.2 交付前再抠这三个细节

第一,把所有测试视频的帧率和分辨率统一转成和训练数据一致,不能用 4K 的测试视频去跑一个在 720p 上训练的模型,分辨率差异直接改变烟羽的纹理尺度。第二,给抓拍证据统一烧录时间戳和车道编号,这是毕设展示环节最容易被忽略但评审一定会问的地方。第三,建议在答辩演示里加一张“烟羽浓度随时间变化”的曲线图,从视频里提取连续帧的黑烟面积比例画成折线,既直观展示时序判定逻辑,又能验证调查取证过程的可追溯性。我自己当年第一次跑通这个系统时就被单帧 mAP 骗了,觉得效果很好,结果一上真实监控视频全是翻车现场,后来退回数据层面把轻烟样本做足、加上时序投票,性能才真正稳下来。做完上面这几步验证,再写使用文档和论文也就水到渠成,希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询