Detecting and Removing Sliver Polygons in Catchment Boundaries
Sliver polygons are the thin, low-area fragments that appear along catchment divides when a raster boundary is vectorised or when two coverages are overlaid: geometrically valid, topologically legal, and almost always wrong. This guide measures them and dissolves them away in Python, as a focused reference within the Boundary Topology Validation topic, part of the broader Watershed Delineation & Catchment Synchronization coverage on this site.
A sliver is not caught by ordinary validity checks. It passes is_valid, it does not overlap its neighbours, and it may not even leave a gap. What marks it out is shape: it is tiny in area and extremely elongated, hugging a divide it should never have become. Detecting slivers therefore needs a shape metric, and removing them cleanly needs a rule for deciding which neighbour rightfully owns the fragment. The general defect-detection workflow that surfaces these fragments in the first place is covered in validating catchment boundary topology with GeoPandas and Shapely; here the focus is the sliver’s distinctive geometry and its repair.
What makes a sliver measurably different
Two numbers separate a sliver from a legitimate small catchment: absolute area and compactness. A headwater catchment can be small, but it is roughly blob-shaped. A sliver is small and thread-like. The Polsby-Popper score captures exactly that combination.
The Polsby-Popper compactness is 4 * pi * area / perimeter^2. It equals 1 for a circle and collapses toward 0 as a shape stretches thin, which is why it discriminates a genuine small catchment (moderate compactness) from a sliver (near-zero compactness) even when both have small area. Using area and compactness together avoids the two classic mistakes: deleting a real headwater catchment because it is small, or keeping a large-perimeter sliver because its area alone squeaks past the threshold.
Prerequisites
conda create -n sliver python=3.11 geopandas shapely numpy -c conda-forge
conda activate sliver
Operate in a projected CRS with metre units so area and perimeter carry physical meaning. Ideally your coverage has already passed the individual-validity and overlap checks, since merging a sliver into an invalid neighbour propagates the invalidity.
Detecting slivers
The detection pass computes both metrics and flags polygons that fail on area and compactness together.
import logging
import numpy as np
import geopandas as gpd
logger = logging.getLogger(__name__)
def flag_slivers(
catchments: gpd.GeoDataFrame,
area_max: float = 5_000.0,
compactness_max: float = 0.05,
) -> gpd.GeoDataFrame:
"""Flag polygons below both an area and a Polsby-Popper compactness threshold.
area_max : upper area bound in m^2 for a candidate sliver
compactness_max : Polsby-Popper score below which a shape is 'thread-like'
"""
out = catchments.copy()
area = out.geometry.area
perimeter = out.geometry.length
# Polsby-Popper: 1.0 for a circle, -> 0 for an elongated sliver
out["polsby_popper"] = (4.0 * np.pi * area) / np.square(perimeter)
out["area_m2"] = area
out["is_sliver"] = (out["area_m2"] < area_max) & (out["polsby_popper"] < compactness_max)
n = int(out["is_sliver"].sum())
logger.info(
"Flagged %d slivers of %d catchments (area<%.0f m^2 and PP<%.3f)",
n, len(out), area_max, compactness_max
)
if n:
logger.info(
"Sliver area range: %.1f to %.1f m^2",
float(out.loc[out.is_sliver, "area_m2"].min()),
float(out.loc[out.is_sliver, "area_m2"].max()),
)
return out
Always plot a histogram of polsby_popper before committing to compactness_max. Real coverages show a clear bimodal split: a dense grouping of compact catchments near 0.2 to 0.6 and a thin tail of slivers below 0.05. Put the threshold in the valley between them.
Removing slivers by longest shared border
The removal rule is deliberate. Each sliver is merged into the neighbour with which it shares the longest boundary, because that edge is the best evidence of which catchment the fragment was carved from. Measuring shared-border length turns a fuzzy “nearest” question into a precise one.
import logging
import geopandas as gpd
from shapely.ops import unary_union
logger = logging.getLogger(__name__)
def merge_slivers(flagged: gpd.GeoDataFrame) -> gpd.GeoDataFrame:
"""Merge each flagged sliver into the neighbour sharing the longest border."""
result = flagged.copy()
slivers = result[result.is_sliver]
keepers = result[~result.is_sliver]
logger.info("Merging %d slivers into %d retained catchments", len(slivers), len(keepers))
# Spatial index over the catchments a sliver could attach to
sindex = keepers.sindex
absorbed = {} # keeper_index -> list of sliver geometries to union in
for s_idx, sliver in slivers.iterrows():
candidates = keepers.iloc[list(sindex.query(sliver.geometry, predicate="touches"))]
if candidates.empty:
logger.warning("Sliver %s touches no retained catchment; left in place", s_idx)
continue
# Longest shared border wins
best_idx, best_len = None, -1.0
for k_idx, keeper in candidates.iterrows():
shared = sliver.geometry.intersection(keeper.geometry)
shared_len = shared.length # boundary overlap length in metres
if shared_len > best_len:
best_idx, best_len = k_idx, shared_len
absorbed.setdefault(best_idx, []).append(sliver.geometry)
logger.debug("Sliver %s -> catchment %s (shared border %.1f m)", s_idx, best_idx, best_len)
# Apply the unions, keeping the retained catchment's attributes
for k_idx, geoms in absorbed.items():
merged = unary_union([result.geometry.loc[k_idx], *geoms])
result.loc[k_idx, "geometry"] = merged
cleaned = result.loc[~result.is_sliver].drop(columns=["is_sliver"], errors="ignore")
logger.info("Coverage reduced from %d to %d catchments", len(flagged), len(cleaned))
return cleaned
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
gdf = gpd.read_file("catchments.gpkg").to_crs(5070)
logger.info("Loaded %d catchments for sliver cleanup", len(gdf))
flagged = flag_slivers(gdf)
cleaned = merge_slivers(flagged)
cleaned.to_file("catchments_desliver.gpkg", driver="GPKG")
Because merging is done with unary_union rather than deletion, the area a sliver occupied is transferred into its neighbour instead of vanishing. That is what keeps the coverage gap-free through the operation.
Worked example: an iterative cleanup pass
A LiDAR-derived coverage of 1,840 catchments flags 63 slivers on the first flag_slivers pass, all under 5,000 m^2 with Polsby-Popper scores below 0.04. Running merge_slivers absorbs 58 of them into their longest-border neighbours immediately. The remaining five share their longest border with other slivers, so they have no retained neighbour to attach to and the function logs them as left in place.
A second pass fixes this. Because 58 slivers were merged in round one, several of their former neighbours have grown and now present the longest shared border to the five stragglers. Re-running flag_slivers followed by merge_slivers clears four of the five; the last is a genuine 4,200 m^2 fragment pinched between two large catchments that each touch it along nearly equal borders. The longest-border rule still resolves it deterministically — the winning edge is 340 m against 315 m — and the fragment merges cleanly. After two passes the coverage holds 1,777 catchments, every one above the compactness floor, with total area conserved to within floating-point tolerance because every removal was a union rather than a deletion. The lesson is that sliver removal is naturally iterative: each pass changes the neighbour graph, so a fixed point is reached in two or three rounds rather than one.
Threshold reference
| Parameter | Typical value | Effect of raising it |
|---|---|---|
area_max |
1–3 DEM cells worth of area | catches larger fragments but risks absorbing small real catchments |
compactness_max |
0.03–0.08 | flags fatter shapes; too high starts flagging legitimate elongated valleys |
| shared-border predicate | touches |
switch to intersects only if slivers overlap rather than abut |
| precision grid | one DEM cell | coarser snapping merges near-duplicate edges before measuring borders |
Gotchas & edge cases
- A sliver touching only slivers has no valid merge target. Process removal iteratively: after the first pass, some newly merged catchments become the longest-border neighbour for a sliver that previously had none.
- Corner-touching neighbours report near-zero shared length. A neighbour that meets the sliver only at a vertex yields a
Pointor near-zero-length intersection. The longest-border rule correctly ignores it, but log the chosen length so a suspiciously short winner is visible. - Absorbing many slivers can dent a neighbour’s compactness. After merging, re-run
flag_sliverson the result; a catchment that swallowed a long thread may itself now fail compactness and need smoothing. - Deleting instead of unioning silently creates gaps. Never drop a sliver outright. The whole point of the longest-border merge is to conserve area; deletion trades a sliver for a hole, which is a worse defect.
Frequently Asked Questions
Why merge a sliver into its longest-shared-border neighbour rather than its largest neighbour?
The longest shared border is the boundary most likely to be the sliver’s true parent divide, so merging along it preserves the hydrologic divide better than attaching to whichever neighbour happens to have the greatest area. A large neighbour may only touch the sliver at a corner, and merging there would distort the boundary far from where the sliver actually belongs.
What Polsby-Popper value separates a sliver from a real catchment?
Polsby-Popper returns 1.0 for a perfect circle and approaches 0 for a long thin shape. Delineated catchments are irregular but rarely drop below about 0.1, so a compactness under roughly 0.05 combined with a small absolute area is a reliable sliver signature. Tune both thresholds against a histogram of your own coverage rather than trusting a single universal cutoff.
Can removing slivers ever create a gap in the coverage?
Not if you merge by union rather than delete. Deleting a sliver leaves the area it occupied uncovered, which is a gap. Unioning the sliver into a neighbour transfers its area into that neighbour, so the total covered area is preserved. Always revalidate coverage after merging to confirm no gap or overlap was introduced.
Related Topics
- Boundary Topology Validation — parent topic covering the end-to-end validation of delineated catchment coverages
- Validating Catchment Boundary Topology with GeoPandas and Shapely — the detection stage that surfaces the slivers this page removes, along with gaps and overlaps
- Watershed Delineation & Catchment Synchronization — the overview linking delineation, validation, and reconciliation