diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b9e8bd0a..d01f1bea 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/ambv/black - rev: stable + rev: 20.8b1 hooks: - id: black language_version: python3 \ No newline at end of file diff --git a/kloppy/domain/models/event.py b/kloppy/domain/models/event.py index 1c4285af..8f35391a 100644 --- a/kloppy/domain/models/event.py +++ b/kloppy/domain/models/event.py @@ -5,6 +5,7 @@ from typing import List, Union, Dict from kloppy.domain.models.common import DatasetType +from kloppy.utils import camelcase_to_snakecase, removes_suffix from .common import DataRecord, Dataset, Team, Player from .pitch import Point @@ -76,6 +77,51 @@ class EventType(Enum): CARD = "CARD" PLAYER_ON = "PLAYER_ON" PLAYER_OFF = "PLAYER_OFF" + RECOVERY = "RECOVERY" + BALL_OUT = "BALL_OUT" + FOUL_COMMITTED = "FOUL_COMMITTED" + + +@dataclass +class Qualifier(ABC): + @abstractmethod + def to_dict(self): + pass + + @property + def name(self): + return camelcase_to_snakecase( + removes_suffix(type(self).__name__, "Qualifier") + ) + + +@dataclass +class BoolQualifier(Qualifier, ABC): + value: bool + + def to_dict(self): + return {f"is_{self.name}": self.value} + + +class EnumQualifier(Qualifier, ABC): + value: Enum + + def to_dict(self): + return {f"{self.name}_type": self.value.value} + + +class SetPieceType(Enum): + GOAL_KICK = "GOAL_KICK" + FREE_KICK = "FREE_KICK" + THROW_IN = "THROW_IN" + CORNER_KICK = "CORNER_KICK" + PENALTY = "PENALTY" + KICK_OFF = "KICK_OFF" + + +@dataclass +class SetPieceQualifier(EnumQualifier): + value: SetPieceType @dataclass @@ -90,6 +136,8 @@ class Event(DataRecord, ABC): raw_event: Dict state: Dict[str, any] + qualifiers: List[Qualifier] + @property @abstractmethod def event_type(self) -> EventType: @@ -178,6 +226,24 @@ class CardEvent(Event): event_name: str = "card" +@dataclass +class RecoveryEvent(Event): + event_type: EventType = EventType.RECOVERY + event_name: str = "recovery" + + +@dataclass +class BallOutEvent(Event): + event_type: EventType = EventType.BALL_OUT + event_name: str = "ball_out" + + +@dataclass +class FoulCommittedEvent(Event): + event_type: EventType = EventType.FOUL_COMMITTED + event_name: str = "foul_committed" + + @dataclass class EventDataset(Dataset): records: List[ @@ -225,4 +291,10 @@ def add_state(self, *args, **kwargs): "CardEvent", "CardType", "EventDataset", + "RecoveryEvent", + "FoulCommittedEvent", + "BallOutEvent", + "SetPieceType", + "Qualifier", + "SetPieceQualifier", ] diff --git a/kloppy/helpers.py b/kloppy/helpers.py index 725313eb..e31958b4 100644 --- a/kloppy/helpers.py +++ b/kloppy/helpers.py @@ -24,6 +24,7 @@ EventType, Player, DataRecord, + SetPieceType, ) @@ -202,6 +203,11 @@ def _event_to_pandas_row_converter(event: Event) -> Dict: "end_coordinates_y": event.end_coordinates.y, } ) + + if event.qualifiers: + for qualifier in event.qualifiers: + row.update(qualifier.to_dict()) + return row diff --git a/kloppy/infra/serializers/event/metrica/json_serializer.py b/kloppy/infra/serializers/event/metrica/json_serializer.py index a79f3ff3..e6f7db55 100644 --- a/kloppy/infra/serializers/event/metrica/json_serializer.py +++ b/kloppy/infra/serializers/event/metrica/json_serializer.py @@ -12,12 +12,19 @@ ShotEvent, TakeOnEvent, CarryEvent, + RecoveryEvent, + FoulCommittedEvent, + BallOutEvent, GenericEvent, PassResult, ShotResult, TakeOnResult, CarryResult, EventType, + SetPieceType, + SetPieceQualifier, + Qualifier, + Event, ) from kloppy.infra.serializers.event import EventDataSerializer @@ -42,6 +49,16 @@ MS_PASS_OUTCOME_OFFSIDE, ] +# Set Pieces +MS_SET_PIECE = 5 +MS_SET_PIECE_GOAL_KICK = 20 +MS_SET_PIECE_FREE_KICK = 32 +MS_SET_PIECE_THROW_IN = 34 +MS_SET_PIECE_CORNER_KICK = 33 +MS_SET_PIECE_PENALTY = 36 +MS_SET_PIECE_KICK_OFF = 35 + + # Shots MS_EVENT_TYPE_SHOT = 2 MS_SHOT_OUTCOME_BLOCKED = 25 @@ -63,8 +80,12 @@ MS_EVENT_TYPE_DRIBBLE = 45 MS_EVENT_TYPE_CARRY = 10 MS_EVENT_TYPE_CHALLENGE = 9 +MS_EVENT_TYPE_RECOVERY = 3 +MS_EVENT_TYPE_FOUL_COMMITTED = 4 MS_EVENT_TYPE_CARD = 8 +OUT_EVENT_RESULTS = [PassResult.OUT] + def _parse_coordinates(event_start_or_end: dict) -> Point: x = event_start_or_end["x"] @@ -88,39 +109,70 @@ def _parse_subtypes(event: dict) -> List: return None -def _parse_pass(event: Dict, subtypes: List, team: Team) -> Dict: +def _parse_pass( + event: Dict, previous_event: Dict, subtypes: List, team: Team +) -> Dict: - pass_type_id = event["type"]["id"] + event_type_id = event["type"]["id"] - if pass_type_id == MS_PASS_OUTCOME_COMPLETE: + if event_type_id == MS_PASS_OUTCOME_COMPLETE: result = PassResult.COMPLETE receiver_player = team.get_player_by_id(event["to"]["id"]) receiver_coordinates = _parse_coordinates(event["end"]) receive_timestamp = event["end"]["time"] else: - if pass_type_id == MS_PASS_OUTCOME_OUT: + if event_type_id == MS_PASS_OUTCOME_OUT: result = PassResult.OUT - elif pass_type_id == MS_PASS_OUTCOME_INCOMPLETE: + elif event_type_id == MS_PASS_OUTCOME_INCOMPLETE: if subtypes and MS_PASS_OUTCOME_OFFSIDE in subtypes: result = PassResult.OFFSIDE else: result = PassResult.INCOMPLETE else: - raise Exception(f"Unknown pass outcome: {pass_type_id}") + raise Exception(f"Unknown pass outcome: {event_type_id}") receiver_player = None receiver_coordinates = None receive_timestamp = None + qualifiers = _get_event_qualifiers(event, previous_event, subtypes) + return dict( result=result, receiver_coordinates=receiver_coordinates, receiver_player=receiver_player, receive_timestamp=receive_timestamp, + qualifiers=qualifiers, ) -def _parse_shot(event: Dict, subtypes: List) -> Dict: +def _get_event_qualifiers( + event: Dict, previous_event: Dict, subtypes: List +) -> List[Qualifier]: + + previous_event_type_id = previous_event["type"]["id"] + qualifiers = [] + if previous_event_type_id == MS_SET_PIECE: + set_piece_subtypes = _parse_subtypes(previous_event) + if MS_SET_PIECE_CORNER_KICK in set_piece_subtypes: + qualifiers.append( + SetPieceQualifier(value=SetPieceType.CORNER_KICK) + ) + elif MS_SET_PIECE_FREE_KICK in set_piece_subtypes: + qualifiers.append(SetPieceQualifier(value=SetPieceType.FREE_KICK)) + elif MS_SET_PIECE_PENALTY in set_piece_subtypes: + qualifiers.append(SetPieceQualifier(value=SetPieceType.PENALTY)) + elif MS_SET_PIECE_THROW_IN in set_piece_subtypes: + qualifiers.append(SetPieceQualifier(value=SetPieceType.THROW_IN)) + elif MS_SET_PIECE_KICK_OFF in set_piece_subtypes: + qualifiers.append(SetPieceQualifier(value=SetPieceType.KICK_OFF)) + elif subtypes and MS_SET_PIECE_GOAL_KICK in subtypes: + qualifiers.append(SetPieceQualifier(value=SetPieceType.GOAL_KICK)) + + return qualifiers + + +def _parse_shot(event: Dict, previous_event: Dict, subtypes: List) -> Dict: if MS_SHOT_OUTCOME_OFF_TARGET in subtypes: result = ShotResult.OFF_TARGET elif MS_SHOT_OUTCOME_SAVED in subtypes: @@ -134,7 +186,9 @@ def _parse_shot(event: Dict, subtypes: List) -> Dict: else: raise Exception(f"Unknown shot outcome") - return dict(result=result) + qualifiers = _get_event_qualifiers(event, previous_event, subtypes) + + return dict(result=result, qualifiers=qualifiers) def _parse_carry(event: Dict) -> Dict: @@ -164,6 +218,10 @@ def _parse_ball_owning_team(event_type: int, team: Team) -> Team: return None +def _include_event(event: Event, wanted_event_types: List) -> bool: + return not wanted_event_types or event.event_type in wanted_event_types + + class MetricaEventsJsonSerializer(EventDataSerializer): @staticmethod def __validate_inputs(inputs: Dict[str, Readable]): @@ -236,7 +294,8 @@ def deserialize( ] events = [] - for raw_event in raw_events["data"]: + for i, raw_event in enumerate(raw_events["data"]): + if raw_event["team"]["id"] == metadata.teams[0].team_id: team = metadata.teams[0] elif raw_event["team"]["id"] == metadata.teams[1].team_id: @@ -254,6 +313,7 @@ def deserialize( for period in metadata.periods if period.id == raw_event["period"] ][0] + previous_event = raw_events["data"][i - 1] generic_event_kwargs = dict( # from DataRecord @@ -269,9 +329,12 @@ def deserialize( raw_event=raw_event, ) + iteration_events = [] + if event_type in MS_PASS_TYPES: pass_event_kwargs = _parse_pass( event=raw_event, + previous_event=previous_event, subtypes=subtypes, team=team, ) @@ -283,38 +346,78 @@ def deserialize( elif event_type == MS_EVENT_TYPE_SHOT: shot_event_kwargs = _parse_shot( - event=raw_event, subtypes=subtypes + event=raw_event, + previous_event=previous_event, + subtypes=subtypes, ) event = ShotEvent.create( - **shot_event_kwargs, **generic_event_kwargs + **shot_event_kwargs, + **generic_event_kwargs, ) elif subtypes and MS_EVENT_TYPE_DRIBBLE in subtypes: take_on_event_kwargs = _parse_take_on(subtypes=subtypes) event = TakeOnEvent.create( - **take_on_event_kwargs, **generic_event_kwargs + qualifiers=None, + **take_on_event_kwargs, + **generic_event_kwargs, ) + elif event_type == MS_EVENT_TYPE_CARRY: carry_event_kwargs = _parse_carry( event=raw_event, ) event = CarryEvent.create( + qualifiers=None, **carry_event_kwargs, **generic_event_kwargs, ) + + elif event_type == MS_EVENT_TYPE_RECOVERY: + event = RecoveryEvent.create( + result=None, + qualifiers=None, + **generic_event_kwargs, + ) + + elif event_type == MS_EVENT_TYPE_FOUL_COMMITTED: + event = FoulCommittedEvent.create( + result=None, + qualifiers=None, + **generic_event_kwargs, + ) + else: event = GenericEvent.create( result=None, + qualifiers=None, event_name=raw_event["type"]["name"], **generic_event_kwargs, ) - if ( - not wanted_event_types - or event.event_type in wanted_event_types - ): + if _include_event(event, wanted_event_types): events.append(event) + # Checks if the event ended out of the field and adds a synthetic out event + if event.result in OUT_EVENT_RESULTS: + generic_event_kwargs["ball_state"] = BallState.DEAD + if raw_event["end"]["x"]: + generic_event_kwargs[ + "coordinates" + ] = _parse_coordinates(raw_event["end"]) + generic_event_kwargs["timestamp"] = raw_event["end"][ + "time" + ] + + event = BallOutEvent.create( + result=None, + qualifiers=None, + **generic_event_kwargs, + ) + + if _include_event(event, wanted_event_types): + events.append(event) + return EventDataset( metadata=metadata, records=events, diff --git a/kloppy/infra/serializers/event/opta/serializer.py b/kloppy/infra/serializers/event/opta/serializer.py index 7493a334..27972ffa 100644 --- a/kloppy/infra/serializers/event/opta/serializer.py +++ b/kloppy/infra/serializers/event/opta/serializer.py @@ -30,12 +30,133 @@ Metadata, Player, Position, + RecoveryEvent, + BallOutEvent, + FoulCommittedEvent, + Qualifier, + SetPieceQualifier, + SetPieceType, ) from kloppy.infra.serializers.event import EventDataSerializer from kloppy.utils import Readable, performance_logging logger = logging.getLogger(__name__) +EVENT_TYPE_START_PERIOD = 32 +EVENT_TYPE_END_PERIOD = 30 + +EVENT_TYPE_PASS = 1 +EVENT_TYPE_OFFSIDE_PASS = 2 +EVENT_TYPE_TAKE_ON = 3 +EVENT_TYPE_SHOT_MISS = 13 +EVENT_TYPE_SHOT_POST = 14 +EVENT_TYPE_SHOT_SAVED = 15 +EVENT_TYPE_SHOT_GOAL = 16 +EVENT_TYPE_BALL_OUT = 5 +EVENT_TYPE_CORNER_AWARDED = 6 +EVENT_TYPE_FOUL_COMMITTED = 4 +EVENT_TYPE_RECOVERY = 49 + +BALL_OUT_EVENTS = [EVENT_TYPE_BALL_OUT, EVENT_TYPE_CORNER_AWARDED] + +BALL_OWNING_EVENTS = ( + EVENT_TYPE_PASS, + EVENT_TYPE_OFFSIDE_PASS, + EVENT_TYPE_TAKE_ON, + EVENT_TYPE_SHOT_MISS, + EVENT_TYPE_SHOT_POST, + EVENT_TYPE_SHOT_SAVED, + EVENT_TYPE_SHOT_GOAL, + EVENT_TYPE_RECOVERY, +) + +EVENT_QUALIFIER_GOAL_KICK = 124 +EVENT_QUALIFIER_FREE_KICK = 5 +EVENT_QUALIFIER_THROW_IN = 107 +EVENT_QUALIFIER_CORNER_KICK = 6 +EVENT_QUALIFIER_PENALTY = 9 +EVENT_QUALIFIER_KICK_OFF = 279 + +event_type_names = { + 1: "pass", + 2: "offside pass", + 3: "take on", + 4: "foul", + 5: "out", + 6: "corner awarded", + 7: "tackle", + 8: "interception", + 9: "turnover", + 10: "save", + 11: "claim", + 12: "clearance", + 13: "miss", + 14: "post", + 15: "attempt saved", + 16: "goal", + 17: "card", + 18: "player off", + 19: "player on", + 20: "player retired", + 21: "player returns", + 22: "player becomes goalkeeper", + 23: "goalkeeper becomes player", + 24: "condition change", + 25: "official change", + 26: "unknown26", + 27: "start delay", + 28: "end delay", + 29: "unknown29", + 30: "end", + 31: "unknown31", + 32: "start", + 33: "unknown33", + 34: "team set up", + 35: "player changed position", + 36: "player changed jersey number", + 37: "collection end", + 38: "temp_goal", + 39: "temp_attempt", + 40: "formation change", + 41: "punch", + 42: "good skill", + 43: "deleted event", + 44: "aerial", + 45: "challenge", + 46: "unknown46", + 47: "rescinded card", + 48: "unknown46", + 49: "ball recovery", + 50: "dispossessed", + 51: "error", + 52: "keeper pick-up", + 53: "cross not claimed", + 54: "smother", + 55: "offside provoked", + 56: "shield ball opp", + 57: "foul throw in", + 58: "penalty faced", + 59: "keeper sweeper", + 60: "chance missed", + 61: "ball touch", + 62: "unknown62", + 63: "temp_save", + 64: "resume", + 65: "contentious referee decision", + 66: "possession data", + 67: "50/50", + 68: "referee drop ball", + 69: "failed to block", + 70: "injury time announcement", + 71: "coach setup", + 72: "caught offside", + 73: "other ball contact", + 74: "blocked pass", + 75: "delayed start", + 76: "early end", + 77: "player off pitch", +} + def _parse_f24_datetime(dt_str: str) -> float: return ( @@ -45,30 +166,35 @@ def _parse_f24_datetime(dt_str: str) -> float: ) -def _parse_pass(qualifiers: Dict[int, str], outcome: int) -> Dict: +def _parse_pass(raw_qualifiers: Dict[int, str], outcome: int) -> Dict: if outcome: receiver_coordinates = Point( - x=float(qualifiers[140]), y=float(qualifiers[141]) + x=float(raw_qualifiers[140]), y=float(raw_qualifiers[141]) ) result = PassResult.COMPLETE else: result = PassResult.INCOMPLETE receiver_coordinates = None + qualifiers = _get_event_qualifiers(raw_qualifiers) + return dict( result=result, receiver_coordinates=receiver_coordinates, receiver_player=None, receive_timestamp=None, + qualifiers=qualifiers, ) -def _parse_offside_pass() -> Dict: +def _parse_offside_pass(raw_qualifiers: List) -> Dict: + qualifiers = _get_event_qualifiers(raw_qualifiers) return dict( result=PassResult.OFFSIDE, receiver_coordinates=None, receiver_player=None, receive_timestamp=None, + qualifiers=qualifiers, ) @@ -81,16 +207,18 @@ def _parse_take_on(outcome: int) -> Dict: def _parse_shot( - qualifiers: Dict[int, str], type_id: int, coordinates: Point + raw_qualifiers: Dict[int, str], type_id: int, coordinates: Point ) -> Dict: if type_id == EVENT_TYPE_SHOT_GOAL: - if 28 in qualifiers: + if 28 in raw_qualifiers: coordinates = Point(x=100 - coordinates.x, y=100 - coordinates.y) result = ShotResult.GOAL else: result = None - return dict(coordinates=coordinates, result=result) + qualifiers = _get_event_qualifiers(raw_qualifiers) + + return dict(coordinates=coordinates, result=result, qualifiers=qualifiers) def _parse_team_players( @@ -155,98 +283,22 @@ def _team_from_xml_elm(team_elm, f7_root) -> Team: return team -EVENT_TYPE_START_PERIOD = 32 -EVENT_TYPE_END_PERIOD = 30 +def _get_event_qualifiers(raw_qualifiers: List) -> List[Qualifier]: + qualifiers = [] + if EVENT_QUALIFIER_CORNER_KICK in raw_qualifiers: + qualifiers.append(SetPieceQualifier(value=SetPieceType.CORNER_KICK)) + elif EVENT_QUALIFIER_FREE_KICK in raw_qualifiers: + qualifiers.append(SetPieceQualifier(value=SetPieceType.FREE_KICK)) + elif EVENT_QUALIFIER_PENALTY in raw_qualifiers: + qualifiers.append(SetPieceQualifier(value=SetPieceType.PENALTY)) + elif EVENT_QUALIFIER_THROW_IN in raw_qualifiers: + qualifiers.append(SetPieceQualifier(value=SetPieceType.THROW_IN)) + elif EVENT_QUALIFIER_KICK_OFF in raw_qualifiers: + qualifiers.append(SetPieceQualifier(value=SetPieceType.KICK_OFF)) + elif EVENT_QUALIFIER_GOAL_KICK in raw_qualifiers: + qualifiers.append(SetPieceQualifier(value=SetPieceType.GOAL_KICK)) -EVENT_TYPE_PASS = 1 -EVENT_TYPE_OFFSIDE_PASS = 1 -EVENT_TYPE_TAKE_ON = 3 -EVENT_TYPE_SHOT_MISS = 13 -EVENT_TYPE_SHOT_POST = 14 -EVENT_TYPE_SHOT_SAVED = 15 -EVENT_TYPE_SHOT_GOAL = 16 - -event_type_names = { - 1: "pass", - 2: "offside pass", - 3: "take on", - 4: "foul", - 5: "out", - 6: "corner awarded", - 7: "tackle", - 8: "interception", - 9: "turnover", - 10: "save", - 11: "claim", - 12: "clearance", - 13: "miss", - 14: "post", - 15: "attempt saved", - 16: "goal", - 17: "card", - 18: "player off", - 19: "player on", - 20: "player retired", - 21: "player returns", - 22: "player becomes goalkeeper", - 23: "goalkeeper becomes player", - 24: "condition change", - 25: "official change", - 26: "unknown26", - 27: "start delay", - 28: "end delay", - 29: "unknown29", - 30: "end", - 31: "unknown31", - 32: "start", - 33: "unknown33", - 34: "team set up", - 35: "player changed position", - 36: "player changed jersey number", - 37: "collection end", - 38: "temp_goal", - 39: "temp_attempt", - 40: "formation change", - 41: "punch", - 42: "good skill", - 43: "deleted event", - 44: "aerial", - 45: "challenge", - 46: "unknown46", - 47: "rescinded card", - 48: "unknown46", - 49: "ball recovery", - 50: "dispossessed", - 51: "error", - 52: "keeper pick-up", - 53: "cross not claimed", - 54: "smother", - 55: "offside provoked", - 56: "shield ball opp", - 57: "foul throw in", - 58: "penalty faced", - 59: "keeper sweeper", - 60: "chance missed", - 61: "ball touch", - 62: "unknown62", - 63: "temp_save", - 64: "resume", - 65: "contentious referee decision", - 66: "possession data", - 67: "50/50", - 68: "referee drop ball", - 69: "failed to block", - 70: "injury time announcement", - 71: "coach setup", - 72: "caught offside", - 73: "other ball contact", - 74: "blocked pass", - 75: "delayed start", - 76: "early end", - 77: "player off pitch", -} - -BALL_OWNING_EVENTS = (1, 2, 3, 13, 14, 15, 16, 49) + return qualifiers def _get_event_type_name(type_id: int) -> str: @@ -402,7 +454,7 @@ def deserialize( x = float(event_elm.attrib["x"]) y = float(event_elm.attrib["y"]) outcome = int(event_elm.attrib["outcome"]) - qualifiers = { + raw_qualifiers = { int( qualifier_elm.attrib["qualifier_id"] ): qualifier_elm.attrib.get("value") @@ -432,13 +484,15 @@ def deserialize( ) if type_id == EVENT_TYPE_PASS: - pass_event_kwargs = _parse_pass(qualifiers, outcome) + pass_event_kwargs = _parse_pass( + raw_qualifiers, outcome + ) event = PassEvent.create( **pass_event_kwargs, **generic_event_kwargs, ) elif type_id == EVENT_TYPE_OFFSIDE_PASS: - pass_event_kwargs = _parse_offside_pass() + pass_event_kwargs = _parse_offside_pass(raw_qualifiers) event = PassEvent.create( **pass_event_kwargs, **generic_event_kwargs, @@ -446,6 +500,7 @@ def deserialize( elif type_id == EVENT_TYPE_TAKE_ON: take_on_event_kwargs = _parse_take_on(outcome) event = TakeOnEvent.create( + qualifiers=None, **take_on_event_kwargs, **generic_event_kwargs, ) @@ -456,7 +511,7 @@ def deserialize( EVENT_TYPE_SHOT_GOAL, ): shot_event_kwargs = _parse_shot( - qualifiers, + raw_qualifiers, type_id, coordinates=generic_event_kwargs["coordinates"], ) @@ -464,10 +519,34 @@ def deserialize( kwargs.update(generic_event_kwargs) kwargs.update(shot_event_kwargs) event = ShotEvent.create(**kwargs) + + elif type_id == EVENT_TYPE_RECOVERY: + event = RecoveryEvent.create( + result=None, + qualifiers=None, + **generic_event_kwargs, + ) + + elif type_id == EVENT_TYPE_FOUL_COMMITTED: + event = FoulCommittedEvent.create( + result=None, + qualifiers=None, + **generic_event_kwargs, + ) + + elif type_id in BALL_OUT_EVENTS: + generic_event_kwargs["ball_state"] = BallState.DEAD + event = BallOutEvent.create( + result=None, + qualifiers=None, + **generic_event_kwargs, + ) + else: event = GenericEvent.create( **generic_event_kwargs, result=None, + qualifiers=None, event_name=_get_event_type_name(type_id), ) diff --git a/kloppy/infra/serializers/event/statsbomb/serializer.py b/kloppy/infra/serializers/event/statsbomb/serializer.py index eda4ece0..ae39017e 100644 --- a/kloppy/infra/serializers/event/statsbomb/serializer.py +++ b/kloppy/infra/serializers/event/statsbomb/serializer.py @@ -31,13 +31,20 @@ PlayerOnEvent, PlayerOffEvent, CardType, + Qualifier, + SetPieceQualifier, + SetPieceType, + RecoveryEvent, + FoulCommittedEvent, + BallOutEvent, + Event, ) from kloppy.infra.serializers.event import EventDataSerializer from kloppy.utils import Readable, performance_logging logger = logging.getLogger(__name__) - +SB_EVENT_TYPE_RECOVERY = 2 SB_EVENT_TYPE_DRIBBLE = 14 SB_EVENT_TYPE_SHOT = 16 SB_EVENT_TYPE_PASS = 30 @@ -67,6 +74,15 @@ SB_SHOT_OUTCOME_SAVED = 100 SB_SHOT_OUTCOME_OFF_WAYWARD = 101 +SB_EVENT_TYPE_FREE_KICK = 62 +SB_EVENT_TYPE_THROW_IN = 67 +SB_EVENT_TYPE_KICK_OFF = 65 +SB_EVENT_TYPE_CORNER_KICK = 61 +SB_EVENT_TYPE_PENALTY = 88 +SB_EVENT_TYPE_GOAL_KICK = 63 + +OUT_EVENT_RESULTS = [PassResult.OUT, TakeOnResult.OUT] + def parse_str_ts(timestamp: str) -> float: h, m, s = timestamp.split(":") @@ -108,21 +124,45 @@ def _parse_pass(pass_dict: Dict, team: Team, fidelity_version: int) -> Dict: raise Exception(f"Unknown pass outcome: {outcome_id}") receiver_player = None - receiver_coordinates = None else: result = PassResult.COMPLETE receiver_player = team.get_player_by_id(pass_dict["recipient"]["id"]) - receiver_coordinates = _parse_coordinates( - pass_dict["end_location"], fidelity_version - ) + + receiver_coordinates = _parse_coordinates( + pass_dict["end_location"], fidelity_version + ) + + qualifiers = _get_event_qualifiers(pass_dict) return dict( result=result, receiver_coordinates=receiver_coordinates, receiver_player=receiver_player, + qualifiers=qualifiers, ) +def _get_event_qualifiers(qualifiers_dict: Dict) -> List[Qualifier]: + qualifiers = [] + if "type" in qualifiers_dict: + if qualifiers_dict["type"]["id"] == SB_EVENT_TYPE_CORNER_KICK: + qualifiers.append( + SetPieceQualifier(value=SetPieceType.CORNER_KICK) + ) + elif qualifiers_dict["type"]["id"] == SB_EVENT_TYPE_FREE_KICK: + qualifiers.append(SetPieceQualifier(value=SetPieceType.FREE_KICK)) + elif qualifiers_dict["type"]["id"] == SB_EVENT_TYPE_PENALTY: + qualifiers.append(SetPieceQualifier(value=SetPieceType.PENALTY)) + elif qualifiers_dict["type"]["id"] == SB_EVENT_TYPE_THROW_IN: + qualifiers.append(SetPieceQualifier(value=SetPieceType.THROW_IN)) + elif qualifiers_dict["type"]["id"] == SB_EVENT_TYPE_KICK_OFF: + qualifiers.append(SetPieceQualifier(value=SetPieceType.KICK_OFF)) + elif qualifiers_dict["type"]["id"] == SB_EVENT_TYPE_GOAL_KICK: + qualifiers.append(SetPieceQualifier(value=SetPieceType.GOAL_KICK)) + + return qualifiers + + def _parse_shot(shot_dict: Dict) -> Dict: outcome_id = shot_dict["outcome"]["id"] if outcome_id == SB_SHOT_OUTCOME_OFF_TARGET: @@ -140,7 +180,9 @@ def _parse_shot(shot_dict: Dict) -> Dict: else: raise Exception(f"Unknown shot outcome: {outcome_id}") - return dict(result=result) + qualifiers = _get_event_qualifiers(shot_dict) + + return dict(result=result, qualifiers=qualifiers) def _parse_carry(carry_dict: Dict, fidelity_version: int) -> Dict: @@ -225,6 +267,10 @@ def _determine_xy_fidelity_versions(events: List[Dict]) -> Tuple[int, int]: return shot_fidelity_version, xy_fidelity_version +def _include_event(event: Event, wanted_event_types: List) -> bool: + return not wanted_event_types or event.event_type in wanted_event_types + + class StatsBombSerializer(EventDataSerializer): @staticmethod def __validate_inputs(inputs: Dict[str, Readable]): @@ -450,7 +496,9 @@ def deserialize( take_on_dict=raw_event["dribble"] ) event = TakeOnEvent.create( - **take_on_event_kwargs, **generic_event_kwargs + qualifiers=None, + **take_on_event_kwargs, + **generic_event_kwargs, ) elif event_type == SB_EVENT_TYPE_CARRY: carry_event_kwargs = _parse_carry( @@ -458,6 +506,7 @@ def deserialize( fidelity_version=fidelity_version, ) event = CarryEvent.create( + qualifiers=None, # TODO: Consider moving this to _parse_carry end_timestamp=timestamp + raw_event["duration"], **carry_event_kwargs, @@ -471,6 +520,7 @@ def deserialize( ) event = SubstitutionEvent.create( result=None, + qualifiers=None, **substitution_event_kwargs, **generic_event_kwargs, ) @@ -481,6 +531,7 @@ def deserialize( if card_kwargs["card_type"]: event = CardEvent.create( result=None, + qualifiers=None, card_type=card_kwargs["card_type"], **generic_event_kwargs, ) @@ -493,32 +544,62 @@ def deserialize( if card_kwargs["card_type"]: event = CardEvent.create( result=None, + qualifiers=None, card_type=card_kwargs["card_type"], **generic_event_kwargs, ) elif event_type == SB_EVENT_TYPE_PLAYER_ON: event = PlayerOnEvent.create( - result=None, **generic_event_kwargs + result=None, qualifiers=None, **generic_event_kwargs ) elif event_type == SB_EVENT_TYPE_PLAYER_OFF: event = PlayerOffEvent.create( - result=None, **generic_event_kwargs + result=None, qualifiers=None, **generic_event_kwargs + ) + + elif event_type == SB_EVENT_TYPE_RECOVERY: + event = RecoveryEvent.create( + result=None, + qualifiers=None, + **generic_event_kwargs, + ) + + elif event_type == SB_EVENT_TYPE_FOUL_COMMITTED: + event = FoulCommittedEvent.create( + result=None, + qualifiers=None, + **generic_event_kwargs, ) # rest: generic else: event = GenericEvent.create( result=None, + qualifiers=None, event_name=raw_event["type"]["name"], **generic_event_kwargs, ) - if ( - not wanted_event_types - or event.event_type in wanted_event_types - ): + if _include_event(event, wanted_event_types): events.append(event) + # Checks if the event ended out of the field and adds a synthetic out event + if event.result in OUT_EVENT_RESULTS: + generic_event_kwargs["ball_state"] = BallState.DEAD + if event.receiver_coordinates: + generic_event_kwargs[ + "coordinates" + ] = event.receiver_coordinates + + event = BallOutEvent.create( + result=None, + qualifiers=None, + **generic_event_kwargs, + ) + + if _include_event(event, wanted_event_types): + events.append(event) + metadata = Metadata( teams=teams, periods=periods, diff --git a/kloppy/tests/test_helpers.py b/kloppy/tests/test_helpers.py index 9df0c683..62b98a10 100644 --- a/kloppy/tests/test_helpers.py +++ b/kloppy/tests/test_helpers.py @@ -164,7 +164,7 @@ def test_to_pandas_generic_events(self): ) dataframe = to_pandas(dataset) - dataframe = dataframe[dataframe.event_type == "GENERIC:out"] + dataframe = dataframe[dataframe.event_type == "BALL_OUT"] assert dataframe.shape[0] == 2 def test_to_pandas_additional_columns(self): diff --git a/kloppy/tests/test_metrica.py b/kloppy/tests/test_metrica.py index 661a9c36..72b5bb98 100644 --- a/kloppy/tests/test_metrica.py +++ b/kloppy/tests/test_metrica.py @@ -7,6 +7,8 @@ AttackingDirection, Orientation, Point, + EventType, + SetPieceType, ) from kloppy.domain.models.common import DatasetType @@ -91,7 +93,7 @@ def test_correct_deserialization(self): assert dataset.metadata.provider == Provider.METRICA assert dataset.dataset_type == DatasetType.EVENT - assert len(dataset.events) == 3620 + assert len(dataset.events) == 3684 assert len(dataset.metadata.periods) == 2 assert dataset.metadata.orientation is None assert dataset.metadata.teams[0].name == "Team A" @@ -115,3 +117,6 @@ def test_correct_deserialization(self): end_timestamp=5742.12, attacking_direction=AttackingDirection.NOT_SET, ) + + # Make sure we are using the improved event types. + dataset.records[1].qualifiers[0].value == SetPieceType.KICK_OFF diff --git a/kloppy/tests/test_state_builder.py b/kloppy/tests/test_state_builder.py index 8d25445c..394c9d1f 100644 --- a/kloppy/tests/test_state_builder.py +++ b/kloppy/tests/test_state_builder.py @@ -38,9 +38,9 @@ def test_score_state_builder(self): events_per_score[str(score)] = len(events) assert events_per_score == { - "0-0": 2884, - "1-0": 711, - "2-0": 404, + "0-0": 2897, + "1-0": 717, + "2-0": 405, "3-0": 3, } diff --git a/kloppy/tests/test_statsbomb.py b/kloppy/tests/test_statsbomb.py index 2068403d..5c5bda31 100644 --- a/kloppy/tests/test_statsbomb.py +++ b/kloppy/tests/test_statsbomb.py @@ -36,7 +36,7 @@ def test_correct_deserialization(self): assert dataset.metadata.provider == Provider.STATSBOMB assert dataset.dataset_type == DatasetType.EVENT - assert len(dataset.events) == 4002 + assert len(dataset.events) == 4022 assert len(dataset.metadata.periods) == 2 assert ( dataset.metadata.orientation == Orientation.ACTION_EXECUTING_TEAM diff --git a/kloppy/utils.py b/kloppy/utils.py index b88a67f5..81fd179e 100644 --- a/kloppy/utils.py +++ b/kloppy/utils.py @@ -44,3 +44,10 @@ def camelcase_to_snakecase(name): """Convert camel-case string to snake-case.""" s1 = _first_cap_re.sub(r"\1_\2", name) return _all_cap_re.sub(r"\1_\2", s1).lower() + + +def removes_suffix(string, suffix): + if string[-len(suffix):] == suffix: + return string[: -len(suffix)] + else: + return string