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 23503d1e..ae39017e 100644 --- a/kloppy/infra/serializers/event/statsbomb/serializer.py +++ b/kloppy/infra/serializers/event/statsbomb/serializer.py @@ -149,7 +149,7 @@ def _get_event_qualifiers(qualifiers_dict: Dict) -> List[Qualifier]: qualifiers.append( SetPieceQualifier(value=SetPieceType.CORNER_KICK) ) - elif qualifiers_dict["type"]["id"] == SB_EVENT_TYPE_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)) 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_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, }