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.

Three contributions to a thermal seam, and which are correctable Three stacked bars showing the contribution of each cause to a typical one point four degree seam. Sensor drift contributes zero point nine degrees and is fully correctable. Spatial non-uniformity within the frame contributes zero point three five degrees and is correctable with a flat field. View-angle dependence contributes zero point one five degrees and is real physics that can only be reduced by using a narrower part of each frame. A note states that correcting the first two leaves a seam of about zero point fifteen degrees, which is a fair statement of the survey's relative accuracy. a typical 1.4 °C seam, decomposed sensor drift · 0.90 °C fully correctable non-uniformity · 0.35 correctable with a flat field angle · 0.15 real physics — reduce, not remove After the two corrections, about 0.15 °C of seam remains. That residual is a fair statement of the survey's relative accuracy — and worth reporting. Blending would have hidden all three and reported nothing.

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.

Why a thermal seam is not an RGB seam with different numbers Two columns. The RGB seam column notes that the two frames saw the same scene, that the disagreement is radiometric and a gain correction reaches it, that blending is cosmetically acceptable, and that the underlying values are not a measurement. The thermal seam column notes that the two frames saw the scene at different times and the surface genuinely changed between them, that the disagreement is partly real, that blending averages two true measurements of different moments into one that is true of neither, and that the honest treatment is a hard cut with the acquisition time recorded per region. an RGB seam both frames saw the same scene the disagreement is radiometric a gain correction reaches it blending is cosmetically fine a thermal seam the frames saw different moments part of the disagreement is real blending averages two true values into a false one hard cut, with acquisition time recorded Temperature changes during a flight. A smooth mosaic can only be achieved by hiding that.

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.

Seamline routed across a thermal boundary against routed around it Two plan views of the same overlap between two frames, containing a warm building and a cool field. In the first, the seamline runs straight across the building, so the half-degree disagreement between the frames appears as a visible step through a high-contrast feature. In the second, the seamline is routed around the building through the uniform field, where the same half-degree disagreement is invisible against a smooth background. A note states that the disagreement is identical in both cases and only its visibility changes. straight seam routed seam warm building visible step through the roof warm building invisible against a smooth field The disagreement is identical. Only its visibility changed. Which is the honest kind of improvement, unlike blending.

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.

Thermal Orthomosaic Processing in Python