Mapping Burned Area with NBR Differencing
To map fire extent and severity, compute the Normalised Burn Ratio before and after the fire and difference them:
def nbr(nir, swir22):
denom = nir + swir22
return (nir - swir22) / denom
dnbr = nbr(nir_pre, swir_pre) - nbr(nir_post, swir_post) # positive = burn severity
dNBR is the best-established application of the method described in Change Detection and Differencing Workflows, and the one case where published class breaks make a derived threshold optional.
Why This Arises in Remote Sensing Workflows
Fire changes vegetation in a way the spectrum reports unusually clearly. Combustion removes chlorophyll and water, so near-infrared reflectance falls sharply, while char and exposed soil raise short-wave infrared reflectance. NBR combines exactly those two bands, so it moves further and more consistently after fire than any general vegetation index — which is why burned-area mapping is one of the few change applications where a single date pair is genuinely adequate.
The operational demand is immediate: agencies need extent within days for response and severity within weeks for rehabilitation planning. That timeline rules out composite pairs and time-series fitting, and it means the pipeline has to be robust with whatever two clear scenes exist around the event.
The complication is look-alikes. Anything that removes vegetation and exposes soil looks like fire in NBR: clear-felling, ploughing, a drained reservoir, a landslide. Separating them is the part of the workflow that needs judgement rather than arithmetic.
Environment & Setup
| Package | Version | Why |
|---|---|---|
rioxarray |
≥0.15 | Masked reads, alignment, COG output |
xarray |
≥2023.6 | Labelled arithmetic |
rasterio |
≥1.3.0 | Resampling and I/O |
scikit-image |
≥0.22 | Minimum mapping unit cleanup |
numpy |
≥1.23 | Classification and statistics |
pip install "rioxarray>=0.15" "xarray>=2023.6" "rasterio>=1.3.0" "scikit-image>=0.22"
Complete Working Example
This function computes dNBR, classifies it with the widely used severity breaks, removes water and small objects, and returns both the continuous and classified layers.
import numpy as np
import rioxarray # noqa: F401
import xarray as xr
from rasterio.enums import Resampling
from skimage.morphology import remove_small_objects
# Widely used dNBR severity breaks (USGS convention)
SEVERITY_BREAKS = [
(-np.inf, 0.10, 0, "unburned"),
(0.10, 0.27, 1, "low"),
(0.27, 0.44, 2, "moderate-low"),
(0.44, 0.66, 3, "moderate-high"),
(0.66, np.inf, 4, "high"),
]
def nbr(nir: xr.DataArray, swir: xr.DataArray) -> xr.DataArray:
denom = nir + swir
return xr.where(denom != 0, (nir - swir) / denom, np.nan).astype("float32")
def map_burn(
pre: dict, post: dict, *, mmu_pixels: int = 6, water_ndwi_threshold: float = 0.0
) -> tuple[xr.DataArray, xr.DataArray, dict]:
"""Return (dnbr, severity, stats) for one fire event."""
# B8A and B12 share the 20 m grid — no resampling needed between them
nir_pre = rioxarray.open_rasterio(pre["b8a"], masked=True).squeeze(drop=True).astype("float32")
swir_pre = rioxarray.open_rasterio(pre["b12"], masked=True).squeeze(drop=True).astype("float32")
nir_post = rioxarray.open_rasterio(post["b8a"], masked=True).squeeze(drop=True)
swir_post = rioxarray.open_rasterio(post["b12"], masked=True).squeeze(drop=True)
nir_post = nir_post.rio.reproject_match(nir_pre, resampling=Resampling.bilinear).astype("float32")
swir_post = swir_post.rio.reproject_match(nir_pre, resampling=Resampling.bilinear).astype("float32")
nbr_pre, nbr_post = nbr(nir_pre, swir_pre), nbr(nir_post, swir_post)
dnbr = (nbr_pre - nbr_post).astype("float32")
# Water look-alike removal: exclude pixels that are water on either date
green_pre = rioxarray.open_rasterio(pre["b03"], masked=True).squeeze(drop=True).astype("float32")
green_pre = green_pre.rio.reproject_match(nir_pre, resampling=Resampling.bilinear)
ndwi_pre = xr.where((green_pre + nir_pre) != 0,
(green_pre - nir_pre) / (green_pre + nir_pre), np.nan)
dnbr = dnbr.where(ndwi_pre < water_ndwi_threshold)
severity = xr.zeros_like(dnbr, dtype="uint8")
for lo, hi, code, _label in SEVERITY_BREAKS:
severity = xr.where((dnbr >= lo) & (dnbr < hi), code, severity)
severity = severity.where(dnbr.notnull())
burned = (severity.fillna(0).values >= 1)
burned = remove_small_objects(burned, min_size=mmu_pixels)
severity = severity.where(xr.DataArray(burned | (severity.fillna(0).values == 0),
coords=severity.coords, dims=severity.dims), 0)
pixel_area_ha = abs(dnbr.rio.resolution()[0] * dnbr.rio.resolution()[1]) / 10_000
stats = {
"burned_pixels": int(burned.sum()),
"burned_area_ha": round(float(burned.sum()) * pixel_area_ha, 2),
"high_severity_pixels": int((severity == 4).sum()),
"date_pre": pre["date"],
"date_post": post["date"],
"mmu_pixels": mmu_pixels,
}
return dnbr, severity, stats
if __name__ == "__main__":
dnbr, severity, stats = map_burn(
{"b8a": "B8A_pre.tif", "b12": "B12_pre.tif", "b03": "B03_pre.tif", "date": "2023-08-04"},
{"b8a": "B8A_post.tif", "b12": "B12_post.tif", "date": "2023-08-21"},
)
print(stats)
severity.rio.to_raster("burn_severity.tif", driver="COG", compress="DEFLATE", dtype="uint8")
Using B8A rather than B08 is a small decision with a real payoff: it is already on the 20 m grid that B12 uses, so the two bands need no relative resampling and the index is computed from unmodified observations.
Variant Patterns
1. Relativised dNBR for mixed vegetation
Absolute dNBR depends on how much vegetation there was to burn, so the same fire severity produces a smaller dNBR in sparse shrubland than in closed forest. RdNBR normalises by pre-fire condition.
import numpy as np
denom = np.sqrt(np.abs(nbr_pre.where(np.abs(nbr_pre) > 0.001)))
rdnbr = (dnbr / denom).astype("float32")
Use RdNBR when one map spans several vegetation types and the severity classes must mean the same thing across them; use dNBR when the area is homogeneous and interpretability matters more.
2. Offsetting for phenology between the dates
outside = dnbr.where(~perimeter_buffer) # ring around the fire, known unburned
offset = float(outside.median(skipna=True))
dnbr_corrected = dnbr - offset
3. Separating harvest from fire
Clear-felling produces a dNBR signature close to moderate-severity fire. Three discriminators usually resolve it: shape, since harvest blocks have straight edges and fires do not; the short-wave infrared magnitude, which is higher over char than over slash; and independent context such as an active-fire detection during the window. Where the distinction matters, encode the shape test — a compactness or edge-straightness measure on each connected component — rather than leaving it to visual inspection, and record which components were reclassified.
Validating a Burn Map
Fire is one of the better-served applications for validation because independent evidence usually exists.
Compare the mapped perimeter against an independent product if one is published for the event, and report the agreement as an area ratio rather than as a subjective assessment. Differences at the low-severity boundary are expected; differences in the high-severity core are a warning.
Check that severity decreases outward. A real fire almost always shows a severity gradient from the core to the perimeter, so a map with high-severity pixels scattered outside a low-severity ring is reporting noise, not structure.
Confirm the unburned control. Sample the surroundings and confirm their dNBR is centred near zero after the offset correction — if it is not, the correction did not work and the class boundaries are misplaced, which is discussed in Thresholding Change Maps and Removing Noise.
Common Errors
Lakes and rivers appear as high severity
Water was not masked, or the water mask came from only one date. Compute NDWI on both dates and exclude pixels that are water on either — the index is covered in Computing EVI and NDWI from Sentinel-2 Bands.
The whole scene shows low severity
Phenological drying between the dates has shifted the background. Apply the unburned-surroundings offset before classifying.
Severity classes look wrong for this vegetation type
The published breaks were calibrated on different ecosystems. Either recalibrate against local field severity data, or switch to RdNBR and say so in the product metadata.
Frequently Asked Questions
Q: Which Sentinel-2 bands does NBR use? B08 near-infrared and B12 long short-wave infrared for the standard formulation. B8A is often preferred over B08 because it is narrower and shares the 20 m grid with B12, which removes a resampling step.
Q: Is dNBR or RdNBR better? dNBR measures absolute change and is easier to interpret; RdNBR normalises by pre-fire condition and compares better across vegetation types with different pre-fire biomass. Sparse pre-fire vegetation is where the two disagree most.
Q: Why does water show up as high severity? Water has strongly negative NBR, so a lake that was dry before and wet after produces a large positive dNBR. Mask water on both dates with an index such as NDWI before classifying.
Related
- Change Detection and Differencing Workflows — the parent topic and its pairing rules.
- Thresholding Change Maps and Removing Noise — cleanup and class boundaries in general.
- Computing EVI and NDWI from Sentinel-2 Bands — the water mask this workflow needs.
- Spectral Index Calculation Pipelines — running NBR alongside other indices at scale.