Handling UTM Zone Boundary Crossings in AV Maps
Route an autonomous vehicle across a 6° UTM meridian and, unless the map is built for it, the vehicle frame jumps by hundreds of kilometres of easting the instant a tile switches zones — a discontinuity no localization filter recovers from cleanly. This task makes the crossing invisible: assign one zone per tile, stand up an overlap corridor around the meridian, blend the two zone solutions across it, and gate the residual step. It follows directly from the coordinate reference systems for AVs stage, which commits each tile to a projection; here we handle what happens where two of those projections meet.
An overlap corridor around the 6° meridian lets the vehicle frame blend from zone A to zone B without a step:
Prerequisites #
- Python 3.10+, pyproj 3.6+ (PROJ 9.x), NumPy 1.24+, and Shapely 2.0+ for corridor geometry.
- Input: HD map tiles with per-tile metadata carrying a resolved UTM EPSG code (from the WGS84→UTM conversion stage) and a tile centroid in WGS84.
- Upstream stage: dynamic zone resolution has already assigned each tile a single zone; this step handles adjacency where those assignments differ.
- Output: an overlap corridor of dual-zone tiles plus a blend function that returns a continuous pose, with the seam discontinuity certified ≤0.02 m.
Step-by-Step #
1. Resolve and pin one zone per tile #
Never let a tile hold coordinates from two zones. Resolve the zone from the tile centroid, and detect tiles near a meridian so they can be flagged for the corridor.
import numpy as np
def utm_zone(lon: float) -> int:
return int((lon + 180.0) // 6) + 1 # 1..60
def zone_central_meridian(zone: int) -> float:
return -180.0 + (zone - 0.5) * 6.0 # degrees longitude
def near_meridian(lon: float, lat: float, margin_m: float = 400.0) -> bool:
"""True if the point is within margin_m of its zone's east/west edge."""
edge_lon = -180.0 + round((lon + 180.0) / 6.0) * 6.0 # nearest 6° edge
m_per_deg = 111_320.0 * np.cos(np.radians(lat))
return abs(lon - edge_lon) * m_per_deg < margin_m
Key parameters: margin_m is the corridor half-width in metres; m_per_deg scales longitude degrees to metres at the tile's latitude so the margin is metric, not angular. Expected output: a boolean flag per tile marking corridor membership.
2. Build the overlap corridor #
For every corridor tile, materialize a second copy reprojected into the neighbouring zone. The vehicle then always has coverage in whichever zone it currently occupies.
from pyproj import Transformer
def dual_zone_tile(points_wgs84: np.ndarray, zone_a: int, zone_b: int):
"""Return the tile's points in both neighbouring UTM zones (metres)."""
def to_zone(epsg):
tr = Transformer.from_crs(4326, epsg, always_xy=True)
e, n = tr.transform(points_wgs84[:, 0], points_wgs84[:, 1])
return np.column_stack([e, n])
epsg_a = 32600 + zone_a
epsg_b = 32600 + zone_b
return {"zone_a": to_zone(epsg_a), "zone_b": to_zone(epsg_b)}
Key parameters: zone_a/zone_b are the adjacent zones; the function returns both projections so the runtime can pick by current position. Store both under the tile id with the source WGS84 retained for re-derivation.
3. Blend poses across the seam #
Inside the corridor, compute the pose in both zones and linearly weight them by the vehicle's fractional position across the corridor. Blending happens after reprojecting both solutions into one working frame (ENU or the outgoing zone) so the interpolation is metrically meaningful.
def blend_pose(pose_a: np.ndarray, pose_b: np.ndarray, t: float) -> np.ndarray:
"""Linear blend of two same-frame poses; t in [0, 1] across the corridor."""
t = float(np.clip(t, 0.0, 1.0))
return (1.0 - t) * pose_a + t * pose_b
Key parameter: t is the crossing fraction — 0 at the entry edge, 1 at the exit edge — so the vehicle frame slides continuously from zone A to zone B. Both inputs must already be in the same working frame; blending raw eastings from two different zones is meaningless.
Verification & Acceptance Criteria #
Certify the seam before shipping the tile pair. Sample poses densely across the corridor and confirm the maximum step between consecutive samples stays under the discontinuity budget.
def assert_seam_continuous(poses: np.ndarray, tol_m: float = 0.02):
steps = np.linalg.norm(np.diff(poses, axis=0), axis=1)
# remove the expected per-sample motion; only the residual jump matters
residual = steps - np.median(steps)
assert residual.max() < tol_m, f"seam step {residual.max():.3f} m > {tol_m}"
print(f"max seam residual = {residual.max()*1000:.1f} mm") # < 20 mm
Acceptance gate: maximum seam residual ≤0.02 m, blend weight t monotonic across the corridor, and both zone copies present for every corridor tile. Cross-check that no shipped tile carries two EPSG codes — the single-zone invariant from the parent CRS stage still holds per tile.
Common Errors & Fixes #
Easting jumps ~500 km at the tile boundary. The runtime switched zones without reprojecting the previous pose into the new zone. Always transform the carried-forward pose into the incoming zone before comparing or blending; never subtract eastings across zones.
t never reaches 1 and the blend snaps at the far edge. The corridor is narrower than the localization lookahead, so the vehicle exits before the blend completes. Widen margin_m to at least the lookahead distance plus one tile length.
Scale error grows toward the meridian. Tiles were forced into a single zone across too wide a span. Confirm each tile uses its own centroid-resolved zone; beyond ~3–4° from the central meridian the transverse-Mercator scale exceeds the 0.05 m budget and needs the neighbouring zone.
Blended heading spins near ±180°. Linear interpolation of raw yaw wraps incorrectly across the branch cut. Blend heading as a unit vector (or with SLERP), not as a raw angle — the same interpolation used in motion-compensating LiDAR scans with SLERP.
FAQ #
Why not just keep the whole map in one UTM zone? #
Forcing a distant region into a single UTM zone grows the transverse-Mercator scale error beyond the 0.04 percent zone-edge budget — past roughly 3 to 4 degrees of longitude from the central meridian the scale distortion exceeds the 0.05 m RMSE localization tolerance. A fleet operating across a wide east-west span must use multiple zones and manage the seam, or switch to a custom transverse Mercator centred on the operational region.
How wide should the overlap corridor be? #
Size it to at least the localization lookahead plus one tile, typically 200 to 500 m either side of the meridian. It must be wide enough that the vehicle is fully inside the corridor for several localization cycles, so the blend completes before either zone's coverage runs out. Wider corridors cost duplicated storage; narrower ones risk a hard switch mid-manoeuvre.
Can I avoid the seam with a local tangent plane instead? #
Yes. Switching the runtime planner to a local ENU tangent plane anchored at a per-tile origin removes the zone seam entirely, because ENU has no zone concept. The trade-off is per-tile origin bookkeeping and a reprojection when tiles are stitched. Many fleets keep the archival map in UTM for reproducibility and derive ENU at runtime, which localizes the seam problem to tile load rather than the live control loop.
Related #
- Converting WGS84 to UTM for AV Pipelines — the batch projection that assigns each tile its zone in the first place.
- Managing Map Tile Boundaries in ROS2 — the runtime tile-loading logic that consumes the dual-zone corridor.
- Coordinate Reference Systems for AVs — the parent stage covering zone resolution, datum, and geoid handling.
Up one level: Coordinate Reference Systems for AVs — the parent workflow covering zone resolution, datum, and geoid handling this seam step extends.