Where building heights come from, and how wrong they are

On this page

Problem statement

Every 3D building analysis starts with a height, and the height is almost always estimated. The four sources β€” OpenStreetMap tags, LiDAR, national 3D products and floor-count rules β€” have different coverage, different definitions and different errors, and mixing them without saying so produces a model whose accuracy nobody can state.

Two numbers make the scale of the problem concrete. In a block of central The Hague covered by the city's LoD2 model, 217 of 918 OpenStreetMap buildings carry building:levels β€” 23.6% β€” and none carries height. And of the buildings that do have a level count, estimating height as levels Γ— 3 m predicts the measured eaves to within 2 m for 85.7% of them and the ridge for only 28.6%.

This guide covers each source, what it measures, and how to record the uncertainty rather than hiding it.

Quick answer

Use the best source available per building, and record which one:

import numpy as np, pandas as pd

def best_height(row):
    if pd.notna(row.get("lidar_p95")):
        return row["lidar_p95"] - row["ground_z"], "lidar"
    if pd.notna(row.get("osm_height")):
        return float(row["osm_height"]), "osm:height"
    if pd.notna(row.get("osm_levels")):
        return float(row["osm_levels"]) * 3.0, "osm:levels x3"
    return np.nan, "unknown"

b[["height_m", "height_source"]] = b.apply(
    lambda r: pd.Series(best_height(r)), axis=1)
print(b["height_source"].value_counts())
height_source
unknown          701
osm:levels x3    217
Name: count, dtype: int64

A height_source column costs one column and turns "the model says 12 m" into "the model says 12 m, estimated from a level count".

Four building height sources compared on coverage, what they measure and error. four sourcesLiDARnational LoD2OSM heightOSM levelsmeasured?βœ“yesβœ“yesstatedβœ•nocoveragewhere flownβœ“nationalβœ•0.04%!23.6%typical errorβœ“~0.3 mβœ“~0.5 m~1 mβœ•1.2 mwhat it givesβœ“surfaceβœ“eaves+ridgea height!a countOSM coverage: height on 1 of 2,300 buildings, levels on 217 of 918 in central Den Haag spatialworkflow.io
Only one of the four measures the building; the rest infer it.

Which height, then measured, national or tagged

1. Decide which height you mean

Eaves, ridge, mean roof, or the top of a parapet. On a real LoD2 tile of The Hague the median eaves height was 5.61 m above ground and the median ridge 6.68 m. Per building the picture is sharper: two thirds of the solids have flat roofs, where the two coincide, and on the 34.3% with a pitched roof the ridge sits a median 3.15 m above the eaves. Every downstream number inherits this choice.

2. Prefer a measured source

LiDAR gives you the actual surface. The standard recipe is the 95th percentile of first-return heights inside the footprint minus the local terrain height, with the percentile rather than the maximum so that a chimney or an aerial does not set the building height.

3. Know what the national product used

An LoD1 or LoD2 national product has a documented height definition and an accuracy figure. Read it: some use the 50th percentile of roof points, some the 70th, some the ridge.

4. Treat OSM tags as sparse but exact where present

height is metres and is usually right when it exists; it just rarely exists. In the tile's extent, 1 of 2,300 OSM buildings had it, and none of the 918 inside the model. building:levels is far commoner at 23.6%, and is a count, not a height.

5. Calibrate the storey height locally, do not assume 3 m

Dividing measured height by level count on the buildings that have both gives the local figure directly. On the Hague sample that was 3.20 m per level to the eaves and 4.53 m per level to the ridge. Using 3.0 m against the eaves gave a median error of βˆ’0.43 m and a mean absolute error of 1.15 m.

6. Do not use level counts to estimate ridge height

The roof is not in the level count. Estimating ridge height from levels Γ— 3.0 m had a mean absolute error of 2.93 m against 1.15 m for the eaves, and only 28.6% of buildings within 2 m.

7. Record the source and the uncertainty per building

A height_source and a height_sigma_m column make every downstream product honest, and let a user filter to the buildings whose heights were measured.

Mean absolute error of four level-to-height rules against measured heights. mean absolute error, 217 buildingslevels x3.0 vs eaves1.15 m Β· 85.7% within 2 mlevels x3.0 vs ridge2.93 m Β· 28.6%levels x3.0+1.5 vs ridge1.86 m Β· 69.1%levels x3.5 vs ridge2.26 m Β· 45.2%03.5 mLevel counts predict the eaves; the roof is not a storey spatialworkflow.io
Level counts predict eaves height. They do not predict the ridge, because the roof is not a storey.

LiDAR heights and calibrated storey heights for Den Haag

The data used on this page

Two real sources for the same streets of central Den Haag. The measured heights are the LoD2 tile of The Hague's 3D city model published as a CityJSON sample dataset (DenHaag_01.city.json, saved as model.city.json): 1,990 solids, each carrying the eaves and ridge height above ground that the city measured. The level counts are every OpenStreetMap building in the tile's extent, via the maps.mail.ru Overpass mirror, as it answered on 9 October 2026. The model covers only about 41% of its bounding box, so the sample is the OSM buildings whose centre falls inside a LoD2 solid, and each takes that solid's measured heights.

import io
import json
import os
import zipfile

import geopandas as gpd
import numpy as np
import osmnx as ox
import pandas as pd
import requests
from shapely.geometry import Polygon, box
from shapely.ops import unary_union

# 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-heights-sources-explained-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("model.city.json"):
    reply = requests.get("https://3d.bk.tudelft.nl/opendata/cityjson/3dcities/v2.0/DenHaag_01.city.json", timeout=300)
    reply.raise_for_status()
    open("model.city.json", "wb").write(reply.content)
d = json.load(open("model.city.json"))
V = np.array(d["vertices"]) * d["transform"]["scale"] + d["transform"]["translate"]

rows = []
for oid, o in d["CityObjects"].items():
    a = o.get("attributes", {})
    for g in o.get("geometry", []):
        if g["type"] != "Solid" or "RelativeEavesHeight" not in a:
            continue
        sem = g["semantics"]
        ground = [Polygon(V[surface[0]][:, :2])
                  for shell, values in zip(g["boundaries"], sem["values"])
                  for surface, v in zip(shell, values)
                  if v is not None and sem["surfaces"][v]["type"] == "GroundSurface"]
        rows.append({"id": oid, "eaves": a["RelativeEavesHeight"], "ridge": a["RelativeRidgeHeight"],
                     "geometry": unary_union(ground)})
solids = gpd.GeoDataFrame(rows, crs=28992)

ox.settings.overpass_url = "https://maps.mail.ru/osm/tools/overpass/api"
ox.settings.overpass_rate_limit = False
x0, y0, x1, y1 = d["metadata"]["geographicalExtent"][0], d["metadata"]["geographicalExtent"][1], \
                 d["metadata"]["geographicalExtent"][3], d["metadata"]["geographicalExtent"][4]
tile = gpd.GeoSeries([box(x0, y0, x1, y1)], crs=28992)
osm = ox.features_from_polygon(tile.to_crs(4326).iloc[0], {"building": True})
osm = osm[osm.geom_type.isin(["Polygon", "MultiPolygon"])].to_crs(28992)
osm = osm[osm.representative_point().within(tile.iloc[0])]
b = gpd.GeoDataFrame({
    "id": np.arange(len(osm)),
    "osm_levels": pd.to_numeric(osm.get("building:levels"), errors="coerce").values,
    "osm_height": pd.to_numeric(osm.get("height"), errors="coerce").values if "height" in osm else np.nan,
}, geometry=osm.geometry.values, crs=28992)
hit = gpd.sjoin(gpd.GeoDataFrame(b[["id"]], geometry=b.representative_point(), crs=28992),
                solids[["eaves", "ridge", "geometry"]], predicate="within").drop_duplicates("id")
b = b.merge(hit[["id", "eaves", "ridge"]].rename(columns={"eaves": "measured_eaves_m", "ridge": "measured_ridge_m"}),
            on="id", how="left")
b = b[b.measured_eaves_m.notna()].reset_index(drop=True)     # the tile covers ~41% of its bounding box
b["levels_n"] = b["osm_levels"]
print(f"{len(solids):,} LoD2 solids; {len(osm):,} OSM buildings in the extent, {len(b):,} inside the model")
1,990 LoD2 solids; 2,300 OSM buildings in the extent, 918 inside the model

Example 1 β€” heights from LiDAR, done defensibly

import numpy as np, geopandas as gpd, laspy
from shapely.geometry import Point

def heights_from_lidar(buildings, las_path, percentile=95, min_points=20):
    las = laspy.read(las_path)
    keep = las.return_number == 1
    pts = gpd.GeoDataFrame(
        {"z": las.z[keep]},
        geometry=gpd.points_from_xy(las.x[keep], las.y[keep]),
        crs=buildings.crs,
    )
    joined = gpd.sjoin(pts, buildings[["id", "geometry"]], predicate="within")
    stats = joined.groupby("id")["z"].agg(
        n="size", top=lambda s: np.percentile(s, percentile))
    stats = stats[stats["n"] >= min_points]
    out = buildings.merge(stats, left_on="id", right_index=True, how="left")
    out["height_m"] = out["top"] - out["ground_z"]
    out["height_source"] = np.where(out["height_m"].notna(),
                                    f"lidar p{percentile}", "unknown")
    return out

This one needs a point cloud, so it is not run on this page; How to get building heights from LiDAR in Python runs the same recipe on AHN data. min_points matters: a footprint with three returns inside it gives a height that is noise. Report how many buildings fell below the threshold.

Example 2 β€” calibrate the storey height against measured data

import pandas as pd

both = b.dropna(subset=["levels_n", "measured_eaves_m", "measured_ridge_m"])
print(f"buildings with both a level count and a measured height: {len(both):,}")
print(f"implied storey height to eaves : {(both.measured_eaves_m / both.levels_n).median():.2f} m")
print(f"implied storey height to ridge : {(both.measured_ridge_m / both.levels_n).median():.2f} m")

for per_level, target in [(3.0, "measured_eaves_m"), (3.0, "measured_ridge_m"),
                          (3.5, "measured_ridge_m")]:
    err = both.levels_n * per_level - both[target]
    print(f"{per_level} m/level vs {target:20} median {err.median():+.2f} m, "
          f"MAE {err.abs().mean():.2f} m, within 2 m {(err.abs() < 2).mean():.1%}")
buildings with both a level count and a measured height: 217
implied storey height to eaves : 3.20 m
implied storey height to ridge : 4.53 m
3.0 m/level vs measured_eaves_m     median -0.43 m, MAE 1.15 m, within 2 m 85.7%
3.0 m/level vs measured_ridge_m     median -3.07 m, MAE 2.93 m, within 2 m 28.6%
3.5 m/level vs measured_ridge_m     median -2.07 m, MAE 2.26 m, within 2 m 45.2%

Example 3 β€” an uncertainty column the rest of the pipeline can use

SIGMA = {"lidar p95": 0.4, "national lod2": 0.5, "osm:height": 1.0,     # replace with your sources' stated accuracy
         "osm:levels x3": 1.2, "unknown": np.nan}

b["height_sigma_m"] = b["height_source"].map(SIGMA)
b["volume_m3"] = b.area * b["height_m"]
b["volume_sigma_m3"] = b.area * b["height_sigma_m"]

print(b.groupby("height_source")[["height_m", "height_sigma_m"]].median().round(2))
               height_m  height_sigma_m
height_source                          
osm:levels x3       6.0             1.2
unknown             NaN             NaN

Example 4 β€” the other numbers on this page

print(f"OSM buildings inside the model: {len(b):,}; with building:levels {b.osm_levels.notna().sum():,} "
      f"({b.osm_levels.notna().mean():.1%}); with height {b.osm_height.notna().sum()}")
print(f"LoD2 solids: {len(solids):,}; median eaves {solids.eaves.median():.2f} m, "
      f"median ridge {solids.ridge.median():.2f} m; per solid, ridge minus eaves: median {(solids.ridge - solids.eaves).median():.2f} m, "
      f"{(solids.ridge > solids.eaves + 0.5).mean():.1%} have a pitched roof (>0.5 m), their median {(solids.ridge - solids.eaves)[solids.ridge > solids.eaves + 0.5].median():.2f} m")
err = both.levels_n * 3.0 + 1.5 - both.measured_ridge_m
print(f"levels x 3.0 + 1.5 vs ridge: within 2 m {(err.abs() < 2).mean():.1%}, MAE {err.abs().mean():.2f} m")
OSM buildings inside the model: 918; with building:levels 217 (23.6%); with height 0
LoD2 solids: 1,990; median eaves 5.61 m, median ridge 6.68 m; per solid, ridge minus eaves: median 0.00 m, 34.3% have a pitched roof (>0.5 m), their median 3.15 m
levels x 3.0 + 1.5 vs ridge: within 2 m 69.1%, MAE 1.86 m

The 1.2 m for level-derived heights is the measured mean absolute error against the eaves above (1.15 m), not a guess; the other three are placeholders for the accuracy your own sources document. Carrying it forward means a total volume can be quoted with an interval instead of a false precision.

Why level counts predict eaves

Why level counts predict eaves and not ridge

A storey is a habitable floor; the roof above the top storey is not one. Level counts therefore encode the height to the top of the last floor, which is approximately the eaves. Adding a roof height to the estimate helps on average β€” levels Γ— 3.0 + 1.5 raised the within-2-m share from 28.6% to 69.1% against the ridge β€” but roofs vary more than storeys do, so its mean absolute error of 1.86 m stays well above the 1.15 m the same rule achieves against the eaves.

Why the storey height has to be calibrated locally

Storey heights vary with age and use: older town houses and commercial ground floors are tall, post-war flats are low. This central Hague sample implies 3.20 m per level to the eaves, so the common 3 m rule came out slightly short here, by a median 0.43 m. A single constant across mixed stock has a mixed error β€” which is a good reason to calibrate per building class, and per city, rather than assume.

Why the 95th percentile rather than the maximum

A LiDAR return on a chimney, an aerial, a lift overrun or a bird sets the maximum. The 95th percentile of first returns inside the footprint is stable, and the choice of percentile should be stated because it changes the answer: p50 gives roughly the eaves on a pitched roof, p95 roughly the ridge.

Why the source column is worth more than a better estimate

A model whose heights are 2 m out and whose metadata says so is usable: a user can propagate the error, filter to the measured buildings, or decide the analysis tolerates it. A model whose heights are 2 m out and claims nothing is a trap. Recording the source costs one column.

Eaves, ridge and mean roof height on the same building. which height?the sectionfour defensible answerson 1,990 buildingsridge6.68 m medianmean roofbetween the twoeaves5.61 m medianground5.96 m NAP19% apart on the same stockThe definition changes the answer by more than the measurement error spatialworkflow.io
Four defensible heights for one building; the metadata has to say which.

Basements, units, roof levels and slopes

  • Basements are not levels. building:levels excludes them; building:levels:underground is separate.
  • height in OSM may carry units. "12 m" and "40'" both appear.
  • roof:levels is additional, not included in building:levels.
  • Terrain slope makes "height above ground" ambiguous. State which ground.
  • Tall single-storey buildings break every rule. Churches, warehouses, sports halls.
  • Parapets add a metre on flat-roofed buildings and are in the LiDAR.
  • Trees overhanging a footprint inflate LiDAR heights. Filter by return number and classification.
  • Old models miss new buildings entirely. A height of NaN is better than a height from 2015.

FAQ

Where do building heights come from?

Four sources: LiDAR or photogrammetry (measured), national 3D products (measured, with a documented definition), OpenStreetMap height tags (sparse but reliable), and floor counts converted with a storey-height rule (common and weak).

How many OpenStreetMap buildings have a height?

Very few. In a central Den Haag sample of 918 buildings, none had height and 217 β€” 23.6% β€” had building:levels.

How accurate is levels Γ— 3 m?

Against measured eaves heights it had a mean absolute error of 1.15 m with 85.7% within 2 m. Against ridge heights it was far worse: 2.93 m and 28.6%.

What storey height should I use?

Calibrate it against buildings that have both a level count and a measured height. One real sample in central The Hague implied 3.20 m per level to the eaves.

Should I use the maximum LiDAR return for building height?

No. Use a percentile β€” commonly the 95th of first returns inside the footprint β€” so a chimney or an aerial does not set the height.

Do I need to record where each height came from?

Yes. A height_source column plus a per-source uncertainty is what lets anyone downstream state the accuracy of a volume or a shadow.