| """Kloppy EventDataset to SPADL converter.""" |
|
|
| import warnings |
| from typing import Optional, Union, cast |
|
|
| import kloppy |
| import pandas as pd |
| from kloppy.domain import ( |
| BodyPart, |
| CardType, |
| CarryEvent, |
| ClearanceEvent, |
| CoordinateSystem, |
| Dimension, |
| DuelEvent, |
| DuelResult, |
| DuelType, |
| Event, |
| EventDataset, |
| EventType, |
| FoulCommittedEvent, |
| GoalkeeperActionType, |
| GoalkeeperEvent, |
| InterceptionResult, |
| MetricPitchDimensions, |
| MiscontrolEvent, |
| Orientation, |
| Origin, |
| PassEvent, |
| PassResult, |
| PassType, |
| PitchDimensions, |
| Provider, |
| Qualifier, |
| RecoveryEvent, |
| SetPieceType, |
| ShotEvent, |
| ShotResult, |
| TakeOnEvent, |
| TakeOnResult, |
| VerticalOrientation, |
| ) |
| from packaging import version |
| from pandera.typing import DataFrame |
|
|
| from . import config as spadlconfig |
| from .base import _add_dribbles, _fix_clearances |
| from .schema import SPADLSchema |
|
|
| _KLOPPY_VERSION = version.parse(kloppy.__version__) |
| _SUPPORTED_PROVIDERS = { |
| Provider.STATSBOMB: version.parse("3.15.0"), |
| |
| } |
|
|
|
|
| def convert_to_actions( |
| dataset: EventDataset, game_id: Optional[Union[str, int]] = None |
| ) -> DataFrame[SPADLSchema]: |
| """Convert a Kloppy event data set to SPADL actions. |
| |
| Parameters |
| ---------- |
| dataset : EventDataset |
| A Kloppy event data set. |
| game_id : str or int, optional |
| The identifier of the game. If not provided, the game id will not be |
| set in the SPADL DataFrame. |
| |
| Returns |
| ------- |
| actions : pd.DataFrame |
| DataFrame with corresponding SPADL actions. |
| |
| """ |
| |
| if dataset.metadata.provider not in _SUPPORTED_PROVIDERS: |
| warnings.warn( |
| f"Converting {dataset.metadata.provider} data is not yet supported. " |
| f"The result may be incorrect or incomplete. " |
| f"Supported providers are: {', '.join([p.value for p in _SUPPORTED_PROVIDERS.keys()])}" |
| ) |
| elif _KLOPPY_VERSION < _SUPPORTED_PROVIDERS[dataset.metadata.provider]: |
| warnings.warn( |
| f"Converting {dataset.metadata.provider} data is only supported from " |
| f"Kloppy version {_SUPPORTED_PROVIDERS[dataset.metadata.provider]} (you have {_KLOPPY_VERSION}). " |
| f"The result may be incorrect or incomplete." |
| ) |
|
|
| |
| new_dataset = dataset.transform( |
| to_orientation=Orientation.HOME_AWAY, |
| to_coordinate_system=_SoccerActionCoordinateSystem( |
| pitch_length=dataset.metadata.coordinate_system.pitch_length, |
| pitch_width=dataset.metadata.coordinate_system.pitch_width, |
| ), |
| ) |
|
|
| |
| actions = [] |
| for event in new_dataset.events: |
| action = dict( |
| game_id=game_id, |
| original_event_id=event.event_id, |
| period_id=event.period.id, |
| time_seconds=event.timestamp.total_seconds(), |
| team_id=event.team.team_id if event.team else None, |
| player_id=event.player.player_id if event.player else None, |
| start_x=event.coordinates.x if event.coordinates else None, |
| start_y=event.coordinates.y if event.coordinates else None, |
| **_get_end_location(event), |
| **_parse_event(event), |
| ) |
| actions.append(action) |
|
|
| |
| df_actions = ( |
| pd.DataFrame(actions) |
| .sort_values(["game_id", "period_id", "time_seconds"], kind="mergesort") |
| .reset_index(drop=True) |
| ) |
| df_actions = df_actions[df_actions.type_id != spadlconfig.actiontypes.index("non_action")] |
|
|
| df_actions = _fix_clearances(df_actions) |
|
|
| df_actions["action_id"] = range(len(df_actions)) |
| df_actions = _add_dribbles(df_actions) |
|
|
| return cast(DataFrame[SPADLSchema], df_actions) |
|
|
|
|
| class _SoccerActionCoordinateSystem(CoordinateSystem): |
| @property |
| def provider(self) -> Provider: |
| return "SoccerAction" |
|
|
| @property |
| def origin(self) -> Origin: |
| return Origin.BOTTOM_LEFT |
|
|
| @property |
| def vertical_orientation(self) -> VerticalOrientation: |
| return VerticalOrientation.BOTTOM_TO_TOP |
|
|
| @property |
| def pitch_dimensions(self) -> PitchDimensions: |
| return MetricPitchDimensions( |
| x_dim=Dimension(0, spadlconfig.field_length), |
| y_dim=Dimension(0, spadlconfig.field_width), |
| pitch_length=self.pitch_length, |
| pitch_width=self.pitch_width, |
| standardized=True, |
| ) |
|
|
|
|
| def _get_end_location(event: Event) -> dict[str, Optional[float]]: |
| if isinstance(event, PassEvent): |
| if event.receiver_coordinates: |
| return { |
| "end_x": event.receiver_coordinates.x, |
| "end_y": event.receiver_coordinates.y, |
| } |
| elif isinstance(event, CarryEvent): |
| if event.end_coordinates: |
| return { |
| "end_x": event.end_coordinates.x, |
| "end_y": event.end_coordinates.y, |
| } |
| elif isinstance(event, ShotEvent): |
| if event.result_coordinates: |
| return { |
| "end_x": event.result_coordinates.x, |
| "end_y": event.result_coordinates.y, |
| } |
| if event.coordinates: |
| return {"end_x": event.coordinates.x, "end_y": event.coordinates.y} |
| return {"end_x": None, "end_y": None} |
|
|
|
|
| def _parse_event(event: Event) -> dict[str, int]: |
| events = { |
| EventType.PASS: _parse_pass_event, |
| EventType.SHOT: _parse_shot_event, |
| EventType.TAKE_ON: _parse_take_on_event, |
| EventType.CARRY: _parse_carry_event, |
| EventType.FOUL_COMMITTED: _parse_foul_event, |
| EventType.DUEL: _parse_duel_event, |
| EventType.CLEARANCE: _parse_clearance_event, |
| EventType.MISCONTROL: _parse_miscontrol_event, |
| EventType.GOALKEEPER: _parse_goalkeeper_event, |
| EventType.INTERCEPTION: _parse_interception_event, |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| } |
| parser = events.get(event.event_type, _parse_event_as_non_action) |
| a, r, b = parser(event) |
| return { |
| "type_id": spadlconfig.actiontypes.index(a), |
| "result_id": spadlconfig.results.index(r), |
| "bodypart_id": spadlconfig.bodyparts.index(b), |
| } |
|
|
|
|
| def _qualifiers(event: Event) -> list[Qualifier]: |
| if event.qualifiers: |
| return [q.value for q in event.qualifiers] |
| return [] |
|
|
|
|
| def _parse_bodypart(qualifiers: list[Qualifier], default: str = "foot") -> str: |
| if BodyPart.HEAD in qualifiers: |
| b = "head" |
| elif BodyPart.RIGHT_FOOT in qualifiers: |
| b = "foot_right" |
| elif BodyPart.LEFT_FOOT in qualifiers: |
| b = "foot_left" |
| elif BodyPart.CHEST in qualifiers or BodyPart.OTHER in qualifiers: |
| b = "other" |
| elif BodyPart.HEAD_OTHER in qualifiers: |
| b = "head/other" |
| else: |
| b = default |
| return b |
|
|
|
|
| def _parse_event_as_non_action(event: Event) -> tuple[str, str, str]: |
| a = "non_action" |
| r = "success" |
| b = "foot" |
| return a, r, b |
|
|
|
|
| def _parse_pass_event(event: PassEvent) -> tuple[str, str, str]: |
| qualifiers = _qualifiers(event) |
| b = _parse_bodypart(qualifiers) |
|
|
| a = "pass" |
| r = None |
| if SetPieceType.FREE_KICK in qualifiers: |
| if ( |
| PassType.CHIPPED_PASS in qualifiers |
| or PassType.CROSS in qualifiers |
| or PassType.HIGH_PASS in qualifiers |
| or PassType.LONG_BALL in qualifiers |
| ): |
| a = "freekick_crossed" |
| else: |
| a = "freekick_short" |
| elif SetPieceType.CORNER_KICK in qualifiers: |
| if ( |
| PassType.CHIPPED_PASS in qualifiers |
| or PassType.CROSS in qualifiers |
| or PassType.HIGH_PASS in qualifiers |
| or PassType.LONG_BALL in qualifiers |
| ): |
| a = "corner_crossed" |
| else: |
| a = "corner_short" |
| elif SetPieceType.GOAL_KICK in qualifiers: |
| a = "goalkick" |
| elif SetPieceType.THROW_IN in qualifiers: |
| a = "throw_in" |
| b = "other" |
| elif PassType.CROSS in qualifiers: |
| a = "cross" |
| else: |
| a = "pass" |
|
|
| if BodyPart.KEEPER_ARM in qualifiers: |
| b = "other" |
|
|
| if r is None: |
| if event.result in [PassResult.INCOMPLETE, PassResult.OUT]: |
| r = "fail" |
| elif event.result == PassResult.OFFSIDE: |
| r = "offside" |
| elif event.result == PassResult.COMPLETE: |
| r = "success" |
| else: |
| |
| a = "non_action" |
| r = "success" |
|
|
| return a, r, b |
|
|
|
|
| def _parse_shot_event(event: ShotEvent) -> tuple[str, str, str]: |
| qualifiers = _qualifiers(event) |
| b = _parse_bodypart(qualifiers) |
|
|
| if SetPieceType.FREE_KICK in qualifiers: |
| a = "shot_freekick" |
| elif SetPieceType.PENALTY in qualifiers: |
| a = "shot_penalty" |
| else: |
| a = "shot" |
|
|
| if event.result == ShotResult.GOAL: |
| r = "success" |
| elif event.result == ShotResult.OWN_GOAL: |
| a = "bad_touch" |
| r = "owngoal" |
| else: |
| r = "fail" |
|
|
| return a, r, b |
|
|
|
|
| def _parse_take_on_event(event: TakeOnEvent) -> tuple[str, str, str]: |
| a = "take_on" |
|
|
| if event.result == TakeOnResult.COMPLETE: |
| r = "success" |
| else: |
| r = "fail" |
|
|
| b = "foot" |
|
|
| return a, r, b |
|
|
|
|
| def _parse_carry_event(_e: CarryEvent) -> tuple[str, str, str]: |
| a = "dribble" |
| r = "success" |
| b = "foot" |
| return a, r, b |
|
|
|
|
| def _parse_interception_event(event: RecoveryEvent) -> tuple[str, str, str]: |
| a = "interception" |
| qualifiers = _qualifiers(event) |
| b = _parse_bodypart(qualifiers, default="foot") |
|
|
| if event.result == InterceptionResult.LOST or event.result == InterceptionResult.OUT: |
| r = "fail" |
| else: |
| r = "success" |
|
|
| return a, r, b |
|
|
|
|
| def _parse_foul_event(event: FoulCommittedEvent) -> tuple[str, str, str]: |
| a = "foul" |
| r = "fail" |
| b = "foot" |
|
|
| qualifiers = _qualifiers(event) |
| if CardType.FIRST_YELLOW in qualifiers: |
| r = "yellow_card" |
| elif CardType.SECOND_YELLOW in qualifiers: |
| r = "red_card" |
| elif CardType.RED in qualifiers: |
| r = "red_card" |
|
|
| return a, r, b |
|
|
|
|
| def _parse_duel_event(event: DuelEvent) -> tuple[str, str, str]: |
| qualifiers = _qualifiers(event) |
|
|
| a = "non_action" |
| b = "foot" |
| if DuelType.GROUND in qualifiers and DuelType.LOOSE_BALL not in qualifiers: |
| a = "tackle" |
| b = "foot" |
|
|
| if event.result == DuelResult.LOST: |
| r = "fail" |
| else: |
| r = "success" |
|
|
| return a, r, b |
|
|
|
|
| def _parse_clearance_event(event: ClearanceEvent) -> tuple[str, str, str]: |
| a = "clearance" |
| r = "success" |
| qualifiers = _qualifiers(event) |
| b = _parse_bodypart(qualifiers) |
| return a, r, b |
|
|
|
|
| def _parse_miscontrol_event(event: MiscontrolEvent) -> tuple[str, str, str]: |
| a = "bad_touch" |
| r = "fail" |
| b = "foot" |
| return a, r, b |
|
|
|
|
| def _parse_goalkeeper_event(event: GoalkeeperEvent) -> tuple[str, str, str]: |
| a = "non_action" |
| r = "success" |
| qualifiers = _qualifiers(event) |
| b = _parse_bodypart(qualifiers, default="other") |
|
|
| if GoalkeeperActionType.SAVE in qualifiers: |
| a = "keeper_save" |
| r = "success" |
| |
| |
| |
| if GoalkeeperActionType.CLAIM in qualifiers: |
| a = "keeper_claim" |
| if GoalkeeperActionType.SMOTHER in qualifiers: |
| a = "keeper_claim" |
| if GoalkeeperActionType.PUNCH in qualifiers: |
| a = "keeper_punch" |
| if GoalkeeperActionType.PICK_UP in qualifiers: |
| a = "keeper_pick_up" |
| if GoalkeeperActionType.REFLEX in qualifiers: |
| pass |
|
|
| return a, r, b |
|
|