Lab 21 - Change Detection: Mapping Burn Severity

Objective: Map burn severity for the 2021 Caldor Fire using before-after NBR differencing with Landsat 8.

What You'll Learn

  • Build cloud-masked median composites for pre-fire and post-fire periods
  • Calculate NBR for both composites using Landsat 8 bands
  • Compute dNBR and classify burn severity using USGS thresholds
  • Calculate the total burned area in hectares
  • Interpret a classified burn severity map

Why This Matters

When a wildfire tears through a landscape, the first question responders ask is: how bad was it? Wildfire burn severity maps help emergency response teams, ecologists, and land managers prioritize where to focus recovery efforts.

The USGS Burned Area Emergency Response (BAER) program relies on dNBR maps to identify areas at risk of erosion, flooding, and habitat loss. In this lab, we'll produce the same type of map that professionals use in the field. That's a powerful skill to have.

Before You Start

  • Prerequisites: Spectral Indices module, Cloud Masking module, Change Detection module.
  • Estimated time: 45 minutes
  • You will need: GEE Code Editor access

Key Terms

dNBR
Differenced Normalized Burn Ratio: pre-fire NBR minus post-fire NBR. Positive values indicate burned areas.
Burn severity
A classification of how intensely a fire affected vegetation and soil, ranging from unburned to high severity.
Change detection
Comparing images from two different dates to identify where and how the landscape has changed.
Before-after analysis
A change detection approach that uses one pre-event and one post-event image to quantify change.

Lab Instructions

The 2021 Caldor Fire burned over 221,000 acres in El Dorado County, California, threatening the Lake Tahoe basin. Let's map its burn severity using Landsat 8 imagery.

Part 1 - Load the pre-fire imagery

We'll start by creating a cloud-masked median composite of Landsat 8 imagery from before the fire. The Caldor Fire started in mid-August 2021, so we'll use May through July for the pre-fire period.

// Cloud masking function for Landsat 8 SR
function maskL8sr(image) {
  var qaMask = image.select('QA_PIXEL').bitwiseAnd(1 << 3).eq(0)
      .and(image.select('QA_PIXEL').bitwiseAnd(1 << 4).eq(0));
  return image.updateMask(qaMask)
      .multiply(0.0000275).add(-0.2);
}

// Define the study area (Caldor Fire region)
var fireArea = ee.Geometry.Point([-120.5, 38.7]);
var studyRegion = fireArea.buffer(25000); // 25 km buffer

// Load pre-fire imagery (May - July 2021)
var preFire = ee.ImageCollection('LANDSAT/LC08/C02/T1_L2')
  .filterDate('2021-05-01', '2021-07-31')
  .filterBounds(studyRegion)
  .filter(ee.Filter.lt('CLOUD_COVER', 20))
  .map(maskL8sr)
  .median();

print('Pre-fire composite created');
Map.centerObject(studyRegion, 11);
Map.addLayer(preFire, {
  bands: ['SR_B4', 'SR_B3', 'SR_B2'],
  min: 0, max: 0.3
}, 'Pre-fire True Color');

Understanding the code:

  • maskL8sr() removes cloud and shadow pixels using the QA_PIXEL band, then applies the scale/offset correction.
  • .buffer(25000) creates a 25 km radius around the center point to capture the full fire perimeter.
  • .median() combines multiple images into a single cloud-free composite. Think of it like stacking photos and picking the most typical value for each pixel.

What you should see

A true color image of the Sierra Nevada foothills showing green forested terrain before the fire. This is our "before" snapshot.

Part 2 - Load the post-fire imagery

Now let's load imagery from after the fire was contained. The Caldor Fire was fully contained in October 2021, so we'll use October through December for the post-fire period.

// Load post-fire imagery (October - December 2021)
var postFire = ee.ImageCollection('LANDSAT/LC08/C02/T1_L2')
  .filterDate('2021-10-01', '2021-12-31')
  .filterBounds(studyRegion)
  .filter(ee.Filter.lt('CLOUD_COVER', 20))
  .map(maskL8sr)
  .median();

Map.addLayer(postFire, {
  bands: ['SR_B4', 'SR_B3', 'SR_B2'],
  min: 0, max: 0.3
}, 'Post-fire True Color');

Understanding the code:

  • The post-fire composite uses the same cloud masking and compositing approach as the pre-fire composite.
  • Toggle between the pre-fire and post-fire layers in the Layers panel to visually compare the two scenes. The difference is dramatic.

What you should see

A true color image showing brown and dark areas where the fire burned, contrasting with the surrounding green forest. Here's where it gets interesting: that contrast is exactly what we'll quantify next.

Part 3 - Calculate NBR for both dates

The Normalized Burn Ratio uses NIR (Band 5) and SWIR (Band 7) to detect burn conditions. Here's the key insight: healthy vegetation has high NIR reflectance and low SWIR reflectance. Burned areas show the opposite pattern. NBR captures that contrast beautifully.

// Calculate NBR for pre-fire composite
// NBR = (NIR - SWIR2) / (NIR + SWIR2)
var preNBR = preFire.normalizedDifference(['SR_B5', 'SR_B7']).rename('preNBR');

// Calculate NBR for post-fire composite
var postNBR = postFire.normalizedDifference(['SR_B5', 'SR_B7']).rename('postNBR');

// Visualize NBR images
Map.addLayer(preNBR, {min: -1, max: 1,
  palette: ['red', 'yellow', 'green']}, 'Pre-fire NBR');
Map.addLayer(postNBR, {min: -1, max: 1,
  palette: ['red', 'yellow', 'green']}, 'Post-fire NBR');

Understanding the code:

  • .normalizedDifference(['SR_B5', 'SR_B7']) computes (B5 - B7) / (B5 + B7), which is the standard NBR formula.
  • Pre-fire NBR should be high (green) in forested areas. Post-fire NBR should drop sharply (red) where the fire burned. That drop is our signal.

Part 4 - Compute dNBR

Now let's subtract the post-fire NBR from the pre-fire NBR. This produces the differenced NBR. High positive values mean the fire hit hard. Values near zero mean nothing changed. Think of it like a "damage score" for every pixel.

// Compute dNBR (pre minus post)
var dNBR = preNBR.subtract(postNBR).rename('dNBR');

// Clip to study region
dNBR = dNBR.clip(studyRegion);

// Display the continuous dNBR
Map.addLayer(dNBR, {
  min: -0.5, max: 1.3,
  palette: ['green', 'lightyellow', 'yellow', 'orange', 'red', 'darkred']
}, 'dNBR');

// Print dNBR statistics
print('dNBR statistics:', dNBR.reduceRegion({
  reducer: ee.Reducer.minMax(),
  geometry: studyRegion,
  scale: 30,
  maxPixels: 1e9
}));

Understanding the code:

  • preNBR.subtract(postNBR) computes the difference. Where fire burned, post-fire NBR is much lower than pre-fire NBR, producing a large positive dNBR value.
  • .clip(studyRegion) restricts the output to our area of interest.

What you should see

A continuous dNBR map with the fire perimeter clearly visible in warm colors (orange to dark red). Surrounding unburned areas should appear green or light yellow. The fire scar practically jumps off the screen.

Part 5 - Classify burn severity

Here's the cool part. We'll apply the standard USGS burn severity thresholds to turn our continuous dNBR into a classified map. Each pixel gets assigned to one of seven severity classes, just like the professionals do it.

// Classify dNBR into USGS burn severity categories
var burnSeverity = ee.Image(0)
  .where(dNBR.lt(-0.25), 1)                           // Enhanced regrowth (high)
  .where(dNBR.gte(-0.25).and(dNBR.lt(-0.1)), 2)       // Enhanced regrowth (low)
  .where(dNBR.gte(-0.1).and(dNBR.lt(0.1)), 3)         // Unburned
  .where(dNBR.gte(0.1).and(dNBR.lt(0.27)), 4)         // Low severity
  .where(dNBR.gte(0.27).and(dNBR.lt(0.44)), 5)        // Moderate-low severity
  .where(dNBR.gte(0.44).and(dNBR.lt(0.66)), 6)        // Moderate-high severity
  .where(dNBR.gte(0.66), 7)                            // High severity
  .clip(studyRegion)
  .selfMask();

// Display classified map
Map.addLayer(burnSeverity, {
  min: 1, max: 7,
  palette: [
    '#38a800',   // 1 - Enhanced regrowth (high)
    '#6fc400',   // 2 - Enhanced regrowth (low)
    '#ffffbe',   // 3 - Unburned
    '#ffff00',   // 4 - Low severity
    '#ffaa00',   // 5 - Moderate-low severity
    '#ff5500',   // 6 - Moderate-high severity
    '#e60000'    // 7 - High severity
  ]
}, 'Burn Severity Classes');

Understanding the code:

  • ee.Image(0) creates a blank image that serves as the starting canvas for classification.
  • .where(condition, value) sets pixels to a class value wherever the condition is true. It's like painting by numbers.
  • .selfMask() removes pixels with a value of 0 (any that were not classified).
  • The palette assigns a color gradient from green (regrowth) through yellow (unburned) to red (high severity).

What you should see

A classified map with distinct color bands. The fire perimeter should be clearly outlined, with red areas in the fire's core and yellow/orange at the edges where the fire burned less intensely. Congratulations, you just built a USGS-style burn severity map!

Part 6 - Calculate burned area

Finally, let's calculate the total area burned in each severity class. This is the type of summary statistic that appears in official fire damage reports.

// Calculate area for each burn class (in hectares)
// Focus on classes 4-7 (actual burn, not regrowth or unburned)
var burnedMask = burnSeverity.gte(4);
var burnedArea = burnedMask.multiply(ee.Image.pixelArea());

var totalBurned = burnedArea.reduceRegion({
  reducer: ee.Reducer.sum(),
  geometry: studyRegion,
  scale: 30,
  maxPixels: 1e9
});

print('Total burned area (hectares):',
  ee.Number(totalBurned.get('constant')).divide(10000));

// Calculate high-severity area specifically (class 7)
var highSeverityMask = burnSeverity.eq(7);
var highArea = highSeverityMask.multiply(ee.Image.pixelArea());

var highTotal = highArea.reduceRegion({
  reducer: ee.Reducer.sum(),
  geometry: studyRegion,
  scale: 30,
  maxPixels: 1e9
});

print('High severity area (hectares):',
  ee.Number(highTotal.get('constant')).divide(10000));

Understanding the code:

  • ee.Image.pixelArea() creates an image where each pixel's value is its area in square meters.
  • Multiplying by the binary mask keeps only the pixels we care about.
  • ee.Reducer.sum() adds up all the pixel areas.
  • Dividing by 10,000 converts from square meters to hectares.

What you should see

The console will print the total burned area and the high-severity area in hectares. The Caldor Fire's official burned area was approximately 89,000 hectares (221,835 acres). Your result may differ depending on your study region and cloud masking, and that's okay.

Check Your Understanding

  1. Why do we subtract post-fire NBR from pre-fire NBR (and not the reverse)?
  2. What would happen to your dNBR results if you forgot to apply cloud masking?
  3. A pixel has a dNBR of 0.35. What severity class does it belong to?
  4. Why is it important to use imagery from after the fire is fully contained (not while it is still burning)?

Troubleshooting

Problem: The map shows no data or is mostly masked

Solution: Check that your date range and location are correct. Print collection.size() to verify images exist. Try relaxing the CLOUD_COVER filter to 40 if too few images pass the filter.

Problem: The dNBR map has strange stripes or artifacts

Solution: This usually means one of your composites has gaps from cloud masking. Widen your date range to include more images, or reduce the cloud cover threshold.

Problem: Burned area is much larger or smaller than expected

Solution: Verify your buffer distance and check the dNBR thresholds. If your study region extends far beyond the fire, the area calculation will include unburned pixels that happen to have low dNBR values from other causes.

Problem: "Image.subtract: No band named 'preNBR'" error

Solution: Make sure you used .rename('preNBR') and .rename('postNBR') when computing the NBR images.

Key Takeaways

  • dNBR (pre minus post) is the standard index for mapping wildfire burn severity.
  • NBR uses NIR (SR_B5) and SWIR (SR_B7), which are highly sensitive to burn conditions.
  • USGS severity thresholds provide a standardized classification framework used worldwide.
  • Cloud masking is critical: unmasked clouds produce false change signals.
  • ee.Image.pixelArea() combined with ee.Reducer.sum() calculates geographic area from classified maps.

Common Mistakes to Avoid

  • Forgetting to apply .multiply(0.0000275).add(-0.2) before computing indices.
  • Using pre-fire dates that overlap with the fire event itself.
  • Setting the buffer too small and clipping off part of the fire perimeter.
  • Not specifying maxPixels: 1e9 in reduceRegion(), which causes errors on large areas.

Pro Tips

  • Toggle the pre-fire and post-fire true color layers to visually confirm the fire location before running the full analysis.
  • Use the Inspector tool to click on specific pixels and check their dNBR values.
  • Add the USGS MTBS (Monitoring Trends in Burn Severity) dataset to compare your results with official burn perimeters.
  • Export your classified map as a GeoTIFF for use in ArcGIS Pro or QGIS.

📋 Lab Submission

Subject: Lab 21 - Change Detection - [Your Name]

Include in your submission:

  • Shareable GEE script link (click "Get Link" in the Code Editor)
  • Screenshot of your classified burn severity map
  • Total burned area (hectares) printed in your console
  • Question 1: Which burn severity class covers the largest area in your map, and what might explain this pattern?
  • Question 2: How would your results change if you used imagery from the same season in two different years (for example, summer 2020 vs. summer 2022) instead of the pre-fire and post-fire approach? What are the advantages and disadvantages?