pyramids-eo 0.3.0__tar.gz → 0.5.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 (76) hide show
  1. {pyramids_eo-0.3.0/src/pyramids_eo.egg-info → pyramids_eo-0.5.0}/PKG-INFO +3 -3
  2. {pyramids_eo-0.3.0 → pyramids_eo-0.5.0}/pyproject.toml +3 -3
  3. {pyramids_eo-0.3.0 → pyramids_eo-0.5.0}/src/pyramids_eo/__init__.py +29 -0
  4. {pyramids_eo-0.3.0 → pyramids_eo-0.5.0}/src/pyramids_eo/composites/_common.py +20 -0
  5. {pyramids_eo-0.3.0 → pyramids_eo-0.5.0}/src/pyramids_eo/composites/blend.py +68 -5
  6. pyramids_eo-0.5.0/src/pyramids_eo/composites/true_color.py +248 -0
  7. {pyramids_eo-0.3.0 → pyramids_eo-0.5.0}/src/pyramids_eo/composites/true_color_night.py +58 -3
  8. {pyramids_eo-0.3.0 → pyramids_eo-0.5.0}/src/pyramids_eo/earthengine/__init__.py +6 -0
  9. pyramids_eo-0.5.0/src/pyramids_eo/earthengine/reader.py +2511 -0
  10. pyramids_eo-0.5.0/src/pyramids_eo/enhance.py +309 -0
  11. {pyramids_eo-0.3.0 → pyramids_eo-0.5.0}/src/pyramids_eo/errors.py +17 -0
  12. pyramids_eo-0.5.0/src/pyramids_eo/resample.py +153 -0
  13. {pyramids_eo-0.3.0 → pyramids_eo-0.5.0}/src/pyramids_eo/sensors/__init__.py +2 -0
  14. {pyramids_eo-0.3.0 → pyramids_eo-0.5.0}/src/pyramids_eo/sensors/readers/__init__.py +10 -6
  15. {pyramids_eo-0.3.0 → pyramids_eo-0.5.0}/src/pyramids_eo/sensors/readers/_common.py +44 -1
  16. {pyramids_eo-0.3.0 → pyramids_eo-0.5.0}/src/pyramids_eo/sensors/readers/fci.py +74 -41
  17. {pyramids_eo-0.3.0 → pyramids_eo-0.5.0}/src/pyramids_eo/sensors/readers/fci_l1c.py +278 -60
  18. pyramids_eo-0.5.0/src/pyramids_eo/sensors/registry/data/msi.yaml +29 -0
  19. pyramids_eo-0.5.0/src/pyramids_eo/sentinel/__init__.py +41 -0
  20. pyramids_eo-0.5.0/src/pyramids_eo/sentinel/_connection.py +263 -0
  21. pyramids_eo-0.5.0/src/pyramids_eo/sentinel/product.py +239 -0
  22. pyramids_eo-0.5.0/src/pyramids_eo/sentinel/s2/__init__.py +17 -0
  23. pyramids_eo-0.5.0/src/pyramids_eo/sentinel/s2/masks.py +207 -0
  24. pyramids_eo-0.5.0/src/pyramids_eo/sentinel/s2/product.py +326 -0
  25. pyramids_eo-0.5.0/src/pyramids_eo/sentinel/s2/reader.py +426 -0
  26. pyramids_eo-0.5.0/src/pyramids_eo/sentinel/s2/scaling.py +141 -0
  27. {pyramids_eo-0.3.0 → pyramids_eo-0.5.0/src/pyramids_eo.egg-info}/PKG-INFO +3 -3
  28. {pyramids_eo-0.3.0 → pyramids_eo-0.5.0}/src/pyramids_eo.egg-info/SOURCES.txt +17 -0
  29. pyramids_eo-0.5.0/src/pyramids_eo.egg-info/requires.txt +6 -0
  30. {pyramids_eo-0.3.0 → pyramids_eo-0.5.0}/tests/test_composites_blend.py +79 -0
  31. pyramids_eo-0.5.0/tests/test_composites_true_color.py +252 -0
  32. {pyramids_eo-0.3.0 → pyramids_eo-0.5.0}/tests/test_composites_true_color_night.py +69 -0
  33. {pyramids_eo-0.3.0 → pyramids_eo-0.5.0}/tests/test_earthengine_reader.py +1576 -159
  34. pyramids_eo-0.5.0/tests/test_enhance.py +246 -0
  35. {pyramids_eo-0.3.0 → pyramids_eo-0.5.0}/tests/test_readers_fci.py +70 -0
  36. {pyramids_eo-0.3.0 → pyramids_eo-0.5.0}/tests/test_readers_fci_l1c.py +256 -1
  37. pyramids_eo-0.5.0/tests/test_resample.py +207 -0
  38. pyramids_eo-0.5.0/tests/test_sentinel_connection.py +79 -0
  39. pyramids_eo-0.5.0/tests/test_sentinel_registry.py +28 -0
  40. pyramids_eo-0.5.0/tests/test_sentinel_s2.py +279 -0
  41. pyramids_eo-0.5.0/tests/test_sentinel_units.py +830 -0
  42. pyramids_eo-0.3.0/src/pyramids_eo/composites/true_color.py +0 -66
  43. pyramids_eo-0.3.0/src/pyramids_eo/earthengine/reader.py +0 -1582
  44. pyramids_eo-0.3.0/src/pyramids_eo.egg-info/requires.txt +0 -6
  45. pyramids_eo-0.3.0/tests/test_composites_true_color.py +0 -94
  46. {pyramids_eo-0.3.0 → pyramids_eo-0.5.0}/LICENSE +0 -0
  47. {pyramids_eo-0.3.0 → pyramids_eo-0.5.0}/README.md +0 -0
  48. {pyramids_eo-0.3.0 → pyramids_eo-0.5.0}/setup.cfg +0 -0
  49. {pyramids_eo-0.3.0 → pyramids_eo-0.5.0}/src/pyramids_eo/composites/__init__.py +0 -0
  50. {pyramids_eo-0.3.0 → pyramids_eo-0.5.0}/src/pyramids_eo/composites/background.py +0 -0
  51. {pyramids_eo-0.3.0 → pyramids_eo-0.5.0}/src/pyramids_eo/composites/geometry.py +0 -0
  52. {pyramids_eo-0.3.0 → pyramids_eo-0.5.0}/src/pyramids_eo/composites/overlay.py +0 -0
  53. {pyramids_eo-0.3.0 → pyramids_eo-0.5.0}/src/pyramids_eo/earthengine/credentials.py +0 -0
  54. {pyramids_eo-0.3.0 → pyramids_eo-0.5.0}/src/pyramids_eo/sensors/readers/harmonise.py +0 -0
  55. {pyramids_eo-0.3.0 → pyramids_eo-0.5.0}/src/pyramids_eo/sensors/readers/seviri.py +0 -0
  56. {pyramids_eo-0.3.0 → pyramids_eo-0.5.0}/src/pyramids_eo/sensors/registry/__init__.py +0 -0
  57. {pyramids_eo-0.3.0 → pyramids_eo-0.5.0}/src/pyramids_eo/sensors/registry/calibration.py +0 -0
  58. {pyramids_eo-0.3.0 → pyramids_eo-0.5.0}/src/pyramids_eo/sensors/registry/data/fci.yaml +0 -0
  59. {pyramids_eo-0.3.0 → pyramids_eo-0.5.0}/src/pyramids_eo/sensors/registry/data/seviri.yaml +0 -0
  60. {pyramids_eo-0.3.0 → pyramids_eo-0.5.0}/src/pyramids_eo/sensors/registry/sensors.py +0 -0
  61. {pyramids_eo-0.3.0 → pyramids_eo-0.5.0}/src/pyramids_eo/stac/__init__.py +0 -0
  62. {pyramids_eo-0.3.0 → pyramids_eo-0.5.0}/src/pyramids_eo/stac/auth_cdse.py +0 -0
  63. {pyramids_eo-0.3.0 → pyramids_eo-0.5.0}/src/pyramids_eo/stac/signers.py +0 -0
  64. {pyramids_eo-0.3.0 → pyramids_eo-0.5.0}/src/pyramids_eo.egg-info/dependency_links.txt +0 -0
  65. {pyramids_eo-0.3.0 → pyramids_eo-0.5.0}/src/pyramids_eo.egg-info/top_level.txt +0 -0
  66. {pyramids_eo-0.3.0 → pyramids_eo-0.5.0}/tests/test_composites_geometry.py +0 -0
  67. {pyramids_eo-0.3.0 → pyramids_eo-0.5.0}/tests/test_composites_overlay.py +0 -0
  68. {pyramids_eo-0.3.0 → pyramids_eo-0.5.0}/tests/test_composites_real.py +0 -0
  69. {pyramids_eo-0.3.0 → pyramids_eo-0.5.0}/tests/test_composites_static_image.py +0 -0
  70. {pyramids_eo-0.3.0 → pyramids_eo-0.5.0}/tests/test_earthengine_credentials.py +0 -0
  71. {pyramids_eo-0.3.0 → pyramids_eo-0.5.0}/tests/test_extras_gating.py +0 -0
  72. {pyramids_eo-0.3.0 → pyramids_eo-0.5.0}/tests/test_package.py +0 -0
  73. {pyramids_eo-0.3.0 → pyramids_eo-0.5.0}/tests/test_readers_harmonise.py +0 -0
  74. {pyramids_eo-0.3.0 → pyramids_eo-0.5.0}/tests/test_readers_seviri.py +0 -0
  75. {pyramids_eo-0.3.0 → pyramids_eo-0.5.0}/tests/test_registry.py +0 -0
  76. {pyramids_eo-0.3.0 → pyramids_eo-0.5.0}/tests/test_stac_signers.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pyramids-eo
3
- Version: 0.3.0
3
+ Version: 0.5.0
4
4
  Summary: Remote-sensing / Earth-observation tier for the pyramids GIS stack
5
5
  Author-email: Mostafa Farrag <moah.farag@gmail.com>
6
6
  License: GNU General Public License v3
@@ -24,11 +24,11 @@ Classifier: Intended Audience :: Developers
24
24
  Requires-Python: <4,>=3.11
25
25
  Description-Content-Type: text/markdown
26
26
  License-File: LICENSE
27
- Requires-Dist: pyramids-gis>=0.53.0
27
+ Requires-Dist: pyramids-gis>=0.56.0
28
28
  Requires-Dist: numpy>=2.0.0
29
29
  Requires-Dist: pyyaml>=6.0
30
30
  Provides-Extra: viz
31
- Requires-Dist: pyramids-gis[viz]>=0.53.0; extra == "viz"
31
+ Requires-Dist: pyramids-gis[viz]>=0.56.0; extra == "viz"
32
32
  Dynamic: license-file
33
33
 
34
34
  # pyramids-eo
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pyramids-eo"
3
- version = "0.3.0"
3
+ version = "0.5.0"
4
4
  description = "Remote-sensing / Earth-observation tier for the pyramids GIS stack"
5
5
  readme = {file = "README.md", content-type = "text/markdown"}
6
6
  authors = [{ name = "Mostafa Farrag", email = "moah.farag@gmail.com" }]
@@ -25,13 +25,13 @@ classifiers = [
25
25
  requires-python = ">= 3.11, <4"
26
26
 
27
27
  dependencies = [
28
- "pyramids-gis >=0.53.0",
28
+ "pyramids-gis >=0.56.0",
29
29
  "numpy >=2.0.0",
30
30
  "pyyaml >=6.0",
31
31
  ]
32
32
 
33
33
  [project.optional-dependencies]
34
- viz = ["pyramids-gis[viz]>=0.53.0"]
34
+ viz = ["pyramids-gis[viz]>=0.56.0"]
35
35
 
36
36
  [dependency-groups]
37
37
  dev = [
@@ -11,14 +11,35 @@ resample a swath — and how to reach signed EO cloud assets. It is scoped by
11
11
 
12
12
  from __future__ import annotations
13
13
 
14
+ # isort: off
15
+ # MUST be the first runtime import in the whole package. pyramids bundles GDAL
16
+ # (osgeo) and activates it on import; nothing in pyramids-eo — nor any osgeo
17
+ # import anywhere downstream — works until `import pyramids` has run. GDAL is
18
+ # therefore NEVER a dependency of pyramids-eo; it arrives through pyramids. Do
19
+ # not reorder this below any other import, and do not add gdal/osgeo to
20
+ # pyproject.toml.
21
+ import pyramids as _pyramids_bootstrap # noqa: F401 (activates the bundled osgeo)
22
+
23
+ # isort: on
24
+
14
25
  from importlib.metadata import PackageNotFoundError
15
26
  from importlib.metadata import version as _get_version
16
27
 
17
28
  from pyramids_eo.earthengine import (
18
29
  EarthEngineCredentials,
30
+ ReadCost,
31
+ Window,
19
32
  collection_from_earthengine,
33
+ estimate_earthengine_cost,
20
34
  from_earthengine,
21
35
  )
36
+ from pyramids_eo.sentinel import (
37
+ SclClass,
38
+ collection_from_sentinel2,
39
+ from_sentinel2,
40
+ open_product,
41
+ scl_mask,
42
+ )
22
43
 
23
44
  try:
24
45
  __version__ = _get_version("pyramids-eo")
@@ -27,7 +48,15 @@ except PackageNotFoundError: # pragma: no cover
27
48
 
28
49
  __all__ = [
29
50
  "EarthEngineCredentials",
51
+ "ReadCost",
52
+ "SclClass",
53
+ "Window",
30
54
  "__version__",
31
55
  "collection_from_earthengine",
56
+ "collection_from_sentinel2",
57
+ "estimate_earthengine_cost",
32
58
  "from_earthengine",
59
+ "from_sentinel2",
60
+ "open_product",
61
+ "scl_mask",
33
62
  ]
@@ -53,3 +53,23 @@ def _wrap_like(out: np.ndarray, *candidates: Any) -> Any:
53
53
  return Dataset.create_from_array(
54
54
  out, geo=template.geotransform, epsg=template.epsg, no_data_value=np.nan
55
55
  )
56
+
57
+
58
+ def _coverage(value: Any) -> np.ndarray:
59
+ """Per-pixel validity mask — `True` where every band of `value` is finite.
60
+
61
+ Coverage is derived from data validity (finite values), never from pixel
62
+ brightness, so a dark-but-valid pixel (e.g. night ocean) stays covered while
63
+ an off-disk / terminator pixel (NaN from calibration) is not.
64
+
65
+ Args:
66
+ value: An array-like or pyramids `Dataset`. A `(band, H, W)` image
67
+ reduces over its bands; an `(H, W)` image is taken as-is.
68
+
69
+ Returns:
70
+ A boolean `(H, W)` mask, `True` where the pixel is finite in all bands.
71
+ """
72
+ arr = _as_array(value)
73
+ if arr.ndim >= 3:
74
+ return np.all(np.isfinite(arr), axis=tuple(range(arr.ndim - 2)))
75
+ return np.isfinite(arr)
@@ -13,7 +13,7 @@ from typing import Any
13
13
 
14
14
  import numpy as np
15
15
 
16
- from pyramids_eo.composites._common import _as_array, _wrap_like
16
+ from pyramids_eo.composites._common import _as_array, _coverage, _wrap_like
17
17
 
18
18
  _MODES = ("day_night", "day_only", "night_only")
19
19
 
@@ -66,6 +66,7 @@ def day_night_blend(
66
66
  lim_low: float = 78.0,
67
67
  lim_high: float = 88.0,
68
68
  mode: str = "day_night",
69
+ keep_alpha: bool = False,
69
70
  ) -> Any:
70
71
  """Cross-fade a day and a night image by solar zenith angle.
71
72
 
@@ -84,11 +85,19 @@ def day_night_blend(
84
85
  lim_high: SZA at/above which it is fully night (default 88).
85
86
  mode: `"day_night"` (blend, default), `"day_only"` (`day * weight`), or
86
87
  `"night_only"` (`night * (1 - weight)`).
88
+ keep_alpha: When `True`, append a coverage / alpha band — `1.0` where the
89
+ contributing input(s) are finite, the geometry is defined, and the
90
+ blended pixel itself is finite; `0.0` otherwise (see
91
+ :func:`pyramids_eo.composites._common._coverage`). The band comes from
92
+ data validity, not brightness, so a dark-but-valid pixel stays covered
93
+ while a NaN-producing twilight pixel does not. Default `False` leaves
94
+ the output unchanged.
87
95
 
88
96
  Returns:
89
97
  The blended image. A pyramids `Dataset` (carrying `day`'s / `night`'s
90
98
  geotransform + CRS) when either input is a `Dataset`, otherwise an
91
- ndarray.
99
+ ndarray. With `keep_alpha=True` the output carries one extra (alpha)
100
+ band.
92
101
 
93
102
  Raises:
94
103
  ValueError: When `mode` is unknown or `lim_low >= lim_high`.
@@ -104,15 +113,27 @@ def day_night_blend(
104
113
  >>> day_night_blend(day, night, sza).round(2).tolist()
105
114
  [[1.0, 0.5], [0.0, 0.0]]
106
115
 
116
+ ```
117
+ - `keep_alpha` appends a coverage band, giving a 4-band RGBA result:
118
+ ```python
119
+ >>> import numpy as np
120
+ >>> from pyramids_eo.composites import day_night_blend
121
+ >>> day = np.ones((3, 1, 2))
122
+ >>> night = np.zeros((3, 1, 2))
123
+ >>> sza = np.array([[0.0, 0.0]])
124
+ >>> day_night_blend(day, night, sza, keep_alpha=True).shape
125
+ (4, 1, 2)
126
+
107
127
  ```
108
128
  """
109
129
  if mode not in _MODES:
110
130
  raise ValueError(f"mode must be one of {_MODES}; got {mode!r}")
111
131
 
112
- weight = day_weight(_as_array(sza), lim_low=lim_low, lim_high=lim_high)
132
+ weight2d = day_weight(_as_array(sza), lim_low=lim_low, lim_high=lim_high)
113
133
  day_arr = _as_array(day)
114
- if weight.ndim == 2 and day_arr.ndim == 3:
115
- weight = weight[np.newaxis, ...]
134
+ weight = weight2d
135
+ if weight2d.ndim == 2 and day_arr.ndim == 3:
136
+ weight = weight2d[np.newaxis, ...]
116
137
 
117
138
  # np.where zeros each image's contribution where its weight is 0, so a NaN
118
139
  # in a fully-weighted-out region (e.g. sun-angle-normalised day reflectance,
@@ -131,4 +152,46 @@ def day_night_blend(
131
152
  # pixel masked as NaN rather than collapsing it to 0 — matching day_weight's
132
153
  # NaN propagation (the weight-zeroing above only handles image-side NaN).
133
154
  out = np.where(np.isnan(weight), np.nan, out)
155
+
156
+ if keep_alpha:
157
+ out = _append_alpha(out, day, night, weight2d, mode)
158
+
134
159
  return _wrap_like(out, day, night)
160
+
161
+
162
+ def _append_alpha(
163
+ out: np.ndarray, day: Any, night: Any, weight2d: np.ndarray, mode: str
164
+ ) -> np.ndarray:
165
+ """Append a coverage / alpha band to a blended image.
166
+
167
+ Coverage is the validity of the mode's contributing input(s) — `day` for
168
+ `"day_only"`, `night` for `"night_only"`, their union for `"day_night"` —
169
+ masked out where the SZA geometry (weight) is undefined.
170
+
171
+ Args:
172
+ out: The blended image, `(H, W)` or `(band, H, W)`.
173
+ day: The day input (array-like or `Dataset`).
174
+ night: The night input (array-like or `Dataset`).
175
+ weight2d: The 2-D day weight, used to mask undefined-geometry pixels.
176
+ mode: The blend mode driving which inputs contribute.
177
+
178
+ Returns:
179
+ `out` with one extra trailing (alpha) band: `1.0` covered, `0.0` not.
180
+ """
181
+ if mode == "day_only":
182
+ covered = _coverage(day)
183
+ elif mode == "night_only":
184
+ covered = _coverage(night)
185
+ else:
186
+ covered = _coverage(day) | _coverage(night)
187
+ # Intersect with the finite blended output so alpha is never 1 over a NaN
188
+ # pixel — in the twilight band both inputs contribute, so a single NaN input
189
+ # yields NaN RGB there even though the input-union coverage is 1.
190
+ covered = covered & ~np.isnan(np.asarray(weight2d, dtype=float)) & _coverage(out)
191
+ alpha = np.asarray(covered, dtype=float)
192
+
193
+ if out.ndim >= 3:
194
+ alpha = np.broadcast_to(alpha, out.shape[1:])
195
+ return np.concatenate([out, alpha[np.newaxis, ...]], axis=0)
196
+ alpha = np.broadcast_to(alpha, out.shape)
197
+ return np.stack([out, alpha], axis=0)
@@ -0,0 +1,248 @@
1
+ """True-colour RGB from calibrated reflectance bands.
2
+
3
+ SEVIRI carries no native green band, so for it `true_color` synthesises one from
4
+ the red, blue, and near-IR ("veggie") reflectances using the CIMSS weighted
5
+ recipe (`green_mode="synthetic"`, the default), then stacks red / green / blue
6
+ into an RGB image.
7
+
8
+ FCI FDHSI *does* carry a native green band (`vis_05`, ~0.51 um). For FCI, pass
9
+ that band as `green=` and select `green_mode="native"` to use it directly, or
10
+ `green_mode="ndvi_hybrid"` to blend it with the NIR by an NDVI fraction (more NIR
11
+ over barren surfaces, less over vegetation) — the reference true-colour look.
12
+
13
+ This is the **no-Rayleigh** variant (per the pyramids-eo compositing decision):
14
+ by default it does the band combination only. Atmospheric / Rayleigh correction
15
+ is intentionally out of scope of the base install to keep the dependency
16
+ footprint small; the result is slightly flatter over ocean / haze than a
17
+ Rayleigh-corrected image. A correction can be opted in per call via the
18
+ `rayleigh=` callable, which is applied to each solar band before green synthesis.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import math
24
+ from collections.abc import Callable
25
+ from typing import Any
26
+
27
+ import numpy as np
28
+
29
+ from pyramids_eo.composites._common import _as_array, _wrap_like
30
+
31
+ #: Default CIMSS synthetic-green weights (red, near-IR/veggie, blue).
32
+ _DEFAULT_GREEN_WEIGHTS = (0.45, 0.10, 0.45)
33
+
34
+ #: Green-synthesis modes accepted by :func:`true_color`.
35
+ _GREEN_MODES = ("synthetic", "native", "ndvi_hybrid")
36
+
37
+
38
+ def _ndvi_hybrid_green(
39
+ green: np.ndarray,
40
+ red: np.ndarray,
41
+ nir: np.ndarray,
42
+ *,
43
+ strength: float = 3.0,
44
+ limits: tuple[float, float] = (0.15, 0.05),
45
+ ndvi_min: float = 0.0,
46
+ ndvi_max: float = 1.0,
47
+ ) -> np.ndarray:
48
+ """Blend a native green band with the NIR by a per-pixel NDVI fraction.
49
+
50
+ Computes `NDVI = (nir - red) / (nir + red)`, optionally sharpens it with a
51
+ non-linear `strength`, maps it linearly from `[ndvi_min, ndvi_max]` onto
52
+ `[limits[0], limits[1]]` to get a NIR fraction, then returns
53
+ `(1 - fraction) * green + fraction * nir`. With the default `limits`, barren
54
+ pixels (low NDVI) take more NIR (0.15) and vegetated pixels take less (0.05),
55
+ so the native green dominates (0.85-0.95).
56
+
57
+ Args:
58
+ green: The native green reflectance (e.g. FCI `vis_05`).
59
+ red: The red reflectance (NDVI red term).
60
+ nir: The near-IR reflectance (NDVI NIR term and blend source).
61
+ strength: Non-linear sharpening exponent applied to the NDVI; `1.0`
62
+ leaves it linear. Must be `> 0`.
63
+ limits: The `(low_ndvi, high_ndvi)` NIR fractions the NDVI is mapped onto.
64
+ Fractions outside `[0, 1]` extrapolate the green beyond `[green, nir]`.
65
+ ndvi_min: Lower NDVI clip / mapping bound.
66
+ ndvi_max: Upper NDVI clip / mapping bound.
67
+
68
+ Returns:
69
+ The blended green channel, same shape as the inputs.
70
+
71
+ Raises:
72
+ ValueError: When `strength <= 0` or `limits` is not a 2-tuple.
73
+ """
74
+ if strength <= 0:
75
+ raise ValueError(f"strength must be > 0, got {strength}")
76
+ if len(limits) != 2:
77
+ raise ValueError(f"limits must be a 2-tuple (low, high); got {limits!r}")
78
+ red = np.asarray(red, dtype=float)
79
+ nir = np.asarray(nir, dtype=float)
80
+ green = np.asarray(green, dtype=float)
81
+ denom = nir + red
82
+ ndvi = np.divide(nir - red, denom, out=np.zeros_like(denom), where=denom != 0)
83
+ ndvi = np.clip(np.nan_to_num(ndvi), ndvi_min, ndvi_max)
84
+ if not math.isclose(strength, 1.0):
85
+ powered = ndvi**strength
86
+ ndvi = powered / (powered + (1.0 - ndvi) ** strength)
87
+ span = ndvi_max - ndvi_min
88
+ fraction = (ndvi - ndvi_min) / span * (limits[1] - limits[0]) + limits[0]
89
+ return np.asarray((1.0 - fraction) * green + fraction * nir, dtype=float)
90
+
91
+
92
+ def _build_green_channel(
93
+ green_mode: str,
94
+ r: np.ndarray,
95
+ b: np.ndarray,
96
+ n: np.ndarray,
97
+ g_native: np.ndarray | None,
98
+ green_weights: tuple[float, float, float],
99
+ ndvi_strength: float,
100
+ ndvi_limits: tuple[float, float],
101
+ ) -> np.ndarray:
102
+ """Build the green channel for the requested `green_mode`.
103
+
104
+ Args:
105
+ green_mode: `"synthetic"`, `"native"`, or `"ndvi_hybrid"`.
106
+ r: Red reflectance (already rayleigh-corrected if requested).
107
+ b: Blue reflectance.
108
+ n: Near-IR reflectance.
109
+ g_native: The native green band, or `None`.
110
+ green_weights: The `(red, nir, blue)` weights for the synthetic green.
111
+ ndvi_strength: NDVI sharpening for `"ndvi_hybrid"`.
112
+ ndvi_limits: The `(low, high)` NIR fractions for `"ndvi_hybrid"`.
113
+
114
+ Returns:
115
+ The green-channel array.
116
+
117
+ Raises:
118
+ ValueError: When `green_weights` is not a 3-tuple, or a native /
119
+ ndvi_hybrid mode is requested without a `green=` band.
120
+ """
121
+ if green_mode == "synthetic":
122
+ if len(green_weights) != 3:
123
+ raise ValueError(
124
+ f"green_weights must be a 3-tuple (red, nir, blue); got {green_weights!r}"
125
+ )
126
+ wr, wn, wb = green_weights
127
+ return wr * r + wn * n + wb * b
128
+ if g_native is None:
129
+ raise ValueError(f"green_mode={green_mode!r} requires a `green=` band")
130
+ if green_mode == "native":
131
+ return g_native
132
+ return _ndvi_hybrid_green(
133
+ g_native, r, n, strength=ndvi_strength, limits=ndvi_limits
134
+ )
135
+
136
+
137
+ def true_color(
138
+ red: Any,
139
+ blue: Any,
140
+ nir: Any,
141
+ *,
142
+ green: Any = None,
143
+ green_mode: str = "synthetic",
144
+ green_weights: tuple[float, float, float] = _DEFAULT_GREEN_WEIGHTS,
145
+ ndvi_strength: float = 3.0,
146
+ ndvi_limits: tuple[float, float] = (0.15, 0.05),
147
+ rayleigh: Callable[[np.ndarray], Any] | None = None,
148
+ gamma: float | None = None,
149
+ clip: bool = False,
150
+ ) -> Any:
151
+ """Build a true-colour RGB from red, blue, and near-IR reflectances.
152
+
153
+ The green channel is built per `green_mode`:
154
+
155
+ * `"synthetic"` (default) — `green = wr*red + wn*nir + wb*blue` (the CIMSS
156
+ recipe), for sensors with no native green band (SEVIRI). Byte-identical to
157
+ the historical behaviour.
158
+ * `"native"` — use the `green=` band directly (FCI `vis_05`).
159
+ * `"ndvi_hybrid"` — blend the `green=` band with the NIR by an NDVI fraction
160
+ (see :func:`_ndvi_hybrid_green`).
161
+
162
+ When `rayleigh` is given it is applied to each solar band (red, blue, nir, and
163
+ a native `green`) before green synthesis; `None` (default) leaves the bands
164
+ untouched and adds no dependency.
165
+
166
+ Args:
167
+ red: Red-band reflectance — array-like or a pyramids `Dataset`.
168
+ blue: Blue-band reflectance, same grid as `red`.
169
+ nir: Near-IR ("veggie") reflectance — the synthetic-green source, the
170
+ NDVI NIR term, and the `ndvi_hybrid` blend source.
171
+ green: Native green reflectance (FCI `vis_05`). Required for
172
+ `green_mode` `"native"` / `"ndvi_hybrid"`, ignored for `"synthetic"`.
173
+ green_mode: How to build the green channel — `"synthetic"` (default),
174
+ `"native"`, or `"ndvi_hybrid"`.
175
+ green_weights: The `(red, nir, blue)` weights for the synthetic green
176
+ (default the CIMSS `(0.45, 0.10, 0.45)`).
177
+ ndvi_strength: Non-linear NDVI sharpening for `"ndvi_hybrid"` (default 3.0).
178
+ ndvi_limits: The `(low, high)` NIR fractions for `"ndvi_hybrid"`.
179
+ rayleigh: Optional callable applied to each solar band before green
180
+ synthesis (an atmospheric correction), or `None`.
181
+ gamma: Optional gamma to apply (`value ** (1 / gamma)`), or `None`.
182
+ clip: When `True`, clip the output to `[0, 1]`.
183
+
184
+ Returns:
185
+ The `(3, H, W)` RGB image. A pyramids `Dataset` (carrying an input
186
+ `Dataset`'s geotransform + CRS) when any input is a `Dataset`, otherwise
187
+ an ndarray.
188
+
189
+ Raises:
190
+ ValueError: When `green_mode` is unknown, a native/ndvi_hybrid mode is
191
+ requested without a `green=` band, or `green_weights` is not a
192
+ 3-tuple.
193
+
194
+ Examples:
195
+ - Synthetic CIMSS green (SEVIRI-style) from red / blue / NIR reflectance:
196
+ ```python
197
+ >>> import numpy as np
198
+ >>> from pyramids_eo.composites import true_color
199
+ >>> r, b, n = np.full((1, 1), 0.2), np.full((1, 1), 0.6), np.full((1, 1), 0.9)
200
+ >>> true_color(r, b, n)[1].round(3).tolist()
201
+ [[0.45]]
202
+
203
+ ```
204
+ - Use FCI's native green band directly (`green_mode="native"`):
205
+ ```python
206
+ >>> import numpy as np
207
+ >>> from pyramids_eo.composites import true_color
208
+ >>> out = true_color(
209
+ ... np.full((1, 1), 0.2),
210
+ ... np.full((1, 1), 0.6),
211
+ ... np.full((1, 1), 0.9),
212
+ ... green=np.full((1, 1), 0.44),
213
+ ... green_mode="native",
214
+ ... )
215
+ >>> out[1].tolist()
216
+ [[0.44]]
217
+
218
+ ```
219
+ """
220
+ if green_mode not in _GREEN_MODES:
221
+ raise ValueError(
222
+ f"green_mode must be one of {_GREEN_MODES}; got {green_mode!r}"
223
+ )
224
+
225
+ r = _as_array(red)
226
+ b = _as_array(blue)
227
+ n = _as_array(nir)
228
+ g_native = _as_array(green) if green is not None else None
229
+
230
+ if rayleigh is not None:
231
+ r = np.asarray(rayleigh(r), dtype=float)
232
+ b = np.asarray(rayleigh(b), dtype=float)
233
+ n = np.asarray(rayleigh(n), dtype=float)
234
+ if g_native is not None:
235
+ g_native = np.asarray(rayleigh(g_native), dtype=float)
236
+
237
+ green_ch = _build_green_channel(
238
+ green_mode, r, b, n, g_native, green_weights, ndvi_strength, ndvi_limits
239
+ )
240
+
241
+ rgb = np.stack([r, green_ch, b], axis=0)
242
+
243
+ if gamma is not None:
244
+ rgb = np.where(rgb > 0, rgb, 0.0) ** (1.0 / gamma)
245
+ if clip:
246
+ rgb = np.clip(rgb, 0.0, 1.0)
247
+
248
+ return _wrap_like(rgb, red, blue, nir, green)
@@ -23,7 +23,7 @@ from typing import Any
23
23
 
24
24
  import numpy as np
25
25
 
26
- from pyramids_eo.composites._common import _as_array, _wrap_like
26
+ from pyramids_eo.composites._common import _as_array, _coverage, _wrap_like
27
27
  from pyramids_eo.composites.blend import day_night_blend
28
28
  from pyramids_eo.composites.overlay import alpha_overlay
29
29
 
@@ -63,6 +63,7 @@ def true_color_with_night_ir(
63
63
  *,
64
64
  lim_low: float = 78.0,
65
65
  lim_high: float = 88.0,
66
+ keep_alpha: bool = False,
66
67
  ) -> Any:
67
68
  """Compose the full `true_color_with_night_ir` day/night image.
68
69
 
@@ -78,10 +79,64 @@ def true_color_with_night_ir(
78
79
  sza: Per-pixel solar zenith angle in degrees, from `solar_zenith_angle`.
79
80
  lim_low: SZA at/below which it is fully day (default 78).
80
81
  lim_high: SZA at/above which it is fully night (default 88).
82
+ keep_alpha: When `True`, append a coverage / alpha band to the `(3, H, W)`
83
+ result, giving `(4, H, W)`. Coverage is derived from the two
84
+ *satellite-derived* inputs before the global background is merged in —
85
+ the `day` true-colour image (valid on the day side of the disk) and
86
+ the `night_ir_rgba` clouds (valid on the night side) — so it marks the
87
+ whole sensor disk, including dark-but-valid night pixels, and is `0`
88
+ off-disk. Where the band is `0` the RGB is set to a finite `0` so a
89
+ premultiplied composite stays NaN-free. Default `False` returns the
90
+ 3-band image unchanged.
81
91
 
82
92
  Returns:
83
93
  The composed day/night image — a pyramids `Dataset` when the inputs carry
84
- one, otherwise an ndarray.
94
+ one, otherwise an ndarray. `(4, H, W)` when `keep_alpha=True`, else
95
+ `(3, H, W)`.
96
+
97
+ Examples:
98
+ - Compose a day/night frame from the primitives:
99
+ ```python
100
+ >>> import numpy as np
101
+ >>> from pyramids_eo.composites import night_ir, true_color_with_night_ir
102
+ >>> day = np.ones((3, 1, 2))
103
+ >>> clouds = night_ir(np.ones((1, 2)), np.ones((1, 2)), np.ones((1, 2)))
104
+ >>> bg = np.zeros((3, 1, 2))
105
+ >>> true_color_with_night_ir(day, clouds, bg, np.zeros((1, 2))).shape
106
+ (3, 1, 2)
107
+
108
+ ```
109
+ - Keep a coverage band for edge feathering (4-band RGBA):
110
+ ```python
111
+ >>> import numpy as np
112
+ >>> from pyramids_eo.composites import night_ir, true_color_with_night_ir
113
+ >>> day = np.ones((3, 1, 2))
114
+ >>> clouds = night_ir(np.ones((1, 2)), np.ones((1, 2)), np.ones((1, 2)))
115
+ >>> bg = np.zeros((3, 1, 2))
116
+ >>> out = true_color_with_night_ir(
117
+ ... day, clouds, bg, np.zeros((1, 2)), keep_alpha=True
118
+ ... )
119
+ >>> out.shape
120
+ (4, 1, 2)
121
+
122
+ ```
85
123
  """
86
124
  night = alpha_overlay(night_ir_rgba, background)
87
- return day_night_blend(day, night, sza, lim_low=lim_low, lim_high=lim_high)
125
+ blended = day_night_blend(day, night, sza, lim_low=lim_low, lim_high=lim_high)
126
+ if not keep_alpha:
127
+ return blended
128
+
129
+ # Coverage from the satellite-derived inputs *before* the global background
130
+ # is merged in: the day true-colour image covers the day side of the disk,
131
+ # the night-IR clouds (RGB, ignoring their own alpha) cover the night side.
132
+ # Their union is the sensor disk. Intersect it with the finite blended pixels
133
+ # so alpha is never 1 over a NaN, and zero the RGB wherever it is not finite
134
+ # so a downstream premultiplied (RGB * alpha) composite cannot leak NaN.
135
+ disk = _coverage(day) | _coverage(_as_array(night_ir_rgba)[:3])
136
+ blended_arr = _as_array(blended)
137
+ rgb = blended_arr if blended_arr.ndim >= 3 else blended_arr[np.newaxis, ...]
138
+ finite_rgb = _coverage(rgb)
139
+ coverage = disk & finite_rgb
140
+ rgb = np.where(finite_rgb[np.newaxis, ...], rgb, 0.0)
141
+ rgba = np.concatenate([rgb, coverage.astype(float)[np.newaxis, ...]], axis=0)
142
+ return _wrap_like(rgba, day, night_ir_rgba, background)
@@ -19,12 +19,18 @@ from __future__ import annotations
19
19
 
20
20
  from pyramids_eo.earthengine.credentials import EarthEngineCredentials
21
21
  from pyramids_eo.earthengine.reader import (
22
+ ReadCost,
23
+ Window,
22
24
  collection_from_earthengine,
25
+ estimate_earthengine_cost,
23
26
  from_earthengine,
24
27
  )
25
28
 
26
29
  __all__ = [
27
30
  "EarthEngineCredentials",
31
+ "ReadCost",
32
+ "Window",
28
33
  "collection_from_earthengine",
34
+ "estimate_earthengine_cost",
29
35
  "from_earthengine",
30
36
  ]