Joining Lane Attributes Across Map Tiles
Process a map tile-by-tile and every lane that crosses a seam gets extracted twice, from two partial views — and joined naively, the two halves leave a step in width or heading right at the border that a lateral controller feels. This task reconciles them: match lane fragments across the seam by geometry, merge their attribute records under one stable global id, and gate the join on cross-seam continuity. It is the seam-repair stage that makes batch lane-attribute extraction whole after a distributed pass such as Dask parallelization.
Overlapping tile buffers let border fragments be matched and their attributes blended into one continuous record:
Prerequisites #
- Python 3.10+, GeoPandas 0.14+, Shapely 2.0+ (STRtree), NumPy 1.24+.
- Input: per-tile attribute tables produced with a small overlap buffer, each holding lane fragments with width, heading, and marking attributes in one projected CRS.
- Upstream stage: the distributed extraction pass has written one attribute file per tile.
- Output: a merged attribute table where cross-seam lanes carry a single global id and continuous attributes.
Step-by-Step #
1. Select border fragments in the overlap buffer #
Only lanes touching the seam need joining. Select fragments whose endpoints fall inside the overlap buffer of each adjacent tile pair.
import numpy as np
def border_fragments(gdf, seam_x: float, buffer_m: float = 5.0):
"""Fragments with an endpoint within buffer_m of the vertical seam."""
def ends(geom):
c = np.asarray(geom.coords)
return c[0], c[-1]
keep = []
for idx, row in gdf.iterrows():
p0, p1 = ends(row.geometry)
if min(abs(p0[0] - seam_x), abs(p1[0] - seam_x)) <= buffer_m:
keep.append(idx)
return gdf.loc[keep]
Key parameter: buffer_m is the seam overlap half-width; it must match the buffer the tiles were processed with so a border lane appears in both.
2. Match fragments across the seam #
Pair a fragment from tile A with one from tile B when their near-seam endpoints are within a match radius and their headings agree.
def match_fragments(frag_a, frag_b, radius=1.0, heading_tol_deg=8.0):
"""Return (a_idx, b_idx) pairs matched by proximity and heading."""
pairs = []
for ia, ra in frag_a.iterrows():
ea = np.asarray(ra.geometry.coords)[-1]
for ib, rb in frag_b.iterrows():
eb = np.asarray(rb.geometry.coords)[0]
if np.linalg.norm(ea - eb) <= radius and \
abs(ra.heading - rb.heading) <= heading_tol_deg:
pairs.append((ia, ib))
return pairs
Key parameters: radius (1 m) bounds endpoint distance across the seam; heading_tol_deg (8°) prevents matching two different lanes ending near the same point.
3. Merge and blend attributes #
Assign one global id, and blend continuous attributes across the overlap so nothing steps at the seam.
def merge_pair(ra, rb, global_id):
w = 0.5 * (ra.width + rb.width) # blend continuous attrs
h = 0.5 * (ra.heading + rb.heading)
marking = ra.marking if ra.n_vertices >= rb.n_vertices else rb.marking
return {"global_id": global_id, "width": w, "heading": h,
"marking": marking, "sources": [ra.tile, rb.tile]}
Key parameter: continuous attributes are averaged (blend across the overlap for a smooth transition); categorical attributes take the value from the fragment with more near-seam evidence.
Verification & Acceptance Criteria #
Gate every join on cross-seam continuity so no attribute jumps at the border.
def assert_seam_continuous(merged, tol_width=0.05, tol_head=2.0):
for m in merged:
assert abs(m["width_a"] - m["width_b"]) <= tol_width, \
f"width step {abs(m['width_a']-m['width_b']):.3f} m at {m['global_id']}"
assert abs(m["heading_a"] - m["heading_b"]) <= tol_head, "heading step too large"
print(f"{len(merged)} seams joined, all within tolerance")
Acceptance gate: width step ≤0.05 m and heading step ≤2° across every joined seam; every cross-seam lane has exactly one global id; and each merged record lists its source tiles for audit. Steps beyond tolerance point to an extraction edge effect, not a join bug — fix the extractor's border handling.
Common Errors & Fixes #
A lane splits into two global ids at the seam. The match radius is too small or the tiles were processed without an overlap buffer. Confirm the buffer exists and set radius above the typical endpoint spacing.
Two parallel lanes merged into one. Heading was not checked, so two adjacent lanes ending near the seam matched. Enforce heading_tol_deg and, on multi-lane roads, also require lateral-offset agreement.
Width steps persist after blending. The extraction produced genuinely different widths from the two partial views. Widen the overlap buffer so each tile sees more of the lane, reducing the edge effect, then re-extract.
Duplicate records after the join. The reverse pair (B→A) was also merged. Deduplicate by canonicalizing each pair as a sorted tuple before merging, the same idempotence guard used in detecting dangling lanes and connectivity gaps.
FAQ #
Why do lane attributes disagree at tile borders? #
Tiles are usually processed independently, so a lane crossing a seam is extracted twice from two partial views. Edge effects — a boundary that runs off the tile, fewer vertices near the border, or a slightly different smoothing window — make the two halves compute marginally different width or heading. Individually each is within tolerance, but joined naively they leave a visible step at the seam that a lateral controller feels as a jolt.
How are fragments matched across a seam? #
By geometry, not id, because independently processed tiles rarely share lane ids. Each tile is processed with a small overlap buffer so a lane near the border appears in both tiles. Fragments are paired when their endpoints fall within a match radius across the seam and their headings agree within a few degrees. Requiring heading agreement prevents matching two different lanes that happen to end near the same point.
Which tile's attribute wins when they differ? #
Neither wins outright. For continuous attributes like width and heading, blend across the overlap so the value transitions smoothly rather than stepping. For categorical attributes like marking type, prefer the value computed from the fragment with more vertices near the seam, since it had more evidence. Always record which tiles contributed so a later audit can trace a joined attribute back to its sources.
Related #
- Parallelizing Lane-Attribute Extraction with Dask — the distributed pass that produces the per-tile tables this join reconciles.
- Automating Lane-Width Attribute Sync — the width extractor whose seam disagreements this step resolves.
- Managing Map Tile Boundaries in ROS2 — the runtime side of the same tile-seam problem.
Up one level: Batch Lane-Attribute Extraction — the parent workflow whose tile outputs this seam join stitches together.