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.

How a viewer fetches only the octree nodes it needs from a COPC An octree drawn as four levels. The root node holds a sparse sample of the whole survey at about two megabytes. Level one holds eight nodes, level two sixty-four, and level three five hundred and twelve, with detail increasing at each level. A shaded path shows the nodes a viewer requests when zoomed into one corner: the root, one node at each of levels one and two, and four at level three, totalling about eleven megabytes fetched from a forty-three gigabyte file. A note states that the rest of the file is never transferred. root · 2 MB 3 MB level 1 — 8 nodes level 2 — 64 nodes level 3 — 512 nodes ≈ 11 MB fetched from a 43 GB file The unshaded nodes are never requested, and never leave the server.

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.

What conversion to COPC actually rearranges A four-stage view. Stage one reads the source cloud and establishes its extent. Stage two builds an octree over that extent, assigning every point to a node by position. Stage three orders the points so that each node's points are contiguous on disk, coarsest level first. Stage four writes a LAZ file whose variable length record carries the node index, so a reader can request one node by byte range. A note states that the point data is unchanged and only its arrangement differs, which is why a COPC file opens in any LAZ reader. 1. read source cloud, establish the extent 2. octree assign every point to a node by position 3. order node points contiguous, coarsest level first 4. write LAZ plus a node index in a length record The points are unchanged; only their arrangement differs, which is why any LAZ reader opens it.

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}
Data transferred for a first view, by hosting configuration Three bars comparing what a client transfers to open one corner of a forty-three gigabyte survey. With a plain LAZ file the client transfers the full forty-three gigabytes. With a COPC on a host that does not honour range requests, the transfer is still forty-three gigabytes because the viewer must download everything to read any of it. With a COPC on a host that honours ranges and exposes the Content-Range header, the transfer is about eleven megabytes. A note states that the middle case is the one teams ship by accident. plain LAZ 43 GB COPC, no ranges 43 GB COPC, ranges + CORS 11 MB The middle bar is the one teams ship by accident. The file is correct; the hosting silently discards its only advantage. Which is why the hosting check belongs in the deploy pipeline, not in a runbook.

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 Range headers. 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.

Point Cloud Formats and Interchange in Python