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.

What overlap does to a merge Two adjacent tiles sharing an overlap strip. Merged as delivered, the strip contains points from both files and its density doubles. Cropped to their nominal extents first, each point is contributed by exactly one tile and the density is uniform across the join. merged as delivered 2× density the overlap strip holds every point twice; density maps show the tile grid cropped, then merged each point contributed once, density uniform across the join

# 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

json
{"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

json
{"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

python
"""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
Two destinations, two different products A merge target chosen by purpose. A result that will be queried by area and resolution wants COPC, which builds an octree and is bounded by memory. A result that will be reprocessed or re-tiled wants plain LAZ, which is a concatenation, streams, and has no size ceiling. will be queried by area writers.copc — octree, memory-bound will be served to a viewer writers.copc — one URL, range reads will be reprocessed writers.las — concatenation, streams will be re-tiled writers.las — no size ceiling choosing before you start matters because the two have different size limits, not different syntax

# 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.

The cost of merging without cropping Sixteen tiles merged two ways. Cropped to nominal extents first, the merged block holds 64.1 million points in 1.9 gigabytes. Merged as delivered, it holds 71.8 million points in 2.2 gigabytes — twelve percent more, all of it duplicated returns along the tile boundaries. 16 tiles delivered with a 20 m overlap cropped, then merged 64.1 M points · 1.9 GB merged as delivered 71.8 M points · 2.2 GB the extra 7.7 M points are duplicates along the tile boundaries they raise measured density by 12% in the strips and by nothing in the middle, which is exactly the pattern that makes a density map show the tile grid.

# 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.