euler-loading 2.5.0__tar.gz → 2.7.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.5.0 → euler_loading-2.7.0}/PKG-INFO +2 -2
  2. {euler_loading-2.5.0 → euler_loading-2.7.0}/README.md +118 -7
  3. euler_loading-2.7.0/docs/loader-attributes.md +183 -0
  4. {euler_loading-2.5.0 → euler_loading-2.7.0}/euler_loading/_resolution.py +27 -0
  5. {euler_loading-2.5.0 → euler_loading-2.7.0}/euler_loading/_writing.py +28 -4
  6. {euler_loading-2.5.0 → euler_loading-2.7.0}/euler_loading/dataset.py +566 -15
  7. {euler_loading-2.5.0 → euler_loading-2.7.0}/euler_loading/loaders/contracts.py +6 -6
  8. {euler_loading-2.5.0 → euler_loading-2.7.0}/euler_loading/loaders/cpu/generic.py +46 -12
  9. {euler_loading-2.5.0 → euler_loading-2.7.0}/euler_loading/loaders/cpu/generic_dense_depth.py +14 -7
  10. {euler_loading-2.5.0 → euler_loading-2.7.0}/euler_loading/loaders/cpu/real_drive_sim.py +4 -4
  11. {euler_loading-2.5.0 → euler_loading-2.7.0}/euler_loading/loaders/cpu/vkitti2.py +8 -8
  12. {euler_loading-2.5.0 → euler_loading-2.7.0}/euler_loading/loaders/generate/loaders.json +34 -0
  13. {euler_loading-2.5.0 → euler_loading-2.7.0}/euler_loading/loaders/gpu/generic.py +46 -12
  14. {euler_loading-2.5.0 → euler_loading-2.7.0}/euler_loading/loaders/gpu/generic_dense_depth.py +10 -5
  15. {euler_loading-2.5.0 → euler_loading-2.7.0}/euler_loading/loaders/gpu/real_drive_sim.py +7 -7
  16. {euler_loading-2.5.0 → euler_loading-2.7.0}/euler_loading/loaders/gpu/vkitti2.py +8 -8
  17. {euler_loading-2.5.0 → euler_loading-2.7.0}/package-lock.json +1 -1
  18. {euler_loading-2.5.0 → euler_loading-2.7.0}/pyproject.toml +3 -2
  19. {euler_loading-2.5.0 → euler_loading-2.7.0}/tests/test_dataset.py +1113 -1
  20. {euler_loading-2.5.0 → euler_loading-2.7.0}/tests/test_loaders.py +107 -2
  21. {euler_loading-2.5.0 → euler_loading-2.7.0}/tests/test_writing.py +123 -0
  22. {euler_loading-2.5.0 → euler_loading-2.7.0}/.github/workflows/workflow.yml +0 -0
  23. {euler_loading-2.5.0 → euler_loading-2.7.0}/.gitignore +0 -0
  24. {euler_loading-2.5.0 → euler_loading-2.7.0}/euler_loading/__init__.py +0 -0
  25. {euler_loading-2.5.0 → euler_loading-2.7.0}/euler_loading/_dataset_contract.py +0 -0
  26. {euler_loading-2.5.0 → euler_loading-2.7.0}/euler_loading/_ds_crawler_utils.py +0 -0
  27. {euler_loading-2.5.0 → euler_loading-2.7.0}/euler_loading/_metadata.py +0 -0
  28. {euler_loading-2.5.0 → euler_loading-2.7.0}/euler_loading/indexing.py +0 -0
  29. {euler_loading-2.5.0 → euler_loading-2.7.0}/euler_loading/loaders/__init__.py +0 -0
  30. {euler_loading-2.5.0 → euler_loading-2.7.0}/euler_loading/loaders/_annotations.py +0 -0
  31. {euler_loading-2.5.0 → euler_loading-2.7.0}/euler_loading/loaders/_writer_utils.py +0 -0
  32. {euler_loading-2.5.0 → euler_loading-2.7.0}/euler_loading/loaders/cpu/__init__.py +0 -0
  33. {euler_loading-2.5.0 → euler_loading-2.7.0}/euler_loading/loaders/generate/__init__.py +0 -0
  34. {euler_loading-2.5.0 → euler_loading-2.7.0}/euler_loading/loaders/generate/__main__.py +0 -0
  35. {euler_loading-2.5.0 → euler_loading-2.7.0}/euler_loading/loaders/generic.py +0 -0
  36. {euler_loading-2.5.0 → euler_loading-2.7.0}/euler_loading/loaders/gpu/__init__.py +0 -0
  37. {euler_loading-2.5.0 → euler_loading-2.7.0}/euler_loading/loaders/real_drive_sim.py +0 -0
  38. {euler_loading-2.5.0 → euler_loading-2.7.0}/euler_loading/loaders/vkitti2.py +0 -0
  39. {euler_loading-2.5.0 → euler_loading-2.7.0}/euler_loading/preprocessing.py +0 -0
  40. {euler_loading-2.5.0 → euler_loading-2.7.0}/example.py +0 -0
  41. {euler_loading-2.5.0 → euler_loading-2.7.0}/sample_rds.py +0 -0
  42. {euler_loading-2.5.0 → euler_loading-2.7.0}/tests/__init__.py +0 -0
  43. {euler_loading-2.5.0 → euler_loading-2.7.0}/tests/conftest.py +0 -0
  44. {euler_loading-2.5.0 → euler_loading-2.7.0}/tests/example_rds_calib.json +0 -0
  45. {euler_loading-2.5.0 → euler_loading-2.7.0}/tests/test_id_schema.py +0 -0
  46. {euler_loading-2.5.0 → euler_loading-2.7.0}/tests/test_indexing.py +0 -0
  47. {euler_loading-2.5.0 → euler_loading-2.7.0}/tests/test_preprocessing.py +0 -0
  48. {euler_loading-2.5.0 → euler_loading-2.7.0}/tests/test_real_dataset.py +0 -0
  49. {euler_loading-2.5.0 → euler_loading-2.7.0}/vkitti_cpu_example_output.json +0 -0
  50. {euler_loading-2.5.0 → euler_loading-2.7.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.5.0
3
+ Version: 2.7.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
@@ -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 — `keyed_modalities` joins each augmented sample to its single GT file via the value encoded in the augmented sample's deepest hierarchy key.
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
+ | `keyed_by` | `Mapping[str, str] \| None` | Optional join configuration used only when this modality is passed under `MultiModalDataset(keyed_modalities=...)`. Recognised keys: `key_name` (named-group prefix at the regular modality's deepest hierarchy key, e.g. `"file_id"`; auto-detected from the anchor's data when omitted) and `modality` (anchor regular-modality name; auto-inferred when there's exactly one regular). Both keys are optional — the kwarg can be left unset entirely. |
69
+ | `cache` | `bool \| None` | Opt-in/out for in-memory caching of decoded values (only meaningful for hierarchical and keyed 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` (small calibration files benefit), keyed → `False` (safe for the augmentation case where the keyed modality holds full per-sample tensors and unbounded caching would OOM). |
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.
@@ -135,23 +137,30 @@ Returns a dict mapping each regular modality name to `{"path": ..., "origin_path
135
137
 
136
138
  Returns a dict mapping each hierarchical modality name to `{"path": ..., "origin_path": ...}` and includes `split` when configured.
137
139
 
140
+ ### `MultiModalDataset.keyed_modality_paths()`
141
+
142
+ Returns a dict mapping each keyed modality name to `{"path": ..., "origin_path": ...}` plus `keyed_by_modality` (the resolved anchor) and `keyed_by_key_name` (the resolved or auto-detected key name). Includes `split` when configured. Useful for verifying which prefix the auto-detection picked.
143
+
138
144
  ### `MultiModalDataset.get_modality_metadata(modality_name)`
139
145
 
140
146
  Returns the ds-crawler metadata dict for the given modality.
141
147
 
142
- ### `MultiModalDataset(modalities, hierarchical_modalities=None, transforms=None)`
148
+ ### `MultiModalDataset(modalities, hierarchical_modalities=None, transforms=None, keyed_modalities=None, strict_keyed=False)`
143
149
 
144
150
  PyTorch `Dataset`. On construction it:
145
151
 
146
- 1. Runs `ds_crawler.index_dataset_from_path()` for every modality (regular and hierarchical).
152
+ 1. Runs `ds_crawler.index_dataset_from_path()` for every modality (regular, hierarchical, and keyed).
147
153
  2. Computes the **sorted intersection** of file IDs across all regular modalities.
148
- 3. Logs warnings for unmatched files; raises `ValueError` when the intersection is empty.
154
+ 3. Validates keyed-modality joins for every common id; drops samples whose join misses (or raises when `strict_keyed=True`).
155
+ 4. Logs warnings for unmatched files; raises `ValueError` when the intersection is empty or all samples are dropped by keyed-join validation.
149
156
 
150
157
  | Parameter | Type | Description |
151
158
  |-----------|------|-------------|
152
159
  | `modalities` | `dict[str, Modality]` | At least one entry required. Keys become the sample dict keys. These modalities participate in ID intersection. |
153
160
  | `hierarchical_modalities` | `dict[str, Modality] \| None` | Optional modalities whose files live at intermediate hierarchy levels (e.g. per-scene intrinsics). These do **not** participate in ID intersection. Each sample will contain a dict `{file_id: loaded_result}` with all files at or above the sample's hierarchy level. Results are cached so shared files are parsed only once. |
161
+ | `keyed_modalities` | `dict[str, Modality] \| None` | Optional modalities joined to a regular sample by the value of the regular sample's deepest hierarchy key. See [Keyed modalities](#keyed-modalities) below. Contributes a single loaded value per sample (not a dict). Caching is **off by default** for keyed modalities (per-sample tensors can be large); set `Modality(..., cache=True)` to opt in when the keyed dataset is small enough to fit in memory. |
154
162
  | `transforms` | `list[Callable[[dict], dict]] \| None` | Applied in order after loading. Each receives and returns the full sample dict. |
163
+ | `strict_keyed` | `bool` | When `True`, missing or mis-decoded keyed-modality joins raise immediately at construction instead of warning + dropping the affected samples. Default `False`. |
155
164
 
156
165
  #### Sample dict
157
166
 
@@ -166,16 +175,26 @@ PyTorch `Dataset`. On construction it:
166
175
  ...
167
176
  },
168
177
  ...
178
+ "<keyed_modality_name>": <loader result>, # one entry per keyed modality (single value)
179
+ ...
169
180
  "id": str, # file ID (leaf only, shared across modalities)
170
181
  "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": ...},
182
+ "meta": { # per-modality ds-crawler file entries
183
+ "<modality_name>": {"id": ..., "path": ..., "path_properties": ..., "basename_properties": ..., "attributes": ...},
184
+ ...
185
+ },
186
+ "attributes": { # per-modality top-level surface for file_entry["attributes"]
187
+ "<modality_name>": {...}, # for regular and keyed modalities (single dict)
188
+ "<hierarchical_modality_name>": { # for hierarchical modalities (one dict per matched file)
189
+ "<file_id>": {...},
190
+ ...
191
+ },
173
192
  ...
174
193
  },
175
194
  }
176
195
  ```
177
196
 
178
- Hierarchical modality results are cached so shared files are parsed only once.
197
+ Hierarchical modality results are cached by default; keyed modality results are not. Both can be flipped via `Modality(..., cache=True|False)` — see the [`cache` row in the `Modality` table](#modalitypath--loadernone-metadatanone) and [Keyed modalities](#keyed-modalities) for details on when each default is the right choice.
179
198
 
180
199
  ### `FileRecord`
181
200
 
@@ -315,6 +334,98 @@ Files across modalities are matched by these IDs, so **the directory structure m
315
334
 
316
335
  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
336
 
337
+ ## Keyed modalities
338
+
339
+ A **keyed modality** joins each regular sample to a single file in another dataset by reading the *value* of the regular sample's deepest hierarchy key. This is the right tool when one modality augments samples of another at a different hierarchy depth — typically: many augmented files per ground-truth sample.
340
+
341
+ ### When to use it
342
+
343
+ Layout where the augmented modality nests files under an extra `file_id:<id>` level, while the GT keeps the file id as the filename stem:
344
+
345
+ ```
346
+ augmented_rgb/
347
+ scene_000000/CS_FRONT/file_id:000000000000000025/mor_10m.png
348
+ scene_000000/CS_FRONT/file_id:000000000000000025/mor_20m.png
349
+ scene_000000/CS_FRONT/file_id:000000000000000026/mor_10m.png
350
+ ...
351
+ gt_depth/
352
+ scene_000000/CS_FRONT/000000000000000025.png
353
+ scene_000000/CS_FRONT/000000000000000026.png
354
+ ...
355
+ ```
356
+
357
+ ds-crawler indexes both layouts unchanged; the augmented modality's `indexing.hierarchy.separator` must be `":"` (or any single separator) so the deepest key `"file_id:000…025"` decodes into `("file_id", "000…025")`.
358
+
359
+ ### Wiring
360
+
361
+ ```python
362
+ from euler_loading import Modality, MultiModalDataset
363
+
364
+ dataset = MultiModalDataset(
365
+ modalities={
366
+ "rgb_aug": Modality("/data/augmented_rgb", loader=load_rgb),
367
+ },
368
+ keyed_modalities={
369
+ "depth": Modality("/data/gt_depth", loader=load_depth),
370
+ },
371
+ )
372
+
373
+ sample = dataset[0]
374
+ # sample["rgb_aug"] – the per-aug RGB
375
+ # sample["depth"] – the GT depth for this file_id (single value, shared across augs)
376
+ # sample["full_id"] – e.g. "/scene_000000/CS_FRONT/file_id:000…025/mor_10m"
377
+ # sample["id"] – the augmentation's leaf id (e.g. "mor_10m")
378
+ ```
379
+
380
+ `keyed_by` is optional — both `key_name` (the named-group prefix at the regular modality's deepest hierarchy key) and `modality` (the anchor regular modality) are auto-detected when unambiguous. Set them explicitly when:
381
+
382
+ - there are multiple regular modalities (anchor must be picked: `keyed_by={"modality": "rgb_aug", "key_name": "file_id"}`); or
383
+ - the anchor's deepest hierarchy keys mix multiple prefixes (e.g. some `file_id:…` and some `frame:…`).
384
+
385
+ ### Sample shape
386
+
387
+ A keyed modality contributes a **single loaded value** per sample, unlike hierarchical modalities which return `{file_id: loader_result}`. The join returns exactly one record by construction (one GT per augmented sample's file id at the parent hierarchy prefix), so a dict would be misleading.
388
+
389
+ ### How the join works
390
+
391
+ For each common id in the regular modalities, euler-loading:
392
+
393
+ 1. Looks up the anchor record's `hierarchy_path`, e.g. `(scene_000000, CS_FRONT, file_id:000…025)`.
394
+ 2. Splits the deepest key on the regular modality's `indexing.hierarchy.separator` → `("file_id", "000…025")`.
395
+ 3. Verifies the key name matches the configured (or auto-detected) `key_name`.
396
+ 4. Looks up the keyed modality's record at hierarchy `(scene_000000, CS_FRONT)` with `id == "000…025"`.
397
+
398
+ ### Caching
399
+
400
+ Caching for keyed modalities is **off by default**, which differs from hierarchical modalities (default on). The reason is workload shape: hierarchical files are usually tiny calibration / metadata blobs that you'd happily keep in memory forever, whereas a keyed modality typically holds the same kind of full per-sample tensor as a regular modality. Caching every decoded GT depth tensor in a 500 GB dataset would OOM the process.
401
+
402
+ Override per modality:
403
+
404
+ ```python
405
+ keyed_modalities={
406
+ # Small GT (a few hundred MB total): worth caching to avoid re-reads
407
+ # across augmentations of the same file_id.
408
+ "depth": Modality("/data/gt_depth", loader=load_depth, cache=True),
409
+ # Large GT (multi-hundred-GB total): keep it off, re-read each access.
410
+ "lidar": Modality("/data/gt_lidar", loader=load_lidar), # cache=False default
411
+ }
412
+ ```
413
+
414
+ When `cache=True`, the GT for `file_id:000…025` is decoded once and reused across every augmentation that points at it. When `cache=False` (the default), every sample access re-reads and re-decodes the file.
415
+
416
+ ### Validation and missing joins
417
+
418
+ Construction-time validation runs the decoding for every common id. Samples whose decode or lookup fails are dropped from the dataset with a per-modality warning summarising the count. Pass `strict_keyed=True` to raise instead — useful in pipelines where dropped samples should be a hard error. Common error cases (with precise messages):
419
+
420
+ - key-name mismatch (deepest key starts with a different prefix);
421
+ - regular modality with no `indexing.hierarchy.separator`;
422
+ - ambiguous auto-detection (multiple distinct deepest-key prefixes in the anchor);
423
+ - regular modality with no hierarchy at all.
424
+
425
+ ### Writing keyed-modality outputs
426
+
427
+ `MultiModalDataset.write_sample(...)` accepts keyed modality names alongside regular ones. The destination path is derived from the keyed record (the GT's relative path), so prediction outputs land in the GT-shape layout — *not* under a synthetic `file_id:` subdirectory.
428
+
318
429
  ## DenseDepthLoader protocol
319
430
 
320
431
  `euler_loading.DenseDepthLoader` is a `runtime_checkable` Protocol defining the loader contract for dense-depth datasets. A conforming module must expose:
@@ -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)