|
| 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) |
0 commit comments