Streaming COPC Octree Nodes Over HTTP Range Requests
A COPC file answers a spatial query the same way an indexed FlatGeobuf answers a bounding-box query: the client reads a small index, works out which byte ranges it needs, and fetches only those. This walkthrough is for the engineer implementing or debugging that read path against an archive — where the difference between a correct implementation and a naive one is two orders of magnitude in transferred bytes, and where the failure modes are the quiet kind that return correct-looking data.
The Read Path, Node by Node
A COPC’s octree is stored as a hierarchy of pages, each listing child nodes with their byte offsets and point counts. A client descends only the branches that intersect the query, and stops descending when the resolution is sufficient.
Step-by-Step Procedure
Step 1 — Read the header and the COPC info block
The info block gives the octree’s root offset, the spacing at each level, and the extent — everything needed to plan the descent.
import requests, struct
URL = "https://spatial-archive.s3.eu-west-1.amazonaws.com/archive/lidar/2023/region_north.copc.laz"
def ranged(url, start, length):
r = requests.get(url, headers={"Range": f"bytes={start}-{start + length - 1}"})
assert r.status_code == 206, f"expected 206, got {r.status_code}" # never accept 200 here
return r.content
header = ranged(URL, 0, 375 + 160) # LAS header plus the COPC info VLR
Step 2 — Descend the hierarchy, fetching one page per level
def select_nodes(url, root_offset, root_size, query_bbox, max_depth):
"""Fetch hierarchy pages and return the node entries intersecting the query."""
selected, pending = [], [(root_offset, root_size)]
while pending:
offset, size = pending.pop()
page = ranged(url, offset, size)
for i in range(0, len(page), 32): # 32-byte entries
level, x, y, z, node_off, node_size, count = struct.unpack("<iiiiqii", page[i:i+32])
if not intersects(level, x, y, z, query_bbox):
continue # prune the whole branch
if count < 0: # negative count = child page
pending.append((node_off, node_size))
elif level <= max_depth:
selected.append((node_off, node_size, count))
return selected
Pruning at the branch rather than at the leaf is the whole optimisation: a branch that does not intersect the query is never fetched, and neither are any of its descendants.
Step 3 — Fetch the selected nodes, coalescing adjacent ranges
Nodes that are adjacent in the file should be fetched in one request. Because COPC writes nodes in octree order, spatially adjacent nodes are usually adjacent in the file too.
def coalesce(ranges, gap=1 << 20): # merge ranges separated by less than 1 MB
ranges.sort()
out = [list(ranges[0])]
for off, size, _ in ranges[1:]:
last = out[-1]
if off - (last[0] + last[1]) <= gap:
last[1] = off + size - last[0]
else:
out.append([off, size])
return out
Validation & Verification
Verify that the client is reading a small fraction of the object and that every response was a partial one.
# Measure with a real client: bytes transferred for a bounded query
pdal info --stats --bounds "([503000,504000],[5431000,5432000])" \
/vsis3/spatial-archive/archive/lidar/2023/region_north.copc.laz \
2>&1 | grep -E 'points|bbox'
# Confirm the storage endpoint answers 206 and not 200
curl -sI -r 0-374 "$URL" | head -3
Expected output is a point count far smaller than the object’s total, and an HTTP status of 206 Partial Content with a Content-Range header. A 200 means something in the path is serving whole objects, and the query is silently downloading 8.6 GB.
Choosing a Depth Limit for the Query
The octree lets a client stop descending at any level, and choosing where to stop is the single largest lever on how much a query transfers. The right depth follows from what the result is for: a map view needs enough points to look right at the current zoom, while an analysis needs every point in the extent.
Expose the limit as a parameter of the read rather than a property of the client, so the same reader can serve a map view and an analysis without two code paths. The COPC info block carries the spacing at each level, which is what lets a client convert a target point spacing on screen into a depth.
Troubleshooting
| Symptom | Root cause | Fix |
|---|---|---|
| Query returns correct points but transfers gigabytes | An intermediary stripped the Range header |
Assert on the 206 status; test the public path, not the origin |
| Hundreds of small requests | Ranges not coalesced | Merge ranges separated by less than about a megabyte |
| Client descends the whole tree | Branch pruning applied at leaves only | Prune on the hierarchy entry’s own bounds, before fetching children |
| Points outside the query returned | Node granularity, not a bug | Filter points client-side after decode; nodes are the fetch unit, not the answer |
| 416 on some ranges | Hierarchy read from a different file version | Re-read the header; never cache offsets across object versions |
Operational Execution Checklist
Caching Node Ranges Between Queries
Interactive clients issue many overlapping queries as a user pans and zooms, and the hierarchy pages they need are almost always the same ones. Caching those separately from the point data turns the second and subsequent queries into a single point-data fetch.
The last row is the constraint that makes the rest safe. Because a COPC’s byte offsets are meaningful only within one file, a client that caches offsets across a republication reads the wrong points without any error. Versioned paths remove the possibility entirely.
Frequently Asked Questions
Does the resolution limit save transfer as well as time?
Yes, and it is the largest available saving for visualisation clients. Stopping the descent at a shallow level returns a thinned but spatially complete point set, which is exactly what a map view needs — and it fetches a fraction of the nodes a full-resolution read would. The octree’s level structure is what makes that a range selection rather than a post-filter.
Can a COPC in Deep Archive be read this way?
No. Ranged reads require a class that serves ranges, so a Deep Archive COPC must be restored first, after which its temporary copy behaves normally. Where point clouds are queried rather than retrieved whole, keep them in an instant-retrieval class and accept the higher per-gigabyte price for the collection that needs it.
How does this compare with reading tiled LAZ?
A tiled LAZ archive has no in-file index, so the client’s only pruning mechanism is the tile grid encoded in file names — coarse, and it still reads each selected tile in full. COPC replaces a directory listing with an octree and whole-file reads with node reads, which is why the same query moves 47 MB instead of 840 MB.
How does a client know which depth corresponds to a screen resolution?
From the spacing value in the COPC info block, which gives the point spacing at the root level; each deeper level halves it. A client computes the ground distance one screen pixel covers at the current zoom and descends until the level’s spacing is at or below it. That calculation is what turns a zoom level into a byte range, and it is the whole reason the spacing is in the header.
Should a reader prefetch nodes it has not been asked for?
Modestly, and only in the direction of travel. Prefetching the neighbours of the current viewport makes panning feel instantaneous and costs bandwidth that is usually available; prefetching a whole level speculatively does not. Cap it by a byte budget rather than by node count, since node sizes vary by an order of magnitude across a survey.
What happens if the hierarchy is corrupted?
The reader sees offsets that point at the wrong bytes, and LAZ decompression usually fails outright rather than returning wrong points — which is fortunate, because it makes the failure loud. Structural validation of the hierarchy is therefore worth including in the integrity sampling for point-cloud collections, since it is the one part of the file whose corruption is not caught by a whole-file checksum being correct.
Related
- Point Cloud & COPC Conversion for Spatial Archives — the parent topic, including why consolidation and indexing are one decision.
- Converting LAS to COPC for Cold Archives — producing the objects this read path consumes.
- Streaming FlatGeobuf Features Over HTTP Range Requests — the same mechanics for vector features, including the CDN failure modes.
- Tuning COG Internal Tile Size for Range Requests — the over-fetch trade-off in the raster equivalent.
Up one level: Point Cloud & COPC Conversion.