From 92940d41151f9bfe795aba01638accb48e571be3 Mon Sep 17 00:00:00 2001 From: Koen Vossen Date: Tue, 28 Apr 2020 15:57:42 +0200 Subject: [PATCH 01/14] Metrica Tracking deserializer --- README.md | 8 +- kloppy/__init__.py | 2 +- kloppy/domain/__init__.py | 2 +- kloppy/domain/models/tracking.py | 9 +- kloppy/domain/services/__init__.py | 21 +++ kloppy/domain/services/enrichers/__init__.py | 49 ++++++ kloppy/infra/serializers/__init__.py | 3 +- kloppy/infra/serializers/tracking/__init__.py | 3 + .../infra/serializers/{ => tracking}/base.py | 6 +- kloppy/infra/serializers/tracking/metrica.py | 163 ++++++++++++++++++ .../serializers/{ => tracking}/tracab.py | 74 ++++---- setup.py | 2 +- 12 files changed, 288 insertions(+), 54 deletions(-) create mode 100644 kloppy/domain/services/enrichers/__init__.py create mode 100644 kloppy/infra/serializers/tracking/__init__.py rename kloppy/infra/serializers/{ => tracking}/base.py (63%) create mode 100644 kloppy/infra/serializers/tracking/metrica.py rename kloppy/infra/serializers/{ => tracking}/tracab.py (69%) diff --git a/README.md b/README.md index 844e538e..36e7d68c 100644 --- a/README.md +++ b/README.md @@ -45,12 +45,14 @@ from kloppy import TRACABSerializer serializer = TRACABSerializer() -with open("tracab_data.dat", "rb") as data, \ +with open("tracab_data.dat", "rb") as raw, \ open("tracab_metadata.xml", "rb") as meta: data_set = serializer.deserialize( - data=data, - metadata=meta, + inputs={ + 'raw_data': raw, + 'meta_data': meta + }, options={ "sample_rate": 1 / 12 } diff --git a/kloppy/__init__.py b/kloppy/__init__.py index 9c8fd125..b35a989c 100644 --- a/kloppy/__init__.py +++ b/kloppy/__init__.py @@ -1 +1 @@ -from .infra.serializers import TRACABSerializer \ No newline at end of file +from .infra.serializers import * \ No newline at end of file diff --git a/kloppy/domain/__init__.py b/kloppy/domain/__init__.py index 480c727e..820c0256 100644 --- a/kloppy/domain/__init__.py +++ b/kloppy/domain/__init__.py @@ -1,2 +1,2 @@ from .models import * -from .services.transformers import * +from .services import * diff --git a/kloppy/domain/models/tracking.py b/kloppy/domain/models/tracking.py index 0d748cbc..b6368ba0 100644 --- a/kloppy/domain/models/tracking.py +++ b/kloppy/domain/models/tracking.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from enum import Enum +from enum import Enum, Flag from typing import List, Optional, Dict from .pitch import ( @@ -113,6 +113,7 @@ def set_attacking_direction(self, attacking_direction: AttackingDirection): @dataclass class Frame(object): frame_id: int + timestamp: float ball_owning_team: BallOwningTeam ball_state: BallState @@ -123,8 +124,14 @@ class Frame(object): ball_position: Point +class DataSetFlag(Flag): + BALL_OWNING_TEAM = 1 + BALL_STATE = 2 + + @dataclass class DataSet(object): + flags: DataSetFlag pitch_dimensions: PitchDimensions orientation: Orientation diff --git a/kloppy/domain/services/__init__.py b/kloppy/domain/services/__init__.py index e69de29b..71ed4bd2 100644 --- a/kloppy/domain/services/__init__.py +++ b/kloppy/domain/services/__init__.py @@ -0,0 +1,21 @@ +from typing import List + +from .. import AttackingDirection, Frame + +from .transformers import Transformer +# from .enrichers import TrackingPossessionEnricher + + +def avg(items: List[float]) -> float: + return sum(items) / len(items) + + +def attacking_direction_from_frame(frame: Frame) -> AttackingDirection: + """ This method should only be called for the first frame of a """ + avg_x_home = avg([player.x for player in frame.home_team_player_positions.values() if player]) + avg_x_away = avg([player.x for player in frame.away_team_player_positions.values() if player]) + + if avg_x_home < avg_x_away: + return AttackingDirection.HOME_AWAY + else: + return AttackingDirection.AWAY_HOME diff --git a/kloppy/domain/services/enrichers/__init__.py b/kloppy/domain/services/enrichers/__init__.py new file mode 100644 index 00000000..4f6d2a13 --- /dev/null +++ b/kloppy/domain/services/enrichers/__init__.py @@ -0,0 +1,49 @@ +# from dataclasses import dataclass +# +# from ...models.tracking import DataSet as TrackingDataSet, BallState, BallOwningTeam, DataSetFlag +# from ...models.event import DataSet as EventDataSet +# +# +# @dataclass +# class GameState(object): +# ball_state: BallState +# ball_owning_team: BallOwningTeam +# +# +# class TrackingPossessionEnricher(object): +# def _reduce_game_state(self, game_state: GameState, Event: event) -> GameState: +# pass +# +# def enrich_inplace(self, tracking_data_set: TrackingDataSet, event_data_set: EventDataSet) -> None: +# """ +# Return an enriched tracking data set. +# +# Use the event data to rebuild game state. +# +# Iterate through all tracking data events and apply event data to the GameState whenever +# they happen. +# +# """ +# if tracking_data_set.flags & (DataSetFlag.BALL_OWNING_TEAM | DataSetFlag.BALL_STATE): +# return +# +# # set some defaults +# game_state = GameState( +# ball_state=BallState.DEAD, +# ball_owning_team=BallOwningTeam.HOME +# ) +# +# next_event_idx = 0 +# +# for frame in tracking_data_set.frames: +# if next_event_idx < len(event_data_set.frames): +# event = event_data_set.events[next_event_idx] +# if frame.period.id == event.period.id and frame.timestamp >= event.timestamp: +# game_state = self._reduce_game_state( +# game_state, +# event_data_set.events[next_event_idx] +# ) +# next_event_idx += 1 +# +# frame.ball_owning_team = game_state.ball_owning_team +# frame.ball_state = game_state.ball_state diff --git a/kloppy/infra/serializers/__init__.py b/kloppy/infra/serializers/__init__.py index b72a088d..40024c0f 100644 --- a/kloppy/infra/serializers/__init__.py +++ b/kloppy/infra/serializers/__init__.py @@ -1,2 +1 @@ -from .base import TrackingDataSerializer -from .tracab import TRACABSerializer \ No newline at end of file +from .tracking import TrackingDataSerializer, TRACABSerializer, MetricaTrackingSerializer diff --git a/kloppy/infra/serializers/tracking/__init__.py b/kloppy/infra/serializers/tracking/__init__.py new file mode 100644 index 00000000..3e2bd090 --- /dev/null +++ b/kloppy/infra/serializers/tracking/__init__.py @@ -0,0 +1,3 @@ +from .base import TrackingDataSerializer +from .tracab import TRACABSerializer +from .metrica import MetricaTrackingSerializer diff --git a/kloppy/infra/serializers/base.py b/kloppy/infra/serializers/tracking/base.py similarity index 63% rename from kloppy/infra/serializers/base.py rename to kloppy/infra/serializers/tracking/base.py index 306fd0a4..4d02deff 100644 --- a/kloppy/infra/serializers/base.py +++ b/kloppy/infra/serializers/tracking/base.py @@ -1,13 +1,13 @@ from abc import ABC, abstractmethod from typing import Tuple, Dict -from ..utils import Readable -from ...domain.models import DataSet +from ...utils import Readable +from ....domain.models import DataSet class TrackingDataSerializer(ABC): @abstractmethod - def deserialize(self, data: Readable, metadata: Readable, options: Dict) -> DataSet: + def deserialize(self, inputs: Dict[str, Readable], options: Dict = None) -> DataSet: raise NotImplementedError @abstractmethod diff --git a/kloppy/infra/serializers/tracking/metrica.py b/kloppy/infra/serializers/tracking/metrica.py new file mode 100644 index 00000000..a3569d36 --- /dev/null +++ b/kloppy/infra/serializers/tracking/metrica.py @@ -0,0 +1,163 @@ +from typing import Tuple, List, Dict, Iterator + +import heapq + +from ....domain import attacking_direction_from_frame +from ....domain.models import ( + DataSet, + AttackingDirection, + Frame, + Point, + Period, + Orientation, + PitchDimensions, + Dimension, DataSetFlag) +from ...utils import Readable, performance_logging +from . import TrackingDataSerializer + + +# PartialFrame = namedtuple("PartialFrame", "team period frame_id player_positions ball_position") + + +def create_iterator(data: Readable, sample_rate: float) -> Iterator: + """ + Sample file: + + ,,,Away,,Away,,Away,,Away,,Away,,Away,,Away,,Away,,Away,,Away,,Away,,Away,,Away,,Away,,Away, + ,,,25,,15,,16,,17,,18,,19,,20,,21,,22,,23,,24,,26,,27,,28,,, + Period,Frame,Time [s],Player25,,Player15,,Player16,,Player17,,Player18,,Player19,,Player20,,Player21,,Player22,,Player23,,Player24,,Player26,,Player27,,Player28,,Ball, + 1,1,0.04,0.90509,0.47462,0.58393,0.20794,0.67658,0.4671,0.6731,0.76476,0.40783,0.61525,0.45472,0.38709,0.5596,0.67775,0.55243,0.43269,0.50067,0.94322,0.43693,0.05002,0.37833,0.27383,NaN,NaN,NaN,NaN,NaN,NaN,0.45472,0.38709 + + Notes: + 1. the y-axe is flipped because Metrica use (y, -y) instead of (-y, y) + """ + # lines = list(map(lambda x: x.strip().decode("ascii"), data.readlines())) + + team = None + frame_idx = 0 + frame_sample = 1 / sample_rate + player_jersey_numbers = [] + period = None + + for i, line in enumerate(data): + line = line.strip().decode('ascii') + columns = line.split(',') + if i == 0: + # team + pass + elif i == 1: + player_jersey_numbers = columns[3:-2:2] + elif i == 2: + # consider doing some validation on the columns + pass + else: + + period_id = int(columns[0]) + frame_id = int(columns[1]) + + if period is None or period.id != period_id: + period = Period( + id=period_id, + start_frame_id=frame_id, + end_frame_id=frame_id + ) + else: + # consider not update this every frame for performance reasons + period.end_frame_id = frame_id + + if frame_idx % frame_sample == 0: + yield dict( + # Period will be updated during reading the file.... + # Might introduce bugs here + period=period, + frame_id=frame_id, + player_positions={ + player_no: Point( + x=float(columns[3 + i * 2]), + y=-1 * float(columns[3 + i * 2 + 1]) + ) + for i, player_no in enumerate(player_jersey_numbers) + if columns[3 + i * 2] != 'NaN' + }, + ball_position=Point( + x=float(columns[-2]), + y=-1 * float(columns[-1]) + ) + ) + frame_idx += 1 + + +class MetricaTrackingSerializer(TrackingDataSerializer): + @staticmethod + def __validate_inputs(inputs: Dict[str, Readable]): + if "home_raw_data" not in inputs: + raise ValueError("Please specify a value for 'home_raw_data'") + if "away_raw_data" not in inputs: + raise ValueError("Please specify a value for 'away_raw_data'") + + def deserialize(self, inputs: Dict[str, Readable], options: Dict = None) -> DataSet: + self.__validate_inputs(inputs) + if not options: + options = {} + + sample_rate = float(options.get('sample_rate', 1.0)) + + with performance_logging("prepare"): + home_iterator = create_iterator(inputs['home_raw_data'], sample_rate) + away_iterator = create_iterator(inputs['away_raw_data'], sample_rate) + + partial_frames = zip(home_iterator, away_iterator) + + with performance_logging("loading"): + frames = [] + periods = [] + # consider reading this from data + frame_rate = 25 + for home_partial_frame, away_partial_frame in partial_frames: + assert home_partial_frame['frame_id'] == away_partial_frame['frame_id'], "Mismatch" + + period: Period = home_partial_frame['period'] + frame_id: int = home_partial_frame['frame_id'] + + frame = Frame( + frame_id=home_partial_frame['frame_id'], + # -1 needed because frame_id is 1-based + timestamp=(frame_id - (period.start_frame_id - 1)) / frame_rate, + ball_position=home_partial_frame['ball_position'], + home_team_player_positions=home_partial_frame['player_positions'], + away_team_player_positions=away_partial_frame['player_positions'], + period=period, + ball_state=None, + ball_owning_team=None + ) + + frames.append(frame) + + if not periods or period.id != periods[-1].id: + periods.append(period) + + if not period.attacking_direction_set: + period.set_attacking_direction( + attacking_direction=attacking_direction_from_frame(frame) + ) + + orientation = ( + Orientation.FIXED_HOME_AWAY + if periods[0].attacking_direction == AttackingDirection.HOME_AWAY else + Orientation.FIXED_AWAY_HOME + ) + + return DataSet( + flags=~(DataSetFlag.BALL_STATE | DataSetFlag.BALL_OWNING_TEAM), + frame_rate=frame_rate, + orientation=orientation, + pitch_dimensions=PitchDimensions( + x_dim=Dimension(0, 1), + y_dim=Dimension(0, 1) + ), + periods=periods, + frames=frames + ) + + def serialize(self, data_set: DataSet) -> Tuple[str, str]: + raise NotImplementedError diff --git a/kloppy/infra/serializers/tracab.py b/kloppy/infra/serializers/tracking/tracab.py similarity index 69% rename from kloppy/infra/serializers/tracab.py rename to kloppy/infra/serializers/tracking/tracab.py index 092c23c5..1bda4e4c 100644 --- a/kloppy/infra/serializers/tracab.py +++ b/kloppy/infra/serializers/tracking/tracab.py @@ -1,8 +1,8 @@ -from typing import Tuple, List, Dict +from typing import Tuple, Dict from lxml import objectify -from ...domain.models import ( +from ....domain import ( DataSet, AttackingDirection, Frame, @@ -12,29 +12,16 @@ Period, Orientation, PitchDimensions, - Dimension) -from ..utils import Readable, performance_logging + Dimension, + attacking_direction_from_frame, + DataSetFlag) +from ...utils import Readable, performance_logging from . import TrackingDataSerializer -def avg(items: List[float]) -> float: - return sum(items) / len(items) - - -def attacking_direction_from_frame(frame: Frame) -> AttackingDirection: - """ This method should only be called for the first frame of a """ - avg_x_home = avg([player.x for player in frame.home_team_player_positions.values()]) - avg_x_away = avg([player.x for player in frame.away_team_player_positions.values()]) - - if avg_x_home < avg_x_away: - return AttackingDirection.HOME_AWAY - else: - return AttackingDirection.AWAY_HOME - - class TRACABSerializer(TrackingDataSerializer): @classmethod - def _frame_from_line(cls, period, line): + def _frame_from_line(cls, period, line, frame_rate): line = str(line) frame_id, players, ball = line.strip().split(":")[:3] @@ -52,8 +39,11 @@ def _frame_from_line(cls, period, line): ball_x, ball_y, ball_z, ball_speed, ball_owning_team, ball_state = ball.rstrip(";").split(",")[:6] + frame_id = int(frame_id) + return Frame( - frame_id=int(frame_id), + frame_id=frame_id, + timestamp=(frame_id - period.start_frame_id) / frame_rate, ball_position=Point(float(ball_x), float(ball_y)), ball_state=BallState.from_string(ball_state), ball_owning_team=BallOwningTeam.from_string(ball_owning_team), @@ -62,7 +52,16 @@ def _frame_from_line(cls, period, line): period=period ) - def deserialize(self, data: Readable, metadata, options: Dict = None) -> DataSet: + @staticmethod + def __validate_inputs(inputs: Dict[str, Readable]): + if "meta_data" not in inputs: + raise ValueError("Please specify a value for 'meta_data'") + if "raw_data" not in inputs: + raise ValueError("Please specify a value for 'raw_data'") + + def deserialize(self, inputs: Dict[str, Readable], options: Dict = None) -> DataSet: + self.__validate_inputs(inputs) + if not options: options = {} @@ -70,7 +69,7 @@ def deserialize(self, data: Readable, metadata, options: Dict = None) -> DataSet only_alive = bool(options.get('only_alive', True)) with performance_logging("Loading metadata"): - match = objectify.fromstring(metadata.read()).match + match = objectify.fromstring(inputs['meta_data'].read()).match frame_rate = int(match.attrib['iFrameRateFps']) pitch_size_width = float(match.attrib['fPitchXSizeMeters']) pitch_size_height = float(match.attrib['fPitchYSizeMeters']) @@ -88,13 +87,12 @@ def deserialize(self, data: Readable, metadata, options: Dict = None) -> DataSet ) ) - original_orientation = None with performance_logging("Loading data"): def _iter(): n = 0 sample = 1. / sample_rate - for line in data.readlines(): + for line in inputs['data'].readlines(): line = line.strip().decode("ascii") frame_id = int(line[:10].split(":", 1)[0]) @@ -109,23 +107,10 @@ def _iter(): frames = [] for period, line in _iter(): - if not original_orientation: - # determine orientation of entire dataset - frame = self._frame_from_line( - period, - line - ) - - attacking_direction = attacking_direction_from_frame(frame) - original_orientation = ( - Orientation.FIXED_HOME_AWAY - if attacking_direction == AttackingDirection.HOME_AWAY else - Orientation.FIXED_AWAY_HOME - ) - frame = self._frame_from_line( period, - line + line, + frame_rate ) if not period.attacking_direction_set: @@ -133,11 +118,16 @@ def _iter(): attacking_direction=attacking_direction_from_frame(frame) ) - frames.append(frame) + orientation = ( + Orientation.FIXED_HOME_AWAY + if periods[0].attacking_direction == AttackingDirection.HOME_AWAY else + Orientation.FIXED_AWAY_HOME + ) return DataSet( + flags=DataSetFlag.BALL_OWNING_TEAM | DataSetFlag.BALL_STATE, frame_rate=frame_rate, - orientation=original_orientation, + orientation=orientation, pitch_dimensions=PitchDimensions( x_dim=Dimension(-1 * pitch_size_width / 2, pitch_size_width / 2), y_dim=Dimension(-1 * pitch_size_height / 2, pitch_size_height / 2), diff --git a/setup.py b/setup.py index 36a05843..dfeaedb3 100644 --- a/setup.py +++ b/setup.py @@ -23,7 +23,7 @@ author='Koen Vossen', author_email='info@koenvossen.nl', url="https://github.com/PySport/kloppy", - packages=setuptools.find_packages(), + packages=setuptools.find_packages(exclude=["test"]), license='Creative Commons Attribution-Noncommercial-Share Alike license', description="Standardizing soccer tracking- and event data", long_description="\n".join(DOCLINES), From a83c30b649fabe21d4637c3e0a9daf2eabe2bd59 Mon Sep 17 00:00:00 2001 From: Koen Vossen Date: Tue, 28 Apr 2020 16:27:55 +0200 Subject: [PATCH 02/14] Some more fixes --- kloppy/domain/services/__init__.py | 2 +- .../domain/services/transformers/__init__.py | 15 +++------ kloppy/infra/serializers/tracking/base.py | 4 +-- kloppy/infra/serializers/tracking/metrica.py | 32 ++++++++----------- kloppy/infra/serializers/tracking/tracab.py | 5 +-- 5 files changed, 25 insertions(+), 33 deletions(-) diff --git a/kloppy/domain/services/__init__.py b/kloppy/domain/services/__init__.py index 71ed4bd2..10b8ac29 100644 --- a/kloppy/domain/services/__init__.py +++ b/kloppy/domain/services/__init__.py @@ -1,6 +1,6 @@ from typing import List -from .. import AttackingDirection, Frame +from kloppy.domain import AttackingDirection, Frame from .transformers import Transformer # from .enrichers import TrackingPossessionEnricher diff --git a/kloppy/domain/services/transformers/__init__.py b/kloppy/domain/services/transformers/__init__.py index e23a8b66..54cdc0e1 100644 --- a/kloppy/domain/services/transformers/__init__.py +++ b/kloppy/domain/services/transformers/__init__.py @@ -1,4 +1,4 @@ -from ...models import ( +from kloppy.domain import ( Point, PitchDimensions, Orientation, @@ -6,11 +6,6 @@ DataSet, BallOwningTeam, AttackingDirection) -class VoidPointTransformer(object): - def transform_point(self, point: Point) -> Point: - return point - - class Transformer(object): def __init__(self, from_pitch_dimensions: PitchDimensions, from_orientation: Orientation, @@ -35,7 +30,7 @@ def transform_point(self, point: Point, flip: bool) -> Point: y=self._to_pitch_dimensions.y_dim.from_base(y_base) ) - def get_clip(self, ball_owning_team: BallOwningTeam, attacking_direction: AttackingDirection) -> bool: + def __needs_flip(self, ball_owning_team: BallOwningTeam, attacking_direction: AttackingDirection) -> bool: if self._from_orientation == self._to_orientation: flip = False else: @@ -53,13 +48,14 @@ def get_clip(self, ball_owning_team: BallOwningTeam, attacking_direction: Attack return flip def transform_frame(self, frame: Frame) -> Frame: - flip = self.get_clip( + flip = self.__needs_flip( ball_owning_team=frame.ball_owning_team, attacking_direction=frame.period.attacking_direction ) return Frame( # doesn't change + timestamp=frame.timestamp, frame_id=frame.frame_id, ball_owning_team=frame.ball_owning_team, ball_state=frame.ball_state, @@ -67,8 +63,6 @@ def transform_frame(self, frame: Frame) -> Frame: # changes ball_position=self.transform_point(frame.ball_position, flip), - - # bla home_team_player_positions={ jersey_no: self.transform_point(point, flip) for jersey_no, point @@ -102,6 +96,7 @@ def transform_data_set(cls, frames = list(map(transformer.transform_frame, data_set.frames)) return DataSet( + flags=data_set.flags, frame_rate=data_set.frame_rate, periods=data_set.periods, pitch_dimensions=to_pitch_dimensions, diff --git a/kloppy/infra/serializers/tracking/base.py b/kloppy/infra/serializers/tracking/base.py index 4d02deff..237382ed 100644 --- a/kloppy/infra/serializers/tracking/base.py +++ b/kloppy/infra/serializers/tracking/base.py @@ -1,8 +1,8 @@ from abc import ABC, abstractmethod from typing import Tuple, Dict -from ...utils import Readable -from ....domain.models import DataSet +from kloppy.infra.utils import Readable +from kloppy.domain import DataSet class TrackingDataSerializer(ABC): diff --git a/kloppy/infra/serializers/tracking/metrica.py b/kloppy/infra/serializers/tracking/metrica.py index a3569d36..4871cecc 100644 --- a/kloppy/infra/serializers/tracking/metrica.py +++ b/kloppy/infra/serializers/tracking/metrica.py @@ -1,22 +1,18 @@ -from typing import Tuple, List, Dict, Iterator - -import heapq - -from ....domain import attacking_direction_from_frame -from ....domain.models import ( - DataSet, - AttackingDirection, - Frame, - Point, - Period, - Orientation, - PitchDimensions, - Dimension, DataSetFlag) -from ...utils import Readable, performance_logging -from . import TrackingDataSerializer - +from typing import Tuple, Dict, Iterator + +from kloppy.domain import (attacking_direction_from_frame, + DataSet, + AttackingDirection, + Frame, + Point, + Period, + Orientation, + PitchDimensions, + Dimension, + DataSetFlag) +from kloppy.infra.utils import Readable, performance_logging -# PartialFrame = namedtuple("PartialFrame", "team period frame_id player_positions ball_position") +from . import TrackingDataSerializer def create_iterator(data: Readable, sample_rate: float) -> Iterator: diff --git a/kloppy/infra/serializers/tracking/tracab.py b/kloppy/infra/serializers/tracking/tracab.py index 1bda4e4c..da7a22a5 100644 --- a/kloppy/infra/serializers/tracking/tracab.py +++ b/kloppy/infra/serializers/tracking/tracab.py @@ -2,7 +2,7 @@ from lxml import objectify -from ....domain import ( +from kloppy.domain import ( DataSet, AttackingDirection, Frame, @@ -15,7 +15,8 @@ Dimension, attacking_direction_from_frame, DataSetFlag) -from ...utils import Readable, performance_logging +from kloppy.infra.utils import Readable, performance_logging + from . import TrackingDataSerializer From 913f7867efd54a352d2312b471c0e5b3705477cb Mon Sep 17 00:00:00 2001 From: Koen Vossen Date: Tue, 28 Apr 2020 17:40:10 +0200 Subject: [PATCH 03/14] Cleanup some more code --- CHANGES.txt | 4 +- README.md | 23 ++++++++ kloppy/domain/models/pitch.py | 4 +- kloppy/domain/models/tracking.py | 21 ++++---- kloppy/domain/services/enrichers/__init__.py | 4 +- .../domain/services/transformers/__init__.py | 15 +++--- kloppy/infra/serializers/tracking/metrica.py | 25 ++++----- kloppy/tests/__init__.py | 0 kloppy/tests/test_metrica.py | 53 +++++++++++++++++++ setup.py | 14 ++++- 10 files changed, 127 insertions(+), 36 deletions(-) create mode 100644 kloppy/tests/__init__.py create mode 100644 kloppy/tests/test_metrica.py diff --git a/CHANGES.txt b/CHANGES.txt index d126a68d..3ef60d19 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1 +1,3 @@ -v0.1, 2020-04-23 -- Initial release. \ No newline at end of file +v0.1, 2020-04-23 -- Initial release. +v0.2, 2020-XX-XX -- Add Metrica Tracking Serializer including automated tests + Cleanup some import statements diff --git a/README.md b/README.md index 36e7d68c..ad62d6ab 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,29 @@ with open("tracab_data.dat", "rb") as raw, \ # start working with data_set ``` +or Metrica data +```python +from kloppy import MetricaTrackingSerializer + +serializer = MetricaTrackingSerializer() + +with open("Sample_Game_1_RawTrackingData_Away_Team.csv", "rb") as raw_home, \ + open("Sample_Game_1_RawTrackingData_Home_Team.csv", "rb") as raw_away: + + data_set = serializer.deserialize( + inputs={ + 'raw_data_home': raw_home, + 'raw_data_away': raw_away + }, + options={ + "sample_rate": 1 / 12 + } + ) + + # start working with data_set +``` + + ### Transform the pitch dimensions Data providers use their own pitch dimensions. Some use actual meters while others use 100x100. Use the Transformer to get from one pitch dimensions to another one. ```python diff --git a/kloppy/domain/models/pitch.py b/kloppy/domain/models/pitch.py index 6bc30830..86d7ef95 100644 --- a/kloppy/domain/models/pitch.py +++ b/kloppy/domain/models/pitch.py @@ -2,7 +2,7 @@ @dataclass -class Dimension(object): +class Dimension: min: float max: float @@ -14,7 +14,7 @@ def from_base(self, value: float) -> float: @dataclass -class PitchDimensions(object): +class PitchDimensions: x_dim: Dimension y_dim: Dimension x_per_meter: float = None diff --git a/kloppy/domain/models/tracking.py b/kloppy/domain/models/tracking.py index b6368ba0..9fba6bb2 100644 --- a/kloppy/domain/models/tracking.py +++ b/kloppy/domain/models/tracking.py @@ -8,7 +8,7 @@ ) -class Player(object): +class Player: jersey_no: str position: Point @@ -59,25 +59,24 @@ class Orientation(Enum): FIXED_HOME_AWAY = "fixed-home-away" FIXED_AWAY_HOME = "fixed-away-home" - @staticmethod - def get_orientation_factor(orientation: 'Orientation', + def get_orientation_factor(self, attacking_direction: AttackingDirection, ball_owning_team: BallOwningTeam): - if orientation == Orientation.FIXED_HOME_AWAY: + if self == Orientation.FIXED_HOME_AWAY: return -1 - elif orientation == Orientation.FIXED_AWAY_HOME: + elif self == Orientation.FIXED_AWAY_HOME: return 1 - elif orientation == Orientation.HOME_TEAM: + elif self == Orientation.HOME_TEAM: if attacking_direction == AttackingDirection.HOME_AWAY: return -1 else: return 1 - elif orientation == Orientation.AWAY_TEAM: + elif self == Orientation.AWAY_TEAM: if attacking_direction == AttackingDirection.AWAY_HOME: return -1 else: return 1 - elif orientation == Orientation.BALL_OWNING_TEAM: + elif self == Orientation.BALL_OWNING_TEAM: if ((ball_owning_team == BallOwningTeam.HOME and attacking_direction == AttackingDirection.HOME_AWAY) or @@ -89,7 +88,7 @@ def get_orientation_factor(orientation: 'Orientation', @dataclass -class Period(object): +class Period: id: int start_frame_id: int end_frame_id: int @@ -111,7 +110,7 @@ def set_attacking_direction(self, attacking_direction: AttackingDirection): @dataclass -class Frame(object): +class Frame: frame_id: int timestamp: float ball_owning_team: BallOwningTeam @@ -130,7 +129,7 @@ class DataSetFlag(Flag): @dataclass -class DataSet(object): +class DataSet: flags: DataSetFlag pitch_dimensions: PitchDimensions orientation: Orientation diff --git a/kloppy/domain/services/enrichers/__init__.py b/kloppy/domain/services/enrichers/__init__.py index 4f6d2a13..73e61418 100644 --- a/kloppy/domain/services/enrichers/__init__.py +++ b/kloppy/domain/services/enrichers/__init__.py @@ -5,12 +5,12 @@ # # # @dataclass -# class GameState(object): +# class GameState: # ball_state: BallState # ball_owning_team: BallOwningTeam # # -# class TrackingPossessionEnricher(object): +# class TrackingPossessionEnricher: # def _reduce_game_state(self, game_state: GameState, Event: event) -> GameState: # pass # diff --git a/kloppy/domain/services/transformers/__init__.py b/kloppy/domain/services/transformers/__init__.py index 54cdc0e1..477da9a6 100644 --- a/kloppy/domain/services/transformers/__init__.py +++ b/kloppy/domain/services/transformers/__init__.py @@ -3,10 +3,10 @@ PitchDimensions, Orientation, Frame, - DataSet, BallOwningTeam, AttackingDirection) + DataSet, BallOwningTeam, AttackingDirection, DataSetFlag) -class Transformer(object): +class Transformer: def __init__(self, from_pitch_dimensions: PitchDimensions, from_orientation: Orientation, to_pitch_dimensions: PitchDimensions, to_orientation: Orientation): @@ -34,13 +34,11 @@ def __needs_flip(self, ball_owning_team: BallOwningTeam, attacking_direction: At if self._from_orientation == self._to_orientation: flip = False else: - orientation_factor_from = Orientation.get_orientation_factor( - orientation=self._from_orientation, + orientation_factor_from = self._from_orientation.get_orientation_factor( ball_owning_team=ball_owning_team, attacking_direction=attacking_direction ) - orientation_factor_to = Orientation.get_orientation_factor( - orientation=self._to_orientation, + orientation_factor_to = self._to_orientation.get_orientation_factor( ball_owning_team=ball_owning_team, attacking_direction=attacking_direction ) @@ -87,6 +85,11 @@ def transform_data_set(cls, elif not to_pitch_dimensions: to_pitch_dimensions = data_set.pitch_dimensions + if to_orientation == Orientation.BALL_OWNING_TEAM: + if not data_set.flags & DataSetFlag.BALL_OWNING_TEAM: + raise ValueError("Cannot transform to BALL_OWNING_TEAM orientation when dataset doesn't contain " + "ball owning team data") + transformer = cls( from_pitch_dimensions=data_set.pitch_dimensions, from_orientation=data_set.orientation, diff --git a/kloppy/infra/serializers/tracking/metrica.py b/kloppy/infra/serializers/tracking/metrica.py index 4871cecc..74f0ff30 100644 --- a/kloppy/infra/serializers/tracking/metrica.py +++ b/kloppy/infra/serializers/tracking/metrica.py @@ -25,7 +25,7 @@ def create_iterator(data: Readable, sample_rate: float) -> Iterator: 1,1,0.04,0.90509,0.47462,0.58393,0.20794,0.67658,0.4671,0.6731,0.76476,0.40783,0.61525,0.45472,0.38709,0.5596,0.67775,0.55243,0.43269,0.50067,0.94322,0.43693,0.05002,0.37833,0.27383,NaN,NaN,NaN,NaN,NaN,NaN,0.45472,0.38709 Notes: - 1. the y-axe is flipped because Metrica use (y, -y) instead of (-y, y) + 1. the y-axis is flipped because Metrica use (y, -y) instead of (-y, y) """ # lines = list(map(lambda x: x.strip().decode("ascii"), data.readlines())) @@ -39,15 +39,13 @@ def create_iterator(data: Readable, sample_rate: float) -> Iterator: line = line.strip().decode('ascii') columns = line.split(',') if i == 0: - # team - pass + team = columns[3] elif i == 1: player_jersey_numbers = columns[3:-2:2] elif i == 2: # consider doing some validation on the columns pass else: - period_id = int(columns[0]) frame_id = int(columns[1]) @@ -63,6 +61,7 @@ def create_iterator(data: Readable, sample_rate: float) -> Iterator: if frame_idx % frame_sample == 0: yield dict( + team=team, # Period will be updated during reading the file.... # Might introduce bugs here period=period, @@ -70,14 +69,14 @@ def create_iterator(data: Readable, sample_rate: float) -> Iterator: player_positions={ player_no: Point( x=float(columns[3 + i * 2]), - y=-1 * float(columns[3 + i * 2 + 1]) + y=1 - float(columns[3 + i * 2 + 1]) ) for i, player_no in enumerate(player_jersey_numbers) if columns[3 + i * 2] != 'NaN' }, ball_position=Point( x=float(columns[-2]), - y=-1 * float(columns[-1]) + y=1 - float(columns[-1]) ) ) frame_idx += 1 @@ -86,10 +85,10 @@ def create_iterator(data: Readable, sample_rate: float) -> Iterator: class MetricaTrackingSerializer(TrackingDataSerializer): @staticmethod def __validate_inputs(inputs: Dict[str, Readable]): - if "home_raw_data" not in inputs: - raise ValueError("Please specify a value for 'home_raw_data'") - if "away_raw_data" not in inputs: - raise ValueError("Please specify a value for 'away_raw_data'") + if "raw_data_home" not in inputs: + raise ValueError("Please specify a value for input 'raw_data_home'") + if "raw_data_away" not in inputs: + raise ValueError("Please specify a value for input 'raw_data_away'") def deserialize(self, inputs: Dict[str, Readable], options: Dict = None) -> DataSet: self.__validate_inputs(inputs) @@ -99,8 +98,8 @@ def deserialize(self, inputs: Dict[str, Readable], options: Dict = None) -> Data sample_rate = float(options.get('sample_rate', 1.0)) with performance_logging("prepare"): - home_iterator = create_iterator(inputs['home_raw_data'], sample_rate) - away_iterator = create_iterator(inputs['away_raw_data'], sample_rate) + home_iterator = create_iterator(inputs['raw_data_home'], sample_rate) + away_iterator = create_iterator(inputs['raw_data_away'], sample_rate) partial_frames = zip(home_iterator, away_iterator) @@ -111,6 +110,8 @@ def deserialize(self, inputs: Dict[str, Readable], options: Dict = None) -> Data frame_rate = 25 for home_partial_frame, away_partial_frame in partial_frames: assert home_partial_frame['frame_id'] == away_partial_frame['frame_id'], "Mismatch" + assert home_partial_frame['team'] == 'Home', "Wrong file passed for home data" + assert away_partial_frame['team'] == 'Away', "Wrong file passed for away data" period: Period = home_partial_frame['period'] frame_id: int = home_partial_frame['frame_id'] diff --git a/kloppy/tests/__init__.py b/kloppy/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/kloppy/tests/test_metrica.py b/kloppy/tests/test_metrica.py new file mode 100644 index 00000000..98051b38 --- /dev/null +++ b/kloppy/tests/test_metrica.py @@ -0,0 +1,53 @@ +from io import BytesIO + +from kloppy import MetricaTrackingSerializer +from kloppy.domain import Period, AttackingDirection, Orientation, Point + + +class TestMetricaTracking: + def test_deserialization(self): + raw_data_home = BytesIO(b""",,,Home,,Home,,Home,,Home,,Home,,Home,,Home,,Home,,Home,,Home,,Home,,Home,,Home,,Home,,, +,,,11,,1,,2,,3,,4,,5,,6,,7,,8,,9,,10,,12,,13,,14,,, +Period,Frame,Time [s],Player11,,Player1,,Player2,,Player3,,Player4,,Player5,,Player6,,Player7,,Player8,,Player9,,Player10,,Player12,,Player13,,Player14,,Ball, +1,1,0.04,0.00082,0.48238,0.32648,0.65322,0.33701,0.48863,0.30927,0.35529,0.32137,0.21262,0.41094,0.72589,0.41698,0.47843,0.39125,0.3255,0.45388,0.21174,0.52697,0.3798,0.55243,0.43269,NaN,NaN,NaN,NaN,NaN,NaN,0.45472,0.38709 +1,2,0.08,0.00096,0.48238,0.32648,0.65322,0.33701,0.48863,0.30927,0.35529,0.32137,0.21262,0.41094,0.72589,0.41698,0.47843,0.39125,0.3255,0.45388,0.21174,0.52697,0.3798,0.55243,0.43269,NaN,NaN,NaN,NaN,NaN,NaN,0.49645,0.40656 +1,3,0.12,0.00114,0.48238,0.32648,0.65322,0.33701,0.48863,0.30927,0.35529,0.32137,0.21262,0.41094,0.72589,0.41698,0.47843,0.39125,0.3255,0.45388,0.21174,0.52697,0.3798,0.55243,0.43269,NaN,NaN,NaN,NaN,NaN,NaN,0.53716,0.42556 +2,145004,5800.16,0.90492,0.45355,NaN,NaN,0.34089,0.64569,0.31214,0.67501,0.11428,0.92765,0.25757,0.60019,NaN,NaN,0.37398,0.62446,0.17401,0.83396,0.1667,0.76677,NaN,NaN,0.30044,0.68311,0.33637,0.65366,0.34089,0.64569,NaN,NaN +2,145005,5800.2,0.90456,0.45356,NaN,NaN,0.34056,0.64552,0.31171,0.67468,0.11428,0.92765,0.25721,0.60089,NaN,NaN,0.37398,0.62446,0.17358,0.8343,0.16638,0.76665,NaN,NaN,0.30044,0.68311,0.33615,0.65317,0.34056,0.64552,NaN,NaN +2,145006,5800.24,0.90456,0.45356,NaN,NaN,0.33996,0.64544,0.31122,0.67532,0.11428,0.92765,0.25659,0.60072,NaN,NaN,0.37398,0.62446,0.17327,0.8346,0.1659,0.76555,NaN,NaN,0.30044,0.68311,0.33563,0.65166,0.33996,0.64544,NaN,NaN""") + + raw_data_away = BytesIO(b""",,,Away,,Away,,Away,,Away,,Away,,Away,,Away,,Away,,Away,,Away,,Away,,Away,,Away,,Away,,Away, +,,,25,,15,,16,,17,,18,,19,,20,,21,,22,,23,,24,,26,,27,,28,,, +Period,Frame,Time [s],Player25,,Player15,,Player16,,Player17,,Player18,,Player19,,Player20,,Player21,,Player22,,Player23,,Player24,,Player26,,Player27,,Player28,,Ball, +1,1,0.04,0.90509,0.47462,0.58393,0.20794,0.67658,0.4671,0.6731,0.76476,0.40783,0.61525,0.45472,0.38709,0.5596,0.67775,0.55243,0.43269,0.50067,0.94322,0.43693,0.05002,0.37833,0.27383,NaN,NaN,NaN,NaN,NaN,NaN,0.45472,0.38709 +1,2,0.08,0.90494,0.47462,0.58393,0.20794,0.67658,0.4671,0.6731,0.76476,0.40783,0.61525,0.45472,0.38709,0.5596,0.67775,0.55243,0.43269,0.50067,0.94322,0.43693,0.05002,0.37833,0.27383,NaN,NaN,NaN,NaN,NaN,NaN,0.49645,0.40656 +1,3,0.12,0.90434,0.47463,0.58393,0.20794,0.67658,0.4671,0.6731,0.76476,0.40783,0.61525,0.45472,0.38709,0.5596,0.67775,0.55243,0.43269,0.50067,0.94322,0.43693,0.05002,0.37833,0.27383,NaN,NaN,NaN,NaN,NaN,NaN,0.53716,0.42556 +2,145004,5800.16,0.12564,0.55386,0.17792,0.56682,0.25757,0.60019,0.0988,0.92391,0.21235,0.77391,NaN,NaN,0.14926,0.56204,0.10285,0.81944,NaN,NaN,0.29331,0.488,NaN,NaN,0.35561,0.55254,0.19805,0.452,0.21798,0.81079,NaN,NaN +2,145005,5800.2,0.12564,0.55386,0.1773,0.56621,0.25721,0.60089,0.0988,0.92391,0.21235,0.77391,NaN,NaN,0.14857,0.56068,0.10231,0.81944,NaN,NaN,0.29272,0.48789,NaN,NaN,0.35532,0.55243,0.19766,0.45237,0.21798,0.81079,NaN,NaN +2,145006,5800.24,0.12564,0.55386,0.17693,0.56675,0.25659,0.60072,0.0988,0.92391,0.21235,0.77391,NaN,NaN,0.14846,0.56017,0.10187,0.8198,NaN,NaN,0.29267,0.48903,NaN,NaN,0.35495,0.55364,0.19754,0.45364,0.21798,0.81079,NaN,NaN""") + + serializer = MetricaTrackingSerializer() + + data_set = serializer.deserialize( + inputs={ + 'raw_data_home': raw_data_home, + 'raw_data_away': raw_data_away + } + ) + + assert len(data_set.frames) == 6 + assert len(data_set.periods) == 2 + assert data_set.orientation == Orientation.FIXED_HOME_AWAY + assert data_set.periods[0] == Period(id=1, start_frame_id=1, end_frame_id=3, + attacking_direction=AttackingDirection.HOME_AWAY) + assert data_set.periods[1] == Period(id=2, start_frame_id=145004, end_frame_id=145006, + attacking_direction=AttackingDirection.AWAY_HOME) + + # make sure data is loaded correctly (including flip y-axis) + assert data_set.frames[0].home_team_player_positions['11'] == Point(x=0.00082, y=1 - 0.48238) + assert data_set.frames[0].away_team_player_positions['25'] == Point(x=0.90509, y=1 - 0.47462) + assert data_set.frames[0].ball_position == Point(x=0.45472, y=1 - 0.38709) + + # make sure player data is only in the frame when the player is at the pitch + assert '14' not in data_set.frames[0].home_team_player_positions + assert '14' in data_set.frames[3].home_team_player_positions diff --git a/setup.py b/setup.py index dfeaedb3..4ca77002 100644 --- a/setup.py +++ b/setup.py @@ -19,7 +19,7 @@ setup( name='kloppy', - version='0.1', + version='0.2.0', author='Koen Vossen', author_email='info@koenvossen.nl', url="https://github.com/PySport/kloppy", @@ -27,4 +27,14 @@ license='Creative Commons Attribution-Noncommercial-Share Alike license', description="Standardizing soccer tracking- and event data", long_description="\n".join(DOCLINES), -) \ No newline at end of file + python_requires='>=3.7', + install_requires=[ + 'lxml>=4.5.0' + ], + extras_require={ + 'dev': [ + 'pytest', + 'flake8' + ] + } +) From fd326de410296865ef53a77818f73afd34cd163d Mon Sep 17 00:00:00 2001 From: Koen Vossen Date: Tue, 28 Apr 2020 21:56:02 +0200 Subject: [PATCH 04/14] Make create_iterator private and add docstrings to deserialize methods --- kloppy/infra/serializers/tracking/metrica.py | 204 ++++++++++++------- kloppy/infra/serializers/tracking/tracab.py | 40 ++++ kloppy/tests/test_metrica.py | 2 +- 3 files changed, 167 insertions(+), 79 deletions(-) diff --git a/kloppy/infra/serializers/tracking/metrica.py b/kloppy/infra/serializers/tracking/metrica.py index 74f0ff30..0f6fa5a9 100644 --- a/kloppy/infra/serializers/tracking/metrica.py +++ b/kloppy/infra/serializers/tracking/metrica.py @@ -1,3 +1,4 @@ +from collections import namedtuple from typing import Tuple, Dict, Iterator from kloppy.domain import (attacking_direction_from_frame, @@ -15,74 +16,9 @@ from . import TrackingDataSerializer -def create_iterator(data: Readable, sample_rate: float) -> Iterator: - """ - Sample file: - - ,,,Away,,Away,,Away,,Away,,Away,,Away,,Away,,Away,,Away,,Away,,Away,,Away,,Away,,Away,,Away, - ,,,25,,15,,16,,17,,18,,19,,20,,21,,22,,23,,24,,26,,27,,28,,, - Period,Frame,Time [s],Player25,,Player15,,Player16,,Player17,,Player18,,Player19,,Player20,,Player21,,Player22,,Player23,,Player24,,Player26,,Player27,,Player28,,Ball, - 1,1,0.04,0.90509,0.47462,0.58393,0.20794,0.67658,0.4671,0.6731,0.76476,0.40783,0.61525,0.45472,0.38709,0.5596,0.67775,0.55243,0.43269,0.50067,0.94322,0.43693,0.05002,0.37833,0.27383,NaN,NaN,NaN,NaN,NaN,NaN,0.45472,0.38709 - - Notes: - 1. the y-axis is flipped because Metrica use (y, -y) instead of (-y, y) - """ - # lines = list(map(lambda x: x.strip().decode("ascii"), data.readlines())) - - team = None - frame_idx = 0 - frame_sample = 1 / sample_rate - player_jersey_numbers = [] - period = None - - for i, line in enumerate(data): - line = line.strip().decode('ascii') - columns = line.split(',') - if i == 0: - team = columns[3] - elif i == 1: - player_jersey_numbers = columns[3:-2:2] - elif i == 2: - # consider doing some validation on the columns - pass - else: - period_id = int(columns[0]) - frame_id = int(columns[1]) - - if period is None or period.id != period_id: - period = Period( - id=period_id, - start_frame_id=frame_id, - end_frame_id=frame_id - ) - else: - # consider not update this every frame for performance reasons - period.end_frame_id = frame_id - - if frame_idx % frame_sample == 0: - yield dict( - team=team, - # Period will be updated during reading the file.... - # Might introduce bugs here - period=period, - frame_id=frame_id, - player_positions={ - player_no: Point( - x=float(columns[3 + i * 2]), - y=1 - float(columns[3 + i * 2 + 1]) - ) - for i, player_no in enumerate(player_jersey_numbers) - if columns[3 + i * 2] != 'NaN' - }, - ball_position=Point( - x=float(columns[-2]), - y=1 - float(columns[-1]) - ) - ) - frame_idx += 1 - - class MetricaTrackingSerializer(TrackingDataSerializer): + __PartialFrame = namedtuple("PartialFrame", "team period frame_id player_positions ball_position") + @staticmethod def __validate_inputs(inputs: Dict[str, Readable]): if "raw_data_home" not in inputs: @@ -90,7 +26,117 @@ def __validate_inputs(inputs: Dict[str, Readable]): if "raw_data_away" not in inputs: raise ValueError("Please specify a value for input 'raw_data_away'") + def __create_iterator(self, data: Readable, sample_rate: float) -> Iterator: + """ + Notes: + 1. the y-axis is flipped because Metrica use (y, -y) instead of (-y, y) + """ + + team = None + frame_idx = 0 + frame_sample = 1 / sample_rate + player_jersey_numbers = [] + period = None + + for i, line in enumerate(data): + line = line.strip().decode('ascii') + columns = line.split(',') + if i == 0: + team = columns[3] + elif i == 1: + player_jersey_numbers = columns[3:-2:2] + elif i == 2: + # consider doing some validation on the columns + pass + else: + period_id = int(columns[0]) + frame_id = int(columns[1]) + + if period is None or period.id != period_id: + period = Period( + id=period_id, + start_frame_id=frame_id, + end_frame_id=frame_id + ) + else: + # consider not update this every frame for performance reasons + period.end_frame_id = frame_id + + if frame_idx % frame_sample == 0: + yield self.__PartialFrame( + team=team, + period=period, + frame_id=frame_id, + player_positions={ + player_no: Point( + x=float(columns[3 + i * 2]), + y=1 - float(columns[3 + i * 2 + 1]) + ) + for i, player_no in enumerate(player_jersey_numbers) + if columns[3 + i * 2] != 'NaN' + }, + ball_position=Point( + x=float(columns[-2]), + y=1 - float(columns[-1]) + ) if columns[-2] != 'NaN' else None + ) + frame_idx += 1 + + @staticmethod + def __validate_partials(home_partial_frame: __PartialFrame, away_partial_frame: __PartialFrame): + + if home_partial_frame.frame_id != away_partial_frame.frame_id: + raise ValueError(f"frame_id mismatch: home {home_partial_frame.frame_id}, " + f"away: {away_partial_frame.frame_id}") + if home_partial_frame.ball_position != away_partial_frame.ball_position: + raise ValueError(f"ball position mismatch: home {home_partial_frame.ball_position}, " + f"away: {away_partial_frame.ball_position}. Do the files belong to the" + f" same game? frame_id: {home_partial_frame.frame_id}") + if home_partial_frame.team != 'Home': + raise ValueError("raw_data_home contains away team data") + if away_partial_frame.team != 'Away': + raise ValueError("raw_data_away contains home team data") + def deserialize(self, inputs: Dict[str, Readable], options: Dict = None) -> DataSet: + """ + Deserialize Metrica tracking data into a `DataSet`. + + Parameters + ---------- + inputs : dict + input `raw_data_home` should point to a `Readable` object containing + the 'csv' formatted raw data for the home team. input `raw_data_away` should point + to a `Readable` object containing the 'csv' formatted raw data for the away team. + options : dict + Options for deserialization of the Metrica file. Possible options are + `sample_rate` (float between 0 and 1) to specify the amount of + frames that should be loaded. + Returns + ------- + data_set : DataSet + Raises + ------ + ValueError when both input files don't seem to belong to each other + + See Also + -------- + + Examples + -------- + >>> serializer = MetricaTrackingSerializer() + >>> with open("Sample_Game_1_RawTrackingData_Away_Team.csv", "rb") as raw_home, \ + >>> open("Sample_Game_1_RawTrackingData_Home_Team.csv", "rb") as raw_away: + >>> + >>> data_set = serializer.deserialize( + >>> inputs={ + >>> 'raw_data_home': raw_home, + >>> 'raw_data_away': raw_away + >>> }, + >>> options={ + >>> 'sample_rate': 1/12 + >>> } + >>> ) + """ self.__validate_inputs(inputs) if not options: options = {} @@ -98,8 +144,8 @@ def deserialize(self, inputs: Dict[str, Readable], options: Dict = None) -> Data sample_rate = float(options.get('sample_rate', 1.0)) with performance_logging("prepare"): - home_iterator = create_iterator(inputs['raw_data_home'], sample_rate) - away_iterator = create_iterator(inputs['raw_data_away'], sample_rate) + home_iterator = self.__create_iterator(inputs['raw_data_home'], sample_rate) + away_iterator = self.__create_iterator(inputs['raw_data_away'], sample_rate) partial_frames = zip(home_iterator, away_iterator) @@ -108,21 +154,23 @@ def deserialize(self, inputs: Dict[str, Readable], options: Dict = None) -> Data periods = [] # consider reading this from data frame_rate = 25 + + partial_frame_type = self.__PartialFrame + home_partial_frame: partial_frame_type + away_partial_frame: partial_frame_type for home_partial_frame, away_partial_frame in partial_frames: - assert home_partial_frame['frame_id'] == away_partial_frame['frame_id'], "Mismatch" - assert home_partial_frame['team'] == 'Home', "Wrong file passed for home data" - assert away_partial_frame['team'] == 'Away', "Wrong file passed for away data" + self.__validate_partials(home_partial_frame, away_partial_frame) - period: Period = home_partial_frame['period'] - frame_id: int = home_partial_frame['frame_id'] + period: Period = home_partial_frame.period + frame_id: int = home_partial_frame.frame_id frame = Frame( - frame_id=home_partial_frame['frame_id'], + frame_id=frame_id, # -1 needed because frame_id is 1-based timestamp=(frame_id - (period.start_frame_id - 1)) / frame_rate, - ball_position=home_partial_frame['ball_position'], - home_team_player_positions=home_partial_frame['player_positions'], - away_team_player_positions=away_partial_frame['player_positions'], + ball_position=home_partial_frame.ball_position, + home_team_player_positions=home_partial_frame.player_positions, + away_team_player_positions=away_partial_frame.player_positions, period=period, ball_state=None, ball_owning_team=None diff --git a/kloppy/infra/serializers/tracking/tracab.py b/kloppy/infra/serializers/tracking/tracab.py index da7a22a5..0a0a25cb 100644 --- a/kloppy/infra/serializers/tracking/tracab.py +++ b/kloppy/infra/serializers/tracking/tracab.py @@ -61,6 +61,46 @@ def __validate_inputs(inputs: Dict[str, Readable]): raise ValueError("Please specify a value for 'raw_data'") def deserialize(self, inputs: Dict[str, Readable], options: Dict = None) -> DataSet: + """ + Deserialize TRACAB tracking data into a `DataSet`. + + Parameters + ---------- + inputs : dict + input `raw_data` should point to a `Readable` object containing + the 'csv' formatted raw data. input `meta_data` should point to + the xml metadata data. + options : dict + Options for deserialization of the TRACAB file. Possible options are + `only_alive` (boolean) to specify that only frames with alive ball state + should be loaded, or `sample_rate` (float between 0 and 1) to specify + the amount of frames that should be loaded. + Returns + ------- + data_set : DataSet + Raises + ------ + - + + See Also + -------- + + Examples + -------- + >>> serializer = TRACABSerializer() + >>> with open("metadata.xml", "rb") as meta, \ + >>> open("raw.dat", "rb") as raw: + >>> data_set = serializer.deserialize( + >>> inputs={ + >>> 'meta_data': meta, + >>> 'raw_data': raw + >>> }, + >>> options={ + >>> 'only_alive': True, + >>> 'sample_rate': 1/12 + >>> } + >>> ) + """ self.__validate_inputs(inputs) if not options: diff --git a/kloppy/tests/test_metrica.py b/kloppy/tests/test_metrica.py index 98051b38..d5e35aed 100644 --- a/kloppy/tests/test_metrica.py +++ b/kloppy/tests/test_metrica.py @@ -5,7 +5,7 @@ class TestMetricaTracking: - def test_deserialization(self): + def test_correct_deserialization(self): raw_data_home = BytesIO(b""",,,Home,,Home,,Home,,Home,,Home,,Home,,Home,,Home,,Home,,Home,,Home,,Home,,Home,,Home,,, ,,,11,,1,,2,,3,,4,,5,,6,,7,,8,,9,,10,,12,,13,,14,,, Period,Frame,Time [s],Player11,,Player1,,Player2,,Player3,,Player4,,Player5,,Player6,,Player7,,Player8,,Player9,,Player10,,Player12,,Player13,,Player14,,Ball, From 7849405a724f77fbac73c8b645477128b9881e03 Mon Sep 17 00:00:00 2001 From: Koen Vossen Date: Tue, 28 Apr 2020 22:37:15 +0200 Subject: [PATCH 05/14] Fix broken TRACAB --- kloppy/infra/serializers/tracking/metrica.py | 76 ++++++++++---------- kloppy/infra/serializers/tracking/tracab.py | 4 +- 2 files changed, 41 insertions(+), 39 deletions(-) diff --git a/kloppy/infra/serializers/tracking/metrica.py b/kloppy/infra/serializers/tracking/metrica.py index 0f6fa5a9..56750fa4 100644 --- a/kloppy/infra/serializers/tracking/metrica.py +++ b/kloppy/infra/serializers/tracking/metrica.py @@ -99,44 +99,44 @@ def __validate_partials(home_partial_frame: __PartialFrame, away_partial_frame: def deserialize(self, inputs: Dict[str, Readable], options: Dict = None) -> DataSet: """ - Deserialize Metrica tracking data into a `DataSet`. - - Parameters - ---------- - inputs : dict - input `raw_data_home` should point to a `Readable` object containing - the 'csv' formatted raw data for the home team. input `raw_data_away` should point - to a `Readable` object containing the 'csv' formatted raw data for the away team. - options : dict - Options for deserialization of the Metrica file. Possible options are - `sample_rate` (float between 0 and 1) to specify the amount of - frames that should be loaded. - Returns - ------- - data_set : DataSet - Raises - ------ - ValueError when both input files don't seem to belong to each other - - See Also - -------- - - Examples - -------- - >>> serializer = MetricaTrackingSerializer() - >>> with open("Sample_Game_1_RawTrackingData_Away_Team.csv", "rb") as raw_home, \ - >>> open("Sample_Game_1_RawTrackingData_Home_Team.csv", "rb") as raw_away: - >>> - >>> data_set = serializer.deserialize( - >>> inputs={ - >>> 'raw_data_home': raw_home, - >>> 'raw_data_away': raw_away - >>> }, - >>> options={ - >>> 'sample_rate': 1/12 - >>> } - >>> ) - """ + Deserialize Metrica tracking data into a `DataSet`. + + Parameters + ---------- + inputs : dict + input `raw_data_home` should point to a `Readable` object containing + the 'csv' formatted raw data for the home team. input `raw_data_away` should point + to a `Readable` object containing the 'csv' formatted raw data for the away team. + options : dict + Options for deserialization of the Metrica file. Possible options are + `sample_rate` (float between 0 and 1) to specify the amount of + frames that should be loaded. + Returns + ------- + data_set : DataSet + Raises + ------ + ValueError when both input files don't seem to belong to each other + + See Also + -------- + + Examples + -------- + >>> serializer = MetricaTrackingSerializer() + >>> with open("Sample_Game_1_RawTrackingData_Away_Team.csv", "rb") as raw_home, \ + >>> open("Sample_Game_1_RawTrackingData_Home_Team.csv", "rb") as raw_away: + >>> + >>> data_set = serializer.deserialize( + >>> inputs={ + >>> 'raw_data_home': raw_home, + >>> 'raw_data_away': raw_away + >>> }, + >>> options={ + >>> 'sample_rate': 1/12 + >>> } + >>> ) + """ self.__validate_inputs(inputs) if not options: options = {} diff --git a/kloppy/infra/serializers/tracking/tracab.py b/kloppy/infra/serializers/tracking/tracab.py index 0a0a25cb..8e59b709 100644 --- a/kloppy/infra/serializers/tracking/tracab.py +++ b/kloppy/infra/serializers/tracking/tracab.py @@ -133,7 +133,7 @@ def _iter(): n = 0 sample = 1. / sample_rate - for line in inputs['data'].readlines(): + for line in inputs['raw_data'].readlines(): line = line.strip().decode("ascii") frame_id = int(line[:10].split(":", 1)[0]) @@ -154,6 +154,8 @@ def _iter(): frame_rate ) + frames.append(frame) + if not period.attacking_direction_set: period.set_attacking_direction( attacking_direction=attacking_direction_from_frame(frame) From 52a806d10c80cd4017e5e6f289527e784b0bebfa Mon Sep 17 00:00:00 2001 From: Koen Vossen Date: Sat, 2 May 2020 17:34:45 +0200 Subject: [PATCH 06/14] Metrica Event data WIP --- kloppy/domain/models/__init__.py | 4 +- kloppy/domain/models/common.py | 107 +++++++++ kloppy/domain/models/event.py | 210 +++++++++++++++++ kloppy/domain/models/tracking.py | 133 +---------- kloppy/domain/services/enrichers/__init__.py | 89 ++++--- .../domain/services/transformers/__init__.py | 13 +- kloppy/infra/serializers/event/__init__.py | 0 kloppy/infra/serializers/event/base.py | 15 ++ .../serializers/event/metrica/__init__.py | 0 .../serializers/event/metrica/serializer.py | 17 ++ .../serializers/event/metrica/subtypes.py | 222 ++++++++++++++++++ kloppy/infra/serializers/tracking/metrica.py | 34 +-- kloppy/infra/serializers/tracking/tracab.py | 44 ++-- kloppy/tests/test_metrica.py | 16 +- 14 files changed, 684 insertions(+), 220 deletions(-) create mode 100644 kloppy/domain/models/common.py create mode 100644 kloppy/domain/models/event.py create mode 100644 kloppy/infra/serializers/event/__init__.py create mode 100644 kloppy/infra/serializers/event/base.py create mode 100644 kloppy/infra/serializers/event/metrica/__init__.py create mode 100644 kloppy/infra/serializers/event/metrica/serializer.py create mode 100644 kloppy/infra/serializers/event/metrica/subtypes.py diff --git a/kloppy/domain/models/__init__.py b/kloppy/domain/models/__init__.py index c7c7884b..e7acd60a 100644 --- a/kloppy/domain/models/__init__.py +++ b/kloppy/domain/models/__init__.py @@ -1,2 +1,4 @@ -from .tracking import * +from .common import * from .pitch import * +from .tracking import * + diff --git a/kloppy/domain/models/common.py b/kloppy/domain/models/common.py new file mode 100644 index 00000000..7940d9ff --- /dev/null +++ b/kloppy/domain/models/common.py @@ -0,0 +1,107 @@ +from dataclasses import dataclass +from enum import Enum, Flag +from typing import Optional, List + +from .pitch import PitchDimensions + + +class Team(Enum): + HOME = "home" + AWAY = "away" + + +class BallState(Enum): + ALIVE = "alive" + DEAD = "dead" + + +class AttackingDirection(Enum): + HOME_AWAY = "home-away" # home L -> R, away R -> L + AWAY_HOME = "away-home" # home R -> L, away L -> R + NOT_SET = "not-set" # not set yet + + +class Orientation(Enum): + # change when possession changes + BALL_OWNING_TEAM = "ball-owning-team" + + # changes during half-time + HOME_TEAM = "home-team" + AWAY_TEAM = "away-team" + + # won't change during match + FIXED_HOME_AWAY = "fixed-home-away" + FIXED_AWAY_HOME = "fixed-away-home" + + def get_orientation_factor(self, + attacking_direction: AttackingDirection, + ball_owning_team: Team): + if self == Orientation.FIXED_HOME_AWAY: + return -1 + elif self == Orientation.FIXED_AWAY_HOME: + return 1 + elif self == Orientation.HOME_TEAM: + if attacking_direction == AttackingDirection.HOME_AWAY: + return -1 + else: + return 1 + elif self == Orientation.AWAY_TEAM: + if attacking_direction == AttackingDirection.AWAY_HOME: + return -1 + else: + return 1 + elif self == Orientation.BALL_OWNING_TEAM: + if ((ball_owning_team == Team.HOME + and attacking_direction == AttackingDirection.HOME_AWAY) + or + (ball_owning_team == Team.AWAY + and attacking_direction == AttackingDirection.AWAY_HOME)): + return -1 + else: + return 1 + + +@dataclass +class Period: + id: int + start_timestamp: float + end_timestamp: float + attacking_direction: Optional[AttackingDirection] = AttackingDirection.NOT_SET + + def contains(self, timestamp: float): + return self.start_timestamp <= timestamp <= self.end_timestamp + + @property + def attacking_direction_set(self): + return self.attacking_direction != AttackingDirection.NOT_SET + + def set_attacking_direction(self, attacking_direction: AttackingDirection): + self.attacking_direction = attacking_direction + + def __eq__(self, other): + return self.id == other.id + + +class DataSetFlag(Flag): + BALL_OWNING_TEAM = 1 + BALL_STATE = 2 + + +@dataclass +class DataRecord: + timestamp: float + ball_owning_team: Team + ball_state: BallState + + period: Period + + +@dataclass +class DataSet: + flags: DataSetFlag + pitch_dimensions: PitchDimensions + orientation: Orientation + periods: List[Period] + records: List[DataRecord] + + diff --git a/kloppy/domain/models/event.py b/kloppy/domain/models/event.py new file mode 100644 index 00000000..ef9122db --- /dev/null +++ b/kloppy/domain/models/event.py @@ -0,0 +1,210 @@ +# Metrica Documentation https://github.com/metrica-sports/sample-data/blob/master/documentation/events-definitions.pdf + +from dataclasses import dataclass +from enum import Enum, Flag +from csv import reader +from typing import List, Type, Dict, Callable, Set, Union + +from .pitch import PitchDimensions, Point +from .common import DataRecord, DataSet, Team + + +class EventType(Enum): + SET_PIECE = "SET PIECE" + RECOVERY = "RECOVERY" + PASS = "PASS" + BALL_LOST = "BALL LOST" + BALL_OUT = "BALL OUT" + SHOT = "SHOT" + FAULT_RECEIVED = "FAULT RECEIVED" + CHALLENGE = "CHALLENGE" + CARD = "CARD" + + +class SubType(Enum): + pass + + +class ChallengeType(SubType): + Ground = "GROUND" + + +class ChallengeResult(SubType): + Won = "Won" + Lost = "LOST" + + +class Fault(SubType): + Fault = "FAULT" + Advantage = "ADVANTAGE" + + +class Interference1(SubType): + Interception = "INTERCEPTION" + Theft = "THEFT" + + +class Interference2(SubType): + Blocked = "BLOCKED" + Saved = "SAVED" + + +class Intervention(SubType): + Voluntary = "VOLUNTARY" + Forced = "FORCED" + End_Half = "END HALF" + + +class Attempt(SubType): + Clearance = "CLEARANCE" + Cross = "CROSS" + Through_Ball = "THROUGH BALL" + Deep_Ball = "DEEP BALL" + Goal_Kick = "GOAL KICK" + + +class Offside(SubType): + Offside = "OFFSIDE" + + +class BodyPart(SubType): + Head = "HEAD" + Foot = "FOOT" + + +class Deflection(SubType): + Woodwork = "WOODWORK" + Referee_hit = "REFEREE HIT" + Handball = "HANDBALL" + + +class ShotDirection(SubType): + On_Target = "ON TARGET" + Off_Target = "OFF TARGET" + + +class ShotResult(SubType): + Goal = "GOAL" + Out = "OUT" + Blocked = "BLOCKED" + Saved = "SAVED" + + +class Challenge(SubType): + Tackle = "TACKLE" + Dribble = "DRIBBLE" + Ground = "GROUND" + Aerial = "AERIAL" + + +class Card(SubType): + Yellow = "YELLOW" + Red = "RED" + Dismissal = "DISMISSAL" + + +class SetPiece(SubType): + Kick_off = "KICK OFF" + Throw_In = "THROW IN" + Corner_Kick = "CORNER KICK" + Goal_Kick = "GOAL KICK" + Free_Kick = "FREE KICK" + + +class FKAttempt(SubType): + Direct = "DIRECT" + Indirect = "INDIRECT" + + +class Retaken(SubType): + Retaken = "RETAKEN" + + + +""" +@dataclass +class Frame: + frame_id: int + timestamp: float + ball_owning_team: Team + ball_state: BallState + + period: Period + + home_team_player_positions: Dict[str, Point] + away_team_player_positions: Dict[str, Point] + ball_position: Point + +""" + + +@dataclass +class Event(DataRecord): + event_id: str + team: Team + event_type: EventType + + end_timestamp: float # allowed to be same as timestamp + + player_jersey_no: str + position: Point + + secondary_player_jersey_no: str + secondary_position: Point + + +@dataclass +class EventDataSet(DataSet): + frame_rate: int + records: List[Event] + + +if __name__ == '__main__': + + + data_file = "Sample_Game_1_RawEventsData.csv" + + with open(data_file, 'r') as read_obj: + csv_reader = reader(read_obj) + next(csv_reader) # skip the header + + for team_, Type, subtype, period, start_f, start_t, end_f, end_t, From, to, start_x, start_y, end_x, end_y in csv_reader: + + ## iron out any formatting issues + Type = Type.upper() + subtype = subtype.upper() + period = int(period) + team_ = team_.title() + From = From.title() + to = to.title() + + + team = Team.HOME if team_ == "Home" else Team.AWAY + + eventtype = EventType_map[Type] + + periodid = PeriodEvent(period) + + player = Player(From) + next_player = Player(to) + + start_frame = frame_id(start_f) + end_frame = frame_id(end_t) + + start_time = time_id(start_t) + end_time = time_id(end_f) + + start_location = Point(start_x, start_y) + end_location = Point(end_x, end_y) + + + print("-"*50) + print(team, eventtype, periodid, player, next_player, start_frame, end_frame, start_time, end_time, start_location, end_location) + + subtypes = subtype.split('-') + + if subtype == "": + pass + else: + challenge_type, fault, result, intf1, intf2, intv, atmp, ofsid, bdy, dflc, shtdir, shotres, chall, crd, setp, fk, rtake= build_subtypes(subtypes, [ChallengeType, Fault, ChallengeResult, Interference1, Interference2, Intervention, Attempt, Offside, BodyPart, Deflection, ShotDirection, ShotResult, Challenge, Card, SetPiece, FKAttempt, Retaken]) + print(challenge_type, fault, result, intf1, intf2, intv, atmp, ofsid, bdy, dflc, shtdir, shotres, chall, crd, setp, fk, rtake) \ No newline at end of file diff --git a/kloppy/domain/models/tracking.py b/kloppy/domain/models/tracking.py index 9fba6bb2..e6ce9848 100644 --- a/kloppy/domain/models/tracking.py +++ b/kloppy/domain/models/tracking.py @@ -1,139 +1,22 @@ from dataclasses import dataclass -from enum import Enum, Flag -from typing import List, Optional, Dict +from typing import List, Dict -from .pitch import ( - PitchDimensions, - Point +from .common import ( + DataSet, + DataRecord ) - - -class Player: - jersey_no: str - position: Point - - -class BallOwningTeam(Enum): - HOME = 0 - AWAY = 1 - - @classmethod - def from_string(cls, string): - if string == "H": - return cls.HOME - elif string == "A": - return cls.AWAY - else: - raise Exception(u"Unknown ball owning team: {}".format(string)) - - -class BallState(Enum): - ALIVE = "alive" - DEAD = "dead" - - @classmethod - def from_string(cls, string): - if string == "Alive": - return cls.ALIVE - elif string == "Dead": - return cls.DEAD - else: - raise Exception(u"Unknown ball state: {}".format(string)) - - -class AttackingDirection(Enum): - HOME_AWAY = "home-away" # home L -> R, away R -> L - AWAY_HOME = "away-home" # home R -> L, away L -> R - NOT_SET = "not-set" # not set yet - - -class Orientation(Enum): - # change when possession changes - BALL_OWNING_TEAM = "ball-owning-team" - - # changes during half-time - HOME_TEAM = "home-team" - AWAY_TEAM = "away-team" - - # won't change during match - FIXED_HOME_AWAY = "fixed-home-away" - FIXED_AWAY_HOME = "fixed-away-home" - - def get_orientation_factor(self, - attacking_direction: AttackingDirection, - ball_owning_team: BallOwningTeam): - if self == Orientation.FIXED_HOME_AWAY: - return -1 - elif self == Orientation.FIXED_AWAY_HOME: - return 1 - elif self == Orientation.HOME_TEAM: - if attacking_direction == AttackingDirection.HOME_AWAY: - return -1 - else: - return 1 - elif self == Orientation.AWAY_TEAM: - if attacking_direction == AttackingDirection.AWAY_HOME: - return -1 - else: - return 1 - elif self == Orientation.BALL_OWNING_TEAM: - if ((ball_owning_team == BallOwningTeam.HOME - and attacking_direction == AttackingDirection.HOME_AWAY) - or - (ball_owning_team == BallOwningTeam.AWAY - and attacking_direction == AttackingDirection.AWAY_HOME)): - return -1 - else: - return 1 +from .pitch import Point @dataclass -class Period: - id: int - start_frame_id: int - end_frame_id: int - attacking_direction: Optional[AttackingDirection] = AttackingDirection.NOT_SET - - @property - def frame_count(self): - return self.end_frame_id - self.start_frame_id - - def contains(self, frame_id: int): - return self.start_frame_id <= frame_id <= self.end_frame_id - - @property - def attacking_direction_set(self): - return self.attacking_direction != AttackingDirection.NOT_SET - - def set_attacking_direction(self, attacking_direction: AttackingDirection): - self.attacking_direction = attacking_direction - - -@dataclass -class Frame: +class Frame(DataRecord): frame_id: int - timestamp: float - ball_owning_team: BallOwningTeam - ball_state: BallState - - period: Period - home_team_player_positions: Dict[str, Point] away_team_player_positions: Dict[str, Point] ball_position: Point -class DataSetFlag(Flag): - BALL_OWNING_TEAM = 1 - BALL_STATE = 2 - - @dataclass -class DataSet: - flags: DataSetFlag - pitch_dimensions: PitchDimensions - orientation: Orientation - +class TrackingDataSet(DataSet): frame_rate: int - periods: List[Period] - frames: List[Frame] + records: List[Frame] diff --git a/kloppy/domain/services/enrichers/__init__.py b/kloppy/domain/services/enrichers/__init__.py index 73e61418..4b8f8108 100644 --- a/kloppy/domain/services/enrichers/__init__.py +++ b/kloppy/domain/services/enrichers/__init__.py @@ -1,49 +1,40 @@ -# from dataclasses import dataclass -# -# from ...models.tracking import DataSet as TrackingDataSet, BallState, BallOwningTeam, DataSetFlag -# from ...models.event import DataSet as EventDataSet -# -# -# @dataclass -# class GameState: -# ball_state: BallState -# ball_owning_team: BallOwningTeam -# -# -# class TrackingPossessionEnricher: -# def _reduce_game_state(self, game_state: GameState, Event: event) -> GameState: -# pass -# -# def enrich_inplace(self, tracking_data_set: TrackingDataSet, event_data_set: EventDataSet) -> None: -# """ -# Return an enriched tracking data set. -# -# Use the event data to rebuild game state. -# -# Iterate through all tracking data events and apply event data to the GameState whenever -# they happen. -# -# """ -# if tracking_data_set.flags & (DataSetFlag.BALL_OWNING_TEAM | DataSetFlag.BALL_STATE): -# return -# -# # set some defaults -# game_state = GameState( -# ball_state=BallState.DEAD, -# ball_owning_team=BallOwningTeam.HOME -# ) -# -# next_event_idx = 0 -# -# for frame in tracking_data_set.frames: -# if next_event_idx < len(event_data_set.frames): -# event = event_data_set.events[next_event_idx] -# if frame.period.id == event.period.id and frame.timestamp >= event.timestamp: -# game_state = self._reduce_game_state( -# game_state, -# event_data_set.events[next_event_idx] -# ) -# next_event_idx += 1 -# -# frame.ball_owning_team = game_state.ball_owning_team -# frame.ball_state = game_state.ball_state +from dataclasses import dataclass + +from ...models.tracking import DataSet as TrackingDataSet +from ...models.event import DataSet as EventDataSet +from ...models.common import DataSetFlag, Team, BallState + + +class TrackingPossessionEnricher: + def enrich_inplace(self, tracking_data_set: TrackingDataSet, event_data_set: EventDataSet) -> None: + """ + Return an enriched tracking data set. + + Use the event data to rebuild game state. + + Iterate through all tracking data events and apply event data to the GameState whenever + they happen. + + """ + if tracking_data_set.flags & (DataSetFlag.BALL_OWNING_TEAM | DataSetFlag.BALL_STATE): + return + + if not event_data_set.flags & (DataSetFlag.BALL_OWNING_TEAM | DataSetFlag.BALL_STATE): + raise Exception("Event DataSet does not contain ball owning team or ball state information") + + # set some defaults + next_event_idx = 0 + + current_ball_owning_team = Team.HOME + current_ball_state = BallState.DEAD + + for frame in tracking_data_set.records: + if next_event_idx < len(event_data_set.records): + event = event_data_set.records[next_event_idx] + if frame.period == event.period and frame.timestamp >= event.timestamp: + current_ball_owning_team = event.ball_owning_team + current_ball_state = event.ball_state + next_event_idx += 1 + + frame.ball_owning_team = current_ball_owning_team + frame.ball_state = current_ball_state diff --git a/kloppy/domain/services/transformers/__init__.py b/kloppy/domain/services/transformers/__init__.py index 477da9a6..07f6f3c3 100644 --- a/kloppy/domain/services/transformers/__init__.py +++ b/kloppy/domain/services/transformers/__init__.py @@ -3,7 +3,10 @@ PitchDimensions, Orientation, Frame, - DataSet, BallOwningTeam, AttackingDirection, DataSetFlag) + Team, AttackingDirection, + + TrackingDataSet, DataSetFlag +) class Transformer: @@ -30,7 +33,7 @@ def transform_point(self, point: Point, flip: bool) -> Point: y=self._to_pitch_dimensions.y_dim.from_base(y_base) ) - def __needs_flip(self, ball_owning_team: BallOwningTeam, attacking_direction: AttackingDirection) -> bool: + def __needs_flip(self, ball_owning_team: Team, attacking_direction: AttackingDirection) -> bool: if self._from_orientation == self._to_orientation: flip = False else: @@ -75,9 +78,9 @@ def transform_frame(self, frame: Frame) -> Frame: @classmethod def transform_data_set(cls, - data_set: DataSet, + data_set: TrackingDataSet, to_pitch_dimensions: PitchDimensions = None, - to_orientation: Orientation = None) -> DataSet: + to_orientation: Orientation = None) -> TrackingDataSet: if not to_pitch_dimensions and not to_orientation: return data_set elif not to_orientation: @@ -98,7 +101,7 @@ def transform_data_set(cls, ) frames = list(map(transformer.transform_frame, data_set.frames)) - return DataSet( + return TrackingDataSet( flags=data_set.flags, frame_rate=data_set.frame_rate, periods=data_set.periods, diff --git a/kloppy/infra/serializers/event/__init__.py b/kloppy/infra/serializers/event/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/kloppy/infra/serializers/event/base.py b/kloppy/infra/serializers/event/base.py new file mode 100644 index 00000000..2fdc3150 --- /dev/null +++ b/kloppy/infra/serializers/event/base.py @@ -0,0 +1,15 @@ +from abc import ABC, abstractmethod +from typing import Tuple, Dict + +from kloppy.infra.utils import Readable +from kloppy.domain import EventDataSet + + +class TrackingDataSerializer(ABC): + @abstractmethod + def deserialize(self, inputs: Dict[str, Readable], options: Dict = None) -> EventDataSet: + raise NotImplementedError + + @abstractmethod + def serialize(self, data_set: EventDataSet) -> Tuple[str, str]: + raise NotImplementedError diff --git a/kloppy/infra/serializers/event/metrica/__init__.py b/kloppy/infra/serializers/event/metrica/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/kloppy/infra/serializers/event/metrica/serializer.py b/kloppy/infra/serializers/event/metrica/serializer.py new file mode 100644 index 00000000..dfeab7f8 --- /dev/null +++ b/kloppy/infra/serializers/event/metrica/serializer.py @@ -0,0 +1,17 @@ +from kloppy.domain.models.event import EventType + + +event_type_map = { + "SET PIECE": EventType.SET_PIECE, + "RECOVERY": EventType.RECOVERY, + "PASS": EventType.PASS, + "BALL LOST": EventType.BALL_LOST, + "BALL OUT": EventType.BALL_OUT, + "SHOT": EventType.SHOT, + "FAULT RECEIVED": EventType.FAULT_RECEIVED, + "CHALLENGE": EventType.CHALLENGE, + "CARD": EventType.CARD +} + +# https://github.com/Friends-of-Tracking-Data-FoTD/passing-networks-in-python/blob/master/processing/tracking.py +# https://github.com/HarvardSoccer/TrackingData/blob/fa7701893c928e9fcec358ec6e281743c00e6bc1/Metrica.py#L251 \ No newline at end of file diff --git a/kloppy/infra/serializers/event/metrica/subtypes.py b/kloppy/infra/serializers/event/metrica/subtypes.py new file mode 100644 index 00000000..33c9346b --- /dev/null +++ b/kloppy/infra/serializers/event/metrica/subtypes.py @@ -0,0 +1,222 @@ +from typing import List, Type, Union, Dict, Callable + +from kloppy.domain.models.event import ( + Retaken, FKAttempt, SetPiece, Card, Challenge, + ShotResult, ShotDirection, Deflection, BodyPart, + Offside, Attempt, Intervention, Interference1, Interference2, + ChallengeType, ChallengeResult, Fault, + SubType) + + +def build_retaken(string: str) -> Retaken: + if string == "RETAKEN": + return Retaken.Retaken + else: + raise ValueError(f"Unknown retaken type: {string}") + + +def build_fkattempt(string: str) -> FKAttempt: + if string == "DIRECT": + return FKAttempt.Direct + elif string == "INDIRECT": + return FKAttempt.Indirect + else: + raise ValueError(f"Unknown fkattempt type: {string}") + + +def build_setpiece(string: str) -> SetPiece: + if string == "KICK OFF": + return SetPiece.Kick_off + elif string == "THROW IN": + return SetPiece.Throw_In + elif string == "CORNER KICK": + return SetPiece.Corner_Kick + elif string == "GOAL KICK": + return SetPiece.Goal_Kick + elif string == "FREE KICK": + return SetPiece.Free_Kick + else: + raise ValueError(f"Unknown setpiece type: {string}") + + +def build_card(string: str) -> Card: + if string == "YELLOW": + return Card.Yellow + elif string == "RED": + return Card.Red + elif string == "DISMISSAL": + return Card.Dismissal + else: + raise ValueError(f"Unknown card type: {string}") + + +def build_challenge(string: str) -> Challenge: + if string == "TACKLE": + return Challenge.Tackle + elif string == "DRIBBLE": + return Challenge.Dribble + elif string == "GROUND": + return Challenge.Ground + elif string == "AERIAL": + return Challenge.Aerial + else: + raise ValueError(f"Unknown challenge type: {string}") + + + +def build_shotresult(string: str) -> ShotResult: + if string == "GOAL": + return ShotResult.Goal + elif string == "OUT": + return ShotResult.Out + elif string == "BLOCKED": + return ShotResult.Blocked + elif string == "SAVED": + return ShotResult.Saved + else: + raise ValueError(f"Unknown deflection type: {string}") + + +def build_shotdirection(string: str) -> ShotDirection: + if string == "ON TARGET": + return ShotDirection.On_Target + elif string == "OFF TARGET": + return ShotDirection.Off_Target + else: + raise ValueError(f"Unknown shotdirection type: {string}") + + +def build_Deflection(string: str) -> Deflection: + if string == "WOODWORK": + return Deflection.Woodwork + elif string == "REFEREE HIT": + return Deflection.Referee_hit + elif string == "HANDBALL": + return Deflection.Handball + else: + raise ValueError(f"Unknown deflection type: {string}") + + +def build_bodypart(string: str) -> BodyPart: + if string == "HEAD": + return BodyPart.Head + elif string == "FOOT": + return BodyPart.Foot + else: + raise ValueError(f"Unknown bodypart type: {string}") + + +def build_offside(string: str) -> Offside: + if string == "OFFSIDE": + return Offside.Offside + else: + raise ValueError(f"Unknown offside type: {string}") + + +def build_attempt(string: str) -> Attempt: + if string == "CLEARANCE": + return Attempt.Clearance + elif string == "CROSS": + return Attempt.Cross + elif string == "THROUGH BALL": + return Attempt.Through_Ball + elif string == "DEEP BALL": + return Attempt.Deep_Ball + elif string == "GOAL KICK": + return Attempt.Goal_Kick + else: + raise ValueError(f"Unknown attempt type: {string}") + + +def build_intervention(string: str) -> Intervention: + if string == "VOLUNTARY": + return Intervention.Voluntary + elif string == "FORCED": + return Intervention.Forced + elif string == "END HALF": + return Intervention.End_Half + else: + raise ValueError(f"Unknown intervention type: {string}") + + +def build_interference1(string: str) -> Interference1: + if string == "INTERCEPTION": + return Interference1.Interception + elif string == "THEFT": + return Interference1.Theft + else: + raise ValueError(f"Unknown interference1 type: {string}") + + +def build_interference2(string: str) -> Interference2: + if string == "BLOCKED": + return Interference2.Blocked + elif string == "SAVED": + return Interference2.Saved + else: + raise ValueError(f"Unknown interference2 type: {string}") + + +def build_challenge_type(string: str) -> ChallengeType: + if string == "GROUND": + return ChallengeType.Ground + else: + raise ValueError(f"Unknown challenge type: {string}") + + +def build_fault(string: str) -> Fault: + if string == "FAULT": + return Fault.Fault + elif string == "ADVANTAGE": + return Fault.Advantage + else: + raise ValueError(f"Unknown fault type: {string}") + + +def build_challenge_result(string: str) -> ChallengeResult: + if string == "WON": + return ChallengeResult.Won + elif string == "LOST": + return ChallengeResult.Lost + else: + raise ValueError(f"Unknown challenge result: {string}") + + +factories: Dict[Type[SubType], Callable] = { + ChallengeType: build_challenge_type, + Fault: build_fault, + ChallengeResult: build_challenge_result, + Interference1: build_interference1, + Interference2: build_interference2, + Intervention: build_intervention, + Attempt: build_attempt, + Offside: build_offside, + BodyPart: build_bodypart, + Deflection: build_Deflection, + ShotDirection: build_shotdirection, + ShotResult: build_shotresult, + Challenge: build_challenge, + Card: build_card, + SetPiece: build_setpiece, + FKAttempt: build_fkattempt, + Retaken: build_retaken +} + + +def build_subtypes(items: List[str], subtype_types: List[Type[SubType]]) -> List[Union[SubType, None]]: + result = [None] * len(subtype_types) + for item in items: + for i, subtype_type in enumerate(subtype_types): + assert subtype_type in factories, f"Factory missing for {subtype_type}" + + try: + subtype = factories[subtype_type](item) + break + except ValueError: + continue + else: + raise ValueError(f"Cannot determine subtype type of f{item}") + + result[i] = subtype + + return result diff --git a/kloppy/infra/serializers/tracking/metrica.py b/kloppy/infra/serializers/tracking/metrica.py index 56750fa4..e162e517 100644 --- a/kloppy/infra/serializers/tracking/metrica.py +++ b/kloppy/infra/serializers/tracking/metrica.py @@ -2,7 +2,7 @@ from typing import Tuple, Dict, Iterator from kloppy.domain import (attacking_direction_from_frame, - DataSet, + TrackingDataSet, AttackingDirection, Frame, Point, @@ -26,7 +26,7 @@ def __validate_inputs(inputs: Dict[str, Readable]): if "raw_data_away" not in inputs: raise ValueError("Please specify a value for input 'raw_data_away'") - def __create_iterator(self, data: Readable, sample_rate: float) -> Iterator: + def __create_iterator(self, data: Readable, sample_rate: float, frame_rate: int) -> Iterator: """ Notes: 1. the y-axis is flipped because Metrica use (y, -y) instead of (-y, y) @@ -55,12 +55,12 @@ def __create_iterator(self, data: Readable, sample_rate: float) -> Iterator: if period is None or period.id != period_id: period = Period( id=period_id, - start_frame_id=frame_id, - end_frame_id=frame_id + start_timestamp=frame_id / frame_rate, + end_timestamp=frame_id / frame_rate ) else: # consider not update this every frame for performance reasons - period.end_frame_id = frame_id + period.end_timestamp = frame_id / frame_rate if frame_idx % frame_sample == 0: yield self.__PartialFrame( @@ -97,9 +97,9 @@ def __validate_partials(home_partial_frame: __PartialFrame, away_partial_frame: if away_partial_frame.team != 'Away': raise ValueError("raw_data_away contains home team data") - def deserialize(self, inputs: Dict[str, Readable], options: Dict = None) -> DataSet: + def deserialize(self, inputs: Dict[str, Readable], options: Dict = None) -> TrackingDataSet: """ - Deserialize Metrica tracking data into a `DataSet`. + Deserialize Metrica tracking data into a `TrackingDataSet`. Parameters ---------- @@ -113,7 +113,7 @@ def deserialize(self, inputs: Dict[str, Readable], options: Dict = None) -> Data frames that should be loaded. Returns ------- - data_set : DataSet + data_set : TrackingDataSet Raises ------ ValueError when both input files don't seem to belong to each other @@ -143,17 +143,18 @@ def deserialize(self, inputs: Dict[str, Readable], options: Dict = None) -> Data sample_rate = float(options.get('sample_rate', 1.0)) + # consider reading this from data + frame_rate = 25 + with performance_logging("prepare"): - home_iterator = self.__create_iterator(inputs['raw_data_home'], sample_rate) - away_iterator = self.__create_iterator(inputs['raw_data_away'], sample_rate) + home_iterator = self.__create_iterator(inputs['raw_data_home'], sample_rate, frame_rate) + away_iterator = self.__create_iterator(inputs['raw_data_away'], sample_rate, frame_rate) partial_frames = zip(home_iterator, away_iterator) with performance_logging("loading"): frames = [] periods = [] - # consider reading this from data - frame_rate = 25 partial_frame_type = self.__PartialFrame home_partial_frame: partial_frame_type @@ -166,8 +167,7 @@ def deserialize(self, inputs: Dict[str, Readable], options: Dict = None) -> Data frame = Frame( frame_id=frame_id, - # -1 needed because frame_id is 1-based - timestamp=(frame_id - (period.start_frame_id - 1)) / frame_rate, + timestamp=frame_id / frame_rate - period.start_timestamp, ball_position=home_partial_frame.ball_position, home_team_player_positions=home_partial_frame.player_positions, away_team_player_positions=away_partial_frame.player_positions, @@ -192,7 +192,7 @@ def deserialize(self, inputs: Dict[str, Readable], options: Dict = None) -> Data Orientation.FIXED_AWAY_HOME ) - return DataSet( + return TrackingDataSet( flags=~(DataSetFlag.BALL_STATE | DataSetFlag.BALL_OWNING_TEAM), frame_rate=frame_rate, orientation=orientation, @@ -201,8 +201,8 @@ def deserialize(self, inputs: Dict[str, Readable], options: Dict = None) -> Data y_dim=Dimension(0, 1) ), periods=periods, - frames=frames + records=frames ) - def serialize(self, data_set: DataSet) -> Tuple[str, str]: + def serialize(self, data_set: TrackingDataSet) -> Tuple[str, str]: raise NotImplementedError diff --git a/kloppy/infra/serializers/tracking/tracab.py b/kloppy/infra/serializers/tracking/tracab.py index 8e59b709..9be39784 100644 --- a/kloppy/infra/serializers/tracking/tracab.py +++ b/kloppy/infra/serializers/tracking/tracab.py @@ -3,18 +3,18 @@ from lxml import objectify from kloppy.domain import ( - DataSet, + TrackingDataSet, DataSetFlag, AttackingDirection, Frame, Point, - BallOwningTeam, + Team, BallState, Period, Orientation, PitchDimensions, Dimension, attacking_direction_from_frame, - DataSetFlag) +) from kloppy.infra.utils import Readable, performance_logging from . import TrackingDataSerializer @@ -42,12 +42,26 @@ def _frame_from_line(cls, period, line, frame_rate): frame_id = int(frame_id) + if ball_owning_team == "H": + ball_owning_team = Team.HOME + elif ball_owning_team == "A": + ball_owning_team = Team.AWAY + else: + raise Exception(f"Unknown ball owning team: {ball_owning_team}") + + if ball_state == "Alive": + ball_state = BallState.ALIVE + elif ball_state == "Dead": + ball_state = BallState.DEAD + else: + raise Exception(f"Unknown ball state: {ball_state}") + return Frame( frame_id=frame_id, - timestamp=(frame_id - period.start_frame_id) / frame_rate, + timestamp=frame_id / frame_rate - period.start_timestamp, ball_position=Point(float(ball_x), float(ball_y)), - ball_state=BallState.from_string(ball_state), - ball_owning_team=BallOwningTeam.from_string(ball_owning_team), + ball_state=ball_state, + ball_owning_team=ball_owning_team, home_team_player_positions=home_team_player_positions, away_team_player_positions=away_team_player_positions, period=period @@ -60,9 +74,9 @@ def __validate_inputs(inputs: Dict[str, Readable]): if "raw_data" not in inputs: raise ValueError("Please specify a value for 'raw_data'") - def deserialize(self, inputs: Dict[str, Readable], options: Dict = None) -> DataSet: + def deserialize(self, inputs: Dict[str, Readable], options: Dict = None) -> TrackingDataSet: """ - Deserialize TRACAB tracking data into a `DataSet`. + Deserialize TRACAB tracking data into a `TrackingDataSet`. Parameters ---------- @@ -77,7 +91,7 @@ def deserialize(self, inputs: Dict[str, Readable], options: Dict = None) -> Data the amount of frames that should be loaded. Returns ------- - data_set : DataSet + data_set : TrackingDataSet Raises ------ - @@ -123,8 +137,8 @@ def deserialize(self, inputs: Dict[str, Readable], options: Dict = None) -> Data periods.append( Period( id=int(period.attrib['iId']), - start_frame_id=start_frame_id, - end_frame_id=end_frame_id + start_timestamp=start_frame_id / frame_rate, + end_timestamp=end_frame_id / frame_rate ) ) @@ -141,7 +155,7 @@ def _iter(): continue for period in periods: - if period.contains(frame_id): + if period.contains(frame_id / frame_rate): if n % sample == 0: yield period, line n += 1 @@ -167,7 +181,7 @@ def _iter(): Orientation.FIXED_AWAY_HOME ) - return DataSet( + return TrackingDataSet( flags=DataSetFlag.BALL_OWNING_TEAM | DataSetFlag.BALL_STATE, frame_rate=frame_rate, orientation=orientation, @@ -178,9 +192,9 @@ def _iter(): y_per_meter=100 ), periods=periods, - frames=frames + records=frames ) - def serialize(self, data_set: DataSet) -> Tuple[str, str]: + def serialize(self, data_set: TrackingDataSet) -> Tuple[str, str]: raise NotImplementedError diff --git a/kloppy/tests/test_metrica.py b/kloppy/tests/test_metrica.py index d5e35aed..ee86c3e5 100644 --- a/kloppy/tests/test_metrica.py +++ b/kloppy/tests/test_metrica.py @@ -35,19 +35,19 @@ def test_correct_deserialization(self): } ) - assert len(data_set.frames) == 6 + assert len(data_set.records) == 6 assert len(data_set.periods) == 2 assert data_set.orientation == Orientation.FIXED_HOME_AWAY - assert data_set.periods[0] == Period(id=1, start_frame_id=1, end_frame_id=3, + assert data_set.periods[0] == Period(id=1, start_timestamp=0.04, end_timestamp=0.12, attacking_direction=AttackingDirection.HOME_AWAY) - assert data_set.periods[1] == Period(id=2, start_frame_id=145004, end_frame_id=145006, + assert data_set.periods[1] == Period(id=2, start_timestamp=5800.16, end_timestamp=5800.24, attacking_direction=AttackingDirection.AWAY_HOME) # make sure data is loaded correctly (including flip y-axis) - assert data_set.frames[0].home_team_player_positions['11'] == Point(x=0.00082, y=1 - 0.48238) - assert data_set.frames[0].away_team_player_positions['25'] == Point(x=0.90509, y=1 - 0.47462) - assert data_set.frames[0].ball_position == Point(x=0.45472, y=1 - 0.38709) + assert data_set.records[0].home_team_player_positions['11'] == Point(x=0.00082, y=1 - 0.48238) + assert data_set.records[0].away_team_player_positions['25'] == Point(x=0.90509, y=1 - 0.47462) + assert data_set.records[0].ball_position == Point(x=0.45472, y=1 - 0.38709) # make sure player data is only in the frame when the player is at the pitch - assert '14' not in data_set.frames[0].home_team_player_positions - assert '14' in data_set.frames[3].home_team_player_positions + assert '14' not in data_set.records[0].home_team_player_positions + assert '14' in data_set.records[3].home_team_player_positions From 8ac8688e04a393af16b7285a906302071452e6e8 Mon Sep 17 00:00:00 2001 From: Koen Vossen Date: Sat, 2 May 2020 20:50:30 +0200 Subject: [PATCH 07/14] Fix some tests --- kloppy/domain/models/common.py | 8 +-- kloppy/domain/services/enrichers/__init__.py | 2 +- kloppy/infra/serializers/tracking/tracab.py | 2 + kloppy/tests/test_tracab.py | 62 ++++++++++++++++++++ 4 files changed, 68 insertions(+), 6 deletions(-) create mode 100644 kloppy/tests/test_tracab.py diff --git a/kloppy/domain/models/common.py b/kloppy/domain/models/common.py index 7940d9ff..3fb7be0b 100644 --- a/kloppy/domain/models/common.py +++ b/kloppy/domain/models/common.py @@ -1,3 +1,4 @@ +from abc import ABC from dataclasses import dataclass from enum import Enum, Flag from typing import Optional, List @@ -78,9 +79,6 @@ def attacking_direction_set(self): def set_attacking_direction(self, attacking_direction: AttackingDirection): self.attacking_direction = attacking_direction - def __eq__(self, other): - return self.id == other.id - class DataSetFlag(Flag): BALL_OWNING_TEAM = 1 @@ -88,7 +86,7 @@ class DataSetFlag(Flag): @dataclass -class DataRecord: +class DataRecord(ABC): timestamp: float ball_owning_team: Team ball_state: BallState @@ -97,7 +95,7 @@ class DataRecord: @dataclass -class DataSet: +class DataSet(ABC): flags: DataSetFlag pitch_dimensions: PitchDimensions orientation: Orientation diff --git a/kloppy/domain/services/enrichers/__init__.py b/kloppy/domain/services/enrichers/__init__.py index 4b8f8108..fef1f318 100644 --- a/kloppy/domain/services/enrichers/__init__.py +++ b/kloppy/domain/services/enrichers/__init__.py @@ -31,7 +31,7 @@ def enrich_inplace(self, tracking_data_set: TrackingDataSet, event_data_set: Eve for frame in tracking_data_set.records: if next_event_idx < len(event_data_set.records): event = event_data_set.records[next_event_idx] - if frame.period == event.period and frame.timestamp >= event.timestamp: + if frame.period.id == event.period.id and frame.timestamp >= event.timestamp: current_ball_owning_team = event.ball_owning_team current_ball_state = event.ball_state next_event_idx += 1 diff --git a/kloppy/infra/serializers/tracking/tracab.py b/kloppy/infra/serializers/tracking/tracab.py index 9be39784..501d51ea 100644 --- a/kloppy/infra/serializers/tracking/tracab.py +++ b/kloppy/infra/serializers/tracking/tracab.py @@ -149,6 +149,8 @@ def _iter(): for line in inputs['raw_data'].readlines(): line = line.strip().decode("ascii") + if not line: + continue frame_id = int(line[:10].split(":", 1)[0]) if only_alive and not line.endswith("Alive;:"): diff --git a/kloppy/tests/test_tracab.py b/kloppy/tests/test_tracab.py new file mode 100644 index 00000000..0ee682d5 --- /dev/null +++ b/kloppy/tests/test_tracab.py @@ -0,0 +1,62 @@ +from io import BytesIO + +from kloppy import TRACABSerializer +from kloppy.domain import Period, AttackingDirection, Orientation, Point + + +class TestTracabTracking: + def test_correct_deserialization(self): + meta_data = BytesIO(b""" + + + + + + + + + """) + + raw_data = BytesIO(b""" + 100:0,1,19,8889,-666,0.55;1,2,19,-1234,-294,0.07;:-27,25,0,27.00,H,Alive;: + 101:0,1,19,8889,-666,0.55;1,2,19,-1234,-294,0.07;:-27,25,0,27.00,H,Alive;: + 102:0,1,19,8889,-666,0.55;1,2,19,-1234,-294,0.07;:-27,25,0,27.00,H,Alive;: + + 200:0,1,1337,8889,-666,0.55;1,2,19,-1234,-294,0.07;:-27,25,0,27.00,H,Alive;: + 201:0,1,1337,8889,-666,0.55;1,2,19,-1234,-294,0.07;:-27,25,0,27.00,H,Alive;: + 202:0,1,1337,8889,-666,0.55;1,2,19,-1234,-294,0.07;:-27,25,0,27.00,H,Alive;: + 203:0,1,1337,8889,-666,0.55;1,2,19,-1234,-294,0.07;:-27,25,0,27.00,H,Alive;: + """) + serializer = TRACABSerializer() + + data_set = serializer.deserialize( + inputs={ + 'meta_data': meta_data, + 'raw_data': raw_data + } + ) + + assert len(data_set.records) == 6 + assert len(data_set.periods) == 2 + assert data_set.orientation == Orientation.FIXED_HOME_AWAY + assert data_set.periods[0] == Period(id=1, start_timestamp=4.0, end_timestamp=4.08, + attacking_direction=AttackingDirection.HOME_AWAY) + + # attacking direction is super weird ;-) + assert data_set.periods[1] == Period(id=2, start_timestamp=8.0, end_timestamp=8.08, + attacking_direction=AttackingDirection.HOME_AWAY) + + assert data_set.records[0].home_team_player_positions['19'] == Point(x=-1234.0, y=-294.0) + assert data_set.records[0].away_team_player_positions['19'] == Point(x=8889, y=-666) + assert data_set.records[0].ball_position == Point(x=-27, y=25) + + # make sure player data is only in the frame when the player is at the pitch + assert '1337' not in data_set.records[0].away_team_player_positions + assert '1337' in data_set.records[3].away_team_player_positions From 7bb83c6544fa94d4e179b09efbc00da561704f9a Mon Sep 17 00:00:00 2001 From: Koen Vossen Date: Sat, 2 May 2020 20:53:31 +0200 Subject: [PATCH 08/14] Improve test --- kloppy/tests/test_tracab.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/kloppy/tests/test_tracab.py b/kloppy/tests/test_tracab.py index 0ee682d5..b424a285 100644 --- a/kloppy/tests/test_tracab.py +++ b/kloppy/tests/test_tracab.py @@ -1,7 +1,7 @@ from io import BytesIO from kloppy import TRACABSerializer -from kloppy.domain import Period, AttackingDirection, Orientation, Point +from kloppy.domain import Period, AttackingDirection, Orientation, Point, BallState, Team class TestTracabTracking: @@ -26,8 +26,8 @@ def test_correct_deserialization(self): raw_data = BytesIO(b""" 100:0,1,19,8889,-666,0.55;1,2,19,-1234,-294,0.07;:-27,25,0,27.00,H,Alive;: - 101:0,1,19,8889,-666,0.55;1,2,19,-1234,-294,0.07;:-27,25,0,27.00,H,Alive;: - 102:0,1,19,8889,-666,0.55;1,2,19,-1234,-294,0.07;:-27,25,0,27.00,H,Alive;: + 101:0,1,19,8889,-666,0.55;1,2,19,-1234,-294,0.07;:-27,25,0,27.00,A,Alive;: + 102:0,1,19,8889,-666,0.55;1,2,19,-1234,-294,0.07;:-27,25,0,27.00,H,Dead;: 200:0,1,1337,8889,-666,0.55;1,2,19,-1234,-294,0.07;:-27,25,0,27.00,H,Alive;: 201:0,1,1337,8889,-666,0.55;1,2,19,-1234,-294,0.07;:-27,25,0,27.00,H,Alive;: @@ -40,6 +40,9 @@ def test_correct_deserialization(self): inputs={ 'meta_data': meta_data, 'raw_data': raw_data + }, + options={ + "only_alive": False } ) @@ -56,6 +59,12 @@ def test_correct_deserialization(self): assert data_set.records[0].home_team_player_positions['19'] == Point(x=-1234.0, y=-294.0) assert data_set.records[0].away_team_player_positions['19'] == Point(x=8889, y=-666) assert data_set.records[0].ball_position == Point(x=-27, y=25) + assert data_set.records[0].ball_state == BallState.ALIVE + assert data_set.records[0].ball_owning_team == Team.HOME + + assert data_set.records[1].ball_owning_team == Team.AWAY + + assert data_set.records[2].ball_state == BallState.DEAD # make sure player data is only in the frame when the player is at the pitch assert '1337' not in data_set.records[0].away_team_player_positions From 4b861ef97922208544a47b1c4e55ff43f3d2e3bb Mon Sep 17 00:00:00 2001 From: Koen Vossen Date: Sat, 2 May 2020 21:04:02 +0200 Subject: [PATCH 09/14] also test tracab attacking direction --- kloppy/tests/test_tracab.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/kloppy/tests/test_tracab.py b/kloppy/tests/test_tracab.py index b424a285..6643d738 100644 --- a/kloppy/tests/test_tracab.py +++ b/kloppy/tests/test_tracab.py @@ -29,10 +29,10 @@ def test_correct_deserialization(self): 101:0,1,19,8889,-666,0.55;1,2,19,-1234,-294,0.07;:-27,25,0,27.00,A,Alive;: 102:0,1,19,8889,-666,0.55;1,2,19,-1234,-294,0.07;:-27,25,0,27.00,H,Dead;: - 200:0,1,1337,8889,-666,0.55;1,2,19,-1234,-294,0.07;:-27,25,0,27.00,H,Alive;: - 201:0,1,1337,8889,-666,0.55;1,2,19,-1234,-294,0.07;:-27,25,0,27.00,H,Alive;: - 202:0,1,1337,8889,-666,0.55;1,2,19,-1234,-294,0.07;:-27,25,0,27.00,H,Alive;: - 203:0,1,1337,8889,-666,0.55;1,2,19,-1234,-294,0.07;:-27,25,0,27.00,H,Alive;: + 200:0,1,1337,-8889,-666,0.55;1,2,19,-1234,-294,0.07;:-27,25,0,27.00,H,Alive;: + 201:0,1,1337,-8889,-666,0.55;1,2,19,-1234,-294,0.07;:-27,25,0,27.00,H,Alive;: + 202:0,1,1337,-8889,-666,0.55;1,2,19,-1234,-294,0.07;:-27,25,0,27.00,H,Alive;: + 203:0,1,1337,-8889,-666,0.55;1,2,19,-1234,-294,0.07;:-27,25,0,27.00,H,Alive;: """) serializer = TRACABSerializer() @@ -52,9 +52,8 @@ def test_correct_deserialization(self): assert data_set.periods[0] == Period(id=1, start_timestamp=4.0, end_timestamp=4.08, attacking_direction=AttackingDirection.HOME_AWAY) - # attacking direction is super weird ;-) assert data_set.periods[1] == Period(id=2, start_timestamp=8.0, end_timestamp=8.08, - attacking_direction=AttackingDirection.HOME_AWAY) + attacking_direction=AttackingDirection.AWAY_HOME) assert data_set.records[0].home_team_player_positions['19'] == Point(x=-1234.0, y=-294.0) assert data_set.records[0].away_team_player_positions['19'] == Point(x=8889, y=-666) From 4ec3c97f953bcd51b0ab60b201aab9a71db104c2 Mon Sep 17 00:00:00 2001 From: Koen Vossen Date: Sun, 3 May 2020 15:06:28 +0200 Subject: [PATCH 10/14] Metrica WIP --- kloppy/domain/models/__init__.py | 1 + kloppy/domain/models/event.py | 125 +++++++++--- kloppy/domain/models/tracking.py | 4 + kloppy/domain/services/__init__.py | 2 +- kloppy/domain/services/enrichers/__init__.py | 5 +- kloppy/infra/serializers/__init__.py | 1 + kloppy/infra/serializers/event/__init__.py | 2 + kloppy/infra/serializers/event/base.py | 2 +- .../serializers/event/metrica/__init__.py | 1 + .../serializers/event/metrica/serializer.py | 191 +++++++++++++++++- .../serializers/event/metrica/subtypes.py | 12 +- kloppy/tests/test_enricher.py | 59 ++++++ kloppy/tests/test_metrica.py | 51 ++++- 13 files changed, 419 insertions(+), 37 deletions(-) create mode 100644 kloppy/tests/test_enricher.py diff --git a/kloppy/domain/models/__init__.py b/kloppy/domain/models/__init__.py index e7acd60a..f1d6ecaf 100644 --- a/kloppy/domain/models/__init__.py +++ b/kloppy/domain/models/__init__.py @@ -1,4 +1,5 @@ from .common import * from .pitch import * from .tracking import * +from .event import * diff --git a/kloppy/domain/models/event.py b/kloppy/domain/models/event.py index ef9122db..02035e70 100644 --- a/kloppy/domain/models/event.py +++ b/kloppy/domain/models/event.py @@ -1,26 +1,14 @@ # Metrica Documentation https://github.com/metrica-sports/sample-data/blob/master/documentation/events-definitions.pdf - +from abc import ABC, abstractmethod from dataclasses import dataclass -from enum import Enum, Flag +from enum import Enum from csv import reader -from typing import List, Type, Dict, Callable, Set, Union +from typing import List, Union -from .pitch import PitchDimensions, Point +from .pitch import Point from .common import DataRecord, DataSet, Team -class EventType(Enum): - SET_PIECE = "SET PIECE" - RECOVERY = "RECOVERY" - PASS = "PASS" - BALL_LOST = "BALL LOST" - BALL_OUT = "BALL OUT" - SHOT = "SHOT" - FAULT_RECEIVED = "FAULT RECEIVED" - CHALLENGE = "CHALLENGE" - CARD = "CARD" - - class SubType(Enum): pass @@ -120,6 +108,10 @@ class Retaken(SubType): Retaken = "RETAKEN" +class OwnGoal(SubType): + OwnGoal = "OWN GOAL" + + """ @dataclass @@ -138,25 +130,110 @@ class Frame: """ +class EventType(Enum): + SET_PIECE = "SET PIECE" + RECOVERY = "RECOVERY" + PASS = "PASS" + BALL_LOST = "BALL LOST" + BALL_OUT = "BALL OUT" + SHOT = "SHOT" + FAULT_RECEIVED = "FAULT RECEIVED" + CHALLENGE = "CHALLENGE" + CARD = "CARD" + + @dataclass -class Event(DataRecord): - event_id: str +class Event(DataRecord, ABC): + event_id: int team: Team - event_type: EventType - end_timestamp: float # allowed to be same as timestamp player_jersey_no: str position: Point - secondary_player_jersey_no: str - secondary_position: Point + @property + @abstractmethod + def event_type(self) -> EventType: + raise NotImplementedError + + +@dataclass +class SetPieceEvent(Event): + @property + def event_type(self) -> EventType: + return EventType.SET_PIECE + + +@dataclass +class ShotEvent(Event): + shot_result: ShotResult + + @property + def event_type(self) -> EventType: + return EventType.PASS + + +@dataclass +class FaultReceivedEvent(Event): + @property + def event_type(self) -> EventType: + return EventType.FAULT_RECEIVED + + +@dataclass +class ChallengeEvent(Event): + @property + def event_type(self) -> EventType: + return EventType.CHALLENGE + + +@dataclass +class CardEvent(Event): + @property + def event_type(self) -> EventType: + return EventType.CARD + + +@dataclass +class RecoveryEvent(Event): + @property + def event_type(self) -> EventType: + return EventType.RECOVERY + + +@dataclass +class BallLossEvent(Event): + @property + def event_type(self) -> EventType: + return EventType.BALL_LOST + + +@dataclass +class BallOutEvent(Event): + @property + def event_type(self) -> EventType: + return EventType.BALL_OUT + + +@dataclass +class PassEvent(Event): + receiver_player_jersey_no: str + receiver_position: Point + + @property + def event_type(self) -> EventType: + return EventType.PASS @dataclass class EventDataSet(DataSet): - frame_rate: int - records: List[Event] + records: List[Union[ + SetPieceEvent, ShotEvent + ]] + + @property + def events(self): + return self.records if __name__ == '__main__': diff --git a/kloppy/domain/models/tracking.py b/kloppy/domain/models/tracking.py index e6ce9848..59bf5280 100644 --- a/kloppy/domain/models/tracking.py +++ b/kloppy/domain/models/tracking.py @@ -20,3 +20,7 @@ class Frame(DataRecord): class TrackingDataSet(DataSet): frame_rate: int records: List[Frame] + + @property + def frames(self): + return self.records diff --git a/kloppy/domain/services/__init__.py b/kloppy/domain/services/__init__.py index 10b8ac29..a7ed207f 100644 --- a/kloppy/domain/services/__init__.py +++ b/kloppy/domain/services/__init__.py @@ -3,7 +3,7 @@ from kloppy.domain import AttackingDirection, Frame from .transformers import Transformer -# from .enrichers import TrackingPossessionEnricher +from .enrichers import TrackingPossessionEnricher def avg(items: List[float]) -> float: diff --git a/kloppy/domain/services/enrichers/__init__.py b/kloppy/domain/services/enrichers/__init__.py index fef1f318..8d6e9919 100644 --- a/kloppy/domain/services/enrichers/__init__.py +++ b/kloppy/domain/services/enrichers/__init__.py @@ -24,9 +24,8 @@ def enrich_inplace(self, tracking_data_set: TrackingDataSet, event_data_set: Eve # set some defaults next_event_idx = 0 - - current_ball_owning_team = Team.HOME - current_ball_state = BallState.DEAD + current_ball_owning_team = None + current_ball_state = None for frame in tracking_data_set.records: if next_event_idx < len(event_data_set.records): diff --git a/kloppy/infra/serializers/__init__.py b/kloppy/infra/serializers/__init__.py index 40024c0f..5aaa2dca 100644 --- a/kloppy/infra/serializers/__init__.py +++ b/kloppy/infra/serializers/__init__.py @@ -1 +1,2 @@ from .tracking import TrackingDataSerializer, TRACABSerializer, MetricaTrackingSerializer +from .event import EventDataSerializer, MetricaEventSerializer diff --git a/kloppy/infra/serializers/event/__init__.py b/kloppy/infra/serializers/event/__init__.py index e69de29b..57bdc2df 100644 --- a/kloppy/infra/serializers/event/__init__.py +++ b/kloppy/infra/serializers/event/__init__.py @@ -0,0 +1,2 @@ +from .base import EventDataSerializer +from .metrica import MetricaEventSerializer \ No newline at end of file diff --git a/kloppy/infra/serializers/event/base.py b/kloppy/infra/serializers/event/base.py index 2fdc3150..16d4af69 100644 --- a/kloppy/infra/serializers/event/base.py +++ b/kloppy/infra/serializers/event/base.py @@ -5,7 +5,7 @@ from kloppy.domain import EventDataSet -class TrackingDataSerializer(ABC): +class EventDataSerializer(ABC): @abstractmethod def deserialize(self, inputs: Dict[str, Readable], options: Dict = None) -> EventDataSet: raise NotImplementedError diff --git a/kloppy/infra/serializers/event/metrica/__init__.py b/kloppy/infra/serializers/event/metrica/__init__.py index e69de29b..e8663be6 100644 --- a/kloppy/infra/serializers/event/metrica/__init__.py +++ b/kloppy/infra/serializers/event/metrica/__init__.py @@ -0,0 +1 @@ +from .serializer import MetricaEventSerializer \ No newline at end of file diff --git a/kloppy/infra/serializers/event/metrica/serializer.py b/kloppy/infra/serializers/event/metrica/serializer.py index dfeab7f8..c3a2c231 100644 --- a/kloppy/infra/serializers/event/metrica/serializer.py +++ b/kloppy/infra/serializers/event/metrica/serializer.py @@ -1,7 +1,25 @@ -from kloppy.domain.models.event import EventType +from typing import Tuple +import csv +from kloppy.domain import ( + EventDataSet, Team, Point, Period, Orientation, + DataSetFlag, PitchDimensions, Dimension, + AttackingDirection +) +from kloppy.domain.models.event import ( + EventType, + SetPieceEvent, PassEvent, RecoveryEvent, + BallOutEvent, BallLossEvent, + ShotEvent, FaultReceivedEvent, ChallengeEvent, + CardEvent +) +from kloppy.infra.utils import Readable -event_type_map = { +from .. import EventDataSerializer + +from .subtypes import * + +event_type_map: Dict[str, EventType] = { "SET PIECE": EventType.SET_PIECE, "RECOVERY": EventType.RECOVERY, "PASS": EventType.PASS, @@ -14,4 +32,171 @@ } # https://github.com/Friends-of-Tracking-Data-FoTD/passing-networks-in-python/blob/master/processing/tracking.py -# https://github.com/HarvardSoccer/TrackingData/blob/fa7701893c928e9fcec358ec6e281743c00e6bc1/Metrica.py#L251 \ No newline at end of file +# https://github.com/HarvardSoccer/TrackingData/blob/fa7701893c928e9fcec358ec6e281743c00e6bc1/Metrica.py#L251 + + +class MetricaEventSerializer(EventDataSerializer): + @staticmethod + def __validate_inputs(inputs: Dict[str, Readable]): + if "raw_data" not in inputs: + raise ValueError("Please specify a value for input 'raw_data'") + + def deserialize(self, inputs: Dict[str, Readable], options: Dict = None) -> EventDataSet: + self.__validate_inputs(inputs) + + periods = [] + period = None + + events = [] + + reader = csv.DictReader(map(lambda x: x.decode('utf-8'), inputs['raw_data'])) + for event_id, record in enumerate(reader): + event_type = event_type_map[record['Type']] + subtypes = record['Subtype'].split('-') + + start_timestamp = float(record['Start Time [s]']) + end_timestamp = float(record['End Time [s]']) + + period_id = int(record['Period']) + if not period or period.id != period_id: + period = Period( + id=period_id, + start_timestamp=start_timestamp, + end_timestamp=end_timestamp + ) + periods.append(period) + else: + period.end_timestamp = end_timestamp + + if record['Team'] == 'Home': + team = Team.HOME + elif record['Team'] == 'Away': + team = Team.AWAY + else: + raise ValueError(f'Unknown team: {record["team"]}') + + event_kwargs = dict( + # From DataRecord: + timestamp=start_timestamp, + ball_owning_team=None, ## todo + ball_state=None, # todo + period=period, + + # From Event: + event_id=event_id, + team=team, + end_timestamp=end_timestamp, + player_jersey_no=record['From'][6:], + position=Point( + x=float(record['Start X']), + y=1 - float(record['Start Y']) + ) if record['Start X'] != 'NaN' else None, + ) + + secondary_position = None + if record['End X'] != 'NaN': + secondary_position = Point( + x=float(record['End X']), + y=1 - float(record['End Y']) + ) + + secondary_jersey_no = None + if record['To']: + secondary_jersey_no = record['To'][6:] + + event = None + if event_type == EventType.SET_PIECE: + set_piece, fk_attempt, retaken = \ + build_subtypes(subtypes, [SetPiece, FKAttempt, Retaken]) + + event = SetPieceEvent( + **event_kwargs + ) + elif event_type == EventType.RECOVERY: + interference1, interference2 = \ + build_subtypes(subtypes, [Interference1, Interference2]) + + event = RecoveryEvent( + **event_kwargs + ) + elif event_type == EventType.PASS: + body_part, attempt, deflection, offside = \ + build_subtypes(subtypes, [BodyPart, Attempt, Deflection, Offside]) + + event = PassEvent( + receiver_position=secondary_position, + receiver_player_jersey_no=secondary_jersey_no, + **event_kwargs + ) + elif event_type == EventType.BALL_LOST: + body_part, attempt, interference1, intervention, deflection, offside = \ + build_subtypes(subtypes, [ + BodyPart, Attempt, Interference1, Intervention, + Deflection, Offside + ]) + + event = BallLossEvent( + **event_kwargs + ) + elif event_type == EventType.BALL_OUT: + body_part, attempt, intervention, deflection, offside, own_goal = \ + build_subtypes(subtypes, [ + BodyPart, Attempt, Intervention, Deflection, + Offside, OwnGoal + ]) + + event = BallOutEvent( + **event_kwargs + ) + elif event_type == EventType.SHOT: + body_part, deflection, shot_direction, shot_result, offside = \ + build_subtypes(subtypes, [ + BodyPart, Deflection, ShotDirection, + ShotResult, Offside + ]) + + event = ShotEvent( + shot_result=shot_result, + **event_kwargs + ) + elif event_type == EventType.FAULT_RECEIVED: + event = FaultReceivedEvent( + **event_kwargs + ) + elif event_type == EventType.CHALLENGE: + challenge, fault, challenge_result = \ + build_subtypes(subtypes, [Challenge, Fault, ChallengeResult]) + + event = ChallengeEvent( + **event_kwargs + ) + elif event_type == EventType.CARD: + card, = build_subtypes(subtypes, [Card]) + + event = CardEvent( + **event_kwargs + ) + else: + raise NotImplementedError(f"EventType {event_type} not implemented") + + events.append(event) + + orientation = ( + Orientation.FIXED_HOME_AWAY + if periods[0].attacking_direction == AttackingDirection.HOME_AWAY else + Orientation.FIXED_AWAY_HOME + ) + + return EventDataSet( + flags=DataSetFlag.BALL_STATE | DataSetFlag.BALL_OWNING_TEAM, + orientation=orientation, + pitch_dimensions=PitchDimensions( + x_dim=Dimension(0, 1), + y_dim=Dimension(0, 1) + ), + periods=periods, + records=events + ) + + def serialize(self, data_set: EventDataSet) -> Tuple[str, str]: + raise NotImplementedError diff --git a/kloppy/infra/serializers/event/metrica/subtypes.py b/kloppy/infra/serializers/event/metrica/subtypes.py index 33c9346b..03516c9c 100644 --- a/kloppy/infra/serializers/event/metrica/subtypes.py +++ b/kloppy/infra/serializers/event/metrica/subtypes.py @@ -5,7 +5,8 @@ ShotResult, ShotDirection, Deflection, BodyPart, Offside, Attempt, Intervention, Interference1, Interference2, ChallengeType, ChallengeResult, Fault, - SubType) + SubType, OwnGoal +) def build_retaken(string: str) -> Retaken: @@ -86,7 +87,7 @@ def build_shotdirection(string: str) -> ShotDirection: raise ValueError(f"Unknown shotdirection type: {string}") -def build_Deflection(string: str) -> Deflection: +def build_deflection(string: str) -> Deflection: if string == "WOODWORK": return Deflection.Woodwork elif string == "REFEREE HIT": @@ -192,7 +193,7 @@ def build_challenge_result(string: str) -> ChallengeResult: Attempt: build_attempt, Offside: build_offside, BodyPart: build_bodypart, - Deflection: build_Deflection, + Deflection: build_deflection, ShotDirection: build_shotdirection, ShotResult: build_shotresult, Challenge: build_challenge, @@ -206,6 +207,9 @@ def build_challenge_result(string: str) -> ChallengeResult: def build_subtypes(items: List[str], subtype_types: List[Type[SubType]]) -> List[Union[SubType, None]]: result = [None] * len(subtype_types) for item in items: + if not item: + continue + for i, subtype_type in enumerate(subtype_types): assert subtype_type in factories, f"Factory missing for {subtype_type}" @@ -215,7 +219,7 @@ def build_subtypes(items: List[str], subtype_types: List[Type[SubType]]) -> List except ValueError: continue else: - raise ValueError(f"Cannot determine subtype type of f{item}") + raise ValueError(f"Cannot determine subtype type of {item}") result[i] = subtype diff --git a/kloppy/tests/test_enricher.py b/kloppy/tests/test_enricher.py new file mode 100644 index 00000000..f725024b --- /dev/null +++ b/kloppy/tests/test_enricher.py @@ -0,0 +1,59 @@ +from kloppy.domain import TrackingDataSet, EventDataSet, PitchDimensions, Dimension, Orientation, DataSetFlag, Period, \ + Frame, TrackingPossessionEnricher, SetPieceEvent, BallState, Team + + +class TestEnricher: + def test_enrich_tracking_data(self): + periods = [ + Period(id=1, start_timestamp=0.0, end_timestamp=10.0), + Period(id=2, start_timestamp=15.0, end_timestamp=25.0) + ] + tracking_data = TrackingDataSet( + flags=~(DataSetFlag.BALL_OWNING_TEAM | DataSetFlag.BALL_STATE), + pitch_dimensions=PitchDimensions( + x_dim=Dimension(0, 100), + y_dim=Dimension(-50, 50) + ), + orientation=Orientation.HOME_TEAM, + frame_rate=25, + records=[ + Frame( + frame_id=1, + timestamp=0.1, + ball_owning_team=None, + ball_state=None, + period=periods[0], + + away_team_player_positions={}, + home_team_player_positions={}, + ball_position=None + ) + ], + periods=periods + ) + + event_data = EventDataSet( + flags=DataSetFlag.BALL_OWNING_TEAM | DataSetFlag.BALL_STATE, + pitch_dimensions=PitchDimensions( + x_dim=Dimension(0, 100), + y_dim=Dimension(-50, 50) + ), + orientation=Orientation.HOME_TEAM, + records=[ + # SetPieceEvent( + # event_id=1, + # timestamp=0.1, + # ball_owning_team=Team.HOME, + # ball_state=BallState.ALIVE, + # period=periods[0], + # team=Team.HOME, + # ) + ], + periods=periods + ) + + enricher = TrackingPossessionEnricher() + enricher.enrich_inplace( + tracking_data_set=tracking_data, + event_data_set=event_data + ) \ No newline at end of file diff --git a/kloppy/tests/test_metrica.py b/kloppy/tests/test_metrica.py index ee86c3e5..f3e3f352 100644 --- a/kloppy/tests/test_metrica.py +++ b/kloppy/tests/test_metrica.py @@ -1,6 +1,6 @@ from io import BytesIO -from kloppy import MetricaTrackingSerializer +from kloppy import MetricaTrackingSerializer, MetricaEventSerializer from kloppy.domain import Period, AttackingDirection, Orientation, Point @@ -51,3 +51,52 @@ def test_correct_deserialization(self): # make sure player data is only in the frame when the player is at the pitch assert '14' not in data_set.records[0].home_team_player_positions assert '14' in data_set.records[3].home_team_player_positions + + +class TestMetricaEvent: + def test_correct_deserialization(self): + raw_data = BytesIO(b"""Team,Type,Subtype,Period,Start Frame,Start Time [s],End Frame,End Time [s],From,To,Start X,Start Y,End X,End Y +Away,SET PIECE,KICK OFF,1,1,0.04,0,0,Player19,,NaN,NaN,NaN,NaN +Away,PASS,,1,1,0.04,3,0.12,Player19,Player21,0.45,0.39,0.55,0.43 +Away,PASS,,1,3,0.12,17,0.68,Player21,Player15,0.55,0.43,0.58,0.21 +Away,PASS,,1,45,1.8,61,2.44,Player15,Player19,0.55,0.19,0.45,0.31 +Away,PASS,,1,77,3.08,96,3.84,Player19,Player21,0.45,0.32,0.49,0.47 +Away,PASS,,1,191,7.64,217,8.68,Player21,Player22,0.4,0.73,0.32,0.98 +Away,PASS,,1,279,11.16,303,12.12,Player22,Player17,0.39,0.96,0.49,0.98 +Away,BALL LOST,INTERCEPTION,1,346,13.84,380,15.2,Player17,,0.51,0.97,0.27,0.75 +Home,RECOVERY,INTERCEPTION,1,378,15.12,378,15.12,Player2,,0.27,0.78,NaN,NaN +Home,BALL LOST,INTERCEPTION,1,378,15.12,452,18.08,Player2,,0.27,0.78,0.59,0.64 +Away,RECOVERY,INTERCEPTION,1,453,18.12,453,18.12,Player16,,0.57,0.67,NaN,NaN +Away,BALL LOST,HEAD-INTERCEPTION,1,453,18.12,497,19.88,Player16,,0.57,0.67,0.33,0.65 +Away,CHALLENGE,AERIAL-LOST,1,497,19.88,497,19.88,Player18,,0.38,0.67,NaN,NaN +Home,CHALLENGE,AERIAL-WON,1,498,19.92,498,19.92,Player2,,0.36,0.67,NaN,NaN +Home,RECOVERY,INTERCEPTION,1,498,19.92,498,19.92,Player2,,0.36,0.67,NaN,NaN +Home,PASS,HEAD,1,498,19.92,536,21.44,Player2,Player9,0.36,0.67,0.53,0.59 +Home,PASS,,1,536,21.44,556,22.24,Player9,Player10,0.53,0.59,0.5,0.65 +Home,BALL LOST,INTERCEPTION,1,572,22.88,616,24.64,Player10,,0.5,0.65,0.67,0.44 +Away,RECOVERY,INTERCEPTION,1,618,24.72,618,24.72,Player16,,0.64,0.46,NaN,NaN +Away,PASS,,1,763,30.52,784,31.36,Player16,Player19,0.58,0.27,0.51,0.33 +Away,PASS,,1,784,31.36,804,32.16,Player19,Player20,0.51,0.33,0.57,0.47 +Away,PASS,,1,834,33.36,881,35.24,Player20,Player22,0.53,0.53,0.44,0.92 +Away,PASS,,1,976,39.04,1010,40.4,Player22,Player17,0.36,0.96,0.48,0.86 +Away,BALL LOST,INTERCEPTION,1,1110,44.4,1134,45.36,Player17,,0.42,0.79,0.31,0.84 +Home,RECOVERY,INTERCEPTION,1,1134,45.36,1134,45.36,Player5,,0.32,0.89,NaN,NaN +Home,PASS,HEAD,1,1134,45.36,1154,46.16,Player5,Player6,0.32,0.89,0.31,0.78 +Home,PASS,,1,1154,46.16,1177,47.08,Player6,Player10,0.31,0.78,0.41,0.74 +Home,PASS,,1,1226,49.04,1266,50.64,Player10,Player8,0.46,0.68,0.56,0.34 +Home,BALL LOST,INTERCEPTION,1,1370,54.8,1375,55,Player8,,0.86,0.26,0.88,0.28 +Away,RECOVERY,INTERCEPTION,1,1374,54.96,1374,54.96,Player15,,0.87,0.29,NaN,NaN +Away,BALL OUT,,1,1374,54.96,1425,57,Player15,,0.87,0.29,1.05,0.17 +Home,SET PIECE,CORNER KICK,1,2143,85.72,2143,85.72,Player6,,NaN,NaN,NaN,NaN +Home,PASS,,1,2143,85.72,2184,87.36,Player6,Player10,1,0.01,0.9,0.09 +Home,PASS,CROSS,1,2263,90.52,2289,91.56,Player10,Player9,0.89,0.14,0.92,0.47 +Home,SHOT,HEAD-ON TARGET-GOAL,1,2289,91.56,2309,92.36,Player9,,0.92,0.47,1.01,0.55 +Away,SET PIECE,KICK OFF,1,3675,147,3675,147,Player19,,NaN,NaN,NaN,NaN +Away,PASS,,1,3675,147,3703,148.12,Player19,Player21,0.49,0.5,0.58,0.52""") + + serializer = MetricaEventSerializer() + serializer.deserialize( + inputs={ + 'raw_data': raw_data + } + ) \ No newline at end of file From 24ad27032eac7bc248a629cf5a11655b8bf43dba Mon Sep 17 00:00:00 2001 From: Koen Vossen Date: Sun, 3 May 2020 22:01:59 +0200 Subject: [PATCH 11/14] Add ball_state WIP --- .../serializers/event/metrica/serializer.py | 58 ++++++++++++++++++- .../serializers/event/metrica/subtypes.py | 10 +++- 2 files changed, 64 insertions(+), 4 deletions(-) diff --git a/kloppy/infra/serializers/event/metrica/serializer.py b/kloppy/infra/serializers/event/metrica/serializer.py index c3a2c231..73a446b3 100644 --- a/kloppy/infra/serializers/event/metrica/serializer.py +++ b/kloppy/infra/serializers/event/metrica/serializer.py @@ -1,13 +1,14 @@ +from collections import namedtuple from typing import Tuple import csv from kloppy.domain import ( EventDataSet, Team, Point, Period, Orientation, DataSetFlag, PitchDimensions, Dimension, - AttackingDirection + AttackingDirection, BallState ) from kloppy.domain.models.event import ( - EventType, + EventType, Event, SetPieceEvent, PassEvent, RecoveryEvent, BallOutEvent, BallLossEvent, ShotEvent, FaultReceivedEvent, ChallengeEvent, @@ -36,11 +37,49 @@ class MetricaEventSerializer(EventDataSerializer): + __GameState = namedtuple("GameState", "ball_owning_team ball_state") + @staticmethod def __validate_inputs(inputs: Dict[str, Readable]): if "raw_data" not in inputs: raise ValueError("Please specify a value for input 'raw_data'") + def __reduce_game_state(self, event: Event, game_state: __GameState) -> __GameState: + # Dead -> Alive + new_state = None + if event.event_type == EventType.SET_PIECE: + new_state = self.__GameState( + ball_state=BallState.ALIVE, + ball_owning_team=event.team + ) + elif event.event_type == EventType.RECOVERY: + new_state = self.__GameState( + ball_state=BallState.ALIVE, + ball_owning_team=event.team + ) + + # Alive -> Dead + elif event.event_type == EventType.SHOT: + event: ShotEvent + if event.shot_result == ShotResult.OUT: + new_state = self.__GameState( + ball_state=BallState.DEAD, + ball_owning_team=None + ) + elif event.shot_result == ShotResult.GOAL: + new_state = self.__GameState( + ball_state=BallState.DEAD, + ball_owning_team=None + ) + elif event.event_type == EventType.BALL_OUT: + # own-goal is part of this event + new_state = self.__GameState( + ball_state=BallState.DEAD, + ball_owning_team=None + ) + + return new_state if new_state else game_state + def deserialize(self, inputs: Dict[str, Readable], options: Dict = None) -> EventDataSet: self.__validate_inputs(inputs) @@ -49,6 +88,11 @@ def deserialize(self, inputs: Dict[str, Readable], options: Dict = None) -> Even events = [] + game_state = self.__GameState( + ball_state=BallState.DEAD, + ball_owning_team=None + ) + reader = csv.DictReader(map(lambda x: x.decode('utf-8'), inputs['raw_data'])) for event_id, record in enumerate(reader): event_type = event_type_map[record['Type']] @@ -179,8 +223,18 @@ def deserialize(self, inputs: Dict[str, Readable], options: Dict = None) -> Even else: raise NotImplementedError(f"EventType {event_type} not implemented") + # We want to attach the game_state after the event to the event + game_state = self.__reduce_game_state( + event=event, + game_state=game_state + ) + + event.ball_state = game_state.ball_state + event.ball_owning_team = game_state.ball_owning_team + events.append(event) + orientation = ( Orientation.FIXED_HOME_AWAY if periods[0].attacking_direction == AttackingDirection.HOME_AWAY else diff --git a/kloppy/infra/serializers/event/metrica/subtypes.py b/kloppy/infra/serializers/event/metrica/subtypes.py index 03516c9c..b5dc32c7 100644 --- a/kloppy/infra/serializers/event/metrica/subtypes.py +++ b/kloppy/infra/serializers/event/metrica/subtypes.py @@ -64,7 +64,6 @@ def build_challenge(string: str) -> Challenge: raise ValueError(f"Unknown challenge type: {string}") - def build_shotresult(string: str) -> ShotResult: if string == "GOAL": return ShotResult.Goal @@ -183,6 +182,12 @@ def build_challenge_result(string: str) -> ChallengeResult: raise ValueError(f"Unknown challenge result: {string}") +def build_owngoal(string: str) -> OwnGoal: + if string == "GOAL": + return OwnGoal.OwnGoal + else: + raise ValueError(f"Unknown owngoal type: {string}") + factories: Dict[Type[SubType], Callable] = { ChallengeType: build_challenge_type, Fault: build_fault, @@ -200,7 +205,8 @@ def build_challenge_result(string: str) -> ChallengeResult: Card: build_card, SetPiece: build_setpiece, FKAttempt: build_fkattempt, - Retaken: build_retaken + Retaken: build_retaken, + OwnGoal: build_owngoal } From d4d60bc62e5be49849e4a461948548eff93016a6 Mon Sep 17 00:00:00 2001 From: Koen Vossen Date: Tue, 5 May 2020 20:32:13 +0200 Subject: [PATCH 12/14] minor --- kloppy/infra/serializers/event/metrica/serializer.py | 1 - 1 file changed, 1 deletion(-) diff --git a/kloppy/infra/serializers/event/metrica/serializer.py b/kloppy/infra/serializers/event/metrica/serializer.py index 73a446b3..252a8b58 100644 --- a/kloppy/infra/serializers/event/metrica/serializer.py +++ b/kloppy/infra/serializers/event/metrica/serializer.py @@ -234,7 +234,6 @@ def deserialize(self, inputs: Dict[str, Readable], options: Dict = None) -> Even events.append(event) - orientation = ( Orientation.FIXED_HOME_AWAY if periods[0].attacking_direction == AttackingDirection.HOME_AWAY else From d63392eb62caf4b0240f39ffe8f2a693cb770a28 Mon Sep 17 00:00:00 2001 From: Koen Vossen Date: Tue, 5 May 2020 20:50:10 +0200 Subject: [PATCH 13/14] Disable event data --- kloppy/domain/models/__init__.py | 2 +- kloppy/domain/services/__init__.py | 2 +- .../domain/services/transformers/__init__.py | 29 +++-- kloppy/infra/serializers/__init__.py | 2 +- kloppy/tests/test_enricher.py | 118 +++++++++--------- kloppy/tests/test_metrica.py | 98 +++++++-------- 6 files changed, 128 insertions(+), 123 deletions(-) diff --git a/kloppy/domain/models/__init__.py b/kloppy/domain/models/__init__.py index f1d6ecaf..370e0da7 100644 --- a/kloppy/domain/models/__init__.py +++ b/kloppy/domain/models/__init__.py @@ -1,5 +1,5 @@ from .common import * from .pitch import * from .tracking import * -from .event import * +# NOT YET: from .event import * diff --git a/kloppy/domain/services/__init__.py b/kloppy/domain/services/__init__.py index a7ed207f..b654fdc6 100644 --- a/kloppy/domain/services/__init__.py +++ b/kloppy/domain/services/__init__.py @@ -3,7 +3,7 @@ from kloppy.domain import AttackingDirection, Frame from .transformers import Transformer -from .enrichers import TrackingPossessionEnricher +# NOT YET: from .enrichers import TrackingPossessionEnricher def avg(items: List[float]) -> float: diff --git a/kloppy/domain/services/transformers/__init__.py b/kloppy/domain/services/transformers/__init__.py index 07f6f3c3..4e21286e 100644 --- a/kloppy/domain/services/transformers/__init__.py +++ b/kloppy/domain/services/transformers/__init__.py @@ -5,7 +5,7 @@ Frame, Team, AttackingDirection, - TrackingDataSet, DataSetFlag + TrackingDataSet, DataSetFlag, DataSet, # NOT YET: EventDataSet ) @@ -78,9 +78,9 @@ def transform_frame(self, frame: Frame) -> Frame: @classmethod def transform_data_set(cls, - data_set: TrackingDataSet, + data_set: DataSet, to_pitch_dimensions: PitchDimensions = None, - to_orientation: Orientation = None) -> TrackingDataSet: + to_orientation: Orientation = None) -> DataSet: if not to_pitch_dimensions and not to_orientation: return data_set elif not to_orientation: @@ -99,13 +99,18 @@ def transform_data_set(cls, to_pitch_dimensions=to_pitch_dimensions, to_orientation=to_orientation ) - frames = list(map(transformer.transform_frame, data_set.frames)) + if isinstance(data_set, TrackingDataSet): + frames = list(map(transformer.transform_frame, data_set.records)) - return TrackingDataSet( - flags=data_set.flags, - frame_rate=data_set.frame_rate, - periods=data_set.periods, - pitch_dimensions=to_pitch_dimensions, - orientation=to_orientation, - frames=frames - ) + return TrackingDataSet( + flags=data_set.flags, + frame_rate=data_set.frame_rate, + periods=data_set.periods, + pitch_dimensions=to_pitch_dimensions, + orientation=to_orientation, + records=frames + ) + #elif isinstance(data_set, EventDataSet): + # raise Exception("EventDataSet transformer not implemented yet") + else: + raise Exception("Unknown DataSet type") diff --git a/kloppy/infra/serializers/__init__.py b/kloppy/infra/serializers/__init__.py index 5aaa2dca..186246b7 100644 --- a/kloppy/infra/serializers/__init__.py +++ b/kloppy/infra/serializers/__init__.py @@ -1,2 +1,2 @@ from .tracking import TrackingDataSerializer, TRACABSerializer, MetricaTrackingSerializer -from .event import EventDataSerializer, MetricaEventSerializer +# NOT YET: from .event import EventDataSerializer, MetricaEventSerializer diff --git a/kloppy/tests/test_enricher.py b/kloppy/tests/test_enricher.py index f725024b..36d797ab 100644 --- a/kloppy/tests/test_enricher.py +++ b/kloppy/tests/test_enricher.py @@ -1,59 +1,59 @@ -from kloppy.domain import TrackingDataSet, EventDataSet, PitchDimensions, Dimension, Orientation, DataSetFlag, Period, \ - Frame, TrackingPossessionEnricher, SetPieceEvent, BallState, Team - - -class TestEnricher: - def test_enrich_tracking_data(self): - periods = [ - Period(id=1, start_timestamp=0.0, end_timestamp=10.0), - Period(id=2, start_timestamp=15.0, end_timestamp=25.0) - ] - tracking_data = TrackingDataSet( - flags=~(DataSetFlag.BALL_OWNING_TEAM | DataSetFlag.BALL_STATE), - pitch_dimensions=PitchDimensions( - x_dim=Dimension(0, 100), - y_dim=Dimension(-50, 50) - ), - orientation=Orientation.HOME_TEAM, - frame_rate=25, - records=[ - Frame( - frame_id=1, - timestamp=0.1, - ball_owning_team=None, - ball_state=None, - period=periods[0], - - away_team_player_positions={}, - home_team_player_positions={}, - ball_position=None - ) - ], - periods=periods - ) - - event_data = EventDataSet( - flags=DataSetFlag.BALL_OWNING_TEAM | DataSetFlag.BALL_STATE, - pitch_dimensions=PitchDimensions( - x_dim=Dimension(0, 100), - y_dim=Dimension(-50, 50) - ), - orientation=Orientation.HOME_TEAM, - records=[ - # SetPieceEvent( - # event_id=1, - # timestamp=0.1, - # ball_owning_team=Team.HOME, - # ball_state=BallState.ALIVE, - # period=periods[0], - # team=Team.HOME, - # ) - ], - periods=periods - ) - - enricher = TrackingPossessionEnricher() - enricher.enrich_inplace( - tracking_data_set=tracking_data, - event_data_set=event_data - ) \ No newline at end of file +# from kloppy.domain import TrackingDataSet, EventDataSet, PitchDimensions, Dimension, Orientation, DataSetFlag, Period, \ +# Frame, TrackingPossessionEnricher, SetPieceEvent, BallState, Team +# +# +# class TestEnricher: +# def test_enrich_tracking_data(self): +# periods = [ +# Period(id=1, start_timestamp=0.0, end_timestamp=10.0), +# Period(id=2, start_timestamp=15.0, end_timestamp=25.0) +# ] +# tracking_data = TrackingDataSet( +# flags=~(DataSetFlag.BALL_OWNING_TEAM | DataSetFlag.BALL_STATE), +# pitch_dimensions=PitchDimensions( +# x_dim=Dimension(0, 100), +# y_dim=Dimension(-50, 50) +# ), +# orientation=Orientation.HOME_TEAM, +# frame_rate=25, +# records=[ +# Frame( +# frame_id=1, +# timestamp=0.1, +# ball_owning_team=None, +# ball_state=None, +# period=periods[0], +# +# away_team_player_positions={}, +# home_team_player_positions={}, +# ball_position=None +# ) +# ], +# periods=periods +# ) +# +# event_data = EventDataSet( +# flags=DataSetFlag.BALL_OWNING_TEAM | DataSetFlag.BALL_STATE, +# pitch_dimensions=PitchDimensions( +# x_dim=Dimension(0, 100), +# y_dim=Dimension(-50, 50) +# ), +# orientation=Orientation.HOME_TEAM, +# records=[ +# # SetPieceEvent( +# # event_id=1, +# # timestamp=0.1, +# # ball_owning_team=Team.HOME, +# # ball_state=BallState.ALIVE, +# # period=periods[0], +# # team=Team.HOME, +# # ) +# ], +# periods=periods +# ) +# +# enricher = TrackingPossessionEnricher() +# enricher.enrich_inplace( +# tracking_data_set=tracking_data, +# event_data_set=event_data +# ) \ No newline at end of file diff --git a/kloppy/tests/test_metrica.py b/kloppy/tests/test_metrica.py index f3e3f352..181fcfd6 100644 --- a/kloppy/tests/test_metrica.py +++ b/kloppy/tests/test_metrica.py @@ -1,6 +1,6 @@ from io import BytesIO -from kloppy import MetricaTrackingSerializer, MetricaEventSerializer +from kloppy import MetricaTrackingSerializer # NOT YET: , MetricaEventSerializer from kloppy.domain import Period, AttackingDirection, Orientation, Point @@ -52,51 +52,51 @@ def test_correct_deserialization(self): assert '14' not in data_set.records[0].home_team_player_positions assert '14' in data_set.records[3].home_team_player_positions - -class TestMetricaEvent: - def test_correct_deserialization(self): - raw_data = BytesIO(b"""Team,Type,Subtype,Period,Start Frame,Start Time [s],End Frame,End Time [s],From,To,Start X,Start Y,End X,End Y -Away,SET PIECE,KICK OFF,1,1,0.04,0,0,Player19,,NaN,NaN,NaN,NaN -Away,PASS,,1,1,0.04,3,0.12,Player19,Player21,0.45,0.39,0.55,0.43 -Away,PASS,,1,3,0.12,17,0.68,Player21,Player15,0.55,0.43,0.58,0.21 -Away,PASS,,1,45,1.8,61,2.44,Player15,Player19,0.55,0.19,0.45,0.31 -Away,PASS,,1,77,3.08,96,3.84,Player19,Player21,0.45,0.32,0.49,0.47 -Away,PASS,,1,191,7.64,217,8.68,Player21,Player22,0.4,0.73,0.32,0.98 -Away,PASS,,1,279,11.16,303,12.12,Player22,Player17,0.39,0.96,0.49,0.98 -Away,BALL LOST,INTERCEPTION,1,346,13.84,380,15.2,Player17,,0.51,0.97,0.27,0.75 -Home,RECOVERY,INTERCEPTION,1,378,15.12,378,15.12,Player2,,0.27,0.78,NaN,NaN -Home,BALL LOST,INTERCEPTION,1,378,15.12,452,18.08,Player2,,0.27,0.78,0.59,0.64 -Away,RECOVERY,INTERCEPTION,1,453,18.12,453,18.12,Player16,,0.57,0.67,NaN,NaN -Away,BALL LOST,HEAD-INTERCEPTION,1,453,18.12,497,19.88,Player16,,0.57,0.67,0.33,0.65 -Away,CHALLENGE,AERIAL-LOST,1,497,19.88,497,19.88,Player18,,0.38,0.67,NaN,NaN -Home,CHALLENGE,AERIAL-WON,1,498,19.92,498,19.92,Player2,,0.36,0.67,NaN,NaN -Home,RECOVERY,INTERCEPTION,1,498,19.92,498,19.92,Player2,,0.36,0.67,NaN,NaN -Home,PASS,HEAD,1,498,19.92,536,21.44,Player2,Player9,0.36,0.67,0.53,0.59 -Home,PASS,,1,536,21.44,556,22.24,Player9,Player10,0.53,0.59,0.5,0.65 -Home,BALL LOST,INTERCEPTION,1,572,22.88,616,24.64,Player10,,0.5,0.65,0.67,0.44 -Away,RECOVERY,INTERCEPTION,1,618,24.72,618,24.72,Player16,,0.64,0.46,NaN,NaN -Away,PASS,,1,763,30.52,784,31.36,Player16,Player19,0.58,0.27,0.51,0.33 -Away,PASS,,1,784,31.36,804,32.16,Player19,Player20,0.51,0.33,0.57,0.47 -Away,PASS,,1,834,33.36,881,35.24,Player20,Player22,0.53,0.53,0.44,0.92 -Away,PASS,,1,976,39.04,1010,40.4,Player22,Player17,0.36,0.96,0.48,0.86 -Away,BALL LOST,INTERCEPTION,1,1110,44.4,1134,45.36,Player17,,0.42,0.79,0.31,0.84 -Home,RECOVERY,INTERCEPTION,1,1134,45.36,1134,45.36,Player5,,0.32,0.89,NaN,NaN -Home,PASS,HEAD,1,1134,45.36,1154,46.16,Player5,Player6,0.32,0.89,0.31,0.78 -Home,PASS,,1,1154,46.16,1177,47.08,Player6,Player10,0.31,0.78,0.41,0.74 -Home,PASS,,1,1226,49.04,1266,50.64,Player10,Player8,0.46,0.68,0.56,0.34 -Home,BALL LOST,INTERCEPTION,1,1370,54.8,1375,55,Player8,,0.86,0.26,0.88,0.28 -Away,RECOVERY,INTERCEPTION,1,1374,54.96,1374,54.96,Player15,,0.87,0.29,NaN,NaN -Away,BALL OUT,,1,1374,54.96,1425,57,Player15,,0.87,0.29,1.05,0.17 -Home,SET PIECE,CORNER KICK,1,2143,85.72,2143,85.72,Player6,,NaN,NaN,NaN,NaN -Home,PASS,,1,2143,85.72,2184,87.36,Player6,Player10,1,0.01,0.9,0.09 -Home,PASS,CROSS,1,2263,90.52,2289,91.56,Player10,Player9,0.89,0.14,0.92,0.47 -Home,SHOT,HEAD-ON TARGET-GOAL,1,2289,91.56,2309,92.36,Player9,,0.92,0.47,1.01,0.55 -Away,SET PIECE,KICK OFF,1,3675,147,3675,147,Player19,,NaN,NaN,NaN,NaN -Away,PASS,,1,3675,147,3703,148.12,Player19,Player21,0.49,0.5,0.58,0.52""") - - serializer = MetricaEventSerializer() - serializer.deserialize( - inputs={ - 'raw_data': raw_data - } - ) \ No newline at end of file +# +# class TestMetricaEvent: +# def test_correct_deserialization(self): +# raw_data = BytesIO(b"""Team,Type,Subtype,Period,Start Frame,Start Time [s],End Frame,End Time [s],From,To,Start X,Start Y,End X,End Y +# Away,SET PIECE,KICK OFF,1,1,0.04,0,0,Player19,,NaN,NaN,NaN,NaN +# Away,PASS,,1,1,0.04,3,0.12,Player19,Player21,0.45,0.39,0.55,0.43 +# Away,PASS,,1,3,0.12,17,0.68,Player21,Player15,0.55,0.43,0.58,0.21 +# Away,PASS,,1,45,1.8,61,2.44,Player15,Player19,0.55,0.19,0.45,0.31 +# Away,PASS,,1,77,3.08,96,3.84,Player19,Player21,0.45,0.32,0.49,0.47 +# Away,PASS,,1,191,7.64,217,8.68,Player21,Player22,0.4,0.73,0.32,0.98 +# Away,PASS,,1,279,11.16,303,12.12,Player22,Player17,0.39,0.96,0.49,0.98 +# Away,BALL LOST,INTERCEPTION,1,346,13.84,380,15.2,Player17,,0.51,0.97,0.27,0.75 +# Home,RECOVERY,INTERCEPTION,1,378,15.12,378,15.12,Player2,,0.27,0.78,NaN,NaN +# Home,BALL LOST,INTERCEPTION,1,378,15.12,452,18.08,Player2,,0.27,0.78,0.59,0.64 +# Away,RECOVERY,INTERCEPTION,1,453,18.12,453,18.12,Player16,,0.57,0.67,NaN,NaN +# Away,BALL LOST,HEAD-INTERCEPTION,1,453,18.12,497,19.88,Player16,,0.57,0.67,0.33,0.65 +# Away,CHALLENGE,AERIAL-LOST,1,497,19.88,497,19.88,Player18,,0.38,0.67,NaN,NaN +# Home,CHALLENGE,AERIAL-WON,1,498,19.92,498,19.92,Player2,,0.36,0.67,NaN,NaN +# Home,RECOVERY,INTERCEPTION,1,498,19.92,498,19.92,Player2,,0.36,0.67,NaN,NaN +# Home,PASS,HEAD,1,498,19.92,536,21.44,Player2,Player9,0.36,0.67,0.53,0.59 +# Home,PASS,,1,536,21.44,556,22.24,Player9,Player10,0.53,0.59,0.5,0.65 +# Home,BALL LOST,INTERCEPTION,1,572,22.88,616,24.64,Player10,,0.5,0.65,0.67,0.44 +# Away,RECOVERY,INTERCEPTION,1,618,24.72,618,24.72,Player16,,0.64,0.46,NaN,NaN +# Away,PASS,,1,763,30.52,784,31.36,Player16,Player19,0.58,0.27,0.51,0.33 +# Away,PASS,,1,784,31.36,804,32.16,Player19,Player20,0.51,0.33,0.57,0.47 +# Away,PASS,,1,834,33.36,881,35.24,Player20,Player22,0.53,0.53,0.44,0.92 +# Away,PASS,,1,976,39.04,1010,40.4,Player22,Player17,0.36,0.96,0.48,0.86 +# Away,BALL LOST,INTERCEPTION,1,1110,44.4,1134,45.36,Player17,,0.42,0.79,0.31,0.84 +# Home,RECOVERY,INTERCEPTION,1,1134,45.36,1134,45.36,Player5,,0.32,0.89,NaN,NaN +# Home,PASS,HEAD,1,1134,45.36,1154,46.16,Player5,Player6,0.32,0.89,0.31,0.78 +# Home,PASS,,1,1154,46.16,1177,47.08,Player6,Player10,0.31,0.78,0.41,0.74 +# Home,PASS,,1,1226,49.04,1266,50.64,Player10,Player8,0.46,0.68,0.56,0.34 +# Home,BALL LOST,INTERCEPTION,1,1370,54.8,1375,55,Player8,,0.86,0.26,0.88,0.28 +# Away,RECOVERY,INTERCEPTION,1,1374,54.96,1374,54.96,Player15,,0.87,0.29,NaN,NaN +# Away,BALL OUT,,1,1374,54.96,1425,57,Player15,,0.87,0.29,1.05,0.17 +# Home,SET PIECE,CORNER KICK,1,2143,85.72,2143,85.72,Player6,,NaN,NaN,NaN,NaN +# Home,PASS,,1,2143,85.72,2184,87.36,Player6,Player10,1,0.01,0.9,0.09 +# Home,PASS,CROSS,1,2263,90.52,2289,91.56,Player10,Player9,0.89,0.14,0.92,0.47 +# Home,SHOT,HEAD-ON TARGET-GOAL,1,2289,91.56,2309,92.36,Player9,,0.92,0.47,1.01,0.55 +# Away,SET PIECE,KICK OFF,1,3675,147,3675,147,Player19,,NaN,NaN,NaN,NaN +# Away,PASS,,1,3675,147,3703,148.12,Player19,Player21,0.49,0.5,0.58,0.52""") +# +# serializer = MetricaEventSerializer() +# serializer.deserialize( +# inputs={ +# 'raw_data': raw_data +# } +# ) \ No newline at end of file From 2dcbffa9cb1f5483a4d4ba1cacb9805bf89d9859 Mon Sep 17 00:00:00 2001 From: Koen Vossen Date: Tue, 5 May 2020 20:54:30 +0200 Subject: [PATCH 14/14] Update changelog --- CHANGES.txt | 7 ++++--- README.md | 4 ++-- setup.py | 3 +-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/CHANGES.txt b/CHANGES.txt index 3ef60d19..88eab272 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,3 +1,4 @@ -v0.1, 2020-04-23 -- Initial release. -v0.2, 2020-XX-XX -- Add Metrica Tracking Serializer including automated tests - Cleanup some import statements +v0.1.0, 2020-04-23 -- Initial release. +v0.2.0, 2020-05-05 -- Change interface of TrackingDataSerializer + Add Metrica Tracking Serializer including automated tests + Cleanup some import statements diff --git a/README.md b/README.md index ad62d6ab..8a317e5b 100644 --- a/README.md +++ b/README.md @@ -139,9 +139,9 @@ Data models - [ ] Event Tracking data (de)serializers -- [ ] Automated tests +- [x] Automated tests - [x] TRACAB -- [ ] MetricaSports +- [x] MetricaSports - [ ] BallJames - [ ] FIFA EPTS diff --git a/setup.py b/setup.py index 4ca77002..40a1bc0e 100644 --- a/setup.py +++ b/setup.py @@ -33,8 +33,7 @@ ], extras_require={ 'dev': [ - 'pytest', - 'flake8' + 'pytest' ] } )