How to Summarise Land Cover by Polygon in Python

On this page

Problem statement

"What does each city in the county look like?" asks for land cover composition by polygon: the share of each class inside each boundary. It looks like a job for a zonal statistics function, and most of them default to mean, min, max and sum. On a categorical raster those numbers are nonsense: the mean of class codes 21 and 82 is 51.5, which is not a land cover at all. What a categorical raster needs is a count of pixels per class per zone, turned into areas and shares.

This page summarises NLCD 2021 by city for the incorporated places of Williamson County, Texas, and compares their composition with 2001.

Quick answer

Rasterise the polygons onto the land cover grid, then cross-tabulate zone against class:

zones = features.rasterize([(g, k + 1) for k, g in enumerate(PLACES.geometry)], out_shape=LC[2021].shape, transform=TRANSFORM)
inside = zones > 0
counts = pd.crosstab(pd.Series(zones[inside], name="zone"), pd.Series(LC[2021][inside], name="class"))
counts.index = PLACES.NAME.iloc[counts.index - 1]
shares = counts.T.groupby(GROUP).sum().T.pipe(lambda t: t.div(t.sum(axis=1), axis=0) * 100)
print(shares.round(0).astype(int).sort_values("Developed", ascending=False).to_string())
class         Agriculture  Barren  Developed  Forest  Grassland  Shrub  Water  Wetland
NAME                                                                                  
Granger                10       0         88       0          2      0      0        0
Thrall                 20       0         74       0          3      2      0        0
Round Rock              2       0         73       5         16      0      1        2
Cedar Park              0       1         73      16          6      3      1        0
Bartlett               15       0         71       0         13      0      0        0
Florence                4       0         56       5         27      6      0        1
Hutto                  31       1         52       0         13      2      0        2
Jarrell                 2       1         52       1         44      0      0        0
Liberty Hill            0       2         49      11         29     10      0        0
Leander                 0       1         49      27         16      7      0        1
Georgetown              2       1         44      18         27      2      3        2
Taylor                 47       0         34       1         11      3      0        4
Coupland               66       0         23       0          4      5      1        1
Weir                   15       1         21       2         57      0      1        4
NLCD 2021 land cover around Williamson County, Texas, with the outlines and names of its 14 cities: the large developed cities of Round Rock, Cedar Park, Leander and Georgetown in the south-west among red developed land, Hutto and Taylor in the farmland to the east, and small towns such as Granger, Bartlett, Thrall, Coupland, Jarrell, Florence, Weir and Liberty Hill. NLCD 2021 land cover around Williamson County, Texas, with the outlines and names of its 14 cities: the large developed cities of Round Rock, Cedar Park, Leander and Georgetown in the south-west among red developed land, Hutto and Taylor in the farmland to the east, and small towns such as Granger, Bartlett, Thrall, Coupland, Jarrell, Florence, Weir and Liberty Hill.
The zones: 14 incorporated places over NLCD 2021.

Why the mean is wrong, areas and shares, same zones over time

1. See why the mean is wrong

A zonal mean of the class codes runs without complaint and produces numbers between codes:

codes_mean = pd.Series({PLACES.NAME.iloc[k - 1]: LC[2021][zones == k].mean() for k in range(1, len(PLACES) + 1)})
print(codes_mean.round(1).sort_values().head(4).to_string())
print("those 'classes' do not exist: NLCD codes are", sorted(NLCD_CLASSES))
Granger       29.0
Cedar Park    29.8
Round Rock    34.3
Thrall        36.4
those 'classes' do not exist: NLCD codes are [11, 12, 21, 22, 23, 24, 31, 41, 42, 43, 52, 71, 81, 82, 90, 95]

A city with a mean code of about 30 is not close to class 31, barren land; it is a mix of developed (21–24) and cropland or grass (71–82). Only counts per class mean anything.

2. Report areas as well as shares

Shares hide size. Convert counts to square kilometres:

km2 = counts.T.groupby(GROUP).sum().T * PIXEL_KM2
print(km2[["Developed", "Grassland", "Agriculture", "Forest"]].round(1).sort_values("Developed", ascending=False).head(6).to_string())
class       Developed  Grassland  Agriculture  Forest
NAME                                                 
Round Rock       72.9       15.7          2.3     4.8
Georgetown       69.3       42.6          3.8    28.5
Leander          49.1       15.9          0.2    27.0
Cedar Park       48.6        4.3          0.0    10.7
Taylor           19.9        6.2         27.7     0.7
Hutto            18.7        4.6         11.2     0.1

3. Compare years with the same zones

Reuse the same zone raster for 2001 so every difference is a change in land cover, not in the polygons:

dev = {y: pd.Series(np.bincount(zones[inside], weights=np.isin(LC[y][inside], [21, 22, 23, 24]), minlength=len(PLACES) + 1)[1:] * PIXEL_KM2,
                    index=PLACES.NAME) for y in (2001, 2021)}
change = pd.DataFrame({"2001 km²": dev[2001], "2021 km²": dev[2021]})
change["growth"] = change["2021 km²"] / change["2001 km²"] - 1
print(change.sort_values("2021 km²", ascending=False).head(6).round(2).to_string(formatters={"growth": "{:+.0%}".format}))
            2001 km²  2021 km² growth
NAME                                 
Round Rock     50.89     72.90   +43%
Georgetown     39.37     69.31   +76%
Leander        18.08     49.12  +172%
Cedar Park     26.52     48.57   +83%
Taylor         17.05     19.85   +16%
Hutto           4.22     18.75  +344%

Hutto's developed area more than quadrupled and Leander's nearly tripled, while Taylor, among the farmland of the east, grew by 16%.

Stacked bar chart of land cover shares for 14 cities: Granger 88 percent developed, Thrall, Round Rock, Cedar Park and Bartlett over 70 percent, Leander and Georgetown under half with large shares of forest and grassland, Taylor a third developed and nearly half agriculture, Coupland two thirds agriculture, and Weir mostly grassland. Stacked bar chart of land cover shares for 14 cities: Granger 88 percent developed, Thrall, Round Rock, Cedar Park and Bartlett over 70 percent, Leander and Georgetown under half with large shares of forest and grassland, Taylor a third developed and nearly half agriculture, Coupland two thirds agriculture, and Weir mostly grassland.
Composition of each city from counts per class.

The most and least developed city

The data used on this page

NLCD 2001 and 2021 (2021 release, 30 m) for Williamson County, Texas, from MRLC's WCS, and the 2023 Census cartographic boundary file of Texas places, keeping incorporated places whose interior point lies in the county; each is summarised whole, including any part in a neighbouring county. Place boundaries are 2023 boundaries for both years.

import time
from pathlib import Path

import geopandas as gpd
import numpy as np
import pandas as pd
import rasterio
import requests
from rasterio import features

Path("data").mkdir(exist_ok=True)
WCS = "https://www.mrlc.gov/geoserver/mrlc_download/wcs"
for name, url in {"cb_2023_us_county_20m.zip": "https://www2.census.gov/geo/tiger/GENZ2023/shp/cb_2023_us_county_20m.zip",
                  "cb_2023_48_place_500k.zip": "https://www2.census.gov/geo/tiger/GENZ2023/shp/cb_2023_48_place_500k.zip"}.items():
    if not Path("data", name).exists():
        Path("data", name).write_bytes(requests.get(url, timeout=300).content)
COUNTY = gpd.read_file("data/cb_2023_us_county_20m.zip").query("GEOID == '48491'").to_crs(5070)
places = gpd.read_file("data/cb_2023_48_place_500k.zip").to_crs(5070)
PLACES = places[(places.LSAD == "25") & places.representative_point().within(COUNTY.union_all())].sort_values("NAME").reset_index(drop=True)
x0, y0, x1, y1 = PLACES.total_bounds
LC = {}
for year in (2001, 2021):
    path = Path(f"data/nlcd_places_{year}.tif")
    if not path.exists():
        for attempt in range(4):
            r = requests.get(WCS, params={"service": "WCS", "version": "2.0.1", "request": "GetCoverage",
                                          "coverageId": f"mrlc_download__NLCD_{year}_Land_Cover_L48",
                                          "subset": [f"X({x0},{x1})", f"Y({y0},{y1})"], "format": "image/geotiff"}, timeout=300)
            if r.status_code < 500:
                break
            time.sleep(15 * (attempt + 1))
        r.raise_for_status()
        path.write_bytes(r.content)
    with rasterio.open(path) as src:
        LC[year], TRANSFORM = src.read(1), src.transform
PIXEL_KM2 = 30 * 30 / 1e6
NLCD_CLASSES = [11, 12, 21, 22, 23, 24, 31, 41, 42, 43, 52, 71, 81, 82, 90, 95]
GROUP = {11: "Water", 21: "Developed", 22: "Developed", 23: "Developed", 24: "Developed", 31: "Barren", 41: "Forest", 42: "Forest",
         43: "Forest", 52: "Shrub", 71: "Grassland", 81: "Agriculture", 82: "Agriculture", 90: "Wetland", 95: "Wetland"}
print(f"{len(PLACES)} cities: {', '.join(PLACES.NAME)}")
14 cities: Bartlett, Cedar Park, Coupland, Florence, Georgetown, Granger, Hutto, Jarrell, Leander, Liberty Hill, Round Rock, Taylor, Thrall, Weir

Example 1: the most and least developed city

dev_share = shares["Developed"].sort_values()
print(f"least developed: {dev_share.index[0]} {dev_share.iloc[0]:.0f}%; most developed: {dev_share.index[-1]} {dev_share.iloc[-1]:.0f}%")
agri = shares["Agriculture"].idxmax()
print(f"most agricultural: {agri}, {shares.loc[agri, 'Agriculture']:.0f}% agriculture and {shares.loc[agri, 'Developed']:.0f}% developed")
least developed: Weir 21%; most developed: Granger 88%
most agricultural: Coupland, 66% agriculture and 23% developed

Zonal statistics on classes is a cross-tabulation

Zonal statistics on a categorical raster is a cross-tabulation: each pixel contributes its zone and its class, and the result is a count for every pair. Shares follow by dividing each zone's row by its total, areas by multiplying by the pixel area. The same table gives a majority class (the largest count) or a diversity index if needed. What it never gives is an average class, because class codes are labels, not quantities.

Zonal statistics on a land cover raster: a mean of class codes against counts per class. what a zonal summary of land cover should returnmean of class codes✕Granger: 29.0, Cedar Park: 29.8✕values that are no class at all✕mixes developed (21–24) with crops (82)✕changes if the codes are renumberedcounts per class✓pd.crosstab(zone, class)✓Granger: 88% developed✓shares, areas and majority class✓same zone raster for every year14 cities of Williamson County, Texas, NLCD 2021 spatialworkflow.io
What a zonal summary of land cover should return.

Rasterising polygons assigns each pixel wholly to the zone containing its centre, which is exact enough for cities of tens of square kilometres at 30 m. For small or thin polygons, tools that weight partial pixels by their covered fraction (exactextract, or rasterstats with all_touched handled carefully) do better. Whatever the method, the same zone raster must be reused across years, or boundary differences show up as land cover change.

Overlapping polygons, nodata and equal-area CRSs

Land cover by polygon: problems and fixes. land cover by polygon problemsSymptomFixa mean class of 29.8count pixels per class insteadgrowth that is annexationsame boundaries for both yearsthin polygons miss pixelsarea-weighted tool such asexactextractoverlapping zones lose pixelsone rasterise pass per polygonNLCD 2001 and 2021, Census places 2023 spatialworkflow.io
Land cover by polygon problems and fixes.
  • rasterstats: use categorical=True for counts per class instead of the default statistics.
  • Overlapping polygons: rasterising gives each pixel to one polygon (the last drawn); overlapping zones need one pass per polygon.
  • Nodata: NLCD land cover uses 0 outside the mapped area; drop it before computing shares.
  • Boundaries change: cities annex land; with 2023 boundaries, part of a city's "growth" is land that was outside it in 2001.
  • Equal-area CRS: count × 900 m² is valid because NLCD is in CONUS Albers.

FAQ

How do I calculate land cover percentages by polygon in Python?

Rasterise the polygons to the land cover grid, cross-tabulate zone against class with pd.crosstab, and divide each row by its total.

Why is the zonal mean of a land cover raster meaningless?

Class codes are labels. Averaging them gives values between codes that correspond to no class.

What is the majority class of a zone?

The class with the largest pixel count in that zone, the idxmax of its row in the cross-tabulation.

Can I use rasterstats for land cover?

Yes, with categorical=True, which returns the count of each class in each polygon.