├── .flake8 ├── .github └── workflows │ ├── publish-docs.yml │ ├── publish-package.yml │ └── test-windows.yml ├── .gitignore ├── .pre-commit-config.yaml ├── LICENSE ├── README.md ├── codeball ├── __init__.py ├── codeball_frames.py ├── game_dataset.py ├── patterns │ ├── __init__.py │ ├── passes_into_the_box.py │ ├── patterns.py │ ├── patterns_config.json │ ├── set_pieces.py │ └── team_stretched.py ├── tactical.py ├── tests │ ├── __init__.py │ ├── files │ │ ├── code_xml.xml │ │ ├── events.json │ │ ├── game_dataset.obj │ │ ├── metadata.xml │ │ └── tracking.txt │ ├── test_models.py │ ├── test_patterns.py │ └── test_visualizations.py ├── utils │ ├── __init__.py │ └── json_encoders.py └── visualizations.py ├── docs ├── CNAME ├── changelog.md ├── codeball-frames.md ├── examples │ ├── example-pattern.md │ ├── game_dataset.ipynb │ ├── output.patt │ └── run_patterns.ipynb ├── format-for-play.md ├── game-dataset.md ├── how-to-import-to-play.md ├── index.md ├── media │ ├── passes_into_the_box.gif │ ├── set_pieces.gif │ ├── sprint.gif │ └── team_stretched.gif ├── metrica-play-api.md ├── patterns.md ├── tactical.md └── visualizations.md ├── mkdocs.yml ├── pyproject.toml ├── requirements.txt └── setup.py /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = E203, E266, E501, W503, F403, F401 3 | max-line-length = 79 4 | max-complexity = 18 5 | select = B,C,E,F,W,T4,B9 -------------------------------------------------------------------------------- /.github/workflows/publish-docs.yml: -------------------------------------------------------------------------------- 1 | name: Publish documentation 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | deploy: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v2 12 | - name: Set up Python 13 | uses: actions/setup-python@v2 14 | with: 15 | python-version: '3.8' 16 | - name: Install dependencies 17 | run: | 18 | python -m pip install --upgrade pip 19 | pip install mkdocs-material mkdocs-jupyter nbconvert==5.6.1 20 | - name: Publish 21 | run: mkdocs gh-deploy --force -------------------------------------------------------------------------------- /.github/workflows/publish-package.yml: -------------------------------------------------------------------------------- 1 | name: Upload Python Package 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | deploy: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v2 12 | - name: Set up Python 13 | uses: actions/setup-python@v2 14 | with: 15 | python-version: '3.8' 16 | - name: Install dependencies 17 | run: | 18 | python -m pip install --upgrade pip 19 | pip install setuptools wheel twine 20 | - name: Build and publish 21 | env: 22 | TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} 23 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 24 | run: | 25 | python setup.py sdist bdist_wheel 26 | twine upload dist/* -------------------------------------------------------------------------------- /.github/workflows/test-windows.yml: -------------------------------------------------------------------------------- 1 | name: Python Package Windows 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | build: 13 | 14 | runs-on: windows-latest 15 | strategy: 16 | matrix: 17 | python-version: [3.7, 3.8] 18 | 19 | steps: 20 | - uses: actions/checkout@v2 21 | - name: Set up Python ${{ matrix.python-version }} 22 | uses: actions/setup-python@v1 23 | with: 24 | python-version: ${{ matrix.python-version }} 25 | - name: Install dependencies 26 | run: | 27 | python -m pip install --upgrade pip 28 | pip install black flake8 pytest 29 | pip install -e . 30 | - name: Lint with flake8 31 | run: | 32 | flake8 . 33 | - name: Code formatting 34 | run: | 35 | pip install black 36 | black . 37 | - name: Test with pytest 38 | run: | 39 | pytest -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | env 6 | dist 7 | *.egg-info 8 | build 9 | pip-wheel-metadata 10 | site -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/ambv/black 3 | rev: 20.8b1 4 | hooks: 5 | - id: black 6 | language_version: python3.8 7 | - repo: https://gitlab.com/pycqa/flake8 8 | rev: 3.8.3 9 | hooks: 10 | - id: flake8 -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Metrica Sports 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # codeball: data driven tactical and video analysis of soccer games 2 | 3 | [![PyPI Latest Release](https://img.shields.io/pypi/v/codeball.svg)](https://pypi.org/project/codeball/) 4 | [![Downloads](https://pepy.tech/badge/codeball)](https://pepy.tech/project/codeball) 5 | ![](https://img.shields.io/github/license/metrica-sports/codeball) 6 | ![](https://img.shields.io/pypi/pyversions/codeball) 7 | [![Powered by Metrica Sports](https://img.shields.io/badge/Powered%20by-Metrica%20Sports-green)](https://metrica-sports.com/) 8 | -------- 9 | 10 | ## Why codeball? 11 | 12 | While there are several pieces of code / repositories around that provide different tools and bits of codes to do tactical analysis of individual games, there is no centralized place in which they live. Moreover, most of the analysis done is usually not linked or easy to link with the actual footage of the match. Codeball's objective is to change that by: 13 | 14 | 1. Building a central repository for different types of data driven tactical analysis methods / tools. 15 | 2. Making it easy to link those analyses with a video of the game in different formats. 16 | 17 | ## What can you do with it 18 | 19 | The main types of work / development you can do with codeball are: 20 | 21 | #### Work with tracking and event data 22 | 23 | - Codeball creates subclasses of *Pandas DataFrames* for events and tracking data; and provides you with handy methods to work with the data. 24 | - Work with or create your own tactical models like *Zones* so that you can for example do `game_dataset.events.into(Zones.OPPONENT_BOX)` and it will return a DataFrame only with the events into the opponents box. You can also chain methods, like `game_dataset.events.type("PASS").into(Zones.OPPONENT_BOX)` and will return only passes into the box. Or for example do `game_dataset.tracking.team('FIFATMA').players('field').dimension('x')` to get the x coordinate of the field players (no goalkeeper data) for team with id FIFATMA. 25 | - [Not yet implemented] Easily access tactical tools or methods like computing passes networks, pitch control,EPV models, etc 26 | 27 | #### Create Patterns to analyze the game 28 | 29 | - Analyze games based on Patterns. A Pattern is a unit of analysis that looks for moments in the game in which a certain thing happens. That certain thing is defined inside the Pattern, but codeball provides tools to easily create them, configure them and export them in different formats for different platforms. 30 | - You can create your own patterns, or also use the ones provided with the package and configure them to your liking. 31 | 32 | #### Add annotations to the events for Metrica Play 33 | 34 | - Codeball incorporates all the annotations models and API information needed to import events with annotations into Metrica Play. 35 | - You can add directly from the code any visualization available in Metrica Play (spotlights, rings, future trail, areas, drawings, text, etc) to any event. 36 | 37 | ## Example 38 | 39 | You can use any of the above functionality independently. However they are most powerful when combined. As an example, the below code defines a pattern that will look for all passes into the opponent's box. Moreover to be imported into Metrica Play, it will add an arrow and a 2s pause in the video at the moment of the pass, and will add an arrow to the 2D field indicating start and end position of the pass. 40 | 41 | ```python 42 | class PassesIntoTheBox(Pattern): 43 | def __init__( 44 | self, 45 | game_dataset: GameDataset, 46 | name: str, 47 | code: str, 48 | in_time: int = 0, 49 | out_time: int = 0, 50 | parameters: dict = None, 51 | ): 52 | super().__init__( 53 | name, code, in_time, out_time, parameters, game_dataset 54 | ) 55 | 56 | def run(self) -> List[PatternEvent]: 57 | 58 | passes_into_the_box = ( 59 | self.game_dataset.events.type("PASS") 60 | .into(Zones.OPPONENT_BOX) 61 | .result("COMPLETE") 62 | ) 63 | 64 | return [ 65 | self.build_pattern_event(event_row) 66 | for i, event_row in passes_into_the_box.iterrows() 67 | ] 68 | 69 | def build_pattern_event(self, event_row) -> PatternEvent: 70 | pattern_event = self.from_event(event_row) 71 | pattern_event.add_arrow(event_row) 72 | pattern_event.add_pause(pause_time=2000) 73 | 74 | return pattern_event 75 | ``` 76 | 77 | The above code produces this output when imported into Metrica Play: 78 | 79 |

80 | 81 |

82 | 83 | ## Supported Data Providers 84 | 85 | This package is very much WIP. At the moment it only works based on Metrica Sports Elite datasets. However, it uses Kloppy to read in the data so that in the near future will support data from any provider. 86 | 87 | ## Trying it out 88 | 89 | There are no open source Elite datasets at the moment that work with this package. However if you are interested in testing it out and/or developing your own patterns and/or test them in Metrica Play reach out to bruno@metrica-sports.com or [@brunodagnino](https://twitter.com/brunodagnino) on Twitter. 90 | 91 | ## Install it 92 | 93 | Installers for the latest released version are available at the [Python package index](https://pypi.org/project/codeball). 94 | 95 | ```sh 96 | pip install codeball 97 | ``` 98 | 99 | ## Contribute 100 | 101 | While created and maintained by Metrica Sports, it's distributed under an MIT license and it welcomes contributions from members of the community, clubs and other companies. You can find the repository on [Github](https://github.com/metrica-sports/codeball). Also, if you have ideas for patterns we should implement, or methods we should include (e.g. pitch control, EPV, similarity search, etc), let us know! You can create an issue on the repo, or reach out to bruno@metrica-sports.com or [@brunodagnino](https://twitter.com/brunodagnino) on Twitter. 102 | 103 | ## Documentation 104 | 105 | Check the [documentation](https://codeball.metrica-sports.com) for a more detailed explanation of this package. 106 | 107 | ## Tentative TODO 108 | 109 | This is a very incomplete list of the things we have in mind, and it will probably change as we get input from the community / users. However it gives you a rough idea of the direction in which we want to go with this project! 110 | 111 | * more Zones (half spaces, thirds, 14, etc) - [done] 112 | * crete types for players, events, etc to filter the data. 113 | * more ways to filter event and tracking data (e.g pass length) 114 | * more patterns (currently 4 in the making) 115 | * pitch control from `game_dataset.pitch_control([frame/s])`, same with EPV. 116 | * easily query xG, g+, xT, etc for events 117 | * corner strategy classifier. 118 | * support for other providers, likely StatsBomb next. 119 | * export events in xml format 120 | * methods to easily sync tracking and event from different providers. 121 | * any suggestions? -------------------------------------------------------------------------------- /codeball/__init__.py: -------------------------------------------------------------------------------- 1 | from .game_dataset import * 2 | from .visualizations import * 3 | from .tactical import * 4 | from .codeball_frames import * 5 | from .patterns import * 6 | -------------------------------------------------------------------------------- /codeball/codeball_frames.py: -------------------------------------------------------------------------------- 1 | from pandas import DataFrame, Series 2 | from codeball.tactical import Zones, Area 3 | 4 | 5 | class BaseFrame(DataFrame): 6 | """This is a base dataframe""" 7 | 8 | _internal_names = DataFrame._internal_names + ["records"] 9 | _internal_names_set = set(_internal_names) 10 | 11 | _metadata = ["metadata", "data_type"] 12 | 13 | @property 14 | def _constructor(self): 15 | return BaseFrame 16 | 17 | def get_team_by_id(self, team_id): 18 | return next( 19 | team for team in self.metadata.teams if team.team_id == team_id 20 | ) 21 | 22 | def get_period_by_id(self, period_id): 23 | return next( 24 | period 25 | for period in self.metadata.periods 26 | if period.id == period_id 27 | ) 28 | 29 | def get_other_team_id(self, team_id): 30 | if self.metadata.teams[0].team_id == team_id: 31 | return self.metadata.teams[1].team_id 32 | 33 | if self.metadata.teams[1].team_id == team_id: 34 | return self.metadata.teams[0].team_id 35 | 36 | 37 | class TrackingFrame(BaseFrame): 38 | @property 39 | def _constructor(self): 40 | return TrackingFrame 41 | 42 | def team(self, team_id): 43 | team = self.get_team_by_id(team_id) 44 | players_ids = [player.player_id for player in team.players] 45 | 46 | column_names = [] 47 | for player_id in players_ids: 48 | if f"{player_id}_x" in self.columns: 49 | column_names.extend([f"{player_id}_x", f"{player_id}_y"]) 50 | 51 | return self[column_names] 52 | 53 | def dimension(self, dimension): 54 | return self.filter(regex=f"_{dimension}") 55 | 56 | def players(self, group=None): 57 | 58 | column_names = [] 59 | for team in self.metadata.teams: 60 | for player in team.players: 61 | if f"{player.player_id}_x" in self.columns: 62 | column_names.extend( 63 | [f"{player.player_id}_x", f"{player.player_id}_y"] 64 | ) 65 | 66 | if group == "field": 67 | for team in self.metadata.teams: 68 | for player in team.players: 69 | if player.position.position_id == 0: 70 | if f"{player.player_id}_x" in column_names: 71 | column_names.remove(f"{player.player_id}_x") 72 | if f"{player.player_id}_y" in column_names: 73 | column_names.remove(f"{player.player_id}_y") 74 | 75 | return self[column_names] 76 | 77 | def phase(self, defending_team_id=None, attacking_team_id=None): 78 | 79 | if defending_team_id: 80 | attacking_team_id = self.get_other_team_id(defending_team_id) 81 | 82 | if attacking_team_id: 83 | return self["ball_owning_team_id"] == attacking_team_id 84 | 85 | def stretched(self, threshold): 86 | team_span = self.max(axis=1) - self.min(axis=1) 87 | pitch_length = ( 88 | self.metadata.pitch_dimensions.x_dim.max 89 | / self.metadata.pitch_dimensions.x_per_meter 90 | ) 91 | return team_span > (threshold / pitch_length) 92 | 93 | 94 | class EventsFrame(BaseFrame): 95 | 96 | _internal_names = DataFrame._internal_names + ["records"] 97 | _internal_names_set = set(_internal_names) 98 | 99 | _metadata = ["metadata", "data_type"] 100 | 101 | @property 102 | def _constructor(self): 103 | return EventsFrame 104 | 105 | def type(self, type): 106 | return self.loc[self["event_type"] == type] 107 | 108 | def result(self, result): 109 | return self.loc[self["result"] == result] 110 | 111 | @staticmethod 112 | def __validate_areas(args): 113 | areas = [] 114 | for arg in args: 115 | if isinstance(arg, Zones): 116 | if type(arg.areas) == tuple: 117 | for area in arg.areas: 118 | areas.append(area) 119 | else: 120 | areas.append(arg.areas) 121 | elif isinstance(arg, Area): 122 | areas.append(arg) 123 | 124 | return areas 125 | 126 | def starts_inside(self, *args): 127 | 128 | areas = self.__validate_areas(args) 129 | 130 | for i, area in enumerate(areas): 131 | x_indexes = (self["coordinates_x"] > area.points[0][0]) & ( 132 | self["coordinates_x"] < area.points[1][0] 133 | ) 134 | y_indexes = (self["coordinates_y"] > area.points[0][1]) & ( 135 | self["coordinates_y"] < area.points[1][1] 136 | ) 137 | area_indexes = x_indexes & y_indexes 138 | if i == 0: 139 | event_idexes = area_indexes 140 | else: 141 | event_idexes = event_idexes | area_indexes 142 | 143 | return self.loc[event_idexes] 144 | 145 | def starts_outside(self, *args): 146 | 147 | areas = self.__validate_areas(args) 148 | 149 | for i, area in enumerate(areas): 150 | x_indexes = (self["coordinates_x"] < area.points[0][0]) | ( 151 | self["coordinates_x"] > area.points[1][0] 152 | ) 153 | y_indexes = (self["coordinates_y"] < area.points[0][1]) | ( 154 | self["coordinates_y"] > area.points[1][1] 155 | ) 156 | area_indexes = x_indexes | y_indexes 157 | if i == 0: 158 | event_idexes = area_indexes 159 | else: 160 | event_idexes = event_idexes | area_indexes 161 | 162 | return self.loc[event_idexes] 163 | 164 | def ends_inside(self, *args): 165 | 166 | areas = self.__validate_areas(args) 167 | 168 | for i, area in enumerate(areas): 169 | x_indexes = (self["end_coordinates_x"] > area.points[0][0]) & ( 170 | self["end_coordinates_x"] < area.points[1][0] 171 | ) 172 | y_indexes = (self["end_coordinates_y"] > area.points[0][1]) & ( 173 | self["end_coordinates_y"] < area.points[1][1] 174 | ) 175 | area_indexes = x_indexes & y_indexes 176 | if i == 0: 177 | event_idexes = area_indexes 178 | else: 179 | event_idexes = event_idexes | area_indexes 180 | 181 | return self.loc[event_idexes] 182 | 183 | def ends_outside(self, *args): 184 | 185 | areas = self.__validate_areas(args) 186 | 187 | for i, area in enumerate(areas): 188 | x_indexes = (self["end_coordinates_x"] < area.points[0][0]) | ( 189 | self["end_coordinates_x"] > area.points[1][0] 190 | ) 191 | y_indexes = (self["end_coordinates_y"] < area.points[0][1]) | ( 192 | self["end_coordinates_y"] > area.points[1][1] 193 | ) 194 | area_indexes = x_indexes | y_indexes 195 | if i == 0: 196 | event_idexes = area_indexes 197 | else: 198 | event_idexes = event_idexes | area_indexes 199 | 200 | return self.loc[event_idexes] 201 | 202 | def into(self, *args): 203 | return self.starts_outside(*args).ends_inside(*args) 204 | 205 | 206 | class PossessionsFrame(BaseFrame): 207 | @property 208 | def _constructor(self): 209 | return PossessionsFrame 210 | 211 | 212 | class CodesFrame(BaseFrame): 213 | 214 | _internal_names = DataFrame._internal_names + ["records"] 215 | _internal_names_set = set(_internal_names) 216 | 217 | _metadata = ["metadata", "data_type"] 218 | 219 | @property 220 | def _constructor(self): 221 | return CodesFrame 222 | -------------------------------------------------------------------------------- /codeball/game_dataset.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | import json 3 | from dataclasses import dataclass, field 4 | from typing import Optional, List, Dict 5 | from enum import Enum 6 | import pandas as pd 7 | from kloppy.domain import ( 8 | Dataset, 9 | Team, 10 | EventType, 11 | ResultType, 12 | AttackingDirection, 13 | Ground, 14 | Point, 15 | ) 16 | from kloppy import ( 17 | load_epts_tracking_data, 18 | load_metrica_json_event_data, 19 | to_pandas, 20 | load_xml_code_data, 21 | ) 22 | 23 | import codeball.utils as utils 24 | from codeball.tactical import Zones, Possession 25 | from codeball.codeball_frames import ( 26 | EventsFrame, 27 | TrackingFrame, 28 | PossessionsFrame, 29 | CodesFrame, 30 | ) 31 | 32 | 33 | class DataType(Enum): 34 | TRACKING = "tracking" 35 | EVENT = "event" 36 | CODE = "code" 37 | 38 | 39 | class GameDatasetType(Enum): 40 | ONLY_TRACKING = "only_tracking" 41 | ONLY_EVENTS = "only_events" 42 | FULL_SAME_PROVIDER = "full_same_provider" 43 | FULL_MIXED_PROVIDERS = "full_mixed_providers" 44 | 45 | 46 | class GameDataset: 47 | def __init__( 48 | self, 49 | tracking_metadata_file=None, 50 | tracking_data_file=None, 51 | events_metadata_file=None, 52 | events_data_file=None, 53 | codes_files=None, 54 | ): 55 | self.files = { 56 | "tracking_metadata_file": tracking_metadata_file, 57 | "tracking_data_file": tracking_data_file, 58 | "events_metadata_file": events_metadata_file, 59 | "events_data_file": events_data_file, 60 | } 61 | 62 | if tracking_data_file: 63 | tracking_dataset = load_epts_tracking_data( 64 | metadata_filename=tracking_metadata_file, 65 | raw_data_filename=tracking_data_file, 66 | ) 67 | self.tracking = TrackingFrame(to_pandas(tracking_dataset)) 68 | self.tracking.data_type = DataType.TRACKING 69 | self.tracking.metadata = tracking_dataset.metadata 70 | self.tracking.records = tracking_dataset.records 71 | else: 72 | self.tracking = None 73 | 74 | if events_data_file: 75 | events_dataset = load_metrica_json_event_data( 76 | metadata_filename=events_metadata_file, 77 | raw_data_filename=events_data_file, 78 | ) 79 | self.events = EventsFrame(to_pandas(events_dataset)) 80 | self.events.data_type = DataType.EVENT 81 | self.events.metadata = events_dataset.metadata 82 | self.events.records = events_dataset.records 83 | else: 84 | self.events = None 85 | 86 | if codes_files: 87 | 88 | if type(codes_files) is not list: 89 | codes_files = [codes_files] 90 | 91 | self.codes = [] 92 | for codes_file in codes_files: 93 | codes_dataset = load_xml_code_data( 94 | xml_filename=codes_file, 95 | ) 96 | 97 | codes = CodesFrame(to_pandas(codes_dataset)) 98 | codes.data_type = DataType.CODE 99 | codes.metadata = codes_dataset.metadata 100 | codes.records = codes_dataset.records 101 | 102 | self.codes.append(codes) 103 | 104 | else: 105 | self.codes = None 106 | 107 | self._enrich_data() 108 | 109 | @property 110 | def game_dataset_type(self) -> GameDatasetType: 111 | if self.has_tracking_data and self.has_event_data: 112 | # TODO: handle different providers when available in the EPTS dataset 113 | return GameDatasetType.FULL_SAME_PROVIDER 114 | 115 | if self.has_tracking_data: 116 | return GameDatasetType.ONLY_TRACKING 117 | 118 | if self.has_event_data: 119 | return GameDatasetType.ONLY_EVENTS 120 | 121 | @property 122 | def metadata(self): 123 | if self.game_dataset_type == GameDatasetType.ONLY_TRACKING: 124 | return self.tracking.metadata 125 | 126 | if self.game_dataset_type == GameDatasetType.ONLY_EVENTS: 127 | return self.events.metadata 128 | 129 | if self.game_dataset_type == GameDatasetType.FULL_SAME_PROVIDER: 130 | return self.tracking.metadata 131 | 132 | if self.game_dataset_type == GameDatasetType.FULL_MIXED_PROVIDERS: 133 | raise AttributeError( 134 | f"Can't retrieve a common metadata for the game_dataset " 135 | f"because it's of type: {self.game_dataset_type}" 136 | ) 137 | 138 | @property 139 | def has_tracking_data(self): 140 | if self.tracking is not None: 141 | return True 142 | else: 143 | return False 144 | 145 | @property 146 | def has_event_data(self): 147 | if self.events is not None: 148 | return True 149 | else: 150 | return False 151 | 152 | def _enrich_data(self): 153 | if self.has_tracking_data: 154 | self._set_periods_attacking_direction() 155 | 156 | if self.has_event_data: 157 | self._build_possessions() 158 | self._enrich_events() 159 | 160 | if self.has_tracking_data and self.has_event_data: 161 | self._enrich_tracking() 162 | 163 | def _build_possessions(self): 164 | start_event_types = ["RECOVERY", "SET PIECE"] 165 | end_event_types = ["FAULT RECEIVED", "SHOT", "BALL OUT", "BALL LOST"] 166 | 167 | possessions = [] 168 | for event in self.events.records: 169 | if event.raw_event["type"]["name"] in start_event_types: 170 | possession_start = event.timestamp 171 | 172 | if event.raw_event["type"]["name"] in end_event_types: 173 | if ( 174 | hasattr(event, "receive_timestamp") 175 | and event.receive_timestamp 176 | ): 177 | possession_end = event.receive_timestamp 178 | else: 179 | possession_end = event.timestamp 180 | 181 | possessions.append( 182 | [ 183 | event.raw_event["team"]["id"], 184 | possession_start, 185 | possession_end, 186 | ] 187 | ) 188 | 189 | self.possessions = PossessionsFrame( 190 | possessions, columns=["team_id", "start", "end"] 191 | ) 192 | 193 | def _set_periods_attacking_direction(self): 194 | for i, period in enumerate(self.metadata.periods): 195 | 196 | start = period.start_timestamp 197 | end = period.end_timestamp 198 | period_idx = (self.tracking["timestamp"] >= start) & ( 199 | self.tracking["timestamp"] <= end 200 | ) 201 | 202 | home_x_mean = ( 203 | self.tracking.team(self.metadata.teams[0].team_id) 204 | .dimension("x") 205 | .loc[period_idx] 206 | .mean() 207 | .mean() 208 | ) 209 | 210 | away_x_mean = ( 211 | self.tracking.team(self.metadata.teams[1].team_id) 212 | .dimension("x") 213 | .loc[period_idx] 214 | .mean() 215 | .mean() 216 | ) 217 | 218 | # TODO: check if there is a way to set the metadata on the game_dataset and 219 | # get that to set the periods on each data package. 220 | if home_x_mean <= away_x_mean: 221 | self.tracking.metadata.periods[ 222 | i 223 | ].attacking_direction = AttackingDirection.HOME_AWAY 224 | 225 | if self.has_event_data: 226 | self.events.metadata.periods[ 227 | i 228 | ].attacking_direction = AttackingDirection.HOME_AWAY 229 | else: 230 | self.tracking.metadata.periods[ 231 | i 232 | ].attacking_direction = AttackingDirection.AWAY_HOME 233 | 234 | if self.has_event_data: 235 | self.events.metadata.periods[ 236 | i 237 | ].attacking_direction = AttackingDirection.AWAY_HOME 238 | 239 | def _enrich_events(self): 240 | for index, event_row in self.events.iterrows(): 241 | 242 | team = self.events.get_team_by_id(event_row["team_id"]) 243 | period = self.events.get_period_by_id(event_row["period_id"]) 244 | 245 | revert_home = ( 246 | team.ground == Ground.HOME 247 | and period.attacking_direction == AttackingDirection.AWAY_HOME 248 | ) 249 | revert_away = ( 250 | team.ground == Ground.AWAY 251 | and period.attacking_direction == AttackingDirection.HOME_AWAY 252 | ) 253 | 254 | if revert_home or revert_away: 255 | 256 | self.events.at[index, "inverted"] = True 257 | 258 | self.events.at[index, "coordinates_x"] = ( 259 | -event_row["coordinates_x"] + 1 260 | ) 261 | self.events.at[index, "coordinates_y"] = ( 262 | -event_row["coordinates_y"] + 1 263 | ) 264 | 265 | self.events.at[index, "end_coordinates_x"] = ( 266 | -event_row["end_coordinates_x"] + 1 267 | ) 268 | self.events.at[index, "end_coordinates_y"] = ( 269 | -event_row["end_coordinates_y"] + 1 270 | ) 271 | 272 | else: 273 | self.events.at[index, "inverted"] = False 274 | 275 | for index, event_row in self.events.iterrows(): 276 | 277 | # Renrich set pieces with coordiants and end time of next event 278 | if event_row["event_type"] == "GENERIC:SET PIECE": 279 | self.events.at[index, "coordinates_x"] = self.events.at[ 280 | index + 1, "coordinates_x" 281 | ] 282 | self.events.at[index, "coordinates_y"] = self.events.at[ 283 | index + 1, "coordinates_y" 284 | ] 285 | self.events.at[index, "end_coordinates_x"] = self.events.at[ 286 | index + 1, "end_coordinates_x" 287 | ] 288 | self.events.at[index, "end_coordinates_y"] = self.events.at[ 289 | index + 1, "end_coordinates_y" 290 | ] 291 | self.events.at[index, "end_timestamp"] = self.events.at[ 292 | index + 1, "end_timestamp" 293 | ] 294 | 295 | def _enrich_tracking(self): 296 | for i, possession in self.possessions.iterrows(): 297 | indexes = (self.tracking["timestamp"] >= possession["start"]) & ( 298 | self.tracking["timestamp"] <= possession["end"] 299 | ) 300 | self.tracking.loc[indexes, "ball_owning_team_id"] = possession[ 301 | "team_id" 302 | ] 303 | 304 | def find_intervals( 305 | self, boolean_series: pd.Series, minimum_interval: float = 5 306 | ) -> List: 307 | intervals = [] 308 | interval_open = False 309 | for i, f in enumerate(boolean_series): 310 | if f is True and interval_open is False: 311 | interval_open = True 312 | start_interval = i 313 | elif f is False and interval_open is True: 314 | interval_open = False 315 | end_interval = i - 1 316 | if ( 317 | end_interval - start_interval 318 | ) > self.tracking.metadata.frame_rate * minimum_interval: 319 | intervals.append([start_interval, end_interval]) 320 | 321 | return intervals 322 | 323 | def frame_to_misliseconds(self, frame: int) -> float: 324 | return frame * 1000 / self.tracking.metadata.frame_rate 325 | -------------------------------------------------------------------------------- /codeball/patterns/__init__.py: -------------------------------------------------------------------------------- 1 | from .patterns import * 2 | from .team_stretched import * 3 | from .set_pieces import * 4 | from .passes_into_the_box import * 5 | -------------------------------------------------------------------------------- /codeball/patterns/passes_into_the_box.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | from codeball import GameDataset, Zones 3 | from codeball.patterns import PatternEvent, Pattern 4 | 5 | 6 | class PassesIntoTheBox(Pattern): 7 | def __init__( 8 | self, 9 | game_dataset: GameDataset, 10 | name: str, 11 | code: str, 12 | in_time: int = 0, 13 | out_time: int = 0, 14 | parameters: dict = None, 15 | ): 16 | super().__init__( 17 | name, code, in_time, out_time, parameters, game_dataset 18 | ) 19 | 20 | def run(self) -> List[PatternEvent]: 21 | 22 | passes_into_the_box = ( 23 | self.game_dataset.events.type("PASS") 24 | .into(Zones.OPPONENT_BOX) 25 | .result("COMPLETE") 26 | ) 27 | 28 | return [ 29 | self.build_pattern_event(event_row) 30 | for i, event_row in passes_into_the_box.iterrows() 31 | ] 32 | 33 | def build_pattern_event(self, event_row) -> PatternEvent: 34 | pattern_event = self.from_event(event_row) 35 | pattern_event.add_arrow(event_row) 36 | pattern_event.add_pause(pause_time=2000) 37 | 38 | return pattern_event 39 | -------------------------------------------------------------------------------- /codeball/patterns/patterns.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | import json 3 | import os 4 | from dataclasses import dataclass, field 5 | from abc import ABC, abstractmethod 6 | from typing import List, Dict 7 | import numpy as np 8 | from kloppy.domain.models import Event 9 | import codeball.visualizations as vizs 10 | import codeball.utils as utils 11 | from codeball import GameDataset 12 | 13 | 14 | @dataclass 15 | class PatternEvent: 16 | pattern_code: str 17 | start_time: float 18 | event_time: float 19 | end_time: float 20 | coordinates: List[float] = field(default_factory=list) 21 | visualizations: List[vizs.Visualization] = field(default_factory=list) 22 | tags: List[str] = field(default_factory=list) 23 | 24 | def add_spotlights(self, players_codes: List[str]): 25 | self.visualizations = vizs.Spotlight( 26 | start_time=self.start_time, 27 | end_time=self.end_time, 28 | players=players_codes, 29 | ) 30 | 31 | def add_team_length(self, team_code: str): 32 | self.visualizations.append( 33 | vizs.TeamSize( 34 | start_time=self.start_time, 35 | end_time=self.end_time, 36 | team=team_code, 37 | line="length", 38 | ) 39 | ) 40 | 41 | def add_pause(self, start_time: float = None, pause_time: float = 1000): 42 | self.visualizations.append( 43 | vizs.Pause( 44 | start_time=self.event_time, 45 | end_time=self.event_time, 46 | pause_time=pause_time, 47 | ) 48 | ) 49 | 50 | def add_arrow(self, event): 51 | if event["inverted"]: 52 | points = { 53 | "start": { 54 | "x": -self.coordinates[0][0] + 1, 55 | "y": -self.coordinates[0][1] + 1, 56 | }, 57 | "end": { 58 | "x": -self.coordinates[1][0] + 1, 59 | "y": -self.coordinates[1][1] + 1, 60 | }, 61 | } 62 | else: 63 | points = { 64 | "start": { 65 | "x": self.coordinates[0][0], 66 | "y": self.coordinates[0][1], 67 | }, 68 | "end": { 69 | "x": self.coordinates[1][0], 70 | "y": self.coordinates[1][1], 71 | }, 72 | } 73 | 74 | self.visualizations.append( 75 | vizs.Arrow( 76 | start_time=self.event_time, 77 | end_time=self.event_time, 78 | points=points, 79 | options={"pinned": True, "width": 0.3}, 80 | ) 81 | ) 82 | 83 | 84 | @dataclass 85 | class Pattern(ABC): 86 | def __init__( 87 | self, 88 | name: str, 89 | code: str, 90 | in_time: int, 91 | out_time: int, 92 | parameters: dict, 93 | game_dataset: GameDataset, 94 | ): 95 | self.name = name 96 | self.code = code 97 | self.in_time = in_time 98 | self.out_time = out_time 99 | self.parameters = parameters 100 | self.game_dataset = game_dataset 101 | 102 | @abstractmethod 103 | def run(self, game_dataset: GameDataset) -> List[PatternEvent]: 104 | """ Runs the pattern to compute the PatternEvents""" 105 | raise NotImplementedError 106 | 107 | @abstractmethod 108 | def build_pattern_event( 109 | self, game_dataset: GameDataset 110 | ) -> List[PatternEvent]: 111 | """ Builds each pattern event""" 112 | raise NotImplementedError 113 | 114 | def from_event(self, event) -> PatternEvent: 115 | 116 | coordinates = [] 117 | if np.isnan(event["end_coordinates_x"]): 118 | 119 | coordinates = [ 120 | event["coordinates_x"], 121 | event["coordinates_y"], 122 | ] 123 | 124 | end_time = event["timestamp"] 125 | 126 | else: 127 | coordinates = [ 128 | [event["coordinates_x"], event["coordinates_y"]], 129 | [event["end_coordinates_x"], event["end_coordinates_y"]], 130 | ] 131 | 132 | end_time = event["end_timestamp"] 133 | 134 | return PatternEvent( 135 | pattern_code=self.code, 136 | start_time=round(event["timestamp"] - self.in_time) * 1000, 137 | event_time=round(event["timestamp"]) * 1000, 138 | end_time=round(end_time + self.out_time) * 1000, 139 | coordinates=coordinates, 140 | ) 141 | 142 | def from_interval(self, interval: list) -> PatternEvent: 143 | return PatternEvent( 144 | pattern_code=self.code, 145 | start_time=self.game_dataset.frame_to_misliseconds(interval[0]) 146 | - self.in_time * 1000, 147 | event_time=self.game_dataset.frame_to_misliseconds(interval[0]), 148 | end_time=self.game_dataset.frame_to_misliseconds(interval[1]) 149 | + self.out_time * 1000, 150 | ) 151 | 152 | 153 | @dataclass 154 | class PatternsSet: 155 | game_dataset: GameDataset 156 | patterns_config: Dict = field(default_factory=dict) 157 | patterns: List[Pattern] = field(default_factory=list) 158 | 159 | def load_patterns_config(self, config_file: str): 160 | 161 | with open(config_file) as json_file: 162 | self.patterns_config = json.load(json_file) 163 | 164 | def initialize_patterns(self, config_file: str): 165 | 166 | self.load_patterns_config(config_file) 167 | 168 | self.patterns = [] 169 | for pattern_config in self.patterns_config: 170 | if pattern_config["include"]: 171 | pattern = self._initialize_pattern(pattern_config) 172 | self.patterns.append(pattern) 173 | 174 | def _initialize_pattern(self, pattern_config: Dict) -> Pattern: 175 | 176 | import codeball 177 | 178 | pattern_class = getattr(codeball, pattern_config["pattern_class"]) 179 | pattern = pattern_class( 180 | game_dataset=self.game_dataset, 181 | name=pattern_config["name"], 182 | code=pattern_config["code"], 183 | parameters=pattern_config["parameters"], 184 | in_time=pattern_config["in_time"] 185 | if "in_time" in pattern_config 186 | else 0, 187 | out_time=pattern_config["out_time"] 188 | if "out_time" in pattern_config 189 | else 0, 190 | ) 191 | 192 | return pattern 193 | 194 | def run_patterns(self): 195 | for pattern in self.patterns: 196 | pattern.events = pattern.run() 197 | 198 | def save_patterns_for_play(self, file_path: str): 199 | events_for_json = self._get_event_for_json() 200 | patterns_config = self._get_patterns_config_for_json() 201 | 202 | json_file_data = { 203 | "events": events_for_json, 204 | "insert": {"patterns": patterns_config}, 205 | } 206 | 207 | with open(file_path, "w") as f: 208 | json.dump(json_file_data, f, cls=utils.DataClassEncoder, indent=4) 209 | 210 | def _get_event_for_json(self): 211 | events_for_json = [] 212 | for pattern in self.patterns: 213 | events_for_json = events_for_json + pattern.events 214 | 215 | return events_for_json 216 | 217 | def _get_patterns_config_for_json(self): 218 | patterns_config = [] 219 | for pattern in self.patterns_config: 220 | pattern_config = {"name": pattern["name"], "code": pattern["code"]} 221 | patterns_config.append(pattern_config) 222 | 223 | return patterns_config 224 | -------------------------------------------------------------------------------- /codeball/patterns/patterns_config.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "include": true, 4 | "name": "Team Stretched", 5 | "code": "MET_001", 6 | "pattern_class": "TeamStretched", 7 | "parameters": {"team_code": "FIFATMA", "threshold": 35}, 8 | "in_time": 2, 9 | "out_time": 2 10 | }, 11 | { 12 | "include": true, 13 | "name": "Set Pieces", 14 | "code": "MET_002", 15 | "pattern_class": "SetPieces", 16 | "parameters": null, 17 | "in_time": 2, 18 | "out_time": 2 19 | }, 20 | { 21 | "include": true, 22 | "name": "Passes into the box", 23 | "code": "MET_003", 24 | "pattern_class": "PassesIntoTheBox", 25 | "parameters": null, 26 | "in_time": 2, 27 | "out_time": 2 28 | } 29 | ] 30 | 31 | -------------------------------------------------------------------------------- /codeball/patterns/set_pieces.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | from codeball import GameDataset 3 | from codeball.patterns import PatternEvent, Pattern 4 | 5 | 6 | class SetPieces(Pattern): 7 | def __init__( 8 | self, 9 | game_dataset: GameDataset, 10 | name: str, 11 | code: str, 12 | in_time: int = 0, 13 | out_time: int = 0, 14 | parameters: dict = None, 15 | ): 16 | super().__init__( 17 | name, code, in_time, out_time, parameters, game_dataset 18 | ) 19 | 20 | def run(self) -> List[PatternEvent]: 21 | 22 | set_pieces = self.game_dataset.events.type("GENERIC:SET PIECE") 23 | 24 | return [ 25 | self.build_pattern_event(event_row) 26 | for i, event_row in set_pieces.iterrows() 27 | ] 28 | 29 | def build_pattern_event(self, event_row) -> PatternEvent: 30 | pattern_event = self.from_event(event_row) 31 | pattern_event.add_spotlights(event_row["player_id"]) 32 | pattern_event.tags = event_row["team_id"] 33 | return pattern_event 34 | -------------------------------------------------------------------------------- /codeball/patterns/team_stretched.py: -------------------------------------------------------------------------------- 1 | from typing import List 2 | from codeball import GameDataset 3 | from codeball.patterns import PatternEvent, Pattern 4 | 5 | 6 | class TeamStretched(Pattern): 7 | def __init__( 8 | self, 9 | game_dataset: GameDataset, 10 | name: str, 11 | code: str, 12 | in_time: int = 0, 13 | out_time: int = 0, 14 | parameters: dict = None, 15 | ): 16 | super().__init__( 17 | name, code, in_time, out_time, parameters, game_dataset 18 | ) 19 | self.game_dataset = game_dataset 20 | self.team_code = self.parameters["team_code"] 21 | self.threshold = self.parameters["threshold"] 22 | 23 | def run(self) -> List[PatternEvent]: 24 | 25 | # Computes frames in which the team is defending 26 | defending_indexes = self.game_dataset.tracking.phase( 27 | defending_team_id=self.team_code 28 | ) 29 | 30 | # Computes frames in which the team is stretched horiontally 31 | stretched_indexes = ( 32 | self.game_dataset.tracking.team(self.team_code) 33 | .players("field") 34 | .dimension("x") 35 | .stretched(self.threshold) 36 | ) 37 | 38 | indexes = stretched_indexes & defending_indexes 39 | stretched_intervals = self.game_dataset.find_intervals(indexes) 40 | 41 | return [ 42 | self.build_pattern_event(interval) 43 | for interval in stretched_intervals 44 | ] 45 | 46 | def build_pattern_event(self, interval: List[int]) -> PatternEvent: 47 | pattern_event = self.from_interval(interval) 48 | pattern_event.add_team_length(team_code=self.team_code) 49 | pattern_event.tags = self.team_code 50 | 51 | return pattern_event 52 | -------------------------------------------------------------------------------- /codeball/tactical.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from enum import Enum 3 | from kloppy.domain.models import Team 4 | 5 | 6 | class AreaType(Enum): 7 | RECTANGLE = "rectangle" 8 | POLYGON = "polygon" 9 | 10 | 11 | class Area: 12 | def __init__(self, *points): 13 | self.__validate_points(points) 14 | self.points = points 15 | 16 | @staticmethod 17 | def __validate_points(points): 18 | 19 | for point in points: 20 | if isinstance(point, (tuple, list)) and len(point) != 2: 21 | raise TypeError( 22 | "A point should be a tuple or a list of length 2" 23 | ) 24 | 25 | if len(points) < 2: 26 | raise TypeError( 27 | "At least 2 points should be given to define an Area." 28 | ) 29 | 30 | @property 31 | def type(self): 32 | if len(self.points) == 2: 33 | return AreaType.RECTANGLE 34 | else: 35 | return AreaType.POLYGON 36 | 37 | 38 | class Zones(Enum): 39 | OPPONENT_BOX = Area((0.84, 0.2), (1, 0.8)) 40 | OWN_BOX = Area((0, 0.2), (0.16, 0.8)) 41 | 42 | ATTACKING_THIRD = Area((2 / 3, 0), (1, 1)) 43 | MIDDLE_THIRD = Area((1 / 3, 0), (2 / 3, 1)) 44 | DEFENDING_THIRD = Area((0, 0), (1 / 3, 1)) 45 | 46 | OWN_HALF = Area((0, 0), (0.5, 1)) 47 | OPPONENT_HALF = Area((0.5, 0), (1, 1)) 48 | 49 | LEFT_HALF_SPACE = Area((0, 0.2), (1, 0.4)) 50 | RIGHT_HALF_SPACE = Area((0, 0.6), (1, 0.8)) 51 | HALF_SPACES = (Area((0, 0.2), (1, 0.4)), Area((0, 0.6), (1, 0.8))) 52 | 53 | CENTRE = Area((0, 0.4), (1, 0.6)) 54 | 55 | LEFT_WING = Area((0, 0), (1, 0.2)) 56 | RIGHT_WING = Area((0, 0.8), (1, 1)) 57 | WINGS = (Area((0, 0), (1, 0.2)), Area((0, 0.8), (1, 1))) 58 | 59 | ZONE_14 = Area((2 / 3, 1 / 3), (5 / 6, 2 / 3)) 60 | 61 | @property 62 | def areas(self): 63 | return self.value 64 | 65 | 66 | @dataclass 67 | class Possession: 68 | start: float 69 | end: float 70 | team: Team 71 | -------------------------------------------------------------------------------- /codeball/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/metrica-sports/codeball/06161ac69f9b7b53e3820537e780ab962c4349e6/codeball/tests/__init__.py -------------------------------------------------------------------------------- /codeball/tests/files/code_xml.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | P1 6 | 3.6 7 | 9.7 8 | PASS 9 | 13 | 17 | 21 | 22 | 23 | P2 24 | 68.3 25 | 74.5 26 | PASS 27 | 31 | 35 | 39 | 40 | 41 | P3 42 | 103.6 43 | 109.6 44 | SHOT 45 | 49 | 53 | 54 | 55 | -------------------------------------------------------------------------------- /codeball/tests/files/events.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": [ 3 | { 4 | "index": 1, 5 | "team": { 6 | "name": "Team A", 7 | "id": "FIFATMA" 8 | }, 9 | "type": { 10 | "name": "SET PIECE", 11 | "id": 5 12 | }, 13 | "subtypes": { 14 | "name": "KICK OFF", 15 | "id": 35 16 | }, 17 | "start": { 18 | "frame": 361, 19 | "time": 14.44, 20 | "x": null, 21 | "y": null 22 | }, 23 | "end": { 24 | "frame": 361, 25 | "time": 14.44, 26 | "x": null, 27 | "y": null 28 | }, 29 | "period": 1, 30 | "from": { 31 | "name": "Player 10", 32 | "id": "P3577" 33 | }, 34 | "to": null 35 | }, 36 | { 37 | "index": 2, 38 | "team": { 39 | "name": "Team A", 40 | "id": "FIFATMA" 41 | }, 42 | "type": { 43 | "name": "PASS", 44 | "id": 1 45 | }, 46 | "subtypes": null, 47 | "start": { 48 | "frame": 361, 49 | "time": 14.44, 50 | "x": 0.50125, 51 | "y": 0.48725 52 | }, 53 | "end": { 54 | "frame": 377, 55 | "time": 15.08, 56 | "x": 0.49864, 57 | "y": 0.48705 58 | }, 59 | "period": 1, 60 | "from": { 61 | "name": "Player 10", 62 | "id": "P3577" 63 | }, 64 | "to": { 65 | "name": "Player 7", 66 | "id": "P3574" 67 | } 68 | }, 69 | { 70 | "index": 3, 71 | "team": { 72 | "name": "Team A", 73 | "id": "FIFATMA" 74 | }, 75 | "type": { 76 | "name": "CARRY", 77 | "id": 10 78 | }, 79 | "subtypes": null, 80 | "start": { 81 | "frame": 377, 82 | "time": 15.08, 83 | "x": 0.49864, 84 | "y": 0.48705 85 | }, 86 | "end": { 87 | "frame": 384, 88 | "time": 15.36, 89 | "x": 0.497, 90 | "y": 0.485 91 | }, 92 | "period": 1, 93 | "from": { 94 | "name": "Player 7", 95 | "id": "P3574" 96 | }, 97 | "to": null 98 | }, 99 | { 100 | "index": 4, 101 | "team": { 102 | "name": "Team A", 103 | "id": "FIFATMA" 104 | }, 105 | "type": { 106 | "name": "PASS", 107 | "id": 1 108 | }, 109 | "subtypes": null, 110 | "start": { 111 | "frame": 384, 112 | "time": 15.36, 113 | "x": 0.497, 114 | "y": 0.485 115 | }, 116 | "end": { 117 | "frame": 426, 118 | "time": 17.04, 119 | "x": 0.63373, 120 | "y": 0.63449 121 | }, 122 | "period": 1, 123 | "from": { 124 | "name": "Player 7", 125 | "id": "P3574" 126 | }, 127 | "to": { 128 | "name": "Player 8", 129 | "id": "P3575" 130 | } 131 | }, 132 | { 133 | "index": 5, 134 | "team": { 135 | "name": "Team A", 136 | "id": "FIFATMA" 137 | }, 138 | "type": { 139 | "name": "CARRY", 140 | "id": 10 141 | }, 142 | "subtypes": null, 143 | "start": { 144 | "frame": 426, 145 | "time": 17.04, 146 | "x": 0.63373, 147 | "y": 0.63449 148 | }, 149 | "end": { 150 | "frame": 465, 151 | "time": 18.6, 152 | "x": 0.66986, 153 | "y": 0.59707 154 | }, 155 | "period": 1, 156 | "from": { 157 | "name": "Player 8", 158 | "id": "P3575" 159 | }, 160 | "to": null 161 | }, 162 | { 163 | "index": 6, 164 | "team": { 165 | "name": "Team A", 166 | "id": "FIFATMA" 167 | }, 168 | "type": { 169 | "name": "PASS", 170 | "id": 1 171 | }, 172 | "subtypes": null, 173 | "start": { 174 | "frame": 465, 175 | "time": 18.6, 176 | "x": 0.66986, 177 | "y": 0.59707 178 | }, 179 | "end": { 180 | "frame": 507, 181 | "time": 20.28, 182 | "x": 0.80602, 183 | "y": 0.39821 184 | }, 185 | "period": 1, 186 | "from": { 187 | "name": "Player 8", 188 | "id": "P3575" 189 | }, 190 | "to": { 191 | "name": "Player 2", 192 | "id": "P3569" 193 | } 194 | }, 195 | { 196 | "index": 7, 197 | "team": { 198 | "name": "Team A", 199 | "id": "FIFATMA" 200 | }, 201 | "type": { 202 | "name": "CARRY", 203 | "id": 10 204 | }, 205 | "subtypes": null, 206 | "start": { 207 | "frame": 507, 208 | "time": 20.28, 209 | "x": 0.80602, 210 | "y": 0.39821 211 | }, 212 | "end": { 213 | "frame": 530, 214 | "time": 21.2, 215 | "x": 0.80929, 216 | "y": 0.42922 217 | }, 218 | "period": 1, 219 | "from": { 220 | "name": "Player 2", 221 | "id": "P3569" 222 | }, 223 | "to": null 224 | }, 225 | { 226 | "index": 8, 227 | "team": { 228 | "name": "Team A", 229 | "id": "FIFATMA" 230 | }, 231 | "type": { 232 | "name": "PASS", 233 | "id": 1 234 | }, 235 | "subtypes": null, 236 | "start": { 237 | "frame": 530, 238 | "time": 21.2, 239 | "x": 0.80929, 240 | "y": 0.42922 241 | }, 242 | "end": { 243 | "frame": 580, 244 | "time": 23.2, 245 | "x": 0.79906, 246 | "y": 0.81522 247 | }, 248 | "period": 1, 249 | "from": { 250 | "name": "Player 2", 251 | "id": "P3569" 252 | }, 253 | "to": { 254 | "name": "Player 3", 255 | "id": "P3570" 256 | } 257 | }, 258 | { 259 | "index": 9, 260 | "team": { 261 | "name": "Team A", 262 | "id": "FIFATMA" 263 | }, 264 | "type": { 265 | "name": "CARRY", 266 | "id": 10 267 | }, 268 | "subtypes": null, 269 | "start": { 270 | "frame": 580, 271 | "time": 23.2, 272 | "x": 0.79906, 273 | "y": 0.81522 274 | }, 275 | "end": { 276 | "frame": 598, 277 | "time": 23.92, 278 | "x": 0.79756, 279 | "y": 0.81998 280 | }, 281 | "period": 1, 282 | "from": { 283 | "name": "Player 3", 284 | "id": "P3570" 285 | }, 286 | "to": null 287 | }, 288 | { 289 | "index": 10, 290 | "team": { 291 | "name": "Team A", 292 | "id": "FIFATMA" 293 | }, 294 | "type": { 295 | "name": "PASS", 296 | "id": 1 297 | }, 298 | "subtypes": null, 299 | "start": { 300 | "frame": 598, 301 | "time": 23.92, 302 | "x": 0.79756, 303 | "y": 0.81998 304 | }, 305 | "end": { 306 | "frame": 628, 307 | "time": 25.12, 308 | "x": 0.68101, 309 | "y": 0.98059 310 | }, 311 | "period": 1, 312 | "from": { 313 | "name": "Player 3", 314 | "id": "P3570" 315 | }, 316 | "to": { 317 | "name": "Player 4", 318 | "id": "P3571" 319 | } 320 | }, 321 | { 322 | "index": 11, 323 | "team": { 324 | "name": "Team A", 325 | "id": "FIFATMA" 326 | }, 327 | "type": { 328 | "name": "CARRY", 329 | "id": 10 330 | }, 331 | "subtypes": null, 332 | "start": { 333 | "frame": 628, 334 | "time": 25.12, 335 | "x": 0.68101, 336 | "y": 0.98059 337 | }, 338 | "end": { 339 | "frame": 652, 340 | "time": 26.08, 341 | "x": 0.67819, 342 | "y": 0.97711 343 | }, 344 | "period": 1, 345 | "from": { 346 | "name": "Player 4", 347 | "id": "P3571" 348 | }, 349 | "to": null 350 | }, 351 | { 352 | "index": 12, 353 | "team": { 354 | "name": "Team A", 355 | "id": "FIFATMA" 356 | }, 357 | "type": { 358 | "name": "PASS", 359 | "id": 1 360 | }, 361 | "subtypes": null, 362 | "start": { 363 | "frame": 652, 364 | "time": 26.08, 365 | "x": 0.67819, 366 | "y": 0.97711 367 | }, 368 | "end": { 369 | "frame": 692, 370 | "time": 27.68, 371 | "x": 0.81302, 372 | "y": 0.86586 373 | }, 374 | "period": 1, 375 | "from": { 376 | "name": "Player 4", 377 | "id": "P3571" 378 | }, 379 | "to": { 380 | "name": "Player 3", 381 | "id": "P3570" 382 | } 383 | }, 384 | { 385 | "index": 13, 386 | "team": { 387 | "name": "Team A", 388 | "id": "FIFATMA" 389 | }, 390 | "type": { 391 | "name": "CARRY", 392 | "id": 10 393 | }, 394 | "subtypes": null, 395 | "start": { 396 | "frame": 692, 397 | "time": 27.68, 398 | "x": 0.81302, 399 | "y": 0.86586 400 | }, 401 | "end": { 402 | "frame": 722, 403 | "time": 28.88, 404 | "x": 0.81388, 405 | "y": 0.86224 406 | }, 407 | "period": 1, 408 | "from": { 409 | "name": "Player 3", 410 | "id": "P3570" 411 | }, 412 | "to": null 413 | }, 414 | { 415 | "index": 14, 416 | "team": { 417 | "name": "Team A", 418 | "id": "FIFATMA" 419 | }, 420 | "type": { 421 | "name": "PASS", 422 | "id": 1 423 | }, 424 | "subtypes": null, 425 | "start": { 426 | "frame": 722, 427 | "time": 28.88, 428 | "x": 0.81388, 429 | "y": 0.86224 430 | }, 431 | "end": { 432 | "frame": 755, 433 | "time": 30.2, 434 | "x": 0.96031, 435 | "y": 0.62313 436 | }, 437 | "period": 1, 438 | "from": { 439 | "name": "Player 3", 440 | "id": "P3570" 441 | }, 442 | "to": { 443 | "name": "Player 11", 444 | "id": "P3578" 445 | } 446 | }, 447 | { 448 | "index": 15, 449 | "team": { 450 | "name": "Team A", 451 | "id": "FIFATMA" 452 | }, 453 | "type": { 454 | "name": "CARRY", 455 | "id": 10 456 | }, 457 | "subtypes": null, 458 | "start": { 459 | "frame": 755, 460 | "time": 30.2, 461 | "x": 0.96031, 462 | "y": 0.62313 463 | }, 464 | "end": { 465 | "frame": 830, 466 | "time": 33.2, 467 | "x": 0.94749, 468 | "y": 0.49927 469 | }, 470 | "period": 1, 471 | "from": { 472 | "name": "Player 11", 473 | "id": "P3578" 474 | }, 475 | "to": null 476 | }, 477 | { 478 | "index": 16, 479 | "team": { 480 | "name": "Team A", 481 | "id": "FIFATMA" 482 | }, 483 | "type": { 484 | "name": "PASS", 485 | "id": 1 486 | }, 487 | "subtypes": null, 488 | "start": { 489 | "frame": 830, 490 | "time": 33.2, 491 | "x": 0.94749, 492 | "y": 0.49927 493 | }, 494 | "end": { 495 | "frame": 862, 496 | "time": 34.48, 497 | "x": 0.92755, 498 | "y": 0.19358 499 | }, 500 | "period": 1, 501 | "from": { 502 | "name": "Player 11", 503 | "id": "P3578" 504 | }, 505 | "to": { 506 | "name": "Player 2", 507 | "id": "P3569" 508 | } 509 | }, 510 | { 511 | "index": 17, 512 | "team": { 513 | "name": "Team A", 514 | "id": "FIFATMA" 515 | }, 516 | "type": { 517 | "name": "CARRY", 518 | "id": 10 519 | }, 520 | "subtypes": null, 521 | "start": { 522 | "frame": 862, 523 | "time": 34.48, 524 | "x": 0.92755, 525 | "y": 0.19358 526 | }, 527 | "end": { 528 | "frame": 897, 529 | "time": 35.88, 530 | "x": 0.9066, 531 | "y": 0.14186 532 | }, 533 | "period": 1, 534 | "from": { 535 | "name": "Player 2", 536 | "id": "P3569" 537 | }, 538 | "to": null 539 | }, 540 | { 541 | "index": 18, 542 | "team": { 543 | "name": "Team A", 544 | "id": "FIFATMA" 545 | }, 546 | "type": { 547 | "name": "BALL OUT", 548 | "id": 6 549 | }, 550 | "subtypes": { 551 | "name": "END HALF", 552 | "id": 19 553 | }, 554 | "start": { 555 | "frame": 897, 556 | "time": 35.88, 557 | "x": 0.9066, 558 | "y": 0.14186 559 | }, 560 | "end": { 561 | "frame": 957, 562 | "time": 38.28, 563 | "x": 0.51, 564 | "y": -0.01 565 | }, 566 | "period": 1, 567 | "from": { 568 | "name": "Player 2", 569 | "id": "P3569" 570 | }, 571 | "to": null 572 | }, 573 | { 574 | "index": 19, 575 | "team": { 576 | "name": "Team B", 577 | "id": "FIFATMB" 578 | }, 579 | "type": { 580 | "name": "SET PIECE", 581 | "id": 5 582 | }, 583 | "subtypes": { 584 | "name": "KICK OFF", 585 | "id": 35 586 | }, 587 | "start": { 588 | "frame": 1125, 589 | "time": 45, 590 | "x": null, 591 | "y": null 592 | }, 593 | "end": { 594 | "frame": 1125, 595 | "time": 45, 596 | "x": null, 597 | "y": null 598 | }, 599 | "period": 1, 600 | "from": { 601 | "name": "Player 21", 602 | "id": "P3588" 603 | }, 604 | "to": null 605 | }, 606 | { 607 | "index": 20, 608 | "team": { 609 | "name": "Team B", 610 | "id": "FIFATMB" 611 | }, 612 | "type": { 613 | "name": "PASS", 614 | "id": 1 615 | }, 616 | "subtypes": null, 617 | "start": { 618 | "frame": 1125, 619 | "time": 45, 620 | "x": 0.5, 621 | "y": 0.5 622 | }, 623 | "end": { 624 | "frame": 1149, 625 | "time": 45.96, 626 | "x": 0.65727, 627 | "y": 0.09506 628 | }, 629 | "period": 1, 630 | "from": { 631 | "name": "Player 21", 632 | "id": "P3588" 633 | }, 634 | "to": { 635 | "name": "Player 25", 636 | "id": "P3592" 637 | } 638 | }, 639 | { 640 | "index": 21, 641 | "team": { 642 | "name": "Team B", 643 | "id": "FIFATMB" 644 | }, 645 | "type": { 646 | "name": "PASS", 647 | "id": 1 648 | }, 649 | "subtypes": null, 650 | "start": { 651 | "frame": 1149, 652 | "time": 45.96, 653 | "x": 0.65727, 654 | "y": 0.09506 655 | }, 656 | "end": { 657 | "frame": 1180, 658 | "time": 47.2, 659 | "x": 0.58876, 660 | "y": 0.02204 661 | }, 662 | "period": 1, 663 | "from": { 664 | "name": "Player 25", 665 | "id": "P3592" 666 | }, 667 | "to": { 668 | "name": "Player 21", 669 | "id": "P3588" 670 | } 671 | }, 672 | { 673 | "index": 22, 674 | "team": { 675 | "name": "Team B", 676 | "id": "FIFATMB" 677 | }, 678 | "type": { 679 | "name": "CARRY", 680 | "id": 10 681 | }, 682 | "subtypes": null, 683 | "start": { 684 | "frame": 1149, 685 | "time": 45.96, 686 | "x": 0.65727, 687 | "y": 0.09506 688 | }, 689 | "end": { 690 | "frame": 1150, 691 | "time": 46, 692 | "x": 0.65727, 693 | "y": 0.09506 694 | }, 695 | "period": 1, 696 | "from": { 697 | "name": "Player 25", 698 | "id": "P3592" 699 | }, 700 | "to": null 701 | }, 702 | { 703 | "index": 23, 704 | "team": { 705 | "name": "Team B", 706 | "id": "FIFATMB" 707 | }, 708 | "type": { 709 | "name": "CARRY", 710 | "id": 10 711 | }, 712 | "subtypes": null, 713 | "start": { 714 | "frame": 1180, 715 | "time": 47.2, 716 | "x": 0.58876, 717 | "y": 0.02204 718 | }, 719 | "end": { 720 | "frame": 1198, 721 | "time": 47.92, 722 | "x": 0.58828, 723 | "y": 0.03237 724 | }, 725 | "period": 1, 726 | "from": { 727 | "name": "Player 21", 728 | "id": "P3588" 729 | }, 730 | "to": null 731 | }, 732 | { 733 | "index": 24, 734 | "team": { 735 | "name": "Team B", 736 | "id": "FIFATMB" 737 | }, 738 | "type": { 739 | "name": "BALL LOST", 740 | "id": 7 741 | }, 742 | "subtypes": null, 743 | "start": { 744 | "frame": 1198, 745 | "time": 47.92, 746 | "x": 0.58828, 747 | "y": 0.03237 748 | }, 749 | "end": { 750 | "frame": 1234, 751 | "time": 49.36, 752 | "x": 0.71, 753 | "y": 0.02 754 | }, 755 | "period": 1, 756 | "from": { 757 | "name": "Player 21", 758 | "id": "P3588" 759 | }, 760 | "to": null 761 | }, 762 | { 763 | "index": 25, 764 | "team": { 765 | "name": "Team A", 766 | "id": "FIFATMA" 767 | }, 768 | "type": { 769 | "name": "RECOVERY", 770 | "id": 3 771 | }, 772 | "subtypes": null, 773 | "start": { 774 | "frame": 1232, 775 | "time": 49.28, 776 | "x": 0.73052, 777 | "y": 0.05659 778 | }, 779 | "end": { 780 | "frame": 1232, 781 | "time": 49.28, 782 | "x": null, 783 | "y": null 784 | }, 785 | "period": 1, 786 | "from": { 787 | "name": "Player 1", 788 | "id": "P3568" 789 | }, 790 | "to": null 791 | }, 792 | { 793 | "index": 26, 794 | "team": { 795 | "name": "Team A", 796 | "id": "FIFATMA" 797 | }, 798 | "type": { 799 | "name": "BALL LOST", 800 | "id": 7 801 | }, 802 | "subtypes": null, 803 | "start": { 804 | "frame": 1232, 805 | "time": 49.28, 806 | "x": 0.73052, 807 | "y": 0.05659 808 | }, 809 | "end": { 810 | "frame": 1288, 811 | "time": 51.52, 812 | "x": 0.53, 813 | "y": 0.3 814 | }, 815 | "period": 1, 816 | "from": { 817 | "name": "Player 1", 818 | "id": "P3568" 819 | }, 820 | "to": null 821 | }, 822 | { 823 | "index": 27, 824 | "team": { 825 | "name": "Team A", 826 | "id": "FIFATMA" 827 | }, 828 | "type": { 829 | "name": "CARRY", 830 | "id": 10 831 | }, 832 | "subtypes": null, 833 | "start": { 834 | "frame": 1232, 835 | "time": 49.28, 836 | "x": 0.73052, 837 | "y": 0.05659 838 | }, 839 | "end": { 840 | "frame": 1233, 841 | "time": 49.32, 842 | "x": 0.73052, 843 | "y": 0.05659 844 | }, 845 | "period": 1, 846 | "from": { 847 | "name": "Player 1", 848 | "id": "P3568" 849 | }, 850 | "to": null 851 | }, 852 | { 853 | "index": 28, 854 | "team": { 855 | "name": "Team B", 856 | "id": "FIFATMB" 857 | }, 858 | "type": { 859 | "name": "RECOVERY", 860 | "id": 3 861 | }, 862 | "subtypes": null, 863 | "start": { 864 | "frame": 1285, 865 | "time": 51.4, 866 | "x": 0.51723, 867 | "y": 0.24393 868 | }, 869 | "end": { 870 | "frame": 1285, 871 | "time": 51.4, 872 | "x": null, 873 | "y": null 874 | }, 875 | "period": 1, 876 | "from": { 877 | "name": "Player 23", 878 | "id": "P3590" 879 | }, 880 | "to": null 881 | }, 882 | { 883 | "index": 29, 884 | "team": { 885 | "name": "Team B", 886 | "id": "FIFATMB" 887 | }, 888 | "type": { 889 | "name": "BALL LOST", 890 | "id": 7 891 | }, 892 | "subtypes": null, 893 | "start": { 894 | "frame": 1285, 895 | "time": 51.4, 896 | "x": 0.51723, 897 | "y": 0.24393 898 | }, 899 | "end": { 900 | "frame": 1313, 901 | "time": 52.52, 902 | "x": 0.64, 903 | "y": 0.22 904 | }, 905 | "period": 1, 906 | "from": { 907 | "name": "Player 23", 908 | "id": "P3590" 909 | }, 910 | "to": null 911 | }, 912 | { 913 | "index": 30, 914 | "team": { 915 | "name": "Team B", 916 | "id": "FIFATMB" 917 | }, 918 | "type": { 919 | "name": "CARRY", 920 | "id": 10 921 | }, 922 | "subtypes": null, 923 | "start": { 924 | "frame": 1285, 925 | "time": 51.4, 926 | "x": 0.51723, 927 | "y": 0.24393 928 | }, 929 | "end": { 930 | "frame": 1286, 931 | "time": 51.44, 932 | "x": 0.51723, 933 | "y": 0.24393 934 | }, 935 | "period": 1, 936 | "from": { 937 | "name": "Player 23", 938 | "id": "P3590" 939 | }, 940 | "to": null 941 | }, 942 | { 943 | "index": 31, 944 | "team": { 945 | "name": "Team A", 946 | "id": "FIFATMA" 947 | }, 948 | "type": { 949 | "name": "RECOVERY", 950 | "id": 3 951 | }, 952 | "subtypes": null, 953 | "start": { 954 | "frame": 1314, 955 | "time": 52.56, 956 | "x": 0.63227, 957 | "y": 0.21048 958 | }, 959 | "end": { 960 | "frame": 1314, 961 | "time": 52.56, 962 | "x": null, 963 | "y": null 964 | }, 965 | "period": 1, 966 | "from": { 967 | "name": "Player 6", 968 | "id": "P3573" 969 | }, 970 | "to": null 971 | }, 972 | { 973 | "index": 32, 974 | "team": { 975 | "name": "Team A", 976 | "id": "FIFATMA" 977 | }, 978 | "type": { 979 | "name": "PASS", 980 | "id": 1 981 | }, 982 | "subtypes": null, 983 | "start": { 984 | "frame": 1314, 985 | "time": 52.56, 986 | "x": 0.63227, 987 | "y": 0.21048 988 | }, 989 | "end": { 990 | "frame": 1368, 991 | "time": 54.72, 992 | "x": 0.55851, 993 | "y": 0.38123 994 | }, 995 | "period": 1, 996 | "from": { 997 | "name": "Player 6", 998 | "id": "P3573" 999 | }, 1000 | "to": { 1001 | "name": "Player 7", 1002 | "id": "P3574" 1003 | } 1004 | }, 1005 | { 1006 | "index": 33, 1007 | "team": { 1008 | "name": "Team A", 1009 | "id": "FIFATMA" 1010 | }, 1011 | "type": { 1012 | "name": "CARRY", 1013 | "id": 10 1014 | }, 1015 | "subtypes": null, 1016 | "start": { 1017 | "frame": 1314, 1018 | "time": 52.56, 1019 | "x": 0.63227, 1020 | "y": 0.21048 1021 | }, 1022 | "end": { 1023 | "frame": 1315, 1024 | "time": 52.6, 1025 | "x": 0.63227, 1026 | "y": 0.21048 1027 | }, 1028 | "period": 1, 1029 | "from": { 1030 | "name": "Player 6", 1031 | "id": "P3573" 1032 | }, 1033 | "to": null 1034 | }, 1035 | { 1036 | "index": 34, 1037 | "team": { 1038 | "name": "Team B", 1039 | "id": "FIFATMB" 1040 | }, 1041 | "type": { 1042 | "name": "CHALLENGE", 1043 | "id": 9 1044 | }, 1045 | "subtypes": [ 1046 | { 1047 | "name": "GROUND", 1048 | "id": 42 1049 | }, 1050 | { 1051 | "name": "LOST", 1052 | "id": 49 1053 | } 1054 | ], 1055 | "start": { 1056 | "frame": 1366, 1057 | "time": 54.64, 1058 | "x": 0.54952, 1059 | "y": 0.37123 1060 | }, 1061 | "end": { 1062 | "frame": 1366, 1063 | "time": 54.64, 1064 | "x": null, 1065 | "y": null 1066 | }, 1067 | "period": 1, 1068 | "from": { 1069 | "name": "Player 23", 1070 | "id": "P3590" 1071 | }, 1072 | "to": null 1073 | }, 1074 | { 1075 | "index": 35, 1076 | "team": { 1077 | "name": "Team A", 1078 | "id": "FIFATMA" 1079 | }, 1080 | "type": { 1081 | "name": "CHALLENGE", 1082 | "id": 9 1083 | }, 1084 | "subtypes": [ 1085 | { 1086 | "name": "GROUND", 1087 | "id": 42 1088 | }, 1089 | { 1090 | "name": "WON", 1091 | "id": 48 1092 | } 1093 | ], 1094 | "start": { 1095 | "frame": 1368, 1096 | "time": 54.72, 1097 | "x": 0.55851, 1098 | "y": 0.38123 1099 | }, 1100 | "end": { 1101 | "frame": 1368, 1102 | "time": 54.72, 1103 | "x": null, 1104 | "y": null 1105 | }, 1106 | "period": 1, 1107 | "from": { 1108 | "name": "Player 7", 1109 | "id": "P3574" 1110 | }, 1111 | "to": null 1112 | }, 1113 | { 1114 | "index": 36, 1115 | "team": { 1116 | "name": "Team A", 1117 | "id": "FIFATMA" 1118 | }, 1119 | "type": { 1120 | "name": "CARRY", 1121 | "id": 10 1122 | }, 1123 | "subtypes": null, 1124 | "start": { 1125 | "frame": 1368, 1126 | "time": 54.72, 1127 | "x": 0.55851, 1128 | "y": 0.38123 1129 | }, 1130 | "end": { 1131 | "frame": 1410, 1132 | "time": 56.4, 1133 | "x": 0.58673, 1134 | "y": 0.42462 1135 | }, 1136 | "period": 1, 1137 | "from": { 1138 | "name": "Player 7", 1139 | "id": "P3574" 1140 | }, 1141 | "to": null 1142 | }, 1143 | { 1144 | "index": 37, 1145 | "team": { 1146 | "name": "Team A", 1147 | "id": "FIFATMA" 1148 | }, 1149 | "type": { 1150 | "name": "PASS", 1151 | "id": 1 1152 | }, 1153 | "subtypes": null, 1154 | "start": { 1155 | "frame": 1410, 1156 | "time": 56.4, 1157 | "x": 0.58673, 1158 | "y": 0.42462 1159 | }, 1160 | "end": { 1161 | "frame": 1437, 1162 | "time": 57.48, 1163 | "x": 0.61178, 1164 | "y": 0.28761 1165 | }, 1166 | "period": 1, 1167 | "from": { 1168 | "name": "Player 7", 1169 | "id": "P3574" 1170 | }, 1171 | "to": { 1172 | "name": "Player 6", 1173 | "id": "P3573" 1174 | } 1175 | }, 1176 | { 1177 | "index": 38, 1178 | "team": { 1179 | "name": "Team A", 1180 | "id": "FIFATMA" 1181 | }, 1182 | "type": { 1183 | "name": "CARRY", 1184 | "id": 10 1185 | }, 1186 | "subtypes": null, 1187 | "start": { 1188 | "frame": 1437, 1189 | "time": 57.48, 1190 | "x": 0.61178, 1191 | "y": 0.28761 1192 | }, 1193 | "end": { 1194 | "frame": 1473, 1195 | "time": 58.92, 1196 | "x": 0.60722, 1197 | "y": 0.30677 1198 | }, 1199 | "period": 1, 1200 | "from": { 1201 | "name": "Player 6", 1202 | "id": "P3573" 1203 | }, 1204 | "to": null 1205 | }, 1206 | { 1207 | "index": 39, 1208 | "team": { 1209 | "name": "Team A", 1210 | "id": "FIFATMA" 1211 | }, 1212 | "type": { 1213 | "name": "PASS", 1214 | "id": 1 1215 | }, 1216 | "subtypes": null, 1217 | "start": { 1218 | "frame": 1473, 1219 | "time": 58.92, 1220 | "x": 0.60722, 1221 | "y": 0.30677 1222 | }, 1223 | "end": { 1224 | "frame": 1523, 1225 | "time": 60.92, 1226 | "x": 0.60393, 1227 | "y": 0.84411 1228 | }, 1229 | "period": 1, 1230 | "from": { 1231 | "name": "Player 6", 1232 | "id": "P3573" 1233 | }, 1234 | "to": { 1235 | "name": "Player 4", 1236 | "id": "P3571" 1237 | } 1238 | }, 1239 | { 1240 | "index": 40, 1241 | "team": { 1242 | "name": "Team A", 1243 | "id": "FIFATMA" 1244 | }, 1245 | "type": { 1246 | "name": "CARRY", 1247 | "id": 10 1248 | }, 1249 | "subtypes": null, 1250 | "start": { 1251 | "frame": 1523, 1252 | "time": 60.92, 1253 | "x": 0.60393, 1254 | "y": 0.84411 1255 | }, 1256 | "end": { 1257 | "frame": 1639, 1258 | "time": 65.56, 1259 | "x": 0.60751, 1260 | "y": 0.88738 1261 | }, 1262 | "period": 1, 1263 | "from": { 1264 | "name": "Player 4", 1265 | "id": "P3571" 1266 | }, 1267 | "to": null 1268 | }, 1269 | { 1270 | "index": 41, 1271 | "team": { 1272 | "name": "Team A", 1273 | "id": "FIFATMA" 1274 | }, 1275 | "type": { 1276 | "name": "PASS", 1277 | "id": 1 1278 | }, 1279 | "subtypes": null, 1280 | "start": { 1281 | "frame": 1639, 1282 | "time": 65.56, 1283 | "x": 0.60751, 1284 | "y": 0.88738 1285 | }, 1286 | "end": { 1287 | "frame": 1687, 1288 | "time": 67.48, 1289 | "x": 0.77456, 1290 | "y": 0.74867 1291 | }, 1292 | "period": 1, 1293 | "from": { 1294 | "name": "Player 4", 1295 | "id": "P3571" 1296 | }, 1297 | "to": { 1298 | "name": "Player 3", 1299 | "id": "P3570" 1300 | } 1301 | }, 1302 | { 1303 | "index": 42, 1304 | "team": { 1305 | "name": "Team A", 1306 | "id": "FIFATMA" 1307 | }, 1308 | "type": { 1309 | "name": "CARRY", 1310 | "id": 10 1311 | }, 1312 | "subtypes": null, 1313 | "start": { 1314 | "frame": 1687, 1315 | "time": 67.48, 1316 | "x": 0.77456, 1317 | "y": 0.74867 1318 | }, 1319 | "end": { 1320 | "frame": 1710, 1321 | "time": 68.4, 1322 | "x": 0.77939, 1323 | "y": 0.74052 1324 | }, 1325 | "period": 1, 1326 | "from": { 1327 | "name": "Player 3", 1328 | "id": "P3570" 1329 | }, 1330 | "to": null 1331 | }, 1332 | { 1333 | "index": 43, 1334 | "team": { 1335 | "name": "Team A", 1336 | "id": "FIFATMA" 1337 | }, 1338 | "type": { 1339 | "name": "PASS", 1340 | "id": 1 1341 | }, 1342 | "subtypes": null, 1343 | "start": { 1344 | "frame": 1710, 1345 | "time": 68.4, 1346 | "x": 0.77939, 1347 | "y": 0.74052 1348 | }, 1349 | "end": { 1350 | "frame": 1748, 1351 | "time": 69.92, 1352 | "x": 0.80277, 1353 | "y": 0.41404 1354 | }, 1355 | "period": 1, 1356 | "from": { 1357 | "name": "Player 3", 1358 | "id": "P3570" 1359 | }, 1360 | "to": { 1361 | "name": "Player 2", 1362 | "id": "P3569" 1363 | } 1364 | }, 1365 | { 1366 | "index": 44, 1367 | "team": { 1368 | "name": "Team A", 1369 | "id": "FIFATMA" 1370 | }, 1371 | "type": { 1372 | "name": "CARRY", 1373 | "id": 10 1374 | }, 1375 | "subtypes": null, 1376 | "start": { 1377 | "frame": 1748, 1378 | "time": 69.92, 1379 | "x": 0.80277, 1380 | "y": 0.41404 1381 | }, 1382 | "end": { 1383 | "frame": 1791, 1384 | "time": 71.64, 1385 | "x": 0.78088, 1386 | "y": 0.34444 1387 | }, 1388 | "period": 1, 1389 | "from": { 1390 | "name": "Player 2", 1391 | "id": "P3569" 1392 | }, 1393 | "to": null 1394 | }, 1395 | { 1396 | "index": 45, 1397 | "team": { 1398 | "name": "Team A", 1399 | "id": "FIFATMA" 1400 | }, 1401 | "type": { 1402 | "name": "PASS", 1403 | "id": 1 1404 | }, 1405 | "subtypes": null, 1406 | "start": { 1407 | "frame": 1791, 1408 | "time": 71.64, 1409 | "x": 0.78088, 1410 | "y": 0.34444 1411 | }, 1412 | "end": { 1413 | "frame": 1829, 1414 | "time": 73.16, 1415 | "x": 0.67045, 1416 | "y": 0.0539 1417 | }, 1418 | "period": 1, 1419 | "from": { 1420 | "name": "Player 2", 1421 | "id": "P3569" 1422 | }, 1423 | "to": { 1424 | "name": "Player 1", 1425 | "id": "P3568" 1426 | } 1427 | }, 1428 | { 1429 | "index": 46, 1430 | "team": { 1431 | "name": "Team A", 1432 | "id": "FIFATMA" 1433 | }, 1434 | "type": { 1435 | "name": "CARRY", 1436 | "id": 10 1437 | }, 1438 | "subtypes": null, 1439 | "start": { 1440 | "frame": 1829, 1441 | "time": 73.16, 1442 | "x": 0.67045, 1443 | "y": 0.0539 1444 | }, 1445 | "end": { 1446 | "frame": 1852, 1447 | "time": 74.08, 1448 | "x": 0.6676, 1449 | "y": 0.06382 1450 | }, 1451 | "period": 1, 1452 | "from": { 1453 | "name": "Player 1", 1454 | "id": "P3568" 1455 | }, 1456 | "to": null 1457 | }, 1458 | { 1459 | "index": 47, 1460 | "team": { 1461 | "name": "Team A", 1462 | "id": "FIFATMA" 1463 | }, 1464 | "type": { 1465 | "name": "PASS", 1466 | "id": 1 1467 | }, 1468 | "subtypes": null, 1469 | "start": { 1470 | "frame": 1852, 1471 | "time": 74.08, 1472 | "x": 0.6676, 1473 | "y": 0.06382 1474 | }, 1475 | "end": { 1476 | "frame": 1872, 1477 | "time": 74.88, 1478 | "x": 0.64323, 1479 | "y": 0.22396 1480 | }, 1481 | "period": 1, 1482 | "from": { 1483 | "name": "Player 1", 1484 | "id": "P3568" 1485 | }, 1486 | "to": { 1487 | "name": "Player 6", 1488 | "id": "P3573" 1489 | } 1490 | }, 1491 | { 1492 | "index": 48, 1493 | "team": { 1494 | "name": "Team A", 1495 | "id": "FIFATMA" 1496 | }, 1497 | "type": { 1498 | "name": "PASS", 1499 | "id": 1 1500 | }, 1501 | "subtypes": null, 1502 | "start": { 1503 | "frame": 1872, 1504 | "time": 74.88, 1505 | "x": 0.64323, 1506 | "y": 0.22396 1507 | }, 1508 | "end": { 1509 | "frame": 1904, 1510 | "time": 76.16, 1511 | "x": 0.69468, 1512 | "y": 0.0488 1513 | }, 1514 | "period": 1, 1515 | "from": { 1516 | "name": "Player 6", 1517 | "id": "P3573" 1518 | }, 1519 | "to": { 1520 | "name": "Player 1", 1521 | "id": "P3568" 1522 | } 1523 | }, 1524 | { 1525 | "index": 49, 1526 | "team": { 1527 | "name": "Team A", 1528 | "id": "FIFATMA" 1529 | }, 1530 | "type": { 1531 | "name": "CARRY", 1532 | "id": 10 1533 | }, 1534 | "subtypes": null, 1535 | "start": { 1536 | "frame": 1872, 1537 | "time": 74.88, 1538 | "x": 0.64323, 1539 | "y": 0.22396 1540 | }, 1541 | "end": { 1542 | "frame": 1873, 1543 | "time": 74.92, 1544 | "x": 0.64323, 1545 | "y": 0.22396 1546 | }, 1547 | "period": 1, 1548 | "from": { 1549 | "name": "Player 6", 1550 | "id": "P3573" 1551 | }, 1552 | "to": null 1553 | }, 1554 | { 1555 | "index": 50, 1556 | "team": { 1557 | "name": "Team A", 1558 | "id": "FIFATMA" 1559 | }, 1560 | "type": { 1561 | "name": "CARRY", 1562 | "id": 10 1563 | }, 1564 | "subtypes": null, 1565 | "start": { 1566 | "frame": 1904, 1567 | "time": 76.16, 1568 | "x": 0.69468, 1569 | "y": 0.0488 1570 | }, 1571 | "end": { 1572 | "frame": 1924, 1573 | "time": 76.96, 1574 | "x": 0.68617, 1575 | "y": 0.04201 1576 | }, 1577 | "period": 1, 1578 | "from": { 1579 | "name": "Player 1", 1580 | "id": "P3568" 1581 | }, 1582 | "to": null 1583 | }, 1584 | { 1585 | "index": 51, 1586 | "team": { 1587 | "name": "Team A", 1588 | "id": "FIFATMA" 1589 | }, 1590 | "type": { 1591 | "name": "PASS", 1592 | "id": 1 1593 | }, 1594 | "subtypes": null, 1595 | "start": { 1596 | "frame": 1924, 1597 | "time": 76.96, 1598 | "x": 0.68617, 1599 | "y": 0.04201 1600 | }, 1601 | "end": { 1602 | "frame": 1956, 1603 | "time": 78.24, 1604 | "x": 0.5279, 1605 | "y": 0.00891 1606 | }, 1607 | "period": 1, 1608 | "from": { 1609 | "name": "Player 1", 1610 | "id": "P3568" 1611 | }, 1612 | "to": { 1613 | "name": "Player 5", 1614 | "id": "P3572" 1615 | } 1616 | }, 1617 | { 1618 | "index": 52, 1619 | "team": { 1620 | "name": "Team A", 1621 | "id": "FIFATMA" 1622 | }, 1623 | "type": { 1624 | "name": "PASS", 1625 | "id": 1 1626 | }, 1627 | "subtypes": null, 1628 | "start": { 1629 | "frame": 1956, 1630 | "time": 78.24, 1631 | "x": 0.5279, 1632 | "y": 0.00891 1633 | }, 1634 | "end": { 1635 | "frame": 1977, 1636 | "time": 79.08, 1637 | "x": 0.50468, 1638 | "y": 0.06428 1639 | }, 1640 | "period": 1, 1641 | "from": { 1642 | "name": "Player 5", 1643 | "id": "P3572" 1644 | }, 1645 | "to": { 1646 | "name": "Player 10", 1647 | "id": "P3577" 1648 | } 1649 | }, 1650 | { 1651 | "index": 53, 1652 | "team": { 1653 | "name": "Team A", 1654 | "id": "FIFATMA" 1655 | }, 1656 | "type": { 1657 | "name": "CARRY", 1658 | "id": 10 1659 | }, 1660 | "subtypes": null, 1661 | "start": { 1662 | "frame": 1956, 1663 | "time": 78.24, 1664 | "x": 0.5279, 1665 | "y": 0.00891 1666 | }, 1667 | "end": { 1668 | "frame": 1957, 1669 | "time": 78.28, 1670 | "x": 0.5279, 1671 | "y": 0.00891 1672 | }, 1673 | "period": 1, 1674 | "from": { 1675 | "name": "Player 5", 1676 | "id": "P3572" 1677 | }, 1678 | "to": null 1679 | }, 1680 | { 1681 | "index": 54, 1682 | "team": { 1683 | "name": "Team A", 1684 | "id": "FIFATMA" 1685 | }, 1686 | "type": { 1687 | "name": "PASS", 1688 | "id": 1 1689 | }, 1690 | "subtypes": null, 1691 | "start": { 1692 | "frame": 1977, 1693 | "time": 79.08, 1694 | "x": 0.50468, 1695 | "y": 0.06428 1696 | }, 1697 | "end": { 1698 | "frame": 1998, 1699 | "time": 79.92, 1700 | "x": 0.53862, 1701 | "y": 0.07451 1702 | }, 1703 | "period": 1, 1704 | "from": { 1705 | "name": "Player 10", 1706 | "id": "P3577" 1707 | }, 1708 | "to": { 1709 | "name": "Player 5", 1710 | "id": "P3572" 1711 | } 1712 | }, 1713 | { 1714 | "index": 55, 1715 | "team": { 1716 | "name": "Team A", 1717 | "id": "FIFATMA" 1718 | }, 1719 | "type": { 1720 | "name": "CARRY", 1721 | "id": 10 1722 | }, 1723 | "subtypes": null, 1724 | "start": { 1725 | "frame": 1977, 1726 | "time": 79.08, 1727 | "x": 0.50468, 1728 | "y": 0.06428 1729 | }, 1730 | "end": { 1731 | "frame": 1978, 1732 | "time": 79.12, 1733 | "x": 0.50468, 1734 | "y": 0.06428 1735 | }, 1736 | "period": 1, 1737 | "from": { 1738 | "name": "Player 10", 1739 | "id": "P3577" 1740 | }, 1741 | "to": null 1742 | }, 1743 | { 1744 | "index": 56, 1745 | "team": { 1746 | "name": "Team A", 1747 | "id": "FIFATMA" 1748 | }, 1749 | "type": { 1750 | "name": "CARRY", 1751 | "id": 10 1752 | }, 1753 | "subtypes": null, 1754 | "start": { 1755 | "frame": 1998, 1756 | "time": 79.92, 1757 | "x": 0.53862, 1758 | "y": 0.07451 1759 | }, 1760 | "end": { 1761 | "frame": 2045, 1762 | "time": 81.8, 1763 | "x": 0.52523, 1764 | "y": 0.18418 1765 | }, 1766 | "period": 1, 1767 | "from": { 1768 | "name": "Player 5", 1769 | "id": "P3572" 1770 | }, 1771 | "to": null 1772 | }, 1773 | { 1774 | "index": 57, 1775 | "team": { 1776 | "name": "Team A", 1777 | "id": "FIFATMA" 1778 | }, 1779 | "type": { 1780 | "name": "BALL LOST", 1781 | "id": 7 1782 | }, 1783 | "subtypes": { 1784 | "name": "END HALF", 1785 | "id": 19 1786 | }, 1787 | "start": { 1788 | "frame": 2045, 1789 | "time": 81.8, 1790 | "x": 0.52523, 1791 | "y": 0.18418 1792 | }, 1793 | "end": { 1794 | "frame": 2075, 1795 | "time": 83, 1796 | "x": 0.35, 1797 | "y": 0.21 1798 | }, 1799 | "period": 1, 1800 | "from": { 1801 | "name": "Player 5", 1802 | "id": "P3572" 1803 | }, 1804 | "to": null 1805 | } 1806 | ], 1807 | "metadata": [ 1808 | { 1809 | "category": "TYPE", 1810 | "name": "PASS", 1811 | "id": "1" 1812 | }, 1813 | { 1814 | "category": "TYPE", 1815 | "name": "SHOT", 1816 | "id": "2" 1817 | }, 1818 | { 1819 | "category": "TYPE", 1820 | "name": "RECOVERY", 1821 | "id": "3" 1822 | }, 1823 | { 1824 | "category": "TYPE", 1825 | "name": "FAULT RECEIVED", 1826 | "id": "4" 1827 | }, 1828 | { 1829 | "category": "TYPE", 1830 | "name": "SET PIECE", 1831 | "id": "5" 1832 | }, 1833 | { 1834 | "category": "TYPE", 1835 | "name": "BALL OUT", 1836 | "id": "6" 1837 | }, 1838 | { 1839 | "category": "TYPE", 1840 | "name": "BALL LOST", 1841 | "id": "7" 1842 | }, 1843 | { 1844 | "category": "TYPE", 1845 | "name": "CARD", 1846 | "id": "8" 1847 | }, 1848 | { 1849 | "category": "TYPE", 1850 | "name": "CHALLENGE", 1851 | "id": "9" 1852 | }, 1853 | { 1854 | "category": "TYPE", 1855 | "name": "CARRY", 1856 | "id": "10" 1857 | }, 1858 | { 1859 | "category": "SUBTYPE", 1860 | "name": "HEAD", 1861 | "id": "11" 1862 | }, 1863 | { 1864 | "category": "SUBTYPE", 1865 | "name": "CLEARANCE", 1866 | "id": "12" 1867 | }, 1868 | { 1869 | "category": "SUBTYPE", 1870 | "name": "CROSS", 1871 | "id": "13" 1872 | }, 1873 | { 1874 | "category": "SUBTYPE", 1875 | "name": "THROUGH BALL", 1876 | "id": "14" 1877 | }, 1878 | { 1879 | "category": "SUBTYPE", 1880 | "name": "DEEP BALL", 1881 | "id": "15" 1882 | }, 1883 | { 1884 | "category": "SUBTYPE", 1885 | "name": "OFFSIDE", 1886 | "id": "16" 1887 | }, 1888 | { 1889 | "category": "SUBTYPE", 1890 | "name": "VOLUNTARY", 1891 | "id": "17" 1892 | }, 1893 | { 1894 | "category": "SUBTYPE", 1895 | "name": "FORCED", 1896 | "id": "18" 1897 | }, 1898 | { 1899 | "category": "SUBTYPE", 1900 | "name": "END HALF", 1901 | "id": "19" 1902 | }, 1903 | { 1904 | "category": "SUBTYPE", 1905 | "name": "GOAL KICK", 1906 | "id": "20" 1907 | }, 1908 | { 1909 | "category": "SUBTYPE", 1910 | "name": "HAND BALL", 1911 | "id": "21" 1912 | }, 1913 | { 1914 | "category": "SUBTYPE", 1915 | "name": "REFEREE HIT", 1916 | "id": "22" 1917 | }, 1918 | { 1919 | "category": "SUBTYPE", 1920 | "name": "INTERCEPTION", 1921 | "id": "23" 1922 | }, 1923 | { 1924 | "category": "SUBTYPE", 1925 | "name": "THEFT", 1926 | "id": "24" 1927 | }, 1928 | { 1929 | "category": "SUBTYPE", 1930 | "name": "BLOCKED", 1931 | "id": "25" 1932 | }, 1933 | { 1934 | "category": "SUBTYPE", 1935 | "name": "SAVED", 1936 | "id": "26" 1937 | }, 1938 | { 1939 | "category": "SUBTYPE", 1940 | "name": "WOODWORK", 1941 | "id": "27" 1942 | }, 1943 | { 1944 | "category": "SUBTYPE", 1945 | "name": "ON TARGET", 1946 | "id": "28" 1947 | }, 1948 | { 1949 | "category": "SUBTYPE", 1950 | "name": "OFF TARGET", 1951 | "id": "29" 1952 | }, 1953 | { 1954 | "category": "SUBTYPE", 1955 | "name": "GOAL", 1956 | "id": "30" 1957 | }, 1958 | { 1959 | "category": "SUBTYPE", 1960 | "name": "OUT", 1961 | "id": "31" 1962 | }, 1963 | { 1964 | "category": "SUBTYPE", 1965 | "name": "FREE KICK", 1966 | "id": "32" 1967 | }, 1968 | { 1969 | "category": "SUBTYPE", 1970 | "name": "CORNER KICK", 1971 | "id": "33" 1972 | }, 1973 | { 1974 | "category": "SUBTYPE", 1975 | "name": "THROW IN", 1976 | "id": "34" 1977 | }, 1978 | { 1979 | "category": "SUBTYPE", 1980 | "name": "KICK OFF", 1981 | "id": "35" 1982 | }, 1983 | { 1984 | "category": "SUBTYPE", 1985 | "name": "PENALTY", 1986 | "id": "36" 1987 | }, 1988 | { 1989 | "category": "SUBTYPE", 1990 | "name": "DIRECT", 1991 | "id": "37" 1992 | }, 1993 | { 1994 | "category": "SUBTYPE", 1995 | "name": "INDIRECT", 1996 | "id": "38" 1997 | }, 1998 | { 1999 | "category": "SUBTYPE", 2000 | "name": "RETAKEN", 2001 | "id": "39" 2002 | }, 2003 | { 2004 | "category": "SUBTYPE", 2005 | "name": "YELLOW", 2006 | "id": "40" 2007 | }, 2008 | { 2009 | "category": "SUBTYPE", 2010 | "name": "RED", 2011 | "id": "41" 2012 | }, 2013 | { 2014 | "category": "SUBTYPE", 2015 | "name": "GROUND", 2016 | "id": "42" 2017 | }, 2018 | { 2019 | "category": "SUBTYPE", 2020 | "name": "AERIAL", 2021 | "id": "43" 2022 | }, 2023 | { 2024 | "category": "SUBTYPE", 2025 | "name": "TACKLE", 2026 | "id": "44" 2027 | }, 2028 | { 2029 | "category": "SUBTYPE", 2030 | "name": "DRIBBLE", 2031 | "id": "45" 2032 | }, 2033 | { 2034 | "category": "SUBTYPE", 2035 | "name": "FAULT", 2036 | "id": "46" 2037 | }, 2038 | { 2039 | "category": "SUBTYPE", 2040 | "name": "ADVANTAGE", 2041 | "id": "47" 2042 | }, 2043 | { 2044 | "category": "SUBTYPE", 2045 | "name": "WON", 2046 | "id": "48" 2047 | }, 2048 | { 2049 | "category": "SUBTYPE", 2050 | "name": "LOST", 2051 | "id": "49" 2052 | } 2053 | ] 2054 | } 2055 | -------------------------------------------------------------------------------- /codeball/tests/files/game_dataset.obj: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/metrica-sports/codeball/06161ac69f9b7b53e3820537e780ab962c4349e6/codeball/tests/files/game_dataset.obj -------------------------------------------------------------------------------- /codeball/tests/test_models.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import pandas as pd 4 | 5 | from kloppy import ( 6 | load_epts_tracking_data, 7 | to_pandas, 8 | load_metrica_json_event_data, 9 | load_xml_code_data, 10 | ) 11 | 12 | from codeball import ( 13 | GameDataset, 14 | DataType, 15 | TrackingFrame, 16 | EventsFrame, 17 | CodesFrame, 18 | PossessionsFrame, 19 | BaseFrame, 20 | Zones, 21 | Area, 22 | PatternEvent, 23 | Pattern, 24 | PatternsSet, 25 | ) 26 | 27 | import codeball.visualizations as vizs 28 | 29 | 30 | class TestModels: 31 | def test_pattern_event(self): 32 | 33 | xy = [0.3, 0.6] 34 | 35 | viz = vizs.Players( 36 | start_time=500, end_time=700, players=[], options=[] 37 | ) 38 | 39 | pattern_event = PatternEvent( 40 | pattern_code="MET_001", 41 | start_time=400, 42 | event_time=500, 43 | end_time=800, 44 | coordinates=[xy, xy], 45 | visualizations=[viz, viz], 46 | tags=["T001"], 47 | ) 48 | 49 | assert pattern_event.end_time == 800 50 | assert pattern_event.coordinates[0][0] == 0.3 51 | assert pattern_event.visualizations[0].start_time == 500 52 | 53 | def test_pattern(self): 54 | class pattern_class(Pattern): 55 | def __init__( 56 | self, 57 | name: str, 58 | code: str, 59 | in_time: int = 0, 60 | out_time: int = 0, 61 | parameters: dict = None, 62 | game_dataset: GameDataset = None, 63 | ): 64 | super().__init__( 65 | name, code, in_time, out_time, parameters, game_dataset 66 | ) 67 | 68 | def run(self): 69 | return True 70 | 71 | def build_pattern_event(self): 72 | pass 73 | 74 | test_pattern = pattern_class( 75 | name="Test Pattern", 76 | code="MET_001", 77 | in_time=3, 78 | out_time=2, 79 | parameters=None, 80 | game_dataset=None, 81 | ) 82 | 83 | assert test_pattern.in_time == 3 84 | assert test_pattern.run() is True 85 | 86 | def test_game_dataset(self): 87 | 88 | base_dir = os.path.dirname(__file__) 89 | 90 | game_dataset = GameDataset( 91 | tracking_metadata_file=f"{base_dir}/files/metadata.xml", 92 | tracking_data_file=f"{base_dir}/files/tracking.txt", 93 | events_metadata_file=f"{base_dir}/files/metadata.xml", 94 | events_data_file=f"{base_dir}/files/events.json", 95 | ) 96 | 97 | assert game_dataset.tracking.data_type == DataType.TRACKING 98 | assert game_dataset.events.data_type == DataType.EVENT 99 | 100 | def test_tracking_game_dataset(self): 101 | 102 | base_dir = os.path.dirname(__file__) 103 | 104 | game_dataset = GameDataset( 105 | tracking_metadata_file=f"{base_dir}/files/metadata.xml", 106 | tracking_data_file=f"{base_dir}/files/tracking.txt", 107 | ) 108 | 109 | assert game_dataset.tracking.data_type == DataType.TRACKING 110 | assert game_dataset.has_event_data is False 111 | 112 | def test_codes_only_game_dataset(self): 113 | 114 | base_dir = os.path.dirname(__file__) 115 | 116 | game_dataset = GameDataset( 117 | codes_files=f"{base_dir}/files/code_xml.xml", 118 | ) 119 | 120 | assert game_dataset.codes[0].data_type == DataType.CODE 121 | assert game_dataset.has_event_data is False 122 | 123 | def test_pattern_set(self): 124 | 125 | base_dir = os.path.dirname(__file__) 126 | 127 | game_dataset = GameDataset( 128 | tracking_metadata_file=f"{base_dir}/files/metadata.xml", 129 | tracking_data_file=f"{base_dir}/files/tracking.txt", 130 | events_metadata_file=f"{base_dir}/files/metadata.xml", 131 | events_data_file=f"{base_dir}/files/events.json", 132 | ) 133 | 134 | class pattern_class(Pattern): 135 | def __init__( 136 | self, 137 | name: str, 138 | code: str, 139 | in_time: int = 0, 140 | out_time: int = 0, 141 | parameters: dict = None, 142 | game_dataset: GameDataset = None, 143 | ): 144 | super().__init__( 145 | name, code, in_time, out_time, parameters, game_dataset 146 | ) 147 | 148 | def run(self): 149 | return True 150 | 151 | def build_pattern_event(self): 152 | pass 153 | 154 | test_pattern = pattern_class( 155 | name="Test Pattern", 156 | code="MET_001", 157 | in_time=3, 158 | out_time=2, 159 | parameters=None, 160 | game_dataset=game_dataset, 161 | ) 162 | 163 | patterns_set = PatternsSet(game_dataset=game_dataset) 164 | patterns_set.patterns = [test_pattern, test_pattern] 165 | 166 | assert patterns_set.game_dataset.events.data_type == DataType.EVENT 167 | assert len(patterns_set.patterns) == 2 168 | 169 | def test_base_data_frame(self): 170 | data = { 171 | "player1_x": [1, 2, 3, 4], 172 | "player2_x": [5, 6, 7, 8], 173 | "player3_x": [9, 10, 11, 12], 174 | } 175 | base_df = BaseFrame(data) 176 | base_df.metadata = "metadata" 177 | base_df.records = [1, 2, 3, 4] 178 | base_df.data_type = "test" 179 | 180 | assert isinstance(base_df, BaseFrame) 181 | assert hasattr(base_df, "metadata") 182 | assert hasattr(base_df, "records") 183 | 184 | assert isinstance(base_df[["player1_x", "player2_x"]], BaseFrame) 185 | assert hasattr(base_df[["player1_x", "player2_x"]], "metadata") 186 | assert not hasattr(base_df[["player1_x", "player2_x"]], "records") 187 | 188 | def test_tracking_data_frame(self): 189 | 190 | base_dir = os.path.dirname(__file__) 191 | 192 | tracking_dataset = load_epts_tracking_data( 193 | metadata_filename=f"{base_dir}/files/metadata.xml", 194 | raw_data_filename=f"{base_dir}/files/tracking.txt", 195 | ) 196 | tracking = TrackingFrame(to_pandas(tracking_dataset)) 197 | tracking.data_type = DataType.TRACKING 198 | tracking.metadata = tracking_dataset.metadata 199 | tracking.records = tracking_dataset.records 200 | 201 | assert tracking.get_team_by_id("FIFATMA").team_id == "FIFATMA" 202 | assert tracking.get_period_by_id(1).id == 1 203 | assert tracking.get_other_team_id("FIFATMA") == "FIFATMB" 204 | assert tracking.team("FIFATMA").shape[1] == 22 205 | assert tracking.dimension("x").shape[1] == 23 206 | assert tracking.players().shape[1] == 44 207 | assert tracking.players("field").shape[1] == 40 208 | assert sum(tracking.phase(defending_team_id="FIFATMA")) == 0 209 | assert sum(tracking.team("FIFATMA").stretched(90)) == 863 210 | 211 | def test_events_data_frame(self): 212 | 213 | base_dir = os.path.dirname(__file__) 214 | 215 | events_dataset = load_metrica_json_event_data( 216 | metadata_filename=f"{base_dir}/files/metadata.xml", 217 | raw_data_filename=f"{base_dir}/files/events.json", 218 | ) 219 | events = EventsFrame(to_pandas(events_dataset)) 220 | events.data_type = DataType.EVENT 221 | events.metadata = events_dataset.metadata 222 | events.records = events_dataset.records 223 | 224 | assert events.type("PASS").shape[0] == 26 225 | assert events.result("COMPLETE").shape[0] == 45 226 | assert events.into(Zones.OPPONENT_BOX).shape[0] == 1 227 | assert events.starts_inside(Zones.OPPONENT_BOX).shape[0] == 2 228 | assert events.ends_inside(Zones.OPPONENT_BOX).shape[0] == 2 229 | assert events.ends_outside(Zones.OPPONENT_BOX).shape[0] == 43 230 | 231 | # Test diferent ways to input Zones and areas 232 | 233 | custom_area = Area((0.25, 0.2), (0.75, 0.8)) 234 | 235 | assert ( 236 | events.ends_outside(Zones.OPPONENT_BOX, Zones.OWN_BOX).shape[0] 237 | == 45 238 | ) 239 | assert ( 240 | events.ends_inside(Zones.OPPONENT_BOX, custom_area).shape[0] == 14 241 | ) 242 | assert events.ends_inside(custom_area, custom_area).shape[0] == 12 243 | 244 | def test_codes_data_frame(self): 245 | 246 | base_dir = os.path.dirname(__file__) 247 | 248 | codes_dataset = load_xml_code_data( 249 | xml_filename=f"{base_dir}/files/code_xml.xml", 250 | ) 251 | 252 | codes = CodesFrame(to_pandas(codes_dataset)) 253 | codes.data_type = DataType.CODE 254 | codes.metadata = codes_dataset.metadata 255 | codes.records = codes_dataset.records 256 | 257 | assert len(codes.records) == 3 258 | -------------------------------------------------------------------------------- /codeball/tests/test_patterns.py: -------------------------------------------------------------------------------- 1 | import pickle 2 | import os 3 | from codeball import GameDataset, Pattern 4 | from codeball import TeamStretched, PassesIntoTheBox, SetPieces 5 | 6 | 7 | class TestPatterns: 8 | def setup_class(self): 9 | base_dir = os.path.dirname(__file__) 10 | 11 | self.game_dataset = GameDataset( 12 | tracking_metadata_file=f"{base_dir}/files/metadata.xml", 13 | tracking_data_file=f"{base_dir}/files/tracking.txt", 14 | events_metadata_file=f"{base_dir}/files/metadata.xml", 15 | events_data_file=f"{base_dir}/files/events.json", 16 | ) 17 | 18 | def test_team_stretched(self): 19 | parameters = {"team_code": "FIFATMA", "threshold": 40} 20 | team_stretched = TeamStretched( 21 | game_dataset=self.game_dataset, 22 | name="Test Team Stretched", 23 | code="TEST_001", 24 | parameters=parameters, 25 | ) 26 | events = team_stretched.run() 27 | 28 | assert "events" in locals() 29 | 30 | def test_set_pieces(self): 31 | set_pieces = SetPieces( 32 | game_dataset=self.game_dataset, name="Set Pieces", code="TEST_002" 33 | ) 34 | events = set_pieces.run() 35 | 36 | assert "events" in locals() 37 | 38 | def test_passes_into_the_box(self): 39 | passes_into_the_box = PassesIntoTheBox( 40 | game_dataset=self.game_dataset, 41 | name="Passes into the box", 42 | code="TEST_003", 43 | ) 44 | events = passes_into_the_box.run() 45 | 46 | assert "events" in locals() 47 | -------------------------------------------------------------------------------- /codeball/tests/test_visualizations.py: -------------------------------------------------------------------------------- 1 | import codeball.visualizations as vizs 2 | import pytest 3 | 4 | 5 | class TestVisualizations: 6 | def test_players(self): 7 | 8 | with pytest.raises(TypeError): 9 | vizs.Players(50, 100) 10 | 11 | viz = vizs.Players( 12 | start_time=500, end_time=700, players=["P001", "P002"] 13 | ) 14 | 15 | assert viz.start_time == 500 16 | assert len(viz.players) == 2 17 | assert viz.options["id"] is True 18 | 19 | def test_trails(self): 20 | with pytest.raises(TypeError): 21 | vizs.Trails(50, 100) 22 | 23 | viz = vizs.Trails( 24 | start_time=500, end_time=1000, players=["P001", "P002"] 25 | ) 26 | 27 | assert viz.start_time == 500 28 | assert viz.end_time == 1000 29 | assert len(viz.players) == 2 30 | assert viz.options["width"] == 0.24 31 | 32 | def test_future_trails(self): 33 | with pytest.raises(TypeError): 34 | vizs.FutureTrails(50, 100) 35 | 36 | viz = vizs.FutureTrails( 37 | start_time=500, end_time=1000, players=["P001", "P002"] 38 | ) 39 | 40 | assert viz.start_time == 500 41 | assert viz.end_time == 1000 42 | assert len(viz.players) == 2 43 | assert viz.options["width"] == 0.24 44 | 45 | def test_magnifiers(self): 46 | with pytest.raises(TypeError): 47 | vizs.Magnifiers(50, 100) 48 | 49 | viz = vizs.Magnifiers( 50 | start_time=500, end_time=1000, players=["P001", "P002"] 51 | ) 52 | 53 | assert viz.start_time == 500 54 | assert viz.end_time == 1000 55 | assert len(viz.players) == 2 56 | assert viz.options["size"] == 1 57 | 58 | def test_measurer(self): 59 | with pytest.raises(TypeError): 60 | vizs.Measurer(50, 100) 61 | 62 | viz = vizs.Measurer( 63 | start_time=500, end_time=1000, players=["P001", "P002"] 64 | ) 65 | 66 | assert viz.start_time == 500 67 | assert viz.end_time == 1000 68 | assert len(viz.players) == 2 69 | assert viz.options["closed"] is False 70 | 71 | def test_team_size(self): 72 | with pytest.raises(TypeError): 73 | vizs.TeamSize(50, 100) 74 | 75 | viz = vizs.TeamSize(start_time=500, end_time=1000, team="T001") 76 | 77 | assert viz.start_time == 500 78 | assert viz.end_time == 1000 79 | assert viz.options["color"] == "#683391" 80 | 81 | def test_tactical_lines(self): 82 | with pytest.raises(TypeError): 83 | vizs.TacticalLines(50, 100) 84 | 85 | viz = vizs.TacticalLines(start_time=500, end_time=1000, team="T001") 86 | 87 | assert viz.start_time == 500 88 | assert viz.end_time == 1000 89 | assert viz.options["borderColor"] == "#ffffff" 90 | 91 | def test_pause(self): 92 | 93 | viz = vizs.Pause(start_time=500, end_time=500) 94 | 95 | assert viz.pause_time == 5000 96 | 97 | def test_chroma_key(self): 98 | 99 | viz = vizs.ChromaKey() 100 | 101 | assert viz.options["smoothing"] == 0.1 102 | 103 | def test_arrow(self): 104 | viz = vizs.Arrow( 105 | start_time=500, 106 | end_time=1000, 107 | points={ 108 | "start": {"x": 0.5, "y": 0.5}, 109 | "end": {"x": 0.8, "y": 0.9}, 110 | }, 111 | options={"pinned": True, "width": 0.3}, 112 | ) 113 | 114 | assert viz.options["pinned"] is True 115 | -------------------------------------------------------------------------------- /codeball/utils/__init__.py: -------------------------------------------------------------------------------- 1 | from .json_encoders import * 2 | -------------------------------------------------------------------------------- /codeball/utils/json_encoders.py: -------------------------------------------------------------------------------- 1 | import json 2 | from dataclasses import is_dataclass 3 | 4 | 5 | class DataClassEncoder(json.JSONEncoder): 6 | def default(self, obj): 7 | if is_dataclass(obj): 8 | return obj.__dict__ 9 | 10 | return json.JSONEncoder.default(self, obj) 11 | -------------------------------------------------------------------------------- /codeball/visualizations.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | from typing import Optional, List, Dict 3 | 4 | 5 | @dataclass # Base Visualization Data Class 6 | class Visualization: 7 | start_time: int 8 | end_time: int 9 | # tool_id: str 10 | # options: Optional[Dict] = field(default_factory=dict) 11 | 12 | 13 | # Players 14 | @dataclass 15 | class Players(Visualization): 16 | players: List[str] 17 | tool_id: str = "players" 18 | options: Dict = field( 19 | default_factory=lambda: { 20 | "id": True, 21 | "speed": True, 22 | "size": 1.0, # [0.2, 2.5] 23 | "color": "#000000", 24 | "boxPositionDown": False, 25 | "spotlight": False, 26 | "spotlightSize": 0.5, # Multiplier [0.2, 4.0] 27 | "spotlightColor": "#FFFFFF", 28 | "spotlightOpacity": 0.43, # [0.0, 1.0] 29 | "spotlightHeight": 2.0, # [0.1, 10.0] 30 | "ringSize": 0.73, 31 | "ringBorder": False, 32 | "ringBorderColor": "#FFFFFF", 33 | "ringFill": False, 34 | "ringFillColor": "#DC3322", 35 | "is3d": True, 36 | } 37 | ) 38 | 39 | 40 | @dataclass 41 | class Spotlight(Visualization): 42 | players: List[str] 43 | tool_id: str = "players" 44 | options: Dict = field( 45 | default_factory=lambda: { 46 | "id": False, 47 | "speed": False, 48 | "size": 1.0, # [0.2, 2.5] 49 | "color": "#000000", 50 | "boxPositionDown": False, 51 | "spotlight": True, 52 | "spotlightSize": 0.5, # Multiplier [0.2, 4.0] 53 | "spotlightColor": "#FFFFFF", 54 | "spotlightOpacity": 0.43, # [0.0, 1.0] 55 | "spotlightHeight": 2.0, # [0.1, 10.0] 56 | "ringSize": 0.73, 57 | "ringBorder": False, 58 | "ringBorderColor": "#FFFFFF", 59 | "ringFill": False, 60 | "ringFillColor": "#DC3322", 61 | "is3d": True, 62 | } 63 | ) 64 | 65 | 66 | @dataclass 67 | class Ring(Visualization): 68 | players: List[str] 69 | tool_id: str = "players" 70 | options: Dict = field( 71 | default_factory=lambda: { 72 | "id": False, 73 | "speed": False, 74 | "size": 1.0, # [0.2, 2.5] 75 | "color": "#000000", 76 | "boxPositionDown": False, 77 | "spotlight": False, 78 | "spotlightSize": 0.5, # Multiplier [0.2, 4.0] 79 | "spotlightColor": "#FFFFFF", 80 | "spotlightOpacity": 0.43, # [0.0, 1.0] 81 | "spotlightHeight": 2.0, # [0.1, 10.0] 82 | "ringSize": 0.73, 83 | "ringBorder": True, 84 | "ringBorderColor": "#FFFFFF", 85 | "ringFill": True, 86 | "ringFillColor": "#DC3322", 87 | "is3d": True, 88 | } 89 | ) 90 | 91 | 92 | # Trails 93 | @dataclass 94 | class Trails(Visualization): 95 | players: List[str] 96 | tool_id: str = "trails" 97 | options: Dict = field( 98 | default_factory=lambda: { 99 | "color": "#0062ad", 100 | "continuous": True, 101 | "dotted": False, 102 | "dashSize": 1.0, # Multiplier [0.2, 2.5]. Only Dotted 103 | "is3d": True, 104 | "ringBorder": True, 105 | "offsetOpacity": 0.26, # [0.0, 1.0] 106 | "opacity": 1.0, # [0.0, 1.0] 107 | "ringBorderColor": "#ffffff", 108 | "ringFill": True, 109 | "ringFillColor": "#009cdd", 110 | "ringSize": 1.0, # Multiplier [0.6, 4.0] 111 | "seconds": 5.0, # [1.0, 99.0] 112 | "thickness": 0.1, # Multiplier [0.1, 5.0]. Only in 3D 113 | "width": 0.24, # Multiplier [0.1, 2.0] 114 | } 115 | ) 116 | 117 | 118 | # FutureTrails 119 | @dataclass 120 | class FutureTrails(Visualization): 121 | players: List[str] 122 | tool_id: str = "futureTrails" 123 | options: Dict = field( 124 | default_factory=lambda: { 125 | "color": "#ff9e2d", 126 | "continuous": True, 127 | "dotted": False, 128 | "dashSize": 1.0, # Multiplier [0.2, 2.5]. Only Dotted 129 | "is3d": True, 130 | "ringBorder": True, 131 | "offsetOpacity": 0.26, # [0.0, 1.0] 132 | "opacity": 1.0, # [0.0, 1.0] 133 | "ringBorderColor": "#ffffff", 134 | "ringFill": True, 135 | "ringFillColor": "#ffdc3a", 136 | "ringSize": 1.0, # Multiplier [0.6, 4.0] 137 | "seconds": 5.0, # [1.0, 99.0] 138 | "thickness": 0.1, # Multiplier [0.1, 5.0]. Only in 3D 139 | "width": 0.24, # Multiplier [0.1, 2.0] 140 | } 141 | ) 142 | 143 | 144 | # Magnifiers 145 | @dataclass 146 | class Magnifiers(Visualization): 147 | players: List[str] 148 | tool_id: str = "magnifiers" 149 | options: Dict = field( 150 | default_factory=lambda: { 151 | "color": "#ffffff", 152 | "zoom": 1.0, # Multiplier, [0.2, 1.5] 153 | "size": 1.0, # Multiplier, [0.5, 1.5] 154 | } 155 | ) 156 | 157 | 158 | # Measurer 159 | @dataclass 160 | class Measurer(Visualization): 161 | players: List[str] 162 | tool_id: str = "measurer" 163 | options: Dict = field( 164 | default_factory=lambda: { 165 | "borderColor": "#dc3322", 166 | "borderEdgeOpacity": 0.4, # [0.0, 1.0] 167 | "borderOpacity": 0.9, # [0.0, 1.0] 168 | "closed": False, 169 | "continuous": True, 170 | "dashSize": 1.45, # Multiplier [0.2, 2.5]. Only Dotted 171 | "distance": True, 172 | "distanceColor": "#ffffff", 173 | "distanceIs3d": True, 174 | "distancePosition": 0.92, # Multiplier [0.5, 2.0] 175 | "distanceOpacity": 1.0, # [0.0, 1.0] 176 | "distanceSize": 1.01, # Multiplier [0.5, 1.5] 177 | "dotted": False, 178 | "fillColor": "#dc3322", # Only Closed 179 | "fillOpacity": 0.42, # [0.0, 1.0] 180 | "is3d": True, 181 | "ringBorder": True, 182 | "ringBorderColor": "#ffffff", 183 | "ringFill": True, 184 | "ringFillColor": "#dc3322", 185 | "ringSize": 0.91, # Multiplier [0.6, 4.0] 186 | "thickness": 0.13, # Multiplier [0.0, 5.0]. Only in 3D 187 | "width": 0.23, # Multiplier [0.15, 2.0] 188 | } 189 | ) 190 | 191 | 192 | # TeamSize 193 | @dataclass 194 | class TeamSize(Visualization): 195 | team: str 196 | line: str = "width" # Values: 'width' or 'length' 197 | tool_id: str = "teamSize" 198 | options: Dict = field( 199 | default_factory=lambda: { 200 | "continuous": True, 201 | "dotted": False, 202 | "color": "#683391", 203 | "x": 0.0, 204 | "y": 0.0, 205 | "width": 0.23, # [0.1, 5.0] 206 | "edgeOpacity": 0.0, # [0.0, 1.0] 207 | "opacity": 1.0, # [0.0, 1.0] 208 | "thickness": 0.22, # Multiplier [0.0, 5.0]. Only in 3D 209 | "dashSize": 0.6, # Multiplier [0.2, 2.5]. Only Dotted 210 | "distance": True, 211 | "distanceColor": "#ffffff", 212 | "distancePosition": 1.12, # [0.5, 2.0] 213 | "distanceOpacity": 1.0, # Multiplier [0.0, 1.0] 214 | "distanceSize": 1.3, # Multiplier [0.5, 1.5] 215 | "distanceIs3d": True, 216 | "is3d": True, 217 | } 218 | ) 219 | 220 | 221 | # TacticalLines 222 | @dataclass 223 | class TacticalLines(Visualization): 224 | team: str 225 | line: str = "defenders" # Values: 'defenders', 'midfielders' or 'strikers' 226 | tool_id: str = "tacticalLines" 227 | options: Dict = field( 228 | default_factory=lambda: { 229 | "borderColor": "#ffffff", 230 | "borderEdgeOpacity": 0.3, # [0.1, 1.0] 231 | "borderOpacity": 1.0, # [0.0, 1.0] 232 | "fillColor": "#ffffff", 233 | "fillOpacity": 0.4, # [0.0, 1.0] 234 | "closed": False, # Only used when line is 'midfielders' 235 | "width": 0.23, # [0.1, 2.0] 236 | "thickness": 0.3, # Multiplier [0.0, 5.0]. Only in 3D 237 | "dashSize": 1.0, # Multiplier [0.2, 2.5]. Only Dotted 238 | "continuous": True, 239 | "dotted": False, 240 | "is3d": True, 241 | "ringBorder": True, 242 | "ringBorderColor": "#ffffff", 243 | "ringFill": True, 244 | "ringFillColor": "#ffffff", 245 | "ringSize": 0.6, # [0.6, 4.0] 246 | "distance": True, 247 | "distanceColor": "#ffffff", 248 | "distancePosition": 0.5, # Multiplier [0.5, 2.0] 249 | "distanceOpacity": 1.0, # [0.0, 1.0] 250 | "distanceSize": 0.73, # [0.5, 1.5] 251 | "distanceIs3d": True, 252 | } 253 | ) 254 | 255 | 256 | # Pause 257 | @dataclass 258 | class Pause(Visualization): 259 | pause_time: float = 5000 # Milliseconds 260 | tool_id: str = "pause" 261 | 262 | 263 | # ChromaKey 264 | @dataclass 265 | class ChromaKey: 266 | tool_id: str = "chromaKey" 267 | options: Dict = field( 268 | default_factory=lambda: { 269 | "threshold": 0.01, # [0.0, 1.0] 270 | "smoothing": 0.1, # [0.0, 1.0] 271 | } 272 | ) 273 | 274 | 275 | @dataclass 276 | class Arrow(Visualization): 277 | tool_id: str = "arrow" 278 | points: Dict = field( 279 | default_factory=lambda: { 280 | "start": {"x": 0.0, "y": 0.0}, 281 | "end": {"x": 0.0, "y": 0.0}, 282 | } 283 | ) 284 | options: Dict = field( 285 | default_factory=lambda: { 286 | "arrowheadWidth": 1.5, # [0.99, 2.0] 287 | "color": "#ff4f43", 288 | "continuous": True, 289 | "curvature": 0.0, # Multiplier [-1.0, 1.0]. Only in 3D 290 | "dashSize": 0.4, # Multiplier [0.2, 2.5]. Only Dotted 291 | "distance": False, 292 | "distanceColor": "#ffffff", 293 | "distancePosition": 0.92, # Multiplier [0.5, 2.0] 294 | "distanceOpacity": 1.0, # [0.0, 1.0] 295 | "distanceSize": 1.01, # [0.5, 4.0] 296 | "distanceIs3d": True, 297 | "dotted": False, 298 | "dynamic": False, 299 | "edgeOpacity": 0.2, # [0.0, 1.0] 300 | "opacity": 0.9, # [0.0, 1.0] 301 | "height": 0.0, # [0.0, 0.15] 302 | "heightCenter": 0.0, # [-1.0, 1.0] 303 | "is3d": True, 304 | "pinned": False, 305 | "thickness": 0.15, # Multiplier [0.0, 5.0]. Only in 3D 306 | "width": 0.5, # [0.1, 5.0] 307 | } 308 | ) 309 | 310 | # TODO Add other visualizations not here, specially shape. 311 | -------------------------------------------------------------------------------- /docs/CNAME: -------------------------------------------------------------------------------- 1 | codeball.metrica-sports.com -------------------------------------------------------------------------------- /docs/changelog.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 0.1.1 (2020-10-19) 4 | - More Zones (before only one was defined) 5 | - Users can now define their own Areas as well. 6 | - Methods that use to take only one Zone, now can take several Zones, Areas, or a combination of both. 7 | - We change the way the Zones enum worked. Now the Areas (before called boxes) are the value of the type. 8 | - We changes Box, by a new class Area, which has an area type as attribute. 9 | 10 | -------------------------------------------------------------------------------- /docs/codeball-frames.md: -------------------------------------------------------------------------------- 1 | # What's a CodeballFrame? 2 | 3 | CodeballFrames are subclasses of Pandas DataFrames. They have all the methods of a 4 | standard DataFrame, but have additional methods and attributes that make it easier 5 | to work, filter, and handle data from the game. The base class is `BaseFrame`, and 6 | the main CodeballFrame classes are `TrackingFrame` and `EventsFrame`. An important 7 | aspect of CodeballFrame, as it is the case for DataFrames, is that methods can be chained. 8 | 9 | ## BaseFrame 10 | 11 | ### Attributes / properties 12 | 13 | All CodeballFrame have 3 attributes: 14 | 15 | * records: this holds the records from the datasets created by Kloppy when reading in the 16 | data. This attribute is not passed to the result of any action that modifies the data frame. 17 | * metadata: this holds the metadata from the datasets created by Kloppy when reading in 18 | the data. This attribute is preserved when filtering or modifying the data frame. 19 | However is lost if the result is a series. 20 | * data_type: this indicates what type of data the data frame holds. For example 21 | `DataType.TRACKING` or `DataType.EVENT` 22 | 23 | ### Methods 24 | 25 | * ***get_team_by_id*** 26 | * ***get_period_by_id*** 27 | * ***get_other_team_id*** 28 | 29 | ## TrackingFrame 30 | 31 | A CodeballFrame that holds tracking data. 32 | 33 | ### Methods 34 | 35 | * ***team()***: `TrackingFrame.team(team_id)` will return a TrackingFrame only 36 | containing the columns that have data for team with id team_id. 37 | * ***dimension()***: `TrackingFrame.dimension('x')` will return a TrackingFrame only 38 | containing the columns with data on the x axis. 39 | * ***players()***: `TrackingFrame.players('field')` will return a TrackingFrame only 40 | containing the columns with data for the field players (excluding goalkeeper). 41 | `TrackingFrame.players()` will return a TrackingFrame containing the data for all players 42 | but drop all the other columns. 43 | * ***phase()***: `TrackingFrame.phase(defending_team=team_id)` will return a Series 44 | with True on the frames the team with id `team_id` was defending and False otherwise. 45 | * ***stretched()***: `TrackingFrame.stretched(50)` will return a Series 46 | with True on the frames the stretched of the values in the columns of the TrackingFrame 47 | is higher than the threshold. 48 | 49 | Since all these methods can be chained, is you want to get the x coordinates of field players 50 | for team with id `team_id`, you can do: 51 | ```python 52 | TrackingDataFrame.team(team_id).players(field).dimension('x') 53 | ``` 54 | 55 | ## EventsFrame 56 | 57 | A CodeballFrame that holds event data. 58 | 59 | ### Methods 60 | 61 | * ***type()***: This allows to filter by the`type` column. `EventsFrame.type('PASS')` 62 | will return a EventsFrame only containing the rows that correspond to `PASS` type events. 63 | * ***result()***: This allows to filter by the`result` column. `EventsFrame.result('COMPLETE')` 64 | will return a EventsFrame only containing the events with result `COMPLETE` 65 | * ***into()***, ***starts_inside()***, ***starts_outside()***, ***ends_inside()***, 66 | ***ends_outside()***: are all similar. They take one or more `Zones` or `Area`(see the [tactical](../tactical) section) and 67 | filter events depending on whether they start, end, etc in that `Zones`. Zones will be able 68 | to be defined by the user, or use any of the ones defined in the package (link to Tactical) 69 | module to be added later to the documentation. 70 | 71 | These methods can also be chained, so if you wanted to filter by completed passes into the 72 | opponents box you can do: 73 | ```python 74 | EventsFrame.type("PASS").into(Zones.OPPONENT_BOX).result("COMPLETE") 75 | ``` 76 | 77 | ## CodesFrame 78 | 79 | A CodeballFrame that holds codes from an xml file. 80 | 81 | The columns present on this CodeballFrame will depend on how your XML is format, but as a general rule you'll 82 | see one row per `code`, with columns for the `code_id`, `timestamp`, `end_timestamp`, `code` (name) and then one column for each tag. -------------------------------------------------------------------------------- /docs/examples/example-pattern.md: -------------------------------------------------------------------------------- 1 | As an example, the below code defines a pattern that will look for all passes into the opponents box. Moreover to be imported into Metrica Play, it will add an arrow and a 2s pause in the video at the moment of the pass, and will add an arrow to the 2D field indicating start and end position of the pass. 2 | 3 | ```python 4 | class PassesIntoTheBox(Pattern): 5 | def __init__( 6 | self, 7 | game_dataset: GameDataset, 8 | name: str, 9 | code: str, 10 | in_time: int = 0, 11 | out_time: int = 0, 12 | parameters: dict = None, 13 | ): 14 | super().__init__( 15 | name, code, in_time, out_time, parameters, game_dataset 16 | ) 17 | 18 | def run(self) -> List[PatternEvent]: 19 | 20 | passes_into_the_box = ( 21 | self.game_dataset.events.type("PASS") 22 | .into(Zones.OPPONENT_BOX) 23 | .result("COMPLETE") 24 | ) 25 | 26 | return [ 27 | self.build_pattern_event(event_row) 28 | for i, event_row in passes_into_the_box.iterrows() 29 | ] 30 | 31 | def build_pattern_event(self, event_row) -> PatternEvent: 32 | pattern_event = self.from_event(event_row) 33 | pattern_event.add_arrow(event_row) 34 | pattern_event.add_pause(pause_time=2000) 35 | 36 | return pattern_event 37 | ``` 38 | 39 | With this configuration: 40 | 41 | ```json 42 | { 43 | "include": true, 44 | "name": "Passes into the box", 45 | "code": "MET_003", 46 | "pattern_class": "PassesIntoTheBox", 47 | "parameters": null, 48 | "in_time": 2, 49 | "out_time": 2 50 | } 51 | ``` 52 | 53 | Produces this output when imported into Metrica Play: 54 | 55 |

56 | 57 |

-------------------------------------------------------------------------------- /docs/examples/game_dataset.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "metadata": { 3 | "language_info": { 4 | "codemirror_mode": { 5 | "name": "ipython", 6 | "version": 3 7 | }, 8 | "file_extension": ".py", 9 | "mimetype": "text/x-python", 10 | "name": "python", 11 | "nbconvert_exporter": "python", 12 | "pygments_lexer": "ipython3", 13 | "version": "3.8.2-final" 14 | }, 15 | "orig_nbformat": 2, 16 | "kernelspec": { 17 | "name": "python_defaultSpec_1599830137598", 18 | "display_name": "Python 3.8.2 64-bit ('env': venv)" 19 | } 20 | }, 21 | "nbformat": 4, 22 | "nbformat_minor": 2, 23 | "cells": [ 24 | { 25 | "source": [ 26 | "# Initialize a GameDataset and some sample operations\n", 27 | "\n", 28 | "A GameDatset instance can contain tracking and events data in a TrackingFrame and EventsFrame respectively. " 29 | ], 30 | "cell_type": "markdown", 31 | "metadata": {} 32 | }, 33 | { 34 | "cell_type": "code", 35 | "execution_count": 2, 36 | "metadata": { 37 | "tags": [] 38 | }, 39 | "outputs": [ 40 | { 41 | "output_type": "stream", 42 | "name": "stdout", 43 | "text": "\n\n" 44 | } 45 | ], 46 | "source": [ 47 | "import sys\n", 48 | "sys.path.insert(1, '../../')\n", 49 | "\n", 50 | "from codeball import GameDataset, Zones\n", 51 | "\n", 52 | "metadata_file = (r\"../../codeball/tests/files/metadata.xml\")\n", 53 | "tracking_file = (r\"../../codeball/tests/files/tracking.txt\")\n", 54 | "events_file = (r\"../../codeball/tests/files/events.json\")\n", 55 | "\n", 56 | "game_dataset = GameDataset(\n", 57 | " tracking_metadata_file=metadata_file,\n", 58 | " tracking_data_file=tracking_file,\n", 59 | " events_metadata_file=metadata_file,\n", 60 | " events_data_file=events_file,\n", 61 | ")\n", 62 | "\n", 63 | "print(type(game_dataset.tracking))\n", 64 | "print(type(game_dataset.events))" 65 | ] 66 | }, 67 | { 68 | "cell_type": "markdown", 69 | "metadata": {}, 70 | "source": [ 71 | "## Tracking\n", 72 | "\n", 73 | "GameDataset.tracking holds a TrackingFrame with all the tacking data of the game. " 74 | ] 75 | }, 76 | { 77 | "cell_type": "code", 78 | "execution_count": 7, 79 | "metadata": {}, 80 | "outputs": [ 81 | { 82 | "output_type": "execute_result", 83 | "data": { 84 | "text/plain": " period_id timestamp ball_state ball_owning_team_id ball_x ball_y \\\n0 1 0.04 None None NaN NaN \n1 1 0.08 None None NaN NaN \n2 1 0.12 None None NaN NaN \n3 1 0.16 None None NaN NaN \n4 1 0.20 None None NaN NaN \n\n P3578_x P3578_y P3568_x P3568_y ... P3590_x P3590_y P3591_x \\\n0 0.84722 0.52855 0.65268 0.24792 ... 0.41381 0.52790 0.41787 \n1 0.84722 0.52855 0.65231 0.24513 ... 0.41375 0.52780 0.41719 \n2 0.84722 0.52855 0.65197 0.24387 ... 0.41371 0.52906 0.41697 \n3 0.84722 0.52855 0.65166 0.24288 ... 0.41370 0.53056 0.41685 \n4 0.84722 0.52855 0.65141 0.24251 ... 0.41369 0.53151 0.41669 \n\n P3591_y P3592_x P3592_y P3593_x P3593_y P3594_x P3594_y \n0 0.48086 0.41215 0.36689 0.47050 0.73219 0.48864 0.36357 \n1 0.47864 0.41132 0.36169 0.47040 0.73204 0.48834 0.36362 \n2 0.47824 0.41131 0.36072 0.47075 0.73229 0.48814 0.36372 \n3 0.47815 0.41117 0.35930 0.47118 0.73266 0.48793 0.36278 \n4 0.47749 0.41120 0.35910 0.47163 0.73287 0.48784 0.36240 \n\n[5 rows x 50 columns]", 85 | "text/html": "
\n\n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
period_idtimestampball_stateball_owning_team_idball_xball_yP3578_xP3578_yP3568_xP3568_y...P3590_xP3590_yP3591_xP3591_yP3592_xP3592_yP3593_xP3593_yP3594_xP3594_y
010.04NoneNoneNaNNaN0.847220.528550.652680.24792...0.413810.527900.417870.480860.412150.366890.470500.732190.488640.36357
110.08NoneNoneNaNNaN0.847220.528550.652310.24513...0.413750.527800.417190.478640.411320.361690.470400.732040.488340.36362
210.12NoneNoneNaNNaN0.847220.528550.651970.24387...0.413710.529060.416970.478240.411310.360720.470750.732290.488140.36372
310.16NoneNoneNaNNaN0.847220.528550.651660.24288...0.413700.530560.416850.478150.411170.359300.471180.732660.487930.36278
410.20NoneNoneNaNNaN0.847220.528550.651410.24251...0.413690.531510.416690.477490.411200.359100.471630.732870.487840.36240
\n

5 rows × 50 columns

\n
" 86 | }, 87 | "metadata": {}, 88 | "execution_count": 7 89 | } 90 | ], 91 | "source": [ 92 | "game_dataset.tracking.head()" 93 | ] 94 | }, 95 | { 96 | "cell_type": "markdown", 97 | "metadata": {}, 98 | "source": [ 99 | "If you want to filter the TrackingFrame, you can use it's methods (on top of all standard DataFrame methods). For example to get a TrackingFrame only with the data of team with team_id `FIFATMA` you can do:" 100 | ] 101 | }, 102 | { 103 | "cell_type": "code", 104 | "execution_count": 8, 105 | "metadata": {}, 106 | "outputs": [ 107 | { 108 | "output_type": "execute_result", 109 | "data": { 110 | "text/plain": " P3578_x P3578_y P3568_x P3568_y P3569_x P3569_y P3570_x P3570_y \\\n0 0.84722 0.52855 0.65268 0.24792 0.66525 0.46562 0.68103 0.59083 \n1 0.84722 0.52855 0.65231 0.24513 0.66482 0.46548 0.68095 0.59054 \n2 0.84722 0.52855 0.65197 0.24387 0.66467 0.46537 0.68078 0.59035 \n3 0.84722 0.52855 0.65166 0.24288 0.66460 0.46488 0.68063 0.58987 \n4 0.84722 0.52855 0.65141 0.24251 0.66452 0.46469 0.68052 0.58934 \n\n P3571_x P3571_y ... P3573_x P3573_y P3574_x P3574_y P3575_x \\\n0 0.62405 0.80669 ... 0.60798 0.45155 0.50212 0.45314 0.62012 \n1 0.62371 0.80594 ... 0.60783 0.44918 0.50158 0.45544 0.61987 \n2 0.62354 0.80601 ... 0.60779 0.44866 0.50126 0.45662 0.61980 \n3 0.62318 0.80604 ... 0.60762 0.44898 0.50119 0.45815 0.61976 \n4 0.62286 0.80626 ... 0.60748 0.44888 0.50114 0.45986 0.61967 \n\n P3575_y P3576_x P3576_y P3577_x P3577_y \n0 0.60667 0.51839 0.77140 0.50555 0.50863 \n1 0.60474 0.51801 0.77130 0.50545 0.50532 \n2 0.60422 0.51787 0.77080 0.50552 0.50524 \n3 0.60397 0.51773 0.77031 0.50563 0.50524 \n4 0.60417 0.51759 0.77008 0.50576 0.50531 \n\n[5 rows x 22 columns]", 111 | "text/html": "
\n\n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
P3578_xP3578_yP3568_xP3568_yP3569_xP3569_yP3570_xP3570_yP3571_xP3571_y...P3573_xP3573_yP3574_xP3574_yP3575_xP3575_yP3576_xP3576_yP3577_xP3577_y
00.847220.528550.652680.247920.665250.465620.681030.590830.624050.80669...0.607980.451550.502120.453140.620120.606670.518390.771400.505550.50863
10.847220.528550.652310.245130.664820.465480.680950.590540.623710.80594...0.607830.449180.501580.455440.619870.604740.518010.771300.505450.50532
20.847220.528550.651970.243870.664670.465370.680780.590350.623540.80601...0.607790.448660.501260.456620.619800.604220.517870.770800.505520.50524
30.847220.528550.651660.242880.664600.464880.680630.589870.623180.80604...0.607620.448980.501190.458150.619760.603970.517730.770310.505630.50524
40.847220.528550.651410.242510.664520.464690.680520.589340.622860.80626...0.607480.448880.501140.459860.619670.604170.517590.770080.505760.50531
\n

5 rows × 22 columns

\n
" 112 | }, 113 | "metadata": {}, 114 | "execution_count": 8 115 | } 116 | ], 117 | "source": [ 118 | "game_dataset.tracking.team('FIFATMA').head()" 119 | ] 120 | }, 121 | { 122 | "source": [ 123 | "Final example, let's say you want to get the x coordiante data, only for the field players (excluding goalkeeper) for team_id `FIFATMA`, you can get that by doing:" 124 | ], 125 | "cell_type": "markdown", 126 | "metadata": {} 127 | }, 128 | { 129 | "cell_type": "code", 130 | "execution_count": 6, 131 | "metadata": {}, 132 | "outputs": [ 133 | { 134 | "output_type": "execute_result", 135 | "data": { 136 | "text/plain": " P3568_x P3569_x P3570_x P3571_x P3572_x P3573_x P3574_x P3575_x \\\n0 0.65268 0.66525 0.68103 0.62405 0.50533 0.60798 0.50212 0.62012 \n1 0.65231 0.66482 0.68095 0.62371 0.50461 0.60783 0.50158 0.61987 \n2 0.65197 0.66467 0.68078 0.62354 0.50430 0.60779 0.50126 0.61980 \n3 0.65166 0.66460 0.68063 0.62318 0.50394 0.60762 0.50119 0.61976 \n4 0.65141 0.66452 0.68052 0.62286 0.50371 0.60748 0.50114 0.61967 \n\n P3576_x P3577_x \n0 0.51839 0.50555 \n1 0.51801 0.50545 \n2 0.51787 0.50552 \n3 0.51773 0.50563 \n4 0.51759 0.50576 ", 137 | "text/html": "
\n\n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
P3568_xP3569_xP3570_xP3571_xP3572_xP3573_xP3574_xP3575_xP3576_xP3577_x
00.652680.665250.681030.624050.505330.607980.502120.620120.518390.50555
10.652310.664820.680950.623710.504610.607830.501580.619870.518010.50545
20.651970.664670.680780.623540.504300.607790.501260.619800.517870.50552
30.651660.664600.680630.623180.503940.607620.501190.619760.517730.50563
40.651410.664520.680520.622860.503710.607480.501140.619670.517590.50576
\n
" 138 | }, 139 | "metadata": {}, 140 | "execution_count": 6 141 | } 142 | ], 143 | "source": [ 144 | "game_dataset.tracking.team('FIFATMA').players('field').dimension('x').head()" 145 | ] 146 | }, 147 | { 148 | "source": [ 149 | "## Events\n", 150 | "\n", 151 | "Similarly, GameDataset.events holds a TrackingFrame with all the tacking data of the game, and if you want to filter it, you can do so using it's methods. For example to get all event that go into the opponent box you can do `game_dataset.events.into(Zones.OPPONENT_BOX)`, or if you want to get all the passes you can do:" 152 | ], 153 | "cell_type": "markdown", 154 | "metadata": {} 155 | }, 156 | { 157 | "cell_type": "code", 158 | "execution_count": 15, 159 | "metadata": {}, 160 | "outputs": [ 161 | { 162 | "output_type": "execute_result", 163 | "data": { 164 | "text/plain": " event_id event_type result success period_id timestamp end_timestamp \\\n1 None PASS COMPLETE True 1 14.44 15.08 \n3 None PASS COMPLETE True 1 15.36 17.04 \n5 None PASS COMPLETE True 1 18.60 20.28 \n7 None PASS COMPLETE True 1 21.20 23.20 \n9 None PASS COMPLETE True 1 23.92 25.12 \n\n ball_state ball_owning_team team_id player_id coordinates_x \\\n1 alive FIFATMA FIFATMA P3577 0.49875 \n3 alive FIFATMA FIFATMA P3574 0.50300 \n5 alive FIFATMA FIFATMA P3575 0.33014 \n7 alive FIFATMA FIFATMA P3569 0.19071 \n9 alive FIFATMA FIFATMA P3570 0.20244 \n\n coordinates_y end_coordinates_x end_coordinates_y receiver_player_id \\\n1 0.51275 0.50136 0.51295 P3574 \n3 0.51500 0.36627 0.36551 P3575 \n5 0.40293 0.19398 0.60179 P3569 \n7 0.57078 0.20094 0.18478 P3570 \n9 0.18002 0.31899 0.01941 P3571 \n\n inverted \n1 True \n3 True \n5 True \n7 True \n9 True ", 165 | "text/html": "
\n\n\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n
event_idevent_typeresultsuccessperiod_idtimestampend_timestampball_stateball_owning_teamteam_idplayer_idcoordinates_xcoordinates_yend_coordinates_xend_coordinates_yreceiver_player_idinverted
1NonePASSCOMPLETETrue114.4415.08aliveFIFATMAFIFATMAP35770.498750.512750.501360.51295P3574True
3NonePASSCOMPLETETrue115.3617.04aliveFIFATMAFIFATMAP35740.503000.515000.366270.36551P3575True
5NonePASSCOMPLETETrue118.6020.28aliveFIFATMAFIFATMAP35750.330140.402930.193980.60179P3569True
7NonePASSCOMPLETETrue121.2023.20aliveFIFATMAFIFATMAP35690.190710.570780.200940.18478P3570True
9NonePASSCOMPLETETrue123.9225.12aliveFIFATMAFIFATMAP35700.202440.180020.318990.01941P3571True
\n
" 166 | }, 167 | "metadata": {}, 168 | "execution_count": 15 169 | } 170 | ], 171 | "source": [ 172 | "game_dataset.events.type('PASS').head()" 173 | ] 174 | }, 175 | { 176 | "cell_type": "markdown", 177 | "metadata": {}, 178 | "source": [ 179 | "Since in this game tracking and event daa come from the same provider, GameDataset.metadata for this case is the same as GameDataset.tracking.metadata and GameDataset.events.metadata. There ou can access metadata about the data like frame rate, field dimensions, teams and players details, etc. Like:" 180 | ] 181 | }, 182 | { 183 | "cell_type": "code", 184 | "execution_count": 23, 185 | "metadata": {}, 186 | "outputs": [ 187 | { 188 | "output_type": "execute_result", 189 | "data": { 190 | "text/plain": "'Player 5'" 191 | }, 192 | "metadata": {}, 193 | "execution_count": 23 194 | } 195 | ], 196 | "source": [ 197 | "game_dataset.metadata.teams[0].players[5].name" 198 | ] 199 | }, 200 | { 201 | "cell_type": "code", 202 | "execution_count": 24, 203 | "metadata": {}, 204 | "outputs": [ 205 | { 206 | "output_type": "execute_result", 207 | "data": { 208 | "text/plain": "25" 209 | }, 210 | "metadata": {}, 211 | "execution_count": 24 212 | } 213 | ], 214 | "source": [ 215 | "game_dataset.metadata.frame_rate" 216 | ] 217 | }, 218 | { 219 | "cell_type": "code", 220 | "execution_count": 25, 221 | "metadata": {}, 222 | "outputs": [ 223 | { 224 | "output_type": "execute_result", 225 | "data": { 226 | "text/plain": "Score(home=0, away=2)" 227 | }, 228 | "metadata": {}, 229 | "execution_count": 25 230 | } 231 | ], 232 | "source": [ 233 | "game_dataset.metadata.score" 234 | ] 235 | }, 236 | { 237 | "source": [ 238 | "For more details about metadata attributes and methods see [kloppy's documentation](https://kloppy.pysport.org/)." 239 | ], 240 | "cell_type": "markdown", 241 | "metadata": {} 242 | } 243 | ] 244 | } -------------------------------------------------------------------------------- /docs/examples/output.patt: -------------------------------------------------------------------------------- 1 | { 2 | "events": [ 3 | { 4 | "pattern_code": "MET_002", 5 | "start_time": 12000, 6 | "event_time": 14000, 7 | "end_time": 17000, 8 | "coordinates": [ 9 | [ 10 | 0.49875, 11 | 0.51275 12 | ], 13 | [ 14 | 0.50136, 15 | 0.51295 16 | ] 17 | ], 18 | "visualizations": { 19 | "start_time": 12000, 20 | "end_time": 17000, 21 | "players": "P3577", 22 | "tool_id": "players", 23 | "options": { 24 | "id": false, 25 | "speed": false, 26 | "spotlight": true, 27 | "ring": false, 28 | "spotlightColor": "#FFFFFF", 29 | "ringColor": "#000000", 30 | "size": 1.0 31 | } 32 | }, 33 | "tags": "FIFATMA" 34 | }, 35 | { 36 | "pattern_code": "MET_002", 37 | "start_time": 43000, 38 | "event_time": 45000, 39 | "end_time": 48000, 40 | "coordinates": [ 41 | [ 42 | 0.5, 43 | 0.5 44 | ], 45 | [ 46 | 0.65727, 47 | 0.09506 48 | ] 49 | ], 50 | "visualizations": { 51 | "start_time": 43000, 52 | "end_time": 48000, 53 | "players": "P3588", 54 | "tool_id": "players", 55 | "options": { 56 | "id": false, 57 | "speed": false, 58 | "spotlight": true, 59 | "ring": false, 60 | "spotlightColor": "#FFFFFF", 61 | "ringColor": "#000000", 62 | "size": 1.0 63 | } 64 | }, 65 | "tags": "FIFATMB" 66 | } 67 | ], 68 | "insert": { 69 | "patterns": [ 70 | { 71 | "name": "Team Stretched", 72 | "code": "MET_001" 73 | }, 74 | { 75 | "name": "Set Pieces", 76 | "code": "MET_002" 77 | }, 78 | { 79 | "name": "Passes into the box", 80 | "code": "MET_003" 81 | } 82 | ] 83 | } 84 | } -------------------------------------------------------------------------------- /docs/examples/run_patterns.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "metadata": { 3 | "language_info": { 4 | "codemirror_mode": { 5 | "name": "ipython", 6 | "version": 3 7 | }, 8 | "file_extension": ".py", 9 | "mimetype": "text/x-python", 10 | "name": "python", 11 | "nbconvert_exporter": "python", 12 | "pygments_lexer": "ipython3", 13 | "version": "3.8.2-final" 14 | }, 15 | "orig_nbformat": 2, 16 | "kernelspec": { 17 | "name": "python_defaultSpec_1600218423960", 18 | "display_name": "Python 3.8.2 64-bit ('env': venv)" 19 | } 20 | }, 21 | "nbformat": 4, 22 | "nbformat_minor": 2, 23 | "cells": [ 24 | { 25 | "cell_type": "markdown", 26 | "metadata": {}, 27 | "source": [ 28 | "# Modules\n", 29 | "GameDataset is a class that will hold methods and data for one game. PatternsSet is a calss that willhold methods, patterns, and pattern_events data for this game. " 30 | ] 31 | }, 32 | { 33 | "cell_type": "code", 34 | "execution_count": 3, 35 | "metadata": {}, 36 | "outputs": [], 37 | "source": [ 38 | "import sys\n", 39 | "sys.path.insert(1, '../../')\n", 40 | "\n", 41 | "from codeball import GameDataset, PatternsSet" 42 | ] 43 | }, 44 | { 45 | "cell_type": "markdown", 46 | "metadata": {}, 47 | "source": [ 48 | "\n", 49 | "# Initialize GameDataset\n", 50 | "\n", 51 | "Define data files. Currently reading the files in the test folder of the package. Initialize game dataset. This loads the data for each data type using Kloppy, and then stores it on game_dataset as instances of TrackinDataFrame and EventsDataFrame. Both of them are subclasses of a pandas Dataframe. Other than holding the data in a dataframe, they also have methods to work with, filter etc the data they contain." 52 | ] 53 | }, 54 | { 55 | "cell_type": "code", 56 | "execution_count": 5, 57 | "metadata": {}, 58 | "outputs": [], 59 | "source": [ 60 | "\n", 61 | "metadata_file = (r\"../../codeball/tests/files/metadata.xml\")\n", 62 | "tracking_file = (r\"../../codeball/tests/files/tracking.txt\")\n", 63 | "events_file = (r\"../../codeball/tests/files/events.json\")\n", 64 | "\n", 65 | "game_dataset = GameDataset(\n", 66 | " tracking_metadata_file=metadata_file,\n", 67 | " tracking_data_file=tracking_file,\n", 68 | " events_metadata_file=metadata_file,\n", 69 | " events_data_file=events_file,\n", 70 | ")" 71 | ] 72 | }, 73 | { 74 | "cell_type": "markdown", 75 | "metadata": {}, 76 | "source": [ 77 | "# Instantiate PatternSet and run and export patterns for play. \n", 78 | "The first step is to instantiate a PatternsSet instance. It takes as arugment a GameDataset instance so that all patterns can have access to the data of the game. Conceptually a pattern is an analysis that will return the moments in the game a certain thing happend, with that thing being defined in the pattern / analysis. For example, look for all passes into the box. \n", 79 | "\n", 80 | "Next step is to initialize the patterns by reading the patterns config from ../codeball/patterns/patterns_config.json. However you can specify your own pattern config by providing it as an input to initialize_patterns. Then `run_patterns` iterates over all the patterns in the PatternSet and runs them. Finally, `save_patterns_for_play` method takes all the Patterns and PatternEvents in the PatternsSet and outputs them on a json fotmat that can be imported into Metrica Play via Metrica Cloud. \n", 81 | "\n", 82 | "If you didn't clone the repo, you can get the config file [here](https://github.com/metrica-sports/codeball/blob/master/codeball/patterns/patterns_config.json).\n" 83 | ] 84 | }, 85 | { 86 | "cell_type": "code", 87 | "execution_count": 8, 88 | "metadata": {}, 89 | "outputs": [], 90 | "source": [ 91 | "patterns_set = PatternsSet(game_dataset=game_dataset)\n", 92 | "\n", 93 | "patterns_set.initialize_patterns(config_file=r\"../../codeball/patterns/patterns_config.json\")\n", 94 | " \n", 95 | "patterns_set.run_patterns()\n", 96 | "\n", 97 | "patterns_set.save_patterns_for_play(\"output.patt\")" 98 | ] 99 | } 100 | ] 101 | } -------------------------------------------------------------------------------- /docs/format-for-play.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # Patterns and events format for Play 4 | This documentation describes the API to import into import events with annotations associated to them into Play. We refer to this type of events as `patterns`. Patterns are imported into Play via a `json` file with a `.patt` extension. Below an explanation of how this file should be formatted. This documentation is compatible with versions **2.5.0 of Play by Metrica Sports** or higher. Old patterns files are still supported, see section [Versions](#versions). 5 | 6 | ## Format 7 | 8 | The patterns files is in JSON format. It has to main entry points: `events` and `insert` 9 | 10 | The **events** entry is an array containing all the events you detect for each game. Those events are going to be stored and associated to the game you are uploading them to. 11 | 12 | The **insert** entry is an object that will contain a declaration of `patterns` , `tags` and `tag_groups` you want to create. **It's important to notice that this will be created and store in the database and will be shared among all you games.** 13 | 14 | You don't need to create all the patterns, tags and groups each time. If you want to add them to all of your files, that's not a problem. As long the unique code you added to any of them is already in the database there will be no duplicates. 15 | 16 | You can use the **insert** field for updating your patterns, tags and tag_groups. More on that in the Patterns, Tags and Tag Groups sections. 17 | 18 | ``` 19 | { 20 | "events": [ 21 | // Here you'll list al events you want to create 22 | ], 23 | "insert": { 24 | "patterns": [ 25 | // By listing patterns here you'll be able to create 26 | // and update patterns 27 | ], 28 | "tag_groups": [ 29 | // By listing tag groups here you'll be able to create 30 | // and update tag groups 31 | ], 32 | "tags": [ 33 | // By listing tags here you'll be able to create 34 | // and update tags 35 | ] 36 | } 37 | } 38 | ``` 39 | 40 | ## Prefix 41 | 42 | Every time you want to create something in our database, like a Pattern, Tag Group or Tag you'll need to add a code to it. You can manage your codes any way you like as long you create unique codes for each resource. We'll provide you wit a **prefix** you'll need to add to your codes. If a resource you want to create doesn't have the appropriate prefix it will be omitted. Let's say you want to create some patterns and your prefix is **RCNG.** You could do something like `RCNG_001` and `RCNG_002` or `RCNG_COUNTER` and `RCNG_POSESSION`. It's up to you how you manage codes, but the prefix is mandatory. 43 | 44 | ## Patterns 45 | 46 | We call Pattern to a type of detection. Let's say you create an algorithm for detecting Counter-attacks. You'll create a Pattern called Counter-Attacks and each Counter-attack you detect will be an event associated to this Pattern. 47 | 48 | A Pattern needs to have a unique code and a name, that's it. If a code already exists on the database it would not create the pattern again it will update the name to what's written in the current file. It's important to remember that Patterns are shared by all your games, so updating the name will have an effect on previous and future games. 49 | 50 | ``` 51 | "patterns": [ 52 | { 53 | "name": "Counter Attack", 54 | "code": "RCNG_001" 55 | }, 56 | { 57 | "name": "Defensive Positioning", 58 | "code": "RCNG_002" 59 | } 60 | ], 61 | ``` 62 | 63 | This is how patterns will be listed in Play for each game: 64 | 65 | ![image.png](https://storage.googleapis.com/slite-api-files-production/files/e161fbb9-ed22-4329-98f7-5a8d0c0b2285/image.png) 66 | 67 | ## Tags and Tag Groups 68 | 69 | For any given Pattern you create, most likely you'll want to create some Tags and Tag Groups as well. Let's say you have your Counter Attacks pattern and you want to be able to filter by "Fast" and "Slow", or "Successful" and "Failed". For that you can create Tags. And those Tags can be grouped. For example, "Fast" and "Slow" could be two tags on the group "Counter Speed". 70 | 71 | For creating a Tag Group you only need to add a unique code and a name. 72 | 73 | For creating a Tag you'll need to add a unique code, a name and the code of the group the tags belongs to. 74 | 75 | ``` 76 | "tag_groups": [ 77 | { 78 | "name": "Counter Speed", 79 | "code": "RCNG_GROUP_001" 80 | }, 81 | { 82 | "name": "Counter Quality", 83 | "code": "RCNG_GROUP_002" 84 | } 85 | ], 86 | "tags": [ 87 | { 88 | "name": "Fast", 89 | "code": "RCNG_TAG_001", 90 | "group": "RCNG_GROUP_001" 91 | }, 92 | { 93 | "name": "Slow", 94 | "code": "RCNG_TAG_002", 95 | "group": "RCNG_GROUP_001" 96 | } 97 | ], 98 | ``` 99 | 100 | You'll see Tags organized by groups in the filter section when a Pattern is selected 101 | 102 | ![image.png](https://storage.googleapis.com/slite-api-files-production/files/967dd7e4-6d86-4a07-9acc-3df7ba14438c/image.png) 103 | 104 | Once you create a Tag Group, and associate a Tag with it, the tag associated to a group will always be organized based no that group. 105 | 106 | An important point. Players and Team codes, for example **ESPBCN** or **P030** can be used as tags directly. So no need to create tags for teams and players. Just use them as any other tags you have created. 107 | 108 | ## Creating and updating 109 | 110 | This information about patterns, tags and tag groups, can be included every time the json is uploaded. However they are only needed when you want to create one of them, or when you want to update on of them (e.g. change name). 111 | 112 | 113 | # Events 114 | 115 | On event is one detection of a Pattern. Events happen at a given time in the video and have a duration and many other properties. An Events belongs to a Pattern and has Tags associated to it. This is how, when you select a Pattern you will see a list with all the Events belonging to that Pattern. And when you select an event you will see a list with all the tags added to that Event. This is how you construct events. There are three main categories of concepts. 1) One is the information about the event itself. 2) Another is the information about the annotations. 3) The last one is the tags and team information for filtering in Play. 116 | 117 | An example event with annotations looks like this: 118 | 119 | ``` 120 | { 121 | "pattern": RCNG_PATTERN_001, 122 | "start_time": 5000, 123 | "event_time": 10000, 124 | "end_time": 25000, 125 | "coordinates": [ 126 | [0.39,0.51], 127 | [0.44,0.42] 128 | ], 129 | "visualizations": { 130 | "start_time": 7000, 131 | "end_time": 23000, 132 | "players": "P030", 133 | "tool_id": "players", 134 | "options": { 135 | "speed": 1 136 | }, 137 | "version": 2 138 | }, 139 | "tags": [ 140 | "ESPBCN", 141 | "P030", 142 | "RCGN_TAG_001", 143 | "RCGN_TAG_007" 144 | ], 145 | "team": "ESPBCN" 146 | } 147 | ``` 148 | 149 | This is an example of a sprint type event which has a `speed` visualization. 150 | 151 | This is an event that belongs to the `pattern` code `RCNG_PATTERN_001`, that starts at time `5000` and ends at time `25000`, with the event indicator being located at `10000` in the timeline of the video. All times are in milliseconds. 152 | 153 | In this case, in Play the event will be located at time `10000` in the timeline that , but when you select it, the video will play from time `5000` to `25000`. 154 | 155 | Moreover, this event has coordinates. There are two options for coordinates. If you provide just a pair of xy coordinates, it will show a dot on the 2D field in Play. If you provide two pairs, it will show an arrow. In this case it will show an arrow in the 2D field (NOT in the video) going from `[0.39, 0.51]` to `[0.44, 0.42]`. 156 | 157 | This event also has a annotation. In this case, the speed will show up for this player from time `7000` to `23000` for player `P030`. To code for that, the `tool_id` is set to `players` type visualization that has `speed` as `1` (true). This visualization is also of `version = 2`, which means it's formatted for the new visualizations in version 2.5.0 of Play and onwards. See section [Versions](#versions). 158 | 159 | Finally, this event has the tags `"ESPBCN","P030",RCGN_TAG_001,RCGN_TAG_007` and belongs to the team `ESPBCN`. 160 | 161 | Below a summary of the information about fields related events and fields related to annotations. 162 | 163 | ## Fields common to all events 164 | 165 | ``` 166 | { 167 | "pattern": 15, // Pattern code 168 | "start_time": 5000, // Number in milliseconds 169 | "event_time": 10000, // Number in milliseconds 170 | "end_time": 25000, // Number in milliseconds 171 | "coordinates": [ // In normalized coordinates. `null` if empty. 172 | [0.39,0.51], 173 | [0.44,0.42] 174 | ] 175 | "visualizations":[...], // See below. `null` if empty. 176 | "tags": [ // Codes of the tags. `[]` if empty. 177 | "ESPBCN", 178 | "P030" 179 | ], 180 | "team": "ESPBCN" // `null` if empty. 181 | } 182 | ``` 183 | 184 | 185 | ## Fields common to all visualizations 186 | 187 | The following attributes have to be defined for each tool. Annotations' order matters, they will be created and displayed in the same order they are declared, first annotation will be rendered at the bottom/background. 188 | ``` 189 | { 190 | start_time : 1040, // Milliseconds 191 | end_time : 2130, // Milliseconds 192 | tool_id : 'players', // The ID of the tool 193 | ... // Each tool could have other mandatory attributes (players, team, points, etc.) 194 | options : {}, // Optional object attribute for the tool 195 | version : 2 // Which version of API it's the viz compatible with 196 | } 197 | ``` 198 | 199 | ## Versions 200 | In version **2.5.0** of Play by Metrica Sports we introduced [new and improved visualizations](https://metrica-sports.com/a-wonderful-day-for-play/). These new visualizations add several new parameters and options to the visualizations, thus the API for adding visualizations has changed. In particular around the available options for each type of visualization. 201 | 202 | For example whereas on the original version (version 1), to define a ring you would do: 203 | ``` 204 | { 205 | start_time : 1040, 206 | end_time : 2130, 207 | tool_id : 'players', 208 | players : ['P001', 'P002'], 209 | options : { ring: true, color: '#FFFFFF' }, 210 | } 211 | ``` 212 | 213 | On the new version (version 2) rings have a border and a fill that can de defined separately. So to define a ring you need to do: 214 | ``` 215 | { 216 | start_time : 1040, 217 | end_time : 2130, 218 | tool_id : 'players', 219 | players : ['P001', 'P002'], 220 | options : { ringFill: true, ringBorder: true, ringFillColor: '#FFFFFF', ringBorderColor: '#FFFF00' }, 221 | version : 2 222 | } 223 | ``` 224 | 225 | 226 | Note that in the last one we noted that this visualization is of version 2 so that the API know how to read this visualization. However, while different in formats, the API on version 2.5.0 onwards is compatible with older versions. If a visualization doesn't have a version indicated in the options it will be assumed it's from version 2, and the options will be populated accordingly. For example, if you import the above example of a version 1 ring, it would be like importing: 227 | ``` 228 | 229 | { 230 | start_time : 1040, 231 | end_time : 2130, 232 | tool_id : 'players', 233 | players : ['P001', 'P002'], 234 | options : { ringFill: true, ringBorder: true, ringFillColor: '#FFFFFF', ringBorderColor: '#FFFFFF' }, 235 | version : 2 236 | } 237 | ``` -------------------------------------------------------------------------------- /docs/game-dataset.md: -------------------------------------------------------------------------------- 1 | # What's a GameDataset? 2 | 3 | A GameDataset is a class that serves 2 purposes: 4 | 5 | 1. Hold CodeballFrames for tracking, event, and other data types 6 | 2. Provide methods to enrich those CodeballFrames 7 | 3. Provide auxiliary methods to process and handle data that require information from the game_dataset (e.g. frame rate) 8 | 9 | ## Attributes 10 | 11 | * ***tracking***: contains a `TrackingFrame` 12 | * ***events***: contains an `EventsFrame` 13 | 14 | ## Properties 15 | 16 | * ***game_dataset_type***: return an Enum with the type of the dataset, which could be [ONLY_TRACKING, ONLY_EVENTS, FULL_SAME_PROVIDER, FULL_MIXED_PROVIDERS] 17 | * ***metadata***: the metadata of the dataset (unless it's a FULL_MIXED_PROVIDERS type) that comes from loading the data with kloppy. 18 | 19 | ## Enrichment methods 20 | 21 | There is a main method ***_enrich_data*** that runs all the below: 22 | 23 | * ***_build_possessions*** 24 | * ***_set_periods_attacking_direction*** 25 | * ***_enrich_events*** 26 | * ***_enrich_tracking*** 27 | 28 | ## Auxiliar methods 29 | * ***find_interval***: given a Series of bool values computes the intervals of True values. 30 | * ***frame_to_milliseconds***: given a frame number it returns the value in milliseconds of that moment in the video. 31 | 32 | -------------------------------------------------------------------------------- /docs/how-to-import-to-play.md: -------------------------------------------------------------------------------- 1 | 2 | Once you have created a .patt file, you can import it to Metrica Play via [Metrica Cloud](https://cloud.metrica-play.com/). To do so you need to select the Video Project you want to upload a file and upload it under the more options section (three dots all the way to the left of the Video Project). You'll get a notification informing you if the upload was or wasn't successful. 3 | 4 | Once the file is uploaded, you can go to Metrica Play and download the Video Project from the DB Manager. If you already did that, you can: 5 | 6 | 1. Select the Video Project in the Video manager, and then on Pattern Events more options select Retry download. 7 | 2. Close and open the application again and will get a notification informing you there is a new file and you can click directly there to download it. 8 | 9 | If you had uploaded a file already and want to upload a new one, go to Cloud, delete the previously uploaded file and do any of the two steps described above. -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # codeball: data driven tactical and video analysis of soccer games 2 | 3 | [![PyPI Latest Release](https://img.shields.io/pypi/v/codeball.svg)](https://pypi.org/project/codeball/) 4 | [![Downloads](https://pepy.tech/badge/codeball)](https://pepy.tech/project/codeball) 5 | ![](https://img.shields.io/github/license/metrica-sports/codeball) 6 | ![](https://img.shields.io/pypi/pyversions/codeball) 7 | [![Powered by Metrica Sports](https://img.shields.io/badge/Powered%20by-Metrica%20Sports-green)](https://metrica-sports.com/) 8 | -------- 9 | 10 | ## Why codeball? 11 | 12 | While there are several pieces of code / repositories around that provide different tools and bits of codes to do tactical analysis of individual games, there is no centralized place in which they live. Moreover, most of the analysis done is usually not linked or easy to link with the actual footage of the match. Codeball's objective is to change that by: 13 | 14 | 1. Building a central repository for different types of data driven tactical analysis methods / tools. 15 | 2. Making it easy to link those analyses with a video of the game in different formats. 16 | 17 | ## What can you do with it 18 | 19 | The main types of work / development you can do with codeball are: 20 | 21 | #### Work with tracking and event data 22 | 23 | - Codeball creates subclasses of *Pandas DataFrames* for events and tracking data; and provides you with handy methods to work with the data. 24 | - Work with or create your own tactical models like *Zones* so that you can for example do `game_dataset.events.into(Zones.OPPONENT_BOX)` and it will return a DataFrame only with the events into the opponents box. You can also chain methods, like `game_dataset.events.type("PASS").into(Zones.OPPONENT_BOX)` and will return only passes into the box. Or for example do `game_dataset.tracking.team('FIFATMA').players('field').dimension('x')` to get the x coordinate of the field players (no goalkeeper data) for team with id FIFATMA. 25 | - [Not yet implemented] Easily access tactical tools or methods like computing passes networks, pitch control,EPV models, etc 26 | 27 | #### Create Patterns to analyze the game 28 | 29 | - Analyze games based on Patterns. A Pattern is a unit of analysis that looks for moments in the game in which a certain thing happens. That certain thing is defined inside the Pattern, but codeball provides tools to easily create them, configure them and export them in different formats for different platforms. 30 | - You can create your own patterns, or also use the ones provided with the package and configure them to your liking. 31 | 32 | #### Add annotations to the events for Metrica Play 33 | 34 | - Codeball incorporates all the annotations models and API information needed to import events with annotations into Metrica Play. 35 | - You can add directly from the code any visualization available in Metrica Play (spotlights, rings, future trail, areas, drawings, text, etc) to any event. 36 | 37 | ## Example 38 | 39 | You can use any of the above functionality independently. However they are most powerful when combined. As an example, the below code defines a pattern that will look for all passes into the opponent's box. Moreover to be imported into Metrica Play, it will add an arrow and a 2s pause in the video at the moment of the pass, and will add an arrow to the 2D field indicating start and end position of the pass. 40 | 41 | ```python 42 | class PassesIntoTheBox(Pattern): 43 | def __init__( 44 | self, 45 | game_dataset: GameDataset, 46 | name: str, 47 | code: str, 48 | in_time: int = 0, 49 | out_time: int = 0, 50 | parameters: dict = None, 51 | ): 52 | super().__init__( 53 | name, code, in_time, out_time, parameters, game_dataset 54 | ) 55 | 56 | def run(self) -> List[PatternEvent]: 57 | 58 | passes_into_the_box = ( 59 | self.game_dataset.events.type("PASS") 60 | .into(Zones.OPPONENT_BOX) 61 | .result("COMPLETE") 62 | ) 63 | 64 | return [ 65 | self.build_pattern_event(event_row) 66 | for i, event_row in passes_into_the_box.iterrows() 67 | ] 68 | 69 | def build_pattern_event(self, event_row) -> PatternEvent: 70 | pattern_event = self.from_event(event_row) 71 | pattern_event.add_arrow(event_row) 72 | pattern_event.add_pause(pause_time=2000) 73 | 74 | return pattern_event 75 | ``` 76 | 77 | The above code produces this output when imported into Metrica Play: 78 | 79 |

80 | 81 |

82 | 83 | ## Supported Data Providers 84 | 85 | This package is very much WIP. At the moment it only works based on Metrica Sports Elite datasets. However, it uses Kloppy to read in the data so that in the near future will support data from any provider. 86 | 87 | ## Trying it out 88 | 89 | There are no open source Elite datasets at the moment that work with this package. However if you are interested in testing it out and/or developing your own patterns and/or test them in Metrica Play reach out to bruno@metrica-sports.com or [@brunodagnino](https://twitter.com/brunodagnino) on Twitter. 90 | 91 | ## Install it 92 | 93 | Installers for the latest released version are available at the [Python package index](https://pypi.org/project/codeball). 94 | 95 | ```sh 96 | pip install codeball 97 | ``` 98 | 99 | ## Contribute 100 | 101 | While created and maintained by Metrica Sports, it's distributed under an MIT license and it welcomes contributions from members of the community, clubs and other companies. You can find the repository on [Github](https://github.com/metrica-sports/codeball). Also, if you have ideas for patterns we should implement, or methods we should include (e.g. pitch control, EPV, similarity search, etc), let us know! You can create an issue on the repo, or reach out to bruno@metrica-sports.com or [@brunodagnino](https://twitter.com/brunodagnino) on Twitter. 102 | 103 | ## Documentation 104 | 105 | Check the [documentation](https://codeball.metrica-sports.com) for a more detailed explanation of this package. 106 | 107 | ## Tentative TODO 108 | 109 | This is a very incomplete list of the things we have in mind, and it will probably change as we get input from the community / users. However it gives you a rough idea of the direction in which we want to go with this project! 110 | 111 | * more Zones (half spaces, thirds, 14, etc) - [done] 112 | * crete types for players, events, etc to filter the data. 113 | * more ways to filter event and tracking data (e.g pass length) 114 | * more patterns (currently 4 in the making) 115 | * pitch control from `game_dataset.pitch_control([frame/s])`, same with EPV. 116 | * easily query xG, g+, xT, etc for events 117 | * corner strategy classifier. 118 | * support for other providers, likely StatsBomb next. 119 | * export events in xml format 120 | * methods to easily sync tracking and event from different providers. 121 | * any suggestions? -------------------------------------------------------------------------------- /docs/media/passes_into_the_box.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/metrica-sports/codeball/06161ac69f9b7b53e3820537e780ab962c4349e6/docs/media/passes_into_the_box.gif -------------------------------------------------------------------------------- /docs/media/set_pieces.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/metrica-sports/codeball/06161ac69f9b7b53e3820537e780ab962c4349e6/docs/media/set_pieces.gif -------------------------------------------------------------------------------- /docs/media/sprint.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/metrica-sports/codeball/06161ac69f9b7b53e3820537e780ab962c4349e6/docs/media/sprint.gif -------------------------------------------------------------------------------- /docs/media/team_stretched.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/metrica-sports/codeball/06161ac69f9b7b53e3820537e780ab962c4349e6/docs/media/team_stretched.gif -------------------------------------------------------------------------------- /docs/metrica-play-api.md: -------------------------------------------------------------------------------- 1 | # What can you do with Play's API? 2 | 3 | > Note that to import a pattern file to Play you do not need to use codeball. As long as the file you import is formatted as this documentation indicates you'll be able to import the file. How you create that file can be any language, any library. Codeball is one way to do it, but not the only one! Feel free to use whatever language or library/is you feel more comfortable with! 4 | 5 | Play API for patterns and visualizations allows you to import patterns and events into Play. The interesting part is that not only it allows you to import events that have a start/end time, tags, etc; but also to add visualizations directly from the code. For example the following json code: 6 | 7 | ```json 8 | { 9 | "pattern": "RCNG_PATTERN_001", 10 | "start_time": 5000, 11 | "event_time": 10000, 12 | "end_time": 25000, 13 | "coordinates": [ 14 | [0.39,0.51], 15 | [0.44,0.42] 16 | ], 17 | "visualizations": { 18 | "start_time": 7000, 19 | "end_time": 23000, 20 | "players": "P030", 21 | "tool_id": "players", 22 | "options": { 23 | "speed": 1 24 | }, 25 | "version": 2 26 | }, 27 | "tags": [ 28 | "ESPBCN", 29 | "P030", 30 | "RCGN_TAG_001", 31 | "RCGN_TAG_007" 32 | ], 33 | "team": "ESPBCN" 34 | } 35 | ``` 36 | 37 | This is an example of a sprint type event which has a `speed` visualization. This event belongs to the `pattern` code `RCNG_PATTERN_001`, that starts at time `5000` and ends at time `25000`, with the event indicator being located at `10000` in the timeline of the video. All times are in milliseconds. In this case, in Play the event will be located at time `10000` in the timeline that , but when you select it, the video will play from time `5000` to `25000`. 38 | 39 | Moreover, this event has coordinates. There are two options for coordinates. If you provide just a pair of xy coordinates, it will show a dot on the 2D field in Play. If you provide two pairs, it will show an arrow. In this case it will show an arrow in the 2D field (NOT in the video) going from `[0.39, 0.51]` to `[0.44, 0.42]`. 40 | 41 | This event also has a annotation. In this case, the speed will show up for this player from time `7000` to `23000` for player `P030`. To code for that, the `tool_id` is set to `players` type visualization that has `speed` as `1` (true). This visualization is also of version = 2, which means it's formatted for the new visualizations in version 2.5.0 of Play and onwards. See section Versions. 42 | 43 | Finally, this event has the tags `"ESPBCN","P030",RCGN_TAG_001,RCGN_TAG_007` and belongs to the team `ESPBCN`. 44 | 45 | And by producing this file and importing it to Play via Metrica Cloud, you'll see this result automatically. 46 | 47 |

48 | 49 |

50 | 51 | # Import to Play 52 | To import a json file to Play, you have to do it via the video project created in Metrica Cloud. To do so go to this option on your video project: 53 | -------------------------------------------------------------------------------- /docs/patterns.md: -------------------------------------------------------------------------------- 1 | ## Configuration 2 | 3 | All the patterns below, are available included in Codeball and ready to be used. They have a default configuration included with the package, but you can create your own config file if you want to change for example, the name of the patterns, the in and out time, or the parameters the use to compute events. The configuration for a pattern looks like the below. For more details check the example pattern in the Examples section. 4 | 5 | ```json 6 | { 7 | "include": true, 8 | "name": "Passes into the box", 9 | "code": "MET_003", 10 | "pattern_class": "PassesIntoTheBox", 11 | "parameters": null, 12 | "in_time": 2, 13 | "out_time": 2 14 | } 15 | ``` 16 | 17 | **** 18 | 19 | ## Available patterns 20 | 21 | ### TeamStretched 22 | 23 | This pattern looks for moments in which the team is stretched horizontally while defending for more than 5 seconds. It returns those moments with a TeamSize length visualization for the duration of the infringement. This pattern doesn't add anything on the 2D field. 24 | 25 | Parameters: 26 | 27 | * team: str -> Code of the team you want to analyze 28 | * threshold: float -> What is the stretch threshold in meters 29 | 30 | In this example the threshold is at 35 meters. 31 | 32 |

33 | 34 |

35 | 36 | **** 37 | 38 | ### SetPieces 39 | 40 | This pattern return set Pieces include: kick offs, throw ins, corner kicks penalties, free kicks. Beside indicating the moment of the game in which they tke place, it adds a spotlight on the player tacking the set piece. This pattern also adds a dot on the 2D field for each event. 41 | 42 |

43 | 44 |

45 | 46 | **** 47 | 48 | ### PassesIntoTheBox 49 | 50 | This pattern finds completed passes into the opponent box. For each one of those passes, it creates a pattern event that at the moment of the pass makes a 2s pause and draws an arrow on the video showing the pass. This pattern also adds an arrow on the 2D field for each event. 51 | 52 |

53 | 54 |

-------------------------------------------------------------------------------- /docs/tactical.md: -------------------------------------------------------------------------------- 1 | # Tactical Module 2 | The tactical module includes different classes and methods to help work with the data from a tactical perspective. 3 | 4 | ## Areas 5 | An Area is a class used to define an area of the pitch. You can define simple rectangular areas, or more complex polygonal ones. To define an area you need to provide 2 or more points (in normalized coordinates, for example (0.5,0.5) would be the center of the pitch). If you provide only two points it will define a rectangular area, in which the first point is the top-left one and the second one is the bottom-right one. If you provide 3 or more points it will be consider a polygon. 6 | 7 | Areas have a `type` attribute that will return an Enum with either `AreaType.RECTANGLE` or `AreaType.POLYGON`. 8 | 9 | ## Zones 10 | Zones is an Enum that defines a list of zones that are tactically relevant. Each type could be defined by one or more `Area`. The list of currently defined Zones is: 11 | 12 | - OPPONENT_BOX 13 | - OWN_BOX 14 | - ATTACKING_THIRD 15 | - MIDDLE_THIRD 16 | - DEFENDING_THIRD 17 | - OWN_HALF 18 | - OPPONENT_HALF 19 | - LEFT_HALF_SPACE 20 | - RIGHT_HALF_SPACE 21 | - HALF_SPACES 22 | - CENTRE 23 | - LEFT_WING 24 | - RIGHT_WING 25 | - WINGS 26 | - ZONE_14 27 | 28 | ## Using Areas and Zones 29 | You can use Areas and Zones in the methods provided by `CodeballFrames`. For example you can do: 30 | ```python 31 | EventsFrame.into(Zones.HALF_SPACES) 32 | ``` 33 | 34 | Or you can define your own areas and then provide them as filters: 35 | ```python 36 | custom_area = Area((0.16,0),(0.84,1)) 37 | EventsFrame.into(custom_area) 38 | ``` 39 | 40 | Finally, you can combine and provide one or more areas/zones, or a mix of zones and areas: 41 | ```python 42 | custom_area = Area((0.16,0),(0.84,1)) 43 | EventsFrame.into(Zones.HALF_SPACES,custom_area) 44 | ``` -------------------------------------------------------------------------------- /docs/visualizations.md: -------------------------------------------------------------------------------- 1 | # Visualizations types and settings 2 | This section describes all the possible visualizations that can be added to an event, as well as the API to be imported into Play. For each one of these possibilities, there is a dataclass defined in `visualizations.py` so that they can be easily added from the code. 3 | 4 | ## Fields common to all visualizations 5 | 6 | The following attributes have to be defined for each tool. Annotations' order matters, they will be created and displayed in the same order they are declared, first annotation will be rendered at the bottom/background. 7 | ``` 8 | { 9 | start_time: 1040, // Milliseconds 10 | end_time: 2130, // Milliseconds 11 | tool_id: 'players', // The ID of the tool 12 | ... // Each tool could have other mandatory attributes 13 | options: {}, // Optional object attribute for the tool 14 | version: 2 // Which version of API it's the viz compatible with 15 | } 16 | ``` 17 | 18 | ## Tools 19 | This is a list of all the tools that is possible to add as annotations. Each option of each tool is optional. If you don't include some of them, default value will be used. The `options` attribute and the children of it are optional. If you don't include some or all of them, default values will be used 20 | 21 | ### Players 22 | The `tool_id` is `players`. 23 | 24 | **Players** 25 | ``` 26 | players: ['P001', 'P002'] 27 | ``` 28 | 29 | **Options** 30 | ``` 31 | options: { 32 | id: false, 33 | speed: false, 34 | size: 1.0, // [0.2, 2.5] 35 | color: '#000000', 36 | boxPositionDown: false, 37 | spotlight: false, 38 | spotlightSize: 0.5, // Multiplier [0.2, 4.0] 39 | spotlightColor: '#FFFFFF', 40 | spotlightOpacity: 0.43, // [0.0, 1.0] 41 | spotlightHeight: 2.0, // [0.1, 10.0] 42 | ringSize: 0.73, 43 | ringBorder: false, 44 | ringBorderColor: '#FFFFFF', 45 | ringFill: false, 46 | ringFillColor: '#DC3322', 47 | is3d: false 48 | } 49 | 50 | ``` 51 | *** 52 | ### Trails 53 | The `tool_id` is `trails`. 54 | 55 | **Players** 56 | ``` 57 | players: ['P001', 'P002'] 58 | ``` 59 | 60 | **Options** 61 | ``` 62 | options: { 63 | color: '#0062ad', 64 | continuous: true, 65 | dotted: false, 66 | dashSize: 1.0, // Multiplier [0.2, 2.5]. Only Dotted 67 | is3d: false, 68 | ringBorder: true, 69 | offsetOpacity: 0.26, // [0.0, 1.0] 70 | opacity: 1.0, // [0.0, 1.0] 71 | ringBorderColor: "#ffffff", 72 | ringFill: true, 73 | ringFillColor: '#009cdd', 74 | ringSize: 1.0, // Multiplier [0.6, 4.0] 75 | seconds: 5.0, // [1.0, 99.0] 76 | thickness: 0.1, // Multiplier [0.1, 5.0]. Only in 3D 77 | width: 0.24 // Multiplier [0.1, 2.0] 78 | } 79 | ``` 80 | *** 81 | ### Future Trails 82 | The `tool_id` is `futureTrails`. 83 | 84 | **Players** 85 | ``` 86 | players: ['P001', 'P002'] 87 | ``` 88 | 89 | **Options** 90 | ``` 91 | options: { 92 | color: '#ff9e2d', 93 | continuous: true, 94 | dashSize: 0.6, // Multiplier [0.2, 2.5]. Only Dotted 95 | dotted: false, 96 | is3d: false, 97 | offsetOpacity: 0.05, // [0.0, 1.0] 98 | opacity: 1.0, // [0.0, 1.0] 99 | ringBorder: true, 100 | ringBorderColor: "#ffffff", 101 | ringFill: true, 102 | ringFillColor: '#ffdc3a', 103 | ringSize: 1.0, // Multiplier [0.6, 4.0] 104 | seconds: 5.0, // [1.0, 99.0] 105 | thickness: 0.23, // Multiplier [0.1, 5.0]. Only in 3D 106 | width: 0.29 // Multiplier [0.1, 2.0] 107 | } 108 | ``` 109 | *** 110 | ### Magnifiers 111 | The `tool_id` is `magnifiers`. 112 | 113 | **Players** 114 | ``` 115 | players: ['P001', 'P002'] 116 | ``` 117 | 118 | **Options** 119 | ``` 120 | options: { 121 | color: '#ffffff', 122 | zoom: 1.0, // [0.2, 1.5] 123 | size: 1.0 // [0.5, 1.5] 124 | } 125 | ``` 126 | *** 127 | ### Measurer 128 | The `tool_id` is `measurer`. 129 | 130 | **Players** 131 | ``` 132 | players: ['P001', 'P002'] 133 | ``` 134 | 135 | **Options** 136 | ``` 137 | options: { 138 | borderColor: '#dc3322', 139 | borderEdgeOpacity: 0.4, // [0.0, 1.0] 140 | borderOpacity: 0.9, // [0.0, 1.0] 141 | closed: false, 142 | continuous: true, 143 | dashSize: 1.45, // Multiplier [0.2, 2.5]. Only Dotted 144 | distance: true, 145 | distanceColor: '#ffffff', 146 | distanceIs3d: false, 147 | distancePosition: 0.92, // Multiplier [0.5, 2.0] 148 | distanceOpacity: 1.0, // [0.0, 1.0] 149 | distanceSize: 1.01, // Multiplier [0.5, 1.5] 150 | dotted: false, 151 | fillColor: '#dc3322', Only Closed 152 | fillOpacity: 0.42, // [0.0, 1.0] 153 | is3d: false, 154 | ringBorder: true, 155 | ringBorderColor: '#ffffff', 156 | ringFill: true, 157 | ringFillColor: '#dc3322', 158 | ringSize: 0.91, // Multiplier [0.6, 4.0] 159 | thickness: 0.13, // Multiplier [0.0, 5.0]. Only in 3D 160 | width: 0.23, // Multiplier [0.15, 2.0] 161 | } 162 | ``` 163 | *** 164 | ### Team Size 165 | The `tool_id` is `teamSize`. 166 | 167 | **Team** 168 | ``` 169 | team: 'T001' 170 | ``` 171 | 172 | **Line** 173 | ``` 174 | line: 'width' // Values: 'width' or 'length' 175 | ``` 176 | 177 | **Options** 178 | ``` 179 | options: { 180 | continuous: true, 181 | dotted: false, 182 | color: '#683391', 183 | x: 0.0, 184 | y: 0.0, 185 | width: 0.23, // [0.1, 5.0] 186 | edgeOpacity: 0.0, // [0.0, 1.0] 187 | opacity: 1.0, // [0.0, 1.0] 188 | thickness: 0.22, // Multiplier [0.0, 5.0]. Only in 3D 189 | dashSize: 0.6, // Multiplier [0.2, 2.5]. Only Dotted 190 | distance: true, 191 | distanceColor: '#ffffff', 192 | distancePosition: 1.12, // [0.5, 2.0] 193 | distanceOpacity: 1.0, // Multiplier [0.0, 1.0] 194 | distanceSize: 1.3, // Multiplier [0.5, 1.5] 195 | distanceIs3d: false, 196 | is3d: false 197 | } 198 | ``` 199 | *** 200 | ### Tactical Lines 201 | The `tool_id` is `tacticalLines`. 202 | 203 | **Team** 204 | ``` 205 | team: 'T001' 206 | ``` 207 | 208 | **Line** 209 | ``` 210 | line: 'defenders' // Values: 'defenders', 'midfielders' or 'strikers' 211 | ``` 212 | 213 | **Options** 214 | ``` 215 | options: { 216 | borderColor: "#ffffff", 217 | borderEdgeOpacity: 0.3, // [0.1, 1.0] 218 | borderOpacity: 1.0, // [0.0, 1.0] 219 | fillColor: "#ffffff", 220 | fillOpacity: 0.4, // [0.0, 1.0] 221 | closed: false, // Only used when line is 'midfielders' 222 | width: 0.23, // [0.1, 2.0] 223 | thickness: 0.3, // Multiplier [0.0, 5.0]. Only in 3D 224 | dashSize: 1.0, // Multiplier [0.2, 2.5]. Only Dotted 225 | continuous: true, 226 | dotted: false, 227 | is3d: false, 228 | ringBorder: true, 229 | ringBorderColor: "#ffffff", 230 | ringFill: true, 231 | ringFillColor: "#ffffff", 232 | ringSize: 0.6, // [0.6, 4.0] 233 | distance: true, 234 | distanceColor: "#ffffff", 235 | distancePosition: 0.5, // Multiplier [0.5, 2.0] 236 | distanceOpacity: 1.0, // [0.0, 1.0] 237 | distanceSize: 0.73, // [0.5, 1.5] 238 | distanceIs3d: false 239 | } 240 | ``` 241 | *** 242 | ### Line 3D 243 | The `tool_id` is `line3d`. 244 | 245 | **Points** 246 | ``` 247 | // Normalized 248 | points: { 249 | start: { x: 0.0, y: 0.0 }, 250 | end: { x: 0.0, y: 0.0 } 251 | } 252 | ``` 253 | 254 | **Options** 255 | ``` 256 | options: { 257 | arrowheadWidth: 1.5, // [0.99, 2.0] 258 | color: '#ff4f43', 259 | continuous: true, 260 | curvature: 0.0, // [-1.0, 1.0] 261 | dashSize: 0.4, // Multiplier [0.2, 2.5] 262 | distance: false, 263 | distanceColor: '#ffffff', 264 | distancePosition: 0.92, // Multiplier [0.5, 2.0] 265 | distanceOpacity: 1.0, // [0.0, 1.0] 266 | distanceSize: 1.01, // Multiplier [0.5, 4.0] 267 | distanceIs3d: false, 268 | dotted: false, 269 | dynamic: false, 270 | edgeOpacity: 0.2, // [0.0, 1.0] 271 | opacity: 0.9, // [0.0, 1.0] 272 | height: 0.075, // [0.0, 0.15] 273 | heightCenter: 0.0, // [-1.0, 1.0] 274 | is3d: false, 275 | pinned: false, 276 | thickness: 0.15, // Multiplier [0.0, 5.0] 277 | width: 0.5 // [0.1, 5.0] 278 | } 279 | ``` 280 | *** 281 | ### Freehand 282 | The `tool_id` is `freehand`. 283 | 284 | **Points** 285 | ``` 286 | // Normalized: screen-space or field-space if 'is3d' is enabled. 287 | points: [ 288 | { x: 0.0, y: 0.0 }, 289 | ..., 290 | { x: 0.0, y: 0.0 } 291 | ] 292 | ``` 293 | 294 | **Options** 295 | ``` 296 | options: { 297 | color: '#9edd34', 298 | arrowheadWidth: 3.0, // [0.99, 5.0] 299 | continuous: true, 300 | dashSize: 0.5, // Multiplier [0.2, 2.0]. Only Dotted 301 | dotted: false, 302 | offsetOpacity: 0.2, // [0.0, 1.0] 303 | opacity: 0.9, // [0.0, 1.0] 304 | is3d: false, 305 | pinned: false, 306 | thickness: 0.07, // Multiplier [0.0, 5.0]. Only in 3D 307 | width: 0.2 // [0.1, 0.2] 308 | } 309 | ``` 310 | *** 311 | ### Circle 312 | The `tool_id` is `circle`. 313 | 314 | **Center and Radius** 315 | ``` 316 | // Normalized: screen-space or field-space if 'is3d' is enabled. 317 | center: { x: 0.0, y: 0.0 }, 318 | radius: { x: 0.1, y: 0.1 } 319 | ``` 320 | 321 | **Options** 322 | ``` 323 | options: { 324 | borderColor: '#b3b3b3', 325 | borderOpacity: 1.0, // [0.0, 1.0] 326 | fillColor: '#ffdc3a', 327 | fillOpacity: 0.26, // [0.0, 1.0] 328 | fillSolid: true, 329 | fillPattern: false, 330 | is3d: false, 331 | pinned: false, 332 | thickness: 0.5, // Multiplier [0.0, 5.0]. Only in 3D 333 | width: 0.62 // [0.0, 3.0] 334 | } 335 | ``` 336 | *** 337 | ### Shape 338 | The `tool_id` is `shape`. 339 | 340 | **Points** 341 | ``` 342 | // Normalized: screen-space or field-space if 'is3d' is enabled. 343 | points: [ 344 | { x: 0.0, y: 0.0 }, 345 | ..., 346 | { x: 0.0, y: 0.0 } 347 | ] 348 | ``` 349 | 350 | **Options** 351 | ``` 352 | options: { 353 | borderColor: '#0062ad', 354 | borderOpacity: 1.0, // [0.1, 1.0] 355 | borderContinuous: false, 356 | borderDotted: true, 357 | closed: false, 358 | dashSize: 0.6, // Multiplier [0.5, 1.5]. Only Dotted 359 | distance: true, 360 | distanceColor: "#ffffff", 361 | distancePosition: 1.0, // Multiplier [0.5, 2.0] 362 | distanceOpacity: 1.0, // [0.0, 1.0] 363 | distanceSize: 1.0, // [0.5, 1.5] 364 | distanceIs3d: false, 365 | fillColor: '#0062ad', 366 | fillOpacity: 0.25, // [0.0, 1.0] 367 | fillSolid: true, 368 | fillPattern: false, 369 | is3d: false, 370 | pinned: false, 371 | thickness: 0.1, // Multiplier [0.0, 5.0]. Only in 3D 372 | width: 0.15 // [0.0, 2.0] 373 | } 374 | ``` 375 | *** 376 | ### Arrow 377 | The `tool_id` is `arrow`. 378 | 379 | **Points** 380 | ``` 381 | // Normalized: screen-space or field-space if 'is3d' is enabled. 382 | points: { 383 | start: { x: 0.0, y: 0.0 }, 384 | end: { x: 0.0, y: 0.0 } 385 | } 386 | ``` 387 | 388 | **Options** 389 | ``` 390 | options: { 391 | arrowheadWidth: 1.5, // [0.99, 2.0] 392 | color: '#ff4f43', 393 | continuous: true, 394 | curvature: 0.0, // Multiplier [-1.0, 1.0]. Only in 3D 395 | dashSize: 0.4, // Multiplier [0.2, 2.5]. Only Dotted 396 | distance: false, 397 | distanceColor: "#ffffff", 398 | distancePosition: 0.92, // Multiplier [0.5, 2.0] 399 | distanceOpacity: 1.0, // [0.0, 1.0] 400 | distanceSize: 1.01, // [0.5, 4.0] 401 | distanceIs3d: false, 402 | dotted: false, 403 | dynamic: false, 404 | edgeOpacity: 0.2, // [0.0, 1.0] 405 | opacity: 0.9, // [0.0, 1.0] 406 | height: 0.0, // [0.0, 0.15] 407 | heightCenter: 0.0, // [-1.0, 1.0] 408 | is3d: false, 409 | pinned: false, 410 | thickness: 0.15, // Multiplier [0.0, 5.0]. Only in 3D 411 | width: 0.5 // [0.1, 5.0] 412 | } 413 | ``` 414 | *** 415 | ### Dragger 416 | The `tool_id` is `dragger`. 417 | 418 | **Points** 419 | ``` 420 | // Normalized: screen-space only. Flag 'is3d' is only applied to arrow. 421 | points: { 422 | start: { x: 0.0, y: 0.0 }, 423 | end: { x: 0.0, y: 0.0 } 424 | } 425 | ``` 426 | 427 | **Options** 428 | ``` 429 | options: { 430 | arrowColor: '#ffdc3a', 431 | arrowContinuous: true, 432 | arrowDashSize: 0.6, // Multiplier [0.6, 2.5]. Only arrowDotted 433 | arrowDotted: false, 434 | arrowDynamic: false, 435 | arrowheadWidth: 2.0, // [0.0, 2.0] 436 | arrowEdgeOpacity: 0.2, // [0.0, 1.0] 437 | arrowOpacity: 0.9, // [0.0, 1.0] 438 | arrowOffsetY: 0.46, // [-1.0, 1.0] 439 | arrowThickness: 0.23, // Multiplier [0.0, 5.0]. Only in 3D 440 | arrowWidth: 0.65, // [0.2, 5.0] 441 | distance: false, 442 | distanceColor: "#ffffff", 443 | distanceIs3d: false, 444 | distancePosition: 1.0, // [0.5, 2.0] 445 | distanceOpacity: 1.0, // [0.0, 1.0] 446 | distanceSize: 1.0, // [0.5, 4.0] 447 | fade: 0.5, // [0.1, 1.0] 448 | is3d: false, // Refer to arrow 449 | opacity: 0.4, // [0.0, 1.0] 450 | scale: 1.0, // [0.2, 2.0] 451 | size: 1.0, // [0.5, 4.0] 452 | smoothing: 0.05, // [0.0, 1.0] 453 | threshold: 0.2 // [0.0, 1.0] 454 | } 455 | ``` 456 | *** 457 | ### Text Box 458 | The `tool_id` is `textBox`. 459 | 460 | **Text** 461 | ``` 462 | text: 'Insert Text' 463 | ``` 464 | 465 | **Position** 466 | ``` 467 | position: { x: 0.45, y: 0.45 } // Normalized: screen-space or field-space if 'is3d' is enabled. 468 | ``` 469 | 470 | **Options** 471 | ``` 472 | options: { 473 | width: 0.1, 474 | height: 0.1, 475 | size: 1.0, // [0.5, 4.0] 476 | rotation: 0.0, // [-Math.PI, Math.PI] 477 | color: "#ffffff", 478 | opacity: 1.0, // [0.0, 1.0] 479 | align: 'center', 480 | background: false, 481 | backgroundColor: "#000000", 482 | backgroundOpacity: 0.5, // [0.0, 1.0] 483 | is3d: false 484 | } 485 | ``` 486 | *** 487 | ### Image 488 | The `tool_id` is `image`. Image will be downloaded locally and placed in the workspace path. 489 | 490 | **URL** 491 | ``` 492 | url: 'https://www.google.com/images/branding/googlelogo/2x/googlelogo_color_92x30dp.png' 493 | ``` 494 | 495 | **Position** 496 | ``` 497 | position: { x: 0.25, y: 0.25 } // Normalized: screen-space or field-space if 'is3d' is enabled. 498 | ``` 499 | 500 | **Scale** 501 | ``` 502 | scale: { x: 0.5, y: 0.5 } // Normalized: screen-space or field-space if 'is3d' is enabled. 503 | ``` 504 | 505 | **Options** 506 | ``` 507 | options: { 508 | rotation: 0.0, // [-Math.PI, Math.PI] 509 | opacity: 1.0, // [0.0, 1.0] 510 | is3d: false 511 | } 512 | ``` 513 | 514 | *** 515 | ### Pause 516 | The `tool_id` is `pause`. 517 | 518 | **Pause Time** 519 | ``` 520 | pause_time: 5000 // Milliseconds 521 | ``` 522 | *** 523 | ### Chroma-Key 524 | The `tool_id` is `chromaKey`. It'll be computed on each clip created. Options should be set according to the scene, so if it remains similar during a game, maybe you want to adapt these values from a sample clip in Play and use them in all chroma-key events. Otherwise, you should not pass any option, use default values and fit them in each clip if needed. 525 | 526 | Since the order in which visualizations added declared in the event is preserved when they are imported in Play, the chroma-key tool should be added in the specific desired position. For example, if you want to add shape in the field and an arrow, but chroma key only to have an effect on the shape on the field, the order in the event should be: shape - chroma key - arrow. 527 | 528 | **Options** 529 | ``` 530 | options: { 531 | threshold: 0.01, // [0.0, 1.0] 532 | smoothing: 0.1 // [0.0, 1.0] 533 | } 534 | ``` 535 | 536 | ### Timer 537 | The `tool_id` is `timer`. 538 | 539 | **Options** 540 | ``` 541 | options: { 542 | x: 0.825, // Normalized: screen-space or field-space if 'is3d' is enabled. 543 | y: 0.02, // Normalized: screen-space or field-space if 'is3d' is enabled. 544 | width: 0.16, 545 | height: 0.15, 546 | offsetTime: 0, // Unix timestamp. 547 | decimals: 0, // 0 (no decimals), 1 (tenths of second), 2 (hundredths of second) and 3 (thousandths of second) 548 | size: 2.7, // [0.5, 4.0] 549 | rotation: 0.0, [-Math.PI, Math.PI] 550 | color: '#ffffff', 551 | opacity: 1.0, // [0.0, 1.0] 552 | background: true, 553 | backgroundColor: '#000000', 554 | backgroundOpacity: 0.75, // [0.0, 1.0] 555 | clockwise: true, 556 | is3d: false 557 | } 558 | ``` 559 | 560 | ### Offside 561 | The `tool_id` is `offside`. 562 | 563 | **Options** 564 | ``` 565 | options: { 566 | teamId: 'T001', 567 | defender: true, 568 | defenderColor: '#ffffff', 569 | defenderOpacity: 1.0, // [0.0, 1.0] 570 | defenderContinuous: true, 571 | defenderDotted: false, 572 | defenderWidth: 0.12, // [0.05, 1.0] 573 | defenderThickness: 0.05, // [0.0, 7.5] 574 | defenderDashSize: 0.6, // [0.2, 2.5] 575 | defenderFieldColor: '#000000', 576 | defenderFieldOpacity: 0.55, // [0.0, 1.0] 577 | defenderFieldFade: 5.0, // [1.0, 8.0] 578 | attacker: true, 579 | automaticColor: false, 580 | attackerColor: '#ffff00', 581 | attackerOpacity: 1.0, // [0.0, 1.0] 582 | attackerContinuous: true, 583 | attackerDotted: false, 584 | attackerWidth: 0.12, // [0.05, 1.0] 585 | attackerThickness: 0.05, // [0.0, 7.5] 586 | attackerDashSize: 0.6, // [0.2, 2.5] 587 | attackerFieldColor: '#ffff00', 588 | attackerFieldOpacity: 0.0, // [0.0, 1.0] 589 | attackerFieldFade: 2.0 // [1.0, 8.0] 590 | } 591 | ``` -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: Codeball v0.4.0 2 | site_url: https://codeball.metrica-sports.com 3 | repo_url: https://github.com/metrica-sports/codeball 4 | repo_name: 'Codeball' 5 | edit_uri: blob/master/docs/ 6 | 7 | theme: 8 | name: material 9 | palette: 10 | primary: teal 11 | 12 | plugins: 13 | - search 14 | - mkdocs-jupyter: 15 | include_source: True 16 | 17 | nav: 18 | - Home: index.md 19 | - GameDataset: game-dataset.md 20 | - CodeballFrames: codeball-frames.md 21 | - Patterns: patterns.md 22 | - Tactical: tactical.md 23 | - Metrica Play API: 24 | - What you can do: metrica-play-api.md 25 | - File Formatting: format-for-play.md 26 | - Visualizations: visualizations.md 27 | - How to import a file: how-to-import-to-play.md 28 | - Examples: 29 | - GameDataset: examples/game_dataset.ipynb 30 | - Example pattern: examples/example-pattern.md 31 | - Run patterns: examples/run_patterns.ipynb 32 | - Changelog: changelog.md 33 | 34 | markdown_extensions: 35 | - pymdownx.highlight: 36 | use_pygments: true 37 | linenums: true 38 | linenums_style: table 39 | 40 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | line-length = 79 3 | include = '\.pyi?$' 4 | exclude = ''' 5 | /( 6 | \.git 7 | | \.pytest_cache 8 | | \.mypy_cache 9 | | \.vscode 10 | | \.env 11 | )/ 12 | ''' -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/metrica-sports/codeball/06161ac69f9b7b53e3820537e780ab962c4349e6/requirements.txt -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | import pathlib 3 | 4 | here = pathlib.Path(__file__).parent.resolve() 5 | 6 | long_description = (here / "README.md").read_text(encoding="utf-8") 7 | 8 | setup( 9 | name="codeball", 10 | version="v0.4.0", 11 | author="Bruno Dagnino", 12 | author_email="bruno@metrica-sports.com", 13 | url="https://github.com/metrica-sports/codeball", 14 | packages=find_packages(exclude=["tests"]), 15 | description="Data driven tactical and video analysis of soccer games", 16 | long_description=long_description, 17 | long_description_content_type="text/markdown", 18 | classifiers=[ 19 | "Development Status :: 3 - Alpha", 20 | "Intended Audience :: Developers", 21 | "Intended Audience :: Science/Research", 22 | "Programming Language :: Python :: 3", 23 | "Programming Language :: Python :: 3.7", 24 | "Programming Language :: Python :: 3.8", 25 | "License :: OSI Approved :: MIT License", 26 | "Topic :: Scientific/Engineering", 27 | ], 28 | python_requires=">=3.7, <4", 29 | install_requires=[ 30 | "kloppy == 1.7.0", 31 | "pandas>=1.0.5", 32 | ], 33 | extras_require={ 34 | "test": ["pytest"], 35 | "development": ["pre-commit"], 36 | }, 37 | ) 38 | --------------------------------------------------------------------------------