Band Alignment and Stacking for Multispectral Sets
A multispectral rig with five bands has five separate cameras, each with its own lens and its own optical axis, mounted a few centimetres apart. At survey altitude those offsets project to several pixels on the ground, and the size of the offset depends on how far away the ground is — so a single fixed correction is right at one altitude and wrong everywhere else.
Every index that combines bands inherits the error. A two-pixel misalignment between red and near-infrared writes a bright fringe along one side of every edge in the scene and a dark fringe along the other, and those fringes end up in plot statistics as though they were crop variability.
This page covers estimating the alignment per frame, judging whether it succeeded, applying it without resampling twice, and writing a stacked raster whose bands can still be identified a year later. It is the geometric half of multispectral, thermal and index mapping pipelines.
Audience and prerequisites. Python 3.10+, a set of per-band frames captured simultaneously, and OpenCV. Frames that have already been through a reconstruction are usually aligned as a side effect; this page is about the frames going in.
Prerequisites
| Library / tool | Minimum version | Install command | Role |
|---|---|---|---|
opencv-python |
≥ 4.8 | pip install opencv-python |
Feature detection, homography estimation, warping |
numpy |
≥ 1.24 | pip install numpy |
Array work and residual statistics |
rasterio |
≥ 1.3 | pip install "rasterio>=1.3" |
Writing stacked GeoTIFFs with band descriptions |
scikit-image |
≥ 0.22 | pip install scikit-image |
Phase correlation as a fallback |
Conceptual architecture
Alignment between two bands of the same scene is well described by a homography — an eight-parameter projective transform — provided the scene is roughly planar at the scale of the frame. For aerial imagery of terrain with modest relief that holds well; over a scene with tall buildings it does not, and the residual concentrates on the structures.
The estimation runs per frame rather than per flight because the transform depends on the distance to the subject. A rig calibrated at 80 m and flown at 50 m has a systematically different parallax, and the resulting misalignment is a constant offset across the whole survey — which, being constant, is invisible in any comparison between frames and perfectly visible in every index.
One band is chosen as the reference and the others are warped onto it. The reference should be the band with the most texture, usually green or red, because that is where feature matching works best.
Figure 1 — Why alignment is estimated per frame rather than read from a calibration file.
Step 1: Match features between bands
Bands of different wavelengths do not look the same — vegetation is dark in red and bright in near-infrared — so an intensity-based matcher struggles. Gradient-based features work far better, because edges appear in the same place in every band even when the contrast reverses.
import cv2
import numpy as np
def match_bands(reference: np.ndarray, target: np.ndarray,
*, max_features: int = 4000) -> tuple:
"""Feature correspondences between two bands of the same frame.
Normalising each band independently before detection is what makes this
work across wavelengths: the absolute levels differ by a factor of
several between red and near-infrared, and the gradients do not.
"""
def prep(img):
f = img.astype(np.float32)
f = (f - np.percentile(f, 2)) / max(np.percentile(f, 98) - np.percentile(f, 2), 1e-6)
return np.clip(f * 255, 0, 255).astype(np.uint8)
a, b = prep(reference), prep(target)
detector = cv2.AKAZE_create()
ka, da = detector.detectAndCompute(a, None)
kb, db = detector.detectAndCompute(b, None)
if da is None or db is None or len(ka) < 20 or len(kb) < 20:
raise ValueError("too few features to align these bands")
matcher = cv2.BFMatcher(cv2.NORM_HAMMING)
raw = matcher.knnMatch(da, db, k=2)
good = [m for m, n in raw if m.distance < 0.75 * n.distance]
if len(good) < 20:
raise ValueError(f"only {len(good)} good matches — bands too dissimilar")
src = np.float32([ka[m.queryIdx].pt for m in good]).reshape(-1, 1, 2)
dst = np.float32([kb[m.trainIdx].pt for m in good]).reshape(-1, 1, 2)
return src, dst, good
AKAZE rather than SIFT is a deliberate choice here: it is free of patent concerns, fast enough to run on every frame of a survey, and its response is dominated by structure rather than texture, which is what survives a wavelength change.
Step 2: Estimate the homography and judge it
import cv2
import numpy as np
def estimate_alignment(src: np.ndarray, dst: np.ndarray,
*, ransac_px: float = 2.0) -> dict:
"""Projective transform from target band to reference, with a residual.
The residual is the number that matters. A homography can always be
fitted; whether it describes the data is a separate question, and the
median reprojection error of the inliers answers it.
"""
H, inliers = cv2.findHomography(dst, src, cv2.RANSAC, ransac_px,
maxIters=5000, confidence=0.999)
if H is None:
raise ValueError("homography estimation failed")
mask = inliers.ravel().astype(bool)
projected = cv2.perspectiveTransform(dst[mask], H)
residuals = np.linalg.norm(projected - src[mask], axis=2).ravel()
return {"H": H,
"inliers": int(mask.sum()),
"inlier_fraction": float(mask.mean()),
"median_residual_px": float(np.median(residuals)),
"p95_residual_px": float(np.percentile(residuals, 95))}
def alignment_acceptable(result: dict, *, max_median_px: float = 0.5,
min_inliers: int = 40,
min_inlier_fraction: float = 0.4) -> list[str]:
"""Reasons this alignment should not be trusted, if any."""
problems = []
if result["median_residual_px"] > max_median_px:
problems.append(f"median residual {result['median_residual_px']:.2f} px")
if result["inliers"] < min_inliers:
problems.append(f"only {result['inliers']} inliers")
if result["inlier_fraction"] < min_inlier_fraction:
problems.append(f"inlier fraction {result['inlier_fraction']:.2f}")
return problems
Half a pixel is the threshold worth holding to. Below it, index fringes are not visible at any realistic zoom; above one pixel they are obvious at plot boundaries. The consequences of exceeding it are worked through in fixing band misregistration artifacts in index rasters.
Step 3: Warp once, not twice
Each resampling step blurs. A pipeline that warps a band for alignment and then warps it again during orthorectification has resampled twice, and the second pass compounds the softness of the first. Composing the transforms and applying one warp avoids it.
import cv2
import numpy as np
def warp_band(band: np.ndarray, H: np.ndarray, shape: tuple[int, int],
*, interpolation: int = cv2.INTER_CUBIC) -> np.ndarray:
"""Apply an alignment homography to one band.
Cubic rather than nearest: the values are continuous reflectance, and
nearest-neighbour would preserve the quantisation of the sensor at the
cost of a half-pixel positional error that is precisely what this step
exists to remove.
"""
h, w = shape
return cv2.warpPerspective(band.astype(np.float32), H, (w, h),
flags=interpolation,
borderMode=cv2.BORDER_CONSTANT,
borderValue=float("nan"))
def compose(H_align: np.ndarray, H_ortho: np.ndarray) -> np.ndarray:
"""One transform instead of two, so the band is resampled once."""
return H_ortho @ H_align
A NaN border value rather than zero is worth the small awkwardness it introduces downstream. The edges of a warped band have no data, and zero is a legitimate reflectance value; conflating the two puts a black frame into every index.
Figure 2 — Two warps, and the sharpness they cost. Composition is free.
Step 4: Write a stack that stays identifiable
A five-band GeoTIFF with no band descriptions is a file whose third band might be red edge or might be near-infrared, and there is no way to tell from the data. Every index computed from it afterwards is a guess.
import numpy as np
import rasterio
def write_band_stack(bands: dict[str, np.ndarray], out_path: str, *,
profile: dict, wavelengths: dict[str, float],
order: list[str] | None = None) -> dict:
"""Write a band stack with descriptions, wavelengths and NaN NoData.
The band order is explicit rather than dictionary order, because a
downstream consumer indexing by position must be able to rely on it.
"""
order = order or sorted(bands, key=lambda b: wavelengths[b])
stack = np.stack([bands[b] for b in order]).astype("float32")
profile = dict(profile)
profile.update(count=len(order), dtype="float32", nodata=np.nan,
compress="deflate", predictor=3, tiled=True)
with rasterio.open(out_path, "w", **profile) as dst:
dst.write(stack)
for i, band in enumerate(order, start=1):
dst.set_band_description(i, band)
dst.update_tags(i, WAVELENGTH_NM=f"{wavelengths[band]:.1f}",
UNITS="reflectance")
dst.update_tags(BAND_ORDER=",".join(order))
return {"bands": order, "shape": stack.shape}
def read_band(path: str, name: str) -> np.ndarray:
"""Read a band by name rather than by index, and fail if it is absent."""
with rasterio.open(path) as src:
descriptions = [d or "" for d in src.descriptions]
if name not in descriptions:
raise KeyError(f"band {name!r} not in {descriptions}")
return src.read(descriptions.index(name) + 1, masked=True).filled(np.nan)
Reading by name rather than index is the habit that pays. An index computed as (band4 - band3) / (band4 + band3) breaks silently the day a sensor with a different band order is used; the same computation written against names does not.
Step 5: Handle the frames where matching fails
On any real survey a few percent of frames will not align by feature matching: a frame entirely over water, a bare field with no structure, a frame taken during a turn with motion blur. Failing the whole flight on those is wrong, and silently accepting a bad transform is worse. Three fallbacks, in order, cover almost all of them.
Phase correlation. Where features fail, whole-frame correlation in the Fourier domain often succeeds, because it uses every pixel rather than a few hundred keypoints. It recovers translation only — not rotation or scale — which is usually sufficient between bands of one rig.
import numpy as np
from skimage.registration import phase_cross_correlation
def translation_fallback(reference: np.ndarray, target: np.ndarray,
*, upsample: int = 10) -> dict:
"""Sub-pixel translation between two bands, using the whole frame.
Normalising both to zero mean and unit variance first is what makes this
work across wavelengths: phase correlation is sensitive to structure and
indifferent to absolute level, but only once the level is removed.
"""
def norm(img):
a = np.nan_to_num(img.astype(np.float32))
return (a - a.mean()) / max(a.std(), 1e-6)
shift, error, _ = phase_cross_correlation(norm(reference), norm(target),
upsample_factor=upsample)
H = np.array([[1.0, 0.0, -shift[1]],
[0.0, 1.0, -shift[0]],
[0.0, 0.0, 1.0]])
return {"H": H, "shift_px": shift.tolist(), "error": float(error),
"method": "phase_correlation"}
Carry the neighbour’s transform. Consecutive frames of a flight line see almost the same geometry, so the previous successful transform is a good estimate for a failed frame — provided the altitude did not change materially between them. Flag the frame so the substitution is visible in the record rather than assumed later.
Reject the frame. Where neither works, exclude the frame from the mosaic. A handful of missing frames on a survey flown with adequate overlap costs nothing; a handful of misaligned frames contaminates every index value in their footprint.
def align_with_fallbacks(reference, target, previous_H=None) -> dict:
"""Try features, then phase correlation, then the neighbour, then reject."""
try:
src, dst, _ = match_bands(reference, target)
result = estimate_alignment(src, dst)
problems = alignment_acceptable(result)
if not problems:
return {**result, "method": "features"}
except ValueError:
problems = ["feature matching failed"]
fallback = translation_fallback(reference, target)
if fallback["error"] < 0.4:
return {**fallback, "note": f"features rejected: {problems}"}
if previous_H is not None:
return {"H": previous_H, "method": "carried_forward",
"note": "no alignment found; using the previous frame's transform"}
return {"H": None, "method": "rejected",
"note": "no usable alignment; exclude this frame"}
The ordering encodes a preference for accuracy over coverage, which is the right way round for a measurement product. A visual mosaic would order it the other way.
Record which method produced each frame’s transform. On a healthy survey, ninety-five percent or more will be feature-based; a flight where a third came from carried-forward transforms is telling you something about the imagery that is worth knowing before the index is delivered.
Parameter deep-dive
| Parameter | Type | Default | Valid range | Effect |
|---|---|---|---|---|
| Reference band | str | green or red | any | Most textured band matches best |
max_features |
int | 4000 | 1000–10000 | More is slower, rarely better |
| Lowe ratio | float | 0.75 | 0.6–0.85 | Lower is stricter, fewer matches |
ransac_px |
float | 2.0 | 1.0–4.0 | Inlier threshold during fitting |
max_median_px |
float | 0.5 | 0.3–1.0 | Acceptance gate for the alignment |
min_inliers |
int | 40 | 20–200 | Below this the fit is not constrained |
| Interpolation | enum | cubic | cubic / linear | Cubic for continuous reflectance |
| Border value | float | NaN | NaN / 0 | Zero is a valid reflectance; NaN is not |
| Band order | list | by wavelength | explicit | Must be recorded, never inferred |
Verification and output inspection
The alignment can be verified directly by measuring how well the bands agree at edges, which is where misalignment shows.
import numpy as np
from scipy import ndimage
def edge_agreement(a: np.ndarray, b: np.ndarray, *, percentile: float = 95.0) -> dict:
"""Do the two bands' strong edges fall in the same places?
Gradient magnitude is comparable between bands even when the contrast
reverses, so the overlap of their strongest edges is a direct measure of
alignment that needs no features or transforms.
"""
def edges(img):
gy, gx = np.gradient(np.nan_to_num(img.astype(np.float32)))
g = np.hypot(gx, gy)
return g > np.nanpercentile(g, percentile)
ea, eb = edges(a), edges(b)
intersection = np.count_nonzero(ea & eb)
union = np.count_nonzero(ea | eb)
dilated = ndimage.binary_dilation(ea, iterations=1)
within_one_px = np.count_nonzero(dilated & eb) / max(np.count_nonzero(eb), 1)
return {"jaccard": intersection / max(union, 1),
"within_one_px": float(within_one_px),
"ok": within_one_px > 0.8}
A figure above 0.8 for within_one_px corresponds to an alignment good enough that index fringes are not visible. It is a useful gate precisely because it measures the property that matters — edge coincidence — rather than a proxy such as the number of matched features.
Figure 3 — The artefact appears exactly where the deliverable is read most closely.
Troubleshooting
Alignment fails on frames over water or bare soil. Too little texture for feature matching. Fall back to phase correlation, which uses the whole frame rather than features, or carry the previous frame’s transform forward with a flag.
Residuals are small overall and large over buildings. The planar assumption has broken. A homography cannot describe parallax on tall structures; either accept the local error or reconstruct properly and orthorectify each band.
The alignment is good but the index still has fringes. Check that both bands were resampled the same number of times, and that the NoData borders are not being included in the index arithmetic.
Band descriptions are missing after a processing step. Some writers drop them. Assert descriptions on read, and re-set them whenever a stack is rewritten.
Every frame aligns but the mosaic still shows band offsets. The alignment was estimated on the raw frames and applied after orthorectification, or the reference band changed between frames. Fix the reference band for the whole flight and compose the transforms rather than applying them in sequence.
The transform varies wildly between consecutive frames. Feature matching is latching onto different structures. Constrain the search by seeding from the previous frame’s transform and rejecting solutions far from it.
Warping produces a black border in the index. The border value was zero, which is a valid reflectance. Use NaN and mask.