v1.9.3: Faster Predict, Correct YOLO Test-Split & Export Fixes
RF-DETR 1.9.3 is a maintenance release: no breaking changes, no new top-level public API. It fixes ten user-facing bugs and improves five behavior refinements, spanning training (YOLO test-split evaluation, auto-batch memory estimation, resumed-run metrics history, EMA epoch-boundary double-counting), inference (predict() no longer blocks per-image on a CUDA sync for CUDA-tensor inputs), export (ONNX/TFLite no longer silently drop multi-label detections, ONNX Runtime benchmark now honors --device), and segmentation (skip_blocks path now applies the learned feature projection it was missing during training).
Users training on YOLO-format datasets with a test split, exporting to ONNX/TFLite, or relying on batch_size="auto" benefit most from this release. Callers passing CUDA tensors directly to predict() get a small inference speedup from the sync fix; everyone should note the PostProcess tie-breaking change described below — non-breaking, but worth a glance if code depends on exact output order.
✨ Spotlights / highlights
predict() no longer blocks on a per-image CUDA sync for CUDA-tensor inputs (#1341)
RFDETR.predict()'s [0, 1] pixel-range validation used to sync the host after every single image. For callers passing CUDA tensors directly (the host-round-trip-free path), range-check results are now collected unsynced across the whole batch and resolved once, so later images' GPU work can overlap the sync from earlier ones. File-path and PIL inputs go through a CPU tensor conversion first and were never blocked by this sync, so they see no change here.
from rfdetr import RFDETRSmall
model = RFDETRSmall()
cuda_images = [img.cuda() for img in preprocessed_tensors]
# same call, same output — just no longer stalls the host once per CUDA-tensor image
detections = model.predict(cuda_images)YOLO-format evaluate(split="test") now evaluates the real test split (#1329, #1343)
Previously, requesting the test split on a YOLO-format dataset silently evaluated valid/ instead. Now it resolves the real test/ split when present, falls back to valid/ with a logged warning when the split is genuinely absent, and raises when a test path is declared in data.yaml but unresolvable or invalid.
model.train(dataset_dir="my_yolo_dataset", ...)
model.evaluate(split="test") # now evaluates test/, not valid/PostProcess selection is now deterministically tie-broken (#1320)
Detection selection switched from torch.topk to a stable argsort, so ties resolve the same way every run: descending score, then ascending flattened query/class index — the same rule now shared with the torch-free export decoders (both sides changed together in this PR). See the migration guide below if your code depends on tie order.
ONNX/TFLite export no longer silently drops multi-label detections (#1320)
The ONNX and TFLite reference decoders used a per-query argmax, keeping at most one class per query — any query scoring above threshold on more than one class quietly lost the rest. Both decoders now flatten (queries, classes) pairs and take the top-scoring ones before thresholding, mirroring PostProcess's own selection rule.
batch_size="auto" now accounts for AdamW's optimizer-state memory (#1342)
The auto-batch probe previously ignored AdamW's exp_avg/exp_avg_sq state, so the probed batch size could overshoot real training memory and OOM on the first optimizer step. It now models that state via a shadow optimizer and warns when a non-AdamW optimizer is configured (the estimate may then be too conservative or too permissive, since it no longer directly applies).
🔄 Migration guide
No breaking changes in this release.
Three behavior changes are worth a second look — none require a code change to keep working:
PostProcessdetection ordering may shift on score ties. Selection now uses a stableargsortinstead oftorch.topk, so ties resolve by ascending flattened query/class index instead oftopk's implementation-defined order. Same detections, same scores — only the order among exact ties can differ, sodetections[0]may point at a different (but equally-scored) box than before. Ordering among tied scores was never a documented contract.PostProcess(num_select=<negative>)now raisesValueErrorat construction instead of being silently accepted — if you were relying on that being a no-op, pass a non-negative value instead. (This selection now sorts the full flattened query/class score axis instead of doing a partial top-k; unmeasured this release, but callers with very large query/class counts on the hot inference path may want to benchmark their own workload.)RFDETR.predict()shape-validation errors changed type. Passing a malformed-rank input together withinclude_source_image=Truenow raises a publicValueErrorwith a shape message, where it previously surfaced an internalRuntimeErrorfrompermute(). If you were catchingRuntimeErroraround this call, catchValueErrorinstead (or both, for compatibility across versions).RFDETREMACallback.on_train_epoch_endno longer exists as an override point. If you subclassRFDETREMACallbackand rely on its ownon_train_epoch_endrunning (or override it yourself), note the epoch-boundary EMA update it performed is gone (that's the #1319 fix above) — calls tosuper().on_train_epoch_end(...)still resolve fine via PyTorch Lightning's baseCallback, but as a no-op rather than an EMA update.
📝 Notable changes
🌱 Changed
HungarianMatcher's compact-path safety gate now computes its target-side half (box/label finiteness checks) once per training step instead of once permatcher()call.SetCriterion.forwardinvokesmatcher()separately for the final layer, each auxiliary decoder layer, and the encoder layer with the sametargets, so the target-side precheck is now precomputed once and reused across all of them, keyed ontargetsobject identity pluspred_boxesdtype/device andnum_classes(a mismatch triggers a fresh computation). Matching results are unchanged. Callers must not mutatetargetsin place between precompute and reuse — the identity check cannot detect that. (#1340)- Per-class confidence-threshold sweeps in evaluation are now O(N log N) instead of O(T·N): one stable ascending sort per class plus
np.searchsortedinto precomputed suffix sums replaces a full rescan per threshold. NaN scores are explicitly masked so they never count as "above threshold". Results are unchanged. (#1339) RFDETR.predict()no longer blocks the host on a per-image CUDA sync for its[0, 1]pixel-range validation. The range-check tensors are now collected unsynced across all images and resolved to Python booleans once, after every image's conversion, range check, and transfer have already been queued, so later images' GPU work can overlap the sync. Error-message precedence per image is unchanged. A malformed-rank input combined withinclude_source_image=Truenow raises a publicValueErrorwith a shape message, where it previously surfaced an internalRuntimeErrorfrompermute(). (#1341)Transformer.forward's two-stage query selection now gathers thetorch.topk-selected rows before running the bbox-delta MLP (enc_out_bbox_embed), not after — the MLP is pointwise with no cross-token mixing, so it only ever needs at mostnum_queriesrows that survive selection, not every one of thesum(H*W)encoder positions. (#1334)PostProcessbox/mask/keypoint selection is now deterministically tie-broken:torch.argsort(..., stable=True)plus a slice replacestorch.topk, so ties resolve by descending score then ascending flattened query/class index — the same rule now shared with the torch-free export decoders (both sides changed together in this PR). Output ordering may differ from 1.9.2 when scores tie (same detections, different order;detections[0]may change), but ordering among equal scores was never contractual.PostProcess(num_select=<negative>)now raisesValueErrorat construction instead of being silently accepted. (#1320)
🔧 Fixed
- YOLO-format
evaluate(split="test")now evaluates the realtest/split, where it previously silently fell back tovalid/. When no resolvabletestsplit exists, it falls back tovalid/with a logged warning; when atestpath is declared indata.yamlbut unresolvable or invalid, it raises instead of silently relabeling as validation data. COCO-format datasets are unaffected — they never attempt atestfallback. (#1329, #1343) metrics.csvtraining history now survives a resumed run instead of being wiped on the first write after resume. (#1325)SegmentationHead'sskip_blocksbranch now applies the learnedspatial_features_projprojection, matching the non-skip branch — the projection was being skipped entirely. Affects segmentation training loss trajectories only (encoder auxiliary mask supervision); the export path already applied this projection unconditionally before this change, andpredict()outputs and exported models are unchanged. (#1331)- Non-finite keypoint predictions no longer poison the shared box-head gradient via
ref_wh, in both the decoder and encoder branches. The keypoint loss itself now also masks out non-finite predicted keypoints and non-finite target areas rather than letting them poison the loss. Not yet covered: the matcher's own keypoint cost still lacks the equivalent guard. (#1336) batch_size="auto"now models AdamW's optimizer-state memory in its probe, preventing an OOM on the first optimizer step when the probed batch size previously overshot real training memory. (#1342)- ONNX Runtime benchmark now honors the requested
--device, instead of always binding GPU 0. (#1346) - Training metric plots now draw a legend on every subplot, not just the loss subplot. (#1335)
- ONNX/TFLite reference decode now mirrors
PostProcess's multi-label selection instead of a per-queryargmax, which silently dropped legitimate detections whenever a query scored above threshold on more than one class. (#1320) - EMA no longer double-counts the last training step of every epoch, which previously bypassed
ema_update_interval. (#1319) model.export(format="tflite")no longer hangs at the ONNX→TFLite step (an Abseil weak-symbol coalescing issue between theonnxC extension and TensorFlow). (#1323)
🏆 Contributors
- adenstamm (@adenstamm) — fixed missing legends on training metric plots.
- Aman Harsh (@amanharshx, LinkedIn) — fixed a TFLite export hang by preloading TensorFlow before ONNX.
- aryan kolapkar (@arubittu) — enabled strict mypy checking across the datasets and namespace modules.
- Flo (@flhoxha) — fixed YOLO-format datasets to evaluate the real test split.
- FootysHands (@ayo0la) — fixed
metrics.csvhistory being lost across resumed training runs. - Jesús Royeth (@JESUSROYETH) — the release's most prolific contributor this cycle: the
predict()CUDA-sync fix, the matcher target-side safety-gate caching, the YOLO test-split fallback, AdamW-aware auto-batch probing, the O(N log N) confidence-threshold sweep, the keypoint/ref_whnon-finite guard, the two-stage top-k gather-order fix, theSegmentationHeadskip_blocksfix, the ONNX/TFLite multi-label decode fix, and the EMA epoch-boundary double-count fix. - jirka (@Borda, LinkedIn) — pre-commit and documentation maintenance.
- Vlad Voropaev (@voropaevv, LinkedIn) — fixed the ONNX Runtime benchmark to honor the requested CUDA device.
Full changelog: 1.9.2...1.9.3