Generating Turn Paths Through Signalised Junctions

A turn path is the only geometry in an HD map with no ground truth behind it, so every parameter that shapes it has to be justified by something other than appearance. This task synthesises one path per legal movement for intersection and junction modeling: entry anchored at the stop line, geometry from a cubic Hermite, and a tension solved against the curvature limit rather than chosen.

Why the stop line and the lane end are not the same place:

Stop-Line Anchoring against Lane-End Anchoring An approach lane with the stop line and lane end marked separately, showing the unmapped gap a lane-end anchored path leaves. junction interior stop line lane geometry ends anchored at the stop line — covers the whole movement 4.5 m unmapped — the vehicle is already moving before a path exists the gap widens where a pedestrian crossing sits between the line and the junction

Prerequisites #

  • Python 3.10+, NumPy 1.24+, shapely 2.0+.
  • Input: entry and exit poses per movement, the stop-line geometry, and a per-movement design speed.
  • Upstream stage: the movement enumeration from the junction connection table.
  • Output: an arc-length-parameterized path per movement, with its solved tension recorded.

Step-by-Step #

1. Anchor the entry pose at the stop line #

python
import numpy as np
from shapely.geometry import LineString

def entry_pose(lane_centre: LineString, stop_line: LineString):
    """Pose where the lane crosses its stop line, not where its geometry ends."""
    hit = lane_centre.intersection(stop_line)
    if hit.is_empty:
        s = lane_centre.length              # no stop line: fall back to the end
    else:
        s = lane_centre.project(hit if hit.geom_type == "Point" else hit.centroid)
    p = np.array(lane_centre.interpolate(s).coords[0])
    ahead = np.array(lane_centre.interpolate(min(s + 1.0, lane_centre.length)).coords[0])
    return p, float(np.arctan2(*(ahead - p)[::-1]))

The fallback is explicit rather than silent: a movement with no stop line is a finding for the survey queue, and recording which movements used the fallback is what makes that queue exist.

2. Interpolate with a cubic Hermite #

python
def hermite(p0, h0, p1, h1, tension: float, n: int = 80) -> np.ndarray:
    chord = float(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]
    return ((2*t**3 - 3*t**2 + 1) * p0 + (t**3 - 2*t**2 + t) * m0 +
            (-2*t**3 + 3*t**2) * p1 + (t**3 - t**2) * m1)

Both end tangents are exactly the lane headings, so position and heading continuity are structural rather than checked.

3. Solve the tension against the curvature limit #

python
def solve_tension(p0, h0, p1, h1, k_limit: float,
                  lo: float = 0.15, hi: float = 1.2, iters: int = 24) -> float:
    """Smallest tension whose peak curvature is within the limit."""
    for _ in range(iters):
        mid = 0.5 * (lo + hi)
        if peak_curvature(hermite(p0, h0, p1, h1, mid)) > k_limit:
            lo = mid                # too tight: more tension flattens it
        else:
            hi = mid
    return hi

Key parameters: lo and hi bracket the useful range — below about 0.15 the path is nearly a chord, above about 1.2 it loops. Twenty-four bisections resolve the tension to about 1e-7, far finer than needed, and cost nothing. Returning hi rather than mid guarantees the returned tension satisfies the limit.

How peak curvature and swing width trade against each other as tension rises:

Curvature and Swing Against the Tension Parameter A falling curvature curve and a rising swing curve over tension, with both limits marked and the usable window between their crossings shaded. usable window 0.150.420.781.2 tension → peak curvature κ limit 0.031 lateral swing 1.8 m drivable margin the solver returns the left edge — the tightest drivable path — and the swept-area check owns the right edge

4. Resample by arc length #

python
def resample(path: np.ndarray, spacing_m: float = 0.5) -> np.ndarray:
    seg = np.linalg.norm(np.diff(path, axis=0), axis=1)
    s = np.concatenate(([0.0], np.cumsum(seg)))
    target = np.arange(0.0, s[-1], spacing_m)
    return np.column_stack([np.interp(target, s, path[:, i]) for i in (0, 1)])

Constant spacing makes the stored path directly comparable with ordinary lane centrelines and removes the vertex bunching that would otherwise skew any downstream curvature estimate, for the reasons set out in calculating road curvature with Python and Shapely.

Verification & Acceptance Criteria #

python
def assert_turn_paths(movements, paths, tol_hdg=0.02) -> None:
    for mv, path in paths.items():
        p0, h0 = movements[mv].entry
        p1, h1 = movements[mv].exit
        assert np.linalg.norm(path[0] - p0) <= 0.05, f"{mv}: entry not at the stop line"
        assert np.linalg.norm(path[-1] - p1) <= 0.05, f"{mv}: exit off the lane start"
        assert abs(heading_at(path, 0) - h0) <= tol_hdg, f"{mv}: entry heading step"
        assert abs(heading_at(path, -1) - h1) <= tol_hdg, f"{mv}: exit heading step"
        assert peak_curvature(path) <= curvature_limit(movements[mv].design_speed)
        d = np.linalg.norm(np.diff(path, axis=0), axis=1)
        assert d.std() < 0.02, f"{mv}: path is not arc-length parameterized"

Acceptance gate: entry and exit positions within 0.05 m; headings within 0.02 rad; peak curvature inside the per-movement limit; and near-constant vertex spacing, which is the cheap proof that resampling ran.

Where each turn-path parameter comes from, and which one is a survey fact rather than a choice:

Provenance of Each Turn-Path Parameter Four rows pairing a turn-path parameter with its source and whether it is a measurement, an attribute or a derived value. four parameters, none of them tuned by eye entry / exit pose where the movement starts and ends approach geometry + stop line surveyed design speed per movement, not per junction junction design or regulation an attribute curvature limit what the vehicle can execute lateral cap over speed squared derived tension how wide the path swings bisected against the curvature limit solved a parameter chosen by eye per junction is a parameter nobody will maintain across a re-survey

Common Errors & Fixes #

A waiting vehicle has no path to follow. The entry is anchored at the lane end. Anchor at the stop line, and record movements where no stop line was found.

Paths look right and the controller overshoots on tight turns. The tension is a constant. Solve it per movement against the curvature limit.

Curvature computed downstream is spiky. The path was stored in its Hermite parameterization, so vertices bunch in the tight section. Resample by arc length before storing.

The solver returns the upper bracket on every movement. The curvature limit is unreachable at any tension, which means the design speed is too high for the junction's geometry. Lower the per-movement speed rather than widening the limit.

A path swings outside the junction on a shallow turn. The bisection found a low-curvature solution that is geometrically wide. That is the swept-area check's job to reject, and its rejection should feed back as a lower design speed, not a hand-tuned tension.

FAQ #

Why anchor the path at the stop line rather than the lane end? #

Because the stop line is where the vehicle physically is when it begins the movement, and it is often several metres short of where the lane geometry ends. Starting the path at the lane end produces a turn that begins inside the junction, so a vehicle waiting at the line has no path to follow until it has already entered — which is exactly the moment it needs one. The lane end is a geometric artefact; the stop line is a fact about the road.

How is the tension parameter chosen? #

By solving rather than by eye. Tension scales the tangent magnitudes, and peak curvature falls monotonically as it rises until the path starts swinging wide enough to leave the drivable area. Bisecting on tension until peak curvature sits just inside the limit gives the tightest path that is still drivable, and the swept-area check then decides whether that path is also legal. A hand-picked constant produces paths that are fine on four-way junctions and undrivable on tight ones.

Does the path need to be resampled? #

Yes. A Hermite is parameterized by t, not by arc length, so its vertices bunch where the curve is tight — precisely where a downstream curvature computation is most sensitive to spacing. Resampling to constant arc-length spacing before storing the path removes that bias and makes the stored geometry comparable with ordinary lane centrelines, which are already stored that way.

Up one level: Intersection & Junction Modeling — the stage this synthesis is the core of.