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 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
+ }
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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: bdist_wheel (0.43.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ sentle