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.

Two independent dials, four outcomes A two by two grid. With neither bounds nor resolution set, every node is fetched — the whole file. With resolution only, shallow nodes across the whole extent are fetched, which is a coarse overview. With bounds only, every level within one window is fetched. With both, only the nodes that intersect the window down to the requested level, which is a handful of range requests. no bounds bounds set no resolution resolution set every node 2.6 GB — the whole file, plus index overhead every level, one window 172 MB — right for a measurement over a small area shallow levels, everywhere 41 MB — right for an overview of the whole region intersecting nodes only 4.1 MB in 5 requests — what the format was built for the two options are independent, and the bottom-right cell is the only one that is cheap in both dimensions

# 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

bash
pdal info block_04.copc.laz --summary | python -m json.tool | grep -A8 bounds

Anything you pass to bounds has to live inside that box, in those units.

# Step 2 — Ask for a window

json
{"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

json
{"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

json
{"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

python
"""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"
Five requests, in this order, every time The request sequence for a bounded query. The reader fetches the file header, then the COPC info record holding the octree, then works out which nodes intersect the window at the requested level, then fetches those node ranges. Only the last step scales with how much data you asked for; the first three are fixed. 1 · header 4 KB — fixed 2 · copc info VLR 160 KB — fixed 3 · hierarchy page 380 KB — fixed 4 · node ranges 3.6 MB — scales with the query a 1 km² window at 2 m resolution the three fixed requests are why the cache matters on a loop of queries against one object

# 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.

You get whole nodes, not exactly your window A requested rectangular window drawn over an octree node grid. The nodes that intersect the window are shaded, and they extend beyond it on every side because the reader fetches whole nodes. A crop filter after the reader trims the result to the requested rectangle, at no network cost since the points are already local. octree nodes at the requested level, with the query window drawn over them the window you asked for the nodes you are sent filters.crop trims the difference, locally and for free this is why a bounded read returns more points than the window contains, and why that is not a bug

# 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.