Skip to content

Commit c3294a5

Browse files
Bordaclaude[bot]Copilot
authored
docs(export): fix ONNX Runtime example missing decode step (#1251)
Raw session.run output was unpacked directly as boxes/labels with no sigmoid, no background-column drop, and no name-based output matching. At num_classes=3 the logits dim (num_classes+1=4) collides with the box dim (4, cxcywh), so shape-based disambiguation silently swaps tensors and produces garbage boxes (#1247). Adds the missing decode and a warning about the collision, mirroring export/_onnx/inference.py. --------- Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent 1e0f5eb commit c3294a5

1 file changed

Lines changed: 44 additions & 1 deletion

File tree

docs/learn/export.md

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -742,6 +742,20 @@ Once exported, you can use the ONNX model with various inference frameworks:
742742

743743
### ONNX Runtime
744744

745+
The exported graph returns **raw** tensors — `dets` (`pred_boxes`, normalized `cxcywh`) and
746+
`labels` (`pred_logits`, un-activated). Nothing is decoded inside the graph, so your inference
747+
code must apply sigmoid, drop the no-object background column, and convert box format
748+
yourself.
749+
750+
!!! warning "Match outputs by name, not by shape"
751+
752+
RF-DETR always adds an implicit no-object class, so the logits tensor's last dimension is
753+
`num_classes + 1`. If `num_classes == 3`, that dimension is `4` — identical to the box
754+
tensor's last dimension (`4`, `cxcywh`). Disambiguating outputs by shape instead of by name
755+
(`"dets"` / `"labels"`) will silently swap boxes and logits at exactly `num_classes == 3`,
756+
producing garbage detections while every other `num_classes` value looks fine. Always match
757+
by name first.
758+
745759
```python
746760
import onnxruntime as ort
747761
import numpy as np
@@ -767,9 +781,38 @@ image_array = np.expand_dims(image_array, axis=0)
767781

768782
# Run inference
769783
outputs = session.run(None, {"input": image_array})
770-
boxes, labels = outputs
784+
785+
# Match outputs by name — do NOT assume positional order or infer role from shape.
786+
output_names = [out.name for out in session.get_outputs()]
787+
boxes_idx = next((i for i, name in enumerate(output_names) if "dets" in name), None)
788+
logits_idx = next((i for i, name in enumerate(output_names) if "labels" in name), None)
789+
if boxes_idx is None or logits_idx is None:
790+
raise ValueError(f"Could not find expected outputs 'dets'/'labels'. Available outputs: {output_names}")
791+
792+
boxes_cwh = outputs[boxes_idx][0] # (num_queries, 4) normalized cxcywh
793+
# Drop the last logit column: RF-DETR appends a no-object slot (num_classes + 1 total).
794+
logits = outputs[logits_idx][0, :, :-1] # (num_queries, num_classes)
795+
796+
# RF-DETR uses per-class sigmoid (multi-label), not softmax.
797+
scores_all = 1.0 / (1.0 + np.exp(-logits.clip(-88, 88)))
798+
scores = scores_all.max(axis=-1)
799+
class_ids = scores_all.argmax(axis=-1)
800+
801+
threshold = 0.5
802+
keep = scores > threshold
803+
804+
# cxcywh (normalized) -> xyxy (pixel space)
805+
cx, cy, bw, bh = boxes_cwh[keep].T
806+
xyxy = np.stack([cx - bw / 2, cy - bh / 2, cx + bw / 2, cy + bh / 2], axis=1)
807+
xyxy *= np.array([image.width, image.height, image.width, image.height], dtype=np.float32)
808+
809+
boxes, labels, confidences = xyxy, class_ids[keep], scores[keep]
771810
```
772811

812+
For a fuller reference implementation (name-based matching with a documented shape-based
813+
fallback), see `_run_inference` in
814+
[`src/rfdetr/export/_onnx/inference.py`](https://github.com/roboflow/rf-detr/blob/develop/src/rfdetr/export/_onnx/inference.py).
815+
773816
## Next Steps
774817

775818
After exporting your model, you may want to:

0 commit comments

Comments
 (0)