How to Clip and Tile a Point Cloud in Python

On this page

Problem statement

Point clouds are too large to process whole and have no spatial index, so "give me the points in this box" is the expensive operation the format was not designed for.

Two consequences shape every workflow. Clipping a plain LAZ file means decompressing all of it. And processing a large area means tiling it, which introduces edge effects at every tile boundary.

A real 3DEP delivery arrives already tiled. The nine used here — a corner of the USGS survey of New York City after Hurricane Sandy — are 1,500 m squares, 393 MB and 105,039,668 points between them. That tiling is what makes a bounding-box read cheap: you can reject most tiles from their headers alone.

Quick answer

Filter on tile headers before decompressing anything:

import glob
import laspy
import numpy as np


def read_bbox(pattern, bounds):
    """Read only the tiles whose header bounds intersect the box."""
    left, bottom, right, top = bounds
    parts, read, skipped = [], 0, 0

    for path in sorted(glob.glob(pattern)):
        with laspy.open(path) as reader:          # header only
            h = reader.header
            if (h.maxs[0] < left or h.mins[0] > right or
                    h.maxs[1] < bottom or h.mins[1] > top):
                skipped += 1
                continue
        read += 1
        las = laspy.read(path)
        x, y = np.asarray(las.x), np.asarray(las.y)
        inside = (x >= left) & (x <= right) & (y >= bottom) & (y <= top)
        if inside.any():
            parts.append((x[inside], y[inside], np.asarray(las.z)[inside],
                          np.asarray(las.classification)[inside]))

    print(f"  {read} tiles read, {skipped} skipped from headers")
    return [np.concatenate(a) for a in zip(*parts)]


import time

start = time.perf_counter()
x, y, z, classification = read_bbox("tiles/*.laz", reservoir.total_bounds)
print(f"  {len(x):,} points in the reservoir's box, {time.perf_counter() - start:.1f} s")

start = time.perf_counter()
for path in sorted(glob.glob("tiles/*.laz")):
    with laspy.open(path) as reader:
        reader.header.maxs
print(f"  all nine headers: {(time.perf_counter() - start) * 1000:.1f} ms")
  1 tiles read, 8 skipped from headers
  1,631,125 points in the reservoir's box, 0.4 s
  all nine headers: 1.6 ms

Opening a header is a fraction of a millisecond; decompressing a tile takes seconds. For a delivery of hundreds of tiles that ratio is the whole optimisation.

Tile headers tested against a query box so most tiles are rejected without decompression, with only the intersecting few read. the header is the poor man’s spatial index9 tiles on disk393 MBopen each header1.6 ms for all ninereject on bounds8 of 9 tilesdecompress therest0.4 s for oneFor a tiled delivery this is a one-level spatial index and no code spatialworkflow.io
The header is the poor man's spatial index, and for a tiled delivery it is usually enough.

Clip by geometry, tile with a buffer, size by memory

1. Clip by geometry, not just by box

from shapely.prepared import prep
from shapely.geometry import Point

prepared = prep(polygon)
inside = np.fromiter(
    (prepared.contains(Point(px, py)) for px, py in zip(x, y)),
    dtype=bool, count=len(x))

Point-in-polygon over millions of points is slow in a Python loop even prepared — 7.2 s for 1.6 million points here. Filter by the polygon's bounding box first, a vectorised comparison, and run the exact test on the survivors with shapely.contains_xy, which is vectorised too (Example 1).

2. Tile with a buffer, then trim

Every neighbourhood operation reads beyond the cell it writes. A DTM interpolation, a ground filter, a smoothing pass — each needs points outside the tile to produce correct values at the edge.

BUFFER = 20.0     # metres, larger than any neighbourhood you will use

read_bounds = (left - BUFFER, bottom - BUFFER, right + BUFFER, top + BUFFER)
write_bounds = (left, bottom, right, top)

Process the buffered points, write only the unbuffered extent. Without this, every tile boundary shows as a seam.

3. Size the tiles by memory, not by aesthetics

A tile of n points costs roughly 24 bytes for coordinates as float64 plus a few more for attributes — call it 40 bytes per point. Measure the density from the headers rather than assuming it:

counts, area = 0, 0.0
for path in sorted(glob.glob("tiles/*.laz")):
    with laspy.open(path) as reader:
        h = reader.header
        counts += h.point_count
        area += (h.maxs[0] - h.mins[0]) * (h.maxs[1] - h.mins[1])
density = counts / area
for size in (250, 500, 1000):
    n = density * size ** 2
    print(f"{size:5d} m tile: {n / 1e6:5.2f} million points, about {n * 40 / 1e6:4.0f} MB")
print(f"{counts:,} points over {area / 1e6:.2f} km², {density:.2f} points per m²")
  250 m tile:  0.32 million points, about   13 MB
  500 m tile:  1.30 million points, about   52 MB
 1000 m tile:  5.19 million points, about  207 MB
105,039,668 points over 20.25 km², 5.19 points per m²

Pick the tile size so the buffered tile fits comfortably, with room for the intermediate arrays your processing creates.

4. Write tiles that align to a shared grid

left = np.floor(bounds[0] / tile_size) * tile_size

Snapping tile origins to a multiple of the tile size makes the outputs mosaic without resampling, and makes it trivial to work out which tile contains a coordinate.

5. Use COPC or EPT when you control the format

If you are producing the data, or converting it once, an indexed format turns every future bounding-box query into a range request rather than a full read. That is the difference between an interactive tool and a batch job.

A tile read with a buffer so neighbourhood operations are correct at the edge, then written without the buffer. read buffered, write unbufferedread extentbuffertile + 20 mwrite extentno seamtile onlyA 20 m buffer costs 17% extra reading on 500 m tiles and 96% on 100 m tiles spatialworkflow.io
Read buffered, write unbuffered. Skipping this produces a seam at every boundary.

Clipping efficiently and a tiling scheme

The data used on this page

Nine tiles of the USGS 3DEP delivery NY_New_York_CMGP_SANDY_LiDAR_15 — the post-Hurricane-Sandy survey of New York City, flown in 2013–14 — covering 4.5 × 4.5 km of Manhattan around Central Park: USNG tiles 18TWL 850–880 E, 135–165 N, each 1,500 m square, LAS 1.2 compressed as LAZ, in NAD83(2011) / UTM zone 18N with NAVD88 heights. They come from the USGS rockyweb server, which is slow — expect well over an hour for the 393 MB. The clipping polygon is the Jacqueline Kennedy Onassis Reservoir from OpenStreetMap (way 6678417).

import os
import subprocess

import geopandas as gpd

BASE = ("https://rockyweb.usgs.gov/vdelivery/Datasets/Staged/Elevation/LPC/Projects/"
        "NY_New_York_CMGP_SANDY_LiDAR_15/NY_CMPG_2013/LAZ/USGS_LPC_NY_New_York_CMGP_SANDY_LiDAR_15_18TWL")
TILES = [f"{e}{n}" for n in (135, 150, 165) for e in (850, 865, 880)]
os.makedirs("tiles", exist_ok=True)
for tile in TILES:
    path = f"tiles/{tile}.laz"
    for _ in range(20):                                  # rockyweb drops long transfers: resume
        if os.path.exists(path):
            break
        if subprocess.run(["curl", "-sfL", "-C", "-", "--retry", "3", "-o", path + ".part",
                           f"{BASE}{tile}.laz"]).returncode == 0:
            os.replace(path + ".part", path)

if not os.path.exists("reservoir.geojson"):
    subprocess.run(["curl", "-sf", "-A", "spatialworkflow.io guide", "-o", "reservoir.geojson",
                    "https://nominatim.openstreetmap.org/lookup?osm_ids=W6678417&format=geojson&polygon_geojson=1"],
                   check=True)
reservoir = gpd.read_file("reservoir.geojson").to_crs("EPSG:6347")
print(len(os.listdir("tiles")), "tiles,", f"{sum(os.path.getsize(f'tiles/{t}.laz') for t in TILES) / 1e6:.0f} MB;",
      f"reservoir {reservoir.area.sum() / 1e6:.3f} km²")
9 tiles, 393 MB; reservoir 0.387 km²

Example 1 — clipping to a polygon, efficiently

import numpy as np
from shapely.geometry import Point
from shapely.prepared import prep


def clip_to_polygon(x, y, z, classification, polygon):
    """Bounding box first, exact containment only on the survivors."""
    left, bottom, right, top = polygon.bounds
    in_box = (x >= left) & (x <= right) & (y >= bottom) & (y <= top)
    print(f"  bounding box keeps {in_box.sum():,} of {len(x):,} "
          f"({in_box.mean():.1%})")

    if not in_box.any():
        return tuple(np.array([]) for _ in range(4))

    prepared = prep(polygon)
    bx, by = x[in_box], y[in_box]
    exact = np.fromiter((prepared.contains(Point(a, b)) for a, b in zip(bx, by)),
                        dtype=bool, count=len(bx))

    keep = np.zeros(len(x), bool)
    keep[np.nonzero(in_box)[0][exact]] = True
    print(f"  polygon keeps {keep.sum():,} ({keep.mean():.1%})")
    return x[keep], y[keep], z[keep], classification[keep]


import shapely

polygon = reservoir.geometry.iloc[0]
start = time.perf_counter()
clipped = clip_to_polygon(x, y, z, classification, polygon)
print(f"  prepared contains, point by point: {time.perf_counter() - start:.1f} s")

start = time.perf_counter()
inside = shapely.contains_xy(polygon, x, y)
print(f"  shapely.contains_xy on all {len(x):,}: {time.perf_counter() - start:.2f} s, "
      f"same result: {inside.sum() == len(clipped[0])}")
print(f"  water (class 9) inside: {(clipped[3] == 9).mean():.1%}")
  bounding box keeps 1,631,125 of 1,631,125 (100.0%)
  polygon keeps 519,699 (31.9%)
  prepared contains, point by point: 7.3 s
  shapely.contains_xy on all 1,631,125: 0.08 s, same result: True
  water (class 9) inside: 46.1%

The two-stage filter is not an optimisation detail when the exact test is a Python-level call per point: here the prepared-geometry loop took 7.2 s for 1.6 million points. Shapely 2 does the same test vectorised — shapely.contains_xy gave the identical answer in 0.07 s, a hundred times faster — so with a current Shapely the box filter matters less than never looping in Python.

The reservoir also shows what the clip keeps. Only 31.9% of the points in its bounding box were inside it, and 46.1% of those were classed as water: a lidar pulse that reaches open water mostly does not come back, so the reservoir is sparse compared with the park around it.

Clipping the USGS New York lidar to the Central Park reservoir: points kept at each stage and the time the exact test took two ways. clipping one tile to the Central Park reservoirstagepointstimetile headers read9 tiles, 1 kept1.6 msreservoir's bounding box1,631,1250.4 s to decompressinside, prepared contains loop519,6997.2 sinside, shapely.contains_xy519,6990.07 sof those, class 9 water46.1%Same answer, a hundred times faster: vectorise the exact test spatialworkflow.io
Example 1's measurements. The polygon kept a third of its bounding box, and half of what it kept is water, where few pulses return.

Example 2 — a tiling scheme with buffers

import numpy as np


def tile_scheme(bounds, tile_size, buffer=20.0, snap=True):
    """Tile definitions with read and write extents, on a snapped grid."""
    left, bottom, right, top = bounds
    if snap:
        left = np.floor(left / tile_size) * tile_size
        bottom = np.floor(bottom / tile_size) * tile_size

    tiles = []
    y = bottom
    while y < top:
        x = left
        while x < right:
            tiles.append({
                "id": f"{int(x)}_{int(y)}",
                "write": (x, y, x + tile_size, y + tile_size),
                "read": (x - buffer, y - buffer,
                         x + tile_size + buffer, y + tile_size + buffer),
            })
            x += tile_size
        y += tile_size

    print(f"  {len(tiles)} tiles of {tile_size} m with a {buffer} m buffer")
    print(f"  buffered area is {((tile_size + 2 * buffer) / tile_size) ** 2:.2f}x "
          "the written area")
    return tiles


delivery = (585_000, 4_513_500, 589_500, 4_518_000)
for size in (1500, 500, 100):
    scheme = tile_scheme(delivery, size)
  9 tiles of 1500 m with a 20.0 m buffer
  buffered area is 1.05x the written area
  81 tiles of 500 m with a 20.0 m buffer
  buffered area is 1.17x the written area
  2025 tiles of 100 m with a 20.0 m buffer
  buffered area is 1.96x the written area

The overhead ratio is worth printing. A 20 m buffer on the delivery's own 1,500 m tiles costs 5% extra reading, on 500 m tiles 17%, and on 100 m tiles 96% — the signal to use larger tiles.

Example 3 — processing tiles in parallel

A process pool has to send each worker the function to run, and since Python 3.14 Linux starts workers with forkserver, which can only send a function its processes can import. A worker defined in a notebook cell cannot be imported, and the fork context is no escape here: laspy's LAZ decoder runs its own thread pool, and a process forked from one with live threads hung with its workers at 0% CPU. So the worker goes in a module:

%%writefile tile_worker.py
import glob
import os

import laspy
import numpy as np
import rasterio
from rasterio.fill import fillnodata
from rasterio.transform import from_origin


def read_bbox(pattern, bounds):
    """Read only the tiles whose header bounds intersect the box."""
    left, bottom, right, top = bounds
    parts = []
    for path in sorted(glob.glob(pattern)):
        with laspy.open(path) as reader:
            h = reader.header
            if h.maxs[0] < left or h.mins[0] > right or h.maxs[1] < bottom or h.mins[1] > top:
                continue
        las = laspy.read(path)
        x, y = np.asarray(las.x), np.asarray(las.y)
        inside = (x >= left) & (x <= right) & (y >= bottom) & (y <= top)
        parts.append((x[inside], y[inside], np.asarray(las.z)[inside],
                      np.asarray(las.classification)[inside]))
    return [np.concatenate(a) for a in zip(*parts)]


def dtm_from_points(x, y, z, classification, cell, bounds):
    """Mean height of ground (class 2) points per cell, gaps filled from neighbours."""
    left, bottom, right, top = bounds
    width, height = int(round((right - left) / cell)), int(round((top - bottom) / cell))
    ground = classification == 2
    col = ((x[ground] - left) / cell).astype(int)
    row = ((top - y[ground]) / cell).astype(int)
    ok = (col >= 0) & (col < width) & (row >= 0) & (row < height)
    index = row[ok] * width + col[ok]
    counts = np.bincount(index, minlength=width * height).reshape(height, width)
    sums = np.bincount(index, weights=z[ground][ok], minlength=width * height).reshape(height, width)
    dtm = np.where(counts > 0, sums / np.maximum(counts, 1), 0).astype("float32")
    dtm = fillnodata(dtm, mask=counts > 0, max_search_distance=15)   # the neighbourhood operation
    dtm[dtm == 0] = np.nan
    return dtm, counts, from_origin(left, top, cell, cell)


def write_raster(path, array, transform, offset=(0, 0)):
    profile = {"driver": "GTiff", "width": array.shape[1], "height": array.shape[0], "count": 1,
               "dtype": "float32", "crs": "EPSG:6347", "nodata": np.nan,
               "transform": transform * transform.translation(*offset)}
    with rasterio.open(path, "w", **profile) as dst:
        dst.write(array, 1)


def process_tile(spec, pattern, cell=1.0, out_dir="dtm"):
    """One tile: read buffered, process, write unbuffered."""
    x, y, z, classification = read_bbox(pattern, spec["read"])
    if len(x) == 0:
        return spec["id"], 0

    dtm, counts, transform = dtm_from_points(x, y, z, classification, cell, spec["read"])

    wl, wb, wr, wt = spec["write"]
    left, top = spec["read"][0], spec["read"][3]
    c0 = int((wl - left) / cell)
    c1 = int((wr - left) / cell)
    r0 = int((top - wt) / cell)
    r1 = int((top - wb) / cell)
    trimmed = dtm[r0:r1, c0:c1]

    os.makedirs(out_dir, exist_ok=True)
    write_raster(f"{out_dir}/{spec['id']}.tif", trimmed, transform, offset=(c0, r0))
    return spec["id"], int(np.isfinite(trimmed).sum())
from concurrent.futures import ProcessPoolExecutor

import rasterio
from tile_worker import dtm_from_points, process_tile


def run_tiles(tiles, pattern, workers=4, **kwargs):
    with ProcessPoolExecutor(workers) as pool:
        futures = [pool.submit(process_tile, t, pattern, **kwargs) for t in tiles]
        for future in futures:
            tile_id, cells = future.result()
            print(f"  {tile_id}: {cells:,} cells written")


around_reservoir = tile_scheme((587_000, 4_515_000, 588_000, 4_516_000), 500)
run_tiles(around_reservoir, "tiles/*.laz", workers=2)
  4 tiles of 500 m with a 20.0 m buffer
  buffered area is 1.17x the written area
  587000_4515000: 195,665 cells written
  587500_4515000: 143,772 cells written
  587000_4515500: 182,816 cells written
  587500_4515500: 122,014 cells written

Tiles are independent once buffered, so process-level parallelism is straightforward and scales with cores. The buffer is what makes that true: without it, tiles would need to exchange edge data and the parallelism would be gone.

Does the buffer matter? Run the same four tiles with no buffer, and compare both mosaics with one DTM made in a single pass over the whole square:

def mosaic(out_dir, block=(587_000, 4_515_000), size=500, cell=1.0):
    n = int(size / cell)
    out = np.full((2 * n, 2 * n), np.nan, dtype="float32")
    for i, y0 in enumerate((block[1] + size, block[1])):          # top row first
        for j, x0 in enumerate((block[0], block[0] + size)):
            with rasterio.open(f"{out_dir}/{int(x0)}_{int(y0)}.tif") as src:
                out[i * n:(i + 1) * n, j * n:(j + 1) * n] = src.read(1)
    return out


unbuffered = tile_scheme((587_000, 4_515_000, 588_000, 4_516_000), 500, buffer=0.0)
run_tiles(unbuffered, "tiles/*.laz", workers=2, out_dir="dtm_nobuffer")

whole = (586_980, 4_514_980, 588_020, 4_516_020)
reference = dtm_from_points(*read_bbox("tiles/*.laz", whole), 1.0, whole)[0][20:-20, 20:-20]
near_seam = np.zeros(reference.shape, bool)
near_seam[485:515, :] = near_seam[:, 485:515] = True
for label, out_dir in [("20 m buffer", "dtm"), ("no buffer", "dtm_nobuffer")]:
    diff = np.abs(mosaic(out_dir) - reference)
    for where, sel in [("within 15 m of a seam", near_seam), ("elsewhere", ~near_seam)]:
        d = diff[sel]
        print(f"  {label:12} {where:22} max {np.nanmax(d):5.2f} m   "
              f"cells differing by > 1 cm: {(d > 0.01).sum():6,}   no value: {np.isnan(d).sum():6,}")
  4 tiles of 500 m with a 0.0 m buffer
  buffered area is 1.00x the written area
  587000_4515000: 195,665 cells written
  587500_4515000: 143,526 cells written
  587000_4515500: 182,780 cells written
  587500_4515500: 122,009 cells written
  4 tiles read, 5 skipped from headers
  20 m buffer  within 15 m of a seam  max  0.00 m   cells differing by > 1 cm:      0   no value: 36,270
  20 m buffer  elsewhere              max  0.00 m   cells differing by > 1 cm:      0   no value: 319,463
  no buffer    within 15 m of a seam  max  1.64 m   cells differing by > 1 cm:    765   no value: 36,486
  no buffer    elsewhere              max  2.42 m   cells differing by > 1 cm:  4,026   no value: 319,534

With a 20 m buffer the four tiles reproduced the single-pass DTM exactly — not one cell off by more than a centimetre. Without it, 765 cells within 15 m of the internal seams differed, by up to 1.64 m, where hole-filling near a tile edge could only look one way. The 4,026 cells "elsewhere" are mostly along the square's outer edge, which is a seam too. About a third of the square has no value in either version: the reservoir and the buildings have no ground returns within the 15-cell fill distance.

Four 500 m DTM tiles compared with a single-pass DTM of the same square, with a 20 m buffer and with none. four tiles against one pass, 1 m cellswithin 15 m of a seamrest of the square20 m buffer✓0 cells differ✓0 cells differno buffer✕765 cells, up to 1.64 m✕4,026 cells, up to 2.42 mBuffered tiles reproduced the single pass exactly; unbuffered ones did not spatialworkflow.io
The seam test from Example 3. The differences sit where hole-filling at a tile edge could only look one way.

Why LAZ makes clipping expensive

Why LAZ makes clipping expensive

LAZ compresses in independent chunks, but the points inside them are in acquisition order — roughly, the order the aircraft flew. The points in any bounding box are therefore scattered across every chunk.

So reading a small area from a large file means decompressing all of it. The chunking does allow a reader to skip a chunk whose declared bounds miss the query, but writers do not always record those bounds, and acquisition order means most chunks overlap most queries anyway.

That is precisely the gap COPC fills: it reorders the points into an octree so that spatial locality exists in the file layout, and records the byte range of each node.

Why the header filter works so well on a tiled delivery

A tiled delivery has already done the spatial sort, at the granularity of a tile. Each header records the tile's bounds, and reading a header is a few hundred bytes.

For the nine tiles used here, the reservoir query rejected eight of them from their headers — all nine headers took 1.6 ms to read — and decompressed one, in 0.4 s. A delivery of hundreds of tiles scales the same way: a small query costs its header reads plus the few tiles it touches.

This is a spatial index with one level and no code. It is why tiled deliveries remain the norm despite indexed formats existing.

Why buffers are not optional

Any operation with a neighbourhood — interpolation, ground filtering, smoothing, slope — needs data outside the cell it writes.

At a tile edge without a buffer, that neighbourhood is truncated. A ground filter sees half its usual context and makes different decisions; an interpolation extrapolates instead of interpolating. The result is a visible discontinuity along every boundary, and it does not average out when the tiles are mosaicked.

The buffer must exceed the largest neighbourhood in the pipeline. Twenty metres covers most raster operations at metre resolution; a ground filter with a 50 m window needs 50.

Why tile size is a memory decision

Small tiles mean more boundaries, more buffer overhead and more per-tile fixed cost. Large tiles mean more memory per worker and less parallelism.

The buffer overhead makes this concrete. A 20 m buffer costs 17% extra area on 500 m tiles and 96% on 100 m tiles — so the smallest sensible tile is set by the buffer, not by preference.

Work backwards from memory: choose the largest tile whose buffered point count fits comfortably in a worker's share of RAM, and divide the area accordingly.

Buffer overhead as a share of written area, growing sharply as tiles get smaller. extra area read for a 20 m buffer, by tile size1000 m tiles1.08×500 m tiles1.17×250 m tiles1.35×100 m tiles1.96×02.1The smallest sensible tile is set by the buffer, not by preference spatialworkflow.io
A 20 m buffer nearly doubles the reading on 100 m tiles. That is what sets the minimum tile size.

Header filters, bounding boxes and process pools

  • Filter on tile headers first. 1.6 ms for nine headers against 0.4 s to decompress one tile here.
  • Bounding box before exact polygon containment, and the exact test vectorised with shapely.contains_xy.
  • A process pool needs an importable worker on Python 3.14's forkserver default; fork deadlocked here against laspy's decoder threads.
  • Buffer must exceed the largest neighbourhood in the pipeline.
  • Write the unbuffered extent or tiles overlap in the mosaic.
  • Snap tile origins to a multiple of the tile size.
  • Buffer overhead grows as tiles shrink — 17% at 500 m, 96% at 100 m for a 20 m buffer.
  • Points exactly on a boundary belong to one tile only; use a half-open interval.
  • COPC or EPT if you control the format — a bounding-box query becomes a range request.

FAQ

How do I clip a point cloud to a polygon in Python?

Filter by the polygon's bounding box first with vectorised comparisons, then run the exact test on the survivors with shapely.contains_xy — a hundred times faster than a prepared geometry in a Python loop.

Why is reading a small area from a LAZ file so slow?

LAZ has no spatial index and stores points in acquisition order, so the points in any box are scattered through the whole file. Use COPC or EPT if you need cheap spatial queries.

How do I avoid seams between tiles?

Read each tile with a buffer larger than any neighbourhood your processing uses, and write only the unbuffered extent.

How big should the buffer be?

Larger than the biggest window in the pipeline. Twenty metres covers most metre-resolution raster work; a ground filter with a 50 m window needs 50.

What tile size should I use?

The largest whose buffered point count fits in a worker's memory. Small tiles pay a large buffer overhead — 96% extra area for a 20 m buffer on 100 m tiles.

Can I process tiles in parallel?

Yes, once they are buffered. Buffering is what makes tiles independent, so a process pool scales with cores.

Should I convert my tiles to COPC?

If you will query them repeatedly by area, yes. A one-off conversion turns every later bounding-box read into a range request instead of a full decompression.