Measuring Boundary Accuracy with IoU and F1
Intersection over union scores the mask; matching connected components scores the objects:
import numpy as np
def iou(pred: np.ndarray, ref: np.ndarray) -> float:
inter = np.logical_and(pred, ref).sum()
union = np.logical_or(pred, ref).sum()
return float(inter / union) if union else float("nan")
For anything delineated — fields, buildings, water bodies — that number says far more than overall accuracy does. This page belongs to validating raster predictions and accuracy assessment in Raster Machine Learning & Model Inference.
What Per-Pixel Accuracy Cannot See
Two segmentation results can have identical per-pixel accuracy and be worth completely different amounts. The difference is in the topology: whether objects are separated correctly, not whether their interiors are labelled correctly.
Object-level metrics exist to make that failure visible. Instance IoU asks whether each reference object has a well-matched prediction; boundary F1 asks whether the edges are in the right place; and the count of merges and splits asks whether the topology survived.
Environment & Setup
| Package | Version pin | Used for |
|---|---|---|
numpy |
>=1.23 |
Mask arithmetic and the overlap matrix |
scipy |
>=1.11 |
ndimage.label for connected components, distance transforms |
rasterio |
>=1.3.0 |
Reading the aligned prediction and reference |
pip install "numpy>=1.23" "scipy>=1.11" "rasterio>=1.3.0"
Complete Working Example
import numpy as np
from scipy import ndimage
def instance_scores(pred_mask: np.ndarray, ref_mask: np.ndarray,
*, iou_threshold: float = 0.5) -> dict[str, float]:
"""Match predicted objects to reference objects by overlap."""
pred_lbl, n_pred = ndimage.label(pred_mask)
ref_lbl, n_ref = ndimage.label(ref_mask)
if n_pred == 0 or n_ref == 0:
return {"precision": 0.0, "recall": 0.0, "f1": 0.0,
"n_pred": n_pred, "n_ref": n_ref}
# Pairwise intersection counts via a single 2D histogram
both = (pred_lbl > 0) & (ref_lbl > 0)
pairs = np.bincount(pred_lbl[both] * (n_ref + 1) + ref_lbl[both],
minlength=(n_pred + 1) * (n_ref + 1))
inter = pairs.reshape(n_pred + 1, n_ref + 1)[1:, 1:]
pred_area = np.bincount(pred_lbl.ravel(), minlength=n_pred + 1)[1:]
ref_area = np.bincount(ref_lbl.ravel(), minlength=n_ref + 1)[1:]
union = pred_area[:, None] + ref_area[None, :] - inter
iou_matrix = np.divide(inter, union, out=np.zeros_like(inter, "float64"),
where=union > 0)
matched = (iou_matrix >= iou_threshold)
tp = int(matched.any(axis=1).sum()) # predictions with a match
precision = tp / n_pred
recall = int(matched.any(axis=0).sum()) / n_ref
f1 = 2 * precision * recall / (precision + recall) if precision + recall else 0.0
return {"precision": precision, "recall": recall, "f1": f1,
"n_pred": n_pred, "n_ref": n_ref}
def boundary_f1(pred_mask: np.ndarray, ref_mask: np.ndarray,
*, tolerance_px: int = 2) -> float:
"""F1 over boundary pixels, allowing a tolerance in pixels."""
def edges(m: np.ndarray) -> np.ndarray:
return m ^ ndimage.binary_erosion(m, iterations=1)
pe, re = edges(pred_mask), edges(ref_mask)
# Distance from every pixel to the nearest reference / predicted edge
d_ref = ndimage.distance_transform_edt(~re)
d_pred = ndimage.distance_transform_edt(~pe)
precision = (d_ref[pe] <= tolerance_px).mean() if pe.any() else 0.0
recall = (d_pred[re] <= tolerance_px).mean() if re.any() else 0.0
return (2 * precision * recall / (precision + recall)
if precision + recall else 0.0)
The overlap matrix built with a single bincount is what keeps this usable on a full scene. The obvious double loop over predicted and reference objects is fine for a chip and hopeless for a tile containing forty thousand fields.
Reading the IoU Curve
The shape of the curve diagnoses the failure mode. A curve that falls steeply between 0.5 and 0.7 means objects are found but their extents are loose, which usually points at a boundary that is systematically dilated or eroded — often the result of all_touched rasterization during training, as discussed in rasterizing labels into segmentation masks. A curve that is already low at 0.5 means objects are being missed or merged outright, which is a topology problem rather than a precision one.
It is worth reporting merge and split counts alongside the curve, because they name the failure directly: a reference object matched by two or more predictions was split, and a prediction matching two or more references merged them.
Verification
import numpy as np
scores = instance_scores(pred_mask, ref_mask, iou_threshold=0.5)
assert 0.0 <= scores["f1"] <= 1.0
print(f"objects: {scores['n_pred']} predicted vs {scores['n_ref']} reference")
print(f"count ratio: {scores['n_pred'] / max(scores['n_ref'], 1):.2f}")
print("boundary F1 @2 px:", round(boundary_f1(pred_mask, ref_mask, tolerance_px=2), 3))
The count ratio is the fastest possible smoke test. A ratio near 0.5 means objects are being merged in pairs; a ratio near 2 means they are being split. Either is visible before any metric is computed, and either invalidates a delineation product no matter how good the pixel accuracy looks. Compute it per tile rather than only for the scene, because merging and splitting are often concentrated where fields are smallest, and a scene-wide ratio close to one can hide a region where the product is unusable.
Finally, always compute these on the post-processed output rather than the raw prediction, because the minimum mapping unit and majority filter from smoothing and post-processing classification rasters change the object count substantially — usually for the better, and always in a way the metric should reflect.
Common Errors
IoU is high but the object count is wrong
Semantic IoU was computed on the merged mask rather than per instance. Label connected components first; the two metrics answer different questions.
MemoryError building the overlap matrix
The scene contains tens of thousands of objects and the dense IoU matrix is enormous. Compute it per tile, or use a sparse formulation keyed only on pairs that actually intersect.
Boundary F1 is near zero despite a good-looking map
The tolerance is smaller than the registration error between prediction and reference. Raise it to two or three pixels, or fix the alignment first. A quick way to tell the two causes apart is to compute the score at tolerances of one, two and four pixels: a registration problem produces a steep rise across that range, while a genuine delineation problem stays low at all three.
Frequently Asked Questions
Q: Why is per-pixel accuracy misleading for segmentation? Because the pixels that define an object’s shape are a tiny fraction of its area. A model can merge two adjacent fields into one and lose under one per cent of pixels, while destroying the field count that the product exists to produce.
Q: What IoU threshold counts as a match? Fifty per cent is the common convention and it is generous. For delineation work where boundary position matters, report a curve across thresholds from 0.5 to 0.9 rather than a single number, because that curve is where merging and splitting show up.
Q: What tolerance should a boundary F1 use? One to three pixels, chosen from the registration accuracy of the imagery rather than from the model. A tolerance smaller than the georegistration error measures the georeferencing, not the delineation.
Related
- Validating Raster Predictions and Accuracy Assessment — where object metrics sit alongside per-pixel ones.
- Computing Confusion Matrices from Raster Pairs — the per-pixel counterpart.
- Vectorizing Predicted Masks to Polygons — turning the scored objects into a deliverable layer.
- Rasterizing Labels into Segmentation Masks — where systematically loose boundaries usually originate.