Building an Image Pair Graph from GPS Positions
Feature matching over every pair of a large survey is not slow, it is infeasible — fifty million comparisons for ten thousand images. The single most effective reduction uses information the survey already carries: two frames taken far apart cannot overlap, so their pair never needs testing.
Doing that correctly takes more than a distance threshold. The positions are in degrees and the threshold is in metres, the right radius depends on the altitude and the sensor, terrain relief changes the footprint, and a radius that is slightly too small fragments the reconstruction in a way that looks like a matching failure. This page covers each of those, as the implementation detail behind matching strategies for large and linear surveys.
Working in a metric frame
EXIF positions are latitude and longitude in degrees, and a degree is not a distance. A degree of longitude is 111 km at the equator and 71 km at 50° north, so a threshold in degrees is a different threshold at every latitude and a different one in each axis.
Projecting to a local metric frame before doing any geometry removes the problem entirely and costs one transformation. An east-north-up frame centred on the survey is the natural choice: distances are metres, the axes are orthogonal, and nothing about the arithmetic depends on where the site is.
import numpy as np
from pyproj import CRS, Transformer
def to_local_enu(lat: np.ndarray, lon: np.ndarray, alt: np.ndarray) -> dict:
"""Project geographic positions into a local metric frame.
A transverse Mercator centred on the survey keeps distortion under a part
per million across any site a drone can fly, so distances in the frame
are metres to well within the accuracy anything downstream needs.
"""
lat0, lon0 = float(np.mean(lat)), float(np.mean(lon))
local = CRS.from_proj4(
f"+proj=tmerc +lat_0={lat0} +lon_0={lon0} +k=1 +x_0=0 +y_0=0 "
"+ellps=WGS84 +units=m +no_defs")
transformer = Transformer.from_crs(CRS.from_epsg(4326), local, always_xy=True)
east, north = transformer.transform(lon, lat)
return {"enu": np.column_stack([east, north, alt]),
"origin": (lat0, lon0), "crs": local.to_proj4()}
Deriving the radius from the geometry
The radius should be the distance beyond which two frames cannot share ground, which follows from the sensor and the altitude rather than from experience.
import numpy as np
def footprint_width(altitude_m: float, sensor_width_mm: float,
focal_mm: float) -> float:
"""Ground width covered by one frame, from the sensor geometry."""
return altitude_m * sensor_width_mm / max(focal_mm, 1e-9)
def pair_radius(altitude_m: float, sensor_width_mm: float, focal_mm: float,
*, relief_m: float = 0.0, margin: float = 1.15) -> dict:
"""Radius within which two frames may overlap.
Relief matters in the direction that surprises people: ground closer to
the aircraft has a smaller footprint, so a hilltop reduces the overlap
and the radius must be computed at the lowest altitude above ground
anywhere in the survey rather than at the nominal one.
"""
effective_altitude = max(altitude_m - relief_m, 1.0)
width = footprint_width(effective_altitude, sensor_width_mm, focal_mm)
return {"footprint_m": width, "radius_m": width * margin,
"effective_altitude_m": effective_altitude,
"note": ("relief reduces the footprint on high ground, so the radius is "
"computed there" if relief_m > 0 else "flat-terrain assumption")}
Computing at the lowest height above ground is the conservative choice and the right one. A survey flown at 100 m over a site with a 40 m hill has a footprint on the hilltop that is only 60 % of the one in the valley, and a radius derived from the nominal altitude will exclude genuine overlaps exactly where the terrain is most interesting.
Figure 1 — Why the radius is computed at the lowest height above ground rather than the nominal altitude.
Building the graph
import numpy as np
from scipy.spatial import cKDTree
def build_pair_graph(enu: np.ndarray, radius_m: float,
*, sequential_window: int = 4,
max_pairs_per_image: int = 400) -> dict:
"""Candidate pairs from position, plus capture-order neighbours.
Capping per image rather than globally keeps the graph even: a global cap
truncated in index order would leave the end of a survey unmatched, which
is a failure mode that produces a reconstruction missing its last lines.
"""
tree = cKDTree(enu[:, :2])
pairs = set()
for i in range(len(enu)):
neighbours = tree.query_ball_point(enu[i, :2], radius_m)
neighbours = [j for j in neighbours if j != i]
if len(neighbours) > max_pairs_per_image:
distances = np.linalg.norm(enu[neighbours, :2] - enu[i, :2], axis=1)
keep = np.argsort(distances)[:max_pairs_per_image]
neighbours = [neighbours[k] for k in keep]
for j in neighbours:
pairs.add((min(i, j), max(i, j)))
for i in range(len(enu)):
for j in range(i + 1, min(i + sequential_window + 1, len(enu))):
pairs.add((i, j))
return {"pairs": sorted(pairs), "count": len(pairs),
"mean_per_image": 2 * len(pairs) / max(len(enu), 1)}
Figure 3 — Three pair types, and only the first is found by accident.
Edge-case matrix
| Situation | Effect | Handling |
|---|---|---|
| Threshold in degrees | Different radius per latitude and axis | Project to a metric frame |
| Nominal altitude used with relief | Pairs missing on high ground | Compute at the lowest height above ground |
| Missing positions on some frames | Those frames get no spatial pairs | Sequential window covers them |
| Wrong positions | Wrong pairs both ways | Sanity-check positions first |
| Very dense capture | Hundreds of neighbours per image | Cap per image, nearest first |
| Two flights merged | Spurious long-distance pairs | Split by time gap before pairing |
| Variable altitude survey | One radius is wrong everywhere | Per-image radius from each frame’s altitude |
| Oblique orbit around a structure | Positions close, views opposed | Add appearance candidates |
The oblique-orbit row is a genuine limitation of position-based pairing. Two frames on opposite sides of a building are metres apart and share no ground at all, while two frames on the same side are further apart and overlap completely. Position alone cannot tell them apart, and the remedy is appearance-based candidates or gimbal-attitude filtering.
import numpy as np
def filter_by_view_direction(enu: np.ndarray, yaw_deg: np.ndarray,
pairs: list, *, max_angle_deg: float = 100.0) -> list:
"""Drop pairs whose cameras were pointing in incompatible directions.
Two frames a few metres apart looking in opposite directions share no
ground, and testing the pair wastes matching time and occasionally
produces a confident wrong match on a repetitive facade.
"""
yaw = np.radians(np.asarray(yaw_deg, dtype=float))
direction = np.column_stack([np.cos(yaw), np.sin(yaw)])
limit = np.cos(np.radians(max_angle_deg))
return [(i, j) for i, j in pairs
if float(direction[i] @ direction[j]) > limit]
Verification snippet
import networkx as nx
import numpy as np
def verify_pair_graph(pairs: list, n_images: int,
*, min_median_degree: int = 8) -> dict:
"""Connectivity and redundancy checks before any matching runs."""
graph = nx.Graph()
graph.add_nodes_from(range(n_images))
graph.add_edges_from(pairs)
components = list(nx.connected_components(graph))
degrees = np.array([d for _, d in graph.degree()])
problems = []
if len(components) > 1:
sizes = sorted((len(c) for c in components), reverse=True)
problems.append(f"{len(components)} components, largest {sizes[0]} of {n_images} "
"— the reconstruction will fragment")
if float(np.median(degrees)) < min_median_degree:
problems.append(f"median degree {np.median(degrees):.0f} is below "
f"{min_median_degree} — single match failures will disconnect images")
isolated = int((degrees == 0).sum())
if isolated:
problems.append(f"{isolated} images have no candidate pairs at all")
return {"components": len(components), "median_degree": float(np.median(degrees)),
"min_degree": int(degrees.min()), "isolated": isolated,
"problems": problems, "ready": not problems}
Running this before matching converts hours of wasted compute into a message. A disconnected graph will always produce a fragmented reconstruction, and it takes a second to detect and minutes to fix by widening the radius.
Figure 2 — Where to choose the radius, measured on the survey rather than assumed.
Splitting merged flights before pairing
A directory containing two flights produces spatial pairs between them wherever they overlap, which is usually correct, and sequential pairs across the join, which is not: the last frame of one flight and the first of the next are adjacent in index order and may be kilometres apart.
import numpy as np
def split_by_time_gap(timestamps_s: np.ndarray, *, gap_s: float = 600.0) -> list[dict]:
"""Group frames into flights by looking for long gaps in capture time."""
order = np.argsort(timestamps_s)
t = np.asarray(timestamps_s, dtype=float)[order]
breaks = np.flatnonzero(np.diff(t) > gap_s) + 1
groups = np.split(order, breaks)
return [{"flight": i, "indices": g.tolist(),
"duration_min": float((t[breaks[i - 1] if i else 0] - t[0]) / 60)}
for i, g in enumerate(groups)]
Sequential pairing should then run within each flight rather than across the whole directory, which is a one-line change and removes a small population of nonsensical pairs that occasionally produce a confident wrong match.
When to escalate
- The graph is disconnected at every radius. Coverage has a genuine gap — the survey missed an area, or a section was flown at a different altitude. Widening will not bridge it.
- Positions are present and the graph still fragments. Check the positions first; a hemisphere or datum error produces exactly this.
- The survey has no positions. Fall back to appearance-based pairing, covered in using vocabulary trees for large dataset matching.