euler-preprocess 2.2.0__tar.gz → 2.3.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 (48) hide show
  1. {euler_preprocess-2.2.0 → euler_preprocess-2.3.0}/PKG-INFO +34 -15
  2. {euler_preprocess-2.2.0 → euler_preprocess-2.3.0}/README.md +33 -14
  3. {euler_preprocess-2.2.0 → euler_preprocess-2.3.0}/euler_preprocess/common/output.py +25 -3
  4. {euler_preprocess-2.2.0 → euler_preprocess-2.3.0}/euler_preprocess/fog/models.py +277 -16
  5. {euler_preprocess-2.2.0 → euler_preprocess-2.3.0}/euler_preprocess/fog/transform.py +80 -1
  6. {euler_preprocess-2.2.0 → euler_preprocess-2.3.0}/euler_preprocess.egg-info/PKG-INFO +34 -15
  7. {euler_preprocess-2.2.0 → euler_preprocess-2.3.0}/pyproject.toml +1 -1
  8. {euler_preprocess-2.2.0 → euler_preprocess-2.3.0}/tests/test_fog_aux_outputs.py +69 -1
  9. {euler_preprocess-2.2.0 → euler_preprocess-2.3.0}/euler_preprocess/__init__.py +0 -0
  10. {euler_preprocess-2.2.0 → euler_preprocess-2.3.0}/euler_preprocess/cli.py +0 -0
  11. {euler_preprocess-2.2.0 → euler_preprocess-2.3.0}/euler_preprocess/common/__init__.py +0 -0
  12. {euler_preprocess-2.2.0 → euler_preprocess-2.3.0}/euler_preprocess/common/dataset.py +0 -0
  13. {euler_preprocess-2.2.0 → euler_preprocess-2.3.0}/euler_preprocess/common/device.py +0 -0
  14. {euler_preprocess-2.2.0 → euler_preprocess-2.3.0}/euler_preprocess/common/intrinsics.py +0 -0
  15. {euler_preprocess-2.2.0 → euler_preprocess-2.3.0}/euler_preprocess/common/io.py +0 -0
  16. {euler_preprocess-2.2.0 → euler_preprocess-2.3.0}/euler_preprocess/common/logging.py +0 -0
  17. {euler_preprocess-2.2.0 → euler_preprocess-2.3.0}/euler_preprocess/common/noise.py +0 -0
  18. {euler_preprocess-2.2.0 → euler_preprocess-2.3.0}/euler_preprocess/common/normalize.py +0 -0
  19. {euler_preprocess-2.2.0 → euler_preprocess-2.3.0}/euler_preprocess/common/sampling.py +0 -0
  20. {euler_preprocess-2.2.0 → euler_preprocess-2.3.0}/euler_preprocess/common/transform.py +0 -0
  21. {euler_preprocess-2.2.0 → euler_preprocess-2.3.0}/euler_preprocess/fog/__init__.py +0 -0
  22. {euler_preprocess-2.2.0 → euler_preprocess-2.3.0}/euler_preprocess/fog/airlight_from_sky.py +0 -0
  23. {euler_preprocess-2.2.0 → euler_preprocess-2.3.0}/euler_preprocess/fog/augmentations.py +0 -0
  24. {euler_preprocess-2.2.0 → euler_preprocess-2.3.0}/euler_preprocess/fog/dcp_airlight.py +0 -0
  25. {euler_preprocess-2.2.0 → euler_preprocess-2.3.0}/euler_preprocess/fog/dcp_airlight_torch.py +0 -0
  26. {euler_preprocess-2.2.0 → euler_preprocess-2.3.0}/euler_preprocess/fog/dcp_heuristic_airlight.py +0 -0
  27. {euler_preprocess-2.2.0 → euler_preprocess-2.3.0}/euler_preprocess/fog/dcp_heuristic_airlight_torch.py +0 -0
  28. {euler_preprocess-2.2.0 → euler_preprocess-2.3.0}/euler_preprocess/fog/foggify.py +0 -0
  29. {euler_preprocess-2.2.0 → euler_preprocess-2.3.0}/euler_preprocess/fog/foggify_logging.py +0 -0
  30. {euler_preprocess-2.2.0 → euler_preprocess-2.3.0}/euler_preprocess/fog/logging.py +0 -0
  31. {euler_preprocess-2.2.0 → euler_preprocess-2.3.0}/euler_preprocess/radial/__init__.py +0 -0
  32. {euler_preprocess-2.2.0 → euler_preprocess-2.3.0}/euler_preprocess/radial/transform.py +0 -0
  33. {euler_preprocess-2.2.0 → euler_preprocess-2.3.0}/euler_preprocess/sky_depth/__init__.py +0 -0
  34. {euler_preprocess-2.2.0 → euler_preprocess-2.3.0}/euler_preprocess/sky_depth/transform.py +0 -0
  35. {euler_preprocess-2.2.0 → euler_preprocess-2.3.0}/euler_preprocess.egg-info/SOURCES.txt +0 -0
  36. {euler_preprocess-2.2.0 → euler_preprocess-2.3.0}/euler_preprocess.egg-info/dependency_links.txt +0 -0
  37. {euler_preprocess-2.2.0 → euler_preprocess-2.3.0}/euler_preprocess.egg-info/entry_points.txt +0 -0
  38. {euler_preprocess-2.2.0 → euler_preprocess-2.3.0}/euler_preprocess.egg-info/requires.txt +0 -0
  39. {euler_preprocess-2.2.0 → euler_preprocess-2.3.0}/euler_preprocess.egg-info/top_level.txt +0 -0
  40. {euler_preprocess-2.2.0 → euler_preprocess-2.3.0}/setup.cfg +0 -0
  41. {euler_preprocess-2.2.0 → euler_preprocess-2.3.0}/tests/test_airlight_fallback.py +0 -0
  42. {euler_preprocess-2.2.0 → euler_preprocess-2.3.0}/tests/test_cli_sample_selection.py +0 -0
  43. {euler_preprocess-2.2.0 → euler_preprocess-2.3.0}/tests/test_dcp_heuristic_airlight.py +0 -0
  44. {euler_preprocess-2.2.0 → euler_preprocess-2.3.0}/tests/test_foggify_integration.py +0 -0
  45. {euler_preprocess-2.2.0 → euler_preprocess-2.3.0}/tests/test_radial.py +0 -0
  46. {euler_preprocess-2.2.0 → euler_preprocess-2.3.0}/tests/test_sky_depth.py +0 -0
  47. {euler_preprocess-2.2.0 → euler_preprocess-2.3.0}/tests/test_source_backed_output.py +0 -0
  48. {euler_preprocess-2.2.0 → euler_preprocess-2.3.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: 2.2.0
3
+ Version: 2.3.0
4
4
  Summary: Physics-based preprocessing (fog, etc.) for RGB+depth datasets
5
5
  Requires-Python: >=3.9
6
6
  Description-Content-Type: text/markdown
@@ -232,10 +232,10 @@ Each image is assigned a fog model via the `selection` block:
232
232
  "selection": {
233
233
  "mode": "weighted",
234
234
  "weights": {
235
- "uniform": 1.0,
236
- "heterogeneous_k": 0.0,
237
- "heterogeneous_ls": 0.0,
238
- "heterogeneous_k_ls": 0.0
235
+ "uniform": 0.25,
236
+ "heterogeneous_k": 0.35,
237
+ "heterogeneous_ls": 0.25,
238
+ "heterogeneous_k_ls": 0.15
239
239
  }
240
240
  }
241
241
  ```
@@ -309,9 +309,12 @@ variants:
309
309
  "scattering_coefficient": 0.15,
310
310
  "atmospheric_light": [1.0, 1.0, 1.0],
311
311
  "k_hetero": {
312
- "scales": "auto",
313
- "min_factor": 0.5,
314
- "max_factor": 1.5,
312
+ "scales": "smooth_auto",
313
+ "correlation_length_fraction": 0.25,
314
+ "octaves": 3,
315
+ "min_factor": 0.65,
316
+ "max_factor": 1.45,
317
+ "contrast": 0.65,
315
318
  "normalize_to_mean": true
316
319
  }
317
320
  }
@@ -327,26 +330,42 @@ MOR/beta descriptors when available. euler-loading exposes these as
327
330
 
328
331
  ### Heterogeneous Noise Fields
329
332
 
330
- Both `k_hetero` and `ls_hetero` use Perlin FBM (fractional Brownian motion) to generate spatially-varying factor fields:
333
+ Both `k_hetero` and `ls_hetero` use Perlin FBM (fractional Brownian
334
+ motion) to generate spatially-varying factor fields. For realistic fog,
335
+ prefer the smooth mode: it keeps Perlin wavelengths tied to the image size,
336
+ then optionally reduces noise contrast and applies a final blur before mapping
337
+ the noise to physical factors.
331
338
 
332
339
  ```json
333
340
  "k_hetero": {
334
- "scales": "auto",
335
- "min_scale": 2,
341
+ "scales": "smooth_auto",
342
+ "correlation_length_fraction": 0.25,
343
+ "octaves": 3,
336
344
  "max_scale": null,
337
- "min_factor": 0.0,
338
- "max_factor": 1.0,
345
+ "min_factor": 0.65,
346
+ "max_factor": 1.45,
347
+ "contrast": 0.65,
348
+ "smooth_sigma_fraction": 0.0,
339
349
  "normalize_to_mean": true
340
350
  }
341
351
  ```
342
352
 
343
- The noise field (values in [0, 1]) is mapped to a factor field: `factor(x) = min_factor + (max_factor - min_factor) * noise(x)`. When `normalize_to_mean` is `true`, the factor field is rescaled so its spatial mean equals 1.0, preserving the overall fog density while introducing spatial variation.
353
+ The noise field (values in [0, 1]) is mapped to a factor field:
354
+ `factor(x) = min_factor + (max_factor - min_factor) * noise(x)`.
355
+ `contrast < 1` compresses the noise around 0.5 before this mapping, avoiding
356
+ extreme local fog density. When `normalize_to_mean` is `true`, the factor field
357
+ is rescaled so its spatial mean equals 1.0, preserving the overall fog density
358
+ while introducing spatial variation.
344
359
 
345
360
  | Parameter | Effect |
346
361
  |---|---|
347
362
  | `min_factor` / `max_factor` | Range of the multiplicative factor. |
348
363
  | `normalize_to_mean` | Rescale factors so the image-wide mean equals the base value. Recommended for `k_hetero`. |
349
- | `scales` / `min_scale` / `max_scale` | Control spatial frequency content. |
364
+ | `scales: "smooth_auto"` | Build low-frequency Perlin scales from the image size. |
365
+ | `correlation_length_fraction` | Approximate smallest fog feature size as a fraction of the shorter image side. Larger values create smoother gradients. |
366
+ | `octaves` / `lacunarity` / `max_scale` | Control how many increasingly broad Perlin components are mixed. |
367
+ | `contrast` | Compress or expand the Perlin range before mapping to factors. Values below 1 are recommended. |
368
+ | `smooth_sigma` / `smooth_sigma_fraction` | Optional final Gaussian blur in pixels or as a fraction of the shorter image side. |
350
369
 
351
370
  ### Fog Output
352
371
 
@@ -218,10 +218,10 @@ Each image is assigned a fog model via the `selection` block:
218
218
  "selection": {
219
219
  "mode": "weighted",
220
220
  "weights": {
221
- "uniform": 1.0,
222
- "heterogeneous_k": 0.0,
223
- "heterogeneous_ls": 0.0,
224
- "heterogeneous_k_ls": 0.0
221
+ "uniform": 0.25,
222
+ "heterogeneous_k": 0.35,
223
+ "heterogeneous_ls": 0.25,
224
+ "heterogeneous_k_ls": 0.15
225
225
  }
226
226
  }
227
227
  ```
@@ -295,9 +295,12 @@ variants:
295
295
  "scattering_coefficient": 0.15,
296
296
  "atmospheric_light": [1.0, 1.0, 1.0],
297
297
  "k_hetero": {
298
- "scales": "auto",
299
- "min_factor": 0.5,
300
- "max_factor": 1.5,
298
+ "scales": "smooth_auto",
299
+ "correlation_length_fraction": 0.25,
300
+ "octaves": 3,
301
+ "min_factor": 0.65,
302
+ "max_factor": 1.45,
303
+ "contrast": 0.65,
301
304
  "normalize_to_mean": true
302
305
  }
303
306
  }
@@ -313,26 +316,42 @@ MOR/beta descriptors when available. euler-loading exposes these as
313
316
 
314
317
  ### Heterogeneous Noise Fields
315
318
 
316
- Both `k_hetero` and `ls_hetero` use Perlin FBM (fractional Brownian motion) to generate spatially-varying factor fields:
319
+ Both `k_hetero` and `ls_hetero` use Perlin FBM (fractional Brownian
320
+ motion) to generate spatially-varying factor fields. For realistic fog,
321
+ prefer the smooth mode: it keeps Perlin wavelengths tied to the image size,
322
+ then optionally reduces noise contrast and applies a final blur before mapping
323
+ the noise to physical factors.
317
324
 
318
325
  ```json
319
326
  "k_hetero": {
320
- "scales": "auto",
321
- "min_scale": 2,
327
+ "scales": "smooth_auto",
328
+ "correlation_length_fraction": 0.25,
329
+ "octaves": 3,
322
330
  "max_scale": null,
323
- "min_factor": 0.0,
324
- "max_factor": 1.0,
331
+ "min_factor": 0.65,
332
+ "max_factor": 1.45,
333
+ "contrast": 0.65,
334
+ "smooth_sigma_fraction": 0.0,
325
335
  "normalize_to_mean": true
326
336
  }
327
337
  ```
328
338
 
329
- The noise field (values in [0, 1]) is mapped to a factor field: `factor(x) = min_factor + (max_factor - min_factor) * noise(x)`. When `normalize_to_mean` is `true`, the factor field is rescaled so its spatial mean equals 1.0, preserving the overall fog density while introducing spatial variation.
339
+ The noise field (values in [0, 1]) is mapped to a factor field:
340
+ `factor(x) = min_factor + (max_factor - min_factor) * noise(x)`.
341
+ `contrast < 1` compresses the noise around 0.5 before this mapping, avoiding
342
+ extreme local fog density. When `normalize_to_mean` is `true`, the factor field
343
+ is rescaled so its spatial mean equals 1.0, preserving the overall fog density
344
+ while introducing spatial variation.
330
345
 
331
346
  | Parameter | Effect |
332
347
  |---|---|
333
348
  | `min_factor` / `max_factor` | Range of the multiplicative factor. |
334
349
  | `normalize_to_mean` | Rescale factors so the image-wide mean equals the base value. Recommended for `k_hetero`. |
335
- | `scales` / `min_scale` / `max_scale` | Control spatial frequency content. |
350
+ | `scales: "smooth_auto"` | Build low-frequency Perlin scales from the image size. |
351
+ | `correlation_length_fraction` | Approximate smallest fog feature size as a fraction of the shorter image side. Larger values create smoother gradients. |
352
+ | `octaves` / `lacunarity` / `max_scale` | Control how many increasingly broad Perlin components are mixed. |
353
+ | `contrast` | Compress or expand the Perlin range before mapping to factors. Values below 1 are recommended. |
354
+ | `smooth_sigma` / `smooth_sigma_fraction` | Optional final Gaussian blur in pixels or as a fraction of the shorter image side. |
336
355
 
337
356
  ### Fog Output
338
357
 
@@ -447,13 +447,22 @@ class SourceBackedOutputBackend:
447
447
  entry_attributes = None
448
448
  if attributes:
449
449
  entry_attributes = {**(entry_attributes or {}), **attributes}
450
+ source_entry_for_writer = dict(source_meta_copy)
451
+ if output_full_id is not None or output_basename is not None:
452
+ # The caller is intentionally writing a new logical layout
453
+ # (e.g. source sample -> augmentation). Let DatasetWriter derive
454
+ # properties from the new full_id/basename instead of copying the
455
+ # source file's old path and basename captures.
456
+ source_entry_for_writer.pop("path_properties", None)
457
+ source_entry_for_writer.pop("basename_properties", None)
458
+ source_entry_for_writer.pop("attributes", None)
450
459
 
451
460
  if isinstance(self.dataset_writer, ZipDatasetWriter):
452
461
  if supports_stream_target(self.modality_writer):
453
462
  with self.dataset_writer.open(
454
463
  full_id,
455
464
  basename,
456
- source_entry=source_meta_copy,
465
+ source_entry=source_entry_for_writer,
457
466
  attributes=entry_attributes,
458
467
  ) as stream:
459
468
  _set_stream_name(stream, basename)
@@ -466,7 +475,7 @@ class SourceBackedOutputBackend:
466
475
  full_id,
467
476
  basename,
468
477
  temp_path.read_bytes(),
469
- source_entry=source_meta_copy,
478
+ source_entry=source_entry_for_writer,
470
479
  attributes=entry_attributes,
471
480
  )
472
481
  return Path(f"{self.dataset_writer.root}::{relative_path}")
@@ -474,12 +483,25 @@ class SourceBackedOutputBackend:
474
483
  target_path = self.dataset_writer.get_path(
475
484
  full_id,
476
485
  basename,
477
- source_entry=source_meta_copy,
486
+ source_entry=source_entry_for_writer,
478
487
  attributes=entry_attributes,
479
488
  )
480
489
  self.modality_writer(str(target_path), value, self.modality_meta)
481
490
  return target_path
482
491
 
492
+ def set_hierarchy_separator(self, separator: str) -> None:
493
+ """Set the writer hierarchy separator used for future entries."""
494
+ setattr(self.dataset_writer, "_separator", separator)
495
+
496
+ def add_head_addon(self, name: str, payload: dict[str, Any]) -> None:
497
+ """Add a dataset-head addon before the writer saves its artifacts."""
498
+ head = getattr(self.dataset_writer, "_dataset_head", None)
499
+ addons = getattr(head, "addons", None)
500
+ if not isinstance(addons, dict):
501
+ raise RuntimeError("Unsupported dataset writer head object")
502
+ addons[name] = dict(payload)
503
+ self.index_overrides[name] = dict(payload)
504
+
483
505
  def write_json(self, path: Path, data: dict[str, Any]) -> None:
484
506
  raise RuntimeError(
485
507
  "Source-backed outputs do not support auxiliary JSON sidecars."
@@ -28,11 +28,13 @@ DEFAULT_MODEL_CONFIGS = {
28
28
  "visibility_m": {"dist": "constant", "value": 80.0},
29
29
  "atmospheric_light": "from_sky",
30
30
  "k_hetero": {
31
- "scales": "auto",
32
- "min_scale": 2,
31
+ "scales": "smooth_auto",
32
+ "correlation_length_fraction": 0.25,
33
+ "octaves": 3,
33
34
  "max_scale": None,
34
- "min_factor": 0.0,
35
- "max_factor": 1.0,
35
+ "min_factor": 0.65,
36
+ "max_factor": 1.45,
37
+ "contrast": 0.65,
36
38
  "normalize_to_mean": True,
37
39
  },
38
40
  },
@@ -40,11 +42,13 @@ DEFAULT_MODEL_CONFIGS = {
40
42
  "visibility_m": {"dist": "constant", "value": 80.0},
41
43
  "atmospheric_light": "from_sky",
42
44
  "ls_hetero": {
43
- "scales": "auto",
44
- "min_scale": 2,
45
+ "scales": "smooth_auto",
46
+ "correlation_length_fraction": 0.35,
47
+ "octaves": 3,
45
48
  "max_scale": None,
46
- "min_factor": 0.0,
47
- "max_factor": 1.0,
49
+ "min_factor": 0.85,
50
+ "max_factor": 1.08,
51
+ "contrast": 0.55,
48
52
  "normalize_to_mean": False,
49
53
  },
50
54
  },
@@ -52,19 +56,23 @@ DEFAULT_MODEL_CONFIGS = {
52
56
  "visibility_m": {"dist": "constant", "value": 80.0},
53
57
  "atmospheric_light": "from_sky",
54
58
  "k_hetero": {
55
- "scales": "auto",
56
- "min_scale": 2,
59
+ "scales": "smooth_auto",
60
+ "correlation_length_fraction": 0.25,
61
+ "octaves": 3,
57
62
  "max_scale": None,
58
- "min_factor": 0.0,
59
- "max_factor": 1.0,
63
+ "min_factor": 0.65,
64
+ "max_factor": 1.45,
65
+ "contrast": 0.65,
60
66
  "normalize_to_mean": True,
61
67
  },
62
68
  "ls_hetero": {
63
- "scales": "auto",
64
- "min_scale": 2,
69
+ "scales": "smooth_auto",
70
+ "correlation_length_fraction": 0.35,
71
+ "octaves": 3,
65
72
  "max_scale": None,
66
- "min_factor": 0.0,
67
- "max_factor": 1.0,
73
+ "min_factor": 0.85,
74
+ "max_factor": 1.08,
75
+ "contrast": 0.55,
68
76
  "normalize_to_mean": False,
69
77
  },
70
78
  },
@@ -166,6 +174,8 @@ def resolve_scales(
166
174
  scales_spec = hetero_cfg.get("scales", "auto")
167
175
  scales_spec = sample_value(scales_spec, rng)
168
176
  if isinstance(scales_spec, str):
177
+ if scales_spec == "smooth_auto":
178
+ return _resolve_smooth_auto_scales(hetero_cfg, height, width, rng)
169
179
  if scales_spec != "auto":
170
180
  raise ValueError(f"Unsupported scales value: {scales_spec}")
171
181
  min_scale = int(sample_value(hetero_cfg.get("min_scale", 2), rng))
@@ -186,6 +196,255 @@ def resolve_scales(
186
196
  raise ValueError(f"Unsupported scales spec: {scales_spec}")
187
197
 
188
198
 
199
+ def _resolve_smooth_auto_scales(
200
+ hetero_cfg: dict,
201
+ height: int,
202
+ width: int,
203
+ rng: np.random.Generator,
204
+ ) -> list[int]:
205
+ """Resolve low-frequency Perlin scales for realistic fog gradients."""
206
+ min_dimension = max(1, min(height, width))
207
+ max_dimension = max(1, max(height, width))
208
+
209
+ base_scale = _resolve_scale_alias(
210
+ hetero_cfg,
211
+ rng,
212
+ absolute_keys=("correlation_length", "base_scale", "min_scale"),
213
+ fraction_keys=(
214
+ "correlation_length_fraction",
215
+ "base_scale_fraction",
216
+ "min_scale_fraction",
217
+ ),
218
+ fraction_basis=min_dimension,
219
+ default=max(4, int(round(min_dimension * 0.25))),
220
+ )
221
+ max_scale = _resolve_scale_alias(
222
+ hetero_cfg,
223
+ rng,
224
+ absolute_keys=("max_scale",),
225
+ fraction_keys=("max_scale_fraction",),
226
+ fraction_basis=max_dimension,
227
+ default=max_dimension,
228
+ allow_none=True,
229
+ )
230
+ max_scale = max(base_scale, max_scale)
231
+
232
+ octaves = max(
233
+ 1,
234
+ int(round(_sample_float(hetero_cfg.get("octaves", 3), rng, "octaves"))),
235
+ )
236
+ lacunarity = _sample_float(hetero_cfg.get("lacunarity", 2.0), rng, "lacunarity")
237
+ if lacunarity <= 1.0:
238
+ raise ValueError(f"lacunarity must be > 1.0, got {lacunarity}")
239
+
240
+ scales: list[int] = []
241
+ scale = float(base_scale)
242
+ for _ in range(octaves):
243
+ scales.append(max(1, int(round(scale))))
244
+ if scale >= max_scale:
245
+ break
246
+ scale = min(scale * lacunarity, float(max_scale))
247
+ return _unique_positive_scales(scales)
248
+
249
+
250
+ def _resolve_scale_alias(
251
+ hetero_cfg: dict,
252
+ rng: np.random.Generator,
253
+ *,
254
+ absolute_keys: tuple[str, ...],
255
+ fraction_keys: tuple[str, ...],
256
+ fraction_basis: int,
257
+ default: int,
258
+ allow_none: bool = False,
259
+ ) -> int:
260
+ for key in absolute_keys:
261
+ if key not in hetero_cfg:
262
+ continue
263
+ raw_value = hetero_cfg[key]
264
+ if raw_value is None and allow_none:
265
+ break
266
+ return _scale_pixels(raw_value, rng, key)
267
+ for key in fraction_keys:
268
+ if key not in hetero_cfg:
269
+ continue
270
+ fraction = _sample_float(hetero_cfg[key], rng, key)
271
+ if fraction <= 0:
272
+ raise ValueError(f"{key} must be > 0, got {fraction}")
273
+ return max(1, int(round(float(fraction_basis) * fraction)))
274
+ return max(1, int(default))
275
+
276
+
277
+ def _scale_pixels(value, rng: np.random.Generator, name: str) -> int:
278
+ scale = _sample_float(value, rng, name)
279
+ if scale <= 0:
280
+ raise ValueError(f"{name} must be > 0, got {scale}")
281
+ return max(1, int(round(scale)))
282
+
283
+
284
+ def _sample_float(value, rng: np.random.Generator, name: str) -> float:
285
+ sampled = sample_value(value, rng)
286
+ try:
287
+ return float(sampled)
288
+ except (TypeError, ValueError) as exc:
289
+ raise ValueError(f"{name} must resolve to a number, got {sampled!r}") from exc
290
+
291
+
292
+ def _unique_positive_scales(scales: list[int]) -> list[int]:
293
+ unique: list[int] = []
294
+ seen: set[int] = set()
295
+ for scale in scales:
296
+ scale = int(scale)
297
+ if scale <= 0 or scale in seen:
298
+ continue
299
+ seen.add(scale)
300
+ unique.append(scale)
301
+ return unique or [1]
302
+
303
+
304
+ def prepare_noise_field(
305
+ noise: np.ndarray,
306
+ hetero_cfg: dict,
307
+ rng: np.random.Generator,
308
+ ) -> np.ndarray:
309
+ """Apply optional smoothing and contrast control to a Perlin noise field."""
310
+ noise = np.asarray(noise, dtype=np.float32)
311
+ sigma = resolve_smoothing_sigma(hetero_cfg, noise.shape[0], noise.shape[1], rng)
312
+ if sigma > 0.0:
313
+ noise = _gaussian_blur_np(noise, sigma)
314
+ noise = _normalize_noise_np(noise)
315
+ contrast = resolve_noise_contrast(hetero_cfg, rng)
316
+ if contrast != 1.0:
317
+ noise = 0.5 + (noise - 0.5) * contrast
318
+ return np.clip(noise, 0.0, 1.0).astype(np.float32, copy=False)
319
+
320
+
321
+ def prepare_noise_field_torch(
322
+ noise: "torch.Tensor",
323
+ hetero_cfg: dict,
324
+ rng: np.random.Generator,
325
+ ) -> "torch.Tensor":
326
+ """Torch equivalent of :func:`prepare_noise_field`."""
327
+ height = int(noise.shape[-2])
328
+ width = int(noise.shape[-1])
329
+ sigma = resolve_smoothing_sigma(hetero_cfg, height, width, rng)
330
+ if sigma > 0.0:
331
+ noise = _gaussian_blur_torch(noise, sigma)
332
+ noise = _normalize_noise_torch(noise)
333
+ contrast = resolve_noise_contrast(hetero_cfg, rng)
334
+ if contrast != 1.0:
335
+ noise = 0.5 + (noise - 0.5) * contrast
336
+ return torch.clamp(noise, 0.0, 1.0)
337
+
338
+
339
+ def resolve_smoothing_sigma(
340
+ hetero_cfg: dict,
341
+ height: int,
342
+ width: int,
343
+ rng: np.random.Generator,
344
+ ) -> float:
345
+ for key in ("smooth_sigma", "smoothing_sigma", "blur_sigma"):
346
+ if key in hetero_cfg:
347
+ sigma = _sample_float(hetero_cfg[key], rng, key)
348
+ if sigma < 0:
349
+ raise ValueError(f"{key} must be >= 0, got {sigma}")
350
+ return sigma
351
+ for key in (
352
+ "smooth_sigma_fraction",
353
+ "smoothing_sigma_fraction",
354
+ "blur_sigma_fraction",
355
+ ):
356
+ if key in hetero_cfg:
357
+ fraction = _sample_float(hetero_cfg[key], rng, key)
358
+ if fraction < 0:
359
+ raise ValueError(f"{key} must be >= 0, got {fraction}")
360
+ return fraction * float(max(1, min(height, width)))
361
+ return 0.0
362
+
363
+
364
+ def resolve_noise_contrast(hetero_cfg: dict, rng: np.random.Generator) -> float:
365
+ raw = hetero_cfg.get("contrast", hetero_cfg.get("noise_contrast", 1.0))
366
+ contrast = _sample_float(raw, rng, "contrast")
367
+ if contrast < 0:
368
+ raise ValueError(f"contrast must be >= 0, got {contrast}")
369
+ return contrast
370
+
371
+
372
+ def _normalize_noise_np(noise: np.ndarray) -> np.ndarray:
373
+ min_val = float(np.min(noise))
374
+ max_val = float(np.max(noise))
375
+ denom = max_val - min_val
376
+ if denom <= 1e-8:
377
+ return np.full_like(noise, 0.5, dtype=np.float32)
378
+ return ((noise - min_val) / denom).astype(np.float32, copy=False)
379
+
380
+
381
+ def _normalize_noise_torch(noise: "torch.Tensor") -> "torch.Tensor":
382
+ min_val = noise.amin()
383
+ max_val = noise.amax()
384
+ denom = max_val - min_val
385
+ if float(denom.item()) <= 1e-8:
386
+ return torch.full_like(noise, 0.5)
387
+ return (noise - min_val) / denom
388
+
389
+
390
+ def _gaussian_kernel_np(sigma: float) -> np.ndarray:
391
+ radius = max(1, int(math.ceil(3.0 * sigma)))
392
+ offsets = np.arange(-radius, radius + 1, dtype=np.float32)
393
+ kernel = np.exp(-0.5 * (offsets / float(sigma)) ** 2)
394
+ kernel /= float(kernel.sum())
395
+ return kernel.astype(np.float32)
396
+
397
+
398
+ def _convolve_axis_np(
399
+ values: np.ndarray,
400
+ kernel: np.ndarray,
401
+ axis: int,
402
+ ) -> np.ndarray:
403
+ radius = kernel.shape[0] // 2
404
+ padding = [(0, 0)] * values.ndim
405
+ padding[axis] = (radius, radius)
406
+ padded = np.pad(values, padding, mode="edge")
407
+ result = np.zeros_like(values, dtype=np.float32)
408
+ for offset, weight in enumerate(kernel):
409
+ slices = [slice(None)] * values.ndim
410
+ slices[axis] = slice(offset, offset + values.shape[axis])
411
+ result += float(weight) * padded[tuple(slices)]
412
+ return result
413
+
414
+
415
+ def _gaussian_blur_np(noise: np.ndarray, sigma: float) -> np.ndarray:
416
+ if sigma <= 0.0:
417
+ return noise
418
+ kernel = _gaussian_kernel_np(sigma)
419
+ blurred = _convolve_axis_np(noise, kernel, axis=1)
420
+ return _convolve_axis_np(blurred, kernel, axis=0)
421
+
422
+
423
+ def _gaussian_blur_torch(noise: "torch.Tensor", sigma: float) -> "torch.Tensor":
424
+ if sigma <= 0.0:
425
+ return noise
426
+ radius = max(1, int(math.ceil(3.0 * sigma)))
427
+ offsets = torch.arange(
428
+ -radius,
429
+ radius + 1,
430
+ device=noise.device,
431
+ dtype=torch.float32,
432
+ )
433
+ kernel = torch.exp(-0.5 * (offsets / float(sigma)) ** 2)
434
+ kernel = kernel / kernel.sum()
435
+ x = noise.to(dtype=torch.float32).view(
436
+ 1,
437
+ 1,
438
+ int(noise.shape[-2]),
439
+ int(noise.shape[-1]),
440
+ )
441
+ x = torch.nn.functional.pad(x, (radius, radius, 0, 0), mode="replicate")
442
+ x = torch.nn.functional.conv2d(x, kernel.view(1, 1, 1, -1))
443
+ x = torch.nn.functional.pad(x, (0, 0, radius, radius), mode="replicate")
444
+ x = torch.nn.functional.conv2d(x, kernel.view(1, 1, -1, 1))
445
+ return x.view(noise.shape)
446
+
447
+
189
448
  def modulate_with_noise(
190
449
  mean_value: np.ndarray,
191
450
  noise: np.ndarray,
@@ -349,6 +608,7 @@ def apply_model(
349
608
  k_cfg = model_cfg.get("k_hetero", {})
350
609
  k_scales = resolve_scales(k_cfg, height, width, rng)
351
610
  k_noise = perlin_fbm(height, width, k_scales, rng)
611
+ k_noise = prepare_noise_field(k_noise, k_cfg, rng)
352
612
  min_factor = float(sample_value(k_cfg.get("min_factor", 1.0), rng))
353
613
  max_factor = float(sample_value(k_cfg.get("max_factor", 1.0), rng))
354
614
  k_field = modulate_with_noise(
@@ -365,6 +625,7 @@ def apply_model(
365
625
  ls_cfg = model_cfg.get("ls_hetero", {})
366
626
  ls_scales = resolve_scales(ls_cfg, height, width, rng)
367
627
  ls_noise = perlin_fbm(height, width, ls_scales, rng)
628
+ ls_noise = prepare_noise_field(ls_noise, ls_cfg, rng)
368
629
  min_factor = float(sample_value(ls_cfg.get("min_factor", 1.0), rng))
369
630
  max_factor = float(sample_value(ls_cfg.get("max_factor", 1.0), rng))
370
631
  ls_field = modulate_with_noise(
@@ -40,6 +40,7 @@ from euler_preprocess.fog.models import (
40
40
  estimate_airlight_torch,
41
41
  modulate_with_noise_torch,
42
42
  normalize_atmospheric_light_torch,
43
+ prepare_noise_field_torch,
43
44
  resolve_model_config,
44
45
  resolve_scattering_coefficient,
45
46
  resolve_scales,
@@ -51,6 +52,33 @@ from euler_loading.loaders.cpu.generic import (
51
52
  write_map_3d as _write_map_3d,
52
53
  )
53
54
 
55
+ try:
56
+ from ds_crawler import EULER_LAYOUT_ADDON, build_layout_addon
57
+ except ImportError: # pragma: no cover - compatibility with older ds-crawler
58
+ EULER_LAYOUT_ADDON = "euler_layout"
59
+
60
+ def build_layout_addon(**kwargs):
61
+ payload: dict[str, Any] = {
62
+ "version": kwargs.get("version", "1.0"),
63
+ "sample_axis": {
64
+ "name": kwargs["sample_axis_name"],
65
+ "location": kwargs["sample_axis_location"],
66
+ },
67
+ }
68
+ family = kwargs.get("family")
69
+ if family is not None:
70
+ payload["family"] = family
71
+ variant_axis_name = kwargs.get("variant_axis_name")
72
+ if variant_axis_name is not None:
73
+ payload["variant_axis"] = {
74
+ "name": variant_axis_name,
75
+ "location": kwargs.get("variant_axis_location", "file_id"),
76
+ }
77
+ derived_from = kwargs.get("derived_from")
78
+ if derived_from is not None:
79
+ payload["derived_from"] = dict(derived_from)
80
+ return payload
81
+
54
82
 
55
83
  SCATTERING_COEFFICIENT_SLOT = "scattering_coefficient"
56
84
  ATMOSPHERIC_LIGHT_SLOT = "atmospheric_light"
@@ -174,6 +202,7 @@ class FogTransform(Transform):
174
202
  self.config
175
203
  )
176
204
  self.augmentation_specs = list(self.augmentation_config.specs)
205
+ self._configure_output_layout_metadata()
177
206
  self._written_configs: set[str] = set()
178
207
  self.torch_device = None
179
208
  self.use_gpu = False
@@ -374,9 +403,57 @@ class FogTransform(Transform):
374
403
  return suffix
375
404
  return ".png"
376
405
 
406
+ def _layout_family(self) -> str | None:
407
+ raw = self.config.get("dataset_family")
408
+ return raw if isinstance(raw, str) and raw else None
409
+
410
+ def _augmentation_hierarchy_separator(self, backend: Any) -> str:
411
+ separator = getattr(getattr(backend, "dataset_writer", None), "_separator", None)
412
+ if isinstance(separator, str) and separator and separator != "+":
413
+ return separator
414
+ return ":"
415
+
416
+ def _configure_output_layout_metadata(self) -> None:
417
+ """Declare fog outputs as variants grouped by source sample id."""
418
+ if not self.augmentation_specs:
419
+ return
420
+
421
+ sample_axis_name = self.augmentation_config.file_id_hierarchy_name
422
+ if not sample_axis_name:
423
+ return
424
+
425
+ for backend in self.output_backends.values():
426
+ if not getattr(backend, "is_source_backed", False):
427
+ continue
428
+
429
+ separator = self._augmentation_hierarchy_separator(backend)
430
+ set_separator = getattr(backend, "set_hierarchy_separator", None)
431
+ if callable(set_separator):
432
+ set_separator(separator)
433
+
434
+ layout = build_layout_addon(
435
+ family=self._layout_family(),
436
+ sample_axis_name=sample_axis_name,
437
+ sample_axis_location="hierarchy",
438
+ variant_axis_name=self.augmentation_config.attribute_key,
439
+ variant_axis_location="file_id",
440
+ derived_from={
441
+ "source_modality": getattr(backend, "source_modality", "rgb"),
442
+ "source_id_attribute": (
443
+ f"{self.augmentation_config.attribute_key}.source_id"
444
+ ),
445
+ "source_full_id_attribute": (
446
+ f"{self.augmentation_config.attribute_key}.source_full_id"
447
+ ),
448
+ },
449
+ )
450
+ add_head_addon = getattr(backend, "add_head_addon", None)
451
+ if callable(add_head_addon):
452
+ add_head_addon(EULER_LAYOUT_ADDON, layout)
453
+
377
454
  def _file_id_hierarchy_key(self, sample_id: str, backend: Any) -> str:
378
455
  name = self.augmentation_config.file_id_hierarchy_name
379
- separator = getattr(getattr(backend, "dataset_writer", None), "_separator", None)
456
+ separator = self._augmentation_hierarchy_separator(backend)
380
457
  if name and separator:
381
458
  return f"{name}{separator}{sample_id}"
382
459
  return sample_id
@@ -635,6 +712,7 @@ class FogTransform(Transform):
635
712
  torch_gen,
636
713
  self.torch_device,
637
714
  )
715
+ k_noise = prepare_noise_field_torch(k_noise, k_cfg, rng)
638
716
  min_factor = float(sample_value(k_cfg.get("min_factor", 1.0), rng))
639
717
  max_factor = float(sample_value(k_cfg.get("max_factor", 1.0), rng))
640
718
  k_field = modulate_with_noise_torch(
@@ -657,6 +735,7 @@ class FogTransform(Transform):
657
735
  torch_gen,
658
736
  self.torch_device,
659
737
  )
738
+ ls_noise = prepare_noise_field_torch(ls_noise, ls_cfg, rng)
660
739
  min_factor = float(sample_value(ls_cfg.get("min_factor", 1.0), rng))
661
740
  max_factor = float(sample_value(ls_cfg.get("max_factor", 1.0), rng))
662
741
  ls_field = modulate_with_noise_torch(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: euler-preprocess
3
- Version: 2.2.0
3
+ Version: 2.3.0
4
4
  Summary: Physics-based preprocessing (fog, etc.) for RGB+depth datasets
5
5
  Requires-Python: >=3.9
6
6
  Description-Content-Type: text/markdown
@@ -232,10 +232,10 @@ Each image is assigned a fog model via the `selection` block:
232
232
  "selection": {
233
233
  "mode": "weighted",
234
234
  "weights": {
235
- "uniform": 1.0,
236
- "heterogeneous_k": 0.0,
237
- "heterogeneous_ls": 0.0,
238
- "heterogeneous_k_ls": 0.0
235
+ "uniform": 0.25,
236
+ "heterogeneous_k": 0.35,
237
+ "heterogeneous_ls": 0.25,
238
+ "heterogeneous_k_ls": 0.15
239
239
  }
240
240
  }
241
241
  ```
@@ -309,9 +309,12 @@ variants:
309
309
  "scattering_coefficient": 0.15,
310
310
  "atmospheric_light": [1.0, 1.0, 1.0],
311
311
  "k_hetero": {
312
- "scales": "auto",
313
- "min_factor": 0.5,
314
- "max_factor": 1.5,
312
+ "scales": "smooth_auto",
313
+ "correlation_length_fraction": 0.25,
314
+ "octaves": 3,
315
+ "min_factor": 0.65,
316
+ "max_factor": 1.45,
317
+ "contrast": 0.65,
315
318
  "normalize_to_mean": true
316
319
  }
317
320
  }
@@ -327,26 +330,42 @@ MOR/beta descriptors when available. euler-loading exposes these as
327
330
 
328
331
  ### Heterogeneous Noise Fields
329
332
 
330
- Both `k_hetero` and `ls_hetero` use Perlin FBM (fractional Brownian motion) to generate spatially-varying factor fields:
333
+ Both `k_hetero` and `ls_hetero` use Perlin FBM (fractional Brownian
334
+ motion) to generate spatially-varying factor fields. For realistic fog,
335
+ prefer the smooth mode: it keeps Perlin wavelengths tied to the image size,
336
+ then optionally reduces noise contrast and applies a final blur before mapping
337
+ the noise to physical factors.
331
338
 
332
339
  ```json
333
340
  "k_hetero": {
334
- "scales": "auto",
335
- "min_scale": 2,
341
+ "scales": "smooth_auto",
342
+ "correlation_length_fraction": 0.25,
343
+ "octaves": 3,
336
344
  "max_scale": null,
337
- "min_factor": 0.0,
338
- "max_factor": 1.0,
345
+ "min_factor": 0.65,
346
+ "max_factor": 1.45,
347
+ "contrast": 0.65,
348
+ "smooth_sigma_fraction": 0.0,
339
349
  "normalize_to_mean": true
340
350
  }
341
351
  ```
342
352
 
343
- The noise field (values in [0, 1]) is mapped to a factor field: `factor(x) = min_factor + (max_factor - min_factor) * noise(x)`. When `normalize_to_mean` is `true`, the factor field is rescaled so its spatial mean equals 1.0, preserving the overall fog density while introducing spatial variation.
353
+ The noise field (values in [0, 1]) is mapped to a factor field:
354
+ `factor(x) = min_factor + (max_factor - min_factor) * noise(x)`.
355
+ `contrast < 1` compresses the noise around 0.5 before this mapping, avoiding
356
+ extreme local fog density. When `normalize_to_mean` is `true`, the factor field
357
+ is rescaled so its spatial mean equals 1.0, preserving the overall fog density
358
+ while introducing spatial variation.
344
359
 
345
360
  | Parameter | Effect |
346
361
  |---|---|
347
362
  | `min_factor` / `max_factor` | Range of the multiplicative factor. |
348
363
  | `normalize_to_mean` | Rescale factors so the image-wide mean equals the base value. Recommended for `k_hetero`. |
349
- | `scales` / `min_scale` / `max_scale` | Control spatial frequency content. |
364
+ | `scales: "smooth_auto"` | Build low-frequency Perlin scales from the image size. |
365
+ | `correlation_length_fraction` | Approximate smallest fog feature size as a fraction of the shorter image side. Larger values create smoother gradients. |
366
+ | `octaves` / `lacunarity` / `max_scale` | Control how many increasingly broad Perlin components are mixed. |
367
+ | `contrast` | Compress or expand the Perlin range before mapping to factors. Values below 1 are recommended. |
368
+ | `smooth_sigma` / `smooth_sigma_fraction` | Optional final Gaussian blur in pixels or as a fraction of the shorter image side. |
350
369
 
351
370
  ### Fog Output
352
371
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "euler-preprocess"
3
- version = "2.2.0"
3
+ version = "2.3.0"
4
4
  description = "Physics-based preprocessing (fog, etc.) for RGB+depth datasets"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.9"
@@ -314,15 +314,29 @@ def test_stepped_augmentations_write_file_id_layout_and_attributes(
314
314
  (pipeline_root / "foggy_rgb" / ".ds_crawler" / "output.json").read_text()
315
315
  )
316
316
  node = output_index["dataset"]["children"]["Scene01"]["children"]["Camera_0"]
317
- file_id_node = node["children"]["00001"]
317
+ file_id_node = node["children"]["file_id:00001"]
318
318
  entries = {entry["id"]: entry for entry in file_id_node["files"]}
319
319
  assert set(entries) == {"mor_10m", "mor_20m"}
320
+ assert entries["mor_10m"]["path_properties"]["file_id"] == "00001"
321
+ assert entries["mor_10m"]["basename_properties"]["ext"] == "png"
320
322
  attrs = entries["mor_10m"]["attributes"]["fog_augmentation"]
321
323
  assert attrs["id"] == "mor_10m"
322
324
  assert attrs["source_id"] == "00001"
323
325
  assert attrs["meteorological_visibility_m"] == 10.0
324
326
  assert attrs["model"] == "uniform"
325
327
  np.testing.assert_allclose(attrs["atmospheric_light"], [0.4, 0.5, 0.6])
328
+ assert output_index["euler_layout"]["sample_axis"] == {
329
+ "name": "file_id",
330
+ "location": "hierarchy",
331
+ }
332
+ assert output_index["euler_layout"]["variant_axis"] == {
333
+ "name": "fog_augmentation",
334
+ "location": "file_id",
335
+ }
336
+ output_head = json.loads(
337
+ (pipeline_root / "foggy_rgb" / ".ds_crawler" / "dataset-head.json").read_text()
338
+ )
339
+ assert output_head["addons"]["euler_layout"] == output_index["euler_layout"]
326
340
 
327
341
 
328
342
  def test_only_scattering_target_writes_only_scattering(tmp_path: Path) -> None:
@@ -547,6 +561,60 @@ def test_apply_model_returns_spatial_fields_for_heterogeneous() -> None:
547
561
  assert float(k_map.std()) > 0.0
548
562
 
549
563
 
564
+ def test_smooth_auto_scales_are_image_relative_low_frequency() -> None:
565
+ """smooth_auto should avoid the pixel-scale octaves that make fog speckly."""
566
+ from euler_preprocess.fog.models import resolve_scales
567
+
568
+ rng = np.random.default_rng(0)
569
+ cfg = {
570
+ "scales": "smooth_auto",
571
+ "correlation_length_fraction": 0.25,
572
+ "octaves": 4,
573
+ "max_scale_fraction": 1.0,
574
+ }
575
+
576
+ assert resolve_scales(cfg, height=100, width=200, rng=rng) == [25, 50, 100, 200]
577
+
578
+
579
+ def test_smooth_noise_contrast_keeps_heterogeneous_beta_near_mean() -> None:
580
+ """Low noise contrast keeps spatial fog gradients subtle around the base beta."""
581
+ from euler_preprocess.fog.models import apply_model
582
+
583
+ rng = np.random.default_rng(123)
584
+ rgb = np.full((80, 120, 3), 0.5, dtype=np.float32)
585
+ depth = np.full((80, 120), 50.0, dtype=np.float32)
586
+ estimated = np.array([0.8, 0.8, 0.9], dtype=np.float32)
587
+ cfg = {
588
+ "visibility_m": {"dist": "constant", "value": 80.0},
589
+ "atmospheric_light": "from_sky",
590
+ "k_hetero": {
591
+ "scales": "smooth_auto",
592
+ "correlation_length_fraction": 0.25,
593
+ "octaves": 3,
594
+ "min_factor": 0.5,
595
+ "max_factor": 1.5,
596
+ "contrast": 0.2,
597
+ "normalize_to_mean": True,
598
+ },
599
+ }
600
+
601
+ _, k_mean, _, k_map, _ = apply_model(
602
+ rgb,
603
+ depth,
604
+ "heterogeneous_k",
605
+ cfg,
606
+ rng,
607
+ contrast_threshold_default=0.05,
608
+ estimated_airlight=estimated,
609
+ )
610
+
611
+ factors = k_map / k_mean
612
+ assert float(factors.std()) > 0.0
613
+ assert float(factors.min()) >= 0.75
614
+ assert float(factors.max()) <= 1.25
615
+ np.testing.assert_allclose(float(factors.mean()), 1.0, rtol=1e-6)
616
+
617
+
550
618
  def test_apply_model_accepts_direct_scattering_coefficient() -> None:
551
619
  """Stepped configs may specify beta directly instead of MOR/visibility."""
552
620
  from euler_preprocess.fog.models import apply_model