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:
- prefer the capture with lower cloud cover over the chunk
- then prefer the finer ground sample distance
- then prefer the more recent acquisition date
- 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.
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.
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.
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.
Related
- Merging Overlapping Orthophoto Tiles Without Seams — the seam-handling step in detail, with the blend and histogram-match code
- Versioning COG Overviews After a Partial Update — keeping the pyramid consistent without rewriting the mosaic
- Pointer Synchronization for Raster Datasets — how the chunk blobs this merge writes are stored and fetched
- Automated Conflict Detection in Merge Requests — the vector-side equivalent of the classification step
- Cloud-Native Spatial Formats for Versioned Pipelines — the formats that make chunk-level merging possible at all