# Burnt area mapping using Sentinel-2 Near Real Time data ¶

## Background¶

### Normalized Burn Ratio¶

The Normalized Burn Ratio (NBR) is an index that uses the differences in the way healthy green vegetation and burnt vegetation reflect light to find burnt area. It is calculated using the following Sentinel-2 bands: Near Infrared/Band 8 and Shortwave Infrared/Band 12. The equation is defined below:

$$NBR = \frac{(NIR - SWIR)}{(NIR + SWIR)}$$

NBR returns values between -1 and 1. Healthy green vegetation will have a high NBR value while burnt vegetation will have a low value. Areas of dry, brown vegetation or bare soil will also return lower NBR values than green vegetation.

### Delta Normalized Burn Ratio¶

Change in Normalized Burn Ratio - also called Delta Normalized Burn Ratio (dNBR) - is calculated by subtracting the post-fire NBR value from the baseline NBR value as defined in this equation:

$$dNBR = NBR_{baseline} - NBR_{post fire}$$

The dNBR value can be more useful than the NBR alone to determine what is burnt as it shows change from the baseline state. A burnt area will have a positive dNBR value while an unburnt area will have a negative dNBR value or a value close to zero.

dNBR can also be used to describe burn severity (although this notebook does not look at burn severity). A higher severity fire will burn more vegetation, resulting in a higher dNBR. More information on NBR, dNBR and using it to measure burn severity can be found on the UN-SPIDER knowledge portal.

### Defining Burnt From Unburnt Areas¶

Rahman et al. 2018 found a dNBR threshold value of +0.1 appropriate for differentiating burnt from unburnt areas when using Sentinel-2. However, some exploration with different threshold levels may be needed to get a good result in areas with different vegetation types.

In the example presented in this notebook, which covers part of the Clyde Mountain fire in the area north of Batemans Bay, the fire occurred in heavily forested area, which returns a very strong dNBR result. Using +0.1 as a threshold here results in many false positives being picked up in the unburnt urban and forest areas where vegetation drying has occurred prior to the fire. A much more conservative threshold here of +0.3 produces a better result. Keep in mind the limitations of remote sensing and that in an ideal situation ground truth data collected in the location of interest would be used to assist in selecting a threshold.

Some care should also be taken when interpreting results as a number of possible false positives can return a positive dNBR result:

• A lot of smoke in the post burn image can interfere with the dNBR value

• Areas that have been cleared of vegetation by some other means (logging, harvesting, and landslides) towards the end of the baseline period may incorrectly show up as burnt

• Drying out of bright green vegetation such as grasses. If a fire event has been preceded by a rapid drying out of vegetation this can result in a low positive dNBR value in areas that have not burnt.

## Description¶

This notebook calculates the change in Normalized Burn Ratio between a baseline composite image of the pre-fire condition of a selected area and a post-fire event image, in order to find burnt area extent. Specifically this notebook has been designed for mapping fire extent in recent fires, and so assumes that NRT products will need to be used. If the fire of interest is historical, users should select the definitive Sentinel-2 Analysis Ready Product instead.

The user can change the location over which this notebook is run and specify a different date between which pre and post fire condition will be compared. The length of time over which the baseline composite image will be generated can be specified as 3, 6 or 12 months. The code in this notebook will automatically generate the composite image over the set length of time using both Sentinel-2 Near Real Time data and the definitive Sentinel 2 Analysis Ready Product.

The notebook contains the following steps:

1. Select a location for the analysis

2. Define fire event date and length of composite image

4. Generate Normalized Burn Ratio for baseline period

5. Load post-fire data from Near Real Time data

6. Generate Normalized Burn Ratio for post fire image

7. Caculate Delta Normalized Burn Ratio

8. Apply threshold to Delta Normalized Burn Ratio

9. Calculate the area burnt

10. Export results as a GeoTIFF

## Getting started¶

To run this analysis, run all the cells in the notebook, starting with the “Load packages” cell.

Import Python packages that are used for the analysis.

[1]:

import datacube
from datacube.utils import cog
from datetime import datetime
from datetime import timedelta
import pandas as pd
import xarray as xr
import numpy as np
import matplotlib.pyplot as plt

import sys
sys.path.insert(1, '../Tools/')
from dea_tools.plotting import rgb, display_map
from dea_tools.bandindices import calculate_indices


/env/lib/python3.8/site-packages/geopandas/_compat.py:106: UserWarning: The Shapely GEOS version (3.8.0-CAPI-1.13.1 ) is incompatible with the GEOS version PyGEOS was compiled with (3.9.1-CAPI-1.14.2). Conversions between both will be slow.
warnings.warn(


### Connect to the datacube¶

Connect to the datacube so we can access DEA data. The app parameter is a unique name for the analysis which is based on the notebook file name.

[2]:

dc = datacube.Datacube(app="Burnt_area_mapping")


### Select location¶

The selected latitude and longitude will be displayed as a red box on the map below the next cell. This map can be used to find coordinates of other places, simply scroll and click on any point on the map to display the latitude and longitude of that location.

[3]:

# Set the central latitude and longitude
central_lat = -35.653031
central_lon = 150.231667

# Set the buffer to load around the central coordinates
buffer = 0.1

# Compute the bounding box for the study area
study_area_lat = (central_lat - buffer, central_lat + buffer)
study_area_lon = (central_lon - buffer, central_lon + buffer)

display_map(x=study_area_lon, y=study_area_lat, margin=-0.2)

[3]:


### Define fire event date and length of composite image¶

Delta Normalized Burn Ratio produces the best result when using a post-fire image that was collected before much re-growth has occured. However, images collected while the fire is still active can be obscured by smoke and not show the full burn extent. As a result some adjustment of the fire event date entered may be needed to get the best result.

The length of the baseline period can be automatically set to 3, 6 or 12 months

[4]:

# Fire event date
fire_date = '2020-01-05'

# Length of baseline period
baseline_length = '3 months'


#### Automaticaly define date range for baseline composite image¶

[5]:

# Define dates for loading data
if baseline_length == '12 months':
time_step = timedelta(days=365)
if baseline_length == '6 months':
time_step = timedelta(days=182.5)
if baseline_length == '3 months':
time_step = timedelta(days=91)

# Calculate the start and end date for baseline data load
start_date_pre = datetime.strftime(
((datetime.strptime(fire_date, '%Y-%m-%d')) - time_step), '%Y-%m-%d')
end_date_pre = datetime.strftime(
((datetime.strptime(fire_date, '%Y-%m-%d')) - timedelta(days=1)),
'%Y-%m-%d')

# Calculate end date for post fire data load
start_date_post = datetime.strftime(
((datetime.strptime(fire_date, '%Y-%m-%d')) + timedelta(days=1)),
'%Y-%m-%d')
end_date_post = datetime.strftime(
((datetime.strptime(fire_date, '%Y-%m-%d')) + timedelta(days=90)),
'%Y-%m-%d')

# Print dates
print(f'start_date_pre:  {start_date_pre}')
print(f'end_date_pre:    {end_date_pre}')
print(f'fire_date:       {fire_date}')
print(f'start_date_post: {start_date_post}')
print(f'end_date_post:   {end_date_post}')

start_date_pre:  2019-10-06
end_date_pre:    2020-01-04
fire_date:       2020-01-05
start_date_post: 2020-01-06
end_date_post:   2020-04-04


#### Create a function to combine ARD and NRT if both contain data¶

[6]:

def combine_if(nrt, ard):

# If both ARD and NRT data is available
if nrt is not None and ard is not None:
print('Combining ARD and NRT data')

# Print dates to see what images are available for both datasets
ard_dates = ard.time.dt.strftime('%Y-%m-%d').values
nrt_dates = nrt.time.dt.strftime('%Y-%m-%d').values
print(f'ARD dates\n    {ard_dates}')
print(f'NRT dates\n    {nrt_dates}')

# Find duplicates (convert to single item if list has length 1)
duplicate_dates = np.intersect1d(ard_dates, nrt_dates)
duplicate_dates = (duplicate_dates[0] if
len(duplicate_dates) == 1
else duplicate_dates)

# Only select NRT that is not duplicated in ARD
if len(duplicate_dates) > 0:
nrt = nrt.sel(time=~duplicate_dates)

# Concantenate NRT and ARD data together
dataset = xr.concat([ard, nrt], dim='time').sortby('time')

# Print to see when images are available in output dataset
dates = dataset.time.dt.strftime('%Y-%m-%d').values.tolist()
print(f'Output dates after removing duplicates\n    {dates}')

return dataset

# If only ARD is available
elif ard is not None:
# Convert dates and print to see when images are available
dates = ard.time.dt.strftime('%Y-%m-%d').values.tolist()
print(f'Using ARD data\n    {dates}')

return ard

# If only NRT is available
elif nrt is not None:
# Convert dates and print to see when images are available
dates = nrt.time.dt.strftime('%Y-%m-%d').values.tolist()
print(f'Using NRT data\n    {dates}')

return nrt

# If no data of any kind is available
else:
print('No data avaliable')



[7]:

resolution = (-10, 10)
measurements = ['nbart_blue', 'nbart_green', 'nbart_red',
'nbart_nir_1', 'nbart_swir_3']
min_gooddata = 0.5
output_crs = 'EPSG:3577'


[8]:

try:
# Load all data in basline period avalible from ARD data
products=['s2a_ard_granule', 's2b_ard_granule'],
x=study_area_lon,
y=study_area_lat,
time=(start_date_pre, end_date_pre),
measurements=measurements,
min_gooddata=min_gooddata,
output_crs=output_crs,
resolution=resolution,
group_by='solar_day')
except:
baseline_ard = None
print('There is no baseline ARD data')

try:
# Load remaining data in baseline period available from ARD collection
products=['s2a_nrt_granule', 's2b_nrt_granule'],
x=study_area_lon,
y=study_area_lat,
time=(start_date_pre, end_date_pre),
measurements=measurements,
min_gooddata=min_gooddata,
output_crs=output_crs,
resolution=resolution,
group_by='solar_day')
except:
baseline_nrt = None
print('There is no baseline NRT data')

Finding datasets
s2a_ard_granule
s2b_ard_granule
Counting good quality pixels for each time step
Filtering to 8 out of 17 time steps with at least 50.0% good quality pixels
Finding datasets
s2a_nrt_granule
s2b_nrt_granule
There is no baseline NRT data

[9]:

baseline = combine_if(baseline_nrt, baseline_ard)
baseline

Using ARD data
['2019-10-17', '2019-10-22', '2019-10-27', '2019-11-01', '2019-11-06', '2019-11-21', '2019-12-01', '2019-12-26']

[9]:

<xarray.Dataset>
Dimensions:       (time: 8, x: 2108, y: 2457)
Coordinates:
* time          (time) datetime64[ns] 2019-10-17T00:06:42.096662 ... 2019-1...
* y             (y) float64 -4e+06 -4e+06 -4e+06 ... -4.024e+06 -4.024e+06
* x             (x) float64 1.633e+06 1.633e+06 ... 1.654e+06 1.654e+06
spatial_ref   int32 3577
Data variables:
nbart_blue    (time, y, x) float32 188.0 129.0 176.0 213.0 ... nan nan nan
nbart_green   (time, y, x) float32 253.0 172.0 215.0 294.0 ... nan nan nan
nbart_red     (time, y, x) float32 192.0 123.0 178.0 234.0 ... nan nan nan
nbart_nir_1   (time, y, x) float32 2168.0 1716.0 2058.0 ... nan nan nan
nbart_swir_3  (time, y, x) float32 398.0 355.0 355.0 423.0 ... nan nan nan
Attributes:
crs:           EPSG:3577
grid_mapping:  spatial_ref

## Generate Normalized Burn Ratio for baseline period¶

[10]:

# Calculate NBR for the baseline images
baseline = calculate_indices(baseline,
index='NBR',
collection='ga_s2_1',
drop=False)

# Compute median using all observations in the dataset along the time axis
baseline_image = baseline.median(dim='time')

# Select NBR
baseline_NBR = baseline_image.NBR


Plot the baseline NBR data side-by-side with an RGB plot of the study area:

[11]:

# Set up subplots
f, axarr = plt.subplots(1, 2, figsize=(13, 7), squeeze=False)

# Visualise baseline image as true colour image
rgb(baseline_image,
bands=['nbart_red', 'nbart_green', 'nbart_blue'],
ax=axarr[0, 0])
axarr[0, 0].set_title('Baseline RGB')
axarr[0, 0].set_xlabel('X coordinate')
axarr[0, 0].set_ylabel('Y coordinate')

# Visualise baseline image as NBR image
baseline_NBR.plot(cmap='RdBu', vmin=-1, vmax=1, ax=axarr[0, 1])
axarr[0, 1].set_title('Baseline NBR')
axarr[0, 1].yaxis.set_visible(False)
axarr[0, 1].set_xlabel('X coordinate');


[12]:

try:
# Load post-fire NRT data from Sentinel-2A and 2B
products=['s2a_nrt_granule', 's2b_nrt_granule'],
x=study_area_lon,
y=study_area_lat,
time=(start_date_post, end_date_post),
min_gooddata=min_gooddata,
measurements=measurements,
output_crs=output_crs,
resolution=resolution,
group_by='solar_day')
except:
post_nrt = None
print('There is no baseline NRT data')

try:
# Load post-fire ARD data from Sentinel-2A and 2B
products=['s2a_ard_granule', 's2b_ard_granule'],
x=study_area_lon,
y=study_area_lat,
time=('2020-01-06', '2020-04-04'),
min_gooddata=min_gooddata,
measurements=measurements,
output_crs=output_crs,
resolution=resolution,
group_by='solar_day')
except:
post_ard = None
print('There is no baseline ARD data')

Finding datasets
s2a_nrt_granule
s2b_nrt_granule
There is no baseline NRT data
Finding datasets
s2a_ard_granule
s2b_ard_granule
Counting good quality pixels for each time step
Filtering to 8 out of 17 time steps with at least 50.0% good quality pixels


### Create post-fire dataset¶

[13]:

post_col = combine_if(post_nrt, post_ard)

Using ARD data
['2020-01-10', '2020-01-30', '2020-02-04', '2020-02-24', '2020-03-10', '2020-03-20', '2020-03-25', '2020-04-04']


### Generate Normalized Burn Ratio for post fire image¶

To calculate the post-fire NBR image, we can choose to use a single, clear image (if one exists), or to calculate a median composite using the time-series. Comment out the code you don’t want to use in the cells below.

[14]:

### Use a single image:

# Select the most recent image after the fire
post_image = post_col.isel(time=0)

# Calculate NBR
post_image = calculate_indices(post_image, index='NBR', collection='ga_s2_1', drop=False)

# Select NBR
post_NBR = post_image.NBR

### Or use a median composite:

# # # Calculate NBR on all post-fire images
# post_combined = calculate_indices(post_col, index='NBR', collection='ga_s2_1', drop=False)

# # Calculate the median post-fire image
# post_image = post_combined.median(dim='time')

# # Select NBR
# post_NBR = post_image.NBR
# post_NBR


Plot the post-fire NBR data side-by-side with an RGB plot of the study area:

[15]:

# Set up subplots
f, axarr = plt.subplots(1, 2, figsize=(13, 7), squeeze=False)

# Visualise post-fire image as a true colour image
rgb(post_image,
bands=['nbart_red', 'nbart_green', 'nbart_blue'],
ax=axarr[0, 0])
axarr[0, 0].set_title('Post-fire RGB')
axarr[0, 0].set_xlabel('X coordinate')
axarr[0, 0].set_ylabel('Y coordinate')

# Visualise post-fire image as NBR image
post_NBR.plot(cmap='RdBu', vmin=-1, vmax=1, ax=axarr[0, 1])
axarr[0, 1].set_title('Post-fire NBR')
axarr[0, 1].yaxis.set_visible(False)
axarr[0, 1].set_xlabel('X coordinate');


## Calculate Delta Normalized Burn Ratio¶

We can now compute delta NBR by subtracting our post-fire NBR data from our baseline NBR data:

[16]:

delta_NBR = baseline_NBR - post_NBR

# Visualise dNBR image
delta_NBR.plot(cmap='RdBu_r', vmin=-1, vmax=1, figsize=(11, 9))
plt.xlabel('X coordinate')
plt.ylabel('Y coordinate');


## Apply threshold to Delta Normalized Burn Ratio¶

Set and apply the NBR threshold. Here we set it to 0.3, but this will need adjustment depending on the use case.

[17]:

# Set threshold
threshold = 0.3

# Apply threshold
burnt = delta_NBR > threshold

# Mask post-fire true colour image


Re-visualize pre and post-fire true colour images to help adjust the NBR threshold:

[18]:

# Set up subplots
f, axarr = plt.subplots(2, 3, figsize=(13, 11))
bands=['nbart_red', 'nbart_green', 'nbart_blue']

baseline_NBR.plot(cmap='RdBu', vmin=-1, vmax=1,
axarr[0, 0].set_title('Baseline NBR')
axarr[0, 0].set_ylabel('Y coordinate')
axarr[0, 0].xaxis.set_visible(False)

post_NBR.plot(cmap='RdBu', vmin=-1, vmax=1,
axarr[0, 1].set_title('Post-fire NBR')
axarr[0, 1].yaxis.set_visible(False)
axarr[0, 1].xaxis.set_visible(False)

delta_NBR.plot(cmap='RdBu_r', vmin=-1, vmax=1,
axarr[0, 2].set_title('Delta NBR')
axarr[0, 2].yaxis.set_visible(False)
axarr[0, 2].xaxis.set_visible(False)

rgb(baseline_image, bands=bands, ax=axarr[1, 0])
axarr[1, 0].set_title('Baseline RGB')
axarr[1, 0].set_title('Baseline RGB')
axarr[1, 0].set_xlabel('X coordinate')
axarr[1, 0].set_ylabel('Y coordinate')

rgb(post_image, bands=bands, ax=axarr[1,1])
axarr[1, 1].set_title('Post-fire RGB')
axarr[1, 1].set_xlabel('X coordinate')
axarr[1, 1].yaxis.set_visible(False)

rgb(post_image.where(burnt==1), bands=bands, ax=axarr[1, 2])
axarr[1, 2].set_title('Burnt RGB')
axarr[1, 2].set_xlabel('X coordinate')
axarr[1, 2].yaxis.set_visible(False)


## Calculate area burnt¶

[19]:

# Constants for calculating burnt area
pixel_length = resolution[1]  # in metres
m_per_km = 1000  # conversion from metres to kilometres

# Area per pixel
area_per_pixel = pixel_length ** 2 / m_per_km ** 2

# Calculate areas
unburnt_area = (delta_NBR <= threshold).sum() * area_per_pixel
burnt_area = burnt.sum() * area_per_pixel
not_nan_area = delta_NBR.notnull().sum() * area_per_pixel
nan_area = delta_NBR.isnull().sum() * area_per_pixel
total_area = unburnt_area + burnt_area + nan_area

print(f'Unburnt area:            {unburnt_area.item():.2f} km^2')
print(f'Burnt area:              {burnt_area.item():.2f} km^2')
print(f'Nan area:                {nan_area.item():.2f} km^2')
print(f'Total area (no nans):    {not_nan_area.item():.2f} km^2')
print(f'Total area (with nans):  {total_area.item():.2f} km^2')

Unburnt area:            165.57 km^2
Burnt area:              210.91 km^2
Nan area:                141.46 km^2
Total area (no nans):    376.48 km^2
Total area (with nans):  517.94 km^2


## Export results to GeoTIFF¶

The baseline reference image and the post fire image will both be saved as a multiband GeoTIFF with the following bands in the following order: Blue, Green, Red, NIR, SWIR.

The thresholded burnt area image will be saved as a single band image, where a value of 1 = burnt and a value of 0 = not burnt.

[20]:

# Define an area name to be used in saved file names
area_name = 'Example'

# Write baseline reference image to multi-band GeoTIFF
cog.write_cog(baseline_image.NBR, f'{area_name}_baseline_image.tif', overwrite=True)

# Write post fire image to multi-band GeoTIFF
cog.write_cog(post_image.NBR, f'{area_name}_post_fire_image.tif', overwrite=True)

# Turn delta NBR into a Xarray Dataset for export to GeoTIFF
cog.write_cog(delta_NBR, f'{area_name}_delta_NBR.tif', overwrite=True)

[20]:

PosixPath('Example_delta_NBR.tif')


Contact: If you need assistance, please post a question on the Open Data Cube Slack channel or on the GIS Stack Exchange using the open-data-cube tag (you can view previously asked questions here). If you would like to report an issue with this notebook, you can file one on Github.

Compatible datacube version:

[21]:

print(datacube.__version__)

1.8.5


## Tags¶

Browse all available tags on the DEA User Guide’s Tags Index

Tags: sandbox compatible, sentinel 2, load_ard, rgb, display_map, calculate_indices, NBR, change detection, real world, fire mapping, index calculation, image compositing