简介:该资源为游泳者与溺水行为检测数据集,共含895张从30段视频中截取并标注好的jpg图片,采用VOC与YOLO双格式提供,覆盖swimmer、drowning两个类别,总标注框数1530个,适合水域安全监控、游泳姿态识别及溺水预警等计算机视觉项目的训练与验证。资源包共2000个文件,其中包含895个xml标注文件、895个txt标签文件及208个jpg图片文件,压缩包整体约315.71MB;xml与txt标注文件均按同名图片一一对应,可便捷接入LabelImg、YOLO系列等常用训练流程。需要留意的是,drowning类别框数仅97个,样本比例不均衡,且溺水状态本身存在主观性,下载后建议结合真实场景重新校正标签。目前该资源已有1897人学习下载,适合目标检测方向的研究者、算法工程师及高校学生用作实验数据补充,尤其便于开展小样本类别增强与误检边界分析。
1. 溺水检测数据集为什么值得自己做一遍:2类别895张的真实边界
拿到一份“游泳者溺水数据集VOC+YOLO格式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字段,如果不管difficult=1的难例直接转换,模型就会被那些标注特别不清晰的框干扰。所以转换脚本里建议保留一个开关,遇到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', encoding='utf-8') return True return False def main(): parser = argparse.ArgumentParser(description='VOC XML -> YOLO txt') parser.add_argument('--xml_dir', type=str, default='Annotations') parser.add_argument('--txt_dir', type=str, default='labels') parser.add_argument('--classes', type=str, default='swimmer,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(parents=True, exist_ok=True) 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去重,可能这次跑出swimmer=0、drowning=1,下次顺序相反,训练后推理时类别名就对不上了。xml里宽高从size节点读,如果xml里没有size,脚本会跳过,避免生成0中心的标签,这是不少转换工具翻车的原因。
还有一个容易被忽略的点:脚本里对difficult=1的目标直接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', encoding='utf-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', type=str, default='images') parser.add_argument('--label_dir', type=str, default='labels') parser.add_argument('--classes', type=str, default='swimmer,drowning') parser.add_argument('--sample', type=int, default=30) 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类别分类的边界框回归和分类分支同时训练,在推理时才能直接输出class=1的溺水目标,这本身就简化了后续业务逻辑。
这里顺便提一下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', type=str, default='images') parser.add_argument('--label_dir', type=str, default='labels') parser.add_argument('--out_dir', type=str, default='yolo_dataset') parser.add_argument('--val_ratio', type=float, default=0.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(parents=True, exist_ok=True) label_out.mkdir(parents=True, exist_ok=True) 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(f'train: {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 \ model=yolov8n.pt \ epochs=120 \ imgsz=640 \ batch=16 \ patience=30 \ project=runs/detect \ name=drowning_baseline参数里最重要的三个是imgsz、batch和patience。imgsz=640是速度和精度的平衡点,不要为了省显存降到320,否则远距离的溺水者身体特征全部丢失。batch根据显存调,12G显卡用16没问题,8G降到8。patience=30表示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 fps=10 frame_%03d.jpg把抽出来的帧放到一个目录里,直接用训练时的模型跑预测:
yolo detect predict model=runs/detect/drowning_baseline/weights/best.pt \ source=video_frames/ \ conf=0.25 \ save_txt=True \ save_conf=True这里conf不是越低越好。建议首次验证用0.25,把结果全部保存后,用脚本统计每个目标出现的帧数,专门看连续帧计数。如果同一坐标附近的目标出现次数少于3帧,基本能判定是闪烁误检。确认模型稳定后,再逐步调高conf到生产值。
5.2 把训练好的yolo模型导出onnx并设置动态尺寸
边缘部署几乎不会直接跑pyTorch模型,常见做法是导出onnx,再用onnxruntime或者转成TensorRT。yolo导出onnx的命令在ultralytics里一行搞定:
yolo export model=runs/detect/drowning_baseline/weights/best.pt format=onnx dynamic=True imgsz=640导出前要确认opset版本和onnxruntime匹配,我一般用opset=12,省得在旧设备上遇到算子不支持的问题。dynamic=True让输入的宽高不再固定,对从视频流抽帧来说很有用;但如果目标设备性能比较差,建议固定成640x640,能省去动态shape带来的额外计算。导出后最好用一段python或已有工具确保输出结果与训练时的推理一致,这步不能省。曾经遇到过模型在pytorch下mAP正常,导出后输出全为零的情况,原因是一个自定义前处理层在onnx里丢失了归一化,所以导出后要在真实图上跑一遍。
5.3 从数据集到产品:结合多模态AI分析的落地思路
这个泳池溺水检测方向,单靠895张图片训练出的2D检测器,其实只能当告警触发源,不能直接作为最终判决。现在更被认可的落地路径是“yolo目标检测 + 多模态AI分析”的复合方案:先由目标检测模型框出游泳者和溺水者候选,再接入关键点姿态、时序轨迹、甚至红外或深度信息来二次确认。比如用d435i深度相机测距,算出目标到相机的距离和大小变化,排除远处倒影;或结合骨骼关键点判断头部朝下持续时间,比单纯依赖box特征更稳。
对于中小团队,最常见做法是先跑通yolo检测和规则滤波,给业务侧一个“疑似目标+截图+连续帧数”的事件流。等你发现误检率降到可接受范围,再逐步引入多模态模型。数据集本身不完美,但它是搭建这个流水线的第一块砖。
6. 从895张到可靠数据集:数据增强、难例挖掘与人工复审的一次工作流
6.1 先按效果反推增强策略,不要一开始堆满增强
当验证集mAP到不了0.8时,第一反应是加增强。但895张数据本身很小,堆上所有增强会让模型连原有分布都学不到。我现在的习惯是先只开mosaic+HSV,跑完看结果;如果溺水类漏检多,单独对溺水者做复制和轻微旋转,再针对性地加模糊。增强参数也要记录在训练命令上,乱调的话没法复现。这里有一个很实用的度量:把增强后的样本画出来看,如果人眼已经分辨不出游泳和溺水,那这个增强强度对模型就是噪音而不是增益,必须降下来。
6.2 难例挖掘:让模型自己帮你找错标
训练出一个不算差的模型后,把整个训练集跑一遍推理,找出“模型高置信度但和gt不匹配”的框。这类框多数是标注松动、类别错误、极端遮挡。把它们抽出来人工看一遍,错标修正,没标补齐,再重新训练一轮。这个过程通常能让mAP提升两个点。难例挖掘比单纯增加图片更值得做,因为它把标注资源集中在模型真正困惑的区域,而不是平摊到所有图片上。895张数据做一轮这操作,等于把有效数据量放大好几倍。
6.3 人工复审清单
每次改数据后,至少抽10张每类图,用可视化脚本看框的贴合度。现在我已经把这套流程固化到脚本里,而不是每次手动点开图片。数据科学里最贵的是标注质量,不是数据集大小。我自己的习惯是每次训练前都跑一遍统计脚本,输出类别数量、图片尺寸、无效标注文件清单,三项没有异常才启动训练。这套流程跑顺了,再回去加数据,模型才不会因为新样本进来而倒退。希望帮到你。
本文还有配套的精品资源,点击获取