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.

Building shape metrics with what each measures, its range and what it is sensitive to. six metrics, not all independentmetricmeasuresrangesensitive toareasizem²building divisioncircular compactnessblob-likeness0–1irregular outlineselongationstretch0–1littleorientationangle to cardinal0–45°near-square shapescornersoutline detailcountevery vertexshared wallsadjacency0–1snapping toleranceRelational metrics carry what single-polygon indices miss spatialworkflow.io
Six metrics that are not all measuring the same thing.

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.

A closed perimeter block with shared walls against detached buildings with none. shared walls separate the fabricsclosed blockmost of the perimeter is party walldetachedno shared wallsTwo identical footprints can sit in completely different fabrics spatialworkflow.io
Shared walls: the metric that separates a terrace from a detached house.

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.

Number of buildings in central Den Haag by deviation from the nearest cardinal direction. buildings by angle to north–south / east–west0–5°5995–15°72515–30°8,46230–45°9,8100500010kThe middle 80% fall between 22° and 38°: a grid turned away from the compass spatialworkflow.io
Orientation shows the grid: only one building in fifteen lines up with the compass.

Multipolygons, courtyards and shared walls

  • Multipolygons (a building with detached parts) need explode or a decision about which part counts.
  • Courtyards: holes in footprints change perimeter and compactness; momepy.courtyards counts them.
  • Shared-wall tolerance: adjacent footprints rarely touch exactly; momepy.shared_walls takes 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.

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.