How to Cluster Urban Fabric Types in Python

On this page

Problem statement

A city is made of recognisable kinds of place: the perimeter blocks of the old centre, rows of terraced houses, post-war slabs in open space, office towers, industrial sheds. Morphometric classification tries to find those types from data — compute a set of form measures for every building, standardise them, and let a clustering algorithm group similar buildings.

It works well enough to be widely used, and it has three predictable failure modes: measures that duplicate each other dominate the result, per-building values are too noisy to give coherent areas, and the number of clusters is chosen after looking at the map. Each has a standard remedy.

Quick answer

Compute a small set of complementary measures per building, smooth them over neighbours, standardise, cluster:

import numpy as np, pandas as pd, geopandas as gpd, momepy
from libpysal import graph
from sklearn.preprocessing import StandardScaler
from sklearn.cluster import KMeans

b = gpd.read_parquet("denhaag_bag.parquet")
b = b[b.is_valid & (b.area > 20)].reset_index(drop=True)
b["height"] = (b.b3_h_70p - b.b3_h_maaiveld).clip(lower=2.5)

cells = momepy.morphological_tessellation(b, clip=momepy.buffered_limit(b, buffer=100))
X = pd.DataFrame({
    "log_area": np.log10(b.area),
    "height": b.height,
    "coverage": b.area / cells.area.reindex(b.index),
    "shared_walls": momepy.shared_walls(b) / b.length,
    "elongation": momepy.elongation(b),
}, index=b.index).dropna()

# smooth each measure: mean over the building and its tessellation neighbours up to 3 steps away
contig = graph.Graph.build_contiguity(cells.loc[X.index], rook=False)
W3 = contig.higher_order(k=3, lower_order=True).assign_self_weight(1).transform("r")
Xs = X.apply(lambda col: pd.Series(W3.lag(col.to_numpy()), index=X.index))

Z = StandardScaler().fit_transform(Xs)
km = KMeans(n_clusters=5, n_init=20, random_state=0).fit(Z)
b.loc[X.index, "fabric"] = km.labels_
summary = X.assign(fabric=km.labels_).groupby("fabric").median().round(2)
summary["buildings"] = pd.Series(km.labels_).value_counts().sort_index()
print(summary.to_string())
        log_area  height  coverage  shared_walls  elongation  buildings
fabric                                                                 
0           1.84    9.63      0.47          0.56        0.43       3829
1           2.01   10.89      0.39          0.31        0.51       7136
2           2.03   12.10      0.58          0.36        0.45       2947
3           2.25   13.09      0.32          0.18        0.57       1266
4           1.75    9.01      0.38          0.47        0.62       3405

Each row is a fabric type described by its median building. Cluster 0 is small buildings sharing more than half their perimeter — terraces; cluster 2 covers the most of its land — closed blocks; cluster 3 is the largest and tallest, sharing little wall and covering a third of its cell — free-standing slabs and towers. Naming them — "closed blocks", "terraces", "slabs" — comes after, from those medians and a look at the map.

Few measures, transformed, smoothed, standardised, clustered

1. Choose few, complementary measures

Size, height, coverage, adjacency and shape. Check their correlations; drop one of any pair above about 0.8.

2. Transform skewed measures

Areas and heights are right-skewed; log-transform them so a few very large buildings do not dominate the scaling.

3. Smooth over neighbours

A single building is a noisy sample of its fabric. Averaging each measure over its tessellation neighbours (up to three steps) describes the context the building sits in and yields spatially coherent clusters.

4. Standardise

K-means uses Euclidean distance; unscaled, height in metres would outweigh a ratio between 0 and 1.

5. Choose the number of clusters with a criterion

Compare silhouette scores or inertia across a range of k before looking at maps. Then check that each cluster is interpretable.

6. Validate against the ground

Look at a sample of each cluster in aerial imagery or street view. A cluster that mixes visibly different places needs another measure.

From building footprints through measures, neighbourhood smoothing and standardisation to k-means fabric types. from buildings to fabric typesmeasuressize, height,coverage…check overlapdrop ρ > 0.8smooth3 steps ofneighboursstandardisez-scoresk-meansthen name by eyeThe measures and the smoothing decide more than the algorithm spatialworkflow.io
Measures, context, scale, cluster — then check on the ground.

Choosing the number of fabric types

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 — how many clusters?

from sklearn.metrics import silhouette_score

rng = np.random.default_rng(0)
sample = rng.choice(len(Z), size=min(4000, len(Z)), replace=False)
for k in range(3, 9):
    labels = KMeans(n_clusters=k, n_init=10, random_state=0).fit_predict(Z)
    print(f"k = {k}: silhouette {silhouette_score(Z[sample], labels[sample]):.3f}")
k = 3: silhouette 0.204
k = 4: silhouette 0.210
k = 5: silhouette 0.206
k = 6: silhouette 0.204
k = 7: silhouette 0.202
k = 8: silhouette 0.203

Silhouette scores for urban form are rarely high — fabric types grade into each other. A plateau across several k values means the choice is partly a matter of how fine a typology the question needs.

Example 2 — what smoothing does

raw_labels = KMeans(n_clusters=5, n_init=20, random_state=0).fit_predict(
    StandardScaler().fit_transform(X))
def patchiness(labels):
    lab = pd.Series(labels, index=X.index)
    same = [np.mean(lab.loc[list(nbrs)] == lab.loc[i]) for i, nbrs in
            contig.neighbors.items() if len(nbrs)]
    return np.mean(same)
print(f"share of neighbours in the same cluster: raw {patchiness(raw_labels):.2f}, "
      f"smoothed {patchiness(km.labels_):.2f}")
share of neighbours in the same cluster: raw 0.47, smoothed 0.85

Without smoothing, fewer than half of a building's neighbours share its cluster — a speckled map. With it, 85% do, and the clusters form coherent areas that can be walked and named.

Example 3 — the correlation check

print(X.corr(method="spearman").round(2).to_string())
              log_area  height  coverage  shared_walls  elongation
log_area          1.00    0.59      0.04         -0.36        0.03
height            0.59    1.00      0.06         -0.15       -0.07
coverage          0.04    0.06      1.00          0.24       -0.23
shared_walls     -0.36   -0.15      0.24          1.00       -0.16
elongation        0.03   -0.07     -0.23         -0.16        1.00

No pair here comes near 0.8. The strongest, footprint size and height at 0.59, stays in: larger buildings tend to be taller, but a large low shed and a large tower are different fabrics, and keeping both lets the clustering tell them apart. Run the same check whenever a measure is added.

Five urban fabric clusters in central Den Haag with the median building of each. five fabric types, by their median buildingclusterarea m²heightcoverageshared wallsbuildingsterraces699.6 m0.470.563,829mixed10210.9 m0.390.317,136closed blocks10712.1 m0.580.362,947slabs, towers17813.1 m0.320.181,266small, squarish569.0 m0.380.473,405Names come last, from the medians and a look at the map spatialworkflow.io
Five fabric types, described by their median building.

Smoothing, k-means and human names

Why smoothing is essential

Urban fabric is a property of an area, not of a building: a detached villa in a terrace street is part of the terrace fabric. Averaging over neighbours moves each building's description towards its context, which is what a fabric typology is about. The number of neighbourhood steps sets the scale of the typology.

Why k-means and not something cleverer

Gaussian mixtures, hierarchical clustering and HDBSCAN all work and have advantages. K-means is fast, deterministic with a seed and easy to explain; for exploratory typologies of a few thousand to a few hundred thousand buildings it is the usual starting point. The choice of measures matters far more than the algorithm.

Why the clusters need names from people

The algorithm produces groups with statistical profiles. Whether cluster 2 is "interwar terraces" or "social housing rows" is a judgement that needs local knowledge and a look at the buildings. Publishing unnamed clusters invites readers to name them wrongly.

Silhouette score of k-means fabric clusterings of central Den Haag for three to eight clusters. silhouette by number of clustersk = 30.204k = 40.210k = 50.206k = 60.204k = 70.202k = 80.20300.10.20.3A plateau: the number of types is a choice about how fine a typology is needed spatialworkflow.io
Fabric types grade into each other: no k stands out, so choose by use and check on the ground.

Edges, very large buildings and transfer

  • Edge effects: buildings at the edge of the study area have fewer neighbours and less stable smoothed values.
  • Very large buildings (hospitals, stations) form tiny clusters of their own at high k; decide whether to exclude them.
  • Street measures (width, network centrality) can be added per building through the nearest street segment.
  • Reproducibility: fix the random seed and report the software versions; k-means labels are arbitrary integers.
  • Transfer: clusters fitted in one city describe that city; fit on several cities together for a comparable typology.

FAQ

How do I classify urban fabric from building data?

Compute a few morphometric measures per building, smooth them over neighbours, standardise, and cluster with k-means or similar.

Which measures should I use?

Size, height, coverage, shared walls and a shape index — few and weakly correlated.

Why smooth over neighbours?

Fabric is a property of an area. Smoothing turns noisy per-building values into descriptions of context and gives coherent clusters.

How many clusters should I choose?

Compare silhouette scores or inertia over a range of k, then check interpretability. Expect no sharp optimum.

Do I need a tessellation?

It provides both the coverage denominator and a clean neighbour graph, so it is the usual basis.

How do I name the clusters?

From their median measures and by looking at examples on the ground or in imagery.