Reading COPC in Python with laspy
TL;DR: with laspy.CopcReader.open("https://…/tile.copc.laz") as r: pts = r.query(bounds=laspy.Bounds(mins=np.array([x0, y0]), maxs=np.array([x1, y1])), resolution=1.0) fetches only the octree nodes intersecting the box, down to the level whose point spacing is about the requested resolution. Install laspy[lazrs] and requests for HTTP. The result is a point record with the usual x, y, z, classification fields.
# Context and Motivation
This guide is part of COPC and Cloud-Native Point Cloud Formats. COPC’s value is that a client can read a small area, or a coarse overview, of a huge file by fetching a few byte ranges. PDAL’s readers.copc is the full-featured way to do that inside pipelines. laspy’s CopcReader is the lightweight alternative: pure pip install, no GDAL or PROJ, NumPy arrays out — ideal for notebooks, web services, serverless functions and quick checks against a remote dataset.
# Prerequisites and Assumptions
- laspy 2.4+ installed with
pip install "laspy[lazrs]" requests. - A COPC file, local or served over HTTP(S) with range-request support — which includes S3, Azure Blob and most static hosting.
- Coordinates of the area you want in the file’s CRS;
reader.header.parse_crs()tells you what that is.
# Step-by-Step Implementation
# Step 1 — Open the reader
laspy.CopcReader.open(path_or_url) reads the header, the COPC info VLR and the root hierarchy page — a few kilobytes.
# Step 2 — Inspect the header
reader.header exposes bounds, point count, scales and CRS just like a LAS header; reader.copc_info gives the octree’s centre, half-size and root spacing.
# Step 3 — Query by bounds
reader.query(bounds=laspy.Bounds(mins=..., maxs=...)) returns every point in the box at full resolution.
# Step 4 — Limit resolution for overviews
Add resolution= (metres). laspy stops descending the octree once a level’s spacing is at or below the requested value — ideal for previews and coarse DEMs.
# Step 5 — Use the result like any point record
Access scaled coordinates and fields, stack into arrays, or write to LAS with the reader’s header.
# Complete Working Example
"""Query a remote COPC file by bounds and resolution with laspy."""
from __future__ import annotations
import time
import laspy
import numpy as np
URL = "https://data.example.org/lidar/county_block_7.copc.laz"
with laspy.CopcReader.open(URL) as reader:
h = reader.header
print(f"{h.point_count:,} points, CRS: {h.parse_crs().name}")
print("bounds:", np.round(h.mins, 1), np.round(h.maxs, 1))
cx, cy = (h.mins[0] + h.maxs[0]) / 2, (h.mins[1] + h.maxs[1]) / 2
box = laspy.Bounds(mins=np.array([cx - 250, cy - 250]), maxs=np.array([cx + 250, cy + 250]))
for res in (8.0, 2.0, None): # coarse overview → full detail
t0 = time.perf_counter()
pts = reader.query(bounds=box, resolution=res) if res else reader.query(bounds=box)
dt = time.perf_counter() - t0
label = f"{res} m" if res else "full"
print(f"{label:>6}: {len(pts):>10,} points in {dt:5.1f} s")
ground = pts[pts.classification == 2]
xyz = np.vstack((ground.x, ground.y, ground.z)).T
print("ground points in the box:", xyz.shape[0])
out = laspy.LasData(reader.header)
out.points = pts
out.write("out/block7_center_500m.laz")Illustrative output for a 500 m box on a statewide COPC file:
142,318,907 points, CRS: NAD83(2011) / UTM zone 15N + NAVD88 height
bounds: [ 402000. 4461000. 180.3] [ 462000. 4521000. 512.8]
8.0 m: 8,112 points in 0.6 s
2.0 m: 121,740 points in 1.4 s
full: 3,904,221 points in 6.2 s
ground points in the box: 1,702,558# Choosing Query Sizes for Interactive Work
The most useful habit with CopcReader is to query coarse first and refine only where needed. An 8 m overview of a whole county returns a few hundred thousand points in seconds — enough to see coverage, spot gaps, check classification colours and choose where to look closer. Only then do full-resolution queries on the handful of boxes that matter, each small enough to return in a few seconds.
Box size matters as much as resolution. Octree nodes near the root cover large areas, so a query that barely crosses a node boundary pulls in that whole node’s points at coarse levels. Aligning query boxes to round coordinates — multiples of 100 or 250 m — and keeping them compact rather than long and thin keeps the number of touched nodes down. For systematic work over a large area, tile the area into boxes of a few hundred metres and query them in turn; each query is independent, so a thread pool can overlap the network waits.
Finally, remember that every query re-reads hierarchy pages it needs. Keep one reader open for a session of queries rather than reopening the URL each time; the reader caches what it has already fetched.
# Key Parameter Table
| Argument | Type | Meaning |
|---|---|---|
bounds |
laspy.Bounds |
2D or 3D box in file coordinates; omit for the whole file |
resolution |
float, m | Stop at the octree level whose spacing reaches this |
level |
int or range | Query specific octree levels directly |
reader.copc_info.spacing |
float | Root node point spacing |
reader.header |
LasHeader |
Standard LAS header with CRS |
| HTTP support | requests installed |
Enables https:// sources |
# Verification
- Points inside the box. All returned x and y should lie within the requested bounds.
- Monotonic counts. Finer resolutions return more points, and a full query returns the most.
- Match PDAL. On one box,
readers.copcwith the same bounds should return the same count at full resolution.
assert (pts.x >= box.mins[0]).all() and (pts.x <= box.maxs[0]).all()
assert (pts.y >= box.mins[1]).all() and (pts.y <= box.maxs[1]).all()# Gotchas and Edge Cases
Bounds in the file’s CRS. A box in longitude and latitude against a UTM file returns nothing. Transform your area of interest with pyproj first.
Resolution is approximate. Octree levels halve spacing at each step, so the achieved density is at the first level at or finer than the request — somewhere between the requested value and half of it.
Servers without range requests. Some web servers ignore Range headers and return the whole file. The query then still works but downloads everything; check timings and server configuration.
Authentication. Private buckets need signed URLs or credentials. laspy’s HTTP source reads URLs as given; generate a pre-signed URL for S3 objects, or use PDAL with /vsis3/ for credential-chain access.
# Frequently Asked Questions
Can laspy read COPC files over HTTP?
Yes. With the requests package installed, CopcReader opens https URLs and fetches only the byte ranges it needs for the header, hierarchy and the octree nodes your query touches.
What does the resolution argument do?
It tells laspy how far down the octree to go. Each level has roughly half the point spacing of the one above; the query stops at the first level whose spacing is at or below the requested resolution, returning a thinned but evenly distributed sample.
Should I use laspy or PDAL for COPC?
laspy for lightweight access from Python with minimal dependencies, such as notebooks and web services. PDAL for pipelines that go on to filter, reproject or rasterize, or that need credential-aware cloud access.
How do I save a COPC query result as LAZ?
Create a LasData with the reader’s header, assign the returned points, and write it to a .laz path. The header’s counts and bounds are recomputed on write.
# Related
- COPC and Cloud-Native Point Cloud Formats — how COPC works
- Querying a COPC File by Bounds and Resolution — the PDAL route
- Converting LAZ Tiles to COPC with PDAL — producing COPC files
- laspy and NumPy Workflows for LAS Data — laspy in general
- Reading USGS 3DEP LiDAR from Public Cloud Storage — public cloud-native data