Classifying Water from Intensity and Returns
TL;DR: Grid the tile at 2 m and compute four per-cell cues — return density relative to the tile median, median intensity, share of single returns, and elevation range — combine them into a water score, keep large low-score-free regions adjacent to ground, polygonize them, and set class 9 on ground and unclassified points inside with filters.overlay and filters.assign.
# Context and Motivation
This guide is part of Water and Bridge Classification in LiDAR. The parent workflow uses return density alone, which works for large calm lakes but struggles with small ponds, rivers flown at low sun angle, and wind-roughened water that scatters more returns. Combining several weak cues produces a far more reliable detector, because each false positive fails a different test: dark asphalt has low intensity but normal density; shadow behind a building has low density but is not flat; a flat roof is flat and single-return but is elevated.
This matters most where no reliable hydrography exists — new reservoirs, gravel pits, seasonal ponds, or projects in regions where national water layers are coarse or out of date.
# Prerequisites and Assumptions
- A tile with ground classified and noise removed; intensity populated and roughly consistent across flightlines (see normalizing intensity across flightlines).
- Python with NumPy, SciPy, rasterio, GeoPandas and PDAL bindings.
- A minimum water body size from the project specification.
# Step-by-Step Implementation
# Step 1 — Grid the cues
For every 2 m cell, compute the return count, median intensity, share of single returns (NumberOfReturns == 1), and the elevation range of ground-or-unclassified points.
# Step 2 — Score each cell
Convert each cue to a 0–1 score and average them. Empty cells — the strongest water evidence — get a density score of 1 and neutral scores for the cues they cannot measure.
# Step 3 — Threshold and clean
Keep cells with a score above 0.7, then open to remove specks and close to fill scattered surface returns.
# Step 4 — Keep only regions at ground level
Require that each region’s boundary cells sit within about 1 m of the ground surface around it, which rejects flat roofs.
# Step 5 — Burn into points
Polygonize the regions, drop those below the minimum area, and assign class 9 to ground and unclassified points inside.
# Complete Working Example
"""Multi-cue water detection on a 2 m grid, burned back into points as class 9."""
from __future__ import annotations
import json
from pathlib import Path
import geopandas as gpd
import numpy as np
import pdal
import rasterio.features
from rasterio.transform import from_origin
from scipy import ndimage as ndi
from shapely.geometry import shape
CELL = 2.0
def grid_cues(a: np.ndarray) -> tuple[dict[str, np.ndarray], tuple[float, float, int, int]]:
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)
idx = r * cols + c
n = np.bincount(idx, minlength=rows * cols)
single = np.bincount(idx, weights=(a["NumberOfReturns"] == 1), minlength=rows * cols)
inten = np.bincount(idx, weights=a["Intensity"], minlength=rows * cols)
low = np.isin(a["Classification"], [1, 2])
zmin = np.full(rows * cols, np.inf); zmax = np.full(rows * cols, -np.inf)
np.minimum.at(zmin, idx[low], a["Z"][low]); np.maximum.at(zmax, idx[low], a["Z"][low])
with np.errstate(invalid="ignore", divide="ignore"):
cues = {"n": n, "single": single / n, "intensity": inten / n, "zrange": zmax - zmin,
"zmin": zmin}
return {k: v.reshape(rows, cols) for k, v in cues.items()}, (x0, y1, rows, cols)
def water_score(cues: dict[str, np.ndarray]) -> np.ndarray:
med_n = np.median(cues["n"][cues["n"] > 0])
med_i = np.nanmedian(cues["intensity"])
s_density = np.clip(1.0 - cues["n"] / (0.3 * med_n), 0, 1)
s_int = np.where(np.isnan(cues["intensity"]), 0.5, np.clip(1 - cues["intensity"] / med_i, 0, 1))
s_single = np.where(np.isnan(cues["single"]), 0.5, cues["single"])
s_flat = np.where(np.isfinite(cues["zrange"]), np.clip(1 - cues["zrange"] / 0.5, 0, 1), 0.5)
return (2 * s_density + s_int + s_single + s_flat) / 5.0 # density weighted double
def water_polygons(src: Path, crs: str, min_area: float = 8000.0) -> gpd.GeoDataFrame:
p = pdal.Pipeline(json.dumps({"pipeline": [str(src)]}))
p.execute()
cues, (x0, y1, rows, cols) = grid_cues(p.arrays[0])
mask = water_score(cues) > 0.7
mask = ndi.binary_closing(ndi.binary_opening(mask, iterations=1), iterations=3)
labels, n = ndi.label(mask)
ground = np.where(np.isfinite(cues["zmin"]), cues["zmin"], np.nan)
keep = np.zeros_like(mask)
for lab in range(1, n + 1):
region = labels == lab
ring = ndi.binary_dilation(region, iterations=3) & ~region
inner_edge = region & ~ndi.binary_erosion(region)
if np.nanmedian(ground[ring]) - np.nanmedian(ground[inner_edge]) > -1.0:
keep |= region # sits at ground level
transform = from_origin(x0, y1, CELL, CELL)
polys = [shape(g) for g, v in rasterio.features.shapes(keep.astype("uint8"), mask=keep,
transform=transform)]
gdf = gpd.GeoDataFrame({"geometry": polys}, crs=crs)
gdf = gdf[gdf.area >= min_area].reset_index(drop=True)
gdf["WaterId"] = np.arange(1, len(gdf) + 1)
return gdf
def burn(src: Path, dst: Path, water: gpd.GeoDataFrame) -> None:
gpkg = dst.with_suffix(".water.gpkg")
water.to_file(gpkg, layer="water", driver="GPKG")
pdal.Pipeline(json.dumps({"pipeline": [
str(src),
{"type": "filters.ferry", "dimensions": "=>WaterId"},
{"type": "filters.overlay", "dimension": "WaterId", "datasource": str(gpkg),
"layer": "water", "column": "WaterId"},
{"type": "filters.assign", "value": [
"Classification = 9 WHERE WaterId > 0 && (Classification == 1 || Classification == 2)"]},
{"type": "writers.las", "filename": str(dst), "minor_version": 4,
"dataformat_id": 6, "forward": "all"},
]})).execute()
if __name__ == "__main__":
src = Path("valley_0310.laz")
water = water_polygons(src, "EPSG:6341")
print(f"{len(water)} water bodies, {water.area.sum() / 1e4:.1f} ha")
burn(src, Path("valley_0310_water.laz"), water)# Key Parameter Table
| Parameter | Type | Default | Guidance |
|---|---|---|---|
CELL |
float, m | 2.0 | 1 m for narrow channels at high density; 3–5 m for sparse data |
| density reference | fraction | 0.3 × median | Cells above 30 % of normal density score zero for this cue |
| flatness range | float, m | 0.5 | Elevation range at which a cell stops counting as flat |
| density weight | int | 2 | The most reliable cue counts double |
| score threshold | float | 0.7 | Lower finds more small ponds and more false positives |
| ground-level tolerance | float, m | 1.0 | Region edge must sit within this of surrounding ground |
# Verification
- Against imagery. Overlay polygons on an orthophoto; every large detected region should be visibly water.
- Against hydrography. Where national or client water layers exist, compute the share of their area covered by detections, and the share of detected area outside them.
- No elevated water. Class 9 points should have near-zero height above surrounding ground. Any class 9 point more than a metre above its neighbours’ ground indicates a roof region slipped through.
w = gpd.read_file("valley_0310_water.water.gpkg", layer="water")
assert (w.area >= 8000).all()
print(w.area.describe())# Gotchas and Edge Cases
Specular returns at nadir. Directly below the aircraft, calm water can reflect strongly, producing a stripe of dense, bright returns down the middle of a lake. The closing step fills it when the stripe is narrow; for wide stripes, use per-flightline processing or accept the stripe as water because it is surrounded by water.
Uncalibrated intensity. If intensity differs strongly between flightlines, the intensity cue becomes a flightline detector. Normalize first or drop the cue.
Wetlands and saturated ground. Marshes return weakly and are flat; they will score as water. Whether that is right depends on the specification — decide explicitly and document it.
Tile edges. A lake cut by a tile edge may fall below the minimum area on each side. Detect on a mosaic of grids, or merge polygons across tiles before applying the area filter.
# Frequently Asked Questions
Why not rely on intensity alone to find water?
Intensity depends on range, incidence angle and sensor calibration, and many dry surfaces such as fresh asphalt and dark roofing are just as dark. It helps as one cue among several but produces too many false positives alone.
What does it mean when water has dense bright returns?
Near nadir, calm water reflects the pulse straight back like a mirror, producing strong returns in a narrow stripe under the flight path. Everywhere else the pulse is reflected away or absorbed, so returns are sparse.
Which points should be set to class 9?
Ground and unclassified returns inside the water polygon. Returns from boats, docks and overhanging vegetation should keep their classes. Specifications differ, so confirm the rule for your project.
Can this detect narrow rivers?
Rivers wider than a few cells at the chosen resolution, if they are open to the sky. Tree-lined streams retain canopy returns and rarely look like water from above; they need breaklines or centreline data.
# Related
- Water and Bridge Classification — the overall workflow
- Hydro-Flattening Water Bodies in a DTM — what to do with the polygons
- Classifying Bridge Decks — the other hydrologic class
- Normalizing Intensity Across Flightlines — making the intensity cue trustworthy
- Building a Point Density Raster with PDAL — the density grid as a raster