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.

Ground displacement between the first and last row of a rolling-shutter frame A curve of displacement in pixels against flight speed for three readout times, at a two centimetre ground sample distance. With a ten millisecond readout the displacement reaches five pixels at ten metres per second. With twenty milliseconds it reaches ten pixels. With thirty milliseconds it reaches fifteen. A shaded band below three pixels marks the region where the effect is below typical tie-point noise. A note states that a global shutter has no such curve at all, which is why it is worth paying for on accuracy work. below tie-point noise 2 m/s5 81216 ground speed shear, pixels 10 ms 20 ms 30 ms A global-shutter sensor has no curve here at all, which is the argument for paying for one.

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"}
What controls the size of a rolling shutter error Three rows. Ground speed sets how far the aircraft travels during the sensor readout, so the error scales directly with it and halving the speed halves the distortion. Readout time is a sensor property and varies by an order of magnitude between a cheap rolling shutter and a fast one, which is why two aircraft at the same speed can show very different effects. Ground sample distance converts the resulting ground displacement into pixels, so a low, high-detail flight shows in pixels what a high one absorbs below the noise. A note states that a global shutter removes the effect entirely and is the only complete answer. ground speed how far the aircraft moves during readout — halve it, halve the distortion readout time a sensor property varying by an order of magnitude between cameras ground sample distance converts ground displacement to pixels — low flights show it most A global shutter removes the effect entirely; everything else manages it.

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.

Along-track residuals by flight line, showing the rolling-shutter signature Along-track checkpoint residuals grouped by flight line across eight lines flown in alternating directions. Odd-numbered lines show a positive median residual of about seven centimetres and even-numbered lines a negative one of about six centimetres, alternating consistently. A note states that no other common error produces a residual that reverses with flight direction, which makes this signature conclusive. 0 L1L2L3 L4L5L6 L7L8 flight line, flown in alternating directions +7 cm on outbound lines −6 cm on return lines No other common error reverses with flight direction, which makes this 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.

Camera Calibration and Lens Models in Python