euler-preprocess 3.16.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 (67) hide show
  1. {euler_preprocess-3.16.0 → euler_preprocess-3.17.0}/PKG-INFO +13 -3
  2. euler_preprocess-3.16.0/euler_preprocess.egg-info/PKG-INFO → euler_preprocess-3.17.0/README.md +11 -21
  3. {euler_preprocess-3.16.0 → euler_preprocess-3.17.0}/euler_preprocess/cli.py +8 -1
  4. {euler_preprocess-3.16.0 → euler_preprocess-3.17.0}/euler_preprocess/common/dataset.py +30 -18
  5. {euler_preprocess-3.16.0 → euler_preprocess-3.17.0}/euler_preprocess/common/normalize.py +14 -1
  6. {euler_preprocess-3.16.0 → euler_preprocess-3.17.0}/euler_preprocess/common/transform.py +5 -0
  7. {euler_preprocess-3.16.0 → euler_preprocess-3.17.0}/euler_preprocess/fog/atmospheric_light.py +2 -2
  8. {euler_preprocess-3.16.0 → euler_preprocess-3.17.0}/euler_preprocess/fog/inference.py +4 -3
  9. {euler_preprocess-3.16.0 → euler_preprocess-3.17.0}/euler_preprocess/fog/logging.py +25 -0
  10. {euler_preprocess-3.16.0 → euler_preprocess-3.17.0}/euler_preprocess/fog/models.py +23 -5
  11. {euler_preprocess-3.16.0 → euler_preprocess-3.17.0}/euler_preprocess/fog/transform.py +39 -0
  12. {euler_preprocess-3.16.0 → euler_preprocess-3.17.0}/euler_preprocess/sky_depth/transform.py +2 -2
  13. euler_preprocess-3.16.0/README.md → euler_preprocess-3.17.0/euler_preprocess.egg-info/PKG-INFO +31 -1
  14. {euler_preprocess-3.16.0 → euler_preprocess-3.17.0}/euler_preprocess.egg-info/SOURCES.txt +1 -0
  15. {euler_preprocess-3.16.0 → euler_preprocess-3.17.0}/euler_preprocess.egg-info/requires.txt +1 -1
  16. {euler_preprocess-3.16.0 → euler_preprocess-3.17.0}/pyproject.toml +2 -2
  17. {euler_preprocess-3.16.0 → euler_preprocess-3.17.0}/tests/test_fog_aux_outputs.py +2 -0
  18. euler_preprocess-3.17.0/tests/test_fog_input_normalization.py +259 -0
  19. {euler_preprocess-3.16.0 → euler_preprocess-3.17.0}/tests/test_sky_depth.py +11 -0
  20. {euler_preprocess-3.16.0 → euler_preprocess-3.17.0}/CHANGELOG.md +0 -0
  21. {euler_preprocess-3.16.0 → euler_preprocess-3.17.0}/LICENSE +0 -0
  22. {euler_preprocess-3.16.0 → euler_preprocess-3.17.0}/MANIFEST.in +0 -0
  23. {euler_preprocess-3.16.0 → euler_preprocess-3.17.0}/docs/spatial.md +0 -0
  24. {euler_preprocess-3.16.0 → euler_preprocess-3.17.0}/euler_preprocess/__init__.py +0 -0
  25. {euler_preprocess-3.16.0 → euler_preprocess-3.17.0}/euler_preprocess/common/__init__.py +0 -0
  26. {euler_preprocess-3.16.0 → euler_preprocess-3.17.0}/euler_preprocess/common/color.py +0 -0
  27. {euler_preprocess-3.16.0 → euler_preprocess-3.17.0}/euler_preprocess/common/device.py +0 -0
  28. {euler_preprocess-3.16.0 → euler_preprocess-3.17.0}/euler_preprocess/common/intrinsics.py +0 -0
  29. {euler_preprocess-3.16.0 → euler_preprocess-3.17.0}/euler_preprocess/common/io.py +0 -0
  30. {euler_preprocess-3.16.0 → euler_preprocess-3.17.0}/euler_preprocess/common/logging.py +0 -0
  31. {euler_preprocess-3.16.0 → euler_preprocess-3.17.0}/euler_preprocess/common/noise.py +0 -0
  32. {euler_preprocess-3.16.0 → euler_preprocess-3.17.0}/euler_preprocess/common/output.py +0 -0
  33. {euler_preprocess-3.16.0 → euler_preprocess-3.17.0}/euler_preprocess/common/sampling.py +0 -0
  34. {euler_preprocess-3.16.0 → euler_preprocess-3.17.0}/euler_preprocess/fog/__init__.py +0 -0
  35. {euler_preprocess-3.16.0 → euler_preprocess-3.17.0}/euler_preprocess/fog/airlight_from_sky.py +0 -0
  36. {euler_preprocess-3.16.0 → euler_preprocess-3.17.0}/euler_preprocess/fog/augmentations.py +0 -0
  37. {euler_preprocess-3.16.0 → euler_preprocess-3.17.0}/euler_preprocess/fog/capture.py +0 -0
  38. {euler_preprocess-3.16.0 → euler_preprocess-3.17.0}/euler_preprocess/fog/dcp_airlight.py +0 -0
  39. {euler_preprocess-3.16.0 → euler_preprocess-3.17.0}/euler_preprocess/fog/dcp_airlight_torch.py +0 -0
  40. {euler_preprocess-3.16.0 → euler_preprocess-3.17.0}/euler_preprocess/fog/dcp_heuristic_airlight.py +0 -0
  41. {euler_preprocess-3.16.0 → euler_preprocess-3.17.0}/euler_preprocess/fog/dcp_heuristic_airlight_torch.py +0 -0
  42. {euler_preprocess-3.16.0 → euler_preprocess-3.17.0}/euler_preprocess/fog/pipeline.py +0 -0
  43. {euler_preprocess-3.16.0 → euler_preprocess-3.17.0}/euler_preprocess/radial/__init__.py +0 -0
  44. {euler_preprocess-3.16.0 → euler_preprocess-3.17.0}/euler_preprocess/radial/transform.py +0 -0
  45. {euler_preprocess-3.16.0 → euler_preprocess-3.17.0}/euler_preprocess/sky_depth/__init__.py +0 -0
  46. {euler_preprocess-3.16.0 → euler_preprocess-3.17.0}/euler_preprocess/spatial.py +0 -0
  47. {euler_preprocess-3.16.0 → euler_preprocess-3.17.0}/euler_preprocess.egg-info/dependency_links.txt +0 -0
  48. {euler_preprocess-3.16.0 → euler_preprocess-3.17.0}/euler_preprocess.egg-info/entry_points.txt +0 -0
  49. {euler_preprocess-3.16.0 → euler_preprocess-3.17.0}/euler_preprocess.egg-info/top_level.txt +0 -0
  50. {euler_preprocess-3.16.0 → euler_preprocess-3.17.0}/examples/spatial-five-field.json +0 -0
  51. {euler_preprocess-3.16.0 → euler_preprocess-3.17.0}/setup.cfg +0 -0
  52. {euler_preprocess-3.16.0 → euler_preprocess-3.17.0}/tests/test_airlight_fallback.py +0 -0
  53. {euler_preprocess-3.16.0 → euler_preprocess-3.17.0}/tests/test_cli_sample_selection.py +0 -0
  54. {euler_preprocess-3.16.0 → euler_preprocess-3.17.0}/tests/test_dcp_heuristic_airlight.py +0 -0
  55. {euler_preprocess-3.16.0 → euler_preprocess-3.17.0}/tests/test_dense_gloomy_daylight_config.py +0 -0
  56. {euler_preprocess-3.16.0 → euler_preprocess-3.17.0}/tests/test_fog_aware_auto_exposure.py +0 -0
  57. {euler_preprocess-3.16.0 → euler_preprocess-3.17.0}/tests/test_fog_cpu_gpu_parity.py +0 -0
  58. {euler_preprocess-3.16.0 → euler_preprocess-3.17.0}/tests/test_fog_integration.py +0 -0
  59. {euler_preprocess-3.16.0 → euler_preprocess-3.17.0}/tests/test_modality_roles.py +0 -0
  60. {euler_preprocess-3.16.0 → euler_preprocess-3.17.0}/tests/test_radial.py +0 -0
  61. {euler_preprocess-3.16.0 → euler_preprocess-3.17.0}/tests/test_real_drive_sim_scenario_profiles.py +0 -0
  62. {euler_preprocess-3.16.0 → euler_preprocess-3.17.0}/tests/test_scene_illumination.py +0 -0
  63. {euler_preprocess-3.16.0 → euler_preprocess-3.17.0}/tests/test_sensor_identity.py +0 -0
  64. {euler_preprocess-3.16.0 → euler_preprocess-3.17.0}/tests/test_source_backed_output.py +0 -0
  65. {euler_preprocess-3.16.0 → euler_preprocess-3.17.0}/tests/test_spatial.py +0 -0
  66. {euler_preprocess-3.16.0 → euler_preprocess-3.17.0}/tests/test_tone_map_lut.py +0 -0
  67. {euler_preprocess-3.16.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.16.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
@@ -119,6 +119,15 @@ loader.
119
119
  | `sky-depth` | `depth`, `semantic_segmentation` | — |
120
120
  | `radial` | `depth`, `intrinsics` | — |
121
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
+
122
131
  **Per-sample vs. hierarchical modalities.** You don't declare a modality's role:
123
132
  it is inferred from the ds-crawler indexes. A modality whose qualified IDs
124
133
  (hierarchy path plus file ID) match the transform's per-sample inputs is joined per
@@ -667,7 +676,8 @@ where sky depth is encoded as zero or infinity and needs a large finite value.
667
676
  }
668
677
  ```
669
678
 
670
- `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).
671
681
 
672
682
  CLI runs write a source-backed depth dataset mirroring the input depth modality's paths,
673
683
  filenames, extensions, and metadata. Standalone `SkyDepthTransform(...)` usage keeps the
@@ -1,23 +1,3 @@
1
- Metadata-Version: 2.4
2
- Name: euler-preprocess
3
- Version: 3.16.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">
@@ -119,6 +99,15 @@ loader.
119
99
  | `sky-depth` | `depth`, `semantic_segmentation` | — |
120
100
  | `radial` | `depth`, `intrinsics` | — |
121
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
+
122
111
  **Per-sample vs. hierarchical modalities.** You don't declare a modality's role:
123
112
  it is inferred from the ds-crawler indexes. A modality whose qualified IDs
124
113
  (hierarchy path plus file ID) match the transform's per-sample inputs is joined per
@@ -667,7 +656,8 @@ where sky depth is encoded as zero or infinity and needs a large finite value.
667
656
  }
668
657
  ```
669
658
 
670
- `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).
671
661
 
672
662
  CLI runs write a source-backed depth dataset mirroring the input depth modality's paths,
673
663
  filenames, extensions, and metadata. Standalone `SkyDepthTransform(...)` usage keeps the
@@ -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)
@@ -5,26 +5,27 @@ from collections import Counter
5
5
  from euler_preprocess.common.logging import get_logger
6
6
 
7
7
 
8
- def _make_modality(entry: str | dict):
8
+ def _make_modality(entry: str | dict, function: str | None = None):
9
+ """Build a ``Modality``; an entry's own ``function`` replaces *function*."""
9
10
  from euler_loading import Modality
10
11
 
11
12
  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
- )
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)
28
29
 
29
30
 
30
31
  def _load_index_tree(name: str, modality) -> dict:
@@ -151,6 +152,7 @@ def build_dataset(
151
152
  config: dict,
152
153
  required_modalities: set[str],
153
154
  required_hierarchical: set[str] | None = None,
155
+ modality_functions: dict[str, str] | None = None,
154
156
  ):
155
157
  """Build a ``MultiModalDataset`` from a config dict.
156
158
 
@@ -169,6 +171,12 @@ def build_dataset(
169
171
  same way. *required_modalities* are per-sample inputs and anchor that
170
172
  inference; *required_hierarchical* names further required modalities,
171
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.
172
180
  """
173
181
  from euler_loading import MultiModalDataset, SamplePreprocessor
174
182
 
@@ -191,7 +199,11 @@ def build_dataset(
191
199
  f"'modalities' must contain at least: {', '.join(sorted(required))}"
192
200
  )
193
201
 
194
- modalities = {name: _make_modality(entry) for name, entry in configured.items()}
202
+ functions = modality_functions or {}
203
+ modalities = {
204
+ name: _make_modality(entry, functions.get(name))
205
+ for name, entry in configured.items()
206
+ }
195
207
  regular, hierarchical = _resolve_roles(modalities, set(required_modalities))
196
208
  logger = get_logger()
197
209
  for name in regular:
@@ -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)
@@ -20,6 +20,10 @@ class Transform(ABC):
20
20
  store per sample or once per hierarchy level; their role is inferred
21
21
  from the ds-crawler indexes (see
22
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).
23
27
 
24
28
  Output slots:
25
29
  Most transforms produce a single output (the *primary* slot, declared
@@ -32,6 +36,7 @@ class Transform(ABC):
32
36
 
33
37
  REQUIRED_MODALITIES: ClassVar[set[str]] = set()
34
38
  REQUIRED_HIERARCHICAL_MODALITIES: ClassVar[set[str]] = set()
39
+ MODALITY_FUNCTIONS: ClassVar[dict[str, str]] = {}
35
40
  SOURCE_MODALITY: ClassVar[str | None] = None
36
41
  OUTPUT_SLOT: ClassVar[str | None] = None
37
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))
@@ -301,6 +301,7 @@ class FogTransform(Transform):
301
301
 
302
302
  REQUIRED_MODALITIES: ClassVar[set[str]] = {"rgb", "depth", "semantic_segmentation"}
303
303
  REQUIRED_HIERARCHICAL_MODALITIES: ClassVar[set[str]] = set()
304
+ MODALITY_FUNCTIONS: ClassVar[dict[str, str]] = {"semantic_segmentation": "sky_mask"}
304
305
  SOURCE_MODALITY: ClassVar[str] = "rgb"
305
306
  OUTPUT_SLOT: ClassVar[str] = "rgb"
306
307
  OUTPUT_SLOTS: ClassVar[tuple[str, ...]] = (
@@ -399,6 +400,8 @@ class FogTransform(Transform):
399
400
  self.resize_depth_flag,
400
401
  self.seed,
401
402
  self.contrast_threshold_default,
403
+ scenarios=self._scenario_log_entries(),
404
+ scenario_scope=self._scenario_scope(),
402
405
  )
403
406
 
404
407
  airlight_method = self.config.get("airlight")
@@ -664,6 +667,42 @@ class FogTransform(Transform):
664
667
  name = config.get("name", config.get("id"))
665
668
  return None if name is None else str(name)
666
669
 
670
+ def _scenario_log_entries(self) -> list[tuple[str, Any, Any]]:
671
+ """Return ``(name, weight, model)`` per scenario profile for the config log."""
672
+ entries: list[tuple[str, Any, Any]] = []
673
+ for index, profile in enumerate(self.scenario_profiles):
674
+ payload = self._scenario_payload(profile)
675
+ model = next(
676
+ (
677
+ payload[key]
678
+ for key in ("model", "model_name", "fog_model")
679
+ if key in payload
680
+ ),
681
+ None,
682
+ )
683
+ weight = profile.get(
684
+ "weight",
685
+ profile.get("profile_weight", profile.get("probability", 1.0)),
686
+ )
687
+ name = self._scenario_name(profile) or f"scenario_{index:03d}"
688
+ entries.append((name, weight, model))
689
+ return entries
690
+
691
+ def _scenario_scope(self) -> str:
692
+ """Describe how often a scenario profile is drawn, for the config log."""
693
+ if self.mode == "progressive":
694
+ return "progressive"
695
+ if (
696
+ self.use_gpu
697
+ and not self.augmentation_specs
698
+ and self.gpu_scenario_scope == "batch"
699
+ ):
700
+ return (
701
+ "batch, condition parameters per "
702
+ f"{self.gpu_condition_parameter_scope}"
703
+ )
704
+ return "sample"
705
+
667
706
  def _scenario_config_override(self, payload: dict[str, Any]) -> dict[str, Any]:
668
707
  return {
669
708
  key: value
@@ -31,6 +31,7 @@ class SkyDepthTransform(Transform):
31
31
  """
32
32
 
33
33
  REQUIRED_MODALITIES: ClassVar[set[str]] = {"depth", "semantic_segmentation"}
34
+ MODALITY_FUNCTIONS: ClassVar[dict[str, str]] = {"semantic_segmentation": "sky_mask"}
34
35
  SOURCE_MODALITY: ClassVar[str] = "depth"
35
36
  OUTPUT_SLOT: ClassVar[str] = "depth"
36
37
 
@@ -257,8 +258,7 @@ class SkyDepthTransform(Transform):
257
258
  log.warning(
258
259
  " SANITY CHECK: %.2f%% of pixels are marked as sky in sample "
259
260
  "'%s'. That is suspiciously high — check that the mask is not "
260
- "inverted or that `astype(bool)` is not accidentally treating "
261
- "every non-zero class id as sky.",
261
+ "inverted and that the dataset head's `skyclass` names the sky.",
262
262
  pct,
263
263
  sample_id,
264
264
  )
@@ -1,3 +1,23 @@
1
+ Metadata-Version: 2.4
2
+ Name: euler-preprocess
3
+ Version: 3.17.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.29.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
+
1
21
  <!-- euler header — shared across the euler packages.
2
22
  Per package, change only: the <h1>, the tagline, and the badge URLs. -->
3
23
  <p align="center">
@@ -99,6 +119,15 @@ loader.
99
119
  | `sky-depth` | `depth`, `semantic_segmentation` | — |
100
120
  | `radial` | `depth`, `intrinsics` | — |
101
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
+
102
131
  **Per-sample vs. hierarchical modalities.** You don't declare a modality's role:
103
132
  it is inferred from the ds-crawler indexes. A modality whose qualified IDs
104
133
  (hierarchy path plus file ID) match the transform's per-sample inputs is joined per
@@ -647,7 +676,8 @@ where sky depth is encoded as zero or infinity and needs a large finite value.
647
676
  }
648
677
  ```
649
678
 
650
- `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).
651
681
 
652
682
  CLI runs write a source-backed depth dataset mirroring the input depth modality's paths,
653
683
  filenames, extensions, and metadata. Standalone `SkyDepthTransform(...)` usage keeps the
@@ -51,6 +51,7 @@ tests/test_dense_gloomy_daylight_config.py
51
51
  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
+ tests/test_fog_input_normalization.py
54
55
  tests/test_fog_integration.py
55
56
  tests/test_modality_roles.py
56
57
  tests/test_radial.py
@@ -1,6 +1,6 @@
1
1
  numpy
2
2
  Pillow
3
- euler-loading>=2.24.0
3
+ euler-loading>=2.29.0
4
4
  ds-crawler>=2.11.0
5
5
  euler-dataset-contract>=0.5.0
6
6
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "euler-preprocess"
7
- version = "3.16.0"
7
+ version = "3.17.0"
8
8
  description = "Physics-based preprocessing (fog, etc.) for RGB+depth datasets"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -16,7 +16,7 @@ authors = [
16
16
  dependencies = [
17
17
  "numpy",
18
18
  "Pillow",
19
- "euler-loading>=2.24.0",
19
+ "euler-loading>=2.29.0",
20
20
  "ds-crawler>=2.11.0",
21
21
  "euler-dataset-contract>=0.5.0",
22
22
  ]
@@ -1753,6 +1753,7 @@ def test_render_fog_sample_accepts_modalities_and_named_scenario() -> None:
1753
1753
  config=config,
1754
1754
  scenario_profile_name="dark_reference",
1755
1755
  mode="cpu",
1756
+ sky_class=(29, 0, 0),
1756
1757
  )
1757
1758
  image = render_fog_image(
1758
1759
  rgb=rgb,
@@ -1767,6 +1768,7 @@ def test_render_fog_sample_accepts_modalities_and_named_scenario() -> None:
1767
1768
  config=config,
1768
1769
  scenario_profile_name="dark_reference",
1769
1770
  mode="cpu",
1771
+ sky_class=(29, 0, 0),
1770
1772
  )
1771
1773
 
1772
1774
  assert result.scenario_name == "dark_reference"
@@ -0,0 +1,259 @@
1
+ """Regression tests for sky-mask and estimated-airlight normalisation."""
2
+
3
+ import json
4
+
5
+ import numpy as np
6
+ import pytest
7
+ from ds_crawler import DatasetWriter
8
+ from euler_loading.loaders.cpu import real_drive_sim as rds
9
+ from euler_loading.loaders.cpu.generic_dense_depth import write_depth
10
+
11
+ from euler_preprocess.common.dataset import build_dataset
12
+ from euler_preprocess.common.logging import get_logger
13
+ from euler_preprocess.common.normalize import normalize_sky_mask
14
+ from euler_preprocess.fog.atmospheric_light import AtmosphericLightResolver
15
+ from euler_preprocess.fog.models import (
16
+ apply_model,
17
+ normalize_atmospheric_light,
18
+ resolve_model_config,
19
+ )
20
+ from euler_preprocess.fog.transform import FogTransform
21
+ from euler_preprocess.sky_depth.transform import SkyDepthTransform
22
+
23
+ try:
24
+ import torch
25
+ except ImportError:
26
+ torch = None
27
+
28
+ SKY = 23
29
+ # Cityscapes-style label map: every pixel has a non-zero class ID.
30
+ LABELS = np.array(
31
+ [
32
+ [SKY, SKY, SKY, SKY],
33
+ [SKY, 7, 7, 11],
34
+ [7, 7, 11, 11],
35
+ [7, 7, 11, 11],
36
+ ],
37
+ dtype=np.int64,
38
+ )
39
+ UNIFORM_CFG = {
40
+ "visibility_m": 100.0,
41
+ "atmospheric_light": "from_sky",
42
+ "airlight_dampening": False,
43
+ }
44
+
45
+
46
+ class TestNormalizeSkyMask:
47
+ def test_class_map_raises(self):
48
+ with pytest.raises(ValueError, match="sky_mask loader") as excinfo:
49
+ normalize_sky_mask(LABELS[None])
50
+ assert "7, 11, 23" in str(excinfo.value)
51
+
52
+ def test_rgb_class_map_raises(self):
53
+ # VKITTI2 GPU layout (3, H, W); vegetation (90, 240, 0) shares sky's red.
54
+ colours = np.zeros((3, 6, 6), dtype=np.int64)
55
+ colours[:, :2] = np.array([90, 200, 255])[:, None, None]
56
+ colours[:, 2:] = np.array([90, 240, 0])[:, None, None]
57
+ with pytest.raises(ValueError, match="boolean sky mask"):
58
+ normalize_sky_mask(colours)
59
+
60
+ def test_boolean_and_binary_masks_pass(self):
61
+ mask = LABELS == SKY
62
+ np.testing.assert_array_equal(normalize_sky_mask(mask), mask)
63
+ binary = mask.astype(np.uint8)[..., None]
64
+ np.testing.assert_array_equal(normalize_sky_mask(binary), mask)
65
+
66
+ @pytest.mark.skipif(torch is None, reason="torch not installed")
67
+ def test_gpu_loader_mask_passes(self):
68
+ mask = torch.from_numpy(LABELS == SKY)[None]
69
+ np.testing.assert_array_equal(normalize_sky_mask(mask), LABELS == SKY)
70
+
71
+
72
+ def test_fog_transform_rejects_class_maps(tmp_path):
73
+ cfg = {
74
+ "airlight": "from_sky",
75
+ "device": "cpu",
76
+ "models": {"uniform": UNIFORM_CFG},
77
+ "selection": {"mode": "fixed", "model": "uniform"},
78
+ }
79
+ path = tmp_path / "fog.json"
80
+ path.write_text(json.dumps(cfg))
81
+ transform = FogTransform(config_path=str(path), out_path=str(tmp_path / "out"))
82
+ sample = {
83
+ "rgb": np.full(LABELS.shape + (3,), 0.5, dtype=np.float32),
84
+ "depth": np.full(LABELS.shape, 20.0, dtype=np.float32),
85
+ "semantic_segmentation": LABELS,
86
+ "id": "frame",
87
+ }
88
+ with pytest.raises(ValueError, match="sky_mask loader"):
89
+ transform.run([sample])
90
+
91
+
92
+ # Real Drive Sim class IDs: 29 is sky, 31 the class a relabelled head declares.
93
+ RDS_CLASSES = np.array([[29, 29, 7], [31, 7, 7]], dtype=np.uint8)
94
+ FRAMES = ("Scene01/Camera_0/0000", "Scene01/Camera_0/0001")
95
+
96
+
97
+ def _rds_dataset_config(tmp_path, skyclass, segmentation_entry=None) -> dict:
98
+ """Depth plus an RDS ``class_segmentation`` index whose head declares *skyclass*."""
99
+ depth = DatasetWriter(
100
+ tmp_path / "depth",
101
+ name="depth",
102
+ type="depth",
103
+ separator=None,
104
+ meta={"radial_depth": False, "scale_to_meters": 1.0, "range": [0, 1000]},
105
+ euler_loading={"loader": "generic_dense_depth", "function": "depth"},
106
+ )
107
+ segmentation = DatasetWriter(
108
+ tmp_path / "segmentation",
109
+ name="segmentation",
110
+ type="semantic_segmentation",
111
+ separator=None,
112
+ meta={"skyclass": skyclass},
113
+ euler_loading={"loader": "real_drive_sim", "function": "class_segmentation"},
114
+ )
115
+ for full_id in FRAMES:
116
+ name = full_id.rsplit("/", 1)[-1]
117
+ write_depth(str(depth.get_path(full_id, f"{name}.npy")), np.full((2, 3), 5.0, dtype=np.float32))
118
+ rds.write_class_segmentation(str(segmentation.get_path(full_id, f"{name}.png")), RDS_CLASSES)
119
+ depth.save_index()
120
+ segmentation.save_index()
121
+ entry = str(tmp_path / "segmentation")
122
+ if segmentation_entry is not None:
123
+ entry = {"path": entry, **segmentation_entry}
124
+ return {
125
+ "modalities": {"depth": str(tmp_path / "depth"), "semantic_segmentation": entry}
126
+ }
127
+
128
+
129
+ def _run_sky_depth(tmp_path, config) -> np.ndarray:
130
+ dataset = build_dataset(
131
+ config,
132
+ SkyDepthTransform.REQUIRED_MODALITIES,
133
+ None,
134
+ SkyDepthTransform.MODALITY_FUNCTIONS,
135
+ )
136
+ cfg_path = tmp_path / "sky_depth.json"
137
+ cfg_path.write_text(json.dumps({"sky_depth_value": 900.0, "sanity_check_samples": 0}))
138
+ transform = SkyDepthTransform(str(cfg_path), str(tmp_path / "out"))
139
+ return np.load(transform.run(dataset)[0])
140
+
141
+
142
+ class TestSkyMaskFromDatasetHead:
143
+ """The transforms read class-segmentation indexes through euler-loading's ``sky_mask``."""
144
+
145
+ def test_sky_comes_from_the_head_skyclass(self, tmp_path):
146
+ config = _rds_dataset_config(tmp_path, [29, 0, 0])
147
+ np.testing.assert_array_equal(
148
+ _run_sky_depth(tmp_path, config), np.where(RDS_CLASSES == 29, 900.0, 5.0)
149
+ )
150
+
151
+ def test_relabelled_head_moves_the_sky(self, tmp_path):
152
+ config = _rds_dataset_config(tmp_path, [31, 0, 0])
153
+ np.testing.assert_array_equal(
154
+ _run_sky_depth(tmp_path, config), np.where(RDS_CLASSES == 31, 900.0, 5.0)
155
+ )
156
+
157
+ def test_function_null_keeps_the_class_map_and_fails_loudly(self, tmp_path):
158
+ config = _rds_dataset_config(tmp_path, [29, 0, 0], {"function": None})
159
+ with pytest.raises(ValueError, match="boolean sky mask"):
160
+ _run_sky_depth(tmp_path, config)
161
+
162
+
163
+ class TestEstimatedAirlightRange:
164
+ # A DCP-heuristic estimate whose luminance rescale overshoots one channel.
165
+ ESTIMATE = [0.9, 1.03, 0.98]
166
+ CLIPPED = [0.9, 1.0, 0.98]
167
+
168
+ def test_estimate_overshoot_is_clipped_not_rescaled(self):
169
+ result = normalize_atmospheric_light(np.array(self.ESTIMATE), allow_8bit=False)
170
+ np.testing.assert_allclose(result, self.CLIPPED, rtol=1e-6)
171
+
172
+ def test_configured_8bit_colour_is_still_rescaled(self):
173
+ result = normalize_atmospheric_light(np.array([200, 210, 220]))
174
+ np.testing.assert_allclose(result, np.array([200, 210, 220]) / 255.0, rtol=1e-6)
175
+
176
+ def test_cpu_model_keeps_overshooting_estimate(self):
177
+ _foggy, _beta, ls_base, _k_map, _ls_map = apply_model(
178
+ np.full((4, 4, 3), 0.5, dtype=np.float32),
179
+ np.full((4, 4), 20.0, dtype=np.float32),
180
+ "uniform",
181
+ resolve_model_config("uniform", {"uniform": UNIFORM_CFG}),
182
+ np.random.default_rng(0),
183
+ 0.05,
184
+ np.array(self.ESTIMATE, dtype=np.float32),
185
+ )
186
+ np.testing.assert_allclose(ls_base, self.CLIPPED, rtol=1e-6)
187
+
188
+ @pytest.mark.skipif(torch is None, reason="torch not installed")
189
+ def test_torch_sample_keeps_overshooting_estimate(self):
190
+ _k_mean, ls_base = AtmosphericLightResolver("from_sky").resolve_model_torch(
191
+ model_cfg=resolve_model_config("uniform", {"uniform": UNIFORM_CFG}),
192
+ rng=np.random.default_rng(0),
193
+ contrast_threshold_default=0.05,
194
+ estimated_airlight_t=torch.tensor(self.ESTIMATE),
195
+ device=torch.device("cpu"),
196
+ )
197
+ torch.testing.assert_close(ls_base, torch.tensor(self.CLIPPED))
198
+
199
+ @pytest.mark.skipif(torch is None, reason="torch not installed")
200
+ def test_torch_batch_overshoot_leaves_other_samples_unchanged(self, monkeypatch):
201
+ resolver = AtmosphericLightResolver("from_sky")
202
+ estimates = torch.tensor([[0.5, 0.6, 0.7], self.ESTIMATE])
203
+ monkeypatch.setattr(resolver, "_estimate_batch_torch", lambda *a, **k: estimates)
204
+ model_cfg = resolve_model_config("uniform", {"uniform": UNIFORM_CFG})
205
+ items = [
206
+ {"model_cfg": model_cfg, "rng": np.random.default_rng(index)}
207
+ for index in range(2)
208
+ ]
209
+ _k_means, ls_base = resolver.resolve_uniform_batch_torch(
210
+ rgb_batch=torch.zeros(2, 4, 4, 3),
211
+ items=items,
212
+ device=torch.device("cpu"),
213
+ contrast_threshold_default=0.05,
214
+ )
215
+ torch.testing.assert_close(
216
+ ls_base, torch.tensor([[0.5, 0.6, 0.7], self.CLIPPED])
217
+ )
218
+
219
+
220
+ class TestScenarioSelectionLog:
221
+ def _log_messages(self, tmp_path, caplog, scenario_profiles):
222
+ cfg = {
223
+ "airlight": "from_sky",
224
+ "device": "cpu",
225
+ "models": {"uniform": UNIFORM_CFG},
226
+ "selection": {"mode": "fixed", "model": "heterogeneous_k_ls"},
227
+ "scenario_profiles": scenario_profiles,
228
+ }
229
+ path = tmp_path / "scenarios.json"
230
+ path.write_text(json.dumps(cfg))
231
+ logger = get_logger()
232
+ logger.addHandler(caplog.handler)
233
+ try:
234
+ FogTransform(config_path=str(path), out_path=str(tmp_path / "out"))
235
+ finally:
236
+ logger.removeHandler(caplog.handler)
237
+ return caplog.messages
238
+
239
+ def test_profiles_with_models_do_not_log_fixed_model(self, tmp_path, caplog):
240
+ messages = self._log_messages(
241
+ tmp_path,
242
+ caplog,
243
+ [
244
+ {"name": "haze", "weight": 0.7, "model": "uniform"},
245
+ {"name": "clear", "weight": 0.3, "model": "uniform", "clear_weather": True},
246
+ ],
247
+ )
248
+ assert any("2 scenario_profiles (scope=sample)" in m for m in messages)
249
+ assert "Scenario haze: weight=0.7 model=uniform" in messages
250
+ assert not any("Model fixed" in m for m in messages)
251
+
252
+ def test_profile_without_model_defers_to_selection(self, tmp_path, caplog):
253
+ messages = self._log_messages(
254
+ tmp_path,
255
+ caplog,
256
+ [{"name": "default_model", "weight": 1.0}],
257
+ )
258
+ assert "Scenario default_model: weight=1.0 model=<selection>" in messages
259
+ assert "Model fixed: heterogeneous_k_ls" in messages
@@ -181,3 +181,14 @@ class TestSkyDepthTransform:
181
181
  transform = SkyDepthTransform(str(cfg_path), str(out_path), strict=False)
182
182
  paths = transform.run(samples)
183
183
  assert len(paths) == 2
184
+
185
+ def test_class_map_raises(self, tmp_path, sky_depth_config):
186
+ """A class-ID map must not be cast to bool (every non-zero class -> sky)."""
187
+ depth = np.ones((4, 4), dtype=np.float32)
188
+ labels = np.full((4, 4), 7, dtype=np.int64)
189
+ labels[0] = 23
190
+ samples = [{"id": "labels", "depth": depth, "semantic_segmentation": labels}]
191
+
192
+ transform = SkyDepthTransform(str(sky_depth_config), str(tmp_path / "output"))
193
+ with pytest.raises(ValueError, match="sky_mask loader"):
194
+ transform.run(samples)