Deriving Superelevation from Point-Cloud Cross-Sections

Superelevation — the lateral banking a road carries through a curve — is a safety-relevant attribute the planner uses for lateral acceleration limits, and it can only be measured from 3D surface data, not the 2D centerline. This task recovers it from a registered LiDAR point cloud: step along the centerline, cut thin cross-sections normal to the heading, robustly fit the cross-slope with RANSAC, and gate each station against curvature-consistent banking. It is the 3D companion to curvature in the road curvature and superelevation mapping stage, and it depends on a well-aligned cloud from point cloud registration.

At each station a normal slab of points is RANSAC-fit to recover the cross-slope, then checked against curvature:

Superelevation Recovery from Cross-Section Slabs A horizontal centerline with evenly spaced station ticks. One station shows a perpendicular slab. To the right, a small plot of lateral offset versus height with a fitted line through inlier points and two outlier points off the line. Below, a gate checks banking consistency with curvature. centerline · stations normal slab z offset RANSAC line · outliers ignored banking sign · magnitude vs curvature

Prerequisites #

  • Python 3.10+, NumPy 1.24+, SciPy 1.11+, scikit-learn 1.3+ (RANSACRegressor), open3d 0.18+ for slab selection.
  • Input: a registered, ground-classified LiDAR point cloud in the map's projected CRS, plus the lane centerline with per-station heading.
  • Upstream stage: the cloud is aligned by point cloud registration with the ICP algorithm; curvature per station is already computed.
  • Output: a per-station superelevation profile (cross-slope in percent) with an inlier count and fit residual per station.

Step-by-Step #

1. Cut a cross-section slab normal to the centerline #

At each station, project points into the local frame (along-track, cross-track, up) and keep those within a thin along-track slab.

python
import numpy as np

def cross_section(points: np.ndarray, station: np.ndarray, heading: float,
                  half_thick=0.25, half_width=6.0):
    """Points within a thin slab normal to the heading at `station`."""
    t = np.array([np.cos(heading), np.sin(heading)])   # along-track
    n = np.array([-np.sin(heading), np.cos(heading)])  # cross-track
    d = points[:, :2] - station
    along = d @ t
    cross = d @ n
    m = (np.abs(along) <= half_thick) & (np.abs(cross) <= half_width)
    return cross[m], points[m, 2]                       # (offset, height)

Key parameters: half_thick (0.25 m) keeps the slab thin so longitudinal grade does not leak in; half_width bounds it to the roadway. Expected output: lateral offsets and heights for the slab.

2. RANSAC-fit the cross-slope #

Fit a robust line to (offset, height); its slope is the banking. RANSAC rejects curb and vegetation returns.

python
from sklearn.linear_model import RANSACRegressor

def fit_cross_slope(offset: np.ndarray, height: np.ndarray, residual_thr=0.03):
    r = RANSACRegressor(residual_threshold=residual_thr, min_samples=0.5)
    r.fit(offset.reshape(-1, 1), height)
    slope = float(r.estimator_.coef_[0])                # rise / run
    inliers = int(r.inlier_mask_.sum())
    return slope * 100.0, inliers                       # superelevation in %

Key parameters: residual_threshold (0.03 m) sets the inlier band around the pavement; min_samples=0.5 requires half the slab to agree, biasing toward the dominant surface. Expected output: cross-slope in percent and the inlier count.

3. Assemble the per-station profile #

Walk the stations and record slope, inliers, and residual, so weak stations can be flagged.

python
def superelevation_profile(points, stations, headings):
    prof = []
    for st, hd in zip(stations, headings):
        off, h = cross_section(points, st, hd)
        if len(off) < 20:                               # too sparse to trust
            prof.append({"slope_pct": np.nan, "inliers": len(off)})
            continue
        slope_pct, inliers = fit_cross_slope(off, h)
        prof.append({"slope_pct": slope_pct, "inliers": inliers})
    return prof

Verification & Acceptance Criteria #

Gate the profile on curvature consistency and fit quality.

python
def assert_superelevation(prof, kappa, tol_pct=0.3):
    for p, k in zip(prof, kappa):
        if np.isnan(p["slope_pct"]):
            continue
        # banking should share sign with curvature on real curves
        if abs(k) > 0.005:
            assert np.sign(p["slope_pct"]) == np.sign(k) or abs(p["slope_pct"]) < tol_pct, \
                "banking sign inconsistent with curvature"
        assert p["inliers"] >= 20, "too few inliers for a trusted slope"

Acceptance gate: on curved stations (|κ| > 0.005 m⁻¹) the banking sign matches curvature; cross-slope smooth station-to-station within ±0.3%; each accepted station has ≥20 inliers. Sign mismatches or spikes flag a slab corrupted by a curb or barrier — widen the inlier rejection or narrow the slab.

Common Errors & Fixes #

Cross-slope tilts the wrong way on a clear curve. The slab clipped a curb or barrier and RANSAC locked onto it. Narrow half_width to the pavement and lower residual_threshold so only the flat surface is inlier.

Longitudinal grade contaminates the banking. The slab is too thick, so uphill grade adds a false lateral slope. Reduce half_thick to ≤0.25 m and confirm the heading used is the true along-track direction.

Noisy, jumpy profile between stations. Station spacing is finer than the surface noise supports. Increase the station step, or smooth the profile with a short median filter after fitting.

RANSACRegressor raises on degenerate slabs. A near-empty or collinear slab has no consensus set. Guard with the len(off) < 20 skip in step 3 and mark those stations for interpolation.

FAQ #

Why cut cross-sections normal to the centerline? #

Superelevation is the road's lateral tilt, so it must be measured across the road, perpendicular to the direction of travel. A slab cut at any other angle mixes longitudinal grade into the measurement and biases the cross-slope. Orienting the slab along the centerline normal isolates the lateral height change from the longitudinal one, which is what makes the fitted slope the true banking angle.

Why RANSAC instead of a plain least-squares fit? #

A cross-section slab contains more than the road surface — curbs, vegetation, passing vehicles, and barrier returns all land in it. Least squares is pulled off the true surface by those outliers. RANSAC fits the line to the largest consensus set of inliers and ignores the rest, so the recovered cross-slope reflects the pavement, not a curb the slab happened to clip.

How is superelevation cross-checked against curvature? #

Roads bank into curves, so the sign of the cross-slope should follow the sign of the curvature and its magnitude should scale with design speed and curve radius. A station whose fitted banking tilts the wrong way, or is large where the road is straight, is almost always a fit corrupted by an outlier surface. Comparing the profile to the curvature signal catches those stations before the superelevation attribute is written.

Up one level: Road Curvature & Superelevation Mapping — the parent workflow that pairs cross-slope with curvature for HD map serialization.