Fixing Exposure Drift Across a Multispectral Flight

The reflectance mosaic has a gradient across it: the first flight line is consistently brighter than the last, by three or four percent, with nothing on the ground to explain it. Or there is a sharp step in the middle, where two adjacent flight lines disagree along their whole shared edge.

Both are drift — a change in the relationship between incident light and recorded digital number that the calibration chain did not remove. The chain normalises out the exposure and gain the camera reported, so anything that changes the response without changing those reported values survives it, and there are three such things.

This page covers detecting drift from frame overlap, separating its causes, and fitting a correction that does not destroy real variation. It follows the calibration chain in radiometric calibration in Python.

Three causes with three signatures

Auto-exposure lag. The camera adjusts exposure as the scene brightness changes, and it adjusts after the change rather than before. Over a boundary between dark crop and bright stubble, several frames are exposed for the wrong scene. The signature is a transient: a few frames out of step, correlated with scene content, recovering within a second or two.

Sensor warming. A CMOS sensor’s dark current roughly doubles for every 6–8 °C of temperature rise, and the sensor warms through a flight. If the dark level used in calibration was measured cold, the correction is increasingly wrong as the flight proceeds. The signature is monotonic: a smooth gradient from the first line to the last, strongest in the darkest parts of the scene.

Gain steps. Where the camera switches ISO, the response changes by a discrete factor, and the reported gain does not always describe the change exactly — sensor gain stages are not perfectly linear. The signature is a step: a discontinuity at a specific frame index, identical across the whole frame.

Three drift signatures across a flight's frame sequence Three stacked traces of residual brightness against frame number across a flight of about nine hundred frames. The auto-exposure trace is flat with three short transients that recover within a few frames each, coinciding with scene boundaries. The sensor-warming trace is a smooth monotonic rise of about four percent from the first frame to the last. The gain-step trace is flat with one discrete step of two percent at frame four hundred and twenty. A note states that the three are separable by shape alone, which is what makes the diagnosis mechanical. auto-exposure — transients sensor warming — monotonic gain step — discontinuity Separable by shape alone — which is what makes the diagnosis mechanical rather than a judgement.

Figure 1 — Three causes, three shapes. Identifying which one is present decides the correction.

Minimal reproducible solution

Drift is measured from overlap: where two frames see the same ground, they should report the same reflectance, and the ratio between them is the relative drift.

import numpy as np


def overlap_ratios(frames: list[dict], min_shared_px: int = 2000) -> list[dict]:
    """Relative brightness between every overlapping pair of frames.

    `frames` carry a calibrated array and a mask of the ground each covers.
    Only the shared region is compared, and only where both are valid, so a
    ratio reflects the cameras rather than the scene.
    """
    out = []
    for i, a in enumerate(frames):
        for j in range(i + 1, len(frames)):
            b = frames[j]
            shared = a["footprint"] & b["footprint"]
            if shared.sum() < min_shared_px:
                continue
            va = a["values"][shared]
            vb = b["values"][shared]
            ok = np.isfinite(va) & np.isfinite(vb) & (va > 0) & (vb > 0)
            if ok.sum() < min_shared_px:
                continue
            out.append({"i": i, "j": j,
                        "log_ratio": float(np.median(np.log(vb[ok] / va[ok]))),
                        "pixels": int(ok.sum())})
    return out


def solve_frame_gains(pairs: list[dict], n_frames: int,
                      damping: float = 0.01) -> np.ndarray:
    """Per-frame multiplicative gains that make all overlaps agree.

    Working in logs makes it a linear least-squares problem. The damping row
    anchors the solution, which is otherwise determined only up to a global
    scale — and the global scale is what the panel calibration already fixed.
    """
    rows, rhs = [], []
    for p in pairs:
        row = np.zeros(n_frames)
        row[p["i"]], row[p["j"]] = 1.0, -1.0
        rows.append(row)
        rhs.append(p["log_ratio"])
    for k in range(n_frames):
        row = np.zeros(n_frames)
        row[k] = damping
        rows.append(row)
        rhs.append(0.0)

    log_gain, *_ = np.linalg.lstsq(np.asarray(rows), np.asarray(rhs), rcond=None)
    return np.exp(log_gain)

Solving globally rather than chaining frame to frame is what keeps the correction from accumulating error along a flight line. A pairwise chain drifts; a least-squares solve over all overlaps distributes the disagreement and leaves a residual too small to see.

Constraining the correction so it does not eat real signal

An unconstrained per-frame gain can absorb genuine variation — if a whole flight line really is over a different crop, the solver will happily call that a camera difference. Two constraints prevent it.

import numpy as np


def constrain_gains(gains: np.ndarray, frame_times: np.ndarray,
                    *, max_deviation: float = 0.08,
                    smoothness_s: float = 60.0) -> np.ndarray:
    """Keep the correction small and slowly varying.

    Real drift is gradual and modest; a solved gain of 1.4 on one frame is
    the solver explaining a scene difference. Clipping and temporal smoothing
    together restrict the correction to the shape drift actually takes.
    """
    clipped = np.clip(gains, 1 - max_deviation, 1 + max_deviation)
    smoothed = np.empty_like(clipped)
    for i, t in enumerate(frame_times):
        sel = np.abs(frame_times - t) <= smoothness_s / 2
        smoothed[i] = np.median(clipped[sel])
    return smoothed

The smoothing window is the parameter that encodes “drift is slow”. A sixty-second median lets a warming trend through and blocks a per-frame correction that would be fitting scene content. Where the diagnosis found a gain step rather than a trend, the window should be applied on each side of the step separately, so the discontinuity survives.

Drift that the exposure record explains and drift that it does not Two columns. The explained column lists a step change at a shutter or ISO change, a smooth ramp as the camera tracked brightening conditions, and per-band differences following each band's own exposure, all of which divide out from the recorded exposure fields. The unexplained column lists sensor warm-up over the first minutes of a flight, a drift that continues after exposure normalisation, and a change correlated with temperature rather than with light, none of which the exposure record contains and all of which need either a warm-up period or an empirical drift model. the exposure record explains a step at a shutter or ISO change a smooth ramp tracking conditions per-band differences following each band's exposure all of it divides out it does not explain sensor warm-up over the first minutes drift continuing after normalisation change correlated with temperature, not light needs a warm-up period or a drift model Normalise by exposure first; whatever is left is the drift actually worth modelling.

Figure 3 — Subtract what the camera recorded before modelling anything.

Edge-case matrix

Situation Signature Handling
Auto-exposure transients Short spikes at scene boundaries Fix exposure at capture; correct per frame if not
Sensor warming Smooth monotonic trend Temperature-dependent dark model, or a fitted trend
Gain step Discontinuity at one frame Solve the two segments separately
A genuinely different flight line Looks like a step Constrain the correction; check the ground
Very low overlap Few pairs, unstable solve Raise overlap; the solve needs redundancy
Water or shadow in the overlap Ratios dominated by non-surfaces Mask before computing ratios
Drift in one band only Band-specific sensor issue Solve per band; never share a correction
Correction exceeds 8 % Not drift Investigate before applying anything

Verification snippet

import numpy as np


def residual_after_correction(pairs: list[dict], gains: np.ndarray) -> dict:
    """How well the solved gains reconcile the overlaps."""
    before, after = [], []
    for p in pairs:
        before.append(p["log_ratio"])
        after.append(p["log_ratio"] - (np.log(gains[p["i"]]) - np.log(gains[p["j"]])))
    b, a = np.abs(np.asarray(before)), np.abs(np.asarray(after))
    return {"pairs": len(pairs),
            "median_disagreement_before": float(np.median(b)),
            "median_disagreement_after": float(np.median(a)),
            "improvement": float(1 - np.median(a) / max(np.median(b), 1e-9)),
            "ok": float(np.median(a)) < 0.01}

A median residual under about one percent in log space means the overlaps now agree to within a percent, which is below anything visible in a mosaic and below the accuracy any index claims.

Chained against globally solved gain corrections along a flight line A flight line of twelve frames with their correction factors plotted. The chained solution, propagating frame to frame from the first, accumulates error and reaches a seven percent deviation by the twelfth frame. The globally solved version stays within one percent of unity across all twelve, because the disagreement is distributed across every overlap rather than accumulated along a path. A note states that the chained result looks correct at every individual junction and is wrong overall. 1.00 14 812 frame along the flight line chained — 7 % by frame 12 globally solved — within 1 % The chained result is correct at every junction and wrong overall, which is why it survives review.

Figure 2 — Why the correction is solved rather than propagated.

Preventing it at capture

Most drift is avoidable, and the avoidance is cheaper than every correction above.

Fix the exposure. Radiometric capture with auto-exposure active introduces a per-frame variable that the calibration must then undo. Setting shutter, aperture and ISO from a test shot over a representative part of the site removes the transient class of drift entirely, at the cost of accepting clipping in the brightest areas.

Let the camera warm up. A sensor powered on and flown immediately warms fastest during the first few minutes, which is exactly when the flight starts. Five minutes of idle time on the ground moves the steepest part of the warming curve out of the survey.

Avoid ISO changes mid-flight. Where the camera must change gain, doing it between flight lines rather than within one keeps the discontinuity aligned with a mosaic seam instead of cutting across a field.

None of these requires equipment. All three are flight-plan or checklist items, and a survey flown with them needs the correction on this page as a verification rather than as a repair.

When to escalate

  • The solved correction exceeds about eight percent. That is not drift. Look for a gain step the metadata did not report, a change in aperture, or a calibration applied with the wrong panel values.
  • Drift appears in one band only. A single sensor is misbehaving. Correcting it per band is right in the short term, and the hardware needs checking.
  • The correction reconciles overlaps and the index still shows flight-line structure. The residual is likely band misalignment or vignetting rather than exposure, both of which produce frame-correlated patterns of their own.

Radiometric Calibration in Python