Mosaicking CHM Tiles Without Edge Seams in Python

A canopy height mosaic is where every earlier compromise becomes visible at once. Tiles that were classified independently show a height step at their shared edge; tiles whose grids are a fraction of a pixel apart force a resample that smears the whole surface; tiles with inconsistent NoData punch black rectangles through the forest. This guide covers assembling tiles into one regional raster whose seams cannot be found, as the final assembly step of Canopy Height Model Creation within Canopy Height Modeling & Terrain Extraction.

When to use this approach

Method Output Cost Use when
gdalbuildvrt Virtual mosaic, no pixels copied Seconds Almost always — analysis and inspection both work directly on a VRT
gdal_translate from the VRT One materialised GeoTIFF Minutes to hours Handing the product to someone who cannot read a VRT
rasterio.merge In-memory or written array Memory-bound The mosaic fits in RAM and you need custom overlap logic
Feathered blend across overlaps Materialised, smoothed Highest Genuine radiometric mismatch, which a CHM should not have

Build a VRT first, always. It is free, it fails loudly when the grids disagree, and it can be turned into a GeoTIFF later with one command. Reach for feathering only as a last resort — on a height surface a feathered seam does not fix a bad edge, it just makes it harder to find.

The three conditions for a seamless mosaic

A mosaic is seamless when three things hold, and each has its own failure signature.

  1. Identical grid. Same CRS, same cell size, and origins that differ by a whole number of cells. Otherwise every read resamples.
  2. Consistent NoData. One value, declared in the profile of every tile, and never a real elevation like 0.
  3. No conflicting overlap. Either the tiles abut exactly, or where they overlap the values agree — which they will only if the halo discipline from Tiling and Buffering Large LiDAR Surveys with PDAL was followed.
Three ways a canopy height mosaic develops a visible seam Three panels. The first shows two tile grids offset by a fraction of a cell, so their pixel edges do not line up and any read must resample. The second shows a tile whose NoData value differs from its neighbours, leaving a rectangular hole in the assembled surface. The third shows two overlapping tiles where the later one is painted over the earlier, so its differently classified values form a visible rectangle inside the mosaic. 1. Off-grid origins 2. Inconsistent NoData 3. Uncropped overlap 0.4 px offset every read resamples nodata = 0 read as 0 m tall a rectangular clearcut that is not there tile A tile B shared halo strip written twice, last one wins All three are invisible in a thumbnail and obvious in a slope raster derived from the mosaic. Fix them at the tile writer, never with a smoothing pass over the finished surface.

Minimal reproducible example

Validate first, then build. The validation is what turns a silent resample into an error message.

import glob

import rasterio


def assert_mosaicable(tifs: list[str], tol: float = 1e-6) -> dict:
    """Fail loudly if tiles do not share a CRS, a cell size, and a common grid."""
    ref_res = ref_crs = ref_nodata = None
    for path in sorted(tifs):
        with rasterio.open(path) as src:
            res = (abs(src.transform.a), abs(src.transform.e))
            if ref_res is None:
                ref_res, ref_crs, ref_nodata = res, src.crs, src.nodata
            assert src.crs == ref_crs, f"{path}: CRS {src.crs} != {ref_crs}"
            assert abs(res[0] - ref_res[0]) < tol and abs(res[1] - ref_res[1]) < tol, \
                f"{path}: cell size {res} != {ref_res}"
            assert src.nodata == ref_nodata, f"{path}: nodata {src.nodata} != {ref_nodata}"
            # Origin must be a whole number of cells from the reference origin.
            offx = (src.bounds.left / ref_res[0]) % 1.0
            offy = (src.bounds.top / ref_res[1]) % 1.0
            assert min(offx, 1 - offx) < 1e-3 and min(offy, 1 - offy) < 1e-3, \
                f"{path}: origin is off-grid by {offx:.3f}, {offy:.3f} cells"
    return {"n": len(tifs), "res_m": ref_res[0], "crs": str(ref_crs),
            "nodata": ref_nodata}


print(assert_mosaicable(sorted(glob.glob("chm_tiles/*.tif"))))

With that passing, the mosaic itself is one command. -vrtnodata and -srcnodata must both be set or GDAL will treat NoData cells as real heights:

gdalbuildvrt -srcnodata -9999 -vrtnodata -9999 \
    -resolution highest -r nearest \
    chm_mosaic.vrt chm_tiles/*.tif

gdal_translate -of COG -co COMPRESS=DEFLATE -co PREDICTOR=2 \
    -co BLOCKSIZE=512 -co OVERVIEWS=IGNORE_EXISTING \
    chm_mosaic.vrt chm_mosaic.tif

-r nearest is deliberate. Any other resampler will interpolate across NoData at tile edges, which manufactures a soft height ramp exactly where you are trying to prove there is no seam.

When you need custom overlap logic — taking the maximum where two halos meet, for instance, so a crown clipped by one tile is recovered from the other — do it in Python:

import numpy as np
import rasterio
from rasterio.merge import merge


def max_of_overlaps(old_data, new_data, old_nodata, new_nodata, **kwargs):
    """rasterio.merge method: keep the taller value where tiles overlap."""
    valid = ~new_nodata
    np.copyto(old_data, np.maximum(old_data, new_data),
              where=valid & ~old_nodata)
    np.copyto(old_data, new_data, where=valid & old_nodata)


def merge_chm(tifs: list[str], out_path: str, nodata: float = -9999.0) -> None:
    srcs = [rasterio.open(p) for p in sorted(tifs)]
    try:
        mosaic, transform = merge(srcs, nodata=nodata, method=max_of_overlaps)
        profile = srcs[0].profile
        profile.update(height=mosaic.shape[1], width=mosaic.shape[2],
                       transform=transform, nodata=nodata,
                       compress="deflate", predictor=2, tiled=True)
        with rasterio.open(out_path, "w", **profile) as dst:
            dst.write(mosaic)
    finally:
        for s in srcs:
            s.close()

Taking the maximum is the right rule for a canopy surface specifically: a cell is only lower in one tile than another when that tile saw fewer returns there, so the taller value is the better-observed one. The same rule would be wrong for a terrain model, where the minimum is the defensible choice.

Parameter reference

Option Tool Recommended Rationale
-srcnodata / -vrtnodata gdalbuildvrt −9999 Both must be set; omitting either makes NoData a real height
-resolution gdalbuildvrt highest Forces an error rather than a silent downsample if a tile disagrees
-r gdalbuildvrt nearest Any interpolating resampler smears values across tile edges
-of COG gdal_translate COG Ships with overviews and internal tiling; readable by window
PREDICTOR=2 gdal_translate 2 Roughly halves the size of a smooth float surface at no cost
BLOCKSIZE gdal_translate 512 Matches typical windowed reads; 256 for many small requests
method rasterio.merge max (CHM) Recovers crowns clipped by a tile edge; use min for terrain

Expected output and verification

Extent and grid checks prove the tiles fit together. Proving there is no value seam takes one more step: compare the distribution of horizontal height differences along the boundary columns against the same statistic in the tile interiors. A seam is a spike that exists only at the boundary.

import numpy as np
import rasterio


def seam_score(mosaic_path: str, boundary_xs: list[float], sample_rows: int = 2000) -> dict:
    """Compare |dz/dx| at tile boundaries against the interior baseline."""
    with rasterio.open(mosaic_path) as src:
        arr = src.read(1, masked=True).astype("float32")
        res = abs(src.transform.a)
        cols = [int((x - src.bounds.left) / res) for x in boundary_xs]

    step = max(1, arr.shape[0] // sample_rows)
    grad = np.abs(np.diff(arr[::step, :], axis=1))
    interior = float(np.ma.median(grad))

    edges = [float(np.ma.median(grad[:, c - 1:c + 1])) for c in cols
             if 1 <= c < grad.shape[1] - 1]
    worst = max(edges) if edges else 0.0
    return {"interior_median_dz": interior,
            "worst_boundary_median_dz": worst,
            "ratio": worst / interior if interior else float("inf")}


report = seam_score("chm_mosaic.tif", boundary_xs=[501000.0, 502000.0, 503000.0])
assert report["ratio"] < 1.5, f"seam detected at a tile boundary: {report}"

A ratio near 1.0 means the boundary looks exactly like the forest around it, which is the definition of seamless. A ratio above about 1.5 means the tiles were classified with different parameters or without a halo, and the fix is upstream — no amount of mosaicking technique will repair it.

Seam detection by comparing height-gradient distributions Two overlaid distributions of absolute cell-to-cell height difference. The interior distribution is a single smooth peak near half a metre, falling away to almost nothing by three metres. The boundary distribution matches it at low values but carries a second bump between three and six metres, which is the height step introduced by tiles classified without shared context. 0 2 4 6 8 |Δheight| between adjacent cells (m) frequency tile interior boundary columns seam mode The extra mode is the tile step; a seamless mosaic puts the two curves on top of each other. Choosing between a virtual mosaic, a materialised file and a custom merge A decision flow. Tiles enter a grid validation step. If validation fails, the path returns to the tile writer to fix resolution, CRS or NoData rather than proceeding. If it passes, the default branch builds a virtual mosaic. From there one branch materialises a Cloud Optimized GeoTIFF for distribution and another applies a custom overlap rule in Python when halo values must be reconciled. tile set n GeoTIFFs validate grid CRS, res, origin, nodata fix the tile writer build a VRT seconds, no pixels materialise COG for distribution custom merge max where halos meet fails A validation failure is never solved downstream — the only fix is at the tile writer.

Common pitfalls

  • Setting -vrtnodata and forgetting -srcnodata. The VRT then declares a NoData value that none of its sources actually use, so the holes stay filled with −9999 and every statistic is poisoned.
  • Using bilinear resampling in gdalbuildvrt. It interpolates across NoData at tile edges and creates a smooth ramp that looks like a genuine canopy gradient.
  • Mosaicking before validating alignment. GDAL will happily produce output from misaligned tiles, resampling silently; the result looks fine and is blurred everywhere.
  • Materialising the GeoTIFF too early. A VRT costs nothing and can be rebuilt in seconds when one tile is reprocessed. A 200 GB GeoTIFF cannot.
  • Treating a seam as a cosmetic problem. A height step at a boundary propagates into crown segmentation, biomass, and fuel layers. Fix the tile processing, then re-mosaic.

Frequently Asked Questions

Should the regional product be one file or stay a VRT?

Keep the VRT as the working product and materialise a Cloud Optimized GeoTIFF only for distribution. Analysis code reads a VRT exactly like a GeoTIFF, and when a single tile is reprocessed the VRT updates in seconds where a materialised mosaic has to be rebuilt in full.

How do I mosaic tiles that genuinely have different resolutions?

Do not. Resample the odd tiles to the common grid as an explicit, recorded step, with the resampler chosen deliberately — average when going coarser, so a downsampled canopy keeps its mean height rather than whichever pixel happened to be sampled. Then mosaic the harmonised set.

Does the mosaic need overviews?

For anything that will be opened in a desktop GIS or served to a map, yes — without them every pan redraws the full resolution. Build them into the COG at creation time rather than as a sidecar, so the pyramid travels with the file.

Up: Canopy Height Model Creation · Canopy Height Modeling & Terrain Extraction