Merging UTM Zone Boundary Datasets Without Slivers Jump to heading

Two survey datasets that meet at a UTM zone boundary — say Zone 17N and Zone 18N along the 84° W meridian — never share exact vertices along the seam. Each was digitized in its own projected grid, so reprojecting both to a common CRS leaves the shared edge as two near-coincident but distinct lines. Union them and you get a chain of sliver polygons: hair-thin gaps and overlaps a few centimetres wide that break topology, inflate feature counts, and fail area-conservation checks. This procedure reprojects both zones to one grid, snaps the seam, and removes the slivers deterministically. It is a focused build within Multi-CRS Dataset Harmonization, the parent stage inside CRS Normalization & Sync that resolves many source CRS into one authoritative output.

The steps follow configure (Step 1, common target and reprojection), execute (Steps 2-4, snap, union, remove), and log (Step 5, seam lineage). The hard part is not the reprojection — Datum Transformation Fallback Chains already owns that — it is making two independently-digitized edges become one.

Prerequisites checklist Jump to heading

Step 1: Choose a common target and reproject both zones Jump to heading

Neither UTM zone can be the target — each is only valid within its own 6° band, and forcing zone-18 data into zone-17 coordinates distorts it near the far edge. Pick a CRS that legitimately spans both zones (a NAD83 UTM-adjacent grid, a state plane zone covering the corridor, or an equal-area projection) and reproject both onto it. Deciding which spanning CRS minimizes distortion is covered in Selecting a Minimum-Distortion Target CRS.

python
# geopandas >= 0.14, pyproj >= 3.6 — Python 3.10+
import geopandas as gpd

TARGET_CRS = "EPSG:5070"  # NAD83 CONUS Albers Equal Area — spans multiple UTM zones


def reproject_zones(west: gpd.GeoDataFrame, east: gpd.GeoDataFrame) -> tuple[gpd.GeoDataFrame, gpd.GeoDataFrame]:
    """Bring both zone datasets onto one common target CRS before any geometric merge."""
    if west.crs is None or east.crs is None:
        raise ValueError("both datasets must carry a resolvable CRS")
    west_t = west.to_crs(TARGET_CRS)
    east_t = east.to_crs(TARGET_CRS)
    assert west_t.crs.equals(east_t.crs), "reprojection targets diverged"
    return west_t, east_t

Target rules:

  • Choose a CRS whose domain of validity covers the full merged extent; a single UTM zone does not.
  • Prefer an equal-area projection when the merge feeds area-conservation checks, so the sliver test compares like with like.
  • Reproject before snapping — snapping in mismatched grids moves vertices along the wrong axis.

Step 2: Snap the shared seam Jump to heading

Reprojection alone leaves two distinct edges at the seam. Snap the vertices of each dataset to the other within a tolerance so near-coincident points collapse to identical coordinates. This is the same snapping contract described in setting tolerance thresholds for automated coordinate snapping — the tolerance must exceed the seam misalignment but stay below the smallest real feature.

python
# shapely >= 2.0, geopandas >= 0.14 — Python 3.10+
import geopandas as gpd
from shapely import snap, union_all


def snap_seam(west: gpd.GeoDataFrame, east: gpd.GeoDataFrame, tol_m: float) -> gpd.GeoDataFrame:
    """Snap west-zone vertices onto the east-zone edge so the seam becomes coincident."""
    east_ref = union_all(east.geometry.to_numpy())  # snapping reference geometry
    west = west.copy()
    west["geometry"] = west.geometry.apply(lambda g: snap(g, east_ref, tolerance=tol_m))
    merged = gpd.GeoDataFrame(
        __import__("pandas").concat([west, east], ignore_index=True), crs=west.crs
    )
    merged["geometry"] = merged.geometry.apply(lambda g: g if g.is_valid else g.buffer(0))
    return merged

Snap rules:

  • Set tol_m just above the measured seam misalignment (inspect it first) and well below the smallest legitimate parcel width.
  • Snap one dataset onto the other’s edge, not both to a shared grid independently — asymmetric snapping guarantees coincidence.
  • Repair with make_valid/buffer(0) after snapping; collapsing vertices can produce transient self-intersections.

Step 3: Union and detect slivers Jump to heading

Union the snapped datasets, then score every resulting polygon. A sliver is small in area and thin — a low ratio of area to the square of its perimeter (the Polsby-Popper compactness score). Area alone flags small-but-legitimate parcels; combine both to isolate seam artifacts.

python
# shapely >= 2.0, geopandas >= 0.14 — Python 3.10+
import math
import geopandas as gpd


def flag_slivers(gdf: gpd.GeoDataFrame, min_area_m2: float, min_compactness: float) -> gpd.GeoDataFrame:
    """Tag polygons that are both small and thin as slivers."""
    gdf = gdf.explode(index_parts=False).reset_index(drop=True)
    area = gdf.geometry.area
    perim = gdf.geometry.length
    # Polsby-Popper: 4*pi*area / perimeter^2  -> 1.0 is a circle, ~0 is a sliver.
    compactness = (4 * math.pi * area) / perim.pow(2).replace(0, math.nan)
    gdf["is_sliver"] = (area < min_area_m2) & (compactness < min_compactness)
    return gdf

Detection rules:

  • Require both a sub-threshold area and a low compactness score; either alone produces false positives.
  • Explode multipart geometries first so a sliver attached to a valid polygon is scored on its own.
  • Set min_area_m2 from the seam width times a typical parcel length, not an arbitrary constant.

Step 4: Remove or absorb slivers Jump to heading

A flagged sliver is either a gap (remove it, or absorb it into the neighbour that should own it) or an overlap (dissolve the duplicate). Absorb rather than delete when the sliver carries area that must be conserved, so the merged coverage stays gapless.

python
# geopandas >= 0.14, shapely >= 2.0 — Python 3.10+
import geopandas as gpd


def absorb_slivers(gdf: gpd.GeoDataFrame) -> gpd.GeoDataFrame:
    """Merge each sliver into its largest-area touching neighbour; drop true gaps."""
    keep = gdf[~gdf["is_sliver"]].reset_index(drop=True)
    slivers = gdf[gdf["is_sliver"]]
    for _, sliver in slivers.iterrows():
        touching = keep[keep.geometry.touches(sliver.geometry)]
        if touching.empty:
            continue  # orphan gap — dropping it removes a hole, not real land
        idx = touching.geometry.area.idxmax()
        keep.at[idx, "geometry"] = keep.at[idx, "geometry"].union(sliver.geometry)
    keep["geometry"] = keep.geometry.apply(lambda g: g if g.is_valid else g.buffer(0))
    return keep

Removal rules:

  • Absorb slivers into the largest touching neighbour to conserve area along the seam.
  • Drop only orphan slivers with no valid neighbour — those are gaps, not land.
  • Re-validate after each union; absorbing can momentarily invalidate the recipient polygon.

Step 5: Log seam lineage Jump to heading

Record what the seam merge did so an auditor can reproduce it: the snap tolerance, how many slivers were found, and how much area was absorbed.

python
# Python 3.10+
import json
from pathlib import Path


def log_seam(path: Path, tol_m: float, sliver_count: int, absorbed_m2: float) -> None:
    record = {
        "operation": "utm_seam_merge",
        "snap_tolerance_m": tol_m,
        "slivers_detected": sliver_count,
        "absorbed_area_m2": round(absorbed_m2, 4),
    }
    with path.open("a") as fh:
        fh.write(json.dumps(record) + "\n")

Feed these NDJSON rows into the lineage store that Multi-CRS Dataset Harmonization emits, so the merged corridor carries the same manifest-anchored provenance as every other harmonized source consumed by Geospatial Compliance Reporting & Audit Trails.

Verification Jump to heading

Confirm the seam is truly gapless and no slivers survived:

python
merged = absorb_slivers(flag_slivers(snap_seam(w, e, 0.05), min_area_m2=1.0, min_compactness=0.05))

# 1. No sliver-class polygon remains.
assert not merged.get("is_sliver", False).any() if "is_sliver" in merged else True

# 2. Area is conserved within a millimetre-scale budget after absorption.
before = w.to_crs(5070).geometry.area.sum() + e.to_crs(5070).geometry.area.sum()
after = merged.geometry.area.sum()
assert abs(after - before) / before < 1e-4, "area not conserved across the seam"

# 3. The union has no interior gaps along the seam.
from shapely import union_all
assert len(union_all(merged.geometry.to_numpy()).interiors) == 0 \
    if hasattr(union_all(merged.geometry.to_numpy()), "interiors") else True

On the CLI, ogrinfo -so merged.gpkg layer should report a feature count equal to the input total minus absorbed slivers, and a topology check in QGIS or pyproj-driven validation should return zero gap/overlap errors along the meridian.

Troubleshooting Jump to heading

Symptom Likely cause Fix
Hair-thin polygons line the zone boundary Two independently-digitized edges never snapped to coincidence Snap one dataset onto the other’s edge before union (Step 2).
Real small parcels deleted as slivers Sliver test used area alone Require low compactness and sub-threshold area together (Step 3).
Merged area smaller than inputs Slivers dropped instead of absorbed Absorb into the largest touching neighbour; drop only orphans (Step 4).
Distortion grows toward the far edge A single UTM zone used as the target CRS Reproject both zones to a spanning CRS (Step 1).
Snap collapses valid narrow features Tolerance set larger than the smallest real feature Lower tol_m below the minimum feature width; inspect seam offset first.