Skip to content

Commit 1d0cadb

Browse files
committed
Fix hillshade gradient to use Horn's method (#1175)
hillshade was using np.gradient (2-point central difference) for gradients while slope and aspect already used Horn's 8-neighbor Sobel kernel. This caused measurable divergence from GDAL on noisy terrain (up to 0.1 shading units on rough DEMs). Replace np.gradient in _run_numpy with an @ngjit Horn kernel matching slope.py. Update the CUDA kernel (_gpu_calc_numba) with the same 8-neighbor stencil. Both dask backends inherit the fix automatically through their chunk functions. Update test_hillshade_gdal_equivalence to validate against Horn's method reference instead of np.gradient. Add test_hillshade_horn_vs_central_diff to confirm the two methods produce meaningfully different results on noisy data.
1 parent 334607c commit 1d0cadb

File tree

2 files changed

+129
-45
lines changed

2 files changed

+129
-45
lines changed

xrspatial/hillshade.py

Lines changed: 56 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,46 @@
1616
from .utils import (_boundary_to_dask, _pad_array, _validate_boundary,
1717
_validate_raster, _validate_scalar,
1818
calc_cuda_dims, get_dataarray_resolution,
19-
has_cuda_and_cupy, is_cupy_array, is_cupy_backed)
19+
has_cuda_and_cupy, is_cupy_array, is_cupy_backed,
20+
ngjit)
2021
from .dataset_support import supports_dataset
2122

2223

24+
@ngjit
25+
def _horn_hillshade(data, cellsize_x, cellsize_y, sin_alt, cos_alt,
26+
sin_az, cos_az):
27+
"""Hillshade using Horn's method (weighted 8-neighbor Sobel kernel).
28+
29+
Gradient kernel matches slope.py and GDAL gdaldem hillshade.
30+
"""
31+
rows, cols = data.shape
32+
out = np.empty(data.shape, dtype=np.float32)
33+
out[:] = np.nan
34+
for y in range(1, rows - 1):
35+
for x in range(1, cols - 1):
36+
a = data[y + 1, x - 1]
37+
b = data[y + 1, x]
38+
c = data[y + 1, x + 1]
39+
d = data[y, x - 1]
40+
f = data[y, x + 1]
41+
g = data[y - 1, x - 1]
42+
h = data[y - 1, x]
43+
i = data[y - 1, x + 1]
44+
45+
dz_dx = ((c + 2 * f + i) - (a + 2 * d + g)) / (8 * cellsize_x)
46+
dz_dy = ((g + 2 * h + i) - (a + 2 * b + c)) / (8 * cellsize_y)
47+
48+
xx_plus_yy = dz_dx * dz_dx + dz_dy * dz_dy
49+
shaded = (sin_alt + cos_alt * (dz_dy * cos_az - dz_dx * sin_az)) \
50+
/ math.sqrt(1.0 + xx_plus_yy)
51+
if shaded < 0.0:
52+
shaded = 0.0
53+
elif shaded > 1.0:
54+
shaded = 1.0
55+
out[y, x] = shaded
56+
return out
57+
58+
2359
def _run_numpy(data, azimuth=225, angle_altitude=25,
2460
cellsize_x=1.0, cellsize_y=1.0, boundary='nan'):
2561
if boundary != 'nan':
@@ -37,25 +73,8 @@ def _run_numpy(data, azimuth=225, angle_altitude=25,
3773
sin_az = np.sin(az_rad)
3874
cos_az = np.cos(az_rad)
3975

40-
# Gradient with actual cell spacing (matches GDAL Horn method)
41-
dy, dx = np.gradient(data, cellsize_y, cellsize_x)
42-
xx_plus_yy = dx * dx + dy * dy
43-
44-
# GDAL-equivalent hillshade formula (simplified from the original
45-
# trig-heavy version; see issue #748 and GDAL gdaldem_lib.cpp):
46-
# shaded = (sin(alt) + cos(alt) * sqrt(xx+yy) * sin(aspect - az))
47-
# / sqrt(1 + xx+yy)
48-
# where aspect = atan2(dy, dx), expanded inline:
49-
# sin(aspect - az) = (dy*cos(az) - dx*sin(az)) / sqrt(xx+yy)
50-
# so sqrt(xx+yy) cancels, giving:
51-
shaded = (sin_alt + cos_alt * (dy * cos_az - dx * sin_az)) \
52-
/ np.sqrt(1.0 + xx_plus_yy)
53-
54-
# Clamp negatives (shadow) then scale to [0, 1]
55-
result = np.clip(shaded, 0.0, 1.0)
56-
result[(0, -1), :] = np.nan
57-
result[:, (0, -1)] = np.nan
58-
return result
76+
return _horn_hillshade(data, cellsize_x, cellsize_y,
77+
sin_alt, cos_alt, sin_az, cos_az)
5978

6079

6180
def _run_dask_numpy(data, azimuth, angle_altitude,
@@ -86,15 +105,27 @@ def _gpu_calc_numba(
86105

87106
i, j = cuda.grid(2)
88107
if i > 0 and i < data.shape[0]-1 and j > 0 and j < data.shape[1] - 1:
89-
dx = (data[i, j+1] - data[i, j-1]) / (2.0 * cellsize_x)
90-
dy = (data[i+1, j] - data[i-1, j]) / (2.0 * cellsize_y)
91-
92-
xx_plus_yy = dx * dx + dy * dy
93-
shaded = (sin_alt + cos_alt * (dy * cos_az - dx * sin_az)) \
108+
# Horn's method (weighted 8-neighbor Sobel kernel)
109+
a = data[i + 1, j - 1]
110+
b = data[i + 1, j]
111+
c = data[i + 1, j + 1]
112+
d = data[i, j - 1]
113+
f = data[i, j + 1]
114+
g = data[i - 1, j - 1]
115+
h = data[i - 1, j]
116+
ii = data[i - 1, j + 1]
117+
118+
dz_dx = ((c + 2.0 * f + ii) - (a + 2.0 * d + g)) / (8.0 * cellsize_x)
119+
dz_dy = ((g + 2.0 * h + ii) - (a + 2.0 * b + c)) / (8.0 * cellsize_y)
120+
121+
xx_plus_yy = dz_dx * dz_dx + dz_dy * dz_dy
122+
shaded = (sin_alt + cos_alt * (dz_dy * cos_az - dz_dx * sin_az)) \
94123
/ math.sqrt(1.0 + xx_plus_yy)
95124

96125
if shaded < 0.0:
97126
shaded = 0.0
127+
elif shaded > 1.0:
128+
shaded = 1.0
98129
output[i, j] = shaded
99130

100131

xrspatial/tests/test_hillshade.py

Lines changed: 73 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -96,40 +96,93 @@ def test_hillshade_resolution_sensitivity():
9696
"hillshade should be sensitive to cell resolution"
9797

9898

99-
def test_hillshade_gdal_equivalence():
99+
def _horn_reference(data, cellsize_x, cellsize_y, azimuth, altitude):
100+
"""Pure-numpy Horn's method reference for testing.
101+
102+
Computes gradients with the 8-neighbor Sobel kernel and applies the
103+
GDAL hillshade formula.
100104
"""
101-
Verify that _run_numpy matches the GDAL hillshade formula directly.
105+
az_rad = azimuth * np.pi / 180.
106+
alt_rad = altitude * np.pi / 180.
102107

103-
GDAL formula (gdaldem_lib.cpp):
104-
aspect = atan2(dy, dx)
105-
shaded = (sin(alt) + cos(alt)*sqrt(xx+yy)*sin(aspect-az))
106-
/ sqrt(1 + xx+yy)
107-
where dx, dy are gradients divided by cell spacing.
108+
# Horn / Sobel kernel gradients (same as GDAL gdaldem)
109+
a = data[2:, :-2]
110+
b = data[2:, 1:-1]
111+
c = data[2:, 2:]
112+
d = data[1:-1, :-2]
113+
f = data[1:-1, 2:]
114+
g = data[:-2, :-2]
115+
h = data[:-2, 1:-1]
116+
i = data[:-2, 2:]
117+
118+
dz_dx = ((c + 2 * f + i) - (a + 2 * d + g)) / (8 * cellsize_x)
119+
dz_dy = ((g + 2 * h + i) - (a + 2 * b + c)) / (8 * cellsize_y)
120+
121+
xx_plus_yy = dz_dx**2 + dz_dy**2
122+
shaded = (
123+
np.sin(alt_rad) + np.cos(alt_rad) * (dz_dy * np.cos(az_rad) - dz_dx * np.sin(az_rad))
124+
) / np.sqrt(1 + xx_plus_yy)
125+
return np.clip(shaded, 0.0, 1.0)
126+
127+
128+
def test_hillshade_gdal_equivalence():
129+
"""
130+
Verify that _run_numpy matches the GDAL hillshade formula with
131+
Horn's method (weighted 8-neighbor Sobel kernel) for gradients.
108132
"""
109133
rng = np.random.default_rng(99)
110134
data = rng.random((20, 20)).astype(np.float32) * 500
111135
cellsize_x, cellsize_y = 30.0, 30.0
112136
azimuth, altitude = 315.0, 45.0
113137

114-
# Reference: direct GDAL formula
138+
ref = _horn_reference(data, cellsize_x, cellsize_y, azimuth, altitude)
139+
140+
result = _run_numpy(data, azimuth=azimuth, angle_altitude=altitude,
141+
cellsize_x=cellsize_x, cellsize_y=cellsize_y)
142+
143+
interior = slice(1, -1)
144+
assert_allclose(result[interior, interior], ref, atol=1e-6)
145+
146+
147+
def test_hillshade_horn_vs_central_diff():
148+
"""Horn's method should differ from simple central differences on noisy data.
149+
150+
This validates that the implementation actually uses the 8-neighbor Sobel
151+
kernel rather than 2-point np.gradient, which was the bug in #1175.
152+
"""
153+
rng = np.random.default_rng(1175)
154+
# Smooth ramp with noise to amplify the difference between methods
155+
rows, cols = 64, 64
156+
base = np.tile(np.linspace(0, 500, cols), (rows, 1))
157+
noise = rng.normal(0, 2, (rows, cols))
158+
data = (base + noise).astype(np.float32)
159+
cellsize_x, cellsize_y = 30.0, 30.0
160+
azimuth, altitude = 315.0, 45.0
161+
162+
# Our result (should be Horn)
163+
result = _run_numpy(data, azimuth=azimuth, angle_altitude=altitude,
164+
cellsize_x=cellsize_x, cellsize_y=cellsize_y)
165+
interior = slice(1, -1)
166+
167+
# Horn reference
168+
horn_ref = _horn_reference(data, cellsize_x, cellsize_y, azimuth, altitude)
169+
assert_allclose(result[interior, interior], horn_ref, atol=1e-6)
170+
171+
# np.gradient reference (old method) -- should NOT match
115172
az_rad = azimuth * np.pi / 180.
116173
alt_rad = altitude * np.pi / 180.
117174
dy, dx = np.gradient(data, cellsize_y, cellsize_x)
118175
xx_plus_yy = dx**2 + dy**2
119-
aspect = np.arctan2(dy, dx)
120-
gdal_shaded = (
121-
np.sin(alt_rad)
122-
+ np.cos(alt_rad) * np.sqrt(xx_plus_yy) * np.sin(aspect - az_rad)
176+
old_shaded = (
177+
np.sin(alt_rad) + np.cos(alt_rad) * (dy * np.cos(az_rad) - dx * np.sin(az_rad))
123178
) / np.sqrt(1 + xx_plus_yy)
124-
gdal_ref = np.clip(gdal_shaded, 0.0, 1.0)
179+
old_ref = np.clip(old_shaded, 0.0, 1.0)
125180

126-
# Our implementation
127-
result = _run_numpy(data, azimuth=azimuth, angle_altitude=altitude,
128-
cellsize_x=cellsize_x, cellsize_y=cellsize_y)
129-
130-
interior = slice(1, -1)
131-
assert_allclose(result[interior, interior],
132-
gdal_ref[interior, interior], atol=1e-6)
181+
max_diff = np.max(np.abs(result[interior, interior] - old_ref[interior, interior]))
182+
assert max_diff > 0.01, (
183+
f"Expected meaningful difference between Horn and central-diff, "
184+
f"got max diff {max_diff}"
185+
)
133186

134187

135188
@dask_array_available

0 commit comments

Comments
 (0)