Converting LAS to COPC for Cloud Streaming
The client wants to look at the point cloud. The point cloud is 43 GB. Sending it on a disk takes two days and they will open it once; putting it behind a download link means a three-hour wait and a machine that cannot load it anyway.
COPC — cloud-optimized point cloud — solves this the way a Cloud-Optimized GeoTIFF solves it for rasters, and for exactly the same reason. The file is reorganised so that a client can fetch only the parts it needs over HTTP range requests, at the level of detail it is currently displaying. Zooming into one corner fetches a few megabytes; the other 43 GB stay on the server.
This page covers producing one correctly, hosting it so the range requests actually work, and verifying both. It is the streaming half of point cloud formats and interchange in Python.
What is inside a COPC
A COPC is a valid LAZ 1.4 file with two additions. The points are reordered into an octree: the root node holds a sparse sample of the whole survey, and each level down subdivides space into eight and adds detail. And a hierarchy structure, stored in the file’s VLRs, records where each node’s compressed chunk begins and how long it is.
That is the whole trick. A viewer reads the header and the hierarchy — a few hundred kilobytes — then requests byte ranges for exactly the nodes intersecting the current view at the current zoom. The format is otherwise ordinary LAZ, so any tool that reads LAZ reads a COPC without knowing what it is.
The practical consequence is that a COPC is strictly better than the LAZ it came from, provided the host serves range requests. It is the same data, the same size to within a few percent, readable by the same software, and additionally streamable.
Figure 1 — The access pattern. Nothing about the data changes; only its order and an index.
Minimal reproducible solution
import json
import subprocess
from pathlib import Path
def to_copc(src: str, dst: str, *, keep_extra_dims: bool = True) -> dict:
"""Write a COPC from a LAS or LAZ file, preserving header and attributes.
`forward: all` is what carries scale, offset, CRS and the source's own
metadata through. Without it the writer applies its own defaults and a
correctly scaled LAS becomes an incorrectly scaled COPC.
"""
stage = {"type": "writers.copc", "filename": dst, "forward": "all"}
if keep_extra_dims:
stage["extra_dims"] = "all"
subprocess.run(["pdal", "pipeline", "--stdin"],
input=json.dumps({"pipeline": [src, stage]}),
text=True, check=True)
return {"bytes": Path(dst).stat().st_size,
"source_bytes": Path(src).stat().st_size}
Conversion is a full rewrite — the points are reordered — so it costs a pass over the data, roughly the time of a LAZ recompression. For a 43 GB survey that is tens of minutes, once, and it is worth doing as the final step of every job rather than on request.
Hosting: the part that is not a Python problem
A COPC on a host that does not support HTTP range requests is just a large LAZ file that clients download in full. Three server behaviours are required, and two of them are frequently missing on default configurations.
The host must advertise Accept-Ranges: bytes and honour a Range header by returning 206 Partial Content. Static object storage does this by default; some CDNs and many application servers do not. And the host must send permissive CORS headers — including exposing Content-Range — or a browser-based viewer cannot read the responses even when the server returns them correctly.
import requests
def check_hosting(url: str) -> dict:
"""Confirm a hosted COPC can actually be streamed by a browser viewer."""
head = requests.head(url, timeout=15)
ranged = requests.get(url, headers={"Range": "bytes=0-4095"}, timeout=15)
problems = []
if head.headers.get("Accept-Ranges") != "bytes":
problems.append("server does not advertise byte ranges")
if ranged.status_code != 206:
problems.append(f"range request returned {ranged.status_code}, not 206 — "
"the whole file will be downloaded")
if len(ranged.content) != 4096:
problems.append(f"range request returned {len(ranged.content)} bytes, "
"not the 4096 requested")
acao = ranged.headers.get("Access-Control-Allow-Origin")
if acao not in ("*",):
problems.append(f"CORS origin is {acao!r} — a browser viewer will be blocked")
expose = (ranged.headers.get("Access-Control-Expose-Headers") or "").lower()
if "content-range" not in expose:
problems.append("Content-Range is not exposed to browsers")
return {"status": ranged.status_code, "problems": problems}
The Access-Control-Expose-Headers check is the one that catches people. Everything else can be correct, the server returns 206 with the right bytes, and the viewer still fails — because the browser will not let JavaScript read a response header it was not told to expose, and the viewer needs Content-Range to know what it received.
Figure 3 — Conversion rearranges bytes, not data.
Edge-case matrix
| Situation | Symptom | Handling |
|---|---|---|
| Source header has wrong scale | COPC inherits it | Fix the LAS first; the COPC is a faithful copy |
forward: all omitted |
CRS and scale reset to defaults | Always forward |
| Host without range support | Full download, viewer stalls | Move to object storage |
| CORS not configured | Viewer fails with no useful error | Allow the origin and expose Content-Range |
| Very small cloud (< 5 M points) | Octree overhead outweighs benefit | Plain LAZ is fine |
| Extremely deep octree | Many tiny requests, slow first paint | Default hierarchy page size is right; do not tune |
| Cloud with no spatial extent set | Hierarchy built over wrong bounds | Verify header bounds before converting |
| File served through a caching CDN | Range requests cached inconsistently | Confirm the CDN forwards Range |
Verification snippet
import json
import subprocess
def verify_copc(path: str) -> dict:
"""Structural verification of a written COPC, without a viewer."""
out = subprocess.run(["pdal", "info", "--metadata", path],
capture_output=True, text=True, check=True)
meta = json.loads(out.stdout)["metadata"]
problems = []
if not meta.get("copc", False):
problems.append("file is not recognised as COPC")
srs = meta.get("srs", {}).get("horizontal", "")
if not srs:
problems.append("no horizontal CRS — the cloud will not position")
scale = meta.get("scale_x")
if scale and scale > 0.005:
problems.append(f"scale {scale} is coarser than 5 mm")
if meta.get("count", 0) == 0:
problems.append("zero points")
return {"points": meta.get("count"), "scale_x": scale,
"srs": srs[:60], "problems": problems}
Figure 2 — Producing the file is half the job. The other half is a server configuration nobody tests.
Delivering it as a product rather than a URL
A COPC behind a link is useful; a COPC behind a link with three pieces of metadata is a deliverable. Ship a small JSON alongside it giving the CRS, the point count and bounds, and the classification scheme used — the same information the volume record carries for a volume. A client who opens the viewer six months later and wonders what class 6 means then has an answer.
Where several surveys of the same site exist, keeping them at stable, predictable URLs — one per epoch, named by date — lets a viewer switch between them without any server-side machinery. That is the cheapest possible change-visualisation product, and clients use it far more than the change maps that cost twenty times as much to produce.
Finally, set a long cache lifetime and version the URL rather than overwriting in place. A COPC that is replaced under the same URL will serve a mixture of old and new byte ranges to any client that had part of it cached, and the resulting corruption is very hard to diagnose from the viewer’s error message.
When to escalate
- The viewer works from one network and not another. A proxy is stripping or rewriting
Rangeheaders. That is a network conversation, not a processing one. - First paint is slow despite correct ranges. The root node may be unusually large because the source cloud had extreme outliers stretching its bounds. Denoise before converting, per filtering noise and outliers from dense clouds.
- The client needs the data offline. COPC is still a valid LAZ; give them the same file. No separate export is needed.