ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

厨房积水检测实战:Pascal VOC与YOLO双格式标注转换及YOLOv8训练指南

2026/9/11 23:14:11 拓冰建站 浏览量
厨房积水检测实战:Pascal VOC与YOLO双格式标注转换及YOLOv8训练指南 简介这是一份面向计算机视觉目标检测任务的厨房积水检测数据集涵盖88张真实厨房场景图片共标注562个目标框其中积水water290个、泡沫foam272个适用于训练YOLO、Faster R-CNN等主流检测模型。资源采用Pascal VOC与YOLO双格式存储每个样本均包含jpg原图、xml标注文件和txt标注文件并附有labelImg标注工具配置可方便地直接接入常见训练框架。整个压缩包共268个文件、约18.23MB以jpg、xml、txt为主要文件类型体积轻量、目录清晰便于下载后快速完成数据划分与格式转换。目前已有161人学习使用适合入门级开发者进行目标检测实战练习也可作为厨房安全监控场景中积水与泡沫识别任务的验证数据。1. 厨房积水检测先跟标注口径较劲我最早接触厨房积水检测这个任务时被“水”这个目标搞得非常恼火水不像人、车、猫狗那样有固定轮廓它在不同光线下可能是透明反光区可能是大滩白色也可能只是地面颜色略深的一小条。泡沫更麻烦白色瓷砖上一团白色泡沫对比度极低初学者经常把泡沫边缘多框一两个像素导致后续模型训练时正样本边界很脏。这份厨房积水检测数据集只有 88 张 JPG2 个类别foam 和 water共 562 个目标框但每张图都同时给出了 Pascal VOC 的 xml 和 YOLO 的 txt 标注类别分布也相对均匀foam 272 框、water 290 框。对做目标检测的从业者来说它不是一个能直接用来刷精度的成熟大库而是一个能快速验证标注流程、坐标转换和训练管线的绝佳实验场。下面从双格式标注结构拆起把“拿到手之后怎么查、怎么训、怎么迭代”讲透。2. 解剖 VOC 与 YOLO 双格式目录组织与坐标换算这份数据的最直接价值是同一张图同时存在三种文件firc_kichen_10.jpg、firc_kichen_10.xml、firc_kichen_10.txt。jpg 是原始图像xml 是 labelImg 默认保存的 Pascal VOC 标注txt 是 YOLO 训练直接读取的标签。三者放在同一目录下文件名前缀全部一致只是扩展名不同。这里注意文件名里的firc_kichen是原作者的命名习惯不影响使用但它在清洗数据时提醒了一件事不要用文件名里的语义字段做什么判断比如kichen不是kitchen如果脚本里写死了关键字很容易匹配不到。find . -name *.jpg | wc -l find . -name *.xml | wc -l find . -name *.txt | wc -l三行命令分别统计三类文件数量。正常情况下输出都是 88如果出现wc -l返回值不一致说明标注文件存在缺失或重复命名。实操中我第一次跑的时候发现 xml 比 jpg 多一个原因是 Windows 系统下解压产生了desktop.ini和隐藏副本所以建议在 Linux 环境下统计或者在 Windows 下先关闭“隐藏已知文件类型的扩展名”。这是最常被忽略的数据集卫生问题。2.1 labelImg 的 VOC 标注字段解析打开任意一个 xml 文件能看到典型的 Pascal VOC 结构。size记录图片宽度、高度和通道数object是每个目标的主体包含类别名name和一个bndbox子节点。最关键的就是 bndbox 中的xmin、ymin、xmax、ymax四个像素坐标值。labelImg 画矩形框时按住鼠标左键从目标左上角拖到右下角释放后这四个值就被固定下来。一个需要注意的细节是XML 里的坐标是整数像素如果画框时目标边缘带有半透明反光labelImg 的最终坐标取决于你松手的位置而不是目标的实际物理边界。这也是所有目标检测标注共有的主观性来源。annotation folder./folder filenamefirc_kichen_10.jpg/filename size width640/width height480/height depth3/depth /size object namewater/name bndbox xmin120/xmin ymin80/ymin xmax300/xmax ymax220/ymax /bndbox /object /annotation上面的 xml 是典型格式只保留了本次任务关注的字段。name的取值必须严格和类别清单一致要么foam要么water如果出现Water或者water这类带空格、大小写不一致的值YOLO 训练时类别映射会整体错位。检查这类问题不能靠肉眼我一般写一个脚本直接枚举name去重输出所有出现过的类别名再和预期集合比对。2.2 从 bndbox 到 YOLO 归一化坐标YOLO 训练不读 xml它要求 txt 中每行对应一个目标格式为class_id center_x center_y width height其中center_x、center_y、width、height全部归一化到 0 到 1 区间除以图片的宽度和高度。这段换算逻辑和 KITTI 标注转 YOLO 的思路完全一样只是 KITTI 还多了一个截断和遮挡的字段需要处理而这里的矩形框标注只有四个坐标转换要简单得多。VOC bndbox 字段YOLO txt 字段计算公式xmin, xmaxcenter_x(xmin xmax) / 2 / widthymin, ymaxcenter_y(ymin ymax) / 2 / heightxmin, xmaxwidth(xmax - xmin) / widthymin, ymaxheight(ymax - ymin) / height训练时模型预测的也是归一化坐标输出后乘以图片宽高才能还原成像素框。需要注意这里的width是图片的像素宽度不是 bndbox 的宽度很多人第一次写转换脚本时会把两者混淆。判断一个 txt 是否合理可以随机挑几行把归一化的中心点乘回图片宽高看它是否落在对应物体的中心位置。2.3 用脚本验证 XML 与 TXT 是否真的对应既然数据集本身宣称 xml 和 txt 标注一致我会用脚本做一次“主动验证”。核心逻辑是读取每一个 xml按照第 2.2 节的公式算出归一化坐标再读取同文件名的 txt比较同一目标是否能在容差范围内匹配上。import glob import xml.etree.ElementTree as ET xml_files glob.glob(*.xml) class_map {foam: 0, water: 1} for xml_file in xml_files: tree ET.parse(xml_file) root tree.getroot() size root.find(size) w_img int(size.find(width).text) h_img int(size.find(height).text) txt_file xml_file.replace(.xml, .txt) txt_lines [] with open(txt_file) as f: for line in f: parts line.strip().split() if len(parts) 5: txt_lines.append(tuple(map(float, parts))) print(f {xml_file} ) for obj in root.findall(object): name obj.find(name).text if name not in class_map: print( [UNKNOWN CLASS], name) continue box obj.find(bndbox) xmin int(float(box.find(xmin).text)) ymin int(float(box.find(ymin).text)) xmax int(float(box.find(xmax).text)) ymax int(float(box.find(ymax).text)) cx ((xmin xmax) / 2) / w_img cy ((ymin ymax) / 2) / h_img bw (xmax - xmin) / w_img bh (ymax - ymin) / h_img # 查找与当前目标 IoU 大于 0.9 的 yolo 标注行 matched False for line in txt_lines: iou min(cx bw / 2, line[1] line[3] / 2) - max(cx - bw / 2, line[1] - line[3] / 2) if iou 0.0: matched True break print(f {name} - matched{matched})这段代码的核心不是计算精确 IoU而是快速寻找是否存在一个 YOLO 框和 XML 框的中心接近。如果matchedFalse说明要么 txt 漏标要么两类标注顺序不一致。实际跑下来这份数据集的 xml 和 txt 对应关系是好的但如果你从网上下载到类似的数据集这一关必须过。我曾经遇到一个项目里 50 张图的 txt 全部是旧标注重新标注后只导出了 xml忘记同步导出 txt导致训练时模型 loss 下降但 mAP 始终是 0问题就出在这里。3. 训练前质量门禁校验 YOLO 标签的越界与错位拿到数据集后不经验证直接训练是很多人的习惯但小样本数据集容错率极低。88 张图里如果有一张图的标签错位相当于引入了一个较大的噪声样本。这一章我会给出一个完整的质量门禁脚本覆盖图片可读性、txt 行格式、类别 ID 和边界越界这几类最常见问题。3.1 图片读取与 txt 格式校验YOLO 的 txt 有个硬性要求每行 5 个值第一个值是类别 ID后四个值是浮点数并且浮点数必须在 0 到 1 之间允许边界值。如果类别 ID 写成了字符串或者坐标里出现了负值、大于 1 的值ultralytics 仓库在训练时会抛出异常但有些基于 Darknet 的老版本训练框架不会报错而是把异常值当作损坏样本跳过导致模型缺训一类目标。import os import glob import cv2 class_names [foam, water] num_classes len(class_names) img_files glob.glob(*.jpg) for img_path in img_files: img cv2.imread(img_path) if img is None: print([FAIL] unreadable:, img_path) continue h, w img.shape[:2] txt_path img_path.replace(.jpg, .txt) if not os.path.exists(txt_path): print([FAIL] missing txt:, txt_path) continue with open(txt_path) as f: for line_no, line in enumerate(f, 1): parts line.strip().split() if len(parts) ! 5: print(f[FAIL] bad line {txt_path}:{line_no}: {line.strip()}) continue cls_id int(parts[0]) cx, cy, bw, bh map(float, parts[1:]) if cls_id 0 or cls_id num_classes: print(f[FAIL] class id out of range {txt_path}:{line_no}: {cls_id}) if any(v 0.0 or v 1.0 for v in (cx, cy, bw, bh)): print(f[WARN] value out of [0,1] {txt_path}:{line_no}: {parts[1:]}) # 反算像素坐标给 1 像素容差 xmin (cx - bw / 2) * w xmax (cx bw / 2) * w ymin (cy - bh / 2) * h ymax (cy bh / 2) * h if xmin -1 or ymin -1 or xmax w 1 or ymax h 1: print(f[WARN] bbox out of image {txt_path}:{line_no}: f({xmin:.1f},{ymin:.1f},{xmax:.1f},{ymax:.1f}))这段脚本里[FAIL]是我认为必须修复的问题[WARN]是建议人工确认的问题。越界框不一定总是错误当目标贴着图片边缘时labelImg 画出来的框可能刚好卡在边界反算后会出现 1 到 2 像素的越界这是允许的。真正危险的是 xmin 远小于 0 或 xmax 远大于图片宽度说明标注时图片分辨率与 txt 生成时的分辨率不一致。运行完脚本后我还会把 img_files 的数量和已有的 txt 文件数量做一次差集确保没有孤儿文件。这个步骤放在任何 YOLO 训练数据标记之前都不过分。3.2 分类别框数统计与边界情况作者已经在数据集说明里给出了统计foam 272 框、water 290 框总框数 562。但拿到本地后我依然会自己统计一遍因为网上传文件经常出现有人手动增删文件后统计信息失效的情况。统计方式很简单累加每个 txt 的行数并按类别 ID 分组。在这个数据集里0对应foam1对应water类别 ID 顺序必须和后面训练用的data.yaml保持一致。问题类型典型症状定位方式类别 ID 错位训练 loss 不降验证集识别结果全乱统计 txt 中类别 ID 的最大值是否小于类别数框宽高为零模型回归 loss 出现 NaN检查 txt 中 width/height 是否为 0图片与标签缺失训练中报 FileNotFoundError 或跳过样本对比 jpg/xml/txt 三个集合的差集标注文件混用部分 txt 是旧版本目标位置明显偏用第 2.3 节的脚本做 xml 与 txt 对比表格里的四类问题在这个数据集上我实际遇到了第一类foam和water在 xml 里的顺序不是固定的但转换后的 txt 中类别 ID 必须和 data.yaml 一致。如果有人在 labelImg 中把类别列表定义成[water, foam]那么同一个目标转换出的 ID 就会相反训练出来的模型会把水和泡沫混淆。这个数据集的原作者用了 labelImg 的标准流程默认类别顺序是[foam, water]所以要充分信任目录中的 txt而不是自己从 xml 重新转换后覆盖它。3.3 重叠标注是问题还是资产厨房积水场景里泡沫常常漂在水面上所以一个较大的 water 框内部会套着几个 foam 框。从检测任务角度这不是标注错误而是真实场景中的目标层级关系。YOLO 的边界框回归是独立预测每个目标的训练时每个 ground truth 框都会匹配一个预测分支所以框内有框并不会互相干扰。真正的风险在于人工标注时把“泡沫聚集区”和“泡沫下积水区”的边界画混导致一个目标同时有两种类别。检查这类问题最直接的方式是把所有标签画在图片上看重叠框的类别是否明显矛盾。我在实际项目里会用 OpenCV 画框并保存为新的 jpg然后快速翻看这 88 张图比任何统计指标都直观。import cv2 import glob for img_path in glob.glob(*.jpg): img cv2.imread(img_path) h, w img.shape[:2] txt_path img_path.replace(.jpg, .txt) with open(txt_path) as f: for line in f: cls_id, cx, cy, bw, bh line.strip().split() cx, cy, bw, bh map(float, (cx, cy, bw, bh)) xmin int((cx - bw / 2) * w) ymin int((cy - bh / 2) * h) xmax int((cx bw / 2) * w) ymax int((cy bh / 2) * h) color (255, 0, 0) if cls_id 0 else (0, 255, 255) cv2.rectangle(img, (xmin, ymin), (xmax, ymax), color, 2) cv2.putText(img, foam if cls_id 0 else water, (xmin, max(0, ymin - 4)), cv2.FONT_HERSHEY_SIMPLEX, 0.5, color, 1) cv2.imwrite(viz/ img_path.replace(.jpg, _viz.jpg), img)这段代码把所有标注框可视化输出到viz目录。蓝色是 foam黄色是 water。翻看时重点看两类框的重叠边界如果发现某个 foam 框的四条边完全贴住 water 框的四条边说明标注者在两者之间做了错误复制需要手动修正。这个检查在只有 88 张图时成本很低但对后面的训练质量影响很大。4. 用 YOLOv8 训练自己的数据集目录划分、data.yaml 与参数预设数据集本身没有划分 train/val所以要先自己组织目录再写 data.yaml最后开始训练。这一章按 yolov8 训练自己的数据集的标准流程走同时给出针对 88 张小样本的工程化建议。4.1 构建训练目录并划分样本YOLO 训练目录的标准结构是 images 和 labels 两个根目录各自下面再分 train 和 val。images 里放 jpglabels 里放同文件名的 txt。很多人在这一步搞混把 txt 和 jpg 放在同一个目录下让 YOLO 自动找这在旧版 Darknet 里可以但 ultralytics 的新接口期望严格的目录分离。划分样本时我一般按 80/20 分割也就是 88 张图中约 70 张训练、18 张验证并且设置随机种子保证可复现。import os import shutil import random import glob random.seed(42) img_files glob.glob(*.jpg) random.shuffle(img_files) split_idx int(len(img_files) * 0.8) train_files img_files[:split_idx] val_files img_files[split_idx:] for split, files in [(train, train_files), (val, val_files)]: os.makedirs(fdataset/images/{split}, exist_okTrue) os.makedirs(fdataset/labels/{split}, exist_okTrue) for img_path in files: shutil.copy(img_path, fdataset/images/{split}/{os.path.basename(img_path)}) txt_path img_path.replace(.jpg, .txt) if os.path.exists(txt_path): shutil.copy(txt_path, fdataset/labels/{split}/{os.path.basename(txt_path)})这里的 0.8 比例是经验值88 张图如果验证集占比太大训练集只有 60 张左右模型很容易欠拟合如果验证集太小又不能反映真实指标。我选择 80/20 是为了让损失曲线相对稳定。random.seed(42)保证每次运行脚本得到相同的划分结果这对复现实验很重要。实际拿到这份数据集时最好先看看 88 张图中是否包含同一个场景的连续帧如果同一场景在训练和验证中都出现过验证指标会虚高需要按视频片段而不是按单张图片划分。这个数据集没有提供视频片段信息所以只能依靠随机划分并在后期用真实场景验证泛化能力。4.2 data.yaml 与类别映射在 dataset 目录下创建data.yaml这个文件告诉 YOLO 训练图片路径、标签路径和类别名。路径建议使用相对路径并把path指向你的数据集根目录这样整个数据集可以整体移动而不需要改配置。path: /home/user/kitchen_dataset train: images/train val: images/val names: 0: foam 1: waternames的索引顺序必须和 txt 中的类别 ID 严格对应。这里0对应泡沫1对应积水。如果 txt 里第一个数字是1代表泡沫而不是水模型会在蒸馏阶段学到完全错误的类别语义。注意 YAML 中names的书写有 key 和类别名的映射也可以写成names: [foam, water]但显式写出索引更不容易出错。验证类别映射最直接的方式是用 YOLO 提供的回调函数绘制一张图片看预测框的类别文本是否正确但训练前验证的方法是读取任意一个 txt 的第一行数字手动在 labelImg 中确认该目标类别和 names 列表比对。4.3 小样本训练参数预设完成目录和配置文件后直接运行训练命令。这里我选择yolov8n.pt作为起点因为我们面对的是 88 张图、562 个框模型容量必须小。如果一开始就用 YOLOv8m 或 YOLOv8l即使加载 COCO 预训练权重也很容易在几十轮内把标注噪声和背景纹理一起拟合进去。COCO2017 数据集结构里没有厨房积水类预训练权重提供的价值是底层边缘、纹理和反光特征所以迁移学习对小数据集是有效的但冻结骨干网络反而会限制针对积水这种无定形目标的定制特征学习我不建议冻结任何层。yolo detect train \ datakitchen_dataset/data.yaml \ modelyolov8n.pt \ epochs200 \ imgsz640 \ batch16 \ patience30 \ lr00.005 \ seed42这个数据集是厨房场景目标相对大imgsz640已经足够再大不会带来明显收益反而增加过拟合风险。batch16取决于显存如果你的显卡只有 8GB可以降到 8如果显存充足16 是均衡值。patience30表示验证集 mAP 连续 30 轮不提升就提前停止这比硬跑 200 轮更老实。重点在lr00.005这是比默认值更低的初始学习率因为小样本下梯度的方向不稳定太大的学习率会让损失条在早期震荡。实际训练起来我建议每 20 个 epoch 查看一次runs/detect/train/results.png如果 mAP50 在 60 轮后还在上涨但 mAP50-95 停滞说明模型在更高 IoU 阈值下定位不准可能和泡沫边界主观性强有关。yolo detect train \ datakitchen_dataset/data.yaml \ modelyolov8n.pt \ epochs100 \ imgsz640 \ batch16 \ patience20 \ lr00.003上面的第二个命令对应我常做的超参检查把学习率调低、轮数压缩对比验证集 loss 和 mAP 变化用来判断 200 轮训练是否真的有必要。如果两个命令的结果差异不大说明 100 轮已经足够继续加训练轮数只是浪费。这个技巧在小数据集上特别有用能快速排除“训练不足”这个干扰变量。需要强调这个数据集不附带模型权重原作者也声明不作精度保证所以训练结果只能作为场景下限参考真正的应用验证必须用你自己拍的厨房地面照片。5. 用推理结果反查标注把误检变成训练样本训练结束后很多人看一眼 mAP 就结束了这是浪费。更好的做法是用验证集跑一遍推理保存预测结果然后逐张对比标注和预测之间的差异。这一步能把小数据集里最容易出现的“标注者盲区”暴露出来比如地砖反光被漏标成水或者细小泡沫被标成噪声。yolo detect predict \ modelruns/detect/train/weights/best.pt \ sourcekitchen_dataset/images/val \ save_txtTrue \ save_confTrue \ conf0.25save_txtTrue会把每个预测结果写到runs/detect/predict/labels/下save_confTrue在每行末尾追加置信度。拿到预测文件后我会写一个脚本把预测框和 ground truth 框做空间匹配找出那些预测置信度在 0.3 到 0.7 之间、却没有对应 GT 的框这些通常不是误检而是标注时被遗漏的真实目标。把这些图片重新用 labelImg 打开补上漏标的水或泡沫框然后合并回训练集。处理水迹反光时我常用的技巧是降低conf到 0.1让模型输出更多候选框再按置信度排序去检查。泡沫区域往往呈现高光白色而水区更接近灰色透明两者在 BGR 色彩空间中的分布差异并不大所以模型经常把这两类混淆。这时我会在补标注后额外增加几个“难例图片”用手机在同一厨房不同时间拍十几张图用训练好的模型预测只把预测不稳定的图挑出来标注。这种方式比单纯跑数据增强更有效因为它是从真实采样分布中挖掘最有信息量的样本。最后一个小技巧用验证集预测的conf得分来反向检查标注一致性。如果同一个 water 目标在不同图片中置信度忽高忽低大概率是该目标在部分图片里的标注框偏大或偏小。把这个不一致找出来统一标注口径比多训练 100 轮带来的提升更明显。我通常会随机抽 5 张验证集图片把预测框的边缘和人工标注框画在同一张图上肉眼比对边缘差几个像素。若差异大于 10%说明有人画框时把边缘的水花也框进去了这个统一修订动作完成后再把这批新数据按第 4 节的划分逻辑并回训练集重新训练一轮。本文还有配套的精品资源点击获取