Skip to content

Commit 846c607

Browse files
committed
Add tests for morph_gradient, morph_white_tophat, morph_black_tophat (#1025)
27 tests covering correctness, NaN propagation, edge cases, dataset support, and all four backends (numpy, dask, cupy, dask+cupy).
1 parent 7d3d8a5 commit 846c607

File tree

1 file changed

+329
-0
lines changed

1 file changed

+329
-0
lines changed
Lines changed: 329 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,329 @@
1+
"""Tests for derived morphological ops: gradient, white top-hat, black top-hat."""
2+
3+
from __future__ import annotations
4+
5+
import numpy as np
6+
import pytest
7+
import xarray as xr
8+
9+
from xrspatial.morphology import (
10+
morph_black_tophat,
11+
morph_closing,
12+
morph_dilate,
13+
morph_erode,
14+
morph_gradient,
15+
morph_opening,
16+
morph_white_tophat,
17+
)
18+
from xrspatial.tests.general_checks import (
19+
create_test_raster,
20+
cuda_and_cupy_available,
21+
dask_array_available,
22+
general_output_checks,
23+
)
24+
25+
26+
# ---------------------------------------------------------------------------
27+
# Test data
28+
# ---------------------------------------------------------------------------
29+
30+
_DATA = np.array([
31+
[1., 5., 3., 2., 7.],
32+
[4., 8., 6., 1., 3.],
33+
[2., 9., 7., 5., 4.],
34+
[3., 1., 4., 8., 6.],
35+
[6., 2., 3., 7., 9.],
36+
], dtype=np.float64)
37+
38+
_KERNEL_3x3 = np.ones((3, 3), dtype=np.uint8)
39+
40+
41+
# ---------------------------------------------------------------------------
42+
# morph_gradient correctness
43+
# ---------------------------------------------------------------------------
44+
45+
def test_gradient_equals_dilate_minus_erode():
46+
"""Gradient must equal dilate - erode."""
47+
agg = create_test_raster(_DATA)
48+
grad = morph_gradient(agg, kernel=_KERNEL_3x3, boundary='nearest')
49+
dilated = morph_dilate(agg, kernel=_KERNEL_3x3, boundary='nearest')
50+
eroded = morph_erode(agg, kernel=_KERNEL_3x3, boundary='nearest')
51+
expected = dilated.data - eroded.data
52+
np.testing.assert_allclose(grad.data, expected, equal_nan=True)
53+
54+
55+
def test_gradient_nonnegative():
56+
"""Gradient is always >= 0 for non-NaN cells."""
57+
agg = create_test_raster(_DATA)
58+
grad = morph_gradient(agg, kernel=_KERNEL_3x3, boundary='nearest')
59+
assert np.all(grad.data[~np.isnan(grad.data)] >= 0)
60+
61+
62+
def test_gradient_uniform_is_zero():
63+
"""Uniform raster produces zero gradient."""
64+
data = np.full((7, 7), 5.0, dtype=np.float64)
65+
agg = create_test_raster(data)
66+
grad = morph_gradient(agg, kernel=_KERNEL_3x3, boundary='nearest')
67+
np.testing.assert_allclose(grad.data, 0.0)
68+
69+
70+
def test_gradient_output_metadata():
71+
agg = create_test_raster(_DATA)
72+
grad = morph_gradient(agg, kernel=_KERNEL_3x3)
73+
general_output_checks(agg, grad, verify_attrs=True)
74+
assert grad.name == 'gradient'
75+
76+
77+
# ---------------------------------------------------------------------------
78+
# morph_white_tophat correctness
79+
# ---------------------------------------------------------------------------
80+
81+
def test_white_tophat_equals_original_minus_opening():
82+
agg = create_test_raster(_DATA)
83+
wth = morph_white_tophat(agg, kernel=_KERNEL_3x3, boundary='nearest')
84+
opened = morph_opening(agg, kernel=_KERNEL_3x3, boundary='nearest')
85+
expected = agg.data - opened.data
86+
np.testing.assert_allclose(wth.data, expected, equal_nan=True)
87+
88+
89+
def test_white_tophat_isolates_bright_spike():
90+
"""A single bright spike should appear in the white top-hat."""
91+
data = np.zeros((7, 7), dtype=np.float64)
92+
data[3, 3] = 100.0
93+
agg = create_test_raster(data)
94+
wth = morph_white_tophat(agg, kernel=_KERNEL_3x3, boundary='nearest')
95+
# The spike is removed by opening, so original - opening = spike
96+
assert wth.data[3, 3] == 100.0
97+
# Background stays zero
98+
assert wth.data[0, 0] == 0.0
99+
100+
101+
def test_white_tophat_nonnegative():
102+
"""White top-hat is >= 0 (opening <= original)."""
103+
agg = create_test_raster(_DATA)
104+
wth = morph_white_tophat(agg, kernel=_KERNEL_3x3, boundary='nearest')
105+
assert np.all(wth.data[~np.isnan(wth.data)] >= -1e-10)
106+
107+
108+
def test_white_tophat_output_metadata():
109+
agg = create_test_raster(_DATA)
110+
wth = morph_white_tophat(agg, kernel=_KERNEL_3x3)
111+
general_output_checks(agg, wth, verify_attrs=True)
112+
assert wth.name == 'white_tophat'
113+
114+
115+
# ---------------------------------------------------------------------------
116+
# morph_black_tophat correctness
117+
# ---------------------------------------------------------------------------
118+
119+
def test_black_tophat_equals_closing_minus_original():
120+
agg = create_test_raster(_DATA)
121+
bth = morph_black_tophat(agg, kernel=_KERNEL_3x3, boundary='nearest')
122+
closed = morph_closing(agg, kernel=_KERNEL_3x3, boundary='nearest')
123+
expected = closed.data - agg.data
124+
np.testing.assert_allclose(bth.data, expected, equal_nan=True)
125+
126+
127+
def test_black_tophat_isolates_dark_pit():
128+
"""A single dark pit should appear in the black top-hat."""
129+
data = np.full((7, 7), 100.0, dtype=np.float64)
130+
data[3, 3] = 0.0
131+
agg = create_test_raster(data)
132+
bth = morph_black_tophat(agg, kernel=_KERNEL_3x3, boundary='nearest')
133+
# The pit is filled by closing, so closing - original = pit depth
134+
assert bth.data[3, 3] == 100.0
135+
# Background stays zero
136+
assert bth.data[0, 0] == 0.0
137+
138+
139+
def test_black_tophat_nonnegative():
140+
"""Black top-hat is >= 0 (closing >= original)."""
141+
agg = create_test_raster(_DATA)
142+
bth = morph_black_tophat(agg, kernel=_KERNEL_3x3, boundary='nearest')
143+
assert np.all(bth.data[~np.isnan(bth.data)] >= -1e-10)
144+
145+
146+
def test_black_tophat_output_metadata():
147+
agg = create_test_raster(_DATA)
148+
bth = morph_black_tophat(agg, kernel=_KERNEL_3x3)
149+
general_output_checks(agg, bth, verify_attrs=True)
150+
assert bth.name == 'black_tophat'
151+
152+
153+
# ---------------------------------------------------------------------------
154+
# NaN handling
155+
# ---------------------------------------------------------------------------
156+
157+
def test_gradient_nan_propagation():
158+
data = _DATA.copy()
159+
data[2, 2] = np.nan
160+
agg = create_test_raster(data)
161+
grad = morph_gradient(agg, kernel=_KERNEL_3x3)
162+
assert np.isnan(grad.data[2, 2])
163+
164+
165+
def test_white_tophat_nan_propagation():
166+
data = _DATA.copy()
167+
data[2, 2] = np.nan
168+
agg = create_test_raster(data)
169+
wth = morph_white_tophat(agg, kernel=_KERNEL_3x3)
170+
assert np.isnan(wth.data[2, 2])
171+
172+
173+
def test_black_tophat_nan_propagation():
174+
data = _DATA.copy()
175+
data[2, 2] = np.nan
176+
agg = create_test_raster(data)
177+
bth = morph_black_tophat(agg, kernel=_KERNEL_3x3)
178+
assert np.isnan(bth.data[2, 2])
179+
180+
181+
# ---------------------------------------------------------------------------
182+
# Edge cases
183+
# ---------------------------------------------------------------------------
184+
185+
def test_default_kernel():
186+
"""Functions work with the default kernel argument."""
187+
agg = create_test_raster(_DATA)
188+
for func in [morph_gradient, morph_white_tophat, morph_black_tophat]:
189+
result = func(agg)
190+
general_output_checks(agg, result, verify_attrs=True)
191+
192+
193+
def test_single_cell_raster():
194+
data = np.array([[42.0]], dtype=np.float64)
195+
agg = create_test_raster(data)
196+
# With boundary='nearest', single cell -> all ops see same value -> 0
197+
for func in [morph_gradient, morph_white_tophat, morph_black_tophat]:
198+
result = func(agg, boundary='nearest')
199+
assert result.data[0, 0] == 0.0
200+
201+
202+
# ---------------------------------------------------------------------------
203+
# Dask backend
204+
# ---------------------------------------------------------------------------
205+
206+
@dask_array_available
207+
def test_gradient_dask_equals_numpy():
208+
numpy_agg = create_test_raster(_DATA, backend='numpy')
209+
dask_agg = create_test_raster(_DATA, backend='dask')
210+
np_result = morph_gradient(numpy_agg, kernel=_KERNEL_3x3, boundary='nearest')
211+
dk_result = morph_gradient(dask_agg, kernel=_KERNEL_3x3, boundary='nearest')
212+
np.testing.assert_allclose(
213+
dk_result.data.compute(), np_result.data, equal_nan=True,
214+
)
215+
216+
217+
@dask_array_available
218+
def test_white_tophat_dask_equals_numpy():
219+
numpy_agg = create_test_raster(_DATA, backend='numpy')
220+
dask_agg = create_test_raster(_DATA, backend='dask')
221+
np_result = morph_white_tophat(numpy_agg, kernel=_KERNEL_3x3, boundary='nearest')
222+
dk_result = morph_white_tophat(dask_agg, kernel=_KERNEL_3x3, boundary='nearest')
223+
np.testing.assert_allclose(
224+
dk_result.data.compute(), np_result.data, equal_nan=True,
225+
)
226+
227+
228+
@dask_array_available
229+
def test_black_tophat_dask_equals_numpy():
230+
numpy_agg = create_test_raster(_DATA, backend='numpy')
231+
dask_agg = create_test_raster(_DATA, backend='dask')
232+
np_result = morph_black_tophat(numpy_agg, kernel=_KERNEL_3x3, boundary='nearest')
233+
dk_result = morph_black_tophat(dask_agg, kernel=_KERNEL_3x3, boundary='nearest')
234+
np.testing.assert_allclose(
235+
dk_result.data.compute(), np_result.data, equal_nan=True,
236+
)
237+
238+
239+
# ---------------------------------------------------------------------------
240+
# CuPy backend
241+
# ---------------------------------------------------------------------------
242+
243+
@cuda_and_cupy_available
244+
def test_gradient_cupy_equals_numpy():
245+
numpy_agg = create_test_raster(_DATA, backend='numpy')
246+
cupy_agg = create_test_raster(_DATA, backend='cupy')
247+
np_result = morph_gradient(numpy_agg, kernel=_KERNEL_3x3, boundary='nearest')
248+
cp_result = morph_gradient(cupy_agg, kernel=_KERNEL_3x3, boundary='nearest')
249+
np.testing.assert_allclose(
250+
cp_result.data.get(), np_result.data, equal_nan=True,
251+
)
252+
253+
254+
@cuda_and_cupy_available
255+
def test_white_tophat_cupy_equals_numpy():
256+
numpy_agg = create_test_raster(_DATA, backend='numpy')
257+
cupy_agg = create_test_raster(_DATA, backend='cupy')
258+
np_result = morph_white_tophat(numpy_agg, kernel=_KERNEL_3x3, boundary='nearest')
259+
cp_result = morph_white_tophat(cupy_agg, kernel=_KERNEL_3x3, boundary='nearest')
260+
np.testing.assert_allclose(
261+
cp_result.data.get(), np_result.data, equal_nan=True,
262+
)
263+
264+
265+
@cuda_and_cupy_available
266+
def test_black_tophat_cupy_equals_numpy():
267+
numpy_agg = create_test_raster(_DATA, backend='numpy')
268+
cupy_agg = create_test_raster(_DATA, backend='cupy')
269+
np_result = morph_black_tophat(numpy_agg, kernel=_KERNEL_3x3, boundary='nearest')
270+
cp_result = morph_black_tophat(cupy_agg, kernel=_KERNEL_3x3, boundary='nearest')
271+
np.testing.assert_allclose(
272+
cp_result.data.get(), np_result.data, equal_nan=True,
273+
)
274+
275+
276+
# ---------------------------------------------------------------------------
277+
# Dask + CuPy backend
278+
# ---------------------------------------------------------------------------
279+
280+
@cuda_and_cupy_available
281+
@dask_array_available
282+
def test_gradient_dask_cupy_equals_numpy():
283+
numpy_agg = create_test_raster(_DATA, backend='numpy')
284+
dask_cupy_agg = create_test_raster(_DATA, backend='dask+cupy')
285+
np_result = morph_gradient(numpy_agg, kernel=_KERNEL_3x3, boundary='nearest')
286+
dc_result = morph_gradient(dask_cupy_agg, kernel=_KERNEL_3x3, boundary='nearest')
287+
np.testing.assert_allclose(
288+
dc_result.data.compute().get(), np_result.data, equal_nan=True,
289+
)
290+
291+
292+
@cuda_and_cupy_available
293+
@dask_array_available
294+
def test_white_tophat_dask_cupy_equals_numpy():
295+
numpy_agg = create_test_raster(_DATA, backend='numpy')
296+
dask_cupy_agg = create_test_raster(_DATA, backend='dask+cupy')
297+
np_result = morph_white_tophat(numpy_agg, kernel=_KERNEL_3x3, boundary='nearest')
298+
dc_result = morph_white_tophat(dask_cupy_agg, kernel=_KERNEL_3x3, boundary='nearest')
299+
np.testing.assert_allclose(
300+
dc_result.data.compute().get(), np_result.data, equal_nan=True,
301+
)
302+
303+
304+
@cuda_and_cupy_available
305+
@dask_array_available
306+
def test_black_tophat_dask_cupy_equals_numpy():
307+
numpy_agg = create_test_raster(_DATA, backend='numpy')
308+
dask_cupy_agg = create_test_raster(_DATA, backend='dask+cupy')
309+
np_result = morph_black_tophat(numpy_agg, kernel=_KERNEL_3x3, boundary='nearest')
310+
dc_result = morph_black_tophat(dask_cupy_agg, kernel=_KERNEL_3x3, boundary='nearest')
311+
np.testing.assert_allclose(
312+
dc_result.data.compute().get(), np_result.data, equal_nan=True,
313+
)
314+
315+
316+
# ---------------------------------------------------------------------------
317+
# Dataset support
318+
# ---------------------------------------------------------------------------
319+
320+
def test_gradient_dataset():
321+
data = np.random.default_rng(1025).random((5, 5)).astype(np.float64)
322+
ds = xr.Dataset({
323+
'a': xr.DataArray(data, dims=['y', 'x']),
324+
'b': xr.DataArray(data * 2, dims=['y', 'x']),
325+
})
326+
for func in [morph_gradient, morph_white_tophat, morph_black_tophat]:
327+
result = func(ds, boundary='nearest')
328+
assert isinstance(result, xr.Dataset)
329+
assert set(result.data_vars) == {'a', 'b'}

0 commit comments

Comments
 (0)