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 +7 -0
- cesard/ancillary.py +628 -0
- cesard/ard.py +577 -0
- cesard/config.py +94 -0
- cesard/dem.py +443 -0
- cesard/metadata/__init__.py +0 -0
- cesard/metadata/extract.py +347 -0
- cesard/metadata/mapping.py +142 -0
- cesard/metadata/stac.py +661 -0
- cesard/metadata/xml.py +754 -0
- cesard/search.py +440 -0
- cesard/snap.py +510 -0
- cesard/tile_extraction.py +306 -0
- cesard-1.0.0.dist-info/METADATA +56 -0
- cesard-1.0.0.dist-info/RECORD +18 -0
- cesard-1.0.0.dist-info/WHEEL +5 -0
- cesard-1.0.0.dist-info/licenses/LICENSE.txt +13 -0
- cesard-1.0.0.dist-info/top_level.txt +1 -0
cesard/__init__.py
ADDED
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
|