ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

汽油泄露视觉检测实战:YOLO数据集格式转换与训练避坑指南

汽油泄露视觉检测实战:YOLO数据集格式转换与训练避坑指南

简介:YOLO汽油泄露检测数据集是一套面向目标检测任务的真实场景数据集,适合计算机视觉学习者、算法工程师及安全监控场景开发者使用。包内包含1000张高质量标注图片,采用LabelImg工具精细标注,并同时提供VOC(xml)、COCO(json)和YOLO(txt)三种标准格式标签,分别存放于独立文件夹,可直接用于YOLO系列模型训练。资源总计2000个文件,以xml标签、txt标签、Python脚本、yaml配置及HTML教程为主,压缩包大小约123.63MB。除数据外,还附赠YOLO环境搭建(含Linux/Windows)与训练案例教程、数据集划分脚本,支持按需求自动切分训练集、验证集和测试集,并生成ImageSets下的索引文件,方便快速开展实验。目前已有255人浏览学习,适合需要规范数据集练习YOLO检测流程或开展泄露检测落地项目的读者。

1. 汽油泄露的视觉检测难在哪:数据问题先于模型问题

做加油站、化工厂罐区、输油管道附近的视觉监测,很多人第一步就栽在数据集上。拿通用目标检测模型直接去跑现场画面,水泥地上的暗色油渍、柏油路的黑斑、雨后地面的反光,全都会被当成候选框报出来。误报一多,值班人员直接把报警关了,系统就成了摆设。这种安全场景要的是稳定和极低误报,而模型在训练时没见过多少真实负例,光靠调结构、调阈值根本救不回来。这也是「YOLO汽油泄露检测数据集」这类资源被反复讨论的原因:1000张图,对应VOC、COCO、YOLO三份标签,外加划分脚本和训练教程,等于把「从零跑通一个泄露检测模型」的最小路径打包好了。它适合先用几天时间做可行性验证的团队,也适合拿来当算法基线,把精力集中到数据补采和误报治理上。这包东西解决的从来不是模型创新问题,而是开局问题:让你第一天就能把YOLO训练起来,再谈现场好不好用。

2. 拆数据包:三份标签的文件级差异与动手前两个检查点

2.1 三种标注格式的文件形态与目录结构

拿到这类数据包,我一般会把三份标签当成三种不同的「协议」来看,而不是先急着跑训练。VOC格式长什么样:每一张图对应一个同名xml文件,标注框用xmin/ymin/xmax/ymax四个像素坐标表示,类别写在object/name字段里。COCO格式相反,全部图片的标注集中在一个json文件里,通过image_id关联图片,框的表示是[x, y, width, height]像素坐标,类别用整数category_id表示。YOLO格式则是每张图同名txt,每行一条标注,第一列是类别ID,后面四列是归一化后的中心点x、中心点y、宽、高。

为什么同一个数据集要准备三份?因为不同训练框架读取标签的入口不一样。YOLO系列直接读txt,省得训练时现转;基于检测框架的很多旧项目习惯用VOC;部分新框架、或者要跑评测基准时更认COCO。三份标签让模型框架的选择不被卡住,这是一份资源做得完整的标志。

项目VOC(xml)COCO(json)YOLO(txt)
存储方式每张图一个同名xml所有标注集中在一个json每张图一个同名txt
坐标含义xmin/ymin/xmax/ymax 像素bbox 为 x,y,width,height 像素x_center, y_center, w, h 归一化
类别表示object.name 字符串category_id 整数每行第一列整数
读取特点逐文件解析载入后按image_id聚合逐行解析

目录结构方面,常见排布是images/放图片,Annotations/放VOC的xml,annotations/下放COCO的json,labels/放YOLO的txt,再附一个划分脚本和训练教程文档。解压之后先对照这个结构过一遍,确认文件命名前缀能不能一一对上,这一步不值钱,但能省掉后面整夜的排查。

2.2 先跑一遍数据审计,清掉缺标签和空标签

动手训练前,我会先用脚本把数据资产审计一遍。核心就两个问题:图片和标签能不能对得上、标签里到底有哪些类别。很多时候从网上下载的资源或同事转交的数据包会有零星几张图没标签,训练时直接报错或者被跳过,看起来是小问题,实际会影响划分比例和类别统计。下面这个脚本针对YOLO目录做一次快速核对:

import json import os image_dir = "images" label_dir = "labels" # 1) 检查图片与标签是否一一对应 for img_name in os.listdir(image_dir): stem = os.path.splitext(img_name)[0] txt_path = os.path.join(label_dir, stem + ".txt") if not os.path.exists(txt_path): print("no label:", img_name) # 2) 检查是否有空标签 for txt_name in os.listdir(label_dir): txt_path = os.path.join(label_dir, txt_name) if os.path.getsize(txt_path) == 0: print("empty label:", txt_name)

脚本逻辑很直白:先按图片文件名找同名txt,找不到就把图片名打出来;再遍历labels目录,把大小为0的txt列出来。YOLO训练时空标签文件不一定会报错,但会让模型把这个样本当成纯背景进行反向传播,干扰程度取决于空标签数量。COCO目录则单独检查json:

with open("annotations/instances_train.json") as f: coco = json.load(f) print("categories:", [c["name"] for c in coco["categories"]]) print("images:", len(coco["images"])) print("annotations:", len(coco["annotations"]))

COCO的json结构里,categories数组能直接看到类别名和ID的映射关系,annotations的条数如果远小于预期,说明部分图片的标注缺失了。这一步做完,数据包的基本健康状况就有数了。

2.3 单类别小数据也要看的类别平衡与负样本占比

这1000张图是单类别还是多类别,直接影响后续策略。汽油泄露检测通常就一个正类gas_leak,但目录里可能混进了少量其他类别的历史标签,比如water、shadow之类。我一般会统计每个类别ID在总标签里出现的次数,如果次要类别占比低于5%,会直接把它剔除,只留主类别,避免模型在1000张的小数据上学到一个几乎没出现过几次的类,白占参数。

更值得关注的是负样本比例,也就是一张图里完全没有任何泄露框的「纯背景图」占比。工业场景里监控画面绝大多数时间都是正常的,模型上线后看到的几乎全是负样本;如果训练集里全是正样本,模型会把「地面有点暗」当作泄露。实操中建议保留20%到30%负样本图,或者额外采集一些同场景无目标画面放进去。这份数据包如果没有专门给负样本,可以在补采时按这个比例配,效果会比盲目加增强更明显。

3. VOC、COCO、YOLO互转:坐标换算与四个边界坑

3.1 把VOC像素框换算成YOLO归一化框的公式

三份标签的核心差异是坐标系。VOC的xmin, ymin, xmax, ymax是像素坐标,左上角为原点;YOLO要求中心点坐标和宽高都除以图像宽高,变成0到1之间的小数。换算关系并不复杂:

x_center = ((xmin + xmax) / 2) / img_width y_center = ((ymin + ymax) / 2) / img_height w = (xmax - xmin) / img_width h = (ymax - ymin) / img_height

第1章开头写了,这里再强调下最容易出错的地方:图像宽高必须从xml的size节点读取,而不能拿文件名去猜。很多图片的宽高并非统一尺寸,转出来自然不对;更隐蔽的是部分xml里xmax写的是右下角坐标加一,即开区间表示法,转换后框会整体偏移一到两个像素。单类检测里这一两个像素影响不大,但如果后续做精细分割或小目标检测,会出现边界对齐问题。另外,VOC里有的框边界会略微超出图像范围,转换时要做clip,把归一化值限制在0到1之间。

3.2 VOC转YOLO的最小转换脚本

下面这个脚本是我常用的转换模板,只依赖Python标准库,不引入额外依赖:

import os import xml.etree.ElementTree as ET CLASS_MAP = {"gas_leak": 0} # 类别名到YOLO类ID的映射 def voc_to_yolo(xml_path, out_dir): tree = ET.parse(xml_path) root = tree.getroot() # 图像宽高必须从xml的size节点读,不能用固定值 size = root.find("size") img_w = int(size.find("width").text) img_h = int(size.find("height").text) lines = [] for obj in root.findall("object"): name = obj.find("name").text.strip() if name not in CLASS_MAP: 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) x_center = (x1 + x2) / 2 / img_w y_center = (y1 + y2) / 2 / img_h w = (x2 - x1) / img_w h = (y2 - y1) / img_h # 越界保护:泄露标注偶尔会把框画出图外 x_center = min(max(x_center, 0.0), 1.0) y_center = min(max(y_center, 0.0), 1.0) w = min(w, 1.0) h = min(h, 1.0) lines.append(f"{CLASS_MAP[name]} {x_center:.6f} {y_center:.6f} {w:.6f} {h:.6f}") if not lines: return False # 空标签不生成txt,避免训练干扰 txt_name = os.path.splitext(os.path.basename(xml_path))[0] + ".txt" with open(os.path.join(out_dir, txt_name), "w") as f: f.write("\n".join(lines)) return True

逻辑说明:先定位xml里object的name字段,命中类别映射才处理;再从bndbox读四个坐标,按第一节的公式换算成YOLO格式。参数说明:CLASS_MAP按你自己数据包的类别去改,如果三份标签里主类别ID不是0,这里默认的{"gas_leak": 0}需要调整成与COCO json的categories数组顺序一致;越界clip的作用是防止某些框角点落在图像外的标注在后续训练中产生负数坐标,导致loss异常。

3.3 COCO转YOLO:category_id减一与bbox偏移

COCO json转YOLO有另外两个硬性差异。第一,COCO的category_id从1开始计数,而YOLO的类别ID从0开始,转换时要做一次映射减一。第二,COCO的bbox是左上角坐标加宽高的方式,即[x, y, width, height],中心点计算时要在x上加width/2。下面是一段可直接套用的转换核心:

import json import os def coco_to_yolo(json_path, out_dir): with open(json_path) as f: data = json.load(f) # 将COCO的category_id映射为YOLO类ID cat_map = {cat["id"]: i for i, cat in enumerate(data["categories"])} img_info = {img["id"]: img for img in data["images"]} anns_by_img = {} for ann in data["annotations"]: anns_by_img.setdefault(ann["image_id"], []).append(ann) for img_id, img in img_info.items(): w, h = img["width"], img["height"] lines = [] for ann in anns_by_img.get(img_id, []): x, y, bw, bh = ann["bbox"] if bw <= 0 or bh <= 0: continue cx = (x + bw / 2) / w cy = (y + bh / 2) / h nw = bw / w nh = bh / h lines.append(f"{cat_map[ann['category_id']]} {cx:.6f} {cy:.6f} {nw:.6f} {nh:.6f}") img_name = img["file_name"] txt_path = os.path.join(out_dir, os.path.splitext(img_name)[0] + ".txt") with open(txt_path, "w") as f: f.write("\n".join(lines))

逻辑说明:先构建image_id到图片信息的字典,再把所有标注按image_id聚合,最后逐图组合成YOLO行。参数说明:cat_map保证了即使COCO里类别ID不连续也能正确转换;bw <= 0的过滤是为了跳过那些在标注过程中被误操作成空框的坏标注。COCO json里有时还带着segmentation多边形字段,转YOLO时直接忽略即可,YOLO的txt只吃矩形框。

3.4 转完必须做可视化校验,别只看坐标数值

转换脚本跑完,我一眼都不会信输出的txt,因为坐标数值对不代表框的位置对。归一化之后人眼已经没法直接从数字判断框是否贴合目标,必须把框画回图上抽查。做法是随机抽20到30张图,读txt,把归一化坐标乘回图片宽高,用OpenCV画矩形然后保存:

import cv2 import os colors = [(0, 0, 255)] def draw_yolo_txt(img_path, txt_path, out_dir): img = cv2.imread(img_path) h, w = img.shape[:2] with open(txt_path) as f: for line in f: parts = line.strip().split() if len(parts) < 5: continue _, cx, cy, bw, bh = parts cx, cy, bw, bh = float(cx) * w, float(cy) * h, float(bw) * w, float(bh) * h x1, y1 = int(cx - bw / 2), int(cy - bh / 2) x2, y2 = int(cx + bw / 2), int(cy + bh / 2) cv2.rectangle(img, (x1, y1), (x2, y2), colors[0], 2) cv2.imwrite(os.path.join(out_dir, os.path.basename(img_path)), img)

这步能暴露三种问题:框整体偏移说明坐标换算有系统误差;框明显偏大或偏小说明VOC的坐标语义理解错了;完全没有框说明该图标签缺失,后面训练时要补。可视化校验是转换流程里最便宜的质量保障,一定要做。如果觉得OpenCV环境装了麻烦,也可以直接用标注工具打开几张图核对,效果一样。

4. 划分脚本与训练教程:先切数据再跑YOLO

4.1 划分脚本核心逻辑:随机种子与三目录输出

数据包里带的划分脚本,功能上应该做的是同一件事:把图片和对应标签按比例切成 train / val / test 三份。这类脚本我自己也写过很多遍,核心就三块:收集文件名、固定随机种子做shuffle、按比例复制或移动文件。下面是一个针对YOLO目录结构的最小版本:

import os import random import shutil SRC_IMAGES = "images" # 原始图片目录 SRC_LABELS = "labels" # 对应YOLO标签目录 OUT = "split" # 输出根目录 RATIO = [0.7, 0.2, 0.1] # train / val / test SEED = 42 names = [os.path.splitext(f)[0] for f in os.listdir(SRC_IMAGES)] random.Random(SEED).shuffle(names) train_n = int(len(names) * RATIO[0]) val_n = int(len(names) * RATIO[1]) parts = { "train": names[:train_n], "val": names[train_n: train_n + val_n], "test": names[train_n + val_n:], } for part, items in parts.items(): img_out = os.path.join(OUT, part, "images") lbl_out = os.path.join(OUT, part, "labels") os.makedirs(img_out, exist_ok=True) os.makedirs(lbl_out, exist_ok=True) for name in items: shutil.copy(os.path.join(SRC_IMAGES, name + ".jpg"), img_out) shutil.copy(os.path.join(SRC_LABELS, name + ".txt"), lbl_out)

逻辑说明:先把所有图片文件名取出,按固定seed打乱,再按比例切三段。参数说明:SEED = 42保证每次运行结果一致,方便复现,如果你上次实验跑得好,想完全还原划分,这个seed必须保持不变;RATIO可以根据数据量调整,小数据我更建议7:2:1而不是8:1:1,因为1000张图里 val 拿200张做验证比较稳;代码里shutil.copy是复制不是移动,原图保留一份,防止划分出错后没法找回。扩展名.jpg要实际看一下你们的图片是jpg还是png,改成一致即可。

4.2 比例与分组:小数据集优先按场景切分

随机shuffle在图片没有序列相关性时没问题,但泄露检测的素材往往来自少量几段监控视频,同一段视频里相邻帧的背景、光照、角度几乎一样。如果只按文件名随机切,同一段视频的相似帧会同时出现在train和val里,val指标会虚高,模型到了陌生场景马上现原形。针对这种情况,我建议按场景分组后再划分,而不是按单张图。

操作方法:每个文件名去掉末尾的帧编号前缀,保留场景标识,例如site3_00231.jpg的前缀site3就是场景ID。先对场景ID列表做shuffle,再把同一个场景的所有帧划到同一分区。这样val里出现的是整段没见过的场景,mAP会更接近真实水平。分组划分会让val损失一些「同分布」优势,但换来的是对泛化能力更诚实的估计,安全检测项目值得这么干。

4.3 YOLO最小训练命令:gas.yaml与五个必调参数

数据划分完,下一步是组织YOLO训练。首先要写一个数据配置文件,YOLOv8的格式如下:

path: /absolute/path/to/split train: train/images val: val/images test: test/images nc: 1 names: ['gas_leak']

path建议写绝对路径,因为有些训练脚本在相对路径下会找不到数据集;nc是类别数,单类就写1;names的列表顺序必须和标签txt里的第一列ID一一对应。配置写好后,训练命令就是一行:

yolo detect train \ model=yolov8s.pt \ data=gas.yaml \ epochs=120 \ imgsz=640 \ batch=16 \ patience=30 \ close_mosaic=10 \ project=runs \ name=gas_leak_s

参数说明:model=yolov8s.pt是带COCO预训练权重的s模型,1000张图的小数据集从s起步最稳,n模型更快但对小目标特征提取弱一点;epochs=120对小数据足够了,配patience=30做早停,通常70到90轮就会收敛;imgsz=640是默认输入尺寸,如果泄露区域普遍很小,建议改imgsz=960,代价是显存和训练时间增加一半左右;batch=16在12GB以上显存能跑,8GB显存改成8;close_mosaic=10表示最后10轮关闭mosaic增强,让模型回归真实分布。这里特别说明下,数据集里的训练教程如果是基于旧一点的流程,结构也是这四个必调项:模型尺寸、输入尺寸、批次、早停耐心值,照着这个思路调就不会跑偏。

4.4 训练产物的快速检查

训练结束看目录里产出的文件,重点看三样东西。results.png里有loss曲线和mAP曲线,如果mAP50-95一直横盘不动,说明模型容量或数据有问题,不是epochs不够。confusion_matrix.png能看到背景被误判成目标的量,这会直接影响误报率。val_batch_pred.jpg最直观,直接展示模型在一批验证图上的预测框,我会专门看小目标有没有被框住、框有没有明显偏移。best.pt 和 last.pt 两个权重文件要用best来推理,last是最后一轮的断点,不是最优结果。

训练完先别急着部署,把best.pt拿到测试目录上跑一轮预测,用yolo predict model=runs/gas_leak_s/weights/best.pt source=split/test/images save_txt=True生成所有测试图的预测txt,再人工数一遍漏检和误检。这一步相当于给模型做一次小考,通过后再谈现场。

5. 避坑:汽油泄露检测训练最常翻车的五个点

5.1 沥青地面的黑色斑块被误检成泄露

现象:白天测试时,模型把沥青路面上的裂缝、深色污渍、积水阴影都框出来了,置信度还不低。原因:正样本里泄露区域的油渍也是深色块,模型学到的区分特征是「深色区域」而不是「油渍质感」,负样本不足导致决策边界太宽。解决:做一轮难例挖掘,把验证阶段误检的bbox截图,直接从原图crop出来作为背景图加入训练目录,标签文件留空。这样模型能看到更多「像油渍但不是油渍」的样本,误报会明显下降。如果加了背景图还压不住,就把后处理置信度阈值从默认0.25调高到0.4再测,但阈值只能缓解,根治还是要靠负样本。

5.2 白天收敛很好,夜间几乎全挂

现象:白天画面的mAP50有0.9以上,傍晚和夜间现场推理时漏检大量增加,暗光区域还会冒出奇怪误报。原因:训练数据的光照分布高度集中在白天,低照度样本占比太低,模型没学到暗光下的目标外观;夜间图像噪声模式也和白天完全不同,这些差异是亮度增强模拟不出来的。解决:优先补采夜间和黄昏数据,哪怕只有一两百张做微调都比纯增强强;如果补采条件受限,用亮度对比度扰动、gamma变换模拟低照度,把白天图压暗作为增强项。只要是真实安全项目,夜间数据这一关绕不过去,越早规划越好。

5.3 小目标漏检:泄露区域只有十几个像素

现象:管线刚出现渗漏时,油渍往往只是画面里一块几十像素的暗斑,模型直接忽略;同一个目标在白天大图上又能框住。原因:输入640分辨率经过多次下采样后,小目标的特征只剩几个像素,信息基本丢失;训练时小目标框参与loss计算的权重也偏低。解决:把imgsz提到960或1280;如果显存撑不住,就用切片推理思路,把输入图按重叠区域切块分别检测再合并,常见做法是用SAHI这类工具,也可自己在外围写个滑窗逻辑;另一个很有效的办法是在标注时把小目标周围的上下文轮廓一起框进去,让模型学到「细长暗痕」这类形态特征。

5.4 翻转增强把液体重力方向改了

现象:开启水平翻转和垂直翻转增强后,验证mAP不升反降,尤其对罐壁、斜坡这类有明确方向性的画面。原因:垂直翻转会让液体流向往天上流,模型学到的是与物理方向矛盾的外观先验;水平翻转对部分场景同样有语义干扰,比如从左侧流向右的细长油痕翻转后和真实分布相反。解决:这类安全检测场景,翻转增强要谨慎。我一般只保留轻微随机尺度、亮度、饱和度扰动,关闭水平和垂直翻转;mosaic增强也降到很低权重或只在早期开几轮,因为多图拼接会让液体纹理形态变得不真实。工业数据集小而专,别盲目照搬通用检测那套大增强配置。

5.5 mAP很高但现场没有参考价值

现象:val的mAP50高达0.95,一上线到现场连续误报,被现场人员吐槽成「空气检测器」。原因:最典型的是数据泄漏,训练集和val/test来自同一段视频帧序列,模型等于开卷考试;另一个原因是验证图像和真实现场存在域差,比如相机安装高度、角度、画幅不同。解决:用第4章的方法按场景分组重新划分,让val完全由模型没见过的场景组成;再拿一段真实现场视频做外推验证,统计误报数。如果重训后mAP明显下降,别慌,那才是模型真实水平的底;在现场把硬件位置固定后,尽量采集与部署视角一致的数据再迭代一轮。

6. 交付前验证:mAP之外三个指标与现场外推

模型训练完,先别急着写「效果很好」的总结,泄露检测这类安全场景,验收方真正关心的是误报和漏报,不是mAP分数。mAP是模型之间的对比指标,现场看的是行为指标,我会在最后阶段固定测三类数值:召回率、每小时误报次数、单帧延迟。

指标度量方式关注点
召回率在困难样本集上数漏检框泄露检测宁可误报,不能漏报
每小时误报连续跑一段现场视频,统计误报框折算现场人员容忍度有限,误报多会关系统
单帧延迟包含预处理与后处理的单帧推理耗时决定能不能接实时视频流

外推验证的具体做法:找一段从来没进过训练集的监控视频,直接跑yolo predict model=best.pt source=never_seen.mp4,逐帧过一遍,把误报帧数和漏检帧数记下来。我一般会拉上现场同事一起看预测结果,因为只有他们知道画面里哪些区域是正常的油污、哪些是真实的危险泄露。

如果条件允许,再做一次小规模现场mock:在可操作的空地上用少量机油和清水各倒一小片,用部署相机拍几十帧,作为从未参与训练的真实样本。机油在光照下会有虹彩干涉色,这是和清水、沥青阴影的重要视觉差异,模型如果能从这片小样里框出机油而对清水无感,才说明它学到了油渍的本质特征,而不是单纯在找暗色区域。做mock实验时注意安全和环保规范,少量液体在可控容器内操作即可,不要直接倒在下水道附近。

最后调阈值时,我会按F1曲线选一个偏低的置信度,比如0.15到0.25之间,保证召回率优先;同时后端加帧级判定,连续三五帧命中同一个区域才触发报警,能有效削掉单帧抖动造成的误报。这样阈值可以放低,最终用户看到的误报次数还是可控的。有一点我一直记得:之前有个项目白天指标漂亮,跑夜间现场才发现训练集的夜间图片占比不到3%,后来把时段划分、场景分组和现场mock写成了固定流程,每次交付前先跑这三步验证再谈上线。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进