Reading LAS and LAZ Files with laspy
To read a LAS or LAZ file in Python, open it with laspy, read the header before allocating anything, and iterate the points in chunks using the lower-case x, y and z accessors so the header scale and offset are applied. The two failures that dominate first attempts are reading the raw integer arrays, which yields coordinates at the wrong origin and scale, and reading the whole file, which for an airborne delivery is an out-of-memory kill. This page is part of Point Cloud and Reality Capture Integration.
How LAS Stores a Point
A LAS file is a header followed by fixed-size point records. Each record stores X, Y and Z as 32-bit signed integers, and the header carries a scale and an offset per axis. The real coordinate is raw * scale + offset.
This is why a file spanning kilometres can carry millimetre resolution in 32 bits, and it is why the distinction between accessors matters. las.X gives the stored integer; las.x applies the transformation. The difference does not raise, it just relocates the survey.
The header also declares the point format, which decides what attributes exist. Intensity and return number are present in every format; GPS time, colour, and near-infrared are not. Code that reads las.red on a format without colour fails, and code that assumes las.classification is meaningful succeeds on a file where nothing assigned it.
Finally, the coordinate reference system lives in the variable-length records, either as WKT or as legacy GeoTIFF keys depending on the LAS version. laspy exposes both through one accessor, which returns None when the file declares nothing.
Production-Ready Script
# laspy[lazrs]>=2.5, numpy>=1.24, Python 3.9+
from __future__ import annotations
from dataclasses import dataclass
import numpy as np
import laspy
@dataclass(frozen=True)
class CloudHeader:
count: int
point_format: int
scales: tuple[float, float, float]
offsets: tuple[float, float, float]
mins: tuple[float, float, float]
maxs: tuple[float, float, float]
crs_wkt: str | None
has_classification: bool
has_colour: bool
def inspect(path: str) -> CloudHeader:
"""Header only — one seek, and it decides every later choice."""
with laspy.open(path) as reader:
h = reader.header
dims = {d.name for d in h.point_format.dimensions}
crs = h.parse_crs()
return CloudHeader(
count=h.point_count,
point_format=h.point_format.id,
scales=tuple(h.scales), offsets=tuple(h.offsets),
mins=tuple(h.mins), maxs=tuple(h.maxs),
crs_wkt=crs.to_wkt() if crs is not None else None,
has_classification="classification" in dims,
has_colour={"red", "green", "blue"} <= dims,
)
def read_filtered(
path: str,
*,
classes: set[int] | None = None,
bbox: tuple[float, float, float, float] | None = None,
chunk: int = 2_000_000,
) -> np.ndarray:
"""Chunked read returning an (n, 3) float64 array of REAL coordinates."""
meta = inspect(path)
if meta.crs_wkt is None:
raise ValueError(f"{path}: no CRS in the variable-length records")
if classes and not meta.has_classification:
raise ValueError(f"{path}: point format {meta.point_format} has no classification")
kept: list[np.ndarray] = []
with laspy.open(path) as reader:
for points in reader.chunk_iterator(chunk):
mask = np.ones(len(points), dtype=bool)
if classes:
mask &= np.isin(points.classification, list(classes))
# .x/.y/.z apply the header scale and offset; .X/.Y/.Z do not.
x, y, z = np.asarray(points.x), np.asarray(points.y), np.asarray(points.z)
if bbox:
minx, miny, maxx, maxy = bbox
mask &= (x >= minx) & (x <= maxx) & (y >= miny) & (y <= maxy)
if mask.any():
kept.append(np.column_stack((x[mask], y[mask], z[mask])))
if not kept:
return np.empty((0, 3), dtype=float)
return np.vstack(kept)
if __name__ == "__main__":
meta = inspect("survey.laz")
print(f"{meta.count:,} points, format {meta.point_format}, "
f"classification={meta.has_classification}")
ground = read_filtered("survey.laz", classes={2})
print(f"ground points: {len(ground):,}")
Key implementation notes:
inspectnever reads a point. On a 400-million-point file that is the difference between a decision and a memory kill.- The CRS check raises rather than defaulting. An unlabelled cloud assumed into the project system is the point-cloud equivalent of a DXF assumed into millimetres.
- Filtering happens inside the chunk loop, so peak memory is one chunk plus the retained points rather than the whole file.
np.isinhandles a set of classes in one vectorised pass; a Python-level membership test per point dominates the runtime.- The classification availability check fails early with a useful message instead of raising an attribute error deep in the loop.
Compatibility Matrix
| Component | Supported range | Notes |
|---|---|---|
laspy |
>=2.5 |
chunk_iterator, parse_crs |
| LAZ support | lazrs or laszip backend |
LAS works without one; LAZ does not |
| LAS versions | 1.2 – 1.4 | 1.4 adds WKT CRS and extended point formats |
| Point formats | 0 – 10 | attribute availability varies; check before reading |
numpy |
>=1.24 |
column stacking and boolean masking |
Fallback Strategies
1. LaspyException on a LAZ file. No compression backend. Install laspy[lazrs], and assert the backend at start-up so the failure surfaces at deploy rather than on the first compressed delivery.
2. Coordinates are absurdly large. Raw accessors. Change las.X to las.x; the magnitude of the error is the reciprocal of the header scale, typically a thousand.
3. No CRS in the file. Common in processing intermediates and in PLY or PCD conversions. Obtain it from the survey report and record it explicitly rather than assuming — and prefer keeping the cloud in LAS so the metadata has somewhere to live.
4. Classification is all zeros. The producer never ran a classifier. Filtering on class 2 then returns nothing, which looks like a bug in the filter. Check the distinct classification values before relying on them.
5. The bounding box filter returns nothing. The bbox is in a different coordinate system from the cloud. Compare the filter bounds against meta.mins and meta.maxs before assuming the file is empty of the area of interest.
FAQ
What is the difference between las.x and las.X?
The lower-case accessors return real coordinates: the stored integer multiplied by the header scale and added to the header offset. The upper-case accessors return the raw stored integers. Reading the raw values gives a cloud with the correct shape at the wrong origin and the wrong scale — the classic symptom is a site that appears to be thousands of kilometres across.
Why does laspy refuse to open my LAZ file?
LAZ is compressed and laspy needs a compression backend to decode it. Install laspy[lazrs] or laspy[laszip]. Without one, LAS opens and LAZ raises — which in a container is a dependency that was present in development and absent in the image.
How do I read only the ground points?
Filter on the classification field, which uses the ASPRS class numbering — 2 is ground. Do it inside the chunk loop so only ground points accumulate. Treat the classification as the producer’s claim rather than as ground truth, and validate the resulting surface before building terrain from it.
Related Pages
- Point Cloud and Reality Capture Integration — parent reference on density, coordinate metadata and registration
- Clipping Point Clouds to CAD Boundaries in Python — the next step for most pipelines that read a cloud
- Aligning a Point Cloud to a BIM Model with ICP — sibling guide on registering the cloud once it is read