Source code: Deforestation detection in Python with Global Forest Change data (Rondônia, 2001–2025)
Runnable Python scripts, requirements and console output for the tutorial “Deforestation detection in Python with Global Forest Change data (Rondônia…
Last updated
These are the exact scripts used to produce every figure and number in Deforestation detection in Python with Global Forest Change data (Rondônia, 2001–2025). Download them, install the requirements into a fresh virtual environment and run them from this folder.
- deforestation_gfc.py
- requirements.txt
- console.txt: the console output recorded when we ran it
python -m venv .venv.venv\Scripts\activate # macOS/Linux: source .venv/bin/activatepip install -r requirements.txtpython deforestation_gfc.pydeforestation_gfc.py
"""Deforestation detection in Python with Hansen/UMD Global Forest Change (v1.13, 2001-2025),correct pixel areas, a Landsat/Sentinel-2 before-and-after from the Planetary Computer, an NBRcheck against the GFC loss year and a MODIS burned-area cross-check, for central Rondonia, Brazil.
TechnicalGIS tutorial: https://www.technicalgis.com/tutorials/deforestation-detection-python/Data: Hansen/UMD/Google/USGS/NASA (CC BY 4.0); Copernicus Sentinel-2, USGS Landsat and NASA MODISvia the Microsoft Planetary Computer."""import sysimport warningsfrom importlib.metadata import versionfrom pathlib import Path
import numpy as npimport pandas as pdimport rasteriofrom rasterio.enums import Resamplingfrom rasterio.transform import from_originfrom rasterio.vrt import WarpedVRTfrom rasterio.windows import from_boundsfrom pyproj import Geodfrom shapely.geometry import box, shapeimport pystac_clientimport planetary_computerimport matplotlibmatplotlib.use("Agg") # render to files only; no GUI backendimport matplotlib.pyplot as pltfrom matplotlib.colors import ListedColormap, BoundaryNorm
warnings.filterwarnings("ignore")plt.rcParams.update({"figure.dpi": 150, "savefig.bbox": "tight", "font.size": 9})DATA = Path("data"); DATA.mkdir(exist_ok=True)OUT = Path("output"); OUT.mkdir(exist_ok=True)
VERSION = "GFC-2025-v1.13"GFC = f"https://storage.googleapis.com/earthenginepartners-hansen/{VERSION}/Hansen_{VERSION}_{{layer}}_10S_070W.tif"AOI = (-64.0, -11.0, -63.0, -10.0) # lon/lat: central Rondonia around Buritis and Campo Novo de RondoniaRES = 0.00025 # GFC pixel size in degrees (0.9 arc-seconds)FOREST_CANOPY = 30 # % canopy cover in 2000 that counts as forest (the GFW default)STAC_URL = "https://planetarycomputer.microsoft.com/api/stac/v1"
def section(title): print(f"\n=== {title} ===")
section("versions")print(f"python {sys.version.split()[0]} | rasterio {version('rasterio')} | numpy {version('numpy')} | " f"pyproj {version('pyproj')} | pystac-client {version('pystac-client')} | " f"planetary-computer {version('planetary-computer')}")
# ---------------------------------------------------------------- Step 1: read GFC layers for the AOIsection("Global Forest Change layers")
def read_gfc(layer): """Window of one 10x10 degree GFC tile, cached locally as a tiled GeoTIFF.""" path = DATA / f"{VERSION}_{layer}_rondonia.tif" if not path.exists(): with rasterio.open("/vsicurl/" + GFC.format(layer=layer)) as src: win = from_bounds(*AOI, transform=src.transform) arr = src.read(1, window=win) prof = src.profile | {"width": arr.shape[1], "height": arr.shape[0], "transform": src.window_transform(win), "tiled": True, "blockxsize": 512, "blockysize": 512, "compress": "deflate"} with rasterio.open(path, "w", **prof) as dst: dst.write(arr, 1) with rasterio.open(path) as src: return src.read(1), src.transform
treecover, transform = read_gfc("treecover2000")lossyear, _ = read_gfc("lossyear")lossyear = lossyear.astype(np.int16) # uint8 overflows when we add 2000datamask, _ = read_gfc("datamask")H, W = treecover.shapeprint(f"{VERSION}: {W} x {H} pixels of {RES} deg over lon {AOI[0]}..{AOI[2]}, lat {AOI[1]}..{AOI[3]}")print(f"lossyear values: {lossyear.min()}..{lossyear.max()} (0 = no loss, 1..25 = 2001..2025)")print(f"datamask: land {(datamask == 1).mean():.2%}, water {(datamask == 2).mean():.2%}, no data {(datamask == 0).mean():.2%}")
# ---------------------------------------------------------------- Step 2: true pixel areassection("pixel area")geod = Geod(ellps="WGS84")lats = transform.f + transform.e * np.arange(H + 1) # row edges, north to southrow_area = np.array([abs(geod.polygon_area_perimeter([0, RES, RES, 0], [lats[i], lats[i], lats[i + 1], lats[i + 1]])[0]) for i in range(H)]) # m2 of one pixel in each roww_m = geod.inv(0, -10.5, RES, -10.5)[2]h_m = geod.inv(0, -10.5 + RES / 2, 0, -10.5 - RES / 2)[2]print(f"at 10.5 S one pixel is {w_m:.2f} m wide x {h_m:.2f} m tall = {row_area[H // 2]:.1f} m2 ({row_area[H // 2] / 1e4:.4f} ha)")print(f"row areas range {row_area.min():.1f}..{row_area.max():.1f} m2 across the AOI")for lat in (0, 10.5, 30, 50, 60, 70): a = abs(geod.polygon_area_perimeter([0, RES, RES, 0], [lat, lat, lat + RES, lat + RES])[0]) print(f" pixel area at {lat:4.1f} deg: {a:7.1f} m2 ({a / 900:.2f} x a 30 m square)")area_ha = np.repeat(row_area[:, None] / 1e4, W, axis=1).astype(np.float32) # hectares per pixel
def ha(mask): return float(area_ha[mask].sum())
land = datamask == 1forest2000 = land & (treecover >= FOREST_CANOPY)print(f"AOI land area {ha(land):,.0f} ha; forest (>= {FOREST_CANOPY}% canopy) in 2000 {ha(forest2000):,.0f} ha " f"({ha(forest2000) / ha(land):.1%} of land)")naive = forest2000.sum() * 0.09print(f"pixel count x 0.09 ha would give {naive:,.0f} ha, {naive / ha(forest2000) - 1:+.1%} too much")
# ---------------------------------------------------------------- Step 3: loss by yearsection("forest loss by year (canopy >= 30% in 2000)")years = np.arange(2001, 2026)loss = forest2000 & (lossyear > 0)by_year = np.bincount(lossyear[forest2000].ravel(), weights=area_ha[forest2000].ravel(), minlength=26)[1:26]table = pd.DataFrame({"year": years, "loss_ha": by_year})table["pct_of_2000_forest"] = 100 * table.loss_ha / ha(forest2000)table["moving_avg_3yr"] = table.loss_ha.rolling(3, center=True).mean()print(table.to_string(index=False, float_format=lambda v: f"{v:,.1f}"))table.to_csv(OUT / "loss_by_year.csv", index=False)print(f"total 2001-2025: {ha(loss):,.0f} ha = {ha(loss) / ha(forest2000):.1%} of the 2000 forest; " f"peak {years[by_year.argmax()]} ({by_year.max():,.0f} ha)")print(f"2001-2010 mean {by_year[:10].mean():,.0f} ha/yr; 2011-2020 mean {by_year[10:20].mean():,.0f} ha/yr; " f"2021-2025 mean {by_year[20:].mean():,.0f} ha/yr")for thr in (10, 30, 50, 75): f = land & (treecover >= thr) print(f"canopy threshold {thr:2d}%: forest 2000 {ha(f):,.0f} ha, loss 2001-2025 {ha(f & (lossyear > 0)):,.0f} ha")print(f"loss pixels outside the 30% forest mask: {ha(land & (lossyear > 0) & ~forest2000):,.0f} ha")
# figure 1: tree cover 2000 and loss yearly_cmap = plt.get_cmap("Spectral_r", 25)fig, axes = plt.subplots(1, 2, figsize=(12.5, 5.6), constrained_layout=True)ext = (AOI[0], AOI[2], AOI[1], AOI[3])im0 = axes[0].imshow(np.where(land, treecover, np.nan), cmap="YlGn", vmin=0, vmax=100, extent=ext, interpolation="nearest")fig.colorbar(im0, ax=axes[0], shrink=0.75, label="tree canopy cover in 2000 (%)")axes[0].set_title("Tree cover 2000", fontsize=10)axes[1].imshow(np.where(forest2000 & (lossyear == 0), 1, np.nan), cmap=ListedColormap(["#c7dfb7"]), extent=ext, interpolation="nearest")im1 = axes[1].imshow(np.where(loss, lossyear + 2000, np.nan), cmap=ly_cmap, vmin=2000.5, vmax=2025.5, extent=ext, interpolation="nearest")fig.colorbar(im1, ax=axes[1], shrink=0.75, label="year of forest loss", ticks=[2001, 2005, 2010, 2015, 2020, 2025])axes[1].set_title("Forest loss 2001-2025 (pale green = forest remaining)", fontsize=10)for ax in axes: ax.set_xlabel("longitude"); ax.set_ylabel("latitude")fig.savefig(OUT / "01-treecover-lossyear.png")plt.close(fig)
# figure 2: annual lossfig, ax = plt.subplots(figsize=(10, 4.2), constrained_layout=True)ax.bar(years, table.loss_ha / 1000, color=[ly_cmap(i) for i in range(25)], edgecolor="none")ax.plot(years, table.moving_avg_3yr / 1000, color="#333333", lw=1.6, label="3-year moving average")ax.axvspan(2000.5, 2010.5, color="#999999", alpha=0.12, lw=0)ax.text(2005.5, ax.get_ylim()[1] * 0.93, "2001-2010: original method,\nnot yet reprocessed", ha="center", va="top", fontsize=8, color="#555555")ax.text(2018, ax.get_ylim()[1] * 0.93, "2011 onward: reprocessed with Landsat 8 (2013+) and 9 (2022+)", ha="center", va="top", fontsize=8, color="#555555")ax.set_ylabel("forest loss (thousand ha)")ax.set_xlim(2000.3, 2025.7)ax.legend(frameon=False, loc="center right")ax.set_title(f"Annual tree cover loss, central Rondonia (canopy >= {FOREST_CANOPY}% in 2000), GFC v1.13", fontsize=10)fig.savefig(OUT / "02-loss-by-year.png")plt.close(fig)
# figure 3: pixel area against latitudelat_axis = np.arange(0, 80.1, 0.5)pix = np.array([abs(geod.polygon_area_perimeter([0, RES, RES, 0], [l, l, l + RES, l + RES])[0]) for l in lat_axis])fig, ax = plt.subplots(figsize=(7.5, 3.8), constrained_layout=True)ax.plot(lat_axis, pix, color="#3288bd", lw=2, label="true area of a 0.00025 deg GFC pixel")ax.axhline(900, color="#d53e4f", lw=1.4, ls="--", label="'30 m pixel' assumption (900 m2)")ax.axvspan(10, 11, color="#fdae61", alpha=0.5, lw=0, label="this study area")ax.set_xlabel("latitude (degrees north or south)"); ax.set_ylabel("pixel area (m2)")ax.set_xlim(0, 80); ax.set_ylim(0, 1000)ax.legend(frameon=False, loc="lower left")fig.savefig(OUT / "03-pixel-area-latitude.png")plt.close(fig)
# ---------------------------------------------------------------- Step 4: pick a zoom area of recent losssection("zoom area")Z = 480 # 0.12 degrees, about 13 kmrecent = (forest2000 & (lossyear >= 18) & (lossyear <= 24)).astype(np.float32)blocks = recent[: H // Z * Z, : W // Z * Z].reshape(H // Z, Z, W // Z, Z).sum(axis=(1, 3))bi, bj = np.unravel_index(blocks.argmax(), blocks.shape)r0, c0 = bi * Z, bj * Zzt = transform * rasterio.Affine.translation(c0, r0)zb = (zt.c, zt.f - Z * RES, zt.c + Z * RES, zt.f)zsl = (slice(r0, r0 + Z), slice(c0, c0 + Z))zly, zf, zarea = lossyear[zsl], forest2000[zsl], area_ha[zsl]print(f"zoom: lon {zb[0]:.3f}..{zb[2]:.3f}, lat {zb[1]:.3f}..{zb[3]:.3f} ({Z} x {Z} GFC pixels)")print(f"forest 2000 {zarea[zf].sum():,.0f} ha; loss 2001-2017 {zarea[zf & (zly >= 1) & (zly <= 17)].sum():,.0f} ha; " f"loss 2018-2024 {zarea[zf & (zly >= 18) & (zly <= 24)].sum():,.0f} ha; loss 2025 {zarea[zf & (zly == 25)].sum():,.0f} ha")
catalog = pystac_client.Client.open(STAC_URL, modifier=planetary_computer.sign_inplace)
def read_to_grid(href, bounds, shape, resampling=Resampling.average, dtype="float32"): """Read a COG band straight onto our lat/lon grid (GFC pixels) with GDAL's warper.""" t = from_origin(bounds[0], bounds[3], RES, RES) with rasterio.open(href) as src, WarpedVRT(src, crs="EPSG:4326", transform=t, width=shape[1], height=shape[0], resampling=resampling) as vrt: return vrt.read(1).astype(dtype)
def s2_reflectance(item, band, bounds, shape): dn = read_to_grid(item.assets[band].href, bounds, shape) offset = 1000 if item.properties.get("s2:processing_baseline", "00.00") >= "04.00" else 0 return np.where(dn == 0, np.nan, (dn - offset) / 10000)
def best_s2(year, bounds, months="06-01/08-31", n=3): items = catalog.search(collections=["sentinel-2-l2a"], bbox=bounds, datetime=f"{year}-{months.replace('/', f'/{year}-')}", query={"eo:cloud_cover": {"lt": 20}}).item_collection() items = sorted((it for it in items if shape(it.geometry).contains(box(*bounds))), key=lambda it: it.properties["eo:cloud_cover"]) return items[:n]
def nbr_composite(items, bounds, shape): """Median NBR of the clearest scenes, with cloud, shadow, cirrus and nodata masked by SCL.""" stack = [] for it in items: scl = read_to_grid(it.assets["SCL"].href, bounds, shape, Resampling.nearest) nir, swir = s2_reflectance(it, "B8A", bounds, shape), s2_reflectance(it, "B12", bounds, shape) nbr = (nir - swir) / (nir + swir) nbr[np.isin(scl, [0, 1, 3, 8, 9, 10])] = np.nan stack.append(nbr) return np.nanmedian(np.stack(stack), axis=0)
def stretch(rgb, lo=2, hi=98): p1, p2 = np.nanpercentile(rgb, [lo, hi]) return np.clip((rgb - p1) / (p2 - p1), 0, 1)
# ---------------------------------------------------------------- Step 5: before and after imagerysection("before/after imagery")landsat = catalog.search(collections=["landsat-c2-l2"], bbox=zb, datetime="2000-06-01/2000-08-31", query={"platform": {"eq": "landsat-7"}, "eo:cloud_cover": {"lt": 5}}).item_collection()ls = sorted(landsat, key=lambda it: (it.properties["eo:cloud_cover"], it.datetime))[0]print(f"Landsat 7 before: {ls.id} cloud {ls.properties['eo:cloud_cover']}%")ls_rgb = np.dstack([read_to_grid(ls.assets[b].href, zb, (Z, Z)) * 0.0000275 - 0.2 for b in ("red", "green", "blue")])s2_before, s2_after = best_s2(2017, zb, n=1)[0], best_s2(2025, zb, n=1)[0]rgbs = {}for label, it in [("before", s2_before), ("after", s2_after)]: print(f"Sentinel-2 {label}: {it.id} cloud {it.properties['eo:cloud_cover']:.1f}% " f"baseline {it.properties.get('s2:processing_baseline')}") rgbs[label] = np.dstack([s2_reflectance(it, b, zb, (Z, Z)) for b in ("B04", "B03", "B02")])zext = (zb[0], zb[2], zb[1], zb[3])fig, axes = plt.subplots(1, 4, figsize=(16, 4.5), constrained_layout=True)panels = [(stretch(ls_rgb), f"Landsat 7 true colour, {ls.datetime:%d %b %Y}"), (stretch(rgbs["before"]), f"Sentinel-2 true colour, {s2_before.datetime:%d %b %Y}"), (stretch(rgbs["after"]), f"Sentinel-2 true colour, {s2_after.datetime:%d %b %Y}")]for ax, (img, title) in zip(axes, panels): ax.imshow(img, extent=zext); ax.set_title(title, fontsize=10)axes[3].imshow(np.where(zf & (zly == 0), 1, np.nan), cmap=ListedColormap(["#c7dfb7"]), extent=zext, interpolation="nearest")im = axes[3].imshow(np.where(zf & (zly > 0), zly + 2000, np.nan), cmap=ly_cmap, vmin=2000.5, vmax=2025.5, extent=zext, interpolation="nearest")fig.colorbar(im, ax=axes[3], shrink=0.8, label="GFC loss year")axes[3].set_title("GFC v1.13 loss year", fontsize=10)for ax in axes: ax.set_xticks([]); ax.set_yticks([])fig.savefig(OUT / "04-before-after.png")plt.close(fig)
# ---------------------------------------------------------------- Step 6: NBR check against the GFC loss yearsection("Sentinel-2 dry-season NBR, 2017-2025")nbr_years = {}for yr in range(2017, 2026): items = best_s2(yr, zb) nbr_years[yr] = nbr_composite(items, zb, (Z, Z)) print(f"{yr}: {len(items)} scenes ({', '.join(f'{it.datetime:%d %b}' for it in items)}), " f"valid {np.isfinite(nbr_years[yr]).mean():.1%}")stable = zf & (zly == 0)traj = {"stable forest": [np.nanmedian(nbr_years[y][stable]) for y in nbr_years]}for ly in range(18, 26): m = zf & (zly == ly) if m.sum() > 200: traj[f"lost in {2000 + ly}"] = [np.nanmedian(nbr_years[y][m]) for y in nbr_years]print("median NBR by year (rows: GFC class, columns: 2017..2025)")for k, v in traj.items(): print(f" {k:14s} " + " ".join(f"{x:5.2f}" for x in v))
# year of the largest year-on-year NBR drop vs the GFC year, pixel by pixel (loss 2018-2025)yrs = np.array(list(nbr_years))cube = np.stack([nbr_years[y] for y in yrs])drops = cube[:-1] - cube[1:] # positive = NBR fell from year t to t+1valid = np.isfinite(drops).all(axis=0)drop_year = yrs[1:][np.nanargmax(np.where(np.isfinite(drops), drops, -9), axis=0)]biggest = np.nanmax(np.where(np.isfinite(drops), drops, -9), axis=0)sel = zf & (zly >= 18) & validdiff = drop_year[sel] - (zly[sel] + 2000)print(f"loss pixels 2018-2025 with a full NBR record: {sel.sum():,}")print(f"largest annual NBR drop in the GFC year: {(diff == 0).mean():.1%}; one year later: {(diff == 1).mean():.1%}; " f"one year earlier: {(diff == -1).mean():.1%}; further off: {(abs(diff) > 1).mean():.1%}")print(f"largest drop >= 0.2 for {(biggest[sel] >= 0.2).mean():.1%} of GFC loss pixels vs " f"{(biggest[stable & valid] >= 0.2).mean():.1%} of stable-forest pixels")dnbr = nbr_years[2017] - nbr_years[2025]print(f"dNBR 2017->2025 median: stable forest {np.nanmedian(dnbr[stable]):.3f}, " f"lost 2018-2025 {np.nanmedian(dnbr[zf & (zly >= 18)]):.3f}, " f"lost 2001-2017 {np.nanmedian(dnbr[zf & (zly >= 1) & (zly <= 17)]):.3f}")
fig, axes = plt.subplots(1, 2, figsize=(13, 4.6), constrained_layout=True, gridspec_kw={"width_ratios": [1, 1.25]})im = axes[0].imshow(dnbr, cmap="RdYlGn_r", vmin=-0.4, vmax=0.8, extent=zext, interpolation="nearest")fig.colorbar(im, ax=axes[0], shrink=0.85, label="dNBR = NBR 2017 - NBR 2025")axes[0].contour(np.flipud(zf & (zly >= 18)).astype(float), levels=[0.5], colors="#222222", linewidths=0.5, extent=zext)axes[0].set_title("Sentinel-2 dNBR, dry season 2017 to 2025\n(black outline: GFC loss 2018-2025)", fontsize=10)axes[0].set_xticks([]); axes[0].set_yticks([])cols = lambda i: plt.get_cmap("plasma")(0.85 * i / max(1, len(traj) - 2))for i, (k, v) in enumerate(traj.items()): style = dict(color="#1a9850", lw=2.5) if k == "stable forest" else dict(color=cols(i - 1), lw=1.4) axes[1].plot(yrs, v, marker="o", ms=3, label=k, **style)axes[1].set_xlabel("dry season (June-August)"); axes[1].set_ylabel("median NBR")axes[1].set_title("NBR of pixels grouped by their GFC loss year", fontsize=10)axes[1].legend(frameon=False, fontsize=8, ncol=2, loc="lower left")fig.savefig(OUT / "05-nbr-check.png")plt.close(fig)
# ---------------------------------------------------------------- Step 7: how much of the loss burned? (MODIS MCD64A1)section("MODIS burned area vs GFC loss")burn_rows = []for yr in range(2019, 2026): burned = np.zeros((H, W), dtype=bool) items = catalog.search(collections=["modis-64A1-061"], bbox=AOI, datetime=f"{yr}-01-01/{yr}-12-31").item_collection() for it in items: bd = read_to_grid(it.assets["Burn_Date"].href, (AOI[0], AOI[1], AOI[2], AOI[3]), (H, W), Resampling.nearest, dtype="int16") burned |= bd > 0 m = forest2000 & (lossyear == yr - 2000) months = len({it.properties["start_datetime"][:7] for it in items}) burn_rows.append((yr, months, ha(m), ha(m & burned), ha(burned & land)))burn = pd.DataFrame(burn_rows, columns=["year", "modis_months", "gfc_loss_ha", "loss_in_modis_burn_ha", "modis_burned_ha"])burn["share_burned"] = (burn.loss_in_modis_burn_ha / burn.gfc_loss_ha).where(burn.modis_months == 12)print(burn.to_string(index=False, float_format=lambda v: f"{v:,.2f}" if v < 1 else f"{v:,.0f}"))burn.to_csv(OUT / "loss_vs_modis_burned.csv", index=False)
fig, ax = plt.subplots(figsize=(9, 3.9), constrained_layout=True)ax.bar(burn.year, burn.gfc_loss_ha / 1000, color="#abdda4", label="GFC forest loss")ax.bar(burn.year, burn.loss_in_modis_burn_ha / 1000, color="#d53e4f", label="of which inside a MODIS burn scar that year")for _, r in burn.iterrows(): label = f"{r.share_burned:.0%}" if r.modis_months == 12 else f"{r.modis_months:.0f} months\nof MODIS" ax.text(r.year, r.gfc_loss_ha / 1000 + 0.15, label, ha="center", fontsize=8)ax.set_ylim(0, burn.gfc_loss_ha.max() / 1000 * 1.25)ax.set_ylabel("thousand ha"); ax.legend(frameon=False, loc="upper left", ncol=2)ax.set_title("How much of each year's GFC loss burned in the same year (MODIS MCD64A1, 500 m)", fontsize=10)fig.savefig(OUT / "06-loss-vs-fire.png")plt.close(fig)full = burn[burn.modis_months == 12]print(f"complete years {full.year.min()}-{full.year.max()}: {full.loss_in_modis_burn_ha.sum() / full.gfc_loss_ha.sum():.1%} " f"of GFC loss sat inside a same-year MODIS burn scar")print("\nwrote figures 01-06, output/loss_by_year.csv and output/loss_vs_modis_burned.csv")