简介:面向YOLO系列目标检测实践者的网球与球员检测数据集,包含551张已标注的JPG图像,可支撑目标检测模型从训练到验证的完整流程。数据集围绕网球、球员两类目标构建,专门用于解决体育场景下小目标遮挡、动态捕捉等标注难题,适合正在学习YOLOv5、YOLOv7、YOLOv8、YOLOv9、YOLOv10、YOLO11等算法的开发者快速上手。压缩包共1578个文件,其中551张JPG原始图像、513个XML标注文件、513个TXT标注文件及1个YAML配置文件相互对应;XML为VOC格式,TXT为YOLO格式,均按类别、中心点坐标、宽高归一化方式记录目标框,适配常见训练框架。整个资源包约27.67MB,数据集已按训练、验证、测试划分,下载后无需额外整理即可直接使用;且两种标注格式分别存放,直接切换工具链无需格式转换,能节省不少数据预处理时间。目前已有138人学习,尤其适合需要现成数据开展模型对比实验或课程设计的用户。
1. yolo算法实战资源:拆解一份能直接开训的网球目标检测数据集
做目标检测的同行,应该都对这种场景不陌生:网上辛辛苦苦找到的数据集,要么标签格式混乱,要么缺了关键配置文件,光是清洗数据就能消耗掉一个下午的精力。这份网球检测数据集不太一样,551张图像,每一张都带齐了YOLO和VOC两种格式的标签,data.yaml已经配好,解压出来就能直接丢给YOLOv5/YOLOv8/YOLOv9甚至YOLOv11开训。它解决的是体育视觉分析里的两个高频诉求:识别运动员站位、追踪网球的运动轨迹。对于刚入门YOLO系列算法想跑通完整训练流程的新手,或者需要在短时间内产出体育检测demo的工程师,这份数据都是很好的落地样本。它能帮你省掉最枯燥的标注和格式转换环节,直接把精力花在模型调参和推理优化上。
2. 数据解剖:YOLO与VOC双格式背后的标注逻辑
一份数据集能不能“直接开训”,关键看标注文件的规范程度。这一章我们深入目录结构和标注文件本身,把它内部的存储逻辑彻底说清楚。
2.1 目录结构与data.yaml解析
解压yolo算法-网球-球员数据集-551张图像带标签-球-球员.zip之后,你会看到它已经按深度学习的标准习惯划分好了训练集与验证集。下面是我在本地解压后看到的典型目录结构:
tennis_dataset/ ├── data.yaml ├── train/ │ ├── images/ │ │ ├── img_0141_111.jpg │ │ ├── img_0141_112.jpg │ │ └── ... │ └── labels/ │ ├── img_0141_111.txt │ ├── img_0141_112.txt │ └── ... ├── val/ │ ├── images/ │ │ ├── img_0141_463.jpg │ │ └── ... │ └── labels/ │ ├── img_0141_463.txt │ └── ... └── voc_xml/ ├── img_0141_111.xml ├── img_0141_112.xml └── ...data.yaml是YOLO系列算法通用的数据集描述文件,它直接告诉训练脚本“图像在哪、标签在哪、有几个类别”。这份文件的内容大致长这样:
path: ../datasets/tennis_dataset # 数据集根目录,建议改成你机器上的绝对路径 train: train/images # 训练图片路径,相对于path val: val/images # 验证图片路径,相对于path nc: 2 # 类别数量,只有 ball 和 player 两类 names: ['ball', 'player'] # 类别名字,0对应ball,1对应player这里的path字段是头号大坑。如果你用相对路径,YOLO会基于当前工作目录去解析。但实际项目中,数据集常常放在项目目录之外,训练脚本的工作目录和数据集根目录不一致,就会报出“No images found”之类的错误。我一般会把path直接写成绝对路径,比如D:/datasets/tennis_dataset,同时把train和val保持相对路径写法,这样拼接出来的最终路径永远是唯一的。
2.2 YOLO txt标签和VOC xml标签的换算关系
train/labels目录下的txt文件严格遵循YOLO格式规范,每一行代表一个目标,由5个数值组成,空格分隔:
<class> <x_center> <y_center> <width> <height>其中中心点坐标和宽高都是相对于图像宽高的比例值,范围在0到1之间。拿img_0141_111.txt里的某一行举例:
0 0.4832 0.5231 0.0812 0.0715这行含义是:类别为0(ball),中心点在图像横向48.32%、纵向52.31%的位置,框的宽度占整张图宽度的8.12%,高度占整张图高度的7.15%。
而voc_xml目录下的XML文件,用的是绝对像素坐标。用OpenCV读取原图拿到宽高后,两者可以互相换算。下面这个脚本能把txt转成绝对坐标,并从xml里读取同样目标的坐标做交叉验证:
# convert_check.py import cv2 import xml.etree.ElementTree as ET # 读取YOLO txt第一行 txt_path = 'train/labels/img_0141_111.txt' with open(txt_path) as f: line = f.readline().strip().split() cls, x_c, y_c, w_n, h_n = float(line[0]), float(line[1]), float(line[2]), float(line[3]), float(line[4]) # 读取原图获取尺寸 img = cv2.imread('train/images/img_0141_111.jpg') H, W = img.shape[:2] # 归一化坐标转绝对像素坐标 x1 = int((x_c - w_n / 2) * W) y1 = int((y_c - h_n / 2) * H) x2 = int((x_c + w_n / 2) * W) y2 = int((y_c + h_n / 2) * H) # 从VOC xml里读同样的目标做对比 tree = ET.parse('voc_xml/img_0141_111.xml') root = tree.getroot() for obj in root.iter('object'): name = obj.find('name').text if name == 'ball': bndbox = obj.find('bndbox') x1_xml = int(bndbox.find('xmin').text) y1_xml = int(bndbox.find('ymin').text) x2_xml = int(bndbox.find('xmax').text) y2_xml = int(bndbox.find('ymax').text) print(f"TXT 转换坐标: ({x1}, {y1}, {x2}, {y2})") print(f"XML 原始坐标: ({x1_xml}, {y1_xml}, {x2_xml}, {y2_xml})")这段脚本是数据集的“消毒剂”。因为网上流传的数据集经常出现txt和xml对不上的情况,比如转换坐标时忘记除以宽高,导致框偏移半个身位。跑完这个脚本,如果两边坐标对不上,那就要警惕标注源文件本身有问题了。
2.3 类别平衡:为什么“球”总是比“球员”难检测
我在统计标签时发现,ball的实例数明显少于player,而且球在图像里往往只有几十个像素,属于标准的小目标检测。球员的框就大多了,动辄占据图像高度40%以上。这种不平衡会直接影响损失函数:模型只要多学一点“球员”的特征,loss就能大幅下降,而“球”对loss的贡献被稀释,训练很容易陷入局部最优。
常见做法是给ball类别加权重,YOLOv8的model.train()参数里有个cls,它控制分类损失的权重系数。比如把cls=0.6调高,模型会偏向“把更多注意力放在识别小物体类别上”。这个我们在第五章的训练参数详解里会再次展开。
3. 训练前置:数据检查、划分与增强的实操步骤
这部分的重要性常被忽略,但恰恰是决定训练上限的关键。拿到底层数据后,我建议按下面三步走,每一步都有对应的可执行脚本。
3.1 用Python脚本快速排查空标签和越界框
训练前扫描标签文件是我的固定习惯,这个习惯避免过很多次“训练到一半才发现数据是脏的”的尴尬。脚本逻辑很简单:遍历train/labels目录下所有txt,检查文件是否为空,以及5个数值是否都在0到1之间。
# scan_labels.py import os bad_files = [] empty_files = [] for split in ['train', 'val']: label_dir = f'{split}/labels' for fname in os.listdir(label_dir): fpath = os.path.join(label_dir, fname) with open(fpath) as f: lines = f.readlines() if len(lines) == 0: empty_files.append(fname) continue for line in lines: parts = line.strip().split() if len(parts) != 5: bad_files.append(fname + " 格式错误") break cls, x_c, y_c, w, h = map(float, parts) if not (0.0 <= x_c <= 1.0 and 0.0 <= y_c <= 1.0 and 0.0 <= w <= 1.0 and 0.0 <= h <= 1.0): bad_files.append(fname + " 坐标越界") break print("空标签文件:", len(empty_files)) print("异常文件:", len(bad_files)) if bad_files: print(bad_files[:10])跑完如果empty_files或bad_files数量不为零,就要回到对应图像上看是删掉还是重新标注。我通常直接删空标签文件,一张图没有任何目标,留着只会增加无效计算量。
3.2 复现训练集/验证集划分
虽然这份数据集已经划分好了,但如果你想自己重划,或者以后处理类似数据,千万不要随机打乱。网球图像来自连续视频帧,同一个拍摄角度的连续帧背景几乎完全一样。如果这些连续帧同时出现在训练集和验证集里,验证的mAP会虚高到吓人,但模型一部署到真实视频上就全面崩盘。
注意看文件名格式:img_0141_xxx.jpg,其中0141应该是视频编号,xxx是帧序号。按视频编号分组划分,才能从源头阻断数据泄露:
# split_by_video.py import os import random from collections import defaultdict img_dir = 'all_images' # 假设你先把所有图片集中放置 files = os.listdir(img_dir) groups = defaultdict(list) for f in files: video_id = f.split('_')[1] # 取出0141这类视频编号 groups[video_id].append(f) # 按视频组划分 80% / 20% train_files = [] val_files = [] group_keys = list(groups.keys()) random.shuffle(group_keys) val_groups = set(group_keys[:int(len(group_keys) * 0.2)]) for v_id, flist in groups.items(): if v_id in val_groups: val_files.extend(flist) else: train_files.extend(flist) # 得到划分后,再把文件移动或软链到 train/val 的 images 和 labels 目录这里的核心参数是0.2,即保留20%的视频片段作为验证集。要注意的是,如果整个数据集只有3个视频片段,20%意味着验证集只有1个片段,多样性不足。这时可以考虑按帧号切分,比如用每个视频的前70%帧训练,后30%帧验证,模拟“模型没见过未来帧”的真实部署场景。
3.3 增强策略:如何让551张图撑住100个epoch
数据量不大,增强策略就是决定成败的杠杆。YOLO官方自带的增强已经很强,但它不会根据你的目标尺寸自动调节。针对网球这种“小球”场景,把mosaic完全拉满并不是最优解,马赛克增强会把4张图随机缩放拼在一起,小球很容易被缩放到不足5个像素,最终的标注框已经不是真实目标了。
我通常会把mosaic降到0.5,同时开启copy_paste增强。下面是一个可以直接参考的训练配置:
from ultralytics import YOLO model = YOLO('yolov8s.pt') model.train( data='data.yaml', epochs=100, imgsz=640, mosaic=0.5, # 一半batch保持正常图像,防止模型陷入“马赛克世界” copy_paste=0.2, # 从一张图复制目标实例粘贴到另一张图 hsv_h=0.015, # 色调偏移不要大,网球场颜色很统一 hsv_s=0.5, # 饱和度扰动可以大一点,模拟不同灯光环境 fliplr=0.5, # 水平翻转对体育场景有效 scale=0.3, # 缩放增强,模拟球的大小变化 )重点解释两个参数。mosaic=0.5意味着每2个batch里有一个batch是马赛克拼接的,另一个batch是原始图像。YOLOv8默认会在最后10个epoch自动关闭马赛克(close_mosaic=10),让模型回到真实分布上微调。如果前90个epoch全用马赛克,最后10个epoch突然面对真实图像,边界框回归头需要一个痛苦的适应过程,而mosaic=0.5恰好能缓解这种“记忆断层”。copy_paste=0.2的价值在于,它会从一张图里把“球”这个实例抠出来,粘贴到另一张图,直接变相增加了小目标样本数量,又不会改变目标的真实物理尺寸。
4. 避坑记录:实测踩过的五个训练深坑
这章全是实战里翻车后沉淀下来的经验。每一条都按“现象 -> 原因 -> 解决”的顺序来讲,直接对应你训练时大概率会遇到的报错或性能问题。
4.1 现象:小目标球体AP50很高,AP却几乎是0
这算是我入行时最困惑的问题之一。模型很快学会了检测球员,但球总是漏检严重。打开results.csv查看,发现metrics/mAP50(B)勉强能看,但metrics/mAP50-95(B)低得离谱。
原因在于球本身的像素面积占比太小,而COCO评估协议里,mAP50-95对预测框的IoU要求更严,必须达到0.75以上才算正样本匹配。小球分辨率低,预测框中心偏移5个像素,IoU就掉到0.6以下,直接判为错误检测。
解决方法是把imgsz从640提高到1280。球在放大后的输入图像里能占据更多像素,特征提取网络能学到边缘细节。显存不够就降低batch数值,优先保imgsz。
4.2 现象:XML标注与TXT标注坐标对不上
有次我同时使用数据集的voc_xml和labels去做交叉验证,发现用XML训练出来的模型,在真实图片上预测的框总是整体偏向左上角。排查了两天,只到我去校验两者坐标转换关系才找到原因:某个前人写的转换脚本在计算x_center的时候,不小心把x_min当作中心点写进去了。
原因是转换工具脚本不严谨。YOLO转VOC或反向转换时,只要忘记除以图像宽高,就会生成这种“半成品”标签。
解决方法是启动训练前,强制跑一遍2.2节里的交叉验证脚本。如果发现坐标对不上,不要心存侥幸直接开训。正确做法是放弃txt,从XML重新生成全部标注:
# 从xml重新生成txt for xml_file in voc_xml/*.xml: 解析 xmin, ymin, xmax, ymax norm_x_c = (xmin + xmax) / 2 / W norm_w = (xmax - xmin) / W # 写入新txt从那以后我每次拿到新数据集都会强制走一遍交叉验证,绝对不省这一步。
4.3 现象:验证集出现“重图”导致mAP虚高
训练时mAP高得喜人,但把模型放到一段全新的网球比赛视频里测试,检测效果却一塌糊涂。后来我把训练集和验证集里的路径一一对比,发现同一个视频的连续帧被随机划分到了两边。
原因就是划分数据集时用了纯随机划分。网球视频连续帧的背景高度相似,模型在训练集里“背”下了背景纹理,验证时自然成绩好。
解决方法是按视频编号分组划分,也就是第三章里提到的split_by_video.py。如果视频素材来源复杂,我甚至会故意把验证集的时间段往后挪,比如用前70%帧做训练,后30%帧做验证,这样能模拟“模型没见过未来帧”的真实部署场景,得到的mAP才有参考意义。
4.4 现象:马赛克增强把小目标切成了“鬼影”
训练到一半可视化训练输出,发现不少拼接图像里的球只剩下半个轮廓,但标签框仍然标注为完整的球。这种“鬼影”会让模型学习到错误的语义。
原因是mosaic=1.0时,4张图缩放拼接后尺寸剧烈变化。如果球落在图像拼接边界位置,就直接被截断。模型试图用一个完整的框去拟合半个球,导致特征混乱。
解决方法是把mosaic调成0.5或0.0,同时打开copy_paste=0.2。前者的作用是保留一部分真实分布,后者的作用是增加小目标样本的同时不切割目标本身。
4.5 现象:检测结果中球员框把球“吞”了
推理时发现,球员的预测框往往把身边的球也圈进去了,甚至出现把球识别成球员的低级错误。查看混淆矩阵,ball被错判为player的比例接近10%。
原因是类别严重不平衡,加上网球运动员挥拍瞬间,球拍和球在图像上会形成一团模糊的运动纹理,模型很容易把这种纹理归为球员。
解决方法是做两手准备。一是推理时把conf阈值从0.25提高到0.4,牺牲一点召回率换取干净的画面。二是重新训练时给ball类别提高loss权重,让模型在分类时对“这是球”这件事更敏感。最直接的办法是检查原始标注,很多快速移动中的球,标注框会画大一圈,把球拍也包进去了,属于典型的标注噪声。
5. YOLOv8训练与推理实战:从data.yaml到mAP50的变化曲线
这一章是完整跑通流程的实操记录。从配置文件修改,到模型选型,再到训练命令、日志判读和批量推理,全部按顺序走一遍。
5.1 修改data.yaml指向你的本地路径
前面说过,path字段是头号大坑。以Windows环境为例,我一般是这样写的:
path: D:/datasets/tennis_dataset train: train/images val: val/images nc: 2 names: 0: ball 1: player注意names这里用字典写法。网上的data.yaml模板大多是列表写法,也就是['ball', 'player']。两种YOLOv8都兼容,但如果你的数据集类别超过5个,字典写法能更直观地看到索引和类名的对应关系,避免类别错位。
改完后先用YOLO提供的验证接口测试数据加载是否正常,不要直接开训:
from ultralytics import YOLO model = YOLO('yolov8n.pt') metrics = model.val(data='data.yaml')如果图片路径配置有问题,这里会直接抛出AssertionError: train: No images in train/images。看到这个错误,优先排查path路径是否正确,以及train字段有没有误改成绝对路径。
5.2 选择模型体积:n/s/m/l在网球场景下的取舍
YOLOv8提供了n、s、m、l、x五种体积。对于网球检测这种实时性要求高、小目标多的场景,选型很关键。下面是几张模型的对比,以RTX 3060为参考基准:
| 模型 | 参数量 | 推理耗时(ms) | 小目标能力 | 建议场景 |
|---|---|---|---|---|
| YOLOv8n | 3.2M | 1.2 | 较差 | 快速验证流程 |
| YOLOv8s | 11.2M | 2.1 | 中等 | 首选 |
| YOLOv8m | 25.9M | 4.0 | 较强 | 追求精度 |
| YOLOv8l | 43.7M | 8.1 | 强 | 离线分析 |
想要实时追踪网球的轨迹,模型推理速度必须跟上视频帧率。如果用的是YOLOv8n,预测框抖动会比较明显,因为小球的特征不够丰富。我用得最多的是YOLOv8s,它兼顾了显存占用和检测精度,实测在1080P视频上能做到20ms左右的推理延迟。
5.3 训练指令参数详解与日志解读
下面是完整训练命令,建议直接保存成train.sh脚本:
yolo train \ model=yolov8s.pt \ data=data.yaml \ imgsz=640 \ epochs=150 \ batch=16 \ device=0 \ optimizer=AdamW \ lr0=0.01 \ lrf=0.01 \ close_mosaic=10 \ augment=True逐项说明每个参数的作用。
epochs=150:551张图在数据增强加持下,150个epoch基本能让模型收敛。YOLOv8默认开启patience=50早停机制,如果连续50个epoch验证指标没有提升,训练会自动终止。batch=16:在12G显存显卡上是安全配置,如果只有8G显存,降到8。optimizer=AdamW:YOLOv8默认是SGD,但AdamW在小数据集上收敛明显更快,它对噪声标签的鲁棒性更好。close_mosaic=10:这个参数前面提过,最后10个epoch关闭马赛克增强,让模型回到真实分布微调。
训练开始后不要傻等。我习惯用pandas读取runs/detect/train/results.csv,实时监控损失曲线:
import pandas as pd import matplotlib.pyplot as plt df = pd.read_csv('runs/detect/train/results.csv') plt.plot(df['epoch'], df['metrics/mAP50(B)'], label='mAP50') plt.plot(df['epoch'], df['metrics/mAP50-95(B)'], label='mAP50-95') plt.legend() plt.show()如果mAP50持续稳定上升,但mAP50-95掉头向下,说明模型进入过拟合阶段,可以提前停止训练,然后去调低epochs或者加大weight_decay正则化权重。
5.4 加载best.pt进行批量推理保存带框图像
训练结束后,runs/detect/train/weights/目录下会生成best.pt和last.pt。best.pt是验证集上指标最高的模型,推理时直接用它。批量处理测试图像的脚本如下:
from ultralytics import YOLO import cv2 import glob model = YOLO('runs/detect/train/weights/best.pt') for path in glob.glob('test/*.jpg'): result = model.predict(path, conf=0.25, imgsz=640)[0] # result.plot() 返回BGR通道的numpy数组 frame = result.plot() cv2.imwrite('output_' + path.split('/')[-1], frame)这里有两个容易忽略的点。第一,result.plot()返回的是BGR数组,如果用plt.imshow()直接显示,画面颜色会整体偏蓝,需要手动转成RGB。第二,conf=0.25在网球场景下可能偏低,如果你发现输出图像上有一堆小框,请把阈值上调到0.4,画面立刻会干净很多。
6. 部署边界与进阶技巧:把551张图的模型用到实拍视频中
训练完模型只是第一步,真正要落到实拍视频或者服务器接口里,还需要解决导出格式和推理策略的问题。
6.1 导出ONNX或TensorRT时的注意事项
把best.pt转成ONNX,是跨平台部署最常用的手段。YOLOv8的导出命令很简单:
yolo export model=runs/detect/train/weights/best.pt format=onnx dynamic=True这里重点说dynamic=True。如果部署场景的输入尺寸固定为640x640,建议把它改成dynamic=False。因为TensorRT在做图优化时,固定输入尺寸比动态尺寸能采用更激进的kernel选择策略,实测推理速度能提升20%以上。
6.2 TTA与多尺度推理:把漏掉的球找回来
在实拍视频中,球的运动模糊严重,单次推理经常出现“这一帧有球、下一帧没球”的跳变。如果业务允许离线分析,我强烈建议开启测试时增强:
model.predict('match_clip.mp4', imgsz=[640, 1280], augment=True, conf=0.2)imgsz=[640, 1280]参数会让模型对同一帧图像跑两次推理,分别用640和1280两种分辨率,然后把结果融合。对小目标检测来说,这就是“后悔药”,多尺度推理能明显把球召回率拉回来。代价是推理时间接近翻倍,实时场景慎用。
说一个我的个人习惯:每次训练新数据集,我都会在results.csv的趋势图上加一条“目标框平均宽高”的参考线。如果训练过程中这个值低于10像素,说明输入分辨率不够,或者增强策略把目标缩得太小了。强制自己走一遍这种“数据体检”,能少走很多弯路。希望帮到你。
本文还有配套的精品资源,点击获取