Merge Strategies for Raster and Imagery Mosaics

Merging two raster branches is a per-chunk arbitration problem wearing a binary file’s clothing: the pixels themselves never conflict, but the question of which capture owns a given piece of ground always does. This guide is part of Branching & Merge Strategies for Spatial Datasets.

Prerequisites & Environment Setup

Before merging raster branches in production, confirm each of the following:

Core Algorithmic Patterns

1. Three-way diff over chunk hashes

The merge base gives raster branching the same leverage it gives text. For each chunk position, compare the hash on the base, on the branch and on the trunk:

  • unchanged on both sides → keep the base chunk, copy nothing
  • changed on one side only → take that side, no decision required
  • changed on both sides → arbitrate

On a typical update — a new flight covering one municipality merged into a national mosaic — the third category is a few per cent of chunks, and the first is well over ninety. Copying only what changed is what keeps a merge proportional to the edit rather than to the dataset.

2. Arbitration by recorded rule, not by branch order

When both branches changed a chunk, something has to choose. Taking “theirs” or “ours” resolves the merge and explains nothing, and it produces a mosaic whose composition depends on the order the branches happened to merge.

A rule ladder, evaluated per chunk and recorded per decision, produces the same mosaic regardless of merge order:

  1. prefer the capture with lower cloud cover over the chunk
  2. then prefer the finer ground sample distance
  3. then prefer the more recent acquisition date
  4. then, if still tied, escalate the chunk to review

Cloud cover before recency is deliberate: a newer capture obscured by cloud is worse than an older clear one, and defaulting to recency produces mosaics with white patches nobody chose.

3. Seams are part of the merge

Two chunks taken from different captures meet at a line where sun angle, atmosphere and calibration differ. Each chunk is correct; the boundary between them is a visible artefact. Handling it belongs inside the merge because the merge is the only step that knows which boundaries are new.

The cheap and usually sufficient treatment is a feathered blend across a narrow band on either side of the boundary, applied only where the two sides came from different sources. Where radiometric differences are large, a histogram match of the incoming chunk against its neighbours is the next step up — and where they are larger still, the honest answer is that the two captures should not be mosaicked together at all.

A three-way chunk diff over one mosaic A grid of mosaic chunk positions classified by a three-way comparison against the merge base: most converged, a band changed only on the incoming branch, a few changed only on the trunk, and a handful changed on both, which are the only ones needing a decision. r0 r1 r2 ? r3 ? r4 changed on the incoming branch only — taken changed on the trunk only — kept changed on both — arbitrated converged — copied, never re-encoded Two chunks out of forty-five need a rule. The other forty-three are decided by the merge base.

Production Workflow Implementation

Step 1 — Classify every chunk

import json
from collections import Counter


def classify_chunks(base_manifest, ours_manifest, theirs_manifest):
    """Three-way classification of chunk positions by hash.

    Manifests map a chunk key ("z/x/y" or "band/row/col") to a content hash.
    """
    keys = set(base_manifest) | set(ours_manifest) | set(theirs_manifest)
    plan = {}
    for key in sorted(keys):
        base = base_manifest.get(key)
        ours = ours_manifest.get(key)
        theirs = theirs_manifest.get(key)

        if ours == theirs:
            plan[key] = ("converged", ours)          # includes unchanged
        elif ours == base:
            plan[key] = ("take_theirs", theirs)
        elif theirs == base:
            plan[key] = ("take_ours", ours)
        else:
            plan[key] = ("arbitrate", (ours, theirs))
    return plan


plan = classify_chunks(
    json.load(open("manifests/base.json")),
    json.load(open("manifests/ours.json")),
    json.load(open("manifests/theirs.json")),
)
print(Counter(action for action, _ in plan.values()))
# Counter({'converged': 41_882, 'take_theirs': 1_204, 'take_ours': 96, 'arbitrate': 137})

The counter is the merge’s cost estimate. If arbitrate is large, the two branches were working the same ground and the branching model — not the merge tool — is what needs attention.

Step 2 — Apply the arbitration ladder

def arbitrate(chunk_key, ours_meta, theirs_meta, cloud_tolerance=0.02):
    """Choose between two candidate chunks by recorded rule.

    Returns (winner, rule) or (None, 'escalate') when no rule separates them.
    """
    if abs(ours_meta["cloud_frac"] - theirs_meta["cloud_frac"]) > cloud_tolerance:
        winner = min((ours_meta, theirs_meta), key=lambda m: m["cloud_frac"])
        return winner, "lower_cloud_cover"

    if ours_meta["gsd_m"] != theirs_meta["gsd_m"]:
        winner = min((ours_meta, theirs_meta), key=lambda m: m["gsd_m"])
        return winner, "finer_resolution"

    if ours_meta["captured_on"] != theirs_meta["captured_on"]:
        winner = max((ours_meta, theirs_meta), key=lambda m: m["captured_on"])
        return winner, "more_recent_capture"

    return None, "escalate"

Every return carries the rule that decided it. Those strings end up in the merge manifest, and they are what turn “the mosaic changed” into “this chunk came from the March flight because the June one was 40% cloud”.

Step 3 — Write only the chunks that changed

import rasterio
from rasterio.windows import Window


def apply_plan(base_path, out_path, plan, sources, chunk=512):
    """Copy the base mosaic and overwrite only the chunks the plan changes."""
    with rasterio.open(base_path) as src:
        profile = src.profile
        profile.update(tiled=True, blockxsize=chunk, blockysize=chunk,
                       compress="deflate", predictor=2)
        with rasterio.open(out_path, "w", **profile) as dst:
            for _, window in src.block_windows(1):
                key = f"{window.row_off}/{window.col_off}"
                action, payload = plan.get(key, ("converged", None))
                if action == "converged":
                    data = src.read(window=window)
                else:
                    with rasterio.open(sources[key]) as chosen:
                        data = chosen.read(window=window)
                dst.write(data, window=window)

Writing block-aligned windows matters: an unaligned write forces the driver to read, decompress, modify and re-encode a whole block, which turns a chunk-level merge back into a full rewrite.

Step 4 — Blend the new seams

Only boundaries between chunks that came from different sources need treatment:

import numpy as np


def feather(left, right, width=16):
    """Linear cross-fade across a shared vertical boundary.

    left and right are the pixel bands either side of the seam, each of shape
    (bands, rows, width). Returns the blended band pair.
    """
    ramp = np.linspace(0.0, 1.0, width, dtype="float32")
    blended_left = left * (1.0 - ramp) + right * ramp
    blended_right = left * (1.0 - ramp) + right * ramp
    return blended_left.astype(left.dtype), blended_right.astype(right.dtype)

Keep the blend band narrow — sixteen pixels at full resolution is usually invisible while remaining well under any feature of interest. A wide feather smears real edges, and on a cadastral overlay that is a correctness problem rather than an aesthetic one.

Step 5 — Rebuild overviews over the changed extent

# Bounding box of the changed chunks only, not the whole mosaic
gdaladdo -r average --config GDAL_NUM_THREADS ALL_CPUS \
         --config COMPRESS_OVERVIEW DEFLATE \
         merged_mosaic.tif 2 4 8 16 32

# Confirm the pyramid exists at every level the client requests
gdalinfo merged_mosaic.tif | grep -A2 "Overviews"

This step is skipped more often than any other, and its failure mode is specific: the mosaic is right when you zoom all the way in and wrong everywhere else. Reviewers looking at a map at regional scale see the old imagery and conclude the merge did nothing.

Step 6 — Commit the decision manifest with the mosaic

{
  "merge_base": "a41f…",
  "ours": "9c02…",
  "theirs": "e77b…",
  "decisions": [
    {"chunk": "2048/6144", "winner": "flight_2026_03", "rule": "lower_cloud_cover"},
    {"chunk": "2048/6656", "winner": "flight_2026_06", "rule": "more_recent_capture"},
    {"chunk": "3072/1024", "winner": null, "rule": "escalate"}
  ],
  "seams_blended": 41,
  "overviews_rebuilt": [2, 4, 8, 16, 32]
}

With this file, the merged mosaic is reproducible from its two parents. Without it, the mosaic is a binary somebody produced.

The arbitration ladder, in the order it is evaluated A decision ladder for a chunk both branches changed: materially lower cloud cover wins, then finer ground sample distance, then the more recent acquisition; anything still tied is escalated rather than resolved by branch order. Is one capture materially less cloudy over this chunk? Clearer capture wins recorded as lower_cloud_cover yes no Is one capture finer resolution? Finer GSD wins recorded as finer_resolution yes no Is one acquisition more recent? Newer capture wins recorded as more_recent_capture yes no Escalate the chunk a tie is a decision, not a coin toss Cloud cover outranks recency deliberately: a newer capture under cloud is worse than an older clear one.

Code Reliability Patterns

Refuse a merge across mismatched grids. Compare grid origin, chunk size and CRS on both manifests before anything else, and fail with the difference rather than proceeding. A merge across a half-chunk offset produces a plausible mosaic in which every chunk is subtly misplaced.

Distinguish nodata from not-updated. If a branch writes nodata over a region it did not survey, a naive merge treats that as an edit and erases the other branch’s imagery. Carry an explicit coverage mask per branch, and let arbitration consult it.

Verify block alignment after writing. A quick gdalinfo check that block size matches the intended chunk size catches the case where a driver quietly chose its own tiling — which turns every future merge into a full rewrite.

Keep the escalation list short and visible. Chunks that no rule separates should surface as a list a person can review with a map, not as a silent fallback to one side.

Performance & Scale Considerations

The merge cost is dominated by two things: reading the base mosaic and re-encoding blocks. Copying converged chunks byte-for-byte rather than decoding and re-encoding them is the single largest saving available, and it requires the output profile to match the input’s compression and predictor exactly — otherwise every block must be re-encoded regardless of whether it changed.

Parallelism follows the chunk grid naturally. Chunks are independent, so a merge over 40,000 chunks scales nearly linearly with worker count until object-storage throughput becomes the limit. Fetch chunks in batches sized to the storage backend’s optimal read rather than one at a time; on typical object stores that is the difference between a merge taking minutes and taking an hour.

Overview rebuilding is the part that does not parallelise cleanly, because each level depends on the one below. Restricting it to the changed extent keeps it proportional to the edit; rebuilding the full pyramid on a national mosaic after a municipal update costs more than the entire rest of the merge.

Where a raster merge spends its time Horizontal bars comparing the cost of the four phases of a chunk-level merge on a national mosaic after a municipal update: copying converged chunks, re-encoding arbitrated chunks, blending seams, and rebuilding overviews over the changed extent. MERGE COST BY PHASE Copy converged 74 s byte copy, only if the profile matches Re-encode arbitrated 41 s Blend seams 12 s Rebuild overviews 186 s bounded to the changed extent — unbounded it is 40× this The last bar is the one people skip, and the one that decides whether the merge is visible at map scale.

Troubleshooting & Failure Modes

Symptom Root Cause Fix
The merge rewrote every chunk Output profile differs from input, so all blocks were re-encoded Match compression, predictor and block size to the source profile; verify with gdalinfo before merging
Imagery is old at every zoom except the deepest Overviews not rebuilt after the merge Run gdaladdo over the changed extent as part of the merge step
Visible checkerboard across the merged region Chunks arbitrated to alternating sources with no seam handling Blend seams where sources differ; consider histogram matching if the radiometric gap is large
A surveyed region came back as nodata A branch’s nodata treated as an edit Carry an explicit coverage mask and consult it during arbitration
Merged mosaic differs depending on merge order Arbitration fell back to branch precedence Make the rule ladder total and deterministic; escalate ties rather than defaulting
Every chunk classified as arbitrate Grids differ between branches, so no chunk matches the base Normalise both branches to the recorded grid, then re-diff

FAQ

Why can't a raster merge just take the newer file?

Because a mosaic is not one file’s worth of decision. Two branches typically update different regions, so taking either wholesale discards the other’s work. Chunk-level merging keeps both and narrows the decision to the chunks both branches touched — usually a small percentage. That is the same argument that makes feature branching for GIS development teams workable for vector data.

What makes two adjacent chunks show a visible seam?

Different captures. Sun angle, atmospheric conditions and sensor calibration all differ between flights, so two chunks that are individually correct meet at a discontinuity. The merge is arithmetically right and visually wrong, which is exactly why seam handling belongs in the merge — it is the only step that knows which boundaries are new.

Do overviews really need rebuilding after a merge?

Yes, and skipping it is the most common raster merge defect. Overviews are derived data: after the merge the full-resolution pixels are correct while the pyramid still shows the pre-merge mosaic. The change is then invisible at every zoom level except the deepest, and a reviewer looking at a regional view will report that the merge did nothing.

How do I keep the merged mosaic reproducible?

Commit the decision manifest with the mosaic: which chunk came from which source, under which rule, plus the seams blended and the overview levels rebuilt. The merged mosaic can then be rebuilt from its two parents and the manifest, which is what makes it reviewable. The same reasoning underlies provenance and lineage tracking for spatial pipelines.

Does this work for Zarr as well as COG?

Yes, and Zarr is in some ways the easier case: chunks are already separate objects, so applying a merge plan means writing the chosen chunk objects and updating the manifest, with no block-alignment concerns at all. What Zarr does not give you is a built-in pyramid, so the overview step becomes an explicit multiscale group you maintain yourself. Chunking Zarr arrays for incremental raster commits covers the layout decisions that make it work.

Back to Branching & Merge Strategies for Spatial Datasets