├── .gitignore ├── LICENSE ├── README.md ├── docs ├── etsy.bibtex ├── logo.png └── logo_large.png ├── etsy ├── __init__.py ├── config.py ├── schema.py ├── scoring.py └── sync.py ├── pyproject.toml └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Data 2 | data/ 3 | 4 | # Distribution / packaging 5 | *.egg-info/ 6 | *.egg 7 | dist/ 8 | build/ 9 | lib/ 10 | lib64/ 11 | 12 | # Byte-compiled / optimized / DLL files 13 | __pychache__/ 14 | *.py[cod] 15 | *$py.class 16 | 17 | # Juypter Notebook 18 | .ipynb_checkpoints 19 | 20 | # IDE 21 | .idea/ 22 | 23 | # IPython 24 | profile_default/ 25 | ipython_config.py 26 | 27 | # pyenv 28 | .python-version 29 | 30 | # Environments 31 | .env 32 | .venv 33 | env/ 34 | venv/ 35 | 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) DTAI - KU Leuven – All rights reserved. 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 |
6 |
7 | 8 | ![Python Version: 3.10+](https://img.shields.io/badge/Python-3.10+-blue.svg) 9 | [![License: Apache 2.0](https://img.shields.io/badge/License-Apache%202.0-green.svg)](https://opensource.org/license/apache-2-0/) 10 | 11 |
12 | 13 | ## What is it? 14 | 15 | **ETSY** is a rule-based synchronization algorithm to **synchronize soccer event data with** its corresponding **tracking / positional data**. 16 | The algorithm synchronizes each playing period separately using two steps: 17 | 18 | 1. *Synchronize kickoff*: removes the constant time-bias due to the clocks in the event and tracking data not starting at the same time. 19 | 2. *Synchronize remaining events using a rule-base*: uses event-specific rules and a distance-based score function to identify the best matching frame within a selected time window around each event. 20 | 21 | ## Installation 22 | 23 | You can install ETSY directly from GitHub. 24 | 25 | ```sh 26 | # Clone the repository 27 | $ git clone git://github.com/ML-KULeuven/ETSY.git 28 | $ cd ETSY 29 | # Create a virtual environment 30 | $ python3 -m venv venv_name 31 | $ source venv_name/bin/activate 32 | # Install the package and its dependencies 33 | $ pip install . 34 | ``` 35 | 36 | ## Data Format 37 | 38 | ETSY relies on the [SPADL representation](https://github.com/ML-KULeuven/socceraction) for the event data. The exact format of the required event and tracking data is specified in `etsy/schema.py`. Your event and tracking data should be converted to this schema before using ETSY. 39 | 40 | ## Example Use 41 | 42 | ```python 43 | from etsy import sync 44 | 45 | # Initialize event-tracking synchronizer with given event data (df_events), 46 | # tracking data (df_tracking), and recording frequency of the tracking data (fps_tracking) 47 | ETSY = sync.EventTrackingSynchronizer(df_events, df_tracking, fps=fps_tracking) 48 | 49 | # Run the synchronization 50 | ETSY.synchronize() 51 | 52 | # Inspect the matched frames and scores 53 | print(ETSY.matched_frames) 54 | print(ETSY.scores) 55 | ``` 56 | 57 | ## Research 58 | 59 | If you make use of this package in your research, please consider citing the following paper: 60 | 61 | - Maaike Van Roy, Lorenzo Cascioli, and Jesse Davis. **ETSY: A rule-based approach to Event and Tracking data SYnchronization**. In Machine Learning and Data Mining for Sports Analytics ECML/PKDD 2023 Workshop (2023).
[ [bibtex](./docs/etsy.bibtex) ] 62 | 63 | 64 | Additionally, the following blog post gives an overview of ETSY: 65 | 66 | - [Introducing ETSY](https://dtai.cs.kuleuven.be/sports/blog/etsy:-a-rule-based-approach-to-event-and-tracking-data-synchronization) 67 | 68 | ## License 69 | 70 | Distributed under the terms of the [Apache License, Version 2.0](https://opensource.org/license/apache-2-0/), ETSY is free and open source software. Although not strictly required, we appreciate it if you include a link to this repo or cite our research in your work if you make use of it. 71 | 72 | 73 | -------------------------------------------------------------------------------- /docs/etsy.bibtex: -------------------------------------------------------------------------------- 1 | @inproceedings{VanRoy2023ETSY, 2 | author = {Van Roy, Maaike and Cascioli, Lorenzo and Davis, Jesse}, 3 | title = {ETSY: A rule-based approach to Event and Tracking data SYnchronization}, 4 | booktitle = {Machine Learning and Data Mining for Sports Analytics ECML/PKDD 2023 Workshop}, 5 | year = {2023}, 6 | pages = {11,23}, 7 | numpages = {12}, 8 | publisher = {Springer}, 9 | } 10 | -------------------------------------------------------------------------------- /docs/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ML-KULeuven/ETSY/18f554b112708be57f560b0c9d22fb8fe645807b/docs/logo.png -------------------------------------------------------------------------------- /docs/logo_large.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ML-KULeuven/ETSY/18f554b112708be57f560b0c9d22fb8fe645807b/docs/logo_large.png -------------------------------------------------------------------------------- /etsy/__init__.py: -------------------------------------------------------------------------------- 1 | """ETSY. 2 | 3 | :copyright: (c) 2023 by DTAI KU Leuven. 4 | :license: Apache 2.0, see LICENSE for more details. 5 | """ 6 | __version__ = "0.1" -------------------------------------------------------------------------------- /etsy/config.py: -------------------------------------------------------------------------------- 1 | """Defines ETSY configuration.""" 2 | 3 | FIELD_LENGTH = 105.0 # unit: meters 4 | FIELD_WIDTH = 68.0 # unit: meters 5 | 6 | SPADL_TYPES = [ 7 | "pass", 8 | "cross", 9 | "throw_in", 10 | "freekick_crossed", 11 | "freekick_short", 12 | "corner_crossed", 13 | "corner_short", 14 | "take_on", 15 | "foul", 16 | "tackle", 17 | "interception", 18 | "shot", 19 | "shot_penalty", 20 | "shot_freekick", 21 | "keeper_save", 22 | "keeper_claim", 23 | "keeper_punch", 24 | "keeper_pick_up", 25 | "clearance", 26 | "bad_touch", 27 | "non_action", 28 | "dribble", 29 | "goalkick", 30 | ] 31 | 32 | SPADL_BODYPARTS = ["foot", "head", "other", "head/other", "foot_left", "foot_right"] 33 | 34 | PASS_LIKE_OPEN = ["pass", "cross", "shot", "clearance", "keeper_punch", "take_on"] 35 | 36 | SET_PIECE = [ 37 | "throw_in", 38 | "freekick_crossed", 39 | "freekick_short", 40 | "corner_crossed", 41 | "corner_short", 42 | "shot_penalty", 43 | "shot_freekick", 44 | "goalkick", 45 | ] 46 | 47 | FAULT_LIKE = ["foul", "tackle"] 48 | 49 | BAD_TOUCH = ["bad_touch"] 50 | 51 | INCOMING_LIKE = ["interception", "keeper_save", "keeper_claim", "keeper_pick_up"] 52 | 53 | NOT_HANDLED = ["non_action", "dribble"] 54 | 55 | TIME_PASS_LIKE_OPEN = 5 # unit: seconds 56 | TIME_SET_PIECE = 10 # unit: seconds 57 | TIME_FAULT_LIKE = 5 # unit: seconds 58 | TIME_BAD_TOUCH = 5 # unit: seconds 59 | TIME_INCOMING_LIKE = 5 # unit: seconds 60 | -------------------------------------------------------------------------------- /etsy/schema.py: -------------------------------------------------------------------------------- 1 | """Schemas for the event and tracking data.""" 2 | from pandera import Column, DataFrameSchema, Check, Index 3 | import numpy as np 4 | from etsy import config 5 | 6 | event_schema = DataFrameSchema( 7 | { 8 | "period_id": Column(int, Check(lambda s: s.isin([1, 2]))), 9 | "timestamp": Column(np.dtype("datetime64[ns]")), 10 | "player_id": Column(object), 11 | "type_name": Column(str, Check(lambda s: s.isin(config.SPADL_TYPES))), 12 | "start_x": Column(float, Check(lambda s: (s >= 0) & (s <= config.FIELD_LENGTH))), 13 | "start_y": Column(float, Check(lambda s: (s >= 0) & (s <= config.FIELD_WIDTH))), 14 | "bodypart_id": Column(int, Check(lambda s: s.isin(range(len(config.SPADL_BODYPARTS))))), 15 | }, 16 | index=Index(int), 17 | ) 18 | 19 | tracking_schema = DataFrameSchema( 20 | { 21 | "period_id": Column(int, Check(lambda s: s.isin([1, 2]))), 22 | "timestamp": Column(np.dtype("datetime64[ns]")), 23 | "frame": Column(int, Check(lambda s: s >= 0)), 24 | "player_id": Column(object, nullable=True), # Mandatory for players (not ball) 25 | "ball": Column(bool), 26 | "x": Column(float), 27 | "y": Column(float), 28 | "z": Column( 29 | float, Check(lambda s: s >= 0), nullable=True 30 | ), # Mandatory for ball (not players) 31 | "acceleration": Column(float), 32 | }, 33 | index=Index(int), 34 | ) 35 | -------------------------------------------------------------------------------- /etsy/scoring.py: -------------------------------------------------------------------------------- 1 | """Defines ETSY scoring functions.""" 2 | import numpy as np 3 | from etsy.config import FIELD_LENGTH, FIELD_WIDTH 4 | 5 | 6 | def down_lin_func(mini: float, maxi: float, minval: float, maxval: float): 7 | if maxi == mini: 8 | return lambda x: maxval 9 | 10 | a = (minval - maxval) / (maxi - mini) 11 | b = (maxi * maxval - mini * minval) / (maxi - mini) 12 | 13 | return lambda x: a * x + b 14 | 15 | 16 | score_dist = down_lin_func(0.0, np.sqrt(FIELD_LENGTH**2 + FIELD_WIDTH**2), 0, 100 / 3) 17 | score_dist_player = down_lin_func(0.0, np.sqrt(FIELD_LENGTH**2 + FIELD_WIDTH**2), 0, 100 / 3) 18 | score_dist_ball = down_lin_func(0.0, np.sqrt(FIELD_LENGTH**2 + FIELD_WIDTH**2), 0, 100 / 3) 19 | 20 | 21 | def score_frames( 22 | mask_func, 23 | dist_to_ball, 24 | height_ball, 25 | dist_event_player, 26 | dist_event_ball, 27 | acceleration, 28 | timestamps, 29 | bodypart, 30 | ): 31 | scores = np.zeros(len(dist_to_ball)) 32 | 33 | idx = mask_func( 34 | dist_to_ball, 35 | height_ball, 36 | acceleration, 37 | timestamps, 38 | bodypart, 39 | ) 40 | 41 | if len(idx[0]) > 0: 42 | scores[idx] += ( 43 | score_dist(dist_to_ball[idx]) 44 | + score_dist_player(dist_event_player[idx]) 45 | + score_dist_ball(dist_event_ball[idx]) 46 | ) 47 | 48 | return scores 49 | -------------------------------------------------------------------------------- /etsy/sync.py: -------------------------------------------------------------------------------- 1 | """Implements the ETSY algorithm.""" 2 | import numpy as np 3 | import pandas as pd 4 | from tqdm import tqdm 5 | from etsy import config, scoring, schema 6 | 7 | 8 | class EventTrackingSynchronizer: 9 | """Synchronize event and tracking data using the ETSY algorithm. 10 | 11 | Parameters 12 | ---------- 13 | events : pd.DataFrame 14 | Event data to synchronize, according to schema etsy.schema.event_schema. 15 | tracking : pd.DataFrame 16 | Tracking data to synchronize, according to schema etsy.schema.tracking_schema. 17 | fps : int 18 | Recording frequency (frames per second) of the tracking data. 19 | kickoff_time : int 20 | Length of the window (in seconds) at the start of a playing period in which to search for the kickoff frame. 21 | """ 22 | 23 | def __init__( 24 | self, 25 | events: pd.DataFrame, 26 | tracking: pd.DataFrame, 27 | fps: int = 10, 28 | kickoff_time: int = 5, 29 | ): 30 | schema.event_schema.validate(events) 31 | schema.tracking_schema.validate(tracking) 32 | 33 | # Ensure unique index 34 | assert list(events.index.unique()) == [i for i in range(len(events))] 35 | assert list(tracking.index.unique()) == [i for i in range(len(tracking))] 36 | 37 | # Ensure frame identifiers are increasing by 1 38 | # assert list(tracking.frame.unique()) == [ 39 | # i for i in range(max(tracking[tracking.period_id == 2].frame) + 1) 40 | # ] 41 | 42 | self.events = events 43 | self.tracking = tracking 44 | self.fps = fps 45 | self.kickoff_time = kickoff_time 46 | self.last_matched_ts = pd.Timestamp("2000-01-01 01:00:00") 47 | 48 | # Store synchronization results 49 | self.shifted_timestamp = pd.Series(pd.NaT, index=[i for i in range(len(self.tracking))]) 50 | self.matched_frames = pd.Series(np.nan, index=[i for i in range(len(self.events))]) 51 | self.scores = pd.Series(np.nan, index=[i for i in range(len(self.events))]) 52 | 53 | def find_kickoff(self, period: int): 54 | """Searches for the kickoff frame in a given playing period. 55 | 56 | Parameters 57 | ---------- 58 | period: int 59 | The given playing period. 60 | 61 | Returns 62 | ------- 63 | The found kickoff frame. 64 | """ 65 | kickoff_event = self.events[self.events.period_id == period].iloc[0] 66 | 67 | if kickoff_event.type_name != "pass": 68 | raise Exception("First event is not a pass!") 69 | 70 | # Frames to search 71 | frame = self.tracking[self.tracking.period_id == period].frame.iloc[0] 72 | frames_to_check = [j for j in range(frame, frame + self.fps * self.kickoff_time, 1)] 73 | 74 | df_selection_player = self.tracking[ 75 | ( 76 | (self.tracking.frame.isin(frames_to_check)) 77 | & (self.tracking.period_id == period) 78 | & (self.tracking.player_id == kickoff_event.player_id) 79 | ) 80 | ] 81 | df_selection_ball = self.tracking[ 82 | ( 83 | (self.tracking.frame.isin(frames_to_check)) 84 | & (self.tracking.period_id == period) 85 | & self.tracking.ball 86 | & (self.tracking.x <= 55) 87 | & (self.tracking.x >= 50) 88 | & (self.tracking.y <= 36.5) 89 | & (self.tracking.y >= 31.5) 90 | ) 91 | ] 92 | 93 | # Mask of frames that have both player and ball in it 94 | mask_player = np.isin( 95 | df_selection_player.frame.to_numpy(), 96 | sorted(set(df_selection_player.frame.values) & set(df_selection_ball.frame.values)), 97 | ) 98 | mask_ball = np.isin( 99 | df_selection_ball.frame.to_numpy(), 100 | sorted(set(df_selection_player.frame.values) & set(df_selection_ball.frame.values)), 101 | ) 102 | 103 | dists = np.ones(len(df_selection_player)) * np.inf 104 | dists[mask_player] = np.sqrt( 105 | (df_selection_player[mask_player].x.values - df_selection_ball[mask_ball].x.values) 106 | ** 2 107 | + (df_selection_player[mask_player].y.values - df_selection_ball[mask_ball].y.values) 108 | ** 2 109 | ) 110 | 111 | dist_idx = np.where(dists <= 2.0) 112 | if len(dist_idx[0]) == 0: 113 | best_idx = np.argmin(dists) 114 | else: 115 | max_accel = np.nanmax(df_selection_player.acceleration.values[dist_idx]) 116 | best_idx = np.where(df_selection_player.acceleration == max_accel)[0][0] 117 | 118 | return df_selection_player.iloc[best_idx] 119 | 120 | def _find_matching_frame( 121 | self, 122 | event_idx: int, 123 | player_window: pd.DataFrame, 124 | ball_window: pd.DataFrame, 125 | mask_func, 126 | ): 127 | """Finds the matching frame of the given event within the given window. 128 | 129 | Parameters 130 | ---------- 131 | event_idx: int 132 | The index of the event to be matched. 133 | player_window: pd.DataFrame 134 | All frames of the acting player within a certain window. 135 | ball_window: pd.DataFrame 136 | All frames of the ball within the same window. 137 | mask_func: 138 | One of the action-specific filters, depending on the event's type. 139 | 140 | Returns 141 | ------- 142 | int 143 | Index of the matching frame in the tracking dataframe. 144 | float 145 | Score associated with the matching frame. 146 | """ 147 | 148 | event = self.events.loc[event_idx] 149 | 150 | # Mask of frames that have both player and ball in it 151 | mask_player = np.isin( 152 | player_window.frame.to_numpy(), 153 | sorted(set(player_window.frame.values) & set(ball_window.frame.values)), 154 | ) 155 | mask_ball = np.isin( 156 | ball_window.frame.to_numpy(), 157 | sorted(set(player_window.frame.values) & set(ball_window.frame.values)), 158 | ) 159 | 160 | # Retrieve features 161 | acceleration = np.ones(len(player_window)) * np.nan 162 | acceleration[mask_player] = ball_window[mask_ball].acceleration.values 163 | 164 | height_ball = np.ones(len(player_window)) * np.inf 165 | height_ball[mask_player] = ball_window[mask_ball].z.values 166 | 167 | dist_event_player = np.ones(len(player_window)) * np.inf 168 | dist_event_player[mask_player] = np.sqrt( 169 | (player_window[mask_player].x.values - event.start_x) ** 2 170 | + (player_window[mask_player].y.values - event.start_y) ** 2 171 | ) 172 | 173 | dist_event_ball = np.ones(len(player_window)) * np.inf 174 | dist_event_ball[mask_player] = np.sqrt( 175 | (ball_window[mask_ball].x.values - event.start_x) ** 2 176 | + (ball_window[mask_ball].y.values - event.start_y) ** 2 177 | ) 178 | 179 | dists = np.ones(len(player_window)) * np.inf 180 | dists[mask_player] = np.sqrt( 181 | (player_window[mask_player].x.values - ball_window[mask_ball].x.values) ** 2 182 | + (player_window[mask_player].y.values - ball_window[mask_ball].y.values) ** 2 183 | ) 184 | 185 | # Score frames 186 | scores = scoring.score_frames( 187 | mask_func, 188 | dists, 189 | height_ball, 190 | dist_event_player, 191 | dist_event_ball, 192 | acceleration, 193 | self.shifted_timestamp.loc[player_window.index.values], 194 | event.bodypart_id, 195 | ) 196 | id_max = np.argmax(scores) 197 | 198 | return player_window.index[id_max], scores[id_max] 199 | 200 | def _window_of_frames( 201 | self, 202 | player_id, 203 | event_timestamp: np.dtype("datetime64[ns]"), 204 | period: int, 205 | s: int, 206 | ): 207 | """Identifies the qualifying window of frames around the event's timestamp. 208 | 209 | Parameters 210 | ---------- 211 | player_id: Any 212 | The player executing the action. 213 | event_timestamp: np.datetime64[ns] 214 | The event's timestamp. 215 | period: int 216 | The playing period in which the event was performed. 217 | s: int 218 | Window length (in seconds). 219 | 220 | Returns 221 | ------- 222 | pd.DataFrame 223 | All frames of the acting player in the given window. 224 | pd.DataFrame 225 | All frames containing the ball in the given window. 226 | """ 227 | 228 | # Find closest frame time-wise 229 | idx = self.tracking[self.tracking.period_id == period].index.values 230 | frame = self.tracking[self.tracking.period_id == period].loc[ 231 | [(abs(self.shifted_timestamp.loc[idx] - event_timestamp)).idxmin()] 232 | ].frame.values[0] 233 | 234 | # Get all frames to search through 235 | all_frames = [j for j in range(frame, frame + self.fps * s, 1)] 236 | all_frames.extend([j for j in range(frame - 1, frame - self.fps * s, -1)]) 237 | 238 | # Select all player and ball frames within window range 239 | player_frames = self.tracking[ 240 | ( 241 | (self.tracking.player_id == player_id) 242 | & (self.tracking.frame.isin(all_frames)) 243 | & (self.tracking.period_id == period) 244 | ) 245 | ] 246 | ball_frames = self.tracking[ 247 | ( 248 | self.tracking.ball 249 | & (self.tracking.frame.isin(all_frames)) 250 | & (self.tracking.period_id == period) 251 | ) 252 | ] 253 | 254 | return player_frames, ball_frames 255 | 256 | def _adjust_time_bias_tracking( 257 | self, kickoff_timestamp: np.dtype("datetime64[ns]"), period: int 258 | ): 259 | """Corrects the tracking data timestamps by removing the constant bias. 260 | 261 | Parameters 262 | ---------- 263 | kickoff_timestamp: np.datetime64[ns] 264 | Timestamp of the kickoff in the tracking data. 265 | period: int 266 | The playing period in which the given kickoff is situated. 267 | """ 268 | 269 | start_time_events = self.events[self.events.period_id == period].iloc[0].timestamp 270 | 271 | kickoff_diff = abs(start_time_events - kickoff_timestamp) 272 | 273 | if start_time_events > kickoff_timestamp: 274 | self.shifted_timestamp.loc[ 275 | self.tracking[self.tracking.period_id == period] 276 | .index[0] : self.tracking[self.tracking.period_id == period] 277 | .index[-1] 278 | ] = (self.tracking[self.tracking.period_id == period].timestamp + kickoff_diff) 279 | 280 | else: 281 | self.shifted_timestamp.loc[ 282 | self.tracking[self.tracking.period_id == period] 283 | .index[0] : self.tracking[self.tracking.period_id == period] 284 | .index[-1] 285 | ] = (self.tracking[self.tracking.period_id == period].timestamp - kickoff_diff) 286 | 287 | def _mask_incoming_like( 288 | self, 289 | dist_to_ball, 290 | height_ball, 291 | acceleration, 292 | timestamps, 293 | bodypart, 294 | ): 295 | mask_dist_ball = dist_to_ball <= 2 296 | mask_height_ball = height_ball <= 3 297 | mask_timestamps = timestamps > self.last_matched_ts 298 | mask_acceleration = acceleration <= 0 299 | 300 | return np.where(mask_dist_ball & mask_height_ball & mask_timestamps & mask_acceleration) 301 | 302 | def _mask_fault_like( 303 | self, 304 | dist_to_ball, 305 | height_ball, 306 | acceleration, 307 | timestamps, 308 | bodypart, 309 | ): 310 | mask_dist_ball = dist_to_ball <= 3 311 | mask_height_ball = height_ball <= 4 312 | mask_timestamps = timestamps > self.last_matched_ts 313 | 314 | return np.where(mask_dist_ball & mask_height_ball & mask_timestamps) 315 | 316 | def _mask_bad_touch( 317 | self, 318 | dist_to_ball, 319 | height_ball, 320 | acceleration, 321 | timestamps, 322 | bodypart, 323 | ): 324 | mask_dist_ball = dist_to_ball <= 3 325 | mask_height_ball = height_ball <= 3 326 | mask_timestamps = timestamps > self.last_matched_ts 327 | 328 | if (bodypart == 0) | (bodypart == 4) | (bodypart == 5): 329 | mask_height = height_ball <= 1.5 330 | elif (bodypart == 1) | (bodypart == 3): 331 | mask_height = height_ball > 1.0 332 | else: 333 | mask_height = np.ones(len(dist_to_ball)) 334 | 335 | return np.where(mask_dist_ball & mask_height_ball & mask_timestamps & mask_height) 336 | 337 | def _mask_pass_like( 338 | self, 339 | dist_to_ball, 340 | height_ball, 341 | acceleration, 342 | timestamps, 343 | bodypart, 344 | ): 345 | mask_dist_ball = dist_to_ball <= 2.5 346 | mask_height_ball = height_ball <= 3 347 | mask_timestamps = timestamps > self.last_matched_ts 348 | 349 | if (bodypart == 0) | (bodypart == 4) | (bodypart == 5): 350 | mask_height = height_ball <= 1.5 351 | mask_acceleration = acceleration >= 0 352 | elif (bodypart == 1) | (bodypart == 3): 353 | mask_height = height_ball > 1.0 354 | mask_acceleration = np.ones(len(dist_to_ball)) 355 | else: 356 | mask_height = np.ones(len(dist_to_ball)) 357 | mask_acceleration = acceleration >= 0 358 | 359 | return np.where( 360 | mask_dist_ball & mask_height_ball & mask_timestamps & mask_acceleration & mask_height 361 | ) 362 | 363 | def _sync_events_of_period(self, period: int): 364 | """Synchronizes the event and tracking data of a given playing period. 365 | 366 | Parameters 367 | ---------- 368 | period: int 369 | The playing period of which to synchronize the event and tracking data. 370 | 371 | Returns 372 | ------- 373 | np.array(float) 374 | Array containing all matched frame identifiers for the events, or NaN if no match could be found. 375 | np.array(float) 376 | Array containing the score for each match, or NaN is no match could be found. 377 | """ 378 | 379 | matched_frames = np.ones(len(self.events[self.events.period_id == period]) - 1) * np.nan 380 | scores = np.ones(len(self.events[self.events.period_id == period]) - 1) * np.nan 381 | 382 | idx_start = self.events[self.events.period_id == period].index[0] + 1 383 | 384 | if period == 1: 385 | idxs = self.events.index[ 386 | ( 387 | (self.events.index >= idx_start) 388 | & (self.events.index < self.events[self.events.period_id == 2].index[0]) 389 | ) 390 | ] 391 | else: 392 | idxs = self.events.index[self.events.index >= idx_start] 393 | 394 | for k, idx in enumerate(tqdm(idxs)): 395 | player_id = self.events.loc[idx].player_id 396 | event_time = self.events.loc[idx].timestamp 397 | type_action = self.events.loc[idx].type_name 398 | 399 | if type_action in config.PASS_LIKE_OPEN: 400 | s = config.TIME_PASS_LIKE_OPEN 401 | score_fn = self._mask_pass_like 402 | elif type_action in config.SET_PIECE: 403 | s = config.TIME_SET_PIECE 404 | score_fn = self._mask_pass_like 405 | elif type_action in config.INCOMING_LIKE: 406 | s = config.TIME_INCOMING_LIKE 407 | score_fn = self._mask_incoming_like 408 | elif type_action in config.BAD_TOUCH: 409 | s = config.TIME_BAD_TOUCH 410 | score_fn = self._mask_bad_touch 411 | elif type_action in config.FAULT_LIKE: 412 | s = config.TIME_FAULT_LIKE 413 | score_fn = self._mask_fault_like 414 | elif type_action in config.NOT_HANDLED: 415 | continue 416 | else: 417 | raise Exception(f"Event type {type_action} unknown!") 418 | 419 | player_window, ball_window = self._window_of_frames(player_id, event_time, period, s) 420 | 421 | if len(player_window) > 0: 422 | frame_idx, score = self._find_matching_frame( 423 | idx, player_window, ball_window, score_fn 424 | ) 425 | 426 | if score > 0.0: 427 | matched_frames[k] = self.tracking.loc[frame_idx].frame 428 | scores[k] = score 429 | self.last_matched_ts = self.shifted_timestamp.loc[frame_idx] 430 | else: 431 | print(f"No window found at {event_time}!") 432 | 433 | return matched_frames, scores 434 | 435 | def synchronize(self): 436 | """ 437 | Applies the ETSY synchronization algorithm on the instantiated class. 438 | """ 439 | 440 | # Find kickoff & adjust time bias between events and tracking 441 | kickoff_frame_p1 = self.find_kickoff(period=1) 442 | 443 | self._adjust_time_bias_tracking(kickoff_frame_p1.timestamp, 1) 444 | self.last_matched_ts = self.shifted_timestamp.loc[ 445 | self.tracking[self.tracking.timestamp == kickoff_frame_p1.timestamp].index.values[0] 446 | ] 447 | 448 | # Sync events of playing period 1 449 | matched_frames_p1, scores_p1 = self._sync_events_of_period(1) 450 | 451 | # Find kickoff & adjust time bias between events and tracking 452 | kickoff_frame_p2 = self.find_kickoff(period=2) 453 | self._adjust_time_bias_tracking(kickoff_frame_p2.timestamp, 2) 454 | self.last_matched_ts = self.shifted_timestamp.loc[ 455 | self.tracking[((self.tracking.timestamp == kickoff_frame_p2.timestamp) & ( 456 | self.tracking.period_id == 2))].index.values[0] 457 | ] 458 | 459 | # Sync events of playing period 2 460 | matched_frames_p2, scores_p2 = self._sync_events_of_period(2) 461 | 462 | # Store result 463 | self.matched_frames.loc[self.events.index[0]] = kickoff_frame_p1.frame 464 | self.matched_frames.loc[ 465 | self.events.index[1 : len(self.events[self.events.period_id == 1])] 466 | ] = matched_frames_p1 467 | self.matched_frames.loc[ 468 | self.events.index[len(self.events[self.events.period_id == 1])] 469 | ] = kickoff_frame_p2.frame 470 | self.matched_frames.loc[ 471 | self.events.index[len(self.events[self.events.period_id == 1]) + 1 :] 472 | ] = matched_frames_p2 473 | 474 | self.scores.loc[ 475 | self.events.index[1 : len(self.events[self.events.period_id == 1])] 476 | ] = scores_p1 477 | self.scores.loc[ 478 | self.events.index[len(self.events[self.events.period_id == 1]) + 1 :] 479 | ] = scores_p2 480 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools>=61.0"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [project] 6 | name = "etsy" 7 | version = "0.1" 8 | authors = [ 9 | { name="Maaike Van Roy", email="maaike.vanroy@kuleuven.be" }, 10 | ] 11 | description = "Soccer Event - Tracking Synchronization" 12 | readme = "README.md" 13 | requires-python = ">=3.10" 14 | classifiers = [ 15 | "Programming Language :: Python :: 3", 16 | "License :: OSI Approved :: Apache Software License", 17 | "Operating System :: OS Independent", 18 | ] 19 | dependencies = [ 20 | "numpy", 21 | "pandas", 22 | "pandera", 23 | "tqdm", 24 | ] 25 | 26 | [project.urls] 27 | "Homepage" = "https://github.com/ML-KULeuven/ETSY" -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | if __name__ == "__main__": 4 | setup(packages=["etsy"]) --------------------------------------------------------------------------------