Building Pipelines with the Python Stage API

TL;DR: With python-pdal 3.x, pdal.Reader.las(filename="in.laz") | pdal.Filter.smrf(slope=0.15) | pdal.Writer.las(filename="out.laz") builds a pdal.Pipeline directly — no JSON strings. Wrap common chains in small factory functions, add stages conditionally with plain if statements, and write pipeline.pipeline (the JSON form) beside each output for provenance.

# Context and Motivation

This guide is part of PDAL Pipeline Templating and Parameterization. The stage API is the most Pythonic way to parameterize a pipeline, because there is nothing to render: stages are objects, options are keyword arguments, and conditional structure is ordinary Python. It suits code that already decides things at run time — reproject only if the CRS differs, add an outlier filter only for one sensor, choose a writer by output extension — and it is the easiest form to unit test.

The trade-off is that the pipeline no longer lives in a file a non-programmer can read. That is solved by serializing the composed pipeline to JSON and storing it beside each output, which also keeps provenance intact.

Stages as objects, pipelines by composition Three stage objects — Reader.las, Filter.smrf and Writer.gdal — joined by pipe operators into one Pipeline object. Below, the Pipeline exposes three things: execute to run it, arrays and metadata for results, and the pipeline property that returns the equivalent JSON. Reader.las(...) | Filter.smrf(slope=0.15) | Writer.gdal(resolution=1.0) pdal.Pipeline .execute() · .arrays · .metadata · .log .pipeline → the equivalent JSON

# Prerequisites and Assumptions

  • python-pdal 3.x (pip install pdal or conda-forge python-pdal), which provides pdal.Reader, pdal.Filter and pdal.Writer.
  • PDAL 2.4+ underneath; stage names and options are PDAL’s own, so the stage API exposes whatever the installed PDAL supports.
  • Familiarity with the equivalent JSON form — the API is a thin layer over it.

# Step-by-Step Implementation

# Step 1 — Create stages with typed keyword arguments

pdal.Filter.smrf(slope=0.15, window=18.0) creates a stage; attribute access names the stage type, keyword arguments become options.

# Step 2 — Compose with the pipe operator

a | b joins stages into a Pipeline; piping a pipeline with another stage appends to it.

# Step 3 — Build conditionally

Start from the reader, then add stages in if blocks. The result is always a valid linear chain.

# Step 4 — Wrap recurring chains in factories

A function returning a stage or a short chain — noise_removal(), ground_only() — keeps options consistent across projects and makes them testable.

# Step 5 — Serialize for provenance

pipeline.pipeline returns the JSON string. Write it next to the output before or after executing.

# Complete Working Example

python
"""Compose a DTM pipeline with the PDAL Python stage API."""
from __future__ import annotations

import json
from pathlib import Path

import pdal


def noise_removal() -> pdal.Pipeline:
    return (pdal.Filter.range(limits="Classification![7:7],Classification![18:18]")
            | pdal.Filter.outlier(method="statistical", mean_k=12, multiplier=2.5))


def ground(slope: float = 0.15, window: float = 18.0) -> pdal.Pipeline:
    return (pdal.Filter.smrf(slope=slope, window=window, threshold=0.5, scalar=1.25)
            | pdal.Filter.range(limits="Classification[2:2]"))


def dtm_pipeline(src: Path, dst: Path, *, target_crs: str | None, source_crs: str,
                 resolution: float, steep: bool = False) -> pdal.Pipeline:
    p = pdal.Reader.las(filename=str(src), default_srs=source_crs) | noise_removal()
    if target_crs and target_crs != source_crs:
        p |= pdal.Filter.reprojection(out_srs=target_crs)
    p |= ground(slope=0.35, window=12.0) if steep else ground()
    p |= pdal.Writer.gdal(filename=str(dst), resolution=resolution, output_type="idw",
                          window_size=6, data_type="float32",
                          gdalopts="COMPRESS=DEFLATE,TILED=YES")
    return p


def run(src: Path, out_dir: Path, **kwargs) -> int:
    out_dir.mkdir(parents=True, exist_ok=True)
    dst = out_dir / f"{src.stem}_dtm.tif"
    pipeline = dtm_pipeline(src, dst, **kwargs)
    spec = json.loads(pipeline.pipeline)
    (out_dir / f"{src.stem}.pipeline.json").write_text(json.dumps(spec, indent=2, sort_keys=True))
    n = pipeline.execute()
    print(f"{src.name}: {n} points -> {dst.name}")
    print(" -> ".join(s["type"] for s in spec["pipeline"]))
    return n


if __name__ == "__main__":
    run(Path("tiles/t_0431.laz"), Path("out"), target_crs="EPSG:6347",
        source_crs="EPSG:26918", resolution=1.0, steep=True)

Expected console output:

text
t_0431.laz: 18422907 points -> t_0431_dtm.tif
readers.las -> filters.range -> filters.outlier -> filters.reprojection -> filters.smrf -> filters.range -> writers.gdal

The executed count is the number of points reaching the end of the chain, which here is only the ground points written to the raster.

Plain if statements shape the chain A flow from the reader through noise removal. A decision on whether the target CRS differs adds a reprojection stage or skips it. A second decision on steep terrain selects SMRF with slope 0.35 and window 12, or the default slope 0.15 and window 18. Both paths end at the same writer. reader + noise CRS differs? reprojection steep? smrf 0.35 / 12 smrf 0.15 / 18 yes no yes no

# Stage Factories as a Shared Library

The factories in the example — noise_removal() and ground() — are the real payoff of the stage API. Once a team has a small module of them, every new pipeline is assembled from pieces that are already reviewed, tested and tuned, and a change to how noise is removed lands in every workflow the next time it runs.

A few conventions keep such a module healthy. Give every factory keyword-only arguments with defaults that match your standard specification, so the common case needs no arguments and unusual cases are visible at the call site. Return a Pipeline even for single stages, so callers can always use | without caring how many stages a factory contains. Keep factories free of file paths; readers and writers belong to the calling workflow, while factories describe processing. And test each factory in isolation by piping a tiny synthetic array through it, which the stage.pipeline(arr) form makes easy.

python
import numpy as np

def test_noise_removal_drops_class_7() -> None:
    pts = np.zeros(4, dtype=[("X", "f8"), ("Y", "f8"), ("Z", "f8"), ("Classification", "u1")])
    pts["X"] = [0, 1, 2, 3]
    pts["Classification"] = [2, 7, 1, 18]
    p = pdal.Filter.range(limits="Classification![7:7],Classification![18:18]").pipeline(pts)
    p.execute()
    assert sorted(p.arrays[0]["Classification"].tolist()) == [1, 2]

# Key Parameter Table

API element Example Notes
pdal.Reader.<type> pdal.Reader.las(filename=...) Any reader PDAL knows; pdal.Reader("x.laz") infers type
pdal.Filter.<type> pdal.Filter.hag_nn(count=2) Options as keyword arguments
pdal.Writer.<type> pdal.Writer.copc(filename=...) Writers end a chain
a | b stage or pipeline on either side Returns a new Pipeline
p |= stage in-place append Convenient inside if blocks
p.pipeline JSON string For provenance and validation
stage.pipeline(arr) pdal.Filter.smrf().pipeline(arr) Run a chain on a NumPy structured array

# Verification

  • Compare with the JSON you expect. In a unit test, build the pipeline with fixed arguments and compare json.loads(p.pipeline) against a committed expected structure.
  • Validate with PDAL. Write p.pipeline to a file and run pdal pipeline --validate in CI.
  • Check the stage order. Print the stage types after construction; a missing |= inside a branch is easy to miss by reading code.
python
def test_steep_uses_steep_smrf(tmp_path: Path) -> None:
    p = dtm_pipeline(Path("a.laz"), tmp_path / "a.tif", target_crs=None,
                     source_crs="EPSG:6347", resolution=1.0, steep=True)
    stages = json.loads(p.pipeline)["pipeline"]
    smrf = next(s for s in stages if s["type"] == "filters.smrf")
    assert smrf["slope"] == 0.35 and smrf["window"] == 12.0
    assert not any(s["type"] == "filters.reprojection" for s in stages)

# Gotchas and Edge Cases

Options pass through unchecked until execution. A misspelled keyword such as windw=18 is accepted by the Python object and only rejected by PDAL when the pipeline is validated or executed. Validate the serialized JSON in tests.

Non-linear pipelines need tags. The | operator builds linear chains. Branching pipelines with inputs and tag are possible by passing tag= and inputs= as options, but they are easier to read as JSON; see branching a PDAL pipeline with tags.

Older bindings. python-pdal 2.x only accepts JSON strings. Pin 3.x in your environment and container so the stage API is guaranteed to exist.

When a typo surfaces A timeline of four moments: constructing the stage object, composing the pipeline, validating the serialized JSON, and executing. A misspelled option passes the first two silently and is caught at validation; without validation it fails only at execution, possibly deep into a batch run. Filter.smrf(windw=18) reader | smrf | writer pdal --validate execute() accepted accepted caught here in CI or here, mid-batch validate the serialized JSON in tests so the typo never reaches a production run

# Frequently Asked Questions

Which python-pdal version supports the stage API?

The Reader, Filter and Writer classes and the pipe operator arrived with python-pdal 3.x. Earlier versions accept only JSON strings passed to pdal.Pipeline.

How do I get JSON out of a pipeline built with the stage API?

Read the pipeline property of the Pipeline object; it returns the equivalent JSON string, which you can parse, store or validate with the PDAL command-line tool.

Can I mix JSON and stage objects?

Yes. Construct a Pipeline from JSON and pipe additional stage objects onto it, or build the core in code and serialize it. Both produce the same underlying pipeline.

Is the stage API slower than JSON?

No. It only constructs the same pipeline description; execution happens in PDAL’s C++ code exactly as it does for JSON input.