Skip to content

[Bug][Relax][ONNX] NonMaxSuppression uses 0.5 instead of the ONNX default IoU threshold #20506

Description

@Yuhx141

Version

  • Executed revision: e269315c90e3a061c9e1c77b370ce883b1b223f4
  • Current main source rechecked: 0587e59a696cb435ad4d328726a07daa8d966f3c; the converter still defaults a missing iou_threshold to 0.5
  • TVM: 0.26.dev0
  • Host: Ubuntu 24.04 x86-64, LLVM CPU

Reproducer

Run the complete script below.
The two boxes have IoU 1/7, so the missing ONNX threshold of 0 suppresses the second box,
while a threshold of 0.5 keeps it.

Observed output:

explicit_iou=False ORT [[0, 0, 0]] TVM [[0, 0, 0], [0, 0, 1]]
explicit_iou=True  ORT [[0, 0, 0], [0, 0, 1]] TVM [[0, 0, 0], [0, 0, 1]]

The first line omits the optional input and fails. The second line explicitly supplies 0.5
and is the passing control.

Expected behavior

The ONNX NonMaxSuppression schema says a missing iou_threshold defaults to 0. The imported
function should therefore match ONNX Runtime and suppress the second overlapping box.

Actual behavior and likely cause

The Relax ONNX converter assigns 0.5 whenever iou_threshold is absent, apparently copying
the native TVM NMS default rather than the ONNX contract. Current main retains that branch.

Duplicate check

GitHub issue, pull-request, and repository searches found no report for this default mismatch
as of 2026-09-30. Issue #17767 predates Relax support for NonMaxSuppression. PR #19547 fixed
the missing default for max_output_boxes_per_class, and PR #19843 fixed 1-D scalar inputs;
both retained iou_threshold=0.5 and do not cover this case.

Complete reproducer

import numpy as np
import onnx
import onnxruntime as ort
import tvm
from onnx import TensorProto, helper, numpy_helper
from tvm import relax
from tvm.relax.frontend.onnx import from_onnx


def make_model(explicit_iou):
    boxes = helper.make_tensor_value_info("boxes", TensorProto.FLOAT, [1, 2, 4])
    scores = helper.make_tensor_value_info("scores", TensorProto.FLOAT, [1, 1, 2])
    initializers = [numpy_helper.from_array(np.array([10], dtype="int64"), name="max_out")]
    node_inputs = ["boxes", "scores", "max_out"]
    if explicit_iou:
        initializers.append(
            numpy_helper.from_array(np.array([0.5], dtype="float32"), name="iou")
        )
        node_inputs.append("iou")
    output = helper.make_tensor_value_info("selected", TensorProto.INT64, [None, 3])
    node = helper.make_node("NonMaxSuppression", node_inputs, ["selected"])
    model = helper.make_model(
        helper.make_graph([node], "nms_default_iou", [boxes, scores], [output], initializers),
        opset_imports=[helper.make_opsetid("", 11)],
    )
    onnx.checker.check_model(model)
    return model


def run(explicit_iou):
    model = make_model(explicit_iou)
    feeds = {
        "boxes": np.array([[[0, 0, 2, 2], [1, 1, 3, 3]]], dtype="float32"),
        "scores": np.array([[[0.9, 0.8]]], dtype="float32"),
    }
    expected = ort.InferenceSession(
        model.SerializeToString(), providers=["CPUExecutionProvider"]
    ).run(None, feeds)[0]
    mod = from_onnx(model, keep_params_in_input=False)
    executable = relax.build(mod, target="llvm", relax_pipeline="default", exec_mode="bytecode")
    actual = relax.VirtualMachine(executable, tvm.cpu())["main"](
        tvm.runtime.tensor(feeds["boxes"]), tvm.runtime.tensor(feeds["scores"])
    ).numpy()
    print("explicit_iou", explicit_iou, "ORT", expected.tolist(), "TVM", actual.tolist())
    if explicit_iou:
        np.testing.assert_array_equal(actual, expected)


run(False)
run(True)

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions