Skip to content
Merged
Show file tree
Hide file tree
Changes from 27 commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
e11409d
feat(OsmWay): Add helper methods.
binh-dam-ibigroup Sep 29, 2025
28b188f
feat(CrosswalkNamer): Add class for crosswalk namer w/ cross street l…
binh-dam-ibigroup Sep 29, 2025
38f5e11
refactor(OsmWay): Remove redundant transit platform logic.
binh-dam-ibigroup Sep 29, 2025
6b83df3
test(CrosswalkNamer): Make test OSM ways static.
binh-dam-ibigroup Sep 29, 2025
21e3289
feat(CrosswalkNamer): Implement basic crosswalk naming.
binh-dam-ibigroup Sep 29, 2025
a4689cb
refactor(CrosswalkNamer): Extract turn lane condition.
binh-dam-ibigroup Sep 30, 2025
989f7c2
test(CrosswalkNamer): Support service roads and turn lanes.
binh-dam-ibigroup Sep 30, 2025
1daa299
docs(CrosswalkNamer): Update class JavaDoc.
binh-dam-ibigroup Oct 1, 2025
aeb0547
style: Apply Prettier.
binh-dam-ibigroup Oct 1, 2025
38f0060
Merge branch 'dev-2.x' into crosswalk-naming
binh-dam-ibigroup Oct 1, 2025
347e72d
feat(SidewalkCrosswalkNamer): Add sidewalk_crosswalk option to osmNam…
binh-dam-ibigroup Oct 1, 2025
cb6e68e
fix(OsmWay): Relax criteria for crossings.
binh-dam-ibigroup Oct 1, 2025
1acf57c
fix(StatesToWalkStepsMapper): Make new instruction for crossing follo…
binh-dam-ibigroup Oct 1, 2025
b52cedb
style: Apply prettier.
binh-dam-ibigroup Oct 1, 2025
602cb89
perf(CrosswwalkNamer): Use geo buffer to limit candidate intersecting…
binh-dam-ibigroup Oct 1, 2025
f5003d0
feat(CrosswwalkNamer): Rename single sidewalk on either side of cross…
binh-dam-ibigroup Oct 2, 2025
9560214
Merge branch 'dev-2.x' into crosswalk-naming
binh-dam-ibigroup Oct 2, 2025
fb5bc8e
docs(BuildCondfiguration): Update build docs.
binh-dam-ibigroup Oct 2, 2025
109f15d
test(StatesToWalkStepsMapper): Remove null edge cases.
binh-dam-ibigroup Oct 2, 2025
c760b69
refactor: Extract NamerWithGeoBuffer.
binh-dam-ibigroup Oct 2, 2025
e03a998
refactor(NamerWithGeoBuffer): Extract addToSpatialIndex.
binh-dam-ibigroup Oct 2, 2025
146fbb8
refactor(NamerWithGeoBuffer): Inline method and tweak JavaDoc.
binh-dam-ibigroup Oct 2, 2025
3cb0082
test(NamerTestUtils): Extract edgeBuilder method.
binh-dam-ibigroup Oct 2, 2025
2027866
test(CrosswalkNamer): Fix null flag.
binh-dam-ibigroup Oct 2, 2025
dc99a18
test(SidewalkNamer): Fix typo.
binh-dam-ibigroup Oct 2, 2025
3492317
refactor(SidewalkNamer): Require OsmWay.
binh-dam-ibigroup Oct 2, 2025
1f2d8f2
style: Apply prettier.
binh-dam-ibigroup Oct 2, 2025
7cd0ec3
Merge branch 'dev-2.x' into crosswalk-naming
binh-dam-ibigroup Oct 14, 2025
c75f928
test(OsmWay): Reuse WayTestData code.
binh-dam-ibigroup Oct 14, 2025
e6351db
test(OsmWay): Rename createFootway to createCrossing.
binh-dam-ibigroup Oct 14, 2025
31a9e64
test(OsmWay): Apply prettier.
binh-dam-ibigroup Oct 14, 2025
340b5fc
feat(StreetEdge): Add a crossing flag.
binh-dam-ibigroup Oct 14, 2025
3c92825
docs(StatesToWalkStepsMapper): Add isSameStreet JavaDoc.
binh-dam-ibigroup Oct 14, 2025
cf97687
fix(StatesToWalkStepsMapper): Evaluate crossings if they don't use de…
binh-dam-ibigroup Oct 14, 2025
615e303
refactor(StreetEdgeIndex): Extract spatial index helper/wrapper.
binh-dam-ibigroup Oct 14, 2025
5fd15af
refactor(StreetEdgeIndex): Move PreciseBuffer and EdgeOnLevel to pack…
binh-dam-ibigroup Oct 14, 2025
320ee6d
refactor(PreciseBuffer): Rename to PreciseBufferFactory.
binh-dam-ibigroup Oct 14, 2025
df13ef6
refactor: Remove inheritance from SideWalkNamer and CrosswalkNamer.
binh-dam-ibigroup Oct 14, 2025
93f81ab
refactor(EdgeNamer): Rename postprocess to finalizeNames.
binh-dam-ibigroup Oct 14, 2025
555c188
Use fewer static methods
leonardehrenfried Oct 15, 2025
685adfd
Merge branch 'dev-2.x' into crosswalk-naming, resolve conflicts
binh-dam-ibigroup Oct 15, 2025
fce3f0c
refactor(CrosswalkNamer): Remove use of level sets.
binh-dam-ibigroup Oct 15, 2025
ba3c149
refactor(OsmWay): Add isTurnLane method.
binh-dam-ibigroup Oct 15, 2025
241a85a
refactor(StreetEdgeBuilderFactory): Rename test class, reuse in Sidew…
binh-dam-ibigroup Oct 15, 2025
f5b2081
feat(CrosswalkNamer): Add freeway ramp as crossing type.
binh-dam-ibigroup Oct 15, 2025
954cf59
refactor(CrosswalkNamer): Implement i18n.
binh-dam-ibigroup Oct 16, 2025
bd8de75
Merge branch 'dev-2.x' into crosswalk-naming
binh-dam-ibigroup Oct 16, 2025
3072289
chore(WayProperties): Tweak French text
binh-dam-ibigroup Oct 16, 2025
334a0cd
refactor(CrosswalkNamer): Change remaining 'crossing' to 'crosswalk'.
binh-dam-ibigroup Oct 16, 2025
7d52252
style(CrosswalkNamer): Apply prettier.
binh-dam-ibigroup Oct 16, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
package org.opentripplanner.graph_builder.module.osm.naming;

import static org.opentripplanner.osm.model.TraverseDirection.FORWARD;

import gnu.trove.list.TLongList;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
import org.locationtech.jts.geom.Geometry;
import org.opentripplanner.framework.geometry.HashGridSpatialIndex;
import org.opentripplanner.framework.i18n.I18NString;
import org.opentripplanner.graph_builder.module.osm.StreetEdgePair;
import org.opentripplanner.osm.model.OsmEntity;
import org.opentripplanner.osm.model.OsmWay;
import org.opentripplanner.osm.model.TraverseDirection;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
* A namer that assigns names to crosswalks using the name or type of the crossed street.
* <p>
* The algorithm works as follows:
* - For each crosswalk, we find the intersecting street edge that shares a node.
* - Apply a name depending on the type of street:
* * For named streets, name the crossing so it reads "crossing over 10th Street".
* * For service roads (e.g. car access to commercial complexes, such as
* <a href="https://www.openstreetmap.org/way/1024601318">...</a>),
* use "crossing over service road".
* * For turn lanes or slip lanes at intersections (shortcuts from a street to another,
* to bypass traffic signals, prevalent in North America,
* e.g. <a href="https://www.openstreetmap.org/way/1139062913">...</a>),
* use "crossing over turn lane".
*/
public class CrosswalkNamer extends NamerWithGeoBuffer {

private static final Logger LOG = LoggerFactory.getLogger(CrosswalkNamer.class);
private static final int BUFFER_METERS = 25;

private HashGridSpatialIndex<EdgeOnLevel> streetEdges = new HashGridSpatialIndex<>();
private HashGridSpatialIndex<EdgeOnLevel> sidewalkEdges = new HashGridSpatialIndex<>();
private Collection<EdgeOnLevel> unnamedCrosswalks = new ArrayList<>();

@Override
public void recordEdges(OsmEntity way, StreetEdgePair pair) {
if (way instanceof OsmWay osmWay) {
// Record unnamed crossings to a list.
if (osmWay.isCrossing() && way.hasNoName() && !way.isExplicitlyUnnamed()) {
pair
.asIterable()
.forEach(edge -> unnamedCrosswalks.add(new EdgeOnLevel(osmWay, edge, way.getLevels())));
}
// Record (short) sidewalks to a geometric index
else if (way.isSidewalk()) {
addToSpatialIndex(way, pair, sidewalkEdges, BUFFER_METERS);
}
// Record named streets, service roads, and slip/turn lanes to a geometric index.
else if (
!osmWay.isFootway() && (way.isNamed() || osmWay.isServiceRoad() || isTurnLane(osmWay))
) {
addToSpatialIndex(way, pair, streetEdges);
}
}
}

@Override
public void postprocess() {
postprocess(unnamedCrosswalks, BUFFER_METERS, "crosswalks", LOG);

// Set the indices to null so they can be garbage-collected
streetEdges = null;
sidewalkEdges = null;
unnamedCrosswalks = null;
}

/**
* The actual worker method that runs the business logic on an individual sidewalk edge.
* This will also name adjacent sidewalks on each end if they are the only adjacent sidewalks.
*/
@Override
protected boolean assignNameToEdge(EdgeOnLevel crosswalkOnLevel, Geometry buffer) {
var crosswalk = crosswalkOnLevel.edge();
OsmWay way = crosswalkOnLevel.way();

var streetCandidates = streetEdges
.query(buffer.getEnvelopeInternal())
.stream()
.map(EdgeOnLevel::way)
.toList();

var crossStreetOpt = getIntersectingStreet(way, streetCandidates);
if (crossStreetOpt.isPresent()) {
OsmWay crossStreet = crossStreetOpt.get();
// TODO: i18n
if (crossStreet.isNamed()) {
crosswalk.setName(
I18NString.of(String.format("crossing over %s", crossStreet.getAssumedName()))
);
} else if (crossStreet.isServiceRoad()) {
crosswalk.setName(I18NString.of("crossing over service road"));
} else if (isTurnLane(crossStreet)) {
crosswalk.setName(I18NString.of("crossing over turn lane"));
} else {
// Default on using the OSM way ID, which should not happen.
crosswalk.setName(I18NString.of(String.format("crossing %s", way.getId())));
}

var adjacentSidewalks = sidewalkEdges
.query(buffer.getEnvelopeInternal())
.stream()
.filter(e -> e.way().isAdjacentTo(way))
.filter(e -> e.edge().nameIsDerived())
.toList();

// Group sidewalks at each end of the crosswalk.
TLongList nodes = way.getNodeRefs();
renameAdjacentSidewalk(adjacentSidewalks, crosswalk.getName(), nodes.get(0));
renameAdjacentSidewalk(adjacentSidewalks, crosswalk.getName(), nodes.get(nodes.size() - 1));

return true;
}

return false;
}

/**
* Rename a sidewalk, among candidates, if it is the only adjacent sidewalk to the given crosswalk.
*/
private void renameAdjacentSidewalk(
List<EdgeOnLevel> adjacentSidewalks,
I18NString crosswalkName,
long nodeId
) {
List<EdgeOnLevel> sidewalks = adjacentSidewalks
.stream()
.filter(e -> e.way().getNodeRefs().contains(nodeId))
.toList();
if (sidewalks.size() == 1) {
sidewalks.getFirst().edge().setName(crosswalkName);
}
}

/**
* Gets the intersecting street, if any, for the given way and candidate streets.
*/
public static Optional<OsmWay> getIntersectingStreet(OsmWay way, Collection<OsmWay> streets) {
TLongList nodeRefs = way.getNodeRefs();
if (nodeRefs.size() >= 3) {
// There needs to be at least three nodes: 2 extremities that are on the sidewalk,
// and one somewhere in the middle that joins the crossing with the street.
// We exclude the first and last node which are on the sidewalk.
long[] nodeRefsArray = nodeRefs.toArray(1, nodeRefs.size() - 2);
return streets
.stream()
.filter(w -> Arrays.stream(nodeRefsArray).anyMatch(nid -> w.getNodeRefs().contains(nid)))
.findFirst();
}
return Optional.empty();
}

private static boolean isTurnLane(OsmEntity way) {
Optional<TraverseDirection> oneWayCar = way.isOneWay("motorcar");
return oneWayCar.isPresent() && oneWayCar.get() == FORWARD;
}
Copy link
Member

Choose a reason for hiding this comment

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

Is this a general definition of turn lanes? If it is we can move it into the OsmWay class.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Will have to come up with a generic one. I will paste a few OSM links in the PR description.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I have "standardized" turn lanes in ba3c149.


public Collection<EdgeOnLevel> getUnnamedCrosswalks() {
return unnamedCrosswalks;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
package org.opentripplanner.graph_builder.module.osm.naming;

import java.util.Collection;
import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;
import org.geotools.api.referencing.FactoryException;
import org.geotools.api.referencing.crs.CoordinateReferenceSystem;
import org.geotools.api.referencing.operation.MathTransform;
import org.geotools.api.referencing.operation.TransformException;
import org.geotools.geometry.jts.JTS;
import org.geotools.referencing.CRS;
import org.geotools.referencing.crs.DefaultGeographicCRS;
import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.Envelope;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.operation.buffer.BufferParameters;
import org.opentripplanner.framework.geometry.HashGridSpatialIndex;
import org.opentripplanner.framework.i18n.I18NString;
import org.opentripplanner.graph_builder.module.osm.StreetEdgePair;
import org.opentripplanner.graph_builder.services.osm.EdgeNamer;
import org.opentripplanner.osm.model.OsmEntity;
import org.opentripplanner.osm.model.OsmWay;
import org.opentripplanner.street.model.edge.StreetEdge;
import org.opentripplanner.utils.lang.DoubleUtils;
import org.opentripplanner.utils.logging.ProgressTracker;
import org.slf4j.Logger;

/**
* Base class for namers that use a geo buffer to query geo features.
*/
public abstract class NamerWithGeoBuffer implements EdgeNamer {

protected PreciseBuffer preciseBuffer;

@Override
public I18NString name(OsmEntity way) {
return way.getAssumedName();
}

protected void postprocess(
Collection<EdgeOnLevel> unnamedEdges,
int bufferMeters,
String type,
Logger logger
) {
ProgressTracker progress = ProgressTracker.track(
String.format("Assigning names to %s", type),
500,
unnamedEdges.size()
);

this.preciseBuffer = new PreciseBuffer(computeEnvelopeCenter(unnamedEdges), bufferMeters);

final AtomicInteger namesApplied = new AtomicInteger(0);
unnamedEdges
.parallelStream()
.forEach(edgeOnLevel -> {
var buffer = preciseBuffer.preciseBuffer(edgeOnLevel.edge.getGeometry());
if (assignNameToEdge(edgeOnLevel, buffer)) {
namesApplied.incrementAndGet();
}

// Keep lambda! A method-ref would cause incorrect class and line number to be logged
// noinspection Convert2MethodRef
progress.step(m -> logger.info(m));
});

logger.info(
"Assigned names to {} of {} {} ({}%)",
namesApplied.get(),
unnamedEdges.size(),
type,
DoubleUtils.roundTo2Decimals(((double) namesApplied.get() / unnamedEdges.size()) * 100)
);

logger.info(progress.completeMessage());
}

/**
* Implementation-specific logic for naming an edge.
* @return true if a name was applied, false otherwise.
*/
protected abstract boolean assignNameToEdge(EdgeOnLevel edgeOnLevel, Geometry buffer);

/**
* Compute the centroid of all sidewalk edges.
*/
private Coordinate computeEnvelopeCenter(Collection<EdgeOnLevel> edges) {
var envelope = new Envelope();
edges.forEach(e -> {
envelope.expandToInclude(e.edge.getFromVertex().getCoordinate());
envelope.expandToInclude(e.edge.getToVertex().getCoordinate());
});
return envelope.centre();
}

/**
* Adds an entry to a geospatial index.
*/
protected static void addToSpatialIndex(
OsmEntity way,
StreetEdgePair pair,
HashGridSpatialIndex<EdgeOnLevel> spatialIndex
) {
addToSpatialIndex(way, pair, spatialIndex, Integer.MAX_VALUE);
}

/**
* Adds an entry to a geospatial index if its length is less than a threshold.
*/
protected static void addToSpatialIndex(
OsmEntity way,
StreetEdgePair pair,
HashGridSpatialIndex<EdgeOnLevel> spatialIndex,
int maxLengthMeters
) {
// We generate two edges for each osm way: one there and one back. This spatial index only
// needs to contain one item for each road segment with a unique geometry and name, so we
// add only one of the two edges.
var edge = pair.pickAny();
if (edge.getDistanceMeters() <= maxLengthMeters) {
spatialIndex.insert(
edge.getGeometry().getEnvelopeInternal(),
new EdgeOnLevel((OsmWay) way, edge, way.getLevels())
);
}
}

public record EdgeOnLevel(OsmWay way, StreetEdge edge, Set<String> levels) {}

/**
* A class to cache the expensive construction of a Universal Traverse Mercator coordinate
* reference system.
* Re-using the same CRS for all edges might introduce tiny imprecisions for OTPs use cases
* but speeds up the processing enormously and is a price well worth paying.
*/
protected static final class PreciseBuffer {

private final double distanceInMeters;
private final MathTransform toTransform;
private final MathTransform fromTransform;

private PreciseBuffer(Coordinate coordinate, double distanceInMeters) {
this.distanceInMeters = distanceInMeters;
String code = "AUTO:42001,%s,%s".formatted(coordinate.x, coordinate.y);
try {
CoordinateReferenceSystem auto = CRS.decode(code);
this.toTransform = CRS.findMathTransform(DefaultGeographicCRS.WGS84, auto);
this.fromTransform = CRS.findMathTransform(auto, DefaultGeographicCRS.WGS84);
} catch (FactoryException e) {
throw new RuntimeException(e);
}
}

/**
* Add a buffer around a geometry that makes sure that the buffer is the same distance (in
* meters) anywhere on earth.
* <p>
* Background: If you call the regular buffer() method on a JTS geometry that uses WGS84 as the
* coordinate reference system, the buffer will be accurate at the equator but will become more
* and more elongated the farther north/south you go.
* <p>
* Taken from https://stackoverflow.com/questions/36455020
*/
protected Geometry preciseBuffer(Geometry geometry) {
try {
Geometry pGeom = JTS.transform(geometry, toTransform);
Geometry pBufferedGeom = pGeom.buffer(distanceInMeters, 4, BufferParameters.CAP_FLAT);
return JTS.transform(pBufferedGeom, fromTransform);
} catch (TransformException e) {
throw new RuntimeException(e);
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package org.opentripplanner.graph_builder.module.osm.naming;

import org.opentripplanner.framework.i18n.I18NString;
import org.opentripplanner.graph_builder.module.osm.StreetEdgePair;
import org.opentripplanner.graph_builder.services.osm.EdgeNamer;
import org.opentripplanner.osm.model.OsmEntity;

/**
* Combines the sidewalk and crosswalk namer.
*/
public class SidewalkCrosswalkNamer implements EdgeNamer {

private final SidewalkNamer sidewalkNamer = new SidewalkNamer();
private final CrosswalkNamer crosswalkNamer = new CrosswalkNamer();

@Override
public I18NString name(OsmEntity way) {
return way.getAssumedName();
}

@Override
public void recordEdges(OsmEntity way, StreetEdgePair pair) {
sidewalkNamer.recordEdges(way, pair);
crosswalkNamer.recordEdges(way, pair);
}

@Override
public void postprocess() {
sidewalkNamer.postprocess();
crosswalkNamer.postprocess();
}
}
Loading
Loading