Branching a PDAL Pipeline with Tags

TL;DR: Give a stage a "tag", then start each branch with a stage whose "inputs" lists that tag. One reader tagged cleaned can feed a ground branch ending in a DTM writer, a first-return branch ending in a DSM writer and a pass-through branch ending in a LAZ writer — all in one pipeline, reading and cleaning the tile once.

# Context and Motivation

This guide is part of PDAL Stage Chaining. Most pipelines are written as a straight list, and PDAL treats that list as a chain where each stage’s input is the stage before it. But a pipeline is really a directed acyclic graph, and the tag and inputs options let you write that graph explicitly. The practical payoff is avoiding repeated work: a production DTM run usually also needs a DSM, a classified point cloud and maybe an intensity image, and reading, decompressing and noise-filtering the same 2 GB tile four times is wasted time and I/O.

Branching also keeps related outputs consistent. When the DTM and DSM come from one pipeline, they are guaranteed to share the same cleaning, the same CRS handling and the same PDAL version.

One read, three products A reader feeds a range filter tagged cleaned. Three branches take cleaned as input. The ground branch keeps class 2 and writes a DTM with writers.gdal. The surface branch keeps first returns and writes a DSM with writers.gdal. The archive branch writes the whole cleaned cloud to LAZ. readers.las filters.range tag: cleaned range Classification[2:2] range ReturnNumber[1:1] writers.gdal → DTM writers.gdal → DSM writers.las → LAZ

# Prerequisites and Assumptions

  • PDAL 2.x; tag and inputs have been part of the pipeline format for many releases.
  • A tile with ground classified (for the DTM branch) and return numbers populated (for the DSM branch).
  • Enough memory for the tile in standard mode. Branching pipelines do not stream, because more than one downstream consumer needs the same points.

# Step-by-Step Implementation

# Step 1 — Tag the shared stage

Add "tag": "cleaned" to the last stage whose output every branch needs — here, the noise filter after the reader.

# Step 2 — Start each branch with inputs

The first stage of each branch sets "inputs": ["cleaned"]. Every following stage in that branch chains implicitly from the one before it, exactly as in a linear pipeline.

# Step 3 — End each branch with a writer

Each branch terminates in its own writer. PDAL executes every branch that ends in a writer.

# Step 4 — Tag writers for command-line overrides

Tagging the writers (dtm_out, dsm_out, laz_out) lets you override each filename independently with --stage.<tag>.filename.

# Step 5 — Validate the graph

pdal pipeline --validate reports unknown tags and cycles; run it before the first real execution.

# Complete Working Example

json
{
  "pipeline": [
    { "type": "readers.las", "filename": "tiles/t_0431.laz", "tag": "raw" },
    { "type": "filters.range", "limits": "Classification![7:7],Classification![18:18]",
      "inputs": ["raw"], "tag": "cleaned" },

    { "type": "filters.range", "limits": "Classification[2:2]",
      "inputs": ["cleaned"], "tag": "ground" },
    { "type": "writers.gdal", "filename": "out/t_0431_dtm.tif", "inputs": ["ground"],
      "resolution": 1.0, "output_type": "idw", "window_size": 6,
      "data_type": "float32", "tag": "dtm_out" },

    { "type": "filters.range", "limits": "ReturnNumber[1:1]",
      "inputs": ["cleaned"], "tag": "first" },
    { "type": "writers.gdal", "filename": "out/t_0431_dsm.tif", "inputs": ["first"],
      "resolution": 1.0, "output_type": "max", "data_type": "float32", "tag": "dsm_out" },

    { "type": "writers.las", "filename": "out/t_0431_clean.laz", "inputs": ["cleaned"],
      "minor_version": 4, "dataformat_id": 6, "forward": "all", "tag": "laz_out" }
  ]
}

Running it from Python and checking all three outputs exist:

python
import json
from pathlib import Path

import pdal

spec = json.loads(Path("branch.json").read_text())
p = pdal.Pipeline(json.dumps(spec))
n = p.execute()
print("points processed:", n)
for f in ("out/t_0431_dtm.tif", "out/t_0431_dsm.tif", "out/t_0431_clean.laz"):
    assert Path(f).exists(), f"missing {f}"

Explicit inputs on every stage is verbose but unambiguous; you can omit inputs on a stage whose input is simply the stage before it, but writing them all out makes the graph readable at a glance.

What branching saves Two stacked bars of wall time for producing a DTM, a DSM and a LAZ from one tile. Three separate pipelines each read and clean the tile, totalling 138 seconds, of which read and clean account for 84. One branched pipeline reads and cleans once, totalling 82 seconds. 3 separate runs 138 s 1 branched run 82 s read + decompress + clean branch-specific work illustrative 1 km² tile, 40 M points, LAZ input

# Key Parameter Table

Option On Meaning
tag any stage Names the stage’s output so other stages can refer to it
inputs any non-reader stage List of tags this stage consumes; several tags merge their views
--stage.<tag>.<option> command line Override one tagged stage’s option
filters.merge after several inputs Explicitly merges views into one before further processing
writers.* branch end Every branch ending in a writer is executed

# Verification

  • All outputs written. Each writer’s file exists after execution; a branch whose input tag is misspelled fails validation rather than silently being skipped.
  • Consistent extents. The DTM and DSM rasters should share their origin and size when the same resolution and bounds apply; compare with gdalinfo.
  • Point counts per branch. Run with --verbose 4 to see how many points each stage received.

# Gotchas and Edge Cases

Branches receive copies. When two stages consume the same tag, each branch works on its own view. A filter in one branch that modifies a dimension — filters.assign changing classes, say — does not affect the other branch. That is usually what you want, but it also means memory holds more than one view at a time for stages that copy.

Multiple inputs merge. A stage with "inputs": ["a", "b"] receives both views. For writers that means both sets of points are written together — useful for merging, surprising if you meant to pick one.

No streaming. A tag consumed by more than one stage forces standard execution. If memory is the constraint, split the work into a streaming cleaning pass that writes an intermediate LAZ, then a branched pass; see splitting a blocking pipeline into two passes.

Two inputs means both sets of points Two branches tagged ground and first feed one writer whose inputs list both tags. The writer receives the union of both point sets, so ground points that are also first returns appear twice in the output. A note recommends one writer per branch unless a merge is intended. tag: ground tag: first writers.las inputs: ["ground", "first"] union of both views: first-return ground twice one writer per branch unless you genuinely mean to merge

Readers do not take inputs. Tags on readers name them for later stages; inputs on a reader is meaningless. Multiple readers feeding one stage is the merge pattern covered in merging multiple readers in one pipeline.

# Frequently Asked Questions

How do I write several outputs from one PDAL pipeline?

Tag the stage whose output all outputs share, then start each output’s branch with a stage that lists that tag in inputs and end it with its own writer. PDAL runs every branch that ends in a writer.

Do branches share memory or copy the points?

Each consumer of a tag gets its own view of the points, so changes in one branch do not leak into another. Depending on the stages involved, that can mean extra memory for the duration of the run.

Can a branching pipeline run in streaming mode?

No. A stage output consumed by more than one downstream stage requires standard mode. Split the work into a streaming pass that writes an intermediate file and a branched pass over that file if memory is tight.

What happens if I reference a tag that does not exist?

Validation fails with an error naming the unknown input. Run pdal pipeline with the validate flag before executing new branched pipelines.