ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

烟雾检测数据集实战:VOC/COCO/YOLO格式转换与划分避坑指南

烟雾检测数据集实战:VOC/COCO/YOLO格式转换与划分避坑指南

简介:本资源为面向目标检测学习者的YOLO烟雾检测数据集,适用于安防监控、工业安全等场景下的烟雾识别模型训练,适合具备一定深度学习基础、正在做课程设计或课题研究的学生与开发者。压缩包共2000个文件,约86.54MB,包含1000张真实场景高质量图片,以及voc(xml)、coco(json)和yolo(txt)三种格式标签,分别存放于不同文件夹,可直接用于YOLO系列模型训练;另附yaml配置文件、数据集划分脚本与训练教程文档,方便按需划分训练集、验证集与测试集。资源还提供Windows与Linux环境搭建及训练案例教程,帮助读者快速跑通训练流程并迁移到自己的数据。目前已有362人学习下载,标注框质量高、场景丰富,是一份开箱即用的烟雾检测实战资料。

1. 烟雾检测数据集拿到手,先别急着喂给 YOLO

烟雾目标检测跟行人、车辆检测有个本质区别:烟雾没有刚性轮廓,边缘弥散、半透明,同一团烟在连续帧里形状能差出三成以上。所以当你拿到一份标称 1000 张图片、带 VOC/COCO/YOLO 三种格式标签、还附了划分脚本和训练教程的压缩包时,第一反应不该是解压完直接train.py,而是先搞清楚这批标注到底是怎么框的、三种格式之间能不能无损互转、划分脚本按什么策略切分。这三个问题任何一个翻车,后面 loss 曲线再漂亮,模型上线也是黑匣子。

这份资源真正值钱的地方不在图片数量,而在于它把格式转换和数据集划分这两件脏活提前做完了。1000 张对于烟雾检测属于小样本起步量级,适合做原型验证、迁移学习微调,或者作为自建数据集的格式参考模板。适合谁用:刚入门 YOLO 目标检测、想跑通一个完整烟雾检测 pipeline 的工程师;手里有监控视频拉流需求、想先拿现成数据验证模型可行性的开发者;以及需要 VOC/COCO/YOLO 三格式对照样本来理解标注差异的算法同学。下面按「先验数据 → 再跑训练 → 最后避坑」的顺序拆开讲。

2. 三种标签格式的差异与转换逻辑:VOC、COCO、YOLO 到底怎么选

2.1 三种格式的坐标体系与文件结构

VOC 格式的核心是每张图对应一个 XML 文件,坐标用绝对像素值表示,结构是<xmin> <ymin> <xmax> <ymax>,原点在左上角。COCO 格式用一个统一的 JSON 文件管理所有图片和标注,坐标同样是绝对像素值,但字段是[x, y, width, height],注意这里是宽高而不是右下角坐标。YOLO 格式每张图对应一个 txt 文件,坐标是归一化后的中心点加宽高,即[x_center, y_center, width, height],所有值都在 0 到 1 之间。

这三种格式的差异不只是文件组织方式,更关键的是坐标语义。VOC 和 COCO 用绝对像素,YOLO 用归一化相对值。转换时最容易出错的就是归一化那一步——除以的是图片原始宽高,不是网络输入尺寸。很多人拿 640×640 的输入尺寸去归一化,结果标签全错位。

格式文件组织坐标类型单类字段多类处理
VOC每图一个 XML绝对像素 xmin/ymin/xmax/ymax<object>节点多个 object 节点
COCO单个 JSON绝对像素 x/y/w/hannotations 数组category_id 区分
YOLO每图一个 txt归一化中心点+宽高每行一个目标行首 class_id

2.2 用脚本做 VOC 到 YOLO 的批量转换

拿到 VOC 格式的 XML 后,转成 YOLO 格式是最常见的操作。下面这段脚本处理单目录下所有 XML,输出对应的 txt 文件。关键点在于读取图片真实尺寸,而不是硬编码。

import xml.etree.ElementTree as ET import os from PIL import Image def voc_to_yolo(xml_dir, img_dir, out_dir, class_map): """ xml_dir: VOC标注文件夹 img_dir: 对应图片文件夹,用于读取真实宽高 out_dir: YOLO标签输出文件夹 class_map: {'smoke': 0} 类别名到id的映射 """ os.makedirs(out_dir, exist_ok=True) for xml_file in os.listdir(xml_dir): if not xml_file.endswith('.xml'): continue tree = ET.parse(os.path.join(xml_dir, xml_file)) root = tree.getroot() # 用文件名去匹配图片,注意扩展名可能不同 img_name = os.path.splitext(xml_file)[0] img_path = None for ext in ['.jpg', '.png', '.jpeg']: candidate = os.path.join(img_dir, img_name + ext) if os.path.exists(candidate): img_path = candidate break if img_path is None: print(f'跳过 {xml_file}:找不到对应图片') continue with Image.open(img_path) as im: w, h = im.size # 真实宽高,不是网络输入尺寸 lines = [] for obj in root.findall('object'): cls_name = obj.find('name').text if cls_name not in class_map: continue bbox = obj.find('bndbox') xmin = float(bbox.find('xmin').text) ymin = float(bbox.find('ymin').text) xmax = float(bbox.find('xmax').text) ymax = float(bbox.find('ymax').text) # 归一化中心点与宽高 xc = (xmin + xmax) / 2.0 / w yc = (ymin + ymax) / 2.0 / h bw = (xmax - xmin) / w bh = (ymax - ymin) / h # 边界裁剪,防止标注越界导致训练报错 xc = min(max(xc, 0.0), 1.0) yc = min(max(yc, 0.0), 1.0) bw = min(max(bw, 0.0), 1.0) bh = min(max(bh, 0.0), 1.0) lines.append(f"{class_map[cls_name]} {xc:.6f} {yc:.6f} {bw:.6f} {bh:.6f}") with open(os.path.join(out_dir, img_name + '.txt'), 'w') as f: f.write('\n'.join(lines))

逻辑说明:先解析 XML 拿到绝对坐标,再用 PIL 读图片真实尺寸做归一化。参数方面,class_map必须和后续训练时的data.yaml里names顺序一致,否则类别全乱。:.6f保留六位小数是 YOLO 官方推荐的精度,太少会导致小目标框偏移。边界裁剪那四行是血泪经验——有些标注框会超出图片边缘一两个像素,不裁剪的话 YOLO 训练时直接报invalid label然后中断。

2.3 COCO 转 YOLO 时最容易忽略的 category_id 映射

COCO 的 JSON 里categories数组的id不一定是 0 开始的连续整数。有些数据集从 1 开始,有些中间跳号。转 YOLO 时必须重新映射成从 0 开始的连续整数,否则 YOLO 会认为你有几十个类别但实际只标注了其中几个。

import json import os from PIL import Image def coco_to_yolo(json_path, img_dir, out_dir): with open(json_path, 'r') as f: data = json.load(f) # 建立 category_id 到连续 id 的映射 cat_ids = sorted([c['id'] for c in data['categories']]) cat_map = {old: new for new, old in enumerate(cat_ids)} # 建立 image_id 到文件信息的索引 img_info = {img['id']: img for img in data['images']} os.makedirs(out_dir, exist_ok=True) # 按 image_id 分组标注 from collections import defaultdict anns = defaultdict(list) for ann in data['annotations']: anns[ann['image_id']].append(ann) for img_id, ann_list in anns.items(): info = img_info[img_id] w, h = info['width'], info['height'] lines = [] for ann in ann_list: if ann.get('iscrowd', 0) == 1: continue # 跳过 crowd 标注,YOLO 不支持 x, y, bw, bh = ann['bbox'] # COCO 是左上角+宽高 xc = (x + bw / 2.0) / w yc = (y + bh / 2.0) / h nw = bw / w nh = bh / h cls_id = cat_map[ann['category_id']] lines.append(f"{cls_id} {xc:.6f} {yc:.6f} {nw:.6f} {nh:.6f}") # 用原文件名(去扩展名)作为 txt 名 base = os.path.splitext(info['file_name'])[0] with open(os.path.join(out_dir, base + '.txt'), 'w') as f: f.write('\n'.join(lines))

这里有两个参数要盯住:iscrowd为 1 的标注必须跳过,YOLO 的损失函数不处理这种区域;cat_map的排序决定了最终类别 id,如果后面要跟 VOC 转换结果合并,两边映射必须对齐。常见做法是统一用类别名字母序排,别用原始 id 顺序。

3. 数据集划分脚本怎么写:训练集、验证集、测试集的比例与分层策略

3.1 按 8:1:1 切分的最小实现

烟雾检测数据集的划分不能纯随机。如果同一段视频里连续抽帧的图片被分到训练集和验证集两边,验证集精度会虚高,因为模型见过几乎一样的画面。常见做法是先按来源分组,再在组内随机划分。

import os import random import shutil def split_dataset(img_dir, label_dir, out_root, ratios=(0.8, 0.1, 0.1), seed=42): """ img_dir: 所有图片放一个文件夹 label_dir: 所有YOLO txt放一个文件夹 out_root: 输出根目录,下面建 images/labels 的 train/val/test ratios: 训练/验证/测试比例 """ random.seed(seed) # 固定种子保证可复现 names = [os.path.splitext(f)[0] for f in os.listdir(img_dir) if f.lower().endswith(('.jpg', '.png', '.jpeg'))] random.shuffle(names) n = len(names) n_train = int(n * ratios[0]) n_val = int(n * ratios[1]) splits = { 'train': names[:n_train], 'val': names[n_train:n_train + n_val], 'test': names[n_train + n_val:] } for split, file_list in splits.items(): img_out = os.path.join(out_root, 'images', split) lbl_out = os.path.join(out_root, 'labels', split) os.makedirs(img_out, exist_ok=True) os.makedirs(lbl_out, exist_ok=True) for name in file_list: # 找图片,扩展名可能不统一 for ext in ['.jpg', '.png', '.jpeg']: src_img = os.path.join(img_dir, name + ext) if os.path.exists(src_img): shutil.copy(src_img, os.path.join(img_out, name + ext)) break src_lbl = os.path.join(label_dir, name + '.txt') if os.path.exists(src_lbl): shutil.copy(src_lbl, os.path.join(lbl_out, name + '.txt')) print(f'训练集 {len(splits["train"])},验证集 {len(splits["val"])},测试集 {len(splits["test"])}')

参数说明:seed=42是习惯用法,换成任何固定整数都行,关键是别用系统时间做种子,否则每次跑出来的划分都不一样,实验没法对比。ratios三个数加起来必须等于 1,1000 张图按 8:1:1 就是 800/100/100。如果数据量少于 500 张,建议改成 7:2:1 或者做交叉验证,验证集太小的话 mAP 波动能到正负 5 个点。

3.2 分层抽样:让每个 split 的烟雾目标数量分布一致

纯随机划分有个隐患:某些图片里烟雾目标特别多,某些只有一小团。如果验证集恰好抽到一堆目标密集的图,评估结果会偏乐观。分层抽样的思路是按每张图的标注框数量分桶,再从每个桶里按比例抽。

import os from collections import defaultdict def stratified_split(label_dir, ratios=(0.8, 0.1, 0.1), seed=42): random.seed(seed) # 按目标数量分桶 buckets = defaultdict(list) for f in os.listdir(label_dir): if not f.endswith('.txt'): continue with open(os.path.join(label_dir, f)) as fh: lines = [l for l in fh.read().strip().split('\n') if l] count = len(lines) # 桶的粒度:0, 1, 2, 3, 4+ key = min(count, 4) buckets[key].append(os.path.splitext(f)[0]) train, val, test = [], [], [] for key in sorted(buckets.keys()): names = buckets[key] random.shuffle(names) n = len(names) n_train = int(n * ratios[0]) n_val = int(n * ratios[1]) train.extend(names[:n_train]) val.extend(names[n_train:n_train + n_val]) test.extend(names[n_train + n_val:]) return train, val, test

这个策略在烟雾数据集上尤其重要,因为烟雾标注经常出现「一张图里多个独立烟源」的情况。分桶粒度用min(count, 4)是为了避免某个桶只有一两张图导致划分比例失真。如果某个桶的图片数少于 10 张,可以考虑合并到相邻桶。

3.3 划分后必须做的三项校验

划分脚本跑完不代表没事了。我一般会做三个检查:第一,确认每个 split 的图片数和标签数一致,缺标签的图片要么补标要么剔除;第二,统计每个 split 里各类别的实例总数,偏差超过 10% 就重新调种子;第三,随机抽 5 张图把 YOLO 标签画回原图上看框位置对不对,这一步能抓出归一化除错尺寸的低级错误。

import os def validate_split(out_root): for split in ['train', 'val', 'test']: img_dir = os.path.join(out_root, 'images', split) lbl_dir = os.path.join(out_root, 'labels', split) imgs = {os.path.splitext(f)[0] for f in os.listdir(img_dir)} lbls = {os.path.splitext(f)[0] for f in os.listdir(lbl_dir)} missing = imgs - lbls extra = lbls - imgs print(f'{split}: 图片 {len(imgs)},标签 {len(lbls)},缺标签 {len(missing)},多余标签 {len(extra)}') # 统计类别分布 cls_count = {} for lbl in lbls: with open(os.path.join(lbl_dir, lbl + '.txt')) as f: for line in f: if line.strip(): cid = line.split()[0] cls_count[cid] = cls_count.get(cid, 0) + 1 print(f' 类别分布: {cls_count}')

missing集合里的图片就是没有标注的,如果数量超过总数的 2%,建议直接剔除而不是补零标签——空标签图片会让模型学到「这里什么都没有」的负样本,但烟雾检测里负样本应该是有意识地采集无烟场景,而不是标注遗漏。

4. 用这份数据集跑通 YOLO 训练:从 data.yaml 到第一轮推理

4.1 data.yaml 的路径与类别配置

YOLO 训练入口是一个 yaml 文件,里面写清楚训练集、验证集路径和类别名。路径建议用绝对路径,相对路径在不同工作目录下跑容易翻车。

# smoke_data.yaml path: /home/user/smoke_dataset # 数据集根目录 train: images/train val: images/val test: images/test nc: 1 # 类别数,烟雾检测通常只有一类 names: 0: smoke

nc和names的键必须对应,names里写类别名,顺序和转换脚本里的class_map一致。如果后面要加「火焰」类,改成nc: 2,names加一行1: fire,同时所有标签文件里的 class_id 也要同步改。

4.2 训练命令与关键超参

假设用 YOLOv8 或相近版本的命令行接口,最小训练命令如下:

yolo detect train \ data=smoke_data.yaml \ model=yolov8n.pt \ epochs=100 \ imgsz=640 \ batch=16 \ lr0=0.01 \ patience=20 \ project=runs/smoke \ name=exp1

参数逐个说:model=yolov8n.pt用预训练权重做迁移学习,烟雾数据集只有 1000 张,从零训几乎不可能收敛。imgsz=640是标准输入尺寸,如果烟雾目标普遍很小,可以提到 1280,但显存占用翻四倍。batch=16在 8GB 显存上跑 640 分辨率基本安全,爆显存就降到 8。lr0=0.01是初始学习率,微调场景可以降到 0.001 避免破坏预训练特征。patience=20表示验证集指标 20 轮不提升就早停,小数据集上这个值别设太大,否则过拟合了还在跑。

4.3 训练过程中的指标解读与中断判断

训练日志里重点看三个指标:box_loss、cls_loss和mAP50。烟雾检测的box_loss下降通常比常规目标慢,因为烟雾边界模糊,模型很难精确回归到标注框边缘。如果box_loss降到 0.5 左右就下不去了,别急着调参,先看看标注框是不是画得太紧——烟雾标注一般建议框到肉眼可见的烟雾外沿再往外扩 5 到 10 个像素。

mAP50在 1000 张数据上,单类烟雾检测能到 0.6 到 0.75 就算正常。如果低于 0.4,按这个顺序排查:标签格式对不对 → 图片和标签是否一一对应 → 学习率是不是太大 → 预训练权重有没有加载成功。cls_loss如果一直不降,检查data.yaml里的nc是不是写成了实际类别数。

训练中断的判断标准:连续 10 轮mAP50波动小于 0.005 且box_loss不再下降,就可以停了。小数据集上过拟合来得很快,通常 80 到 120 轮之间就是拐点。

5. 烟雾检测数据集踩坑记录:从标签错位到验证集虚高

5.1 标签归一化除了网络输入尺寸而不是原图尺寸

现象:训练 loss 正常下降,但推理时框全部偏移,有的跑到图片外面。原因:转换脚本里用了imgsz而不是PIL.Image.size做归一化。解决:归一化分母必须是图片原始宽高,网络输入尺寸只影响训练时的 resize 操作,不影响标签数值。

5.2 训练集和验证集出现同一段视频的相邻帧

现象:验证集mAP50比测试集高出 15 个点以上。原因:随机划分把连续帧分到了不同 split,模型在验证集上相当于见过近似重复样本。解决:划分前先按视频来源或时间戳分组,组内再随机切分。如果数据来源信息丢失,可以用图片感知哈希做去重,相似度超过阈值的归为一组。

5.3 COCO 的 iscrowd 标注没跳过导致训练报错

现象:训练启动时报AssertionError: label out of bounds或类似错误。原因:COCO 里iscrowd=1的标注区域可能超出图片边界,转 YOLO 后坐标不在 0 到 1 之间。解决:转换时加if ann.get('iscrowd', 0) == 1: continue,同时对所有坐标做min(max(v, 0), 1)裁剪。

5.4 空标签图片被当成负样本喂进去

现象:模型对烟雾的召回率偏低,很多明显有烟的图检测不到。原因:划分时把没有标注文件的图片也复制过去了,YOLO 把空 txt 当作「这张图没有目标」的负样本。解决:划分脚本里加校验,图片没有对应标签文件时直接跳过,不要生成空 txt。负样本应该是有意识采集的无烟场景,并且同样要经过标注流程确认无烟。

5.5 验证集太小导致早停过早触发

现象:训练到 30 轮就停了,但模型明显还没收敛。原因:验证集只有 50 张图,mAP50波动大,连续几轮不提升就触发了patience。解决:验证集至少占总量的 10%,1000 张图就是 100 张。如果数据实在少,把patience提到 30 到 50,或者改用固定轮数训练再手动选最佳权重。

6. 小样本烟雾检测的进阶技巧:从 1000 张里榨出更多信息

1000 张图片做烟雾检测,数据量是硬约束。我一般会从三个方向补:第一,用 MOSAIC 和 MixUp 增强,YOLO 默认已经开了,但烟雾场景可以把mosaic=1.0保持到训练结束前 10 轮再关,让模型多见拼接场景。第二,用预训练权重做 backbone 冻结,先冻住前几层训 20 轮,再解冻全量微调,这样小数据集上收敛更稳。第三,把推理时的conf阈值从默认 0.25 降到 0.1 先看召回,烟雾检测漏检的代价通常比误检高。

验证模型有没有真正学到烟雾特征,而不是记住背景,有个简单办法:拿几张训练集里没有的场景图,手动把烟雾区域裁出来贴到纯色背景上推理。如果模型还能框出来,说明学到的是烟雾本身的纹理和颜色分布;如果框不出来了,大概率是过拟合到背景了。这个测试我每次训完烟雾模型都会跑一遍,比看 mAP 曲线更能说明问题。

最后说个习惯:每次改完划分脚本或转换脚本,先拿 10 张图跑一遍完整流程,把生成的 YOLO 标签用cv2.rectangle画回原图上看一眼。这个动作花不了两分钟,但能拦住九成以上的标签格式问题。希望帮到你。

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

返回列表