Choosing Tile Size and Halo Width for Flow Accumulation

Both numbers are usually chosen by copying whatever the last project used, and both are measurable in a few minutes. Getting them wrong produces either a pipeline that wastes half its compute on redundant halo cells or one that leaves visible seams in the delineation. This guide covers the measurement, as part of the tiled and parallel watershed processing topic within watershed delineation and catchment synchronization.

Prerequisites

  • A representative sample of the DEM — three or four tiles spanning the terrain types present is enough.
  • scipy.ndimage for connected-component labelling of depressions.
  • The per-worker memory budget, stated as a number.

Core Technique: Measure the Halo from the Depressions

A halo is wide enough when no depression that straddles a tile boundary extends beyond it. Wider than that buys nothing; narrower produces two different fill levels on either side of the seam, and the flow directions disagree there.

The measurement is direct: label the closed depressions in a sample area, take each one’s extent in cells, and read off a high percentile.

The Halo Comes Off the Depression Extent Distribution Histogram of depression extents on a 1 metre LiDAR sample. Most depressions are under 20 cells across, with a long tail. The 99th percentile sits at 96 cells, which is the halo requirement; the largest single depression reaches 340 cells and is handled by the fill pass rather than the halo. <10 10–20 20–35 35–55 55–80 80–120 120–180 180–260 >260 depression extent (cells) count, log 99th percentile = 96 cells → set the halo to 128 The tail beyond it is handled by the fill pass over the mosaic, not the halo.

Annotated Code Example

python
import logging

import numpy as np
import rasterio
import richdem as rd
from rasterio.windows import Window
from scipy import ndimage

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

# Peak bytes per cell across the whole conditioning + routing chain: input
# float32, filled float32, working copy float32, direction uint8,
# accumulation float64. Measured, not assumed.
PEAK_BYTES_PER_CELL = 21


def measure_halo(
    dem_path: str,
    sample_windows: int = 4,
    sample_size: int = 2048,
    percentile: float = 99.0,
) -> dict:
    """
    Measure the halo a DEM actually needs, from its depression extents.

    Samples several windows, fills each, labels the cells that were raised,
    and reports the extent distribution of those depressions.
    """
    with rasterio.open(dem_path) as src:
        width, height = src.width, src.height
        nodata = src.nodata if src.nodata is not None else -9999.0

        # Spread the samples across the raster rather than clustering them,
        # so a single terrain type does not dominate the estimate.
        step_r = max(1, (height - sample_size) // max(1, sample_windows))
        step_c = max(1, (width - sample_size) // max(1, sample_windows))
        extents = []

        for i in range(sample_windows):
            r = min(height - sample_size, i * step_r)
            c = min(width - sample_size, i * step_c)
            if r < 0 or c < 0:
                continue
            patch = src.read(1, window=Window(c, r, sample_size, sample_size))
            patch = patch.astype(np.float32)

            grid = rd.rdarray(patch.copy(), no_data=nodata)
            rd.FillDepressions(grid, epsilon=False, in_place=True)
            raised = np.asarray(grid) - patch

            # Any cell the fill raised was inside a depression. Label the
            # connected groups and measure each one's bounding box.
            labels, n = ndimage.label(raised > 1e-6)
            if n == 0:
                continue
            for sl in ndimage.find_objects(labels):
                extents.append(max(sl[0].stop - sl[0].start,
                                   sl[1].stop - sl[1].start))
            log.info("window (%d,%d): %d depressions", r, c, n)

    if not extents:
        log.warning("No depressions found in the sample — a halo of 32 cells "
                    "is a safe floor for flow direction alone")
        return {"halo_cells": 32, "n_depressions": 0}

    arr = np.asarray(extents)
    p = float(np.percentile(arr, percentile))
    # Round up to a power of two so the halo aligns with typical block sizes.
    halo = int(2 ** np.ceil(np.log2(max(p, 8))))
    log.info("%d depressions sampled: median %d, p%.0f %d, max %d cells → halo %d",
             len(arr), int(np.median(arr)), percentile, int(p), int(arr.max()), halo)
    return {
        "halo_cells": halo,
        "n_depressions": len(arr),
        "median_extent": int(np.median(arr)),
        "percentile_extent": int(p),
        "max_extent": int(arr.max()),
    }


def choose_tile_size(memory_budget_gb: float, halo: int,
                     max_overhead: float = 0.15) -> int:
    """
    Largest tile that fits the budget, and smallest that keeps halo waste low.

    Returns a tile size in cells, rounded down to a multiple of 512 so it
    aligns with typical internal blocking.
    """
    budget_cells = (memory_budget_gb * 1e9) / PEAK_BYTES_PER_CELL
    # (tile + 2*halo)^2 must fit the budget.
    max_tile = int(np.sqrt(budget_cells)) - 2 * halo
    # Halo overhead is about 4*halo/tile; invert for the minimum tile.
    min_tile = int(4 * halo / max_overhead)

    if max_tile < min_tile:
        log.warning("Budget of %.1f GB cannot hold a tile large enough to keep "
                    "halo overhead under %.0f %% with a %d-cell halo — either "
                    "raise the budget or accept the overhead",
                    memory_budget_gb, 100 * max_overhead, halo)
        tile = max(512, (max_tile // 512) * 512)
    else:
        tile = max(512, (max_tile // 512) * 512)

    overhead = 4.0 * halo / tile
    padded = tile + 2 * halo
    peak_gb = (padded ** 2) * PEAK_BYTES_PER_CELL / 1e9
    log.info("Tile %d cells, halo %d → padded %d, peak %.2f GB per worker, "
             "halo overhead %.0f %%", tile, halo, padded, peak_gb, 100 * overhead)
    return tile


# --- Example usage ---
# m = measure_halo("state_1m_cog.tif", sample_windows=4)
# tile = choose_tile_size(memory_budget_gb=6.0, halo=m["halo_cells"])

Parameter Reference

Quantity How to get it Typical value
Halo 99th percentile depression extent, rounded to a power of two 32–256 cells
Tile size From the memory budget, floor to a multiple of 512 2048–8192 cells
Peak bytes per cell Sum of every simultaneous array’s dtype ≈ 21
Halo overhead 4 × halo / tile Keep under 15 %
Accumulation halo Not applicable Use an ordered exchange
Halo Overhead Falls Sharply with Tile Size Overhead curves against tile size from 512 to 8192 cells for halos of 64, 128 and 256 cells. At 1024 cells a 128-cell halo costs 50 percent extra computation; at 4096 it costs 12 percent, and at 8192 it costs 6 percent. 512 1 024 2 048 4 096 8 192 tile size (cells) 0 % 25 % 50 % 75 % 100 % halo 64 halo 128 halo 256 under 15 % — the usable zone

Worked Example: Two Terrains, Two Answers

The same measurement on two datasets gives very different halos, which is the argument for measuring at all.

Steep mountain, 1 m LiDAR Low-relief coastal plain, 1 m LiDAR
Depressions per 2048² sample 480 14 200
Median extent 6 cells 11 cells
99th percentile extent 18 cells 96 cells
Halo chosen 32 128
Tile at 6 GB budget 8 192 7 680
Halo overhead 1.6 % 6.7 %

The mountain dataset needs almost no halo, because depressions there are small pits from vegetation noise. The coastal plain, with its wide flat depressions behind roads and levees, needs four times as much — and running the mountain’s halo on it would leave a filled surface that disagrees across every seam.

The two knobs pull against each other under a fixed memory budget: a wider halo forces a smaller core tile, which raises the overhead the halo was meant to justify. Plotting the feasible pairs makes the compromise explicit.

Tile and Halo Under a Fixed Budget Under a 6 gigabyte per worker budget the padded tile cannot exceed about 17 000 cells square. A 64-cell halo allows a 16 000-cell tile at 1.6 percent overhead, a 128-cell halo a 15 700-cell tile at 3.3 percent, and a 512-cell halo a 15 000-cell tile at 13.7 percent. halo 64 → 1.6 % overhead halo 128 → 3.3 % halo 256 → 6.8 % halo 512 → 13.7 % 16 000 15 700 15 400 15 000 core tile, cells largest feasible tile under a 6 GB per-worker budget The tile barely shrinks; the overhead is what a wider halo really costs.

Gotchas and Edge Cases

  • A halo copied from another project. The two rows above differ by a factor of four on the same sensor and resolution. Terrain, not sensor, sets the number.
  • Measuring on one sample window. A single window over uniform terrain understates the tail. Spread the samples.
  • Using the maximum depression extent. The largest depression is often a genuine lake, which the halo should not attempt to contain. A high percentile plus the mosaic-level fill pass is the right combination.
  • Applying the halo to accumulation. No width works. The accumulation stage needs the ordered exchange described in the parent topic.
  • Tile size not a multiple of the internal block size. Every read then straddles blocks — see writing Cloud-Optimized GeoTIFFs.
  • Budget stated per machine. With eight workers a 32 GB machine gives each worker 4 GB, not 32. The tile calculation takes the per-worker figure.
  • Ignoring the epsilon flag when measuring. Filling with epsilon=True raises a thin gradient across every flat, so almost every cell registers as raised and the depression labels merge into one enormous blob. Measure with epsilon=False.