mapchete-eo 2026.2.0__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.
Files changed (89) hide show
  1. mapchete_eo/__init__.py +1 -0
  2. mapchete_eo/array/__init__.py +0 -0
  3. mapchete_eo/array/buffer.py +16 -0
  4. mapchete_eo/array/color.py +29 -0
  5. mapchete_eo/array/convert.py +163 -0
  6. mapchete_eo/base.py +653 -0
  7. mapchete_eo/blacklist.txt +175 -0
  8. mapchete_eo/cli/__init__.py +30 -0
  9. mapchete_eo/cli/bounds.py +22 -0
  10. mapchete_eo/cli/options_arguments.py +227 -0
  11. mapchete_eo/cli/s2_brdf.py +77 -0
  12. mapchete_eo/cli/s2_cat_results.py +130 -0
  13. mapchete_eo/cli/s2_find_broken_products.py +77 -0
  14. mapchete_eo/cli/s2_jp2_static_catalog.py +166 -0
  15. mapchete_eo/cli/s2_mask.py +71 -0
  16. mapchete_eo/cli/s2_mgrs.py +45 -0
  17. mapchete_eo/cli/s2_rgb.py +114 -0
  18. mapchete_eo/cli/s2_verify.py +129 -0
  19. mapchete_eo/cli/static_catalog.py +82 -0
  20. mapchete_eo/eostac.py +30 -0
  21. mapchete_eo/exceptions.py +87 -0
  22. mapchete_eo/image_operations/__init__.py +12 -0
  23. mapchete_eo/image_operations/blend_functions.py +579 -0
  24. mapchete_eo/image_operations/color_correction.py +136 -0
  25. mapchete_eo/image_operations/compositing.py +266 -0
  26. mapchete_eo/image_operations/dtype_scale.py +43 -0
  27. mapchete_eo/image_operations/fillnodata.py +130 -0
  28. mapchete_eo/image_operations/filters.py +319 -0
  29. mapchete_eo/image_operations/linear_normalization.py +81 -0
  30. mapchete_eo/image_operations/sigmoidal.py +114 -0
  31. mapchete_eo/io/__init__.py +37 -0
  32. mapchete_eo/io/assets.py +496 -0
  33. mapchete_eo/io/items.py +162 -0
  34. mapchete_eo/io/levelled_cubes.py +259 -0
  35. mapchete_eo/io/path.py +155 -0
  36. mapchete_eo/io/products.py +423 -0
  37. mapchete_eo/io/profiles.py +45 -0
  38. mapchete_eo/platforms/sentinel2/__init__.py +17 -0
  39. mapchete_eo/platforms/sentinel2/_mapper_registry.py +89 -0
  40. mapchete_eo/platforms/sentinel2/bandpass_adjustment.py +104 -0
  41. mapchete_eo/platforms/sentinel2/brdf/__init__.py +8 -0
  42. mapchete_eo/platforms/sentinel2/brdf/config.py +32 -0
  43. mapchete_eo/platforms/sentinel2/brdf/correction.py +260 -0
  44. mapchete_eo/platforms/sentinel2/brdf/hls.py +251 -0
  45. mapchete_eo/platforms/sentinel2/brdf/models.py +44 -0
  46. mapchete_eo/platforms/sentinel2/brdf/protocols.py +27 -0
  47. mapchete_eo/platforms/sentinel2/brdf/ross_thick.py +136 -0
  48. mapchete_eo/platforms/sentinel2/brdf/sun_angle_arrays.py +76 -0
  49. mapchete_eo/platforms/sentinel2/config.py +241 -0
  50. mapchete_eo/platforms/sentinel2/driver.py +43 -0
  51. mapchete_eo/platforms/sentinel2/masks.py +329 -0
  52. mapchete_eo/platforms/sentinel2/metadata_parser/__init__.py +6 -0
  53. mapchete_eo/platforms/sentinel2/metadata_parser/base.py +56 -0
  54. mapchete_eo/platforms/sentinel2/metadata_parser/default_path_mapper.py +135 -0
  55. mapchete_eo/platforms/sentinel2/metadata_parser/models.py +78 -0
  56. mapchete_eo/platforms/sentinel2/metadata_parser/s2metadata.py +639 -0
  57. mapchete_eo/platforms/sentinel2/preconfigured_sources/__init__.py +57 -0
  58. mapchete_eo/platforms/sentinel2/preconfigured_sources/guessers.py +108 -0
  59. mapchete_eo/platforms/sentinel2/preconfigured_sources/item_mappers.py +171 -0
  60. mapchete_eo/platforms/sentinel2/preconfigured_sources/metadata_xml_mappers.py +217 -0
  61. mapchete_eo/platforms/sentinel2/preprocessing_tasks.py +50 -0
  62. mapchete_eo/platforms/sentinel2/processing_baseline.py +163 -0
  63. mapchete_eo/platforms/sentinel2/product.py +747 -0
  64. mapchete_eo/platforms/sentinel2/source.py +114 -0
  65. mapchete_eo/platforms/sentinel2/types.py +114 -0
  66. mapchete_eo/processes/__init__.py +0 -0
  67. mapchete_eo/processes/config.py +51 -0
  68. mapchete_eo/processes/dtype_scale.py +112 -0
  69. mapchete_eo/processes/eo_to_xarray.py +19 -0
  70. mapchete_eo/processes/merge_rasters.py +239 -0
  71. mapchete_eo/product.py +323 -0
  72. mapchete_eo/protocols.py +61 -0
  73. mapchete_eo/search/__init__.py +14 -0
  74. mapchete_eo/search/base.py +285 -0
  75. mapchete_eo/search/config.py +113 -0
  76. mapchete_eo/search/s2_mgrs.py +313 -0
  77. mapchete_eo/search/stac_search.py +278 -0
  78. mapchete_eo/search/stac_static.py +197 -0
  79. mapchete_eo/search/utm_search.py +251 -0
  80. mapchete_eo/settings.py +25 -0
  81. mapchete_eo/sort.py +60 -0
  82. mapchete_eo/source.py +109 -0
  83. mapchete_eo/time.py +62 -0
  84. mapchete_eo/types.py +76 -0
  85. mapchete_eo-2026.2.0.dist-info/METADATA +91 -0
  86. mapchete_eo-2026.2.0.dist-info/RECORD +89 -0
  87. mapchete_eo-2026.2.0.dist-info/WHEEL +4 -0
  88. mapchete_eo-2026.2.0.dist-info/entry_points.txt +11 -0
  89. mapchete_eo-2026.2.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,266 @@
1
+ import logging
2
+ from enum import Enum
3
+ from typing import Callable, Optional
4
+
5
+ import cv2
6
+ import numpy as np
7
+ import numpy.ma as ma
8
+ from mapchete import Timer
9
+ from rasterio.plot import reshape_as_image, reshape_as_raster
10
+
11
+ from mapchete_eo.image_operations import blend_functions
12
+
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ def to_rgba(arr: np.ndarray) -> np.ndarray:
18
+ def _expanded_mask(arr: ma.MaskedArray) -> np.ndarray:
19
+ if isinstance(arr.mask, np.bool_):
20
+ return np.full(arr.shape, fill_value=arr.mask, dtype=bool)
21
+ else:
22
+ return arr.mask
23
+
24
+ # make sure array is a proper MaskedArray with expanded mask
25
+ if not isinstance(arr, ma.MaskedArray):
26
+ arr = ma.masked_array(arr, mask=np.zeros(arr.shape, dtype=bool))
27
+ if arr.dtype != np.uint8:
28
+ raise TypeError(f"image array must be of type uint8, not {str(arr.dtype)}")
29
+ num_bands = arr.shape[0]
30
+ if num_bands == 1:
31
+ alpha = np.where(~_expanded_mask(arr[0]), 255, 0).astype(np.uint8, copy=False)
32
+ out = np.stack([arr[0], arr[0], arr[0], alpha]).data
33
+ elif num_bands == 2:
34
+ out = np.stack([arr[0], arr[0], arr[0], arr[1]]).data
35
+ elif num_bands == 3:
36
+ alpha = np.where(
37
+ (
38
+ ~_expanded_mask(arr[0])
39
+ & ~_expanded_mask(arr[1])
40
+ & ~_expanded_mask(arr[2])
41
+ ),
42
+ 255,
43
+ 0,
44
+ ).astype(np.uint8, copy=False)
45
+ out = np.stack([arr[0], arr[1], arr[2], alpha]).data
46
+ elif num_bands == 4:
47
+ out = arr.data
48
+ else: # pragma: no cover
49
+ raise TypeError(
50
+ f"array must have between one and four bands but has {num_bands}"
51
+ )
52
+ return np.array(out, dtype=np.float16)
53
+
54
+
55
+ def _blend_base(
56
+ bg: np.ndarray, fg: np.ndarray, opacity: float, operation: Callable
57
+ ) -> ma.MaskedArray:
58
+ # generate RGBA output and run compositing and normalize by dividing by 255
59
+ out_arr = reshape_as_raster(
60
+ (
61
+ operation(
62
+ reshape_as_image(to_rgba(bg) / 255),
63
+ reshape_as_image(to_rgba(fg) / 255),
64
+ opacity,
65
+ )
66
+ * 255
67
+ ).astype(np.uint8, copy=False)
68
+ )
69
+ # generate mask from alpha band
70
+ out_mask = np.where(out_arr[3] == 0, True, False)
71
+ return ma.masked_array(out_arr, mask=np.stack([out_mask for _ in range(4)]))
72
+
73
+
74
+ def normal(bg: np.ndarray, fg: np.ndarray, opacity: float = 1) -> ma.MaskedArray:
75
+ return _blend_base(bg, fg, opacity, blend_functions.normal)
76
+
77
+
78
+ def soft_light(bg: np.ndarray, fg: np.ndarray, opacity: float = 1) -> ma.MaskedArray:
79
+ return _blend_base(bg, fg, opacity, blend_functions.soft_light)
80
+
81
+
82
+ def lighten_only(bg: np.ndarray, fg: np.ndarray, opacity: float = 1) -> ma.MaskedArray:
83
+ return _blend_base(bg, fg, opacity, blend_functions.lighten_only)
84
+
85
+
86
+ def screen(bg: np.ndarray, fg: np.ndarray, opacity: float = 1) -> ma.MaskedArray:
87
+ return _blend_base(bg, fg, opacity, blend_functions.screen)
88
+
89
+
90
+ def dodge(bg: np.ndarray, fg: np.ndarray, opacity: float = 1) -> ma.MaskedArray:
91
+ return _blend_base(bg, fg, opacity, blend_functions.dodge)
92
+
93
+
94
+ def addition(bg: np.ndarray, fg: np.ndarray, opacity: float = 1) -> ma.MaskedArray:
95
+ return _blend_base(bg, fg, opacity, blend_functions.addition)
96
+
97
+
98
+ def darken_only(bg: np.ndarray, fg: np.ndarray, opacity: float = 1) -> ma.MaskedArray:
99
+ return _blend_base(bg, fg, opacity, blend_functions.darken_only)
100
+
101
+
102
+ def multiply(bg: np.ndarray, fg: np.ndarray, opacity: float = 1) -> ma.MaskedArray:
103
+ return _blend_base(bg, fg, opacity, blend_functions.multiply)
104
+
105
+
106
+ def hard_light(bg: np.ndarray, fg: np.ndarray, opacity: float = 1) -> ma.MaskedArray:
107
+ return _blend_base(bg, fg, opacity, blend_functions.hard_light)
108
+
109
+
110
+ def difference(bg: np.ndarray, fg: np.ndarray, opacity: float = 1) -> ma.MaskedArray:
111
+ return _blend_base(bg, fg, opacity, blend_functions.difference)
112
+
113
+
114
+ def subtract(bg: np.ndarray, fg: np.ndarray, opacity: float = 1) -> ma.MaskedArray:
115
+ return _blend_base(bg, fg, opacity, blend_functions.subtract)
116
+
117
+
118
+ def grain_extract(bg: np.ndarray, fg: np.ndarray, opacity: float = 1) -> ma.MaskedArray:
119
+ return _blend_base(bg, fg, opacity, blend_functions.grain_extract)
120
+
121
+
122
+ def grain_merge(bg: np.ndarray, fg: np.ndarray, opacity: float = 1) -> ma.MaskedArray:
123
+ return _blend_base(bg, fg, opacity, blend_functions.grain_merge)
124
+
125
+
126
+ def divide(bg: np.ndarray, fg: np.ndarray, opacity: float = 1) -> ma.MaskedArray:
127
+ return _blend_base(bg, fg, opacity, blend_functions.divide)
128
+
129
+
130
+ def overlay(bg: np.ndarray, fg: np.ndarray, opacity: float = 1) -> ma.MaskedArray:
131
+ return _blend_base(bg, fg, opacity, blend_functions.overlay)
132
+
133
+
134
+ METHODS = {
135
+ "multiply": multiply,
136
+ "normal": normal,
137
+ "soft_light": soft_light,
138
+ "lighten_only": lighten_only,
139
+ "screen": screen,
140
+ "dodge": dodge,
141
+ "addition": addition,
142
+ "darken_only": darken_only,
143
+ "hard_light": hard_light,
144
+ "difference": difference,
145
+ "subtract": subtract,
146
+ "grain_extract": grain_extract,
147
+ "grain_merge": grain_merge,
148
+ "divide": divide,
149
+ "overlay": overlay,
150
+ }
151
+
152
+
153
+ def composite(
154
+ method: str, bg: np.ndarray, fg: np.ndarray, opacity: float = 1
155
+ ) -> ma.MaskedArray:
156
+ """
157
+ Composite two image arrays using a named blending method.
158
+
159
+ Args:
160
+ method: Blending method name (e.g., 'multiply', 'screen').
161
+ bg: Background image array (channels-first).
162
+ fg: Foreground image array (channels-first).
163
+ opacity: Opacity of the foreground layer (0-1).
164
+
165
+ Returns:
166
+ ma.MaskedArray: Blended RGBA result.
167
+ """
168
+ return METHODS[method](bg, fg, opacity)
169
+
170
+
171
+ def fuzzy_mask(
172
+ arr: np.ndarray,
173
+ fill_value: float,
174
+ radius: int = 0,
175
+ invert: bool = True,
176
+ dilate: bool = True,
177
+ ) -> np.ndarray:
178
+ """Create fuzzy mask from binary mask."""
179
+ if arr.ndim == 2:
180
+ arr = np.expand_dims(arr, 0)
181
+ if arr.ndim != 3:
182
+ raise TypeError("array must have exactly three dimensions")
183
+ if arr.shape[0] == 1:
184
+ three_bands = np.stack([arr[0] for _ in range(3)])
185
+ elif arr.shape[0] == 3:
186
+ three_bands = arr
187
+ else:
188
+ raise TypeError(
189
+ f"array must have either one or three bands, not {arr.shape[0]}"
190
+ )
191
+ if invert:
192
+ three_bands = ~three_bands
193
+ # convert mask into an image and set true values to fill value
194
+ # dilate = buffer image using the blur radius
195
+ out = np.multiply(reshape_as_image(three_bands), fill_value, dtype=np.uint8)
196
+ if dilate and radius:
197
+ with Timer() as t:
198
+ kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (radius, radius))
199
+ logger.debug("dilation kernel generated in %s", t)
200
+ with Timer() as t:
201
+ out = cv2.morphologyEx(out, cv2.MORPH_DILATE, kernel)
202
+ logger.debug("dilation took %s", t)
203
+ # blur and return
204
+ if radius:
205
+ with Timer() as t:
206
+ out = reshape_as_raster(cv2.blur(out, (radius, radius)))[0]
207
+ logger.debug("blur filter took %s", t)
208
+ else:
209
+ out = reshape_as_raster(out)[0]
210
+ if invert:
211
+ return -(out - fill_value).astype(np.uint8)
212
+ return out
213
+
214
+
215
+ class GradientPosition(Enum):
216
+ inside = "inside"
217
+ outside = "outside"
218
+ edge = "edge"
219
+
220
+
221
+ def fuzzy_alpha_mask(
222
+ arr: np.ndarray,
223
+ mask: Optional[np.ndarray] = None,
224
+ radius=0,
225
+ fill_value=255,
226
+ gradient_position=GradientPosition.outside,
227
+ ) -> np.ndarray:
228
+ """Return an RGBA array with a fuzzy alpha mask."""
229
+ try:
230
+ gradient_position = (
231
+ GradientPosition[gradient_position]
232
+ if isinstance(gradient_position, str)
233
+ else gradient_position
234
+ )
235
+ except KeyError:
236
+ raise ValueError(f"unknown gradient_position: {gradient_position}")
237
+
238
+ if arr.shape[0] != 3:
239
+ raise TypeError("input array must have exactly three bands")
240
+
241
+ if mask is None:
242
+ if not isinstance(arr, ma.MaskedArray):
243
+ raise TypeError(
244
+ "input array must be a numpy MaskedArray or mask must be provided"
245
+ )
246
+ mask = arr.mask
247
+
248
+ if gradient_position == GradientPosition.outside:
249
+ fuzzy = fuzzy_mask(
250
+ mask, fill_value=fill_value, radius=radius, invert=False, dilate=True
251
+ )
252
+
253
+ elif gradient_position == GradientPosition.inside:
254
+ fuzzy = fuzzy_mask(
255
+ mask, fill_value=fill_value, radius=radius, invert=True, dilate=True
256
+ )
257
+
258
+ elif gradient_position == GradientPosition.edge:
259
+ fuzzy = fuzzy_mask(mask, fill_value=fill_value, radius=radius, dilate=False)
260
+
261
+ else: # pragma: no cover
262
+ raise ValueError(f"unknown gradient_position: {gradient_position}")
263
+
264
+ # doing this makes sure that originally masked pixels are also fully masked
265
+ # fuzzy[mask[0]] = 255
266
+ 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 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