ventuari.dev

Tutoriales / Incendios

Cómo detectar áreas quemadas con dNBR y Google Earth Engine

Cómo calcular el índice NBR y el dNBR en Google Earth Engine con compuestos antes y después del fuego, y obtener un mapa de severidad del incendio y el área quemada en hectáreas. El mismo flujo en el Code Editor (JavaScript) y en Colab (Python).

Por VentuariPublicado el 8 min de lectura

Mapa de severidad de un incendio sobre imagen Sentinel-2: zonas quemadas en tonos de amarillo a rojo oscuro sobre vegetación verde.

Qué es el índice NBR y el dNBR

La vegetación sana refleja mucho el infrarrojo cercano y poco el infrarrojo de onda corta. Cuando se quema, esa relación se invierte: el infrarrojo cercano cae y el de onda corta sube. El NBR (Normalized Burn Ratio, índice normalizado de quema) resume ese contraste:

NBR=NIR−SWIR2NIR+SWIR2\mathrm{NBR} = \dfrac{\mathrm{NIR} - \mathrm{SWIR}_2}{\mathrm{NIR} + \mathrm{SWIR}_2}

En Sentinel-2 se usan B8A (864,8 nm) y B12 (2202,4 nm en Sentinel-2A y 2185,7 nm en 2B), ambas a 20 m, según el catálogo de Earth Engine. Un solo NBR no basta para saber qué se quemó, porque un suelo desnudo también da valores bajos. Por eso se compara una imagen de antes con una de después:

dNBR=NBRantes−NBRdespueˊs\mathrm{dNBR} = \mathrm{NBR}_{\text{antes}} - \mathrm{NBR}_{\text{después}}

Cuanto más alto el dNBR, más fuerte fue el cambio. Los valores cercanos a cero indican que la superficie no cambió.

Cómo elegir las fechas

Usa dos ventanas cortas y lo más parecidas posible en época del año: una justo antes del incendio y otra poco después, cuando el humo ya se disipó pero la vegetación aún no rebrotó. En sabanas y pastizales ese rebrote puede empezar en pocas semanas, así que conviene no alejarse mucho de la fecha del evento.

El script parte de dos fechas de referencia, una antes y otra después del incendio, y busca imágenes en una ventana de ±5 días alrededor de cada una. Sentinel-2 pasa por el mismo lugar cada 5 días aproximadamente, así que cada ventana de 11 días suele incluir dos pasadas: si una viene nublada, la otra la compensa.

Cada ventana se convierte en un compuesto sin nubes con Cloud Score+, igual que en el tutorial de NDVI.

El script

Pega esto en el Code Editor y cambia la ubicación y las dos fechas de referencia por las de tu evento. El ejemplo es un incendio de agosto de 2026 cerca de Honda, Tolima: el área de análisis es un cuadrado de 6 km de lado centrado en el punto del evento.

dnbr_sentinel2.js
/**
 * Burn severity using Sentinel-2 dNBR
 *
 * Location:
 *     Departamento: Tolima
 *     Municipio: Honda
 *     Latitude: 5.26256
 *     Longitude: -74.73897
 *
 * Reference dates:
 *     PRE:  2026-08-10
 *     POST: 2026-08-25
 */


/**
 * Configuration
 */
var departamento = 'TOLIMA';

var municipio = 'HONDA';

var fecha_antes = '2026-08-10';

var fecha_despues = '2026-08-25';

var lat = 5.26256;

var lon = -74.73897;

var radio_aoi_m = 3000;

var dias_ventana = 5;

var umbral_claro = 0.60;


/**
 * Area of interest
 */
var punto_evento = ee.Geometry.Point([lon, lat]);

var aoi = punto_evento
    .buffer(radio_aoi_m)
    .bounds();


/**
 * Date windows
 *
 * A ±5 day window is used around each reference date.
 */
var fecha_antes_ee = ee.Date(fecha_antes);

var fecha_despues_ee = ee.Date(fecha_despues);

var antes_inicio = fecha_antes_ee.advance(-dias_ventana, 'day');

var antes_fin = fecha_antes_ee.advance(dias_ventana + 1, 'day');

var despues_inicio = fecha_despues_ee.advance(-dias_ventana, 'day');

var despues_fin = fecha_despues_ee.advance(dias_ventana + 1, 'day');


/**
 * Collections
 */
var s2 = ee.ImageCollection('COPERNICUS/S2_SR_HARMONIZED');

var cs_plus = ee.ImageCollection(
    'GOOGLE/CLOUD_SCORE_PLUS/V1/S2_HARMONIZED'
);

var agua_jrc = ee.Image('JRC/GSW1_4/GlobalSurfaceWater')
    .select('occurrence');


/**
 * Creates a cloud-filtered NBR composite.
 *
 * @param {ee.Date} inicio Start date.
 * @param {ee.Date} fin End date.
 * @return {ee.Image} NBR composite.
 */
var nbr_compuesto = function (inicio, fin) {
    var imagenes = s2
        .filterBounds(aoi)
        .filterDate(inicio, fin)
        .linkCollection(cs_plus, ['cs_cdf'])
        .map(function (imagen) {
            var mascara_clara = imagen
                .select('cs_cdf')
                .gte(umbral_claro);

            return imagen.updateMask(mascara_clara);
        });

    return imagenes
        .median()
        .normalizedDifference(['B8A', 'B12'])
        .rename('nbr')
        .clip(aoi);
};


/**
 * PRE and POST NBR
 */
var nbr_antes = nbr_compuesto(
    antes_inicio,
    antes_fin
);

var nbr_despues = nbr_compuesto(
    despues_inicio,
    despues_fin
);


/**
 * Permanent water mask
 *
 * Pixels with JRC occurrence > 50% are excluded.
 */
var es_agua = agua_jrc
    .unmask(0)
    .gt(50);


/**
 * dNBR
 */
var dnbr = nbr_antes
    .subtract(nbr_despues)
    .rename('dnbr')
    .updateMask(es_agua.not())
    .clip(aoi);


/**
 * Burn severity
 *
 * Key and Benson (2006):
 *
 * 0 = Unburned / very low
 * 1 = Low
 * 2 = Moderate-low
 * 3 = Moderate-high
 * 4 = High
 *
 * Note:
 * Class 0 is preserved and visualized.
 */
var severidad = dnbr
    .gte(0.10)
    .add(dnbr.gte(0.27))
    .add(dnbr.gte(0.44))
    .add(dnbr.gte(0.66))
    .rename('severidad');


/**
 * Total burned area with severity >= 2
 *
 * Includes:
 *     2 = Moderate-low
 *     3 = Moderate-high
 *     4 = High
 */
var mascara_quemada = severidad.gte(2);

var area_quemada_ha = ee.Image
    .pixelArea()
    .divide(1e4)
    .updateMask(mascara_quemada)
    .reduceRegion({
        reducer: ee.Reducer.sum(),
        geometry: aoi,
        scale: 20,
        maxPixels: 1e13,
        tileScale: 4,
    })
    .get('area');

print('Área quemada estimada (ha)', area_quemada_ha);

print('Departamento', departamento);

print('Municipio', municipio);

print(
    'Ventana PRE',
    antes_inicio.format('YYYY-MM-dd'),
    antes_fin.advance(-1, 'day').format('YYYY-MM-dd')
);

print(
    'Ventana POST',
    despues_inicio.format('YYYY-MM-dd'),
    despues_fin.advance(-1, 'day').format('YYYY-MM-dd')
);


/**
 * Visualization
 */
var vis_nbr = {
    min: -0.5,
    max: 0.8,
    palette: [
        '#7f0000',
        '#d73027',
        '#fdae61',
        '#ffffbf',
        '#1a9850',
        '#006837',
    ],
};

var vis_dnbr = {
    min: -0.25,
    max: 0.9,
    palette: [
        '#1a9850',
        '#91cf60',
        '#ffffbf',
        '#fdae61',
        '#d73027',
        '#7f0000',
    ],
};

var vis_severidad = {
    min: 0,
    max: 4,
    palette: [
        '#444444', // 0 - Unburned / very low
        '#fee08b', // 1 - Low
        '#fdae61', // 2 - Moderate-low
        '#f46d43', // 3 - Moderate-high
        '#a50026', // 4 - High
    ],
};


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

Map.addLayer(
    nbr_antes,
    vis_nbr,
    'NBR PRE | ' + fecha_antes,
    false
);

Map.addLayer(
    nbr_despues,
    vis_nbr,
    'NBR POST | ' + fecha_despues,
    false
);

Map.addLayer(
    dnbr,
    vis_dnbr,
    'dNBR',
    true
);

Map.addLayer(
    severidad,
    vis_severidad,
    'Severidad',
    true
);


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

En la consola aparecen el área quemada estimada en hectáreas (clases 2 a 4), el departamento, el municipio y las fechas exactas que abarca cada ventana. La capa de severidad conserva la clase 0 en gris para distinguir lo que no se quemó.

La lógica es idéntica. Para ver el mapa se usa geemap, y el área y las fechas se traen con getInfo(), que aquí es seguro porque son valores sueltos y no imágenes completas.

dnbr_sentinel2.py
"""
Burn severity using Sentinel-2 dNBR

Location:
    Departamento: Tolima
    Municipio: Honda
    Latitude: 5.26256
    Longitude: -74.73897

Reference dates:
    PRE:  2026-08-10
    POST: 2026-08-25
"""

import ee
import geemap

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


# Configuration
departamento = 'TOLIMA'
municipio = 'HONDA'
fecha_antes = '2026-08-10'
fecha_despues = '2026-08-25'
lat = 5.26256
lon = -74.73897
radio_aoi_m = 3000
dias_ventana = 5
umbral_claro = 0.60


# Area of interest
punto_evento = ee.Geometry.Point([lon, lat])
aoi = punto_evento.buffer(radio_aoi_m).bounds()


# Date windows: a ±5 day window is used around each reference date
fecha_antes_ee = ee.Date(fecha_antes)
fecha_despues_ee = ee.Date(fecha_despues)

antes_inicio = fecha_antes_ee.advance(-dias_ventana, 'day')
antes_fin = fecha_antes_ee.advance(dias_ventana + 1, 'day')
despues_inicio = fecha_despues_ee.advance(-dias_ventana, 'day')
despues_fin = fecha_despues_ee.advance(dias_ventana + 1, 'day')


# Collections
s2 = ee.ImageCollection('COPERNICUS/S2_SR_HARMONIZED')
cs_plus = ee.ImageCollection('GOOGLE/CLOUD_SCORE_PLUS/V1/S2_HARMONIZED')
agua_jrc = ee.Image('JRC/GSW1_4/GlobalSurfaceWater').select('occurrence')


def nbr_compuesto(inicio, fin):
    """Creates a cloud-filtered NBR composite."""

    def enmascarar(imagen):
        mascara_clara = imagen.select('cs_cdf').gte(umbral_claro)
        return imagen.updateMask(mascara_clara)

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

    return (
        imagenes.median()
        .normalizedDifference(['B8A', 'B12'])
        .rename('nbr')
        .clip(aoi)
    )


# PRE and POST NBR
nbr_antes = nbr_compuesto(antes_inicio, antes_fin)
nbr_despues = nbr_compuesto(despues_inicio, despues_fin)


# Permanent water mask: pixels with JRC occurrence > 50% are excluded
es_agua = agua_jrc.unmask(0).gt(50)


# dNBR
dnbr = (
    nbr_antes.subtract(nbr_despues)
    .rename('dnbr')
    .updateMask(es_agua.Not())
    .clip(aoi)
)


# Burn severity, Key and Benson (2006):
# 0 = Unburned / very low, 1 = Low, 2 = Moderate-low,
# 3 = Moderate-high, 4 = High
severidad = (
    dnbr.gte(0.10)
    .add(dnbr.gte(0.27))
    .add(dnbr.gte(0.44))
    .add(dnbr.gte(0.66))
    .rename('severidad')
)


# Total burned area with severity >= 2 (moderate-low, moderate-high, high)
mascara_quemada = severidad.gte(2)

area_quemada_ha = (
    ee.Image.pixelArea()
    .divide(1e4)
    .updateMask(mascara_quemada)
    .reduceRegion(
        reducer=ee.Reducer.sum(),
        geometry=aoi,
        scale=20,
        maxPixels=1e13,
        tileScale=4,
    )
    .get('area')
)

print('Área quemada estimada (ha):', area_quemada_ha.getInfo())
print('Departamento:', departamento)
print('Municipio:', municipio)
print(
    'Ventana PRE:',
    antes_inicio.format('YYYY-MM-dd').getInfo(),
    antes_fin.advance(-1, 'day').format('YYYY-MM-dd').getInfo(),
)
print(
    'Ventana POST:',
    despues_inicio.format('YYYY-MM-dd').getInfo(),
    despues_fin.advance(-1, 'day').format('YYYY-MM-dd').getInfo(),
)


# Visualization
vis_nbr = {
    'min': -0.5,
    'max': 0.8,
    'palette': ['#7f0000', '#d73027', '#fdae61', '#ffffbf', '#1a9850', '#006837'],
}

vis_dnbr = {
    'min': -0.25,
    'max': 0.9,
    'palette': ['#1a9850', '#91cf60', '#ffffbf', '#fdae61', '#d73027', '#7f0000'],
}

vis_severidad = {
    'min': 0,
    'max': 4,
    'palette': [
        '#444444',  # 0 - Unburned / very low
        '#fee08b',  # 1 - Low
        '#fdae61',  # 2 - Moderate-low
        '#f46d43',  # 3 - Moderate-high
        '#a50026',  # 4 - High
    ],
}


# Map
mapa = geemap.Map()
mapa.add_basemap('SATELLITE')
mapa.centerObject(aoi, 13)
mapa.addLayer(nbr_antes, vis_nbr, f'NBR PRE | {fecha_antes}', False)
mapa.addLayer(nbr_despues, vis_nbr, f'NBR POST | {fecha_despues}', False)
mapa.addLayer(dnbr, vis_dnbr, 'dNBR', True)
mapa.addLayer(severidad, vis_severidad, 'Severidad', True)
mapa


# Export
tarea = ee.batch.Export.image.toDrive(
    image=severidad.toByte(),
    description='severidad_dnbr_HONDA_TOLIMA_20260825',
    folder='earthengine',
    fileNamePrefix='severidad_dnbr_HONDA_TOLIMA_20260825',
    region=aoi,
    scale=20,
    maxPixels=1e13,
)
tarea.start()

Fíjate en es_agua.Not(): en Python el método va con mayúscula porque not es una palabra reservada del lenguaje. Lo mismo pasa con And() y Or().

181 hectáreas quemadas cerca de Honda en agosto de 2026

Con el script de arriba, el incendio de agosto de 2026 cerca de Honda dejó unas 181 hectáreas quemadas con severidad moderada baja o mayor, dentro de un área de análisis de 3.600 ha. La cicatriz forma una franja alargada de norte a sur, al oeste del río Magdalena, con el núcleo más severo en su parte central.

Mapa dNBR del incendio cerca de Honda, Tolima: una franja de rojo intenso a naranja sobre vegetación verde, al oeste del río Magdalena.
dNBR del incendio. En rojo, las zonas con mayor pérdida de vegetación; en verde, sin cambio. El río Magdalena queda fuera del cálculo por la máscara de agua permanente.
Área quemada estimada (severidad ≥ 2)181,46 ha
UbicaciónHonda, Tolima
Ventana antes5 – 15 ago 2026
Ventana después20 – 30 ago 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. Resta al revés: después menos antes. El dNBR es antes − después. Con la resta invertida el área quemada sale negativa y los umbrales de severidad dejan de tener sentido.
  2. Combina B8 (10 m) con B12 (20 m). Funciona, pero mezcla resoluciones y anchos de banda distintos. B8A tiene la misma resolución que B12 y es la banda recomendada para el NBR en Sentinel-2.
  3. Aplica los umbrales de Key y Benson como verdad absoluta. Se definieron para bosques templados. En sabanas y pastizales la severidad queda en las clases bajas aunque el área se haya quemado por completo.
  4. No enmascara el agua. Los cambios de nivel en ríos y lagunas producen dNBR alto y aparecen como falsos incendios.
  5. Escribe .not() en Python. not es una palabra reservada de Python y el script no llega a ejecutarse. El método correcto es .Not(), igual que .And() y .Or().

Umbrales de severidad del dNBR

Los umbrales son los propuestos por Key y Benson (2006) para el dNBR sin escalar; UN-SPIDER los recoge como la clasificación del USGS. Muchos trabajos multiplican el dNBR por 1000; en ese caso los cortes son 100, 270, 440 y 660.

dNBRClaseSeveridad
Menor que 0.100No quemado (valores negativos: rebrote)
0.10 a 0.271Baja
0.27 a 0.442Moderada baja
0.44 a 0.663Moderada alta
Mayor que 0.664Alta

Estos cortes se definieron para bosques templados. El script cuenta como área quemada las clases 2 a 4 (dNBR de 0.27 o más) y deja fuera la clase 1, que en zonas tropicales suele mezclar quemas leves con cambios de la vegetación entre una fecha y otra. En sabanas y pastizales, donde el fuego rara vez pasa de severidad baja, revisa la clase 1 sobre la imagen antes de descartarla.

Falsos positivos del dNBR: agua, cosechas y huecos en el mapa

Aparecen falsos incendios en bordes de ríos y lagunas

Los cambios en el nivel del agua alteran el NBR. El script ya descarta los cuerpos de agua permanentes con el dataset JRC Global Surface Water; si tu zona tiene humedales estacionales, baja el umbral de occurrence de 50 a 20.

Zonas de cultivo marcadas como quemadas

Una cosecha o un arado entre las dos fechas producen un dNBR alto sin que haya fuego. Acorta las ventanas alrededor del evento o cruza el resultado con focos de calor de FIRMS (FIRMS en el catálogo de Earth Engine) para confirmar que hubo fuego.

Huecos en el mapa

Alguna de las dos ventanas no tiene píxeles despejados. Amplía esa ventana o baja el umbral de Cloud Score+ a 0.5. Revisa primero la ventana de después: el humo de los propios incendios también cuenta como nube.

Preguntas frecuentes

¿Qué es el dNBR?

Es la diferencia entre el índice NBR antes y después de un incendio: dNBR=NBRantes−NBRdespueˊs\mathrm{dNBR} = \mathrm{NBR}_{\text{antes}} - \mathrm{NBR}_{\text{después}}. Mide cuánto cambió la vegetación con el fuego: cuanto más alto, mayor la severidad. Valores cercanos a cero indican que la superficie no cambió.

¿Cuál es la fórmula del índice NBR en Sentinel-2?

NBR=(NIR−SWIR2) / (NIR+SWIR2)\mathrm{NBR} = (\mathrm{NIR} - \mathrm{SWIR}_2) \,/\, (\mathrm{NIR} + \mathrm{SWIR}_2). En Sentinel-2 se usan las bandas B8A (infrarrojo cercano, 864,8 nm) y B12 (infrarrojo de onda corta, 2202,4 nm en Sentinel-2A), ambas a 20 m de resolución.

¿Qué valores de dNBR indican un área quemada?

Según Key y Benson (2006): menos de 0.10 es no quemado; de 0.10 a 0.27, severidad baja; de 0.27 a 0.44, moderada baja; de 0.44 a 0.66, moderada alta; y más de 0.66, alta. En sabanas tropicales conviene revisar la clase baja sobre la imagen.

¿Se puede calcular el NBR con Landsat 8 o 9?

Sí. En Landsat 8 y 9 el NBR usa la banda 5 (infrarrojo cercano) y la banda 7 (infrarrojo de onda corta 2), a 30 m. En la Colección 2 hay que aplicar antes la escala y el desplazamiento de la reflectancia (× 0,0000275 y −0,2): el desplazamiento no se cancela en la división y, sin él, el NBR sale mal (ejemplo con el NDVI).

Fuentes

  1. Landscape Assessment (LA)Key, C. H. y Benson, N. C. (2006). FIREMON, USDA Forest Service, RMRS-GTR-164-CDUmbrales de severidad del dNBR.
  2. Normalized Burn Ratio (NBR)UN-SPIDER Knowledge PortalExplicación del NBR y del dNBR y la clasificación de severidad del USGS.
  3. Harmonized Sentinel-2 MSI: MultiSpectral Instrument, Level-2A (SR)Google Earth Engine, catálogo de datosBandas B8A y B12 y sus longitudes de onda.
  4. Cloud Score+ S2_HARMONIZED V1Google Earth Engine, catálogo de datosMáscara de nubes de los compuestos antes y después.
  5. JRC Global Surface Water Mapping Layers, v1.4Google Earth Engine, catálogo de datosMáscara de agua permanente.
  6. FIRMS: Fire Information for Resource Management SystemGoogle Earth Engine, catálogo de datosFocos de calor para confirmar que hubo fuego.

Sigue aprendiendo

Mira esta técnica aplicada en el mapa de incendios en Colombia. ¿Lo necesitas hecho y verificado para tu zona? Mira el servicio Mapas de incendios, agua y cobertura.

¿Necesitas mapas de incendios para tu zona o una aplicación para revisarlos?

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