Matching Thermal and RGB Orthomosaics
The thermal orthomosaic and the RGB orthomosaic of the same site do not line up. A roof that is crisp in the RGB is offset by two metres in the thermal, and the offset varies across the survey — a metre here, three metres there, in different directions.
The instinct is to warp one onto the other. That fixes the appearance and not the product: a thermal mosaic built from a failed reconstruction has internal geometric errors that no global warp removes, so aligning it at one point misaligns it at another.
The robust approach is to stop reconstructing from thermal imagery at all. This page covers solving the geometry from the RGB camera, measuring the fixed transform between the two sensors, and projecting thermal frames through the RGB camera model. It is the geometric strategy referenced throughout thermal orthomosaic processing in Python.
Why thermal reconstruction fails
Structure-from-motion needs distinctive, repeatable features across many frames. Thermal imagery of a landscape offers few of them.
The resolution is low — 640 × 512 is common, against 20 megapixels for an RGB camera on the same airframe — so there are two orders of magnitude fewer pixels to find features in. The contrast is driven by temperature differences, which are smooth over most natural surfaces; a field that is rich in visual texture is nearly featureless thermally. And what contrast there is moves: shadows shift, surfaces warm unevenly, and a feature matched between two frames taken eight minutes apart may not be in the same place because the thermal scene itself changed.
The result is reconstructions that converge on a subset of frames, break into submodels, or produce a camera solution with large residuals — exactly the failure modes described in troubleshooting alignment and matching failures, but reached far more easily.
Figure 1 — Two pipelines. Only one of them has a geometry worth the radiometry it carries.
Measuring the rigid transform once
The two cameras are bolted to the same frame, so the transform between them is fixed. It is measured from a calibration flight over a target visible in both — which in practice means a target with both visual and thermal contrast, such as a metal plate on grass, or a tray of water.
import cv2
import numpy as np
def solve_extrinsic(rgb_points: np.ndarray, thermal_points: np.ndarray,
rgb_K: np.ndarray, thermal_K: np.ndarray,
dist_rgb=None, dist_thermal=None) -> dict:
"""Rigid transform from the RGB camera frame to the thermal camera frame.
Uses correspondences on a target seen by both. Because the cameras are
rigidly mounted, one good calibration flight serves indefinitely — but it
must be re-measured whenever either camera is removed or serviced.
"""
ok, rvec, tvec, inliers = cv2.solvePnPRansac(
rgb_points.astype(np.float32).reshape(-1, 1, 3),
thermal_points.astype(np.float32).reshape(-1, 1, 2),
thermal_K, dist_thermal, flags=cv2.SOLVEPNP_ITERATIVE,
reprojectionError=2.0, confidence=0.999)
if not ok:
raise ValueError("could not solve the camera-to-camera transform")
R, _ = cv2.Rodrigues(rvec)
T = np.eye(4)
T[:3, :3] = R
T[:3, 3] = tvec.ravel()
projected, _ = cv2.projectPoints(rgb_points.astype(np.float32), rvec, tvec,
thermal_K, dist_thermal)
resid = np.linalg.norm(projected.reshape(-1, 2) - thermal_points, axis=1)
return {"transform": T,
"baseline_m": float(np.linalg.norm(tvec)),
"inliers": int(len(inliers) if inliers is not None else 0),
"median_residual_px": float(np.median(resid))}
A residual above about two thermal pixels means the correspondences or the thermal intrinsics are poor, and the transform should not be trusted. A baseline far from the physical separation of the two cameras on the rig — which is measurable with a ruler — is a second and very effective sanity check.
Projecting the thermal frames
import numpy as np
def thermal_pose_from_rgb(rgb_pose: np.ndarray, extrinsic: np.ndarray) -> np.ndarray:
"""Thermal camera pose, given the RGB pose the reconstruction solved.
The reconstruction solved where the RGB camera was for every frame. The
thermal camera was, by construction, at a fixed offset from it, so its
pose is the composition — no thermal feature matching required.
"""
return rgb_pose @ np.linalg.inv(extrinsic)
def time_align(rgb_times: np.ndarray, thermal_times: np.ndarray,
*, max_gap_s: float = 0.5) -> list[tuple[int, int]]:
"""Pair each thermal frame with the RGB frame taken closest in time.
The two cameras rarely trigger simultaneously, and at 8 m/s a half-second
offset is four metres of ground. Frames that cannot be paired within the
tolerance are excluded rather than paired approximately.
"""
pairs = []
for j, t in enumerate(thermal_times):
i = int(np.argmin(np.abs(rgb_times - t)))
if abs(rgb_times[i] - t) <= max_gap_s:
pairs.append((i, j))
return pairs
Time alignment is where this approach most often goes wrong in practice. The two cameras have independent clocks, and a drift of even a second between them puts every thermal frame several metres from where the pipeline thinks it is. Synchronising the clocks before the flight, or recording a shared event both cameras see, is worth the small effort.
Figure 3 — Three reasons image matching is the wrong tool here.
Edge-case matrix
| Situation | Handling |
|---|---|
| No RGB camera flown | Reconstruct from thermal and accept the geometry, or re-fly |
| Cameras triggered independently | Pair on timestamps; exclude unpaired frames |
| Clock drift between cameras | Fit an offset from a shared event; re-check each flight |
| Camera removed and remounted | Re-measure the extrinsic; it has changed |
| Thermal intrinsics unknown | Calibrate from a thermal checkerboard before anything else |
| Target with no thermal contrast | Use a metal plate or a water tray, not a printed board |
| Thermal frames at a different rate | Expected; pair by time and use every thermal frame once |
| Large terrain relief | Project through the reconstructed surface, not a plane |
The last row matters on any site with structures. Projecting a thermal frame onto a flat plane places roofs at ground elevation, displacing them by the building height times the tangent of the view angle — several metres at the edge of a frame. Projecting through the reconstructed surface removes it.
Verification snippet
import cv2
import numpy as np
def check_alignment(thermal_mosaic: np.ndarray, rgb_mosaic: np.ndarray,
*, sample_windows: int = 40, window_px: int = 128) -> dict:
"""Measure residual offsets between the two mosaics at many locations.
A single global offset means the extrinsic is wrong; offsets that vary
across the site mean the geometry is wrong, which a warp cannot fix.
"""
h, w = thermal_mosaic.shape[:2]
rng = np.random.default_rng(0)
offsets = []
for _ in range(sample_windows):
y = int(rng.integers(0, max(h - window_px, 1)))
x = int(rng.integers(0, max(w - window_px, 1)))
a = np.nan_to_num(thermal_mosaic[y:y + window_px, x:x + window_px]).astype(np.float32)
b = np.nan_to_num(rgb_mosaic[y:y + window_px, x:x + window_px]).astype(np.float32)
if a.std() < 1e-3 or b.std() < 1e-3:
continue
shift = cv2.phaseCorrelate((a - a.mean()) / a.std(), (b - b.mean()) / b.std())[0]
offsets.append(shift)
arr = np.array(offsets)
if arr.size < 8:
return {"note": "too few textured windows to measure"}
return {"median_offset_px": arr.mean(axis=0).tolist(),
"offset_spread_px": float(np.std(np.linalg.norm(arr, axis=1))),
"systematic": bool(np.linalg.norm(arr.mean(axis=0)) > 2 * np.std(arr)),
"note": ("a consistent offset — the extrinsic is wrong"
if np.linalg.norm(arr.mean(axis=0)) > 2 * np.std(arr)
else "offsets vary across the site — the geometry is wrong")}
Distinguishing a consistent offset from a varying one is the whole diagnostic. A consistent offset is a transform error and is fixable in seconds; a varying one means the thermal geometry is internally wrong and the projection approach is needed.
Figure 2 — The diagnostic. Consistency of the offsets is the whole question.
When to escalate
- No RGB camera was flown. Either accept the thermal geometry with its errors stated, or re-fly. There is no processing fix for a reconstruction that had insufficient features.
- The extrinsic changes between flights. A camera is not rigidly mounted, or is being removed between jobs. That is a hardware issue and every flight until it is resolved needs its own calibration.
- Alignment is good and features still look displaced on tall structures. The projection used a plane rather than the reconstructed surface. Re-project through the surface model.