Skip to content

Commit 5bde36e

Browse files
authored
Remove RichDEM from source, moved utility functions to conftest.py (#612)
1 parent 6384b5d commit 5bde36e

File tree

9 files changed

+257
-316
lines changed

9 files changed

+257
-316
lines changed

CONTRIBUTING.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ pytest
5050

5151
Running `pytest` will trigger a script that automatically downloads test data from [https://github.com/GlacioHack/xdem-data](https://github.com/GlacioHack/xdem-data) used to run all tests.
5252

53+
RichDEM should only be used for testing purposes within the xDEM project. The functionality of xDEM must not depend on RichDEM.
54+
5355
### Documentation
5456

5557
If your changes need to be reflected in the documentation, update the related pages located in `doc/source/`. The documentation is written in MyST markdown syntax, similar to GitHub's default Markdown (see [MyST-NB](https://myst-nb.readthedocs.io/en/latest/authoring/text-notebooks.html) for details).

dev-environment.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@ dependencies:
2020

2121
# Optional dependencies
2222
- pytransform3d
23-
- richdem
2423

2524
# Test dependencies
2625
- gdal # To test against GDAL
@@ -29,6 +28,7 @@ dependencies:
2928
- pyyaml
3029
- flake8
3130
- pylint
31+
- richdem
3232

3333
# Doc dependencies
3434
- sphinx

doc/source/terrain.md

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,7 @@ and tested for consistency against [gdaldem](https://gdal.org/programs/gdaldem.h
1010
## Quick use
1111

1212
Terrain attribute methods can either be called directly from a {class}`~xdem.DEM` (e.g., {func}`xdem.DEM.slope`) or
13-
through the {class}`~xdem.terrain` module which allows array input. If computational performance
14-
is key, xDEM can rely on [RichDEM](https://richdem.readthedocs.io/) by specifying `use_richdem=True` for speed-up
15-
of its supported attributes (slope, aspect, curvature).
13+
through the {class}`~xdem.terrain` module which allows array input.
1614

1715
## Slope
1816

examples/basic/temp.tif.aux.xml

Lines changed: 0 additions & 12 deletions
This file was deleted.

setup.cfg

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,14 +52,14 @@ opt =
5252
opencv
5353
openh264
5454
pytransform3d
55-
richdem
5655
noisyopt
5756
test =
5857
pytest
5958
pytest-xdist
6059
pyyaml
6160
flake8
6261
pylint
62+
richdem
6363
doc =
6464
sphinx
6565
sphinx-book-theme

tests/conftest.py

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
from typing import Callable, List, Union
2+
3+
import geoutils as gu
4+
import numpy as np
5+
import pytest
6+
import richdem as rd
7+
from geoutils.raster import RasterType
8+
9+
from xdem._typing import NDArrayf
10+
11+
12+
@pytest.fixture(scope="session") # type: ignore
13+
def raster_to_rda() -> Callable[[RasterType], rd.rdarray]:
14+
def _raster_to_rda(rst: RasterType) -> rd.rdarray:
15+
"""
16+
Convert geoutils.Raster to richDEM rdarray.
17+
"""
18+
arr = rst.data.filled(rst.nodata).squeeze()
19+
rda = rd.rdarray(arr, no_data=rst.nodata)
20+
rda.geotransform = rst.transform.to_gdal()
21+
return rda
22+
23+
return _raster_to_rda
24+
25+
26+
@pytest.fixture(scope="session") # type: ignore
27+
def get_terrainattr_richdem(raster_to_rda: Callable[[RasterType], rd.rdarray]) -> Callable[[RasterType, str], NDArrayf]:
28+
def _get_terrainattr_richdem(rst: RasterType, attribute: str = "slope_radians") -> NDArrayf:
29+
"""
30+
Derive terrain attribute for DEM opened with geoutils.Raster using RichDEM.
31+
"""
32+
rda = raster_to_rda(rst)
33+
terrattr = rd.TerrainAttribute(rda, attrib=attribute)
34+
terrattr[terrattr == terrattr.no_data] = np.nan
35+
return np.array(terrattr)
36+
37+
return _get_terrainattr_richdem
38+
39+
40+
@pytest.fixture(scope="session") # type: ignore
41+
def get_terrain_attribute_richdem(
42+
get_terrainattr_richdem: Callable[[RasterType, str], NDArrayf]
43+
) -> Callable[[RasterType, Union[str, list[str]], bool, float, float, float], Union[RasterType, list[RasterType]]]:
44+
def _get_terrain_attribute_richdem(
45+
dem: RasterType,
46+
attribute: Union[str, List[str]],
47+
degrees: bool = True,
48+
hillshade_altitude: float = 45.0,
49+
hillshade_azimuth: float = 315.0,
50+
hillshade_z_factor: float = 1.0,
51+
) -> Union[RasterType, List[RasterType]]:
52+
"""
53+
Derive one or multiple terrain attributes from a DEM using RichDEM.
54+
"""
55+
if isinstance(attribute, str):
56+
attribute = [attribute]
57+
58+
if not isinstance(dem, gu.Raster):
59+
raise ValueError("DEM must be a geoutils.Raster object.")
60+
61+
terrain_attributes = {}
62+
63+
# Check which products should be made to optimize the processing
64+
make_aspect = any(attr in attribute for attr in ["aspect", "hillshade"])
65+
make_slope = any(
66+
attr in attribute
67+
for attr in [
68+
"slope",
69+
"hillshade",
70+
"planform_curvature",
71+
"aspect",
72+
"profile_curvature",
73+
"maximum_curvature",
74+
]
75+
)
76+
make_hillshade = "hillshade" in attribute
77+
make_curvature = "curvature" in attribute
78+
make_planform_curvature = "planform_curvature" in attribute or "maximum_curvature" in attribute
79+
make_profile_curvature = "profile_curvature" in attribute or "maximum_curvature" in attribute
80+
81+
if make_slope:
82+
terrain_attributes["slope"] = get_terrainattr_richdem(dem, "slope_radians")
83+
84+
if make_aspect:
85+
# The aspect of RichDEM is returned in degrees, we convert to radians to match the others
86+
terrain_attributes["aspect"] = np.deg2rad(get_terrainattr_richdem(dem, "aspect"))
87+
# For flat slopes, RichDEM returns a 90° aspect by default, while GDAL return a 180° aspect
88+
# We stay consistent with GDAL
89+
slope_tmp = get_terrainattr_richdem(dem, "slope_radians")
90+
terrain_attributes["aspect"][slope_tmp == 0] = np.pi
91+
92+
if make_hillshade:
93+
# If a different z-factor was given, slopemap with exaggerated gradients.
94+
if hillshade_z_factor != 1.0:
95+
slopemap = np.arctan(np.tan(terrain_attributes["slope"]) * hillshade_z_factor)
96+
else:
97+
slopemap = terrain_attributes["slope"]
98+
99+
azimuth_rad = np.deg2rad(360 - hillshade_azimuth)
100+
altitude_rad = np.deg2rad(hillshade_altitude)
101+
102+
# The operation below yielded the closest hillshade to GDAL (multiplying by 255 did not work)
103+
# As 0 is generally no data for this uint8, we add 1 and then 0.5 for the rounding to occur between
104+
# 1 and 255
105+
terrain_attributes["hillshade"] = np.clip(
106+
1.5
107+
+ 254
108+
* (
109+
np.sin(altitude_rad) * np.cos(slopemap)
110+
+ np.cos(altitude_rad) * np.sin(slopemap) * np.sin(azimuth_rad - terrain_attributes["aspect"])
111+
),
112+
0,
113+
255,
114+
).astype("float32")
115+
116+
if make_curvature:
117+
terrain_attributes["curvature"] = get_terrainattr_richdem(dem, "curvature")
118+
119+
if make_planform_curvature:
120+
terrain_attributes["planform_curvature"] = get_terrainattr_richdem(dem, "planform_curvature")
121+
122+
if make_profile_curvature:
123+
terrain_attributes["profile_curvature"] = get_terrainattr_richdem(dem, "profile_curvature")
124+
125+
# Convert the unit if wanted.
126+
if degrees:
127+
for attr in ["slope", "aspect"]:
128+
if attr not in terrain_attributes:
129+
continue
130+
terrain_attributes[attr] = np.rad2deg(terrain_attributes[attr])
131+
132+
output_attributes = [terrain_attributes[key].reshape(dem.shape) for key in attribute]
133+
134+
if isinstance(dem, gu.Raster):
135+
output_attributes = [
136+
gu.Raster.from_array(attr, transform=dem.transform, crs=dem.crs, nodata=-99999)
137+
for attr in output_attributes
138+
]
139+
140+
return output_attributes if len(output_attributes) > 1 else output_attributes[0]
141+
142+
return _get_terrain_attribute_richdem

tests/test_terrain.py

Lines changed: 9 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,7 @@ def test_attribute_functions_against_gdaldem(self, attribute: str) -> None:
183183
"attribute",
184184
["slope_Horn", "aspect_Horn", "hillshade_Horn", "curvature", "profile_curvature", "planform_curvature"],
185185
) # type: ignore
186-
def test_attribute_functions_against_richdem(self, attribute: str) -> None:
186+
def test_attribute_functions_against_richdem(self, attribute: str, get_terrain_attribute_richdem) -> None:
187187
"""
188188
Test that all attribute functions give the same results as those of RichDEM within a small tolerance.
189189
@@ -202,12 +202,14 @@ def test_attribute_functions_against_richdem(self, attribute: str) -> None:
202202

203203
# Functions for RichDEM wrapper methods
204204
functions_richdem = {
205-
"slope_Horn": lambda dem: xdem.terrain.slope(dem, degrees=True, use_richdem=True),
206-
"aspect_Horn": lambda dem: xdem.terrain.aspect(dem, degrees=True, use_richdem=True),
207-
"hillshade_Horn": lambda dem: xdem.terrain.hillshade(dem, use_richdem=True),
208-
"curvature": lambda dem: xdem.terrain.curvature(dem, use_richdem=True),
209-
"profile_curvature": lambda dem: xdem.terrain.profile_curvature(dem, use_richdem=True),
210-
"planform_curvature": lambda dem: xdem.terrain.planform_curvature(dem, use_richdem=True),
205+
"slope_Horn": lambda dem: get_terrain_attribute_richdem(dem, attribute="slope", degrees=True),
206+
"aspect_Horn": lambda dem: get_terrain_attribute_richdem(dem, attribute="aspect", degrees=True),
207+
"hillshade_Horn": lambda dem: get_terrain_attribute_richdem(dem, attribute="hillshade"),
208+
"curvature": lambda dem: get_terrain_attribute_richdem(dem, attribute="curvature"),
209+
"profile_curvature": lambda dem: get_terrain_attribute_richdem(dem, attribute="profile_curvature"),
210+
"planform_curvature": lambda dem: get_terrain_attribute_richdem(
211+
dem, attribute="planform_curvature", degrees=True
212+
),
211213
}
212214

213215
# Copy the DEM to ensure that the inter-test state is unchanged, and because the mask will be modified.
@@ -355,15 +357,6 @@ def test_get_terrain_attribute_errors(self) -> None:
355357
"""Test the get_terrain_attribute function raises appropriate errors."""
356358

357359
# Below, re.escape() is needed to match expressions that have special characters (e.g., parenthesis, bracket)
358-
with pytest.raises(
359-
ValueError,
360-
match=re.escape("RichDEM can only compute the slope and aspect using the " "default method of Horn (1981)"),
361-
):
362-
xdem.terrain.slope(self.dem, method="ZevenbergThorne", use_richdem=True)
363-
364-
with pytest.raises(ValueError, match="To derive RichDEM attributes, the DEM passed must be a Raster object"):
365-
xdem.terrain.slope(self.dem.data, resolution=self.dem.res, use_richdem=True)
366-
367360
with pytest.raises(
368361
ValueError,
369362
match=re.escape(

xdem/dem.py

Lines changed: 13 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -375,69 +375,44 @@ def to_vcrs(
375375
)
376376

377377
@copy_doc(terrain, remove_dem_res_params=True)
378-
def slope(
379-
self,
380-
method: str = "Horn",
381-
degrees: bool = True,
382-
use_richdem: bool = False,
383-
) -> RasterType:
384-
return terrain.slope(self, method=method, degrees=degrees, use_richdem=use_richdem)
378+
def slope(self, method: str = "Horn", degrees: bool = True) -> RasterType:
379+
return terrain.slope(self, method=method, degrees=degrees)
385380

386381
@copy_doc(terrain, remove_dem_res_params=True)
387382
def aspect(
388383
self,
389384
method: str = "Horn",
390385
degrees: bool = True,
391-
use_richdem: bool = False,
392386
) -> RasterType:
393387

394-
return terrain.aspect(self, method=method, degrees=degrees, use_richdem=use_richdem)
388+
return terrain.aspect(self, method=method, degrees=degrees)
395389

396390
@copy_doc(terrain, remove_dem_res_params=True)
397391
def hillshade(
398-
self,
399-
method: str = "Horn",
400-
azimuth: float = 315.0,
401-
altitude: float = 45.0,
402-
z_factor: float = 1.0,
403-
use_richdem: bool = False,
392+
self, method: str = "Horn", azimuth: float = 315.0, altitude: float = 45.0, z_factor: float = 1.0
404393
) -> RasterType:
405394

406-
return terrain.hillshade(
407-
self, method=method, azimuth=azimuth, altitude=altitude, z_factor=z_factor, use_richdem=use_richdem
408-
)
395+
return terrain.hillshade(self, method=method, azimuth=azimuth, altitude=altitude, z_factor=z_factor)
409396

410397
@copy_doc(terrain, remove_dem_res_params=True)
411-
def curvature(
412-
self,
413-
use_richdem: bool = False,
414-
) -> RasterType:
398+
def curvature(self) -> RasterType:
415399

416-
return terrain.curvature(self, use_richdem=use_richdem)
400+
return terrain.curvature(self)
417401

418402
@copy_doc(terrain, remove_dem_res_params=True)
419-
def planform_curvature(
420-
self,
421-
use_richdem: bool = False,
422-
) -> RasterType:
403+
def planform_curvature(self) -> RasterType:
423404

424-
return terrain.planform_curvature(self, use_richdem=use_richdem)
405+
return terrain.planform_curvature(self)
425406

426407
@copy_doc(terrain, remove_dem_res_params=True)
427-
def profile_curvature(
428-
self,
429-
use_richdem: bool = False,
430-
) -> RasterType:
408+
def profile_curvature(self) -> RasterType:
431409

432-
return terrain.profile_curvature(self, use_richdem=use_richdem)
410+
return terrain.profile_curvature(self)
433411

434412
@copy_doc(terrain, remove_dem_res_params=True)
435-
def maximum_curvature(
436-
self,
437-
use_richdem: bool = False,
438-
) -> RasterType:
413+
def maximum_curvature(self) -> RasterType:
439414

440-
return terrain.maximum_curvature(self, use_richdem=use_richdem)
415+
return terrain.maximum_curvature(self)
441416

442417
@copy_doc(terrain, remove_dem_res_params=True)
443418
def topographic_position_index(self, window_size: int = 3) -> RasterType:

0 commit comments

Comments
 (0)