How to Burn a Known River Network into a DEM
Problem statement
Channels derived from a DEM do not always sit where the rivers are. A 30 m surface model puts tree canopy on valley floors, smooths narrow gorges and lets flow cut across meander necks; the derived stream wanders tens of metres from the mapped one, and some tributaries join the wrong river. Stream burning lowers the DEM along a mapped network so that flow routing follows it. It is a blunt tool: it fixes alignment where the mapped network is right and imposes errors where it is not.
Measured on the Esopus Creek basin, New York, burning the USGS NHDPlus High Resolution network (805.7 km of streams, artificial paths and connectors in the basin) into the 30 m Copernicus GLO-30 DEM and the 10 m 3DEP DEM, with streams defined at 1 kmยฒ:
- On GLO-30, only 70.5% of mapped streams of order 3 and above lay within 30 m of a derived channel. Lowering the mapped lines by 10 m raised that to 87.4%; WhiteboxTools'
fill_burnraised it to 96.6%. - The catchment barely changed: 490.46 kmยฒ unburned, 491.48 kmยฒ burned, against 497.3 kmยฒ published; overlap with the USGS basin polygon rose from IoU 0.9794 to 0.9840.
- A 2 m burn did little; 50 m aligned more than 10 m but gave the same catchment: 70.5%, 74.0%, 87.4% and 96.1% of major mapped streams were found with no burn and with 2, 10 and 50 m, while the catchment was 491.48 kmยฒ at 10 m and 491.50 kmยฒ at 50 m.
- On the 10 m 3DEP DEM burning was nearly redundant: 94.0% of order-3-and-above streams were already within 30 m, 97.5% after
fill_burn.
Quick answer
import numpy as np
import rasterio
from rasterio.features import rasterize
with rasterio.open("dem.tif") as src:
profile, dem = src.profile, src.read(1)
lines = rasterize(streams.to_crs(profile["crs"]).geometry, out_shape=dem.shape, transform=profile["transform"]).astype(bool)
burned = np.where(lines & (dem != profile["nodata"]), dem - 10, dem).astype(profile["dtype"])
with rasterio.open("dem_burned.tif", "w", **profile) as dst:
dst.write(burned, 1)
Then condition and route the burned DEM as usual. Burn only surface flow paths โ leave out pipelines and tunnels โ and check the result against the mapped network, not just the catchment area.
Step-by-step solution
1. Decide whether you need to burn
Burn when derived channels must match a mapped network: to attach model results to mapped reaches, to compute distance to a known river, or where the DEM is too coarse or too noisy to find channels. Measure the alignment first (Example 3). On the 10 m 3DEP DEM, 97.1% of derived channel cells were within 30 m of a mapped line without any burning.
2. Choose and clean the network
Use the lines water actually follows on the surface. NHDPlus HR in the basin included 8.54 km of pipeline, part of the Shandaken Tunnel that carries water between reservoirs underground. Burning it lowered 56,214 GLO-30 cells by up to 32.6 m along a route no surface water takes. It happened not to move the gauge's catchment, but it creates channels across ridges. Keep stream/river, artificial path and connector types; drop pipelines.
3. Match the network to the DEM's CRS and extent
Reproject the lines to the DEM's CRS and clip them to its extent. Lines that end exactly at the edge are fine; lines outside it are ignored by rasterisation.
4. Rasterise the lines
rasterize marks every cell a line passes through; the default marks only cells whose centres are touched by the line's rendering, all_touched=True marks more. A one-cell-wide channel is what D8 routing needs.
5. Lower the lines, not the whole valley
Subtract a fixed depth. On GLO-30, 2 m left derived channels almost as scattered as before: 86.9% of channel cells within 30 m of a mapped line and 74.0% of major mapped streams found. 10 m brought 96.1% and 87.4%; 50 m reached 100% and 96.1%, as good as fill_burn, without changing the catchment. The deeper the burn, the more the mapped network overrides the DEM, and very deep burns create artificial canyons that distort slope and HAND; go deep only where the network is trustworthy.
6. Or use a tool that burns and fills together
WhiteboxTools' fill_burn burns the network, fills depressions and routes consistently in one step, and took 0.4 s on GLO-30 and 3.7 s on 3DEP. It changed 68,472 GLO-30 cells and gave the best alignment of any method: 96.6% of mapped streams of order 3 and above within 30 m of a derived channel.
7. Condition and route the burned DEM
A burned DEM still has pits and flats. Fill or breach it, then compute flow directions and accumulation as for any DEM; see conditioning a DEM.
8. Check alignment and area
Compare two ways: how much of the derived network sits on mapped lines, and how much of the mapped network is found by derived channels. Check the catchment at a gauge too. On GLO-30, burning moved the Coldbrook catchment by 1 kmยฒ and raised IoU with the USGS basin polygon from 0.9794 to 0.9840; alignment is what changed.
Code examples
Example 1 โ burn at several depths and route each DEM
import os
import geopandas as gpd
import numpy as np
import rasterio
import whitebox
from pyproj import Transformer
from rasterio.features import rasterize
from shapely.geometry import Point
wbt = whitebox.WhiteboxTools()
wbt.set_verbose_mode(False)
wbt.set_working_dir(os.path.abspath("."))
gx, gy = Transformer.from_crs("EPSG:4269", "EPSG:32618", always_xy=True).transform(-74.2701944, 42.0144722)
basin = gpd.read_file("nldi_basin_01362500.geojson").to_crs(32618)
flowlines = gpd.read_file("nhdplushr_flowlines_esopus.gpkg").to_crs(32618)
network = flowlines[flowlines.ftype.isin([460, 558, 334])] # stream/river, artificial path, connector
with rasterio.open("esopus_glo30_utm.tif") as src:
profile, dem, transform = src.profile, src.read(1), src.transform
cell = transform.a
in_basin = rasterize(basin.geometry, out_shape=dem.shape, transform=transform).astype(bool)
def evaluate(dem_path, tag):
wbt.fill_depressions(dem_path, f"{tag}_filled.tif", fix_flats=True)
wbt.d8_pointer(f"{tag}_filled.tif", f"{tag}_d8.tif")
wbt.d8_flow_accumulation(f"{tag}_d8.tif", f"{tag}_acc.tif", out_type="cells", pntr=True)
with rasterio.open(f"{tag}_acc.tif") as src:
acc = src.read(1)
col, row = ~transform * (gx, gy)
r, c, k = int(row), int(col), int(150 / cell)
window = acc[r - k:r + k + 1, c - k:c + k + 1]
dr, dc = np.unravel_index(window.argmax(), window.shape)
gpd.GeoDataFrame(geometry=[Point(*(transform * (c - k + dc + 0.5, r - k + dr + 0.5)))], crs=32618).to_file(f"{tag}_outlet.shp")
wbt.watershed(f"{tag}_d8.tif", f"{tag}_outlet.shp", f"{tag}_ws.tif")
with rasterio.open(f"{tag}_ws.tif") as src:
ws = src.read(1)
ws = (ws != src.nodata) & (ws > 0)
streams = (acc * cell * cell / 1e6 >= 1.0) & in_basin
return streams, window.max() * cell * cell / 1e6, (ws & in_basin).sum() / (ws | in_basin).sum()
lines = rasterize(network.geometry, out_shape=dem.shape, transform=transform).astype(bool) & (dem != profile["nodata"])
results = {"no burn": evaluate("esopus_glo30_utm.tif", "raw")}
for depth in (2, 10, 50):
with rasterio.open(f"burn_{depth}m.tif", "w", **profile) as dst:
dst.write(np.where(lines, dem - depth, dem).astype(profile["dtype"]), 1)
results[f"burn {depth} m"] = evaluate(f"burn_{depth}m.tif", f"burn{depth}")
for name, (_, km2, iou) in results.items():
print(f"{name:10} catchment {km2:.2f} km2 ({km2 / 497.3 - 1:+.1%}), IoU with the USGS basin {iou:.4f}")
no burn catchment 490.46 km2 (-1.4%), IoU with the USGS basin 0.9794
burn 2 m catchment 490.49 km2 (-1.4%), IoU with the USGS basin 0.9795
burn 10 m catchment 491.48 km2 (-1.2%), IoU with the USGS basin 0.9840
burn 50 m catchment 491.50 km2 (-1.2%), IoU with the USGS basin 0.9840
Example 2 โ WhiteboxTools fill_burn
import time
network[["geometry"]].to_file("nhd_network.shp")
start = time.perf_counter()
wbt.fill_burn("esopus_glo30_utm.tif", "nhd_network.shp", "fill_burn.tif")
seconds = time.perf_counter() - start
with rasterio.open("fill_burn.tif") as src:
changed = (np.abs(src.read(1) - dem) > 1e-3) & (dem != profile["nodata"])
results["fill_burn"] = evaluate("fill_burn.tif", "fillburn")
_, km2, iou = results["fill_burn"]
print(f"fill_burn {seconds:.1f} s, {changed.sum():,} cells changed; catchment {km2:.2f} km2, IoU {iou:.4f}")
fill_burn 0.4 s, 68,472 cells changed; catchment 491.48 km2, IoU 0.9840
Example 3 โ how well derived channels find the mapped network
from scipy.ndimage import distance_transform_edt
mapped = gpd.clip(flowlines[flowlines.ftype.isin([460, 558])], basin.geometry.make_valid())
samples = {}
for lowest in (1, 3):
points = [part.interpolate(d) for g in mapped[mapped.streamorde >= lowest].geometry
for part in getattr(g, "geoms", [g]) for d in np.arange(0, part.length, 10.0)]
samples[lowest] = (((np.array([p.y for p in points]) - transform.f) / -cell).astype(int),
((np.array([p.x for p in points]) - transform.c) / cell).astype(int))
mapped_cells = rasterize(mapped.geometry, out_shape=dem.shape, transform=transform, all_touched=True).astype(bool)
to_mapped = distance_transform_edt(~mapped_cells) * cell
for name, (streams, _, _) in results.items():
to_derived = distance_transform_edt(~streams) * cell
print(f"{name:10} derived cells within 30 m of mapped {np.mean(to_mapped[streams] <= 30):.1%}; mapped within 30 m of derived: "
f"all orders {np.mean(to_derived[samples[1]] <= 30):.1%}, order 3+ {np.mean(to_derived[samples[3]] <= 30):.1%}")
no burn derived cells within 30 m of mapped 84.2%; mapped within 30 m of derived: all orders 34.9%, order 3+ 70.5%
burn 2 m derived cells within 30 m of mapped 86.9%; mapped within 30 m of derived: all orders 37.1%, order 3+ 74.0%
burn 10 m derived cells within 30 m of mapped 96.1%; mapped within 30 m of derived: all orders 42.8%, order 3+ 87.4%
burn 50 m derived cells within 30 m of mapped 100.0%; mapped within 30 m of derived: all orders 45.0%, order 3+ 96.1%
fill_burn derived cells within 30 m of mapped 100.0%; mapped within 30 m of derived: all orders 45.4%, order 3+ 96.6%
The mapped network is sampled every 10 m, and each sample is checked against the distance to the nearest derived channel cell.
Explanation
Why derived channels miss mapped rivers
GLO-30 is a surface model: over forest its elevations include canopy, and a 30 m cell averages across narrow valley floors. The lowest cells in a valley may be on one bank, and flow can cut corners across meanders. The derived channel is then consistently offset from the mapped centreline โ a median of 30 m, one cell, on GLO-30 โ and, where a ridge is smoothed away, joins the wrong valley.
Why burning changes alignment more than area
Catchment area is set by divides, most of which lie far from the channels being burned. Burning moves where water runs inside the catchment, which changes which cells count as channels, but rarely which side of a ridge a cell drains to. On GLO-30 the gauge catchment moved by 0.2%; alignment on order-3-and-above streams rose by 26 percentage points.
Why low-order recall stays low
The comparison counts mapped streams of every order, and NHDPlus HR maps many first-order streams draining less than 1 kmยฒ. Channels defined at 1 kmยฒ cannot find them whatever the DEM: across all orders, 34.9% of mapped length was within 30 m of a derived channel unburned and 45.4% after fill_burn. The order-3-and-above figure is the fair measure of burning.
Why fill_burn beats a simple subtraction
A fixed-depth burn creates a trench whose bottom still follows the DEM's noisy profile, and conditioning afterwards has to fill the trench's own pits. fill_burn burns and then fills and routes so the burned network drains continuously downstream; on GLO-30 it matched the alignment of a 50 m trench, 96.6% against 96.1% of major mapped streams.
Edge cases or notes
- Outdated networks impose old channels where rivers have moved; check the network's date against the DEM's.
- Pipelines, tunnels and canals are not surface flow paths; exclude them unless you are modelling managed transfers.
- Braided or looping lines create multiple burned paths; D8 can follow only one.
- Network gaps at culverts or confluences leave burned segments that do not connect; snap and dissolve lines first.
- Deep burns distort slope, HAND and anything else computed from the burned DEM; keep the unburned DEM for those.
- Walls โ raising the DEM along mapped divides โ are the complement of burning where catchment boundaries are known.
- Resolution mismatch: a network mapped at 1:24,000 is finer than a 30 m DEM can represent; burning forces it anyway.
Internal links
- How to extract a stream network from a DEM โ measuring derived against mapped streams
- How to condition a DEM for hydrology: breaching or filling โ what to run after burning
- How DEM resolution and source change a drainage network โ why GLO-30 needs burning more
- How to delineate a watershed from a pour point in Python โ catchments from burned DEMs
- Fixing streams that stop at roads, bridges and embankments โ burning at crossings only
- How to calculate height above nearest drainage (HAND) in Python โ a product distorted by deep burns
- Stream order explained: Strahler, Shreve and what they measure โ the orders used in the comparison
- How to rasterize a vector layer in Python โ turning lines into cells
FAQ
What is stream burning in a DEM?
Lowering the DEM's elevations along a mapped river network so that flow routing follows the mapped channels. Lowering NHDPlus HR lines by 10 m in GLO-30 raised aligned major streams from 70.5% to 87.4%.
How deep should I burn streams into a DEM?
Deep enough to beat the relief across the valley floor. On GLO-30, 2 m was too little; 10 m found 87.4% of major mapped streams and 50 m 96.1%, with the same catchment. Deeper burns override the DEM more, so use them only where the mapped network is reliable.
Does stream burning change watershed area?
Usually only slightly. The Esopus catchment on GLO-30 went from 490.46 to 491.48 kmยฒ; burning mainly changes where channels run inside the catchment.
Should I include pipelines when burning streams?
No. The Shandaken Tunnel in NHDPlus HR would have lowered 56,214 GLO-30 cells by up to 32.6 m along an underground route.
Is stream burning needed for a 10 m DEM?
Often not. On the 10 m 3DEP DEM, 94.0% of major mapped streams were already within 30 m of a derived channel; fill_burn raised it to 97.5%.
What does WhiteboxTools FillBurn do?
It burns a vector stream network into a DEM and fills the result so the network drains continuously. It took 0.4 s on the 30 m DEM and aligned 96.6% of major mapped streams.