Thresholding dNBR to Standard Burn Severity Classes in Python

A dNBR raster is a continuous number that nobody manages by. Severity becomes actionable only once it is a class — unburned, low, moderate, high — and the break values decide how many hectares fall into each. Published thresholds are widely used and were calibrated in a specific forest type on a specific sensor, so applying them unexamined to another system quietly moves hectares between classes. This guide covers doing it defensibly, inside Burn Severity Mapping with Python, part of Fire Risk & Fuel Assessment.

When to use each index

Index Formula basis Strength Weakness
dNBR Pre-fire NBR minus post-fire NBR Absolute change; the standard Depends on how much there was to lose
RdNBR dNBR divided by a function of pre-fire NBR Comparable across pre-fire vegetation Unstable where pre-fire NBR is near zero
RBR dNBR divided by pre-fire NBR plus a constant Stable version of RdNBR Less widely tabulated
dNDVI Change in NDVI Available where SWIR is not Weaker severity discrimination in forest

Use dNBR in a homogeneous forest where every stand had roughly the same pre-fire biomass. Use RdNBR or RBR when the fire crossed a mosaic — closed forest, open woodland, shrub — because absolute change conflates severity with how much vegetation existed to begin with. That distinction and its arithmetic are set out in Burn Severity Mapping with Python, which owns the index definitions this page thresholds.

Offset correction comes before any threshold

Two scenes a year apart differ in phenology, illumination and atmosphere even where nothing burned. That difference — the offset — shifts the whole dNBR distribution and therefore every class boundary. Correcting it is a single subtraction and it is not optional.

where the offset is the median scaled dNBR of unburned pixels just outside the perimeter, in the same vegetation types that burned.

The unburned offset and what it does to the class breaks Two overlaid distributions of scaled dNBR. The unburned distribution, taken from a buffer outside the fire perimeter, is a narrow peak centred near plus forty rather than at zero, because of phenological and atmospheric differences between the two dates. The burned distribution is broad and centred near four hundred. Vertical lines mark the standard class breaks at 100, 270 and 660, and a second set of lines shows where those breaks fall after the offset is subtracted, forty units to the left. −100 0 100 270 500 700 scaled dNBR unburned buffer burned area 100 270 660 offset = +40 Solid lines are the published breaks; dashed are the same breaks after the offset is removed. A 40-unit offset typically moves several percent of the scar between severity classes.

Minimal reproducible example

import numpy as np
import geopandas as gpd
import rasterio
from rasterio.mask import mask

# Widely used scaled-dNBR breaks. Treat them as a starting point, not a standard.
BREAKS = {
    "unburned": (-np.inf, 99),
    "low": (100, 269),
    "moderate": (270, 659),
    "high": (660, np.inf),
}


def compute_offset(dnbr_path: str, perimeter_path: str,
                   buffer_m: float = 2000.0, inner_m: float = 200.0) -> float:
    """Median scaled dNBR in an unburned ring around the fire perimeter."""
    perim = gpd.read_file(perimeter_path)
    with rasterio.open(dnbr_path) as src:
        geom = perim.to_crs(src.crs).geometry.union_all()
        ring = geom.buffer(buffer_m).difference(geom.buffer(inner_m))
        arr, _ = mask(src, [ring], crop=True, filled=True, nodata=np.nan)
    vals = arr[0][np.isfinite(arr[0])]
    return float(np.median(vals))


def classify_severity(dnbr: np.ndarray, offset: float = 0.0) -> np.ndarray:
    """Ordinal severity classes from offset-corrected scaled dNBR."""
    d = dnbr - offset
    cls = np.zeros(d.shape, dtype="uint8")      # 0 = nodata
    valid = np.isfinite(d)
    cls[valid] = 1                              # 1 = unburned / very low
    cls[valid & (d >= 100)] = 2                 # 2 = low
    cls[valid & (d >= 270)] = 3                 # 3 = moderate
    cls[valid & (d >= 660)] = 4                 # 4 = high
    return cls

The inner buffer matters. Taking the offset from pixels immediately outside the perimeter contaminates it with edge effects — scorch, partly burned ground, and perimeter mapping error — all of which bias the offset upward and shift every class boundary in the same direction.

Calibrate the breaks where you can

Published breaks were derived by regressing dNBR against field Composite Burn Index measurements in particular forests. Where you have CBI plots, fit local breaks; the fitted values often differ from the published ones by 50–100 units, which is several percent of the scar.

import numpy as np


def calibrate_breaks(dnbr_at_plots: np.ndarray, cbi: np.ndarray,
                     cbi_breaks=(0.1, 1.25, 2.25)) -> dict:
    """Map CBI class boundaries onto dNBR by a monotone fit through the plot data."""
    ok = np.isfinite(dnbr_at_plots) & np.isfinite(cbi)
    d, c = dnbr_at_plots[ok], cbi[ok]
    order = np.argsort(c)
    d_sorted, c_sorted = d[order], c[order]
    fitted = {}
    for b in cbi_breaks:
        # Interpolate the dNBR value at each CBI boundary.
        fitted[b] = float(np.interp(b, c_sorted, d_sorted))
    return {
        "n_plots": int(ok.sum()),
        "dnbr_at_cbi": {str(k): round(v) for k, v in fitted.items()},
        "spearman": float(np.corrcoef(np.argsort(np.argsort(d)),
                                      np.argsort(np.argsort(c)))[0, 1]),
    }

A Spearman correlation below about 0.7 between dNBR and CBI means the index is not tracking severity well at this site — usually because pre-fire vegetation was heterogeneous, which is the signal to switch to RdNBR rather than to adjust the breaks.

Parameter reference

Parameter Type Default Range Rationale
scaling int ×1000 dNBR is conventionally reported scaled by 1000
buffer_m float (m) 2000 1000–5000 Outer edge of the offset ring; wide enough for a stable median
inner_m float (m) 200 100–500 Excludes the contaminated perimeter edge
low break int 100 60–150 Separates detectable change from noise
moderate break int 270 200–350 The class boundary that moves the most hectares
high break int 660 500–800 Where overstory mortality becomes near-complete
minimum offset sample int 5000 px Below this the median is unstable

Expected output and verification

The checks that catch real errors are about distribution shape and about the relationship to independent evidence:

import numpy as np


def severity_report(cls: np.ndarray, dnbr: np.ndarray, offset: float) -> dict:
    """Class areas plus the diagnostics that reveal a bad offset or bad breaks."""
    valid = cls > 0
    n = int(valid.sum())
    frac = {name: round(float((cls == i).sum() / n), 3)
            for i, name in enumerate(["nodata", "unburned", "low", "moderate", "high"])
            if i > 0}
    d = dnbr[valid] - offset
    return {
        "n_px": n,
        "class_fraction": frac,
        "median_dnbr": round(float(np.median(d))),
        "p95_dnbr": round(float(np.percentile(d, 95))),
        "offset_applied": round(offset, 1),
    }


rep = severity_report(cls, dnbr, offset)
assert rep["class_fraction"]["unburned"] < 0.5, \
    f"most of the perimeter classed unburned — check the offset sign: {rep}"
assert rep["class_fraction"]["high"] < 0.8, \
    f"almost everything high severity — check the pre-fire date: {rep}"

The unburned-fraction assertion catches the sign error, which is the single most common mistake: subtracting post from pre gives positive dNBR for a burn, and reversing it produces a map where the fire looks like vegetation growth.

Feed the classifier something it must reject as a standing test:

rng = np.random.default_rng(20260809)
# An unburned scene differenced against itself: every pixel must land in class 1.
noise = rng.normal(0.0, 15.0, size=20000)
cls_null = classify_severity(noise, offset=0.0)
assert (cls_null == 1).mean() > 0.99, \
    "noise around zero produced burn classes — the low break is too tight"
How much area moves when thresholds are calibrated locally Two stacked bars for the same fire. The upper bar uses published thresholds and splits the scar into eight percent unburned, twenty two percent low, forty one percent moderate and twenty nine percent high. The lower bar uses locally calibrated thresholds and gives eleven, twenty eight, forty four and seventeen percent, moving twelve percent of the scar out of the high severity class, which is the class that drives salvage and replanting budgets. Published breaks 8% 22% 41% 29% Calibrated to CBI plots 11% 28% 44% 17% unburned low moderate high Twelve percent of the scar moves out of high severity — the class that drives salvage budgets. Same imagery, same fire, same index. Only the break values differ. Field composite burn index against scaled dNBR Field plot composite burn index is plotted against scaled dNBR. The relationship rises clearly but with substantial scatter, and plots from adjacent severity classes overlap in a band around each threshold. Three vertical lines mark the fitted class breaks, and shaded bands around them mark the ranges where field crews and the index disagree, which is where a confusion matrix will show its off-diagonal mass. 0 1 2 3 0 250 500 750 1000 scaled dNBR field CBI Shaded bands are where field and index disagree — the width of that band is the map's real precision.

Common pitfalls

  • Skipping the offset. Every published threshold assumes an offset-corrected input. Without it the classes are shifted by whatever the phenological difference happened to be.
  • Taking the offset from immediately outside the perimeter. Edge pixels are partly burned and bias the offset upward.
  • Using an initial-assessment threshold on an extended assessment. Immediate post-fire imagery and next-season imagery need different breaks, because delayed mortality has not yet expressed in the first.
  • Applying forest thresholds to shrubland. Shrub systems lose far less NBR in an equally lethal fire, so forest breaks under-classify them severely.
  • Reporting class areas without stating the breaks. Two agencies mapping the same fire will disagree by tens of percent, and the difference is always in the thresholds.

Frequently Asked Questions

Should I use initial or extended assessment imagery?

Extended — one growing season after the fire — for anything about ecological effect, because delayed mortality takes months to appear in reflectance. Use initial assessment when the question is immediate fire behaviour or when an operational decision cannot wait. Never mix the two in one product, and record which one a map used.

What if the fire crossed several vegetation types?

Either stratify — separate breaks per type, calibrated separately — or switch to a relativised index that removes the pre-fire biomass dependence. Stratifying is more work and more defensible; relativising is one line and slightly less transparent. Do not apply one set of absolute breaks across a mosaic.

How many CBI plots do I need to calibrate?

Thirty well-distributed plots spanning the full severity range is enough for usable breaks; sixty is comfortable. What matters more than the count is the spread: twenty plots all in high-severity ground calibrate nothing, because the boundaries you care about are where there are no observations.

Up: Burn Severity Mapping with Python · Fire Risk & Fuel Assessment