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.
# 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
pdal translate tile_0431.laz tile_0431.copc.laz \
--writers.copc.forward=all --verbose 4# Step 2 — Confirm the CRS survived
pdal info tile_0431.copc.laz --metadata | grep -i -A2 srsA 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
{
"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
"""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.
# 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.
# 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.
# Related
- COPC and Cloud-Native Point Cloud Formats — the parent guide to the format and its octree
- Querying a COPC File by Bounds and Resolution — reading back only what a question needs
- COPC vs EPT for Web Delivery — choosing between the two cloud-native layouts
- Converting LAS to LAZ with PDAL — the simpler conversion and what it preserves
- Reading and Writing LAS VLRs with PDAL — why forward matters on every rewrite