diff --git a/kloppy/domain/models/common.py b/kloppy/domain/models/common.py index 6c9603df..3d9e973d 100644 --- a/kloppy/domain/models/common.py +++ b/kloppy/domain/models/common.py @@ -29,7 +29,17 @@ else: from typing_extensions import Self -from .pitch import PitchDimensions, Point, Dimension +from .pitch import ( + PitchDimensions, + Unit, + Point, + Dimension, + NormalizedPitchDimensions, + MetricPitchDimensions, + ImperialPitchDimensions, + OptaPitchDimensions, + WyscoutPitchDimensions, +) from .formation import FormationType from ...exceptions import ( OrientationError, @@ -449,9 +459,8 @@ def __str__(self): @dataclass class CoordinateSystem(ABC): - normalized: bool - length: float = None - width: float = None + pitch_length: Optional[float] = None + pitch_width: Optional[float] = None def __eq__(self, other): if isinstance(other, CoordinateSystem): @@ -483,6 +492,10 @@ def vertical_orientation(self) -> VerticalOrientation: def pitch_dimensions(self) -> PitchDimensions: raise NotImplementedError + @property + def normalized(self) -> bool: + return isinstance(self.pitch_dimensions, NormalizedPitchDimensions) + @dataclass class KloppyCoordinateSystem(CoordinateSystem): @@ -500,17 +513,21 @@ def vertical_orientation(self) -> VerticalOrientation: @property def pitch_dimensions(self) -> PitchDimensions: - if self.length is not None and self.width is not None: - return PitchDimensions( + if self.pitch_length is not None and self.pitch_width is not None: + return NormalizedPitchDimensions( x_dim=Dimension(0, 1), y_dim=Dimension(0, 1), - length=self.length, - width=self.width, + pitch_length=self.pitch_length, + pitch_width=self.pitch_width, + standardized=False, ) else: - return PitchDimensions( + return NormalizedPitchDimensions( x_dim=Dimension(0, 1), y_dim=Dimension(0, 1), + pitch_length=105, + pitch_width=68, + standardized=True, ) @@ -537,12 +554,13 @@ def vertical_orientation(self) -> VerticalOrientation: @property def pitch_dimensions(self) -> PitchDimensions: - return PitchDimensions( - x_dim=Dimension(-1 * self.length * 100 / 2, self.length * 100 / 2), - y_dim=Dimension(-1 * self.width * 100 / 2, self.width * 100 / 2), - length=self.length, - width=self.width, - ) + return MetricPitchDimensions( + x_dim=Dimension(-1 * self.pitch_length / 2, self.pitch_length / 2), + y_dim=Dimension(-1 * self.pitch_width / 2, self.pitch_width / 2), + pitch_length=self.pitch_length, + pitch_width=self.pitch_width, + standardized=False, + ).convert(to_unit=Unit.CENTIMETERS) @dataclass @@ -561,11 +579,12 @@ def vertical_orientation(self) -> VerticalOrientation: @property def pitch_dimensions(self) -> PitchDimensions: - return PitchDimensions( - x_dim=Dimension(-1 * self.length / 2, self.length / 2), - y_dim=Dimension(-1 * self.width / 2, self.width / 2), - length=self.length, - width=self.width, + return MetricPitchDimensions( + x_dim=Dimension(-1 * self.pitch_length / 2, self.pitch_length / 2), + y_dim=Dimension(-1 * self.pitch_width / 2, self.pitch_width / 2), + pitch_length=self.pitch_length, + pitch_width=self.pitch_width, + standardized=False, ) @@ -585,9 +604,8 @@ def vertical_orientation(self) -> VerticalOrientation: @property def pitch_dimensions(self) -> PitchDimensions: - return PitchDimensions( - x_dim=Dimension(0, 100), - y_dim=Dimension(0, 100), + return OptaPitchDimensions( + pitch_length=self.pitch_length, pitch_width=self.pitch_width ) @@ -607,11 +625,12 @@ def vertical_orientation(self) -> VerticalOrientation: @property def pitch_dimensions(self) -> PitchDimensions: - return PitchDimensions( - x_dim=Dimension(0, self.length), - y_dim=Dimension(0, self.width), - length=self.length, - width=self.width, + return MetricPitchDimensions( + x_dim=Dimension(0, self.pitch_length), + y_dim=Dimension(0, self.pitch_width), + pitch_length=self.pitch_length, + pitch_width=self.pitch_width, + standardized=False, ) @@ -631,11 +650,12 @@ def vertical_orientation(self) -> VerticalOrientation: @property def pitch_dimensions(self) -> PitchDimensions: - return PitchDimensions( - x_dim=Dimension(-self.length / 2, self.length / 2), - y_dim=Dimension(-self.width / 2, self.width / 2), - length=self.length, - width=self.width, + return MetricPitchDimensions( + x_dim=Dimension(-self.pitch_length / 2, self.pitch_length / 2), + y_dim=Dimension(-self.pitch_width / 2, self.pitch_width / 2), + pitch_length=self.pitch_length, + pitch_width=self.pitch_width, + standardized=False, ) @@ -655,9 +675,12 @@ def vertical_orientation(self) -> VerticalOrientation: @property def pitch_dimensions(self) -> PitchDimensions: - return PitchDimensions( + return ImperialPitchDimensions( x_dim=Dimension(0, 120), y_dim=Dimension(0, 80), + pitch_length=self.pitch_length, + pitch_width=self.pitch_width, + standardized=True, ) @@ -676,9 +699,8 @@ def vertical_orientation(self) -> VerticalOrientation: @property def pitch_dimensions(self) -> PitchDimensions: - return PitchDimensions( - x_dim=Dimension(0, 100), - y_dim=Dimension(0, 100), + return WyscoutPitchDimensions( + pitch_length=self.pitch_length, pitch_width=self.pitch_width ) @@ -698,11 +720,12 @@ def vertical_orientation(self) -> VerticalOrientation: @property def pitch_dimensions(self) -> PitchDimensions: - return PitchDimensions( - x_dim=Dimension(-1 * self.length / 2, self.length / 2), - y_dim=Dimension(-1 * self.width / 2, self.width / 2), - length=self.length, - width=self.width, + return MetricPitchDimensions( + x_dim=Dimension(-1 * self.pitch_length / 2, self.pitch_length / 2), + y_dim=Dimension(-1 * self.pitch_width / 2, self.pitch_width / 2), + pitch_length=self.pitch_length, + pitch_width=self.pitch_width, + standardized=False, ) @@ -722,10 +745,22 @@ def vertical_orientation(self) -> VerticalOrientation: @property def pitch_dimensions(self) -> PitchDimensions: - return PitchDimensions( - x_dim=Dimension(-1, 1), - y_dim=Dimension(-1, 1), - ) + if self.pitch_length is not None and self.pitch_width is not None: + return NormalizedPitchDimensions( + x_dim=Dimension(-1, 1), + y_dim=Dimension(-1, 1), + pitch_length=self.pitch_length, + pitch_width=self.pitch_width, + standardized=False, + ) + else: + return NormalizedPitchDimensions( + x_dim=Dimension(-1, 1), + y_dim=Dimension(-1, 1), + pitch_length=105, + pitch_width=68, + standardized=True, + ) @dataclass @@ -744,11 +779,13 @@ def vertical_orientation(self) -> VerticalOrientation: @property def pitch_dimensions(self) -> PitchDimensions: - return PitchDimensions( + # FIXME: This does not seem correct + return NormalizedPitchDimensions( x_dim=Dimension(0, 100), y_dim=Dimension(0, 100), - length=self.length, - width=self.width, + pitch_length=105, + pitch_width=68, + standardized=True, ) @@ -771,45 +808,51 @@ def __repr__(self): def build_coordinate_system( - provider: Provider, dataset_type: DatasetType = DatasetType.EVENT, **kwargs -): - if provider == Provider.TRACAB: - return TracabCoordinateSystem(normalized=False, **kwargs) - - if provider == Provider.KLOPPY: - return KloppyCoordinateSystem(normalized=True, **kwargs) - - if provider == Provider.METRICA: - return MetricaCoordinateSystem(normalized=True, **kwargs) - - if provider == Provider.OPTA: - return OptaCoordinateSystem(normalized=False, **kwargs) - - if provider == Provider.SPORTEC: - if dataset_type == DatasetType.TRACKING: - return SportecTrackingDataCoordinateSystem( - normalized=False, **kwargs + provider: Provider, + dataset_type: DatasetType = DatasetType.EVENT, + pitch_length: Optional[float] = None, + pitch_width: Optional[float] = None, +) -> CoordinateSystem: + """Build a coordinate system for a given provider and dataset type. + + Args: + provider: The provider of the dataset. + dataset_type: The type of the dataset. + pitch_length: The real length of the pitch. + pitch_width: The real width of the pitch. + + Returns: + The coordinate system for the given provider and dataset type. + """ + coordinate_systems = { + Provider.TRACAB: TracabCoordinateSystem, + Provider.KLOPPY: KloppyCoordinateSystem, + Provider.METRICA: MetricaCoordinateSystem, + Provider.OPTA: OptaCoordinateSystem, + Provider.SPORTEC: { + DatasetType.EVENT: SportecEventDataCoordinateSystem, + DatasetType.TRACKING: SportecTrackingDataCoordinateSystem, + }, + Provider.STATSBOMB: StatsBombCoordinateSystem, + Provider.WYSCOUT: WyscoutCoordinateSystem, + Provider.SKILLCORNER: SkillCornerCoordinateSystem, + Provider.DATAFACTORY: DatafactoryCoordinateSystem, + Provider.SECONDSPECTRUM: SecondSpectrumCoordinateSystem, + Provider.STATSPERFORM: StatsPerformCoordinateSystem, + } + + if provider in coordinate_systems: + if isinstance(coordinate_systems[provider], dict): + assert dataset_type in coordinate_systems[provider] + return coordinate_systems[provider][dataset_type]( + pitch_length=pitch_length, pitch_width=pitch_width ) else: - return SportecEventDataCoordinateSystem(normalized=False, **kwargs) - - if provider == Provider.STATSBOMB: - return StatsBombCoordinateSystem(normalized=False, **kwargs) - - if provider == Provider.WYSCOUT: - return WyscoutCoordinateSystem(normalized=False, **kwargs) - - if provider == Provider.SKILLCORNER: - return SkillCornerCoordinateSystem(normalized=False, **kwargs) - - if provider == Provider.DATAFACTORY: - return DatafactoryCoordinateSystem(normalized=False, **kwargs) - - if provider == Provider.SECONDSPECTRUM: - return SecondSpectrumCoordinateSystem(normalized=False, **kwargs) - - if provider == Provider.STATSPERFORM: - return StatsPerformCoordinateSystem(normalized=False, **kwargs) + return coordinate_systems[provider]( + pitch_length=pitch_length, pitch_width=pitch_width + ) + else: + raise ValueError(f"Invalid provider: {provider}") class DatasetFlag(Flag): diff --git a/kloppy/domain/models/pitch.py b/kloppy/domain/models/pitch.py index b1f64617..f0f0fea8 100644 --- a/kloppy/domain/models/pitch.py +++ b/kloppy/domain/models/pitch.py @@ -1,53 +1,53 @@ +import warnings from dataclasses import dataclass from enum import Enum from math import sqrt from typing import Optional +from kloppy.exceptions import KloppyError -@dataclass -class Dimension: - """ - Attributes: - min: Minimal possible value within this dimension - max: Maximal possible value within this dimension - """ +DEFAULT_PITCH_LENGTH = 105.0 +DEFAULT_PITCH_WIDTH = 68.0 - min: float - max: float - def __eq__(self, other): - if isinstance(self, Dimension): - return self.min == other.min and self.max == other.max +class Unit(Enum): + """Unit to measure distances on a pitch.""" - return False + METERS = "m" + YARDS = "y" + CENTIMETERS = "cm" + FEET = "ft" + NORMED = "normed" - def to_base(self, value: float) -> float: - return (value - self.min) / (self.max - self.min) - - def from_base(self, value: float) -> float: - return value * (self.max - self.min) + self.min + def convert(self, to_unit: "Unit", value: float) -> float: + """Converts a value from one unit to another. + Arguments: + to_unit: The unit to convert to + value: The value to convert + Returns: + The value converted to the target unit + """ + conversion_factors = { + (Unit.METERS, Unit.METERS): 1, + (Unit.METERS, Unit.YARDS): 1.09361, + (Unit.METERS, Unit.CENTIMETERS): 100, + (Unit.METERS, Unit.FEET): 3.281, + } + if self == to_unit: + return value + elif self == Unit.NORMED or to_unit == Unit.NORMED: + raise ValueError("Cannot convert to or from a normed unit") -@dataclass -class PitchDimensions: - """ - Attributes: - x_dim: See [`Dimension`][kloppy.domain.models.pitch.Dimension] - y_dim: See [`Dimension`][kloppy.domain.models.pitch.Dimension] - x_per_meter: number of units per meter in the x dimension - y_per_meter: number of units per meter in the y dimension - """ - - x_dim: Dimension - y_dim: Dimension - length: float = None - width: float = None - - def __eq__(self, other): - if isinstance(self, PitchDimensions): - return self.x_dim == other.x_dim and self.y_dim == other.y_dim - - return False + factor_to_meter = conversion_factors.get((Unit.METERS, self)) + factor_from_meter = conversion_factors.get((Unit.METERS, to_unit)) + assert ( + factor_to_meter is not None + ), f"Conversion factor for {self} is not defined" + assert ( + factor_from_meter is not None + ), f"Conversion factor for {to_unit} is not defined" + return value / factor_to_meter * factor_from_meter @dataclass(frozen=True) @@ -83,3 +83,588 @@ class Point3D(Point): """ z: Optional[float] + + +@dataclass(frozen=True) +class Dimension: + """Limits of pitch boundaries along a single axis. + + Attributes: + min: Minimal possible value within this dimension + max: Maximal possible value within this dimension + """ + + min: Optional[float] = None + max: Optional[float] = None + + def to_base(self, value: float) -> float: + """Map a value from this dimension to [0, 1].""" + if self.min is None or self.max is None: + raise KloppyError( + "The pitch boundaries need to be fully specified to convert coordinates." + ) + return (value - self.min) / (self.max - self.min) + + def from_base(self, value: float) -> float: + """Map a value from [0, 1] to this dimension.""" + if self.min is None or self.max is None: + raise KloppyError( + "The pitch boundaries need to be fully specified to convert coordinates." + ) + return value * (self.max - self.min) + self.min + + +@dataclass +class PitchDimensions: + """Specifies the dimensions of a pitch. + + Attributes: + x_dim: Limits of pitch boundaries in longitudinal direction. + See [`Dimension`][kloppy.domain.models.pitch.Dimension] + y_dim: Limits of pitch boundaries in lateral direction. + See [`Dimension`][kloppy.domain.models.pitch.Dimension] + standardized: Used to denote standardized pitch dimensions where data + is scaled along the axes independent of the actual dimensions of + the pitch. To get non-distored calculations, the `length` and + `width` of the pitch need to be specified. + unit: The unit in which distances are measured along the axes of the pitch. + goal_width: Width of the goal. + goal_height: Height of the goal. + six_yard_width: Width of the six yard box. + six_yard_length: Length of the six yard box. + penalty_area_width: Width of the penalty area. + penalty_area_length: Length of the penalty area. + circle_radius: Radius of the center circle (in the longitudinal direction). + corner_radius: Radius of the corner arcs. + penalty_spot_distance: Distance from the goal line to the penalty spot. + penalty_arc_radius: Radius of the penalty arc (in the longitudinal direction). + pitch_length: True length of the pitch, in meters. + pitch_width: True width of the pitch, in meters. + """ + + x_dim: Dimension + y_dim: Dimension + standardized: bool + unit: Unit + + goal_width: float + goal_height: Optional[float] + six_yard_width: float + six_yard_length: float + penalty_area_width: float + penalty_area_length: float + circle_radius: float + corner_radius: float + penalty_spot_distance: float + penalty_arc_radius: float + + pitch_length: Optional[float] = None + pitch_width: Optional[float] = None + + def convert(self, to_unit: Unit) -> "PitchDimensions": + """Convert the pitch dimensions to another unit. + + Arguments: + to_unit: The unit to convert to + + Returns: + The pitch dimensions in the target unit + """ + return PitchDimensions( + x_dim=Dimension( + min=self.unit.convert(to_unit, self.x_dim.min) + if self.x_dim.min is not None + else None, + max=self.unit.convert(to_unit, self.x_dim.max) + if self.x_dim.max is not None + else None, + ), + y_dim=Dimension( + min=self.unit.convert(to_unit, self.y_dim.min) + if self.y_dim.min is not None + else None, + max=self.unit.convert(to_unit, self.y_dim.max) + if self.y_dim.max is not None + else None, + ), + standardized=self.standardized, + unit=to_unit, + goal_width=self.unit.convert(to_unit, self.goal_width), + goal_height=self.unit.convert(to_unit, self.goal_height) + if self.goal_height is not None + else None, + six_yard_width=self.unit.convert(to_unit, self.six_yard_width), + six_yard_length=self.unit.convert(to_unit, self.six_yard_length), + penalty_area_width=self.unit.convert( + to_unit, self.penalty_area_width + ), + penalty_area_length=self.unit.convert( + to_unit, self.penalty_area_length + ), + circle_radius=self.unit.convert(to_unit, self.circle_radius), + corner_radius=self.unit.convert(to_unit, self.corner_radius), + penalty_spot_distance=self.unit.convert( + to_unit, self.penalty_spot_distance + ), + penalty_arc_radius=self.unit.convert( + to_unit, self.penalty_arc_radius + ), + pitch_length=self.pitch_length, + pitch_width=self.pitch_width, + ) + + def _transformation_zones_x(self, length: float): + assert self.x_dim.min is not None + penalty_arc = self.penalty_spot_distance + self.penalty_arc_radius + return [ + # goal line to 6 yard box + (self.x_dim.min, self.x_dim.min + self.six_yard_length), + # 6 yard box to penalty spot + ( + self.x_dim.min + self.six_yard_length, + self.x_dim.min + self.penalty_spot_distance, + ), + # penalty spot to penalty area + ( + self.x_dim.min + self.penalty_spot_distance, + self.x_dim.min + self.penalty_area_length, + ), + # penalty area to penalty arc + ( + self.x_dim.min + self.penalty_area_length, + self.x_dim.min + penalty_arc, + ), + # penalty arc to center circle + ( + self.x_dim.min + penalty_arc, + self.x_dim.min + length / 2 - self.circle_radius, + ), + # center circle to center line + ( + self.x_dim.min + length / 2 - self.circle_radius, + self.x_dim.min + length / 2, + ), + ] + + def _transformation_zones_y(self, width: float): + assert self.y_dim.min is not None + return [ + # side line to penalty area + ( + self.y_dim.min, + self.y_dim.min + (width - self.penalty_area_width) / 2, + ), + # penalty area to six yard box + ( + self.y_dim.min + (width - self.penalty_area_width) / 2, + self.y_dim.min + (width - self.six_yard_width) / 2, + ), + # six yard box to inside goalpost + ( + self.y_dim.min + (width - self.six_yard_width) / 2, + self.y_dim.min + (width - self.goal_width) / 2, + ), + # inside goalpost to center + ( + self.y_dim.min + (width - self.goal_width) / 2, + self.y_dim.min + width / 2, + ), + ] + + def to_metric_base( + self, + point: Point, + pitch_length: float = DEFAULT_PITCH_LENGTH, + pitch_width: float = DEFAULT_PITCH_WIDTH, + ) -> Point: + """ + Convert a point from this pitch dimensions to the IFAB pitch dimensions. + + Arguments: + point: The point to convert + + Returns: + The point in the IFAB pitch dimensions + """ + if ( + self.x_dim.min is None + or self.x_dim.max is None + or self.y_dim.min is None + or self.y_dim.max is None + ): + raise KloppyError( + "The pitch boundaries need to be fully specified to convert coordinates." + ) + + ifab_dims = MetricPitchDimensions( + x_dim=Dimension(0, pitch_length), + y_dim=Dimension(0, pitch_width), + pitch_length=pitch_length, + pitch_width=pitch_width, + standardized=False, + ) + x_ifab_zones = ifab_dims._transformation_zones_x(pitch_length) + y_ifab_zones = ifab_dims._transformation_zones_y(pitch_width) + x_from_zones = self._transformation_zones_x( + self.x_dim.max - self.x_dim.min + ) + y_from_zones = self._transformation_zones_y( + self.y_dim.max - self.y_dim.min + ) + + def transform(v, from_zones, from_length, ifab_zones, ifab_length): + mirror = False + if v > from_zones[-1][1]: + v = from_length - (v - from_zones[0][0]) + from_zones[0][0] + mirror = True + # find the zone the v coordinate is in + try: + zone = next( + ( + idx + for idx, zone in enumerate(from_zones) + if zone[0] <= v <= zone[1] + ) + ) + scale = ( + # length of the zone in IFAB dimensions + (ifab_zones[zone][1] - ifab_zones[zone][0]) + # length of the zone in the original dimensions + / (from_zones[zone][1] - from_zones[zone][0]) + ) + # ifab = smallest IFAB value of the zone + (v - smallest v value of the zone) * scaling factor of the zone + ifab = ifab_zones[zone][0] + (v - from_zones[zone][0]) * scale + except StopIteration: + # value is outside of the pitch dimensions + ifab = ifab_zones[0][0] + (v - from_zones[0][0]) * ( + ifab_length / from_length + ) + if mirror: + ifab = ifab_length - ifab + return ifab + + if isinstance(point, Point3D): + return Point3D( + x=transform( + point.x, + x_from_zones, + self.x_dim.max - self.x_dim.min, + x_ifab_zones, + pitch_length, + ), + y=transform( + point.y, + y_from_zones, + self.y_dim.max - self.y_dim.min, + y_ifab_zones, + pitch_width, + ), + z=( + point.z * 2.44 / self.goal_height + if self.goal_height is not None + else point.z + ) + if point.z is not None + else None, + ) + else: + return Point( + x=transform( + point.x, + x_from_zones, + self.x_dim.max - self.x_dim.min, + x_ifab_zones, + pitch_length, + ), + y=transform( + point.y, + y_from_zones, + self.y_dim.max - self.y_dim.min, + y_ifab_zones, + pitch_width, + ), + ) + + def from_metric_base( + self, + point: Point, + pitch_length: float = DEFAULT_PITCH_LENGTH, + pitch_width: float = DEFAULT_PITCH_WIDTH, + ) -> Point: + """ + Convert a point from the IFAB pitch dimensions to this pitch dimensions. + + Arguments: + point: The point to convert + + Returns: + The point in the regular pitch dimensions + """ + if ( + self.x_dim.min is None + or self.x_dim.max is None + or self.y_dim.min is None + or self.y_dim.max is None + ): + raise KloppyError( + "The pitch boundaries need to be fully specified to convert coordinates." + ) + + ifab_dims = MetricPitchDimensions( + x_dim=Dimension(0, pitch_length), + y_dim=Dimension(0, pitch_width), + pitch_length=pitch_length, + pitch_width=pitch_width, + standardized=False, + ) + x_ifab_zones = ifab_dims._transformation_zones_x(pitch_length) + y_ifab_zones = ifab_dims._transformation_zones_y(pitch_width) + x_to_zones = self._transformation_zones_x( + self.x_dim.max - self.x_dim.min + ) + y_to_zones = self._transformation_zones_y( + self.y_dim.max - self.y_dim.min + ) + + def transform(v, to_zones, to_length, ifab_zones, ifab_length): + mirror = False + if v > ifab_length / 2: + v = ifab_length - v + mirror = True + # find the zone the v coordinate is in + try: + zone = next( + ( + idx + for idx, zone in enumerate(ifab_zones) + if zone[0] <= v <= zone[1] + ) + ) + scale = ( + # length of the zone in the original dimensions + (to_zones[zone][1] - to_zones[zone][0]) + # length of the zone in IFAB dimensions + / (ifab_zones[zone][1] - ifab_zones[zone][0]) + ) + # ifab = smallest IFAB value of the zone + (v - smallest v value of the zone) * scaling factor of the zone + v = to_zones[zone][0] + (v - ifab_zones[zone][0]) * scale + except StopIteration: + # value is outside of the pitch dimensions + v = to_zones[0][0] + (v - ifab_zones[0][0]) * ( + to_length / ifab_length + ) + if mirror: + v = (to_length + to_zones[0][0] - v) + to_zones[0][0] + return v + + if isinstance(point, Point3D): + return Point3D( + x=transform( + point.x, + x_to_zones, + self.x_dim.max - self.x_dim.min, + x_ifab_zones, + pitch_length, + ), + y=transform( + point.y, + y_to_zones, + self.y_dim.max - self.y_dim.min, + y_ifab_zones, + pitch_width, + ), + z=( + point.z * self.goal_height / 2.44 + if self.goal_height is not None + else point.z + ) + if point.z is not None + else None, + ) + else: + return Point( + x=transform( + point.x, + x_to_zones, + self.x_dim.max - self.x_dim.min, + x_ifab_zones, + pitch_length, + ), + y=transform( + point.y, + y_to_zones, + self.y_dim.max - self.y_dim.min, + y_ifab_zones, + pitch_width, + ), + ) + + def distance_between( + self, point1: Point, point2: Point, unit: Unit = Unit.METERS + ) -> float: + """ + Calculate the distance between two points in the coordinate system. + + Arguments: + point1: The first point + point2: The second point + unit: The unit to measure the distance in + + Returns: + The distance between the two points in the given unit + """ + if self.pitch_length is None or self.pitch_width is None: + warnings.warn( + "The pitch length and width are not specified. " + "Assuming a standard pitch size of 105x68 meters. " + "This may lead to incorrect results.", + stacklevel=2, + ) + pitch_length = DEFAULT_PITCH_LENGTH + pitch_width = DEFAULT_PITCH_WIDTH + else: + pitch_length = self.pitch_length + pitch_width = self.pitch_width + point1_ifab = self.to_metric_base(point1, pitch_length, pitch_width) + point2_ifab = self.to_metric_base(point2, pitch_length, pitch_width) + dist = point1_ifab.distance_to(point2_ifab) + return Unit.METERS.convert(unit, dist) + + +@dataclass +class MetricPitchDimensions(PitchDimensions): + """The standard pitch dimensions in meters by IFAB regulations. + + For national matches, the length of the pitch can be between 90 and 120 + meters, and the width can be between 45 and 90 meters. For international + matches, the length can be between 100 and 110 meters, and the width can + be between 64 and 75 meters. All other dimensions are fixed. + + See https://www.theifab.com/laws/latest/the-field-of-play. + """ + + unit: Unit = Unit.METERS + + goal_width: float = 7.32 + goal_height: Optional[float] = 2.44 + six_yard_width: float = 18.32 + six_yard_length: float = 5.5 + penalty_area_width: float = 40.32 + penalty_area_length: float = 16.5 + circle_radius: float = 9.15 + corner_radius: float = 1 + penalty_spot_distance: float = 11 + penalty_arc_radius: float = 9.15 + + +@dataclass +class ImperialPitchDimensions(PitchDimensions): + """The standard pitch dimensions in yards by IFAB regulations. + + For national matches, the length of the pitch can be between 100 and 130 + yards, and the width can be between 50 and 100 yards. For international + matches, the length can be between 110 and 120 yards, and the width can + be between 70 and 80 yards. All other dimensions are fixed. + + See https://www.theifab.com/laws/latest/the-field-of-play. + """ + + unit: Unit = Unit.YARDS + + goal_width: float = 8 + goal_height: Optional[float] = 2.66 + six_yard_width: float = 20 + six_yard_length: float = 6 + penalty_area_width: float = 44 + penalty_area_length: float = 18 + circle_radius: float = 10 + corner_radius: float = 1 + penalty_spot_distance: float = 12 + penalty_arc_radius: float = 10 + + +@dataclass +class NormalizedPitchDimensions(MetricPitchDimensions): + """The standard pitch dimensions in normalized units. + + The pitch dimensions are normalized to a unit square, where the length + and width of the pitch are 1. All other dimensions are scaled accordingly. + """ + + x_dim: Dimension = Dimension(0, 1) + y_dim: Dimension = Dimension(0, 1) + standardized: bool = False + unit: Unit = Unit.NORMED + + def __post_init__(self): + if self.pitch_length is None or self.pitch_width is None: + raise ValueError("The pitch length and width need to be specified") + dim_width = self.y_dim.max - self.y_dim.min + dim_length = self.x_dim.max - self.x_dim.min + self.goal_width = self.goal_width / self.pitch_width * dim_width + self.six_yard_width = ( + self.six_yard_width / self.pitch_width * dim_width + ) + self.six_yard_length = ( + self.six_yard_length / self.pitch_length * dim_length + ) + self.penalty_area_width = ( + self.penalty_area_width / self.pitch_width * dim_width + ) + self.penalty_area_length = ( + self.penalty_area_length / self.pitch_length * dim_length + ) + self.circle_radius = ( + self.circle_radius / self.pitch_length * dim_length + ) + self.corner_radius = ( + self.corner_radius / self.pitch_length * dim_length + ) + self.penalty_spot_distance = ( + self.penalty_spot_distance / self.pitch_length * dim_length + ) + self.penalty_arc_radius = ( + self.penalty_arc_radius / self.pitch_length * dim_length + ) + + +@dataclass +class OptaPitchDimensions(PitchDimensions): + """The pitch dimensions used by Opta.""" + + x_dim: Dimension = Dimension(0, 100) + y_dim: Dimension = Dimension(0, 100) + standardized: bool = True + unit: Unit = Unit.NORMED + + goal_width: float = 9.6 + goal_height: Optional[float] = 38 + six_yard_width: float = 26.4 + six_yard_length: float = 5.8 + penalty_area_width: float = 57.8 + penalty_area_length: float = 17.0 + circle_radius: float = 9.00 + corner_radius: float = 0.97 + penalty_spot_distance: float = 11.5 + penalty_arc_radius: float = 8.9 + + +@dataclass +class WyscoutPitchDimensions(PitchDimensions): + """The pitch dimensions used by Wyscout.""" + + x_dim: Dimension = Dimension(0, 100) + y_dim: Dimension = Dimension(0, 100) + standardized: bool = True + unit: Unit = Unit.NORMED + + goal_width: float = 12.0 + goal_height: Optional[float] = None + six_yard_width: float = 26.0 + six_yard_length: float = 6.0 + penalty_area_width: float = 62.0 + penalty_area_length: float = 16.0 + circle_radius: float = 8.84 # inferred + corner_radius: float = 0.97 # inferred + penalty_spot_distance: float = 10.0 + penalty_arc_radius: float = 7.74 # inferred diff --git a/kloppy/domain/services/transformers/dataset.py b/kloppy/domain/services/transformers/dataset.py index 11f44b52..3e784cc6 100644 --- a/kloppy/domain/services/transformers/dataset.py +++ b/kloppy/domain/services/transformers/dataset.py @@ -21,6 +21,8 @@ Provider, build_coordinate_system, DatasetType, + DEFAULT_PITCH_LENGTH, + DEFAULT_PITCH_WIDTH, ) from kloppy.domain.models.event import Event from kloppy.exceptions import KloppyError @@ -102,16 +104,23 @@ def change_point_dimensions( if point is None: return None - x_base = self._from_pitch_dimensions.x_dim.to_base(point.x) - y_base = self._from_pitch_dimensions.y_dim.to_base(point.y) + base_pitch_length = ( + self._from_pitch_dimensions.pitch_length or DEFAULT_PITCH_LENGTH + ) + base_pitch_width = ( + self._from_pitch_dimensions.pitch_width or DEFAULT_PITCH_WIDTH + ) - x = self._to_pitch_dimensions.x_dim.from_base(x_base) - y = self._to_pitch_dimensions.y_dim.from_base(y_base) + point_base = self._from_pitch_dimensions.to_metric_base( + point, pitch_length=base_pitch_length, pitch_width=base_pitch_width + ) + point_to = self._to_pitch_dimensions.from_metric_base( + point_base, + pitch_length=base_pitch_length, + pitch_width=base_pitch_width, + ) - if isinstance(point, Point3D): - return Point3D(x=x, y=y, z=point.z) - else: - return Point(x=x, y=y) + return point_to def flip_point( self, point: Union[Point, Point3D, None] @@ -245,23 +254,33 @@ def __change_point_coordinate_system( if not point: return None - x = self._from_pitch_dimensions.x_dim.to_base(point.x) - y = self._from_pitch_dimensions.y_dim.to_base(point.y) + base_pitch_length = ( + self._from_pitch_dimensions.pitch_length or DEFAULT_PITCH_LENGTH + ) + base_pitch_width = ( + self._from_pitch_dimensions.pitch_width or DEFAULT_PITCH_WIDTH + ) + + point_base = self._from_pitch_dimensions.to_metric_base( + point, pitch_length=base_pitch_length, pitch_width=base_pitch_width + ) if ( self._from_coordinate_system.vertical_orientation != self._to_coordinate_system.vertical_orientation ): - y = 1 - y + point_base = replace( + point_base, + y=base_pitch_width - point_base.y, + ) - if not self._to_coordinate_system.normalized: - x = self._to_pitch_dimensions.x_dim.from_base(x) - y = self._to_pitch_dimensions.y_dim.from_base(y) + point_to = self._to_pitch_dimensions.from_metric_base( + point_base, + pitch_length=base_pitch_length, + pitch_width=base_pitch_width, + ) - if isinstance(point, Point3D): - return Point3D(x=x, y=y, z=point.z) - else: - return Point(x=x, y=y) + return point_to def __flip_frame(self, frame: Frame): players_data = {} @@ -476,24 +495,24 @@ def __init__( def build( self, - length: float, - width: float, provider: Provider, dataset_type: DatasetType, + pitch_length: Optional[float] = None, + pitch_width: Optional[float] = None, ): from_coordinate_system = build_coordinate_system( # This comment forces black to keep the arguments as multi-line provider, - length=length, - width=width, dataset_type=dataset_type, + pitch_length=pitch_length, + pitch_width=pitch_width, ) to_coordinate_system = build_coordinate_system( self.to_coordinate_system, - length=length, - width=width, dataset_type=self.to_dataset_type or dataset_type, + pitch_length=pitch_length, + pitch_width=pitch_width, ) return DatasetTransformer( diff --git a/kloppy/helpers.py b/kloppy/helpers.py index 21cf6917..ebd50a4a 100644 --- a/kloppy/helpers.py +++ b/kloppy/helpers.py @@ -1,9 +1,7 @@ from typing import Union, Optional -from collections.abc import Sequence from .domain import ( Dataset, - Dimension, Orientation, PitchDimensions, DatasetTransformer, @@ -16,7 +14,7 @@ def transform( dataset: Dataset, to_orientation: Optional[Union[Orientation, str]] = None, - to_pitch_dimensions: Optional[Union[PitchDimensions, Sequence]] = None, + to_pitch_dimensions: Optional[PitchDimensions] = None, to_coordinate_system: Optional[ Union[CoordinateSystem, Provider, str] ] = None, @@ -25,28 +23,19 @@ def transform( if to_orientation is not None and isinstance(to_orientation, str): to_orientation = Orientation[to_orientation.upper()] - # convert raw pitch dimensions to object - if to_pitch_dimensions is not None and isinstance( - to_pitch_dimensions, Sequence - ): - to_pitch_dimensions = PitchDimensions( - x_dim=Dimension(*to_pitch_dimensions[0]), - y_dim=Dimension(*to_pitch_dimensions[1]), - ) - # convert raw coordinate system to object if to_coordinate_system is not None: if isinstance(to_coordinate_system, str): to_coordinate_system = build_coordinate_system( provider=Provider[to_coordinate_system.upper()], - length=dataset.metadata.coordinate_system.length, - width=dataset.metadata.coordinate_system.width, + pitch_length=dataset.metadata.coordinate_system.pitch_length, + pitch_width=dataset.metadata.coordinate_system.pitch_width, ) elif isinstance(to_coordinate_system, Provider): to_coordinate_system = build_coordinate_system( provider=to_coordinate_system, - length=dataset.metadata.coordinate_system.length, - width=dataset.metadata.coordinate_system.width, + pitch_length=dataset.metadata.coordinate_system.pitch_length, + pitch_width=dataset.metadata.coordinate_system.pitch_width, ) return DatasetTransformer.transform_dataset( diff --git a/kloppy/infra/serializers/event/datafactory/deserializer.py b/kloppy/infra/serializers/event/datafactory/deserializer.py index 808cf20a..88a81dd9 100644 --- a/kloppy/infra/serializers/event/datafactory/deserializer.py +++ b/kloppy/infra/serializers/event/datafactory/deserializer.py @@ -357,7 +357,7 @@ def provider(self) -> Provider: return Provider.DATAFACTORY def deserialize(self, inputs: DatafactoryInputs) -> EventDataset: - transformer = self.get_transformer(length=2, width=2) + transformer = self.get_transformer() with performance_logging("load data", logger=logger): data = json.load(inputs.event_data) diff --git a/kloppy/infra/serializers/event/deserializer.py b/kloppy/infra/serializers/event/deserializer.py index ce80705b..7a670772 100644 --- a/kloppy/infra/serializers/event/deserializer.py +++ b/kloppy/infra/serializers/event/deserializer.py @@ -44,13 +44,16 @@ def should_include_event(self, event: Event) -> bool: return event.event_type in self.event_types def get_transformer( - self, length: float, width: float, provider: Optional[Provider] = None + self, + pitch_length: Optional[float] = None, + pitch_width: Optional[float] = None, + provider: Optional[Provider] = None, ) -> DatasetTransformer: return self.transformer_builder.build( - length=length, - width=width, provider=provider or self.provider, dataset_type=DatasetType.EVENT, + pitch_length=pitch_length, + pitch_width=pitch_width, ) @property diff --git a/kloppy/infra/serializers/event/metrica/json_deserializer.py b/kloppy/infra/serializers/event/metrica/json_deserializer.py index 16461bc7..04a54050 100644 --- a/kloppy/infra/serializers/event/metrica/json_deserializer.py +++ b/kloppy/infra/serializers/event/metrica/json_deserializer.py @@ -264,8 +264,8 @@ def deserialize(self, inputs: MetricaJsonEventDataInputs) -> EventDataset: ) transformer = self.get_transformer( - length=metadata.pitch_dimensions.length, - width=metadata.pitch_dimensions.width, + pitch_length=metadata.pitch_dimensions.pitch_length, + pitch_width=metadata.pitch_dimensions.pitch_width, ) with performance_logging("parse data", logger=logger): diff --git a/kloppy/infra/serializers/event/opta/deserializer.py b/kloppy/infra/serializers/event/opta/deserializer.py index 44ca6690..af91d8d7 100644 --- a/kloppy/infra/serializers/event/opta/deserializer.py +++ b/kloppy/infra/serializers/event/opta/deserializer.py @@ -694,7 +694,7 @@ def provider(self) -> Provider: return Provider.OPTA def deserialize(self, inputs: OptaInputs) -> EventDataset: - transformer = self.get_transformer(length=100, width=100) + transformer = self.get_transformer() with performance_logging("load data", logger=logger): f7_root = objectify.fromstring(inputs.f7_data.read()) diff --git a/kloppy/infra/serializers/event/sportec/deserializer.py b/kloppy/infra/serializers/event/sportec/deserializer.py index 420e2821..b2f5cd65 100644 --- a/kloppy/infra/serializers/event/sportec/deserializer.py +++ b/kloppy/infra/serializers/event/sportec/deserializer.py @@ -407,7 +407,8 @@ def deserialize(self, inputs: SportecEventDataInputs) -> EventDataset: sportec_metadata = sportec_metadata_from_xml_elm(match_root) teams = home_team, away_team = sportec_metadata.teams transformer = self.get_transformer( - length=sportec_metadata.x_max, width=sportec_metadata.y_max + pitch_length=sportec_metadata.x_max, + pitch_width=sportec_metadata.y_max, ) periods = [] diff --git a/kloppy/infra/serializers/event/statsbomb/deserializer.py b/kloppy/infra/serializers/event/statsbomb/deserializer.py index 920774a0..f79a13c7 100644 --- a/kloppy/infra/serializers/event/statsbomb/deserializer.py +++ b/kloppy/infra/serializers/event/statsbomb/deserializer.py @@ -38,7 +38,7 @@ def provider(self) -> Provider: def deserialize(self, inputs: StatsBombInputs) -> EventDataset: # Intialize coordinate system transformer - self.transformer = self.get_transformer(length=120, width=80) + self.transformer = self.get_transformer() # Load data from JSON files # and determine fidelity versions for x/y coordinates diff --git a/kloppy/infra/serializers/event/wyscout/deserializer_v2.py b/kloppy/infra/serializers/event/wyscout/deserializer_v2.py index 4e1815cc..a4fc8a0f 100644 --- a/kloppy/infra/serializers/event/wyscout/deserializer_v2.py +++ b/kloppy/infra/serializers/event/wyscout/deserializer_v2.py @@ -476,7 +476,7 @@ def provider(self) -> Provider: return Provider.WYSCOUT def deserialize(self, inputs: WyscoutInputs) -> EventDataset: - transformer = self.get_transformer(length=100, width=100) + transformer = self.get_transformer() with performance_logging("load data", logger=logger): raw_events = json.load(inputs.event_data) diff --git a/kloppy/infra/serializers/event/wyscout/deserializer_v3.py b/kloppy/infra/serializers/event/wyscout/deserializer_v3.py index d4e8a0ce..fa6cb0e7 100644 --- a/kloppy/infra/serializers/event/wyscout/deserializer_v3.py +++ b/kloppy/infra/serializers/event/wyscout/deserializer_v3.py @@ -466,7 +466,7 @@ def provider(self) -> Provider: return Provider.WYSCOUT def deserialize(self, inputs: WyscoutInputs) -> EventDataset: - transformer = self.get_transformer(length=100, width=100) + transformer = self.get_transformer() with performance_logging("load data", logger=logger): raw_events = json.load(inputs.event_data) diff --git a/kloppy/infra/serializers/tracking/deserializer.py b/kloppy/infra/serializers/tracking/deserializer.py index 0635566c..9d2373e7 100644 --- a/kloppy/infra/serializers/tracking/deserializer.py +++ b/kloppy/infra/serializers/tracking/deserializer.py @@ -30,13 +30,16 @@ def __init__( self.transformer_builder = DatasetTransformerBuilder(coordinate_system) def get_transformer( - self, length: float, width: float, provider: Optional[Provider] = None + self, + pitch_length: Optional[float] = None, + pitch_width: Optional[float] = None, + provider: Optional[Provider] = None, ) -> DatasetTransformer: return self.transformer_builder.build( - length=length, - width=width, provider=provider or self.provider, dataset_type=DatasetType.TRACKING, + pitch_length=pitch_length, + pitch_width=pitch_width, ) @property diff --git a/kloppy/infra/serializers/tracking/metrica_csv.py b/kloppy/infra/serializers/tracking/metrica_csv.py index 4bcdb34e..e20c1172 100644 --- a/kloppy/infra/serializers/tracking/metrica_csv.py +++ b/kloppy/infra/serializers/tracking/metrica_csv.py @@ -151,14 +151,10 @@ def __validate_partials( def deserialize( self, inputs: MetricaCSVTrackingDataInputs ) -> TrackingDataset: - # TODO: consider passing this in __init__ - length = 105 - width = 68 - # consider reading this from data frame_rate = 25 - transformer = self.get_transformer(length=length, width=width) + transformer = self.get_transformer() with performance_logging("prepare", logger=logger): home_iterator = self.__create_iterator( diff --git a/kloppy/infra/serializers/tracking/metrica_epts/deserializer.py b/kloppy/infra/serializers/tracking/metrica_epts/deserializer.py index efb0d5b3..b81a6d45 100644 --- a/kloppy/infra/serializers/tracking/metrica_epts/deserializer.py +++ b/kloppy/infra/serializers/tracking/metrica_epts/deserializer.py @@ -99,9 +99,8 @@ def deserialize( if metadata.provider and metadata.pitch_dimensions: transformer = self.get_transformer( - length=metadata.pitch_dimensions.length, - width=metadata.pitch_dimensions.width, - provider=metadata.coordinate_system.provider, + pitch_length=metadata.pitch_dimensions.pitch_length, + pitch_width=metadata.pitch_dimensions.pitch_width, ) else: transformer = None diff --git a/kloppy/infra/serializers/tracking/metrica_epts/metadata.py b/kloppy/infra/serializers/tracking/metrica_epts/metadata.py index 0ffb00c6..67175194 100644 --- a/kloppy/infra/serializers/tracking/metrica_epts/metadata.py +++ b/kloppy/infra/serializers/tracking/metrica_epts/metadata.py @@ -7,6 +7,7 @@ from kloppy.domain import ( Period, PitchDimensions, + NormalizedPitchDimensions, Dimension, Score, Ground, @@ -174,11 +175,11 @@ def _load_pitch_dimensions( field_size_elm = field_size_path.find(metadata_elm).find("FieldSize") if field_size_elm is not None and normalized: - return PitchDimensions( + return NormalizedPitchDimensions( x_dim=Dimension(0, 1), y_dim=Dimension(0, 1), - length=int(field_size_elm.find("Width")), - width=int(field_size_elm.find("Height")), + pitch_length=int(field_size_elm.find("Width")), + pitch_width=int(field_size_elm.find("Height")), ) else: return None @@ -308,8 +309,8 @@ def load_metadata( if provider and pitch_dimensions: from_coordinate_system = build_coordinate_system( provider, - length=pitch_dimensions.length, - width=pitch_dimensions.width, + pitch_length=pitch_dimensions.pitch_length, + pitch_width=pitch_dimensions.pitch_width, ) else: from_coordinate_system = None diff --git a/kloppy/infra/serializers/tracking/secondspectrum.py b/kloppy/infra/serializers/tracking/secondspectrum.py index d9f4121b..5933f13a 100644 --- a/kloppy/infra/serializers/tracking/secondspectrum.py +++ b/kloppy/infra/serializers/tracking/secondspectrum.py @@ -240,7 +240,7 @@ def deserialize(self, inputs: SecondSpectrumInputs) -> TrackingDataset: # Handles the tracking frame data with performance_logging("Loading data", logger=logger): transformer = self.get_transformer( - length=pitch_size_width, width=pitch_size_height + pitch_length=pitch_size_width, pitch_width=pitch_size_height ) def _iter(): diff --git a/kloppy/infra/serializers/tracking/skillcorner.py b/kloppy/infra/serializers/tracking/skillcorner.py index 91e5800f..39d07c1c 100644 --- a/kloppy/infra/serializers/tracking/skillcorner.py +++ b/kloppy/infra/serializers/tracking/skillcorner.py @@ -320,7 +320,7 @@ def deserialize(self, inputs: SkillCornerInputs) -> TrackingDataset: pitch_size_length = metadata["pitch_length"] transformer = self.get_transformer( - length=pitch_size_length, width=pitch_size_width + pitch_length=pitch_size_length, pitch_width=pitch_size_width ) home_team_id = metadata["home_team"]["id"] diff --git a/kloppy/infra/serializers/tracking/sportec/deserializer.py b/kloppy/infra/serializers/tracking/sportec/deserializer.py index 10934a27..038cb3ab 100644 --- a/kloppy/infra/serializers/tracking/sportec/deserializer.py +++ b/kloppy/infra/serializers/tracking/sportec/deserializer.py @@ -123,7 +123,8 @@ def deserialize( teams = home_team, away_team = sportec_metadata.teams periods = sportec_metadata.periods transformer = self.get_transformer( - length=sportec_metadata.x_max, width=sportec_metadata.y_max + pitch_length=sportec_metadata.x_max, + pitch_width=sportec_metadata.y_max, ) with performance_logging("parse raw data", logger=logger): diff --git a/kloppy/infra/serializers/tracking/statsperform.py b/kloppy/infra/serializers/tracking/statsperform.py index b780dbf1..2265f5ad 100644 --- a/kloppy/infra/serializers/tracking/statsperform.py +++ b/kloppy/infra/serializers/tracking/statsperform.py @@ -310,13 +310,8 @@ def deserialize(self, inputs: StatsPerformInputs) -> TrackingDataset: with performance_logging("Loading tracking data", logger=logger): frame_rate = self.__get_frame_rate(tracking_data) - pitch_size_length = 100 - pitch_size_width = 100 - transformer = self.get_transformer( - length=pitch_size_length, - width=pitch_size_width, - ) + transformer = self.get_transformer() def _iter(): n = 0 diff --git a/kloppy/infra/serializers/tracking/tracab/tracab_dat.py b/kloppy/infra/serializers/tracking/tracab/tracab_dat.py index f47a73d8..4a636980 100644 --- a/kloppy/infra/serializers/tracking/tracab/tracab_dat.py +++ b/kloppy/infra/serializers/tracking/tracab/tracab_dat.py @@ -160,7 +160,7 @@ def deserialize(self, inputs: TRACABInputs) -> TrackingDataset: with performance_logging("Loading data", logger=logger): transformer = self.get_transformer( - length=pitch_size_width, width=pitch_size_height + pitch_length=pitch_size_width, pitch_width=pitch_size_height ) def _iter(): diff --git a/kloppy/infra/serializers/tracking/tracab/tracab_json.py b/kloppy/infra/serializers/tracking/tracab/tracab_json.py index 6fcb82b5..ca361183 100644 --- a/kloppy/infra/serializers/tracking/tracab/tracab_json.py +++ b/kloppy/infra/serializers/tracking/tracab/tracab_json.py @@ -197,7 +197,7 @@ def deserialize(self, inputs: TRACABInputs) -> TrackingDataset: teams = [home_team, away_team] transformer = self.get_transformer( - length=pitch_size_length, width=pitch_size_width + pitch_length=pitch_size_length, pitch_width=pitch_size_width ) with performance_logging("Loading data", logger=logger): diff --git a/kloppy/tests/test_datafactory.py b/kloppy/tests/test_datafactory.py index 2daec1af..d02a1af8 100644 --- a/kloppy/tests/test_datafactory.py +++ b/kloppy/tests/test_datafactory.py @@ -76,4 +76,5 @@ def test_correct_deserialization(self, event_data: str): def test_correct_normalized_deserialization(self, event_data: str): dataset = datafactory.load(event_data=event_data) - assert dataset.events[0].coordinates == Point(0.505, 0.505) + assert dataset.events[0].coordinates.x == pytest.approx(0.505, 0.001) + assert dataset.events[0].coordinates.y == pytest.approx(0.505, 0.001) diff --git a/kloppy/tests/test_helpers.py b/kloppy/tests/test_helpers.py index a8bc5fc5..65df77ad 100644 --- a/kloppy/tests/test_helpers.py +++ b/kloppy/tests/test_helpers.py @@ -15,7 +15,7 @@ Point, AttackingDirection, TrackingDataset, - PitchDimensions, + NormalizedPitchDimensions, Dimension, Orientation, Provider, @@ -53,8 +53,11 @@ def _get_tracking_dataset(self): ] metadata = Metadata( flags=(DatasetFlag.BALL_OWNING_TEAM), - pitch_dimensions=PitchDimensions( - x_dim=Dimension(0, 100), y_dim=Dimension(-50, 50) + pitch_dimensions=NormalizedPitchDimensions( + x_dim=Dimension(0, 100), + y_dim=Dimension(-50, 50), + pitch_length=105, + pitch_width=68, ), orientation=Orientation.HOME_AWAY, frame_rate=25, @@ -107,7 +110,12 @@ def test_transform(self): # orientation change AND dimension scale transformed_dataset = tracking_data.transform( to_orientation="AWAY_HOME", - to_pitch_dimensions=[[0, 1], [0, 1]], + to_pitch_dimensions=NormalizedPitchDimensions( + x_dim=Dimension(min=0, max=1), + y_dim=Dimension(min=0, max=1), + pitch_length=105, + pitch_width=68, + ), ) assert transformed_dataset.frames[0].ball_coordinates == Point3D( @@ -122,8 +130,11 @@ def test_transform(self): assert transformed_dataset.metadata.coordinate_system is None assert ( transformed_dataset.metadata.pitch_dimensions - == PitchDimensions( - x_dim=Dimension(min=0, max=1), y_dim=Dimension(min=0, max=1) + == NormalizedPitchDimensions( + x_dim=Dimension(min=0, max=1), + y_dim=Dimension(min=0, max=1), + pitch_length=105, + pitch_width=68, ) ) @@ -131,8 +142,11 @@ def test_transform_to_pitch_dimensions(self): tracking_data = self._get_tracking_dataset() transformed_dataset = tracking_data.transform( - to_pitch_dimensions=PitchDimensions( - x_dim=Dimension(min=0, max=1), y_dim=Dimension(min=0, max=1) + to_pitch_dimensions=NormalizedPitchDimensions( + x_dim=Dimension(min=0, max=1), + y_dim=Dimension(min=0, max=1), + pitch_length=105, + pitch_width=68, ), ) @@ -144,16 +158,25 @@ def test_transform_to_pitch_dimensions(self): ) assert ( transformed_dataset.metadata.pitch_dimensions - == PitchDimensions( - x_dim=Dimension(min=0, max=1), y_dim=Dimension(min=0, max=1) + == NormalizedPitchDimensions( + x_dim=Dimension(min=0, max=1), + y_dim=Dimension(min=0, max=1), + pitch_length=105, + pitch_width=68, ) ) def test_transform_to_orientation(self): + to_pitch_dimensions = NormalizedPitchDimensions( + x_dim=Dimension(min=0, max=1), + y_dim=Dimension(min=0, max=1), + pitch_length=105, + pitch_width=68, + ) # Create a dataset with the KLOPPY pitch dimensions # and HOME_AWAY orientation original = self._get_tracking_dataset().transform( - to_pitch_dimensions=[[0, 1], [0, 1]], + to_pitch_dimensions=to_pitch_dimensions, ) assert original.metadata.orientation == Orientation.HOME_AWAY assert original.frames[0].ball_coordinates == Point3D(x=1, y=0, z=0) @@ -165,7 +188,7 @@ def test_transform_to_orientation(self): # Transform to AWAY_HOME orientation transform1 = original.transform( to_orientation=Orientation.AWAY_HOME, - to_pitch_dimensions=[[0, 1], [0, 1]], + to_pitch_dimensions=to_pitch_dimensions, ) assert transform1.metadata.orientation == Orientation.AWAY_HOME # all coordinates should be flipped @@ -182,7 +205,7 @@ def test_transform_to_orientation(self): # Transform to STATIC_AWAY_HOME orientation transform2 = transform1.transform( to_orientation=Orientation.STATIC_AWAY_HOME, - to_pitch_dimensions=[[0, 1], [0, 1]], + to_pitch_dimensions=to_pitch_dimensions, ) assert transform2.metadata.orientation == Orientation.STATIC_AWAY_HOME # all coordintes in the second half should be flipped @@ -195,7 +218,7 @@ def test_transform_to_orientation(self): # Transform to BALL_OWNING_TEAM orientation transform3 = transform2.transform( to_orientation=Orientation.BALL_OWNING_TEAM, - to_pitch_dimensions=[[0, 1], [0, 1]], + to_pitch_dimensions=to_pitch_dimensions, ) assert transform3.metadata.orientation == Orientation.BALL_OWNING_TEAM # the coordinates of frame 1 should be flipped @@ -213,7 +236,7 @@ def test_transform_to_orientation(self): # this should be identical to BALL_OWNING_TEAM for tracking data transform4 = transform3.transform( to_orientation=Orientation.ACTION_EXECUTING_TEAM, - to_pitch_dimensions=[[0, 1], [0, 1]], + to_pitch_dimensions=to_pitch_dimensions, ) assert ( transform4.metadata.orientation @@ -227,7 +250,7 @@ def test_transform_to_orientation(self): # Transform back to the original HOME_AWAY orientation transform5 = transform4.transform( to_orientation=Orientation.HOME_AWAY, - to_pitch_dimensions=[[0, 1], [0, 1]], + to_pitch_dimensions=to_pitch_dimensions, ) # we should be back at the original for frame1, frame2 in zip(original.frames, transform5.frames): @@ -253,14 +276,13 @@ def test_transform_to_coordinate_system(self, base_dir): to_coordinate_system=Provider.METRICA ) transformerd_coordinate_system = MetricaCoordinateSystem( - normalized=True, - length=dataset.metadata.coordinate_system.length, - width=dataset.metadata.coordinate_system.width, + pitch_length=dataset.metadata.coordinate_system.pitch_length, + pitch_width=dataset.metadata.coordinate_system.pitch_width, ) assert transformed_dataset.records[0].players_data[ player_home_19 - ].coordinates == Point(x=0.3766, y=0.5489999999999999) + ].coordinates == Point(x=0.37660000000000005, y=0.5489999999999999) assert ( transformed_dataset.metadata.orientation == dataset.metadata.orientation @@ -396,8 +418,12 @@ def test_to_pandas_incomplete_pass(self, base_dir): incomplete_passes = df[ (df.event_type == "PASS") & (df.result == "INCOMPLETE") ].reset_index() - assert incomplete_passes.loc[0, "end_coordinates_y"] == 0.90625 - assert incomplete_passes.loc[0, "end_coordinates_x"] == 0.7125 + assert incomplete_passes.loc[0, "end_coordinates_y"] == pytest.approx( + 0.91519, 1e-4 + ) + assert incomplete_passes.loc[0, "end_coordinates_x"] == pytest.approx( + 0.70945, 1e-4 + ) def test_to_pandas_additional_columns(self): tracking_data = self._get_tracking_dataset() diff --git a/kloppy/tests/test_metadata.py b/kloppy/tests/test_metadata.py index 5a7775dd..1edd82ef 100644 --- a/kloppy/tests/test_metadata.py +++ b/kloppy/tests/test_metadata.py @@ -1,21 +1,137 @@ -from kloppy.domain import Dimension, PitchDimensions +from math import sqrt +import pytest + +from kloppy.domain import ( + Dimension, + NormalizedPitchDimensions, + Point, + Point3D, + OptaPitchDimensions, + Unit, + MetricPitchDimensions, +) +from kloppy.domain.services.transformers import DatasetTransformer class TestPitchdimensions: - def test_pitchdimensions_properties(self): - pitch_without_scale = PitchDimensions( - x_dim=Dimension(-100, 100), y_dim=Dimension(-50, 50) + def test_normalized_pitchdimensions(self): + pitch_with_scale = NormalizedPitchDimensions( + x_dim=Dimension(-100, 100), + y_dim=Dimension(-50, 50), + pitch_length=120, + pitch_width=80, + ) + + assert pitch_with_scale.pitch_length == 120 + assert pitch_with_scale.pitch_width == 80 + + def test_to_metric_base_dimensions(self): + pitch = OptaPitchDimensions() + + ifab_point = pitch.to_metric_base(Point(11.5, 50)) + assert ifab_point == Point(11, 34) + + ifab_point = pitch.to_metric_base(Point3D(0, 50, 38)) + assert ifab_point == Point3D(0, 34, 2.44) + + ifab_point = pitch.to_metric_base( + Point(60, 61), pitch_length=105, pitch_width=68 + ) + assert round(ifab_point.x, 2) == 62.78 + assert round(ifab_point.y, 2) == 41.72 + + def test_to_metric_base_dimensions_out_of_bounds(self): + pitch = NormalizedPitchDimensions( + x_dim=Dimension(-100, 100), + y_dim=Dimension(-50, 50), + pitch_length=120, + pitch_width=80, + ) + ifab_point = pitch.to_metric_base(Point(-100, 0)) + assert ifab_point == Point(0, 34) + ifab_point = pitch.to_metric_base(Point(-105, 0)) + assert ifab_point == Point(-2.625, 34) + ifab_point = pitch.to_metric_base(Point(105, 0)) + assert ifab_point == Point(107.625, 34) + + def test_from_metric_base_dimensions(self): + pitch = OptaPitchDimensions() + + opta_point = pitch.from_metric_base(Point(11, 34)) + assert opta_point == Point(11.5, 50) + + opta_point = pitch.from_metric_base(Point3D(0, 34, 2.44)) + assert opta_point == Point3D(0, 50, 38) + + ifab_point = pitch.from_metric_base( + Point(62.78, 41.72), pitch_length=105, pitch_width=68 + ) + assert round(ifab_point.x, 2) == 60 + assert round(ifab_point.y, 2) == 61 + + def test_from_metric_base_dimensions_out_of_bounds(self): + pitch = NormalizedPitchDimensions( + x_dim=Dimension(-100, 100), + y_dim=Dimension(-50, 50), + pitch_length=120, + pitch_width=80, + ) + point = pitch.from_metric_base(Point(0, 34)) + assert point == Point(-100, 0) + point = pitch.from_metric_base(Point(-2.625, 34)) + assert point == Point(-105, 0) + point = pitch.from_metric_base(Point(107.625, 34)) + assert point == Point(105, 0) + + def test_distance_between(self): + pitch = OptaPitchDimensions(pitch_length=105, pitch_width=68) + + distance = pitch.distance_between( + Point(0, 0), + Point(100, 100), + ) + assert distance == sqrt(105**2 + 68**2) + + distance = pitch.distance_between(Point(0, 50), Point(11.5, 50)) + assert distance == 11 + + distance = pitch.distance_between( + Point(100, 50), Point(100 - 11.5, 50) ) + assert distance == 11 - assert pitch_without_scale.length is None - assert pitch_without_scale.width is None + distance = pitch.distance_between( + Point(0, 50), Point(11.5, 50), unit=Unit.CENTIMETERS + ) + assert distance == 1100 - pitch_with_scale = PitchDimensions( + pitch = NormalizedPitchDimensions( x_dim=Dimension(-100, 100), y_dim=Dimension(-50, 50), - length=120, - width=80, + pitch_length=120, + pitch_width=80, + ) + distance = pitch.distance_between( + Point(-100, -50), + Point(100, 50), ) + assert distance == sqrt(120**2 + 80**2) - assert pitch_with_scale.length == 120 - assert pitch_with_scale.width == 80 + def test_transform(self): + transformer = DatasetTransformer( + from_pitch_dimensions=OptaPitchDimensions(), + to_pitch_dimensions=MetricPitchDimensions( + x_dim=Dimension(0, 105), + y_dim=Dimension(0, 68), + pitch_length=105, + pitch_width=68, + standardized=False, + ), + ) + # the corner of the penalty area should remain the corner of + # the penalty area in the new coordinate system + transformed_point = transformer.change_point_dimensions( + Point(17, 78.9) + ) + assert transformed_point.x == pytest.approx(16.5) + assert transformed_point.y == pytest.approx(54.16) diff --git a/kloppy/tests/test_metrica_epts.py b/kloppy/tests/test_metrica_epts.py index 6031b451..028a8af8 100644 --- a/kloppy/tests/test_metrica_epts.py +++ b/kloppy/tests/test_metrica_epts.py @@ -145,9 +145,8 @@ def test_correct_deserialization(self, meta_data: str, raw_data: str): first_player ].coordinates == Point(x=0.30602, y=0.97029) - assert dataset.records[0].ball_coordinates == Point3D( - x=0.52867, y=0.7069, z=None - ) + assert dataset.records[0].ball_coordinates.x == pytest.approx(0.52867) + assert dataset.records[0].ball_coordinates.y == pytest.approx(0.7069) def test_other_data_deserialization(self, meta_data: str, raw_data: str): dataset = metrica.load_tracking_epts( diff --git a/kloppy/tests/test_opta.py b/kloppy/tests/test_opta.py index 15c63d46..951ab98e 100644 --- a/kloppy/tests/test_opta.py +++ b/kloppy/tests/test_opta.py @@ -25,7 +25,7 @@ Orientation, PassQualifier, PassType, - PitchDimensions, + OptaPitchDimensions, Point, Point, Point3D, @@ -143,14 +143,12 @@ def test_periods(self, dataset): def test_pitch_dimensions(self, dataset): """It should set the correct pitch dimensions""" - assert dataset.metadata.pitch_dimensions == PitchDimensions( - x_dim=Dimension(0, 100), y_dim=Dimension(0, 100) - ) + assert dataset.metadata.pitch_dimensions == OptaPitchDimensions() def test_coordinate_system(self, dataset): """It should set the correct coordinate system""" assert dataset.metadata.coordinate_system == build_coordinate_system( - Provider.OPTA, width=100, length=100 + Provider.OPTA ) def test_score(self, dataset): @@ -199,7 +197,8 @@ def test_correct_normalized_deserialization(self, base_dir): f24_data=base_dir / "files" / "opta_f24.xml", ) event = dataset.get_event_by_id("1510681159") - assert event.coordinates == Point(0.501, 0.506) + assert event.coordinates.x == pytest.approx(0.501, 0.001) + assert event.coordinates.y == pytest.approx(0.50672, 0.001) def test_ball_owning_team(self, dataset: EventDataset): """Test if the ball owning team is correctly set""" diff --git a/kloppy/tests/test_secondspectrum.py b/kloppy/tests/test_secondspectrum.py index 9ce0ddb0..b31f7a4a 100644 --- a/kloppy/tests/test_secondspectrum.py +++ b/kloppy/tests/test_secondspectrum.py @@ -79,7 +79,7 @@ def test_correct_deserialization( home_player = dataset.metadata.teams[0].players[2] assert home_player.player_id == "8xwx2" assert dataset.records[0].players_coordinates[home_player] == Point( - x=-8.943903672572427, y=-28.171654132650364 + x=-8.943903672572427, y=-28.171654132650365 ) away_player = dataset.metadata.teams[1].players[3] @@ -112,7 +112,7 @@ def test_correct_normalized_deserialization( home_player = dataset.metadata.teams[0].players[2] assert dataset.records[0].players_coordinates[home_player] == Point( - x=0.4146981051733674, y=0.9144718866065965 + x=0.4146981051733674, y=0.9144718866065964 ) assert ( dataset.records[0].players_data[home_player].speed diff --git a/kloppy/tests/test_skillcorner.py b/kloppy/tests/test_skillcorner.py index 4e715e7f..a84518fa 100644 --- a/kloppy/tests/test_skillcorner.py +++ b/kloppy/tests/test_skillcorner.py @@ -123,7 +123,7 @@ def test_correct_normalized_deserialization( home_player = dataset.metadata.teams[0].players[2] assert dataset.records[0].players_data[ home_player - ].coordinates == Point(x=0.8225688718076191, y=0.6405503322430882) + ].coordinates == Point(x=0.8225688718076191, y=0.6405503322430883) def test_skip_empty_frames(self, meta_data: str, raw_data: str): dataset = skillcorner.load( diff --git a/kloppy/tests/test_sportec.py b/kloppy/tests/test_sportec.py index 5eb36646..b39deeee 100644 --- a/kloppy/tests/test_sportec.py +++ b/kloppy/tests/test_sportec.py @@ -93,7 +93,7 @@ def test_correct_normalized_event_data_deserialization( event_data=event_data, meta_data=meta_data ) - assert dataset.events[0].coordinates == Point(0.5640999999999999, 1) + assert dataset.events[0].coordinates == Point(0.5641, 1) def test_pass_receiver_coordinates( self, event_data: Path, meta_data: Path diff --git a/kloppy/tests/test_statsbomb.py b/kloppy/tests/test_statsbomb.py index 4160041f..929d3da8 100644 --- a/kloppy/tests/test_statsbomb.py +++ b/kloppy/tests/test_statsbomb.py @@ -12,7 +12,7 @@ TakeOnResult, Dimension, BallState, - PitchDimensions, + ImperialPitchDimensions, CardQualifier, DatasetFlag, CarryResult, @@ -168,14 +168,14 @@ def test_periods(self, dataset): def test_pitch_dimensions(self, dataset): """It should set the correct pitch dimensions""" - assert dataset.metadata.pitch_dimensions == PitchDimensions( - x_dim=Dimension(0, 120), y_dim=Dimension(0, 80) + assert dataset.metadata.pitch_dimensions == ImperialPitchDimensions( + x_dim=Dimension(0, 120), y_dim=Dimension(0, 80), standardized=True ) def test_coordinate_system(self, dataset): """It should set the correct coordinate system""" assert dataset.metadata.coordinate_system == build_coordinate_system( - Provider.STATSBOMB, width=80, length=120 + Provider.STATSBOMB ) @pytest.mark.xfail @@ -511,10 +511,10 @@ def test_correct_normalized_deserialization(self): player_3089 = dataset.metadata.teams[0].get_player_by_id("3089") assert freeze_frame.players_coordinates[ player_3089 - ].x == pytest.approx(0.762, abs=1e-2) + ].x == pytest.approx(0.756, abs=1e-2) assert freeze_frame.players_coordinates[ player_3089 - ].y == pytest.approx(0.352, abs=1e-2) + ].y == pytest.approx(0.340, abs=1e-2) # The 360 freeze-frame should have standardized coordinates pass_event = dataset.get_event_by_id( @@ -529,28 +529,28 @@ def test_correct_normalized_deserialization(self): print(coordinates_per_team) assert coordinates_per_team == { "Belgium": [ - Point(x=0.29992169834015553, y=0.5208156671179599), - Point(x=0.30643713954672475, y=0.7026264079766127), - Point(x=0.3214467545271119, y=0.3826089652330575), - Point(x=0.405612967947393, y=0.9422736509345233), - Point(x=0.4062973621807315, y=0.0482915505598324), - Point(x=0.42465606231382946, y=0.5964959025774841), - Point(x=0.4508398556294095, y=0.5289610882963541), - Point(x=0.4890186975946768, y=0.2568344237508965), - Point(x=0.4950593030298559, y=0.7029586200529815), - Point(x=0.4995833333333334, y=0.499375), + Point(x=0.30230680550305883, y=0.5224074534269804), + Point(x=0.3084765294211162, y=0.7184206360532097), + Point(x=0.3226897158515237, y=0.37349986446702277), + Point(x=0.4023899669270551, y=0.9477821783616865), + Point(x=0.40303804636433893, y=0.04368333723843663), + Point(x=0.4212117680196045, y=0.6039661694463063), + Point(x=0.4485925347438968, y=0.5311757597543106), + Point(x=0.48851669519900487, y=0.23786065306469226), + Point(x=0.494833442596935, y=0.7187789039787056), + Point(x=0.49956428571428574, y=0.49932720588235296), ], "Portugal": [ - Point(x=0.5007398055585528, y=0.64528577145353), - Point(x=0.503027413811032, y=0.8161700273569469), - Point(x=0.5276528464860737, y=0.2579702535077385), - Point(x=0.5278342018640673, y=0.3780770836091537), - Point(x=0.5771632092108575, y=0.5977748576718983), - Point(x=0.6031968709341459, y=0.5636937522375899), - Point(x=0.661570167297097, y=0.3648423832684863), - Point(x=0.6653717429879116, y=0.7616501561039648), - Point(x=0.665577307051358, y=0.5960104748540174), - Point(x=0.6887022647928279, y=0.4433104586034662), + Point(x=0.5007736252412294, y=0.6565826947047873), + Point(x=0.5031658098709648, y=0.8337119724588331), + Point(x=0.5289169766111513, y=0.23908556750834548), + Point(x=0.5291066225207104, y=0.36861254114712655), + Point(x=0.5806906702033539, y=0.605345434744204), + Point(x=0.6059524111158714, y=0.568591301432695), + Point(x=0.6612283488962987, y=0.3543398250934656), + Point(x=0.6648282083259679, y=0.7820736977591778), + Point(x=0.6650228649084968, y=0.6034426689602147), + Point(x=0.6869207840759295, y=0.43896225927824783), ], } diff --git a/kloppy/tests/test_tracab.py b/kloppy/tests/test_tracab.py index 2976feaf..f3f67d33 100644 --- a/kloppy/tests/test_tracab.py +++ b/kloppy/tests/test_tracab.py @@ -222,4 +222,4 @@ def test_correct_normalized_deserialization( assert dataset.records[0].players_data[ player_home_19 - ].coordinates == Point(x=0.3766, y=0.5489999999999999) + ].coordinates == Point(x=0.37660000000000005, y=0.5489999999999999) diff --git a/kloppy/tests/test_wyscout.py b/kloppy/tests/test_wyscout.py index 4bcc58a3..10d058e5 100644 --- a/kloppy/tests/test_wyscout.py +++ b/kloppy/tests/test_wyscout.py @@ -142,7 +142,9 @@ def test_foul_committed_event(self, dataset: EventDataset): def test_correct_normalized_deserialization(self, event_v2_data: Path): dataset = wyscout.load(event_data=event_v2_data, data_version="V2") - assert dataset.records[2].coordinates == Point(0.29, 0.06) + assert dataset.records[2].coordinates == Point( + 0.2981354967264447, 0.06427244582043344 + ) class TestWyscoutV3: @@ -192,7 +194,9 @@ def test_coordinates(self, dataset: EventDataset): def test_normalized_deserialization(self, event_v3_data: Path): dataset = wyscout.load(event_data=event_v3_data, data_version="V3") - assert dataset.records[2].coordinates == Point(0.36, 0.78) + assert dataset.records[2].coordinates == Point( + 0.36417591801878735, 0.7695098039215686 + ) def test_goalkeeper_event(self, dataset: EventDataset): goalkeeper_event = dataset.get_event_by_id(1331979498)