sentle 2024.5.3__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.
- sentle/__init__.py +0 -0
- sentle/cloud_mask.py +54 -0
- sentle/const.py +20 -0
- sentle/data/sentinel2_grid_stripped_with_epsg.gpkg +0 -0
- sentle/sentle.py +1076 -0
- sentle/snow_mask.py +27 -0
- sentle/utils.py +24 -0
- sentle-2024.5.3.dist-info/LICENSE.md +21 -0
- sentle-2024.5.3.dist-info/METADATA +31 -0
- sentle-2024.5.3.dist-info/RECORD +12 -0
- sentle-2024.5.3.dist-info/WHEEL +5 -0
- sentle-2024.5.3.dist-info/top_level.txt +1 -0
sentle/__init__.py
ADDED
|
File without changes
|
sentle/cloud_mask.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
import pkg_resources
|
|
5
|
+
import torch
|
|
6
|
+
import xarray as xr
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def load_cloudsen_model(device: str = "cpu"):
|
|
10
|
+
pkg_path = os.path.dirname(
|
|
11
|
+
pkg_resources.resource_filename("sentle", "sentle.py"))
|
|
12
|
+
model_path = os.path.join(pkg_path, "data", "cloudmodel.pt")
|
|
13
|
+
cloudsen_model = torch.jit.load(model_path)
|
|
14
|
+
cloudsen_model.eval()
|
|
15
|
+
cloudsen_model.to(device)
|
|
16
|
+
|
|
17
|
+
return cloudsen_model
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
S2_cloud_mask_band = "S2_cloud_classification"
|
|
21
|
+
S2_cloud_prob_bands = [
|
|
22
|
+
"S2_clear_sky_probability", "S2_thick_cloud_probability",
|
|
23
|
+
"S2_thin_cloud_probability", "S2_shadow_probability"
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def compute_cloud_mask(array: np.array, model: torch.jit.ScriptModule,
|
|
28
|
+
S2_cloud_classification_device: str):
|
|
29
|
+
|
|
30
|
+
assert array.shape == (
|
|
31
|
+
12, 732,
|
|
32
|
+
732), "only supporting shape (12, 732, 732) for cloud masking for now"
|
|
33
|
+
|
|
34
|
+
# add padding so that shape is divisable by 16 for cloudsen
|
|
35
|
+
array = np.pad(array, [(0, 0), (2, 2), (2, 2)], "edge")
|
|
36
|
+
|
|
37
|
+
# expand one dim because it needs it
|
|
38
|
+
array = np.expand_dims(array, axis=0)
|
|
39
|
+
|
|
40
|
+
# Convert array to torch tensor, divide by 10000
|
|
41
|
+
# This mantains the array in [0,1]
|
|
42
|
+
tensor = torch.from_numpy(array) / 10000
|
|
43
|
+
|
|
44
|
+
# move to device
|
|
45
|
+
tensor = tensor.to(S2_cloud_classification_device)
|
|
46
|
+
|
|
47
|
+
# Compute the cloud mask
|
|
48
|
+
with torch.no_grad():
|
|
49
|
+
cloud_probabilities = model(tensor.type(torch.float32)).cpu().numpy()
|
|
50
|
+
|
|
51
|
+
# remove padding again
|
|
52
|
+
cloud_probabilities = cloud_probabilities[0, :, 2:-2, 2:-2]
|
|
53
|
+
|
|
54
|
+
return cloud_probabilities
|
sentle/const.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
S2_subtile_size = 732
|
|
2
|
+
|
|
3
|
+
S2_RAW_BANDS = [
|
|
4
|
+
'B01', 'B02', 'B03', 'B04', 'B05', 'B06', 'B07', 'B08', 'B8A', 'B09',
|
|
5
|
+
'B11', 'B12'
|
|
6
|
+
]
|
|
7
|
+
S2_RAW_BAND_RESOLUTION = {
|
|
8
|
+
'B01': 60,
|
|
9
|
+
'B02': 10,
|
|
10
|
+
'B03': 10,
|
|
11
|
+
'B04': 10,
|
|
12
|
+
'B05': 20,
|
|
13
|
+
'B06': 20,
|
|
14
|
+
'B07': 20,
|
|
15
|
+
'B08': 10,
|
|
16
|
+
'B8A': 20,
|
|
17
|
+
'B09': 60,
|
|
18
|
+
'B11': 20,
|
|
19
|
+
'B12': 20
|
|
20
|
+
}
|
|
Binary file
|
sentle/sentle.py
ADDED
|
@@ -0,0 +1,1076 @@
|
|
|
1
|
+
import itertools
|
|
2
|
+
import warnings
|
|
3
|
+
|
|
4
|
+
import dask.array
|
|
5
|
+
import geopandas as gpd
|
|
6
|
+
import numpy as np
|
|
7
|
+
import pandas as pd
|
|
8
|
+
import pkg_resources
|
|
9
|
+
import planetary_computer
|
|
10
|
+
import pystac_client
|
|
11
|
+
import rasterio
|
|
12
|
+
import scipy.ndimage as sc
|
|
13
|
+
import xarray as xr
|
|
14
|
+
import zarr
|
|
15
|
+
from affine import Affine
|
|
16
|
+
from dask.distributed import Client, LocalCluster, Variable
|
|
17
|
+
from numcodecs import Blosc
|
|
18
|
+
from pystac_client.item_search import DatetimeLike
|
|
19
|
+
from pystac_client.stac_api_io import StacApiIO
|
|
20
|
+
from rasterio import transform, warp, windows
|
|
21
|
+
from rasterio.crs import CRS
|
|
22
|
+
from rasterio.enums import Resampling
|
|
23
|
+
from shapely.geometry import Polygon, box
|
|
24
|
+
from urllib3 import Retry
|
|
25
|
+
|
|
26
|
+
from .cloud_mask import compute_cloud_mask, load_cloudsen_model, S2_cloud_prob_bands, S2_cloud_mask_band
|
|
27
|
+
from .snow_mask import compute_potential_snow_layer, S2_snow_mask_band
|
|
28
|
+
from .utils import bounds_from_transform_height_width_res, transform_height_width_from_bounds_res
|
|
29
|
+
from .const import S2_RAW_BANDS, S2_RAW_BAND_RESOLUTION, S2_subtile_size
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def recrop_write_window(win, overall_height, overall_width):
|
|
33
|
+
""" Determine write window based on overlap with actual bounds and also
|
|
34
|
+
return how the array that will be written needs to be cropped. """
|
|
35
|
+
|
|
36
|
+
# global
|
|
37
|
+
grow = win.row_off
|
|
38
|
+
gcol = win.col_off
|
|
39
|
+
gwidth = win.width
|
|
40
|
+
gheight = win.height
|
|
41
|
+
|
|
42
|
+
# local
|
|
43
|
+
lrow = 0
|
|
44
|
+
lcol = 0
|
|
45
|
+
lwidth = win.width
|
|
46
|
+
lheight = win.height
|
|
47
|
+
|
|
48
|
+
# if overlapping to the left
|
|
49
|
+
if gcol < 0:
|
|
50
|
+
lcol = abs(gcol)
|
|
51
|
+
gwidth -= abs(gcol)
|
|
52
|
+
lwidth -= abs(gcol)
|
|
53
|
+
gcol = 0
|
|
54
|
+
|
|
55
|
+
# if overlapping on the bottom
|
|
56
|
+
if grow < 0:
|
|
57
|
+
lrow = abs(grow)
|
|
58
|
+
gheight -= abs(grow)
|
|
59
|
+
lheight -= abs(grow)
|
|
60
|
+
grow = 0
|
|
61
|
+
|
|
62
|
+
# if overlapping to the right
|
|
63
|
+
if overall_width < (gcol + gwidth):
|
|
64
|
+
difwidth = (gcol + gwidth) - overall_width
|
|
65
|
+
gwidth -= difwidth
|
|
66
|
+
lwidth -= difwidth
|
|
67
|
+
|
|
68
|
+
# if overlapping to the top
|
|
69
|
+
if overall_height < (grow + gheight):
|
|
70
|
+
difheight = (grow + gheight) - overall_height
|
|
71
|
+
gheight -= difheight
|
|
72
|
+
lheight -= difheight
|
|
73
|
+
|
|
74
|
+
assert gcol >= 0
|
|
75
|
+
assert grow >= 0
|
|
76
|
+
assert gwidth <= win.width
|
|
77
|
+
assert gheight <= win.height
|
|
78
|
+
assert overall_height >= (grow + gheight)
|
|
79
|
+
assert overall_width >= (gcol + gwidth)
|
|
80
|
+
assert lcol >= 0
|
|
81
|
+
assert lrow >= 0
|
|
82
|
+
assert lwidth <= win.width
|
|
83
|
+
assert lheight <= win.height
|
|
84
|
+
assert lwidth == gwidth
|
|
85
|
+
assert lheight == gheight
|
|
86
|
+
assert all(
|
|
87
|
+
x % 1 == 0
|
|
88
|
+
for x in [grow, gcol, gwidth, gheight, lrow, lcol, lwidth, lheight])
|
|
89
|
+
|
|
90
|
+
return windows.Window(
|
|
91
|
+
row_off=grow, col_off=gcol, height=gheight,
|
|
92
|
+
width=gwidth).round_offsets().round_lengths(), windows.Window(
|
|
93
|
+
row_off=lrow, col_off=lcol, height=lheight,
|
|
94
|
+
width=lwidth).round_offsets().round_lengths()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def obtain_subtiles(target_crs: CRS, left: float, bottom: float, right: float,
|
|
98
|
+
top: float):
|
|
99
|
+
"""Retrieves the sentinel subtiles that intersect the with the specified
|
|
100
|
+
bounds. The bounds are interpreted based on the given target_crs.
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
# TODO make it possible to not only use naive bounds but also MultiPolygons
|
|
104
|
+
|
|
105
|
+
# check if supplied sub_tile_width makes sense
|
|
106
|
+
assert (S2_subtile_size >= 16) and (
|
|
107
|
+
S2_subtile_size
|
|
108
|
+
<= 10980), "S2_subtile_size needs to within 16 and 10980"
|
|
109
|
+
assert (
|
|
110
|
+
10980 %
|
|
111
|
+
S2_subtile_size) == 0, "S2_subtile_size needs to be a divisor of 10980"
|
|
112
|
+
|
|
113
|
+
# load sentinel grid
|
|
114
|
+
s2grid = Variable("s2gridfile").get()
|
|
115
|
+
assert s2grid.crs == "EPSG:4326"
|
|
116
|
+
|
|
117
|
+
# convert bounds to sentinel grid crs
|
|
118
|
+
transformed_bounds = Polygon(*warp.transform_geom(
|
|
119
|
+
src_crs=target_crs,
|
|
120
|
+
dst_crs=s2grid.crs,
|
|
121
|
+
geom=box(left, bottom, right, top))["coordinates"])
|
|
122
|
+
|
|
123
|
+
# extract overlapping sentinel tiles
|
|
124
|
+
s2grid = s2grid[s2grid["geometry"].intersects(transformed_bounds)]
|
|
125
|
+
|
|
126
|
+
general_subtile_windows = [
|
|
127
|
+
windows.Window(col_off, row_off, S2_subtile_size, S2_subtile_size)
|
|
128
|
+
for col_off, row_off in itertools.product(
|
|
129
|
+
np.arange(0, 10980, S2_subtile_size),
|
|
130
|
+
np.arange(0, 10980, S2_subtile_size))
|
|
131
|
+
]
|
|
132
|
+
|
|
133
|
+
# reproject s2 footprint to local utm footprint
|
|
134
|
+
s2grid["s2_footprint_utm"] = s2grid[[
|
|
135
|
+
"geometry", "crs"
|
|
136
|
+
]].apply(lambda ser: Polygon(*warp.transform_geom(
|
|
137
|
+
src_crs=s2grid.crs, dst_crs=ser["crs"], geom=ser["geometry"].geoms[0])[
|
|
138
|
+
"coordinates"]),
|
|
139
|
+
axis=1)
|
|
140
|
+
|
|
141
|
+
# obtain transform of each sentinel 2 tile in local utm crs
|
|
142
|
+
s2grid["tile_transform"] = s2grid["s2_footprint_utm"].apply(
|
|
143
|
+
lambda x: transform.from_bounds(*x.bounds, width=10980, height=10980))
|
|
144
|
+
|
|
145
|
+
# convert read window to polygon in S2 local CRS, then transform to
|
|
146
|
+
# s2grid.crs and check overlap with transformed bounds
|
|
147
|
+
s2grid["intersecting_windows"] = s2grid[["tile_transform", "crs"]].apply(
|
|
148
|
+
lambda ser: [
|
|
149
|
+
win_subtile for win_subtile in general_subtile_windows
|
|
150
|
+
if transformed_bounds.intersects(
|
|
151
|
+
Polygon(*warp.transform_geom(
|
|
152
|
+
src_crs=ser["crs"],
|
|
153
|
+
dst_crs=s2grid.crs,
|
|
154
|
+
geom=box(*windows.bounds(win_subtile, ser["tile_transform"]
|
|
155
|
+
)))["coordinates"]))
|
|
156
|
+
],
|
|
157
|
+
axis=1)
|
|
158
|
+
|
|
159
|
+
# each line contains one subtile of a sentinel that we want to download and
|
|
160
|
+
# process because it intersects the specified bounds to download
|
|
161
|
+
s2grid = s2grid.explode("intersecting_windows")
|
|
162
|
+
|
|
163
|
+
return s2grid
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def get_stac_api_io():
|
|
167
|
+
retry = Retry(total=5,
|
|
168
|
+
backoff_factor=1,
|
|
169
|
+
status_forcelist=[502, 503, 504],
|
|
170
|
+
allowed_methods=None)
|
|
171
|
+
return StacApiIO(max_retries=retry)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def calculate_aligned_transform(src_crs, dst_crs, height, width, left, bottom,
|
|
175
|
+
right, top, tres):
|
|
176
|
+
tf, repr_width, repr_height = warp.calculate_default_transform(
|
|
177
|
+
src_crs=src_crs,
|
|
178
|
+
dst_crs=dst_crs,
|
|
179
|
+
width=width,
|
|
180
|
+
height=height,
|
|
181
|
+
left=left,
|
|
182
|
+
bottom=bottom,
|
|
183
|
+
right=right,
|
|
184
|
+
top=top,
|
|
185
|
+
resolution=tres)
|
|
186
|
+
|
|
187
|
+
tf = Affine(
|
|
188
|
+
tf.a,
|
|
189
|
+
tf.b,
|
|
190
|
+
tf.c - (tf.c % tres),
|
|
191
|
+
tf.d,
|
|
192
|
+
tf.e,
|
|
193
|
+
# + target_resolution because upper left corner
|
|
194
|
+
tf.f - (tf.f % tres) + tres,
|
|
195
|
+
tf.g,
|
|
196
|
+
tf.h,
|
|
197
|
+
tf.i,
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
# include one more pixel because rounding down
|
|
201
|
+
repr_width += 1
|
|
202
|
+
repr_height += 1
|
|
203
|
+
|
|
204
|
+
return tf, repr_height, repr_width
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def process_S2_subtile(intersecting_windows, stac_item, timestamp,
|
|
208
|
+
target_crs: CRS, target_resolution: float,
|
|
209
|
+
ptile_transform, ptile_width: int, ptile_height: int,
|
|
210
|
+
S2_mask_snow: bool, S2_cloud_classification: bool,
|
|
211
|
+
S2_compute_nbar: bool,
|
|
212
|
+
S2_cloud_classification_device: str, cloud_mask_model):
|
|
213
|
+
|
|
214
|
+
# init array that needs to be filled
|
|
215
|
+
subtile_array = np.empty(
|
|
216
|
+
(len(S2_RAW_BANDS), S2_subtile_size, S2_subtile_size),
|
|
217
|
+
dtype=np.float32)
|
|
218
|
+
band_names = S2_RAW_BANDS.copy()
|
|
219
|
+
|
|
220
|
+
# save CRS of downloaded sentinel tiles
|
|
221
|
+
s2_crs = None
|
|
222
|
+
# save transformation of sentinel tile for later processing
|
|
223
|
+
s2_tile_transform = None
|
|
224
|
+
# retrieve each band for subtile in sentinel tile
|
|
225
|
+
for i, band in enumerate(S2_RAW_BANDS):
|
|
226
|
+
href = stac_item.assets[band].href
|
|
227
|
+
with rasterio.open(href) as dr:
|
|
228
|
+
|
|
229
|
+
# convert read window respective to tile resolution
|
|
230
|
+
# (lower resolution -> fewer pixels for same area)
|
|
231
|
+
factor = S2_RAW_BAND_RESOLUTION[band] // 10
|
|
232
|
+
orig_win = intersecting_windows
|
|
233
|
+
read_window = windows.Window(orig_win.col_off // factor,
|
|
234
|
+
orig_win.row_off // factor,
|
|
235
|
+
orig_win.width // factor,
|
|
236
|
+
orig_win.height // factor)
|
|
237
|
+
|
|
238
|
+
# read subtile and directly upsample to 10m resolution using
|
|
239
|
+
# nearest-neighbor (default)
|
|
240
|
+
read_data = dr.read(indexes=1,
|
|
241
|
+
window=read_window,
|
|
242
|
+
out_shape=(S2_subtile_size, S2_subtile_size),
|
|
243
|
+
out_dtype=np.float32)
|
|
244
|
+
|
|
245
|
+
# harmonization
|
|
246
|
+
if float(stac_item.properties["s2:processing_baseline"]) >= 4.0:
|
|
247
|
+
# adjust reflectance for non-zero values
|
|
248
|
+
read_data[read_data != 0] -= 1000
|
|
249
|
+
|
|
250
|
+
# save
|
|
251
|
+
subtile_array[i] = read_data
|
|
252
|
+
|
|
253
|
+
# save and validate epsg
|
|
254
|
+
assert (s2_crs is None) or (
|
|
255
|
+
s2_crs == dr.crs), "CRS mismatch within one sentinel tile"
|
|
256
|
+
s2_crs = dr.crs
|
|
257
|
+
|
|
258
|
+
# save transform for a 10m band tile
|
|
259
|
+
if band == "B02":
|
|
260
|
+
s2_tile_transform = dr.transform
|
|
261
|
+
|
|
262
|
+
# determine bounds based on subtile window and tile transform
|
|
263
|
+
subtile_bounds_utm = windows.bounds(intersecting_windows,
|
|
264
|
+
s2_tile_transform)
|
|
265
|
+
assert (
|
|
266
|
+
subtile_bounds_utm[2] - subtile_bounds_utm[0]
|
|
267
|
+
) // 10 == S2_subtile_size, "mismatch between subtile size and bounds on x-axis"
|
|
268
|
+
assert (
|
|
269
|
+
subtile_bounds_utm[3] - subtile_bounds_utm[1]
|
|
270
|
+
) // 10 == S2_subtile_size, "mismatch between subtile size and bounds on y-axis"
|
|
271
|
+
|
|
272
|
+
if S2_cloud_classification:
|
|
273
|
+
result_probs = compute_cloud_mask(
|
|
274
|
+
subtile_array,
|
|
275
|
+
cloud_mask_model,
|
|
276
|
+
S2_cloud_classification_device=S2_cloud_classification_device)
|
|
277
|
+
band_names += S2_cloud_prob_bands
|
|
278
|
+
subtile_array = np.concatenate([subtile_array, result_probs])
|
|
279
|
+
|
|
280
|
+
# 3 reproject to target_crs for each band
|
|
281
|
+
# determine transform --> round to target resolution so that reprojected
|
|
282
|
+
# subtiles align across subtiles
|
|
283
|
+
subtile_repr_transform, subtile_repr_height, subtile_repr_width = calculate_aligned_transform(
|
|
284
|
+
src_crs=s2_crs,
|
|
285
|
+
dst_crs=target_crs,
|
|
286
|
+
width=subtile_array.shape[1],
|
|
287
|
+
height=subtile_array.shape[0],
|
|
288
|
+
left=subtile_bounds_utm[0],
|
|
289
|
+
bottom=subtile_bounds_utm[1],
|
|
290
|
+
right=subtile_bounds_utm[2],
|
|
291
|
+
top=subtile_bounds_utm[3],
|
|
292
|
+
tres=target_resolution)
|
|
293
|
+
|
|
294
|
+
# billinear reprojection for everything
|
|
295
|
+
subtile_array_repr = np.empty(
|
|
296
|
+
(len(band_names), subtile_repr_height, subtile_repr_width),
|
|
297
|
+
dtype=np.float32)
|
|
298
|
+
warp.reproject(source=subtile_array,
|
|
299
|
+
destination=subtile_array_repr,
|
|
300
|
+
src_transform=transform.from_bounds(*subtile_bounds_utm,
|
|
301
|
+
width=S2_subtile_size,
|
|
302
|
+
height=S2_subtile_size),
|
|
303
|
+
src_crs=s2_crs,
|
|
304
|
+
dst_crs=target_crs,
|
|
305
|
+
src_nodata=0,
|
|
306
|
+
dst_nodata=0,
|
|
307
|
+
dst_transform=subtile_repr_transform,
|
|
308
|
+
resampling=Resampling.bilinear)
|
|
309
|
+
# explicit clear
|
|
310
|
+
del subtile_array
|
|
311
|
+
|
|
312
|
+
# compute bounds in target crs based on rounded transform
|
|
313
|
+
subtile_bounds_tcrs = bounds_from_transform_height_width_res(
|
|
314
|
+
tf=subtile_repr_transform,
|
|
315
|
+
height=subtile_repr_height,
|
|
316
|
+
width=subtile_repr_width,
|
|
317
|
+
resolution=target_resolution)
|
|
318
|
+
|
|
319
|
+
# figure out where to write the subtile within the overall bounds
|
|
320
|
+
write_win = windows.from_bounds(
|
|
321
|
+
*subtile_bounds_tcrs,
|
|
322
|
+
transform=ptile_transform).round_offsets().round_lengths()
|
|
323
|
+
|
|
324
|
+
write_win, local_win = recrop_write_window(write_win, ptile_height,
|
|
325
|
+
ptile_width)
|
|
326
|
+
|
|
327
|
+
# crop subtile_array based on computed local win because it could overlap
|
|
328
|
+
# with the overall bounds
|
|
329
|
+
subtile_array_repr = subtile_array_repr[:, local_win.
|
|
330
|
+
row_off:local_win.height +
|
|
331
|
+
local_win.row_off, local_win.
|
|
332
|
+
col_off:local_win.col_off +
|
|
333
|
+
local_win.width]
|
|
334
|
+
|
|
335
|
+
return subtile_array_repr, write_win, band_names
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def height_width_from_bounds_res(left, bottom, right, top, res):
|
|
339
|
+
# determine width and height based on bounds and resolution
|
|
340
|
+
width, w_rem = divmod(abs(right - left), res)
|
|
341
|
+
assert w_rem == 0
|
|
342
|
+
height, h_rem = divmod(abs(top - bottom), res)
|
|
343
|
+
assert h_rem == 0
|
|
344
|
+
return height, width
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def open_catalog():
|
|
348
|
+
return pystac_client.Client.open(Variable("stac_endpoint").get(),
|
|
349
|
+
modifier=planetary_computer.sign_inplace,
|
|
350
|
+
stac_io=get_stac_api_io())
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def process_ptile(
|
|
354
|
+
da: xr.DataArray,
|
|
355
|
+
target_crs: CRS,
|
|
356
|
+
target_resolution: float,
|
|
357
|
+
S2_cloud_classification_device: str,
|
|
358
|
+
time_composite_freq: str,
|
|
359
|
+
S2_apply_snow_mask: bool,
|
|
360
|
+
S2_apply_cloud_mask: bool,
|
|
361
|
+
S2_mask_snow: bool = False,
|
|
362
|
+
S2_cloud_classification: bool = False,
|
|
363
|
+
S2_return_cloud_probabilities: bool = False,
|
|
364
|
+
S2_compute_nbar: bool = False,
|
|
365
|
+
):
|
|
366
|
+
"""Passing chunk to either sentinel-1 or sentinel-2 processor"""
|
|
367
|
+
|
|
368
|
+
# TODO add assert to mutually exclude chunks where both s1 and s2 bands are present
|
|
369
|
+
if ("vv" in da.band.data or "vh" in da.band.data):
|
|
370
|
+
return process_ptile_S1(da=da,
|
|
371
|
+
target_crs=target_crs,
|
|
372
|
+
target_resolution=target_resolution,
|
|
373
|
+
time_composite_freq=time_composite_freq)
|
|
374
|
+
else:
|
|
375
|
+
return process_ptile_S2_dispatcher(
|
|
376
|
+
da=da,
|
|
377
|
+
target_crs=target_crs,
|
|
378
|
+
target_resolution=target_resolution,
|
|
379
|
+
S2_cloud_classification=S2_cloud_classification,
|
|
380
|
+
S2_cloud_classification_device=S2_cloud_classification_device,
|
|
381
|
+
S2_mask_snow=S2_mask_snow,
|
|
382
|
+
S2_return_cloud_probabilities=S2_return_cloud_probabilities,
|
|
383
|
+
S2_compute_nbar=S2_compute_nbar,
|
|
384
|
+
time_composite_freq=time_composite_freq,
|
|
385
|
+
S2_apply_snow_mask=S2_apply_snow_mask,
|
|
386
|
+
S2_apply_cloud_mask=S2_apply_cloud_mask)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def process_ptile_S1(da: xr.DataArray, target_crs: CRS,
|
|
390
|
+
target_resolution: float, time_composite_freq: str):
|
|
391
|
+
|
|
392
|
+
# compute bounds of ptile
|
|
393
|
+
ptile_bounds = bounds_from_dataarray(da, target_resolution)
|
|
394
|
+
|
|
395
|
+
# timestamp
|
|
396
|
+
if time_composite_freq is None:
|
|
397
|
+
datetime_range = da.time.data[0]
|
|
398
|
+
else:
|
|
399
|
+
timestamp_center = da.time.data[0]
|
|
400
|
+
datetime_range = [
|
|
401
|
+
timestamp_center - (pd.Timedelta(time_composite_freq) / 2),
|
|
402
|
+
timestamp_center + (pd.Timedelta(time_composite_freq) / 2)
|
|
403
|
+
]
|
|
404
|
+
|
|
405
|
+
# open stac catalog
|
|
406
|
+
catalog = open_catalog()
|
|
407
|
+
|
|
408
|
+
# retrieve items (possible across multiple sentinel tile) for specified
|
|
409
|
+
# timestamp
|
|
410
|
+
item_list = list(
|
|
411
|
+
catalog.search(collections=["sentinel-1-rtc"],
|
|
412
|
+
datetime=datetime_range,
|
|
413
|
+
bbox=warp.transform_bounds(
|
|
414
|
+
src_crs=target_crs,
|
|
415
|
+
dst_crs="EPSG:4326",
|
|
416
|
+
left=ptile_bounds[0],
|
|
417
|
+
bottom=ptile_bounds[1],
|
|
418
|
+
right=ptile_bounds[2],
|
|
419
|
+
top=ptile_bounds[3])).item_collection())
|
|
420
|
+
|
|
421
|
+
if len(item_list) == 0:
|
|
422
|
+
# if there is nothing within the bounds and for that timestamp return.
|
|
423
|
+
# possible and normal
|
|
424
|
+
return da
|
|
425
|
+
|
|
426
|
+
# intiate one array representing the entire subtile for that timestamp
|
|
427
|
+
tile_array = np.full(shape=(da.shape[1], da.shape[2], da.shape[3]),
|
|
428
|
+
fill_value=0,
|
|
429
|
+
dtype=np.float32)
|
|
430
|
+
|
|
431
|
+
# count how many values we add per pixel to compute mean later
|
|
432
|
+
tile_array_count = np.full(shape=(da.shape[1], da.shape[2], da.shape[3]),
|
|
433
|
+
fill_value=0,
|
|
434
|
+
dtype=np.uint8)
|
|
435
|
+
|
|
436
|
+
# determine ptile dimensions and transform from bounds
|
|
437
|
+
ptile_transform, ptile_height, ptile_width = transform_height_width_from_bounds_res(
|
|
438
|
+
*ptile_bounds, target_resolution)
|
|
439
|
+
|
|
440
|
+
for item in item_list:
|
|
441
|
+
# iterate through S1 assets
|
|
442
|
+
for i, s1_asset in enumerate(da.band.data):
|
|
443
|
+
with rasterio.open(item.assets[s1_asset].href) as dr:
|
|
444
|
+
# reproject ptile bounds to S1 tile CRS
|
|
445
|
+
ptile_bounds_local_crs = warp.transform_bounds(
|
|
446
|
+
target_crs, dr.crs, *ptile_bounds)
|
|
447
|
+
# figure out which area of the image is interesting for us
|
|
448
|
+
read_win = dr.window(*ptile_bounds_local_crs)
|
|
449
|
+
# read windowed
|
|
450
|
+
data = dr.read(indexes=1,
|
|
451
|
+
window=read_win,
|
|
452
|
+
out_dtype=np.float32)
|
|
453
|
+
|
|
454
|
+
# compute aligned reprojection
|
|
455
|
+
tile_repr_transform, tile_repr_height, tile_repr_width = calculate_aligned_transform(
|
|
456
|
+
dr.crs, target_crs, data.shape[0], data.shape[1],
|
|
457
|
+
*ptile_bounds_local_crs, target_resolution)
|
|
458
|
+
|
|
459
|
+
data_repr = np.empty((tile_repr_height, tile_repr_width),
|
|
460
|
+
dtype=np.float32)
|
|
461
|
+
|
|
462
|
+
# billinear reprojection for everything
|
|
463
|
+
warp.reproject(source=data,
|
|
464
|
+
destination=data_repr,
|
|
465
|
+
src_transform=transform.from_bounds(
|
|
466
|
+
*ptile_bounds_local_crs,
|
|
467
|
+
height=read_win.height,
|
|
468
|
+
width=read_win.width),
|
|
469
|
+
src_crs=dr.crs,
|
|
470
|
+
dst_crs=target_crs,
|
|
471
|
+
src_nodata=dr.nodata,
|
|
472
|
+
dst_nodata=0,
|
|
473
|
+
dst_transform=tile_repr_transform,
|
|
474
|
+
resampling=Resampling.bilinear)
|
|
475
|
+
|
|
476
|
+
# explicit clear
|
|
477
|
+
del data
|
|
478
|
+
|
|
479
|
+
# compute bounds of reprojected tile in target crs
|
|
480
|
+
# this will have nans and so on
|
|
481
|
+
tile_bounds_trcs = bounds_from_transform_height_width_res(
|
|
482
|
+
tf=tile_repr_transform,
|
|
483
|
+
height=tile_repr_height,
|
|
484
|
+
width=tile_repr_width,
|
|
485
|
+
resolution=target_resolution)
|
|
486
|
+
|
|
487
|
+
# figure out where to write the subtile within the overall bounds
|
|
488
|
+
write_win = windows.from_bounds(
|
|
489
|
+
*tile_bounds_trcs,
|
|
490
|
+
transform=ptile_transform).round_offsets().round_lengths()
|
|
491
|
+
|
|
492
|
+
# determine crop to avoid out of bounds
|
|
493
|
+
write_win, local_win = recrop_write_window(
|
|
494
|
+
write_win, ptile_height, ptile_width)
|
|
495
|
+
|
|
496
|
+
# crop reprojected downlaoded data
|
|
497
|
+
data_repr = data_repr[local_win.row_off:local_win.height +
|
|
498
|
+
local_win.row_off,
|
|
499
|
+
local_win.col_off:local_win.col_off +
|
|
500
|
+
local_win.width]
|
|
501
|
+
|
|
502
|
+
# save it
|
|
503
|
+
tile_array[i, write_win.row_off:write_win.row_off +
|
|
504
|
+
write_win.height,
|
|
505
|
+
write_win.col_off:write_win.col_off +
|
|
506
|
+
write_win.width] += data_repr
|
|
507
|
+
|
|
508
|
+
# save where we have NaNs
|
|
509
|
+
tile_array_count[i, write_win.row_off:write_win.row_off +
|
|
510
|
+
write_win.height,
|
|
511
|
+
write_win.col_off:write_win.col_off +
|
|
512
|
+
write_win.width] += ~(data_repr == 0)
|
|
513
|
+
|
|
514
|
+
with warnings.catch_warnings():
|
|
515
|
+
# filter out divide by zero warning, this is expected here
|
|
516
|
+
warnings.simplefilter("ignore")
|
|
517
|
+
tile_array /= tile_array_count
|
|
518
|
+
|
|
519
|
+
# replace zeros with nans
|
|
520
|
+
tile_array[tile_array == 0] = np.nan
|
|
521
|
+
|
|
522
|
+
return xr.DataArray(data=np.expand_dims(tile_array, axis=0),
|
|
523
|
+
dims=["time", "band", "y", "x"],
|
|
524
|
+
coords=dict(time=[da.time.data[0]],
|
|
525
|
+
band=da.band,
|
|
526
|
+
x=da.x,
|
|
527
|
+
y=da.y))
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def bounds_from_dataarray(da, target_resolution):
|
|
531
|
+
# (add target resolution to miny and maxx because we are using top-left
|
|
532
|
+
# coordinates)
|
|
533
|
+
bound_left = da.x.min().item()
|
|
534
|
+
bound_bottom = da.y.min().item() - target_resolution
|
|
535
|
+
bound_right = da.x.max().item() + target_resolution
|
|
536
|
+
bound_top = da.y.max().item()
|
|
537
|
+
|
|
538
|
+
return (bound_left, bound_bottom, bound_right, bound_top)
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def process_ptile_S2_dispatcher(
|
|
542
|
+
da: xr.DataArray,
|
|
543
|
+
target_crs: CRS,
|
|
544
|
+
target_resolution: float,
|
|
545
|
+
S2_cloud_classification_device: str,
|
|
546
|
+
time_composite_freq: str,
|
|
547
|
+
S2_apply_snow_mask: bool,
|
|
548
|
+
S2_apply_cloud_mask: bool,
|
|
549
|
+
S2_mask_snow: bool = False,
|
|
550
|
+
S2_cloud_classification: bool = False,
|
|
551
|
+
S2_return_cloud_probabilities: bool = False,
|
|
552
|
+
S2_compute_nbar: bool = False,
|
|
553
|
+
):
|
|
554
|
+
|
|
555
|
+
# compute bounds of ptile
|
|
556
|
+
bound_left, bound_bottom, bound_right, bound_top = bounds_from_dataarray(
|
|
557
|
+
da, target_resolution)
|
|
558
|
+
|
|
559
|
+
# open stac catalog
|
|
560
|
+
catalog = open_catalog()
|
|
561
|
+
|
|
562
|
+
# obtain sub-sentinel2 tiles based on supplied bounds and CRS
|
|
563
|
+
subtiles = obtain_subtiles(target_crs, bound_left, bound_bottom,
|
|
564
|
+
bound_right, bound_top)
|
|
565
|
+
|
|
566
|
+
# determine ptile dimensions and transform from bounds
|
|
567
|
+
ptile_transform, ptile_height, ptile_width = transform_height_width_from_bounds_res(
|
|
568
|
+
bound_left, bound_bottom, bound_right, bound_top, target_resolution)
|
|
569
|
+
|
|
570
|
+
# timestamp
|
|
571
|
+
if time_composite_freq is None:
|
|
572
|
+
datetime_range = da.time.data[0]
|
|
573
|
+
else:
|
|
574
|
+
timestamp_center = da.time.data[0]
|
|
575
|
+
datetime_range = [
|
|
576
|
+
timestamp_center - (pd.Timedelta(time_composite_freq) / 2),
|
|
577
|
+
timestamp_center + (pd.Timedelta(time_composite_freq) / 2)
|
|
578
|
+
]
|
|
579
|
+
|
|
580
|
+
# retrieve items (possible across multiple sentinel tile) for specified
|
|
581
|
+
# timestamp
|
|
582
|
+
item_list = list(
|
|
583
|
+
catalog.search(collections=["sentinel-2-l2a"],
|
|
584
|
+
datetime=datetime_range,
|
|
585
|
+
bbox=warp.transform_bounds(
|
|
586
|
+
src_crs=target_crs,
|
|
587
|
+
dst_crs="EPSG:4326",
|
|
588
|
+
left=bound_left,
|
|
589
|
+
bottom=bound_bottom,
|
|
590
|
+
right=bound_right,
|
|
591
|
+
top=bound_top)).item_collection())
|
|
592
|
+
|
|
593
|
+
if len(item_list) == 0:
|
|
594
|
+
return da
|
|
595
|
+
|
|
596
|
+
items = pd.DataFrame()
|
|
597
|
+
items["item"] = item_list
|
|
598
|
+
items["tile"] = items["item"].apply(lambda x: x.properties["s2:mgrs_tile"])
|
|
599
|
+
items["ts"] = items["item"].apply(lambda x: x.datetime)
|
|
600
|
+
|
|
601
|
+
# load cloudsen model
|
|
602
|
+
cloudsen_model = load_cloudsen_model(
|
|
603
|
+
S2_cloud_classification_device) if S2_cloud_classification else None
|
|
604
|
+
|
|
605
|
+
# intiate one array representing the entire subtile for that timestamp
|
|
606
|
+
num_bands = da.shape[1]
|
|
607
|
+
|
|
608
|
+
ptile_array = np.full(shape=(num_bands, da.shape[2], da.shape[3]),
|
|
609
|
+
fill_value=0,
|
|
610
|
+
dtype=np.float32)
|
|
611
|
+
|
|
612
|
+
# count how many values we add per pixel to compute mean later
|
|
613
|
+
ptile_array_count = np.full(shape=(num_bands, da.shape[2], da.shape[3]),
|
|
614
|
+
fill_value=0,
|
|
615
|
+
dtype=np.uint8)
|
|
616
|
+
|
|
617
|
+
ptile_array_bands = None
|
|
618
|
+
timestamps_it = items["ts"].drop_duplicates().tolist()
|
|
619
|
+
assert (len(timestamps_it) == 1 and time_composite_freq is None) or (
|
|
620
|
+
len(timestamps_it) >= 1 and time_composite_freq is not None)
|
|
621
|
+
for ts in timestamps_it:
|
|
622
|
+
ptile_timestamp, ptile_array_bands = process_ptile_S2(
|
|
623
|
+
timestamp=ts,
|
|
624
|
+
target_crs=target_crs,
|
|
625
|
+
target_resolution=target_resolution,
|
|
626
|
+
S2_cloud_classification=S2_cloud_classification,
|
|
627
|
+
S2_cloud_classification_device=S2_cloud_classification_device,
|
|
628
|
+
S2_mask_snow=S2_mask_snow,
|
|
629
|
+
S2_return_cloud_probabilities=S2_return_cloud_probabilities,
|
|
630
|
+
S2_compute_nbar=S2_compute_nbar,
|
|
631
|
+
subtiles=subtiles,
|
|
632
|
+
catalog=catalog,
|
|
633
|
+
ptile_transform=ptile_transform,
|
|
634
|
+
ptile_width=ptile_width,
|
|
635
|
+
ptile_height=ptile_height,
|
|
636
|
+
cloudsen_model=cloudsen_model,
|
|
637
|
+
items=items[items["ts"] == ts])
|
|
638
|
+
|
|
639
|
+
# apply masks and drop classification layers if doing temporal aggregation
|
|
640
|
+
if S2_apply_snow_mask:
|
|
641
|
+
snow_index = ptile_array_bands.index(S2_snow_mask_band)
|
|
642
|
+
ptile_timestamp *= ptile_timestamp[snow_index]
|
|
643
|
+
|
|
644
|
+
if time_composite_freq is not None:
|
|
645
|
+
ptile_timestamp = np.delete(ptile_timestamp,
|
|
646
|
+
snow_index,
|
|
647
|
+
axis=0)
|
|
648
|
+
|
|
649
|
+
if S2_apply_cloud_mask:
|
|
650
|
+
cloud_index = ptile_array_bands.index(S2_cloud_mask_band)
|
|
651
|
+
ptile_timestamp *= (ptile_timestamp[cloud_index] == 0)
|
|
652
|
+
|
|
653
|
+
if time_composite_freq is not None:
|
|
654
|
+
ptile_timestamp = np.delete(ptile_timestamp,
|
|
655
|
+
cloud_index,
|
|
656
|
+
axis=0)
|
|
657
|
+
|
|
658
|
+
# save new data
|
|
659
|
+
ptile_array += ptile_timestamp
|
|
660
|
+
|
|
661
|
+
# count where we added data
|
|
662
|
+
ptile_array_count += ptile_timestamp != 0
|
|
663
|
+
|
|
664
|
+
if time_composite_freq is not None:
|
|
665
|
+
if S2_snow_mask_band in ptile_array_bands:
|
|
666
|
+
ptile_array_bands.remove(S2_snow_mask_band)
|
|
667
|
+
if S2_cloud_mask_band in ptile_array_bands:
|
|
668
|
+
ptile_array_bands.remove(S2_cloud_mask_band)
|
|
669
|
+
|
|
670
|
+
# compute mean based on sum and count for each pixel
|
|
671
|
+
with warnings.catch_warnings():
|
|
672
|
+
# filter out divide by zero warning, this is expected here
|
|
673
|
+
warnings.simplefilter("ignore")
|
|
674
|
+
ptile_array /= ptile_array_count
|
|
675
|
+
|
|
676
|
+
# ... and set all such pixels to nan (of which some are already nan because
|
|
677
|
+
# of divide by zero)
|
|
678
|
+
# determine nodata mask based on where values are zero -> mean nodata for S2...
|
|
679
|
+
# (need to do this here, because after computing mean there will be nans
|
|
680
|
+
# from divide by zero)
|
|
681
|
+
ptile_array[:,
|
|
682
|
+
np.any(ptile_array[
|
|
683
|
+
[ptile_array_bands.index(band)
|
|
684
|
+
for band in S2_RAW_BANDS]] == 0,
|
|
685
|
+
axis=0)] = np.nan
|
|
686
|
+
|
|
687
|
+
# expand dimensions -> one timestep
|
|
688
|
+
ptile_array = np.expand_dims(ptile_array, axis=0)
|
|
689
|
+
|
|
690
|
+
# wrap numpy array into xarray again
|
|
691
|
+
out_array = xr.DataArray(data=ptile_array,
|
|
692
|
+
dims=["time", "band", "y", "x"],
|
|
693
|
+
coords=dict(time=[da.time.data[0]],
|
|
694
|
+
band=ptile_array_bands,
|
|
695
|
+
x=da.x,
|
|
696
|
+
y=da.y))
|
|
697
|
+
|
|
698
|
+
# only return bands that have been requested
|
|
699
|
+
return out_array.sel(band=da.band)
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
def process_ptile_S2(
|
|
703
|
+
timestamp,
|
|
704
|
+
target_crs: CRS,
|
|
705
|
+
target_resolution: float,
|
|
706
|
+
S2_cloud_classification_device: str,
|
|
707
|
+
subtiles,
|
|
708
|
+
catalog,
|
|
709
|
+
ptile_transform,
|
|
710
|
+
ptile_height,
|
|
711
|
+
ptile_width,
|
|
712
|
+
cloudsen_model,
|
|
713
|
+
items,
|
|
714
|
+
S2_mask_snow: bool = False,
|
|
715
|
+
S2_cloud_classification: bool = False,
|
|
716
|
+
S2_return_cloud_probabilities: bool = False,
|
|
717
|
+
S2_compute_nbar: bool = False,
|
|
718
|
+
):
|
|
719
|
+
# cloud classification layer and snow mask is added later
|
|
720
|
+
num_bands = len(S2_RAW_BANDS)
|
|
721
|
+
|
|
722
|
+
if S2_cloud_classification:
|
|
723
|
+
# we need the probs here, will remove when returning
|
|
724
|
+
num_bands += 4
|
|
725
|
+
|
|
726
|
+
# intiate one array representing the entire subtile for that timestamp
|
|
727
|
+
subtile_array = np.full(shape=(num_bands, ptile_height, ptile_width),
|
|
728
|
+
fill_value=0,
|
|
729
|
+
dtype=np.float32)
|
|
730
|
+
|
|
731
|
+
# count how many values we add per pixel to compute mean later
|
|
732
|
+
subtile_array_count = np.full(shape=(num_bands, ptile_height, ptile_width),
|
|
733
|
+
fill_value=0,
|
|
734
|
+
dtype=np.uint8)
|
|
735
|
+
|
|
736
|
+
subtile_array_bands = None
|
|
737
|
+
for st in subtiles.itertuples(index=False, name="subtile"):
|
|
738
|
+
# filter items by sentinel tile name
|
|
739
|
+
subdf = items[items["tile"] == st.name]
|
|
740
|
+
|
|
741
|
+
if subdf.empty:
|
|
742
|
+
# can happen with orbit edges, because we filter stac with bounds
|
|
743
|
+
continue
|
|
744
|
+
|
|
745
|
+
# NOTE it is possible that multiple items are returned for one
|
|
746
|
+
# timestamp and a sentinel tile. These are duplicates and a bug in
|
|
747
|
+
# sentinel2 repository
|
|
748
|
+
stac_item = subdf["item"].iloc[0]
|
|
749
|
+
|
|
750
|
+
subtile_array_ret, write_win, subtile_array_bands = process_S2_subtile(
|
|
751
|
+
intersecting_windows=st.intersecting_windows,
|
|
752
|
+
stac_item=stac_item,
|
|
753
|
+
timestamp=timestamp,
|
|
754
|
+
target_crs=target_crs,
|
|
755
|
+
target_resolution=target_resolution,
|
|
756
|
+
ptile_transform=ptile_transform,
|
|
757
|
+
ptile_width=ptile_width,
|
|
758
|
+
ptile_height=ptile_height,
|
|
759
|
+
S2_mask_snow=S2_mask_snow,
|
|
760
|
+
S2_cloud_classification=S2_cloud_classification,
|
|
761
|
+
S2_compute_nbar=S2_compute_nbar,
|
|
762
|
+
S2_cloud_classification_device=S2_cloud_classification_device,
|
|
763
|
+
cloud_mask_model=cloudsen_model)
|
|
764
|
+
|
|
765
|
+
# also replace nan with 0 so that the mean computation works
|
|
766
|
+
# (this is reverted later)
|
|
767
|
+
subtile_array[:,
|
|
768
|
+
write_win.row_off:write_win.row_off + write_win.height,
|
|
769
|
+
write_win.col_off:write_win.col_off +
|
|
770
|
+
write_win.width] += subtile_array_ret
|
|
771
|
+
|
|
772
|
+
subtile_array_count[:, write_win.row_off:write_win.row_off +
|
|
773
|
+
write_win.height,
|
|
774
|
+
write_win.col_off:write_win.col_off +
|
|
775
|
+
write_win.width] += ~(subtile_array_ret == 0)
|
|
776
|
+
|
|
777
|
+
with warnings.catch_warnings():
|
|
778
|
+
# filter out divide by zero warning, this is expected here
|
|
779
|
+
warnings.simplefilter("ignore")
|
|
780
|
+
subtile_array /= subtile_array_count
|
|
781
|
+
|
|
782
|
+
# compute cloud classification layer
|
|
783
|
+
if S2_cloud_classification:
|
|
784
|
+
|
|
785
|
+
cloud_prob_indices = [
|
|
786
|
+
subtile_array_bands.index(band) for band in S2_cloud_prob_bands
|
|
787
|
+
]
|
|
788
|
+
|
|
789
|
+
# select cloud class based on maximum probability
|
|
790
|
+
cloud_class = np.argmax(subtile_array[cloud_prob_indices],
|
|
791
|
+
axis=0,
|
|
792
|
+
keepdims=True)
|
|
793
|
+
|
|
794
|
+
# apply max filter on cloud clases to dilate invalid pixels
|
|
795
|
+
cloud_class = sc.maximum_filter(cloud_class,
|
|
796
|
+
size=(1, 7, 7),
|
|
797
|
+
mode="nearest").astype(np.float32)
|
|
798
|
+
|
|
799
|
+
# save cloud classes layer
|
|
800
|
+
subtile_array = np.concatenate([subtile_array, cloud_class], axis=0)
|
|
801
|
+
subtile_array_bands.append(S2_cloud_mask_band)
|
|
802
|
+
|
|
803
|
+
if not S2_return_cloud_probabilities:
|
|
804
|
+
subtile_array = np.delete(subtile_array,
|
|
805
|
+
cloud_prob_indices,
|
|
806
|
+
axis=0)
|
|
807
|
+
for band in S2_cloud_prob_bands:
|
|
808
|
+
del subtile_array_bands[subtile_array_bands.index(band)]
|
|
809
|
+
|
|
810
|
+
if S2_mask_snow:
|
|
811
|
+
subtile_array = np.concatenate([
|
|
812
|
+
subtile_array,
|
|
813
|
+
np.expand_dims(compute_potential_snow_layer(
|
|
814
|
+
B03=subtile_array[subtile_array_bands.index("B03")],
|
|
815
|
+
B11=subtile_array[subtile_array_bands.index("B11")],
|
|
816
|
+
B08=subtile_array[subtile_array_bands.index("B08")]),
|
|
817
|
+
axis=0)
|
|
818
|
+
])
|
|
819
|
+
subtile_array_bands.append(S2_snow_mask_band)
|
|
820
|
+
|
|
821
|
+
return subtile_array, subtile_array_bands
|
|
822
|
+
|
|
823
|
+
|
|
824
|
+
def check_and_round_bounds(left, bottom, right, top, res):
|
|
825
|
+
h_rem = abs(top - bottom) % res
|
|
826
|
+
if h_rem != 0:
|
|
827
|
+
warnings.warn(
|
|
828
|
+
"Specified top/bottom bounds are not perfectly divisable by specified target_resolution. The resulting coverage will be rounded up to the next pixel value."
|
|
829
|
+
)
|
|
830
|
+
top -= h_rem
|
|
831
|
+
|
|
832
|
+
w_rem = abs(right - left) % res
|
|
833
|
+
if w_rem != 0:
|
|
834
|
+
warnings.warn(
|
|
835
|
+
"Specified left/right bounds are not perfectly divisable by specified target_resolution. The resulting coverage will be rounded up to the next pixel value."
|
|
836
|
+
)
|
|
837
|
+
right -= w_rem
|
|
838
|
+
|
|
839
|
+
return left, bottom, right, top
|
|
840
|
+
|
|
841
|
+
|
|
842
|
+
def process(target_crs: CRS,
|
|
843
|
+
target_resolution: float,
|
|
844
|
+
bound_left: float,
|
|
845
|
+
bound_bottom: float,
|
|
846
|
+
bound_right: float,
|
|
847
|
+
bound_top: float,
|
|
848
|
+
datetime: DatetimeLike,
|
|
849
|
+
processing_spatial_chunk_size: int = 4000,
|
|
850
|
+
S1_assets: list[str] = ["vh", "vv"],
|
|
851
|
+
S2_mask_snow: bool = False,
|
|
852
|
+
S2_cloud_classification: bool = False,
|
|
853
|
+
S2_cloud_classification_device="cpu",
|
|
854
|
+
S2_return_cloud_probabilities: bool = False,
|
|
855
|
+
S2_compute_nbar: bool = False,
|
|
856
|
+
num_workers: int = 1,
|
|
857
|
+
threads_per_worker: int = 1,
|
|
858
|
+
memory_limit_per_worker: str = None,
|
|
859
|
+
dashboard_address: str = "127.0.0.1:9988",
|
|
860
|
+
time_composite_freq: str = None,
|
|
861
|
+
S2_apply_snow_mask: bool = False,
|
|
862
|
+
S2_apply_cloud_mask: bool = False):
|
|
863
|
+
"""
|
|
864
|
+
Parameters
|
|
865
|
+
----------
|
|
866
|
+
|
|
867
|
+
target_crs : CRS
|
|
868
|
+
Specifies the target CRS that all data will be reprojected to.
|
|
869
|
+
target_resolution : float
|
|
870
|
+
Determines the resolution that all data is reprojected to in the `target_crs`.
|
|
871
|
+
bound_left : float
|
|
872
|
+
Left bound of area that is supposed to be covered. Unit is in `target_crs`.
|
|
873
|
+
bound_bottom : float
|
|
874
|
+
Bottom bound of area that is supposed to be covered. Unit is in `target_crs`.
|
|
875
|
+
bound_right : float
|
|
876
|
+
Right bound of area that is supposed to be covered. Unit is in `target_crs`.
|
|
877
|
+
bound_top : float
|
|
878
|
+
Top bound of area that is supposed to be covered. Unit is in `target_crs`.
|
|
879
|
+
datetime : DatetimeLike
|
|
880
|
+
Specifies time range of data to be downloaded. This is forwarded to the respective stac interface.
|
|
881
|
+
S2_mask_snow : bool, default=False
|
|
882
|
+
Whether to create a snow mask. Based on https://doi.org/10.1016/j.rse.2011.10.028.
|
|
883
|
+
S2_cloud_classification : bool, default=False
|
|
884
|
+
Whether to create cloud classification layer, where `0=clear sky`, `2=thick cloud`, `3=thin cloud`, `4=shadow`.
|
|
885
|
+
S2_cloud_classification_device : str, default="cpu"
|
|
886
|
+
On which device to run cloud classification. Either `cpu` or `cuda`.
|
|
887
|
+
S2_return_cloud_probabilities : bool, default=False
|
|
888
|
+
Whether to return raw cloud probabilities which were used to determine the cloud classes.
|
|
889
|
+
S2_compute_nbar : bool, default=False
|
|
890
|
+
Whether to compute NBAR using the sen2nbar package. Coming soon.
|
|
891
|
+
num_workers : int, default=1
|
|
892
|
+
Number of cores to scale computation across. Plan 4GiB of RAM per worker.
|
|
893
|
+
threads_per_worker: int, default=1
|
|
894
|
+
Number threads to use for each worker. Anything >1 has not been tested.
|
|
895
|
+
memory_limit_per_worker: str, default=None
|
|
896
|
+
Maximum amount of RAM per worker, passed to dask `LocalCluster`. `None` means no limit and is recommended.
|
|
897
|
+
dashboard_address: str, default="127.0.0.1:9988"
|
|
898
|
+
Address where the dask dashboard can be accessed.
|
|
899
|
+
time_composite_freq: str, default=None
|
|
900
|
+
Rounding interval across which data is averaged.
|
|
901
|
+
S2_apply_snow_mask: bool, default=False
|
|
902
|
+
Whether to replace snow with NaN.
|
|
903
|
+
S2_apply_cloud_mask: bool, default=False
|
|
904
|
+
Whether to replace anything that is not clear sky with NaN.
|
|
905
|
+
"""
|
|
906
|
+
|
|
907
|
+
if threads_per_worker > 1:
|
|
908
|
+
warnings.warn(
|
|
909
|
+
"More then one thread per worker may overflow memory. Not tested yet"
|
|
910
|
+
)
|
|
911
|
+
|
|
912
|
+
if time_composite_freq is not None and (not S2_apply_snow_mask
|
|
913
|
+
and not S2_apply_cloud_mask):
|
|
914
|
+
warnings.warn(
|
|
915
|
+
"Temporal aggregation is specified, but neither cloud or snow mask is set to be applied. This may yield useless aggregations for Sentinel-2 data."
|
|
916
|
+
)
|
|
917
|
+
|
|
918
|
+
# setup local processor
|
|
919
|
+
cluster = LocalCluster(dashboard_address=dashboard_address,
|
|
920
|
+
n_workers=num_workers,
|
|
921
|
+
threads_per_worker=threads_per_worker,
|
|
922
|
+
memory_limit=memory_limit_per_worker)
|
|
923
|
+
client = Client(cluster)
|
|
924
|
+
print("Dask client dashboard link:", client.dashboard_link)
|
|
925
|
+
|
|
926
|
+
# load Sentinel 2 grid
|
|
927
|
+
Variable("s2gridfile").set(
|
|
928
|
+
gpd.read_file(
|
|
929
|
+
pkg_resources.resource_filename(
|
|
930
|
+
__name__, "data/sentinel2_grid_stripped_with_epsg.gpkg")))
|
|
931
|
+
|
|
932
|
+
# TODO support to only download subset of bands (mutually exclusive with cloud classification and partially snow_mask) -> or no sentinel 2 at all
|
|
933
|
+
|
|
934
|
+
# derive bands to save from arguments
|
|
935
|
+
bands_to_save = S2_RAW_BANDS.copy()
|
|
936
|
+
if S2_mask_snow and time_composite_freq is None:
|
|
937
|
+
bands_to_save.append(S2_snow_mask_band)
|
|
938
|
+
if S2_compute_nbar:
|
|
939
|
+
warnings.warn(
|
|
940
|
+
"NBAR computation currently not supported. Coming Soon. Ignoring..."
|
|
941
|
+
)
|
|
942
|
+
S2_compute_nbar = False
|
|
943
|
+
# bands_to_save += [
|
|
944
|
+
# "NBAR_B02",
|
|
945
|
+
# "NBAR_B03",
|
|
946
|
+
# "NBAR_B04",
|
|
947
|
+
# "NBAR_B05",
|
|
948
|
+
# "NBAR_B06",
|
|
949
|
+
# "NBAR_B07",
|
|
950
|
+
# "NBAR_B08",
|
|
951
|
+
# "NBAR_B11",
|
|
952
|
+
# "NBAR_B12",
|
|
953
|
+
# ]
|
|
954
|
+
if S2_cloud_classification and time_composite_freq is None:
|
|
955
|
+
bands_to_save.append(S2_cloud_mask_band)
|
|
956
|
+
if S2_return_cloud_probabilities:
|
|
957
|
+
bands_to_save += S2_cloud_prob_bands
|
|
958
|
+
if S1_assets is not None:
|
|
959
|
+
assert len(set(S1_assets) -
|
|
960
|
+
set(["vv", "vh"])) == 0, "Unsupported S1 bands."
|
|
961
|
+
bands_to_save += S1_assets
|
|
962
|
+
|
|
963
|
+
# sign into planetary computer
|
|
964
|
+
stac_endpoint = "https://planetarycomputer.microsoft.com/api/stac/v1"
|
|
965
|
+
Variable("stac_endpoint").set(stac_endpoint)
|
|
966
|
+
catalog = open_catalog()
|
|
967
|
+
|
|
968
|
+
# get all items within date range and area
|
|
969
|
+
collections = ["sentinel-2-l2a"]
|
|
970
|
+
if S1_assets is not None:
|
|
971
|
+
collections.append("sentinel-1-rtc")
|
|
972
|
+
search = catalog.search(collections=collections,
|
|
973
|
+
datetime=datetime,
|
|
974
|
+
bbox=warp.transform_bounds(src_crs=target_crs,
|
|
975
|
+
dst_crs="EPSG:4326",
|
|
976
|
+
left=bound_left,
|
|
977
|
+
bottom=bound_bottom,
|
|
978
|
+
right=bound_right,
|
|
979
|
+
top=bound_top))
|
|
980
|
+
|
|
981
|
+
# sort timesteps and filter duplicates -> multiple items can have the
|
|
982
|
+
# exact same timestamp
|
|
983
|
+
df = pd.DataFrame()
|
|
984
|
+
items = list(search.item_collection())
|
|
985
|
+
df["ts_raw"] = [i.datetime for i in items]
|
|
986
|
+
df["collection"] = [i.collection_id for i in items]
|
|
987
|
+
|
|
988
|
+
if time_composite_freq is not None:
|
|
989
|
+
df["ts"] = df["ts_raw"].dt.round(freq=time_composite_freq)
|
|
990
|
+
else:
|
|
991
|
+
df["ts"] = df["ts_raw"]
|
|
992
|
+
|
|
993
|
+
# remove duplicates for timeaxis
|
|
994
|
+
df = df.drop_duplicates("ts")
|
|
995
|
+
|
|
996
|
+
bound_left, bound_bottom, bound_right, bound_top = check_and_round_bounds(
|
|
997
|
+
bound_left, bound_bottom, bound_right, bound_top, target_resolution)
|
|
998
|
+
|
|
999
|
+
height, width = height_width_from_bounds_res(bound_left, bound_bottom,
|
|
1000
|
+
bound_right, bound_top,
|
|
1001
|
+
target_resolution)
|
|
1002
|
+
|
|
1003
|
+
# figure out band chunk shape
|
|
1004
|
+
if S1_assets is not None:
|
|
1005
|
+
band_chunks = (len(bands_to_save) - len(S1_assets), len(S1_assets))
|
|
1006
|
+
else:
|
|
1007
|
+
band_chunks = len(bands_to_save)
|
|
1008
|
+
|
|
1009
|
+
# chunks with one per timestep -> many empty timesteps for specific areas,
|
|
1010
|
+
# because we have all the timesteps for Germany
|
|
1011
|
+
da = xr.DataArray(
|
|
1012
|
+
data=dask.array.full(
|
|
1013
|
+
shape=(df["ts"].nunique(), len(bands_to_save), height, width),
|
|
1014
|
+
chunks=(1, band_chunks, processing_spatial_chunk_size,
|
|
1015
|
+
processing_spatial_chunk_size),
|
|
1016
|
+
# needs to be float in order to store NaNs
|
|
1017
|
+
dtype=np.float32,
|
|
1018
|
+
fill_value=np.nan),
|
|
1019
|
+
dims=["time", "band", "y", "x"],
|
|
1020
|
+
coords=dict(
|
|
1021
|
+
time=df["ts"].tolist(),
|
|
1022
|
+
band=bands_to_save,
|
|
1023
|
+
x=np.arange(bound_left, bound_right,
|
|
1024
|
+
target_resolution).astype(np.float32),
|
|
1025
|
+
# we do y-axis in reverse: top-left coordinate
|
|
1026
|
+
y=np.arange(bound_top, bound_bottom,
|
|
1027
|
+
-target_resolution).astype(np.float32)))
|
|
1028
|
+
|
|
1029
|
+
da = da.map_blocks(
|
|
1030
|
+
process_ptile,
|
|
1031
|
+
kwargs=dict(
|
|
1032
|
+
target_crs=target_crs,
|
|
1033
|
+
target_resolution=target_resolution,
|
|
1034
|
+
S2_mask_snow=S2_mask_snow,
|
|
1035
|
+
S2_cloud_classification=S2_cloud_classification,
|
|
1036
|
+
S2_return_cloud_probabilities=S2_return_cloud_probabilities,
|
|
1037
|
+
S2_compute_nbar=S2_compute_nbar,
|
|
1038
|
+
S2_cloud_classification_device=S2_cloud_classification_device,
|
|
1039
|
+
time_composite_freq=time_composite_freq,
|
|
1040
|
+
S2_apply_snow_mask=S2_apply_snow_mask,
|
|
1041
|
+
S2_apply_cloud_mask=S2_apply_cloud_mask,
|
|
1042
|
+
),
|
|
1043
|
+
template=da)
|
|
1044
|
+
|
|
1045
|
+
# remove timezone, otherwise crash -> zarr caveat
|
|
1046
|
+
# ... and use numpy.datetime64 with second precision
|
|
1047
|
+
return da.assign_coords(
|
|
1048
|
+
dict(time=[
|
|
1049
|
+
pd.Timestamp(i.replace(tzinfo=None)).to_datetime64()
|
|
1050
|
+
for i in da.time.data
|
|
1051
|
+
]))
|
|
1052
|
+
|
|
1053
|
+
|
|
1054
|
+
def save_as_zarr(da, path: str):
|
|
1055
|
+
"""
|
|
1056
|
+
Triggers dask compute and saves chunks whenever they have been
|
|
1057
|
+
processed. Empty chunks are not written. Chunks are compressed with
|
|
1058
|
+
lz4.
|
|
1059
|
+
|
|
1060
|
+
Parameters
|
|
1061
|
+
----------
|
|
1062
|
+
path : str
|
|
1063
|
+
Specifies where save path of the zarr file.
|
|
1064
|
+
"""
|
|
1065
|
+
|
|
1066
|
+
# NOTE the compression may not be optimal, need to benchmark
|
|
1067
|
+
store = zarr.storage.DirectoryStore(path, dimension_separator=".")
|
|
1068
|
+
da.rename("sentle").to_zarr(store=store,
|
|
1069
|
+
mode="w-",
|
|
1070
|
+
compute=True,
|
|
1071
|
+
encoding={
|
|
1072
|
+
"sentle": {
|
|
1073
|
+
"write_empty_chunks": False,
|
|
1074
|
+
"compressor": Blosc(cname="lz4"),
|
|
1075
|
+
}
|
|
1076
|
+
})
|
sentle/snow_mask.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import xarray as xr
|
|
3
|
+
|
|
4
|
+
S2_snow_mask_band = "S2_snow_mask"
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def compute_potential_snow_layer(B03, B08, B11):
|
|
8
|
+
"""Creates the Potential Snow Layer (PSL) as described by Equation 20 of Zhu and
|
|
9
|
+
Woodcock, 2012 [1]_.
|
|
10
|
+
|
|
11
|
+
References
|
|
12
|
+
----------
|
|
13
|
+
.. [1] https://doi.org/10.1016/j.rse.2011.10.028
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
# Select the required bands and scale
|
|
17
|
+
G = B03 / 10000
|
|
18
|
+
N = B08 / 10000
|
|
19
|
+
S1 = B11 / 10000
|
|
20
|
+
|
|
21
|
+
# Compute the Normalized Difference Snow Index
|
|
22
|
+
NDSI = (G - S1) / (G + S1)
|
|
23
|
+
|
|
24
|
+
# Eq. 20. (Zhu and Woodcock, 2012) and invert (True is clear, False is snow)
|
|
25
|
+
PSL = ~((NDSI > 0.15) & (N > 0.11) & (G > 0.1))
|
|
26
|
+
|
|
27
|
+
return PSL
|
sentle/utils.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from rasterio import transform
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def bounds_from_transform_height_width_res(tf, height, width, resolution):
|
|
5
|
+
# minx, miny, maxx, maxy
|
|
6
|
+
return (tf.c, tf.f - (height * resolution), tf.c + (width * resolution),
|
|
7
|
+
tf.f)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def transform_height_width_from_bounds_res(left, bottom, right, top, res):
|
|
11
|
+
width, rem = divmod(right - left, res)
|
|
12
|
+
assert rem == 0
|
|
13
|
+
width = int(width)
|
|
14
|
+
height, rem = divmod(top - bottom, res)
|
|
15
|
+
assert rem == 0
|
|
16
|
+
height = int(height)
|
|
17
|
+
tf = transform.from_bounds(west=left,
|
|
18
|
+
south=bottom,
|
|
19
|
+
east=right,
|
|
20
|
+
north=top,
|
|
21
|
+
width=width,
|
|
22
|
+
height=height)
|
|
23
|
+
|
|
24
|
+
return tf, height, width
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Clemens Mosig
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: sentle
|
|
3
|
+
Version: 2024.5.3
|
|
4
|
+
Summary: Sentinel-1 and Sentinel-2 scalable downloader.
|
|
5
|
+
Author: Clemens Mosig
|
|
6
|
+
Author-email: clemens.mosig@uni-leipzig.de
|
|
7
|
+
Classifier: Development Status :: 3 - Alpha
|
|
8
|
+
Classifier: Intended Audience :: Education
|
|
9
|
+
Classifier: Programming Language :: Python :: 2
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Operating System :: MacOS :: MacOS X
|
|
12
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
13
|
+
License-File: LICENSE.md
|
|
14
|
+
Requires-Dist: dask >=2024.5.0
|
|
15
|
+
Requires-Dist: pystac-client >=0.7.7
|
|
16
|
+
Requires-Dist: pystac >=1.10.1
|
|
17
|
+
Requires-Dist: rasterio >=1.3.10
|
|
18
|
+
Requires-Dist: affine >=2.4.0
|
|
19
|
+
Requires-Dist: pandas >=2.2.2
|
|
20
|
+
Requires-Dist: numpy >=1.26.4
|
|
21
|
+
Requires-Dist: shapely >=2.0.4
|
|
22
|
+
Requires-Dist: zarr >=2.18.1
|
|
23
|
+
Requires-Dist: geopandas >=0.14.4
|
|
24
|
+
Requires-Dist: planetary-computer >=1.0.0
|
|
25
|
+
Requires-Dist: xarray >=2024.5.0
|
|
26
|
+
Requires-Dist: distributed >=2024.5.0
|
|
27
|
+
Requires-Dist: numcodecs >=0.12.1
|
|
28
|
+
Requires-Dist: scipy >=1.13.0
|
|
29
|
+
Requires-Dist: torch >=2.3.0
|
|
30
|
+
|
|
31
|
+
Sentinel-1 & Sentinel-2 data cubes at large scale (bigger-than-memory) on any machine with integrated cloud detection, snow masking, harmonization, merging, and temporal composites.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
sentle/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
sentle/cloud_mask.py,sha256=NYMsmbbi9aEHDnQ4Z46nRBpJpYETNO_WC8C9mZ_9BRQ,1559
|
|
3
|
+
sentle/const.py,sha256=6j5JUvugcCK0wukhTGWpOWUKhdazc6kcX1Lrw14tTRk,341
|
|
4
|
+
sentle/sentle.py,sha256=qm4y7nroCX_AxeLwsAmM_uFhWxomsKzJ6allO9qiPYQ,42501
|
|
5
|
+
sentle/snow_mask.py,sha256=7psB1zLMJMssCwd-m31ng9E6w0angVq-Xs7VZxnT1QA,665
|
|
6
|
+
sentle/utils.py,sha256=NTOjVqIO7UiYT31F_oeyIFTg927HoxxMeXqDM2sikTI,776
|
|
7
|
+
sentle/data/sentinel2_grid_stripped_with_epsg.gpkg,sha256=fupV2DugjUgRLBlcJYvPhq3J946hzA7b_lBkGUbS_7I,17944576
|
|
8
|
+
sentle-2024.5.3.dist-info/LICENSE.md,sha256=L0ty6v20NpX3bRhPf7Y4CzEn9QLmZ212x_DbXHA4TtY,1070
|
|
9
|
+
sentle-2024.5.3.dist-info/METADATA,sha256=YpmP2du5kEkP-TuNYnioB7dzcg6r9uAr5kkuq_Dfz5k,1189
|
|
10
|
+
sentle-2024.5.3.dist-info/WHEEL,sha256=GJ7t_kWBFywbagK5eo9IoUwLW6oyOeTKmQ-9iHFVNxQ,92
|
|
11
|
+
sentle-2024.5.3.dist-info/top_level.txt,sha256=hvsMFKRNXFVMvhS02mpl6r32XQ0dpVOjhA7rszOWYPI,7
|
|
12
|
+
sentle-2024.5.3.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
sentle
|