How to Parse OpenDRIVE XML with Python

This guide walks through parsing an ASAM OpenDRIVE (.xodr) file with Python at the ingestion stage of an HD mapping pipeline, where multi-kilometre corridor tiles must be streamed under a fixed RAM ceiling before road geometry and lane topology are handed to spatial indexing.

OpenDRIVE encodes dense geometric primitives, multi-layered lane topology, and complex junction connectivity inside a deeply nested XML hierarchy. Tree-based deserialization (xml.etree.ElementTree, xmltodict) routinely exhausts heap on tiles above ~50 MB, triggering GC thrashing that destabilises downstream path planning. The workflow below uses lxml.etree.iterparse for constant-memory streaming, pins validation to the official ASAM XSD, and reconstructs reference-line geometry to a stated tolerance.

Constant-memory streaming parse: process each road, then release it before the next:

Constant-memory OpenDRIVE streaming-parse pipeline A multi-gigabyte .xodr file is streamed road-by-road with lxml.iterparse. Each road's attributes are extracted, then the element is cleared and its preceding siblings purged before looping to the next road. When no roads remain, the run passes through an XSD validation gate, reference-line geometry evaluation, lane topology and junction adjacency extraction, and is handed off to the spatial index. .xodr tile multi-GB corridor, >50 MB lxml.iterparse events=("end",) · tag="road" Extract attributes id · length · lanes · geometry elem.clear() purge preceding siblings More roads? XSD validation isolated worker Geometry evaluation line · arc · spiral · poly3 Lane topology + junction adjacency Spatial index handoff yes next road no

Prerequisites #

  • Python 3.10+ (the type hints below use X | Y union syntax and tuple[...] generics).
  • lxml 4.9+ — pip install "lxml>=4.9". The streaming parser and XMLSchema validator both come from lxml.etree; the stdlib xml.etree does not support sibling purging during iterparse.
  • numpy 1.24+ for vectorized geometry evaluation; scipy 1.10+ if you extend the clothoid handler with scipy.special.fresnel.
  • ASAM OpenDRIVE XSD, version-matched to your data. Pin the schema file (e.g. OpenDRIVE_1.7.0.xsd) to your pipeline release; do not auto-download at parse time.
  • Input assumption: a well-formed .xodr whose root is <OpenDRIVE> with a <header revMajor revMinor> declaring the version. This step runs after tiles land in object storage and before lane-level topology modeling builds the routing graph. Reference-line coordinates are reprojected later by the coordinate reference systems for AVs stage, so parsing here stays in the file's native local frame.

Step-by-Step #

1. Stream <road> elements with a constant-memory loop #

The standard ElementTree implementation loads the entire document into memory. Use lxml.etree.iterparse with events=("end",) so each <road> fires only once fully populated, then explicitly release the node and purge its preceding siblings to keep the live tree small.

python
from lxml import etree
from typing import Iterator, Any

def stream_opendrive_roads(filepath: str) -> Iterator[dict[str, Any]]:
    # tag="road" → callback fires once per fully-closed <road> element
    context = etree.iterparse(filepath, events=("end",), tag="road")
    for _, elem in context:
        road = {
            "id": elem.get("id"),
            "length": float(elem.get("length", 0.0)),
            "junction": elem.get("junction", "-1"),  # "-1" == not in a junction
            "geometries": _extract_planview(elem),
            "lane_sections": _extract_lane_sections(elem),
        }
        yield road
        elem.clear()                       # drop this node's children/text
        while elem.getprevious() is not None:
            del elem.getparent()[0]        # purge already-processed siblings

Key parameters: tag="road" restricts callbacks to road elements (junctions and the header are skipped at the C level, not in Python). The elem.clear() plus sibling-purge pair is what holds memory flat — without it lxml retains the whole left side of the tree. Expected output: a generator yielding one dict per road, in document order, each carrying id, length, junction, and the nested geometry/lane payloads extracted below.

2. Validate against the ASAM XSD before trusting topology #

OpenDRIVE changed structurally between 1.4 and 1.8 (lane-offset interpolation, junction priority, elevation sampling). Parsing without OpenDRIVE schema validation risks silent topology corruption. Run validation in an isolated subprocess so a malformed tile cannot stall the main ingestion worker.

python
from lxml import etree

def validate_opendrive(filepath: str, xsd_path: str) -> tuple[bool, str]:
    schema = etree.XMLSchema(etree.parse(xsd_path))
    try:
        schema.assertValid(etree.parse(filepath))
        return True, ""
    except etree.DocumentInvalid:
        # error_log carries precise line/column refs for quarantine routing
        return False, str(schema.error_log)

Pin xsd_path to the version declared in the file's <header> — validating a 1.7 file against the 1.4 schema produces false negatives on <laneOffset> ordering. Expected output: (True, "") on a clean tile, or (False, "<line:col: message>") whose log you route to a quarantine queue.

3. Evaluate reference-line geometry primitives #

OpenDRIVE reference lines are parametric: line, arc, spiral (clothoid), poly3, and paramPoly3, each anchored at an (s, x, y, hdg) offset. Evaluate every primitive at the parameter s to emit dense waypoints. Arcs and lines are closed-form; clothoids need a Fresnel approximation.

python
import numpy as np

def evaluate_geometry(s: float, geom_type: str, p: dict[str, float]) -> np.ndarray:
    """Return local (x, y) at arc-length s within a single primitive."""
    if geom_type == "line":
        return np.array([s, 0.0])
    if geom_type == "arc":
        k = p["curvature"]                 # signed: + left, - right
        return np.array([np.sin(k * s) / k, (1.0 - np.cos(k * s)) / k])
    if geom_type == "paramPoly3":
        u = p["aU"] + p["bU"]*s + p["cU"]*s**2 + p["dU"]*s**3
        v = p["aV"] + p["bV"]*s + p["cV"]*s**2 + p["dV"]*s**3
        return np.array([u, v])
    raise NotImplementedError(f"Unsupported geometry type: {geom_type}")

After evaluating in primitive-local coordinates, rotate by the primitive's hdg and translate by (x, y) to place the point in the road frame. Accumulate hdg consistently across segments — dropping a heading offset introduces cumulative lateral drift. Expected output: an (N, 2) array of road-frame waypoints whose spacing you control by sampling s at a fixed step (e.g. 0.5 m).

4. Walk lane sections into a topology payload #

Lane IDs reset at every <laneSection>, and <connection> / <predecessor> / <successor> tags define allowable manoeuvres. Resolve each laneID to a globally unique key and tag junction transitions by elementType so the downstream graph builder never emits an invalid routing edge.

python
def _extract_lane_sections(road_elem) -> list[dict[str, Any]]:
    sections = []
    for ls in road_elem.iterfind(".//laneSection"):
        s0 = float(ls.get("s", 0.0))
        lanes = []
        for lane in ls.iterfind(".//lane"):
            lanes.append({
                "id": int(lane.get("id")),        # 0 = reference line; +left / -right
                "type": lane.get("type", "driving"),
                "widths": [
                    {k: float(w.get(k, 0.0)) for k in ("sOffset", "a", "b", "c", "d")}
                    for w in lane.iterfind("width")
                ],
            })
        sections.append({"s": s0, "lanes": lanes})
    return sections

The width polynomial coefficients a, b, c, d are evaluated per lane against the section-local offset sOffset when boundaries are generated later. Expected output: an ordered list of lane sections, each with its start s and a list of typed, width-parametrised lanes.

Verification & Acceptance Criteria #

Confirm the parse succeeded with explicit assertions, not eyeballing:

python
def acceptance_checks(roads: list[dict[str, Any]]) -> None:
    assert roads, "no <road> elements parsed — check tag filter / namespaces"
    for r in roads:
        assert r["id"] is not None, "road missing id attribute"
        assert r["length"] > 0.0, f"road {r['id']} has non-positive length"
        # reference-line reconstruction must close to within tolerance
        wp = reconstruct_reference_line(r["geometries"])
        residual = abs(arc_length(wp) - r["length"])
        assert residual <= 0.05, f"road {r['id']} length residual {residual:.3f} m > 0.05 m"

Acceptance thresholds: reconstructed reference-line arc length must match the declared road@length within ≤0.05 m RMSE; lateral centerline error against the source primitives must stay ≤0.1 m. Track peak RSS with tracemalloc and assert it stays flat across roads — a monotonic climb means elem.clear() is not releasing C-level buffers (see below). A clean run logs one accepted record per road with zero quarantine routes.

Common Errors & Fixes #

XPath / iterfind returns empty despite visible tags — OEM extensions inject an unprefixed default namespace, so iterfind("road") silently matches nothing. Strip namespaces during the stream, or query with the wildcard:

python
for road in root.iterfind("{*}road"):   # {*} matches any namespace URI
    ...

Memory climbs linearly across a large tileelem.clear() alone is insufficient; lxml retains preceding siblings under the root. The while elem.getprevious() is not None: del elem.getparent()[0] loop in step 1 is mandatory. Verify with tracemalloc.get_traced_memory() sampled every 100 roads — the high-water mark should plateau.

lxml.etree.DocumentInvalid on a structurally fine file — almost always a version mismatch: the file declares revMajor="1" revMinor="7" but you validated against the 1.4 XSD. Read the <header> first and select the matching schema; do not hard-code one XSD.

KeyError: 'curvature' in evaluate_geometry — a <geometry> block contained a <spiral> or <poly3> child you have not implemented yet, so its parameter dict lacks curvature. Dispatch on the child tag name, not on a guessed type string, and raise NotImplementedError with the offending road id for targeted triage.

FAQ #

Why use lxml instead of the standard library xml.etree? #

xml.etree.iterparse cannot delete preceding siblings during iteration, so memory still grows with document size. lxml exposes getprevious() / getparent() for true streaming and ships a fast C-backed XMLSchema validator, which the stdlib lacks entirely.

How large an OpenDRIVE file can this approach handle? #

Memory is bounded by the largest single <road> element, not the file, so multi-gigabyte tiles parse within a fixed ceiling (typically well under 500 MB working set per worker). For throughput, partition the file by road and fan out across a concurrent.futures.ProcessPoolExecutor to avoid GIL contention.

Do I need to validate every tile against the XSD in production? #

Validate on ingestion and on any schema-version change; it is the cheapest gate against silent topology corruption. For re-processing already-accepted tiles you can skip validation, but keep the version check on the <header> so a mismatched XSD never reaches the geometry stage.

Where does the parsed output go next? #

The geometry waypoints and lane-section payload feed reprojection and graph construction. Reference-line coordinates are converted out of the file's local frame during the coordinate-system stage, and lane adjacency is consumed by the topology graph that drives routing and planning.

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