
简介这是一份基于YOLO算法实现的网球运动实时分析项目源码适合计算机视觉学习者、体育数据分析爱好者及想落地目标检测与关键点识别流程的开发者。项目可完成球员与网球的检测、球场关键点提取并进一步统计运动员速度、击球速度和击球次数属于完整的多任务视觉分析方案。包体共1231个文件约598MB主要内容包括581个txt标注文件与578个jpg图像样本用于模型训练15个py脚本和3个ipynb笔记文件覆盖球检测、球场关键点标注及击球分析等阶段的训练与推理另含avi/mp4示例视频、pt/pth模型权重及yaml配置便于直接验证效果或继续微调。目前已有739人学习下载适合希望结合实战数据集跑通检测、关键点回归与速度/次数统计的读者。1. 从一局比赛录像到可视化数据表YOLO网球分析做了什么把一段网球比赛录像拖进程序几分钟后得到球员跑动速度曲线、击球次数和落点分布听起来像是专业转播团队才能做的分析。这个项目把整条链路压缩在了几个 notebook 里先用 YOLO 实时检测球员和网球再用一个轻量 CNN 回归球场关键点最后通过坐标换算计算出速度、击球次数等指标。它不是那种只出个 demo 效果图的教学项目而是连模型训练代码、标注样本和处理好的 avi 视频一起给出的可复现工程。适合想搞懂目标检测怎么和运动场景结合的人也适合正在找 yolo 实战项目源码做毕设或技术预研的开发者。项目运行环境是 Win10 Python 3.9.7所有依赖都在常见深度学习栈内不用特殊硬件也能跑通。2. YOLO目标检测与网球训练小目标、损失函数、标注顺序2.1 为什么选YOLO而不是Faster R-CNN网球场景里有两类目标球员是中等尺寸目标而网球在画面中常常只有十几到几十个像素属于典型的小目标。Faster R-CNN 这类两阶段检测器精度上限高但在单张 1080p 画面上的推理速度很难做到实时尤其后期还要跑关键点网络和追踪CPU 或普通 GPU 压力会非常大。YOLO 将分类和回归合并到一次前向传播中v5 版本在 GTX 1060 上跑 640x640 输入能到 50 FPS 左右给后面的关键点计算留出了充足预算。项目中给出的tennis_ball_detector_training.ipynb和主分析文件ball_analysis.ipynb都沿用 YOLO 格式的训练流程。如果你打开过 YOLOv5 源码就会发现它的数据组织方式非常直接每张图对应一个同名 txt每行写一个目标。球员和球可以放在同一个模型里输出两个类别但实际经验是把它们分开训练更稳妥因为球的尺度太小和球员共享特征图时容易被训练噪声带偏。这个项目选择的是分开处理球检测器专注小目标球员检测器负责稳定的大目标框。2.2 标注数据与数据集格式转换打开项目里的tennis_court_keypoints_training.ipynb之前建议先看懂球检测的训练数据。项目附带了几张 Roboflow 导出的红土场样本文件名类似clay175_jpg.rf.fc05e01e37329c42850a077f7497c418.jpg这种命名是 Roboflow 导出的典型格式框信息通常在配套的_classes.txt和_annotations.coco.json里。如果你自己从零标注我一般推荐用 CVAT 或 LabelImg 直接导出 YOLO txt省去转换环节。如果你手上只有 COCO 格式的标注则需要转换成 YOLO 路径。下面这段脚本能把 COCO 的 bbox 转成 YOLO 的归一化中心坐标# coco2yolo.py将COCO标注转为YOLO训练格式 import json from pathlib import Path def coco2yolo(coco_json, out_dir): with open(coco_json) as f: data json.load(f) cat_map {} for cat in data[categories]: # 假设 categories 里 id1 是网球id2 是球员 cat_map[cat[id]] cat[id] - 1 # 转为 0/1 for img in data[images]: w, h img[width], img[height] lines [] for ann in data[annotations]: if ann[image_id] ! img[id]: continue cls cat_map[ann[category_id]] x, y, bw, bh ann[bbox] cx (x bw / 2) / w cy (y bh / 2) / h lines.append(f{cls} {cx:.6f} {cy:.6f} {bw / w:.6f} {bh / h:.6f}) out_path Path(out_dir) / (Path(img[file_name]).stem .txt) out_path.write_text(\n.join(lines))代码里把 COCO 的左上角宽高格式转成了 YOLO 需要的中心点加归一化宽高。注意cat_map的映射逻辑如果你的类别 id 不是从 1 开始需要先验证一下样本的category_id否则训练时类别标签会错位。转换完成后把data.yaml中的nc设为实际类别数names设为[tennis_ball, player]或你自己的顺序。2.3 训练YOLO模型的命令与损失函数影响如果复用 YOLOv5 的训练入口命令如下python train.py --img 640 --batch 16 --epochs 100 \ --data tennis.yaml --weights yolov5s.pt \ --cache --workers 4参数的含义并不复杂--img控制训练尺寸640 是速度和精度的平衡点--batch受显存限制GTX 1660 上 16 比较稳妥16G 显存可以开到 32--epochs我一般先跑 100 看看收敛曲线球检测器通常 60 epoch 左右就能收敛--cache会把图片预加载到内存避免每次 epoch 都读磁盘--workers在 Windows 上建议不要超过 4否则会遇到 dataloader 卡死的问题。说到损失函数YOLOv5 的损失由三部分构成box 回归损失用的是 CIoU置信度损失和分类损失都用 BCE With Logits。训练小目标时最影响漏检的是置信度损失因为背景样本远远多于前景样本大多数负样本的梯度会淹没球的回传。项目里如果出现“训练完球老是检不到”的情况先检查是否用了默认的hyp.scratch.yaml可以适当调低loss_ot相关的正负样本分配阈值。YOLOv8 之后引入了 TAL 动态标签分配对小目标的容忍度比 v5 高但项目源码基于 v5 结构不做迁移的话调参方向还是集中在训练尺寸和类别权重上。2.4 训练完成后的验证conf-thres与iou-thres训练完模型后最容易被忽略的是推理阈值。YOLO 默认conf_thres0.25对于网球这种小目标置信度往往只有 0.2 左右直接导致球被过滤掉。项目实战里我通常会先跑一遍检测脚本把每帧的原始置信度打印出来看分布再定阈值python detect.py --weights runs/train/exp/weights/best.pt \ --source input_video.avi --conf-thres 0.15 --iou-thres 0.5conf-thres降低到 0.15 后球框会出现一定数量的误检但后续的轨迹跟踪可以靠运动连续性滤掉离群点。iou-thres是 NMS 的阈值0.5 对于紧凑的球目标足够了设得太高会把同一个球拆成多个框。验证时不要只看 mAP还要抽查几段视频观察球是否在击球瞬间保持连续检测这是后续统计击球次数的数据基础。3. CNN提取球场关键点与透视变换3.1 关键点回归热图比全连接更适合球场定位球场检测的目的是拿到场地边界和发球线位置。常见做法不是直接回归四个角点坐标而是让 CNN 输出热图。每个关键点对应一个二维概率分布网络在学习过程中会隐式编码空间上下文比全连接层直接输出数值稳定得多。项目中的tennis_court_keypoints_training.ipynb就是这种思路输入是一帧图像输出是 K 个通道的热图每个通道对应一个球场关键点。如果用 PyTorch 自己复现一个轻量版结构大致是这样# keypoint_model.py轻量关键点热图回归网络 import torch.nn as nn class CourtKeypointNet(nn.Module): def __init__(self, num_keypoints6): super().__init__() self.backbone nn.Sequential( nn.Conv2d(3, 32, 3, padding1), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(32, 64, 3, padding1), nn.ReLU(), nn.MaxPool2d(2), nn.Conv2d(64, 128, 3, padding1), nn.ReLU(), ) self.head nn.Conv2d(128, num_keypoints, 1) def forward(self, x): feat self.backbone(x) # 输入 [B,3,H,W] - [B,128,H/4,W/4] heatmaps self.head(feat) # 输出 [B,num_keypoints,H/4,W/4] heatmaps nn.functional.interpolate( heatmaps, sizex.shape[2:], modebilinear, align_cornersTrue) return heatmapshead用的是 1x1 卷积把特征图压缩成关键点通道数最后用双线性插值把热图恢复到原图尺寸这样计算 loss 时可以直接和标注的高斯热图对齐。训练时损失函数选 MSE 就可以但要注意对热图做高斯模糊标注而不是标一个硬点。硬点会让网络输出尖峰在后续argmax找坐标时容易出现像素级抖动高斯热图给出的响应更平滑。3.2 从关键点到单应矩阵拿到关键点之后最核心的一步是把图像坐标系的点映射到标准场地坐标系。网球场的底线、边线分布是固定的只要知道图像里至少四个对应点就能用cv2.findHomography算出单应矩阵 H。# homography.py用关键点计算单应矩阵并校正坐标 import cv2 import numpy as np # 标准球场坐标系单位英尺 std_pts np.array([ [0.0, 0.0], # 左下角 [36.0, 0.0], # 右下角 [36.0, 39.0], # 右上角 [0.0, 39.0] # 左上角 ], dtypenp.float32) # 图像中检测到的对应关键点由热图argmax得到 img_pts np.array([ [120, 450], [520, 448], [518, 160], [125, 162] ], dtypenp.float32) H, status cv2.findHomography(img_pts, std_pts, cv2.RANSAC, 3.0)RANSAC 的 3.0 是重投影误差阈值单位是像素这个值控制了内点筛选的严格程度。关键点定位的抖动如果在 2 像素以内阈值给 3 不会误伤如果模型训练得一般抖动达到 5 像素以上建议放大到 5.0否则后续测速时每帧的坐标会突然跳变。算出 H 之后任何图像坐标都可以用cv2.perspectiveTransform转到标准坐标系从而计算真实距离。3.3 标注自己的关键点训练集项目里没有直接给出关键点标注的原始标注文件只有几张 jpg 样例。如果你要复现并落地到其他球场需要自己标注。工具上我用 labelme导出 JSON 后转成热图。标注的关键点位置选择很有讲究不要只标四个角点最好把两条发球线和网带交点也标上因为网球比赛大多数击球落在发球区附近有这些内点约束单应矩阵的求解会更稳。# labelme_json2heatmap.py把关键点坐标转成高斯热图 import numpy as np import cv2 def generate_heatmap(img_h, img_w, points, sigma3): # points: [(x, y), ...] heatmaps [] for (x, y) in points: hm np.zeros((img_h, img_w), dtypenp.float32) if x 0 or y 0: heatmaps.append(hm) continue # 高斯核覆盖范围为3倍sigma d int(3 * sigma) xx, yy np.meshgrid(np.arange(max(0, x-d), min(img_w, xd1)), np.arange(max(0, y-d), min(img_h, yd1))) hm[yy, xx] np.exp(-((xx - x)**2 (yy - y)**2) / (2 * sigma**2)) heatmaps.append(hm) return np.stack(heatmaps, axis0)sigma 取 3 到 5 比较合适。sigma 越小热图越尖锐训练时网络越难收敛sigma 太大坐标定位的精度会下降。实际训练时可以对热图做随机旋转和平移增强因为不同场地的拍摄角度差异很大。值得注意的是热图输出分辨率如果比原图小比如 1/4 或 1/8那么到实际坐标映射时要把argmax的结果放大相应倍数否则关键点位置会系统性偏移。4. 从逐帧检测到指标计算速度、击球次数与轨迹平滑4.1 多目标跟踪从检测框到轨迹IOU匹配要计算球员速度首先得区分哪几个框属于同一名球员。最简单的做法是基于 IoU 的贪心匹配虽然不像 DeepSORT 那样带外观特征但在网球这种运动员数量固定的场景中已经够用。核心逻辑是上一帧的跟踪框和当前帧检测框计算 IoU超过 0.3 就认为是同一个目标否则开启新轨迹。# tracker.py基于IoU的轻量级目标追踪 class IoUTracker: def __init__(self, iou_threshold0.3): self.tracks {} # id - [bbox] self.next_id 0 self.iou_threshold iou_threshold def update(self, detections): matched {} used set() for tid, prev_box in self.tracks.items(): best_iou, best_det 0, None for i, det in enumerate(detections): if i in used: continue iou self._iou(prev_box, det) if iou best_iou: best_iou, best_det iou, i if best_det is not None and best_iou self.iou_threshold: matched[tid] detections[best_det] used.add(best_det) # 未匹配的检测框创建新轨迹 for i, det in enumerate(detections): if i not in used: self.tracks[self.next_id] det matched[self.next_id] det self.next_id 1 # 更新轨迹 self.tracks {tid: matched[tid] for tid in matched} return matched这段跟踪代码里没有做卡尔曼滤波所以轨迹没有预测能力。如果球飞行的过程中有遮挡检测框消失后再出现IoU 匹配会失败轨迹会断成两条。此时要么引入卡尔曼做运动预测要么使用 ByteTrack 这种基于低置信度框二次匹配的算法。对于球员速度测量断轨迹影响不大但对于击球次数球轨迹断裂会直接丢计数所以球跟踪必须额外做插值。实际评估跟踪质量时我还习惯计算多目标跟踪的中心点偏差和 ID Switch 次数。这个项目里不需要生成 MOTChallenge 标准结果但我会在调参时统计同一轨迹的检测框数量是否连续如果轨迹断裂过多就调低 IoU 阈值或加入运动预测。4.2 击球次数判定球速突变与接近检测击球本质上是个瞬态事件可以从球轨迹的突变来识别。正手击球瞬间球的方向会在 1-2 帧内发生明显反转同时球和球员的距离极小。因此我常用两个条件联合判定球在连续两帧的位移方向夹角大于 60 度且球到任一球员腰部的距离小于 2 米。# shot_count.py基于轨迹突变和球员距离统计击球次数 import numpy as np def count_shots(ball_track, player_boxes): shots 0 for i in range(1, len(ball_track) - 1): p0 np.array(ball_track[i - 1]) p1 np.array(ball_track[i]) p2 np.array(ball_track[i 1]) v1 p1 - p0 v2 p2 - p1 if np.linalg.norm(v1) 0 or np.linalg.norm(v2) 0: continue cos_angle np.dot(v1, v2) / (np.linalg.norm(v1) * np.linalg.norm(v2)) angle np.arccos(np.clip(cos_angle, -1, 1)) # 判定击球方向变化超过60度且离球员近 if angle np.deg2rad(60): for box in player_boxes: player_center ((box[0] box[2]) / 2, (box[1] box[3]) / 2) dist np.linalg.norm(p1 - np.array(player_center)) if dist 120: # 像素距离根据画面尺寸调整 shots 1 break return shots像素距离 120 并不是通用值。如果摄像头是全景视角整个场地宽度可能只有 600 像素120 像素已经很短如果是跟拍特写球和球员的像素距离会更大。我在实际使用时会先计算球员高度和图片高度的比值动态缩放这个距离阈值比固定值鲁棒得多。另外这个判定逻辑会把球员截击和发球都算进去如果你的指标只统计回合内击球还需要外加一个“球必须落在场地内”的条件。4.3 速度计算与透视校正原始像素坐标不能直接测速必须先通过第 3 章算出的单应矩阵投影到场地坐标系。假设视频是 25 FPS帧间间隔是 0.04 秒那么球员某一时刻的速度可以近似为相邻两帧投影坐标的欧式距离除以时间间隔。# speed.py利用单应矩阵计算球员速度 def compute_speed(projected_track, fps25): speeds [] dt 1.0 / fps for i in range(1, len(projected_track)): p0 projected_track[i - 1] # 标准坐标系下的坐标 p1 projected_track[i] dist np.linalg.norm(p1 - p0) # 单位与标准坐标系标定时一致 speed dist / dt # 每帧速度 speeds.append(speed) return speeds注意dist的单位取决于std_pts定义时用的单位。如果用英尺那么算出来的速度是英尺每秒转成公里每小时要乘 1.09728。更合理的做法是把标准球场坐标直接定义成米网球场长 23.77 米、宽 8.23 米这样速度单位就是米每秒观众更容易理解。实战中我会额外用移动平均窗口对速度曲线平滑窗口大小 5 帧否则逐帧的检测噪声会带来明显的锯齿。速度曲线的峰值可以用于评估球员爆发力短期均值则用来衡量跑动强度。5. 把源码跑起来环境配置、推理调参与iframe级坑5.1 Win10 Python 3.9.7 的环境搭建项目源码是在 Win10 和 Python 3.9.7 下开发的不建议用 Python 3.12 直接跑因为部分深度依赖的预编译轮子在 Windows 上还不全。我用到了以下安装顺序能省掉很多编译问题python -m venv tennis_env tennis_env\Scripts\activate pip install torch1.10.0cpu torchvision0.11.0cpu -f https://download.pytorch.org/whl/cpu/torch_stable.html pip install opencv-python4.5.4.60 numpy pandas jupyter matplotlib tqdmWindows 下最麻烦的依赖是pycocotools直接 pip 安装会报 Microsoft Visual C 错误。常见做法是下载预编译的pycocotoolswheel或者用pip install pycocotools-windows这个替代包。如果你使用的是 AMD 显卡YOLOv5 默认的 CUDA 版本无法直接调用可以安装torch-directml包并把推理设备改名为dml。这会牺牲一部分速度但至少能把项目跑通。5.2 三个notebook的分工和运行顺序拿到源码后不要急着从头跑到尾。tennis_ball_detector_training.ipynb负责球的检测训练tennis_court_keypoints_training.ipynb负责球场关键点训练ball_analysis.ipynb才是主流程推理和分析。如果你没有足够的训练数据建议先直接用项目里训练好的权重跑ball_analysis.ipynb确认整个管线的输入输出再回头看训练代码。主流程里的输入是input_video.avi输出是output_video.avi中间会打印每一帧的检测结果。在ball_analysis.ipynb中有一个窗口对检测结果做后验证。我一般会先提取前 100 帧测试运行速度如果处理一帧花了超过 0.1 秒就需要降低输入分辨率或把球检测和关键点检测分到两个进程。项目里没有给出独立的推理脚本但你可以用nbconvert把 notebook 转成.py文件再用argparse封装视频路径参数。5.3 提高小目标检出率的实用技巧球检测是整个分析流程最脆弱的部分跑通之后值得花时间调优。一个立竿见影的方法是提高推理尺寸把 YOLO 的--img从 640 提到 960小目标的感受野会覆盖更多像素漏检率明显下降。代价是推理时间大约翻一倍但测试阶段可以接受。另一个技巧是 TTA也就是测试时增强把原图、缩放、翻转分别推理再综合置信度能进一步提升召回。如果球在快速运动时出现运动模糊可以尝试把视频先做逐帧去交错了。input_video.avi如果采集自电视转播可能带隔行扫描直接用会导致球和球拍边缘出现拖影建议用ffmpeg -i input_video.avi -vf yadif output_progressive.avi预处理后再送入模型。对于关键点网络常见的坑是训练时热图尺寸和原图不一致导致坐标偏移。务必在tennis_court_keypoints_training.ipynb里检查热图标签的下采样比例并在推理时把argmax坐标乘上对应的 stride。如果发现底线检测不稳定可以把关键点数量从 4 调整为 6增加发球线中点约束单应矩阵的 RANSAC 迭代次数也相应提高。最终把ball_analysis.ipynb中conf_thres设到 0.15、iou_thres设到 0.5并用 RANSAC 阈值 3.0这套组合在我重放视频时击球计数的准确率能到 85% 以上。本文还有配套的精品资源点击获取