Breaching Road Embankments and Culverts with WhiteboxTools
Breaching is the conditioning edit that removes an obstruction instead of drowning the ground behind it, and BreachDepressionsLeastCost is the most practical implementation available from Python. It finds, for each depression, the cheapest path out — cheapest meaning the least total elevation that has to be cut — and carves that path rather than raising the depression to its spill point. This guide covers its parameters and the ways it goes wrong, as part of the hydro-conditioning for culverts and road crossings topic within hydrology data preparation and DEM processing.
Prerequisites
whiteboxinstalled and able to download its binary on first run (pip install whitebox, then one call to warm the cache).- A DEM in a projected CRS with square cells — all distances are specified in cells and converted mentally to metres.
- Knowledge of the tallest embankment in the area of interest, which sets
max_cost.
Core Technique: Least-Cost Breaching
For each depression the tool identifies the pour point, then searches outward for a cell lower than the depression’s minimum. Every candidate path has a cost equal to the total elevation that would have to be removed along it. The cheapest path within the distance limit wins, and the tool carves it.
The behaviour that surprises people is path C. A long path around the end of an embankment can have a lower cost than a short path through it, because cost counts elevation, not distance. The dist parameter is what stops the tool from preferring it — and setting dist too large is how a breach ends up running two hundred metres along a ditch line instead of through the culvert.
Annotated Code Example
import logging
import os
import numpy as np
import rasterio
from whitebox import WhiteboxTools
log = logging.getLogger(__name__)
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
def breach_then_fill(
dem_path: str,
out_dir: str,
dist_cells: int = 40,
max_cost_m: float = 6.0,
min_dist: bool = True,
flat_increment: float = 0.001,
) -> dict:
"""
Breach what can be breached, then fill the residue.
Parameters
----------
dem_path : Input DEM (projected CRS, square cells).
out_dir : Working directory for intermediate and final rasters.
dist_cells : Maximum breach search radius, in cells.
max_cost_m : Maximum total elevation a single path may cut through.
min_dist : Prefer the shortest path among those within max_cost.
flat_increment : Epsilon gradient applied to flats by the fill pass.
Returns
-------
Dict of output paths and the depression counts at each stage.
"""
os.makedirs(out_dir, exist_ok=True)
wbt = WhiteboxTools()
wbt.set_working_dir(os.path.abspath(out_dir))
wbt.verbose = False # the tool is extremely chatty by default
dem_abs = os.path.abspath(dem_path)
breached = "dem_breached.tif"
conditioned = "dem_conditioned.tif"
sinks_before = "sinks_before.tif"
sinks_after = "sinks_after.tif"
# --- Count depressions before, so the effect is measurable rather than
# assumed. Sink() labels each closed depression with a unique id. ---
wbt.sink(dem_abs, sinks_before)
n_before = _count_labels(os.path.join(out_dir, sinks_before))
log.info("Depressions before conditioning: %d", n_before)
# --- Breach. min_dist=True makes the tool prefer the shortest of the
# acceptable paths, which keeps a breach on the culvert line rather than
# letting it wander along a ditch to a marginally cheaper exit. ---
log.info("Breaching with dist=%d cells, max_cost=%.2f m", dist_cells, max_cost_m)
wbt.breach_depressions_least_cost(
dem=dem_abs,
output=breached,
dist=dist_cells,
max_cost=max_cost_m,
min_dist=min_dist,
flat_increment=flat_increment,
fill=False, # fill separately so each stage is auditable
)
wbt.sink(os.path.join(out_dir, breached), sinks_after)
n_after = _count_labels(os.path.join(out_dir, sinks_after))
log.info("Depressions after breaching: %d (%.1f %% removed)",
n_after, 100.0 * (n_before - n_after) / max(1, n_before))
# --- Fill the residue. Anything the breach could not reach within its
# limits is either a genuine closed basin or a structure taller than
# max_cost, and filling is the right treatment for both. ---
wbt.fill_depressions(
dem=os.path.join(out_dir, breached),
output=conditioned,
fix_flats=True,
flat_increment=flat_increment,
)
log.info("Filled the residual depressions → %s", conditioned)
# --- Report how much terrain actually moved. A conditioning run that
# changed millions of cells has flooded something. ---
changed, max_cut, max_raise = _difference_stats(
dem_abs, os.path.join(out_dir, conditioned)
)
log.info("Cells modified: %d; deepest cut %.2f m; highest fill %.2f m",
changed, max_cut, max_raise)
return {
"breached": os.path.join(out_dir, breached),
"conditioned": os.path.join(out_dir, conditioned),
"depressions_before": n_before,
"depressions_after_breach": n_after,
"cells_modified": changed,
}
def _count_labels(path: str) -> int:
with rasterio.open(path) as src:
arr = src.read(1)
nodata = src.nodata
valid = arr[arr != nodata] if nodata is not None else arr
return int(np.unique(valid[valid > 0]).size)
def _difference_stats(before_path: str, after_path: str):
with rasterio.open(before_path) as a, rasterio.open(after_path) as b:
x = a.read(1).astype("float64")
y = b.read(1).astype("float64")
nodata = a.nodata
m = np.isfinite(x) & np.isfinite(y)
if nodata is not None:
m &= x != nodata
d = np.where(m, y - x, 0.0)
return int((np.abs(d) > 1e-6).sum()), float(-d.min()), float(d.max())
# --- Example usage ---
# breach_then_fill("basin_1m.tif", "./conditioning", dist_cells=40, max_cost_m=6.0)
Parameter Reference
| Parameter | Typical value | Effect |
|---|---|---|
dist |
25–60 cells | Must exceed the widest obstruction in cells. Larger values slow the tool sharply and allow implausible paths |
max_cost |
3–8 m | Separates embankments from dams. Depressions needing a deeper cut are left for the fill pass |
min_dist |
True |
Prefers the shortest acceptable path, keeping the breach on the crossing rather than around it |
flat_increment |
0.001 m | Epsilon gradient on flats, so the breached channel is not a flat surface |
fill |
False |
Keep the stages separate so each is measurable; run fill_depressions explicitly afterwards |
Worked Example: Output Interpretation
A 1 m LiDAR DEM over a 60 km² rural basin with dist=40, max_cost=6.0:
| Stage | Depressions | Cells modified | Deepest cut |
|---|---|---|---|
| Original DEM | 18 420 | — | — |
| After breaching | 1 490 | 41 300 | 5.8 m |
| After fill | 0 | 218 000 | — |
Read those numbers as a set. Breaching removed 92 % of depressions by modifying 41 000 cells — a tiny fraction of a 60-million-cell grid, which is what a targeted edit should look like. The fill pass then modified far more cells, but almost all of that is the epsilon gradient applied across flats, not real flooding. A deepest cut of 5.8 m sits just under max_cost, which means at least one structure was at the limit and worth inspecting manually.
If the breaching stage had modified two million cells, the cause would be max_cost set high enough to carve through dam crests, and the reservoirs would be gone.
The two parameters interact, and the interaction is what decides whether a run targets structures or reshapes the landscape. Reading them as a grid makes the safe corner obvious.
Gotchas and Edge Cases
distin cells, embankments in metres. On a 1 m DEM a 25 m road needsdistabove 25; on a 10 m DEM the same road needsdistabove 3. Converting in the wrong direction is the most common configuration error.max_costabove the dam height. Reservoirs drain silently. Compare depression volume before and after, split by whether a mapped waterbody sits inside.min_dist=Falsewith a largedist. Breaches wander along ditches to marginally cheaper exits, producing channels that do not correspond to any structure on the ground.- Skipping the fill pass. Breaching does not guarantee a depressionless surface. The flow router will produce sinks wherever a depression survived.
flat_incrementleft at zero. The breached channel becomes a flat, and flow direction across it is arbitrary — see removing flat area artifacts from flow direction grids.- Relative paths. WhiteboxTools resolves outputs against its working directory but inputs against the current process directory. Passing absolute paths for inputs and bare names for outputs, as above, avoids the most common source of “file not found” on a tool that has already run.
Related Topics
- Hydro-Conditioning for Culverts and Road Crossings — the parent topic: classification, ordering and validation of the whole conditioning step
- Burning Stream Networks into DEMs with the AGREE Method — the global alternative, for grids that cannot resolve the channel at all
- Validating Hydro-Conditioning Against NHD Flowlines — proving the breaches did what was intended
- Best Practices for Filling Sinks in High-Resolution LiDAR Data — the fill pass that follows, at sub-metre resolution