Fixing Thermal Mosaic Seam Lines
The thermal mosaic has a grid of faint rectangles across it, one per frame, each about a degree different from its neighbours. Every instinct says to blend them away, and every mosaicking tool offers a setting that will.
Do not. In a visual orthomosaic a seam is a defect, because the product is a picture and the picture should not show its construction. In a thermal orthomosaic a seam is a measurement of disagreement between two independent observations of the same ground, and blending it away does not resolve the disagreement — it distributes it across the overlap and produces values that neither frame measured.
This page covers the three causes of thermal seams, which of them can be fixed, and how to use the seams that remain as a quality statement. It extends the mosaicking discussion in thermal orthomosaic processing in Python.
Three causes, two of them fixable
Sensor drift. Two frames taken twelve minutes apart differ by whatever the sensor drifted in twelve minutes. This is the dominant cause on a long flight and it is fully correctable, as described in correcting thermal drift across a flight.
Thermal falloff within a frame. Microbolometers have a spatial non-uniformity that the factory correction does not fully remove, and it changes with sensor temperature. The frame centre reads differently from the frame edge — typically by a few tenths of a degree, occasionally by more than one — so where one frame’s edge meets another’s centre, a seam appears. Correctable, with a flat-field-style correction.
Genuine view-angle dependence. A surface does not emit equally in all directions. At the edge of a wide-angle thermal frame the view angle from vertical can reach 30°, and for many surfaces the apparent temperature falls measurably at that angle. This is real physics, not an instrument fault, and it is not correctable — only reducible, by narrowing the useful part of each frame.
Figure 1 — What a seam is made of. Two thirds of it is an instrument fault worth fixing.
Minimal reproducible solution
The non-uniformity correction is a thermal flat field: an image of a uniform-temperature scene, from which the frame’s spatial response is fitted.
import numpy as np
def fit_thermal_flat_field(frames: list[np.ndarray], *, order: int = 3) -> np.ndarray:
"""Estimate the frame's spatial non-uniformity from many ordinary frames.
Averaging hundreds of frames over varied ground leaves only what is
common to all of them, which is the sensor's own pattern. This is more
practical than a true flat field, because a uniform-temperature scene
large enough to fill the frame is hard to arrange in the field.
"""
stack = np.stack([f - np.nanmedian(f) for f in frames])
pattern = np.nanmedian(stack, axis=0)
h, w = pattern.shape
yy, xx = np.mgrid[0:h, 0:w]
r = np.hypot(xx - w / 2, yy - h / 2).ravel()
good = np.isfinite(pattern.ravel())
design = np.vander(r[good], order + 1, increasing=True)
coeffs, *_ = np.linalg.lstsq(design, pattern.ravel()[good], rcond=None)
model = (np.vander(r, order + 1, increasing=True) @ coeffs).reshape(h, w)
return (model - np.median(model)).astype("float32")
def apply_flat_field(frame: np.ndarray, pattern: np.ndarray) -> np.ndarray:
"""Subtract the spatial pattern; thermal non-uniformity is additive."""
return (frame - pattern).astype("float32")
Subtracting rather than dividing is correct here and differs from the multiplicative vignette correction used for reflectance. Thermal non-uniformity is an additive offset in temperature, not a multiplicative gain, because the quantity being corrected is a temperature rather than a radiance ratio.
Building the pattern from ordinary survey frames rather than a dedicated flat field is the practical part. Over a few hundred frames of varied ground, everything that is scene-dependent averages out and what remains is the sensor.
Seamline placement instead of blending
Where a seam must be reduced without blending, the lever is where the seam runs. A seamline routed through a thermally uniform area — a large field, a car park — is far less visible than one crossing a boundary between surfaces at different temperatures.
import numpy as np
from scipy import ndimage
def seam_cost_surface(mosaic_estimate: np.ndarray) -> np.ndarray:
"""Cost for routing a seamline: high where the scene has thermal structure.
A seam is invisible where the two frames agree and the scene is smooth,
so the cost is the local gradient. Routing a minimum-cost path through
this surface puts the joins where nobody will see them.
"""
gy, gx = np.gradient(np.nan_to_num(mosaic_estimate))
grad = np.hypot(gx, gy)
return ndimage.gaussian_filter(grad, sigma=2.0) + 1e-3
This is the same idea used for visual orthomosaics, described in fixing visible seamlines and colour banding, applied to a different quantity. The difference is that for thermal it is the only acceptable seam treatment, where a visual product may legitimately blend.
Figure 3 — The seam is partly a measurement, which is why smoothing it lies.
Edge-case matrix
| Situation | Handling |
|---|---|
| Seam magnitude above 1 °C | Drift correction has not been applied or has failed |
| Seams only at frame edges | Spatial non-uniformity; fit a flat field |
| Seams worst on metal surfaces | View-angle dependence on a low-emissivity surface |
| Seams across water | Water is nearly Lambertian in thermal; suspect drift |
| Seams follow flight lines | Drift, since a line is a continuous time block |
| Seams form a checkerboard | Alternating line direction plus angle dependence |
| Seam disappears after blending | The disagreement is still there, now distributed |
| No overlap to measure | Cannot quantify; increase overlap on the next flight |
Verification snippet
import numpy as np
def seam_statistics(pairs: list[dict]) -> dict:
"""Distribution of disagreement across every frame overlap in the mosaic.
`pairs` carry the median difference over each overlap. The distribution
of those differences is the single best quality statement a thermal
mosaic can carry — and it needs no ground truth at all.
"""
diffs = np.array([p["median_diff_c"] for p in pairs], dtype=float)
diffs = diffs[np.isfinite(diffs)]
if diffs.size < 10:
return {"note": "too few overlaps to characterise"}
return {"overlaps": int(diffs.size),
"median_abs_c": float(np.median(np.abs(diffs))),
"p95_abs_c": float(np.percentile(np.abs(diffs), 95)),
"systematic_c": float(np.median(diffs)),
"relative_accuracy_c": float(np.percentile(np.abs(diffs), 95)),
"note": ("a systematic offset across overlaps means residual drift"
if abs(float(np.median(diffs))) > 0.2
else "overlaps are unbiased; the spread is the relative accuracy")}
Separating the median of the signed differences from the median of the absolute ones is what distinguishes a residual drift — which biases every overlap in one direction — from random disagreement, which does not.
Figure 2 — Routing moves the seam; blending would have changed the values.
Reporting seams rather than hiding them
Once the seam statistics are computed, they are the most useful thing in the delivery note, because they answer the question a client is actually going to ask: how much can I trust a difference between two places in this map.
A useful form is one sentence and one number. “Overlapping observations of the same ground agree to within 0.3 °C at the 95th percentile, measured across 1,840 frame overlaps” tells a reader that a one-degree difference between two panels in a solar array is real and a quarter-degree difference is not. That is precisely the judgement they need to make, and no datasheet figure supports it.
Two refinements are worth adding on a regular programme. Track the figure across flights, because a rising seam spread is an early sign of a sensor developing a fault or a flight pattern that has drifted longer. And report it per region where the survey is large, since a corner flown at the end of a long flight may have a worse residual than the rest.
The alternative — a blended mosaic with no statistics — gives the client a smooth picture and no basis for any decision, while quietly containing all of the same disagreement.
When to escalate
- Seams remain above half a degree after both corrections. Look at whether the flight had a power cycle, a battery change, or a long hold — all of which break a single drift model.
- The client insists on a seamless product. Deliver two: a blended visual version clearly labelled as not for measurement, and the unblended measurement raster. Both is cheaper than the conversation about which one they are looking at.
- Seams are worst over one surface type. That is view-angle dependence, and the fix is to use a narrower central crop of each frame — which costs overlap and therefore flight time.