diff --git a/CHANGES.txt b/CHANGES.txt index d126a68d..88eab272 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1 +1,4 @@ -v0.1, 2020-04-23 -- Initial release. \ No newline at end of file +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 6a213e49..5b3b5f81 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 } @@ -59,6 +61,29 @@ with open("tracab_data.dat", "rb") as data, \ # 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 @@ -114,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/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/__init__.py b/kloppy/domain/models/__init__.py index c7c7884b..370e0da7 100644 --- a/kloppy/domain/models/__init__.py +++ b/kloppy/domain/models/__init__.py @@ -1,2 +1,5 @@ -from .tracking import * +from .common import * from .pitch import * +from .tracking import * +# NOT YET: from .event import * + diff --git a/kloppy/domain/models/common.py b/kloppy/domain/models/common.py new file mode 100644 index 00000000..3fb7be0b --- /dev/null +++ b/kloppy/domain/models/common.py @@ -0,0 +1,105 @@ +from abc import ABC +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 + + +class DataSetFlag(Flag): + BALL_OWNING_TEAM = 1 + BALL_STATE = 2 + + +@dataclass +class DataRecord(ABC): + timestamp: float + ball_owning_team: Team + ball_state: BallState + + period: Period + + +@dataclass +class DataSet(ABC): + 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..02035e70 --- /dev/null +++ b/kloppy/domain/models/event.py @@ -0,0 +1,287 @@ +# 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 +from csv import reader +from typing import List, Union + +from .pitch import Point +from .common import DataRecord, DataSet, Team + + +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" + + +class OwnGoal(SubType): + OwnGoal = "OWN GOAL" + + + +""" +@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 + +""" + + +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, ABC): + event_id: int + team: Team + end_timestamp: float # allowed to be same as timestamp + + player_jersey_no: str + 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): + records: List[Union[ + SetPieceEvent, ShotEvent + ]] + + @property + def events(self): + return self.records + + +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/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 0d748cbc..59bf5280 100644 --- a/kloppy/domain/models/tracking.py +++ b/kloppy/domain/models/tracking.py @@ -1,133 +1,26 @@ from dataclasses import dataclass -from enum import Enum -from typing import List, Optional, Dict +from typing import List, Dict -from .pitch import ( - PitchDimensions, - Point +from .common import ( + DataSet, + DataRecord ) - - -class Player(object): - 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" - - @staticmethod - def get_orientation_factor(orientation: 'Orientation', - attacking_direction: AttackingDirection, - ball_owning_team: BallOwningTeam): - if orientation == Orientation.FIXED_HOME_AWAY: - return -1 - elif orientation == Orientation.FIXED_AWAY_HOME: - return 1 - elif orientation == Orientation.HOME_TEAM: - if attacking_direction == AttackingDirection.HOME_AWAY: - return -1 - else: - return 1 - elif orientation == Orientation.AWAY_TEAM: - if attacking_direction == AttackingDirection.AWAY_HOME: - return -1 - else: - return 1 - elif orientation == 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(object): - 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(object): +class Frame(DataRecord): frame_id: int - 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 @dataclass -class DataSet(object): - pitch_dimensions: PitchDimensions - orientation: Orientation - +class TrackingDataSet(DataSet): frame_rate: int - periods: List[Period] - frames: List[Frame] + 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 e69de29b..b654fdc6 100644 --- a/kloppy/domain/services/__init__.py +++ b/kloppy/domain/services/__init__.py @@ -0,0 +1,21 @@ +from typing import List + +from kloppy.domain import AttackingDirection, Frame + +from .transformers import Transformer +# NOT YET: 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..8d6e9919 --- /dev/null +++ b/kloppy/domain/services/enrichers/__init__.py @@ -0,0 +1,39 @@ +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 = None + current_ball_state = None + + 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.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 + + 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 e23a8b66..4e21286e 100644 --- a/kloppy/domain/services/transformers/__init__.py +++ b/kloppy/domain/services/transformers/__init__.py @@ -1,17 +1,15 @@ -from ...models import ( +from kloppy.domain import ( Point, PitchDimensions, Orientation, Frame, - DataSet, BallOwningTeam, AttackingDirection) + Team, AttackingDirection, + TrackingDataSet, DataSetFlag, DataSet, # NOT YET: EventDataSet +) -class VoidPointTransformer(object): - def transform_point(self, point: Point) -> Point: - return point - -class Transformer(object): +class Transformer: def __init__(self, from_pitch_dimensions: PitchDimensions, from_orientation: Orientation, to_pitch_dimensions: PitchDimensions, to_orientation: Orientation): @@ -35,17 +33,15 @@ 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: Team, attacking_direction: AttackingDirection) -> bool: 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 ) @@ -53,13 +49,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 +64,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 @@ -93,18 +88,29 @@ 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, 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 DataSet( - 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 b72a088d..186246b7 100644 --- a/kloppy/infra/serializers/__init__.py +++ b/kloppy/infra/serializers/__init__.py @@ -1,2 +1,2 @@ -from .base import TrackingDataSerializer -from .tracab import TRACABSerializer \ No newline at end of file +from .tracking import TrackingDataSerializer, TRACABSerializer, MetricaTrackingSerializer +# NOT YET: from .event import EventDataSerializer, MetricaEventSerializer diff --git a/kloppy/infra/serializers/event/__init__.py b/kloppy/infra/serializers/event/__init__.py new file mode 100644 index 00000000..57bdc2df --- /dev/null +++ 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 new file mode 100644 index 00000000..16d4af69 --- /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 EventDataSerializer(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..e8663be6 --- /dev/null +++ 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 new file mode 100644 index 00000000..252a8b58 --- /dev/null +++ b/kloppy/infra/serializers/event/metrica/serializer.py @@ -0,0 +1,255 @@ +from collections import namedtuple +from typing import Tuple +import csv + +from kloppy.domain import ( + EventDataSet, Team, Point, Period, Orientation, + DataSetFlag, PitchDimensions, Dimension, + AttackingDirection, BallState +) +from kloppy.domain.models.event import ( + EventType, Event, + SetPieceEvent, PassEvent, RecoveryEvent, + BallOutEvent, BallLossEvent, + ShotEvent, FaultReceivedEvent, ChallengeEvent, + CardEvent +) +from kloppy.infra.utils import Readable + +from .. import EventDataSerializer + +from .subtypes import * + +event_type_map: Dict[str, EventType] = { + "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 + + +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) + + periods = [] + period = None + + 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']] + 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") + + # 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 + 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 new file mode 100644 index 00000000..b5dc32c7 --- /dev/null +++ b/kloppy/infra/serializers/event/metrica/subtypes.py @@ -0,0 +1,232 @@ +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, OwnGoal +) + + +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}") + + +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, + 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, + OwnGoal: build_owngoal +} + + +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}" + + try: + subtype = factories[subtype_type](item) + break + except ValueError: + continue + else: + raise ValueError(f"Cannot determine subtype type of {item}") + + result[i] = subtype + + return result diff --git a/kloppy/infra/serializers/tracab.py b/kloppy/infra/serializers/tracab.py deleted file mode 100644 index 092c23c5..00000000 --- a/kloppy/infra/serializers/tracab.py +++ /dev/null @@ -1,153 +0,0 @@ -from typing import Tuple, List, Dict - -from lxml import objectify - -from ...domain.models import ( - DataSet, - AttackingDirection, - Frame, - Point, - BallOwningTeam, - BallState, - Period, - Orientation, - PitchDimensions, - Dimension) -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): - line = str(line) - frame_id, players, ball = line.strip().split(":")[:3] - - home_team_player_positions = {} - away_team_player_positions = {} - - for player in players.split(";")[:-1]: - team_id, target_id, jersey_no, x, y, speed = player.split(",") - team_id = int(team_id) - - if team_id == 1: - home_team_player_positions[jersey_no] = Point(float(x), float(y)) - elif team_id == 0: - away_team_player_positions[jersey_no] = Point(float(x), float(y)) - - ball_x, ball_y, ball_z, ball_speed, ball_owning_team, ball_state = ball.rstrip(";").split(",")[:6] - - return Frame( - frame_id=int(frame_id), - 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), - home_team_player_positions=home_team_player_positions, - away_team_player_positions=away_team_player_positions, - period=period - ) - - def deserialize(self, data: Readable, metadata, options: Dict = None) -> DataSet: - if not options: - options = {} - - sample_rate = float(options.get('sample_rate', 1.0)) - only_alive = bool(options.get('only_alive', True)) - - with performance_logging("Loading metadata"): - match = objectify.fromstring(metadata.read()).match - frame_rate = int(match.attrib['iFrameRateFps']) - pitch_size_width = float(match.attrib['fPitchXSizeMeters']) - pitch_size_height = float(match.attrib['fPitchYSizeMeters']) - - periods = [] - for period in match.iterchildren(tag='period'): - start_frame_id = int(period.attrib['iStartFrame']) - end_frame_id = int(period.attrib['iEndFrame']) - if start_frame_id != 0 or end_frame_id != 0: - periods.append( - Period( - id=int(period.attrib['iId']), - start_frame_id=start_frame_id, - end_frame_id=end_frame_id - ) - ) - - original_orientation = None - with performance_logging("Loading data"): - def _iter(): - n = 0 - sample = 1. / sample_rate - - for line in data.readlines(): - line = line.strip().decode("ascii") - - frame_id = int(line[:10].split(":", 1)[0]) - if only_alive and not line.endswith("Alive;:"): - continue - - for period in periods: - if period.contains(frame_id): - if n % sample == 0: - yield period, line - n += 1 - - 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 - ) - - if not period.attacking_direction_set: - period.set_attacking_direction( - attacking_direction=attacking_direction_from_frame(frame) - ) - - frames.append(frame) - - return DataSet( - frame_rate=frame_rate, - orientation=original_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), - x_per_meter=100, - y_per_meter=100 - ), - periods=periods, - frames=frames - ) - - def serialize(self, data_set: DataSet) -> Tuple[str, str]: - raise NotImplementedError - 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 62% rename from kloppy/infra/serializers/base.py rename to kloppy/infra/serializers/tracking/base.py index 306fd0a4..237382ed 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 kloppy.infra.utils import Readable +from kloppy.domain 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..e162e517 --- /dev/null +++ b/kloppy/infra/serializers/tracking/metrica.py @@ -0,0 +1,208 @@ +from collections import namedtuple +from typing import Tuple, Dict, Iterator + +from kloppy.domain import (attacking_direction_from_frame, + TrackingDataSet, + AttackingDirection, + Frame, + Point, + Period, + Orientation, + PitchDimensions, + Dimension, + DataSetFlag) +from kloppy.infra.utils import Readable, performance_logging + +from . import TrackingDataSerializer + + +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: + 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 __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) + """ + + 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_timestamp=frame_id / frame_rate, + end_timestamp=frame_id / frame_rate + ) + else: + # consider not update this every frame for performance reasons + period.end_timestamp = frame_id / frame_rate + + 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) -> TrackingDataSet: + """ + Deserialize Metrica tracking data into a `TrackingDataSet`. + + 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 : TrackingDataSet + 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 = {} + + 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, 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 = [] + + 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: + self.__validate_partials(home_partial_frame, away_partial_frame) + + period: Period = home_partial_frame.period + frame_id: int = home_partial_frame.frame_id + + frame = Frame( + frame_id=frame_id, + 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, + 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 TrackingDataSet( + 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, + records=frames + ) + + 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 new file mode 100644 index 00000000..501d51ea --- /dev/null +++ b/kloppy/infra/serializers/tracking/tracab.py @@ -0,0 +1,202 @@ +from typing import Tuple, Dict + +from lxml import objectify + +from kloppy.domain import ( + TrackingDataSet, DataSetFlag, + AttackingDirection, + Frame, + Point, + Team, + BallState, + Period, + Orientation, + PitchDimensions, + Dimension, + attacking_direction_from_frame, +) +from kloppy.infra.utils import Readable, performance_logging + +from . import TrackingDataSerializer + + +class TRACABSerializer(TrackingDataSerializer): + @classmethod + def _frame_from_line(cls, period, line, frame_rate): + line = str(line) + frame_id, players, ball = line.strip().split(":")[:3] + + home_team_player_positions = {} + away_team_player_positions = {} + + for player in players.split(";")[:-1]: + team_id, target_id, jersey_no, x, y, speed = player.split(",") + team_id = int(team_id) + + if team_id == 1: + home_team_player_positions[jersey_no] = Point(float(x), float(y)) + elif team_id == 0: + away_team_player_positions[jersey_no] = Point(float(x), float(y)) + + ball_x, ball_y, ball_z, ball_speed, ball_owning_team, ball_state = ball.rstrip(";").split(",")[:6] + + 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 / frame_rate - period.start_timestamp, + ball_position=Point(float(ball_x), float(ball_y)), + 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 + ) + + @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) -> TrackingDataSet: + """ + Deserialize TRACAB tracking data into a `TrackingDataSet`. + + 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 : TrackingDataSet + 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: + options = {} + + sample_rate = float(options.get('sample_rate', 1.0)) + only_alive = bool(options.get('only_alive', True)) + + with performance_logging("Loading metadata"): + 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']) + + periods = [] + for period in match.iterchildren(tag='period'): + start_frame_id = int(period.attrib['iStartFrame']) + end_frame_id = int(period.attrib['iEndFrame']) + if start_frame_id != 0 or end_frame_id != 0: + periods.append( + Period( + id=int(period.attrib['iId']), + start_timestamp=start_frame_id / frame_rate, + end_timestamp=end_frame_id / frame_rate + ) + ) + + with performance_logging("Loading data"): + def _iter(): + n = 0 + sample = 1. / sample_rate + + 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;:"): + continue + + for period in periods: + if period.contains(frame_id / frame_rate): + if n % sample == 0: + yield period, line + n += 1 + + frames = [] + for period, line in _iter(): + frame = self._frame_from_line( + period, + line, + frame_rate + ) + + frames.append(frame) + + 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 TrackingDataSet( + flags=DataSetFlag.BALL_OWNING_TEAM | DataSetFlag.BALL_STATE, + frame_rate=frame_rate, + 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), + x_per_meter=100, + y_per_meter=100 + ), + periods=periods, + records=frames + ) + + def serialize(self, data_set: TrackingDataSet) -> Tuple[str, str]: + raise NotImplementedError + 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_enricher.py b/kloppy/tests/test_enricher.py new file mode 100644 index 00000000..36d797ab --- /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 new file mode 100644 index 00000000..181fcfd6 --- /dev/null +++ b/kloppy/tests/test_metrica.py @@ -0,0 +1,102 @@ +from io import BytesIO + +from kloppy import MetricaTrackingSerializer # NOT YET: , MetricaEventSerializer +from kloppy.domain import Period, AttackingDirection, Orientation, Point + + +class TestMetricaTracking: + 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, +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.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=0.04, end_timestamp=0.12, + attacking_direction=AttackingDirection.HOME_AWAY) + 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.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.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 diff --git a/kloppy/tests/test_tracab.py b/kloppy/tests/test_tracab.py new file mode 100644 index 00000000..6643d738 --- /dev/null +++ b/kloppy/tests/test_tracab.py @@ -0,0 +1,70 @@ +from io import BytesIO + +from kloppy import TRACABSerializer +from kloppy.domain import Period, AttackingDirection, Orientation, Point, BallState, Team + + +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,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;: + """) + serializer = TRACABSerializer() + + data_set = serializer.deserialize( + inputs={ + 'meta_data': meta_data, + 'raw_data': raw_data + }, + options={ + "only_alive": False + } + ) + + 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) + + assert data_set.periods[1] == Period(id=2, start_timestamp=8.0, end_timestamp=8.08, + 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) + 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 + assert '1337' in data_set.records[3].away_team_player_positions diff --git a/setup.py b/setup.py index 36a05843..40a1bc0e 100644 --- a/setup.py +++ b/setup.py @@ -19,12 +19,21 @@ setup( name='kloppy', - version='0.1', + version='0.2.0', 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), -) \ No newline at end of file + python_requires='>=3.7', + install_requires=[ + 'lxml>=4.5.0' + ], + extras_require={ + 'dev': [ + 'pytest' + ] + } +)