OSRM Integration for Fleet Map Matching
The Open Source Routing Machine (OSRM) ships two services a telematics pipeline actually needs: /route, which returns the fastest path between waypoints, and /match, which snaps a noisy GPS trace onto the road network using a hidden Markov model internally. For fleet map matching the /match service is the workhorse — it turns a scatter of jittery fixes into an ordered sequence of road segments with a confidence score, a decoded geometry, and per-point tracepoints that tell you exactly where each observation landed. Getting reliable output from it at fleet scale is less about the HTTP call and more about the dataset you build, the search radiuses you send, and how you parse and window the response.
This page walks the full integration: building the OSRM dataset with the multi-level Dijkstra (MLD) pipeline, calling /match with radiuses, timestamps, and annotations from Python, parsing matchings and tracepoints, decoding the polyline6 geometry, and handling the failure modes that only appear once you are pushing millions of daily fixes through the engine. It assumes your trace has already been through GPS preprocessing — projected, de-duplicated, and smoothed — because OSRM will faithfully match garbage to the nearest road if you let it. If you are choosing between engines rather than committing to OSRM, start with choosing a routing engine.
Why Raw HTTP Calls Fail at Fleet Scale
A single curl against a demo server is trivial. A pipeline matching thousands of vehicle-days per hour is where naive integrations fall apart, and the failures are rarely a straight HTTP error:
A global radius drops good fixes and accepts bad ones. The /match service searches for candidate road edges within a radius of each coordinate. Send one constant radius and you simultaneously reject accurate points that fall just outside a too-tight radius and snap high-error points to the wrong parallel road with a too-loose one. Radius must be derived per point from the GPS accuracy of that fix.
Long trips silently exceed max-matching-size. The match plugin caps input at 100 coordinates by default. A full delivery shift is thousands of fixes. Send it whole and you get a TooBig error; naively truncate and you lose the tail of every trip. Windowing with overlap is mandatory, not optional.
Signal gaps force impossible routes. When a vehicle enters a tunnel or a device drops offline, the trace has a spatial jump. Without gaps=split, OSRM tries to connect the two ends with a real route — often a multi-kilometre detour that never happened — and the confidence collapses for the whole matching.
Coordinate order is a silent, hemisphere-scale bug. OSRM URLs are longitude,latitude. Databases, GeoJSON readers, and humans mostly think lat,lon. A swap does not error; it relocates the point to open ocean and returns NoSegment or an absurd match. At fleet scale this shows up as a mysterious few percent of trips with zero confidence.
One connection per request throttles throughput. Opening a fresh TCP connection for every trip caps you well below what the engine can serve. A pooled, retrying HTTP client is required to keep the osrm-routed process saturated — covered in depth in self-hosting OSRM with Docker for fleet-scale map matching.
Prerequisites
OSRM backend. osrm-backend v5.27 or later, run from the official Docker image ghcr.io/project-osrm/osrm-backend. The command sequence below and the polyline6 default geometry are stable across the 5.2x series.
Vehicle profile. A Lua profile matching your fleet: the bundled car.lua for vans and cars, truck.lua (or a customized profile enforcing weight/height/HGV road restrictions) for heavy freight. The profile is baked in at extract time and cannot be changed without re-extracting.
Python environment. Python 3.10+, httpx ≥ 0.27 (or requests ≥ 2.31) for the HTTP calls, polyline ≥ 2.0 for decoding, and numpy/pandas for handling the trace. httpx is preferred because its connection pooling and per-request timeouts map cleanly onto a fleet workload.
A preprocessed trace. OSRM matches what you give it. Run coordinate reference system mapping so you can reason about metric radiuses, Kalman filtering for GPS noise reduction to suppress jitter and produce a per-point covariance you can turn into a radius, and timestamp synchronization so the timestamps array is monotonic.
Building the OSRM Dataset
OSRM cannot answer any request until it has processed an OpenStreetMap extract into its own graph format. There are two contraction algorithms — Contraction Hierarchies (CH) and multi-level Dijkstra (MLD). Use MLD for map matching: it supports fast per-request customization and is the pipeline the match plugin is tuned against. The dataset build and the running server must agree on the algorithm.
Download a regional .osm.pbf extract (a country or metro area, not the planet, unless you have the RAM), then run the three-stage MLD pipeline. These are shell commands, run once per data refresh:
# 1. Extract: apply the vehicle profile to the OSM data
osrm-extract -p /opt/car.lua region-latest.osm.pbf
# 2. Partition: build the multi-level cell hierarchy (MLD only)
osrm-partition region-latest.osrm
# 3. Customize: compute cell edge weights (MLD only)
osrm-customize region-latest.osrm
# 4. Serve with the MLD algorithm
osrm-routed --algorithm mld --max-matching-size 500 region-latest.osrm
Two flags matter for fleet matching. --algorithm mld must match the osrm-partition/osrm-customize build; if you built with osrm-contract (CH) instead, the server refuses to start. --max-matching-size raises the 100-coordinate default ceiling — set it to the largest window you intend to send, but be aware that larger windows use more memory and CPU per request. The full containerized version of this sequence, including RAM sizing per region, is in the self-hosting guide.
Step-by-Step Workflow
Step 1 — Prepare the trace as an ordered coordinate array
OSRM consumes a single trip at a time: coordinates in longitude,latitude order, sorted by time, de-duplicated. Duplicate consecutive fixes (a parked vehicle emitting the same position) waste the coordinate budget and can confuse the HMM transition model.
import pandas as pd
import numpy as np
def prepare_trip(df: pd.DataFrame) -> pd.DataFrame:
"""
Order and de-duplicate a single vehicle trip for OSRM /match.
Expects columns: utc_ts (datetime), lon, lat, and optionally hdop.
Returns a frame sorted by time with consecutive duplicate fixes removed.
"""
df = df.sort_values("utc_ts").reset_index(drop=True)
# Drop consecutive fixes closer than ~1 m — parked-vehicle repeats
moved = (
df[["lon", "lat"]].diff().abs().sum(axis=1) > 1e-5
)
moved.iloc[0] = True # always keep the first fix
return df[moved].reset_index(drop=True)
Expected output shape: the same columns, fewer rows, guaranteed monotonic utc_ts. Keep the original row index around if you need to write matched positions back onto every raw fix later.
Step 2 — Derive per-point radiuses from GPS accuracy
The single most impactful parameter is the radiuses array. Each entry is the search radius in metres for the corresponding coordinate. A fix with HDOP 1 deserves a tight radius; one at HDOP 6 in an urban canyon needs a wide one. If you have run a Kalman filter, 3 * sqrt(P[0,0]) (a 3-sigma position uncertainty) is a principled radius; otherwise scale a base error by HDOP.
def radiuses_from_hdop(hdop: np.ndarray, base_m: float = 8.0,
floor_m: float = 4.0, ceil_m: float = 50.0) -> list:
"""
Map per-fix HDOP to an OSRM search radius in metres.
base_m : radius at HDOP = 1 (a good consumer fix)
floor_m: never search tighter than this (OSRM ignores sub-metre radii)
ceil_m : cap the radius so a bad fix cannot snap across a wide junction
"""
r = np.clip(base_m * np.nan_to_num(hdop, nan=1.0), floor_m, ceil_m)
return [round(float(v), 1) for v in r]
Why the cap matters: an uncapped radius lets a 200 m outlier grab a road two blocks away, corrupting the matching. The ceil_m bound trades a possible NoSegment (recoverable) for a guaranteed-wrong snap (silent and unrecoverable).
Step 3 — Build and send the /match request
Assemble the coordinate string, attach radiuses, timestamps, annotations=true (for per-segment nodes/durations), overview=full, geometries=polyline6, and gaps=split. tidy=true lets OSRM pre-simplify a dense noisy trace before matching, which often improves results on high-frequency data.
import httpx
class OSRMMatchClient:
"""Thin OSRM /match wrapper over a pooled httpx client."""
def __init__(self, base_url: str = "http://localhost:5000",
profile: str = "driving", timeout_s: float = 10.0):
self.base_url = base_url.rstrip("/")
self.profile = profile
self._client = httpx.Client(
timeout=timeout_s,
limits=httpx.Limits(max_connections=32, max_keepalive_connections=16),
)
def match(self, lonlat: list, timestamps: list, radiuses: list,
tidy: bool = True) -> dict:
"""
Call /match for one trip.
lonlat : list of (lon, lat) tuples, time-ordered
timestamps : list of UNIX epoch seconds, one per coordinate
radiuses : list of search radii in metres, one per coordinate
"""
coords = ";".join(f"{lon:.6f},{lat:.6f}" for lon, lat in lonlat)
url = f"{self.base_url}/match/v1/{self.profile}/{coords}"
params = {
"geometries": "polyline6", # 6-digit precision geometry
"overview": "full", # full-resolution matched line
"annotations": "true", # nodes, durations, distances per leg
"gaps": "split", # break the trace at large gaps
"tidy": "true" if tidy else "false",
"radiuses": ";".join(str(r) for r in radiuses),
"timestamps": ";".join(str(int(t)) for t in timestamps),
}
resp = self._client.get(url, params=params)
resp.raise_for_status()
return resp.json()
Expected response shape: a JSON object with code == "Ok", a matchings list (one entry per continuous matched sub-trace — more than one when gaps=split fractures the trace), and a tracepoints list the same length as your input coordinates, where each entry is either null (an unmatched fix) or an object carrying matchings_index, waypoint_index, location, and alternatives_count.
Step 4 — Parse matchings, tracepoints, and confidence
Each matching carries a confidence in [0, 1] and a geometry. The tracepoints tie every original fix back to a position on the matched line, which is what you store per raw ping.
import polyline
def parse_match(result: dict) -> dict:
"""
Flatten an OSRM /match response into storable records.
Returns a dict with:
matchings : list of {confidence, coords, distance_m, duration_s}
tracepoints : list aligned to input fixes; None where unmatched
"""
if result.get("code") != "Ok":
raise ValueError(f"OSRM match failed: {result.get('code')}")
matchings = []
for m in result["matchings"]:
# polyline6 -> [(lat, lon), ...]; OSRM encodes lat,lon in the polyline
latlon = polyline.decode(m["geometry"], precision=6)
coords = [(lon, lat) for lat, lon in latlon] # store as (lon, lat)
matchings.append({
"confidence": m.get("confidence", 0.0),
"coords": coords,
"distance_m": m.get("distance", 0.0),
"duration_s": m.get("duration", 0.0),
})
tracepoints = []
for tp in result["tracepoints"]:
if tp is None:
tracepoints.append(None) # this fix matched nothing
else:
lon, lat = tp["location"] # already [lon, lat]
tracepoints.append({
"matchings_index": tp["matchings_index"],
"waypoint_index": tp["waypoint_index"],
"snapped_lon": lon,
"snapped_lat": lat,
"alternatives": tp.get("alternatives_count", 0),
})
return {"matchings": matchings, "tracepoints": tracepoints}
The polyline6 gotcha. OSRM’s geometry field is encoded with the Google polyline algorithm at precision 6, and the algorithm encodes (latitude, longitude) pairs — the reverse of the (lon, lat) order OSRM uses everywhere else. Decode with precision=6 (precision 5 yields coordinates off by a factor of ten) and swap the axes on the way out if your storage convention is lon,lat. Tracepoint location fields, by contrast, are already [lon, lat].
Step 5 — Window long trips with overlap
Because max-matching-size caps a request, split any trip longer than your window into overlapping chunks. Overlap lets consecutive windows agree on the boundary road and prevents a seam artefact where two windows snap the same junction to different edges.
def window_indices(n: int, size: int = 100, overlap: int = 10):
"""Yield (start, end) slices of length <= size with `overlap` shared fixes."""
if n <= size:
yield (0, n)
return
start = 0
step = size - overlap
while start < n:
yield (start, min(start + size, n))
if start + size >= n:
break
start += step
Stitch the per-window matchings back together on the shared overlap, preferring the window with the higher confidence at the seam. Deduplicate the geometry across the overlap so segment distances are not double-counted downstream.
Radiuses Tuning from GPS Accuracy
The radiuses parameter is where domain knowledge enters the match. Treat it as the operational encoding of your receiver’s error distribution:
| Fix quality | Typical HDOP | Suggested radius | Rationale |
|---|---|---|---|
| Open-sky highway | 0.8 – 1.5 | 4 – 8 m | Tight radius prevents snapping to a parallel frontage road |
| Suburban / mixed | 1.5 – 3.0 | 8 – 20 m | Absorbs moderate multipath without over-reaching |
| Urban canyon | 3.0 – 6.0 | 20 – 40 m | Wide enough to reach the true road past reflected signal |
| Post-tunnel reacquisition | > 6.0 | 40 – 50 m (capped) | Bad fix; cap prevents a cross-junction wrong snap |
The failure symmetry is the point: too small and OSRM returns NoSegment for good fixes; too large and it confidently snaps to the wrong road. Because a wrong snap is silent and a NoSegment is loud, bias slightly toward the smaller radius and let the error surface. When you have a Kalman posterior, prefer 3 * sqrt(P[0,0]) over the HDOP heuristic — it already fuses the kinematic model with measurement noise.
Routing Engine Integration Notes
/match vs /route. Use /match to reconstruct where a vehicle went from observed fixes; use /route to compute where it should go between known waypoints (planned-vs-actual comparison, ETA, detour detection). They share the coordinate-order and profile conventions but differ in output: /route has no tracepoints or confidence.
Coordinate order, once more. Every OSRM URL is longitude,latitude. This is the same convention as Valhalla and GraphHopper, so a pipeline that normalizes to [lon, lat] once can target any of the three engines. Confirm axis order with a single known point before a bulk run.
OSRM as the HMM emission backend. OSRM’s internal matcher is a hidden Markov model, but you can also use OSRM purely as a nearest-edge and routing oracle behind your own decoder. That is exactly what building an HMM-based map matcher with OSRM and Python does — calling /route for transition probabilities while running the Viterbi decoder yourself. Use the built-in /match when you want speed and simplicity; run your own HMM when you need custom emission or transition models.
Feeding downstream stages. The decoded geometry and per-fix snapped positions feed speed profiling from raw GPS coordinates — matched positions give cleaner segment speeds than raw fixes — and the tracepoint sequence feeds stop detection with road-referenced coordinates.
Operational Troubleshooting
NoSegment: a coordinate has no routable edge nearby
Cause: No edge in the loaded profile lies within the coordinate’s search radius — a radius too small for the fix’s error, a lon/lat swap that relocated the point, or a profile that excludes the road class.
Symptom: code: "Ok" overall but one or more tracepoints entries are null; or code: "NoSegment" for the whole request when the first/last point is unroutable.
Fix: Widen that point’s radius (HDOP-scaled, capped). Verify coordinate order is longitude,latitude. Confirm the profile includes the vehicle’s road classes — a truck.lua profile will refuse a road tagged hgv=no, correctly leaving the fix unmatched.
NoMatch: the trace cannot be snapped as a coherent route
Cause: The overall sequence is too noisy or too sparse for the HMM to find a plausible path — large gaps without gaps=split, wildly inconsistent radiuses, or a trace that teleports back and forth.
Symptom: code: "NoMatch" with no matchings.
Fix: Set gaps=split so breaks fracture the trace instead of forcing an impossible connection. Enable tidy=true to pre-simplify dense noisy input. Re-run upstream outlier removal — a single 500 m spike can make an entire window unmatchable.
Radius too small: good fixes silently dropped
Cause: A global radius tuned for open-sky data rejects urban-canyon fixes whose true road sits just outside the radius.
Symptom: A run of null tracepoints concentrated in dense areas; confidence otherwise fine.
Fix: Move from a global constant to per-point radiuses derived from HDOP or Kalman covariance. Raise the base radius and the ceiling for known urban routes. Log the null-tracepoint rate per area to catch systematic under-reach.
TooBig: request exceeds max-matching-size
Cause: The coordinate count exceeds the server’s max-matching-size (default 100).
Symptom: code: "TooBig".
Fix: Window the trip with window_indices and stitch on the overlap. Alternatively raise --max-matching-size at server start, weighing the extra per-request memory and latency. Very long shifts should be windowed regardless — a single giant matching is harder to debug and reprocess.
Tunnel gap forces a phantom detour
Cause: A signal gap leaves two trace segments separated in space; without gaps=split OSRM routes between them as if the vehicle drove the connecting roads.
Symptom: One matching with a suspiciously long distance_m, low confidence, and a geometry that visibly detours through unrelated streets.
Fix: Always send gaps=split. For gaps you want bridged deliberately, interpolate the tunnel gap upstream and let OSRM match the interpolated points, rather than letting the engine invent a route.
tidy vs non-tidy changes the tracepoint alignment
Cause: With tidy=true, OSRM removes redundant coordinates before matching, so the returned tracepoints array no longer maps one-to-one onto your input in the way you might assume.
Symptom: Off-by-index errors when writing snapped positions back onto raw fixes after enabling tidy.
Fix: Always align by the tracepoint’s waypoint_index and matchings_index rather than positional order. When you need an exact one-to-one mapping to raw fixes, use tidy=false and accept the extra matching cost, or re-associate tidied points by nearest timestamp.
Deployment Checklist
Parent topic: Routing Engine Integration for Fleet Telematics
Related
- Self-Hosting OSRM with Docker for Fleet-Scale Map Matching — the containerized dataset build plus a pooled, batched, retrying match client
- Building an HMM-based Map Matcher with OSRM and Python — using OSRM as a routing oracle behind your own Viterbi decoder
- Choosing a Routing Engine: OSRM vs Valhalla vs GraphHopper — trade-offs before committing a pipeline to OSRM
- Kalman Filtering for GPS Noise Reduction — produces the per-point covariance that yields principled match radiuses
- Speed Profiling from Raw GPS Coordinates — consumes the matched geometry for cleaner segment-level speeds