Converting OpenDRIVE to Lanelet2 with Python
The two schemas disagree about what a lane is. OpenDRIVE describes a road as a reference line plus signed lateral offsets; Lanelet2 describes a lane as a pair of explicit boundary ways sharing nodes with its neighbours. Converting between them is therefore not a field mapping but a change of representation, and the step everyone underestimates is node deduplication — because in Lanelet2, connectivity is node identity.
This task performs that conversion for map format interoperability, using the classification from mapping OpenDRIVE, Lanelet2 and NDS.Live schemas to decide what is derived and what goes to the sidecar.
The offset construction, which is where the geometry actually changes hands:
Prerequisites #
- Python 3.10+, NumPy 1.24+, SciPy 1.11+, lxml 5.x; an OSM-XML writer for the output.
- Input: an OpenDRIVE file that passes XSD and semantic validation, parsed as in how to parse OpenDRIVE XML with Python.
- Upstream stage: reference-line evaluation, which supplies position and heading at any station.
- Output: a Lanelet2 OSM-XML map plus the sidecar of unmappable fields.
Step-by-Step #
1. Sample the reference line adaptively #
Uniform sampling either bloats the straights or under-samples the corners. Sample by curvature so the chord error is bounded everywhere.
import numpy as np
def adaptive_stations(length: float, curvature, tol_m: float = 0.05,
s_min: float = 0.5, s_max: float = 10.0) -> np.ndarray:
"""Stations whose chord error stays under `tol_m` for the local curvature."""
s, out = 0.0, [0.0]
while s < length:
k = abs(curvature(s))
step = s_max if k < 1e-6 else min(s_max, max(s_min,
2.0 * np.sqrt(2.0 * tol_m / k)))
s = min(length, s + step)
out.append(s)
return np.asarray(out)
Key parameters: tol_m is the chord-error budget, drawn from the conversion tolerance; s_min bounds the file size on hairpins. Expected output: a monotonically increasing station array, dense in corners and sparse on straights.
2. Offset to lane boundaries #
Widths accumulate outward from the reference line, right-hand lanes negative and left-hand positive, exactly as the schema defines them.
def boundary_points(ref_xy: np.ndarray, ref_hdg: np.ndarray,
widths: dict[int, np.ndarray]) -> dict[int, np.ndarray]:
"""Boundary polylines keyed by the lane id whose outer edge they form."""
nx, ny = -np.sin(ref_hdg), np.cos(ref_hdg) # left-hand normal
out, offset = {}, np.zeros(len(ref_xy))
for lane_id in sorted((i for i in widths if i > 0)): # left lanes
offset = offset + widths[lane_id]
out[lane_id] = ref_xy + np.column_stack((nx, ny)) * offset[:, None]
offset = np.zeros(len(ref_xy))
for lane_id in sorted((i for i in widths if i < 0), reverse=True): # right
offset = offset - widths[lane_id]
out[lane_id] = ref_xy + np.column_stack((nx, ny)) * offset[:, None]
return out
The reference line itself becomes way 0, shared by lane +1 and lane −1. Getting the sign convention wrong here mirrors the map about its own centreline, which is easy to miss because the result still looks like a road.
3. Deduplicate shared nodes #
This is the step that creates connectivity. Vertices closer than the snap tolerance become one node object.
from scipy.spatial import cKDTree
def dedupe_nodes(all_points: np.ndarray, snap_m: float = 0.01):
"""Collapse coincident vertices into shared nodes; return ids per vertex."""
tree = cKDTree(all_points)
pairs = tree.query_pairs(snap_m, output_type="ndarray")
node_of = np.arange(len(all_points))
for i, j in pairs: # union by lowest index
a, b = node_of[i], node_of[j]
if a != b:
node_of[node_of == max(a, b)] = min(a, b)
unique = {old: new for new, old in enumerate(sorted(set(node_of.tolist())))}
return np.array([unique[n] for n in node_of])
snap_m at 0.01 m is deliberately far tighter than the map's positional tolerance: these vertices were produced by the same sampler from the same station, so genuine coincidence is exact to floating point. A loose snap merges lanes that merely pass close, which is the error detecting dangling lanes and connectivity gaps exists to catch.
What a too-loose and a too-tight snap each produce:
4. Emit regulatory elements #
Speed limits and marking types are direct fields, but they land in Lanelet2 as regulatory elements and way tags rather than as lane attributes.
def regulatory_elements(lane) -> list[dict]:
out = []
if lane.speed_limit_kph is not None:
out.append({"type": "regulatory_element", "subtype": "speed_limit",
"sign_type": "de205", "value": f"{lane.speed_limit_kph} km/h"})
return out
def way_tags(road_mark) -> dict:
return {"type": "line_thin" if road_mark.width < 0.2 else "line_thick",
"subtype": {"solid": "solid", "broken": "dashed",
"solid solid": "solid_solid"}[road_mark.type]}
Marking subtype is per side of the shared way, which matters for the asymmetric solid-and-dashed case discussed in converting Lanelet2 maps to routing graphs.
Verification & Acceptance Criteria #
def assert_conversion(od_map, ll2_map, tol_m=0.05) -> None:
assert ll2_map.validates(), "output is not a valid Lanelet2 map"
for lane in od_map.driving_lanes:
ll = ll2_map.by_identity(lane.identity)
assert ll is not None, f"lane {lane.identity} missing from output"
d = max_centreline_deviation(lane, ll)
assert d <= tol_m, f"lane {lane.identity} deviates {d:.3f} m"
orphan = [l for l in ll2_map.lanelets if not l.successors and not l.boundary]
assert not orphan, f"{len(orphan)} lanelet(s) with no connectivity"
Acceptance gate: the output validates as Lanelet2; every driving lane present with ≤0.05 m maximum centreline deviation; zero interior lanelets without connectivity; and the node count strictly less than the vertex count, which is the cheap proof that deduplication ran at all.
Chord error against sampling interval, which is where the adaptive sampler gets its step from:
Common Errors & Fixes #
The map renders correctly and routes nowhere. Node deduplication did not merge anything. Assert len(nodes) < len(vertices) and check the snap tolerance against the sampler's output precision.
Two adjacent lanes have become one. The snap is too loose. Tighten it toward the sampler's numerical precision rather than toward the map's positional tolerance — they are different quantities.
The map is mirrored about its centreline. The lane-id sign convention was applied to the wrong normal. Left-hand lanes take positive offsets along the left-hand normal; verify against a lane whose type you know.
Corners are visibly polygonal. Sampling is uniform and too coarse for the tightest radius. Switch to the adaptive stations of step 1.
Superelevation and objects are gone. They are unmappable, and this converter's job is to route them to the sidecar rather than to invent a Lanelet2 encoding for them.
FAQ #
What sampling interval should the reference line use? #
Fine enough that the chord error of the tightest arc in the map stays inside the conversion tolerance. For a 0.05 metre budget and a minimum radius of 10 metres that is roughly a 1.4 metre interval; on a motorway with a 400 metre minimum radius the same budget allows nearly 9 metres. Sampling adaptively by curvature rather than uniformly keeps file size down without giving up the tight corners, which is where the error actually lives.
Why does node deduplication matter so much? #
Because Lanelet2 expresses adjacency and continuity through shared node objects rather than through explicit links. Two lanelets are neighbours because they literally share a boundary way, and consecutive because they share the nodes at the join. A converter that emits geometrically coincident but distinct nodes produces a map that looks correct in a viewer and has no connectivity at all — every routing query returns nothing.
How are OpenDRIVE junctions represented in Lanelet2? #
As ordinary lanelets that happen to overlap in space, plus regulatory elements expressing right of way. There is no junction container: each laneLink entry in the OpenDRIVE junction becomes a connecting lanelet whose start nodes are shared with the incoming lane and whose end nodes are shared with the outgoing one. The many-to-many structure survives as shared node identity, which is why the deduplication step has to run across junction geometry too.
Related #
- Mapping OpenDRIVE, Lanelet2 and NDS.Live Schemas — the field classification this converter follows.
- Round-Tripping HD Map Formats Without Losing Attributes — measuring what this conversion costs.
- Converting Lanelet2 Maps to Routing Graphs — the consumer that depends on the shared nodes this step creates.
Up one level: Map Format Interoperability: OpenDRIVE, Lanelet2 & NDS.Live — the stage this conversion implements.