黑烟车自动识别系统实战:级联检测与阈值设计避坑指南

发布时间:2026/9/26 23:45:29
黑烟车自动识别系统实战:级联检测与阈值设计避坑指南 简介基于深度学习的黑烟车自动识别系统完整方案面向计算机科学与技术专业本科生尤其适合毕业设计、课程设计与综合大作业。方案以卷积神经网络为核心自动提取车辆尾气黑烟特征并完成分类识别覆盖数据预处理、模型训练、性能评估等完整流程代码均模块化设计并带注释便于二次开发与复现。压缩包共2000个文件以988个xml标注文件、491张jpg训练图像、18个Python源码、6个doc文档及论文PDF为主整体74.41MB目录按训练数据、算法源码、文档论文分类组织查阅方便。该项目在导师指导下完成评定成绩98分配套标注数据集、技术文档和学术论文可为读者提供从环境监测场景理解到模型落地的完整参考。目前已有44人学习适合需要高质量完整作品支撑的高年级本科生。1. 黑烟车自动识别不是目标检测的简单套用先想清问题再选模型傍晚物流园区出口一辆柴油货车刚起步排气管吐出一团浓黑烟。这套画面如果靠人盯视频一个人看16路监控撑不过20分钟环保监管真正要的是把每一次冒烟变成含时间、车牌、截图和置信度的证据链。基于深度学习的黑烟车自动识别系统就是把这套流程自动化目标检测锁车尾分类网络判烟团帧间确认压误报。我自己接手过几版这类项目先给想动手的人一句实话——难点不在模型在数据定义和阈值设计。下文直接讲清实现方案、可复制的源码结构和主要踩坑点。2. 方案选型与数据组织级联检测为什么比端到端更扛误报黑烟车自动识别系统的第一步不是选网络而是先想清楚你要识别的东西在图像里长什么样。黑烟的本质是柴油车尾气里的颗粒物对光线的吸收和散射它在画面里呈现为半透明、无固定轮廓、颜色从浅灰到深黑连续变化的气团。它没有刚性物体的稳定纹理也没有明确边界同一个烟团在不同相机的白平衡、曝光参数下拍出来几乎像两个东西。所以直接套用通用目标检测思路用深度学习模型端到端去画烟框通常会在真实路口翻车。模型很容易学到深色一团这种弱特征天空、树荫、湿路面、玻璃反光全都会触发。另一个问题是标注一致性同一个冒烟场景两个人画的烟框大小经常差一倍训练出来的模型预测框方差极大后处理根本没法收敛。下面把选型过程拆开讲。2.1 黑烟不是物体选型前先看烟雾的成像特征烟雾具有三个区别于普通检测目标的成像特征。第一是半透明性烟会透出背景纹理所以基于边缘梯度的特征不稳定第二是飘散性烟团形态在连续帧之间变化很快前后两帧的框几乎没有重叠第三是紧贴性黑烟的判定必须结合是否从车尾排出这个空间先验单独看一团灰色云朵无法确认来源。基于这三个特征常见的深度学习算法选型也就清晰了。语义分割网络可以像素级标注烟团边界边界精度最高但标注成本极高而且边缘部署帧率不好看适合科研验证和小范围单车道场景。让通用目标检测模型直接输出烟框工程上最简但误报率最不可控适合演示试点。真正适合正式执法取景的是级联结构先用检测网络锁定车尾再对车尾区域做裁剪交给一个轻量分类网络判断是否冒黑烟。2.2 级联检测路线车尾检测与黑烟分类职责怎么拆级联方案把任务拆成两个子问题。车尾检测只需要区分是车尾还是不是车尾这个问题非常接近通用目标检测模型不需要关心烟长什么样只负责输出车尾位置黑烟分类只需要对固定大小的ROI判黑烟/无烟不用去猜烟框在哪天然规避了烟团边界模糊的问题。两个模型分开之后调参空间也变大了。车尾检测模型在低光照下可以单独降低置信度阈值保证候选框不漏分类模型在雨天可以单独提高阈值避免水汽误判。资源占用上也有优势检测器不需要每帧都跑可以每3帧取一次分类器只对检测框做推理计算量小很多。如果用一个端到端模型同时出车尾和烟框任何一路阈值变化都会互相牵制排障时非常痛苦。方案优点缺点适用场景端到端YOLO烟框部署简单、pipeline短误报率高、标注标准难统一试点展示、快速验证车尾检测黑烟分类误报可控、模块独立调参工程链路多一步正式点位、执法取证语义分割烟团边界准、可做面积估计标注成本高、算力要求高科研、单车道小范围2.3 训练数据从哪来真实抓拍、合成样本与负样本配比黑烟车样本天然稀缺不是每个路口每天都有人冒烟。常见做法是三种来源混合。第一是真实抓拍在目标点位连续录制视频后抽帧按天气、时段、车道数归档第二是合成样本把已抠出的真实烟团用alpha混合或泊松融合贴到正常车尾图上用来补充烟形多样性第三是分类负样本包括正常尾气、白色水汽、扬尘、路面阴影、隧道口暗斑、雨天车窗水渍。合成样本的比例要控制住。我一般把贴图样本占比压在30%以内因为合成烟团的边缘和真实成像差距很大贴图太多会让模型学到锐利边缘反而拖累真实场景。负样本配比上正负比做到1:2到1:3负样本里还要保证一半是长得像烟的难负样本否则白天误报率根本压不下来。2.4 标注规范什么时候算黑烟什么时候算负样本标注规范是整个项目能否复现的根基。我们内部定过三条规则。第一黑烟判定以林格曼黑度约2级为界淡到能清晰透出背景的灰色尾气一律算负样本。第二烟在标注时只标烟团主体框紧贴车尾外沿并延伸出车尾后侧2到3米范围飘远半个车身以上的残余烟不画进去因为执法凭证必须证明烟来自当前车辆。第三连续视频只标烟团最清晰的那一帧过渡帧和模糊帧全部丢回负样本。这三条规则看起来简单但能直接影响模型能力边界。分类模型判断的是车尾附近是否有黑烟如果标注时把远处飘散的烟也画进来模型就会学到画面里只要有黑色区域就算烟误报随之而来。数据组每周会抽帧做交叉复核同一个烟被两个人标出不同框时以烟团可见面积最大的一版为准保证训练目标单一。3. 完整源码怎么组织训练、推理与业务触发的最小可运行路径标题里强调完整源码说明读者要的不是一个模型权重而是一套能从相机画面推到报警记录的工程。一个可维护的黑烟车自动识别系统源码核心由四块组成配置管理、模型推理、训练脚本、业务触发逻辑。缺了任何一块现场都没法长期跑。3.1 源码目录结构与模块划分我习惯把目录按职责切分配置和数据独立于代码这样更换点位或更新模型时不用动推理逻辑。black_smoke_system/ ├── config/ │ ├── settings.yaml # 模型路径、阈值、帧间确认参数 │ └── scenes/ # 按点位/时段加载不同阈值 ├── data/ │ ├── images/train # 车尾图与烟图 │ ├── labels # YOLO格式与分类标签 │ └── negative_samples/ # 难负样本单独归档 ├── models/ │ ├── car_detector.pt # 车尾检测权重 │ └── smoke_classifier.onnx # 黑烟分类权重 ├── scripts/ │ ├── train_detector.py │ ├── train_classifier.py │ ├── export_onnx.py │ └── offline_eval.py # 离线回放验证 ├── inference/ │ ├── detector.py # 车尾检测封装 │ ├── classifier.py # 黑烟分类封装 │ └── pipeline.py # 级联推理主逻辑 ├── utils/ │ ├── letterbox.py │ ├── image_aug.py │ └── logger.py # 推理日志与证据归档 └── main.py # 相机取流、触发、保存证据链config与代码解耦是最关键的设计。每个点位有自己的阈值文件换现场参数不需要重新部署程序data目录里负样本单独留目录训练脚本可以按需抽样不会被随机打散。models目录同时放pt权重和onnx方便训练调试和边缘端推理共存。3.2 推理侧代码检测器与分类器怎么拼成一条管线推理侧用onnxruntime做统一推断便于在x86和Jetson上共用同一套后处理代码。核心类是BlackSmokePipeline负责把车尾框和烟概率两个模型串起来。import cv2 import numpy as np import onnxruntime as ort class BlackSmokePipeline: def __init__(self, det_onnx, cls_onnx, det_input640, cls_input224, det_conf0.35, cls_conf0.40, iou_thres0.45): # 检测和分类分别加载会话GPU不行时自动回落CPU self.det_session ort.InferenceSession( det_onnx, providers[CUDAExecutionProvider, CPUExecutionProvider]) self.cls_session ort.InferenceSession( cls_onnx, providers[CUDAExecutionProvider, CPUExecutionProvider]) self.det_input det_input self.cls_input cls_input self.det_conf det_conf self.cls_conf cls_conf self.iou_thres iou_thres def letterbox(self, img, size): # 保持宽高比缩放用114做填充避免拉伸变形 h, w img.shape[:2] ratio min(size / h, size / w) new_h, new_w int(h * ratio), int(w * ratio) resized cv2.resize(img, (new_w, new_h), interpolationcv2.INTER_LINEAR) canvas np.full((size, size, 3), 114, dtypenp.uint8) x_off, y_off (size - new_w) // 2, (size - new_h) // 2 canvas[y_off:y_off new_h, x_off:x_off new_w] resized return canvas, ratio, x_off, y_off def detect(self, frame): # 车尾检测输出坐标换算回原图 det_in, ratio, x_off, y_off self.letterbox(frame, self.det_input) det_in det_in[:, :, ::-1].transpose(2, 0, 1)[None].astype(np.float32) / 255.0 preds self.det_session.run(None, {images: det_in})[0] boxes, scores parse_yolo_output( preds, ratio, x_off, y_off, self.det_conf, self.iou_thres) return boxes, scores def classify_smoke(self, frame, box, expand_ratio0.3): # 车尾框向后上方扩边ROI交给分类模型 x1, y1, x2, y2 box w, h x2 - x1, y2 - y1 x1 max(0, int(x1 - w * expand_ratio)) x2 min(frame.shape[1], int(x2 w * expand_ratio)) y2 min(frame.shape[0], int(y2 h * 0.15)) roi frame[y1:y2, x1:x2] roi cv2.resize(roi, (self.cls_input, self.cls_input)) roi roi[:, :, ::-1].transpose(2, 0, 1)[None].astype(np.float32) / 255.0 out self.cls_session.run(None, {input: roi})[0] return float(out[0][1]) # 假设输出两列0为正常1为黑烟parse_yolo_output负责解析检测头输出并做NMS输入是[1, N, 6]的预测数组逻辑与YOLO系列后处理一致。检测输入用640是因为要看清车尾与烟团的相对位置分类输入用224是因为判断烟的关键是颜色和纹理不需要过多细节反而能抑制细节噪点。expand_ratio先只扩横向y2只向下扩0.15因为黑烟绝大多数出现在车尾后上方向下扩多了会把路面阴影带进分类器。分类置信度阈值0.40只作为初筛真正触发报警要交给业务层的帧间确认逻辑。这样做的好处是分类模型偶尔单帧误判时系统不会立刻报警而是等连续帧投票。3.3 训练侧组织方式分类数据集的配比与欠采样分类训练脚本最需要注意的是负样本抽样。直接把全部负样本读进训练集正负比失衡且训练时间被浪费。我用自定义Dataset做随机欠采样把正负比锁在1:2.5左右。import random import cv2 import torch from torch.utils import data class SmokeDataset(data.Dataset): def __init__(self, smoke_list, normal_list, target_ratio2.5): self.smoke_files smoke_list # 负样本欠采样控制normal/smoke比例 sample_n int(len(smoke_list) * target_ratio) self.normal_files random.sample(normal_list, min(len(normal_list), sample_n)) self.files [(f, 1) for f in self.smoke_files] \ [(f, 0) for f in self.normal_files] def __len__(self): return len(self.files) def __getitem__(self, idx): path, label self.files[idx] img cv2.imread(path) img random_resize_crop(img, 224) # 模拟烟团画幅变化 img img[:, :, ::-1].transpose(2, 0, 1).astype(np.float32) / 255.0 return torch.from_numpy(img), labelsmoke_list里的正样本要先做增广包括亮度扰动、随机模糊、轻微旋转模拟烟团浓度变化和运动模糊。负样本不做额外增广保持原始成像状态避免把模糊学成烟的特征。训练时分类器用resnet18这类轻量backbone不要在几千张图的小数据集上直接上resnet50以上很容易过拟合。训练命令参考# 分类器训练入口 python train_classifier.py \ --data-dir data/classifier \ --backbone resnet18 \ --epochs 30 \ --batch 64 \ --lr 0.001 \ --output models/smoke_classifier.onnx # 车尾检测器沿用YOLO训练入口迁移自COCO不需要长训 python train.py \ --data data/car_det.yaml \ --weights yolov5s.pt \ --img 640 \ --batch 16 \ --epochs 80 \ --device 0车尾检测器用640输入、80轮足够因为车尾是强结构目标迁移学习收益大。分类器30轮属于保守值现场数据量少时先看验证集loss曲线不要盲目加轮数。3.4 触发逻辑与证据链保存帧间确认的主循环怎么写业务层最容易被忽略却是上线后最影响体验的部分。触发逻辑用置信度分档配合连续帧确认避免单帧瞬时干扰导致的误报。import collections class TriggerController: def __init__(self, conf_high0.70, conf_low0.50, frame_high2, frame_low4): self.conf_high conf_high self.conf_low conf_low self.frame_high frame_high self.frame_low frame_low self.hits collections.deque(maxlen10) def update(self, smoke_prob): # 高概率记2分中等概率记1分低概率记0分 if smoke_prob self.conf_high: self.hits.append(2) elif smoke_prob self.conf_low: self.hits.append(1) else: self.hits.append(0) high sum(1 for h in self.hits if h 2) mid sum(1 for h in self.hits if h 1) if high self.frame_high or (high mid) self.frame_low: return True return False def save_evidence(frame, box, prob, camera_id): # 保存原图、ROI裁剪、时间戳、置信度到按日期分目录的证据库 ts time.strftime(%Y%m%d_%H%M%S) cv2.imwrite(fevidence/{ts}_{camera_id}_full.jpg, frame) cv2.imwrite(fevidence/{ts}_{camera_id}_roi.jpg, crop_roi(frame, box)) write_json_record(ts, camera_id, box, prob)主循环里检测器每3帧跑一次分类器对每个车尾框都跑。触发后写入2分钟冷却时间防止同一条烟被连续抓拍。RingBuffer保存触发前2秒的完整视频段这样证据链不止有烟团截图还有冒烟全过程执法复核时能看清是哪个瞬间开始冒烟。4. 黑烟车识别落地避坑5个让误报率和漏检率翻车的真实问题这章是整篇文章里最值钱的部分。以下问题我都实际遇到过前三个让项目差点被叫停后两个属于上线后才暴露的边界情况。每条按现象、原因、解决三步写清楚。4.1 白天误报率居高不下先查负样本再查增广策略现象是大晴天白天每小时触发二三十次截图一看全是天空、水泥路面、树荫和玻璃反光。模型把深色斑块当成了烟尤其是逆光下的天空区域颜色和烟团非常接近。原因是训练集里正样本占比过高难负样本太少。模型见过大量黑色没见过和烟很像但不是烟的东西。解决方法是做难例挖掘把误报截图按周回灌训练集替换掉一部分容易辨认的干净路面负样本让难负样本占到负样本总量的50%以上。我做过一次对比难负样本比例从30%提到60%后白天误报率降了一个量级漏检率几乎没变。4.2 雨雾天几乎不可用增广样本和阈值分档怎么补雨雾天出现的典型情况是两种极端要么大量误报要么完全漏检。雾天时空气本身是灰白色的烟混在雾里分类器判断混乱雨天车尾反光和水渍又像暗色烟团。原因是训练集里缺少天气维度。解决分两步训练侧加入雨雾增广包括降低亮度、高斯模糊、随机雨线、雾化模拟让分类器见过低对比度下的烟样部署侧按天气切换阈值雨天把分类置信度下限提高0.10到0.15宁可漏检也不要误报因为雨天取证本身容易被质疑。4.3 多车道跟车时把烟认到邻车ROI越界要控制多车道排队起步的场景里黑烟车被前车挡住烟飘出来飘到旁边车道系统把烟记到了邻车头上车牌识别跟着错。原因是ROI横向扩边过大扩到了邻车区域。解决方法是限制扩边范围车尾框横向外扩不超过框宽的0.3倍如果画面里有车道分割线ROI不能越过分割线。另一个补救是加一个遮挡判断当车尾框内出现大面积非车辆色块时把当前帧缓存住等车辆分离后再判定。延迟一两秒触发可以接受车牌挂错在执法环节是完全不能接受的。4.4 快门与曝光让漏检翻倍相机参数要跟着模型一起调同一套模型在A点位漏检很少在B点位漏检明显增多。两边参数一模一样最后查出来是相机电子快门默认1/4000黑烟在高速快门下拍出来是一层很透的灰烟分类概率怎么都压不过阈值。原因是短曝光截断了烟团的亮度累积烟看起来不黑。解决方法是把相机设为快门优先固定在1/1000到1/2000之间关闭自动曝光白平衡手动固定。夜间不要用白光补光白光会把烟尘颗粒打亮改用红外补光烟在红外下仍然保持暗色特征。这个坑在方案设计阶段最容易忽略属于只调算法不看成像链路的典型教训。4.5 单帧触发误报、多帧触发漏报帧间确认逻辑要分档黑烟车起步冒烟往往只持续1到2秒如果按固定5帧确认会大量漏检改成单帧确认后瞬时阴影、水汽又会误报。原因是触发策略一刀切没有结合置信度。解决方法是按3.4节的分档逻辑高置信度0.70以上连续2帧触发中等置信度0.50到0.70连续4帧触发。黑烟浓度是渐变的烟团浓度最高那一帧通常出现在起步后第1秒分档确认能在保住短暂冒烟触发的同时压掉瞬时干扰。触发后加2分钟冷却证据链也不会被重复报警淹没。5. 参数调优与边缘端部署置信度、帧间确认与加速配置一次定明白深度学习模型跑通只算完成一半另一半在现场参数和部署形态。这一章把黑烟车自动识别系统里最容易反复改的参数一次讲清楚。5.1 检测置信度、分类置信度与NMS阈值的联动关系车尾检测的NMS阈值建议固定在0.45到0.50。车尾是刚性结构同一辆车不会同时输出多个语义不同的框阈值太高会导致重叠框残留分类器对同一车尾重复判烟拉高触发频率。检测置信度影响的是候选框数量调低到0.30左右检测器会输出大量低质量框分类器计算量成倍涨调高到0.50以上车尾被遮挡或逆光时漏检测整个链路直接失效。我一般把检测置信度放在0.35到0.45之间再往下调之前先确认GPU利用率还有余量。分类置信度是直接决定误报率的旋钮。单看分类器输出时0.40作为初筛合适真实触发采用分档策略也就是0.50到0.70这个区间用帧数换可信度。注意不要把分类阈值直接拉到0.80黑烟浓度变化的瞬间帧频繁落在0.60到0.75之间阈值太高会把真正的冒烟帧全部滤掉。5.2 晴天、雨雾、夜间的阈值组合一张参数表直接抄同一套模型在不同天气下应该加载不同参数。我按点位和时段拆分配置文件由主程序根据日出日落时刻和天气状态自动切换。场景检测置信度分类初筛高置信度触发中置信度触发曝光建议晴天白天0.400.400.702帧0.504帧快门1/1600固定白平衡阴天0.350.400.702帧0.504帧快门1/1250雨雾天0.350.500.803帧0.606帧快门1/1000开启雨刷联动夜间红外0.450.450.752帧0.554帧红外补光关闭白光雨雾天提高阈值是为了保执法证据质量夜间红外下烟团特征比白天更清晰所以检测置信度可以略高。这些数值是我在不同项目里调出来的起点每个点位的光照条件不一样上线后用离线回放修正。5.3 TensorRT加速与边缘端部署推理耗时先测再调边缘端常选NVIDIA Jetson系列或x86工控机加GPU。模型先转ONNX再转TensorRTFP16精度下检测640输入约10到20毫秒分类224输入约1到3毫秒。转引擎的命令如下# 把车尾检测ONNX转成TensorRT引擎FP16加速 trtexec --onnxcar_detector.onnx --saveEnginecar_detector.engine --fp16 # 分类模型同理固定batch1避免动态shape带来的额外开销 trtexec --onnxsmoke_classifier.onnx --saveEnginesmoke_classifier.engine --fp16一台边缘设备接4到8路相机时不要真的一路一路跑满帧。常见做法是每路按3到5 FPS抽帧把各路的推理请求排队到同一个GPU流上避免多路同时抢占导致帧吞吐骤降。Jetson上用TensorRT时注意显存和内存分离FP16引擎虽然显存减半但多路并行时CPU侧图像缩放反而容易成为瓶颈。5.4 离线回放验证上线前先用一小时录像说话所有参数改完必须用离线回放验证而不是直接推到现场。从目标点位下载覆盖白天、雨天、夜间的录像跑完整推理流程输出每帧判定和置信度到JSON人工复核误报和漏检。python offline_eval.py \ --video site_a_test.mp4 \ --config config/scenes/site_a_day.yaml \ --output result_day.json统计维度很简单误报率按次/小时漏检率按人工标记冒烟车辆里系统漏掉的比例。每次模型更新、阈值变更都要跑同一组回放录像防止改好了A点、打爆了B点的回归问题。这组回放录像就是项目最宝贵的资产比模型权重本身值钱。6. 从能跑到能上线最后三件事必须做在前面系统在测试机上跑通只能算demo真正能上线长期运行我靠的是最后这三件事。第一件事把误报库当代码资产来维护。每次现场报警截图里被人工否掉的图按周归档进负样本目录。我习惯建一个false_positives目录按周分区存放分类器重训时把这些图按较高权重混入训练集。新人接手项目时第一课不是看模型结构而是翻最近一个月的误报图比看十遍论文都有用。第二件事阈值必须按场景分文件不要一个全局配置打天下。config/scenes下每个点位都有自己的yaml主程序根据日出日落和天气状态自动切换配置。夜间和雨雾天用错白天参数是最常见的上线事故自动化切换比让现场人员手动调整可靠得多。第三件事模型文件、配置版本和证据链必须能对上。每次推理日志里写清楚模型版本号、阈值版本号、置信度、触发时间。模型命名带日期和数据版本类似smoke_cls_v3_20250115.onnx。一旦执法复核翻车按日志能快速定位是哪一版模型、哪个参数阈值造成的问题。我吃过一次大亏模型升级后没跑离线回归直接上线结果A点位误报率暴增运维连夜回滚。后来我养成了习惯所有模型和参数变更必须先过离线回放再小范围灰度最后全量推送。黑烟车自动识别系统拼的不只是深度学习算法而是数据、阈值、证据链一起迭代的工程能力。希望帮到你。本文还有配套的精品资源点击获取