Source code: LiDAR point clouds in Python with laspy: from a LAZ file to a DTM, DSM and canopy height model

Runnable Python scripts, requirements and console output for the tutorial “LiDAR point clouds in Python with laspy: from a LAZ file to a DTM, DSM and…

Last updated

These are the exact scripts used to produce every figure and number in LiDAR point clouds in Python with laspy: from a LAZ file to a DTM, DSM and canopy height model. Download them, install the requirements into a fresh virtual environment and run them from this folder.

Run it
python -m venv .venv
.venv\Scripts\activate # macOS/Linux: source .venv/bin/activate
pip install -r requirements.txt
python lidar_dtm_chm.py

lidar_dtm_chm.py

lidar_dtm_chm.py
"""LiDAR point clouds in Python with laspy: stream USGS 3DEP lidar, read a LAZ file,
remove noise, build a DTM, DSM and canopy height model (CHM), and measure the
Washington Monument.
Data: USGS 3DEP lidar (MD_VA_Sandy_NCR_2014), streamed as Cloud Optimised Point
Cloud (COPC) files from the Microsoft Planetary Computer. No account needed.
Run with: python lidar_dtm_chm.py
"""
import platform
import time
from importlib.metadata import version
from pathlib import Path
import laspy
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
import planetary_computer
import pystac_client
import rasterio
import scipy
from matplotlib.colors import BoundaryNorm, LightSource, ListedColormap
from pyproj import Transformer
from rasterio.crs import CRS
from rasterio.transform import from_origin
from scipy import ndimage
from scipy.interpolate import griddata
from scipy.spatial import cKDTree
HERE = Path(__file__).parent
OUT = HERE / "output"
DATA = HERE / "data"
OUT.mkdir(exist_ok=True)
DATA.mkdir(exist_ok=True)
plt.rcParams.update({"figure.dpi": 150, "savefig.bbox": "tight", "font.size": 9})
print("=== versions ===")
print(f"python {platform.python_version()} | laspy {laspy.__version__} | lazrs {version('lazrs')} | "
f"numpy {np.__version__} | scipy {scipy.__version__} | rasterio {rasterio.__version__}")
# ---------------------------------------------------------------- 1. find tiles
LON, LAT = -77.0353, 38.8895 # Washington Monument
EPSG = 26918 # NAD83 / UTM zone 18N, the CRS of this lidar project
to_utm = Transformer.from_crs(4326, EPSG, always_xy=True)
to_ll = Transformer.from_crs(EPSG, 4326, always_xy=True)
mx, my = to_utm.transform(LON, LAT)
xmin, xmax, ymin, ymax = mx - 500, mx + 500, my - 400, my + 400
lon0, lat0 = to_ll.transform(xmin, ymin)
lon1, lat1 = to_ll.transform(xmax, ymax)
catalog = pystac_client.Client.open("https://planetarycomputer.microsoft.com/api/stac/v1",
modifier=planetary_computer.sign_inplace)
items = list(catalog.search(collections=["3dep-lidar-copc"], bbox=[lon0, lat0, lon1, lat1]).items())
print("\n=== STAC search ===")
print(f"area of interest: {xmax - xmin:.0f} m x {ymax - ymin:.0f} m centred on the Washington Monument")
for it in items:
print(f"{it.id} points={it.properties['pc:count']:,}")
# ------------------------------------------------- 2. stream only the points we need
print("\n=== streaming COPC ===")
bounds = laspy.Bounds(mins=np.array([xmin, ymin]), maxs=np.array([xmax, ymax]))
parts, header = [], None
t = time.perf_counter()
for it in items:
with laspy.CopcReader.open(it.assets["data"].href) as reader:
if header is None:
header = reader.header
print(f"LAS {header.version}, point format {header.point_format.id}, "
f"CRS: {header.parse_crs().name}")
pts = reader.query(bounds)
print(f"{it.id[-20:]}: {len(pts):,} points in the area of interest")
if len(pts):
parts.append(pts)
print(f"streamed in {time.perf_counter() - t:.1f} s")
# Merge into one LasData and save it as an ordinary LAZ file.
# Each tile has its own scale/offset, so copy real-world x/y/z rather than the raw integers.
out_header = laspy.LasHeader(point_format=header.point_format.id, version=header.version)
out_header.offsets = np.array([xmin, ymin, 0.0])
out_header.scales = np.array([0.01, 0.01, 0.01])
out_header.add_crs(header.parse_crs())
merged = laspy.LasData(out_header)
merged.points = laspy.ScaleAwarePointRecord.zeros(sum(len(p) for p in parts), header=out_header)
for dim in header.point_format.dimension_names:
if dim not in ("X", "Y", "Z"):
merged[dim] = np.concatenate([np.asarray(p[dim]) for p in parts])
merged.x = np.concatenate([np.asarray(p.x) for p in parts])
merged.y = np.concatenate([np.asarray(p.y) for p in parts])
merged.z = np.concatenate([np.asarray(p.z) for p in parts])
laz_path = DATA / "national_mall.laz"
merged.write(laz_path)
print(f"wrote data/{laz_path.name} ({laz_path.stat().st_size / 1e6:.1f} MB)")
# ---------------------------------------------------------------- 3. read and inspect
print("\n=== laspy.read ===")
las = laspy.read(laz_path)
print(f"points: {las.header.point_count:,}")
print(f"dimensions: {list(las.point_format.dimension_names)}")
print(f"x {las.x.min():.2f}..{las.x.max():.2f} y {las.y.min():.2f}..{las.y.max():.2f} "
f"z {las.z.min():.2f}..{las.z.max():.2f}")
area = (xmax - xmin) * (ymax - ymin)
print(f"density: {las.header.point_count / area:.1f} points/m²")
# Codes 17, 18 and 25 are this project's overlap classes: the standard class + 16.
NAMES = {1: "Unclassified", 2: "Ground", 7: "Noise", 9: "Water", 10: "Ignored ground",
11: "Withheld", 17: "Overlap unclassified", 18: "Overlap ground", 25: "Overlap water"}
x, y, z = np.asarray(las.x), np.asarray(las.y), np.asarray(las.z)
cls = np.asarray(las.classification)
rn, nr = np.asarray(las.return_number), np.asarray(las.number_of_returns)
print("\nclass points share z p1..p99 (m) name")
for c, n in zip(*np.unique(cls, return_counts=True)):
zp = np.percentile(z[cls == c], [1, 99])
print(f"{c:>5} {n:>9,} {n / len(cls):6.1%} {zp[0]:6.1f}..{zp[1]:6.1f} {NAMES.get(c, '?')}")
print(f"\npoints from pulses with more than one return: {(nr > 1).mean():.1%}")
# Chunked reading: how you would process a file too big for memory.
counts = np.zeros(256, dtype=np.int64)
with laspy.open(laz_path) as f:
for chunk in f.chunk_iterator(1_000_000):
counts += np.bincount(np.asarray(chunk.classification), minlength=256)
print(f"chunked pass agrees with laspy.read: "
f"{bool((counts[counts > 0] == np.unique(cls, return_counts=True)[1]).all())}")
GROUND = np.isin(cls, [2, 18])
WATER = np.isin(cls, [9, 25])
# ---------------------------------------------------------------- 4. remove noise
print("\n=== statistical outlier removal ===")
t = time.perf_counter()
tree = cKDTree(np.c_[x, y, z])
dist, _ = tree.query(np.c_[x, y, z], k=9, workers=-1) # nearest 8 neighbours (+ the point itself)
mean_d = dist[:, 1:].mean(axis=1)
limit = mean_d.mean() + 3 * mean_d.std()
NOISE = (cls == 7) | (mean_d > limit)
print(f"mean neighbour distance {mean_d.mean():.2f} m, limit {limit:.2f} m, took {time.perf_counter() - t:.1f} s")
print(f"outliers: {NOISE.sum():,} ({NOISE.mean():.2%}); of the {(z > 200).sum()} points above 200 m, "
f"{(NOISE & (z > 200)).sum()} are outliers")
keep = ~NOISE
# ---------------------------------------------------------------- 5. figures of the cloud
GROUPS = [("Ground (2, 18)", [2, 18], "#c9a66b"), ("Water (9, 25)", [9, 25], "#2c7fb8"),
("Unclassified: trees, buildings (1, 17)", [1, 17], "#2f7d3c"), ("Other (10, 11)", [10, 11], "#d7301f")]
rng = np.random.default_rng(0)
sub = rng.choice(len(x), size=min(1_000_000, len(x)), replace=False)
sub = sub[keep[sub]]
fig, ax = plt.subplots(figsize=(9, 7.4))
for label, codes, colour in GROUPS:
m = np.isin(cls[sub], codes)
ax.scatter(x[sub][m] - xmin, y[sub][m] - ymin, s=0.12, c=colour, label=label, rasterized=True, linewidths=0)
ax.set_aspect("equal")
ax.set_xlim(0, xmax - xmin)
ax.set_ylim(0, ymax - ymin)
ax.set_xlabel("metres east")
ax.set_ylabel("metres north")
ax.set_title("USGS 3DEP lidar around the Washington Monument, coloured by class")
ax.legend(markerscale=25, loc="upper left", fontsize=8, framealpha=0.95)
fig.savefig(OUT / "01-classification.png")
plt.close(fig)
band = (np.abs(y - my) < 2.0) & keep
fig, ax = plt.subplots(figsize=(10, 4))
for label, codes, colour in GROUPS[:3]:
m = band & np.isin(cls, codes)
ax.scatter(x[m] - mx, z[m], s=0.6, c=colour, label=label, linewidths=0)
ax.set_xlabel("metres east of the Monument")
ax.set_ylabel("elevation (m, NAVD88)")
ax.set_title("4 m-wide east–west slice through the point cloud")
ax.legend(markerscale=10, fontsize=8, loc="upper left")
ax.grid(alpha=0.3)
fig.savefig(OUT / "02-profile.png")
plt.close(fig)
# ---------------------------------------------------------------- 6. rasterise
print("\n=== rasterise to 1 m ===")
RES = 1.0
ncol, nrow = int((xmax - xmin) / RES), int((ymax - ymin) / RES)
col = np.clip(((x - xmin) / RES).astype(int), 0, ncol - 1)
row = np.clip(((ymax - y) / RES).astype(int), 0, nrow - 1)
cell = row * ncol + col
# DSM: highest non-noise return in each cell.
dsm = np.full(nrow * ncol, -np.inf)
np.maximum.at(dsm, cell[keep], z[keep])
dsm[np.isinf(dsm)] = np.nan
dsm = dsm.reshape(nrow, ncol)
# DTM: mean of ground and water returns (water is the "ground" of a lake) in each cell.
terrain = (GROUND | WATER) & keep
gsum = np.bincount(cell[terrain], weights=z[terrain], minlength=nrow * ncol)
gcnt = np.bincount(cell[terrain], minlength=nrow * ncol)
dtm = np.where(gcnt > 0, gsum / np.maximum(gcnt, 1), np.nan).reshape(nrow, ncol)
print(f"DSM cells with no return: {np.isnan(dsm).mean():.1%}")
print(f"DTM cells with no ground point: {np.isnan(dtm).mean():.1%}")
t = time.perf_counter()
rr, cc = np.nonzero(~np.isnan(dtm))
hr, hc = np.nonzero(np.isnan(dtm))
dtm[hr, hc] = griddata((rr, cc), dtm[rr, cc], (hr, hc), method="linear")
for arr in (dtm, dsm): # anything left (edges, outside the convex hull): nearest neighbour
holes = np.isnan(arr)
if holes.any():
idx = ndimage.distance_transform_edt(holes, return_distances=False, return_indices=True)
arr[holes] = arr[tuple(idx)][holes]
print(f"gap filling: {time.perf_counter() - t:.1f} s")
ndsm = np.clip(dsm - dtm, 0, None)
print(f"DTM {dtm.min():.1f}..{dtm.max():.1f} m | DSM {dsm.min():.1f}..{dsm.max():.1f} m | "
f"height above ground max {ndsm.max():.1f} m")
# ---------------------------------------------------------------- 7. canopy height model
print("\n=== canopy height model ===")
height_pt = z - dtm.ravel()[cell]
tall = keep & ~GROUND & ~WATER & (height_pt > 2)
multi = np.bincount(cell[tall & (nr > 1)], minlength=nrow * ncol)
alln = np.bincount(cell[tall], minlength=nrow * ncol)
frac = np.where(alln > 0, multi / np.maximum(alln, 1), 0).reshape(nrow, ncol)
elevated = ndsm > 2
# Laser pulses split into several returns in tree crowns but not on solid roofs.
veg = ndimage.binary_opening(elevated & (frac >= 0.5), iterations=1)
struct = ndimage.binary_opening(elevated & (frac < 0.5), iterations=1)
chm = np.where(veg, ndsm, 0.0)
print(f"cells more than 2 m above ground: {elevated.mean():.1%}")
print(f" mostly multi-return (trees): {veg.mean():.1%} | mostly single-return (buildings, structures): "
f"{struct.mean():.1%}")
canopy = ndsm[veg]
print(f"tree canopy height: median {np.median(canopy):.1f} m, 95th percentile {np.percentile(canopy, 95):.1f} m, "
f"max {canopy.max():.1f} m")
labels, _ = ndimage.label(veg)
sizes = np.bincount(labels.ravel())[1:]
print(f"canopy patches of 20 m² or more: {(sizes >= 20).sum()}")
transform = from_origin(xmin, ymax, RES, RES)
crs = CRS.from_wkt(header.parse_crs().to_wkt())
for name, arr in [("dtm", dtm), ("dsm", dsm), ("chm", chm)]:
with rasterio.open(OUT / f"{name}.tif", "w", driver="GTiff", height=nrow, width=ncol, count=1,
dtype="float32", crs=crs, transform=transform, compress="deflate", nodata=-9999) as dst:
dst.write(arr.astype("float32"), 1)
print("wrote output/dtm.tif, output/dsm.tif, output/chm.tif")
ls = LightSource(azdeg=315, altdeg=45)
ext = [0, xmax - xmin, 0, ymax - ymin]
fig, axes = plt.subplots(1, 2, figsize=(11, 4.6), constrained_layout=True)
for ax, arr, title in [(axes[0], dtm, "DTM: bare earth (ground and water points)"),
(axes[1], dsm, "DSM: highest return in each 1 m cell")]:
shade = ls.shade(np.clip(arr, -2, 40), cmap=plt.cm.gist_earth, vert_exag=2, blend_mode="soft",
vmin=-2, vmax=40)
ax.imshow(shade, extent=ext)
ax.set_title(title)
ax.set_xlabel("metres east")
axes[0].set_ylabel("metres north")
fig.savefig(OUT / "03-dtm-dsm.png")
plt.close(fig)
fig, ax = plt.subplots(figsize=(8.5, 6.4), constrained_layout=True)
im = ax.imshow(np.clip(ndsm, 0, 40), extent=ext, cmap="viridis")
fig.colorbar(im, ax=ax, shrink=0.85, label="height above ground (m), clipped at 40 m")
ax.annotate("Washington Monument (169 m)", xy=(mx - xmin, my - ymin), xytext=(mx - xmin - 330, my - ymin + 150),
color="white", fontsize=9, arrowprops=dict(arrowstyle="->", color="white"))
ax.annotate("construction cranes", xy=(720, 560), xytext=(560, 700), color="white", fontsize=9,
arrowprops=dict(arrowstyle="->", color="white"))
ax.set_title("Normalised DSM (DSM − DTM): height of everything above the ground")
ax.set_xlabel("metres east")
ax.set_ylabel("metres north")
fig.savefig(OUT / "04-ndsm.png")
plt.close(fig)
fig, axes = plt.subplots(1, 2, figsize=(11, 4.6), width_ratios=[1.6, 1], constrained_layout=True)
fig.get_layout_engine().set(wspace=0.12)
lab = np.zeros(ndsm.shape, dtype=int)
lab[struct] = 1
axes[0].imshow(lab, extent=ext, interpolation="nearest", cmap=ListedColormap(["#f2efe9", "#9a9a9a"]),
norm=BoundaryNorm([-0.5, 0.5, 1.5], 2))
im = axes[0].imshow(np.where(veg, chm, np.nan), extent=ext, cmap="YlGn", vmin=0, vmax=30, interpolation="nearest")
fig.colorbar(im, ax=axes[0], shrink=0.85, label="canopy height (m)")
axes[0].set_title("Canopy height model (buildings and structures in grey)")
axes[0].set_xlabel("metres east")
axes[0].set_ylabel("metres north")
axes[1].hist(canopy, bins=np.arange(2, 36, 1), color="#31a354", edgecolor="white", linewidth=0.4)
axes[1].axvline(np.median(canopy), color="#222", ls="--", lw=1)
axes[1].text(np.median(canopy) + 0.6, axes[1].get_ylim()[1] * 0.92, f"median {np.median(canopy):.1f} m", fontsize=8)
axes[1].set_xlabel("canopy height (m)")
axes[1].set_ylabel("number of 1 m cells")
axes[1].set_title("Distribution of tree heights")
axes[1].grid(axis="y", alpha=0.3)
fig.savefig(OUT / "05-canopy-height-model.png")
plt.close(fig)
# ---------------------------------------------------------------- 8. measure the Monument
print("\n=== Washington Monument ===")
d = np.hypot(x - mx, y - my)
near = (d < 10) & keep
top_i = np.argmax(np.where(near, z, -np.inf))
ring = GROUND & keep & (d > 15) & (d < 30)
base = np.median(z[ring])
print(f"highest point within 10 m: z={z[top_i]:.2f} m at {d[top_i]:.1f} m from the centre "
f"(class {cls[top_i]}, return {rn[top_i]} of {nr[top_i]})")
print(f"ground 15–30 m from the centre: median z={base:.2f} m from {ring.sum():,} ground points")
height = z[top_i] - base
print(f"lidar height: {height:.2f} m ({height / 0.3048:.1f} ft)")
OFFICIAL = 169.046 # NGS 2013–14 survey: 554 ft 7 11/32 in
print(f"official height (NGS survey): {OFFICIAL} m; difference {height - OFFICIAL:+.2f} m")