euler-preprocess 3.1.0__tar.gz → 3.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 (53) hide show
  1. {euler_preprocess-3.1.0/euler_preprocess.egg-info → euler_preprocess-3.3.0}/PKG-INFO +23 -1
  2. euler_preprocess-3.1.0/PKG-INFO → euler_preprocess-3.3.0/README.md +22 -14
  3. euler_preprocess-3.3.0/euler_preprocess/fog/__init__.py +11 -0
  4. {euler_preprocess-3.1.0 → euler_preprocess-3.3.0}/euler_preprocess/fog/atmospheric_light.py +8 -5
  5. {euler_preprocess-3.1.0 → euler_preprocess-3.3.0}/euler_preprocess/fog/capture.py +335 -0
  6. euler_preprocess-3.3.0/euler_preprocess/fog/inference.py +379 -0
  7. {euler_preprocess-3.1.0 → euler_preprocess-3.3.0}/euler_preprocess/fog/transform.py +151 -25
  8. euler_preprocess-3.1.0/README.md → euler_preprocess-3.3.0/euler_preprocess.egg-info/PKG-INFO +36 -0
  9. {euler_preprocess-3.1.0 → euler_preprocess-3.3.0}/euler_preprocess.egg-info/SOURCES.txt +1 -0
  10. {euler_preprocess-3.1.0 → euler_preprocess-3.3.0}/pyproject.toml +1 -1
  11. {euler_preprocess-3.1.0 → euler_preprocess-3.3.0}/tests/test_fog_aux_outputs.py +341 -0
  12. euler_preprocess-3.1.0/euler_preprocess/sky_depth/__init__.py +0 -0
  13. {euler_preprocess-3.1.0 → euler_preprocess-3.3.0}/euler_preprocess/__init__.py +0 -0
  14. {euler_preprocess-3.1.0 → euler_preprocess-3.3.0}/euler_preprocess/cli.py +0 -0
  15. {euler_preprocess-3.1.0 → euler_preprocess-3.3.0}/euler_preprocess/common/__init__.py +0 -0
  16. {euler_preprocess-3.1.0 → euler_preprocess-3.3.0}/euler_preprocess/common/dataset.py +0 -0
  17. {euler_preprocess-3.1.0 → euler_preprocess-3.3.0}/euler_preprocess/common/device.py +0 -0
  18. {euler_preprocess-3.1.0 → euler_preprocess-3.3.0}/euler_preprocess/common/intrinsics.py +0 -0
  19. {euler_preprocess-3.1.0 → euler_preprocess-3.3.0}/euler_preprocess/common/io.py +0 -0
  20. {euler_preprocess-3.1.0 → euler_preprocess-3.3.0}/euler_preprocess/common/logging.py +0 -0
  21. {euler_preprocess-3.1.0 → euler_preprocess-3.3.0}/euler_preprocess/common/noise.py +0 -0
  22. {euler_preprocess-3.1.0 → euler_preprocess-3.3.0}/euler_preprocess/common/normalize.py +0 -0
  23. {euler_preprocess-3.1.0 → euler_preprocess-3.3.0}/euler_preprocess/common/output.py +0 -0
  24. {euler_preprocess-3.1.0 → euler_preprocess-3.3.0}/euler_preprocess/common/sampling.py +0 -0
  25. {euler_preprocess-3.1.0 → euler_preprocess-3.3.0}/euler_preprocess/common/transform.py +0 -0
  26. {euler_preprocess-3.1.0 → euler_preprocess-3.3.0}/euler_preprocess/fog/airlight_from_sky.py +0 -0
  27. {euler_preprocess-3.1.0 → euler_preprocess-3.3.0}/euler_preprocess/fog/augmentations.py +0 -0
  28. {euler_preprocess-3.1.0 → euler_preprocess-3.3.0}/euler_preprocess/fog/dcp_airlight.py +0 -0
  29. {euler_preprocess-3.1.0 → euler_preprocess-3.3.0}/euler_preprocess/fog/dcp_airlight_torch.py +0 -0
  30. {euler_preprocess-3.1.0 → euler_preprocess-3.3.0}/euler_preprocess/fog/dcp_heuristic_airlight.py +0 -0
  31. {euler_preprocess-3.1.0 → euler_preprocess-3.3.0}/euler_preprocess/fog/dcp_heuristic_airlight_torch.py +0 -0
  32. {euler_preprocess-3.1.0 → euler_preprocess-3.3.0}/euler_preprocess/fog/foggify.py +0 -0
  33. {euler_preprocess-3.1.0 → euler_preprocess-3.3.0}/euler_preprocess/fog/foggify_logging.py +0 -0
  34. {euler_preprocess-3.1.0 → euler_preprocess-3.3.0}/euler_preprocess/fog/logging.py +0 -0
  35. {euler_preprocess-3.1.0 → euler_preprocess-3.3.0}/euler_preprocess/fog/models.py +0 -0
  36. {euler_preprocess-3.1.0 → euler_preprocess-3.3.0}/euler_preprocess/fog/pipeline.py +0 -0
  37. {euler_preprocess-3.1.0/euler_preprocess/fog → euler_preprocess-3.3.0/euler_preprocess/radial}/__init__.py +0 -0
  38. {euler_preprocess-3.1.0 → euler_preprocess-3.3.0}/euler_preprocess/radial/transform.py +0 -0
  39. {euler_preprocess-3.1.0/euler_preprocess/radial → euler_preprocess-3.3.0/euler_preprocess/sky_depth}/__init__.py +0 -0
  40. {euler_preprocess-3.1.0 → euler_preprocess-3.3.0}/euler_preprocess/sky_depth/transform.py +0 -0
  41. {euler_preprocess-3.1.0 → euler_preprocess-3.3.0}/euler_preprocess.egg-info/dependency_links.txt +0 -0
  42. {euler_preprocess-3.1.0 → euler_preprocess-3.3.0}/euler_preprocess.egg-info/entry_points.txt +0 -0
  43. {euler_preprocess-3.1.0 → euler_preprocess-3.3.0}/euler_preprocess.egg-info/requires.txt +0 -0
  44. {euler_preprocess-3.1.0 → euler_preprocess-3.3.0}/euler_preprocess.egg-info/top_level.txt +0 -0
  45. {euler_preprocess-3.1.0 → euler_preprocess-3.3.0}/setup.cfg +0 -0
  46. {euler_preprocess-3.1.0 → euler_preprocess-3.3.0}/tests/test_airlight_fallback.py +0 -0
  47. {euler_preprocess-3.1.0 → euler_preprocess-3.3.0}/tests/test_cli_sample_selection.py +0 -0
  48. {euler_preprocess-3.1.0 → euler_preprocess-3.3.0}/tests/test_dcp_heuristic_airlight.py +0 -0
  49. {euler_preprocess-3.1.0 → euler_preprocess-3.3.0}/tests/test_foggify_integration.py +0 -0
  50. {euler_preprocess-3.1.0 → euler_preprocess-3.3.0}/tests/test_radial.py +0 -0
  51. {euler_preprocess-3.1.0 → euler_preprocess-3.3.0}/tests/test_sky_depth.py +0 -0
  52. {euler_preprocess-3.1.0 → euler_preprocess-3.3.0}/tests/test_source_backed_output.py +0 -0
  53. {euler_preprocess-3.1.0 → euler_preprocess-3.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: 3.1.0
3
+ Version: 3.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
@@ -323,6 +323,28 @@ noise is fine-grained rather than blocky. `black_noise_floor` with
323
323
  noise in near-clipped black regions, so the strongest visible noise sits in
324
324
  dim-but-readable shadows.
325
325
 
326
+ Set `sensor.noise_adjustment` for relative, scenario-level noise controls on
327
+ top of the selected camera/condition profile. `level: 1.0` leaves the authored
328
+ profile unchanged; lower values suppress read/static/chroma noise and higher
329
+ values amplify it. `static_chroma_bias` ranges from `-1.0` for more fixed
330
+ pattern, row/column, banding, and bad-pixel noise to `1.0` for more
331
+ chromatic/high-ISO-looking shadow noise:
332
+
333
+ ```json
334
+ {
335
+ "capture_overrides": {
336
+ "sensor": {
337
+ "condition_profile": "nominal_gloom",
338
+ "noise_adjustment": {
339
+ "enabled": true,
340
+ "level": 1.25,
341
+ "static_chroma_bias": 0.35
342
+ }
343
+ }
344
+ }
345
+ }
346
+ ```
347
+
326
348
  Any stage can define `condition_profiles` to sample coherent per-image settings
327
349
  before the stage runs. This is useful for exposure states where ISO, exposure
328
350
  gain, read noise, banding, and dark/fog noise modulation should move together:
@@ -1,17 +1,3 @@
1
- Metadata-Version: 2.4
2
- Name: euler-preprocess
3
- Version: 3.1.0
4
- Summary: Physics-based preprocessing (fog, etc.) for RGB+depth datasets
5
- Requires-Python: >=3.9
6
- Description-Content-Type: text/markdown
7
- Requires-Dist: numpy
8
- Requires-Dist: Pillow
9
- Requires-Dist: euler-loading
10
- Provides-Extra: gpu
11
- Requires-Dist: torch; extra == "gpu"
12
- Provides-Extra: progress
13
- Requires-Dist: tqdm; extra == "progress"
14
-
15
1
  # euler-preprocess
16
2
 
17
3
  Physics-based preprocessing transforms for multi-modal RGB+depth datasets. Built on top of [euler-loading](https://github.com/d-rothen/euler-loading) and [ds-crawler](https://github.com/d-rothen/ds-crawler).
@@ -323,6 +309,28 @@ noise is fine-grained rather than blocky. `black_noise_floor` with
323
309
  noise in near-clipped black regions, so the strongest visible noise sits in
324
310
  dim-but-readable shadows.
325
311
 
312
+ Set `sensor.noise_adjustment` for relative, scenario-level noise controls on
313
+ top of the selected camera/condition profile. `level: 1.0` leaves the authored
314
+ profile unchanged; lower values suppress read/static/chroma noise and higher
315
+ values amplify it. `static_chroma_bias` ranges from `-1.0` for more fixed
316
+ pattern, row/column, banding, and bad-pixel noise to `1.0` for more
317
+ chromatic/high-ISO-looking shadow noise:
318
+
319
+ ```json
320
+ {
321
+ "capture_overrides": {
322
+ "sensor": {
323
+ "condition_profile": "nominal_gloom",
324
+ "noise_adjustment": {
325
+ "enabled": true,
326
+ "level": 1.25,
327
+ "static_chroma_bias": 0.35
328
+ }
329
+ }
330
+ }
331
+ }
332
+ ```
333
+
326
334
  Any stage can define `condition_profiles` to sample coherent per-image settings
327
335
  before the stage runs. This is useful for exposure states where ISO, exposure
328
336
  gain, read noise, banding, and dark/fog noise modulation should move together:
@@ -0,0 +1,11 @@
1
+ from euler_preprocess.fog.inference import (
2
+ FogInferenceResult,
3
+ render_fog_image,
4
+ render_fog_sample,
5
+ )
6
+
7
+ __all__ = [
8
+ "FogInferenceResult",
9
+ "render_fog_image",
10
+ "render_fog_sample",
11
+ ]
@@ -155,12 +155,13 @@ class AtmosphericLightResolver:
155
155
  items: list[dict],
156
156
  device: Any,
157
157
  contrast_threshold_default: float,
158
+ method: str | None = None,
158
159
  ) -> tuple[list[float], "torch.Tensor"]:
159
160
  """Resolve beta and dampened base L_s for a uniform-model torch batch."""
160
161
  al_spec = items[0]["model_cfg"].get("atmospheric_light", "from_sky")
161
162
  airlight_is_estimated = uses_estimated_airlight(al_spec)
162
163
  if airlight_is_estimated:
163
- ls_base = self._estimate_batch_torch(rgb_batch, items, device)
164
+ ls_base = self._estimate_batch_torch(rgb_batch, items, device, method)
164
165
  ls_base = normalize_atmospheric_light_torch(ls_base)
165
166
  else:
166
167
  ls_values = []
@@ -209,17 +210,19 @@ class AtmosphericLightResolver:
209
210
  rgb_batch: "torch.Tensor",
210
211
  items: list[dict],
211
212
  device: Any,
213
+ method: str | None = None,
212
214
  ) -> "torch.Tensor":
213
- if self.method == "from_sky":
215
+ resolved_method = method or self.method
216
+ if resolved_method == "from_sky":
214
217
  return self._estimate_from_sky_batch_torch(rgb_batch, items, device)
215
- estimator = self._get_estimator_torch(self.method)
218
+ estimator = self._get_estimator_torch(resolved_method)
216
219
  if estimator is None:
217
220
  raise RuntimeError(
218
- f"Torch airlight estimator unavailable for method '{self.method}'."
221
+ f"Torch airlight estimator unavailable for method '{resolved_method}'."
219
222
  )
220
223
  al_list = []
221
224
  for idx, item in enumerate(items):
222
- if self.method == "dcp":
225
+ if resolved_method == "dcp":
223
226
  al_list.append(estimator.compute(rgb_batch[idx]))
224
227
  else:
225
228
  sky_mask_t = torch.from_numpy(item["sky_mask"]).to(
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from copy import deepcopy
3
4
  import io
4
5
  import math
5
6
  from dataclasses import dataclass, field
@@ -522,6 +523,7 @@ class SensorStage(ConfiguredCaptureStage):
522
523
  rng,
523
524
  manual_exposure_gain=exposure,
524
525
  )
526
+ config = _apply_sensor_noise_adjustment(config, rng)
525
527
  wb = _sample_triplet(config.get("white_balance", [1.0, 1.0, 1.0]), rng)
526
528
  wb_jitter = _sample_float(config, "white_balance_jitter", 0.0, rng)
527
529
  if wb_jitter > 0.0:
@@ -717,6 +719,7 @@ class SensorStage(ConfiguredCaptureStage):
717
719
  rng,
718
720
  manual_exposure_gain=exposure,
719
721
  )
722
+ config = _apply_sensor_noise_adjustment(config, rng)
720
723
  wb = _sample_triplet(config.get("white_balance", [1.0, 1.0, 1.0]), rng)
721
724
  wb_jitter = _sample_float(config, "white_balance_jitter", 0.0, rng)
722
725
  if wb_jitter > 0.0:
@@ -2152,6 +2155,338 @@ def _resolve_electron_capacity_torch(
2152
2155
  )
2153
2156
 
2154
2157
 
2158
+ def _apply_sensor_noise_adjustment(
2159
+ config: Mapping[str, Any],
2160
+ rng: np.random.Generator,
2161
+ ) -> Mapping[str, Any]:
2162
+ cfg = _config_block(config, "noise_adjustment")
2163
+ if not cfg or not _bool_value(cfg.get("enabled", True)):
2164
+ return config
2165
+
2166
+ level = max(
2167
+ _sample_float_from_keys(
2168
+ cfg,
2169
+ ("level", "amount", "factor", "noise_level"),
2170
+ 1.0,
2171
+ rng,
2172
+ ),
2173
+ 0.0,
2174
+ )
2175
+ bias = float(
2176
+ np.clip(
2177
+ _sample_float_from_keys(
2178
+ cfg,
2179
+ ("static_chroma_bias", "character_bias", "chroma_bias"),
2180
+ 0.0,
2181
+ rng,
2182
+ ),
2183
+ -1.0,
2184
+ 1.0,
2185
+ )
2186
+ )
2187
+ groups = _config_block(cfg, "groups")
2188
+ limits = _config_block(cfg, "limits")
2189
+
2190
+ min_factor = max(_sample_float(limits, "min_factor", 0.0, rng), 0.0)
2191
+ max_factor = max(_sample_float(limits, "max_factor", 8.0, rng), min_factor)
2192
+
2193
+ def group_factor(name: str, base: float) -> float:
2194
+ factor = base * max(_sample_float(groups, name, 1.0, rng), 0.0)
2195
+ return float(np.clip(factor, min_factor, max_factor))
2196
+
2197
+ read_factor = group_factor("read", level * math.pow(2.0, 0.35 * bias))
2198
+ static_factor = group_factor("static", level * math.pow(2.0, -bias))
2199
+ banding_factor = group_factor("banding", static_factor)
2200
+ bad_pixel_factor = group_factor("bad_pixels", static_factor)
2201
+ chroma_factor = group_factor("chroma", level * math.pow(2.0, bias))
2202
+ shadow_luma_factor = group_factor("shadow_luma", level)
2203
+ modulation_factor = group_factor("modulation", 1.0 + (level - 1.0) * 0.75)
2204
+
2205
+ if (
2206
+ abs(read_factor - 1.0) < 1e-9
2207
+ and abs(static_factor - 1.0) < 1e-9
2208
+ and abs(banding_factor - 1.0) < 1e-9
2209
+ and abs(bad_pixel_factor - 1.0) < 1e-9
2210
+ and abs(chroma_factor - 1.0) < 1e-9
2211
+ and abs(shadow_luma_factor - 1.0) < 1e-9
2212
+ and abs(modulation_factor - 1.0) < 1e-9
2213
+ ):
2214
+ return config
2215
+
2216
+ adjusted = deepcopy(dict(config))
2217
+
2218
+ _scale_config_path(adjusted, ("read_noise_electrons",), read_factor, min_value=0.0)
2219
+ _scale_config_path(adjusted, ("read_noise_sigma",), read_factor, min_value=0.0)
2220
+
2221
+ _scale_config_path(adjusted, ("fixed_pattern_sigma",), static_factor, min_value=0.0)
2222
+ _scale_config_path(adjusted, ("row_noise_sigma",), banding_factor, min_value=0.0)
2223
+ _scale_config_path(adjusted, ("column_noise_sigma",), banding_factor, min_value=0.0)
2224
+ _scale_config_path(
2225
+ adjusted,
2226
+ ("banding_modulation",),
2227
+ banding_factor,
2228
+ min_value=0.0,
2229
+ max_value=1.0,
2230
+ )
2231
+
2232
+ max_bad_pixel_probability = _sample_float(
2233
+ limits,
2234
+ "max_bad_pixel_probability",
2235
+ 0.05,
2236
+ rng,
2237
+ )
2238
+ _scale_config_path(
2239
+ adjusted,
2240
+ ("hot_pixel_probability",),
2241
+ bad_pixel_factor,
2242
+ min_value=0.0,
2243
+ max_value=max_bad_pixel_probability,
2244
+ )
2245
+ _scale_config_path(
2246
+ adjusted,
2247
+ ("dead_pixel_probability",),
2248
+ bad_pixel_factor,
2249
+ min_value=0.0,
2250
+ max_value=max_bad_pixel_probability,
2251
+ )
2252
+
2253
+ for key in ("dark_gain", "depth_gain", "fog_gain"):
2254
+ _scale_config_path(
2255
+ adjusted,
2256
+ ("noise_modulation", key),
2257
+ modulation_factor,
2258
+ min_value=0.0,
2259
+ )
2260
+ _scale_config_path(
2261
+ adjusted,
2262
+ ("noise_modulation", "max_gain"),
2263
+ modulation_factor,
2264
+ anchor=1.0,
2265
+ min_value=1.0,
2266
+ )
2267
+
2268
+ _scale_config_path(
2269
+ adjusted,
2270
+ ("shadow_recovery_noise", "luma_sigma"),
2271
+ shadow_luma_factor,
2272
+ min_value=0.0,
2273
+ )
2274
+ _scale_config_path(
2275
+ adjusted,
2276
+ ("shadow_recovery_noise", "chroma_sigma"),
2277
+ chroma_factor,
2278
+ min_value=0.0,
2279
+ )
2280
+ _scale_config_path(
2281
+ adjusted,
2282
+ ("shadow_recovery_noise", "blotch_sigma"),
2283
+ chroma_factor,
2284
+ min_value=0.0,
2285
+ )
2286
+ _scale_config_path(
2287
+ adjusted,
2288
+ ("shadow_recovery_noise", "red_chroma_gain"),
2289
+ chroma_factor,
2290
+ anchor=1.0,
2291
+ min_value=0.0,
2292
+ )
2293
+ _scale_config_path(
2294
+ adjusted,
2295
+ ("shadow_recovery_noise", "blue_chroma_gain"),
2296
+ chroma_factor,
2297
+ anchor=1.0,
2298
+ min_value=0.0,
2299
+ )
2300
+ _scale_config_path(
2301
+ adjusted,
2302
+ ("shadow_recovery_noise", "chroma_axis_correlation"),
2303
+ chroma_factor,
2304
+ min_value=-0.95,
2305
+ max_value=0.95,
2306
+ )
2307
+ return adjusted
2308
+
2309
+
2310
+ def _sample_float_from_keys(
2311
+ config: Mapping[str, Any],
2312
+ keys: tuple[str, ...],
2313
+ default: float,
2314
+ rng: np.random.Generator,
2315
+ ) -> float:
2316
+ for key in keys:
2317
+ if config.get(key) is not None:
2318
+ return _sample_float(config, key, default, rng)
2319
+ return float(default)
2320
+
2321
+
2322
+ def _scale_config_path(
2323
+ config: dict[str, Any],
2324
+ path: tuple[str, ...],
2325
+ factor: float,
2326
+ *,
2327
+ anchor: float = 0.0,
2328
+ min_value: float | None = None,
2329
+ max_value: float | None = None,
2330
+ ) -> None:
2331
+ parent: dict[str, Any] = config
2332
+ for key in path[:-1]:
2333
+ value = parent.get(key)
2334
+ if not isinstance(value, dict):
2335
+ return
2336
+ parent = value
2337
+ key = path[-1]
2338
+ if key not in parent or parent[key] is None:
2339
+ return
2340
+ parent[key] = _scale_numeric_spec(
2341
+ parent[key],
2342
+ factor,
2343
+ anchor=anchor,
2344
+ min_value=min_value,
2345
+ max_value=max_value,
2346
+ )
2347
+
2348
+
2349
+ def _scale_numeric_spec(
2350
+ spec: Any,
2351
+ factor: float,
2352
+ *,
2353
+ anchor: float = 0.0,
2354
+ min_value: float | None = None,
2355
+ max_value: float | None = None,
2356
+ ) -> Any:
2357
+ if isinstance(spec, bool) or spec is None:
2358
+ return spec
2359
+ if isinstance(spec, (int, float)):
2360
+ return _clamp_scaled_number(
2361
+ anchor + (float(spec) - anchor) * factor,
2362
+ min_value,
2363
+ max_value,
2364
+ )
2365
+ if isinstance(spec, list):
2366
+ return [
2367
+ _scale_numeric_spec(
2368
+ value,
2369
+ factor,
2370
+ anchor=anchor,
2371
+ min_value=min_value,
2372
+ max_value=max_value,
2373
+ )
2374
+ for value in spec
2375
+ ]
2376
+ if isinstance(spec, tuple):
2377
+ return tuple(
2378
+ _scale_numeric_spec(
2379
+ value,
2380
+ factor,
2381
+ anchor=anchor,
2382
+ min_value=min_value,
2383
+ max_value=max_value,
2384
+ )
2385
+ for value in spec
2386
+ )
2387
+ if not isinstance(spec, dict):
2388
+ return spec
2389
+
2390
+ scaled = dict(spec)
2391
+ dist = scaled.get("dist")
2392
+ if dist is None:
2393
+ if "value" in scaled:
2394
+ scaled["value"] = _scale_numeric_spec(
2395
+ scaled["value"],
2396
+ factor,
2397
+ anchor=anchor,
2398
+ min_value=min_value,
2399
+ max_value=max_value,
2400
+ )
2401
+ return scaled
2402
+
2403
+ if dist == "constant":
2404
+ scaled["value"] = _scale_numeric_spec(
2405
+ scaled.get("value", 0.0),
2406
+ factor,
2407
+ anchor=anchor,
2408
+ min_value=min_value,
2409
+ max_value=max_value,
2410
+ )
2411
+ elif dist == "uniform":
2412
+ scaled["min"] = _scale_numeric_spec(
2413
+ scaled["min"],
2414
+ factor,
2415
+ anchor=anchor,
2416
+ min_value=min_value,
2417
+ max_value=max_value,
2418
+ )
2419
+ scaled["max"] = _scale_numeric_spec(
2420
+ scaled["max"],
2421
+ factor,
2422
+ anchor=anchor,
2423
+ min_value=min_value,
2424
+ max_value=max_value,
2425
+ )
2426
+ if scaled["min"] > scaled["max"]:
2427
+ scaled["min"], scaled["max"] = scaled["max"], scaled["min"]
2428
+ elif dist == "normal":
2429
+ scaled["mean"] = _scale_numeric_spec(
2430
+ scaled["mean"],
2431
+ factor,
2432
+ anchor=anchor,
2433
+ min_value=min_value,
2434
+ max_value=max_value,
2435
+ )
2436
+ scaled["std"] = max(float(scaled.get("std", 0.0)) * abs(factor), 0.0)
2437
+ for key in ("min", "max"):
2438
+ if key in scaled and scaled[key] is not None:
2439
+ scaled[key] = _scale_numeric_spec(
2440
+ scaled[key],
2441
+ factor,
2442
+ anchor=anchor,
2443
+ min_value=min_value,
2444
+ max_value=max_value,
2445
+ )
2446
+ if scaled.get("min") is not None and scaled.get("max") is not None:
2447
+ if scaled["min"] > scaled["max"]:
2448
+ scaled["min"], scaled["max"] = scaled["max"], scaled["min"]
2449
+ elif dist == "lognormal":
2450
+ if anchor == 0.0 and factor > 0.0:
2451
+ scaled["mean"] = float(scaled.get("mean", 0.0)) + math.log(factor)
2452
+ for key in ("min", "max"):
2453
+ if key in scaled and scaled[key] is not None:
2454
+ scaled[key] = _scale_numeric_spec(
2455
+ scaled[key],
2456
+ factor,
2457
+ anchor=anchor,
2458
+ min_value=min_value,
2459
+ max_value=max_value,
2460
+ )
2461
+ if scaled.get("min") is not None and scaled.get("max") is not None:
2462
+ if scaled["min"] > scaled["max"]:
2463
+ scaled["min"], scaled["max"] = scaled["max"], scaled["min"]
2464
+ elif dist == "choice":
2465
+ scaled["values"] = [
2466
+ _scale_numeric_spec(
2467
+ value,
2468
+ factor,
2469
+ anchor=anchor,
2470
+ min_value=min_value,
2471
+ max_value=max_value,
2472
+ )
2473
+ for value in scaled.get("values", [])
2474
+ ]
2475
+ return scaled
2476
+
2477
+
2478
+ def _clamp_scaled_number(
2479
+ value: float,
2480
+ min_value: float | None,
2481
+ max_value: float | None,
2482
+ ) -> float:
2483
+ if min_value is not None:
2484
+ value = max(float(min_value), value)
2485
+ if max_value is not None:
2486
+ value = min(float(max_value), value)
2487
+ return float(value)
2488
+
2489
+
2155
2490
  def _sensor_noise_modulation(
2156
2491
  signal: np.ndarray,
2157
2492
  context: CaptureContext,