How to Calculate Building Shape Metrics in Python
On this page
Problem statement
A footprint is a polygon, and a polygon can be described by dozens of numbers: area, perimeter, compactness, elongation, orientation, number of corners, how much wall it shares with its neighbours. Morphology studies compute many of them for every building and then cluster, map or compare the results. Most of them are one line of code with momepy or shapely.
The difficulty is interpretation. Many shape indices are strongly correlated with each other and with plain size; several are sensitive to how a building was digitised — an extra vertex, a merged terrace — more than to its actual form. The useful set is small, and each one needs checking against what it claims to measure.
Quick answer
Compute a handful of complementary metrics on a projected footprint layer:
import numpy as np, pandas as pd, geopandas as gpd, momepy
b = gpd.read_parquet("denhaag_bag.parquet") # from the footprints guide, EPSG:28992
b = b[b.geometry.is_valid & (b.area > 10)].reset_index(drop=True)
b["area"] = b.area
b["perimeter"] = b.length
b["compactness"] = momepy.circular_compactness(b) # area / area of the enclosing circle
b["elongation"] = momepy.elongation(b) # short / long side of the rotated rectangle
b["orientation"] = momepy.orientation(b) # 0–45° deviation from cardinal
b["corners"] = momepy.corners(b)
b["shared_walls"] = momepy.shared_walls(b) / b.perimeter # share of the perimeter touching neighbours
cols = ["area", "compactness", "elongation", "orientation", "corners", "shared_walls"]
print(f"{len(b):,} buildings")
print(b[cols].describe(percentiles=[.1, .5, .9]).T[["10%", "50%", "90%"]].round(2).to_string())
19,596 buildings
10% 50% 90%
area 29.01 86.36 252.75
compactness 0.29 0.48 0.61
elongation 0.30 0.50 0.86
orientation 22.02 30.01 38.35
corners 4.00 6.00 14.00
shared_walls 0.00 0.34 0.67
Shared walls is the metric that most distinguishes this part of Den Haag. The median building shares a third of its perimeter with a neighbour and the top tenth share two-thirds or more — terraces and closed blocks — while at least a tenth are free-standing and share none.
Project, validate, then size, compactness and elongation
1. Project, validate and filter
Shape metrics need metres and valid polygons. Remove slivers and tiny structures (under 10 m² here) that are digitising artefacts or sheds.
2. Start with size
Area and perimeter carry most of the variance. Every other index should be checked for correlation with area before being used as an independent descriptor.
3. Choose one compactness index
Circular compactness, isoperimetric quotient, convexity and form factor all measure "how blob-like" and correlate strongly. Pick one and say which.
4. Measure elongation and orientation separately
Elongation says how stretched a footprint is; orientation says which way it points relative to the grid. Together they separate terraces from towers and slabs.
5. Add a relational metric
Shape alone ignores context. Shared-wall ratio, distance to the nearest neighbour and alignment with neighbours describe how buildings sit together — often the most informative part of urban form.
6. Check sensitivity to the source
Recompute on a second footprint source for a sample. Metrics that change a lot between sources are describing the digitisation.
Redundant metrics and digitising sensitivity
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). The OpenStreetMap answers are read as the Overpass mirror gave them on 9 October 2026.
import io
import os
import zipfile
import geopandas as gpd
import requests
from pyproj import Transformer
# OpenStreetMap as Overpass returned it on 9 October 2026, saved as osmnx's own cache so the counts
# below do not drift as OSM is edited; delete the cache folder to query live instead
if not os.path.exists("cache"):
reply = requests.get("https://data.spatialworkflow.io/osm-cache-building-shape-metrics-python-2026-10-09.zip", timeout=300)
reply.raise_for_status()
with zipfile.ZipFile(io.BytesIO(reply.content)) as archive:
archive.extractall("cache")
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 — which metrics are redundant?
b["log_area"] = np.log10(b.area)
b["convexity"] = momepy.convexity(b)
b["form_factor"] = momepy.form_factor(b, b.b3_h_70p - b.b3_h_maaiveld)
corr = b[["log_area", "compactness", "convexity", "elongation", "corners", "shared_walls"]].corr(
method="spearman").round(2)
print(corr.to_string())
log_area compactness convexity elongation corners shared_walls
log_area 1.00 -0.15 -0.47 -0.01 0.57 -0.23
compactness -0.15 1.00 0.46 0.87 -0.25 -0.07
convexity -0.47 0.46 1.00 0.13 -0.83 0.19
elongation -0.01 0.87 0.13 1.00 -0.00 -0.17
corners 0.57 -0.25 -0.83 -0.00 1.00 -0.28
shared_walls -0.23 -0.07 0.19 -0.17 -0.28 1.00
Two pairs stand out: compactness and elongation (ρ = 0.87), and convexity and corners (ρ = −0.83). Each pair is telling one story — how stretched, how jagged — and keeping both in a clustering gives that story double weight. Size, by contrast, is nearly independent of compactness and elongation here.
Example 2 — sensitivity to how the building was drawn
import osmnx as ox
ox.settings.overpass_url = "https://maps.mail.ru/osm/tools/overpass/api" # mirror; overpass-api.de refused
ox.settings.overpass_rate_limit = False
osm = ox.features_from_bbox((4.285, 52.065, 4.330, 52.095), {"building": True})
osm = osm[osm.geom_type == "Polygon"].to_crs(28992)
osm = osm[osm.area > 10]
for name, g in (("3D BAG", b), ("OSM", osm)):
print(f"{name:<7} n={len(g):>6,} median area {g.area.median():>6.0f} m² "
f"median corners {momepy.corners(g).median():>4.0f} "
f"median compactness {momepy.circular_compactness(g).median():.2f}")
3D BAG n=19,596 median area 86 m² median corners 6 median compactness 0.48
OSM n=19,800 median area 86 m² median corners 6 median compactness 0.48
Here the two sources give identical medians, because Dutch OSM buildings were imported from the BAG — the same outlines under two names. That is the best case. Where a source divides the same ground differently, per-building statistics shift even though nothing about the city changed, so a morphology study should name its footprint source regardless.
Example 3 — orientation relative to the street grid
bins = pd.cut(b.orientation, [0, 5, 15, 30, 45], include_lowest=True)
print(b.groupby(bins, observed=True).agg(buildings=("area", "size"),
median_area=("area", "median")).to_string())
buildings median_area
orientation
(-0.001, 5.0] 599 77.626831
(5.0, 15.0] 725 108.138916
(15.0, 30.0] 8462 81.165955
(30.0, 45.0] 9810 88.521057
Orientation in momepy is the deviation from the nearest cardinal direction, 0–45°. A strongly clustered distribution means buildings follow a grid; a spread-out one means they follow curving streets or several overlapping grids. Here only about one building in fifteen sits within 15° of north–south or east–west, and the middle 80% fall between 22° and 38°: the centre's streets are laid out on grids turned well away from the compass.
Compactness indices that agree
Why compactness indices agree with each other
They are different normalisations of the same comparison — the footprint against an ideal round or square shape. For rectangular buildings they are almost monotonic functions of each other. Differences appear only for irregular shapes, which in a real city are few.
Why shape metrics depend on digitisation
A corner count depends on every vertex a mapper placed; a compactness index on whether a terrace is one polygon or ten. These are properties of the data as much as of the buildings. Metrics based on area and on relations between buildings are more robust than those based on the outline's detail.
Why relational metrics matter most
A detached house and a terraced house can have identical footprints. What differs is what surrounds them. Shared walls, gaps between buildings and alignment along a street capture the difference that people recognise immediately and single-polygon shape indices miss.
Multipolygons, courtyards and shared walls
- Multipolygons (a building with detached parts) need
explodeor a decision about which part counts. - Courtyards: holes in footprints change perimeter and compactness;
momepy.courtyardscounts them. - Shared-wall tolerance: adjacent footprints rarely touch exactly;
momepy.shared_wallstakes a tolerance for near-coincident edges. - Scale: a metric's meaning changes between a house and a hospital; compare within size classes or use size-normalised indices.
- Heights: form factor and volume-based indices need a height; the 3D BAG supplies one.
Internal links
- How to download building footprints from OSM and Overture — the input layer
- Urban morphology explained — what the metrics are for
- How to cluster urban fabric types — combining metrics
- Morphological tessellation explained — the space around each building
- How to simplify geometries in GeoPandas — why vertex counts are fragile
- Building height sources explained — the third dimension
FAQ
How do I calculate building compactness in Python?
momepy.circular_compactness(gdf) compares each footprint with its minimum enclosing circle; shapely gives the ingredients for other indices.
What is elongation?
The ratio of the short to the long side of the minimum rotated rectangle around the footprint: 1 for a square, near 0 for a slab.
What does building orientation measure in momepy?
The deviation of the footprint's main axis from the nearest cardinal direction, from 0° to 45°.
Which shape metrics should I use?
Size, one compactness index, elongation, orientation and at least one relational metric such as shared walls.
Why do my metrics change with a different footprint source?
The sources divide and digitise buildings differently. Name the source and prefer metrics that are robust to it.
What are shared walls?
The length of a footprint's perimeter that touches neighbouring buildings — high in terraces and closed blocks, zero for detached buildings.