基于YOLOv8与传统视觉的森林火灾双通道识别方案
发布时间:2026/9/30 9:36:39来源:尧图网络
简介基于计算机视觉的森林火灾识别算法设计面向计算机视觉、人工智能与森林防火交叉领域的学习者和研究人员。资源从图像获取、图像预处理、特征提取、火灾识别到火灾预警系统梳理了一套完整的检测流程其中对颜色、纹理、形状等特征选择方式以及支持向量机、随机森林、神经网络等机器学习模型的应用思路均有所介绍并兼顾了数据结构与深度学习技术。文末还讨论了图像质量、环境干扰、算法复杂度等工程约束便于读者判断方案的可行性与改进方向。压缩包内为一个PDF文件大小约5.54MB内容集中适合快速通读也可作为课程设计、毕业设计或实际监测项目的前期参考。目前已有194人学习/下载值得相关方向读者关注。1. 森林火灾识别不只是“火焰检测”这个标题在解决什么问题基于计算机视觉的森林火灾识别算法设计看起来是在讲“用摄像头识别火”但真正做过林区项目的人会告诉你火焰检测只是明牌烟雾检测才是黑匣子。森林火灾的早期信号绝大多数是烟柱而不是明火而烟是半透明的、飘忽的、没有清晰轮廓的传统阈值方案会在云和雾气上反复翻车。这篇文章从一个可落地的双通道方案讲起火焰走目标检测烟雾走运动检测加颜色特征最后在决策层融合。适合正在做林区监控、边缘设备部署或者计算机视觉课程设计的读者照着可以把准确率从“演示能跑”推进到“现场敢用”。2. 方案选型火焰用目标检测烟雾用传统视觉为什么分两条腿森林场景和城市安防最大的差别在于背景本身就充满“假阳性”。树枝晃动、云影移动、阳光在叶片上的反光、远处河流的水面闪烁这些在像素层面都像“运动物体”。如果只靠一个模型想把火焰和烟雾同时端到端地检测出来数据标注环节就会先崩溃火焰有明确边界可以画框而烟雾的边缘是渐变的标注员自己都说不清框该画到哪里。所以这里有个常见的工程做法把问题拆成两条独立的技术路线。火焰目标刚性、颜色集中橙红到亮黄白、形态随燃烧状态变化这类任务交给单阶段目标检测最合适烟雾则反过来它几乎没有稳定的形状特征但有两个相对稳定的物理特性——它在运动而且它会让背景的纹理变得模糊、颜色向灰白偏移。这两条路线分别建模最后融合是野外环境下最不容易翻车的结构。2.1 火焰检测为什么选 YOLOv8 而不是更重的网络火焰检测的第一个选型约束来自部署位置。林区的摄像头挂在铁塔或者山头近端通常只有一个嵌入式盒子没有 GPU 服务器图像通过有线或者无线链路传回后端。所以“能在边缘设备上跑实时推理”是刚需这就把不少精度高但计算量大的二阶检测器排除掉了。Faster R-CNN 在小目标上的精度确实更好但它的两阶段结构在边缘盒子上很难跑到实时帧率功耗也压不下来。YOLOv8 这一代把 anchor-free 和解耦头结合起来n 系列模型大小在 5MB 量级边缘推理的帧率普遍可以接受。对于森林火灾这种单一目标、背景相对固定的场景yolov8n 往往已经够用s 系列则适合摄像头视野内火焰只占极小像素、需要更多特征容量的情况。下表是常见选型对比数据是基于同类项目经验的参考值实际以你的设备和数据集为准。模型边缘推理速度参考小目标表现部署生态适合场景YOLOv8n约 30-60 FPS一般ONNX/TensorRT/NCNN 都有现成路径塔架摄像头、低功耗盒子YOLOv8s约 20-40 FPS略好同上模型体积翻倍视野更广、火焰像素更小的场景Faster R-CNN边缘盒子难以实时好转边缘部署成本高有 GPU 后端、不做实时告警选 YOLOv8 还有一个现实原因预训练权重覆盖面广迁移学习收敛快。火焰数据集的样本量通常只有几千到几万张远远撑不起从零训练一个检测器。用 COCO 上训练好的权重做微调一百多个 epoch 就能看到可用的权重这对项目周期有限的团队非常重要。2.2 烟雾检测为什么保留传统 CV 分支烟雾检测如果也走深度学习路线首先要面对的就是标注一致性问题。烟雾边缘模糊不同标注员画的框 IoU 可能只有 0.4 上下模型学到的边界是噪声。其次烟雾在远处常常只有一小片半透明的区域目标检测模型对“低对比度的柔软目标”天然不敏感这跟火焰是完全相反的两个方向。传统视觉的分支则更稳。烟雾在监控视频里有两个特征躲不开第一是它一定在运动第二是它会改变所在区域的背景统计特性。帧间差分能感知“局部持续发生的运动”HSI 颜色空间里烟雾表现为低饱和、中高亮度这两个特征都不依赖精确的边界框。把它们组合在一起就能得到“这个区域既在动、又变灰白模糊”的判据误报率远低于单用任何一个特征。这套思路在算法设计上其实是把“检测”降维成了“分割后判别”不需要定位出烟柱的左上角和右下角只需要在图像中找到可疑区域并判断它是否符合烟雾的物理特征。对林区监控来说这种模糊的定位足够触发告警因为后续还要靠人确认。2.3 数据集准备公开数据、自采与标注的七三开数据集决定了模型性能上限后面所有的调参都只是在逼近这个上限。火焰检测的数据准备我的经验是“七三开”七成用公开的火灾图像集和视频抽帧三成根据你实际部署场景自采或者网上现搜火灾现场图补进去。全部依赖公开数据的模型在真实场景里往往因为相机白平衡、分辨率和角度差异而变笨。自采数据的重点不是拍得漂亮而是要覆盖相机看到的真实状态。比如摄像头装在铁塔上朝下俯拍跟网上很多平视火灾照片的角度完全不同再比如夜间火焰和白天火焰的颜色分布差异极大公开数据里夜间的占比往往偏低。标注用 LabelImg 画矩形框就行类别就写 fire不要画烟雾框给 YOLO 用——烟雾框的质量问题在上一节已经说过。标注完成后写一个脚本把图片和标注文件划分成训练集和验证集这一步看起来简单但很多人直接手动拖文件夹结果训练集里某段连续视频的帧全挤在一起验证时假指标高得离谱。按文件哈希或者时间戳抽样能有效避免这个问题python import os import random import shutil from collections import defaultdictrandom.seed(42)img_dir raw_images label_dir raw_labels train_img, train_label dataset/images/train, dataset/labels/train val_img, val_label dataset/images/val, dataset/labels/valfor d in [train_img, train_label, val_img, val_label]: os.makedirs(d, exist_okTrue)按视频名分组同一视频的帧必须进同一集合避免信息泄漏groups defaultdict(list) for fname in os.listdir(img_dir): video_id fname.rsplit(_, 1)[0] # 假设文件名如 fire_001_00012.jpg groups[video_id].append(fname)all_groups list(groups.keys()) random.shuffle(all_groups) val_count max(1, int(len(all_groups) * 0.2)) val_groups set(all_groups[:val_count])for gid, frames in groups.items(): is_val gid in val_groups for fname in frames: src_img os.path.join(img_dir, fname) src_lbl os.path.join(label_dir, fname.replace(.jpg, .txt)) dst_img os.path.join(val_img if is_val else train_img, fname) dst_lbl os.path.join(val_label if is_val else train_label, fname.replace(.jpg, .txt)) shutil.copy(src_img, dst_img) shutil.copy(src_lbl, dst_lbl)print(train:, len(os.listdir(train_img)), val:, len(os.listdir(val_img))) 这段脚本的核心是按视频分组而不是按帧分组。如果同一段视频的连续帧一部分进训练集、一部分进验证集模型相当于提前“见过了”验证集的相邻状态验证集 mAP 会虚高。这里用文件名里_前的视频编号做分组实际项目里按你的命名规则调整前缀即可。划分比例 0.2 是常用值数据量小比如只有几百张时可以改成 0.15但别低于 0.1否则验证集太薄置信度和类别损失曲线波动会很大看不出真实收敛趋势。3. 火焰检测模块YOLO 训练配置与推理落地数据准备好之后接下来的全部工作几乎都压在“训练配置”和“结果筛选”上。这一节给出一个能直接抄的训练流程包括标注格式转换、训练命令参数说明、以及从验证结果里挑出可用权重的方法。3.1 数据标注格式从 VOC 到 YOLO 的转换边界坑LabelImg 默认把标注保存成 PASCAL VOC 格式的 XML而 YOLO 系列需要每张图对应一个同名 txt每行格式为class_id x_center y_center width height四个坐标值都归一化到 0 到 1。新手最容易翻车的地方是坐标归一化时用错了分母w 是(xmax - xmin) / image_width不是xmax / image_width。x_center 是(xmin xmax) / 2 / image_width不是xmin / image_width。边界框贴图边缘时归一化后出现微小的负数或者大于 1 的值训练直接报坐标越界。下面这个转换脚本把边界情况一并处理了clip 操作保证所有值落在合法区间python import xml.etree.ElementTree as ET import os import globdef voc_to_yolo(xml_path, out_dir, class_map): tree ET.parse(xml_path) root tree.getroot() img_w int(root.find(size/width).text) img_h int(root.find(size/height).text) txt_name os.path.basename(xml_path).replace(.xml, .txt) lines [] for obj in root.findall(object): cls obj.find(name).text if cls not in class_map: continue box obj.find(bndbox) xmin float(box.find(xmin).text) ymin float(box.find(ymin).text) xmax float(box.find(xmax).text) ymax float(box.find(ymax).text) xmin max(0.0, min(xmin, img_w - 1)) xmax max(0.0, min(xmax, img_w - 1)) ymin max(0.0, min(ymin, img_h - 1)) ymax max(0.0, min(ymax, img_h - 1)) x_center (xmin xmax) / 2.0 / img_w y_center (ymin ymax) / 2.0 / img_h box_w (xmax - xmin) / img_w box_h (ymax - ymin) / img_h lines.append(f{class_map[cls]} {x_center:.6f} {y_center:.6f} {box_w:.6f} {box_h:.6f}) if lines: with open(os.path.join(out_dir, txt_name), w) as f: f.write(\n.join(lines))class_map {fire: 0} for xml_path in glob.glob(labels_xml/*.xml): voc_to_yolo(xml_path, labels_yolo, class_map) 这段代码里的 clip 看起来多余但实际数据里确实存在标注框超出图像宽高的情况尤其是标注员从视频抽帧时赶进度框稍微拖出画面边界。如果不处理YOLO 训练时 loss 会出现 NaN 或者跳跃式上涨。另一个细节是类别只写fire一个烟雾框不参与火焰检测训练避免不同模态的标注噪声互相干扰。数据集目录最终长这个样子text dataset/ ├── images/ │ ├── train/ │ └── val/ ├── labels/ │ ├── train/ │ └── val/ └── fire_smoke.yaml3.2 训练命令与超参数四个参数决定是否收敛新建fire_smoke.yaml内容如下yaml path: ./dataset train: images/train val: images/val names: 0: fire然后跑训练命令bash yolo train modelyolov8n.pt datafire_smoke.yaml epochs150 imgsz640 batch16 patience20 project./runs namefire_v1epochs150 是个经验值火焰数据集几百到几千张时100 个 epoch 只能让 mAP 爬到平台期前段150 到 180 之间能看到明显的收敛尾部再长收益很小。patience20 表示验证集 mAP 连续 20 个 epoch 不涨就早停这个值设太大会浪费时间设太小又会在 mAP 还没爬稳时就把训练掐了。imgsz640 是 YOLOv8 的推荐平衡点如果火焰在画面里特别小可以试 imgsz1280但显存占用会翻四倍边缘设备上训练不现实不如用第 5 章说的滑窗切块。 batch 取决于你的显存16G 显存跑 yolov8n 用 batch16 比较稳。如果训练时显存不够优先降 batch 而不是降 imgsz因为对目标检测来说batch 小导致 batchnorm 统计量不稳同样能引发训练震荡。微调时优化器默认用 AdamW这个不用换学习率默认 0.01 对迁移学习是安全的。 训练过程里每个 epoch 结束会打印一堆指标重点看 box_loss、cls_loss 和 mAP50-95 三项。box_loss 如果降得很慢但还在降说明模型在学定位cls_loss 在 50 个 epoch 后基本平滑说明分类已经收敛mAP50-95 前期波动大很正常最终落在 0.5 到 0.8 之间算合理范围——注意这是单一类别且数据量不大的典型结果网上那种 mAP 0.95 基本都是多类大数据的模型不要拿它当目标。 ### 3.3 从验证输出筛选权重别只看 mAP 训练结束后 runs/fire_v1/weights/ 下会生成 best.pt 和 last.pt很多人直接拿 best.pt 去部署这不一定对。best.pt 是验证集上 mAP 最高的权重但只反映整体统计指标不看具体失败样本很容易被假象迷惑。 最值得做的验证是跑一遍验证集混淆矩阵和 PR 曲线。如果召回率在 0.9 附近、精确率不高说明模型把大量树影、反光误判成了火现场报警会响个不停反过来如果精确率很高、召回率只有 0.6说明模型保守漏报风险大森林火灾场景下宁可多误报也不该漏报这个权衡要倾向召回率。 把 best.pt 在真实现场视频上抽帧跑一遍推理比任何指标都直观。推理命令 bash yolo predict modelruns/fire_v1/weights/best.pt sourcetest_video.mp4 conf0.25 imgsz640 saveTrueconf0.25是默认阈值实际部署时不要直接用这个值。拉 200 张有火的帧和 200 张无火的帧画出置信度分布选两者交叉点附近的 conf 值。如果交叉点低于 0.1说明训练数据的多样性不足火焰和背景在特征空间里区分度太低这时候调 conf 已经救不回来只能回头补数据。4. 烟雾检测模块帧间差分与 HSI 颜色空间的双通道算法火焰检测的短板在“早”。摄像头能拍到明火时火势往往已经成规模了而烟雾的出现比明火早几分钟到十几分钟。烟雾检测的价值就在这里。这一节给出一个常见的双通道方案运动通道用三帧差分静态通道用 HSI 颜色判别最后在决策层做加权融合。4.1 帧间差分连续帧的“变化率”怎么当特征最简单的运动检测是连续两帧直接做差但两帧差分会把摄像头微抖动的噪声也当成运动。三帧差分是性价比更高的做法取前两帧的差与后两帧的差求交集能抵消一部分全局相机位移带来的噪声。在树梢摇摆的林区场景这个抑制效果非常关键。python import cv2 import numpy as npdef three_frame_diff(prev2, prev1, curr, threshold25): # 相邻两帧分别做差然后取与运算保留真正变化的部分 diff1 cv2.absdiff(prev2, prev1) diff2 cv2.absdiff(prev1, curr) motion cv2.bitwise_and(diff1, diff2)_, mask cv2.threshold(motion, threshold, 255, cv2.THRESH_BINARY) kernel cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5)) mask cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel, iterations2) mask cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel, iterations2) return maskthreshold25是灰度差分阈值值越小对运动越敏感误报也越多。林区白天光线变化大一般取 20 到 30夜间画面噪点大取 40 到 50 更稳。形态学开运算去掉孤立噪点闭运算把烟雾区域内部断裂的孔洞补上两个操作的核大小取 5 是平衡点核太小补不上轻微的灰度波动核太大帧率低而且会把相邻的几个运动目标糊成一个区域。这只是一个求掩码的函数实际检测时要叠加上“持续运动”的判据。烟雾的运动虽然连续但每一帧差分掩码可能只覆盖烟柱的一部分。常见的做法是维护一个滑动窗口统计最近 10 帧中该区域被标记为运动的次数超过 7 次才认为是可疑运动区这个思路跟时序平滑是同一个道理但计算量小得多适合边缘设备。4.2 HSI 颜色空间为什么烟雾是“低饱和中高亮”烟雾的颜色特征是灰白、灰蓝色在背景是绿色树冠或褐色山体时差异明显。用 RGB 直接做阈值很容易受到光照影响同一个灰度值在阴影里和在阳光下物理含义完全不同。HSV/HSI 色彩空间把色相、饱和度、亮度三个维度拆开烟雾的判别条件就变成了“饱和度低、亮度适中”。python def smoke_color_mask(bgr_frame, s_thresh60, v_low90, v_high255): # BGR 转 HSVH 通道不参与判断主要看 S 通道的饱和度和 V 通道的亮度 hsv cv2.cvtColor(bgr_frame, cv2.COLOR_BGR2HSV) h, s, v cv2.split(hsv)mask (s s_thresh) (v v_low) (v v_high) mask mask.astype(np.uint8) * 255 kernel cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (7, 7)) mask cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel, iterations1) return maskv_low90是防止把暗影判成烟雾树荫、黑色岩石的饱和度也低但亮度偏低。v_high255是防止过曝区域参与判断白天阳光直射的枯草地反光很亮只靠饱和度筛不掉它必须配合亮度上限。s_thresh60是在 0 到 255 刻度下的经验值比这个值更低的区域通常是无色或弱色区域。这个单帧颜色掩码的问题在于没有纹理信息参与判断。云和雾气同样低饱和度高亮度颜色掩码在阴天会失效。所以颜色通道的输出不是独立报警源而是给运动通道“加分”的证据。4.3 双通道融合规则融合与加权融合怎么选运动通道的副产品是“疑似区域”颜色通道的副产品是“疑似烟雾色区域”。两者独立计算融合时用最简单的重叠面积做决策。工程上不要一开始就上复杂分类器规则融合跑两周现场数据才能积累出真正值得用统计模型解决的失败样本。python def smoke_alert(motion_mask, color_mask, overlap_thresh500, frame_h540, frame_w960): # 两个掩码按位与得到既在运动、颜色又符合烟雾的区域 overlap cv2.bitwise_and(motion_mask, color_mask)# 过滤小噪点只保留面积超过阈值的连通域 num_labels, labels, stats, _ cv2.connectedComponentsWithStats(overlap, connectivity8) max_area 0 for i in range(1, num_labels): area stats[i, cv2.CC_STAT_AREA] if area max_area: max_area area if max_area overlap_thresh: return True, max_area return False, max_areaoverlap_thresh500是绝对像素面积但不同分辨率下这个值要缩放。图像分辨率从 960x540 提到 1920x1080同样的烟柱面积会变成 4 倍阈值对应调大。所以更稳妥的做法是按面积占比算max_area / (frame_h * frame_w) 0.001。连续报警的一致性判据很关键单帧满足条件就触发会让整条视频流疯狂误报。常见做法是维护一个计数连续 10 帧中有 8 帧满足条件才输出告警。这个“10 帧 8 次”的窗口不要超过 3 秒否则失去早期告警意义也不要低于 5 帧否则一段飞鸟飞过都可能触发报警。另外告诫一点这套融合规则在风大的天气会把快速移动的树影误判为运动低饱和这时候宁可直接降低颜色通道权重也不要为了压误报把运动阈值调高因为那会连真烟一起丢掉。5. 避坑指南训练不收敛、误报和漏报的五个现场原因这章我直接把项目里遇到过的坑按“现象 → 原因 → 解决”写出来。每一条都踩过至少一次有的是调参救回来的有的是只能靠补数据压下去的。坑 1训练 loss 降得很好验证 mAP 也不差但现场视频里远处的小火苗一帧都检不出来。原因不是模型不行而是训练数据里根本缺少“远处小火苗”这种样本。公开数据集里火焰大多是中近景拍摄几十米外的火在 640 分辨率下只有十几个像素YOLO 对小目标的特征表达能力本来就弱。解决方式是在标注完尺度的数据之后把 1920x1080 的现场视频按 640 边长滑窗切成四块分别送进模型同时把切出来的块的标注做一次自动平移换算让模型见过不同尺度下的火焰。补完这批样本漏检率会肉眼可见地下降。坑 2白天 10 点到 14 点误报率直线上升树冠反光区域被黄色框框出来。原因是阳光直射下枯草和树叶的反射亮度接近火焰亮部颜色通道把高亮当成了火。解决方式不是删掉颜色通道而是引入时间位的权重正午时分降低颜色通道的置信度烟雾检测则提高运动通道的权重。这个在部署端做很简单读一下系统时钟把一个sunlight_penalty乘到颜色通道的分数上即可。坑 3阴雨天没有阳光干扰但一朵云飘过就报警而且报警区域跟着云走。原因是帧间差分把云的运动当成烟雾运动而两种东西在低饱和高亮特征上一模一样。单靠两个通道无法区分云的“边缘清晰运动”和烟雾的“边缘模糊运动”。解决方式是在融合里加上模糊度判据对检测区域做 Laplacian 变换计算方差云的边缘方差大烟雾的边缘方差小。有了这个 blur_score阴天的误报能够压掉一大半。坑 4训练到一半 loss 突然变 NaN。多数情况下是标注数据里有几张图是反转过的bounding box 坐标没有跟着镜像翻转导致 YOLO 的 anchor 分配逻辑算出了非法值。解决方式是写一个数据增强一致性的检查脚本对每张图做水平翻转后对 label 里的 x_center 做1 - x_center变换再重新喂给模型。这个坑很难在指标上看出来因为 loss 不 NaN 时模型照样学只是 mAP 上不去。坑 5边缘设备上用 TensorRT 做 INT8 量化后小目标漏检率反而变高了。原因是量化把低响应的特征图截断了原本置信度 0.3 的远端小火苗被压到 0.1 以下。解决方式是量化时用代表性数据集几百张带火焰的图片做校准不要直接用 COCO 图像集或者折中一下用 FP16 精度。对可靠性要求高的森林防火项目我一般把分钟级推理通道保持 FP16只在帧率要求高的通道上做 INT8。6. 部署推进剪枝量化与现场校准的后悔药训练完的权重只是一串文件离“能交付的识别系统”还差一个部署转换和现场校准的过程。这一节给出两条推进路径模型轻量化以及布署之后的阈值自校准。先做 ONNX 导出bash yolo export modelruns/fire_v1/weights/best.pt formatonnx opset12导出后的 ONNX 文件可以直接喂给 TensorRT 或 NCNN。NVIDIA 系盒子用 TensorRT 转 engine命令大致是 bash trtexec --onnxfire.onnx --fp16 --saveEnginefire.engine这一条命令背后要留意的参数是--workspace转大模型时 workspace 太小会拒绝执行一般给 2GB 到 4GB。如果设备是寒武纪、瑞芯微这类国产芯片走 NCNN 或厂商自带的转换工具链流程一样ONNX 是中间表示先保证它导出成功再谈后面。部署时配置一个简短的推理循环每 3 秒抽 1 帧跑火焰检测每 0.5 秒跑一组烟雾检测两个模块分开调度避免 CPU 和 GPU 互相抢占。现场校准是很多项目最后悔没做的一步。出厂默认阈值是基于你自己采集的旧视频调的现场的光照、相机白平衡、视角全不一样。我习惯部署完先空跑一周把每天触发的报警帧全部存下来周末统计一次哪些是误报、哪些是漏报再调两个东西——YOLO 的 conf 值以及烟雾模块的s_thresh和overlap_thresh。调完之后你会发现校准前后的误报率可能差一个数量级。另外一个容易忽略的参数是“报警冷却时间”。森林场景里如果真着火报警应该只触发一次而不是每秒触发一次。程序里加一个alert_cooldown_seconds 60的计时器火警 ID 生成后在一分钟内不重复告警既减轻值班人员负担也方便后期回溯视频。我自己的教训是有一次现场在下午 4 点逆光树冠整个过曝成亮黄色颜色通道全面失守误报刷了满屏。后来在系统里加了一个“手动关闭颜色通道”的维护开关白天强逆光时段让现场人员切到运动通道单通道模式误报立刻降了。这个开关看起来很土但它比任何算法都可靠。算法永远有边界给现场留一个能让有经验的人做决定的后门才是工程方案的完整形态。希望帮到你。本文还有配套的精品资源点击获取
网站建设高端定制企业官网