Source code for floodlight.models.geometry

import warnings

import numpy as np
from scipy.spatial.distance import cdist
from scipy.spatial import ConvexHull, QhullError
import matplotlib.pyplot as plt

from floodlight import XY
from floodlight.core.property import TeamProperty, PlayerProperty
from floodlight.models.base import BaseModel, requires_fit


[docs] class CentroidModel(BaseModel): """Computations based on the geometric center of all players, commonly referred to as a team's *centroid*. Upon calling the :func:`~CentroidModel.fit`-method, this model calculates a team's centroid. The following calculations can subsequently be queried by calling the corresponding methods: - Centroid [1]_ --> :func:`~CentroidModel.centroid` - Centroid Distance --> :func:`~CentroidModel.centroid_distance` - Stretch Index [2]_ --> :func:`~CentroidModel.stretch_index` Notes ----- Team centroids are computed as the arithmetic mean of all player positions (based on *numpy*'s nanmean function). For a fixed point in time and :math:`N` players with corresponding positions :math:`x_1, \\dots, x_N \\in \\mathbb{R}^2`, the centroid is calculated as .. math:: C = \\frac{1}{N} \\sum_i^N x_i. Examples -------- >>> import numpy as np >>> from floodlight import XY >>> from floodlight.models.geometry import CentroidModel >>> xy = XY(np.array(((1, 1, 2, -2), (1.5, np.nan, np.nan, -0)))) >>> cm = CentroidModel() >>> cm.fit(xy) >>> cm.centroid() XY(xy=array([[ 1.5, -0.5], [ 1.5, 0. ]]), framerate=None, direction=None) >>> cm.stretch_index(xy) TeamProperty(property=array([1.5811388, nan]), name='stretch_index', framerate=None) >>> cm.stretch_index(xy, axis='x') TeamProperty(property=array([0.5, 0.]), name='stretch_index', framerate=None) References ---------- .. [1] `Sampaio, J., & Maçãs, V. (2012). Measuring tactical behaviour in football. International Journal of Sports Medicine, 33(05), 395-401. <https://www.thieme-connect.de/products/ejournals/abstract/10.1055/s-0031-13 01320>`_ .. [2] `Bourbousson, J., Sève, C., & McGarry, T. (2010). Space–time coordination dynamics in basketball: Part 2. The interaction between the two teams. Journal of Sports Sciences, 28(3), 349-358. <https://www.tandfonline.com/doi/full/10.1080/02640410903503640>`_ """ def __init__(self): super().__init__() # model parameter self._centroid_ = None
[docs] def fit(self, xy: XY, exclude_xIDs: list = None): """Fit the model to the given data and calculate team centroids. Parameters ---------- xy: XY Player spatiotemporal data for which the centroid is calculated. exclude_xIDs: list, optional A list of xIDs to be excluded from computation. This can be useful if one would like, for example, to exclude goalkeepers from analysis. """ if not exclude_xIDs: exclude_xIDs = [] # boolean for column inclusion, initialize to True for all columns include = np.full((xy.N * 2), True) # exclude columns according to exclude_xIDs for xID in exclude_xIDs: if xID not in range(0, xy.N): raise ValueError( f"Expected entries of exclude_xIDs to be in range 0 to {xy.N}, " f"got {xID}." ) exclude_start = xID * 2 exclude_end = exclude_start + 2 include[exclude_start:exclude_end] = False with warnings.catch_warnings(): # suppress warnings caused by empty slices warnings.filterwarnings("ignore", category=RuntimeWarning) # calculate centroid centroids = np.nanmean(xy.xy[:, include].reshape((len(xy), -1, 2)), axis=1) # wrap as XY object self._centroid_ = XY( xy=centroids, framerate=xy.framerate, direction=xy.direction )
[docs] @requires_fit def centroid(self) -> XY: """Returns the team centroid positions as computed by the fit method. Returns ------- centroid: XY An XY object of shape (T, 2), where T is the total number of frames. The two columns contain the centroids' x- and y-coordinates, respectively. """ return self._centroid_
[docs] @requires_fit def centroid_distance(self, xy: XY, axis: str = None) -> PlayerProperty: """Calculates the Euclidean distance of each player to the fitted centroids. Parameters ---------- xy: XY Player spatiotemporal data for which the distances to the fitted centroids are calculated. axis: {None, 'x', 'y'}, optional Optional argument that restricts distance calculation to either the x- or y-dimension of the data. If set to None (default), distances are calculated in both dimensions. Returns ------- centroid_distance: PlayerProperty A PlayerProperty object of shape (T, N), where T is the total number of frames. Each column contains the distances to the team centroid of the player with corresponding xID. """ # check matching lengths T = len(self._centroid_) if len(xy) != T: raise ValueError( f"Length of xy ({len(xy)}) does not match length of fitted centroids " f"({T})." ) # calculate distances on specified axis distances = np.full((T, xy.N), np.nan) if axis is None: for t in range(T): distances[t] = cdist( self._centroid_[t].reshape(-1, 2), xy[t].reshape(-1, 2) ) elif axis == "x": for t in range(T): distances[t] = cdist( self._centroid_.x[t].reshape(-1, 1), xy.x[t].reshape(-1, 1) ) elif axis == "y": for t in range(T): distances[t] = cdist( self._centroid_.y[t].reshape(-1, 1), xy.y[t].reshape(-1, 1) ) else: raise ValueError( f"Expected axis to be one of (None, 'x', 'y'), got {axis}." ) # wrap as PlayerProperty centroid_distance = PlayerProperty( property=distances, name="centroid_distance", framerate=xy.framerate, ) return centroid_distance
[docs] @requires_fit def stretch_index(self, xy: XY, axis: str = None) -> TeamProperty: """Calculates the *Stretch Index*, i.e., the mean distance of all players to the team centroid. Parameters ---------- xy: XY Player spatiotemporal data for which the stretch index is calculated. axis: {None, 'x', 'y'}, optional Optional argument that restricts stretch index calculation to either the x- or y-dimension of the data. If set to None (default), the stretch index is calculated in both dimensions. Returns ------- stretch_index: TeamProperty A TeamProperty object of shape (T,), where T is the total number of frames. Each entry contains the stretch index of that particular frame. """ # get player distances from centroid centroid_distances = self.centroid_distance(xy=xy, axis=axis) with warnings.catch_warnings(): # suppress warnings caused by empty slices warnings.filterwarnings("ignore", category=RuntimeWarning) # calculate stretch index stretch_index = np.nanmean(centroid_distances.property, axis=1) # wrap as TeamProperty object stretch_index = TeamProperty( property=stretch_index, name="stretch_index", framerate=xy.framerate ) return stretch_index
[docs] class NearestMateModel(BaseModel): """Computations for within-team distance metrics. Upon calling the :func:`~NearestMateModel.fit`-method, this model calculates pairwise distances between all players for each frame. The following calculations can subsequently be queried by calling the corresponding methods: - Distance to Nearest Mate --> :func:`~NearestMateModel.distance_to_nearest_mate` - Team Spread [5]_ --> :func:`~NearestMateModel.team_spread` Notes ----- The calculations are performed as follows: - *Distance to Nearest Mate (DTNM)*: For each player in each frame, the Euclidean distance to their nearest teammate is computed. - *Team Spread*: The Frobenius norm of the lower triangular matrix of all pairwise player distances, representing the overall dispersion of the team. Examples -------- >>> import numpy as np >>> from floodlight import XY >>> from floodlight.models.geometry import NearestMateModel >>> xy = XY(np.array(((1, 1, 2, -2), (1.5, np.nan, np.nan, -0)))) >>> nmm = NearestMateModel() >>> nmm.fit(xy) >>> dtnm = nmm.distance_to_nearest_mate() >>> dtnm PlayerProperty(property=array([[3.16227766, 3.16227766], [nan, nan]]), name='distance_to_nearest_mate', framerate=None) >>> dtnm.property.mean(axis=1) # Mean distance per frame array([3.16227766, nan]) >>> nmm.team_spread() TeamProperty(property=array([3.16227766, nan]), name='team_spread', framerate=None) References ---------- .. [5] `Bartlett, R., Button, C., Robins, M., Dutt-Mazumder, A., & Kennedy, G. (2012). Analysing team coordination patterns from player movement trajectories in soccer: Methodological considerations. International Journal of Performance Analysis in Sport, 12(2), 398-424. <https://www.tandfonline.com/doi/abs/10.1080/24748668.2012.11868607>`_ """ def __init__(self): super().__init__() self._pairwise_distances_ = None self._framerate = None
[docs] def fit(self, xy: XY): """Fit the model to the given data and calculate pairwise distances. Parameters ---------- xy: XY Player spatiotemporal data for which the pairwise distances are calculated. """ self._framerate = xy.framerate # Initialize distance array with fixed shape (T, N, N) self._pairwise_distances_ = np.full((len(xy), xy.N, xy.N), np.nan) for t in range(len(xy)): # Get coordinates for all players coords = xy.frame(t).reshape(-1, 2) # Calculate pairwise distances (NaN propagates naturally) self._pairwise_distances_[t] = cdist(coords, coords) # Set diagonal to NaN (self-distances are meaningless) np.fill_diagonal(self._pairwise_distances_[t], np.nan)
[docs] @requires_fit def distance_to_nearest_mate(self) -> PlayerProperty: """Calculates the distance to the nearest teammate for each player. Returns ------- distance_to_nearest_mate: PlayerProperty A PlayerProperty object of shape (T, N), where T is the total number of frames and N is the number of players. Each entry contains the distance to the nearest teammate for that player in that frame. """ T, N, _ = self._pairwise_distances_.shape dtnm = np.full((T, N), np.nan) with warnings.catch_warnings(): # suppress warnings caused by all-NaN slices warnings.filterwarnings("ignore", category=RuntimeWarning) for t in range(T): # Calculate minimum distance per player (NaN values ignored) dtnm[t] = np.nanmin(self._pairwise_distances_[t], axis=1) return PlayerProperty( property=dtnm, name="distance_to_nearest_mate", framerate=self._framerate, )
[docs] @requires_fit def team_spread(self) -> TeamProperty: """Calculates the team spread (Frobenius norm of distance matrix). Returns ------- spread: TeamProperty A TeamProperty object of shape (T,), where T is the total number of frames. Each entry contains the team spread (Frobenius norm of the pairwise distance matrix) for that frame. """ T = len(self._pairwise_distances_) spread = np.full(T, np.nan) for t in range(T): distances = self._pairwise_distances_[t] # Check if all distances are NaN (no valid players) if np.isnan(distances).all(): continue # Calculate Frobenius norm of lower triangular matrix # Replace NaN with 0 (missing distances don't contribute) spread[t] = np.linalg.norm(np.nan_to_num(np.tril(distances)), ord="fro") return TeamProperty( property=spread, name="team_spread", framerate=self._framerate )
[docs] class NearestOpponentModel(BaseModel): """Computations for between-team distance metrics. Upon calling the :func:`~NearestOpponentModel.fit`-method, this model calculates pairwise distances between players of opposing teams. The following calculations can subsequently be queried: - Distance to Nearest Opponent [6]_ --> :func:`~NearestOpponentModel.distance_to_nearest_opponent` Notes ----- For each player in each frame, the Euclidean distance to their nearest opponent is computed. References ---------- .. [6] `Gonçalves, B., Marcelino, R., Torres-Ronda, L., Torrents, C., & Sampaio, J. (2016). Effects of emphasising opposition and cooperation on collective movement behaviour during football small-sided games. Journal of sports sciences, 34(14), 1346-1354. <https://www.tandfonline.com/doi/full/10.1080/02640414.2016.1143111>`_ Examples -------- >>> import numpy as np >>> from floodlight import XY >>> from floodlight.models.geometry import NearestOpponentModel >>> xy1 = XY(np.array(((1, 1, 2, -2), (1.5, np.nan, np.nan, -0)))) >>> xy2 = XY(np.array(((2, 2, -1, -1), (0.5, np.nan, np.nan, 1)))) >>> nom = NearestOpponentModel() >>> nom.fit(xy1, xy2) >>> dtno1, dtno2 = nom.distance_to_nearest_opponent() >>> dtno1 PlayerProperty(property=array([[1.41421356, 3.16227766], [nan, nan]]), name='distance_to_nearest_opponent', framerate=None) >>> dtno1.property.mean(axis=1) # Mean distance per frame array([2.28824561, nan]) """ def __init__(self): super().__init__() self._pairwise_distances_ = None self._framerate = None
[docs] def fit(self, xy1: XY, xy2: XY): """Fit the model to the given data and calculate pairwise distances. Parameters ---------- xy1: XY Player spatiotemporal data for the first team. xy2: XY Player spatiotemporal data for the second team. """ self._framerate = xy1.framerate # Initialize distance array with fixed shape (T, N1, N2) self._pairwise_distances_ = np.full((len(xy1), xy1.N, xy2.N), np.nan) for t in range(len(xy1)): # Get coordinates for all players (NaN values preserved) coords1 = xy1.frame(t).reshape(-1, 2) coords2 = xy2.frame(t).reshape(-1, 2) # Calculate pairwise distances (NaN propagates naturally) self._pairwise_distances_[t] = cdist(coords1, coords2)
[docs] @requires_fit def distance_to_nearest_opponent( self, ) -> tuple[PlayerProperty, PlayerProperty]: """Calculates distance to nearest opponent for each player on both teams. Returns ------- distance_to_nearest_opponent: tuple[PlayerProperty, PlayerProperty] A tuple of two PlayerProperty objects of shape (T, N) containing distances to nearest opponent for each player in the first and second team for each frame. """ T, N1, N2 = self._pairwise_distances_.shape dtno1 = np.full((T, N1), np.nan) dtno2 = np.full((T, N2), np.nan) with warnings.catch_warnings(): # suppress warnings caused by all-NaN slices warnings.filterwarnings("ignore", category=RuntimeWarning) for t in range(T): distances = self._pairwise_distances_[t] # Calculate minimum distance per player for each team (NaN ignored) dtno1[t] = np.nanmin(distances, axis=1) dtno2[t] = np.nanmin(distances, axis=0) return ( PlayerProperty( property=dtno1, name="distance_to_nearest_opponent", framerate=self._framerate, ), PlayerProperty( property=dtno2, name="distance_to_nearest_opponent", framerate=self._framerate, ), )
[docs] class ConvexHullModel(BaseModel): """Computations based on the convex hull of player positions. Upon calling the :func:`~ConvexHullModel.fit` method, this model calculates convex hull objects for each frame. The following calculations can subsequently be queried by calling the corresponding methods: - Convex Hull Area --> :func:`~ConvexHullModel.convex_hull_area` - Convex Hull Visualization --> :func:`~ConvexHullModel.plot` Notes ----- The convex hull is computed using scipy's `ConvexHull class <https://docs.scipy.org/doc/scipy/reference/generated/scipy.spatial.ConvexHull. html>`_ and can be understood as the minimal convex area containing all (outfield) players. The convex hull is also known in the literature under the terms 'surface area', 'coverage area' and 'playing area' [3]_. When multiple XY objects are provided, all players from all teams are combined and the convex hull encompasses all of them. This is commonly referred to as the *Effective Area of Play (EAP)* [4]_. Examples -------- >>> import numpy as np >>> from floodlight import XY >>> from floodlight.models.geometry import ConvexHullModel Single team convex hull (excluding goalkeeper): >>> xy = XY(np.array([[0, 0, 10, 0, 10, 10, 0, 10], ... [0, 0, 10, 0, 5, 10, 5, 5]])) >>> chm = ConvexHullModel() >>> chm.fit(xy, exclude_xIDs=[[0]]) >>> chull = chm.convex_hull_area() >>> chull TeamProperty(property=array([50., 12.5]), name='convex_hull_area', framerate=None) Effective Area of Play (both teams): >>> xy_home = XY(np.array([[0, 10, 20, 20], [10, 20, 10, 20]])) >>> xy_away = XY(np.array([[40, 50, 60, 60], [50, 70, 50, 60]])) >>> chm = ConvexHullModel() >>> chm.fit([xy_home, xy_away]) >>> eap = chm.convex_hull_area() >>> eap TeamProperty(property=array([400., 200.]), name='convex_hull_area', framerate=None) References ---------- .. [3] `Moura, F. A., Martins, L. E. B., Anido, R. D. O., De Barros, R. M. L., & Cunha, S. A. (2012). Quantitative analysis of Brazilian football players' organisation on the pitch. Sports biomechanics, 11(1), 85-96. <https://www.tandfonline.com/doi/full/10.1080/14763141.2011.637123>`_ .. [4] `Clemente, M. F., Couceiro, S. M., Martins, F. M., Mendes, R., & Figueiredo, A. J. (2013). Measuring Collective Behaviour in Football Teams: Inspecting the impact of each half of the match on ball possession. International Journal of Performance Analysis in Sport, 13(3), 678-689. <https://www.tandfonline.com/doi/abs/10.1080/24748668.2013.11868680>`_ """ def __init__(self): super().__init__() self._convex_hulls_ = None self._framerate = None
[docs] def fit(self, xy: XY | list[XY], exclude_xIDs: list[list] | None = None): """Fit the model to the given data and calculate convex hulls. Parameters ---------- xy : XY or list[XY] Single XY object or list of XY objects. If list, all XY objects will be combined and the convex hull will encompass all players (effective playing space). exclude_xIDs : list[list], optional For each XY object, a list of xIDs to exclude from computation. This can be useful to exclude goalkeepers from analysis. Length must match number of XY objects. Examples: - Single XY with `xID=0` excluded: ``[[0]]`` - Two XYs with both `xID=0` excluded: ``[[0], [0]]`` """ # Normalize inputs to lists xy_list = [xy] if isinstance(xy, XY) else xy if exclude_xIDs is None: exclude_xIDs = [None] * len(xy_list) elif len(exclude_xIDs) != len(xy_list): raise ValueError( f"exclude_xIDs length ({len(exclude_xIDs)}) must match " f"number of XY objects ({len(xy_list)})" ) T = len(xy_list[0]) self._framerate = xy_list[0].framerate # Validate all XY objects have same length for i, xy_obj in enumerate(xy_list[1:], start=1): if len(xy_obj) != T: raise ValueError( f"All XY objects must have same length. " f"xy[0] has {T} frames, xy[{i}] has {len(xy_obj)} frames" ) # Concatenate all XY arrays xy_arrays = [xy_obj.xy for xy_obj in xy_list] combined_xy = np.hstack(xy_arrays) # Create validity mask valid_mask = self._create_validity_mask(xy_list, exclude_xIDs) # Calculate convex hull for each frame self._convex_hulls_ = [] for t in range(T): hull = self._calculate_convex_hull_for_frame(combined_xy[t], valid_mask) self._convex_hulls_.append(hull)
def _create_validity_mask( self, xy_list: list[XY], exclude_xIDs: list[list | None] ) -> np.ndarray: """Create a boolean mask indicating which coordinates to include. Parameters ---------- xy_list : list[XY] List of XY objects. exclude_xIDs : list[list | None] Exclusion lists for each XY object. Returns ------- valid_mask : np.ndarray Boolean array where True = include coordinate, False = exclude. Shape matches total number of coordinates in concatenated XY. """ masks = [] for xy_obj, excl in zip(xy_list, exclude_xIDs): # Start with all True (include all) mask = np.ones(xy_obj.N * 2, dtype=bool) if excl is not None: for xid in excl: if xid < 0 or xid >= xy_obj.N: raise ValueError( f"xID {xid} out of range [0, {xy_obj.N-1}] " f"for XY object with {xy_obj.N} players" ) # Exclude both x and y coordinates mask[xid * 2] = False mask[xid * 2 + 1] = False masks.append(mask) # Concatenate all masks return np.concatenate(masks) def _calculate_convex_hull_for_frame( self, frame_data: np.ndarray, valid_mask: np.ndarray ) -> ConvexHull | None: """Calculate convex hull for a single frame. Parameters ---------- frame_data : np.ndarray Frame data (1D array of all coordinates). valid_mask : np.ndarray Boolean mask indicating which coordinates to include. Returns ------- hull : ConvexHull or None ConvexHull object if successful, None if insufficient valid points. """ MIN_POINTS_FOR_CHULL = 3 # Exclude players (apply mask) masked_data = frame_data[valid_mask] # Reshape to (N, 2) coordinate pairs points = masked_data.reshape(-1, 2) # Exclude points with NaN valid_points = points[~np.isnan(points).any(axis=1)] # Check for at least 3 points if len(valid_points) < MIN_POINTS_FOR_CHULL: return None # Calculate ConvexHull (scipy handles collinearity/duplicates) try: return ConvexHull(valid_points) except QhullError: # QhullError for collinear points, duplicates, or other geometric issues return None
[docs] @requires_fit def convex_hull_area(self) -> TeamProperty: """Calculates the area enclosed by the convex hull. Returns ------- convex_hull_area : TeamProperty A TeamProperty object of shape (T,), where T is the total number of frames. Each entry contains the area enclosed by the convex hull for that frame. Frames with insufficient valid points have NaN values. Notes ----- If the model was fitted with: - Single XY object: returns the team's convex hull area - Multiple XY objects: returns the effective area of play (EAP) """ areas = np.array( [ hull.volume if hull is not None else np.nan for hull in self._convex_hulls_ ] ) return TeamProperty( property=areas, name="convex_hull_area", framerate=self._framerate )
[docs] @requires_fit def plot( self, t: int, ax=None, fill: bool = True, fill_alpha: float = 0.3, **kwargs ): """Plot the convex hull for a given time point on a matplotlib axes. Parameters ---------- t : int Frame index to plot. ax : matplotlib.axes.Axes, optional Matplotlib axes to plot on. If None, a new figure and axes are created. fill : bool, optional Whether to fill the convex hull polygon. Default is True. fill_alpha : float, optional Transparency of the fill (0=transparent, 1=opaque). Default is 0.3. Only used if fill=True. **kwargs : optional Additional keyword arguments passed to the line plot. Common options: - color : str, default 'black' - alpha : float, default 1.0 (line transparency) - linewidth : float, default 2 - linestyle : str (e.g., '--', ':') - Any other matplotlib.axes.Axes.plot() parameters Returns ------- ax : matplotlib.axes.Axes The axes object with the convex hull plotted. Notes ----- The kwargs are passed to the plot function of matplotlib for drawing the hull boundary. To customize the plots have a look at `matplotlib <https://matplotlib.org/stable/api/_as_gen/matplotlib.axes.Axes.plot.html>`_. Examples -------- >>> import matplotlib.pyplot as plt >>> from floodlight import Pitch >>> >>> pitch = Pitch( ... xlim=(0, 105), ylim=(0, 68), sport="football", unit="m", boundaries="fixed" ... ) >>> >>> fig, ax = plt.subplots() >>> pitch.plot(ax=ax) >>> >>> # Filled hull with custom color >>> chm.plot(t=0, ax=ax, color='blue', fill_alpha=0.2) .. image:: ../../_img/sample_chm_plot_filled.png >>> # Dashed outline, no fill >>> chm.plot(t=0, ax=ax, fill=False, linestyle="--", linewidth=3) .. image:: ../../_img/sample_chm_plot_dashed.png """ ax = ax or plt.subplots()[1] hull = self._convex_hulls_[t] # Handle None hull - return axes if hull is None: return ax # Extract plotting parameters with defaults color = kwargs.pop("color", "black") linewidth = kwargs.pop("linewidth", 2) # Get hull vertices points = hull.points vertices = hull.vertices # Close the polygon vertices_closed = np.append(vertices, vertices[0]) hull_points = points[vertices_closed] # Plot boundary ax.plot( hull_points[:, 0], hull_points[:, 1], color=color, linewidth=linewidth, **kwargs, ) # Fill if requested if fill: ax.fill(hull_points[:, 0], hull_points[:, 1], color=color, alpha=fill_alpha) return ax