Validating OpenDRIVE Files Against the XSD Schema
A malformed OpenDRIVE file that reaches the planner is a safety defect, so validation belongs at the front of every ingest path — but XSD validation alone is only half the job. The schema catches structural errors; it cannot see a road link that points nowhere or a geometry record whose arc length runs backwards. This task compiles the ASAM XSD with lxml, validates structure, layers the semantic checks the schema cannot express, and wires both into a CI gate that fails the build on any violation. It is the guard that protects every downstream consumer of the OpenDRIVE schema, from lane successor graphs to format interoperability.
Structural XSD validation and semantic invariant checks combine into one CI gate:
Prerequisites #
- Python 3.10+, lxml 5.x (libxml2-backed XSD validation), and the ASAM OpenDRIVE XSD files matching the versions you accept (1.7 and/or 1.8).
- Input: an
.xodrfile; the header declaresrevMajor/revMinor. - Upstream stage: this runs before parsing — nothing downstream should touch a file that has not passed.
- Output: a boolean pass/fail plus a list of
(line, message)violations, ready to print or serialize for CI.
Step-by-Step #
1. Select and compile the version-matched XSD #
Read the declared version from the header, then compile the corresponding schema once. Compilation is expensive; cache the compiled validator per version.
from functools import lru_cache
from lxml import etree
XSD_BY_VERSION = {("1", "7"): "OpenDRIVE_1.7.xsd",
("1", "8"): "OpenDRIVE_1.8.xsd"}
def document_version(path: str) -> tuple[str, str]:
tree = etree.parse(path)
hdr = tree.find("header")
return hdr.get("revMajor"), hdr.get("revMinor")
@lru_cache(maxsize=4)
def load_schema(major: str, minor: str) -> etree.XMLSchema:
return etree.XMLSchema(etree.parse(XSD_BY_VERSION[(major, minor)]))
Key parameter: lru_cache keeps each compiled XMLSchema resident, so validating a batch of files pays the compile cost once per version. Expected output: an XMLSchema validator object.
2. Run structural validation and collect the full error log #
Validate the parsed tree and harvest the entire error_log, not just the boolean result, so a single pass reports every structural fault with its line number.
def validate_structure(path: str) -> list[tuple[int, str]]:
tree = etree.parse(path)
major, minor = tree.getroot().find("header").get("revMajor"), \
tree.getroot().find("header").get("revMinor")
schema = load_schema(major, minor)
if schema.validate(tree):
return []
return [(e.line, e.message) for e in schema.error_log]
Key parameter: schema.error_log holds one entry per violation after validate returns False. Expected output: an empty list on success, or (line, message) tuples for every structural error.
3. Layer semantic invariant checks #
The XSD cannot enforce cross-references or ordering. Add explicit checks for the invariants that matter: geometry s must be non-decreasing within a road, every road link must resolve, and no junction may be empty.
def validate_semantics(path: str) -> list[tuple[int, str]]:
tree = etree.parse(path)
issues, road_ids = [], {r.get("id") for r in tree.findall("road")}
for road in tree.findall("road"):
s_vals = [float(g.get("s")) for g in road.findall("planView/geometry")]
if any(b < a for a, b in zip(s_vals, s_vals[1:])):
issues.append((road.sourceline, f"road {road.get('id')}: s not monotonic"))
for link in road.findall("link/successor") + road.findall("link/predecessor"):
if link.get("elementType") == "road" and link.get("elementId") not in road_ids:
issues.append((link.sourceline,
f"road {road.get('id')}: link to missing road {link.get('elementId')}"))
for junc in tree.findall("junction"):
if not junc.findall("connection"):
issues.append((junc.sourceline, f"junction {junc.get('id')}: no connections"))
return issues
Key parameters: the s-monotonicity check catches geometry authored out of order; the link resolution check catches dangling references; the junction check catches empty intersections. Expected output: a list of semantic (line, message) tuples.
Verification & Acceptance Criteria #
Combine both layers into one gate and fail CI on any violation.
def validate_opendrive(path: str) -> bool:
problems = validate_structure(path) + validate_semantics(path)
for line, msg in problems:
print(f"{path}:{line}: {msg}")
return not problems
if __name__ == "__main__":
import sys
sys.exit(0 if validate_opendrive(sys.argv[1]) else 1)
Acceptance gate: zero structural and zero semantic violations; the process exits non-zero on any failure so CI blocks the merge. Track the count over time — a rising semantic-error rate usually means an upstream conversion tool regressed, not that authors got sloppy.
Common Errors & Fixes #
XMLSchemaParseError: element decl. not found on load. The XSD imports were not resolved — ASAM ships the schema across several files. Keep the imported .xsd files alongside the main one, or compile from a directory that resolves the xs:include paths.
A 1.8 file fails against elements the 1.7 schema doesn't know. The version selector is wrong. Read revMajor/revMinor from the header and load the matching schema; never hard-code one version.
Validation passes but the map still breaks routing. The defect is semantic, not structural. Ensure validate_semantics runs — a dangling elementId is invisible to the XSD but fatal to building lane successor graphs from OpenDRIVE.
Huge files exhaust memory during validation. etree.parse builds the whole tree. For very large maps, validate structure with etree.XMLSchema in streaming mode via iterparse, or split the file per road before validation.
FAQ #
Isn't XSD validation enough on its own? #
No. The XSD guarantees the document is structurally well-formed — correct element nesting, required attributes, valid data types — but it cannot express cross-references or ordering. A file can be fully schema-valid yet point a road link at a non-existent road, list geometry records whose s coordinates decrease, or declare a junction with no connections. Those are semantic defects that need a second validation layer on top of the XSD.
Which XSD version should I validate against? #
Match the XSD to the revMajor and revMinor declared in the file header. OpenDRIVE 1.7 and 1.8 differ in allowed elements, so validating a 1.8 file against the 1.7 schema produces false failures on legitimately new elements, and the reverse silently accepts elements the target consumer will reject. Read the version from the header first and select the schema accordingly.
Why collect the whole error log instead of stopping at the first error? #
Stopping at the first error turns validation into a slow one-at-a-time fix loop for map authors. lxml exposes the complete error_log after a validation pass, so a single run can report every structural violation with its line number. Emitting all of them at once lets an author or an automated repair step address a batch in one iteration, which matters when a conversion tool has produced hundreds of similar defects.
Related #
- How to Parse OpenDRIVE XML with Python — the parsing step that runs only after validation passes.
- Building Lane Successor Graphs from OpenDRIVE — a consumer that depends on the resolvable-link invariant checked here.
- Map Format Interoperability: OpenDRIVE, Lanelet2 & NDS.Live — why a file must be schema-valid before any conversion.
Up one level: OpenDRIVE Schema Breakdown — the parent workflow this validation step guards the entrance to.