YOLOv5烟雾检测实战:小目标、误报与部署验收
简介面向安全监控与火灾预警方向的开发者这份资料包提供一套可直接上手的YOLOv5烟雾检测方案内含已训练模型、带标注数据集以及训练与推理代码适合具备一定深度学习基础、希望快速搭建烟雾识别原型的读者。压缩包共2000个文件约360.52MB主要文件类型包括jpg图像、txt标签、xml标注以及yaml配置、py脚本、pt权重等覆盖数据准备、模型训练、评估与部署所需的关键材料目录组织便于按模块查阅。已有2357人学习下载说明该方案在目标检测实践中具有一定参考价值。读者可据此复现烟雾检测流程理解标注格式转换、数据配置、模型微调与推理部署之间的衔接并借助现成权重快速验证效果也可在自有场景数据上继续调优用于监控视频分析、无人机巡检或智能安防设备原型开发。1. 从一次误报说起烟雾检测方案要解决的真问题某实验室的走廊摄像头连续三周报警值守人员跑上去八次七次是保洁推车带起的水汽。这件事之后他们把原来那套基于颜色阈值和帧差法的方案全部下掉换成了基于 YOLOv5 的烟雾检测。烟雾检测这件事的难处从来不在「模型能不能跑起来」而在于三件事数据集里到底有没有足够多「长得像烟但不是烟」的负样本、早期稀薄烟雾在 640 分辨率下还剩几个像素、以及误报压不下来时现场人员多久会把告警关掉。这套方案面向三类人要交毕业设计或课程设计的同学、要给厂区仓库做一套可演示告警链路的开发者、以及手上有摄像头但缺一份能直接开训数据集的人。从选型、架构、训练到验收把这条链路完整走一遍。2. 需求与技术选型为什么不上分类网络也不上分割2.1 检测、分类与分割三条路线的边界分类网络整图输出一个「有烟 / 无烟」的概率。它便宜、训得快但对小目标天然不敏感——全局池化把空间信息压掉了一团只占画面 1% 面积的初期烟雾扔进分类网络基本等于扔进噪声里。更要命的是报警时刻你说不清烟在哪。现场人员需要知道往哪个方位确认系统也需要用区域信息去过滤走廊尽头那盏反光的应急灯。分割能给出像素级掩膜理论上最贴合烟雾这种无定形目标但标注成本是检测框的五到十倍而且烟雾边界本身就带主观性——两个人标同一张图边缘能差出一大截。标注一致性差反而会往训练信号里灌噪声。检测框是折中点标注快、评估标准明确IoU 是可计算量、能直接换算成告警区域。这就是大多数烟雾检测项目落在目标检测这条路线上的现实原因。这里有个容易被忽略的差异烟雾的「框」和行人的「框」完全不是一回事。行人有刚性边界框紧一点松一点IoU 都还稳烟雾是半透明的、边缘渐变的同一团烟不同人标出来的面积可能差 30%。所以训练时别指望模型学到精确边界评估时也别照搬 COCO 那套严格阈值去卡自己。我一般把 mAP0.5 当主指标mAP0.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、32640 输入下对应 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, window15, hit_ratio0.6, iou_gate0.3): self.window deque(maxlenwindow) # 只保留最近 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, keylambda 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参数怎么定window15在 15 FPS 抽帧下对应一秒hit_ratio0.6意味着这一秒里至少九帧有检出才算数。这个组合把「一闪而过的误检」全部挡在门外同时响应延迟控制在一秒左右现场可以接受。iou_gate0.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 640baseline 先用标准尺寸后面再和 960 那版对照。--batch 168G 显存下的经验值。显存够就提到 32batch 大一点 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 秒从烟出现到推送的时间戳差在线部分不要图省事用循环视频代替。循环视频里没有真实的光照变化、没有摄像头的自动增益抖动、没有背景里走动的人这些恰恰是误报的主要来源。有条件就用真实点位录二十四小时没条件至少录满一个完整的昼夜。答辩环节的讲法也有讲究。与其说「我训了一个 YOLOv5mAP 0.9」不如换成三段式先说场景约束几路摄像头、什么硬件、要多快响应再说方案选择为什么是 s 不是 m为什么加时序投票而不是单帧判断最后说验证结果在多少段真实视频上召回多少、误报从多少降到多少。前两段证明你懂问题第三段证明你解决的问题是真的。评审问得最多的问题永远是「你这个 0.9 是怎么来的、测试集和训练集有没有重叠」提前准备好划分策略的说明比多背几个指标数字管用。还有一个容易被追问的点告警压不下来时怎么办。老实说光靠模型压不住得靠分层——先靠时序投票压掉高频抖动再靠区域掩膜屏蔽掉固定的干扰源比如排风口最后靠告警冷却时间避免重复推送。这三层里模型只占第一层剩下两层是工程问题。能把这句话讲清楚的方案比 mAP 高两个点的方案更容易被认可。说个我自己的教训。早前做一个类似的项目验收前一天才想起来测连续运行结果跑满六小时之后误报突然变多查了半天是摄像头的自动曝光在傍晚切换画面整体亮度变了模型把变亮的墙面当成了烟。从那以后我每次做这类交付验收清单里强制加一条「连续运行不少于二十四小时覆盖昼夜切换」不管工期多紧都先跑起来白天该干别的干别的。希望这套结构能帮到你少走一段弯路。本文还有配套的精品资源点击获取