Handling Rolling Shutter in Fast Flight Lines
The survey reconstructs cleanly and the checkpoints show a systematic error along the direction of flight: everything is displaced a few centimetres one way on the outbound lines and the other way on the return lines. The pattern alternates with the flight direction, which is the signature nothing else produces.
A rolling-shutter sensor does not capture a frame at an instant. It reads rows sequentially over ten to thirty milliseconds, so the bottom of the image is exposed later than the top — and if the aircraft moved in between, the two halves of the frame were taken from different places. This page covers estimating the effect, correcting it, and deciding whether to fly slower instead. It supports the model discussion in camera calibration and lens models in Python.
How large the effect actually is
The displacement between the first and last row is simply speed multiplied by readout time. At 10 m/s with a 20 ms readout, that is 20 cm on the ground — which at a 2 cm ground sample distance is ten pixels of shear across the frame.
Whether that matters depends on what the survey is for. Ten pixels of shear in a visual orthomosaic is invisible after mosaicking, because adjacent frames overlap and the blend hides it. Ten pixels in a survey claiming three-centimetre accuracy is the whole error budget.
The effect is worse in three situations: faster flight, longer readout, and lower altitude. The last is counter-intuitive until the arithmetic is written down — the ground displacement is fixed by speed and time, so a lower altitude with a finer ground sample distance turns the same centimetres into more pixels.
Figure 1 — The shear, at a 2 cm ground sample distance. Flying slower moves left along every curve.
Minimal reproducible solution
import numpy as np
def rolling_shutter_shear(speed_m_s: float, readout_ms: float,
gsd_m: float) -> dict:
"""Ground and pixel displacement between the first and last sensor row.
The ground figure is fixed by speed and time; the pixel figure depends on
the ground sample distance, so flying lower makes the same displacement
worse in pixels even though it is unchanged in metres.
"""
ground_m = speed_m_s * readout_ms / 1000.0
pixels = ground_m / max(gsd_m, 1e-9)
return {"ground_displacement_m": ground_m, "shear_px": pixels,
"significant": pixels > 3.0,
"note": ("below typical tie-point noise" if pixels <= 3 else
"large enough to bias the reconstruction along the flight line")}
def max_speed_for_tolerance(readout_ms: float, gsd_m: float,
max_shear_px: float = 3.0) -> float:
"""The speed at which the shear stays within a stated pixel tolerance."""
return max_shear_px * gsd_m / (readout_ms / 1000.0)
The second function is the one to put in a flight planner. Given a camera’s readout time and the planned ground sample distance, it returns the speed the aircraft must not exceed — which is far more useful than discovering the problem in the checkpoints.
Correcting rather than avoiding
Modern reconstruction engines can model the effect, solving a per-frame velocity alongside the pose and applying a row-dependent correction. It works well and costs two things: a slower solve, and an additional parameter per frame that can absorb other errors if the geometry is weak.
def rolling_shutter_options(camera: dict, flight: dict) -> dict:
"""Whether to enable rolling-shutter modelling for this survey.
Enabling it on a survey where the effect is negligible adds free
parameters for no benefit, which on weak geometry is a cost rather than a
neutral choice.
"""
gsd = flight["altitude_m"] * camera["sensor_pitch_um"] * 1e-6 / camera["focal_mm"] * 1000
shear = rolling_shutter_shear(flight["speed_m_s"], camera["readout_ms"], gsd)
if camera.get("shutter") == "global":
return {"enable": False, "reason": "global shutter; no rolling effect exists"}
if not shear["significant"]:
return {"enable": False, "shear_px": shear["shear_px"],
"reason": "shear is below tie-point noise; the extra parameters cost more "
"than they buy"}
return {"enable": True, "shear_px": shear["shear_px"],
"reason": f"{shear['shear_px']:.1f} px of shear along the flight line"}
Figure 3 — Three multiplied terms, of which only the first is under the pilot’s control.
Edge-case matrix
| Situation | Effect | Handling |
|---|---|---|
| Global shutter | None | Do not enable the correction |
| Slow flight, coarse GSD | Under a pixel | Ignore |
| Fast corridor survey | Large, one direction only | Model it, or slow down |
| Alternating line directions | Error alternates in sign | The characteristic signature |
| Oblique frames | Shear plus a scale change | Modelling handles it; geometry helps |
| Hovering capture | None while stationary | Ignore |
| Windy conditions | Ground speed varies per line | Use per-frame speed, not the plan |
| Weak geometry plus modelling on | Parameters absorb other errors | Prefer flying slower |
The wind row is worth planning for. A flight planned at 8 m/s flies its downwind lines at 12 and its upwind lines at 4, so the shear differs by a factor of three between adjacent lines — which is exactly the alternating pattern that appears in checkpoints, and it is stronger than the direction reversal alone would produce.
import numpy as np
def per_frame_shear(speeds_m_s: np.ndarray, readout_ms: float,
gsd_m: float) -> dict:
"""Shear per frame from the actual ground speed, not the planned one."""
shear = np.asarray(speeds_m_s, dtype=float) * readout_ms / 1000.0 / gsd_m
return {"median_px": float(np.median(shear)),
"max_px": float(shear.max()),
"frames_over_3px": int((shear > 3).sum()),
"wind_affected": bool(shear.max() / max(shear.min(), 1e-9) > 1.8)}
Verification snippet
import numpy as np
def detect_rolling_shutter_bias(residuals_xy: np.ndarray,
flight_headings_deg: np.ndarray) -> dict:
"""Look for a residual that flips sign with the flight direction.
Rolling-shutter bias is along-track and reverses with the line direction,
which no other common error does. Projecting the residuals onto each
frame's heading and looking at the sign is a direct test.
"""
headings = np.radians(np.asarray(flight_headings_deg, dtype=float))
direction = np.column_stack([np.cos(headings), np.sin(headings)])
along = np.sum(np.asarray(residuals_xy, dtype=float) * direction, axis=1)
outbound = along[np.cos(headings) > 0]
inbound = along[np.cos(headings) <= 0]
if outbound.size < 5 or inbound.size < 5:
return {"note": "need residuals from both flight directions"}
mo, mi = float(np.median(outbound)), float(np.median(inbound))
return {"outbound_median_m": mo, "inbound_median_m": mi,
"difference_m": mo - mi,
"rolling_shutter_likely": (mo * mi < 0) and abs(mo - mi) > 0.02,
"note": ("along-track residual reverses with flight direction — "
"rolling shutter" if mo * mi < 0 else
"no direction-dependent along-track bias")}
The sign reversal is the definitive test and it needs nothing beyond residuals that already exist. A cross-track bias, a datum error or a doming all fail it, which makes a positive result unusually conclusive.
Figure 2 — The signature, in residuals any controlled survey already has.
Flying slower against modelling it
Both remove the effect and they are not equivalent.
Flying slower removes the cause. It costs flight time — halving the speed roughly doubles the mission — and it adds no parameters to the solve, which matters most on exactly the weak geometries where a rolling-shutter parameter would be dangerous.
Modelling it costs nothing in the air and adds one velocity per frame to the adjustment. On a well-controlled survey with obliques, that is free; on a nadir grid with perimeter control, it is another parameter that can absorb a surface error.
The rule that follows is the same one as for intrinsics: where the geometry is strong, model it; where it is weak, avoid the cause. A corridor survey — weak geometry, high speed, long lines — is the case where flying slower is almost always right.
When to escalate
- The camera’s readout time is unknown. Vendors rarely publish it. It can be measured by photographing a rotating target of known speed, or estimated by fitting the correction on a controlled survey and reading off the implied velocity.
- The effect persists after modelling. Check that per-frame ground speed, not the planned speed, is being used; wind makes the two differ substantially.
- Accuracy requirements exceed what a rolling shutter allows. A global-shutter camera is the answer. No correction fully removes an effect this size at survey speeds.