Mapping OpenDRIVE, Lanelet2 and NDS.Live Schemas

Every conversion project starts with a correspondence table and most of them start with the wrong one, because the interesting entries are not the fields that match. They are the fields that nearly match — where a plausible copy loses meaning — and the fields with no counterpart, which quietly disappear unless somebody has decided in advance where they go.

This reference builds that table for map format interoperability: a three-way classification of every field as direct, derivable or unmappable, and a sidecar convention that makes the unmappable third survive a round trip.

The classification, with the honest proportions:

Direct, Derivable and Unmappable Fields in One Conversion A single proportional bar split into direct, derivable and unmappable segments, each expanded below into the fields it contains and the handling it requires. direct derivable unmappable copy as-is · lane width at a station · speed limit · coarse lane type no tolerance — equality is the acceptance test compute, then check · reference line → boundary ways · link records → shared nodes · marking side and subtype each carries a tolerance; the round-trip gate measures it sidecar or lose · elevation + superelevation records · object and signal records · vendor extensions the target cannot express these; silence here is the usual bug the proportions shift with the pair, but the third bucket is never empty for any pair of these three schemas a converter with no sidecar has decided the third bucket is empty without checking

Prerequisites #

  • Python 3.10+, lxml 5.x for OpenDRIVE, lanelet2 or an OSM-XML reader for Lanelet2.
  • Input: a source map that already validates against its own schema — conversion is not a repair mechanism.
  • Upstream stage: schema validation, as in validating OpenDRIVE files against the XSD schema.
  • Output: a target map plus a sidecar of unmappable fields keyed by stable identity.

The Correspondence Table #

The three schemas describe the same road with different primitives. What follows is the correspondence at the level that actually matters — the lane and its boundary.

Concept OpenDRIVE Lanelet2 NDS.Live Class
Lane centre geometry Derived from reference line + offsets Derived from bounding ways Explicit polyline derivable
Lane boundary <laneSection> width records Explicit left/right ways Explicit boundary geometry derivable
Longitudinal continuity <link> predecessor/successor Shared node identity Explicit successor references derivable
Lateral adjacency Lane id ordering within a section Shared boundary way Explicit neighbour references derivable
Lane width <width> polynomial in s Implied by the two ways Attribute at station derivable
Speed limit <speed> record Regulatory element Attribute direct
Marking type <roadMark> type/subtype on the way Attribute direct
Elevation profile <elevationProfile> Z on nodes Attribute derivable
Superelevation <superelevation> record unmappable
Junction connection matrix <junction>/<laneLink> Implicit in shared nodes Explicit connections derivable
Objects and signals <objects>, <signals> Regulatory elements (partial) Feature layers (partial) partly unmappable

The last column is the one to act on. Anything marked derivable needs a tolerance; anything marked unmappable needs a sidecar entry or an explicit, documented decision to drop it.

Step-by-Step #

1. Classify every field, and make the classification total #

The failure mode this prevents is a field that is silently in no bucket.

python
DIRECT = {"speed_limit", "road_mark_type", "lane_type"}
DERIVABLE = {"geometry", "width", "predecessor", "successor",
             "left_neighbour", "right_neighbour", "elevation"}
UNMAPPABLE = {"superelevation", "objects", "signals", "user_data"}

def classify(field: str) -> str:
    if field in DIRECT:
        return "direct"
    if field in DERIVABLE:
        return "derivable"
    if field in UNMAPPABLE:
        return "unmappable"
    raise KeyError(f"{field!r} is unclassified — decide before converting")

Raising rather than defaulting is the point: a new field appearing in a source map is a decision for a person, and a converter that guesses will guess "drop it". Expected output: one of three strings, or an exception that stops the run.

2. Derive rather than approximate #

A derivable field is computed in the target's own terms, not copied across and hoped over.

python
import numpy as np

def lane_width_at(left_way: np.ndarray, right_way: np.ndarray,
                  stations: np.ndarray) -> np.ndarray:
    """Lanelet2 has no width field; derive it from the two boundary ways."""
    from scipy.spatial import cKDTree
    tree = cKDTree(right_way)
    d, _ = tree.query(left_way)
    return np.interp(stations, np.linspace(0, 1, len(d)) * stations[-1], d)

The tolerance on this derivation is the sampling interval of the two ways: a coarse way pair returns a width that is right on average and wrong at any given station. Record the interval alongside the result, because the round-trip gate needs it to interpret its own residual.

3. Sidecar what the target cannot express #

python
import json

def sidecar_entry(feature_id: str, source_schema: str,
                  unmappable: dict) -> dict:
    return {
        "id": feature_id,
        "source_schema": source_schema,
        "fields": unmappable,
    }

def write_sidecar(path, entries: list[dict]) -> None:
    path.write_text(json.dumps(sorted(entries, key=lambda e: e["id"]),
                               sort_keys=True, indent=None))

Key parameter: feature_id must be the identity that survives the conversion — normally the lane identity established in converting OpenDRIVE to Lanelet2 with Python, not a schema-local numeric id, which is renumbered on the way through.

How the three artefacts travel together, and what happens to each on the way back:

Conformant Target Plus Sidecar Round-Tripping Losslessly A forward conversion producing a conformant target and a sidecar, and a reverse conversion recombining them, with the fields that would be lost if the sidecar were absent listed separately. OpenDRIVE source validated, complete Lanelet2 target conformant · direct + derived sidecar unmappable, keyed by lane identity reverse output within the derivation tolerance without the sidecar the reverse output loses, silently: superelevation · objects · signals · every vendor extension both outputs ship together

Verification & Acceptance Criteria #

python
def assert_correspondence_total(source_fields, sidecar, target) -> None:
    for f in source_fields:
        classify(f)                       # raises on anything unclassified

    unmapped = {f for f in source_fields if classify(f) == "unmappable"}
    carried = {k for e in sidecar for k in e["fields"]}
    assert unmapped <= carried, f"lost without trace: {sorted(unmapped - carried)}"

    assert target.validates(), "target is not conformant to its own schema"

Acceptance gate: every source field classified; every unmappable field present in the sidecar; the emitted target validates against the unextended standard schema; and the reverse conversion reproduces direct fields exactly and derived fields within the recorded tolerance.

Where each schema keeps the same fact, which is why a field-by-field table is the wrong mental model:

Where Each Schema Keeps the Same Fact Four rows pairing a road fact with where each schema stores it and whether all three can express it. the same fact, stored three different ways lane continuity one fact, three encodings link records · shared nodes · successor refs all three lane width one fact, three encodings width polynomial · gap between ways · station attribute all three marking type one fact, three encodings roadMark · way subtype · attribute all three superelevation one fact, one encoding a first-class record — and nothing in the other two OpenDRIVE only a field-by-field table implies a correspondence; the last row is why the classification has to allow "no counterpart"

Common Errors & Fixes #

Superelevation vanishes on the way to Lanelet2 and back. It is unmappable, and without a sidecar entry there is nowhere for it to go. Add it to UNMAPPABLE and to the sidecar; do not encode it as a Lanelet2 tag, which makes the target non-conformant.

Lane identity is renumbered, so the sidecar no longer resolves. The sidecar was keyed on a schema-local id. Key it on the stable identity the conversion establishes, and assert the key set matches on both sides.

A new vendor extension appears and is dropped. classify defaulted instead of raising. Restore the raise; an unclassified field should stop the run.

Round-trip geometry is off by more than the tolerance. The derivation used a coarser sampling than the gate assumes. Record the sampling interval with the output and feed it into the gate rather than hard-coding a number.

FAQ #

Which fields genuinely map one-to-one across all three schemas? #

Fewer than most conversion projects assume: lane width at a station, speed limit, and the coarse lane type. Almost everything else is either represented differently — geometry as parametric curves against explicit boundary ways — or exists in one schema and not another. Treating the overlap as larger than it is produces converters that appear to work and quietly drop the fields nobody tested.

What is a derivable field? #

One the target can express but not in the source's form, so it must be computed rather than copied. OpenDRIVE's signed lane offsets against a reference line are derivable into Lanelet2 boundary ways by sampling; Lanelet2's shared node identity is derivable into OpenDRIVE predecessor and successor links by matching endpoints. The distinction matters because a derivation has a tolerance and a copy does not, and that tolerance is what the round-trip gate measures.

Why keep a sidecar instead of extending the target schema? #

Because an extended target stops being the target: a consumer that validates against the standard schema will reject it, which is the whole reason for converting. A sidecar keyed by stable identity keeps the emitted map conformant while preserving what the conversion could not express, so the reverse direction is lossless and the forward output is still a valid file of its declared type.

Up one level: Map Format Interoperability: OpenDRIVE, Lanelet2 & NDS.Live — the stage this correspondence table serves.