Classifying Recovery Classes from Multi-Year dNBR in Python
A continuous recovery surface is the right analytical product and the wrong management one. Decisions are categorical — replant, monitor, do nothing — so at some point the trajectory has to become a class, and where the thresholds fall determines how many hectares get a planting budget. This guide makes that conversion explicit and auditable, inside Post-Fire Vegetation Recovery Monitoring, part of Fire Risk & Fuel Assessment.
When to use this approach
| Product | Form | Audience |
|---|---|---|
| Relative recovery raster | Continuous 0–1 | Analysts; the input to everything else |
| Recovery class raster | 4–5 ordinal classes | Operations; drives the field programme |
| Per-stand class | One class per polygon | Planning and budgeting |
| Recovery rate raster | Continuous, years⁻¹ | Modellers and monitoring design |
| Reburn hazard flag | Boolean | Fire management, as in Detecting Reburn Risk from Recovery and Fuel Rasters |
Produce the continuous surface first and keep it. Classes are a view of it, and when the thresholds are revisited — and they always are — you want to reclassify rather than reprocess.
Relative recovery from a dNBR series
The severity index measures the drop; the recovery index measures how much of that drop has closed. With the pre-fire baseline and the first post-fire year:
The second form is the useful one operationally: if you already produce a dNBR raster each year against the same pre-fire baseline, the recovery index is one division. It also makes the failure mode obvious — where the initial dNBR is small, the denominator is small, and the ratio explodes. Lightly burned pixels must be excluded rather than divided.
Minimal reproducible example
import numpy as np
import rasterio
DNBR_MIN = 0.10 # below this the burn is not reliably detected
def relative_recovery(dnbr_initial: np.ndarray, dnbr_year: np.ndarray) -> np.ndarray:
"""RRI = 1 - dNBR_t / dNBR_initial, masked where the denominator is unreliable."""
out = np.full(dnbr_initial.shape, np.nan, dtype="float32")
usable = np.isfinite(dnbr_initial) & np.isfinite(dnbr_year) & (dnbr_initial >= DNBR_MIN)
out[usable] = 1.0 - (dnbr_year[usable] / dnbr_initial[usable])
# RRI above 1 is real (overshoot); below -0.5 is noise or a reburn.
out[out < -0.5] = np.nan
return np.clip(out, -0.5, 2.0)
def classify(rri: np.ndarray, rate: np.ndarray) -> np.ndarray:
"""Ordinal recovery classes. 0 = unclassified, 1 = none … 4 = rapid."""
cls = np.zeros(rri.shape, dtype="uint8")
valid = np.isfinite(rri)
cls[valid] = 1
cls[valid & (rri >= 0.25)] = 2
cls[valid & (rri >= 0.50)] = 3
cls[valid & (rri >= 0.75)] = 4
# Demote anything whose fitted rate says it has stalled, whatever its level.
stalled = valid & np.isfinite(rate) & (rate < 0.08) & (rri < 0.75)
cls[stalled] = np.minimum(cls[stalled], 2)
return cls
The demotion rule is what stops the class map from being a repackaged snapshot. A pixel that reached 60% in the first two years and has not moved since is a different management case from one climbing steadily through 60%, and only the rate distinguishes them.
Clean up to a minimum mapping unit
A raw class raster is speckled, and a field crew cannot act on a 400 m² island of “replant” inside a recovering stand. A majority filter followed by a small-region merge produces something operational.
from scipy import ndimage as ndi
def apply_mmu(cls: np.ndarray, cell_m: float = 20.0, mmu_ha: float = 0.5) -> np.ndarray:
"""Merge regions smaller than the minimum mapping unit into their neighbours."""
min_px = int(np.ceil(mmu_ha * 10_000.0 / (cell_m ** 2)))
out = ndi.median_filter(cls, size=3, mode="nearest")
for value in np.unique(out):
if value == 0:
continue
labels, n = ndi.label(out == value)
if n == 0:
continue
sizes = ndi.sum(np.ones_like(labels), labels, index=range(1, n + 1))
small = np.isin(labels, [i + 1 for i, s in enumerate(sizes) if s < min_px])
if small.any():
# Replace each small island with the modal class of its dilated ring.
ring = ndi.binary_dilation(small, np.ones((3, 3), bool)) & ~small
fill = np.bincount(out[ring], minlength=int(out.max()) + 1)
fill[0] = 0
out[small] = int(fill.argmax())
return out
State the minimum mapping unit alongside the map. Half a hectare at 20 m is 13 pixels, which removes speckle without erasing genuine small openings; a 2 ha unit produces a much tidier map that has silently deleted every seed-tree gap.
Parameter reference
| Parameter | Type | Default | Range | Rationale |
|---|---|---|---|---|
DNBR_MIN |
float | 0.10 | 0.08–0.15 | The severity detection floor; below it the recovery ratio is undefined |
| RRI class breaks | tuple | 0.25 / 0.50 / 0.75 | — | Even quartiles; adjust to local regeneration standards and say so |
| stalled rate | float | 0.08 | 0.05–0.12 | Demotion threshold; a half-life beyond ~9 years is not a trajectory |
mmu_ha |
float | 0.5 | 0.25–2.0 | Smallest reportable patch; larger values erase real openings |
| assessment year | int | 5 | 3–10 | Year 3 is too early in slow systems; year 10 is too late to intervene cheaply |
| median filter size | int | 3 | 3–5 | Removes single-pixel speckle without moving class boundaries |
Expected output and verification
A class map is validated the way any classification is: against independent observations, with a confusion matrix, and reporting the errors by class rather than one overall figure.
import numpy as np
def confusion(observed: np.ndarray, predicted: np.ndarray, n_classes: int = 5) -> dict:
"""Confusion matrix plus per-class producer and user accuracy."""
cm = np.zeros((n_classes, n_classes), dtype="int64")
for o, p in zip(observed, predicted):
cm[int(o), int(p)] += 1
total = cm.sum()
correct = np.trace(cm)
producer = np.divide(np.diag(cm), cm.sum(axis=1),
out=np.zeros(n_classes), where=cm.sum(axis=1) > 0)
user = np.divide(np.diag(cm), cm.sum(axis=0),
out=np.zeros(n_classes), where=cm.sum(axis=0) > 0)
# Cohen's kappa: agreement beyond what class prevalence alone would give.
pe = float((cm.sum(axis=0) * cm.sum(axis=1)).sum()) / (total ** 2)
po = correct / total
return {
"overall": round(float(po), 3),
"kappa": round(float((po - pe) / (1 - pe)), 3) if pe < 1 else float("nan"),
"producer": [round(float(x), 3) for x in producer],
"user": [round(float(x), 3) for x in user],
"n": int(total),
}
res = confusion(plot_class_observed, plot_class_predicted)
assert res["overall"] > 0.6, f"class map is not usable: {res}"
assert res["kappa"] > 0.4, f"agreement no better than prevalence would give: {res}"
Kappa is the assertion that matters. In a fire where 70% of the area recovered rapidly, always predicting “rapid” scores 70% overall accuracy and is worthless; kappa near zero exposes that immediately, and overall accuracy never will.
Report the per-class figures separately, because the operationally important class is usually the rarest. A map with 85% overall accuracy that finds only half the genuinely non-recovering stands has failed at the one job it was built for.
Common pitfalls
- Dividing by a small initial dNBR. Lightly burned pixels produce recovery values in the hundreds. Mask below the detection floor rather than clipping afterwards.
- Thresholds chosen after seeing the hectare totals. Set the breaks from a regeneration standard or a prior study, record them, and then compute the areas.
- A minimum mapping unit large enough to erase the signal. In a fire whose regeneration is patchy at the 0.2 ha scale, a 2 ha unit maps a uniformity that is not there.
- Overall accuracy as the headline. With an imbalanced class distribution it is uninformative. Lead with kappa and per-class producer accuracy.
- Reclassifying from the class raster. Always reclassify from the continuous surface; going class-to-class compounds every earlier threshold decision.
Frequently Asked Questions
How many recovery classes should I use?
Four is the usual compromise: none, slow, moderate, rapid. Fewer loses the distinction between “watch” and “act”; more produces classes that field crews cannot separate reliably, which shows up immediately as off-diagonal mass in the confusion matrix.
Should classes be defined on the recovery level or on the rate?
On both, with the level leading and the rate demoting. The level is what has been measured; the rate is what predicts the future. A rule that uses only one of them will either flag every slow-but-healthy stand or miss every stalled one.
What if I have no field plots to validate against?
Use whatever independent evidence exists — aerial photography interpreted at points, a regeneration survey from a neighbouring fire in the same forest type, or operational records of which stands were replanted. State clearly that the accuracy figure comes from a proxy. A class map with no validation at all should be published as provisional, not as a product.
Related
- Post-Fire Vegetation Recovery Monitoring — the pipeline this classification concludes
- Measuring Post-Fire NDVI Recovery Trajectories in Python — produces the rate this classifier uses
- Calculating dNBR from Sentinel-2 in Python — the index this recovery ratio is built from
- Thresholding dNBR to Standard Burn Severity Classes — the same threshold discipline applied to severity
Up: Post-Fire Recovery Monitoring · Fire Risk & Fuel Assessment