How to Resample a Population Grid to Another Resolution in Python

On this page

Problem statement

Population grids rarely arrive at the resolution or on the grid an analysis needs. A 100 m grid is too fine to map a country, a square grid has to become hexagons for a dashboard, and a national census grid has to be put on the European reference grid to be combined with other countries. Each of these is an aggregation of counts, and done right it keeps every person exactly once. Done with a generic resampling method it loses or invents people, as the reprojection fix shows.

This page aggregates GHS-POP from 100 m to 1 km by block summing and checks the result against the 1 km product the JRC publishes, sums Statistics Netherlands' 100 m census squares into H3 hexagons, and moves them onto the EU's 1 km reference grid in EPSG:3035.

Quick answer

When the coarse cells are whole blocks of fine ones on the same grid, reshape and sum; nothing is interpolated:

f = 10                                                                    # 10 x 10 cells of 100 m = 1 km
h, w = (P100.shape[0] // f) * f, (P100.shape[1] // f) * f
P1k = P100[:h, :w].reshape(h // f, f, w // f, f).sum(axis=(1, 3))
print(f"100 m: {P100.shape[1]} x {P100.shape[0]} cells, {P100.sum():,.0f} people")
print(f"1 km:  {P1k.shape[1]} x {P1k.shape[0]} cells, {P1k.sum():,.0f} people")
100 m: 3000 x 3500 cells, 36,568,535 people
1 km:  300 x 350 cells, 36,568,535 people

The window, a rectangle around the Netherlands that also takes in parts of Belgium and Germany, holds 36,568,535 people in 3,000 by 3,500 cells of 100 m, and exactly the same number in 300 by 350 cells of 1 km.

Map of the Randstad in 4,898 H3 hexagons of resolution 8, each coloured by the CBS 2020 residents summed into it on a log scale from 5 to over 10,000: dense clusters of dark hexagons in Amsterdam, Den Haag, Rotterdam and Utrecht, ribbons of lighter hexagons along roads and villages, and the North Sea coast cutting off the top left. Map of the Randstad in 4,898 H3 hexagons of resolution 8, each coloured by the CBS 2020 residents summed into it on a log scale from 5 to over 10,000: dense clusters of dark hexagons in Amsterdam, Den Haag, Rotterdam and Utrecht, ribbons of lighter hexagons along roads and villages, and the North Sea coast cutting off the top left.
CBS squares in H3 hexagons.

Line blocks up, compare with the 1 km product, hexagons

1. Line the blocks up with the coarse grid

Block summing is only exact if the fine grid's origin falls on a coarse cell corner. GHS-POP's 100 m and 1 km Mollweide tiles share their corners, so read a window whose bounds are whole kilometres:

print(f"window: x {BOUNDS[0]:,.0f} to {BOUNDS[2]:,.0f}, y {BOUNDS[1]:,.0f} to {BOUNDS[3]:,.0f} m (whole km: {all(b % 1000 == 0 for b in BOUNDS)})")
print(f"100 m origin {T100.c:,.0f}, {T100.f:,.0f}; 1 km origin {T1K.c:,.0f}, {T1K.f:,.0f}")
window: x 230,000 to 530,000, y 5,930,000 to 6,280,000 m (whole km: True)
100 m origin 230,000, 6,280,000; 1 km origin 230,000, 6,280,000

Both windows start at x 230,000 m and y 6,280,000 m, a whole-kilometre corner, so every 1 km cell is exactly ten by ten 100 m cells.

2. Compare with the published 1 km product

The JRC publishes GHS-POP at 1 km as a separate product. If it is the 100 m grid summed, the two match cell for cell:

d = P1k - G1K
print(f"published 1 km: {G1K.sum():,.0f} people; block sum: {P1k.sum():,.0f}")
print(f"cells: {d.size:,}; identical to 1 person: {(np.abs(d) < 1).mean():.1%}; largest difference {np.abs(d).max():,.0f}")
print(f"correlation {np.corrcoef(P1k.ravel(), G1K.ravel())[0, 1]:.4f}")
published 1 km: 36,568,535 people; block sum: 36,568,535
cells: 105,000; identical to 1 person: 100.0%; largest difference 0
correlation 1.0000

The block sum and the published 1 km product are identical: all 105,000 cells agree to within one person, with a largest difference of zero. GHS-POP's 1 km Mollweide grid is its 100 m grid aggregated, so either can be derived from the other.

GHS-POP 100 m and 1 km Mollweide grids over the Netherlands and the block sum that links them. GHS-POP Mollweide: one grid at two resolutions100 m product3,000 x 3,500 cells, 36,568,535 peopleblock sum, 10 x 10reshape and sum, no interpolation1 km product300 x 350 cells, 36,568,535 peopledifference0 people in all 105,000 cellsWindow around the Netherlands, R2023A epoch 2020 spatialworkflow.io
One grid at two resolutions.

3. Squares into hexagons

H3 cells do not nest with squares, so assign each 100 m square to the hexagon containing its centre and sum. At resolution 8 a hexagon averages 0.74 km², about 74 squares:

lon, lat = Transformer.from_crs(28992, 4326, always_xy=True).transform(SQ.x * 100 + 50, SQ.y * 100 + 50)
SQ["h3"] = [h3.latlng_to_cell(b, a, 8) for a, b in zip(lon, lat)]
H = SQ.groupby("h3").residents.sum()
print(f"{len(SQ):,} squares into {len(H):,} hexagons; total {H.sum():,} = {SQ.residents.sum():,}")
print(f"squares per hexagon: median {SQ.groupby('h3').size().median():.0f}; most residents in one hexagon {H.max():,}")
379,905 squares into 32,222 hexagons; total 16,740,785 = 16,740,785
squares per hexagon: median 4; most residents in one hexagon 15,560

The 379,905 squares fall into 32,222 hexagons, and the total is unchanged at 16,740,785. A median hexagon holds only 4 populated squares, because most hexagons cover sparsely settled land; the fullest holds 15,560 residents.

Onto the EU reference grid

The data used on this page

GHS-POP R2023A epoch 2020 in World Mollweide at 100 m and at 1 km (tiles R3_C19 and R4_C19), and the CBS 100 m grid, final 2020 edition.

import io
import zipfile
from pathlib import Path

import h3
import numpy as np
import pyogrio
import rasterio
import requests
from pyproj import Transformer
from rasterio.merge import merge

Path("data").mkdir(exist_ok=True)
BASE = "https://jeodpp.jrc.ec.europa.eu/ftp/jrc-opendata/GHSL/GHS_POP_GLOBE_R2023A"
def tiles(res):
    out = []
    for t in ("R3_C19", "R4_C19"):
        tif = Path("data", f"GHS_POP_E2020_GLOBE_R2023A_54009_{res}_V1_0_{t}.tif")
        if not tif.exists():
            url = f"{BASE}/GHS_POP_E2020_GLOBE_R2023A_54009_{res}/V1-0/tiles/{tif.stem}.zip"
            zipfile.ZipFile(io.BytesIO(requests.get(url, timeout=900).content)).extract(tif.name, "data")
        out.append(rasterio.open(tif))
    return out
BOUNDS = (230_000, 5_930_000, 530_000, 6_280_000)                         # the Netherlands in Mollweide, whole km
a100, T100 = merge(tiles(100), bounds=BOUNDS)
a1k, T1K = merge(tiles(1000), bounds=BOUNDS)
P100, G1K = np.where(a100[0] > 0, a100[0], 0).astype("float64"), np.where(a1k[0] > 0, a1k[0], 0).astype("float64")
GPKG = Path("data/cbs_vk100_2020_vol.gpkg")
if not GPKG.exists():
    url = "https://www.cbs.nl/-/media/cbs/dossiers/nederland-regionaal/vierkanten/100/2023-cbs_vk100_2020_vol.zip"
    zipfile.ZipFile(io.BytesIO(requests.get(url, timeout=900).content)).extract(GPKG.name, "data")
V = pyogrio.read_dataframe(GPKG, columns=["crs28992res100m", "aantal_inwoners"], read_geometry=False)
SQ = V[V.aantal_inwoners > 0].copy()
SQ[["x", "y"]] = SQ.crs28992res100m.str.extract(r"E(\d+)N(\d+)").astype(int).to_numpy()
SQ["residents"] = SQ.aantal_inwoners
print(f"GHS-POP 100 m window {P100.shape}, 1 km window {G1K.shape}; CBS squares with residents: {len(SQ):,}")
GHS-POP 100 m window (3500, 3000), 1 km window (350, 300); CBS squares with residents: 379,905

The merged windows are 3,500 by 3,000 cells at 100 m and 350 by 300 at 1 km; 379,905 CBS squares have a published resident count.

Example 1: onto the EU 1 km reference grid

The European statistical grid is 1 km squares in EPSG:3035, named by their lower-left corner in kilometres. Reproject each square's centre, floor to the kilometre, and sum:

x, y = Transformer.from_crs(28992, 3035, always_xy=True).transform(SQ.x * 100 + 50, SQ.y * 100 + 50)
SQ["eu1km"] = ["1kmN%dE%d" % (b // 1000, a // 1000) for a, b in zip(x, y)]
E = SQ.groupby("eu1km").residents.sum()
print(f"{len(E):,} EU 1 km cells with residents; total {E.sum():,}")
print(f"densest: {E.idxmax()} with {E.max():,}; squares per EU cell: median {SQ.groupby('eu1km').size().median():.0f}")
23,842 EU 1 km cells with residents; total 16,740,785
densest: 1kmN3261E3973 with 24,590; squares per EU cell: median 6

On the EU 1 km grid the squares make 23,842 populated cells, again 16,740,785 people in all. The fullest, 1kmN3261E3973, holds 24,590 residents; a median EU cell gets 6 CBS squares, because the many sparsely settled cells have few populated squares.

Map of central Amsterdam with CBS 100 m squares in blue on the RD New grid and the EU 1 km reference grid drawn as red lines: the red grid is slightly rotated against the squares because it is in EPSG:3035, so many squares straddle two EU cells and are assigned by their centres. Map of central Amsterdam with CBS 100 m squares in blue on the RD New grid and the EU 1 km reference grid drawn as red lines: the red grid is slightly rotated against the squares because it is in EPSG:3035, so many squares straddle two EU cells and are assigned by their centres.
Two grids that do not nest.

When aggregation keeps the total exactly

Choosing how to aggregate a population grid onto another grid. how to move counts to another gridHow do the two grids relate?coarse cells nestBlock sum100 m to 1 km: exactcells do not nestAssign by centre, sumH3, EU 1 km grid: total kepttarget is finerSplit by areadensity x target areaEvery path keeps each person exactly once spatialworkflow.io
How to move counts.

Aggregation keeps a population total exactly when every fine cell is assigned to one coarse cell and its count is added there. Block summing on nested grids does that by construction. Assigning by centre does it for grids that do not nest, such as squares into hexagons or one national grid onto another, at the cost of moving each fine cell's people to wherever its centre falls; with 100 m squares going into cells of 0.7 to 1 km², that shift is small next to the cell size.

The comparison with the published 1 km product shows what a consistent product family looks like: GHS-POP's 1 km Mollweide grid is its 100 m grid summed, to the person, so the two can be mixed freely. That is worth checking rather than assuming for any pair of products; when one is not the aggregate of the other, derive the coarse one from the fine one yourself, so the numbers at both scales agree.

Nearly nesting grids and suppressed squares

Checks after aggregating a population grid. after aggregatingtotals match16,740,785 before and afterblocks start on a coarse cornerwhole kilometresno partial blocks at the edgecrop or pad firstsame CRS, or assign by centreRD New vs EPSG:3035derive, do not mixone source for every scaleGHS-POP 100 m and 1 km, CBS squares, H3 and the EU grid spatialworkflow.io
After aggregating.
  • Grids that nearly nest: the CBS 100 m squares and the RD New 1 km grid nest; the CBS squares and the EU 1 km grid do not, because they are in different CRSs, so they need centre assignment.
  • Edges of a block sum: a window that is not a whole number of blocks leaves partial blocks; crop to whole kilometres first, as here, or pad with zeros.
  • Hexagon resolution: H3 resolutions step by a factor of about 7 in area; resolution 7 averages 5.2 km² and resolution 9 0.11 km², close to a 100 m square, too fine to aggregate into.
  • Suppressed squares stay missing: aggregating the CBS grid does not recover suppressed or unpublished squares; the totals keep their shortfall.

FAQ

How do I aggregate a population raster to a coarser resolution in Python?

If the coarse grid nests in the fine one, reshape the array into blocks and sum: a.reshape(h // f, f, w // f, f).sum(axis=(1, 3)). Otherwise assign each fine cell to a coarse cell by its centre and sum with groupby.

Is GHSL 1 km the same as GHSL 100 m summed?

For the Mollweide grids, yes: over the Netherlands and its neighbours the R2023A 1 km product equals the 100 m product summed in 10 by 10 blocks, cell for cell.

How do I put population on H3 hexagons?

Convert each cell centre to latitude and longitude, get its H3 cell with h3.latlng_to_cell, and sum by hexagon.

What is the EU 1 km grid?

The European statistical reference grid of 1 km squares in EPSG:3035, named by the lower-left corner, for example 1kmN3200E4000.