Skip to content

Extracting semantic tags, road and lane types & speed limits#484

Open
aprabou wants to merge 8 commits into
BerkeleyLearnVerify:mainfrom
aprabou:main
Open

Extracting semantic tags, road and lane types & speed limits#484
aprabou wants to merge 8 commits into
BerkeleyLearnVerify:mainfrom
aprabou:main

Conversation

@aprabou

@aprabou aprabou commented Jul 3, 2026

Copy link
Copy Markdown

Description

This extension allows for the extraction of semantic tags and speed limits/types from roads and lanes.
The changes are made to the following files:

roads.py:

  • added distributionFunction speedLimitAt to get the speed limit at coordinate-s along a given range
  • speedLimit element depreciated

xodr_parser.py:

  • Speed parsing/among helpers:

    • speed_to_mps
    • speed_limit_ranges_from_type_records
    • speed_limit_ranges_from_lane_records
    • effective_speed_limit_ranges
    • speed_limit_for_s_interval
    • assign_speed_limit_from_ranges.
  • When an interval straddles multiple limits, the minimum is used and an OpenDriveWarning is emitted.

  • Tag helpers:

    • merge_scenic_tags
    • lane_scenic_tags
    • _junction_tags
    • assign_semantic_tags
  • Propagation helpers:

    • propagate_speed_limit
    • apply_lane_speed_limits
    • propagate_tags
  • Road, Lane, and Junction now capture:

    • type_records
    • lane speed_records
    • junction type_
    • extra_tags
  • Road.toScenicRoad wires per-section and per-lane speed limits and tags into the Scenic objects.

  • Expanded drivable_lane_types / sidewalk_lane_types / shoulder_lane_types defaults (e.g. slipLane, biking, walking, restricted, curb, none; parking classified as a shoulder).

test_opendrive.py:

  • New unit tests covering speed-unit conversion, road/lane speed-range parsing, per-section and per-lane effective limits, multi-limit warnings, and tag propagation

Checklist

  • I have tested the changes locally via pytest and/or other means
  • I have added or updated relevant documentation
  • I have autoformatted the code with black and isort
  • I have added test cases (if applicable)

@dfremont dfremont self-requested a review July 6, 2026 21:26
Comment thread src/scenic/domains/driving/roads.py
Comment thread src/scenic/formats/opendrive/xodr_parser.py
Comment on lines +64 to +65
if speed_mps is not None:
current_speed = speed_mps

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If speed_mps is None (which I assume could happen if the corresponding type element has no speed element or has one specifying "no limit"), wouldn't this code mean we inherit the speed limit from the previous range? That doesn't seem right to me: if there is no speed limit for part of the road, we should respect that (and keep None as the speed limit there).

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That does make sense. I made the following changes and would like to know if this aligns with dealing with None values:

  • _speed_limit_ranges_from_s_speed_records: removed current_speed inheritance. Each record now has its own speed_mps which can be None. Also added a prepend record starting at 0.0, so coverage always starts at s = 0, if the first record has s > 0.
  • speed_limit_ranges_from_type_records: no more inheritance across types, allows for None speeds.
  • assign_speed_limit_from_ranges: if None gaps exist, it is kept track of rather than discarded.
  • speed_limit_for_s_interval: interval overlapping only undefined stretches returns None.

Comment thread src/scenic/formats/opendrive/xodr_parser.py
Comment thread src/scenic/formats/opendrive/xodr_parser.py
]


def speed_limit_for_s_interval(ranges, s_start, s_end):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the code would be simpler if you had this function just return the set of speed limits (you can call it speed_limits_for_s_interval) and move the min to the one place where you call this function.

Comment thread src/scenic/formats/opendrive/xodr_parser.py
Comment thread src/scenic/formats/opendrive/xodr_parser.py

element.speedLimit = min(speeds)
if len(speeds) > 1:
element.speedLimitRanges = tuple(ranges)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should set this attribute unconditionally, not only when there are multiple ranges. (That way consumer code can assume speedLimitRanges always has a meaningful value.)

element.speedLimitRanges = ()


def merge_scenic_tags(*tag_sets):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is just a union -- I don't think it needs a special helper function and unit tests. You can just write frozenset(type_tags | self.extra_tags).

Comment on lines +200 to +201
if junction.name:
tags.add(junction.name)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should go into the name attribute, not the tags.

tags = _junction_tags(junction)
if not tags:
continue
for road_id, road in road_map.roads.items():

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This nested loop will take quadratic time. I'd compute the tags for each junction first (might as well just do it in the Junction initializer), then loop over roads and pull out the tags from the appropriate junction.

Comment on lines +244 to +245
for element in elements:
if isinstance(element, _ROAD_PROPAGATION_TYPES):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think iterating over a bunch of elements and only taking the ones of the correct type is roundabout and confusing. Where this function is used, you know exactly which sub-elements to use (e.g. road.laneGroups and road.sections), so I would propagate to those directly.

element.speedLimit = speed_limit


def apply_lane_speed_limits(elements):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It should be the min over the sections. Also I don't think we need a helper function for this: just do it directly when creating the Lane.

element.speedLimit = max(section_limits)


def propagate_tags(tags, elements):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As above, let's do this directly with the correct elements and not use a helper function.

self.id_ = id_
self.width = [] # List of tuples (Poly3, int) for width and s-offset.
self.speed_records = [] # [(sOffset, speed_mps), ...] from <speed> elements.
self.speed_limit = None # First record's speed; kept for backward compatibility.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are no backwards-compatibility concerns since this is a new attribute. I think you can drop it. (Unlike NetworkElement.speedLimit, which we could deprecate but shouldn't remove immediately.)

Comment on lines +856 to +857
if prev_id not in offsets:
continue

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the reason for this change? This condition should never be true.

type_tags = frozenset(
open_drive_type
for _, open_drive_type, _ in self.type_records
if open_drive_type

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How can open_drive_type be falsy?

f"road {self.id_} lane {id_} section s=[{s_start},{s_end})"
),
)
else:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not obvious to me how we get into this case. Do we really need two cases, or is there a way to make it uniform?

Comment thread src/scenic/formats/opendrive/xodr_parser.py
"offRamp",
"connectingRamp",
"slipLane",
"biking",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a bike lane, not a driving lane.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In that case, similar to how the code deals with sidewalk and shoulder lane types, should I model biking lanes as a separate LaneGroup.bikeLane?

Comment thread src/scenic/formats/opendrive/xodr_parser.py
"restricted",
"curb",
"parking",
"none",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the motivation for including none? It isn't necessarily a shoulder.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The spec mentions that while it does not have actual content, it serves for applications to register that OpenDRIVE is still present in case the driver leaves the road. I'm not sure if this would be relevant to Scenic but I wanted to add that in case it was.

Comment thread src/scenic/formats/opendrive/xodr_parser.py
Comment on lines +365 to +368
road_map = RoadMap()
road_map.parse(path)
road_map.calculate_geometry(num=5, calc_intersect=True)
road = road_map.toScenicNetwork().roads[0]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you use parse_scenic_network (or a variant) to simplify this kind of test?

Comment on lines +392 to +394
internal_road = road_map.roads[7]
internal_road.extra_tags = frozenset({"onramp"})
road, _ = internal_road.toScenicRoad(road_map.tolerance)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rather than modifying internal attributes like this, I think it's clearer to generate the whole Network object and then assert things about it.

road_map = RoadMap()
road_map.parse(path)
road_map.calculate_geometry(num=5, calc_intersect=True)
return road_map.toScenicNetwork()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Until now no tests in this folder used Network: tests that do are in tests/domains/driving instead. Since you're checking OpenDRIVE-specific things you could keep them in this folder, but maybe make a new file to distinguish them (e.g. test_speeds.py or test_tags.py).

@dfremont dfremont left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is definitely headed in the right direction, thanks Ashwin! I think we can simplify the code a lot, though: see the comments above. Here are a few other suggestions:

  • Let's add a Network.speedLimitAt method to get the speed limit (if any) at a given point. I think it can just call Network.laneSectionAt to get the lane section, compute s in the same way that the first line of PolylineRegion.nearestSegmentTo does (only necessary if there's more than 1 speed limit range), and then look up the right speed limit range.
  • I haven't finished reading all the tests yet (I'll wait until you refactor things), but they're pretty verbose: please see if you can simplify the code. One note is that I don't think we need unit tests for things like propagate_speed_limit: that's an internal function which could easily get refactored away. I'd rather have integration tests which load a simple map and then assert things about the resulting Network object. (Of course, for functions that will be used in many places and are easily testable on their own, unit tests make sense.)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants