cesard 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
cesard/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ from importlib.metadata import version, PackageNotFoundError
2
+
3
+ try:
4
+ __version__ = version(__name__)
5
+ except PackageNotFoundError:
6
+ # package is not installed
7
+ pass
cesard/ancillary.py ADDED
@@ -0,0 +1,628 @@
1
+ import os
2
+ import io
3
+ import logging
4
+ import requests
5
+ import hashlib
6
+ import tempfile
7
+ import zipfile
8
+ import dateutil.parser
9
+ from pathlib import Path
10
+ from multiformats import multihash
11
+ import binascii
12
+ from lxml import etree
13
+ from datetime import datetime, timedelta, timezone
14
+ from osgeo import ogr, osr
15
+ import numpy as np
16
+ import spatialist
17
+ from spatialist.raster import Raster, rasterize
18
+ from spatialist.vector import bbox, intersect, boundary, vectorize, Vector, crsConvert
19
+ import pyroSAR
20
+ from pyroSAR.ancillary import Lock, LockCollection
21
+ from pyroSAR import identify_many
22
+ from collections import defaultdict
23
+ from typing import Callable, List, TypeVar
24
+
25
+ log = logging.getLogger('cesard')
26
+
27
+ T = TypeVar('T') # any type
28
+ K = TypeVar('K') # key
29
+
30
+
31
+ def check_scene_consistency(scenes):
32
+ """
33
+ Check the consistency of a scene selection.
34
+ The following pyroSAR object attributes must be the same:
35
+
36
+ - sensor
37
+ - acquisition_mode
38
+ - product
39
+ - frameNumber (data take ID)
40
+
41
+ Parameters
42
+ ----------
43
+ scenes: list[str or pyroSAR.drivers.ID]
44
+
45
+ Returns
46
+ -------
47
+
48
+ Raises
49
+ ------
50
+ RuntimeError
51
+ """
52
+ scenes = identify_many(scenes)
53
+ for attr in ['sensor', 'acquisition_mode', 'product', 'frameNumber']:
54
+ values = set([getattr(x, attr) for x in scenes])
55
+ if not len(values) == 1:
56
+ msg = f"scene selection differs in attribute '{attr}': {values}"
57
+ raise RuntimeError(msg)
58
+
59
+
60
+ def check_spacing(spacing):
61
+ """
62
+ Check whether the spacing fits into the MGRS tile boundaries.
63
+
64
+ Parameters
65
+ ----------
66
+ spacing: int or float
67
+ the target pixel spacing in meters
68
+
69
+ Returns
70
+ -------
71
+
72
+ """
73
+ # 109800 m is the edge length of one tile.
74
+ # the overlap between tiles is either 9780 or 9840 m.
75
+ overlap_edges = [9780, 9840, 109800]
76
+ options = []
77
+ for i in range(1, (spacing + 1) * 10):
78
+ if all([x % (i / 10) == 0 for x in overlap_edges]):
79
+ options.append(i / 10)
80
+ if spacing not in options:
81
+ raise RuntimeError(f'target spacing of {spacing} m does not align '
82
+ f'with MGRS tile size and overlaps.\nOptions: {options}')
83
+
84
+
85
+ def generate_unique_id(encoded_str, length=4):
86
+ """
87
+
88
+ Returns a unique product identifier as a hexadecimal string.
89
+ The CRC-16 algorithm used to compute the unique identifier is
90
+ CRC-CCITT (0xFFFF). The resulting CRC value is truncated to
91
+ the number of hexadecimal characters specified by the `length`
92
+ argument.
93
+
94
+ Parameters
95
+ ----------
96
+ encoded_str: bytes
97
+ A string that should be used to generate a unique id from.
98
+ The string needs to be encoded; e.g.: `'abc'.encode()`.
99
+ length: int, optional
100
+ The desired length of the output string in hexadecimal
101
+ characters (max: 4). Values higher than 4 will be capped
102
+ at 4, since CRC-16 only produces 16 bits.
103
+
104
+ Returns
105
+ -------
106
+ p_id: str
107
+ The unique product identifier (upper-case hexadecimal string).
108
+ """
109
+ crc = binascii.crc_hqx(encoded_str, 0xffff)
110
+ max_length = 4 # Max characters for 16-bit CRC
111
+ length = max(1, min(length, max_length)) # Clamp between 1 and 4
112
+ mask = (1 << (length * 4)) - 1 # Each hex digit = 4 bits
113
+ p_id = f'{crc & mask:0{length}X}'
114
+ return p_id
115
+
116
+
117
+ def get_max_ext(geometries, buffer=None, crs=None):
118
+ """
119
+ Gets the maximum extent from a list of geometries.
120
+
121
+ Parameters
122
+ ----------
123
+ geometries: list[spatialist.vector.Vector]
124
+ List of :class:`~spatialist.vector.Vector` geometries.
125
+ buffer: float or None
126
+ The buffer in units of the geometries' CRS to add to the extent.
127
+ crs: str or int or None
128
+ The target CRS of the extent. If None (default) the extent is
129
+ expressed in the CRS of the input geometries.
130
+
131
+ Returns
132
+ -------
133
+ max_ext: dict
134
+ The maximum extent of the selected :class:`~spatialist.vector.Vector` geometries including the chosen buffer.
135
+ """
136
+ max_ext = {}
137
+ crs_list = []
138
+ for geo in geometries:
139
+ crs_list.append(f"EPSG:{geo.getProjection('epsg')}")
140
+ if len(max_ext.keys()) == 0:
141
+ max_ext = geo.extent
142
+ else:
143
+ ext = geo.extent
144
+ for key in ['xmin', 'ymin']:
145
+ if ext[key] < max_ext[key]:
146
+ max_ext[key] = ext[key]
147
+ for key in ['xmax', 'ymax']:
148
+ if ext[key] > max_ext[key]:
149
+ max_ext[key] = ext[key]
150
+ crs_list = list(set(crs_list))
151
+ if len(crs_list) > 1:
152
+ raise RuntimeError(f'The input geometries are in different CRSs: {crs_list}')
153
+ max_ext = dict(max_ext)
154
+ if buffer is not None:
155
+ max_ext['xmin'] -= buffer
156
+ max_ext['xmax'] += buffer
157
+ max_ext['ymin'] -= buffer
158
+ max_ext['ymax'] += buffer
159
+ if crs is not None:
160
+ with bbox(coordinates=max_ext, crs=crs_list[0]) as geo:
161
+ geo.reproject(projection=crs)
162
+ max_ext = geo.extent
163
+ return max_ext
164
+
165
+
166
+ def group_by_attr(items: List[T], key_fn: Callable[[T], K]) -> List[List[T]]:
167
+ """
168
+ Group items based on a key function.
169
+
170
+ :param items: The list of arbitrary items to group.
171
+ :param key_fn: A function that extracts a key from each item.
172
+ :returns: A list of groups, where each group is a list of items with the same key.
173
+
174
+ Example
175
+ -------
176
+ >>> list_in = ['abc', 'axy', 'brt', 'btk']
177
+ >>> print(group_by_attr(list_in, lambda x: x[0]))
178
+ [['abc', 'axy'], ['brt', 'btk']]
179
+
180
+ >>> list_in = [{'a': 1}, {'a': 2}, {'a': 1}, {'a': 2}]
181
+ >>> print(group_by_attr(list_in, lambda x: x['a']))
182
+ [[{'a': 1}, {'a': 1}], [{'a': 2}, {'a': 2}]]
183
+ """
184
+ grouped = defaultdict(list)
185
+ for item in items:
186
+ key = key_fn(item)
187
+ grouped[key].append(item)
188
+ return list(grouped.values())
189
+
190
+
191
+ def group_by_time(scenes, time=3):
192
+ """
193
+ Group scenes by their acquisition time difference.
194
+
195
+ Parameters
196
+ ----------
197
+ scenes:list[pyroSAR.drivers.ID or str]
198
+ a list of image names
199
+ time: int or float
200
+ a time difference in seconds by which to group the scenes.
201
+ The default of 3 seconds incorporates the overlap between SLCs.
202
+
203
+ Returns
204
+ -------
205
+ list[list[pyroSAR.drivers.ID]]
206
+ a list of sub-lists containing the file names of the grouped scenes
207
+ """
208
+ # sort images by time stamp
209
+ scenes = identify_many(scenes, sortkey='start')
210
+
211
+ if len(scenes) < 2:
212
+ return [scenes]
213
+
214
+ groups = [[scenes[0]]]
215
+ group = groups[0]
216
+
217
+ for i in range(1, len(scenes)):
218
+ start = datetime.strptime(scenes[i].start, '%Y%m%dT%H%M%S')
219
+ stop_pred = datetime.strptime(scenes[i - 1].stop, '%Y%m%dT%H%M%S')
220
+ diff = abs((stop_pred - start).total_seconds())
221
+ if diff <= time:
222
+ group.append(scenes[i])
223
+ else:
224
+ groups.append([scenes[i]])
225
+ group = groups[-1]
226
+ return groups
227
+
228
+
229
+ def vrt_add_overviews(vrt, overviews, resampling='AVERAGE'):
230
+ """
231
+ Add overviews to an existing VRT file.
232
+ Existing overviews will be overwritten.
233
+
234
+ Parameters
235
+ ----------
236
+ vrt: str
237
+ the VRT file
238
+ overviews: list[int]
239
+ the overview levels
240
+ resampling: str
241
+ the overview resampling method
242
+
243
+ Returns
244
+ -------
245
+
246
+ """
247
+ tree = etree.parse(vrt)
248
+ root = tree.getroot()
249
+ ovr = root.find('OverviewList')
250
+ if ovr is None:
251
+ ovr = etree.SubElement(root, 'OverviewList')
252
+ ovr.text = ' '.join([str(x) for x in overviews])
253
+ ovr.attrib['resampling'] = resampling.lower()
254
+ etree.indent(root)
255
+ tree.write(vrt, pretty_print=True, xml_declaration=False, encoding='utf-8')
256
+
257
+
258
+ def buffer_min_overlap(geom1, geom2, percent=1, step=None):
259
+ """
260
+ Buffer a rectangular geometry to a minimum overlap with a second geometry.
261
+ The geometry is iteratively buffered until the minimum overlap is reached.
262
+ If the overlap of the input geometries is already larger than the defined
263
+ threshold, a copy of the original geometry is returned.
264
+
265
+ Parameters
266
+ ----------
267
+ geom1: spatialist.vector.Vector
268
+ the geometry to be buffered
269
+ geom2: spatialist.vector.Vector
270
+ the reference geometry to intersect with
271
+ percent: int or float
272
+ the minimum overlap in percent of `geom1`
273
+ step: int or float or None
274
+ the buffering step size. If None, the step size is 0.1 % of the
275
+ average rectangle corner length.
276
+
277
+ Returns
278
+ -------
279
+
280
+ """
281
+ geom1_crs = geom1.getProjection('epsg')
282
+ geom2_crs = geom2.getProjection('epsg')
283
+ if geom1_crs != geom2_crs:
284
+ raise ValueError('both geometries must have the same CRS')
285
+ geom2_area = geom2.getArea()
286
+ ext = geom1.extent
287
+ ext2 = ext.copy()
288
+ if step is None:
289
+ xdist = ext['xmax'] - ext['xmin']
290
+ ydist = ext['ymax'] - ext['ymin']
291
+ step = (xdist + ydist) / 2 / 1000
292
+ buffer = 0
293
+ overlap = 0
294
+ while overlap <= percent:
295
+ xbuf = buffer * step
296
+ ybuf = buffer * step
297
+ ext2['xmin'] = ext['xmin'] - xbuf
298
+ ext2['xmax'] = ext['xmax'] + xbuf
299
+ ext2['ymin'] = ext['ymin'] - ybuf
300
+ ext2['ymax'] = ext['ymax'] + ybuf
301
+ with bbox(ext2, geom1_crs) as geom3:
302
+ ext3 = geom3.extent
303
+ with intersect(geom2, geom3) as inter:
304
+ inter_area = inter.getArea()
305
+ overlap = inter_area / geom2_area * 100
306
+ buffer += 1
307
+ return bbox(ext3, geom1_crs)
308
+
309
+
310
+ def date_to_utc(date, as_datetime=False, str_format='%Y%m%dT%H%M%S'):
311
+ """
312
+ convert a date object to a UTC date string or datetime object.
313
+
314
+ Parameters
315
+ ----------
316
+ date: str or datetime or None
317
+ the date object to convert; timezone-unaware dates are interpreted as UTC.
318
+ as_datetime: bool
319
+ return a datetime object instead of a string?
320
+ str_format: str
321
+ the output string format (ignored if `as_datetime` is True)
322
+
323
+ Returns
324
+ -------
325
+ str or datetime or None
326
+ the date string or datetime object in UTC time zone
327
+ """
328
+ if date is None:
329
+ return date
330
+ elif isinstance(date, str):
331
+ out = dateutil.parser.parse(date)
332
+ elif isinstance(date, datetime):
333
+ out = date
334
+ else:
335
+ raise TypeError('date must be a string, datetime object or None')
336
+ if out.tzinfo is None:
337
+ out = out.replace(tzinfo=timezone.utc)
338
+ else:
339
+ out = out.astimezone(timezone.utc)
340
+ if not as_datetime:
341
+ out = out.strftime(str_format)
342
+ return out
343
+
344
+
345
+ def buffer_time(start, stop, as_datetime=False, str_format='%Y%m%dT%H%M%S', **kwargs):
346
+ """
347
+ Time range buffering
348
+
349
+ Parameters
350
+ ----------
351
+ start: str
352
+ the start time date object to convert; timezone-unaware dates are interpreted as UTC.
353
+ stop: str
354
+ the stop time date object to convert; timezone-unaware dates are interpreted as UTC.
355
+ as_datetime: bool
356
+ return datetime objects instead of strings?
357
+ str_format: str
358
+ the output string format (ignored if `as_datetime` is True)
359
+ kwargs
360
+ time arguments passed to :func:`datetime.timedelta`
361
+
362
+ Returns
363
+ -------
364
+ tuple[str | datetime]
365
+ the buffered start and stop time as string or datetime object
366
+ """
367
+ td = timedelta(**kwargs)
368
+ start = date_to_utc(start, as_datetime=True) - td
369
+ stop = date_to_utc(stop, as_datetime=True) + td
370
+ if not as_datetime:
371
+ start = start.strftime(str_format)
372
+ stop = stop.strftime(str_format)
373
+ return start, stop
374
+
375
+
376
+ def get_kml():
377
+ """
378
+ Download the Sentinel-2 MGRS grid KML file. The target folder is ~/cesard.
379
+
380
+ Returns
381
+ -------
382
+ str
383
+ the path to the KML file
384
+ """
385
+ remote = ('https://sentiwiki.copernicus.eu/__attachments/1692737/'
386
+ 'S2A_OPER_GIP_TILPAR_MPC__20151209T095117_V20150622T000000_21000101T000000_B00.zip')
387
+ local_path = os.path.join(os.path.expanduser('~'), '.cesard')
388
+ os.makedirs(local_path, exist_ok=True)
389
+ local = os.path.join(local_path, os.path.basename(remote).replace('.zip', '.kml'))
390
+ with Lock(local):
391
+ if not os.path.isfile(local):
392
+ log.info(f'downloading MGRS grid KML file to {local_path}')
393
+ r = requests.get(remote)
394
+ with zipfile.ZipFile(io.BytesIO(r.content)) as zf:
395
+ zf.extractall(local_path)
396
+ return local
397
+
398
+
399
+ def compute_hash(file_path, algorithm='sha256', chunk_size=8192, multihash_encode=True):
400
+ """
401
+ Compute the (multi)hash of a file using the specified algorithm.
402
+
403
+ Parameters
404
+ ----------
405
+ file_path: str
406
+ Path to the file.
407
+ algorithm: str
408
+ Hash algorithm to use (default is 'sha256').
409
+ chunk_size: int
410
+ Size of chunks to read from the file in bytes (default is 8192).
411
+ multihash_encode: bool
412
+ Encode the hash according to the
413
+ `multihash specification <https://github.com/multiformats/multihash>`_
414
+ (default is True)?
415
+ The hash generated by `hashlib` will be wrapped using
416
+ :func:`multiformats.multihash.wrap`.
417
+
418
+ Returns
419
+ -------
420
+ str
421
+ the hexadecimal hash string of the file.
422
+
423
+ See Also
424
+ --------
425
+ :mod:`hashlib`
426
+ :mod:`multiformats.multihash`
427
+ """
428
+ # lookup between hashlib and multihash algorithm names; to be extended if necessary
429
+ algo_lookup = {'sha1': 'sha1',
430
+ 'sha256': 'sha2-256',
431
+ 'sha512': 'sha2-512'}
432
+ if algorithm not in algo_lookup.keys():
433
+ raise ValueError(f'Hash algorithm must be one of {algo_lookup.keys()}')
434
+ hash_func = getattr(hashlib, algorithm)()
435
+ with open(file_path, 'rb') as f:
436
+ while chunk := f.read(chunk_size):
437
+ hash_func.update(chunk)
438
+ if multihash_encode:
439
+ digest = hash_func.digest()
440
+ mh = multihash.wrap(digest, algo_lookup[algorithm])
441
+ return mh.hex()
442
+ else:
443
+ return hash_func.hexdigest()
444
+
445
+
446
+ def datamask(measurement, dm_ras, dm_vec):
447
+ """
448
+ Create data masks for a given image file.
449
+ The created raster data mask does not contain a simple mask of nodata values.
450
+ Rather, a boundary vector geometry containing all valid pixels is created and
451
+ then rasterized. This boundary geometry (single polygon) is saved as `dm_vec`.
452
+ In this case `dm_vec` is returned.
453
+ If the input image only contains nodata values, no raster data mask is created,
454
+ and an empty dummy vector mask is created. In this case the function will return
455
+ `None`.
456
+
457
+
458
+ Parameters
459
+ ----------
460
+ measurement: str
461
+ the binary image file
462
+ dm_ras: str
463
+ the name of the raster data mask
464
+ dm_vec: str
465
+ the name of the vector data mask
466
+
467
+ Returns
468
+ -------
469
+ str or None
470
+ `dm_vec` if the vector data mask contains a geometry or None otherwise
471
+ """
472
+
473
+ def mask_from_array(arr, dm_vec, dm_ras, ref):
474
+ """
475
+
476
+ Parameters
477
+ ----------
478
+ arr: np.ndarray
479
+ dm_vec: str
480
+ dm_ras: str
481
+ ref: spatialist.raster.Raster
482
+
483
+ Returns
484
+ -------
485
+ str or None
486
+ """
487
+ # create a dummy vector mask if the mask only contains 0 values
488
+ if len(arr[arr == 1]) == 0:
489
+ Path(dm_vec).touch(exist_ok=False)
490
+ return None
491
+ # vectorize the raster data mask
492
+ with vectorize(target=arr, reference=ref) as vec:
493
+ # compute a valid data boundary geometry (vector data mask)
494
+ with boundary(vec, expression="value=1") as bounds:
495
+ # rasterize the vector data mask
496
+ if not os.path.isfile(dm_ras):
497
+ rasterize(vectorobject=bounds, reference=ref,
498
+ outname=dm_ras)
499
+ # write the vector data mask
500
+ bounds.write(outfile=dm_vec)
501
+ return dm_vec
502
+
503
+ if os.path.isfile(dm_vec) and os.path.isfile(dm_ras):
504
+ return None if os.path.getsize(dm_vec) == 0 else dm_vec
505
+
506
+ with LockCollection([dm_vec, dm_ras]):
507
+ if not os.path.isfile(dm_vec):
508
+ if not os.path.isfile(dm_ras):
509
+ with Raster(measurement) as ras:
510
+ arr = ras.array()
511
+ # create a nodata mask
512
+ mask = ~np.isnan(arr)
513
+ del arr
514
+ out = mask_from_array(arr=mask, dm_vec=dm_vec,
515
+ dm_ras=dm_ras, ref=ras)
516
+ else:
517
+ # read the raster data mask
518
+ with Raster(dm_ras) as ras:
519
+ mask = ras.array()
520
+ out = mask_from_array(arr=mask, dm_vec=dm_vec,
521
+ dm_ras=dm_ras, ref=ras)
522
+ del mask
523
+ else:
524
+ if os.path.getsize(dm_vec) == 0:
525
+ out = None
526
+ else:
527
+ out = dm_vec
528
+ return out
529
+
530
+
531
+ def get_tmp_name(suffix):
532
+ """
533
+ Get the name of a temporary file with defined suffix.
534
+ Files are placed in a subdirectory 'cesard' of the regular
535
+ temporary directory so the latter is not flooded with too
536
+ many files in case they are not properly deleted.
537
+
538
+ Parameters
539
+ ----------
540
+ suffix: str
541
+ the file suffix/extension, e.g. '.tif'
542
+
543
+ Returns
544
+ -------
545
+
546
+ """
547
+ tmpdir = os.path.join(tempfile.gettempdir(), 'cesard')
548
+ os.makedirs(tmpdir, exist_ok=True)
549
+ return tempfile.NamedTemporaryFile(suffix=suffix, dir=tmpdir).name
550
+
551
+
552
+ def combine_polygons(vector, crs=4326, multipolygon=False, layer_name='combined'):
553
+ """
554
+ Combine polygon vector objects into one.
555
+ The output is a single vector object with the polygons either stored in
556
+ separate features or combined into a single multipolygon geometry.
557
+
558
+ Parameters
559
+ ----------
560
+ vector: spatialist.vector.Vector or list[spatialist.vector.Vector]
561
+ the input vector object(s). Providing only one object only makes sense when `multipolygon=True`.
562
+ crs: int or str
563
+ the target CRS. Default: EPSG:4326
564
+ multipolygon: bool
565
+ combine all polygons into one multipolygon?
566
+ Default False: write each polygon into a separate feature.
567
+ layer_name: str
568
+ the layer name of the output vector object.
569
+
570
+ Returns
571
+ -------
572
+ spatialist.vector.Vector
573
+ """
574
+ if not isinstance(vector, list):
575
+ vector = [vector]
576
+ ##############################################################################
577
+ # check geometry types
578
+ geometry_names = []
579
+ field_defs = []
580
+ for item in vector:
581
+ field_defs.extend(item.fieldDefs)
582
+ for feature in item.layer:
583
+ geom = feature.GetGeometryRef()
584
+ geometry_names.append(geom.GetGeometryName())
585
+ item.layer.ResetReading()
586
+ geom = None
587
+ geometry_names = list(set(geometry_names))
588
+ if not all(x == 'POLYGON' for x in geometry_names):
589
+ raise RuntimeError('All geometries must be of type POLYGON')
590
+ ##############################################################################
591
+ vec = Vector(driver='Memory')
592
+ srs_out = crsConvert(crs, 'osr')
593
+ if multipolygon:
594
+ geom_type = ogr.wkbMultiPolygon
595
+ geom_out = [ogr.Geometry(geom_type)]
596
+ else:
597
+ geom_type = ogr.wkbPolygon
598
+ geom_out = []
599
+ fields = []
600
+ vec.addlayer(name=layer_name, srs=srs_out, geomType=geom_type)
601
+ for item in vector:
602
+ fieldnames = item.fieldnames
603
+ if item.srs.IsSame(srs_out):
604
+ coord_trans = None
605
+ else:
606
+ coord_trans = osr.CoordinateTransformation(item.srs, srs_out)
607
+ for feature in item.layer:
608
+ geom = feature.GetGeometryRef()
609
+ if coord_trans is not None:
610
+ geom.Transform(coord_trans)
611
+ if multipolygon:
612
+ geom_out[0].AddGeometry(geom.Clone())
613
+ else:
614
+ fields.append({x: feature.GetField(x) for x in fieldnames})
615
+ geom_out.append(geom.Clone())
616
+ item.layer.ResetReading()
617
+ geom = None
618
+ if multipolygon:
619
+ geom_out = geom_out[0].UnionCascaded()
620
+ vec.addfeature(geom_out)
621
+ else:
622
+ for field_def in field_defs:
623
+ if field_def.GetName() not in vec.fieldnames:
624
+ vec.layer.CreateField(field_def)
625
+ for i, geom in enumerate(geom_out):
626
+ vec.addfeature(geometry=geom, fields=fields[i])
627
+ geom_out = None
628
+ return vec