Passing NumPy Arrays into a PDAL Pipeline
TL;DR: Build a NumPy structured array whose field names are PDAL dimension names (X, Y, Z as float64, Classification as uint8, Intensity as uint16 …), then either pdal.Pipeline(json.dumps({"pipeline": [stages...]}), arrays=[arr]) with no reader in the spec, or pdal.Filter.smrf().pipeline(arr). After execute(), results are in pipeline.arrays, and a writer stage can put them straight into LAS, LAZ or COPC.
# Context and Motivation
This guide is part of Programmable Python Filters. Points do not always start life in a LAS file. They come out of a photogrammetry library, a simulation, a CSV export from survey software, a deep-learning model that predicted classes, or a laspy script that already did half the work. Writing them to a temporary LAS just so PDAL can read them back wastes time and disk. The Python bindings accept arrays directly: the array takes the place of the reader, and every downstream stage — SMRF, HAG, reprojection, writers — behaves exactly as it would on file input.
The only rule is that PDAL has to recognize the array’s fields as dimensions, which comes down to names and types.
# Prerequisites and Assumptions
python-pdal3.x, which acceptsarrays=in thePipelineconstructor and providesstage.pipeline(arr).- NumPy structured arrays (not plain 2D arrays); a 2D
(N, 3)array has no field names for PDAL to map. - Coordinates in a known CRS. Arrays carry no CRS, so stages that care — reprojection, writers — need it supplied explicitly.
# Step-by-Step Implementation
# Step 1 — Define a dtype with PDAL dimension names
Use PDAL’s canonical names and sensible types. Unknown names are kept as custom dimensions; misspelled standard names (classification in lower case) become custom dimensions too, which is a common silent bug.
# Step 2 — Fill the array
Copy coordinates and attributes from wherever they came from. Unset fields default to zero.
# Step 3 — Build the pipeline without a reader
Write the stage list starting from the first filter, and pass arrays=[arr].
# Step 4 — Supply the CRS where needed
Set in_srs on filters.reprojection and a_srs on writers, because the array has no spatial reference.
# Step 5 — Read the results
pipeline.arrays returns a list of output arrays — one per output view — with any new dimensions appended.
# Complete Working Example
Classifying ground on points from a photogrammetry export and writing LAZ and COPC:
"""Feed an in-memory point set through SMRF and HAG, then write LAZ and COPC."""
from __future__ import annotations
import json
import numpy as np
import pandas as pd
import pdal
CRS = "EPSG:6347+5703"
DTYPE = np.dtype([
("X", "f8"), ("Y", "f8"), ("Z", "f8"),
("Intensity", "u2"), ("Red", "u2"), ("Green", "u2"), ("Blue", "u2"),
("Classification", "u1"), ("ReturnNumber", "u1"), ("NumberOfReturns", "u1"),
])
def from_csv(path: str) -> np.ndarray:
df = pd.read_csv(path, usecols=["x", "y", "z", "r", "g", "b"])
arr = np.zeros(len(df), dtype=DTYPE)
arr["X"], arr["Y"], arr["Z"] = df.x, df.y, df.z
# 8-bit colour from the export scaled to the 16-bit range LAS expects.
arr["Red"], arr["Green"], arr["Blue"] = df.r * 257, df.g * 257, df.b * 257
arr["ReturnNumber"] = 1
arr["NumberOfReturns"] = 1
arr["Classification"] = 1
return arr
def classify(arr: np.ndarray) -> np.ndarray:
stages = [
{"type": "filters.outlier", "method": "statistical", "mean_k": 12, "multiplier": 2.5},
{"type": "filters.range", "limits": "Classification![7:7]"},
{"type": "filters.smrf", "slope": 0.2, "window": 16, "threshold": 0.45, "cell": 0.5},
{"type": "filters.hag_nn", "count": 2},
{"type": "writers.las", "filename": "out/site_ground.laz", "a_srs": CRS,
"minor_version": 4, "dataformat_id": 7, "extra_dims": "HeightAboveGround=float"},
{"type": "writers.copc", "filename": "out/site_ground.copc.laz", "a_srs": CRS},
]
p = pdal.Pipeline(json.dumps({"pipeline": stages}), arrays=[arr])
n = p.execute()
out = p.arrays[0]
print(f"{n:,} points; ground share {(out['Classification'] == 2).mean():.1%}")
return out
if __name__ == "__main__":
classify(from_csv("exports/site_dense_cloud.csv"))The same run with the stage API — one line for a single filter:
ground = pdal.Filter.smrf(slope=0.2, window=16, threshold=0.45).pipeline(arr)
ground.execute()
result = ground.arrays[0]# Key Parameter Table
| Dimension | Recommended type | Notes |
|---|---|---|
X, Y, Z |
f8 |
Always doubles; writers apply scale and offset |
Intensity |
u2 |
16-bit in LAS |
Classification |
u1 |
ASPRS codes 0–255 |
ReturnNumber, NumberOfReturns |
u1 |
Set to 1 for single-return sources |
Red, Green, Blue |
u2 |
16-bit; scale 8-bit colour by 257 |
GpsTime |
f8 |
Optional; zero if unknown |
| custom fields | any | Kept as extra dimensions; write with extra_dims |
# Verification
- Round trip. Write the array to LAS through a writer stage, read it back with a reader, and compare fields; coordinates should match to within the writer’s scale.
- Dimension names. Print
p.arrays[0].dtype.namesafter execution. Any duplicate-looking names (Classificationandclassification) indicate a naming mistake. - CRS in output.
pdal info --metadata out/site_ground.lazmust show the CRS given ina_srs.
names = p.arrays[0].dtype.names
lower = [n for n in names if n.lower() in {"classification", "intensity"} and n not in
{"Classification", "Intensity"}]
assert not lower, f"mis-cased dimension names: {lower}"# Gotchas and Edge Cases
Plain 2D arrays. np.column_stack([x, y, z]) has no field names and cannot be passed as a PDAL array. Convert with np.core.records.fromarrays or build a structured array as above.
Missing CRS. Reprojection needs in_srs and writers need a_srs when input comes from arrays. Without them, the output file has no CRS and every downstream tool guesses.
Multiple arrays. arrays=[a, b] passes two input views; follow them with filters.merge if the next stage should see them together, exactly as with multiple readers.
Large arrays and memory. Passing an array does not copy it into a file, but PDAL builds its own point table from it, so memory holds both for the run. For very large arrays, write to LAZ in chunks with laspy and let PDAL stream from the file instead.
# Frequently Asked Questions
How do I pass a NumPy array to PDAL?
Build a structured array with PDAL dimension names as fields, then pass it with the arrays argument of pdal.Pipeline along with a pipeline that has no reader, or call a stage’s pipeline method with the array. Execute and read the results from pipeline.arrays.
What dtype should X, Y and Z have?
Float64. PDAL stores coordinates as doubles internally, and writers convert them to scaled integers using the scale and offset you set.
Why did SMRF not update my classification field?
Probably because the field is named in a different case, such as lower-case classification, which PDAL treats as a separate custom dimension. Use the exact standard name Classification.
Can I write the array straight to COPC?
Yes. Put writers.copc at the end of the pipeline with a_srs set. The array is processed by any earlier stages and then written as a COPC file.
# Related
- Programmable Python Filters — Python inside PDAL, the reverse direction
- Writing a filters.python Stage with NumPy — per-view Python code in a pipeline
- Building Pipelines with the Python Stage API — stage.pipeline(arr) and composition
- Reading LAS into NumPy with laspy — getting arrays from files without PDAL
- Running SMRF on Photogrammetric Point Clouds — the photogrammetry case in depth