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:

Accumulating Signed Lane Widths into Explicit Boundary Ways A reference line with normals at sampled stations, showing accumulated lane widths placing boundary positions that become Lanelet2 ways. reference line (s) +w(+1) s −w(−1) −w(−1)−w(−2) way Away B way Cway D lanelet(+1) = ways A and B · lanelet(−1) = ways B and C · lanelet(−2) = ways C and D way B belongs to two lanelets, which is exactly how Lanelet2 expresses lateral adjacency

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.

python
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.

python
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.

python
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:

Three Snap Tolerances and the Connectivity Each Produces Three panels showing over-merged, correct and under-merged node deduplication with the routing consequence of each. two lanes fused shared where truly coincident nothing merges snap 0.5 m snap 0.01 m snap 0.0001 m lane changes legal everywhere adjacency and continuity correct no connectivity at all the first and third both render as a plausible map in a viewer, which is why this step needs its own assertion rather than an eyeball

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.

python
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 #

python
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:

Chord Error Against Sampling Interval at Three Radii Three rows pairing a curve radius with the chord error at two sampling intervals and the interval a 0.05 metre budget allows. chord error = interval squared over eight times the radius radius 10 m · a hairpin tightest urban geometry 0.025 m at 1.4 m · 0.31 m at 5 m budget allows ~2.0 m radius 50 m · an urban bend the common case 0.005 m at 1.4 m · 0.062 m at 5 m budget allows ~4.5 m radius 400 m · a motorway curve gentlest geometry 0.001 m at 1.4 m · 0.008 m at 5 m budget allows ~8.9 m a uniform interval must satisfy the tightest radius in the file, so it over-samples every straight by a factor of four

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.

Up one level: Map Format Interoperability: OpenDRIVE, Lanelet2 & NDS.Live — the stage this conversion implements.