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:
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.
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.
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 #
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:
Verification & Acceptance Criteria #
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:
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.
Related #
- Converting OpenDRIVE to Lanelet2 with Python — the forward conversion this table governs.
- Round-Tripping HD Map Formats Without Losing Attributes — the gate that measures what the derivations cost.
- Building Lane Successor Graphs from OpenDRIVE — the connectivity model the derivable rows depend on.
Up one level: Map Format Interoperability: OpenDRIVE, Lanelet2 & NDS.Live — the stage this correspondence table serves.