From 21f712ed1326fe516b545222f548623b015d3d74 Mon Sep 17 00:00:00 2001 From: Aleksandrs Drozdovs Date: Mon, 3 Aug 2026 20:09:12 +0100 Subject: [PATCH] feat: add exclude_missing_ball_frames option across all tracking providers Adds an exclude_missing_ball_frames parameter (default False) to the tracking deserializers for PFF, Second Spectrum, SkillCorner, Sportec, StatsPerform, and Tracab. When enabled, frames with sentinel/missing ball coordinates are parsed with ball_coordinates=None instead of being silently dropped or producing bogus values, matching the design converged on in PySport/kloppy#523 discussion. Sportec has no native missing-ball sentinel, so the flag is accepted as a documented no-op there for interface consistency. Addresses PySport/kloppy#528. --- kloppy/_providers/pff.py | 5 +++ kloppy/_providers/secondspectrum.py | 4 ++ kloppy/_providers/skillcorner.py | 4 ++ kloppy/_providers/sportec.py | 7 +++ kloppy/_providers/statsperform.py | 4 ++ kloppy/_providers/tracab.py | 4 ++ .../serializers/tracking/deserializer.py | 5 +++ kloppy/infra/serializers/tracking/pff.py | 11 ++++- .../serializers/tracking/secondspectrum.py | 27 ++++++++--- .../infra/serializers/tracking/skillcorner.py | 11 ++++- .../tracking/sportec/deserializer.py | 15 ++++++- .../serializers/tracking/statsperform.py | 10 ++++- .../tracking/tracab/deserializer.py | 12 ++++- ...cond_spectrum_fake_data_missing_ball.jsonl | 2 + kloppy/tests/test_pff.py | 18 ++++++++ kloppy/tests/test_secondspectrum.py | 45 +++++++++++++++++++ kloppy/tests/test_skillcorner.py | 14 ++++++ kloppy/tests/test_sportec.py | 13 ++++++ kloppy/tests/test_statsperform.py | 17 +++++++ kloppy/tests/test_tracab.py | 14 ++++++ 20 files changed, 231 insertions(+), 11 deletions(-) create mode 100644 kloppy/tests/files/second_spectrum_fake_data_missing_ball.jsonl diff --git a/kloppy/_providers/pff.py b/kloppy/_providers/pff.py index ce7e9628e..e619f7798 100644 --- a/kloppy/_providers/pff.py +++ b/kloppy/_providers/pff.py @@ -14,6 +14,7 @@ def load_tracking( limit: Optional[int] = None, coordinates: Optional[str] = None, only_alive: Optional[bool] = False, + exclude_missing_ball_frames: Optional[bool] = False, ) -> TrackingDataset: """ Load and deserialize tracking data from the provided metadata, roster metadata, and raw data files. @@ -26,6 +27,9 @@ def load_tracking( limit (Optional[int], optional): The maximum number of records to process. If None, all records are processed. Defaults to None. coordinates (Optional[str], optional): The coordinate system to use for the tracking data (e.g., "pff"). Defaults to None. only_alive (Optional[bool], optional): Whether to include only sequences when the ball is in play. Defaults to False. + exclude_missing_ball_frames (Optional[bool], optional): Whether to + exclude frames in which the ball coordinates are missing (e.g. + not tracked). Defaults to False. Returns: TrackingDataset: A deserialized TrackingDataset object containing the processed tracking data. @@ -35,6 +39,7 @@ def load_tracking( limit=limit, coordinate_system=coordinates, only_alive=only_alive, + exclude_missing_ball_frames=exclude_missing_ball_frames, ) with ( open_as_file(meta_data) as meta_data_fp, diff --git a/kloppy/_providers/secondspectrum.py b/kloppy/_providers/secondspectrum.py index ef9f95d86..8228516cb 100644 --- a/kloppy/_providers/secondspectrum.py +++ b/kloppy/_providers/secondspectrum.py @@ -16,6 +16,7 @@ def load( limit: Optional[int] = None, coordinates: Optional[str] = None, only_alive: Optional[bool] = False, + exclude_missing_ball_frames: Optional[bool] = False, ) -> TrackingDataset: """ Load SecondSpectrum tracking data. @@ -30,6 +31,8 @@ def load( limit: Limit the number of frames to load to the first `limit` frames. coordinates: The coordinate system to use. only_alive: Only include frames in which the game is not paused. + exclude_missing_ball_frames: Exclude frames in which the ball + coordinates are missing (e.g. not tracked). Returns: The parsed tracking data. @@ -39,6 +42,7 @@ def load( limit=limit, coordinate_system=coordinates, only_alive=only_alive, + exclude_missing_ball_frames=exclude_missing_ball_frames, ) with ( open_as_file(meta_data) as meta_data_fp, diff --git a/kloppy/_providers/skillcorner.py b/kloppy/_providers/skillcorner.py index 97a6003e2..1f72728b2 100644 --- a/kloppy/_providers/skillcorner.py +++ b/kloppy/_providers/skillcorner.py @@ -20,6 +20,7 @@ def load( include_empty_frames: Optional[bool] = False, data_version: Optional[str] = None, only_alive: Optional[bool] = False, + exclude_missing_ball_frames: Optional[bool] = False, ) -> TrackingDataset: """ Load SkillCorner broadcast tracking data. @@ -33,6 +34,8 @@ def load( include_empty_frames: Include frames in which no objects were tracked. only_alive: Only include frames in which the game is not paused. data_version: Specify the input data version. + exclude_missing_ball_frames: Exclude frames in which the ball + coordinates are missing (e.g. not tracked). Returns: The parsed tracking data. @@ -50,6 +53,7 @@ def load( include_empty_frames=include_empty_frames, data_version=data_version, only_alive=only_alive, + exclude_missing_ball_frames=exclude_missing_ball_frames, ) with ( open_as_file(meta_data) as meta_data_fp, diff --git a/kloppy/_providers/sportec.py b/kloppy/_providers/sportec.py index 9f232cf39..04ea0514b 100644 --- a/kloppy/_providers/sportec.py +++ b/kloppy/_providers/sportec.py @@ -57,6 +57,7 @@ def load_tracking( limit: Optional[int] = None, coordinates: Optional[str] = None, only_alive: Optional[bool] = False, + exclude_missing_ball_frames: Optional[bool] = False, ) -> TrackingDataset: """ Load Sportec Solutions tracking data. @@ -68,6 +69,8 @@ def load_tracking( limit: Limit the number of frames to load to the first `limit` frames. coordinates: The coordinate system to use. only_alive: Only include frames in which the game is not paused. + exclude_missing_ball_frames: Exclude frames in which the ball + coordinates are missing (e.g. not tracked). Returns: The parsed tracking data. @@ -77,6 +80,7 @@ def load_tracking( limit=limit, coordinate_system=coordinates, only_alive=only_alive, + exclude_missing_ball_frames=exclude_missing_ball_frames, ) with ( open_as_file(meta_data) as meta_data_fp, @@ -194,6 +198,7 @@ def load_open_tracking_data( limit: Optional[int] = None, coordinates: Optional[str] = None, only_alive: Optional[bool] = False, + exclude_missing_ball_frames: Optional[bool] = False, ) -> TrackingDataset: """ Load tracking data for a game from the IDSSE dataset. @@ -210,6 +215,7 @@ def load_open_tracking_data( limit: coordinates: only_alive: + exclude_missing_ball_frames: Notes: The dataset contains seven full matches of raw event and position data @@ -242,4 +248,5 @@ def load_open_tracking_data( limit=limit, coordinates=coordinates, only_alive=only_alive, + exclude_missing_ball_frames=exclude_missing_ball_frames, ) diff --git a/kloppy/_providers/statsperform.py b/kloppy/_providers/statsperform.py index b23f17047..1527296cc 100644 --- a/kloppy/_providers/statsperform.py +++ b/kloppy/_providers/statsperform.py @@ -105,6 +105,7 @@ def load_tracking( limit: Optional[int] = None, coordinates: Optional[str] = None, only_alive: Optional[bool] = False, + exclude_missing_ball_frames: Optional[bool] = False, ) -> TrackingDataset: """ Load Stats Perform tracking data. @@ -119,6 +120,8 @@ def load_tracking( limit: Limit the number of frames to load to the first `limit` frames. coordinates: The coordinate system to use. only_alive: Only include frames in which the game is not paused. + exclude_missing_ball_frames: Exclude frames in which the ball + coordinates are missing (e.g. not tracked). Returns: The parsed tracking data. @@ -129,6 +132,7 @@ def load_tracking( limit=limit, coordinate_system=coordinates, only_alive=only_alive, + exclude_missing_ball_frames=exclude_missing_ball_frames, ) with ( open_as_file(ma1_data) as ma1_data_fp, diff --git a/kloppy/_providers/tracab.py b/kloppy/_providers/tracab.py index dae0d0d10..e47308ac4 100644 --- a/kloppy/_providers/tracab.py +++ b/kloppy/_providers/tracab.py @@ -17,6 +17,7 @@ def load( coordinates: Optional[str] = None, only_alive: bool = False, file_format: Optional[str] = None, + exclude_missing_ball_frames: Optional[bool] = False, ) -> TrackingDataset: """ Load TRACAB tracking data. @@ -29,6 +30,8 @@ def load( coordinates: The coordinate system to use. only_alive: Only include frames in which the game is not paused. file_format: Deprecated. The format will be inferred based on the file extensions. + exclude_missing_ball_frames: Exclude frames in which the ball + coordinates are missing (e.g. not tracked). Returns: The parsed tracking data. @@ -79,6 +82,7 @@ def load( limit=limit, coordinate_system=coordinates, only_alive=only_alive, + exclude_missing_ball_frames=exclude_missing_ball_frames, ) with ( open_as_file(meta_data) as meta_data_fp, diff --git a/kloppy/infra/serializers/tracking/deserializer.py b/kloppy/infra/serializers/tracking/deserializer.py index 67a38c5bb..ee6850d73 100644 --- a/kloppy/infra/serializers/tracking/deserializer.py +++ b/kloppy/infra/serializers/tracking/deserializer.py @@ -18,6 +18,7 @@ def __init__( limit: Optional[int] = None, sample_rate: Optional[float] = None, coordinate_system: Optional[Union[str, Provider]] = None, + exclude_missing_ball_frames: Optional[bool] = False, ): if not limit: limit = 0 @@ -29,6 +30,10 @@ def __init__( self.transformer_builder = DatasetTransformerBuilder(coordinate_system) + if not exclude_missing_ball_frames: + exclude_missing_ball_frames = False + self.exclude_missing_ball_frames = exclude_missing_ball_frames + def get_transformer( self, pitch_length: Optional[float] = None, diff --git a/kloppy/infra/serializers/tracking/pff.py b/kloppy/infra/serializers/tracking/pff.py index 19a6fc4a2..317168626 100644 --- a/kloppy/infra/serializers/tracking/pff.py +++ b/kloppy/infra/serializers/tracking/pff.py @@ -101,8 +101,11 @@ def __init__( sample_rate: Optional[float] = None, coordinate_system: Optional[Union[str, Provider]] = None, only_alive: Optional[bool] = False, + exclude_missing_ball_frames: Optional[bool] = False, ): - super().__init__(limit, sample_rate, coordinate_system) + super().__init__( + limit, sample_rate, coordinate_system, exclude_missing_ball_frames + ) self.only_alive = only_alive self._ball_owning_team = None @@ -369,6 +372,12 @@ def _iter(): ) ) + if ( + self.exclude_missing_ball_frames + and frame.ball_coordinates is None + ): + continue + # if Regular Time if _frame_period in {1, 2}: frames.append(frame) diff --git a/kloppy/infra/serializers/tracking/secondspectrum.py b/kloppy/infra/serializers/tracking/secondspectrum.py index 112aa376c..b78e7111b 100644 --- a/kloppy/infra/serializers/tracking/secondspectrum.py +++ b/kloppy/infra/serializers/tracking/secondspectrum.py @@ -73,8 +73,11 @@ def __init__( sample_rate: Optional[float] = None, coordinate_system: Optional[Union[str, Provider]] = None, only_alive: Optional[bool] = False, + exclude_missing_ball_frames: Optional[bool] = False, ): - super().__init__(limit, sample_rate, coordinate_system) + super().__init__( + limit, sample_rate, coordinate_system, exclude_missing_ball_frames + ) self.only_alive = only_alive @property @@ -88,10 +91,17 @@ def _frame_from_framedata(cls, teams, period, frame_data): if frame_data["ball"]["xyz"]: ball_x, ball_y, ball_z = frame_data["ball"]["xyz"] - ball_coordinates = Point3D( - float(ball_x), float(ball_y), float(ball_z) - ) - ball_speed = frame_data["ball"]["speed"] + if float(ball_z) == -10: + # Second Spectrum uses a z-coordinate of -10 as a sentinel + # value to indicate that the ball position is unknown (e.g. + # dead ball frames where the ball is not tracked). + ball_coordinates = None + ball_speed = None + else: + ball_coordinates = Point3D( + float(ball_x), float(ball_y), float(ball_z) + ) + ball_speed = frame_data["ball"]["speed"] else: ball_coordinates = None ball_speed = None @@ -302,6 +312,13 @@ def _iter(): frame = self._frame_from_framedata(teams, period, frame_data) frame = transformer.transform_frame(frame) + + if ( + self.exclude_missing_ball_frames + and frame.ball_coordinates is None + ): + continue + frames.append(frame) n_frames += 1 diff --git a/kloppy/infra/serializers/tracking/skillcorner.py b/kloppy/infra/serializers/tracking/skillcorner.py index 9905d573e..2c3835bb1 100644 --- a/kloppy/infra/serializers/tracking/skillcorner.py +++ b/kloppy/infra/serializers/tracking/skillcorner.py @@ -75,8 +75,11 @@ def __init__( include_empty_frames: Optional[bool] = False, data_version: Optional[str] = None, only_alive: bool = False, + exclude_missing_ball_frames: Optional[bool] = False, ): - super().__init__(limit, sample_rate, coordinate_system) + super().__init__( + limit, sample_rate, coordinate_system, exclude_missing_ball_frames + ) self.include_empty_frames = include_empty_frames self.data_version = data_version self.only_alive = only_alive @@ -566,6 +569,12 @@ def _iter(): continue frame = transformer.transform_frame(frame) + if ( + self.exclude_missing_ball_frames + and frame.ball_coordinates is None + ): + continue + frames.append(frame) n_frames += 1 diff --git a/kloppy/infra/serializers/tracking/sportec/deserializer.py b/kloppy/infra/serializers/tracking/sportec/deserializer.py index d45f347d2..391f13bcf 100644 --- a/kloppy/infra/serializers/tracking/sportec/deserializer.py +++ b/kloppy/infra/serializers/tracking/sportec/deserializer.py @@ -308,8 +308,11 @@ def __init__( sample_rate: Optional[float] = None, coordinate_system: Optional[Union[str, Provider]] = None, only_alive: bool = False, + exclude_missing_ball_frames: Optional[bool] = False, ): - super().__init__(limit, sample_rate, coordinate_system) + super().__init__( + limit, sample_rate, coordinate_system, exclude_missing_ball_frames + ) self.only_alive = only_alive def deserialize(self, inputs: SportecTrackingDataInputs) -> TrackingDataset: @@ -423,7 +426,15 @@ def deserialize(self, inputs: SportecTrackingDataInputs) -> TrackingDataset: ball_speed=float(ball_data.get("S", 0)), other_data={}, ) - frames.append(transformer.transform_frame(frame)) + frame = transformer.transform_frame(frame) + + if ( + self.exclude_missing_ball_frames + and frame.ball_coordinates is None + ): + continue + + frames.append(frame) frame_count += 1 except KeyError as e: logger.warning( diff --git a/kloppy/infra/serializers/tracking/statsperform.py b/kloppy/infra/serializers/tracking/statsperform.py index fff8d8764..d541bb5cc 100644 --- a/kloppy/infra/serializers/tracking/statsperform.py +++ b/kloppy/infra/serializers/tracking/statsperform.py @@ -43,8 +43,11 @@ def __init__( sample_rate: Optional[float] = None, coordinate_system: Optional[Union[str, Provider]] = None, only_alive: Optional[bool] = False, + exclude_missing_ball_frames: Optional[bool] = False, ): - super().__init__(limit, sample_rate, coordinate_system) + super().__init__( + limit, sample_rate, coordinate_system, exclude_missing_ball_frames + ) self.only_alive = only_alive self._provider = provider @@ -190,6 +193,11 @@ def _iter(): self.only_alive and frame.ball_state == BallState.DEAD ): continue + if ( + self.exclude_missing_ball_frames + and frame.ball_coordinates is None + ): + continue frames.append(frame) n_frames += 1 diff --git a/kloppy/infra/serializers/tracking/tracab/deserializer.py b/kloppy/infra/serializers/tracking/tracab/deserializer.py index fb8e3067f..4a32fcf8a 100644 --- a/kloppy/infra/serializers/tracking/tracab/deserializer.py +++ b/kloppy/infra/serializers/tracking/tracab/deserializer.py @@ -31,8 +31,11 @@ def __init__( sample_rate: Optional[float] = None, coordinate_system: Optional[Union[str, Provider]] = None, only_alive: bool = False, + exclude_missing_ball_frames: Optional[bool] = False, ): - super().__init__(limit, sample_rate, coordinate_system) + super().__init__( + limit, sample_rate, coordinate_system, exclude_missing_ball_frames + ) self.only_alive = only_alive @property @@ -68,6 +71,13 @@ def deserialize(self, inputs: TRACABInputs) -> TrackingDataset: ) ): frame = transformer.transform_frame(frame) + + if ( + self.exclude_missing_ball_frames + and frame.ball_coordinates is None + ): + continue + frames.append(frame) if self.limit and n + 1 >= (self.limit / self.sample_rate): diff --git a/kloppy/tests/files/second_spectrum_fake_data_missing_ball.jsonl b/kloppy/tests/files/second_spectrum_fake_data_missing_ball.jsonl new file mode 100644 index 000000000..3735fe4fd --- /dev/null +++ b/kloppy/tests/files/second_spectrum_fake_data_missing_ball.jsonl @@ -0,0 +1,2 @@ +{"period": 1, "frameIdx": 0, "gameClock": 0.0, "wallClock": 0, "homePlayers": [{"playerId": "20grw", "number": 18, "xyz": [47.08390519972812, -23.939657073124085, 0.0], "speed": 4.159728710616509}, {"playerId": "6isrbi", "number": 19, "xyz": [19.17951616720707, -18.48668548635993, 0.0], "speed": 2.970267598952689}, {"playerId": "um288", "number": 4, "xyz": [-30.509158016824763, 33.65864992667011, 0.0], "speed": 6.348061414609102}, {"playerId": "4g3xq", "number": 10, "xyz": [30.17646822220036, -23.553471074622724, 0.0], "speed": 7.198769564560433}, {"playerId": "8xwx2", "number": 2, "xyz": [-8.943903672572427, -28.171654132650364, 0.0], "speed": 6.578958220040129}, {"playerId": "92vl5", "number": 13, "xyz": [-18.92639312241409, -19.33210269741475, 0.0], "speed": 1.8135661713032674}, {"playerId": "bvgtb", "number": 16, "xyz": [-13.924452254475682, 23.13785420227054, 0.0], "speed": 7.226293244793556}, {"playerId": "1lxq7v", "number": 14, "xyz": [-9.914449514605563, 9.328319654492567, 0.0], "speed": 1.9584318294446863}, {"playerId": "45w3q", "number": 3, "xyz": [-7.022877875154813, 23.023272240950618, 0.0], "speed": 7.109345942137604}, {"playerId": "qyk3c", "number": 15, "xyz": [-4.987389181079115, 30.22372922092669, 0.0], "speed": 5.178095998697229}, {"playerId": "o8x4i", "number": 8, "xyz": [-3.064965005231983, -13.660815735415385, 0.0], "speed": 7.6112618403084875}], "awayPlayers": [{"playerId": "r63sch", "number": 16, "xyz": [-8.665538194381213, 5.617606910595072, 0], "speed": 5.760820874469998}, {"playerId": "rtwi56", "number": 9, "xyz": [-19.81448388759739, -22.918811216735563, 0], "speed": 6.481607157019558}, {"playerId": "v7whr2", "number": 19, "xyz": [9.202983907546757, -19.60341782134609, 0], "speed": 0.878302509243686}, {"playerId": "j4mao7", "number": 4, "xyz": [-10.735280832860404, 30.31540452260084, 0], "speed": 3.731662373613796}, {"playerId": "rifq85", "number": 5, "xyz": [-21.39492284025012, -5.9462944058408, 0], "speed": 4.0277387540324705}, {"playerId": "vopck", "number": 18, "xyz": [22.97612688473503, -2.0016430713183926, 0], "speed": 5.9327388004505215}, {"playerId": "2q0uv", "number": 3, "xyz": [-45.11871334915762, -20.06459030559596, 0], "speed": 6.013778909583734}, {"playerId": "9bgzhy", "number": 0, "xyz": [-14.093654281648053, 33.55791756142988, 0], "speed": 0.3998524714346736}, {"playerId": "56zeu", "number": 12, "xyz": [-47.577496222772595, 17.454360144081754, 0], "speed": 3.138109659801027}, {"playerId": "27cl51", "number": 17, "xyz": [-26.504869268227615, 27.962809235078748, 0], "speed": 5.601974076542458}, {"playerId": "eh90mu", "number": 15, "xyz": [27.448466335973844, 8.19488340273177, 0], "speed": 6.810978214682541}], "ball": {"xyz": [35.677209254343666, 7.89935190646589, 0.0], "speed": [2.7858965434718073]}, "live": false, "lastTouch": "home"} +{"period": 1, "frameIdx": 400, "gameClock": 16.0, "wallClock": 16000, "homePlayers": [{"playerId": "20grw", "number": 18, "xyz": [25.268790982431074, 22.20366326500799, 0.0], "speed": 3.399643389984491}, {"playerId": "6isrbi", "number": 19, "xyz": [30.58807809399908, -6.795431589960108, 0.0], "speed": 2.089086482831614}, {"playerId": "um288", "number": 4, "xyz": [12.384734186892985, 29.872026327916622, 0.0], "speed": 1.281519916600737}, {"playerId": "4g3xq", "number": 10, "xyz": [11.289462273006825, -21.286392309162064, 0.0], "speed": 7.481216812137205}, {"playerId": "8xwx2", "number": 2, "xyz": [31.18022590708573, -20.300594162860495, 0.0], "speed": 6.258715381588318}, {"playerId": "92vl5", "number": 13, "xyz": [-8.862631236841855, 1.7995532080610457, 0.0], "speed": 4.454909450993342}, {"playerId": "bvgtb", "number": 16, "xyz": [25.914033752537225, 7.577602665341962, 0.0], "speed": 2.269060713765395}, {"playerId": "1lxq7v", "number": 14, "xyz": [-4.53962458264393, 18.18907904228159, 0.0], "speed": 7.113934776712516}, {"playerId": "45w3q", "number": 3, "xyz": [31.14073482829733, -32.98296716567569, 0.0], "speed": 3.506944273542337}, {"playerId": "qyk3c", "number": 15, "xyz": [41.536043363743346, -11.869768643616899, 0.0], "speed": 4.446668017766199}, {"playerId": "o8x4i", "number": 8, "xyz": [-34.144823948253084, 13.020788077473325, 0.0], "speed": 2.2582109522856735}], "awayPlayers": [{"playerId": "r63sch", "number": 16, "xyz": [42.503700344829156, 8.449738887757828, 0], "speed": 6.842748604269784}, {"playerId": "rtwi56", "number": 9, "xyz": [-45.2791551642064, 32.827281801557355, 0], "speed": 4.4133759698368555}, {"playerId": "v7whr2", "number": 19, "xyz": [23.87036121406162, 29.37794190783506, 0], "speed": 1.4989383465177823}, {"playerId": "j4mao7", "number": 4, "xyz": [47.537378317299336, -2.800366493529472, 0], "speed": 5.93025565914477}, {"playerId": "rifq85", "number": 5, "xyz": [43.505059125325836, -17.11545218922354, 0], "speed": 0.2551582847421887}, {"playerId": "vopck", "number": 18, "xyz": [47.97213745552502, -15.844698779340803, 0], "speed": 1.1468915263706894}, {"playerId": "2q0uv", "number": 3, "xyz": [18.6206553724836, -22.520512965631585, 0], "speed": 1.6840667945456342}, {"playerId": "9bgzhy", "number": 0, "xyz": [-20.080274662004204, -4.428060178117001, 0], "speed": 6.175830486845053}, {"playerId": "56zeu", "number": 12, "xyz": [-18.339986168931866, 8.46679062167231, 0], "speed": 5.4975572149978}, {"playerId": "27cl51", "number": 17, "xyz": [2.3443601968923917, -3.673330114344593, 0], "speed": 1.4473853671749843}, {"playerId": "eh90mu", "number": 15, "xyz": [33.969400496582615, 1.560273112097392, 0], "speed": 0.3955959287690938}], "ball": {"xyz": [-23.147073918432426, 13.69367399756424, -10], "speed": 0.0}, "live": false, "lastTouch": "away"} diff --git a/kloppy/tests/test_pff.py b/kloppy/tests/test_pff.py index a0a10c11e..7e567e590 100644 --- a/kloppy/tests/test_pff.py +++ b/kloppy/tests/test_pff.py @@ -89,6 +89,24 @@ def test_correct_deserialization_limit_sample( ) assert len(dataset.records) == 100 + def test_exclude_missing_ball_frames( + self, + raw_data_home_starts_left: Path, + meta_data_home_starts_left: Path, + rosters_meta_data_home_starts_left: Path, + ): + dataset = pff.load_tracking( + meta_data=meta_data_home_starts_left, + roster_meta_data=rosters_meta_data_home_starts_left, + raw_data=raw_data_home_starts_left, + coordinates="pff", + only_alive=False, + exclude_missing_ball_frames=True, + ) + assert all( + frame.ball_coordinates is not None for frame in dataset.records + ) + def test_correct_deserialization( self, raw_data_home_starts_left: Path, diff --git a/kloppy/tests/test_secondspectrum.py b/kloppy/tests/test_secondspectrum.py index 10d249dea..b92eb6acf 100644 --- a/kloppy/tests/test_secondspectrum.py +++ b/kloppy/tests/test_secondspectrum.py @@ -26,6 +26,10 @@ def raw_data(self, base_dir) -> str: def raw_data_utf8sig(self, base_dir) -> str: return base_dir / "files/second_spectrum_fake_data_utf8sig.jsonl" + @pytest.fixture + def raw_data_missing_ball(self, base_dir) -> str: + return base_dir / "files/second_spectrum_fake_data_missing_ball.jsonl" + @pytest.fixture def additional_meta_data(self, base_dir) -> str: return base_dir / "files/second_spectrum_fake_metadata.json" @@ -183,6 +187,47 @@ def test_correct_deserialization_ascii( assert len(dataset.records) == 13 + def test_missing_ball_sentinel_parsed_as_none( + self, + meta_data: Path, + raw_data_missing_ball: Path, + additional_meta_data: Path, + ): + # A z-coordinate of -10 is Second Spectrum's sentinel value for an + # untracked ball. By default it should be parsed as + # `ball_coordinates=None` rather than a literal Point3D(..., -10). + dataset = secondspectrum.load( + meta_data=meta_data, + raw_data=raw_data_missing_ball, + additional_meta_data=additional_meta_data, + only_alive=False, + coordinates="secondspectrum", + ) + + assert len(dataset.records) == 2 + assert dataset.records[0].ball_coordinates is not None + assert dataset.records[1].ball_coordinates is None + + def test_exclude_missing_ball_frames( + self, + meta_data: Path, + raw_data_missing_ball: Path, + additional_meta_data: Path, + ): + dataset = secondspectrum.load( + meta_data=meta_data, + raw_data=raw_data_missing_ball, + additional_meta_data=additional_meta_data, + only_alive=False, + coordinates="secondspectrum", + exclude_missing_ball_frames=True, + ) + + assert len(dataset.records) == 1 + assert all( + frame.ball_coordinates is not None for frame in dataset.records + ) + def test_utf8_fails_with_bom_but_utf8sig_works(self, raw_data_utf8sig): import json diff --git a/kloppy/tests/test_skillcorner.py b/kloppy/tests/test_skillcorner.py index 45cb08c0e..e76cfbeef 100644 --- a/kloppy/tests/test_skillcorner.py +++ b/kloppy/tests/test_skillcorner.py @@ -45,6 +45,20 @@ def test_correct_deserialization_timestamp( include_empty_frames=True, ) + def test_exclude_missing_ball_frames(self, raw_data: Path, meta_data: Path): + dataset = skillcorner.load( + meta_data=meta_data, + raw_data=raw_data, + coordinates="skillcorner", + include_empty_frames=True, + only_alive=False, + exclude_missing_ball_frames=True, + ) + + assert all( + frame.ball_coordinates is not None for frame in dataset.records + ) + def test_correct_deserialization(self, raw_data: Path, meta_data: Path): dataset = skillcorner.load( meta_data=meta_data, diff --git a/kloppy/tests/test_sportec.py b/kloppy/tests/test_sportec.py index d19b77152..ed3210ac4 100644 --- a/kloppy/tests/test_sportec.py +++ b/kloppy/tests/test_sportec.py @@ -140,6 +140,19 @@ def dataset(self, raw_data: Path, meta_data: Path) -> TrackingDataset: only_alive=False, ) + def test_exclude_missing_ball_frames(self, raw_data: Path, meta_data: Path): + dataset = sportec.load_tracking( + raw_data=raw_data, + meta_data=meta_data, + coordinates="sportec", + limit=None, + only_alive=False, + exclude_missing_ball_frames=True, + ) + assert all( + frame.ball_coordinates is not None for frame in dataset.records + ) + def test_load_metadata(self, dataset: TrackingDataset): assert dataset.metadata.provider == Provider.SPORTEC assert dataset.dataset_type == DatasetType.TRACKING diff --git a/kloppy/tests/test_statsperform.py b/kloppy/tests/test_statsperform.py index d36290018..ccf3abeaa 100644 --- a/kloppy/tests/test_statsperform.py +++ b/kloppy/tests/test_statsperform.py @@ -304,6 +304,23 @@ def test_correct_deserialization_limit_sample( ) assert len(tracking_dataset.records) == 25 + def test_exclude_missing_ball_frames( + self, tracking_data: Path, tracking_metadata_xml: Path + ): + tracking_dataset = statsperform.load_tracking( + ma1_data=tracking_metadata_xml, + ma25_data=tracking_data, + tracking_system="sportvu", + coordinates="sportvu", + pitch_length=105, + pitch_width=68, + exclude_missing_ball_frames=True, + ) + assert all( + frame.ball_coordinates is not None + for frame in tracking_dataset.records + ) + def test_coordinate_system_without_pitch_dimensions( self, tracking_data: Path, tracking_metadata_xml: Path ): diff --git a/kloppy/tests/test_tracab.py b/kloppy/tests/test_tracab.py index 42ad21ea4..f06ceb38d 100644 --- a/kloppy/tests/test_tracab.py +++ b/kloppy/tests/test_tracab.py @@ -146,6 +146,20 @@ def test_correct_normalized_deserialization( player_home_1 ].coordinates == Point(x=1.0019047619047619, y=0.49602941176470583) + def test_exclude_missing_ball_frames( + self, json_meta_data: Path, json_raw_data: Path + ): + dataset = tracab.load( + meta_data=json_meta_data, + raw_data=json_raw_data, + coordinates="tracab", + only_alive=False, + exclude_missing_ball_frames=True, + ) + assert all( + frame.ball_coordinates is not None for frame in dataset.records + ) + class TestTracabDATTracking: def test_correct_deserialization(