Benchmarks¶
Feature comparison¶
Five libraries evaluate COCO-format detections from Python; three of them are
Rust engines. Versions: pycocotools 2.0.11, faster-coco-eval 1.8.0,
ultrafast-pycocotools 0.1.13, vernier 0.5.4, hotcoco main (1.3 development).
| Feature | pycocotools | faster-coco-eval | ultrafast-pycocotools | vernier | hotcoco |
|---|---|---|---|---|---|
| Installation | Prebuilt wheels | Prebuilt wheels | Prebuilt wheels | Prebuilt wheels | Prebuilt wheels — pip install just works |
| Metric parity | Reference | Exact | precision/recall/scores bit-identical on x86-64; segm differs on arm64 (see Segmentation) |
Bit-identical in parity_mode="strict" on x86-64; segm differs on arm64 |
precision/recall/scores bit-identical on x86-64 and arm64; summary metrics bit-identical (measured on arm64) |
| LVIS evaluation | No | Yes — via lvis_style=True flag |
Yes — via lvis_style=True flag |
Yes — federated AP | Yes — 13 metrics, LVISeval class, init_as_lvis() |
| Open Images, oriented boxes | No | No | No | No | Yes — Challenge protocol with group-of; OBB IoU |
| Panoptic, semantic | No | No | No | Yes — PQ, mIoU | PQ/SQ/RQ matching panopticapi (parity), from PNG files or from masks with no PNG files; mIoU/fwIoU/mAcc/aAcc matching mmsegmentation (parity), from arrays or PNG label maps |
| TIDE error analysis | No | No | No | Yes | Yes — 6 error types, ΔAP per type |
| Confusion matrix | No | No | Yes | Yes | Yes — cross-category, configurable threshold |
| Calibration | No | No | No | Yes — ECE/MCE | Yes — ECE/MCE, reliability curve |
| F-scores | No | No | No | No | Yes — F-beta at any β |
| Per-class AP | Manual only | Yes — via extended_metrics |
Yes — per_category_stats() |
Yes — per_class table |
Built-in via get_results(per_class=True) |
| Model comparison | No | No | No | No | Yes — bootstrap CIs per metric |
| Dataset operations | No | No | No | No | Yes — filter, merge, split, sample, stats |
| Format conversion | No | No | No | No | Yes — COCO ↔ YOLO, VOC, CVAT, DOTA, Open Images CSV |
| Dataset browser | No | No | No | No | Yes — coco explore |
| PyTorch integration | Via torchvision | Yes — TorchVision compatible | init_as_pycocotools() shim |
DLPack tensors; patch_pycocotools() |
Yes — CocoDetection, CocoEvaluator |
| Rust API | No | No | No | Yes — crate on crates.io | Yes — native crate on crates.io |
| CLI | No | No | No | Yes — vernier binary |
Yes — coco (Python) + coco-eval (Rust) |
| Results export | No | No | stats_as_dict |
JSON via CLI | Yes — JSON with params + metrics + per-class |
| Memory at scale | Exceeds physical RAM on O365 | Exceeds physical RAM on O365 | Completes (their measurement) | Completes (their measurement) | Completes within physical RAM (details) |
| Python versions | 3.9+ | 3.10+ | 3.8+ | 3.10+ | 3.10+ |
| License | BSD | Apache 2.0 | BSD-2 | MIT or Apache 2.0 | MIT |
Speed benchmarks¶
Hardware: Apple M1 MacBook Air — 8 cores (4 performance + 4 efficiency), 8 GB RAM Dataset: COCO val2017 — 5,000 images Detections: 36,781 synthetic — see Methodology Timing: Wall clock time — per-cell median of 3 runs, each in a fresh process, at both 1× and 10×, captured back to back in one session (2026-10-07). Absolute times drift between captures on this machine, so compare them only within a table; the speedup ratios are not affected. Peak memory is each process's own resident set. Versions: pycocotools 2.0.11, faster-coco-eval 1.8.0, ultrafast-pycocotools 0.1.13, vernier 0.5.4, hotcoco 1.2.0.
Results (1x detections)¶

hotcoco.plot.| Eval Type | pycocotools | faster-coco-eval | ultrafast-pycocotools | vernier | hotcoco |
|---|---|---|---|---|---|
| bbox | 5.11s | 0.88s (5.8×) | 0.07s (74.9×) | 0.22s (22.9×) | 0.05s (113.3×) |
| segm | 6.25s | 2.63s (2.4×) | 0.10s (63.4×) | 0.58s (10.8×) | 0.06s (110.5×) |
| keypoints | 2.32s | 0.95s (2.5×) | 0.07s (32.2×) | 0.17s (13.7×) | 0.04s (55.3×) |
Speedups in parentheses are vs pycocotools.
Peak resident memory, same runs (MB):
| Eval Type | pycocotools | faster-coco-eval | ultrafast-pycocotools | vernier | hotcoco |
|---|---|---|---|---|---|
| bbox | 659 | 629 | 94 | 173 | 114 |
| segm | 608 | 657 | 117 | 200 | 132 |
| keypoints | 266 | 287 | 106 | 146 | 97 |
The three Rust engines are 11–113× ahead of pycocotools, and hotcoco is fastest on every row. On memory it sits below vernier and within about 1.2× of ultrafast-pycocotools, which parses detections straight into columnar arrays while hotcoco materializes one annotation record per detection.
Results (10x detections)¶
Scaling detections by 10x (~368,000) to test behavior under higher load:
| Eval Type | pycocotools | faster-coco-eval | ultrafast-pycocotools | vernier | hotcoco |
|---|---|---|---|---|---|
| bbox | 24.81s | 3.40s (7.3×) | 0.15s (160.7×) | 0.89s (27.8×) | 0.12s (199.5×) |
| segm | 25.24s | 7.06s (3.6×) | 0.27s (93.5×) | 1.73s (14.6×) | 0.23s (108.0×) |
| keypoints | 9.42s | 5.99s (1.6×) | 0.31s (29.9×) | 0.92s (10.2×) | 0.24s (39.2×) |
Peak resident memory, same runs (MB):
| Eval Type | pycocotools | faster-coco-eval | ultrafast-pycocotools | vernier | hotcoco |
|---|---|---|---|---|---|
| bbox | 1519 | 1080 | 187 | 558 | 216 |
| segm | 1626 | 1437 | 375 | 860 | 381 |
| keypoints | 1173 | 1256 | 463 | 704 | 331 |
The results file is read a few megabytes at a time, so at this scale the peak is set by the evaluation, not the load: hotcoco holds about 210 bytes per detection with no slack around it, the evaluator shares the datasets rather than copying them, and the peak is within 1.2× of ultrafast-pycocotools' on bbox and segm and below it on keypoints. On keypoints, the 325 MB results file no longer sits in memory beside its records.
Absolute times stay under 0.3s at 368,000 detections, and hotcoco is fastest on every row.
StreamingEval holds less still. On Objects365 val with 1M synthetic detections,
matching each batch as it arrives and keeping only the lean per-pair records
peaks at 270 MB above the loaded ground truth, against 739 MB for collecting
every prediction and evaluating at the end. The numbers are identical either
way, whatever the batch size.
Where the time goes¶
The preceding end-to-end numbers blend two very different phases: load (JSON
parsing and index building — COCO() + loadRes) and eval (evaluate +
accumulate + summarize). Splitting them shows where each library spends its
time (same protocol, same synthetic detections as the 1× table):
| Eval type | Phase | pycocotools | faster-coco-eval | ultrafast-pycocotools | vernier | hotcoco |
|---|---|---|---|---|---|---|
| bbox | load | 0.31s | 0.31s (1.0×) | 0.05s (6.1×) | 0.09s (3.6×) | 0.03s (11.5×) |
| eval | 4.66s | 0.59s (8.0×) | 0.02s (229.0×) | 0.12s (39.5×) | 0.01s (378.0×) | |
| segm | load | 0.33s | 0.33s (1.0×) | 0.05s (6.1×) | 0.09s (3.8×) | 0.03s (10.5×) |
| eval | 5.38s | 2.19s (2.5×) | 0.05s (115.5×) | 0.48s (11.1×) | 0.02s (244.7×) | |
| keypoints | load | 0.48s | 0.48s (1.0×) | 0.04s (13.2×) | 0.05s (10.5×) | 0.03s (15.2×) |
| eval | 1.79s | 0.44s (4.0×) | 0.03s (52.8×) | 0.12s (15.6×) | 0.01s (186.2×) | |
| bbox, bbox-only GT | load | 0.16s | 0.15s (1.0×) | 0.03s (5.6×) | 0.02s (7.1×) | 0.02s (9.3×) |
| eval | 4.44s | 0.52s (8.5×) | 0.02s (221.1×) | 0.12s (36.7×) | 0.01s (369.6×) |
hotcoco's evaluation phase is 186–378× faster than pycocotools'; the end-to-end headline is lower because loading, which no library can speed up by much more than 10×, is a large share of hotcoco's total.
The bbox-only GT row strips the polygon segmentation the official instances files carry on every annotation — about two-thirds of the file bytes, which bbox evaluation never reads. It represents datasets that never had masks (custom bbox datasets, YOLO conversions, Objects365), where the load column is the one you'll actually see.
Objects365 scale benchmark¶
Hardware: Windows 11, AMD Ryzen 5 5600X — 6 cores / 12 threads, 16 GB RAM + swap Dataset: Objects365 val — 80,000 images, 1.2M annotations, 365 categories Detections: ~1.2M synthetic bbox (capped at 100/image, seed=42) Timing: Wall clock time, single run Versions: pycocotools 2.0.11, faster-coco-eval 1.7.2, hotcoco 0.3.0 — this run predates the 1.0 loading work, so the hotcoco figure is conservative
| Library | Time | Peak RAM | Committed | Speedup |
|---|---|---|---|---|
| pycocotools | 721.18s | 14.34 GB | 23.71 GB | baseline |
| faster-coco-eval | 250.90s | 14.57 GB | 29.96 GB | 2.9x |
| hotcoco | 18.32s | 7.47 GB | 8.11 GB | 39.4x |
Peak RAM is the peak working set (physical memory). Committed includes swap — both pycocotools and faster-coco-eval exceeded physical RAM and relied heavily on the pagefile, which significantly inflated their wall clock times. hotcoco completed within physical memory with minimal swap.
Metric parity¶
Reference: pycocotools 2.0.11, numpy 2.4.3.
Ground truth: COCO val2017 — 5,000 images.
Detections: the published instances_val2017_fake*_results.json files from
ppwwyyxx/cocoapi, the same inputs pycocotools uses in its own
tests.
The precision, recall, and scores arrays are bit-identical to pycocotools'
on all three evaluation types — every cell, checked with an integer compare of the
raw f64 bits. The summary metrics below are bit-identical too: they are means over
those identical arrays, and hotcoco sums them in the order numpy's mean does —
pairwise, over the same flattened elements. The diffs in the following tables are
raw measured differences, not rounded.
Bounding box¶
| Metric | pycocotools | hotcoco | Diff |
|---|---|---|---|
| AP | 0.57793065 | 0.57793065 | 0.00e+00 |
| AP50 | 0.86052720 | 0.86052720 | 0.00e+00 |
| AP75 | 0.60003745 | 0.60003745 | 0.00e+00 |
| APs | 0.32723763 | 0.32723763 | 0.00e+00 |
| APm | 0.70684507 | 0.70684507 | 0.00e+00 |
| APl | 0.91751661 | 0.91751661 | 0.00e+00 |
| AR1 | 0.42708926 | 0.42708926 | 0.00e+00 |
| AR10 | 0.68690535 | 0.68690535 | 0.00e+00 |
| AR100 | 0.70127765 | 0.70127765 | 0.00e+00 |
| ARs | 0.43712612 | 0.43712612 | 0.00e+00 |
| ARm | 0.80637778 | 0.80637778 | 0.00e+00 |
| ARl | 0.95956720 | 0.95956720 | 0.00e+00 |
The threshold grids are constructed to match numpy.linspace bit-for-bit, which
removed the last systematic source of divergence here.
Segmentation¶
| Metric | pycocotools | hotcoco | Diff |
|---|---|---|---|
| AP | 0.65763117 | 0.65763117 | 0.00e+00 |
| AP50 | 0.92315461 | 0.92315461 | 0.00e+00 |
| AP75 | 0.70141134 | 0.70141134 | 0.00e+00 |
| APs | 0.46056290 | 0.46056290 | 0.00e+00 |
| APm | 0.77182113 | 0.77182113 | 0.00e+00 |
| APl | 0.93431919 | 0.93431919 | 0.00e+00 |
| AR1 | 0.45457448 | 0.45457448 | 0.00e+00 |
| AR10 | 0.74556101 | 0.74556101 | 0.00e+00 |
| AR100 | 0.76167772 | 0.76167772 | 0.00e+00 |
| ARs | 0.54570783 | 0.54570783 | 0.00e+00 |
| ARm | 0.85891625 | 0.85891625 | 0.00e+00 |
| ARl | 0.98103170 | 0.98103170 | 0.00e+00 |
Exact. The residual segmentation once carried (AP ~1e-5) came from polygon
rasterization, where the reference's C compiler contracts s*t+ys into a single
fused multiply-add on arm64 but not on x86-64, whose PyPI wheels target a baseline
without the instruction. hotcoco mirrors that choice per architecture, so the
comparison is exact on either kind of machine. Masks can differ by a boundary pixel
between the two architectures — as pycocotools' own do. The other two Rust
evaluators do not mirror the contraction: on Apple Silicon, ultrafast-pycocotools
0.1.11 and vernier 0.5.4 differ from the arm64 pycocotools wheel in 283 cells of the
segm precision tensor on these detections, by up to 0.72 per cell and 1e-5 in AP
(measured 2026-09-25); on x86-64 all three agree.
Keypoints¶
| Metric | pycocotools | hotcoco | Diff |
|---|---|---|---|
| AP | 0.41255451 | 0.41255451 | 0.00e+00 |
| AP50 | 0.60631206 | 0.60631206 | 0.00e+00 |
| AP75 | 0.42916428 | 0.42916428 | 0.00e+00 |
| APm | 0.40337197 | 0.40337197 | 0.00e+00 |
| APl | 0.88304294 | 0.88304294 | 0.00e+00 |
| AR | 0.76642003 | 0.76642003 | 0.00e+00 |
| AR50 | 0.97481108 | 0.97481108 | 0.00e+00 |
| AR75 | 0.80636020 | 0.80636020 | 0.00e+00 |
| ARm | 0.62190658 | 0.62190658 | 0.00e+00 |
| ARl | 0.96335935 | 0.96335935 | 0.00e+00 |
Keypoint metrics are exact. Keypoint evaluation reports 10 metrics — see Keypoint evaluation.
TIDE¶
Reference: tidecv, on COCO val2017 at pos_thr=0.5.
On the 4,589 images without a crowd region, every ΔAP agrees within ±0.005 and
every error count matches. On all 5,000 images, Loc reads about 0.01 lower here
and Miss about 0.007 higher; the other types still agree within ±0.005:
| Error | hotcoco ΔAP | tidecv ΔAP | hotcoco count | tidecv count | hotcoco ΔAP, crowd-free | tidecv ΔAP, crowd-free |
|---|---|---|---|---|---|---|
| Cls | 0.0000 | 0.0000 | 13 | 13 | 0.0000 | 0.0000 |
| Loc | 0.1036 | 0.1135 | 3,121 | 3,738 | 0.0972 | 0.0974 |
| Both | 0.0007 | 0.0001 | 726 | 766 | 0.0006 | 0.0001 |
| Dupe | 0.0001 | 0.0000 | 27 | 37 | 0.0001 | 0.0000 |
| Bkg | 0.0109 | 0.0105 | 6,039 | 6,414 | 0.0109 | 0.0104 |
| Miss | 0.0148 | 0.0075 | 1,102 | 529 | 0.0065 | 0.0059 |
The ranking — which error type is costing you the most AP — is the same, and that is
what the metric is for. The difference comes from crowd handling: hotcoco builds TIDE
on the same COCO-convention matching as its AP (so tide_errors() and ev.stats
always agree about which detections exist), while tidecv removes crowd regions from
matching entirely — its false-positive counts run higher and its Miss runs lower as
a result. scripts/parity_tide.py runs both comparisons.
Open Images¶
Reference: the TensorFlow Object Detection API — the implementation the official protocol page points to.
Open Images evaluation is compared over 70 cases covering group-of absorption, IoA containment at and around the 0.5 boundary, undetected group-of boxes, overlapping group-of boxes, and randomized multi-class scenes. Both mAP and per-class AP are compared, and every case agrees to within one ulp (worst difference 1.11e-16). It runs in CI on every commit.
Two things that comparison does not cover, and why provenance still reports
"extension" for Open Images runs:
- Non-exhaustive image-level labels. The challenge ignores detections of a class not verified on an image, and counts detections of a negatively-labeled class as false positives. hotcoco does not implement this — it needs per-image label data that COCO-format JSON cannot carry. A real challenge submission would score differently.
- Hierarchy expansion is applied to annotations before evaluation rather than inside it, so it sits outside the compared surface.
Panoptic¶
Reference: panopticapi at commit
7bb4655, numpy 2.4.3.
Ground truth: COCO panoptic val2017 — 5,000 images, 133 categories.
Predictions: the ground truth perturbed deterministically by
scripts/download_panoptic.py — segments dropped, relabeled, eroded, shifted, and
merged, plus spurious segments — so that every matching rule fires and PQ sits
well inside (0, 1).
Below the averages, every per-category TP, FP, and FN count is identical and the summed IoUs agree to 9.1e-12. The averages:
| Metric | panopticapi | hotcoco | Diff |
|---|---|---|---|
| PQ | 0.624866 | 0.624866 | 2.2e-16 |
| SQ | 0.934635 | 0.934635 | 2.2e-16 |
| RQ | 0.668410 | 0.668410 | 0.0 |
| PQ_th | 0.604005 | 0.604005 | 1.1e-16 |
| SQ_th | 0.931952 | 0.931952 | 1.1e-16 |
| RQ_th | 0.648102 | 0.648102 | 0.0 |
| PQ_st | 0.656354 | 0.656354 | 1.1e-16 |
| SQ_st | 0.938683 | 0.938683 | 4.4e-16 |
| RQ_st | 0.699063 | 0.699063 | 0.0 |
The differences that are not zero are summation order: panopticapi splits the images across worker processes and adds the per-worker sums, so its own last bits depend on the core count. The gate is 1e-9.
On the same files, same machine as the tables above, panopticapi's pq_compute
takes 16.1 s across its 8 worker processes and hotcoco 6.0 s (medians of 3 fresh
processes, one session); PNG decoding is most of hotcoco's time. The synthetic
version of this comparison — 50 random cases and 5 hand-built ones, counts exact,
plus the PNG-free mask path against the PNG path — runs in CI on every commit.
Semantic¶
References: mmsegmentation IoUMetric
at v1.2.2 (c685fe6), torchmetrics
1.9.0 multiclass_jaccard_index, detectron2
SemSegEvaluator at 1e3e13b, and the Boundary IoU authors'
boundary-iou-api mask_to_boundary
at 37d2558, each run from its pinned source by scripts/gen_semantic_fixtures.py
and frozen to tests/fixtures/semantic_expected.json.
Inputs: 67 synthetic cases — 60 random ones of 2 to 9 classes, 1 to 4 label maps
each, ignored pixels in half of them, reduce_zero_label in a fifth, classes present
on one side only; four hand-built corners (a perfect prediction, an all-ignored split,
a class never predicted, a class never in the ground truth); and three maps of 60 × 80
to 90 × 120 built from rectangles, so the boundary band is two or three pixels wide
and most pixels are interior.
Against mmseg, the reference hotcoco follows: every per-class IoU, Acc, and Dice,
mIoU, mAcc, aAcc, and mDice within 1e-9 on all 67 cases (the differences are
0 or one float64 ulp). Against torchmetrics, per-class IoU and mIoU within 1e-6 —
it reduces in float32, and sits about 1e-8 from mmseg. Against detectron2, fwIoU,
mAcc, and aAcc within 1e-9, and mIoU identical on the cases with no
predicted-but-absent class and different on all 30 that have one, which is the
documented convention choice being observed.
Against the authors' kernel, every per-class boundary IoU and mBIoU within 1e-9;
against detectron2's label-map erosion, mBIoU differs on 58 of the 67 cases, which
is the second documented choice being observed. The
confusion-matrix metrics are pure functions of the matrix and the band a pure
function of the maps, so this synthetic comparison covers them; there is no
real-dataset run for the family. Both suites run on every commit.
Verify it yourself¶
You do not have to take these numbers on faith, and you should not have to clone the repo to check them. Install both libraries and run your own ground truth and detections through each:
import contextlib, io
import numpy as np
from pycocotools.coco import COCO as PyCOCO
from pycocotools.cocoeval import COCOeval as PyCOCOeval
import hotcoco
GT, DT, IOU_TYPE = "instances_val2017.json", "my_detections.json", "bbox"
def run(coco_cls, eval_cls):
with contextlib.redirect_stdout(io.StringIO()): # both print a lot
gt = coco_cls(GT)
dt = gt.loadRes(DT)
e = eval_cls(gt, dt, IOU_TYPE)
e.evaluate(); e.accumulate(); e.summarize()
return np.asarray(e.stats)
ref = run(PyCOCO, PyCOCOeval)
got = run(hotcoco.COCO, hotcoco.COCOeval)
for i, (a, b) in enumerate(zip(ref, got)):
print(f"[{i:2}] pycocotools={a:.8f} hotcoco={b:.8f} diff={abs(a - b):.2e}")
print("max diff:", np.abs(ref - got).max())
On val2017 the project's own parity gate requires exact equality, so any difference at all is a bug. On your own data, anything above ~1e-12 is worth opening an issue.
The same shape works for hotcoco.mask against pycocotools.mask, operation by
operation.
Beyond val2017, a hypothesis-based fuzzer checks ~10,000 generated datasets — including degenerate zero-area boxes and other edge cases hand-written tests miss — against pycocotools at a 1e-10 tolerance.
Methodology¶
- Wall clock time includes file I/O, evaluation, and accumulation. Excludes Python import time.
- Core count affects the ratio. hotcoco evaluates in parallel; pycocotools is single-threaded. Speedups therefore scale with the cores available, and the numbers here come from an 8-core machine — a 4-core laptop sees less, a 32-core server more. Run the suite on your own hardware for a figure that describes it.
- Detections are synthetic — generated from GT annotations with a fixed seed (
seed=42), so AP scores are meaningless but detection count and format are representative of real model output. Fixed seed means results are identical across runs. - Only detections are scaled for the 10x benchmark — ground truth annotations are unchanged.
Reproducing the benchmarks¶
These run from a repo checkout — see CONTRIBUTING for the build. One command fetches the annotations and generates the synthetic detection files:
just download-coco # ~240 MB — val2017 annotations + parity result files
That produces:
data/
├── annotations/
│ ├── instances_val2017.json
│ └── person_keypoints_val2017.json
├── bbox_val2017_results.json
├── segm_val2017_results.json
└── kpt_val2017_results.json
With that in place:
just bench # speed benchmark (1x)
uv run python scripts/bench.py --phases # load/eval phase breakdown
uv run python scripts/bench.py --scale 10 # 10x stress test
just parity # metric parity vs pycocotools
just parity-tide # TIDE vs tidecv
just download-panoptic # 35 MB — panoptic val2017 + perturbed predictions
just parity-panoptic # PQ/SQ/RQ vs panopticapi
uv run pytest tests/test_mask_parity.py # hotcoco.mask vs pycocotools.mask, bit for bit
uv run pytest tests/test_parity_oid.py # Open Images vs the TF Object Detection API
uv run pytest tests/test_parity_semantic.py # mIoU, mDice vs mmseg, torchmetrics, detectron2; boundary IoU vs its authors' kernel (frozen)
just fuzz # hypothesis fuzzer, ~10,000 generated datasets
The Objects365 benchmark needs a separate download:
uv pip install polars
just download-o365 # ~220 MB — O365 val annotations
uv run python scripts/bench_objects365.py