Skip to content

Commit

Permalink
Merge branch 'master' into fix/wyscout-record-foul-on-card
Browse files Browse the repository at this point in the history
  • Loading branch information
koenvo authored Dec 29, 2023
2 parents 38f6aba + c2f0160 commit 5e07842
Show file tree
Hide file tree
Showing 5 changed files with 123 additions and 41 deletions.
8 changes: 6 additions & 2 deletions kloppy/domain/models/event.py
Original file line number Diff line number Diff line change
Expand Up @@ -368,13 +368,15 @@ class BodyPart(Enum):
RIGHT_FOOT (BodyPart): Pass or Shot with right foot, save with right foot (for goalkeepers).
LEFT_FOOT (BodyPart): Pass or Shot with left foot, save with left foot (for goalkeepers).
HEAD (BodyPart): Pass or Shot with head, save with head (for goalkeepers).
OTHER (BodyPart): Other body part (chest, back, etc.), for Pass and Shot.
HEAD_OTHER (BodyPart): Pass or Shot with head or other body part. Only used when the
data provider does not distinguish between HEAD and OTHER.
BOTH_HANDS (BodyPart): Goalkeeper only. Save with both hands.
CHEST (BodyPart): Goalkeeper only. Save with chest.
LEFT_HAND (BodyPart): Goalkeeper only. Save with left hand.
RIGHT_HAND (BodyPart): Goalkeeper only. Save with right hand.
DROP_KICK (BodyPart): Pass is a keeper drop kick.
KEEPER_ARM (BodyPart): Pass thrown from keepers hands.
OTHER (BodyPart): Other body part (chest, back, etc.), for Pass and Shot.
NO_TOUCH (BodyPart): Pass only. A player deliberately let the pass go past him
instead of receiving it to deliver to a teammate behind him.
(Also known as a "dummy").
Expand All @@ -383,14 +385,16 @@ class BodyPart(Enum):
RIGHT_FOOT = "RIGHT_FOOT"
LEFT_FOOT = "LEFT_FOOT"
HEAD = "HEAD"
OTHER = "OTHER"
HEAD_OTHER = "HEAD_OTHER"

BOTH_HANDS = "BOTH_HANDS"
CHEST = "CHEST"
LEFT_HAND = "LEFT_HAND"
RIGHT_HAND = "RIGHT_HAND"
DROP_KICK = "DROP_KICK"
KEEPER_ARM = "KEEPER_ARM"
OTHER = "OTHER"

NO_TOUCH = "NO_TOUCH"


Expand Down
114 changes: 89 additions & 25 deletions kloppy/infra/serializers/event/statsbomb/specification.py
Original file line number Diff line number Diff line change
Expand Up @@ -859,19 +859,104 @@ class KEEPER_SWEEPER:
class OUTCOME(Enum, metaclass=TypesEnumMeta):
CLAIM = 47
CLEAR = 48
WON = 4
SUCCESS = 15

def _create_events(
self, event_factory: EventFactory, **generic_event_kwargs
) -> List[Event]:
goalkeeper_dict = self.raw_event["goalkeeper"]
generic_event_kwargs = self._parse_generic_kwargs()
qualifiers = _get_goalkeeper_qualifiers(
self.raw_event["goalkeeper"]
) + _get_body_part_qualifiers(self.raw_event["goalkeeper"])

# parse body part
body_part_qualifiers = _get_body_part_qualifiers(goalkeeper_dict)
hands_used = any(
q.value
in [
BodyPart.LEFT_HAND,
BodyPart.RIGHT_HAND,
BodyPart.BOTH_HANDS,
]
for q in body_part_qualifiers
)
head_or_foot_used = any(
q.value
in [
BodyPart.LEFT_FOOT,
BodyPart.RIGHT_FOOT,
BodyPart.HEAD,
]
for q in body_part_qualifiers
)
bodypart_missing = len(body_part_qualifiers) == 0

# parse action type qualifiers
save_event_types = [
GOALKEEPER.TYPE.SHOT_SAVED,
GOALKEEPER.TYPE.PENALTY_SAVED_TO_POST,
GOALKEEPER.TYPE.SAVED_TO_POST,
GOALKEEPER.TYPE.SHOT_SAVED_OFF_TARGET,
GOALKEEPER.TYPE.SHOT_SAVED_TO_POST,
]
type_id = GOALKEEPER.TYPE(goalkeeper_dict.get("type", {}).get("id"))
outcome_id = goalkeeper_dict.get("outcome", {}).get("id")
qualifiers = []
if type_id in save_event_types:
qualifiers.append(
GoalkeeperQualifier(value=GoalkeeperActionType.SAVE)
)
elif type_id == GOALKEEPER.TYPE.SMOTHER:
qualifiers.append(
GoalkeeperQualifier(value=GoalkeeperActionType.SMOTHER)
)
elif type_id == GOALKEEPER.TYPE.PUNCH:
qualifiers.append(
GoalkeeperQualifier(value=GoalkeeperActionType.PUNCH)
)
elif type_id == GOALKEEPER.TYPE.COLLECTED:
qualifiers.append(
GoalkeeperQualifier(value=GoalkeeperActionType.CLAIM)
)
elif type_id == GOALKEEPER.TYPE.KEEPER_SWEEPER:
outcome_id = GOALKEEPER.KEEPER_SWEEPER.OUTCOME(
goalkeeper_dict.get("outcome", {}).get("id")
)
if outcome_id == GOALKEEPER.KEEPER_SWEEPER.OUTCOME.CLAIM:
# a goalkeeper can only pick up the ball with his hands
if hands_used or bodypart_missing:
qualifiers.append(
GoalkeeperQualifier(value=GoalkeeperActionType.PICK_UP)
)
# otherwise it's a recovery
else:
recovery = event_factory.build_recovery(
result=None,
qualifiers=body_part_qualifiers,
**generic_event_kwargs,
)
return [recovery]
elif outcome_id in [
GOALKEEPER.KEEPER_SWEEPER.OUTCOME.CLEAR,
GOALKEEPER.KEEPER_SWEEPER.OUTCOME.SUCCESS,
]:
# if the goalkeeper uses his foot or head, it's a clearance
if head_or_foot_used:
clearance = event_factory.build_clearance(
result=None,
qualifiers=body_part_qualifiers,
**generic_event_kwargs,
)
return [clearance]
# otherwise, it's a save
else:
qualifiers.append(
GoalkeeperQualifier(value=GoalkeeperActionType.SAVE)
)

if qualifiers:
goalkeeper_event = event_factory.build_goalkeeper_event(
result=None,
qualifiers=qualifiers,
qualifiers=qualifiers + body_part_qualifiers,
**generic_event_kwargs,
)
return [goalkeeper_event]
Expand Down Expand Up @@ -1174,27 +1259,6 @@ def _get_set_piece_qualifiers(
return []


def _get_goalkeeper_qualifiers(
goalkeeper_dict: Dict,
) -> List[GoalkeeperQualifier]:
sb_to_kloppy_goalkeeper_mapping = {
GOALKEEPER.TYPE.SHOT_SAVED: GoalkeeperActionType.SAVE,
GOALKEEPER.TYPE.PENALTY_SAVED_TO_POST: GoalkeeperActionType.SAVE,
GOALKEEPER.TYPE.SAVED_TO_POST: GoalkeeperActionType.SAVE,
GOALKEEPER.TYPE.SHOT_SAVED_OFF_TARGET: GoalkeeperActionType.SAVE,
GOALKEEPER.TYPE.SHOT_SAVED_TO_POST: GoalkeeperActionType.SAVE,
GOALKEEPER.TYPE.SMOTHER: GoalkeeperActionType.SMOTHER,
GOALKEEPER.TYPE.PUNCH: GoalkeeperActionType.PUNCH,
}
if "type" in goalkeeper_dict:
type_id = GOALKEEPER.TYPE(goalkeeper_dict["type"])
if type_id in sb_to_kloppy_goalkeeper_mapping:
kloppy_event_type = sb_to_kloppy_goalkeeper_mapping[type_id]
return [GoalkeeperQualifier(value=kloppy_event_type)]

return []


def event_decoder(raw_event: Dict) -> Union[EVENT, Dict]:
type_to_event = {
EVENT_TYPE.PASS: PASS,
Expand Down
23 changes: 18 additions & 5 deletions kloppy/infra/serializers/event/wyscout/deserializer_v2.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,17 @@ def _generic_qualifiers(raw_event: Dict) -> List[Qualifier]:
return qualifiers


def _bodypart_qualifiers(raw_event: Dict) -> List[Qualifier]:
qualifiers = []
if _has_tag(raw_event, wyscout_tags.LEFT_FOOT):
qualifiers.append(BodyPartQualifier(BodyPart.LEFT_FOOT))
elif _has_tag(raw_event, wyscout_tags.RIGHT_FOOT):
qualifiers.append(BodyPartQualifier(BodyPart.RIGHT_FOOT))
elif _has_tag(raw_event, wyscout_tags.HEAD_BODY):
qualifiers.append(BodyPartQualifier(BodyPart.HEAD_OTHER))
return qualifiers


def _create_shot_result_coordinates(raw_event: Dict) -> Optional[Point]:
"""Estimate the shot end location from the Wyscout tags.
Expand Down Expand Up @@ -156,8 +167,10 @@ def _create_shot_result_coordinates(raw_event: Dict) -> Optional[Point]:


def _parse_shot(raw_event: Dict, next_event: Dict) -> Dict:
result = None
qualifiers = _generic_qualifiers(raw_event)
qualifiers.extend(_bodypart_qualifiers(raw_event))

result = None
if _has_tag(raw_event, 101):
result = ShotResult.GOAL
elif _has_tag(raw_event, 2101):
Expand Down Expand Up @@ -193,6 +206,7 @@ def _pass_qualifiers(raw_event) -> List[Qualifier]:
qualifiers.append(PassQualifier(PassType.CROSS))
elif raw_event["subEventId"] == wyscout_events.PASS.HAND:
qualifiers.append(PassQualifier(PassType.HAND_PASS))
qualifiers.append(BodyPartQualifier(BodyPart.KEEPER_ARM))
elif raw_event["subEventId"] == wyscout_events.PASS.HEAD:
qualifiers.append(PassQualifier(PassType.HEAD_PASS))
qualifiers.append(BodyPartQualifier(BodyPart.HEAD))
Expand All @@ -205,10 +219,9 @@ def _pass_qualifiers(raw_event) -> List[Qualifier]:
elif raw_event["subEventId"] == wyscout_events.PASS.SMART:
qualifiers.append(PassQualifier(PassType.SMART_PASS))

if _has_tag(raw_event, wyscout_tags.LEFT_FOOT):
qualifiers.append(BodyPartQualifier(BodyPart.LEFT_FOOT))
elif _has_tag(raw_event, wyscout_tags.RIGHT_FOOT):
qualifiers.append(BodyPartQualifier(BodyPart.RIGHT_FOOT))
# If the subevent type did not define the bodypart, we infer it from the tags
if not any(isinstance(q, BodyPartQualifier) for q in qualifiers):
qualifiers.extend(_bodypart_qualifiers(raw_event))

if _has_tag(raw_event, wyscout_tags.HIGH):
qualifiers.append(PassQualifier(PassType.HIGH_PASS))
Expand Down
12 changes: 3 additions & 9 deletions kloppy/tests/test_statsbomb.py
Original file line number Diff line number Diff line change
Expand Up @@ -725,7 +725,7 @@ class TestStatsBombClearanceEvent:
def test_deserialize_all(self, dataset: EventDataset):
"""It should deserialize all clearance events"""
events = dataset.find_all("clearance")
assert len(events) == 35
assert len(events) == 35 + 1 # clearances + keeper sweeper

def test_attributes(self, dataset: EventDataset):
"""Verify specific attributes of clearances"""
Expand Down Expand Up @@ -867,10 +867,8 @@ def test_loose_ground_duel_qualfiers(self, dataset: EventDataset):
class TestStatsBombGoalkeeperEvent:
"""Tests related to deserializing 30/Goalkeeper events"""

@pytest.mark.xfail
def test_deserialize_all(self, dataset: EventDataset):
"""It should deserialize all goalkeeper events"""
# TODO: not yet fully implemented, see #225
events = dataset.find_all("goalkeeper")
assert (
len(events) == 32 - 24 - 1 - 1
Expand Down Expand Up @@ -901,29 +899,25 @@ def test_smother(self, dataset: EventDataset):
"""It should deserialize goalkeeper smothers"""
assert True # no example in the dataset

@pytest.mark.xfail
def test_collected(self, dataset: EventDataset):
"""It should deserialize goalkeeper collections"""
# TODO: not yet implemented, see #225
collected = dataset.get_event_by_id(
"5156545b-7add-4b6a-a8e4-c68672267464"
)
assert collected.get_qualifier_value(GoalkeeperQualifier) == (
GoalkeeperActionType.CLAIM
)

@pytest.mark.xfail
def test_keeper_sweeper(self, dataset: EventDataset):
"""It should deserialize keeper sweeper actions"""
# TODO: not yet implemented, see #225
# keeper sweeper with outcome 'clear' should be deserialized as
# as a clearance event
# as a clearance event if the keeper uses his feet or head
sweeper_clear = dataset.get_event_by_id(
"6c84a193-d45b-4d6e-97bc-3f07af9001db"
)
assert sweeper_clear.event_type == EventType.CLEARANCE
# keeper sweeper with outcome 'claim' should be deserialized as
# a goalkeeper pick-up event
# a goalkeeper pick-up event if the keeper uses his hands
sweeper_claim = dataset.get_event_by_id(
"460f558e-c951-4262-b467-e078ea1faefc"
)
Expand Down
7 changes: 7 additions & 0 deletions kloppy/tests/test_wyscout.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

import pytest
from kloppy.domain import (
BodyPart,
BodyPartQualifier,
Point,
EventDataset,
SetPieceType,
Expand Down Expand Up @@ -167,6 +169,11 @@ def test_duel_event(self, dataset: EventDataset):
assert sliding_tackle_duel_event.event_type == EventType.DUEL
assert (
sliding_tackle_duel_event.get_qualifier_values(DuelQualifier)[2]
dataset.events[118].get_qualifier_value(BodyPartQualifier)
== BodyPart.RIGHT_FOOT
)
assert (
dataset.events[268].get_qualifier_values(DuelQualifier)[2]
== DuelType.SLIDING_TACKLE
)

Expand Down

0 comments on commit 5e07842

Please sign in to comment.