rustat-python-api 0.5.8__tar.gz → 0.6.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (19) hide show
  1. {rustat-python-api-0.5.8/rustat_python_api.egg-info → rustat-python-api-0.6.1}/PKG-INFO +1 -1
  2. rustat-python-api-0.6.1/rustat_python_api/pitch_control.py +719 -0
  3. {rustat-python-api-0.5.8 → rustat-python-api-0.6.1}/rustat_python_api/processing.py +17 -10
  4. {rustat-python-api-0.5.8 → rustat-python-api-0.6.1/rustat_python_api.egg-info}/PKG-INFO +1 -1
  5. {rustat-python-api-0.5.8 → rustat-python-api-0.6.1}/rustat_python_api.egg-info/requires.txt +1 -0
  6. {rustat-python-api-0.5.8 → rustat-python-api-0.6.1}/setup.py +3 -2
  7. rustat-python-api-0.5.8/rustat_python_api/pitch_control.py +0 -330
  8. {rustat-python-api-0.5.8 → rustat-python-api-0.6.1}/LICENSE +0 -0
  9. {rustat-python-api-0.5.8 → rustat-python-api-0.6.1}/README.md +0 -0
  10. {rustat-python-api-0.5.8 → rustat-python-api-0.6.1}/pyproject.toml +0 -0
  11. {rustat-python-api-0.5.8 → rustat-python-api-0.6.1}/rustat_python_api/__init__.py +0 -0
  12. {rustat-python-api-0.5.8 → rustat-python-api-0.6.1}/rustat_python_api/config.py +0 -0
  13. {rustat-python-api-0.5.8 → rustat-python-api-0.6.1}/rustat_python_api/models_api.py +0 -0
  14. {rustat-python-api-0.5.8 → rustat-python-api-0.6.1}/rustat_python_api/parser.py +0 -0
  15. {rustat-python-api-0.5.8 → rustat-python-api-0.6.1}/rustat_python_api/urls.py +0 -0
  16. {rustat-python-api-0.5.8 → rustat-python-api-0.6.1}/rustat_python_api.egg-info/SOURCES.txt +0 -0
  17. {rustat-python-api-0.5.8 → rustat-python-api-0.6.1}/rustat_python_api.egg-info/dependency_links.txt +0 -0
  18. {rustat-python-api-0.5.8 → rustat-python-api-0.6.1}/rustat_python_api.egg-info/top_level.txt +0 -0
  19. {rustat-python-api-0.5.8 → rustat-python-api-0.6.1}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: rustat-python-api
3
- Version: 0.5.8
3
+ Version: 0.6.1
4
4
  Summary: A Python wrapper for RuStat API
5
5
  Home-page: https://github.com/dailydaniel/rustat-python-api
6
6
  Author: Daniel Zholkovsky
@@ -0,0 +1,719 @@
1
+ import pandas as pd
2
+ import numpy as np
3
+ from scipy.stats import multivariate_normal as mvn
4
+ import matplotlib.pyplot as plt
5
+ import matplotlib.animation as animation
6
+ import matplotsoccer as mpl
7
+ import torch
8
+ from tqdm import tqdm
9
+
10
+
11
+ class PitchControl:
12
+ def __init__(self, tracking: pd.DataFrame, events: pd.DataFrame, ball_data: pd.DataFrame = None):
13
+ self.team_ids = tracking['team_id'].unique()
14
+ sides = tracking.groupby('team_id')['side_1h'].unique()
15
+ side_by_team = dict(zip(self.team_ids, sides[self.team_ids].apply(lambda x: x[0])))
16
+ self.side_by_half = {
17
+ 1: side_by_team,
18
+ 2:
19
+ {
20
+ team: 'left' if side == 'right' else 'right'
21
+ for team, side in side_by_team.items()
22
+ }
23
+ }
24
+
25
+ self._grid_cache: dict[tuple[int, str, torch.dtype], tuple[torch.Tensor, torch.Tensor, torch.Tensor]] = {}
26
+
27
+ self.locs_home, self.locs_away, self.locs_ball, self.t = self.get_locs(
28
+ tracking,
29
+ events,
30
+ ball_data
31
+ )
32
+
33
+ def get_locs(self, tracking: pd.DataFrame, events: pd.DataFrame, ball_data: pd.DataFrame | None) -> tuple:
34
+ events = events[[
35
+ 'possession_number', 'team_id', 'possession_team_id',
36
+ 'half', 'second', 'pos_x', 'pos_y'
37
+ ]]
38
+
39
+ events = self.swap_coords_batch(events)
40
+
41
+ if ball_data is None:
42
+ ball_data = self.interpolate_ball_data(
43
+ events[['half', 'second', 'pos_x', 'pos_y']],
44
+ tracking
45
+ )
46
+
47
+ locs_home, locs_away = self.build_player_locs(tracking)
48
+
49
+ locs_ball = {
50
+ half: ball_data[ball_data['half'] == half][['pos_x', 'pos_y']].values
51
+ for half in tracking['half'].unique()
52
+ }
53
+
54
+ t = {
55
+ half: ball_data[ball_data['half'] == half]['second'].values
56
+ for half in tracking['half'].unique()
57
+ }
58
+
59
+ return locs_home, locs_away, locs_ball, t
60
+
61
+ # def swap_coords(self, row, how: str = 'x'):
62
+ # half = row['half']
63
+ # team_id = row['team_id']
64
+ # possession_team_id = row['possession_team_id']
65
+ # x = row['pos_x']
66
+ # y = row['pos_y']
67
+
68
+ # if isinstance(possession_team_id, list):
69
+ # current_side = 'left' if team_id in possession_team_id else 'right'
70
+ # real_side = self.side_by_half[half][str(int(team_id))]
71
+ # else:
72
+ # current_side = 'left' if team_id == possession_team_id else 'right'
73
+ # real_side = self.side_by_half[half][str(int(team_id))]
74
+
75
+ # if current_side != real_side:
76
+ # if how == 'x':
77
+ # x = 105 - x
78
+ # else:
79
+ # y = 68 - y
80
+
81
+ # return x if how == 'x' else y
82
+
83
+ def swap_coords_batch(self, events: pd.DataFrame) -> pd.DataFrame:
84
+ """Vectorised replacement for per-row `swap_coords`.
85
+
86
+ Modifies *events* in-place: flips coordinates for rows where the
87
+ current attacking direction (left/right) does not match the
88
+ canonical side stored in ``self.side_by_half``.
89
+ Returns the same DataFrame for chaining.
90
+ """
91
+
92
+ side_by_half = self.side_by_half # local alias for speed
93
+
94
+ def needs_swap(row):
95
+ team_id = row['team_id']
96
+ poss = row['possession_team_id']
97
+ half = row['half']
98
+
99
+ current_left = team_id in poss if isinstance(poss, list) else team_id == poss
100
+ current_side = 'left' if current_left else 'right'
101
+ real_side = side_by_half[half][str(int(team_id))]
102
+ return current_side != real_side
103
+
104
+ mask = events.apply(needs_swap, axis=1)
105
+
106
+ # flip coords in bulk
107
+ events.loc[mask, 'pos_x'] = 105 - events.loc[mask, 'pos_x']
108
+ events.loc[mask, 'pos_y'] = 68 - events.loc[mask, 'pos_y']
109
+ return events
110
+
111
+ def build_player_locs(self, tracking: pd.DataFrame):
112
+ """Vectorised construction of player location dictionaries.
113
+
114
+ Returns (locs_home, locs_away) where each is
115
+ {half: {player_id: np.ndarray(T,2)}}.
116
+ """
117
+ locs_home = {1: {}, 2: {}}
118
+ locs_away = {1: {}, 2: {}}
119
+
120
+ # Work per half to keep order and avoid extra boolean checks.
121
+ for half in (1, 2):
122
+ half_df = tracking[tracking['half'] == half]
123
+ for side, locs_out in [('left', locs_home), ('right', locs_away)]:
124
+ side_df = half_df[half_df['side_1h'] == side]
125
+ for pid, grp in side_df.groupby('player_id'):
126
+ locs_out[half][pid] = grp[['pos_x', 'pos_y']].values
127
+ return locs_home, locs_away
128
+
129
+ @staticmethod
130
+ def interpolate_ball_data(
131
+ ball_data: pd.DataFrame,
132
+ player_data: pd.DataFrame
133
+ ) -> pd.DataFrame:
134
+ ball_data = ball_data.drop_duplicates(subset=['second', 'half'])
135
+
136
+ interpolated_data = []
137
+ for half in ball_data['half'].unique():
138
+ ball_half = ball_data[ball_data['half'] == half]
139
+ player_half = player_data[player_data['half'] == half]
140
+
141
+ player_times = player_half['second'].unique()
142
+
143
+ ball_half = ball_half.sort_values(by='second')
144
+ interpolated_half = pd.DataFrame({'second': player_times})
145
+ interpolated_half['pos_x'] = np.interp(
146
+ interpolated_half['second'], ball_half['second'], ball_half['pos_x']
147
+ )
148
+ interpolated_half['pos_y'] = np.interp(
149
+ interpolated_half['second'], ball_half['second'], ball_half['pos_y']
150
+ )
151
+ interpolated_half['half'] = half
152
+ interpolated_data.append(interpolated_half)
153
+
154
+ interpolated_ball_data = pd.concat(interpolated_data, ignore_index=True)
155
+ return interpolated_ball_data
156
+
157
+ @staticmethod
158
+ def get_player_data(player_id, half, tracking):
159
+ timestamps = tracking[tracking['half'] == half]['second'].unique()
160
+ player_data = tracking[
161
+ (tracking['player_id'] == player_id)
162
+ & (tracking['half'] == half)
163
+ ][['second', 'pos_x', 'pos_y']]
164
+
165
+ player_data_full = pd.DataFrame({'second': timestamps})
166
+ player_data_full = player_data_full.merge(player_data, on='second', how='left')
167
+
168
+ return player_data_full[['pos_x', 'pos_y']].values
169
+
170
+ def influence_np(
171
+ self,
172
+ player_index: str,
173
+ location: np.ndarray,
174
+ time_index: int,
175
+ home_or_away: str,
176
+ half: int,
177
+ verbose: bool = False
178
+ ):
179
+ if home_or_away == 'h':
180
+ data = self.locs_home[half].copy()
181
+ elif home_or_away == 'a':
182
+ data = self.locs_away[half].copy()
183
+ else:
184
+ raise ValueError("Enter either 'h' or 'a'.")
185
+
186
+ locs_ball = self.locs_ball[half].copy()
187
+ t = self.t[half].copy()
188
+
189
+ if (
190
+ np.all(np.isfinite(data[player_index][[time_index, time_index + 1], :]))
191
+ & np.all(np.isfinite(locs_ball[time_index, :]))
192
+ ):
193
+ jitter = 1e-10 ## to prevent identically zero covariance matrices when velocity is zero
194
+ ## compute velocity by fwd difference
195
+ s = (
196
+ np.linalg.norm(
197
+ data[player_index][time_index + 1,:]
198
+ - data[player_index][time_index,:] + jitter
199
+ )
200
+ / (t[time_index + 1] - t[time_index])
201
+ )
202
+ ## velocities in x,y directions
203
+ sxy = (
204
+ (data[player_index][time_index + 1, :] - data[player_index][time_index, :] + jitter)
205
+ / (t[time_index + 1] - t[time_index])
206
+ )
207
+ ## angle between velocity vector & x-axis
208
+ theta = np.arccos(sxy[0] / np.linalg.norm(sxy))
209
+ ## rotation matrix
210
+ R = np.array([[np.cos(theta), -np.sin(theta)],[np.sin(theta), np.cos(theta)]])
211
+ mu = data[player_index][time_index, :] + sxy * 0.5
212
+ Srat = (s / 13) ** 2
213
+ Ri = np.linalg.norm(locs_ball[time_index, :] - data[player_index][time_index, :])
214
+ ## don't think this function is specified in the paper but looks close enough to fig 9
215
+ Ri = np.minimum(4 + Ri ** 3/ (18 ** 3 / 6), 10)
216
+ S = np.array([[(1 + Srat) * Ri / 2, 0], [0, (1 - Srat) * Ri / 2]])
217
+ Sigma = np.matmul(R, S)
218
+ Sigma = np.matmul(Sigma, S)
219
+ Sigma = np.matmul(Sigma, np.linalg.inv(R)) ## this is not efficient, forgive me.
220
+ out = mvn.pdf(location, mu, Sigma) / mvn.pdf(data[player_index][time_index, :], mu, Sigma)
221
+ else:
222
+ if verbose:
223
+ print("Data is not finite.")
224
+ out = np.zeros(location.shape[0])
225
+ return out
226
+
227
+ def _batch_influence_pt(
228
+ self,
229
+ player_dict: dict,
230
+ locs: torch.Tensor,
231
+ time_index: int,
232
+ half: int,
233
+ device: str,
234
+ dtype: torch.dtype,
235
+ ) -> torch.Tensor:
236
+ """Compute cumulative influence of *many* players at once.
237
+
238
+ Parameters
239
+ ----------
240
+ player_dict : dict[player_id -> np.ndarray(shape=(T,2))]
241
+ Pre-loaded trajectory arrays for one team.
242
+ locs : torch.Tensor shape (N,2)
243
+ Grid locations (already on correct device / dtype).
244
+ time_index : int
245
+ Frame index t.
246
+ half : int
247
+ Half number.
248
+ device, dtype : torch configuration.
249
+
250
+ Returns
251
+ -------
252
+ torch.Tensor shape (N,)
253
+ Sum of influences from all valid players in *player_dict*.
254
+ """
255
+
256
+ pos_t_list, pos_tp1_list = [], []
257
+ for arr in player_dict.values():
258
+ # Ensure we have t and t+1 and no NaNs at those rows
259
+ if (
260
+ time_index + 1 < arr.shape[0]
261
+ and np.isfinite(arr[[time_index, time_index + 1], :]).all()
262
+ ):
263
+ pos_t_list.append(arr[time_index])
264
+ pos_tp1_list.append(arr[time_index + 1])
265
+
266
+ if not pos_t_list:
267
+ return torch.zeros(locs.shape[0], device=device, dtype=dtype)
268
+
269
+ pos_t = torch.tensor(np.asarray(pos_t_list), device=device, dtype=dtype) # (P,2)
270
+ pos_tp1 = torch.tensor(np.asarray(pos_tp1_list), device=device, dtype=dtype) # (P,2)
271
+
272
+ # Velocity, speed, rotation -------------------------
273
+ dt_sec = float(self.t[half][time_index + 1] - self.t[half][time_index])
274
+ sxy = (pos_tp1 - pos_t) / dt_sec # (P,2)
275
+
276
+ speed = torch.linalg.norm(sxy, dim=1) # (P,)
277
+ norm_sxy = speed.clamp(min=1e-6)
278
+
279
+ theta = torch.acos(torch.clamp(sxy[:, 0] / norm_sxy, -1 + 1e-6, 1 - 1e-6)) # (P,)
280
+ cos_t, sin_t = torch.cos(theta), torch.sin(theta)
281
+
282
+ R = torch.stack(
283
+ [
284
+ torch.stack([cos_t, -sin_t], dim=1),
285
+ torch.stack([sin_t, cos_t], dim=1),
286
+ ],
287
+ dim=1,
288
+ ) # (P,2,2)
289
+
290
+ # Shape parameters ----------------------------------
291
+ Srat = (speed / 13) ** 2 # (P,)
292
+
293
+ ball_pos = torch.tensor(
294
+ self.locs_ball[half][time_index], device=device, dtype=dtype
295
+ ) # (2,)
296
+ Ri = torch.linalg.norm(ball_pos - pos_t, dim=1) # (P,)
297
+ Ri = torch.minimum(4 + Ri ** 3 / (18 ** 3 / 6), torch.tensor(10.0, device=device, dtype=dtype))
298
+
299
+ S11 = (1 + Srat) * Ri / 2
300
+ S22 = (1 - Srat) * Ri / 2
301
+
302
+ S = torch.zeros((pos_t.shape[0], 2, 2), device=device, dtype=dtype)
303
+ S[:, 0, 0] = S11
304
+ S[:, 1, 1] = S22
305
+
306
+ Sigma = R @ S @ S @ R.transpose(1, 2) # (P,2,2)
307
+
308
+ eye = torch.eye(2, device=device, dtype=dtype) * 1e-6
309
+ eye = eye.expand(pos_t.shape[0], 2, 2) # broadcast to (P,2,2)
310
+
311
+ if dtype == torch.float16:
312
+ Sigma_inv = torch.linalg.inv((Sigma + eye).float()).to(dtype)
313
+ else:
314
+ Sigma_inv = torch.linalg.inv(Sigma + eye)
315
+
316
+ # Mean ----------------------------------------------
317
+ mu = pos_t + 0.5 * sxy # (P,2)
318
+
319
+ # Grid diff & Mahalanobis ----------------------------
320
+ diff = locs.view(1, -1, 2) # (1,N,2)
321
+ diff = diff - mu.unsqueeze(1) # (P,N,2)
322
+
323
+ maha = torch.einsum('pni,pij,pnj->pn', diff, Sigma_inv, diff) # (P,N)
324
+
325
+ # Replace NaNs that arise from invalid player positions with large value
326
+ # so their influence tends to zero after exponent, then eliminate residual NaNs.
327
+ maha = torch.nan_to_num(maha, nan=1e9, posinf=1e9, neginf=1e9)
328
+
329
+ out = torch.exp(-0.5 * maha) # (P,N)
330
+
331
+ return out.sum(dim=0) # sum over players
332
+
333
+ def _batch_team_influence_frames_pt(
334
+ self,
335
+ pos_t: torch.Tensor, # (F,P,2)
336
+ pos_tp1: torch.Tensor, # (F,P,2)
337
+ ball_pos: torch.Tensor, # (F,2)
338
+ dt_secs: torch.Tensor, # (F,)
339
+ locs: torch.Tensor, # (N,2)
340
+ dtype: torch.dtype,
341
+ ) -> torch.Tensor: # returns (F,N)
342
+ """Vectorised influence for many frames & players of ОДНОЙ команды."""
343
+
344
+ device = locs.device
345
+
346
+ sxy = (pos_tp1 - pos_t) / dt_secs[:, None, None] # (F,P,2)
347
+ speed = torch.linalg.norm(sxy, dim=-1) # (F,P)
348
+ norm_sxy = speed.clamp(min=1e-6)
349
+
350
+ theta = torch.acos(torch.clamp(sxy[..., 0] / norm_sxy, -1 + 1e-6, 1 - 1e-6))
351
+ cos_t, sin_t = torch.cos(theta), torch.sin(theta) # (F,P)
352
+
353
+ R = torch.stack(
354
+ [
355
+ torch.stack([cos_t, -sin_t], dim=-1),
356
+ torch.stack([sin_t, cos_t], dim=-1),
357
+ ],
358
+ dim=-2,
359
+ ) # (F,P,2,2)
360
+
361
+ Srat = (speed / 13) ** 2 # (F,P)
362
+
363
+ Ri = torch.linalg.norm(ball_pos[:, None, :] - pos_t, dim=-1) # (F,P)
364
+ Ri = torch.minimum(4 + Ri ** 3 / (18 ** 3 / 6), torch.tensor(10.0, device=device, dtype=dtype))
365
+
366
+ S11 = (1 + Srat) * Ri / 2
367
+ S22 = (1 - Srat) * Ri / 2
368
+
369
+ S = torch.zeros((*pos_t.shape[:-1], 2, 2), device=device, dtype=dtype) # (F,P,2,2)
370
+ S[..., 0, 0] = S11
371
+ S[..., 1, 1] = S22
372
+
373
+ Sigma = R @ S @ S @ R.transpose(-1, -2) # (F,P,2,2)
374
+
375
+ eye = torch.eye(2, device=device, dtype=dtype) * 1e-6
376
+ eye = eye.view(1, 1, 2, 2)
377
+
378
+ if dtype == torch.float16:
379
+ Sigma_inv = torch.linalg.inv((Sigma + eye).float()).to(dtype)
380
+ else:
381
+ Sigma_inv = torch.linalg.inv(Sigma + eye)
382
+
383
+ mu = pos_t + 0.5 * sxy # (F,P,2)
384
+
385
+ diff = locs.view(1, 1, -1, 2) # (1,1,N,2)
386
+ diff = diff - mu.unsqueeze(2) # (F,P,N,2)
387
+
388
+ maha = torch.einsum('fpni,fpij,fpnj->fpn', diff, Sigma_inv, diff) # (F,P,N)
389
+
390
+ # Replace NaNs that arise from invalid player positions with large value
391
+ # so their influence tends to zero after exponent, then eliminate residual NaNs.
392
+ maha = torch.nan_to_num(maha, nan=1e9, posinf=1e9, neginf=1e9)
393
+
394
+ out = torch.exp(-0.5 * maha) # (F,P,N)
395
+
396
+ return out.sum(dim=1) # sum over players
397
+
398
+ @staticmethod
399
+ def _stack_team_frames(players: list[np.ndarray], frames: np.ndarray, device: str, dtype: torch.dtype):
400
+ """Stack positions for given frames into torch tensors (pos_t, pos_tp1)."""
401
+ # Ensure every player's trajectory is long enough; if not, pad by repeating
402
+ # the last available coordinate so that indexing `frames` and `frames+1` is safe.
403
+ max_needed = frames[-1] + 1 # we access idx and idx+1
404
+
405
+ padded = []
406
+ for p in players:
407
+ if len(p) <= max_needed:
408
+ pad_len = max_needed + 1 - len(p)
409
+ if pad_len > 0:
410
+ last = p[-1][None, :]
411
+ p = np.vstack([p, np.repeat(last, pad_len, axis=0)])
412
+ padded.append(p)
413
+
414
+ pos_t = torch.tensor(
415
+ np.stack([p[frames] for p in padded], axis=1), device=device, dtype=dtype
416
+ ) # (F,P,2)
417
+ pos_tp1 = torch.tensor(
418
+ np.stack([p[frames + 1] for p in padded], axis=1), device=device, dtype=dtype
419
+ )
420
+ return pos_t, pos_tp1
421
+
422
+ def _fit_full_pt(
423
+ self,
424
+ half: int,
425
+ dt: int,
426
+ device: str,
427
+ batch_size: int,
428
+ use_fp16: bool,
429
+ verbose: bool,
430
+ ):
431
+ """Internal helper with fully batched PyTorch implementation."""
432
+
433
+ dtype = torch.float16 if use_fp16 else torch.float32
434
+ xx_t, yy_t, locs_t = self._get_grid(dt, device, dtype)
435
+ xx, yy = xx_t.cpu().numpy(), yy_t.cpu().numpy()
436
+
437
+ T = len(self.t[half]) - 1
438
+ pc_all = np.empty((T, dt, dt), dtype=np.float32)
439
+
440
+ home_players = list(self.locs_home[half].values())
441
+ away_players = list(self.locs_away[half].values())
442
+
443
+ for start in tqdm(range(0, T, batch_size)):
444
+ end = min(start + batch_size, T)
445
+ frames = np.arange(start, end)
446
+
447
+ # deltas t
448
+ dt_secs = torch.tensor(
449
+ self.t[half][frames + 1] - self.t[half][frames],
450
+ device=device,
451
+ dtype=dtype,
452
+ )
453
+
454
+ ball_pos = torch.tensor(
455
+ self.locs_ball[half][frames], device=device, dtype=dtype
456
+ )
457
+
458
+ # stack teams
459
+ pos_t_h, pos_tp1_h = self._stack_team_frames(home_players, frames, device, dtype)
460
+ pos_t_a, pos_tp1_a = self._stack_team_frames(away_players, frames, device, dtype)
461
+
462
+ Zh = self._batch_team_influence_frames_pt(
463
+ pos_t_h, pos_tp1_h, ball_pos, dt_secs, locs_t, dtype
464
+ )
465
+ Za = self._batch_team_influence_frames_pt(
466
+ pos_t_a, pos_tp1_a, ball_pos, dt_secs, locs_t, dtype
467
+ )
468
+
469
+ res_batch = torch.sigmoid(Za - Zh).reshape(-1, dt, dt)
470
+ pc_all[start:end] = res_batch.cpu().numpy().astype(np.float32)
471
+
472
+ if verbose:
473
+ print(f"pt full: frames {start}-{end-1} done")
474
+
475
+ return pc_all, xx, yy
476
+
477
+ def _get_grid(self, dt: int, device: str, dtype: torch.dtype) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
478
+ """Helper to create a grid of locations for torch-based pitch control."""
479
+ x = torch.linspace(0, 105, dt, device=device, dtype=dtype)
480
+ y = torch.linspace(0, 68, dt, device=device, dtype=dtype)
481
+ xx_t, yy_t = torch.meshgrid(x, y, indexing="xy") # (dt,dt)
482
+ locs_t = torch.stack([xx_t, yy_t], dim=-1).reshape(-1, 2) # (N,2)
483
+ return xx_t, yy_t, locs_t
484
+
485
+ def _fit_pt(
486
+ self,
487
+ half: int,
488
+ tp: int,
489
+ dt: int = 200,
490
+ device: str = "cpu",
491
+ verbose: bool = False,
492
+ use_fp16: bool = True,
493
+ ):
494
+ """Torch-accelerated pitch-control calculation.
495
+
496
+ Returns
497
+ -------
498
+ result : np.ndarray shape (dt,dt)
499
+ xx, yy : np.ndarray meshgrids (dt,dt)
500
+ """
501
+ if torch is None:
502
+ raise ImportError("PyTorch is required for backend='torch'.")
503
+
504
+ dtype = torch.float16 if (use_fp16 and device != "cpu") else torch.float32
505
+
506
+ # ---- grid caching ----
507
+ key = (dt, device, dtype)
508
+ if key not in self._grid_cache:
509
+ xx_t, yy_t, locs_t = self._get_grid(dt, device, dtype)
510
+ # Store; keep cache small (max 3 grids)
511
+ if len(self._grid_cache) >= 3:
512
+ self._grid_cache.pop(next(iter(self._grid_cache)))
513
+ self._grid_cache[key] = (xx_t, yy_t, locs_t)
514
+ else:
515
+ xx_t, yy_t, locs_t = self._grid_cache[key]
516
+
517
+ # --- vectorised influence computation ---
518
+ Zh = self._batch_influence_pt(
519
+ self.locs_home[half], locs_t, tp, half, device, dtype
520
+ )
521
+ Za = self._batch_influence_pt(
522
+ self.locs_away[half], locs_t, tp, half, device, dtype
523
+ )
524
+
525
+ res_t = torch.sigmoid(Za - Zh).reshape(dt, dt)
526
+
527
+ # Convert to numpy for downstream plotting
528
+ return res_t.cpu().numpy(), xx_t.cpu().numpy(), yy_t.cpu().numpy()
529
+
530
+ def _fit_np(self, half: int, tp: int, dt: int, verbose: bool = False) -> tuple:
531
+ x = np.linspace(0, 105, dt)
532
+ y = np.linspace(0, 68, dt)
533
+ xx, yy = np.meshgrid(x, y)
534
+
535
+ Zh = np.zeros(dt*dt)
536
+ Za = np.zeros(dt*dt)
537
+
538
+ locations = np.c_[xx.flatten(),yy.flatten()]
539
+
540
+ for k in self.locs_home[half].keys():
541
+ # if len(self.locs_home[half][k]) >= tp:
542
+ Zh += self.influence_np(k, locations, tp, 'h', half, verbose)
543
+ for k in self.locs_away[half].keys():
544
+ # if len(self.locs_away[half][k]) >= tp:
545
+ Za += self.influence_np(k, locations, tp, 'a', half, verbose)
546
+
547
+ Zh = Zh.reshape((dt, dt))
548
+ Za = Za.reshape((dt, dt))
549
+ result = 1 / (1 + np.exp(-Za + Zh))
550
+
551
+ return result, xx, yy
552
+
553
+ def fit(
554
+ self,
555
+ half: int,
556
+ tp: int,
557
+ dt: int = 100,
558
+ backend: str = "np",
559
+ device: str = "cpu",
560
+ verbose: bool = False,
561
+ use_fp16: bool = True,
562
+ ):
563
+ """Selects NumPy or PyTorch backend depending on `backend`."""
564
+ match backend:
565
+ case "np" | "numpy":
566
+ return self._fit_np(half, tp, dt, verbose)
567
+ case "torch" | "pt":
568
+ return self._fit_pt(half, tp, dt, device=device, verbose=verbose, use_fp16=use_fp16)
569
+ case _:
570
+ raise ValueError(f"Unknown backend '{backend}'. Use 'np' or 'torch'.")
571
+
572
+ def fit_full(
573
+ self,
574
+ half: int,
575
+ dt: int = 100,
576
+ backend: str = "np",
577
+ device: str = "cpu",
578
+ batch_size: int = 30*60,
579
+ use_fp16: bool = True,
580
+ verbose: bool = False,
581
+ ):
582
+ """Compute pitch-control map for *каждый* кадр тайма.
583
+
584
+ Returns
585
+ -------
586
+ maps : np.ndarray, shape (T, dt, dt)
587
+ Pitch-control probability for home team at every frame.
588
+ xx, yy : np.ndarray, shape (dt, dt)
589
+ Coordinate grids (общие для всех кадров).
590
+ """
591
+
592
+ T = len(self.t[half]) - 1 # мы используем t и t+1, поэтому последний кадр T-1
593
+
594
+ match backend:
595
+ case "np" | "numpy":
596
+ pc_all = np.empty((T, dt, dt), dtype=np.float32)
597
+ for tp in tqdm(range(T)):
598
+ pc_map, xx, yy = self._fit_np(half, tp, dt, verbose=False)
599
+ pc_all[tp] = pc_map.astype(np.float32)
600
+ if verbose and tp % 500 == 0:
601
+ print(f"np full-match: done {tp}/{T}")
602
+ return pc_all, xx, yy
603
+
604
+ case "torch" | "pt":
605
+ return self._fit_full_pt(
606
+ half, dt, device, batch_size, use_fp16, verbose
607
+ )
608
+ case _:
609
+ raise ValueError("backend must be 'np' or 'pt'")
610
+
611
+ def draw_pitch_control(
612
+ self,
613
+ half: int,
614
+ tp: int,
615
+ pitch_control: tuple = None,
616
+ save: bool = False,
617
+ dt: int = 200,
618
+ filename: str = 'pitch_control'
619
+ ):
620
+ if pitch_control is None:
621
+ pitch_control, xx, yy = self.fit(half, tp, dt)
622
+ else:
623
+ pitch_control, xx, yy = pitch_control
624
+
625
+ fig, ax = plt.subplots(figsize=(10.5, 6.8))
626
+ # mpl.field(fieldcolor="white", linecolor="black", alpha=1, show=False, ax=ax)
627
+ mpl.field("white", show=False, ax=ax)
628
+
629
+ plt.contourf(xx, yy, pitch_control)
630
+
631
+ for k in self.locs_home[half].keys():
632
+ # if len(self.locs_home[half][k]) >= tp:
633
+ if np.isfinite(self.locs_home[half][k][tp, :]).all():
634
+ plt.scatter(
635
+ self.locs_home[half][k][tp, 0],
636
+ self.locs_home[half][k][tp, 1],
637
+ color='darkgrey'
638
+ )
639
+
640
+ for k in self.locs_away[half].keys():
641
+ # if len(self.locs_away[half][k]) >= tp:
642
+ if np.isfinite(self.locs_away[half][k][tp, :]).all():
643
+ plt.scatter(
644
+ self.locs_away[half][k][tp, 0],
645
+ self.locs_away[half][k][tp, 1], color='black'
646
+ )
647
+
648
+ plt.scatter(
649
+ self.locs_ball[half][tp, 0],
650
+ self.locs_ball[half][tp, 1],
651
+ color='red'
652
+ )
653
+
654
+ if save:
655
+ plt.savefig(f'{filename}.png', dpi=300)
656
+ else:
657
+ plt.show()
658
+
659
+ def animate_pitch_control(
660
+ self,
661
+ half: int,
662
+ tp: int,
663
+ filename: str = "pitch_control_animation",
664
+ dt: int = 200,
665
+ frames: int = 30,
666
+ interval: int = 1000
667
+ ):
668
+ """
669
+ ffmpeg should be installed on your machine.
670
+ """
671
+ fig, ax = plt.subplots(figsize=(10.5, 6.8))
672
+
673
+ def animate(i):
674
+ fr = tp + i
675
+ pitch_control, xx, yy = self.fit(half, fr, dt)
676
+
677
+ mpl.field("white", show=False, ax=ax)
678
+ ax.axis('off')
679
+
680
+ plt.contourf(xx, yy, pitch_control)
681
+
682
+ for k in self.locs_home[half].keys():
683
+ # if len(self.locs_home[half][k]) >= fr:
684
+ if np.isfinite(self.locs_home[half][k][fr, :]).all():
685
+ plt.scatter(
686
+ self.locs_home[half][k][fr, 0],
687
+ self.locs_home[half][k][fr, 1],
688
+ color='darkgrey'
689
+ )
690
+ for k in self.locs_away[half].keys():
691
+ # if len(self.locs_away[half][k]) >= fr:
692
+ if np.isfinite(self.locs_away[half][k][fr, :]).all():
693
+ plt.scatter(
694
+ self.locs_away[half][k][fr, 0],
695
+ self.locs_away[half][k][fr, 1],
696
+ color='black'
697
+ )
698
+
699
+ plt.scatter(
700
+ self.locs_ball[half][fr, 0],
701
+ self.locs_ball[half][fr, 1],
702
+ color='red'
703
+ )
704
+
705
+ return ax
706
+
707
+ x = np.linspace(0, 105, dt)
708
+ y = np.linspace(0, 68, dt)
709
+ xx, yy = np.meshgrid(x, y)
710
+
711
+ ani = animation.FuncAnimation(
712
+ fig=fig,
713
+ func=animate,
714
+ frames=min(frames, len(self.locs_ball[half]) - tp),
715
+ interval=interval,
716
+ blit=False
717
+ )
718
+
719
+ ani.save(f'{filename}.mp4', writer='ffmpeg')
@@ -13,20 +13,27 @@ def process_list(x: pd.Series):
13
13
  else:
14
14
  return lst
15
15
 
16
+ def take_last(x: pd.Series):
17
+ lst = x.dropna().tolist()
18
+ if lst:
19
+ return lst[-1]
20
+ return np.nan
21
+
16
22
 
17
23
  def gluing(df: pd.DataFrame) -> pd.DataFrame:
18
24
  cols = ['player_id', 'half', 'second', 'pos_x', 'pos_y']
19
25
 
20
- df_gb = df.groupby(cols).agg(process_list).reset_index()
21
- df_gb['possession_number'] = df_gb['possession_number'].apply(
22
- lambda x: max(x) if isinstance(x, list) else x
23
- )
24
- df_gb['pos_dest_x'] = df_gb['pos_dest_x'].apply(
25
- lambda x: x[0] if isinstance(x, list) else x
26
- )
27
- df_gb['pos_dest_y'] = df_gb['pos_dest_y'].apply(
28
- lambda x: x[0] if isinstance(x, list) else x
29
- )
26
+ agg_rules = {}
27
+
28
+ for col_name in df.columns:
29
+ if col_name not in cols:
30
+ if col_name in ['action_name', 'action_id']:
31
+ agg_rules[col_name] = process_list
32
+ else:
33
+ agg_rules[col_name] = take_last
34
+
35
+ df_gb = df.groupby(cols).agg(agg_rules).reset_index()
36
+
30
37
  df_gb['pos_dest_nan'] = (df_gb['pos_dest_x'].isna() & df_gb['pos_dest_y'].isna()).astype(int)
31
38
  df_gb = df_gb.sort_values(by=['half', 'second', 'possession_number', 'pos_dest_nan']).reset_index(drop=True)
32
39
  return df_gb
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: rustat-python-api
3
- Version: 0.5.8
3
+ Version: 0.6.1
4
4
  Summary: A Python wrapper for RuStat API
5
5
  Home-page: https://github.com/dailydaniel/rustat-python-api
6
6
  Author: Daniel Zholkovsky
@@ -4,3 +4,4 @@ tqdm==4.66.5
4
4
  scipy==1.14.1
5
5
  matplotlib
6
6
  matplotsoccer
7
+ torch
@@ -2,7 +2,7 @@ from setuptools import setup, find_packages
2
2
 
3
3
  setup(
4
4
  name='rustat-python-api',
5
- version='0.5.8',
5
+ version='0.6.1',
6
6
  description='A Python wrapper for RuStat API',
7
7
  long_description=open('README.md').read(),
8
8
  long_description_content_type='text/markdown',
@@ -17,7 +17,8 @@ setup(
17
17
  'tqdm==4.66.5',
18
18
  'scipy==1.14.1',
19
19
  'matplotlib',
20
- 'matplotsoccer'
20
+ 'matplotsoccer',
21
+ 'torch'
21
22
  ],
22
23
  classifiers=[
23
24
  'Programming Language :: Python :: 3',
@@ -1,330 +0,0 @@
1
- import pandas as pd
2
- import numpy as np
3
- from scipy.stats import multivariate_normal as mvn
4
- import matplotlib.pyplot as plt
5
- import matplotlib.animation as animation
6
- import matplotsoccer as mpl
7
-
8
-
9
- class PitchControl:
10
- def __init__(self, tracking: pd.DataFrame, events: pd.DataFrame, ball_data: pd.DataFrame = None):
11
- self.team_ids = tracking['team_id'].unique()
12
- sides = tracking.groupby('team_id')['side_1h'].unique()
13
- side_by_team = dict(zip(self.team_ids, sides[self.team_ids].apply(lambda x: x[0])))
14
- self.side_by_half = {
15
- 1: side_by_team,
16
- 2:
17
- {
18
- team: 'left' if side == 'right' else 'right'
19
- for team, side in side_by_team.items()
20
- }
21
- }
22
-
23
- self.locs_home, self.locs_away, self.locs_ball, self.t = self.get_locs(
24
- tracking,
25
- events,
26
- ball_data
27
- )
28
-
29
- def get_locs(self, tracking: pd.DataFrame, events: pd.DataFrame, ball_data: pd.DataFrame | None) -> tuple:
30
- events = events[[
31
- 'possession_number', 'team_id', 'possession_team_id',
32
- 'half', 'second', 'pos_x', 'pos_y'
33
- ]]
34
-
35
- events.loc[:, 'pos_x'] = events.apply(
36
- lambda x: self.swap_coords(x, 'x'), axis=1
37
- )
38
- events.loc[:, 'pos_y'] = events.apply(
39
- lambda x: self.swap_coords(x, 'y'), axis=1
40
- )
41
-
42
- if ball_data is None:
43
- ball_data = self.interpolate_ball_data(
44
- events[['half', 'second', 'pos_x', 'pos_y']],
45
- tracking
46
- )
47
-
48
- locs_home = {
49
- half: {
50
- player_id: self.get_player_data(player_id, half, tracking)
51
- for player_id in tracking[tracking['side_1h'] == 'left']['player_id'].unique()
52
- }
53
- for half in tracking['half'].unique()
54
- }
55
-
56
- locs_away = {
57
- half: {
58
- player_id: self.get_player_data(player_id, half, tracking)
59
- for player_id in tracking[tracking['side_1h'] == 'right']['player_id'].unique()
60
- }
61
- for half in tracking['half'].unique()
62
- }
63
-
64
- locs_ball = {
65
- half: ball_data[ball_data['half'] == half][['pos_x', 'pos_y']].values
66
- for half in tracking['half'].unique()
67
- }
68
-
69
- t = {
70
- half: ball_data[ball_data['half'] == half]['second'].values
71
- for half in tracking['half'].unique()
72
- }
73
-
74
- return locs_home, locs_away, locs_ball, t
75
-
76
-
77
- def swap_coords(self, row, how: str = 'x'):
78
- half = row['half']
79
- team_id = row['team_id']
80
- possession_team_id = row['possession_team_id']
81
- x = row['pos_x']
82
- y = row['pos_y']
83
-
84
- if isinstance(possession_team_id, list):
85
- current_side = 'left' if team_id in possession_team_id else 'right'
86
- real_side = self.side_by_half[half][str(int(team_id))]
87
- else:
88
- current_side = 'left' if team_id == possession_team_id else 'right'
89
- real_side = self.side_by_half[half][str(int(team_id))]
90
-
91
- if current_side != real_side:
92
- if how == 'x':
93
- x = 105 - x
94
- else:
95
- y = 68 - y
96
-
97
- return x if how == 'x' else y
98
-
99
- @staticmethod
100
- def interpolate_ball_data(
101
- ball_data: pd.DataFrame,
102
- player_data: pd.DataFrame
103
- ) -> pd.DataFrame:
104
- ball_data = ball_data.drop_duplicates(subset=['second', 'half'])
105
-
106
- interpolated_data = []
107
- for half in ball_data['half'].unique():
108
- ball_half = ball_data[ball_data['half'] == half]
109
- player_half = player_data[player_data['half'] == half]
110
-
111
- player_times = player_half['second'].unique()
112
-
113
- ball_half = ball_half.sort_values(by='second')
114
- interpolated_half = pd.DataFrame({'second': player_times})
115
- interpolated_half['pos_x'] = np.interp(
116
- interpolated_half['second'], ball_half['second'], ball_half['pos_x']
117
- )
118
- interpolated_half['pos_y'] = np.interp(
119
- interpolated_half['second'], ball_half['second'], ball_half['pos_y']
120
- )
121
- interpolated_half['half'] = half
122
- interpolated_data.append(interpolated_half)
123
-
124
- interpolated_ball_data = pd.concat(interpolated_data, ignore_index=True)
125
- return interpolated_ball_data
126
-
127
- @staticmethod
128
- def get_player_data(player_id, half, tracking):
129
- timestamps = tracking[tracking['half'] == half]['second'].unique()
130
- player_data = tracking[
131
- (tracking['player_id'] == player_id)
132
- & (tracking['half'] == half)
133
- ][['second', 'pos_x', 'pos_y']]
134
-
135
- player_data_full = pd.DataFrame({'second': timestamps})
136
- player_data_full = player_data_full.merge(player_data, on='second', how='left')
137
-
138
- return player_data_full[['pos_x', 'pos_y']].values
139
-
140
- # return tracking[
141
- # (tracking['player_id'] == player_id)
142
- # & (tracking['half'] == half)
143
- # ][['pos_x', 'pos_y']].values
144
-
145
- def influence_function(
146
- self,
147
- player_index: str,
148
- location: np.ndarray,
149
- time_index: int,
150
- home_or_away: str,
151
- half: int,
152
- verbose: bool = False
153
- ):
154
- if home_or_away == 'h':
155
- data = self.locs_home[half].copy()
156
- elif home_or_away == 'a':
157
- data = self.locs_away[half].copy()
158
- else:
159
- raise ValueError("Enter either 'h' or 'a'.")
160
-
161
- locs_ball = self.locs_ball[half].copy()
162
- t = self.t[half].copy()
163
-
164
- if (
165
- np.all(np.isfinite(data[player_index][[time_index, time_index + 1], :]))
166
- & np.all(np.isfinite(locs_ball[time_index, :]))
167
- ):
168
- jitter = 1e-10 ## to prevent identically zero covariance matrices when velocity is zero
169
- ## compute velocity by fwd difference
170
- s = (
171
- np.linalg.norm(
172
- data[player_index][time_index + 1,:]
173
- - data[player_index][time_index,:] + jitter
174
- )
175
- / (t[time_index + 1] - t[time_index])
176
- )
177
- ## velocities in x,y directions
178
- sxy = (
179
- (data[player_index][time_index + 1, :] - data[player_index][time_index, :] + jitter)
180
- / (t[time_index + 1] - t[time_index])
181
- )
182
- ## angle between velocity vector & x-axis
183
- theta = np.arccos(sxy[0] / np.linalg.norm(sxy))
184
- ## rotation matrix
185
- R = np.array([[np.cos(theta), -np.sin(theta)],[np.sin(theta), np.cos(theta)]])
186
- mu = data[player_index][time_index, :] + sxy * 0.5
187
- Srat = (s / 13) ** 2
188
- Ri = np.linalg.norm(locs_ball[time_index, :] - data[player_index][time_index, :])
189
- ## don't think this function is specified in the paper but looks close enough to fig 9
190
- Ri = np.minimum(4 + Ri ** 3/ (18 ** 3 / 6), 10)
191
- S = np.array([[(1 + Srat) * Ri / 2, 0], [0, (1 - Srat) * Ri / 2]])
192
- Sigma = np.matmul(R, S)
193
- Sigma = np.matmul(Sigma, S)
194
- Sigma = np.matmul(Sigma, np.linalg.inv(R)) ## this is not efficient, forgive me.
195
- out = mvn.pdf(location, mu, Sigma) / mvn.pdf(data[player_index][time_index, :], mu, Sigma)
196
- else:
197
- if verbose:
198
- print("Data is not finite.")
199
- out = np.zeros(location.shape[0])
200
- return out
201
-
202
- def fit(self, half: int, tp: int, dt: int, verbose: bool = False) -> tuple:
203
- x = np.linspace(0, 105, dt)
204
- y = np.linspace(0, 68, dt)
205
- xx, yy = np.meshgrid(x, y)
206
-
207
- Zh = np.zeros(dt*dt)
208
- Za = np.zeros(dt*dt)
209
-
210
- locations = np.c_[xx.flatten(),yy.flatten()]
211
-
212
- for k in self.locs_home[half].keys():
213
- # if len(self.locs_home[half][k]) >= tp:
214
- Zh += self.influence_function(k, locations, tp, 'h', half, verbose)
215
- for k in self.locs_away[half].keys():
216
- # if len(self.locs_away[half][k]) >= tp:
217
- Za += self.influence_function(k, locations, tp, 'a', half, verbose)
218
-
219
- Zh = Zh.reshape((dt, dt))
220
- Za = Za.reshape((dt, dt))
221
- result = 1 / (1 + np.exp(-Za + Zh))
222
-
223
- return result, xx, yy
224
-
225
- def draw_pitch_control(
226
- self,
227
- half: int,
228
- tp: int,
229
- pitch_control: tuple = None,
230
- save: bool = False,
231
- dt: int = 200,
232
- filename: str = 'pitch_control'
233
- ):
234
- if pitch_control is None:
235
- pitch_control, xx, yy = self.fit(half, tp, dt)
236
-
237
- fig, ax = plt.subplots(figsize=(10.5, 6.8))
238
- mpl.field("white", show=False, ax=ax)
239
-
240
- plt.contourf(xx, yy, pitch_control)
241
-
242
- for k in self.locs_home[half].keys():
243
- # if len(self.locs_home[half][k]) >= tp:
244
- if np.isfinite(self.locs_home[half][k][tp, :]).all():
245
- plt.scatter(
246
- self.locs_home[half][k][tp, 0],
247
- self.locs_home[half][k][tp, 1],
248
- color='darkgrey'
249
- )
250
-
251
- for k in self.locs_away[half].keys():
252
- # if len(self.locs_away[half][k]) >= tp:
253
- if np.isfinite(self.locs_away[half][k][tp, :]).all():
254
- plt.scatter(
255
- self.locs_away[half][k][tp, 0],
256
- self.locs_away[half][k][tp, 1], color='black'
257
- )
258
-
259
- plt.scatter(
260
- self.locs_ball[half][tp, 0],
261
- self.locs_ball[half][tp, 1],
262
- color='red'
263
- )
264
-
265
- if save:
266
- plt.savefig(f'{filename}.png', dpi=300)
267
- else:
268
- plt.show()
269
-
270
- def animate_pitch_control(
271
- self,
272
- half: int,
273
- tp: int,
274
- filename: str = "pitch_control_animation",
275
- dt: int = 200,
276
- frames: int = 30,
277
- interval: int = 1000
278
- ):
279
- """
280
- ffmpeg should be installed on your machine.
281
- """
282
- fig, ax = plt.subplots(figsize=(10.5, 6.8))
283
-
284
- def animate(i):
285
- fr = tp + i
286
- pitch_control, xx, yy = self.fit(half, fr, dt)
287
-
288
- mpl.field("white", show=False, ax=ax)
289
- ax.axis('off')
290
-
291
- plt.contourf(xx, yy, pitch_control)
292
-
293
- for k in self.locs_home[half].keys():
294
- # if len(self.locs_home[half][k]) >= fr:
295
- if np.isfinite(self.locs_home[half][k][fr, :]).all():
296
- plt.scatter(
297
- self.locs_home[half][k][fr, 0],
298
- self.locs_home[half][k][fr, 1],
299
- color='darkgrey'
300
- )
301
- for k in self.locs_away[half].keys():
302
- # if len(self.locs_away[half][k]) >= fr:
303
- if np.isfinite(self.locs_away[half][k][fr, :]).all():
304
- plt.scatter(
305
- self.locs_away[half][k][fr, 0],
306
- self.locs_away[half][k][fr, 1],
307
- color='black'
308
- )
309
-
310
- plt.scatter(
311
- self.locs_ball[half][fr, 0],
312
- self.locs_ball[half][fr, 1],
313
- color='red'
314
- )
315
-
316
- return ax
317
-
318
- x = np.linspace(0, 105, dt)
319
- y = np.linspace(0, 68, dt)
320
- xx, yy = np.meshgrid(x, y)
321
-
322
- ani = animation.FuncAnimation(
323
- fig=fig,
324
- func=animate,
325
- frames=min(frames, len(self.locs_ball[half]) - tp),
326
- interval=interval,
327
- blit=False
328
- )
329
-
330
- ani.save(f'{filename}.mp4', writer='ffmpeg')