OpenDRIVE Schema Breakdown: Extraction Pipeline for HD Map Geometry

OpenDRIVE (the ASAM .xodr exchange format) is the canonical carrier for road-network geometry, lane topology, and static environment attributes in autonomous driving stacks. This page scopes one sub-problem of the parent HD Mapping Architecture & Spatial Data Standards pipeline: deterministically translating the nested OpenDRIVE XML tree into queryable spatial primitives that downstream localization, perception, and planning can consume. The hard requirements are quantitative — reference-line sampling must hold lateral chord error ≤0.05 m, segment-boundary heading must stay continuous to ≤0.5°, and the emitted lane graph must contain zero orphaned references and zero cycles. Anything looser propagates centimeter-scale drift into GNSS/IMU fusion and silently corrupts route generation.

The nested OpenDRIVE element hierarchy a parser must traverse:

OpenDRIVE element hierarchy A top-down tree of the OpenDRIVE XML schema a parser must traverse. The root element splits into header, road, and junction. The road element expands into planView (holding geometry primitives line, arc, spiral, poly3), elevationProfile, lanes (laneSection containing lane, which holds a link with predecessor and successor), and objects and signals. The junction element holds connection elements mapping incoming to connecting roads. Geometry, link, and connection are highlighted as the leaf primitives the extractor consumes.

The five-stage extraction pipeline that consumes this hierarchy:

Five-stage OpenDRIVE extraction pipeline A left-to-right pipeline of five stages. Stage one is streaming ingest using iterparse with a namespace strip. Stage two is parametric curve evaluation, sampling s-intervals and projecting into a metric CRS. Stage three resolves the lane hierarchy into a directed acyclic graph from predecessor and successor links. Stage four performs object and signal semantic enrichment. Stage five is a validation gate that runs XSD and topology checks: a pass branch serializes the result to a protobuf or binary tile, and a fail branch rejects the file for orphaned references or cycles.

Parser strategy comparison #

Three parsing strategies are viable for OpenDRIVE; the right choice is dictated by file size and whether you need random access during extraction. Naive ElementTree DOM parsing is acceptable only for small fixtures and unit tests.

Strategy Peak RAM (per 500 MB tile) Random access Use-case fit
xml.etree.ElementTree (DOM) ~6–10× file size (3–5 GB) Full XPath Test fixtures, small corridor tiles ≤20 MB
lxml.etree.iterparse (streaming) <150 MB, constant None — single forward pass Production batch ingest, fleet-scale tiling
xmlschema / SAX with XSD bind ~2–3× file size Schema-bound objects Strict-conformance audits where every attribute is validated

For production tile servers the streaming approach is the only one that holds a bounded memory ceiling. The implementation details — the elem.clear() sibling-purge pattern and the isolated XSD worker — are covered in depth in parsing OpenDRIVE XML with Python. The compact tiles the strategy below emits are the unit of change tracked by HD map version control, so the parser must be deterministic: the same .xodr input must always produce a byte-identical tile.

Stage-by-stage implementation walkthrough #

Stage 1 — Streaming ingestion & namespace resolution #

Constraint: hold peak RAM under ~150 MB regardless of tile size, and strip the ASAM namespace before any tag match. Raw .xodr exports for dense metropolitan grids routinely exceed 500 MB, so DOM parsing is off the table. Drive lxml.etree.iterparse() on end events filtered to road and junction, extract the subtree, then clear() it and purge preceding siblings so the live set never grows. Namespace resolution must happen upfront so downstream tag matches stay literal.

python
from lxml import etree

def stream_roads(xodr_path):
    """Yield each <road> subtree, holding constant memory."""
    context = etree.iterparse(xodr_path, events=("end",), tag=("road", "junction"))
    for _event, elem in context:
        yield elem
        # Release the processed node and any preceding siblings.
        elem.clear()
        while elem.getprevious() is not None:
            del elem.getparent()[0]
    del context

If the source uses a namespaced root ({http://www.asam.net/...}road), pass the qualified tag or run a one-pass prefix strip before iterating, so the tag= filter resolves.

Stage 2 — Parametric curve evaluation & CRS projection #

Constraint: the road reference line is a sequence of parametric primitives — line, arc, spiral (clothoid), and poly3. Each <geometry> node carries an s (longitudinal) offset, x/y start, and hdg (heading). Sample each primitive at a fixed s-interval (0.25–1.0 m) into dense (x, y, heading) tuples, then project immediately into one metric CRS. Correct handling of coordinate reference systems for AVs is non-negotiable: an unhandled UTM zone transition or inconsistent datum shift injects centimeter-scale drift that later corrupts sensor fusion and spatial data alignment.

python
import numpy as np
from pyproj import Transformer

def eval_arc(x0, y0, hdg, length, curvature, ds=0.5):
    """Sample a constant-curvature arc into (x, y, heading) tuples."""
    s = np.arange(0.0, length + 1e-9, ds)
    if abs(curvature) < 1e-12:                       # degenerate → straight line
        x = x0 + s * np.cos(hdg)
        y = y0 + s * np.sin(hdg)
        return np.column_stack([x, y, np.full_like(s, hdg)])
    radius = 1.0 / curvature
    theta = hdg + curvature * s
    x = x0 + radius * (np.sin(theta) - np.sin(hdg))
    y = y0 - radius * (np.cos(theta) - np.cos(hdg))
    return np.column_stack([x, y, theta])

# Project the sampled centerline into the working metric CRS (UTM 32N here).
to_utm = Transformer.from_crs("EPSG:4326", "EPSG:32632", always_xy=True)

The clothoid (spiral) case requires a Fresnel-integral evaluation rather than a closed form; sample it more densely (toward 0.25 m) because curvature varies linearly along s.

Stage 3 — Lane hierarchy resolution & directed graph construction #

Constraint: every <lane> inside a <laneSection> carries id, type, level, and connectivity via <link> (<predecessor>, <successor>). Compile these into a directed acyclic graph whose nodes are lane segments and whose edges encode permissible transitions, merges, and splits. That graph feeds lane-level topology modeling for route-graph generation and cost assignment.

python
import networkx as nx

def build_lane_graph(roads):
    g = nx.DiGraph()
    for road in roads:
        rid = road.get("id")
        for sec in road.iter("laneSection"):
            s0 = float(sec.get("s"))
            for lane in sec.iter("lane"):
                node = (rid, s0, int(lane.get("id")))
                g.add_node(node, type=lane.get("type"), level=lane.get("level"))
                link = lane.find("link")
                if link is not None:
                    succ = link.find("successor")
                    if succ is not None:
                        g.add_edge(node, (rid, s0, int(succ.get("id"))))
    assert nx.is_directed_acyclic_graph(g), "lane graph contains a cycle"
    return g

The assert is the topology gate: a cycle in the lane DAG means the export is malformed and must be rejected before serialization.

Stage 4 — Object & signal integration & semantic enrichment #

Constraint: <object> and <signal> elements encode traffic-control devices, barriers, poles, and crosswalks as bounding boxes, parametric shapes, or reference paths. Each carries an s/t placement relative to the road reference line, not absolute coordinates, so attachment must run after Stage 2 has resolved the centerline into metric space. Attach each element to the nearest valid lane segment by spatial proximity and heading alignment, then map OpenDRIVE type enumerations to a unified ontology so perception and prediction receive consistent constraints regardless of the source vendor. Resolving trafficLight and stopSign semantics into machine-readable state requires cross-referencing the ASAM specification against local regulatory tables. A normalization layer here is what makes maps from different vendors interchangeable downstream.

python
import numpy as np

def attach_signal(signal, road_id, centerline, lane_index):
    """Place an (s, t) signal onto the nearest lane segment of one road."""
    s = float(signal.get("s"))
    t = float(signal.get("t"))                       # signed lateral offset
    # Locate the centerline sample nearest this s-station.
    i = int(np.searchsorted(centerline["s"], s))
    x, y, hdg = centerline["xy_hdg"][min(i, len(centerline["s"]) - 1)]
    # Lateral offset along the road normal (heading + 90°).
    px = x + t * np.cos(hdg + np.pi / 2.0)
    py = y + t * np.sin(hdg + np.pi / 2.0)
    return {
        "id": signal.get("id"),
        "road": road_id,
        "lane": lane_index.nearest(road_id, s, t),   # snap to valid lane id
        "type": ONTOLOGY[signal.get("type"), signal.get("subtype")],
        "xy": (px, py),
    }

The ONTOLOGY lookup is the vendor-normalization layer: it collapses each (type, subtype) pair from the source export into one canonical enum the runtime understands, so a trafficLight from one supplier and a 1000001 code from another resolve to the same state machine.

Stage 5 — Validation, serialization & downstream handoff #

Constraint: verify XSD compliance, detect orphaned references, and flag topological inconsistencies (disconnected lane graphs, overlapping geometry), then serialize the validated primitives, graph, and semantics into a compact binary/protobuf tile for low-latency onboard access. This bridges offline compilation and runtime, and keeps the pipeline deterministic and auditable for automotive safety review.

python
from lxml import etree

def validate_xsd(xodr_path, xsd_path):
    schema = etree.XMLSchema(etree.parse(xsd_path))
    doc = etree.parse(xodr_path)
    schema.assertValid(doc)        # raises DocumentInvalid with line numbers

Validation & QC automation #

Each gate emits a pass/fail with a concrete number so failures are reproducible in CI rather than judged by eye.

  • XSD conformance: XMLSchema.assertValid must pass against the declared revMajor.revMinor schema. Pin the XSD per OpenDRIVE version (1.4 → 1.8); do not validate a 1.8 file against a 1.4 schema.
  • Reference-line fidelity: lateral chord error between consecutive samples ≤0.05 m RMSE; heading discontinuity at segment joins ≤0.5°; s-coordinate strictly monotonic increasing.
  • Lane-graph integrity: zero orphaned predecessor/successor references; zero cycles (nx.is_directed_acyclic_graph true); s-boundaries aligned across adjacent sections to ≤1e-6 m.
  • Geometry overlap: zero self-intersections in any single road reference line (shapely.LineString.is_simple true).

Wire these as a pytest suite that runs on every map-compilation commit and fails the build on the first violated threshold.

Edge cases & failure patterns #

  • XSD namespace drift across versions. OpenDRIVE 1.6+ qualifies the root with the ASAM namespace; a tag="road" filter silently matches nothing and the parser yields an empty map. Detect by asserting at least one <road> was yielded.
  • Clothoid under-sampling. A fixed 1.0 m ds on a tight spiral (entry/exit ramps) blows past the 0.05 m chord-error gate. Switch to curvature-adaptive sampling on spiral and poly3 primitives.
  • Floating-point precision loss in raw UTM. Easting values near 5e5 with sub-cm geometry lose precision in IEEE 754 doubles. Subtract a per-tile local origin before sampling, store the offset in the tile header.
  • Orphaned junction connections. A <connection> whose connectingRoad was filtered out (e.g. by a bounding-box crop) leaves dangling edges. Reject, or drop the edge and log, depending on your fallback policy.

Performance & scale notes #

  • Memory ceiling: the streaming parser holds <150 MB peak on 500 MB tiles; the lane graph and sampled centerlines dominate the residual footprint, not the XML.
  • Batch concurrency: tiles are independent — fan out across a process pool (one worker per .xodr). Run XSD validation in an isolated worker so a malformed file's exception cannot abort the batch.
  • Serialization: emit protobuf rather than re-serialized XML; a metropolitan tile compresses roughly 8–12× and loads into the runtime without re-parsing curves.
  • Caching: memoize per-segment transformation matrices and geoid offsets so repeated compilations over an unchanged corridor skip projection work.

Frequently asked questions #

Why use lxml.iterparse instead of DOM parsing for OpenDRIVE files? A DOM materialises the entire tree (3–5 GB for a 500 MB tile) and exhausts the heap. iterparse processes one <road> at a time and clears it, holding a constant footprint under ~150 MB.

What s-interval should the reference-line evaluator sample at? 0.25–1.0 m. Tighten toward 0.25 m on clothoid spirals and intersection geometry to hold chord error ≤0.05 m; coarsen toward 1.0 m on long straight segments to control point count.

What triggers pipeline rejection during lane-graph construction? Orphaned predecessor/successor references, cycles in the lane DAG, and laneId/s-boundary misalignment across adjacent lane sections.

FAQ #

Why use lxml.iterparse instead of DOM parsing for OpenDRIVE files? #

Dense metropolitan .xodr exports routinely exceed 500 MB. A DOM materialises the whole tree and exhausts the heap, while iterparse processes one at a time and clears it, holding a constant RAM footprint independent of tile size.

What s-interval should the reference-line evaluator sample at? #

0.25–1.0 m. Tighten toward 0.25 m on tight clothoid spirals and intersection geometry to keep lateral chord error under 0.05 m; coarsen toward 1.0 m on long straight line segments to control point count.

What causes pipeline rejection during lane-graph construction? #

Orphaned predecessor/successor references that resolve to no valid segment, cycles in the lane DAG, and laneId or s-coordinate boundaries that fail to align across adjacent lane sections.

Up one level: HD Mapping Architecture & Spatial Data Standards.