From 45ab84c668f1f0947a97e776971569cb94e2661a Mon Sep 17 00:00:00 2001 From: UnravelSports <64530306+UnravelSports@users.noreply.github.com> Date: Tue, 17 Dec 2024 20:07:57 +0100 Subject: [PATCH] feat(sportec): add referees to metadata; fix(sportec): parsing tracking data with referee --------- Co-authored-by: UnravelSports [JB] Co-authored-by: Pieter Robberechts --- kloppy/domain/models/common.py | 43 +- .../serializers/event/sportec/deserializer.py | 38 + .../tracking/sportec/deserializer.py | 10 + .../files/sportec_positional_w_referee.xml | 671 ++++++++++++++++++ kloppy/tests/test_sportec.py | 56 ++ kloppy/utils.py | 5 + 6 files changed, 822 insertions(+), 1 deletion(-) create mode 100644 kloppy/tests/files/sportec_positional_w_referee.xml diff --git a/kloppy/domain/models/common.py b/kloppy/domain/models/common.py index c1830d1b..b4880451 100644 --- a/kloppy/domain/models/common.py +++ b/kloppy/domain/models/common.py @@ -20,7 +20,7 @@ from .position import PositionType -from ...utils import deprecated +from ...utils import deprecated, snake_case if sys.version_info >= (3, 8): from typing import Literal @@ -119,6 +119,46 @@ def __str__(self): return self.value +class OfficialType(Enum): + """Enumeration for types of officials (referees).""" + + VideoAssistantReferee = "Video Assistant Referee" + MainReferee = "Main Referee" + AssistantReferee = "Assistant Referee" + FourthOfficial = "Fourth Official" + + def __str__(self): + return self.value + + +@dataclass(frozen=True) +class Official: + """ + Represents an official (referee) with optional names and roles. + """ + + official_id: str + name: Optional[str] = None + first_name: Optional[str] = None + last_name: Optional[str] = None + role: Optional[OfficialType] = None + + @property + def full_name(self): + """ + Returns the full name of the official, falling back to role-based or ID-based naming. + """ + if self.name: + return self.name + if self.first_name and self.last_name: + return f"{self.first_name} {self.last_name}" + if self.last_name: + return self.last_name + if self.role: + return f"{snake_case(str(self.role))}_{self.official_id}" + return f"official_{self.official_id}" + + @dataclass(frozen=True) class Player: """ @@ -1016,6 +1056,7 @@ class Metadata: game_id: Optional[str] = None home_coach: Optional[str] = None away_coach: Optional[str] = None + officials: Optional[List] = field(default_factory=list) attributes: Optional[Dict] = field(default_factory=dict, compare=False) def __post_init__(self): diff --git a/kloppy/infra/serializers/event/sportec/deserializer.py b/kloppy/infra/serializers/event/sportec/deserializer.py index 14895206..57d105a4 100644 --- a/kloppy/infra/serializers/event/sportec/deserializer.py +++ b/kloppy/infra/serializers/event/sportec/deserializer.py @@ -29,6 +29,8 @@ CardType, AttackingDirection, PositionType, + Official, + OfficialType, ) from kloppy.exceptions import DeserializationError from kloppy.infra.serializers.event.deserializer import EventDataDeserializer @@ -55,6 +57,14 @@ "LA": PositionType.LeftWing, } +referee_types_mapping: Dict[str, OfficialType] = { + "referee": OfficialType.MainReferee, + "firstAssistant": OfficialType.AssistantReferee, + "videoReferee": OfficialType.VideoAssistantReferee, + "secondAssistant": OfficialType.AssistantReferee, + "fourthOfficial": OfficialType.FourthOfficial, +} + logger = logging.getLogger(__name__) @@ -102,6 +112,7 @@ class SportecMetadata(NamedTuple): fps: int home_coach: str away_coach: str + officials: List[Official] def sportec_metadata_from_xml_elm(match_root) -> SportecMetadata: @@ -213,6 +224,31 @@ def sportec_metadata_from_xml_elm(match_root) -> SportecMetadata: ] ) + if hasattr(match_root, "MatchInformation") and hasattr( + match_root.MatchInformation, "Referees" + ): + officials = [] + referee_path = objectify.ObjectPath( + "PutDataRequest.MatchInformation.Referees" + ) + referee_elms = referee_path.find(match_root).iterchildren( + tag="Referee" + ) + + for referee in referee_elms: + ref_attrib = referee.attrib + officials.append( + Official( + official_id=ref_attrib["PersonId"], + name=ref_attrib["Shortname"], + first_name=ref_attrib["FirstName"], + last_name=ref_attrib["LastName"], + role=referee_types_mapping[ref_attrib["Role"]], + ) + ) + else: + officials = [] + return SportecMetadata( score=score, teams=teams, @@ -222,6 +258,7 @@ def sportec_metadata_from_xml_elm(match_root) -> SportecMetadata: fps=SPORTEC_FPS, home_coach=home_coach, away_coach=away_coach, + officials=officials, ) @@ -673,6 +710,7 @@ def deserialize(self, inputs: SportecEventDataInputs) -> EventDataset: game_id=game_id, home_coach=home_coach, away_coach=away_coach, + officials=sportec_metadata.officials, ) return EventDataset( diff --git a/kloppy/infra/serializers/tracking/sportec/deserializer.py b/kloppy/infra/serializers/tracking/sportec/deserializer.py index 3f418375..7cc08516 100644 --- a/kloppy/infra/serializers/tracking/sportec/deserializer.py +++ b/kloppy/infra/serializers/tracking/sportec/deserializer.py @@ -122,6 +122,7 @@ def deserialize( with performance_logging("parse metadata", logger=logger): sportec_metadata = sportec_metadata_from_xml_elm(match_root) teams = home_team, away_team = sportec_metadata.teams + periods = sportec_metadata.periods transformer = self.get_transformer( pitch_length=sportec_metadata.x_max, @@ -130,6 +131,12 @@ def deserialize( home_coach = sportec_metadata.home_coach away_coach = sportec_metadata.away_coach + official_ids = [] + if sportec_metadata.officials: + official_ids = [ + x.official_id for x in sportec_metadata.officials + ] + with performance_logging("parse raw data", logger=logger): date = parse( match_root.MatchInformation.General.attrib["KickoffTime"] @@ -156,6 +163,7 @@ def _iter(): for i, (frame_id, frame_data) in enumerate( sorted(raw_frames.items()) ): + if "ball" not in frame_data: # Frames without ball data are corrupt. continue @@ -193,6 +201,7 @@ def _iter(): ) for player_id, raw_player_data in frame_data.items() if player_id != "ball" + and player_id not in official_ids }, other_data={}, ball_coordinates=Point3D( @@ -242,6 +251,7 @@ def _iter(): game_id=game_id, home_coach=home_coach, away_coach=away_coach, + officials=sportec_metadata.officials, ) return TrackingDataset( diff --git a/kloppy/tests/files/sportec_positional_w_referee.xml b/kloppy/tests/files/sportec_positional_w_referee.xml new file mode 100644 index 00000000..d9f12d8f --- /dev/null +++ b/kloppy/tests/files/sportec_positional_w_referee.xml @@ -0,0 +1,671 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/kloppy/tests/test_sportec.py b/kloppy/tests/test_sportec.py index 1c11bb78..ac8ad2de 100644 --- a/kloppy/tests/test_sportec.py +++ b/kloppy/tests/test_sportec.py @@ -16,6 +16,8 @@ BallState, Point3D, PositionType, + OfficialType, + Official, ) from kloppy import sportec @@ -119,6 +121,10 @@ class TestSportecTrackingData: def raw_data(self, base_dir) -> str: return base_dir / "files/sportec_positional.xml" + @pytest.fixture + def raw_data_referee(self, base_dir) -> str: + return base_dir / "files/sportec_positional_w_referee.xml" + @pytest.fixture def meta_data(self, base_dir) -> str: return base_dir / "files/sportec_meta.xml" @@ -145,6 +151,7 @@ def test_load_metadata(self, raw_data: Path, meta_data: Path): assert dataset.metadata.periods[1].end_timestamp == timedelta( seconds=4000 + 2996.68 ) + assert len(dataset.metadata.officials) == 4 def test_load_frames(self, raw_data: Path, meta_data: Path): dataset = sportec.load_tracking( @@ -238,3 +245,52 @@ def test_enriched_metadata(self, raw_data: Path, meta_data: Path): if away_coach: assert isinstance(away_coach, str) assert away_coach == "M. Rose" + + def test_referees(self, raw_data_referee: Path, meta_data: Path): + dataset = sportec.load_tracking( + raw_data=raw_data_referee, + meta_data=meta_data, + coordinates="sportec", + only_alive=True, + ) + assert len(dataset.metadata.officials) == 4 + + assert ( + Official( + official_id="42", + name="Pierluigi Collina", + role=OfficialType.MainReferee, + ).role.value + == "Main Referee" + ) + + assert ( + Official( + official_id="42", + name="Pierluigi Collina", + role=OfficialType.MainReferee, + ).full_name + == "Pierluigi Collina" + ) + assert ( + Official( + official_id="42", + first_name="Pierluigi", + last_name="Collina", + role=OfficialType.MainReferee, + ).full_name + == "Pierluigi Collina" + ) + assert ( + Official( + official_id="42", + last_name="Collina", + role=OfficialType.MainReferee, + ).full_name + == "Collina" + ) + assert ( + Official(official_id="42", role=OfficialType.MainReferee).full_name + == "main_referee_42" + ) + assert Official(official_id="42").full_name == "official_42" diff --git a/kloppy/utils.py b/kloppy/utils.py index b0858398..68d36af2 100644 --- a/kloppy/utils.py +++ b/kloppy/utils.py @@ -169,3 +169,8 @@ def __get__(self, instance, owner): stacklevel=2, ) return self.value + + +def snake_case(s: str) -> str: + """Convert a string to snake_case.""" + return re.sub(r"[\s\-]+", "_", s.strip()).lower()