Skip to article frontmatterSkip to article content

Exploring Land Cover Data (Impact Observatory)

King's College London

Context

Purpose

Introduce manipulation and exploratory analysis of classified land use and cover data, using example data created by Impact Observatory from ESA Sentinel-2 imagery.

Dataset description

There are now many classified (categorical) land cover data products freely available that are useful for Environmental Data Science. These include:

These products are provided as 2D rasters (spatial) or 3D data cubes (spatio-temporal). The number and classification of discrete land cover classes varies between products, but at their most basic will distinguish between broad land covers such as ‘crops’, ‘forest’ and ‘built-up’. The nominal (categorical) character of the data influences the types of analysis appropriate.

This notebook uses data created by Impact Observatory. The data are a time series for 2017-2021 of annual global land use and land cover (LULC) mapped at 10m spatial resolution Observatory, 2022. The data are derived from ESA Sentinel-2 imagery with each annual map specifying individual pixels as belonging to one of 9 LULC classes Karra et al., 2021. The Impact Observatory LULC model uses deep learning methods to infer a single annual LULC class for each pixel in a Sentinel-2 image. Each annual global LULC map is produced by aggregating multiple inferences for images from across a given year (requiring processing approximately 2 million images to create each annual map).

Highlights

Contributions

Dataset originator/creator

The data are available under a Creative Commons BY-4.0 license.

Code

Load libraries

Source
#system
import os
import warnings
warnings.filterwarnings(action='ignore')

#data handling
import pystac_client
import odc.stac
from pystac.extensions.item_assets import ItemAssetsExtension

import geopandas as gpd
import rasterio as rio
import numpy as np
import pandas as pd
from shapely.geometry import Polygon
import xarray as xr
import rioxarray
import planetary_computer

#visualisation
import matplotlib.pyplot as plt
import matplotlib.colors as mplc
import holoviews as hv
import hvplot.pandas  
from holoviews import opts, dim

#data analysis
from sklearn import metrics  #for confusion matrix
from rasterstats import zonal_stats
Loading...

Set project structure

The next cell creates a separate folder to save any notebook outputs you should wish (e.g. below we use this directory in the demo of how you might load data from a local file).

notebook_folder = './notebook'
if not os.path.exists(notebook_folder):
    os.makedirs(notebook_folder)

1. Fetch and Load Data

This notebook uses annual land cover data from Impact Observatory, 10m spatial resolution and global extent for 2017-2021. These data are hosted online by Microsoft’s Planetary Computer which enable you, the user, to run the code in the cloud (e.g. via binder).

Below we will use the pystac-client package to access STAC information from the Planetary Computer API, then read it using the odc-stac package. In doing so we will:

  • fetch data for a study area in Mato Grosso, Brazil
  • resample the original 10m spatial resolution to 300m (to decrease execution times in this example notebook)

Cloud Data

First we need to specify the location and extent of study area to fetch the relevant data. We do this by specying a bounding box around the center point of the study area.

#specify center point of the study area
x, y = (-56.1, -12.2)  #area in MT

#define a square bounding box for the study area
km2deg = 1.0 / 111 #1 deg corresponds to approximately 111 km at the equator
xd = 200 * km2deg
yd = 325 * km2deg
study_bbox_coords = [[x - xd, y - yd], [x - xd, y + yd], [x + xd, y + yd], [x + xd, y - yd]]

#view bounding box
study_bbox_coords
[[-57.9018018018018, -15.127927927927928], [-57.9018018018018, -9.27207207207207], [-54.2981981981982, -9.27207207207207], [-54.2981981981982, -15.127927927927928]]

We can then visualise the location and extent of the study area using hvPlot.

#create a GeoPandas GeoDataFrame 
study_poly = Polygon(study_bbox_coords)

crs = {'init': 'epsg:4326'}
sa_gpd = gpd.GeoDataFrame(index=[0], crs=crs, geometry=[study_poly])  

#interactive plot using hvplot.pandas
sa_gpd_interactive = sa_gpd.hvplot(
    geo=True,crs=sa_gpd.crs.to_epsg(),alpha=0.5, xlim=(-75, -35), ylim=(-30, 10),  tiles='CartoLight')
sa_gpd_interactive
Loading...

With our study area defined, we can now query Microsoft Planetary Computer data that is available for this location using pystac-client.

catalog = pystac_client.Client.open("https://planetarycomputer.microsoft.com/api/stac/v1", modifier=planetary_computer.sign_inplace)

#https://pystac-client.readthedocs.io/en/latest/tutorials/pystac-client-introduction.html#API-Search
query = catalog.search(
    collections=["io-lulc-9-class"],
    limit=100,
    bbox=study_poly.bounds
)

#https://pystac-client.readthedocs.io/en/latest/tutorials/pystac-client-introduction.html#Items
items = list(query.get_items())
print(f"Found: {len(items):d} datasets")

print(items)
Found: 6 datasets
[<Item id=21L-2022>, <Item id=21L-2021>, <Item id=21L-2020>, <Item id=21L-2019>, <Item id=21L-2018>, <Item id=21L-2017>]

For the available datasets we can access and view the associated metadata.

stac_json = query.get_all_items_as_dict()     # Convert STAC items into a GeoJSON FeatureCollection
gdf = gpd.GeoDataFrame.from_features(stac_json)  #convert GeoJSON to GeoPandas to view nicely
gdf.head()
Loading...

The global LULC datasets are for ‘tiles’ covering regions of the earth’s surface - each line in the DataFrame above is for one year for tile 21L. We can also plot to show the study area does not cover multiple land cover tiles.

Source
%matplotlib inline
fig, axes = plt.subplots(figsize=(6, 12))

#tiles are 
gdf.plot(
    "io:tile_id",
    ax=axes,
    edgecolor="blue",
    categorical=True,
    aspect="equal",
    alpha=0.5,
    legend=True,
    legend_kwds={"loc": "upper left", "frameon": False, "ncol": 1},
)

#add polygon to show our study area
sa_gpd.plot(edgecolor="red", facecolor="none", ax=axes)

plt.show()
<Figure size 600x1200 with 1 Axes>

From the figure above we can see the whole study area (represented by the red rectangle) is within in the tiles (represented by the blue rectangles) returned by the pystac query.

Now we are happy we know what datasets we wish to fetch, we can use odc-stac to fetch and load the data into memory, specifying the projection and spatial resolution we want as well as how we want data grouped in layers:

Source
#projection
SMepsg = 3857  #https://epsg.io/3857 Geographic crs, units are m
SMepsg_str = "epsg:{0}".format(SMepsg)

#spatial resolution (in units of projection)
datares = 300

#https://odc-stac.readthedocs.io/en/latest/_api/odc.stac.load.html
lcxr = odc.stac.load(
    items,             #load the items from our query above
    groupby="start_datetime",  #create spatial layers that have the same timestamp     
    chunks={},         #use Dask to speed loading
    dtype='int',
    #next lines load study area at resolution specified by datares variable
    #without these entire image is returned at original resolution
    crs=SMepsg_str,    #specify the desired crs 
    resolution=datares,    #specify the desired spatial resolution (units as per crs)
    bbox=study_poly.bounds,    #specify bounding box in Lon/Lat (ignores crs units, use x,y to use crs units)
    resampling="mode"  #ensure sensible type for categorical data (options seem to be as for datacube.Datacube.load)
)

The data object we have created is an xarray Dataset which is a dict-like collection of DataArray objects with aligned dimensions. We can check the dimensions, coordinates, data and attributes (see xarray terminology):

lcxr
Loading...

Load Local Data

In the previous section we fetched data from an online repository and loaded it into memory. In the sections below we will continue to work with that (and you can skip to the next section now, if you don’t want to see how to save and/or read data from a local drive).

The recommended way to store xarray data structures is netCDF, a binary file format for self-described datasets that originated in the geosciences.

For example, to save the xarray Dataset we created above on disk as netCDF, we would use something like:

lcxr.to_netcdf(os.path.join(notebook_folder,"MT-study-area.nc"))

Then to load the local netCDF file into a xarray Dataset we would use:

lcxr_disk = xr.open_dataset(os.path.join(notebook_folder,"MT-study-area.nc"), decode_coords="all")
#lcxr.to_netcdf(os.path.join(notebook_folder,"MT-study-area.nc"))
#lcxr_disk = xr.open_dataset(os.path.join(notebook_folder,"MT-study-area.nc"), decode_coords="all")
#lcxr_disk

2. Visualisation

Our dataset contains a 2D raster layer of land cover types for individual snapshots in time. Each map is a composite of land use/cover predictions for 9 classes throughout the year in order to generate a representative snapshot of each year.

We can visualise these spatio-temporal data in a variety of different ways, both static and interactive. But before exploring these, we will create a matplotlib colormap to display the 9 land cover classes with intuitive colors.

Source
%matplotlib inline
#class names and IDs are held in an 'asset' of the collection
collection = catalog.get_collection("io-lulc-9-class")
ia = ItemAssetsExtension.ext(collection)
iaa = ia.item_assets["data"]

#get the names of the land cover classes with their ID in the raster (as a dictionary, name:ID)
class_names = {iaa["summary"]: iaa["values"][0] for iaa in iaa.properties["file:values"]}

#flip the keys:values in the dictionary (to create ID:name)
values_to_classes = {v: k for k, v in class_names.items()}

max_class = max(values_to_classes.keys())

#construct a matplotlib colormap
with rio.open(items[0].assets["data"].href) as src:
    colormap_def = src.colormap(1)  # get metadata colormap for band 1
    colormap = [
        np.array(colormap_def[i]) / 255 for i in range(max_class+1)
    ]  # transform to matplotlib color format
lc_cmap = mplc.ListedColormap(colormap)
print(class_names)
lc_cmap
Loading...

Now we can use the colormap with matplotlib to plot a static 2D map for a given time point.

Source
%matplotlib inline
ts='2017'  #which year layer

#plotting helpers
vmin = 0
vmax = lc_cmap.N

p=lcxr.sel(time=ts).isel(time=0).to_array("band").plot.imshow(
    col="band",
    cmap=lc_cmap,
    vmin=vmin, vmax=vmax,
    size=6
)

ticks = np.linspace(start=vmin+0.5, stop=vmax-0.5, num=vmax)
labels = [values_to_classes.get(i, "No Data") for i in range(vmax)]
p.cbar.set_ticks(ticks, labels=labels)
plt.axis('scaled')
plt.axis('off')
plt.title("Land cover for {0} at {1}m resolution".format(ts, datares), size=14)
plt.show()
<Figure size 700x600 with 2 Axes>

Similarly, we could make a static plot showing maps for each point in time to visually examine change.

Source
%matplotlib inline
p = lcxr.data.plot(
    col="time",
    cmap=lc_cmap,
    vmin=vmin, vmax=vmax,
    figsize=(16, 5)
)
ticks = np.linspace(start=vmin+0.5, stop=vmax-0.5, num=vmax)
labels = [values_to_classes.get(i, "No Data") for i in range(vmax)]
p.cbar.set_ticks(ticks, labels=labels)

for axes in p.axes.flat:
    axes.axis('off')
    axes.axis('scaled')
<Figure size 1600x500 with 7 Axes>

We can use holoviews to create an interactive plot - you should be able to use the slider below to see change through time in the same space.

Source
#plotting helpers
levels = [i for i in range(lc_cmap.N)]
bnorm = mplc.BoundaryNorm(levels, ncolors=len(levels))

#https://holoviews.org/user_guide/Gridded_Datasets.html?highlight=dask#working-with-xarray-data-types
hv.extension('matplotlib')
hv_ds = hv.Dataset(lcxr)[:, :, :]

lcmaps = hv_ds.to(hv.Image, kdims=["x", "y"], dynamic=True).options(cmap=lc_cmap, norm=bnorm, fig_inches=(5,8), colorbar=True)
lcmaps
Loading...

3. Analysing Aggregate Change

In this section we’re going to examine land cover change across the study area. Our aim is to produce a bar plot that shows increases or decreases in land area for each land cover class between two points in time.

We can quickly identify the unique values in our land cover maps and how many times they are observed (i.e. how many pixels fall in each land cover class). We can use numpy’s .unique function to count the occurrence of each value, then output as a Pandas DataFrame.

#list to hold pixel counts, class ID:name dict as list to append to 
counts_ls = [values_to_classes]

#loop over items
for i, j in enumerate(items):
    lcmap = lcxr.isel(time=i).to_array("band")            #get pixels for this year as an array
    unique, counts = np.unique(lcmap, return_counts=True)  #get the unique values and corresponding counts 
    lcmap_counts = dict(zip(unique, counts))               #combine them into a dict for easier viewing
    counts_ls.append(lcmap_counts)    

counts_df = pd.DataFrame(counts_ls).T     #create df from list and transpose

#create list of data years as string
counts_yr = list(lcxr["time"].values.astype('datetime64[Y]').astype('str'))  

counts_yr.insert(0, "Land Cover")        #insert 'LC'
counts_df.columns = counts_yr            #then use as df header
counts_df                                #print
Loading...

It might be more intuitve (and useful) to work in units of area (e.g. square km) rather than pixel counts, so let’s convert the pixel counts to area (sq km):

def cell_to_sqkm(x):
    return x * ((datares*datares) / 1000000)

sqkm_df = counts_df.copy(deep=False)
sqkm_df.loc[:,'2017':] = sqkm_df.loc[:,'2017':].apply(cell_to_sqkm)

sqkm_df
Loading...

We can see that some land cover classes are not present in any year (indicated by NaN). We’ll drop these and also reshape the data from ‘wide’ to ‘long’ for plotting.

sqkm_df = sqkm_df.dropna()    #drop No Data rows

#make data long
sqkm_df_long = pd.melt(sqkm_df, id_vars=['Land Cover'])
sqkm_df_long.rename(columns={'variable':'Year','value':'Area (sq km)'}, inplace = True)

From the Pandas DataFrame we can create both static and interactive bar plots. Here’s the static version with matplotlib.

Source
%matplotlib inline
fig, ax1 = plt.subplots(1, figsize=(8,4))

#drop NAN rows from data when plotting
sqkm_df[sqkm_df['2017'] > 0].set_index('Land Cover').plot(kind='bar', ax=ax1, cmap='viridis')

x_ticks_labels = ['Water','Trees','Flooded Veg','Crops', 'Built Area', 'Bare Ground', 'Rangeland']
ax1.set_xticklabels(x_ticks_labels, rotation=45)

plt.tick_params(labelsize=12)  
plt.xlabel('Land Cover', labelpad=10, fontsize=16)
plt.ylabel("Area (sq km)",labelpad=10, fontsize=16)     
plt.legend(title="Year")
plt.show()
<Figure size 800x400 with 1 Axes>
Source
hv.extension('bokeh')
color_lst = [mplc.to_hex(colormap[1]),mplc.to_hex(colormap[2]),mplc.to_hex(colormap[4]),
             mplc.to_hex(colormap[5]),mplc.to_hex(colormap[7]),mplc.to_hex(colormap[8]),mplc.to_hex(colormap[11])]
lc_year_bar = sqkm_df_long.hvplot.bar(y='Area (sq km)', x='Land Cover', by='Year', rot=90).options(
    color='Land Cover', cmap=color_lst, show_legend=False)
lc_year_bar
Loading...

We could also show this by calculating and plotting the change between first and final time snapshot.

Source
%matplotlib inline
#calculate
sqkm_df['Diffc-1721'] = sqkm_df['2021'] - sqkm_df['2017']

#plot
fig, ax1 = plt.subplots(1, figsize=(8,4))
sqkm_df['Diffc-1721'].plot(kind='bar', ax=ax1, cmap='viridis')
x_ticks_labels = ['Water','Trees','Flooded Veg','Crops', 'Built Area', 'Bare Ground', 'Rangeland']
ax1.set_xticklabels(x_ticks_labels, rotation=45)
plt.title("Change 2017-2021", size=16)
plt.tick_params(labelsize=12)  
plt.xlabel('Land Cover', labelpad=10, fontsize=14)
plt.ylabel("Area (sq km)",labelpad=10, fontsize=14)     
plt.show()
<Figure size 800x400 with 1 Axes>

4. Analysing Pixel-by-Pixel Change

In the last section we saw overall change in the land covers (e.g. decrease in Trees and Rangeland, increase in Crops). But which classes were changing to which other classes?

To understand the transitions between each class we need to analyse change for each pixel individually. We’ll enumerate individual pixels’ transitions for the first and last time snapshots using the a confusion matrix (using a function from sklearn and then visualise using a Sankey diagram (using holoviews).

First, the confusion matrix.

#connvert xarray DataArrays to numpy arrays
lc2017 = lcxr.sel(time='2017').isel(time=0).to_array("band").values.flatten()
lc2021 = lcxr.sel(time='2021').isel(time=0).to_array("band").values.flatten()

#create the confustion matrix using sklearn
conf = metrics.confusion_matrix(lc2017, lc2021)

#output as Pandas DF (drop no data land covers)
conf_pd = pd.DataFrame(conf,
                       index=sqkm_df[sqkm_df['2017'] != None]['Land Cover'],
                       columns=sqkm_df['Land Cover'])
conf_pd
Loading...

From the docs, the result is a:

confusion matrix whose i-th row and j-th column entry indicates the number of samples with true label being i-th class and predicted label being j-th class.

So for us, this means 2017 classes are the rows, with 2021 classes in columns (there are no predictions here - the 2021 observations are in place of any prediction). The number of cells that did not change are on the diagonal.

Now we can use the confusion matrix (after some manipulation) to create an interactive Sankey diagram using holoviews to visualise the transitions between each of the classes.

Source
#create the nodes
lc_nodes = list(conf_pd.index) + list(conf_pd.index)
nodes = hv.Dataset(enumerate(lc_nodes), 'index', 'label')

#create the edges (flows between nodes)
#list of source lcs
sourcelc = []
for i in range(0, 7):
    sourcelc += [i] * 7
#list of target lcs
targetlc = list(range(7,14))
targetlc *= 7    
                 
#list of transition counts    
conf_list = conf.flatten().tolist()  
#convert to area and round (for hover display)
conf_sqkm_list = list(map(cell_to_sqkm, conf_list))
conf_sqkm_list = [round(item) for item in conf_sqkm_list]

#combine lists for sankey
edges_sqkm = list(zip(sourcelc, targetlc, conf_sqkm_list))

#from holoviews import opts, dim
hv.extension('bokeh')
value_dim = hv.Dimension('Transition', unit='sq km')

#create the sankey from the edges and nodes
transitions = hv.Sankey((edges_sqkm, nodes), ['From', 'To'], vdims=value_dim)

transitions = transitions.opts(
    opts.Sankey(labels='label', label_position='right', show_values=False, width=900, height=300, cmap=color_lst,
                edge_color=dim('From').str(), node_color=dim('label').str()))
transitions
Loading...

Using the data objects we created to make the Sankey diagram, we can also view the most frequent transitions in a table:

edges = list(zip(sourcelc, targetlc, conf_list))                   #edges as counts
trans_df = pd.DataFrame(edges, columns =['From', 'To', 'Count'])   #convert edges list of tuples to pandas df

nodes_dict = dict(zip(list(nodes['index']),list(nodes['label'])))  #create dictionary for next replace calls
trans_df['From'].replace(to_replace=nodes_dict, inplace=True)      #replace ids with labels
trans_df['To'].replace(to_replace=nodes_dict, inplace=True)        #replace ids with labels

trans_df = trans_df[trans_df['To'] != trans_df['From']]            #remove rows of 'no transition'
trans_df['Area (sqkm)'] = trans_df['Count'].apply(cell_to_sqkm)    #add area column
trans_df.sort_values('Count', ascending=False).head()              #output head, sorted
Loading...

5. Analysing Zonal Change

Above we have analysed the data for our entire study area and on a pixel-by-pixel basis. But sometimes we want to work at a level between the entire study area and individuals, using zones that belong to some kind of vector geography. This is where raster zonal statistics come in:

Zonal Statistics uses groupings to calculate statistics for specified zones.

The statistics are summary statistics (e.g. mean, median, standard deviation) of all the pixels that fall in each zone. The zones might be watersheds, biomes or ecoregions (physically) or states, countries, counties or census districts (socio-economically).

Here, we’ll use municipality boundaries as zones. For Brazil these are freely available online in (zipped) shapefile format which we can read using GeoPandas (which inherits from Pandas).

#load shapefile for municipalities
zipfile = "https://geoftp.ibge.gov.br/organizacao_do_territorio/malhas_territoriais/malhas_municipais/municipio_2021/UFs/MT/MT_Municipios_2021.zip"
munis = gpd.read_file(zipfile)
munis.head()
Loading...

Data Manipulation

Before we can use these data with the raster data we need to do some manipulation, including:

  • setting the dtype of the CD_MUN series appropriately
  • setting the map projection to be consistent with that for the raster land cover data (we’ll also do this for the study area polygon we made above)
#munis.dtypes                                  #check dtypes
munis['CD_MUN'] = munis['CD_MUN'].astype(int)  #set appropriate for muni code
#munis.crs                                     #check projection
munis = munis.to_crs(SMepsg)                   #reproject to match our raster data
sa_gpd = sa_gpd.to_crs(SMepsg)         #also reproject sa_polygon

And now we can plot to check things are aligning nicely and to visualise our zones (municipalities).

Source
%matplotlib inline
fig, axes = plt.subplots(figsize=(6, 12))

munis.plot(
    ax=axes,
    edgecolor="blue",
    facecolor='None',
    categorical=True,
    aspect="equal",
    alpha=0.5
)

#add polygon to show our study area
sa_gpd.plot(edgecolor="red", facecolor="red", alpha=0.5, ax=axes)
axes.set_xlabel('x [metre]')
axes.set_ylabel('y [metre]')
plt.show()
<Figure size 600x1200 with 1 Axes>
#import hvplot.pandas  
#munis.hvplot(
#    geo=True,crs=munis.crs.to_epsg(),alpha=0.25, tiles=True) * sa_gpd.hvplot(
#    geo=True,crs=sa_gpd.crs.to_epsg(), alpha=0.25)

Let’s also make a static plot with matplotlib to visualise municipality boundaries over the raster land cover data.

Source
%matplotlib inline
fig,ax1=plt.subplots(figsize=(8,8))

#year 2017 is in the 0 position of lcxr['data']
lcxr['data'][0].plot(
    cmap=lc_cmap,
    vmin=vmin,
    vmax=vmax,
    ax=ax1
)

munis.plot(ax=ax1, facecolor='None', edgecolor='red', linewidth=1)

ax1.set_xlim(min(lcxr['data'][0].x), max(lcxr['data'][0].x)) 
ax1.set_ylim(min(lcxr['data'][0].y), max(lcxr['data'][0].y)) 
plt.axis('off')
plt.title("Land cover for {0} at {1}m resolution".format(ts, datares), size=14)
plt.show()
<Figure size 800x800 with 2 Axes>

Before we do our zonal statistics, to make comparison fair we will consider only those municipalities whose boundaries are completely within the study area.

First, remove municipalities from the vector shapefile that are not completely within the study area.

Source
%matplotlib inline
pip_mask = munis.within(sa_gpd.loc[0, 'geometry'])  #spatial query: select only munis within study area
sa_munis = munis[pip_mask==True]                    #remove non-selected munis
sa_munis.plot(facecolor='None')                     #check by plotting
<Axes: >
<Figure size 640x480 with 1 Axes>

Next, create a raster from the new vector shapefile to mask cells from our land cover raster.

#collapse to list for rio.features.rasterize below
sa_munis_shapes = sa_munis[['geometry', 'CD_MUN']].values.tolist()

#get the spatial transform data needed for rio.features.rasterize below
lcxr_transform = lcxr['data'][0].rio.transform()   #this only works if rioxarray has been loaded - why? 

#create the mask raster (cells are CD_MUN for municipality polygons within study area, else -999)
sa_munis_r = rio.features.rasterize(sa_munis_shapes, fill=-999, 
                                    out_shape=lcxr['data'][0].shape, 
                                    transform=lcxr_transform) 

#create xarray DataArray from the new raster
sa_munis_xr = xr.DataArray(data=sa_munis_r,
                           name='Municipality code',
                           dims=["y", "x"],
                           coords={'y':('y',lcxr.y.data),
                                   'x':('x',lcxr.x.data)})

Let’s visualise the raster we’ve created from the vector shapefile of municipality boundaries.

Source
%matplotlib inline
fig,ax1=plt.subplots(figsize=(7,7))

sa_munis_xr.plot.imshow(
    cmap='Reds',
    vmin=5090000, vmax=5110000,
    ax=ax1
)

plt.axis('off')
plt.show()
<Figure size 700x700 with 2 Axes>

And finally, mask the existing land cover xarray DataSet with this new xarray DataArray.

#mask original land cover xarray with new xarray
lcxr_mask = lcxr.where(sa_munis_xr >0)  #nodata was set to -999

Now let’s check the mask worked by plotting the new (masked) land cover data with the municipality polygons overlaid.

Source
%matplotlib inline
fig,ax1=plt.subplots(figsize=(8,8))

p = lcxr_mask['data'][0].plot(
    cmap=lc_cmap,
    vmin=vmin,
    vmax=vmax,
    ax=ax1
)

munis.plot(ax=ax1, facecolor='None', edgecolor='red', linewidth=1)

ax1.set_xlim(min(lcxr['data'][0].x), max(lcxr['data'][0].x)) 
ax1.set_ylim(min(lcxr['data'][0].y), max(lcxr['data'][0].y)) 

plt.show()
<Figure size 800x800 with 2 Axes>

One last piece of data manipulation is needed as the zonal_stats function we will use from the rasterstats package expects a 2D array in a format that can be read by the rasterio package (e.g. a numpy array). Hence data are for a single year of our data set.

lcxr_transform = lcxr_mask['data'][0].rio.transform()     #get the spatial transform info 
lcxr_17 = np.array(lcxr_mask['data'][0].astype('int32'))  #convert data for 2017 to numpy array

Zonal Stats

Now we’re ready to actually calculate the statistics from the land cover raster by municipality shapefile polygon. When specifying that our input raster is categorical (as is the case for our land cover data), the zonal_stats function returns a list of dictionaries, one for each municipality polgyon. In turn, each dictionary provides cell counts (values) for each (land cover) class (keys).

#from rasterstats import zonal_stats
lcxr_17z = zonal_stats(sa_munis, lcxr_17,
                           affine=lcxr_transform,
                           categorical=True)
lcxr_17z   
[{1: 467, 2: 55424, 4: 1, 5: 6225, 7: 296, 8: 79, 11: 40802}, {2: 1298, 5: 1340, 7: 71, 11: 2264}, {1: 390, 2: 9892, 5: 1536, 7: 49, 8: 54, 11: 16024}, {1: 76, 2: 28919, 5: 12480, 7: 79, 8: 90, 11: 3094}, {1: 262, 2: 10150, 4: 12, 5: 2246, 7: 118, 8: 32, 11: 23202}, {1: 4, 2: 3926, 5: 7005, 7: 28, 8: 3, 11: 4240}, {1: 15, 2: 33223, 4: 7, 5: 49192, 7: 188, 8: 63, 11: 15496}, {1: 34, 2: 14085, 4: 20, 5: 24556, 7: 39, 8: 284, 11: 972}, {1: 20, 2: 17018, 4: 5, 5: 13094, 7: 80, 8: 147, 11: 3680}, {1: 820, 2: 34203, 4: 79, 5: 6941, 7: 35, 8: 63, 11: 10356}, {1: 90, 2: 12272, 5: 28703, 7: 340, 8: 107, 11: 1758}, {1: 17, 2: 11858, 4: 21, 5: 5391, 7: 105, 8: 23, 11: 29141}, {1: 26, 2: 8113, 5: 3555, 7: 39, 8: 16, 11: 4179}, {1: 6, 2: 15114, 4: 2, 5: 4206, 7: 30, 8: 76, 11: 8255}, {1: 618, 2: 9902, 5: 1429, 7: 17, 11: 27368}, {1: 650, 2: 35901, 4: 16, 5: 5528, 7: 49, 8: 88, 11: 26713}, {1: 40, 2: 51898, 4: 16, 5: 50012, 7: 264, 8: 333, 11: 10289}, {1: 19, 2: 5981, 5: 2891, 7: 60, 11: 6914}, {1: 412, 2: 39336, 4: 126, 5: 4792, 7: 34, 8: 62, 11: 22007}, {1: 45, 2: 3675, 5: 847, 7: 18, 8: 3, 11: 6109}, {1: 6, 2: 5861, 5: 3779, 7: 29, 8: 24, 11: 19387}, {1: 133, 2: 49603, 4: 5, 5: 20542, 7: 49, 8: 423, 11: 9087}, {1: 1, 2: 3764, 4: 1, 5: 2178, 7: 15, 11: 7941}, {1: 16, 2: 27081, 4: 1, 5: 19384, 7: 88, 8: 98, 11: 6865}, {1: 32, 2: 24314, 5: 20581, 7: 22, 8: 59, 11: 11259}, {1: 280, 2: 19897, 4: 13, 5: 21250, 7: 812, 8: 286, 11: 3981}, {1: 356, 2: 32036, 4: 7, 5: 71821, 7: 455, 8: 263, 11: 4285}, {1: 35, 2: 58226, 4: 11, 5: 23880, 7: 76, 8: 244, 11: 15598}, {1: 42, 2: 25238, 5: 23900, 7: 123, 8: 269, 11: 3141}, {1: 8, 2: 8036, 4: 7, 5: 2251, 7: 45, 8: 32, 11: 17373}, {1: 21, 2: 15438, 5: 19175, 7: 68, 8: 205, 11: 951}, {1: 65, 2: 3043, 4: 61, 5: 1852, 7: 26, 8: 60, 11: 7849}, {1: 1, 2: 9736, 4: 3, 5: 3474, 7: 23, 8: 13, 11: 9437}, {1: 88, 2: 101187, 5: 23348, 7: 50, 8: 382, 11: 10755}, {1: 20, 2: 36506, 5: 673, 7: 40, 8: 4, 11: 22004}]
lcxr_17z_pd = pd.DataFrame(lcxr_17z)                        #convert list of dicts to DataFrame
lcxr_17z_pd.set_index(sa_munis['NM_MUN'],inplace=True)      #use municipality name as index
lcxr_17z_pd.rename(columns=values_to_classes,inplace=True)  #rename columns using dict created above
lcxr_17z_pd = lcxr_17z_pd.add_suffix('17')                  #remind ourselves these data are for a given year
lcxr_17z_pd.head()     
Loading...

We could examine these total counts, but in many ways it is more useful to consider the proportion of a muncipality that is covered by each land cover.

lcxr_17z_pd['sum17']=lcxr_17z_pd.sum(axis=1)                  #add column for total cells in the muni

lcxr_17prop_pd = pd.DataFrame(index = lcxr_17z_pd.index)      #create a new DataFrame to hold the proportions

#calculate proportion for each land cover
for column in lcxr_17z_pd.loc[:,'Water17':'Rangeland17']:
    lcxr_17prop_pd['{}-prop'.format(column)] = lcxr_17z_pd[column] / lcxr_17z_pd['sum17']  
    
lcxr_17prop_pd.head()
Loading...

We could create a-spatial plots (e.g. bar plots) directly from this DataFrame. To create spatial plots (maps) we need to attach the spatial information for each municipality to create a GeoPandas GeoDataFrame:

lcxr_17prop_gpd = pd.merge(sa_munis, lcxr_17prop_pd, how='left', left_on = 'NM_MUN', right_index=True)
lcxr_17prop_gpd.head()
Loading...

And now we can do a static plot to visualise the proportion of two land covers across the municipalities.

Source
%matplotlib inline
cols = ['Crops17-prop','Rangeland17-prop']     #columns to plot
cbins = [0.15,0.3,0.45,0.6,0.75]               #classification bins for plotting

fig, ax = plt.subplots(1, 2, figsize=(9, 30))

for ix, col in enumerate(cols):
    lcxr_17prop_gpd.plot(
        column=col, cmap='viridis',
        scheme='UserDefined', 
        classification_kwds={'bins': cbins},  
        linewidth=0., 
        legend=True, legend_kwds={"title":col,"loc": 5},
        ax=ax[ix]
    )
    
    ax[ix].title.set_text(cols[ix])
    ax[ix].set_xlim(-6450000,-5750000)   #axis limits to fit legend
    ax[ix].set_xlabel('x [metre]')
    ax[ix].set_ylabel('y [metre]')

plt.show()
<Figure size 900x3000 with 2 Axes>

An interactive plot enables linked pan/zoom of the two variables (although without consistent classification of polygons).

Source
cols = ['Crops17-prop','Rangeland17-prop']     #columns to plot

#import hvplot.pandas  # noqa
hv.extension('bokeh')
lcxr_17prop_interactive = lcxr_17prop_gpd.hvplot(
    c=cols[0],geo=True, crs=lcxr_17prop_gpd.crs.to_epsg(),cmap='viridis', title=cols[0]) + lcxr_17prop_gpd.hvplot(
    c=cols[1],geo=True, crs=lcxr_17prop_gpd.crs.to_epsg(),cmap='viridis', title=cols[1])

lcxr_17prop_interactive
Loading...

To examine change through time, we need to calculate the zonal stats for a second year, then calculate proportional change between the two time points. First the zonal stats.

Source
lcxr_21 = np.array(lcxr_mask['data'][4].astype('int32'))  #convert data for 2021 to numpy array

lcxr_21z = zonal_stats(sa_munis, lcxr_21,
                           affine=lcxr_transform,
                           categorical=True)

lcxr_21z_pd = pd.DataFrame(lcxr_21z)                        #convert list of dicts to DataFrame
lcxr_21z_pd.set_index(sa_munis['NM_MUN'],inplace=True)      #use municipality name as index
lcxr_21z_pd.rename(columns=values_to_classes,inplace=True)  #rename columns using dict created above
lcxr_21z_pd = lcxr_21z_pd.add_suffix('21')                  #remind ourselves these data are for a given year

lcxr_21z_pd['sum21']=lcxr_21z_pd.sum(axis=1)                  #add column for total cells in the muni
lcxr_21z_pd.head() 
Loading...

Now calculate the proportional change between the two years.

Source
lcxr_1721z_pd = pd.merge(lcxr_17z_pd, lcxr_21z_pd, how='left', left_index = True, right_index=True)

lcxr_1721prop_pd = pd.DataFrame(index = lcxr_1721z_pd.index)      #create a new DataFrame to hold the proportions

lcpresent = ['Water','Trees','Flooded vegetation','Crops', 'Built area', 'Bare ground', 'Rangeland']

#calculate proportional change for each land cover
for column in lcpresent:
    initial = lcxr_1721z_pd['{}17'.format(column)]
    final = lcxr_1721z_pd['{}21'.format(column)]
    lcxr_1721prop_pd['{}-propD'.format(column)] = (final - initial) / initial  
    
lcxr_1721prop_pd.describe()
Loading...

From the summary statistics we can already begin to see differences in the trajectories of change. To map change spatially we finally merge with the spatial information.

lcxr_1721prop_gpd = pd.merge(sa_munis, lcxr_1721prop_pd, how='left', left_on = 'NM_MUN', right_index=True)

Given that proportional change can be positive or negative, we should use a diverging colour palette.

Source
%matplotlib inline
cols = ['Crops-propD','Rangeland-propD']     #columns to plot
cbins = [-2,-1.5,-1,-0.5, 0, 0.5,1,1.5,2,2.5]               #classification bins for plotting

fig, ax = plt.subplots(1, 2, figsize=(9, 30))

for ix, col in enumerate(cols):
    lcxr_1721prop_gpd.plot(
        column=col, cmap='RdYlGn',   #colour palette
        scheme='UserDefined', 
        classification_kwds={'bins': cbins},  
        linewidth=0., 
        legend=True, legend_kwds={"title":col,"loc": 5},
        ax=ax[ix]
    )
    
    ax[ix].title.set_text(cols[ix])
    ax[ix].set_xlim(-6450000,-5750000)   #axis limits to fit legend
    ax[ix].set_xlabel('x [metre]')
    ax[ix].set_ylabel('y [metre]')

plt.show()
<Figure size 900x3000 with 2 Axes>

This allows us to see that all municipalities decreased their Rangeland cover and increased their Cropland cover between 2017 and 2021, and to locate municipalities with extremes of change (which we could then go and further investigate).

Summary

This notebook introduce manipulation and exploratory analysis of classified land cover data. Specifically, it:

  • used pystac-client to access information from the Microsoft’s Planetary Computer API and used that with odc-stac to read land cover raster data from an online repository.
  • used matplotlib and holoviews to visualise the spatio-temporal land cover data statically and interactively.
  • used from sklearn to create a matrix of pixel-by-pixel change and then holoviews to visualise that change using a Sankey diagram.
  • used rasterstats to analyse zonal change with tabular data and visualisations.

Citing this Notebook

Please see CITATION.cff for the full citation information. The citation file can be exported to APA or BibTex formats (learn more here).

Additional information

Review: This notebook has been reviewed by one or more members of the Environmental Data Science book community. The open review is available here.

Dataset: 10m Annual Land Use Land Cover (9-class) from Impact Observatory for 2017-2021.

License: The code in this notebook is licensed under the MIT License. The Environmental Data Science book is licensed under the Creative Commons by Attribution 4.0 license. See further details here.

Contact: If you have any suggestion or report an issue with this notebook, feel free to create an issue or send a direct message to environmental.ds.book@gmail.com.

Notebook repository version: v2.0.0
Last tested: 2025-04-21
References
  1. Observatory, I. (n.d.). Methodology & Accuracy Summary. Impact Observatory. Retrieved July 1, 2022, from https://www.impactobservatory.com/static/lulc_methodology_accuracy-ee742a0a389a85a0d4e7295941504ac2.pdf
  2. Karra, K., Kontgis, C., Statman-Weil, Z., Mazzariello, J. C., Mathis, M., & Brumby, S. P. (2021). Global land use / land cover with Sentinel 2 and deep learning. 2021 IEEE International Geoscience and Remote Sensing Symposium IGARSS, 4704–4707. 10.1109/IGARSS47720.2021.9553499