Action spotting from tracking data¶
Spot the ten SN-GAR actions across a whole match from player and ball coordinates instead of pixels, scored with the same tight mAP as the video baseline.
The design rule was: change the input, change nothing else. The tracking path reuses the library's E2E-Spot training loop, GRU head, sliding-window inference and mAP evaluator unmodified. Only the data layer and the encoder are new. That is what makes the two modalities comparable — a difference in the final number is a difference in the input, not in the recipe.
| Video baseline | Tracking | |
|---|---|---|
| Config | configs/localization/video_action_spotting.yaml |
configs/localization/tracking_action_spotting.yaml |
| Input | 224×398 mp4, 5 fps | parquet rows, 5 fps |
| Per frame | RGB image | 23-node graph |
| Encoder | rny008_gsm |
graph_conv_seq |
| Head | GRU | GRU |
| Clip | 300 frames (60 s) | 300 frames (60 s) |
| lr | 0.0008 | 0.001 |
1. The dataset¶
The dataset is not built from the GAR manifests. It is built from raw event and tracking
data by tools/convert/build_sngar_spotting.py, documented in full in
docs/tools/sngar-spotting.md. Both modalities are
produced in one pass so their ground truth is identical by construction.
python tools/convert/build_sngar_spotting.py \
--events-dir <data-root>/RawEventsData --tracking-dir <data-root>/PlayerPoseTracking \
--video-dir <data-root>/224p --out-root release --num-workers 24
Published as
OpenSportsLab/SNGAR-Spotting-Tracking
and
OpenSportsLab/SNGAR-Spotting-Video.
Superseded:
create_action_spotting_dataset.pyandsync_video_events_from_tracking.py, which lived beside the data rather than in this repo. The event-extraction rules they defined are preserved verbatim intools/convert/sngar_events.py; the surrounding build is rewritten.
| Input | Content |
|---|---|
RawEventsData/<game>.json |
source event stream, one JSON list per game |
PlayerPoseTracking/<game>.jsonl.bz2 |
~29.97 Hz tracking frames, bz2 line-delimited JSON |
64 games, split alphabetically by game id into 45 / 9 / 10. Three stages:
- Tracking → parquet. Each
jsonl.bz2is decoded and flattened into one whole-match table. Team IDs are resolved once per game, then every frame's players are taggedGK/DEF/MID/FWD. Rows are sorted by(videoTimeMs, frameNum)and deduplicated onvideoTimeMs, keeping the first, so the clock is strictly monotone. - Events → annotations. Possession and game events map to the 10 SN-GAR
labels, reproducing the original SN-GAR converter rule for rule. One instant
may legitimately emit several labels (a headed shot is
HEADERandSHOT). - Alignment. Events whose nearest tracking row is more than
--tolerance-ms(default 34 ms, one native frame period) away are dropped — they cannot be localised in tracking. 94,285 extracted → 87,939 kept.
Output is OSL v2:
sngar-action-spotting-tracking/
├── annotations_{train,valid,test}.json task=action_spotting, 10 labels
└── {train,valid,test}/videos/<game>.parquet
The event set¶
The published dataset is 87,939 events — one label per instant, alignment tolerance 10 ms. That is the historical SN-GAR action-spotting count, and the build defaults reproduce it with no flags.
Results in §5 used 88,380 events
The runs below were measured against a build with tolerance 34 ms rather than 10 ms, which keeps 441 additional events. Those 441 sit within half a native frame period of a tracking row — correctly aligned, and excluded only because 10 ms is narrower than the clock's own half-period. Totals therefore differ by 0.5%; per-class ordering does not change.
Rebuild with --tolerance-ms 34 to reproduce §5 exactly. The parquets are
identical either way — only the annotation JSONs differ, so
--annotations-only regenerates them in about two minutes.
Historically the video modality was given its event set by a separate sync script, which drifted: the two modalities ended up thousands of events apart, a difference any modality comparison would have read as a modality effect. The current builder writes both from one event list, so this cannot recur.
tools/convert/build_soccernet_gar_action_spotting.pyin this repo is a separate path that regroups the GAR clip manifests into spotting annotations. It did not produce the dataset the results were measured on.
Verification¶
verify_sngar_spotting_dataset.py runs 20 checks in four levels: builder logic
against the original converter, byte-identical rebuild from raw data, data
quality, and trainer consumability. The one to know is D3, the oracle test:
feed perfect predictions through the real evaluator and it scores 99.07%
tight mAP. Before the evaluation fixes in §4 the same test scored 0.98% — a
1% ceiling no model could ever beat. Run it before trusting any spotting number.
2. Parquet to graph¶
Everything below lives in opensportslib/datasets/utils/tracking.py.
Feature layout. Each frame becomes 23 objects × 8 features. Slot 0 is always the ball, 1–11 home, 12–22 away, players ordered by jersey number so a slot means something consistent over time.
| Index | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
|---|---|---|---|---|---|---|---|---|
| Field | x | y | is_ball | is_home | is_away | dx | dy | z |
Objects that aren't tracked in a frame get the sentinel -200.0, which becomes
-2.0 after normalization — deliberately outside the valid range, so the model
can tell "absent" from "at the centre spot".
The whole-match cache. A spotting parquet is a full match: ~175k rows, 0.8–1.2 GB, one row group with no predicate pushdown. Re-parsing it for every randomly sampled window is not viable. So each game is parsed exactly once into three memory-mapped sidecars next to the parquet:
<game>.parquet.features.npy (rows, 23, 8) float32
<game>.parquet.times.npy (rows,) float64 videoTimeMs
<game>.parquet.positions.npy (rows, 23) int8 GK/DEF/MID/FWD codes
The cache is raw on purpose — no velocities, no normalization, no augmentation. Those are window- and config-dependent, and are applied after slicing.
Per window. slice_window takes every stride-th row from the cache
(stride = native rate ÷ extract_fps), pads out-of-range frames with the
missing sentinel, then computes dx, dy on the decimated sequence — velocity
must reflect the gap between sampled frames, not raw rows. Augmentations
(horizontal flip, vertical flip, team swap — all label-preserving) run next, then
normalization by the pitch constants, then one PyG Data graph per frame.
Edges. edge_type on the encoder component picks the connectivity:
none, full, knn, distance, ball_knn, ball_distance, or positional
(the default — formation lines GK↔DEF↔MID↔FWD within each team, plus the ball
connected to every player).
3. Model¶
graph_conv_seq (GraphSequenceEncoder, in models/backbones/builder.py) is a
thin wrapper on the existing GAR GraphEncoder. The classification encoder pools
to one vector per graph; spotting needs one embedding per frame, so the wrapper
reshapes (B*T, H) back to (B, T, H) using the seq_len the collate attaches
to the PyG Batch. The stock GRU head then emits per-frame logits, exactly as it
does for RGB features.
The collate flattens a batch's B×T per-frame graphs into a single PyG Batch
(core/utils/data.py). Eval collate requires batch_size: 1, mirroring how the
E2E inferer indexes clip["frame"][0].
Mixup is off: blending node features across two differently-structured graphs is
not defined. DALI is off too — it decodes video, and is now pinned away from
non-video modalities rather than failing with KeyError: 'frame'.
4. The part that was actually hard: clock vs row¶
For decoded video a frame index and a clock position are interchangeable —
t = frame / fps. For a tracking parquet they are not. Recording starts
mid-broadcast (row 0 can be ~80 s into the match clock) and the clock jumps
60–90 s across half-time. Every path that turned an index into a timestamp by
dividing by fps was wrong, and wrong silently — none of them raised, each
returned a plausible number.
The fix is two indices per event, kept apart:
| Field | Meaning | Used by |
|---|---|---|
frame |
parquet row ÷ stride | window slicer, get_labels |
eval_frame |
fps × position_ms / 1000 |
mAP evaluation |
Datasets additionally expose frame_times, the absolute match clock of every
decimated frame. process_frame_predictions._locate() uses it to map a clip
index to (timestamp_ms, eval index), re-deriving the eval index the same way
the ground truth does, so both sides land in one coordinate system. RGB has no
frame_times, keeps the old arithmetic, and is untouched.
Three related traps closed at the same time (commit 5e22f2f):
- mAP tolerances were in frames, so strictness scaled with
extract_fps— 4 frames is 1.9 s at 2 fps but 0.8 s at 5 fps. Now given in seconds and converted with the run's own rate. - The evaluation vector was clamped at 90 minutes, folding stoppage-time events onto the final bin and merging distinct events. Now sized from the actual labels and predictions.
- Class order was permuted on checkpoint reload — a
{name: index}mapping round-tripped through YAML comes back alphabetical, solist(mapping)remapped labels.classes_to_ordered_list()now orders by index.
Any result measured before that commit should be treated as suspect.
5. Running it¶
RUN_ID=my_run CUDA_VISIBLE_DEVICES=0 \
python3 -u run_spotting.py opensportslib/configs/localization/tracking_action_spotting.yaml
run_spotting.py trains, infers and evaluates in one go, reading annotation
paths from the config's own DATA.common.data_root so config and data cannot
disagree. Swap in video_action_spotting.yaml for the baseline — same script.
First run over a new dataset builds the per-game caches (slow, once); afterwards they are memory-mapped. The cache rebuilds automatically whenever the parquet is newer than its sidecars.
Where things live¶
| Path | What |
|---|---|
tools/convert/build_sngar_spotting.py |
Raw source data → the published spotting dataset pair; see docs/tools/sngar-spotting.md |
tools/convert/sngar_events.py |
The event-extraction rules that define the SN-GAR label set |
tools/convert/verify_sngar_spotting.py |
Contract verification for a built release |
tools/download/push_sngar_spotting.py |
Publishes the pair to the Hugging Face Hub |
tools/convert/build_soccernet_gar_action_spotting.py |
Separate path: GAR manifests → spotting annotations |
opensportslib/datasets/utils/tracking.py |
Feature layout, cache, edges, augmentations |
opensportslib/datasets/localization_dataset.py |
TrackingActionSpot{,Video}Dataset |
opensportslib/core/utils/load_annotations.py |
annotationstoe2eformat_tracking, frame/eval_frame |
opensportslib/core/utils/data.py |
Graph collate functions |
opensportslib/models/backbones/builder.py |
GraphSequenceEncoder |
opensportslib/metrics/localization_metric.py |
_locate, second-based tolerances |