euler-preprocess 3.11.0__tar.gz → 3.13.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.13.0/LICENSE +21 -0
  2. {euler_preprocess-3.11.0/euler_preprocess.egg-info → euler_preprocess-3.13.0}/PKG-INFO +43 -10
  3. euler_preprocess-3.11.0/PKG-INFO → euler_preprocess-3.13.0/README.md +39 -24
  4. {euler_preprocess-3.11.0 → euler_preprocess-3.13.0}/euler_preprocess/fog/capture.py +64 -7
  5. euler_preprocess-3.11.0/README.md → euler_preprocess-3.13.0/euler_preprocess.egg-info/PKG-INFO +56 -9
  6. {euler_preprocess-3.11.0 → euler_preprocess-3.13.0}/euler_preprocess.egg-info/SOURCES.txt +1 -0
  7. {euler_preprocess-3.11.0 → euler_preprocess-3.13.0}/pyproject.toml +10 -1
  8. euler_preprocess-3.13.0/tests/test_tone_map_lut.py +139 -0
  9. euler_preprocess-3.11.0/tests/test_tone_map_lut.py +0 -37
  10. {euler_preprocess-3.11.0 → euler_preprocess-3.13.0}/MANIFEST.in +0 -0
  11. {euler_preprocess-3.11.0 → euler_preprocess-3.13.0}/euler_preprocess/__init__.py +0 -0
  12. {euler_preprocess-3.11.0 → euler_preprocess-3.13.0}/euler_preprocess/cli.py +0 -0
  13. {euler_preprocess-3.11.0 → euler_preprocess-3.13.0}/euler_preprocess/common/__init__.py +0 -0
  14. {euler_preprocess-3.11.0 → euler_preprocess-3.13.0}/euler_preprocess/common/color.py +0 -0
  15. {euler_preprocess-3.11.0 → euler_preprocess-3.13.0}/euler_preprocess/common/dataset.py +0 -0
  16. {euler_preprocess-3.11.0 → euler_preprocess-3.13.0}/euler_preprocess/common/device.py +0 -0
  17. {euler_preprocess-3.11.0 → euler_preprocess-3.13.0}/euler_preprocess/common/intrinsics.py +0 -0
  18. {euler_preprocess-3.11.0 → euler_preprocess-3.13.0}/euler_preprocess/common/io.py +0 -0
  19. {euler_preprocess-3.11.0 → euler_preprocess-3.13.0}/euler_preprocess/common/logging.py +0 -0
  20. {euler_preprocess-3.11.0 → euler_preprocess-3.13.0}/euler_preprocess/common/noise.py +0 -0
  21. {euler_preprocess-3.11.0 → euler_preprocess-3.13.0}/euler_preprocess/common/normalize.py +0 -0
  22. {euler_preprocess-3.11.0 → euler_preprocess-3.13.0}/euler_preprocess/common/output.py +0 -0
  23. {euler_preprocess-3.11.0 → euler_preprocess-3.13.0}/euler_preprocess/common/sampling.py +0 -0
  24. {euler_preprocess-3.11.0 → euler_preprocess-3.13.0}/euler_preprocess/common/transform.py +0 -0
  25. {euler_preprocess-3.11.0 → euler_preprocess-3.13.0}/euler_preprocess/fog/__init__.py +0 -0
  26. {euler_preprocess-3.11.0 → euler_preprocess-3.13.0}/euler_preprocess/fog/airlight_from_sky.py +0 -0
  27. {euler_preprocess-3.11.0 → euler_preprocess-3.13.0}/euler_preprocess/fog/atmospheric_light.py +0 -0
  28. {euler_preprocess-3.11.0 → euler_preprocess-3.13.0}/euler_preprocess/fog/augmentations.py +0 -0
  29. {euler_preprocess-3.11.0 → euler_preprocess-3.13.0}/euler_preprocess/fog/dcp_airlight.py +0 -0
  30. {euler_preprocess-3.11.0 → euler_preprocess-3.13.0}/euler_preprocess/fog/dcp_airlight_torch.py +0 -0
  31. {euler_preprocess-3.11.0 → euler_preprocess-3.13.0}/euler_preprocess/fog/dcp_heuristic_airlight.py +0 -0
  32. {euler_preprocess-3.11.0 → euler_preprocess-3.13.0}/euler_preprocess/fog/dcp_heuristic_airlight_torch.py +0 -0
  33. {euler_preprocess-3.11.0 → euler_preprocess-3.13.0}/euler_preprocess/fog/inference.py +0 -0
  34. {euler_preprocess-3.11.0 → euler_preprocess-3.13.0}/euler_preprocess/fog/logging.py +0 -0
  35. {euler_preprocess-3.11.0 → euler_preprocess-3.13.0}/euler_preprocess/fog/models.py +0 -0
  36. {euler_preprocess-3.11.0 → euler_preprocess-3.13.0}/euler_preprocess/fog/pipeline.py +0 -0
  37. {euler_preprocess-3.11.0 → euler_preprocess-3.13.0}/euler_preprocess/fog/transform.py +0 -0
  38. {euler_preprocess-3.11.0 → euler_preprocess-3.13.0}/euler_preprocess/radial/__init__.py +0 -0
  39. {euler_preprocess-3.11.0 → euler_preprocess-3.13.0}/euler_preprocess/radial/transform.py +0 -0
  40. {euler_preprocess-3.11.0 → euler_preprocess-3.13.0}/euler_preprocess/sky_depth/__init__.py +0 -0
  41. {euler_preprocess-3.11.0 → euler_preprocess-3.13.0}/euler_preprocess/sky_depth/transform.py +0 -0
  42. {euler_preprocess-3.11.0 → euler_preprocess-3.13.0}/euler_preprocess.egg-info/dependency_links.txt +0 -0
  43. {euler_preprocess-3.11.0 → euler_preprocess-3.13.0}/euler_preprocess.egg-info/entry_points.txt +0 -0
  44. {euler_preprocess-3.11.0 → euler_preprocess-3.13.0}/euler_preprocess.egg-info/requires.txt +0 -0
  45. {euler_preprocess-3.11.0 → euler_preprocess-3.13.0}/euler_preprocess.egg-info/top_level.txt +0 -0
  46. {euler_preprocess-3.11.0 → euler_preprocess-3.13.0}/setup.cfg +0 -0
  47. {euler_preprocess-3.11.0 → euler_preprocess-3.13.0}/tests/test_airlight_fallback.py +0 -0
  48. {euler_preprocess-3.11.0 → euler_preprocess-3.13.0}/tests/test_cli_sample_selection.py +0 -0
  49. {euler_preprocess-3.11.0 → euler_preprocess-3.13.0}/tests/test_dcp_heuristic_airlight.py +0 -0
  50. {euler_preprocess-3.11.0 → euler_preprocess-3.13.0}/tests/test_dense_gloomy_daylight_config.py +0 -0
  51. {euler_preprocess-3.11.0 → euler_preprocess-3.13.0}/tests/test_fog_aux_outputs.py +0 -0
  52. {euler_preprocess-3.11.0 → euler_preprocess-3.13.0}/tests/test_fog_aware_auto_exposure.py +0 -0
  53. {euler_preprocess-3.11.0 → euler_preprocess-3.13.0}/tests/test_fog_cpu_gpu_parity.py +0 -0
  54. {euler_preprocess-3.11.0 → euler_preprocess-3.13.0}/tests/test_fog_integration.py +0 -0
  55. {euler_preprocess-3.11.0 → euler_preprocess-3.13.0}/tests/test_radial.py +0 -0
  56. {euler_preprocess-3.11.0 → euler_preprocess-3.13.0}/tests/test_real_drive_sim_scenario_profiles.py +0 -0
  57. {euler_preprocess-3.11.0 → euler_preprocess-3.13.0}/tests/test_scene_illumination.py +0 -0
  58. {euler_preprocess-3.11.0 → euler_preprocess-3.13.0}/tests/test_sensor_identity.py +0 -0
  59. {euler_preprocess-3.11.0 → euler_preprocess-3.13.0}/tests/test_sky_depth.py +0 -0
  60. {euler_preprocess-3.11.0 → euler_preprocess-3.13.0}/tests/test_source_backed_output.py +0 -0
  61. {euler_preprocess-3.11.0 → euler_preprocess-3.13.0}/tests/test_zip_output.py +0 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Daniel Rothenpieler
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -1,9 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: euler-preprocess
3
- Version: 3.11.0
3
+ Version: 3.13.0
4
4
  Summary: Physics-based preprocessing (fog, etc.) for RGB+depth datasets
5
+ Author-email: Daniel Rothenpieler <rothenpielerdaniel@gmail.com>
6
+ License-Expression: MIT
5
7
  Requires-Python: >=3.9
6
8
  Description-Content-Type: text/markdown
9
+ License-File: LICENSE
7
10
  Requires-Dist: numpy
8
11
  Requires-Dist: Pillow
9
12
  Requires-Dist: euler-loading
@@ -11,12 +14,29 @@ Provides-Extra: gpu
11
14
  Requires-Dist: torch; extra == "gpu"
12
15
  Provides-Extra: progress
13
16
  Requires-Dist: tqdm; extra == "progress"
17
+ Dynamic: license-file
14
18
 
15
- # euler-preprocess
19
+ <!-- euler header — shared across the euler packages.
20
+ Per package, change only: the <h1>, the tagline, and the badge URLs. -->
21
+ <p align="center">
22
+ <img src="https://files.chronodle.com/icons/euler.svg" alt="euler" width="96" height="96">
23
+ </p>
16
24
 
17
- Physics-based preprocessing transforms for multi-modal RGB+depth datasets — synthetic
18
- fog, sky-depth normalisation, and planar-to-radial depth conversion. Built on
19
- [euler-loading](https://github.com/d-rothen/euler-loading) and
25
+ <h1 align="center">euler-preprocess</h1>
26
+
27
+ <p align="center">
28
+ <em>Physics-based preprocessing for multi-modal RGB+depth datasets — synthetic fog, sky-depth normalisation, and planar-to-radial depth conversion.</em>
29
+ </p>
30
+
31
+ <p align="center">
32
+ <a href="https://pypi.org/project/euler-preprocess/"><img alt="PyPI" src="https://img.shields.io/pypi/v/euler-preprocess.svg"></a>
33
+ <a href="https://pypi.org/project/euler-preprocess/"><img alt="Python 3.9+" src="https://img.shields.io/badge/python-3.9%2B-blue.svg"></a>
34
+ <a href="LICENSE"><img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-blue.svg"></a>
35
+ </p>
36
+
37
+ ---
38
+
39
+ Built on [euler-loading](https://github.com/d-rothen/euler-loading) and
20
40
  [ds-crawler](https://github.com/d-rothen/ds-crawler).
21
41
 
22
42
  ![Source frame rendered at five meteorological visibility distances](docs/images/fog-visibility-ladder.jpg)
@@ -25,12 +45,13 @@ fog, sky-depth normalisation, and planar-to-radial depth conversion. Built on
25
45
  (`configs/fog_stepped_config.json`). Fog density follows the depth map, so the
26
46
  attenuation is scene-consistent rather than a flat overlay.*
27
47
 
28
- ![Four correlated scene and camera condition profiles](docs/images/camera-scenarios.jpg)
48
+ ![Six correlated scene and camera condition profiles](docs/images/camera-scenarios.jpg)
29
49
 
30
- *The same frame through four `scenario_profiles` from
50
+ *The same frame through all six `scenario_profiles` in
31
51
  `configs/dense_gloomy_daylight_fog_camera.json`. Each profile samples fog density,
32
52
  airlight, exposure, sensor noise and compression together, so weather and camera
33
- response stay correlated.*
53
+ response stay correlated. The last two are deliberately extreme low-light stress
54
+ cases — their auto-exposure targets a mean luminance of ~0.1.*
34
55
 
35
56
  Both sets were rendered with `tools/run_fog_qualitative_samples.py`, which runs a
36
57
  folder of RGB/depth/segmentation samples through the transform and writes images for
@@ -470,8 +491,14 @@ and dark/fog modulation should move together:
470
491
 
471
492
  **Tone mapping.** `isp.tone_map` supports `"reinhard"`, `"aces"`, `"clip"`, and
472
493
  `"lut"`. The LUT mode interpolates a cheap 1D camera-response curve given by
473
- `tone_map_lut`, scaled by `tone_map_strength` and interpreted in
474
- `tone_map_lut_domain`.
494
+ `tone_map_lut` and interpreted in `tone_map_lut_domain`.
495
+
496
+ For `"lut"` and `"aces"`, `tone_map_strength` is an **exponent on the curve's
497
+ response**, not a linear blend: `0` leaves the image unchanged, `1` applies the
498
+ curve exactly, and values above `1` apply it more strongly. Because the blend is
499
+ geometric, output radiance stays strictly non-negative at any strength. For
500
+ `"reinhard"` the strength is a curve parameter (`x / (1 + strength * x)`) as
501
+ before.
475
502
 
476
503
  ### Scenario Profiles
477
504
 
@@ -653,3 +680,9 @@ The transform config takes no parameters (`{}`); intrinsics are read from the
653
680
  CLI runs write a source-backed depth dataset mirroring the input depth modality's layout
654
681
  and writer metadata, with `meta.radial_depth` set to `true` in the emitted `index.json`.
655
682
  Standalone `RadialTransform(...)` usage keeps the legacy `.npy` output behaviour.
683
+
684
+ ---
685
+
686
+ ## License
687
+
688
+ [MIT](LICENSE) © Daniel Rothenpieler
@@ -1,22 +1,24 @@
1
- Metadata-Version: 2.4
2
- Name: euler-preprocess
3
- Version: 3.11.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
- # euler-preprocess
16
-
17
- Physics-based preprocessing transforms for multi-modal RGB+depth datasets — synthetic
18
- fog, sky-depth normalisation, and planar-to-radial depth conversion. Built on
19
- [euler-loading](https://github.com/d-rothen/euler-loading) and
1
+ <!-- euler header — shared across the euler packages.
2
+ Per package, change only: the <h1>, the tagline, and the badge URLs. -->
3
+ <p align="center">
4
+ <img src="https://files.chronodle.com/icons/euler.svg" alt="euler" width="96" height="96">
5
+ </p>
6
+
7
+ <h1 align="center">euler-preprocess</h1>
8
+
9
+ <p align="center">
10
+ <em>Physics-based preprocessing for multi-modal RGB+depth datasets — synthetic fog, sky-depth normalisation, and planar-to-radial depth conversion.</em>
11
+ </p>
12
+
13
+ <p align="center">
14
+ <a href="https://pypi.org/project/euler-preprocess/"><img alt="PyPI" src="https://img.shields.io/pypi/v/euler-preprocess.svg"></a>
15
+ <a href="https://pypi.org/project/euler-preprocess/"><img alt="Python 3.9+" src="https://img.shields.io/badge/python-3.9%2B-blue.svg"></a>
16
+ <a href="LICENSE"><img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-blue.svg"></a>
17
+ </p>
18
+
19
+ ---
20
+
21
+ Built on [euler-loading](https://github.com/d-rothen/euler-loading) and
20
22
  [ds-crawler](https://github.com/d-rothen/ds-crawler).
21
23
 
22
24
  ![Source frame rendered at five meteorological visibility distances](docs/images/fog-visibility-ladder.jpg)
@@ -25,12 +27,13 @@ fog, sky-depth normalisation, and planar-to-radial depth conversion. Built on
25
27
  (`configs/fog_stepped_config.json`). Fog density follows the depth map, so the
26
28
  attenuation is scene-consistent rather than a flat overlay.*
27
29
 
28
- ![Four correlated scene and camera condition profiles](docs/images/camera-scenarios.jpg)
30
+ ![Six correlated scene and camera condition profiles](docs/images/camera-scenarios.jpg)
29
31
 
30
- *The same frame through four `scenario_profiles` from
32
+ *The same frame through all six `scenario_profiles` in
31
33
  `configs/dense_gloomy_daylight_fog_camera.json`. Each profile samples fog density,
32
34
  airlight, exposure, sensor noise and compression together, so weather and camera
33
- response stay correlated.*
35
+ response stay correlated. The last two are deliberately extreme low-light stress
36
+ cases — their auto-exposure targets a mean luminance of ~0.1.*
34
37
 
35
38
  Both sets were rendered with `tools/run_fog_qualitative_samples.py`, which runs a
36
39
  folder of RGB/depth/segmentation samples through the transform and writes images for
@@ -470,8 +473,14 @@ and dark/fog modulation should move together:
470
473
 
471
474
  **Tone mapping.** `isp.tone_map` supports `"reinhard"`, `"aces"`, `"clip"`, and
472
475
  `"lut"`. The LUT mode interpolates a cheap 1D camera-response curve given by
473
- `tone_map_lut`, scaled by `tone_map_strength` and interpreted in
474
- `tone_map_lut_domain`.
476
+ `tone_map_lut` and interpreted in `tone_map_lut_domain`.
477
+
478
+ For `"lut"` and `"aces"`, `tone_map_strength` is an **exponent on the curve's
479
+ response**, not a linear blend: `0` leaves the image unchanged, `1` applies the
480
+ curve exactly, and values above `1` apply it more strongly. Because the blend is
481
+ geometric, output radiance stays strictly non-negative at any strength. For
482
+ `"reinhard"` the strength is a curve parameter (`x / (1 + strength * x)`) as
483
+ before.
475
484
 
476
485
  ### Scenario Profiles
477
486
 
@@ -653,3 +662,9 @@ The transform config takes no parameters (`{}`); intrinsics are read from the
653
662
  CLI runs write a source-backed depth dataset mirroring the input depth modality's layout
654
663
  and writer metadata, with `meta.radial_depth` set to `true` in the emitted `index.json`.
655
664
  Standalone `RadialTransform(...)` usage keeps the legacy `.npy` output behaviour.
665
+
666
+ ---
667
+
668
+ ## License
669
+
670
+ [MIT](LICENSE) © Daniel Rothenpieler
@@ -4960,6 +4960,48 @@ def _apply_bad_pixels_torch(
4960
4960
  return out
4961
4961
 
4962
4962
 
4963
+ # Radiance floor used when blending tone curves in log space, and the bounds
4964
+ # applied to the log-space result so extreme strengths cannot overflow ``exp``.
4965
+ _TONE_CURVE_FLOOR = 1e-8
4966
+ _TONE_CURVE_LOG_MIN = -80.0
4967
+ _TONE_CURVE_LOG_MAX = 20.0
4968
+
4969
+
4970
+ def _blend_tone_curve_np(
4971
+ image: np.ndarray,
4972
+ mapped: np.ndarray,
4973
+ strength: float,
4974
+ ) -> np.ndarray:
4975
+ """Apply a tone curve with ``strength`` acting as an exponent on its response.
4976
+
4977
+ The blend is geometric rather than linear: ``strength`` exponentiates the
4978
+ per-pixel response ratio ``mapped / image``, so ``0`` returns the input
4979
+ unchanged, ``1`` reproduces the curve exactly, and values above ``1`` apply
4980
+ the curve more strongly. A linear blend extrapolates through zero once
4981
+ ``strength`` exceeds ``1 / (1 - toe_slope)``, which drives every shadow
4982
+ pixel negative and clips whole frames to black; the geometric form stays
4983
+ strictly non-negative for any strength.
4984
+ """
4985
+ if strength == 1.0:
4986
+ return np.asarray(mapped, dtype=np.float32)
4987
+ if strength == 0.0:
4988
+ return np.asarray(image, dtype=np.float32)
4989
+
4990
+ image = np.asarray(image, dtype=np.float32)
4991
+ mapped = np.asarray(mapped, dtype=np.float32)
4992
+ log_image = np.log(np.maximum(image, _TONE_CURVE_FLOOR))
4993
+ log_mapped = np.log(np.maximum(mapped, _TONE_CURVE_FLOOR))
4994
+ log_blend = np.clip(
4995
+ log_image + strength * (log_mapped - log_image),
4996
+ _TONE_CURVE_LOG_MIN,
4997
+ _TONE_CURVE_LOG_MAX,
4998
+ )
4999
+ return np.where(image > 0.0, np.exp(log_blend), 0.0).astype(
5000
+ np.float32,
5001
+ copy=False,
5002
+ )
5003
+
5004
+
4963
5005
  def _apply_tone_map_np(
4964
5006
  image: np.ndarray,
4965
5007
  mode: str,
@@ -4977,18 +5019,33 @@ def _apply_tone_map_np(
4977
5019
  d = 0.59
4978
5020
  e = 0.14
4979
5021
  mapped = (img * (a * img + b)) / (img * (c * img + d) + e)
4980
- return img * (1.0 - strength) + mapped * strength
5022
+ return _blend_tone_curve_np(img, mapped, strength)
4981
5023
  if mode == "clip":
4982
5024
  return _clip01(img)
4983
5025
  if mode == "lut":
4984
5026
  mapped = _apply_tone_map_lut_np(img, config or {})
4985
- return (img * (1.0 - strength) + mapped * strength).astype(
4986
- np.float32,
4987
- copy=False,
4988
- )
5027
+ return _blend_tone_curve_np(img, mapped, strength)
4989
5028
  raise ValueError(f"Unsupported tone_map: {mode}")
4990
5029
 
4991
5030
 
5031
+ def _blend_tone_curve_torch(image, mapped, strength: float):
5032
+ """Torch counterpart of :func:`_blend_tone_curve_np`."""
5033
+ if strength == 1.0:
5034
+ return mapped
5035
+ if strength == 0.0:
5036
+ return image
5037
+
5038
+ log_image = torch.log(torch.clamp(image, min=_TONE_CURVE_FLOOR))
5039
+ log_mapped = torch.log(torch.clamp(mapped, min=_TONE_CURVE_FLOOR))
5040
+ log_blend = torch.clamp(
5041
+ log_image + strength * (log_mapped - log_image),
5042
+ min=_TONE_CURVE_LOG_MIN,
5043
+ max=_TONE_CURVE_LOG_MAX,
5044
+ )
5045
+ blended = torch.exp(log_blend)
5046
+ return torch.where(image > 0.0, blended, torch.zeros_like(blended))
5047
+
5048
+
4992
5049
  def _apply_tone_map_torch(
4993
5050
  image,
4994
5051
  mode: str,
@@ -5006,12 +5063,12 @@ def _apply_tone_map_torch(
5006
5063
  d = 0.59
5007
5064
  e = 0.14
5008
5065
  mapped = (img * (a * img + b)) / (img * (c * img + d) + e)
5009
- return img * (1.0 - strength) + mapped * strength
5066
+ return _blend_tone_curve_torch(img, mapped, strength)
5010
5067
  if mode == "clip":
5011
5068
  return _clip01_torch(img)
5012
5069
  if mode == "lut":
5013
5070
  mapped = _apply_tone_map_lut_torch(img, config or {})
5014
- return img * (1.0 - strength) + mapped * strength
5071
+ return _blend_tone_curve_torch(img, mapped, strength)
5015
5072
  raise ValueError(f"Unsupported tone_map: {mode}")
5016
5073
 
5017
5074
 
@@ -1,8 +1,42 @@
1
- # euler-preprocess
1
+ Metadata-Version: 2.4
2
+ Name: euler-preprocess
3
+ Version: 3.13.0
4
+ Summary: Physics-based preprocessing (fog, etc.) for RGB+depth datasets
5
+ Author-email: Daniel Rothenpieler <rothenpielerdaniel@gmail.com>
6
+ License-Expression: MIT
7
+ Requires-Python: >=3.9
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE
10
+ Requires-Dist: numpy
11
+ Requires-Dist: Pillow
12
+ Requires-Dist: euler-loading
13
+ Provides-Extra: gpu
14
+ Requires-Dist: torch; extra == "gpu"
15
+ Provides-Extra: progress
16
+ Requires-Dist: tqdm; extra == "progress"
17
+ Dynamic: license-file
18
+
19
+ <!-- euler header — shared across the euler packages.
20
+ Per package, change only: the <h1>, the tagline, and the badge URLs. -->
21
+ <p align="center">
22
+ <img src="https://files.chronodle.com/icons/euler.svg" alt="euler" width="96" height="96">
23
+ </p>
24
+
25
+ <h1 align="center">euler-preprocess</h1>
26
+
27
+ <p align="center">
28
+ <em>Physics-based preprocessing for multi-modal RGB+depth datasets — synthetic fog, sky-depth normalisation, and planar-to-radial depth conversion.</em>
29
+ </p>
30
+
31
+ <p align="center">
32
+ <a href="https://pypi.org/project/euler-preprocess/"><img alt="PyPI" src="https://img.shields.io/pypi/v/euler-preprocess.svg"></a>
33
+ <a href="https://pypi.org/project/euler-preprocess/"><img alt="Python 3.9+" src="https://img.shields.io/badge/python-3.9%2B-blue.svg"></a>
34
+ <a href="LICENSE"><img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-blue.svg"></a>
35
+ </p>
2
36
 
3
- Physics-based preprocessing transforms for multi-modal RGB+depth datasets — synthetic
4
- fog, sky-depth normalisation, and planar-to-radial depth conversion. Built on
5
- [euler-loading](https://github.com/d-rothen/euler-loading) and
37
+ ---
38
+
39
+ Built on [euler-loading](https://github.com/d-rothen/euler-loading) and
6
40
  [ds-crawler](https://github.com/d-rothen/ds-crawler).
7
41
 
8
42
  ![Source frame rendered at five meteorological visibility distances](docs/images/fog-visibility-ladder.jpg)
@@ -11,12 +45,13 @@ fog, sky-depth normalisation, and planar-to-radial depth conversion. Built on
11
45
  (`configs/fog_stepped_config.json`). Fog density follows the depth map, so the
12
46
  attenuation is scene-consistent rather than a flat overlay.*
13
47
 
14
- ![Four correlated scene and camera condition profiles](docs/images/camera-scenarios.jpg)
48
+ ![Six correlated scene and camera condition profiles](docs/images/camera-scenarios.jpg)
15
49
 
16
- *The same frame through four `scenario_profiles` from
50
+ *The same frame through all six `scenario_profiles` in
17
51
  `configs/dense_gloomy_daylight_fog_camera.json`. Each profile samples fog density,
18
52
  airlight, exposure, sensor noise and compression together, so weather and camera
19
- response stay correlated.*
53
+ response stay correlated. The last two are deliberately extreme low-light stress
54
+ cases — their auto-exposure targets a mean luminance of ~0.1.*
20
55
 
21
56
  Both sets were rendered with `tools/run_fog_qualitative_samples.py`, which runs a
22
57
  folder of RGB/depth/segmentation samples through the transform and writes images for
@@ -456,8 +491,14 @@ and dark/fog modulation should move together:
456
491
 
457
492
  **Tone mapping.** `isp.tone_map` supports `"reinhard"`, `"aces"`, `"clip"`, and
458
493
  `"lut"`. The LUT mode interpolates a cheap 1D camera-response curve given by
459
- `tone_map_lut`, scaled by `tone_map_strength` and interpreted in
460
- `tone_map_lut_domain`.
494
+ `tone_map_lut` and interpreted in `tone_map_lut_domain`.
495
+
496
+ For `"lut"` and `"aces"`, `tone_map_strength` is an **exponent on the curve's
497
+ response**, not a linear blend: `0` leaves the image unchanged, `1` applies the
498
+ curve exactly, and values above `1` apply it more strongly. Because the blend is
499
+ geometric, output radiance stays strictly non-negative at any strength. For
500
+ `"reinhard"` the strength is a curve parameter (`x / (1 + strength * x)`) as
501
+ before.
461
502
 
462
503
  ### Scenario Profiles
463
504
 
@@ -639,3 +680,9 @@ The transform config takes no parameters (`{}`); intrinsics are read from the
639
680
  CLI runs write a source-backed depth dataset mirroring the input depth modality's layout
640
681
  and writer metadata, with `meta.radial_depth` set to `true` in the emitted `index.json`.
641
682
  Standalone `RadialTransform(...)` usage keeps the legacy `.npy` output behaviour.
683
+
684
+ ---
685
+
686
+ ## License
687
+
688
+ [MIT](LICENSE) © Daniel Rothenpieler
@@ -1,3 +1,4 @@
1
+ LICENSE
1
2
  MANIFEST.in
2
3
  README.md
3
4
  pyproject.toml
@@ -1,9 +1,18 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
1
5
  [project]
2
6
  name = "euler-preprocess"
3
- version = "3.11.0"
7
+ version = "3.13.0"
4
8
  description = "Physics-based preprocessing (fog, etc.) for RGB+depth datasets"
5
9
  readme = "README.md"
6
10
  requires-python = ">=3.9"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [
14
+ { name = "Daniel Rothenpieler", email = "rothenpielerdaniel@gmail.com" },
15
+ ]
7
16
  dependencies = [
8
17
  "numpy",
9
18
  "Pillow",
@@ -0,0 +1,139 @@
1
+ from __future__ import annotations
2
+
3
+ import numpy as np
4
+ import pytest
5
+
6
+ from euler_preprocess.fog.capture import (
7
+ CaptureArtifactPipeline,
8
+ CaptureContext,
9
+ _apply_tone_map_np,
10
+ )
11
+
12
+ try:
13
+ import torch
14
+
15
+ from euler_preprocess.fog.capture import _apply_tone_map_torch
16
+ except ImportError:
17
+ torch = None
18
+
19
+ # A camera-response curve whose toe compresses shadows ~11x, matching the
20
+ # profiles in configs/dense_gloomy_daylight_fog_camera.json.
21
+ TOE_LUT = [
22
+ 0.0, 0.006, 0.014, 0.028, 0.052, 0.090, 0.145, 0.220,
23
+ 0.320, 0.450, 0.610, 0.780, 0.900, 0.965, 0.995, 1.0,
24
+ ]
25
+ LUT_CONFIG = {"tone_map_lut": TOE_LUT, "tone_map_lut_domain": "linear"}
26
+ # Shadow values below the LUT's linear toe, where a linear strength blend used
27
+ # to extrapolate through zero at strength ~= 1/(1 - 0.09) = 1.0989.
28
+ SHADOWS = np.asarray(
29
+ [[[0.005, 0.005, 0.005], [0.0145, 0.0145, 0.0145], [0.03, 0.03, 0.03]]],
30
+ dtype=np.float32,
31
+ )
32
+
33
+
34
+ def _isp_pipeline(**overrides):
35
+ stage = {
36
+ "type": "isp",
37
+ "denoise_sigma": 0.0,
38
+ "color_matrix": [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
39
+ "tone_map": "lut",
40
+ "tone_map_strength": 1.0,
41
+ "tone_map_lut": TOE_LUT,
42
+ "gamma": "linear",
43
+ "local_contrast_strength": 0.0,
44
+ "sharpen_amount": 0.0,
45
+ "saturation": 1.0,
46
+ }
47
+ stage.update(overrides)
48
+ return CaptureArtifactPipeline.from_config({"capture": {"stages": [stage]}})
49
+
50
+
51
+ def test_isp_tone_map_lut_interpolates_1d_curve() -> None:
52
+ pipeline = _isp_pipeline(tone_map_lut=[0.0, 0.25, 1.0])
53
+ image = np.asarray([[[0.0, 0.5, 1.0]]], dtype=np.float32)
54
+
55
+ result = pipeline.apply_np(image, CaptureContext(rng=np.random.default_rng(9)))
56
+
57
+ np.testing.assert_allclose(result, [[[0.0, 0.25, 1.0]]], atol=1e-6)
58
+
59
+
60
+ def test_tone_map_strength_zero_is_identity() -> None:
61
+ result = _apply_tone_map_np(SHADOWS, "lut", 0.0, LUT_CONFIG)
62
+
63
+ np.testing.assert_allclose(result, SHADOWS, atol=1e-7)
64
+
65
+
66
+ def test_tone_map_strength_one_reproduces_curve_exactly() -> None:
67
+ result = _apply_tone_map_np(SHADOWS, "lut", 1.0, LUT_CONFIG)
68
+
69
+ expected = np.interp(
70
+ SHADOWS,
71
+ np.linspace(0.0, 1.0, len(TOE_LUT)),
72
+ np.asarray(TOE_LUT, dtype=np.float32),
73
+ )
74
+ np.testing.assert_allclose(result, expected, atol=1e-6)
75
+
76
+
77
+ @pytest.mark.parametrize("strength", [1.05, 1.0989, 1.119, 1.22, 2.0, 8.0])
78
+ def test_tone_map_strength_above_one_never_goes_negative(strength: float) -> None:
79
+ """A linear blend crossed zero here and clipped whole frames to black."""
80
+ result = _apply_tone_map_np(SHADOWS, "lut", strength, LUT_CONFIG)
81
+
82
+ assert np.isfinite(result).all()
83
+ assert float(result.min()) > 0.0
84
+
85
+
86
+ def test_isp_does_not_black_out_shadows_at_strength_above_one() -> None:
87
+ """End-to-end guard: the ISP must not crush a dark frame to zero."""
88
+ pipeline = _isp_pipeline(tone_map_strength=1.22, gamma="srgb")
89
+
90
+ result = pipeline.apply_np(SHADOWS, CaptureContext(rng=np.random.default_rng(9)))
91
+
92
+ assert float(result.min()) > 0.0
93
+ assert float(np.median(result)) > 1e-4
94
+
95
+
96
+ def test_tone_map_strength_is_monotonic_and_bounded() -> None:
97
+ previous = None
98
+ for strength in np.linspace(0.0, 4.0, 41):
99
+ result = _apply_tone_map_np(SHADOWS, "lut", float(strength), LUT_CONFIG)
100
+ assert np.isfinite(result).all()
101
+ assert float(result.min()) >= 0.0
102
+ if previous is not None:
103
+ # A compressive curve only darkens further as strength rises.
104
+ assert (result <= previous + 1e-6).all()
105
+ previous = result
106
+
107
+
108
+ def test_tone_map_preserves_black_and_survives_extreme_strength() -> None:
109
+ image = np.zeros((1, 1, 3), dtype=np.float32)
110
+ assert float(_apply_tone_map_np(image, "lut", 3.0, LUT_CONFIG).max()) == 0.0
111
+
112
+ result = _apply_tone_map_np(SHADOWS, "lut", 1e6, LUT_CONFIG)
113
+ assert np.isfinite(result).all()
114
+ assert float(result.min()) >= 0.0
115
+
116
+
117
+ @pytest.mark.parametrize("strength", [0.0, 1.0, 1.3, 5.0])
118
+ def test_aces_tone_map_stays_non_negative(strength: float) -> None:
119
+ result = _apply_tone_map_np(SHADOWS, "aces", strength, None)
120
+
121
+ assert np.isfinite(result).all()
122
+ assert float(result.min()) >= 0.0
123
+
124
+
125
+ @pytest.mark.skipif(torch is None, reason="torch not installed")
126
+ @pytest.mark.parametrize("mode", ["lut", "aces"])
127
+ @pytest.mark.parametrize("strength", [0.0, 0.5, 1.0, 1.119, 2.0])
128
+ def test_torch_tone_map_matches_numpy(mode: str, strength: float) -> None:
129
+ config = LUT_CONFIG if mode == "lut" else None
130
+ expected = _apply_tone_map_np(SHADOWS, mode, strength, config)
131
+
132
+ actual = _apply_tone_map_torch(
133
+ torch.from_numpy(SHADOWS.copy()),
134
+ mode,
135
+ strength,
136
+ config,
137
+ )
138
+
139
+ np.testing.assert_allclose(actual.numpy(), expected, atol=1e-6)
@@ -1,37 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import numpy as np
4
-
5
- from euler_preprocess.fog.capture import CaptureArtifactPipeline, CaptureContext
6
-
7
-
8
- def test_isp_tone_map_lut_interpolates_1d_curve() -> None:
9
- pipeline = CaptureArtifactPipeline.from_config(
10
- {
11
- "capture": {
12
- "stages": [
13
- {
14
- "type": "isp",
15
- "denoise_sigma": 0.0,
16
- "color_matrix": [
17
- [1.0, 0.0, 0.0],
18
- [0.0, 1.0, 0.0],
19
- [0.0, 0.0, 1.0],
20
- ],
21
- "tone_map": "lut",
22
- "tone_map_strength": 1.0,
23
- "tone_map_lut": [0.0, 0.25, 1.0],
24
- "gamma": "linear",
25
- "local_contrast_strength": 0.0,
26
- "sharpen_amount": 0.0,
27
- "saturation": 1.0,
28
- }
29
- ]
30
- }
31
- }
32
- )
33
- image = np.asarray([[[0.0, 0.5, 1.0]]], dtype=np.float32)
34
-
35
- result = pipeline.apply_np(image, CaptureContext(rng=np.random.default_rng(9)))
36
-
37
- np.testing.assert_allclose(result, [[[0.0, 0.25, 1.0]]], atol=1e-6)