Skip to content
Open
1 change: 1 addition & 0 deletions docs/user-guide/loading-data/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ Below is an overview of all currently supported providers, along with links to d
| [Metrica](metrica.ipynb) | :material-minus: | :material-check: | :material-minus: | [:material-eye:](https://github.com/metrica-sports/sample-data) |
| [PFF FC](pff.ipynb) | :material-minus: | :material-check: | :material-minus: | [:material-eye:](https://drive.google.com/drive/u/0/folders/1_a_q1e9CXeEPJ3GdCv_3-rNO3gPqacfa) |
| [SecondSpectrum](secondspectrum.ipynb) | [:material-progress-wrench:](https://github.com/PySport/kloppy/pull/437) | :material-check: | :material-minus: |
| [SciSports (EPTS)](scisports.ipynb) | :material-minus: | :material-check: | :material-minus: | |
| [Signality](signality.ipynb) | :material-minus: | :material-check: | :material-minus: | |
| [SkillCorner](skillcorner.ipynb) | :material-minus: | :material-check: | :material-minus: | [:material-eye:](https://github.com/SkillCorner/opendata) |
| [Sportec](sportec.ipynb) | :material-check: | :material-check: | :material-minus: | [:material-eye:](https://www.nature.com/articles/s41597-025-04505-y) |
Expand Down
46 changes: 46 additions & 0 deletions docs/user-guide/loading-data/scisports.ipynb
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
{
"cells": [
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# SciSports EPTS Tracking\n",
"\n",
"This guide shows how to load SciSports EPTS tracking data in Kloppy.\n"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"from kloppy import scisports\n",
"\n",
"# Replace with your paths\n",
"meta = \"./FIFA metadata.xml\"\n",
"raw = \"./FIFA positions.txt\"\n",
"\n",
"dataset = scisports.load_tracking(meta_data=meta, raw_data=raw, coordinates=\"scisports\")\n",
"dataset\n"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## Options\n",
"- `coordinates`: provider coordinate system (default normalized).\n",
"- `sample_rate`: downsample frames (e.g., 0.5).\n",
"- `limit`: cap number of frames (e.g., 10_000).\n"
]
}
],
"metadata": {
"language_info": {
"name": "python"
}
},
"nbformat": 4,
"nbformat_minor": 2
}
41 changes: 41 additions & 0 deletions kloppy/_providers/scisports.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
from typing import Optional

from kloppy.domain import TrackingDataset
from kloppy.io import FileLike, open_as_file

from kloppy.infra.serializers.tracking.scisports_epts import (
SciSportsEPTSTrackingDataDeserializer,
SciSportsEPTSTrackingDataInputs,
)


def load_tracking_epts(
meta_data: FileLike,
raw_data: FileLike,
sample_rate: Optional[float] = None,
limit: Optional[int] = None,
coordinates: Optional[str] = None,
) -> TrackingDataset:
"""Load SciSports EPTS tracking data.

Args:
meta_data: XML metadata file.
raw_data: positions text file.
sample_rate: Sampling factor.
limit: Max number of frames to parse.
coordinates: Coordinate system to convert to.

Returns:
The parsed tracking dataset.
"""
deserializer = SciSportsEPTSTrackingDataDeserializer(
sample_rate=sample_rate, limit=limit, coordinate_system=coordinates
)
with open_as_file(raw_data) as raw_data_fp, open_as_file(
meta_data
) as meta_data_fp:
return deserializer.deserialize(
inputs=SciSportsEPTSTrackingDataInputs(
raw_data=raw_data_fp, meta_data=meta_data_fp
)
)
30 changes: 30 additions & 0 deletions kloppy/domain/models/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ class Provider(Enum):
SKILLCORNER = "skillcorner"
STATSBOMB = "statsbomb"
SPORTEC = "sportec"
SCISPORTS = "scisports"
WYSCOUT = "wyscout"
KLOPPY = "kloppy"
DATAFACTORY = "datafactory"
Expand Down Expand Up @@ -994,6 +995,34 @@ def pitch_dimensions(self) -> PitchDimensions:
)


class SciSportsCoordinateSystem(ProviderCoordinateSystem):
"""
SciSports tracking coordinate system.
"""

@property
def provider(self) -> Provider:
return Provider.SCISPORTS

@property
def origin(self) -> Origin:
return Origin.TOP_LEFT

@property
def vertical_orientation(self) -> VerticalOrientation:
return VerticalOrientation.TOP_TO_BOTTOM

@property
def pitch_dimensions(self) -> PitchDimensions:
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,
)


class SportecTrackingDataCoordinateSystem(ProviderCoordinateSystem):
"""
Sportec tracking data coordinate system.
Expand Down Expand Up @@ -1390,6 +1419,7 @@ def build_coordinate_system(
Provider.HAWKEYE: HawkEyeCoordinateSystem,
Provider.SPORTVU: SportVUCoordinateSystem,
Provider.SIGNALITY: SignalityCoordinateSystem,
Provider.SCISPORTS: SciSportsCoordinateSystem,
}

if provider in coordinate_systems:
Expand Down
33 changes: 16 additions & 17 deletions kloppy/domain/services/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,32 +7,31 @@
from .transformers import DatasetTransformer, DatasetTransformerBuilder

# NOT YET: from .enrichers import TrackingPossessionEnricher
import math


def avg(items: List[float]) -> float:
if not items:
return 0
return sum(items) / len(items)
def safe_avg(values):
clean = [v for v in values if v is not None and not math.isnan(v)]
return sum(clean) / len(clean) if clean else float("nan")


def attacking_direction_from_frame(frame: Frame) -> AttackingDirection:
"""This method should only be called for the first frame of a period."""
avg_x_home = avg(
[
player_data.coordinates.x
for player, player_data in frame.players_data.items()
if player.team.ground == Ground.HOME
]
avg_x_home = safe_avg(
player_data.coordinates.x
for player, player_data in frame.players_data.items()
if player.team.ground == Ground.HOME
)
avg_x_away = avg(
[
player_data.coordinates.x
for player, player_data in frame.players_data.items()
if player.team.ground == Ground.AWAY
]
avg_x_away = safe_avg(
player_data.coordinates.x
for player, player_data in frame.players_data.items()
if player.team.ground == Ground.AWAY
)

if avg_x_home < avg_x_away:
# return whatever logic you already had
if math.isnan(avg_x_home) or math.isnan(avg_x_away):
return AttackingDirection.NOT_SET
elif avg_x_home < avg_x_away:
return AttackingDirection.LTR
else:
return AttackingDirection.RTL
Expand Down
Loading
Loading