From 7d3d8a53959b71184bdbce59bcbb6c4a31683998 Mon Sep 17 00:00:00 2001 From: Brendan Collins Date: Wed, 18 Mar 2026 06:08:36 -0700 Subject: [PATCH 1/4] Add morph_gradient, morph_white_tophat, morph_black_tophat (#1025) Compose existing erode/dilate/opening/closing operations to implement the three standard derived morphological operators. All four backends are supported via DataArray-level arithmetic. --- xrspatial/__init__.py | 3 ++ xrspatial/morphology.py | 115 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 116 insertions(+), 2 deletions(-) diff --git a/xrspatial/__init__.py b/xrspatial/__init__.py index 3eb227e9..7cabdc3a 100644 --- a/xrspatial/__init__.py +++ b/xrspatial/__init__.py @@ -49,10 +49,13 @@ from xrspatial.hydro import flow_path_d8, flow_path_dinf, flow_path_mfd # noqa from xrspatial.focal import mean # noqa from xrspatial.glcm import glcm_texture # noqa +from xrspatial.morphology import morph_black_tophat # noqa from xrspatial.morphology import morph_closing # noqa from xrspatial.morphology import morph_dilate # noqa from xrspatial.morphology import morph_erode # noqa +from xrspatial.morphology import morph_gradient # noqa from xrspatial.morphology import morph_opening # noqa +from xrspatial.morphology import morph_white_tophat # noqa from xrspatial.hydro import hand # noqa: unified wrapper from xrspatial.hydro import hand_d8, hand_dinf, hand_mfd # noqa from xrspatial.hillshade import hillshade # noqa diff --git a/xrspatial/morphology.py b/xrspatial/morphology.py index 4aba31b5..c61e2e3c 100644 --- a/xrspatial/morphology.py +++ b/xrspatial/morphology.py @@ -1,10 +1,12 @@ -"""Morphological raster operators: erode, dilate, opening, closing. +"""Morphological raster operators. Applies grayscale morphological operations using a structuring element (kernel) over a 2D raster. Erosion computes the local minimum and dilation the local maximum within the kernel footprint. Opening (erosion then dilation) removes small bright features; closing -(dilation then erosion) fills small dark gaps. +(dilation then erosion) fills small dark gaps. Gradient, white +top-hat, and black top-hat are derived by subtracting pairs of the +above. Supports all four backends: numpy, cupy, dask+numpy, dask+cupy. """ @@ -560,3 +562,112 @@ def morph_closing(agg, kernel=None, boundary='nan', name='closing'): agg, kernel, boundary, name, _closing_numpy, _closing_cupy, _closing_dask_numpy, _closing_dask_cupy, ) + + +@supports_dataset +def morph_gradient(agg, kernel=None, boundary='nan', name='gradient'): + """Morphological gradient: dilation minus erosion. + + Highlights edges and transitions in a 2D raster. The result is + always non-negative for non-NaN cells. + + Parameters + ---------- + agg : xarray.DataArray + 2D raster of numeric values. + kernel : numpy.ndarray or None + 2D structuring element with odd dimensions. Defaults to a + 3x3 square. + boundary : str, default ``'nan'`` + Edge handling: ``'nan'``, ``'nearest'``, ``'reflect'``, or + ``'wrap'``. + name : str, default ``'gradient'`` + Name for the output DataArray. + + Returns + ------- + xarray.DataArray + Gradient raster (dilate - erode). + + Examples + -------- + >>> from xrspatial.morphology import morph_gradient + >>> edges = morph_gradient(elevation) + """ + dilated = morph_dilate(agg, kernel=kernel, boundary=boundary) + eroded = morph_erode(agg, kernel=kernel, boundary=boundary) + result = dilated - eroded + result.name = name + return result + + +@supports_dataset +def morph_white_tophat(agg, kernel=None, boundary='nan', name='white_tophat'): + """White top-hat: original minus opening. + + Isolates bright features that are smaller than the structuring + element. + + Parameters + ---------- + agg : xarray.DataArray + 2D raster of numeric values. + kernel : numpy.ndarray or None + 2D structuring element with odd dimensions. Defaults to a + 3x3 square. + boundary : str, default ``'nan'`` + Edge handling: ``'nan'``, ``'nearest'``, ``'reflect'``, or + ``'wrap'``. + name : str, default ``'white_tophat'`` + Name for the output DataArray. + + Returns + ------- + xarray.DataArray + White top-hat raster (original - opening). + + Examples + -------- + >>> from xrspatial.morphology import morph_white_tophat + >>> bright = morph_white_tophat(elevation, kernel=circle_kernel) + """ + opened = morph_opening(agg, kernel=kernel, boundary=boundary) + result = agg - opened + result.name = name + return result + + +@supports_dataset +def morph_black_tophat(agg, kernel=None, boundary='nan', name='black_tophat'): + """Black top-hat: closing minus original. + + Isolates dark features that are smaller than the structuring + element. + + Parameters + ---------- + agg : xarray.DataArray + 2D raster of numeric values. + kernel : numpy.ndarray or None + 2D structuring element with odd dimensions. Defaults to a + 3x3 square. + boundary : str, default ``'nan'`` + Edge handling: ``'nan'``, ``'nearest'``, ``'reflect'``, or + ``'wrap'``. + name : str, default ``'black_tophat'`` + Name for the output DataArray. + + Returns + ------- + xarray.DataArray + Black top-hat raster (closing - original). + + Examples + -------- + >>> from xrspatial.morphology import morph_black_tophat + >>> dark = morph_black_tophat(elevation, kernel=circle_kernel) + """ + closed = morph_closing(agg, kernel=kernel, boundary=boundary) + result = closed - agg + result.name = name + return result From 846c607ccb3ef80395e44867c45a3260c4638c4b Mon Sep 17 00:00:00 2001 From: Brendan Collins Date: Wed, 18 Mar 2026 06:09:34 -0700 Subject: [PATCH 2/4] 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). --- xrspatial/tests/test_morphology_derived.py | 329 +++++++++++++++++++++ 1 file changed, 329 insertions(+) create mode 100644 xrspatial/tests/test_morphology_derived.py diff --git a/xrspatial/tests/test_morphology_derived.py b/xrspatial/tests/test_morphology_derived.py new file mode 100644 index 00000000..a00945a3 --- /dev/null +++ b/xrspatial/tests/test_morphology_derived.py @@ -0,0 +1,329 @@ +"""Tests for derived morphological ops: gradient, white top-hat, black top-hat.""" + +from __future__ import annotations + +import numpy as np +import pytest +import xarray as xr + +from xrspatial.morphology import ( + morph_black_tophat, + morph_closing, + morph_dilate, + morph_erode, + morph_gradient, + morph_opening, + morph_white_tophat, +) +from xrspatial.tests.general_checks import ( + create_test_raster, + cuda_and_cupy_available, + dask_array_available, + general_output_checks, +) + + +# --------------------------------------------------------------------------- +# Test data +# --------------------------------------------------------------------------- + +_DATA = np.array([ + [1., 5., 3., 2., 7.], + [4., 8., 6., 1., 3.], + [2., 9., 7., 5., 4.], + [3., 1., 4., 8., 6.], + [6., 2., 3., 7., 9.], +], dtype=np.float64) + +_KERNEL_3x3 = np.ones((3, 3), dtype=np.uint8) + + +# --------------------------------------------------------------------------- +# morph_gradient correctness +# --------------------------------------------------------------------------- + +def test_gradient_equals_dilate_minus_erode(): + """Gradient must equal dilate - erode.""" + agg = create_test_raster(_DATA) + grad = morph_gradient(agg, kernel=_KERNEL_3x3, boundary='nearest') + dilated = morph_dilate(agg, kernel=_KERNEL_3x3, boundary='nearest') + eroded = morph_erode(agg, kernel=_KERNEL_3x3, boundary='nearest') + expected = dilated.data - eroded.data + np.testing.assert_allclose(grad.data, expected, equal_nan=True) + + +def test_gradient_nonnegative(): + """Gradient is always >= 0 for non-NaN cells.""" + agg = create_test_raster(_DATA) + grad = morph_gradient(agg, kernel=_KERNEL_3x3, boundary='nearest') + assert np.all(grad.data[~np.isnan(grad.data)] >= 0) + + +def test_gradient_uniform_is_zero(): + """Uniform raster produces zero gradient.""" + data = np.full((7, 7), 5.0, dtype=np.float64) + agg = create_test_raster(data) + grad = morph_gradient(agg, kernel=_KERNEL_3x3, boundary='nearest') + np.testing.assert_allclose(grad.data, 0.0) + + +def test_gradient_output_metadata(): + agg = create_test_raster(_DATA) + grad = morph_gradient(agg, kernel=_KERNEL_3x3) + general_output_checks(agg, grad, verify_attrs=True) + assert grad.name == 'gradient' + + +# --------------------------------------------------------------------------- +# morph_white_tophat correctness +# --------------------------------------------------------------------------- + +def test_white_tophat_equals_original_minus_opening(): + agg = create_test_raster(_DATA) + wth = morph_white_tophat(agg, kernel=_KERNEL_3x3, boundary='nearest') + opened = morph_opening(agg, kernel=_KERNEL_3x3, boundary='nearest') + expected = agg.data - opened.data + np.testing.assert_allclose(wth.data, expected, equal_nan=True) + + +def test_white_tophat_isolates_bright_spike(): + """A single bright spike should appear in the white top-hat.""" + data = np.zeros((7, 7), dtype=np.float64) + data[3, 3] = 100.0 + agg = create_test_raster(data) + wth = morph_white_tophat(agg, kernel=_KERNEL_3x3, boundary='nearest') + # The spike is removed by opening, so original - opening = spike + assert wth.data[3, 3] == 100.0 + # Background stays zero + assert wth.data[0, 0] == 0.0 + + +def test_white_tophat_nonnegative(): + """White top-hat is >= 0 (opening <= original).""" + agg = create_test_raster(_DATA) + wth = morph_white_tophat(agg, kernel=_KERNEL_3x3, boundary='nearest') + assert np.all(wth.data[~np.isnan(wth.data)] >= -1e-10) + + +def test_white_tophat_output_metadata(): + agg = create_test_raster(_DATA) + wth = morph_white_tophat(agg, kernel=_KERNEL_3x3) + general_output_checks(agg, wth, verify_attrs=True) + assert wth.name == 'white_tophat' + + +# --------------------------------------------------------------------------- +# morph_black_tophat correctness +# --------------------------------------------------------------------------- + +def test_black_tophat_equals_closing_minus_original(): + agg = create_test_raster(_DATA) + bth = morph_black_tophat(agg, kernel=_KERNEL_3x3, boundary='nearest') + closed = morph_closing(agg, kernel=_KERNEL_3x3, boundary='nearest') + expected = closed.data - agg.data + np.testing.assert_allclose(bth.data, expected, equal_nan=True) + + +def test_black_tophat_isolates_dark_pit(): + """A single dark pit should appear in the black top-hat.""" + data = np.full((7, 7), 100.0, dtype=np.float64) + data[3, 3] = 0.0 + agg = create_test_raster(data) + bth = morph_black_tophat(agg, kernel=_KERNEL_3x3, boundary='nearest') + # The pit is filled by closing, so closing - original = pit depth + assert bth.data[3, 3] == 100.0 + # Background stays zero + assert bth.data[0, 0] == 0.0 + + +def test_black_tophat_nonnegative(): + """Black top-hat is >= 0 (closing >= original).""" + agg = create_test_raster(_DATA) + bth = morph_black_tophat(agg, kernel=_KERNEL_3x3, boundary='nearest') + assert np.all(bth.data[~np.isnan(bth.data)] >= -1e-10) + + +def test_black_tophat_output_metadata(): + agg = create_test_raster(_DATA) + bth = morph_black_tophat(agg, kernel=_KERNEL_3x3) + general_output_checks(agg, bth, verify_attrs=True) + assert bth.name == 'black_tophat' + + +# --------------------------------------------------------------------------- +# NaN handling +# --------------------------------------------------------------------------- + +def test_gradient_nan_propagation(): + data = _DATA.copy() + data[2, 2] = np.nan + agg = create_test_raster(data) + grad = morph_gradient(agg, kernel=_KERNEL_3x3) + assert np.isnan(grad.data[2, 2]) + + +def test_white_tophat_nan_propagation(): + data = _DATA.copy() + data[2, 2] = np.nan + agg = create_test_raster(data) + wth = morph_white_tophat(agg, kernel=_KERNEL_3x3) + assert np.isnan(wth.data[2, 2]) + + +def test_black_tophat_nan_propagation(): + data = _DATA.copy() + data[2, 2] = np.nan + agg = create_test_raster(data) + bth = morph_black_tophat(agg, kernel=_KERNEL_3x3) + assert np.isnan(bth.data[2, 2]) + + +# --------------------------------------------------------------------------- +# Edge cases +# --------------------------------------------------------------------------- + +def test_default_kernel(): + """Functions work with the default kernel argument.""" + agg = create_test_raster(_DATA) + for func in [morph_gradient, morph_white_tophat, morph_black_tophat]: + result = func(agg) + general_output_checks(agg, result, verify_attrs=True) + + +def test_single_cell_raster(): + data = np.array([[42.0]], dtype=np.float64) + agg = create_test_raster(data) + # With boundary='nearest', single cell -> all ops see same value -> 0 + for func in [morph_gradient, morph_white_tophat, morph_black_tophat]: + result = func(agg, boundary='nearest') + assert result.data[0, 0] == 0.0 + + +# --------------------------------------------------------------------------- +# Dask backend +# --------------------------------------------------------------------------- + +@dask_array_available +def test_gradient_dask_equals_numpy(): + numpy_agg = create_test_raster(_DATA, backend='numpy') + dask_agg = create_test_raster(_DATA, backend='dask') + np_result = morph_gradient(numpy_agg, kernel=_KERNEL_3x3, boundary='nearest') + dk_result = morph_gradient(dask_agg, kernel=_KERNEL_3x3, boundary='nearest') + np.testing.assert_allclose( + dk_result.data.compute(), np_result.data, equal_nan=True, + ) + + +@dask_array_available +def test_white_tophat_dask_equals_numpy(): + numpy_agg = create_test_raster(_DATA, backend='numpy') + dask_agg = create_test_raster(_DATA, backend='dask') + np_result = morph_white_tophat(numpy_agg, kernel=_KERNEL_3x3, boundary='nearest') + dk_result = morph_white_tophat(dask_agg, kernel=_KERNEL_3x3, boundary='nearest') + np.testing.assert_allclose( + dk_result.data.compute(), np_result.data, equal_nan=True, + ) + + +@dask_array_available +def test_black_tophat_dask_equals_numpy(): + numpy_agg = create_test_raster(_DATA, backend='numpy') + dask_agg = create_test_raster(_DATA, backend='dask') + np_result = morph_black_tophat(numpy_agg, kernel=_KERNEL_3x3, boundary='nearest') + dk_result = morph_black_tophat(dask_agg, kernel=_KERNEL_3x3, boundary='nearest') + np.testing.assert_allclose( + dk_result.data.compute(), np_result.data, equal_nan=True, + ) + + +# --------------------------------------------------------------------------- +# CuPy backend +# --------------------------------------------------------------------------- + +@cuda_and_cupy_available +def test_gradient_cupy_equals_numpy(): + numpy_agg = create_test_raster(_DATA, backend='numpy') + cupy_agg = create_test_raster(_DATA, backend='cupy') + np_result = morph_gradient(numpy_agg, kernel=_KERNEL_3x3, boundary='nearest') + cp_result = morph_gradient(cupy_agg, kernel=_KERNEL_3x3, boundary='nearest') + np.testing.assert_allclose( + cp_result.data.get(), np_result.data, equal_nan=True, + ) + + +@cuda_and_cupy_available +def test_white_tophat_cupy_equals_numpy(): + numpy_agg = create_test_raster(_DATA, backend='numpy') + cupy_agg = create_test_raster(_DATA, backend='cupy') + np_result = morph_white_tophat(numpy_agg, kernel=_KERNEL_3x3, boundary='nearest') + cp_result = morph_white_tophat(cupy_agg, kernel=_KERNEL_3x3, boundary='nearest') + np.testing.assert_allclose( + cp_result.data.get(), np_result.data, equal_nan=True, + ) + + +@cuda_and_cupy_available +def test_black_tophat_cupy_equals_numpy(): + numpy_agg = create_test_raster(_DATA, backend='numpy') + cupy_agg = create_test_raster(_DATA, backend='cupy') + np_result = morph_black_tophat(numpy_agg, kernel=_KERNEL_3x3, boundary='nearest') + cp_result = morph_black_tophat(cupy_agg, kernel=_KERNEL_3x3, boundary='nearest') + np.testing.assert_allclose( + cp_result.data.get(), np_result.data, equal_nan=True, + ) + + +# --------------------------------------------------------------------------- +# Dask + CuPy backend +# --------------------------------------------------------------------------- + +@cuda_and_cupy_available +@dask_array_available +def test_gradient_dask_cupy_equals_numpy(): + numpy_agg = create_test_raster(_DATA, backend='numpy') + dask_cupy_agg = create_test_raster(_DATA, backend='dask+cupy') + np_result = morph_gradient(numpy_agg, kernel=_KERNEL_3x3, boundary='nearest') + dc_result = morph_gradient(dask_cupy_agg, kernel=_KERNEL_3x3, boundary='nearest') + np.testing.assert_allclose( + dc_result.data.compute().get(), np_result.data, equal_nan=True, + ) + + +@cuda_and_cupy_available +@dask_array_available +def test_white_tophat_dask_cupy_equals_numpy(): + numpy_agg = create_test_raster(_DATA, backend='numpy') + dask_cupy_agg = create_test_raster(_DATA, backend='dask+cupy') + np_result = morph_white_tophat(numpy_agg, kernel=_KERNEL_3x3, boundary='nearest') + dc_result = morph_white_tophat(dask_cupy_agg, kernel=_KERNEL_3x3, boundary='nearest') + np.testing.assert_allclose( + dc_result.data.compute().get(), np_result.data, equal_nan=True, + ) + + +@cuda_and_cupy_available +@dask_array_available +def test_black_tophat_dask_cupy_equals_numpy(): + numpy_agg = create_test_raster(_DATA, backend='numpy') + dask_cupy_agg = create_test_raster(_DATA, backend='dask+cupy') + np_result = morph_black_tophat(numpy_agg, kernel=_KERNEL_3x3, boundary='nearest') + dc_result = morph_black_tophat(dask_cupy_agg, kernel=_KERNEL_3x3, boundary='nearest') + np.testing.assert_allclose( + dc_result.data.compute().get(), np_result.data, equal_nan=True, + ) + + +# --------------------------------------------------------------------------- +# Dataset support +# --------------------------------------------------------------------------- + +def test_gradient_dataset(): + data = np.random.default_rng(1025).random((5, 5)).astype(np.float64) + ds = xr.Dataset({ + 'a': xr.DataArray(data, dims=['y', 'x']), + 'b': xr.DataArray(data * 2, dims=['y', 'x']), + }) + for func in [morph_gradient, morph_white_tophat, morph_black_tophat]: + result = func(ds, boundary='nearest') + assert isinstance(result, xr.Dataset) + assert set(result.data_vars) == {'a', 'b'} From 77d78a46d369d2d62fca11ef1e6bc24e16ca8148 Mon Sep 17 00:00:00 2001 From: Brendan Collins Date: Wed, 18 Mar 2026 06:10:01 -0700 Subject: [PATCH 3/4] Add docs and README entries for derived morphological operators (#1025) --- README.md | 3 +++ docs/source/reference/morphology.rst | 21 +++++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/README.md b/README.md index 8c3ed1d0..ca564422 100644 --- a/README.md +++ b/README.md @@ -181,6 +181,9 @@ In the GIS world, rasters are used for representing continuous phenomena (e.g. e | [Dilate](xrspatial/morphology.py) | Morphological dilation (local maximum over structuring element) | Standard (morphology) | ✅️ | ✅️ | ✅️ | ✅️ | | [Opening](xrspatial/morphology.py) | Erosion then dilation (removes small bright features) | Standard (morphology) | ✅️ | ✅️ | ✅️ | ✅️ | | [Closing](xrspatial/morphology.py) | Dilation then erosion (fills small dark gaps) | Standard (morphology) | ✅️ | ✅️ | ✅️ | ✅️ | +| [Gradient](xrspatial/morphology.py) | Dilation minus erosion (edge detection) | Standard (morphology) | ✅️ | ✅️ | ✅️ | ✅️ | +| [White Top-hat](xrspatial/morphology.py) | Original minus opening (isolate bright features) | Standard (morphology) | ✅️ | ✅️ | ✅️ | ✅️ | +| [Black Top-hat](xrspatial/morphology.py) | Closing minus original (isolate dark features) | Standard (morphology) | ✅️ | ✅️ | ✅️ | ✅️ | ------- diff --git a/docs/source/reference/morphology.rst b/docs/source/reference/morphology.rst index c6625a15..3475ddbc 100644 --- a/docs/source/reference/morphology.rst +++ b/docs/source/reference/morphology.rst @@ -32,6 +32,27 @@ Closing xrspatial.morphology.morph_closing +Gradient +======== +.. autosummary:: + :toctree: _autosummary + + xrspatial.morphology.morph_gradient + +White top-hat +============= +.. autosummary:: + :toctree: _autosummary + + xrspatial.morphology.morph_white_tophat + +Black top-hat +============= +.. autosummary:: + :toctree: _autosummary + + xrspatial.morphology.morph_black_tophat + Kernel Construction =================== .. autosummary:: From 9b658e5dd6e61138030344bd95c81a4c67670024 Mon Sep 17 00:00:00 2001 From: Brendan Collins Date: Wed, 18 Mar 2026 06:13:46 -0700 Subject: [PATCH 4/4] Add gradient/top-hat sections to morphology user guide notebook (#1025) --- .../17_Morphological_Operators.ipynb | 59 +++++++------------ 1 file changed, 21 insertions(+), 38 deletions(-) diff --git a/examples/user_guide/17_Morphological_Operators.ipynb b/examples/user_guide/17_Morphological_Operators.ipynb index 71c95232..9275a80f 100644 --- a/examples/user_guide/17_Morphological_Operators.ipynb +++ b/examples/user_guide/17_Morphological_Operators.ipynb @@ -3,28 +3,12 @@ { "cell_type": "markdown", "metadata": {}, - "source": [ - "# Xarray-Spatial Morphology: Erosion, dilation, opening, and closing\n", - "\n", - "Morphological operators filter rasters by sliding a structuring element (kernel) across the surface and picking the local minimum or maximum at each cell. They show up everywhere from cleaning noisy classification masks to smoothing elevation surfaces before further analysis. This notebook walks through the four operations in `xrspatial.morphology` on both continuous terrain and binary masks." - ] + "source": "# Xarray-Spatial Morphology\n\nMorphological operators filter rasters by sliding a structuring element (kernel) across the surface and picking the local minimum or maximum at each cell. They show up everywhere from cleaning noisy classification masks to smoothing elevation surfaces before further analysis. This notebook walks through the seven operations in `xrspatial.morphology` on both continuous terrain and binary masks." }, { "cell_type": "markdown", "metadata": {}, - "source": [ - "### What you'll build\n", - "\n", - "1. Generate synthetic terrain and a hillshade base layer\n", - "2. Apply erosion and dilation to see how local min/max reshape the surface\n", - "3. Use opening and closing to selectively remove noise\n", - "4. Clean up a noisy binary classification mask\n", - "5. Compare square and circular structuring elements\n", - "\n", - "![Morphological operators preview](images/morphological_operators_preview.png)\n", - "\n", - "[Erosion and dilation](#Erosion-and-dilation) · [Opening and closing](#Opening-and-closing) · [Binary mask cleanup](#Binary-mask-cleanup) · [Circular structuring element](#Circular-structuring-element)" - ] + "source": "### What you'll build\n\n1. Generate synthetic terrain and a hillshade base layer\n2. Apply erosion and dilation to see how local min/max reshape the surface\n3. Use opening and closing to selectively remove noise\n4. Clean up a noisy binary classification mask\n5. Compare square and circular structuring elements\n6. Use gradient, white top-hat, and black top-hat to extract features\n\n![Morphological operators preview](images/morphological_operators_preview.png)\n\n[Erosion and dilation](#Erosion-and-dilation) · [Opening and closing](#Opening-and-closing) · [Binary mask cleanup](#Binary-mask-cleanup) · [Circular structuring element](#Circular-structuring-element) · [Gradient, white top-hat, black top-hat](#Gradient,-white-top-hat,-and-black-top-hat)" }, { "cell_type": "markdown", @@ -45,25 +29,7 @@ } }, "outputs": [], - "source": [ - "%matplotlib inline\n", - "import numpy as np\n", - "import pandas as pd\n", - "import xarray as xr\n", - "\n", - "import matplotlib.pyplot as plt\n", - "from matplotlib.colors import ListedColormap\n", - "from matplotlib.patches import Patch\n", - "\n", - "import xrspatial\n", - "from xrspatial.morphology import (\n", - " _circle_kernel,\n", - " morph_closing,\n", - " morph_dilate,\n", - " morph_erode,\n", - " morph_opening,\n", - ")" - ] + "source": "%matplotlib inline\nimport numpy as np\nimport pandas as pd\nimport xarray as xr\n\nimport matplotlib.pyplot as plt\nfrom matplotlib.colors import ListedColormap\nfrom matplotlib.patches import Patch\n\nimport xrspatial\nfrom xrspatial.morphology import (\n _circle_kernel,\n morph_black_tophat,\n morph_closing,\n morph_dilate,\n morph_erode,\n morph_gradient,\n morph_opening,\n morph_white_tophat,\n)" }, { "cell_type": "markdown", @@ -488,6 +454,23 @@ "The square kernel reaches farther into the diagonal corners than the circular one, so it produces a slightly deeper erosion along ridges that run diagonally. The difference map and cross-section show that the disagreement concentrates on steep slopes where the extra corner pixels pull the minimum lower." ] }, + { + "cell_type": "markdown", + "source": "The gradient lights up wherever the terrain has steep transitions, making it a useful edge detector. The white top-hat pulls out peaks and ridges that are narrower than the 15x15 kernel, while the black top-hat does the same for small valleys and depressions. The bottom-right panel (white minus black) is positive on peaks and negative in valleys, giving a signed feature-size map.\n\nAll three results are non-negative by construction. The gradient equals the sum of the two top-hats (dilate - erode = (orig - opening) + (closing - orig) when opening <= orig <= closing), which you can verify in the cross-section.", + "metadata": {} + }, + { + "cell_type": "code", + "source": "### References\n\n- [Erosion (morphology)](https://en.wikipedia.org/wiki/Erosion_(morphology)), Wikipedia\n- [Dilation (morphology)](https://en.wikipedia.org/wiki/Dilation_(morphology)), Wikipedia\n- [Opening (morphology)](https://en.wikipedia.org/wiki/Opening_(morphology)), Wikipedia\n- [Closing (morphology)](https://en.wikipedia.org/wiki/Closing_(morphology)), Wikipedia\n- [Morphological gradient](https://en.wikipedia.org/wiki/Morphological_gradient), Wikipedia\n- [Top-hat transform](https://en.wikipedia.org/wiki/Top-hat_transform), Wikipedia\n- [Mathematical morphology](https://en.wikipedia.org/wiki/Mathematical_morphology), Wikipedia\n- [Structuring element](https://en.wikipedia.org/wiki/Structuring_element), Wikipedia", + "metadata": {}, + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "source": "## Gradient, white top-hat, and black top-hat\n\nThese three operations are derived from the primitives above:\n\n| Operation | Formula | What it extracts |\n|-----------|---------|-----------------|\n| **Gradient** | dilate - erode | Edges and transitions |\n| **White top-hat** | original - opening | Bright features smaller than the kernel |\n| **Black top-hat** | closing - original | Dark features smaller than the kernel |\n\nEach is a single subtraction of two morphological results, so all four backends are supported automatically.", + "metadata": {} + }, { "cell_type": "code", "execution_count": null, @@ -567,4 +550,4 @@ }, "nbformat": 4, "nbformat_minor": 4 -} +} \ No newline at end of file