近年来,随着体育追踪项目的兴起,越来越多的体育爱好者开始使用自动化运动员追踪技术。大多数方法遵循一个常见的工作流程:收集标注数据,训练YOLO模型,将运动员坐标投影到场地或球场的俯视图中,并利用这些追踪数据生成高级分析,以获取潜在的竞技洞察。然而,在本项目中,我们提供了一种工具,可以绕过对标注数据的需求,转而依赖GroundingDINO的无监督追踪能力,并结合Kalman滤波来克服GroundingDINO输出的噪声问题。
我们的数据集来源于一组公开的广播视频,视频链接:https://github.com/HaydenFaulkner/Tennis。这些数据包括2012年温布尔登奥运会期间多场网球比赛的录像,我们重点关注了塞雷娜·威廉姆斯(Serena Williams)和维多利亚·阿扎伦卡(Victoria Azarenka)之间的一场比赛。
对于不熟悉GroundingDINO的人来说,它将目标检测与语言相结合,允许用户输入提示,例如“一个网球运动员”,模型随后会返回符合描述的候选目标检测框。RoboFlow提供了一个很好的教程,供有兴趣使用的人参考——但我在下面也粘贴了一些非常基础的代码。如下所示,你可以通过提示让模型识别一些在目标检测数据集中很少被标记的对象,比如狗狗和狗的舌头。
from groundingdino.util.inference import load_model, load_image, predict, annotate
BOX_TRESHOLD = 0.35
TEXT_TRESHOLD = 0.25
# processes the image to GroundingDino standards
image_source, image = load_image("dog.jpg")
prompt = "dog tongue, dog"
boxes, logits, phrases = predict(
model=model,
image=image,
caption=TEXT_PROMPT,
box_threshold=BOX_TRESHOLD,
text_threshold=TEXT_TRESHOLD
)
然而,在职业网球场上区分运动员并不像提示“网球运动员”那么简单。模型经常会错误识别场上的其他人员,例如线审、球童和其他裁判,导致标注跳跃且不一致。此外,模型有时甚至无法在某些帧中检测到运动员,导致标注框出现空白或无法持续出现在每一帧中。
追踪在第一例中捕捉到线审,在第二例中捕捉到球童。图片由作者制作
为了解决这些挑战,我们应用了几种有针对性的方法。首先,我们将检测框缩小到所有可能框中的前三个概率最高的框。通常,线审的概率得分高于运动员,这就是为什么我们不只过滤到两个框。然而,这引发了一个新问题:如何在每一帧中自动区分运动员和线审?
我们观察到,线和球工作人员的检测框通常持续时间较短,往往只持续几帧。基于此,我们假设通过关联连续帧中的框,可以过滤掉那些只短暂出现的人员,从而隔离出运动员。
那么,我们如何实现跨帧对象之间的这种关联呢?幸运的是,多目标追踪领域已经对这个问题进行了广泛研究。Kalman滤波器是多目标追踪中的主要工具,通常与其他识别指标(如颜色)结合使用。对于我们的目的,一个基本的Kalman滤波器实现就足够了。简单来说(更深入的探讨可以参考这篇文章),Kalman滤波器是一种基于先前测量结果概率估计对象位置的方法。它在处理噪声数据时特别有效,但也适用于在视频中跨时间关联对象,即使检测不一致(例如运动员未被每一帧追踪到)。我们在这里实现了一个完整的Kalman滤波器,但将在接下来的段落中介绍一些主要步骤。
二维Kalman滤波器的状态非常简单,如下所示。我们只需要跟踪x和y位置以及对象在两个方向上的速度(忽略加速度)。
class KalmanStateVector2D:
x: float
y: float
vx: float
vy: float
Kalman滤波器分为两个步骤:首先预测对象在下一帧中的位置,然后根据新的测量结果(在我们的案例中来自目标检测器)更新预测。然而,在我们的示例中,新帧可能会有多个新对象,甚至可能会丢失前一帧中存在的对象,这就引出了如何将之前看到的框与当前看到的框关联起来的问题。
我们选择使用马氏距离(Mahalanobis distance)结合卡方检验来评估当前检测与过去对象匹配的概率。此外,我们保留了一个过去对象的队列,以便拥有比一帧更长的“记忆”。具体来说,我们的记忆存储了过去30帧中看到的任何对象的轨迹。然后,对于我们在新帧中找到的每个对象,我们遍历我们的记忆,找到最可能与当前对象匹配的先前对象,匹配概率由马氏距离给出。然而,我们也可能看到一个全新的对象,在这种情况下,我们应该将一个新对象添加到我们的记忆中。如果任何对象与记忆中的任何框的关联概率小于30%,我们将其作为新对象添加到记忆中。
完整的Kalman滤波器如下:
from dataclasses import dataclass
import numpy as np
from scipy import stats
class KalmanStateVectorNDAdaptiveQ:
states: np.ndarray # for 2 dimensions these are [x, y, vx, vy]
cov: np.ndarray # 4x4 covariance matrix
def __init__(self, states: np.ndarray) -> None:
self.state_matrix = states
self.q = np.eye(self.state_matrix.shape[0])
self.cov = None
# assumes a single step transition
self.f = np.eye(self.state_matrix.shape[0])
# divide by 2 as we have a velocity for each state
index = self.state_matrix.shape[0] // 2
self.f[:index, index:] = np.eye(index)
def initialize_covariance(self, noise_std: float) -> None:
self.cov = np.eye(self.state_matrix.shape[0]) * noise_std**2
def predict_next_state(self, dt: float) -> None:
self.state_matrix = self.f @ self.state_matrix
self.predict_next_covariance(dt)
def predict_next_covariance(self, dt: float) -> None:
self.cov = self.f @ self.cov @ self.f.T + self.q
def __add__(self, other: np.ndarray) -> np.ndarray:
return self.state_matrix + other
def update_q(
self, innovation: np.ndarray, kalman_gain: np.ndarray, alpha: float = 0.98
) -> None:
innovation = innovation.reshape(-1, 1)
self.q = (
alpha * self.q
+ (1 - alpha) * kalman_gain @ innovation @ innovation.T @ kalman_gain.T
)
class KalmanNDTrackerAdaptiveQ:
def __init__(
self,
state: KalmanStateVectorNDAdaptiveQ,
R: float, # R
Q: float, # Q
h: np.ndarray = None,
) -> None:
self.state = state
self.state.initialize_covariance(Q)
self.predicted_state = None
self.previous_states = []
self.h = np.eye(self.state.state_matrix.shape[0]) if h is None else h
self.R = np.eye(self.h.shape[0]) * R**2
self.previous_measurements = []
self.previous_measurements.append(
(self.h @ self.state.state_matrix).reshape(-1, 1)
)
def predict(self, dt: float) -> None:
self.previous_states.append(self.state)
self.state.predict_next_state(dt)
def update_covariance(self, gain: np.ndarray) -> None:
self.state.cov -= gain @ self.h @ self.state.cov
def update(
self, measurement: np.ndarray, dt: float = 1, predict: bool = True
) -> None:
"""Measurement will be a x, y position"""
self.previous_measurements.append(measurement)
assert dt == 1, "Only single step transitions are supported due to F matrix"
if predict:
self.predict(dt=dt)
innovation = measurement - self.h @ self.state.state_matrix
gain_invertible = self.h @ self.state.cov @ self.h.T + self.R
gain_inverse = np.linalg.inv(gain_invertible)
gain = self.state.cov @ self.h.T @ gain_inverse
new_state = self.state.state_matrix + gain @ innovation
self.update_covariance(gain)
self.state.update_q(innovation, gain)
self.state.state_matrix = new_state
def compute_mahalanobis_distance(self, measurement: np.ndarray) -> float:
innovation = measurement - self.h @ self.state.state_matrix
return np.sqrt(
innovation.T
@ np.linalg.inv(
self.h @ self.state.cov @ self.h.T + self.R
)
@ innovation
)
def compute_p_value(self, distance: float) -> float:
return 1 - stats.chi2.cdf(distance, df=self.h.shape[0])
def compute_p_value_from_measurement(self, measurement: np.ndarray) -> float:
"""Returns the probability that the measurement is consistent with the predicted state"""
distance = self.compute_mahalanobis_distance(measurement)
return self.compute_p_value(distance)
在追踪了过去30帧中检测到的每个对象后,我们现在可以设计启发式方法来精确定位哪些框最有可能代表我们的运动员。我们测试了两种方法:选择最靠近底线中心的框,以及选择在我们记忆中观察历史最长的框。经验上,第一种策略在实际运动员远离底线时经常将线审标记为运动员,使其不太可靠。与此同时,我们注意到GroundingDino往往在不同的线审和球童之间“闪烁”,而真正的运动员则保持相对稳定的存在。因此,我们的最终规则是选择记忆中追踪历史最长的框作为真正的运动员。正如你在初始视频中看到的,对于如此简单的规则来说,它的效果出奇地好!
现在,我们的追踪系统已经在图像上建立,我们可以转向更传统的分析,从鸟瞰视角追踪运动员。这种视角可以评估关键指标,例如总移动距离、运动员速度和球场位置趋势。例如,我们可以分析运动员是否经常根据比赛中的位置针对对手的反手。为了实现这一点,我们需要将运动员坐标从图像投影到标准化的球场模板上,从上方对齐视角以进行空间分析。
这就是单应性变换(homography)发挥作用的地方。单应性描述了两个表面之间的映射关系,在我们的案例中,这意味着将原始图像中的点映射到俯视的球场视图。通过在原始图像中识别一些关键点(例如球场上的线交叉点),我们可以计算一个单应性矩阵,将任何点转换为鸟瞰图。为了创建这个单应性矩阵,我们首先需要识别这些“关键点”。RoboFlow等平台上的各种开源、许可宽松的模型可以帮助检测这些点,或者我们可以在参考图像上手动标记它们以用于变换。
正如你所看到的,预测的关键点并不完美,但我们发现小的误差对最终的变换矩阵影响不大
在标记这些关键点后,下一步是将它们与参考球场图像上的对应点匹配,以生成单应性矩阵。使用OpenCV,我们可以用几行简单的代码创建这个变换矩阵:
import numpy as np
import cv2
# order of the points matters
source = np.array(keypoints) # (n, 2) matrix
target = np.array(court_coords) # (n, 2) matrix
m, _ = cv2.findHomography(source, target)
有了单应性矩阵,我们可以将图像中的任何点映射到参考球场上。对于这个项目,我们的重点是运动员在球场上的位置。为了确定这一点,我们取每个运动员边界框底部的中心点,将其作为他们在鸟瞰图中的球场位置。
我们使用框底部的中心点来映射每个运动员在球场上的位置。图示显示了通过我们的单应性矩阵将关键点转换为鸟瞰图中的网球球场
总之,本项目展示了如何利用GroundingDINO的无监督能力来追踪网球运动员,而无需依赖标注数据,将复杂的目标检测转化为可操作的运动员追踪。通过解决关键挑战——例如区分运动员与其他场上人员、确保跨帧的一致追踪以及将运动员运动映射到球场的鸟瞰图——我们为无需显式标签的稳健追踪管道奠定了基础。
这种方法不仅解锁了移动距离、速度和位置等洞察,还为更深入的比赛分析(如击球目标和战略覆盖)打开了大门。通过进一步改进,包括从GroundingDINO输出中提炼YOLO或RT-DETR模型,我们甚至可以开发出与现有商业解决方案相媲美的实时追踪系统,为网球世界的教练和球迷参与提供强大的工具。