Evaluation¶
hotcoco supports four evaluation types: bounding box, segmentation, keypoints, and oriented bounding box (OBB). All four follow the same workflow.
This page covers the core pipeline. LVIS and Open Images have their own protocols, and the analysis tools that explain why a model scores what it does — confusion matrices, TIDE, calibration, per-image diagnostics — live in Model diagnostics.
The three-step pipeline¶
Every COCO evaluation follows the same pattern:
from hotcoco import COCO, COCOeval
coco_gt = COCO("annotations.json")
coco_dt = coco_gt.load_res("detections.json")
ev = COCOeval(coco_gt, coco_dt, iou_type)
ev.evaluate() # Per-image matching
ev.accumulate() # Aggregate into precision/recall curves
ev.summarize() # Print and compute the 12 summary metrics
use hotcoco::{COCO, COCOeval};
use hotcoco::params::IouType;
use std::path::Path;
let coco_gt = COCO::new(Path::new("annotations.json"))?;
let coco_dt = coco_gt.load_res(Path::new("detections.json"))?;
let mut ev = COCOeval::new(coco_gt, coco_dt, iou_type);
ev.evaluate()?; // Per-image matching
ev.accumulate(); // Aggregate into precision/recall curves
ev.summarize(); // Print and compute the 12 summary metrics
The only thing that changes between eval types is the iou_type parameter and the format of your detections.
Bounding box evaluation¶
Set iou_type to "bbox" (Python) or IouType::Bbox (Rust).
Detection format — each result needs image_id, category_id, bbox as [x, y, width, height], and score:
[
{"image_id": 42, "category_id": 1, "bbox": [10.0, 20.0, 30.0, 40.0], "score": 0.95},
...
]
IoU is computed as the intersection-over-union of the two bounding boxes.
Segmentation evaluation¶
Set iou_type to "segm" (Python) or IouType::Segm (Rust).
Detection format — each result needs image_id, category_id, segmentation as a compressed RLE dict, and score:
[
{
"image_id": 42,
"category_id": 1,
"segmentation": {"counts": "abc123...", "size": [480, 640]},
"score": 0.95
},
...
]
For what RLE is and how to encode masks from numpy arrays, see Mask operations.
IoU is computed on the binary masks after RLE decoding.
Tip
If your results only have bounding boxes, use bbox evaluation instead. load_res generates polygon segmentations from bboxes, but these are axis-aligned rectangles — not instance masks.
Keypoint evaluation¶
Set iou_type to "keypoints" (Python) or IouType::Keypoints (Rust).
Detection format — each result needs image_id, category_id, keypoints as a flat list of [x1, y1, v1, x2, y2, v2, ...], and score:
[
{
"image_id": 42,
"category_id": 1,
"keypoints": [x1, y1, v1, x2, y2, v2, ...],
"score": 0.95
},
...
]
Each keypoint has an (x, y) position and a visibility flag v (0 = not labeled, 1 = labeled but not visible, 2 = labeled and visible).
Similarity is measured using Object Keypoint Similarity (OKS) instead of IoU. OKS uses per-keypoint sigma values that account for annotation noise — keypoints with higher variance (like hips) are weighted less strictly than precise ones (like eyes).
Differences from bbox/segm:
Keypoint evaluation reports 10 metrics instead of 12. There is no small area range — keypoints are only meaningful on medium and large objects — and the default max_dets is [20] rather than [1, 10, 100], so the recall rows are AR, AR50, and AR75 rather than an AR1/AR10/AR100 sweep. The keypoint defaults for area_rng and kpt_oks_sigmas are in the Params reference. Ground truth annotations with num_keypoints == 0 are ignored.
Oriented bounding box (OBB) evaluation¶
Set iou_type to "obb" (Python) or IouType::Obb (Rust).
OBB evaluation is for rotated/oriented detection tasks — aerial imagery, document analysis, and scene text. Each annotation uses a 5-parameter representation: center coordinates, dimensions, and rotation angle.
Detection format — each result needs image_id, category_id, obb as [cx, cy, w, h, angle], and score:
[
{"image_id": 42, "category_id": 1, "obb": [150.0, 200.0, 80.0, 40.0, 0.785], "score": 0.95},
...
]
The obb field is [cx, cy, w, h, angle] where:
cx, cy— center of the rotated rectanglew, h— width and height of the rectangleangle— rotation angle in radians, counter-clockwise positive
IoU is computed via polygon intersection of the two rotated rectangles (Sutherland-Hodgman clipping). This is exact — no approximation or rasterization.
Differences from bbox:
- Same 12 metrics as bbox/segm
load_rescomputesareaand an axis-alignedbboxfrom the OBB- No pycocotools equivalent exists — hotcoco defines the evaluation protocol
Conversion to and from DOTA, the standard aerial-detection label format, is built in — COCO.from_dota() / to_dota() in Python, coco convert --from dota on the CLI, and the hotcoco::convert functions in Rust. See format conversion.
The 12 COCO metrics¶
summarize() computes and prints these metrics (10 for keypoints). The evaluation protocol is defined in the COCO detection evaluation specification (Lin et al., ECCV 2014):
| Index | Metric | IoU | Area | MaxDets |
|---|---|---|---|---|
| 0 | AP | 0.50:0.95 | all | 100 |
| 1 | AP | 0.50 | all | 100 |
| 2 | AP | 0.75 | all | 100 |
| 3 | AP | 0.50:0.95 | small | 100 |
| 4 | AP | 0.50:0.95 | medium | 100 |
| 5 | AP | 0.50:0.95 | large | 100 |
| 6 | AR | 0.50:0.95 | all | 1 |
| 7 | AR | 0.50:0.95 | all | 10 |
| 8 | AR | 0.50:0.95 | all | 100 |
| 9 | AR | 0.50:0.95 | small | 100 |
| 10 | AR | 0.50:0.95 | medium | 100 |
| 11 | AR | 0.50:0.95 | large | 100 |
Reading the table: a detection counts only when its IoU with a ground truth clears the threshold — the headline AP averages over thresholds 0.50–0.95, while AP50 and AP75 fix a single one. AR is the recall achieved with at most 1, 10, or 100 detections per image. The small/medium/large rows restrict to objects under 32², between 32² and 96², and over 96² pixels respectively — models often perform very differently across sizes.
Customizing parameters¶
Modify ev.params before calling evaluate():
ev = COCOeval(coco_gt, coco_dt, "bbox")
# Evaluate a subset of categories
ev.params.cat_ids = [1, 3]
# Evaluate a subset of images
ev.params.img_ids = [42, 139]
# Custom IoU thresholds
ev.params.iou_thrs = [0.5, 0.75, 0.9]
# Custom max detections
ev.params.max_dets = [1, 10, 100]
# Category-agnostic evaluation (pool all categories)
ev.params.use_cats = False
ev.evaluate()
ev.accumulate()
ev.summarize()
let mut ev = COCOeval::new(coco_gt, coco_dt, IouType::Bbox);
ev.params.cat_ids = vec![1, 3];
ev.params.img_ids = vec![42, 139];
ev.params.iou_thrs = vec![0.5, 0.75, 0.9];
ev.params.max_dets = vec![1, 10, 100];
ev.params.use_cats = false;
ev.evaluate()?;
ev.accumulate();
ev.summarize();
Note
Set params before evaluate(). accumulate() follows params as they stand but can only read matches evaluate() made: an IoU threshold, area range, or category added afterwards reports -1.000 ("not computed") until you run evaluate() again, and a reordered grid still reads each row under its own label.
Note
Changing iou_thrs, max_dets, or area_rng from their defaults affects what summarize() can display. The 12-metric output format is fixed — for example, AP50 looks for IoU=0.50 in your thresholds and shows -1.000 if it's not there. A ReferenceDeviationWarning is emitted when your parameters don't match the expected defaults. Filtering by img_ids or cat_ids is safe and won't trigger warnings; use_cats=False pools detections across categories, which marks the run as an extension and warns.
See Params for the full list of configurable parameters.
Sliced evaluation¶
slice_by() re-computes all summary metrics for named subsets of images — without re-running IoU computation. This is useful for comparing model performance across data splits: indoor versus outdoor, day versus night, or small versus large images.
ev = COCOeval(coco_gt, coco_dt, "bbox")
ev.evaluate()
# Define slices as {name: [img_ids]}
slices = {
"indoor": [42, 139, 203, ...],
"outdoor": [78, 412, 901, ...],
}
results = ev.slice_by(slices)
Each slice in results contains the full set of summary metrics plus a delta vs the overall baseline:
for name, sr in results.items():
if name == "_overall":
continue
print(f"{name}: AP={sr['AP']:.3f} (Δ{sr['delta']['AP']:+.3f})")
You can also pass a callable instead of a dict — it receives each image dict and returns a slice name (or None to skip):
results = ev.slice_by(lambda img: "large" if img["width"] > 1000 else "small")
From the CLI¶
Pass --slices <path> to coco eval with a JSON file mapping slice names to image ID lists:
coco eval --gt annotations.json --dt detections.json --slices slices.json
Streaming evaluation¶
evaluate() needs every detection before it matches the first image, so in a training loop the whole evaluation waits for the end of the epoch, and every prediction made along the way is held until then. StreamingEval matches each detector batch as soon as its predictions exist. Call update() after each batch — the matching runs there, not at the end of the epoch — and finalize() returns an ordinary COCOeval with only accumulate() and summarize() left to run.
from hotcoco import COCO, StreamingEval
coco_gt = COCO("instances_val2017.json")
se = StreamingEval(coco_gt.load_cats(coco_gt.get_cat_ids()), iou_type="bbox")
for image_ids, predictions in validation_loader: # one detector batch
gt_anns = coco_gt.load_anns(coco_gt.get_ann_ids(img_ids=image_ids))
se.update(coco_gt.load_imgs(image_ids), gt_anns, predictions) # matched now
ev = se.finalize()
ev.accumulate()
ev.summarize()
predictions is the batch's detections as one list of dicts in the shape load_res() accepts — image_id, category_id, bbox, and score — or as the (N, 7) float array load_res() accepts, float32 straight from the detector included, which skips building a dict per detection. Each batch runs through the same code evaluate() runs, so the numbers are identical to a batch run over the same annotations, whatever order the images arrive in and however they are batched. Memory stays at about 20 bytes per detection instead of an epoch's worth of prediction dicts. The same recipe evaluates a stored results file in chunks when it is too large to load at once.
What update() costs¶
Streaming shortens the wait at the end of the epoch; it does not reduce total CPU time. On 3,000 images with 300 detections and 7 ground truths each, update() in batches of 32 takes about 0.44 s in all (synthetic boxes, 20 categories, Apple M1):
| Part | Time | GIL |
|---|---|---|
| Converting the batch's Python dicts | about 0.29 s | held |
| Loading detections and matching | about 0.15 s | released |
So the conversion of dicts, not the matching, is about two thirds of update(), and it holds the GIL: a thread that needs the interpreter, such as the training loop while a worker thread calls update(), waits through it. Only the remaining third — loading detections and matching — runs without the GIL.
Passing dt_anns as the (N, 7) float array instead skips the per-detection dicts: on the same run update() takes about a third as long, and nearly all of what remains runs without the GIL. For segm, pass the masks as segmentation=, one entry per row.
Each call also has a fixed cost of about 0.2 ms, so pass the detector's whole batch rather than one image at a time: the same run in batches of 4 spends about twice as long loading and matching. Batches of 32 or more leave that cost under a twentieth of update().
Across processes¶
When validation is split across ranks, each rank streams its own images into a StreamingEval built from the same categories and params. Gather the states on one rank and merge them; finalize() then gives exactly what one stream over every image gives:
import torch.distributed as dist
states = [None] * dist.get_world_size()
dist.all_gather_object(states, se.to_bytes())
if dist.get_rank() == 0:
merged = StreamingEval.merge(StreamingEval.from_bytes(s) for s in states)
ev = merged.finalize()
A StreamingEval also pickles, so a metric object that holds one can be checkpointed or sent to another process as it is.
hotcoco runs its parallel work on one thread pool per process, sized the first time it is used from the RAYON_NUM_THREADS environment variable, or from the CPU count when that is unset. Under DDP every rank on a node evaluates at the same time, each with a pool the size of the whole node. Set RAYON_NUM_THREADS to the node's cores divided by its ranks before the first evaluation to split them between the ranks. The API reference has the merge rules.
Analyses that need per-image records — TIDE, the confusion matrix, calibration, per-image diagnostics — need a batch COCOeval; the API reference has what the finalized evaluator supports and the restrictions.
Where to next¶
- LVIS and Open Images — federated AP, category hierarchies, and group-of matching
- Panoptic segmentation — PQ, SQ, and RQ against panopticapi, from PNG files or from masks
- Semantic segmentation — mIoU, fwIoU, mAcc, aAcc, and mDice against mmsegmentation, and boundary IoU, from a validation loop or from PNG label maps
- Model diagnostics — confusion matrix, TIDE error analysis, calibration, F-scores, model comparison, and per-image failure mining
- Working with results — the evaluation report, provenance, per-category AP, JSON export, and experiment-tracker logging