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:
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+ (
Transformerwithalways_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
kappaarray.
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.
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.
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.
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.
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.
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.
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_flagpresent on 100% of output vertices; log the share offlag == 2(fallback) — a spike signals upstream measurement loss, not a sync bug.
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.
Related #
- Engineering Architecture for Batch Lane Attribute Extraction — the parent stage that emits the centerline and width-sample inputs this routine consumes.
- Extracting Lane Boundaries from Point Cloud Data — produces the vectorized centerlines you synchronize against.
- Calculating Road Curvature with Python Shapely — supplies the per-vertex
kappaused to make interpolation curvature-aware. - OpenDRIVE Schema Breakdown — the schema the synced width layer must validate against downstream.
Up one level: this how-to sits under Batch Lane Attribute Extraction within the broader lane geometry extraction pipeline.