euler-loading 2.6.0__tar.gz → 2.8.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 (50) hide show
  1. {euler_loading-2.6.0 → euler_loading-2.8.0}/PKG-INFO +1 -1
  2. {euler_loading-2.6.0 → euler_loading-2.8.0}/README.md +52 -4
  3. {euler_loading-2.6.0 → euler_loading-2.8.0}/euler_loading/_writing.py +4 -3
  4. {euler_loading-2.6.0 → euler_loading-2.8.0}/euler_loading/dataset.py +274 -15
  5. {euler_loading-2.6.0 → euler_loading-2.8.0}/package-lock.json +1 -1
  6. {euler_loading-2.6.0 → euler_loading-2.8.0}/pyproject.toml +1 -1
  7. {euler_loading-2.6.0 → euler_loading-2.8.0}/tests/test_dataset.py +145 -4
  8. {euler_loading-2.6.0 → euler_loading-2.8.0}/.github/workflows/workflow.yml +0 -0
  9. {euler_loading-2.6.0 → euler_loading-2.8.0}/.gitignore +0 -0
  10. {euler_loading-2.6.0 → euler_loading-2.8.0}/docs/loader-attributes.md +0 -0
  11. {euler_loading-2.6.0 → euler_loading-2.8.0}/euler_loading/__init__.py +0 -0
  12. {euler_loading-2.6.0 → euler_loading-2.8.0}/euler_loading/_dataset_contract.py +0 -0
  13. {euler_loading-2.6.0 → euler_loading-2.8.0}/euler_loading/_ds_crawler_utils.py +0 -0
  14. {euler_loading-2.6.0 → euler_loading-2.8.0}/euler_loading/_metadata.py +0 -0
  15. {euler_loading-2.6.0 → euler_loading-2.8.0}/euler_loading/_resolution.py +0 -0
  16. {euler_loading-2.6.0 → euler_loading-2.8.0}/euler_loading/indexing.py +0 -0
  17. {euler_loading-2.6.0 → euler_loading-2.8.0}/euler_loading/loaders/__init__.py +0 -0
  18. {euler_loading-2.6.0 → euler_loading-2.8.0}/euler_loading/loaders/_annotations.py +0 -0
  19. {euler_loading-2.6.0 → euler_loading-2.8.0}/euler_loading/loaders/_writer_utils.py +0 -0
  20. {euler_loading-2.6.0 → euler_loading-2.8.0}/euler_loading/loaders/contracts.py +0 -0
  21. {euler_loading-2.6.0 → euler_loading-2.8.0}/euler_loading/loaders/cpu/__init__.py +0 -0
  22. {euler_loading-2.6.0 → euler_loading-2.8.0}/euler_loading/loaders/cpu/generic.py +0 -0
  23. {euler_loading-2.6.0 → euler_loading-2.8.0}/euler_loading/loaders/cpu/generic_dense_depth.py +0 -0
  24. {euler_loading-2.6.0 → euler_loading-2.8.0}/euler_loading/loaders/cpu/real_drive_sim.py +0 -0
  25. {euler_loading-2.6.0 → euler_loading-2.8.0}/euler_loading/loaders/cpu/vkitti2.py +0 -0
  26. {euler_loading-2.6.0 → euler_loading-2.8.0}/euler_loading/loaders/generate/__init__.py +0 -0
  27. {euler_loading-2.6.0 → euler_loading-2.8.0}/euler_loading/loaders/generate/__main__.py +0 -0
  28. {euler_loading-2.6.0 → euler_loading-2.8.0}/euler_loading/loaders/generate/loaders.json +0 -0
  29. {euler_loading-2.6.0 → euler_loading-2.8.0}/euler_loading/loaders/generic.py +0 -0
  30. {euler_loading-2.6.0 → euler_loading-2.8.0}/euler_loading/loaders/gpu/__init__.py +0 -0
  31. {euler_loading-2.6.0 → euler_loading-2.8.0}/euler_loading/loaders/gpu/generic.py +0 -0
  32. {euler_loading-2.6.0 → euler_loading-2.8.0}/euler_loading/loaders/gpu/generic_dense_depth.py +0 -0
  33. {euler_loading-2.6.0 → euler_loading-2.8.0}/euler_loading/loaders/gpu/real_drive_sim.py +0 -0
  34. {euler_loading-2.6.0 → euler_loading-2.8.0}/euler_loading/loaders/gpu/vkitti2.py +0 -0
  35. {euler_loading-2.6.0 → euler_loading-2.8.0}/euler_loading/loaders/real_drive_sim.py +0 -0
  36. {euler_loading-2.6.0 → euler_loading-2.8.0}/euler_loading/loaders/vkitti2.py +0 -0
  37. {euler_loading-2.6.0 → euler_loading-2.8.0}/euler_loading/preprocessing.py +0 -0
  38. {euler_loading-2.6.0 → euler_loading-2.8.0}/example.py +0 -0
  39. {euler_loading-2.6.0 → euler_loading-2.8.0}/sample_rds.py +0 -0
  40. {euler_loading-2.6.0 → euler_loading-2.8.0}/tests/__init__.py +0 -0
  41. {euler_loading-2.6.0 → euler_loading-2.8.0}/tests/conftest.py +0 -0
  42. {euler_loading-2.6.0 → euler_loading-2.8.0}/tests/example_rds_calib.json +0 -0
  43. {euler_loading-2.6.0 → euler_loading-2.8.0}/tests/test_id_schema.py +0 -0
  44. {euler_loading-2.6.0 → euler_loading-2.8.0}/tests/test_indexing.py +0 -0
  45. {euler_loading-2.6.0 → euler_loading-2.8.0}/tests/test_loaders.py +0 -0
  46. {euler_loading-2.6.0 → euler_loading-2.8.0}/tests/test_preprocessing.py +0 -0
  47. {euler_loading-2.6.0 → euler_loading-2.8.0}/tests/test_real_dataset.py +0 -0
  48. {euler_loading-2.6.0 → euler_loading-2.8.0}/tests/test_writing.py +0 -0
  49. {euler_loading-2.6.0 → euler_loading-2.8.0}/vkitti_cpu_example_output.json +0 -0
  50. {euler_loading-2.6.0 → euler_loading-2.8.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.6.0
3
+ Version: 2.8.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
@@ -4,7 +4,7 @@ Multi-modal PyTorch `Dataset` that synchronises files across arbitrary dataset m
4
4
 
5
5
  Each modality points at a directory (or `.zip` archive) that carries its own `ds-crawler.config` (or cached `output.json`).
6
6
  ds-crawler indexes the directory tree, discovers files, and exposes hierarchical metadata (path properties, calibration files, …).
7
- euler-loading then **intersects file IDs** across all modalities so that every sample contains exactly one file per modality. Additional hierarchical data (e.g. per-scene calibration files) can be loaded via `hierarchical_modalities`.
7
+ euler-loading then **intersects file IDs** across all modalities so that every sample contains exactly one file per modality. Additional hierarchical data (e.g. per-scene calibration files) can be loaded via `hierarchical_modalities`. For augmentation-style datasets — many augmented files per ground-truth sample — `euler_layout` metadata lets `MultiModalDataset.from_layout(...)` load shared targets as collapsed hierarchical modalities.
8
8
  How a file is actually **loaded** (image, depth map, point cloud, …) is configurable per modality — either supply a `Callable` or let euler-loading resolve a built-in loader automatically from the ds-crawler config.
9
9
  Writer functions can be resolved the same way, so inference outputs can be written back in dataset-native formats.
10
10
 
@@ -65,6 +65,8 @@ 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
+ | `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. |
69
+ | `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. |
68
70
  | `metadata` | `dict[str, Any]` | Optional arbitrary metadata. Keys under `metadata["euler_loading"]` are treated as euler-loading defaults. |
69
71
 
70
72
  The loader is the **only** place where domain-specific I/O happens.
@@ -168,14 +170,22 @@ PyTorch `Dataset`. On construction it:
168
170
  ...
169
171
  "id": str, # file ID (leaf only, shared across modalities)
170
172
  "full_id": str, # full hierarchical path including file ID (e.g. "/scene/camera/frame")
171
- "meta": { # per-modality ds-crawler file entries (regular modalities only)
172
- "<modality_name>": {"id": ..., "path": ..., "path_properties": ..., "basename_properties": ...},
173
+ "meta": { # per-modality ds-crawler file entries
174
+ "<modality_name>": {"id": ..., "path": ..., "path_properties": ..., "basename_properties": ..., "attributes": ...},
175
+ ...
176
+ },
177
+ "attributes": { # per-modality top-level surface for file_entry["attributes"]
178
+ "<modality_name>": {...}, # for regular modalities (single dict)
179
+ "<hierarchical_modality_name>": { # for hierarchical modalities (one dict per matched file)
180
+ "<file_id>": {...},
181
+ ...
182
+ },
173
183
  ...
174
184
  },
175
185
  }
176
186
  ```
177
187
 
178
- Hierarchical modality results are cached so shared files are parsed only once.
188
+ Hierarchical modality results are cached by default. This can be flipped via `Modality(..., cache=True|False)` — see the [`cache` row in the `Modality` table](#modalitypath--loadernone-metadatanone).
179
189
 
180
190
  ### `FileRecord`
181
191
 
@@ -315,6 +325,44 @@ Files across modalities are matched by these IDs, so **the directory structure m
315
325
 
316
326
  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.
317
327
 
328
+ ## Layout-aware augmentation loading
329
+
330
+ Augmentation datasets often contain many files for the same source sample, while a target modality such as GT depth should be reused for every augmentation of that source. The recommended layout is to index the source sample as a hierarchy level and the augmentation variant as the file ID:
331
+
332
+ ```
333
+ augmented_rgb/
334
+ scene_000000/CS_FRONT/file_id:000000000000000025/mor_10m.png
335
+ scene_000000/CS_FRONT/file_id:000000000000000025/mor_20m.png
336
+ scene_000000/CS_FRONT/file_id:000000000000000026/mor_10m.png
337
+ ...
338
+ gt_depth/
339
+ scene_000000/CS_FRONT/file_id:000000000000000025/depth.png
340
+ scene_000000/CS_FRONT/file_id:000000000000000026/depth.png
341
+ ...
342
+ ```
343
+
344
+ Both modality indexes should include an `euler_layout` addon. The augmented RGB layout declares a `sample_axis` such as `file_id` at `location="hierarchy"` and a `variant_axis` at `location="file_id"`. The GT depth layout declares the same `sample_axis` and no variant axis.
345
+
346
+ ```python
347
+ from euler_loading import Modality, MultiModalDataset
348
+
349
+ dataset = MultiModalDataset.from_layout(
350
+ {
351
+ "rgb_aug": Modality("/data/augmented_rgb", loader=load_rgb),
352
+ "depth": Modality("/data/gt_depth", loader=load_depth),
353
+ },
354
+ primary="rgb_aug",
355
+ )
356
+
357
+ sample = dataset[0]
358
+ # sample["rgb_aug"] – the per-augmentation RGB
359
+ # sample["depth"] – the GT depth for this file_id, shared across variants
360
+ # sample["full_id"] – e.g. "/scene_000000/CS_FRONT/file_id:000…025/mor_10m"
361
+ # sample["id"] – the augmentation's leaf id, e.g. "mor_10m"
362
+ ```
363
+
364
+ `from_layout(...)` plans the GT modality as `hierarchical_modalities={"depth": Modality(..., collapse_single=True)}`. The hierarchical match uses the regular sample's hierarchy path, so source samples remain disambiguated by their parent hierarchy as well as by `file_id`. If a shared modality declares the same `sample_axis` but its index does not expose that axis as hierarchy, construction raises and the dataset should be re-indexed with the source sample axis in hierarchy.
365
+
318
366
  ## DenseDepthLoader protocol
319
367
 
320
368
  `euler_loading.DenseDepthLoader` is a `runtime_checkable` Protocol defining the loader contract for dense-depth datasets. A conforming module must expose:
@@ -55,10 +55,11 @@ def _wrap_writer_save_index(
55
55
  result["meta"] = dict(meta)
56
56
 
57
57
  if isinstance(addons, Mapping):
58
- for addon_name in ("euler_train", "euler_loading"):
59
- addon = addons.get(addon_name)
58
+ for addon_name, addon in addons.items():
59
+ if not str(addon_name).startswith("euler_"):
60
+ continue
60
61
  if isinstance(addon, Mapping):
61
- result[addon_name] = dict(addon)
62
+ result[str(addon_name)] = dict(addon)
62
63
 
63
64
  index_tree = output.get("index")
64
65
  if isinstance(index_tree, Mapping):
@@ -7,7 +7,7 @@ import os
7
7
  from pathlib import Path
8
8
  import zipfile
9
9
  from collections.abc import Mapping
10
- from dataclasses import dataclass, field
10
+ from dataclasses import dataclass, field, replace
11
11
  from typing import Any, Callable
12
12
 
13
13
  try:
@@ -25,6 +25,11 @@ from ds_crawler import (
25
25
  )
26
26
  from ds_crawler.zip_utils import get_zip_root_prefix, is_zip_path
27
27
 
28
+ try:
29
+ from ds_crawler import get_layout_addon as _ds_crawler_get_layout_addon
30
+ except ImportError: # pragma: no cover - compatibility with older ds-crawler
31
+ _ds_crawler_get_layout_addon = None
32
+
28
33
  from ._ds_crawler_utils import load_index_output, parse_path_with_split
29
34
  from ._metadata import _build_runlog_entry, _get_ds_crawler_descriptor
30
35
  from ._resolution import (
@@ -120,6 +125,112 @@ def _get_index_meta(index_output: Mapping[str, Any]) -> dict[str, Any] | None:
120
125
  return None
121
126
 
122
127
 
128
+ def _get_index_layout(index_output: Mapping[str, Any]) -> dict[str, Any] | None:
129
+ """Return the optional ``euler_layout`` addon from an index payload."""
130
+ if _ds_crawler_get_layout_addon is not None:
131
+ try:
132
+ return _ds_crawler_get_layout_addon(dict(index_output))
133
+ except Exception:
134
+ logger.debug("Failed to parse euler_layout addon.", exc_info=True)
135
+
136
+ head = index_output.get("head")
137
+ if isinstance(head, Mapping):
138
+ addons = head.get("addons")
139
+ if isinstance(addons, Mapping):
140
+ layout = addons.get("euler_layout")
141
+ if isinstance(layout, Mapping):
142
+ return dict(layout)
143
+
144
+ layout = index_output.get("euler_layout")
145
+ if isinstance(layout, Mapping):
146
+ return dict(layout)
147
+ return None
148
+
149
+
150
+ def _layout_axis(layout: Mapping[str, Any] | None, axis: str) -> dict[str, Any] | None:
151
+ if not isinstance(layout, Mapping):
152
+ return None
153
+ value = layout.get(axis)
154
+ return dict(value) if isinstance(value, Mapping) else None
155
+
156
+
157
+ def _layout_axis_name(layout: Mapping[str, Any] | None, axis: str) -> str | None:
158
+ value = _layout_axis(layout, axis)
159
+ name = value.get("name") if value else None
160
+ return name if isinstance(name, str) and name else None
161
+
162
+
163
+ def _layout_has_variant_axis(layout: Mapping[str, Any] | None) -> bool:
164
+ return _layout_axis(layout, "variant_axis") is not None
165
+
166
+
167
+ def _layout_family(layout: Mapping[str, Any] | None) -> str | None:
168
+ if not isinstance(layout, Mapping):
169
+ return None
170
+ family = layout.get("family")
171
+ return family if isinstance(family, str) and family else None
172
+
173
+
174
+ def _layout_families_compatible(
175
+ left: Mapping[str, Any] | None,
176
+ right: Mapping[str, Any] | None,
177
+ ) -> bool:
178
+ left_family = _layout_family(left)
179
+ right_family = _layout_family(right)
180
+ return (
181
+ left_family is None
182
+ or right_family is None
183
+ or left_family == right_family
184
+ )
185
+
186
+
187
+ def _index_hierarchy_separator(index_output: Mapping[str, Any]) -> str | None:
188
+ indexing = index_output.get("indexing")
189
+ hierarchy = indexing.get("hierarchy") if isinstance(indexing, Mapping) else None
190
+ if isinstance(hierarchy, Mapping):
191
+ separator = hierarchy.get("separator")
192
+ if isinstance(separator, str) and separator:
193
+ return separator
194
+ return None
195
+
196
+
197
+ def _tree_has_hierarchy_axis(
198
+ node: Mapping[str, Any],
199
+ *,
200
+ axis_name: str,
201
+ separator: str,
202
+ ) -> bool:
203
+ prefix = f"{axis_name}{separator}"
204
+ children = node.get("children")
205
+ if not isinstance(children, Mapping):
206
+ return False
207
+ for key, child in children.items():
208
+ if isinstance(key, str) and key.startswith(prefix):
209
+ return True
210
+ if isinstance(child, Mapping) and _tree_has_hierarchy_axis(
211
+ child, axis_name=axis_name, separator=separator,
212
+ ):
213
+ return True
214
+ return False
215
+
216
+
217
+ def _index_has_hierarchy_axis(
218
+ index_output: Mapping[str, Any],
219
+ *,
220
+ axis_name: str,
221
+ ) -> bool:
222
+ separator = _index_hierarchy_separator(index_output)
223
+ if not separator:
224
+ return False
225
+ try:
226
+ tree = _get_index_tree(index_output)
227
+ except KeyError:
228
+ return False
229
+ return _tree_has_hierarchy_axis(
230
+ tree, axis_name=axis_name, separator=separator,
231
+ )
232
+
233
+
123
234
  def _get_file_attributes(file_entry: Mapping[str, Any]) -> dict[str, Any] | None:
124
235
  attributes = file_entry.get("attributes")
125
236
  if isinstance(attributes, Mapping):
@@ -217,6 +328,22 @@ class Modality:
217
328
  split: Optional inline split name. When set, euler-loading reads the
218
329
  full ds-crawler metadata from the modality root and overlays the
219
330
  dataset payload from ``.ds_crawler/split_<name>.json``.
331
+ cache: Optional opt-in/out for in-memory caching of loaded values.
332
+ Only meaningful for hierarchical modalities; regular
333
+ modalities load once per sample and never cache.
334
+
335
+ - ``True``: every distinct file is decoded at most once
336
+ per dataset lifetime, then served from memory.
337
+ - ``False``: every access re-reads and re-decodes the
338
+ file. Use this for large per-sample tensors where the
339
+ cache would balloon.
340
+ - ``None`` (default): hierarchical modalities default to
341
+ ``True`` (small calibration files benefit).
342
+ collapse_single: When ``True`` for a hierarchical modality, return the
343
+ single matched hierarchical value directly instead of a
344
+ ``{file_id: value}`` dict. This is useful for shared GT
345
+ tensors loaded once per source sample across augmentations.
346
+ A sample with more than one matched file raises.
220
347
  metadata: Optional arbitrary metadata. Keys under
221
348
  ``metadata["euler_loading"]`` are treated as
222
349
  euler-loading-specific defaults.
@@ -232,6 +359,8 @@ class Modality:
232
359
  hierarchy_scope: str | None = None
233
360
  applies_to: list[str] | None = None
234
361
  split: str | None = None
362
+ cache: bool | None = None
363
+ collapse_single: bool = False
235
364
  metadata: dict[str, Any] = field(default_factory=dict)
236
365
 
237
366
  def __post_init__(self) -> None:
@@ -403,6 +532,100 @@ class MultiModalDataset(_BaseDataset):
403
532
  "hierarchical_modalities": hierarchical_modalities,
404
533
  }
405
534
 
535
+ @classmethod
536
+ def from_layout(
537
+ cls,
538
+ modalities: dict[str, Modality],
539
+ *,
540
+ primary: str | None = None,
541
+ transforms: list[Callable[[dict[str, Any]], dict[str, Any]]] | None = None,
542
+ ) -> "MultiModalDataset":
543
+ """Create a dataset by planning modality roles from ``euler_layout``.
544
+
545
+ When the primary modality has a variant axis (for example fog
546
+ augmentations grouped under ``file_id:<source>``) and an auxiliary
547
+ modality has the same sample axis but no variant axis, the auxiliary is
548
+ loaded as shared per-source data. If the auxiliary index already
549
+ exposes that sample axis as a hierarchy key, it is wired as a collapsed
550
+ hierarchical modality. Otherwise construction fails because the shared
551
+ sample key cannot be addressed unambiguously.
552
+
553
+ Existing constructor semantics remain unchanged; this helper is the
554
+ opt-in path for layout-aware loading.
555
+ """
556
+ if not modalities:
557
+ raise ValueError("At least one modality must be provided.")
558
+
559
+ indexed: dict[str, dict[str, Any]] = {}
560
+ layouts: dict[str, dict[str, Any] | None] = {}
561
+ for name, modality in modalities.items():
562
+ index = load_index_output(
563
+ modality.path,
564
+ split=modality.split,
565
+ index_dataset_from_path_fn=index_dataset_from_path,
566
+ )
567
+ indexed[name] = index
568
+ layouts[name] = _get_index_layout(index)
569
+
570
+ if primary is None:
571
+ primary = next(
572
+ (
573
+ name for name, layout in layouts.items()
574
+ if _layout_has_variant_axis(layout)
575
+ ),
576
+ next(iter(modalities)),
577
+ )
578
+ if primary not in modalities:
579
+ raise ValueError(
580
+ f"primary modality {primary!r} is not in modalities; "
581
+ f"known: {sorted(modalities)}"
582
+ )
583
+
584
+ primary_layout = layouts[primary]
585
+ primary_sample_axis = _layout_axis_name(primary_layout, "sample_axis")
586
+ primary_has_variants = _layout_has_variant_axis(primary_layout)
587
+
588
+ regular_modalities: dict[str, Modality] = {primary: modalities[primary]}
589
+ hierarchical_modalities: dict[str, Modality] = {}
590
+
591
+ for name, modality in modalities.items():
592
+ if name == primary:
593
+ continue
594
+
595
+ layout = layouts[name]
596
+ sample_axis = _layout_axis_name(layout, "sample_axis")
597
+ shared_with_primary = (
598
+ primary_has_variants
599
+ and primary_sample_axis is not None
600
+ and sample_axis == primary_sample_axis
601
+ and not _layout_has_variant_axis(layout)
602
+ and _layout_families_compatible(primary_layout, layout)
603
+ )
604
+
605
+ if not shared_with_primary:
606
+ regular_modalities[name] = modality
607
+ continue
608
+
609
+ if _index_has_hierarchy_axis(indexed[name], axis_name=sample_axis):
610
+ hierarchical_modalities[name] = replace(
611
+ modality, collapse_single=True,
612
+ )
613
+ continue
614
+
615
+ raise ValueError(
616
+ f"Modality {name!r} shares sample_axis={sample_axis!r} with "
617
+ f"variant primary {primary!r}, but its ds-crawler index has "
618
+ "no matching hierarchy axis. Re-index it with that axis as "
619
+ "hierarchy so it can be loaded as a collapsed hierarchical "
620
+ "modality."
621
+ )
622
+
623
+ return cls(
624
+ modalities=regular_modalities,
625
+ hierarchical_modalities=hierarchical_modalities or None,
626
+ transforms=transforms,
627
+ )
628
+
406
629
  def __init__(
407
630
  self,
408
631
  modalities: dict[str, Modality],
@@ -498,10 +721,11 @@ class MultiModalDataset(_BaseDataset):
498
721
  modality_counts = {
499
722
  name: len(lookup) for name, lookup in self._lookups.items()
500
723
  }
501
- raise ValueError(
724
+ message = (
502
725
  f"No common IDs found across modalities. "
503
726
  f"Per-modality file counts: {modality_counts}"
504
727
  )
728
+ raise ValueError(message)
505
729
 
506
730
  for name, lookup in self._lookups.items():
507
731
  total = len(lookup)
@@ -521,7 +745,10 @@ class MultiModalDataset(_BaseDataset):
521
745
  self._zip_prefixes: dict[str, str] = {}
522
746
  self._zip_handles: dict[tuple[str, int], zipfile.ZipFile] = {}
523
747
 
524
- for name, modality in list(modalities.items()) + list(self._hierarchical_modalities.items()):
748
+ for name, modality in (
749
+ list(modalities.items())
750
+ + list(self._hierarchical_modalities.items())
751
+ ):
525
752
  if is_zip_path(modality.path):
526
753
  self._zip_modalities.add(name)
527
754
  self._zip_prefixes[name] = get_zip_root_prefix(
@@ -529,7 +756,13 @@ class MultiModalDataset(_BaseDataset):
529
756
  )
530
757
 
531
758
  # -- Caches ----------------------------------------------------------
532
- self._hierarchical_cache: dict[str, Any] = {}
759
+ # Per-modality caches; ``None`` disables caching for that modality.
760
+ # Hierarchical default: enabled (calibration-style small files).
761
+ self._caches: dict[str, dict[str, Any] | None] = {}
762
+ for name, modality in self._hierarchical_modalities.items():
763
+ self._caches[name] = {} if (
764
+ modality.cache if modality.cache is not None else True
765
+ ) else None
533
766
 
534
767
  # -- Optional transform binding -------------------------------------
535
768
  for transform in self._transforms:
@@ -699,18 +932,21 @@ class MultiModalDataset(_BaseDataset):
699
932
  written_paths: dict[str, str] = {}
700
933
 
701
934
  for modality_name, value in outputs.items():
702
- if modality_name not in self._lookups:
935
+ if modality_name in self._lookups:
936
+ record = self._lookups[modality_name][sample_id]
937
+ modality_meta = _get_index_meta(
938
+ self._index_outputs[modality_name]
939
+ )
940
+ else:
703
941
  raise KeyError(
704
- f"Modality {modality_name!r} is not a regular modality and cannot "
705
- "be addressed by sample index."
942
+ f"Modality {modality_name!r} is not a regular "
943
+ "modality and cannot be addressed by sample index."
706
944
  )
707
945
 
708
946
  writer = self.get_writer(modality_name)
709
947
  destination = _resolve_output_destination(
710
948
  output_root=output_root, modality_name=modality_name
711
949
  )
712
- record = self._lookups[modality_name][sample_id]
713
- modality_meta = _get_index_meta(self._index_outputs[modality_name])
714
950
  basename = Path(record.file_entry["path"]).name
715
951
  relative_path = record.file_entry["path"]
716
952
  full_id = _build_writer_full_id(
@@ -848,36 +1084,59 @@ class MultiModalDataset(_BaseDataset):
848
1084
  files_by_level = self._hierarchical_lookups[name]
849
1085
  matched = match_hierarchical_files(hierarchy_path, files_by_level)
850
1086
  modality_meta = _get_index_meta(self._hierarchical_index_outputs[name])
1087
+ cache = self._caches[name]
851
1088
  loaded: dict[str, Any] = {}
852
1089
  attrs_for_modality: dict[str, dict[str, Any]] = {}
1090
+ entries_by_id: dict[str, dict[str, Any]] = {}
853
1091
  for entry in matched:
854
1092
  entry_attributes = _get_file_attributes(entry)
855
- cache_key = f"{modality.path}/{entry['path']}"
856
1093
  accepts_attributes = self._loaders_accept_attributes[name]
1094
+ cache_key = f"{modality.path}/{entry['path']}"
857
1095
  if accepts_attributes:
858
1096
  cache_key = (
859
1097
  f"{cache_key}::attributes="
860
1098
  f"{_attributes_cache_key(entry_attributes)}"
861
1099
  )
862
- if cache_key not in self._hierarchical_cache:
1100
+ if cache is not None and cache_key in cache:
1101
+ value = cache[cache_key]
1102
+ else:
863
1103
  if name in self._zip_modalities:
864
1104
  file_or_path = self._open_from_zip(
865
1105
  name, modality.path, entry["path"],
866
1106
  )
867
1107
  else:
868
1108
  file_or_path = f"{modality.path}/{entry['path']}"
869
- self._hierarchical_cache[cache_key] = _load_with_optional_attributes(
1109
+ value = _load_with_optional_attributes(
870
1110
  self._resolved_loaders[name],
871
1111
  file_or_path,
872
1112
  modality_meta,
873
1113
  entry_attributes,
874
1114
  accepts_attributes=accepts_attributes,
875
1115
  )
876
- loaded[entry["id"]] = self._hierarchical_cache[cache_key]
1116
+ if cache is not None:
1117
+ cache[cache_key] = value
1118
+ loaded[entry["id"]] = value
1119
+ entries_by_id[entry["id"]] = entry
877
1120
  if entry_attributes:
878
1121
  attrs_for_modality[entry["id"]] = entry_attributes
879
- sample[name] = loaded
880
- attributes[name] = attrs_for_modality
1122
+ if modality.collapse_single:
1123
+ if len(loaded) > 1:
1124
+ raise ValueError(
1125
+ f"Hierarchical modality {name!r} was configured with "
1126
+ f"collapse_single=True, but sample {sample_id!r} "
1127
+ f"matched {len(loaded)} files."
1128
+ )
1129
+ if loaded:
1130
+ only_id, only_value = next(iter(loaded.items()))
1131
+ sample[name] = only_value
1132
+ meta[name] = entries_by_id[only_id]
1133
+ attributes[name] = attrs_for_modality.get(only_id, {})
1134
+ else:
1135
+ sample[name] = None
1136
+ attributes[name] = {}
1137
+ else:
1138
+ sample[name] = loaded
1139
+ attributes[name] = attrs_for_modality
881
1140
 
882
1141
  sample["id"] = file_id
883
1142
  sample["full_id"] = "/" + "/".join(hierarchy_path + (file_id,))
@@ -1,5 +1,5 @@
1
1
  {
2
- "name": "commit-ok",
2
+ "name": "euler-lo",
3
3
  "lockfileVersion": 3,
4
4
  "requires": true,
5
5
  "packages": {}
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "euler-loading"
3
- version = "2.6.0"
3
+ version = "2.8.0"
4
4
  description = "Multi-modal PyTorch dataloader using ds-crawler indices"
5
5
  requires-python = ">=3.9"
6
6
  dependencies = [
@@ -586,7 +586,7 @@ class TestHierarchicalModalities:
586
586
  assert s0 == s1
587
587
 
588
588
  def test_hierarchical_cached(self):
589
- """Shared hierarchical files are loaded only once."""
589
+ """Shared hierarchical files are loaded only once (default behavior)."""
590
590
  loader = MagicMock(return_value="parsed")
591
591
  rgb_index = _deep_regular_index(["f001", "f002", "f003"])
592
592
  hier_index = _hierarchical_intrinsics_index()
@@ -614,6 +614,36 @@ class TestHierarchicalModalities:
614
614
  # The intrinsics file should have been loaded exactly once.
615
615
  loader.assert_called_once()
616
616
 
617
+ def test_hierarchical_cache_false_reloads_every_access(self):
618
+ """Opt-out via cache=False reloads even shared hierarchical files."""
619
+ loader = MagicMock(return_value="parsed")
620
+ rgb_index = _deep_regular_index(["f001", "f002", "f003"])
621
+ hier_index = _hierarchical_intrinsics_index()
622
+
623
+ def mock_index(path, **kw):
624
+ return rgb_index if "rgb" in path else hier_index
625
+
626
+ with patch(
627
+ "euler_loading.dataset.index_dataset_from_path",
628
+ side_effect=mock_index,
629
+ ):
630
+ ds = MultiModalDataset(
631
+ modalities={
632
+ "rgb": Modality("/data/rgb", loader=dummy_loader),
633
+ },
634
+ hierarchical_modalities={
635
+ "cam_intrinsics": Modality(
636
+ "/data/intrinsics", loader=loader, cache=False,
637
+ ),
638
+ },
639
+ )
640
+
641
+ for i in range(3):
642
+ _ = ds[i]
643
+
644
+ # 3 accesses with caching disabled → 3 loads of the same file.
645
+ assert loader.call_count == 3
646
+
617
647
  def test_no_hierarchy_overlap_returns_empty_dict(self):
618
648
  """When hierarchical modality has no matching ancestors, result is {}."""
619
649
  rgb_index = _deep_regular_index(["f001"])
@@ -801,7 +831,7 @@ class TestAugmentedRgbWithHierarchicalDepth:
801
831
  # 2 file-ids x 2 augs per file-id
802
832
  assert len(ds) == 4
803
833
 
804
- def test_each_sample_has_depth_keyed_by_file_id(self):
834
+ def test_each_sample_has_depth_grouped_by_file_id(self):
805
835
  ds = self._make()
806
836
  for i in range(len(ds)):
807
837
  sample = ds[i]
@@ -888,6 +918,117 @@ class TestAugmentedRgbWithHierarchicalDepth:
888
918
  assert loader.call_count == 2
889
919
 
890
920
 
921
+ # ---------------------------------------------------------------------------
922
+ # Layout-aware augmentation planning
923
+ # ---------------------------------------------------------------------------
924
+
925
+
926
+ def _augmented_variant_index(*, separator: str = ":") -> dict[str, Any]:
927
+ """Augmented index with ``children[file_id:<id>]`` source-sample level."""
928
+ return {
929
+ "indexing": {
930
+ "hierarchy": {"separator": separator},
931
+ "id": {"join_char": "+"},
932
+ },
933
+ "dataset": {
934
+ "children": {
935
+ f"file_id{separator}abc": {
936
+ "files": [
937
+ _make_file("aug-aug_1", "abc/aug_1.png"),
938
+ _make_file("aug-aug_2", "abc/aug_2.png"),
939
+ ]
940
+ },
941
+ f"file_id{separator}xyz": {
942
+ "files": [
943
+ _make_file("aug-aug_1", "xyz/aug_1.png"),
944
+ _make_file("aug-aug_2", "xyz/aug_2.png"),
945
+ ]
946
+ },
947
+ }
948
+ },
949
+ }
950
+
951
+
952
+ def _with_layout(
953
+ index: dict[str, Any],
954
+ *,
955
+ variant: bool,
956
+ family: str = "demo",
957
+ ) -> dict[str, Any]:
958
+ result = json.loads(json.dumps(index))
959
+ layout: dict[str, Any] = {
960
+ "version": "1.0",
961
+ "family": family,
962
+ "sample_axis": {"name": "file_id", "location": "hierarchy"},
963
+ }
964
+ if variant:
965
+ layout["variant_axis"] = {
966
+ "name": "fog_augmentation",
967
+ "location": "file_id",
968
+ }
969
+ result["head"] = {
970
+ "contract": {"kind": "dataset_head", "version": "1.0"},
971
+ "dataset": {"id": "demo", "name": "Demo"},
972
+ "modality": {"key": "rgb" if variant else "depth", "meta": {}},
973
+ "addons": {"euler_layout": layout},
974
+ }
975
+ result.setdefault(
976
+ "indexing",
977
+ {"hierarchy": {"separator": ":"}, "id": {"join_char": "+"}},
978
+ )
979
+ return result
980
+
981
+
982
+ class TestLayoutAwareDatasetPlanning:
983
+ def test_from_layout_collapses_shared_hierarchical_modality(self):
984
+ rgb_index = _with_layout(_augmented_variant_index(), variant=True)
985
+ depth_index = _with_layout(
986
+ _per_id_depth_hierarchical_index(), variant=False,
987
+ )
988
+
989
+ def mock_index(path, **kw):
990
+ return rgb_index if "rgb" in path else depth_index
991
+
992
+ with patch(
993
+ "euler_loading.dataset.index_dataset_from_path",
994
+ side_effect=mock_index,
995
+ ):
996
+ ds = MultiModalDataset.from_layout(
997
+ {
998
+ "rgb_aug": Modality("/data/rgb_aug", loader=dummy_loader),
999
+ "depth": Modality("/data/depth", loader=dummy_loader),
1000
+ },
1001
+ primary="rgb_aug",
1002
+ )
1003
+
1004
+ sample = ds[0]
1005
+
1006
+ assert len(ds) == 4
1007
+ assert isinstance(sample["depth"], str)
1008
+ assert sample["depth"].startswith("loaded:/data/depth/")
1009
+ assert isinstance(sample["meta"]["depth"], dict)
1010
+
1011
+ def test_from_layout_rejects_flat_shared_modality(self):
1012
+ rgb_index = _with_layout(_augmented_variant_index(), variant=True)
1013
+ depth_index = _with_layout(_flat_index("depth", ["abc", "xyz"]), variant=False)
1014
+
1015
+ def mock_index(path, **kw):
1016
+ return rgb_index if "rgb" in path else depth_index
1017
+
1018
+ with patch(
1019
+ "euler_loading.dataset.index_dataset_from_path",
1020
+ side_effect=mock_index,
1021
+ ):
1022
+ with pytest.raises(ValueError, match="no matching hierarchy axis"):
1023
+ MultiModalDataset.from_layout(
1024
+ {
1025
+ "rgb_aug": Modality("/data/rgb_aug", loader=dummy_loader),
1026
+ "depth": Modality("/data/depth", loader=dummy_loader),
1027
+ },
1028
+ primary="rgb_aug",
1029
+ )
1030
+
1031
+
891
1032
  # ---------------------------------------------------------------------------
892
1033
  # Per-file ``attributes`` field exposed on samples
893
1034
  # ---------------------------------------------------------------------------
@@ -939,7 +1080,7 @@ class TestSampleAttributes:
939
1080
  assert "attributes" in sample
940
1081
  assert isinstance(sample["attributes"], dict)
941
1082
 
942
- def test_top_level_attributes_keyed_by_modality(self):
1083
+ def test_top_level_attributes_grouped_by_modality(self):
943
1084
  ds = self._make()
944
1085
  sample = ds[0]
945
1086
  assert sample["attributes"]["rgb"] == {"weight": 0.42, "src": "blender"}
@@ -1159,7 +1300,7 @@ class TestHierarchicalModalityAttributes:
1159
1300
  },
1160
1301
  )
1161
1302
 
1162
- def test_hierarchical_attributes_keyed_by_file_id(self):
1303
+ def test_hierarchical_attributes_grouped_by_file_id(self):
1163
1304
  ds = self._make()
1164
1305
  sample = ds[0]
1165
1306
  assert sample["attributes"]["cam_intrinsics"] == {
File without changes
File without changes