简介:游泳溺水识别数据集面向从事计算机视觉、智慧安防及运动安全监控的算法工程师和研究者,专门解决溺水行为样本稀少、标注成本高的问题。压缩包内共2000个文件,其中1998张JPEG格式图像涵盖多种泳姿、光线条件与水面干扰,配合2个COCO JSON标注文件,后者记录目标类别、边界框及关键属性,能直接用于目标检测、实例分割和行为识别等模型迭代。资源整体约375.06MB,图像与标注文件一一匹配,便于自行划分训练集、验证集;标注工作基于9984张原始图片完成,报告平均识别率达91.7%以上,数据可信度较高。目前已有554人学习浏览,适合作为游泳馆、海滨浴场等场景预警系统的算法基准。读者可利用该数据集快速开展预训练模型微调、数据增强与模型评估,降低人工标注负担,加速防溺水项目从实验到落地的进程。
1. 游泳溺水识别数据集:9984张图加COCO标注,先解决缺数据的三个月
做泳池视觉预警的人都有同感:最难的从来不是把模型跑起来,而是没有足够的溺水正样本。这套游泳溺水识别数据集直接把9984张原始图片整理到位,统一用COCO JSON格式标注,官方说明里平均识别率在91.7%以上。对正在做防溺水预警、泳池监控、体育安全系统的算法工程师和研究生来说,它最有价值的地方在于省掉了数据采集和标注这两个最耗时间的环节,拿回来可以直接进入训练和迭代。
2. COCO JSON结构拆解:categories、images、annotations三个顶层字段的读法
拿到数据集第一件事不是急着训练,而是把标注文件读懂。COCO JSON格式的标注把所有信息塞在一个文件里,顶层字段就那么几个,但每个字段的含义和边界不对,后面要么转格式出错,要么训练时类别错位。
2.1 为什么选COCO JSON而不是VOC XML或YOLO TXT
目标检测的标注格式有很多种,常见的是COCO JSON、VOC XML、YOLO TXT三种。这个数据集选COCO JSON,我认为是平衡了可读性和扩展性。VOC XML每个图片对应一个XML文件,文件数量多,目录一乱就找不到配对;YOLO TXT虽然干净,一行一个目标,但它丢掉了图片尺寸等上下文信息,而且不支持分割多边形。COCO JSON用一个文件就能描述整个训练集的图片信息和标注信息,还能兼容实例分割的segmentation字段,迁移到其他框架时解析逻辑也统一。
我自己在多个项目里切换过三种格式,最直观的感受是:YOLO TXT适合已经在跑的流水线,COCO JSON适合数据集分发和跨框架使用。这套数据集既然面向的是游泳溺水识别这种需要反复迭代的场景,COCO JSON作为原始交付格式是更稳妥的选择。
| 格式 | 存储方式 | 是否含图片尺寸 | 是否支持分割 | 跨框架兼容性 |
|---|---|---|---|---|
| COCO JSON | 单文件 | 是 | 是 | 高 |
| VOC XML | 每图一文件 | 是 | 是 | 中 |
| YOLO TXT | 每图一文件 | 否 | 否 | 高 |
2.2 逐层拆解annotation:bbox、area和图像尺寸的对应关系
在解析COCO JSON之前,先把结构说清楚。顶层通常包含info、licenses、categories、images、annotations五个部分,对训练起决定作用的是后三个。categories是类别表,里面是id和name;images是图片清单,记录每张图的file_name、width、height;annotations是真正的标注列表,每一条对应一个目标框。
关键点在于bbox字段的取值方式。COCO里的bbox是[x, y, width, height],其中x和y是目标框左上角的坐标,而不是中心点。很多从LabelImg转过来的人默认用中心点,这个差异会在转YOLO格式时产生系统性偏移。另外area字段表示目标框面积,在COCO JSON里是已经算好的值,但如果你改过bbox,area一定要重新算,否则评估时mAP计算会受影响。
import json with open('annotations/instances_train.json', 'r', encoding='utf-8') as f: coco = json.load(f) cat_map = {cat['id']: cat['name'] for cat in coco['categories']} print('categories:', cat_map) print('图片数量:', len(coco['images'])) print('标注数量:', len(coco['annotations'])) for ann in coco['annotations'][:3]: x, y, w, h = ann['bbox'] print(f"image_id={ann['image_id']}, category={cat_map.get(ann['category_id'])}, bbox=({x}, {y}, {w}, {h})")这段代码先把类别表打出来,再抽样看前三条标注。为什么要先打印categories?因为文件名里的drowning前缀只代表图片内容,不代表类别顺序。数据集的类别列表到底是两个类还是三个类,以JSON文件里的实际内容为准。我一般拿到任何数据集都会先跑一遍这个检查,确认类别id和名称的映射关系,避免后续转换时臆测。
annotations里的iscrowd字段也值得注意,它标记目标是否为密集人群。如果这个字段值是1,训练时通常要过滤掉,因为密集遮挡场景下的框本身不可靠。这套游泳溺水数据集里我不确定是否包含iscrowd,但解析逻辑里最好保留这个判断,防止训练时引入噪声。
2.3 标注质量检查:用Python脚本拦截越界和空标注
标注格式对不代表内容对。最常见的问题是bbox越界,也就是目标框超出了图片的实际宽高。这个数据集虽然有91.7%的识别率做背书,但下载后做一轮独立校验仍然是必要的,尤其是你准备把它当训练集复用时,脏数据会被模型原封不动地学进去。
import json import os from collections import Counter with open('annotations/instances_train.json', 'r', encoding='utf-8') as f: coco = json.load(f) img_info = {img['id']: img for img in coco['images']} ann_per_img = Counter() issue_count = 0 for ann in coco['annotations']: img = img_info[ann['image_id']] img_path = os.path.join('images', img['file_name']) x, y, w, h = ann['bbox'] if not os.path.exists(img_path): print(f"图片缺失: {img_path}") issue_count += 1 if x < 0 or y < 0 or x + w > img['width'] or y + h > img['height']: print(f"bbox越界: ann_id={ann['id']}, 图片={img['file_name']}") issue_count += 1 ann_per_img[img['file_name']] += 1 empty_imgs = [name for name, cnt in ann_per_img.items() if cnt == 0] print(f"问题总数: {issue_count}, 无标注图片: {len(empty_imgs)}")这段脚本做了三件事:检查图片文件是否真实存在、检查bbox是否在图片范围内、统计每张图片的标注数量。参数上要注意,img['width']和img['height']来自images字段,必须和实际图片尺寸一致,如果标注数据里的尺寸和图片本身的尺寸对不上,越界检查会误报或者漏报。无标注图片需要单独关注,如果目标是“溺水识别”,正样本是溺水动作,无标注图片可能是纯泳池背景,也可能是漏标,得抽样看一眼才知道。
提示:COCO JSON的bbox和图片尺寸都是像素单位,转成YOLO格式后需要归一化到0到1之间,这个转换过程放在下一步做。
3. 把COCO JSON转成YOLO训练格式:转换脚本、归一化与边界处理
现在主流的检测框架里,YOLOv5和YOLOv8虽然都提供COCO格式的训练入口,但实际工程中很多人还是习惯把数据先转成YOLO TXT再喂给模型,因为这样数据加载路径最短,排查问题也简单。这一章把转换流程拆成三个部分来讲。
3.1 转换前期:类别映射表的建立与数据集划分
转换的第一步不是写代码,而是确认类别索引。YOLO格式里每个类别对应一个整数索引,从0开始连续编号。COCO JSON里categories的id不一定连续,也可能不是从0开始,所以需要先构建一个映射关系,把COCO的category_id映射到YOLO的类别索引。
另一个容易忽略的问题是数据集划分。9984张图片如果全部用来训练,没有验证集,最后你只能说“我训练完了”,却没法回答“模型到底过拟合没有”。常见做法是训练集和验证集按9比1或8比2划分。但这里有个前提:先看图片是否来自视频抽帧。如果在划分前不按视频来源分组,同一个视频的相邻帧会被同时分进训练集和验证集,验证结果会虚高,这个坑会在第五章详细说。
import json import random random.seed(42) with open('annotations/instances_train.json', 'r', encoding='utf-8') as f: coco = json.load(f) # 按图片名后缀中的视频标识分组,防止同源帧泄漏 images = coco['images'] video_groups = {} for img in images: # 以文件名中的第一个下划线前缀作为视频分组标识 key = img['file_name'].split('_')[0] video_groups.setdefault(key, []).append(img['id']) all_ids = list(video_groups.keys()) random.shuffle(all_ids) split_idx = int(len(all_ids) * 0.85) train_videos = all_ids[:split_idx] val_videos = all_ids[split_idx:] train_ids = [] for v in train_videos: train_ids.extend(video_groups[v]) val_ids = [] for v in val_videos: val_ids.extend(video_groups[v]) print(f"train图片: {len(train_ids)}, val图片: {len(val_ids)}")这里的核心是video_groups分组逻辑。文件名里的前缀,比如drowning_16_0_2188_jpeg_jpg.rf.xxxx中的drowning_16,可以当作视频来源的粗粒度标识。如果所有图片的命名规则一致,按前缀分组就能有效避免同源视频帧同时出现在训练集和验证集里。如果文件名前缀无法区分视频来源,退而求其次就用文件名本身做哈希分组,但效果不如视频级分组可靠。
3.2 编写COCO转YOLO的转换函数
转换的核心逻辑就是把COCO的bbox改写成YOLO的归一化中心点格式。COCO是左上角坐标加宽高,YOLO是中心坐标加宽高,全部除以图片宽高。这个转换本身不难,真正容易出错的是归一化的分母用错,有人把宽高搞反,还有人忘了处理宽度或高度为0的空框。
import json import os from pathlib import Path def build_mapping(coco): cats = sorted(coco['categories'], key=lambda c: c['id']) mapping = {} for idx, cat in enumerate(cats): mapping[cat['id']] = idx return mapping def convert_coco_bbox_to_yolo(bbox, img_w, img_h): x, y, w, h = bbox if w <= 0 or h <= 0: return None cx = (x + w / 2) / img_w cy = (y + h / 2) / img_h return cx, cy, w / img_w, h / img_h def convert_dataset(coco_path, img_dir, out_dir, mapping): with open(coco_path, 'r', encoding='utf-8') as f: coco = json.load(f) img_info = {img['id']: img for img in coco['images']} img_to_anns = {} for ann in coco['annotations']: img_to_anns.setdefault(ann['image_id'], []).append(ann) out_dir = Path(out_dir) out_dir.mkdir(parents=True, exist_ok=True) for img_id, img in img_info.items(): if img_id not in img_to_anns: continue w, h = img['width'], img['height'] lines = [] for ann in img_to_anns[img_id]: if ann.get('iscrowd', 0): continue yolo_bbox = convert_coco_bbox_to_yolo(ann['bbox'], w, h) if yolo_bbox is None: continue class_idx = mapping[ann['category_id']] cx, cy, bw, bh = yolo_bbox lines.append(f"{class_idx} {cx:.6f} {cy:.6f} {bw:.6f} {bh:.6f}") txt_path = out_dir / (Path(img['file_name']).stem + '.txt') txt_path.write_text('\n'.join(lines), encoding='utf-8') print(f"转换完成,输出目录: {out_dir}")参数说明:convert_coco_bbox_to_yolo里的img_w和img_h必须来自images字段,而不是用OpenCV重新读取图片尺寸,因为重新读图在9984张规模下会慢很多,而且如果原始标注的尺寸字段有误,重新读图反而会掩盖标注问题。mapping的构建用sorted按category_id排序,保证顺序固定,不会因为JSON文件中键的排列顺序不同而改变。
这里的边界处理很直接:宽高小于等于0的标注直接丢弃。正常的人工标注不会出现这种问题,但自动标注或格式转换时会产生退化框,训练时这种框会导致损失函数出现异常值,影响收敛。
3.3 转换后的可视化抽查:必须看图确认结果
转换脚本跑完后,不要急着删掉COCO JSON。先抽样画几张图,把转换后的框画在原图上,肉眼确认类别和位置都对,这一步省不掉。
import cv2 import json from pathlib import Path with open('annotations/instances_val.json', 'r', encoding='utf-8') as f: coco = json.load(f) img_info = {img['id']: img for img in coco['images']} cat_names = {cat['id']: cat['name'] for cat in coco['categories']} label_dir = Path('labels/val') for ann in coco['annotations'][:20]: img = img_info[ann['image_id']] image_path = Path('images') / img['file_name'] image = cv2.imread(str(image_path)) if image is None: print(f"图片读不到: {image_path}") continue x, y, w, h = [int(v) for v in ann['bbox']] cv2.rectangle(image, (x, y), (x + w, y + h), (0, 0, 255), 2) label = cat_names.get(ann['category_id'], 'unknown') cv2.putText(image, label, (x, max(30, y - 5)), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 0, 255), 1) out_path = f"preview_{ann['id']}.jpg" cv2.imwrite(out_path, image) print(f"已保存预览图: {out_path}")这里我直接拿COCO JSON里的bbox画图,而不是读转换后的TXT文件,因为可视化排查的目的是确认原标注质量。转换后的TXT是否正确,可以通过对比同一条标注的坐标来确认。如果画出来的框明显偏了,比如框住的是水面波纹而不是人体,那不是转换脚本的问题,是原标注数据的问题,需要回到数据本身去处理。
4. YOLOv8训练配置:从data.yaml到91.7%平均识别率的参数路径
数据格式搞定了,接下来就是训练。这一章基于一个前提:你已经有可用的YOLOv8环境,并且把数据集按上一章的转换脚本转成了YOLO格式。这里不展开环境安装,只讲配置和参数选择。
4.1 模型选型:为什么用yolov8s而不是yolov8n
模型选择直接看场景。泳池监控的摄像头通常是固定的,视角变化小,人体在画面里的大小相对稳定,但溺水动作往往发生在水面波动、反光强烈的情况下,目标辨识度不高,所以不能一味追求轻量化。yolov8n参数最少,速度快,但在这种低对比度场景下漏检率偏高;yolov8m精度更好,但训练时间和显存占用都上来了。
我一般建议从yolov8s起步,它的容量对单类别或双类别检测足够,训练速度也能接受。如果你的显卡显存只有8G,yolov8s配合batch_size为16是能跑起来的,yolov8m可能就要降低batch_size。先把s跑通,再看验证集的漏检情况决定是否换更大的模型。
| 模型 | 参数量 | 推理速度 | 适合场景 |
|---|---|---|---|
| yolov8n | 3.2M | 最快 | 移动端、实时性极高 |
| yolov8s | 11.2M | 快 | 监控摄像头,精度速度均衡 |
| yolov8m | 25.9M | 中等 | 目标小、背景复杂 |
这个数据集的场景属于固定机位监控,画面变化小但目标特征复杂,yolov8s是最划算的起点。
4.2 data.yaml与训练命令的完整配置
转换完的标注文件只是训练集的一半,还需要一个data.yaml告诉YOLO去哪里找图片、标注文件和类别名。类别名必须和转换时用的索引一一对应,顺序错了模型会学到完全错误的东西。
# dataset.yaml train: ./dataset/train val: ./dataset/val nc: 2 names: 0: swimmer 1: drowning注意names的顺序。如果你在转换脚本里把categories按id排序后得到的索引是0对应某个类,这里就必须保持完全一致。这里的swimmer和drowning是我按场景推的示例,你拿到数据后要打印categories实际确认。
训练命令我习惯把超参数直接写在命令行里:
yolo detect train \ data=dataset.yaml \ model=yolov8s.pt \ epochs=150 \ imgsz=640 \ batch=16 \ patience=30 \ project=runs/drowning \ name=exp1参数含义:epochs设为150是因为检测任务的收敛通常比分类任务慢,溺水动作的帧间差异小,需要更多轮次让模型区分正常游泳和溺水姿态。patience设30代表连续30轮验证集指标不提升就提前停止,防止后段过拟合浪费算力。imgsz设640是YOLO系列的标准输入尺寸,不需要刻意调大,泳池场景的目标尺寸不算极小,640够用。
4.3 训练日志怎么读、91.7%怎么验证
训练结束后,YOLO会在runs/drowning/exp1/下生成weights目录,里面有best.pt和last.pt。best.pt是验证集指标最好的权重,last.pt是最后一轮的权重。正常情况用best.pt做推理。
在验证集上跑一轮评估:
yolo detect val \ model=runs/drowning/exp1/weights/best.pt \ data=dataset.yaml \ batch=16命令输出里会给出mAP50、mAP50-95等指标。数据集资料中提到的“平均识别率91.7%以上”,从目标检测的评估口径看,一般指的是mAP50或者某个置信度阈值下的准确率。你复现时不用纠结具体口径,只要在同样的验证集划分下,能跑出90%以上的mAP50,就说明数据集的标注质量和训练流程是可靠的。
注意:复现结果低于91.7%时,先检查数据划分是否一致,而不是怀疑模型。不同验证集划分得出的指标本身就有几个百分点的浮动,这属于正常范围。
5. 游泳溺水数据集避坑指南:五个真实翻车现场与排查方法
数据集的坑往往不在大数据层面,而在细节里。这一章写的是我在类似项目里遇到的高频问题,每一条都是真实翻过车的。
5.1 bbox大面积越界,训练loss不降
现象:训练很早开始,但loss在前几十轮就降不下去,验证集mAP一直在0.3以下徘徊。
原因:标注文件里存在大量越界的bbox,尤其是宽高比不正常的框。YOLO对归一化后的坐标范围有要求,超出0到1的坐标会让损失函数产生梯度异常。
解决:用第2章的越界检查脚本跑一遍全量数据。发现越界标注后,先把越界程度不大的框做裁剪,把坐标限制在图片范围内,越界严重的直接丢弃。两者都做时,优先丢弃而不是裁剪,因为强行裁剪会改变目标语义,比如框住的是半个人。
5.2 类别索引错一位,目标框串成隔壁类别
现象:训练能收敛,验证集mAP也能到0.8以上,但画出来发现模型把泳客识别成溺水,两类结果完全错位。
原因:data.yaml里的names顺序和转换脚本里的mapping不一致。最常见的是在COCO转YOLO时用了sorted后索引,而写data.yaml时凭印象填了类别顺序。
解决:这个问题的排查成本很低,但很隐蔽。做法是在转换脚本里把{yolo索引: 类别名}打印出来,存成一个txt,然后照着这个txt去写data.yaml。不要纯靠记忆,尤其是在你同时处理多个数据集时。
5.3 同一视频抽帧同时进train和val,精度虚高
现象:验证集mAP到了0.95以上,比资料里给的91.7%还高,但放到真实场景测试,漏检很严重。
原因:数据划分时用了纯随机划分,同一个视频的相邻帧被分进训练集和验证集。模型在训练时已经见过几乎一样的帧,验证分数失真。
解决:像第3章那样按视频来源分组划分。9984张图如果来自几百个视频片段,按视频维度划分能让验证结果更可信。如果原数据集没有提供视频分组信息,可以按文件名前缀聚类,粗粒度分组总比纯随机好。
5.4 图片尺寸不一致,resize后所有框偏移
现象:转换后的TXT文件看起来正常,但训练时mAP波动大,而且画出来的检测框总往图像边缘偏。
原因:标注的width和height与实际图片尺寸不一致,比如标注里是1920x1080,但实际图片被压缩成了1280x720。归一化后坐标因分母偏大或偏小而系统性偏移。
解决:转换前用脚本批量读取图片尺寸,和JSON里的width、height做一致性比对。发现不一致的图片单独处理,以实际图片尺寸为准重建标注。
5.5 置信度阈值默认0.25,泳池场景误报高
现象:模型在验证集上表现不错,但部署到泳池场景后,水波纹反光、泳道线、救生员走动频频触发报警。
原因:YOLO默认推理置信度阈值是0.25,这个值在通用目标检测场景下合理,但溺水检测是负样本远多于正样本的场景,误报的代价远高于漏报的代价,不能直接用默认阈值。
解决:用验证集做置信度阈值扫描,把不同阈值下的精确率和召回率画成曲线,结合实际业务对误报和漏报的容忍度选阈值。这个流程在下一章展开讲。
6. 置信度阈值校准:让91.7%在真实泳池场景兑现
模型在验证集上的mAP高,不代表部署后报警准确率高。溺水检测的线上场景和训练场景的分布差异很大:训练时有标注的图片大多构图清晰,而现场画面会有反光、水花、泳道线和不同距离的泳客,默认的置信度阈值往往不适用。我习惯的做法是先做一次批量推理,把每个检测框的置信度都记录下来,再对阈值做网格扫描。
from ultralytics import YOLO import numpy as np model = YOLO("runs/drowning/exp1/weights/best.pt") results = model("dataset/val", conf=0.05, verbose=False) scores = [] for r in results: if r.boxes is None: continue scores.extend(r.boxes.conf.cpu().numpy()) scores = np.array(scores) for threshold in [0.1, 0.2, 0.3, 0.4, 0.5, 0.6]: keep = (scores >= threshold).mean() print(f"conf>={threshold:.1f} 保留比例: {keep:.3f}")这个脚本做的事情很直接:先用一个极低的conf把所有潜在检测框都捞出来,然后观察不同阈值下检测框的保留比例。如果0.3以上保留的框数量骤降,说明大量检测框集中在低置信度区间,这时阈值设在0.4或0.5会更稳。
阈值选好后,还要做一次场景化测试。我通常会录一段泳池日常画面的视频,模拟正常游泳、踩水、仰漂等场景,看模型会不会误报;再截取数据集里的溺水正样本测试,确认漏报情况。在真实落地时,在推理代码里显式传入阈值,同时把连续多帧检测结果做时间维度的平滑,只有连续N帧都检测到疑似溺水才触发报警,能显著减少单帧误检带来的烦躁和警报疲劳。
从那以后,我每次拿到新的检测数据集和训练好的权重,都会强制走一遍阈值校准流程,把置信度阈值当超参数而不是默认值来对待。这个习惯帮我挡掉了不少线上环境的意外翻车。希望帮到你。
本文还有配套的精品资源,点击获取