
简介面向YOLO目标检测实战这份烟雾数据集资源对应整体包含21578张带标签图像的烟雾检测样本主要面向具备一定深度学习基础的目标检测工程师、算法研究员及消防安防领域的应用人员适用于火灾预警、安全监控、工业现场等需要快速识别烟雾的检测场景能够帮助开发者降低数据收集与标注成本。压缩包内实际收录2000个XML标注文件合计268.9MB整个压缩包以ZIP格式封装每个XML文件均记录对应图像中烟雾目标的类别名称与边界框坐标格式兼容Pascal VOC可被YOLOv5、YOLOv8等主流框架直接读取或转换省去部分数据准备步骤。从内容预览看标注文件采用一致的编号命名便于与原图对应并划分数据集该资源目前已有482人学习浏览初学者可通过阅读XML熟悉标签字段构成和坐标表示有经验的开发者可将其作为补充样本增强模型在烟雾小目标、稀疏烟雾或复杂背景下的识别稳定性和鲁棒性。1. yolo 算法与烟雾数据集21578 张带标签图像值得花一个下午跑通我拿到这份 yolo算法-烟雾数据集-21578张图像带标签-烟smoke100-uwe4t.zip 的第一反应是它大概率又是一份 VOC 风格的目标检测包图片和 XML 标签配对存放类别只有一个 smoke。解压后我用脚本统计了一遍图片和标签都在 2.1 万这个量级说明训练一个单类烟雾检测器的数据基础是够的。这份资源能解决的是早期消防预警、工地烟火识别、森林监控这类场景里“烟比火更早出现”的问题适合刚学 yolo 的人跑通全流程也适合老手拿来做数据清洗与调参的测试台。不过我不建议解压后直接开训先把标签拆开看一遍后面能省很多返工时间。2. 拆包看数据目录约定、XML 标签与配对校验很多人拿到数据集第一件事就是写训练脚本结果训练时不是报标签越界就是样本对不上最后才发现是数据本身的问题。我的习惯是先做三件事统计文件数、读一个 XML 标签、校验图片与标签是否一一对应。这三件事坐下来大约二十分钟但能省下后面好几个小时。2.1 目录与文件命名先别假设它长什么样这个压缩包解压出来后我一般先在目录里执行一遍统计确认实际结构而不是凭标题猜“一定有 images 和 labels”。常见的数据集目录有两种一种是JPEGImages/配Annotations/另一种是images/配labels/而这份资源从文件名img_0485_10246.xml看更像 VOC 的 Annotations 命名习惯所以我优先找同名图片。unzip yolo算法-烟雾数据集-21578张图像带标签-烟smoke100-uwe4t.zip -d smoke_dataset cd smoke_dataset find . -name *.xml | wc -l find . -name *.jpg -o -name *.png | wc -l这段命令的逻辑是先解压到smoke_dataset目录然后用find配合wc -l分别统计 XML 和图片文件数。find . -name *.xml会递归找出所有 xml 文件wc -l统计行数行数就是文件数图片命令用-o把 jpg 和 png 合并统计。如果两者数值差很多比如图片多出几百个说明有大量的图没有标签训练时 YOLO 会跳过这些图等于白放进了数据集。我一般要求误差不超过 1%最好完全相等。接下来看目录层级ls -R | head -80ls -R递归列出所有文件head -80只看前 80 行这样可以快速摸清Annotations/和images/到底在哪里。这一步之所以重要是因为很多从公共资源二次打包的数据集目录结构会被改得五花八门有的把 xml 直接放在根目录有的把 train/val 分开成子目录。如果你在数据 yaml 里写错了路径ultralytics 会在跑训练前报错但如果是路径写对了、内部子目录不对报错会更隐晦表现为数据量为 0。关于命名规律项目里的 XML 文件大多长这样img_0485_10246.xml。我理解0485可能是场景编号或视频编号10246是帧号或样本序号。这种命名对训练本身没影响但对划分 train/val 有影响如果你希望同一场景的视频帧不要同时出现在训练集和验证集就应该按第一个编号分组而不是随机地去切。后面第 5 章我会专门讲这个坑。2.2 XML 标签里到底有什么VOC 格式与对象字段VOC 检测格式的 XML 里最核心的是filename、size和object。size里存宽高object里是类别名和bndbox的四个坐标。我随便拿一个样本解析后内容大致是这样annotation filenameimg_0485_10246.jpg/filename size width1920/width height1080/height depth3/depth /size object namesmoke/name poseUnspecified/pose bndbox xmin142/xmin ymin301/ymin xmax670/xmax ymax855/ymax /bndbox /object /annotation这是典型的 VOC1.1 结构但不同来源的数据集会略有差异有的bndbox写成了box有的name字段写成了label。所以我会写一个通用解析脚本先跑五个样本把类别和坐标打印出来确认字段名没有出入。import glob import xml.etree.ElementTree as ET xmls sorted(glob.glob(smoke_dataset/**/*.xml, recursiveTrue)) for xml_path in xmls[:5]: root ET.parse(xml_path).getroot() filename root.findtext(filename) print( file:, filename) for obj in root.findall(object): name obj.findtext(name) box obj.find(bndbox) xmin float(box.findtext(xmin)) ymin float(box.findtext(ymin)) xmax float(box.findtext(xmax)) ymax float(box.findtext(ymax)) print(f {name}: [{xmin:.0f}, {ymin:.0f}, {xmax:.0f}, {ymax:.0f}])这段代码的逻辑是先用递归 glob 把所有 XML 找出来只处理前 5 个root.findtext(filename)读取文件名root.findall(object)遍历所有目标框坐标用float()转成数字方便后面比较。findtext比find().text更安全当字段缺失时返回None而不是抛异常我习惯用它做快速检查。如果你在跑这段脚本时发现box是None说明字段名不叫bndbox需要先把find(bndbox)改成实际字段名。同样重要的还有类别检查。很多时候数据集标题里写 smoke100但标签内部可能会有多种拼写比如smoke、smoke100、Smoke。这时直接统计一下所有name的唯一值from collections import Counter import glob import xml.etree.ElementTree as ET counter Counter() for xml_path in glob.glob(smoke_dataset/**/*.xml, recursiveTrue): root ET.parse(xml_path).getroot() for obj in root.findall(object): counter[obj.findtext(name).strip()] 1 print(counter)参数说明Counter用来计数strip()去掉首尾空格防止smoke和smoke被当成两个类。如果输出里只有smoke那转换脚本的类别映射写一行即可如果还有别的类别名需要决定是统一合并还是剔除。我遇到过一个数据集里混了几张非烟雾的标签训练时 class id 直接爆掉了。2.3 配对校验给训练前加一道保险YOLO 训练时对样本配对要求很严每个训练图片需要在相同相对路径下存在同名 txt 标签。VOC 格式转过去之前最好先做一次图片和 XML 的主文件名配对校验。注意这里我按磁盘主文件名配对而不是读 XML 里的filename字段因为很多数据集在传播时图片被重命名过但 XML 里的filename却没有同步更新。import os import glob def get_stem_set(pattern): return {os.path.splitext(os.path.basename(p))[0] for p in glob.glob(pattern, recursiveTrue)} img_stems get_stem_set(smoke_dataset/**/*.jpg) img_stems | get_stem_set(smoke_dataset/**/*.png) xml_stems get_stem_set(smoke_dataset/**/*.xml)先将 jpg 和 png 的主文件名放进一个集合再与 XML 主文件名集合比较missing_labels img_stems - xml_stems missing_images xml_stems - img_stems print(images:, len(img_stems), xmls:, len(xml_stems)) print(labels missing:, len(missing_labels)) print(images missing:, len(missing_images))这段代码里os.path.splitext(...)[0]去掉扩展名只保留img_0485_10246这样的主名|是集合合并把 jpg 和 png 两个来源统一起来。missing_labels是有图但没标签的样本missing_images是有标签但没图片的样本。正常情况下两个集合都应该是 0。如果missing_labels很多先检查是不是原数据里确实存在大量无标签图或者图片扩展名不是 jpg/png如果missing_images很多可能是 xml 对应的图片被删除了这种情况建议直接把对应 xml 移出数据集否则训练时该样本不会参与计算但会污染验证集的统计。把缺失清单导出成文件可以做到可视化排查python check_pair.py missing.txt我一般还会抽查 10~20 张图用 OpenCV 把框画出来看一眼。这里不给完整代码了思路是读 XML 的bndbox用cv2.rectangle画框另存到check/目录。这一步能发现两类问题一是框把整张图都框住了说明标签要么是全图正样本要么是误标二是框明显偏离烟雾区域多半是图片分辨率与 xml 的 size 不一致后面转换就会出坐标偏移。第 2 章内容到这里基本可以放心把标签源文件作为一个可用的训练输入了。但还要记住VOC xml 本身不能直接喂给 YOLO需要转成 txt这就是下一章的事。3. VOC 标签转 YOLO 训练格式转换脚本与四个边界坑从 VOC XML 转到 YOLO txt核心是把绝对坐标xmin, ymin, xmax, ymax换算成相对图片宽高的归一化坐标x_center, y_center, width, height。这一步很多新手会直接用公式硬套结果遇到空标签、越界框、损坏图片时直接崩掉。我在多次转换后总结了一个能稳定跑完 2.1 万份标签的流程下面拆开讲。3.1 转换脚本一份能直接用的 xml2txt先写一个转换函数输入 XML 路径输出同主文件名的 txt 到指定目录。图片的实际尺寸用 cv2 读取不轻信 XML 里的size因为很多包里的 size 是旧的。import os import glob import cv2 import xml.etree.ElementTree as ET CLASS_MAP {smoke: 0, smoke100: 0, Smoke: 0} def find_image_by_stem(stem, roots[smoke_dataset]): for r in roots: for ext in (jpg, jpeg, png): p os.path.join(r, **, f{stem}.{ext}) hits glob.glob(p, recursiveTrue) if hits: return hits[0] return None def convert_one(xml_path, out_dir): stem os.path.splitext(os.path.basename(xml_path))[0] img_path find_image_by_stem(stem) if not img_path: return False h, w cv2.imread(img_path).shape[:2] root ET.parse(xml_path).getroot() lines [] for obj in root.findall(object): name obj.findtext(name).strip() if name not in CLASS_MAP: continue box obj.find(bndbox) xmin float(box.findtext(xmin)) ymin float(box.findtext(ymin)) xmax float(box.findtext(xmax)) ymax float(box.findtext(ymax)) # 把坐标约束到图片范围内 xmin max(0, min(xmin, w)) ymin max(0, min(ymin, h)) xmax max(0, min(xmax, w)) ymax max(0, min(ymax, h)) bw xmax - xmin bh ymax - ymin if bw 1 or bh 1: continue x_center (xmin xmax) / 2.0 / w y_center (ymin ymax) / 2.0 / h box_w bw / w box_h bh / h lines.append(f{CLASS_MAP[name]} {x_center:.6f} {y_center:.6f} {box_w:.6f} {box_h:.6f}) if lines: out_path os.path.join(out_dir, stem .txt) with open(out_path, w, encodingutf-8) as f: f.write(\n.join(lines) \n) return True这段代码关键点有三个一是find_image_by_stem会在多个根目录下查找同名图片避免转换脚本和图片目录耦合二是用cv2.imread读取真实尺寸而不是用root.find(size)里的宽高三是把所有坐标先 clamp 到[0, w]和[0, h]避免越界框写进 txt 后让网络预测超范围的边界。然后对所有 XML 做循环转换xmls sorted(glob.glob(smoke_dataset/**/*.xml, recursiveTrue)) out_dir smoke_dataset/labels os.makedirs(out_dir, exist_okTrue) ok 0 for x in xmls: if convert_one(x, out_dir): ok 1 else: print(convert failed:, os.path.basename(x)) print(fconverted {ok}/{len(xmls)})这段代码在转换后打印成功数如果convert failed多多半是图片扩展名不同比如.bmp需要在扩展名列表里加进去。3.2 类别映射为什么只有一个 class idCLASS_MAP里我把smoke100也映射到 0是因为从资源标题看smoke100更像是数据集来源标识而不是独立类别。检测任务里如果 class id 从 0 开始nc1那么所有正样本的 class 值都必须是 0。类别映射表要和最终训练用的names完全一致。这里有一个和 COCO 预训练权重相关的点。很多教程直接拿yolov8n.pt或yolov5s.pt来 fit这部分权重是 COCO 80 类上训出来的COCO 的 80 个类里没有 smoke也没有 smoke100。意味着加载预训练权重后模型会保留 backbone 和 neck 的特征提取能力但检测头里类别维度会被重置成nc1所以前几十个 epoch 的 cls_loss 会比较难看这正常。不要试图把 smoke 塞到 COCO 的某个 id比如把类别 id 设成 60 之类那样只会让分类头混乱正确做法是用预训练权重 nc1让 ultralytics 自动改分类头。转换前还可以用 bash 快速确认实际类别名grep -h name smoke_dataset/Annotations/*.xml | sort | uniq -c这段命令里grep -h只输出匹配内容不带文件名sort | uniq -c统计每个类别出现次数。如果输出里除了 smoke 还有别的比如 smoke2 或 fire就需要回到CLASS_MAP里决定是合并还是剔除。我在一个项目里遇到过把“烟囱”也标成 smoke 的情况这类标签会显著拉低模型定位精度最好直接删掉。3.3 转换期四个边界坑第一个坑是 XML 的 size 与实际图宽高不一致。现象是转换后画框整体偏移验证集 mAP 很低而训练 loss 正常。原因往往是数据集从视频抽帧后图片被缩放但 XML 还保留原始分辨率。解决方法是像上面代码一样用cv2.imread(img_path).shape[:2]读真实尺寸完全忽略 XML 的 size 字段。第二个坑是空标签。现象是某些 XML 里object为空或者只有一个name没有bndbox。原因可能是原始标注工具导出的空标注或人工漏标。解决方法是转换时保留空 txt不要删除因为 YOLO 的空标签代表这张图没有目标多用于负样本如果你直接把空 XML 跳过对应的图片也会被跳过等于负样本被隐式丢弃。烟雾检测场景里大量无烟画面做负样本非常重要能有效压背景误检。第三个坑是坐标小数精度。现象是训练时 loss 曲线抖动肉眼看不出数据有问题。原因是个别人会把 xml 转 txt 时x_center只保留两位小数导致框中心偏移最多 1% 的图宽。解决方法是至少保留 6 位小数也就是我在format里写的:.6f。1% 的偏移对小目标来说可能就是几个像素到十几个像素累积起来会让锚框分配不稳定。第四个坑是图片损坏。现象是cv2.imread返回 None转换脚本崩溃。原因多半是下载掉包或图片文件实际上是 0 字节。解决方法是在convert_one里加一行img cv2.imread(img_path) if img is None: print(corrupted:, img_path) return False h, w img.shape[:2]这种损坏图如果不剔除会在训练时让 DataLoader 直接报错。我一般会把损坏图连同它的 XML 一起移到一个quarantine/目录确保训练目录里没有脏数据。转换完成后检查一下 labels 目录的文件数是否和 xml 数一致。如果一致就可以准备训练了。但别高兴太早训练配置还有一批常见的坑等着你。4. 训练前避坑数据 yaml、超参与常见问题排查数据转换完成只是第一步训练前的数据配置文件是整个流程里最容易因为一个小失误而白白跑掉几小时的地方。训练脚本能跑起来和训练结果能看是两回事。下面这些坑我在不同数据集上反复遇到过按现象、原因、解决整理出来希望能帮你少走弯路。4.1 data yaml路径别写绝对路径YOLOv8 的训练入口要求一个数据 yaml核心内容是数据路径、类别数量和类别名。我建议所有路径都用相对路径这样整个项目目录拷到别的机器也不会失效。path: ./smoke_dataset train: images/train val: images/val nc: 1 names: 0: smoke注意path是数据集根目录train和val是图片目录ultralytics 会默认在同级目录的images同级找labels。如果目录结构是images/train和labels/train那么标准做法是 train 下面放图片labels 下面放同名 txttrain和val写图片路径框架用路径替换images为labels定位标签。如果你的标签目录不叫labels可以显式指定比如train: smoke_dataset/images/train加上label: smoke_dataset/labels但最简单的是遵循默认约定。nc: 1表示只有一个类别names里必须有第 0 项且对应转换脚本里的CLASS_MAP中的 class id。如果这里写错比如nc: 2但数据里只有 class 0训练不会报错但会随机初始化一个无用的类别浪费显存还干扰收敛。检查 yaml 是否被正确加载可以运行yolo detect train datasmoke.yaml modelyolov8n.pt epochs1如果 data 路径有问题yaml 加载时会直接报错如果正常会打印出数据集统计信息比如train: 17000、val: 3000之类。4.2 训练命令先 nano 跑通再上大模型用yolov8n先跑一个短实验验证数据链路是否通畅是成本最低的做法。我见过不少人一上来就用yolov8x结果 batch 只能设 2跑了几十轮 loss 没降便怀疑数据有问题。实际上 2.1 万张单类目标的数据量nano 和 small 足够m 以上在边缘部署时也未必划算。yolo detect train \ datasmoke.yaml \ modelyolov8n.pt \ epochs100 \ imgsz640 \ batch16 \ patience20 \ projectsmoke_runs \ nameexp1参数说明imgsz640是缺省推理尺寸烟雾如果以小目标为主后面可以提到 960 或 1280batch16在 16G 显存下比较稳如果显存不足降到 8patience20是早停轮数模型连续 20 轮验证集指标不升则停止避免无效训练project和name控制输出目录多组实验对比时把 name 换掉就行。如果希望训练更稳定可以在命令里追加... optimizerAdamW lr00.0008 close_mosaic10 accumulate2optimizerAdamW在小数据集上通常比 SGD 收敛更平滑lr00.0008比默认的 0.01 低不少适合只有一个类别的数据因为分类头需要重新学学习率大了容易震close_mosaic10表示最后 10 个 epoch 关闭 mosaic 增强让模型适应真实目标的分布。accumulate2是梯度累积次数显存只够跑 batch8 时累积两步等效 batch16能稳住 BN 的统计量。这些参数不是固定的要根据训练日志微调。训练过程中要看的不是每个 epoch 的 mAP而是results.png里的曲线。重点关注train/box_loss和val/box_loss如果两者都在下降mAP 自然会起来如果val/box_loss在某个 epoch 后开始上升说明过拟合了早停会触发。这时候优先加数据增强或增加数据量而不是盲目加 epoch。4.3 训练期常见问题排查下面整理五条我在烟雾数据集上真实遇到过的训练期问题现象训练启动后没几步就报CUDA out of memory。原因batch 或 imgsz 设置过大叠加 mosaic 增强后单张图占用显存被放大四倍。解决把 batch 降到 8或者imgsz480再不行就开 AMP 混合精度显式ampTrue。现象loss 下降但 mAP 始终在 0.15 以下验证集图像里烟雾确实存在。原因常见的不是模型笨而是验证集划分不干净同一场景的视频帧被分成了 train 和 val模型靠着记忆场景里的背景就能得高分或者是标签框太大把烟气扩散区域全包进去IoU 计算很吃亏。解决按文件名前缀场景分组划分数据集别用完全随机切。现象报错Label class 1 exceeds nc1。原因转换脚本里的CLASS_MAP没有覆盖全部类别名某个 XML 里出现smoke100x之类的未知类别class id 落到了 1。解决回到第 3 章先grep -h name统计所有类别名把每一个都加进映射表不想要的类别直接跳过而不是映射。现象验证阶段框整体偏宽看起来像被拉扁。原因图片是 16:9训练时 resize 到 640 保持宽高比会加灰边理论不影响归一化坐标实际常见原因是 xml 的 xmax 标注得过大把烟雾边缘的高透明区也算进去了。解决用可视化脚本把标签框画出来如果确实偏宽可以做一次边框收缩比如每边向内收缩 5%。这个操作小心别把小目标收缩没了。现象同一套训练命令两次结果差异很大。原因数据加载顺序随机、模型初始化随机、GPU 算子随机性。解决固定 seed 和 worker在命令里追加seed42 workers4如果还不行检查是不是开着多卡分布式训练单卡训练加固定 seed 后复现率能到九成以上。这五条里第二条最常见也是烟雾检测场景最隐蔽的问题。烟雾视频帧之间背景高度相似随机划分会让模型学到“哪里有杆子哪里就有烟”这样的背景记忆而不是学到烟本身的纹理。所以第 5 章我会专门讲怎么按场景切分以及怎么看训练日志判断模型状态。5. 训练与评估把烟雾检测的误检率压下来数据链路打通后训练已经能跑起来。但真正要把模型用来做火灾预警不能只看训练集 mAP还要关注 val 集上的召回率和误检率。烟雾的特点是半透明、边缘弥散、远距离看起来像一朵“白斑”这三者都会让模型倾向于漏检或误检。所以下面把重点放在干净的数据划分、合理的超参组合以及用混淆矩阵定位误检来源。5.1 先切一个干净的 train/val很多人用train_test_split随机切这在大多数目标检测数据上问题不大但烟雾数据不行。因为数据往往来自连续视频抽帧相邻帧背景几乎一样随机切会让验证集里出现“跟训练集同背景但不同帧”的泄漏导致 mAP 虚高。正确做法是按场景或视频编号分组。如果img_0485_*表示一个场景那么划分脚本应该以这个前缀为 keyimport glob import os from collections import defaultdict images sorted(glob.glob(smoke_dataset/images/*.jpg)) scene defaultdict(list) for img in images: stem os.path.basename(img) # img_0485_10246.jpg prefix stem.split(_)[0] _ stem.split(_)[1] # img_0485 scene[prefix].append(img)这里把图片按照img_0485这样的大前缀分组同一个前缀下的所有帧放进同一个桶。然后按桶分配 train/valimport random scene_items list(scene.items()) val_count max(1, int(len(scene_items) * 0.2)) rng random.Random(42) rng.shuffle(scene_items) val_scenes dict(scene_items[:val_count]) train_scenes dict(scene_items[val_count:])参数说明random.Random(42)是固定种子保证每次运行划分一样先按场景桶数量取 20% 的桶作为验证集而不是按帧数取 20%这样避免某个场景的帧同时落入两端。最后生成两个图片列表train_imgs [p for sc in train_scenes.values() for p in sc] val_imgs [p for sc in val_scenes.values() for p in sc] print(train:, len(train_imgs), val:, len(val_imgs))这种切分方法会让 val 集帧数往往小于 20%因为小场景桶占比不可控。如果希望验证集帧数更接近 20%可以用带分层的场景抽样但没必要为这种基线实验纠结关键是不能同场景泄漏。5.2 loss 曲线与关键超参训练跑完后打开smoke_runs/exp1/results.png重点看两条曲线train/box_loss和val/box_loss。如果训练集 loss 一路下降验证集 loss 在中段开始回升那就过拟合了如果两者都降得很慢但 mAP 在缓慢上升说明烟雾对模型来说确实难学可以考虑拉大输入分辨率。先说一个结论烟雾检测这种半透明小目标最有效的超参是imgsz和mosaic。imgsz640时一个面积只有 32×32 像素的烟团在模型特征图里只占不到 1 个格子非常容易漏检。调到 960 或 1280同样的目标能多出几倍特征召回率提升明显。显存不够就降 batchyolo detect train \ datasmoke.yaml \ modelyolov8n.pt \ epochs100 \ imgsz960 \ batch8 \ close_mosaic10close_mosaic10是另一个有效手段。mosaic 增强会把四张图拼在一起在训练前半程能大幅增强小目标样本量但到后半程模型会过度依赖马赛克产生的混合背景在真实单图推理时表现变差。所以训练最后 10 个 epoch 关闭 mosaic等于让模型在真实场景分布上微调这个技巧在烟雾数据上收益很直接。如果模型仍然漏检可以考虑数据增强里加copy_paste和mixup。copy_paste会把一个烟雾目标粘贴到另一张无烟图的随机位置相当于无中生有地增加目标数量和背景多样性mixup则是按比例混合两张图帮助模型对透明目标更鲁棒。在 ultralytics 里可以直接传参... copy_paste0.3 mixup0.2 fliplr0.5 scale0.4参数说明copy_paste0.3表示 30% 的概率做复制粘贴增强mixup0.2是混合概率scale0.4限制了随机缩放幅度烟雾框往往大而松缩放太大容易让目标形变失真。这几个参数都需要根据 loss 曲线微调不要一次全拉满。5.3 验证与误检分析不止看 mAP要看混淆矩阵训练完成后下一步是拿 best.pt 在 val 集上做正式验证注意不要用最后一轮权重最好用早停保存的 bestyolo detect val \ datasmoke.yaml \ modelsmoke_runs/exp1/weights/best.pt \ conf0.001这里故意把conf设成 0.001是为了让所有框都输出完整计算 PR 曲线而不是只看高置信度的表现。如果设成 0.25模型的低置信度预测会被滤掉PR 曲线看起来好看但实际部署时高漏检的问题会被掩盖。验证完成后项目目录下会生成confusion_matrix.png重点关注background这一列。background列数值高说明模型把没有烟的地方当成烟这是烟雾检测最头疼的误检。典型场景是云朵、白色建筑、蒸汽、夜晚光晕。降低背景误检的思路有三个一是增加负样本在训练集里加入大量纯背景图用空 txt 表示没有目标二是提高置信度阈值部署时conf0.35以上代价是召回率下降三是做 hard case 收集把验证集误检框保存下来人工打标后并入训练集这叫 hard negative mining。收集误检框的思路很简单from ultralytics import YOLO model YOLO(smoke_runs/exp1/weights/best.pt) for img_path in hard_images: result model.predict(img_path, conf0.1) if result[0].boxes is None: continue boxes result[0].boxes.xyxy.cpu().numpy() # 把没有匹配到真实框的预测存成候选样本这段代码只起到定位硬样本的作用conf0.1把更多疑似目标捞出来方便人工确认哪些是误检。确认后把对应图片加入训练集并写入一个空标签文件表示该位置没有目标或者把误检区域标成负样本。注意空标签代表整张图无目标如果你想只告诉模型“这个白色云朵不是烟”需要的是一个独立类别来抑制它但在单一类别场景下先把整张图作为负样本是更实用的操作。还有一个容易被忽略的指标是mAP50-95。烟雾目标边界模糊mAP50高不代表框定位准mAP50-95对 IoU 更敏感能反映框贴合烟雾轮廓的程度。如果在训练日志里看到mAP50一直在 0.6 以上但mAP50-95只有 0.2 左右说明模型的框偏大或偏小需要回查标签的 bndbox 是否把烟雾外围透明区全包进去了。这种框往往中心偏下因为烟是向上扩散的顶点在上底部在火源标注者容易把底部压得过低。6. 进阶技巧半透明烟雾的增强与滑窗推理烟雾和普通固体目标不同它没有清晰边界训练出来的框天然会比标注框更松。想让检测框更贴近烟雾轮廓我把两项投入产出比最高的技巧写在这里合成负样本增强和滑窗推理。第一项是合成负样本。很多场景里摄像头对着天空、远山、烟囱这些区域容易触发误检但现有数据集里可能没有。我一般会手工截一批背景图直接放到images/train下并在labels/train下放同名的空 txt 文件。这样模型会学到“这些背景图里没有烟”背景误检率能明显下降。注意空 txt 必须存在文件名与图片主名一致否则这张图会被 YOLO 当作无标签数据跳过。第二项是滑窗推理。对于 1080p 甚至 4K 画面直接用imgsz640推理会把小烟团压得很小。常见做法是维护一个滑动窗口把原图切成几个 640×640 的 patch每个 patch 分别推理最后把所有框合并回原图坐标再做一次 NMS 去重。这个思路比单纯拉高 imgsz 更省显存效果也更聚焦。核心逻辑如下import numpy as np import torch import torchvision def sliding_predict(model, img, patch640, stride320, conf0.25): h, w img.shape[:2] boxes, scores [], [] for y in range(0, h - patch 1, stride): for x in range(0, w - patch 1, stride): crop img[y:ypatch, x:xpatch] res model.predict(crop, confconf, imgszpatch, verboseFalse)[0] if res.boxes is None: continue for box, score in zip(res.boxes.xyxy.cpu().numpy(), res.boxes.conf.cpu().numpy()): bx box [x, y, x, y] boxes.append(bx) scores.append(score) if boxes: boxes torch.as_tensor(np.array(boxes)) scores torch.as_tensor(np.array(scores)) keep torchvision.ops.nms(boxes, scores, iou_threshold0.45) return boxes[keep].numpy(), scores[keep].numpy() return np.empty((0, 4)), np.empty((0,))这段代码的逻辑是把大图切成固定 stride 的 patch推理结果里每个框的坐标要加上x和y偏移量最终用 NMS 把相邻 patch 对同一个目标的重复框合并。参数stride320意味着 patch 之间有重叠重叠能有效防止目标恰好落在 patch 边缘被裁掉。这个技巧在老式 GPU 上也能跑出接近imgsz1280的效果代价是推理时间变为原来的数倍。这两项技巧第一次用的时候我吃过一次亏空负样本放进去后训练 loss 反而上升后来发现是空 txt 文件编码有问题ultralytics 读不到就把图片当成无标签样本跳过了等于负样本根本没进去。从此以后我每次扩展训练集都会先随机抽 20 对图片和 txt手动确认标签文件名、内容和位置再跑训练滑窗推理也一定要在合并后画一次图确认没有重复框和坐标越界。这些看起来慢却是烟雾检测这种“边界本来就不清晰”的任务里最值得花的功夫。希望帮到你。本文还有配套的精品资源点击获取