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.

Deck versus the terrain beneath it A profile along a road crossing a stream. Road points on the approaches are ground. Deck points across the span sit several metres above a dashed line interpolated between the two banks, which represents the terrain beneath the bridge. Points above that line by more than 1.5 metres inside the crossing polygon are reclassified as bridge deck. 5.8 m above terrain → class 17 road, class 2 terrain beneath, interpolated from the banks approach points stay ground; only deck points inside the crossing polygon move

# 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.overlay and filters.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

python
"""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
Where the geometry says a bridge can be Plan view. A river runs diagonally. A road crosses it horizontally. The crossing polygon is a rectangle along the road extending 15 metres beyond each bank. Around it, a ring 20 metres wide excluding the road samples bank ground points used to interpolate the terrain beneath the deck. crossing polygon bank ring sampled for terrain river road

# 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.
python
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.

Culverts are not bridges A profile of a road embankment with a small pipe culvert beneath it. The road surface is continuous with the embankment and less than 1.5 metres above the interpolated bank terrain, so the height test leaves it as ground. A note says hydro-enforcement, not reclassification, is the fix for drainage through culverts. road on embankment: stays class 2 culvert pipe drainage through the culvert needs a channel burned into the DTM, not a class change

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.