euler-loading 2.4.0__tar.gz → 2.6.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.4.0 → euler_loading-2.6.0}/PKG-INFO +2 -2
  2. euler_loading-2.6.0/docs/loader-attributes.md +183 -0
  3. {euler_loading-2.4.0 → euler_loading-2.6.0}/euler_loading/_resolution.py +27 -0
  4. {euler_loading-2.4.0 → euler_loading-2.6.0}/euler_loading/_writing.py +28 -4
  5. {euler_loading-2.4.0 → euler_loading-2.6.0}/euler_loading/dataset.py +78 -5
  6. {euler_loading-2.4.0 → euler_loading-2.6.0}/euler_loading/loaders/contracts.py +6 -6
  7. {euler_loading-2.4.0 → euler_loading-2.6.0}/euler_loading/loaders/cpu/generic.py +112 -6
  8. {euler_loading-2.4.0 → euler_loading-2.6.0}/euler_loading/loaders/cpu/generic_dense_depth.py +14 -7
  9. {euler_loading-2.4.0 → euler_loading-2.6.0}/euler_loading/loaders/cpu/real_drive_sim.py +4 -4
  10. {euler_loading-2.4.0 → euler_loading-2.6.0}/euler_loading/loaders/cpu/vkitti2.py +8 -8
  11. {euler_loading-2.4.0 → euler_loading-2.6.0}/euler_loading/loaders/generate/loaders.json +102 -0
  12. {euler_loading-2.4.0 → euler_loading-2.6.0}/euler_loading/loaders/gpu/generic.py +100 -6
  13. {euler_loading-2.4.0 → euler_loading-2.6.0}/euler_loading/loaders/gpu/generic_dense_depth.py +10 -5
  14. {euler_loading-2.4.0 → euler_loading-2.6.0}/euler_loading/loaders/gpu/real_drive_sim.py +7 -7
  15. {euler_loading-2.4.0 → euler_loading-2.6.0}/euler_loading/loaders/gpu/vkitti2.py +8 -8
  16. {euler_loading-2.4.0 → euler_loading-2.6.0}/package-lock.json +1 -1
  17. {euler_loading-2.4.0 → euler_loading-2.6.0}/pyproject.toml +3 -2
  18. {euler_loading-2.4.0 → euler_loading-2.6.0}/tests/test_dataset.py +551 -0
  19. {euler_loading-2.4.0 → euler_loading-2.6.0}/tests/test_loaders.py +291 -2
  20. {euler_loading-2.4.0 → euler_loading-2.6.0}/tests/test_writing.py +123 -0
  21. {euler_loading-2.4.0 → euler_loading-2.6.0}/.github/workflows/workflow.yml +0 -0
  22. {euler_loading-2.4.0 → euler_loading-2.6.0}/.gitignore +0 -0
  23. {euler_loading-2.4.0 → euler_loading-2.6.0}/README.md +0 -0
  24. {euler_loading-2.4.0 → euler_loading-2.6.0}/euler_loading/__init__.py +0 -0
  25. {euler_loading-2.4.0 → euler_loading-2.6.0}/euler_loading/_dataset_contract.py +0 -0
  26. {euler_loading-2.4.0 → euler_loading-2.6.0}/euler_loading/_ds_crawler_utils.py +0 -0
  27. {euler_loading-2.4.0 → euler_loading-2.6.0}/euler_loading/_metadata.py +0 -0
  28. {euler_loading-2.4.0 → euler_loading-2.6.0}/euler_loading/indexing.py +0 -0
  29. {euler_loading-2.4.0 → euler_loading-2.6.0}/euler_loading/loaders/__init__.py +0 -0
  30. {euler_loading-2.4.0 → euler_loading-2.6.0}/euler_loading/loaders/_annotations.py +0 -0
  31. {euler_loading-2.4.0 → euler_loading-2.6.0}/euler_loading/loaders/_writer_utils.py +0 -0
  32. {euler_loading-2.4.0 → euler_loading-2.6.0}/euler_loading/loaders/cpu/__init__.py +0 -0
  33. {euler_loading-2.4.0 → euler_loading-2.6.0}/euler_loading/loaders/generate/__init__.py +0 -0
  34. {euler_loading-2.4.0 → euler_loading-2.6.0}/euler_loading/loaders/generate/__main__.py +0 -0
  35. {euler_loading-2.4.0 → euler_loading-2.6.0}/euler_loading/loaders/generic.py +0 -0
  36. {euler_loading-2.4.0 → euler_loading-2.6.0}/euler_loading/loaders/gpu/__init__.py +0 -0
  37. {euler_loading-2.4.0 → euler_loading-2.6.0}/euler_loading/loaders/real_drive_sim.py +0 -0
  38. {euler_loading-2.4.0 → euler_loading-2.6.0}/euler_loading/loaders/vkitti2.py +0 -0
  39. {euler_loading-2.4.0 → euler_loading-2.6.0}/euler_loading/preprocessing.py +0 -0
  40. {euler_loading-2.4.0 → euler_loading-2.6.0}/example.py +0 -0
  41. {euler_loading-2.4.0 → euler_loading-2.6.0}/sample_rds.py +0 -0
  42. {euler_loading-2.4.0 → euler_loading-2.6.0}/tests/__init__.py +0 -0
  43. {euler_loading-2.4.0 → euler_loading-2.6.0}/tests/conftest.py +0 -0
  44. {euler_loading-2.4.0 → euler_loading-2.6.0}/tests/example_rds_calib.json +0 -0
  45. {euler_loading-2.4.0 → euler_loading-2.6.0}/tests/test_id_schema.py +0 -0
  46. {euler_loading-2.4.0 → euler_loading-2.6.0}/tests/test_indexing.py +0 -0
  47. {euler_loading-2.4.0 → euler_loading-2.6.0}/tests/test_preprocessing.py +0 -0
  48. {euler_loading-2.4.0 → euler_loading-2.6.0}/tests/test_real_dataset.py +0 -0
  49. {euler_loading-2.4.0 → euler_loading-2.6.0}/vkitti_cpu_example_output.json +0 -0
  50. {euler_loading-2.4.0 → euler_loading-2.6.0}/vkitti_gpu_example_output.json +0 -0
@@ -1,9 +1,9 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: euler-loading
3
- Version: 2.4.0
3
+ Version: 2.6.0
4
4
  Summary: Multi-modal PyTorch dataloader using ds-crawler indices
5
5
  Requires-Python: >=3.9
6
- Requires-Dist: ds-crawler
6
+ Requires-Dist: ds-crawler>=2.3.0
7
7
  Requires-Dist: euler-dataset-contract
8
8
  Requires-Dist: numpy
9
9
  Requires-Dist: pillow
@@ -0,0 +1,183 @@
1
+ # Design note: passing per-file `attributes` to loaders
2
+
3
+ Status: **implemented**
4
+
5
+ This is a follow-up to the per-file `attributes` field added in
6
+ ds-crawler (`file_entry["attributes"]`) and surfaced on samples in
7
+ euler-loading (`sample["attributes"][modality]` and
8
+ `sample["meta"][modality]["attributes"]`).
9
+
10
+ That change persists arbitrary per-file metadata through the index
11
+ schema and exposes it on the loaded sample. Loader callables can now opt
12
+ in to receiving the per-file dict by accepting an `attributes=` keyword.
13
+ Legacy loaders that accept only `(path, meta)` keep receiving the old
14
+ call shape.
15
+
16
+ ## When this matters
17
+
18
+ Cases where the loader genuinely needs `attributes` rather than the
19
+ consumer reading it post-hoc on the sample:
20
+
21
+ - Per-file noise level / scale that must be applied during decode
22
+ (e.g. depth scale-to-meters that varies per scan).
23
+ - Per-file masks / clip ranges for normalization.
24
+ - Per-file lookup keys that select an external resource (e.g. a
25
+ per-file calibration tag pointing into a calibration database).
26
+ - Anything where the value can't be reconstructed once the file is
27
+ decoded into a tensor.
28
+
29
+ If `attributes` is purely descriptive (training weight, source tag,
30
+ log fields), keep it on `sample["attributes"][...]` and don't change
31
+ loader signatures.
32
+
33
+ ## Loader signature
34
+
35
+ `euler_loading/loaders/contracts.py`:
36
+
37
+ ```python
38
+ def rgb(
39
+ self,
40
+ path: str | BinaryIO,
41
+ meta: dict[str, Any] | None = None,
42
+ *,
43
+ attributes: dict[str, Any] | None = None,
44
+ ) -> torch.Tensor: ...
45
+ ```
46
+
47
+ Implemented behavior:
48
+
49
+ - `dataset.py` probes each resolved loader once with `inspect.signature`
50
+ and caches whether it accepts `attributes` or `**kwargs`.
51
+ - Compatible loaders receive a copied per-file attributes dict (or
52
+ `None` when the file entry has no attributes).
53
+ - Legacy loaders that do not accept the keyword receive the original
54
+ `(path, meta)` call.
55
+ - Hierarchical-modality cache keys include the attributes payload for
56
+ opted-in loaders, so the same path with different attributes can be
57
+ decoded differently.
58
+ - Built-in CPU/GPU loaders accept `attributes=None`. Most ignore it;
59
+ `generic_dense_depth.depth` consumes
60
+ `attributes["scale_to_meters_override"]` when present.
61
+
62
+ ### Rejected alternative: merge into `meta`
63
+
64
+ Extend `meta` from "modality-level meta" to "merged meta" — keep the
65
+ original keys plus a reserved `meta["__attributes__"]` (or
66
+ `meta["entry"]`) sub-dict. No signature change.
67
+
68
+ Pros: no signature change, no probe.
69
+ Cons: namespace collision risk; loaders parsing `meta` in surprising
70
+ ways may misinterpret the new key. Hides the source of each field.
71
+ Harder to type. Implicit contract.
72
+
73
+ ### Rejected alternative: pass the full file entry
74
+
75
+ Replace `meta` with the file entry dict (carrying `attributes`,
76
+ `path_properties`, etc.). Strictly more information, but breaks every
77
+ existing loader.
78
+
79
+ Pros: maximum flexibility.
80
+ Cons: gratuitous churn, explicit migration of every loader.
81
+
82
+ ## Implementation notes
83
+
84
+ ### 1. Update contracts
85
+
86
+ ```python
87
+ # euler_loading/loaders/contracts.py
88
+ class DenseDepthLoader(Protocol):
89
+ def rgb(
90
+ self,
91
+ path: Union[str, BinaryIO],
92
+ meta: dict[str, Any] | None = None,
93
+ *,
94
+ attributes: dict[str, Any] | None = None,
95
+ ) -> torch.Tensor: ...
96
+ # ... and parallel updates to depth / sky_mask / read_intrinsics ...
97
+ ```
98
+
99
+ ### 2. Loader feature probe
100
+
101
+ The utility that identifies whether each loader accepts the
102
+ `attributes` kwarg:
103
+
104
+ ```python
105
+ # euler_loading/_resolution.py
106
+ import inspect
107
+
108
+ def loader_accepts_attributes(loader: Callable[..., Any]) -> bool:
109
+ try:
110
+ sig = inspect.signature(loader)
111
+ except (TypeError, ValueError):
112
+ return False
113
+ params = sig.parameters
114
+ if "attributes" in params:
115
+ return True
116
+ return any(
117
+ p.kind is inspect.Parameter.VAR_KEYWORD for p in params.values()
118
+ )
119
+ ```
120
+
121
+ Cache the result on the resolved-loaders dict at construction time so
122
+ the probe runs once per modality.
123
+
124
+ ### 3. Update call sites
125
+
126
+ In `MultiModalDataset.__getitem__`:
127
+
128
+ ```python
129
+ loader = self._resolved_loaders[name]
130
+ file_attrs = _get_file_attributes(record.file_entry)
131
+ if self._loaders_accept_attributes[name]:
132
+ sample[name] = loader(file_or_path, modality_meta, attributes=file_attrs)
133
+ else:
134
+ sample[name] = loader(file_or_path, modality_meta)
135
+ ```
136
+
137
+ The hierarchical-modality branch follows the same rule. For opted-in
138
+ loaders, the hierarchical cache key includes a serialized attributes
139
+ fragment so identical paths with different per-file attributes are
140
+ loaded independently.
141
+
142
+ ### 4. Update built-in loaders
143
+
144
+ For each module under `euler_loading/loaders/{cpu,gpu}/`, every `read_*`
145
+ and modality function accepts `*, attributes: ... = None`. Most ignore
146
+ it. `generic_dense_depth.depth` consumes
147
+ `attributes.get("scale_to_meters_override")`.
148
+
149
+ ### 5. Tests
150
+
151
+ Mirror the existing `tests/test_writing.py` and
152
+ `tests/test_dataset.py` structure. Minimum coverage:
153
+
154
+ - A loader that accepts `attributes=` receives the per-file dict.
155
+ - A loader that doesn't is called with the legacy 2-arg form (no
156
+ `TypeError`).
157
+ - A loader with `**kwargs` is treated as accepting attributes.
158
+ - Hierarchical-modality cache: same hierarchical file with two different
159
+ attributes loads twice.
160
+ - Round-trip: file entry attributes → loader receives them → if the
161
+ loader passes them through, they appear on `sample["attributes"]`
162
+ (already the case post the existing change, but pin it).
163
+
164
+ ## Backwards compatibility
165
+
166
+ - Stability of the existing 2-arg signature is preserved by feature
167
+ probing. Loaders that never opt in keep working forever.
168
+ - The `attributes` argument is keyword-only — no position collision.
169
+ - `sample["attributes"]` and `sample["meta"][name]["attributes"]`
170
+ surfaces are unchanged by this work; they already exist.
171
+
172
+ ## Open questions for the implementer
173
+
174
+ 1. Should the feature probe also be done for *writers*? Today writers
175
+ take `(path, value, meta)`. If we want symmetry, writers gain
176
+ `attributes=` too. Same Option 1 treatment.
177
+ 2. Should `loader_accepts_attributes` look at the function's
178
+ `__wrapped__` chain to handle `functools.wraps`-decorated loaders?
179
+ (Probably yes, but only if a real case appears.)
180
+ 3. Worth adding a `FileContext` dataclass — `(meta, attributes,
181
+ file_entry)` — to consolidate future fields rather than growing kwargs
182
+ one at a time? Defer this until we have a second per-file thing to
183
+ add; one new field doesn't justify a wrapper type.
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import importlib
4
+ import inspect
4
5
  import logging
5
6
  from collections.abc import Mapping
6
7
  from types import ModuleType
@@ -68,6 +69,32 @@ def resolve_writer_module(name: str) -> ModuleType:
68
69
  return resolve_loader_module(name)
69
70
 
70
71
 
72
+ def loader_accepts_attributes(loader: Callable[..., Any]) -> bool:
73
+ """Return whether *loader* can accept an ``attributes=`` keyword.
74
+
75
+ Loaders historically accepted ``(path, meta=None)``. Per-file
76
+ attributes are opt-in so legacy loaders keep receiving the old call
77
+ shape, while loaders declaring ``attributes`` or ``**kwargs`` receive
78
+ the new context.
79
+ """
80
+ try:
81
+ signature = inspect.signature(loader)
82
+ except (TypeError, ValueError):
83
+ return False
84
+
85
+ attributes_param = signature.parameters.get("attributes")
86
+ if (
87
+ attributes_param is not None
88
+ and attributes_param.kind is not inspect.Parameter.POSITIONAL_ONLY
89
+ ):
90
+ return True
91
+
92
+ return any(
93
+ param.kind is inspect.Parameter.VAR_KEYWORD
94
+ for param in signature.parameters.values()
95
+ )
96
+
97
+
71
98
  def _resolve_loader(
72
99
  *,
73
100
  modality_name: str,
@@ -244,15 +244,29 @@ def _write_value_to_destination(
244
244
  full_id: str,
245
245
  basename: str,
246
246
  relative_path: str,
247
- source_meta: Mapping[str, Any] | None,
247
+ source_entry: Mapping[str, Any] | None,
248
+ entry_attributes: Mapping[str, Any] | None = None,
248
249
  create_dirs: bool,
249
250
  ) -> str:
251
+ """Write *value* to the destination and (when applicable) record a
252
+ ds-crawler file entry.
253
+
254
+ ``meta`` is the modality-level metadata passed to the loader/writer
255
+ callable. ``entry_attributes`` is the per-file ``attributes`` dict
256
+ written onto the ds-crawler file entry; it is a no-op for plain
257
+ filesystem destinations (no index to record into).
258
+ """
259
+ entry_attributes_dict = (
260
+ dict(entry_attributes) if entry_attributes is not None else None
261
+ )
262
+
250
263
  if isinstance(destination, ZipDatasetWriter):
251
264
  if supports_stream_target(writer):
252
265
  with destination.open(
253
266
  full_id,
254
267
  basename,
255
- source_meta=dict(source_meta or {}),
268
+ source_entry=dict(source_entry or {}),
269
+ attributes=entry_attributes_dict,
256
270
  ) as stream:
257
271
  _set_stream_name(stream, basename)
258
272
  writer(stream, value, meta)
@@ -264,7 +278,8 @@ def _write_value_to_destination(
264
278
  full_id,
265
279
  basename,
266
280
  temp_path.read_bytes(),
267
- source_meta=dict(source_meta or {}),
281
+ source_entry=dict(source_entry or {}),
282
+ attributes=entry_attributes_dict,
268
283
  )
269
284
  _register_destination_rel_path(destination, relative_path)
270
285
  return _destination_location(destination, relative_path)
@@ -273,12 +288,21 @@ def _write_value_to_destination(
273
288
  target_path = destination.get_path(
274
289
  full_id,
275
290
  basename,
276
- source_meta=dict(source_meta or {}),
291
+ source_entry=dict(source_entry or {}),
292
+ attributes=entry_attributes_dict,
277
293
  )
278
294
  writer(str(target_path), value, meta)
279
295
  _register_destination_rel_path(destination, relative_path)
280
296
  return str(target_path)
281
297
 
298
+ if entry_attributes_dict:
299
+ logger.debug(
300
+ "Filesystem destination %s has no index — entry attributes "
301
+ "for %s will not be persisted.",
302
+ destination,
303
+ relative_path,
304
+ )
305
+
282
306
  target_path = Path(destination) / relative_path
283
307
  if create_dirs:
284
308
  target_path.parent.mkdir(parents=True, exist_ok=True)
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import io
4
+ import json
4
5
  import logging
5
6
  import os
6
7
  from pathlib import Path
@@ -29,6 +30,7 @@ from ._metadata import _build_runlog_entry, _get_ds_crawler_descriptor
29
30
  from ._resolution import (
30
31
  _resolve_loader,
31
32
  _resolve_writer,
33
+ loader_accepts_attributes,
32
34
  resolve_loader_module,
33
35
  resolve_writer_module,
34
36
  )
@@ -118,6 +120,33 @@ def _get_index_meta(index_output: Mapping[str, Any]) -> dict[str, Any] | None:
118
120
  return None
119
121
 
120
122
 
123
+ def _get_file_attributes(file_entry: Mapping[str, Any]) -> dict[str, Any] | None:
124
+ attributes = file_entry.get("attributes")
125
+ if isinstance(attributes, Mapping):
126
+ return dict(attributes)
127
+ return None
128
+
129
+
130
+ def _attributes_cache_key(attributes: Mapping[str, Any] | None) -> str:
131
+ if attributes is None:
132
+ return "null"
133
+ return json.dumps(attributes, sort_keys=True, separators=(",", ":"), default=repr)
134
+
135
+
136
+ def _load_with_optional_attributes(
137
+ loader: Callable[..., Any],
138
+ file_or_path: Any,
139
+ meta: dict[str, Any] | None,
140
+ attributes: dict[str, Any] | None,
141
+ *,
142
+ accepts_attributes: bool,
143
+ ) -> Any:
144
+ if accepts_attributes:
145
+ loader_attributes = dict(attributes) if attributes is not None else None
146
+ return loader(file_or_path, meta, attributes=loader_attributes)
147
+ return loader(file_or_path, meta)
148
+
149
+
121
150
  def _extract_id_schema(index_output: Mapping[str, Any]) -> dict[str, Any]:
122
151
  """Pull id/hierarchy separators from a ds-crawler output payload.
123
152
 
@@ -394,6 +423,7 @@ class MultiModalDataset(_BaseDataset):
394
423
  # -- Index each modality and build ID → FileRecord lookups -----------
395
424
  self._lookups: dict[str, dict[str, FileRecord]] = {}
396
425
  self._resolved_loaders: dict[str, Callable[..., Any]] = {}
426
+ self._loaders_accept_attributes: dict[str, bool] = {}
397
427
  self._resolved_writers: dict[str, Callable[..., Any] | None] = {}
398
428
 
399
429
  for name, modality in modalities.items():
@@ -406,6 +436,9 @@ class MultiModalDataset(_BaseDataset):
406
436
  self._resolved_loaders[name] = _resolve_loader(
407
437
  modality_name=name, modality=modality, index=index,
408
438
  )
439
+ self._loaders_accept_attributes[name] = loader_accepts_attributes(
440
+ self._resolved_loaders[name]
441
+ )
409
442
  self._resolved_writers[name] = _resolve_writer(
410
443
  modality_name=name, modality=modality, index=index,
411
444
  )
@@ -438,6 +471,9 @@ class MultiModalDataset(_BaseDataset):
438
471
  self._resolved_loaders[name] = _resolve_loader(
439
472
  modality_name=name, modality=modality, index=index,
440
473
  )
474
+ self._loaders_accept_attributes[name] = loader_accepts_attributes(
475
+ self._resolved_loaders[name]
476
+ )
441
477
  self._resolved_writers[name] = _resolve_writer(
442
478
  modality_name=name, modality=modality, index=index,
443
479
  )
@@ -623,6 +659,7 @@ class MultiModalDataset(_BaseDataset):
623
659
  *,
624
660
  create_dirs: bool = True,
625
661
  overwrite: bool = True,
662
+ attributes: Mapping[str, Mapping[str, Any]] | None = None,
626
663
  ) -> dict[str, str]:
627
664
  """Write model outputs for one sample to disk or a dataset writer.
628
665
 
@@ -640,6 +677,13 @@ class MultiModalDataset(_BaseDataset):
640
677
  filesystem destinations.
641
678
  overwrite: If false, raise when a target file already exists or a
642
679
  duplicate writer entry would be created.
680
+ attributes: Optional per-modality ``{modality: {key: value}}``
681
+ mapping recorded on the destination's ds-crawler file
682
+ entry under the ``"attributes"`` field. Only applies when
683
+ the destination is a :class:`DatasetWriter` /
684
+ :class:`ZipDatasetWriter`; ignored for plain filesystem
685
+ paths. When omitted, any ``attributes`` carried on the
686
+ source file entry is inherited.
643
687
 
644
688
  Returns:
645
689
  Mapping ``{modality_name: written_location}``. For filesystem
@@ -677,6 +721,9 @@ class MultiModalDataset(_BaseDataset):
677
721
  if _destination_rel_exists(destination, relative_path) and not overwrite:
678
722
  raise FileExistsError(_destination_location(destination, relative_path))
679
723
 
724
+ per_modality_attrs = (
725
+ attributes.get(modality_name) if attributes is not None else None
726
+ )
680
727
  written_paths[modality_name] = _write_value_to_destination(
681
728
  destination=destination,
682
729
  writer=writer,
@@ -685,7 +732,8 @@ class MultiModalDataset(_BaseDataset):
685
732
  full_id=full_id,
686
733
  basename=basename,
687
734
  relative_path=relative_path,
688
- source_meta=record.file_entry,
735
+ source_entry=record.file_entry,
736
+ entry_attributes=per_modality_attrs,
689
737
  create_dirs=create_dirs,
690
738
  )
691
739
 
@@ -764,6 +812,7 @@ class MultiModalDataset(_BaseDataset):
764
812
  sample: dict[str, Any] = {}
765
813
 
766
814
  meta: dict[str, dict[str, Any]] = {}
815
+ attributes: dict[str, dict[str, Any]] = {}
767
816
  file_id: str = ""
768
817
  hierarchy_path: tuple[str, ...] = ()
769
818
 
@@ -778,8 +827,16 @@ class MultiModalDataset(_BaseDataset):
778
827
  else:
779
828
  file_or_path = f"{modality.path}/{record.file_entry['path']}"
780
829
 
781
- sample[name] = self._resolved_loaders[name](file_or_path, modality_meta)
830
+ entry_attributes = _get_file_attributes(record.file_entry)
831
+ sample[name] = _load_with_optional_attributes(
832
+ self._resolved_loaders[name],
833
+ file_or_path,
834
+ modality_meta,
835
+ entry_attributes,
836
+ accepts_attributes=self._loaders_accept_attributes[name],
837
+ )
782
838
  meta[name] = record.file_entry
839
+ attributes[name] = entry_attributes or {}
783
840
 
784
841
  # Hierarchy path from the first modality that has one.
785
842
  if not hierarchy_path:
@@ -792,24 +849,40 @@ class MultiModalDataset(_BaseDataset):
792
849
  matched = match_hierarchical_files(hierarchy_path, files_by_level)
793
850
  modality_meta = _get_index_meta(self._hierarchical_index_outputs[name])
794
851
  loaded: dict[str, Any] = {}
852
+ attrs_for_modality: dict[str, dict[str, Any]] = {}
795
853
  for entry in matched:
854
+ entry_attributes = _get_file_attributes(entry)
796
855
  cache_key = f"{modality.path}/{entry['path']}"
856
+ accepts_attributes = self._loaders_accept_attributes[name]
857
+ if accepts_attributes:
858
+ cache_key = (
859
+ f"{cache_key}::attributes="
860
+ f"{_attributes_cache_key(entry_attributes)}"
861
+ )
797
862
  if cache_key not in self._hierarchical_cache:
798
863
  if name in self._zip_modalities:
799
864
  file_or_path = self._open_from_zip(
800
865
  name, modality.path, entry["path"],
801
866
  )
802
867
  else:
803
- file_or_path = cache_key
804
- self._hierarchical_cache[cache_key] = self._resolved_loaders[name](
805
- file_or_path, modality_meta
868
+ file_or_path = f"{modality.path}/{entry['path']}"
869
+ self._hierarchical_cache[cache_key] = _load_with_optional_attributes(
870
+ self._resolved_loaders[name],
871
+ file_or_path,
872
+ modality_meta,
873
+ entry_attributes,
874
+ accepts_attributes=accepts_attributes,
806
875
  )
807
876
  loaded[entry["id"]] = self._hierarchical_cache[cache_key]
877
+ if entry_attributes:
878
+ attrs_for_modality[entry["id"]] = entry_attributes
808
879
  sample[name] = loaded
880
+ attributes[name] = attrs_for_modality
809
881
 
810
882
  sample["id"] = file_id
811
883
  sample["full_id"] = "/" + "/".join(hierarchy_path + (file_id,))
812
884
  sample["meta"] = meta
885
+ sample["attributes"] = attributes
813
886
 
814
887
  for transform in self._transforms:
815
888
  sample = transform(sample)
@@ -23,8 +23,8 @@ class DenseDepthLoader(Protocol):
23
23
  """Contract for dense-depth dataset loaders.
24
24
 
25
25
  A conforming module must expose the following callables, each accepting
26
- a file path and an optional ``meta`` dict, and returning a
27
- ``torch.Tensor``:
26
+ a file path, an optional modality-level ``meta`` dict, and optional
27
+ per-file ``attributes`` keyword, and returning a ``torch.Tensor``:
28
28
 
29
29
  - **rgb** – ``(3, H, W)`` float32 in ``[0, 1]``
30
30
  - **depth** – ``(1, H, W)`` float32 in metres
@@ -32,10 +32,10 @@ class DenseDepthLoader(Protocol):
32
32
  - **read_intrinsics** – ``(3, 3)`` float32 camera matrix *K*
33
33
  """
34
34
 
35
- def rgb(self, path: Union[str, BinaryIO], meta: dict[str, Any] | None = None) -> torch.Tensor: ...
36
- def depth(self, path: Union[str, BinaryIO], meta: dict[str, Any] | None = None) -> torch.Tensor: ...
37
- def sky_mask(self, path: Union[str, BinaryIO], meta: dict[str, Any] | None = None) -> torch.Tensor: ...
38
- def read_intrinsics(self, path: Union[str, BinaryIO], meta: dict[str, Any] | None = None) -> torch.Tensor: ...
35
+ def rgb(self, path: Union[str, BinaryIO], meta: dict[str, Any] | None = None, *, attributes: dict[str, Any] | None = None) -> torch.Tensor: ...
36
+ def depth(self, path: Union[str, BinaryIO], meta: dict[str, Any] | None = None, *, attributes: dict[str, Any] | None = None) -> torch.Tensor: ...
37
+ def sky_mask(self, path: Union[str, BinaryIO], meta: dict[str, Any] | None = None, *, attributes: dict[str, Any] | None = None) -> torch.Tensor: ...
38
+ def read_intrinsics(self, path: Union[str, BinaryIO], meta: dict[str, Any] | None = None, *, attributes: dict[str, Any] | None = None) -> torch.Tensor: ...
39
39
 
40
40
 
41
41
  @runtime_checkable
@@ -10,6 +10,8 @@ Supported file extensions:
10
10
 
11
11
  Return types
12
12
  ------------
13
+ - **map_2d** / **scattering_coefficient** -- ``np.ndarray`` of shape ``(H, W)`` float32.
14
+ - **map_3d** / **atmospheric_light** -- ``np.ndarray`` of shape ``(H, W, C)`` float32.
13
15
  - **spherical_map** -- ``np.ndarray`` of shape ``(H, W, C)`` float32.
14
16
  - **intrinsics** -- ``np.ndarray`` of shape ``(3, 3)`` float32.
15
17
  - **sh_coeffs** -- ``np.ndarray`` of shape ``(N, 3)`` float32.
@@ -19,9 +21,11 @@ Usage::
19
21
  from euler_loading.loaders.cpu import generic
20
22
  from euler_loading import Modality
21
23
 
22
- Modality("/data/my_dataset/spherical_map", loader=generic.spherical_map)
23
- Modality("/data/my_dataset/intrinsics", loader=generic.intrinsics)
24
- Modality("/data/my_dataset/sh_coeffs", loader=generic.sh_coeffs)
24
+ Modality("/data/my_dataset/scattering_coefficient", loader=generic.scattering_coefficient)
25
+ Modality("/data/my_dataset/atmospheric_light", loader=generic.atmospheric_light)
26
+ Modality("/data/my_dataset/spherical_map", loader=generic.spherical_map)
27
+ Modality("/data/my_dataset/intrinsics", loader=generic.intrinsics)
28
+ Modality("/data/my_dataset/sh_coeffs", loader=generic.sh_coeffs)
25
29
  """
26
30
 
27
31
  from __future__ import annotations
@@ -36,6 +40,7 @@ from euler_loading.loaders._writer_utils import (
36
40
  ensure_parent,
37
41
  get_target_name,
38
42
  mark_stream_supported,
43
+ to_hw,
39
44
  to_numpy,
40
45
  )
41
46
 
@@ -85,13 +90,69 @@ def _write_numpy(path: Union[str, BinaryIO], value: Any) -> None:
85
90
  # ---------------------------------------------------------------------------
86
91
 
87
92
 
93
+ @modality_meta(
94
+ modality_type="map_2d",
95
+ dtype="float32",
96
+ shape="HW",
97
+ file_formats=[".npy", ".npz"],
98
+ )
99
+ def map_2d(path: Union[str, BinaryIO], meta: dict[str, Any] | None = None, *, attributes: dict[str, Any] | None = None) -> np.ndarray:
100
+ """Load an arbitrary 2D map as an ``(H, W)`` float32 array.
101
+
102
+ Suitable for any single-channel dense quantity (e.g. scattering
103
+ coefficient, attenuation, opacity) where no dataset-specific decoding
104
+ is required.
105
+ """
106
+ return _load_numpy(path)
107
+
108
+
109
+ @modality_meta(
110
+ modality_type="map_3d",
111
+ dtype="float32",
112
+ shape="HWC",
113
+ file_formats=[".npy", ".npz"],
114
+ )
115
+ def map_3d(path: Union[str, BinaryIO], meta: dict[str, Any] | None = None, *, attributes: dict[str, Any] | None = None) -> np.ndarray:
116
+ """Load an arbitrary 3D map as an ``(H, W, C)`` float32 array.
117
+
118
+ The file is expected to be stored in ``(C, H, W)`` layout and is
119
+ transposed to ``(H, W, C)`` for CPU-oriented processing. Suitable
120
+ for any per-pixel vector quantity (e.g. atmospheric light, surface
121
+ normals, flow).
122
+ """
123
+ arr = _load_numpy(path)
124
+ return np.transpose(arr, (1, 2, 0))
125
+
126
+
127
+ @modality_meta(
128
+ modality_type="scattering_coefficient",
129
+ dtype="float32",
130
+ shape="HW",
131
+ file_formats=[".npy", ".npz"],
132
+ )
133
+ def scattering_coefficient(path: Union[str, BinaryIO], meta: dict[str, Any] | None = None, *, attributes: dict[str, Any] | None = None) -> np.ndarray:
134
+ """Load a scattering-coefficient map as an ``(H, W)`` float32 array."""
135
+ return map_2d(path, meta)
136
+
137
+
138
+ @modality_meta(
139
+ modality_type="atmospheric_light",
140
+ dtype="float32",
141
+ shape="HWC",
142
+ file_formats=[".npy", ".npz"],
143
+ )
144
+ def atmospheric_light(path: Union[str, BinaryIO], meta: dict[str, Any] | None = None, *, attributes: dict[str, Any] | None = None) -> np.ndarray:
145
+ """Load an atmospheric-light map as an ``(H, W, C)`` float32 array."""
146
+ return map_3d(path, meta)
147
+
148
+
88
149
  @modality_meta(
89
150
  modality_type="spherical_map",
90
151
  dtype="float32",
91
152
  shape="HWC",
92
153
  file_formats=[".npy", ".npz"],
93
154
  )
94
- def spherical_map(path: Union[str, BinaryIO], meta: dict[str, Any] | None = None) -> np.ndarray:
155
+ def spherical_map(path: Union[str, BinaryIO], meta: dict[str, Any] | None = None, *, attributes: dict[str, Any] | None = None) -> np.ndarray:
95
156
  """Load a spherical map as an ``(H, W, C)`` float32 array.
96
157
 
97
158
  The file is expected to be stored in ``(C, H, W)`` layout and is
@@ -112,7 +173,7 @@ def spherical_map(path: Union[str, BinaryIO], meta: dict[str, Any] | None = None
112
173
  shape="3x3",
113
174
  file_formats=[".npy", ".npz"],
114
175
  )
115
- def intrinsics(path: Union[str, BinaryIO], meta: dict[str, Any] | None = None) -> np.ndarray:
176
+ def intrinsics(path: Union[str, BinaryIO], meta: dict[str, Any] | None = None, *, attributes: dict[str, Any] | None = None) -> np.ndarray:
116
177
  """Load a camera intrinsics matrix as a ``(3, 3)`` float32 array.
117
178
 
118
179
  The file is expected to contain a ``(3, 3)`` array::
@@ -130,7 +191,7 @@ def intrinsics(path: Union[str, BinaryIO], meta: dict[str, Any] | None = None) -
130
191
  shape="NC",
131
192
  file_formats=[".npy", ".npz"],
132
193
  )
133
- def sh_coeffs(path: Union[str, BinaryIO], meta: dict[str, Any] | None = None) -> np.ndarray:
194
+ def sh_coeffs(path: Union[str, BinaryIO], meta: dict[str, Any] | None = None, *, attributes: dict[str, Any] | None = None) -> np.ndarray:
134
195
  """Load spherical-harmonic coefficients as an ``(N, 3)`` float32 array.
135
196
 
136
197
  *N* is the number of SH basis functions (e.g. 15 for degree-3 SH with
@@ -145,6 +206,51 @@ def sh_coeffs(path: Union[str, BinaryIO], meta: dict[str, Any] | None = None) ->
145
206
  # ---------------------------------------------------------------------------
146
207
 
147
208
 
209
+ @mark_stream_supported
210
+ def write_map_2d(path: Union[str, BinaryIO], value: Any, meta: dict[str, Any] | None = None) -> None:
211
+ """Write a 2D map to NumPy formats based on extension.
212
+
213
+ Accepts ``(H, W)``, ``(1, H, W)``, or ``(H, W, 1)`` input and stores
214
+ the array in ``(H, W)`` layout.
215
+ """
216
+ arr = to_hw(value, name="map_2d")
217
+ _write_numpy(path, arr)
218
+
219
+
220
+ @mark_stream_supported
221
+ def write_map_3d(path: Union[str, BinaryIO], value: Any, meta: dict[str, Any] | None = None) -> None:
222
+ """Write a 3D map to NumPy formats based on extension.
223
+
224
+ Input is expected in ``(H, W, C)`` layout and is transposed to
225
+ ``(C, H, W)`` for storage.
226
+ """
227
+ ensure_parent(path)
228
+ ext = os.path.splitext(_get_name(path))[1].lower()
229
+ arr = to_numpy(value).astype(np.float32)
230
+ arr = np.transpose(arr, (2, 0, 1))
231
+
232
+ if ext == _NPY_EXTENSION:
233
+ np.save(path, arr)
234
+ return
235
+ if ext == _NPZ_EXTENSION:
236
+ np.savez_compressed(path, data=arr)
237
+ return
238
+
239
+ raise ValueError(f"Unsupported map_3d output extension: {ext}")
240
+
241
+
242
+ @mark_stream_supported
243
+ def write_scattering_coefficient(path: Union[str, BinaryIO], value: Any, meta: dict[str, Any] | None = None) -> None:
244
+ """Write a scattering-coefficient map (delegates to :func:`write_map_2d`)."""
245
+ write_map_2d(path, value, meta)
246
+
247
+
248
+ @mark_stream_supported
249
+ def write_atmospheric_light(path: Union[str, BinaryIO], value: Any, meta: dict[str, Any] | None = None) -> None:
250
+ """Write an atmospheric-light map (delegates to :func:`write_map_3d`)."""
251
+ write_map_3d(path, value, meta)
252
+
253
+
148
254
  @mark_stream_supported
149
255
  def write_spherical_map(path: Union[str, BinaryIO], value: Any, meta: dict[str, Any] | None = None) -> None:
150
256
  """Write spherical-map data to NumPy formats based on extension.