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:
The five-stage extraction pipeline that consumes this hierarchy:
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.
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.
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.
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.
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.
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.assertValidmust pass against the declaredrevMajor.revMinorschema. 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_graphtrue);s-boundaries aligned across adjacent sections to ≤1e-6 m. - Geometry overlap: zero self-intersections in any single road reference line (
shapely.LineString.is_simpletrue).
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
dson a tight spiral (entry/exit ramps) blows past the 0.05 m chord-error gate. Switch to curvature-adaptive sampling onspiralandpoly3primitives. - 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>whoseconnectingRoadwas 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
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.
Related #
- How to parse OpenDRIVE XML with Python — the streaming-parse, sibling-purge, and isolated XSD-worker implementation in full.
- Coordinate Reference Systems Engineering Workflow for AV Pipelines — UTM zone resolution and datum handling for Stage 2 projection.
- Lane-Level Topology Modeling — consumes the lane DAG built in Stage 3 for route-graph generation.
- HD Map Version Control: Deterministic Spatial Pipelines for Fleet-Scale Deployment — versioning the serialized tiles this pipeline emits.
- Sensor Fusion & Spatial Data Alignment for HD Mapping Pipelines — the downstream consumer whose accuracy depends on Stage 2 CRS consistency.
Up one level: HD Mapping Architecture & Spatial Data Standards.