-
Notifications
You must be signed in to change notification settings - Fork 41
/
Copy pathterrain.py
1712 lines (1409 loc) · 66.5 KB
/
terrain.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""Terrain attribute calculations, such as slope, aspect, hillshade, curvature and ruggedness indexes."""
from __future__ import annotations
import warnings
from typing import Sized, overload
import geoutils as gu
import numba
import numpy as np
from geoutils.raster import Raster, RasterType
from xdem._typing import MArrayf, NDArrayf
try:
import richdem as rd
_has_rd = True
except ImportError:
_has_rd = False
available_attributes = [
"slope",
"aspect",
"hillshade",
"curvature",
"planform_curvature",
"profile_curvature",
"maximum_curvature",
"topographic_position_index",
"terrain_ruggedness_index",
"roughness",
"rugosity",
"fractal_roughness",
]
def _raster_to_rda(rst: RasterType) -> rd.rdarray:
"""
Get georeferenced richDEM array from geoutils.Raster
:param rst: DEM as raster
:return: DEM
"""
arr = rst.data.filled(rst.nodata).squeeze()
rda = rd.rdarray(arr, no_data=rst.nodata)
rda.geotransform = rst.transform.to_gdal()
return rda
def _get_terrainattr_richdem(rst: RasterType, attribute: str = "slope_radians") -> NDArrayf:
"""
Derive terrain attribute for DEM opened with rasterio. One of "slope_degrees", "slope_percentage", "aspect",
"profile_curvature", "planform_curvature", "curvature" and others (see RichDEM documentation).
:param rst: DEM as raster
:param attribute: RichDEM terrain attribute
:return:
"""
rda = _raster_to_rda(rst)
terrattr = rd.TerrainAttribute(rda, attrib=attribute)
terrattr[terrattr == terrattr.no_data] = np.nan
return np.array(terrattr)
@numba.njit(parallel=True) # type: ignore
def _get_quadric_coefficients(
dem: NDArrayf,
resolution: float,
fill_method: str = "none",
edge_method: str = "none",
make_rugosity: bool = False,
) -> NDArrayf:
"""
Run the pixel-wise analysis in parallel for a 3x3 window using the resolution.
See the xdem.terrain.get_quadric_coefficients() docstring for more info.
"""
# Rename the resolution
L = resolution
# Allocate the output.
output = np.full((12,) + dem.shape, fill_value=np.nan)
# Convert the string to a number (fewer bytes to compare each iteration)
if fill_method == "median":
fill_method_n = numba.uint8(0)
elif fill_method == "mean":
fill_method_n = numba.uint8(1)
elif fill_method == "none":
fill_method_n = numba.uint8(2)
if edge_method == "nearest":
edge_method_n = numba.uint8(0)
elif edge_method == "wrap":
edge_method_n = numba.uint8(1)
elif edge_method == "none":
edge_method_n = numba.uint8(2)
# Loop over every pixel concurrently.
for i in numba.prange(dem.size):
# Derive its associated row and column index.
col = i % dem.shape[1]
row = int(i / dem.shape[1])
# Extract the pixel and its 8 immediate neighbours.
# If the border is reached, just duplicate the closest neighbour to obtain 9 values.
Z = np.empty((9,), dtype=dem.dtype)
count = 0
# If edge_method == "none", validate that it's not near an edge. If so, leave the nans without filling.
if edge_method_n == 2:
if (row < 1) or (row > (dem.shape[0] - 2)) or (col < 1) or (col > (dem.shape[1] - 2)):
continue
for j in range(-1, 2):
for k in range(-1, 2):
# Here the "nearest" edge_method is performed.
if edge_method_n == 0:
row_indexer = min(max(row + k, 0), dem.shape[0] - 1)
col_indexer = min(max(col + j, 0), dem.shape[1] - 1)
elif edge_method_n == 1:
row_indexer = (row + k) % dem.shape[0]
col_indexer = (col + j) % dem.shape[1]
else:
row_indexer = row + k
col_indexer = col + j
Z[count] = dem[row_indexer, col_indexer]
count += 1
# Get a mask of all invalid (nan or inf) values.
invalids = ~np.isfinite(Z)
# Skip the pixel if it and all of its neighbours are invalid
if np.all(invalids):
continue
if np.count_nonzero(invalids) > 0:
if fill_method_n == 0:
# Fill all non-finite values with the most common value.
Z[invalids] = np.nanmedian(Z)
elif fill_method_n == 1:
# Fill all non-finite values with the mean.
Z[invalids] = np.nanmean(Z)
elif fill_method_n == 2:
# Skip the pixel if any of its neighbours are nan.
continue
else:
# This should not occur.
pass
if make_rugosity:
# Rugosity is computed on a 3x3 window like the quadratic coefficients, see Jenness (2004) for details
# For this, we need elevation differences and horizontal length of 16 segments
dzs = np.zeros((16,))
dls = np.zeros((16,))
count_without_center = 0
count_all = 0
# First, the 8 connected segments from the center cells, the center cell is index 4
for j in range(-1, 2):
for k in range(-1, 2):
# Skip if this is the center pixel
if j == 0 and k == 0:
count_all += 1
continue
# The first eight elevation differences from the cell center
dzs[count_without_center] = Z[4] - Z[count_all]
# The first eight planimetric length that can be diagonal or straight from the center
dls[count_without_center] = np.sqrt(j**2 + k**2) * L
count_all += 1
count_without_center += 1
# Manually for the remaining eight segments between surrounding pixels:
# First, four elevation differences along the x axis
dzs[8] = Z[0] - Z[1]
dzs[9] = Z[1] - Z[2]
dzs[10] = Z[6] - Z[7]
dzs[11] = Z[7] - Z[8]
# Second, along the y axis
dzs[12] = Z[0] - Z[3]
dzs[13] = Z[3] - Z[6]
dzs[14] = Z[2] - Z[5]
dzs[15] = Z[5] - Z[8]
# For the planimetric lengths, all are equal to one
dls[8:] = L
# Finally, the half-surface length of each segment
hsl = np.sqrt(dzs**2 + dls**2) / 2
# Starting from up direction anticlockwise, every triangle has 2 segments between center and surrounding
# pixels and 1 segment between surrounding pixels; pixel 4 is the center
# above 4 the index of center-surrounding segment decrease by 1, as the center pixel was skipped
# Triangle 1: pixels 3 and 0
T1 = [hsl[3], hsl[0], hsl[12]]
# Triangle 2: pixels 0 and 1
T2 = [hsl[0], hsl[1], hsl[8]]
# Triangle 3: pixels 1 and 2
T3 = [hsl[1], hsl[2], hsl[9]]
# Triangle 4: pixels 2 and 5
T4 = [hsl[2], hsl[4], hsl[14]]
# Triangle 5: pixels 5 and 8
T5 = [hsl[4], hsl[7], hsl[15]]
# Triangle 6: pixels 8 and 7
T6 = [hsl[7], hsl[6], hsl[11]]
# Triangle 7: pixels 7 and 6
T7 = [hsl[6], hsl[5], hsl[10]]
# Triangle 8: pixels 6 and 3
T8 = [hsl[5], hsl[3], hsl[13]]
list_T = [T1, T2, T3, T4, T5, T6, T7, T8]
# Finally, we compute the 3D surface areas of the 8 triangles
A = np.empty((8,))
count = 0
for T in list_T:
# Half sum of lengths
hs = sum(T) / 2
# Surface area of triangle
A[count] = np.sqrt(hs * (hs - T[0]) * (hs - T[1]) * (hs - T[2]))
count += 1
# Assign the A, B, C, D etc., factors to the output. This ugly syntax is needed to make parallel numba happy.
# Coefficients of Zevenberg and Thorne (1987), Equations 3 to 11
output[0, row, col] = ((Z[0] + Z[2] + Z[6] + Z[8]) / 4 - (Z[1] + Z[3] + Z[5] + Z[7]) / 2 + Z[4]) / (L**4) # A
output[1, row, col] = ((Z[0] + Z[2] - Z[6] - Z[8]) / 4 - (Z[1] - Z[7]) / 2) / (L**3) # B
output[2, row, col] = ((-Z[0] + Z[2] - Z[6] + Z[8]) / 4 + (Z[3] - Z[5]) / 2) / (L**3) # C
output[3, row, col] = ((Z[3] + Z[5]) / 2 - Z[4]) / (L**2) # D
output[4, row, col] = ((Z[1] + Z[7]) / 2 - Z[4]) / (L**2) # E
output[5, row, col] = (-Z[0] + Z[2] + Z[6] - Z[8]) / (4 * L**2) # F
output[6, row, col] = (-Z[3] + Z[5]) / (2 * L) # G
output[7, row, col] = (Z[1] - Z[7]) / (2 * L) # H
output[8, row, col] = Z[4] # I
# Refined coefficients for slope of Horn (1981), page 18 bottom left equations.
output[9, row, col] = ((Z[6] + 2 * Z[7] + Z[8]) - (Z[0] + 2 * Z[1] + Z[2])) / (8 * L)
output[10, row, col] = ((Z[6] + 2 * Z[3] + Z[0]) - (Z[8] + 2 * Z[5] + Z[2])) / (8 * L)
# Rugosity from Jenness (2004): difference between real surface area and planimetric surface area
if make_rugosity:
output[11, row, col] = sum(A) / L**2
return output
def get_quadric_coefficients(
dem: NDArrayf,
resolution: float,
fill_method: str = "none",
edge_method: str = "none",
make_rugosity: bool = False,
) -> NDArrayf:
"""
Computes quadric and other coefficients on a fixed 3x3 pixel window, and that depends on the resolution.
Returns the 9 coefficients of a quadric surface fit to every pixel in the raster, the 2 coefficients of optimized
slope gradient, and the rugosity.
For the quadric surface, based on Zevenbergen and Thorne (1987), http://dx.doi.org/10.1002/esp.3290120107, also
described in the documentation:
https://desktop.arcgis.com/en/arcmap/10.3/tools/spatial-analyst-toolbox/how-curvature-works.htm
The function that is solved is:
Z = Ax²y² + Bx²y + Cxy² + Dx² + Ey² + Fxy + Gx + Hy + I
Where Z is the elevation, x is the distance from left-right and y is the distance from top-bottom.
For the 2 coefficients of optimized slope, based on Horn (1981), http://dx.doi.org/10.1109/PROC.1981.11918, page 18
bottom left equations.
For the rugosity, based on Jenness (2004), https://doi.org/10.2193/0091-7648(2004)032[0829:CLSAFD]2.0.CO;2.
Each pixel's fit can be accessed by coefficients[:, row, col], returning an array of shape 12.
Fill methods
If the 3x3 matrix to fit the quadric function on has NaNs, these need to be handled:
* 'median': NaNs are filled with the median value of the matrix.
* 'mean': NaNs are filled with the mean value of the matrix.
* 'none': If NaNs are encountered, skip the entire cell (default for GDAL and SAGA).
Edge methods
Each iteration requires a 3x3 matrix, so special edge cases have to be made.
* 'nearest': Pixels outside the range are filled using the closest pixel value.
* 'wrap': The array is wrapped so pixels near the right edge will be sampled from the left, etc.
* 'none': Edges will not be analyzed, leaving a 1 pixel edge of NaNs.
Quirks:
* Edges are naively treated by filling the closest value, so that a 3x3 matrix is always calculated.\
It may therefore be slightly off in the edges.
* NaNs and infs are filled with the median of the finites in the matrix, possibly affecting the fit.
* The X and Y resolution needs to be the same. It does not work if they differ.
:param dem: The 2D DEM to be analyzed (3D DEMs of shape (1, row, col) are not supported)
:param resolution: The X/Y resolution of the DEM.
:param fill_method: Fill method to use for NaNs in the 3x3 matrix.
:param edge_method: The method to use near the array edge.
:param make_rugosity: Whether to compute coefficients for rugosity.
:raises ValueError: If the inputs are poorly formatted.
:raises RuntimeError: If unexpected backend errors occurred.
:examples:
>>> dem = np.array([[1, 1, 1],
... [1, 2, 1],
... [1, 1, 1]], dtype="float32")
>>> coeffs = get_quadric_coefficients(dem, resolution=1.0, make_rugosity=True)
>>> coeffs.shape
(12, 3, 3)
>>> coeffs[:9, 1, 1]
array([ 1., 0., 0., -1., -1., 0., 0., 0., 2.])
>>> coeffs[9:11, 1, 1]
array([0., 0.])
>>> coeffs[11, 1, 1]
1.4142135623730954
:returns: An array of coefficients for each pixel of shape (9, row, col).
"""
# This function only formats and validates the inputs. For the true functionality, see _get_quadric_coefficients()
dem_arr = gu.raster.get_array_and_mask(dem)[0]
if len(dem_arr.shape) != 2:
raise ValueError(
f"Invalid input array shape: {dem.shape}, parsed into {dem_arr.shape}. "
"Expected 2D array or 3D array of shape (1, row, col)"
)
if any(dim < 3 for dim in dem_arr.shape):
raise ValueError(f"DEM (shape: {dem.shape}) is too small. Smallest supported shape is (3, 3)")
# Resolution is in other tools accepted as a tuple. Here, it must be just one number, so it's best to sanity check.
if isinstance(resolution, Sized):
raise ValueError("Resolution must be the same for X and Y directions")
allowed_fill_methods = ["median", "mean", "none"]
allowed_edge_methods = ["nearest", "wrap", "none"]
for value, name, allowed in zip(
[fill_method, edge_method], ["fill", "edge"], (allowed_fill_methods, allowed_edge_methods)
):
if value.lower() not in allowed:
raise ValueError(f"Invalid {name} method: '{value}'. Choices: {allowed}")
# Try to run the numba JIT code. It should never fail at this point, so if it does, it should be reported!
try:
coeffs = _get_quadric_coefficients(
dem_arr,
resolution,
fill_method=fill_method.lower(),
edge_method=edge_method.lower(),
make_rugosity=make_rugosity,
)
except Exception as exception:
raise RuntimeError("Unhandled numba exception. Please raise an issue of what happened.") from exception
return coeffs
@numba.njit(parallel=True) # type: ignore
def _get_windowed_indexes(
dem: NDArrayf,
fill_method: str = "median",
edge_method: str = "nearest",
window_size: int = 3,
make_fractal_roughness: bool = False,
) -> NDArrayf:
"""
Run the pixel-wise analysis in parallel for any window size without using the resolution.
See the xdem.terrain.get_windowed_indexes() docstring for more info.
"""
# Allocate the outputs.
output = np.full((5,) + dem.shape, fill_value=np.nan)
# Half window size
hw = int(np.floor(window_size / 2))
# Convert the string to a number (fewer bytes to compare each iteration)
if fill_method == "median":
fill_method_n = numba.uint8(0)
elif fill_method == "mean":
fill_method_n = numba.uint8(1)
elif fill_method == "none":
fill_method_n = numba.uint8(2)
if edge_method == "nearest":
edge_method_n = numba.uint8(0)
elif edge_method == "wrap":
edge_method_n = numba.uint8(1)
elif edge_method == "none":
edge_method_n = numba.uint8(2)
# Loop over every pixel concurrently.
for i in numba.prange(dem.size):
# Derive its associated row and column index.
col = i % dem.shape[1]
row = int(i / dem.shape[1])
# Extract the pixel and its 8 immediate neighbours.
# If the border is reached, just duplicate the closest neighbour to obtain 9 values.
Z = np.empty((window_size**2,), dtype=dem.dtype)
count = 0
# If edge_method == "none", validate that it's not near an edge. If so, leave the nans without filling.
if edge_method_n == 2:
if (row < hw) or (row >= (dem.shape[0] - hw)) or (col < hw) or (col >= (dem.shape[1] - hw)):
continue
for j in range(-hw, -hw + window_size):
for k in range(-hw, -hw + window_size):
# Here the "nearest" edge_method is performed.
if edge_method_n == 0:
row_indexer = min(max(row + k, 0), dem.shape[0] - 1)
col_indexer = min(max(col + j, 0), dem.shape[1] - 1)
elif edge_method_n == 1:
row_indexer = (row + k) % dem.shape[0]
col_indexer = (col + j) % dem.shape[1]
else:
row_indexer = row + k
col_indexer = col + j
Z[count] = dem[row_indexer, col_indexer]
count += 1
# Get a mask of all invalid (nan or inf) values.
invalids = ~np.isfinite(Z)
# Skip the pixel if it and all of its neighbours are invalid
if np.all(invalids):
continue
if np.count_nonzero(invalids) > 0:
if fill_method_n == 0:
# Fill all non-finite values with the most common value.
Z[invalids] = np.nanmedian(Z)
elif fill_method_n == 1:
# Fill all non-finite values with the mean.
Z[invalids] = np.nanmean(Z)
elif fill_method_n == 2:
# Skip the pixel if any of its neighbours are nan.
continue
else:
# This should not occur.
pass
# Difference pixels between specific cells: only useful for Terrain Ruggedness Index
count = 0
index_middle_pixel = int((window_size**2 - 1) / 2)
S = np.empty((window_size**2,))
for _j in range(-hw, -hw + window_size):
for _k in range(-hw, -hw + window_size):
S[count] = np.abs(Z[count] - Z[index_middle_pixel])
count += 1
if make_fractal_roughness:
# Fractal roughness computation according to the box-counting method of Taud and Parrot (2005)
# First, we compute the number of voxels for each pixel of Equation 4
count = 0
V = np.empty((window_size, window_size))
for j in range(-hw, -hw + window_size):
for k in range(-hw, -hw + window_size):
T = Z[count] - Z[index_middle_pixel]
# The following is the equivalent of np.clip, written like this for numba
if T < 0:
V[hw + j, hw + k] = 0
elif T > window_size:
V[hw + j, hw + k] = window_size
else:
V[hw + j, hw + k] = T
count += 1
# Then, we compute the maximum number of voxels for varying box splitting of the cube of side the window
# size, following Equation 5
# Get all the divisors of the half window size
list_box_sizes = []
for j in range(1, hw + 1):
if hw % j == 0:
list_box_sizes.append(j)
Ns = np.empty((len(list_box_sizes),))
for l0 in range(0, len(list_box_sizes)):
# We loop over boxes of size q x q in the cube
q = list_box_sizes[l0]
sumNs = 0
for j in range(0, int((window_size - 1) / q)):
for k in range(0, int((window_size - 1) / q)):
sumNs += np.max(V[slice(j * q, (j + 1) * q), slice(k * q, (k + 1) * q)].flatten())
Ns[l0] = sumNs / q
# Finally, we calculate the slope of the logarithm of Ns with q
# We do the linear regression manually, as np.polyfit is not supported by numba
x = np.log(np.array(list_box_sizes))
y = np.log(Ns)
# The number of observations
n = len(x)
# Mean of x and y vector
m_x = np.mean(x)
m_y = np.mean(y)
# Cross-deviation and deviation about x
SS_xy = np.sum(y * x) - n * m_y * m_x
SS_xx = np.sum(x * x) - n * m_x * m_x
# Calculating slope
b_1 = SS_xy / SS_xx
# The fractal dimension D is the opposite of the slope
D = -b_1
# First output is the Terrain Ruggedness Index from Riley et al. (1999): squareroot of squared sum of
# differences between center and neighbouring pixels
output[0, row, col] = np.sqrt(np.sum(S**2))
# Second output is the Terrain Ruggedness Index from Wilson et al. (2007): mean difference between center
# and neighbouring pixels
output[1, row, col] = np.sum(S) / (window_size**2 - 1)
# Third output is the Topographic Position Index from Weiss (2001): difference between center and mean of
# neighbouring pixels
output[2, row, col] = Z[index_middle_pixel] - (np.sum(Z) - Z[index_middle_pixel]) / (window_size**2 - 1)
# Fourth output is the Roughness from Dartnell (2000): difference between maximum and minimum of the window
output[3, row, col] = np.max(Z) - np.min(Z)
if make_fractal_roughness:
# Fifth output is the Fractal Roughness from Taud et Parrot (2005): estimate of the fractal dimension
# based on volume box-counting
output[4, row, col] = D
return output
def get_windowed_indexes(
dem: NDArrayf,
fill_method: str = "none",
edge_method: str = "none",
window_size: int = 3,
make_fractal_roughness: bool = False,
) -> NDArrayf:
"""
Return terrain indexes based on a windowed calculation of variable size, independent of the resolution.
Includes:
- Terrain Ruggedness Index from Riley et al. (1999),
http://download.osgeo.org/qgis/doc/reference-docs/Terrain_Ruggedness_Index.pdf, for topography and from Wilson
et al. (2007), http://dx.doi.org/10.1080/01490410701295962, for bathymetry.
- Topographic Position Index from Weiss (2001), http://www.jennessent.com/downloads/TPI-poster-TNC_18x22.pdf.
- Roughness from Dartnell (2000), http://dx.doi.org/10.14358/PERS.70.9.1081.
- Fractal roughness from Taud et Parrot (2005), https://doi.org/10.4000/geomorphologie.622.
Nearly all are also referenced in Wilson et al. (2007).
Where Z is the elevation, x is the distance from left-right and y is the distance from top-bottom.
Each pixel's index can be accessed at [:, row, col], returning an array of shape 4.
Fill methods
If the 3x3 matrix to fit the quadric function on has NaNs, these need to be handled:
* 'median': NaNs are filled with the median value of the matrix.
* 'mean': NaNs are filled with the mean value of the matrix.
* 'none': If NaNs are encountered, skip the entire cell (default for GDAL and SAGA).
Edge methods
Each iteration requires a 3x3 matrix, so special edge cases have to be made.
* 'nearest': Pixels outside the range are filled using the closest pixel value.
* 'wrap': The array is wrapped so pixels near the right edge will be sampled from the left, etc.
* 'none': Edges will not be analyzed, leaving a 1 pixel edge of NaNs.
Quirks:
* Edges are naively treated by filling the closest value, so that a 3x3 matrix is always calculated.\
It may therefore be slightly off in the edges.
* NaNs and infs are filled with the median of the finites in the matrix, possibly affecting the fit.
* The X and Y resolution needs to be the same. It does not work if they differ.
:param dem: The 2D DEM to be analyzed (3D DEMs of shape (1, row, col) are not supported).
:param fill_method: Fill method to use for NaNs in the 3x3 matrix.
:param edge_method: The method to use near the array edge.
:param window_size: The size of the window.
:param make_fractal_roughness: Whether to compute fractal roughness.
:raises ValueError: If the inputs are poorly formatted.
:raises RuntimeError: If unexpected backend errors occurred.
:examples:
>>> dem = np.array([[1, 1, 1],
... [1, 2, 1],
... [1, 1, 1]], dtype="float32")
>>> indexes = get_windowed_indexes(dem)
>>> indexes.shape
(5, 3, 3)
>>> indexes[:4, 1, 1]
array([2.82842712, 1. , 1. , 1. ])
:returns: An array of coefficients for each pixel of shape (5, row, col).
"""
# This function only formats and validates the inputs. For the true functionality, see _get_quadric_coefficients()
dem_arr = gu.raster.get_array_and_mask(dem)[0]
if len(dem_arr.shape) != 2:
raise ValueError(
f"Invalid input array shape: {dem.shape}, parsed into {dem_arr.shape}. "
"Expected 2D array or 3D array of shape (1, row, col)"
)
if any(dim < 3 for dim in dem_arr.shape):
raise ValueError(f"DEM (shape: {dem.shape}) is too small. Smallest supported shape is (3, 3)")
if not isinstance(window_size, (int, np.integer)) or window_size % 2 != 1:
raise ValueError("Window size must be an odd integer.")
allowed_fill_methods = ["median", "mean", "none"]
allowed_edge_methods = ["nearest", "wrap", "none"]
for value, name, allowed in zip(
[fill_method, edge_method], ["fill", "edge"], (allowed_fill_methods, allowed_edge_methods)
):
if value.lower() not in allowed:
raise ValueError(f"Invalid {name} method: '{value}'. Choices: {allowed}")
# Try to run the numba JIT code. It should never fail at this point, so if it does, it should be reported!
try:
indexes = _get_windowed_indexes(
dem_arr,
fill_method=fill_method.lower(),
edge_method=edge_method.lower(),
window_size=window_size,
make_fractal_roughness=make_fractal_roughness,
)
except Exception as exception:
raise RuntimeError("Unhandled numba exception. Please raise an issue of what happened.") from exception
return indexes
@overload
def get_terrain_attribute(
dem: NDArrayf | MArrayf,
attribute: str,
resolution: tuple[float, float] | float | None = None,
degrees: bool = True,
hillshade_altitude: float = 45.0,
hillshade_azimuth: float = 315.0,
hillshade_z_factor: float = 1.0,
slope_method: str = "Horn",
tri_method: str = "Riley",
fill_method: str = "none",
edge_method: str = "none",
use_richdem: bool = False,
window_size: int = 3,
) -> NDArrayf:
...
@overload
def get_terrain_attribute(
dem: NDArrayf | MArrayf,
attribute: list[str],
resolution: tuple[float, float] | float | None = None,
degrees: bool = True,
hillshade_altitude: float = 45.0,
hillshade_azimuth: float = 315.0,
hillshade_z_factor: float = 1.0,
slope_method: str = "Horn",
tri_method: str = "Riley",
fill_method: str = "none",
edge_method: str = "none",
use_richdem: bool = False,
window_size: int = 3,
) -> list[NDArrayf]:
...
@overload
def get_terrain_attribute(
dem: RasterType,
attribute: list[str],
resolution: tuple[float, float] | float | None = None,
degrees: bool = True,
hillshade_altitude: float = 45.0,
hillshade_azimuth: float = 315.0,
hillshade_z_factor: float = 1.0,
slope_method: str = "Horn",
tri_method: str = "Riley",
fill_method: str = "none",
edge_method: str = "none",
use_richdem: bool = False,
window_size: int = 3,
) -> list[RasterType]:
...
@overload
def get_terrain_attribute(
dem: RasterType,
attribute: str,
resolution: tuple[float, float] | float | None = None,
degrees: bool = True,
hillshade_altitude: float = 45.0,
hillshade_azimuth: float = 315.0,
hillshade_z_factor: float = 1.0,
slope_method: str = "Horn",
tri_method: str = "Riley",
fill_method: str = "none",
edge_method: str = "none",
use_richdem: bool = False,
window_size: int = 3,
) -> RasterType:
...
def get_terrain_attribute(
dem: NDArrayf | MArrayf | RasterType,
attribute: str | list[str],
resolution: tuple[float, float] | float | None = None,
degrees: bool = True,
hillshade_altitude: float = 45.0,
hillshade_azimuth: float = 315.0,
hillshade_z_factor: float = 1.0,
slope_method: str = "Horn",
tri_method: str = "Riley",
fill_method: str = "none",
edge_method: str = "none",
use_richdem: bool = False,
window_size: int = 3,
) -> NDArrayf | list[NDArrayf] | RasterType | list[RasterType]:
"""
Derive one or multiple terrain attributes from a DEM.
The attributes are based on:
- Slope, aspect, hillshade (first method) from Horn (1981), http://dx.doi.org/10.1109/PROC.1981.11918,
- Slope, aspect, hillshade (second method), and terrain curvatures from Zevenbergen and Thorne (1987),
http://dx.doi.org/10.1002/esp.3290120107.
- Topographic Position Index from Weiss (2001), http://www.jennessent.com/downloads/TPI-poster-TNC_18x22.pdf.
- Terrain Ruggedness Index (topography) from Riley et al. (1999),
http://download.osgeo.org/qgis/doc/reference-docs/Terrain_Ruggedness_Index.pdf.
- Terrain Ruggedness Index (bathymetry) from Wilson et al. (2007), http://dx.doi.org/10.1080/01490410701295962.
- Roughness from Dartnell (2000), http://dx.doi.org/10.14358/PERS.70.9.1081.
- Rugosity from Jenness (2004), https://doi.org/10.2193/0091-7648(2004)032[0829:CLSAFD]2.0.CO;2.
- Fractal roughness from Taud et Parrot (2005), https://doi.org/10.4000/geomorphologie.622.
Aspect and hillshade are derived using the slope, and thus depend on the same method.
More details on the equations in the functions get_quadric_coefficients() and get_windowed_indexes().
The slope, aspect ("Horn" method), and all curvatures ("ZevenbergThorne" method) can also be derived using RichDEM.
Attributes:
* 'slope': The slope in degrees or radians (degs: 0=flat, 90=vertical). Default method: "Horn".
* 'aspect': The slope aspect in degrees or radians (degs: 0=N, 90=E, 180=S, 270=W).
* 'hillshade': The shaded slope in relation to its aspect.
* 'curvature': The second derivative of elevation (the rate of slope change per pixel), multiplied by 100.
* 'planform_curvature': The curvature perpendicular to the direction of the slope, multiplied by 100.
* 'profile_curvature': The curvature parallel to the direction of the slope, multiplied by 100.
* 'maximum_curvature': The maximum curvature.
* 'surface_fit': A quadric surface fit for each individual pixel.
* 'topographic_position_index': The topographic position index defined by a difference to the average of
neighbouring pixels.
* 'terrain_ruggedness_index': The terrain ruggedness index. For topography, defined by the squareroot of squared
differences to neighbouring pixels. For bathymetry, defined by the mean absolute difference to neighbouring
pixels. Default method: "Riley" (topography).
* 'roughness': The roughness, i.e. maximum difference between neighbouring pixels.
* 'rugosity': The rugosity, i.e. difference between real and planimetric surface area.
* 'fractal_roughness': The roughness based on a volume box-counting estimate of the fractal dimension.
:param dem: The DEM to analyze.
:param attribute: The terrain attribute(s) to calculate.
:param resolution: The X/Y or (X, Y) resolution of the DEM.
:param degrees: Convert radians to degrees?
:param hillshade_altitude: The shading altitude in degrees (0-90°). 90° is straight from above.
:param hillshade_azimuth: The shading azimuth in degrees (0-360°) going clockwise, starting from north.
:param hillshade_z_factor: Vertical exaggeration factor.
:param slope_method: Method to calculate the slope, aspect and hillshade: "Horn" or "ZevenbergThorne".
:param tri_method: Method to calculate the Terrain Ruggedness Index: "Riley" (topography) or "Wilson" (bathymetry).
:param fill_method: See the 'get_quadric_coefficients()' docstring for information.
:param edge_method: See the 'get_quadric_coefficients()' docstring for information.
:param use_richdem: Whether to use richDEM for slope, aspect and curvature calculations.
:param window_size: The window size for windowed ruggedness and roughness indexes.
:raises ValueError: If the inputs are poorly formatted or are invalid.
:examples:
>>> dem = np.repeat(np.arange(3), 3).reshape(3, 3)
>>> dem
array([[0, 0, 0],
[1, 1, 1],
[2, 2, 2]])
>>> slope, aspect = get_terrain_attribute(dem, ["slope", "aspect"], resolution=1, edge_method='nearest')
>>> slope # Note the flattening edge effect; see 'get_quadric_coefficients()' for more.
array([[26.56505118, 26.56505118, 26.56505118],
[45. , 45. , 45. ],
[26.56505118, 26.56505118, 26.56505118]])
>>> aspect
array([[0., 0., 0.],
[0., 0., 0.],
[0., 0., 0.]])
:returns: One or multiple arrays of the requested attribute(s)
"""
if isinstance(dem, gu.Raster):
if resolution is None:
resolution = dem.res
# Validate and format the inputs
if isinstance(attribute, str):
attribute = [attribute]
# These require the get_quadric_coefficients() function, which require the same X/Y resolution.
list_requiring_surface_fit = [
"curvature",
"planform_curvature",
"profile_curvature",
"maximum_curvature",
"slope",
"hillshade",
"aspect",
"surface_fit",
"rugosity",
]
attributes_requiring_surface_fit = [attr for attr in attribute if attr in list_requiring_surface_fit]
if use_richdem:
if not _has_rd:
raise ValueError("Optional dependency needed. Install 'richdem'")
if ("slope" in attribute or "aspect" in attribute) and slope_method == "ZevenbergThorne":
raise ValueError("RichDEM can only compute the slope and aspect using the default method of Horn (1981)")
list_requiring_richdem = [
"slope",
"aspect",
"hillshade",
"curvature",
"planform_curvature",
"profile curvature",
"maximum_curvature",
]
attributes_using_richdem = [attr for attr in attribute if attr in list_requiring_richdem]
for attr in attributes_using_richdem:
attributes_requiring_surface_fit.remove(attr)
if not isinstance(dem, gu.Raster):
# Here, maybe we could pass the geotransform based on the resolution, and add a "default" projection as
# this is mandated but likely not used by the rdarray format of RichDEM...
# For now, not supported
raise ValueError("To derive RichDEM attributes, the DEM passed must be a Raster object")
list_requiring_windowed_index = [
"terrain_ruggedness_index",
"topographic_position_index",
"roughness",
"fractal_roughness",
]
attributes_requiring_windowed_index = [attr for attr in attribute if attr in list_requiring_windowed_index]
if resolution is None and len(attributes_requiring_surface_fit) > 1:
raise ValueError(f"'resolution' must be provided as an argument for attributes: {list_requiring_surface_fit}")
choices = list_requiring_surface_fit + list_requiring_windowed_index
for attr in attribute:
if attr not in choices:
raise ValueError(f"Attribute '{attr}' is not supported. Choices: {choices}")
list_slope_methods = ["Horn", "ZevenbergThorne"]
if slope_method.lower() not in [sm.lower() for sm in list_slope_methods]:
raise ValueError(f"Slope method '{slope_method}' is not supported. Must be one of: {list_slope_methods}")
list_tri_methods = ["Riley", "Wilson"]
if tri_method.lower() not in [tm.lower() for tm in list_tri_methods]:
raise ValueError(f"TRI method '{tri_method}' is not supported. Must be one of: {list_tri_methods}")
if (hillshade_azimuth < 0.0) or (hillshade_azimuth > 360.0):
raise ValueError(f"Azimuth must be a value between 0 and 360 degrees (given value: {hillshade_azimuth})")
if (hillshade_altitude < 0.0) or (hillshade_altitude > 90):
raise ValueError("Altitude must be a value between 0 and 90 degrees (given value: {altitude})")
if (hillshade_z_factor < 0.0) or not np.isfinite(hillshade_z_factor):
raise ValueError(f"z_factor must be a non-negative finite value (given value: {hillshade_z_factor})")
# Initialize the terrain_attributes dictionary, which will be filled with the requested values.
terrain_attributes: dict[str, NDArrayf] = {}
# Check which products should be made to optimize the processing
make_aspect = any(attr in attribute for attr in ["aspect", "hillshade"])
make_slope = any(
attr in attribute
for attr in ["slope", "hillshade", "planform_curvature", "aspect", "profile_curvature", "maximum_curvature"]
)
make_hillshade = "hillshade" in attribute
make_surface_fit = len(attributes_requiring_surface_fit) > 0
make_curvature = "curvature" in attribute
make_planform_curvature = "planform_curvature" in attribute or "maximum_curvature" in attribute
make_profile_curvature = "profile_curvature" in attribute or "maximum_curvature" in attribute
make_maximum_curvature = "maximum_curvature" in attribute
make_windowed_index = len(attributes_requiring_windowed_index) > 0
make_topographic_position = "topographic_position_index" in attribute
make_terrain_ruggedness = "terrain_ruggedness_index" in attribute
make_roughness = "roughness" in attribute
make_rugosity = "rugosity" in attribute
make_fractal_roughness = "fractal_roughness" in attribute
# Get array of DEM
dem_arr = gu.raster.get_array_and_mask(dem)[0]
if make_surface_fit:
if not isinstance(resolution, Sized):
resolution = (float(resolution), float(resolution)) # type: ignore
if resolution[0] != resolution[1]:
raise ValueError(
f"Quadric surface fit requires the same X and Y resolution ({resolution} was given). "
f"This was required by: {attributes_requiring_surface_fit}"
)
terrain_attributes["surface_fit"] = get_quadric_coefficients(
dem=dem_arr,
resolution=resolution[0],
fill_method=fill_method,
edge_method=edge_method,
make_rugosity=make_rugosity,
)
if make_slope:
if use_richdem:
terrain_attributes["slope"] = _get_terrainattr_richdem(dem, attribute="slope_radians")
else:
if slope_method == "Horn":
# This calculation is based on page 18 (bottom left) and 20-21 of Horn (1981),
# http://dx.doi.org/10.1109/PROC.1981.11918.
terrain_attributes["slope"] = np.arctan(
(terrain_attributes["surface_fit"][9, :, :] ** 2 + terrain_attributes["surface_fit"][10, :, :] ** 2)
** 0.5
)
elif slope_method == "ZevenbergThorne":
# This calculation is based on Equation 13 of Zevenbergen and Thorne (1987),
# http://dx.doi.org/10.1002/esp.3290120107.
# SLOPE = ARCTAN((G²+H²)**(1/2))
terrain_attributes["slope"] = np.arctan(
(terrain_attributes["surface_fit"][6, :, :] ** 2 + terrain_attributes["surface_fit"][7, :, :] ** 2)
** 0.5
)
if make_aspect:
if use_richdem:
# The aspect of RichDEM is returned in degrees, we convert to radians to match the others
terrain_attributes["aspect"] = np.deg2rad(_get_terrainattr_richdem(dem, attribute="aspect"))
# For flat slopes, RichDEM returns a 90° aspect by default, while GDAL return a 180° aspect
# We stay consistent with GDAL
slope_tmp = _get_terrainattr_richdem(dem, attribute="slope_radians")
terrain_attributes["aspect"][slope_tmp == 0] = np.pi
else:
# ASPECT = ARCTAN(-H/-G) # This did not work
# ASPECT = (ARCTAN2(-G, H) + 0.5PI) % 2PI did work.
with warnings.catch_warnings():
warnings.filterwarnings("ignore", "invalid value encountered in remainder")
if slope_method == "Horn":
# This uses the estimates from Horn (1981).
terrain_attributes["aspect"] = (
-np.arctan2(
-terrain_attributes["surface_fit"][9, :, :], terrain_attributes["surface_fit"][10, :, :]
)
- np.pi
) % (2 * np.pi)
elif slope_method == "ZevenbergThorne":
# This uses the slope estimate from Zevenbergen and Thorne (1987).
terrain_attributes["aspect"] = (
np.arctan2(
-terrain_attributes["surface_fit"][6, :, :], terrain_attributes["surface_fit"][7, :, :]
)
+ np.pi / 2
) % (2 * np.pi)
if make_hillshade:
# If a different z-factor was given, slopemap with exaggerated gradients.
if hillshade_z_factor != 1.0:
slopemap = np.arctan(np.tan(terrain_attributes["slope"]) * hillshade_z_factor)
else:
slopemap = terrain_attributes["slope"]
azimuth_rad = np.deg2rad(360 - hillshade_azimuth)
altitude_rad = np.deg2rad(hillshade_altitude)
# The operation below yielded the closest hillshade to GDAL (multiplying by 255 did not work)
# As 0 is generally no data for this uint8, we add 1 and then 0.5 for the rounding to occur between 1 and 255
terrain_attributes["hillshade"] = np.clip(
1.5
+ 254
* (
np.sin(altitude_rad) * np.cos(slopemap)
+ np.cos(altitude_rad) * np.sin(slopemap) * np.sin(azimuth_rad - terrain_attributes["aspect"])
),
0,
255,
).astype("float32")
if make_curvature:
if use_richdem:
terrain_attributes["curvature"] = _get_terrainattr_richdem(dem, attribute="curvature")
else:
# Curvature is the second derivative of the surface fit equation.
# (URL in get_quadric_coefficients() docstring)
# Curvature = -2(D + E) * 100
terrain_attributes["curvature"] = (
-2 * (terrain_attributes["surface_fit"][3, :, :] + terrain_attributes["surface_fit"][4, :, :]) * 100
)