import warnings
import numpy as np
from scipy.spatial.distance import cdist
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():
# supress 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, 1), 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():
# supress 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