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:

The Three Things a Junction Model Must Produce A junction with approach lane ends, synthesised turn paths between them, swept corridors, and marked conflict points where paths cross. mapped drivable area conflict conflict approach lane ends — known exit lane starts — known turn paths + swept corridors — synthesised the corridor, not the centreline, is what has to stay inside the amber area

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 #

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.

python
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.

python
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.

python
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:

Three Turn-Path Defects and the Check That Finds Each Three panels showing a heading discontinuity, a curvature violation and a kerb-clipping swept corridor, each labelled with the check that detects it. 15° step κ 0.09 limit 0.031 kerb clipped 0.4 m heading discontinuity curvature over limit corridor clips the kerb found by: continuity check found by: curvature check found by: swept-area check only the third path is continuous and within curvature — every check written for ordinary lanes passes it

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.

python
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 #

python
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:

Cost per Junction, by Stage Four rows pairing a junction modelling stage with its cost and what dominates it. one four-way junction, twenty legal movements enumerate movements from the connection table a table walk, no geometry microseconds synthesise paths twenty Hermites, tension solved 24 bisections each, closed form ~2 ms find conflicts 190 pairwise crossings line intersections on short paths <1 ms validate corridors twenty polygon containments the only polygon-heavy stage ~40 ms index the drivable area with an STRtree and test candidates rather than the union, or the last row grows with the junction

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.

Up one level: Lane Geometry Extraction & Road Network Processing — the pipeline this stage completes at the junctions.