Extracción de cuerpos de agua con Landsat 9 en Google Earth Engine
Extrae ríos, ciénagas y lagunas como polígonos a partir de Landsat 9 y expórtalos como shapefile.
Introducción #
Un índice de agua nos dice qué píxeles son agua, pero muchas veces lo que necesitamos es un shapefile con el contorno de ríos, ciénagas y lagunas para llevarlo a QGIS o ArcGIS Pro. En esta lección pasaremos de la imagen al vector, limpiando antes el ruido de la máscara para que los polígonos salgan ordenados.
Objetivos #
- Crear una máscara de agua combinando NDWI, MNDWI y AWEInsh.
- Eliminar islas y charcos pequeños con
connectedPixelCount(). - Convertir la máscara en polígonos con
reduceToVectors(). - Exportar los cuerpos de agua como shapefile a Google Drive.
Recursos #
- ee.Image.reduceToVectors()
- ee.Image.connectedPixelCount()
- Landsat 9 Surface Reflectance (LANDSAT/LC09/C02/T1_L2)
Paso 1: Imagen e índices #
El script usa una geometría llamada AOI. Dibújala con las herramientas de geometría del mapa en el Code Editor y renómbrala como AOI en el panel de importaciones.
Preparamos una escena de Landsat 9 con máscara de nubes y factor de escala, y calculamos NDWI, MNDWI y AWEInsh tal como en la lección Detección de agua con Landsat 9.
// Factor de escala: convierte los valores digitales en reflectancia function scaler(img) { var optical = img.select('SR_B.').multiply(0.0000275).add(-0.2).toFloat(); var thermal = img.select('ST_B.*').multiply(0.00341802).add(149).toFloat(); return img.addBands(optical, null, true).addBands(thermal, null, true); } // Máscara de nubes con la banda QA_PIXEL function mask_clouds(img) { var qa = img.select('QA_PIXEL'); var cirrus = 1 << 2; // bit 2, cirros var cloud = 1 << 3; // bit 3, nubes var cloudShadow = 1 << 4; // bit 4, sombra de nubes var snow = 1 << 5; // bit 5, nieve var mask = qa.bitwiseAnd(cirrus).eq(0) .and(qa.bitwiseAnd(cloud).eq(0)) .and(qa.bitwiseAnd(cloudShadow).eq(0)) .and(qa.bitwiseAnd(snow).eq(0)); return img.updateMask(mask); }
// Primera imagen de Landsat 9 con poca nubosidad sobre el AOI var ls9_image = ee.ImageCollection("LANDSAT/LC09/C02/T1_L2") .filterDate('2023-01-01', '2023-12-31') .filter(ee.Filter.lt("CLOUD_COVER_LAND", 10)) .filterBounds(AOI) .map(mask_clouds) .map(scaler) .first() .clip(AOI); print(ls9_image);
// Falso color NIR-Rojo-Verde, donde el agua se ve oscura y la vegetación roja Map.addLayer(ls9_image, {bands: "SR_B5,SR_B4,SR_B3", min: 0.01, max: 0.41, gamma: 1}, "Landsat 9"); Map.centerObject(AOI, 12);
// Índices para detectar agua var NDWI = ls9_image.normalizedDifference(['SR_B3', 'SR_B5']).rename('NDWI'); var MNDWI = ls9_image.normalizedDifference(['SR_B3', 'SR_B6']).rename('MNDWI'); var AWEInsh = ls9_image.expression('4 * (GREEN - SWIR1) - (0.25 * NIR + 2.75 * SWIR2)', { 'GREEN': ls9_image.select('SR_B3'), 'SWIR1': ls9_image.select('SR_B6'), 'SWIR2': ls9_image.select('SR_B7'), 'NIR': ls9_image.select('SR_B5') }).rename('AWEInsh'); var index = NDWI.addBands([MNDWI, AWEInsh]);
Paso 2: Máscara de agua #
Un píxel es agua si NDWI o MNDWI superan 0, o si AWEInsh supera 0,01. Combinar los tres índices ayuda a no perder orillas ni zonas con vegetación flotante.
// Máscara de agua con los tres índices var waterMask = function(img) { var water = img.select('NDWI').gt(0) .or(img.select('MNDWI').gt(0)) .or(img.select('AWEInsh').gt(0.01)); return water.rename('waterMask'); }; var masking = waterMask(index); Map.addLayer(masking, {}, 'Máscara de agua');
Paso 3: Limpiar la máscara #
La máscara cruda tiene píxeles sueltos que generarían cientos de polígonos diminutos. connectedPixelCount() cuenta cuántos píxeles conectados tiene cada grupo, y con eso rellenamos las islas pequeñas dentro del agua y eliminamos el agua aislada de pocos píxeles. El parámetro FILL_SIZE controla qué tan agresiva es la limpieza.
// Rellenar islas pequeñas: tierra rodeada de agua con menos de FILL_SIZE píxeles var removeIslands = function(img, FILL_SIZE) { var fill = img.not().selfMask().connectedPixelCount(FILL_SIZE).lt(FILL_SIZE); return img.where(fill, 1).rename('waterMask'); }; // Quitar charcos pequeños: agua aislada con menos de FILL_SIZE píxeles var removeSmallWater = function(img, FILL_SIZE) { var fill = img.connectedPixelCount(FILL_SIZE).lt(FILL_SIZE); return img.where(fill, 0).rename('waterMask'); }; var mask_clean = removeSmallWater(removeIslands(masking, 30), 10); Map.addLayer(mask_clean, {}, 'Máscara limpia');
Paso 4: Vectorizar y exportar #
reduceToVectors() agrupa los píxeles contiguos de agua en polígonos. La tabla resultante se exporta como shapefile y puedes abrirla directamente en QGIS.
// Convertir los píxeles de agua en polígonos var waterPolygons = mask_clean.selfMask().reduceToVectors({ geometry: AOI, scale: 30, geometryType: 'polygon', eightConnected: false, labelProperty: 'zone', maxPixels: 1e13 }); Map.addLayer(waterPolygons, {color: 'cyan'}, "Polígonos de agua"); // Exportar como shapefile a Google Drive Export.table.toDrive({ collection: waterPolygons, description: "water_polygons_landsat", folder: "shapes", fileFormat: "SHP" });
Código completo #
Este es el script completo. Cópialo, pégalo en el Code Editor, dibuja tu AOI y presiona Run.
// Factor de escala: convierte los valores digitales en reflectancia function scaler(img) { var optical = img.select('SR_B.').multiply(0.0000275).add(-0.2).toFloat(); var thermal = img.select('ST_B.*').multiply(0.00341802).add(149).toFloat(); return img.addBands(optical, null, true).addBands(thermal, null, true); } // Máscara de nubes con la banda QA_PIXEL function mask_clouds(img) { var qa = img.select('QA_PIXEL'); var cirrus = 1 << 2; // bit 2, cirros var cloud = 1 << 3; // bit 3, nubes var cloudShadow = 1 << 4; // bit 4, sombra de nubes var snow = 1 << 5; // bit 5, nieve var mask = qa.bitwiseAnd(cirrus).eq(0) .and(qa.bitwiseAnd(cloud).eq(0)) .and(qa.bitwiseAnd(cloudShadow).eq(0)) .and(qa.bitwiseAnd(snow).eq(0)); return img.updateMask(mask); } // Primera imagen de Landsat 9 con poca nubosidad sobre el AOI var ls9_image = ee.ImageCollection("LANDSAT/LC09/C02/T1_L2") .filterDate('2023-01-01', '2023-12-31') .filter(ee.Filter.lt("CLOUD_COVER_LAND", 10)) .filterBounds(AOI) .map(mask_clouds) .map(scaler) .first() .clip(AOI); print(ls9_image); // Falso color NIR-Rojo-Verde, donde el agua se ve oscura y la vegetación roja Map.addLayer(ls9_image, {bands: "SR_B5,SR_B4,SR_B3", min: 0.01, max: 0.41, gamma: 1}, "Landsat 9"); Map.centerObject(AOI, 12); // Índices para detectar agua var NDWI = ls9_image.normalizedDifference(['SR_B3', 'SR_B5']).rename('NDWI'); var MNDWI = ls9_image.normalizedDifference(['SR_B3', 'SR_B6']).rename('MNDWI'); var AWEInsh = ls9_image.expression('4 * (GREEN - SWIR1) - (0.25 * NIR + 2.75 * SWIR2)', { 'GREEN': ls9_image.select('SR_B3'), 'SWIR1': ls9_image.select('SR_B6'), 'SWIR2': ls9_image.select('SR_B7'), 'NIR': ls9_image.select('SR_B5') }).rename('AWEInsh'); var index = NDWI.addBands([MNDWI, AWEInsh]); // Máscara de agua con los tres índices var waterMask = function(img) { var water = img.select('NDWI').gt(0) .or(img.select('MNDWI').gt(0)) .or(img.select('AWEInsh').gt(0.01)); return water.rename('waterMask'); }; var masking = waterMask(index); Map.addLayer(masking, {}, 'Máscara de agua'); // Rellenar islas pequeñas: tierra rodeada de agua con menos de FILL_SIZE píxeles var removeIslands = function(img, FILL_SIZE) { var fill = img.not().selfMask().connectedPixelCount(FILL_SIZE).lt(FILL_SIZE); return img.where(fill, 1).rename('waterMask'); }; // Quitar charcos pequeños: agua aislada con menos de FILL_SIZE píxeles var removeSmallWater = function(img, FILL_SIZE) { var fill = img.connectedPixelCount(FILL_SIZE).lt(FILL_SIZE); return img.where(fill, 0).rename('waterMask'); }; var mask_clean = removeSmallWater(removeIslands(masking, 30), 10); Map.addLayer(mask_clean, {}, 'Máscara limpia'); // Convertir los píxeles de agua en polígonos var waterPolygons = mask_clean.selfMask().reduceToVectors({ geometry: AOI, scale: 30, geometryType: 'polygon', eightConnected: false, labelProperty: 'zone', maxPixels: 1e13 }); Map.addLayer(waterPolygons, {color: 'cyan'}, "Polígonos de agua"); // Exportar como shapefile a Google Drive Export.table.toDrive({ collection: waterPolygons, description: "water_polygons_landsat", folder: "shapes", fileFormat: "SHP" });
Conclusiones #
Con este flujo pasas de una imagen satelital a un shapefile de cuerpos de agua listo para medir, editar o cruzar con otras capas. Ajusta los umbrales de los índices y el tamaño de limpieza según tu zona, porque no es lo mismo una ciénaga costera que un embalse de montaña.
Referencias #
- McFeeters, S. K. (1996). The use of the Normalized Difference Water Index (NDWI) in the delineation of open water features. International Journal of Remote Sensing, 17(7), 1425–1432.
- Gao, B.-C. (1996). NDWI—A normalized difference water index for remote sensing of vegetation liquid water from space. Remote Sensing of Environment, 58(3), 257–266.
- Xu, H. (2006). Modification of normalised difference water index (NDWI) to enhance open water features in remotely sensed imagery. International Journal of Remote Sensing, 27(14), 3025–3033.
- Feyisa, G. L., Meilby, H., Fensholt, R. y Proud, S. R. (2014). Automated Water Extraction Index: A new technique for surface water mapping using Landsat imagery. Remote Sensing of Environment, 140, 23–35.
