From a4a46ffe432b685087e8020c7392945ae3732bc3 Mon Sep 17 00:00:00 2001 From: Thiago Costa Porto Date: Sun, 2 Mar 2025 23:08:24 +0100 Subject: [PATCH 01/33] docs(Events): Add entry for ShotResult.OWN_GOAL --- kloppy/domain/models/event.py | 1 + 1 file changed, 1 insertion(+) diff --git a/kloppy/domain/models/event.py b/kloppy/domain/models/event.py index 2ab23ec99..4f81bad87 100644 --- a/kloppy/domain/models/event.py +++ b/kloppy/domain/models/event.py @@ -57,6 +57,7 @@ class ShotResult(ResultType): POST (ShotResult): Shot hit the post BLOCKED (ShotResult): Shot was blocked by another player SAVED (ShotResult): Shot was saved by the keeper + OWN_GOAL (ShotResult): Shot resulted in an own goal """ GOAL = "GOAL" From 1e3497c9014a59946a385f945e04a2ae29512d90 Mon Sep 17 00:00:00 2001 From: Thiago Costa Porto Date: Fri, 30 May 2025 14:11:20 +0200 Subject: [PATCH 02/33] feat(pff): add load_event to PFF provider api --- kloppy/_providers/pff.py | 53 +++++++++++++++++-- .../infra/serializers/event/pff/__init__.py | 8 +++ kloppy/infra/serializers/tracking/pff.py | 6 +-- kloppy/pff.py | 4 +- 4 files changed, 62 insertions(+), 9 deletions(-) create mode 100644 kloppy/infra/serializers/event/pff/__init__.py diff --git a/kloppy/_providers/pff.py b/kloppy/_providers/pff.py index 71c2fbe6e..a89b70997 100644 --- a/kloppy/_providers/pff.py +++ b/kloppy/_providers/pff.py @@ -1,9 +1,13 @@ +from typing import List from kloppy.domain import Optional, TrackingDataset +from kloppy.domain.services.event_factory import EventFactory from kloppy.infra.serializers.tracking.pff import ( - PFF_TrackingDeserializer, - PFF_TrackingInputs, + PFFTrackingDeserializer, + PFFTrackingInputs, ) +from kloppy.infra.serializers.event.pff import PFFEventDeserializer, PFFEventInputs from kloppy.io import FileLike, open_as_file +from kloppy.config import get_config def load_tracking( @@ -30,7 +34,7 @@ def load_tracking( Returns: TrackingDataset: A deserialized TrackingDataset object containing the processed tracking data. """ - deserializer = PFF_TrackingDeserializer( + deserializer = PFFTrackingDeserializer( sample_rate=sample_rate, limit=limit, coordinate_system=coordinates, @@ -40,9 +44,50 @@ def load_tracking( roster_meta_data ) as roster_meta_data_fp, open_as_file(raw_data) as raw_data_fp: return deserializer.deserialize( - inputs=PFF_TrackingInputs( + inputs=PFFTrackingInputs( meta_data=meta_data_fp, roster_meta_data=roster_meta_data_fp, raw_data=raw_data_fp, ) ) + +def load_event( + match_metadata: FileLike, + roster_metadata: FileLike, + raw_event_data: FileLike, + event_types: Optional[List[str]] = None, + coordinates: Optional[str] = None, + event_factory: Optional[EventFactory] = None, + additional_metadata: dict = {}, +) -> EventDataset: + """ + Load PFF event data into a [`EventDataset`][kloppy.domain.models.event.EventDataset] + + Parameters: + match_metadata (FileLike): A file-like object containing metadata about the match. + roster_metadata (FileLike): filename of json containing the lineup information + raw_event_data (FileLike): filename of json containing the events + event_types (List[str], optional): A list of event types to filter the events. If None, all events are included. Defaults to None. + coordinates (str, optional): The coordinate system to use for the tracking data. Defaults to None. + event_factory: (EventFactory, optional): An optional event factory to use for creating events. If None, the default event factory is used. Defaults to None. + additional_metadata (dict, optional): Additional metadata to include in the deserialization process. Defaults to an empty dictionary. + """ + deserializer = PFFEventDeserializer( + event_types=event_types, + coordinate_system=coordinates, + event_factory=event_factory or get_config("event_factory") + ) + + with ( + open_as_file(match_metadata) as metadata_fp, + open_as_file(roster_metadata) as roster_metadata_fp, + open_as_file(raw_event_data) as raw_event_data_fp + ): + return deserializer.deserialize( + inputs=PFFEventInputs( + match_metadata=metadata_fp, + roster_metadata=roster_metadata_fp, + raw_event_data=raw_event_data_fp, + ), + additional_metadata=additional_metadata, + ) diff --git a/kloppy/infra/serializers/event/pff/__init__.py b/kloppy/infra/serializers/event/pff/__init__.py new file mode 100644 index 000000000..2bd9f12f5 --- /dev/null +++ b/kloppy/infra/serializers/event/pff/__init__.py @@ -0,0 +1,8 @@ +"""Convert PFF event stream data to a kloppy EventDataset.""" + +from .deserializer import PFFEventDeserializer, PFFEventInputs + +__all__ = [ + "PFFEventDeserializer", + "PFFEventInputs", +] diff --git a/kloppy/infra/serializers/tracking/pff.py b/kloppy/infra/serializers/tracking/pff.py index 901d03747..826547076 100644 --- a/kloppy/infra/serializers/tracking/pff.py +++ b/kloppy/infra/serializers/tracking/pff.py @@ -91,13 +91,13 @@ class GameEventType: VIDEO_MISSING = "VID" -class PFF_TrackingInputs(NamedTuple): +class PFFTrackingInputs(NamedTuple): meta_data: IO[bytes] roster_meta_data: IO[bytes] raw_data: IO[bytes] -class PFF_TrackingDeserializer(TrackingDataDeserializer[PFF_TrackingInputs]): +class PFFTrackingDeserializer(TrackingDataDeserializer[PFFTrackingInputs]): def __init__( self, limit: Optional[int] = None, @@ -218,7 +218,7 @@ def __get_periods(cls, tracking, frame_rate): return periods - def deserialize(self, inputs: PFF_TrackingInputs) -> TrackingDataset: + def deserialize(self, inputs: PFFTrackingInputs) -> TrackingDataset: # Load datasets metadata = json.load(inputs.meta_data)[0] roster_meta_data = json.load(inputs.roster_meta_data) diff --git a/kloppy/pff.py b/kloppy/pff.py index a0ce8a58c..77287fc40 100644 --- a/kloppy/pff.py +++ b/kloppy/pff.py @@ -1,5 +1,5 @@ """Functions for loading PFF FC data.""" -from ._providers.pff import load_tracking +from ._providers.pff import load_tracking, load_event -__all__ = ["load_tracking"] +__all__ = ["load_tracking", "load_event"] From 12fa547a15bafe21e9f1126692b056d58a9a2769 Mon Sep 17 00:00:00 2001 From: Thiago Costa Porto Date: Thu, 15 May 2025 16:28:19 +0200 Subject: [PATCH 03/33] feat(pff): add deserializer --- .../serializers/event/pff/deserializer.py | 168 ++++++++++++++++++ 1 file changed, 168 insertions(+) create mode 100644 kloppy/infra/serializers/event/pff/deserializer.py diff --git a/kloppy/infra/serializers/event/pff/deserializer.py b/kloppy/infra/serializers/event/pff/deserializer.py new file mode 100644 index 000000000..a0268a52b --- /dev/null +++ b/kloppy/infra/serializers/event/pff/deserializer.py @@ -0,0 +1,168 @@ +from datetime import timedelta +import json +import logging +from itertools import zip_longest +from typing import IO, NamedTuple, Optional + +from kloppy.domain import ( + DatasetFlag, + EventDataset, + FormationType, + Ground, + Metadata, + Orientation, + Period, + Player, + Provider, + Team, +) +from kloppy.exceptions import DeserializationError +from kloppy.infra.serializers.event.deserializer import EventDataDeserializer +from kloppy.utils import performance_logging + +from . import specification as PFF +from .helpers import parse_freeze_frame, parse_str_ts +from .specification import position_types_mapping + +logger = logging.getLogger(__name__) + + +class PFFEventInputs(NamedTuple): + metadata: IO[bytes] + roster_metadata: IO[bytes] + raw_event_data: Optional[IO[bytes]] + + +class PFFDeserializer(EventDataDeserializer[PFFEventInputs]): + @property + def provider(self) -> Provider: + return Provider.PFF + + def deserialize( + self, inputs: PFFEventInputs, additional_metadata + ) -> EventDataset: + # Intialize coordinate system transformer + self.transformer = self.get_transformer() + + # Load data from JSON files + # and determine fidelity versions for x/y coordinates + with performance_logging("load data", logger=logger): + metadata = json.load(inputs.metadata) + players = json.load(inputs.roster_metadata) + raw_events = json.load(inputs.raw_event_data) + + # Create teams and players + with performance_logging("parse teams ans players", logger=logger): + teams = self.create_teams_and_players(metadata, players) + + # Create periods + with performance_logging("parse periods", logger=logger): + periods = self.create_periods(raw_events) + + # Create events + with performance_logging("parse events", logger=logger): + events = [] + for raw_event in raw_event_data: + new_events = ( + raw_event + .set_refs(periods, teams, raw_events) + .deserialize(self.event_factory) + ) + for event in new_events: + if self.should_include_event(event): + # Transform event to the coordinate system + event = self.transformer.transform_event(event) + events.append(event) + + pff_metadata = Metadata( + teams=teams, + periods=periods, + pitch_dimensions=self.transformer.get_to_coordinate_system().pitch_dimensions, + frame_rate=None, + orientation=Orientation.ACTION_EXECUTING_TEAM, + flags=DatasetFlag.BALL_OWNING_TEAM | DatasetFlag.BALL_STATE, + score=None, + provider=Provider.PFF, + coordinate_system=self.transformer.get_to_coordinate_system(), + **additional_metadata, + ) + dataset = EventDataset(metadata=pff_metadata, records=events) + + for event in dataset: + if "homePlayers" in event.raw_event: + # TODO: Freeze frame parsing + # event.freeze_frame = self.transformer.transform_frame( + # parse_freeze_frame( + # freeze_frame=event.raw_event["shot"]["freeze_frame"], + # home_team=teams[0], + # away_team=teams[1], + # event=event, + # fidelity_version=data_version.shot_fidelity_version, + # ) + # ) + return dataset + + def create_teams_and_players(self, metadata, players): + def create_team(team_id, team_name, ground_type): + team = Team( + team_id=team_id, + name=team_name, + ground=ground_type, + ) + team.players = [ + Player( + player_id=player["id"], + team=team, + name=player["name"], + jersey_no=int(player["shirtNumber"]), + starting=player['started'], + starting_position=player_positions.get(player['positionGroupType']) + ) + for player in players + if player['team']['id'] == team_id + ] + return team + + home_team = metadata["homeTeam"] + away_team = metadata["awayTeam"] + + home = create_team(home_team['id'], home_team['name'], Ground.HOME) + away = create_team(away_team['id'], away_team['name'], Ground.AWAY) + return [home, away] + + def create_periods(self, raw_event_data): + half_start_events = {} + half_end_events = {} + + for event in raw_event_data: + event_type = PFF.EVENT_TYPE(event.raw_event["gameEvents"]["gameEventType"]) + period = event.raw_event["gameEvents"]["period"] + + if event_type in [ + PFF.EVENT_TYPE.FIRST_HALF_KICKOFF, + PFF.EVENT_TYPE.SECOND_HALF_KICKOFF, + PFF.EVENT_TYPE.THIRD_HALF_KICKOFF, + PFF.EVENT_TYPE.FOURTH_HALF_KICKOFF, + ]: + half_start_events[period] = event.raw_event + elif event_type == PFF.EVENT_TYPE.END: + half_end_events[period] = event.raw_event + + periods = [] + + for start_event, end_event in zip_longest( + half_start_events.values(), half_end_events.values() + ): + if start_event is None or end_event is None: + raise DeserializationError( + "Failed to determine start and end time of periods." + ) + + period = Period( + id=int(start_event["gameEvents"]["period"]), + start_timestamp=timedelta(seconds=start_event["startTime"]), + end_timestamp=timedelta(seconds=end_event['startTime']), + ) + periods.append(period) + + return periods From 132142d4b7a320b6137a472c35c5d34ba9fc49c7 Mon Sep 17 00:00:00 2001 From: Thiago Costa Porto Date: Mon, 19 May 2025 10:20:10 +0200 Subject: [PATCH 04/33] feat(pff): remove dead imports, add types in deserializer --- kloppy/infra/serializers/event/pff/deserializer.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/kloppy/infra/serializers/event/pff/deserializer.py b/kloppy/infra/serializers/event/pff/deserializer.py index a0268a52b..547556db5 100644 --- a/kloppy/infra/serializers/event/pff/deserializer.py +++ b/kloppy/infra/serializers/event/pff/deserializer.py @@ -2,7 +2,7 @@ import json import logging from itertools import zip_longest -from typing import IO, NamedTuple, Optional +from typing import IO, List, NamedTuple, Optional from kloppy.domain import ( DatasetFlag, @@ -21,8 +21,6 @@ from kloppy.utils import performance_logging from . import specification as PFF -from .helpers import parse_freeze_frame, parse_str_ts -from .specification import position_types_mapping logger = logging.getLogger(__name__) @@ -39,7 +37,7 @@ def provider(self) -> Provider: return Provider.PFF def deserialize( - self, inputs: PFFEventInputs, additional_metadata + self, inputs: PFFEventInputs, additional_metadata: dict ) -> EventDataset: # Intialize coordinate system transformer self.transformer = self.get_transformer() @@ -130,7 +128,7 @@ def create_team(team_id, team_name, ground_type): away = create_team(away_team['id'], away_team['name'], Ground.AWAY) return [home, away] - def create_periods(self, raw_event_data): + def create_periods(self, raw_event_data: list[dict]) -> List[Period]: half_start_events = {} half_end_events = {} From c09b61b985208435f55124c247d8439e39b662c6 Mon Sep 17 00:00:00 2001 From: Thiago Costa Porto Date: Mon, 19 May 2025 16:08:15 +0200 Subject: [PATCH 05/33] wip: feat(pff): PFF specs --- .../serializers/event/pff/deserializer.py | 4 +- kloppy/infra/serializers/event/pff/helpers.py | 148 +++++++++ .../serializers/event/pff/specification.py | 294 ++++++++++++++++++ 3 files changed, 444 insertions(+), 2 deletions(-) create mode 100644 kloppy/infra/serializers/event/pff/helpers.py create mode 100644 kloppy/infra/serializers/event/pff/specification.py diff --git a/kloppy/infra/serializers/event/pff/deserializer.py b/kloppy/infra/serializers/event/pff/deserializer.py index 547556db5..632ce7fc2 100644 --- a/kloppy/infra/serializers/event/pff/deserializer.py +++ b/kloppy/infra/serializers/event/pff/deserializer.py @@ -27,8 +27,8 @@ class PFFEventInputs(NamedTuple): metadata: IO[bytes] - roster_metadata: IO[bytes] - raw_event_data: Optional[IO[bytes]] + players: IO[bytes] + raw_event_data: IO[bytes] class PFFDeserializer(EventDataDeserializer[PFFEventInputs]): diff --git a/kloppy/infra/serializers/event/pff/helpers.py b/kloppy/infra/serializers/event/pff/helpers.py new file mode 100644 index 000000000..8627b443f --- /dev/null +++ b/kloppy/infra/serializers/event/pff/helpers.py @@ -0,0 +1,148 @@ +from datetime import timedelta +from typing import Dict, List, Optional + +from kloppy.domain import ( + ActionValue, + Event, + Frame, + Period, + Player, + PlayerData, + Point, + Point3D, + PositionType, + Team, +) +from kloppy.domain.services.frame_factory import create_frame +from kloppy.exceptions import DeserializationError + + +def get_team_by_id(team_id: int | None, teams: List[Team]) -> Team | None: + """Get a team by its id.""" + if team_id is None: + return None + if str(team_id) == teams[0].team_id: + return teams[0] + elif str(team_id) == teams[1].team_id: + return teams[1] + else: + raise DeserializationError(f"Unknown team_id {team_id}") + + +def get_period_by_id(period_id: int, periods: List[Period]) -> Period: + """Get a period by its id.""" + for period in periods: + if period.id == period_id: + return period + raise DeserializationError(f"Unknown period_id {period_id}") + + +def parse_str_ts(timestamp: str) -> timedelta: + """Parse a HH:mm:ss string timestamp into number of seconds.""" + h, m, s = timestamp.split(":") + return timedelta(seconds=int(h) * 3600 + int(m) * 60 + float(s)) + + +def parse_coordinates( + coordinates: List[float], fidelity_version: int +) -> Point: + """Parse coordinates into a kloppy Point. + + Coordinates are cell-based, so 1,1 (low-granularity) or 0.1,0.1 + (high-granularity) is the top-left square 'yard' of the field (in + landscape), even though 0,0 is the true coordinate of the corner flag. + + [1, 120] x [1, 80] + +-----+-----+ + | 1,1 | 2,1 | + +-----+-----+ + | 1,2 | 2,2 | + +-----+-----+ + """ + cell_side = 0.1 if fidelity_version == 2 else 1.0 + cell_relative_center = cell_side / 2 + if len(coordinates) == 2: + return Point( + x=coordinates[0] - cell_relative_center, + y=coordinates[1] - cell_relative_center, + ) + elif len(coordinates) == 3: + # A coordinate in the goal frame, only used for the end location of + # Shot events. The y-coordinates and z-coordinates are always detailed + # to a tenth of a yard. + return Point3D( + x=coordinates[0] - cell_relative_center, + y=coordinates[1] - 0.05, + z=coordinates[2] - 0.05, + ) + else: + raise DeserializationError( + f"Unknown coordinates format: {coordinates}" + ) + + +def parse_freeze_frame( + freeze_frame: List[Dict], + home_team: Team, + away_team: Team, + event: Event, +) -> Frame: + """Parse a freeze frame into a kloppy Frame.""" + players_data = {} + + def get_player_from_freeze_frame(player_data, team, i): + if "player" in player_data: + return team.get_player_by_id(player_data["player"]["id"]) + elif player_data.get("actor"): + return event.player + elif player_data.get("keeper"): + return team.get_player_by_position( + position=PositionType.Goalkeeper, time=event.time + ) + else: + return Player( + player_id=f"T{team.team_id}-E{event.event_id}-{i}", + team=team, + jersey_no=None, + ) + + for i, freeze_frame_player in enumerate(freeze_frame): + is_teammate = (event.team == home_team) == freeze_frame_player[ + "teammate" + ] + freeze_frame_team = home_team if is_teammate else away_team + + player = get_player_from_freeze_frame( + freeze_frame_player, freeze_frame_team, i + ) + + players_data[player] = PlayerData( + coordinates=parse_coordinates( + freeze_frame_player["location"], fidelity_version + ) + ) + + if event.player not in players_data: + players_data[event.player] = PlayerData(coordinates=event.coordinates) + + FREEZE_FRAME_FPS = 29.97 + + frame_id = int( + event.period.start_timestamp.total_seconds() + + event.timestamp.total_seconds() * FREEZE_FRAME_FPS + ) + + frame = create_frame( + frame_id=frame_id, + ball_coordinates=Point3D( + x=event.coordinates.x, y=event.coordinates.y, z=0 + ), + players_data=players_data, + period=event.period, + timestamp=event.timestamp, + ball_state=event.ball_state, + ball_owning_team=event.ball_owning_team, + other_data={"visible_area": visible_area}, + ) + + return frame diff --git a/kloppy/infra/serializers/event/pff/specification.py b/kloppy/infra/serializers/event/pff/specification.py new file mode 100644 index 000000000..7bfe778db --- /dev/null +++ b/kloppy/infra/serializers/event/pff/specification.py @@ -0,0 +1,294 @@ +from datetime import timedelta +from enum import Enum, EnumMeta +from typing import NamedTuple + +from kloppy.domain import ( + BallState, + BodyPart, + BodyPartQualifier, + CardQualifier, + CardType, + CarryResult, + CounterAttackQualifier, + DuelQualifier, + DuelResult, + DuelType, + Event, + EventFactory, + ExpectedGoals, + FormationType, + GoalkeeperActionType, + GoalkeeperQualifier, + InterceptionResult, + PassQualifier, + PassResult, + PassType, + PositionType, + PostShotExpectedGoals, + SetPieceQualifier, + SetPieceType, + ShotResult, + TakeOnResult, +) +from kloppy.domain.models.event import UnderPressureQualifier +from kloppy.exceptions import DeserializationError +from kloppy.infra.serializers.event.pff.helpers import ( + get_period_by_id, + get_team_by_id, + parse_coordinates, + parse_str_ts, +) + + +position_types_mapping: dict[str, PositionType] = { + 'GK': PositionType.Goalkeeper, # Provider: Goalkeeper + 'RB': PositionType.RightBack, # Provider: Right Back + 'RCB': PositionType.RightCenterBack, # Provider: Right Center Back + 'CB': PositionType.CenterBack, # Provider: Center Back + 'MCB': PositionType.CenterBack, # Provider: Mid Center Back + 'LCB': PositionType.LeftCenterBack, # Provider: Left Center Back + 'LB': PositionType.LeftBack, # Provider: Left Back + 'LWB': PositionType.LeftWingBack, # Provider: Left Wing Back + 'RWB': PositionType.RightWingBack, # Provider: Right Wing Back + 'D': PositionType.Defender, # Provider: Defender + 'M': PositionType.Midfielder, # Provider: Midfielder + 'DM': PositionType.DefensiveMidfield, # Provider: Defensive Midfield + 'RM': PositionType.RightMidfield, # Provider: Right Midfield + 'CM': PositionType.CenterMidfield, # Provider: Center Midfield + 'LM': PositionType.LeftMidfield, # Provider: Left Midfield + 'RW': PositionType.RightWing, # Provider: Right Wing + 'AM': PositionType.AttackingMidfield, # Provider: Attacking Midfield + 'LW': PositionType.LeftWing, # Provider: Left Wing + 'CF': PositionType.Striker, # Provider: Center Forward (mapped to Striker) + 'F': PositionType.Attacker, # Provider: Forward (mapped to Attacker) +} + + +class TypesEnumMeta(EnumMeta): + def __call__(cls, value, *args, **kw): + if isinstance(value, dict): + if value["id"] not in cls._value2member_map_: + raise DeserializationError( + "Unknown PFF {}: {}/{}".format( + ( + cls.__qualname__.replace("_", " ") + .replace(".", " ") + .title() + ), + value["id"], + value["name"], + ) + ) + value = cls(value["id"]) + elif value not in cls._value2member_map_: + raise DeserializationError( + "Unknown PFF {}: {}".format( + ( + cls.__qualname__.replace("_", " ") + .replace(".", " ") + .title() + ), + value, + ) + ) + return super().__call__(value, *args, **kw) + +class END_TYPE(Enum, metaclass=TypesEnumMeta): + """"The list of end of half types used in PFF data.""" + + FIRST_HALF_END = 'FIRST' + SECOND_HALF_END = 'SECOND' + THIRD_HALF_END = 'F' + FOURTH_HALF_END = 'S' + GAME_END = 'G' + + +class EVENT_TYPE(Enum, metaclass=TypesEnumMeta): + """The list of game event types used in PFF data.""" + + FIRST_HALF_KICKOFF = 'FIRSTKICKOFF' + SECOND_HALF_KICKOFF = 'SECONDKICKOFF' + THIRD_HALF_KICKOFF = 'THIRDKICKOFF' + FOURTH_HALF_KICKOFF = 'FOURTHKICKOFF' + GAME_CLOCK_OBSERVATION = 'CLK' + END_OF_HALF = 'END' + GROUND = 'G' + PLAYER_OFF = 'OFF' + PLAYER_ON = 'ON' + POSSESSION = 'OTB' + BALL_OUT_OF_PLAY = 'OUT' + PAUSE_OF_GAME_TIME = 'PAU' + SUB = 'SUB' + VIDEO = 'VID' + + +class POSSESSION_EVENT_TYPE(Enum, metaclass=TypesEnumMeta): + """The list of possession event types used in PFF data.""" + + BALL_CARRY = 'BC' + CHALLENGE = 'CH' + CLEARANCE = 'CL' + CROSS = 'CR' + FOUL = 'FO' + PASS = 'PA' + REBOUND = 'RE' + SHOT = 'SH' + TOUCHES = 'IT' + + +class BODYPART(Enum, metaclass=TypesEnumMeta): + """The list of body parts used in PFF data.""" + + BACK = 'BA' + BOTTOM = 'BO' + TWO_HAND_CATCH = 'CA' + CHEST = 'CH' + HEAD = 'HE' + LEFT_FOOT = 'L' + LEFT_ARM = 'LA' + LEFT_BACK_HEEL = 'LB' + LEFT_SHOULDER = 'LC' + LEFT_HAND = 'LH' + LEFT_KNEE = 'LK' + LEFT_SHIN = 'LS' + LEFT_THIGH = 'LT' + TWO_HAND_PALM = 'PA' + TWO_HAND_PUNCH = 'PU' + RIGHT_FOOT = 'R' + RIGHT_ARM = 'RA' + RIGHT_BACK_HEEL = 'RB' + RIGHT_SHOULDER = 'RC' + RIGHT_HAND = 'RH' + RIGHT_KNEE = 'RK' + RIGHT_SHIN = 'RS' + RIGHT_THIGH = 'RT' + TWO_HANDS = 'TWOHANDS' + VIDEO_MISSING = 'VM' + + +class EVENT: + """Base class for PFF events. + + This class is used to deserialize PFF events into kloppy events. + This default implementation is used for all events that do not have a + specific implementation. They are deserialized into a generic event. + + Args: + raw_event: The raw JSON event. + """ + + def __init__(self, raw_event: dict): + self.raw_event = raw_event + + def set_refs(self, periods, teams, events): + self.period = get_period_by_id(self.raw_event["gameEvents"]["period"], periods) + self.team = get_team_by_id(self.raw_event["gameEvents"]["teamId"], teams) + self.possession_team = get_team_by_id( + self.raw_event["gameEvents"]["teamId"], teams + ) + self.player = ( + self.team.get_player_by_id(self.raw_event["gameEvents"]["playerId"]) + if "player" in self.raw_event + else None + ) + # self.related_events = [ + # events.get(event_id) + # for event_id in self.raw_event.get("related_events", []) + # ] + return self + + def deserialize(self, event_factory: EventFactory) -> list[Event]: + """Deserialize the event. + + Args: + event_factory: The event factory to use to build the event. + + Returns: + A list of kloppy events. + """ + generic_event_kwargs = self._parse_generic_kwargs() + + # create events + base_events = self._create_events( + event_factory, **generic_event_kwargs + ) + # aerial_won_events = self._create_aerial_won_event( + # event_factory, **generic_event_kwargs + # ) + # ball_out_events = self._create_ball_out_event( + # event_factory, **generic_event_kwargs + # ) + + # # add qualifiers + # for event in aerial_won_events + base_events: + # self._add_under_pressure_qualifier(event) + # for event in aerial_won_events + base_events + ball_out_events: + # self._add_play_pattern_qualifiers(event) + + # return events (note: order is important) + return base_events + # return aerial_won_events + base_events + ball_out_events + + def _parse_generic_kwargs(self) -> dict: + return { + "period": self.period, + "timestamp": parse_str_ts(self.raw_event["timestamp"]), + "ball_owning_team": self.possession_team, + "ball_state": BallState.ALIVE, + "event_id": self.raw_event["id"], + "team": self.team, + "player": self.player, + "coordinates": None, + "raw_event": self.raw_event, + } + + def _add_under_pressure_qualifier(self, event: Event) -> Event: + if ("under_pressure" in self.raw_event) and ( + self.raw_event["under_pressure"] + ): + q = UnderPressureQualifier(True) + event.qualifiers = event.qualifiers or [] + event.qualifiers.append(q) + + return event + + def _create_events( + self, event_factory: EventFactory, **generic_event_kwargs + ) -> list[Event]: + generic_event = event_factory.build_generic( + result=None, + qualifiers=None, + event_name=self.raw_event["possessionEvent"]["name"], + **generic_event_kwargs, + ) + return [generic_event] + + +def possession_event_decoder(possession_event: dict) -> EVENT: + type_to_possession_event = { + POSSESSION_EVENT_TYPE.PASS: EVENT, + POSSESSION_EVENT_TYPE.SHOT: EVENT, + POSSESSION_EVENT_TYPE.CROSS: EVENT, + POSSESSION_EVENT_TYPE.CLEARANCE: EVENT, + POSSESSION_EVENT_TYPE.BALL_CARRY: EVENT, + POSSESSION_EVENT_TYPE.CHALLENGE: EVENT, + POSSESSION_EVENT_TYPE.FOUL: EVENT, + POSSESSION_EVENT_TYPE.REBOUND: EVENT, + POSSESSION_EVENT_TYPE.TOUCHES: EVENT, + } + + +def event_decoder(raw_event: dict) -> EVENT: + type_to_event = { + EVENT_TYPE.POSSESSION: EVENT, + EVENT_TYPE.GAME_CLOCK_OBSERVATION: EVENT, + EVENT_TYPE.GROUND: EVENT, + EVENT_TYPE.BALL_OUT_OF_PLAY: EVENT, + EVENT_TYPE.SUB: EVENT, + EVENT_TYPE.PLAYER_ON: EVENT, + EVENT_TYPE.PLAYER_OFF: EVENT, + } + + event_type = EVENT_TYPE(raw_event["gameEvents"]["gameEventType"]) + event_creator = type_to_event.get(event_type, EVENT) + return event_creator(raw_event) From ce9b45aed4e548fbde71975956d8201c1581273f Mon Sep 17 00:00:00 2001 From: Thiago Costa Porto Date: Tue, 3 Jun 2025 12:39:08 +0200 Subject: [PATCH 06/33] fix(pff): update type hints, better arg names --- kloppy/_providers/pff.py | 29 ++++++++++++++--------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/kloppy/_providers/pff.py b/kloppy/_providers/pff.py index a89b70997..9e32045ed 100644 --- a/kloppy/_providers/pff.py +++ b/kloppy/_providers/pff.py @@ -1,5 +1,4 @@ -from typing import List -from kloppy.domain import Optional, TrackingDataset +from kloppy.domain import TrackingDataset, EventDataset from kloppy.domain.services.event_factory import EventFactory from kloppy.infra.serializers.tracking.pff import ( PFFTrackingDeserializer, @@ -14,10 +13,10 @@ def load_tracking( meta_data: FileLike, roster_meta_data: FileLike, raw_data: FileLike, - sample_rate: Optional[float] = None, - limit: Optional[int] = None, - coordinates: Optional[str] = None, - only_alive: Optional[bool] = True, + sample_rate: float | None = None, + limit: int | None = None, + coordinates: str | None = None, + only_alive: bool | None = True, ) -> TrackingDataset: """ Load and deserialize tracking data from the provided metadata, roster metadata, and raw data files. @@ -52,12 +51,12 @@ def load_tracking( ) def load_event( - match_metadata: FileLike, - roster_metadata: FileLike, + metadata: FileLike, + players: FileLike, raw_event_data: FileLike, - event_types: Optional[List[str]] = None, - coordinates: Optional[str] = None, - event_factory: Optional[EventFactory] = None, + event_types: list[str] | None = None, + coordinates: str | None = None, + event_factory: EventFactory | None = None, additional_metadata: dict = {}, ) -> EventDataset: """ @@ -79,14 +78,14 @@ def load_event( ) with ( - open_as_file(match_metadata) as metadata_fp, - open_as_file(roster_metadata) as roster_metadata_fp, + open_as_file(metadata) as metadata_fp, + open_as_file(players) as players_fp, open_as_file(raw_event_data) as raw_event_data_fp ): return deserializer.deserialize( inputs=PFFEventInputs( - match_metadata=metadata_fp, - roster_metadata=roster_metadata_fp, + metadata=metadata_fp, + players=players_fp, raw_event_data=raw_event_data_fp, ), additional_metadata=additional_metadata, From 13af611fee85d69c5a2095c1d8b03c635308c808 Mon Sep 17 00:00:00 2001 From: Thiago Costa Porto Date: Tue, 3 Jun 2025 12:49:17 +0200 Subject: [PATCH 07/33] wip: fix(pff): generate dataset --- .../serializers/event/pff/deserializer.py | 85 ++++++++++--------- 1 file changed, 44 insertions(+), 41 deletions(-) diff --git a/kloppy/infra/serializers/event/pff/deserializer.py b/kloppy/infra/serializers/event/pff/deserializer.py index 632ce7fc2..68ec14716 100644 --- a/kloppy/infra/serializers/event/pff/deserializer.py +++ b/kloppy/infra/serializers/event/pff/deserializer.py @@ -2,7 +2,7 @@ import json import logging from itertools import zip_longest -from typing import IO, List, NamedTuple, Optional +from typing import IO, NamedTuple from kloppy.domain import ( DatasetFlag, @@ -31,7 +31,7 @@ class PFFEventInputs(NamedTuple): raw_event_data: IO[bytes] -class PFFDeserializer(EventDataDeserializer[PFFEventInputs]): +class PFFEventDeserializer(EventDataDeserializer[PFFEventInputs]): @property def provider(self) -> Provider: return Provider.PFF @@ -46,7 +46,7 @@ def deserialize( # and determine fidelity versions for x/y coordinates with performance_logging("load data", logger=logger): metadata = json.load(inputs.metadata) - players = json.load(inputs.roster_metadata) + players = json.load(inputs.players) raw_events = json.load(inputs.raw_event_data) # Create teams and players @@ -58,19 +58,21 @@ def deserialize( periods = self.create_periods(raw_events) # Create events - with performance_logging("parse events", logger=logger): - events = [] - for raw_event in raw_event_data: - new_events = ( - raw_event - .set_refs(periods, teams, raw_events) - .deserialize(self.event_factory) - ) - for event in new_events: - if self.should_include_event(event): - # Transform event to the coordinate system - event = self.transformer.transform_event(event) - events.append(event) + # with performance_logging("parse events", logger=logger): + # events = [] + # for raw_event in raw_event_data: + # new_events = ( + # raw_event + # .set_refs(periods, teams, raw_events) + # .deserialize(self.event_factory) + # ) + # for event in new_events: + # if self.should_include_event(event): + # # Transform event to the coordinate system + # event = self.transformer.transform_event(event) + # events.append(event) + + events = [] pff_metadata = Metadata( teams=teams, @@ -86,21 +88,23 @@ def deserialize( ) dataset = EventDataset(metadata=pff_metadata, records=events) - for event in dataset: - if "homePlayers" in event.raw_event: - # TODO: Freeze frame parsing - # event.freeze_frame = self.transformer.transform_frame( - # parse_freeze_frame( - # freeze_frame=event.raw_event["shot"]["freeze_frame"], - # home_team=teams[0], - # away_team=teams[1], - # event=event, - # fidelity_version=data_version.shot_fidelity_version, - # ) - # ) +# for event in dataset: +# if "homePlayers" in event.raw_event: +# # TODO: Freeze frame parsing +# # event.freeze_frame = self.transformer.transform_frame( +# # parse_freeze_frame( +# # freeze_frame=event.raw_event["shot"]["freeze_frame"], +# # home_team=teams[0], +# # away_team=teams[1], +# # event=event, +# # fidelity_version=data_version.shot_fidelity_version, +# # ) +# # ) return dataset def create_teams_and_players(self, metadata, players): + print(players) + print(metadata) def create_team(team_id, team_name, ground_type): team = Team( team_id=team_id, @@ -109,15 +113,14 @@ def create_team(team_id, team_name, ground_type): ) team.players = [ Player( - player_id=player["id"], + player_id=entry["player"]["id"], team=team, - name=player["name"], - jersey_no=int(player["shirtNumber"]), - starting=player['started'], - starting_position=player_positions.get(player['positionGroupType']) + name=entry['player']["nickname"], + jersey_no=int(entry["shirtNumber"]), + starting_position=PFF.position_types_mapping[entry['positionGroupType']] ) - for player in players - if player['team']['id'] == team_id + for entry in players + if entry['team']['id'] == team_id ] return team @@ -128,13 +131,13 @@ def create_team(team_id, team_name, ground_type): away = create_team(away_team['id'], away_team['name'], Ground.AWAY) return [home, away] - def create_periods(self, raw_event_data: list[dict]) -> List[Period]: + def create_periods(self, raw_event_data: list[dict]) -> list[Period]: half_start_events = {} half_end_events = {} for event in raw_event_data: - event_type = PFF.EVENT_TYPE(event.raw_event["gameEvents"]["gameEventType"]) - period = event.raw_event["gameEvents"]["period"] + event_type = PFF.EVENT_TYPE(event["gameEvents"]["gameEventType"]) + period = event["gameEvents"]["period"] if event_type in [ PFF.EVENT_TYPE.FIRST_HALF_KICKOFF, @@ -142,9 +145,9 @@ def create_periods(self, raw_event_data: list[dict]) -> List[Period]: PFF.EVENT_TYPE.THIRD_HALF_KICKOFF, PFF.EVENT_TYPE.FOURTH_HALF_KICKOFF, ]: - half_start_events[period] = event.raw_event - elif event_type == PFF.EVENT_TYPE.END: - half_end_events[period] = event.raw_event + half_start_events[period] = event + elif event_type == PFF.EVENT_TYPE.END_OF_HALF: + half_end_events[period] = event periods = [] From 9dd70208a5c90f47a7086f786147781d42a31a60 Mon Sep 17 00:00:00 2001 From: Thiago Costa Porto Date: Tue, 3 Jun 2025 12:52:31 +0200 Subject: [PATCH 08/33] chore: remove print --- kloppy/infra/serializers/event/pff/deserializer.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/kloppy/infra/serializers/event/pff/deserializer.py b/kloppy/infra/serializers/event/pff/deserializer.py index 68ec14716..73ca74b64 100644 --- a/kloppy/infra/serializers/event/pff/deserializer.py +++ b/kloppy/infra/serializers/event/pff/deserializer.py @@ -103,8 +103,6 @@ def deserialize( return dataset def create_teams_and_players(self, metadata, players): - print(players) - print(metadata) def create_team(team_id, team_name, ground_type): team = Team( team_id=team_id, @@ -117,6 +115,7 @@ def create_team(team_id, team_name, ground_type): team=team, name=entry['player']["nickname"], jersey_no=int(entry["shirtNumber"]), + # started=entry['started'], starting_position=PFF.position_types_mapping[entry['positionGroupType']] ) for entry in players From 15d93edda2f20e525e66aff299e585115d068bfc Mon Sep 17 00:00:00 2001 From: Thiago Costa Porto Date: Tue, 3 Jun 2025 14:09:28 +0200 Subject: [PATCH 09/33] wip: feat(pff): build a generic dataset --- .../serializers/event/pff/deserializer.py | 53 +++++++++++-------- .../serializers/event/pff/specification.py | 20 ++++--- 2 files changed, 44 insertions(+), 29 deletions(-) diff --git a/kloppy/infra/serializers/event/pff/deserializer.py b/kloppy/infra/serializers/event/pff/deserializer.py index 73ca74b64..26f902d47 100644 --- a/kloppy/infra/serializers/event/pff/deserializer.py +++ b/kloppy/infra/serializers/event/pff/deserializer.py @@ -47,7 +47,7 @@ def deserialize( with performance_logging("load data", logger=logger): metadata = json.load(inputs.metadata) players = json.load(inputs.players) - raw_events = json.load(inputs.raw_event_data) + raw_events = self.load_raw_events(inputs.raw_event_data) # Create teams and players with performance_logging("parse teams ans players", logger=logger): @@ -58,21 +58,18 @@ def deserialize( periods = self.create_periods(raw_events) # Create events - # with performance_logging("parse events", logger=logger): - # events = [] - # for raw_event in raw_event_data: - # new_events = ( - # raw_event - # .set_refs(periods, teams, raw_events) - # .deserialize(self.event_factory) - # ) - # for event in new_events: - # if self.should_include_event(event): - # # Transform event to the coordinate system - # event = self.transformer.transform_event(event) - # events.append(event) - - events = [] + with performance_logging("parse events", logger=logger): + events = [] + for raw_event in raw_events.values(): + new_events = ( + raw_event + .set_refs(periods, teams, raw_events) + .deserialize(self.event_factory) + ) + for event in new_events: + if self.should_include_event(event): + event = self.transformer.transform_event(event) + events.append(event) pff_metadata = Metadata( teams=teams, @@ -102,6 +99,18 @@ def deserialize( # # ) return dataset + + def load_raw_events(self, raw_event_data: IO[bytes]) -> dict[str, PFF.EVENT]: + raw_events = {} + for event in json.load(raw_event_data): + event_id = ( + f'{event['gameEventId']}_{event['possessionEventId']}' + if event['possessionEventId'] is not None + else f'{event['gameEventId']}' + ) + raw_events[event_id] = PFF.event_decoder(event) + return raw_events + def create_teams_and_players(self, metadata, players): def create_team(team_id, team_name, ground_type): team = Team( @@ -130,13 +139,13 @@ def create_team(team_id, team_name, ground_type): away = create_team(away_team['id'], away_team['name'], Ground.AWAY) return [home, away] - def create_periods(self, raw_event_data: list[dict]) -> list[Period]: + def create_periods(self, raw_events: dict[str, PFF.EVENT]) -> list[Period]: half_start_events = {} half_end_events = {} - for event in raw_event_data: - event_type = PFF.EVENT_TYPE(event["gameEvents"]["gameEventType"]) - period = event["gameEvents"]["period"] + for event in raw_events.values(): + event_type = PFF.EVENT_TYPE(event.raw_event["gameEvents"]["gameEventType"]) + period = event.raw_event["gameEvents"]["period"] if event_type in [ PFF.EVENT_TYPE.FIRST_HALF_KICKOFF, @@ -144,9 +153,9 @@ def create_periods(self, raw_event_data: list[dict]) -> list[Period]: PFF.EVENT_TYPE.THIRD_HALF_KICKOFF, PFF.EVENT_TYPE.FOURTH_HALF_KICKOFF, ]: - half_start_events[period] = event + half_start_events[period] = event.raw_event elif event_type == PFF.EVENT_TYPE.END_OF_HALF: - half_end_events[period] = event + half_end_events[period] = event.raw_event periods = [] diff --git a/kloppy/infra/serializers/event/pff/specification.py b/kloppy/infra/serializers/event/pff/specification.py index 7bfe778db..5b4d63eba 100644 --- a/kloppy/infra/serializers/event/pff/specification.py +++ b/kloppy/infra/serializers/event/pff/specification.py @@ -191,10 +191,11 @@ def set_refs(self, periods, teams, events): if "player" in self.raw_event else None ) - # self.related_events = [ - # events.get(event_id) - # for event_id in self.raw_event.get("related_events", []) - # ] + self.related_events = [ + events.get(event_id) + for event_id in events.keys() + if event_id.split('_')[0] == self.raw_event.get("gameEventId", "") + ] return self def deserialize(self, event_factory: EventFactory) -> list[Event]: @@ -230,12 +231,17 @@ def deserialize(self, event_factory: EventFactory) -> list[Event]: # return aerial_won_events + base_events + ball_out_events def _parse_generic_kwargs(self) -> dict: + event_id = ( + self.raw_event["possessionEventId"] + if self.raw_event["possessionEventId"] is not None + else self.raw_event["gameEventId"] + ) return { "period": self.period, - "timestamp": parse_str_ts(self.raw_event["timestamp"]), + "timestamp": self.raw_event["eventTime"], "ball_owning_team": self.possession_team, "ball_state": BallState.ALIVE, - "event_id": self.raw_event["id"], + "event_id": event_id, "team": self.team, "player": self.player, "coordinates": None, @@ -258,7 +264,7 @@ def _create_events( generic_event = event_factory.build_generic( result=None, qualifiers=None, - event_name=self.raw_event["possessionEvent"]["name"], + event_name=self.raw_event["gameEvents"]["gameEventType"], **generic_event_kwargs, ) return [generic_event] From acb53440e4173d0260a57652dad169200a23807c Mon Sep 17 00:00:00 2001 From: Thiago Costa Porto Date: Tue, 3 Jun 2025 14:54:04 +0200 Subject: [PATCH 10/33] wip: feat(pff): add SUBSTITUTION event --- .../serializers/event/pff/deserializer.py | 2 +- kloppy/infra/serializers/event/pff/helpers.py | 10 +---- .../serializers/event/pff/specification.py | 43 +++++++++++++++++-- 3 files changed, 42 insertions(+), 13 deletions(-) diff --git a/kloppy/infra/serializers/event/pff/deserializer.py b/kloppy/infra/serializers/event/pff/deserializer.py index 26f902d47..16dc22a8d 100644 --- a/kloppy/infra/serializers/event/pff/deserializer.py +++ b/kloppy/infra/serializers/event/pff/deserializer.py @@ -114,7 +114,7 @@ def load_raw_events(self, raw_event_data: IO[bytes]) -> dict[str, PFF.EVENT]: def create_teams_and_players(self, metadata, players): def create_team(team_id, team_name, ground_type): team = Team( - team_id=team_id, + team_id=str(team_id), name=team_name, ground=ground_type, ) diff --git a/kloppy/infra/serializers/event/pff/helpers.py b/kloppy/infra/serializers/event/pff/helpers.py index 8627b443f..c86e38aa9 100644 --- a/kloppy/infra/serializers/event/pff/helpers.py +++ b/kloppy/infra/serializers/event/pff/helpers.py @@ -17,7 +17,7 @@ from kloppy.exceptions import DeserializationError -def get_team_by_id(team_id: int | None, teams: List[Team]) -> Team | None: +def get_team_by_id(team_id: int | None, teams: list[Team]) -> Team | None: """Get a team by its id.""" if team_id is None: return None @@ -29,7 +29,7 @@ def get_team_by_id(team_id: int | None, teams: List[Team]) -> Team | None: raise DeserializationError(f"Unknown team_id {team_id}") -def get_period_by_id(period_id: int, periods: List[Period]) -> Period: +def get_period_by_id(period_id: int, periods: list[Period]) -> Period: """Get a period by its id.""" for period in periods: if period.id == period_id: @@ -37,12 +37,6 @@ def get_period_by_id(period_id: int, periods: List[Period]) -> Period: raise DeserializationError(f"Unknown period_id {period_id}") -def parse_str_ts(timestamp: str) -> timedelta: - """Parse a HH:mm:ss string timestamp into number of seconds.""" - h, m, s = timestamp.split(":") - return timedelta(seconds=int(h) * 3600 + int(m) * 60 + float(s)) - - def parse_coordinates( coordinates: List[float], fidelity_version: int ) -> Point: diff --git a/kloppy/infra/serializers/event/pff/specification.py b/kloppy/infra/serializers/event/pff/specification.py index 5b4d63eba..6c3706e81 100644 --- a/kloppy/infra/serializers/event/pff/specification.py +++ b/kloppy/infra/serializers/event/pff/specification.py @@ -36,7 +36,6 @@ get_period_by_id, get_team_by_id, parse_coordinates, - parse_str_ts, ) @@ -181,6 +180,8 @@ def __init__(self, raw_event: dict): self.raw_event = raw_event def set_refs(self, periods, teams, events): + # Some PFF events do not have a teamId assigned but we can get the team via the player + self.teams = teams self.period = get_period_by_id(self.raw_event["gameEvents"]["period"], periods) self.team = get_team_by_id(self.raw_event["gameEvents"]["teamId"], teams) self.possession_team = get_team_by_id( @@ -188,7 +189,7 @@ def set_refs(self, periods, teams, events): ) self.player = ( self.team.get_player_by_id(self.raw_event["gameEvents"]["playerId"]) - if "player" in self.raw_event + if self.team and self.raw_event['gameEvents']['playerId'] is not None else None ) self.related_events = [ @@ -238,7 +239,7 @@ def _parse_generic_kwargs(self) -> dict: ) return { "period": self.period, - "timestamp": self.raw_event["eventTime"], + "timestamp": timedelta(seconds=self.raw_event["eventTime"]), "ball_owning_team": self.possession_team, "ball_state": BallState.ALIVE, "event_id": event_id, @@ -270,6 +271,40 @@ def _create_events( return [generic_event] +class SUBSTITUTION(EVENT): + """PFF Substitution event.""" + + def _create_events( + self, event_factory: EventFactory, **generic_event_kwargs + ) -> list[Event]: + # As of now, PFF Substitution events do not set teamId. + # team = generic_event_kwargs['team'] + + player_off_id = self.raw_event["gameEvents"]["playerOffId"] + player_on_id = self.raw_event["gameEvents"]["playerOnId"] + + team = next( + team for team in self.teams + if team.get_player_by_id(player_off_id) + ) + + player_off = team.get_player_by_id(player_off_id) + player_on = team.get_player_by_id(player_on_id) + + generic_event_kwargs['team'] = team + generic_event_kwargs['player'] = player_off + generic_event_kwargs['ball_state'] = BallState.DEAD + + return [ + event_factory.build_substitution( + result=None, + qualifiers=None, + replacement_player=player_on, + **generic_event_kwargs, + ) + ] + + def possession_event_decoder(possession_event: dict) -> EVENT: type_to_possession_event = { POSSESSION_EVENT_TYPE.PASS: EVENT, @@ -290,7 +325,7 @@ def event_decoder(raw_event: dict) -> EVENT: EVENT_TYPE.GAME_CLOCK_OBSERVATION: EVENT, EVENT_TYPE.GROUND: EVENT, EVENT_TYPE.BALL_OUT_OF_PLAY: EVENT, - EVENT_TYPE.SUB: EVENT, + EVENT_TYPE.SUB: SUBSTITUTION, EVENT_TYPE.PLAYER_ON: EVENT, EVENT_TYPE.PLAYER_OFF: EVENT, } From 1c89b01e16762f3d3c3ab49f6a11805502b56f99 Mon Sep 17 00:00:00 2001 From: Thiago Costa Porto Date: Tue, 3 Jun 2025 17:25:42 +0200 Subject: [PATCH 11/33] wip: feat(pff): add events Events added: FOUL, CARD, PLAYER_ON, PLAYER_OFF, BALL_OUT. Added a handler for POSSESSION_EVENTS. --- .../serializers/event/pff/deserializer.py | 2 + .../serializers/event/pff/specification.py | 201 ++++++++++++++---- 2 files changed, 164 insertions(+), 39 deletions(-) diff --git a/kloppy/infra/serializers/event/pff/deserializer.py b/kloppy/infra/serializers/event/pff/deserializer.py index 16dc22a8d..6b0c9e9ae 100644 --- a/kloppy/infra/serializers/event/pff/deserializer.py +++ b/kloppy/infra/serializers/event/pff/deserializer.py @@ -16,6 +16,7 @@ Provider, Team, ) +from kloppy.domain.models.pitch import PitchDimensions from kloppy.exceptions import DeserializationError from kloppy.infra.serializers.event.deserializer import EventDataDeserializer from kloppy.utils import performance_logging @@ -74,6 +75,7 @@ def deserialize( pff_metadata = Metadata( teams=teams, periods=periods, + # TODO: get pitch dimensions from a event pitch_dimensions=self.transformer.get_to_coordinate_system().pitch_dimensions, frame_rate=None, orientation=Orientation.ACTION_EXECUTING_TEAM, diff --git a/kloppy/infra/serializers/event/pff/specification.py b/kloppy/infra/serializers/event/pff/specification.py index 6c3706e81..e85102618 100644 --- a/kloppy/infra/serializers/event/pff/specification.py +++ b/kloppy/infra/serializers/event/pff/specification.py @@ -30,7 +30,7 @@ ShotResult, TakeOnResult, ) -from kloppy.domain.models.event import UnderPressureQualifier +from kloppy.domain.models.event import CardEvent, FoulCommittedEvent, UnderPressureQualifier from kloppy.exceptions import DeserializationError from kloppy.infra.serializers.event.pff.helpers import ( get_period_by_id, @@ -132,7 +132,8 @@ class POSSESSION_EVENT_TYPE(Enum, metaclass=TypesEnumMeta): PASS = 'PA' REBOUND = 'RE' SHOT = 'SH' - TOUCHES = 'IT' + TOUCHES = 'TC' + EVT_START = 'IT' class BODYPART(Enum, metaclass=TypesEnumMeta): @@ -165,6 +166,21 @@ class BODYPART(Enum, metaclass=TypesEnumMeta): VIDEO_MISSING = 'VM' +class FOUL_TYPE(Enum, metaclass=TypesEnumMeta): + ADVANTAGE = 'A' + INFRIGEMENT = 'I' + MISSED_INFRIGEMENT = 'M' + + +class FOUL_OUTCOME(Enum, metaclass=TypesEnumMeta): + FIRST_YELLOW = 'Y' + SECOND_YELLOW = 'S' + RED = 'R' + WARNING = 'W' + NO_FOUL = 'F' + NO_WARNING = 'N' + + class EVENT: """Base class for PFF events. @@ -214,35 +230,19 @@ def deserialize(self, event_factory: EventFactory) -> list[Event]: base_events = self._create_events( event_factory, **generic_event_kwargs ) - # aerial_won_events = self._create_aerial_won_event( - # event_factory, **generic_event_kwargs - # ) - # ball_out_events = self._create_ball_out_event( - # event_factory, **generic_event_kwargs - # ) - - # # add qualifiers - # for event in aerial_won_events + base_events: - # self._add_under_pressure_qualifier(event) - # for event in aerial_won_events + base_events + ball_out_events: - # self._add_play_pattern_qualifiers(event) + + foul_events = self._create_foul(event_factory, **generic_event_kwargs) # return events (note: order is important) - return base_events - # return aerial_won_events + base_events + ball_out_events + return base_events + foul_events def _parse_generic_kwargs(self) -> dict: - event_id = ( - self.raw_event["possessionEventId"] - if self.raw_event["possessionEventId"] is not None - else self.raw_event["gameEventId"] - ) return { "period": self.period, "timestamp": timedelta(seconds=self.raw_event["eventTime"]), "ball_owning_team": self.possession_team, - "ball_state": BallState.ALIVE, - "event_id": event_id, + "ball_state": BallState.DEAD, + "event_id": self.raw_event["gameEventId"], "team": self.team, "player": self.player, "coordinates": None, @@ -259,6 +259,55 @@ def _add_under_pressure_qualifier(self, event: Event) -> Event: return event + def _create_foul(self, event_factory: EventFactory, **generic_event_kwargs) -> list[CardEvent | FoulCommittedEvent]: + foul_type = self.raw_event['fouls'].get('foulType') + + generic_event_kwargs['ball_state'] = BallState.DEAD + + if foul_type != FOUL_TYPE.INFRIGEMENT.value: + return [] + + events = [] + + card_map = { + FOUL_OUTCOME.FIRST_YELLOW: CardType.FIRST_YELLOW, + FOUL_OUTCOME.SECOND_YELLOW: CardType.SECOND_YELLOW, + FOUL_OUTCOME.RED: CardType.RED, + } + + committer_id = self.raw_event['fouls']['finalCulpritPlayerId'] + team = next(t for t in self.teams if t.get_player_by_id(committer_id)) + + generic_event_kwargs['team'] = team + generic_event_kwargs['player'] = team.get_player_by_id(committer_id) + generic_event_kwargs['ball_state'] = BallState.DEAD + + foul_outcome = self.raw_event['fouls']['finalFoulOutcomeType'] + card_type = card_map.get(FOUL_OUTCOME(foul_outcome)) + card_qualifier = [CardQualifier(value=card_type)] if card_type else [] + + foul = [ + event_factory.build_foul_committed( + result=None, + qualifiers=card_qualifier, + **generic_event_kwargs, + ) + ] + + if card_type: + card = [ + event_factory.build_card( + result=None, + qualifiers=None, + card_type=card_type, + **generic_event_kwargs, + ) + ] + else: + card = [] + + return foul + card + def _create_events( self, event_factory: EventFactory, **generic_event_kwargs ) -> list[Event]: @@ -271,6 +320,21 @@ def _create_events( return [generic_event] +class POSSESSION_EVENT(EVENT): + def _parse_generic_kwargs(self) -> dict: + return { + "period": self.period, + "timestamp": timedelta(seconds=self.raw_event["eventTime"]), + "ball_owning_team": self.possession_team, + "ball_state": BallState.ALIVE, + "event_id": self.raw_event["possessionEventId"], + "team": self.team, + "player": self.player, + "coordinates": None, + "raw_event": self.raw_event, + } + + class SUBSTITUTION(EVENT): """PFF Substitution event.""" @@ -284,8 +348,8 @@ def _create_events( player_on_id = self.raw_event["gameEvents"]["playerOnId"] team = next( - team for team in self.teams - if team.get_player_by_id(player_off_id) + t for t in self.teams + if t.get_player_by_id(player_off_id) ) player_off = team.get_player_by_id(player_off_id) @@ -304,30 +368,89 @@ def _create_events( ) ] +class PLAYER_OFF(EVENT): + """PFF Player Off event.""" -def possession_event_decoder(possession_event: dict) -> EVENT: + def _create_events( + self, event_factory: EventFactory, **generic_event_kwargs + ) -> list[Event]: + generic_event_kwargs['ball_state'] = BallState.DEAD + + return [ + event_factory.build_player_off( + result=None, + qualifiers=None, + **generic_event_kwargs, + ) + ] + + +class PLAYER_ON(EVENT): + """PFF Player On event.""" + + def _create_events( + self, event_factory: EventFactory, **generic_event_kwargs + ) -> list[Event]: + return [ + event_factory.build_player_on( + result=None, + qualifiers=None, + **generic_event_kwargs, + ) + ] + + +class BALL_OUT(EVENT): + """PFF OUT/Ball out of play event.""" + + def _create_events( + self, event_factory: EventFactory, **generic_event_kwargs + ) -> list[Event]: + generic_event_kwargs['ball_state'] = BallState.DEAD + ball_out_event = event_factory.build_ball_out( + result=None, + qualifiers=None, + **generic_event_kwargs, + ) + return [ball_out_event] + + +def possession_event_decoder(raw_event: dict) -> POSSESSION_EVENT: type_to_possession_event = { - POSSESSION_EVENT_TYPE.PASS: EVENT, - POSSESSION_EVENT_TYPE.SHOT: EVENT, - POSSESSION_EVENT_TYPE.CROSS: EVENT, - POSSESSION_EVENT_TYPE.CLEARANCE: EVENT, - POSSESSION_EVENT_TYPE.BALL_CARRY: EVENT, - POSSESSION_EVENT_TYPE.CHALLENGE: EVENT, - POSSESSION_EVENT_TYPE.FOUL: EVENT, - POSSESSION_EVENT_TYPE.REBOUND: EVENT, - POSSESSION_EVENT_TYPE.TOUCHES: EVENT, + POSSESSION_EVENT_TYPE.PASS: POSSESSION_EVENT, + POSSESSION_EVENT_TYPE.SHOT: POSSESSION_EVENT, + POSSESSION_EVENT_TYPE.CROSS: POSSESSION_EVENT, + POSSESSION_EVENT_TYPE.CLEARANCE: POSSESSION_EVENT, + POSSESSION_EVENT_TYPE.BALL_CARRY: POSSESSION_EVENT, + POSSESSION_EVENT_TYPE.CHALLENGE: POSSESSION_EVENT, + POSSESSION_EVENT_TYPE.REBOUND: POSSESSION_EVENT, + POSSESSION_EVENT_TYPE.TOUCHES: POSSESSION_EVENT, + POSSESSION_EVENT_TYPE.EVT_START: POSSESSION_EVENT, } + p_evt_type = raw_event["possessionEvents"]["possessionEventType"] + + if p_evt_type is None: + return POSSESSION_EVENT(raw_event) + + event_type = POSSESSION_EVENT_TYPE(p_evt_type) + event_creator = type_to_possession_event.get(event_type, POSSESSION_EVENT) + return event_creator(raw_event) + def event_decoder(raw_event: dict) -> EVENT: type_to_event = { - EVENT_TYPE.POSSESSION: EVENT, + EVENT_TYPE.POSSESSION: possession_event_decoder, + EVENT_TYPE.FIRST_HALF_KICKOFF: possession_event_decoder, + EVENT_TYPE.SECOND_HALF_KICKOFF: possession_event_decoder, + EVENT_TYPE.THIRD_HALF_KICKOFF: possession_event_decoder, + EVENT_TYPE.FOURTH_HALF_KICKOFF: possession_event_decoder, EVENT_TYPE.GAME_CLOCK_OBSERVATION: EVENT, EVENT_TYPE.GROUND: EVENT, - EVENT_TYPE.BALL_OUT_OF_PLAY: EVENT, + EVENT_TYPE.BALL_OUT_OF_PLAY: BALL_OUT, EVENT_TYPE.SUB: SUBSTITUTION, - EVENT_TYPE.PLAYER_ON: EVENT, - EVENT_TYPE.PLAYER_OFF: EVENT, + EVENT_TYPE.PLAYER_ON: PLAYER_ON, + EVENT_TYPE.PLAYER_OFF: PLAYER_OFF, } event_type = EVENT_TYPE(raw_event["gameEvents"]["gameEventType"]) From b013f67c92a360716c87152f106640ca0e35122b Mon Sep 17 00:00:00 2001 From: Thiago Costa Porto Date: Tue, 3 Jun 2025 18:07:31 +0200 Subject: [PATCH 12/33] wip: feat(pff): add event classes --- .../serializers/event/pff/specification.py | 211 +++++++++++++++--- 1 file changed, 179 insertions(+), 32 deletions(-) diff --git a/kloppy/infra/serializers/event/pff/specification.py b/kloppy/infra/serializers/event/pff/specification.py index e85102618..f700fc51c 100644 --- a/kloppy/infra/serializers/event/pff/specification.py +++ b/kloppy/infra/serializers/event/pff/specification.py @@ -262,8 +262,6 @@ def _add_under_pressure_qualifier(self, event: Event) -> Event: def _create_foul(self, event_factory: EventFactory, **generic_event_kwargs) -> list[CardEvent | FoulCommittedEvent]: foul_type = self.raw_event['fouls'].get('foulType') - generic_event_kwargs['ball_state'] = BallState.DEAD - if foul_type != FOUL_TYPE.INFRIGEMENT.value: return [] @@ -320,21 +318,6 @@ def _create_events( return [generic_event] -class POSSESSION_EVENT(EVENT): - def _parse_generic_kwargs(self) -> dict: - return { - "period": self.period, - "timestamp": timedelta(seconds=self.raw_event["eventTime"]), - "ball_owning_team": self.possession_team, - "ball_state": BallState.ALIVE, - "event_id": self.raw_event["possessionEventId"], - "team": self.team, - "player": self.player, - "coordinates": None, - "raw_event": self.raw_event, - } - - class SUBSTITUTION(EVENT): """PFF Substitution event.""" @@ -357,7 +340,6 @@ def _create_events( generic_event_kwargs['team'] = team generic_event_kwargs['player'] = player_off - generic_event_kwargs['ball_state'] = BallState.DEAD return [ event_factory.build_substitution( @@ -374,8 +356,6 @@ class PLAYER_OFF(EVENT): def _create_events( self, event_factory: EventFactory, **generic_event_kwargs ) -> list[Event]: - generic_event_kwargs['ball_state'] = BallState.DEAD - return [ event_factory.build_player_off( result=None, @@ -406,23 +386,190 @@ class BALL_OUT(EVENT): def _create_events( self, event_factory: EventFactory, **generic_event_kwargs ) -> list[Event]: - generic_event_kwargs['ball_state'] = BallState.DEAD - ball_out_event = event_factory.build_ball_out( - result=None, - qualifiers=None, - **generic_event_kwargs, - ) - return [ball_out_event] + return [ + event_factory.build_ball_out( + result=None, + qualifiers=None, + **generic_event_kwargs, + ) + ] + + +class POSSESSION_EVENT(EVENT): + def _parse_generic_kwargs(self) -> dict: + return { + "period": self.period, + "timestamp": timedelta(seconds=self.raw_event["eventTime"]), + "ball_owning_team": self.possession_team, + "ball_state": BallState.ALIVE, + "event_id": self.raw_event["possessionEventId"], + "team": self.team, + "player": self.player, + "coordinates": None, + "raw_event": self.raw_event, + } + + +class PASS(POSSESSION_EVENT): + """PFF Pass event.""" + + def _create_events( + self, event_factory: EventFactory, **generic_event_kwargs + ) -> list[Event]: + return [ + event_factory.build_pass( + result=None, + qualifiers=None, + **generic_event_kwargs, + ) + ] + + +class SHOT(POSSESSION_EVENT): + """PFF Shot event.""" + + def _create_events( + self, event_factory: EventFactory, **generic_event_kwargs + ) -> list[Event]: + return [ + event_factory.build_shot( + result=None, + qualifiers=None, + **generic_event_kwargs, + ) + ] + + +class CLEARANCE(POSSESSION_EVENT): + """PFF Clearance event.""" + def _create_events( + self, event_factory: EventFactory, **generic_event_kwargs + ) -> list[Event]: + return [ + event_factory.build_clearance( + result=None, + qualifiers=None, + **generic_event_kwargs, + ) + ] + +class CHALLENGE(POSSESSION_EVENT): + """PFF Challenge event.""" + def _create_events( + self, event_factory: EventFactory, **generic_event_kwargs + ) -> list[Event]: + return [ + event_factory.build_duel( + result=None, + qualifiers=None, + **generic_event_kwargs, + ) + ] + +class TAKE_ON(POSSESSION_EVENT): + """PFF Dribble event.""" + def _create_events( + self, event_factory: EventFactory, **generic_event_kwargs + ) -> list[Event]: + return [ + event_factory.build_take_on( + result=None, + qualifiers=None, + **generic_event_kwargs, + ) + ] + + +class CARRY(POSSESSION_EVENT): + """PFF Carry event.""" + + def _create_events( + self, event_factory: EventFactory, **generic_event_kwargs + ) -> list[Event]: + return [ + event_factory.build_carry( + result=None, + qualifiers=None, + **generic_event_kwargs, + ) + ] + + +class PRESSURE(POSSESSION_EVENT): + """PFF Pressure event.""" + def _create_events( + self, event_factory: EventFactory, **generic_event_kwargs + ) -> list[Event]: + return [ + event_factory.build_pressure_event( + result=None, + qualifiers=None, + **generic_event_kwargs, + ) + ] + +class INTERCEPTION(POSSESSION_EVENT): + """PFF Interception event.""" + def _create_events( + self, event_factory: EventFactory, **generic_event_kwargs + ) -> list[Event]: + return [ + event_factory.build_interception( + result=None, + qualifiers=None, + **generic_event_kwargs, + ) + ] + +class RECOVERY(POSSESSION_EVENT): + """PFF Recovery event.""" + def _create_events( + self, event_factory: EventFactory, **generic_event_kwargs + ) -> list[Event]: + return [ + event_factory.build_recovery( + result=None, + qualifiers=None, + **generic_event_kwargs, + ) + ] + +class MISCONTROL(POSSESSION_EVENT): + """PFF Miscontrol event.""" + def _create_events( + self, event_factory: EventFactory, **generic_event_kwargs + ) -> list[Event]: + return [ + event_factory.build_miscontrol( + result=None, + qualifiers=None, + **generic_event_kwargs, + ) + ] + +class GOALKEEPER(POSSESSION_EVENT): + """PFF Goalkeeper event.""" + + def _create_events( + self, event_factory: EventFactory, **generic_event_kwargs + ) -> list[Event]: + return [ + event_factory.build_goalkeeper_event( + result=None, + qualifiers=None, + **generic_event_kwargs, + ) + ] def possession_event_decoder(raw_event: dict) -> POSSESSION_EVENT: type_to_possession_event = { - POSSESSION_EVENT_TYPE.PASS: POSSESSION_EVENT, - POSSESSION_EVENT_TYPE.SHOT: POSSESSION_EVENT, - POSSESSION_EVENT_TYPE.CROSS: POSSESSION_EVENT, - POSSESSION_EVENT_TYPE.CLEARANCE: POSSESSION_EVENT, + POSSESSION_EVENT_TYPE.CROSS: PASS, + POSSESSION_EVENT_TYPE.PASS: PASS, + POSSESSION_EVENT_TYPE.SHOT: SHOT, + POSSESSION_EVENT_TYPE.CLEARANCE: CLEARANCE, POSSESSION_EVENT_TYPE.BALL_CARRY: POSSESSION_EVENT, - POSSESSION_EVENT_TYPE.CHALLENGE: POSSESSION_EVENT, + POSSESSION_EVENT_TYPE.CHALLENGE: CHALLENGE, POSSESSION_EVENT_TYPE.REBOUND: POSSESSION_EVENT, POSSESSION_EVENT_TYPE.TOUCHES: POSSESSION_EVENT, POSSESSION_EVENT_TYPE.EVT_START: POSSESSION_EVENT, From 83a61857be4100ef3631a8347b4ab5c17505ad07 Mon Sep 17 00:00:00 2001 From: Thiago Costa Porto Date: Tue, 3 Jun 2025 18:31:34 +0200 Subject: [PATCH 13/33] wip: tbc --- .../infra/serializers/event/pff/specification.py | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/kloppy/infra/serializers/event/pff/specification.py b/kloppy/infra/serializers/event/pff/specification.py index f700fc51c..c6bd29b04 100644 --- a/kloppy/infra/serializers/event/pff/specification.py +++ b/kloppy/infra/serializers/event/pff/specification.py @@ -196,7 +196,9 @@ def __init__(self, raw_event: dict): self.raw_event = raw_event def set_refs(self, periods, teams, events): - # Some PFF events do not have a teamId assigned but we can get the team via the player + # temp: some PFF events do not have a 'teamId' assigned but we can get + # the team using the player id. Until this is fixed in the PFF data, + # both teams are being "carried over" in the event. self.teams = teams self.period = get_period_by_id(self.raw_event["gameEvents"]["period"], periods) self.team = get_team_by_id(self.raw_event["gameEvents"]["teamId"], teams) @@ -249,24 +251,12 @@ def _parse_generic_kwargs(self) -> dict: "raw_event": self.raw_event, } - def _add_under_pressure_qualifier(self, event: Event) -> Event: - if ("under_pressure" in self.raw_event) and ( - self.raw_event["under_pressure"] - ): - q = UnderPressureQualifier(True) - event.qualifiers = event.qualifiers or [] - event.qualifiers.append(q) - - return event - def _create_foul(self, event_factory: EventFactory, **generic_event_kwargs) -> list[CardEvent | FoulCommittedEvent]: foul_type = self.raw_event['fouls'].get('foulType') if foul_type != FOUL_TYPE.INFRIGEMENT.value: return [] - events = [] - card_map = { FOUL_OUTCOME.FIRST_YELLOW: CardType.FIRST_YELLOW, FOUL_OUTCOME.SECOND_YELLOW: CardType.SECOND_YELLOW, From 939812f721fd5f1c6da5de4c5ad21f13bf29b9cb Mon Sep 17 00:00:00 2001 From: Thiago Costa Porto Date: Fri, 6 Jun 2025 13:30:49 +0200 Subject: [PATCH 14/33] lint: black --- kloppy/_providers/pff.py | 18 +- .../infra/serializers/event/pff/__init__.py | 2 +- .../serializers/event/pff/deserializer.py | 53 ++-- kloppy/infra/serializers/event/pff/helpers.py | 45 +--- .../serializers/event/pff/specification.py | 235 ++++++++++-------- 5 files changed, 179 insertions(+), 174 deletions(-) diff --git a/kloppy/_providers/pff.py b/kloppy/_providers/pff.py index 9e32045ed..a2365c77d 100644 --- a/kloppy/_providers/pff.py +++ b/kloppy/_providers/pff.py @@ -4,7 +4,10 @@ PFFTrackingDeserializer, PFFTrackingInputs, ) -from kloppy.infra.serializers.event.pff import PFFEventDeserializer, PFFEventInputs +from kloppy.infra.serializers.event.pff import ( + PFFEventDeserializer, + PFFEventInputs, +) from kloppy.io import FileLike, open_as_file from kloppy.config import get_config @@ -39,9 +42,11 @@ def load_tracking( coordinate_system=coordinates, only_alive=only_alive, ) - with open_as_file(meta_data) as meta_data_fp, open_as_file( - roster_meta_data - ) as roster_meta_data_fp, open_as_file(raw_data) as raw_data_fp: + with ( + open_as_file(meta_data) as meta_data_fp, + open_as_file(roster_meta_data) as roster_meta_data_fp, + open_as_file(raw_data) as raw_data_fp, + ): return deserializer.deserialize( inputs=PFFTrackingInputs( meta_data=meta_data_fp, @@ -50,6 +55,7 @@ def load_tracking( ) ) + def load_event( metadata: FileLike, players: FileLike, @@ -74,13 +80,13 @@ def load_event( deserializer = PFFEventDeserializer( event_types=event_types, coordinate_system=coordinates, - event_factory=event_factory or get_config("event_factory") + event_factory=event_factory or get_config("event_factory"), ) with ( open_as_file(metadata) as metadata_fp, open_as_file(players) as players_fp, - open_as_file(raw_event_data) as raw_event_data_fp + open_as_file(raw_event_data) as raw_event_data_fp, ): return deserializer.deserialize( inputs=PFFEventInputs( diff --git a/kloppy/infra/serializers/event/pff/__init__.py b/kloppy/infra/serializers/event/pff/__init__.py index 2bd9f12f5..6f20f13f1 100644 --- a/kloppy/infra/serializers/event/pff/__init__.py +++ b/kloppy/infra/serializers/event/pff/__init__.py @@ -1,6 +1,6 @@ """Convert PFF event stream data to a kloppy EventDataset.""" -from .deserializer import PFFEventDeserializer, PFFEventInputs +from .deserializer import PFFEventDeserializer, PFFEventInputs __all__ = [ "PFFEventDeserializer", diff --git a/kloppy/infra/serializers/event/pff/deserializer.py b/kloppy/infra/serializers/event/pff/deserializer.py index 6b0c9e9ae..9b5b37130 100644 --- a/kloppy/infra/serializers/event/pff/deserializer.py +++ b/kloppy/infra/serializers/event/pff/deserializer.py @@ -62,11 +62,9 @@ def deserialize( with performance_logging("parse events", logger=logger): events = [] for raw_event in raw_events.values(): - new_events = ( - raw_event - .set_refs(periods, teams, raw_events) - .deserialize(self.event_factory) - ) + new_events = raw_event.set_refs( + periods, teams, raw_events + ).deserialize(self.event_factory) for event in new_events: if self.should_include_event(event): event = self.transformer.transform_event(event) @@ -87,28 +85,19 @@ def deserialize( ) dataset = EventDataset(metadata=pff_metadata, records=events) -# for event in dataset: -# if "homePlayers" in event.raw_event: -# # TODO: Freeze frame parsing -# # event.freeze_frame = self.transformer.transform_frame( -# # parse_freeze_frame( -# # freeze_frame=event.raw_event["shot"]["freeze_frame"], -# # home_team=teams[0], -# # away_team=teams[1], -# # event=event, -# # fidelity_version=data_version.shot_fidelity_version, -# # ) -# # ) - return dataset + # TODO: add freeze frames + return dataset - def load_raw_events(self, raw_event_data: IO[bytes]) -> dict[str, PFF.EVENT]: + def load_raw_events( + self, raw_event_data: IO[bytes] + ) -> dict[str, PFF.EVENT]: raw_events = {} for event in json.load(raw_event_data): event_id = ( - f'{event['gameEventId']}_{event['possessionEventId']}' - if event['possessionEventId'] is not None - else f'{event['gameEventId']}' + f"{event['gameEventId']}_{event['possessionEventId']}" + if event["possessionEventId"] is not None + else f"{event['gameEventId']}" ) raw_events[event_id] = PFF.event_decoder(event) return raw_events @@ -124,21 +113,23 @@ def create_team(team_id, team_name, ground_type): Player( player_id=entry["player"]["id"], team=team, - name=entry['player']["nickname"], + name=entry["player"]["nickname"], jersey_no=int(entry["shirtNumber"]), # started=entry['started'], - starting_position=PFF.position_types_mapping[entry['positionGroupType']] + starting_position=PFF.position_types_mapping[ + entry["positionGroupType"] + ], ) for entry in players - if entry['team']['id'] == team_id + if entry["team"]["id"] == team_id ] return team home_team = metadata["homeTeam"] away_team = metadata["awayTeam"] - home = create_team(home_team['id'], home_team['name'], Ground.HOME) - away = create_team(away_team['id'], away_team['name'], Ground.AWAY) + home = create_team(home_team["id"], home_team["name"], Ground.HOME) + away = create_team(away_team["id"], away_team["name"], Ground.AWAY) return [home, away] def create_periods(self, raw_events: dict[str, PFF.EVENT]) -> list[Period]: @@ -146,8 +137,10 @@ def create_periods(self, raw_events: dict[str, PFF.EVENT]) -> list[Period]: half_end_events = {} for event in raw_events.values(): - event_type = PFF.EVENT_TYPE(event.raw_event["gameEvents"]["gameEventType"]) - period = event.raw_event["gameEvents"]["period"] + event_type = PFF.EVENT_TYPE( + event.raw_event["gameEvents"]["gameEventType"] + ) + period = event.raw_event["gameEvents"]["period"] if event_type in [ PFF.EVENT_TYPE.FIRST_HALF_KICKOFF, @@ -172,7 +165,7 @@ def create_periods(self, raw_events: dict[str, PFF.EVENT]) -> list[Period]: period = Period( id=int(start_event["gameEvents"]["period"]), start_timestamp=timedelta(seconds=start_event["startTime"]), - end_timestamp=timedelta(seconds=end_event['startTime']), + end_timestamp=timedelta(seconds=end_event["startTime"]), ) periods.append(period) diff --git a/kloppy/infra/serializers/event/pff/helpers.py b/kloppy/infra/serializers/event/pff/helpers.py index c86e38aa9..edf55a5ae 100644 --- a/kloppy/infra/serializers/event/pff/helpers.py +++ b/kloppy/infra/serializers/event/pff/helpers.py @@ -38,41 +38,22 @@ def get_period_by_id(period_id: int, periods: list[Period]) -> Period: def parse_coordinates( - coordinates: List[float], fidelity_version: int + player_id: str, player_coordinates: list[dict[str, object]] ) -> Point: - """Parse coordinates into a kloppy Point. - - Coordinates are cell-based, so 1,1 (low-granularity) or 0.1,0.1 - (high-granularity) is the top-left square 'yard' of the field (in - landscape), even though 0,0 is the true coordinate of the corner flag. - - [1, 120] x [1, 80] - +-----+-----+ - | 1,1 | 2,1 | - +-----+-----+ - | 1,2 | 2,2 | - +-----+-----+ - """ - cell_side = 0.1 if fidelity_version == 2 else 1.0 - cell_relative_center = cell_side / 2 - if len(coordinates) == 2: - return Point( - x=coordinates[0] - cell_relative_center, - y=coordinates[1] - cell_relative_center, - ) - elif len(coordinates) == 3: - # A coordinate in the goal frame, only used for the end location of - # Shot events. The y-coordinates and z-coordinates are always detailed - # to a tenth of a yard. - return Point3D( - x=coordinates[0] - cell_relative_center, - y=coordinates[1] - 0.05, - z=coordinates[2] - 0.05, + """Parse PFF coordinates into a kloppy Point.""" + try: + player = next( + player + for player in player_coordinates + if str(player["playerId"]) == player_id ) - else: - raise DeserializationError( - f"Unknown coordinates format: {coordinates}" + + return Point( + x=player["x"], + y=player["y"], ) + except StopIteration: + raise DeserializationError(f"Unknown player_id {player_id}") def parse_freeze_frame( diff --git a/kloppy/infra/serializers/event/pff/specification.py b/kloppy/infra/serializers/event/pff/specification.py index c6bd29b04..8c57985d2 100644 --- a/kloppy/infra/serializers/event/pff/specification.py +++ b/kloppy/infra/serializers/event/pff/specification.py @@ -30,7 +30,11 @@ ShotResult, TakeOnResult, ) -from kloppy.domain.models.event import CardEvent, FoulCommittedEvent, UnderPressureQualifier +from kloppy.domain.models.event import ( + CardEvent, + FoulCommittedEvent, + UnderPressureQualifier, +) from kloppy.exceptions import DeserializationError from kloppy.infra.serializers.event.pff.helpers import ( get_period_by_id, @@ -40,26 +44,26 @@ position_types_mapping: dict[str, PositionType] = { - 'GK': PositionType.Goalkeeper, # Provider: Goalkeeper - 'RB': PositionType.RightBack, # Provider: Right Back - 'RCB': PositionType.RightCenterBack, # Provider: Right Center Back - 'CB': PositionType.CenterBack, # Provider: Center Back - 'MCB': PositionType.CenterBack, # Provider: Mid Center Back - 'LCB': PositionType.LeftCenterBack, # Provider: Left Center Back - 'LB': PositionType.LeftBack, # Provider: Left Back - 'LWB': PositionType.LeftWingBack, # Provider: Left Wing Back - 'RWB': PositionType.RightWingBack, # Provider: Right Wing Back - 'D': PositionType.Defender, # Provider: Defender - 'M': PositionType.Midfielder, # Provider: Midfielder - 'DM': PositionType.DefensiveMidfield, # Provider: Defensive Midfield - 'RM': PositionType.RightMidfield, # Provider: Right Midfield - 'CM': PositionType.CenterMidfield, # Provider: Center Midfield - 'LM': PositionType.LeftMidfield, # Provider: Left Midfield - 'RW': PositionType.RightWing, # Provider: Right Wing - 'AM': PositionType.AttackingMidfield, # Provider: Attacking Midfield - 'LW': PositionType.LeftWing, # Provider: Left Wing - 'CF': PositionType.Striker, # Provider: Center Forward (mapped to Striker) - 'F': PositionType.Attacker, # Provider: Forward (mapped to Attacker) + "GK": PositionType.Goalkeeper, # Provider: Goalkeeper + "RB": PositionType.RightBack, # Provider: Right Back + "RCB": PositionType.RightCenterBack, # Provider: Right Center Back + "CB": PositionType.CenterBack, # Provider: Center Back + "MCB": PositionType.CenterBack, # Provider: Mid Center Back + "LCB": PositionType.LeftCenterBack, # Provider: Left Center Back + "LB": PositionType.LeftBack, # Provider: Left Back + "LWB": PositionType.LeftWingBack, # Provider: Left Wing Back + "RWB": PositionType.RightWingBack, # Provider: Right Wing Back + "D": PositionType.Defender, # Provider: Defender + "M": PositionType.Midfielder, # Provider: Midfielder + "DM": PositionType.DefensiveMidfield, # Provider: Defensive Midfield + "RM": PositionType.RightMidfield, # Provider: Right Midfield + "CM": PositionType.CenterMidfield, # Provider: Center Midfield + "LM": PositionType.LeftMidfield, # Provider: Left Midfield + "RW": PositionType.RightWing, # Provider: Right Wing + "AM": PositionType.AttackingMidfield, # Provider: Attacking Midfield + "LW": PositionType.LeftWing, # Provider: Left Wing + "CF": PositionType.Striker, # Provider: Center Forward (mapped to Striker) + "F": PositionType.Attacker, # Provider: Forward (mapped to Attacker) } @@ -92,93 +96,94 @@ def __call__(cls, value, *args, **kw): ) return super().__call__(value, *args, **kw) + class END_TYPE(Enum, metaclass=TypesEnumMeta): - """"The list of end of half types used in PFF data.""" + """ "The list of end of half types used in PFF data.""" - FIRST_HALF_END = 'FIRST' - SECOND_HALF_END = 'SECOND' - THIRD_HALF_END = 'F' - FOURTH_HALF_END = 'S' - GAME_END = 'G' + FIRST_HALF_END = "FIRST" + SECOND_HALF_END = "SECOND" + THIRD_HALF_END = "F" + FOURTH_HALF_END = "S" + GAME_END = "G" class EVENT_TYPE(Enum, metaclass=TypesEnumMeta): """The list of game event types used in PFF data.""" - FIRST_HALF_KICKOFF = 'FIRSTKICKOFF' - SECOND_HALF_KICKOFF = 'SECONDKICKOFF' - THIRD_HALF_KICKOFF = 'THIRDKICKOFF' - FOURTH_HALF_KICKOFF = 'FOURTHKICKOFF' - GAME_CLOCK_OBSERVATION = 'CLK' - END_OF_HALF = 'END' - GROUND = 'G' - PLAYER_OFF = 'OFF' - PLAYER_ON = 'ON' - POSSESSION = 'OTB' - BALL_OUT_OF_PLAY = 'OUT' - PAUSE_OF_GAME_TIME = 'PAU' - SUB = 'SUB' - VIDEO = 'VID' + FIRST_HALF_KICKOFF = "FIRSTKICKOFF" + SECOND_HALF_KICKOFF = "SECONDKICKOFF" + THIRD_HALF_KICKOFF = "THIRDKICKOFF" + FOURTH_HALF_KICKOFF = "FOURTHKICKOFF" + GAME_CLOCK_OBSERVATION = "CLK" + END_OF_HALF = "END" + GROUND = "G" + PLAYER_OFF = "OFF" + PLAYER_ON = "ON" + POSSESSION = "OTB" + BALL_OUT_OF_PLAY = "OUT" + PAUSE_OF_GAME_TIME = "PAU" + SUB = "SUB" + VIDEO = "VID" class POSSESSION_EVENT_TYPE(Enum, metaclass=TypesEnumMeta): """The list of possession event types used in PFF data.""" - BALL_CARRY = 'BC' - CHALLENGE = 'CH' - CLEARANCE = 'CL' - CROSS = 'CR' - FOUL = 'FO' - PASS = 'PA' - REBOUND = 'RE' - SHOT = 'SH' - TOUCHES = 'TC' - EVT_START = 'IT' + BALL_CARRY = "BC" + CHALLENGE = "CH" + CLEARANCE = "CL" + CROSS = "CR" + FOUL = "FO" + PASS = "PA" + REBOUND = "RE" + SHOT = "SH" + TOUCHES = "TC" + EVT_START = "IT" class BODYPART(Enum, metaclass=TypesEnumMeta): """The list of body parts used in PFF data.""" - BACK = 'BA' - BOTTOM = 'BO' - TWO_HAND_CATCH = 'CA' - CHEST = 'CH' - HEAD = 'HE' - LEFT_FOOT = 'L' - LEFT_ARM = 'LA' - LEFT_BACK_HEEL = 'LB' - LEFT_SHOULDER = 'LC' - LEFT_HAND = 'LH' - LEFT_KNEE = 'LK' - LEFT_SHIN = 'LS' - LEFT_THIGH = 'LT' - TWO_HAND_PALM = 'PA' - TWO_HAND_PUNCH = 'PU' - RIGHT_FOOT = 'R' - RIGHT_ARM = 'RA' - RIGHT_BACK_HEEL = 'RB' - RIGHT_SHOULDER = 'RC' - RIGHT_HAND = 'RH' - RIGHT_KNEE = 'RK' - RIGHT_SHIN = 'RS' - RIGHT_THIGH = 'RT' - TWO_HANDS = 'TWOHANDS' - VIDEO_MISSING = 'VM' + BACK = "BA" + BOTTOM = "BO" + TWO_HAND_CATCH = "CA" + CHEST = "CH" + HEAD = "HE" + LEFT_FOOT = "L" + LEFT_ARM = "LA" + LEFT_BACK_HEEL = "LB" + LEFT_SHOULDER = "LC" + LEFT_HAND = "LH" + LEFT_KNEE = "LK" + LEFT_SHIN = "LS" + LEFT_THIGH = "LT" + TWO_HAND_PALM = "PA" + TWO_HAND_PUNCH = "PU" + RIGHT_FOOT = "R" + RIGHT_ARM = "RA" + RIGHT_BACK_HEEL = "RB" + RIGHT_SHOULDER = "RC" + RIGHT_HAND = "RH" + RIGHT_KNEE = "RK" + RIGHT_SHIN = "RS" + RIGHT_THIGH = "RT" + TWO_HANDS = "TWOHANDS" + VIDEO_MISSING = "VM" class FOUL_TYPE(Enum, metaclass=TypesEnumMeta): - ADVANTAGE = 'A' - INFRIGEMENT = 'I' - MISSED_INFRIGEMENT = 'M' + ADVANTAGE = "A" + INFRIGEMENT = "I" + MISSED_INFRIGEMENT = "M" class FOUL_OUTCOME(Enum, metaclass=TypesEnumMeta): - FIRST_YELLOW = 'Y' - SECOND_YELLOW = 'S' - RED = 'R' - WARNING = 'W' - NO_FOUL = 'F' - NO_WARNING = 'N' + FIRST_YELLOW = "Y" + SECOND_YELLOW = "S" + RED = "R" + WARNING = "W" + NO_FOUL = "F" + NO_WARNING = "N" class EVENT: @@ -200,20 +205,27 @@ def set_refs(self, periods, teams, events): # the team using the player id. Until this is fixed in the PFF data, # both teams are being "carried over" in the event. self.teams = teams - self.period = get_period_by_id(self.raw_event["gameEvents"]["period"], periods) - self.team = get_team_by_id(self.raw_event["gameEvents"]["teamId"], teams) + self.period = get_period_by_id( + self.raw_event["gameEvents"]["period"], periods + ) + self.team = get_team_by_id( + self.raw_event["gameEvents"]["teamId"], teams + ) self.possession_team = get_team_by_id( self.raw_event["gameEvents"]["teamId"], teams ) self.player = ( - self.team.get_player_by_id(self.raw_event["gameEvents"]["playerId"]) - if self.team and self.raw_event['gameEvents']['playerId'] is not None + self.team.get_player_by_id( + self.raw_event["gameEvents"]["playerId"] + ) + if self.team + and self.raw_event["gameEvents"]["playerId"] is not None else None ) self.related_events = [ events.get(event_id) for event_id in events.keys() - if event_id.split('_')[0] == self.raw_event.get("gameEventId", "") + if event_id.split("_")[0] == self.raw_event.get("gameEventId", "") ] return self @@ -251,8 +263,10 @@ def _parse_generic_kwargs(self) -> dict: "raw_event": self.raw_event, } - def _create_foul(self, event_factory: EventFactory, **generic_event_kwargs) -> list[CardEvent | FoulCommittedEvent]: - foul_type = self.raw_event['fouls'].get('foulType') + def _create_foul( + self, event_factory: EventFactory, **generic_event_kwargs + ) -> list[CardEvent | FoulCommittedEvent]: + foul_type = self.raw_event["fouls"].get("foulType") if foul_type != FOUL_TYPE.INFRIGEMENT.value: return [] @@ -263,14 +277,14 @@ def _create_foul(self, event_factory: EventFactory, **generic_event_kwargs) -> l FOUL_OUTCOME.RED: CardType.RED, } - committer_id = self.raw_event['fouls']['finalCulpritPlayerId'] + committer_id = self.raw_event["fouls"]["finalCulpritPlayerId"] team = next(t for t in self.teams if t.get_player_by_id(committer_id)) - generic_event_kwargs['team'] = team - generic_event_kwargs['player'] = team.get_player_by_id(committer_id) - generic_event_kwargs['ball_state'] = BallState.DEAD + generic_event_kwargs["team"] = team + generic_event_kwargs["player"] = team.get_player_by_id(committer_id) + generic_event_kwargs["ball_state"] = BallState.DEAD - foul_outcome = self.raw_event['fouls']['finalFoulOutcomeType'] + foul_outcome = self.raw_event["fouls"]["finalFoulOutcomeType"] card_type = card_map.get(FOUL_OUTCOME(foul_outcome)) card_qualifier = [CardQualifier(value=card_type)] if card_type else [] @@ -295,7 +309,7 @@ def _create_foul(self, event_factory: EventFactory, **generic_event_kwargs) -> l card = [] return foul + card - + def _create_events( self, event_factory: EventFactory, **generic_event_kwargs ) -> list[Event]: @@ -314,22 +328,19 @@ class SUBSTITUTION(EVENT): def _create_events( self, event_factory: EventFactory, **generic_event_kwargs ) -> list[Event]: - # As of now, PFF Substitution events do not set teamId. + # As of now, PFF Substitution events do not set teamId. # team = generic_event_kwargs['team'] player_off_id = self.raw_event["gameEvents"]["playerOffId"] player_on_id = self.raw_event["gameEvents"]["playerOnId"] - team = next( - t for t in self.teams - if t.get_player_by_id(player_off_id) - ) + team = next(t for t in self.teams if t.get_player_by_id(player_off_id)) player_off = team.get_player_by_id(player_off_id) player_on = team.get_player_by_id(player_on_id) - generic_event_kwargs['team'] = team - generic_event_kwargs['player'] = player_off + generic_event_kwargs["team"] = team + generic_event_kwargs["player"] = player_off return [ event_factory.build_substitution( @@ -340,6 +351,7 @@ def _create_events( ) ] + class PLAYER_OFF(EVENT): """PFF Player Off event.""" @@ -432,6 +444,7 @@ def _create_events( class CLEARANCE(POSSESSION_EVENT): """PFF Clearance event.""" + def _create_events( self, event_factory: EventFactory, **generic_event_kwargs ) -> list[Event]: @@ -443,8 +456,10 @@ def _create_events( ) ] + class CHALLENGE(POSSESSION_EVENT): """PFF Challenge event.""" + def _create_events( self, event_factory: EventFactory, **generic_event_kwargs ) -> list[Event]: @@ -456,8 +471,10 @@ def _create_events( ) ] + class TAKE_ON(POSSESSION_EVENT): """PFF Dribble event.""" + def _create_events( self, event_factory: EventFactory, **generic_event_kwargs ) -> list[Event]: @@ -487,6 +504,7 @@ def _create_events( class PRESSURE(POSSESSION_EVENT): """PFF Pressure event.""" + def _create_events( self, event_factory: EventFactory, **generic_event_kwargs ) -> list[Event]: @@ -498,8 +516,10 @@ def _create_events( ) ] + class INTERCEPTION(POSSESSION_EVENT): """PFF Interception event.""" + def _create_events( self, event_factory: EventFactory, **generic_event_kwargs ) -> list[Event]: @@ -511,8 +531,10 @@ def _create_events( ) ] + class RECOVERY(POSSESSION_EVENT): """PFF Recovery event.""" + def _create_events( self, event_factory: EventFactory, **generic_event_kwargs ) -> list[Event]: @@ -524,8 +546,10 @@ def _create_events( ) ] + class MISCONTROL(POSSESSION_EVENT): """PFF Miscontrol event.""" + def _create_events( self, event_factory: EventFactory, **generic_event_kwargs ) -> list[Event]: @@ -537,6 +561,7 @@ def _create_events( ) ] + class GOALKEEPER(POSSESSION_EVENT): """PFF Goalkeeper event.""" From 30c2449bc43e1aa1d5dc43dd72dde93291d316ca Mon Sep 17 00:00:00 2001 From: Thiago Costa Porto Date: Fri, 6 Jun 2025 13:54:18 +0200 Subject: [PATCH 15/33] feat(pff): add starting coordinates --- kloppy/infra/serializers/event/pff/helpers.py | 24 ++++++++++++------- .../serializers/event/pff/specification.py | 4 ++-- 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/kloppy/infra/serializers/event/pff/helpers.py b/kloppy/infra/serializers/event/pff/helpers.py index edf55a5ae..db55b8216 100644 --- a/kloppy/infra/serializers/event/pff/helpers.py +++ b/kloppy/infra/serializers/event/pff/helpers.py @@ -38,22 +38,28 @@ def get_period_by_id(period_id: int, periods: list[Period]) -> Period: def parse_coordinates( - player_id: str, player_coordinates: list[dict[str, object]] -) -> Point: + player: Player | None, raw_event: dict[str, object] +) -> Point | None: """Parse PFF coordinates into a kloppy Point.""" + if player is None: + return None + + players = raw_event["homePlayers"] + raw_event["awayPlayers"] + try: - player = next( - player - for player in player_coordinates - if str(player["playerId"]) == player_id + player_dict = next( + player_dict + for player_dict in players + if str(player_dict["playerId"]) == player.player_id ) return Point( - x=player["x"], - y=player["y"], + x=player_dict["x"], + y=player_dict["y"], ) except StopIteration: - raise DeserializationError(f"Unknown player_id {player_id}") + print(player) + raise DeserializationError(f"Unknown player {player}") def parse_freeze_frame( diff --git a/kloppy/infra/serializers/event/pff/specification.py b/kloppy/infra/serializers/event/pff/specification.py index 8c57985d2..fcbd048ac 100644 --- a/kloppy/infra/serializers/event/pff/specification.py +++ b/kloppy/infra/serializers/event/pff/specification.py @@ -259,7 +259,7 @@ def _parse_generic_kwargs(self) -> dict: "event_id": self.raw_event["gameEventId"], "team": self.team, "player": self.player, - "coordinates": None, + "coordinates": parse_coordinates(self.player, self.raw_event), "raw_event": self.raw_event, } @@ -407,7 +407,7 @@ def _parse_generic_kwargs(self) -> dict: "event_id": self.raw_event["possessionEventId"], "team": self.team, "player": self.player, - "coordinates": None, + "coordinates": parse_coordinates(self.player, self.raw_event), "raw_event": self.raw_event, } From a4f125f7ae52453138995e1d569c1e7a5b05584b Mon Sep 17 00:00:00 2001 From: Thiago Costa Porto Date: Fri, 6 Jun 2025 13:57:37 +0200 Subject: [PATCH 16/33] feat(pff): add shot outcome --- .../serializers/event/pff/specification.py | 38 ++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/kloppy/infra/serializers/event/pff/specification.py b/kloppy/infra/serializers/event/pff/specification.py index fcbd048ac..385735e58 100644 --- a/kloppy/infra/serializers/event/pff/specification.py +++ b/kloppy/infra/serializers/event/pff/specification.py @@ -430,12 +430,48 @@ def _create_events( class SHOT(POSSESSION_EVENT): """PFF Shot event.""" + class OUTCOME(Enum, metaclass=TypesEnumMeta): + ON_TARGET_BLOCKED = "B" + OFF_TARGET_BLOCKED = "C" + SAVED_OFF_TARGET = "F" + GOAL = "G" + GOAL_LINE_CLEARANCE = "L" + OFF_TARGET = "O" + ON_TARGET = "S" + + @staticmethod + def shot_outcome_to_result(outcome: OUTCOME) -> ShotResult | None: + outcome_map = { + SHOT.OUTCOME.ON_TARGET_BLOCKED: ShotResult.BLOCKED, + SHOT.OUTCOME.OFF_TARGET_BLOCKED: ShotResult.BLOCKED, + SHOT.OUTCOME.SAVED_OFF_TARGET: ShotResult.SAVED, + SHOT.OUTCOME.GOAL: ShotResult.GOAL, + SHOT.OUTCOME.GOAL_LINE_CLEARANCE: ShotResult.BLOCKED, + SHOT.OUTCOME.OFF_TARGET: ShotResult.OFF_TARGET, + SHOT.OUTCOME.ON_TARGET: ShotResult.SAVED, + } + return outcome_map.get(outcome) + def _create_events( self, event_factory: EventFactory, **generic_event_kwargs ) -> list[Event]: + raw_outcome = self.raw_event["possessionEvents"]["shotOutcomeType"] + if raw_outcome is None: + return [ + event_factory.build_generic( + result=None, + qualifiers=None, + event_name=self.raw_event["gameEvents"]["gameEventType"], + **generic_event_kwargs, + ) + ] + + outcome = SHOT.OUTCOME(raw_outcome) + result = self.shot_outcome_to_result(outcome) + return [ event_factory.build_shot( - result=None, + result=result, qualifiers=None, **generic_event_kwargs, ) From 3579b0142644ef027d021e8dbb6db00ecd853c84 Mon Sep 17 00:00:00 2001 From: Thiago Costa Porto Date: Fri, 6 Jun 2025 15:29:11 +0200 Subject: [PATCH 17/33] feat(pff): pff to kloppy body part mapping --- .../serializers/event/pff/specification.py | 46 ++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/kloppy/infra/serializers/event/pff/specification.py b/kloppy/infra/serializers/event/pff/specification.py index 385735e58..641b67046 100644 --- a/kloppy/infra/serializers/event/pff/specification.py +++ b/kloppy/infra/serializers/event/pff/specification.py @@ -141,7 +141,7 @@ class POSSESSION_EVENT_TYPE(Enum, metaclass=TypesEnumMeta): EVT_START = "IT" -class BODYPART(Enum, metaclass=TypesEnumMeta): +class PFF_BODYPART(Enum, metaclass=TypesEnumMeta): """The list of body parts used in PFF data.""" BACK = "BA" @@ -612,6 +612,50 @@ def _create_events( ) ] +def get_body_part_qualifier(pff_body_part_type: str) -> BodyPartQualifier | None: + pff_to_kloppy_body_part = { + PFF_BODYPART.HEAD: BodyPart.HEAD, + + PFF_BODYPART.LEFT_FOOT: BodyPart.LEFT_FOOT, + PFF_BODYPART.LEFT_BACK_HEEL: BodyPart.LEFT_FOOT, + PFF_BODYPART.LEFT_SHIN: BodyPart.LEFT_FOOT, + PFF_BODYPART.LEFT_THIGH: BodyPart.LEFT_FOOT, + PFF_BODYPART.LEFT_KNEE: BodyPart.LEFT_FOOT, + + PFF_BODYPART.RIGHT_FOOT: BodyPart.RIGHT_FOOT, + PFF_BODYPART.RIGHT_BACK_HEEL: BodyPart.RIGHT_FOOT, + PFF_BODYPART.RIGHT_SHIN: BodyPart.RIGHT_FOOT, + PFF_BODYPART.RIGHT_THIGH: BodyPart.RIGHT_FOOT, + PFF_BODYPART.RIGHT_KNEE: BodyPart.RIGHT_FOOT, + + PFF_BODYPART.LEFT_ARM: BodyPart.LEFT_HAND, + PFF_BODYPART.LEFT_HAND: BodyPart.LEFT_HAND, + PFF_BODYPART.LEFT_SHOULDER: BodyPart.LEFT_HAND, + + PFF_BODYPART.RIGHT_ARM: BodyPart.RIGHT_HAND, + PFF_BODYPART.RIGHT_HAND: BodyPart.RIGHT_HAND, + PFF_BODYPART.RIGHT_SHOULDER: BodyPart.RIGHT_HAND, + + PFF_BODYPART.TWO_HAND_PALM: BodyPart.BOTH_HANDS, + PFF_BODYPART.TWO_HAND_CATCH: BodyPart.BOTH_HANDS, + PFF_BODYPART.TWO_HAND_PUNCH: BodyPart.BOTH_HANDS, + PFF_BODYPART.TWO_HANDS: BodyPart.BOTH_HANDS, + + PFF_BODYPART.BACK: BodyPart.OTHER, + PFF_BODYPART.BOTTOM: BodyPart.OTHER, + + PFF_BODYPART.CHEST: BodyPart.CHEST, + } + + try: + pff_body_part = PFF_BODYPART(pff_body_part_type) + body_part = pff_to_kloppy_body_part[pff_body_part] + return BodyPartQualifier(value=body_part) + except ValueError: + raise DeserializationError( + f"Unknown PFF body part: {pff_body_part_type}" + ) + def possession_event_decoder(raw_event: dict) -> POSSESSION_EVENT: type_to_possession_event = { From be3519db9a705c4c162dd8bf12689c890484d8d1 Mon Sep 17 00:00:00 2001 From: Thiago Costa Porto Date: Fri, 6 Jun 2025 15:29:45 +0200 Subject: [PATCH 18/33] feat(pff): add function to collect qualifiers --- kloppy/infra/serializers/event/pff/helpers.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/kloppy/infra/serializers/event/pff/helpers.py b/kloppy/infra/serializers/event/pff/helpers.py index db55b8216..ec3793945 100644 --- a/kloppy/infra/serializers/event/pff/helpers.py +++ b/kloppy/infra/serializers/event/pff/helpers.py @@ -13,6 +13,7 @@ PositionType, Team, ) +from kloppy.domain.models.event import QualifierT from kloppy.domain.services.frame_factory import create_frame from kloppy.exceptions import DeserializationError @@ -58,10 +59,13 @@ def parse_coordinates( y=player_dict["y"], ) except StopIteration: - print(player) raise DeserializationError(f"Unknown player {player}") +def collect_qualifiers(*qualifiers: QualifierT | None) -> list[QualifierT]: + return [q for q in qualifiers if q is not None] + + def parse_freeze_frame( freeze_frame: List[Dict], home_team: Team, From 3016389e7edb5ca4da1b06d57a362681c0bca938 Mon Sep 17 00:00:00 2001 From: Thiago Costa Porto Date: Fri, 6 Jun 2025 15:30:38 +0200 Subject: [PATCH 19/33] feat(pff): pff to kloppy set pieces mapping --- .../serializers/event/pff/specification.py | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/kloppy/infra/serializers/event/pff/specification.py b/kloppy/infra/serializers/event/pff/specification.py index 641b67046..f1720afb5 100644 --- a/kloppy/infra/serializers/event/pff/specification.py +++ b/kloppy/infra/serializers/event/pff/specification.py @@ -171,6 +171,18 @@ class PFF_BODYPART(Enum, metaclass=TypesEnumMeta): VIDEO_MISSING = "VM" +class PFF_SET_PIECE(Enum, metaclass=TypesEnumMeta): + """The list of set piece types used in PFF data.""" + + CORNER = 'C' + DROP_BALL = 'D' + FREE_KICK = 'F' + GOAL_KICK = 'G' + KICK_OFF = 'K' + PENALTY = 'P' + THROW_IN = 'T' + + class FOUL_TYPE(Enum, metaclass=TypesEnumMeta): ADVANTAGE = "A" INFRIGEMENT = "I" @@ -612,6 +624,30 @@ def _create_events( ) ] +def get_set_piece_qualifier(pff_set_piece_type: str) -> SetPieceQualifier | None: + # PFF sets 'O' for... no set piece? + if pff_set_piece_type == 'O': + return None + + pff_to_kloppy_set_piece = { + PFF_SET_PIECE.GOAL_KICK: SetPieceType.GOAL_KICK, + PFF_SET_PIECE.FREE_KICK: SetPieceType.FREE_KICK, + PFF_SET_PIECE.THROW_IN: SetPieceType.THROW_IN, + PFF_SET_PIECE.CORNER: SetPieceType.CORNER_KICK, + PFF_SET_PIECE.PENALTY: SetPieceType.PENALTY, + PFF_SET_PIECE.KICK_OFF: SetPieceType.KICK_OFF, + } + + try: + pff_set_piece = PFF_SET_PIECE(pff_set_piece_type) + set_piece_type = pff_to_kloppy_set_piece[pff_set_piece] + return SetPieceQualifier(value=set_piece_type) + except ValueError: + raise DeserializationError( + f"Can't map PFF set piece type: {pff_set_piece_type}" + ) + + def get_body_part_qualifier(pff_body_part_type: str) -> BodyPartQualifier | None: pff_to_kloppy_body_part = { PFF_BODYPART.HEAD: BodyPart.HEAD, From e9807bdc2a2ed3cdb732aa497e823a6e75ed149e Mon Sep 17 00:00:00 2001 From: Thiago Costa Porto Date: Fri, 6 Jun 2025 15:31:10 +0200 Subject: [PATCH 20/33] feat(pff): add body part and set piece qualifiers to shots --- kloppy/infra/serializers/event/pff/specification.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/kloppy/infra/serializers/event/pff/specification.py b/kloppy/infra/serializers/event/pff/specification.py index f1720afb5..2b3c9b407 100644 --- a/kloppy/infra/serializers/event/pff/specification.py +++ b/kloppy/infra/serializers/event/pff/specification.py @@ -37,6 +37,7 @@ ) from kloppy.exceptions import DeserializationError from kloppy.infra.serializers.event.pff.helpers import ( + collect_qualifiers, get_period_by_id, get_team_by_id, parse_coordinates, @@ -481,10 +482,18 @@ def _create_events( outcome = SHOT.OUTCOME(raw_outcome) result = self.shot_outcome_to_result(outcome) + body_part_value = self.raw_event['possessionEvents']['bodyType'] + body_part = get_body_part_qualifier(body_part_value) + + set_piece_value = self.raw_event['gameEvents']['setpieceType'] + set_piece = get_set_piece_qualifier(set_piece_value) + + qualifiers = collect_qualifiers(body_part, set_piece) + return [ event_factory.build_shot( result=result, - qualifiers=None, + qualifiers=qualifiers, **generic_event_kwargs, ) ] From 129889450fd2704a334d642f9e913ef792c2b0d7 Mon Sep 17 00:00:00 2001 From: Thiago Costa Porto Date: Fri, 6 Jun 2025 17:32:41 +0200 Subject: [PATCH 21/33] fix: related events --- kloppy/infra/serializers/event/pff/deserializer.py | 5 ----- kloppy/infra/serializers/event/pff/specification.py | 10 ++++++++-- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/kloppy/infra/serializers/event/pff/deserializer.py b/kloppy/infra/serializers/event/pff/deserializer.py index 9b5b37130..4797ce0ac 100644 --- a/kloppy/infra/serializers/event/pff/deserializer.py +++ b/kloppy/infra/serializers/event/pff/deserializer.py @@ -43,22 +43,17 @@ def deserialize( # Intialize coordinate system transformer self.transformer = self.get_transformer() - # Load data from JSON files - # and determine fidelity versions for x/y coordinates with performance_logging("load data", logger=logger): metadata = json.load(inputs.metadata) players = json.load(inputs.players) raw_events = self.load_raw_events(inputs.raw_event_data) - # Create teams and players with performance_logging("parse teams ans players", logger=logger): teams = self.create_teams_and_players(metadata, players) - # Create periods with performance_logging("parse periods", logger=logger): periods = self.create_periods(raw_events) - # Create events with performance_logging("parse events", logger=logger): events = [] for raw_event in raw_events.values(): diff --git a/kloppy/infra/serializers/event/pff/specification.py b/kloppy/infra/serializers/event/pff/specification.py index 2b3c9b407..d9918e263 100644 --- a/kloppy/infra/serializers/event/pff/specification.py +++ b/kloppy/infra/serializers/event/pff/specification.py @@ -218,15 +218,19 @@ def set_refs(self, periods, teams, events): # the team using the player id. Until this is fixed in the PFF data, # both teams are being "carried over" in the event. self.teams = teams + self.period = get_period_by_id( self.raw_event["gameEvents"]["period"], periods ) + self.team = get_team_by_id( self.raw_event["gameEvents"]["teamId"], teams ) + self.possession_team = get_team_by_id( self.raw_event["gameEvents"]["teamId"], teams ) + self.player = ( self.team.get_player_by_id( self.raw_event["gameEvents"]["playerId"] @@ -235,11 +239,13 @@ def set_refs(self, periods, teams, events): and self.raw_event["gameEvents"]["playerId"] is not None else None ) + self.related_events = [ - events.get(event_id) + events[event_id] for event_id in events.keys() - if event_id.split("_")[0] == self.raw_event.get("gameEventId", "") + if event_id.split("_")[0] == str(self.raw_event["gameEventId"]) ] + return self def deserialize(self, event_factory: EventFactory) -> list[Event]: From 7c4a0366846870ccca9bd4bac04e1e33e1d3091f Mon Sep 17 00:00:00 2001 From: Thiago Costa Porto Date: Fri, 6 Jun 2025 17:33:41 +0200 Subject: [PATCH 22/33] moving general qualis to evt/possevt classes --- .../serializers/event/pff/specification.py | 139 +++++++++--------- 1 file changed, 71 insertions(+), 68 deletions(-) diff --git a/kloppy/infra/serializers/event/pff/specification.py b/kloppy/infra/serializers/event/pff/specification.py index d9918e263..4fd87bbe1 100644 --- a/kloppy/infra/serializers/event/pff/specification.py +++ b/kloppy/infra/serializers/event/pff/specification.py @@ -269,6 +269,77 @@ def deserialize(self, event_factory: EventFactory) -> list[Event]: # return events (note: order is important) return base_events + foul_events + def _get_set_piece_qualifier(self) -> SetPieceQualifier | None: + pff_set_piece_type = self.raw_event['gameEvents']['setpieceType'] + + if pff_set_piece_type is None or pff_set_piece_type == 'O': + return None + + pff_to_kloppy_set_piece = { + PFF_SET_PIECE.GOAL_KICK: SetPieceType.GOAL_KICK, + PFF_SET_PIECE.FREE_KICK: SetPieceType.FREE_KICK, + PFF_SET_PIECE.THROW_IN: SetPieceType.THROW_IN, + PFF_SET_PIECE.CORNER: SetPieceType.CORNER_KICK, + PFF_SET_PIECE.PENALTY: SetPieceType.PENALTY, + PFF_SET_PIECE.KICK_OFF: SetPieceType.KICK_OFF, + } + + try: + pff_set_piece = PFF_SET_PIECE(pff_set_piece_type) + set_piece_type = pff_to_kloppy_set_piece[pff_set_piece] + return SetPieceQualifier(value=set_piece_type) + except KeyError: + return None + + def _get_body_part_qualifier(self) -> BodyPartQualifier | None: + """Get the body part qualifier from the PFF body part type.""" + + pff_body_part_type = self.raw_event['possessionEvents']['bodyType'] + + if pff_body_part_type is None: + return None + + pff_to_kloppy_body_part = { + PFF_BODYPART.HEAD: BodyPart.HEAD, + + PFF_BODYPART.LEFT_FOOT: BodyPart.LEFT_FOOT, + PFF_BODYPART.LEFT_BACK_HEEL: BodyPart.LEFT_FOOT, + PFF_BODYPART.LEFT_SHIN: BodyPart.LEFT_FOOT, + PFF_BODYPART.LEFT_THIGH: BodyPart.LEFT_FOOT, + PFF_BODYPART.LEFT_KNEE: BodyPart.LEFT_FOOT, + + PFF_BODYPART.RIGHT_FOOT: BodyPart.RIGHT_FOOT, + PFF_BODYPART.RIGHT_BACK_HEEL: BodyPart.RIGHT_FOOT, + PFF_BODYPART.RIGHT_SHIN: BodyPart.RIGHT_FOOT, + PFF_BODYPART.RIGHT_THIGH: BodyPart.RIGHT_FOOT, + PFF_BODYPART.RIGHT_KNEE: BodyPart.RIGHT_FOOT, + + PFF_BODYPART.LEFT_ARM: BodyPart.LEFT_HAND, + PFF_BODYPART.LEFT_HAND: BodyPart.LEFT_HAND, + PFF_BODYPART.LEFT_SHOULDER: BodyPart.LEFT_HAND, + + PFF_BODYPART.RIGHT_ARM: BodyPart.RIGHT_HAND, + PFF_BODYPART.RIGHT_HAND: BodyPart.RIGHT_HAND, + PFF_BODYPART.RIGHT_SHOULDER: BodyPart.RIGHT_HAND, + + PFF_BODYPART.TWO_HAND_PALM: BodyPart.BOTH_HANDS, + PFF_BODYPART.TWO_HAND_CATCH: BodyPart.BOTH_HANDS, + PFF_BODYPART.TWO_HAND_PUNCH: BodyPart.BOTH_HANDS, + PFF_BODYPART.TWO_HANDS: BodyPart.BOTH_HANDS, + + PFF_BODYPART.BACK: BodyPart.OTHER, + PFF_BODYPART.BOTTOM: BodyPart.OTHER, + + PFF_BODYPART.CHEST: BodyPart.CHEST, + } + + try: + pff_body_part = PFF_BODYPART(pff_body_part_type) + body_part = pff_to_kloppy_body_part[pff_body_part] + return BodyPartQualifier(value=body_part) + except KeyError: + return None + def _parse_generic_kwargs(self) -> dict: return { "period": self.period, @@ -639,74 +710,6 @@ def _create_events( ) ] -def get_set_piece_qualifier(pff_set_piece_type: str) -> SetPieceQualifier | None: - # PFF sets 'O' for... no set piece? - if pff_set_piece_type == 'O': - return None - - pff_to_kloppy_set_piece = { - PFF_SET_PIECE.GOAL_KICK: SetPieceType.GOAL_KICK, - PFF_SET_PIECE.FREE_KICK: SetPieceType.FREE_KICK, - PFF_SET_PIECE.THROW_IN: SetPieceType.THROW_IN, - PFF_SET_PIECE.CORNER: SetPieceType.CORNER_KICK, - PFF_SET_PIECE.PENALTY: SetPieceType.PENALTY, - PFF_SET_PIECE.KICK_OFF: SetPieceType.KICK_OFF, - } - - try: - pff_set_piece = PFF_SET_PIECE(pff_set_piece_type) - set_piece_type = pff_to_kloppy_set_piece[pff_set_piece] - return SetPieceQualifier(value=set_piece_type) - except ValueError: - raise DeserializationError( - f"Can't map PFF set piece type: {pff_set_piece_type}" - ) - - -def get_body_part_qualifier(pff_body_part_type: str) -> BodyPartQualifier | None: - pff_to_kloppy_body_part = { - PFF_BODYPART.HEAD: BodyPart.HEAD, - - PFF_BODYPART.LEFT_FOOT: BodyPart.LEFT_FOOT, - PFF_BODYPART.LEFT_BACK_HEEL: BodyPart.LEFT_FOOT, - PFF_BODYPART.LEFT_SHIN: BodyPart.LEFT_FOOT, - PFF_BODYPART.LEFT_THIGH: BodyPart.LEFT_FOOT, - PFF_BODYPART.LEFT_KNEE: BodyPart.LEFT_FOOT, - - PFF_BODYPART.RIGHT_FOOT: BodyPart.RIGHT_FOOT, - PFF_BODYPART.RIGHT_BACK_HEEL: BodyPart.RIGHT_FOOT, - PFF_BODYPART.RIGHT_SHIN: BodyPart.RIGHT_FOOT, - PFF_BODYPART.RIGHT_THIGH: BodyPart.RIGHT_FOOT, - PFF_BODYPART.RIGHT_KNEE: BodyPart.RIGHT_FOOT, - - PFF_BODYPART.LEFT_ARM: BodyPart.LEFT_HAND, - PFF_BODYPART.LEFT_HAND: BodyPart.LEFT_HAND, - PFF_BODYPART.LEFT_SHOULDER: BodyPart.LEFT_HAND, - - PFF_BODYPART.RIGHT_ARM: BodyPart.RIGHT_HAND, - PFF_BODYPART.RIGHT_HAND: BodyPart.RIGHT_HAND, - PFF_BODYPART.RIGHT_SHOULDER: BodyPart.RIGHT_HAND, - - PFF_BODYPART.TWO_HAND_PALM: BodyPart.BOTH_HANDS, - PFF_BODYPART.TWO_HAND_CATCH: BodyPart.BOTH_HANDS, - PFF_BODYPART.TWO_HAND_PUNCH: BodyPart.BOTH_HANDS, - PFF_BODYPART.TWO_HANDS: BodyPart.BOTH_HANDS, - - PFF_BODYPART.BACK: BodyPart.OTHER, - PFF_BODYPART.BOTTOM: BodyPart.OTHER, - - PFF_BODYPART.CHEST: BodyPart.CHEST, - } - - try: - pff_body_part = PFF_BODYPART(pff_body_part_type) - body_part = pff_to_kloppy_body_part[pff_body_part] - return BodyPartQualifier(value=body_part) - except ValueError: - raise DeserializationError( - f"Unknown PFF body part: {pff_body_part_type}" - ) - def possession_event_decoder(raw_event: dict) -> POSSESSION_EVENT: type_to_possession_event = { From 84ce849e3ec5d4a0dc4ba2b53d4184effc6168cb Mon Sep 17 00:00:00 2001 From: Thiago Costa Porto Date: Fri, 6 Jun 2025 17:36:20 +0200 Subject: [PATCH 23/33] shot outcomes, see how this feels --- .../serializers/event/pff/specification.py | 35 ++++++++----------- 1 file changed, 14 insertions(+), 21 deletions(-) diff --git a/kloppy/infra/serializers/event/pff/specification.py b/kloppy/infra/serializers/event/pff/specification.py index 4fd87bbe1..f6d54b66a 100644 --- a/kloppy/infra/serializers/event/pff/specification.py +++ b/kloppy/infra/serializers/event/pff/specification.py @@ -529,8 +529,9 @@ class OUTCOME(Enum, metaclass=TypesEnumMeta): OFF_TARGET = "O" ON_TARGET = "S" + @staticmethod - def shot_outcome_to_result(outcome: OUTCOME) -> ShotResult | None: + def shot_outcome_to_result(raw_event: dict) -> ShotResult | None: outcome_map = { SHOT.OUTCOME.ON_TARGET_BLOCKED: ShotResult.BLOCKED, SHOT.OUTCOME.OFF_TARGET_BLOCKED: ShotResult.BLOCKED, @@ -540,31 +541,23 @@ def shot_outcome_to_result(outcome: OUTCOME) -> ShotResult | None: SHOT.OUTCOME.OFF_TARGET: ShotResult.OFF_TARGET, SHOT.OUTCOME.ON_TARGET: ShotResult.SAVED, } - return outcome_map.get(outcome) + + try: + outcome_type = raw_event['possessionEvents']['shotOutcomeType'] + outcome = SHOT.OUTCOME(outcome_type) + return outcome_map.get(outcome) + except KeyError: + raise DeserializationError( + 'Unable to map shot outcome from PFF' + ) def _create_events( self, event_factory: EventFactory, **generic_event_kwargs ) -> list[Event]: - raw_outcome = self.raw_event["possessionEvents"]["shotOutcomeType"] - if raw_outcome is None: - return [ - event_factory.build_generic( - result=None, - qualifiers=None, - event_name=self.raw_event["gameEvents"]["gameEventType"], - **generic_event_kwargs, - ) - ] - - outcome = SHOT.OUTCOME(raw_outcome) - result = self.shot_outcome_to_result(outcome) - - body_part_value = self.raw_event['possessionEvents']['bodyType'] - body_part = get_body_part_qualifier(body_part_value) + result = self.shot_outcome_to_result(self.raw_event) - set_piece_value = self.raw_event['gameEvents']['setpieceType'] - set_piece = get_set_piece_qualifier(set_piece_value) - + body_part = self._get_body_part_qualifier() + set_piece = self._get_set_piece_qualifier() qualifiers = collect_qualifiers(body_part, set_piece) return [ From 95a6ed31f46a7a1ba72a0bce855573facdbade3e Mon Sep 17 00:00:00 2001 From: Thiago Costa Porto Date: Fri, 6 Jun 2025 17:36:49 +0200 Subject: [PATCH 24/33] pass/cross mesh --- .../serializers/event/pff/specification.py | 117 +++++++++++++++++- 1 file changed, 115 insertions(+), 2 deletions(-) diff --git a/kloppy/infra/serializers/event/pff/specification.py b/kloppy/infra/serializers/event/pff/specification.py index f6d54b66a..d58f62671 100644 --- a/kloppy/infra/serializers/event/pff/specification.py +++ b/kloppy/infra/serializers/event/pff/specification.py @@ -505,13 +505,126 @@ def _parse_generic_kwargs(self) -> dict: class PASS(POSSESSION_EVENT): """PFF Pass event.""" + class TYPE(Enum, metaclass=TypesEnumMeta): + CUTBACK = 'B' + CREATE_CONTEST = 'C' + FLICK_ON = 'F' + LONG_THROW = 'H' + LONG_PASS = 'L' + MISS_HIT = 'M' + BALL_OVER_THE_TOP = 'O' + STANDARD_PASS = 'S' + THROUGH_BALL = 'T' + SWITCH = 'W' + + class OUTCOME(Enum, metaclass=TypesEnumMeta): + BLOCKED = 'B' + COMPLETE = 'C' + DEFENSIVE_INTERCEPTION = 'D' + INADVERTENT_SHOT_OWN_GOAL = 'G' + INADVERTENT_SHOT_GOAL = 'I' + OUT_OF_PLAY = 'O' + STOPPAGE = 'S' + + class CROSS_TYPE(Enum, metaclass=TypesEnumMeta): + DRILLED = 'D' + FLOATED = 'F' + SWING_IN = 'I' + SWING_OUT = 'O' + PLACED = 'P' + + class CROSS_OUTCOME(Enum, metaclass=TypesEnumMeta): + BLOCKED = 'B' + COMPLETE = 'C' + DEFENSIVE_INTERCEPTION = 'D' + INADVERTENT_SHOT_GOAL = 'I' + OUT_OF_PLAY = 'O' + STOPPAGE = 'S' + UNTOUCHED = 'U' + + class HEIGHT(Enum, metaclass=TypesEnumMeta): + ABOVE_HEAD = "A" + GROUND = "G" + BETWEEN_WAIST_AND_HEAD = "H" + OFF_GROUND_BUT_BELOW_WAIST = "L" + VIDEO_MISSING = "M" + HALF_VOLLEY = "V" + + + @staticmethod + def pass_outcome_to_result(raw_event: dict) -> PassResult | None: + outcome_type = ( + raw_event['possessionEvents']['passOutcomeType'] + or raw_event['possessionEvents']['crossOutcomeType'] + ) + + if outcome_type is None: + return None + + outcome_mapping = { + PASS.OUTCOME.COMPLETE: PassResult.COMPLETE, + PASS.OUTCOME.OUT_OF_PLAY: PassResult.OUT, + PASS.OUTCOME.BLOCKED: PassResult.INCOMPLETE, + PASS.OUTCOME.DEFENSIVE_INTERCEPTION: PassResult.INCOMPLETE, + PASS.OUTCOME.INADVERTENT_SHOT_OWN_GOAL: None, + PASS.OUTCOME.INADVERTENT_SHOT_GOAL: None, + PASS.OUTCOME.STOPPAGE: None, + + PASS.CROSS_OUTCOME.COMPLETE: PassResult.COMPLETE, + PASS.CROSS_OUTCOME.OUT_OF_PLAY: PassResult.OUT, + PASS.CROSS_OUTCOME.BLOCKED: PassResult.INCOMPLETE, + PASS.CROSS_OUTCOME.DEFENSIVE_INTERCEPTION: PassResult.INCOMPLETE, + PASS.CROSS_OUTCOME.INADVERTENT_SHOT_GOAL: None, + PASS.CROSS_OUTCOME.UNTOUCHED: PassResult.INCOMPLETE, + PASS.CROSS_OUTCOME.STOPPAGE: None, + } + + pff_outcome = PASS.OUTCOME(outcome_type) + return outcome_mapping[pff_outcome] + + + def _get_pass_qualifiers( + self, body_part: BodyPartQualifier | None + ) -> list[PassQualifier]: + qualifiers = [] + + if self.raw_event['possessionEvents']['possessionEventType'] == 'CR': + qualifiers.append(PassQualifier(value=PassType.CROSS)) + + pass_type = self.raw_event['possessionEvents']['passType'] + if pass_type is not None: + pass_type = PASS.TYPE(pass_type) + if pass_type == PASS.TYPE.THROUGH_BALL: + qualifiers.append(PassQualifier(value=PassType.THROUGH_BALL)) + if pass_type == PASS.TYPE.FLICK_ON: + qualifiers.append(PassQualifier(value=PassType.FLICK_ON)) + if pass_type == PASS.TYPE.STANDARD_PASS: + qualifiers.append(PassQualifier(value=PassType.SIMPLE_PASS)) + + if body_part is not None: + if body_part.value in [BodyPart.LEFT_HAND, BodyPart.RIGHT_HAND]: + qualifiers.append(PassQualifier(value=PassType.HAND_PASS)) + + if body_part.value == BodyPart.HEAD: + qualifiers.append(PassQualifier(value=PassType.HEAD_PASS)) + + return qualifiers + def _create_events( self, event_factory: EventFactory, **generic_event_kwargs ) -> list[Event]: + set_piece = self._get_set_piece_qualifier() + body_part = self._get_body_part_qualifier() + pass_quals = self._get_pass_qualifiers(body_part) + + qualifiers = collect_qualifiers(body_part, set_piece, *pass_quals) + + result = self.pass_outcome_to_result(self.raw_event) + return [ event_factory.build_pass( - result=None, - qualifiers=None, + result=result, + qualifiers=qualifiers, **generic_event_kwargs, ) ] From 792b6cab263d4657ddb51cfc6c334e68644bb892 Mon Sep 17 00:00:00 2001 From: Thiago Costa Porto Date: Mon, 9 Jun 2025 02:03:28 +0200 Subject: [PATCH 25/33] small tidbits --- .../infra/serializers/event/pff/deserializer.py | 8 ++++++-- kloppy/infra/serializers/event/pff/helpers.py | 10 ++++++++-- .../infra/serializers/event/pff/specification.py | 16 +++++++++++----- 3 files changed, 25 insertions(+), 9 deletions(-) diff --git a/kloppy/infra/serializers/event/pff/deserializer.py b/kloppy/infra/serializers/event/pff/deserializer.py index 4797ce0ac..6de8adc27 100644 --- a/kloppy/infra/serializers/event/pff/deserializer.py +++ b/kloppy/infra/serializers/event/pff/deserializer.py @@ -88,9 +88,11 @@ def load_raw_events( self, raw_event_data: IO[bytes] ) -> dict[str, PFF.EVENT]: raw_events = {} - for event in json.load(raw_event_data): + events = json.load(raw_event_data) + events = sorted(events, key=lambda x: x['eventTime']) + for event in events: event_id = ( - f"{event['gameEventId']}_{event['possessionEventId']}" + f"{event['gameEventId']}_{event['possessionEventId']}_{event['gameEvents']['gameEventType']}_{event['eventTime']}" if event["possessionEventId"] is not None else f"{event['gameEventId']}" ) @@ -104,6 +106,7 @@ def create_team(team_id, team_name, ground_type): name=team_name, ground=ground_type, ) + team.players = [ Player( player_id=entry["player"]["id"], @@ -118,6 +121,7 @@ def create_team(team_id, team_name, ground_type): for entry in players if entry["team"]["id"] == team_id ] + return team home_team = metadata["homeTeam"] diff --git a/kloppy/infra/serializers/event/pff/helpers.py b/kloppy/infra/serializers/event/pff/helpers.py index ec3793945..8dd6c1c45 100644 --- a/kloppy/infra/serializers/event/pff/helpers.py +++ b/kloppy/infra/serializers/event/pff/helpers.py @@ -1,5 +1,5 @@ from datetime import timedelta -from typing import Dict, List, Optional +from typing import Dict, List, Optional, Union from kloppy.domain import ( ActionValue, @@ -18,7 +18,7 @@ from kloppy.exceptions import DeserializationError -def get_team_by_id(team_id: int | None, teams: list[Team]) -> Team | None: +def get_team_by_id(team_id: Optional[int], teams: list[Team]) -> Optional[Team]: """Get a team by its id.""" if team_id is None: return None @@ -38,6 +38,12 @@ def get_period_by_id(period_id: int, periods: list[Period]) -> Period: raise DeserializationError(f"Unknown period_id {period_id}") +def find_player(player_id: Union[int, str], teams: list[Team]) -> Optional[Player]: + for team in teams: + player = team.get_player_by_id(player_id) + if player is not None: + return player + def parse_coordinates( player: Player | None, raw_event: dict[str, object] ) -> Point | None: diff --git a/kloppy/infra/serializers/event/pff/specification.py b/kloppy/infra/serializers/event/pff/specification.py index d58f62671..16a9fc7cf 100644 --- a/kloppy/infra/serializers/event/pff/specification.py +++ b/kloppy/infra/serializers/event/pff/specification.py @@ -489,12 +489,18 @@ def _create_events( class POSSESSION_EVENT(EVENT): def _parse_generic_kwargs(self) -> dict: + event_id = ( + self.raw_event["possessionEventId"] + if self.raw_event["possessionEventId"] is not None + else self.raw_event["gameEventId"] + ) + return { "period": self.period, "timestamp": timedelta(seconds=self.raw_event["eventTime"]), "ball_owning_team": self.possession_team, "ball_state": BallState.ALIVE, - "event_id": self.raw_event["possessionEventId"], + "event_id": event_id, "team": self.team, "player": self.player, "coordinates": parse_coordinates(self.player, self.raw_event), @@ -697,7 +703,7 @@ def _create_events( ] -class CHALLENGE(POSSESSION_EVENT): +class DUEL(POSSESSION_EVENT): """PFF Challenge event.""" def _create_events( @@ -823,11 +829,11 @@ def possession_event_decoder(raw_event: dict) -> POSSESSION_EVENT: POSSESSION_EVENT_TYPE.PASS: PASS, POSSESSION_EVENT_TYPE.SHOT: SHOT, POSSESSION_EVENT_TYPE.CLEARANCE: CLEARANCE, - POSSESSION_EVENT_TYPE.BALL_CARRY: POSSESSION_EVENT, - POSSESSION_EVENT_TYPE.CHALLENGE: CHALLENGE, + POSSESSION_EVENT_TYPE.BALL_CARRY: CARRY, + POSSESSION_EVENT_TYPE.CHALLENGE: DUEL, POSSESSION_EVENT_TYPE.REBOUND: POSSESSION_EVENT, POSSESSION_EVENT_TYPE.TOUCHES: POSSESSION_EVENT, - POSSESSION_EVENT_TYPE.EVT_START: POSSESSION_EVENT, + POSSESSION_EVENT_TYPE.EVT_START: BALL_RECEIPT, } p_evt_type = raw_event["possessionEvents"]["possessionEventType"] From f09118ff41d6445c996cdcc73b283c094dc42f35 Mon Sep 17 00:00:00 2001 From: Thiago Costa Porto Date: Mon, 9 Jun 2025 02:18:57 +0200 Subject: [PATCH 26/33] small update to shot outcomes and result --- .../serializers/event/pff/specification.py | 27 ++++++++++--------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/kloppy/infra/serializers/event/pff/specification.py b/kloppy/infra/serializers/event/pff/specification.py index 16a9fc7cf..0aca8320c 100644 --- a/kloppy/infra/serializers/event/pff/specification.py +++ b/kloppy/infra/serializers/event/pff/specification.py @@ -1,6 +1,6 @@ from datetime import timedelta from enum import Enum, EnumMeta -from typing import NamedTuple +from typing import List, Dict, NamedTuple, Union from kloppy.domain import ( BallState, @@ -648,9 +648,17 @@ class OUTCOME(Enum, metaclass=TypesEnumMeta): OFF_TARGET = "O" ON_TARGET = "S" + @property + def outcome(self) -> Union[OUTCOME, None]: + try: + return self.OUTCOME(self.possession_event['shotOutcomeType']) + except Exception: + return None + + def _shot_outcome_to_result(self) -> ShotResult | None: + if self.outcome is None: + return None - @staticmethod - def shot_outcome_to_result(raw_event: dict) -> ShotResult | None: outcome_map = { SHOT.OUTCOME.ON_TARGET_BLOCKED: ShotResult.BLOCKED, SHOT.OUTCOME.OFF_TARGET_BLOCKED: ShotResult.BLOCKED, @@ -661,23 +669,16 @@ def shot_outcome_to_result(raw_event: dict) -> ShotResult | None: SHOT.OUTCOME.ON_TARGET: ShotResult.SAVED, } - try: - outcome_type = raw_event['possessionEvents']['shotOutcomeType'] - outcome = SHOT.OUTCOME(outcome_type) - return outcome_map.get(outcome) - except KeyError: - raise DeserializationError( - 'Unable to map shot outcome from PFF' - ) + return outcome_map.get(self.outcome) def _create_events( self, event_factory: EventFactory, **generic_event_kwargs ) -> list[Event]: - result = self.shot_outcome_to_result(self.raw_event) - body_part = self._get_body_part_qualifier() set_piece = self._get_set_piece_qualifier() + qualifiers = collect_qualifiers(body_part, set_piece) + result = self._shot_outcome_to_result() return [ event_factory.build_shot( From 6545bcf8e31c617a4014f2fd7cdc8c779adf091c Mon Sep 17 00:00:00 2001 From: Thiago Costa Porto Date: Mon, 9 Jun 2025 02:19:33 +0200 Subject: [PATCH 27/33] shortcut to game and possession events --- kloppy/infra/serializers/event/pff/specification.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/kloppy/infra/serializers/event/pff/specification.py b/kloppy/infra/serializers/event/pff/specification.py index 0aca8320c..c3ad26cac 100644 --- a/kloppy/infra/serializers/event/pff/specification.py +++ b/kloppy/infra/serializers/event/pff/specification.py @@ -210,9 +210,17 @@ class EVENT: raw_event: The raw JSON event. """ - def __init__(self, raw_event: dict): + def __init__(self, raw_event: Dict): self.raw_event = raw_event + @property + def game_event(self) -> Dict[str, Union[int, float, str, bool, None]]: + return self.raw_event['gameEvents'] + + @property + def possession_event(self) -> Dict[str, Union[int, float, str, bool, None]]: + return self.raw_event['possessionEvents'] + def set_refs(self, periods, teams, events): # temp: some PFF events do not have a 'teamId' assigned but we can get # the team using the player id. Until this is fixed in the PFF data, From 31fba3cc037dc96010c81c80db890ad05ba0255c Mon Sep 17 00:00:00 2001 From: Thiago Costa Porto Date: Mon, 9 Jun 2025 02:20:19 +0200 Subject: [PATCH 28/33] update to pass outcome --- .../serializers/event/pff/specification.py | 37 ++++++++++--------- 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/kloppy/infra/serializers/event/pff/specification.py b/kloppy/infra/serializers/event/pff/specification.py index c3ad26cac..60e6f4b6f 100644 --- a/kloppy/infra/serializers/event/pff/specification.py +++ b/kloppy/infra/serializers/event/pff/specification.py @@ -564,37 +564,41 @@ class HEIGHT(Enum, metaclass=TypesEnumMeta): VIDEO_MISSING = "M" HALF_VOLLEY = "V" - - @staticmethod - def pass_outcome_to_result(raw_event: dict) -> PassResult | None: - outcome_type = ( - raw_event['possessionEvents']['passOutcomeType'] - or raw_event['possessionEvents']['crossOutcomeType'] - ) + @property + def outcome(self) -> Union[OUTCOME, CROSS_OUTCOME, None]: + try: + return ( + self.OUTCOME(self.possession_event['passOutcomeType']) + or self.CROSS_OUTCOME( + self.possession_event['crossOutcomeType'] + ) + ) + except Exception: + return None - if outcome_type is None: + def _pass_outcome_to_result(self) -> PassResult | None: + if self.outcome is None: return None outcome_mapping = { PASS.OUTCOME.COMPLETE: PassResult.COMPLETE, - PASS.OUTCOME.OUT_OF_PLAY: PassResult.OUT, PASS.OUTCOME.BLOCKED: PassResult.INCOMPLETE, PASS.OUTCOME.DEFENSIVE_INTERCEPTION: PassResult.INCOMPLETE, + PASS.OUTCOME.OUT_OF_PLAY: PassResult.OUT, PASS.OUTCOME.INADVERTENT_SHOT_OWN_GOAL: None, PASS.OUTCOME.INADVERTENT_SHOT_GOAL: None, PASS.OUTCOME.STOPPAGE: None, PASS.CROSS_OUTCOME.COMPLETE: PassResult.COMPLETE, - PASS.CROSS_OUTCOME.OUT_OF_PLAY: PassResult.OUT, PASS.CROSS_OUTCOME.BLOCKED: PassResult.INCOMPLETE, PASS.CROSS_OUTCOME.DEFENSIVE_INTERCEPTION: PassResult.INCOMPLETE, - PASS.CROSS_OUTCOME.INADVERTENT_SHOT_GOAL: None, PASS.CROSS_OUTCOME.UNTOUCHED: PassResult.INCOMPLETE, + PASS.CROSS_OUTCOME.OUT_OF_PLAY: PassResult.OUT, + PASS.CROSS_OUTCOME.INADVERTENT_SHOT_GOAL: None, PASS.CROSS_OUTCOME.STOPPAGE: None, } - pff_outcome = PASS.OUTCOME(outcome_type) - return outcome_mapping[pff_outcome] + return outcome_mapping[self.outcome] def _get_pass_qualifiers( @@ -602,10 +606,10 @@ def _get_pass_qualifiers( ) -> list[PassQualifier]: qualifiers = [] - if self.raw_event['possessionEvents']['possessionEventType'] == 'CR': + if self.possession_event['possessionEventType'] == 'CR': qualifiers.append(PassQualifier(value=PassType.CROSS)) - pass_type = self.raw_event['possessionEvents']['passType'] + pass_type = self.possession_event['passType'] if pass_type is not None: pass_type = PASS.TYPE(pass_type) if pass_type == PASS.TYPE.THROUGH_BALL: @@ -632,8 +636,7 @@ def _create_events( pass_quals = self._get_pass_qualifiers(body_part) qualifiers = collect_qualifiers(body_part, set_piece, *pass_quals) - - result = self.pass_outcome_to_result(self.raw_event) + result = self._pass_outcome_to_result() return [ event_factory.build_pass( From 62295486514ac52d9776c481fc1ca37dbb00ccd0 Mon Sep 17 00:00:00 2001 From: Thiago Costa Porto Date: Mon, 9 Jun 2025 02:21:30 +0200 Subject: [PATCH 29/33] feat(pff): duels --- .../serializers/event/pff/specification.py | 233 +++++++++++++++++- 1 file changed, 221 insertions(+), 12 deletions(-) diff --git a/kloppy/infra/serializers/event/pff/specification.py b/kloppy/infra/serializers/event/pff/specification.py index 60e6f4b6f..3128cfcb6 100644 --- a/kloppy/infra/serializers/event/pff/specification.py +++ b/kloppy/infra/serializers/event/pff/specification.py @@ -718,32 +718,241 @@ def _create_events( class DUEL(POSSESSION_EVENT): """PFF Challenge event.""" - def _create_events( - self, event_factory: EventFactory, **generic_event_kwargs + class TYPE(Enum, metaclass=TypesEnumMeta): + AERIAL_DUEL = 'A' + FROM_BEHIND = 'B' + DRIBBLE = 'D' + FIFTY = 'FIFTY' + GK_SMOTHERS = 'G' + SHIELDING = 'H' + HAND_TACKLE = 'K' # GK specific event + SLIDE_TACKLE = 'L' + SHOULDER_TO_SHOULDER = 'S' + STANDING_TACKLE = 'T' + + class OUTCOME(Enum, metaclass=TypesEnumMeta): + DISTRIBUTION_DISRUPTED = 'B' + FORCED_OUT_OF_PLAY = 'C' + DISTRIBUTES_BALL = 'D' + FOUL = 'F' + SHIELDS_IN_PLAY = 'I' + KEEPS_BALL_WITH_CONTACT = 'K' + ROLLS = 'L' + BEATS_MAN_LOSES_BALL = 'M' + NO_WIN_KEEP_BALL = 'N' + OUT_OF_PLAY = 'O' + PLAYER = 'P' + RETAIN = 'R' + SHIELDS_OUT_OF_PLAY = 'S' + + @property + def outcome(self): + try: + return self.OUTCOME( + self.raw_event['possessionEvents']['challengeOutcomeType'] + ) + except Exception: + return None + + @property + def duel_type(self): + try: + return self.TYPE( + self.raw_event['possessionEvents']['challengeType'] + ) + except Exception: + return None + + def _handle_dribble( + self, event_factory: EventFactory, **generic_event_kwargs ) -> list[Event]: + if self.outcome in [ + DUEL.OUTCOME.OUT_OF_PLAY, + DUEL.OUTCOME.FORCED_OUT_OF_PLAY + ]: + result = TakeOnResult.OUT + elif self.outcome in [ + DUEL.OUTCOME.NO_WIN_KEEP_BALL, + DUEL.OUTCOME.ROLLS, + DUEL.OUTCOME.DISTRIBUTION_DISRUPTED, + DUEL.OUTCOME.DISTRIBUTES_BALL, + DUEL.OUTCOME.PLAYER, + ]: + result = TakeOnResult.INCOMPLETE + else: + result = TakeOnResult.COMPLETE + return [ - event_factory.build_duel( - result=None, - qualifiers=None, - **generic_event_kwargs, + event_factory.build_take_on( + result=result, + qualifiers=[DuelQualifier(value=DuelType.GROUND)], + **generic_event_kwargs ) ] + def _handle_aerial( + self, event_factory: EventFactory, **generic_event_kwargs + ) -> list[Event]: + events = [] -class TAKE_ON(POSSESSION_EVENT): - """PFF Dribble event.""" + qualifiers = [ + DuelQualifier(value=DuelType.LOOSE_BALL), + DuelQualifier(value=DuelType.AERIAL) + ] - def _create_events( + aerialChallengeColumns = [ + 'homeDuelPlayerId', + 'awayDuelPlayerId', + 'challengeKeeperPlayerId', + 'additionalDuelerPlayerId' + ] + + players_involved = [ + find_player(self.possession_event[col], self.teams) + for col in aerialChallengeColumns + if self.raw_event['possessionEvents'][col] is not None + ] + + winner = find_player( + self.raw_event['possessionEvents']['challengeWinnerPlayerId'], + self.teams + ) + + if winner is None: + player_duel_result = [ + (player, DuelResult.NEUTRAL) + for player in players_involved + if player is not None + ] + else: + player_duel_result = [ + ( + player, + ( + DuelResult.WON + if player.team == winner.team + else DuelResult.LOST + ) + ) + for player in players_involved + if player is not None + ] + + for (player, result) in player_duel_result: + kwargs = deepcopy(generic_event_kwargs) + kwargs['team'] = player.team + kwargs['player'] = player + events.append( + event_factory.build_duel( + result=result, + qualifiers=qualifiers, + **kwargs, + ) + ) + + return events + + def _handle_tackle( + self, event_factory: EventFactory, **generic_event_kwargs + ) -> list[Event]: + events = [] + + qualifiers = [DuelQualifier(value=DuelType.GROUND)] + + if self.duel_type == DUEL.TYPE.SLIDE_TACKLE: + qualifiers.append(DuelQualifier(value=DuelType.SLIDING_TACKLE)) + elif self.duel_type == DUEL.TYPE.FIFTY: + qualifiers.append(DuelQualifier(value=DuelType.LOOSE_BALL)) + + challengeColumns = [ + 'ballCarrierPlayerId', + 'challengerPlayerId', + 'challengeKeeperPlayerId', + 'additionalDuelerPlayerId', + ] + + players_involved = [ + find_player(self.raw_event['possessionEvents'][col], self.teams) + for col in challengeColumns + if self.raw_event['possessionEvents'][col] is not None + ] + + winner = find_player( + self.raw_event['possessionEvents']['challengeWinnerPlayerId'], + self.teams + ) + + if not winner: + player_duel_result = [ + (player, DuelResult.NEUTRAL) + for player in players_involved + if player is not None + ] + else: + player_duel_result = [ + ( + player, + ( + DuelResult.WON + if player.team == winner.team + else DuelResult.LOST + ) + ) + for player in players_involved + if player is not None + ] + + for (player, result) in player_duel_result: + kwargs = deepcopy(generic_event_kwargs) + kwargs['team'] = player.team + kwargs['player'] = player + events.append( + event_factory.build_duel( + result=result, + qualifiers=qualifiers, + **kwargs, + ) + ) + + return events + + def _handle_gk( self, event_factory: EventFactory, **generic_event_kwargs ) -> list[Event]: + qualifiers = [ + GoalkeeperQualifier(value=GoalkeeperActionType.SMOTHER) + ] + return [ - event_factory.build_take_on( + event_factory.build_goalkeeper_event( result=None, - qualifiers=None, - **generic_event_kwargs, + qualifiers=qualifiers, + **generic_event_kwargs ) ] + def _create_events( + self, event_factory: EventFactory, **generic_event_kwargs + ) -> list[Event]: + if self.duel_type == DUEL.TYPE.DRIBBLE: + return self._handle_dribble(event_factory, **generic_event_kwargs) + elif self.duel_type == DUEL.TYPE.AERIAL_DUEL: + return self._handle_aerial(event_factory, **generic_event_kwargs) + elif self.duel_type in [ + DUEL.TYPE.SLIDE_TACKLE, + DUEL.TYPE.FIFTY, + DUEL.TYPE.FROM_BEHIND, + DUEL.TYPE.STANDING_TACKLE, + DUEL.TYPE.SHOULDER_TO_SHOULDER, + DUEL.TYPE.SHIELDING, + DUEL.TYPE.HAND_TACKLE # GK Event + ]: + return self._handle_tackle(event_factory, **generic_event_kwargs) + elif self.duel_type == DUEL.TYPE.GK_SMOTHERS: + return self._handle_gk(event_factory, **generic_event_kwargs) + + return [event_factory.build_generic(**generic_event_kwargs)] + class CARRY(POSSESSION_EVENT): """PFF Carry event.""" From c0ab46fa10223a70d4af31a9fbdd9890a81bde5f Mon Sep 17 00:00:00 2001 From: Thiago Costa Porto Date: Mon, 9 Jun 2025 02:22:43 +0200 Subject: [PATCH 30/33] feat(pff): update carry --- .../serializers/event/pff/specification.py | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/kloppy/infra/serializers/event/pff/specification.py b/kloppy/infra/serializers/event/pff/specification.py index 3128cfcb6..1d5efa603 100644 --- a/kloppy/infra/serializers/event/pff/specification.py +++ b/kloppy/infra/serializers/event/pff/specification.py @@ -957,13 +957,33 @@ def _create_events( class CARRY(POSSESSION_EVENT): """PFF Carry event.""" + class OUTCOME(Enum, metaclass=TypesEnumMeta): + RETAIN = 'R' + STOPPAGE = 'S' + BALL_LOSS = 'L' + LEADS_INTO_CHALLENGE = 'C' + + @property + def outcome(self): + try: + return self.OUTCOME(self.possession_event['ballCarryOutcome']) + except Exception: + return None + def _create_events( self, event_factory: EventFactory, **generic_event_kwargs ) -> list[Event]: + if self.outcome == CARRY.OUTCOME.BALL_LOSS: + result = CarryResult.INCOMPLETE + else: + result = CarryResult.COMPLETE + return [ event_factory.build_carry( - result=None, + result=result, qualifiers=None, + end_timestamp=timedelta(seconds=self.raw_event['endTime']), + end_coordinates=None, **generic_event_kwargs, ) ] From b735fdb5352be8041141b275cca5f94ff080c853 Mon Sep 17 00:00:00 2001 From: Thiago Costa Porto Date: Mon, 9 Jun 2025 02:23:29 +0200 Subject: [PATCH 31/33] feat(pff): handle foul event --- kloppy/infra/serializers/event/pff/specification.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/kloppy/infra/serializers/event/pff/specification.py b/kloppy/infra/serializers/event/pff/specification.py index 1d5efa603..bdf4756bc 100644 --- a/kloppy/infra/serializers/event/pff/specification.py +++ b/kloppy/infra/serializers/event/pff/specification.py @@ -125,6 +125,7 @@ class EVENT_TYPE(Enum, metaclass=TypesEnumMeta): PAUSE_OF_GAME_TIME = "PAU" SUB = "SUB" VIDEO = "VID" + FOUL = "FOUL" class POSSESSION_EVENT_TYPE(Enum, metaclass=TypesEnumMeta): @@ -1075,6 +1076,7 @@ def possession_event_decoder(raw_event: dict) -> POSSESSION_EVENT: POSSESSION_EVENT_TYPE.REBOUND: POSSESSION_EVENT, POSSESSION_EVENT_TYPE.TOUCHES: POSSESSION_EVENT, POSSESSION_EVENT_TYPE.EVT_START: BALL_RECEIPT, + POSSESSION_EVENT_TYPE.FOUL: POSSESSION_EVENT, } p_evt_type = raw_event["possessionEvents"]["possessionEventType"] @@ -1100,6 +1102,7 @@ def event_decoder(raw_event: dict) -> EVENT: EVENT_TYPE.SUB: SUBSTITUTION, EVENT_TYPE.PLAYER_ON: PLAYER_ON, EVENT_TYPE.PLAYER_OFF: PLAYER_OFF, + EVENT_TYPE.FOUL: EVENT, } event_type = EVENT_TYPE(raw_event["gameEvents"]["gameEventType"]) From facb56f03cab4c4c25d7fec93d12ba1bb375f0dd Mon Sep 17 00:00:00 2001 From: Thiago Costa Porto Date: Mon, 9 Jun 2025 02:24:20 +0200 Subject: [PATCH 32/33] feat(pff): handle ball receipt --- .../infra/serializers/event/pff/specification.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/kloppy/infra/serializers/event/pff/specification.py b/kloppy/infra/serializers/event/pff/specification.py index bdf4756bc..b7b694961 100644 --- a/kloppy/infra/serializers/event/pff/specification.py +++ b/kloppy/infra/serializers/event/pff/specification.py @@ -701,6 +701,22 @@ def _create_events( ] +class BALL_RECEIPT(POSSESSION_EVENT): + """PFF IT event.""" + + def _create_events( + self, event_factory: EventFactory, **generic_event_kwargs + ) -> list[Event]: + return [ + event_factory.build_generic( + result=None, + qualifiers=None, + event_name="RECEIVAL", + **generic_event_kwargs, + ) + ] + + class CLEARANCE(POSSESSION_EVENT): """PFF Clearance event.""" From 02e69cf91c497fdd6b163564f28235c165d428ba Mon Sep 17 00:00:00 2001 From: Thiago Costa Porto Date: Mon, 9 Jun 2025 02:26:50 +0200 Subject: [PATCH 33/33] fix imports and minor refactor --- kloppy/infra/serializers/event/pff/deserializer.py | 3 ++- kloppy/infra/serializers/event/pff/specification.py | 8 ++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/kloppy/infra/serializers/event/pff/deserializer.py b/kloppy/infra/serializers/event/pff/deserializer.py index 6de8adc27..cf1fb4618 100644 --- a/kloppy/infra/serializers/event/pff/deserializer.py +++ b/kloppy/infra/serializers/event/pff/deserializer.py @@ -16,7 +16,8 @@ Provider, Team, ) -from kloppy.domain.models.pitch import PitchDimensions +from kloppy.domain.models.event import Event, EventType +from kloppy.domain.models.pitch import PitchDimensions, Point from kloppy.exceptions import DeserializationError from kloppy.infra.serializers.event.deserializer import EventDataDeserializer from kloppy.utils import performance_logging diff --git a/kloppy/infra/serializers/event/pff/specification.py b/kloppy/infra/serializers/event/pff/specification.py index b7b694961..8730bc914 100644 --- a/kloppy/infra/serializers/event/pff/specification.py +++ b/kloppy/infra/serializers/event/pff/specification.py @@ -1,3 +1,4 @@ +from copy import deepcopy from datetime import timedelta from enum import Enum, EnumMeta from typing import List, Dict, NamedTuple, Union @@ -38,6 +39,7 @@ from kloppy.exceptions import DeserializationError from kloppy.infra.serializers.event.pff.helpers import ( collect_qualifiers, + find_player, get_period_by_id, get_team_by_id, parse_coordinates, @@ -273,13 +275,15 @@ def deserialize(self, event_factory: EventFactory) -> list[Event]: event_factory, **generic_event_kwargs ) - foul_events = self._create_foul(event_factory, **generic_event_kwargs) + foul_events = self._create_foul( + event_factory, **generic_event_kwargs + ) # return events (note: order is important) return base_events + foul_events def _get_set_piece_qualifier(self) -> SetPieceQualifier | None: - pff_set_piece_type = self.raw_event['gameEvents']['setpieceType'] + pff_set_piece_type = self.game_event['setpieceType'] if pff_set_piece_type is None or pff_set_piece_type == 'O': return None