RichDEM vs WhiteboxTools vs pysheds for D8 Routing

All three libraries implement the same algorithm and produce the same drainage network from the same conditioned surface. What differs is everything around the algorithm — the numbers written into the direction grid, how flats are handled, what has to fit in memory, and how the library behaves when something goes wrong. This guide compares them on those grounds, as part of the D8 flow direction implementation topic within flow routing and stream network extraction.

Prerequisites

  • All three installed: richdem, whitebox, pysheds.
  • One conditioned DEM to compare them on. Comparing on unconditioned data measures the fill implementations rather than the routers.

Core Technique: The Encoding Is the Interoperability Problem

The direction grid is an integer raster, and the integers mean different things in each library.

Two Encodings for the Same Eight Directions A three by three neighbourhood. RichDEM writes 1 for east, then 2, 3, 4, 5, 6, 7, 8 moving anticlockwise. WhiteboxTools and pysheds write the ESRI powers of two: 1 east, 2 south-east, 4 south, 8 south-west, 16 west, 32 north-west, 64 north, 128 north-east. RichDEM — 1…8 anticlockwise from east 4 3 2 5 c 1 6 7 8 values 1–8, so uint8 is ample 0 marks a sink or nodata WhiteboxTools / pysheds — ESRI powers of two 32 64 128 8 c 2 16 4 1 values to 128, so uint8 still fits but the numbers mean something else entirely

Reading a RichDEM grid as though it were ESRI-encoded maps 1 to east correctly by coincidence and everything else to the wrong neighbour. The resulting network is connected, plausible and wrong, which is the worst combination.

Annotated Code Example

python
import logging

import numpy as np
import rasterio

log = logging.getLogger(__name__)
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")

# Offsets are (row, col) with row increasing southwards, matching raster order.
NEIGHBOUR_OFFSETS = {
    "E": (0, 1), "NE": (-1, 1), "N": (-1, 0), "NW": (-1, -1),
    "W": (0, -1), "SW": (1, -1), "S": (1, 0), "SE": (1, 1),
}
RICHDEM_CODES = {"E": 1, "NE": 2, "N": 3, "NW": 4, "W": 5, "SW": 6, "S": 7, "SE": 8}
ESRI_CODES = {"E": 1, "SE": 2, "S": 4, "SW": 8, "W": 16, "NW": 32, "N": 64, "NE": 128}


def convert_d8_encoding(
    src_path: str,
    dst_path: str,
    src_scheme: str = "richdem",
    dst_scheme: str = "esri",
) -> dict:
    """
    Re-encode a D8 direction raster between conventions.

    Both schemes fit in uint8, so nothing about the file signals which one it
    is. Record the scheme in the raster tags, which this function does.
    """
    schemes = {"richdem": RICHDEM_CODES, "esri": ESRI_CODES}
    if src_scheme not in schemes or dst_scheme not in schemes:
        raise ValueError(f"scheme must be one of {sorted(schemes)}")

    with rasterio.open(src_path) as src:
        arr = src.read(1)
        profile = src.profile.copy()
        tags = src.tags()

    declared = tags.get("D8_ENCODING")
    if declared and declared != src_scheme:
        log.warning("Raster is tagged as %r but was read as %r — trust the tag",
                    declared, src_scheme)

    src_codes, dst_codes = schemes[src_scheme], schemes[dst_scheme]
    out = np.zeros_like(arr)
    for direction, src_val in src_codes.items():
        n = int((arr == src_val).sum())
        out[arr == src_val] = dst_codes[direction]
        log.info("  %-2s: %8d cells, %3d → %3d", direction, n,
                 src_val, dst_codes[direction])

    unmapped = int(((arr != 0) & (out == 0)).sum())
    if unmapped:
        log.error("%d non-zero cell(s) had no mapping — the source is not in "
                  "the %r scheme", unmapped, src_scheme)

    profile.update(dtype="uint8", nodata=0, compress="LZW", tiled=True)
    with rasterio.open(dst_path, "w", **profile) as dst:
        dst.write(out.astype("uint8"), 1)
        # Tag the file so the next reader does not have to guess.
        dst.update_tags(D8_ENCODING=dst_scheme)
    log.info("Wrote %s tagged D8_ENCODING=%s", dst_path, dst_scheme)
    return {"output": dst_path, "unmapped_cells": unmapped}


def compare_implementations(dem_path: str, out_dir: str) -> dict:
    """
    Run all three D8 implementations on one conditioned DEM and compare.

    Converts every direction grid to the ESRI scheme before comparing, so the
    comparison is about the algorithm rather than about the numbering.
    """
    import os
    import time

    os.makedirs(out_dir, exist_ok=True)
    results = {}

    # --- RichDEM: in-memory, returns an rdarray carrying metadata ---
    import richdem as rd
    with rasterio.open(dem_path) as src:
        dem = src.read(1).astype(np.float32)
        nodata = src.nodata if src.nodata is not None else -9999.0
    t0 = time.perf_counter()
    grid = rd.rdarray(dem.copy(), no_data=nodata)
    rd_acc = np.asarray(rd.FlowAccumulation(grid, method="D8"))
    results["richdem"] = {"seconds": time.perf_counter() - t0,
                          "max_accumulation": float(rd_acc.max())}

    # --- WhiteboxTools: streams through files, so peak memory stays low ---
    from whitebox import WhiteboxTools
    wbt = WhiteboxTools()
    wbt.set_working_dir(os.path.abspath(out_dir))
    wbt.verbose = False
    t0 = time.perf_counter()
    wbt.d8_flow_accumulation(os.path.abspath(dem_path), "wbt_acc.tif",
                             out_type="cells")
    with rasterio.open(os.path.join(out_dir, "wbt_acc.tif")) as src:
        wbt_max = float(src.read(1).max())
    results["whitebox"] = {"seconds": time.perf_counter() - t0,
                           "max_accumulation": wbt_max}

    # --- pysheds: pure NumPy, easiest to inspect and modify ---
    from pysheds.grid import Grid
    t0 = time.perf_counter()
    pg = Grid.from_raster(dem_path)
    dem_ps = pg.read_raster(dem_path)
    fdir = pg.flowdir(dem_ps)
    ps_acc = pg.accumulation(fdir)
    results["pysheds"] = {"seconds": time.perf_counter() - t0,
                          "max_accumulation": float(np.asarray(ps_acc).max())}

    for name, r in results.items():
        log.info("%-10s %6.1f s, max accumulation %.0f cells",
                 name, r["seconds"], r["max_accumulation"])

    maxima = [r["max_accumulation"] for r in results.values()]
    spread = (max(maxima) - min(maxima)) / max(maxima)
    if spread > 0.01:
        log.warning("Maximum accumulation differs by %.2f %% across "
                    "implementations — check that all three saw the SAME "
                    "conditioned surface", 100 * spread)
    else:
        log.info("All three agree on maximum accumulation to within %.3f %%",
                 100 * spread)
    return results


# --- Example usage ---
# convert_d8_encoding("fdir_richdem.tif", "fdir_esri.tif", "richdem", "esri")
# compare_implementations("basin_conditioned.tif", "./compare")

Parameter Reference: The Comparison

RichDEM WhiteboxTools pysheds
Language C++ with Python bindings Rust, called as a subprocess Pure Python / NumPy
Direction encoding 1–8 anticlockwise from east ESRI powers of two ESRI by default, configurable
Memory model Everything in process Streams through files Everything in process
Larger than memory No Yes No
Flat resolution epsilon gradient in the fill fix_flats, separate Handled inside flowdir
Metadata handling rdarray carries geotransform Files carry it Raster subclass carries it
Error reporting Exceptions Exit codes and stdout text Exceptions
Best for In-memory pipelines, custom terrain attributes Very large grids, batch scripting Interactive work, teaching, modification
Runtime and Memory Across Grid Sizes On a 4 million cell grid all three finish within seconds. On 100 million cells RichDEM takes 96 seconds at 2.1 gigabytes, WhiteboxTools 88 seconds at 0.4 gigabytes, and pysheds 260 seconds at 2.4 gigabytes. On 1 billion cells only WhiteboxTools completes; the other two exhaust memory. 4 M cells all under 6 s 100 M cells 96 s · 88 s · 260 s 1 B cells only WhiteboxTools completes RichDEM WhiteboxTools pysheds runtime dashed = the run exhausted memory

Worked Example: A Handoff That Went Wrong

A pipeline conditioned with RichDEM, wrote the direction grid, and delineated with pysheds:

Check Result Reading
Delineated area at the gauge 214 km² Published 1 482 km²
Network connected components 1 The network is coherent
Direction grid value histogram 1–8, no gaps Every value is valid ESRI-wise too
Cells with code 1 (east) 12 % Matches expectation
Cells with code 3 13 % Read as ESRI: not a valid code, treated as nodata

The delineation succeeded, produced a connected catchment, and got the area wrong by a factor of seven. Nothing raised an error, because both encodings live in the same integer range. Tagging the raster with its encoding, as the code above does, converts this from an invisible failure into a warning on the first read.

A pipeline that crosses libraries needs one rule at each boundary: the file carries its own encoding declaration, and the reader checks it.

Declare the Encoding at Every Handoff WhiteboxTools conditions the DEM and writes it tagged. RichDEM reads the tag, computes directions and writes them tagged with its own encoding. pysheds reads that tag, converts, and delineates. Each arrow carries a tag check. WhiteboxTools condition, very large grid .tif RichDEM flow direction, 1–8 scheme .tif pysheds delineate, ESRI scheme every write sets D8_ENCODING; every read asserts it One tag and one assertion remove the entire class of silent encoding corruption, which is otherwise invisible because both schemes fit in the same integer range.

Gotchas and Edge Cases

  • Untagged direction grids. Add a D8_ENCODING tag on write and check it on read. It costs one line and removes the whole class of error.
  • Mixed conditioning. Two libraries’ fill implementations resolve flats differently, so a direction grid from one and an accumulation grid from the other disagree in flat areas.
  • WhiteboxTools working directory. Outputs resolve against the tool’s working directory and inputs against the process’s, so absolute input paths and bare output names is the combination that behaves.
  • pysheds on very large grids. Pure NumPy holds several full-size arrays; the practical ceiling is well below what the compiled libraries manage.
  • RichDEM array identity. Any NumPy operation on an rdarray returns a plain array and drops the geotransform — see implementing D8 flow routing with RichDEM Python bindings.
  • Comparing on unconditioned data. Measures the three fill implementations, not the three routers, and the differences will be large and uninformative.