Monitoring of deforestation¶

Notebook prepared by Ben Maathuis. ITC-University of Twente, Enschede. The Netherlands.

In order to monitor the deforestation over the last 30 years of a selected area at the edge of the Amazon forest in Brazil use is made of time series of Landsat mosaic images (available till end 2025), combined with more recently acquired Sentinel-2 MSI. Satellite data is obtained using OpenEO and for each time step a NDVI is calculated which is subsequently combined to see the temporal developments.

In [1]:
#Uncomment line below if these packages are not installed
#!pip install contextily shapely pyproj rasterio
In [2]:
#import required libraries - eventually install if not available 
import os
import glob
import ilwis
import openeo
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import matplotlib.colors as mcolors
from matplotlib.colors import ListedColormap
import matplotlib.ticker as mticker
from matplotlib.ticker import MultipleLocator
import contextily as cx
from shapely.geometry import box
from pyproj import Transformer
from rasterio.warp import reproject, calculate_default_transform, Resampling
from rasterio.transform import from_bounds
from rasterio.crs import CRS

Area of interest selected (Amazon region Brazil - around Boca do Acre, situated in the southern part of the Brazilian state of Amazonas)

In [3]:
# AOI bounding box (EPSG:4326)
xmin = -66.85
xmax = -66.50
ymin = -8.70
ymax = -8.48

Display openstreetmap to see the location details of the area of interest specified

In [4]:
# Transform longitude/latitude to Web Mercator (EPSG:3857)
transformer = Transformer.from_crs(
    "EPSG:4326", "EPSG:3857", always_xy=True
)

xmin_m, ymin_m = transformer.transform(xmin, ymin)
xmax_m, ymax_m = transformer.transform(xmax, ymax)

# Create AOI rectangle
aoi = box(xmin_m, ymin_m, xmax_m, ymax_m)

# Plot map
fig, ax = plt.subplots(figsize=(10, 8))

# Set map extent with a small margin
margin_x = (xmax_m - xmin_m) * 3.5
margin_y = (ymax_m - ymin_m) * 3.5

ax.set_xlim(xmin_m - margin_x, xmax_m + margin_x)
ax.set_ylim(ymin_m - margin_y, ymax_m + margin_y)

cx.add_basemap(
    ax,
    source="https://a.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png",
    crs="EPSG:3857"
)

# Draw AOI bounding box
x, y = aoi.exterior.xy
ax.plot(x, y, color="red", linewidth=2, label="AOI boundary")

ax.set_title("Selected Amazon study area (AOI)")
ax.set_xlabel("Easting (m)")
ax.set_ylabel("Northing (m)")
ax.legend()
plt.tight_layout()
plt.show()
No description has been provided for this image
In [5]:
#within the notebook folder a directory is created to store your processing results
work_dir = os.getcwd()+'/result'

print("current dir is: %s" % (os.getcwd()))
print("current working directory is:",work_dir) 

if os.path.isdir(work_dir):
    print("Folder exists")
else:
    print("Folder doesn't exists")
    os.mkdir(work_dir)
current dir is: d:\jupyter\notebook_scripts\Special\Deforestation
current working directory is: d:\jupyter\notebook_scripts\Special\Deforestation/result
Folder exists
In [6]:
#set the working directory for ILWISPy
ilwis.setWorkingCatalog(work_dir)
print(work_dir)
d:\jupyter\notebook_scripts\Special\Deforestation/result
In [7]:
ilwis.version()
Out[7]:
'1.0 build 20260708'
In [8]:
#uncomment last line to remove any previous login credentials
from openeo.rest.auth.config import RefreshTokenStore
#RefreshTokenStore().remove()
In [9]:
#ensure openeo is installed and you have registered
connection = openeo.connect("openeo.dataspace.copernicus.eu").authenticate_oidc()
Authenticated using refresh token.

Get a listing of all collections available, display the id, title and description of the first 20 records, store full collection description details in csv file. Note the record numbers 5 and 17 and check their full content in the downloaded csv file.

In [10]:
collections = connection.list_collections()

df_collections = pd.DataFrame(collections)
display(df_collections[["id", "title", "description"]].head(20))

# Save to CSV
outfile = work_dir + "/openeo_collections.csv"
df_collections.to_csv(outfile, index=False, encoding="utf-8")

print(f"Collections saved to: {outfile}")
id title description
0 SENTINEL1_GRD Sentinel-1 SAR GRD: C-band Synthetic Aperture ... The Sentinel-1 mission provides data from a du...
1 COPERNICUS_30 Copernicus Global 30 meter Digital Elevation M... Copernicus Global 30 meter Digital Elevation M...
2 SENTINEL3_OLCI_L1B Sentinel 3 OLCI Sentinel 3 imagery captured by OLCI sensor
3 SENTINEL3_SLSTR Sentinel 3 SLSTR Sentinel 3 imagery captured by SLSTR sensor
4 SENTINEL_5P_L2 Sentinel 5 Precursor Sentinel 5 Precursor imagery captured by TROPO...
5 LANDSAT_BIMONTHLY_MOSAIC Landsat mosaic (bi-monthly, v1.0.1) Global, cloud-free, and reconstructed historic...
6 SENTINEL3_SYN_L2_SYN Daily global Sentinel-3 Synergy products at 30... [Experimental] Sentinel-3  SYNERGY products ar...
7 SENTINEL3_SLSTR_L2_LST Daily global Sentinel-3 Land surface temperatu... *Experimental* Daily global Sentinel-3 land su...
8 SENTINEL5P_L2_CO Sentinel-5P TROPOMI Carbon Monoxide L2 This Collection provides Sentinel-5P Level-2 C...
9 SENTINEL5P_L2_NO2 Sentinel-5P TROPOMI Nitrogendioxide L2 This Collection provides Sentinel-5P Level-2 N...
10 SENTINEL5P_L2_CH4 Sentinel-5P TROPOMI Methane L2 This Collection provides Sentinel-5P Level-2 C...
11 COPERNICUS_VEGETATION_PHENOLOGY_PRODUCTIVITY_1... Vegetation Phenology and Productivity paramete... [EXPERIMENTAL] Copernicus High Resolution Land...
12 COPERNICUS_VEGETATION_PHENOLOGY_PRODUCTIVITY_1... Vegetation Phenology and Productivity paramete... [EXPERIMENTAL] Copernicus High Resolution Land...
13 COPERNICUS_PLANT_PHENOLOGY_INDEX Seasonal PPI Trajectories, 10-daily, UTM proje... [EXPERIMENTAL] Plant phenology index (PPI) at ...
14 ESA_WORLDCOVER_10M_2020_V1 Global landcover at 10m resolution for 2020. WorldCover provides global land cover products...
15 ESA_WORLDCOVER_10M_2021_V2 Global landcover at 10m resolution for 2021. WorldCover provides global land cover products...
16 SENTINEL1_GLOBAL_MOSAICS Sentinel 1 Monthly Mosaics [EXPERIMENTAL] Sentinel-1 monthly mosaics are ...
17 SENTINEL2_L2A Sentinel-2 L2A SENTINEL-2 is a wide-swath, high-resolution, m...
18 SENTINEL2_L1C Sentinel-2 L1C SENTINEL-2 is a wide-swath, high-resolution, m...
19 SENTINEL3_OLCI_L2_LAND Sentinel-3 OLCI Land Full Resolution (NRT) This collection provides Sentinel-3 OLCI Level...
Collections saved to: d:\jupyter\notebook_scripts\Special\Deforestation/result/openeo_collections.csv

Get metadata description details for selected collection

The current situation¶

In [11]:
connection.describe_collection("SENTINEL2_L2A")
Out[11]:

To get an idea of the current situation an image from Sentinel-2 is going to be retrieved for the area of interest

In [12]:
#note the date 
t = ["2026-05-01", "2026-10-01"]
s2_cube = connection.load_collection("SENTINEL2_L2A",
    spatial_extent={'west': xmin, 'east': xmax, 'south': ymin, 'north': ymax,"crs": "EPSG:4326"},
    temporal_extent= t,
    bands=['B02', 'B03', 'B04', 'B08'],
    max_cloud_cover=10,
)

Temporal aggregation - note that t = ["2026-05-01", "2026-10-01"] which is representing the dry season. From the selected images, perform a temporal aggregation, using the 'median_time' operation which calculates the median value for each pixel across all time steps in a data cube. It reduces the temporal dimension to produce a single image for each band. For example, if a pixel has digital numbers of 25, 60, 40, 90, 35, the median is 40. Outliers caused by clouds or shadow in your time series are likely excluded using such an approach and produces a representative composite from multiple observations.

In [13]:
s2_cube_red = s2_cube.median_time()
In [14]:
s2_cube_red.download(work_dir+"/s2_2026.tif")
In [15]:
#read the S2 image in ilwispy
s2_2026 = ilwis.RasterCoverage ('s2_2026.tif')
print(s2_2026.size())
Size(3871, 2459, 4)
In [16]:
#stretch the rasterbands contained in s2_2026 using a loop
multiple_stretch = []
multiple_bands = ilwis.do('selection',s2_2026,"rasterbands(0..3)") 
ls = ilwis.do('linearstretch',multiple_bands, 1) #using an upper and lower data limit defined by the cumulative 1 and 99 % thresholds
mb_stretch = ilwis.do('setvaluerange', ls, 0, 255, 1) #set ouput to byte range
In [17]:
#store the results as an ILWIS maplist - display the image as a color composite using the ilwis386 desktop software
mb_stretch.store('S2_2026.mpl')

Calculate the NDVI from the Sentinel-2 spectral channels

In [18]:
# select required spectral channels from the initial image stack, here the visible red and NIR channels
Nir_in = ilwis.do('selection',s2_2026,"rasterbands(3)")
Red_in = ilwis.do('selection',s2_2026,"rasterbands(2)")

S2_2026_ndvi = ilwis.do('mapcalc','(@1 - @2)/(@1 + @2)', Nir_in, Red_in)
S2_2026_ndvi = ilwis.do('setvaluerange', S2_2026_ndvi, -1, 1, 0.001)
In [19]:
#store the results as an ILWIS raster map - display the NDVI using the ilwis386 desktop software 
#check the map values, especially of the forest and the defeorested areas
S2_2026_ndvi.store('S2_2026_ndvi.mpr')
In [20]:
#Transform to a numpy array for visualization using imshow
row = S2_2026_ndvi.size().ysize 
col = S2_2026_ndvi.size().xsize
dim = S2_2026_ndvi.size().linearSize()
print(row, col, dim)

raster_2np = np.fromiter(iter(S2_2026_ndvi), np.float64, dim) 
raster_2np = raster_2np.reshape((row, col))
2459 3871 9518789
In [21]:
# Plot the NDVI derived from Sentinel-2 MSI

fig, ax = plt.subplots(figsize=(12, 8))

im = ax.imshow(
    raster_2np,
    extent=[xmin, xmax, ymin, ymax],
    cmap="RdYlGn",
    vmin=0,
    vmax=1
)

# Colorbar
cbar = plt.colorbar(im, ax=ax, shrink=0.5)
cbar.set_label("NDVI")

# Axes
ax.set_xlabel("Longitude (°E)")
ax.set_ylabel("Latitude (°N)")
ax.set_title("NDVI from Sentinel-2 Mosaic of " + (t[0])+" to " + (t[1]))

# Limit to the AOI
ax.set_xlim(xmin, xmax)
ax.set_ylim(ymin, ymax)

plt.tight_layout()
plt.show()
No description has been provided for this image

Visually compare the NDVI plot with the Sentinel-2 color composite using the ilwis386 desktop software

The historical deforestation developments¶

In order to get an idea of the deforestation developments over the last 3 decades use can be made of the Landsat image archive, especially the Landsat bimonthly mosaic. First we review the meta-data.

In [22]:
connection.describe_collection("LANDSAT_BIMONTHLY_MOSAIC")
Out[22]:

As the Landsat bimonthly mosaic is available from 1997 to 2025, a timeseries with a temporal interval of 3 years, would provide a good impression of the temporal deforestation developments. Instead of downloading the individual spectral channels and calculate the NDVI on the local repository, within OpenEO the function cube.ndvi is used, which calculates the Normalized Difference Vegetation Index (NDVI) from the near-infrared (NIR) and red spectral bands of the selected satellite data cube. The median_time function is used to retrieve the final ndvi, which is subsequently downloaded for each of the temporal intervals selected.

In [23]:
# Target years: 1997, 2000, ..., 2024 - note temporal extent from Landsatbi-monthly mosaic collection
years = range(1997, 2025, 3)
In [24]:
#download derived NDVI (only) from Landsat mosaic collection from 1997 to 2025, interval of 3 years

collection_id = "LANDSAT_BIMONTHLY_MOSAIC"

for year in years:
    print(f"Processing NDVI for {year}")

    # Load May–October observations
    cube = connection.load_collection(
        collection_id,
        spatial_extent={'west': (xmin), 'east': (xmax), 'south': (ymin), 'north': (ymax),"crs": "EPSG:4326"},
        temporal_extent=[
            f"{year}-05-01",
            f"{year}-10-01"
        ],
        bands=["B03", "B04"]
    )

    # NDVI = (NIR - Red) / (NIR + Red)
    ndvi = cube.ndvi(
        nir="B04",
        red="B03"
    )

    # Calculate median across the selected period
    ndvi_median = ndvi.median_time()

    # Download GeoTIFF
    outfile = os.path.join(
        work_dir,
        f"NDVI_{year}_May_Oct.tif"
    )

    ndvi_median.download(outfile)

    print(f"Saved: {outfile}")

print("All years processed.")
Processing NDVI for 1997
Saved: d:\jupyter\notebook_scripts\Special\Deforestation/result\NDVI_1997_May_Oct.tif
Processing NDVI for 2000
Saved: d:\jupyter\notebook_scripts\Special\Deforestation/result\NDVI_2000_May_Oct.tif
Processing NDVI for 2003
Saved: d:\jupyter\notebook_scripts\Special\Deforestation/result\NDVI_2003_May_Oct.tif
Processing NDVI for 2006
Saved: d:\jupyter\notebook_scripts\Special\Deforestation/result\NDVI_2006_May_Oct.tif
Processing NDVI for 2009
Saved: d:\jupyter\notebook_scripts\Special\Deforestation/result\NDVI_2009_May_Oct.tif
Processing NDVI for 2012
Saved: d:\jupyter\notebook_scripts\Special\Deforestation/result\NDVI_2012_May_Oct.tif
Processing NDVI for 2015
Saved: d:\jupyter\notebook_scripts\Special\Deforestation/result\NDVI_2015_May_Oct.tif
Processing NDVI for 2018
Saved: d:\jupyter\notebook_scripts\Special\Deforestation/result\NDVI_2018_May_Oct.tif
Processing NDVI for 2021
Saved: d:\jupyter\notebook_scripts\Special\Deforestation/result\NDVI_2021_May_Oct.tif
Processing NDVI for 2024
Saved: d:\jupyter\notebook_scripts\Special\Deforestation/result\NDVI_2024_May_Oct.tif
All years processed.

Load the data so it can be used in ilwispy

In [25]:
# Find all NDVI images
ndvi_files = sorted(glob.glob(os.path.join(work_dir, "NDVI_*_May_Oct.tif")))

# Load images into a dictionary
ndvi = {}

for filename in ndvi_files:
    year = os.path.basename(filename).split("_")[1]

    ndvi[year] = ilwis.RasterCoverage(filename)

    print(f"Loaded NDVI image for {year}")
Loaded NDVI image for 1997
Loaded NDVI image for 2000
Loaded NDVI image for 2003
Loaded NDVI image for 2006
Loaded NDVI image for 2009
Loaded NDVI image for 2012
Loaded NDVI image for 2015
Loaded NDVI image for 2018
Loaded NDVI image for 2021
Loaded NDVI image for 2024
In [26]:
#assign appropriate name 
ndvi_1997 = ndvi["1997"]
ndvi_2000 = ndvi["2000"]
ndvi_2003 = ndvi["2003"]
ndvi_2006 = ndvi["2006"]
ndvi_2009 = ndvi["2009"]
ndvi_2012 = ndvi["2012"]
ndvi_2015 = ndvi["2015"]
ndvi_2018 = ndvi["2018"]
ndvi_2021 = ndvi["2021"]
ndvi_2024 = ndvi["2024"]
In [27]:
print(ndvi_1997.size())
Size(1400, 880, 1)
In [28]:
#store the NDVI as an ILWIS map - display the map using the ilwis386 desktop software
ndvi_1997.store('landsat_ndvi1997.mpr')

Inspect the NDVI map and determine a NDVI value which would separate the forest from the cleared areas. Take special care of mixed pisels.

In [29]:
#Transform to a numpy array for visualization using imshow for selected interval

interval = ndvi_1997

row = interval.size().ysize 
col = interval.size().xsize
dim = interval.size().linearSize()
print(row, col, dim)

raster_2npl = np.fromiter(iter(interval), np.float64, dim) 
raster_2npl = raster_2npl.reshape((row, col))
880 1400 1232000
In [30]:
# Create a plot of the NDVI from the Landsat mosaic of 1997
fig, ax = plt.subplots(figsize=(12, 8))

im = ax.imshow(
    raster_2npl,
    extent=[xmin, xmax, ymin, ymax],
    cmap="RdYlGn",
    vmin=0.5,
    vmax=1
)

# Colorbar
cbar = plt.colorbar(im, ax=ax, shrink=0.5)
cbar.set_label("NDVI")

fname = os.path.basename(str(interval))
year_title = fname.split("_")[1]


# Axes
ax.set_xlabel("Longitude (°E)")
ax.set_ylabel("Latitude (°N)")
ax.set_title("NDVI from Landsat Mosaic of " + year_title) 

# Limit to the AOI
ax.set_xlim(xmin, xmax)
ax.set_ylim(ymin, ymax)

plt.tight_layout()
plt.show()
No description has been provided for this image

In the equations used below the deforestation for each time step is retrieved and combined into a single map. Here a cutoff value as derived from visual inspection of the Landsat ndvi map to separate forest from cleared - non forest pixels used is 0.815, if less or equal then this value, the pixel is assigned deforested. If you have defined another cutoff value it can be replaced in the field below.

In [31]:
cutoff = 0.815 
In [32]:
#decade-1
#Note the use of an f-string to insert the Python variable into the ilwispy map calculation formula below
change1 = ilwis.do('mapcalc', f'iff(@1<={cutoff}, 1, iff(@2<={cutoff},2, iff(@3<={cutoff}, 3, iff(@4<={cutoff}, 4, 5))))',ndvi_1997, ndvi_2000, ndvi_2003, ndvi_2006) 
In [33]:
#to check your results display the map using the ilwis386 desktop software
change1.store('change_1997_2006_cl.mpr')
In [34]:
#Transform to a numpy array for visualization using imshow for selected interval

row = change1.size().ysize 
col = change1.size().xsize
dim = change1.size().linearSize()
print(row, col, dim)

raster_2npl = np.fromiter(iter(change1), np.float64, dim) 
raster_2npl = raster_2npl.reshape((row, col))
880 1400 1232000
In [35]:
#get the class statistics

# Count pixels for each class
values, counts = np.unique(
    raster_2npl[np.isfinite(raster_2npl)],
    return_counts=True
)

# Create pandas DataFrame
df_classes = pd.DataFrame({
    "Class": values,
    "Pixel_count": counts
})

# Display without index
display(df_classes.style.hide(axis="index"))
Class Pixel_count
1.000000 7163
2.000000 9864
3.000000 11805
4.000000 65228
5.000000 1137940
In [36]:
# Create a plot

fig, ax = plt.subplots(figsize=(12, 8))

# Show every other coordinate tick
ax.xaxis.set_major_locator(MultipleLocator(0.05)) #unit is degree
ax.yaxis.set_major_locator(MultipleLocator(0.05))


# Five classes: 1, 2, 3, 4, 5
cmap = plt.get_cmap("jet", 5)

# Each integer class is centered in its color interval
bounds = [0.5, 1.5, 2.5, 3.5, 4.5, 5.5]
norm = mcolors.BoundaryNorm(bounds, cmap.N)

im = ax.imshow(
    raster_2npl,
    extent=[xmin, xmax, ymin, ymax],
    cmap=cmap,
    norm=norm,
    interpolation="nearest"
)


# Color bar
cbar = plt.colorbar(
    im,
    ax=ax,
    boundaries=bounds,
    ticks=[1, 2, 3, 4, 5],
    spacing="uniform",
    shrink=0.4
)

cbar.set_ticklabels([
    "till-1997",
    "till-2001",
    "till-2003",
    "till-2006",
    "forest"
])
cbar.set_label("Deforestation progress")

# Axes
ax.set_xlabel("Longitude (°E)")
ax.set_ylabel("Latitude (°N)")
ax.set_title("Deforestation from Landsat Mosaic between 1997–2006")

ax.set_xlim(xmin, xmax)
ax.set_ylim(ymin, ymax)

plt.tight_layout()
plt.show()
No description has been provided for this image
In [37]:
#decade-2
#calculate the deforestation change for the second time period
change2 = ilwis.do('mapcalc', f'iff(@1<={cutoff}, 1, iff(@2<={cutoff},2, iff(@3<={cutoff}, 3, iff(@4<={cutoff}, 4, 5))))',ndvi_2009, ndvi_2012, ndvi_2015, ndvi_2018) 
In [38]:
change2.store('change_2009_2018_cl.mpr')

Review the change map of 2009-2018 and note in the equations below that the two change maps are combined. Check the change map of to whole period as well

In [39]:
combine = ilwis.do('mapcalc', 'iff(@1<=4, @1, @2+4)',change1, change2)
In [40]:
combine.store('change_1997_2018_cl.mpr')
In [41]:
#Transform to a numpy array for visualization using imshow for selected interval

row = combine.size().ysize 
col = combine.size().xsize
dim = combine.size().linearSize()
print(row, col, dim)

raster_2npl = np.fromiter(iter(combine), np.float64, dim) 
raster_2npl = raster_2npl.reshape((row, col))
880 1400 1232000
In [42]:
#get the class statistics

# Count pixels for each class
values, counts = np.unique(
    raster_2npl[np.isfinite(raster_2npl)],
    return_counts=True
)

# Create pandas DataFrame
df_classes = pd.DataFrame({
    "Class": values,
    "Pixel_count": counts
})

# Display without index
display(df_classes.style.hide(axis="index"))
Class Pixel_count
1.000000 7163
2.000000 9864
3.000000 11805
4.000000 65228
5.000000 35024
6.000000 49499
7.000000 91889
8.000000 85051
9.000000 876477
In [43]:
# Create a plot
fig, ax = plt.subplots(figsize=(12, 8))

# Show every other coordinate tick
ax.xaxis.set_major_locator(MultipleLocator(0.05)) #unit is degree
ax.yaxis.set_major_locator(MultipleLocator(0.05))


# Five classes: 1, 2, 3, 4, 5, 6, 7, 8, 9 
cmap = plt.get_cmap("jet", 9)

# Each integer class is centered in its color interval
bounds = [0.5, 1.5, 2.5, 3.5, 4.5, 5.5, 6.5, 7.5, 8.5, 9.5]
norm = mcolors.BoundaryNorm(bounds, cmap.N)

im = ax.imshow(
    raster_2npl,
    extent=[xmin, xmax, ymin, ymax],
    cmap=cmap,
    norm=norm,
    interpolation="nearest"
)


# Color bar
cbar = plt.colorbar(
    im,
    ax=ax,
    boundaries=bounds,
    ticks=[1, 2, 3, 4, 5, 6, 7, 8, 9],
    spacing="uniform",
    shrink=0.4
)

cbar.set_ticklabels([
    "till-1997",
    "till-2001",
    "till-2003",
    "till-2006",
    "till-2009",
    "till-2012",
    "till-2015",
    "till-2018",
    "forest"
])
cbar.set_label("Deforestation progress")

# Axes
ax.set_xlabel("Longitude (°E)")
ax.set_ylabel("Latitude (°N)")
ax.set_title("Deforestation from Landsat Mosaic between 1997–2018")

ax.set_xlim(xmin, xmax)
ax.set_ylim(ymin, ymax)

plt.tight_layout()
plt.show()
No description has been provided for this image

In order to calculate the change till now, the recent image from Sentinel-2 has to be resampled to Landsat - review also the meta-data given above. Note that Sentinel-2 image retrieved has a spatial resolution of 10 meters, different number of rows and columns, other projection, etc.

In [44]:
# Retrieve the Sentinel-2 derived NDVI georeference information 
print(S2_2026_ndvi.size())
print(S2_2026_ndvi.envelope())

print()

csy = S2_2026_ndvi.coordinateSystem()
print("Coordinate system:", csy.toWKT())
Size(3871, 2459, 1)
736550.000000 9037400.000000 775260.000000 9061990.000000

Coordinate system: PROJCS["s2_2026.tif",GEOCS["s2_2026.tif",DATUM[" WGS 84",[DWGS84],ELLIPSOID["WGS 84",6378137.000000000000,298.257223563000],PRIMEM["Greenwich",0, AUTHORITY["EPSG",8901"]]],PROJECTION["Transverse_Mercator"],PARAMETER[,No]PARAMETER["scale_factor",0.9996],PARAMETER["false_easting",500000],PARAMETER["false_northing",0],PARAMETER["scale",0.9996],PARAMETER["latitude_of_origin",0],PARAMETER["central_meridian",-69],UNIT[meter,1.0]]

Resample Sentinel-2 image to Landsat - also have a look at the georeference and projection information

In [45]:
# Retrieve Landsat georeference information 
print(ndvi_1997.size())
print(ndvi_1997.envelope())
grfLandsat = ndvi_1997.geoReference()
print(grfLandsat)
csy = grfLandsat.coordinateSystem()

print("Coordinate system:", csy.toWKT())
Size(1400, 880, 1)
-66.850000 -8.700000 -66.500000 -8.480000
NDVI_1997_May_Oct.tif
Coordinate system: PROJCS["ndvi_1997_may_oct.tif",GEOCS["ndvi_1997_may_oct.tif",DATUM[" WGS 84",[DWGS84],ELLIPSOID["WGS 84",6378137.000000000000,298.257223563000],PRIMEM["Greenwich",0, AUTHORITY["EPSG",8901"]]],PROJECTION[""],,UNIT[degrees,1.0]]
In [46]:
#conduct the resampling operation for the Sentinel-2 NDVI
s2ndvi2026_res = ilwis.do('resample', S2_2026_ndvi, grfLandsat,'nearestneighbour')

print(s2ndvi2026_res.size())
print(s2ndvi2026_res.coordinateSystem())
Size(1400, 880, 1)
ndvi_1997_may_oct.tif
In [47]:
#check resulting resampled ndvi map derived from Sentinel-2
s2ndvi2026_res.store("s2_ndvi2026_res.mpr")
In [48]:
#calculate the changes during the final decade, also using the resamped Sentinel-2 ndvi from 2026
change3 = ilwis.do('mapcalc', f'iff(@1<={cutoff}, 1, iff(@2<={cutoff},2, iff(@3<={cutoff}, 3, 4)))',ndvi_2021, ndvi_2024, s2ndvi2026_res) 
In [49]:
change3.store('change_2021_2026_cl.mpr')

Combine all timesteps

In [50]:
combine_all = ilwis.do('mapcalc', 'iff(@1<=8, @1, @2+8)',combine, change3)
In [51]:
#also check the results obtained using ilwis desktop
combine_all.store('change_1997_2026_cl.mpr')
In [52]:
#Transform to a numpy array for visualization using imshow for selected interval

row = combine_all.size().ysize 
col = combine_all.size().xsize
dim = combine_all.size().linearSize()
print(row, col, dim)

raster_2npl = np.fromiter(iter(combine_all), np.float64, dim) 
raster_2npl = raster_2npl.reshape((row, col))
880 1400 1232000
In [53]:
#get the class statistics for the whole time series

# Count pixels for each class
values, counts = np.unique(
    raster_2npl[np.isfinite(raster_2npl)],
    return_counts=True
)

# Create pandas DataFrame
df_classes = pd.DataFrame({
    "Class": values,
    "Pixel_count": counts
})

# Assuming the pixel counts are in the second column
df_classes["Year"] = 1997 + (df_classes.index) * 3

# Set class 11 to 2026
df_classes.loc[df_classes["Class"] == 11, "Year"] = 2026

# Remove class 12 from the dataframe
df_classes = df_classes[df_classes["Class"] != 12].copy()

# Set Year to integer
df_classes["Year"] = df_classes["Year"].astype(int)


# Convert pixel size of 30 by 30 meters to hectares / km2
# Area of one pixel: 30 m * 30 m = 900 m}2
# Total area in square meters:  1354 pixels = 1218600 m^2
# Conversion to Hectares: Divide by 10000 (1218600 / 10000) = 121.86 ha
# Conversion to km²: Divide by 1000000 (1218600 / 1000000) = 1.2186 km²
# or
# 1 pixel = 900 m^2 and 1 hectare = 10,000 m^2
# 900/10,000 = 0.09 hectares per pixel

df_classes["Area_ha"] = df_classes["Pixel_count"] * 0.09
df_classes["Area_km2"] = df_classes["Pixel_count"] * 0.0009

# Display without index
display(df_classes.style.hide(axis="index"))
Class Pixel_count Year Area_ha Area_km2
1.000000 7163 1997 644.670000 6.446700
2.000000 9864 2000 887.760000 8.877600
3.000000 11805 2003 1062.450000 10.624500
4.000000 65228 2006 5870.520000 58.705200
5.000000 35024 2009 3152.160000 31.521600
6.000000 49499 2012 4454.910000 44.549100
7.000000 91889 2015 8270.010000 82.700100
8.000000 85051 2018 7654.590000 76.545900
9.000000 66964 2021 6026.760000 60.267600
10.000000 71883 2024 6469.470000 64.694700
11.000000 57533 2026 5177.970000 51.779700
In [54]:
#get the total area deforested over time
deforested = df_classes["Area_km2"].sum()

print(f"Area deforested : {deforested:,.2f} km²")
Area deforested : 496.71 km²
In [55]:
# Sort by year
df_plot = df_classes.sort_values("Year")

fig, ax = plt.subplots(figsize=(10, 5))

ax.bar(df_plot["Year"], df_plot["Area_km2"], width=1.0)

ax.set_xlabel("Year")
ax.set_ylabel("Area (km²)")
ax.set_title("Deforestation from 1997 to 2026")
ax.set_xticks(df_plot["Year"])
ax.tick_params(axis="x", rotation=45)

ax.grid(axis="y", linestyle="--", alpha=0.5)
plt.tight_layout()
plt.show()
No description has been provided for this image

Calculate percentage of deforestation over the time series period for the area of interest

In [56]:
# AoI according to Landsat subset
pixels = 1440 * 880 #number of columns / rows of AoI
pixel_area = 900  # m² per pixel for Landsat
total_area_m2 = pixels * pixel_area
total_area_km2 = total_area_m2 / 1_000_000

print(f"Total Area: {total_area_km2:,.2f} km²")
print(f"Area Deforested : {deforested:,.2f} km²")

percentage_deforest = (deforested / total_area_km2)*100
print(f"Percentage Deforestation in AoI : {percentage_deforest:,.2f} %")
Total Area: 1,140.48 km²
Area Deforested : 496.71 km²
Percentage Deforestation in AoI : 43.55 %
In [57]:
# Plot final results

fig, ax = plt.subplots(figsize=(12, 8))

# Show every other coordinate tick
ax.xaxis.set_major_locator(MultipleLocator(0.05)) #unit is degree
ax.yaxis.set_major_locator(MultipleLocator(0.05))

# twelve classes: 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12 
cmap = plt.get_cmap("jet", 12)

# Each integer class is centered in its color interval
bounds = [0.5, 1.5, 2.5, 3.5, 4.5, 5.5, 6.5, 7.5, 8.5, 9.5, 10.5, 11.5, 12.5]
norm = mcolors.BoundaryNorm(bounds, cmap.N)

im = ax.imshow(
    raster_2npl,
    extent=[xmin, xmax, ymin, ymax],
    cmap=cmap,
    norm=norm,
    interpolation="nearest"
)

# Color bar
cbar = plt.colorbar(
    im,
    ax=ax,
    boundaries=bounds,
    ticks=[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12],
    spacing="uniform",
    shrink=0.4
)

cbar.set_ticklabels([
    "till-1997",
    "till-2001",
    "till-2003",
    "till-2006",
    "till-2009",
    "till-2012",
    "till-2015",
    "till-2018",
    "till-2021",
    "till-2024",
    "till-2026",
    "forest"
])
cbar.set_label("Deforestation progress")

# Axes
ax.set_xlabel("Longitude (°E)")
ax.set_ylabel("Latitude (°N)")
ax.set_title("Deforestation as observed from Landsat Mosaic and Sentinel-2 between 1997–2026")

ax.set_xlim(xmin, xmax)
ax.set_ylim(ymin, ymax)

plt.tight_layout()
plt.show()
No description has been provided for this image

Checking your results using openstreetmap¶

  • assuming that all deforested areas are depicted on the final Sentinel-2 image
  • then in the final binary raster assign 0 to forest and 1 to deforested pixels
In [58]:
change_mask = ilwis.do('mapcalc', f'iff(@1<={cutoff}, 1, 0)',s2ndvi2026_res) 

As can be observed from the deforestation time series map displayed above in the forest and along the clearing edges there is an issue of mixture. In order to deal with this mixture we apply a 3 by 3 mean filter and set a threshold if we still regard this pixel as forested, e.g. if within the filter kernel 6 out of the 9 pixels (the majority) are still marked as 1, then the average is 6/9=0.666, so if we set a threshold on 0.6 then based upon the majority of surrounding pixels composted of forest, the pixel will be assignd 1, else 0. This will substantially reduce the spickled appearance on the mask, especially caused by the 2026 (Sentinel-2) timestep as the cufoff value is likely not representative as it was based on the Landsat bi-monthly mosiac.

In [59]:
filter_avg = ilwis.do('linearrasterfilter', change_mask, 'average')
filter_mask = ilwis.do('mapcalc', 'iff(@1>=0.75, 1, 0)',filter_avg) 
In [60]:
#Transform to a numpy array for visualization using imshow for selected interval

row = filter_mask.size().ysize 
col = filter_mask.size().xsize
dim = filter_mask.size().linearSize()
print(row, col, dim)

filter_mask_2npl = np.fromiter(iter(filter_mask), np.float64, dim) 
filter_mask_2npl = filter_mask_2npl.reshape((row, col))
880 1400 1232000
In [61]:
#display the numpy array created in the previous step using matplotlib 
fig = plt.figure(figsize =(12, 8))
plt.imshow(filter_mask_2npl)
Out[61]:
<matplotlib.image.AxesImage at 0x18988f2ccd0>
No description has been provided for this image
In [62]:
#get the statistics for the filtered total deforested area

# Count pixels for each class
values, counts = np.unique(
    filter_mask_2npl[np.isfinite(filter_mask_2npl)],
    return_counts=True
)

# Create pandas DataFrame
df_2classes_only = pd.DataFrame({
    "Class": values,
    "Pixel_count": counts
})

# Display without index
display(df_2classes_only.style.hide(axis="index"))
Class Pixel_count
0.000000 799955
1.000000 432045
In [63]:
#get the filtered / corrected total area deforested over time
total_pixels = df_2classes_only["Pixel_count"].sum()

class1_percentage = (
    df_2classes_only.loc[df_2classes_only["Class"] == 1, "Pixel_count"].sum()
    / total_pixels
) * 100

print(f"Percentage of class 1: {class1_percentage:.2f}%")
Percentage of class 1: 35.07%
In [64]:
#display results over openstreetsmap

# AOI bounds (EPSG:4326)
xmin = -66.85
xmax = -66.50
ymin = -8.70
ymax = -8.48

# Final binary raster: 0 = forest, 1 = deforested
# final_map should have shape (rows, columns)
height, width = raster_2npl.shape

# Source raster georeferencing
src_transform = from_bounds(
    xmin, ymin, xmax, ymax, width, height
)
src_crs = CRS.from_epsg(4326)
dst_crs = CRS.from_epsg(3857)

# Calculate destination grid
dst_transform, dst_width, dst_height = calculate_default_transform(
    src_crs, dst_crs, width, height,
    xmin, ymin, xmax, ymax
)

# Reproject categorical raster using nearest-neighbour resampling
map_3857 = np.full((dst_height, dst_width), -1, dtype=np.int16)

reproject(
    source=filter_mask_2npl.astype(np.int16),
    destination=map_3857,
    src_transform=src_transform,
    src_crs=src_crs,
    dst_transform=dst_transform,
    dst_crs=dst_crs,
    src_nodata=-1,
    dst_nodata=-1,
    resampling=Resampling.nearest
)

# Transform AOI bounding box to Web Mercator
transformer = Transformer.from_crs(
    "EPSG:4326", "EPSG:3857", always_xy=True
)

xmin_m, ymin_m = transformer.transform(xmin, ymin)
xmax_m, ymax_m = transformer.transform(xmax, ymax)

aoi = box(xmin_m, ymin_m, xmax_m, ymax_m)

# Plot
fig, ax = plt.subplots(figsize=(12, 9))

margin_x = (xmax_m - xmin_m) * 0.15
margin_y = (ymax_m - ymin_m) * 0.15

ax.set_xlim(xmin_m - margin_x, xmax_m + margin_x)
ax.set_ylim(ymin_m - margin_y, ymax_m + margin_y)

# OpenStreetMap basemap
cx.add_basemap(
    ax,
    source="https://a.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png",
    crs="EPSG:3857"
)

# Transparent forest, red deforested areas, transparent outside raster
cmap = ListedColormap(["none", "red"])
cmap.set_bad("none")

plot_map = np.ma.masked_where(
    (map_3857 != 0) & (map_3857 != 1),
    map_3857
)

# Make forest pixels transparent so the OSM map remains visible
plot_map = np.ma.masked_where(plot_map == 0, plot_map)

left = dst_transform.c
top = dst_transform.f
right = left + dst_width * dst_transform.a
bottom = top + dst_height * dst_transform.e

ax.imshow(
    plot_map,
    extent=[left, right, bottom, top],
    origin="upper",
    cmap=ListedColormap(["red"]),
    vmin=0,
    vmax=1,
    interpolation="nearest",
    alpha=0.7,
    zorder=2
)

# AOI boundary
x, y = aoi.exterior.xy
ax.plot(x, y, color="yellow", linewidth=2, label="AOI boundary")

ax.set_title("Amazon deforestation map")
ax.set_xlabel("Easting (m)")
ax.set_ylabel("Northing (m)")
ax.legend()
plt.tight_layout()
plt.show()
No description has been provided for this image

Some stricking differences can be observed, therefor also compare your results with the (latest) Sentinel-2 satellite image

In [65]:
#conduct the resampling operation for the Sentinel-2 MSI images
s2_2026_res = ilwis.do('resample', mb_stretch, grfLandsat,'nearestneighbour')
In [66]:
#load the individual spectral channels of the resampled Sentinel-2 image
Blues = ilwis.do('selection',s2_2026_res,"rasterbands(0)")
Greens = ilwis.do('selection',s2_2026_res,"rasterbands(1)")
Reds = ilwis.do('selection',s2_2026_res,"rasterbands(2)")
In [67]:
#transform the spectral channels from ilwis format to a numpy array using the iterator
Blues_2np = np.fromiter(iter(Blues), np.ubyte, Blues.size().linearSize()) 
Blues_2np = Blues_2np.reshape((Blues.size().ysize, Blues.size().xsize))

Greens_2np = np.fromiter(iter(Greens), np.ubyte, Blues.size().linearSize()) 
Greens_2np = Greens_2np.reshape((Blues.size().ysize, Blues.size().xsize))

Reds_2np = np.fromiter(iter(Reds), np.ubyte, Blues.size().linearSize()) 
Reds_2np = Reds_2np.reshape((Blues.size().ysize, Blues.size().xsize))
In [68]:
#create a numpy 3D data stack
ncol = np.dstack((Reds_2np, Greens_2np, Blues_2np))
In [69]:
# Plot Sentinel-2 composite and deforestation mask
fig, ax = plt.subplots(figsize=(12, 8))

# Background: Sentinel-2 colour composite
ax.imshow(
    ncol,
    extent=[xmin, xmax, ymin, ymax],
    origin="upper"
)

# Mask forest pixels (0), retaining only deforestation pixels (1)
deforestation = np.ma.masked_equal(filter_mask_2npl, 0)

# Transparent background for masked pixels
cmap = ListedColormap(["yellow"])
cmap.set_bad((0, 0, 0, 0))

# Overlay deforestation in semi-transparent yellow
ax.imshow(
    deforestation,
    extent=[xmin, xmax, ymin, ymax],
    origin="upper",
    cmap=cmap,
    vmin=1,
    vmax=1,
    alpha=0.5,
    interpolation="nearest"
)

# Axes and title
ax.set_xlabel("Longitude (°E)")
ax.set_ylabel("Latitude (°N)")
ax.set_title("Resampled Sentinel-2 composite – 2026 with deforestation mask")

ax.set_xlim(xmin, xmax)
ax.set_ylim(ymin, ymax)

plt.tight_layout()
plt.show()
No description has been provided for this image

For the overlay of the deforestation map in semi-transparent yellow you can modify the alpha value to make it more or less transparent to check the results obtained.

The Landsat bi-monthly mosaic time series in conjunction with the more recent Sentinel-2 capability show a lot of potential to conduct change detection studies over the last 3 decades. But is should be noted that spectral response can / is different between the sensors so a single threshold to define the ndvi threshold which is used to determine if a pixel is deforested might not be the correct procedure!

In [ ]: