Merging Processed Tiles into One LAZ
TL;DR: Crop each tile to its nominal extent, merge, then write — and decide before you start whether the destination is one COPC object for querying or a set of tiles for processing, because those are different products with different sizes.
# Context and Motivation
This guide is part of Tile Indexing, Buffering and Merging. Merging looks trivial — several readers, filters.merge, one writer — and the two things that go wrong both happen before the merge stage runs.
The first is duplication. Tiles delivered with overlap, or produced by a buffered pipeline that skipped its final crop, share points along their boundaries. Merging them concatenates those points, so the overlap strips end up with double the density. Nothing errors; the result is a cloud whose density map has a grid drawn on it and whose classification statistics are subtly wrong. Cropping each tile to its nominal extent first removes the problem completely.
The second is scale. Merging is not streamable when the output is COPC, because the octree needs the whole dataset, and it is memory-bound even when it is a plain LAZ concatenation if any filter in the chain buffers. A merge of four hundred tiles is a different operation from a merge of four.
# Prerequisites and Assumptions
| Requirement | Detail |
|---|---|
| PDAL | 2.4+ with filters.merge |
| One CRS | merge does not reproject; mixed inputs produce nonsense |
| Nominal extents | from the tile index, or from the delivery grid definition |
| Memory | a COPC merge holds the whole block; a LAZ merge can stream if nothing buffers |
| A destination decision | one queryable object, or a re-tiled set |
# Step-by-Step Implementation
# Step 1 — Confirm one CRS across the inputs
A single query against the index. Two coordinate systems means reprojecting first — see fixing CRS mismatches.
# Step 2 — Crop each input to its nominal extent
{"type": "readers.las", "filename": "tile_0431.laz"},
{"type": "filters.crop", "bounds": "([512000, 513000], [4783000, 4784000])"}In PDAL a crop after a specific reader applies to that reader’s branch, which is what makes per-tile extents possible in one pipeline.
# Step 3 — Merge
{"type": "filters.merge"}# Step 4 — Choose the writer for the purpose
writers.copc for something that will be queried; writers.las for something that will be re-tiled or reprocessed. The first is one object with an octree; the second is a plain concatenation.
# Step 5 — Verify the arithmetic
Output count must equal the sum of the cropped input counts.
# Complete Working Example
"""Merge a group of tiles, cropping each to its nominal extent first."""
from __future__ import annotations
import json
import logging
from pathlib import Path
import pdal
LOG = logging.getLogger("merge")
def cropped_count(tile: Path, bounds: tuple[float, float, float, float]) -> int:
xmin, ymin, xmax, ymax = bounds
p = pdal.Pipeline(json.dumps({"pipeline": [
{"type": "readers.las", "filename": str(tile)},
{"type": "filters.crop", "bounds": f"([{xmin}, {xmax}], [{ymin}, {ymax}])"},
]}))
return p.execute()
def merge(tiles: dict[Path, tuple[float, float, float, float]],
out: Path, copc: bool = True) -> dict:
expected = sum(cropped_count(t, b) for t, b in tiles.items())
LOG.info("expecting %d points from %d cropped tiles", expected, len(tiles))
stages: list = []
for tile, (xmin, ymin, xmax, ymax) in tiles.items():
stages.append({"type": "readers.las", "filename": str(tile)})
stages.append({"type": "filters.crop",
"bounds": f"([{xmin}, {xmax}], [{ymin}, {ymax}])"})
stages.append({"type": "filters.merge"})
stages.append({"type": "writers.copc" if copc else "writers.las",
"filename": str(out), "forward": "all"})
written = pdal.Pipeline(json.dumps({"pipeline": stages})).execute()
if written != expected:
raise AssertionError(
f"merged {written} points, expected {expected} — check the crop extents"
)
return {"tiles": len(tiles), "points": written, "output": str(out)}
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO)
group = {
Path("out/tile_0431.laz"): (512000.0, 4783000.0, 513000.0, 4784000.0),
Path("out/tile_0432.laz"): (513000.0, 4783000.0, 514000.0, 4784000.0),
}
print(json.dumps(merge(group, Path("block_04.copc.laz")), indent=2))# Key Parameter Table
| Choice | Options | Guidance |
|---|---|---|
| crop before merge | yes | The only reliable way to remove overlap duplication |
| writer | writers.copc / writers.las |
Query product against processing product |
forward |
all |
Header records must survive the merge |
| block size | 5–20 GB | Bounded by memory for COPC; unbounded for plain LAZ |
| verification | count arithmetic | Catches both duplication and an over-tight crop |
# Verification
Counts add up exactly. Asserted above. A surplus means overlap survived; a shortfall means a crop extent is wrong.
Density is uniform across joins. Rasterize a count layer over the merged block; the tile grid should be invisible.
The CRS survived. One check on the output metadata, and the failure that costs most if missed.
# Gotchas and Edge Cases
Merging does not reproject. Two CRSs in, nonsense out, silently.
A COPC merge is memory-bound. The octree needs the whole block. Merge in regions rather than campaigns.
Point order changes. filters.merge concatenates in reader order and writers.copc reorders entirely. Anything that indexed by row number is invalidated.
Extra dimensions must agree. Tiles with different custom dimensions merge into a layout holding the union, with zeros where a tile had nothing — which is legal and rarely what anyone expected.
# Frequently Asked Questions
Why does my merged cloud have doubled density along tile edges?
Because the tiles overlap and nothing removed the duplication. Deliveries are often cut on flight lines rather than the grid, and a buffered pipeline that skipped its final crop produces the same effect. Cropping each input to its nominal extent before the merge removes it entirely.
Can a merge stream?
A plain LAZ merge can, provided nothing in the chain buffers. A COPC merge cannot, because building the octree is a whole-dataset operation. That is what bounds a COPC block to what fits in memory, typically five to twenty gigabytes.
Does merging preserve point order?
No. filters.merge concatenates in reader order, and writers.copc reorders the points entirely to match the octree. Anything that referenced points by row number in the source files is invalidated by the merge.
What happens when tiles carry different extra dimensions?
The merged layout holds the union of them, with zeros filled in where a tile had no such dimension. That is legal, silent, and almost never what the person running the merge expected, so check the schema of the output rather than assuming.
# Related
- Tile Indexing, Buffering and Merging — the parent guide to the extents this crops to
- Buffered Tiling to Avoid Edge Artefacts — the processing step whose final crop this depends on
- Converting LAZ Tiles to COPC with PDAL — the merge target when the result will be queried
- Building a Tile Index with pdal tindex — where the nominal extents come from
- Batch Automation and Cloud Integration for PDAL — the section overview