Semantic segmentation¶
Semantic segmentation labels every pixel with a class and nothing else — no
instances, no scores, one class-id map per image. Its metric is mean
intersection over union (mIoU), reported beside per-class IoU, pixel
accuracy, Dice, and their frequency-weighted and class-averaged forms, and the
reference implementation most published numbers come from is
mmsegmentation's IoUMetric.
Boundary IoU restricts IoU to a band along each class's contour, which
mIoU is blind to on large regions. hotcoco computes all of them from the
same maps — in memory during a validation loop, or from saved PNG files — and
reports them in the same EvalReport
shape as detection and panoptic.
What the metrics measure¶
All but boundary IoU come from one num_classes × num_classes confusion
matrix: how many pixels of ground-truth class g were predicted as class
p, summed over every image in the split. From its diagonal (TP), rows
(TP + FN), and columns (TP + FP):
IoU_c = TP_c / (TP_c + FP_c + FN_c) per class
Acc_c = TP_c / (TP_c + FN_c) per class: recall over the class's pixels
Dice_c = 2 TP_c / (2 TP_c + FP_c + FN_c) per class: the F1 of the class's pixels
mIoU = mean of IoU_c over the classes that have one
mAcc = mean of Acc_c over the classes that have ground truth
mDice = mean of Dice_c over the classes that have one
fwIoU = Σ IoU_c · (TP_c + FN_c) / Σ (TP_c + FN_c) weighted by ground-truth share
aAcc = Σ TP_c / Σ (TP_c + FN_c) overall pixel accuracy
Two rules decide which pixels and classes count:
- a ground-truth pixel equal to the ignore index —
255by default, as in mmseg, Cityscapes, and VOC — is left out on both sides, so a prediction there is neither right nor wrong; - a class with no pixel on either side has no IoU or Dice and is left out of
mIoUandmDice; one with no ground truth has no accuracy and is left out ofmAcc. Each reads as-1.0, hotcoco's "not computed" marker, never as a low score.
Boundary IoU¶
A segmentation can score a high IoU on a large region while getting every
contour wrong, because the interior pixels dominate. Boundary IoU
(Cheng et al., CVPR 2021) keeps only a
band along each mask's contour: the pixels of the mask within d of a pixel
outside it or of the image edge, with d 2% of the image diagonal (46
pixels on a 2048 × 1024 Cityscapes frame). Per class, the ground-truth band
and the predicted band are intersected and unioned, summed over the split,
and divided:
BIoU_c = Σ |B(G_c) ∩ B(P_c)| / Σ |B(G_c) ∪ B(P_c)| per class, over the split
mBIoU = mean of BIoU_c over the classes whose union is not empty
Ignored ground-truth pixels drop out of both bands, as they do from the
matrix. The band is a function of the maps, not of the confusion matrix, so
this is the one metric here that needs each map's shape; update reads it
off the array, a PNG file carries it, and the pass costs a fraction of the
counting. It is on by default and boundary_dilation=None leaves it out,
which reads as -1.0 in mBIoU; boundary_dilation=0.01 narrows the band.
Evaluate from a validation loop¶
The evaluator is fed incrementally. Each update takes a ground-truth map and
a prediction of the same shape — (H, W), or (N, H, W) for a batch — in
any integer dtype:
from hotcoco import semantic
ev = semantic.SemanticEval(19) # Cityscapes train ids
for images, gt in val_loader:
pred = model(images).argmax(1)
ev.update(gt.numpy(), pred.numpy())
ev.summarize()
Class | IoU Acc Dice BIoU
---------------------------------------
class_0 | 97.81 98.92 98.89 81.20
class_1 | 83.12 90.44 90.78 62.35
...
---------------------------------------
mIoU 76.42 fwIoU 92.10 mAcc 84.07 aAcc 95.88 mDice 85.31 mBIoU 58.70 (500 images, 1046249374 pixels)
Pass the class names instead of a count for a readable table and a
per_class keyed by name: SemanticEval(["road", "sidewalk", ...]). Every
label must be below the number of classes, on either side, except a
ground-truth pixel equal to ignore_index; one that is not raises
ValueError naming the pixel, and nothing from that pair is counted.
Distributed runs¶
The whole state is one confusion matrix and the boundary counts beside it,
so an evaluator pickles and two evaluators merge():
import torch.distributed as dist
gathered = [None] * dist.get_world_size()
dist.all_gather_object(gathered, ev) # each rank's SemanticEval
if dist.get_rank() == 0:
total = gathered[0]
for shard in gathered[1:]:
total.merge(shard)
total.report()
The merged result equals the single-process one exactly: the counts are integers, and the order they are added in cannot change the sum.
Evaluate saved predictions¶
A folder of predicted PNG files, named like the ground truth's, evaluates in one call, reading pairs in parallel. By default the PNG files at the top of each folder are paired by file name:
ev = semantic.SemanticEval(150, reduce_zero_label=True) # ADE20K
ev.update_folders("ADEChallengeData2016/annotations/validation", "predictions/")
ev.report()["metrics"]["mIoU"]
Cityscapes keeps a folder per city and several files per frame, told apart by
suffix, and predictions are usually saved flat under the image's name. Three
options pair that layout: recursive walks the subfolders, and the suffixes
say what to strip from each side's name to get the key the two sides share.
A prediction is found wherever it sits in its tree, so nested ground truth
pairs with flat predictions; two predictions with one key raise ValueError.
ev = semantic.SemanticEval(19)
ev.update_folders(
"gtFine/val", "predictions/",
recursive=True, gt_suffix="_gtFine_labelTrainIds.png", pred_suffix="_leftImg8bit.png",
)
This is mmsegmentation's seg_map_suffix and img_suffix rule. A pairing
no suffix expresses goes through update_png on the pairs you choose.
A label-map PNG is single-channel: 8-bit grayscale (Cityscapes
labelTrainIds, ADE20K), 16-bit grayscale for more than 256 classes, or a
palette image whose index is the class (Pascal VOC, whose border color sits
at index 255). The palette is never expanded — the color is only how a viewer
shows the id. An RGB file is refused, since it either carries no ids or is a
COCO panoptic file, whose colors encode segment ids and which
hotcoco.panoptic reads.
reduce_zero_label is mmseg's LoadAnnotations rule for datasets whose files
put "other" at 0: ground-truth 0 becomes 255 and every other ground-truth
label shifts down by one, so a file with 151 labels scores 150 classes. It
applies to the ground truth only; predictions are already in the reduced label
space. mmseg hardcodes that 255, so the rule needs ignore_index=255, the
default; any other value is refused rather than given a meaning the reference
does not have.
Results, the report, and the confusion matrix¶
r = ev.report()
r["metrics"] # {"mIoU", "fwIoU", "mAcc", "aAcc", "mDice", "mBIoU"}, fractions in [0, 1]
r["per_class"]["road"] # {"IoU", "Acc", "Dice", "BIoU"}, computed values only
r["provenance"] # "parity_verified"
ev.results()["per_class"][0] # {"name", "IoU", "Acc", "Dice", "BIoU", "tp", "gt_area", "pred_area"}
ev.stats # the six means in semantic.METRIC_NAMES order
confusion() returns the matrix itself as a uint64 array, row the
ground-truth class, column the predicted class. It is the diagnostic this
family has: a class with a low IoU is either being missed (its row leaks into
other columns) or over-predicted (its column collects other rows), and the
matrix says which, and into what. A class whose BIoU sits well below its
IoU is the other diagnosis: the region is found and its outline is not.
The formulas are callable without an evaluator, the way sklearn.metrics and
torchmetrics.functional are, for a loop that keeps its own counts:
metrics.pixel_confusion builds one
pair's matrix and metrics.mean_iou reads the
numbers off a summed one, boundary IoU excepted.
mIoU of a panoptic prediction¶
A panoptic model is also scored as a semantic one in its papers. Nothing
needs re-reading for that: PanopticEval.report() already carries mIoU,
fwIoU, mAcc, aAcc, and mDice from the pixels it matched, under the
same definitions as this page — see
mIoU from the same pixels. Boundary
IoU is not among them, since that pass keeps the matrix and not the maps.
From the CLI¶
coco semantic eval --gt annotations/validation --pred predictions/ --classes 150 --reduce-zero-label
coco semantic eval --gt gt/ --pred predictions/ --classes names.txt --json
coco semantic eval --gt gt/ --pred predictions/ --classes 19 --no-boundary-iou
The Rust binary has the same subcommand: coco-eval semantic --gt ... --pred ... --classes ....
Both are in the CLI reference.
Verification¶
Three implementations of this metric are quoted in papers, and they agree on
everything but one corner. mmseg's IoUMetric, torchmetrics'
multiclass_jaccard_index, and detectron2's SemSegEvaluator all mask the
ignore index, build one dataset-wide confusion matrix, and take per-class IoU
as TP over the union. They differ on a class that is predicted somewhere but
absent from every ground-truth map: its union is non-empty and its IoU is
0. mmseg and torchmetrics count it toward mIoU; detectron2 drops it.
hotcoco follows mmseg and torchmetrics — the convention two of three
references share, and the one SegFormer-lineage numbers are reported under —
and the difference is visible only on a split where some class is never in
the ground truth. fwIoU is detectron2's definition; mmseg has none. mDice
is mmseg's, under the same rules as mIoU.
Boundary IoU has a paper and a kernel but no semantic evaluator to compare
against: the authors' boundary-iou-api covers instance and panoptic
segmentation, and the per-class accumulation above is the paper's description.
hotcoco holds its band to the authors' mask_to_boundary, run on every class
of every map, and the sums to the definition. detectron2's SemSegEvaluator
also reports a BoundaryIoU, but it erodes the whole label map and keeps
label − window minimum, so a class has a boundary only beside a
lower-numbered class and the number depends on how the classes are numbered.
hotcoco does not follow it, and the fixture records its numbers so the
difference is observed on most cases rather than assumed.
The comparison is frozen rather than run live, because every reference needs
torch or OpenCV: scripts/gen_semantic_fixtures.py downloads the sources at
pinned commits (mmseg v1.2.2, detectron2 1e3e13b, boundary-iou-api
37d2558, torchmetrics 1.9.0), runs them on 67 synthetic cases that exercise
ignored pixels, reduce_zero_label, classes on one side only, all-ignored
splits, and maps large enough for a band wider than one pixel, and writes the
outputs to tests/fixtures/semantic_expected.json. The Rust and Python
suites then check per-class IoU, Acc, and Dice, mIoU, mAcc, aAcc, and
mDice against mmseg to 1e-9, per-class IoU and mIoU against torchmetrics
to 1e-6 (it reduces in float32), fwIoU, mAcc, and aAcc against
detectron2 to 1e-9, per-class and mean boundary IoU against the authors'
kernel to 1e-9, that detectron2's mIoU differs on every case with a
predicted-but-absent class, and that its boundary IoU differs on at least half
the cases, so both choices are observed, not assumed. The confusion-matrix
metrics are pure functions of the matrix and the boundary band is a pure
function of the maps, so synthetic maps cover them; there is no real-dataset
parity run for this family. See Semantic parity.