简介:针对烟雾与火焰检测任务,这份数据集提供了完整的图像与XML标注资源,适合计算机视觉学习者、算法工程师以及安防消防领域研究者,用于目标检测模型的训练与效果评估。压缩包共4118个文件,包含2059个JPG图像与2059个配套XML标注文件,标注类别覆盖“烟雾”和“火”两类目标,整体大小约168.41MB。XML标签记录目标类别与边框位置信息,可直接用于YOLO、Faster R-CNN等主流检测框架的训练输入,省去手动标注环节;类别区分明确,可满足烟雾与火焰的二分类或多类别检测需求。目前已有2326人学习下载,资源整理规范,便于划分训练集、验证集与测试集,适合快速构建烟火识别数据集,支撑火灾预警、森林防护、智能安监等场景的算法开发与项目落地。对于初学者,也能借此熟悉标准标注格式与数据组织方式,是一份可直接投入训练使用的数据集。
1. 烟雾火焰数据集:VOC 标注的烟火检测数据到底能做什么
烟雾火焰数据集听起来像个普通的目标检测数据集,但实际用起来会发现它是烟火识别项目里最难替代的部分。2056 张图片,烟雾和火两类,全部带 xml 标签,意味着它可以直接被 YOLO、SSD、Faster R-CNN 这些主流检测框架消费,也可以先用 VOC 格式的标注工具链做检查、清洗和增强。对于正在做森林防火、工厂安防、城市早期火情预警的工程师来说,这套数据解决的不是“有没有模型”,而是“有没有靠谱的训练起点”。它最适合两类人:一类是刚接触目标检测、需要一套干净数据跑通流程的初学者,另一类是已经跑过公开数据集、想快速评估烟火场景落地效果的从业者。接下来我从标注结构讲起,一直到训练和坑位排查。
2. 读懂 xml 标注:先检查数据,再谈训练
拿到任何 VOC 格式的数据集,第一步不是直接进训练脚本,而是先把你手里的 xml 读一遍。烟火检测这种任务对标注质量尤其敏感,因为烟雾是半透明、非刚性目标,边界天然模糊,不同标注员画的框可能偏差很大。如果直接训练,模型会把这些不一致当成特征去学,轻则收敛慢,重则在真实现场疯狂误报。这一章我会带你逐字段解析 xml,并给出一套能直接跑的体检脚本。
2.1 一张 VOC 格式的 xml 里有什么
VOC(PASCAL VOC)格式的标注文件核心是 object 节点。一个典型的标签长这样:
<annotation> <folder>smoke_fire_2024</folder> <filename>img_0042.jpg</filename> <size> <width>1280</width> <height>720</height> <depth>3</depth> </size> <object> <name>fire</name> <difficult>0</difficult> <bndbox> <xmin>312</xmin> <ymin>98</ymin> <xmax>674</xmax> <ymax>356</ymax> </bndbox> </object> <object> <name>smoke</name> <difficult>0</difficult> <bndbox> <xmin>201</xmin> <ymin>130</ymin> <xmax>512</xmax> <ymax>410</ymax> </bndbox> </object> </annotation>filename 要和同目录下的图片文件名严格一致,size 里的宽高是训练时用来做归一化的基准。object 节点可以出现多次,也就是说一张图里可以同时标多个火头和多团烟雾,这正是烟火场景最常见的形态。难点在于 bndbox 的四个值:xmin、ymin 是左上角,xmax、ymax 是右下角,它们必须是像素坐标,而且不能超出图片边界。后面转换脚本的核心,就是把这四个值读出来换算成 YOLO 需要的中心点加宽高格式。
2.2 训练前先跑一遍标注体检:类别名、边界框、空标注
多数数据集不会像文档里写得那么干净,尤其这类由人工标注整理的数据,常见问题包括类别名大小写混用(fire 和 Fire)、坐标超出图片宽高、宽高为 0 的退化框、以及“图片在但 xml 缺失”或“xml 在但图片缺失”。这些问题单个出现概率不高,但 2056 张图里只要有一批,训练时就会在数据加载阶段报错,或者更隐蔽地让 loss 在某个值附近震荡。我一般会在拿到数据的第一时间跑下面这个检查脚本:
import os import xml.etree.ElementTree as ET from PIL import Image IMG_DIR = "images" XML_DIR = "annotations" img_files = {f for f in os.listdir(IMG_DIR) if f.lower().endswith((".jpg", ".png"))} xml_files = {f for f in os.listdir(XML_DIR) if f.lower().endswith(".xml")} # 1. 图片与标注是否一一对应 only_img = img_files - {os.path.splitext(x)[0] + ".jpg" for x in xml_files} only_xml = xml_files - {os.path.splitext(f)[0] + ".xml" for f in img_files} print("缺 xml 的图片:", only_img if only_img else "无") print("缺图片的 xml:", only_xml if only_xml else "无") # 2. 读取每个 xml,检查 bndbox 合法性 for xml_name in sorted(xml_files)[:50]: tree = ET.parse(os.path.join(XML_DIR, xml_name)) root = tree.getroot() size = root.find("size") w, h = int(size.find("width").text), int(size.find("height").text) for obj in root.findall("object"): name = obj.find("name").text box = obj.find("bndbox") x1 = int(float(box.find("xmin").text)) y1 = int(float(box.find("ymin").text)) x2 = int(float(box.find("xmax").text)) y2 = int(float(box.find("ymax").text)) if x1 >= x2 or y1 >= y2: print(f"{xml_name}: {name} 框宽高异常 ({x1},{y1},{x2},{y2})") if x1 < 0 or y1 < 0 or x2 > w or y2 > h: print(f"{xml_name}: {name} 超出图片边界 ({x1},{y1},{x2},{y2}) vs {w}x{h}") print("抽查完成")这段脚本前半段用了集合差,把“有图没标”和“有标没图”的情况一次性暴露出来。后半段只抽查前 50 个 xml,目的是快速看有没有明显脏数据,全量检查放在转换脚本里更合适。为什么要抽查而不是全量?因为 xml.etree 的解析在全量 2056 个文件上不算慢,但人工去读输出才是瓶颈,抽查先把最严重的问题暴露出来,全量交给后面的转换脚本兜底。注意脚本里我用 int(float(...)) 而不是 int(...),是因为不少标注工具会把坐标写成小数,直接 int() 会在某些写法下报错,先转 float 再下取整更可靠。
提示:类别名出现的所有值都要记录,后面转 YOLO 格式时要按固定顺序映射成 0、1。顺序一旦定了就不要改,否则训练到一半再换类别索引,模型权重和数据集就对不上了。
2.3 统计类别分布:烟雾和火的占比决定后续策略
体检通过之后,下一步是统计每个类别的出现频次和边界框尺寸分布。这个统计直接决定两个事情:一是类别权重怎么配,二是需不需要对某一类做针对性增强。烟火数据的典型特征是 fire 框通常小而集中,smoke 框大而分散,两种框的宽高比差异很大,如果统一按同一个锚框策略跑,小目标的火焰很可能直接被忽略。
import os import xml.etree.ElementTree as ET from collections import Counter, defaultdict XML_DIR = "annotations" category_counter = Counter() box_sizes = defaultdict(list) for xml_name in os.listdir(XML_DIR): if not xml_name.endswith(".xml"): continue tree = ET.parse(os.path.join(XML_DIR, xml_name)) root = tree.getroot() w = int(root.find("size/width").text) h = int(root.find("size/height").text) for obj in root.findall("object"): name = obj.find("name").text category_counter[name] += 1 box = obj.find("bndbox") bw = int(float(box.find("xmax").text)) - int(float(box.find("xmin").text)) bh = int(float(box.find("ymax").text)) - int(float(box.find("ymin").text)) box_sizes[name].append((bw / w, bh / h)) # 归一化到 0-1 print("类别分布:", dict(category_counter)) for k, v in box_sizes.items(): avg_w = sum(s[0] for s in v) / len(v) avg_h = sum(s[1] for s in v) / len(v) print(f"{k}: 框数量 {len(v)}, 平均归一化宽 {avg_w:.3f}, 平均归一化高 {avg_h:.3f}")这里把边界框尺寸除以图片宽高,得到归一化后的相对大小。这么做是为了和后面 YOLO 的输入尺寸对应,比如图片 1280 宽、框 320 宽,归一化后就是 0.25,训练时如果输入缩到 640,这个框在特征图上的像素占比就清楚了。统计结果出来后,常见情况是 smoke 和 fire 的框数量不均衡,比例悬殊到 3:1 以上。此时建议先用原始数据训一版,看混淆矩阵再决定要不要做增强,而不是上来就调类别权重,烟火任务里增强翻车的概率比权重配错还高。
3. 将 xml 转成 YOLO 格式:转换脚本与四个边界坑
VOC 格式虽然通用,但现在的检测框架主流输入是 YOLO 格式的 txt,一个标注文件对应一张图,每行写类别、中心点 x、中心点 y、宽、高,全部归一化到 0~1。很多初学者在这里翻车,不是不会写转换代码,而是漏了坐标从“左上右下”到“中心点宽高”的换算,或者忘了归一化。这一章直接给出我常用的转换脚本,并把转换后最容易出的四个问题一次性讲透。
3.1 为什么要从 VOC 转到 YOLO 格式
YOLO 系列(v5、v8 以及最新的 v11)的默认标注是 txt 格式,每行五个数字。相比 xml,txt 没有标签名而是用数字索引,加载更快,也更符合 anchor-based 检测头对目标框的表达习惯。另一个原因是庞大的生态工具链,Ultralytics 的 API、roboflow 导出、以及大量数据增强库都默认读 txt,转过去之后你能直接用上这些工具,不需要为 xml 写定制 loader。
VOC 的 bndbox 是左上角 (x1, y1) 和右下角 (x2, y2),YOLO 需要的是中心点坐标 (cx, cy) 和宽高 (w, h),换算公式是:
cx = (x1 + x2) / 2 / 图片宽 cy = (y1 + y2) / 2 / 图片高 w = (x2 - x1) / 图片宽 h = (y2 - y1) / 图片高
注意除法在最外层,先算像素值再归一化,顺序反了数值就会差一个量级。
3.2 转换脚本:解析 xml,归一化,写 txt
import os import xml.etree.ElementTree as ET IMG_DIR = "images" XML_DIR = "annotations" OUT_DIR = "labels_yolo" CLASSES = ["smoke", "fire"] # 索引 0 和 1,顺序固定 os.makedirs(OUT_DIR, exist_ok=True) skipped = 0 for xml_name in os.listdir(XML_DIR): if not xml_name.endswith(".xml"): continue tree = ET.parse(os.path.join(XML_DIR, xml_name)) root = tree.getroot() w = int(root.find("size/width").text) h = int(root.find("size/height").text) if w == 0 or h == 0: print(f"跳过 {xml_name}: 图片尺寸异常") skipped += 1 continue lines = [] for obj in root.findall("object"): name = obj.find("name").text.strip() if name not in CLASSES: print(f"跳过 {xml_name}: 未知类别 {name}") skipped += 1 continue box = obj.find("bndbox") x1 = float(box.find("xmin").text) y1 = float(box.find("ymin").text) x2 = float(box.find("xmax").text) y2 = float(box.find("ymax").text) if x1 >= x2 or y1 >= y2 or x2 > w or y2 > h or x1 < 0 or y1 < 0: print(f"跳过 {xml_name}: 非法框 {name}") skipped += 1 continue cx = (x1 + x2) / 2 / w cy = (y1 + y2) / 2 / h bw = (x2 - x1) / w bh = (y2 - y1) / h lines.append(f"{CLASSES.index(name)} {cx:.6f} {cy:.6f} {bw:.6f} {bh:.6f}") base = os.path.splitext(xml_name)[0] with open(os.path.join(OUT_DIR, base + ".txt"), "w") as f: f.write("\n".join(lines)) # 拷贝同名图片到 images_yolo,保持一一对应 os.system(f"cp {IMG_DIR}/{base}.jpg {IMG_DIR}_yolo/{base}.jpg 2>/dev/null || true") print(f"转换完成,跳过 {skipped} 个异常文件")这段脚本把 xml 解析、合法性检查、坐标换算、文件落盘放在一个循环里,前置条件是 CLASSES 列表里的顺序就是最终训练时的类别索引,必须是 0 开始。脚本里跳过异常文件而不中断,因为我处理 2056 张图时发现,中断一次就要回头人工核对,跳过错名单最后集中看打印信息更高效。转换时顺便用 os.system 拷贝同名图片,是为了保证 labels 文件夹和 images 文件夹的文件名一一对应。如果你的图片不全是 jpg,把 cp 那行改成匹配实际扩展名,或者用 shutil.copy2 并在循环里按实际 filename 字段来找图片更可靠。
3.3 转换后必须查的四个问题
第一个问题是空 txt。如果某张图 xml 里有 object 但因为类别名不在 CLASSES 列表里被跳过,输出的 txt 是空文件,YOLO 训练时会忽略它,但空文件本质上代表这张图没标注,你得决定是删掉还是补标。第二个问题是归一化后的 w 或 h 出现负数,这通常是 xml 里 xmin 和 xmax 填反了,有些标注工具画框方向不同会导致这个,转换脚本里的非法框检查能拦住一部分,但填反且 x1 < x2 的情况肉眼很难发现,建议转换后随机抽 20 个 txt 对照原图可视化一遍。
第三个问题是类别索引错位,比如你 CLASSES 写 ["fire", "smoke"],但之前统计时用的是 ["smoke", "fire"],训练时 loss 看起来正常,mAP 却一直上不去,因为模型在学相反映射。第四个问题是多通道 tif 或红外图被误判为普通图,烟火数据里偶尔混入热成像图,PIL 或 OpenCV 读取时通道数不同,YOLO 训练会报错。遇到这种情况,统一转成 RGB 三通道 jpg 再进训练,不要指望模型自己适应通道差异。
转换完不要急着训,先把 labels_yolo 里的 txt 和 images_yolo 里的图片数量对齐,再用下面命令检查每行数字个数是否为 5:
for f in labels_yolo/*.txt; do awk 'NF != 5 {print $0}' "$f" doneawk 输出为空说明所有标注行都是五列,这是 YOLO 格式最基础的红线。跑完这步,数据才真正具备训练条件。
4. 用 YOLOv8 训练烟火检测:划分数据集与参数建议
数据整理干净之后,训练本身反而变成最机械的部分。但烟火检测有两个特点会让训练结果和随机划分差别很大:一是同一场景的多帧图像特征高度相似,二是烟和火的尺度差异极大。这一章讲如何划分数据集、怎么写 data.yaml、以及训练后必须看的指标。所有参数基于我在这类任务上的经验,你可以根据显卡显存调整,不建议照抄。
4.1 划分数据集:先按场景,再随机
2056 张图听起来不少,但如果这些图来自有限个固定机位,随机划分会让同一段视频的连续帧同时出现在训练集和验证集里,模型相当于开卷考试,验证集 mAP 虚高到 0.9 以上,换到新场景立刻跌到 0.4。正确的做法是先看图片命名或目录结构,尽量按视频片段或拍摄场景分组,再把整个场景划进训练或验证。
import os import random from collections import defaultdict IMG_DIR = "images_yolo" scene_groups = defaultdict(list) for f in os.listdir(IMG_DIR): if not f.endswith(".jpg"): continue # 假设文件名前缀 scene_001_frame002.jpg,取前两段做场景 scene = f.split("_")[0] + "_" + f.split("_")[1] scene_groups[scene].append(f) scene_names = list(scene_groups.keys()) random.shuffle(scene_names) train_scenes = scene_names[: int(len(scene_names) * 0.8)] val_scenes = scene_names[int(len(scene_names) * 0.8):] train_txt = "train.txt" val_txt = "val.txt" with open(train_txt, "w") as f: for s in train_scenes: for img in scene_groups[s]: f.write(f"/abs/path/to/{img}\n") with open(val_txt, "w") as f: for s in val_scenes: for img in scene_groups[s]: f.write(f"/abs/path/to/{img}\n") print(f"训练场景 {len(train_scenes)} 个, 图片 {sum(len(scene_groups[s]) for s in train_scenes)} 张") print(f"验证场景 {len(val_scenes)} 个, 图片 {sum(len(scene_groups[s]) for s in val_scenes)} 张")这里示意按文件名前缀分组,实际项目里如果图片没有结构化命名,可以用感知哈希或者 ORB 特征把相似帧聚类,代价是计算量高一些。我一般至少会分 5 播(即 5 次不同随机种子划分)取验证集指标最好的一次,因为单次划分的运气成分在这种规模的数据集上不可忽视。另外注意 train.txt 里写的是绝对路径还是相对路径,YOLO 训练时要保持前后一致,否则换机器跑马上报文件不存在。
4.2 data.yaml 与关键训练参数
Ultralytics YOLOv8 训练时只需要一个 yaml 文件指向数据和类别列表,内容极简:
path: /your/dataset/root train: train.txt val: val.txt names: 0: smoke 1: firetrain 和 val 可以写相对 path 的路径,也可以直接写绝对路径。names 的顺序必须和转换脚本的 CLASSES 完全一致。这里的 path 是后面所有相对路径的根,Ultralytics 会把 train.txt 里的图片路径拼到 path 上,如果 train.txt 里已经是绝对路径,path 字段可以随便填个目录。
训练命令我用得最频繁的是下面这个:
yolo detect train \ data=data.yaml \ model=yolov8n.pt \ epochs=120 \ imgsz=640 \ batch=16 \ patience=20 \ project=runs/smoke_fire \ name=v8n_640挑三个最影响烟火检测结果的参数展开说。imgsz 默认 640,如果你的场景里火焰是小目标,建议试试 960 或 1280,代价是显存和训练时间翻倍,但小目标 AP 通常能涨 3~5 个点。batch 不是越大越好,梯度过大反而让模型在小数据集上过拟合更快,16 在 12G 显存上跑 640 输入比较稳。patience 是早停参数,我见过有人在 2056 张图上训到 120 epoch 消耗大量时间,其实验证集 mAP 在 60~80 epoch 就到平台期了,patience 设 20 能自动停。
注意:不要一上来就上 yolov8x 或加 CBAM 这些花活,先跑通 v8n 基线。烟火检测的瓶颈几乎不在模型容量,在数据标注的框质量和小目标密度。基线 mAP 如果低于 0.6,先回数据侧找原因,换大模型只会掩盖问题。
4.3 训练结束后看什么指标
训练结束后屏幕上会输出一长串指标,重点看三个:mAP50、mAP50-95、以及每个类别的 AP。mAP50 是 IoU 阈值 0.5 时的均值,烟火这类边界模糊的目标框和真实框的 IoU 往往偏低,mAP50-95 很难超过 0.7,不用太焦虑。真正值得关注的是 smoke 和 fire 两个类别的 AP 差异,如果 fire 的 AP 明显低于 smoke,说明小目标漏检是主要矛盾。
我还习惯性地把验证集上模型预测失败的图导出成一张拼图,直接用 Ultralytics 提供的混淆矩阵图不够直观,要配合看具体图片:
yolo detect val \ model=runs/smoke_fire/v8n_640/weights/best.pt \ data=data.yaml \ conf=0.25 \ save_pred=True \ project=runs/smoke_fire \ name=val_checksave_pred=True 会在验证时把带标注框的预测图存下来。你去翻这些图的时候,重点找三类样本:远处的小火苗、浓烟背景下的淡淡烟迹、以及高光反射被误判成火的地方,这三类基本就是烟火检测最后的难点,也直接决定了模型能不能上真实场景。
5. 烟火检测最容易踩的坑:误报、漏报与难例排查
这章内容来自我做了几轮烟火检测项目后的血泪经验,每一条都是先看到现象、再查原因、最后找到解决路径的完整链路。如果你正准备拿这套数据训练,建议先看完这章再动手,能省下不少重训的冤枉时间。
5.1 云、雾被识别成烟雾
现象:模型在训练集的烟雾图上表现不错,但在装有户外摄像头的场景里,把白云、晨雾、水蒸气全部标成 smoke,误报率高到无法上线。
原因:烟雾本质上是半透明的非刚性物体,训练集里如果烟雾图以深色浓烟为主,模型学到的其实是“灰白色团块”这个表面特征,而不是烟的纹理和消散形态。云和水蒸气在颜色、形状上跟烟高度重叠,单靠外观特征天然分不开。
解决:我试过几种办法,最有效的是加时间去分辨。烟雾在连续帧里有扩散和消散的运动特征,云基本不动。落地方法是把模型输出接到一个轻量时序判读器上,连续 N 帧都检出才算报警。如果只做单帧检测,那就得往训练集里补充薄烟、远烟、逆光烟这类难例,让模型见过足够多的“烟但不是云”的样本。
5.2 远处小火苗漏检
现象:验证集 mAP 看着不错,一测现场远处刚燃起的火苗,框完全不触发。这类火苗在 1280 分辨率的原图上往往只有 20~30 像素宽。
原因:训练时 imgsz=640,图片缩一半,小目标尺寸在特征图上只剩几个像素,检测头很难召回。另一个因素是数据集的框尺寸分布里,大火苗占多数,模型对小目标的先验不足。
解决:第一选择是把推理和训练的 imgsz 提上去,960 或 1280;第二选择是做大图切块推理,把原图切成四块分别检测再合并结果;第三选择是给 YOLO 加 P2 检测层,但这需要改模型结构,风险大收益未必明显。我一般先试前两个,效果好且不影响工程可靠性。
5.3 验证集划分不当造成指标虚高
现象:训练的 mAP50 显示 0.92,团队以为模型已经成熟,结果现场评测不到 0.5。
原因:划分数据集时随机切分,同一个摄像机机位的连续帧同时出现在训练集和验证集,模型看到的验证样本和训练样本几乎同一场景,等于开卷考试。这类问题在自采数据上极为常见,因为自采数据天然按机位和时间聚集。
解决:按机位或视频片段分组划分,组内不分家。另一个补救措施是加一个“外人”验证集,找几个完全没参与训练的现场视频片段来跑,指标可信度会高很多。划分脚本我在 4.1 里已经给出,不要嫌麻烦跳过。
5.4 标注文件脏数据导致训练异常
现象:训练在第 20 epoch 左右 loss 突然飙到 nan,或者训练完精度极低,翻验证集图片发现框的位置完全不对。
原因:xml 里有坐标越界、类别名大小写混用、以及小概率存在的 bndbox 填反。转换脚本虽然拦了一大部分,但有些脏数据的坐标本来就在图片范围内,只是语义上是错的,比如把火焰框画到了背景高光上。
解决:把训练集预测结果可视化一遍,重点看置信度最高和最低的那些框,能发现大量标注错误。另一个技巧是训练时加 fliplr 增强后复查一遍,左右翻转最容易暴露标注不对称的问题。数据清洗虽然耗时,但在这类场景下回报率远高于调模型。
6. 上线前最后一步:用小目标专项评测验证模型
模型训练完不是终点,烟火检测这类任务真正麻烦的是小目标,所以我在最终验收时一定会做一次小目标专项评测,而不是只看验证集的 mAP。具体做法是固定一个测试视频片段,从里面截帧,然后把每帧按 2x2 切块,分别用模型检测后合并,记录小目标的召回率。
import cv2 import numpy as np from ultralytics import YOLO model = YOLO("runs/smoke_fire/v8n_640/weights/best.pt") img = cv2.imread("night_fire_042.jpg") h, w = img.shape[:2] tiles = [(0, 0, w // 2, h // 2), (w // 2, 0, w, h // 2), (0, h // 2, w // 2, h), (w // 2, h // 2, w, h)] all_boxes = [] for x1, y1, x2, y2 in tiles: tile = img[y1:y2, x1:x2] results = model(tile, conf=0.25, imgsz=640)[0] for box in results.boxes.data.cpu().numpy(): tx1, ty1, tx2, ty2, conf, cls = box all_boxes.append((int(tx1 + x1), int(ty1 + y1), int(tx2 + x1), int(ty2 + y1), conf, int(cls)))这段代码把原图切成四块做推理,再把坐标映射回原图坐标系。切图推理的收益在于小目标在切块后相对尺寸变大,模型更容易召回。代价是推理时间翻倍,所以这个评测脚本只用于离线验收,不适合直接部署到实时链路。评测时我建议记录下面这张表,每一行代表一帧的检出情况:
| 帧号 | 真实火苗数 | 检出的火苗数 | 最小框宽度(px) | 结论 |
|---|---|---|---|---|
| 042 | 1 | 1 | 18 | 通过 |
| 073 | 2 | 1 | 12 | 小目标漏检 |
把十几帧的结果汇总,如果最小框宽度在 15 像素以下时召回率低于 60%,就可以明确判断模型不适合直接上生产,需要按 5.2 里的方案做增强或切图部署。我现在做烟火检测项目,都会把这个小目标评测流程固定下来,每次迭代模型先跑它,通过再谈上线,免得在现场翻车后回头找数据问题。这套数据本身质量不错,但工程落地从来不是把 2056 张图喂进去就完事,希望这份流程能帮你少走几趟弯路,一次把烟火检测跑通。
本文还有配套的精品资源,点击获取