用 M4 Pro 在 Mac 上跑通羽毛球视频 AI 分析:从 0% 检测率到 95%(踩坑实录)
一段普通的羽毛球比赛视频,输出运动员轨迹、移动速度、累计距离、球的飞行轨迹。完整代码、模型权重、样本视频都在仓库里,clone 下来 5 分钟跑通。
一、为什么写这篇
最近想做一个"把比赛视频变成数据分析"的小项目。GitHub 上搜了一圈,发现现有方案大致三类:
-
只检测球,没有球员分析
-
假设俯视机位(真实比赛画面几乎都是斜拍)
-
写死 Windows 路径,Mac 跑不通
更糟的是,**就算选了一个看起来最完整的方案,照着 README 跑,球检测率是 0% **。原作者代码里写死的某个阈值,在大多数真实视频上根本不工作。
这篇是把整条 pipeline 跑通的工程实录,重点写** 4 个我踩的真实坑**。代码全部开源,你们可以直接复用。
二、整体架构
整套系统分三段,串成一条链路:
原始视频.mp4
│
▼ Step 1: TrackNet ─── 球检测(连续 4 帧热力图模型)
带球轨迹的视频 + 球坐标 CSV
│
▼ Step 2: Overlay ──── YOLOv8s-pose + ByteTrack + 透视矫正
叠加分析的视频
│
▼ Step 3: FX ────────── 子弹时间冻帧 + 慢动作
最终成品视频
为什么拆三段:球和球员是两个完全不同的检测问题(小目标 vs 大目标),用的模型不一样;几何分析必须先把斜拍画面拉成俯视图;特效跟模型无关,分开后改一次特效不用重跑模型。
技术栈:
-
TrackNet:连续 4 帧输入,输出热力图,专门追小目标快速运动
-
YOLOv8s-pose:球员检测 + 17 个人体关键点(用脚踝定位"足点")
-
ByteTrack:跨帧维持球员 ID 不串
-
OpenCV Homography:把画面里梯形球场拉成米制俯视图
三、踩坑全记录
坑 1:TrackNet 默认阈值在真实视频上 0% 检测率
这是最隐蔽的坑。代码里有这么一行:
# scripts/tracknet_runtime/predict.py:35
y_pred = y_pred > 0.5
模型输出 heatmap 后,凡是像素值 > 0.5 的算"有球"。看起来很合理。
实际跑下来,30 秒视频 630 帧里,Visibility 字段 100% 是 0。一个球都没检测到。
写个诊断脚本去看模型实际输出:
# 加载模型 + 跑 4 帧 + 打印 heatmap 最大值
# 4 个不同时间点的采样:
start=50: y max=0.3155 per-frame max=[0.291, 0.300, 0.248, 0.315] >0.5: 0/4
start=200: y max=0.2845 per-frame max=[0.276, 0.267, 0.234, 0.284] >0.5: 0/4
start=350: y max=0.2511 per-frame max=[0.220, 0.226, 0.209, 0.251] >0.5: 0/4
start=500: y max=0.3373 per-frame max=[0.335, 0.324, 0.252, 0.337] >0.5: 0/4
模型实际响应区间是 0.20 - 0.34,永远过不了 0.5 这条线。
为什么:这个 TrackNet checkpoint 是在更高分辨率的训练集上调出来的。我手上的视频是 960×544(很多比赛流媒体压缩后的常见尺寸),球被 resize 成几个像素后,模型有响应但置信度不够。
修法:把硬编码改成环境变量:
# predict.py:35
import os
thresh = float(os.environ.get("TRACKNET_VIS_THRESH", "0.2"))
y_pred = y_pred > thresh
跑命令时:
TRACKNET_VIS_THRESH=0.15 python3 predict.py ...
效果:
| 阈值 | 检出率 | 误检风险 |
|---|---|---|
| 0.50(原版) | 0% | 0 |
| 0.20 | ~80% | 极低 |
| 0.15 | 94.9% | 低 |
| 0.10 | ~98% | 中等 |
实测 0.15 在 short.mp4 上跑出 598/630 = 94.9%,球漏检的那些帧基本是球被网柱遮挡或飞出画面,本来就该是 invisible。
经验:任何含有"硬编码阈值"的预训练模型,迁移到新视频前都要先做一次 heatmap 输出强度的诊断。我把诊断脚本也放进了仓库 scripts/tools/diag_tracknet.py。
坑 2:球员最高速度显示 24 m/s(比博尔特还快)
修完球检测,跑通了完整 pipeline。看输出视频,左侧统计面板出现:
下半场:
当前速度: 0.12 m/s
回合最高: 24.58 m/s ← 离谱
总距离: 28.29 m
24 m/s = 86 km/h。世界冠军级羽毛球运动员极限冲刺约 7 m/s。
去翻代码,找到这段:
# overlay_player_analytics.py
distance = euclidean(pt_m, self.prev_m)
if distance <= 1.2: # 米
inst_speed = distance / dt
self.rally_max_speed = max(self.rally_max_speed, inst_speed)
逻辑是"两帧之间位移 > 1.2 米就当 ID 串了不算"。
问题:21 fps 视频的 dt = 0.048 秒。1.2 米 / 0.048 秒 = 25 m/s。这阈值放行的速度上限刚好就是博尔特速度。
ByteTrack 偶尔会串一帧 ID(球员 A 的位置突然跳到球员 B 那里),位移 0.5-1.0 米的串变也被认为是"真实运动",被记进 rally_max_speed。
修法:阈值不能写死,得跟 dt 挂钩。
# 8 m/s 已经留了点余量给极端冲刺
max_jump_m = 8.0 * dt + 0.05
if distance <= max_jump_m:
inst_speed = distance / dt
修完前后对比:
| 指标 | 修前(1.2m 固定) | 修后(8×dt+0.05) |
|---|---|---|
| 上半场最高速度 | 16 m/s | 9 m/s |
| 下半场最高速度 | 24 m/s | 9 m/s |
| 总距离 | 28.29 m(含虚假累加) | 22.52 m(合理) |
经验:跨帧速度计算的"跳变阈值"必须按帧间隔自适应。同样的逻辑也适用于卡尔曼滤波的过程噪声参数。
坑 3:macOS 上中文渲染成方块
第一次跑出来的视频,左侧统计面板的中文标题全是方块。
代码里字体回退表:
FONT_CANDIDATES = [
r"C:\Windows\Fonts\msyh.ttc", # Windows 微软雅黑
r"C:\Windows\Fonts\msyhbd.ttc",
r"C:\Windows\Fonts\simhei.ttf",
]
这位作者一看就是只在 Windows 上跑过。
修法:加 macOS 字体路径:
FONT_CANDIDATES = [
r"C:\Windows\Fonts\msyh.ttc",
r"C:\Windows\Fonts\msyhbd.ttc",
r"C:\Windows\Fonts\simhei.ttf",
"/System/Library/Fonts/PingFang.ttc", # macOS 苹方
"/System/Library/Fonts/STHeiti Medium.ttc",
"/Library/Fonts/Arial Unicode.ttf",
"/usr/share/fonts/truetype/wqy/wqy-microhei.ttc", # Linux 文泉驿
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
]
按系统优先级匹配。
坑 4:球场角点必须人工标
理论上能用 Hough 变换检测球场白线,再求交点自动算 4 个角。实际:
-
真实比赛底线常被广告板遮挡
-
不同机位畸变不一
-
角点偏 5 像素能让球员速度翻倍(透视矫正放大误差)
最朴素的方案最稳:写一个 OpenCV 鼠标事件脚本,让用户在第一帧上点 4 下,输出像素坐标。
# scripts/tools/select_court.py 核心代码
def on_mouse(event, x, y, flags, param):
if event == cv2.EVENT_LBUTTONDOWN and len(pts) < 4:
pts.append((x, y))
print(f" point {len(pts)}: ({x}, {y})")
cv2.namedWindow(win)
cv2.setMouseCallback(win, on_mouse)
# ...画十字辅助线 + 标 TL/TR/BR/BL 提示
跑一次,按 TL → TR → BR → BL 顺序点 4 下,复制输出的字符串到 overlay 命令的 --court_points 参数。固定机位的视频可以复用同一组数字。
四、最终效果
完整画面:
左侧 4 项统计(从原版 7 项砍下来的,剔除了三项相关性高的):
右上角 Mini Court 俯视轨迹(黄=上半场,粉=下半场,青=球):
五、自己跑
1. 克隆(含 LFS 大文件)
git lfs install # 没装的话先 brew install git-lfs
git clone https://github.com/ychenfen/badminton-pipeline-repro.git
cd badminton-pipeline-repro
仓库里已经包含模型权重(150 MB,走 LFS)和 30 秒样本视频。
2. 装依赖
python3 -m pip install --user --index-url https://pypi.org/simple \
numpy opencv-python pandas Pillow torch ultralytics tqdm \
pycocotools parse lap
pycocotools / parse / lap 是原 requirements.txt 漏列的隐藏依赖。
3. 标球场角点
python3 scripts/tools/select_court.py short.mp4
按提示点 4 下,复制输出的坐标。
4. 一键跑通
TRACKNET_VIS_THRESH=0.15 ./run_all_mac.sh \
--input-video short.mp4 \
--court-points "352,342,628,343,944,527,52,532" \
--yolo-device mps
--yolo-device mps 让 YOLOv8 走 M 系列芯片 GPU。30 秒视频跑全链路约 10 分钟,瓶颈在 TrackNet(CPU only,已列入后续优化)。
六、性能数据(M4 Pro)
| 阶段 | CPU | MPS |
|---|---|---|
| TrackNet(13344 帧全长视频) | ~3 小时 | 暂不支持 |
| Overlay(球员检测) | ~30 分钟 | ~10 分钟 |
| FX(子弹时间) | ~5 分钟 | 同上 |
最大瓶颈是 TrackNet 没接 MPS,已经列入后续工作。
七、还能做什么
仓库里有一份 1500 行的 HANDOVER.md 交接文档,列了 12 个详细任务(P0-P3),每个都按"背景 / 目标 / 步骤 / 涉及文件 / 验证 / 已知陷阱"格式写好,AI agent(Codex / Cursor / Claude Code)可以直接挑一个从那里起步。
最近想推的几项:
-
TrackNet 上 MPS 后端:目前最大瓶颈,做完全长视频从 3 小时压到 30 分钟
-
击球点检测:找球速度方向反向的拐点,自动分回合 + 高亮闪烁
-
球轨迹卡尔曼补漏:把 5% 漏检的帧用前后插值补上
如果你也对这类项目感兴趣,欢迎提 Issue 或 PR。
八、致谢
-
TrackNet 模型来自 TrackNetV3
-
YOLOv8 来自 Ultralytics
-
跟踪算法 ByteTrack
-
样本视频 YouTube 频道 POGBADMINTON
详细文档:HANDOVER.md (含完整原理 + AI agent 任务包)
如果觉得有用点个 star 就是最好的支持。有问题在仓库 Issue 区或评论区交流。