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