Source code: Coordinate reference systems in Python: EPSG codes, pyproj transforms and reprojecting vectors and rasters
Runnable Python scripts, requirements and console output for the tutorial “Coordinate reference systems in Python: EPSG codes, pyproj transforms and…
Last updated
These are the exact scripts used to produce every figure and number in Coordinate reference systems in Python: EPSG codes, pyproj transforms and reprojecting vectors and rasters. Download them, install the requirements into a fresh virtual environment and run them from this folder.
- crs_python.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 crs_python.pycrs_python.py
"""Coordinate reference systems in Python: EPSG codes, pyproj transforms and reprojecting vectors and rasters.
Greater Manchester, UK: ONS borough boundaries, 12,000+ NaPTAN bus stops with published lat/lon ANDBritish National Grid coordinates (a free accuracy test for pyproj), and the Copernicus DEM GLO-30from the Microsoft Planetary Computer, reprojected from WGS 84 to EPSG:27700.
Part 1 of the TechnicalGIS "Geospatial Python essentials" series:https://www.technicalgis.com/tutorials/coordinate-reference-systems-python/"""import osimport sysimport warningsfrom importlib.metadata import versionfrom pathlib import Path
for var in ("PROJ_LIB", "PROJ_DATA", "GDAL_DATA"): # stray installs (e.g. PostGIS) break the wheels' own copies os.environ.pop(var, None)
import numpy as npimport pandas as pdimport geopandas as gpdimport requestsimport pyprojfrom pyproj import CRS, Transformer, Geodfrom pyproj.transformer import TransformerGroupimport rasterioimport rasterio.mergefrom rasterio.warp import calculate_default_transform, reproject, transform_boundsfrom rasterio.enums import Resamplingimport rioxarray # noqa: F401 (registers the .rio accessor)import xarray as xrfrom shapely.geometry import Point, LineStringimport pystac_clientimport planetary_computer as pcimport matplotlibmatplotlib.use("Agg") # render to files onlyimport matplotlib.pyplot as plt
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)
ONS_LAD = ("https://services1.arcgis.com/ESMARspQHYMw9BZ9/arcgis/rest/services/" "Local_Authority_Districts_May_2024_Boundaries_UK_BFC/FeatureServer/0/query")GM_BOROUGHS = ["Bolton", "Bury", "Manchester", "Oldham", "Rochdale", "Salford", "Stockport", "Tameside", "Trafford", "Wigan"]NAPTAN = "https://naptan.api.dft.gov.uk/v1/access-nodes" # ATCO area 180 = Greater ManchesterSTAC_URL = "https://planetarycomputer.microsoft.com/api/stac/v1"TOWN_HALLS = {"Manchester": (-2.24474, 53.47938), "Wigan": (-2.63117, 53.54493)} # lon, lat (WGS 84)
def section(title): print(f"\n=== {title} ===")
section("versions")print(f"python {sys.version.split()[0]} | pyproj {pyproj.__version__} (PROJ {pyproj.proj_version_str}) | " f"geopandas {version('geopandas')} | rasterio {rasterio.__version__} (GDAL {rasterio.__gdal_version__}) | " f"rioxarray {version('rioxarray')}")
# ---------------------------------------------------------------- Step 1: what is in a CRS?section("Step 1: inspect CRSs by EPSG code")for code in [4326, 27700, 3857, 32630]: crs = CRS.from_epsg(code) axes = ", ".join(f"{a.abbrev} ({a.direction}, {a.unit_name})" for a in crs.axis_info) kind = "projected" if crs.is_projected else "geographic" print(f"EPSG:{code:<5} {crs.name:28s} {kind:10s} datum: {crs.datum.name}") print(f" axes: {axes}") print(f" area of use: {crs.area_of_use.name[:70]}")
# ---------------------------------------------------------------- Step 2: transform a point, axis ordersection("Step 2: transform one point (Manchester Town Hall) and the axis-order trap")lon, lat = TOWN_HALLS["Manchester"]t_strict = Transformer.from_crs("EPSG:4326", "EPSG:27700") # authority axis order: lat, lont_xy = Transformer.from_crs("EPSG:4326", "EPSG:27700", always_xy=True) # always lon, lat (x, y)print(f"strict, (lat, lon) in : E {t_strict.transform(lat, lon)[0]:,.1f} N {t_strict.transform(lat, lon)[1]:,.1f}")e_bad, n_bad = t_strict.transform(lon, lat)print(f"strict, (lon, lat) in : E {e_bad:,.1f} N {n_bad:,.1f} <- swapped axes, silently wrong")e, n = t_xy.transform(lon, lat)print(f"always_xy, (lon, lat) : E {e:,.1f} N {n:,.1f}")back = Transformer.from_crs("EPSG:27700", "EPSG:4326", always_xy=True).transform(e, n)print(f"round trip back to WGS 84: lon {back[0]:.6f}, lat {back[1]:.6f}")print(f"operation used: {t_xy.get_last_used_operation().description}")
# ---------------------------------------------------------------- Step 3: which transformation? test on NaPTANsection("Step 3: datum transformations, tested on NaPTAN bus stops")stops_csv = DATA / "naptan_gm.csv"if not stops_csv.exists(): r = requests.get(NAPTAN, params={"dataFormat": "csv", "atcoAreaCodes": "180"}, timeout=300) r.raise_for_status() stops_csv.write_bytes(r.content)naptan = pd.read_csv(stops_csv, low_memory=False)stops = naptan[(naptan.Status == "active") & (naptan.StopType == "BCT") & naptan[["Easting", "Northing", "Longitude", "Latitude"]].notna().all(axis=1)].copy()print(f"NaPTAN Greater Manchester: {len(naptan):,} access nodes, {len(stops):,} active on-street bus stops " f"with both lon/lat and easting/northing")
tg = TransformerGroup("EPSG:4326", "EPSG:27700", always_xy=True)if not tg.best_available: # the OSTN15 grid is not installed yet: fetch it once from cdn.proj.org pyproj.network.set_network_enabled(True) tg.download_grids(verbose=True) tg = TransformerGroup("EPSG:4326", "EPSG:27700", always_xy=True)print(f"{len(tg.transformers)} candidate operations EPSG:4326 -> EPSG:27700, in PROJ's order (first = default):")for tr in tg.transformers: print(f" accuracy {tr.accuracy:5.1f} m {tr.description.split(' + ')[0]}")
# an old-style .prj/proj-string with no datum shift: PROJ can only apply a 'ballpark' transformationbng_no_datum = CRS.from_proj4("+proj=tmerc +lat_0=49 +lon_0=-2 +k=0.9996012717 +x_0=400000 +y_0=-100000 " "+ellps=airy +units=m +no_defs")candidates = { "OSTN15 grid (EPSG default)": tg.transformers[0], "7-parameter Helmert": next(t for t in tg.transformers if "WGS 84 (6)" in t.description), "proj string, no datum shift": Transformer.from_crs("EPSG:4326", bng_no_datum, always_xy=True),}lon_a, lat_a = stops.Longitude.to_numpy(), stops.Latitude.to_numpy()pub = np.c_[stops.Easting.to_numpy(), stops.Northing.to_numpy()]resid = {}for name, tr in candidates.items(): x, y = tr.transform(lon_a, lat_a) d = np.hypot(x - pub[:, 0], y - pub[:, 1]) resid[name] = d print(f"{name:28s} vs NaPTAN easting/northing: median {np.median(d):6.2f} m, 95th pct " f"{np.percentile(d, 95):6.2f} m, max {d.max():7.2f} m")rng = np.random.default_rng(0) # NaPTAN eastings/northings are whole metres: what does rounding alone give?r_only = np.hypot(*rng.uniform(-0.5, 0.5, (2, 100_000)))print(f"rounding to whole metres alone: median {np.median(r_only):.2f} m, max {r_only.max():.2f} m")
# ---------------------------------------------------------------- Step 4: reproject a GeoDataFramesection("Step 4: reproject a GeoDataFrame (ONS boroughs, GeoJSON in WGS 84 -> EPSG:27700)")lad_file = DATA / "gm_lad24_wgs84.geojson"if not lad_file.exists(): where = "LAD24NM IN (" + ",".join(f"'{b}'" for b in GM_BOROUGHS) + ")" r = requests.get(ONS_LAD, params={"where": where, "outFields": "LAD24CD,LAD24NM,Shape__Area", "outSR": 4326, "f": "geojson"}, timeout=180) r.raise_for_status() lad_file.write_bytes(r.content)boroughs = gpd.read_file(lad_file).sort_values("LAD24NM").reset_index(drop=True)print(f"read {len(boroughs)} boroughs, CRS {boroughs.crs.to_string()}, bounds " + ", ".join(f"{v:.3f}" for v in boroughs.total_bounds))boroughs_bng = boroughs.to_crs(27700)print(f"to_crs(27700): CRS {boroughs_bng.crs.to_string()}, bounds " + ", ".join(f"{v:,.0f}" for v in boroughs_bng.total_bounds))wrong = boroughs.set_crs(27700, allow_override=True) # labels the degrees as metres; moves nothingwb = wrong.total_boundswlon, wlat = wrong.to_crs(4326).union_all().centroid.coords[0]print(f"set_crs(27700) by mistake: bounds " + ", ".join(f"{v:.3f}" for v in wb) + f" -> a {wb[2] - wb[0]:.2f} x {wb[3] - wb[1]:.2f} m speck at lon {wlon:.2f}, lat {wlat:.2f} (the BNG false origin)")gm = boroughs_bng.dissolve()[["geometry"]]boroughs_bng.to_file(OUT / "greater_manchester_boroughs_27700.gpkg", layer="boroughs", driver="GPKG")
# ---------------------------------------------------------------- Step 5: areas and distancessection("Step 5: area and distance errors in the wrong CRS")geod = Geod(ellps="WGS84")true_area = np.array([abs(geod.geometry_area_perimeter(g)[0]) for g in boroughs.geometry]) # geodesic, m2areas = {}with warnings.catch_warnings(record=True) as caught: warnings.simplefilter("always") areas["EPSG:4326 (degrees)"] = boroughs.area.to_numpy()print("warning from .area in EPSG:4326:", str(caught[0].message).split(".")[0] if caught else "none")for code in [3857, 32630, 27700]: areas[f"EPSG:{code}"] = boroughs.to_crs(code).area.to_numpy()print(f"Greater Manchester, geodesic area on the WGS 84 ellipsoid: {true_area.sum() / 1e6:,.2f} km2 " f"(ONS Shape__Area: {boroughs.Shape__Area.sum() / 1e6:,.2f} km2)")area_err = {}for k, a in areas.items(): if "degrees" in k: print(f" {k:22s} total = {a.sum():.4f} square degrees (not an area)") continue err = (a.sum() / true_area.sum() - 1) * 100 area_err[k] = err print(f" {k:22s} total = {a.sum() / 1e6:9,.2f} km2 error {err:+7.3f}%")
geod_d = geod.inv(*TOWN_HALLS["Manchester"], *TOWN_HALLS["Wigan"])[2]line = gpd.GeoSeries([LineString([TOWN_HALLS["Manchester"], TOWN_HALLS["Wigan"]])], crs=4326)dist = {"EPSG:4326 (degrees)": line.length.iloc[0]}for code in [3857, 32630, 27700]: dist[f"EPSG:{code}"] = line.to_crs(code).length.iloc[0]print(f"Manchester Town Hall -> Wigan Town Hall, geodesic: {geod_d:,.1f} m")dist_err = {}for k, d in dist.items(): if "degrees" in k: print(f" {k:22s} length = {d:.5f} degrees (not a distance)") continue dist_err[k] = (d / geod_d - 1) * 100 print(f" {k:22s} length = {d:,.1f} m error {dist_err[k]:+7.3f}%")
# a "1 km" buffer drawn in degrees: 0.009 degrees is about 1 km of latitude, but not of longitudep = gpd.GeoSeries([Point(TOWN_HALLS["Manchester"])], crs=4326)with warnings.catch_warnings(): warnings.simplefilter("ignore") buf_deg = p.buffer(0.009).to_crs(27700)buf_m = p.to_crs(27700).buffer(1000)bx0, by0, bx1, by1 = buf_deg.total_boundsprint(f"buffer(0.009) in degrees, measured in BNG: {(bx1 - bx0) / 1000:.2f} km wide x {(by1 - by0) / 1000:.2f} km tall, " f"area {buf_deg.area.iloc[0] / 1e6:.2f} km2 (a true 1 km buffer: {buf_m.area.iloc[0] / 1e6:.2f} km2)")scale = CRS.from_epsg(27700)fac = pyproj.Proj(scale).get_factors(*TOWN_HALLS["Manchester"])print(f"BNG point scale factor at Manchester Town Hall: {fac.meridional_scale:.6f}; Web Mercator: " f"{pyproj.Proj(CRS.from_epsg(3857)).get_factors(*TOWN_HALLS['Manchester']).meridional_scale:.4f}")
# ---------------------------------------------------------------- Step 6: reproject a rastersection("Step 6: reproject a raster (Copernicus DEM GLO-30, EPSG:4326 -> EPSG:27700)")bbox_ll = tuple(boroughs.total_bounds + np.array([-0.02, -0.02, 0.02, 0.02]))dem_ll_tif = DATA / "cop_dem_glo30_gm_4326.tif"if not dem_ll_tif.exists(): catalog = pystac_client.Client.open(STAC_URL, modifier=pc.sign_inplace) items = catalog.search(collections=["cop-dem-glo-30"], bbox=bbox_ll).item_collection() print("tiles:", ", ".join(sorted(it.id for it in items))) arr, transform = rasterio.merge.merge([it.assets["data"].href for it in items], bounds=bbox_ll) with rasterio.open(items[0].assets["data"].href) as src: prof = src.profile prof.update(driver="GTiff", width=arr.shape[2], height=arr.shape[1], transform=transform, compress="deflate", tiled=True, blockxsize=512, blockysize=512) with rasterio.open(dem_ll_tif, "w", **prof) as dst: dst.write(arr)
with rasterio.open(dem_ll_tif) as src: dem_ll = src.read(1) dx, dy = src.res print(f"source: {src.crs.to_string()}, {src.width} x {src.height} px, pixel {dx * 3600:.1f}\" x {dy * 3600:.1f}\" " f"({dx:.6f} x {dy:.6f} deg), nodata {src.nodata}") mid_lat = (src.bounds.bottom + src.bounds.top) / 2 w_m = geod.inv(-2.0, mid_lat, -2.0 + dx, mid_lat)[2] h_m = geod.inv(-2.0, mid_lat, -2.0, mid_lat + dy)[2] print(f"on the ground at {mid_lat:.2f} N one pixel is {w_m:.1f} m wide x {h_m:.1f} m tall")
dst_crs = "EPSG:27700" dst_transform, width, height = calculate_default_transform(src.crs, dst_crs, src.width, src.height, *src.bounds, resolution=30) print(f"target grid: {dst_crs}, {width} x {height} px at 30 m, origin " f"E {dst_transform.c:,.0f} N {dst_transform.f:,.0f}") warped = {} for rs in [Resampling.nearest, Resampling.bilinear, Resampling.cubic]: dst = np.full((height, width), np.nan, "float32") reproject(rasterio.band(src, 1), dst, dst_transform=dst_transform, dst_crs=dst_crs, resampling=rs, dst_nodata=np.nan) warped[rs.name] = dst prof = src.profile | {"crs": dst_crs, "transform": dst_transform, "width": width, "height": height, "nodata": np.nan, "dtype": "float32"}with rasterio.open(OUT / "cop_dem_gm_27700.tif", "w", **prof) as dst: dst.write(warped["bilinear"], 1)
valid = np.isfinite(warped["bilinear"]) & np.isfinite(warped["nearest"])for name in ["nearest", "cubic"]: d = (warped[name] - warped["bilinear"])[valid & np.isfinite(warped[name])] print(f"{name:8s} minus bilinear: mean {d.mean():+.3f} m, RMS {np.sqrt((d ** 2).mean()):.2f} m, " f"99th pct |diff| {np.percentile(np.abs(d), 99):.2f} m")
# the same in one line with rioxarray, then clip to the county boundaryda = rioxarray.open_rasterio(dem_ll_tif, masked=True).squeeze("band", drop=True)da_bng = da.rio.reproject("EPSG:27700", resolution=30, resampling=Resampling.bilinear)print(f"rioxarray: {da_bng.rio.crs}, shape {da_bng.shape}, res {da_bng.rio.resolution()}")same_grid = da_bng.rio.transform() == dst_transform and da_bng.shape == (height, width)diff = np.abs(da_bng.values - warped["bilinear"]) if same_grid else Noneprint(f"same grid as rasterio.warp: {same_grid}" + (f", max |difference| {np.nanmax(diff):.3f} m" if same_grid else ""))dem_gm = da_bng.rio.clip(gm.geometry, gm.crs)iy, ix = np.unravel_index(np.nanargmax(dem_gm.values), dem_gm.shape)hx, hy = float(dem_gm.x[ix]), float(dem_gm.y[iy])hlon, hlat = Transformer.from_crs(27700, 4326, always_xy=True).transform(hx, hy)print(f"clipped to Greater Manchester: {int(np.isfinite(dem_gm.values).sum()):,} cells; elevation " f"{float(dem_gm.min()):.0f}-{float(dem_gm.max()):.0f} m, mean {float(dem_gm.mean()):.0f} m")print(f"highest cell: {float(dem_gm.max()):.0f} m at E {hx:,.0f} N {hy:,.0f} (lon {hlon:.4f}, lat {hlat:.4f})")
# ---------------------------------------------------------------- figuressection("figures")fig, axes = plt.subplots(1, 3, figsize=(13, 4.6), layout="constrained")for ax, (crs_lbl, g) in zip(axes, [("EPSG:4326 (WGS 84, degrees)", boroughs), ("EPSG:3857 (Web Mercator, 'metres')", boroughs.to_crs(3857)), ("EPSG:27700 (British National Grid, metres)", boroughs_bng)]): g.plot(ax=ax, column="LAD24NM", cmap="Spectral", edgecolor="white", linewidth=0.6) if "4326" in crs_lbl: ax.set_aspect("equal") # plotted naively, 1 degree east = 1 degree north ax.set_title(crs_lbl) ax.tick_params(labelsize=7) ax.ticklabel_format(useOffset=False, style="plain") for lbl in ax.get_xticklabels(): lbl.set_rotation(30)for _, row in boroughs_bng.iterrows(): c = row.geometry.representative_point() axes[2].annotate(row.LAD24NM, (c.x, c.y), ha="center", fontsize=6.5, color="#111111")fig.suptitle("The same ten boroughs in three CRSs (axes drawn with equal units)")fig.savefig(OUT / "01-greater-manchester-three-crs.png")plt.close(fig)
fig, ax = plt.subplots(figsize=(8, 3.8), layout="constrained")bins = np.logspace(-3, 2.5, 80)colours = {"OSTN15 grid (EPSG default)": "#3288bd", "7-parameter Helmert": "#f46d43", "proj string, no datum shift": "#9e0142"}for name, d in resid.items(): ax.hist(np.clip(d, 1e-3, None), bins=bins, color=colours[name], alpha=0.8, label=f"{name} (median {np.median(d):.2f} m)")ax.set_xscale("log")ax.set_xlabel("distance between pyproj result and NaPTAN's published easting/northing (m, log scale)")ax.set_ylabel("bus stops")ax.set_title(f"WGS 84 -> British National Grid for {len(stops):,} Greater Manchester bus stops")ax.legend(frameon=False)fig.savefig(OUT / "02-transformation-accuracy.png")plt.close(fig)
fig, axes = plt.subplots(1, 2, figsize=(11, 4.2), layout="constrained", gridspec_kw={"width_ratios": [1.5, 1]})ax = axes[0]labels = list(area_err)yy = np.arange(len(labels))for off, data, lbl, col in [(-0.18, area_err, "area of Greater Manchester", "#3288bd"), (0.18, dist_err, "Manchester-Wigan distance", "#f46d43")]: vals = np.array([data[k] for k in labels]) bars = ax.barh(yy + off, np.abs(vals), height=0.34, color=col, label=lbl) for b, v in zip(bars, vals): ax.annotate(f"{v:+.3f}%", (abs(v), b.get_y() + b.get_height() / 2), xytext=(4, 0), textcoords="offset points", va="center", fontsize=8)ax.set_yticks(yy, labels)ax.set_xscale("log")ax.set_xlim(0.01, 3000)ax.invert_yaxis()ax.set_xlabel("|error| against the geodesic value on the WGS 84 ellipsoid (%, log scale)")ax.legend(frameon=False, loc="lower right")ax.set_title("Measuring in the wrong CRS")ax = axes[1]boroughs_bng[boroughs_bng.LAD24NM == "Manchester"].boundary.plot(ax=ax, color="#999999", lw=0.6)buf_deg.plot(ax=ax, facecolor="#fdae61", edgecolor="#d53e4f", alpha=0.7, label="buffer(0.009) in degrees")buf_m.plot(ax=ax, facecolor="none", edgecolor="#3288bd", lw=2)mx, my = p.to_crs(27700).iloc[0].coords[0]ax.plot(mx, my, "k+", ms=8)ax.set_xlim(mx - 2200, mx + 2200); ax.set_ylim(my - 1600, my + 1600)ax.set_title("'1 km' buffer in degrees (orange)\nvs a true 1 km buffer in BNG (blue)")ax.ticklabel_format(useOffset=False, style="plain")ax.tick_params(labelsize=7)fig.savefig(OUT / "03-area-distance-errors.png")plt.close(fig)
with rasterio.open(dem_ll_tif) as src: ext_ll = [src.bounds.left, src.bounds.right, src.bounds.bottom, src.bounds.top]ext_bng = [dst_transform.c, dst_transform.c + width * 30, dst_transform.f - height * 30, dst_transform.f]fig, axes = plt.subplots(1, 2, figsize=(12, 4.8), layout="constrained")dem_plot = np.where(dem_ll == -32767, np.nan, dem_ll)im = axes[0].imshow(dem_plot, extent=ext_ll, cmap="Spectral_r", vmin=0, vmax=550)boroughs.boundary.plot(ax=axes[0], color="#222222", lw=0.5)axes[0].set_title(f"Copernicus DEM as delivered: EPSG:4326, {dx * 3600:.1f}\" x {dy * 3600:.1f}\" pixels")axes[0].set_aspect("equal")axes[1].imshow(warped["bilinear"], extent=ext_bng, cmap="Spectral_r", vmin=0, vmax=550)boroughs_bng.boundary.plot(ax=axes[1], color="#222222", lw=0.5)axes[1].set_title("Reprojected to EPSG:27700 at 30 m (bilinear)")axes[1].ticklabel_format(useOffset=False, style="plain")axes[1].plot(hx, hy, marker="^", color="black", ms=7)axes[1].annotate(f"{float(dem_gm.max()):.0f} m", (hx, hy), xytext=(6, -10), textcoords="offset points", fontsize=8)fig.colorbar(im, ax=axes, shrink=0.8, label="elevation (m)")fig.savefig(OUT / "04-dem-reprojected.png")plt.close(fig)
# zoom on 600 m near the highest cell: the source pixel grid drawn in BNG, then the two resampled 30 m gridsx0, y1 = (hx // 30) * 30 - 450, (hy // 30) * 30 + 300x1, y0 = x0 + 600, y1 - 600fig, axes = plt.subplots(1, 3, figsize=(13, 4.4), layout="constrained")with rasterio.open(dem_ll_tif) as src: lon0, lat0, lon1, lat1 = transform_bounds("EPSG:27700", src.crs, x0 - 60, y0 - 60, x1 + 60, y1 + 60) win = rasterio.windows.from_bounds(lon0, lat0, lon1, lat1, src.transform).round_offsets().round_lengths() z = src.read(1, window=win) wt = src.window_transform(win)cols_e = wt.c + wt.a * np.arange(z.shape[1] + 1)rows_e = wt.f + wt.e * np.arange(z.shape[0] + 1)LON, LAT = np.meshgrid(cols_e, rows_e)XE, YN = t_xy.transform(LON, LAT)vmin, vmax = np.nanpercentile(warped["bilinear"][int((dst_transform.f - y1) / 30):int((dst_transform.f - y0) / 30), int((x0 - dst_transform.c) / 30):int((x1 - dst_transform.c) / 30)], [1, 99])axes[0].pcolormesh(XE, YN, z, cmap="Spectral_r", vmin=vmin, vmax=vmax, edgecolors="#ffffff", linewidth=0.3)axes[0].set_title(f"Source pixels ({dx * 3600:.1f}\" x {dy * 3600:.1f}\" = {w_m:.1f} x {h_m:.1f} m)\ndrawn in BNG")for ax, name in zip(axes[1:], ["nearest", "bilinear"]): im = ax.imshow(warped[name], extent=ext_bng, cmap="Spectral_r", vmin=vmin, vmax=vmax) rr = np.arange(x0, x1 + 1, 30) ax.vlines(rr, y0, y1, color="#ffffff", lw=0.3); ax.hlines(np.arange(y0, y1 + 1, 30), x0, x1, color="#ffffff", lw=0.3) ax.set_title(f"{name} resampling to 30 x 30 m BNG pixels")for ax in axes: ax.set_xlim(x0, x1); ax.set_ylim(y0, y1); ax.set_aspect("equal") ax.ticklabel_format(useOffset=False, style="plain"); ax.tick_params(labelsize=7)fig.colorbar(im, ax=axes, shrink=0.8, label="elevation (m)")fig.suptitle("600 m of Pennine moorland near the highest cell: nearest copies whole source pixels, bilinear interpolates")fig.savefig(OUT / "05-resampling.png")plt.close(fig)print("saved:", ", ".join(sorted(p.name for p in OUT.glob("*.png"))))