Source code for floodlight.metrics.trajectory_clustering

import warnings

import numpy as np
from scipy.spatial.distance import cdist
from scipy.optimize import linear_sum_assignment

from floodlight import XY
from floodlight.transforms.spatial import subtract_centroid, min_max_normalize
from floodlight.transforms.permutation import assign_roles


def _fsim_score(query: np.ndarray, template: np.ndarray, delta: float) -> float:
    """Compute FSIM between two normalized (N, 2) formation arrays."""
    # squared Euclidean distance matrix
    dist_sq = cdist(query, template, metric="sqeuclidean")
    # similarity matrix clamped to [0, 1]
    similarity = np.maximum(1.0 - dist_sq / delta, 0.0)
    # optimal assignment maximizing total similarity
    row, col = linear_sum_assignment(-similarity)

    return float(similarity[row, col].mean())


[docs] def formation_similarity( xy: XY, template: np.ndarray, exclude_xIDs: list = None, role_assignment: bool = True, n_iter: int = 1, delta: float = 1 / 3, ) -> float: """Computes formation similarity (FSIM) between observed player positions and a formation template via template matching. [1]_ The algorithm classifies a team's formation by comparing role-resolved average positions against an idealized formation template. Parameters ---------- xy: XY Spatiotemporal tracking data for one team, shape (T, 2*N). template: np.ndarray Template position array of shape (M, 2) representing an idealized formation. exclude_xIDs: list, optional A list of player indices (xIDs) to exclude from the analysis. Excluded players are removed from centroid computation and from the formation comparison. This is typically used to exclude goalkeepers. role_assignment: bool, optional If True (default), role assignment via the Hungarian algorithm is applied before averaging. Set to False to skip this step, e.g. when the input data has already been role-assigned externally. n_iter: int, optional Number of role assignment iterations. Only used when ``role_assignment`` is True. Defaults to 1. delta: float, optional Similarity decay parameter controlling how quickly similarity drops with distance between role positions. Motivated by a coarse 3x3 pitch partitioning where roles one zone apart should have near-zero similarity. Defaults to 1/3 according to [1]_. Returns ------- fsim: float Formation similarity score in [0, 1]. Notes ----- The pipeline proceeds as follows: 1. Per-frame centroid subtraction to obtain center-relative positions. 2. Role assignment (optional, default=True) [2]_ via Hungarian algorithm using mean positions as reference (one iteration per default, as proposed in [1]_). 3. Averaging role-resolved positions across frames to obtain a single formation snapshot of shape (N, 2). 4. Independent min-max normalization of query and template to [0, 1] per axis, ensuring scale and compactness invariance. 5. Computation of an N x N similarity matrix using :math:`m_{i,j} = \\max(1 - \\|\\tilde{r}_i - \\tilde{r}_j\\|^2 / \\delta, \\; 0)`, optimal one-to-one assignment via the Hungarian algorithm, and averaging matched similarities to obtain the FSIM score. References ---------- .. [1] `Müller-Budack, E., Theiner, J., Rigoll, G., & Ewerth, R. (2019). Does 4-4-2 exist? An analytics approach to understand and classify football team formations in single match situations. In Proceedings of the 2nd International Workshop on Multimedia Content Analysis in Sports (pp. 25-33). <https://doi.org/10.1145/3347318.3355527>`_ .. [2] `Bialkowski, A., Lucey, P., Carr, P., Yue, Y., Sridharan, S., & Matthews, I. (2014). Identifying team style in soccer using formations learned from spatiotemporal tracking data. In IEEE International Conference on Data Mining Workshop (pp. 9-14). <https://doi.org/10.1109/ICDMW.2014.167>`_ """ if exclude_xIDs is None: exclude_xIDs = [] # step 1: subtract per-frame centroid (excluding specified players) xy_centered = subtract_centroid(xy, exclude_xIDs=exclude_xIDs) # remove excluded players from the data before role assignment if exclude_xIDs: keep = np.full(xy.N * 2, True) for xID in exclude_xIDs: keep[xID * 2 : xID * 2 + 2] = False xy_centered = XY( xy=xy_centered.xy[:, keep], framerate=xy_centered.framerate, direction=xy_centered.direction, ) template = np.asarray(template, dtype=float) if template.ndim != 2 or template.shape[1] != 2: raise ValueError(f"Template must have shape (M, 2), got {template.shape}.") # step 2: role assignment via Hungarian algorithm if role_assignment: xy_centered = assign_roles(xy_centered, n_iter=n_iter) # step 3: average role-resolved positions across frames T = len(xy_centered) with warnings.catch_warnings(): warnings.filterwarnings("ignore", category=RuntimeWarning) mean_formation = np.nanmean(xy_centered.xy.reshape(T, -1, 2), axis=0) # drop all-NaN rows (players never observed) valid = ~np.isnan(mean_formation[:, 0]) mean_formation = mean_formation[valid] # step 4: normalize query and template query = min_max_normalize(mean_formation) norm_template = min_max_normalize(template) # step 5: compute FSIM fsim = _fsim_score(query, norm_template, delta) return fsim