Simplifying Shorelines Without Breaking Topology
A shoreline digitised at 1:10,000 carries far more vertices than a 50-metre analysis grid can use. Dropping them is obviously worthwhile — every spatial predicate, every render, every serialization pays for vertices — and the obvious call, gdf.geometry.simplify(25), produces a layer that is smaller, looks identical at review zoom, and is quietly no longer a partition of the coast. This page explains why, within the coastal vector processing workflow, and gives the shared-edge procedure that gets the same reduction without the damage.
Root cause: two features, two independent copies of one boundary
In a simple-features layer, adjacency is not represented. Two polygons that share a boundary each store their own complete copy of it, vertex for vertex. They agree exactly because the digitiser snapped them, not because the format knows they are related.
Douglas-Peucker is deterministic but not local: which vertices it keeps on a run of coordinates depends on the endpoints of the run and on the vertices around it. Polygon A’s copy of the shared boundary is embedded in A’s ring, with A’s other vertices before and after it; polygon B’s copy sits in a different ring with different neighbours. The two copies are therefore simplified against different context and come out different.
The result is not a small error. Wherever the two simplified lines cross, you get alternating overlaps and gaps — slivers — a few metres wide and running the length of every shared boundary in the layer.
import geopandas as gpd
zones = gpd.read_file("management_zones.gpkg").to_crs("EPSG:32618")
print(zones.geometry.area.sum()) # 4_182_119_884.2 (the true total)
naive = zones.copy()
naive.geometry = zones.geometry.simplify(25.0)
print(naive.geometry.area.sum()) # 4_182_004_711.9 (looks fine)
# The damage is not in the total; it is in the pairwise relationships.
pairs = naive.sindex.query(naive.geometry, predicate="overlaps")
print(len(pairs[0])) # 3_884 overlapping pairs, from 0
The area total barely moves, because the slivers gain and lose roughly equally. Every check that looks at the layer as a whole passes. The check that fails is the one nobody runs.
Step-by-step fix with production code
1. Choose the tolerance from the analysis, not the file size
The tolerance is a statement about what spatial detail the downstream analysis can act on. A habitat model on a 100 m grid cannot use detail below roughly half a cell; a dredging boundary that determines a pay volume cannot lose a metre. Deriving it:
def tolerance_for_grid(cell_size_m: float, safety: float = 0.4) -> float:
"""Simplification tolerance for a layer feeding a raster analysis.
Keeps displacement well inside a cell so a boundary never crosses into a
neighbouring cell as a result of simplification alone.
"""
if cell_size_m <= 0:
raise ValueError("cell_size_m must be positive")
return cell_size_m * safety
tolerance_for_grid(100.0) # 40.0 m — habitat model
tolerance_for_grid(2.0) # 0.8 m — dredge design surface
Where one layer feeds analyses at both ends of that range, publish it twice with the tolerance in the filename. A compromise tolerance is wrong for both consumers and, unlike two files, cannot be corrected downstream.
2. Build a topology, simplify the arcs, rebuild
The Python route is the topojson package, which constructs the shared-arc representation, applies the tolerance per arc, and returns a GeoDataFrame. The arcs are the unit of simplification, so a boundary shared by two features is simplified exactly once.
import logging
import geopandas as gpd
import topojson as tp
logger = logging.getLogger(__name__)
def simplify_preserving_topology(
gdf: gpd.GeoDataFrame, tolerance_m: float
) -> gpd.GeoDataFrame:
"""Shared-edge simplification. Input must be in a projected CRS."""
if gdf.crs is None or gdf.crs.is_geographic:
raise ValueError("tolerance is in CRS units; use a projected CRS")
if not bool(gdf.geometry.is_valid.all()):
raise ValueError("simplify a valid layer only; repair it first")
before = int(gdf.geometry.count_coordinates().sum())
topo = tp.Topology(gdf, prequantize=False, shared_coords=True)
out = topo.toposimplify(tolerance_m).to_gdf()
out.crs = gdf.crs
after = int(out.geometry.count_coordinates().sum())
logger.info(
"toposimplify %.2f m: %d → %d vertices (%.1f%% reduction)",
tolerance_m, before, after, 100.0 * (1.0 - after / before),
)
return out
prequantize=False matters. Quantization snaps coordinates to an integer lattice before simplification and is a second, hidden simplification with its own tolerance; leaving it on means the effective displacement is larger than the tolerance you asked for and you cannot say by how much.
Outside Python, mapshaper -simplify and PostGIS ST_SimplifyPreserveTopology do the same job; PostGIS’s version preserves topology within a single geometry but not across features, so for a multi-feature partition it is ST_CoverageSimplify (PostGIS 3.4+) that is wanted, not the older function whose name suggests otherwise.
3. Verify the partition survived
def assert_still_a_partition(gdf: gpd.GeoDataFrame, max_sliver_m2: float = 1.0) -> None:
"""No feature pair may overlap, beyond a numerical-noise tolerance."""
left, right = gdf.sindex.query(gdf.geometry, predicate="overlaps")
total = 0.0
for i, j in zip(left, right):
if i >= j:
continue # each pair once
total += gdf.geometry.iloc[i].intersection(gdf.geometry.iloc[j]).area
if total > max_sliver_m2:
raise ValueError(f"simplification opened {total:,.1f} m² of overlap slivers")
logger.info("partition intact: %.3f m² total overlap", total)
Verification and acceptance test
def test_simplification_is_lossless_for_the_partition():
zones = gpd.read_file("management_zones.gpkg").to_crs("EPSG:32618")
simple = simplify_preserving_topology(zones, tolerance_for_grid(100.0))
assert_still_a_partition(simple)
assert len(simple) == len(zones)
rel = abs(simple.geometry.area.sum() - zones.geometry.area.sum()) / zones.geometry.area.sum()
assert rel < 1e-3, f"area changed by {rel:.4%}"
# Displacement bound: no simplified boundary may move further than the tolerance.
assert simple.geometry.hausdorff_distance(zones.geometry).max() <= 40.0
The Hausdorff assertion is the one worth keeping. Area and count can both survive a simplification that moved a boundary a kilometre in one place and compensated elsewhere; the Hausdorff distance is the direct statement of “nothing moved further than I authorised”.
What Simplification Costs Downstream, and How to Say So
A simplified shoreline is a different shoreline, and the difference propagates into every product built on it. The three effects worth quantifying before publishing are length, area and the position of the boundary itself.
Length falls, always, and by a lot. Coastlines are close to fractal at survey scales, so their measured length is a function of the ruler. Simplifying at 40 m typically removes 15–30% of the measured length of a rocky coast and much less of a straight sandy one. Any product that reports “kilometres of shoreline” — habitat extent, erosion exposure, permit frontage — is reporting a number that depends on a parameter buried three stages upstream. Publish the tolerance next to the figure or the figure is not reproducible.
Area is nearly conserved and locally is not. Douglas-Peucker cuts corners in both directions, so a long boundary loses and gains area in roughly equal measure and the total barely moves. Locally the picture is different: a headland can lose a hectare while the bay beside it gains one. Zonal statistics over small polygons — a single berth, a monitoring quadrat — feel this directly even when the layer-wide area check passes.
Boundary position moves by up to the tolerance, by construction. That is what the tolerance means, and it is why the Hausdorff assertion above is the honest test. A downstream consumer computing distance-to-shore is inheriting that displacement as a systematic error, so it belongs in their uncertainty budget, which means it has to reach them.
The mechanism for all three is the same and it is cheap: write simplification_tolerance_m, vertices_before, vertices_after and max_displacement_m into the layer metadata at the moment the simplification runs. Four numbers, generated rather than typed, and every question above becomes answerable without re-running anything.
Edge cases and gotchas
- The outer coastline is not shared with anything, and still must not move. Where a layer’s outer boundary is also the boundary of a published national shoreline, simplifying it independently makes the two disagree. Either clip to the authoritative line after simplification, or exclude the outer ring from simplification and accept the vertex cost.
- Islands smaller than the tolerance disappear. Topology-preserving simplification will not create invalid geometry, but a 30 m islet under a 40 m tolerance collapses and is dropped. Check the feature count, and if small islands matter, set a minimum-area floor and exclude those features from the simplification pass entirely.
prequantizedefaults toTrueintopojson. Left on, it snaps to a lattice sized from the data extent before your tolerance is applied, so the effective displacement is larger than requested and varies with the layer’s bounding box. Set it off, or measure the Hausdorff distance and stop trusting the tolerance parameter.
Related
- Repairing Invalid Coastline Polygons in Shapely — simplify only a valid layer; this is how to get one
- Clipping Marine Rasters to a Coastline Mask — where the simplification tolerance meets the raster cell size
- Vector Shoreline & Coastal Geometry Processing — the parent workflow and its validation gates