Women's Football Webinar: Dedicated Data, Smarter Decisions | 12 OctSign Up
LATEST NEWS
All News & Analysis

SkillCorner Open Data #5: Visualización de datos de tracking en el fútbol

.

En nuestra serie Open Data, hemos mostrado cómo se pueden utilizar las métricas agregadas físicas y de Game Intelligence para comparar jugadores y crear perfiles, y también hemos visto cómo visualizar acciones individuales mediante los datos de Dynamic Events.

En esta ocasión, incorporamos los datos de tracking XY.

El tracking es la base sobre la que se construyen todos nuestros datos, y combinarlo con Dynamic Events ofrece posibilidades de análisis y visualización prácticamente infinitas. 

Como punto de partida, nos centraremos en cómo esta combinación aporta todo el contexto del campo y de los jugadores, lo que permite visualizar de forma estática o animada las acciones de los jugadores y los movimientos que las acompañan.

Nuestra publicación gratuita de la temporada 2024/25 de la A-League australiana incluye 10 partidos con datos de tracking y de Dynamic Events que pueden combinarse de esta forma.

Los partidos, y sus ID correspondientes, son:

  • Auckland FC 2 - 0 Newcastle (30/11/2024): 1886347
  • Auckland FC 2 - 1 Wellington Phoenix (07/12/2024): 1899585
  • Brisbane Roar 0 - 1 Perth Glory (21/12/2024): 1925299
  • Central Coast Mariners 1 - 1 Melbourne City (31/12/2024): 1953632
  • Sydney FC 4 - 1 Adelaide United (01/02/2025): 1996435
  • Melbourne City 2 - 0 Macarthur FC (07/03/2025): 2006229
  • Wellington Phoenix 2 - 3 Melbourne Victory (12/04/2025): 2011166
  • Western United 1 - 0 Sydney FC (27/04/2025): 2013725
  • Western United 4 - 2 Auckland FC (03/05/2025): 2015213
  • Melbourne Victory 0 - 1 Auckland FC (17/05/2025): 2017461

Sigue el código que aparece a continuación para descubrir cómo combinar, manipular y visualizar ambos conjuntos de datos.

‍

0 - Instalar librerías


Antes de nada, necesitamos instalar e importar las librerías necesarias y definir nuestra paleta de colores para las visualizaciones.

# Instalar librerías
!pip install numpy pandas mplsoccer skillcorner skillcornerviz
 
import os
from io import BytesIO
import pandas as pd
import json
import numpy as np
import requests
 
# Configurar el campo y el gráfico
from mplsoccer import Pitch ,VerticalPitch
 
#Definir la paleta de colores para la visualización
default_palette=[
   "#252525",
   "#00a82f", #  Verde
   "#32fe6b", #  Lima
   "#7acbff", #  Azur pastel
   "#2388c8", #  Azur
   "#064c62"  #  Azur oscuro
]


Ya estamos listos para empezar.

1 - Cargar los datos de tracking

Nuestro primer paso es cargar el archivo de tracking de un partido concreto.

Tomamos como ejemplo el encuentro del 30/11/2024 entre el Auckland FC y el Newcastle, pero puedes seleccionar fácilmente otro partido introduciendo el match_id correspondiente (indicado en la lista anterior) donde se señala en el código.

# Usar match_id para identificar el archivo correcto | Por defecto: Auckland FC 2 - 0 Newcastle (30/11/2024)
match_id = 1886347
 
# URL del archivo de datos de tracking, actualizada automáticamente según el match_id indicado
url = f"https://github.com/SkillCorner/opendata/raw/refs/heads/master/data/matches/{match_id}/{match_id}_tracking_extrapolated.jsonl"
 
raw_data=pd.read_json(url,lines=True)
 
raw_df = pd.json_normalize(
   raw_data.to_dict("records"),
   "player_data",
   ["frame", "timestamp", "period", "possession", "ball_data"],
)
 
# Extraer 'player_id' y 'group' del diccionario 'possession'
raw_df["possession_player_id"] = raw_df["possession"].apply(
   lambda x: x.get("player_id")
)
raw_df["possession_group"] = raw_df["possession"].apply(lambda x: x.get("group"))
 
# Expandir ball_data con json_normalize
raw_df[["ball_x", "ball_y", "ball_z", "is_detected_ball"]] = pd.json_normalize(
   raw_df.ball_data
)
 
# Añadir el identificador match_id al dataframe
raw_df["match_id"] = match_id
tracking_df = raw_df.copy()
tracking_df.head()

La última línea del código mostrará las primeras filas del dataframe para comprobar que el resultado es correcto y que hemos importado con éxito los datos de tracking.

2 - Cargar los metadatos del partido

A continuación, necesitamos cargar los metadatos del partido elegido y crear un dataframe con los datos de todos los jugadores participantes y la portería hacia la que atacaba su equipo en cada parte.

#match_id se mantiene del bloque de código anterior, pero puedes modificarlo o definirlo aquí si lo ejecutas por separado
#match_id = 1886347
 
#Definir la URL del archivo de metadatos
metadata_url=f"https://raw.githubusercontent.com/SkillCorner/opendata/refs/heads/master/data/matches/{match_id}/{match_id}_match.json"
 
response = requests.get(metadata_url)
raw_match_data = response.json()
 
# El resultado contiene elementos json anidados. Los procesamos
raw_match_df = pd.json_normalize(raw_match_data, max_level=2)
raw_match_df["home_team_side"] = raw_match_df["home_team_side"].astype(str)
 
#Crear un dataframe de jugadores
players_df = pd.json_normalize(
   raw_match_df.to_dict("records"),
   record_path="players",
   meta=[
       "home_team_score",
       "away_team_score",
       "date_time",
       "home_team_side",
       "home_team.name",
       "home_team.id",
       "away_team.name",
       "away_team.id",
   ],  # datos que conservamos
)
 
#Definir una función para convertir el tiempo en segundos
def time_to_seconds(time_str):
   if time_str is None:
       return 90 * 60  # 120 minutos = 7200 segundos
   h, m, s = map(int, time_str.split(':'))
   return h * 3600 + m * 60 + s
 
# Quedarse solo con los jugadores que jugaron y calcular su tiempo total
players_df = players_df[
   ~((players_df.start_time.isna()) & (players_df.end_time.isna()))
]
players_df["total_time"] = players_df["end_time"].apply(time_to_seconds) - players_df[
   "start_time"
].apply(time_to_seconds)
 
# Crear un indicador para el portero
players_df["is_gk"] = players_df["player_role.acronym"] == "GK"
 
# Añadir el nombre del partido
players_df["match_name"] = (
   players_df["home_team.name"] + " vs " + players_df["away_team.name"]
)
 
# Añadir un indicador de si el jugador es local o visitante
players_df["home_away_player"] = np.where(
   players_df.team_id == players_df["home_team.id"], "Home", "Away"
)
 
# Crear un indicador a partir del jugador
players_df["team_name"] = np.where(
   players_df.team_id == players_df["home_team.id"],
   players_df["home_team.name"],
   players_df["away_team.name"],
)
 
# Determinar los lados del campo
players_df[["home_team_side_1st_half", "home_team_side_2nd_half"]] = (
   players_df["home_team_side"]
   .astype(str)
   .str.strip("[]")
   .str.replace("'", "")
   .str.split(", ", expand=True)
)
 
# Limpiar los lados del campo
players_df["direction_player_1st_half"] = np.where(
   players_df.home_away_player == "Home",
   players_df.home_team_side_1st_half,
   players_df.home_team_side_2nd_half,
)
players_df["direction_player_2nd_half"] = np.where(
   players_df.home_away_player == "Home",
   players_df.home_team_side_2nd_half,
   players_df.home_team_side_1st_half,
)
 
# Limpiar y conservar las columnas que necesitamos para el análisis
columns_to_keep = [
   "start_time",
   "end_time",
   "match_name",
   "date_time",
   "home_team.name",
   "away_team.name",
   "id",
   "short_name",
   "number",
   "team_id",
   "team_name",
   "player_role.position_group",
   "total_time",
   "player_role.name",
   "player_role.acronym",
   "is_gk",
   "direction_player_1st_half",
   "direction_player_2nd_half",
   "home_away_player"
]
players_df = players_df[columns_to_keep]
players_df.head()

De nuevo, la última línea muestra las primeras filas del dataframe para comprobar el resultado.tput.

3 - Combinar los datos de tracking y los metadatos

A continuación, combinamos el dataframe de tracking con la información relevante del de metadatos y ajustamos las coordenadas según la fases con y sin balón y las dos partes del partido.

# 1. Combinar el tracking y los metadatos de los jugadores
temp_df = tracking_df.merge(players_df, left_on="player_id", right_on="id")
 
# 2. Asignar la dirección en función del periodo actual
# Utiliza el periodo como índice para elegir entre las columnas de la 1.ª y la 2.ª parte
temp_df['current_direction'] = np.where(
   temp_df['period'] == 1,
   temp_df['direction_player_1st_half'],
   temp_df['direction_player_2nd_half']
)
 
# 3. Determinar el estado de posesión
# Simplificamos la comprobación booleana haciendo coincidir las etiquetas de los equipos
is_home_poss = (temp_df['possession_group'] == 'home team') & (temp_df['home_away_player'] == 'Home')
is_away_poss = (temp_df['possession_group'] == 'away team') & (temp_df['home_away_player'] == 'Away')
temp_df['in_possession'] = is_home_poss | is_away_poss
 
# 4. Crear la máscara de inversión
# Invertimos si:
# (Ataca de derecha a izquierda Y tiene la posesión) O (Defiende de izquierda a derecha Y no tiene la posesión)
flip_mask = (
   ((temp_df['current_direction'] == 'right_to_left') & temp_df['in_possession']) |
   ((temp_df['current_direction'] == 'left_to_right') & ~temp_df['in_possession'])
)
 
# 5. Aplicar la inversión de coordenadas
# Recorrer las columnas existentes evita bloques 'np.where' repetitivos
coords = [c for c in ['x', 'y', 'ball_x', 'ball_y'] if c in temp_df.columns]
for col in coords:
   temp_df[col] = np.where(flip_mask, -temp_df[col], temp_df[col])
 
enriched_tracking_data = temp_df

4 - Cargar Dynamic Events e identificar las acciones deseadas


Ahora cargamos el archivo de Dynamic Events del partido elegido.

#match_id se mantiene del bloque de código anterior, pero puedes modificarlo o definirlo aquí si lo ejecutas por separado
#match_id = 1886347
 
de_match = pd.read_csv(f"https://raw.githubusercontent.com/SkillCorner/opendata/refs/heads/master/data/matches/{match_id}/{match_id}_dynamic_events.csv")
 
de_match.head()

Una vez hecho esto, filtramos los eventos que nos interesa analizar. En este caso, buscamos carreras sin balón en las que el jugador recibió el balón con éxito.

Aquí, las ordenamos por distancia recorrida para identificar las carreras que probablemente resulten interesantes para visualizar, aunque, lógicamente, un scout o analista aplicaría un filtro más elaborado en función del objeto concreto de su análisis.

#Identificar los desmarques sin balón más largos del partido
de_match[(de_match.event_type == "off_ball_run") & (de_match.received == True)][
   [
       "player_id",
       "player_name",
       "team_shortname",
       "event_id",
       "frame_start",
       "frame_end",
       "event_type",
       "event_subtype",
       "received",
       "team_id",
       "player_in_possession_id",
       "x_start",
       "y_start",
       "x_end",
       "y_end",
       "distance_covered"
   ]
 
].sort_values(by="distance_covered", ascending=False)


Para simplificar, seleccionamos como objeto de nuestras visualizaciones la carrera más larga que recibió un pase (event_id == "1_133"): un desdoblamiento por dentro (underlap) de Thomas Aquilina, del Newcastle.

5 - Visualización estática de los datos de tracking

En primer lugar, crearemos una visualización estática de la carrera sin balón, que muestra la trayectoria de la carrera desde el punto de inicio (círculo más pequeño) hasta el final (círculo sin borde). El equipo atacante aparecerá en verde y el equipo defensor en azul. El jugador que realizó el pase aparecerá resaltado en un verde más claro.

Este es el código:

#Filtrar los fotogramas relevantes del desmarque con recepción más largo a partir del archivo de Dynamic Events
 
specific_event = de_match[de_match.event_id == "1_133"][
   [
       "player_id",
       "frame_start",
       "frame_end",
       "event_type",
       "team_id",
       "player_in_possession_id",
       "x_start",
       "y_start",
       "x_end",
       "y_end",
   ]
]
 
synced = specific_event.merge(
   enriched_tracking_data,
   left_on=["frame_end"],
   right_on="frame",
   suffixes=("_event", "_tracking"),
)
 
synced["runner"] = synced.player_id_event == synced.player_id_tracking
synced["ball_carrier"] = synced.player_in_possession_id == synced.player_id_tracking
synced["tip"] = synced.team_id_event == synced.team_id_tracking
 
synced["runner"] = synced.player_id_event == synced.player_id_tracking
synced["ball_carrier"] = synced.player_in_possession_id == synced.player_id_tracking
synced["tip"] = synced.team_id_event == synced.team_id_tracking
 
pitch = Pitch(
   pitch_type="skillcorner",
   line_alpha=0.75,
   pitch_length=105,
   pitch_width=68,
   pitch_color="#212121",
   line_color="white",
   linewidth=1.5,
)
fig, ax = pitch.grid(figheight=8, endnote_height=0, title_height=0)
 
size = 300
possession_team = synced[synced.tip == True]
ax.scatter(
   possession_team["x"],
   possession_team["y"],
   c="#00A82F",
   alpha=0.95,
   s=size,
   edgecolors="white",
   linewidths=1.5,
   zorder=10,
   label="team",
)
 
out_of_possession_team = synced[synced.tip == False]
ax.scatter(
   out_of_possession_team["x"],
   out_of_possession_team["y"],
   c="#0288D1",
   alpha=0.95,
   s=size,
   edgecolors="white",
   linewidths=1,
   zorder=10,
   label="team",
)
 
runner = synced[synced.runner == True]
ax.scatter(
   runner["x"],
   runner["y"],
   c="#00A82F",
   alpha=0.95,
   s=size,
   edgecolors="#00A82F",
   linewidths=2.5,
   zorder=10,
   label="team",
)
 
# Desmarque
ax.plot(
   [runner["x_start"], runner["x"]],
   [runner["y_start"], runner["y"]],
   color="#00A82F",
   linewidth=2,
   ls="--",
)
 
ax.scatter(
   runner["x_start"],
   runner["y_start"],
   c="#00A82F",
   alpha=0.55,
   s=size / 2,
   edgecolors="#00A82F",
   linewidths=2.5,
   zorder=10,
   label="team",
)
 
ball_carrier = synced[synced.ball_carrier == True]
ax.scatter(
   ball_carrier["x"],
   ball_carrier["y"],
   c="#00E676",
   alpha=0.95,
   s=size,
   edgecolors="#00E676",
   linewidths=2.5,
   zorder=10,
   label="team",
)

Y este es el resultado:

‍

Ya podemos ver que representar la posición de todos los demás jugadores en el campo en el momento del pase nos aporta un contexto adicional muy útil frente a mostrar únicamente la trayectoria de la carrera.

Y esto es solo un punto de partida. Sería relativamente sencillo añadir información adicional, como los nombres de los jugadores, un valor de velocidad media u otros jugadores identificados en nuestros datos como opción de pase (Passing Option) en el momento del pase.

6 - Visualización animada de los datos de tracking


Pero para obtener una imagen aún más completa del contexto, podemos crear una visualización animada de la carrera que muestre no solo su trayectoria, sino también los movimientos del resto de los jugadores, tanto atacantes como defensores.

Utilizamos las mismas referencias visuales para el poseedor del balón y los equipos atacante y defensor, y esta vez resaltamos al jugador que realiza la carrera sin balón con un borde blanco.

Este es el código:

# Seleccionar un desmarque concreto para animarlo -- de nuevo elegimos el mismo, el desmarque con recepción más largo
event_id = "1_133"
specific_event = de_match[de_match.event_id == event_id].iloc[0]
 
# Obtener el rango de fotogramas
start_frame = specific_event['frame_start']
end_frame = specific_event['frame_end']
 
# Filtrar los datos de tracking
event_tracking_data = enriched_tracking_data[
   (enriched_tracking_data['frame'] >= start_frame) &
   (enriched_tracking_data['frame'] <= end_frame)
].copy()
 
# Sincronizar los metadatos
event_tracking_data['runner'] = specific_event['player_id'] == event_tracking_data['player_id']
event_tracking_data['ball_carrier'] = specific_event['player_in_possession_id'] == event_tracking_data['player_id']
event_tracking_data['tip'] = specific_event['team_id'] == event_tracking_data['team_id']
 
# --- Lógica de la animación ---
from matplotlib.animation import FuncAnimation
from IPython.display import HTML
 
frames = sorted(event_tracking_data['frame'].unique())
 
pitch = Pitch(
   pitch_type="skillcorner",
   line_alpha=0.75,
   pitch_length=105,
   pitch_width=68,
   pitch_color=default_palette[0],
   line_color='white',
   linewidth=1.5,
)
fig, ax = pitch.grid(figheight=8, endnote_height=0, title_height=0)
 
# FORZAR LOS LÍMITES para garantizar la coherencia de las coordenadas
ax.set_xlim(-52.5, 52.5)
ax.set_ylim(-34, 34)
 
size = 300
 
# Obtener la posición inicial real a partir de los datos de tracking para garantizar una alineación perfecta
runner_start_row = event_tracking_data[(event_tracking_data['frame'] == start_frame) &
                                        (event_tracking_data['player_id'] == specific_event['player_id'])]
 
if not runner_start_row.empty:
   runner_start_x = runner_start_row.iloc[0]['x']
   runner_start_y = runner_start_row.iloc[0]['y']
else:
   runner_start_x = specific_event['x_start']
   runner_start_y = specific_event['y_start']
 
# Inicializar los gráficos
# Usar colores coherentes: el equipo en posesión siempre es palette[1]
possession_scatter = ax.scatter([], [], c=default_palette[1], alpha=0.95, s=size, edgecolors=default_palette[1], linewidths=1.5, zorder=10, label="Team In Possession")
out_possession_scatter = ax.scatter([], [], c=default_palette[4], alpha=0.95, s=size, edgecolors=default_palette[4], linewidths=1, zorder=10, label="Team Out of Possession")
runner_scatter = ax.scatter([], [], c=default_palette[1], alpha=0.95, s=size, edgecolors="white", linewidths=2, zorder=10, label="Runner")
runner_start_scatter = ax.scatter([runner_start_x], [runner_start_y], c=default_palette[1], alpha=0.55, s=size/2, edgecolors=default_palette[1], linewidths=2, zorder=10, label="Run Origin")
runner_line, = ax.plot([], [], color=default_palette[1], linewidth=2, ls="--")
ball_carrier_scatter = ax.scatter([], [], c=default_palette[2], alpha=0.95, s=size, edgecolors=default_palette[2], linewidths=2.5, zorder=10, label="Ball Carrier")
ball_scatter = ax.scatter([], [], c="white", alpha=0.95, s=size/3, edgecolors="black", linewidths=1.5, zorder=15, label="Ball")
 
pitch.draw(ax=ax)
 
def update(frame):
   current_data = event_tracking_data[event_tracking_data['frame'] == frame]
 
   # Actualizar posiciones (los datos de tracking ya están sincronizados con el CSV del partido)
   pos_data = current_data[current_data['tip'] == True]
   if len(pos_data) > 0:
       possession_scatter.set_offsets(pos_data[['x', 'y']].values)
   else:
       possession_scatter.set_offsets(np.empty((0, 2)))
 
   out_pos_data = current_data[current_data['tip'] == False]
   if len(out_pos_data) > 0:
       out_possession_scatter.set_offsets(out_pos_data[['x', 'y']].values)
   else:
       out_possession_scatter.set_offsets(np.empty((0, 2)))
 
   run_data = current_data[current_data['runner'] == True]
   if len(run_data) > 0:
       runner_scatter.set_offsets(run_data[['x', 'y']].values)
       # Conectar el marcador de inicio (CSV) con la posición actual (tracking)
       runner_line.set_data([runner_start_x, run_data['x'].values[0]], [runner_start_y, run_data['y'].values[0]])
   else:
       runner_scatter.set_offsets(np.empty((0, 2)))
       runner_line.set_data([], [])
 
   bc_data = current_data[current_data['ball_carrier'] == True]
   if len(bc_data) > 0:
       ball_carrier_scatter.set_offsets(bc_data[['x', 'y']].values)
   else:
       ball_carrier_scatter.set_offsets(np.empty((0, 2)))
 
   if len(current_data) > 0 and pd.notnull(current_data['ball_x'].iloc[0]):
       ball_scatter.set_offsets(np.array([[current_data['ball_x'].iloc[0], current_data['ball_y'].iloc[0]]]))
   else:
       ball_scatter.set_offsets(np.empty((0, 2)))
 
   return possession_scatter, out_possession_scatter, runner_scatter, runner_line, ball_carrier_scatter, ball_scatter
 
anim = FuncAnimation(fig, update, frames=frames, interval=100, blit=True)
import matplotlib.pyplot as plt
plt.close(fig)
print(f"Animation ready for {event_id} (Synced: {len(frames)} frames)")
HTML(anim.to_jshtml())

‍

Y este es el resultado animado.

‍

Enseguida se aprecia el valor de animar la secuencia para obtener una visión más completa del contexto de la carrera y de su relación con el resto de jugadores. Se ve claramente la aceleración explosiva en cuanto surge la oportunidad del desdoblamiento por dentro, así como las reacciones del equipo defensor. 

Una lista de reproducción de acciones similares aportaría, como mínimo, ideas para nuevas líneas de investigación.

La visualización animada puede exportarse como vídeo.

#Exportar la animación como archivo MP4
 
from google.colab import files
from matplotlib.animation import FFMpegWriter
 
#Definir la configuración del writer
writer = FFMpegWriter(fps=10, metadata=dict(artist="SkillCorner"), bitrate=1800)
 
#Guardar la animación en el entorno de Colab
anim.save("tracking_animation.mp4", writer=writer)
 
#Iniciar la descarga en el navegador a tu equipo local
files.download("tracking_animation.mp4")


O como GIF.

#Exportar la animación como GIF
 
from matplotlib.animation import PillowWriter
 
#Guardar directamente con el motor Pillow integrado
anim.save("tracking_animation.gif", writer=PillowWriter(fps=10))
 
files.download("tracking_animation.gif")

7 - Ve más allá


Afinar y añadir información contextual adicional a las visualizaciones estáticas o animadas sería una continuación lógica del código presentado aquí, al igual que desplazar el foco hacia eventos con balón, como conducciones o pases, o incluso hacia secuencias de juego. Para esos casos de uso, la especificación de Dynamic Events ofrece un marco de referencia fundamental.

Pero también conviene señalar que, aunque en este caso el objetivo final era la visualización, la posibilidad de conectar los frames de tracking con los datos de Dynamic Events abre numerosas oportunidades para analizar o modelizar la ocupación espacial y las relaciones entre jugadores en el campo.

El código completo de este artículo está disponible en un notebook de Google Colab para que puedas adaptarlo fácilmente.

Siempre te animamos a compartir tu trabajo en redes sociales. Si lo haces, cita a SkillCorner como fuente de los datos y etiqueta nuestra cuenta correspondiente en X/Twitter o LinkedIn.

Esperamos que hayas disfrutado de este capítulo de nuestra serie Open Data. Permanece atento a nuestras redes sociales para no perderte entregas futuras.

Artículos Relacionados

Descubre el verdadero valor de los datos de Tracking

Solicita una demo