Snapping Near-Duplicate OSM Nodes with a Tolerance Jump to heading
Close the sub-centimetre gaps that make a ring fail to close, without merging two buildings that are genuinely half a metre apart.
Prerequisites Jump to heading
Conceptual minimum Jump to heading
Snapping merges nodes that are close enough to be the same node. It fixes unclosed rings, broken multipolygon chains and duplicated vertices — and, past a certain tolerance, it starts merging things that are genuinely separate.
The tolerance is the whole decision, and it should come from the data’s provenance rather than from the repair rate. A layer digitised from 20 cm aerial imagery has near-misses at the centimetre scale; a layer traced from 5 m satellite imagery has them at the metre scale, and applying the second tolerance to the first collapses real detail.
The mechanism has one subtlety worth getting right before writing any code.
Nearness is not transitive but clustering makes it so: if A is within tolerance of B and B of C, all three become one node even though A and C may be twice the tolerance apart. This is usually what you want — it is how a chain of near-duplicates collapses — and it is why a large tolerance degrades non-linearly.
Runnable solution Jump to heading
#!/usr/bin/env python3
"""Snap near-duplicate nodes within a tolerance, deterministically."""
from __future__ import annotations
import logging
from collections import defaultdict
from dataclasses import dataclass, field
import numpy as np
from scipy.sparse import coo_matrix
from scipy.sparse.csgraph import connected_components
from scipy.spatial import cKDTree
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
logger = logging.getLogger(__name__)
@dataclass
class SnapResult:
"""Old node id → surviving node id, plus what happened."""
mapping: dict[int, int]
clusters: int = 0
merged: int = 0
max_move_m: float = 0.0
oversized: list[tuple[int, float]] = field(default_factory=list)
def snap_nodes(node_ids: np.ndarray,
coords: np.ndarray,
tolerance_m: float,
max_cluster_diameter_m: float | None = None) -> SnapResult:
"""Cluster nodes within `tolerance_m` and collapse each cluster to one node.
`coords` must be projected metres. Running this on degrees makes the tolerance
mean a different distance at every latitude.
"""
if coords.ndim != 2 or coords.shape[1] != 2:
raise ValueError("coords must be an (n, 2) array of projected metres")
if max_cluster_diameter_m is None:
# A cluster wider than a few tolerances is a chain that ran away.
max_cluster_diameter_m = tolerance_m * 3
tree = cKDTree(coords)
pairs = tree.query_pairs(r=tolerance_m, output_type="ndarray")
if pairs.size == 0:
logger.info("no node pairs within %.3f m", tolerance_m)
return SnapResult(mapping={int(i): int(i) for i in node_ids})
n = len(node_ids)
adjacency = coo_matrix(
(np.ones(len(pairs)), (pairs[:, 0], pairs[:, 1])), shape=(n, n))
count, labels = connected_components(adjacency, directed=False)
mapping: dict[int, int] = {}
merged = 0
max_move = 0.0
oversized: list[tuple[int, float]] = []
for label in range(count):
members = np.flatnonzero(labels == label)
if members.size == 1:
idx = int(members[0])
mapping[int(node_ids[idx])] = int(node_ids[idx])
continue
member_coords = coords[members]
# Diameter check: transitive chaining can produce a cluster far wider than
# the tolerance, and that is a merge nobody asked for.
spread = float(np.max(np.ptp(member_coords, axis=0)))
if spread > max_cluster_diameter_m:
oversized.append((label, spread))
for idx in members: # leave the whole cluster alone
mapping[int(node_ids[idx])] = int(node_ids[idx])
continue
# Deterministic representative: the lowest node id in the cluster.
# A centroid would move every node; the lowest id keeps one of them exact
# and does not depend on iteration order.
survivor_idx = int(members[np.argmin(node_ids[members])])
survivor_id = int(node_ids[survivor_idx])
survivor_xy = coords[survivor_idx]
for idx in members:
mapping[int(node_ids[idx])] = survivor_id
if idx != survivor_idx:
merged += 1
max_move = max(max_move, float(np.hypot(*(coords[idx] - survivor_xy))))
logger.info("%d cluster(s), %d node(s) merged, max move %.3f m, %d oversized skipped",
count, merged, max_move, len(oversized))
return SnapResult(mapping=mapping, clusters=count, merged=merged,
max_move_m=max_move, oversized=oversized)
def rewrite_way_refs(ways: dict[int, list[int]],
mapping: dict[int, int]) -> tuple[dict[int, list[int]], int]:
"""Point every way at the surviving nodes, collapsing repeats the snap created.
Snapping two adjacent nodes of the same way to one leaves the way referencing
the same node twice in a row — a zero-length segment that fails validity.
"""
rewritten: dict[int, list[int]] = {}
degenerate = 0
for way_id, refs in ways.items():
new_refs = [mapping.get(r, r) for r in refs]
collapsed = [new_refs[0]]
for ref in new_refs[1:]:
if ref != collapsed[-1]:
collapsed.append(ref)
# A closed way that collapses below four nodes is no longer a ring.
if len(collapsed) < 2 or (refs[0] == refs[-1] and len(collapsed) < 4):
degenerate += 1
continue
rewritten[way_id] = collapsed
if degenerate:
logger.warning("%d way(s) became degenerate and were dropped", degenerate)
return rewritten, degenerate
Step-by-step walkthrough Jump to heading
cKDTree.query_pairs finds every pair within the tolerance in one call, which is the part that would otherwise be quadratic. On 41 million nodes it is the difference between minutes and never.
connected_components turns pairwise nearness into clusters. Doing this properly is what makes the result deterministic: a naive loop that snaps B to A and then C to B produces a different answer if the nodes are visited in a different order, and “different answer depending on iteration order” is not a property you want in a repair.
The lowest node id is the representative, not the centroid. Two reasons: it is stable across runs regardless of order, and it leaves one node exactly where it was rather than moving every node in the cluster. Where the source has meaningful node identity — an entrance node, a node carrying tags — keeping a real node beats synthesising a new position.
The cluster-diameter guard is the safety valve on transitivity. A chain of near-misses along a terrace can produce a cluster tens of metres across from a 50 cm tolerance, and merging it collapses several buildings into one. Skipping oversized clusters entirely, and reporting them, is better than merging them badly.
rewrite_way_refs is the step most implementations forget. Merging nodes without updating the ways that reference them leaves dangling references — the exact defect Node, Way & Relation Data Model is about. Collapsing consecutive duplicates afterwards matters too: snapping two adjacent nodes of the same way creates a zero-length segment, which fails validity in the way described in Detecting Self-Intersecting OSM Polygons with Shapely.
Verification Jump to heading
Assert the properties that distinguish a repair from damage:
def test_no_node_moves_further_than_the_tolerance():
result = snap_nodes(ids, coords, tolerance_m=0.10)
assert result.max_move_m <= 0.10 * 3 # bounded by the diameter guard
def test_snapping_is_order_independent():
order = np.random.permutation(len(ids))
a = snap_nodes(ids, coords, 0.10).mapping
b = snap_nodes(ids[order], coords[order], 0.10).mapping
assert a == b
def test_ways_have_no_dangling_refs():
rewritten, _ = rewrite_way_refs(ways, result.mapping)
survivors = set(result.mapping.values())
assert all(ref in survivors for refs in rewritten.values() for ref in refs)
def test_rings_stay_closed():
rewritten, _ = rewrite_way_refs(ways, result.mapping)
for way_id, refs in rewritten.items():
if ways[way_id][0] == ways[way_id][-1]:
assert refs[0] == refs[-1], f"way {way_id} was closed and is not now"
Then check the aggregate effect on the layer, which is where over-snapping shows up:
before = gdf.geometry.area.sum()
after = repaired.geometry.area.sum()
logger.info("total area changed by %.4f%%", 100 * (after - before) / before)
A change of a few thousandths of a percent is repair. A change of a tenth of a percent means buildings are being merged, and the tolerance is too large.
Finally, look at the oversized clusters by hand the first time. They are the cases where the tolerance and the data disagree, and they usually reveal something about the source rather than about the algorithm.
Common errors and fixes Jump to heading
| Symptom | Root cause | Fix |
|---|---|---|
| Tolerance behaves differently by latitude | Coordinates in degrees | Project to metres first |
| Different result on each run | Pairwise snapping in iteration order | Cluster with connected components |
| Whole terraces collapse into one building | Transitive chaining | Add a cluster-diameter guard |
| Ways reference nodes that no longer exist | Refs not rewritten | Rewrite every way after snapping |
| Rings fail validity after snapping | Consecutive duplicate refs | Collapse repeats; drop degenerate rings |
| Total area drops noticeably | Tolerance too large for the source | Set it from survey accuracy |
| Runs for hours | Pairwise distance loop | Use a KD-tree query_pairs |
Frequently Asked Questions Jump to heading
What tolerance should I use?
Start from the positional accuracy of the source and use a fraction of it. Data digitised from 20 cm imagery justifies something around 5–10 cm; data traced from coarser satellite imagery justifies more. Do not derive it from the percentage of gaps it closes — that curve keeps rising long after the merges have stopped being correct.
Centroid or an existing node as the representative?
An existing node, in almost every case. It is deterministic, it preserves node identity and any tags attached to it, and it leaves one vertex exactly where the survey put it. A centroid moves every node in the cluster, including the one that was probably right, and it invents a coordinate nobody observed.
Should snapping run before or after geometry assembly?
Before. Snapping exists largely to make assembly succeed — closing ring gaps and joining multipolygon member chains, as in Repairing Unclosed Ways and Broken Multipolygons. Running it afterwards means the assembly has already failed on the gaps you were about to close.
Is it safe to snap nodes on a road network?
With a much smaller tolerance than on buildings, and with care. Parallel carriageways, service roads beside a main road and footways alongside streets are all genuinely separate features a metre or two apart, and merging them creates junctions that do not exist — which then shows up as a routing defect rather than as a geometry one. Snap exact duplicates freely; go beyond a few centimetres only with evidence.
Specification reference Jump to heading
There is no OSM specification for node snapping — it is a repair operation, not a data-model concept. The constraints it must respect are the model’s: every way reference must resolve to an existing node, a closed way must begin and end with the same node reference, and a linear way needs at least two distinct nodes while a ring needs at least four references describing three distinct positions.
Related Jump to heading
- Geometry Validation & Repair — the topic this repair belongs to.
- Repairing Unclosed Ways and Broken Multipolygons — the assembly this unblocks.
- Detecting Self-Intersecting OSM Polygons with Shapely — the validity failures a bad snap creates.
- Picking a UTM Zone for an OSM Extract — getting to metres before setting a tolerance.
- Node, Way & Relation Data Model — the references that must be rewritten.
Up one level: Geometry Validation & Repair.