Build your own metric¶
hotcoco's evaluators report the standard numbers. When you need one they don't
report — F1 at the threshold you deploy at, a confusion matrix with your own
matching rule, a variant of PQ — build it from hotcoco.primitives, which decides
what pairs with what, and hotcoco.metrics, which turns counts into numbers. The
matchers are the ones COCOeval and PanopticEval run, so whatever your metric
shares with theirs comes out the same.
The matchers this page uses are provisional, as the primitives reference explains; it and the metrics reference hold every argument and return shape this page relies on.
The shape of a metric¶
Every metric here takes four steps: score each prediction against each ground truth in one image, match them, count the outcomes, and apply a formula. hotcoco supplies the first two.
| Step | Detection | Panoptic |
|---|---|---|
| Similarity | primitives.bbox_iou or primitives.mask_iou |
primitives.segment_overlaps |
| Matching | primitives.greedy_match |
primitives.match_segments |
| Counting | yours, per image and category | yours, per image and category |
| Formula | yours: precision, recall, F1 | metrics.panoptic_quality |
Count per image, sum the counts over the dataset, and apply the formula once, at
the end. Averaging a ratio per image gives a different number: an image with one
object would weigh as much as an image with fifty. torchmetrics splits a metric
the same way, into update and compute with states that reduce by summing, and
so does TrackEval, into eval_sequence and combine_sequences.
Precision, recall, and F1 at one IoU¶
AP summarizes every confidence threshold; a deployed detector runs at one. This
metric counts true positives, false positives, and misses per category at IoU
0.50 and a minimum score. f_scores reports the
best F1 over all thresholds instead.
Group the annotations by image and category first:
from collections import defaultdict
from operator import itemgetter
import numpy as np
from hotcoco import COCO, metrics, primitives
def group(coco, key):
"""A dataset's annotations grouped by ``key(ann)``, each group in dataset order."""
groups = defaultdict(list)
for ann in coco.load_anns(coco.get_ann_ids()):
groups[key(ann)].append(ann)
return groups
Then match each group and count:
def detection_counts(
gt, dt, iou_thr=0.5, score_thr=float("-inf"), area_range=(0, 1e10), max_det=100
):
"""``[tp, fp, fn]`` per category id, at one IoU threshold."""
lo, hi = area_range
by_cell = itemgetter("image_id", "category_id")
gt_cells, dt_cells = group(gt, by_cell), group(dt, by_cell)
category_ids = set(gt.get_cat_ids())
counts = defaultdict(lambda: np.zeros(3, dtype=np.int64))
for image_id, category_id in gt_cells.keys() | dt_cells.keys():
if category_id not in category_ids:
continue # COCOeval scores only the ground truth's categories
gts = gt_cells[image_id, category_id]
dts = [d for d in dt_cells[image_id, category_id] if d["score"] >= score_thr]
dts = sorted(dts, key=lambda d: -d["score"])[:max_det]
iscrowd = np.array([g["iscrowd"] for g in gts], dtype=bool)
gt_in_range = np.array([lo <= g["area"] <= hi for g in gts], dtype=bool)
dt_in_range = np.array([lo <= d["area"] <= hi for d in dts], dtype=bool)
dt_boxes, gt_boxes = [d["bbox"] for d in dts], [g["bbox"] for g in gts]
sim = primitives.bbox_iou(dt_boxes, gt_boxes, iscrowd)
m = primitives.greedy_match(
sim,
[d["score"] for d in dts],
iou_thr,
gt_ignore=~gt_in_range,
iscrowd=iscrowd,
dt_ignore=~dt_in_range,
)
matched, ignored = m["dt_matches"] >= 0, m["dt_ignore"]
tp = np.sum(matched & ~ignored)
fp = np.sum(~matched & ~ignored)
fn = np.sum(gt_in_range & ~iscrowd) - tp
counts[category_id] += (tp, fp, fn)
return counts
greedy_match takes the detections best score first and gives each the unmatched
ground truth it overlaps most, at or above the threshold. dt_matches holds the
column each detection matched, or -1. A crowd region absorbs any number of
detections without counting them: those come back with dt_ignore set, neither
true nor false positives. Pass iscrowd to both calls, since bbox_iou scores a
crowd region by the detection's own area.
The rest is COCO's protocol. max_det is COCO's cap of 100 detections per image
and category, and a detection whose category the ground truth does not list is
never scored. Dropping detections below score_thr before matching gives the
same matches as dropping them after, because a detection's match depends only on
the detections that score higher.
Sum the counts, then divide:
def precision_recall_f1(tp, fp, fn):
"""Each ratio is the mean of an indicator over what it divides by, so
`mean_or_missing` gives `-1.0`, hotcoco's "not computed", where there is nothing."""
ratio = metrics.mean_or_missing
return ratio(tp, tp + fp), ratio(tp, tp + fn), ratio(2 * tp, 2 * tp + fp + fn)
gt = COCO("instances_val2017.json")
dt = gt.load_res("detections.json")
counts = detection_counts(gt, dt, score_thr=0.5)
precision, recall, f1 = precision_recall_f1(*sum(counts.values()))
names = {c["id"]: c["name"] for c in gt.load_cats(gt.get_cat_ids())}
per_category = {names[c]: precision_recall_f1(*n) for c, n in counts.items()}
sum(counts.values()) pools every category before dividing, the micro average;
per_category has one row per class. A ratio with nothing to divide — recall for
a category no ground truth has — is -1.0, so metrics.is_computed can leave it
out of a mean over categories. With no score_thr, the true and false positives
are the ones COCOeval records in
eval_imgs at the same IoU threshold.
One area range¶
COCO's small, medium, and large scores keep every ground truth in the matching and
flag those outside the range with gt_ignore. A flagged ground truth can still
absorb a detection, so a box on a medium object is not a false positive for
small. Detections outside the range are flagged with dt_ignore, and count only
when they match a ground truth in the range. Both rules are in
detection_counts already:
small = detection_counts(gt, dt, area_range=(0, 32**2))
Cross-class confusion matrix¶
COCOeval matches each category on its own. Drop that split — match every
detection in an image against every ground truth, whatever its class — and each
match records which class a ground truth was taken for.
metrics.confusion_matrix counts those
records, with index K for background: a ground truth nothing matched, or a
detection that matched nothing. This uses group from the preceding section.
def confusion(gt, dt, iou_thr=0.5, max_det=100):
"""``(K + 1, K + 1)`` counts, rows the ground truth's class; ``K`` is background."""
label = {c: i for i, c in enumerate(gt.get_cat_ids())}
by_image, by_category = itemgetter("image_id"), itemgetter("category_id")
gt_images, dt_images = group(gt, by_image), group(dt, by_image)
gt_labels, dt_labels = [], []
for image_id in gt_images.keys() | dt_images.keys():
# Category-major within a score, as COCOeval orders them, so ties break alike.
gts = sorted((g for g in gt_images[image_id] if not g["iscrowd"]), key=by_category)
dts = [d for d in dt_images[image_id] if d["category_id"] in label]
dts = sorted(dts, key=lambda d: (-d["score"], d["category_id"]))[:max_det]
dt_boxes, gt_boxes = [d["bbox"] for d in dts], [g["bbox"] for g in gts]
sim = primitives.bbox_iou(dt_boxes, gt_boxes, [False] * len(gts))
m = primitives.greedy_match(sim, [d["score"] for d in dts], iou_thr)
for d, j in zip(dts, m["dt_matches"]):
gt_labels.append(label[gts[j]["category_id"]] if j >= 0 else None)
dt_labels.append(label[d["category_id"]])
for g, i in zip(gts, m["gt_matches"]):
if i < 0:
gt_labels.append(label[g["category_id"]])
dt_labels.append(None)
return metrics.confusion_matrix(gt_labels, dt_labels, len(label))
cm = confusion(gt, dt)
Crowd regions are left out: a region that absorbs any number of detections would
count one object many times. A detection on a crowd region therefore lands in the
background row, where detection_counts ignores it, so that row is not COCO's
false-positive count.
cm is the "matrix" that ev.confusion_matrix()
returns, so Finding class confusions
reads it the same way. Build it yourself to change the rule: let same-class
matches claim their ground truths first, or keep the label pairs per image to find
the images the confusions come from.
Panoptic quality from the segment matcher¶
Panoptic quality starts from a different similarity: the pixel count of every
pair of ground-truth and predicted segments, which
segment_overlaps reads off two label
maps. match_segments applies PQ's
rules — same category, IoU above 0.5, void pixels left out of the union — and
returns the matched pairs with their IoU, the missed ground truths, the false
positives, and the predictions that count for neither. Per category, the counts
are what metrics.panoptic_quality takes.
image_counts is one image, from label maps in memory — a model's output, say.
panoptic_counts reads the COCO panoptic files and sums the images, which takes
Pillow for the PNG files.
import json
from collections import defaultdict
from pathlib import Path
import numpy as np
from hotcoco import metrics, primitives
from PIL import Image
def image_counts(gt_ids, gt_segments, pred_ids, pred_segments):
"""One image's ``[iou_sum, tp, fp, fn]`` per category id."""
overlaps = primitives.segment_overlaps(gt_ids, pred_ids)
m = primitives.match_segments(gt_segments, pred_segments, overlaps)
gt_category = {s["id"]: s["category_id"] for s in gt_segments}
pred_category = {s["id"]: s["category_id"] for s in pred_segments}
counts = defaultdict(lambda: [0.0, 0, 0, 0])
for gt_id, _, iou in m["matched"]:
counts[gt_category[gt_id]][0] += iou
counts[gt_category[gt_id]][1] += 1
for pred_id in m["false_positives"]:
counts[pred_category[pred_id]][2] += 1
for gt_id in m["missed"]:
counts[gt_category[gt_id]][3] += 1
return counts
def read_ids(path):
"""A COCO panoptic PNG as segment ids: R + 256 G + 256² B."""
rgb = np.asarray(Image.open(path), dtype=np.uint32)
return rgb[..., 0] + 256 * rgb[..., 1] + 256**2 * rgb[..., 2]
def panoptic_counts(gt_json, pred_json, gt_folder, pred_folder):
"""The categories, and ``[iou_sum, tp, fp, fn]`` per category id over every image."""
with open(gt_json) as f:
gt = json.load(f)
with open(pred_json) as f:
pred = {a["image_id"]: a for a in json.load(f)["annotations"]}
totals = {c["id"]: [0.0, 0, 0, 0] for c in gt["categories"]}
for g in gt["annotations"]:
p = pred[g["image_id"]]
gt_ids = read_ids(Path(gt_folder) / g["file_name"])
pred_ids = read_ids(Path(pred_folder) / p["file_name"])
counts = image_counts(gt_ids, g["segments_info"], pred_ids, p["segments_info"])
for category_id, n in counts.items():
totals[category_id] = [t + x for t, x in zip(totals[category_id], n)]
return gt["categories"], totals
def pq_average(categories, counts):
"""PQ, SQ, and RQ averaged over the categories with something to score."""
scores = [metrics.panoptic_quality(*counts[c["id"]]) for c in categories]
scores = [s for s in scores if metrics.is_computed(s[0])]
totals = np.reshape(scores, (-1, 3)).sum(axis=0) # three zeros when nothing scored
return tuple(metrics.mean_or_missing(total, len(scores)) for total in totals)
categories, pq_counts = panoptic_counts(
"panoptic_val2017.json",
"panoptic_predictions.json",
"panoptic_val2017",
"panoptic_predictions",
)
pq, sq, rq = pq_average(categories, pq_counts)
pq_things = pq_average([c for c in categories if c["isthing"]], pq_counts)
pq_stuff = pq_average([c for c in categories if not c["isthing"]], pq_counts)
A category with nothing on either side is left out of the average, as
panopticapi leaves it out. Every count, iou_sum included, equals
PanopticEval's: each image's IoUs are added before the image totals, the order
PanopticEval adds them in. The averages agree to within 1e-12, because
numpy.mean adds pairwise. For the last bit too, average with a running += as
PanopticEval does; since Python 3.12, sum() compensates for rounding and
lands one bit away as well.
PanopticEval keeps the same match records per image in
eval_imgs, for when the standard matching is
all you need.
Next steps¶
- primitives —
mask_iouscores RLE masks the waybbox_iouscores boxes, andlsapfinds an optimal one-to-one assignment where greedy matching is not the protocol. - metrics —
average_precisionturns scores and match flags into AP, andcalibration_errorchecks whether the scores mean what they say.