Co-registering Bands with OpenCV Homographies
The obvious approach to aligning two bands — cross-correlate them and find the offset — works poorly on multispectral imagery, and the reason is instructive. Vegetation is dark in red and bright in near-infrared. Soil is the other way round. A correlation between the two bands over a field of crop and bare patches is negative, and a matcher looking for similarity finds the alignment that maximises anti-correlation, which is not the alignment that puts the same ground in the same place.
What survives a wavelength change is structure: the position of an edge is identical in every band even when the sign of the contrast is not. This page builds an alignment on that property using OpenCV, and — more importantly — measures whether the result is good enough to use. It is the implementation detail behind band alignment and stacking for multispectral sets.
Why gradient features work where intensity does not
A feature detector such as AKAZE or ORB responds to local structure — corners, blobs, edges — detected in a scale space built from gradients. The descriptor it attaches encodes the pattern of gradients around the keypoint, not their absolute values. When the contrast of a region reverses between bands, the gradient magnitudes are preserved and the signs flip, and a binary descriptor built from comparisons is affected far less than an intensity template would be.
That is why these detectors work across wavelengths at all, and it also explains their failure mode: they fail where there is no structure. A frame over uniform water, a bare tilled field at low sun, or a dense uniform canopy presents few reliable keypoints in any band, and the match count collapses.
The practical response is to normalise each band independently before detection — stretching each to the same nominal range so the detector’s internal thresholds behave the same way on both — and to treat a low match count as a signal to fall back rather than as something to force.
Figure 1 — The property that makes cross-band matching possible at all.
Minimal reproducible solution
import cv2
import numpy as np
def coregister(reference: np.ndarray, target: np.ndarray, *,
ransac_px: float = 2.0, ratio: float = 0.75) -> dict:
"""Estimate the homography mapping `target` onto `reference`.
Each band is stretched to the same nominal range before detection so the
detector's internal contrast thresholds behave identically on both — a
step that roughly doubles the usable match count on a typical frame pair.
"""
def prep(img: np.ndarray) -> np.ndarray:
a = img.astype(np.float32)
lo, hi = np.percentile(a[np.isfinite(a)], [2, 98])
return np.clip((a - lo) / max(hi - lo, 1e-6) * 255, 0, 255).astype(np.uint8)
detector = cv2.AKAZE_create(threshold=0.0008)
ka, da = detector.detectAndCompute(prep(reference), None)
kb, db = detector.detectAndCompute(prep(target), None)
if da is None or db is None:
raise ValueError("no descriptors in one of the bands")
matcher = cv2.BFMatcher(cv2.NORM_HAMMING)
pairs = matcher.knnMatch(db, da, k=2)
good = [m for m, n in pairs if m.distance < ratio * n.distance]
if len(good) < 25:
raise ValueError(f"only {len(good)} matches survive the ratio test")
src = np.float32([kb[m.queryIdx].pt for m in good]).reshape(-1, 1, 2)
dst = np.float32([ka[m.trainIdx].pt for m in good]).reshape(-1, 1, 2)
H, mask = cv2.findHomography(src, dst, cv2.RANSAC, ransac_px,
maxIters=5000, confidence=0.999)
if H is None:
raise ValueError("RANSAC found no consistent homography")
keep = mask.ravel().astype(bool)
resid = np.linalg.norm(
cv2.perspectiveTransform(src[keep], H) - dst[keep], axis=2).ravel()
return {"H": H, "matches": len(good), "inliers": int(keep.sum()),
"inlier_fraction": float(keep.mean()),
"median_residual_px": float(np.median(resid)),
"p95_residual_px": float(np.percentile(resid, 95))}
Choosing the RANSAC threshold
The threshold decides which correspondences count as agreeing with the model, and getting it wrong fails in both directions. Too tight and RANSAC discards good matches, leaving the fit under-constrained and unstable between frames. Too loose and it admits mismatches, which drag the homography and produce a transform that is smooth, plausible and wrong.
A useful discipline is to sweep it once on a representative frame pair and look for the plateau: over a range of thresholds the inlier count rises steadily and the residual stays flat, and the right value sits at the top of that plateau.
import numpy as np
def sweep_ransac_threshold(src: np.ndarray, dst: np.ndarray,
thresholds=(0.75, 1.0, 1.5, 2.0, 3.0, 4.0, 6.0)) -> list[dict]:
"""Inliers and residual against the RANSAC threshold, to find the plateau."""
import cv2
rows = []
for t in thresholds:
H, mask = cv2.findHomography(src, dst, cv2.RANSAC, t, maxIters=5000)
if H is None:
rows.append({"threshold": t, "inliers": 0, "median_residual_px": None})
continue
keep = mask.ravel().astype(bool)
resid = np.linalg.norm(
cv2.perspectiveTransform(src[keep], H) - dst[keep], axis=2).ravel()
rows.append({"threshold": t, "inliers": int(keep.sum()),
"inlier_fraction": float(keep.mean()),
"median_residual_px": float(np.median(resid))})
return rows
Figure 2 — Reading the plateau. A rising inlier count with a rising residual is not an improvement.
Figure 3 — Frame-to-frame consistency is the cheapest validity check available.
Edge-case matrix
| Situation | Symptom | Handling |
|---|---|---|
| Uniform water or canopy | Few keypoints, matching fails | Phase correlation fallback |
| Motion blur during a turn | Descriptors unstable | Reject the frame |
| Strong relief in frame | Good global fit, bad over structures | Homography cannot model parallax |
| Ratio test too strict | Under 25 matches | Relax to 0.8, or change detector |
| Ratio test too loose | Many matches, low inlier fraction | Tighten; mismatches drag the fit |
| Bands at very different resolution | Scale mismatch | Resample to a common grid first |
| Repeated structures (crop rows) | Confident wrong matches | Constrain with the expected offset range |
| Frame near the survey edge | Half the frame has no overlap | Expected; judge on inlier count, not fraction |
The repeated-structures row is worth attention on agricultural sites. Crop rows are a regular pattern, and a matcher can lock onto the wrong row with high confidence. Constraining the search to transforms near the expected sensor offset — which is known from the rig geometry to within a pixel or two — eliminates the failure entirely.
Verification snippet
import numpy as np
def sanity_check_homography(H: np.ndarray, shape: tuple[int, int],
*, max_shift_px: float = 60.0,
max_scale_deviation: float = 0.05) -> list[str]:
"""Is this transform physically plausible for two bands of one rig?
Bands on a fixed rig differ by a small translation and a tiny scale
change. A homography implying a large rotation or a ten percent scale is
a fitting artefact, however good its residual looks.
"""
h, w = shape
problems = []
corners = np.float32([[0, 0], [w, 0], [w, h], [0, h]]).reshape(-1, 1, 2)
import cv2
moved = cv2.perspectiveTransform(corners, H).reshape(-1, 2)
shifts = np.linalg.norm(moved - corners.reshape(-1, 2), axis=1)
if shifts.max() > max_shift_px:
problems.append(f"corner moves {shifts.max():.0f} px — implausible for a fixed rig")
scale = np.sqrt(abs(np.linalg.det(H[:2, :2])))
if abs(scale - 1.0) > max_scale_deviation:
problems.append(f"implied scale {scale:.3f} — bands should be near unity")
shear = abs(H[0, 1]) + abs(H[1, 0])
if shear > 0.05:
problems.append(f"implied rotation or shear of {shear:.3f}")
return problems
Checking physical plausibility alongside the residual catches the case where RANSAC has found an internally consistent set of mismatches. A transform that fits its inliers beautifully and moves the frame corner by two hundred pixels is wrong regardless of how good the residual is.
Running it over a whole flight efficiently
Estimating a homography per band pair per frame is four estimations for a five-band rig, times a few thousand frames. Two adjustments keep that to a few minutes rather than an hour.
Detect keypoints in the reference band once per frame and reuse them for all four pairs, rather than re-detecting for each. Detection dominates the runtime, and the reference band’s keypoints do not change between pairs.
Seed each frame’s estimate from the previous frame’s transform and reject solutions far from it. Consecutive frames on a line differ by a fraction of a pixel, so the seed is almost the answer, and the rejection doubles as the repeated-structure guard described above.
When to escalate
- Matching fails on a large share of frames. The imagery is the problem — motion blur from flying too fast for the exposure, or a site with genuinely no structure. Neither is fixed in processing.
- Residuals are acceptable and index fringes persist. Look at whether alignment was applied before or after orthorectification, and whether the bands were resampled a different number of times.
- The rig’s geometry appears to have changed. A dropped or serviced camera can shift a sensor. Compare the median transform across a flight with a previous survey’s; a consistent change is a hardware event worth recording.