用 M4 Pro 在 Mac 上跑通羽毛球视频 AI 分析:从 0% 检测率到 95%(踩坑实录)

0 阅读7分钟

用 M4 Pro 在 Mac 上跑通羽毛球视频 AI 分析:从 0% 检测率到 95%(踩坑实录)

仓库地址:github.com/ychenfen/ba…

一段普通的羽毛球比赛视频,输出运动员轨迹、移动速度、累计距离、球的飞行轨迹。完整代码、模型权重、样本视频都在仓库里,clone 下来 5 分钟跑通。

demo

一、为什么写这篇

最近想做一个"把比赛视频变成数据分析"的小项目。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.1594.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/s9 m/s
下半场最高速度24 m/s9 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 参数。固定机位的视频可以复用同一组数字。

court corners

四、最终效果

完整画面:

full overlay

左侧 4 项统计(从原版 7 项砍下来的,剔除了三项相关性高的):

panel

右上角 Mini Court 俯视轨迹(黄=上半场,粉=下半场,青=球):

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)

阶段CPUMPS
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。

八、致谢


仓库地址github.com/ychenfen/ba…

详细文档HANDOVER.md (含完整原理 + AI agent 任务包)

如果觉得有用点个 star 就是最好的支持。有问题在仓库 Issue 区或评论区交流。