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.
- {pyramids_eo-0.3.0/src/pyramids_eo.egg-info → pyramids_eo-0.5.0}/PKG-INFO +3 -3
- {pyramids_eo-0.3.0 → pyramids_eo-0.5.0}/pyproject.toml +3 -3
- {pyramids_eo-0.3.0 → pyramids_eo-0.5.0}/src/pyramids_eo/__init__.py +29 -0
- {pyramids_eo-0.3.0 → pyramids_eo-0.5.0}/src/pyramids_eo/composites/_common.py +20 -0
- {pyramids_eo-0.3.0 → pyramids_eo-0.5.0}/src/pyramids_eo/composites/blend.py +68 -5
- pyramids_eo-0.5.0/src/pyramids_eo/composites/true_color.py +248 -0
- {pyramids_eo-0.3.0 → pyramids_eo-0.5.0}/src/pyramids_eo/composites/true_color_night.py +58 -3
- {pyramids_eo-0.3.0 → pyramids_eo-0.5.0}/src/pyramids_eo/earthengine/__init__.py +6 -0
- pyramids_eo-0.5.0/src/pyramids_eo/earthengine/reader.py +2511 -0
- pyramids_eo-0.5.0/src/pyramids_eo/enhance.py +309 -0
- {pyramids_eo-0.3.0 → pyramids_eo-0.5.0}/src/pyramids_eo/errors.py +17 -0
- pyramids_eo-0.5.0/src/pyramids_eo/resample.py +153 -0
- {pyramids_eo-0.3.0 → pyramids_eo-0.5.0}/src/pyramids_eo/sensors/__init__.py +2 -0
- {pyramids_eo-0.3.0 → pyramids_eo-0.5.0}/src/pyramids_eo/sensors/readers/__init__.py +10 -6
- {pyramids_eo-0.3.0 → pyramids_eo-0.5.0}/src/pyramids_eo/sensors/readers/_common.py +44 -1
- {pyramids_eo-0.3.0 → pyramids_eo-0.5.0}/src/pyramids_eo/sensors/readers/fci.py +74 -41
- {pyramids_eo-0.3.0 → pyramids_eo-0.5.0}/src/pyramids_eo/sensors/readers/fci_l1c.py +278 -60
- pyramids_eo-0.5.0/src/pyramids_eo/sensors/registry/data/msi.yaml +29 -0
- pyramids_eo-0.5.0/src/pyramids_eo/sentinel/__init__.py +41 -0
- pyramids_eo-0.5.0/src/pyramids_eo/sentinel/_connection.py +263 -0
- pyramids_eo-0.5.0/src/pyramids_eo/sentinel/product.py +239 -0
- pyramids_eo-0.5.0/src/pyramids_eo/sentinel/s2/__init__.py +17 -0
- pyramids_eo-0.5.0/src/pyramids_eo/sentinel/s2/masks.py +207 -0
- pyramids_eo-0.5.0/src/pyramids_eo/sentinel/s2/product.py +326 -0
- pyramids_eo-0.5.0/src/pyramids_eo/sentinel/s2/reader.py +426 -0
- pyramids_eo-0.5.0/src/pyramids_eo/sentinel/s2/scaling.py +141 -0
- {pyramids_eo-0.3.0 → pyramids_eo-0.5.0/src/pyramids_eo.egg-info}/PKG-INFO +3 -3
- {pyramids_eo-0.3.0 → pyramids_eo-0.5.0}/src/pyramids_eo.egg-info/SOURCES.txt +17 -0
- pyramids_eo-0.5.0/src/pyramids_eo.egg-info/requires.txt +6 -0
- {pyramids_eo-0.3.0 → pyramids_eo-0.5.0}/tests/test_composites_blend.py +79 -0
- pyramids_eo-0.5.0/tests/test_composites_true_color.py +252 -0
- {pyramids_eo-0.3.0 → pyramids_eo-0.5.0}/tests/test_composites_true_color_night.py +69 -0
- {pyramids_eo-0.3.0 → pyramids_eo-0.5.0}/tests/test_earthengine_reader.py +1576 -159
- pyramids_eo-0.5.0/tests/test_enhance.py +246 -0
- {pyramids_eo-0.3.0 → pyramids_eo-0.5.0}/tests/test_readers_fci.py +70 -0
- {pyramids_eo-0.3.0 → pyramids_eo-0.5.0}/tests/test_readers_fci_l1c.py +256 -1
- pyramids_eo-0.5.0/tests/test_resample.py +207 -0
- pyramids_eo-0.5.0/tests/test_sentinel_connection.py +79 -0
- pyramids_eo-0.5.0/tests/test_sentinel_registry.py +28 -0
- pyramids_eo-0.5.0/tests/test_sentinel_s2.py +279 -0
- pyramids_eo-0.5.0/tests/test_sentinel_units.py +830 -0
- pyramids_eo-0.3.0/src/pyramids_eo/composites/true_color.py +0 -66
- pyramids_eo-0.3.0/src/pyramids_eo/earthengine/reader.py +0 -1582
- pyramids_eo-0.3.0/src/pyramids_eo.egg-info/requires.txt +0 -6
- pyramids_eo-0.3.0/tests/test_composites_true_color.py +0 -94
- {pyramids_eo-0.3.0 → pyramids_eo-0.5.0}/LICENSE +0 -0
- {pyramids_eo-0.3.0 → pyramids_eo-0.5.0}/README.md +0 -0
- {pyramids_eo-0.3.0 → pyramids_eo-0.5.0}/setup.cfg +0 -0
- {pyramids_eo-0.3.0 → pyramids_eo-0.5.0}/src/pyramids_eo/composites/__init__.py +0 -0
- {pyramids_eo-0.3.0 → pyramids_eo-0.5.0}/src/pyramids_eo/composites/background.py +0 -0
- {pyramids_eo-0.3.0 → pyramids_eo-0.5.0}/src/pyramids_eo/composites/geometry.py +0 -0
- {pyramids_eo-0.3.0 → pyramids_eo-0.5.0}/src/pyramids_eo/composites/overlay.py +0 -0
- {pyramids_eo-0.3.0 → pyramids_eo-0.5.0}/src/pyramids_eo/earthengine/credentials.py +0 -0
- {pyramids_eo-0.3.0 → pyramids_eo-0.5.0}/src/pyramids_eo/sensors/readers/harmonise.py +0 -0
- {pyramids_eo-0.3.0 → pyramids_eo-0.5.0}/src/pyramids_eo/sensors/readers/seviri.py +0 -0
- {pyramids_eo-0.3.0 → pyramids_eo-0.5.0}/src/pyramids_eo/sensors/registry/__init__.py +0 -0
- {pyramids_eo-0.3.0 → pyramids_eo-0.5.0}/src/pyramids_eo/sensors/registry/calibration.py +0 -0
- {pyramids_eo-0.3.0 → pyramids_eo-0.5.0}/src/pyramids_eo/sensors/registry/data/fci.yaml +0 -0
- {pyramids_eo-0.3.0 → pyramids_eo-0.5.0}/src/pyramids_eo/sensors/registry/data/seviri.yaml +0 -0
- {pyramids_eo-0.3.0 → pyramids_eo-0.5.0}/src/pyramids_eo/sensors/registry/sensors.py +0 -0
- {pyramids_eo-0.3.0 → pyramids_eo-0.5.0}/src/pyramids_eo/stac/__init__.py +0 -0
- {pyramids_eo-0.3.0 → pyramids_eo-0.5.0}/src/pyramids_eo/stac/auth_cdse.py +0 -0
- {pyramids_eo-0.3.0 → pyramids_eo-0.5.0}/src/pyramids_eo/stac/signers.py +0 -0
- {pyramids_eo-0.3.0 → pyramids_eo-0.5.0}/src/pyramids_eo.egg-info/dependency_links.txt +0 -0
- {pyramids_eo-0.3.0 → pyramids_eo-0.5.0}/src/pyramids_eo.egg-info/top_level.txt +0 -0
- {pyramids_eo-0.3.0 → pyramids_eo-0.5.0}/tests/test_composites_geometry.py +0 -0
- {pyramids_eo-0.3.0 → pyramids_eo-0.5.0}/tests/test_composites_overlay.py +0 -0
- {pyramids_eo-0.3.0 → pyramids_eo-0.5.0}/tests/test_composites_real.py +0 -0
- {pyramids_eo-0.3.0 → pyramids_eo-0.5.0}/tests/test_composites_static_image.py +0 -0
- {pyramids_eo-0.3.0 → pyramids_eo-0.5.0}/tests/test_earthengine_credentials.py +0 -0
- {pyramids_eo-0.3.0 → pyramids_eo-0.5.0}/tests/test_extras_gating.py +0 -0
- {pyramids_eo-0.3.0 → pyramids_eo-0.5.0}/tests/test_package.py +0 -0
- {pyramids_eo-0.3.0 → pyramids_eo-0.5.0}/tests/test_readers_harmonise.py +0 -0
- {pyramids_eo-0.3.0 → pyramids_eo-0.5.0}/tests/test_readers_seviri.py +0 -0
- {pyramids_eo-0.3.0 → pyramids_eo-0.5.0}/tests/test_registry.py +0 -0
- {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
|
+
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.
|
|
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.
|
|
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
|
+
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.
|
|
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.
|
|
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
|
-
|
|
132
|
+
weight2d = day_weight(_as_array(sza), lim_low=lim_low, lim_high=lim_high)
|
|
113
133
|
day_arr = _as_array(day)
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
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
|
]
|