How to Measure Land Cover Around Points in Python

On this page

Problem statement

How green is the neighbourhood around each school, clinic, sensor or bird sighting? The usual answer is land cover composition in a buffer: the share of each class within 500 m or 1 km of the point. For one point it is a clip and a count. For hundreds of points the buffers overlap, and a zonal statistics run that rasterises all the buffers at once gives each overlapping pixel to only one of them, so crowded points lose part of their neighbourhood. Points near the edge of the downloaded raster lose part of theirs to the edge.

This page measures land cover within 500 m and 1 km of the 195 public schools in Williamson County, Texas, from NLCD 2021, one exact circle per school.

Quick answer

For each point, cut a window around it, keep the pixels whose centres fall within the radius, and count classes:

def composition(x, y, radius):
    col, row = ~TRANSFORM * (x, y)
    r = int(np.ceil(radius / 30)) + 1
    r0, c0 = int(row) - r, int(col) - r
    win = LC[r0:r0 + 2 * r + 1, c0:c0 + 2 * r + 1]
    yy, xx = np.mgrid[r0:r0 + win.shape[0], c0:c0 + win.shape[1]]
    px, py = TRANSFORM * (xx + 0.5, yy + 0.5)
    inside = (px - x) ** 2 + (py - y) ** 2 <= radius ** 2
    v = pd.Series(win[inside]).map(GROUP).value_counts(normalize=True) * 100
    return v.reindex(["Developed", "Green", "Agriculture", "Water"], fill_value=0)

comp = pd.DataFrame([composition(p.x, p.y, 500) for p in SCHOOLS.geometry], index=SCHOOLS.NAME)
print(f"{len(comp)} schools, 500 m radius: median {comp.Developed.median():.0f}% developed, {comp.Green.median():.0f}% green")
print(comp.Green.describe()[["min", "25%", "50%", "75%", "max"]].round(1).to_string())
195 schools, 500 m radius: median 76% developed, 18% green
min     0.0
25%     9.0
50%    18.2
75%    43.7
max    83.2
Map of Williamson County, Texas, over faded NLCD 2021 land cover, with 195 public schools as dots inside 500 metre circles, coloured from pale yellow to dark green by the share of forest, shrub and grassland within 500 metres: most schools in the developed south-west are pale, under 20 percent, while schools in the wooded north-west and rural areas are dark green, up to 83 percent. Map of Williamson County, Texas, over faded NLCD 2021 land cover, with 195 public schools as dots inside 500 metre circles, coloured from pale yellow to dark green by the share of forest, shrub and grassland within 500 metres: most schools in the developed south-west are pale, under 20 percent, while schools in the wooded north-west and rural areas are dark green, up to 83 percent.
Green land within 500 m of each school, one exact circle per school.

Pad the raster, keep buffers apart, compare radii

1. Pad the raster by the radius

A school near the county line needs land cover beyond it. Request the raster for the points' bounding box plus the largest radius, and check that every window fits:

x0, y0, x1, y1 = SCHOOLS.total_bounds
b = array_bounds(*LC.shape, TRANSFORM)
print(f"points span {x0:,.0f}–{x1:,.0f} m east; raster {b[0]:,.0f}–{b[2]:,.0f}; margin {min(x0 - b[0], b[2] - x1, y0 - b[1], b[3] - y1):,.0f} m")
points span -186,719–-123,928 m east; raster -188,205–-122,415; margin 1,486 m

2. Do not rasterise overlapping buffers together

Rasterising all buffers into one zone raster gives every pixel to the last buffer drawn over it. Count how much each school loses:

buffers = SCHOOLS.buffer(1000)
zones = features.rasterize([(g, k + 1) for k, g in enumerate(buffers)], out_shape=LC.shape, transform=TRANSFORM)
got = np.bincount(zones.ravel(), minlength=len(SCHOOLS) + 1)[1:]
full = np.pi * 1000 ** 2 / 900
print(f"1 km buffers: {(got < 0.9 * full).sum()} of {len(SCHOOLS)} schools keep under 90% of their pixels; worst keeps {got.min() / full:.0%}")
print(f"pairs of schools closer than 2 km: {int((SCHOOLS.geometry.apply(lambda p: (SCHOOLS.distance(p) < 2000).sum() - 1).sum()) / 2)}")
same = SCHOOLS.geometry.to_wkb().duplicated(keep=False)
print(f"{same.sum()} schools share their exact location with another, at {SCHOOLS[same].geometry.to_wkb().nunique()} sites")
1 km buffers: 123 of 195 schools keep under 90% of their pixels; worst keeps 0%
pairs of schools closer than 2 km: 788
47 schools share their exact location with another, at 7 sites

Several programmes, such as alternative and juvenile justice schools, are registered at the same address, so their buffers coincide exactly and all but one lose everything. Shared locations also weight a site several times in any average over schools; decide whether to count schools or sites.

Scatter plot of the green share within 500 metres against within 1 kilometre for 195 schools, coloured by NCES locale: city and suburban schools cluster under 30 percent, rural schools spread up to 80 percent; rank correlation 0.90, and 61 percent of schools lie above the diagonal, greener at 1 kilometre. Scatter plot of the green share within 500 metres against within 1 kilometre for 195 schools, coloured by NCES locale: city and suburban schools cluster under 30 percent, rural schools spread up to 80 percent; rank correlation 0.90, and 61 percent of schools lie above the diagonal, greener at 1 kilometre.
Radius changes the numbers more than the ranking.

3. Compare radii

The answer depends on the radius; report it with every number:

comp1k = pd.DataFrame([composition(p.x, p.y, 1000) for p in SCHOOLS.geometry], index=SCHOOLS.NAME)
print(f"median green share: {comp.Green.median():.1f}% within 500 m, {comp1k.Green.median():.1f}% within 1 km")
print(f"rank correlation of green share between radii: {comp.Green.corr(comp1k.Green, method='spearman'):.2f}")
median green share: 18.2% within 500 m, 28.1% within 1 km
rank correlation of green share between radii: 0.90

Green space around schools by locale

The data used on this page

Public school locations for 2022–23 from the National Center for Education Statistics EDGE program (public ArcGIS service), filtered to Williamson County, Texas, and NLCD 2021 (30 m) from MRLC's WCS for the schools' bounding box padded by 1.5 km, in CONUS Albers (EPSG:5070).

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
from rasterio.transform import array_bounds

Path("data").mkdir(exist_ok=True)
NCES = "https://nces.ed.gov/opengis/rest/services/K12_School_Locations/EDGE_GEOCODE_PUBLICSCH_2223/MapServer/0/query"
SCHOOLS = gpd.read_file(requests.get(NCES, params={"where": "STATE = 'TX' AND NMCNTY = 'Williamson County'",
                                                   "outFields": "NAME,LOCALE,CITY", "outSR": 4326, "f": "geojson"}, timeout=120).text).to_crs(5070)
x0, y0, x1, y1 = SCHOOLS.total_bounds + np.array([-1500, -1500, 1500, 1500])
path = Path("data/nlcd_2021_schools.tif")
if not path.exists():
    WCS = "https://www.mrlc.gov/geoserver/mrlc_download/wcs"
    for attempt in range(4):
        r = requests.get(WCS, params={"service": "WCS", "version": "2.0.1", "request": "GetCoverage", "coverageId": "mrlc_download__NLCD_2021_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, TRANSFORM = src.read(1), src.transform
GROUP = {11: "Water", 90: "Water", 95: "Water", 21: "Developed", 22: "Developed", 23: "Developed", 24: "Developed",
         41: "Green", 42: "Green", 43: "Green", 52: "Green", 71: "Green", 31: "Barren", 81: "Agriculture", 82: "Agriculture"}
print(f"{len(SCHOOLS)} schools; NLCD window {LC.shape[1]} x {LC.shape[0]} pixels")
195 schools; NLCD window 2193 x 1580 pixels

Example 1: green space by NCES locale

locale = SCHOOLS.LOCALE.astype(str).str[0].map({"1": "City", "2": "Suburb", "3": "Town", "4": "Rural"})
print(comp.Green.groupby(locale.values).agg(["count", "median"]).round(1).to_string())
        count  median
City       55    10.2
Rural      88    46.3
Suburb     47    12.0
Town        5     4.6

The median rural school has 46% green land within 500 m; city and suburban schools have 10–12%.

Circular zones that overlap

A buffer composition is a categorical zonal summary with circular zones. When zones do not overlap, rasterising them all at once and cross-tabulating is efficient. Points that serve neighbourhoods, such as schools, cluster, and their buffers overlap heavily, so each one must be summarised on its own: either by masking a window per point, as here, or by running the zonal step once per buffer. A circle traced on 30 m pixels holds about 873 pixels at 500 m, enough that pixel-centre membership is a fine approximation.

Two ways to summarise land cover in buffers around many points. buffers around 195 schoolsrasterise all buffers at once✕each pixel goes to one buffer✕123 of 195 schools keep under 90%✕shared sites keep nothing✕fast, but wrong when buffers overlapone circle per point✓window around each point✓pixel centres within the radius✓every school gets its whole circle✓about 873 pixels at 500 mNLCD 2021, 1 km buffers; 788 pairs of schools are closer than 2 km spatialworkflow.io
Why overlapping buffers need summarising one at a time.

The radius is part of the definition. A 500 m buffer describes what is within a short walk, 1 km the wider neighbourhood. Shares at different radii are correlated but not interchangeable, and published studies of greenness and health differ partly because they chose different radii and different definitions of "green". Here green means forest, shrub and grassland; parks mapped as developed open space (class 21) are not counted, a choice worth stating.

Projected buffers, padding and point accuracy

Land cover around points: problems and fixes. buffer composition problemsSymptomFixcrowded points lose pixelssummarise each buffer on its ownedge points get half circlespad the raster by the radiusresults change with radiusreport it; test 500 m and 1 kmparks not counted as greenNLCD maps many as developed (21)Williamson County schools, NLCD 2021 spatialworkflow.io
Buffer composition problems and fixes.
  • Projected CRS: compute buffers and distances in metres; EPSG:5070 here.
  • Edge padding: pad the raster by at least the largest radius, or points near the edge get truncated circles.
  • Class definitions: decide whether developed open space counts as green; it holds most urban parks and lawns in NLCD.
  • Point accuracy: geocoded school locations are usually the building or the parcel; a 30 m error barely changes a 500 m composition.
  • Many points: for thousands of points, process windows in a loop as here, or use exactextract with one feature per buffer.

FAQ

How do I calculate land cover in a buffer around points in Python?

For each point, read a window of the raster around it, keep the pixels whose centres lie within the radius, and count the classes.

Why do my buffer statistics change when buffers overlap?

Rasterising overlapping buffers into one zone layer gives each pixel to one buffer only, so overlapping buffers lose pixels. Summarise each buffer separately.

What buffer radius should I use?

It depends on the question: about 300–500 m for what is within a short walk, 1 km or more for neighbourhood context. Report the radius and test whether results change with it.

Does NLCD count parks as green space?

Many urban parks are mapped as developed open space (class 21), not as forest or grassland.