Skip to content

Commit 64e59b2

Browse files
authored
Add NDWI and MNDWI water indices (#959)
* Add NDWI and MNDWI water indices (#948) Add ndwi() and mndwi() to the multispectral module for mapping surface water bodies. Both reuse the existing normalized-ratio backend functions, so all four backends (NumPy, Dask, CuPy, Dask+CuPy) work out of the box. Includes DataArray/Dataset accessor methods and tests against reference values. * Add docs, README entry, and user guide for NDWI/MNDWI (#948) - Add API reference entries in multispectral.rst - Add two rows to the README feature matrix - Add notebook 17_Water_Indices.ipynb with synthetic data demo * Rename water indices notebook to 18 (#948) Number 17 is taken by another in-flight branch.
1 parent 1c79e4b commit 64e59b2

File tree

7 files changed

+484
-2
lines changed

7 files changed

+484
-2
lines changed

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,8 @@ In the GIS world, rasters are used for representing continuous phenomena (e.g. e
207207
| [Normalized Burn Ratio (NBR)](xrspatial/multispectral.py) | Measures burn severity using NIR and SWIR band difference | ✅️ |✅️ | ✅️ |✅️ |
208208
| [Normalized Burn Ratio 2 (NBR2)](xrspatial/multispectral.py) | Refines burn severity mapping using two SWIR bands | ✅️ |✅️ | ✅️ |✅️ |
209209
| [Normalized Difference Moisture Index (NDMI)](xrspatial/multispectral.py) | Detects vegetation moisture stress from NIR and SWIR reflectance | ✅️ |✅️ | ✅️ |✅️ |
210+
| [Normalized Difference Water Index (NDWI)](xrspatial/multispectral.py) | Maps open water bodies using green and NIR band difference | ✅️ |✅️ | ✅️ |✅️ |
211+
| [Modified Normalized Difference Water Index (MNDWI)](xrspatial/multispectral.py) | Detects water in urban areas using green and SWIR bands | ✅️ |✅️ | ✅️ |✅️ |
210212
| [Normalized Difference Vegetation Index (NDVI)](xrspatial/multispectral.py) | Quantifies vegetation density from red and NIR band difference | ✅️ |✅️ | ✅️ |✅️ |
211213
| [Soil Adjusted Vegetation Index (SAVI)](xrspatial/multispectral.py) | Vegetation index with soil brightness correction factor | ✅️ |✅️ | ✅️ |✅️ |
212214
| [Structure Insensitive Pigment Index (SIPI)](xrspatial/multispectral.py) | Estimates carotenoid-to-chlorophyll ratio for plant stress detection | ✅️ |✅️ | ✅️ |✅️ |

docs/source/reference/multispectral.rst

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,20 @@ Normalized Difference Moisture Index (NDMI)
5353

5454
xrspatial.multispectral.ndmi
5555

56+
Normalized Difference Water Index (NDWI)
57+
========================================
58+
.. autosummary::
59+
:toctree: _autosummary
60+
61+
xrspatial.multispectral.ndwi
62+
63+
Modified Normalized Difference Water Index (MNDWI)
64+
==================================================
65+
.. autosummary::
66+
:toctree: _autosummary
67+
68+
xrspatial.multispectral.mndwi
69+
5670
Normalized Difference Vegetation Index (NDVI)
5771
=============================================
5872
.. autosummary::
Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
{
2+
"cells": [
3+
{
4+
"cell_type": "markdown",
5+
"metadata": {},
6+
"source": [
7+
"# Water Indices: NDWI and MNDWI\n",
8+
"\n",
9+
"This notebook demonstrates the **Normalized Difference Water Index (NDWI)** and\n",
10+
"**Modified Normalized Difference Water Index (MNDWI)** functions in xarray-spatial.\n",
11+
"\n",
12+
"- **NDWI** (McFeeters 1996): `(Green - NIR) / (Green + NIR)` -- highlights open water while suppressing vegetation and soil.\n",
13+
"- **MNDWI** (Xu 2006): `(Green - SWIR) / (Green + SWIR)` -- works better in urban areas by reducing false positives from built-up surfaces.\n",
14+
"\n",
15+
"Both return values in [-1, 1]. Positive values generally indicate water."
16+
]
17+
},
18+
{
19+
"cell_type": "code",
20+
"execution_count": null,
21+
"metadata": {},
22+
"outputs": [],
23+
"source": [
24+
"import numpy as np\n",
25+
"import xarray as xr\n",
26+
"import matplotlib.pyplot as plt\n",
27+
"\n",
28+
"from xrspatial.multispectral import ndwi, mndwi"
29+
]
30+
},
31+
{
32+
"cell_type": "markdown",
33+
"metadata": {},
34+
"source": [
35+
"## Create synthetic band data\n",
36+
"\n",
37+
"We build a simple scene with a water body (high green reflectance, low NIR/SWIR)\n",
38+
"surrounded by vegetation (low green, high NIR) and bare soil."
39+
]
40+
},
41+
{
42+
"cell_type": "code",
43+
"execution_count": null,
44+
"metadata": {},
45+
"outputs": [],
46+
"source": [
47+
"np.random.seed(42)\n",
48+
"rows, cols = 100, 100\n",
49+
"y = np.arange(rows)\n",
50+
"x = np.arange(cols)\n",
51+
"\n",
52+
"# Create a circular water body in the center\n",
53+
"yy, xx = np.meshgrid(y, x, indexing='ij')\n",
54+
"dist = np.sqrt((yy - 50)**2 + (xx - 50)**2)\n",
55+
"water_mask = dist < 20\n",
56+
"veg_mask = (dist >= 20) & (dist < 40)\n",
57+
"soil_mask = dist >= 40\n",
58+
"\n",
59+
"# Green band: water=0.3, vegetation=0.05, soil=0.15\n",
60+
"green = np.where(water_mask, 0.30, np.where(veg_mask, 0.05, 0.15))\n",
61+
"green += np.random.normal(0, 0.01, green.shape)\n",
62+
"\n",
63+
"# NIR band: water=0.02, vegetation=0.45, soil=0.25\n",
64+
"nir = np.where(water_mask, 0.02, np.where(veg_mask, 0.45, 0.25))\n",
65+
"nir += np.random.normal(0, 0.01, nir.shape)\n",
66+
"\n",
67+
"# SWIR band: water=0.01, vegetation=0.20, soil=0.35\n",
68+
"swir = np.where(water_mask, 0.01, np.where(veg_mask, 0.20, 0.35))\n",
69+
"swir += np.random.normal(0, 0.01, swir.shape)\n",
70+
"\n",
71+
"green_da = xr.DataArray(green, dims=['y', 'x'], coords={'y': y, 'x': x})\n",
72+
"nir_da = xr.DataArray(nir, dims=['y', 'x'], coords={'y': y, 'x': x})\n",
73+
"swir_da = xr.DataArray(swir, dims=['y', 'x'], coords={'y': y, 'x': x})\n",
74+
"\n",
75+
"fig, axes = plt.subplots(1, 3, figsize=(14, 4))\n",
76+
"green_da.plot(ax=axes[0], cmap='Greens')\n",
77+
"axes[0].set_title('Green Band')\n",
78+
"nir_da.plot(ax=axes[1], cmap='Reds')\n",
79+
"axes[1].set_title('NIR Band')\n",
80+
"swir_da.plot(ax=axes[2], cmap='copper')\n",
81+
"axes[2].set_title('SWIR Band')\n",
82+
"plt.tight_layout()\n",
83+
"plt.show()"
84+
]
85+
},
86+
{
87+
"cell_type": "markdown",
88+
"metadata": {},
89+
"source": [
90+
"## Compute NDWI\n",
91+
"\n",
92+
"`ndwi(green, nir)` returns `(Green - NIR) / (Green + NIR)`. Water pixels produce\n",
93+
"positive values because green reflectance exceeds NIR over water."
94+
]
95+
},
96+
{
97+
"cell_type": "code",
98+
"execution_count": null,
99+
"metadata": {},
100+
"outputs": [],
101+
"source": [
102+
"ndwi_result = ndwi(green_da, nir_da)\n",
103+
"\n",
104+
"fig, axes = plt.subplots(1, 2, figsize=(10, 4))\n",
105+
"ndwi_result.plot(ax=axes[0], cmap='RdYlBu', vmin=-1, vmax=1)\n",
106+
"axes[0].set_title('NDWI')\n",
107+
"\n",
108+
"# Threshold at 0 to create a binary water mask\n",
109+
"water_detected = (ndwi_result > 0).astype(float)\n",
110+
"water_detected.plot(ax=axes[1], cmap='Blues', vmin=0, vmax=1)\n",
111+
"axes[1].set_title('NDWI > 0 (water mask)')\n",
112+
"plt.tight_layout()\n",
113+
"plt.show()"
114+
]
115+
},
116+
{
117+
"cell_type": "markdown",
118+
"metadata": {},
119+
"source": [
120+
"## Compute MNDWI\n",
121+
"\n",
122+
"`mndwi(green, swir)` substitutes SWIR for NIR. This reduces false water\n",
123+
"detections in built-up areas where NIR can be ambiguous."
124+
]
125+
},
126+
{
127+
"cell_type": "code",
128+
"execution_count": null,
129+
"metadata": {},
130+
"outputs": [],
131+
"source": [
132+
"mndwi_result = mndwi(green_da, swir_da)\n",
133+
"\n",
134+
"fig, axes = plt.subplots(1, 2, figsize=(10, 4))\n",
135+
"mndwi_result.plot(ax=axes[0], cmap='RdYlBu', vmin=-1, vmax=1)\n",
136+
"axes[0].set_title('MNDWI')\n",
137+
"\n",
138+
"water_detected_m = (mndwi_result > 0).astype(float)\n",
139+
"water_detected_m.plot(ax=axes[1], cmap='Blues', vmin=0, vmax=1)\n",
140+
"axes[1].set_title('MNDWI > 0 (water mask)')\n",
141+
"plt.tight_layout()\n",
142+
"plt.show()"
143+
]
144+
},
145+
{
146+
"cell_type": "markdown",
147+
"metadata": {},
148+
"source": [
149+
"## Compare NDWI vs MNDWI\n",
150+
"\n",
151+
"Side-by-side comparison shows that both indices detect the same water body.\n",
152+
"The difference between them is most visible in urban or mixed-use scenes\n",
153+
"where built-up surfaces can confuse NDWI."
154+
]
155+
},
156+
{
157+
"cell_type": "code",
158+
"execution_count": null,
159+
"metadata": {},
160+
"outputs": [],
161+
"source": [
162+
"fig, axes = plt.subplots(1, 3, figsize=(14, 4))\n",
163+
"\n",
164+
"ndwi_result.plot(ax=axes[0], cmap='RdYlBu', vmin=-1, vmax=1)\n",
165+
"axes[0].set_title('NDWI (Green vs NIR)')\n",
166+
"\n",
167+
"mndwi_result.plot(ax=axes[1], cmap='RdYlBu', vmin=-1, vmax=1)\n",
168+
"axes[1].set_title('MNDWI (Green vs SWIR)')\n",
169+
"\n",
170+
"diff = mndwi_result - ndwi_result\n",
171+
"diff.plot(ax=axes[2], cmap='coolwarm', center=0)\n",
172+
"axes[2].set_title('MNDWI - NDWI')\n",
173+
"plt.tight_layout()\n",
174+
"plt.show()"
175+
]
176+
},
177+
{
178+
"cell_type": "markdown",
179+
"metadata": {},
180+
"source": [
181+
"## Using the accessor interface\n",
182+
"\n",
183+
"You can also call NDWI and MNDWI through the `.xrs` accessor on any DataArray.\n",
184+
"When called on the green band, pass the other band as the first argument."
185+
]
186+
},
187+
{
188+
"cell_type": "code",
189+
"execution_count": null,
190+
"metadata": {},
191+
"outputs": [],
192+
"source": [
193+
"import xrspatial # registers .xrs accessor\n",
194+
"\n",
195+
"# Accessor: self = green band\n",
196+
"ndwi_acc = green_da.xrs.ndwi(nir_da)\n",
197+
"mndwi_acc = green_da.xrs.mndwi(swir_da)\n",
198+
"\n",
199+
"# Verify results match\n",
200+
"assert np.allclose(ndwi_result.values, ndwi_acc.values, equal_nan=True)\n",
201+
"assert np.allclose(mndwi_result.values, mndwi_acc.values, equal_nan=True)\n",
202+
"print('Accessor results match direct function calls.')"
203+
]
204+
}
205+
],
206+
"metadata": {
207+
"kernelspec": {
208+
"display_name": "Python 3",
209+
"language": "python",
210+
"name": "python3"
211+
},
212+
"language_info": {
213+
"name": "python",
214+
"version": "3.10.0"
215+
}
216+
},
217+
"nbformat": 4,
218+
"nbformat_minor": 4
219+
}

xrspatial/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,10 @@
5252
from xrspatial.mahalanobis import mahalanobis # noqa
5353
from xrspatial.multispectral import arvi # noqa
5454
from xrspatial.multispectral import evi # noqa
55+
from xrspatial.multispectral import mndwi # noqa
5556
from xrspatial.multispectral import nbr # noqa
5657
from xrspatial.multispectral import ndvi # noqa
58+
from xrspatial.multispectral import ndwi # noqa
5759
from xrspatial.multispectral import savi # noqa
5860
from xrspatial.multispectral import sipi # noqa
5961
from xrspatial.pathfinding import a_star_search # noqa

xrspatial/accessor.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -343,6 +343,16 @@ def kbdi(self, max_temp_agg, precip_agg, annual_precip, **kwargs):
343343
from .fire import kbdi
344344
return kbdi(self._obj, max_temp_agg, precip_agg, annual_precip, **kwargs)
345345

346+
# ---- Multispectral (self = green band for water indices) ----
347+
348+
def ndwi(self, nir_agg, **kwargs):
349+
from .multispectral import ndwi
350+
return ndwi(self._obj, nir_agg, **kwargs)
351+
352+
def mndwi(self, swir_agg, **kwargs):
353+
from .multispectral import mndwi
354+
return mndwi(self._obj, swir_agg, **kwargs)
355+
346356
# ---- Multispectral (self = NIR band) ----
347357

348358
def ndvi(self, red_agg, **kwargs):
@@ -601,6 +611,14 @@ def flame_length(self, **kwargs):
601611

602612
# ---- Multispectral (band mapping via kwargs) ----
603613

614+
def ndwi(self, green, nir, **kwargs):
615+
from .multispectral import ndwi
616+
return ndwi(self._obj, green=green, nir=nir, **kwargs)
617+
618+
def mndwi(self, green, swir, **kwargs):
619+
from .multispectral import mndwi
620+
return mndwi(self._obj, green=green, swir=swir, **kwargs)
621+
604622
def ndvi(self, nir, red, **kwargs):
605623
from .multispectral import ndvi
606624
return ndvi(self._obj, nir=nir, red=red, **kwargs)

0 commit comments

Comments
 (0)