euler-preprocess 3.8.0__tar.gz → 3.10.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 (61) hide show
  1. {euler_preprocess-3.8.0 → euler_preprocess-3.10.0}/PKG-INFO +30 -3
  2. {euler_preprocess-3.8.0 → euler_preprocess-3.10.0}/README.md +29 -2
  3. {euler_preprocess-3.8.0 → euler_preprocess-3.10.0}/euler_preprocess/fog/models.py +172 -0
  4. {euler_preprocess-3.8.0 → euler_preprocess-3.10.0}/euler_preprocess/fog/pipeline.py +3 -0
  5. {euler_preprocess-3.8.0 → euler_preprocess-3.10.0}/euler_preprocess/fog/transform.py +59 -47
  6. {euler_preprocess-3.8.0 → euler_preprocess-3.10.0}/euler_preprocess.egg-info/PKG-INFO +30 -3
  7. {euler_preprocess-3.8.0 → euler_preprocess-3.10.0}/pyproject.toml +1 -1
  8. {euler_preprocess-3.8.0 → euler_preprocess-3.10.0}/tests/test_dense_gloomy_daylight_config.py +8 -1
  9. {euler_preprocess-3.8.0 → euler_preprocess-3.10.0}/tests/test_fog_aux_outputs.py +103 -27
  10. {euler_preprocess-3.8.0 → euler_preprocess-3.10.0}/tests/test_real_drive_sim_scenario_profiles.py +21 -7
  11. {euler_preprocess-3.8.0 → euler_preprocess-3.10.0}/MANIFEST.in +0 -0
  12. {euler_preprocess-3.8.0 → euler_preprocess-3.10.0}/euler_preprocess/__init__.py +0 -0
  13. {euler_preprocess-3.8.0 → euler_preprocess-3.10.0}/euler_preprocess/cli.py +0 -0
  14. {euler_preprocess-3.8.0 → euler_preprocess-3.10.0}/euler_preprocess/common/__init__.py +0 -0
  15. {euler_preprocess-3.8.0 → euler_preprocess-3.10.0}/euler_preprocess/common/color.py +0 -0
  16. {euler_preprocess-3.8.0 → euler_preprocess-3.10.0}/euler_preprocess/common/dataset.py +0 -0
  17. {euler_preprocess-3.8.0 → euler_preprocess-3.10.0}/euler_preprocess/common/device.py +0 -0
  18. {euler_preprocess-3.8.0 → euler_preprocess-3.10.0}/euler_preprocess/common/intrinsics.py +0 -0
  19. {euler_preprocess-3.8.0 → euler_preprocess-3.10.0}/euler_preprocess/common/io.py +0 -0
  20. {euler_preprocess-3.8.0 → euler_preprocess-3.10.0}/euler_preprocess/common/logging.py +0 -0
  21. {euler_preprocess-3.8.0 → euler_preprocess-3.10.0}/euler_preprocess/common/noise.py +0 -0
  22. {euler_preprocess-3.8.0 → euler_preprocess-3.10.0}/euler_preprocess/common/normalize.py +0 -0
  23. {euler_preprocess-3.8.0 → euler_preprocess-3.10.0}/euler_preprocess/common/output.py +0 -0
  24. {euler_preprocess-3.8.0 → euler_preprocess-3.10.0}/euler_preprocess/common/sampling.py +0 -0
  25. {euler_preprocess-3.8.0 → euler_preprocess-3.10.0}/euler_preprocess/common/transform.py +0 -0
  26. {euler_preprocess-3.8.0 → euler_preprocess-3.10.0}/euler_preprocess/fog/__init__.py +0 -0
  27. {euler_preprocess-3.8.0 → euler_preprocess-3.10.0}/euler_preprocess/fog/airlight_from_sky.py +0 -0
  28. {euler_preprocess-3.8.0 → euler_preprocess-3.10.0}/euler_preprocess/fog/atmospheric_light.py +0 -0
  29. {euler_preprocess-3.8.0 → euler_preprocess-3.10.0}/euler_preprocess/fog/augmentations.py +0 -0
  30. {euler_preprocess-3.8.0 → euler_preprocess-3.10.0}/euler_preprocess/fog/capture.py +0 -0
  31. {euler_preprocess-3.8.0 → euler_preprocess-3.10.0}/euler_preprocess/fog/dcp_airlight.py +0 -0
  32. {euler_preprocess-3.8.0 → euler_preprocess-3.10.0}/euler_preprocess/fog/dcp_airlight_torch.py +0 -0
  33. {euler_preprocess-3.8.0 → euler_preprocess-3.10.0}/euler_preprocess/fog/dcp_heuristic_airlight.py +0 -0
  34. {euler_preprocess-3.8.0 → euler_preprocess-3.10.0}/euler_preprocess/fog/dcp_heuristic_airlight_torch.py +0 -0
  35. {euler_preprocess-3.8.0 → euler_preprocess-3.10.0}/euler_preprocess/fog/foggify.py +0 -0
  36. {euler_preprocess-3.8.0 → euler_preprocess-3.10.0}/euler_preprocess/fog/foggify_logging.py +0 -0
  37. {euler_preprocess-3.8.0 → euler_preprocess-3.10.0}/euler_preprocess/fog/inference.py +0 -0
  38. {euler_preprocess-3.8.0 → euler_preprocess-3.10.0}/euler_preprocess/fog/logging.py +0 -0
  39. {euler_preprocess-3.8.0 → euler_preprocess-3.10.0}/euler_preprocess/radial/__init__.py +0 -0
  40. {euler_preprocess-3.8.0 → euler_preprocess-3.10.0}/euler_preprocess/radial/transform.py +0 -0
  41. {euler_preprocess-3.8.0 → euler_preprocess-3.10.0}/euler_preprocess/sky_depth/__init__.py +0 -0
  42. {euler_preprocess-3.8.0 → euler_preprocess-3.10.0}/euler_preprocess/sky_depth/transform.py +0 -0
  43. {euler_preprocess-3.8.0 → euler_preprocess-3.10.0}/euler_preprocess.egg-info/SOURCES.txt +0 -0
  44. {euler_preprocess-3.8.0 → euler_preprocess-3.10.0}/euler_preprocess.egg-info/dependency_links.txt +0 -0
  45. {euler_preprocess-3.8.0 → euler_preprocess-3.10.0}/euler_preprocess.egg-info/entry_points.txt +0 -0
  46. {euler_preprocess-3.8.0 → euler_preprocess-3.10.0}/euler_preprocess.egg-info/requires.txt +0 -0
  47. {euler_preprocess-3.8.0 → euler_preprocess-3.10.0}/euler_preprocess.egg-info/top_level.txt +0 -0
  48. {euler_preprocess-3.8.0 → euler_preprocess-3.10.0}/setup.cfg +0 -0
  49. {euler_preprocess-3.8.0 → euler_preprocess-3.10.0}/tests/test_airlight_fallback.py +0 -0
  50. {euler_preprocess-3.8.0 → euler_preprocess-3.10.0}/tests/test_cli_sample_selection.py +0 -0
  51. {euler_preprocess-3.8.0 → euler_preprocess-3.10.0}/tests/test_dcp_heuristic_airlight.py +0 -0
  52. {euler_preprocess-3.8.0 → euler_preprocess-3.10.0}/tests/test_fog_aware_auto_exposure.py +0 -0
  53. {euler_preprocess-3.8.0 → euler_preprocess-3.10.0}/tests/test_fog_cpu_gpu_parity.py +0 -0
  54. {euler_preprocess-3.8.0 → euler_preprocess-3.10.0}/tests/test_foggify_integration.py +0 -0
  55. {euler_preprocess-3.8.0 → euler_preprocess-3.10.0}/tests/test_radial.py +0 -0
  56. {euler_preprocess-3.8.0 → euler_preprocess-3.10.0}/tests/test_scene_illumination.py +0 -0
  57. {euler_preprocess-3.8.0 → euler_preprocess-3.10.0}/tests/test_sensor_identity.py +0 -0
  58. {euler_preprocess-3.8.0 → euler_preprocess-3.10.0}/tests/test_sky_depth.py +0 -0
  59. {euler_preprocess-3.8.0 → euler_preprocess-3.10.0}/tests/test_source_backed_output.py +0 -0
  60. {euler_preprocess-3.8.0 → euler_preprocess-3.10.0}/tests/test_tone_map_lut.py +0 -0
  61. {euler_preprocess-3.8.0 → euler_preprocess-3.10.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.8.0
3
+ Version: 3.10.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
@@ -499,12 +499,39 @@ pixels when a sky mask is available.
499
499
 
500
500
  **RGB** — The clean scene image. Normalised to float32 in [0, 1]. This is the *I(x)* term in the fog equation -- it gets blended with the airlight according to transmittance.
501
501
 
502
- **Depth** — A per-pixel depth map in **metres**. Provides the *d(x)* term in the transmittance calculation `t(x) = exp(-k * d(x))`. Pixels with greater depth receive more fog. Invalid values (NaN, inf, negative) are clamped to zero (treated as infinitely close, receiving no fog).
502
+ **Depth** — A per-pixel depth map in **metres**. Provides the *d(x)* term in the transmittance calculation `t(x) = exp(-k * d(x))`. Pixels with greater depth receive more fog. Invalid values (NaN, inf, negative) are clamped to zero (treated as infinitely close, receiving no fog). By default, depth remains authoritative even where the semantic map says sky. For noisy real-world predictions, `sky_fog_path` can instead derive a density-weighted sky path through a bounded fog layer from the camera intrinsics and assumed orientation.
503
503
 
504
- **Semantic Segmentation** — A per-pixel semantic segmentation map from which a boolean sky mask is derived, loaded via euler-loading's dataset-specific `semantic_segmentation` loader. The sky mask is used for airlight estimation when the `airlight` method is `"from_sky"`: the mean RGB of all sky pixels in the clean image is used as the airlight colour *L_s*.
504
+ **Semantic Segmentation** — A per-pixel semantic segmentation map from which a boolean sky mask is derived, loaded via euler-loading's dataset-specific `semantic_segmentation` loader. The sky mask is used for airlight estimation when the `airlight` method is `"from_sky"`: the mean RGB of all sky pixels in the clean image is used as the airlight colour *L_s*. When `sky_fog_path` is configured, semantic sky pixels use the modeled fog-layer path while depth remains authoritative everywhere else.
505
505
 
506
506
  **Intrinsics** *(optional)* — When present, planar (z-buffer) depth is converted to radial (Euclidean) depth before fog is applied.
507
507
 
508
+ For a bounded valley-fog treatment of sky, add this inside a model config:
509
+
510
+ ```json
511
+ "sky_fog_path": {
512
+ "mode": "layer",
513
+ "camera_height_m": 1.6,
514
+ "fog_valley_peak_m": 80.0,
515
+ "camera_pitch_deg": 0.0,
516
+ "camera_roll_deg": 0.0,
517
+ "density_profile": "linear_fade",
518
+ "transition_height_m": 10.0,
519
+ "max_path_m": 1000.0
520
+ }
521
+ ```
522
+
523
+ Pixel rays are reconstructed from the intrinsics. Near-horizon rays accumulate
524
+ long paths through fog, while upward rays exit the layer sooner. The default
525
+ `linear_fade` profile has full density below the transition and decreases
526
+ linearly to zero at `fog_valley_peak_m`; both `camera_height_m` and
527
+ `fog_valley_peak_m` are elevations in the same local vertical coordinate.
528
+ `uniform` keeps full density up to the top. Positive `camera_pitch_deg` points
529
+ the optical axis upward. `max_path_m` bounds horizon rays. The fog-layer path feeds the same fog
530
+ opacity, scene-illumination, and atmospheric-light-gradient heuristics as
531
+ ordinary depth. The original depth is retained for non-sky pixels and capture
532
+ effects. Enabling this option without intrinsics raises an error rather than
533
+ silently reverting to predicted sky depth.
534
+
508
535
  ### Airlight Estimation
509
536
 
510
537
  The `airlight` config key selects how the atmospheric light *L_s* is estimated:
@@ -485,12 +485,39 @@ pixels when a sky mask is available.
485
485
 
486
486
  **RGB** — The clean scene image. Normalised to float32 in [0, 1]. This is the *I(x)* term in the fog equation -- it gets blended with the airlight according to transmittance.
487
487
 
488
- **Depth** — A per-pixel depth map in **metres**. Provides the *d(x)* term in the transmittance calculation `t(x) = exp(-k * d(x))`. Pixels with greater depth receive more fog. Invalid values (NaN, inf, negative) are clamped to zero (treated as infinitely close, receiving no fog).
488
+ **Depth** — A per-pixel depth map in **metres**. Provides the *d(x)* term in the transmittance calculation `t(x) = exp(-k * d(x))`. Pixels with greater depth receive more fog. Invalid values (NaN, inf, negative) are clamped to zero (treated as infinitely close, receiving no fog). By default, depth remains authoritative even where the semantic map says sky. For noisy real-world predictions, `sky_fog_path` can instead derive a density-weighted sky path through a bounded fog layer from the camera intrinsics and assumed orientation.
489
489
 
490
- **Semantic Segmentation** — A per-pixel semantic segmentation map from which a boolean sky mask is derived, loaded via euler-loading's dataset-specific `semantic_segmentation` loader. The sky mask is used for airlight estimation when the `airlight` method is `"from_sky"`: the mean RGB of all sky pixels in the clean image is used as the airlight colour *L_s*.
490
+ **Semantic Segmentation** — A per-pixel semantic segmentation map from which a boolean sky mask is derived, loaded via euler-loading's dataset-specific `semantic_segmentation` loader. The sky mask is used for airlight estimation when the `airlight` method is `"from_sky"`: the mean RGB of all sky pixels in the clean image is used as the airlight colour *L_s*. When `sky_fog_path` is configured, semantic sky pixels use the modeled fog-layer path while depth remains authoritative everywhere else.
491
491
 
492
492
  **Intrinsics** *(optional)* — When present, planar (z-buffer) depth is converted to radial (Euclidean) depth before fog is applied.
493
493
 
494
+ For a bounded valley-fog treatment of sky, add this inside a model config:
495
+
496
+ ```json
497
+ "sky_fog_path": {
498
+ "mode": "layer",
499
+ "camera_height_m": 1.6,
500
+ "fog_valley_peak_m": 80.0,
501
+ "camera_pitch_deg": 0.0,
502
+ "camera_roll_deg": 0.0,
503
+ "density_profile": "linear_fade",
504
+ "transition_height_m": 10.0,
505
+ "max_path_m": 1000.0
506
+ }
507
+ ```
508
+
509
+ Pixel rays are reconstructed from the intrinsics. Near-horizon rays accumulate
510
+ long paths through fog, while upward rays exit the layer sooner. The default
511
+ `linear_fade` profile has full density below the transition and decreases
512
+ linearly to zero at `fog_valley_peak_m`; both `camera_height_m` and
513
+ `fog_valley_peak_m` are elevations in the same local vertical coordinate.
514
+ `uniform` keeps full density up to the top. Positive `camera_pitch_deg` points
515
+ the optical axis upward. `max_path_m` bounds horizon rays. The fog-layer path feeds the same fog
516
+ opacity, scene-illumination, and atmospheric-light-gradient heuristics as
517
+ ordinary depth. The original depth is retained for non-sky pixels and capture
518
+ effects. Enabling this option without intrinsics raises an error rather than
519
+ silently reverting to predicted sky depth.
520
+
494
521
  ### Airlight Estimation
495
522
 
496
523
  The `airlight` config key selects how the atmospheric light *L_s* is estimated:
@@ -989,6 +989,170 @@ def _sanitize_depth_torch(depth_m: "torch.Tensor") -> "torch.Tensor":
989
989
  return torch.clamp(depth, min=0.0)
990
990
 
991
991
 
992
+ def _resolve_sky_fog_path_config(
993
+ model_cfg: dict,
994
+ rng: np.random.Generator,
995
+ ) -> dict | None:
996
+ raw = model_cfg.get("sky_fog_path")
997
+ if raw is None or raw is False:
998
+ return None
999
+ if not isinstance(raw, dict):
1000
+ raise ValueError("sky_fog_path must be an object or false")
1001
+ enabled = raw.get("enabled", True)
1002
+ if not isinstance(enabled, bool):
1003
+ raise ValueError("sky_fog_path.enabled must be a boolean")
1004
+ if not enabled:
1005
+ return None
1006
+ mode = str(raw.get("mode", "layer")).lower()
1007
+ if mode != "layer":
1008
+ raise ValueError("sky_fog_path.mode must be 'layer'")
1009
+
1010
+ def value(key: str, default: float) -> float:
1011
+ return _sample_float(raw.get(key, default), rng, f"sky_fog_path.{key}")
1012
+
1013
+ camera_height = value("camera_height_m", 1.6)
1014
+ fog_top = value("fog_valley_peak_m", 80.0)
1015
+ transition = value("transition_height_m", 10.0)
1016
+ max_path = value("max_path_m", 1000.0)
1017
+ if transition < 0.0:
1018
+ raise ValueError("sky_fog_path.transition_height_m must be >= 0")
1019
+ if max_path <= 0.0:
1020
+ raise ValueError("sky_fog_path.max_path_m must be > 0")
1021
+ profile = str(raw.get("density_profile", "linear_fade")).lower()
1022
+ if profile not in {"linear_fade", "uniform"}:
1023
+ raise ValueError(
1024
+ "sky_fog_path.density_profile must be 'linear_fade' or 'uniform'"
1025
+ )
1026
+ return {
1027
+ "layer_height_m": max(fog_top - camera_height, 0.0),
1028
+ "transition_height_m": transition,
1029
+ "max_path_m": max_path,
1030
+ "pitch_rad": math.radians(value("camera_pitch_deg", 0.0)),
1031
+ "roll_rad": math.radians(value("camera_roll_deg", 0.0)),
1032
+ "density_profile": profile,
1033
+ }
1034
+
1035
+
1036
+ def _camera_up_vector(cfg: dict) -> tuple[float, float, float]:
1037
+ pitch = cfg["pitch_rad"]
1038
+ roll = cfg["roll_rad"]
1039
+ return (
1040
+ math.sin(roll) * math.cos(pitch),
1041
+ -math.cos(roll) * math.cos(pitch),
1042
+ math.sin(pitch),
1043
+ )
1044
+
1045
+
1046
+ def apply_sky_fog_path_np(
1047
+ depth_m: np.ndarray,
1048
+ sky_mask: np.ndarray | None,
1049
+ intrinsics: np.ndarray | None,
1050
+ model_cfg: dict,
1051
+ rng: np.random.Generator,
1052
+ ) -> np.ndarray:
1053
+ """Replace sky depth with its density-weighted path through a fog layer."""
1054
+ cfg = _resolve_sky_fog_path_config(model_cfg, rng)
1055
+ if cfg is None or sky_mask is None:
1056
+ return depth_m
1057
+ if intrinsics is None:
1058
+ raise ValueError("sky_fog_path requires camera intrinsics")
1059
+ depth = np.asarray(depth_m, dtype=np.float32)
1060
+ sky = np.asarray(sky_mask, dtype=bool)
1061
+ if sky.shape != depth.shape:
1062
+ raise ValueError(f"sky_mask must have shape {depth.shape}; got {sky.shape}")
1063
+ K = np.asarray(intrinsics, dtype=np.float32)
1064
+ if K.shape != (3, 3):
1065
+ raise ValueError(f"intrinsics must have shape (3, 3); got {K.shape}")
1066
+
1067
+ height, width = depth.shape
1068
+ yy, xx = np.indices((height, width), dtype=np.float32)
1069
+ pixels = np.stack((xx, yy, np.ones_like(xx)), axis=-1)
1070
+ rays = pixels @ np.linalg.inv(K).T
1071
+ rays /= np.maximum(np.linalg.norm(rays, axis=-1, keepdims=True), 1e-8)
1072
+ up = np.maximum(rays @ np.asarray(_camera_up_vector(cfg), dtype=np.float32), 0.0)
1073
+ layer_height = cfg["layer_height_m"]
1074
+ path = np.full_like(depth, cfg["max_path_m"])
1075
+ if layer_height <= 0.0:
1076
+ path.fill(0.0)
1077
+ else:
1078
+ reached_height = np.minimum(up * cfg["max_path_m"], layer_height)
1079
+ if cfg["density_profile"] == "uniform":
1080
+ vertical_optical_depth = reached_height
1081
+ else:
1082
+ transition = min(cfg["transition_height_m"], layer_height)
1083
+ fade_start = layer_height - transition
1084
+ fade_height = np.clip(reached_height - fade_start, 0.0, transition)
1085
+ vertical_optical_depth = np.minimum(reached_height, fade_start)
1086
+ if transition > 0.0:
1087
+ vertical_optical_depth += fade_height - fade_height**2 / (
1088
+ 2.0 * transition
1089
+ )
1090
+ np.divide(vertical_optical_depth, up, out=path, where=up > 1e-8)
1091
+ return np.where(sky, path, depth).astype(np.float32, copy=False)
1092
+
1093
+
1094
+ def apply_sky_fog_path_torch(
1095
+ depth_m: "torch.Tensor",
1096
+ sky_mask: "torch.Tensor | None",
1097
+ intrinsics,
1098
+ model_cfg: dict,
1099
+ rng: np.random.Generator,
1100
+ ) -> "torch.Tensor":
1101
+ """Torch equivalent of :func:`apply_sky_fog_path_np`."""
1102
+ cfg = _resolve_sky_fog_path_config(model_cfg, rng)
1103
+ if cfg is None or sky_mask is None:
1104
+ return depth_m
1105
+ if intrinsics is None:
1106
+ raise ValueError("sky_fog_path requires camera intrinsics")
1107
+ depth = depth_m.to(dtype=torch.float32)
1108
+ sky = torch.as_tensor(sky_mask, device=depth.device, dtype=torch.bool)
1109
+ if tuple(sky.shape) != tuple(depth.shape):
1110
+ raise ValueError(
1111
+ f"sky_mask must have shape {tuple(depth.shape)}; got {tuple(sky.shape)}"
1112
+ )
1113
+ K = torch.as_tensor(intrinsics, device=depth.device, dtype=depth.dtype)
1114
+ if tuple(K.shape) != (3, 3):
1115
+ raise ValueError(f"intrinsics must have shape (3, 3); got {tuple(K.shape)}")
1116
+
1117
+ height, width = int(depth.shape[0]), int(depth.shape[1])
1118
+ yy, xx = torch.meshgrid(
1119
+ torch.arange(height, device=depth.device, dtype=depth.dtype),
1120
+ torch.arange(width, device=depth.device, dtype=depth.dtype),
1121
+ indexing="ij",
1122
+ )
1123
+ pixels = torch.stack((xx, yy, torch.ones_like(xx)), dim=-1)
1124
+ rays = pixels @ torch.linalg.inv(K).T
1125
+ rays = rays / torch.clamp(torch.linalg.vector_norm(rays, dim=-1, keepdim=True), min=1e-8)
1126
+ up_vector = torch.tensor(_camera_up_vector(cfg), device=depth.device, dtype=depth.dtype)
1127
+ up = torch.clamp(rays @ up_vector, min=0.0)
1128
+ layer_height = cfg["layer_height_m"]
1129
+ if layer_height <= 0.0:
1130
+ path = torch.zeros_like(depth)
1131
+ else:
1132
+ reached_height = torch.clamp(
1133
+ up * cfg["max_path_m"], max=layer_height
1134
+ )
1135
+ if cfg["density_profile"] == "uniform":
1136
+ vertical_optical_depth = reached_height
1137
+ else:
1138
+ transition = min(cfg["transition_height_m"], layer_height)
1139
+ fade_start = layer_height - transition
1140
+ fade_height = torch.clamp(
1141
+ reached_height - fade_start, min=0.0, max=transition
1142
+ )
1143
+ vertical_optical_depth = torch.clamp(reached_height, max=fade_start)
1144
+ if transition > 0.0:
1145
+ vertical_optical_depth = vertical_optical_depth + (
1146
+ fade_height - fade_height**2 / (2.0 * transition)
1147
+ )
1148
+ path = torch.where(
1149
+ up > 1e-8,
1150
+ vertical_optical_depth / torch.clamp(up, min=1e-8),
1151
+ torch.full_like(up, cfg["max_path_m"]),
1152
+ )
1153
+ return torch.where(sky, path, depth)
1154
+
1155
+
992
1156
  def _resolve_scene_illumination_config(model_cfg: dict) -> dict:
993
1157
  raw = model_cfg.get("scene_illumination")
994
1158
  if raw is None:
@@ -1464,6 +1628,7 @@ def apply_model(
1464
1628
  contrast_threshold_default: float,
1465
1629
  estimated_airlight: np.ndarray,
1466
1630
  sky_mask: np.ndarray | None = None,
1631
+ intrinsics: np.ndarray | None = None,
1467
1632
  ) -> tuple[np.ndarray, float, np.ndarray, np.ndarray, np.ndarray]:
1468
1633
  """Apply a fog model to ``rgb``.
1469
1634
 
@@ -1493,6 +1658,13 @@ def apply_model(
1493
1658
  sampled_al = sample_value(al_spec, rng)
1494
1659
  raw_ls_base = normalize_atmospheric_light(np.asarray(sampled_al))
1495
1660
 
1661
+ depth_m = apply_sky_fog_path_np(
1662
+ depth_m,
1663
+ sky_mask,
1664
+ intrinsics,
1665
+ model_cfg,
1666
+ rng,
1667
+ )
1496
1668
  height, width = depth_m.shape
1497
1669
  ls_base = dampen_airlight(
1498
1670
  raw_ls_base,
@@ -75,6 +75,7 @@ class FogProcessingPipeline:
75
75
  rng: np.random.Generator,
76
76
  sample_id: str | None,
77
77
  airlight_method: str | None = None,
78
+ intrinsics: np.ndarray | None = None,
78
79
  ) -> FogPipelineResult:
79
80
  render_input_space = self.render_input_space_for(model_cfg)
80
81
  rgb_for_render = srgb_to_linear(rgb) if render_input_space == "srgb" else rgb
@@ -93,6 +94,7 @@ class FogProcessingPipeline:
93
94
  self.contrast_threshold_default,
94
95
  estimated_airlight,
95
96
  sky_mask=sky_mask,
97
+ intrinsics=intrinsics,
96
98
  )
97
99
  if render_input_space == "srgb":
98
100
  foggy = linear_to_srgb(foggy)
@@ -193,6 +195,7 @@ class FogProcessingPipeline:
193
195
  rng=rng,
194
196
  sample_id=sample_id,
195
197
  airlight_method=airlight_method,
198
+ intrinsics=intrinsics,
196
199
  )
197
200
  return self.apply_capture_np(
198
201
  result,
@@ -45,6 +45,7 @@ from euler_preprocess.fog.models import (
45
45
  AIRLIGHT_METHODS,
46
46
  DEFAULT_CONTRAST_THRESHOLD,
47
47
  DEFAULT_MODEL_CONFIGS,
48
+ apply_sky_fog_path_torch,
48
49
  apply_ls_gradient_torch,
49
50
  modulate_with_noise_torch,
50
51
  prepare_noise_field_torch,
@@ -215,8 +216,7 @@ def _freeze_distribution_specs(value: Any, rng: np.random.Generator) -> Any:
215
216
  if "dist" in value:
216
217
  return sample_value(value, rng)
217
218
  return {
218
- key: _freeze_distribution_specs(child, rng)
219
- for key, child in value.items()
219
+ key: _freeze_distribution_specs(child, rng) for key, child in value.items()
220
220
  }
221
221
  if isinstance(value, list):
222
222
  return [_freeze_distribution_specs(child, rng) for child in value]
@@ -333,9 +333,7 @@ class FogTransform(Transform):
333
333
  self.config_path = Path(config_path)
334
334
  if output_backends is not None:
335
335
  if "rgb" not in output_backends:
336
- raise ValueError(
337
- "output_backends must contain the primary 'rgb' slot"
338
- )
336
+ raise ValueError("output_backends must contain the primary 'rgb' slot")
339
337
  self.output_backends = dict(output_backends)
340
338
  self.output_backend = self.output_backends["rgb"]
341
339
  else:
@@ -457,9 +455,7 @@ class FogTransform(Transform):
457
455
  def _rng_for_batch(self, batch_index: int):
458
456
  if self.seed is not None:
459
457
  return np.random.default_rng(
460
- np.random.SeedSequence(
461
- [int(self.seed), int(batch_index), 1_000_003]
462
- )
458
+ np.random.SeedSequence([int(self.seed), int(batch_index), 1_000_003])
463
459
  )
464
460
  return self.base_rng
465
461
 
@@ -524,7 +520,9 @@ class FogTransform(Transform):
524
520
  augmentation: FogAugmentationSpec,
525
521
  ) -> tuple[str, dict]:
526
522
  base_cfg = resolve_model_config(augmentation.model_name, self.models_cfg)
527
- return augmentation.model_name, deep_merge(base_cfg, augmentation.model_overrides)
523
+ return augmentation.model_name, deep_merge(
524
+ base_cfg, augmentation.model_overrides
525
+ )
528
526
 
529
527
  def _parse_scenario_profiles(
530
528
  self,
@@ -598,7 +596,9 @@ class FogTransform(Transform):
598
596
  "a finite non-negative number in progressive mode"
599
597
  )
600
598
 
601
- scenario_name = self._scenario_name(profile) or f"scenario_{scenario_index:03d}"
599
+ scenario_name = (
600
+ self._scenario_name(profile) or f"scenario_{scenario_index:03d}"
601
+ )
602
602
  scenario_id = _sanitize_variant_identifier(scenario_name)
603
603
  for step_index in range(step_count + 1):
604
604
  weight = (
@@ -644,9 +644,7 @@ class FogTransform(Transform):
644
644
  raw.get("scenario_scope", config.get("gpu_scenario_scope", "sample"))
645
645
  ).lower()
646
646
  if scenario_scope not in _GPU_BATCH_SCOPE_VALUES:
647
- raise ValueError(
648
- "gpu_batching.scenario_scope must be 'sample' or 'batch'"
649
- )
647
+ raise ValueError("gpu_batching.scenario_scope must be 'sample' or 'batch'")
650
648
 
651
649
  if "condition_parameter_scope" in raw:
652
650
  condition_scope = str(raw["condition_parameter_scope"]).lower()
@@ -808,7 +806,7 @@ class FogTransform(Transform):
808
806
  if freeze_sampled_parameters
809
807
  else None
810
808
  ),
811
- )
809
+ )
812
810
  model_name = select_model(effective_config, rng)
813
811
  model_cfg = resolve_model_config(model_name, models_cfg)
814
812
  if freeze_sampled_parameters:
@@ -831,9 +829,7 @@ class FogTransform(Transform):
831
829
  if freeze_sampled_parameters:
832
830
  effective_config = _freeze_distribution_specs(effective_config, rng)
833
831
  models_cfg = (
834
- effective_config.get("models")
835
- or effective_config.get("fog_models")
836
- or {}
832
+ effective_config.get("models") or effective_config.get("fog_models") or {}
837
833
  )
838
834
  scenario_overrides = self._scenario_model_overrides(payload)
839
835
 
@@ -952,10 +948,12 @@ class FogTransform(Transform):
952
948
  rng,
953
949
  self.contrast_threshold_default,
954
950
  )
955
- target_beta, _target_visibility, _target_contrast = resolve_scattering_coefficient(
956
- target_cfg,
957
- rng,
958
- self.contrast_threshold_default,
951
+ target_beta, _target_visibility, _target_contrast = (
952
+ resolve_scattering_coefficient(
953
+ target_cfg,
954
+ rng,
955
+ self.contrast_threshold_default,
956
+ )
959
957
  )
960
958
  beta = max(0.0, float(base_beta + (target_beta - base_beta) * weight))
961
959
  cfg.pop("visibility_m", None)
@@ -1090,8 +1088,7 @@ class FogTransform(Transform):
1090
1088
  return self._progressive_neutral_number(key, float(target))
1091
1089
  if _is_numeric_sequence(target):
1092
1090
  return [
1093
- self._progressive_neutral_number(key, float(item))
1094
- for item in target
1091
+ self._progressive_neutral_number(key, float(item)) for item in target
1095
1092
  ]
1096
1093
  if isinstance(target, dict):
1097
1094
  return {
@@ -1135,9 +1132,7 @@ class FogTransform(Transform):
1135
1132
  rng,
1136
1133
  scenario=scenario,
1137
1134
  sample_scenario=False,
1138
- freeze_sampled_parameters=(
1139
- self.gpu_condition_parameter_scope == "batch"
1140
- ),
1135
+ freeze_sampled_parameters=(self.gpu_condition_parameter_scope == "batch"),
1141
1136
  )
1142
1137
 
1143
1138
  def _source_extension(self, sample: dict, backend: Any | None = None) -> str:
@@ -1158,7 +1153,9 @@ class FogTransform(Transform):
1158
1153
  return raw if isinstance(raw, str) and raw else None
1159
1154
 
1160
1155
  def _augmentation_hierarchy_separator(self, backend: Any) -> str:
1161
- separator = getattr(getattr(backend, "dataset_writer", None), "_separator", None)
1156
+ separator = getattr(
1157
+ getattr(backend, "dataset_writer", None), "_separator", None
1158
+ )
1162
1159
  if isinstance(separator, str) and separator and separator != "+":
1163
1160
  return separator
1164
1161
  return ":"
@@ -1646,6 +1643,13 @@ class FogTransform(Transform):
1646
1643
  estimated_airlight_t=estimated_airlight_t,
1647
1644
  device=self.torch_device,
1648
1645
  )
1646
+ render_depth_t = apply_sky_fog_path_torch(
1647
+ depth_t,
1648
+ sky_mask,
1649
+ intrinsics,
1650
+ model_cfg,
1651
+ rng,
1652
+ )
1649
1653
  height = int(depth_t.shape[0])
1650
1654
  width = int(depth_t.shape[1])
1651
1655
 
@@ -1694,7 +1698,7 @@ class FogTransform(Transform):
1694
1698
  )
1695
1699
  ls_field = apply_ls_gradient_torch(
1696
1700
  ls_field,
1697
- depth_t,
1701
+ render_depth_t,
1698
1702
  k_field,
1699
1703
  model_cfg,
1700
1704
  ls_cfg,
@@ -1706,7 +1710,7 @@ class FogTransform(Transform):
1706
1710
 
1707
1711
  foggy, k_map, ls_map = render_fog_fields_torch(
1708
1712
  rgb_t,
1709
- depth_t,
1713
+ render_depth_t,
1710
1714
  k_field,
1711
1715
  ls_field,
1712
1716
  model_cfg,
@@ -1817,7 +1821,9 @@ class FogTransform(Transform):
1817
1821
  "rgb": rgb,
1818
1822
  "depth": depth,
1819
1823
  "intrinsics": intrinsics,
1820
- "sky_mask": normalize_sky_mask(sample["semantic_segmentation"]),
1824
+ "sky_mask": normalize_sky_mask(
1825
+ sample["semantic_segmentation"]
1826
+ ),
1821
1827
  "rng": rng,
1822
1828
  "model_name": plan.model_name,
1823
1829
  "model_cfg": plan.model_cfg,
@@ -1903,7 +1909,16 @@ class FogTransform(Transform):
1903
1909
  rendered = [
1904
1910
  render_fog_fields_torch(
1905
1911
  rgb_batch[idx],
1906
- depth_tensors[idx],
1912
+ apply_sky_fog_path_torch(
1913
+ depth_tensors[idx],
1914
+ torch.from_numpy(item["sky_mask"]).to(
1915
+ device=device,
1916
+ dtype=torch.bool,
1917
+ ),
1918
+ item.get("intrinsics"),
1919
+ item["model_cfg"],
1920
+ item["rng"],
1921
+ ),
1907
1922
  k_means[idx],
1908
1923
  ls_base[idx].view(1, 1, 3),
1909
1924
  item["model_cfg"],
@@ -1943,9 +1958,7 @@ class FogTransform(Transform):
1943
1958
  )
1944
1959
 
1945
1960
  for idx, item in enumerate(uniform_items):
1946
- foggy_img = (
1947
- torch.clamp(foggy[idx], 0.0, 1.0).cpu().numpy()
1948
- )
1961
+ foggy_img = torch.clamp(foggy[idx], 0.0, 1.0).cpu().numpy()
1949
1962
  airlight_np = ls_base[idx].detach().cpu().numpy()
1950
1963
  sample_ref = {
1951
1964
  "id": item["sample_id"],
@@ -1997,7 +2010,8 @@ class FogTransform(Transform):
1997
2010
  K_np = item.get("intrinsics")
1998
2011
  if K_np is not None:
1999
2012
  K_t = torch.from_numpy(K_np).to(
2000
- device=device, dtype=torch.float32,
2013
+ device=device,
2014
+ dtype=torch.float32,
2001
2015
  )
2002
2016
  depth_t = planar_to_radial_depth_torch(depth_t, K_t)
2003
2017
  sky_mask_t = (
@@ -2405,18 +2419,16 @@ class FogTransform(Transform):
2405
2419
  variant.file_id_hierarchy_name,
2406
2420
  )
2407
2421
  output_basename = f"{variant.id}{backend.output_extension or '.npy'}"
2408
- attributes = (
2409
- self._output_variant_attributes(
2410
- sample,
2411
- variant,
2412
- model_name=model_name,
2413
- beta=float(beta) if beta is not None else float(np.mean(value)),
2414
- airlight=(
2415
- airlight
2416
- if airlight is not None
2417
- else np.asarray([np.nan, np.nan, np.nan], dtype=np.float32)
2418
- ),
2419
- )
2422
+ attributes = self._output_variant_attributes(
2423
+ sample,
2424
+ variant,
2425
+ model_name=model_name,
2426
+ beta=float(beta) if beta is not None else float(np.mean(value)),
2427
+ airlight=(
2428
+ airlight
2429
+ if airlight is not None
2430
+ else np.asarray([np.nan, np.nan, np.nan], dtype=np.float32)
2431
+ ),
2420
2432
  )
2421
2433
  backend.write(
2422
2434
  sample,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: euler-preprocess
3
- Version: 3.8.0
3
+ Version: 3.10.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
@@ -499,12 +499,39 @@ pixels when a sky mask is available.
499
499
 
500
500
  **RGB** — The clean scene image. Normalised to float32 in [0, 1]. This is the *I(x)* term in the fog equation -- it gets blended with the airlight according to transmittance.
501
501
 
502
- **Depth** — A per-pixel depth map in **metres**. Provides the *d(x)* term in the transmittance calculation `t(x) = exp(-k * d(x))`. Pixels with greater depth receive more fog. Invalid values (NaN, inf, negative) are clamped to zero (treated as infinitely close, receiving no fog).
502
+ **Depth** — A per-pixel depth map in **metres**. Provides the *d(x)* term in the transmittance calculation `t(x) = exp(-k * d(x))`. Pixels with greater depth receive more fog. Invalid values (NaN, inf, negative) are clamped to zero (treated as infinitely close, receiving no fog). By default, depth remains authoritative even where the semantic map says sky. For noisy real-world predictions, `sky_fog_path` can instead derive a density-weighted sky path through a bounded fog layer from the camera intrinsics and assumed orientation.
503
503
 
504
- **Semantic Segmentation** — A per-pixel semantic segmentation map from which a boolean sky mask is derived, loaded via euler-loading's dataset-specific `semantic_segmentation` loader. The sky mask is used for airlight estimation when the `airlight` method is `"from_sky"`: the mean RGB of all sky pixels in the clean image is used as the airlight colour *L_s*.
504
+ **Semantic Segmentation** — A per-pixel semantic segmentation map from which a boolean sky mask is derived, loaded via euler-loading's dataset-specific `semantic_segmentation` loader. The sky mask is used for airlight estimation when the `airlight` method is `"from_sky"`: the mean RGB of all sky pixels in the clean image is used as the airlight colour *L_s*. When `sky_fog_path` is configured, semantic sky pixels use the modeled fog-layer path while depth remains authoritative everywhere else.
505
505
 
506
506
  **Intrinsics** *(optional)* — When present, planar (z-buffer) depth is converted to radial (Euclidean) depth before fog is applied.
507
507
 
508
+ For a bounded valley-fog treatment of sky, add this inside a model config:
509
+
510
+ ```json
511
+ "sky_fog_path": {
512
+ "mode": "layer",
513
+ "camera_height_m": 1.6,
514
+ "fog_valley_peak_m": 80.0,
515
+ "camera_pitch_deg": 0.0,
516
+ "camera_roll_deg": 0.0,
517
+ "density_profile": "linear_fade",
518
+ "transition_height_m": 10.0,
519
+ "max_path_m": 1000.0
520
+ }
521
+ ```
522
+
523
+ Pixel rays are reconstructed from the intrinsics. Near-horizon rays accumulate
524
+ long paths through fog, while upward rays exit the layer sooner. The default
525
+ `linear_fade` profile has full density below the transition and decreases
526
+ linearly to zero at `fog_valley_peak_m`; both `camera_height_m` and
527
+ `fog_valley_peak_m` are elevations in the same local vertical coordinate.
528
+ `uniform` keeps full density up to the top. Positive `camera_pitch_deg` points
529
+ the optical axis upward. `max_path_m` bounds horizon rays. The fog-layer path feeds the same fog
530
+ opacity, scene-illumination, and atmospheric-light-gradient heuristics as
531
+ ordinary depth. The original depth is retained for non-sky pixels and capture
532
+ effects. Enabling this option without intrinsics raises an error rather than
533
+ silently reverting to predicted sky depth.
534
+
508
535
  ### Airlight Estimation
509
536
 
510
537
  The `airlight` config key selects how the atmospheric light *L_s* is estimated:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "euler-preprocess"
3
- version = "3.8.0"
3
+ version = "3.10.0"
4
4
  description = "Physics-based preprocessing (fog, etc.) for RGB+depth datasets"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.9"
@@ -54,7 +54,7 @@ def test_dense_gloomy_daylight_config_activates_realism_stack() -> None:
54
54
  assert profile["isp"]["tone_map_lut_domain"] == "linear"
55
55
  assert len(profile["isp"]["tone_map_lut"]) >= 2
56
56
 
57
- assert config["scenario_profiles"]
57
+ assert len(config["scenario_profiles"]) == 6
58
58
  clear = _profile_by_name(
59
59
  config["scenario_profiles"],
60
60
  "clear_weather_camera_reference",
@@ -62,6 +62,13 @@ def test_dense_gloomy_daylight_config_activates_realism_stack() -> None:
62
62
  assert clear["clear_weather"] is True
63
63
  assert clear["weight"] == 0.1
64
64
  assert clear["model"] == "uniform"
65
+ high_visibility = _profile_by_name(
66
+ config["scenario_profiles"],
67
+ "clear_high_visibility_daylight_reference",
68
+ )
69
+ assert high_visibility.get("clear_weather", False) is False
70
+ assert high_visibility["weight"] == 0.09
71
+ assert high_visibility["model"] == "heterogeneous_k_ls"
65
72
  assert sum(scenario["weight"] for scenario in config["scenario_profiles"]) == 1.0
66
73
  underexposed_condition = _profile_by_name(
67
74
  profile["sensor"]["condition_profiles"],
@@ -46,11 +46,13 @@ def _write_rgb_dataset(root: Path) -> None:
46
46
  meta={"range": [0, 255]},
47
47
  euler_loading={"loader": "generic_dense_depth", "function": "rgb"},
48
48
  )
49
- rgb = np.dstack([
50
- np.full((4, 6), 32, dtype=np.uint8),
51
- np.full((4, 6), 96, dtype=np.uint8),
52
- np.full((4, 6), 160, dtype=np.uint8),
53
- ])
49
+ rgb = np.dstack(
50
+ [
51
+ np.full((4, 6), 32, dtype=np.uint8),
52
+ np.full((4, 6), 96, dtype=np.uint8),
53
+ np.full((4, 6), 160, dtype=np.uint8),
54
+ ]
55
+ )
54
56
  path = writer.get_path("Scene01/Camera_0/00001", "00001.png")
55
57
  write_rgb(str(path), rgb)
56
58
  writer.save_index()
@@ -247,7 +249,9 @@ def test_writes_scattering_and_airlight_maps(tmp_path: Path) -> None:
247
249
  }
248
250
 
249
251
  transform = FogTransform(
250
- config_path=str(_write_fog_config(tmp_path / "fog_cfg.json", visibility_m=visibility_m)),
252
+ config_path=str(
253
+ _write_fog_config(tmp_path / "fog_cfg.json", visibility_m=visibility_m)
254
+ ),
251
255
  out_path=str(backends["rgb"].root),
252
256
  output_backends=backends,
253
257
  )
@@ -318,20 +322,10 @@ def test_stepped_augmentations_write_file_id_layout_and_attributes(
318
322
  assert path.exists()
319
323
 
320
324
  scattering_path = (
321
- pipeline_root
322
- / "scattering"
323
- / "Scene01"
324
- / "Camera_0"
325
- / "00001"
326
- / "mor_10m.npy"
325
+ pipeline_root / "scattering" / "Scene01" / "Camera_0" / "00001" / "mor_10m.npy"
327
326
  )
328
327
  airlight_path = (
329
- pipeline_root
330
- / "airlight"
331
- / "Scene01"
332
- / "Camera_0"
333
- / "00001"
334
- / "mor_10m.npy"
328
+ pipeline_root / "airlight" / "Scene01" / "Camera_0" / "00001" / "mor_10m.npy"
335
329
  )
336
330
  assert scattering_path.exists()
337
331
  assert airlight_path.exists()
@@ -547,7 +541,9 @@ def test_only_scattering_target_writes_only_scattering(tmp_path: Path) -> None:
547
541
  )
548
542
  transform.run(dataset)
549
543
 
550
- assert (pipeline_root / "scattering" / "Scene01" / "Camera_0" / "00001.npy").exists()
544
+ assert (
545
+ pipeline_root / "scattering" / "Scene01" / "Camera_0" / "00001.npy"
546
+ ).exists()
551
547
  assert not (pipeline_root / "airlight").exists()
552
548
 
553
549
 
@@ -1913,13 +1909,7 @@ def _deterministic_sensor_pipeline(
1913
1909
  }
1914
1910
  if sensor_overrides:
1915
1911
  sensor.update(sensor_overrides)
1916
- return CaptureArtifactPipeline.from_config(
1917
- {
1918
- "capture": {
1919
- "stages": [sensor]
1920
- }
1921
- }
1922
- )
1912
+ return CaptureArtifactPipeline.from_config({"capture": {"stages": [sensor]}})
1923
1913
 
1924
1914
 
1925
1915
  def test_sensor_noise_adjustment_scales_authored_noise_groups() -> None:
@@ -1980,7 +1970,9 @@ def test_sensor_noise_adjustment_scales_authored_noise_groups() -> None:
1980
1970
  < chroma["shadow_recovery_noise"]["blue_chroma_gain"]
1981
1971
  )
1982
1972
  assert static["read_noise_sigma"]["min"] < chroma["read_noise_sigma"]["min"]
1983
- assert static["noise_modulation"]["max_gain"] == chroma["noise_modulation"]["max_gain"]
1973
+ assert (
1974
+ static["noise_modulation"]["max_gain"] == chroma["noise_modulation"]["max_gain"]
1975
+ )
1984
1976
 
1985
1977
 
1986
1978
  def test_sensor_noise_adjustment_level_changes_output_noise() -> None:
@@ -2880,3 +2872,87 @@ def test_apply_model_accepts_direct_scattering_coefficient() -> None:
2880
2872
 
2881
2873
  assert k_mean == 0.123
2882
2874
  np.testing.assert_allclose(k_map, 0.123)
2875
+
2876
+
2877
+ def test_sky_fog_path_uses_ray_angle_and_linear_density_fade() -> None:
2878
+ from euler_preprocess.fog.models import apply_sky_fog_path_np
2879
+
2880
+ depth = np.full((3, 3), 2.0, dtype=np.float32)
2881
+ sky = np.zeros((3, 3), dtype=bool)
2882
+ sky[:, 1] = True
2883
+ intrinsics = np.array(
2884
+ [[1.0, 0.0, 1.0], [0.0, 1.0, 1.0], [0.0, 0.0, 1.0]],
2885
+ dtype=np.float32,
2886
+ )
2887
+ cfg = {
2888
+ "sky_fog_path": {
2889
+ "camera_height_m": 0.0,
2890
+ "fog_valley_peak_m": 10.0,
2891
+ "transition_height_m": 4.0,
2892
+ "max_path_m": 100.0,
2893
+ }
2894
+ }
2895
+
2896
+ result = apply_sky_fog_path_np(
2897
+ depth, sky, intrinsics, cfg, np.random.default_rng(0)
2898
+ )
2899
+
2900
+ # The vertical density integral is 6 m full-density + 2 m fade.
2901
+ np.testing.assert_allclose(result[0, 1], 8.0 * np.sqrt(2.0), rtol=1e-6)
2902
+ assert result[1, 1] == 100.0 # level-camera horizon is capped
2903
+ assert result[2, 1] == 100.0 # downward ray cannot leave through the top
2904
+ assert result[0, 0] == 2.0 # non-sky depth remains untouched
2905
+
2906
+
2907
+ def test_sky_fog_path_changes_rendering_without_changing_non_sky_depth() -> None:
2908
+ from euler_preprocess.fog.models import apply_model
2909
+
2910
+ rgb = np.full((3, 3, 3), 0.2, dtype=np.float32)
2911
+ depth = np.full((3, 3), 1.0, dtype=np.float32)
2912
+ sky = np.zeros((3, 3), dtype=bool)
2913
+ sky[:2, 1] = True
2914
+ intrinsics = np.array(
2915
+ [[1.0, 0.0, 1.0], [0.0, 1.0, 1.0], [0.0, 0.0, 1.0]],
2916
+ dtype=np.float32,
2917
+ )
2918
+ cfg = {
2919
+ "scattering_coefficient": 0.1,
2920
+ "atmospheric_light": [0.8, 0.8, 0.8],
2921
+ "sky_fog_path": {
2922
+ "camera_height_m": 0.0,
2923
+ "fog_valley_peak_m": 10.0,
2924
+ "transition_height_m": 4.0,
2925
+ "max_path_m": 100.0,
2926
+ },
2927
+ }
2928
+
2929
+ foggy, _, _, _, _ = apply_model(
2930
+ rgb,
2931
+ depth,
2932
+ "uniform",
2933
+ cfg,
2934
+ np.random.default_rng(0),
2935
+ 0.05,
2936
+ np.full(3, 0.8, dtype=np.float32),
2937
+ sky_mask=sky,
2938
+ intrinsics=intrinsics,
2939
+ )
2940
+
2941
+ assert foggy[1, 1, 0] > foggy[0, 1, 0] > foggy[2, 1, 0]
2942
+ np.testing.assert_allclose(
2943
+ foggy[2, 1],
2944
+ 0.2 * np.exp(-0.1) + 0.8 * (1.0 - np.exp(-0.1)),
2945
+ )
2946
+
2947
+
2948
+ def test_sky_fog_path_requires_intrinsics() -> None:
2949
+ from euler_preprocess.fog.models import apply_sky_fog_path_np
2950
+
2951
+ with pytest.raises(ValueError, match="requires camera intrinsics"):
2952
+ apply_sky_fog_path_np(
2953
+ np.ones((2, 2), dtype=np.float32),
2954
+ np.ones((2, 2), dtype=bool),
2955
+ None,
2956
+ {"sky_fog_path": {"fog_valley_peak_m": 20.0}},
2957
+ np.random.default_rng(0),
2958
+ )
@@ -33,6 +33,7 @@ DEFAULT_RDS_SAMPLE_ZIP = (
33
33
  RDS_CONFIG_PATH = Path("configs/dense_gloomy_daylight_fog_camera.json")
34
34
  SCENARIO_ORDER = (
35
35
  "clear_weather_camera_reference",
36
+ "clear_high_visibility_daylight_reference",
36
37
  "light_haze_soft_overcast",
37
38
  "moderate_gloomy_fog_nominal_camera",
38
39
  "underexposed_dense_gloom",
@@ -137,10 +138,17 @@ def test_dense_gloomy_scenario_profiles_render_real_drive_sim_samples(
137
138
  for sample in rds_samples:
138
139
  metrics = _render_scenario_metrics(transform, sample)
139
140
 
140
- opacity_by_scenario = [
141
- metrics[name]["opacity_mean"] for name in SCENARIO_ORDER
142
- ]
143
- assert opacity_by_scenario == sorted(opacity_by_scenario)
141
+ clear_metrics = metrics["clear_weather_camera_reference"]
142
+ assert clear_metrics["beta"] == 0.0
143
+ assert clear_metrics["opacity_mean"] == 0.0
144
+ assert clear_metrics["opacity_p95"] == 0.0
145
+ fog_profiles = SCENARIO_ORDER[1:]
146
+ assert all(metrics[name]["opacity_mean"] > 0.0 for name in fog_profiles)
147
+ # Adjacent fog profiles intentionally have overlapping distributions,
148
+ # so sampled opacity need not be strictly ordered for every image.
149
+ assert metrics["severe_low_contrast_sensor_stress"]["opacity_mean"] > (
150
+ metrics["clear_high_visibility_daylight_reference"]["opacity_mean"]
151
+ )
144
152
  assert metrics["severe_low_contrast_sensor_stress"]["opacity_mean"] > (
145
153
  metrics["clear_weather_camera_reference"]["opacity_mean"] + 0.25
146
154
  )
@@ -151,7 +159,7 @@ def test_dense_gloomy_scenario_profiles_render_real_drive_sim_samples(
151
159
  assert metrics["severe_low_contrast_sensor_stress"]["luma_mean"] < (
152
160
  metrics["clear_weather_camera_reference"]["luma_mean"] * 0.65
153
161
  )
154
- chromatically_bounded_profiles = SCENARIO_ORDER[:3]
162
+ chromatically_bounded_profiles = SCENARIO_ORDER[:4]
155
163
  assert all(
156
164
  0.75 <= metrics[name]["blue_red_ratio"] <= 1.65
157
165
  for name in chromatically_bounded_profiles
@@ -164,7 +172,7 @@ def test_dense_gloomy_scenario_profiles_render_real_drive_sim_samples(
164
172
  assert metrics["underexposed_dense_gloom"]["blue_red_ratio"] <= 2.5, {
165
173
  sample["id"]: metrics
166
174
  }
167
- stress_profiles = SCENARIO_ORDER[3:]
175
+ stress_profiles = SCENARIO_ORDER[4:]
168
176
  assert all(
169
177
  metrics[name]["blue_minus_red_mean"] <= 0.1 for name in stress_profiles
170
178
  ), {sample["id"]: metrics}
@@ -328,7 +336,12 @@ def _render_profile(
328
336
  profile: dict[str, Any],
329
337
  scenario_index: int,
330
338
  ):
331
- rng = np.random.default_rng(np.random.SeedSequence([1337, scenario_index, 1]))
339
+ # Keep the original five profiles on their historical deterministic seeds
340
+ # when the new clear-weather profile is inserted at the front.
341
+ stable_scenario_index = max(0, scenario_index - 1)
342
+ rng = np.random.default_rng(
343
+ np.random.SeedSequence([1337, stable_scenario_index, 1])
344
+ )
332
345
  plan = transform._resolve_render_plan(
333
346
  rng,
334
347
  scenario=profile,
@@ -346,6 +359,7 @@ def _render_profile(
346
359
  intrinsics=sample["intrinsics"]["intrinsics"],
347
360
  airlight_method=plan.airlight_method,
348
361
  capture_artifacts=plan.capture_artifacts,
362
+ clear_weather=plan.clear_weather,
349
363
  )
350
364
 
351
365