euler-loading 2.11.0__tar.gz → 2.12.0__tar.gz

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 (53) hide show
  1. {euler_loading-2.11.0 → euler_loading-2.12.0}/PKG-INFO +1 -1
  2. {euler_loading-2.11.0 → euler_loading-2.12.0}/README.md +26 -3
  3. {euler_loading-2.11.0 → euler_loading-2.12.0}/euler_loading/_ds_crawler_utils.py +159 -7
  4. {euler_loading-2.11.0 → euler_loading-2.12.0}/euler_loading/dataset.py +98 -10
  5. {euler_loading-2.11.0 → euler_loading-2.12.0}/euler_loading/loaders/cpu/real_drive_sim.py +134 -0
  6. {euler_loading-2.11.0 → euler_loading-2.12.0}/pyproject.toml +1 -1
  7. {euler_loading-2.11.0 → euler_loading-2.12.0}/tests/test_dataset.py +190 -0
  8. {euler_loading-2.11.0 → euler_loading-2.12.0}/tests/test_loaders.py +41 -0
  9. {euler_loading-2.11.0 → euler_loading-2.12.0}/.github/workflows/workflow.yml +0 -0
  10. {euler_loading-2.11.0 → euler_loading-2.12.0}/.gitignore +0 -0
  11. {euler_loading-2.11.0 → euler_loading-2.12.0}/docs/loader-attributes.md +0 -0
  12. {euler_loading-2.11.0 → euler_loading-2.12.0}/euler_loading/__init__.py +0 -0
  13. {euler_loading-2.11.0 → euler_loading-2.12.0}/euler_loading/_dataset_contract.py +0 -0
  14. {euler_loading-2.11.0 → euler_loading-2.12.0}/euler_loading/_metadata.py +0 -0
  15. {euler_loading-2.11.0 → euler_loading-2.12.0}/euler_loading/_resolution.py +0 -0
  16. {euler_loading-2.11.0 → euler_loading-2.12.0}/euler_loading/_writing.py +0 -0
  17. {euler_loading-2.11.0 → euler_loading-2.12.0}/euler_loading/indexing.py +0 -0
  18. {euler_loading-2.11.0 → euler_loading-2.12.0}/euler_loading/loaders/__init__.py +0 -0
  19. {euler_loading-2.11.0 → euler_loading-2.12.0}/euler_loading/loaders/_annotations.py +0 -0
  20. {euler_loading-2.11.0 → euler_loading-2.12.0}/euler_loading/loaders/_writer_utils.py +0 -0
  21. {euler_loading-2.11.0 → euler_loading-2.12.0}/euler_loading/loaders/contracts.py +0 -0
  22. {euler_loading-2.11.0 → euler_loading-2.12.0}/euler_loading/loaders/cpu/__init__.py +0 -0
  23. {euler_loading-2.11.0 → euler_loading-2.12.0}/euler_loading/loaders/cpu/generic.py +0 -0
  24. {euler_loading-2.11.0 → euler_loading-2.12.0}/euler_loading/loaders/cpu/generic_dense_depth.py +0 -0
  25. {euler_loading-2.11.0 → euler_loading-2.12.0}/euler_loading/loaders/cpu/muses.py +0 -0
  26. {euler_loading-2.11.0 → euler_loading-2.12.0}/euler_loading/loaders/cpu/vkitti2.py +0 -0
  27. {euler_loading-2.11.0 → euler_loading-2.12.0}/euler_loading/loaders/generate/__init__.py +0 -0
  28. {euler_loading-2.11.0 → euler_loading-2.12.0}/euler_loading/loaders/generate/__main__.py +0 -0
  29. {euler_loading-2.11.0 → euler_loading-2.12.0}/euler_loading/loaders/generate/loaders.json +0 -0
  30. {euler_loading-2.11.0 → euler_loading-2.12.0}/euler_loading/loaders/generic.py +0 -0
  31. {euler_loading-2.11.0 → euler_loading-2.12.0}/euler_loading/loaders/gpu/__init__.py +0 -0
  32. {euler_loading-2.11.0 → euler_loading-2.12.0}/euler_loading/loaders/gpu/generic.py +0 -0
  33. {euler_loading-2.11.0 → euler_loading-2.12.0}/euler_loading/loaders/gpu/generic_dense_depth.py +0 -0
  34. {euler_loading-2.11.0 → euler_loading-2.12.0}/euler_loading/loaders/gpu/muses.py +0 -0
  35. {euler_loading-2.11.0 → euler_loading-2.12.0}/euler_loading/loaders/gpu/real_drive_sim.py +0 -0
  36. {euler_loading-2.11.0 → euler_loading-2.12.0}/euler_loading/loaders/gpu/vkitti2.py +0 -0
  37. {euler_loading-2.11.0 → euler_loading-2.12.0}/euler_loading/loaders/muses.py +0 -0
  38. {euler_loading-2.11.0 → euler_loading-2.12.0}/euler_loading/loaders/real_drive_sim.py +0 -0
  39. {euler_loading-2.11.0 → euler_loading-2.12.0}/euler_loading/loaders/vkitti2.py +0 -0
  40. {euler_loading-2.11.0 → euler_loading-2.12.0}/euler_loading/preprocessing.py +0 -0
  41. {euler_loading-2.11.0 → euler_loading-2.12.0}/example.py +0 -0
  42. {euler_loading-2.11.0 → euler_loading-2.12.0}/package-lock.json +0 -0
  43. {euler_loading-2.11.0 → euler_loading-2.12.0}/sample_rds.py +0 -0
  44. {euler_loading-2.11.0 → euler_loading-2.12.0}/tests/__init__.py +0 -0
  45. {euler_loading-2.11.0 → euler_loading-2.12.0}/tests/conftest.py +0 -0
  46. {euler_loading-2.11.0 → euler_loading-2.12.0}/tests/example_rds_calib.json +0 -0
  47. {euler_loading-2.11.0 → euler_loading-2.12.0}/tests/test_id_schema.py +0 -0
  48. {euler_loading-2.11.0 → euler_loading-2.12.0}/tests/test_indexing.py +0 -0
  49. {euler_loading-2.11.0 → euler_loading-2.12.0}/tests/test_preprocessing.py +0 -0
  50. {euler_loading-2.11.0 → euler_loading-2.12.0}/tests/test_real_dataset.py +0 -0
  51. {euler_loading-2.11.0 → euler_loading-2.12.0}/tests/test_writing.py +0 -0
  52. {euler_loading-2.11.0 → euler_loading-2.12.0}/vkitti_cpu_example_output.json +0 -0
  53. {euler_loading-2.11.0 → euler_loading-2.12.0}/vkitti_gpu_example_output.json +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: euler-loading
3
- Version: 2.11.0
3
+ Version: 2.12.0
4
4
  Summary: Multi-modal PyTorch dataloader using ds-crawler indices
5
5
  Requires-Python: >=3.9
6
6
  Requires-Dist: ds-crawler>=2.3.0
@@ -55,7 +55,7 @@ Frozen dataclass describing one data modality.
55
55
 
56
56
  | Parameter | Type | Description |
57
57
  |-----------|------|-------------|
58
- | `path` | `str` | Absolute path to the modality root directory or `.zip` archive. Must contain ds-crawler metadata, either at `.ds_crawler/` or under the configured `metadata_scope`. |
58
+ | `path` | `str` | Absolute path to the modality root directory or `.zip` archive. Must contain ds-crawler metadata, either at `.ds_crawler/` or under the configured `metadata_scope`. Inline selectors are accepted as `/data/ds.zip:train`, `/data/ds.zip#scope=rgb`, or `/data/ds.zip:train#scope=rgb`. |
59
59
  | `origin_path` | `str \| None` | Original path before copying/symlinking (e.g. for SLURM staging). Not used by euler-loading itself — useful for experiment logging to retain references to the original dataset location. |
60
60
  | `loader` | `Callable[..., Any] \| None` | Receives the file path (or `BinaryIO` buffer for zip-backed modalities) and an optional `meta` dict. Returns loaded data. When `None`, the loader is resolved automatically from the ds-crawler index (see [Automatic loader resolution](#automatic-loader-resolution)). |
61
61
  | `writer` | `Callable[..., Any] \| None` | Receives `(path, value, meta)` and writes modality data to disk. When `None`, euler-loading tries to resolve a built-in writer from ds-crawler metadata (`write_<function>` or `write_<suffix>` for `read_<suffix>`). |
@@ -65,7 +65,7 @@ Frozen dataclass describing one data modality.
65
65
  | `hierarchy_scope` | `str \| None` | Optional scope label for hierarchical modalities (e.g. `scene_camera`). |
66
66
  | `applies_to` | `list[str] \| None` | Optional list of regular modality names a hierarchical modality applies to. |
67
67
  | `split` | `str \| None` | Optional inline split name. Loads `.ds_crawler/split_<name>.json` from the modality root (directory or zip) and overlays it on the normal ds-crawler metadata. |
68
- | `metadata_scope` | `str \| None` | Optional namespace below `.ds_crawler`, e.g. `.ds_crawler/camera_extrinsics/index.json`. Use this when multiple logical modalities share one physical directory or zip. If the scoped artifacts are absent, loading falls back to the legacy root-level `.ds_crawler` layout. |
68
+ | `metadata_scope` | `str \| None` | Optional namespace below `.ds_crawler`, e.g. `.ds_crawler/camera_extrinsics/index.json`. Use this when multiple logical modalities share one physical directory or zip. It can also be supplied inline as `#scope=<metadata_scope>`. If omitted, euler-loading infers it only when the choice is deterministic. If configured artifacts are absent, loading falls back to the legacy root-level `.ds_crawler` layout. |
69
69
  | `cache` | `bool \| None` | Opt-in/out for in-memory caching of decoded values (only meaningful for hierarchical modalities; regular modalities are never cached). `True` keeps every distinct loaded file in a process-lifetime dict. `False` re-reads on every access. `None` (default): hierarchical → `True` because small shared calibration files benefit. |
70
70
  | `collapse_single` | `bool` | For hierarchical modalities only. When `True`, a sample that matches exactly one hierarchical file returns that loaded value directly instead of `{file_id: value}`. This is useful for shared GT tensors used by multiple augmentations of the same source sample. |
71
71
  | `metadata` | `dict[str, Any]` | Optional arbitrary metadata. Keys under `metadata["euler_loading"]` are treated as euler-loading defaults. |
@@ -128,6 +128,12 @@ dataset = MultiModalDataset(
128
128
  )
129
129
  ```
130
130
 
131
+ Path-only callers can use the existing colon form directly:
132
+
133
+ ```python
134
+ Modality("/data/rgb:train")
135
+ ```
136
+
131
137
  This works for both directory-backed and zip-backed modalities. The split file only replaces the `dataset` payload; top-level metadata such as dataset type and euler-loading loader hints still come from the canonical ds-crawler index.
132
138
 
133
139
  ### Scoped ds-crawler Metadata
@@ -165,6 +171,23 @@ dataset = MultiModalDataset(
165
171
  )
166
172
  ```
167
173
 
174
+ Path-only callers can select the same scope inline. The scope selector comes
175
+ after the optional split selector:
176
+
177
+ ```python
178
+ Modality("/data/muses.zip#scope=rgb")
179
+ Modality("/data/muses.zip:train#scope=rgb")
180
+ ```
181
+
182
+ If `metadata_scope` is omitted, euler-loading resolves it automatically only
183
+ when doing so is unambiguous:
184
+
185
+ 1. Explicit `metadata_scope` or `#scope=...` wins.
186
+ 2. If root-level `.ds_crawler` metadata exists, the legacy root metadata is used.
187
+ 3. If no root metadata exists and exactly one scope exists, that scope is used.
188
+ 4. If multiple scopes exist, euler-loading matches the modality dict key, `modality_type`, loader `_modality_meta["type"]`, or scoped `dataset-head.json` `modality.key`.
189
+ 5. If the result is still ambiguous, construction raises and lists the available scopes.
190
+
168
191
  This is an additive layout. Existing roots with `.ds_crawler/index.json` continue to load unchanged, and a scoped modality falls back to that legacy location when the scoped artifacts are not present.
169
192
 
170
193
  ### `MultiModalDataset.modality_paths()`
@@ -368,7 +391,7 @@ Writer resolution uses the same module and function metadata:
368
391
 
369
392
  ## ds-crawler integration
370
393
 
371
- Each logical modality must have its own ds-crawler index. Usually that means one modality root with its own `.ds_crawler` artifacts. When several logical modalities share one physical directory or zip, put each artifact set under `.ds_crawler/<metadata_scope>/` and set `Modality(..., metadata_scope=...)`.
394
+ Each logical modality must have its own ds-crawler index. Usually that means one modality root with its own `.ds_crawler` artifacts. When several logical modalities share one physical directory or zip, put each artifact set under `.ds_crawler/<metadata_scope>/` and set `Modality(..., metadata_scope=...)` or append `#scope=<metadata_scope>` to the path.
372
395
  Files across regular modalities are matched by IDs from those indexes, so **the indexed hierarchy and naming conventions must be consistent** across modalities up to modality-specific parts captured in the config.
373
396
 
374
397
  Calibration files or other per-scene/per-sequence metadata can be loaded via `hierarchical_modalities`. These files are matched to samples based on their position in the hierarchy — all files at or above a sample's hierarchy level are included and cached for efficiency.
@@ -3,18 +3,21 @@ from __future__ import annotations
3
3
  from collections.abc import Mapping
4
4
  from pathlib import Path
5
5
  from typing import Any
6
+ from urllib.parse import parse_qsl
6
7
 
7
8
  from ds_crawler import (
8
9
  get_dataset_contract,
10
+ list_metadata_scopes,
9
11
  load_dataset_config,
10
12
  load_dataset_split as _load_dataset_split,
11
13
  validate_metadata_scope,
12
14
  )
13
- from ds_crawler.zip_utils import read_metadata_json
14
15
  from ds_crawler.config import CONFIG_FILENAME as _DS_CRAWLER_CONFIG_FILENAME
15
16
  from ds_crawler.zip_utils import (
17
+ DATASET_HEAD_FILENAME as _DS_CRAWLER_HEAD_FILENAME,
16
18
  OUTPUT_FILENAME as _DS_CRAWLER_OUTPUT_FILENAME,
17
19
  get_split_filename,
20
+ read_metadata_json,
18
21
  validate_split_name,
19
22
  )
20
23
 
@@ -63,7 +66,38 @@ def extract_ds_crawler_properties(index_output: Mapping[str, Any]) -> dict[str,
63
66
  return {}
64
67
 
65
68
 
66
- def parse_path_with_split(path: str) -> tuple[str, str | None]:
69
+ _METADATA_FILENAMES = (
70
+ _DS_CRAWLER_OUTPUT_FILENAME,
71
+ _DS_CRAWLER_CONFIG_FILENAME,
72
+ _DS_CRAWLER_HEAD_FILENAME,
73
+ )
74
+
75
+
76
+ def _parse_path_with_scope(path: str) -> tuple[str, str | None]:
77
+ """Extract an optional ``#scope=<name>`` selector from a path."""
78
+ candidate_path, sep, fragment = path.rpartition("#")
79
+ if not sep:
80
+ return path, None
81
+
82
+ params = parse_qsl(fragment, keep_blank_values=True)
83
+ scope_values = [value for key, value in params if key == "scope"]
84
+ if not scope_values:
85
+ return path, None
86
+
87
+ unsupported = [key for key, _ in params if key != "scope"]
88
+ if unsupported:
89
+ raise ValueError(
90
+ "Unsupported modality path fragment parameter(s): "
91
+ f"{', '.join(sorted(set(unsupported)))}. "
92
+ "Only '#scope=<metadata_scope>' is supported."
93
+ )
94
+ if len(scope_values) > 1:
95
+ raise ValueError("Modality path may contain at most one '#scope=' selector.")
96
+
97
+ return candidate_path, validate_metadata_scope(scope_values[0])
98
+
99
+
100
+ def _parse_path_with_split(path: str) -> tuple[str, str | None]:
67
101
  """Extract an optional inline split suffix from a colon-separated path.
68
102
 
69
103
  Supports paths like ``/data/ds.zip:train`` where the part after the
@@ -88,16 +122,134 @@ def parse_path_with_split(path: str) -> tuple[str, str | None]:
88
122
  return candidate_path, split
89
123
 
90
124
 
125
+ def parse_modality_path(path: str) -> tuple[str, str | None, str | None]:
126
+ """Extract inline ``:split`` and ``#scope=...`` selectors from a path."""
127
+ parsed_path, metadata_scope = _parse_path_with_scope(path)
128
+ parsed_path, split = _parse_path_with_split(parsed_path)
129
+ return parsed_path, split, metadata_scope
130
+
131
+
132
+ def parse_path_with_split(path: str) -> tuple[str, str | None]:
133
+ """Extract an optional inline split suffix from a colon-separated path."""
134
+ parsed_path, split, _ = parse_modality_path(path)
135
+ return parsed_path, split
136
+
137
+
138
+ def _read_metadata_json_safe(
139
+ dataset_path: Path,
140
+ filename: str,
141
+ *,
142
+ metadata_scope: str | None = None,
143
+ ) -> dict[str, Any] | None:
144
+ try:
145
+ return read_metadata_json(
146
+ dataset_path,
147
+ filename,
148
+ metadata_scope=metadata_scope,
149
+ )
150
+ except FileNotFoundError:
151
+ return None
152
+
153
+
154
+ def _has_root_metadata(dataset_path: Path) -> bool:
155
+ """Return whether unscoped ds-crawler metadata exists at a dataset root."""
156
+ return any(
157
+ _read_metadata_json_safe(dataset_path, filename) is not None
158
+ for filename in _METADATA_FILENAMES
159
+ )
160
+
161
+
91
162
  def _has_scoped_metadata(dataset_path: Path, metadata_scope: str) -> bool:
92
163
  """Return whether ds-crawler metadata exists under a configured scope."""
93
- for filename in (_DS_CRAWLER_OUTPUT_FILENAME, _DS_CRAWLER_CONFIG_FILENAME):
94
- if read_metadata_json(
164
+ return any(
165
+ _read_metadata_json_safe(
95
166
  dataset_path,
96
167
  filename,
97
168
  metadata_scope=metadata_scope,
98
- ) is not None:
99
- return True
100
- return False
169
+ ) is not None
170
+ for filename in _METADATA_FILENAMES
171
+ )
172
+
173
+
174
+ def _list_metadata_scopes_safe(dataset_path: Path) -> list[str]:
175
+ try:
176
+ return list_metadata_scopes(dataset_path)
177
+ except FileNotFoundError:
178
+ return []
179
+
180
+
181
+ def _read_scope_modality_key(dataset_path: Path, metadata_scope: str) -> str | None:
182
+ head = _read_metadata_json_safe(
183
+ dataset_path,
184
+ _DS_CRAWLER_HEAD_FILENAME,
185
+ metadata_scope=metadata_scope,
186
+ )
187
+ modality = head.get("modality") if isinstance(head, Mapping) else None
188
+ key = modality.get("key") if isinstance(modality, Mapping) else None
189
+ return as_non_empty_str(key)
190
+
191
+
192
+ def infer_metadata_scope(
193
+ path: str | Path,
194
+ *,
195
+ candidates: list[str | None],
196
+ ) -> str | None:
197
+ """Infer a ds-crawler metadata scope only when the choice is deterministic.
198
+
199
+ Legacy root-level metadata always wins for an unscoped modality. If no
200
+ root metadata exists, a single available scope is selected automatically.
201
+ With multiple scopes, a scope is selected only when exactly one scope name
202
+ or scoped ``dataset-head.json`` modality key matches the provided
203
+ candidates.
204
+ """
205
+ dataset_path = Path(path)
206
+ if _has_root_metadata(dataset_path):
207
+ return None
208
+
209
+ scopes = _list_metadata_scopes_safe(dataset_path)
210
+ if not scopes:
211
+ return None
212
+ if len(scopes) == 1:
213
+ return scopes[0]
214
+
215
+ candidate_set = {
216
+ candidate
217
+ for candidate in (as_non_empty_str(value) for value in candidates)
218
+ if candidate is not None
219
+ }
220
+ if not candidate_set:
221
+ _raise_ambiguous_metadata_scope(path, scopes, candidate_set)
222
+
223
+ matches = {
224
+ scope
225
+ for scope in scopes
226
+ if scope in candidate_set
227
+ or _read_scope_modality_key(dataset_path, scope) in candidate_set
228
+ }
229
+ if len(matches) == 1:
230
+ return next(iter(matches))
231
+ if len(matches) > 1:
232
+ _raise_ambiguous_metadata_scope(path, sorted(matches), candidate_set)
233
+
234
+ _raise_ambiguous_metadata_scope(path, scopes, candidate_set)
235
+
236
+
237
+ def _raise_ambiguous_metadata_scope(
238
+ path: str | Path,
239
+ scopes: list[str],
240
+ candidates: set[str],
241
+ ) -> None:
242
+ candidate_text = (
243
+ f" Candidates were: {', '.join(sorted(candidates))}."
244
+ if candidates
245
+ else ""
246
+ )
247
+ raise ValueError(
248
+ f"Multiple ds-crawler metadata scopes are available for {path!s}: "
249
+ f"{', '.join(scopes)}.{candidate_text} "
250
+ "Specify Modality(..., metadata_scope=...) or append "
251
+ "'#scope=<metadata_scope>' to the path."
252
+ )
101
253
 
102
254
 
103
255
  def load_dataset_config_for_scope(
@@ -31,8 +31,10 @@ except ImportError: # pragma: no cover - compatibility with older ds-crawler
31
31
  _ds_crawler_get_layout_addon = None
32
32
 
33
33
  from ._ds_crawler_utils import (
34
+ as_non_empty_str,
35
+ infer_metadata_scope,
34
36
  load_index_output,
35
- parse_path_with_split,
37
+ parse_modality_path,
36
38
  validate_metadata_scope,
37
39
  )
38
40
  from ._metadata import _build_runlog_entry, _get_ds_crawler_descriptor
@@ -303,6 +305,9 @@ class Modality:
303
305
  ``output.json`` from a prior indexing run). A colon-separated
304
306
  split suffix is also accepted (e.g. ``/data/ds.zip:train``);
305
307
  the suffix is extracted and used as the ``split`` parameter.
308
+ A metadata scope may also be selected inline with
309
+ ``#scope=<metadata_scope>``, including after a split suffix
310
+ (e.g. ``/data/ds.zip:train#scope=rgb``).
306
311
  origin_path: Optional original path string before any copying or symlinking for i.e. slurm staging.
307
312
  This is not used by euler-loading itself but can be useful for experiment
308
313
  logging to retain references to the original dataset location.
@@ -337,9 +342,10 @@ class Modality:
337
342
  logical modalities to share one physical directory or zip while
338
343
  keeping separate ``dataset-head.json``, ``ds-crawler.json``,
339
344
  ``index.json``, and split files under
340
- ``.ds_crawler/<metadata_scope>/``. When scoped artifacts are
341
- absent, loading falls back to the legacy root-level
342
- ``.ds_crawler`` layout.
345
+ ``.ds_crawler/<metadata_scope>/``. If omitted, euler-loading
346
+ infers a scope only when the choice is deterministic. When
347
+ configured scoped artifacts are absent, loading falls back to
348
+ the legacy root-level ``.ds_crawler`` layout.
343
349
  cache: Optional opt-in/out for in-memory caching of loaded values.
344
350
  Only meaningful for hierarchical modalities; regular
345
351
  modalities load once per sample and never cache.
@@ -377,7 +383,7 @@ class Modality:
377
383
  metadata: dict[str, Any] = field(default_factory=dict)
378
384
 
379
385
  def __post_init__(self) -> None:
380
- parsed_path, path_split = parse_path_with_split(self.path)
386
+ parsed_path, path_split, path_metadata_scope = parse_modality_path(self.path)
381
387
  if path_split is not None:
382
388
  if self.split is not None:
383
389
  raise ValueError(
@@ -385,16 +391,93 @@ class Modality:
385
391
  f"({path_split!r}) but an explicit split={self.split!r} "
386
392
  f"was also provided. Use one or the other, not both."
387
393
  )
394
+
395
+ normalized_metadata_scope = (
396
+ validate_metadata_scope(self.metadata_scope)
397
+ if self.metadata_scope is not None
398
+ else None
399
+ )
400
+ if path_metadata_scope is not None:
401
+ if (
402
+ normalized_metadata_scope is not None
403
+ and normalized_metadata_scope != path_metadata_scope
404
+ ):
405
+ raise ValueError(
406
+ f"Modality path {self.path!r} contains an inline "
407
+ f"metadata scope ({path_metadata_scope!r}) but an "
408
+ f"explicit metadata_scope={self.metadata_scope!r} was "
409
+ "also provided. Use one metadata scope selector."
410
+ )
411
+ normalized_metadata_scope = path_metadata_scope
412
+
413
+ if parsed_path != self.path:
388
414
  object.__setattr__(self, "path", parsed_path)
415
+ if path_split is not None:
389
416
  object.__setattr__(self, "split", path_split)
390
- if self.metadata_scope is not None:
417
+ if normalized_metadata_scope is not None:
391
418
  object.__setattr__(
392
419
  self,
393
420
  "metadata_scope",
394
- validate_metadata_scope(self.metadata_scope),
421
+ normalized_metadata_scope,
395
422
  )
396
423
 
397
424
 
425
+ def _loader_modality_type(loader: Callable[..., Any] | None) -> str | None:
426
+ meta = getattr(loader, "_modality_meta", None)
427
+ if not isinstance(meta, Mapping):
428
+ return None
429
+ return as_non_empty_str(meta.get("type"))
430
+
431
+
432
+ def _metadata_modality_type(modality: Modality) -> str | None:
433
+ euler_loading = modality.metadata.get("euler_loading")
434
+ if not isinstance(euler_loading, Mapping):
435
+ return None
436
+ return as_non_empty_str(euler_loading.get("modality_type"))
437
+
438
+
439
+ def _metadata_scope_candidates(name: str, modality: Modality) -> list[str | None]:
440
+ return [
441
+ name,
442
+ modality.modality_type,
443
+ _metadata_modality_type(modality),
444
+ _loader_modality_type(modality.loader),
445
+ ]
446
+
447
+
448
+ def _resolve_metadata_scope(name: str, modality: Modality) -> Modality:
449
+ if modality.metadata_scope is not None:
450
+ return modality
451
+
452
+ metadata_scope = infer_metadata_scope(
453
+ modality.path,
454
+ candidates=_metadata_scope_candidates(name, modality),
455
+ )
456
+ if metadata_scope is None:
457
+ return modality
458
+ return replace(modality, metadata_scope=metadata_scope)
459
+
460
+
461
+ def _resolve_metadata_scopes(
462
+ modalities: dict[str, Modality],
463
+ ) -> dict[str, Modality]:
464
+ return {
465
+ name: _resolve_metadata_scope(name, modality)
466
+ for name, modality in modalities.items()
467
+ }
468
+
469
+
470
+ def _modality_selector_suffix(modality: Modality) -> str:
471
+ selectors = []
472
+ if modality.split is not None:
473
+ selectors.append(f"split={modality.split}")
474
+ if modality.metadata_scope is not None:
475
+ selectors.append(f"metadata_scope={modality.metadata_scope}")
476
+ if not selectors:
477
+ return ""
478
+ return f" ({', '.join(selectors)})"
479
+
480
+
398
481
  class MultiModalDataset(_BaseDataset):
399
482
  """PyTorch Dataset that loads synchronized multi-modal samples.
400
483
 
@@ -581,6 +664,7 @@ class MultiModalDataset(_BaseDataset):
581
664
  if not modalities:
582
665
  raise ValueError("At least one modality must be provided.")
583
666
 
667
+ modalities = _resolve_metadata_scopes(modalities)
584
668
  indexed: dict[str, dict[str, Any]] = {}
585
669
  layouts: dict[str, dict[str, Any] | None] = {}
586
670
  for name, modality in modalities.items():
@@ -663,8 +747,12 @@ class MultiModalDataset(_BaseDataset):
663
747
  if not modalities:
664
748
  raise ValueError("At least one modality must be provided.")
665
749
 
750
+ modalities = _resolve_metadata_scopes(modalities)
751
+ hierarchical_modalities = _resolve_metadata_scopes(
752
+ hierarchical_modalities or {}
753
+ )
666
754
  self._modalities = modalities
667
- self._hierarchical_modalities = hierarchical_modalities or {}
755
+ self._hierarchical_modalities = hierarchical_modalities
668
756
  self._transforms = transforms or []
669
757
  self._index_outputs: dict[str, dict[str, Any]] = {}
670
758
  self._hierarchical_index_outputs: dict[str, dict[str, Any]] = {}
@@ -703,7 +791,7 @@ class MultiModalDataset(_BaseDataset):
703
791
  name,
704
792
  len(lookup),
705
793
  modality.path,
706
- f" (split={modality.split})" if modality.split is not None else "",
794
+ _modality_selector_suffix(modality),
707
795
  )
708
796
 
709
797
  # -- Index hierarchical modalities -----------------------------------
@@ -738,7 +826,7 @@ class MultiModalDataset(_BaseDataset):
738
826
  total_files,
739
827
  len(files_by_level),
740
828
  modality.path,
741
- f" (split={modality.split})" if modality.split is not None else "",
829
+ _modality_selector_suffix(modality),
742
830
  )
743
831
 
744
832
  # -- Compute common IDs (intersection across regular modalities) -----
@@ -12,6 +12,9 @@ Return types
12
12
  (single-channel class IDs).
13
13
  - **sky_mask** – ``np.ndarray`` of shape ``(H, W)`` bool
14
14
  (``True`` where class ID == 29).
15
+ - **calibration** – ``dict[str, dict[str, np.ndarray]]`` keyed by sensor
16
+ name. Each sensor dict contains ``"K"`` (3x3), ``"T"`` (4x4), and
17
+ ``"distortion"`` (8,).
15
18
 
16
19
  Usage::
17
20
 
@@ -24,6 +27,7 @@ Usage::
24
27
 
25
28
  from __future__ import annotations
26
29
 
30
+ import json
27
31
  from typing import Any, BinaryIO, Union
28
32
 
29
33
  import numpy as np
@@ -109,6 +113,136 @@ def sky_mask(path: Union[str, BinaryIO], meta: dict[str, Any] | None = None, *,
109
113
  return np.array(Image.open(path), dtype=np.uint8)[:, :, 0] == _SKY_CLASS_ID
110
114
 
111
115
 
116
+ # ---------------------------------------------------------------------------
117
+ # Calibration loaders
118
+ # ---------------------------------------------------------------------------
119
+
120
+
121
+ def _load_json(path: Union[str, BinaryIO]) -> Any:
122
+ """Load JSON from a file path or an in-memory buffer."""
123
+ if isinstance(path, str):
124
+ with open(path) as f:
125
+ return json.load(f)
126
+ return json.load(path)
127
+
128
+
129
+ def _quat_to_rotation_matrix(qw: float, qx: float, qy: float, qz: float) -> np.ndarray:
130
+ """Convert a quaternion ``(qw, qx, qy, qz)`` to a ``(3, 3)`` rotation matrix."""
131
+ return np.array(
132
+ [
133
+ [1 - 2 * (qy * qy + qz * qz), 2 * (qx * qy - qz * qw), 2 * (qx * qz + qy * qw)],
134
+ [2 * (qx * qy + qz * qw), 1 - 2 * (qx * qx + qz * qz), 2 * (qy * qz - qx * qw)],
135
+ [2 * (qx * qz - qy * qw), 2 * (qy * qz + qx * qw), 1 - 2 * (qx * qx + qy * qy)],
136
+ ],
137
+ dtype=np.float32,
138
+ )
139
+
140
+
141
+ @modality_meta(
142
+ modality_type="calibration",
143
+ dtype="dict",
144
+ hierarchical=True,
145
+ shape="dict",
146
+ file_formats=[".json"],
147
+ meta={"sensors": ["CS_FRONT", "HDL_32E", "HDL_64E"], "keys": ["K", "T", "distortion"]},
148
+ )
149
+ def calibration(path: Union[str, BinaryIO], meta: dict[str, Any] | None = None, *, attributes: dict[str, Any] | None = None) -> dict[str, dict[str, np.ndarray]]:
150
+ """Load a Real Drive Sim calibration JSON.
151
+
152
+ The file contains parallel arrays ``names``, ``intrinsics``, and
153
+ ``extrinsics`` - one entry per sensor. Returns a dict keyed by sensor
154
+ name, where each value contains:
155
+
156
+ - ``"K"`` - ``(3, 3)`` float32 camera-intrinsics matrix.
157
+ - ``"T"`` - ``(4, 4)`` float32 extrinsics matrix (rotation + translation).
158
+ - ``"distortion"`` - ``(8,)`` float32 distortion coefficients
159
+ ``[k1, k2, p1, p2, k3, k4, k5, k6]``.
160
+ """
161
+ data = _load_json(path)
162
+
163
+ result: dict[str, dict[str, np.ndarray]] = {}
164
+ for name, intr, extr in zip(data["names"], data["intrinsics"], data["extrinsics"]):
165
+ fx, fy = intr["fx"], intr["fy"]
166
+ cx, cy = intr["cx"], intr["cy"]
167
+ s = intr["skew"]
168
+ K = np.array(
169
+ [[fx, s, cx], [0.0, fy, cy], [0.0, 0.0, 1.0]],
170
+ dtype=np.float32,
171
+ )
172
+
173
+ rot = extr["rotation"]
174
+ R = _quat_to_rotation_matrix(rot["qw"], rot["qx"], rot["qy"], rot["qz"])
175
+ t = np.array(
176
+ [extr["translation"]["x"], extr["translation"]["y"], extr["translation"]["z"]],
177
+ dtype=np.float32,
178
+ )
179
+ T = np.eye(4, dtype=np.float32)
180
+ T[:3, :3] = R
181
+ T[:3, 3] = t
182
+
183
+ distortion = np.array(
184
+ [
185
+ intr["k1"],
186
+ intr["k2"],
187
+ intr["p1"],
188
+ intr["p2"],
189
+ intr["k3"],
190
+ intr["k4"],
191
+ intr["k5"],
192
+ intr["k6"],
193
+ ],
194
+ dtype=np.float32,
195
+ )
196
+
197
+ result[name] = {"K": K, "T": T, "distortion": distortion}
198
+ return result
199
+
200
+
201
+ @modality_meta(
202
+ modality_type="all_intrinsics",
203
+ dtype="dict",
204
+ hierarchical=True,
205
+ shape="dict",
206
+ file_formats=[".json"],
207
+ )
208
+ def all_intrinsics(path: Union[str, BinaryIO], meta: dict[str, Any] | None = None, *, attributes: dict[str, Any] | None = None) -> dict[str, np.ndarray]:
209
+ """Load only the intrinsics from a Real Drive Sim calibration JSON."""
210
+ data = _load_json(path)
211
+ result: dict[str, np.ndarray] = {}
212
+
213
+ for name, intr in zip(data["names"], data["intrinsics"]):
214
+ fx, fy = intr["fx"], intr["fy"]
215
+ cx, cy = intr["cx"], intr["cy"]
216
+ s = intr["skew"]
217
+ K = np.array(
218
+ [[fx, s, cx], [0.0, fy, cy], [0.0, 0.0, 1.0]],
219
+ dtype=np.float32,
220
+ )
221
+ result[name] = K
222
+
223
+ return result
224
+
225
+
226
+ @modality_meta(
227
+ modality_type="intrinsics",
228
+ dtype="float32",
229
+ hierarchical=True,
230
+ shape="3x3",
231
+ file_formats=[".json"],
232
+ meta={"sensor": "CS_FRONT"},
233
+ )
234
+ def read_intrinsics(path: Union[str, BinaryIO], meta: dict[str, Any] | None = None, *, attributes: dict[str, Any] | None = None) -> np.ndarray:
235
+ """Load the intrinsics for a specific sensor from a Real Drive Sim calibration JSON."""
236
+ sensor = (attributes or {}).get("sensor") or (meta or {}).get("sensor") or "CS_FRONT"
237
+ intrinsics = all_intrinsics(path)
238
+ if sensor not in intrinsics:
239
+ raise KeyError(
240
+ f"Real Drive Sim calibration has no intrinsics for sensor {sensor!r}. "
241
+ f"Available sensors: {sorted(intrinsics)}"
242
+ )
243
+ return intrinsics[sensor]
244
+
245
+
112
246
  # ---------------------------------------------------------------------------
113
247
  # Writers
114
248
  # ---------------------------------------------------------------------------
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "euler-loading"
3
- version = "2.11.0"
3
+ version = "2.12.0"
4
4
  description = "Multi-modal PyTorch dataloader using ds-crawler indices"
5
5
  requires-python = ">=3.9"
6
6
  dependencies = [
@@ -343,6 +343,7 @@ class TestScopedMetadataLoading:
343
343
  ]
344
344
  }
345
345
  }
346
+ _write_metadata(root, "index.json", rgb_index)
346
347
  _write_metadata(
347
348
  root,
348
349
  "index.json",
@@ -412,6 +413,170 @@ class TestScopedMetadataLoading:
412
413
  "metadata_scope": "rgb",
413
414
  }
414
415
 
416
+ def test_metadata_scope_inferred_from_single_available_scope(self, tmp_path):
417
+ root = tmp_path / "muses"
418
+ root.mkdir()
419
+ rgb_index = _flat_index("rgb", ["f001"])
420
+ _write_metadata(root, "index.json", rgb_index, scope="rgb")
421
+
422
+ def mock_index(path, **kwargs):
423
+ assert kwargs.get("metadata_scope") == "rgb"
424
+ return rgb_index
425
+
426
+ with patch(
427
+ "euler_loading.dataset.index_dataset_from_path",
428
+ side_effect=mock_index,
429
+ ):
430
+ ds = MultiModalDataset(
431
+ modalities={
432
+ "image": Modality(str(root), loader=dummy_loader),
433
+ },
434
+ )
435
+
436
+ assert len(ds) == 1
437
+ assert ds.modality_paths()["image"] == {
438
+ "path": str(root),
439
+ "origin_path": None,
440
+ "metadata_scope": "rgb",
441
+ }
442
+
443
+ def test_metadata_scope_inferred_from_modality_name(self, tmp_path):
444
+ root = tmp_path / "muses"
445
+ root.mkdir()
446
+ rgb_index = _flat_index("rgb", ["f001"])
447
+ depth_index = _flat_index("depth", ["f001"])
448
+ _write_metadata(root, "index.json", rgb_index, scope="rgb")
449
+ _write_metadata(root, "index.json", depth_index, scope="depth")
450
+
451
+ def mock_index(path, **kwargs):
452
+ assert kwargs.get("metadata_scope") == "rgb"
453
+ return rgb_index
454
+
455
+ with patch(
456
+ "euler_loading.dataset.index_dataset_from_path",
457
+ side_effect=mock_index,
458
+ ):
459
+ ds = MultiModalDataset(
460
+ modalities={
461
+ "rgb": Modality(str(root), loader=dummy_loader),
462
+ },
463
+ )
464
+
465
+ assert ds.modality_paths()["rgb"]["metadata_scope"] == "rgb"
466
+
467
+ def test_metadata_scope_inferred_from_loader_modality_type(self, tmp_path):
468
+ root = tmp_path / "muses"
469
+ root.mkdir()
470
+ rgb_index = _flat_index("rgb", ["f001"])
471
+ depth_index = _flat_index("depth", ["f001"])
472
+ _write_metadata(root, "index.json", rgb_index, scope="rgb")
473
+ _write_metadata(root, "index.json", depth_index, scope="depth")
474
+
475
+ def rgb_loader(path, meta=None):
476
+ return dummy_loader(path, meta)
477
+
478
+ rgb_loader._modality_meta = {"type": "rgb"} # type: ignore[attr-defined]
479
+
480
+ def mock_index(path, **kwargs):
481
+ assert kwargs.get("metadata_scope") == "rgb"
482
+ return rgb_index
483
+
484
+ with patch(
485
+ "euler_loading.dataset.index_dataset_from_path",
486
+ side_effect=mock_index,
487
+ ):
488
+ ds = MultiModalDataset(
489
+ modalities={
490
+ "image": Modality(str(root), loader=rgb_loader),
491
+ },
492
+ )
493
+
494
+ assert ds.modality_paths()["image"]["metadata_scope"] == "rgb"
495
+
496
+ def test_metadata_scope_inferred_from_scoped_head_modality_key(self, tmp_path):
497
+ root = tmp_path / "muses"
498
+ root.mkdir()
499
+ rgb_index = _flat_index("rgb", ["f001"])
500
+ depth_index = _flat_index("depth", ["f001"])
501
+ _write_metadata(root, "index.json", rgb_index, scope="scope-a")
502
+ _write_metadata(
503
+ root,
504
+ "dataset-head.json",
505
+ {"dataset": {"name": "MUSES"}, "modality": {"key": "rgb"}},
506
+ scope="scope-a",
507
+ )
508
+ _write_metadata(root, "index.json", depth_index, scope="scope-b")
509
+ _write_metadata(
510
+ root,
511
+ "dataset-head.json",
512
+ {"dataset": {"name": "MUSES"}, "modality": {"key": "depth"}},
513
+ scope="scope-b",
514
+ )
515
+
516
+ def mock_index(path, **kwargs):
517
+ assert kwargs.get("metadata_scope") == "scope-a"
518
+ return rgb_index
519
+
520
+ with patch(
521
+ "euler_loading.dataset.index_dataset_from_path",
522
+ side_effect=mock_index,
523
+ ):
524
+ ds = MultiModalDataset(
525
+ modalities={
526
+ "image": Modality(
527
+ str(root),
528
+ loader=dummy_loader,
529
+ modality_type="rgb",
530
+ ),
531
+ },
532
+ )
533
+
534
+ assert ds.modality_paths()["image"]["metadata_scope"] == "scope-a"
535
+
536
+ def test_multiple_metadata_scopes_without_match_raises(self, tmp_path):
537
+ root = tmp_path / "muses"
538
+ root.mkdir()
539
+ _write_metadata(root, "index.json", _flat_index("rgb", ["f001"]), scope="rgb")
540
+ _write_metadata(
541
+ root,
542
+ "index.json",
543
+ _flat_index("depth", ["f001"]),
544
+ scope="depth",
545
+ )
546
+
547
+ with pytest.raises(ValueError, match="Multiple ds-crawler metadata scopes"):
548
+ MultiModalDataset(
549
+ modalities={
550
+ "image": Modality(str(root), loader=dummy_loader),
551
+ },
552
+ )
553
+
554
+ def test_root_metadata_prevents_scope_inference(self, tmp_path):
555
+ root = tmp_path / "muses"
556
+ root.mkdir()
557
+ rgb_index = _flat_index("rgb", ["f001"])
558
+ _write_metadata(root, "index.json", rgb_index)
559
+ _write_metadata(root, "index.json", _flat_index("depth", ["f001"]), scope="rgb")
560
+
561
+ def mock_index(path, **kwargs):
562
+ assert "metadata_scope" not in kwargs
563
+ return rgb_index
564
+
565
+ with patch(
566
+ "euler_loading.dataset.index_dataset_from_path",
567
+ side_effect=mock_index,
568
+ ):
569
+ ds = MultiModalDataset(
570
+ modalities={
571
+ "rgb": Modality(str(root), loader=dummy_loader),
572
+ },
573
+ )
574
+
575
+ assert ds.modality_paths()["rgb"] == {
576
+ "path": str(root),
577
+ "origin_path": None,
578
+ }
579
+
415
580
 
416
581
  class TestPathWithColonSplit:
417
582
  """Colon-separated path:split syntax."""
@@ -436,14 +601,39 @@ class TestPathWithColonSplit:
436
601
  assert mod.path == "/data/ds"
437
602
  assert mod.split == "train"
438
603
 
604
+ def test_scope_extracted_from_path(self):
605
+ mod = Modality("/data/ds.zip#scope=rgb")
606
+ assert mod.path == "/data/ds.zip"
607
+ assert mod.split is None
608
+ assert mod.metadata_scope == "rgb"
609
+
610
+ def test_split_and_scope_extracted_from_path(self):
611
+ mod = Modality("/data/ds.zip:train#scope=rgb")
612
+ assert mod.path == "/data/ds.zip"
613
+ assert mod.split == "train"
614
+ assert mod.metadata_scope == "rgb"
615
+
616
+ def test_inline_scope_can_match_explicit_scope(self):
617
+ mod = Modality("/data/ds.zip#scope=rgb", metadata_scope=" rgb ")
618
+ assert mod.path == "/data/ds.zip"
619
+ assert mod.metadata_scope == "rgb"
620
+
439
621
  def test_colon_and_explicit_split_raises(self):
440
622
  with pytest.raises(ValueError, match="inline split"):
441
623
  Modality("/data/ds:train", split="val")
442
624
 
625
+ def test_inline_scope_conflict_raises(self):
626
+ with pytest.raises(ValueError, match="inline metadata scope"):
627
+ Modality("/data/ds.zip#scope=rgb", metadata_scope="depth")
628
+
443
629
  def test_invalid_metadata_scope_raises(self):
444
630
  with pytest.raises(ValueError, match="metadata_scope"):
445
631
  Modality("/data/ds", metadata_scope="bad/scope")
446
632
 
633
+ def test_invalid_inline_metadata_scope_raises(self):
634
+ with pytest.raises(ValueError, match="metadata_scope"):
635
+ Modality("/data/ds.zip#scope=bad/scope")
636
+
447
637
  def test_windows_drive_letter_not_treated_as_split(self):
448
638
  mod = Modality("C:\\data\\ds")
449
639
  assert mod.path == "C:\\data\\ds"
@@ -813,6 +813,47 @@ class TestRDSCalibration:
813
813
  assert self.result["CS_FRONT"]["distortion"].dtype == torch.float32
814
814
 
815
815
 
816
+ class TestRDSCPUCalibration:
817
+ """CPU calibration loader parses the Real Drive Sim JSON format."""
818
+
819
+ @pytest.fixture(autouse=True)
820
+ def _load(self):
821
+ self.result = cpu_rds.calibration(_RDS_CALIB_PATH)
822
+
823
+ def test_returns_dict_keyed_by_sensor_name(self):
824
+ assert set(self.result.keys()) == {"CS_FRONT", "HDL_32E", "HDL_64E"}
825
+
826
+ def test_each_sensor_has_expected_keys(self):
827
+ for sensor in self.result.values():
828
+ assert set(sensor.keys()) == {"K", "T", "distortion"}
829
+
830
+ def test_intrinsics_shape(self):
831
+ assert self.result["CS_FRONT"]["K"].shape == (3, 3)
832
+
833
+ def test_intrinsics_dtype(self):
834
+ assert self.result["CS_FRONT"]["K"].dtype == np.float32
835
+
836
+ def test_intrinsics_values(self):
837
+ K = self.result["CS_FRONT"]["K"]
838
+ assert np.isclose(K[0, 0], 2262.52001953125)
839
+ assert np.isclose(K[1, 1], 2265.3017578125)
840
+ assert np.isclose(K[0, 2], 1096.97998046875)
841
+ assert np.isclose(K[1, 2], 513.1370239257812)
842
+ assert K[2, 2] == 1.0
843
+
844
+ def test_read_intrinsics_returns_front_camera_matrix(self):
845
+ K = cpu_rds.read_intrinsics(_RDS_CALIB_PATH)
846
+ assert K.shape == (3, 3)
847
+ assert K.dtype == np.float32
848
+ assert np.isclose(K[0, 0], 2262.52001953125)
849
+
850
+ def test_read_intrinsics_can_select_sensor_from_attributes(self):
851
+ K = cpu_rds.read_intrinsics(_RDS_CALIB_PATH, attributes={"sensor": "HDL_32E"})
852
+ assert K.shape == (3, 3)
853
+ assert K.dtype == np.float32
854
+ assert K[0, 0] == 0.0
855
+
856
+
816
857
  # ---------------------------------------------------------------------------
817
858
  # Writer round-trip tests
818
859
  # ---------------------------------------------------------------------------