Computing Ground Sample Distance in Python

Ground sample distance is the size of one pixel on the ground, and it is the number every other survey quantity is measured against: the achievable accuracy, the useful contour interval, the top zoom of a tile set, the resolution the client is quoted. It is also routinely computed wrong by a factor of two or three, from correct inputs, because two of the three quantities involved have a plausible impostor sitting next to them in the metadata.

This page computes it correctly, names both impostors, and validates the result against the reconstruction rather than trusting the arithmetic.

The formula, and the two substitutions that break it

For a nadir frame, ground sample distance is

GSD=Hpf\text{GSD} = \frac{H \cdot p}{f}

where HH is the height above the ground being photographed, pp is the physical pixel pitch of the sensor, and ff is the physical focal length. All three have to be in consistent units, and each has a specific way of going wrong.

The focal length impostor. EXIF carries both FocalLength — the physical value in millimetres — and FocalLengthIn35mmFilm, the value rescaled to a full-frame sensor. On a typical drone camera the second is two to three times the first. Substituting it while keeping the real sensor’s pixel pitch produces a GSD two to three times too fine, and the survey is then quoted a resolution it cannot deliver.

The altitude impostor. GPS altitude in EXIF is a height above the ellipsoid or above mean sea level, not above the ground. On a site 400 m above sea level flown at 90 m, the tag reads roughly 490 and the correct HH is 90. Using the tag directly makes the GSD five times too coarse.

The pixel pitch is not in EXIF at all. It is derived: sensor width divided by image width in pixels. Sensor width is a property of the camera model, usually from a lookup table, and getting it wrong scales the answer directly.

The three inputs and their impostors Three input quantities shown with the wrong value that commonly replaces each. Height above ground is replaced by GPS altitude, which on a site four hundred metres above sea level makes the answer roughly five times too coarse. Physical focal length is replaced by the thirty-five millimetre equivalent, making the answer two to three times too fine. Pixel pitch, which is not present in EXIF and must be derived from the sensor width and image width, is replaced by a guessed sensor width, scaling the answer directly. Each pairing shows the correct source and the magnitude of the error. H — height correct: above ground flight log, or GPS altitude minus terrain elevation impostor: GPS altitude 490 m instead of 90 m 5× too coarse f — focal length correct: FocalLength physical, in millimetres e.g. 8.8 mm impostor: 35 mm equivalent 24 mm instead of 8.8 mm 2.7× too fine p — pixel pitch correct: derived sensor width ÷ image width not an EXIF tag impostor: guessed sensor 13.2 mm assumed for a 17.3 mm 1.3× off, silently Each substitution produces a plausible number. None of the three is caught by a sanity check on the result alone.

Figure 1 — Three inputs, three impostors. All three wrong answers are within the range a survey might legitimately produce, which is why the check has to be against the reconstruction rather than against expectation.

Minimal reproducible solution

Compute from the physical quantities, take the sensor width from a table keyed on the camera model, and require the height above ground as an explicit argument rather than reading it from a tag.

from dataclasses import dataclass

# Sensor width in millimetres, keyed on the EXIF make and model. Extend
# deliberately: a missing entry must raise rather than default.
SENSOR_WIDTH_MM = {
    ("Hasselblad", "L1D-20c"): 13.2,
    ("DJI", "FC6310"): 13.2,
    ("DJI", "FC7303"): 6.3,
    ("SONY", "ILCE-6000"): 23.5,
    ("MicaSense", "RedEdge-MX"): 4.8,
}


@dataclass(frozen=True)
class GSD:
    metres_per_pixel: float
    footprint_m: tuple[float, float]
    pixel_pitch_um: float

    def as_cm(self) -> float:
        return self.metres_per_pixel * 100.0


def compute_gsd(make: str, model: str, image_width_px: int, image_height_px: int,
                focal_length_mm: float, height_above_ground_m: float) -> GSD:
    """Nadir ground sample distance from physical quantities only."""
    key = (make.strip(), model.strip())
    if key not in SENSOR_WIDTH_MM:
        raise KeyError(
            f"sensor width unknown for {key} — add it to the table rather than "
            "assuming a value; the result scales linearly with it")
    if focal_length_mm <= 0 or height_above_ground_m <= 0:
        raise ValueError("focal length and height above ground must be positive")
    if focal_length_mm > 40:
        raise ValueError(
            f"focal length {focal_length_mm} mm is implausible for a UAV camera — "
            "this is almost certainly the 35 mm equivalent")

    sensor_w_mm = SENSOR_WIDTH_MM[key]
    pitch_mm = sensor_w_mm / image_width_px
    gsd_m = height_above_ground_m * pitch_mm / focal_length_mm

    return GSD(
        metres_per_pixel=gsd_m,
        footprint_m=(gsd_m * image_width_px, gsd_m * image_height_px),
        pixel_pitch_um=pitch_mm * 1000.0,
    )

The focal-length plausibility bound is doing real work. A physical UAV camera focal length is between about 4 mm and 35 mm; a 35 mm-equivalent value for the same lens is typically 24 mm or more but paired with a small sensor, and the combination of a large focal length with a 13 mm sensor width is the signature. Rejecting it outright is safer than warning, because the resulting GSD is plausible enough to survive review.

Height above ground is a required argument for the same reason. There is no tag that carries it, and every attempt to derive it inside the function ends up reading GPS altitude.

def height_above_ground(gps_altitude_m: float, terrain_elevation_m: float) -> float:
    """Explicit subtraction, so the caller sees both terms.

    terrain_elevation_m must be in the SAME vertical reference as the GPS
    altitude — mixing ellipsoidal and orthometric here reintroduces the geoid
    separation as an altitude error of tens of metres.
    """
    agl = gps_altitude_m - terrain_elevation_m
    if not (5.0 <= agl <= 400.0):
        raise ValueError(
            f"height above ground of {agl:.0f} m is outside the plausible range — "
            "check that both values share a vertical reference")
    return agl

Edge-case matrix

Input variant Naive result Correct handling
FocalLengthIn35mmFilm used 2–3× too fine Rejected by the plausibility bound
GPS altitude used as AGL Several times too coarse Require AGL explicitly
Ellipsoidal altitude, orthometric terrain Off by the geoid separation Same vertical reference for both
Unknown camera model Silent default sensor width KeyError — add the entry
Cropped or downsampled image Pitch computed from the wrong width Use the original pixel dimensions
Oblique frame Formula does not apply GSD varies across the frame; report a range
Terrain with relief One GSD for the whole block Report at the mean, and the range
Multispectral band at lower resolution Per-band GSD differs Compute per band, not per aircraft

The cropped-image row catches a real pipeline bug: if a preprocessing step resized the frames, the pixel pitch must be recomputed from the new width, and using the original sensor width against the new dimensions is a factor-of-two error waiting in the resize step.

Verify the fix worked

The honest check is against the reconstruction, which measures rather than predicts. The orthomosaic’s own pixel size is the realised GSD, and it should agree with the computed value to within the relief of the site.

import rasterio


def assert_gsd_matches_ortho(expected: GSD, ortho_path: str,
                             tol_ratio: float = 0.15) -> None:
    """The delivered orthomosaic's pixel size is the ground truth."""
    with rasterio.open(ortho_path) as ds:
        realised = abs(ds.transform.a)       # metres per pixel, projected CRS
        assert ds.crs.is_projected, "orthomosaic must be in a projected CRS"

    ratio = realised / expected.metres_per_pixel
    assert abs(ratio - 1.0) <= tol_ratio, (
        f"computed GSD {expected.as_cm():.2f} cm vs orthomosaic "
        f"{realised * 100:.2f} cm — ratio {ratio:.2f}. "
        "A ratio near 2.7 is the 35 mm-equivalent focal length; "
        "a ratio above 4 is GPS altitude used as height above ground.")

Naming the two diagnostic ratios in the assertion message is what makes this useful in a log six months later. A ratio of 2.7 and a ratio of 5 are signatures, not noise, and the person reading the failure is unlikely to be the person who wrote the substitution.

Ratio of realised to computed GSD, and what each value means A number line of the ratio between the orthomosaic's realised pixel size and the computed ground sample distance. A narrow band around one is marked as agreement. A cluster near zero point four is labelled as the thirty-five millimetre equivalent focal length having been used, since it makes the computed value too fine. A cluster near five is labelled as GPS altitude used in place of height above ground. A cluster near one point three is labelled as a wrong sensor width. Each signature is a distinct value rather than a spread, which is what makes the ratio diagnostic. 0.4 0.8 1.0 1.3 2.7 5.0 realised ÷ computed agreement computed too coarse sensor width too large wrong sensor width 13.2 assumed for 17.3 35 mm equivalent used computed far too fine GPS altitude as AGL site well above sea level Each mistake lands on its own value, so the ratio names the cause rather than merely reporting disagreement. Record the ratio in the run manifest and a wrong sensor-width entry is caught on the first survey with that camera.

Figure 2 — The ratio is a diagnostic, not just a pass/fail. Three of the common mistakes produce three distinguishable values, and the fourth produces a ratio that varies with the site’s elevation.

It is worth recording the computed value, the realised value and their ratio in the run manifest on every survey rather than only when something looks wrong. The ratio is stable for a given camera and a correct pipeline, so a fleet that logs it accumulates a per-camera baseline — and the first survey where a new airframe, a firmware update or a changed preprocessing step perturbs that baseline announces itself immediately, rather than being discovered when a client measures the deliverable and finds the resolution is not what the quote said.

When to escalate

  • The ratio is stable and near 1.0 across most surveys and wrong on one camera. The sensor-width table entry for that model is wrong. Correct the table rather than the individual survey; every past run with that camera carries the same error.
  • The ratio varies smoothly with site elevation. GPS altitude is being used as height above ground somewhere in the chain — the variation is the site elevation. Find the substitution rather than calibrating around it.
  • The realised GSD is coarser than computed by exactly the orthophoto resolution setting. Nothing is wrong with the GSD: the export was told to write at a coarser resolution than the imagery supports, which is a deliberate setting described in setting up OpenDroneMap with Python.

Calculating Optimal Flight Overlap for Python Processing

GSD varies across a site with relief A cross-section of a site with sixty metres of relief, flown at a constant barometric altitude. Over the valley floor the height above ground is one hundred and twenty metres and the ground sample distance is three point two centimetres. Over the ridge the height above ground is sixty metres and the ground sample distance is one point six centimetres. A note states that a single quoted figure describes neither end, so the honest report is the value at the mean elevation together with the range, and that terrain-following flight is what removes the variation. constant flight altitude valley floor ridge 120 m AGL → 3.2 cm 60 m AGL → 1.6 cm Quote the value at the mean elevation and the range; a single number describes neither end of a site with relief.

Figure 3 — On a site with relief there is no single GSD. The quoted figure is a mean, and the range is what determines whether the coarsest part of the block still meets the survey class.