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:

Cross-Tile Lane Attribute Join Tile A and tile B separated by a dashed seam with an overlap buffer band. A lane fragment from each tile ends in the buffer. An arrow leads to a merge box producing one global lane record with blended attributes. A gate states the width step across the seam must stay under 0.05 metres. Tile A Tile B overlap buffer match by endpoint + heading Merge → global lane record blend width & heading · stable id width step ≤ 0.05 m

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.

python
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.

python
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.

python
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.

python
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.

Up one level: Batch Lane-Attribute Extraction — the parent workflow whose tile outputs this seam join stitches together.