Skip to content

Commit

Permalink
feat(Wyscout V3): estimate shot result coordinates (#320)
Browse files Browse the repository at this point in the history
---------

Co-authored-by: Pieter Robberechts <[email protected]>
  • Loading branch information
fubininho and probberechts authored Dec 14, 2024
1 parent 7d3c458 commit dff0204
Show file tree
Hide file tree
Showing 2 changed files with 146 additions and 48 deletions.
150 changes: 117 additions & 33 deletions kloppy/infra/serializers/event/wyscout/deserializer_v3.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,58 +2,49 @@
import logging
from dataclasses import replace
from datetime import timedelta, timezone
from enum import Enum
from typing import Dict, List, Optional

from dateutil.parser import parse
from typing import Dict, List

from kloppy.domain import (
BallOutEvent,
BodyPart,
BodyPartQualifier,
CardEvent,
CardType,
CarryResult,
CounterAttackQualifier,
Dimension,
DuelType,
DuelQualifier,
DuelResult,
DuelType,
EventDataset,
FoulCommittedEvent,
GenericEvent,
GoalkeeperQualifier,
FormationType,
GoalkeeperActionType,
GoalkeeperQualifier,
Ground,
InterceptionResult,
Metadata,
Orientation,
PassEvent,
PassQualifier,
PassResult,
PassType,
Period,
PitchDimensions,
Player,
Point,
PositionType,
Provider,
Qualifier,
RecoveryEvent,
SetPieceQualifier,
SetPieceType,
ShotEvent,
ShotResult,
TakeOnEvent,
TakeOnResult,
Team,
FormationType,
CarryResult,
PositionType,
)
from kloppy.exceptions import DeserializationError
from kloppy.utils import performance_logging

from ..deserializer import EventDataDeserializer
from .deserializer_v2 import WyscoutInputs


logger = logging.getLogger(__name__)


Expand Down Expand Up @@ -118,6 +109,26 @@ 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"


def _parse_team(raw_events, wyId: str, ground: Ground) -> Team:
# Get the first formation description
first_period_formation_info = raw_events["formations"][wyId]["1H"]
Expand Down Expand Up @@ -159,6 +170,76 @@ def _parse_team(raw_events, wyId: str, ground: Ground) -> Team:
return 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:
return Point(
x=float(raw_event["location"]["x"]),
y=float(raw_event["location"]["y"]),
)

return None


def _generic_qualifiers(raw_event: Dict) -> List[Qualifier]:
qualifiers: List[Qualifier] = []

Expand Down Expand Up @@ -191,10 +272,7 @@ def _parse_shot(raw_event: Dict) -> Dict:

return {
"result": result,
"result_coordinates": Point(
x=float(0),
y=float(0),
),
"result_coordinates": _create_shot_result_coordinates(raw_event),
"qualifiers": qualifiers,
}

Expand Down Expand Up @@ -677,9 +755,11 @@ def deserialize(self, inputs: WyscoutInputs) -> EventDataset:
periods.append(
Period(
id=period_id,
start_timestamp=timedelta(seconds=0)
if len(periods) == 0
else periods[-1].end_timestamp,
start_timestamp=(
timedelta(seconds=0)
if len(periods) == 0
else periods[-1].end_timestamp
),
end_timestamp=None,
)
)
Expand All @@ -703,16 +783,20 @@ def deserialize(self, inputs: WyscoutInputs) -> EventDataset:
generic_event_args = {
"event_id": raw_event["id"],
"raw_event": raw_event,
"coordinates": Point(
x=float(raw_event["location"]["x"]),
y=float(raw_event["location"]["y"]),
)
if raw_event["location"]
else None,
"coordinates": (
Point(
x=float(raw_event["location"]["x"]),
y=float(raw_event["location"]["y"]),
)
if raw_event["location"]
else None
),
"team": team,
"player": players[team_id][player_id]
if player_id != INVALID_PLAYER
else None,
"player": (
players[team_id][player_id]
if player_id != INVALID_PLAYER
else None
),
"ball_owning_team": ball_owning_team,
"ball_state": None,
"period": periods[-1],
Expand Down
44 changes: 29 additions & 15 deletions kloppy/tests/test_wyscout.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,32 +2,33 @@
from pathlib import Path

import pytest

from kloppy import wyscout
from kloppy.domain import (
BodyPart,
BodyPartQualifier,
Point,
EventDataset,
SetPieceType,
SetPieceQualifier,
CardQualifier,
CardType,
DatasetType,
DuelQualifier,
DuelType,
EventDataset,
EventType,
GoalkeeperQualifier,
FormationType,
GoalkeeperActionType,
CardQualifier,
CardType,
GoalkeeperQualifier,
Orientation,
PassQualifier,
PassResult,
FormationType,
Time,
PassType,
PassQualifier,
Point,
PositionType,
SetPieceQualifier,
SetPieceType,
ShotResult,
Time,
)

from kloppy import wyscout


@pytest.fixture(scope="session")
def event_v2_data(base_dir: Path) -> Path:
Expand Down Expand Up @@ -268,12 +269,25 @@ def test_shot_assist_event(self, dataset: EventDataset):
)

def test_shot_event(self, dataset: EventDataset):
shot_event = dataset.get_event_by_id(1927028534)
assert shot_event.event_type == EventType.SHOT
# a blocked free kick shot
blocked_shot_event = dataset.get_event_by_id(1927028534)
assert blocked_shot_event.event_type == EventType.SHOT
assert blocked_shot_event.result == ShotResult.BLOCKED
assert blocked_shot_event.result_coordinates == Point(x=77.0, y=21.0)
assert (
shot_event.get_qualifier_value(SetPieceQualifier)
blocked_shot_event.get_qualifier_value(SetPieceQualifier)
== SetPieceType.FREE_KICK
)
# off target shot
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
# 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)

def test_foul_committed_event(self, dataset: EventDataset):
foul_committed_event = dataset.get_event_by_id(1927028873)
Expand Down

0 comments on commit dff0204

Please sign in to comment.