Removing flat area artifacts from flow direction grids

Removing flat area artifacts from flow direction grids requires preprocessing the digital elevation model (DEM) to eliminate zero-gradient cells before computing routing matrices. In Python, this is reliably achieved by applying a depression-filling algorithm followed by a flat-resolution step that imposes a minimal, deterministic gradient across contiguous flat zones. The richdem library provides a memory-efficient, C+±backed pipeline for this workflow via rd.FillDepressions and rd.ResolveFlats, while pysheds and whitebox offer alternative implementations for different scale requirements.

Why zero-gradient cells break hydrologic routing

Flat areas originate from LiDAR interpolation artifacts, DEM resampling, or genuine topographic features like floodplains, playas, and lake beds. When a flow direction algorithm encounters a cell with no downslope neighbor, routing halts, creating artificial sinks that break watershed delineation and corrupt accumulation matrices. This is particularly disruptive when implementing Multiple Flow Direction Methods, where proportional flow partitioning assumes continuous slope gradients. Unresolved flats propagate errors downstream, inflate computational overhead due to iterative sink checks, and ultimately compromise Flow Routing Algorithms & Stream Network Extraction pipelines.

Key failure modes include:

  • Routing dead-ends: D8/D∞ algorithms return null directions, leaving cells unassigned.
  • Accumulation underestimation: Flow paths terminate prematurely, skewing discharge calculations.
  • Network fragmentation: Stream extraction thresholds fail to trigger, producing disconnected channel heads.

Core algorithmic workflow

The standard resolution sequence follows three deterministic stages:

  1. Depression filling: Breaches or fills closed basins to ensure hydrologic connectivity. Modern implementations use priority-queue flood algorithms (e.g., Wang & Liu, 2006) that guarantee O(n log n) performance and preserve natural drainage divides.
  2. Flat resolution: Identifies contiguous zero-slope regions and imposes a micro-gradient toward the nearest downslope outlet. The gradient magnitude must be small enough to avoid distorting real topography, but large enough to survive floating-point precision limits.
  3. Direction computation: Calculates flow vectors on the modified surface. The output raster encodes direction codes (1–8 for D8, continuous angles for D∞, or fractional weights for MFD).

For authoritative implementation details, consult the RichDEM documentation and the WhiteboxTools hydrological analysis documentation.

Production-ready Python implementation

The following function chains depression filling, flat resolution, and flow direction computation using richdem for the core terrain processing and rasterio for geospatial I/O. richdem’s ResolveFlats function internally applies the epsilon-gradient approach described by Barnes et al. (2014), which is more robust than a fixed perturbation value.

python
import rasterio
import numpy as np
import richdem as rd
from pathlib import Path

def remove_flat_artifacts(
    dem_path: str,
    output_dem_path: str,
    output_fd_path: str,
    fd_method: str = 'D8'
) -> tuple[str, str]:
    """
    Fills depressions, resolves flat areas, and computes a clean flow direction grid.
    Returns paths to the resolved DEM and flow direction raster.
    """
    dem_path = Path(dem_path)
    if not dem_path.exists():
        raise FileNotFoundError(f"DEM not found: {dem_path}")

    # Load metadata for output writing
    with rasterio.open(dem_path) as src:
        profile = src.profile.copy()
        crs = src.crs
        transform = src.transform
        nodata = src.nodata if src.nodata is not None else -9999.0

    # Load DEM into richdem
    dem = rd.LoadGDAL(str(dem_path))
    dem.no_data = nodata

    # 1. Fill depressions (priority-flood algorithm)
    filled = rd.FillDepressions(dem, in_place=False)

    # 2. Resolve flat areas (imposes minimal epsilon-gradients to break ties)
    resolved = rd.ResolveFlats(filled, in_place=False)

    # 3. Compute flow direction on the resolved surface
    method_key = 'Dinf' if fd_method.upper() in ('DINF', 'D-INF') else 'D8'
    flow_dir = rd.FlowDirection(resolved, method=method_key)

    # Write resolved DEM
    resolved_np = np.array(resolved, dtype=np.float32)
    profile.update(dtype='float32', count=1, nodata=nodata)
    with rasterio.open(output_dem_path, 'w', **profile) as dst:
        dst.write(resolved_np, 1)

    # Write flow direction raster
    fd_np = np.array(flow_dir)
    fd_profile = profile.copy()
    fd_profile.update(dtype=str(fd_np.dtype), nodata=flow_dir.no_data)
    with rasterio.open(output_fd_path, 'w', **fd_profile) as dst:
        dst.write(fd_np, 1)

    return output_dem_path, output_fd_path

Validation & troubleshooting checklist

Before integrating resolved grids into hydrologic models, verify output integrity:

  • Residual flat count: After computing D8 flow direction, np.sum(flow_dir_array == 0) (where 0 indicates no direction assigned) should approach zero. Values >0.1% indicate unresolved lakes or masked boundaries requiring preprocessing.
  • Direction code distribution: D8 outputs should contain values 1–8 plus the nodata value. Unexpected zeros or negatives signal array misalignment or a failed flat-resolution step.
  • Gradient check after resolution: Compute np.nanmin(np.gradient(resolved_dem)[0]) and np.nanmin(np.gradient(resolved_dem)[1]). Within formerly flat regions, values should be non-zero and consistent in sign (always draining toward the outlet).
  • Lake masking: Genuine water bodies should be excluded from flat resolution. Apply a permanent water mask (e.g., JRC Global Surface Water) before running flat resolution to prevent artificial drainage through lakes.
  • Edge handling: Ensure nodata values are correctly propagated at DEM boundaries to avoid directional artifacts at edges.

Scaling to large rasters

Array-native Python workflows excel at rapid prototyping but encounter memory ceilings with continental-scale DEMs (>10 GB). For production pipelines:

  1. Windowed processing: Use rasterio’s block_shapes and windowed reading to process tiles sequentially. Stitch outputs with overlapping buffers (≥500 m) to prevent edge discontinuities.
  2. C++ backends: richdem and whitebox CLI tools support multi-threaded, out-of-core processing. Both implement priority-queue flood algorithms optimized for cache locality.
  3. Dask integration: Wrap raster I/O in dask.array to parallelize tile processing across distributed workers. Maintain spatial indexing to preserve watershed topology across chunk boundaries.
  4. Precision management: Always process DEMs as float32. float64 doubles memory overhead without improving hydrologic accuracy, while int16 clips micro-gradients during flat resolution.

When flat resolution is correctly applied, downstream routing matrices stabilize, accumulation surfaces become monotonic, and stream networks align with observed hydrography. This preprocessing step is non-negotiable for reproducible hydrologic modeling and automated watershed extraction.