Tuning an IoU Threshold for Sensor Coverage
Sweep the threshold across labelled pairs and pick the point where the cost of a false merge equals the cost of a missed one, then band it by footprint size — a fixed positional error costs far more IoU on a small footprint than on a large one, so one threshold treats small overlaps as distinct and large ones as duplicates.
This guide sits under Spatial Overlap Deduplication, within Idempotency & Spatial Deduplication. That topic covers the four-layer pipeline; this guide covers only the number at its centre.
When to use this pattern
- Overlap deduplication is already running with a threshold nobody can justify.
- The two errors have visibly different costs — merged observations that should not have been, or redundant records that should have collapsed.
- Footprints vary in size by more than about a factor of five, which is where a single threshold stops working.
IoU is not scale-free
Complete runnable implementation
from dataclasses import dataclass
from shapely.geometry import shape
from shapely.validation import make_valid
# Footprint area bands in square metres, each with its own threshold. The
# bands come from the fleet's sensor types, not from round numbers.
SIZE_BANDS = (
(0.0, 1e4, "small"), # < 1 hectare — a fixed offset dominates IoU
(1e4, 1e7, "medium"),
(1e7, float("inf"), "large"),
)
def iou(a, b) -> float:
"""Intersection over union. Zero for disjoint, one for identical."""
a, b = make_valid(a), make_valid(b)
union = a.union(b).area
return 0.0 if union == 0 else a.intersection(b).area / union
def containment(a, b) -> float:
"""Intersection over the SMALLER area — catches the nested case.
Two readings where one covers a quarter of the other cap out at an IoU
of 0.25 however well they agree over the shared ground, so a narrow pass
inside a wide swath scores as distinct on IoU alone.
"""
smaller = min(a.area, b.area)
return 0.0 if smaller == 0 else a.intersection(b).area / smaller
def band_of(a, b) -> str:
mean_area = (a.area + b.area) / 2.0
for low, high, name in SIZE_BANDS:
if low <= mean_area < high:
return name
return "large"
@dataclass(frozen=True, slots=True)
class Operating:
threshold: float
precision: float
recall: float
cost: float
def sweep(labelled: list[tuple[object, object, bool]],
false_merge_cost: float, missed_merge_cost: float,
steps: int = 40) -> list[Operating]:
"""Score every candidate threshold against labelled pairs.
`labelled` is (geometry_a, geometry_b, is_duplicate) from real data —
a synthetic set of translated squares produces a clean curve and a
threshold that does not survive contact with sensor footprints.
"""
scores = [(iou(a, b), truth) for a, b, truth in labelled]
results = []
for step in range(1, steps + 1):
threshold = step / steps
tp = sum(1 for s, t in scores if s >= threshold and t)
fp = sum(1 for s, t in scores if s >= threshold and not t)
fn = sum(1 for s, t in scores if s < threshold and t)
precision = tp / (tp + fp) if tp + fp else 1.0
recall = tp / (tp + fn) if tp + fn else 1.0
# Weighted cost, NOT F1: F1 assumes the two errors are equally bad,
# which for a sensor archive they are emphatically not.
results.append(Operating(
threshold=threshold,
precision=precision,
recall=recall,
cost=fp * false_merge_cost + fn * missed_merge_cost,
))
return results
def choose(results: list[Operating]) -> Operating:
return min(results, key=lambda r: (r.cost, -r.threshold))
def is_duplicate(a_geojson: dict, b_geojson: dict,
thresholds: dict[str, float]) -> bool:
"""Apply the tuned threshold for this pair's size band."""
a, b = make_valid(shape(a_geojson)), make_valid(shape(b_geojson))
threshold = thresholds[band_of(a, b)]
# Either measure passing is enough: IoU for similar sizes, containment
# for the nested case it cannot represent.
return iou(a, b) >= threshold or containment(a, b) >= 0.95
Minimising a weighted cost rather than maximising F1 is the decision that makes this tuning rather than curve-fitting. F1 asserts the two errors are equally expensive, which is almost never true and is never checked.
Parameter reference
| Name | Type | Spatial constraint | Default |
|---|---|---|---|
SIZE_BANDS |
tuple | Areas in m²; derived from sensor types, not round numbers | 3 bands |
false_merge_cost |
float |
Relative cost of destroying a distinct observation | 20.0 |
missed_merge_cost |
float |
Relative cost of a redundant record a later pass can collapse | 1.0 |
| Containment threshold | float |
For the nested case IoU cannot express | 0.95 |
| Labelled pairs | list | Real footprints; a few hundred per band minimum | — |
| Area CRS | — | Equal-area projection; areas in EPSG:4326 are not comparable | — |
Gotchas and spatial edge cases
-
Areas computed in EPSG:4326 are in square degrees and are not comparable across latitudes. A footprint at 60° north has roughly half the ground area of an identically-shaped one at the equator, so a size band expressed in degrees puts them in different bands. Reproject to an equal-area CRS before measuring anything the bands depend on.
-
Invalid geometries make
intersectionunreliable rather than failing. A self-intersecting footprint can produce an intersection area larger than either input, so IoU exceeds one.make_validon both inputs is not optional, and the repair itself is covered in Repairing Self-Intersecting Polygons Without Losing Area. -
A synthetic labelled set produces a threshold that does not survive real data. Translated squares give a clean monotone curve; real sensor footprints have ragged edges, partial cloud masks and nested passes, and the curve has a shoulder rather than a crossing. Label real pairs, even if only a few hundred.
-
The nested case needs containment, not a lower IoU threshold. Lowering the IoU threshold enough to catch a narrow pass inside a wide swath also merges genuinely different neighbouring footprints. The two measures answer different questions and should both be available.
-
Re-tune after any change to the sensor fleet or its registration pipeline. A new satellite with better positioning shifts the whole IoU distribution upwards, and the old threshold now merges pairs it was never validated against.
-
Record which threshold and which band produced each merge decision. Without it, a later investigation into a missing observation cannot tell whether the merge was correct under the rules in force at the time, and the tuning becomes unauditable.
Verification
import pytest
from shapely.geometry import Point, box
THRESHOLDS = {"small": 0.55, "medium": 0.75, "large": 0.88}
def test_small_footprint_offset_still_merges():
"""The case a flat 0.8 threshold gets wrong."""
a = Point(0, 0).buffer(25) # 50 m cone
b = Point(10, 0).buffer(25) # same cone, 10 m registration error
assert iou(a, b) < 0.8 # would fail a flat threshold
assert is_duplicate(a.__geo_interface__, b.__geo_interface__, THRESHOLDS)
def test_large_footprint_offset_is_not_merged_by_accident():
"""The band must be strict where a fixed offset barely registers."""
a, b = box(0, 0, 5000, 1000), box(3000, 0, 8000, 1000)
assert not is_duplicate(a.__geo_interface__, b.__geo_interface__, THRESHOLDS)
def test_nested_pass_is_caught_by_containment():
"""IoU caps at 0.25 here however well the pair agrees."""
wide, narrow = box(0, 0, 4000, 4000), box(1000, 1000, 3000, 3000)
assert iou(wide, narrow) < 0.3
assert is_duplicate(wide.__geo_interface__, narrow.__geo_interface__, THRESHOLDS)
def test_weighted_cost_picks_a_higher_threshold_than_f1():
"""The decision the sweep exists to make."""
labelled = _labelled_pairs() # real, from the archive
balanced = choose(sweep(labelled, 1.0, 1.0))
asymmetric = choose(sweep(labelled, 20.0, 1.0))
assert asymmetric.threshold > balanced.threshold
The last test is a property of the tuning process rather than of any particular number, so it keeps holding as the archive grows — and it fails immediately if someone replaces the weighted cost with F1 while tidying the code.
Related
- Spatial Overlap Deduplication — the topic this guide belongs to, and the pipeline this threshold sits in
- Time-Windowed Deduplication for Moving Assets — the cheaper temporal test to run before any geometric one
- Geometry Validation Pipelines — making the inputs valid so the areas mean something
- Merging Overlapping Zone Edits with Shapely — what to do once two geometries are judged to be the same thing