mapchete-eo 2025.7.0__py2.py3-none-any.whl
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.
- mapchete_eo/__init__.py +1 -0
- mapchete_eo/archives/__init__.py +0 -0
- mapchete_eo/archives/base.py +65 -0
- mapchete_eo/array/__init__.py +0 -0
- mapchete_eo/array/buffer.py +16 -0
- mapchete_eo/array/color.py +29 -0
- mapchete_eo/array/convert.py +157 -0
- mapchete_eo/base.py +528 -0
- mapchete_eo/blacklist.txt +175 -0
- mapchete_eo/cli/__init__.py +30 -0
- mapchete_eo/cli/bounds.py +22 -0
- mapchete_eo/cli/options_arguments.py +243 -0
- mapchete_eo/cli/s2_brdf.py +77 -0
- mapchete_eo/cli/s2_cat_results.py +146 -0
- mapchete_eo/cli/s2_find_broken_products.py +93 -0
- mapchete_eo/cli/s2_jp2_static_catalog.py +166 -0
- mapchete_eo/cli/s2_mask.py +71 -0
- mapchete_eo/cli/s2_mgrs.py +45 -0
- mapchete_eo/cli/s2_rgb.py +114 -0
- mapchete_eo/cli/s2_verify.py +129 -0
- mapchete_eo/cli/static_catalog.py +123 -0
- mapchete_eo/eostac.py +30 -0
- mapchete_eo/exceptions.py +87 -0
- mapchete_eo/geometry.py +271 -0
- mapchete_eo/image_operations/__init__.py +12 -0
- mapchete_eo/image_operations/color_correction.py +136 -0
- mapchete_eo/image_operations/compositing.py +247 -0
- mapchete_eo/image_operations/dtype_scale.py +43 -0
- mapchete_eo/image_operations/fillnodata.py +130 -0
- mapchete_eo/image_operations/filters.py +319 -0
- mapchete_eo/image_operations/linear_normalization.py +81 -0
- mapchete_eo/image_operations/sigmoidal.py +114 -0
- mapchete_eo/io/__init__.py +37 -0
- mapchete_eo/io/assets.py +492 -0
- mapchete_eo/io/items.py +147 -0
- mapchete_eo/io/levelled_cubes.py +228 -0
- mapchete_eo/io/path.py +144 -0
- mapchete_eo/io/products.py +413 -0
- mapchete_eo/io/profiles.py +45 -0
- mapchete_eo/known_catalogs.py +42 -0
- mapchete_eo/platforms/sentinel2/__init__.py +17 -0
- mapchete_eo/platforms/sentinel2/archives.py +190 -0
- mapchete_eo/platforms/sentinel2/bandpass_adjustment.py +104 -0
- mapchete_eo/platforms/sentinel2/brdf/__init__.py +8 -0
- mapchete_eo/platforms/sentinel2/brdf/config.py +32 -0
- mapchete_eo/platforms/sentinel2/brdf/correction.py +260 -0
- mapchete_eo/platforms/sentinel2/brdf/hls.py +251 -0
- mapchete_eo/platforms/sentinel2/brdf/models.py +44 -0
- mapchete_eo/platforms/sentinel2/brdf/protocols.py +27 -0
- mapchete_eo/platforms/sentinel2/brdf/ross_thick.py +136 -0
- mapchete_eo/platforms/sentinel2/brdf/sun_angle_arrays.py +76 -0
- mapchete_eo/platforms/sentinel2/config.py +181 -0
- mapchete_eo/platforms/sentinel2/driver.py +78 -0
- mapchete_eo/platforms/sentinel2/masks.py +325 -0
- mapchete_eo/platforms/sentinel2/metadata_parser.py +734 -0
- mapchete_eo/platforms/sentinel2/path_mappers/__init__.py +29 -0
- mapchete_eo/platforms/sentinel2/path_mappers/base.py +56 -0
- mapchete_eo/platforms/sentinel2/path_mappers/earthsearch.py +34 -0
- mapchete_eo/platforms/sentinel2/path_mappers/metadata_xml.py +135 -0
- mapchete_eo/platforms/sentinel2/path_mappers/sinergise.py +105 -0
- mapchete_eo/platforms/sentinel2/preprocessing_tasks.py +26 -0
- mapchete_eo/platforms/sentinel2/processing_baseline.py +160 -0
- mapchete_eo/platforms/sentinel2/product.py +669 -0
- mapchete_eo/platforms/sentinel2/types.py +109 -0
- mapchete_eo/processes/__init__.py +0 -0
- mapchete_eo/processes/config.py +51 -0
- mapchete_eo/processes/dtype_scale.py +112 -0
- mapchete_eo/processes/eo_to_xarray.py +19 -0
- mapchete_eo/processes/merge_rasters.py +235 -0
- mapchete_eo/product.py +278 -0
- mapchete_eo/protocols.py +56 -0
- mapchete_eo/search/__init__.py +14 -0
- mapchete_eo/search/base.py +222 -0
- mapchete_eo/search/config.py +42 -0
- mapchete_eo/search/s2_mgrs.py +314 -0
- mapchete_eo/search/stac_search.py +251 -0
- mapchete_eo/search/stac_static.py +236 -0
- mapchete_eo/search/utm_search.py +251 -0
- mapchete_eo/settings.py +24 -0
- mapchete_eo/sort.py +48 -0
- mapchete_eo/time.py +53 -0
- mapchete_eo/types.py +73 -0
- mapchete_eo-2025.7.0.dist-info/METADATA +38 -0
- mapchete_eo-2025.7.0.dist-info/RECORD +87 -0
- mapchete_eo-2025.7.0.dist-info/WHEEL +5 -0
- mapchete_eo-2025.7.0.dist-info/entry_points.txt +11 -0
- mapchete_eo-2025.7.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from typing import Tuple
|
|
3
|
+
|
|
4
|
+
import cv2
|
|
5
|
+
import numpy as np
|
|
6
|
+
import numpy.ma as ma
|
|
7
|
+
from numpy.typing import DTypeLike
|
|
8
|
+
from rasterio.plot import reshape_as_image, reshape_as_raster
|
|
9
|
+
|
|
10
|
+
from mapchete_eo.image_operations.sigmoidal import sigmoidal
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def color_correct(
|
|
16
|
+
rgb: ma.MaskedArray,
|
|
17
|
+
gamma: float = 1.15,
|
|
18
|
+
clahe_flag: bool = True,
|
|
19
|
+
clahe_clip_limit: float = 1.25,
|
|
20
|
+
clahe_tile_grid_size: Tuple[int, int] = (32, 32),
|
|
21
|
+
sigmoidal_flag: bool = False,
|
|
22
|
+
sigmoidal_constrast: int = 0,
|
|
23
|
+
sigmoidal_bias: float = 0.0,
|
|
24
|
+
saturation: float = 3.2,
|
|
25
|
+
calculations_dtype: DTypeLike = np.float16,
|
|
26
|
+
) -> ma.MaskedArray:
|
|
27
|
+
"""
|
|
28
|
+
Return color corrected 8 bit RGB array from 8 bit input RGB.
|
|
29
|
+
|
|
30
|
+
Uses rio-color to apply correction.
|
|
31
|
+
|
|
32
|
+
Parameters
|
|
33
|
+
----------
|
|
34
|
+
bands : ma.MaskedArray
|
|
35
|
+
Input bands as a 8bit 3D array.
|
|
36
|
+
gamma : float
|
|
37
|
+
Apply gamma in HSV color space.
|
|
38
|
+
clahe_clip_limit : float
|
|
39
|
+
Common values limit the resulting amplification to between 3 and 4.
|
|
40
|
+
See "Contrast Limited AHE" at:
|
|
41
|
+
https://en.wikipedia.org/wiki/Adaptive_histogram_equalization.
|
|
42
|
+
saturation : float
|
|
43
|
+
Controls the saturation in HSV color space.
|
|
44
|
+
|
|
45
|
+
Returns
|
|
46
|
+
-------
|
|
47
|
+
color corrected image : np.ndarray
|
|
48
|
+
"""
|
|
49
|
+
if isinstance(calculations_dtype, str):
|
|
50
|
+
calculations_dtype = np.dtype(getattr(np, calculations_dtype))
|
|
51
|
+
if not isinstance(calculations_dtype, np.dtype):
|
|
52
|
+
raise TypeError(
|
|
53
|
+
f"Harmonization dtype needs to be valid numpy dtype is: {type(calculations_dtype)}"
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
if rgb.dtype != "uint8":
|
|
57
|
+
raise TypeError("rgb must be of dtype np.uint8")
|
|
58
|
+
|
|
59
|
+
# get and keep original mask
|
|
60
|
+
rgb_src_mask = rgb.mask
|
|
61
|
+
rgb_src_fill_value = rgb.fill_value
|
|
62
|
+
|
|
63
|
+
# Move bands to the last axis
|
|
64
|
+
# rgb = np.swapaxes(rgb, 0, 2).astype(np.uint8, copy=False)
|
|
65
|
+
rgb_image = reshape_as_image(rgb)
|
|
66
|
+
|
|
67
|
+
# Saturation from OpenVC
|
|
68
|
+
if saturation != 1.0:
|
|
69
|
+
imghsv = cv2.cvtColor(rgb_image, cv2.COLOR_RGB2HSV).astype(
|
|
70
|
+
calculations_dtype, copy=False
|
|
71
|
+
)
|
|
72
|
+
(h, s, v) = cv2.split(imghsv)
|
|
73
|
+
# add all new HSV values into output
|
|
74
|
+
imghsv = cv2.merge([h, np.clip(s * saturation, 1, 255), v]).astype(
|
|
75
|
+
np.uint8, copy=False
|
|
76
|
+
)
|
|
77
|
+
rgb_image = np.clip(
|
|
78
|
+
cv2.cvtColor(imghsv, cv2.COLOR_HSV2RGB),
|
|
79
|
+
1,
|
|
80
|
+
255, # clip valid values to 1 and 255 to avoid accidental nodata values
|
|
81
|
+
).astype(np.uint8, copy=False)
|
|
82
|
+
|
|
83
|
+
# Sigmodial Contrast and Bias from rio-color
|
|
84
|
+
# Swap bands from last axis to the first one as we are acusstomed to
|
|
85
|
+
# For the sigmodial contrast
|
|
86
|
+
if sigmoidal_flag is True:
|
|
87
|
+
rgb_image = np.clip(
|
|
88
|
+
(
|
|
89
|
+
sigmoidal(
|
|
90
|
+
np.clip(
|
|
91
|
+
rgb_image.astype(calculations_dtype, copy=False) / 255,
|
|
92
|
+
0,
|
|
93
|
+
1,
|
|
94
|
+
).astype(calculations_dtype, copy=False),
|
|
95
|
+
contrast=sigmoidal_constrast,
|
|
96
|
+
bias=sigmoidal_bias,
|
|
97
|
+
out_dtype=calculations_dtype,
|
|
98
|
+
).astype(calculations_dtype, copy=False)
|
|
99
|
+
* 255
|
|
100
|
+
),
|
|
101
|
+
1,
|
|
102
|
+
255,
|
|
103
|
+
).astype(np.uint8, copy=False)
|
|
104
|
+
|
|
105
|
+
# Gamma from rio-color
|
|
106
|
+
if gamma != 1.0:
|
|
107
|
+
rgb_image = np.clip(
|
|
108
|
+
((rgb_image.astype(calculations_dtype) / 255) ** (1.0 / gamma)) * 255,
|
|
109
|
+
1,
|
|
110
|
+
255,
|
|
111
|
+
).astype(np.uint8, copy=False)
|
|
112
|
+
|
|
113
|
+
# CLAHE from OpenVC
|
|
114
|
+
# Some CLAHE info: https://imagej.net/plugins/clahe
|
|
115
|
+
if clahe_flag is True:
|
|
116
|
+
lab = cv2.cvtColor(
|
|
117
|
+
rgb_image.astype(np.uint8, copy=False), cv2.COLOR_RGB2LAB
|
|
118
|
+
).astype(np.uint8, copy=False)
|
|
119
|
+
lab_planes = list(cv2.split(lab))
|
|
120
|
+
clahe = cv2.createCLAHE(
|
|
121
|
+
clipLimit=clahe_clip_limit, tileGridSize=clahe_tile_grid_size
|
|
122
|
+
)
|
|
123
|
+
lab_planes[0] = clahe.apply(lab_planes[0])
|
|
124
|
+
lab = cv2.merge(lab_planes)
|
|
125
|
+
rgb_image = np.clip(
|
|
126
|
+
cv2.cvtColor(lab, cv2.COLOR_LAB2RGB),
|
|
127
|
+
1,
|
|
128
|
+
255, # clip valid values to 1 and 255 to avoid accidental nodata values
|
|
129
|
+
).astype(np.uint8, copy=False)
|
|
130
|
+
|
|
131
|
+
# Return array with original mask
|
|
132
|
+
return ma.masked_array(
|
|
133
|
+
data=reshape_as_raster(rgb_image),
|
|
134
|
+
mask=rgb_src_mask,
|
|
135
|
+
fill_value=rgb_src_fill_value,
|
|
136
|
+
)
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from enum import Enum
|
|
3
|
+
from typing import Callable, Optional
|
|
4
|
+
|
|
5
|
+
import blend_modes
|
|
6
|
+
import cv2
|
|
7
|
+
import numpy as np
|
|
8
|
+
import numpy.ma as ma
|
|
9
|
+
from mapchete import Timer
|
|
10
|
+
from rasterio.plot import reshape_as_image, reshape_as_raster
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def to_rgba(arr: np.ndarray) -> np.ndarray:
|
|
16
|
+
def _expanded_mask(arr: ma.MaskedArray) -> np.ndarray:
|
|
17
|
+
if isinstance(arr.mask, np.bool_):
|
|
18
|
+
return np.full(arr.shape, fill_value=arr.mask, dtype=bool)
|
|
19
|
+
else:
|
|
20
|
+
return arr.mask
|
|
21
|
+
|
|
22
|
+
# make sure array is a proper MaskedArray with expanded mask
|
|
23
|
+
if not isinstance(arr, ma.MaskedArray):
|
|
24
|
+
arr = ma.masked_array(arr, mask=np.zeros(arr.shape, dtype=bool))
|
|
25
|
+
if arr.dtype != np.uint8:
|
|
26
|
+
raise TypeError(f"image array must be of type uint8, not {str(arr.dtype)}")
|
|
27
|
+
num_bands = arr.shape[0]
|
|
28
|
+
if num_bands == 1:
|
|
29
|
+
alpha = np.where(~_expanded_mask(arr[0]), 255, 0).astype(np.uint8, copy=False)
|
|
30
|
+
out = np.stack([arr[0], arr[0], arr[0], alpha]).data
|
|
31
|
+
elif num_bands == 2:
|
|
32
|
+
out = np.stack([arr[0], arr[0], arr[0], arr[1]]).data
|
|
33
|
+
elif num_bands == 3:
|
|
34
|
+
alpha = np.where(
|
|
35
|
+
(
|
|
36
|
+
~_expanded_mask(arr[0])
|
|
37
|
+
& ~_expanded_mask(arr[1])
|
|
38
|
+
& ~_expanded_mask(arr[2])
|
|
39
|
+
),
|
|
40
|
+
255,
|
|
41
|
+
0,
|
|
42
|
+
).astype(np.uint8, copy=False)
|
|
43
|
+
out = np.stack([arr[0], arr[1], arr[2], alpha]).data
|
|
44
|
+
elif num_bands == 4:
|
|
45
|
+
out = arr.data
|
|
46
|
+
else: # pragma: no cover
|
|
47
|
+
raise TypeError(
|
|
48
|
+
f"array must have between one and four bands but has {num_bands}"
|
|
49
|
+
)
|
|
50
|
+
return np.array(out, dtype=np.float16)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _blend_base(
|
|
54
|
+
bg: np.ndarray, fg: np.ndarray, opacity: float, operation: Callable
|
|
55
|
+
) -> ma.MaskedArray:
|
|
56
|
+
# generate RGBA output and run compositing
|
|
57
|
+
out_arr = reshape_as_raster(
|
|
58
|
+
operation(
|
|
59
|
+
reshape_as_image(to_rgba(bg)),
|
|
60
|
+
reshape_as_image(to_rgba(fg)),
|
|
61
|
+
opacity,
|
|
62
|
+
disable_type_checks=True,
|
|
63
|
+
).astype(np.uint8)
|
|
64
|
+
)
|
|
65
|
+
# generate mask from alpha band
|
|
66
|
+
out_mask = np.where(out_arr[3] == 0, True, False)
|
|
67
|
+
return ma.masked_array(out_arr, mask=np.stack([out_mask for _ in range(4)]))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def normal(bg: np.ndarray, fg: np.ndarray, opacity: float = 1) -> ma.MaskedArray:
|
|
71
|
+
return _blend_base(bg, fg, opacity, blend_modes.normal)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def soft_light(bg: np.ndarray, fg: np.ndarray, opacity: float = 1) -> ma.MaskedArray:
|
|
75
|
+
return _blend_base(bg, fg, opacity, blend_modes.soft_light)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def lighten_only(bg: np.ndarray, fg: np.ndarray, opacity: float = 1) -> ma.MaskedArray:
|
|
79
|
+
return _blend_base(bg, fg, opacity, blend_modes.lighten_only)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def screen(bg: np.ndarray, fg: np.ndarray, opacity: float = 1) -> ma.MaskedArray:
|
|
83
|
+
return _blend_base(bg, fg, opacity, blend_modes.screen)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def dodge(bg: np.ndarray, fg: np.ndarray, opacity: float = 1) -> ma.MaskedArray:
|
|
87
|
+
return _blend_base(bg, fg, opacity, blend_modes.dodge)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def addition(bg: np.ndarray, fg: np.ndarray, opacity: float = 1) -> ma.MaskedArray:
|
|
91
|
+
return _blend_base(bg, fg, opacity, blend_modes.addition)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def darken_only(bg: np.ndarray, fg: np.ndarray, opacity: float = 1) -> ma.MaskedArray:
|
|
95
|
+
return _blend_base(bg, fg, opacity, blend_modes.darken_only)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def multiply(bg: np.ndarray, fg: np.ndarray, opacity: float = 1) -> ma.MaskedArray:
|
|
99
|
+
return _blend_base(bg, fg, opacity, blend_modes.multiply)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def hard_light(bg: np.ndarray, fg: np.ndarray, opacity: float = 1) -> ma.MaskedArray:
|
|
103
|
+
return _blend_base(bg, fg, opacity, blend_modes.hard_light)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def difference(bg: np.ndarray, fg: np.ndarray, opacity: float = 1) -> ma.MaskedArray:
|
|
107
|
+
return _blend_base(bg, fg, opacity, blend_modes.difference)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def subtract(bg: np.ndarray, fg: np.ndarray, opacity: float = 1) -> ma.MaskedArray:
|
|
111
|
+
return _blend_base(bg, fg, opacity, blend_modes.subtract)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def grain_extract(bg: np.ndarray, fg: np.ndarray, opacity: float = 1) -> ma.MaskedArray:
|
|
115
|
+
return _blend_base(bg, fg, opacity, blend_modes.grain_extract)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def grain_merge(bg: np.ndarray, fg: np.ndarray, opacity: float = 1) -> ma.MaskedArray:
|
|
119
|
+
return _blend_base(bg, fg, opacity, blend_modes.grain_merge)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def divide(bg: np.ndarray, fg: np.ndarray, opacity: float = 1) -> ma.MaskedArray:
|
|
123
|
+
return _blend_base(bg, fg, opacity, blend_modes.divide)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def overlay(bg: np.ndarray, fg: np.ndarray, opacity: float = 1) -> ma.MaskedArray:
|
|
127
|
+
return _blend_base(bg, fg, opacity, blend_modes.overlay)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
METHODS = {
|
|
131
|
+
"multiply": multiply,
|
|
132
|
+
"normal": normal,
|
|
133
|
+
"soft_light": soft_light,
|
|
134
|
+
"lighten_only": lighten_only,
|
|
135
|
+
"screen": screen,
|
|
136
|
+
"dodge": dodge,
|
|
137
|
+
"addition": addition,
|
|
138
|
+
"darken_only": darken_only,
|
|
139
|
+
"hard_light": hard_light,
|
|
140
|
+
"difference": difference,
|
|
141
|
+
"subtract": subtract,
|
|
142
|
+
"grain_extract": grain_extract,
|
|
143
|
+
"grain_merge": grain_merge,
|
|
144
|
+
"divide": divide,
|
|
145
|
+
"overlay": overlay,
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def composite(
|
|
150
|
+
method: str, bg: np.ndarray, fg: np.ndarray, opacity: float = 1
|
|
151
|
+
) -> ma.MaskedArray:
|
|
152
|
+
return METHODS[method](bg, fg, opacity)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def fuzzy_mask(
|
|
156
|
+
arr: np.ndarray,
|
|
157
|
+
fill_value: float,
|
|
158
|
+
radius: int = 0,
|
|
159
|
+
invert: bool = True,
|
|
160
|
+
dilate: bool = True,
|
|
161
|
+
) -> np.ndarray:
|
|
162
|
+
"""Create fuzzy mask from binary mask."""
|
|
163
|
+
if arr.ndim == 2:
|
|
164
|
+
arr = np.expand_dims(arr, 0)
|
|
165
|
+
if arr.ndim != 3:
|
|
166
|
+
raise TypeError("array must have exactly three dimensions")
|
|
167
|
+
if arr.shape[0] == 1:
|
|
168
|
+
three_bands = np.stack([arr[0] for _ in range(3)])
|
|
169
|
+
elif arr.shape[0] == 3:
|
|
170
|
+
three_bands = arr
|
|
171
|
+
else:
|
|
172
|
+
raise TypeError(
|
|
173
|
+
f"array must have either one or three bands, not {arr.shape[0]}"
|
|
174
|
+
)
|
|
175
|
+
if invert:
|
|
176
|
+
three_bands = ~three_bands
|
|
177
|
+
# convert mask into an image and set true values to fill value
|
|
178
|
+
# dilate = buffer image using the blur radius
|
|
179
|
+
out = np.multiply(reshape_as_image(three_bands), fill_value, dtype=np.uint8)
|
|
180
|
+
if dilate and radius:
|
|
181
|
+
with Timer() as t:
|
|
182
|
+
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (radius, radius))
|
|
183
|
+
logger.debug("dilation kernel generated in %s", t)
|
|
184
|
+
with Timer() as t:
|
|
185
|
+
out = cv2.morphologyEx(out, cv2.MORPH_DILATE, kernel)
|
|
186
|
+
logger.debug("dilation took %s", t)
|
|
187
|
+
# blur and return
|
|
188
|
+
if radius:
|
|
189
|
+
with Timer() as t:
|
|
190
|
+
out = reshape_as_raster(cv2.blur(out, (radius, radius)))[0]
|
|
191
|
+
logger.debug("blur filter took %s", t)
|
|
192
|
+
else:
|
|
193
|
+
out = reshape_as_raster(out)[0]
|
|
194
|
+
if invert:
|
|
195
|
+
return -(out - fill_value).astype(np.uint8)
|
|
196
|
+
return out
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class GradientPosition(Enum):
|
|
200
|
+
inside = "inside"
|
|
201
|
+
outside = "outside"
|
|
202
|
+
edge = "edge"
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def fuzzy_alpha_mask(
|
|
206
|
+
arr: np.ndarray,
|
|
207
|
+
mask: Optional[np.ndarray] = None,
|
|
208
|
+
radius=0,
|
|
209
|
+
fill_value=255,
|
|
210
|
+
gradient_position=GradientPosition.outside,
|
|
211
|
+
) -> np.ndarray:
|
|
212
|
+
"""Return an RGBA array with a fuzzy alpha mask."""
|
|
213
|
+
gradient_position = (
|
|
214
|
+
GradientPosition[gradient_position]
|
|
215
|
+
if isinstance(gradient_position, str)
|
|
216
|
+
else gradient_position
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
if arr.shape[0] != 3:
|
|
220
|
+
raise TypeError("input array must have exactly three bands")
|
|
221
|
+
|
|
222
|
+
if mask is None:
|
|
223
|
+
if not isinstance(arr, ma.MaskedArray):
|
|
224
|
+
raise TypeError(
|
|
225
|
+
"input array must be a numpy MaskedArray or mask must be provided"
|
|
226
|
+
)
|
|
227
|
+
mask = arr.mask
|
|
228
|
+
|
|
229
|
+
if gradient_position == GradientPosition.outside:
|
|
230
|
+
fuzzy = fuzzy_mask(
|
|
231
|
+
mask, fill_value=fill_value, radius=radius, invert=False, dilate=True
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
elif gradient_position == GradientPosition.inside:
|
|
235
|
+
fuzzy = fuzzy_mask(
|
|
236
|
+
mask, fill_value=fill_value, radius=radius, invert=True, dilate=True
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
elif gradient_position == GradientPosition.edge:
|
|
240
|
+
fuzzy = fuzzy_mask(mask, fill_value=fill_value, radius=radius, dilate=False)
|
|
241
|
+
|
|
242
|
+
else: # pragma: no cover
|
|
243
|
+
raise ValueError(f"unknown gradient_position: {gradient_position}")
|
|
244
|
+
|
|
245
|
+
# doing this makes sure that originally masked pixels are also fully masked
|
|
246
|
+
# fuzzy[mask[0]] = 255
|
|
247
|
+
return np.concatenate((arr, np.expand_dims(fuzzy, 0)), axis=0)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
import numpy.ma as ma
|
|
5
|
+
from mapchete.types import NodataVal
|
|
6
|
+
from numpy.typing import DTypeLike
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def dtype_scale(
|
|
10
|
+
bands: ma.MaskedArray,
|
|
11
|
+
nodata: Optional[NodataVal] = None,
|
|
12
|
+
out_dtype: Optional[DTypeLike] = np.uint8,
|
|
13
|
+
max_source_value: float = 10000.0,
|
|
14
|
+
max_output_value: Optional[float] = None,
|
|
15
|
+
) -> ma.MaskedArray:
|
|
16
|
+
"""
|
|
17
|
+
(1) normalize array from range [0:max_value] to range [0:1]
|
|
18
|
+
(2) multiply with out_values to create range [0:out_values]
|
|
19
|
+
(3) clip to [1:out_values] to avoid rounding errors where band value can
|
|
20
|
+
accidentally become nodata (0)
|
|
21
|
+
(4) create masked array with burnt in nodata values and original nodata mask
|
|
22
|
+
"""
|
|
23
|
+
out_dtype = np.dtype(out_dtype)
|
|
24
|
+
|
|
25
|
+
if max_output_value is None:
|
|
26
|
+
max_output_value = np.iinfo(out_dtype).max
|
|
27
|
+
|
|
28
|
+
if nodata is None:
|
|
29
|
+
nodata = 0
|
|
30
|
+
|
|
31
|
+
return ma.masked_where(
|
|
32
|
+
bands == nodata,
|
|
33
|
+
np.where(
|
|
34
|
+
bands.mask,
|
|
35
|
+
nodata,
|
|
36
|
+
np.clip(
|
|
37
|
+
(bands.astype("float16", copy=False) / max_source_value)
|
|
38
|
+
* max_output_value,
|
|
39
|
+
1,
|
|
40
|
+
max_output_value,
|
|
41
|
+
),
|
|
42
|
+
),
|
|
43
|
+
).astype(out_dtype, copy=False)
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from enum import Enum
|
|
3
|
+
|
|
4
|
+
import numpy as np
|
|
5
|
+
import numpy.ma as ma
|
|
6
|
+
from mapchete import Timer
|
|
7
|
+
from rasterio.features import rasterize, shapes
|
|
8
|
+
from rasterio.fill import fillnodata as rio_fillnodata
|
|
9
|
+
from scipy.ndimage.filters import convolve
|
|
10
|
+
from shapely.geometry import shape
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class FillSelectionMethod(str, Enum):
|
|
16
|
+
all = "all"
|
|
17
|
+
patch_size = "patch_size"
|
|
18
|
+
nodata_neighbors = "nodata_neighbors"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def fillnodata(
|
|
22
|
+
bands: ma.MaskedArray,
|
|
23
|
+
method: FillSelectionMethod = FillSelectionMethod.patch_size,
|
|
24
|
+
max_patch_size: int = 2,
|
|
25
|
+
max_nodata_neighbors: int = 0,
|
|
26
|
+
max_search_distance: float = 10,
|
|
27
|
+
smoothing_iterations: int = 0,
|
|
28
|
+
) -> ma.MaskedArray:
|
|
29
|
+
"""
|
|
30
|
+
Interpolate nodata areas up to a given size.
|
|
31
|
+
|
|
32
|
+
This function uses the nodata mask to determine contingent nodata areas. Patches
|
|
33
|
+
up to a certain size are then interpolated using rasterio.fill.fillnodata.
|
|
34
|
+
|
|
35
|
+
Parameters
|
|
36
|
+
----------
|
|
37
|
+
bands : ma.MaskedArray
|
|
38
|
+
Input bands as a 3D array.
|
|
39
|
+
method : str
|
|
40
|
+
Method how to select areas to interpolate. (default: patch_size)
|
|
41
|
+
- all: interpolate all nodata areas
|
|
42
|
+
- patch_size: only interpolate areas up to a certain size. (defined by
|
|
43
|
+
max_patch_size)
|
|
44
|
+
- nodata_neighbors: only interpolate single nodata pixel.
|
|
45
|
+
max_patch_size : int
|
|
46
|
+
Maximum patch size in pixels which is going to be interpolated in "patch_size"
|
|
47
|
+
method.
|
|
48
|
+
max_nodata_neighbors : int
|
|
49
|
+
Maximum number of nodata neighbor pixels in "nodata_neighbors" method.
|
|
50
|
+
max_search_distance : float
|
|
51
|
+
The maxmimum number of pixels to search in all directions to find values to
|
|
52
|
+
interpolate from.
|
|
53
|
+
smoothing_iterations : int
|
|
54
|
+
The number of 3x3 smoothing filter passes to run.
|
|
55
|
+
|
|
56
|
+
Returns
|
|
57
|
+
-------
|
|
58
|
+
filled bands : ma.MaskedArray
|
|
59
|
+
"""
|
|
60
|
+
if not isinstance(bands, ma.MaskedArray): # pragma: no cover
|
|
61
|
+
raise TypeError("bands must be a ma.MaskedArray")
|
|
62
|
+
|
|
63
|
+
def _interpolate(bands, max_search_distance, smoothing_iterations):
|
|
64
|
+
return np.stack(
|
|
65
|
+
[
|
|
66
|
+
rio_fillnodata(
|
|
67
|
+
band.data,
|
|
68
|
+
mask=~band.mask,
|
|
69
|
+
max_search_distance=max_search_distance,
|
|
70
|
+
smoothing_iterations=smoothing_iterations,
|
|
71
|
+
)
|
|
72
|
+
for band in bands
|
|
73
|
+
]
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
if bands.mask.any():
|
|
77
|
+
if method == FillSelectionMethod.all:
|
|
78
|
+
logger.debug("interpolate pixel values in all nodata areas")
|
|
79
|
+
return ma.masked_array(
|
|
80
|
+
data=_interpolate(bands, max_search_distance, smoothing_iterations),
|
|
81
|
+
mask=np.zeros(bands.shape),
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
elif method == FillSelectionMethod.patch_size:
|
|
85
|
+
logger.debug(
|
|
86
|
+
"interpolate pixel values in nodata areas smaller than or equal %s pixel",
|
|
87
|
+
max_patch_size,
|
|
88
|
+
)
|
|
89
|
+
with Timer() as t:
|
|
90
|
+
patches = [
|
|
91
|
+
(p, v)
|
|
92
|
+
for p, v in shapes(bands.mask[0].astype(np.uint8))
|
|
93
|
+
if v == 1 and shape(p).area <= (max_patch_size)
|
|
94
|
+
]
|
|
95
|
+
logger.debug("found %s small nodata patches in %s", len(patches), t)
|
|
96
|
+
if patches:
|
|
97
|
+
interpolation_mask = rasterize(
|
|
98
|
+
patches,
|
|
99
|
+
out_shape=bands[0].data.shape,
|
|
100
|
+
).astype(bool)
|
|
101
|
+
# create masked aray using original mask with removed small patches
|
|
102
|
+
return ma.masked_array(
|
|
103
|
+
data=_interpolate(bands, max_search_distance, smoothing_iterations),
|
|
104
|
+
mask=bands.mask ^ np.stack([interpolation_mask for _ in bands]),
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
elif method == FillSelectionMethod.nodata_neighbors:
|
|
108
|
+
kernel = np.array(
|
|
109
|
+
[
|
|
110
|
+
[0, 1, 0],
|
|
111
|
+
[1, 0, 1],
|
|
112
|
+
[0, 1, 0],
|
|
113
|
+
]
|
|
114
|
+
)
|
|
115
|
+
# count occurances of masked neighbor pixels
|
|
116
|
+
number_mask = bands[0].mask.astype(np.uint8)
|
|
117
|
+
count_mask = convolve(number_mask, kernel)
|
|
118
|
+
# use interpolation on nodata values where there are no neighbor pixels
|
|
119
|
+
interpolation_mask = (count_mask <= max_nodata_neighbors) & bands[0].mask
|
|
120
|
+
# create masked aray using original mask with removed small patches
|
|
121
|
+
return ma.masked_array(
|
|
122
|
+
data=_interpolate(bands, max_search_distance, smoothing_iterations),
|
|
123
|
+
mask=bands.mask ^ np.stack([interpolation_mask for _ in bands]),
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
else: # pragma: no cover
|
|
127
|
+
raise ValueError(f"unknown method: {method}")
|
|
128
|
+
|
|
129
|
+
# if nothing was masked or no small patches could be found, return original data
|
|
130
|
+
return bands
|