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