Morphological Tessellation Explained

On this page

Problem statement

Density, coverage and many other form measures are ratios of a building to "its land". In most countries the land a building stands on is recorded as a cadastral parcel — but parcels are legal objects. One parcel may hold a whole housing block owned by one company; one building may straddle several parcels; streets and parks are parcels of their own, sometimes enormous. And in much of the world there is no open parcel data at all.

Morphological tessellation offers a geometric substitute: every square metre of land is given to the building whose footprint is nearest. The result is consistent everywhere, available wherever footprints are, and different from parcels in ways worth understanding before using it as their proxy.

Quick answer

A tessellation cell is the set of points closer to one building's footprint than to any other, cut to a limit:

  • it partitions the land: no gaps, no overlaps, one cell per building;
  • it follows footprint boundaries, not centroids, so big buildings get proportionally more land;
  • it ignores ownership and streets unless bounded by street enclosures.
import requests, numpy as np, geopandas as gpd, momepy
from shapely.geometry import box

cx, cy = 81000, 455000
aoi = box(cx - 500, cy - 500, cx + 500, cy + 500)
b = gpd.read_parquet("denhaag_bag.parquet")
b = b[b.representative_point().within(aoi) & b.is_valid & (b.area > 10)].reset_index(drop=True)
cells = momepy.morphological_tessellation(b, clip=momepy.buffered_limit(b, buffer=100))

feats, start = [], 0
while True:
    fc = requests.get("https://service.pdok.nl/kadaster/kadastralekaart/wfs/v5_0", params={
        "service": "WFS", "version": "2.0.0", "request": "GetFeature",
        "typeNames": "kadastralekaart:Perceel", "count": 1000, "startIndex": start,
        "outputFormat": "application/json", "bbox": f"{cx-500},{cy-500},{cx+500},{cy+500},EPSG:28992"},
        timeout=300).json()
    feats += fc["features"]
    if len(fc["features"]) < 1000:
        break
    start += 1000
parcels = gpd.GeoDataFrame.from_features(feats, crs=28992)
parcels = parcels[parcels.representative_point().within(aoi)]

print(f"1 km² of central Den Haag: {len(b):,} buildings, {len(cells):,} cells, "
      f"{len(parcels):,} cadastral parcels")
print(f"median cell {cells.area.median():.0f} m², median parcel {parcels.area.median():.0f} m²")
1 km² of central Den Haag: 1,990 buildings, 1,990 cells, 2,367 cadastral parcels
median cell 223 m², median parcel 125 m²

Cells and parcels are the same order of magnitude — the median cell is not quite twice the median parcel — and not the same objects: one follows buildings, the other follows ownership.

What a cell represents, a limit, street bounds

1. Know what a cell represents

A cell is "the land nearest this building". In a terrace that approximates the house plot plus half the street in front; around a detached house, its garden and half the space to the neighbours.

2. Decide on a limit

Without one, edge cells extend indefinitely. A buffered limit around the buildings (commonly 100 m) keeps cells from swallowing fields and water.

3. Decide whether streets bound the cells

A free tessellation shares each street between the buildings on both sides. An enclosed tessellation stops at the street — closer to parcels and to what a plot means.

4. Compare with parcels where you can

Where cadastral parcels exist, check how cell areas relate to parcel areas for your area. The relationship tells you how far to trust cell-based density measures elsewhere.

Building footprints with their tessellation cells and with the cadastral parcels of the same block. cells follow buildings; parcels follow ownershiptessellation cellsone cell per buildingcadastral parcelsone owner, four houses; one building, two parcelsWhere ownership and buildings disagree, cells and parcels diverge spatialworkflow.io
Cells follow buildings; parcels follow ownership.

Cells against parcels for the same buildings

The data used on this page

Building footprints with LiDAR heights from the 3D BAG (the Dutch building register with heights from AHN), fetched from its WFS for central Den Haag — 4.285–4.330° E, 52.065–52.095° N — exactly as in How to download building footprints from OSM and Overture, and saved as denhaag_bag.parquet in the Dutch national grid (EPSG:28992).

import os

import geopandas as gpd
import requests
from pyproj import Transformer

if not os.path.exists("denhaag_bag.parquet"):
    W, S, E, N = 4.285, 52.065, 4.330, 52.095
    to_rd = Transformer.from_crs(4326, 28992, always_xy=True)
    (x0, y0), (x1, y1) = to_rd.transform(W, S), to_rd.transform(E, N)
    feats, start = [], 0
    while True:
        fc = requests.get("https://data.3dbag.nl/api/BAG3D/wfs", params={
            "service": "WFS", "version": "2.0.0", "request": "GetFeature", "typeNames": "BAG3D:lod12",
            "count": 5000, "startIndex": start, "outputFormat": "application/json",
            "bbox": f"{x0},{y0},{x1},{y1},EPSG:28992"}, timeout=300).json()
        feats += fc["features"]
        if len(fc["features"]) < 5000:
            break
        start += 5000
    bag = gpd.GeoDataFrame.from_features(feats, crs=28992)
    bag["height"] = bag.b3_h_70p - bag.b3_h_maaiveld
    bag[["identificatie", "b3_h_70p", "b3_h_maaiveld", "height", "b3_opp_grond", "geometry"]] \
        .to_parquet("denhaag_bag.parquet")
print(len(gpd.read_parquet("denhaag_bag.parquet")), "buildings")
23929 buildings

Example 1 — cell against parcel for the same building

pts = gpd.GeoDataFrame(geometry=b.representative_point(), crs=28992)
j = gpd.sjoin(pts, parcels[["geometry"]], predicate="within")
j = j[~j.index.duplicated()]
ratio = cells.area.reindex(j.index).to_numpy() / parcels.area.loc[j.index_right].to_numpy()
print(f"buildings matched to a parcel: {len(j):,} of {len(b):,}")
print(f"cell area / parcel area: median {np.median(ratio):.2f}, "
      f"10th–90th percentile {np.percentile(ratio, 10):.2f}–{np.percentile(ratio, 90):.2f}")
per_parcel = j.groupby("index_right").size()
print(f"parcels holding several buildings: {(per_parcel > 1).mean():.0%} "
      f"(most: {per_parcel.max()} buildings)")
buildings matched to a parcel: 1,960 of 1,990
cell area / parcel area: median 1.34, 10th–90th percentile 0.20–4.04
parcels holding several buildings: 11% (most: 25 buildings)

The typical cell is a third larger than the parcel its building stands on, mostly because it takes in half the street. The spread — from a fifth of the parcel to four times it — is the honest answer to "is a cell a plot?": sometimes close, sometimes far, most often where one owner holds many buildings or one building spans several parcels.

Example 2 — the limit distance

for d in (50, 100, 200):
    t = momepy.morphological_tessellation(b, clip=momepy.buffered_limit(b, buffer=d))
    cov = b.area.sum() / t.area.sum()
    print(f"limit {d:>3} m: total cell area {t.area.sum() / 1e6:.3f} km², "
          f"coverage {cov:.0%}, median cell {t.area.median():.0f} m²")
limit  50 m: total cell area 1.189 km², coverage 40%, median cell 223 m²
limit 100 m: total cell area 1.450 km², coverage 33%, median cell 223 m²
limit 200 m: total cell area 1.956 km², coverage 24%, median cell 223 m²

The median cell does not move at all: in a dense centre the limit leaves the interior cells alone. The edge cells absorb the difference, and there are enough of them in a 1 km square that total cell area grows by two-thirds and overall coverage falls from 40% to 24% between a 50 m and a 200 m limit. In a suburb with large gardens and open land, the interior changes too. The limit is part of the method.

Example 3 — where the street land goes

public = parcels[parcels.area > 5000]
print(f"parcels over 5,000 m²: {len(public)} covering {public.area.sum() / 1e6:.3f} km² "
      f"of the 1 km² (streets, squares, water, large estates)")
print(f"land within the buffered limit not under any building: "
      f"{(cells.area.sum() - b.area.sum()) / 1e6:.3f} km², all of it assigned to some building's cell")
parcels over 5,000 m²: 20 covering 0.234 km² of the 1 km² (streets, squares, water, large estates)
land within the buffered limit not under any building: 0.971 km², all of it assigned to some building's cell

In the cadastre, streets and squares are parcels of their own. In a free tessellation that land is shared out among the buildings along it — which raises cell areas and lowers cell coverage wherever streets are wide.

Tessellation cell area divided by cadastral parcel area for 1,960 buildings in central Den Haag. cell area ÷ parcel area, per building10th percentile0.20×median1.34× — half the street is in the cell90th percentile4.04×01×2×3×4×11% of parcels hold several buildings, one of them 25 spatialworkflow.io
Cell area against parcel area, building by building.

Footprints rather than centroids

Why footprints rather than centroids

A Voronoi diagram of centroids draws cell boundaries halfway between building centres, which gives a long building the same share of land on its ends as a small one. Sampling points along each footprint's boundary makes the boundary halfway between walls, which is how land around buildings is actually used.

Why tessellation is used even where parcels exist

Parcels are recorded for legal and fiscal reasons. A single parcel can hold a hundred flats; a row of houses can share a parcel with a garage court. Measures built on parcels mix form with ownership history. Tessellation measures form only — and it is identical in method in every city, which parcels never are.

Why enclosed tessellation is the usual compromise

Free cells give street land to buildings; parcels give it to the municipality. Tessellating within street enclosures excludes the street, keeps the geometric consistency and gives cells that look like plots.

Total cell area, overall coverage and median cell area for three tessellation limits in one square kilometre of Den Haag. the limit changes the edges, not the medianbuffer limitcell areacoveragemedian cell50 m1.189 km²40%223 m²100 m1.450 km²33%223 m²200 m1.956 km²24%223 m²Totals and overall coverage depend on the limit; interior cells do not spatialworkflow.io
Same buildings, three limits: coverage from 40% to 24%, median cell unchanged.

Overlaps, tiny buildings and AOI edges

  • Overlapping footprints break the partition; fix them before tessellating.
  • Very small buildings (sheds) get small cells that fragment gardens; filter by a minimum area if they are not of interest.
  • Buildings on the AOI edge have truncated cells; exclude them from statistics.
  • Water and railways should be barriers in an enclosed tessellation.
  • Cadastral data is open in the Netherlands, Denmark and elsewhere; licensing varies by country.

FAQ

What is a morphological tessellation?

A partition of land into cells, one per building, each containing the points closer to that building's footprint than to any other.

Is a tessellation cell the same as a plot?

No. It approximates one, most closely when bounded by streets. Cadastral parcels follow ownership.

Why not use a Voronoi diagram of building centroids?

Centroid Voronoi cells ignore building size and shape; footprint-based tessellation does not.

What is the limit for?

It stops edge cells from extending into open land; a 100 m buffer around buildings is common in cities.

Where can I get cadastral parcels?

From national cadastres where they are open — in the Netherlands through PDOK's Kadastrale Kaart WFS.

Why do cells and parcels differ so much in places?

Parcels group or split buildings by ownership, and streets are separate parcels; cells do neither.