Converting Lanelet2 Maps to Routing Graphs

Lanelet2 stores a map as OSM-XML lanelets with left and right boundary ways, but it never writes an explicit successor link — connectivity is implied by shared node and way identity. This task recovers that structure and builds the weighted directed routing graph the road-network graph construction workflow specifies: parse lanelets, derive successors from coincident bound nodes, add lane-change edges from shared boundaries, and gate on reachability. It is the Lanelet2 counterpart to building lane successor graphs from OpenDRIVE, and a natural target of map format interoperability.

Lanelets link into a routing graph through shared boundary-node identity, gated on reachability:

Lanelet2 to Routing Graph Conversion OSM-XML box feeds a parse-lanelets box. That feeds two edge-derivation boxes: successors from shared end nodes, and lane changes from shared bounds. Both feed a reachability gate, which outputs a routing graph. Lanelet2 OSM-XML Parse lanelets L/R bounds Successors shared end nodes Lane changes shared bound + marking reach? → graph

Prerequisites #

  • Python 3.10+, lxml 5.x (OSM-XML), NetworkX 3.2+, NumPy 1.24+; optionally the lanelet2 bindings for cross-validation.
  • Input: a Lanelet2 map in OSM-XML with nodes, ways, and lanelet relations, in a local metric or projected CRS.
  • Upstream stage: the map is schema-consistent; boundary ways reference valid node ids.
  • Output: a networkx.DiGraph of drivable lanelets with successor and lane-change edges, validated for reachability.

Step-by-Step #

1. Parse lanelets into bounds keyed by node id #

Read the relations; each lanelet references a left and a right boundary way, and each way is an ordered list of node ids.

python
from lxml import etree

def parse_lanelets(path: str):
    tree = etree.parse(path)
    ways = {w.get("id"): [nd.get("ref") for nd in w.findall("nd")]
            for w in tree.findall("way")}
    lanelets = {}
    for rel in tree.findall("relation"):
        tags = {t.get("k"): t.get("v") for t in rel.findall("tag")}
        if tags.get("type") != "lanelet":
            continue
        mem = {m.get("role"): m.get("ref") for m in rel.findall("member")}
        lanelets[rel.get("id")] = {
            "left": ways[mem["left"]], "right": ways[mem["right"]],
            "subtype": tags.get("subtype", "road"),
        }
    return lanelets, ways

Key parameter: only relations tagged type=lanelet are kept; subtype later filters non-drivable lanelets (crosswalks, etc.).

2. Derive successor edges from shared end nodes #

Two lanelets are successors when the last nodes of the leading lanelet's bounds equal the first nodes of the following lanelet's bounds.

python
import networkx as nx

def add_successors(g: nx.DiGraph, lanelets):
    ends = {lid: (ll["left"][-1], ll["right"][-1]) for lid, ll in lanelets.items()}
    starts = {lid: (ll["left"][0], ll["right"][0]) for lid, ll in lanelets.items()}
    for a, a_end in ends.items():
        for b, b_start in starts.items():
            if a != b and a_end == b_start:      # exact shared boundary nodes
                g.add_edge(a, b, kind="successor")

Key parameter: the match is on node-id tuples, exact and tolerance-free — coincident ids mean a definite link.

3. Add lane-change edges honouring markings #

Two lanelets sharing a boundary way are lateral neighbours; add a change edge only if the shared boundary's marking permits it.

python
def add_lane_changes(g, lanelets, way_marking):
    by_bound = {}
    for lid, ll in lanelets.items():
        for side in ("left", "right"):
            by_bound.setdefault(tuple(ll[side]), []).append(lid)
    for bound, members in by_bound.items():
        if len(members) == 2 and way_marking.get(bound) in ("dashed", "none"):
            a, b = members
            g.add_edge(a, b, kind="lane_change")
            g.add_edge(b, a, kind="lane_change")

Key parameter: only dashed/none markings permit a change; a solid shared boundary adds no lateral edge.

Verification & Acceptance Criteria #

Gate the graph on reachability and rule compliance.

python
def assert_routable(g: nx.DiGraph):
    comps = sorted(nx.weakly_connected_components(g), key=len, reverse=True)
    islands = [c for c in comps[1:] if len(c) < 3]
    assert not islands, f"{len(islands)} unreachable lanelet island(s)"
    assert all("kind" in d for *_, d in g.edges(data=True)), "untyped edge"
    print(f"routable: {g.number_of_nodes()} lanelets, {g.number_of_edges()} edges")

Acceptance gate: every drivable lanelet is in the main connected component; no lane-change edge crosses a solid marking; and non-drivable subtypes are excluded. Islands usually mean a boundary way was edited so its shared nodes diverged — re-check node identity at the seam, mirroring detecting dangling lanes and connectivity gaps.

Common Errors & Fixes #

No successor edges found at all. Bounds were compared by coordinate, not node id, and rounding made them differ. Match on the raw node-id references from the ways, which are exact.

Lane-change edges cross solid lines. The marking lookup was skipped. Gate every lateral edge on the shared boundary's marking type; only dashed/none permit a change.

Sidewalks and crosswalks appear in the routing graph. Non-drivable subtypes were not filtered. Exclude lanelets whose subtype is not a driving type before adding edges.

A lanelet links to two successors through one shared node. A shared start node is ambiguous at a fork; require both left and right end nodes to match, not just one, so a fork produces distinct successor edges.

FAQ #

How does Lanelet2 encode connectivity? #

Lanelet2 does not store explicit successor links. Two lanelets are successors when the end nodes of the leading lanelet's bounds are the same OSM nodes as the start nodes of the following lanelet's bounds. Lane changes are implied when two adjacent lanelets share a boundary way. So connectivity is recovered from shared node and way identity, not from a link table, which is the key difference from OpenDRIVE.

Why derive successors from shared nodes rather than geometry? #

Shared node identity is exact; geometric proximity is a guess. If two lanelets are connected in Lanelet2, their touching bounds reference the identical node ids, so matching by id gives a definitive link with no tolerance to tune. Falling back to coordinate proximity would introduce false links where lanelets pass close without connecting, for example at an overpass, so id matching is both faster and more correct.

Should traffic rules affect the routing graph? #

Yes. Lanelet2 carries a traffic-rules concept that decides whether a lanelet is drivable for a given participant and whether a lane change is allowed across a shared boundary, based on the boundary's line-marking type. The routing graph must honour those rules: a solid line means no lane-change edge, and a lanelet not drivable for the vehicle is excluded. Ignoring the rules produces a graph that routes across solid lines or onto sidewalks.

Up one level: Building Routable Road-Network Graphs from HD Maps — the parent workflow this Lanelet2-specific conversion implements.