溺水检测YOLO实战:VOC转YOLO格式与895张数据集训练全流程

发布时间:2026/10/11 8:30:08
溺水检测YOLO实战:VOC转YOLO格式与895张数据集训练全流程
简介该资源为游泳者与溺水行为检测数据集共含895张从30段视频中截取并标注好的jpg图片采用VOC与YOLO双格式提供覆盖swimmer、drowning两个类别总标注框数1530个适合水域安全监控、游泳姿态识别及溺水预警等计算机视觉项目的训练与验证。资源包共2000个文件其中包含895个xml标注文件、895个txt标签文件及208个jpg图片文件压缩包整体约315.71MBxml与txt标注文件均按同名图片一一对应可便捷接入LabelImg、YOLO系列等常用训练流程。需要留意的是drowning类别框数仅97个样本比例不均衡且溺水状态本身存在主观性下载后建议结合真实场景重新校正标签。目前该资源已有1897人学习下载适合目标检测方向的研究者、算法工程师及高校学生用作实验数据补充尤其便于开展小样本类别增强与误检边界分析。1. 溺水检测数据集为什么值得自己做一遍2类别895张的真实边界拿到一份“游泳者溺水数据集VOCYOLO格式2类别895张”时我第一反应不是急着找现成模型而是先算这笔账895张图片堆不出一个大网络但能把从标注理解到训练验证的整条流水线走通。这个数据集的特别之处在于它同时给出pascal voc的xml标注和yolo的txt标注类别固定在“游泳者/溺水者”两个目标上正好卡在真实业务最关心的边界。适合泳池监控、河道巡检、边缘安全盒子的算法工程师去试手。对刚做完yolo入门的人来说这也是少见的能一条龙练格式转换、类别统计、训练调参、部署导出的题目。很多人拿到数据先复制到一个文件夹就开train结果不是目录错就是类别名错训练卡一晚上。真正能落地的做法是先把数据“吃透”VOC和YOLO两种格式之间不是一次转换那么简单还涉及类别顺序、尺寸一致性和训练验证划分。下文就把从VOC转YOLO、训练、避坑到验证的完整路径铺开尽量把过程中容易翻车的地方写清楚。2. 把VOC格式转成YOLO格式目录结构、XML解析与895张样本的转换脚本2.1 先认清两类格式的差异pascal voc的xml和yolo txt到底差在哪pascal voc格式继承自早期目标检测竞赛图片放在JPEGImages标注放在Annotations每个xml对应一张图片。xml里除了filename、size宽、高、深度还有若干object节点每个object包含name标签和bndbox的xmin、ymin、xmax、ymax这些是像素绝对坐标。yolo训练需要的txt则简单很多每行是“class_id x_center y_center width height”其中坐标全部除以图片宽高做归一化四个值都是0到1之间的小数。2类别就代表class_id只取0或1顺序必须和训练配置里的names列表一致。这个差异带来的常见事故是手工写txt时忘了归一化或者把xmin/ymin/xmax/ymax直接当成中心点和宽高导致训练时目标框全跑到图像外。另一个坑是voc格式的object里还有difficult和truncated字段如果不管difficult1的难例直接转换模型就会被那些标注特别不清晰的框干扰。所以转换脚本里建议保留一个开关遇到difficult对象可以选择跳过。这一步不是可有可无895张图里往往有几张是极端角度过滤掉比保留更能稳定loss。2.2 最小可用转换脚本xml_to_yolo.py下面给一个我在本地常用最小脚本兼容VOC格式并支持自定义类别顺序。代码用python编写依赖只有xml.etree.ElementTree和pathlib不需要额外安装包。#!/usr/bin/env python3 # xml_to_yolo.py # 用法: python xml_to_yolo.py --xml_dir Annotations --txt_dir labels --classes swimmer,drowning import argparse import xml.etree.ElementTree as ET from pathlib import Path def voc_to_yolo(xml_path, txt_path, classes): tree ET.parse(xml_path) root tree.getroot() width int(root.find(size/width).text) height int(root.find(size/height).text) if width 0 or height 0: print(f跳过 {xml_path}: 图片尺寸无效) return False lines [] for obj in root.findall(object): name obj.find(name).text if name not in classes: continue # 只保留指定类别 difficult int(obj.find(difficult).text) if obj.find(difficult) is not None else 0 if difficult 1: continue # 跳过difficult难例 box obj.find(bndbox) xmin float(box.find(xmin).text) ymin float(box.find(ymin).text) xmax float(box.find(xmax).text) ymax float(box.find(ymax).text) if xmax xmin or ymax ymin: print(f警告: {xml_path} 中存在无效框) continue x_center (xmin xmax) / 2.0 / width y_center (ymin ymax) / 2.0 / height w (xmax - xmin) / width h (ymax - ymin) / height cls_id classes.index(name) lines.append(f{cls_id} {x_center:.6f} {y_center:.6f} {w:.6f} {h:.6f}) if lines: txt_path.write_text(\n.join(lines) \n, encodingutf-8) return True return False def main(): parser argparse.ArgumentParser(descriptionVOC XML - YOLO txt) parser.add_argument(--xml_dir, typestr, defaultAnnotations) parser.add_argument(--txt_dir, typestr, defaultlabels) parser.add_argument(--classes, typestr, defaultswimmer,drowning, help两个类别名逗号分隔顺序即class_id) args parser.parse_args() classes [c.strip() for c in args.classes.split(,)] xml_dir Path(args.xml_dir) txt_dir Path(args.txt_dir) txt_dir.mkdir(parentsTrue, exist_okTrue) count 0 for xml_file in sorted(xml_dir.glob(*.xml)): txt_file txt_dir / (xml_file.stem .txt) if voc_to_yolo(xml_file, txt_file, classes): count 1 print(f完成: 成功转换 {count}/{len(list(xml_dir.glob(*.xml)))} 个xml) if __name__ __main__: main()这个脚本的要点是类别顺序由--classes参数显式指定而不是按xml里出现的顺序动态编号。因为yolo训练时data.yaml里的names顺序定了就不允许再改如果直接用set去重可能这次跑出swimmer0、drowning1下次顺序相反训练后推理时类别名就对不上了。xml里宽高从size节点读如果xml里没有size脚本会跳过避免生成0中心的标签这是不少转换工具翻车的原因。还有一个容易被忽略的点脚本里对difficult1的目标直接continue。实际数据集如果没有difficult字段用int(obj.find(difficult).text)会抛异常所以先判断再取值这在处理不完全标准的VOC标注时很常见。转换完成后不要急着删xml保留一份原始数据后面训练遇到异常还能回头核对。这是给人留后悔药的习惯。2.3 用可视化脚本检查转换结果画框验证是否对得上转换完不能只看txt生成了多少行要随机抽几十张图把yolo框画回原图肉眼看有没有错位、翻转、漏框。这里我给一个基于opencv-python的检查脚本按yolo数据集的习惯把图片和txt一一对应。# check_yolo_labels.py # 用法: python check_yolo_labels.py --img_dir images --label_dir labels --classes swimmer,drowning --sample 30 import argparse import cv2 from pathlib import Path def draw_yolo_box(img_path, txt_path, classes): img cv2.imread(str(img_path)) if img is None: print(f无法读取图片: {img_path}) return h, w img.shape[:2] with open(txt_path, r, encodingutf-8) as f: for line in f: parts line.strip().split() if len(parts) ! 5: continue cls, xc, yc, bw, bh int(parts[0]), float(parts[1]), float(parts[2]), float(parts[3]), float(parts[4]) x1 int((xc - bw / 2) * w) y1 int((yc - bh / 2) * h) x2 int((xc bw / 2) * w) y2 int((yc bh / 2) * h) color (0, 0, 255) if cls 1 else (0, 255, 0) cv2.rectangle(img, (x1, y1), (x2, y2), color, 2) cv2.putText(img, classes[cls], (x1, max(0, y1 - 5)), cv2.FONT_HERSHEY_SIMPLEX, 0.6, color, 2) cv2.imshow(check, cv2.resize(img, (960, 540))) cv2.waitKey(0) cv2.destroyAllWindows() def main(): parser argparse.ArgumentParser() parser.add_argument(--img_dir, typestr, defaultimages) parser.add_argument(--label_dir, typestr, defaultlabels) parser.add_argument(--classes, typestr, defaultswimmer,drowning) parser.add_argument(--sample, typeint, default30) args parser.parse_args() classes [c.strip() for c in args.classes.split(,)] img_dir Path(args.img_dir) label_dir Path(args.label_dir) for i, img_file in enumerate(sorted(img_dir.glob(*.jpg))): if i args.sample: break txt_file label_dir / (img_file.stem .txt) if txt_file.exists(): draw_yolo_box(img_file, txt_file, classes)画框的时候重点看两类问题一是框的边缘是否恰好在人体边界上二是游泳者和溺水者颜色分配是否符合预期。我一般会连按几十张图遇到框明显偏的回到xml核对是原标注本身就歪还是转换时把坐标算错了。这个环节花不了多少时间但能省掉后面训练两小时才发现数据有问题的后悔药。3. 游泳者/溺水者二分类训练从yolo数据集到可用的检测模型3.1 为什么要按2类别而不是1类别训练类别定义直接影响yolo损失函数刚接触yolo入门的人常有疑问目标只是“有没有溺水”为什么不把所有人都标成一类这里有个关键点溺水检测和普通行人检测不同你要判断的不是“有没有人”而是“这个人处在什么状态”。游泳者是正常姿态溺水者通常伴随挥臂、挣扎、头部下沉等特征空间上两者都是人但yolo要学的是不同类别在图像特征上的区分。如果把两类混成一个“人”类别模型能力上限就是一个人体检测器完全丧失状态判断能力2类别分类的边界框回归和分类分支同时训练在推理时才能直接输出class1的溺水目标这本身就简化了后续业务逻辑。这里顺便提一下yolo损失函数的组成分类损失针对每个anchor预测的类别得分边界框损失负责中心点和宽高的回归。895张的数据量对这么两个类来说不算宽裕所以训练阶段的类别顺序必须固定否则模型学到的类别特征会被打乱。数据不平衡更会直接压过少数类后面避坑章节会细说。现阶段先明确2类别训练是用现有数据做溺水告警时最合理的形态。3.2 划分训练/验证集随机划分与防止同帧泄漏yolo数据集的标准目录结构是把图片放在images/train、images/val标签对应放在labels/train、labels/val再写一个data.yaml指向这些目录。最省事的做法是shuffle后按比例放但如果这份895张数据里有很多是从视频片段抽出来的连续帧直接随机划分会让几乎相同的训练帧出现在验证集里得到虚高的mAP。等部署到真实水域模型会因为没见过稍大的姿态变化立刻翻车。我一般按文件名的前缀分组后再划分如果文件名以clip01、clip02开头就把同一prefix归到一个片段整个片段要么进训练要么进验证。下面的脚本演示了按分组划分# split_by_group.py # 用法: python split_by_group.py --img_dir images --label_dir labels --out_dir dataset --val_ratio 0.2 import argparse import random import shutil from pathlib import Path def main(): parser argparse.ArgumentParser() parser.add_argument(--img_dir, typestr, defaultimages) parser.add_argument(--label_dir, typestr, defaultlabels) parser.add_argument(--out_dir, typestr, defaultyolo_dataset) parser.add_argument(--val_ratio, typefloat, default0.2) args parser.parse_args() img_dir Path(args.img_dir) label_dir Path(args.label_dir) out_dir Path(args.out_dir) groups {} for img_path in sorted(img_dir.glob(*.*)): # 以文件名的前7个字符作为分组键例如 clip01_001.jpg - clip01 key img_path.stem[:7] groups.setdefault(key, []).append(img_path) train_files, val_files [], [] for key, files in groups.items(): if len(files) 2: continue if random.random() args.val_ratio: val_files.extend(files) else: train_files.extend(files) for split, files in [(train, train_files), (val, val_files)]: img_out out_dir / images / split label_out out_dir / labels / split img_out.mkdir(parentsTrue, exist_okTrue) label_out.mkdir(parentsTrue, exist_okTrue) for img_path in files: label_path label_dir / (img_path.stem .txt) if not label_path.exists(): print(f跳过没有标注的图片: {img_path.name}) continue shutil.copy(img_path, img_out / img_path.name) shutil.copy(label_path, label_out / (img_path.stem .txt)) print(ftrain: {len(train_files)}, val: {len(val_files)})这个脚本默认按文件名前7个字符作为分组key如果你的文件名不是这种规则就改成自己的分隔符。分完组后train和val的比例由val_ratio控制20%的验证集对895张来说够用文件太少时可以直接改成15%。脚本会跳过没有对应txt的图片这类孤儿样本留在训练目录里会让yolo报错处理掉更干净。接着要生成data.yaml# data.yaml path: yolo_dataset # 相对于当前工作目录的路径 train: images/train val: images/val nc: 2 names: [swimmer, drowning]data.yaml的names顺序必须与转换脚本--classes的顺序完全一致这是入门yolo时最容易踩的坑。path是包含images和labels的上级目录不是项目根目录。如果训练时提示找不到图片先检查path是不是相对当前工作目录写错了我至少见过三次这种低级事故。3.3 用YOLOv8跑一个快速基线训练命令与关键参数现在数据准备好了训练部分反而最简单。在PyCharm里装好ultralytics库就能直接跑不需要手动编译darknet这是yolo入门阶段最大的体验提升。先安装依赖pip install ultralytics然后执行训练yolo detect train data./yolo_dataset/data.yaml \ modelyolov8n.pt \ epochs120 \ imgsz640 \ batch16 \ patience30 \ projectruns/detect \ namedrowning_baseline参数里最重要的三个是imgsz、batch和patience。imgsz640是速度和精度的平衡点不要为了省显存降到320否则远距离的溺水者身体特征全部丢失。batch根据显存调12G显卡用16没问题8G降到8。patience30表示30个epoch没涨就早停895张小数据集很容易在40轮以内收敛跑满120轮反而可能过拟合。训练完成后看runs/detect/drowning_baseline/weights/best.pt这就是后面验证和导出用的模型。如果机器没显卡也不要气馁yolov8n用CPU跑895张大概每轮一分钟左右只是验证的话完全能接受。比较实用的是用训练得到的confusion_matrix.png观察两个类分别有多少互相混检这比只看mAP要直观得多。4. 895张数据集的避坑清单坐标错位、类别不平衡、水域误检4.1 避坑转换后训练loss不降先检查是不是标签坐标错位现象训练loss一开始就震荡验证集mAP始终在0.2以下而且画出来的框位置明显不对。原因最常见的是xml里的filename和实际图片文件名对不上转换脚本又按xml.stem写txt导致A图片配了B图片的标注。另一个原因是某些xml的size节点和真实图片尺寸不同如果jpg被人为压缩过但xml没更新归一化坐标其实整体偏移了。这类错误在895张里只要混进五六个训练就会被带偏。解决写个最简单的校验脚本对比真实图片宽高和xml里size节点不一致直接打印文件名。再看每张图的目标数完全没有目标的图片要么补标要么从训练集去掉。另外把视觉检查脚本放进日常工作流每次跑数据都先抽查20张确认人和框是对上再开始训练。这个过程五分钟但能省下至少一个下午排错的时间值得。4.2 避坑溺水者类严重类别不平衡导致yolo损失函数被多数类主导现象模型看起来不错但几乎漏检溺水者或者只在极近距离才报警。原因2类别数据集里游泳者和溺水者数量往往差一个量级溺水场景在真实世界本来就更少见。yolo损失函数在计算分类损失时默认对所有样本同等对待少数类在每轮迭代中获得的梯度贡献被多数类淹没模型学会输出“大多数是游泳者”就能把损失压得很低。解决先统计两个类各自有多少个标注框这是调训练策略的基础。如果比例超过3:1我会在脚本里对溺水者图片做一次复制让每个epoch里两类比例接近1:2而不是盲目增加全局epoch。再配合数据增强对溺水者图片做随机水平翻转、小角度旋转和HSV扰动让模型见过更多姿态。负样本不足问题解决之后再看验证集通常溺水类recall会有明显提升。如果还不行再考虑用yolo改进里的nwd等针对小目标的损失替换但这个阶段先不要上把数据调平衡性价比最高。4.3 避坑水域场景误检玻璃反光和地面倒影被当成游泳者现象单张测试图片看精度挺高一旦用视频跑起来泳池边的人影、玻璃反光、水面的树影会被连续误检成游泳者甚至某个位置的倒影会突然变成高置信度溺水者。原因数据集大部分是水面视角构图相对干净实际部署监视器可能装在岸边、顶棚、三脚架视角变化造成特征分布偏移。水面还有反光和镜面倒影轮廓特征和人体上半身很像正好命中模型学到的自我遮蔽特征。这类问题不是标注能彻底解决的属于视角域gap。解决把需要重点看的方向抽成一段实拍视频用模型跑一遍把所有连续多帧出现的高置信度误检框单独截出来人工确认后加入训练集做负样本。这里有个朴素但有效的技巧在数据增强里刻意加入高斯模糊和随机亮度扰动让模型不要依赖纹理细节把注意力放到身体轮廓和姿态上。真到产品阶段再叠加一个时序滤波器连续5帧以上都是同一位置的溺水目标才触发告警能挡掉大部分单帧误检。纯静态模型很难同时兼顾灵敏度和误检率这个边界要在方案设计时就接受。5. 在真实场景里验证模型视频抽帧、yolo导出onnx与边缘部署方向5.1 用视频抽帧而不是测试集图片来验收模型模型训练完不少新手直接拿测试集图片看效果这是不够的。测试集图片是经过挑选的、静态的、往往还做过增强和现场视频的特征分布差距很大。正确做法是挑一段连续的视频抽帧后批量推理看误检在连续帧之间是不是稳定。如果模型在某个倒影上停了两三秒说明那个位置真的过拟合了如果只是偶发一帧闪烁可能只是置信度抖动。ffmpeg抽帧的命令很简单ffmpeg -i test_video.mp4 -vf fps10 frame_%03d.jpg把抽出来的帧放到一个目录里直接用训练时的模型跑预测yolo detect predict modelruns/detect/drowning_baseline/weights/best.pt \ sourcevideo_frames/ \ conf0.25 \ save_txtTrue \ save_confTrue这里conf不是越低越好。建议首次验证用0.25把结果全部保存后用脚本统计每个目标出现的帧数专门看连续帧计数。如果同一坐标附近的目标出现次数少于3帧基本能判定是闪烁误检。确认模型稳定后再逐步调高conf到生产值。5.2 把训练好的yolo模型导出onnx并设置动态尺寸边缘部署几乎不会直接跑pyTorch模型常见做法是导出onnx再用onnxruntime或者转成TensorRT。yolo导出onnx的命令在ultralytics里一行搞定yolo export modelruns/detect/drowning_baseline/weights/best.pt formatonnx dynamicTrue imgsz640导出前要确认opset版本和onnxruntime匹配我一般用opset12省得在旧设备上遇到算子不支持的问题。dynamicTrue让输入的宽高不再固定对从视频流抽帧来说很有用但如果目标设备性能比较差建议固定成640x640能省去动态shape带来的额外计算。导出后最好用一段python或已有工具确保输出结果与训练时的推理一致这步不能省。曾经遇到过模型在pytorch下mAP正常导出后输出全为零的情况原因是一个自定义前处理层在onnx里丢失了归一化所以导出后要在真实图上跑一遍。5.3 从数据集到产品结合多模态AI分析的落地思路这个泳池溺水检测方向单靠895张图片训练出的2D检测器其实只能当告警触发源不能直接作为最终判决。现在更被认可的落地路径是“yolo目标检测 多模态AI分析”的复合方案先由目标检测模型框出游泳者和溺水者候选再接入关键点姿态、时序轨迹、甚至红外或深度信息来二次确认。比如用d435i深度相机测距算出目标到相机的距离和大小变化排除远处倒影或结合骨骼关键点判断头部朝下持续时间比单纯依赖box特征更稳。对于中小团队最常见做法是先跑通yolo检测和规则滤波给业务侧一个“疑似目标截图连续帧数”的事件流。等你发现误检率降到可接受范围再逐步引入多模态模型。数据集本身不完美但它是搭建这个流水线的第一块砖。6. 从895张到可靠数据集数据增强、难例挖掘与人工复审的一次工作流6.1 先按效果反推增强策略不要一开始堆满增强当验证集mAP到不了0.8时第一反应是加增强。但895张数据本身很小堆上所有增强会让模型连原有分布都学不到。我现在的习惯是先只开mosaicHSV跑完看结果如果溺水类漏检多单独对溺水者做复制和轻微旋转再针对性地加模糊。增强参数也要记录在训练命令上乱调的话没法复现。这里有一个很实用的度量把增强后的样本画出来看如果人眼已经分辨不出游泳和溺水那这个增强强度对模型就是噪音而不是增益必须降下来。6.2 难例挖掘让模型自己帮你找错标训练出一个不算差的模型后把整个训练集跑一遍推理找出“模型高置信度但和gt不匹配”的框。这类框多数是标注松动、类别错误、极端遮挡。把它们抽出来人工看一遍错标修正没标补齐再重新训练一轮。这个过程通常能让mAP提升两个点。难例挖掘比单纯增加图片更值得做因为它把标注资源集中在模型真正困惑的区域而不是平摊到所有图片上。895张数据做一轮这操作等于把有效数据量放大好几倍。6.3 人工复审清单每次改数据后至少抽10张每类图用可视化脚本看框的贴合度。现在我已经把这套流程固化到脚本里而不是每次手动点开图片。数据科学里最贵的是标注质量不是数据集大小。我自己的习惯是每次训练前都跑一遍统计脚本输出类别数量、图片尺寸、无效标注文件清单三项没有异常才启动训练。这套流程跑顺了再回去加数据模型才不会因为新样本进来而倒退。希望帮到你。本文还有配套的精品资源点击获取