Water and Bridge Classification in LiDAR
Water and bridges are the two classes that decide whether a LiDAR terrain model is usable for hydrology. Near-infrared lasers are largely absorbed by water, so lakes and rivers come back as sparse, noisy returns — or no returns at all — and a DTM interpolated across them ripples with artefacts. Bridges are the opposite problem: they are solid, flat and connected to the road at both ends, so ground filters routinely classify decks as ground, and the resulting DTM dams every river at every crossing. Delivery specifications such as the USGS Lidar Base Specification require water classified as class 9, bridge decks as class 17, and water bodies above a size threshold flattened to a single elevation in the DEM. This topic, part of the classification and feature extraction section, covers all three.
# Prerequisites
- PDAL 2.5+ with
filters.overlay,filters.hag_nn,filters.covariancefeatures,filters.smrfandwriters.gdal, plus the Python bindings. - Python 3.10+ with NumPy, GeoPandas, Shapely 2.x and rasterio.
- Ground classified, with the understanding that some decks and some water returns will be in class 2 and must be corrected.
- Water body and road polygons, if available. National hydrography layers, OpenStreetMap water and bridge ways, or a client breakline set make both classes far more reliable than detection from the points alone.
- Point source IDs populated, because per-flightline behaviour over water (specular returns at nadir) matters for diagnostics.
- A projected CRS in metres, the same one the breaklines use, or reprojection of the polygons before overlay.
# Core Workflow Architecture
- Find water candidates. Rasterize a return-count grid; cells inside large areas of very low or zero return density, adjacent to ground, are water candidates. Low intensity and flatness strengthen the evidence.
- Build water polygons. Polygonize the candidate cells, clean them morphologically, and merge with any supplied hydrography; drop polygons below the specification’s minimum size.
- Classify water. Burn polygons into the points with
filters.overlayand set class 9 for ground and unclassified points inside them. - Find bridge decks. Within road-over-water or road-over-road crossings, points classified as ground that sit well above the interpolated terrain on either side are deck candidates.
- Classify bridges. Set class 17 on deck points, removing them from the ground used for the DTM.
- Hydro-flatten. Build the DTM from class 2 only, then burn each water polygon at a single elevation derived from its shoreline.
# Full Implementation
"""Water (class 9) and bridge deck (class 17) classification, with hydro-flattening."""
from __future__ import annotations
import json
import logging
from pathlib import Path
import geopandas as gpd
import numpy as np
import pdal
import rasterio
import rasterio.features
from rasterio.transform import from_origin
from scipy import ndimage as ndi
from shapely.geometry import shape
log = logging.getLogger("hydro")
def water_candidates(src: Path, cell: float = 2.0, min_area_m2: float = 8000.0,
crs: str = "EPSG:6341") -> gpd.GeoDataFrame:
"""Polygons of near-empty cells large enough to count as water bodies."""
p = pdal.Pipeline(json.dumps({"pipeline": [str(src)]}))
p.execute()
a = p.arrays[0]
x0, y1 = np.floor(a["X"].min()), np.ceil(a["Y"].max())
cols = int(np.ceil((a["X"].max() - x0) / cell))
rows = int(np.ceil((y1 - a["Y"].min()) / cell))
c = np.clip(((a["X"] - x0) / cell).astype(int), 0, cols - 1)
r = np.clip(((y1 - a["Y"]) / cell).astype(int), 0, rows - 1)
counts = np.zeros((rows, cols), dtype=np.int32)
np.add.at(counts, (r, c), 1)
expected = np.median(counts[counts > 0])
empty = counts < max(1, 0.1 * expected) # under 10 % of typical density
empty = ndi.binary_opening(empty, iterations=2) # drop isolated shadows
empty = ndi.binary_closing(empty, iterations=3) # join returns scattered on the surface
transform = from_origin(x0, y1, cell, cell)
polys = [shape(g) for g, v in rasterio.features.shapes(
empty.astype(np.uint8), mask=empty, transform=transform) if v == 1]
gdf = gpd.GeoDataFrame({"geometry": polys}, crs=crs)
gdf = gdf[gdf.area >= min_area_m2].reset_index(drop=True)
gdf["WaterId"] = np.arange(1, len(gdf) + 1)
log.info("%d water candidates >= %.0f m²", len(gdf), min_area_m2)
return gdf
def classify(src: Path, dst: Path, water: gpd.GeoDataFrame, crs: str = "EPSG:6341") -> None:
water_gpkg = dst.with_suffix(".water.gpkg")
water.to_file(water_gpkg, layer="water", driver="GPKG")
stages = [
{"type": "readers.las", "filename": str(src), "override_srs": crs},
{"type": "filters.ferry", "dimensions": "=>WaterId"},
{"type": "filters.overlay", "dimension": "WaterId",
"datasource": str(water_gpkg), "layer": "water", "column": "WaterId"},
{"type": "filters.assign", "value": [
"Classification = 9 WHERE WaterId > 0 && (Classification == 1 || Classification == 2)"
]},
# Bridge decks: ground-classified points far above the surrounding terrain.
{"type": "filters.hag_nn", "count": 6, "max_distance": 60.0,
"where": "Classification == 2"},
{"type": "filters.assign", "value": [
"Classification = 17 WHERE Classification == 2 && HeightAboveGround > 2.5"
]},
{"type": "writers.las", "filename": str(dst), "minor_version": 4,
"dataformat_id": 6, "forward": "all"},
]
n = pdal.Pipeline(json.dumps({"pipeline": stages})).execute()
log.info("%s: %d points written", dst.name, n)
def hydro_flatten(dtm_path: Path, water: gpd.GeoDataFrame, out_path: Path) -> None:
"""Burn each water polygon at the 5th percentile of its shoreline elevation."""
with rasterio.open(dtm_path) as ds:
dtm = ds.read(1, masked=True).filled(np.nan)
profile, transform = ds.profile, ds.transform
for poly in water.geometry:
ring = poly.exterior.buffer(3.0).difference(poly)
shore = rasterio.features.geometry_mask([ring], dtm.shape, transform, invert=True)
inside = rasterio.features.geometry_mask([poly], dtm.shape, transform, invert=True)
z = np.nanpercentile(dtm[shore], 5)
dtm[inside] = z
profile.update(nodata=-9999.0)
with rasterio.open(out_path, "w", **profile) as out:
out.write(np.nan_to_num(dtm, nan=-9999.0).astype(profile["dtype"]), 1)
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
src = Path("valley_0310.laz")
water = water_candidates(src)
classify(src, Path("valley_0310_hydro.laz"), water)# Code Breakdown
Return count as the water signal. A grid of return counts is the most reliable single piece of evidence for water in near-infrared data: open water returns a small fraction of the density of the land around it. Ten percent of the tile’s median cell count is a conservative threshold; wind-roughened water returns more, calm water less.
Opening then closing. Opening removes small empty patches — shadows behind buildings, dark roofs, missing returns under dense canopy — that are not water. Closing then fills the scattered surface returns inside a lake so the polygon is solid. The order matters: closing first would join shadows into lakes.
8,000 m² minimum. The USGS Lidar Base Specification requires hydro-flattening for inland ponds and lakes of about two acres or more, which is roughly 8,100 m²; it treats rivers above a nominal width of 30 m (100 ft) similarly. Use your contract’s thresholds, and prefer supplied breaklines to derived polygons where you have them.
filters.overlay into a scratch dimension. Overlay writes the polygon’s WaterId onto every point inside it. The dimension has to exist first, which is what filters.ferry with =>WaterId does. Keeping the ID lets you trace any point back to its water body later.
Only classes 1 and 2 become water. Points of overhanging vegetation, docks and boats inside the polygon keep their classes. Specifications differ on whether to relabel above-water points; the conservative default is to leave them.
Bridges from ground above ground. After water is removed from class 2, filters.hag_nn with a where clause computes, for each remaining ground point, its height above its nearest ground neighbours — with max_distance: 60 so the neighbours are drawn from the banks, not only from the deck itself. Deck points sit several metres above those neighbours. This heuristic works because SMRF has already classified most of the terrain correctly; the bridge deck guide shows a more robust version using road polygons.
Shoreline percentile for flattening. The water surface should sit at or just below the lowest bank, so the 5th percentile of DTM values in a 3 m ring around each polygon is used. Taking the minimum would chase one bad cell; the mean would put the water above parts of the bank.
The flattening step deserves a picture of its own, because the choice of where to sample the water elevation is what makes the shoreline look right or wrong in a hillshade.
# Parameter Reference Table
| Parameter | Type | Default here | Typical range | Effect |
|---|---|---|---|---|
cell (density grid) |
float, m | 2.0 | 1–5 | Coarser is steadier but blunts narrow channels |
| density threshold | fraction | 0.10 | 0.05–0.25 | Fraction of median cell count below which a cell is empty |
| opening iterations | int | 2 | 1–4 | Removes shadows and dark roofs from candidates |
| closing iterations | int | 3 | 2–6 | Fills scattered surface returns inside lakes |
min_area_m2 |
float | 8000 | per spec | Smallest water body classified and flattened |
hag_nn count |
int | 6 | 3–12 | Ground neighbours used to judge a deck point |
hag_nn max_distance |
float, m | 60 | 30–150 | Must reach the banks across the widest deck |
| deck height | float, m | 2.5 | 1.5–5 | Minimum clearance of a deck above the terrain |
| shoreline ring | float, m | 3.0 | 1–10 | Width of the band sampled for water elevation |
# Validation and Integrity Checks
- Water polygons against imagery or hydrography. Overlay the derived polygons on orthophotos. False water usually appears as large flat dark roofs, fresh asphalt and deep shadows; false dry land appears where a lake was flown at an angle that returned strong specular points.
- No ground inside water. After classification, count class 2 points inside each polygon; it should be zero, or only a handful along the edge where the polygon overlaps the bank.
- Monotonic river surfaces. For flowing water, flattened elevations must decrease downstream. Sample the flattened DTM along the channel centreline and assert that each step is non-increasing. Lakes need only be flat; rivers need to be flat across and descending along.
- Deck removal lets flow through. Run a quick flow accumulation on the DTM, or simply profile across each bridge: the channel must be continuous beneath every crossing.
def check_downstream(dtm: np.ndarray, rows: np.ndarray, cols: np.ndarray, tol: float = 0.02) -> None:
profile = dtm[rows, cols] # centreline cells ordered upstream to downstream
rises = np.diff(profile) > tol
assert not rises.any(), f"water surface rises at {int(rises.sum())} steps along the river"# Performance Tuning
Water detection is raster work on a coarse grid and takes seconds per tile. The cost is in the classification pipeline, where filters.hag_nn with a large max_distance can be slow if it runs on every point — which is why it is restricted to class 2 with a where clause. On tiles with no water and no roads crossing water, skip the bridge stage entirely; a quick intersection of the tile extent with a road-over-water layer decides that in milliseconds.
Water bodies straddle tiles far more often than buildings, and flattening a lake tile by tile produces steps at every tile edge. Derive water polygons on a whole-project mosaic of the density grid, or merge per-tile polygons before computing shoreline elevations, so each lake gets exactly one elevation.
# Common Errors and Troubleshooting
Dark roofs and fresh asphalt classified as water. They absorb near-infrared too. Require that candidate cells are adjacent to ground, not surrounded by buildings, and filter polygons by compactness — roofs are rectilinear, lakes are not.
Rivers broken into pieces. Narrow channels under overhanging trees retain canopy returns, so the density grid does not drop to zero there. Use supplied centrelines or breaklines for rivers; detection from density alone is reliable only for open water.
Steps in the flattened river. Each polygon was flattened independently. For rivers, flatten along the channel with a gradient — interpolate water elevation from upstream to downstream shoreline samples — rather than to one value.
Deck classified but the DTM still dams. The DTM was built with triangulation across the gap, which bridges the deck’s absence with a straight line at deck height from the approaches. Build it with an interpolation that respects breaklines, or burn the channel from the water polygon before hillshading and hydrology.
Bridge approach embankments labelled as deck. Embankments are genuinely above nearby ground. Restrict the deck test to points over water or over another road, using the water polygons or a road layer as a mask.
# Frequently Asked Questions
Why do water bodies have so few LiDAR returns?
Topographic LiDAR uses near-infrared light, which water absorbs strongly. Most pulses produce no detectable return, except where the beam strikes the surface nearly vertically and reflects straight back, or where waves, foam or debris scatter it. Bathymetric sensors use green light to penetrate water, but that is a different instrument.
What is hydro-flattening?
It is the practice of setting each water body in a DEM to a single flat elevation, or for rivers to a surface that descends smoothly downstream, and making the shoreline consistent with the surrounding terrain. It removes the interpolation noise over water that would otherwise appear as bumps and pits.
Should bridges be classified as ground?
No. ASPRS class 17 exists for bridge decks precisely so that terrain models can exclude them. A DTM that includes decks shows a dam at every crossing, which breaks drainage analysis and flood modelling.
Do I need breaklines, or can I derive water from the points?
Large open lakes can be derived reliably from return density. Rivers, especially narrow or tree-lined ones, are much better handled with supplied hydrography or manually digitized breaklines. Most production work combines both: derive, then correct against supplied layers.
# Related
- Classifying Water from Intensity and Returns — detecting water without supplied polygons
- Hydro-Flattening Water Bodies in a DTM — flat lakes, descending rivers, consistent shorelines
- Classifying Bridge Decks — robust deck detection with road polygons
- DTM Raster Generation — building the terrain model these classes feed
- Filling NoData Voids in DTM Rasters — the gaps water leaves behind