Copying Dimensions with filters.ferry

TL;DR: {"type": "filters.ferry", "dimensions": "Z=>Elevation, HeightAboveGround=>Z"} copies Z into a new Elevation dimension and then HeightAboveGround into Z — a height-normalized cloud that keeps its original elevations. "=>NewDim" with nothing on the left creates an empty dimension that a later stage such as filters.overlay can fill. Ferry copies; it never deletes the source.

# Context and Motivation

This guide is part of Attribute Mapping in PDAL Pipelines. Many PDAL stages read or write a fixed dimension: writers.gdal rasterizes Z by default, filters.covariancefeatures always writes Planarity, filters.smrf writes Classification. filters.ferry is the small adapter that makes those fixed names work for you. It lets you point a stage at a different dimension by copying it into the name the stage expects, keep a backup before a stage overwrites something, and create empty dimensions for stages that fill existing ones rather than creating their own.

It is also one of the cheapest stages in PDAL — a per-point copy that streams — so there is no reason to avoid it for clarity.

Copy, back up, create Three rows. Copy: HeightAboveGround is copied into Z so writers.gdal rasterizes heights. Back up: Classification is copied into OriginalClass before SMRF overwrites Classification. Create: an empty WaterId dimension is created so filters.overlay has a dimension to fill. copy HeightAboveGround Z writers.gdal now rasterizes height back up Classification OriginalClass survives SMRF's overwrite create (nothing) WaterId = 0 ready for filters.overlay

# Prerequisites and Assumptions

  • PDAL 2.x.
  • Source dimensions must exist at the point the ferry stage runs; a new dimension created with =>Name is a double initialized to zero.
  • For height normalization, HeightAboveGround from filters.hag_nn, filters.hag_delaunay or filters.hag_dem upstream.

# Step-by-Step Implementation

# Step 1 — Write mappings as source=>destination

Several mappings go in one comma-separated string and are applied left to right, so Z=>Elevation, HeightAboveGround=>Z backs up Z before overwriting it.

# Step 2 — Create empty dimensions where stages expect them

filters.overlay writes into an existing dimension. "=>BuildingId" creates it first.

# Step 3 — Back up before destructive stages

Before filters.smrf, filters.pmf or a filters.assign that rewrites classes, ferry Classification to a backup so you can compare or restore.

# Step 4 — Normalize heights for downstream tools

Ferry HeightAboveGround into Z before writers.gdal for a canopy height model, before filters.litree for tree segmentation, or before writing a normalized LAZ for software that expects heights in Z.

# Step 5 — Write the extra dimensions you want to keep

A ferried backup only reaches the output file if the writer includes it via extra_dims.

# Complete Working Example

A height-normalized LAZ that keeps original elevations and original classes, plus a CHM raster, in one pipeline:

json
{
  "pipeline": [
    "tiles/t_0431.laz",
    { "type": "filters.range", "limits": "Classification![7:7],Classification![18:18]" },
    { "type": "filters.ferry", "dimensions": "Classification=>VendorClass" },
    { "type": "filters.smrf", "slope": 0.15, "window": 18, "threshold": 0.5 },
    { "type": "filters.hag_nn", "count": 2 },
    { "type": "filters.ferry", "dimensions": "Z=>Elevation, HeightAboveGround=>Z", "tag": "normalized" },
    { "type": "writers.las", "inputs": ["normalized"], "filename": "out/t_0431_normalized.laz",
      "minor_version": 4, "dataformat_id": 6,
      "extra_dims": "Elevation=double,VendorClass=uint8" },
    { "type": "filters.range", "inputs": ["normalized"], "limits": "Z[0:70]" },
    { "type": "writers.gdal", "filename": "out/t_0431_chm.tif", "resolution": 0.5,
      "radius": 0.75, "output_type": "max", "data_type": "float32" }
  ]
}

A Python check that the ferry did what it claims:

python
import json

import numpy as np
import pdal

p = pdal.Pipeline(json.dumps({"pipeline": ["out/t_0431_normalized.laz"]}))
p.execute()
a = p.arrays[0]
ground = a["Classification"] == 2
print("ground Z (should be ~0):", np.round(np.percentile(a["Z"][ground], [5, 50, 95]), 3))
print("elevation range kept:", a["Elevation"].min().round(2), "to", a["Elevation"].max().round(2))
changed = (a["VendorClass"] != a["Classification"]).mean()
print(f"classes changed by SMRF: {changed:.1%}")

Ground points should have Z near zero after normalization; original elevations survive in Elevation; and the vendor’s classes are preserved alongside SMRF’s for comparison.

Terrain removed, objects kept Left: a hillside profile in raw elevation with trees on the slope; tree tops rise with the terrain. Right: the same points after ferrying HeightAboveGround into Z; the ground is flat at zero and trees of equal height have equal tops. Z = elevation Z = height above ground ground at 0; equal trees, equal tops

# Ferry Versus Doing It in NumPy

Everything ferry does could be done in Python after pipeline.arrays: copy a column, add a field with numpy.lib.recfunctions.append_fields, write the array back with a writer. For interactive work that is fine. In production pipelines, ferry is better for three reasons.

First, it keeps the operation inside PDAL’s execution, which means it streams, needs no Python copy of the point table, and works in the command-line tool as well as the bindings. A normalized-height LAZ for a 90-million-point tile is a single pdal pipeline call instead of a script that has to hold the tile in memory.

Second, it puts the intent in the pipeline JSON. A reviewer reading "Z=>Elevation, HeightAboveGround=>Z" knows exactly what the output’s Z means without opening any Python.

Third, it composes with tags and branches. The example writes a normalized LAZ and a CHM from one ferry stage; doing the same in Python would mean either two reads or a second pipeline fed from arrays. Reach for NumPy when the transformation is genuinely computational — a scaled intensity, a derived index — and for ferry whenever the job is simply moving values between names.

# Key Parameter Table

Mapping Effect Typical use
A=>B Copy A into B, creating B if needed Rename for a stage that expects B
=>B Create B as zeros Target for filters.overlay
A=>B, C=>A Back up A, then overwrite it with C Height normalization
Classification=>X Snapshot classes Before SMRF, PMF, assign
writer extra_dims B=type Persist ferried dimensions

# Verification

  • Schema contains the new names. pdal info --schema on the output lists every ferried dimension you asked the writer to keep.
  • Values copied exactly. Before any later stage touches them, the source and destination must be equal point for point.
  • Normalized ground at zero. For height normalization, the median Z of ground points should be within a few centimetres of zero.

# Gotchas and Edge Cases

Order within the string matters. HeightAboveGround=>Z, Z=>Elevation overwrites Z first and then copies the new Z into Elevation — losing the original elevations. Back up first.

Types of new dimensions. A dimension created by ferry is a double. When writing, declare a smaller type in extra_dims if precision allows; eight bytes per point per dimension adds up.

Ferry is not rename. The source dimension remains. If a later stage should not see it, that is fine — unused dimensions cost only memory — but do not expect ferry to remove it.

Back up first, then overwrite Two sequences applied to a point with elevation 212.4 and height above ground 18.3. Backing up first stores 212.4 in Elevation and then sets Z to 18.3. Overwriting first sets Z to 18.3 and then copies 18.3 into Elevation, losing the original elevation. Z=>Elevation, HeightAboveGround=>Z Elevation = 212.4 Z = 18.3 original elevation kept HeightAboveGround=>Z, Z=>Elevation Z = 18.3 Elevation = 18.3 original elevation lost

Reprojection after normalization. Once Z holds heights, a vertical datum transformation would corrupt them. Reproject before normalizing, never after.

# Frequently Asked Questions

What does filters.ferry do?

It copies the values of one dimension into another, creating the destination if it does not exist, or creates an empty dimension when no source is given. The source dimension is left unchanged.

How do I make a height-normalized point cloud in PDAL?

Compute HeightAboveGround with a HAG filter, then ferry Z into a backup dimension and HeightAboveGround into Z in one ferry stage, in that order. Write the backup with extra_dims if you want to keep original elevations.

Why create an empty dimension with ferry?

Some stages, such as filters.overlay, write into an existing dimension rather than creating one. Ferry with an empty source creates the dimension, initialized to zero, so the next stage has somewhere to write.

Does filters.ferry work in streaming mode?

Yes. It copies values point by point and streams, adding negligible time and only the memory of the new dimensions.