Generating Centerlines with a Voronoi Medial Axis
Midpoint averaging works when the two boundaries of a lane are in correspondence — same vertex count, same parameterization, both running the same way. At a junction, a merge or a variable-width segment none of that holds, and the averaging step quietly invents a pairing that produces a centerline wandering off the road.
The medial axis needs no pairing. It is the set of points equidistant from two or more boundary points, which is defined whatever the boundary looks like, and the Voronoi diagram of a densely sampled boundary approximates it directly. This task implements that for centerline generation algorithms.
The four stages, and the one that does most of the work:
Prerequisites #
- Python 3.10+, NumPy 1.24+, SciPy 1.11+ (
spatial.Voronoi), shapely 2.0+, networkx 3.x. - Input: two boundary polylines forming a closed drivable polygon for the segment.
- Upstream stage: boundary extraction, as in extracting lane boundaries from point cloud data.
- Output: one ordered centerline polyline per segment.
Step-by-Step #
1. Densify both boundaries #
import numpy as np
from shapely.geometry import LineString
def densify(line: LineString, spacing_m: float) -> np.ndarray:
n = max(2, int(np.ceil(line.length / spacing_m)) + 1)
return np.array([line.interpolate(s).coords[0]
for s in np.linspace(0.0, line.length, n)])
Choose spacing_m as about a fifth of the narrowest width in the segment. Expected output: two vertex arrays whose spacing is fine relative to the road, typically a few thousand points for a city block.
2. Take the Voronoi diagram and keep interior ridges #
from scipy.spatial import Voronoi
from shapely.geometry import LineString as LS
def interior_ridges(points: np.ndarray, road_poly) -> list:
vor = Voronoi(points)
out = []
for (a, b) in vor.ridge_vertices:
if a < 0 or b < 0: # ridge runs to infinity
continue
seg = LS([vor.vertices[a], vor.vertices[b]])
if road_poly.contains(seg):
out.append(seg)
return out
road_poly.contains rather than intersects is the important choice: a ridge that pokes outside the road is not part of the axis, and admitting it produces a centerline that leaves the carriageway at every boundary concavity.
3. Prune spurs relative to local width #
import networkx as nx
def prune(ridges, road_poly, factor: float = 1.5) -> nx.Graph:
G = nx.Graph()
for seg in ridges:
a, b = tuple(np.round(seg.coords[0], 3)), tuple(np.round(seg.coords[-1], 3))
G.add_edge(a, b, weight=seg.length)
changed = True
while changed:
changed = False
for leaf in [n for n in G if G.degree(n) == 1]:
nb = next(iter(G[leaf]))
local_w = 2.0 * road_poly.exterior.distance(
__import__("shapely").geometry.Point(nb))
if G[leaf][nb]["weight"] < factor * local_w:
G.remove_node(leaf)
changed = True
return G
Key parameters: factor at 1.5 removes lay-bys, tapers and boundary noise while keeping genuine side roads, which are longer than the carriageway is wide. Rounding node coordinates to a millimetre is what makes two ridges that share a Voronoi vertex share a graph node.
4. Stitch the spine into one ordered polyline #
def spine(G: nx.Graph) -> np.ndarray:
ends = [n for n in G if G.degree(n) == 1]
if len(ends) < 2:
raise ValueError("pruned graph has no two endpoints — over-pruned")
src, dst = max(((a, b) for i, a in enumerate(ends) for b in ends[i+1:]),
key=lambda p: nx.shortest_path_length(G, *p, weight="weight"))
return np.array(nx.shortest_path(G, src, dst, weight="weight"))
The longest path between two leaves is the spine; on a segment with a genuine fork this returns the through route, and the branch is recovered by re-running on the residual graph.
What the boundary sampling costs at each spacing, and where the axis stops improving:
Verification & Acceptance Criteria #
def assert_medial_axis(spine_xy, road_poly, boundaries, tol=0.05) -> None:
line = LineString(spine_xy)
assert road_poly.contains(line), "centerline leaves the road polygon"
left, right = boundaries
for p in spine_xy[::10]:
pt = __import__("shapely").geometry.Point(p)
assert abs(left.distance(pt) - right.distance(pt)) <= tol, \
"centerline is not equidistant from the two boundaries"
d = np.linalg.norm(np.diff(spine_xy, axis=0), axis=1)
assert d.max() < 5.0, "spine has a gap — pruning disconnected the graph"
Acceptance gate: the spine contained in the road polygon; equidistance from the two boundaries within 0.05 m at every sampled station; no gap larger than 5 m; and a vertex count that falls, not rises, when the prune factor is raised — the cheap check that pruning is monotone.
What the prune factor removes at each setting, which is how the 1.5 default was arrived at:
Common Errors & Fixes #
The centerline zig-zags. The boundary is too coarsely sampled, so the axis is chasing individual vertices. Densify to a fifth of the width.
The centerline leaves the carriageway at every bend. Interior ridges were selected with intersects rather than contains.
Pruning removes a real side road. The factor is too high, or the local width is being read at the leaf rather than at its neighbour. Measure width at the interior node.
The pruned graph has no endpoints. Everything was pruned, usually because the road polygon is much wider than expected — check the polygon, not the factor.
Two runs on the same input give different spines. Node coordinates are not rounded, so floating-point differences split shared vertices. Round before adding edges.
FAQ #
How dense must the boundary sampling be? #
Well below the narrowest road width in the segment — a fifth of it is a safe rule. The medial axis is defined by which boundary vertices are nearest, so a coarse boundary produces a jagged axis that zig-zags between the sample points rather than running down the middle. Densifying is cheap and the Voronoi cost is only linearithmic in vertex count, so err on the fine side.
Why prune by local width rather than by an absolute length? #
Because the spurs a medial axis grows are proportional to the feature that caused them. A lay-by on a 7 metre carriageway produces a longer spur than the same lay-by on a 3 metre lane, and an absolute threshold either keeps the first or removes real geometry from the second. Expressing the threshold as a multiple of local width — around 1.5 — survives a carriageway that widens without re-tuning.
When is a Voronoi axis worth the cost over midpoint averaging? #
When the two boundaries are not in correspondence: at intersections, at merges, wherever one side has more vertices than the other, and wherever the width varies. Midpoint averaging needs a pairing between left and right vertices and quietly invents one when none exists. The Voronoi axis needs no pairing at all — it is defined by distance to the boundary as a whole — which is exactly why it costs more.
Related #
- Smoothing Centerlines with Quadratic Programming — the stage that turns this spine into a drivable curve.
- Choosing Between Centerline Methods for Intersection Geometry — when this method is the right one.
- Extracting Lane Boundaries from Point Cloud Data — where the boundaries come from.
Up one level: Centerline Generation Algorithms — the stage this method belongs to.