opensportslib 0.3.3.dev3__py3-none-any.whl → 0.3.4.dev1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. opensportslib/apis/base_task_model.py +1 -1
  2. opensportslib/apis/classification.py +8 -0
  3. opensportslib/apis/localization.py +9 -0
  4. opensportslib/core/config/loader.py +5 -1
  5. opensportslib/core/trainer/classification_trainer.py +1 -0
  6. opensportslib/core/utils/load_annotations.py +47 -11
  7. opensportslib/datasets/classification_dataset.py +13 -8
  8. opensportslib/datasets/hf_json.py +7 -4
  9. opensportslib/datasets/hf_tracking.py +77 -11
  10. opensportslib/datasets/localization_dataset.py +22 -7
  11. opensportslib/datasets/utils/tracking.py +61 -10
  12. opensportslib/tools/hf_transfer.py +45 -18
  13. opensportslib/tools/osl_json_to_parquet.py +45 -60
  14. opensportslib/tools/parquet_to_osl_json.py +22 -13
  15. opensportslib/tracking/__init__.py +5 -0
  16. opensportslib/tracking/native.py +252 -0
  17. opensportslib/tracking/tracab.py +391 -0
  18. {opensportslib-0.3.3.dev3.dist-info → opensportslib-0.3.4.dev1.dist-info}/METADATA +17 -1
  19. {opensportslib-0.3.3.dev3.dist-info → opensportslib-0.3.4.dev1.dist-info}/RECORD +32 -27
  20. tests/helpers/tracab.py +47 -0
  21. tests/unit/api/test_remote_sessions.py +23 -0
  22. tests/unit/config/test_architecture.py +10 -0
  23. tests/unit/data/test_hf_json_tracking_spotting.py +24 -11
  24. tests/unit/data/test_hf_tracking_dataset.py +71 -4
  25. tests/unit/data/test_tracking_tracab.py +258 -0
  26. tests/unit/tools/test_conversion.py +26 -0
  27. tests/unit/tools/test_hf_transfer.py +164 -1
  28. {opensportslib-0.3.3.dev3.dist-info → opensportslib-0.3.4.dev1.dist-info}/WHEEL +0 -0
  29. {opensportslib-0.3.3.dev3.dist-info → opensportslib-0.3.4.dev1.dist-info}/entry_points.txt +0 -0
  30. {opensportslib-0.3.3.dev3.dist-info → opensportslib-0.3.4.dev1.dist-info}/licenses/LICENSE +0 -0
  31. {opensportslib-0.3.3.dev3.dist-info → opensportslib-0.3.4.dev1.dist-info}/licenses/LICENSE-COMMERCIAL +0 -0
  32. {opensportslib-0.3.3.dev3.dist-info → opensportslib-0.3.4.dev1.dist-info}/top_level.txt +0 -0
@@ -48,7 +48,7 @@ def _manifest_media_references(payload: dict[str, Any]):
48
48
  for input_obj in inputs:
49
49
  if not isinstance(input_obj, dict):
50
50
  continue
51
- for key in ("path", "ball_path"):
51
+ for key in ("path", "ball_path", "metadata_path", "tf05_path", "tf08_path"):
52
52
  if isinstance(input_obj.get(key), str):
53
53
  yield input_obj[key], lambda value, input_obj=input_obj, key=key: input_obj.__setitem__(key, value)
54
54
  values = input_obj.get("paths")
@@ -23,6 +23,7 @@ def _is_tracking_graph_modality(modality):
23
23
  return str(modality).lower() in {
24
24
  "tracking",
25
25
  "tracking_parquet",
26
+ "tracking_tracab",
26
27
  "tracking_h5",
27
28
  "player_centroids_h5",
28
29
  "player_joints_h5",
@@ -49,6 +50,13 @@ class ClassificationModel(BaseTaskModel):
49
50
  if override is not None:
50
51
  return expand(override)
51
52
 
53
+ from opensportslib.datasets.hf_json import hf_json_source, prepare_hf_json_split
54
+ if not self.is_remote and hf_json_source(self.config):
55
+ from opensportslib.core.config.accessors import set_split_source_path
56
+ prepared = prepare_hf_json_split(self.config, split)
57
+ set_split_source_path(self.config, split, str(prepared.source_path))
58
+ return str(prepared.annotation_path)
59
+
52
60
  path = get_split_annotation_path(self.config, split)
53
61
  if path:
54
62
  return expand(path)
@@ -59,6 +59,15 @@ class LocalizationModel(BaseTaskModel):
59
59
  set_split_source_path(self.config, "valid_data_frames", str(prepared.source_path))
60
60
  return str(prepared.annotation_path)
61
61
 
62
+ from opensportslib.datasets.hf_tracking import hf_tracking_source, prepare_hf_tracking_split, stage_tracking_files
63
+ if hf_tracking_source(self.config):
64
+ prepared = prepare_hf_tracking_split(self.config, split)
65
+ root = stage_tracking_files(prepared)
66
+ set_split_source_path(self.config, split, str(root))
67
+ if split == "valid":
68
+ set_split_source_path(self.config, "valid_data_frames", str(root))
69
+ return str(prepared.annotations_path)
70
+
62
71
  path = get_split_annotation_path(self.config, split)
63
72
  if path:
64
73
  return expand(path)
@@ -145,7 +145,11 @@ def _torch_cuda_available() -> bool:
145
145
 
146
146
 
147
147
  def _dali_available() -> bool:
148
- return importlib.util.find_spec("nvidia.dali") is not None
148
+ try:
149
+ return importlib.util.find_spec("nvidia.dali") is not None
150
+ except ModuleNotFoundError:
151
+ # find_spec raises when the optional parent namespace is absent.
152
+ return False
149
153
 
150
154
 
151
155
  # DALI decodes video; any other input modality (tracking parquets, features)
@@ -74,6 +74,7 @@ def _is_tracking_graph_modality(modality):
74
74
  return str(modality).lower() in {
75
75
  "tracking",
76
76
  "tracking_parquet",
77
+ "tracking_tracab",
77
78
  "tracking_h5",
78
79
  "player_centroids_h5",
79
80
  "player_joints_h5",
@@ -12,6 +12,14 @@ from opensportslib.core.utils.config import load_json
12
12
  from collections import defaultdict
13
13
 
14
14
 
15
+ def select_annotation_head(document, requested=None):
16
+ heads = document["labels"]
17
+ selected = requested or ("action" if "action" in heads else next(iter(heads)) if len(heads) == 1 else None)
18
+ if selected not in heads:
19
+ raise ValueError(f"Select annotation_head from {list(heads)}; got {requested!r}")
20
+ return selected
21
+
22
+
15
23
  LOCALIZATION_ANNOTATION_STATUSES = {"verified", "unlabeled", "excluded"}
16
24
 
17
25
 
@@ -164,7 +172,7 @@ def expand_localization_intervals(record):
164
172
 
165
173
  def load_annotations(
166
174
  annotations_path,
167
- task_key="action",
175
+ task_key=None,
168
176
  exclude_labels=None,
169
177
  multiview=False,
170
178
  input_type="video",
@@ -175,6 +183,10 @@ def load_annotations(
175
183
  with open(annotations_path, "r") as f:
176
184
  data = json.load(f)
177
185
 
186
+ if not isinstance(data["data"], list):
187
+ raise TypeError("OSL data must be a list")
188
+ task_key = select_annotation_head(data, task_key)
189
+
178
190
  # this is used for data slicing experiments.
179
191
  # and doesn't affect the validation and test sets.
180
192
  # if you haven't added "data_scaling" to the config, this will be ignored.
@@ -517,7 +529,7 @@ def annotationstoe2eformat(
517
529
  return labels_e2e, task_name_list[0]
518
530
 
519
531
 
520
- def annotationstoe2eformat_tracking(label_files, video_dirs, extract_fps):
532
+ def annotationstoe2eformat_tracking(label_files, video_dirs, extract_fps, input_type="tracking_parquet", annotation_head=None):
521
533
  """
522
534
  Adapt tracking-parquet action-spotting annotations to E2E format.
523
535
 
@@ -546,6 +558,7 @@ def annotationstoe2eformat_tracking(label_files, video_dirs, extract_fps):
546
558
  classes use to slice windows out of the cached native features.
547
559
  """
548
560
  from opensportslib.datasets.utils.tracking import load_or_build_game_cache
561
+ from opensportslib.tracking.tracab import parse_utc
549
562
 
550
563
  if not isinstance(label_files, list):
551
564
  label_files = [label_files]
@@ -562,20 +575,26 @@ def annotationstoe2eformat_tracking(label_files, video_dirs, extract_fps):
562
575
 
563
576
  annotations = load_json(label_path)
564
577
 
565
- for task_name, task_data in annotations["labels"].items():
566
- labels = task_data.get("labels", {})
567
- task_name_list.append(task_name)
578
+ task_name = select_annotation_head(annotations, annotation_head)
579
+ labels = annotations["labels"][task_name].get("labels", {})
580
+ task_name_list.append(task_name)
568
581
  classes_by_label_dir.append(labels)
569
582
 
570
583
  videos = annotations["data"]
571
584
 
572
585
  for video in tqdm.tqdm(videos):
573
- video_input = video["inputs"][0]
586
+ candidates = [i for i in video["inputs"] if i.get("type") == input_type]
587
+ if len(candidates) != 1:
588
+ raise ValueError(f"Expected one {input_type} input for {video.get('id')!r}; found {len(candidates)}")
589
+ video_input = dict(candidates[0])
590
+ for key in ("metadata_path", "tf05_path", "tf08_path"):
591
+ if video_input.get(key):
592
+ video_input[key] = os.path.abspath(os.path.join(video_dir, video_input[key]))
574
593
  video_path = video_input["path"]
575
594
  full_video_path = os.path.join(video_dir, video_path)
576
595
  assert os.path.isfile(full_video_path), full_video_path
577
596
 
578
- _, times, _ = load_or_build_game_cache(full_video_path)
597
+ _, times, _ = load_or_build_game_cache(full_video_path, descriptor=video_input)
579
598
  num_frames = len(times)
580
599
 
581
600
  native_fps = video_input.get("fps")
@@ -591,7 +610,18 @@ def annotationstoe2eformat_tracking(label_files, video_dirs, extract_fps):
591
610
 
592
611
  events = []
593
612
  for ann in video.get("events", []):
594
- position_ms = float(ann["position_ms"])
613
+ if ann.get("head", task_name) != task_name:
614
+ continue
615
+ if ann["label"] not in labels:
616
+ raise ValueError(f"Event label {ann['label']!r} absent from head {task_name!r}")
617
+ if ann.get("timestamp_utc"):
618
+ if not video_input.get("UTC_time_start"):
619
+ raise ValueError("UTC events require input.UTC_time_start")
620
+ position_ms = (parse_utc(ann["timestamp_utc"]) - parse_utc(video_input["UTC_time_start"])).total_seconds() * 1000
621
+ else:
622
+ position_ms = float(ann["position_ms"])
623
+ if position_ms < 0 or (video_input.get("duration_ms") and position_ms >= video_input["duration_ms"]):
624
+ raise ValueError(f"Event timestamp {position_ms} outside tracking timeline")
595
625
  native_idx = int(np.searchsorted(times, position_ms))
596
626
  native_idx = min(max(native_idx, 0), num_frames - 1)
597
627
  adj_frame = native_idx // stride
@@ -614,6 +644,7 @@ def annotationstoe2eformat_tracking(label_files, video_dirs, extract_fps):
614
644
  events.sort(key=lambda x: x["frame"])
615
645
 
616
646
  labels_e2e.append({
647
+ "input": dict(video_input),
617
648
  "events": events,
618
649
  "fps": sample_fps,
619
650
  "num_frames": num_frames_after,
@@ -834,7 +865,7 @@ def check_config(cfg, split="train"):
834
865
  if get_loader_backend(cfg) == "dali":
835
866
  cfg.TRAIN.execution.repartitions = get_repartition_gpu(cfg.SYSTEM.gpu.count)
836
867
  primary_modality = get_data_modality(cfg)
837
- assert primary_modality in ["rgb", "video", "tracking_parquet"]
868
+ assert primary_modality in ["rgb", "video", "tracking_parquet", "tracking_tracab"]
838
869
  backbone_type = get_component_name_by_kind(cfg, "encoder")
839
870
  assert backbone_type in [
840
871
  # From torchvision
@@ -900,8 +931,13 @@ def check_config(cfg, split="train"):
900
931
  and data_path.endswith(".json")
901
932
  and "labels" in load_json(data_path).keys()
902
933
  ):
903
- for task_name, task_data in load_json(data_path)["labels"].items():
904
- classes = task_data.get("labels", {})
934
+ document = load_json(data_path)
935
+ if primary_modality in {"tracking_parquet", "tracking_tracab"}:
936
+ head = select_annotation_head(document, get_data_params(cfg).get("annotation_head"))
937
+ classes = document["labels"][head].get("labels", {})
938
+ else:
939
+ for task_data in document["labels"].values():
940
+ classes = task_data.get("labels", {})
905
941
  else:
906
942
  assert isinstance(cfg.DATA.common.classes, (list, ListConfig))
907
943
  classes = cfg.DATA.common.classes
@@ -64,7 +64,7 @@ def build(config, annotations_path, processor=None, split="train"):
64
64
  """
65
65
  modality = get_data_modality(config).lower()
66
66
 
67
- if modality in ("tracking", "tracking_parquet"):
67
+ if modality in ("tracking", "tracking_parquet", "tracking_tracab"):
68
68
  from opensportslib.datasets.hf_tracking import hf_tracking_source
69
69
 
70
70
  if hf_tracking_source(config):
@@ -133,7 +133,8 @@ class ClassificationDataset(Dataset):
133
133
  annotation_input_type = "frames_npy"
134
134
 
135
135
  self.samples, self.label_map = load_annotations(
136
- annotations_path,
136
+ annotations_path,
137
+ task_key=data_params.get("annotation_head"),
137
138
  exclude_labels=self.exclude_labels,
138
139
  multiview=is_multiview,
139
140
  input_type=annotation_input_type,
@@ -540,7 +541,7 @@ class TrackingDataset(ClassificationDataset):
540
541
  continue
541
542
 
542
543
  clip_path = clip_paths[0]
543
- df = self._load_tracking_clip(clip_path)
544
+ df = self._load_tracking_clip(clip_path, item["inputs"][0])
544
545
 
545
546
  num_frames = len(df)
546
547
  all_features = np.zeros(
@@ -568,7 +569,7 @@ class TrackingDataset(ClassificationDataset):
568
569
  # regardless of transforms.
569
570
  edge_indices = []
570
571
  for t in range(num_frames):
571
- edge_index = self.build_edge_index(
572
+ edge_index = self._build_edge_index(
572
573
  all_features[t],
573
574
  all_positions[t],
574
575
  self.edge_type,
@@ -587,7 +588,7 @@ class TrackingDataset(ClassificationDataset):
587
588
 
588
589
  print(f"Loaded {len(self.processed_samples)} {self.split} samples")
589
590
 
590
- def _load_tracking_clip(self, path):
591
+ def _load_tracking_clip(self, path, descriptor=None):
591
592
  """read a single parquet tracking clip.
592
593
 
593
594
  Args:
@@ -600,6 +601,10 @@ class TrackingDataset(ClassificationDataset):
600
601
  import pandas as pd
601
602
 
602
603
  full_path = os.path.join(self.video_path, path)
604
+ if descriptor and descriptor.get("type") == "tracking_tracab":
605
+ from opensportslib.tracking import TracabTrackingReader
606
+ with TracabTrackingReader(full_path, descriptor, metadata_base_dir=self.video_path) as reader:
607
+ return pd.DataFrame(reader.iter_uniform_rows())
603
608
  return pd.read_parquet(full_path)
604
609
 
605
610
  def __getitem__(self, idx):
@@ -687,7 +692,7 @@ class TrackingDataset(ClassificationDataset):
687
692
  raise ValueError(f"No tracking paths found for item {idx}")
688
693
 
689
694
  clip_path = clip_paths[0]
690
- df = self._load_tracking_clip(clip_path)
695
+ df = self._load_tracking_clip(clip_path, item["inputs"][0])
691
696
 
692
697
  num_frames = len(df)
693
698
  all_features = np.zeros(
@@ -765,8 +770,8 @@ class HFTrackingDataset(TrackingDataset):
765
770
  self._hf_reader = HFTarTrackingReader(prepared)
766
771
  super().__init__(config, str(prepared.annotations_path), split)
767
772
 
768
- def _load_tracking_clip(self, path):
769
- return self._hf_reader.read_parquet(path)
773
+ def _load_tracking_clip(self, path, descriptor=None):
774
+ return self._hf_reader.read_tracking(path, descriptor)
770
775
 
771
776
 
772
777
  class H5TrackingDataset(ClassificationDataset):
@@ -130,10 +130,13 @@ def prepare_hf_json_split(config, split: str) -> PreparedHFJsonSplit:
130
130
  f"Expected one {media_type!r} input for sample {sample.get('game_id', sample.get('id'))!r}"
131
131
  )
132
132
  item = dict(selected[0])
133
- filename = _safe_path(str(item.get("path") or ""))
134
- if not filename.startswith(f"{split}/"):
135
- raise ValueError(f"Media path {filename!r} is outside split {split!r}")
136
- filenames.add(filename)
133
+ for key in ("path", "metadata_path", "tf05_path", "tf08_path"):
134
+ if key != "path" and not item.get(key):
135
+ continue
136
+ filename = _safe_path(str(item.get(key) or ""))
137
+ if key == "path" and not filename.startswith(f"{split}/"):
138
+ raise ValueError(f"Media path {filename!r} is outside split {split!r}")
139
+ filenames.add(filename)
137
140
  sample["inputs"] = [item]
138
141
 
139
142
  def stage_media(filename: str) -> None:
@@ -13,7 +13,7 @@ from collections import OrderedDict
13
13
  from dataclasses import dataclass
14
14
  from pathlib import Path
15
15
 
16
- from opensportslib.core.config.accessors import get_input_cfg
16
+ from opensportslib.core.config.accessors import get_input_cfg, get_data_modality
17
17
 
18
18
 
19
19
  logger = logging.getLogger(__name__)
@@ -126,7 +126,14 @@ def prepare_hf_tracking_split(config, split: str) -> PreparedTrackingSplit:
126
126
 
127
127
  _set_resolved_revision(config, resolved)
128
128
  safe_repo = re.sub(r"[^A-Za-z0-9_.-]+", "--", repo_id)
129
+ media_type = str(source.get("input_type") or get_data_modality(config))
130
+ if media_type == "tracking":
131
+ media_type = "tracking_parquet"
132
+ if media_type not in {"tracking_parquet", "tracking_tracab"}:
133
+ raise ValueError(f"Unsupported tracking input type: {media_type}")
129
134
  directory = cache_dir / safe_repo / resolved / split
135
+ if media_type != "tracking_parquet":
136
+ directory = directory / media_type
130
137
  directory.mkdir(parents=True, exist_ok=True)
131
138
  annotations_path = directory / "annotations.json"
132
139
  index_path = directory / "index.json"
@@ -181,7 +188,7 @@ def prepare_hf_tracking_split(config, split: str) -> PreparedTrackingSplit:
181
188
 
182
189
  manifest = {}
183
190
  for row in rows("shard_manifest.parquet"):
184
- if row.get("file_role") not in (None, "", "primary") or row.get("input_type") != "tracking_parquet":
191
+ if row.get("file_role") not in (None, "", "primary", "metadata", "tf05", "tf08") or row.get("input_type") != media_type:
185
192
  continue
186
193
  key = (str(row["sample_id"]), str(row["relative_path"]))
187
194
  if row.get("status") == "ok" and row.get("wds_member"):
@@ -190,17 +197,20 @@ def prepare_hf_tracking_split(config, split: str) -> PreparedTrackingSplit:
190
197
  members = {}
191
198
  for sample_id, (shard_name, sample) in locations.items():
192
199
  for input_item in sample.get("inputs", []):
193
- if input_item.get("type") != "tracking_parquet":
200
+ if input_item.get("type") != media_type:
194
201
  continue
195
- path = str(input_item.get("path") or "")
196
- location = manifest.get((sample_id, path))
197
- if not path or location is None or location[0] != shard_name:
198
- raise ValueError(f"Missing tracking TAR member for {sample_id!r}: {path!r}")
199
- if path in members and members[path] != location:
200
- raise ValueError(f"Tracking path maps to multiple TAR members: {path!r}")
201
- members[path] = location
202
+ for key in ("path", "metadata_path", "tf05_path", "tf08_path"):
203
+ if key != "path" and not input_item.get(key):
204
+ continue
205
+ path = str(input_item.get(key) or "")
206
+ location = manifest.get((sample_id, path))
207
+ if not path or location is None or location[0] != shard_name:
208
+ raise ValueError(f"Missing tracking TAR member for {sample_id!r} ({key}): {path!r}")
209
+ if path in members and members[path] != location:
210
+ raise ValueError(f"Tracking path maps to multiple TAR members: {path!r}")
211
+ members[path] = location
202
212
  if not members:
203
- raise ValueError(f"{split} contains no tracking_parquet inputs")
213
+ raise ValueError(f"{split} contains no {media_type} inputs")
204
214
 
205
215
  _write_json_atomic(annotations_path, {**header, "data": samples})
206
216
  _write_json_atomic(index_path, {"members": members})
@@ -277,10 +287,33 @@ class HFTarTrackingReader:
277
287
  return tar
278
288
 
279
289
  def read_parquet(self, path: str):
290
+ return self.read_tracking(path)
291
+
292
+ def read_tracking(self, path: str, descriptor=None):
280
293
  import pandas as pd
281
294
 
282
295
  if path not in self.members:
283
296
  raise FileNotFoundError(f"Tracking clip is absent from Hub manifest: {path}")
297
+ metadata = None
298
+ xml_metadata = {}
299
+ for key in ("metadata_path", "tf05_path", "tf08_path"):
300
+ if not descriptor or not descriptor.get(key):
301
+ continue
302
+ metadata_path = descriptor[key]
303
+ if metadata_path not in self.members:
304
+ raise FileNotFoundError(f"Tracking metadata is absent from Hub manifest: {metadata_path}")
305
+ metadata_shard, metadata_member = self.members[metadata_path]
306
+ try:
307
+ from opensportslib.tracking.native import read_document
308
+ with self._tar(metadata_shard).extractfile(metadata_member) as stream:
309
+ document = read_document(stream, format="json" if key == "metadata_path" else "xml")
310
+ document.source = metadata_path
311
+ if key == "metadata_path":
312
+ metadata = document
313
+ else:
314
+ xml_metadata[key] = document
315
+ except (KeyError, ValueError, TypeError) as exc:
316
+ raise ValueError(f"TRACAB metadata {metadata_path}: {exc}") from exc
284
317
  shard_name, member_name = self.members[path]
285
318
  tar = self._tar(shard_name)
286
319
  try:
@@ -289,9 +322,42 @@ class HFTarTrackingReader:
289
322
  if stream is None:
290
323
  raise KeyError(member_name)
291
324
  with stream:
325
+ if descriptor and descriptor.get("type") == "tracking_tracab":
326
+ from opensportslib.tracking import TracabTrackingReader
327
+ with TracabTrackingReader(stream, descriptor, metadata=metadata, xml_metadata=xml_metadata) as reader:
328
+ return pd.DataFrame(reader.iter_uniform_rows())
292
329
  contents = stream.read()
293
330
  except KeyError as exc:
294
331
  raise FileNotFoundError(
295
332
  f"Tracking TAR member {member_name!r} is missing from {shard_name!r}"
296
333
  ) from exc
297
334
  return pd.read_parquet(io.BytesIO(contents))
335
+
336
+
337
+ def stage_tracking_files(prepared):
338
+ """Materialize raw members once for localization's mmap feature cache."""
339
+ import shutil
340
+ from opensportslib.datasets.hf_json import _safe_path
341
+
342
+ root = prepared.annotations_path.parent / "media"
343
+ root.mkdir(exist_ok=True)
344
+ with (root / ".stage.lock").open("w") as lock:
345
+ fcntl.flock(lock, fcntl.LOCK_EX)
346
+ reader = HFTarTrackingReader(prepared)
347
+ try:
348
+ for path, (shard, member) in reader.members.items():
349
+ destination = root / _safe_path(path)
350
+ if destination.is_file():
351
+ continue
352
+ destination.parent.mkdir(parents=True, exist_ok=True)
353
+ temporary = destination.with_name(destination.name + f".tmp-{os.getpid()}")
354
+ try:
355
+ with reader._tar(shard).extractfile(member) as source, temporary.open("wb") as target:
356
+ shutil.copyfileobj(source, target)
357
+ os.replace(temporary, destination)
358
+ finally:
359
+ temporary.unlink(missing_ok=True)
360
+ finally:
361
+ for tar in reader._open_tars.values():
362
+ tar.close()
363
+ return root
@@ -470,6 +470,8 @@ class LocalizationDataset(Dataset):
470
470
  dilate_len=self.data_cfg.dilate_len,
471
471
  augmentations=_build_tracking_spotting_augmentations(self.config),
472
472
  normalize=normalize,
473
+ input_type=get_runtime_modality(self.config),
474
+ annotation_head=get_data_params(self.config).get("annotation_head"),
473
475
  )
474
476
  elif dataset_type == "TrackingActionSpotVideo":
475
477
  encoder_params = get_component_params_by_kind(self.config, "encoder")
@@ -485,6 +487,8 @@ class LocalizationDataset(Dataset):
485
487
  r=encoder_params.get("radius"),
486
488
  overlap_len=getattr(cfg, "overlap_len", self.data_cfg.clip_len // 2),
487
489
  normalize=normalize,
490
+ input_type=get_runtime_modality(self.config),
491
+ annotation_head=get_data_params(self.config).get("annotation_head"),
488
492
  )
489
493
  else:
490
494
  dataset = None
@@ -1276,17 +1280,24 @@ class _TrackingGraphWindowMixin:
1276
1280
  """
1277
1281
  return batch["frame"].to(device)
1278
1282
 
1279
- def _get_game_cache(self, video_path):
1283
+ def _get_game_cache(self, video_path, descriptor=None):
1280
1284
  """lazily load (and memoize for the lifetime of this dataset
1281
1285
  instance/worker) a game's cached whole-match features."""
1282
- if video_path not in self._game_cache:
1283
- self._game_cache[video_path] = self._load_or_build_game_cache(video_path)
1284
- return self._game_cache[video_path]
1286
+ companion_signatures = []
1287
+ if descriptor and descriptor.get("type") == "tracking_tracab":
1288
+ for field in ("metadata_path", "tf05_path", "tf08_path"):
1289
+ if descriptor.get(field):
1290
+ stat = os.stat(descriptor[field])
1291
+ companion_signatures.append((field, stat.st_size, stat.st_mtime_ns))
1292
+ key = (video_path, json.dumps(descriptor, sort_keys=True), tuple(companion_signatures))
1293
+ if key not in self._game_cache:
1294
+ self._game_cache[key] = self._load_or_build_game_cache(video_path, descriptor=descriptor)
1295
+ return self._game_cache[key]
1285
1296
 
1286
1297
  def _slice_window(self, video_meta, base_idx):
1287
1298
  """Fetch the game cache and slice a decimated feature window +
1288
1299
  per-frame position lists out of it (see tracking.slice_window)."""
1289
- features_cache, _, positions_cache = self._get_game_cache(video_meta["video"])
1300
+ features_cache, _, positions_cache = self._get_game_cache(video_meta["video"], video_meta.get("input"))
1290
1301
  return self._slice_window_fn(
1291
1302
  features_cache, positions_cache, base_idx, self._clip_len, video_meta["stride"]
1292
1303
  )
@@ -1364,12 +1375,14 @@ class TrackingActionSpotDataset(Dataset, _TrackingGraphWindowMixin):
1364
1375
  fg_upsample=-1,
1365
1376
  augmentations=None,
1366
1377
  normalize=True,
1378
+ input_type="tracking_parquet",
1379
+ annotation_head=None,
1367
1380
  ):
1368
1381
  from opensportslib.core.utils.load_annotations import annotationstoe2eformat_tracking
1369
1382
 
1370
1383
  self._src_file = label_file
1371
1384
  self._labels, self.task_name = annotationstoe2eformat_tracking(
1372
- label_file, video_dir, extract_fps
1385
+ label_file, video_dir, extract_fps, input_type, annotation_head
1373
1386
  )
1374
1387
  self._class_dict = _normalize_class_dict(classes)
1375
1388
  self._video_idxs = {x["video"]: i for i, x in enumerate(self._labels)}
@@ -1528,12 +1541,14 @@ class TrackingActionSpotVideoDataset(Dataset, _TrackingGraphWindowMixin, Dataset
1528
1541
  pad_len=5,
1529
1542
  skip_partial_end=True,
1530
1543
  normalize=True,
1544
+ input_type="tracking_parquet",
1545
+ annotation_head=None,
1531
1546
  ):
1532
1547
  from opensportslib.core.utils.load_annotations import annotationstoe2eformat_tracking
1533
1548
 
1534
1549
  self._src_file = label_file
1535
1550
  self._labels, self.task_name = annotationstoe2eformat_tracking(
1536
- label_file, video_dir, extract_fps
1551
+ label_file, video_dir, extract_fps, input_type, annotation_head
1537
1552
  )
1538
1553
  self._class_dict = _normalize_class_dict(classes)
1539
1554
  self._video_idxs = {x["path"]: i for i, x in enumerate(self._labels)}
@@ -59,6 +59,19 @@ _AWAY_SLOT_START = 12
59
59
  # frame-level feature extraction
60
60
  # -------------------------------------------------------------------
61
61
 
62
+ def _tracking_list(value):
63
+ if isinstance(value, str):
64
+ try:
65
+ value = json.loads(value)
66
+ except (ValueError, TypeError):
67
+ return []
68
+ if isinstance(value, np.ndarray):
69
+ value = value.tolist()
70
+ if isinstance(value, dict):
71
+ value = [value]
72
+ return value if isinstance(value, (list, tuple)) else []
73
+
74
+
62
75
  def parse_frame(row, num_objects=NUM_OBJECTS, feature_dim=FEATURE_DIM):
63
76
  """parse a single parquet row into per-object features and positions.
64
77
 
@@ -85,10 +98,9 @@ def parse_frame(row, num_objects=NUM_OBJECTS, feature_dim=FEATURE_DIM):
85
98
  obj_idx = _BALL_SLOT
86
99
 
87
100
  # -- ball (always slot 0) --
88
- ball_str = row.get("balls", "null")
89
- if pd.notna(ball_str) and ball_str not in ("null", ""):
101
+ ball_list = _tracking_list(row.get("balls"))
102
+ if ball_list:
90
103
  try:
91
- ball_list = json.loads(ball_str)
92
104
  if ball_list:
93
105
  ball = ball_list[0]
94
106
  x, y = ball.get("x"), ball.get("y")
@@ -106,10 +118,9 @@ def parse_frame(row, num_objects=NUM_OBJECTS, feature_dim=FEATURE_DIM):
106
118
  obj_idx = _HOME_SLOT_START
107
119
 
108
120
  # -- home players (slots 1-11) --
109
- home_str = row.get("homePlayers", "[]")
110
- if pd.notna(home_str) and home_str not in ("null", ""):
121
+ home_players = _tracking_list(row.get("homePlayers"))
122
+ if home_players:
111
123
  try:
112
- home_players = json.loads(home_str)
113
124
  home_players = sorted(
114
125
  home_players,
115
126
  key=lambda p: int(p.get("jerseyNum", 0)),
@@ -136,10 +147,9 @@ def parse_frame(row, num_objects=NUM_OBJECTS, feature_dim=FEATURE_DIM):
136
147
  obj_idx = _HOME_SLOT_END
137
148
 
138
149
  # -- away players (slots 12-22) --
139
- away_str = row.get("awayPlayers", "[]")
140
- if pd.notna(away_str) and away_str not in ("null", ""):
150
+ away_players = _tracking_list(row.get("awayPlayers"))
151
+ if away_players:
141
152
  try:
142
- away_players = json.loads(away_str)
143
153
  away_players = sorted(
144
154
  away_players,
145
155
  key=lambda p: int(p.get("jerseyNum", 0)),
@@ -705,7 +715,7 @@ def build_game_cache(parquet_path):
705
715
  return all_features, times, all_positions
706
716
 
707
717
 
708
- def load_or_build_game_cache(parquet_path, mmap=True):
718
+ def load_or_build_game_cache(parquet_path, mmap=True, descriptor=None):
709
719
  """load the cached per-frame features/times/positions for a game,
710
720
  building the cache (see build_game_cache) on first access.
711
721
 
@@ -722,6 +732,8 @@ def load_or_build_game_cache(parquet_path, mmap=True):
722
732
  positions: numpy.ndarray (num_frames, NUM_OBJECTS) int8 position
723
733
  codes (see _POSITION_CODES / positions_to_strings).
724
734
  """
735
+ if descriptor and descriptor.get("type") == "tracking_tracab":
736
+ return _load_tracab_cache(parquet_path, descriptor, mmap)
725
737
  cache_paths = _cache_paths(parquet_path)
726
738
  features_path, times_path, positions_path = cache_paths
727
739
 
@@ -794,4 +806,43 @@ def slice_window(features_cache, positions_cache, base_idx, clip_len, stride):
794
806
  positions[i] = positions_to_strings(positions_cache[native_idx])
795
807
 
796
808
  window = compute_deltas(window)
809
+ # A decimated window may skip an empty native frame. Do not infer motion
810
+ # across that dropout, even when both sampled endpoints are observed.
811
+ for i in range(1, clip_len):
812
+ left, right = (base_idx + i - 1) * stride, (base_idx + i) * stride
813
+ if 0 <= left < right < native_num_frames:
814
+ missing = np.any(features_cache[left:right + 1, :, 0] == MISSING_VALUE, axis=0)
815
+ observed = window[i, :, 0] != MISSING_VALUE
816
+ window[i, missing & observed, 5:7] = 0
797
817
  return window, positions
818
+
819
+
820
+ def _load_tracab_cache(path, descriptor, mmap):
821
+ """Versioned feature cache; the raw DAT and manifest are never modified."""
822
+ import fcntl
823
+ import hashlib
824
+ from opensportslib.tracking.tracab import PARSER_VERSION, TracabTrackingReader, resolve_tracab_descriptor
825
+
826
+ descriptor = resolve_tracab_descriptor(descriptor)
827
+
828
+ stat = os.stat(path)
829
+ identity = json.dumps([PARSER_VERSION, stat.st_size, stat.st_mtime_ns, descriptor], sort_keys=True)
830
+ digest = hashlib.sha256(identity.encode()).hexdigest()[:24]
831
+ prefix = f"{path}.tracab-{digest}"
832
+ files = _cache_paths(prefix)
833
+ with open(prefix + ".lock", "a") as lock:
834
+ fcntl.flock(lock, fcntl.LOCK_EX)
835
+ if not all(os.path.isfile(p) for p in files):
836
+ with TracabTrackingReader(path, descriptor) as reader:
837
+ import math
838
+ count = math.ceil(reader.duration_ms * reader.fps / 1000)
839
+ features = np.empty((count, NUM_OBJECTS, FEATURE_DIM), dtype=np.float32)
840
+ positions = np.zeros((count, NUM_OBJECTS), dtype=np.int8)
841
+ times = np.empty(count, dtype=np.float64)
842
+ for index, row in enumerate(reader.iter_uniform_rows()):
843
+ features[index], names = parse_frame(row)
844
+ positions[index] = [_POSITION_CODES.get(name, 0) for name in names]
845
+ times[index] = row["videoTimeMs"]
846
+ for filename, array in zip(files, (features, times, positions)):
847
+ _atomic_save(filename, array)
848
+ return tuple(np.load(filename, mmap_mode="r" if mmap else None) for filename in files)