Skip to content

Commit 4d99e21

Browse files
authored
Improve performance of terrain attributes and add SciPy engine support (#486)
1 parent 1514240 commit 4d99e21

File tree

7 files changed

+1355
-868
lines changed

7 files changed

+1355
-868
lines changed

.coveragerc

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,7 @@ exclude_lines =
33
pragma: not covered
44
@overload
55
except ImportError
6+
@numba.jit
7+
@jit
8+
@numba.njit
9+
@njit

doc/source/citation.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ More details are available on each feature page!
2525
* - Slope, aspect and hillshade
2626
- [Horn (1981)](http://dx.doi.org/10.1109/PROC.1981.11918) or [Zevenbergen and Thorne (1987)](http://dx.doi.org/10.1002/esp.3290120107)
2727
* - Curvatures
28-
- [Zevenbergen and Thorne (1987)](http://dx.doi.org/10.1002/esp.3290120107)
28+
- [Zevenbergen and Thorne (1987)](http://dx.doi.org/10.1002/esp.3290120107) and [Moore et al. (1991)](https://doi.org/10.1002/hyp.3360050103)
2929
* - Topographic position index
3030
- [Weiss (2001)](http://www.jennessent.com/downloads/TPI-poster-TNC_18x22.pdf)
3131
* - Terrain ruggedness index

doc/source/terrain.md

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -54,12 +54,14 @@ slope = dem.slope()
5454
slope = xdem.terrain.slope(dem.data, resolution=dem.res)
5555
```
5656

57-
:::{admonition} Coming soon
58-
:class: note
59-
60-
We are working on further optimizing the computational performance of certain terrain attributes using convolution.
61-
:::
57+
```{tip}
58+
All attributes can be derived using either SciPy or Numba as computing engine. Both engines perform similarly for attributes
59+
based on a surface fit (e.g., slope, aspect, curvatures), while Numba is much faster for windowed indexes (e.g., TPI, roughness).
6260
61+
Note that Numba requires a [just-in-time compilation](https://numba.readthedocs.io/en/stable/reference/jit-compilation.html)
62+
at the first execution of an attribute (usually lasting about 5 seconds). This
63+
compilation is cached and can later be re-used in the same Python environment.
64+
```
6365

6466
## Summary of supported methods
6567

@@ -82,7 +84,7 @@ We are working on further optimizing the computational performance of certain te
8284
- [Horn (1981)](http://dx.doi.org/10.1109/PROC.1981.11918) or [Zevenbergen and Thorne (1987)](http://dx.doi.org/10.1002/esp.3290120107)
8385
* - {ref}`curv`
8486
- Meters{sup}`-1` * 100
85-
- [Zevenbergen and Thorne (1987)](http://dx.doi.org/10.1002/esp.3290120107)
87+
- [Zevenbergen and Thorne (1987)](http://dx.doi.org/10.1002/esp.3290120107) and [Moore et al. (1991)](https://doi.org/10.1002/hyp.3360050103)
8688
* - {ref}`plancurv`
8789
- Meters{sup}`-1` * 100
8890
- [Zevenbergen and Thorne (1987)](http://dx.doi.org/10.1002/esp.3290120107)
@@ -240,7 +242,8 @@ If a surface is convex (like a mountain peak), it will have positive curvature.
240242
If a surface is concave (like a through or a valley bottom), it will have negative curvature.
241243
The curvature values in units of m{sup}`-1` are quite small, so they are by convention multiplied by 100.
242244

243-
The curvature $c$ is based on the method of [Zevenbergen and Thorne (1987)](http://dx.doi.org/10.1002/esp.3290120107).
245+
The curvature $c$ is based on the method of [Zevenbergen and Thorne (1987)](http://dx.doi.org/10.1002/esp.3290120107)
246+
expanded to compute the surface laplacian in [Moore et al. (1991)](https://doi.org/10.1002/hyp.3360050103).
244247

245248
$$
246249

tests/test_terrain.py

Lines changed: 164 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,18 @@
33
import os.path
44
import re
55
import warnings
6+
from typing import Literal
67

78
import geoutils as gu
89
import numpy as np
910
import pytest
11+
import rasterio as rio
1012
from geoutils.raster.distributed_computing import MultiprocConfig
13+
from pyproj import CRS
1114

1215
import xdem
1316

14-
xdem.examples.download_longyearbyen_examples()
15-
16-
PLOT = True
17+
PLOT = False
1718

1819

1920
class TestTerrainAttribute:
@@ -201,19 +202,6 @@ def test_attribute_functions_against_richdem(self, attribute: str, get_test_data
201202
# output = functions_richdem[attribute](dem)
202203
# assert np.all(dem.data.mask == output.data.mask)
203204

204-
def test_hillshade_errors(self) -> None:
205-
"""Validate that the hillshade function raises appropriate errors."""
206-
# Try giving the hillshade invalid arguments.
207-
208-
with pytest.raises(ValueError, match="Azimuth must be a value between 0 and 360"):
209-
xdem.terrain.hillshade(self.dem.data, resolution=self.dem.res, azimuth=361)
210-
211-
with pytest.raises(ValueError, match="Altitude must be a value between 0 and 90"):
212-
xdem.terrain.hillshade(self.dem.data, resolution=self.dem.res, altitude=91)
213-
214-
with pytest.raises(ValueError, match="z_factor must be a non-negative finite value"):
215-
xdem.terrain.hillshade(self.dem.data, resolution=self.dem.res, z_factor=np.inf)
216-
217205
def test_hillshade(self) -> None:
218206
"""Test hillshade-specific settings."""
219207

@@ -229,6 +217,19 @@ def test_hillshade(self) -> None:
229217
# A low altitude should be darker than a high altitude.
230218
assert np.nanmean(low_altitude) < np.nanmean(high_altitude)
231219

220+
def test_hillshade__errors(self) -> None:
221+
"""Validate that the hillshade function raises appropriate errors."""
222+
# Try giving the hillshade invalid arguments.
223+
224+
with pytest.raises(ValueError, match="Azimuth must be a value between 0 and 360"):
225+
xdem.terrain.hillshade(self.dem.data, resolution=self.dem.res, azimuth=361)
226+
227+
with pytest.raises(ValueError, match="Altitude must be a value between 0 and 90"):
228+
xdem.terrain.hillshade(self.dem.data, resolution=self.dem.res, altitude=91)
229+
230+
with pytest.raises(ValueError, match="z_factor must be a non-negative finite value"):
231+
xdem.terrain.hillshade(self.dem.data, resolution=self.dem.res, z_factor=np.inf)
232+
232233
@pytest.mark.parametrize(
233234
"name", ["curvature", "planform_curvature", "profile_curvature", "maximum_curvature"]
234235
) # type: ignore
@@ -239,21 +240,25 @@ def test_curvatures(self, name: str) -> None:
239240
dem = self.dem.copy()
240241

241242
# Derive curvature without any gaps
242-
curvature = xdem.terrain.get_terrain_attribute(
243-
dem.data, attribute=name, resolution=dem.res, edge_method="nearest"
244-
)
243+
curvature = xdem.terrain.get_terrain_attribute(dem.data, attribute=name, resolution=dem.res)
245244

246-
# Validate that the array has the same shape as the input and that all values are finite.
245+
# Validate that the array has the same shape as the input and that all non-edge values are finite.
247246
assert curvature.shape == dem.data.shape
248247
try:
249-
assert np.all(np.isfinite(curvature))
248+
assert np.all(np.isfinite(curvature[1:-1, 1:-1]))
250249
except Exception:
251250
import matplotlib.pyplot as plt
252251

253252
plt.imshow(curvature.squeeze())
254253
plt.show()
255254

256-
with pytest.raises(ValueError, match="Quadric surface fit requires the same X and Y resolution."):
255+
with pytest.raises(
256+
ValueError,
257+
match=re.escape(
258+
f"Surface fit and rugosity require the same X and Y resolution "
259+
f"((1.0, 2.0) was given). This was required by: ['{name}']."
260+
),
261+
):
257262
xdem.terrain.get_terrain_attribute(dem.data, attribute=name, resolution=(1.0, 2.0))
258263

259264
# Introduce some nans
@@ -263,7 +268,7 @@ def test_curvatures(self, name: str) -> None:
263268
# Validate that this doesn't raise weird warnings after introducing nans.
264269
xdem.terrain.get_terrain_attribute(dem.data, attribute=name, resolution=dem.res)
265270

266-
def test_get_terrain_attribute(self) -> None:
271+
def test_get_terrain_attribute__multiple_inputs(self) -> None:
267272
"""Test the get_terrain_attribute function by itself."""
268273

269274
# Validate that giving only one terrain attribute only returns that, and not a list of len() == 1
@@ -286,7 +291,7 @@ def test_get_terrain_attribute(self) -> None:
286291
slope_lowres = xdem.terrain.get_terrain_attribute(self.dem.data, "slope", resolution=self.dem.res[0] * 2)
287292
assert np.nanmean(slope) > np.nanmean(slope_lowres)
288293

289-
def test_get_terrain_attribute_multiproc(self) -> None:
294+
def test_get_terrain_attribute__multiproc(self) -> None:
290295
"""Test the get_terrain attribute function in multiprocessing."""
291296
outfile = "mp_output.tif"
292297
outfile_multi = ["mp_output_slope.tif", "mp_output_aspect.tif", "mp_output_hillshade.tif"]
@@ -336,7 +341,7 @@ def test_get_terrain_attribute_multiproc(self) -> None:
336341
os.remove(outfile)
337342
assert slope.get_stats("mean") > slope_lowres.get_stats("mean")
338343

339-
def test_get_terrain_attribute_errors(self) -> None:
344+
def test_get_terrain_attribute__errors(self) -> None:
340345
"""Test the get_terrain_attribute function raises appropriate errors."""
341346

342347
# Below, re.escape() is needed to match expressions that have special characters (e.g., parenthesis, bracket)
@@ -346,15 +351,25 @@ def test_get_terrain_attribute_errors(self) -> None:
346351
"Slope method 'DoesNotExist' is not supported. Must be one of: " "['Horn', 'ZevenbergThorne']"
347352
),
348353
):
349-
xdem.terrain.slope(self.dem.data, method="DoesNotExist")
354+
xdem.terrain.slope(self.dem.data, resolution=self.dem.res, method="DoesNotExist") # type: ignore
350355

351356
with pytest.raises(
352357
ValueError,
353358
match=re.escape("TRI method 'DoesNotExist' is not supported. Must be one of: " "['Riley', 'Wilson']"),
354359
):
355-
xdem.terrain.terrain_ruggedness_index(self.dem.data, method="DoesNotExist")
360+
xdem.terrain.terrain_ruggedness_index(self.dem.data, method="DoesNotExist") # type: ignore
356361

357-
def test_raster_argument(self) -> None:
362+
# Check warning for geographic CRS
363+
data = np.ones((5, 5))
364+
transform = rio.transform.from_bounds(0, 0, 1, 1, 5, 5)
365+
crs = CRS("EPSG:4326")
366+
nodata = -9999
367+
dem = xdem.DEM.from_array(data, transform=transform, crs=crs, nodata=nodata)
368+
with pytest.warns(match="DEM is not in a projected CRS.*"):
369+
xdem.terrain.get_terrain_attribute(dem, "slope")
370+
371+
def test_get_terrain_attribute__raster_input(self) -> None:
372+
"""Test the get_terrain_attribute function supports raster input/output."""
358373

359374
slope, aspect = xdem.terrain.get_terrain_attribute(self.dem, attribute=["slope", "aspect"])
360375

@@ -384,9 +399,9 @@ def test_rugosity_jenness(self) -> None:
384399
assert rugosity[1, 1] == pytest.approx(r, rel=10 ** (-4))
385400

386401
# Loop for various elevation differences with the center
387-
@pytest.mark.parametrize("dh", np.linspace(0.01, 100, 10)) # type: ignore
402+
@pytest.mark.parametrize("dh", np.linspace(0.01, 100, 3)) # type: ignore
388403
# Loop for different resolutions
389-
@pytest.mark.parametrize("resolution", np.linspace(0.01, 100, 10)) # type: ignore
404+
@pytest.mark.parametrize("resolution", np.linspace(0.01, 100, 3)) # type: ignore
390405
def test_rugosity_simple_cases(self, dh: float, resolution: float) -> None:
391406
"""Test the rugosity calculation for simple cases."""
392407

@@ -415,36 +430,134 @@ def test_rugosity_simple_cases(self, dh: float, resolution: float) -> None:
415430
# Check rugosity value is valid
416431
assert r == pytest.approx(rugosity[1, 1], rel=10 ** (-6))
417432

418-
def test_get_quadric_coefficients(self) -> None:
419-
"""Test the outputs and exceptions of the get_quadric_coefficients() function."""
433+
def test_fractal_roughness(self) -> None:
434+
"""Test fractal roughness for synthetic cases for which we know the output."""
435+
436+
# The fractal dimension of a line is 1 (a single pixel with non-zero value)
437+
dem = np.zeros((13, 13), dtype="float32")
438+
dem[1, 1] = 6.5
439+
frac_rough = xdem.terrain.fractal_roughness(dem)
440+
assert np.round(frac_rough[6, 6], 5) == np.float32(1.0)
441+
442+
# The fractal dimension of plane is 2 (a plan of pixels with non-zero values)
443+
dem = np.zeros((13, 13), dtype="float32")
444+
dem[:, 1] = 13
445+
frac_rough = xdem.terrain.fractal_roughness(dem)
446+
assert np.round(frac_rough[6, 6]) == np.float32(2.0)
447+
448+
# The fractal dimension of a cube is 3 (a block of pixels with non-zero values
449+
dem = np.zeros((13, 13), dtype="float32")
450+
dem[:, :6] = 13
451+
frac_rough = xdem.terrain.fractal_roughness(dem)
452+
assert np.round(frac_rough[6, 6]) == np.float32(3.0)
453+
454+
def test_convolution__quadric_coefficients(self) -> None:
455+
"""Test the outputs of quadric coefficients (not accessible by users)."""
420456

421457
dem = np.array([[1, 1, 1], [1, 2, 1], [1, 1, 1]], dtype="float32")
422458

423-
coefficients = xdem.terrain.get_quadric_coefficients(
424-
dem, resolution=1.0, edge_method="nearest", make_rugosity=True
425-
)
459+
# Get all coefficients and convolve middle mixel
460+
coef_arrs = list(xdem.terrain.all_coefs.values())
461+
coef_names = list(xdem.terrain.all_coefs.keys())
462+
kern3d = np.stack(coef_arrs, axis=0)
463+
coefs = xdem.spatialstats.convolution(
464+
dem.reshape((1, dem.shape[0], dem.shape[1])), filters=kern3d, method="scipy"
465+
).squeeze()[:, 1, 1]
466+
467+
# The 4th to last coefficient is identity, so the dem itself
468+
assert np.array_equal(coefs[coef_names.index("zt_i")], dem[1, 1])
469+
470+
# The third should be concave in the x-direction
471+
assert coefs[coef_names.index("zt_d")] < 0
472+
473+
# The fourth should be concave in the y-direction
474+
assert coefs[coef_names.index("zt_e")] < 0
475+
476+
def test_convolution_equal__engine(self) -> None:
477+
"""
478+
Check that convolution through SciPy or Numba give equal result for all kernels.
479+
This calls the convolution subfunctions directly (as they need to be chained sequentially with other
480+
steps in the main functions).
481+
"""
482+
483+
# Stack to convolve all coefs at once
484+
coef_arrs = list(xdem.terrain.all_coefs.values())
485+
kern3d = np.stack(coef_arrs, axis=0)
486+
487+
rnd = np.random.default_rng(42)
488+
dem = rnd.normal(size=(5, 7))
489+
490+
# With SciPy
491+
conv_scipy = xdem.spatialstats.convolution(
492+
dem.reshape((1, dem.shape[0], dem.shape[1])), filters=kern3d, method="scipy"
493+
).squeeze()[:, 3, 3]
426494

427-
# Check all coefficients are finite with an edge method
428-
assert np.all(np.isfinite(coefficients))
495+
# With Numba
496+
_, M1, M2 = kern3d.shape
497+
half_M1 = int((M1 - 1) / 2)
498+
half_M2 = int((M2 - 1) / 2)
499+
dem = np.pad(dem, pad_width=((half_M1, half_M1), (half_M2, half_M2)), constant_values=np.nan)
500+
conv_numba = xdem.terrain._convolution_numba(dem, filters=kern3d, row=3, col=3)
429501

430-
# The 4th to last coefficient is the dem itself (could maybe be removed in the future as it is duplication..)
431-
assert np.array_equal(coefficients[-4, :, :], dem)
502+
np.allclose(conv_scipy, conv_numba, equal_nan=True)
432503

433-
# The middle pixel (index 1, 1) should be concave in the x-direction
434-
assert coefficients[3, 1, 1] < 0
504+
@pytest.mark.parametrize(
505+
"attribute",
506+
["slope", "aspect", "hillshade", "curvature", "profile_curvature", "planform_curvature", "maximum_curvature"],
507+
) # type: ignore
508+
@pytest.mark.parametrize("slope_method", ["Horn", "ZevenbergThorne"]) # type: ignore
509+
def test_get_surface_attributes__engine(
510+
self, attribute: str, slope_method: Literal["Horn", "ZevenbergThorne"]
511+
) -> None:
512+
"""Check that all quadric coefficients from the convolution give the same results as with the numba loop."""
513+
514+
rnd = np.random.default_rng(42)
515+
dem = rnd.normal(size=(5, 7))
435516

436-
# The middle pixel (index 1, 1) should be concave in the y-direction
437-
assert coefficients[4, 1, 1] < 0
517+
attrs_scipy = xdem.terrain._get_surface_attributes(
518+
dem=dem, resolution=2, surface_attributes=[attribute], slope_method=slope_method, engine="scipy"
519+
)
520+
attrs_numba = xdem.terrain._get_surface_attributes(
521+
dem=dem, resolution=2, surface_attributes=[attribute], slope_method=slope_method, engine="numba"
522+
)
438523

439-
with pytest.raises(ValueError, match="Invalid input array shape"):
440-
xdem.terrain.get_quadric_coefficients(dem.reshape((1, 1, -1)), 1.0)
524+
assert np.allclose(attrs_scipy, attrs_numba, equal_nan=True)
525+
# assert np.allclose(coefs_numba, coefs_numba_cv, equal_nan=True)
526+
527+
@pytest.mark.parametrize(
528+
"attribute",
529+
[
530+
"topographic_position_index",
531+
"terrain_ruggedness_index_Riley",
532+
"terrain_ruggedness_index_Wilson",
533+
"roughness",
534+
"rugosity",
535+
"fractal_roughness",
536+
],
537+
) # type: ignore
538+
def test_get_windowed_indices__engine(self, attribute: str) -> None:
539+
"""Check that all quadric coefficients from the convolution give the same results as with the numba loop."""
540+
541+
rnd = np.random.default_rng(42)
542+
dem = rnd.normal(size=(15, 15))
543+
544+
# Get TRI method if specified
545+
if "Wilson" in attribute or "Riley" in attribute:
546+
attribute = "terrain_ruggedness_index"
547+
tri_method: Literal["Riley", "Wilson"]
548+
tri_method = attribute.split("_")[-1] # type: ignore
549+
# Otherwise use any one, doesn't matter
550+
else:
551+
tri_method = "Wilson"
552+
553+
attrs_scipy = xdem.terrain._get_windowed_indexes(
554+
dem=dem, window_size=3, resolution=1, windowed_indexes=[attribute], tri_method=tri_method, engine="scipy"
555+
)
556+
attrs_numba = xdem.terrain._get_windowed_indexes(
557+
dem=dem, window_size=3, resolution=1, windowed_indexes=[attribute], tri_method=tri_method, engine="numba"
558+
)
441559

442-
# Validate that when using the edge_method="none", only the one non-edge value is kept.
443-
coefs = xdem.terrain.get_quadric_coefficients(dem, resolution=1.0, edge_method="none")
444-
assert np.count_nonzero(np.isfinite(coefs[0, :, :])) == 1
445-
# When using edge wrapping, all coefficients should be finite.
446-
coefs = xdem.terrain.get_quadric_coefficients(dem, resolution=1.0, edge_method="wrap")
447-
assert np.count_nonzero(np.isfinite(coefs[0, :, :])) == 9
560+
assert np.allclose(attrs_scipy, attrs_numba, equal_nan=True)
448561

449562
def test_get_terrain_attribute__out_dtype(self) -> None:
450563

0 commit comments

Comments
 (0)