diff --git a/README.md b/README.md index 4b9172b..d560c56 100644 --- a/README.md +++ b/README.md @@ -14,25 +14,103 @@ The **unravelsports** package aims to aid researchers, analysts and enthusiasts by providing intermediary steps in the complex process of converting raw sports data into meaningful information and actionable insights. +This package currently supports: +- ⚽🏈 [**Polars DataFrame Conversion**](#polars-dataframes) +- ⚽🏈 [**Graph Neural Network**](#graph-neural-networks) Training, Graph Conversion and Prediction +- ⚽ [**Pressing Intensity**](#pressing-intensity) [[Bekkers (2025)](https://unravelsports.github.io/2024/12/12/pressing-intensity.html)] + πŸŒ€ Features ----- -### **Convert** +### **Polars DataFrames** + +⚽🏈 **Convert Tracking Data** into [Polars DataFrames](https://pola.rs/) for rapid data conversion and data processing. + +For soccer we rely on [Kloppy](https://kloppy.pysport.org/) and as such we support _Sportec_$^1$, _SkillCorner_$^1$, _PFF_$^{1, 2}$, _Metrica_$^1$, _StatsPerform_, _Tracab (CyronHego)_ and _SecondSpectrum_ tracking data. + +For American Football we use [BigDataBowl Data](https://www.kaggle.com/competitions/nfl-big-data-bowl-2025/data) directly. -⚽ **Soccer positional tracking data** into [Graphs](examples/graphs_faq.md) to train **graph neural networks** by leveraging the powerful [**Kloppy**](https://github.com/PySport/kloppy) data conversion standard for - - _Metrica_ - - _Sportec_ - - _Tracab (CyronHego)_ - - _SecondSpectrum_ - - _SkillCorner_ - - _StatsPerform_ - -🏈 **BigDataBowl American football positional tracking data** into [Graphs](examples/graphs_faq.md) to train **graph neural networks** by leveraging [**Polars**](https://github.com/pola-rs/polars). +⚽ +```python +from unravel.soccer import KloppyPolarsDataset + +from kloppy import skillcorner + +kloppy_dataset = skillcorner.load_open_data( + match_id=2068, + include_empty_frames=False, + limit=500, +) +kloppy_polars_dataset = KloppyPolarsDataset( + kloppy_dataset=kloppy_dataset, + ball_carrier_threshold=25.0 +) +``` + +$^1$ Open data available through kloppy. + +$^2$ Currently unreleased in kloppy, only available through kloppy master branch. [Click here for World Cup 2022 Dataset](https://www.blog.fc.pff.com/blog/enhanced-2022-world-cup-dataset) + +🏈 +```python +from unravel.american_football import BigDataBowlDataset + +bdb = BigDataBowlDataset( + tracking_file_path="week1.csv", + players_file_path="players.csv", + plays_file_path="plays.csv", +) +``` ### **Graph Neural Networks** -These [Graphs](examples/graphs_faq.md) can be used with [**Spektral**](https://github.com/danielegrattarola/spektral) - a flexible framework for training graph neural networks. + +⚽🏈 Convert **[Polars Dataframes](#polars-dataframes)** into [Graphs](examples/graphs_faq.md) to train **graph neural networks**. These [Graphs](examples/graphs_faq.md) can be used with [**Spektral**](https://github.com/danielegrattarola/spektral) - a flexible framework for training graph neural networks. `unravelsports` allows you to **randomize** and **split** data into train, test and validation sets along matches, sequences or possessions to avoid leakage and improve model quality. And finally, **train**, **validate** and **test** your (custom) Graph model(s) and easily **predict** on new data. +```python +converter = SoccerGraphConverterPolars( + dataset=kloppy_polars_dataset, + max_player_speed=12.0, + max_ball_speed=28.0, + self_loop_ball=True, + adjacency_matrix_connect_type="ball", + adjacency_matrix_type="split_by_team", + label_type="binary", + defending_team_node_value=0.1, + non_potential_receiver_node_value=0.1, + random_seed=False, + pad=False, + verbose=False, +) +``` + +### **Pressing Intensity** + +Compute [**Pressing Intensity**](https://arxiv.org/abs/2501.04712) for a whole game (or segment) of Soccer tracking data. + +See [**Pressing Intensity Jupyter Notebook**](examples/pressing_intensity.ipynb) for an example how to create mp4 videos. + +```python +from unravel.soccer import PressingIntensity + +import polars as pl + +model = PressingIntensity( + dataset=kloppy_polars_dataset +) +model.fit( + start_time = pl.duration(minutes=1, seconds=53), + end_time = pl.duration(minutes=2, seconds=32), + period_id = 1, + method="teams", + ball_method="max", + orient="home_away", + speed_threshold=2.0, +) +``` + +![1. FC KΓΆln vs. FC Bayern MΓΌnchen (May 27th 2023)](assets/gif/preview.gif) + βŒ› ***More to come soon...!*** πŸŒ€ Quick Start @@ -43,6 +121,9 @@ These [Graphs](examples/graphs_faq.md) can be used with [**Spektral**](https://g πŸ“– 🏈 The [**BigDataBowl Converter Tutorial Jupyter Notebook**](examples/2_big_data_bowl_guide.ipynb) gives an guide on how to convert the BigDataBowl data into Graphs. +πŸ“– ⚽ The [**Pressing Intensity Tutorial Jupyter Notebook**](examples/pressing_intensity.ipynb) gives a description on how to create Pressing Intensity videos. + + πŸŒ€ Documentation ----- For now, follow the [**Graph Converter Tutorial**](examples/1_kloppy_gnn_train.ipynb) and check the [**Graph FAQ**](examples/graphs_faq.md), more documentation will follow! diff --git a/assets/gif/preview.gif b/assets/gif/preview.gif new file mode 100644 index 0000000..2e99953 Binary files /dev/null and b/assets/gif/preview.gif differ diff --git a/assets/preview.gif b/assets/preview.gif new file mode 100644 index 0000000..2e99953 Binary files /dev/null and b/assets/preview.gif differ diff --git a/assets/video/KOL v BAY (Pressing Intensity).mp4 b/assets/video/KOL v BAY (Pressing Intensity).mp4 new file mode 100644 index 0000000..53427ec Binary files /dev/null and b/assets/video/KOL v BAY (Pressing Intensity).mp4 differ diff --git a/assets/video/KOL v BAY.mp4 b/assets/video/KOL v BAY.mp4 new file mode 100644 index 0000000..e4f938e Binary files /dev/null and b/assets/video/KOL v BAY.mp4 differ diff --git a/examples/1_kloppy_gnn_train.ipynb b/examples/1_kloppy_gnn_train.ipynb index 2c15497..4cb71fd 100644 --- a/examples/1_kloppy_gnn_train.ipynb +++ b/examples/1_kloppy_gnn_train.ipynb @@ -222,7 +222,7 @@ }, { "cell_type": "code", - "execution_count": 3, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -248,7 +248,6 @@ " dataset = KloppyPolarsDataset(\n", " kloppy_dataset=kloppy_dataset, ball_carrier_threshold=25.0\n", " )\n", - " dataset.load()\n", "\n", " dataset.add_graph_ids()\n", "\n", @@ -693,7 +692,7 @@ }, { "cell_type": "code", - "execution_count": 13, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -705,7 +704,6 @@ "dataset = KloppyPolarsDataset(\n", " kloppy_dataset=kloppy_dataset, ball_carrier_threshold=25.0\n", ")\n", - "dataset.load()\n", "dataset.add_graph_ids(by=[\"frame_id\"])\n", "\n", "preds_converter = SoccerGraphConverterPolars(\n", diff --git a/examples/2_big_data_bowl_guide.ipynb b/examples/2_big_data_bowl_guide.ipynb index b6ed01c..7be281f 100644 --- a/examples/2_big_data_bowl_guide.ipynb +++ b/examples/2_big_data_bowl_guide.ipynb @@ -75,7 +75,7 @@ }, { "cell_type": "code", - "execution_count": 1, + "execution_count": null, "metadata": {}, "outputs": [ { @@ -129,7 +129,6 @@ " players_file_path=\".data/nfl-big-data-bowl-2023/players.csv\",\n", " plays_file_path=\".data/nfl-big-data-bowl-2023/plays.csv\",\n", ")\n", - "bdb.load()\n", "bdb.add_graph_ids(by=[\"gameId\", \"playId\"], column_name=\"graph_id\")\n", "bdb.add_dummy_labels(by=[\"gameId\", \"playId\", \"frameId\"], column_name=\"label\")" ] diff --git a/examples/pressing_intensity.ipynb b/examples/pressing_intensity.ipynb new file mode 100644 index 0000000..d3b2fc9 --- /dev/null +++ b/examples/pressing_intensity.ipynb @@ -0,0 +1,584 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## πŸŒ€ 𝐏𝐫𝐞𝐬𝐬𝐒𝐧𝐠 𝐈𝐧𝐭𝐞𝐧𝐬𝐒𝐭𝐲: 𝐴𝑛 𝐼𝑛𝑑𝑒𝑖𝑑𝑖𝑣𝑒 π‘€π‘’π‘Žπ‘ π‘’π‘Ÿπ‘’ π‘“π‘œπ‘Ÿ π‘ƒπ‘Ÿπ‘’π‘ π‘ π‘–π‘›π‘” 𝑖𝑛 πΉπ‘œπ‘œπ‘‘π‘π‘Žπ‘™π‘™\n", + "\n", + "Within this notebook we demonstrate how to compute [𝐏𝐫𝐞𝐬𝐬𝐒𝐧𝐠 𝐈𝐧𝐭𝐞𝐧𝐬𝐒𝐭𝐲](https://unravelsports.github.io/2024/12/12/pressing-intensity.html) ([ArXiv PDF](https://arxiv.org/abs/2501.04712)) for a single sequence of play from freely available positional tracking data of 1. FC KΓΆln vs. FC Bayern MΓΌnchen (May 27th 2023), using the new `unravel.soccer.PressingIntensity`, [Kloppy](https://kloppy.pysport.org/) and [Polars](https://pola.rs/).\n", + "\n", + "We will create an `.mp4` video (as shown below, on the left) of this 35-second segment of play using [mplsoccer](https://mplsoccer.readthedocs.io/en/latest/), [matplotlib](https://matplotlib.org/) and [seaborn](https://seaborn.pydata.org/). \n", + "\n", + "πŸ—’οΈ You can also use this to compute **Pressing Intensity** for a whole game in under a minute!\n", + "\n", + "πŸ—’οΈ The match video blew is for illustrative purposes only, you'll need to source and sync your own match footage" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + "
\n", + " \n", + "
\n", + "
\n", + "
\n", + " \n", + " \n", + "
\n", + " \n", + " \n", + " " + ], + "text/plain": [ + "" + ] + }, + "execution_count": 1, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "from unravel.utils import display\n", + "\n", + "from os.path import join\n", + "\n", + "display.show(\n", + " video_path=[\n", + " join(\"assets\", \"video\", \"KOL v BAY (Pressing Intensity).mp4\"),\n", + " join(\"assets\", \"video\", \"KOL v BAY.mp4\"),\n", + " ]\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "-------\n", + "\n", + "### 0. Installation\n", + "\n", + "First install `unravelsports` if you haven't done so already!" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [], + "source": [ + "# %pip install unravelsports --quiet" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "-----\n", + "\n", + "### 1. Loading Open Tracking Dataset\n", + "\n", + "To start we load a single game of DFL (Sportec) open tracking data ([Bassek, M. et al. (2024)](https://www.nature.com/articles/s41597-025-04505-y)). \n", + "\n", + "You can choose any of the following games:\n", + "\n", + "```python\n", + "matches = {\n", + " 'J03WMX': \"1. FC KΓΆln vs. FC Bayern MΓΌnchen\",\n", + " 'J03WN1': \"VfL Bochum 1848 vs. Bayer 04 Leverkusen\",\n", + " 'J03WPY': \"Fortuna DΓΌsseldorf vs. 1. FC NΓΌrnberg\",\n", + " 'J03WOH': \"Fortuna DΓΌsseldorf vs. SSV Jahn Regensburg\",\n", + " 'J03WQQ': \"Fortuna DΓΌsseldorf vs. FC St. Pauli\",\n", + " 'J03WOY': \"Fortuna DΓΌsseldorf vs. F.C. Hansa Rostock\",\n", + " 'J03WR9': \"Fortuna DΓΌsseldorf vs. 1. FC Kaiserslautern\"\n", + "}\n", + "```" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "**Kloppy Parameters**\n", + "\n", + "We load the data using kloppy and setting `limit=5000`. We do this because our moment happens early in the game and therefor we don't need to load the whole dataset.\n", + "\n", + "**Coordinate System**\n", + "\n", + "We load the data with the \"secondspectrum\" coordinate system to ensure that $X \\in \\left[ -\\frac{L_{\\text{pitch}}}{2}, \\frac{L_{\\text{pitch}}}{2} \\right]$ and $Y \\in \\left[ -\\frac{W_{\\text{pitch}}}{2}, \\frac{W_{\\text{pitch}}}{2} \\right]$" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [], + "source": [ + "from kloppy import sportec\n", + "\n", + "coordinates = \"secondspectrum\"\n", + "\n", + "kloppy_dataset = sportec.load_open_tracking_data(\n", + " match_id=\"J03WMX\", coordinates=coordinates, limit=5000\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "------\n", + "\n", + "### 2. Data Preparation\n", + "\n", + "We use the `KloppyPolarsDataset` to convert the regular kloppy `TrackingDataset` into a Polars dataframe with some additional stuff. \n", + "\n", + "We set `orient_ball_owning=False`, this is useful to not overwrite the playing directions. Leaving this as the default `True` would rotate the pitch every time the ball changes possession. This would render the resulting video unwatchable.\n", + "\n", + "πŸ’‘ For example we can call `dataset.settings` to get an overview of the settings that relate to the newly created `KloppyPolarsDataset` object.\n", + "\n", + "❌ Creating a video of a full match might not be the greatest idea :)" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [], + "source": [ + "from unravel.soccer import KloppyPolarsDataset\n", + "\n", + "dataset = KloppyPolarsDataset(kloppy_dataset=kloppy_dataset, orient_ball_owning=False)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "------\n", + "\n", + "### 3. Pressing Intensity Out of the Box\n", + "\n", + "Now, we're going to use the `PressingIntensity` model (see [the 𝐏𝐫𝐞𝐬𝐬𝐒𝐧𝐠 𝐈𝐧𝐭𝐞𝐧𝐬𝐒𝐭𝐲 blog](https://unravelsports.github.io/2024/12/12/pressing-intensity.html) and/or [Bekkers (2015)](https://arxiv.org/abs/2501.04712) for more information) in combination with the newly created `dataset` to fit this model on.\n", + "\n", + "#### > Match segment\n", + "\n", + "As an example we select a match segment from KΓΆln vs. Bayern with high pressing intensity as shown in the video at the top. This segment runs from approximately 1:52 to 2:32 in the first half.\n", + "\n", + "πŸ’‘ Feel free to run it on the whole game, by removing `start_time`, `end_time` and `period_id`. It should take between 15-60 seconds to compute the whole match. Please note that you'll have to reload the kloppy and polars datasets too without the `limit=5000`.\n", + "\n", + "#### > PressingIntensity Parameters\n", + "\n", + "- **method** determines how we assign to rows and columns in the **Pressing Intensity** matrix. \n", + " - \"teams\" will set one team as the rows, and the other team as the columns. (Matrix is 11x11)\n", + " - \"full\" will set both teams as the rows and as the columns. (Matrix is 22x22)\n", + "- **ball_method** determines who we handle pressure in relation to the ball within the **Pressing Intensity** matrix.\n", + " - \"max\" will assign the maximum value on the ball carrying player of either a player applying pressure on them, or on the ball.\n", + " - \"include\" will simply include the ball object as it's own row / column in the matrix, resulting in a 11x12 or 23x23 matrix. \n", + " - \"exclude\" will not consider the ball.\n", + "- **orient** when we set **method=\"teams\"** this allows you to choose which axis will be the home or the away team . \n", + " - \"home_away\" will set the home team as the rows and away team as columns\n", + " - \"away_home\" will set the away team as the rows and home team as columns\n", + " - \"ball_owning\" will set the ball owning team as the rows and pressing team as the columns\n", + " - \"pressing\" will set the pressing team as the rows and ball owning team as the columns\n", + "- **orient** when we set **method=\"full\"** this will order the rows and columns accordingly with the indicated team showing first.\n", + "\n", + "#### > PressingIntensity Output\n", + "\n", + "`model.output` is a Polars dataframe with one row per frame and a NxN matrix for time_to_intercept, probability_to_intercept and the corresponding object ids for the columns and rows\n" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "shape: (5, 8)
game_idperiod_idframe_idtimestamptime_to_interceptprobability_to_interceptcolumnsrows
stri64i64duration[ΞΌs]list[list[f64]]list[list[f64]]list[str]list[str]
"DFL-MAT-J03WMX"1128721m 54s 880ms[[2.390701, 2.861643, … 3.90449], [2.898738, 4.564472, … 2.922833], … [2.813985, 4.03852, … 3.4157]][[0.0, 0.0, … 0.0], [0.0, 0.0, … 0.0], … [0.0, 0.0, … 0.0]]["DFL-OBJ-0000IA", "DFL-OBJ-0002AU", … "DFL-OBJ-J01B8N"]["DFL-OBJ-00012X", "DFL-OBJ-000270", … "DFL-OBJ-J01D1W"]
"DFL-MAT-J03WMX"1128731m 54s 920ms[[2.232504, 2.696582, … 3.951205], [2.941554, 4.503709, … 2.966431], … [2.736845, 3.856013, … 3.459606]][[0.0, 0.0, … 0.0], [0.0, 0.0, … 0.0], … [0.0, 0.0, … 0.0]]["DFL-OBJ-0000IA", "DFL-OBJ-0002AU", … "DFL-OBJ-J01B8N"]["DFL-OBJ-00012X", "DFL-OBJ-000270", … "DFL-OBJ-J01D1W"]
"DFL-MAT-J03WMX"1128741m 54s 960ms[[2.13599, 2.557744, … 3.988354], [2.996597, 4.454919, … 2.972684], … [2.766442, 3.697494, … 3.498005]][[0.0, 0.0, … 0.0], [0.0, 0.0, … 0.0], … [0.0, 0.0, … 0.0]]["DFL-OBJ-0000IA", "DFL-OBJ-0002AU", … "DFL-OBJ-J01B8N"]["DFL-OBJ-00012X", "DFL-OBJ-000270", … "DFL-OBJ-J01D1W"]
"DFL-MAT-J03WMX"1128751m 55s[[2.065953, 2.413484, … 4.042976], [3.042859, 4.403997, … 3.015308], … [2.727374, 3.538569, … 3.547081]][[0.0, 0.0, … 0.0], [0.0, 0.0, … 0.0], … [0.0, 0.0, … 0.0]]["DFL-OBJ-0000IA", "DFL-OBJ-0002AU", … "DFL-OBJ-J01B8N"]["DFL-OBJ-00012X", "DFL-OBJ-000270", … "DFL-OBJ-J01D1W"]
"DFL-MAT-J03WMX"1128761m 55s 40ms[[2.176109, 2.363153, … 4.005502], [3.042964, 4.352531, … 2.952664], … [2.943937, 3.499443, … 3.520907]][[0.0, 0.0, … 0.0], [0.0, 0.0, … 0.0], … [0.0, 0.0, … 0.0]]["DFL-OBJ-0000IA", "DFL-OBJ-0002AU", … "DFL-OBJ-J01B8N"]["DFL-OBJ-00012X", "DFL-OBJ-000270", … "DFL-OBJ-J01D1W"]
" + ], + "text/plain": [ + "shape: (5, 8)\n", + "β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”\n", + "β”‚ game_id ┆ period_id ┆ frame_id ┆ timestamp ┆ time_to_in ┆ probabilit ┆ columns ┆ rows β”‚\n", + "β”‚ --- ┆ --- ┆ --- ┆ --- ┆ tercept ┆ y_to_inter ┆ --- ┆ --- β”‚\n", + "β”‚ str ┆ i64 ┆ i64 ┆ duration[ΞΌ ┆ --- ┆ cept ┆ list[str] ┆ list[str] β”‚\n", + "β”‚ ┆ ┆ ┆ s] ┆ list[list[ ┆ --- ┆ ┆ β”‚\n", + "β”‚ ┆ ┆ ┆ ┆ f64]] ┆ list[list[ ┆ ┆ β”‚\n", + "β”‚ ┆ ┆ ┆ ┆ ┆ f64]] ┆ ┆ β”‚\n", + "β•žβ•β•β•β•β•β•β•β•β•β•β•β•β•ͺ═══════════β•ͺ══════════β•ͺ════════════β•ͺ════════════β•ͺ════════════β•ͺ═══════════β•ͺ═══════════║\n", + "β”‚ DFL-MAT-J0 ┆ 1 ┆ 12872 ┆ 1m 54s ┆ [[2.390701 ┆ [[0.0, ┆ [\"DFL-OBJ ┆ [\"DFL-OBJ β”‚\n", + "β”‚ 3WMX ┆ ┆ ┆ 880ms ┆ , ┆ 0.0, … ┆ -0000IA\", ┆ -00012X\", β”‚\n", + "β”‚ ┆ ┆ ┆ ┆ 2.861643, ┆ 0.0], ┆ \"DFL-OBJ- ┆ \"DFL-OBJ- β”‚\n", + "β”‚ ┆ ┆ ┆ ┆ … 3.9044… ┆ [0.0, ┆ 00… ┆ 00… β”‚\n", + "β”‚ ┆ ┆ ┆ ┆ ┆ 0.0,… ┆ ┆ β”‚\n", + "β”‚ DFL-MAT-J0 ┆ 1 ┆ 12873 ┆ 1m 54s ┆ [[2.232504 ┆ [[0.0, ┆ [\"DFL-OBJ ┆ [\"DFL-OBJ β”‚\n", + "β”‚ 3WMX ┆ ┆ ┆ 920ms ┆ , ┆ 0.0, … ┆ -0000IA\", ┆ -00012X\", β”‚\n", + "β”‚ ┆ ┆ ┆ ┆ 2.696582, ┆ 0.0], ┆ \"DFL-OBJ- ┆ \"DFL-OBJ- β”‚\n", + "β”‚ ┆ ┆ ┆ ┆ … 3.9512… ┆ [0.0, ┆ 00… ┆ 00… β”‚\n", + "β”‚ ┆ ┆ ┆ ┆ ┆ 0.0,… ┆ ┆ β”‚\n", + "β”‚ DFL-MAT-J0 ┆ 1 ┆ 12874 ┆ 1m 54s ┆ [[2.13599, ┆ [[0.0, ┆ [\"DFL-OBJ ┆ [\"DFL-OBJ β”‚\n", + "β”‚ 3WMX ┆ ┆ ┆ 960ms ┆ 2.557744, ┆ 0.0, … ┆ -0000IA\", ┆ -00012X\", β”‚\n", + "β”‚ ┆ ┆ ┆ ┆ … 3.98835… ┆ 0.0], ┆ \"DFL-OBJ- ┆ \"DFL-OBJ- β”‚\n", + "β”‚ ┆ ┆ ┆ ┆ ┆ [0.0, ┆ 00… ┆ 00… β”‚\n", + "β”‚ ┆ ┆ ┆ ┆ ┆ 0.0,… ┆ ┆ β”‚\n", + "β”‚ DFL-MAT-J0 ┆ 1 ┆ 12875 ┆ 1m 55s ┆ [[2.065953 ┆ [[0.0, ┆ [\"DFL-OBJ ┆ [\"DFL-OBJ β”‚\n", + "β”‚ 3WMX ┆ ┆ ┆ ┆ , ┆ 0.0, … ┆ -0000IA\", ┆ -00012X\", β”‚\n", + "β”‚ ┆ ┆ ┆ ┆ 2.413484, ┆ 0.0], ┆ \"DFL-OBJ- ┆ \"DFL-OBJ- β”‚\n", + "β”‚ ┆ ┆ ┆ ┆ … 4.0429… ┆ [0.0, ┆ 00… ┆ 00… β”‚\n", + "β”‚ ┆ ┆ ┆ ┆ ┆ 0.0,… ┆ ┆ β”‚\n", + "β”‚ DFL-MAT-J0 ┆ 1 ┆ 12876 ┆ 1m 55s ┆ [[2.176109 ┆ [[0.0, ┆ [\"DFL-OBJ ┆ [\"DFL-OBJ β”‚\n", + "β”‚ 3WMX ┆ ┆ ┆ 40ms ┆ , ┆ 0.0, … ┆ -0000IA\", ┆ -00012X\", β”‚\n", + "β”‚ ┆ ┆ ┆ ┆ 2.363153, ┆ 0.0], ┆ \"DFL-OBJ- ┆ \"DFL-OBJ- β”‚\n", + "β”‚ ┆ ┆ ┆ ┆ … 4.0055… ┆ [0.0, ┆ 00… ┆ 00… β”‚\n", + "β”‚ ┆ ┆ ┆ ┆ ┆ 0.0,… ┆ ┆ β”‚\n", + "β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜" + ] + }, + "execution_count": 5, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "from unravel.soccer import PressingIntensity\n", + "\n", + "import polars as pl\n", + "\n", + "model = PressingIntensity(dataset=dataset)\n", + "model.fit(\n", + " start_time=pl.duration(minutes=1, seconds=53),\n", + " end_time=pl.duration(minutes=2, seconds=32),\n", + " period_id=1,\n", + " method=\"teams\",\n", + " ball_method=\"max\",\n", + " orient=\"home_away\",\n", + " speed_threshold=2.0,\n", + ")\n", + "model.output.head()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "------\n", + "\n", + "\n", + "### 4. Creating Video\n", + "\n", + "**Plotting Helpers**\n", + "\n", + "We create some additional functionality to plot our Pressing Intensity matrix and the field of play including players and ball." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from unravel.utils import ColorMaps\n", + "\n", + "import seaborn as sns\n", + "\n", + "import matplotlib.pyplot as plt\n", + "\n", + "from mplsoccer import VerticalPitch\n", + "from matplotlib.animation import FuncAnimation\n", + "\n", + "import seaborn as sns\n", + "import numpy as np\n", + "import pandas as pd\n", + "\n", + "HOME_COLOR, HOME_GK_COLOR = \"red\", \"grey\"\n", + "AWAY_COLOR, AWAY_GK_COLOR = \"black\", \"green\"\n", + "BALL_COLOR = \"orange\"\n", + "\n", + "\n", + "def __plot_settings(ax, row_players, column_players, speed_threshold: float = None):\n", + " for t in ax.texts:\n", + " t.set_text(t.get_text() + \" %\")\n", + " ax.figure.axes[-1].yaxis.label.set_size(10)\n", + "\n", + " ax.tick_params(\n", + " axis=\"both\",\n", + " which=\"major\",\n", + " labelsize=10,\n", + " labelbottom=False,\n", + " bottom=False,\n", + " top=False,\n", + " labeltop=True,\n", + " )\n", + "\n", + " ax.tick_params(length=0)\n", + " ax.xaxis.set_label_position(\"top\")\n", + "\n", + " row_colors = [\n", + " (\n", + " (\n", + " HOME_COLOR\n", + " if player.is_home and not player.is_gk\n", + " else (\n", + " HOME_GK_COLOR\n", + " if player.is_home\n", + " else AWAY_COLOR if not player.is_gk else AWAY_GK_COLOR\n", + " )\n", + " )\n", + " if player is not None\n", + " else BALL_COLOR\n", + " )\n", + " for player in row_players\n", + " ]\n", + " column_colors = [\n", + " (\n", + " (\n", + " HOME_COLOR\n", + " if player.is_home and not player.is_gk\n", + " else (\n", + " HOME_GK_COLOR\n", + " if player.is_home\n", + " else AWAY_COLOR if not player.is_gk else AWAY_GK_COLOR\n", + " )\n", + " )\n", + " if player is not None\n", + " else BALL_COLOR\n", + " )\n", + " for player in column_players\n", + " ]\n", + "\n", + " [t.set_color(color) for t, color in zip(ax.xaxis.get_ticklabels(), column_colors)]\n", + " [t.set_color(color) for t, color in zip(ax.yaxis.get_ticklabels(), row_colors)]\n", + "\n", + " ax.set_xticklabels(ax.get_xticklabels(), rotation=45)\n", + " ax.set_yticklabels(ax.get_yticklabels(), rotation=45)\n", + "\n", + " fontsize = 15\n", + " if model._method == \"teams\":\n", + " ax.set_ylabel(row_players[0].team_name, fontsize=fontsize)\n", + " ax.set_xlabel(column_players[0].team_name, fontsize=fontsize)\n", + " else:\n", + " ax.set_ylabel(\"\", fontsize=fontsize)\n", + " ax.set_xlabel(\"\", fontsize=fontsize)\n", + "\n", + " for t in ax.texts:\n", + " t.set_text(t.get_text())\n", + " if speed_threshold is not None:\n", + " ax.set_title(f\"Active Pressing [v > {speed_threshold}m/s]\", fontsize=14)\n", + "\n", + "\n", + "def __plot_dots(frame_data, ax):\n", + " import matplotlib.patheffects as path_effects\n", + "\n", + " # Because we use VerticalPitch we flip x and y\n", + "\n", + " for r in frame_data.iter_rows(named=True):\n", + " v, vy, vx, y, x = r[\"v\"], r[\"vx\"], r[\"vy\"], r[\"x\"], r[\"y\"]\n", + " is_ball = True if r[\"team_id\"] == \"ball\" else False\n", + "\n", + " if not is_ball:\n", + " player = dataset.get_player_by_id(player_id=r[\"id\"])\n", + "\n", + " color = (\n", + " HOME_COLOR\n", + " if player.is_home and not player.is_gk\n", + " else (\n", + " HOME_GK_COLOR\n", + " if player.is_home\n", + " else AWAY_COLOR if not player.is_gk else AWAY_GK_COLOR\n", + " )\n", + " )\n", + " ax.scatter(x, y, color=color, s=150)\n", + "\n", + " if v > 1.0:\n", + " ax.annotate(\n", + " \"\",\n", + " xy=(x + vx, y + vy),\n", + " xytext=(x, y),\n", + " arrowprops=dict(arrowstyle=\"->\", color=color, lw=3),\n", + " )\n", + " # # Text with white border\n", + " text = ax.text(\n", + " x,\n", + " y,\n", + " player.number,\n", + " color=color,\n", + " fontsize=8,\n", + " ha=\"center\",\n", + " va=\"center\",\n", + " zorder=5,\n", + " )\n", + " text.set_path_effects(\n", + " [\n", + " path_effects.Stroke(\n", + " linewidth=2, foreground=\"white\"\n", + " ), # White border\n", + " path_effects.Normal(), # Restore normal text appearance\n", + " ]\n", + " )\n", + " else:\n", + " ax.scatter(x, y, color=BALL_COLOR, s=50, zorder=10)\n", + "\n", + "\n", + "def __plot_matrix(\n", + " matrix, row_players, column_players, ax, speed_threshold: float = None\n", + "):\n", + "\n", + " df = pd.DataFrame(\n", + " data=matrix,\n", + " index=[p.number if p is not None else \"ball\" for p in row_players],\n", + " columns=[p.number if p is not None else \"ball\" for p in column_players],\n", + " )\n", + " sns.heatmap(\n", + " df * 100,\n", + " xticklabels=True,\n", + " yticklabels=True,\n", + " cmap=ColorMaps.YELLOW_RED,\n", + " ax=ax,\n", + " vmin=0,\n", + " vmax=100,\n", + " annot=True,\n", + " fmt=\".0f\",\n", + " square=True,\n", + " linewidths=0.5,\n", + " cbar=False,\n", + " )\n", + " __plot_settings(ax, row_players, column_players, speed_threshold)\n", + " return ax" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": {}, + "outputs": [], + "source": [ + "def plot_pressing_intensity(row, ax1, ax2):\n", + " period_id = row[\"period_id\"]\n", + " frame_id = row[\"frame_id\"]\n", + "\n", + " row_players = [dataset.get_player_by_id(player_id) for player_id in row[\"rows\"]]\n", + " column_players = [\n", + " dataset.get_player_by_id(player_id) for player_id in row[\"columns\"]\n", + " ]\n", + "\n", + " frame_data = model.dataset.filter(\n", + " (pl.col(\"frame_id\") == frame_id) & (pl.col(\"period_id\") == period_id)\n", + " )\n", + " __plot_dots(frame_data=frame_data, ax=ax1)\n", + " __plot_matrix(\n", + " matrix=np.array([x for x in row[\"probability_to_intercept\"]]),\n", + " row_players=row_players,\n", + " column_players=column_players,\n", + " speed_threshold=model._speed_threshold,\n", + " ax=ax2,\n", + " )" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "**Video**\n", + "\n", + "Finally, we render our 35 second video using `FuncAnimation` and `mplsoccer`.\n", + "\n", + "You might need to `pip install ffmpeg` for this to work." + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "metadata": {}, + "outputs": [ + { + "data": { + "image/png": "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jwU/AAAAAMAGOnfuHO/2+FaW3b17t5o3b65//vlHadOmVcWKFZUlSxbduXNHv//+u/bu3atx48bpp59+eq4VSps3by53d3dJ0oMHD3TixAlt2LBBGzZs0JgxYzRgwIAXC5eKLFmyRJMmTZKLi0uc+9auXatbt2499fEFChRQ586dra/7f28DwMui+QcAAAAANjB79uwE7RcYGKjatWvr4cOH6t+/v4YMGaJ06dJZ7zebzVq5cqU+/fRTXbp06blqGD9+vPLkyRNr2+TJk9W7d28NHTpUXbp0UZYsWZ7rmKlJ6dKldfDgQa1bt05NmjSJc/+8efPk6OioYsWK6dChQ/Ee49VXX9Wrr776xNsA8LKY9gsAAAAAKZTFYlHHjh318OFDBQQEaOzYsbEaf5Lk4OCgZs2a6Y8//pC/v/9Ln7NXr17KlSuXIiIitGfPnpc+npG1bdtWDg4Omj9/fpz7bt++rXXr1ql27drKmjWrDaoDgGg0/wAAAAAghdqwYYOOHj2qnDlzatCgQU/d19PTU8WKFUuU8/r4+EiSIiMjY20/c+aMAgICVKlSJWXNmlUuLi7KmTOnOnXqpD///DPWvlevXpWzs7Ny5cqlqKioeM+zYMECmUymOFOgLRaLFi5cqFq1ailjxoxyc3OTr6+vAgIC9ODBgzjHqVGjhkwmky5cuKAFCxaoYsWK8vDwUIYMGSRJAQEBMplMmj17to4cOaLGjRsrY8aMSpcunapXr67du3e/0POULVs21apVSz/99JPu3LkT677FixcrLCxMHTp0eOLjTSZTnJGXMWKu/RcQEPDErCtXrlTFihWVLl06eXl5qW3btk8c/RnznNatW1fe3t5yc3NTnjx51KpVK23atCnex4SGhmrAgAHKnTu3XF1dVaBAAX3++eeyWCzx7h8cHKyBAwfKz89PadKkkaenp/X5+a/Hry34vOcB8Hxo/gEAAABAChWz8EbLli3l5JQ8V226e/eutZHn6+sb677p06drxIgRun//vsqVK6fGjRsrffr0mjt3rsqVK6fDhw9b982WLZsaN26sS5cuacOGDfGea9q0aZKk7t27W7eZzWa1b99e7dq10++//65SpUqpYcOGun//voYPH66aNWsqNDQ03uONGTNGHTt2lIuLi15//fU4zdD9+/erYsWKunDhgurVq6eCBQtq+/btql27to4e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Klu2rDJkyKBDhw7Fe829xFC3bl1J0ooVK5Lk+ABeDs0/AAAAAEihTCaT5s2bJzc3NwUEBGjgwIG6f/9+rH0sFotWr14tf39//f777y99zgsXLmjcuHGSpIYNG1q3xyzAsHz58ljX/Lt9+7a6detmXfjjSXr06KHw8HB9++23cnZ21ltvvRVnH1dXV3366acKCQlRs2bN4h1NePnyZc2dO/dFoiW5wMBABQUFqX///gnaP2aV3VGjRsXaPm7cOO3cuTNRanJ1ddVHH30kKXo15IsXL8a6/86dO9q2bdtLnaN58+by8/PT/PnzNXLkyDjX6bNYLNq1a5d27dr1UucB8GKY9gsAAAAAKVipUqX066+/qnnz5ho7dqy+/vprVapUSVmyZNGdO3e0f/9+Xbt2TW5ubtYpngn1ySefxLvab3h4uMqXL6++ffta9/X391fdunX1yy+/qFChQqpRo4YkaevWrcqUKZPefPNNrVq16onn6tSpkwYMGKAHDx7ozTfflI+PT7z7DRgwQCdPntTcuXPl6+ur0qVLK2/evAoPD9epU6d0/PhxlShRQh07dnyurClR//79tXTpUn355ZfaunWr8ufPryNHjujvv/9Wr169NHny5EQ5z2effaaDBw9q5cqVKlSokKpWrSofHx/9/fffOnDggOrWrWttRL4IJycnrVy5UvXq1dPQoUP1zTffqESJEvLx8VFQUJACAwN1/fp1/e9//3vmtRwBJD6afwAAAACQwlWpUkVnzpzR999/rzVr1ujw4cO6deuW3N3dVbhwYfXs2VNvv/32E1dOfZJly5ZZ/20ymeTh4aEyZcqoVatW6t27t1xcXGLtv2rVKo0ePVqLFy/W+vXr5ePjozZt2mjUqFGxGoXx8fT0VJkyZbRz5844C308zsHBQXPmzFGLFi00depU/f777zpw4IAyZsyoXLlyqV+/fmrduvVz5UypihYtqs2bN2vgwIH67bffdO7cOVWpUkWLFy/WwYMHE+08Tk5OWrZsmebOnauZM2dq//79evjwobJly6bXX39d77777kufo2DBgjp48KC++eYbLV++XHv37lVkZKSyZs2q0qVLq3HjxmrVqlUipAHwvEyW/y7FlAI1ql9f169fl4+Pj9Zu2GDrcpBAvG4AUjO+BtonXjcASDp///238ubNq1y5cuncuXMymUy2LgkAUgWu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S5YsISUlxUMJRERERERExBU08k9EPCs4GFq0gGuvhRtuOPv8H38YzcFnnoEuXYzrTp6EjRuN5p/NBj16GFOHS7CEhAQSExOpVasWR48epVOnTjz00ENMnjyZ1q1b57u2X79+ALRp04ZXXnmFSZMm8c033+BwOIiPj2fcuHGeiCAiIiIiIiIuoOafiHhedLSxlt8ff5z7mqwsWLr07ON2e966gCXYrtO7JqelpdG5c2eWLFnCggULWLRoEWvXruX777+nXbt2TJ06lWuvvZZTp04xZswY7rzzTn7++We6du3K559/TlJSkoeTiIiIiIiISFHStF8R8bywMIiNvbR7J0827i/hMjMzsdvtdOzYkS+++IIlS5YQHR3Nl19+ydGjR7nzzjt58803ycjIIDo6mrVr1/Liiy+yadMmDh48SOPGjbHb7WRkZHg6ioiIiIiIiBQhjfwTkeIhMtL4Gh1tTOXNzj73tTnnY2Pz7ivh/Pz8uO6661i2bBmff/45jzzyCF9++SUhpzdC2blzJwGnd1AuV64cderUYd++fVgsFsqXL09GRgZWqxV/7ZosIiIiIiJiKmr+iUjxYLEY03dbtoS4OFiyBHxOD06228FqNZ47HBAeblyrEX+5QkNDadGiBfv27ePOO++ka9euzJw5E4CyZcsyduxY/vnnH6Kiorjxxhv55ptvmDZtGiNHjqRs2bKsXLkSILdZKCJyuXxN/FOmsnknZfNOyuadzJwNzJ1P2cypBEcXkWIpLMx4pKRAfDwkJUFGBvj7Q0iIsatvCd/coyBDhgxh7ty5+Pn58frrr+c7V6ZMGQAqVapEly5d2LFjB76+vkybNo1bbrmFSZMmsXXrViwWCxElfNdkESk6J84zgNub+dqUzRspm3dSNu9k5mxg7nzK5p0K09RU809EiqegINCus4UWHBzMr7/+ysqVK2natOlZ51977TVSU1Pp3r07t912G6dOnWL79u3cddddLFq0CJvNRnh4OEFqrIqIiIiIiJiKmn8iIiYxZMgQ2rdvf95rnn/++QKP2+12orRrsoiIiIiIiOlot18REZMICwsj9hJ3TZ48eTJhWkNRRERERETEdNT8ExExkcjIyNwGoM12/sHdOedjY2OJ1K7JIiIiIiIipqTmn4iIiVgsFqKiokhMTCQ8PByLxYLVasV6erfknOcWi4Xw8HASExOJiorCYrF4uHIRERERERFxBa35JyJiQmFhYYSFhZGSkkJ8fDxJSUlkZGTg7+9PSEgIERER2txDRERERESkBFDzT0TExIKCghinXZNFRERERERKLE37FRERERERERERMSk1/0RERERERERERExKzT8RERERERERERGTUvNPRERERERERETEpNT8ExERERERERERMSk1/0RERERERERERExKzT8RERERERERERGTUvNPRERERERERETEpNT8ExERERERERERMSk1/0RERERERERERExKzT8RERERERERERGTsnm6ABEREREpRn7/HRYvhpQU6NULrr8eypb1dFUiIiIicok08k9EREREDFu2QJs28OWX8OmnMGgQ7Nrl6apERERE5DKo+SciIiIi8Ndf0K8fDB8OS5bA7t1wxRWwZo3bS/nowznc1qkjLZs1ZkD/PmzZvPm816/93xp6hHeh7Q3NefrJUWSdOpV7LjMzkx7hXdi/f5+ryy4UM2cDc+dTtjzKpmyupmx5lE3ZioKafyIiIiJiTPe1WuGee4yvAI0bG8cHDYL4eGMqsIutWP45sS9PYtjwR/ho/ifUrVuPh4fdR1paWoHXOxwOnnoiij59+zPzw4/Zvm0rC+bPyz3/2iux9Onbn5o1a7m89gsxczYwdz5ly6NsyuZqypZH2ZStqKj5JyIiIiJw5AgcOGCM+Dt5EiZPhkWLjOdpaTBtmnHs2DGXljHrg3juvKsvPXv15pqQEMaMfxZfX18+XbSwwOvT09NJT0+n3933EBISSvsOHdnz+24ANm38mW1btzBg0L0urbmwzJwNzJ1P2fIom7K5mrLlUTZlKypq/omIiIgIhIdDw4YwdCh06wZjxxobf7z+Onz+OfTuDZ99BqmpLish69QpdmzfRus2bXOP+fj40Lp1Wzb/srHAeypXrkzVqlVZu2Y1x48fZ+PPPxFaty5ZWVlMjJnA2PExWHNGMnqQmbOBufMpW37KpmyupGz5KZuyFRU1/0RERERKon//hcxMyMjIO/bttzB/Pjz2GDRqBC1bQna2cS4sDEqXhhMnXFZS+uF07HY7AQEB+Y4HBASQeo6mo8Vi4eW4V3n37anceUc36tWrT89evZkx/V1a3tCK0mXKMHhAf27v1oW5c2a7rPYLMXM2MHc+ZctP2ZTNlZQtP2VTtqJic8t3EREREZHiY/t2GDkSDh6Ef/6Bl182Nvuw2eDGG2HFCjh1Cs78QXfRIqhYEapW9VjZ59L8+hZ8OC9v6s3evXtYuvgzPl7wCRGDBzJg4L3c2C6M3j27c32LFlxbt54Hq704Zs4G5s6nbMpW3CibshU3yua+bBr5JyIiIlKSbN9ujOJr2BCio6F/f4iIgK1b865p0wYOHTKuGzcO7r/f2PBj+nSoVMllpVWqWAmr1XrWAtppaWlUqVKl0O/z3IRxRI0ajcPpZOeO7XTq0pWAgABatGjJhg0/FnXZhWLmbGDufMp2YcrmXsp2YcrmXsp2YZ7OpuafiIiISElx6JAx4m/AAJgyxdjZNy7OGO03Y4ZxTXY2VKgAX35pjAT8+mtjavCqVXDddS4tr1Tp0tRv0JD169bmHnM4HKxfv5Ym1zUr1HssWjifChUq0qHjLTjsdgCyT09dzs7Ozj3mbmbOBubOp2znp2zup2znp2zup2znVxyyqfknIiIiUlJkZcHhw3DXXcZrh8P4WqeO0RgEo+EH0KCBsQbgd9/B7NnGazcYNDiCRQvmsfjTT/h9926ej5nA8ePH6dnrzgvem5aWxnvvTOPJZ8YA4F+hAldffQ1zZn3AL5s2sn7dWpo2a+7iBOdm5mxg7nzKVjBlUzZXULaCKZuyXQ6t+SciIiJSUlSrZjTyQkON13Y7+PhArVrwxx/5rz1yxBgBWKaMW0vsels46YcOMfXN10lNPUjdevWZ+s50AgoxteblSRO5d/BQAgOr5R6LmfgiY58ZzYezZzE44j4aNW7iyvLPy8zZwNz5lK1gyqZsrqBsBVM2ZbscFqfT6XT5d7lM3bp25cCBAwQGBrJsxQpPlyMiIiImVaJ+5nA4jMYfwJgxsGGDsdEHwKRJRtNvxIi8kYAX6UR2EdVZzPjalM0bKZt3UjbvZOZsYO58yuadfAvxo5pG/omIiIiURD4+4HSCxZL3GowNPp5/HjZuvOTGn4iIiIgUH1rzT0RERKSkypkAYrNBUBDExsLLLxujAF28uYeIiIiIuIf+nCsiIiJSUuWM9itVCt57D/z9YfVqaO7ZjSNEREREpOho5J+IiIhISdeli/H1f/+DFi08W4uIiIiIFCmN/BMREREp6Vq0gMxMKFfO05WIiIiISBHTyD8RERERUeNPRERExKTU/BMRERERERERETEpNf9ERERERERERERMSs0/ERERERERERERk1LzT0RERERERERExKTU/BMRERERERERETEpNf9ERERERERERERMSs0/ERERERERERERk1LzT0RERERERERExKTU/BMRERERERERETEpNf9ERERERERERERMSs0/ERERERERERERk1LzT0RERERERERExKTU/BMRERERERERETEpNf9ERERERERERERMSs0/ERERERERERERk1LzT0RERERERERExKRsni5ARERERMzH18Q/ZSqbd1I276Rs3snM2cDc+ZTNnEpwdBERERFxlRPZnq7ANXxtyuaNlM07KZt3MnM2MHc+ZfNOhWlqatqviIiIiIiIiIiISan5JyIiIiIiIiIiYlJq/omIiIiIiIiIiJiUmn8iIiIiIiIiIiImpeafiIiIiIiIiIiISan5JyIiIiIiIiIiYlJq/omIiIiIiIiIiJiUmn8iIiIiIiIiIiImpeafiIiIiIiIiIiISan5JyIiIiIiIiIiYlJq/omIiIiIiIiIiJiUmn8iIiIiIiIiIiImpeafiIiIiIiIiIiISan5JyIiIiIiIiIiYlJq/omIiIiIiIiIiJiUmn8iIiIiIiIiIiImpeafiIiIiIiIiIiISan5JyIiIiIiIiIiYlJq/omIiIiIiIiIiJiUmn8iIiIiIiIiIiImpeafiIiIiOR37BicOuWxb//Rh3O4rVNHWjZrzID+fdiyefN5r1/7vzX0CO9C2xua8/STo8g6o/bMzEx6hHdh//59ri67UMycDcydT9nyKJuyuZqy5VE2ZSsKav6JiIiISJ6tW6FvX1i3Dk6edPu3X7H8c2JfnsSw4Y/w0fxPqFu3Hg8Pu4+0tLQCr3c4HDz1RBR9+vZn5ocfs33bVhbMn5d7/rVXYunTtz81a9ZyV4RzMnM2MHc+ZcujbMrmasqWR9mUraio+SciIiIihm3boF07uPJKqFMHypRxewmzPojnzrv60rNXb64JCWHM+Gfx9fXl00ULC7w+PT2d9PR0+t19DyEhobTv0JE9v+8GYNPGn9m2dQsDBt3rzgjnZOZsYO58ypZH2ZTN1ZQtj7IpW1FR809ERERE4N9/ITIS7r4b3n4bgoJg507YtAmSk91SQtapU+zYvo3WbdrmHvPx8aF167Zs/mVjgfdUrlyZqlWrsnbNao4fP87Gn38itG5dsrKymBgzgbHjY7BarW6p/3zMnA3MnU/Z8lM2ZXMlZctP2ZStqKj5JyIiIiJgsxlr/T3wANjt0LUr3HsvhIVBv37w/vsuLyH9cDp2u52AgIB8xwMCAkhNTS3wHovFwstxr/Lu21O5845u1KtXn569ejNj+ru0vKEVpcuUYfCA/tzerQtz58x2eYZzMXM2MHc+ZctP2ZTNlZQtP2VTtqJic8t3EREREZHi7fBh+PVXSE2FUaOMY9Onw/798O23MGYMVKgAd93l0TIL0vz6Fnw4L2/qzd69e1i6+DM+XvAJEYMHMmDgvdzYLozePbtzfYsWXFu3ngervThmzgbmzqdsylbcKJuyFTfK5r5sGvknIiIiIhAYCLfcAosXw65dMHIkNGlijAAcMQJuvRW++cYYFeh0uqSEShUrYbVaz1pAOy0tjSpVqhT6fZ6bMI6oUaNxOJ3s3LGdTl26EhAQQIsWLdmw4ceiLrtQzJwNzJ1P2S5M2dxL2S5M2dxL2S7M09nU/BMRERERsFggKgri42HZMjh1Ku/clVdCtWqwfTv4+BjXukCp0qWp36Ah69etzT3mcDhYv34tTa5rVqj3WLRwPhUqVKRDx1tw2O0AZGdn537NOeZuZs4G5s6nbOenbO6nbOenbO6nbOdXHLKp+SciIiIihhYtYPly4/m77xq7/+bIyoJrr4XTP6y6yqDBESxaMI/Fn37C77t383zMBI4fP07PXnde8N60tDTee2caTz4zBgD/ChW4+uprmDPrA37ZtJH169bStFlzl9Z/PmbOBubOp2wFUzZlcwVlK5iyKdvlsDidLpq3UYS6de3KgQMHCAwMZNmKFZ4uR0T+KzkZEhKMaWKZmeDnB6GhMGQIBAd7ujoRkULTzxynrVpl7Pp75ZXQuLExCnDxYli9Gho1KtRbnLiMHuHcObP5IP59UlMPUrdefUY/PYYmTa674H2joyNp2qw5dw8YmHtsy+bNjH1mNIfSDnHPwEE8NPzRSy8M8LUp2/kU13zKdn7KdjZlU7Zz0f8PnJuync1d2S5EzT8RuXSJiRAXB0uXGtPAwFgLKmfbcocDuneH6Ghjt0gRkWJOP3Oc4ddfYfZsWLfO+IPO8OGFbvzB5f/iV1wVxS+1xZWyeSdl807K5r3MnE/ZvFNhmn/a7VdELp7TaTT9Ro0Cm814bbdDpUoQEmKM/vvtN+P48uWwZAnExkJkpMvWiRIRkSJWty4895zxhxzI+yOPiIiIiHgV/RQnIhdvyhSj8QfG2k9VqsCMGXDoEPzwA+zYAYcPw2235a0NFR1t3CciIt7Fx0eNPxEREREvpp/kROTiJCYajbwcZcvCd99BeDiMHAlNmkD79rB5MzzxRP57o6ONdaRERERERERExC3U/BORixMXZ0z1zfHoo3DNNXDzzVChAnzyCbz6qjEFOGeqWA6bzbhfRERERERERNxCa/6JSOElJxube5y5T9CddxrHAgNhwoT8148dm/91drax/l9KCgQFubxcERERERERkZJOI/9EpPASEvKv+2S1QsuW8M03xrp/bdpAnTpwzz2Qlgb9+p39Hj4+EB/vtpJFRERERERESjI1/0Sk8Hbtyv+6TBmjAZiRAV9/bTQHf/vNmN4bHQ19+0KtWme/T1KSW8oVERERERERKenU/BORwsvMBLs97/WxY3D0KFSrBp06Qd26cOAADBsGK1ca19Svn/897HajWSgiIiIiIiIiLqfmn4gUnp+fMdLvTLt2QatWcPCg8bpWLeN5xYrG68zM/NdbreDv7/JSRURERERERETNPxG5GKGhZx9LSDA2/fj7b3jySfjgAxg1Ch5/HA4dgl9+OfuekBBXVyoiIiIiIiIiqPknIhdjyBBwOPIfmz7dWOfvxx+hZk1jPb8ZM2DwYBgxAk6cyH+9wwEREW4rWURERERERKQkU/NPRAovOBi6dzc29Mhx7BjcdBO8/jr07AmjR8PJk9C5M8yZk/9+mw169ICgILeWLSIiIiIiIlJSqfknIhcnOhqys/MfO3IExoyBq64y1gXs1Am++urse+12iIpyT50iIiIiIiIiouafiFyksDCIjb20eydPNu4XEREREREREbdQ809ELl5kZF4D8MwpwAXJOR8ba9wnIiIiIiIiIm6j5p+IXDyLxZi+m5gI4eHGa6vVeEDec4vFOJ+YaFxvsXi2bhEREREREZES5gJDdkREziMszHikpEB8vLHTb0YG+PtDSIixq6829xARERERERHxGDX/ROTyBQXBuHGerkJERERERERE/kPTfkVERERERERERExKzT8RERERERERERGTUvNPRERERERERETEpNT8ExERERERERERMSlt+CEiIiIiRc7XxD9lKpt3UjbvpGzeyczZwNz5lM2cSnB0EREREXGVE9mersA1fG3K5o2U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rVth/nxjw49582DFCnjqKWN30hYtjObfv/8a03/P5HBARIRn6hcREREREXEDTfsVERHvFBwMI0YYI/+uvx4qV4a//oL//S9vam9UFHz1lbH236pVxlTgHDYbhIdDUJBn6hcREREREXEDNf9ERMR7RUZC+/awcmXB548fh08+Kfic3W40B0VERERERExM035FRMR7hYVBbOyl3Tt5snG/iIiIiIiIian5JyIi3i0yMq8BaLvAgPac87Gxxn0iIiIiIiImp+afiIh4N4vFmL6bmGis4WexgNVqPCDvucVinE9MNK63WDxbt4iIiIiIiBtozT8RETGHsDDjkZIC8fGQlAQZGeDvDyEhxq6+2txDRERERERKGDX/RETEXIKCYNw4T1chIiIiIiJSLGjar4iIiIiIiIiIiEmp+SciIiIiIiIiImJSav6JiIiIiIiIiIiYlJp/IiIiIiIiIiIiJqXmn4iIiIiIiIiIiEmp+SciIiIiIiIiImJSav6JiIiIiIiIiIiYlJp/IiIiIiIiIiIiJqXmn4iIiIiIiIiIiEmp+SciIiIiIiIiImJSNk8X4AnJyckkJCSwa9cuMjMz8fPzIzQ0lCFDhhAcHOzp8kRERES8nq+Jf8pUNu+kbN5J2byTmbOBufMpmzmVqOiJiYnExcWxdOlSfHyMQY92ux2r1QrAhAkT6N69O9HR0YSFhXmyVBERERGvdiLb0xW4hq9N2byRsnknZfNOZs4G5s6nbN6pME3NEjHt1+l0EhsbS8+ePbFYLHTr1o0rrrgCu90OQI0aNbjvvvt4+OGH2b17N+3btycuLg6n0+nhykVERERERERERC5diRj5N2XKFCZMmMCmTZsICQkB4I8//uCaa67hiSee4IUXXuDUqVM4HA58fX154403+L//+z8AoqKiPFm6iIiIiIiIiIjIJTP9yL/ExESio6N54YUXqFGjBk2aNOGOO+7gqquu4uGHH2bixIk8//zz1K5dm2uuuYYRI0YwfPhwhg0bRnR0NKtWrfJ0BBERERERERERkUti+uZfXFwc7du3Z8SIETzzzDNcddVVHDlyBIARI0awbt063nvvPbZt28bvv//O2rVrmT9/Po899hg2m424uDgPJxAREREREREREbk0pm7+JScn8/XXXzN37lzWrFnDb7/9xoABA3LPh4aG8vHHH9O/f38qVaqEj48PERERzJs3jwYNGlC5cmWWLFlCSkqKB1OIiIiIiIiIiIhcGlM3/xISEoiJiaFixYqMGDGCqVOnYrFY8l2TnJxMnTp1+Omnn1i8eDF16tQhOTkZgFq1auHj40N8fLwnyhcREREREREREbkspt7w4+jRo4wbN44nn3ySQYMGUbt2bdavX5/vmlOnTmGz2cjOziY7O5tSpUpx6tQpAEqXLg1AUlKS22sXkXNzOBykp6fz999/88/ff+d+TUtL49SpU7n/PdvtdnwsFmw2G7ZSpbDZbFSoUIFq1apRrXp1qlevTvUaNQisWhVbqVKejiUiIiIiIiJS5Ezd/AsKCgLglVdeoVevXgwYMIA//viD3377jeHDhzN16lRq1KjBP//8wx133EGpUqXYvn07NWrUAGD//v3Y7XYyMjI8GUOkRPv7r7/YuHEjv2zaxB979xqNvn/+ISsrq8i+h8VioUqVKlSvXp2atWrRuEkTmjZtSkhoKFartci+j4iIiIiIiIi7mbr5d/z4cQCWLVuWe2zlypVkZmbSv39/ADp37kxcXBxPPfUUVatW5cUXX6Rz586kpqayf/9+rFYr/v7+HqlfpFhKToaEBNi1CzIzwc8PQkNhyBAIDr6st3Y4HPy+ezebNm1i08aNbNq0iX/+/rtIyj7TyVOnSEtL48TJkzgcDnx8fNi3fz9/7ttHmS1b+GLFCgDKlStnNAKbNaNps2Y0bNgQX1/fIq9HRERERERExFVM3fw7efIko0aN4vrrrwegf//+pKWlsXr1asLCwjh69Cg9e/Zk2rRpdO7cmQoVKvDbb7/x7rvv8u6772K327FarYSEhHg4iUgxkJgIcXGwdCn4nF4u1G6HnJFxEyZA9+4QHQ1hYYV+2xPHj5OYmMiXX3zBxp9/JjMz87zXlytf3piue/pRrXp1qlWrRvXq1alatSplfH2Nab42G1arFafDkTsNeNWqVbzx5pv8lJiIxWLB6XTidDpzn+//+28q+PtTvVo1/MqX599//2Xd2rWsW7sWAJvNRv0GDbj55pvpctttBAYGXsr/kiIiIiIiIiJuY+rm3+DBg6lduzZOpxOAw4cP079/f1q2bMm///5LcHAw8+bN45tvvuH777/nxIkTdOzYkW3btvHCCy8AxkikiIgIT8YQ8SynE374AU6ehBdfhBEj4PHHYds2qF0bhg6F6683nqemGs2/fv0gMhL+s8FODrvdzk8bNvD555/z3TffcOzYsQKv8/X1pVHjxjRt2pSmzZvToEED/Pz8LiGCk7i4OEaNGoXNZstt+l177bU0bNiQY8eOsWHDBtLS0jj677/8umsXN3foQCmbjUNpabnvk52dzZbNm9myeTNvvP46LVq2JLxbN27u2JFy5cpddF0iIiIiIiIirmbq5l9wcDDdu3dn+fLlZGdnM3bsWJKTk6lSpQqLFi0iPT2dLl260K9fP3r27EmpUqV49NFHiY+Pz90IJDw8PHftQJES6dQpYzrvDz/AihXQq5fR2HvgAVi9GkqVgh9/hC++gK5d4amn4M47jXujovK9VdKuXXy+bBlfrFjBgQMHzvpWFStWzJ1i27RpU+rWrVskG3FMmTKFUaNGAUYDLygoiLfffpvw8PB81w0bNox3330XgO9WrmTy5Mn079cvd83BTafXHQSjofjjDz/w4w8/8OKkSXTo0IHbunWjVatW2Gym/mgVERERERERL2Jx5gyLK8a6de3KgQMHCAwMZNnptbgKa9WqVbRv3/6Svq/FYmHlypWEXcQURhFTWb8eWrWCAQPg6FFYu9ZY7+/ECWPE3+HD8NBDkJICmzfDpElQqxZ07Gjcn5hIdtu2fP3VV8yeNYtfd+4861uUK1+eW2+9ldu6daNZs2b45EwpLiKJiYl06NAh93X58uXZtGkTPj4+PP3003z99deUL1+eV155hauuuormzZufdf+ZnwEpycks//xzln/+OX/++edZ369y5crc1acPffv3p0KFCkWaRURc73J+5pD8TmR7ugLX8LUpmzdSNu+kbN7JzNnA3PmUzTv5FmLsiemHp4SFhREbG0t0dPRF3zt58mQ1/qRke+klmDoV5swxXrdokXfuyBFj04+33zZed+6c71anzcZfUVEMr1GDff9pklltNtq2bUt4t27c1K6dSzfRiIuLw2azkZ1tfNI//PDD1KhRg0aNGhEVFcWMGTM4ceIER44cISUlJd+9NpuNuLi4fJ8DQcHBPPjQQzwwbBhbNm9m+eef89WXX3LkyBEADh06xLvvvMOsmTO58667GDBwIFWrVnVZPhEREREREZHzKdohNsVUZGQksbGxABecjpdzPjY2lsjISJfXJlJsJSfDp59Cs2bGOn4FadsW7r+/wFOW7Gyqb9hA9u+/5x6r36ABo0aP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xMXFsXTpUnx8jIH8drsd6+lNYSZMmED37t2Jjo4mLCzMk6WKiIiIiIgHadqviIiIF3E6ncTGxtKhQwduuOEGDhw4wOHDhzly5AhHjx7l77//ZuTIkTidTpYvX0779u2Ji4vD6XR6unQREREREfEAjfwTERHxIlOmTGHUqFGUK1eOp556ilmzZrFly5bc8y1btiQmJoa4uDiys7MBiI6OBiAqKsojNUvJ5GvinzKVzTspm3dSNu9k5mxg7nzKZk4a+SciIuIlEhMTcxt5pUuXxmq1snjxYk6ePEmTJk1o0qQJV155JceOHTvr3ujoaFatWuXukkVERERExMNKcN9TRETEu8TFxWGz2XJH9OW4+eab6d27d+7r+++//6xpvjabjbi4OK3/JyIiIiJSwmjkn4iIiBdITk5m6dKlZzX+AJ599lluuukm/u///o8DBw7w4IMP5m78kSM7O5slS5aQkpLirpJFRERERKQYUPNPRETECyQkJOTu6gtw6tQp7HY7w4cPZ86cOUydOpXDhw/TrVs3WrRowZ133nnWe/j4+BAfH+/OskVERERExMPU/BMREfECu3btyvf633//5ZVXXqFWrVqsWbOGo0ePMn36dLZv387vv/9Oy5YtC3yfpKQkd5QrIiIiIiLFhNb8ExER8QKZmZnY7fbc11dffTVDhgyhbNmyVKtWjYoVK5KVlYXdbqdUqVIFTg+22+1kZGS4s2wREREREfEwjfwTERHxAn5+fvnW8bv11lupUKECEydOpFy5ciQnJ3PXXXdx4403EhwczLp16856D6vVir+/vzvLFhERERERD9PIPxERES8QGhqa7/WRI0fw8fGhTJkyvPfee9hsNm699VYeeughvvzyS5YsWVLg+4SEhLijXBERERERKSbU/BMREfECQ4YMYcKECbmvFy9ezPTp0xkxYgSVKlUC4J9//uGVV15h4sSJOJ3Os97D4XAQERHhrpJFRERERKQYUPNPRETECwQHB9O9e3eWL19OdnY2x48f56GHHuKhhx6iQoUKOBwOMjMzz3m/zWYjPDycoKAgN1YtIiIiIiKepjX/REREvER0dHSBG3kcOXLkvI0/MDb7iIqKclVpIiIiIiJSTKn5JyIi4iXCwsKIjY29pHsnT55MWFhYEVckIiIiIiLFnZp/IiIiXiQyMjK3AWiznX/1jpzzsbGxREZG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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "FILE_PATH = \"FC Koln v Bayern.mp4\"\n", + "\n", + "pitch = VerticalPitch(\n", + " pitch_type=coordinates,\n", + " pitch_length=dataset.settings.pitch_dimensions.pitch_length,\n", + " pitch_width=dataset.settings.pitch_dimensions.pitch_width,\n", + " pitch_color=\"white\",\n", + " line_color=\"#343131\",\n", + ")\n", + "\n", + "fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 10), gridspec_kw={\"wspace\": 0.08})\n", + "fig.subplots_adjust(left=0.05, right=0.95, top=0.95, bottom=0.05)\n", + "\n", + "\n", + "def update(idx):\n", + " ax1.clear()\n", + " ax2.clear()\n", + "\n", + " pitch.draw(ax=ax1)\n", + " row = model.output.to_pandas().iloc[idx]\n", + " plot_pressing_intensity(row, ax1, ax2)\n", + "\n", + "\n", + "ani = FuncAnimation(fig, update, frames=len(model.output), repeat=False)\n", + "ani.save(\n", + " FILE_PATH, fps=kloppy_dataset.metadata.frame_rate, extra_args=[\"-vcodec\", \"libx264\"]\n", + ")" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".venv311", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.11" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/tests/files/sportec_events.xml b/tests/files/sportec_events.xml new file mode 100644 index 0000000..c8065cd --- /dev/null +++ b/tests/files/sportec_events.xml @@ -0,0 +1,6535 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/files/sportec_meta.xml b/tests/files/sportec_meta.xml new file mode 100644 index 0000000..46f1ffe --- /dev/null +++ b/tests/files/sportec_meta.xml @@ -0,0 +1,83 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/files/sportec_tracking.xml b/tests/files/sportec_tracking.xml new file mode 100644 index 0000000..3d8d3b1 --- /dev/null +++ b/tests/files/sportec_tracking.xml @@ -0,0 +1,537 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/test_bigdb.py b/tests/test_bigdb.py index 98de174..a7f1051 100644 --- a/tests/test_bigdb.py +++ b/tests/test_bigdb.py @@ -20,7 +20,7 @@ AmericanFootballGraphConverter, AmericanFootballPitchDimensions, ) -from unravel.american_football.graphs.dataset import Constant +from unravel.american_football.dataset import Constant from unravel.utils import ( flatten_to_reshaped_array, make_sparse, @@ -45,13 +45,31 @@ def plays(self, base_dir: Path) -> str: return base_dir / "files" / "bdb_plays-1.csv" @pytest.fixture - def dataset(self, coordinates: str, players: str, plays: str): + def default_dataset(self, coordinates: str, players: str, plays: str): bdb_dataset = BigDataBowlDataset( tracking_file_path=coordinates, players_file_path=players, plays_file_path=plays, + max_player_speed=8.0, + max_ball_speed=28.0, + max_player_acceleration=10.0, + max_ball_acceleration=10.0, + ) + bdb_dataset.add_graph_ids(by=["gameId", "playId"]) + bdb_dataset.add_dummy_labels(by=["gameId", "playId", "frameId"]) + return bdb_dataset + + @pytest.fixture + def non_default_dataset(self, coordinates: str, players: str, plays: str): + bdb_dataset = BigDataBowlDataset( + tracking_file_path=coordinates, + players_file_path=players, + plays_file_path=plays, + max_player_speed=12.0, + max_ball_speed=24.0, + max_player_acceleration=11.0, + max_ball_acceleration=12.0, ) - bdb_dataset.load() bdb_dataset.add_graph_ids(by=["gameId", "playId"]) bdb_dataset.add_dummy_labels(by=["gameId", "playId", "frameId"]) return bdb_dataset @@ -138,10 +156,6 @@ def node_feature_values(self): @pytest.fixture def arguments(self): return dict( - max_player_speed=8.0, - max_ball_speed=28.0, - max_player_acceleration=10.0, - max_ball_acceleration=10.0, self_loop_ball=True, adjacency_matrix_connect_type="ball", adjacency_matrix_type="split_by_team", @@ -156,10 +170,6 @@ def arguments(self): @pytest.fixture def non_default_arguments(self): return dict( - max_player_speed=12.0, - max_ball_speed=24.0, - max_player_acceleration=11.0, - max_ball_acceleration=12.0, self_loop_ball=False, adjacency_matrix_connect_type="ball", adjacency_matrix_type="dense_ap", @@ -171,12 +181,14 @@ def non_default_arguments(self): ) @pytest.fixture - def gnnc(self, dataset, arguments): - return AmericanFootballGraphConverter(dataset=dataset, **arguments) + def gnnc(self, default_dataset, arguments): + return AmericanFootballGraphConverter(dataset=default_dataset, **arguments) @pytest.fixture - def gnnc_non_default(self, dataset, non_default_arguments): - return AmericanFootballGraphConverter(dataset=dataset, **non_default_arguments) + def gnnc_non_default(self, non_default_dataset, non_default_arguments): + return AmericanFootballGraphConverter( + dataset=non_default_dataset, **non_default_arguments + ) def test_settings(self, gnnc_non_default, non_default_arguments): settings = gnnc_non_default.settings @@ -198,16 +210,7 @@ def test_settings(self, gnnc_non_default, non_default_arguments): assert settings.min_height == 150.0 assert settings.max_weight == 200.0 assert settings.min_weight == 60.0 - assert settings.max_ball_speed == non_default_arguments["max_ball_speed"] - assert settings.max_ball_speed == non_default_arguments["max_ball_speed"] - assert ( - settings.max_player_acceleration - == non_default_arguments["max_player_acceleration"] - ) - assert ( - settings.max_ball_acceleration - == non_default_arguments["max_ball_acceleration"] - ) + assert settings.self_loop_ball == non_default_arguments["self_loop_ball"] assert ( settings.adjacency_matrix_connect_type @@ -246,19 +249,23 @@ def test_raw_data(self, raw_dataset: pd.DataFrame): assert row_10["o"] == pytest.approx(100.77, rel=1e-9) assert row_10["dir"] == pytest.approx(55.29, rel=1e-9) - def test_dataset_loader(self, dataset: tuple): - assert isinstance(dataset, BigDataBowlDataset) - assert isinstance(dataset.data, pl.DataFrame) - assert isinstance(dataset.pitch_dimensions, AmericanFootballPitchDimensions) + def test_dataset_loader(self, default_dataset: tuple): + assert isinstance(default_dataset, BigDataBowlDataset) + assert isinstance(default_dataset.data, pl.DataFrame) + assert isinstance( + default_dataset.settings.pitch_dimensions, AmericanFootballPitchDimensions + ) - assert dataset.pitch_dimensions.pitch_length == 120.0 - assert dataset.pitch_dimensions.pitch_width == 53.3 - assert dataset.pitch_dimensions.x_dim.max == 60.0 - assert dataset.pitch_dimensions.y_dim.max == 26.65 - assert dataset.pitch_dimensions.standardized == False - assert dataset.pitch_dimensions.unit == Unit.YARDS + settings = default_dataset.settings + + assert settings.pitch_dimensions.pitch_length == 120.0 + assert settings.pitch_dimensions.pitch_width == 53.3 + assert settings.pitch_dimensions.x_dim.max == 60.0 + assert settings.pitch_dimensions.y_dim.max == 26.65 + assert settings.pitch_dimensions.standardized == False + assert settings.pitch_dimensions.unit == Unit.YARDS - data = dataset.data + data = default_dataset.data assert len(data) == 6049 diff --git a/tests/test_kloppy_polars.py b/tests/test_kloppy_polars.py index 983afee..1caae3d 100644 --- a/tests/test_kloppy_polars.py +++ b/tests/test_kloppy_polars.py @@ -1,12 +1,20 @@ from pathlib import Path -from unravel.soccer import SoccerGraphConverterPolars, KloppyPolarsDataset +from unravel.soccer import ( + SoccerGraphConverterPolars, + KloppyPolarsDataset, + PressingIntensity, + Constant, + Column, + Group, +) from unravel.utils import ( dummy_labels, dummy_graph_ids, CustomSpektralDataset, + reshape_array, ) -from kloppy import skillcorner +from kloppy import skillcorner, sportec from kloppy.domain import Ground, TrackingDataset, Orientation from typing import List, Dict @@ -15,6 +23,9 @@ import pytest import numpy as np +import numpy.testing as npt + +import polars as pl class TestKloppyPolarsData: @@ -26,6 +37,14 @@ def match_data(self, base_dir: Path) -> str: def structured_data(self, base_dir: Path) -> str: return base_dir / "files" / "skillcorner_structured_data.json.gz" + @pytest.fixture + def raw_sportec(self, base_dir: Path) -> str: + return base_dir / "files" / "sportec_tracking.xml" + + @pytest.fixture + def meta_sportec(self, base_dir: Path) -> str: + return base_dir / "files" / "sportec_meta.xml" + @pytest.fixture() def kloppy_dataset(self, match_data: str, structured_data: str) -> TrackingDataset: return skillcorner.load( @@ -36,6 +55,25 @@ def kloppy_dataset(self, match_data: str, structured_data: str) -> TrackingDatas limit=500, ) + @pytest.fixture() + def kloppy_dataset_sportec( + self, raw_sportec: str, meta_sportec: str + ) -> TrackingDataset: + return sportec.load_tracking( + raw_data=raw_sportec, + meta_data=meta_sportec, + coordinates="secondspectrum", + only_alive=False, + limit=500, + ) + + @pytest.fixture() + def kloppy_polars_sportec_dataset( + self, kloppy_dataset_sportec: TrackingDataset + ) -> KloppyPolarsDataset: + dataset = KloppyPolarsDataset(kloppy_dataset=kloppy_dataset_sportec) + return dataset + @pytest.fixture() def kloppy_polars_dataset( self, kloppy_dataset: TrackingDataset @@ -43,8 +81,11 @@ def kloppy_polars_dataset( dataset = KloppyPolarsDataset( kloppy_dataset=kloppy_dataset, ball_carrier_threshold=25.0, + max_player_speed=12.0, + max_player_acceleration=12.0, + max_ball_speed=13.5, + max_ball_acceleration=100, ) - dataset.load() dataset.add_dummy_labels(by=["game_id", "frame_id"]) dataset.add_graph_ids(by=["game_id", "frame_id"]) return dataset @@ -57,10 +98,6 @@ def spc_padding( dataset=kloppy_polars_dataset, chunk_size=2_0000, non_potential_receiver_node_value=0.1, - max_player_speed=12.0, - max_player_acceleration=12.0, - max_ball_speed=13.5, - max_ball_acceleration=100, self_loop_ball=True, adjacency_matrix_connect_type="ball", adjacency_matrix_type="split_by_team", @@ -80,10 +117,6 @@ def soccer_polars_converter( dataset=kloppy_polars_dataset, chunk_size=2_0000, non_potential_receiver_node_value=0.1, - max_player_speed=12.0, - max_player_acceleration=12.0, - max_ball_speed=13.5, - max_ball_acceleration=100, self_loop_ball=True, adjacency_matrix_connect_type="ball", adjacency_matrix_type="split_by_team", @@ -94,6 +127,323 @@ def soccer_polars_converter( verbose=False, ) + @pytest.fixture() + def soccer_polars_converter_graph_level_features( + self, kloppy_polars_dataset: KloppyPolarsDataset + ) -> SoccerGraphConverterPolars: + + kloppy_polars_dataset.data = ( + kloppy_polars_dataset.data + # note, normally you'd join these columns on a frame level + .with_columns( + [ + pl.lit(1).alias("fake_graph_feature_a"), + pl.lit(0.12).alias("fake_graph_feature_b"), + ] + ) + ) + + return SoccerGraphConverterPolars( + dataset=kloppy_polars_dataset, + graph_feature_cols=["fake_graph_feature_a", "fake_graph_feature_b"], + chunk_size=2_0000, + non_potential_receiver_node_value=0.1, + self_loop_ball=True, + adjacency_matrix_connect_type="ball", + adjacency_matrix_type="split_by_team", + label_type="binary", + defending_team_node_value=0.0, + random_seed=False, + pad=False, + verbose=False, + ) + + def test_pi_teams_max_home_away( + self, + kloppy_polars_sportec_dataset: KloppyPolarsDataset, + kloppy_dataset_sportec: TrackingDataset, + ): + assert len(kloppy_dataset_sportec) == 21 + assert len(kloppy_polars_sportec_dataset.data) == 21 * 23 + + model = PressingIntensity(dataset=kloppy_polars_sportec_dataset) + model.fit( + method="teams", ball_method="max", orient="home_away", speed_threshold=2 + ) + + assert isinstance(model.output, pl.DataFrame) + assert len(model.output) == 21 + assert "game_id" in model.output.columns + assert "period_id" in model.output.columns + assert "frame_id" in model.output.columns + assert "timestamp" in model.output.columns + assert "time_to_intercept" in model.output.columns + assert "probability_to_intercept" in model.output.columns + assert "columns" in model.output.columns + assert "rows" in model.output.columns + + row = model.output[0] + assert ( + row["time_to_intercept"].dtype + == row["probability_to_intercept"].dtype + == pl.List(pl.List(pl.Float64)) + ) + assert row["rows"].dtype == row["columns"].dtype == pl.List(pl.String) + + assert ( + reshape_array(row["rows"][0]).shape + == reshape_array(row["columns"][0]).shape + == (11,) + ) + assert ( + reshape_array(row["time_to_intercept"][0]).shape + == reshape_array(row["probability_to_intercept"][0]).shape + == (11, 11) + ) + home_team, away_team = kloppy_dataset_sportec.metadata.teams + assert reshape_array(row["rows"][0])[0] in [ + x.player_id for x in home_team.players + ] + assert reshape_array(row["columns"][0])[0] in [ + x.player_id for x in away_team.players + ] + + assert ( + kloppy_polars_sportec_dataset.data[Column.BALL_OWNING_TEAM_ID][0] + == home_team.team_id + ) + assert ( + pytest.approx(reshape_array(row["time_to_intercept"][0])[0][0], abs=1e-5) + == 2.6428493704618106 + ) + + def test_pi_teams_include_home_away( + self, + kloppy_polars_sportec_dataset: KloppyPolarsDataset, + kloppy_dataset_sportec: TrackingDataset, + ): + assert len(kloppy_dataset_sportec) == 21 + assert len(kloppy_polars_sportec_dataset.data) == 21 * 23 + + model = PressingIntensity(dataset=kloppy_polars_sportec_dataset) + model.fit( + method="teams", ball_method="include", orient="home_away", speed_threshold=2 + ) + row = model.output[0] + assert reshape_array(row["rows"][0]).shape == (12,) + assert reshape_array(row["columns"][0]).shape == (11,) + assert reshape_array(row["time_to_intercept"][0]).shape == (12, 11) + assert reshape_array(row["probability_to_intercept"][0]).shape == (12, 11) + + def test_pi_teams_exclude_home_away( + self, + kloppy_polars_sportec_dataset: KloppyPolarsDataset, + kloppy_dataset_sportec: TrackingDataset, + ): + assert len(kloppy_dataset_sportec) == 21 + assert len(kloppy_polars_sportec_dataset.data) == 21 * 23 + + model = PressingIntensity(dataset=kloppy_polars_sportec_dataset) + model.fit( + method="teams", ball_method="exclude", orient="home_away", speed_threshold=2 + ) + row = model.output[0] + + arr = reshape_array(row["probability_to_intercept"][0]) + count = np.count_nonzero(np.isclose(arr, 0.0, atol=1e-5)) + assert count == 121 + + assert reshape_array(row["rows"][0]).shape == (11,) + assert reshape_array(row["columns"][0]).shape == (11,) + assert reshape_array(row["time_to_intercept"][0]).shape == (11, 11) + assert reshape_array(row["probability_to_intercept"][0]).shape == (11, 11) + + def test_pi_full_max_home_away( + self, + kloppy_polars_sportec_dataset: KloppyPolarsDataset, + kloppy_dataset_sportec: TrackingDataset, + ): + assert len(kloppy_dataset_sportec) == 21 + assert len(kloppy_polars_sportec_dataset.data) == 21 * 23 + + model = PressingIntensity(dataset=kloppy_polars_sportec_dataset) + model.fit( + method="full", ball_method="max", orient="home_away", speed_threshold=2 + ) + row = model.output[0] + assert reshape_array(row["rows"][0]).shape == (22,) + assert reshape_array(row["columns"][0]).shape == (22,) + assert reshape_array(row["time_to_intercept"][0]).shape == (22, 22) + assert reshape_array(row["probability_to_intercept"][0]).shape == (22, 22) + + home_team, away_team = kloppy_dataset_sportec.metadata.teams + home_player_ids = [x.player_id for x in home_team.players] + away_player_ids = [x.player_id for x in away_team.players] + + for hp_id in reshape_array(row["rows"][0])[0:11]: + assert hp_id in home_player_ids + + for ap_id in reshape_array(row["rows"][0])[11:]: + assert ap_id in away_player_ids + + def test_pi_full_exclude_home_away( + self, + kloppy_polars_sportec_dataset: KloppyPolarsDataset, + kloppy_dataset_sportec: TrackingDataset, + ): + assert len(kloppy_dataset_sportec) == 21 + assert len(kloppy_polars_sportec_dataset.data) == 21 * 23 + + model = PressingIntensity(dataset=kloppy_polars_sportec_dataset) + model.fit( + method="full", ball_method="exclude", orient="home_away", speed_threshold=2 + ) + row = model.output[0] + assert reshape_array(row["rows"][0]).shape == (22,) + assert reshape_array(row["columns"][0]).shape == (22,) + npt.assert_array_equal( + reshape_array(row["rows"][0]), reshape_array(row["columns"][0]) + ) + assert reshape_array(row["time_to_intercept"][0]).shape == (22, 22) + assert reshape_array(row["probability_to_intercept"][0]).shape == (22, 22) + + def test_pi_full_include_home_away( + self, + kloppy_polars_sportec_dataset: KloppyPolarsDataset, + kloppy_dataset_sportec: TrackingDataset, + ): + assert len(kloppy_dataset_sportec) == 21 + assert len(kloppy_polars_sportec_dataset.data) == 21 * 23 + + model = PressingIntensity(dataset=kloppy_polars_sportec_dataset) + model.fit( + method="full", ball_method="include", orient="home_away", speed_threshold=2 + ) + row = model.output[0] + assert reshape_array(row["rows"][0]).shape == (23,) + assert reshape_array(row["columns"][0]).shape == (23,) + assert reshape_array(row["time_to_intercept"][0]).shape == (23, 23) + assert reshape_array(row["probability_to_intercept"][0]).shape == (23, 23) + + def test_pi_full_include_ball_owning( + self, + kloppy_polars_sportec_dataset: KloppyPolarsDataset, + kloppy_dataset_sportec: TrackingDataset, + ): + assert len(kloppy_dataset_sportec) == 21 + assert len(kloppy_polars_sportec_dataset.data) == 21 * 23 + + model = PressingIntensity(dataset=kloppy_polars_sportec_dataset) + model.fit( + method="full", + ball_method="include", + orient="ball_owning", + speed_threshold=2, + ) + row = model.output[0] + arr = reshape_array(row["probability_to_intercept"][0]) + count = np.count_nonzero(np.isclose(arr, 0.0, atol=1e-5)) + assert count == 527 + + assert reshape_array(row["rows"][0]).shape == (23,) + assert reshape_array(row["columns"][0]).shape == (23,) + assert reshape_array(row["time_to_intercept"][0]).shape == (23, 23) + assert reshape_array(row["probability_to_intercept"][0]).shape == (23, 23) + + home_team, away_team = kloppy_dataset_sportec.metadata.teams + home_player_ids = [x.player_id for x in home_team.players] + away_player_ids = [x.player_id for x in away_team.players] + + assert ( + kloppy_polars_sportec_dataset.data[Column.BALL_OWNING_TEAM_ID][0] + == home_team.team_id + ) + + for hp_id in reshape_array(row["rows"][0])[0:11]: + assert hp_id in home_player_ids + + for ap_id in reshape_array(row["rows"][0])[11:22]: + assert ap_id in away_player_ids + + assert reshape_array(row["rows"][0])[22] == Constant.BALL + + def test_pi_full_include_pressing( + self, + kloppy_polars_sportec_dataset: KloppyPolarsDataset, + kloppy_dataset_sportec: TrackingDataset, + ): + assert len(kloppy_dataset_sportec) == 21 + assert len(kloppy_polars_sportec_dataset.data) == 21 * 23 + + model = PressingIntensity(dataset=kloppy_polars_sportec_dataset) + model.fit( + method="full", ball_method="include", orient="pressing", speed_threshold=2 + ) + row = model.output[0] + assert reshape_array(row["rows"][0]).shape == (23,) + assert reshape_array(row["columns"][0]).shape == (23,) + assert reshape_array(row["time_to_intercept"][0]).shape == (23, 23) + assert reshape_array(row["probability_to_intercept"][0]).shape == (23, 23) + home_team, away_team = kloppy_dataset_sportec.metadata.teams + home_player_ids = [x.player_id for x in home_team.players] + away_player_ids = [x.player_id for x in away_team.players] + + assert ( + kloppy_polars_sportec_dataset.data[Column.BALL_OWNING_TEAM_ID][0] + == home_team.team_id + ) + + assert ( + reshape_array(row["rows"][0])[22] + == reshape_array(row["columns"][0])[22] + == Constant.BALL + ) + + for ap_id in reshape_array(row["columns"][0])[0:11]: + assert ap_id in away_player_ids + + for hp_id in reshape_array(row["rows"][0])[11:22]: + assert hp_id in home_player_ids + + def test_pi_teams_exclude_home_away_speed_0( + self, + kloppy_polars_sportec_dataset: KloppyPolarsDataset, + kloppy_dataset_sportec: TrackingDataset, + ): + assert len(kloppy_dataset_sportec) == 21 + assert len(kloppy_polars_sportec_dataset.data) == 21 * 23 + + model = PressingIntensity(dataset=kloppy_polars_sportec_dataset) + model.fit( + method="teams", ball_method="exclude", orient="home_away", speed_threshold=0 + ) + row = model.output[0] + + arr = reshape_array(row["probability_to_intercept"][0]) + count = np.count_nonzero(np.isclose(arr, 0.0, atol=1e-5)) + assert count == 33 + + def test_pi_full_include_ball_owning_speed_0( + self, + kloppy_polars_sportec_dataset: KloppyPolarsDataset, + kloppy_dataset_sportec: TrackingDataset, + ): + assert len(kloppy_dataset_sportec) == 21 + assert len(kloppy_polars_sportec_dataset.data) == 21 * 23 + + model = PressingIntensity(dataset=kloppy_polars_sportec_dataset) + model.fit( + method="full", + ball_method="include", + orient="ball_owning", + speed_threshold=0, + ) + row = model.output[0] + + arr = reshape_array(row["probability_to_intercept"][0]) + count = np.count_nonzero(np.isclose(arr, 0.0, atol=1e-5)) + assert count == 117 + def test_padding(self, spc_padding: SoccerGraphConverterPolars): spektral_graphs = spc_padding.to_spektral_graphs() @@ -121,7 +471,8 @@ def test_to_spektral_graph( x = data[0].x n_players = x.shape[0] assert x.shape == (n_players, 15) - assert 0.4524340998288571 == pytest.approx(x[0, 0], abs=1e-5) + print(">>>", x[0, 0]) + assert 0.5475659001711429 == pytest.approx(x[0, 0], abs=1e-5) assert 0.9948105277764999 == pytest.approx(x[0, 4], abs=1e-5) assert 0.2941671698429814 == pytest.approx(x[8, 2], abs=1e-5) @@ -186,3 +537,41 @@ def test_to_spektral_graph( dataset.split_test_train( split_train=4, split_test=5, by_graph_id=True, random_seed=42 ) + + def test_to_spektral_graph( + self, soccer_polars_converter_graph_level_features: SoccerGraphConverterPolars + ): + """ + Test navigating (next/prev) through events + """ + frame = soccer_polars_converter_graph_level_features.dataset.filter( + pl.col("graph_id") == "2417-1529" + ) + ball_index = ( + frame.select(pl.arg_where(pl.col("team_id") == Constant.BALL)) + .to_series() + .to_list()[0] + ) + assert len(frame) == 15 + + spektral_graphs = ( + soccer_polars_converter_graph_level_features.to_spektral_graphs() + ) + + assert 1 == 1 + + data = spektral_graphs + assert data[0].id == "2417-1529" + assert len(data) == 384 + assert isinstance(data[0], Graph) + + x = data[0].x + n_players = x.shape[0] + assert x.shape == (n_players, 17) + assert 0.5475659001711429 == pytest.approx(x[0, 0], abs=1e-5) + assert 0.8997899683121747 == pytest.approx(x[0, 4], abs=1e-5) + assert 0.2941671698429814 == pytest.approx(x[8, 2], abs=1e-5) + assert 1 == pytest.approx(x[ball_index, 15]) + assert 0.12 == pytest.approx(x[ball_index, 16]) + assert 0 == pytest.approx(x[0, 15]) + assert 0 == pytest.approx(x[13, 16]) diff --git a/tests/test_spektral.py b/tests/test_spektral.py index 6e14ae4..db273f9 100644 --- a/tests/test_spektral.py +++ b/tests/test_spektral.py @@ -43,8 +43,11 @@ def bdb_dataset(self, coordinates: str, players: str, plays: str): tracking_file_path=coordinates, players_file_path=players, plays_file_path=plays, + max_player_speed=8.0, + max_ball_speed=28.0, + max_player_acceleration=10.0, + max_ball_acceleration=10.0, ) - bdb_dataset.load() bdb_dataset.add_graph_ids(by=["gameId", "playId"]) bdb_dataset.add_dummy_labels(by=["gameId", "playId", "frameId"]) return bdb_dataset @@ -120,10 +123,6 @@ def bdb_converter( ) -> AmericanFootballGraphConverter: return AmericanFootballGraphConverter( dataset=bdb_dataset, - max_player_speed=8.0, - max_ball_speed=28.0, - max_player_acceleration=10.0, - max_ball_acceleration=10.0, self_loop_ball=True, adjacency_matrix_connect_type="ball", adjacency_matrix_type="split_by_team", @@ -141,10 +140,6 @@ def bdb_converter_preds( return AmericanFootballGraphConverter( dataset=bdb_dataset, prediction=True, - max_player_speed=8.0, - max_ball_speed=28.0, - max_player_acceleration=10.0, - max_ball_acceleration=10.0, self_loop_ball=True, adjacency_matrix_connect_type="ball", adjacency_matrix_type="split_by_team", diff --git a/unravel/american_football/__init__.py b/unravel/american_football/__init__.py index e1858db..1c60eee 100644 --- a/unravel/american_football/__init__.py +++ b/unravel/american_football/__init__.py @@ -1 +1,2 @@ from .graphs import * +from .dataset import * diff --git a/unravel/american_football/_example_helpers/__init__.py b/unravel/american_football/_example_helpers/__init__.py new file mode 100644 index 0000000..f4bd74c --- /dev/null +++ b/unravel/american_football/_example_helpers/__init__.py @@ -0,0 +1 @@ +from .utils import plays_variables, remove_plays diff --git a/unravel/american_football/_example_helpers/utils.py b/unravel/american_football/_example_helpers/utils.py new file mode 100644 index 0000000..6b96d2a --- /dev/null +++ b/unravel/american_football/_example_helpers/utils.py @@ -0,0 +1,189 @@ +import polars as pl + + +def remove_plays(data): + """ + Because we are trying to predict is a pass is being thrown from the pre-snap paterns + We remove plays that are pass or run + """ + remove_plays = ( + data.filter( + ((pl.col("frameType") == "BEFORE_SNAP") | (pl.col("frameType") == "SNAP")) + & ( + pl.col("event").is_in( + [ + "field_goal_play", + "pass_forward", + "timeout_away", + "timeout_home", + "snap_direct", + ] + ) + ) + ) + .select(["gameId", "playId"]) + .unique() + ) + data = ( + data.join(remove_plays, on=["gameId", "playId"], how="anti") + .filter( + (pl.col("frameType") == "BEFORE_SNAP") | (pl.col("frameType") == "SNAP") + ) + .sort(by=["gameId", "playId", "frameId", "nflId"]) + .with_columns( + pl.col("event") + .fill_null(strategy="forward") + .over(["gameId", "playId", "nflId"]) + .alias("event") + ) + .filter( + ~pl.col("event").is_in(["huddle_break_offense", "huddle_start_offense"]) + ) + .filter(pl.col("frameType") != "SNAP") + ) + return data + + +def plays_variables(plays, games): + data = plays.select( + [ + "gameId", + "playId", + "possessionTeam", + "quarter", + "down", + "yardsToGo", + "yardlineNumber", + "gameClock", + "preSnapHomeScore", + "preSnapVisitorScore", + "preSnapHomeTeamWinProbability", + "passResult", + "passLength", + "prePenaltyYardsGained", + "yardsGained", + ] + ).join(games.select(["gameId", "homeTeamAbbr", "visitorTeamAbbr"]), on="gameId") + print(data.columns) + data = data.with_columns( + [ + pl.when(pl.col("possessionTeam") == pl.col("homeTeamAbbr")) + .then(True) + .otherwise(False) + .alias("isHome"), + pl.when(pl.col("possessionTeam") == pl.col("homeTeamAbbr")) + .then(pl.col("preSnapHomeScore") - pl.col("preSnapVisitorScore")) + .otherwise(pl.col("preSnapVisitorScore") - pl.col("preSnapHomeScore")) + .alias("preSnapScoreDiff"), + pl.when(pl.col("possessionTeam") == pl.col("homeTeamAbbr")) + .then(pl.col("preSnapHomeTeamWinProbability")) + .otherwise(1 - pl.col("preSnapHomeTeamWinProbability")) + .alias("preSnapTeamWinProbability"), + pl.col("gameClock") + .str.split(":") + .list.get(0) + .cast(pl.Int32) + .alias("quarterMinute"), + pl.col("gameClock") + .str.split(":") + .list.get(1) + .cast(pl.Int32) + .alias("quarterSecond"), + ] + ) + data = data.with_columns( + [ + pl.when(pl.col("quarter") <= 4) + .then( + 3600 + - ( + (pl.col("quarter").clip(lower_bound=1) - 1) * 900 + + (pl.col("quarterMinute") * 60 + pl.col("quarterSecond")) + ) + ) + .otherwise(600 - (pl.col("quarterMinute") * 60 + pl.col("quarterSecond"))) + .alias("gameSecondsLeft"), + pl.when(pl.col("quarter") <= 4) + .then(900 - (pl.col("quarterMinute") * 60 + pl.col("quarterSecond"))) + .otherwise(600 - (pl.col("quarterMinute") * 60 + pl.col("quarterSecond"))) + .alias("quarterSecondsLeft"), + pl.col("passResult").is_not_null().alias("isPass"), + pl.col("yardsToGo").alias("yardsToGoText"), + ( + pl.col("visitorTeamAbbr").cast(str) + + " " + + pl.col("preSnapVisitorScore").cast(str) + + " - " + + pl.col("homeTeamAbbr").cast(str) + + " " + + pl.col("preSnapHomeScore").cast(str) + ).alias("scoreBoard"), + ] + ) + print(data.columns) + data = data.with_columns( + [ + pl.col("yardsToGo") / 50.0, + pl.col("preSnapScoreDiff") / 100.0, + pl.when(pl.col("quarter") <= 4) + .then(pl.col("quarterMinute") / 15.0) + .otherwise(pl.col("quarterMinute") / 10.0) + .alias("quarterMinute"), + pl.col("quarterSecond") / 60.0, + pl.when(pl.col("quarter") <= 4) + .then(pl.col("gameSecondsLeft") / 3600) + .otherwise(pl.col("gameSecondsLeft") / 600) + .alias("gameSecondsLeft"), + pl.when(pl.col("quarter") <= 4) + .then((pl.col("quarterMinute") * 60 + pl.col("quarterSecond")) / 900) + .otherwise((pl.col("quarterMinute") * 60 + pl.col("quarterSecond")) / 600) + .alias("quarterSecondsLeft"), + pl.col("yardlineNumber") / 50, + (pl.col("prePenaltyYardsGained") / 100.0).alias( + "prePenaltyYardsGainedNorm" + ), + ] + ) + + data = data.drop( + [ + "gameClock", + "preSnapHomeScore", + "preSnapVisitorScore", + "preSnapHomeTeamWinProbability", + "passResult", + "passLength", + "homeTeamAbbr", + "possessionTeam", + ] + ) + + y_columns = [ + "prePenaltyYardsGained", + "yardsGained", + "isPass", + "prePenaltyYardsGainedNorm", + ] + + x_columns = [ + "quarter", + "down", + "yardsToGo", + "isHome", + "preSnapScoreDiff", + "preSnapTeamWinProbability", + "quarterMinute", + "quarterSecond", + "gameSecondsLeft", + "quarterSecondsLeft", + "yardlineNumber", + ] + + other_columns = [ + col for col in data.columns if col not in y_columns and col not in x_columns + ] + + return { + "data": data, + "columns": {"x": x_columns, "y": y_columns, "other": other_columns}, + } diff --git a/unravel/american_football/dataset/__init__.py b/unravel/american_football/dataset/__init__.py new file mode 100644 index 0000000..cdd22c6 --- /dev/null +++ b/unravel/american_football/dataset/__init__.py @@ -0,0 +1,2 @@ +from .dataset import * +from .objects import * diff --git a/unravel/american_football/graphs/dataset.py b/unravel/american_football/dataset/dataset.py similarity index 53% rename from unravel/american_football/graphs/dataset.py rename to unravel/american_football/dataset/dataset.py index 29c4fd6..30a038e 100644 --- a/unravel/american_football/graphs/dataset.py +++ b/unravel/american_football/dataset/dataset.py @@ -6,40 +6,17 @@ import numpy as np -from .graph_settings import AmericanFootballPitchDimensions, Dimension, Unit -from ...utils import DefaultDataset, add_dummy_label_column, add_graph_id_column +from kloppy.domain import Dimension, Unit, Orientation +from ...utils import ( + DefaultSettings, + DefaultDataset, + AmericanFootballPitchDimensions, + add_dummy_label_column, + add_graph_id_column, +) -class Constant: - BALL = "football" - QB = "QB" - - -class Column: - OBJECT_ID = "nflId" - - GAME_ID = "gameId" - FRAME_ID = "frameId" - PLAY_ID = "playId" - - X = "x" - Y = "y" - - ACCELERATION = "a" - SPEED = "s" - ORIENTATION = "o" - DIRECTION = "dir" - TEAM = "team" - CLUB = "club" - OFFICIAL_POSITION = "officialPosition" - POSSESSION_TEAM = "possessionTeam" - HEIGHT_CM = "height_cm" - WEIGHT_KG = "weight_kg" - - -class Group: - BY_FRAME = [Column.GAME_ID, Column.PLAY_ID, Column.FRAME_ID] - BY_PLAY_POSSESSION_TEAM = [Column.GAME_ID, Column.PLAY_ID, Column.POSSESSION_TEAM] +from .objects import Column, Group, Constant @dataclass @@ -50,6 +27,11 @@ def __init__( players_file_path: str, plays_file_path: str, sample_rate: float = None, + max_player_speed: float = 12.0, + max_ball_speed: float = 28.0, + max_player_acceleration: float = 6.0, + max_ball_acceleration: float = 13.5, + orient_ball_owning: bool = True, **kwargs, ): super().__init__(**kwargs) @@ -57,11 +39,40 @@ def __init__( self.players_file_path = players_file_path self.plays_file_path = plays_file_path self.sample_rate = 1 if sample_rate is None else sample_rate - self.pitch_dimensions = AmericanFootballPitchDimensions() + + self._max_player_speed = max_player_speed + self._max_ball_speed = max_ball_speed + self._max_player_acceleration = max_player_acceleration + self._max_ball_acceleration = max_ball_acceleration + self._orient_ball_owning = orient_ball_owning + + self.load() + + def __apply_settings( + self, + ): + return DefaultSettings( + provider="nfl", + home_team_id=None, + away_team_id=None, + pitch_dimensions=AmericanFootballPitchDimensions(), + orientation=( + Orientation.BALL_OWNING_TEAM + if self._orient_ball_owning + else Orientation.NOT_SET + ), + max_player_speed=self._max_player_speed, + max_ball_speed=self._max_ball_speed, + max_player_acceleration=self._max_player_acceleration, + max_ball_acceleration=self._max_ball_acceleration, + ball_carrier_threshold=None, + ) def load(self): - pitch_length = self.pitch_dimensions.pitch_length - pitch_width = self.pitch_dimensions.pitch_width + self.settings = self.__apply_settings() + + pitch_length = self.settings.pitch_dimensions.pitch_length + pitch_width = self.settings.pitch_dimensions.pitch_width sample = 1.0 / self.sample_rate @@ -79,57 +90,63 @@ def load(self): df = df.with_columns(pl.col(Column.CLUB).alias(Column.TEAM)) df = df.drop(Column.CLUB) - df = ( - df.with_columns( - pl.when(pl.col("playDirection") == play_direction) - .then(pl.col(Column.ORIENTATION) + 180) # rotate 180 degrees - .otherwise(pl.col(Column.ORIENTATION)) - .alias(Column.ORIENTATION), - pl.when(pl.col("playDirection") == play_direction) - .then(pl.col(Column.DIRECTION) + 180) # rotate 180 degrees - .otherwise(pl.col(Column.DIRECTION)) - .alias(Column.DIRECTION), - ) - .with_columns( - [ - (pl.col(Column.X) - (pitch_length / 2)).alias(Column.X), - (pl.col(Column.Y) - (pitch_width / 2)).alias(Column.Y), - # convert to radian on (-pi, pi) range - ( - ((pl.col(Column.ORIENTATION) * np.pi / 180) + np.pi) - % (2 * np.pi) - - np.pi - ).alias(Column.ORIENTATION), - ( - ((pl.col(Column.DIRECTION) * np.pi / 180) + np.pi) % (2 * np.pi) - - np.pi - ).alias(Column.DIRECTION), - ] - ) - .with_columns( - [ + if self._orient_ball_owning: + df = ( + df.with_columns( pl.when(pl.col("playDirection") == play_direction) - .then(pl.col(Column.X) * -1.0) - .otherwise(pl.col(Column.X)) - .alias(Column.X), + .then(pl.col(Column.ORIENTATION) + 180) # rotate 180 degrees + .otherwise(pl.col(Column.ORIENTATION)) + .alias(Column.ORIENTATION), pl.when(pl.col("playDirection") == play_direction) - .then(pl.col(Column.Y) * -1.0) - .otherwise(pl.col(Column.Y)) - .alias(Column.Y), - # set "football" to nflId -9999 for ordering purposes - pl.when(pl.col(Column.TEAM) == Constant.BALL) - .then(-9999.9) - .otherwise(pl.col(Column.OBJECT_ID)) - .alias(Column.OBJECT_ID), - ] - ) - .with_columns( - [ - pl.lit(play_direction).alias("playDirection"), - ] + .then(pl.col(Column.DIRECTION) + 180) # rotate 180 degrees + .otherwise(pl.col(Column.DIRECTION)) + .alias(Column.DIRECTION), + ) + .with_columns( + [ + (pl.col(Column.X) - (pitch_length / 2)).alias(Column.X), + (pl.col(Column.Y) - (pitch_width / 2)).alias(Column.Y), + # convert to radian on (-pi, pi) range + ( + ((pl.col(Column.ORIENTATION) * np.pi / 180) + np.pi) + % (2 * np.pi) + - np.pi + ).alias(Column.ORIENTATION), + ( + ((pl.col(Column.DIRECTION) * np.pi / 180) + np.pi) + % (2 * np.pi) + - np.pi + ).alias(Column.DIRECTION), + ] + ) + .with_columns( + [ + pl.when(pl.col("playDirection") == play_direction) + .then(pl.col(Column.X) * -1.0) + .otherwise(pl.col(Column.X)) + .alias(Column.X), + pl.when(pl.col("playDirection") == play_direction) + .then(pl.col(Column.Y) * -1.0) + .otherwise(pl.col(Column.Y)) + .alias(Column.Y), + # set "football" to nflId -9999 for ordering purposes + pl.when(pl.col(Column.TEAM) == Constant.BALL) + .then(-9999.9) + .otherwise(pl.col(Column.OBJECT_ID)) + .alias(Column.OBJECT_ID), + ] + ) + .with_columns( + [ + pl.lit(play_direction).alias("playDirection"), + ] + ) + .filter((pl.col(Column.FRAME_ID) % sample) == 0) + ).collect() + else: + raise NotImplementedError( + "Currently, BigDataBowlDataset only allows Orientation.BALL_OWNING" ) - .filter((pl.col(Column.FRAME_ID) % sample) == 0) - ).collect() players = pl.read_csv( self.players_file_path, @@ -182,7 +199,7 @@ def load(self): self.data = df # update pitch dimensions to how it looks after loading - self.pitch_dimensions = AmericanFootballPitchDimensions( + self.settings.pitch_dimensions = AmericanFootballPitchDimensions( x_dim=Dimension(min=-pitch_length / 2, max=pitch_length / 2), y_dim=Dimension(min=-pitch_width / 2, max=pitch_width / 2), standardized=False, @@ -191,7 +208,7 @@ def load(self): pitch_width=pitch_width, ) - return self.data, self.pitch_dimensions + return self.data, self.settings def add_dummy_labels( self, by: List[str] = ["gameId", "playId", "frameId"] diff --git a/unravel/american_football/dataset/objects.py b/unravel/american_football/dataset/objects.py new file mode 100644 index 0000000..379b40e --- /dev/null +++ b/unravel/american_football/dataset/objects.py @@ -0,0 +1,30 @@ +class Constant: + BALL = "football" + QB = "QB" + + +class Column: + OBJECT_ID = "nflId" + + GAME_ID = "gameId" + FRAME_ID = "frameId" + PLAY_ID = "playId" + + X = "x" + Y = "y" + + ACCELERATION = "a" + SPEED = "s" + ORIENTATION = "o" + DIRECTION = "dir" + TEAM = "team" + CLUB = "club" + OFFICIAL_POSITION = "officialPosition" + POSSESSION_TEAM = "possessionTeam" + HEIGHT_CM = "height_cm" + WEIGHT_KG = "weight_kg" + + +class Group: + BY_FRAME = [Column.GAME_ID, Column.PLAY_ID, Column.FRAME_ID] + BY_PLAY_POSSESSION_TEAM = [Column.GAME_ID, Column.PLAY_ID, Column.POSSESSION_TEAM] diff --git a/unravel/american_football/graphs/__init__.py b/unravel/american_football/graphs/__init__.py index a45c95f..6cfa61a 100644 --- a/unravel/american_football/graphs/__init__.py +++ b/unravel/american_football/graphs/__init__.py @@ -3,5 +3,4 @@ AmericanFootballGraphSettings, AmericanFootballPitchDimensions, ) -from .dataset import BigDataBowlDataset from .features import * diff --git a/unravel/american_football/graphs/features/adjacency_matrix.py b/unravel/american_football/graphs/features/adjacency_matrix.py index 4272871..de6e35c 100644 --- a/unravel/american_football/graphs/features/adjacency_matrix.py +++ b/unravel/american_football/graphs/features/adjacency_matrix.py @@ -1,7 +1,7 @@ import numpy as np from ....utils import AdjacencyMatrixType, AdjacenyMatrixConnectType -from ..dataset import Constant +from ...dataset import Constant def compute_adjacency_matrix(team, possession_team, settings): diff --git a/unravel/american_football/graphs/features/edge_features.py b/unravel/american_football/graphs/features/edge_features.py index 78f491c..a1ffca6 100644 --- a/unravel/american_football/graphs/features/edge_features.py +++ b/unravel/american_football/graphs/features/edge_features.py @@ -8,7 +8,7 @@ normalize_speed_differences_nfl, normalize_accelerations_nfl, ) -from ..dataset import Constant +from ...dataset import Constant def compute_edge_features(adjacency_matrix, p, s, a, o, dir, team, settings): diff --git a/unravel/american_football/graphs/features/node_features.py b/unravel/american_football/graphs/features/node_features.py index 59737a3..fd9a646 100644 --- a/unravel/american_football/graphs/features/node_features.py +++ b/unravel/american_football/graphs/features/node_features.py @@ -12,7 +12,7 @@ normalize_between, ) -from ..dataset import Constant +from ...dataset import Constant def compute_node_features( @@ -51,7 +51,8 @@ def compute_node_features( ball_index = np.where(team == ball_id)[0] ball_position = position[ball_index][0] else: - ball_position = np.sarray([np.nan, np.nan]) + ball_position = np.asarray([np.nan, np.nan]) + ball_index = 0 x_normed = normalize_between( value=x, diff --git a/unravel/american_football/graphs/graph_converter.py b/unravel/american_football/graphs/graph_converter.py index d6df259..ffdb9cc 100644 --- a/unravel/american_football/graphs/graph_converter.py +++ b/unravel/american_football/graphs/graph_converter.py @@ -9,7 +9,7 @@ from spektral.data import Graph -from .dataset import BigDataBowlDataset, Group, Column, Constant +from ..dataset import BigDataBowlDataset, Group, Column, Constant from .graph_settings import ( AmericanFootballGraphSettings, @@ -36,6 +36,9 @@ class AmericanFootballGraphConverter(DefaultGraphConverter): graph_features_as_node_features_columns (list): List of columns in the dataset that are Graph level features (e.g. team strength rating, win probabilities etc) we want to add to our model. They will be recorded as Node Features on the "football" node. + They should be joined to the BigDataBowlDataset.data dataframe such that + each Group in the group_by has the same value per column. We take the first value of the group, and assign this as a + "graph level feature" to the ball node. """ def __init__( @@ -60,15 +63,12 @@ def __init__( else dataset._graph_id_column ) - self.dataset: pl.DataFrame = dataset.data - self.pitch_dimensions: AmericanFootballPitchDimensions = ( - dataset.pitch_dimensions - ) self.chunk_size = chunk_size self.attacking_non_qb_node_value = attacking_non_qb_node_value self.graph_feature_cols = graph_feature_cols + self.settings = self._apply_graph_settings(settings=dataset.settings) - self.settings = self._apply_settings() + self.dataset: pl.DataFrame = dataset.data self._sport_specific_checks() @@ -140,13 +140,13 @@ def __remove_with_missing_football(): __remove_with_missing_values(min_object_count=10) __remove_with_missing_football() - def _apply_settings(self): + def _apply_graph_settings(self, settings): return AmericanFootballGraphSettings( - pitch_dimensions=self.pitch_dimensions, - max_player_speed=self.max_player_speed, - max_ball_speed=self.max_ball_speed, - max_ball_acceleration=self.max_ball_acceleration, - max_player_acceleration=self.max_player_acceleration, + pitch_dimensions=settings.pitch_dimensions, + max_player_speed=settings.max_player_speed, + max_ball_speed=settings.max_ball_speed, + max_ball_acceleration=settings.max_ball_acceleration, + max_player_acceleration=settings.max_player_acceleration, self_loop_ball=self.self_loop_ball, adjacency_matrix_connect_type=self.adjacency_matrix_connect_type, adjacency_matrix_type=self.adjacency_matrix_type, @@ -185,6 +185,17 @@ def __exprs_variables(self): def __compute(self, args: List[pl.Series]) -> dict: d = {col: args[i].to_numpy() for i, col in enumerate(self.__exprs_variables)} + if self.graph_feature_cols is not None: + failed = [ + col + for col in self.graph_feature_cols + if not np.all(d[col] == d[col][0]) + ] + if failed: + raise ValueError( + f"""graph_feature_cols contains multiple different values for a group in the groupby ({Group.BY_FRAME}) selection for the columns {failed}. Make sure each group has the same values per individual column.""" + ) + graph_features = ( np.asarray([d[col] for col in self.graph_feature_cols]).T[0] if self.graph_feature_cols @@ -312,14 +323,14 @@ def process_chunk(chunk: pl.DataFrame) -> List[dict]: return [ { "a": make_sparse( - reshape_array( + reshape_from_size( chunk["a"][i], chunk["a_shape_0"][i], chunk["a_shape_1"][i] ) ), - "x": reshape_array( + "x": reshape_from_size( chunk["x"][i], chunk["x_shape_0"][i], chunk["x_shape_1"][i] ), - "e": reshape_array( + "e": reshape_from_size( chunk["e"][i], chunk["e_shape_0"][i], chunk["e_shape_1"][i] ), "y": np.asarray([chunk[self.label_column][i]]), diff --git a/unravel/american_football/graphs/graph_settings.py b/unravel/american_football/graphs/graph_settings.py index 9c96dfe..a1dacbb 100644 --- a/unravel/american_football/graphs/graph_settings.py +++ b/unravel/american_football/graphs/graph_settings.py @@ -1,27 +1,9 @@ -from ...utils import DefaultGraphSettings +from ...utils import DefaultGraphSettings, AmericanFootballPitchDimensions from dataclasses import dataclass, field from kloppy.domain import Dimension, Unit from typing import Optional -PITCH_LENGTH = 120.0 -PITCH_WIDTH = 53.3 - - -@dataclass -class AmericanFootballPitchDimensions: - pitch_length: float = PITCH_LENGTH - pitch_width: float = PITCH_WIDTH - standardized: bool = False - unit: Unit = Unit.YARDS - - x_dim: Dimension = field(default_factory=lambda: Dimension(min=0, max=PITCH_LENGTH)) - y_dim: Dimension = field(default_factory=lambda: Dimension(min=0, max=PITCH_WIDTH)) - end_zone: float = field(init=False) - - def __post_init__(self): - self.end_zone = self.x_dim.max - 10 # Calculated value - @dataclass class AmericanFootballGraphSettings(DefaultGraphSettings): diff --git a/unravel/soccer/__init__.py b/unravel/soccer/__init__.py index e1858db..ec9d779 100644 --- a/unravel/soccer/__init__.py +++ b/unravel/soccer/__init__.py @@ -1 +1,3 @@ from .graphs import * +from .models import * +from .dataset import * diff --git a/unravel/soccer/dataset/__init__.py b/unravel/soccer/dataset/__init__.py new file mode 100644 index 0000000..c3f61dc --- /dev/null +++ b/unravel/soccer/dataset/__init__.py @@ -0,0 +1,3 @@ +from .kloppy_polars import * +from .utils import * +from .objects import * diff --git a/unravel/soccer/graphs/dataset.py b/unravel/soccer/dataset/kloppy_polars.py similarity index 62% rename from unravel/soccer/graphs/dataset.py rename to unravel/soccer/dataset/kloppy_polars.py index b15c452..b976b74 100644 --- a/unravel/soccer/graphs/dataset.py +++ b/unravel/soccer/dataset/kloppy_polars.py @@ -5,13 +5,23 @@ DatasetTransformer, DatasetFlag, SecondSpectrumCoordinateSystem, + MetricPitchDimensions, + Provider, ) -from typing import List, Dict, Union +from typing import List, Dict, Union, Literal, Tuple from dataclasses import field, dataclass -from ...utils import DefaultDataset, add_dummy_label_column, add_graph_id_column +from ...utils import ( + DefaultDataset, + DefaultSettings, + add_dummy_label_column, + add_graph_id_column, +) + +from .objects import Column, Group, Constant +from .utils import apply_speed_acceleration_filters import polars as pl @@ -20,49 +30,20 @@ DEFAULT_BALL_SMOOTHING_PARAMS = {"window_length": 3, "polyorder": 1} -class Constant: - BALL = "ball" - - -class Column: - BALL_OWNING_TEAM_ID = "ball_owning_team_id" - BALL_OWNING_PLAYER_ID = "ball_owning_player_id" - IS_BALL_CARRIER = "is_ball_carrier" - PERIOD_ID = "period_id" - TIMESTAMP = "timestamp" - BALL_STATE = "ball_state" - FRAME_ID = "frame_id" - GAME_ID = "game_id" - TEAM_ID = "team_id" - OBJECT_ID = "id" - POSITION_NAME = "position_name" - - X = "x" - Y = "y" - Z = "z" - - SPEED = "v" - VX = "vx" - VY = "vy" - VZ = "vz" - - ACCELERATION = "a" - AX = "ax" - AY = "ay" - AZ = "az" - - -class Group: - BY_FRAME = [Column.GAME_ID, Column.PERIOD_ID, Column.FRAME_ID] - BY_FRAME_TEAM = [Column.GAME_ID, Column.PERIOD_ID, Column.FRAME_ID, Column.TEAM_ID] - BY_OBJECT_PERIOD = [Column.OBJECT_ID, Column.PERIOD_ID] - - @dataclass class SoccerObject: id: Union[str, int] team_id: Union[str, int] position_name: str + number: int = None + name: str = None + team_name: str = None + is_gk: bool = None + is_home: bool = None + object_type: Literal["ball", "player"] = "player" + + def __repr__(self): + return f"({self.object_type.capitalize()} name={self.name}, number={self.number}, player_id={self.id}, is_gk={self.is_gk}, is_home={self.is_home})" @dataclass @@ -71,37 +52,74 @@ def __init__( self, kloppy_dataset: TrackingDataset, ball_carrier_threshold: float = 25.0, + max_player_speed: float = 12.0, + max_ball_speed: float = 28.0, + max_player_acceleration: float = 6.0, + max_ball_acceleration: float = 13.5, + orient_ball_owning: bool = True, **kwargs, ): super().__init__(**kwargs) self.kloppy_dataset = kloppy_dataset - self.ball_carrier_threshold = ball_carrier_threshold - self._overwrite_orientation: bool = False + + self._ball_carrier_threshold = ball_carrier_threshold + self._max_player_speed = max_player_speed + self._max_ball_speed = max_ball_speed + self._max_player_acceleration = max_player_acceleration + self._max_ball_acceleration = max_ball_acceleration + self._orient_ball_owning = orient_ball_owning self._infer_goalkeepers: bool = False if not isinstance(self.kloppy_dataset, TrackingDataset): raise Exception("'kloppy_dataset' should be of type float") - if not isinstance(self.ball_carrier_threshold, float): + if not isinstance(self._ball_carrier_threshold, float): raise Exception("'ball_carrier_threshold' should be of type float") - def __transform_orientation(self): - if not self.kloppy_dataset.metadata.flags & DatasetFlag.BALL_OWNING_TEAM: - self._overwrite_orientation = True - # In this package attacking is always left to right, so if this is not giving in Kloppy, overwrite it - to_orientation = Orientation.STATIC_HOME_AWAY - else: - to_orientation = Orientation.BALL_OWNING_TEAM - - self.kloppy_dataset = DatasetTransformer.transform_dataset( - dataset=self.kloppy_dataset, - to_orientation=to_orientation, - to_coordinate_system=SecondSpectrumCoordinateSystem( - pitch_length=self.kloppy_dataset.metadata.pitch_dimensions.pitch_length, - pitch_width=self.kloppy_dataset.metadata.pitch_dimensions.pitch_width, - ), + self.load() + + def __repr__(self) -> str: + n_frames = ( + self.data[Column.FRAME_ID].n_unique() if hasattr(self, "data") else None + ) + return f"KloppyPolarsDataset(n_frames={n_frames})" + + def __transform_orientation( + self, + ) -> Tuple[TrackingDataset, Union[None, TrackingDataset]]: + """ + We create orientation transformed kloppy datasets. + We set it to Orientation.STATIC_HOME_AWAY if it is currently BALL_OWNING to compute speed and accelerations correctly using Polars. + If we set it Orientation.BALL_OWNING directly, as we did previously, the coordinates can flip by *-1.0 in the middle of a sequence, this breaks the + speed and acceleration computations. + + We flip it to BALL_OWNING later using __fix_orientation_to_ball_owning, if needed + + We keep the provided kloppy orientation if we set orient_ball_owning to False + """ + secondspectrum_coordinate_system = SecondSpectrumCoordinateSystem( + pitch_length=self.kloppy_dataset.metadata.pitch_dimensions.pitch_length, + pitch_width=self.kloppy_dataset.metadata.pitch_dimensions.pitch_width, ) - return self.kloppy_dataset + + if self.kloppy_dataset.metadata.orientation not in [ + Orientation.STATIC_HOME_AWAY, + Orientation.STATIC_AWAY_HOME, + Orientation.HOME_AWAY, + Orientation.AWAY_HOME, + ]: + kloppy_static = DatasetTransformer.transform_dataset( + dataset=self.kloppy_dataset, + to_orientation=Orientation.STATIC_HOME_AWAY, + to_coordinate_system=secondspectrum_coordinate_system, + ) + else: + kloppy_static = DatasetTransformer.transform_dataset( + dataset=self.kloppy_dataset, + to_coordinate_system=secondspectrum_coordinate_system, + ) + + return kloppy_static def __get_objects(self): def __artificial_game_id() -> str: @@ -116,20 +134,58 @@ def __artificial_game_id() -> str: ): self._infer_goalkeepers = True home_players = [ - SoccerObject(p.player_id, p.team.team_id, None) + SoccerObject( + id=p.player_id, + team_id=p.team.team_id, + position_name=None, + number=p.jersey_no, + name=p.last_name, + team_name=p.team.name, + is_home=True, + object_type="player", + ) for p in home_team.players ] away_players = [ - SoccerObject(p.player_id, p.team.team_id, None) + SoccerObject( + id=p.player_id, + team_id=p.team.team_id, + position_name=None, + number=p.jersey_no, + name=p.last_name, + team_name=p.team.name, + is_home=False, + object_type="player", + ) for p in away_team.players ] else: home_players = [ - SoccerObject(p.player_id, p.team.team_id, p.starting_position.code) + SoccerObject( + id=p.player_id, + team_id=p.team.team_id, + position_name=p.starting_position.code, + number=p.jersey_no, + name=p.last_name, + team_name=p.team.name, + is_home=True, + is_gk=True if p.starting_position.code == "GK" else False, + object_type="player", + ) for p in home_team.players ] away_players = [ - SoccerObject(p.player_id, p.team.team_id, p.starting_position.code) + SoccerObject( + id=p.player_id, + team_id=p.team.team_id, + position_name=p.starting_position.code, + number=p.jersey_no, + name=p.last_name, + team_name=p.team.name, + is_home=False, + is_gk=True if p.starting_position.code == "GK" else False, + object_type="player", + ) for p in away_team.players ] ball_object = SoccerObject(Constant.BALL, Constant.BALL, Constant.BALL) @@ -138,10 +194,10 @@ def __artificial_game_id() -> str: game_id = __artificial_game_id() return (home_players, away_players, ball_object, game_id) - def __unpivot(self, object, coordinate): + def __unpivot(self, df, object, coordinate): column = f"{object.id}_{coordinate}" - return self.data.unpivot( + return df.unpivot( index=[ Column.PERIOD_ID, Column.TIMESTAMP, @@ -291,11 +347,10 @@ def __add_velocity( .alias(Column.SPEED) ] ) - return df def __add_acceleration(self, df: pl.DataFrame): - df = ( + return ( df.with_columns( [ # Calculate differences in vx, vy, and dt for acceleration @@ -333,17 +388,17 @@ def __add_acceleration(self, df: pl.DataFrame): ] ) ) - return df def __melt( self, + df: pl.DataFrame, home_players: List[SoccerObject], away_players: List[SoccerObject], ball_object: SoccerObject, game_id: Union[int, str], ): melted_dfs = [] - columns = self.data.columns + columns = df.columns for object in [ball_object] + home_players + away_players: melted_object_dfs = [] @@ -353,7 +408,7 @@ def __melt( if not any(object.id in column for column in columns): continue - melted_df = self.__unpivot(object, coordinate) + melted_df = self.__unpivot(df, object, coordinate) if object.id == Constant.BALL and coordinate == Column.Z: if melted_df[coordinate].is_null().all(): @@ -388,41 +443,40 @@ def __melt( def __infer_ball_carrier(self, df: pl.DataFrame): if Column.BALL_OWNING_PLAYER_ID not in df.columns: df = df.with_columns( - pl.lit(False) + pl.lit(None) .cast(df.schema[Column.OBJECT_ID]) .alias(Column.BALL_OWNING_PLAYER_ID) ) - # handle the non ball owning frames ball = df.filter(pl.col(Column.TEAM_ID) == Constant.BALL) players = df.filter(pl.col(Column.TEAM_ID) != Constant.BALL) # ball owning team is empty, so we can drop it. Goal is to replace it - result = ( - players.join( - ball.select( - Group.BY_FRAME - + [ - pl.col(Column.X).alias("ball_x"), - pl.col(Column.Y).alias("ball_y"), - pl.col(Column.Z).alias("ball_z"), - ] - ), - on=Group.BY_FRAME, - how="left", - ) - .with_columns( - [ - ( - (pl.col(Column.X) - pl.col("ball_x")) ** 2 - + (pl.col(Column.Y) - pl.col("ball_y")) ** 2 - + (pl.col(Column.Z) - pl.col("ball_z")) ** 2 - ) - .sqrt() - .alias("ball_dist") + players_ball = players.join( + ball.select( + Group.BY_FRAME + + [ + pl.col(Column.X).alias("ball_x"), + pl.col(Column.Y).alias("ball_y"), + pl.col(Column.Z).alias("ball_z"), ] - ) - .group_by(Group.BY_FRAME) + ), + on=Group.BY_FRAME, + how="left", + ).with_columns( + [ + ( + (pl.col(Column.X) - pl.col("ball_x")) ** 2 + + (pl.col(Column.Y) - pl.col("ball_y")) ** 2 + + (pl.col(Column.Z) - pl.col("ball_z")) ** 2 + ) + .sqrt() + .alias("ball_dist") + ] + ) + # Update ball_owning_team if necessary + ball_owning_team = (players_ball.drop(Column.BALL_OWNING_TEAM_ID)).join( + players_ball.group_by(Group.BY_FRAME) .agg( [ pl.when((pl.col(Column.BALL_OWNING_TEAM_ID).is_null())) @@ -430,35 +484,68 @@ def __infer_ball_carrier(self, df: pl.DataFrame): pl.col(Column.TEAM_ID) .filter( (pl.col("ball_dist") == pl.col("ball_dist").min()) - & (pl.col("ball_dist").min() < self.ball_carrier_threshold) + & ( + pl.col("ball_dist").min() + < self.settings.ball_carrier_threshold + ) ) .first() ) .otherwise(pl.col(Column.BALL_OWNING_TEAM_ID)) .alias(Column.BALL_OWNING_TEAM_ID), - pl.when((pl.col(Column.BALL_OWNING_PLAYER_ID).is_null())) - .then( - pl.col(Column.OBJECT_ID) - .filter( - (pl.col("ball_dist") == pl.col("ball_dist").min()) - & (pl.col("ball_dist").min() < self.ball_carrier_threshold) - ) - .first() - ) - .otherwise(pl.col(Column.BALL_OWNING_PLAYER_ID)) - .alias(Column.BALL_OWNING_PLAYER_ID), ] ) .with_columns( [ - pl.col(Column.BALL_OWNING_PLAYER_ID) - .list.first() - .alias(Column.BALL_OWNING_PLAYER_ID), pl.col(Column.BALL_OWNING_TEAM_ID) .list.first() .alias(Column.BALL_OWNING_TEAM_ID), ] + ), + on=Group.BY_FRAME, + how="left", + ) + # Make sure the ball owning player is on the ball owning team + result = ( + (ball_owning_team.drop(Column.BALL_OWNING_PLAYER_ID)) + .join( + ball_owning_team.filter( + (pl.col(Column.BALL_OWNING_TEAM_ID) == pl.col(Column.TEAM_ID)) + ) + .group_by(Group.BY_FRAME) + .agg( + [ + pl.when((pl.col(Column.BALL_OWNING_PLAYER_ID).is_null())) + .then( + pl.col(Column.OBJECT_ID) + .filter( + (pl.col("ball_dist") == pl.col("ball_dist").min()) + & ( + pl.col("ball_dist").min() + < self.settings.ball_carrier_threshold + ) + ) + .first() + ) + .otherwise(pl.col(Column.BALL_OWNING_PLAYER_ID)) + .alias(Column.BALL_OWNING_PLAYER_ID) + ] + ) + .with_columns( + [ + pl.col(Column.BALL_OWNING_PLAYER_ID) + .list.first() + .alias(Column.BALL_OWNING_PLAYER_ID), + ] + ), + on=Group.BY_FRAME, + how="left", ) + .select( + Group.BY_FRAME + + [Column.BALL_OWNING_TEAM_ID, Column.BALL_OWNING_PLAYER_ID] + ) + .unique() ) df = ( df.drop([Column.BALL_OWNING_PLAYER_ID, Column.BALL_OWNING_TEAM_ID]) @@ -477,7 +564,7 @@ def __infer_ball_carrier(self, df: pl.DataFrame): return df def __infer_goalkeepers(self, df: pl.DataFrame): - goal_x = self.pitch_dimensions.pitch_length / 2 + goal_x = self.settings.pitch_dimensions.pitch_length / 2 goal_y = 0 df_with_distances = df.filter( @@ -535,11 +622,11 @@ def __infer_goalkeepers(self, df: pl.DataFrame): def __fix_orientation_to_ball_owning( self, df: pl.DataFrame, home_team_id: Union[str, int] ): - # When _overwrite_orientation is True, it means the orientation is "STATIC_HOME_AWAY" + # When orient_ball_owning is True, it means the orientation has to flip from "STATIC_HOME_AWAY" to "BALL_OWNING" in the Polars dataframe # This means that when away is the attacking team we can flip all coordinates by -1.0 - flip_columns = [Column.X, Column.Y, Column.VX, Column.VY, Column.AX, Column.AY] + self.settings.orientation = Orientation.BALL_OWNING_TEAM return df.with_columns( [ pl.when( @@ -550,10 +637,26 @@ def __fix_orientation_to_ball_owning( ] ) + def __apply_settings( + self, + pitch_dimensions, + ): + home_team, away_team = self.kloppy_dataset.metadata.teams + return DefaultSettings( + provider="secondspectrum", + orientation=self.kloppy_dataset.metadata.orientation, + home_team_id=home_team.team_id, + away_team_id=away_team.team_id, + pitch_dimensions=pitch_dimensions, + max_player_speed=self._max_player_speed, + max_ball_speed=self._max_ball_speed, + max_player_acceleration=self._max_player_acceleration, + max_ball_acceleration=self._max_ball_acceleration, + ball_carrier_threshold=self._ball_carrier_threshold, + ) + def load( self, - player_smoothing_params: Union[dict, None] = DEFAULT_PLAYER_SMOOTHING_PARAMS, - ball_smoothing_params: Union[dict, None] = DEFAULT_BALL_SMOOTHING_PARAMS, ): if self.kloppy_dataset.metadata.orientation == Orientation.NOT_SET: raise ValueError( @@ -561,20 +664,31 @@ def load( ) self.kloppy_dataset = self.__transform_orientation() - self.pitch_dimensions = self.kloppy_dataset.metadata.pitch_dimensions - self.data = self.kloppy_dataset.to_df(engine="polars") - (self._home_players, self._away_players, self._ball_object, self._game_id) = ( + self.settings = self.__apply_settings( + pitch_dimensions=self.kloppy_dataset.metadata.pitch_dimensions + ) + + (self.home_players, self.away_players, self._ball_object, self._game_id) = ( self.__get_objects() ) + + df = self.kloppy_dataset.to_df(engine="polars") df = self.__melt( - self._home_players, self._away_players, self._ball_object, self._game_id + df, self.home_players, self.away_players, self._ball_object, self._game_id + ) + df = self.__add_velocity( + df, DEFAULT_PLAYER_SMOOTHING_PARAMS, DEFAULT_BALL_SMOOTHING_PARAMS ) - - df = self.__add_velocity(df, player_smoothing_params, ball_smoothing_params) df = self.__add_acceleration(df) + df = apply_speed_acceleration_filters( + df, + max_player_speed=self.settings.max_player_speed, + max_ball_speed=self.settings.max_ball_speed, + max_player_acceleration=self.settings.max_player_acceleration, + max_ball_acceleration=self.settings.max_ball_acceleration, + ) df = df.drop(["dx", "dy", "dz", "dt", "dvx", "dvy", "dvz"]) - df = df.filter(~(pl.col(Column.X).is_null() & pl.col(Column.Y).is_null())) if ( @@ -587,7 +701,10 @@ def load( df = self.__infer_ball_carrier(df) - if self._overwrite_orientation: + if ( + self._orient_ball_owning + and self.settings.orientation != Orientation.BALL_OWNING_TEAM + ): home_team, _ = self.kloppy_dataset.metadata.teams df = self.__fix_orientation_to_ball_owning( df, home_team_id=home_team.team_id @@ -597,7 +714,7 @@ def load( df = self.__infer_goalkeepers(df) self.data = df - return self.data, self.pitch_dimensions + return self def add_dummy_labels(self, by: List[str] = ["game_id", "frame_id"]) -> pl.DataFrame: self.data = add_dummy_label_column(self.data, by, self._label_column) @@ -606,3 +723,23 @@ def add_dummy_labels(self, by: List[str] = ["game_id", "frame_id"]) -> pl.DataFr def add_graph_ids(self, by: List[str] = ["game_id", "period_id"]) -> pl.DataFrame: self.data = add_graph_id_column(self.data, by, self._graph_id_column) return self.data + + def get_player_by_id(self, player_id): + if hasattr(self, "home_players") and hasattr(self, "away_players"): + for player in self.home_players + self.away_players: + if player.id == player_id: + return player + else: + raise ValueError( + "No home_players or away_players, first load() the dataset" + ) + + def get_team_id_by_player_id(self, player_id): + if hasattr(self, "home_players") and hasattr(self, "away_players"): + for player in self.home_players + self.away_players: + if player.id == player_id: + return player.team_id + else: + raise ValueError( + "No home_players or away_players, first load() the dataset" + ) diff --git a/unravel/soccer/dataset/objects.py b/unravel/soccer/dataset/objects.py new file mode 100644 index 0000000..ae20acf --- /dev/null +++ b/unravel/soccer/dataset/objects.py @@ -0,0 +1,37 @@ +class Constant: + BALL = "ball" + + +class Column: + BALL_OWNING_TEAM_ID = "ball_owning_team_id" + BALL_OWNING_PLAYER_ID = "ball_owning_player_id" + IS_BALL_CARRIER = "is_ball_carrier" + PERIOD_ID = "period_id" + TIMESTAMP = "timestamp" + BALL_STATE = "ball_state" + FRAME_ID = "frame_id" + GAME_ID = "game_id" + TEAM_ID = "team_id" + OBJECT_ID = "id" + POSITION_NAME = "position_name" + + X = "x" + Y = "y" + Z = "z" + + SPEED = "v" + VX = "vx" + VY = "vy" + VZ = "vz" + + ACCELERATION = "a" + AX = "ax" + AY = "ay" + AZ = "az" + + +class Group: + BY_FRAME = [Column.GAME_ID, Column.PERIOD_ID, Column.FRAME_ID] + BY_FRAME_TEAM = [Column.GAME_ID, Column.PERIOD_ID, Column.FRAME_ID, Column.TEAM_ID] + BY_OBJECT_PERIOD = [Column.OBJECT_ID, Column.PERIOD_ID] + BY_TIMESTAMP = [Column.GAME_ID, Column.PERIOD_ID, Column.FRAME_ID, Column.TIMESTAMP] diff --git a/unravel/soccer/dataset/utils.py b/unravel/soccer/dataset/utils.py new file mode 100644 index 0000000..8f8d23f --- /dev/null +++ b/unravel/soccer/dataset/utils.py @@ -0,0 +1,39 @@ +from .kloppy_polars import Column, Constant, Group + +import polars as pl + + +def apply_speed_acceleration_filters( + dataset: pl.DataFrame, + max_ball_speed: float, + max_player_speed: float, + max_ball_acceleration: float, + max_player_acceleration: float, +): + return dataset.with_columns( + pl.when( + (pl.col(Column.OBJECT_ID) == Constant.BALL) + & (pl.col(Column.SPEED) > max_ball_speed) + ) + .then(max_ball_speed) + .when( + (pl.col(Column.OBJECT_ID) != Constant.BALL) + & (pl.col(Column.SPEED) > max_player_speed) + ) + .then(max_player_speed) + .otherwise(pl.col(Column.SPEED)) + .alias(Column.SPEED) + ).with_columns( + pl.when( + (pl.col(Column.OBJECT_ID) == Constant.BALL) + & (pl.col(Column.ACCELERATION) > max_ball_acceleration) + ) + .then(max_ball_acceleration) + .when( + (pl.col(Column.OBJECT_ID) != Constant.BALL) + & (pl.col(Column.ACCELERATION) > max_player_acceleration) + ) + .then(max_player_acceleration) + .otherwise(pl.col(Column.ACCELERATION)) + .alias(Column.ACCELERATION) + ) diff --git a/unravel/soccer/graphs/__init__.py b/unravel/soccer/graphs/__init__.py index 2991890..843e405 100644 --- a/unravel/soccer/graphs/__init__.py +++ b/unravel/soccer/graphs/__init__.py @@ -5,5 +5,3 @@ from .graph_frame import GraphFrame from .exceptions import * from .features import * - -from .dataset import KloppyPolarsDataset diff --git a/unravel/soccer/graphs/features/adjacency_matrix_pl.py b/unravel/soccer/graphs/features/adjacency_matrix_pl.py index 2e27ea2..f446130 100644 --- a/unravel/soccer/graphs/features/adjacency_matrix_pl.py +++ b/unravel/soccer/graphs/features/adjacency_matrix_pl.py @@ -3,7 +3,7 @@ from ....utils import AdjacencyMatrixType, AdjacenyMatrixConnectType, distance_to_ball -from ..dataset import Constant +from ...dataset.kloppy_polars import Constant def compute_adjacency_matrix_pl(team, ball_owning_team, settings, ball_carrier_idx): diff --git a/unravel/soccer/graphs/features/edge_features_pl.py b/unravel/soccer/graphs/features/edge_features_pl.py index ce4defe..b7ea54e 100644 --- a/unravel/soccer/graphs/features/edge_features_pl.py +++ b/unravel/soccer/graphs/features/edge_features_pl.py @@ -20,7 +20,7 @@ normalize_accelerations_nfl, ) -from ..dataset import Constant +from ...dataset.kloppy_polars import Constant def compute_edge_features_pl(adjacency_matrix, p3d, p2d, s, velocity, team, settings): diff --git a/unravel/soccer/graphs/features/node_features_pl.py b/unravel/soccer/graphs/features/node_features_pl.py index c95d8b2..f773142 100644 --- a/unravel/soccer/graphs/features/node_features_pl.py +++ b/unravel/soccer/graphs/features/node_features_pl.py @@ -18,7 +18,7 @@ normalize_speed, distance_to_ball, ) -from ..dataset import Constant +from ...dataset.kloppy_polars import Constant def compute_node_features_pl( @@ -30,10 +30,20 @@ def compute_node_features_pl( possession_team, is_gk, ball_carrier, + graph_features, settings, ): ball_id = Constant.BALL + position = np.stack((x, y), axis=-1) + + if len(np.where(team == ball_id)) >= 1: + ball_index = np.where(team == ball_id)[0] + ball_position = position[ball_index][0] + else: + ball_position = np.asarray([np.nan, np.nan]) + ball_index = 0 + goal_mouth_position = ( settings.pitch_dimensions.x_dim.max, (settings.pitch_dimensions.y_dim.max + settings.pitch_dimensions.y_dim.min) / 2, @@ -61,7 +71,7 @@ def compute_node_features_pl( max_value=settings.pitch_dimensions.y_dim.max, min_value=settings.pitch_dimensions.y_dim.min, ) - s_normed = normalize_speeds_nfl(s, team, ball_id=Constant.BALL, settings=settings) + s_normed = normalize_speeds_nfl(s, team, ball_id=ball_id, settings=settings) uv_velocity = unit_vectors(velocity) angles = normalize_angles(np.arctan2(uv_velocity[:, 1], uv_velocity[:, 0])) @@ -115,4 +125,10 @@ def compute_node_features_pl( axis=-1, ) ) + + if graph_features is not None: + eg = np.ones((X.shape[0], graph_features.shape[0])) * 0.0 + eg[ball_index] = graph_features + X = np.hstack((X, eg)) + return X diff --git a/unravel/soccer/graphs/graph_converter.py b/unravel/soccer/graphs/graph_converter.py index 1262598..752103d 100644 --- a/unravel/soccer/graphs/graph_converter.py +++ b/unravel/soccer/graphs/graph_converter.py @@ -63,6 +63,9 @@ class SoccerGraphConverter(DefaultGraphConverter): Infers 'attacking_team' if no 'ball_owning_team' (Kloppy) or 'attacking_team' (List[Dict]) is provided, by finding player closest to ball using ball xyz. Also infers ball_carrier within ball_carrier_threshold infer_goalkeepers (bool): set True if no GK label is provider, set False for incomplete (broadcast tracking) data that might not have a GK in every frame + max_ball_speed (float): The maximum speed of the ball in meters per second. Defaults to 28.0. + max_player_speed (float): The maximum speed of a player in meters per second. Defaults to 12.0. + max_ball_speed (float): The maximum speed of the ball in meters per second. Defaults to 28.0. ball_carrier_threshold (float): The distance threshold to determine the ball carrier. Defaults to 25.0. boundary_correction (float): A correction factor for boundary calculations, used to correct out of bounds as a percentages (Used as 1+boundary_correction, ie 0.05). Defaults to None. non_potential_receiver_node_value (float): Value between 0 and 1 to assign to the defing team players @@ -77,6 +80,11 @@ class SoccerGraphConverter(DefaultGraphConverter): infer_goalkeepers: bool = True infer_ball_ownership: bool = True boundary_correction: float = None + + max_player_speed: float = 12.0 + max_ball_speed: float = 28.0 + # max_player_acceleration: float = 6.0 + # max_ball_acceleration: float = 13.5 ball_carrier_treshold: float = 25.0 non_potential_receiver_node_value: float = 0.1 @@ -168,6 +176,18 @@ def _sport_specific_checks(self): if self.boundary_correction and not isinstance(self.boundary_correction, float): raise Exception("'boundary_correction' should be of type float") + if not isinstance(self.max_player_speed, (float, int)): + raise Exception("'max_player_speed' should be of type float or int") + + if not isinstance(self.max_ball_speed, (float, int)): + raise Exception("'max_ball_speed' should be of type float or int") + + # if not isinstance(self.max_player_acceleration, (float, int)): + # raise Exception("'max_player_acceleration' should be of type float or int") + + # if not isinstance(self.max_ball_acceleration, (float, int)): + # raise Exception("'max_ball_acceleration' should be of type float or int") + if self.ball_carrier_treshold and not isinstance( self.ball_carrier_treshold, float ): diff --git a/unravel/soccer/graphs/graph_converter_pl.py b/unravel/soccer/graphs/graph_converter_pl.py index e2f5342..819e4d1 100644 --- a/unravel/soccer/graphs/graph_converter_pl.py +++ b/unravel/soccer/graphs/graph_converter_pl.py @@ -3,7 +3,7 @@ from dataclasses import dataclass -from typing import List, Union, Dict, Literal, Any +from typing import List, Union, Dict, Literal, Any, Optional from kloppy.domain import ( MetricPitchDimensions, @@ -12,7 +12,7 @@ from spektral.data import Graph from .graph_settings_pl import GraphSettingsPolars -from .dataset import KloppyPolarsDataset, Column, Group, Constant +from ..dataset.kloppy_polars import KloppyPolarsDataset, Column, Group, Constant from .features import ( compute_node_features_pl, compute_adjacency_matrix_pl, @@ -33,21 +33,29 @@ class SoccerGraphConverterPolars(DefaultGraphConverter): Converts our dataset TrackingDataset into an internal structure Attributes: - dataset (TrackingDataset): Kloppy TrackingDataset. + dataset (KloppyPolarsDataset): KloppyPolarsDataset created from a Kloppy dataset. chunk_size (int): Determines how many Graphs get processed simultanously. non_potential_receiver_node_value (float): Value between 0 and 1 to assign to the defing team players + graph_feature_cols (list[str]): List of columns in the dataset that are Graph level features (e.g. team strength rating, win probabilities etc) + we want to add to our model. A list of column names corresponding to the Polars dataframe within KloppyPolarsDataset.data + that are graph level features. They should be joined to the KloppyPolarsDataset.data dataframe such that + each Group in the group_by has the same value per column. We take the first value of the group, and assign this as a + "graph level feature" to the ball node. """ dataset: KloppyPolarsDataset = None chunk_size: int = 2_0000 non_potential_receiver_node_value: float = 0.1 + graph_feature_cols: Optional[List[str]] = None def __post_init__(self): if not isinstance(self.dataset, KloppyPolarsDataset): raise ValueError("dataset should be of type KloppyPolarsDataset...") - self.pitch_dimensions: MetricPitchDimensions = self.dataset.pitch_dimensions + self.pitch_dimensions: MetricPitchDimensions = ( + self.dataset.settings.pitch_dimensions + ) self.label_column: str = ( self.label_col if self.label_col is not None else self.dataset._label_column ) @@ -60,8 +68,7 @@ def __post_init__(self): self.dataset = self.dataset.data self._sport_specific_checks() - self.settings = self._apply_settings() - self.dataset = self._apply_filters() + self.settings = self._apply_graph_settings() if self.pad: self.dataset = self._apply_padding() @@ -223,42 +230,13 @@ def __warn_dropped_frames(dropped_frames, total_frames): """ ) - def _apply_filters(self): - return self.dataset.with_columns( - pl.when( - (pl.col(Column.OBJECT_ID) == Constant.BALL) - & (pl.col(Column.SPEED) > self.settings.max_ball_speed) - ) - .then(self.settings.max_ball_speed) - .when( - (pl.col(Column.OBJECT_ID) != Constant.BALL) - & (pl.col(Column.SPEED) > self.settings.max_player_speed) - ) - .then(self.settings.max_player_speed) - .otherwise(pl.col(Column.SPEED)) - .alias(Column.SPEED) - ).with_columns( - pl.when( - (pl.col(Column.OBJECT_ID) == Constant.BALL) - & (pl.col(Column.ACCELERATION) > self.settings.max_ball_acceleration) - ) - .then(self.settings.max_ball_acceleration) - .when( - (pl.col(Column.OBJECT_ID) != Constant.BALL) - & (pl.col(Column.ACCELERATION) > self.settings.max_player_acceleration) - ) - .then(self.settings.max_player_acceleration) - .otherwise(pl.col(Column.ACCELERATION)) - .alias(Column.ACCELERATION) - ) - - def _apply_settings(self): + def _apply_graph_settings(self): return GraphSettingsPolars( pitch_dimensions=self.pitch_dimensions, - max_player_speed=self.max_player_speed, - max_ball_speed=self.max_ball_speed, - max_player_acceleration=self.max_player_acceleration, - max_ball_acceleration=self.max_ball_acceleration, + max_player_speed=self.settings.max_player_speed, + max_ball_speed=self.settings.max_ball_speed, + max_player_acceleration=self.settings.max_player_acceleration, + max_ball_acceleration=self.settings.max_ball_acceleration, self_loop_ball=self.self_loop_ball, adjacency_matrix_connect_type=self.adjacency_matrix_connect_type, adjacency_matrix_type=self.adjacency_matrix_type, @@ -304,7 +282,7 @@ def _sport_specific_checks(self): @property def __exprs_variables(self): - return [ + exprs_variables = [ Column.X, Column.Y, Column.Z, @@ -323,10 +301,33 @@ def __exprs_variables(self): self.graph_id_column, self.label_column, ] + exprs = ( + exprs_variables + if self.graph_feature_cols is None + else exprs_variables + self.graph_feature_cols + ) + return exprs def __compute(self, args: List[pl.Series]) -> dict: d = {col: args[i].to_numpy() for i, col in enumerate(self.__exprs_variables)} + if self.graph_feature_cols is not None: + failed = [ + col + for col in self.graph_feature_cols + if not np.all(d[col] == d[col][0]) + ] + if failed: + raise ValueError( + f"""graph_feature_cols contains multiple different values for a group in the groupby ({Group.BY_FRAME}) selection for the columns {failed}. Make sure each group has the same values per individual column.""" + ) + + graph_features = ( + np.asarray([d[col] for col in self.graph_feature_cols]).T[0] + if self.graph_feature_cols + else None + ) + if not np.all(d[self.graph_id_column] == d[self.graph_id_column][0]): raise ValueError( "graph_id selection contains multiple different values. Make sure each graph_id is unique by at least game_id and frame_id..." @@ -373,25 +374,14 @@ def __compute(self, args: List[pl.Series]) -> dict: possession_team=d[Column.BALL_OWNING_TEAM_ID], is_gk=(d[Column.POSITION_NAME] == self.settings.goalkeeper_id).astype(int), ball_carrier=d[Column.IS_BALL_CARRIER], + graph_features=graph_features, settings=self.settings, ) return { - "e": pl.Series( - [edge_features.tolist()], dtype=pl.List(pl.List(pl.Float64)) - ), - "x": pl.Series( - [node_features.tolist()], dtype=pl.List(pl.List(pl.Float64)) - ), - "a": pl.Series( - [adjacency_matrix.tolist()], dtype=pl.List(pl.List(pl.Int32)) - ), - "e_shape_0": edge_features.shape[0], - "e_shape_1": edge_features.shape[1], - "x_shape_0": node_features.shape[0], - "x_shape_1": node_features.shape[1], - "a_shape_0": adjacency_matrix.shape[0], - "a_shape_1": adjacency_matrix.shape[1], + "e": edge_features.tolist(), + "x": node_features.tolist(), + "a": adjacency_matrix.tolist(), self.graph_id_column: d[self.graph_id_column][0], self.label_column: d[self.label_column][0], } @@ -403,12 +393,6 @@ def return_dtypes(self): "e": pl.List(pl.List(pl.Float64)), "x": pl.List(pl.List(pl.Float64)), "a": pl.List(pl.List(pl.Float64)), - "e_shape_0": pl.Int64, - "e_shape_1": pl.Int64, - "x_shape_0": pl.Int64, - "x_shape_1": pl.Int64, - "a_shape_0": pl.Int64, - "a_shape_1": pl.Int64, self.graph_id_column: pl.String, self.label_column: pl.Int64, } @@ -425,45 +409,16 @@ def _convert(self): return_dtype=self.return_dtypes, ).alias("result_dict") ) - .with_columns( - [ - *[ - pl.col("result_dict").struct.field(f).alias(f) - for f in [ - "a", - "e", - "x", - self.graph_id_column, - self.label_column, - ] - ], - *[ - pl.col("result_dict") - .struct.field(f"{m}_shape_{i}") - .alias(f"{m}_shape_{i}") - for m in ["a", "e", "x"] - for i in [0, 1] - ], - ] - ) - .drop("result_dict") + .unnest("result_dict") ) def to_graph_frames(self) -> List[dict]: def process_chunk(chunk: pl.DataFrame) -> List[dict]: return [ { - "a": make_sparse( - reshape_array( - chunk["a"][i], chunk["a_shape_0"][i], chunk["a_shape_1"][i] - ) - ), - "x": reshape_array( - chunk["x"][i], chunk["x_shape_0"][i], chunk["x_shape_1"][i] - ), - "e": reshape_array( - chunk["e"][i], chunk["e_shape_0"][i], chunk["e_shape_1"][i] - ), + "a": make_sparse(reshape_array(arr=chunk["a"][i])), + "x": reshape_array(arr=chunk["x"][i]), + "e": reshape_array(arr=chunk["e"][i]), "y": np.asarray([chunk[self.label_column][i]]), "id": chunk[self.graph_id_column][i], } diff --git a/unravel/soccer/graphs/graph_settings_pl.py b/unravel/soccer/graphs/graph_settings_pl.py index a7713f4..10165be 100644 --- a/unravel/soccer/graphs/graph_settings_pl.py +++ b/unravel/soccer/graphs/graph_settings_pl.py @@ -3,10 +3,9 @@ from ...utils import DefaultGraphSettings from dataclasses import dataclass, field -from kloppy.domain import Dimension, Unit, MetricPitchDimensions -from typing import Optional +from kloppy.domain import MetricPitchDimensions -from .dataset import Constant +from ..dataset import Constant @dataclass diff --git a/unravel/soccer/models/__init__.py b/unravel/soccer/models/__init__.py new file mode 100644 index 0000000..83ebcd7 --- /dev/null +++ b/unravel/soccer/models/__init__.py @@ -0,0 +1,2 @@ +from .pressing_intensity import * +from .utils import * diff --git a/unravel/soccer/models/pressing_intensity.py b/unravel/soccer/models/pressing_intensity.py new file mode 100644 index 0000000..3ca6c9b --- /dev/null +++ b/unravel/soccer/models/pressing_intensity.py @@ -0,0 +1,347 @@ +import numpy as np +import polars as pl + +from dataclasses import dataclass, field + +from typing import Literal, List, Union + +from ..dataset.kloppy_polars import ( + KloppyPolarsDataset, + MetricPitchDimensions, + Group, + Column, + Constant, +) + +from mplsoccer import VerticalPitch, Pitch +from matplotlib.axes import Axes +from matplotlib.figure import Figure + +from .utils import time_to_intercept, probability_to_intercept + + +@dataclass +class PressingIntensity: + dataset: KloppyPolarsDataset + chunk_size: int = field(init=True, repr=False, default=2_0000) + + _method: str = field(init=False, repr=False, default="teams") + _ball_method: str = field(init=False, repr=False, default="max") + _speed_threshold: float = field(init=False, repr=False, default=None) + _reaction_time: float = field(init=False, repr=False, default=0.7) + _sigma: float = field(init=False, repr=False, default=0.45) + _time_threshold: float = field(init=False, repr=False, default=1.5) + _orient: str = field(init=False, repr=False, default="ball_owning") + + def __post_init__(self): + if not isinstance(self.dataset, KloppyPolarsDataset): + raise ValueError("dataset should be of type KloppyPolarsDataset...") + + self.settings = self.dataset.settings + self.dataset = self.dataset.data + + def __repr__(self): + n_frames = ( + self.output[Column.FRAME_ID].n_unique() if hasattr(self, "output") else None + ) + return f"PressingIntensity(n_frames={n_frames})" + + @property + def __exprs_variables(self): + return [ + Column.X, + Column.Y, + Column.Z, + Column.VX, + Column.VY, + Column.VZ, + Column.SPEED, + Column.TEAM_ID, + Column.BALL_OWNING_TEAM_ID, + Column.OBJECT_ID, + Column.IS_BALL_CARRIER, + ] + + def __compute(self, args: List[pl.Series]) -> dict: + def _set_minimum(matrix, ball_carrier_idx, ball_idx): + # Take the element-wise maximum of the ball carrier and the ball + matrix[:, ball_carrier_idx] = np.minimum( + matrix[:, ball_carrier_idx], matrix[:, ball_idx] + ) + # Delete ball column + matrix = np.delete(matrix, ball_idx, axis=1) + return matrix + + d = {col: args[i].to_numpy() for i, col in enumerate(self.__exprs_variables)} + + ball_idx, ball_carrier_idx = None, None + + if self._ball_method in ["max", "include"]: + ball_mask = d[Column.TEAM_ID] == Constant.BALL + ball_owning_mask = (d[Column.TEAM_ID] == d[Column.BALL_OWNING_TEAM_ID]) | ( + ball_mask + ) + non_ball_owning_mask = ~ball_owning_mask + + elif self._ball_method == "exclude": + ball_mask = d[Column.TEAM_ID] != Constant.BALL + ball_owning_mask = (d[Column.TEAM_ID] == d[Column.BALL_OWNING_TEAM_ID]) & ( + ball_mask + ) + non_ball_owning_mask = ( + d[Column.TEAM_ID] != d[Column.BALL_OWNING_TEAM_ID] + ) & ball_mask + + if self._method == "teams": + ball_owning_idxs = np.where(ball_owning_mask)[0] + non_ball_owning_idxs = np.where(non_ball_owning_mask)[0] + + if self._ball_method == "max": + ball_idx = np.where( + d[Column.TEAM_ID][ball_owning_idxs] == Constant.BALL + )[0][0] + ball_carrier_idx = np.where( + d[Column.IS_BALL_CARRIER][ball_owning_idxs] + )[0][0] + + xs1, ys1, zs1 = ( + d[Column.X][ball_owning_idxs], + d[Column.Y][ball_owning_idxs], + d[Column.Z][ball_owning_idxs], + ) + xs2, ys2, zs2 = ( + d[Column.X][non_ball_owning_idxs], + d[Column.Y][non_ball_owning_idxs], + d[Column.Z][non_ball_owning_idxs], + ) + + vxs1, vys1, vzs1 = ( + d[Column.VX][ball_owning_idxs], + d[Column.VY][ball_owning_idxs], + d[Column.VZ][ball_owning_idxs], + ) + vxs2, vys2, vzs2 = ( + d[Column.VX][non_ball_owning_idxs], + d[Column.VY][non_ball_owning_idxs], + d[Column.VZ][non_ball_owning_idxs], + ) + column_objects, row_objects = ( + d[Column.OBJECT_ID][ball_owning_idxs], + d[Column.OBJECT_ID][non_ball_owning_idxs], + ) + + if self._speed_threshold: + column_mask = d[Column.SPEED][ball_owning_idxs] < self._speed_threshold + row_mask = d[Column.SPEED][non_ball_owning_idxs] < self._speed_threshold + + elif self._method == "full": + if self._ball_method == "exclude": + mask = np.where(ball_mask)[0] + else: + mask = np.where(d[Column.TEAM_ID] == d[Column.TEAM_ID])[0] + + if self._ball_method == "max": + ball_idx = np.where(ball_mask)[0][0] + ball_carrier_idx = np.where(d[Column.IS_BALL_CARRIER][mask])[0][0] + + xs1, ys1, zs1 = xs2, ys2, zs2 = ( + d[Column.X][mask], + d[Column.Y][mask], + d[Column.Z][mask], + ) + vxs1, vys1, vzs1 = vxs2, vys2, vzs2 = ( + d[Column.VX][mask], + d[Column.VY][mask], + d[Column.VZ][mask], + ) + column_objects, row_objects = ( + d[Column.OBJECT_ID][mask], + d[Column.OBJECT_ID][mask], + ) + + if self._speed_threshold: + column_mask = d[Column.SPEED][mask] < self._speed_threshold + row_mask = d[Column.SPEED][mask] < self._speed_threshold + + if ball_idx is not None: + column_objects = np.delete(column_objects, ball_idx, axis=0) + if self._speed_threshold: + column_mask = np.delete(column_mask, ball_idx, axis=0) + + p1 = np.stack((xs1, ys1, zs1), axis=-1) + p2 = np.stack((xs2, ys2, zs2), axis=-1) + v1 = np.stack((vxs1, vys1, vzs1), axis=-1) + v2 = np.stack((vxs2, vys2, vzs2), axis=-1) + + tti = time_to_intercept( + p1=p1, + p2=p2, + v1=v1, + v2=v2, + reaction_time=self._reaction_time, + max_object_speed=self.settings.max_player_speed, + ) + if self._ball_method == "max": + tti = _set_minimum( + matrix=tti, ball_carrier_idx=ball_carrier_idx, ball_idx=ball_idx + ) + if self._method == "full": + tti = np.delete(tti, ball_idx, axis=0) + row_objects = np.delete(row_objects, ball_idx, axis=0) + if self._speed_threshold: + row_mask = np.delete(row_mask, ball_idx, axis=0) + + pti = probability_to_intercept( + time_to_intercept=tti, + tti_sigma=self._sigma, + tti_time_threshold=self._time_threshold, + ) + + if self._method == "full": + np.fill_diagonal(tti, np.inf) + np.fill_diagonal(tti, 0.0) + + if self._speed_threshold: + pti[row_mask, :] = 0.0 + pti[:, column_mask] = 0.0 + + if ( + ( + (self._orient == "away_home") + & (d[Column.BALL_OWNING_TEAM_ID][0] != self.settings.home_team_id) + ) + | ( + (self._orient == "home_away") + & (d[Column.BALL_OWNING_TEAM_ID][0] == self.settings.home_team_id) + ) + | (self._orient == "pressing") + ): + return { + "time_to_intercept": tti.T.tolist(), + "probability_to_intercept": pti.T.tolist(), + "columns": row_objects.tolist(), + "rows": column_objects.tolist(), + } + + return { + "time_to_intercept": tti.tolist(), + "probability_to_intercept": pti.tolist(), + "columns": column_objects.tolist(), + "rows": row_objects.tolist(), + } + + def fit( + self, + start_time: pl.duration = None, + end_time: pl.duration = None, + period_id: int = None, + speed_threshold: float = None, + reaction_time: float = 0.7, + time_threshold: float = 1.5, + sigma: float = 0.45, + method: Literal["teams", "full"] = "teams", + ball_method: Literal["include", "exclude", "max"] = "max", + orient: Literal[ + "ball_owning", "pressing", "home_away", "away_home" + ] = "ball_owning", + ): + """ + method: str ["teams", "full"] + "teams" creates a 11x11 matrix, "full" creates a 22x22 matrix + ball_method: str ["include", "exclude", "max"] + "include" creates a 11x12 matrix + "exclude" ignores ball + "max" keeps 11x11 but ball carrier pressing intensity is now max(ball, ball_carrier) + speed_threshold: float. + Masks pressing intensity to only include players travelling above a certain speed + threshold in meters per second. + orient: str ["ball_owning", "pressing", "home_away", "away_home"] + Pressing Intensity output as seen from the 'row' perspective. + method and orient are in sync, meaning "full" and "away_home" sorts row and columns + such that the away team players are displayed first + """ + if period_id is not None and not isinstance(period_id, int): + raise TypeError("period_id should be of type integer") + if method not in ["teams", "full"]: + raise ValueError("method should be 'teams' or 'full'") + if ball_method not in ["include", "exclude", "max"]: + raise ValueError("ball_method should be 'include', 'exclude' or 'max'") + if orient not in ["ball_owning", "pressing", "home_away", "away_home"]: + raise ValueError( + "method should be 'ball_owning', 'pressing', 'home_away', 'away_home'" + ) + if not isinstance(reaction_time, Union[float, int]): + raise TypeError("reaction_time should be of type float") + if speed_threshold is not None and not isinstance( + speed_threshold, Union[float, int] + ): + raise TypeError("speed_threshold should be of type float (or None)") + if not isinstance(time_threshold, Union[float, int]): + raise TypeError("time_threshold should be of type float") + if not isinstance(sigma, Union[float, int]): + raise TypeError("sigma should be of type float") + + self._method = method + self._ball_method = ball_method + self._speed_threshold = speed_threshold + self._reaction_time = reaction_time + self._time_threshold = time_threshold + self._sigma = sigma + self._orient = orient + + if all(x is None for x in [start_time, end_time, period_id]): + df = self.dataset + elif all(x is not None for x in [start_time, end_time, period_id]): + df = self.dataset.filter( + (pl.col(Column.TIMESTAMP).is_between(start_time, end_time)) + & (pl.col(Column.PERIOD_ID) == period_id) + ) + else: + raise ValueError( + "Please specificy all of start_time, end_time and period_id or none of them..." + ) + + sort_descending = [False] * len(Group.BY_TIMESTAMP) + if self._orient in ["home_away", "away_home"]: + alias = "is_home" + sort_by = Group.BY_TIMESTAMP + [alias] + sort_descending = sort_descending + ( + [True] if self._orient == "home_away" else [False] + ) + with_columns = [ + pl.when(pl.col(Column.TEAM_ID) == self.settings.home_team_id) + .then(True) + .when(pl.col(Column.TEAM_ID) == Constant.BALL) + .then(None) + .otherwise(False) + .alias(alias) + ] + elif self._orient in ["ball_owning", "pressing"]: + alias = "is_ball_owning" + sort_by = Group.BY_TIMESTAMP + [alias] + sort_descending = sort_descending + ( + [True] if self._orient == "ball_owning" else [False] + ) + with_columns = [ + pl.when(pl.col(Column.TEAM_ID) == pl.col(Column.BALL_OWNING_TEAM_ID)) + .then(True) + .when(pl.col(Column.TEAM_ID) == Constant.BALL) + .then(None) + .otherwise(False) + .alias(alias) + ] + + self.output = ( + df.with_columns(with_columns) + .sort(by=sort_by, descending=sort_descending, nulls_last=True) + .group_by(Group.BY_TIMESTAMP, maintain_order=True) + .agg( + pl.map_groups( + exprs=self.__exprs_variables, + function=self.__compute, + ).alias("results") + ) + .unnest("results") + ) + + return self diff --git a/unravel/soccer/models/utils.py b/unravel/soccer/models/utils.py new file mode 100644 index 0000000..4ca89bd --- /dev/null +++ b/unravel/soccer/models/utils.py @@ -0,0 +1,99 @@ +import numpy as np + + +def probability_to_intercept( + time_to_intercept: np.ndarray, tti_sigma: float, tti_time_threshold: float +): + exponent = ( + -np.pi / np.sqrt(3.0) / tti_sigma * (tti_time_threshold - time_to_intercept) + ) + # we take the below step to avoid Overflow errors, np.exp does not like values above ~700. + # exp(25) should already result in p ~ 0.000% + exponent = np.clip(exponent, -700, 700) + p = 1 / (1.0 + np.exp(exponent)) + return p + + +def time_to_intercept( + p1: np.ndarray, + p2: np.ndarray, + v1: np.ndarray, + v2: np.ndarray, + reaction_time: float, + max_object_speed: float, +) -> np.ndarray: + """ + BSD 3-Clause License + + Copyright (c) 2025 [UnravelSports] + + See: https://opensource.org/licenses/BSD-3-Clause + + This project includes code and contributions from: + - Joris Bekkers (UnravelSports) + + Permission is hereby granted to redistribute this software under the BSD 3-Clause License, with proper attribution + ---------- + + Calculate the Time-to-Intercept (TTI) pressing intensity for a group of players. + + This function estimates the time required for Player 1 to press Player 2 based on their + positions, velocities, reaction times, and maximum running speed. It calculates an + interception time matrix for all possible pairings of players. + + Parameters + ---------- + p1 : ndarray + An array of shape (n, 2) representing the positions of Pressing Players. + Each row corresponds to a player's position as (x, y) coordinates. + + p2 : ndarray + An array of shape (m, 2) representing the positions of Players on the In Possession Team (potentially including the ball location) + Each row corresponds to a player's position as (x, y) coordinates. + + v1 : ndarray + An array of shape (n, 2) representing the velocities corresponding to v1. Each row corresponds + to a player's velocity as (vx, vy). + + v2 : ndarray + An array of shape (m, 2) representing the velocities corresponding to p2. Each row corresponds + to a player's velocity as (vx, vy). + + reaction_time : float + The reaction time of p1'ss (in seconds) before they start moving towards p2's. + + max_velocity : float + The maximum running velocity of Player 1 (in meters per second). + + Returns + ------- + t : ndarray + A 2D array of shape (m, n) where t[i, j] represents the time required for Player 1[j] + to press Player 2[i]. + """ + u = (p1 + v1) - p1 # Adjusted velocity of Pressing Players + d2 = p2 + v2 # Destination of Players Under Pressure + + v = ( + d2[:, None, :] - p1[None, :, :] + ) # Relative motion vector between Pressing Players and Players Under Pressure + + u_mag = np.linalg.norm(u, axis=-1) # Magnitude of Pressing Players velocity + v_mag = np.linalg.norm(v, axis=-1) # Magnitude of relative motion vector + dot_product = np.sum(u * v, axis=-1) + + epsilon = 1e-10 # We add epsilon to avoid dividing by zero (which throws a warning) + angle = np.arccos(dot_product / (u_mag * v_mag + epsilon)) + + r_reaction = ( + p1 + v1 * reaction_time + ) # Adjusted position of Pressing Players after reaction time + d = d2[:, None, :] - r_reaction[None, :, :] # Distance vector after reaction time + + t = ( + u_mag * angle / np.pi # Time contribution from angular adjustment + + reaction_time # Add reaction time + + np.linalg.norm(d, axis=-1) / max_object_speed + ) # Time contribution from running + + return t diff --git a/unravel/utils/__init__.py b/unravel/utils/__init__.py index 961c060..d3ea75e 100644 --- a/unravel/utils/__init__.py +++ b/unravel/utils/__init__.py @@ -2,3 +2,5 @@ from .objects import * from .exceptions import * from .features import * +from .display import * +from .helpers import * diff --git a/unravel/utils/display/__init__.py b/unravel/utils/display/__init__.py new file mode 100644 index 0000000..f08e89d --- /dev/null +++ b/unravel/utils/display/__init__.py @@ -0,0 +1,2 @@ +from .video import * +from .colors import * diff --git a/unravel/utils/display/colors.py b/unravel/utils/display/colors.py new file mode 100644 index 0000000..8385423 --- /dev/null +++ b/unravel/utils/display/colors.py @@ -0,0 +1,76 @@ +from dataclasses import dataclass, field +from typing import Union, Tuple + +import re + +from matplotlib.colors import LinearSegmentedColormap + + +@dataclass +class Color: + color: Union[str, Tuple[int, int, int], Tuple[int, int, int, float]] + hex_value: str = field(init=False) + + def __post_init__(self): + self.hex_value = self.to_hex(self.color) + + @staticmethod + def to_hex( + color: Union[str, Tuple[int, int, int], Tuple[int, int, int, float]] + ) -> str: + if isinstance(color, str): + try: + import matplotlib.colors as mcolors + except ImportError: + raise ImportError( + "Seems like you don't have matplotlib installed. Please" + " install it using: pip install matplotlib" + ) + # Handle named colors via Matplotlib + if color.lower() in mcolors.CSS4_COLORS: + return mcolors.to_hex(color) + # Handle hex format + if re.match(r"^#(?:[0-9a-fA-F]{3}){1,2}$", color): + return color.lower() + raise ValueError(f"Invalid color format: {color}") + + elif isinstance(color, tuple): + if len(color) == 3: + r, g, b = color + return f"#{r:02x}{g:02x}{b:02x}" + elif len(color) == 4: + r, g, b, a = color + if not (0 <= a <= 1): + raise ValueError("Alpha value must be between 0 and 1.") + return f"#{r:02x}{g:02x}{b:02x}{int(a * 255):02x}" + else: + raise ValueError("Tuple must be RGB or RGBA.") + else: + raise TypeError("Unsupported color format.") + + +@dataclass +class TeamColors: + jersey: Color + goalkeeper: Color = None + + def __post_init__(self): + if not isinstance(self.jersey, Color): + self.jersey = Color(self.jersey) + if not isinstance(self.goalkeeper, Color): + self.goalkeeper = Color(self.goalkeeper) + + +@dataclass +class GameColors: + home_team: TeamColors + away_team: TeamColors + + +YlRd = ["#F7FBFF", "#FFEDA0", "#FEB24C", "#FD8D3C", "#E31A1C", "#BD0026", "#800026"] + + +@dataclass +class ColorMaps: + YELLOW_RED = LinearSegmentedColormap.from_list("", YlRd) + YELLOW_RED_R = LinearSegmentedColormap.from_list("", list(reversed(YlRd))) diff --git a/unravel/utils/display/video.py b/unravel/utils/display/video.py new file mode 100644 index 0000000..9074deb --- /dev/null +++ b/unravel/utils/display/video.py @@ -0,0 +1,59 @@ +from IPython.display import HTML + +from typing import List, Union + + +def show( + video_path: Union[List[str], str], + width=640, + height=480, + as_ipython_display: bool = True, +): + if isinstance(video_path, str): + video_path = [video_path] + + html_videos = "".join( + [ + f'' + for i, x in enumerate(video_path) + ] + ) + html_play_btns = "".join( + [ + f'document.getElementById("video{i}").play();' + for i, _ in enumerate(video_path) + ] + ) + html_pause_btns = "".join( + [ + f'document.getElementById("video{i}").pause();' + for i, _ in enumerate(video_path) + ] + ) + + html_string = f""" +
+ {html_videos} +
+
+
+ + +
+ + + """ + if as_ipython_display: + return HTML(html_string) + return html_string diff --git a/unravel/utils/features/utils.py b/unravel/utils/features/utils.py index ef89bc2..7d89df3 100644 --- a/unravel/utils/features/utils.py +++ b/unravel/utils/features/utils.py @@ -183,7 +183,11 @@ def flatten_to_reshaped_array(arr, s0, s1, as_list=False): return result_array if not as_list else result_array.tolist() -def reshape_array(arr, s0, s1): +def reshape_array(arr): + return np.array([a for a in arr.to_numpy()]) + + +def reshape_from_size(arr, s0, s1): return np.array([item for sublist in arr for item in sublist]).reshape(s0, s1) diff --git a/unravel/utils/helpers/__init__.py b/unravel/utils/helpers/__init__.py new file mode 100644 index 0000000..6e95282 --- /dev/null +++ b/unravel/utils/helpers/__init__.py @@ -0,0 +1 @@ +from .kloppy_helpers import * diff --git a/unravel/utils/helpers/kloppy_helpers.py b/unravel/utils/helpers/kloppy_helpers.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/unravel/utils/helpers/kloppy_helpers.py @@ -0,0 +1 @@ + diff --git a/unravel/utils/objects/__init__.py b/unravel/utils/objects/__init__.py index 4299b16..e513679 100644 --- a/unravel/utils/objects/__init__.py +++ b/unravel/utils/objects/__init__.py @@ -6,3 +6,4 @@ from .default_graph_settings import DefaultGraphSettings from .default_graph_converter import DefaultGraphConverter from .default_dataset import DefaultDataset +from .default_settings import AmericanFootballPitchDimensions, DefaultSettings diff --git a/unravel/utils/objects/default_dataset.py b/unravel/utils/objects/default_dataset.py index 17ad9b6..ffa0519 100644 --- a/unravel/utils/objects/default_dataset.py +++ b/unravel/utils/objects/default_dataset.py @@ -3,8 +3,8 @@ @dataclass class DefaultDataset: - _graph_id_column: str = field(default="graph_id") - _label_column: str = field(default="label") + _graph_id_column: str = field(default="graph_id", repr=False) + _label_column: str = field(default="label", repr=False) def load(self): raise NotImplementedError() diff --git a/unravel/utils/objects/default_graph_converter.py b/unravel/utils/objects/default_graph_converter.py index dfc9133..24da225 100644 --- a/unravel/utils/objects/default_graph_converter.py +++ b/unravel/utils/objects/default_graph_converter.py @@ -46,9 +46,8 @@ class DefaultGraphConverter: The latter can be useful when splitting graphs by possession or sequence id. In this case the dict would be {frame_id: sequence_id/possession_id}. Note that sequence_id/possession_id should probably be unique for the whole dataset. Perhaps like so {frame_id: 'match_id-sequence_id'}. Defaults to None. - max_player_speed (float): The maximum speed of a player in meters per second. Defaults to 12.0. - max_ball_speed (float): The maximum speed of the ball in meters per second. Defaults to 28.0. - boundary_correction (float): A correction factor for boundary calculations, used to correct out of bounds as a percentages (Used as 1+boundary_correction, ie 0.05). Defaults to None. + + boundary_correction (float): A correction factor for boundary calculations, used to correct out of bounds as a percentages (Used as 1+boundary_correction, ie 0.05). Defaults to None. self_loop_ball (bool): Flag to indicate if the ball node should have a self-loop. Defaults to True. adjacency_matrix_connect_type (AdjacencyMatrixConnectType): The type of connection used in the adjacency matrix, typically related to the ball. Defaults to AdjacenyMatrixConnectType.BALL. adjacency_matrix_type (AdjacencyMatrixType): The type of adjacency matrix, indicating how connections are structured, such as split by team. Defaults to AdjacencyMatrixType.SPLIT_BY_TEAM. @@ -65,10 +64,6 @@ class DefaultGraphConverter: prediction: bool = False - max_player_speed: float = 12.0 - max_ball_speed: float = 28.0 - max_player_acceleration: float = 6.0 - max_ball_acceleration: float = 13.5 self_loop_ball: bool = False adjacency_matrix_connect_type: Union[ Literal["ball"], Literal["ball_carrier"], Literal["no_connection"] @@ -126,18 +121,6 @@ def __post_init__(self): if not isinstance(self.prediction, bool): raise Exception("'prediction' should be of type boolean (bool)") - if not isinstance(self.max_player_speed, (float, int)): - raise Exception("'max_player_speed' should be of type float or int") - - if not isinstance(self.max_ball_speed, (float, int)): - raise Exception("'max_ball_speed' should be of type float or int") - - if not isinstance(self.max_player_acceleration, (float, int)): - raise Exception("'max_player_acceleration' should be of type float or int") - - if not isinstance(self.max_ball_acceleration, (float, int)): - raise Exception("'max_ball_acceleration' should be of type float or int") - if not isinstance(self.self_loop_ball, bool): raise Exception("'self_loop_ball' should be of type boolean (bool)") @@ -163,7 +146,7 @@ def _sport_specific_checks(self): "No sport specific checks implementend... Make sure to check for existens of labels of some sort, and graph ids of some sort..." ) - def _apply_settings(self): + def _apply_graph_settings(self): raise NotImplementedError() def _convert(self): diff --git a/unravel/utils/objects/default_settings.py b/unravel/utils/objects/default_settings.py new file mode 100644 index 0000000..ef16053 --- /dev/null +++ b/unravel/utils/objects/default_settings.py @@ -0,0 +1,44 @@ +import numpy as np +from dataclasses import dataclass, field +from typing import Union + +from kloppy.domain import Dimension, Unit, MetricPitchDimensions, Provider, Orientation + +from ..features import ( + AdjacencyMatrixType, + AdjacenyMatrixConnectType, + PredictionLabelType, + Pad, +) + +PITCH_LENGTH = 120.0 +PITCH_WIDTH = 53.3 + + +@dataclass +class AmericanFootballPitchDimensions: + pitch_length: float = PITCH_LENGTH + pitch_width: float = PITCH_WIDTH + standardized: bool = False + unit: Unit = Unit.YARDS + + x_dim: Dimension = field(default_factory=lambda: Dimension(min=0, max=PITCH_LENGTH)) + y_dim: Dimension = field(default_factory=lambda: Dimension(min=0, max=PITCH_WIDTH)) + end_zone: float = field(init=False) + + def __post_init__(self): + self.end_zone = self.x_dim.max - 10 + + +@dataclass +class DefaultSettings: + home_team_id: Union[str, int] + away_team_id: Union[str, int] + provider: Union[Provider, str] + pitch_dimensions: Union[MetricPitchDimensions, AmericanFootballPitchDimensions] + orientation: Orientation + max_player_speed: float = 12.0 + max_ball_speed: float = 28.0 + max_player_acceleration: float = 6.0 + max_ball_acceleration: float = 13.5 + ball_carrier_threshold: float = 25.0