Classifying Bridge Decks
TL;DR: Intersect road lines with water and road lines with each other to get crossing polygons, buffer them by the road width, estimate the terrain beneath each crossing from ground points on the banks outside the deck, and reclassify class 2 points inside the crossing that sit more than about 1.5 m above that terrain to class 17 with filters.assign.
# Context and Motivation
This guide is part of Water and Bridge Classification in LiDAR. Ground filters such as SMRF look for the lowest continuous surface, and a bridge deck is continuous with the road that leads onto it. Short bridges are often classified as ground in their entirety. The resulting DTM carries a raised causeway across every river and every underpass, which blocks drainage in hydrologic models and misrepresents the terrain for flood mapping.
The parent topic’s heuristic — ground points well above their nearest ground neighbours — catches long, high bridges. It misses short culvert-style crossings, and it can flag steep road embankments. Using road and water geometry to say where bridges can be, and the banks to say what the terrain under them looks like, makes deck detection reliable.
# Prerequisites and Assumptions
- A tile with ground classified; water polygons if available.
- Road centrelines (OpenStreetMap, a national road network, or client GIS) in the tile’s CRS, ideally with a width or number-of-lanes attribute.
- PDAL 2.4+ with
filters.overlayandfilters.assign; Python with GeoPandas, Shapely, NumPy and SciPy.
# Step-by-Step Implementation
# Step 1 — Build crossing polygons
Intersect roads with water polygons, and roads with other roads (for overpasses). Buffer each intersection segment along the road by 15 m beyond the obstacle on both sides and across the road by half its width plus 2 m.
# Step 2 — Burn crossing IDs into points
filters.overlay writes a CrossingId onto every point inside a crossing polygon.
# Step 3 — Estimate terrain beneath each crossing
Take ground points in a ring around the crossing but outside the road buffer — the banks — and interpolate a surface beneath the deck with a linear interpolator.
# Step 4 — Test deck height
For class 2 points inside each crossing, compute height above the bank surface. Points more than 1.5 m above it are deck.
# Step 5 — Reclassify and rebuild the DTM
Write class 17 for deck points, then rebuild the DTM from class 2 only; the channel beneath each bridge is now filled from the banks.
# Complete Working Example
"""Bridge deck detection from road crossings and bank terrain."""
from __future__ import annotations
import json
from pathlib import Path
import geopandas as gpd
import numpy as np
import pdal
from scipy.interpolate import LinearNDInterpolator
def crossings(roads: gpd.GeoDataFrame, water: gpd.GeoDataFrame) -> gpd.GeoDataFrame:
parts = []
for road in roads.itertuples():
width = getattr(road, "width_m", 8.0) or 8.0
for wpoly in water.geometry[water.intersects(road.geometry)]:
seg = road.geometry.intersection(wpoly.buffer(15.0))
parts.append(seg.buffer(width / 2 + 2.0, cap_style="flat"))
gdf = gpd.GeoDataFrame({"geometry": parts}, crs=roads.crs)
gdf = gdf.dissolve().explode(index_parts=False).reset_index(drop=True)
gdf["CrossingId"] = np.arange(1, len(gdf) + 1)
return gdf
def classify_decks(src: Path, dst: Path, xing: gpd.GeoDataFrame, min_clear: float = 1.5) -> int:
gpkg = dst.with_suffix(".crossings.gpkg")
xing.to_file(gpkg, layer="crossings", driver="GPKG")
p = pdal.Pipeline(json.dumps({"pipeline": [
str(src),
{"type": "filters.ferry", "dimensions": "=>CrossingId"},
{"type": "filters.overlay", "dimension": "CrossingId", "datasource": str(gpkg),
"layer": "crossings", "column": "CrossingId"},
]}))
p.execute()
a = p.arrays[0]
ground = a["Classification"] == 2
deck_total = 0
for row in xing.itertuples():
inside = a["CrossingId"] == row.CrossingId
bank_zone = row.geometry.buffer(20.0).difference(row.geometry)
minx, miny, maxx, maxy = bank_zone.bounds
near = ground & ~inside & (a["X"] > minx) & (a["X"] < maxx) & (a["Y"] > miny) & (a["Y"] < maxy)
if near.sum() < 20:
continue
banks = LinearNDInterpolator(np.column_stack([a["X"][near], a["Y"][near]]), a["Z"][near])
cand = inside & ground
terrain = banks(a["X"][cand], a["Y"][cand])
deck = np.zeros(len(a), dtype=bool)
deck[np.nonzero(cand)[0]] = np.nan_to_num(a["Z"][cand] - terrain, nan=0.0) > min_clear
a["Classification"][deck] = 17
deck_total += int(deck.sum())
pdal.Writer.las(filename=str(dst), minor_version=4, dataformat_id=6,
forward="all").pipeline(a).execute()
return deck_total
if __name__ == "__main__":
roads = gpd.read_file("roads.gpkg").to_crs("EPSG:6341")
water = gpd.read_file("water.gpkg").to_crs("EPSG:6341")
xing = crossings(roads, water)
n = classify_decks(Path("valley_0310_water.laz"), Path("valley_0310_bridges.laz"), xing)
print(f"{len(xing)} crossings, {n} deck points reclassified to 17")Using the bank ring rather than all ground points is the key design choice: ground points inside the crossing include the deck itself, which would make the terrain estimate follow the deck and find nothing.
# Key Parameter Table
| Parameter | Type | Default | Guidance |
|---|---|---|---|
| approach extension | float, m | 15 | Beyond the water edge along the road; long enough to include abutments |
| lateral buffer | float, m | width/2 + 2 | Covers parapets and sidewalks |
| bank ring | float, m | 20 | Width of the ring sampled for terrain beneath |
min_clear |
float, m | 1.5 | Deck height above terrain; lower for culverts, higher to avoid embankments |
| minimum bank points | int | 20 | Skip crossings where the interpolator would be unreliable |
# Verification
- Every crossing opened. Profile the rebuilt DTM along the water centreline through each crossing; it should descend smoothly with no bump under the bridge.
- Deck counts plausible. A two-lane bridge 30 m long at 15 pts/m² holds roughly 5,000 deck points. Crossings with a handful of deck points are probably culverts or false positives.
- Approaches untouched. Class 17 points should not extend more than a few metres beyond the water edge along the road.
check = pdal.Pipeline(json.dumps({"pipeline": [
"valley_0310_bridges.laz", {"type": "filters.range", "limits": "Classification[17:17]"}]}))
print("deck points:", check.execute())# Gotchas and Edge Cases
Culverts. A road over a culvert has no gap beneath it; the ground really is continuous. Deck height tests correctly leave culverts as ground, but some specifications want a breakline or a channel cut through them in the DTM. That is a hydro-enforcement step, separate from classification.
Overpasses above roads. The terrain beneath an overpass is the lower road, which has its own ground points inside the crossing polygon. Use the lower road’s points as the reference surface, or restrict the deck test to points above the lower road by more than a vehicle height.
Missing or misaligned road data. Community road data can be offset by several metres. Buffer generously and rely on the height test to reject points that are not deck.
Wide multi-span bridges. Piers inside the river are real ground-level structures. Leave points on piers unclassified or classify them to class 19 or 17 according to your specification, but never to ground.
# Frequently Asked Questions
Why do ground filters classify bridges as ground?
Ground filters look for the lowest continuous surface, and a deck is continuous with the road on both approaches. Unless the span is long compared with the filter’s window, the filter cannot tell the deck from terrain.
What ASPRS class is used for bridge decks?
Class 17, bridge deck, defined in LAS 1.4. Points on the deck surface, including vehicles if not otherwise classified, typically go into this class so they can be excluded from the bare-earth DTM.
Do I need road data to find bridges?
It is not strictly required, but it makes detection far more reliable. Without it, bridges must be inferred from elevated flat surfaces spanning low ground, which also matches embankments, dams and some buildings.
What about overpasses that do not cross water?
Intersect roads with other roads and railways as well as water. The terrain beneath is the lower road, so use its points as the reference surface.
# Related
- Water and Bridge Classification — the full hydrologic workflow
- Hydro-Flattening Water Bodies in a DTM — flattening the river once decks are removed
- Classifying Water from Intensity and Returns — the water polygons used here
- Assigning Classification with Conditional filters.assign — WHERE-clause reclassification
- SMRF vs PMF for Dense Urban LiDAR — why ground filters struggle with bridges