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".
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.
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.
Basements, units, roof levels and slopes
- Basements are not levels.
building:levelsexcludes them;building:levels:undergroundis separate. heightin OSM may carry units."12 m"and"40'"both appear.roof:levelsis additional, not included inbuilding: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.
Internal links
- How to get building heights from LiDAR in Python β the measured route in full
- Level of detail explained for 3D city models β what national products give you
- How to extrude building footprints into 3D in Python β using the height
- 3D spatial data models explained: 2.5D, meshes and solids β why 2.5D needs exactly one number
- Extruded buildings float above or sink into the ground β the ground half of the problem
- The OpenStreetMap data model explained β where the tags come from
- LiDAR surfaces explained: DSM, DTM and CHM β DSM minus DTM
- How to calculate building volumes and floor area in Python β what the error propagates into
- How to calculate coverage and floor area ratio β what heights are needed for
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.