ventuari.dev

Tutoriales / Agua

Cómo detectar cuerpos de agua con MNDWI y Google Earth Engine

Qué es el MNDWI, en qué se diferencia del NDWI y cómo usarlo en Google Earth Engine para mapear cuerpos de agua: un compuesto sin nubes, la máscara de agua, su superficie en hectáreas y la exportación a shapefile. El mismo flujo en el Code Editor (JavaScript) y en Colab (Python).

Por VentuariPublicado el 7 min de lectura

Imagen satelital en falso color de un río con sus lagunas y canales: el agua en tonos oscuros sobre vegetación verde.

Qué es el MNDWI y su fórmula

El agua refleja algo de luz verde pero absorbe casi todo el infrarrojo de onda corta. El suelo, la vegetación y las construcciones reflejan más infrarrojo de onda corta que verde. El MNDWI (índice de agua de diferencia normalizada modificado, Xu, 2006) aprovecha ese contraste:

MNDWI=Verde−SWIR1Verde+SWIR1\mathrm{MNDWI} = \dfrac{\text{Verde} - \mathrm{SWIR}_1}{\text{Verde} + \mathrm{SWIR}_1}

En Sentinel-2 el verde es B3 (560 nm, 10 m) y el infrarrojo de onda corta es B11 (1613,7 nm, 20 m), según el catálogo de Earth Engine para Sentinel-2A. Los valores positivos suelen ser agua. Frente al NDWI clásico, que usa el infrarrojo cercano, el MNDWI confunde menos el agua con zonas urbanas y suelo húmedo.

MNDWI vs NDWI: cuál usar

Con el nombre NDWI circulan dos índices distintos. El de McFeeters (1996) combina el verde y el infrarrojo cercano (B3 y B8 en Sentinel-2) y detecta agua abierta. El de Gao (1996) combina el infrarrojo cercano y el de onda corta (B8 y B11) y mide la humedad de la vegetación, no el agua superficial.

El MNDWI parte del NDWI de McFeeters y cambia el infrarrojo cercano por el de onda corta. Así separa mejor el agua de las zonas urbanas y del suelo húmedo, que con el NDWI se confunden con agua. Para mapear cuerpos de agua con Sentinel-2 o Landsat, el MNDWI suele ser la mejor opción; el NDWI de McFeeters funciona bien en agua abierta lejos de ciudades.

Elegir el umbral

El punto de partida habitual es 0: todo lo que esté por encima se considera agua. En aguas turbias o con vegetación flotante, como ciénagas y planos inundables, el valor puede bajar, y un umbral entre −0.1 y 0 recupera esas zonas. Conviene revisar el resultado sobre el color real antes de fijarlo.

El script

Pega esto en el Code Editor y cambia las fechas por las de tu periodo. El ejemplo es el embalse de Betania, en el Huila, entre enero y marzo de 2026.

El área de análisis es un polígono dibujado en el mapa. Antes de ejecutar, usa la herramienta de dibujo del Code Editor para trazar el contorno de tu zona; el polígono aparece en Imports con el nombre geometry, que es el que usa el script. Si le das otro nombre, cámbialo también en var aoi = geometry;.

agua_mndwi.js
/**
 * Water surface detection using Sentinel-2 MNDWI
 *
 * Location:
 *     Embalse de Betania
 *     Departamento: Huila
 *     Colombia
 *
 * Period:
 *     2026-01-01 to 2026-03-31
 */


/**
 * Configuration
 */
var fecha_inicio = '2026-01-01';

var fecha_fin = '2026-03-31';

var umbral_claro = 0.60;

var umbral_agua = 0;


/**
 * Area of interest
 *
 * Approximate extent of Embalse de Betania.
 */
var aoi = geometry;


/**
 * Collections
 */
var cloud_score_path = 'GOOGLE/CLOUD_SCORE_PLUS/V1/S2_HARMONIZED';

var sentinel_2_path = 'COPERNICUS/S2_SR_HARMONIZED';

var s2 = ee.ImageCollection(sentinel_2_path);

var cs_plus = ee.ImageCollection(cloud_score_path);


/**
 * Sentinel-2 cloud-filtered collection
 */
var imagenes = s2
    .filterBounds(aoi)
    .filterDate(fecha_inicio, fecha_fin)
    .linkCollection(cs_plus, ['cs_cdf'])
    .map(function (imagen) {
        var mascara_clara = imagen
            .select('cs_cdf')
            .gte(umbral_claro);

        return imagen.updateMask(mascara_clara);
    });

print('Número de imágenes Sentinel-2', imagenes.size());


/**
 * Cloud-free composite
 */
var compuesto = imagenes
    .median()
    .clip(aoi);


/**
 * MNDWI
 *
 * Green: B3
 * SWIR1: B11
 */
var mndwi = compuesto
    .normalizedDifference(['B3', 'B11'])
    .rename('mndwi');


/**
 * Water classification
 *
 * 0 = Non-water
 * 1 = Water
 */
var agua = mndwi
    .gt(umbral_agua)
    .rename('agua');


/**
 * Water surface area
 */
var hectareas = ee.Image
    .pixelArea()
    .divide(1e4)
    .rename('area_ha')
    .updateMask(agua)
    .reduceRegion({
        reducer: ee.Reducer.sum(),
        geometry: aoi,
        scale: 20,
        maxPixels: 1e13,
        tileScale: 4,
    })
    .get('area_ha');

print('Superficie de agua (ha)', hectareas);


/**
 * Water polygons
 */
var poligonos = agua
    .selfMask()
    .reduceToVectors({
        geometry: aoi,
        scale: 20,
        geometryType: 'polygon',
        eightConnected: false,
        labelProperty: 'agua',
        maxPixels: 1e13,
        tileScale: 4,
    });


/**
 * Visualization
 */
var vis_rgb = {
    bands: ['B4', 'B3', 'B2'],
    min: 0,
    max: 3000,
};

var vis_mndwi = {
    min: -0.6,
    max: 0.6,
    palette: [
        '#8c510a',
        '#f6e8c3',
        '#c7eae5',
        '#35978f',
        '#01665e',
    ],
};

var vis_agua = {
    min: 1,
    max: 1,
    palette: ['#2166ac'],
};


/**
 * Map
 */
Map.setOptions('SATELLITE');

Map.addLayer(compuesto, vis_rgb, 'Sentinel-2 RGB', true);

Map.addLayer(mndwi, vis_mndwi, 'MNDWI', false);

Map.addLayer(agua.selfMask(), vis_agua, 'Agua', true);

Map.addLayer(aoi, { color: 'ffffff' }, 'AOI', false);

print('Ventana temporal', fecha_inicio + ' a ' + fecha_fin);


/**
 * Export water mask
 */
Export.image.toDrive({
    image: agua.toByte(),
    description: 'agua_mndwi_betania_2026',
    folder: 'earthengine',
    fileNamePrefix: 'agua_mndwi_betania_2026',
    region: aoi,
    scale: 20,
    maxPixels: 1e13,
});


/**
 * Export water polygons
 */
Export.table.toDrive({
    collection: poligonos,
    description: 'agua_poligonos_betania_2026',
    folder: 'earthengine',
    fileNamePrefix: 'agua_poligonos_betania_2026',
    fileFormat: 'SHP',
});

En la consola aparecen el número de imágenes Sentinel-2 que entraron al compuesto, la superficie de agua en hectáreas y el periodo analizado. La capa MNDWI queda oculta en el mapa; actívala para ver cómo se distribuyen los valores antes de ajustar el umbral. En Tasks aparecen dos tareas: la máscara en GeoTIFF y los polígonos en shapefile.

La lógica es idéntica. En Colab no hay herramienta de dibujo, así que el polígono se escribe como una lista de coordenadas [lon, lat] en coordenadas_aoi. La superficie y el número de imágenes se traen con getInfo(), que aquí es seguro porque son valores sueltos.

agua_mndwi.py
"""
Water surface detection using Sentinel-2 MNDWI

Location:
    Embalse de Betania
    Departamento: Huila
    Colombia

Period:
    2026-01-01 to 2026-03-31
"""

import ee
import geemap

ee.Authenticate()
ee.Initialize(project='tu-proyecto-gcp')


# Configuration
fecha_inicio = '2026-01-01'
fecha_fin = '2026-03-31'
umbral_claro = 0.60
umbral_agua = 0


# Area of interest: approximate extent of Embalse de Betania.
# Same polygon as "geometry" in the Code Editor, as [lon, lat] pairs.
coordenadas_aoi = [
    # [lon, lat], [lon, lat], ...
]
aoi = ee.Geometry.Polygon([coordenadas_aoi])


# Collections
cloud_score_path = 'GOOGLE/CLOUD_SCORE_PLUS/V1/S2_HARMONIZED'
sentinel_2_path = 'COPERNICUS/S2_SR_HARMONIZED'

s2 = ee.ImageCollection(sentinel_2_path)
cs_plus = ee.ImageCollection(cloud_score_path)


# Sentinel-2 cloud-filtered collection
def enmascarar(imagen):
    mascara_clara = imagen.select('cs_cdf').gte(umbral_claro)
    return imagen.updateMask(mascara_clara)


imagenes = (
    s2.filterBounds(aoi)
    .filterDate(fecha_inicio, fecha_fin)
    .linkCollection(cs_plus, ['cs_cdf'])
    .map(enmascarar)
)

print('Número de imágenes Sentinel-2:', imagenes.size().getInfo())


# Cloud-free composite
compuesto = imagenes.median().clip(aoi)


# MNDWI (green: B3, SWIR1: B11)
mndwi = compuesto.normalizedDifference(['B3', 'B11']).rename('mndwi')


# Water classification: 0 = non-water, 1 = water
agua = mndwi.gt(umbral_agua).rename('agua')


# Water surface area
hectareas = (
    ee.Image.pixelArea()
    .divide(1e4)
    .rename('area_ha')
    .updateMask(agua)
    .reduceRegion(
        reducer=ee.Reducer.sum(),
        geometry=aoi,
        scale=20,
        maxPixels=1e13,
        tileScale=4,
    )
    .get('area_ha')
)

print('Superficie de agua (ha):', hectareas.getInfo())


# Water polygons
poligonos = agua.selfMask().reduceToVectors(
    geometry=aoi,
    scale=20,
    geometryType='polygon',
    eightConnected=False,
    labelProperty='agua',
    maxPixels=1e13,
    tileScale=4,
)


# Visualization
vis_rgb = {'bands': ['B4', 'B3', 'B2'], 'min': 0, 'max': 3000}

vis_mndwi = {
    'min': -0.6,
    'max': 0.6,
    'palette': ['#8c510a', '#f6e8c3', '#c7eae5', '#35978f', '#01665e'],
}

vis_agua = {'min': 1, 'max': 1, 'palette': ['#2166ac']}


# Map
mapa = geemap.Map()
mapa.add_basemap('SATELLITE')
mapa.centerObject(aoi, 11)
mapa.addLayer(compuesto, vis_rgb, 'Sentinel-2 RGB', True)
mapa.addLayer(mndwi, vis_mndwi, 'MNDWI', False)
mapa.addLayer(agua.selfMask(), vis_agua, 'Agua', True)
mapa.addLayer(aoi, {'color': 'ffffff'}, 'AOI', False)
mapa

print('Ventana temporal:', fecha_inicio, 'a', fecha_fin)


# Export water mask
ee.batch.Export.image.toDrive(
    image=agua.toByte(),
    description='agua_mndwi_betania_2026',
    folder='earthengine',
    fileNamePrefix='agua_mndwi_betania_2026',
    region=aoi,
    scale=20,
    maxPixels=1e13,
).start()


# Export water polygons
ee.batch.Export.table.toDrive(
    collection=poligonos,
    description='agua_poligonos_betania_2026',
    folder='earthengine',
    fileNamePrefix='agua_poligonos_betania_2026',
    fileFormat='SHP',
).start()

6.089 hectáreas de agua en el embalse de Betania

Con el script de arriba, el compuesto de enero a marzo de 2026 detecta 6.089 hectáreas de agua en el embalse de Betania. El MNDWI separa con claridad el espejo de agua y el cauce del río Magdalena del suelo y la vegetación que los rodean, y también marca cuerpos de agua pequeños alrededor del embalse.

Mapa MNDWI del embalse de Betania, Huila: el embalse y el río en verde azulado sobre un fondo de suelo y vegetación en tonos marrones y beige.
MNDWI del compuesto de enero a marzo de 2026. En verde azulado, el agua; en marrón y beige, suelo y vegetación.
Superficie de agua6.088,57 ha
UbicaciónEmbalse de Betania, Huila
Periodo1 ene – 31 mar 2026

Lo que la IA suele hacer mal

Si le pides este script a un asistente de IA, lo más probable es que funcione a medias. Estos son los fallos que más aparecen en el código generado, y cómo los evita el script de arriba:

  1. Confunde los NDWI. Hay dos índices con el mismo nombre: el de McFeeters (B3, B8) detecta agua abierta y el de Gao (B8, B11) mide humedad de la vegetación. Mezclarlos da mapas que no significan lo que parecen.
  2. Calcula el área contando píxeles por 100 m². Supone píxeles de 10 m aunque el cálculo se haga a 20 m y no corrige la proyección. ee.Image.pixelArea() da el área real de cada píxel.
  3. Fija el umbral en 0 sin revisarlo. Sirve para agua clara, pero en ciénagas turbias o con vegetación flotante deja fuera buena parte del agua. Compara siempre con el color real.
  4. Vectoriza sin escala ni maxPixels. reduceToVectors sobre un área grande sin esos parámetros falla o genera millones de polígonos diminutos.

Cómo leer los valores

Los rangos son orientativos y cambian con la turbidez del agua y la época del año.

MNDWILo que suele indicar
Mayor que 0.3Agua abierta y clara: lagos, embalses, ríos anchos
0 a 0.3Agua turbia, poco profunda o mezclada con vegetación
−0.2 a 0Suelo muy húmedo, humedales, bordes de cuerpos de agua
Menor que −0.2Vegetación, suelo seco y zonas construidas

Cuando el MNDWI se equivoca: sombras, ciénagas y vectores pesados

Sombras de montañas o de nubes detectadas como agua

Las sombras también absorben el infrarrojo y pueden pasar el umbral. En zonas de relieve, descarta las pendientes fuertes con un modelo de elevación, por ejemplo ee.Terrain.slope(ee.Image('USGS/SRTMGL1_003')).lt(10) como máscara adicional.

La exportación a shapefile falla o tarda demasiado

Un área grande a 20 m produce millones de polígonos pequeños. Sube la escala de reduceToVectors a 30 o 60 m, o elimina los parches diminutos antes de vectorizar con agua.selfMask().connectedPixelCount(100).gte(10).

El agua de una ciénaga aparece fragmentada

La vegetación flotante y la turbidez bajan el MNDWI. Prueba un umbral de −0.1 o combina el resultado con la frecuencia histórica de agua del dataset JRC Global Surface Water.

Preguntas frecuentes

¿Qué es el MNDWI?

Es el índice de agua de diferencia normalizada modificado (Xu, 2006). Combina la banda verde y la de infrarrojo de onda corta para resaltar el agua superficial: los valores positivos suelen corresponder a agua.

¿Cuál es la fórmula del MNDWI en Sentinel-2?

MNDWI=(Verde−SWIR1) / (Verde+SWIR1)\mathrm{MNDWI} = (\text{Verde} - \mathrm{SWIR}_1) \,/\, (\text{Verde} + \mathrm{SWIR}_1). En Sentinel-2 se usan las bandas B3 (verde, 560 nm) y B11 (infrarrojo de onda corta, 1613,7 nm).

¿Qué diferencia hay entre el NDWI y el MNDWI?

El NDWI de McFeeters usa verde e infrarrojo cercano; el MNDWI usa verde e infrarrojo de onda corta. El MNDWI confunde menos el agua con zonas urbanas y suelo húmedo, por eso es preferible para mapear cuerpos de agua.

¿Qué umbral de MNDWI indica agua?

El punto de partida habitual es 0. En aguas turbias, ciénagas o con vegetación flotante, un umbral entre −0.1 y 0 recupera más agua. Conviene revisar el resultado sobre la imagen en color real antes de fijarlo.

Fuentes

  1. Modification of normalised difference water index (NDWI) to enhance open water features in remotely sensed imageryXu, H. (2006). International Journal of Remote SensingArtículo original del MNDWI.
  2. The use of the Normalized Difference Water Index (NDWI) in the delineation of open water featuresMcFeeters, S. K. (1996). International Journal of Remote SensingNDWI con el verde y el infrarrojo cercano, para agua abierta.
  3. NDWI—A normalized difference water index for remote sensing of vegetation liquid water from spaceGao, B.-C. (1996). Remote Sensing of EnvironmentNDWI con el infrarrojo cercano y el de onda corta, para la humedad de la vegetación.
  4. Harmonized Sentinel-2 MSI: MultiSpectral Instrument, Level-2A (SR)Google Earth Engine, catálogo de datosBandas B3 y B11 y sus longitudes de onda.
  5. JRC Global Surface Water Mapping Layers, v1.4Google Earth Engine, catálogo de datosFrecuencia histórica de agua para comparar y completar el resultado.
  6. NASA SRTM Digital Elevation 30mGoogle Earth Engine, catálogo de datosModelo de elevación para descartar sombras de montaña.

Sigue aprendiendo

Mira esta técnica aplicada en el mapa de inundaciones en Córdoba y Antioquia. ¿Lo necesitas hecho y verificado para tu zona? Mira el servicio Tu análisis, automatizado.

¿Necesitas mapas de agua para varios años o un proceso que los genere solo?

Escribe a contacto@ventuari.dev con el área y el periodo, y recibirás una propuesta con alcance y precio.