Choosing Blending Modes for Orthomosaic Quality
Every mosaicking tool offers a blending choice, and the default is usually feathering because it makes the fewest people complain. For a survey deliverable that is not the same thing as being correct: blending trades measurable sharpness for a less visible seam, and on a mosaic that will be digitised against, that trade can be the wrong way round.
This page sets out what each mode does to the pixels, what it costs, and which deliverable each suits. It belongs to orthomosaic radiometry and seamline control.
The three modes
Hard cut. Every output pixel comes from exactly one source image. Nothing is averaged, so nothing is blurred. Every residual difference appears as a sharp step along the seamline.
Feathering. Within a transition zone either side of the seamline, output pixels are a weighted average of the two sources, with the weight running smoothly from one to the other. The step becomes a gradient. Any misregistration between the two images becomes a double image across that zone.
Multi-band blending. The images are decomposed into frequency bands. Low frequencies — the broad brightness differences — are blended over a wide zone; high frequencies — the detail — are blended over a very narrow one, or taken from a single source. The brightness step disappears without the detail smearing.
Figure 1 — The same seam, three treatments.
Minimal reproducible solution
Feathering is a weight computed from distance to the seam, and the distance transform does all the work.
import numpy as np
from scipy import ndimage
def feather_weights(mask_a: np.ndarray, mask_b: np.ndarray,
*, width: int = 48) -> np.ndarray:
"""Blend weight for image A, running 1 → 0 across the transition zone.
Distance to the other image's exclusive area is a better driver than
distance to the seamline itself: it degrades gracefully where the overlap
is narrower than the requested width, rather than producing a truncated
ramp with a residual step at its end.
"""
only_b = mask_b & ~mask_a
distance = ndimage.distance_transform_edt(~only_b).astype("float32")
weight = np.clip(distance / float(width), 0.0, 1.0)
weight[~mask_a] = 0.0
return weight
def feather_blend(img_a: np.ndarray, img_b: np.ndarray,
mask_a: np.ndarray, mask_b: np.ndarray,
*, width: int = 48) -> np.ndarray:
"""Weighted average of two co-registered images across their overlap."""
wa = feather_weights(mask_a, mask_b, width=width)
wb = feather_weights(mask_b, mask_a, width=width)
total = wa + wb
total[total == 0] = 1.0
return (img_a.astype("float32") * wa + img_b.astype("float32") * wb) / total
The width parameter is the whole decision. It should be wide enough that the brightness step is spread below visibility and narrow enough that misregistration does not become a visible double image — which is a real tension, and multi-band blending exists precisely because the two requirements pull in opposite directions.
Choosing by deliverable
The right mode follows from what the mosaic is for.
Digitising and measurement. Hard cut, with well-routed seamlines. Any averaging displaces edges slightly, and an operator digitising a kerb line against a feathered mosaic is tracing a compromise between two views of it. Sharpness is the product; a visible seam along a hedge is an acceptable price.
Visual presentation and web maps. Multi-band, or feathering if multi-band is unavailable. The mosaic will be viewed at reduced scale by people who will judge it on appearance, and the sub-pixel displacement is irrelevant to that use.
Change detection between epochs. Hard cut, and the same seamlines each time if at all possible. Blending introduces an epoch-specific smoothing that a difference operation will read as change, which is the most insidious error on this page because it looks like a result.
Input to automated analysis. Depends on the analysis. Anything that thresholds or classifies on texture is disturbed by feathering; anything that works on broad radiometry is disturbed by hard cuts.
Figure 2 — The deliverable decides, not the appearance at full extent.
Implementing multi-band blending
The idea behind multi-band blending is that the two requirements pulling against each other — a wide zone for brightness, a narrow one for detail — apply to different parts of the signal, so they can be satisfied separately and recombined.
Decompose each image into a small pyramid of frequency bands. The coarsest band holds the broad brightness differences; the finest holds edges and texture. Blend each band with a zone width proportional to that band’s scale, then sum the results. The brightness difference is spread over hundreds of pixels while edges are handed over across two or three.
def gaussian_pyramid(img: np.ndarray, levels: int = 5) -> list[np.ndarray]:
"""Successively smoothed copies, each at half the previous detail scale."""
out = [img.astype("float32")]
for _ in range(levels - 1):
out.append(ndimage.gaussian_filter(out[-1], sigma=2.0))
return out
def laplacian_pyramid(img: np.ndarray, levels: int = 5) -> list[np.ndarray]:
"""Difference of successive smoothings, plus the smoothest residual.
Each level holds the detail present at one scale and absent at the next,
which is exactly the decomposition the blend needs: the finest level is
edges, the coarsest residual is broad brightness.
"""
g = gaussian_pyramid(img, levels)
return [g[i] - g[i + 1] for i in range(levels - 1)] + [g[-1]]
def multiband_blend(img_a: np.ndarray, img_b: np.ndarray,
mask_a: np.ndarray, mask_b: np.ndarray,
*, levels: int = 5, base_width: int = 6) -> np.ndarray:
"""Blend each frequency band with a zone sized to that band's scale.
Width doubles per level, so the finest band hands over across a handful of
pixels and the coarsest across a few hundred — which is the entire trick.
"""
la = laplacian_pyramid(img_a, levels)
lb = laplacian_pyramid(img_b, levels)
out = np.zeros_like(la[0])
for level in range(levels):
width = base_width * (2 ** level)
wa = feather_weights(mask_a, mask_b, width=width)
wb = feather_weights(mask_b, mask_a, width=width)
total = wa + wb
total[total == 0] = 1.0
out += (la[level] * wa + lb[level] * wb) / total
return out
Five levels with a base width of six pixels gives a finest-band handover of six pixels and a coarsest of ninety-six, which suits typical survey ground sample distances of two to five centimetres. On a coarser mosaic — say, a 15 cm product — halve the level count, because the fine bands hold little beyond noise at that resolution and blending them wastes memory for no visible gain.
The memory cost is real: five levels of float32 for two images is ten times the working set of a hard cut. On large mosaics this is the reason to process by tile, with a halo at least as wide as the coarsest band’s blend zone so that no tile boundary falls inside an active transition.
Figure 3 — Three modes, three measurable costs.
Edge-case matrix
| Situation | Effect | Handling |
|---|---|---|
| Feathering a measurement mosaic | Edges displaced | Hard cut instead |
| Wide feather, poor registration | Visible doubling | Narrow the zone or fix alignment |
| Narrow feather, big step | Step merely blurred | Fix the balance, not the blend |
| Feather wider than the overlap | Truncated ramp, residual step | Drive weights from distance, not seam |
| Blending across epochs | Smoothing read as change | Hard cut, consistent seamlines |
| Multi-band on a huge mosaic | Memory pressure | Process by tile with a halo |
| Blending over a building | Double roof | Exclude tall areas from the zone |
| Nodata inside the zone | Weights collapse | Guard the zero-weight case |
Verification snippet
def sharpness_cost(hard_cut: np.ndarray, blended: np.ndarray,
seam_zone: np.ndarray) -> dict:
"""How much detail did blending cost, measured inside the zone only?
Comparing over the whole mosaic dilutes the effect into invisibility: the
zone is a small fraction of the area, so a severe local smear barely moves
a global statistic.
"""
def energy(a):
gy, gx = np.gradient(a.astype("float32"))
return float(np.hypot(gx, gy)[seam_zone].mean())
before, after = energy(hard_cut), energy(blended)
return {
"gradient_before": before,
"gradient_after": after,
"detail_retained": after / before if before else 1.0,
# Below about 0.85, the blend is costing real sharpness where an
# operator will be digitising.
"acceptable_for_measurement": (after / before if before else 1.0) > 0.85,
}
When to escalate
- No blend width works. The images are misaligned. Fix the reconstruction; blending cannot hide a registration error, only spread it.
- Multi-band exhausts memory. Tile the mosaic with a halo at least as wide as the widest blend zone, and blend each tile independently.
- Two deliverables want different modes. Produce both. Mosaicking is cheap next to reconstruction, and a measurement mosaic and a presentation mosaic from the same project is a routine deliverable pair.