Skip to content

Commit 7fc37d3

Browse files
committed
Adding OpenDRIVE (.xodr) map ingestion and export capabilities.
1 parent f4a98b6 commit 7fc37d3

10 files changed

Lines changed: 2514 additions & 0 deletions

File tree

src/trajdata/dataset_specific/xodr/__init__.py

Whitespace-only changes.
Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
1+
"""Lane connectivity and junction handling for XODR parsing.
2+
3+
This module handles road-to-road connectivity, lane adjacency,
4+
and junction connections.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
import xml.etree.ElementTree as ET
10+
from typing import Dict, List
11+
12+
import numpy as np
13+
14+
from .datatypes import LaneGeom
15+
16+
# Constants
17+
# Maximum distance (meters) to connect junction lanes
18+
JUNCTION_LANE_CONNECTION_THRESHOLD = 10.0
19+
20+
21+
def build_lane_adjacency(road_to_lanes: Dict[str, List[LaneGeom]]) -> None:
22+
"""Build adjacency relationships between lanes within each road.
23+
24+
Args:
25+
road_to_lanes: Dictionary mapping road IDs to their lane geometries.
26+
Modified in place to add adjacency relationships.
27+
"""
28+
for lanes in road_to_lanes.values():
29+
left_lanes = sorted(
30+
[lane for lane in lanes if lane.is_left], key=lambda lane: lane.lane_id_xml
31+
)
32+
right_lanes = sorted(
33+
[lane for lane in lanes if not lane.is_left],
34+
key=lambda lane: lane.lane_id_xml,
35+
reverse=True,
36+
)
37+
for side in (left_lanes, right_lanes):
38+
for i, curr in enumerate(side):
39+
if i > 0:
40+
curr.adj_right.add(side[i - 1].unique_id)
41+
if i < len(side) - 1:
42+
curr.adj_left.add(side[i + 1].unique_id)
43+
44+
# Populate legal lane change permissions from XODR neighbor references
45+
# IMPORTANT: Only allow lane changes that are BOTH legal AND physically possible
46+
for lane in lanes:
47+
# For most lanes, only forward neighbors are relevant
48+
# Intersect with geometric adjacency to ensure physical reachability
49+
lane.can_change_left.update(lane.left_neighbor_forward & lane.adj_left)
50+
lane.can_change_right.update(lane.right_neighbor_forward & lane.adj_right)
51+
52+
# For lanes with direction="both", backward neighbors are also important
53+
# (vehicles can legally travel in either direction)
54+
if lane.direction == "both":
55+
# When traveling backward, left becomes right and vice versa
56+
lane.can_change_right.update(
57+
lane.left_neighbor_backward & lane.adj_right
58+
)
59+
lane.can_change_left.update(
60+
lane.right_neighbor_backward & lane.adj_left
61+
)
62+
63+
64+
def extract_road_connections(root: ET.Element) -> Dict[str, Dict]:
65+
"""Extract road-to-road connectivity from <link> elements.
66+
67+
Args:
68+
root: Root XML element of the XODR document
69+
70+
Returns:
71+
Dictionary mapping road IDs to their connectivity info
72+
"""
73+
road_connections: Dict[str, Dict] = {}
74+
75+
for road in root.findall("road"):
76+
road_id = road.attrib["id"]
77+
link_elem = road.find("link")
78+
if link_elem is not None:
79+
connections = {}
80+
predecessor = link_elem.find("predecessor")
81+
successor = link_elem.find("successor")
82+
83+
if (
84+
predecessor is not None
85+
and predecessor.attrib.get("elementType") == "road"
86+
):
87+
connections["predecessor"] = {
88+
"road_id": predecessor.attrib["elementId"],
89+
"contact": predecessor.attrib.get("contactPoint", "start"),
90+
}
91+
92+
if successor is not None and successor.attrib.get("elementType") == "road":
93+
connections["successor"] = {
94+
"road_id": successor.attrib["elementId"],
95+
"contact": successor.attrib.get("contactPoint", "start"),
96+
}
97+
98+
if connections:
99+
road_connections[road_id] = connections
100+
101+
return road_connections
102+
103+
104+
def connect_lanes_between_roads(
105+
root: ET.Element,
106+
road_connections: Dict[str, Dict],
107+
road_to_lanes: Dict[str, List[LaneGeom]],
108+
) -> None:
109+
"""Connect lanes between roads using connectivity information.
110+
111+
Handles both regular road connections (exact lane ID match) and
112+
junction connections (spatial proximity).
113+
114+
Args:
115+
root: Root XML element of the XODR document
116+
road_connections: Road connectivity information
117+
road_to_lanes: Dictionary mapping road IDs to their lanes
118+
Modified in place to add lane connections.
119+
"""
120+
for road_id, connections in road_connections.items():
121+
current_road_lanes = road_to_lanes.get(road_id, [])
122+
123+
for direction, conn_info in connections.items():
124+
connected_road_id = conn_info["road_id"]
125+
connected_road_lanes = road_to_lanes.get(connected_road_id, [])
126+
127+
# Determine if this is a junction connection
128+
curr_road = next(
129+
(r for r in root.findall("road") if r.attrib["id"] == road_id), None
130+
)
131+
conn_road = next(
132+
(
133+
r
134+
for r in root.findall("road")
135+
if r.attrib["id"] == connected_road_id
136+
),
137+
None,
138+
)
139+
140+
curr_is_junction = (
141+
curr_road is not None and curr_road.attrib.get("junction", "-1") != "-1"
142+
)
143+
conn_is_junction = (
144+
conn_road is not None and conn_road.attrib.get("junction", "-1") != "-1"
145+
)
146+
147+
if curr_is_junction or conn_is_junction:
148+
# Junction connection: use spatial proximity
149+
_connect_junction_lanes(
150+
current_road_lanes,
151+
connected_road_lanes,
152+
direction,
153+
)
154+
else:
155+
# Regular road connection: use exact lane ID match
156+
_connect_regular_lanes(
157+
current_road_lanes,
158+
connected_road_lanes,
159+
direction,
160+
)
161+
162+
163+
def _connect_junction_lanes(
164+
current_road_lanes: List[LaneGeom],
165+
connected_road_lanes: List[LaneGeom],
166+
direction: str,
167+
) -> None:
168+
"""Connect lanes in junction areas using spatial proximity.
169+
170+
Args:
171+
current_road_lanes: Lanes from current road
172+
connected_road_lanes: Lanes from connected road
173+
direction: "successor" or "predecessor"
174+
"""
175+
for curr_lane in current_road_lanes:
176+
if not curr_lane.is_driving:
177+
continue # Only connect driveable lanes
178+
179+
# Find closest lane in connected road (by end/start point proximity)
180+
curr_endpoint = (
181+
curr_lane.center[-1] if direction == "successor" else curr_lane.center[0]
182+
)
183+
184+
best_match = None
185+
min_distance = float("inf")
186+
187+
for conn_lane in connected_road_lanes:
188+
if not conn_lane.is_driving:
189+
continue
190+
191+
# Check distance to appropriate endpoint
192+
conn_endpoint = (
193+
conn_lane.center[0]
194+
if direction == "successor"
195+
else conn_lane.center[-1]
196+
)
197+
distance = np.linalg.norm(curr_endpoint[:2] - conn_endpoint[:2])
198+
199+
if (
200+
distance < min_distance
201+
and distance < JUNCTION_LANE_CONNECTION_THRESHOLD
202+
):
203+
min_distance = distance
204+
best_match = conn_lane
205+
206+
if best_match is not None:
207+
if direction == "successor":
208+
curr_lane.next_lanes.add(best_match.unique_id)
209+
best_match.prev_lanes.add(curr_lane.unique_id)
210+
elif direction == "predecessor":
211+
curr_lane.prev_lanes.add(best_match.unique_id)
212+
best_match.next_lanes.add(curr_lane.unique_id)
213+
214+
215+
def _connect_regular_lanes(
216+
current_road_lanes: List[LaneGeom],
217+
connected_road_lanes: List[LaneGeom],
218+
direction: str,
219+
) -> None:
220+
"""Connect lanes between regular roads using exact lane ID match.
221+
222+
Args:
223+
current_road_lanes: Lanes from current road
224+
connected_road_lanes: Lanes from connected road
225+
direction: "successor" or "predecessor"
226+
"""
227+
for curr_lane in current_road_lanes:
228+
for conn_lane in connected_road_lanes:
229+
if curr_lane.lane_id_xml == conn_lane.lane_id_xml: # Same lane number
230+
if direction == "successor":
231+
curr_lane.next_lanes.add(conn_lane.unique_id)
232+
conn_lane.prev_lanes.add(curr_lane.unique_id)
233+
elif direction == "predecessor":
234+
curr_lane.prev_lanes.add(conn_lane.unique_id)
235+
conn_lane.next_lanes.add(curr_lane.unique_id)
236+
237+
238+
def process_junction_connections(
239+
root: ET.Element,
240+
lane_geoms: Dict[str, LaneGeom],
241+
) -> None:
242+
"""Process explicit junction laneLink connectivity.
243+
244+
Args:
245+
root: Root XML element of the XODR document
246+
lane_geoms: Dictionary of all lane geometries
247+
Modified in place to add junction connections.
248+
"""
249+
for junction in root.findall("junction"):
250+
for conn in junction.findall("connection"):
251+
conn_road_id = conn.attrib["connectingRoad"]
252+
incoming_road_id = conn.attrib["incomingRoad"]
253+
contact_pt = conn.attrib.get(
254+
"contactPoint", "end"
255+
) # 'start' or 'end' relative to incoming road
256+
257+
for ll in conn.findall("laneLink"):
258+
from_lane = int(ll.attrib["from"]) # lane id in connectingRoad
259+
to_lane = int(ll.attrib["to"]) # lane id in incomingRoad
260+
261+
unique_from = f"{conn_road_id}_{from_lane}"
262+
unique_to = f"{incoming_road_id}_{to_lane}"
263+
264+
if unique_from not in lane_geoms or unique_to not in lane_geoms:
265+
continue # malformed reference, skip
266+
267+
# Establish prev/next according to OpenDRIVE spec:
268+
# incomingRoad -> connectingRoad -> (other outgoing road)
269+
if contact_pt == "start":
270+
# connectingRoad starts at incoming road, so incoming -> connecting
271+
lane_geoms[unique_from].prev_lanes.add(unique_to)
272+
lane_geoms[unique_to].next_lanes.add(unique_from)
273+
else: # 'end'
274+
# connectingRoad ends at incoming road, so connecting -> incoming
275+
lane_geoms[unique_from].next_lanes.add(unique_to)
276+
lane_geoms[unique_to].prev_lanes.add(unique_from)
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
"""Data structures for XODR parsing.
2+
3+
This module contains the core data structures used throughout the XODR
4+
parsing pipeline.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
from dataclasses import dataclass, field
10+
from enum import IntEnum
11+
from typing import Dict, Optional, Set, Tuple
12+
13+
import numpy as np
14+
15+
16+
class MapElementType(IntEnum):
17+
"""Types of map elements that can be parsed from XODR."""
18+
19+
ROAD_LANE = 1
20+
PED_CROSSWALK = 3
21+
PED_WALKWAY = 4
22+
ROAD_EDGE = 5
23+
TRAFFIC_SIGN = 6
24+
WAIT_LINE = 7
25+
26+
27+
@dataclass
28+
class LaneGeom:
29+
"""Geometry and metadata for a single lane along an OpenDRIVE road."""
30+
31+
lane_id_xml: int # id attribute from XML (negative for right side)
32+
unique_id: str # roadId_laneId string, globally unique
33+
center: np.ndarray # (N, 3) xyz
34+
left_edge: Optional[np.ndarray] # (N, 3) xyz when present
35+
right_edge: Optional[np.ndarray] # (N, 3) xyz when present
36+
headings: np.ndarray # (N,) radians
37+
road_id: str
38+
lane_type: str # Original lane type from XODR (driving, parking, sidewalk, etc.)
39+
is_driving: bool
40+
is_left: bool # True if lane_id > 0 per OpenDRIVE convention
41+
direction: str = "standard" # Lane direction: "standard", "reversed", or "both"
42+
43+
# Connectivity placeholders (filled later by parse_xodr)
44+
next_lanes: Set[str] = field(default_factory=set)
45+
prev_lanes: Set[str] = field(default_factory=set)
46+
# Geometric adjacency (physically adjacent)
47+
adj_left: Set[str] = field(default_factory=set)
48+
adj_right: Set[str] = field(default_factory=set)
49+
50+
# Legal adjacency from XODR (where lane changes are allowed)
51+
# Lanes we can legally change to on the left/right
52+
can_change_left: Set[str] = field(default_factory=set)
53+
can_change_right: Set[str] = field(default_factory=set)
54+
55+
# Traffic infrastructure (filled later)
56+
traffic_sign_ids: Set[str] = field(default_factory=set)
57+
wait_line_ids: Set[str] = field(default_factory=set)
58+
59+
# Neighbor lanes from XODR (forward/backward references)
60+
left_neighbor_forward: Set[str] = field(default_factory=set) # From lane.link.left
61+
right_neighbor_forward: Set[str] = field(
62+
default_factory=set
63+
) # From lane.link.right
64+
left_neighbor_backward: Set[str] = field(default_factory=set)
65+
right_neighbor_backward: Set[str] = field(default_factory=set)
66+
67+
68+
@dataclass
69+
class ParsedXodr:
70+
"""Return object of parse_xodr holding lanes & auxiliary map data."""
71+
72+
lanes: Dict[str, LaneGeom]
73+
extent: np.ndarray # (6,) [min_x, min_y, min_z, max_x, max_y, max_z]
74+
road_edges: Dict[str, np.ndarray] # Values: (N, 3) xyz polylines
75+
traffic_signs: Dict[
76+
str, Tuple[np.ndarray, str]
77+
] # Values: ((3,) xyz position, type_str)
78+
wait_lines: Dict[
79+
str, Tuple[np.ndarray, str]
80+
] # Values: ((N, 3) xyz polylines, wait_line_type)
81+
sidewalks: Dict[str, np.ndarray] # Values: (N, 3) xyz polylines
82+
crosswalks: Dict[str, np.ndarray] # Values: (N, 3) xyz polylines

0 commit comments

Comments
 (0)