Adding a New Dimension from a Python Filter
TL;DR: Three declarations must agree or the dimension vanishes: add_dimension: "Name=type" on filters.python, an outs["Name"] array of that exact dtype in the function, and extra_dims: "Name=type" on a LAS 1.4 writer with point format 6 or above.
# Context and Motivation
This guide is part of Programmable Python Filters in PDAL. Modifying an existing dimension is straightforward; creating one is where the three-way agreement between the pipeline, the function and the writer starts to matter, and where a mismatch produces a file that looks fine and has quietly lost your work.
The reason it takes three declarations is that a PDAL dimension is three different things at three moments. Inside the pipeline it is a column in the point layout, allocated before any stage runs. Inside your function it is a NumPy array with a dtype. Inside the LAS file it is an entry in the extra-bytes descriptor record with a documented type and name. Nothing propagates automatically between those three, and each one fails differently: a missing add_dimension throws, a dtype mismatch truncates, and a missing extra_dims writes a perfectly valid file with no trace of the dimension at all.
# Prerequisites and Assumptions
| Requirement | Detail |
|---|---|
| PDAL | 2.4+ with Python support |
| Output format | LAS 1.4, point format 6 or above — earlier formats have no standard extra-bytes mechanism |
| A type decision | make it before writing code; changing it later means rewriting every consumer |
laspy |
for reading the extra-bytes descriptor back |
# Step-by-Step Implementation
# Step 1 — Choose the narrowest type that holds the values
A confidence score in 0–100 is uint8, one byte per point. The same score as double is eight, and on an 18 million point tile that difference is 126 MB in every copy of the file forever. Types available include uint8, int8, uint16, int16, uint32, int32, float, double.
# Step 2 — Declare it on the filter
{"type": "filters.python", "script": "score.py", "function": "confidence",
"module": "score", "add_dimension": "Confidence=uint8"}Several dimensions are declared as a list: "add_dimension": ["Confidence=uint8", "Roughness=float"].
# Step 3 — Produce exactly that dtype
outs["Confidence"] = score.astype(np.uint8)# Step 4 — Declare it on the writer
{"type": "writers.las", "filename": "out.laz", "minor_version": 4,
"dataformat_id": 6, "extra_dims": "Confidence=uint8", "forward": "all"}"extra_dims": "all" also works and is blunter: it writes every non-standard dimension in the layout, including any a previous stage added that you did not want persisted.
# Step 5 — Read it back before believing it
import laspy
with laspy.open("out.laz") as fh:
print([d.name for d in fh.header.point_format.extra_dimensions])# Complete Working Example
"""Add a per-point confidence dimension derived from local return geometry."""
import numpy as np
# Weights are configuration, not logic — pdalargs can override them per run.
DEFAULTS = {"w_single": 40, "w_last": 30, "w_intensity": 30}
def confidence(ins, outs):
args = {**DEFAULTS, **globals().get("pdalargs", {})}
ret = ins["ReturnNumber"].astype(np.int16)
n_ret = ins["NumberOfReturns"].astype(np.int16)
intensity = ins["Intensity"].astype(np.float64)
single = (n_ret == 1)
last = (ret == n_ret) & ~single
# Intensity contributes on a normalised 0-1 scale, robust to outliers.
lo, hi = np.percentile(intensity, [5, 95])
span = max(hi - lo, 1.0)
inten_score = np.clip((intensity - lo) / span, 0.0, 1.0)
score = (single * float(args["w_single"])
+ last * float(args["w_last"])
+ inten_score * float(args["w_intensity"]))
outs["Confidence"] = np.clip(score, 0, 255).astype(np.uint8)
return True
def test_single_return_scores_highest():
n = 3
ins = {
"ReturnNumber": np.array([1, 1, 2], dtype=np.uint8),
"NumberOfReturns": np.array([1, 3, 3], dtype=np.uint8),
"Intensity": np.array([5000, 5000, 5000], dtype=np.uint16),
}
outs = {}
confidence(ins, outs)
assert outs["Confidence"].dtype == np.uint8
assert len(outs["Confidence"]) == n
assert outs["Confidence"][0] > outs["Confidence"][2]The pipeline that uses it:
{
"pipeline": [
{"type": "readers.las", "filename": "tile_0431.laz"},
{"type": "filters.python", "script": "score.py", "function": "confidence",
"module": "score", "add_dimension": "Confidence=uint8",
"pdalargs": {"w_intensity": 20}},
{"type": "writers.las", "filename": "tile_0431_scored.laz",
"compression": "laszip", "minor_version": 4, "dataformat_id": 6,
"extra_dims": "Confidence=uint8", "forward": "all"}
]
}# Key Parameter Table
| Type | Bytes | Range | Use for |
|---|---|---|---|
uint8 |
1 | 0–255 | flags, scores, small class codes |
int8 |
1 | −128–127 | signed offsets, deltas |
uint16 |
2 | 0–65,535 | counts, scaled ratios |
int16 |
2 | ±32,767 | scan angles, signed residuals |
float |
4 | ~7 digits | heights above ground, distances |
double |
8 | ~16 digits | rarely justified for a derived dimension |
# Verification
The descriptor exists. laspy’s point_format.extra_dimensions is the authoritative list; if your name is absent, extra_dims was wrong.
The type is what you asked for. Read arr["Confidence"].dtype after a round trip. A uint8 that returns as float64 means the two declarations disagreed and PDAL widened it.
The values are not all zero. A dimension that exists and is uniformly zero means the function never ran, ran on an empty buffer, or wrote under a different key.
# Gotchas and Edge Cases
LAS 1.2 cannot carry it. Writing a file with minor_version: 2 discards extra dimensions without an error. Point format matters too: formats 0 to 5 have no standard extra-bytes mechanism, so use format 6 or above — the version differences are laid out in LAS/LAZ file structure.
extra_dims: "all" writes more than you meant. Intermediate dimensions added by other stages come along, inflating the file with values nobody downstream reads.
A name collision with a standard dimension is silently accepted. Adding a dimension called Intensity shadows the real one. Prefix your own names if there is any doubt.
Compression does not save you. LAZ compresses extra dimensions poorly compared with coordinates, because they lack the spatial correlation the encoder exploits — one of the effects visible in converting LAS to LAZ.
# Frequently Asked Questions
Why does my new dimension not appear in the output file?
Almost always because extra_dims was not set on the writer. add_dimension makes the dimension exist inside the pipeline; extra_dims makes the writer store it. Without the second, the pipeline succeeds, the file is valid, and the dimension is simply absent.
Which type should I choose?
The narrowest one that holds your values. A flag or a 0 to 100 score fits in a uint8 at one byte per point; the same value stored as a double costs eight, which on an 18 million point tile is 147 megabytes instead of 18 — in every copy of the file, forever.
Can I add a dimension to a LAS 1.2 file?
Not in a way any other tool will read. LAS 1.2 and point formats 0 to 5 have no standard extra-bytes mechanism, so the writer discards the dimension without an error. Write LAS 1.4 with point format 6 or above.
What happens if the declared type and the NumPy dtype disagree?
PDAL reconciles them, and the reconciliation is not always the one you wanted — values may be truncated or the dimension widened. Cast explicitly in the function so the two declarations state the same thing.
# Related
- Programmable Python Filters in PDAL — the parent guide to the stage and its cost model
- Writing a filters.python Stage with NumPy — the signature and casting rules for the function itself
- Debugging and Profiling filters.python — what to do when the dimension is present and empty
- Mapping Custom Attributes in PDAL Pipelines — moving dimensions between names with ferry and assign
- LAS/LAZ File Structure — where the extra-bytes descriptor lives in the file