How to Detect New Forest Clearing with Sentinel-2 in Python

On this page

Problem statement

Annual forest-loss products arrive months after the year they describe. Monitoring needs something faster: compare a recent satellite image with one from before and flag forest that has turned into bare soil or pasture. Sentinel-2 revisits every five days at 10 m, is free, and is served as cloud-optimised GeoTIFFs that can be read by window. The basic detector — a drop in NDVI between two cloud-free dry-season images, inside a forest mask — takes a page of code.

Its difficulties are the ones every change detector has: clouds and their shadows, seasonal changes that look like clearing, and a threshold that trades missed clearings against false alarms. Checking the result against an independent loss map tells you where on that trade-off you are.

Quick answer

Pick two clear dry-season scenes a year apart, compute NDVI for both inside the forest mask, and flag large drops:

import numpy as np, rasterio, pystac_client
from rasterio.windows import from_bounds
from rasterio.warp import transform_bounds

W, S, E, N = -62.95, -10.75, -62.80, -10.60            # ~16 × 16 km inside the Hansen window
cat = pystac_client.Client.open("https://earth-search.aws.element84.com/v1")

def clearest(date_range):
    items = cat.search(collections=["sentinel-2-l2a"], bbox=[W, S, E, N], datetime=date_range,
                       query={"eo:cloud_cover": {"lt": 5}}).item_collection()
    return min(items, key=lambda i: i.properties["eo:cloud_cover"])

def ndvi(item):
    out = {}
    for band in ("red", "nir", "scl"):
        with rasterio.open(item.assets[band].href) as src:
            win = from_bounds(*transform_bounds("EPSG:4326", src.crs, W, S, E, N), src.transform)
            scale = 2 if band == "scl" else 1                  # SCL is 20 m
            out[band] = src.read(1, window=win, out_shape=(int(win.height * scale),
                                                           int(win.width * scale))).astype("float32")
            if band == "red":
                tf, crs = src.window_transform(win), src.crs
    bad = np.isin(out["scl"], [0, 1, 3, 8, 9, 10])             # nodata, saturated, shadow, cloud
    v = (out["nir"] - out["red"]) / (out["nir"] + out["red"] + 1e-6)
    return np.where(bad, np.nan, v), tf, crs

before = clearest("2023-07-01/2023-08-31")
after = clearest("2024-07-01/2024-08-31")
n0, tf, crs = ndvi(before)
n1, _, _ = ndvi(after)
print(f"before: {before.id} ({before.properties['eo:cloud_cover']:.1f}% cloud)")
print(f"after:  {after.id} ({after.properties['eo:cloud_cover']:.1f}% cloud)")
forest_like = n0 > 0.75                                          # dense green in the dry season
drop = n0 - n1
cleared = forest_like & (drop > 0.35)
ha = 0.01                                                        # a 10 m pixel is 0.01 ha
print(f"forest-like pixels before: {forest_like.sum() * ha:,.0f} ha")
print(f"flagged as cleared (NDVI drop > 0.35): {cleared.sum() * ha:,.0f} ha")
before: S2A_20LMP_20230704_0_L2A (0.0% cloud)
after:  S2A_20LMP_20240718_0_L2A (0.0% cloud)
forest-like pixels before: 10,852 ha
flagged as cleared (NDVI drop > 0.35): 822 ha

Two images, one subtraction, a threshold. The flagged area is a candidate list — the next step is checking it against something independent.

Dry season, masks, a forest baseline, a threshold

1. Use the dry season

In southern Amazonia the dry season (June–September) gives the clearest images and is when clearing and burning happen. Comparing the same season in consecutive years removes most phenology.

2. Mask clouds and shadows

Sentinel-2 L2A's scene classification layer (SCL) marks clouds, cirrus and cloud shadows. Shadows look like dark bare soil in NDVI terms; masking them prevents false clearings.

3. Build a forest mask from the earlier image

Dense, green vegetation in the dry season — NDVI above about 0.75 — is a practical forest proxy for the baseline. A proper forest map (Hansen, national products) is better where available.

4. Threshold the drop

Clearing takes NDVI from 0.8+ to bare-soil or pasture values (0.2–0.5). A drop of 0.3–0.4 separates clearing from normal variation; the threshold sets the balance between missed and false detections.

5. Clean the result

Remove isolated pixels (a minimum mapping unit of a few pixels) and flag detections next to masked clouds as less certain.

6. Validate

Compare with an independent loss map for the same period, or with a sample checked visually on imagery.

From two dry-season Sentinel-2 scenes through cloud masking, NDVI, a forest mask and a drop threshold to cleared-area candidates. a two-date NDVI detectortwo scenessame season, a yearapartSCL maskcloud, shadowNDVI before> 0.75 =forest-likedrop > 0.35candidate clearingvalidatesame intervalThe flagged area is a candidate list, not a loss figure spatialworkflow.io
A two-date NDVI detector: season, masks, threshold, validation.

New clearing against Hansen's loss

The data used on this page

The Rondônia window used throughout the forest guides (63.0°–62.5°W, 10.8°–10.3°S): Hansen Global Forest Change v1.12 — tree cover in 2000, the year of loss, and the data mask — read from Google's storage into a three-band rondonia_gfc.tif, as in the Hansen reading guide.

import os

import rasterio
from rasterio.windows import from_bounds

W, S, E, N = -63.0, -10.8, -62.5, -10.3
V = "GFC-2024-v1.12"
if not os.path.exists("rondonia_gfc.tif"):
    bands = []
    for name in ("treecover2000", "lossyear", "datamask"):
        url = f"https://storage.googleapis.com/earthenginepartners-hansen/{V}/Hansen_{V}_{name}_10S_070W.tif"
        with rasterio.open(url) as src:
            window = from_bounds(W, S, E, N, src.transform)
            bands.append(src.read(1, window=window))
            profile = src.profile | {"count": 3, "width": bands[0].shape[1], "height": bands[0].shape[0],
                                     "transform": src.window_transform(window), "compress": "deflate",
                                     "driver": "GTiff", "tiled": False}
    profile.pop("blockxsize", None)
    profile.pop("blockysize", None)
    with rasterio.open("rondonia_gfc.tif", "w", **profile) as dst:
        for i, band in enumerate(bands, start=1):
            dst.write(band, i)
with rasterio.open("rondonia_gfc.tif") as src:
    print(src.count, src.shape, src.crs, src.res)
3 (2000, 2000) EPSG:4326 (0.00025, 0.00025)

Example 1 — compare with Hansen's 2024 loss

from rasterio.warp import reproject, Resampling
with rasterio.open("rondonia_gfc.tif") as src:
    ly = np.zeros(n0.shape, dtype="uint8")
    reproject(rasterio.band(src, 2), ly, dst_transform=tf, dst_crs=crs,
              resampling=Resampling.nearest)
hansen_2024 = ly == 24
valid = forest_like & ~np.isnan(drop)
tp = (cleared & hansen_2024 & valid).sum()
print(f"Hansen 2024 loss inside the forest mask: {(hansen_2024 & valid).sum() * ha:,.0f} ha")
print(f"detected by NDVI drop:                   {tp * ha:,.0f} ha "
      f"({tp / max((hansen_2024 & valid).sum(), 1):.0%} of Hansen)")
print(f"NDVI detections Hansen has no 2024 loss for: "
      f"{(cleared & ~hansen_2024 & valid).sum() * ha:,.0f} ha")
Hansen 2024 loss inside the forest mask: 170 ha
detected by NDVI drop:                   11 ha (6% of Hansen)
NDVI detections Hansen has no 2024 loss for: 812 ha

Agreement is poor in both directions, and the dates explain much of it. The two images are from early July 2023 and mid-July 2024, while Hansen's 2024 loss runs to December. Clearing and burning peak in August and September, after the second image, so most of Hansen's 2024 loss had not yet happened when it was taken. Clearing between July and December 2023 is in the NDVI difference but belongs to Hansen's 2023. And 2024 was a severe drought year in the Amazon, when browning canopy and burnt vegetation lower NDVI without a stand-replacing loss. A two-date detector measures change between two days, not loss in a calendar year: compare it with a reference over the same interval.

Example 2 — the threshold trade-off

for t in (0.2, 0.3, 0.35, 0.45, 0.55):
    det = forest_like & (drop > t) & valid
    hit = (det & hansen_2024).sum() / max((hansen_2024 & valid).sum(), 1)
    extra = (det & ~hansen_2024).sum() * ha
    print(f"drop > {t:.2f}: {det.sum() * ha:>7,.0f} ha flagged, {hit:5.0%} of Hansen 2024 found, "
          f"{extra:>7,.0f} ha not in Hansen 2024")
drop > 0.20:   2,133 ha flagged,   26% of Hansen 2024 found,   2,090 ha not in Hansen 2024
drop > 0.30:   1,212 ha flagged,   12% of Hansen 2024 found,   1,191 ha not in Hansen 2024
drop > 0.35:     822 ha flagged,    6% of Hansen 2024 found,     812 ha not in Hansen 2024
drop > 0.45:     196 ha flagged,    1% of Hansen 2024 found,     194 ha not in Hansen 2024
drop > 0.55:      10 ha flagged,    0% of Hansen 2024 found,      10 ha not in Hansen 2024

Lower thresholds find more of the reference loss and flag much more that the reference does not have; at no threshold does the detector find even a third of Hansen's 2024 loss here, because the dates do not line up. There is no correct threshold, only a choice about which error is cheaper — and no threshold fixes a mismatch in the period.

Example 3 — a minimum mapping unit

from scipy import ndimage
labels, n = ndimage.label(cleared)
sizes = np.bincount(labels.ravel())[1:]
keep = np.isin(labels, np.where(sizes >= 10)[0] + 1)            # at least 0.1 ha
print(f"{n} detected patches; {(sizes >= 10).sum()} of at least 0.1 ha, "
      f"covering {keep.sum() * ha:,.0f} ha of the {cleared.sum() * ha:,.0f} ha flagged")
2003 detected patches; 741 of at least 0.1 ha, covering 762 ha of the 822 ha flagged

Most noise is in isolated pixels and tiny patches; real clearings are contiguous. A minimum patch size removes much of the noise for a small loss of detections.

Area flagged and share of Hansen 2024 loss found at five NDVI drop thresholds. area flagged as cleared, hadrop > 0.202,133 ha · 26% of Hansen founddrop > 0.301,212 ha · 12%drop > 0.35822 ha · 6%drop > 0.45196 ha · 1%drop > 0.5510 ha · 0%011002200No threshold fixes a mismatch between the image dates and the reference year spatialworkflow.io
Every threshold trades missed clearings for false alarms.

Why NDVI drops, and two dates are fragile

Why NDVI drops at clearing

Healthy canopy reflects strongly in the near infrared and absorbs red light; bare soil and burned or dry pasture do neither. Clearing therefore moves NDVI from above 0.8 to 0.2–0.5 in a single step, a much larger change than seasonal variation in evergreen forest.

Why two dates are fragile

Any difference between two single images includes clouds missed by the mask, haze, sun angle and phenology. Operational systems such as Brazil's DETER use many images and time-series methods for exactly this reason. A two-date detector is a transparent starting point, not an alert system.

Why the reference disagrees too

Hansen's loss is also a model output, on a calendar year and at 30 m. Disagreement between two maps measures the uncertainty of both. A sample of visually checked points is the only way to estimate the accuracy of either.

The two Sentinel-2 image dates against the Hansen 2024 loss year and the dry-season clearing peak. what each measurement covers4 Jul 2023image before1 Jan 2024GFC year starts18 Jul 2024image afterAug–Sep 2024clearing peak31 DecGFC year endsMost of Hansen’s 2024 loss happened after the second image spatialworkflow.io
The detector and the reference measure different intervals.

Cloud-free scenes, logging and fire scars

  • Cloud-free scenes are rare in the wet season; the method is a dry-season tool in the tropics.
  • Selective logging removes individual trees and barely changes NDVI; it needs other methods.
  • Fire scars drop NDVI like clearing and may recover; a later image separates them.
  • Radar (Sentinel-1) sees through clouds and is used for year-round alerts.
  • Harmonisation: processing baseline changes in Sentinel-2 L2A (2022) shifted reflectance offsets; use harmonised collections for long series.

FAQ

How do I detect deforestation with Sentinel-2?

Compare NDVI between cloud-masked dry-season images a year apart, within a forest mask, and flag large drops.

What NDVI drop indicates clearing?

Around 0.3–0.4 from a forest baseline above 0.75; tune the threshold against reference data.

How do I mask clouds in Sentinel-2 L2A?

Use the scene classification layer: exclude cloud, cirrus, cloud shadow, saturated and no-data classes.

Why doesn't my detection match Hansen?

Different periods (dry season to dry season against calendar year), resolutions, masks and methods.

Can this detect selective logging?

Not reliably; canopy gaps from logging barely change NDVI at 10 m.

What about the rainy season?

Optical images are mostly cloudy then; radar-based methods fill the gap.