euler-preprocess 3.15.0__tar.gz → 3.16.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 (67) hide show
  1. {euler_preprocess-3.15.0/euler_preprocess.egg-info → euler_preprocess-3.16.0}/PKG-INFO +18 -10
  2. {euler_preprocess-3.15.0 → euler_preprocess-3.16.0}/README.md +17 -9
  3. euler_preprocess-3.16.0/euler_preprocess/common/dataset.py +212 -0
  4. {euler_preprocess-3.15.0 → euler_preprocess-3.16.0}/euler_preprocess/common/intrinsics.py +3 -2
  5. {euler_preprocess-3.15.0 → euler_preprocess-3.16.0}/euler_preprocess/common/transform.py +9 -0
  6. {euler_preprocess-3.15.0 → euler_preprocess-3.16.0}/euler_preprocess/fog/transform.py +3 -2
  7. {euler_preprocess-3.15.0 → euler_preprocess-3.16.0}/euler_preprocess/radial/transform.py +3 -3
  8. {euler_preprocess-3.15.0 → euler_preprocess-3.16.0}/euler_preprocess/spatial.py +9 -1
  9. {euler_preprocess-3.15.0 → euler_preprocess-3.16.0/euler_preprocess.egg-info}/PKG-INFO +18 -10
  10. {euler_preprocess-3.15.0 → euler_preprocess-3.16.0}/euler_preprocess.egg-info/SOURCES.txt +1 -0
  11. {euler_preprocess-3.15.0 → euler_preprocess-3.16.0}/pyproject.toml +1 -1
  12. euler_preprocess-3.16.0/tests/test_modality_roles.py +192 -0
  13. {euler_preprocess-3.15.0 → euler_preprocess-3.16.0}/tests/test_spatial.py +11 -0
  14. euler_preprocess-3.15.0/euler_preprocess/common/dataset.py +0 -73
  15. {euler_preprocess-3.15.0 → euler_preprocess-3.16.0}/CHANGELOG.md +0 -0
  16. {euler_preprocess-3.15.0 → euler_preprocess-3.16.0}/LICENSE +0 -0
  17. {euler_preprocess-3.15.0 → euler_preprocess-3.16.0}/MANIFEST.in +0 -0
  18. {euler_preprocess-3.15.0 → euler_preprocess-3.16.0}/docs/spatial.md +0 -0
  19. {euler_preprocess-3.15.0 → euler_preprocess-3.16.0}/euler_preprocess/__init__.py +0 -0
  20. {euler_preprocess-3.15.0 → euler_preprocess-3.16.0}/euler_preprocess/cli.py +0 -0
  21. {euler_preprocess-3.15.0 → euler_preprocess-3.16.0}/euler_preprocess/common/__init__.py +0 -0
  22. {euler_preprocess-3.15.0 → euler_preprocess-3.16.0}/euler_preprocess/common/color.py +0 -0
  23. {euler_preprocess-3.15.0 → euler_preprocess-3.16.0}/euler_preprocess/common/device.py +0 -0
  24. {euler_preprocess-3.15.0 → euler_preprocess-3.16.0}/euler_preprocess/common/io.py +0 -0
  25. {euler_preprocess-3.15.0 → euler_preprocess-3.16.0}/euler_preprocess/common/logging.py +0 -0
  26. {euler_preprocess-3.15.0 → euler_preprocess-3.16.0}/euler_preprocess/common/noise.py +0 -0
  27. {euler_preprocess-3.15.0 → euler_preprocess-3.16.0}/euler_preprocess/common/normalize.py +0 -0
  28. {euler_preprocess-3.15.0 → euler_preprocess-3.16.0}/euler_preprocess/common/output.py +0 -0
  29. {euler_preprocess-3.15.0 → euler_preprocess-3.16.0}/euler_preprocess/common/sampling.py +0 -0
  30. {euler_preprocess-3.15.0 → euler_preprocess-3.16.0}/euler_preprocess/fog/__init__.py +0 -0
  31. {euler_preprocess-3.15.0 → euler_preprocess-3.16.0}/euler_preprocess/fog/airlight_from_sky.py +0 -0
  32. {euler_preprocess-3.15.0 → euler_preprocess-3.16.0}/euler_preprocess/fog/atmospheric_light.py +0 -0
  33. {euler_preprocess-3.15.0 → euler_preprocess-3.16.0}/euler_preprocess/fog/augmentations.py +0 -0
  34. {euler_preprocess-3.15.0 → euler_preprocess-3.16.0}/euler_preprocess/fog/capture.py +0 -0
  35. {euler_preprocess-3.15.0 → euler_preprocess-3.16.0}/euler_preprocess/fog/dcp_airlight.py +0 -0
  36. {euler_preprocess-3.15.0 → euler_preprocess-3.16.0}/euler_preprocess/fog/dcp_airlight_torch.py +0 -0
  37. {euler_preprocess-3.15.0 → euler_preprocess-3.16.0}/euler_preprocess/fog/dcp_heuristic_airlight.py +0 -0
  38. {euler_preprocess-3.15.0 → euler_preprocess-3.16.0}/euler_preprocess/fog/dcp_heuristic_airlight_torch.py +0 -0
  39. {euler_preprocess-3.15.0 → euler_preprocess-3.16.0}/euler_preprocess/fog/inference.py +0 -0
  40. {euler_preprocess-3.15.0 → euler_preprocess-3.16.0}/euler_preprocess/fog/logging.py +0 -0
  41. {euler_preprocess-3.15.0 → euler_preprocess-3.16.0}/euler_preprocess/fog/models.py +0 -0
  42. {euler_preprocess-3.15.0 → euler_preprocess-3.16.0}/euler_preprocess/fog/pipeline.py +0 -0
  43. {euler_preprocess-3.15.0 → euler_preprocess-3.16.0}/euler_preprocess/radial/__init__.py +0 -0
  44. {euler_preprocess-3.15.0 → euler_preprocess-3.16.0}/euler_preprocess/sky_depth/__init__.py +0 -0
  45. {euler_preprocess-3.15.0 → euler_preprocess-3.16.0}/euler_preprocess/sky_depth/transform.py +0 -0
  46. {euler_preprocess-3.15.0 → euler_preprocess-3.16.0}/euler_preprocess.egg-info/dependency_links.txt +0 -0
  47. {euler_preprocess-3.15.0 → euler_preprocess-3.16.0}/euler_preprocess.egg-info/entry_points.txt +0 -0
  48. {euler_preprocess-3.15.0 → euler_preprocess-3.16.0}/euler_preprocess.egg-info/requires.txt +0 -0
  49. {euler_preprocess-3.15.0 → euler_preprocess-3.16.0}/euler_preprocess.egg-info/top_level.txt +0 -0
  50. {euler_preprocess-3.15.0 → euler_preprocess-3.16.0}/examples/spatial-five-field.json +0 -0
  51. {euler_preprocess-3.15.0 → euler_preprocess-3.16.0}/setup.cfg +0 -0
  52. {euler_preprocess-3.15.0 → euler_preprocess-3.16.0}/tests/test_airlight_fallback.py +0 -0
  53. {euler_preprocess-3.15.0 → euler_preprocess-3.16.0}/tests/test_cli_sample_selection.py +0 -0
  54. {euler_preprocess-3.15.0 → euler_preprocess-3.16.0}/tests/test_dcp_heuristic_airlight.py +0 -0
  55. {euler_preprocess-3.15.0 → euler_preprocess-3.16.0}/tests/test_dense_gloomy_daylight_config.py +0 -0
  56. {euler_preprocess-3.15.0 → euler_preprocess-3.16.0}/tests/test_fog_aux_outputs.py +0 -0
  57. {euler_preprocess-3.15.0 → euler_preprocess-3.16.0}/tests/test_fog_aware_auto_exposure.py +0 -0
  58. {euler_preprocess-3.15.0 → euler_preprocess-3.16.0}/tests/test_fog_cpu_gpu_parity.py +0 -0
  59. {euler_preprocess-3.15.0 → euler_preprocess-3.16.0}/tests/test_fog_integration.py +0 -0
  60. {euler_preprocess-3.15.0 → euler_preprocess-3.16.0}/tests/test_radial.py +0 -0
  61. {euler_preprocess-3.15.0 → euler_preprocess-3.16.0}/tests/test_real_drive_sim_scenario_profiles.py +0 -0
  62. {euler_preprocess-3.15.0 → euler_preprocess-3.16.0}/tests/test_scene_illumination.py +0 -0
  63. {euler_preprocess-3.15.0 → euler_preprocess-3.16.0}/tests/test_sensor_identity.py +0 -0
  64. {euler_preprocess-3.15.0 → euler_preprocess-3.16.0}/tests/test_sky_depth.py +0 -0
  65. {euler_preprocess-3.15.0 → euler_preprocess-3.16.0}/tests/test_source_backed_output.py +0 -0
  66. {euler_preprocess-3.15.0 → euler_preprocess-3.16.0}/tests/test_tone_map_lut.py +0 -0
  67. {euler_preprocess-3.15.0 → euler_preprocess-3.16.0}/tests/test_zip_output.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: euler-preprocess
3
- Version: 3.15.0
3
+ Version: 3.16.0
4
4
  Summary: Physics-based preprocessing (fog, etc.) for RGB+depth datasets
5
5
  Author-email: Daniel Rothenpieler <rothenpielerdaniel@gmail.com>
6
6
  License-Expression: MIT
@@ -94,9 +94,7 @@ loader.
94
94
  "modalities": {
95
95
  "rgb": {"path": "/path/to/rgb", "split": "train"},
96
96
  "depth": "/path/to/depth",
97
- "semantic_segmentation": "/path/to/classSegmentation"
98
- },
99
- "hierarchical_modalities": {
97
+ "semantic_segmentation": "/path/to/classSegmentation",
100
98
  "intrinsics": {"path": "/path/to/intrinsics"}
101
99
  }
102
100
  }
@@ -109,17 +107,27 @@ loader.
109
107
  | `output_slot` | Optional slot selector when `pipeline.output_targets` has several entries. Defaults to `rgb` for `fog` and `depth` for `sky-depth` / `radial`. |
110
108
  | `sample` | Optional 0-based dataset index. Only `dataset[sample]` is transformed — useful for small benchmark slices of large datasets. |
111
109
  | `samples` | Optional multi-sample selector: a list of indices (`[0, 10, 20]`) or a slice object (`{"start": 0, "stop": 1000, "step": 2, "count": 100}`). `stop` is exclusive, `count` caps the result. Mutually exclusive with `sample`. |
112
- | `modalities` | Modalities that participate in sample-ID intersection. Each value is a path string or an object with `path` and optional `split`. |
113
- | `hierarchical_modalities` | Per-scene data (e.g. intrinsics), same format. Loaded once per scene and cached. |
110
+ | `modalities` | All input modalities. Each value is a path string or an object with `path` and optional `split`. Whether a modality is per-sample or hierarchical is inferred (see below). |
111
+ | `hierarchical_modalities` | Legacy, optional. Same format; entries are pooled with `modalities` and resolved the same way. |
114
112
  | `pipeline` | Optional runtime routing block compatible with `euler-inference`. |
115
113
 
116
114
  **Required modalities per transform:**
117
115
 
118
- | Transform | `modalities` | `hierarchical_modalities` |
116
+ | Transform | Required | Optional |
119
117
  |---|---|---|
120
- | `fog` | `rgb`, `depth`, `semantic_segmentation` | `intrinsics` when available; used for radial depth conversion and camera-profile optics |
118
+ | `fog` | `rgb`, `depth`, `semantic_segmentation` | `intrinsics`; used for radial depth conversion and camera-profile optics |
121
119
  | `sky-depth` | `depth`, `semantic_segmentation` | — |
122
- | `radial` | `depth` | `intrinsics` |
120
+ | `radial` | `depth`, `intrinsics` | — |
121
+
122
+ **Per-sample vs. hierarchical modalities.** You don't declare a modality's role:
123
+ it is inferred from the ds-crawler indexes. A modality whose qualified IDs
124
+ (hierarchy path plus file ID) match the transform's per-sample inputs is joined per
125
+ sample, e.g. Synscapes' per-image `meta/<id>.json` intrinsics. A modality whose
126
+ files share no sample IDs but sit at or above the samples' hierarchy level is
127
+ matched hierarchically and shared by every sample beneath it, e.g. one intrinsics
128
+ file per scene or camera. A modality that is neither raises an error, and the
129
+ inferred roles are logged. Listing a per-sample modality under
130
+ `hierarchical_modalities` logs a warning and loads it per sample.
123
131
 
124
132
  When a modality directory contains ds-crawler split files
125
133
  (`.ds_crawler/split_<name>.json`), set `split` on that modality to select a subset.
@@ -677,7 +685,7 @@ d_radial(u, v) = d_planar(u, v) * sqrt(((u - cx)/fx)^2 + ((v - cy)/fy)^2 + 1)
677
685
  ```
678
686
 
679
687
  The transform config takes no parameters (`{}`); intrinsics are read from the
680
- `intrinsics` hierarchical modality.
688
+ `intrinsics` modality, stored either per sample or per scene/camera.
681
689
 
682
690
  CLI runs write a source-backed depth dataset mirroring the input depth modality's layout
683
691
  and writer metadata, with `meta.radial_depth` set to `true` in the emitted `index.json`.
@@ -74,9 +74,7 @@ loader.
74
74
  "modalities": {
75
75
  "rgb": {"path": "/path/to/rgb", "split": "train"},
76
76
  "depth": "/path/to/depth",
77
- "semantic_segmentation": "/path/to/classSegmentation"
78
- },
79
- "hierarchical_modalities": {
77
+ "semantic_segmentation": "/path/to/classSegmentation",
80
78
  "intrinsics": {"path": "/path/to/intrinsics"}
81
79
  }
82
80
  }
@@ -89,17 +87,27 @@ loader.
89
87
  | `output_slot` | Optional slot selector when `pipeline.output_targets` has several entries. Defaults to `rgb` for `fog` and `depth` for `sky-depth` / `radial`. |
90
88
  | `sample` | Optional 0-based dataset index. Only `dataset[sample]` is transformed — useful for small benchmark slices of large datasets. |
91
89
  | `samples` | Optional multi-sample selector: a list of indices (`[0, 10, 20]`) or a slice object (`{"start": 0, "stop": 1000, "step": 2, "count": 100}`). `stop` is exclusive, `count` caps the result. Mutually exclusive with `sample`. |
92
- | `modalities` | Modalities that participate in sample-ID intersection. Each value is a path string or an object with `path` and optional `split`. |
93
- | `hierarchical_modalities` | Per-scene data (e.g. intrinsics), same format. Loaded once per scene and cached. |
90
+ | `modalities` | All input modalities. Each value is a path string or an object with `path` and optional `split`. Whether a modality is per-sample or hierarchical is inferred (see below). |
91
+ | `hierarchical_modalities` | Legacy, optional. Same format; entries are pooled with `modalities` and resolved the same way. |
94
92
  | `pipeline` | Optional runtime routing block compatible with `euler-inference`. |
95
93
 
96
94
  **Required modalities per transform:**
97
95
 
98
- | Transform | `modalities` | `hierarchical_modalities` |
96
+ | Transform | Required | Optional |
99
97
  |---|---|---|
100
- | `fog` | `rgb`, `depth`, `semantic_segmentation` | `intrinsics` when available; used for radial depth conversion and camera-profile optics |
98
+ | `fog` | `rgb`, `depth`, `semantic_segmentation` | `intrinsics`; used for radial depth conversion and camera-profile optics |
101
99
  | `sky-depth` | `depth`, `semantic_segmentation` | — |
102
- | `radial` | `depth` | `intrinsics` |
100
+ | `radial` | `depth`, `intrinsics` | — |
101
+
102
+ **Per-sample vs. hierarchical modalities.** You don't declare a modality's role:
103
+ it is inferred from the ds-crawler indexes. A modality whose qualified IDs
104
+ (hierarchy path plus file ID) match the transform's per-sample inputs is joined per
105
+ sample, e.g. Synscapes' per-image `meta/<id>.json` intrinsics. A modality whose
106
+ files share no sample IDs but sit at or above the samples' hierarchy level is
107
+ matched hierarchically and shared by every sample beneath it, e.g. one intrinsics
108
+ file per scene or camera. A modality that is neither raises an error, and the
109
+ inferred roles are logged. Listing a per-sample modality under
110
+ `hierarchical_modalities` logs a warning and loads it per sample.
103
111
 
104
112
  When a modality directory contains ds-crawler split files
105
113
  (`.ds_crawler/split_<name>.json`), set `split` on that modality to select a subset.
@@ -657,7 +665,7 @@ d_radial(u, v) = d_planar(u, v) * sqrt(((u - cx)/fx)^2 + ((v - cy)/fy)^2 + 1)
657
665
  ```
658
666
 
659
667
  The transform config takes no parameters (`{}`); intrinsics are read from the
660
- `intrinsics` hierarchical modality.
668
+ `intrinsics` modality, stored either per sample or per scene/camera.
661
669
 
662
670
  CLI runs write a source-backed depth dataset mirroring the input depth modality's layout
663
671
  and writer metadata, with `meta.radial_depth` set to `true` in the emitted `index.json`.
@@ -0,0 +1,212 @@
1
+ from __future__ import annotations
2
+
3
+ from collections import Counter
4
+
5
+ from euler_preprocess.common.logging import get_logger
6
+
7
+
8
+ def _make_modality(entry: str | dict):
9
+ from euler_loading import Modality
10
+
11
+ if isinstance(entry, str):
12
+ return Modality(entry)
13
+ return Modality(
14
+ entry["path"],
15
+ **{
16
+ key: entry[key]
17
+ for key in (
18
+ "split",
19
+ "metadata_scope",
20
+ "collapse_single",
21
+ "cache",
22
+ "applies_to",
23
+ "hierarchy_scope",
24
+ )
25
+ if key in entry
26
+ },
27
+ )
28
+
29
+
30
+ def _load_index_tree(name: str, modality) -> dict:
31
+ """Load a modality's ds-crawler tree the same way ``MultiModalDataset`` does."""
32
+ from ds_crawler import index_dataset_from_path
33
+ from euler_loading._ds_crawler_utils import load_index_output
34
+ from euler_loading.dataset import _get_index_tree, _resolve_metadata_scope
35
+
36
+ modality = _resolve_metadata_scope(name, modality)
37
+ index = load_index_output(
38
+ modality.path,
39
+ split=modality.split,
40
+ metadata_scope=modality.metadata_scope,
41
+ index_dataset_from_path_fn=index_dataset_from_path,
42
+ )
43
+ return _get_index_tree(index)
44
+
45
+
46
+ def _check_attachment(
47
+ name: str, seed: str, tree: dict, samples_per_level: Counter
48
+ ) -> None:
49
+ """Check that hierarchical modality *name* applies to the samples by tree position."""
50
+ from euler_loading.indexing import collect_hierarchical_files, match_hierarchical_files
51
+
52
+ files_by_level = collect_hierarchical_files(tree)
53
+ attached = {
54
+ level: len(match_hierarchical_files(level, files_by_level))
55
+ for level in samples_per_level
56
+ }
57
+ covered = sum(count for level, count in samples_per_level.items() if attached[level])
58
+ if not covered:
59
+ raise ValueError(
60
+ f"Modality '{name}' shares no sample IDs with '{seed}' and none of its "
61
+ "files sit at or above the hierarchy level of any sample, so it can be "
62
+ "neither joined per sample nor matched hierarchically. Check that its "
63
+ f"ds-crawler IDs and hierarchy follow the same conventions as '{seed}'."
64
+ )
65
+ logger = get_logger()
66
+ total = sum(samples_per_level.values())
67
+ log = logger.info if covered == total else logger.warning
68
+ log(
69
+ "Modality '%s' is hierarchical (no sample IDs shared with '%s'); it applies "
70
+ "to %d/%d samples by hierarchy position.",
71
+ name,
72
+ seed,
73
+ covered,
74
+ total,
75
+ )
76
+ most = max(attached.values())
77
+ if most > 1:
78
+ logger.warning(
79
+ "Hierarchical modality '%s' attaches up to %d files to one sample. If it "
80
+ "holds per-sample data, its ds-crawler IDs do not match '%s'.",
81
+ name,
82
+ most,
83
+ seed,
84
+ )
85
+
86
+
87
+ def _resolve_roles(
88
+ modalities: dict, per_sample: set[str]
89
+ ) -> tuple[list[str], list[str]]:
90
+ """Split modality names into regular and hierarchical ones.
91
+
92
+ The role follows from the ds-crawler indexes. A modality is regular
93
+ (joined per sample) when its qualified file IDs -- hierarchy path plus
94
+ file ID -- overlap those of the seed modality. Otherwise it is
95
+ hierarchical (matched by tree position, like per-scene intrinsics) and
96
+ must sit at or above the hierarchy level of at least one sample.
97
+
98
+ *per_sample* modalities are regular by contract and the first one is the
99
+ seed. Without one, the seed is the modality aligned with the most others,
100
+ then the one with the most files, then the first listed.
101
+ """
102
+ from euler_loading.indexing import collect_files
103
+
104
+ names = list(modalities)
105
+ if len(names) < 2 or per_sample.issuperset(names):
106
+ return names, []
107
+
108
+ trees = {name: _load_index_tree(name, modalities[name]) for name in names}
109
+ # Qualified sample ID -> hierarchy path, keyed like euler-loading's lookups.
110
+ sample_ids = {}
111
+ for name, tree in trees.items():
112
+ sample_ids[name] = {
113
+ "/".join(r.hierarchy_path + (r.file_entry["id"],)): r.hierarchy_path
114
+ for r in collect_files(tree)
115
+ }
116
+
117
+ def aligned(name: str, other: str) -> bool:
118
+ return not sample_ids[name].keys().isdisjoint(sample_ids[other].keys())
119
+
120
+ anchors = [name for name in names if name in per_sample]
121
+ seed = anchors[0] if anchors else max(
122
+ names,
123
+ key=lambda name: (
124
+ sum(aligned(other, name) for other in names),
125
+ len(sample_ids[name]),
126
+ ),
127
+ )
128
+ regular = [
129
+ name
130
+ for name in names
131
+ if name in per_sample or name == seed or aligned(name, seed)
132
+ ]
133
+ hierarchical = [name for name in names if name not in regular]
134
+ get_logger().info(
135
+ "Modality roles inferred from ds-crawler indexes: per-sample %s; "
136
+ "hierarchical %s",
137
+ ", ".join(regular),
138
+ ", ".join(hierarchical) or "none",
139
+ )
140
+
141
+ # Without common samples MultiModalDataset raises its own, clearer error.
142
+ common = set(sample_ids[seed]).intersection(*(sample_ids[name] for name in regular))
143
+ if common:
144
+ samples_per_level = Counter(sample_ids[seed][sample_id] for sample_id in common)
145
+ for name in hierarchical:
146
+ _check_attachment(name, seed, trees[name], samples_per_level)
147
+ return regular, hierarchical
148
+
149
+
150
+ def build_dataset(
151
+ config: dict,
152
+ required_modalities: set[str],
153
+ required_hierarchical: set[str] | None = None,
154
+ ):
155
+ """Build a ``MultiModalDataset`` from a config dict.
156
+
157
+ Each modality entry is either a plain path string or a dict with
158
+ ``path`` and optional ``split``. Loader resolution (which function to
159
+ call, which module to use) is handled by euler-loading via the
160
+ ds-crawler index at each path — point the config at a path whose
161
+ index declares the function you want (e.g. a ``sky_mask`` index for
162
+ boolean sky masks vs. a ``class_segmentation`` index for raw class
163
+ id maps).
164
+
165
+ Every modality can be listed under ``modalities``: whether it is joined
166
+ per sample or matched hierarchically (e.g. per-scene intrinsics) is
167
+ inferred from the ds-crawler indexes. Entries under the legacy
168
+ ``hierarchical_modalities`` key are pooled with them and resolved the
169
+ same way. *required_modalities* are per-sample inputs and anchor that
170
+ inference; *required_hierarchical* names further required modalities,
171
+ whose role is inferred like any other.
172
+ """
173
+ from euler_loading import MultiModalDataset, SamplePreprocessor
174
+
175
+ raw_modalities = config.get("modalities", {})
176
+ raw_hierarchical = config.get("hierarchical_modalities", {})
177
+
178
+ duplicated = raw_modalities.keys() & raw_hierarchical.keys()
179
+ if duplicated:
180
+ raise ValueError(
181
+ "Modalities listed under both 'modalities' and "
182
+ f"'hierarchical_modalities': {', '.join(sorted(duplicated))}"
183
+ )
184
+ configured = {**raw_modalities, **raw_hierarchical}
185
+
186
+ required = set(required_modalities) | set(required_hierarchical or ())
187
+ missing = required - configured.keys()
188
+ if missing:
189
+ raise ValueError(
190
+ f"Missing required modalities in config: {', '.join(sorted(missing))}. "
191
+ f"'modalities' must contain at least: {', '.join(sorted(required))}"
192
+ )
193
+
194
+ modalities = {name: _make_modality(entry) for name, entry in configured.items()}
195
+ regular, hierarchical = _resolve_roles(modalities, set(required_modalities))
196
+ logger = get_logger()
197
+ for name in regular:
198
+ if name in raw_hierarchical:
199
+ logger.warning(
200
+ "Modality '%s' is listed under 'hierarchical_modalities' but holds "
201
+ "per-sample data; loading it per sample.",
202
+ name,
203
+ )
204
+
205
+ return MultiModalDataset(
206
+ modalities={name: modalities[name] for name in regular},
207
+ hierarchical_modalities={name: modalities[name] for name in hierarchical}
208
+ or None,
209
+ transforms=[SamplePreprocessor.from_config(config["preprocessing"])]
210
+ if config.get("preprocessing")
211
+ else None,
212
+ )
@@ -13,8 +13,9 @@ except ImportError:
13
13
  def extract_intrinsics(sample: dict) -> np.ndarray | None:
14
14
  """Extract the 3x3 intrinsics matrix from a sample dict.
15
15
 
16
- Hierarchical modalities are stored as ``sample[name][file_id]``.
17
- For intrinsics the convention is ``sample["intrinsics"]["intrinsics"]``.
16
+ Per-sample intrinsics are stored directly as ``sample["intrinsics"]``.
17
+ Hierarchical modalities are stored as ``sample[name][file_id]``; for
18
+ intrinsics the convention is ``sample["intrinsics"]["intrinsics"]``.
18
19
  """
19
20
  intr_dict = sample.get("intrinsics")
20
21
  if intr_dict is None:
@@ -12,6 +12,15 @@ class Transform(ABC):
12
12
  Subclasses declare the modalities they need via class variables and
13
13
  implement :meth:`run` to process samples.
14
14
 
15
+ Required modalities:
16
+ :attr:`REQUIRED_MODALITIES` are per-sample inputs (RGB, depth, masks);
17
+ they are always joined per sample and anchor the role inference of
18
+ every other modality. :attr:`REQUIRED_HIERARCHICAL_MODALITIES` are
19
+ further required inputs, such as camera intrinsics, that a dataset may
20
+ store per sample or once per hierarchy level; their role is inferred
21
+ from the ds-crawler indexes (see
22
+ :func:`~euler_preprocess.common.dataset.build_dataset`).
23
+
15
24
  Output slots:
16
25
  Most transforms produce a single output (the *primary* slot, declared
17
26
  via :attr:`OUTPUT_SLOT`). Transforms that produce additional auxiliary
@@ -286,8 +286,9 @@ class FogTransform(Transform):
286
286
  "depth": np.ndarray (H, W), float in meters
287
287
  "semantic_segmentation": np.ndarray (H, W), boolean sky mask
288
288
  "id": str
289
- "intrinsics": dict -- hierarchical modality containing ``"intrinsics"``
290
- key mapping to a (3, 3) camera intrinsics matrix *K*.
289
+ "intrinsics": (3, 3) camera intrinsics matrix *K* (per-sample
290
+ modality), or a dict with an ``"intrinsics"`` key
291
+ mapping to *K* (hierarchical modality).
291
292
  When present, planar (z-buffer) depth is converted to
292
293
  radial (Euclidean) depth before fog is applied.
293
294
 
@@ -15,8 +15,8 @@ from euler_preprocess.common.transform import Transform
15
15
  class RadialTransform(Transform):
16
16
  """Convert planar (z-buffer) depth to radial (Euclidean) depth.
17
17
 
18
- Requires camera intrinsics (hierarchical modality ``intrinsics``) to
19
- compute the per-pixel ray direction.
18
+ Requires camera intrinsics (modality ``intrinsics``, stored per sample or
19
+ per hierarchy level) to compute the per-pixel ray direction.
20
20
 
21
21
  Config JSON keys:
22
22
  ``device`` (str): ``"cpu"`` (default). GPU support reserved for future use.
@@ -61,7 +61,7 @@ class RadialTransform(Transform):
61
61
  if K is None:
62
62
  raise ValueError(
63
63
  f"Sample '{sample.get('id', '?')}' has no intrinsics. "
64
- "RadialTransform requires the 'intrinsics' hierarchical modality."
64
+ "RadialTransform requires the 'intrinsics' modality."
65
65
  )
66
66
 
67
67
  radial = planar_to_radial_depth(depth, K)
@@ -27,7 +27,15 @@ def export_spatial(config):
27
27
  dataset_config = {
28
28
  key: value for key, value in config.items() if key != "preprocessing"
29
29
  }
30
- dataset = build_dataset(dataset_config, set(config.get("modalities", {})))
30
+ # Bound fields with a spatial layout are per-sample images; the role of the
31
+ # others (e.g. intrinsics) is inferred from their ds-crawler indexes.
32
+ per_sample = {
33
+ name
34
+ for options in variants.values()
35
+ for name, binding in options["bindings"]["fields"].items()
36
+ if "H" in binding["profile"]["layout"]
37
+ }
38
+ dataset = build_dataset(dataset_config, per_sample)
31
39
  plans = {}
32
40
  for variant, options in variants.items():
33
41
  bindings = deepcopy(options["bindings"])
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: euler-preprocess
3
- Version: 3.15.0
3
+ Version: 3.16.0
4
4
  Summary: Physics-based preprocessing (fog, etc.) for RGB+depth datasets
5
5
  Author-email: Daniel Rothenpieler <rothenpielerdaniel@gmail.com>
6
6
  License-Expression: MIT
@@ -94,9 +94,7 @@ loader.
94
94
  "modalities": {
95
95
  "rgb": {"path": "/path/to/rgb", "split": "train"},
96
96
  "depth": "/path/to/depth",
97
- "semantic_segmentation": "/path/to/classSegmentation"
98
- },
99
- "hierarchical_modalities": {
97
+ "semantic_segmentation": "/path/to/classSegmentation",
100
98
  "intrinsics": {"path": "/path/to/intrinsics"}
101
99
  }
102
100
  }
@@ -109,17 +107,27 @@ loader.
109
107
  | `output_slot` | Optional slot selector when `pipeline.output_targets` has several entries. Defaults to `rgb` for `fog` and `depth` for `sky-depth` / `radial`. |
110
108
  | `sample` | Optional 0-based dataset index. Only `dataset[sample]` is transformed — useful for small benchmark slices of large datasets. |
111
109
  | `samples` | Optional multi-sample selector: a list of indices (`[0, 10, 20]`) or a slice object (`{"start": 0, "stop": 1000, "step": 2, "count": 100}`). `stop` is exclusive, `count` caps the result. Mutually exclusive with `sample`. |
112
- | `modalities` | Modalities that participate in sample-ID intersection. Each value is a path string or an object with `path` and optional `split`. |
113
- | `hierarchical_modalities` | Per-scene data (e.g. intrinsics), same format. Loaded once per scene and cached. |
110
+ | `modalities` | All input modalities. Each value is a path string or an object with `path` and optional `split`. Whether a modality is per-sample or hierarchical is inferred (see below). |
111
+ | `hierarchical_modalities` | Legacy, optional. Same format; entries are pooled with `modalities` and resolved the same way. |
114
112
  | `pipeline` | Optional runtime routing block compatible with `euler-inference`. |
115
113
 
116
114
  **Required modalities per transform:**
117
115
 
118
- | Transform | `modalities` | `hierarchical_modalities` |
116
+ | Transform | Required | Optional |
119
117
  |---|---|---|
120
- | `fog` | `rgb`, `depth`, `semantic_segmentation` | `intrinsics` when available; used for radial depth conversion and camera-profile optics |
118
+ | `fog` | `rgb`, `depth`, `semantic_segmentation` | `intrinsics`; used for radial depth conversion and camera-profile optics |
121
119
  | `sky-depth` | `depth`, `semantic_segmentation` | — |
122
- | `radial` | `depth` | `intrinsics` |
120
+ | `radial` | `depth`, `intrinsics` | — |
121
+
122
+ **Per-sample vs. hierarchical modalities.** You don't declare a modality's role:
123
+ it is inferred from the ds-crawler indexes. A modality whose qualified IDs
124
+ (hierarchy path plus file ID) match the transform's per-sample inputs is joined per
125
+ sample, e.g. Synscapes' per-image `meta/<id>.json` intrinsics. A modality whose
126
+ files share no sample IDs but sit at or above the samples' hierarchy level is
127
+ matched hierarchically and shared by every sample beneath it, e.g. one intrinsics
128
+ file per scene or camera. A modality that is neither raises an error, and the
129
+ inferred roles are logged. Listing a per-sample modality under
130
+ `hierarchical_modalities` logs a warning and loads it per sample.
123
131
 
124
132
  When a modality directory contains ds-crawler split files
125
133
  (`.ds_crawler/split_<name>.json`), set `split` on that modality to select a subset.
@@ -677,7 +685,7 @@ d_radial(u, v) = d_planar(u, v) * sqrt(((u - cx)/fx)^2 + ((v - cy)/fy)^2 + 1)
677
685
  ```
678
686
 
679
687
  The transform config takes no parameters (`{}`); intrinsics are read from the
680
- `intrinsics` hierarchical modality.
688
+ `intrinsics` modality, stored either per sample or per scene/camera.
681
689
 
682
690
  CLI runs write a source-backed depth dataset mirroring the input depth modality's layout
683
691
  and writer metadata, with `meta.radial_depth` set to `true` in the emitted `index.json`.
@@ -52,6 +52,7 @@ tests/test_fog_aux_outputs.py
52
52
  tests/test_fog_aware_auto_exposure.py
53
53
  tests/test_fog_cpu_gpu_parity.py
54
54
  tests/test_fog_integration.py
55
+ tests/test_modality_roles.py
55
56
  tests/test_radial.py
56
57
  tests/test_real_drive_sim_scenario_profiles.py
57
58
  tests/test_scene_illumination.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "euler-preprocess"
7
- version = "3.15.0"
7
+ version = "3.16.0"
8
8
  description = "Physics-based preprocessing (fog, etc.) for RGB+depth datasets"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -0,0 +1,192 @@
1
+ """Tests for inferring per-sample vs. hierarchical modality roles."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ from pathlib import Path
8
+
9
+ import numpy as np
10
+ import pytest
11
+
12
+ from ds_crawler import DatasetWriter
13
+ from euler_loading import Modality, MultiModalDataset
14
+ from euler_loading.loaders.cpu.generic import write_intrinsics
15
+ from euler_loading.loaders.cpu.generic_dense_depth import write_depth
16
+
17
+ from euler_preprocess.cli import _run_transform
18
+ from euler_preprocess.common.dataset import build_dataset
19
+ from euler_preprocess.common.intrinsics import extract_intrinsics, planar_to_radial_depth
20
+ from euler_preprocess.common.normalize import normalize_depth
21
+ from euler_preprocess.radial.transform import RadialTransform
22
+
23
+ FRAMES = ("Scene01/Camera_0/0000", "Scene01/Camera_0/0001", "Scene02/Camera_0/0000")
24
+
25
+
26
+ def _K(fx: float) -> np.ndarray:
27
+ return np.array([[fx, 0.0, 3.0], [0.0, fx, 2.0], [0.0, 0.0, 1.0]], dtype=np.float32)
28
+
29
+
30
+ def _write_depth(root: Path, full_ids=FRAMES) -> str:
31
+ writer = DatasetWriter(
32
+ root,
33
+ name="synthetic_depth",
34
+ type="depth",
35
+ separator=None,
36
+ meta={"radial_depth": False, "scale_to_meters": 1.0, "range": [0, 1000]},
37
+ euler_loading={"loader": "generic_dense_depth", "function": "depth"},
38
+ )
39
+ for full_id in full_ids:
40
+ path = writer.get_path(full_id, full_id.rsplit("/", 1)[-1] + ".npy")
41
+ write_depth(str(path), np.full((4, 6), 25.0, dtype=np.float32))
42
+ writer.save_index()
43
+ return str(root)
44
+
45
+
46
+ def _write_intrinsics(root: Path, matrices: dict[str, np.ndarray]) -> str:
47
+ writer = DatasetWriter(
48
+ root,
49
+ name="synthetic_intrinsics",
50
+ type="intrinsics",
51
+ separator=None,
52
+ euler_loading={"loader": "generic", "function": "intrinsics"},
53
+ )
54
+ for full_id, K in matrices.items():
55
+ write_intrinsics(str(writer.get_path(full_id, f"{K[0, 0]:.0f}.npy")), K)
56
+ writer.save_index()
57
+ return str(root)
58
+
59
+
60
+ def _per_sample_intrinsics(root: Path) -> str:
61
+ return _write_intrinsics(root, {fid: _K(500.0 + i) for i, fid in enumerate(FRAMES)})
62
+
63
+
64
+ def _per_scene_intrinsics(root: Path) -> str:
65
+ return _write_intrinsics(
66
+ root, {"Scene01/intrinsics": _K(600.0), "Scene02/intrinsics": _K(700.0)}
67
+ )
68
+
69
+
70
+ def _roles(dataset) -> tuple[set[str], set[str]]:
71
+ return set(dataset.modality_paths()), set(dataset.hierarchical_modality_paths())
72
+
73
+
74
+ def _fx_by_full_id(dataset) -> dict[str, float]:
75
+ return {
76
+ dataset[i]["full_id"]: float(extract_intrinsics(dataset[i])[0, 0])
77
+ for i in range(len(dataset))
78
+ }
79
+
80
+
81
+ @pytest.mark.parametrize("key", ["modalities", "hierarchical_modalities"])
82
+ def test_per_sample_intrinsics_are_joined_per_sample(tmp_path, key):
83
+ config = {"modalities": {"depth": _write_depth(tmp_path / "depth")}}
84
+ config.setdefault(key, {})["intrinsics"] = _per_sample_intrinsics(
85
+ tmp_path / "intrinsics"
86
+ )
87
+
88
+ dataset = build_dataset(
89
+ config,
90
+ RadialTransform.REQUIRED_MODALITIES,
91
+ RadialTransform.REQUIRED_HIERARCHICAL_MODALITIES,
92
+ )
93
+
94
+ assert _roles(dataset) == ({"depth", "intrinsics"}, set())
95
+ assert _fx_by_full_id(dataset) == {
96
+ "/Scene01/Camera_0/0000": 500.0,
97
+ "/Scene01/Camera_0/0001": 501.0,
98
+ "/Scene02/Camera_0/0000": 502.0,
99
+ }
100
+
101
+
102
+ @pytest.mark.parametrize("key", ["modalities", "hierarchical_modalities"])
103
+ def test_per_scene_intrinsics_are_matched_hierarchically(tmp_path, key):
104
+ config = {"modalities": {"depth": _write_depth(tmp_path / "depth")}}
105
+ config.setdefault(key, {})["intrinsics"] = _per_scene_intrinsics(
106
+ tmp_path / "intrinsics"
107
+ )
108
+
109
+ dataset = build_dataset(
110
+ config,
111
+ RadialTransform.REQUIRED_MODALITIES,
112
+ RadialTransform.REQUIRED_HIERARCHICAL_MODALITIES,
113
+ )
114
+
115
+ assert _roles(dataset) == ({"depth"}, {"intrinsics"})
116
+ assert _fx_by_full_id(dataset) == {
117
+ "/Scene01/Camera_0/0000": 600.0,
118
+ "/Scene01/Camera_0/0001": 600.0,
119
+ "/Scene02/Camera_0/0000": 700.0,
120
+ }
121
+
122
+
123
+ def test_seed_without_required_modalities_is_the_sample_modality(tmp_path):
124
+ # The calibration sits at the same level as the frames and is listed first.
125
+ calibration = _write_intrinsics(
126
+ tmp_path / "intrinsics", {"Scene01/Camera_0/calibration": _K(800.0)}
127
+ )
128
+ depth = _write_depth(tmp_path / "depth", FRAMES[:2])
129
+
130
+ dataset = build_dataset(
131
+ {"modalities": {"intrinsics": calibration, "depth": depth}}, set()
132
+ )
133
+
134
+ assert _roles(dataset) == ({"depth"}, {"intrinsics"})
135
+ assert len(dataset) == 2
136
+ assert set(dataset[0]["intrinsics"]) == {"calibration"}
137
+
138
+
139
+ def test_modality_neither_per_sample_nor_attached_raises(tmp_path):
140
+ config = {
141
+ "modalities": {
142
+ "depth": _write_depth(tmp_path / "depth"),
143
+ "intrinsics": _write_intrinsics(
144
+ tmp_path / "intrinsics", {"Scene99/intrinsics": _K(600.0)}
145
+ ),
146
+ },
147
+ }
148
+
149
+ with pytest.raises(ValueError, match="neither joined per sample nor matched"):
150
+ build_dataset(config, {"depth"})
151
+
152
+
153
+ def test_modality_listed_under_both_keys_raises(tmp_path):
154
+ intrinsics = _per_scene_intrinsics(tmp_path / "intrinsics")
155
+ config = {
156
+ "modalities": {"depth": _write_depth(tmp_path / "depth"), "intrinsics": intrinsics},
157
+ "hierarchical_modalities": {"intrinsics": intrinsics},
158
+ }
159
+
160
+ with pytest.raises(ValueError, match="both 'modalities' and 'hierarchical_modalities'"):
161
+ build_dataset(config, {"depth"})
162
+
163
+
164
+ @pytest.mark.parametrize("layout", [_per_sample_intrinsics, _per_scene_intrinsics])
165
+ def test_radial_cli_infers_intrinsics_role(tmp_path, layout):
166
+ (tmp_path / "radial.json").write_text("{}", encoding="utf-8")
167
+ config_path = tmp_path / "dataset.json"
168
+ config_path.write_text(
169
+ json.dumps(
170
+ {
171
+ "transform_config_path": "radial.json",
172
+ "output_path": str(tmp_path / "radial_depth"),
173
+ "modalities": {
174
+ "depth": _write_depth(tmp_path / "depth"),
175
+ "intrinsics": layout(tmp_path / "intrinsics"),
176
+ },
177
+ }
178
+ ),
179
+ encoding="utf-8",
180
+ )
181
+ source = build_dataset(json.loads(config_path.read_text()), {"depth"})
182
+
183
+ assert _run_transform(argparse.Namespace(config=str(config_path)), RadialTransform) == 0
184
+
185
+ output = MultiModalDataset({"depth": Modality(str(tmp_path / "radial_depth"))})
186
+ assert len(output) == len(source) == len(FRAMES)
187
+ for i in range(len(source)):
188
+ expected = planar_to_radial_depth(
189
+ normalize_depth(source[i]["depth"]), extract_intrinsics(source[i])
190
+ )
191
+ assert output[i]["full_id"] == source[i]["full_id"]
192
+ np.testing.assert_allclose(normalize_depth(output[i]["depth"]), expected, rtol=1e-6)
@@ -77,6 +77,17 @@ def test_producer_captures_and_reloads_five_outputs(tmp_path, zip_output):
77
77
  )
78
78
 
79
79
 
80
+ def test_producer_infers_hierarchical_intrinsics_listed_as_modality(tmp_path):
81
+ config, ds, plan, _data, _original = config_for(tmp_path)
82
+ config["modalities"].update(config.pop("hierarchical_modalities"))
83
+ paths = export_spatial(config)
84
+ ds.enable_transform_capture({"default": plan})
85
+ expected = ds[1]
86
+ for field, path in paths.items():
87
+ sample = MultiModalDataset({field: Modality(path, metadata_scope="saved")})[0]
88
+ np.testing.assert_array_equal(sample[field], expected[field])
89
+
90
+
80
91
  def test_common_builder_consumes_preprocessing(tmp_path):
81
92
  config, ds, _, _, _ = config_for(tmp_path)
82
93
  configured = build_dataset(config, set(config["modalities"]))
@@ -1,73 +0,0 @@
1
- from __future__ import annotations
2
-
3
-
4
- def _make_modality(entry: str | dict):
5
- from euler_loading import Modality
6
-
7
- if isinstance(entry, str):
8
- return Modality(entry)
9
- return Modality(
10
- entry["path"],
11
- **{
12
- key: entry[key]
13
- for key in (
14
- "split",
15
- "metadata_scope",
16
- "collapse_single",
17
- "cache",
18
- "applies_to",
19
- "hierarchy_scope",
20
- )
21
- if key in entry
22
- },
23
- )
24
-
25
-
26
- def build_dataset(
27
- config: dict,
28
- required_modalities: set[str],
29
- required_hierarchical: set[str] | None = None,
30
- ):
31
- """Build a ``MultiModalDataset`` from a config dict.
32
-
33
- Each modality entry is either a plain path string or a dict with
34
- ``path`` and optional ``split``. Loader resolution (which function to
35
- call, which module to use) is handled by euler-loading via the
36
- ds-crawler index at each path — point the config at a path whose
37
- index declares the function you want (e.g. a ``sky_mask`` index for
38
- boolean sky masks vs. a ``class_segmentation`` index for raw class
39
- id maps).
40
- """
41
- from euler_loading import MultiModalDataset, SamplePreprocessor
42
-
43
- raw_modalities = config.get("modalities", {})
44
- raw_hierarchical = config.get("hierarchical_modalities", {})
45
-
46
- missing = required_modalities - raw_modalities.keys()
47
- if missing:
48
- raise ValueError(
49
- f"Missing required modalities in config: {', '.join(sorted(missing))}. "
50
- f"'modalities' must contain at least: {', '.join(sorted(required_modalities))}"
51
- )
52
-
53
- if required_hierarchical:
54
- missing_h = required_hierarchical - raw_hierarchical.keys()
55
- if missing_h:
56
- raise ValueError(
57
- f"Missing required hierarchical modalities in config: "
58
- f"{', '.join(sorted(missing_h))}. 'hierarchical_modalities' must "
59
- f"contain at least: {', '.join(sorted(required_hierarchical))}"
60
- )
61
-
62
- modalities = {name: _make_modality(entry) for name, entry in raw_modalities.items()}
63
- hierarchical_modalities = {
64
- name: _make_modality(entry) for name, entry in raw_hierarchical.items()
65
- }
66
-
67
- return MultiModalDataset(
68
- modalities=modalities,
69
- hierarchical_modalities=hierarchical_modalities or None,
70
- transforms=[SamplePreprocessor.from_config(config["preprocessing"])]
71
- if config.get("preprocessing")
72
- else None,
73
- )