socr / spadl /kloppy.py
scfive's picture
Upload 203 files
d6ea71e verified
"""Kloppy EventDataset to SPADL converter."""
import warnings
from typing import Optional, Union, cast
import kloppy
import pandas as pd # type: ignore
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"),
# Provider.OPTA: 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.
"""
# Check if Kloppy is installed and if the version is supported
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."
)
# Convert the dataset to the SPADL coordinate system
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,
),
)
# Convert the events to SPADL actions
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)
# Create the SPADL actions DataFrame
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,
# other non-action events
# EventType.GENERIC: _parse_event_as_non_action,
# EventType.RECOVERY: _parse_event_as_non_action,
# EventType.SUBSTITUTION: _parse_event_as_non_action,
# EventType.CARD: _parse_event_as_non_action,
# EventType.PLAYER_ON: _parse_event_as_non_action,
# EventType.PLAYER_OFF: _parse_event_as_non_action,
# EventType.BALL_OUT: _parse_event_as_non_action,
# EventType.FORMATION_CHANGE:_parse_event_as_non_action,
}
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]: # noqa: C901
qualifiers = _qualifiers(event)
b = _parse_bodypart(qualifiers)
a = "pass" # default
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:
# discard interrupted events
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.SAVE_ATTEMPT in qualifiers:
# a = "keeper_save"
# r = "fail"
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