Calculating Image Overlap and Ground Sample Distance for Site Survey

Before a serpentine grid can be laid or a corridor buffered, the survey needs a single quantitative anchor: the ground sample distance that the flight will actually achieve, and the flight-line spacing that holds the planned overlap around it. This guide, part of Drone Flight Planning for Photogrammetric Survey, turns the camera specification and a target resolution into the three numbers every mission needs — flying height, ground footprint, and line spacing — as one copy-paste function. It arises constantly in fieldwork because the person planning the flight is rarely the person who chose the drone, and the sensor on the airframe is frequently not the one the last plan assumed.

The ground sample distance is the real-world size of one pixel on the ground. For a nadir frame it is fixed by the sensor width SwS_w, the flying height HH, the focal length ff, and the image width in pixels IwI_w:

GSD=SwHfIw\text{GSD} = \frac{S_w \cdot H}{f \cdot I_w}

Everything else in this guide is algebra on that identity. Invert it for height when you have a resolution target; multiply it by pixel counts for the footprint; apply overlap fractions to the footprint for spacing and shutter timing.

Overlap and footprint geometry Two adjacent flight lines with overlapping frame footprints, showing side overlap between lines and forward overlap along a line. Overlap geometry between frames side overlap (across track) line spacing forward overlap sets the shutter interval along a line; side overlap sets the gap between adjacent lines.

Context & When to Use

Reach for these calculations whenever the airframe, lens, or resolution requirement changes — which in practice is every new site. The trade-offs are concrete. Flying lower tightens the GSD but multiplies flight lines, battery swaps, and processing time; a 5 mm/px masonry survey can be an order of magnitude more imagery than a 20 mm/px landscape pass over the same footprint. Raising overlap strengthens the tie-point network and rescues low-texture surfaces, but again inflates image count and matching cost. For heritage work the deciding factor is usually the smallest feature that must be legible in the final orthomosaic: tooling marks and inscription strokes demand a GSD at or below half their width, whereas earthwork and enclosure mapping tolerates far coarser pixels. Compute the numbers first, then let the flight grid generator consume the spacing this function returns.

Implementation

The function below takes a camera specification and a target GSD and returns a complete geometric summary. It works in SI throughout (metres, seconds) with millimetre inputs for the sensor and lens, mirroring how manufacturers publish specifications.

# requirements.txt
# numpy==1.26.4
from dataclasses import dataclass, asdict
import numpy as np


@dataclass
class Camera:
    sensor_w_mm: float   # physical sensor width
    sensor_h_mm: float   # physical sensor height
    focal_mm: float      # lens focal length
    img_w_px: int        # image width in pixels
    img_h_px: int        # image height in pixels


def survey_geometry(cam: Camera, target_gsd_m: float,
                    forward_overlap: float = 0.80,
                    side_overlap: float = 0.70,
                    ground_speed_ms: float = 5.0) -> dict:
    """
    From a camera and a target GSD, return flying height, ground footprint,
    flight-line spacing, and shutter interval for an archaeological survey.

    GSD = (sensor_w * H) / (focal * img_w)  ->  H = GSD * focal * img_w / sensor_w
    All distances are metres; sensor and focal length given in mm.
    """
    if not (0 < forward_overlap < 1) or not (0 < side_overlap < 1):
        raise ValueError("Overlaps must be fractions strictly between 0 and 1.")

    sensor_w_m = cam.sensor_w_mm / 1000.0
    sensor_h_m = cam.sensor_h_mm / 1000.0
    focal_m = cam.focal_mm / 1000.0

    # Invert the GSD identity for flying height above the surface.
    height_m = target_gsd_m * focal_m * cam.img_w_px / sensor_w_m

    # Ground footprint of a single frame at that height.
    footprint_across_m = target_gsd_m * cam.img_w_px   # perpendicular to flight
    footprint_along_m = target_gsd_m * cam.img_h_px    # along flight line

    # Overlap -> geometry. Side overlap is between adjacent lines;
    # forward overlap governs spacing between successive exposures.
    line_spacing_m = footprint_across_m * (1.0 - side_overlap)
    photo_advance_m = footprint_along_m * (1.0 - forward_overlap)
    shutter_interval_s = photo_advance_m / ground_speed_ms

    return {
        "target_gsd_mm": round(target_gsd_m * 1000, 2),
        "flying_height_m": round(height_m, 2),
        "footprint_across_m": round(footprint_across_m, 2),
        "footprint_along_m": round(footprint_along_m, 2),
        "line_spacing_m": round(line_spacing_m, 2),
        "shutter_interval_s": round(shutter_interval_s, 3),
        "photos_per_100m_line": int(np.ceil(100.0 / photo_advance_m)),
    }


if __name__ == "__main__":
    # DJI Zenmuse P1 full-frame, 35 mm lens, 5 mm/px masonry survey.
    p1 = Camera(sensor_w_mm=35.9, sensor_h_mm=24.0, focal_mm=35,
                img_w_px=8192, img_h_px=5460)
    result = survey_geometry(p1, target_gsd_m=0.005,
                             forward_overlap=0.80, side_overlap=0.70,
                             ground_speed_ms=4.0)
    for k, v in result.items():
        print(f"{k:>22}: {v}")

The archaeological logic lives in the defaults: 80/70 overlap is a conservative floor that survives bare soil and rubble, and a 4 m/s ground speed keeps motion blur below one pixel at typical shutter speeds when the GSD is fine. Loosen these only when the surface is richly textured.

Verification

Run the module and check the printed geometry against a hand calculation. For the P1 example at 5 mm/px the height should land near 22 m, and the footprint across track near 41 m. Assert the invariant that recomputing GSD from the returned height reproduces the target:

def test_roundtrip():
    p1 = Camera(35.9, 24.0, 35, 8192, 5460)
    g = survey_geometry(p1, 0.005)
    # Recover GSD from height and confirm it matches within 0.1 mm.
    gsd_check = (p1.sensor_w_mm / 1000) * g["flying_height_m"] \
                / ((p1.focal_mm / 1000) * p1.img_w_px)
    assert abs(gsd_check - 0.005) < 1e-4, gsd_check
    assert g["line_spacing_m"] < g["footprint_across_m"]  # overlap > 0
    print("geometry self-consistent")

test_roundtrip()

Expected output is geometry self-consistent with no AssertionError. If line_spacing_m ever equals or exceeds footprint_across_m, the side overlap fell to zero or below and adjacent strips will not tie.

Common Errors & Fixes

  • ZeroDivisionError: float division by zeroground_speed_ms was passed as 0, or sensor_w_mm was left unset. A stationary aircraft has no shutter interval; supply the real cruise speed and confirm the sensor width came from the manufacturer sheet, not the crop-factor “equivalent”.
  • Computed height is roughly 1.5× or 2.3× the expected value — a crop-sensor focal length was used with a full-frame sensor width (or vice versa). The SwS_w and ff must describe the same physical camera; do not mix a 35 mm-equivalent focal length with the true sensor width.
  • ValueError: Overlaps must be fractions strictly between 0 and 1 — overlap was passed as a percentage (80) rather than a fraction (0.80). The function guards against this because an 80.0 overlap silently produces catastrophic negative spacing.
Field note. Manufacturers quote sensor size as a diagonal marketing figure ("1-inch sensor") that is not the physical width. Always take $S_w$ from the pixel pitch times the pixel count, or from the datasheet's stated sensor dimensions in millimetres — a "1-inch" sensor is really about 13.2 mm wide, and using 25.4 mm will put your aircraft at nearly double the intended altitude.

Part of Photogrammetry & 3D Site Mapping Pipelines.