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
where is the height above the ground being photographed, is the physical pixel pitch of the sensor, and 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 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.
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.
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.
Related
- Calculating optimal flight overlap for Python processing
- Automating camera intrinsic matrix extraction
- Choosing RMSE thresholds by survey class
← Calculating Optimal Flight Overlap for Python Processing
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.