├── .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 | 
9 | [](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"])
--------------------------------------------------------------------------------