Skip to content

feat(training): hotcoco as default COCO evaluator - #1402

Open
Borda wants to merge 1 commit into
developfrom
feat/hotcoco
Open

feat(training): hotcoco as default COCO evaluator#1402
Borda wants to merge 1 commit into
developfrom
feat/hotcoco

Conversation

@Borda

@Borda Borda commented Aug 25, 2026

Copy link
Copy Markdown
Member

Summary

Detection and segmentation COCO evaluation (validation and test mAP) now runs on hotcoco, a Rust COCO evaluator distributed under MIT with numpy as its only runtime dependency. The new TrainConfig.eval_backend knob selects between it and the previous evaluator:

model.train(dataset_dir=..., eval_backend="hotcoco")           # default
model.train(dataset_dir=..., eval_backend="faster_coco_eval")  # previous evaluator

Reported metrics do not change. The parity tests compare every aggregate, per-class and class-ID output of both backends for box-only and box-plus-mask evaluation and require exact equality (rtol=0, atol=0), which they reach.

faster-coco-eval is not removed and is not optional. TorchMetrics resolves its COCO helpers from a closed backend-name enum with no hotcoco member, so the adapter constructs the backend under the supported name and replaces the three resolved module surfaces (coco, cocoeval, mask_utils) afterwards. Every private helper the adapter calls — COCO-format construction, statistics conversion — stays TorchMetrics' own and is shared by both backends.

Keypoint evaluation uses its own OKS path and is untouched. The ONNX/TensorRT benchmark evaluator in rfdetr.evaluation.coco_eval continues to call faster-coco-eval directly.

What changed

  • TrainConfig.eval_backend: Literal["hotcoco", "faster_coco_eval"] = "hotcoco", threaded through build_trainer into COCOEvalCallback and from there into all three metric constructions: validation, train split, and EMA.
  • _HotCocoBackend(CocoBackend) in src/rfdetr/training/coco_map.py, plus _HotCocoMaskUtils for the one mask utility that needs input coercion.
  • A detection-array fast path: for box-only evaluation on hotcoco, predictions reach the backend as a single [image_id, x, y, width, height, score, category_id] array through COCO.loadRes instead of as one Python dictionary per detection.
  • File-descriptor-level output suppression for the three evaluator calls, because hotcoco prints from Rust and contextlib.redirect_stdout does not reach it.
  • hotcoco>=0.5,<0.6 added to the train extra.

Measured impact

Both backends driven through OnePassCocoMeanAveragePrecision itself — not through a standalone harness — on synthetic accumulated state with a plausible score and IoU distribution at each dataset's real image, annotation and class counts. eval_max_dets=500, macOS CPU, single machine.

State class_metrics faster-coco-eval hotcoco speedup max abs. difference
Playing-Cards-shaped (2,020 images, 52 classes, 8.1k GT, 100 det/image) off 1.07 s 0.23 s 4.6x 0.00e+00
Playing-Cards-shaped on 0.97 s 0.28 s 3.4x 0.00e+00
COCO-val-shaped (5,000 images, 80 classes, 36.6k GT, 300 det/image) off 6.60 s 1.51 s 4.4x 0.00e+00
COCO-val-shaped on 6.21 s 1.49 s 4.2x 0.00e+00

These are compute() timings. They do not include the validation forward pass, which usually dominates a validation epoch.

Why the first integration only reached 1.7x

The first working version swapped the evaluator and nothing else, and measured 1.7x. The stage split at COCO-val scale shows why that was the wrong place to stop:

Stage faster-coco-eval hotcoco, evaluator swap only hotcoco, this PR
Build COCO datasets 1.76 s 2.32 s 0.40 s
COCO / evaluator construction 0.00 s 0.57 s 0.50 s
evaluate + accumulate + summarize 4.44 s 0.59 s 0.33 s
total 6.20 s 3.49 s 1.23 s

The evaluator itself was already about 8x faster after the swap, but 83% of what remained was TorchMetrics building one Python dictionary per detection — over 1.5 million of them at that scale — which a backend swap does not touch. The detection-array path removes that work rather than accelerating it. Boxes need no conversion on that path because TorchMetrics already stores them as COCO xywh at update() time, and rows stay in stored-state order so equal-scoring detections keep the tie order the dictionary path produced.

The array path is deliberately narrow. It is skipped for faster_coco_eval, whose own loadRes is slower than the dictionary path it would replace (3.7 s against 1.8 s at COCO-val scale); for mask evaluation, where an array carries no segmentation; and for states holding no boxes. _prediction_dataset_for_iou_type raises rather than proceeding if a multi-IoU-type evaluation ever reaches an array-built dataset.

Projected impact on the training loop

This is arithmetic on two prior measurements, not a run. No training epoch was timed with this branch. The projection is included because "4x faster evaluation" invites the wrong conclusion about epoch time, and the honest answer is that it is small.

Anchor. The M1 profiling run (.plans/closed/results_training-perf.md): Colab L4, rfdetr-small, Playing-Cards val split (1,978 images, 39 classes, 300 queries per image), median 323.8 s/epoch — train 256.5 s (79.2%), val_bar 46.0 s, post-validation gap 21.3 s. In that run map_compute was 17.1 s/epoch across the base and EMA metrics (8.5 s + 8.6 s), 5.3% of the epoch and 80% of the post-validation gap. Two changes have landed since and both shrink it before this PR applies: PR11 (the _get_coco_format score hoist) took it to roughly 15 s/epoch for both metrics, and PR12 made EMA-only evaluation the default, so the default path computes one metric rather than two — call it ~7.5 s/epoch.

Method and its limits. The same source measures the machine behind the microbenchmarks above as roughly 3.7x faster than the Colab L4 vCPU, so absolute seconds do not transfer between the two. Only the ratio is carried over, and carrying it over assumes the speedup factor is insensitive to CPU speed. That assumption is untested: the removed dictionary construction is single-threaded Python and the remaining evaluation is native code, and those two do not necessarily scale together across machines.

Scenario (L4 anchor, 323.8 s/epoch) map_compute before ratio applied after saving share of epoch
Detection, current defaults (eval_base_model=False, log_per_class_metrics=False) ~7.5 s 4.4–4.6x ~1.6–1.7 s ~5.8 s ~1.8%
Detection, log_per_class_metrics=True ~7.5 s 3.4–4.2x ~1.8–2.2 s ~5.3–5.7 s ~1.6–1.8%
Detection, eval_base_model=True (two metrics) ~15 s 4.4–4.6x ~3.3–3.4 s ~11.6 s ~3.6%
Segmentation (dictionary path retained) ~7.5 s ~1.7x ~4.4 s ~3.1 s ~1.0%
COCO-2017-scale training (~73 min/epoch training alone) seconds <0.2%

The segmentation row is the weakest of the five: the array path does not apply there, so only the evaluator swap contributes, and that 1.7x was measured on box state rather than mask state. Treat it as an order of magnitude, not a figure.

Verdict: this is not a training-throughput change. On the anchor workload it is worth under 2% of an epoch on the default path, and proportionally less as the dataset or model grows, because the validation forward pass and training step both scale while compute() scales with detection count alone. It matters where compute() is the whole job rather than a rounding error next to the forward pass: standalone evaluation of a checkpoint, large validation sets, sweeps that evaluate far more often than they train, and eval_interval=1 on small models. On COCO-val-shaped state that job goes from 6.4 s to 1.5 s.

Compatibility traps handled

Five behaviors differ from faster-coco-eval in ways the adapter has to absorb. Four of them produce a silently wrong number rather than an error, so each has a dedicated test that fails if the handling is dropped.

  1. params is copy-on-read. ev.params.maxDets = [...] is a no-op. The adapter assigns the whole object back. Mutating in place — which both TorchMetrics and RF-DETR previously did — would have silently reduced eval_max_dets=500 to COCO's default 100.
  2. dataset is also copy-on-read, and the constructor discards unrecognized fields. The area_bbox/area_segm swap that a ("bbox", "segm") evaluation performs would be invisible to the evaluator, leaking one IoU type's areas into the other's COCO size buckets. The adapter keeps the source dictionary and rebuilds. In the shipped regression fixture, dropping this doubles bbox_map_small from 0.5 to 1.0.
  3. mask.encode returns RLE counts as bytes, and the COCO constructor decodes only the string form, reading a bytes payload back as an empty mask. Mask AP collapses to 0.000 with nothing raised. The adapter decodes to UTF-8.
  4. mask.encode accepts uint8 only and rejects the boolean array TorchMetrics hands it. This one is loud, but with a confusing TypeError: 'ndarray' object is not an instance of 'ndarray'.
  5. The IoU type is spelled iou_type, not iouType. Loud. The adapter dispatches the keyword per backend rather than passing it positionally: positional works on both today, but a parameter inserted before it upstream would then bind the IoU type to the wrong slot and evaluate a detection run as segmentation, silently.

Separately, hotcoco writes to the standard output and error descriptors from Rust: a twelve-line summary table on descriptor 1, and one warning per evaluator parameter that differs from the COCO defaults on descriptor 2. RF-DETR overrides maxDets and iouThrs on every evaluation, so those warnings would repeat each validation epoch and each IoU type while describing intended configuration. Both descriptors are redirected around the three evaluator calls only, so the adapter's own contract-failure logging is not swallowed with them; genuine failures still surface, because the bindings raise Python exceptions rather than reporting errors on descriptor 2.

Tests

  • Exact-parity tests for box-only and box-plus-mask evaluation across every output key, on a fixture whose per-class values are genuinely fractional rather than 0 or 1.
  • maxDets effectiveness, parametrized over both backends.
  • Annotation-area regression, parametrized over both backends: a false positive whose box is COCO-"small" and whose mask is "medium", scored above the true positive, so a leaked area moves bbox_map_small from 0.5 to 1.0. Verified by mutation — reverting the per-IoU-type rebuild fails it on both backends.
  • The backend prints nothing to either descriptor during compute().
  • A missing hotcoco reports the install rather than a torchmetrics incompatibility. The contract check resolves the evaluator inside an except ImportError, so the import has to happen eagerly in _HotCocoBackend.__init__ to survive it.
  • The configured backend reaches the validation, train-split and EMA metrics alike, and omitting it selects hotcoco.
  • _validate_private_contract skips _get_coco_datasets on the hotcoco path: that helper ends with a dataset assignment and a createIndex() call, neither of which hotcoco supports, so the adapter never calls it and an upstream rename must not block hotcoco users over a call they do not make.

tests/training/ passes at 1182 passed, 15 skipped. pre-commit run --all-files is clean.

Packaging

hotcoco 0.5.0 is MIT, depends on numpy at runtime and nothing else, and ships cp39-abi3 wheels for manylinux2014 x86_64 and aarch64, macOS x86_64 and arm64, and win_amd64. uv sync --dry-run --extra train resolves it on every interpreter in requires-python, 3.10 through 3.14.

Two consequences worth knowing before merge:

  • No musllinux wheels. Alpine and other musl-based images installing rfdetr[train] will fall back to building from source, which requires a Rust toolchain. This is a new constraint on that platform, since every other train dependency ships a musl wheel or is pure Python. No CI job covers musl today, so this is stated rather than measured.
  • Installing it puts a generically-named coco console script on PATH, which can shadow an unrelated coco command in the same environment.

The >=0.5,<0.6 ceiling is deliberate rather than conventional. Three of the worked-around behaviors fail silently, so each pre-1.0 minor has to be re-verified against the parity tests before it is allowed in, and a tight ceiling is what forces that.

Not measured

  • No CUDA, L4, or trained-model run. Every number above is macOS CPU on synthetic accumulated state, generated at real dataset shapes rather than dumped from a trained model. Parity is exact on those fixtures; the timings are single-machine.
  • No DDP run. The state merge completes before compute() is entered, so no collective is issued inside the descriptor-redirect window. That is an ordering argument from the code, not a measurement.
  • No segmentation timing. The segmentation projection reuses a ratio measured on box state.

Rollback

eval_backend="faster_coco_eval" restores the previous evaluator on a per-run basis with no reinstall — it remains a required dependency and a fully tested path. Reverting the commit removes the knob and the train-extra entry.

- Add `TrainConfig.eval_backend`, a `Literal["hotcoco", "faster_coco_eval"]` field defaulting to `"hotcoco"`, selecting the COCO evaluator used for detection and segmentation validation and test mAP. `build_trainer` passes it to `COCOEvalCallback`, which forwards it to the validation, train-split and EMA metrics alike; `OnePassCocoMeanAveragePrecision` now validates its `backend` argument against `_SUPPORTED_BACKENDS` instead of pinning it to `faster_coco_eval`.
- Add `_HotCocoBackend`, a TorchMetrics `CocoBackend` subclass that overrides the resolved COCO, evaluator and mask surfaces. TorchMetrics resolves those from a closed backend-name enum with no hotcoco member, so the parent is constructed with the supported `faster_coco_eval` name and the three surfaces are replaced afterwards; faster-coco-eval therefore remains a required dependency. Every private helper the adapter calls on the backend stays TorchMetrics' own.
- Work around four hotcoco behaviors that return a silently wrong answer rather than raising, each covered by a test that fails if the handling is dropped: `params` is assigned as a whole object because its getter returns a copy, which would otherwise leave `maxDets` at COCO's default 100; `_coco_datasets` returns the source COCO-format dictionary as a third element because hotcoco's constructor keeps only the fields it recognizes and drops the `area_bbox`/`area_segm` values a multi-IoU-type evaluation switches between; `_build_coco` decodes bytes RLE `counts` to `utf-8` because the constructor reads a bytes payload back as an empty mask and collapses mask AP to `0.0`; and `_HotCocoMaskUtils.encode` coerces TorchMetrics' boolean mask array to `uint8`, which the Rust binding requires.
- Load box-only hotcoco predictions through `loadRes` as one `[image_id, x, y, width, height, score, category_id]` array instead of the per-detection annotation dictionaries TorchMetrics materializes, which dominate `compute()` once the evaluator itself is fast. Rows stay in stored-state order so equal-scoring detections keep the tie order the dictionary path produced, and boxes need no conversion because `update()` already stored them as COCO `xywh`. Segmentation, the `faster_coco_eval` backend, and states holding no boxes keep the dictionary path; `_prediction_dataset_for_iou_type` raises if a multi-IoU-type evaluation ever reaches an array-built dataset.
- Silence hotcoco's evaluation output at the file-descriptor level via `_silenced_rust_output`, since it prints from Rust and `contextlib.redirect_stdout` does not reach it. The window covers only `evaluate`, `accumulate` and `summarize`, so the adapter's own contract-failure logging is not suppressed along with the backend's per-parameter warnings. Failures still surface as Python exceptions.
- Pass the IoU type by each backend's own keyword rather than positionally, so a parameter inserted before it upstream cannot bind a detection run to segmentation.
- Skip `_get_coco_datasets` in `_validate_private_contract` when the hotcoco backend is active: that helper ends with a `dataset` assignment and `createIndex()` call, neither of which hotcoco supports, so the adapter never calls it and an upstream rename must not block hotcoco users.
- Add `hotcoco>=0.5,<0.6` to the `train` extra. The ceiling is deliberate rather than conventional: three of the worked-around behaviors fail silently, so each pre-1.0 minor has to be re-verified against the parity tests before it is allowed in.
- Add parity tests asserting exact equality across every aggregate, per-class and class-ID output of both backends for box-only and box-plus-mask evaluation, plus coverage for `maxDets` reaching the evaluator, for evaluation printing nothing, for the missing-dependency `ImportError` naming the extra, and for each IoU type's annotation areas driving its own COCO size buckets.
- Document the knob in the training-parameters table and the `[train]` extra description, and record the default change in the changelog.

---
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
@Borda Borda added the enhancement New feature or request label Aug 25, 2026
@Borda
Borda requested a balanced review from Copilot August 25, 2026 06:48
@socket-security

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addedpypi/​hotcoco@​0.5.09610010010090

View full report

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds hotcoco as the default training-time COCO evaluator while retaining faster-coco-eval as a fallback.

Changes:

  • Adds backend selection throughout training configuration and callbacks.
  • Implements hotcoco adaptation and a detection-array fast path.
  • Adds parity/regression tests, dependency metadata, and documentation.

Reviewed changes

Copilot reviewed 10 out of 10 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
src/rfdetr/config.py Defines eval_backend.
src/rfdetr/training/trainer.py Forwards backend configuration.
src/rfdetr/training/callbacks/coco_eval.py Applies the backend to all metrics.
src/rfdetr/training/coco_map.py Implements hotcoco integration and optimizations.
tests/training/test_coco_map.py Tests parity and compatibility behavior.
tests/training/callbacks/test_coco_eval_callback.py Tests callback backend propagation.
pyproject.toml Adds the hotcoco dependency.
docs/learn/train/training-parameters.md Documents backend selection.
CHANGELOG.md Records the evaluator change.
AGENTS.md Updates dependency guidance.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

segmentation: bool = False,
eval_interval: int = 1,
log_per_class_metrics: bool = True,
eval_backend: Literal["hotcoco", "faster_coco_eval"] = "hotcoco",
Comment on lines +144 to +148
saved = {descriptor: os.dup(descriptor) for descriptor in (1, 2)}
devnull = os.open(os.devnull, os.O_WRONLY)
try:
for descriptor in saved:
os.dup2(devnull, descriptor)
Comment on lines +140 to +142
eval_backend: COCO evaluation backend, mirroring :attr:`~rfdetr.config.TrainConfig.eval_backend`. Both
backends return identical metrics; ``"hotcoco"`` is the faster default and ``"faster_coco_eval"`` is
the previous evaluator.
Comment on lines +907 to +913
def missing_dependency() -> Any:
raise ImportError("hotcoco requires the optional dependency; install it with: pip install 'rfdetr[hotcoco]'")

monkeypatch.setattr("rfdetr.training.coco_map._hotcoco", missing_dependency)

with pytest.raises(ImportError, match=r"rfdetr\[hotcoco\]"):
OnePassCocoMeanAveragePrecision(backend="hotcoco")
@codecov

codecov Bot commented Aug 25, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.62044% with 6 lines in your changes missing coverage. Please review.
✅ Project coverage is 86%. Comparing base (7b9cba6) to head (46c83e0).
⚠️ Report is 2 commits behind head on develop.

Additional details and impacted files
@@           Coverage Diff           @@
##           develop   #1402   +/-   ##
=======================================
  Coverage       86%     86%           
=======================================
  Files          114     114           
  Lines        14877   14976   +99     
=======================================
+ Hits         12832   12928   +96     
- Misses        2045    2048    +3     
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@Borda

Borda commented Aug 25, 2026

Copy link
Copy Markdown
Member Author

cross-ref: derekallman/hotcoco#5

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants