Seguimiento de jugadores de tiro cero en tenis con filtrado de Kalman | de Derek Austin | enero de 2025

Seguimiento automatizado de tenis sin etiquetas: GroundingDINO, filtrado de Kalman y homografía de la cancha

Seguimiento de tiro cero de un punto de tenis completo. Vídeo proporcionado bajo licencia MIT y animación creada por el autor.

Con el reciente aumento de proyectos de seguimiento deportivo, muchos inspirados por El popular proyecto de seguimiento del fútbol de Skalskiha habido un cambio notable hacia el uso de seguimiento automatizado de jugadores para los aficionados al deporte. La mayoría de estos enfoques siguen un flujo de trabajo familiar: recopilar datos etiquetados, entrenar un modelo YOLO, proyectar las coordenadas de los jugadores en una vista aérea del campo o cancha y utilizar estos datos de seguimiento para generar análisis avanzados para obtener información competitiva potencial. Sin embargo, en este proyecto, proporcionamos las herramientas para evitar la necesidad de datos etiquetados, confiando en su lugar en las capacidades de seguimiento de disparo cero de GroundingDINO en combinación con una implementación de filtro Kalman para superar las salidas ruidosas de GroundingDino.

Nuestro conjunto de datos se origina a partir de un conjunto de transmitir vídeosdisponible públicamente bajo una licencia del MIT gracias a Hayden Faulkner y su equipo.¹ Estos datos incluyen imágenes de varios partidos de tenis durante los Juegos Olímpicos de 2012 en Wimbledon; nos centramos en un partido entre Serena Williams y Victoria Azarenka.

Un punto encontrado entre Serena Williams y Victoria Azarenka. El vídeo se hace público bajo una licencia MIT.

GroundingDINO, para aquellos que no están familiarizados, fusiona la detección de objetos con el lenguaje, lo que permite a los usuarios proporcionar un mensaje como “un jugador de tenis”, que luego lleva al modelo a devolver cuadros de detección de objetos candidatos que se ajustan a la descripción. RoboFlow tiene un gran tutorial aquí para aquellos interesados ​​en usarlo, pero también he pegado un código muy básico a continuación. Como se ve a continuación, puede solicitar al modelo que identifique objetos que rara vez se etiquetarían en un conjunto de datos de detección de objetos, como la lengua de un perro.

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
)

Salida de GroundingDino cuando se le solicita “Perro” y “Lengua de perro”. La imagen es propiedad del autor.

Sin embargo, distinguir a los jugadores en una cancha de tenis profesional no es tan simple como indicar “tenistas”. El modelo a menudo identifica erróneamente a otras personas en la cancha, como jueces de línea, encargados de la pelota y otros árbitros, lo que provoca anotaciones nerviosas e inconsistentes. Además, a veces el modelo ni siquiera detecta a los jugadores en ciertos cuadros, lo que genera espacios y cuadros no persistentes que no aparecen de manera confiable en cada cuadro.

El seguimiento selecciona a una persona de línea en el primer ejemplo y a una persona de pelota en el segundo. Imagen realizada por el autor.

Para abordar estos desafíos, aplicamos algunos métodos específicos. Primero, reducimos los cuadros de detección a solo las tres probabilidades principales de todos los cuadros posibles. A menudo, los jueces de línea tienen una puntuación de probabilidad más alta que los jugadores, razón por la cual no filtramos solo a dos casillas. Sin embargo, esto plantea una nueva pregunta: ¿cómo podemos distinguir automáticamente a los jugadores de los jueces de línea en cada cuadro?

Observamos que las casillas de detección para el personal de línea y de pelota suelen tener períodos de tiempo más cortos y, a menudo, duran solo unos pocos fotogramas. Basándonos en esto, planteamos la hipótesis de que al asociar cuadros en fotogramas consecutivos, podríamos filtrar a las personas que sólo aparecen brevemente, aislando así a los jugadores.

Entonces, ¿cómo logramos este tipo de asociación entre objetos a través de fotogramas? Afortunadamente, el campo del seguimiento de objetos múltiples ha estudiado ampliamente este problema. Los filtros de Kalman son un pilar del seguimiento de múltiples objetos, a menudo combinados con otras métricas de identificación, como el color. Para nuestros propósitos, una implementación básica del filtro Kalman es suficiente. En términos simples (para una inmersión más profunda, consulte esto artículo out), un filtro de Kalman es un método para estimar probabilísticamente la posición de un objeto basándose en mediciones anteriores. Es particularmente efectivo con datos ruidosos, pero también funciona bien al asociar objetos a lo largo del tiempo en videos, incluso cuando las detecciones son inconsistentes, como cuando no se rastrea a un jugador en cada cuadro. Implementamos todo un filtro de Kalman aquí pero describiremos algunos de los pasos principales en los siguientes párrafos.

Un estado de filtro de Kalman para 2 dimensiones es bastante simple, como se muestra a continuación. Todo lo que tenemos que hacer es realizar un seguimiento de la ubicación xey, así como de la velocidad de los objetos en ambas direcciones (ignoramos la aceleración).

class KalmanStateVector2D:
x: float
y: float
vx: float
vy: float

El filtro de Kalman opera en dos pasos: primero predice la ubicación de un objeto en el siguiente cuadro y luego actualiza esta predicción en función de una nueva medición, en nuestro caso, del detector de objetos. Sin embargo, en nuestro ejemplo, un nuevo cuadro podría tener múltiples objetos nuevos, o incluso podría eliminar objetos que estaban presentes en el cuadro anterior, lo que lleva a la pregunta de cómo podemos asociar cuadros que hemos visto anteriormente con los que se ven actualmente.

Elegimos hacer esto utilizando la distancia de Mahalanobis, junto con una prueba de chi-cuadrado, para evaluar la probabilidad de que una detección actual coincida con un objeto pasado. Además, mantenemos una cola de objetos pasados ​​para tener una “memoria” más larga que solo un fotograma. En concreto, nuestra memoria almacena la trayectoria de cualquier objeto visto en los últimos 30 fotogramas. Luego, para cada objeto que encontramos en un nuevo cuadro, iteramos sobre nuestra memoria y encontramos que el objeto anterior con mayor probabilidad coincide con el actual dado por la probabilidad dada por la distancia de Mahalanbois. Sin embargo, es posible que también estemos viendo un objeto completamente nuevo, en cuyo caso deberíamos agregar un objeto nuevo a nuestra memoria. Si algún objeto tiene <30% de probabilidad de estar asociado con cualquier cuadro en nuestra memoria, lo agregamos a nuestra memoria como un nuevo objeto.

Proporcionamos nuestro filtro Kalman completo a continuación para aquellos que prefieren el código.

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)

Habiendo rastreado cada objeto detectado durante los últimos 30 fotogramas, ahora podemos diseñar heurísticas para identificar qué cuadros representan con mayor probabilidad a nuestros jugadores. Probamos dos enfoques: seleccionar las casillas más cercanas al centro de la línea de base y elegir aquellas con la historia observada más larga en nuestra memoria. Empíricamente, la primera estrategia a menudo señalaba a los jueces de línea como jugadores cada vez que el jugador real se alejaba de la línea de fondo, lo que la hacía menos confiable. Mientras tanto, notamos que GroundingDino tiende a “variar” entre diferentes jueces de línea y personas con pelota, mientras que los jugadores genuinos mantienen una presencia algo estable. Como resultado, nuestra regla final es elegir las casillas en nuestra memoria con el historial de seguimiento más largo como los verdaderos jugadores. Como puedes ver en el vídeo inicial, ¡es sorprendentemente efectivo para una regla tan simple!

Con nuestro sistema de seguimiento ahora establecido en la imagen, podemos avanzar hacia un análisis más tradicional rastreando a los jugadores desde una perspectiva aérea. Este punto de vista permite evaluar métricas clave, como la distancia total recorrida, la velocidad de los jugadores y las tendencias de posicionamiento en la cancha. Por ejemplo, podríamos analizar si un jugador apunta con frecuencia al revés de su oponente según la ubicación durante un punto. Para lograr esto, necesitamos proyectar las coordenadas del jugador desde la imagen en una plantilla de cancha estandarizada vista desde arriba, alineando la perspectiva para el análisis espacial.

Aquí es donde entra en juego la homografía. La homografía describe el mapeo entre dos superficies, lo que, en nuestro caso, significa mapear los puntos de nuestra imagen original en una vista aérea de la cancha. Al identificar algunos puntos clave en la imagen original, como las intersecciones de líneas en una cancha, podemos calcular una matriz de homografía que traduce cualquier punto a una vista de pájaro. Para crear esta matriz de homografía, primero debemos identificar estos “puntos clave”. Varios modelos de código abierto con licencia permisiva en plataformas como RoboFlow pueden ayudar a detectar estos puntos, o podemos etiquetarlos nosotros mismos en una imagen de referencia para usar en la transformación.

Como puede ver, los puntos clave predichos no son perfectos, pero encontramos que los pequeños errores no afectan mucho la matriz de transformación final.

Después de etiquetar estos puntos clave, el siguiente paso es relacionarlos con los puntos correspondientes en una imagen de corte de referencia para generar una matriz de homografía. ¡Usando OpenCV, podemos crear esta matriz de transformación con unas pocas líneas de código simples!

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)

Con la matriz de homografía en mano, podemos mapear cualquier punto de nuestra imagen en el campo de referencia. Para este proyecto, nuestro foco está en la posición del jugador en la cancha. Para determinar esto, tomamos el punto medio en la base del cuadro delimitador de cada jugador, usándolo como su ubicación en la cancha a vista de pájaro.

Usamos el punto medio en la parte inferior del cuadro para mapear dónde se encuentra cada jugador en la cancha. La ilustración muestra el punto clave trasladado a la cancha de tenis visto a vista de pájaro con nuestra matriz de homografía.

En resumen, este proyecto demuestra cómo podemos utilizar las capacidades de tiro cero de GroundingDINO para rastrear a los jugadores de tenis sin depender de datos etiquetados, transformando la detección de objetos complejos en un seguimiento de jugadores procesable. Al abordar desafíos clave, como distinguir a los jugadores del resto del personal en la cancha, garantizar un seguimiento consistente en todos los fotogramas y mapear los movimientos de los jugadores a vista de pájaro de la cancha, hemos sentado las bases para un sistema de seguimiento sólido, todo sin el Necesidad de etiquetas explícitas.

Este enfoque no solo desbloquea información como la distancia recorrida, la velocidad y el posicionamiento, sino que también abre la puerta a análisis de partidos más profundos, como la focalización de tiros y la cobertura estratégica de la cancha. Con un mayor perfeccionamiento, incluida la destilación de un modelo YOLO o RT-DETR a partir de los resultados de GroundingDINO, podríamos incluso desarrollar un sistema de seguimiento en tiempo real que rivalice con las soluciones comerciales existentes, proporcionando una herramienta poderosa tanto para el entrenamiento como para la participación de los fanáticos en el mundo del tenis.