Converting LAZ Tiles to COPC with PDAL

TL;DR: pdal translate in.laz out.copc.laz --writers.copc.forward=all converts one tile; for a directory, merge related tiles into one COPC per region rather than converting each tile separately, because one large indexed object is the whole point of the format.

# Context and Motivation

This guide is part of COPC and Cloud-Native Point Cloud Formats. Converting is mechanically simple — one writer, one option — so the substance here is the decisions around it: which tiles to merge, what the conversion costs, and what has to be checked before the old tiles can be deleted.

The merging decision is the one people get wrong. Converting 5,000 tiles into 5,000 COPC files preserves every problem tiling created: a client still has to know which file covers its area, still has to stitch results across boundaries, and now also pays an octree per tile. Merging a whole survey block into one COPC lets the octree do the spatial indexing that the tile grid was standing in for.

Convert each tile, or merge the block Above, sixteen tiles converted one for one into sixteen COPC files: the client still has to know which file covers its area and still stitches across boundaries. Below, the same sixteen tiles merged into a single COPC whose octree indexes the whole block, so a client asks for a bounding box and the format finds the data. 16 tiles → 16 COPC files 16 objects the client still needs a tile index, and still stitches across edges 16 tiles → 1 COPC one octree, one object 1 object the client asks for a bounding box and the format finds the data

# Prerequisites and Assumptions

Requirement Detail
PDAL 2.4+ with writers.copc
Source LAS or LAZ with a valid CRS; all inputs to one COPC must share it
Disk room for source and output together during conversion
Memory merging is not streamable — the octree needs the whole block
Naming the .copc.laz double extension, which most tooling recognises

The memory row matters. Building an octree is inherently a whole-dataset operation, so a merged COPC of a 40 GB block needs a machine that can hold it. Regions of 5–20 GB are a practical sweet spot; beyond that, split by natural boundaries rather than by grid.

# Step-by-Step Implementation

# Step 1 — Convert a single tile to check the settings

bash
pdal translate tile_0431.laz tile_0431.copc.laz \
  --writers.copc.forward=all --verbose 4

# Step 2 — Confirm the CRS survived

bash
pdal info tile_0431.copc.laz --metadata | grep -i -A2 srs

A COPC file with no CRS is a file no client can place, and the conversion will not warn you.

# Step 3 — Merge a block rather than converting each tile

json
{
  "pipeline": [
    "tiles/tile_0431.laz",
    "tiles/tile_0432.laz",
    "tiles/tile_0433.laz",
    {"type": "filters.merge"},
    {"type": "writers.copc", "filename": "block_04.copc.laz", "forward": "all"}
  ]
}

# Step 4 — Verify before deleting anything

Point counts, bounds and CRS, all compared against the sum of the inputs. Only then is the source safe to archive.

# Complete Working Example

python
"""Merge a group of LAZ tiles into one COPC file and verify the result."""
from __future__ import annotations

import json
import logging
from pathlib import Path

import pdal

LOG = logging.getLogger("copc_merge")


def tile_points(path: Path) -> int:
    p = pdal.Pipeline(json.dumps({"pipeline": [
        {"type": "readers.las", "filename": str(path), "count": 1}]}))
    p.execute()
    return int(p.quickinfo["readers.las"]["num_points"])


def merge_to_copc(tiles: list[Path], dst: Path) -> dict:
    expected = sum(tile_points(t) for t in tiles)
    LOG.info("merging %d tiles, %d points expected", len(tiles), expected)

    stages: list = [{"type": "readers.las", "filename": str(t)} for t in tiles]
    stages.append({"type": "filters.merge"})
    stages.append({"type": "writers.copc", "filename": str(dst), "forward": "all"})

    written = pdal.Pipeline(json.dumps({"pipeline": stages})).execute()
    if written != expected:
        raise AssertionError(f"merged {written} points, expected {expected}")

    check = pdal.Pipeline(json.dumps({"pipeline": [
        {"type": "readers.copc", "filename": str(dst), "resolution": 50.0}]}))
    check.execute()
    info = check.quickinfo["readers.copc"]
    if not info.get("srs", {}).get("horizontal"):
        raise AssertionError("output has no horizontal CRS — forward did not carry it")

    return {"tiles": len(tiles), "points": written,
            "coarse_sample": len(check.arrays[0]),
            "bounds": info["bounds"], "output": str(dst)}


if __name__ == "__main__":
    logging.basicConfig(level=logging.INFO)
    group = sorted(Path("tiles").glob("tile_04*.laz"))
    print(json.dumps(merge_to_copc(group, Path("block_04.copc.laz")), indent=2))

# Key Parameter Table

Option Stage Guidance
forward writers.copc all — the CRS record above all else
a_srs writers.copc Only when the source CRS is wrong; it relabels, it does not transform
filters.merge Required when several readers feed one writer
resolution readers.copc Coarse value for verification; fast structural check
output name .copc.laz, by convention, so tooling recognises it

# Verification

Counts add up. The assertion above compares the merged output against the sum of the inputs.

The CRS is present. Also asserted, because it is the failure that survives every other check.

A coarse read spans the whole block. Read at 50-metre resolution and confirm the returned points cover the merged bounds, not one tile’s worth.

Plain LAS tools still open it. pdal info block_04.copc.laz should work through readers.las too — the compatibility guarantee, tested rather than assumed.

What conversion costs, by block size Conversion time and output size for four merge groups. Four tiles take 41 seconds and produce 0.6 gigabytes. Sixteen tiles take 168 seconds and 2.4 gigabytes. Sixty-four tiles take 780 seconds and 9.6 gigabytes, and the octree build starts to dominate. Two hundred and fifty-six tiles exceeds a 32 gigabyte worker. merging N tiles of about 150 MB each, on an 8-core worker with 32 GB 4 tiles 41 s · 0.6 GB out 16 tiles 168 s · 2.4 GB out 64 tiles 780 s · 9.6 GB out 256 tiles out of memory — the octree needs the whole block

# Gotchas and Edge Cases

Mixed CRSs merge into nonsense. filters.merge does not reproject. Every input must already share a CRS, which for a campaign spanning a UTM zone boundary means reprojecting first — see reprojecting point clouds from UTM to WGS84.

Overlapping tiles double-count. Merging tiles that were delivered with overlap produces duplicate points in the overlap strips. Crop each tile to its nominal extent first, or accept that density metrics will be wrong.

Conversion is not streamable. No amount of chunk tuning helps; the octree is a whole-dataset structure. Size the machine for the block.

What to check before deleting the source Four checks before the original tiles can be archived. The merged point count equals the sum of the inputs. The horizontal CRS is present in the output. A coarse read spans the full merged bounds. And an ordinary LAS reader opens the file, which is the backward-compatibility promise the format makes. 1 · merged point count equals the sum of the input counts 2 · a horizontal CRS is present in the output metadata 3 · a coarse read spans the whole merged extent, not one tile 4 · readers.las opens the same file — the compatibility promise, tested

# Frequently Asked Questions

Should I convert each tile to its own COPC file?

Usually not. Converting 5,000 tiles into 5,000 COPC files keeps every problem the tile grid created — the client still needs an index and still stitches across boundaries — and adds an octree per tile. Merge tiles into regional blocks so the octree does the spatial indexing.

How large can a merged COPC block be?

It is bounded by memory, because building an octree is a whole-dataset operation that cannot stream. Blocks of five to twenty gigabytes are practical on ordinary workers; beyond that split along natural boundaries rather than pushing the machine.

Does the conversion change my points?

The coordinates and dimensions are preserved, but the point data record format is upgraded to 6, 7 or 8 because COPC requires it. That changes how the classification flags are stored, and the point order changes entirely since the octree ordering is the file layout.

What has to be true before I delete the source tiles?

Four things: the merged point count equals the sum of the inputs, a horizontal CRS is present in the output, a coarse read spans the whole merged extent rather than one tile, and an ordinary LAS reader opens the file. All four are cheap and one of them always catches something.