euler-preprocess 3.15.0__tar.gz → 3.17.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 (68) hide show
  1. {euler_preprocess-3.15.0 → euler_preprocess-3.17.0}/PKG-INFO +30 -12
  2. euler_preprocess-3.15.0/euler_preprocess.egg-info/PKG-INFO → euler_preprocess-3.17.0/README.md +28 -30
  3. {euler_preprocess-3.15.0 → euler_preprocess-3.17.0}/euler_preprocess/cli.py +8 -1
  4. euler_preprocess-3.17.0/euler_preprocess/common/dataset.py +224 -0
  5. {euler_preprocess-3.15.0 → euler_preprocess-3.17.0}/euler_preprocess/common/intrinsics.py +3 -2
  6. {euler_preprocess-3.15.0 → euler_preprocess-3.17.0}/euler_preprocess/common/normalize.py +14 -1
  7. {euler_preprocess-3.15.0 → euler_preprocess-3.17.0}/euler_preprocess/common/transform.py +14 -0
  8. {euler_preprocess-3.15.0 → euler_preprocess-3.17.0}/euler_preprocess/fog/atmospheric_light.py +2 -2
  9. {euler_preprocess-3.15.0 → euler_preprocess-3.17.0}/euler_preprocess/fog/inference.py +4 -3
  10. {euler_preprocess-3.15.0 → euler_preprocess-3.17.0}/euler_preprocess/fog/logging.py +25 -0
  11. {euler_preprocess-3.15.0 → euler_preprocess-3.17.0}/euler_preprocess/fog/models.py +23 -5
  12. {euler_preprocess-3.15.0 → euler_preprocess-3.17.0}/euler_preprocess/fog/transform.py +42 -2
  13. {euler_preprocess-3.15.0 → euler_preprocess-3.17.0}/euler_preprocess/radial/transform.py +3 -3
  14. {euler_preprocess-3.15.0 → euler_preprocess-3.17.0}/euler_preprocess/sky_depth/transform.py +2 -2
  15. {euler_preprocess-3.15.0 → euler_preprocess-3.17.0}/euler_preprocess/spatial.py +9 -1
  16. euler_preprocess-3.15.0/README.md → euler_preprocess-3.17.0/euler_preprocess.egg-info/PKG-INFO +48 -10
  17. {euler_preprocess-3.15.0 → euler_preprocess-3.17.0}/euler_preprocess.egg-info/SOURCES.txt +2 -0
  18. {euler_preprocess-3.15.0 → euler_preprocess-3.17.0}/euler_preprocess.egg-info/requires.txt +1 -1
  19. {euler_preprocess-3.15.0 → euler_preprocess-3.17.0}/pyproject.toml +2 -2
  20. {euler_preprocess-3.15.0 → euler_preprocess-3.17.0}/tests/test_fog_aux_outputs.py +2 -0
  21. euler_preprocess-3.17.0/tests/test_fog_input_normalization.py +259 -0
  22. euler_preprocess-3.17.0/tests/test_modality_roles.py +192 -0
  23. {euler_preprocess-3.15.0 → euler_preprocess-3.17.0}/tests/test_sky_depth.py +11 -0
  24. {euler_preprocess-3.15.0 → euler_preprocess-3.17.0}/tests/test_spatial.py +11 -0
  25. euler_preprocess-3.15.0/euler_preprocess/common/dataset.py +0 -73
  26. {euler_preprocess-3.15.0 → euler_preprocess-3.17.0}/CHANGELOG.md +0 -0
  27. {euler_preprocess-3.15.0 → euler_preprocess-3.17.0}/LICENSE +0 -0
  28. {euler_preprocess-3.15.0 → euler_preprocess-3.17.0}/MANIFEST.in +0 -0
  29. {euler_preprocess-3.15.0 → euler_preprocess-3.17.0}/docs/spatial.md +0 -0
  30. {euler_preprocess-3.15.0 → euler_preprocess-3.17.0}/euler_preprocess/__init__.py +0 -0
  31. {euler_preprocess-3.15.0 → euler_preprocess-3.17.0}/euler_preprocess/common/__init__.py +0 -0
  32. {euler_preprocess-3.15.0 → euler_preprocess-3.17.0}/euler_preprocess/common/color.py +0 -0
  33. {euler_preprocess-3.15.0 → euler_preprocess-3.17.0}/euler_preprocess/common/device.py +0 -0
  34. {euler_preprocess-3.15.0 → euler_preprocess-3.17.0}/euler_preprocess/common/io.py +0 -0
  35. {euler_preprocess-3.15.0 → euler_preprocess-3.17.0}/euler_preprocess/common/logging.py +0 -0
  36. {euler_preprocess-3.15.0 → euler_preprocess-3.17.0}/euler_preprocess/common/noise.py +0 -0
  37. {euler_preprocess-3.15.0 → euler_preprocess-3.17.0}/euler_preprocess/common/output.py +0 -0
  38. {euler_preprocess-3.15.0 → euler_preprocess-3.17.0}/euler_preprocess/common/sampling.py +0 -0
  39. {euler_preprocess-3.15.0 → euler_preprocess-3.17.0}/euler_preprocess/fog/__init__.py +0 -0
  40. {euler_preprocess-3.15.0 → euler_preprocess-3.17.0}/euler_preprocess/fog/airlight_from_sky.py +0 -0
  41. {euler_preprocess-3.15.0 → euler_preprocess-3.17.0}/euler_preprocess/fog/augmentations.py +0 -0
  42. {euler_preprocess-3.15.0 → euler_preprocess-3.17.0}/euler_preprocess/fog/capture.py +0 -0
  43. {euler_preprocess-3.15.0 → euler_preprocess-3.17.0}/euler_preprocess/fog/dcp_airlight.py +0 -0
  44. {euler_preprocess-3.15.0 → euler_preprocess-3.17.0}/euler_preprocess/fog/dcp_airlight_torch.py +0 -0
  45. {euler_preprocess-3.15.0 → euler_preprocess-3.17.0}/euler_preprocess/fog/dcp_heuristic_airlight.py +0 -0
  46. {euler_preprocess-3.15.0 → euler_preprocess-3.17.0}/euler_preprocess/fog/dcp_heuristic_airlight_torch.py +0 -0
  47. {euler_preprocess-3.15.0 → euler_preprocess-3.17.0}/euler_preprocess/fog/pipeline.py +0 -0
  48. {euler_preprocess-3.15.0 → euler_preprocess-3.17.0}/euler_preprocess/radial/__init__.py +0 -0
  49. {euler_preprocess-3.15.0 → euler_preprocess-3.17.0}/euler_preprocess/sky_depth/__init__.py +0 -0
  50. {euler_preprocess-3.15.0 → euler_preprocess-3.17.0}/euler_preprocess.egg-info/dependency_links.txt +0 -0
  51. {euler_preprocess-3.15.0 → euler_preprocess-3.17.0}/euler_preprocess.egg-info/entry_points.txt +0 -0
  52. {euler_preprocess-3.15.0 → euler_preprocess-3.17.0}/euler_preprocess.egg-info/top_level.txt +0 -0
  53. {euler_preprocess-3.15.0 → euler_preprocess-3.17.0}/examples/spatial-five-field.json +0 -0
  54. {euler_preprocess-3.15.0 → euler_preprocess-3.17.0}/setup.cfg +0 -0
  55. {euler_preprocess-3.15.0 → euler_preprocess-3.17.0}/tests/test_airlight_fallback.py +0 -0
  56. {euler_preprocess-3.15.0 → euler_preprocess-3.17.0}/tests/test_cli_sample_selection.py +0 -0
  57. {euler_preprocess-3.15.0 → euler_preprocess-3.17.0}/tests/test_dcp_heuristic_airlight.py +0 -0
  58. {euler_preprocess-3.15.0 → euler_preprocess-3.17.0}/tests/test_dense_gloomy_daylight_config.py +0 -0
  59. {euler_preprocess-3.15.0 → euler_preprocess-3.17.0}/tests/test_fog_aware_auto_exposure.py +0 -0
  60. {euler_preprocess-3.15.0 → euler_preprocess-3.17.0}/tests/test_fog_cpu_gpu_parity.py +0 -0
  61. {euler_preprocess-3.15.0 → euler_preprocess-3.17.0}/tests/test_fog_integration.py +0 -0
  62. {euler_preprocess-3.15.0 → euler_preprocess-3.17.0}/tests/test_radial.py +0 -0
  63. {euler_preprocess-3.15.0 → euler_preprocess-3.17.0}/tests/test_real_drive_sim_scenario_profiles.py +0 -0
  64. {euler_preprocess-3.15.0 → euler_preprocess-3.17.0}/tests/test_scene_illumination.py +0 -0
  65. {euler_preprocess-3.15.0 → euler_preprocess-3.17.0}/tests/test_sensor_identity.py +0 -0
  66. {euler_preprocess-3.15.0 → euler_preprocess-3.17.0}/tests/test_source_backed_output.py +0 -0
  67. {euler_preprocess-3.15.0 → euler_preprocess-3.17.0}/tests/test_tone_map_lut.py +0 -0
  68. {euler_preprocess-3.15.0 → euler_preprocess-3.17.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.17.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
@@ -9,7 +9,7 @@ Description-Content-Type: text/markdown
9
9
  License-File: LICENSE
10
10
  Requires-Dist: numpy
11
11
  Requires-Dist: Pillow
12
- Requires-Dist: euler-loading>=2.24.0
12
+ Requires-Dist: euler-loading>=2.29.0
13
13
  Requires-Dist: ds-crawler>=2.11.0
14
14
  Requires-Dist: euler-dataset-contract>=0.5.0
15
15
  Provides-Extra: gpu
@@ -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,36 @@ 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
+ `fog` and `sky-depth` need `semantic_segmentation` as a boolean sky mask. They load
123
+ it with the `sky_mask` function of the euler-loading module that the index's
124
+ dataset head names, so a `class_segmentation` index works as is: the sky is the
125
+ head's `skyclass` (Synscapes `[23, 23, 23]`, Real Drive Sim `[29, 0, 0]`, VKITTI2
126
+ `[90, 200, 255]`), and the log names the loader and the sky definition in use.
127
+ Set `"function"` in the modality entry to use another loader function, or
128
+ `"function": null` for the one the head declares. A non-binary mask raises an
129
+ error rather than marking every non-zero class as sky.
130
+
131
+ **Per-sample vs. hierarchical modalities.** You don't declare a modality's role:
132
+ it is inferred from the ds-crawler indexes. A modality whose qualified IDs
133
+ (hierarchy path plus file ID) match the transform's per-sample inputs is joined per
134
+ sample, e.g. Synscapes' per-image `meta/<id>.json` intrinsics. A modality whose
135
+ files share no sample IDs but sit at or above the samples' hierarchy level is
136
+ matched hierarchically and shared by every sample beneath it, e.g. one intrinsics
137
+ file per scene or camera. A modality that is neither raises an error, and the
138
+ inferred roles are logged. Listing a per-sample modality under
139
+ `hierarchical_modalities` logs a warning and loads it per sample.
123
140
 
124
141
  When a modality directory contains ds-crawler split files
125
142
  (`.ds_crawler/split_<name>.json`), set `split` on that modality to select a subset.
@@ -659,7 +676,8 @@ where sky depth is encoded as zero or infinity and needs a large finite value.
659
676
  }
660
677
  ```
661
678
 
662
- `sky_depth_value` defaults to `1000.0`.
679
+ `sky_depth_value` defaults to `1000.0`. The sky mask comes from the dataset head as
680
+ described under [Dataset Config](#dataset-config).
663
681
 
664
682
  CLI runs write a source-backed depth dataset mirroring the input depth modality's paths,
665
683
  filenames, extensions, and metadata. Standalone `SkyDepthTransform(...)` usage keeps the
@@ -677,7 +695,7 @@ d_radial(u, v) = d_planar(u, v) * sqrt(((u - cx)/fx)^2 + ((v - cy)/fy)^2 + 1)
677
695
  ```
678
696
 
679
697
  The transform config takes no parameters (`{}`); intrinsics are read from the
680
- `intrinsics` hierarchical modality.
698
+ `intrinsics` modality, stored either per sample or per scene/camera.
681
699
 
682
700
  CLI runs write a source-backed depth dataset mirroring the input depth modality's layout
683
701
  and writer metadata, with `meta.radial_depth` set to `true` in the emitted `index.json`.
@@ -1,23 +1,3 @@
1
- Metadata-Version: 2.4
2
- Name: euler-preprocess
3
- Version: 3.15.0
4
- Summary: Physics-based preprocessing (fog, etc.) for RGB+depth datasets
5
- Author-email: Daniel Rothenpieler <rothenpielerdaniel@gmail.com>
6
- License-Expression: MIT
7
- Requires-Python: >=3.9
8
- Description-Content-Type: text/markdown
9
- License-File: LICENSE
10
- Requires-Dist: numpy
11
- Requires-Dist: Pillow
12
- Requires-Dist: euler-loading>=2.24.0
13
- Requires-Dist: ds-crawler>=2.11.0
14
- Requires-Dist: euler-dataset-contract>=0.5.0
15
- Provides-Extra: gpu
16
- Requires-Dist: torch; extra == "gpu"
17
- Provides-Extra: progress
18
- Requires-Dist: tqdm; extra == "progress"
19
- Dynamic: license-file
20
-
21
1
  <!-- euler header — shared across the euler packages.
22
2
  Per package, change only: the <h1>, the tagline, and the badge URLs. -->
23
3
  <p align="center">
@@ -94,9 +74,7 @@ loader.
94
74
  "modalities": {
95
75
  "rgb": {"path": "/path/to/rgb", "split": "train"},
96
76
  "depth": "/path/to/depth",
97
- "semantic_segmentation": "/path/to/classSegmentation"
98
- },
99
- "hierarchical_modalities": {
77
+ "semantic_segmentation": "/path/to/classSegmentation",
100
78
  "intrinsics": {"path": "/path/to/intrinsics"}
101
79
  }
102
80
  }
@@ -109,17 +87,36 @@ loader.
109
87
  | `output_slot` | Optional slot selector when `pipeline.output_targets` has several entries. Defaults to `rgb` for `fog` and `depth` for `sky-depth` / `radial`. |
110
88
  | `sample` | Optional 0-based dataset index. Only `dataset[sample]` is transformed — useful for small benchmark slices of large datasets. |
111
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`. |
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. |
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. |
114
92
  | `pipeline` | Optional runtime routing block compatible with `euler-inference`. |
115
93
 
116
94
  **Required modalities per transform:**
117
95
 
118
- | Transform | `modalities` | `hierarchical_modalities` |
96
+ | Transform | Required | Optional |
119
97
  |---|---|---|
120
- | `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 |
121
99
  | `sky-depth` | `depth`, `semantic_segmentation` | — |
122
- | `radial` | `depth` | `intrinsics` |
100
+ | `radial` | `depth`, `intrinsics` | — |
101
+
102
+ `fog` and `sky-depth` need `semantic_segmentation` as a boolean sky mask. They load
103
+ it with the `sky_mask` function of the euler-loading module that the index's
104
+ dataset head names, so a `class_segmentation` index works as is: the sky is the
105
+ head's `skyclass` (Synscapes `[23, 23, 23]`, Real Drive Sim `[29, 0, 0]`, VKITTI2
106
+ `[90, 200, 255]`), and the log names the loader and the sky definition in use.
107
+ Set `"function"` in the modality entry to use another loader function, or
108
+ `"function": null` for the one the head declares. A non-binary mask raises an
109
+ error rather than marking every non-zero class as sky.
110
+
111
+ **Per-sample vs. hierarchical modalities.** You don't declare a modality's role:
112
+ it is inferred from the ds-crawler indexes. A modality whose qualified IDs
113
+ (hierarchy path plus file ID) match the transform's per-sample inputs is joined per
114
+ sample, e.g. Synscapes' per-image `meta/<id>.json` intrinsics. A modality whose
115
+ files share no sample IDs but sit at or above the samples' hierarchy level is
116
+ matched hierarchically and shared by every sample beneath it, e.g. one intrinsics
117
+ file per scene or camera. A modality that is neither raises an error, and the
118
+ inferred roles are logged. Listing a per-sample modality under
119
+ `hierarchical_modalities` logs a warning and loads it per sample.
123
120
 
124
121
  When a modality directory contains ds-crawler split files
125
122
  (`.ds_crawler/split_<name>.json`), set `split` on that modality to select a subset.
@@ -659,7 +656,8 @@ where sky depth is encoded as zero or infinity and needs a large finite value.
659
656
  }
660
657
  ```
661
658
 
662
- `sky_depth_value` defaults to `1000.0`.
659
+ `sky_depth_value` defaults to `1000.0`. The sky mask comes from the dataset head as
660
+ described under [Dataset Config](#dataset-config).
663
661
 
664
662
  CLI runs write a source-backed depth dataset mirroring the input depth modality's paths,
665
663
  filenames, extensions, and metadata. Standalone `SkyDepthTransform(...)` usage keeps the
@@ -677,7 +675,7 @@ d_radial(u, v) = d_planar(u, v) * sqrt(((u - cx)/fx)^2 + ((v - cy)/fy)^2 + 1)
677
675
  ```
678
676
 
679
677
  The transform config takes no parameters (`{}`); intrinsics are read from the
680
- `intrinsics` hierarchical modality.
678
+ `intrinsics` modality, stored either per sample or per scene/camera.
681
679
 
682
680
  CLI runs write a source-backed depth dataset mirroring the input depth modality's layout
683
681
  and writer metadata, with `meta.radial_depth` set to `true` in the emitted `index.json`.
@@ -178,7 +178,14 @@ def _run_transform(args: argparse.Namespace, transform_class: type) -> int:
178
178
 
179
179
  required_modalities = transform_class.REQUIRED_MODALITIES
180
180
  required_hierarchical = transform_class.REQUIRED_HIERARCHICAL_MODALITIES or None
181
- dataset = build_dataset(config, required_modalities, required_hierarchical)
181
+ # euler-loading logs which loader (and sky definition) each modality uses.
182
+ get_logger("euler_loading")
183
+ dataset = build_dataset(
184
+ config,
185
+ required_modalities,
186
+ required_hierarchical,
187
+ transform_class.MODALITY_FUNCTIONS,
188
+ )
182
189
  output_backends = prepare_output_backends(config, dataset, transform_class)
183
190
  primary_backend = next(iter(output_backends.values()))
184
191
  samples = _select_configured_samples(config, dataset, logger)
@@ -0,0 +1,224 @@
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, function: str | None = None):
9
+ """Build a ``Modality``; an entry's own ``function`` replaces *function*."""
10
+ from euler_loading import Modality
11
+
12
+ if isinstance(entry, str):
13
+ return Modality(entry, function=function)
14
+ options = {
15
+ key: entry[key]
16
+ for key in (
17
+ "split",
18
+ "metadata_scope",
19
+ "collapse_single",
20
+ "cache",
21
+ "applies_to",
22
+ "hierarchy_scope",
23
+ "function",
24
+ )
25
+ if key in entry
26
+ }
27
+ options.setdefault("function", function)
28
+ return Modality(entry["path"], **options)
29
+
30
+
31
+ def _load_index_tree(name: str, modality) -> dict:
32
+ """Load a modality's ds-crawler tree the same way ``MultiModalDataset`` does."""
33
+ from ds_crawler import index_dataset_from_path
34
+ from euler_loading._ds_crawler_utils import load_index_output
35
+ from euler_loading.dataset import _get_index_tree, _resolve_metadata_scope
36
+
37
+ modality = _resolve_metadata_scope(name, modality)
38
+ index = load_index_output(
39
+ modality.path,
40
+ split=modality.split,
41
+ metadata_scope=modality.metadata_scope,
42
+ index_dataset_from_path_fn=index_dataset_from_path,
43
+ )
44
+ return _get_index_tree(index)
45
+
46
+
47
+ def _check_attachment(
48
+ name: str, seed: str, tree: dict, samples_per_level: Counter
49
+ ) -> None:
50
+ """Check that hierarchical modality *name* applies to the samples by tree position."""
51
+ from euler_loading.indexing import collect_hierarchical_files, match_hierarchical_files
52
+
53
+ files_by_level = collect_hierarchical_files(tree)
54
+ attached = {
55
+ level: len(match_hierarchical_files(level, files_by_level))
56
+ for level in samples_per_level
57
+ }
58
+ covered = sum(count for level, count in samples_per_level.items() if attached[level])
59
+ if not covered:
60
+ raise ValueError(
61
+ f"Modality '{name}' shares no sample IDs with '{seed}' and none of its "
62
+ "files sit at or above the hierarchy level of any sample, so it can be "
63
+ "neither joined per sample nor matched hierarchically. Check that its "
64
+ f"ds-crawler IDs and hierarchy follow the same conventions as '{seed}'."
65
+ )
66
+ logger = get_logger()
67
+ total = sum(samples_per_level.values())
68
+ log = logger.info if covered == total else logger.warning
69
+ log(
70
+ "Modality '%s' is hierarchical (no sample IDs shared with '%s'); it applies "
71
+ "to %d/%d samples by hierarchy position.",
72
+ name,
73
+ seed,
74
+ covered,
75
+ total,
76
+ )
77
+ most = max(attached.values())
78
+ if most > 1:
79
+ logger.warning(
80
+ "Hierarchical modality '%s' attaches up to %d files to one sample. If it "
81
+ "holds per-sample data, its ds-crawler IDs do not match '%s'.",
82
+ name,
83
+ most,
84
+ seed,
85
+ )
86
+
87
+
88
+ def _resolve_roles(
89
+ modalities: dict, per_sample: set[str]
90
+ ) -> tuple[list[str], list[str]]:
91
+ """Split modality names into regular and hierarchical ones.
92
+
93
+ The role follows from the ds-crawler indexes. A modality is regular
94
+ (joined per sample) when its qualified file IDs -- hierarchy path plus
95
+ file ID -- overlap those of the seed modality. Otherwise it is
96
+ hierarchical (matched by tree position, like per-scene intrinsics) and
97
+ must sit at or above the hierarchy level of at least one sample.
98
+
99
+ *per_sample* modalities are regular by contract and the first one is the
100
+ seed. Without one, the seed is the modality aligned with the most others,
101
+ then the one with the most files, then the first listed.
102
+ """
103
+ from euler_loading.indexing import collect_files
104
+
105
+ names = list(modalities)
106
+ if len(names) < 2 or per_sample.issuperset(names):
107
+ return names, []
108
+
109
+ trees = {name: _load_index_tree(name, modalities[name]) for name in names}
110
+ # Qualified sample ID -> hierarchy path, keyed like euler-loading's lookups.
111
+ sample_ids = {}
112
+ for name, tree in trees.items():
113
+ sample_ids[name] = {
114
+ "/".join(r.hierarchy_path + (r.file_entry["id"],)): r.hierarchy_path
115
+ for r in collect_files(tree)
116
+ }
117
+
118
+ def aligned(name: str, other: str) -> bool:
119
+ return not sample_ids[name].keys().isdisjoint(sample_ids[other].keys())
120
+
121
+ anchors = [name for name in names if name in per_sample]
122
+ seed = anchors[0] if anchors else max(
123
+ names,
124
+ key=lambda name: (
125
+ sum(aligned(other, name) for other in names),
126
+ len(sample_ids[name]),
127
+ ),
128
+ )
129
+ regular = [
130
+ name
131
+ for name in names
132
+ if name in per_sample or name == seed or aligned(name, seed)
133
+ ]
134
+ hierarchical = [name for name in names if name not in regular]
135
+ get_logger().info(
136
+ "Modality roles inferred from ds-crawler indexes: per-sample %s; "
137
+ "hierarchical %s",
138
+ ", ".join(regular),
139
+ ", ".join(hierarchical) or "none",
140
+ )
141
+
142
+ # Without common samples MultiModalDataset raises its own, clearer error.
143
+ common = set(sample_ids[seed]).intersection(*(sample_ids[name] for name in regular))
144
+ if common:
145
+ samples_per_level = Counter(sample_ids[seed][sample_id] for sample_id in common)
146
+ for name in hierarchical:
147
+ _check_attachment(name, seed, trees[name], samples_per_level)
148
+ return regular, hierarchical
149
+
150
+
151
+ def build_dataset(
152
+ config: dict,
153
+ required_modalities: set[str],
154
+ required_hierarchical: set[str] | None = None,
155
+ modality_functions: dict[str, str] | None = None,
156
+ ):
157
+ """Build a ``MultiModalDataset`` from a config dict.
158
+
159
+ Each modality entry is either a plain path string or a dict with
160
+ ``path`` and optional ``split``. Loader resolution (which function to
161
+ call, which module to use) is handled by euler-loading via the
162
+ ds-crawler index at each path — point the config at a path whose
163
+ index declares the function you want (e.g. a ``sky_mask`` index for
164
+ boolean sky masks vs. a ``class_segmentation`` index for raw class
165
+ id maps).
166
+
167
+ Every modality can be listed under ``modalities``: whether it is joined
168
+ per sample or matched hierarchically (e.g. per-scene intrinsics) is
169
+ inferred from the ds-crawler indexes. Entries under the legacy
170
+ ``hierarchical_modalities`` key are pooled with them and resolved the
171
+ same way. *required_modalities* are per-sample inputs and anchor that
172
+ inference; *required_hierarchical* names further required modalities,
173
+ whose role is inferred like any other.
174
+
175
+ *modality_functions* maps a modality to the loader function to use from
176
+ the module its dataset head names (a transform's ``MODALITY_FUNCTIONS``),
177
+ e.g. ``sky_mask`` for a ``class_segmentation`` index; the sky then comes
178
+ from the head's ``skyclass``. A modality entry's own ``function`` key
179
+ overrides it, and ``"function": null`` keeps the head's function.
180
+ """
181
+ from euler_loading import MultiModalDataset, SamplePreprocessor
182
+
183
+ raw_modalities = config.get("modalities", {})
184
+ raw_hierarchical = config.get("hierarchical_modalities", {})
185
+
186
+ duplicated = raw_modalities.keys() & raw_hierarchical.keys()
187
+ if duplicated:
188
+ raise ValueError(
189
+ "Modalities listed under both 'modalities' and "
190
+ f"'hierarchical_modalities': {', '.join(sorted(duplicated))}"
191
+ )
192
+ configured = {**raw_modalities, **raw_hierarchical}
193
+
194
+ required = set(required_modalities) | set(required_hierarchical or ())
195
+ missing = required - configured.keys()
196
+ if missing:
197
+ raise ValueError(
198
+ f"Missing required modalities in config: {', '.join(sorted(missing))}. "
199
+ f"'modalities' must contain at least: {', '.join(sorted(required))}"
200
+ )
201
+
202
+ functions = modality_functions or {}
203
+ modalities = {
204
+ name: _make_modality(entry, functions.get(name))
205
+ for name, entry in configured.items()
206
+ }
207
+ regular, hierarchical = _resolve_roles(modalities, set(required_modalities))
208
+ logger = get_logger()
209
+ for name in regular:
210
+ if name in raw_hierarchical:
211
+ logger.warning(
212
+ "Modality '%s' is listed under 'hierarchical_modalities' but holds "
213
+ "per-sample data; loading it per sample.",
214
+ name,
215
+ )
216
+
217
+ return MultiModalDataset(
218
+ modalities={name: modalities[name] for name in regular},
219
+ hierarchical_modalities={name: modalities[name] for name in hierarchical}
220
+ or None,
221
+ transforms=[SamplePreprocessor.from_config(config["preprocessing"])]
222
+ if config.get("preprocessing")
223
+ else None,
224
+ )
@@ -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:
@@ -98,7 +98,9 @@ def normalize_sky_mask(sky_mask) -> np.ndarray:
98
98
  """Normalise a sky mask to a 2-D boolean numpy array ``(H, W)``.
99
99
 
100
100
  Handles torch tensors (GPU loaders) and the ``(1, H, W)`` channel-first
101
- layout produced by euler-loading's GPU loaders.
101
+ layout produced by euler-loading's GPU loaders. A non-boolean input may
102
+ only hold 0/1: casting class IDs to bool would mark every non-zero class
103
+ as sky.
102
104
  """
103
105
  mask = _to_numpy(sky_mask)
104
106
  # CHW → take first channel: (C, H, W) → (H, W)
@@ -109,4 +111,15 @@ def normalize_sky_mask(sky_mask) -> np.ndarray:
109
111
  mask = mask[0]
110
112
  if mask.ndim == 3 and mask.shape[-1] == 1:
111
113
  mask = mask[..., 0]
114
+ if mask.dtype != np.bool_ and np.any((mask != 0) & (mask != 1)):
115
+ values = np.unique(mask)
116
+ shown = ", ".join(str(value) for value in values[:8])
117
+ if values.size > 8:
118
+ shown += ", ..."
119
+ raise ValueError(
120
+ "semantic_segmentation must be a boolean sky mask, got a "
121
+ f"{mask.dtype} array with values [{shown}]. Load it with the "
122
+ "dataset's euler-loading sky_mask loader (the CLI selects it "
123
+ "automatically) or pass a boolean mask."
124
+ )
112
125
  return mask.astype(bool)
@@ -12,6 +12,19 @@ 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
+ :attr:`MODALITY_FUNCTIONS` maps a modality to the euler-loading loader
24
+ function the transform needs, taken from the loader module its
25
+ dataset head names (``{"semantic_segmentation": "sky_mask"}`` reads a
26
+ class-segmentation index as sky masks).
27
+
15
28
  Output slots:
16
29
  Most transforms produce a single output (the *primary* slot, declared
17
30
  via :attr:`OUTPUT_SLOT`). Transforms that produce additional auxiliary
@@ -23,6 +36,7 @@ class Transform(ABC):
23
36
 
24
37
  REQUIRED_MODALITIES: ClassVar[set[str]] = set()
25
38
  REQUIRED_HIERARCHICAL_MODALITIES: ClassVar[set[str]] = set()
39
+ MODALITY_FUNCTIONS: ClassVar[dict[str, str]] = {}
26
40
  SOURCE_MODALITY: ClassVar[str | None] = None
27
41
  OUTPUT_SLOT: ClassVar[str | None] = None
28
42
  OUTPUT_SLOTS: ClassVar[tuple[str, ...]] = ()
@@ -124,7 +124,7 @@ class AtmosphericLightResolver:
124
124
  airlight_is_estimated = uses_estimated_airlight(al_spec)
125
125
  if airlight_is_estimated:
126
126
  raw_ls_base = normalize_atmospheric_light_torch(
127
- estimated_airlight_t
127
+ estimated_airlight_t, allow_8bit=False
128
128
  ).squeeze(0)
129
129
  else:
130
130
  sampled_al = sample_value(al_spec, rng)
@@ -156,7 +156,7 @@ class AtmosphericLightResolver:
156
156
  airlight_is_estimated = uses_estimated_airlight(al_spec)
157
157
  if airlight_is_estimated:
158
158
  ls_base = self._estimate_batch_torch(rgb_batch, items, device, method)
159
- ls_base = normalize_atmospheric_light_torch(ls_base)
159
+ ls_base = normalize_atmospheric_light_torch(ls_base, allow_8bit=False)
160
160
  else:
161
161
  ls_values = []
162
162
  for item in items:
@@ -58,7 +58,7 @@ def render_fog_image(
58
58
  mode: FogInferenceMode = "cpu",
59
59
  sample_id: str | None = None,
60
60
  sample_index: int = 0,
61
- sky_class: Sequence[int | float] | None = (29, 0, 0),
61
+ sky_class: Sequence[int | float] | None = None,
62
62
  ) -> np.ndarray:
63
63
  """Render and return only the RGB image for one in-memory sample."""
64
64
 
@@ -89,14 +89,15 @@ def render_fog_sample(
89
89
  mode: FogInferenceMode = "cpu",
90
90
  sample_id: str | None = None,
91
91
  sample_index: int = 0,
92
- sky_class: Sequence[int | float] | None = (29, 0, 0),
92
+ sky_class: Sequence[int | float] | None = None,
93
93
  ) -> FogInferenceResult:
94
94
  """Render fog/camera artifacts for a single in-memory sample.
95
95
 
96
96
  The call accepts the four modalities used by :class:`FogTransform` without
97
97
  requiring a dataset reader or output backend. ``semantic_segmentation`` may
98
98
  be either a boolean sky mask or an RGB semantic label map; RGB maps are
99
- converted using ``sky_class`` which defaults to ``[29, 0, 0]``.
99
+ converted using ``sky_class``, the map's sky colour as its dataset head
100
+ declares it in ``skyclass`` (e.g. ``[29, 0, 0]`` for Real Drive Sim).
100
101
  """
101
102
 
102
103
  if mode not in ("cpu", "gpu"):
@@ -1,6 +1,8 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import logging
4
+ from collections.abc import Sequence
5
+ from typing import Any
4
6
 
5
7
 
6
8
  def log_config(
@@ -18,7 +20,14 @@ def log_config(
18
20
  resize_depth: bool,
19
21
  seed,
20
22
  contrast_threshold_default: float,
23
+ scenarios: Sequence[tuple[str, Any, Any]] = (),
24
+ scenario_scope: str = "sample",
21
25
  ) -> None:
26
+ """Log the resolved run settings.
27
+
28
+ *scenarios* holds ``(name, weight, model)`` per scenario profile, with
29
+ ``model`` ``None`` when the profile defers to ``selection``.
30
+ """
22
31
  logger.info("Loaded config from %s -> output %s", config_path, out_path)
23
32
  logger.info(
24
33
  "Device: requested=%s active=%s use_gpu=%s torch=%s",
@@ -36,6 +45,22 @@ def log_config(
36
45
  seed,
37
46
  contrast_threshold_default,
38
47
  )
48
+ if scenarios:
49
+ logger.info(
50
+ "Model selection: %d scenario_profiles (scope=%s) choose the model; "
51
+ "'selection' only applies to profiles without one",
52
+ len(scenarios),
53
+ scenario_scope,
54
+ )
55
+ for name, weight, model in scenarios:
56
+ logger.info(
57
+ "Scenario %s: weight=%s model=%s",
58
+ name,
59
+ weight,
60
+ "<selection>" if model is None else model,
61
+ )
62
+ if all(model is not None for _name, _weight, model in scenarios):
63
+ return
39
64
  selection = config.get("selection")
40
65
  if selection:
41
66
  mode = selection.get("mode", "fixed")
@@ -160,18 +160,34 @@ def resolve_scattering_coefficient(
160
160
  )
161
161
 
162
162
 
163
- def normalize_atmospheric_light(value: np.ndarray) -> np.ndarray:
163
+ def normalize_atmospheric_light(
164
+ value: np.ndarray, *, allow_8bit: bool = True
165
+ ) -> np.ndarray:
166
+ """Return ``value`` as an RGB triple clipped to ``[0, 1]``.
167
+
168
+ With *allow_8bit*, values above 1 are read as an 8-bit colour and divided
169
+ by 255. That only suits configured colours: estimated airlight comes from
170
+ ``[0, 1]`` RGB and may overshoot 1 slightly after the estimators' luminance
171
+ rescaling, so estimate callers pass ``allow_8bit=False`` to clip instead.
172
+ """
164
173
  value = np.asarray(value, dtype=np.float32)
165
174
  if value.ndim == 0:
166
175
  value = np.array([value, value, value], dtype=np.float32)
167
176
  if value.ndim != 1 or value.shape[0] != 3:
168
177
  raise ValueError("atmospheric_light must be scalar or length-3 list")
169
- if value.max() > 1.0:
178
+ if allow_8bit and value.max() > 1.0:
170
179
  value = value / 255.0
171
180
  return np.clip(value, 0.0, 1.0)
172
181
 
173
182
 
174
- def normalize_atmospheric_light_torch(value: "torch.Tensor") -> "torch.Tensor":
183
+ def normalize_atmospheric_light_torch(
184
+ value: "torch.Tensor", *, allow_8bit: bool = True
185
+ ) -> "torch.Tensor":
186
+ """Torch counterpart of :func:`normalize_atmospheric_light`.
187
+
188
+ The 8-bit check uses the maximum over every row, so stacked estimates must
189
+ pass ``allow_8bit=False`` or one overshooting sample rescales the batch.
190
+ """
175
191
  value_t = value.to(torch.float32)
176
192
  if value_t.ndim == 0:
177
193
  value_t = value_t.repeat(3)
@@ -180,7 +196,7 @@ def normalize_atmospheric_light_torch(value: "torch.Tensor") -> "torch.Tensor":
180
196
  if value_t.ndim != 2 or value_t.shape[-1] != 3:
181
197
  raise ValueError("atmospheric_light must be scalar or length-3 list")
182
198
  max_val = float(value_t.max().item()) if value_t.numel() else 0.0
183
- if max_val > 1.0:
199
+ if allow_8bit and max_val > 1.0:
184
200
  value_t = value_t / 255.0
185
201
  return torch.clamp(value_t, 0.0, 1.0)
186
202
 
@@ -1653,7 +1669,9 @@ def apply_model(
1653
1669
  al_spec = model_cfg.get("atmospheric_light", "from_sky")
1654
1670
  airlight_is_estimated = uses_estimated_airlight(al_spec)
1655
1671
  if airlight_is_estimated:
1656
- raw_ls_base = normalize_atmospheric_light(estimated_airlight)
1672
+ raw_ls_base = normalize_atmospheric_light(
1673
+ estimated_airlight, allow_8bit=False
1674
+ )
1657
1675
  else:
1658
1676
  sampled_al = sample_value(al_spec, rng)
1659
1677
  raw_ls_base = normalize_atmospheric_light(np.asarray(sampled_al))