From e9e9efb282e42dd2e69ec05ba3cb5ae522dbe3c0 Mon Sep 17 00:00:00 2001 From: driesdeprest Date: Fri, 14 Feb 2025 13:42:39 +0100 Subject: [PATCH 1/3] add shot result z coordinates for wyscout V3 --- .../event/wyscout/deserializer_v3.py | 158 +++++++++--------- kloppy/tests/test_wyscout.py | 7 +- 2 files changed, 82 insertions(+), 83 deletions(-) diff --git a/kloppy/infra/serializers/event/wyscout/deserializer_v3.py b/kloppy/infra/serializers/event/wyscout/deserializer_v3.py index c29cfd568..933b5e0e1 100644 --- a/kloppy/infra/serializers/event/wyscout/deserializer_v3.py +++ b/kloppy/infra/serializers/event/wyscout/deserializer_v3.py @@ -4,7 +4,7 @@ from dataclasses import replace from datetime import datetime, timedelta, timezone from enum import Enum -from typing import Dict, List, Optional +from typing import Dict, List, Optional, Union from kloppy.domain import ( BodyPart, @@ -39,6 +39,7 @@ CarryResult, ExpectedGoals, PostShotExpectedGoals, + Point3D, ) from kloppy.exceptions import DeserializationError, DeserializationWarning from kloppy.utils import performance_logging @@ -110,24 +111,74 @@ def _flip_point(point: Point) -> Point: return Point(x=100 - point.x, y=100 - point.y) -class ShotZoneResults(str, Enum): - GOAL_BOTTOM_LEFT = "glb" - GOAL_BOTTOM_RIGHT = "grb" - GOAL_BOTTOM_CENTER = "gb" - GOAL_CENTER_LEFT = "gl" - GOAL_CENTER = "gc" - GOAL_CENTER_RIGHT = "gr" - GOAL_TOP_LEFT = "glt" - GOAL_TOP_RIGHT = "grt" - GOAL_TOP_CENTER = "gt" - OUT_BOTTOM_RIGHT = "obr" - OUT_BOTTOM_LEFT = "olb" - OUT_RIGHT = "or" - OUT_LEFT = "ol" - OUT_LEFT_TOP = "olt" - OUT_TOP = "ot" - OUT_RIGHT_TOP = "ort" - BLOCKED = "bc" +class ShotZoneResults(Enum): + """ + Wyscout does not provide end-coordinates of shots. Instead shots on goal + are tagged with a zone. The zones and corresponding y and z-coordinates are depicted below. + + (z-coordinate of zone or post) + + olt | ot | ort 3.5 + -------------------------------- + ||=================|| 2.77 + ------------------------------- + || glt | gt | grt || 2 + -------------------------------- + ol || gl | gc | gr || or 1 + -------------------------------- + olb || glb | gb | grb || orb 0 + + 40 45 50 55 60 (y-coordinate of zone) + 44.62 55.38 (y-coordinate of post) + """ + + GOAL_BOTTOM_LEFT = ("glb", Point3D(100, 45, 0)) + GOAL_BOTTOM_CENTER = ("gb", Point3D(100, 50, 0)) + GOAL_BOTTOM_RIGHT = ("grb", Point3D(100, 55, 0)) + GOAL_CENTER_LEFT = ("gl", Point3D(100, 45, 1)) + GOAL_CENTER = ("gc", Point3D(100, 50, 1)) + GOAL_CENTER_RIGHT = ("gr", Point3D(100, 55, 1)) + GOAL_TOP_LEFT = ("glt", Point3D(100, 45, 2)) + GOAL_TOP_CENTER = ("gt", Point3D(100, 50, 2)) + GOAL_TOP_RIGHT = ("grt", Point3D(100, 55, 2)) + POST_BOTTOM_LEFT = ("plb", Point3D(100, 44.62, 0)) + POST_BOTTOM_RIGHT = ("pbr", Point3D(100, 55.38, 0)) + OUT_BOTTOM_LEFT = ("olb", Point3D(100, 40, 0)) + OUT_BOTTOM_RIGHT = ("obr", Point3D(100, 60, 0)) + POST_LEFT = ("pl", Point3D(100, 44.62, 1)) + POST_RIGHT = ("pr", Point3D(100, 55.38, 1)) + OUT_LEFT = ("ol", Point3D(100, 40, 1)) + OUT_RIGHT = ("or", Point3D(100, 60, 1)) + POST_TOP_LEFT = ("ptl", Point3D(100, 44.62, 2)) + POST_TOP_RIGHT = ("ptr", Point3D(100, 55.38, 2)) + OUT_TOP_LEFT = ("otl", Point3D(100, 40, 3.5)) + OUT_TOP_RIGHT = ("ort", Point3D(100, 60, 3.5)) + POST_TOP = ("pt", Point3D(100, 50, 2.77)) + OUT_TOP = ("ot", Point3D(100, 50, 3.5)) + BLOCKED = ("bc", None) + + def __init__(self, code: str, point: Point3D): + self._code = code + self._point = point + + @property + def code(self) -> str: + """Returns the string identifier of the shot result.""" + return self._code + + @property + def point(self) -> Point3D: + """Returns the predefined Point3D associated with the shot result.""" + return self._point + + @classmethod + def point_from_code(cls, code: str) -> Union[Point3D, None]: + """Retrieves the enum member from a string code.""" + for zone in cls: + if zone.code == code: + return zone.point + + warnings.warn(f"Invalid shot zone code: {code}") def _parse_team(raw_events, wyId: str, ground: Ground) -> Team: @@ -172,73 +223,18 @@ def _parse_team(raw_events, wyId: str, ground: Ground) -> Team: def _create_shot_result_coordinates(raw_event: Dict) -> Optional[Point]: - """Estimate the shot end location from the Wyscout tags. - - Wyscout does not provide end-coordinates of shots. Instead shots on goal - are tagged with a zone. This function maps each of these zones to - a coordinate. The zones and corresponding y-coordinate are depicted below. - - - olt | ot | ort - -------------------------------- - ||=================|| - ------------------------------- - || glt | gt | grt || - -------------------------------- - ol || gl | gc | gr || or - -------------------------------- - olb || glb | gb | grb || orb - - 40 45 50 55 60 (y-coordinate of zone) - 44.62 55.38 (y-coordiante of post) - """ - if ( - raw_event["shot"]["goalZone"] == ShotZoneResults.GOAL_BOTTOM_CENTER - or raw_event["shot"]["goalZone"] == ShotZoneResults.GOAL_CENTER - or raw_event["shot"]["goalZone"] == ShotZoneResults.GOAL_TOP_CENTER - ): - return Point(100.0, 50.0) - - if ( - raw_event["shot"]["goalZone"] == ShotZoneResults.GOAL_BOTTOM_RIGHT - or raw_event["shot"]["goalZone"] == ShotZoneResults.GOAL_CENTER_RIGHT - or raw_event["shot"]["goalZone"] == ShotZoneResults.GOAL_TOP_RIGHT - ): - return Point(100.0, 55.0) - - if ( - raw_event["shot"]["goalZone"] == ShotZoneResults.GOAL_BOTTOM_LEFT - or raw_event["shot"]["goalZone"] == ShotZoneResults.GOAL_CENTER_LEFT - or raw_event["shot"]["goalZone"] == ShotZoneResults.GOAL_TOP_LEFT - ): - return Point(100.0, 45.0) - - if raw_event["shot"]["goalZone"] == ShotZoneResults.OUT_TOP: - return Point(100.0, 50.0) - - if ( - raw_event["shot"]["goalZone"] == ShotZoneResults.OUT_RIGHT_TOP - or raw_event["shot"]["goalZone"] == ShotZoneResults.OUT_RIGHT - or raw_event["shot"]["goalZone"] == ShotZoneResults.OUT_BOTTOM_RIGHT - ): - return Point(100.0, 60.0) - - if ( - raw_event["shot"]["goalZone"] == ShotZoneResults.OUT_LEFT_TOP - or raw_event["shot"]["goalZone"] == ShotZoneResults.OUT_LEFT - or raw_event["shot"]["goalZone"] == ShotZoneResults.OUT_BOTTOM_LEFT - ): - return Point(100.0, 40.0) - - # If the shot is blocked, the start location is the best possible estimate - # for the shot's end location - if raw_event["shot"]["goalZone"] == ShotZoneResults.BLOCKED: + """Estimate the shot end location from the Wyscout tags.""" + if raw_event["shot"]["goalZone"] == ShotZoneResults.BLOCKED.code: return Point( x=float(raw_event["location"]["x"]), y=float(raw_event["location"]["y"]), ) - return None + else: + result_coordinates = ShotZoneResults.point_from_code( + raw_event["shot"]["goalZone"] + ) + return result_coordinates def _generic_qualifiers(raw_event: Dict) -> List[Qualifier]: diff --git a/kloppy/tests/test_wyscout.py b/kloppy/tests/test_wyscout.py index c1b752bf7..a0b65ff47 100644 --- a/kloppy/tests/test_wyscout.py +++ b/kloppy/tests/test_wyscout.py @@ -28,6 +28,7 @@ SetPieceType, ShotResult, Time, + Point3D, ) @@ -283,12 +284,14 @@ def test_shot_event(self, dataset: EventDataset): off_target_shot = dataset.get_event_by_id(1927028562) assert off_target_shot.event_type == EventType.SHOT assert off_target_shot.result == ShotResult.OFF_TARGET - assert off_target_shot.result_coordinates is None + assert off_target_shot.result_coordinates == Point3D( + x=100, y=40, z=3.5 + ) # on target shot on_target_shot = dataset.get_event_by_id(1927028637) assert on_target_shot.event_type == EventType.SHOT assert on_target_shot.result == ShotResult.SAVED - assert on_target_shot.result_coordinates == Point(100.0, 45.0) + assert on_target_shot.result_coordinates == Point3D(x=100, y=45, z=1) def test_foul_committed_event(self, dataset: EventDataset): foul_committed_event = dataset.get_event_by_id(1927028873) From 4a1cc5a6ee52cc4e749d2479a20a45c085bc5236 Mon Sep 17 00:00:00 2001 From: driesdeprest Date: Sun, 23 Mar 2025 11:32:24 +0100 Subject: [PATCH 2/3] fix shot zone labels --- .../event/wyscout/deserializer_v3.py | 58 +++++++++---------- 1 file changed, 26 insertions(+), 32 deletions(-) diff --git a/kloppy/infra/serializers/event/wyscout/deserializer_v3.py b/kloppy/infra/serializers/event/wyscout/deserializer_v3.py index 933b5e0e1..574405017 100644 --- a/kloppy/infra/serializers/event/wyscout/deserializer_v3.py +++ b/kloppy/infra/serializers/event/wyscout/deserializer_v3.py @@ -113,28 +113,32 @@ def _flip_point(point: Point) -> Point: class ShotZoneResults(Enum): """ - Wyscout does not provide end-coordinates of shots. Instead shots on goal - are tagged with a zone. The zones and corresponding y and z-coordinates are depicted below. - - (z-coordinate of zone or post) - - olt | ot | ort 3.5 - -------------------------------- - ||=================|| 2.77 - ------------------------------- - || glt | gt | grt || 2 - -------------------------------- - ol || gl | gc | gr || or 1 - -------------------------------- - olb || glb | gb | grb || orb 0 - - 40 45 50 55 60 (y-coordinate of zone) - 44.62 55.38 (y-coordinate of post) + Wyscout does not provide end-coordinates of shots. Instead shots on goal + are tagged with a zone. The zones and corresponding y and z-coordinates are depicted below. + + (z-coordinate of zone or post) + + olt | ot | otr 3.5 + -------------------------------- + ||=================|| 2.77 + ------------------------------- + || glt | gt | grt || 2 + -------------------------------- + ol || gl | gc | gr || or 1 + -------------------------------- + olb || glb | gb | gbr || orb 0 + + 40 45 50 55 60 (y-coordinate of zone) + 44.62 55.38 (y-coordinate of post) + + Attributes: + code (str): The string identifier of the shot result. + point (Point3D): The predefined Point3D associated with the shot result. """ GOAL_BOTTOM_LEFT = ("glb", Point3D(100, 45, 0)) GOAL_BOTTOM_CENTER = ("gb", Point3D(100, 50, 0)) - GOAL_BOTTOM_RIGHT = ("grb", Point3D(100, 55, 0)) + GOAL_BOTTOM_RIGHT = ("gbr", Point3D(100, 55, 0)) GOAL_CENTER_LEFT = ("gl", Point3D(100, 45, 1)) GOAL_CENTER = ("gc", Point3D(100, 50, 1)) GOAL_CENTER_RIGHT = ("gr", Point3D(100, 55, 1)) @@ -152,27 +156,17 @@ class ShotZoneResults(Enum): POST_TOP_LEFT = ("ptl", Point3D(100, 44.62, 2)) POST_TOP_RIGHT = ("ptr", Point3D(100, 55.38, 2)) OUT_TOP_LEFT = ("otl", Point3D(100, 40, 3.5)) - OUT_TOP_RIGHT = ("ort", Point3D(100, 60, 3.5)) + OUT_TOP_RIGHT = ("otr", Point3D(100, 60, 3.5)) POST_TOP = ("pt", Point3D(100, 50, 2.77)) OUT_TOP = ("ot", Point3D(100, 50, 3.5)) BLOCKED = ("bc", None) def __init__(self, code: str, point: Point3D): - self._code = code - self._point = point - - @property - def code(self) -> str: - """Returns the string identifier of the shot result.""" - return self._code - - @property - def point(self) -> Point3D: - """Returns the predefined Point3D associated with the shot result.""" - return self._point + self.code = code + self.point = point @classmethod - def point_from_code(cls, code: str) -> Union[Point3D, None]: + def point_from_code(cls, code: str) -> Optional[Point3D]: """Retrieves the enum member from a string code.""" for zone in cls: if zone.code == code: From a20d10403b53a83c26f6a99ab0cadda0c17ee09e Mon Sep 17 00:00:00 2001 From: driesdeprest Date: Wed, 2 Apr 2025 09:17:10 +0200 Subject: [PATCH 3/3] fix goal top left and right labels --- kloppy/infra/serializers/event/wyscout/deserializer_v3.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/kloppy/infra/serializers/event/wyscout/deserializer_v3.py b/kloppy/infra/serializers/event/wyscout/deserializer_v3.py index 574405017..1c1c24463 100644 --- a/kloppy/infra/serializers/event/wyscout/deserializer_v3.py +++ b/kloppy/infra/serializers/event/wyscout/deserializer_v3.py @@ -122,7 +122,7 @@ class ShotZoneResults(Enum): -------------------------------- ||=================|| 2.77 ------------------------------- - || glt | gt | grt || 2 + || gtl | gt | gtr || 2 -------------------------------- ol || gl | gc | gr || or 1 -------------------------------- @@ -142,9 +142,9 @@ class ShotZoneResults(Enum): GOAL_CENTER_LEFT = ("gl", Point3D(100, 45, 1)) GOAL_CENTER = ("gc", Point3D(100, 50, 1)) GOAL_CENTER_RIGHT = ("gr", Point3D(100, 55, 1)) - GOAL_TOP_LEFT = ("glt", Point3D(100, 45, 2)) + GOAL_TOP_LEFT = ("gtl", Point3D(100, 45, 2)) GOAL_TOP_CENTER = ("gt", Point3D(100, 50, 2)) - GOAL_TOP_RIGHT = ("grt", Point3D(100, 55, 2)) + GOAL_TOP_RIGHT = ("gtr", Point3D(100, 55, 2)) POST_BOTTOM_LEFT = ("plb", Point3D(100, 44.62, 0)) POST_BOTTOM_RIGHT = ("pbr", Point3D(100, 55.38, 0)) OUT_BOTTOM_LEFT = ("olb", Point3D(100, 40, 0))