Geometric Nearest-Edge Snapping in Python

This page extends the snapper sketched in choosing a map-matching algorithm into a self-contained, copy-paste class you can drop into a preprocessing job. The specific edge case it addresses is the one that makes naive snapping usable in practice: a single nearest-edge query flickers between parallel roads and jumps across junctions, so this implementation retrieves several candidates per ping and disciplines the choice with a perpendicular-distance gate, an optional heading gate, and a one-step continuity check. It is still a stateless geometric method — it has no memory of connectivity and no probabilities — so it remains the right tool only where roads are well-separated or sampling is dense enough to keep matches unambiguous.

Nearest-edge snapping with distance and heading gates A GPS ping sits between two parallel road edges. The geometrically nearest edge disagrees with the vehicle heading and is rejected by the heading gate; the second candidate, aligned with travel direction and inside the distance gate, is accepted as the snapped match. Edge A (nearest, wrong heading) Edge B (aligned with travel, accepted) GPS ping perp dist to A: gated by heading perp dist to B: within max_snap vehicle heading

Compatibility and Configuration Requirements

Requirement Minimum version / value Notes
Python 3.10 Type hints and structural pattern used in examples
shapely 2.0 STRtree.query_nearest and vectorised predicates require the 2.x API; 1.x is unsupported
geopandas 0.14 Ships Shapely 2.x backend; provides the road-network GeoDataFrame and CRS handling
numpy 1.24 Vectorised bearing arithmetic
Coordinate reference system Metric (local UTM zone) Pings and edges must share a projected CRS; degrees silently corrupt distances
Edge geometry LineString per road segment MultiLineString edges must be exploded to single parts before indexing

The class assumes the road network is already loaded as a projected GeoDataFrame of LineString edges — typically fetched with osmnx and reprojected. Both the pings and the edges must be in the same metric CRS.

The Snapper Class

import numpy as np
from shapely import STRtree
from shapely.geometry import Point, LineString
import geopandas as gpd
from typing import Optional


class NearestEdgeSnapper:
    """
    Stateless geometric map matcher: snap GPS pings to the nearest road edge.

    Retrieves the k nearest candidate edges per ping and selects the best one
    that passes a perpendicular-distance gate and an optional heading gate,
    then flags matches that jump implausibly far from the previous match.

    Parameters
    ----------
    edges : geopandas.GeoDataFrame
        Road segments with a LineString geometry column, in a metric CRS.
    max_snap_distance_m : float
        Reject any candidate whose perpendicular distance to the ping exceeds
        this many metres. Set to roughly 3x the GPS error std dev: ~25-40 m for
        consumer trackers, tighter for RTK. Too small drops valid matches near
        wide roads; too large lets pings snap to unrelated nearby streets.
    candidate_k : int
        Number of nearest edges retrieved per ping. k=1 is pure nearest-edge
        (fast, but no heading disambiguation). k=4-6 lets the heading gate pick
        the correctly-oriented edge on parallel roads. Larger k costs query time.
    heading_gate_deg : float or None
        If a ping heading is supplied, reject candidates whose edge bearing
        differs from it by more than this many degrees (mod 180, so direction of
        digitisation does not matter). None disables the gate. 45 deg is a safe
        default; tighten to 30 for well-mapped urban grids.
    max_jump_m : float
        Continuity check: flag (do not drop) a match whose snapped point is more
        than this far from the previous ping's snapped point. Surfaces junction
        jumps for review. Scale with sampling interval and speed limit.
    """

    def __init__(
        self,
        edges: gpd.GeoDataFrame,
        max_snap_distance_m: float = 30.0,
        candidate_k: int = 5,
        heading_gate_deg: Optional[float] = 45.0,
        max_jump_m: float = 400.0,
    ):
        if edges.crs is None or edges.crs.is_geographic:
            raise ValueError(
                "edges must be in a projected metric CRS (e.g. a UTM zone); "
                "snapping on geographic degrees corrupts distances."
            )
        self.edges = edges.reset_index(drop=True)
        self.geoms = self.edges.geometry.values
        self.tree = STRtree(self.geoms)          # built once; reuse per trace
        self.max_snap_distance_m = max_snap_distance_m
        self.candidate_k = candidate_k
        self.heading_gate_deg = heading_gate_deg
        self.max_jump_m = max_jump_m

    @staticmethod
    def _edge_bearing_deg(edge: LineString, along: Point) -> float:
        """Local bearing of the edge near the projected point, in [0, 360)."""
        s = edge.project(along)                  # arc-length of nearest point
        a = edge.interpolate(max(s - 1.0, 0.0))
        b = edge.interpolate(min(s + 1.0, edge.length))
        ang = np.degrees(np.arctan2(b.x - a.x, b.y - a.y))
        return ang % 360.0

    @staticmethod
    def _heading_diff_mod180(a: float, b: float) -> float:
        """Smallest angular difference ignoring travel direction, in [0, 90]."""
        d = abs(a - b) % 180.0
        return min(d, 180.0 - d)

    def snap_point(
        self,
        x: float,
        y: float,
        heading_deg: Optional[float] = None,
        prev_xy: Optional[tuple] = None,
    ) -> dict:
        """
        Snap a single ping. Returns a dict with the chosen edge index, snapped
        coordinates, perpendicular distance, jump distance, and a suspect flag.
        edge_i is None when no candidate passes the gates.
        """
        pt = Point(x, y)
        # query_nearest returns candidate edge indices, nearest first
        idx = self.tree.query_nearest(pt, all_matches=True)
        idx = np.atleast_1d(idx)[: self.candidate_k]

        best = None
        for edge_i in idx:
            edge = self.geoms[int(edge_i)]
            perp_m = pt.distance(edge)
            if perp_m > self.max_snap_distance_m:
                continue
            proj = edge.interpolate(edge.project(pt))
            if heading_deg is not None and self.heading_gate_deg is not None:
                bearing = self._edge_bearing_deg(edge, proj)
                if self._heading_diff_mod180(bearing, heading_deg) > self.heading_gate_deg:
                    continue
            if best is None or perp_m < best["perp_m"]:
                best = {"edge_i": int(edge_i), "snap_x": proj.x,
                        "snap_y": proj.y, "perp_m": float(perp_m)}

        if best is None:
            return {"edge_i": None, "snap_x": None, "snap_y": None,
                    "perp_m": None, "jump_m": None, "suspect": True}

        jump_m = 0.0
        if prev_xy is not None:
            jump_m = Point(best["snap_x"], best["snap_y"]).distance(Point(prev_xy))
        best["jump_m"] = float(jump_m)
        best["suspect"] = bool(jump_m > self.max_jump_m)
        return best

    def snap_trace(self, pings: np.ndarray, headings: Optional[np.ndarray] = None) -> list:
        """
        Snap an ordered trace. pings is shape (N, 2) of [x, y] in the edge CRS;
        headings is an optional (N,) array of bearings in degrees. Returns a list
        of per-ping result dicts in input order.
        """
        results = []
        prev_xy = None
        for i in range(len(pings)):
            hd = None if headings is None else float(headings[i])
            res = self.snap_point(pings[i, 0], pings[i, 1], heading_deg=hd, prev_xy=prev_xy)
            results.append(res)
            if res["edge_i"] is not None:
                prev_xy = (res["snap_x"], res["snap_y"])
        return results

Execution and Tuning

Instantiate once against the projected road network, then call snap_trace per trip. Build the STRtree a single time and reuse the instance — reconstructing the index per trace costs roughly 200 ms per 100k edges and dwarfs the query time.

snapper = NearestEdgeSnapper(
    edges_utm,                 # GeoDataFrame in EPSG:326xx
    max_snap_distance_m=30.0,
    candidate_k=5,
    heading_gate_deg=45.0,
)
matched = snapper.snap_trace(pings_xy, headings=ping_bearings)
suspect_rate = sum(m["suspect"] for m in matched) / len(matched)

The knobs, and the effect of moving each:

  • max_snap_distance_m — the hard reject threshold. Raise it when clean pings near wide multi-lane roads are being dropped (edge_i returns None); lower it when pings are snapping to unrelated streets one block over. A value near 3 · σ_z (three times the GPS error standard deviation) is the principled starting point.
  • candidate_k — how many edges the heading gate gets to choose among. At k=1 the heading gate is inert because there is no alternative to fall back on. Raise to 5–6 on networks with parallel carriageways and service roads; each extra candidate adds query and projection cost.
  • heading_gate_deg — how much bearing disagreement is tolerated before an edge is rejected. Tighten toward 30° on well-mapped urban grids to aggressively kill parallel-road flicker; loosen toward 60° where the heading signal is noisy or the road geometry is coarsely digitised. Supply headings from the ping stream or derive them during speed profiling; without a heading the gate is skipped entirely.
  • max_jump_m — the continuity flag. It never drops a match, only marks suspect, so use it as a monitoring signal. Scale it with the sampling interval and the local speed limit: a 30-second gap at 90 km/h legitimately covers 750 m, so a flat 400 m threshold will over-flag sparse traces.

Track suspect_rate per region as your escalation trigger. When it stays high on dense-urban traces despite tuning, the network is too ambiguous for a stateless method and those traces belong on a hidden Markov model matcher.

Common Pitfalls

Parallel-road ambiguity: matches flicker between a carriageway and its service road

The geometrically nearest edge is not always the correct one when two roads run within GPS-error distance of each other. With candidate_k=1 the snapper has no way to recover — it takes whichever edge is momentarily closest. Raise candidate_k to 5–6 and supply headings so the heading gate can reject the edge whose bearing disagrees with the direction of travel. If flicker persists after tightening heading_gate_deg, the ambiguity is structural and the trace should go to an HMM, whose transition probability penalises the physically implausible hop between the two roads.

Junction jumps: the snapped point leaps to a cross street

At intersections several edges meet within a few metres, so a ping with even modest error can snap onto a crossing road, producing a matched point far from the previous one. The max_jump_m continuity check surfaces these as suspect rather than hiding them, but it does not prevent them — a stateless snapper cannot, because it has no memory of the edge the vehicle was just on. Use the flag to quantify how often it happens; a high junction-jump rate is a direct signal that the network needs the connectivity model an HMM provides.

CRS in degrees: max_snap_distance_m silently means nothing

If the GeoDataFrame is still in EPSG:4326, pt.distance(edge) returns a value in decimal degrees, not metres, so max_snap_distance_m=30 is interpreted as 30 degrees — thousands of kilometres — and every candidate passes the gate. The constructor guards against this by rejecting a geographic CRS, but confirm the reprojection actually happened: reproject both pings and edges to the same local UTM zone with to_crs(epsg=...) before constructing the snapper, and sanity-check that a known 100 m span measures as roughly 100 in the projected units.


Up: Choosing a Map-Matching Algorithm: HMM vs Geometric Snapping vs Viterbi | Trajectory Analysis & Map Matching Techniques