ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

YOLOv5烟雾检测实战:小目标、误报与部署验收

YOLOv5烟雾检测实战:小目标、误报与部署验收

简介:面向安全监控与火灾预警方向的开发者,这份资料包提供一套可直接上手的YOLOv5烟雾检测方案:内含已训练模型、带标注数据集以及训练与推理代码,适合具备一定深度学习基础、希望快速搭建烟雾识别原型的读者。压缩包共2000个文件,约360.52MB,主要文件类型包括jpg图像、txt标签、xml标注,以及yaml配置、py脚本、pt权重等,覆盖数据准备、模型训练、评估与部署所需的关键材料,目录组织便于按模块查阅。已有2357人学习下载,说明该方案在目标检测实践中具有一定参考价值。读者可据此复现烟雾检测流程,理解标注格式转换、数据配置、模型微调与推理部署之间的衔接,并借助现成权重快速验证效果;也可在自有场景数据上继续调优,用于监控视频分析、无人机巡检或智能安防设备原型开发。

1. 从一次误报说起:烟雾检测方案要解决的真问题

某实验室的走廊摄像头连续三周报警,值守人员跑上去八次,七次是保洁推车带起的水汽。这件事之后,他们把原来那套基于颜色阈值和帧差法的方案全部下掉,换成了基于 YOLOv5 的烟雾检测。烟雾检测这件事的难处从来不在「模型能不能跑起来」,而在于三件事:数据集里到底有没有足够多「长得像烟但不是烟」的负样本、早期稀薄烟雾在 640 分辨率下还剩几个像素、以及误报压不下来时现场人员多久会把告警关掉。这套方案面向三类人:要交毕业设计或课程设计的同学、要给厂区仓库做一套可演示告警链路的开发者、以及手上有摄像头但缺一份能直接开训数据集的人。从选型、架构、训练到验收,把这条链路完整走一遍。

2. 需求与技术选型:为什么不上分类网络,也不上分割

2.1 检测、分类与分割三条路线的边界

分类网络整图输出一个「有烟 / 无烟」的概率。它便宜、训得快,但对小目标天然不敏感——全局池化把空间信息压掉了,一团只占画面 1% 面积的初期烟雾,扔进分类网络基本等于扔进噪声里。更要命的是报警时刻你说不清烟在哪。现场人员需要知道往哪个方位确认,系统也需要用区域信息去过滤走廊尽头那盏反光的应急灯。分割能给出像素级掩膜,理论上最贴合烟雾这种无定形目标,但标注成本是检测框的五到十倍,而且烟雾边界本身就带主观性——两个人标同一张图,边缘能差出一大截。标注一致性差,反而会往训练信号里灌噪声。检测框是折中点:标注快、评估标准明确(IoU 是可计算量)、能直接换算成告警区域。这就是大多数烟雾检测项目落在目标检测这条路线上的现实原因。

这里有个容易被忽略的差异:烟雾的「框」和行人的「框」完全不是一回事。行人有刚性边界,框紧一点松一点,IoU 都还稳;烟雾是半透明的、边缘渐变的,同一团烟不同人标出来的面积可能差 30%。所以训练时别指望模型学到精确边界,评估时也别照搬 COCO 那套严格阈值去卡自己。我一般把 mAP@0.5 当主指标,mAP@0.5:0.95 只作为「框稳不稳定」的辅助观察——后者掉得厉害但前者还行,通常说明标注口径松散,不代表模型废了。

还有一类需求经常被漏掉:多路视频。一个仓库八个摄像头,如果方案按单路设计,部署时会立刻撞上算力墙。这决定了后面选模型规模时不能只盯着精度看,得把「一路多少 FPS、总共几路」写进需求文档里。

2.2 YOLOv5 五个规模的权衡

YOLOv5 提供 n、s、m、l、x 五档,差别在网络深度和宽度。烟雾检测的实际情况是:早期烟雾目标小、特征弱,模型太小会漏;但烟雾纹理本身不像人脸那样需要极深的语义抽象,模型太大收益递减,还会把推理延迟拖垮,多路场景直接崩盘。

规模量级单帧延迟小目标召回典型用法
n最小最快差只适合做演示,别上生产
s约七百万参数快一般边缘设备、多路实时,首选起步档
m中等中较好服务器端跑四到八路,性价比最高
l大慢好单路高精度,训练成本明显上升
x最大最慢最好有充足数据和算力才考虑

我一般从 s 起步,先跑一版 baseline,然后看验证集上的漏检分布。如果漏检集中在画面远端那种只有几十像素的小烟团上,再往 m 走;如果连近处占满画面的大烟团都漏,那基本是数据问题而不是模型容量问题——换多大的模型都救不回来。这个判断顺序很关键,血泪经验是很多人一上来就上 x,训了两天发现是标注里漏了一批夜间样本。

另外别忽略预训练权重的价值。烟雾数据集通常就几千张,从零训收敛慢、还容易过拟合;从 COCO 预训练权重启动,前几十轮就能把 backbone 的通用特征用起来,小数据集上差别非常明显。

2.3 数据集构成与标注口径

数据集的价值不在数量,在负样本的「像」。烟雾检测误报的最大来源不是模型不行,是训练时压根没见过类似的东西:清晨的雾气、食堂排风口的蒸汽、工地扬尘、白墙被夕阳打出的高光、低码率摄像头下的运动模糊、车辆尾气。这些在特征空间里和稀薄烟雾高度重叠,模型没被明确告知「这些不是」,它就会自作主张。

正样本按三个维度分层,别只堆数量:

  • 浓度:浓黑烟、灰白烟、稀薄初期烟。第三类决定早期预警能力,必须占足够比例,通常不低于三成。
  • 场景:室内车间、室外堆场、走廊、夜间灯光下、逆光窗口。
  • 尺度:像素高度小于 32 的远景小目标要单独统计占比。如果全是占满画面的大烟团,训出来的模型在现场只能等火烧大了才报。

标注口径我一般定三条,写进数据集说明里,所有人都按这个来:框贴住肉眼可见的烟雾主体外沿,稀薄到几乎看不见的部分裁掉,不留「心理预期框」;连成一片的烟合并成一个框,不做逐缕拆分;被遮挡超过一半的目标不标。第三条尤其重要,因为强行标注遮挡样本会让模型学到「半截物体也算完整物体」的错误先验,推理时会对着墙角的一缕水汽输出高置信度。

3. 总体架构设计:采集、推理、决策、告警四层怎么切

3.1 四层链路与解耦原则

一个能上现场的烟雾检测系统,模型只是其中一层。整套链路我一般切成四层,每层只对自己的输入输出负责:

层职责输入输出出问题时的表现
采集层取流、抽帧、缩放视频流或本地文件统一尺寸的 BGR 帧花屏、卡顿、帧率不稳
推理层前向计算 + NMS帧框、类别、置信度框全消失或满屏爆框
决策层时序投票、区域过滤单帧检测结果事件对象告警抖动、漏告警
告警层落库、推送、去重事件对象通知重复轰炸值守人员

解耦的硬性要求只有一条:推理层不能知道告警层长什么样。它只吐一个标准结构的结果列表,不关心后面是发短信、写数据库还是点亮声光报警器。这样换模型、换推送通道互不影响。见过太多项目把推送逻辑写进推理循环里,后来要加一路摄像头都得改模型代码。

采集层的抽帧策略也值得单独说。不是所有场景都需要 30 FPS 全量推理——烟雾扩散是慢过程,从出现到形成可见烟团通常要几秒到几十秒。五到十 FPS 抽帧完全够用,算力直接省下三分之二。多路场景里这一刀砍下去,等于白捡两路摄像头的预算。

3.2 针对稀薄烟雾的检测头与输入尺寸调整

YOLOv5 默认三个检测头,stride 分别是 8、16、32,640 输入下对应 80×80、40×40、20×20 的特征图。远景里一团 20 到 40 像素高的初期烟雾,落到 stride 32 那层只剩一两个格子,网络基本学不到东西。这是漏检最主要的结构性原因。

两种做法。第一种是加一个 stride 4 的 P2 检测头:在 neck 部分再接一层上采样和特征融合,把更浅层的高分辨率特征也送进检测分支。好处是直接提升小目标召回,代价是显存和耗时涨得厉害,输入 640 时显存占用大概翻倍。第二种是把输入尺寸提上去,从 640 提到 960 甚至 1280,小目标在特征图上的像素数随之增加。

我一般先试第二种,因为改一行配置的事,效果立竿见影:

# 训练与推理时的输入尺寸要严格对齐,这里先定 960 # data/smoke_960.yaml 只放数据路径,尺寸参数通过命令行传 path: ./smoke_dataset train: images/train val: images/val nc: 1 names: ['smoke']
# 用 960 输入训一版,和 640 那版做对照 python train.py --img 960 --batch 8 --epochs 150 \ --data data/smoke_960.yaml --weights yolov5s.pt \ --project runs/smoke --name v2_s_960

--batch从 16 降到 8 是显存换来的:输入面积翻了一倍多,显存占用也跟着涨,8G 卡上 960 输入基本就是 batch 8 的天花板。如果显存实在不够,可以开梯度累积或者用--rect做矩形训练减少填充浪费。这一步之后还漏,再动结构加 P2 头——顺序别反,先动结构的话,你没法判断收益是来自检测头还是来自数据本身。

3.3 时序决策层:单帧结果怎么变成一次告警

单帧检测必然会抖:同一团烟这帧检出、下帧漏掉,置信度在 0.3 到 0.6 之间反复横跳。直接拿「置信度大于 0.5 就报警」这条规则上线,现场值守人员半小时内就会把告警关掉。工程上的做法是滑动窗口投票加空间连续性校验。

from collections import deque class SmokeEventTracker: """把逐帧检测结果收敛成一次告警事件。 两道闸门:窗口内命中比例达标 + 存在空间上连续的区域。 """ def __init__(self, window=15, hit_ratio=0.6, iou_gate=0.3): self.window = deque(maxlen=window) # 只保留最近 N 帧的命中标记 self.hit_ratio = hit_ratio # 窗口内至少多少比例有检出 self.iou_gate = iou_gate # 与上一帧框的最小空间重叠 self._last = None def update(self, boxes): # boxes: [[x1, y1, x2, y2, conf], ...],本帧所有烟雾框 self.window.append(1 if boxes else 0) if len(self.window) < self.window.maxlen: return False # 窗口没填满,先不做判断 if sum(self.window) / len(self.window) < self.hit_ratio: return False # 抖动太频繁,判为噪声 return self._stable_region(boxes) # 命中达标后才看空间连续性 def _stable_region(self, boxes): if not boxes: return False best = max(boxes, key=lambda b: b[4]) # 取置信度最高的框 if self._last is None: self._last = best return False # 第一帧只记录,不触发 if iou(best, self._last) < self.iou_gate: self._last = best return False # 位置跳变,判为随机误检 self._last = best return True def iou(a, b): ax1, ay1, ax2, ay2 = a[:4] bx1, by1, bx2, by2 = b[:4] ix1, iy1 = max(ax1, bx1), max(ay1, by1) ix2, iy2 = min(ax2, bx2), min(ay2, by2) inter = max(0, ix2 - ix1) * max(0, iy2 - iy1) union = (ax2 - ax1) * (ay2 - ay1) + (bx2 - bx1) * (by2 - by1) - inter return inter / union if union > 0 else 0.0

参数怎么定:window=15在 15 FPS 抽帧下对应一秒,hit_ratio=0.6意味着这一秒里至少九帧有检出才算数。这个组合把「一闪而过的误检」全部挡在门外,同时响应延迟控制在一秒左右,现场可以接受。iou_gate=0.3解决的是另一种误检——画面左边闪一下、右边闪一下,命中率达标但位置不连续,那多半是两个不相干的干扰源,不是同一团烟。

调参方向很明确:现场误报多就把hit_ratio提到 0.75,代价是告警延迟变长;漏报多就降到 0.5,同时把iou_gate放宽到 0.2。这两个数字不要拍脑袋定,拿一段真实的现场录像回放跑一遍,看误报和漏报各自减少多少,比在验证集上盯着 mAP 有用得多。

4. 训练流程落地:数据配置、超参与曲线判读

4.1 数据集目录与 YAML 写法

YOLOv5 的目录约定很死,但也正因为死,配对了就不会出问题:

smoke_dataset/ ├── images/ │ ├── train/ # 训练图像,jpg 或 png │ └── val/ # 验证图像 ├── labels/ │ ├── train/ # 与图像同名的 .txt │ └── val/ └── smoke.yaml
# smoke.yaml path: ./smoke_dataset # 数据集根目录,相对于训练脚本的工作目录 train: images/train val: images/val nc: 1 # 只检测烟雾一类,别把「火」和「烟」混成一个类 names: ['smoke']

标注格式是每行一个目标:cls cx cy w h,全部归一化到 0 到 1。常见翻车点是用 VOC 的 xml 直接改后缀成 txt——那个格式是绝对坐标加左上右下,网络读进去会得到一堆越界的框,训练照样跑,loss 也能降,但训出来的东西完全不能用。转换脚本一定要写,转换完抽十张可视化确认。

另一个坑是空标签文件。images/train下有一张图,labels/train下就必须有同名.txt,哪怕内容是空的。空文件是合法的负样本,YOLOv5 会把它当「这张图里没有目标」来学。文件缺失则是另一种处理——直接跳过这张图。很多人把无目标的图像挑出来删了标签,等于亲手把负样本全扔了,然后回头抱怨模型误报高。

nc这个参数也别乱加。有人把「白烟」「黑烟」「火星」分成三类,结果每类的样本都不够,模型在小类上根本学不动。除非三类在视觉上确实需要区分(比如只有黑烟才触发最高级告警),否则统一成smoke一类,用置信度和时序逻辑去做分级,比硬分三类靠谱。

4.2 从预训练权重启动的训练命令

python train.py \ --img 640 \ --batch 16 \ --epochs 150 \ --data data/smoke.yaml \ --weights yolov5s.pt \ --hyp data/hyps/hyp.scratch-low.yaml \ --cache ram \ --close-mosaic 10 \ --project runs/smoke \ --name v1_s_baseline

参数逐个说清楚,方便按自己的条件改:

  • --img 640:baseline 先用标准尺寸,后面再和 960 那版对照。
  • --batch 16:8G 显存下的经验值。显存够就提到 32,batch 大一点 BN 统计更稳;显存不够就降到 8,别硬撑去调小模型。
  • --epochs 150:单类检测任务配合余弦退火,通常 100 到 200 轮收敛。数据量少于两千张时可以从 100 起。
  • --weights yolov5s.pt:从 COCO 预训练启动。这是小数据集上能不能训出可用模型的关键,千万别从零开始。
  • --hyp hyp.scratch-low.yaml:低强度增强。烟雾数据量一般不大,用高强度增强(大角度旋转、强色彩抖动)会破坏烟雾的形态特征,反而过拟合。
  • --cache ram:把图像缓存进内存,IO 不再是瓶颈,训练速度提升很明显。内存小于 16G 时改用--cache disk。
  • --close-mosaic 10:最后 10 轮关掉 mosaic。mosaic 四图拼接在训练期很有用,但它制造的目标尺度和真实场景分布有偏差,收尾阶段关掉能让模型在真实分布上收敛得更干净。

这套参数是一个能跑出可用 baseline 的起点,不是最优解。真正的调参发生在看完第一轮结果之后。

4.3 曲线判读与调参:什么时候该加数据而不是调参

训练完先别急着看 mAP 那个数字,打开runs/smoke/v1_s_baseline/results.csv,看三条曲线的走向。三种典型形态对应三种完全不同的处理:

第一种,训练 loss 和验证 loss 同步下降,mAP 稳步上升。这是正常收敛,继续训或者加数据都行。

第二种,训练 loss 一路降,验证 loss 中途翘头往上走。这是过拟合。此时的正确动作是加数据或者加增强,不是加 epoch——加 epoch 只会让翘得更狠。如果数据实在加不动,可以尝试降低模型规模,s 换 n,或者提高 dropout、加大 weight decay。

第三种,两条 loss 都降得很平,mAP 卡在 0.4 以下上不去。这种情况八成不是调参问题,是标注问题:漏标、框太松、类别错、或者训练集和验证集分布差太远。先别动超参,抽五十张验证图把标注画出来肉眼过一遍:

import cv2, glob from pathlib import Path # 抽查验证集标注,按任意键翻页,ESC 退出 for p in glob.glob("smoke_dataset/images/val/*.jpg")[:50]: img = cv2.imread(p) h, w = img.shape[:2] lab = Path(p.replace("images", "labels").replace(".jpg", ".txt")) if not lab.exists(): continue for line in lab.read_text().strip().splitlines(): cls, cx, cy, bw, bh = map(float, line.split()) # YOLO 是归一化中心点 + 宽高,换回像素坐标才能画 x1, y1 = int((cx - bw / 2) * w), int((cy - bh / 2) * h) x2, y2 = int((cx + bw / 2) * w), int((cy + bh / 2) * h) cv2.rectangle(img, (x1, y1), (x2, y2), (0, 255, 0), 2) cv2.imshow("label_check", img) if cv2.waitKey(0) == 27: break

这个脚本干的事很简单,但省下来的时间非常可观。我见过一个项目卡在 mAP 0.35 反复调学习率调了两周,最后发现验证集里有一批图的标签是整个文件错位的——图像和标签文件名对不上,读进去的框全是别的图。这种问题曲线是看不出来的,只能靠可视化。

5. 常见问题排查:五个最容易翻车的点

5.1 数据与标注阶段的坑

现象:验证集 mAP 到了 0.85,现场却频繁漏检。

原因通常不是模型,是验证集和现场分布不一致。数据集里的正样本可能大多来自网上收集的浓烟图,背景干净、烟雾占画面三分之一;现场摄像头是广角、逆光、码率还低,早期烟雾只有指甲盖大小。模型在验证集上学到的是「大团黑烟」这个模式,现场根本喂不进同样的输入。

解决的办法是把现场截帧补进训练集,至少占总量的两成,并且专门抽一批小目标样本做分层评估。别只看总体 mAP,单独统计像素高度小于 32 的目标的召回率,这个数字才是现场早期预警能力的真实反映。

现象:模型对雾气、蒸汽、白色墙面疯狂误报。

原因很直接:负样本里没有这些东西。模型没见过「长得像烟但不是烟」的样本,它只能按颜色和纹理的相似度做判断,白雾和稀薄白烟在这两个维度上几乎一样。

解决的动作是把易混淆样本当成一个独立的采集任务来做,而不是等误报出现了再去补。具体做法是让摄像头在无烟状态下连续录二十四小时,把不同时段(清晨、正午、傍晚、夜间开灯)的截帧均匀抽样,全部作为负样本进训练集,标签文件留空。这批数据的价值往往比再找五百张正样本还高。补完之后重新训,误报率通常能掉一半以上。

5.2 训练与指标阶段的坑

现象:训练到一半显存溢出,或者 batch 加到 16 就报 OOM。

原因不一定是显存不够,很多时候是输入尺寸和 batch 的组合没算清。显存占用大致和batch × img²成正比,从 640 提到 960 相当于单张图面积涨了 2.25 倍,batch 必须同比降下来,否则一定爆。

解决时先看nvidia-smi的实际占用而不是标称显存,因为系统和其他进程会吃掉一部分。8G 卡上 640 输入配 batch 16 是安全线,960 输入就要降到 8 甚至 4。显存不够又不想降 batch,可以开--accumulate做梯度累积,用时间换显存;或者用--rect矩形训练,减少不同长宽比图像填充带来的浪费。

现象:验证集指标异常高,但换个场景就崩。

最常见的原因是数据泄漏:同一段视频的相邻帧被随机划分到了训练集和验证集。相邻帧之间差异极小,模型在训练时基本「见过」验证集的图,指标自然虚高。

解决办法是按视频片段划分而不是按帧随机划分。同一个视频源的所有帧只能进同一个集合,训练集和验证集的视频源不能有重叠。这个细节在数据量大的时候容易被忽略,但它是评估可信度的地基。划分完之后验证集指标通常会掉几个点,这才是真实水平。

5.3 部署推理阶段的坑

现象:训练时用的是 640,部署时为了看得更清楚改成 1280,结果精度反而下降。

原因是模型在固定尺度上学到的特征分布被破坏了。YOLOv5 的 letterbox 预处理会把图像等比缩放再填充,训练时 640 输入对应的目标像素尺度是固定的,推理时突然变成 1280,同一个烟团在特征图上的尺度翻倍,超出了网络学过的范围。

要改输入尺寸就重训,别在推理端单方面改。如果只是想看清远处,正确做法是重训一版 960 的模型,然后训练和推理都用 960,两边严格对齐。

现象:本地测试效果好,接到摄像头就各种异常框。

多半是预处理不一致。训练时用的是 OpenCV 读图(BGR 通道序)、letterbox 填充、除以 255 归一化;部署时如果用别的库读帧(比如某些流媒体库输出 RGB),或者自己写了 resize 但忘了保持长宽比,输入分布就偏了。

最稳的做法是部署时直接复用训练代码里的LoadImages或letterbox函数,一行都别自己重写。如果必须重写,就在两边各打印一次预处理后的张量均值和形状做对照,数字对上再往下走。这个对齐动作花五分钟,能省掉一整天的玄学排查。

6. 验收与答辩:怎么证明这套模型在现场真能用

验收这件事,最忌讳拿验证集 mAP 当唯一证据。mAP 是在一个静态数据集上算出来的,它回答不了「连续跑二十四小时会不会误报」「从烟出现到推送要几秒」这类现场问题。我在做验收清单时一般分两块:离线指标和在线指标。

指标类型具体指标合格线怎么测
离线正样本召回率(IoU 0.5)不低于 0.90独立验证集,按尺度分层统计
离线小目标召回率(高度小于 32 像素)不低于 0.75单独筛出小目标子集评估
离线负样本误报率不高于 5%用纯负样本集跑一遍
在线二十四小时误报次数每路不高于 2 次无烟场景连续跑一整天
在线单帧推理延迟不高于 80 毫秒目标硬件实测,取 P95
在线首次告警延迟不高于 3 秒从烟出现到推送的时间戳差

在线部分不要图省事用循环视频代替。循环视频里没有真实的光照变化、没有摄像头的自动增益抖动、没有背景里走动的人,这些恰恰是误报的主要来源。有条件就用真实点位录二十四小时,没条件至少录满一个完整的昼夜。

答辩环节的讲法也有讲究。与其说「我训了一个 YOLOv5,mAP 0.9」,不如换成三段式:先说场景约束(几路摄像头、什么硬件、要多快响应),再说方案选择(为什么是 s 不是 m,为什么加时序投票而不是单帧判断),最后说验证结果(在多少段真实视频上召回多少、误报从多少降到多少)。前两段证明你懂问题,第三段证明你解决的问题是真的。评审问得最多的问题永远是「你这个 0.9 是怎么来的、测试集和训练集有没有重叠」,提前准备好划分策略的说明,比多背几个指标数字管用。

还有一个容易被追问的点:告警压不下来时怎么办。老实说,光靠模型压不住,得靠分层——先靠时序投票压掉高频抖动,再靠区域掩膜屏蔽掉固定的干扰源(比如排风口),最后靠告警冷却时间避免重复推送。这三层里模型只占第一层,剩下两层是工程问题。能把这句话讲清楚的方案,比 mAP 高两个点的方案更容易被认可。

说个我自己的教训。早前做一个类似的项目,验收前一天才想起来测连续运行,结果跑满六小时之后误报突然变多,查了半天是摄像头的自动曝光在傍晚切换,画面整体亮度变了,模型把变亮的墙面当成了烟。从那以后我每次做这类交付,验收清单里强制加一条「连续运行不少于二十四小时,覆盖昼夜切换」,不管工期多紧都先跑起来,白天该干别的干别的。希望这套结构能帮到你,少走一段弯路。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进