Tiling and Buffering Large LiDAR Surveys with PDAL

Every neighbourhood operation in a LiDAR pipeline — ground classification, outlier removal, interpolation — needs points beyond the pixel it is computing. Process a tile in isolation and those operations run out of data at the edge, producing a visible ridge of wrong values along every tile boundary that survives all the way into the finished terrain and canopy products. The fix is a halo: process a tile with a margin of borrowed neighbouring points, then crop the margin away. This guide covers exactly that, as one operational step inside LiDAR Point Cloud Preprocessing, part of Canopy Height Modeling & Terrain Extraction.

When to use this approach

Tiling is not optional above a certain survey size, but how you tile depends on what you are running.

Strategy Tile shape Halo needed Use when
Vendor delivery tiles as-is Fixed grid, usually 1 km² Yes, always The survey already arrives tiled and you only need to add the buffer
filters.splitter Regular square grid on a chosen origin Yes You control the grid and want predictable, mosaic-friendly extents
filters.chipper Equal point-count chips Yes Density varies wildly and you need balanced runtimes rather than equal areas
Whole survey in memory None Only for areas under roughly 50 million points

Use filters.splitter for anything that becomes a raster: an aligned square grid mosaics without resampling. Reserve filters.chipper for point-domain work — segmentation, statistics — where equal work per chip matters more than a tidy grid.

Sizing the halo

The halo has to be at least as wide as the widest neighbourhood any stage will reach for. Take the maximum over the stages in the pipeline:

where is the ground filter’s largest morphological window, the mean point spacing, the largest crown radius if you will segment trees, and the void-fill search distance. For a typical forestry pipeline — SMRF at an 18 m window, crowns up to 8 m radius, a 6 m fill distance — a 30 m halo covers everything, and 50 m is a safe default that costs little.

Nominal tile extent versus the buffered processing extent A three by three grid of square nominal tiles. The centre tile is drawn with a wider dashed square around it representing the halo buffer, which reaches into all eight neighbouring tiles. Returns falling inside the halo are read and used by neighbourhood operations such as ground classification, but the write step crops back to the solid nominal square so neighbouring outputs abut exactly once with no overlap and no gap. nominal 1 km × 1 km halo b = 50 m b Read every return inside the dashed halo Process ground filter, outlier removal, interpolation all see full context across the seam Write crop to the solid nominal square only Every tile borrows context from its neighbours and gives back only its own ground.

Minimal reproducible example

Two pipelines do the work. The first splits a survey into a buffered grid; the second processes one buffered tile and crops on write.

Splitting with an explicit halo — filters.splitter takes the buffer directly, so neighbouring points are duplicated into each tile rather than dropped:

{
  "pipeline": [
    "survey.laz",
    {
      "type": "filters.splitter",
      "length": 1000,
      "buffer": 50,
      "origin_x": 500000,
      "origin_y": 5400000
    },
    {
      "type": "writers.las",
      "filename": "tiles/tile_#.laz",
      "forward": "all",
      "compression": "laszip"
    }
  ]
}

Pinning origin_x and origin_y to a round coordinate is what makes the outputs mosaic without resampling later. Leave the origin unset and PDAL anchors the grid to the data’s own bounding box, so two surveys of the same forest tile differently and every multi-temporal comparison inherits a half-pixel shift.

Processing one buffered tile and cropping back on write:

import json
import pdal


def process_tile(in_laz: str, out_tif: str, bounds: tuple, buffer_m: float = 50.0):
    """Classify + rasterize a buffered tile, writing only the nominal extent.

    bounds is the NOMINAL (unbuffered) extent as (minx, maxx, miny, maxy).
    """
    minx, maxx, miny, maxy = bounds
    pipeline = {
        "pipeline": [
            in_laz,
            {"type": "filters.outlier", "method": "statistical",
             "mean_k": 12, "multiplier": 2.2},
            {"type": "filters.smrf", "slope": 0.2, "window": 18.0,
             "scalar": 1.2, "threshold": 0.45},
            {"type": "filters.range", "limits": "Classification[2:2]"},
            # Crop AFTER every neighbourhood operation, never before.
            {"type": "filters.crop",
             "bounds": f"([{minx},{maxx}],[{miny},{maxy}])"},
            {"type": "writers.gdal", "filename": out_tif, "resolution": 1.0,
             "output_type": "min", "data_type": "float32",
             "bounds": f"([{minx},{maxx}],[{miny},{maxy}])",
             "gdalopts": "COMPRESS=DEFLATE,PREDICTOR=2,TILED=YES"},
        ]
    }
    pdal.Pipeline(json.dumps(pipeline)).execute()

The ordering is the whole point: filters.crop sits after the classifier and before the writer. Crop first and you have thrown away the context the classifier needed; do not crop at all and neighbouring rasters overlap, so the mosaic double-counts along every seam.

Deriving the nominal bounds for each tile from its own header, with the halo subtracted:

import json
import subprocess


def nominal_bounds(tile_laz: str, buffer_m: float = 50.0) -> tuple:
    """Read a buffered tile's extent and shrink it back to the nominal grid cell."""
    info = json.loads(subprocess.run(
        ["pdal", "info", "--metadata", tile_laz],
        capture_output=True, text=True, check=True).stdout)
    md = info["metadata"]
    return (md["minx"] + buffer_m, md["maxx"] - buffer_m,
            md["miny"] + buffer_m, md["maxy"] - buffer_m)

Parameter reference

Parameter Stage Type Default Recommended Rationale
length filters.splitter float (m) 1000 500–2000 Tile edge length; smaller tiles parallelise better but multiply halo overhead
buffer filters.splitter float (m) 0 30–50 Halo width; must exceed the widest neighbourhood any stage uses
origin_x / origin_y filters.splitter float data bbox round coordinate Pins the grid so repeat surveys tile identically
capacity filters.chipper int (points) 5000 100k–1M Points per chip; size it so one chip fits comfortably in a worker’s memory
bounds filters.crop string nominal extent Applied after processing to remove the halo
bounds writers.gdal string data extent nominal extent Forces an exact raster extent so tiles mosaic pixel-for-pixel

A 50 m halo on a 1 km tile adds about 20% more points to read and process. On a 250 m tile the same halo adds 96% — the overhead is why very small tiles are a false economy.

Expected output and verification

Correct tiling produces outputs that abut exactly once. The two failures worth testing for are gaps (halo cropped too aggressively) and overlaps (not cropped at all), and both are visible in the extents alone:

import glob

import rasterio
from itertools import combinations


def check_mosaic(tifs: list[str], tol: float = 1e-6) -> dict:
    """Verify tile rasters share a grid, do not overlap, and leave no gaps."""
    boxes, res, crs = [], None, None
    for path in tifs:
        with rasterio.open(path) as src:
            boxes.append(src.bounds)
            if res is None:
                res, crs = abs(src.transform.a), src.crs
            assert abs(abs(src.transform.a) - res) < tol, f"{path}: resolution differs"
            assert src.crs == crs, f"{path}: CRS differs"
            # Pixel edges must land on the same global grid.
            assert abs(src.bounds.left / res - round(src.bounds.left / res)) < 1e-3, \
                f"{path}: origin off-grid — tiles will not mosaic cleanly"

    overlaps = 0
    for a, b in combinations(boxes, 2):
        if a.left < b.right - tol and b.left < a.right - tol and \
           a.bottom < b.top - tol and b.bottom < a.top - tol:
            overlaps += 1
    return {"n_tiles": len(boxes), "overlapping_pairs": overlaps, "res_m": res}


report = check_mosaic(sorted(glob.glob("dtm/*.tif")))
assert report["overlapping_pairs"] == 0, "tiles overlap — the halo was never cropped"

The stronger check is on values rather than extents: mosaic the tiles, then sample the finished raster along a tile boundary and along a line well inside a tile, and compare the distribution of local slope. A seam shows up as a slope spike confined to the boundary column.

Terrain profile across a tile boundary with and without a halo Two elevation profiles are drawn across the same tile boundary, which is marked by a vertical dashed line. The upper profile, produced without a halo, is smooth on each side but jumps abruptly at the seam because the ground filter ran out of context at the tile edge. The lower profile, produced with a fifty metre halo, crosses the boundary continuously with no visible step. tile boundary No halo 50 m halo step at the seam continuous across the boundary A seam in the DTM becomes a seam in the CHM, the slope raster, and every model built on them. Halo overhead against tile size Three curves of extra points read as a percentage against tile edge length from two hundred metres to two kilometres. A thirty metre halo costs about eighty percent on a small tile and under ten percent on a large one. A fifty metre halo costs nearly one hundred and twenty percent on a small tile and about twenty percent at one kilometre. A one hundred metre halo is prohibitive below five hundred metres. All three converge towards negligible overhead as tiles grow. 0% 50% 100% 150% 200 m 500 m 1 km 1.5 km 2 km tile edge length extra points read 100 m halo 50 m halo 30 m halo 1 km tile, 50 m halo — about 20% Very small tiles parallelise well and spend most of their compute on borrowed points.

Common pitfalls

  • Cropping before classification. The most common ordering mistake, and it silently reproduces exactly the artifact the halo was added to prevent.
  • Letting PDAL choose the grid origin. Two acquisitions over the same forest then tile differently, and every change-detection raster inherits a sub-pixel misalignment that looks like real growth.
  • A halo narrower than the ground filter window. An 18 m SMRF window with a 10 m halo still starves at the edge; the artifact is smaller but not gone.
  • Forgetting bounds on writers.gdal. Without it the raster extent follows the surviving points, so a tile with no returns in its top-left corner produces a raster smaller than its tile and the mosaic gets a hole.
  • Assuming vendor tiles are already buffered. They almost never are. Delivery tiles are storage units, not processing units.

Frequently Asked Questions

Should tiles be square, or does shape not matter?

Square minimises the halo perimeter for a given area, which minimises duplicated points. It also aligns naturally with raster grids. Long thin tiles — a flight-line strip, for instance — carry far more halo per unit of useful area and are worth re-tiling before processing.

Can I skip the halo if I mosaic the point cloud first?

Only if the whole survey fits in memory, which is the case this technique exists to avoid. Merging classified tiles after the fact does not help either: the misclassification at each edge is already baked into the classification, and merging just joins two wrong edges together.

How do I parallelise without thrashing memory?

Cap the worker count at the physical core count and remember each worker decompresses a full buffered tile. A 1 km tile at 15 returns/m² is roughly 15 million points, which is on the order of a gigabyte in memory once attributes are attached. Six workers is comfortable on a 32 GB machine; twelve is not.

Up: LiDAR Point Cloud Preprocessing · Canopy Height Modeling & Terrain Extraction