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.

Band alignment as a per-frame estimate against a fixed calibration Two flight profiles over the same terrain. In the first, a fixed factory calibration is applied: it is exact at the calibration altitude of eighty metres and leaves a growing misalignment as the terrain rises toward the aircraft, reaching two point four pixels over a ridge. In the second, the homography is estimated per frame from the imagery, and the residual stays below half a pixel across the same terrain. A note states that the fixed-calibration error is constant within each frame and therefore invisible in any frame-to-frame comparison. fixed calibration per-frame estimate flight line terrain 2.4 px over the ridge misalignment under 0.5 px throughout The fixed-calibration error is constant within each frame. Which is exactly why no frame-to-frame comparison reveals it, and every index shows it.

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.

Sharpness cost of resampling once against twice Two edge profiles across the same boundary. The single-warp profile transitions from low to high over about one and a half pixels. The double-warp profile transitions over about two and a half pixels, a visibly softer edge, because each cubic resampling spreads the transition. Beneath, a note gives the effective resolution loss as roughly forty percent and states that composing the two transforms into one costs nothing and removes it entirely. warped once — 1.5 px transition warped twice — 2.5 px position across the edge roughly 40 % of the effective resolution, lost to a second resample composing the two transforms costs nothing and removes it entirely

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.

What band misregistration does downstream Three rows describing the consequence of even a one-pixel band offset. At a field boundary, the red and near-infrared values at a given output pixel come from different sides of the edge, producing a line of extreme index values that follows every boundary in the scene. Within uniform crop the effect is invisible, which is why misregistration routinely survives visual inspection. In zonal statistics the boundary artefact is included in whichever plot polygon contains it, biasing that plot's mean by an amount proportional to its perimeter-to-area ratio, so small plots are affected most. at every boundary red and NIR come from different sides of the edge — a line of extreme values within uniform crop invisible, which is why misregistration survives visual inspection in zonal statistics biases each plot by its perimeter-to-area ratio — small plots worst A one-pixel offset is undetectable by eye and clearly visible in the index statistics.

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.

Multispectral, Thermal & Index Mapping Pipelines