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.
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)
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.
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.