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.
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.
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.
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.
Internal links
- Urban morphology explained — the field and its measures
- How to calculate building shape metrics — the per-building measures
- How to create a morphological tessellation — the neighbourhood structure
- Spatial clustering explained — clustering in general
- Spatial weights islands warning — neighbour graphs and their gaps
- How to calculate street network metrics — measures to add from the streets
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.