Intersection & Junction Modeling
Everywhere else in lane geometry extraction and road-network processing the job is to recover geometry that exists on the ground. Inside a junction there is nothing to recover: the paint stops at the stop line, the drivable surface is one continuous area, and the paths vehicles take across it are conventions rather than markings. Junction geometry is therefore authored, and authored geometry needs a different kind of validation — not "does this match the survey" but "is this drivable, does it stay on the road, and where does it conflict".
The tolerances are tighter than on open road because the consequences are worse. A turn path whose curvature exceeds the vehicle's limit produces a manoeuvre the controller cannot execute; one whose swept area clips a kerb produces a manoeuvre it should not. Both pass every check written for ordinary lanes.
What has to be synthesised, and what each piece is checked against:
There is a second reason junctions deserve their own stage rather than a footnote in centreline generation. Every other lane in the map is a claim about a surface a surveyor drove; a turn path is a claim about behaviour, and behaviour has a legal dimension the geometry does not carry. Two paths that cross are only a conflict if both movements can be green at once, which is a signalling question; a path that crosses a hatched area is only invalid if the regulation excluding that area applies to the vehicle class in question. Keeping the synthesis, the kinematic check and the legality layer as three separable concerns is what lets a fleet re-run one of them when a signal plan changes without re-authoring the geometry, and what keeps a regulation change from silently invalidating a survey.
The third reason is volume. A city of a few thousand junctions carries tens of thousands of movements, and each of them is a small authored artefact that has to be regenerated whenever an approach lane moves. Anything in this stage that requires a human decision per movement does not scale, which is why every parameter below — the interpolation tension, the design speed, the safety margin — is either solved against a stated limit or attached to the junction as an attribute. A number chosen by eye per junction is a number nobody will maintain across a re-survey.
Synthesis Method Comparison #
Four methods are in use for generating the turn path between a known entry and a known exit. They differ in what they guarantee.
| Method | Position continuity | Heading continuity | Curvature continuity | Cost | Fails when |
|---|---|---|---|---|---|
| Straight chord | yes | no | no | trivial | always, except a through movement |
| Circular arc fit | yes | approximately | no | trivial | entry and exit headings are not tangent to one circle |
| Cubic Bézier / Hermite | yes | yes | no | low | curvature spikes mid-path on tight turns |
| Clothoid pair (G2) | yes | yes | yes | moderate | the junction is too small for two spirals to fit |
The cubic Hermite is the working default: it is cheap, it guarantees the heading continuity that a controller needs at the join, and its curvature — while not continuous with the neighbouring lanes — is bounded and checkable. The clothoid pair is worth the cost where the junction is fast, because a curvature step at entry is felt as a jerk exactly where the vehicle is least able to absorb it, the same G2 argument made in fitting clothoid transitions to lane centerlines.
Stage-by-Stage Implementation #
Stage 1 — Enumerate the legal movements #
The constraint: one path per legal movement, taken from the schema's connection table rather than inferred from geometry. This is the same expansion described in building lane successor graphs from OpenDRIVE, and the reason it cannot be shortcut is that turn movements overlap in space — geometry alone cannot tell a left turn from a through movement that happens to pass through the same square metres.
def legal_movements(junction, restrictions) -> list[tuple]:
"""(incoming lane, outgoing lane) pairs the schema and regulations permit."""
out = []
for conn in junction.connections:
for link in conn.lane_links:
pair = ((conn.incoming_road, link.from_id),
(conn.connecting_road, link.to_id))
if pair not in restrictions.forbidden:
out.append(pair)
return sorted(out)
A four-way junction with two lanes per approach typically yields sixteen to twenty-four movements after restrictions.
Stage 2 — Synthesise a path per movement #
The constraint: position and heading continuity at both ends, exactly. A cubic Hermite interpolant gives both by construction.
import numpy as np
def hermite_path(p0, h0, p1, h1, n: int = 60, tension: float = 0.4):
"""Cubic Hermite from p0 heading h0 to p1 heading h1."""
chord = np.linalg.norm(np.asarray(p1) - np.asarray(p0))
m0 = tension * chord * np.array([np.cos(h0), np.sin(h0)])
m1 = tension * chord * np.array([np.cos(h1), np.sin(h1)])
t = np.linspace(0.0, 1.0, n)[:, None]
h00 = 2 * t**3 - 3 * t**2 + 1
h10 = t**3 - 2 * t**2 + t
h01 = -2 * t**3 + 3 * t**2
h11 = t**3 - t**2
return h00 * p0 + h10 * m0 + h01 * p1 + h11 * m1
tension scales the tangent magnitudes; near 0.4 of the chord it produces paths that look like the line a driver takes. Raising it fattens the turn and lowers peak curvature at the cost of swinging wider, which is the trade the swept-area check in stage 4 arbitrates.
Stage 3 — Bound the curvature #
The constraint: peak curvature must stay inside what the vehicle class can execute at the junction's design speed. A path that violates this is not a tight path, it is an undrivable one.
def peak_curvature(path: np.ndarray) -> float:
d1 = np.gradient(path, axis=0)
d2 = np.gradient(d1, axis=0)
num = np.abs(d1[:, 0] * d2[:, 1] - d1[:, 1] * d2[:, 0])
den = np.power(np.sum(d1 ** 2, axis=1), 1.5) + 1e-12
return float((num / den).max())
def curvature_limit(speed_m_s: float, a_lat_max: float = 2.0) -> float:
"""Curvature the vehicle can hold at this speed within a lateral-acceleration cap."""
return a_lat_max / max(speed_m_s ** 2, 1e-6)
At 8 m/s with a 2.0 m/s² lateral cap the limit is 0.031 m⁻¹ — a 32 m radius. A residential left turn is routinely tighter than that, which is why junction design speed is a per-movement attribute rather than a junction-wide one.
The three ways a synthesised path fails, and the check that catches each:
Stage 4 — Validate the swept corridor, not the centreline #
The constraint: the area a vehicle body sweeps along the path must stay inside the mapped drivable surface. Buffering the centreline by half the vehicle width is the cheap approximation; it under-estimates on tight turns, where the rear axle cuts inside the path.
from shapely.geometry import LineString
from shapely.ops import unary_union
def swept_corridor(path: np.ndarray, width_m: float = 2.0,
wheelbase_m: float = 2.9):
"""Union of vehicle footprints along the path, including rear-axle cut-in."""
centre = LineString(path)
body = centre.buffer(width_m / 2.0, cap_style=2)
heading = np.arctan2(*np.gradient(path, axis=0)[:, ::-1].T)
rear = path - wheelbase_m * np.column_stack((np.cos(heading), np.sin(heading)))
return unary_union([body, LineString(rear).buffer(width_m / 2.0, cap_style=2)])
Including the rear-axle track is what turns a plausible corridor into a correct one: on a tight left turn the rear wheels track up to a metre inside the path the front axle follows, and that metre is exactly where the kerb is.
Validation & QC Automation #
def validate_junction(junction, movements, drivable_area, tol_hdg=0.02) -> list[str]:
findings = []
for mv, path in movements.items():
if abs(heading_at(path, 0) - mv.entry_heading) > tol_hdg:
findings.append(f"{mv.id}: entry heading step")
if abs(heading_at(path, -1) - mv.exit_heading) > tol_hdg:
findings.append(f"{mv.id}: exit heading step")
k = peak_curvature(path)
if k > curvature_limit(mv.design_speed):
findings.append(f"{mv.id}: curvature {k:.3f} over limit")
if not drivable_area.contains(swept_corridor(path)):
findings.append(f"{mv.id}: swept corridor leaves the drivable area")
return findings
The enforced thresholds: entry and exit heading within 0.02 rad; peak curvature inside the per-movement limit; swept corridor fully contained in the drivable area; and every legal movement present, with the count matching the connection table exactly.
Edge Cases & Failure Patterns #
A junction with no mapped drivable area. The containment check silently passes because an empty geometry contains nothing and contains on an empty polygon returns False for everything — verify the area is non-empty before validating against it.
Two movements share a path. A shortcut synthesised one path per approach pair and reused it. Each (entry lane, exit lane) pair needs its own path; sharing puts vehicles in the wrong exit lane.
A tight residential turn cannot meet the curvature limit at any tension. The junction's design speed is wrong, not the path. Lower it per movement; a 12 m radius turn is drivable at 5 m/s and not at 8.
Conflict points are recorded between a movement and itself. The pairwise crossing search is not excluding identical or shared-entry movements. Two paths from the same entry lane diverge rather than conflict.
The corridor check fails only on left turns. Rear-axle cut-in was omitted. Include the rear track as in stage 4.
What a junction costs to model, and where the cost actually is:
Performance & Scale Notes #
Junction modelling is cheap per junction and there are a lot of junctions. A four-way junction with twenty movements costs about twenty Hermite evaluations, twenty curvature scans and one hundred and ninety pairwise crossing tests — microseconds. What is not cheap is the swept-corridor containment test, which is a polygon operation against a possibly complex drivable area; index the drivable area with an STRtree and test against the candidate polygons rather than the union.
Conflict points are computed once and stored, because the junction does not change between frames. A national map has millions of them and they compress well: a conflict is two movement identifiers and two stations.
FAQ #
Why can't junction geometry be extracted like ordinary lanes? #
Because there is nothing to extract. Inside a junction there are usually no lane markings at all — the paint stops at the stop line and resumes on the far side — so the boundary detection that produces ordinary lanes returns nothing. Junction geometry has to be synthesised from the incoming and outgoing lane ends and then validated, which makes it the one part of an HD map that is authored rather than surveyed.
What makes a synthesised turn path acceptable? #
Three things. It must join its incoming and outgoing lanes with continuous position and heading, so the vehicle does not have to jump. Its curvature must stay inside the vehicle class's limit at the junction's speed, so the path is drivable rather than merely geometric. And its swept area must stay inside the mapped drivable surface, so the path does not cut a corner across a kerb. A path that satisfies the first two and fails the third looks perfect in a graph view.
What is a conflict point and why record it? #
It is a place where two turn paths through the same junction cross, so two vehicles following them cannot both be there. Recording conflicts in the map lets the planner reason about right of way before it perceives the other vehicle, which is what makes an unprotected turn tractable. Computing them at run time from raw geometry is possible but wasteful — the junction does not change between frames, so the crossings belong in the map.
How many turn paths does a junction need? #
One per legal movement, which is one per laneLink entry in the source schema rather than one per approach. A four-way junction with two lanes on each approach commonly has sixteen to twenty-four legal movements once turn restrictions are applied, and each needs its own path because each has a different entry lane, exit lane and curvature. Synthesising one path per approach pair and reusing it across lanes is the shortcut that puts vehicles in the wrong exit lane.
Related #
- Generating Turn Paths Through Signalised Junctions — the synthesis and its tension parameter in full.
- Modelling OpenDRIVE Junction Connections in Python — reading the connection table these movements come from.
- Validating Intersection Drivable Area — the swept-corridor containment check.
- Validating Turn Restrictions in Road Graphs — the legality layer that filters the movement list.
- Choosing Between Centerline Methods for Intersection Geometry — the related decision for the approach lanes themselves.
Up one level: Lane Geometry Extraction & Road Network Processing — the pipeline this stage completes at the junctions.