Managing Map Tile Boundaries in ROS2: Prefetch, Stitch, and Memory-Bounded Eviction
This page implements a map_tile_loader node that prefetches adjacent HD-map tiles, stitches lanelet/OpenDRIVE edges across partition seams, and evicts stale tiles under a hard RAM ceiling — the runtime layer that keeps a tiled lane-level topology graph continuous as the ego-vehicle crosses tile boundaries at speed.
Tiled HD maps trade global memory residency for bounded working sets, but the trade only holds if seam handling is deterministic. A misaligned or late-loaded boundary surfaces downstream as a routing-graph fracture (planner sees a phantom cul-de-sac), a perception-to-map registration jump, or a localization state reset. The steps below produce continuous, topologically valid graphs across seams with bounded latency and a hard resident-set ceiling of 1.5 GB for the active tile set.
The prefetch-and-stitch loop with trajectory-aware memory eviction:
Prerequisites #
This step runs inside a ROS2 Humble (or later) workspace and assumes the tile grid was already produced upstream by the tiling stage of the HD Mapping Architecture & Spatial Data Standards pipeline. Concretely:
- ROS2 Humble Hawksbill,
rmw_cyclonedds_cppasRMW_IMPLEMENTATION, with iceoryx shared-memory transport available. - Python 3.10+,
numpy1.24+,shapely2.0+,pyproj3.5+,lxml4.9+ (Lanelet2/OpenDRIVE XML parsing),networkx3.1+ (boundary-node stitching). - Tile layout: a uniform metric grid (default 250 m × 250 m) indexed by integer
(tile_x, tile_y), each tile carrying a 50–200 m overlap apron sized to the LiDAR field-of-view and planning horizon. Per-tile geometry is normalized to a local ENU tangent plane against a single map origin, with absolute WGS84 metadata retained — see coordinate reference systems for AVs for the projection contract this step depends on. - Inputs consumed:
nav_msgs/Odometryon/odomand on-disk tiles as memory-mappable.npygeometry plus Lanelet2.osm/ OpenDRIVE.xodrtopology. - Alignment tolerance: shared-edge vertices across neighbouring tiles must agree to ≤0.05 m RMSE before a seam is treated as stitchable.
Step-by-step #
1. Map odometry to the active tile and detect seam approach #
The loader keys on the ego pose, computes the current tile index, and checks the signed distance to each of the four tile edges. A crossing is "approaching" once the vehicle is within the overlap apron, which is what gives the async fetch time to complete before the seam is reached.
import numpy as np
TILE_SIZE_M = 250.0 # uniform grid pitch
OVERLAP_M = 120.0 # apron width; 50–200 m per FOV + planning horizon
ORIGIN_E, ORIGIN_N = 0.0, 0.0 # ENU map origin (metres)
def tile_index(e: float, n: float) -> tuple[int, int]:
"""ENU easting/northing -> integer tile (x, y)."""
return int((e - ORIGIN_E) // TILE_SIZE_M), int((n - ORIGIN_N) // TILE_SIZE_M)
def approaching_seams(e: float, n: float) -> list[tuple[int, int]]:
"""Return neighbour tile indices whose seam is within the overlap apron."""
tx, ty = tile_index(e, n)
local_e = (e - ORIGIN_E) - tx * TILE_SIZE_M # 0..TILE_SIZE_M within tile
local_n = (n - ORIGIN_N) - ty * TILE_SIZE_M
neighbours = []
if local_e < OVERLAP_M: neighbours.append((tx - 1, ty))
if local_e > TILE_SIZE_M - OVERLAP_M: neighbours.append((tx + 1, ty))
if local_n < OVERLAP_M: neighbours.append((tx, ty - 1))
if local_n > TILE_SIZE_M - OVERLAP_M: neighbours.append((tx, ty + 1))
return neighbours
approaching_seams returns [] when the ego is in the tile interior and one or two neighbour indices near an edge or corner. Wire it to the /odom callback and request any returned index not already resident.
2. Memory-map tile geometry instead of deserializing XML into the heap #
Parsing a full Lanelet2 or OpenDRIVE tree into Python objects on every load is the dominant cause of latency spikes. Keep geometry as a memory-mapped structured array so the OS pages it lazily, and parse only the topology edges you actually stitch.
import numpy as np
# Structured dtype: lane vertices kept as a flat mmap, topology kept separate.
VERT_DTYPE = np.dtype([("lane_id", "i8"), ("e", "f8"), ("n", "f8"), ("z", "f8")])
def load_tile_geometry(path: str) -> np.memmap:
"""Lazily map a tile's vertex array; no full read into RAM."""
return np.memmap(path, dtype=VERT_DTYPE, mode="r")
def shared_edge_vertices(arr: np.memmap, axis: str, value: float,
band: float = 0.5) -> np.ndarray:
"""Vertices lying on the shared seam edge (within `band` metres)."""
coord = arr["e"] if axis == "e" else arr["n"]
mask = np.abs(coord - value) <= band
return arr[mask]
load_tile_geometry returns a np.memmap; resident memory rises only for the pages actually touched. shared_edge_vertices extracts the thin band of vertices on a seam for the alignment check in step 3.
3. Validate seam alignment with a shared-edge least-squares residual #
Before stitching, confirm the two tiles agree on the seam. Match nearest shared-edge vertices between the active tile and the incoming neighbour and compute the RMSE; reject the merge if it exceeds 0.05 m, because stitching across a misaligned seam injects a lateral kink the planner reads as a real geometry feature.
import numpy as np
from scipy.spatial import cKDTree
def seam_rmse(active: np.ndarray, neighbour: np.ndarray) -> float:
"""RMSE between matched shared-edge vertices of two tiles (metres)."""
a = np.column_stack([active["e"], active["n"], active["z"]])
b = np.column_stack([neighbour["e"], neighbour["n"], neighbour["z"]])
if len(a) == 0 or len(b) == 0:
return float("inf")
dist, _ = cKDTree(b).query(a, k=1)
return float(np.sqrt(np.mean(dist ** 2)))
ALIGN_TOL_M = 0.05
def seam_is_stitchable(active: np.ndarray, neighbour: np.ndarray) -> bool:
return seam_rmse(active, neighbour) <= ALIGN_TOL_M
seam_rmse returns a metric residual; inf means one side had no edge vertices (an indexing or apron-sizing bug, not a near-miss). A stitchable seam reports a residual at or below 0.05 m.
4. Stitch virtual boundary nodes across the seam #
Lanelet and OpenDRIVE segments that span a seam are split at the tile edge, so each tile holds a dangling half-edge. Insert a virtual boundary node that fuses the matched endpoints into one graph vertex; without it the routing planner treats both halves as dead ends. This is the runtime mirror of the connectivity rules enforced by the topological validation rules stage.
import networkx as nx
import numpy as np
def stitch_boundary(graph: nx.DiGraph,
active_ends: np.ndarray,
neighbour_ends: np.ndarray,
weld_tol: float = 0.05) -> int:
"""Weld matched seam endpoints into shared nodes. Returns edges welded."""
welded = 0
for ae in active_ends:
d = np.hypot(neighbour_ends["e"] - ae["e"], neighbour_ends["n"] - ae["n"])
j = int(np.argmin(d)) if len(d) else -1
if j >= 0 and d[j] <= weld_tol:
a_node = (int(ae["lane_id"]), "seam")
n_node = (int(neighbour_ends[j]["lane_id"]), "seam")
graph.add_edge(a_node, n_node, kind="virtual_boundary", weight=float(d[j]))
graph.add_edge(n_node, a_node, kind="virtual_boundary", weight=float(d[j]))
welded += 1
return welded
stitch_boundary returns the count of welded edges; it should equal the number of lanes crossing the seam. A return of 0 at a multi-lane seam means the endpoints fell outside weld_tol — re-check the step-3 RMSE before publishing the merged graph.
5. Evict tiles outside the predicted trajectory under a hard ceiling #
When resident memory crosses 85% of the 1.5 GB active-set ceiling, evict the least-recently-used tiles that also fall outside the ego's predicted 10-second kinematic envelope. Pinning the eviction to the trajectory prevents flushing a tile the planner is about to re-enter.
from collections import OrderedDict
CEILING_BYTES = int(1.5 * 1024**3)
HIGH_WATER = 0.85
def evict_tiles(resident: "OrderedDict[tuple[int,int], dict]",
predicted_tiles: set[tuple[int, int]],
used_bytes: int) -> list[tuple[int, int]]:
"""LRU-evict tiles outside the trajectory until below the high-water mark."""
evicted = []
target = int(CEILING_BYTES * HIGH_WATER)
for idx in list(resident.keys()): # OrderedDict = LRU order
if used_bytes <= target:
break
if idx in predicted_tiles:
continue # never evict the path ahead
used_bytes -= resident[idx]["nbytes"]
del resident[idx]
evicted.append(idx)
return evicted
evict_tiles returns the indices it dropped and leaves resident below the 85% mark. Because mapped tiles back onto np.memmap, deleting the reference releases the pages without an explicit free.
Verification and acceptance criteria #
Confirm the seam handling against four gates before promoting the node:
- Alignment:
seam_rmse(active, neighbour) <= 0.05for every crossed seam in a replayed run. Log the residual on each merge and assert the max stays ≤0.05 m. - Connectivity: after
stitch_boundary, the mergednx.DiGraphhas zero seam-local nodes with out-degree 0 except genuine map terminals —assert nx.number_of_isolates(merged) == 0and no new sinks appear within the apron band. - Latency: trace the odometry-callback-to-graph-publish span with
ros2_tracing; the p99 prefetch-plus-stitch span must stay under the inter-tile travel time (≥4.5 s at 250 m / 55 m·s⁻¹), so the fetch never blocks the planner. - Memory: sample resident set with
ros2 topic hzplus/proc/self/statmduring a dense urban rosbag; peak must hold below 1.5 GB and never trigger an OOM kill.
A passing run replays a recorded boundary-crossing rosbag, crosses every seam, and publishes a continuous routing graph with no latency spike above the per-tile budget.
Common errors and fixes #
Planner reports a phantom dead end after a crossing. The seam was detected but stitch_boundary returned 0 — the half-edges never welded. Almost always the neighbour tile's geometry was not yet resident when the merge fired, so the apron is too small for the fetch latency. Widen OVERLAP_M toward 200 m or move the fetch earlier in approaching_seams.
seam_rmse returns inf. One tile contributed no shared-edge vertices. The seam axis/value passed to shared_edge_vertices does not match the tile pitch — verify value is the seam coordinate in the same ENU frame, not a per-tile-local offset. A datum mismatch here is the same class of bug as a mis-set transformer in the projection layer.
RMSE is small but the merged centerline shows a lateral kink. The seam is aligned in plan view but the two tiles were normalized to different ENU origins, so z and heading diverge. Re-normalize both tiles against the single map origin before stitching; the apron geometry must share one tangent plane, not two adjacent ones.
Resident set climbs past 1.5 GB and the node is OOM-killed in dense urban grids. evict_tiles is being starved because too many tiles fall inside predicted_tiles. Shorten the kinematic envelope from 10 s, or down-sample the prediction so the protected set cannot grow unbounded at low speed in stop-and-go traffic.
Related #
- Lane-Level Topology Modeling for Autonomous Vehicle Spatial Data — the parent workflow that produces the directed lane graph these tiles partition.
- Coordinate Reference Systems Engineering Workflow for AV Pipelines — the ENU normalization contract every tile must satisfy before its seams can be stitched.
- Topological Validation Rules — the offline connectivity checks that the virtual boundary nodes must keep satisfied at runtime.
Up one level: this how-to sits under Lane-Level Topology Modeling.