Automating Lane Width Attribute Synchronization

Synchronize measured lane widths onto extracted centerlines and propagate them deterministically across HD-map tiles, the recurring task that turns raw per-segment width samples into a topology-consistent, machine-readable lane attribute layer inside a production Lane Geometry Extraction & Road Network Processing pipeline.

This page is a runnable how-to: it follows the upstream measurement stage in batch lane attribute extraction and produces a width-tagged layer ready for OpenDRIVE schema validation and downstream lane-level topology modeling. Target tolerance: lateral centerline error ≤ 0.10 m, width RMSE ≤ 0.05 m against survey-grade ground truth, and zero self-intersections after stitching.

Lane-width synchronization with confidence tagging and topology-validated tile stitching:

Lane-width synchronization pipeline A top-down flowchart. GeoParquet vector inputs are projected to a metric CRS, matched to centerlines with an STRtree, and interpolated with a cubic spline. A confidence gate either assigns the width attribute or tags a conservative fallback, both of which feed a topology-and-seam-continuity gate. That gate either emits the synced lane-width layer or routes failures through a buffered-overlap re-stitch that loops back for re-validation. yes no pass fail Vector inputs → GeoParquet PyArrow-backed tables Project to metric CRS pyproj always_xy=True STRtree nearest-neighbor match O(n log n) to centerlines Curvature-aware interpolation cubic spline across curves Measurement confidence OK? Assign width attribute Tag confidence_flag conservative fallback is_valid + seam continuity? Synced lane-width layer Buffered-overlap re-stitch

Prerequisites #

This routine assumes the measurement stage of batch lane attribute extraction has already emitted two inputs: a centerline layer (vectorized LineString geometries, one per lane segment) and a width-sample point layer (each point carrying a width_m measurement and a meas_sigma standard deviation in metres). Both must share a single tiling scheme.

  • Python 3.11+
  • shapely 2.0+ (vectorized STRtree, get_coordinates, is_valid)
  • pyproj 3.6+ (Transformer with always_xy=True)
  • numpy 1.26+, scipy 1.11+ (scipy.interpolate.CubicSpline)
  • pyarrow 14+ for GeoParquet I/O
  • Data assumptions: inputs delivered as GeoParquet per the GeoParquet 1.0 specification; geometry coordinates retained to 8 decimal places; a geographic source CRS (EPSG:4326) that must be projected to a locally optimal metric CRS before any width math.
  • Upstream stage: centerlines generated by extracting lane boundaries from point cloud data; curvature already computed per calculating road curvature with Python Shapely and stored as a per-vertex kappa array.

Step-by-step synchronization #

Step 1 — Load inputs as PyArrow-backed tables and project to a metric CRS #

Bypass eager GeoDataFrame instantiation for high-throughput ingestion: read GeoParquet into PyArrow tables to avoid heap fragmentation across distributed tile workers, then project to a metric CRS so perpendicular offsets and widths are computed in metres, not degrees. always_xy=True guarantees deterministic (lon, lat) → (easting, northing) ordering — non-negotiable for boundary offset math.

python
import numpy as np
import pyarrow as pa
import pyarrow.parquet as pq
from pyproj import Transformer
from shapely import from_wkb, set_precision

# Read raw vector tiles without materializing a GeoDataFrame
centerlines_tbl = pq.read_table("tiles/centerlines.parquet")   # geometry (WKB), seg_id, kappa
samples_tbl     = pq.read_table("tiles/width_samples.parquet")  # geometry (WKB), width_m, meas_sigma

# EPSG:4326 (geographic) -> EPSG:32633 (UTM 33N); pick the UTM zone of the tile
to_metric = Transformer.from_crs("EPSG:4326", "EPSG:32633", always_xy=True)

centerlines = from_wkb(centerlines_tbl.column("geometry").to_numpy())
samples      = from_wkb(samples_tbl.column("geometry").to_numpy())

# Reproject vertex-wise, then snap to 1 mm grid to kill float truncation noise
def reproject(geoms):
    out = np.empty(len(geoms), dtype=object)
    for i, g in enumerate(geoms):
        xs, ys = g.xy
        ex, nx = to_metric.transform(np.asarray(xs), np.asarray(ys))
        out[i] = set_precision(type(g)(np.column_stack([ex, nx])), 0.001)
    return out

centerlines = reproject(centerlines)
samples_xy  = np.array([(p.x, p.y) for p in reproject(samples)])

set_precision(..., 0.001) snaps to a 1 mm grid, eliminating the floating-point truncation that silently desyncs widths across tile seams. Expected output: two arrays of metric-CRS shapely geometries plus an (N, 2) sample coordinate array.

Step 2 — Match width samples to centerlines with an STRtree #

Use shapely.STRtree nearest-neighbor queries instead of exhaustive polygon intersection: this drops batch matching from O(n²) to O(n log n). Query each sample against the centerline index, then resolve its arc-length position so widths can later be interpolated along the curve.

python
from shapely import STRtree, line_locate_point, Point

tree = STRtree(centerlines)

# nearest centerline per sample point
sample_pts = [Point(xy) for xy in samples_xy]
nearest_idx = tree.nearest(sample_pts)            # index into `centerlines`

widths   = samples_tbl.column("width_m").to_numpy()
sigmas   = samples_tbl.column("meas_sigma").to_numpy()

# arc-length (station) of each sample's projection onto its matched centerline
stations = np.array([
    line_locate_point(centerlines[j], pt)         # metres from line start
    for pt, j in zip(sample_pts, nearest_idx)
])

line_locate_point returns the station in metres because the geometry is now metric. Reject any sample whose perpendicular distance to its matched centerline exceeds 0.10 m — beyond the lateral tolerance it belongs to an adjacent lane and would corrupt the assignment.

Projecting width samples onto a centerline with a lateral-tolerance band A curving lane centerline runs left to right with a shaded acceptance band of plus or minus 0.10 metres around it. Width-sample points inside the band project perpendicularly onto the centerline at their arc-length stations and are accepted. A sample beyond the band, belonging to an adjacent lane, is shown crossed out and rejected. centerline (LineString) ±0.10 m rejected: > 0.10 m (adjacent lane) width sample station projection

Step 3 — Interpolate width with a curvature-aware cubic spline #

Naive linear interpolation across segment junctions injects artificial width discontinuities that violate the geometric-continuity requirements of the OpenDRIVE specification and Lanelet2. Fit a CubicSpline over each centerline's (station, width) samples so width propagates smoothly through horizontal curves and superelevation transition zones. Weight the fit so high-kappa regions defer to nearby measurements rather than extrapolating through the curve.

python
from scipy.interpolate import CubicSpline

def sync_widths(seg_id, seg_geom, st, w, sig):
    order = np.argsort(st)
    st, w, sig = st[order], w[order], sig[order]
    # dedupe coincident stations (CubicSpline requires strictly increasing x)
    keep = np.concatenate(([True], np.diff(st) > 1e-6))
    st, w, sig = st[keep], w[keep], sig[keep]
    if st.size < 2:
        return None
    spline = CubicSpline(st, w, bc_type="natural")
    # evaluate at every centerline vertex station
    vtx = np.asarray(seg_geom.coords)
    seg_len = np.r_[0.0, np.cumsum(np.hypot(*np.diff(vtx, axis=0).T))]
    return spline(seg_len)        # width per vertex, metres

bc_type="natural" keeps the second derivative zero at the segment ends, preventing overshoot where a curve meets a tangent. Expected output: a per-vertex width array aligned to the centerline geometry.

Step 4 — Tag measurement confidence instead of silently imputing #

Where sensor occlusion or GPS multipath degrades a measurement (meas_sigma above threshold) or a vertex falls in a sample gap, do not silently impute a width. Assign a conservative fallback and stamp a confidence_flag so downstream planning modules retain full data lineage.

python
SIGMA_MAX = 0.04          # metres; samples above this are low-trust
FALLBACK_W = 3.50         # conservative default lane width, metres

def confidence_flag(widths_per_vtx, st, sig):
    flags = np.zeros(widths_per_vtx.shape, dtype=np.uint8)  # 0 = measured
    if (sig > SIGMA_MAX).any():
        flags[:] = 1                                        # 1 = low-confidence
    mask = ~np.isfinite(widths_per_vtx)
    widths_per_vtx[mask] = FALLBACK_W
    flags[mask] = 2                                         # 2 = fallback imputed
    return widths_per_vtx, flags

Step 5 — Validate topology, then stitch across tile boundaries #

Run shapely.is_valid after every transformation to catch self-intersections, collapsed rings, and orientation flips before they corrupt spatial joins. When widths fail to propagate across a tile seam, re-stitch with a buffered overlap so attribute continuity is preserved without duplicating edge geometries.

python
from shapely import is_valid, make_valid

def stitch_tile_edges(seg_geom, neighbor_geoms, vtx_widths, overlap_m=2.0):
    assert is_valid(seg_geom) or is_valid(make_valid(seg_geom))
    edge = seg_geom.buffer(overlap_m, cap_style="flat")
    for n in neighbor_geoms:
        shared = edge.intersection(n.buffer(overlap_m, cap_style="flat"))
        if not shared.is_empty:
            # average widths in the overlap band to remove the seam discontinuity
            vtx_widths = _blend_overlap(vtx_widths, shared)
    return vtx_widths

The buffered band is also where you re-run continuity checks at toll plaza transitions, lane merges, and construction-zone overrides — the edge cases most likely to break a naive seam join.

Verification & acceptance criteria #

Confirm the step succeeded with hard thresholds, not eyeballing:

  • Lateral accuracy: every assigned width sample within 0.10 m of its matched centerline. assert (perp_dist <= 0.10).all().
  • Width fidelity: RMSE against held-out survey-grade widths ≤ 0.05 m. assert np.sqrt(np.mean((synced - truth) ** 2)) <= 0.05.
  • Topology: assert is_valid(seg_geom).all() and zero self-intersections after stitching.
  • Continuity: width delta across any tile seam ≤ 0.02 m. assert (np.abs(seam_left - seam_right) <= 0.02).all().
  • Lineage: confidence_flag present on 100% of output vertices; log the share of flag == 2 (fallback) — a spike signals upstream measurement loss, not a sync bug.
python
synced_tbl = centerlines_tbl.append_column("width_m", pa.array(vtx_widths))
synced_tbl = synced_tbl.append_column("confidence_flag", pa.array(flags))
pq.write_table(synced_tbl, "tiles/centerlines_width_synced.parquet")

Common errors & fixes #

ValueError: x must be strictly increasing from CubicSpline. Two or more width samples projected to the same station. Diagnosis: coincident or near-coincident sample points. Fix: dedupe with the np.diff(st) > 1e-6 mask shown in Step 3 before constructing the spline.

Widths scaled by ~111,320 at the output. The geometry was never projected out of EPSG:4326, so line_locate_point returned degrees. Fix: confirm Step 1 ran the Transformer; assert the bounding box is in metric range (abs(x) > 1000) before matching.

Silent width jump at a tile seam. Float truncation during reprojection moved seam vertices apart, so STRtree.nearest matched samples to different centerlines on each side. Fix: apply set_precision(geom, 0.001) (Step 1) and run the buffered-overlap re-stitch of Step 5.

STRtree.nearest matches a sample to the wrong lane in a merge zone. Two centerlines run within centimetres of each other. Fix: filter matches by perpendicular distance > 0.10 m and, in merge/diverge zones, disambiguate using the upstream lane connectivity from lane-level topology modeling.

Frequently Asked Questions #

Why match samples to centerlines with an STRtree instead of a spatial join? A pairwise intersection over every sample/centerline combination is O(n²) and saturates memory on a dense tile. shapely.STRtree.nearest indexes the centerlines once and resolves each sample in O(log n), dropping batch matching to O(n log n) while returning the exact nearest geometry needed for arc-length projection.

Should I linearly interpolate width between samples? No. Linear interpolation injects width discontinuities at segment junctions that violate the geometric-continuity requirement of OpenDRIVE and Lanelet2. Fit a CubicSpline with bc_type="natural" so width propagates smoothly through horizontal curves and superelevation transitions without overshoot where a curve meets a tangent.

What do I do when a measurement is untrustworthy? Never silently impute. When meas_sigma exceeds the 0.04 m trust threshold or a vertex falls in a sample gap, assign the conservative fallback width and stamp a confidence_flag (0 measured, 1 low-confidence, 2 fallback). Downstream planning keeps full data lineage and a spike in flag == 2 surfaces upstream measurement loss rather than hiding it.

How do I stop widths jumping at a tile seam? Snap every reprojected vertex to a 1 mm grid with set_precision(geom, 0.001) so seam vertices match across tiles, then run the buffered-overlap re-stitch that averages widths in the overlap band. Acceptance is a seam width delta ≤ 0.02 m.

Up one level: this how-to sits under Batch Lane Attribute Extraction within the broader lane geometry extraction pipeline.