Querying a COPC File by Bounds and Resolution
TL;DR: Set both bounds and resolution on readers.copc. bounds decides which octree nodes are fetched, resolution decides how deep into the tree the reader goes, and omitting either one turns an indexed query back into a whole-file read.
# Context and Motivation
This guide is part of COPC and Cloud-Native Point Cloud Formats. Conversion is the easy half; the value only arrives when something reads the file the way it was built to be read.
Two options do all the work, and they are independent. bounds restricts the query in space: the reader consults the octree index, works out which nodes intersect the window, and fetches only those byte ranges. resolution restricts it in detail: nodes below the requested level are never fetched at all, because each level already holds a spatially even sample of everything beneath it. A query that sets neither reads the whole file, and — this is the part that surprises people — it reads it slightly more slowly than the equivalent plain LAZ would, because the index is overhead when you are going to read everything anyway.
# Prerequisites and Assumptions
| Requirement | Detail |
|---|---|
| PDAL | 2.4+ with readers.copc |
| A COPC file | local, over HTTPS, or on /vsis3/ |
| Bounds in the file’s CRS | the option does not reproject its argument |
| Range-request support | required for anything but a local file |
The CRS row is the one that costs an afternoon. bounds is interpreted in the file’s own coordinate system, so a window in latitude and longitude against a file in UTM matches nothing and returns zero points without an error.
# Step-by-Step Implementation
# Step 1 — Read the file’s extent first
pdal info block_04.copc.laz --summary | python -m json.tool | grep -A8 boundsAnything you pass to bounds has to live inside that box, in those units.
# Step 2 — Ask for a window
{"type": "readers.copc", "filename": "block_04.copc.laz",
"bounds": "([512000, 513000], [4783000, 4784000])"}The syntax is ([xmin, xmax], [ymin, ymax]), optionally with a third pair for Z.
# Step 3 — Ask for a level of detail
{"type": "readers.copc", "filename": "block_04.copc.laz",
"bounds": "([512000, 513000], [4783000, 4784000])",
"resolution": 2.0}The value is a node size in CRS units. Two metres over a one-kilometre window is a good interactive default; leave it unset when the answer will be measured rather than looked at.
# Step 4 — Use a polygon when the area is not a rectangle
{"type": "readers.copc", "filename": "block_04.copc.laz",
"polygon": "POLYGON((512000 4783000, 512800 4783100, 512600 4783900, 512000 4783000))"}Node granularity still applies: you get whole nodes that intersect the polygon, so follow with filters.crop for an exact clip.
# Complete Working Example
"""Query a COPC file by window and level of detail, and report what it cost."""
from __future__ import annotations
import json
import time
from pathlib import Path
import pdal
def query(src: str, bounds: tuple[tuple[float, float], tuple[float, float]],
resolution: float | None = None, exact: bool = False) -> dict:
(xmin, xmax), (ymin, ymax) = bounds
reader = {
"type": "readers.copc",
"filename": src,
"bounds": f"([{xmin}, {xmax}], [{ymin}, {ymax}])",
}
if resolution is not None:
reader["resolution"] = resolution
stages: list = [reader]
if exact:
# Node granularity means points just outside the window arrive too.
stages.append({
"type": "filters.crop",
"bounds": f"([{xmin}, {xmax}], [{ymin}, {ymax}])",
})
started = time.perf_counter()
pipeline = pdal.Pipeline(json.dumps({"pipeline": stages}))
n = pipeline.execute()
elapsed = time.perf_counter() - started
arr = pipeline.arrays[0]
return {
"points": n,
"seconds": round(elapsed, 2),
"resolution": resolution,
"exact": exact,
"x_range": [float(arr["X"].min()), float(arr["X"].max())] if n else None,
"y_range": [float(arr["Y"].min()), float(arr["Y"].max())] if n else None,
}
if __name__ == "__main__":
src = "block_04.copc.laz"
window = ((512000.0, 513000.0), (4783000.0, 4784000.0))
overview = query(src, window, resolution=10.0)
detailed = query(src, window, resolution=0.5)
clipped = query(src, window, resolution=0.5, exact=True)
print(json.dumps({"overview": overview, "detailed": detailed,
"clipped": clipped}, indent=2))
assert overview["points"] < detailed["points"], "coarser must return fewer points"
assert clipped["points"] <= detailed["points"], "cropping cannot add points"
assert clipped["x_range"][0] >= window[0][0] - 1e-6, "crop did not enforce the window"# Key Parameter Table
| Option | Type | Default | Effect |
|---|---|---|---|
bounds |
string | whole file | ([xmin,xmax],[ymin,ymax]) in the file’s CRS; selects nodes, not points |
polygon |
WKT | — | Non-rectangular window; same node granularity |
resolution |
float | 0 (all) | Coarsest node size to read, in CRS units |
count |
int | all | Hard cap on points returned; useful for a smoke test |
header / vlr |
bool | false | Return only metadata, without fetching point chunks |
# Verification
Coarser returns fewer. Asserted above — if it does not, resolution is being ignored, which usually means an option name typo.
The window was honoured. The X and Y ranges of the result must lie inside the requested bounds once filters.crop is applied. Without the crop they will overshoot by up to one node, which is expected rather than wrong.
A far-away window returns nothing. Query a box outside the file’s extent and confirm zero points and no exception. That is also the symptom of a CRS mismatch, so pair it with a query you know should succeed.
# Gotchas and Edge Cases
Bounds in the wrong CRS return zero points silently. No exception, no warning, no data. Read the file’s extent first and check your window overlaps it before blaming anything else.
A coarse read is not a valid input to a measurement. The octree sample is spatially even but statistically incomplete — minimum elevations are missing, so a DTM built from it sits above the true surface. Use resolution for display and exploration only; the DTM raster generation guide assumes a full read.
Reading over HTTPS needs a server that honours ranges. Test with curl -r 0-1023 against the URL. If the whole object comes back, no client setting will fix it.
A pipeline that forgot resolution looks like a slow format. It is the commonest complaint about COPC and it is a missing option, not the format.
# Frequently Asked Questions
Why does my bounded query return points outside the window?
Because the reader fetches whole octree nodes, and a node that intersects your window brings all of its points with it. That is the design — it is what keeps the number of range requests small. Add filters.crop with the same bounds after the reader for an exact clip, which costs nothing because the points are already local.
What does the resolution value actually mean?
It is a node size in CRS units, not a point spacing. The reader descends the octree until node size falls below the value and stops, so a resolution of two metres over a one-kilometre window returns a spatially even sample suitable for display. Leave it unset when the result will be measured.
Why did my query return zero points with no error?
Almost always because the bounds were expressed in a different coordinate system from the file. The option does not reproject its argument, so a latitude and longitude window against a UTM file matches no nodes and returns an empty result without complaint.
Is a coarse read good enough to build a DTM?
No. The octree sample is spatially even but statistically incomplete, so the lowest returns in each cell — exactly the ones a bare-earth surface needs — are missing. A DTM built from a coarse read sits systematically above the true ground.
# Related
- COPC and Cloud-Native Point Cloud Formats — the parent guide to the octree these options traverse
- Converting LAZ Tiles to COPC with PDAL — producing the files this guide reads
- COPC vs EPT for Web Delivery — how the two formats answer the same query
- Streaming LAZ from S3 with PDAL — the range-request mechanics underneath a remote query
- Point Cloud Data Standards and Fundamentals — the section overview