Correcting Thermal Drift Across a Flight

The thermal mosaic of a solar array shows the east side running a degree and a half warmer than the west. The client asks which string is underperforming. Nothing is: the east side was flown last, twenty minutes after take-off, and the sensor had warmed by a degree and a half in the meantime.

Uncooled microbolometers — which is every drone thermal camera below research budgets — have no active temperature stabilisation. Their response is a function of the sensor’s own temperature, and the sensor’s temperature rises through a flight as the airframe warms and the airflow changes. The effect is systematic, monotonic and, once measured, entirely removable.

This page covers measuring it from the data, separating it from genuine surface warming, and applying the correction. It follows the conversion in thermal orthomosaic processing in Python.

Why the drift is not the same as the scene warming

Both produce a temperature that rises with time, which is why they are easy to conflate and why a naive correction removes real signal along with the artefact.

The scene genuinely warms through a morning flight: a concrete pad at 9 am is cooler than the same pad at 9.30. That warming is a property of the world and belongs in the product. The sensor drift is a property of the instrument and does not.

They are separable because they have different spatial signatures. Scene warming affects surfaces differently according to their thermal mass — concrete slowly, a metal roof quickly, water barely at all — while sensor drift affects every pixel of every frame identically. A correction fitted on one surface and validated on a second with a very different thermal mass distinguishes them.

Separating sensor drift from genuine scene warming Three series across a twenty-two minute flight. A metal roof rises steeply by three point four degrees, being both warmed by the sun and affected by drift. A concrete pad rises by two point eight degrees. A water body rises by two point four degrees despite having almost no real warming, because drift affects it equally. The common component of two point three degrees is the sensor drift, and the differences between the surfaces are the real warming. A note states that fitting on one surface alone cannot distinguish the two. 0 min 8 16 22 time through the flight metal roof +3.4 °C concrete +2.8 °C water +2.4 °C common component = sensor drift, +2.3 °C Water barely warms in twenty minutes, so almost all of its rise is the instrument.

Figure 1 — Three surfaces with different thermal mass. What they share is the sensor.

Minimal reproducible solution

import numpy as np


def fit_drift(observations: list[dict]) -> dict:
    """Fit sensor drift from repeated observations of several reference targets.

    `observations` are dicts of time_s, target, temperature_c. A joint fit
    with one drift term shared across targets and a warming term per target
    is what separates the instrument from the scene: the shared slope is the
    drift, and each target keeps its own physical trend.
    """
    targets = sorted({o["target"] for o in observations})
    t = np.array([o["time_s"] for o in observations], dtype=float)
    y = np.array([o["temperature_c"] for o in observations], dtype=float)

    # Design: one shared drift slope, plus per-target intercept and slope.
    cols = [t]
    for name in targets:
        sel = np.array([o["target"] == name for o in observations], dtype=float)
        cols.append(sel)              # per-target intercept
        cols.append(sel * t)          # per-target physical warming
    A = np.column_stack(cols)

    coeffs, *_ = np.linalg.lstsq(A, y, rcond=None)
    drift_per_s = float(coeffs[0])
    residual = y - A @ coeffs

    return {"drift_c_per_min": drift_per_s * 60.0,
            "total_drift_c": drift_per_s * float(t.max() - t.min()),
            "per_target_warming_c_per_min": {
                name: float(coeffs[2 + 2 * i] * 60.0) for i, name in enumerate(targets)},
            "residual_sd_c": float(np.std(residual, ddof=1)),
            "observations": len(observations), "targets": targets}

The joint fit is what makes this defensible with a single flight’s data. With one target, drift and warming are perfectly confounded and any split between them is an assumption; with two or more targets of different thermal mass, the shared component is identifiable.

Applying the correction

import numpy as np


def correct_frame(temps_c: np.ndarray, frame_time_s: float,
                  reference_time_s: float, drift_c_per_min: float) -> np.ndarray:
    """Refer a frame's temperatures back to the sensor state at a reference time.

    Choosing the flight's midpoint as the reference rather than its start
    halves the largest correction applied to any frame, which keeps the
    correction small relative to the measurement everywhere.
    """
    minutes = (frame_time_s - reference_time_s) / 60.0
    return (temps_c - drift_c_per_min * minutes).astype("float32")


def reference_time(frame_times_s: np.ndarray) -> float:
    """The flight midpoint, which minimises the largest applied correction."""
    return float((frame_times_s.min() + frame_times_s.max()) / 2.0)
Apparent temperature of a stable reference across a flight A trace of the apparent temperature of a physically stable reference surface, measured in every frame across a forty-minute flight. The trace rises steeply over the first several minutes as the detector warms, then flattens, with small discrete steps thereafter where the camera's internal shutter fired and re-zeroed the array. A note states that the stable reference is what makes the drift visible at all, that the warm-up ramp is why a flight should begin only after the camera has been powered for several minutes, and that the residual steps are removed by fitting between shutter events rather than across them. elapsed flight time apparent temperature of a stable reference Fit between shutter events, not across them — the steps are re-zeroing, not drift.

Figure 3 — Warm-up ramp, then shutter steps. Two effects, two treatments.

Edge-case matrix

Situation Handling
One reference target only Drift and warming confounded; state the assumption
Targets of similar thermal mass Poorly conditioned fit; add water or shade
Target observed twice only Fits a line with no residual; add a third pass
Flight under changing cloud Scene warming is not linear; fit a smoother trend
Camera power-cycled mid-flight Drift resets; fit segments separately
Long warm-up before take-off Smaller drift; fit anyway and confirm
Very short flight Drift may be below the noise; report it as such
Two batteries, two flights Treat as two flights; drift restarts

The power-cycle row is worth guarding explicitly, because it produces a discontinuity that a single linear fit smears across the whole flight:

import numpy as np


def detect_drift_segments(times_s: np.ndarray, temps_c: np.ndarray,
                          *, jump_c: float = 0.6) -> list[tuple[float, float]]:
    """Split a flight where the sensor appears to have reset."""
    order = np.argsort(times_s)
    t, y = times_s[order], temps_c[order]
    breaks = [0]
    for i in range(1, len(t)):
        if abs(y[i] - y[i - 1]) > jump_c and (t[i] - t[i - 1]) < 120:
            breaks.append(i)
    breaks.append(len(t))
    return [(float(t[breaks[i]]), float(t[breaks[i + 1] - 1]))
            for i in range(len(breaks) - 1)]

Verification snippet

import numpy as np


def verify_drift_correction(seam_diffs_c: np.ndarray,
                            before_seam_diffs_c: np.ndarray) -> dict:
    """Overlapping frames must agree better after the correction than before.

    Frames that overlap were taken at different times, so any residual drift
    shows as a systematic disagreement between them. The improvement in that
    disagreement is a direct measure of whether the correction worked.
    """
    b = np.abs(before_seam_diffs_c[np.isfinite(before_seam_diffs_c)])
    a = np.abs(seam_diffs_c[np.isfinite(seam_diffs_c)])
    if a.size < 20 or b.size < 20:
        return {"note": "too few overlaps to judge"}

    med_b, med_a = float(np.median(b)), float(np.median(a))
    return {"median_before_c": med_b, "median_after_c": med_a,
            "improvement": float(1 - med_a / max(med_b, 1e-9)),
            "ok": med_a < 0.5,
            "note": ("overlaps now agree within half a degree" if med_a < 0.5
                     else "residual drift remains; check for a segment break")}

Using frame overlaps rather than the reference targets for verification is deliberate: the targets were used to fit the correction, so checking against them is circular. The overlaps are independent and cover the whole survey rather than a few points in it.

Frame-overlap disagreement before and after drift correction Two distributions of the absolute temperature disagreement between overlapping frames. Before correction the distribution is broad, centred near one point four degrees and reaching three degrees. After correction it is narrow and centred near zero point three degrees, with almost nothing beyond one degree. A vertical line marks the half-degree acceptance threshold, which the corrected distribution is comfortably inside. A note records that overlaps are an independent check because the correction was fitted on reference targets instead. before — median 1.4 °C after — 0.3 °C 0.5 °C acceptance 0 1.0 2.0 3.0 °C absolute disagreement between overlapping frames An independent check: the correction was fitted on targets, not on overlaps.

Figure 2 — The correction, verified against data it was not fitted to.

Designing the flight so the correction is easy

The correction above needs repeated observations of stable targets spread through the flight, and getting them is a flight-planning decision rather than a processing one.

Put the targets where the flight already goes. A reference surface near the take-off point is observed at the start and the end with no extra flying. One near the middle of the pattern adds a third observation for free. Three observations spread across the flight are enough for a linear fit with a residual to check it against.

Choose surfaces with different thermal mass. A concrete pad and a shallow water tray is the classic pair: the concrete warms measurably over twenty minutes and the water barely does, which is exactly the contrast the joint fit needs. A metal plate adds a third, fast-warming point.

Fly the pattern so time and space are not confounded. A survey flown as parallel lines from west to east has time increasing with easting, so drift and any real east-west gradient are indistinguishable. Flying alternate lines, or splitting the survey into two interleaved halves, breaks the confounding and makes the drift separable from a genuine spatial pattern.

That last point is the one most often missed and the one that matters most on sites with a real gradient — a solar array where the west side genuinely does run warmer, or a field with a moisture gradient. Interleaving costs a few minutes of extra transit and turns an unanswerable question into a measurable one.

def interleave_flight_lines(lines: list[int]) -> list[int]:
    """Reorder flight lines so acquisition time is not aligned with position."""
    evens = [n for n in lines if n % 2 == 0]
    odds = [n for n in lines if n % 2 == 1]
    return evens + odds[::-1]

When to escalate

  • No reference target was flown. The drift can sometimes be recovered from overlaps alone, treating the disagreement between frames as the signal, but the result is weaker and the absolute level is unconstrained. Fly a target next time; a square of concrete costs nothing.
  • The residual scatter is large after correction. Something other than linear drift is present — a power cycle, a change of flight altitude, or wind cooling the airframe. Segment the flight and re-fit.
  • The drift exceeds about half a degree per minute. That is not normal warming; check whether the camera was started immediately before take-off, and allow a warm-up period.

Thermal Orthomosaic Processing in Python