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.
# Prerequisites and Assumptions
python-pdal3.x (pip install pdalor conda-forgepython-pdal), which providespdal.Reader,pdal.Filterandpdal.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
"""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:
t_0431.laz: 18422907 points -> t_0431_dtm.tif
readers.las -> filters.range -> filters.outlier -> filters.reprojection -> filters.smrf -> filters.range -> writers.gdalThe executed count is the number of points reaching the end of the chain, which here is only the ground points written to the raster.
# 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.
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.pipelineto a file and runpdal pipeline --validatein CI. - Check the stage order. Print the stage types after construction; a missing
|=inside a branch is easy to miss by reading code.
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.
# 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.
# Related
- PDAL Pipeline Templating and Parameterization — choosing a mechanism
- Parameterizing Pipelines with Jinja Templates — the file-based alternative
- Overriding Stage Options from the Command Line — per-run values without code
- Passing NumPy Arrays into a PDAL Pipeline — running stage chains on arrays
- Testing PDAL Pipelines with pytest — tests for composed pipelines