This is an automated email from the ASF dual-hosted git repository.

tlopex pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tvm.git


The following commit(s) were added to refs/heads/main by this push:
     new 9fbdd94c3b [Fix][Relax][Frontend][TFLite] Correct quantized SSD 
inference (#20291)
9fbdd94c3b is described below

commit 9fbdd94c3b67a81a7edc88182aa4d28412e8b4e2
Author: Hongyi Wu <[email protected]>
AuthorDate: Fri Sep 11 11:43:16 2026 +0800

    [Fix][Relax][Frontend][TFLite] Correct quantized SSD inference (#20291)
    
    ## Summary
    
    This PR fixes Relax import and execution of quantized TFLite SSD models
    and adds focused regression coverage for the repaired conversion paths.
    
    - Preserve TFLite `DETECTION_POSTPROCESS` class semantics by removing
    the optional background channel, keeping zero-based labels, and avoiding
    a second softmax.
    - Add quantized `AVERAGE_POOL_2D` and `MAX_POOL_2D` to the supported QDQ
    conversion path.
    - Correct per-channel depthwise-convolution dequantization and the
    weight layout when `depth_multiplier > 1`.
    - Add targeted frontend, operator, and parser regressions for those
    conversion semantics.
    
    ## Design
    
    ### TFLite detection postprocessing
    
    TFLite SSD class scores are already activated. When the input class
    tensor has one more channel than the custom op's `num_classes`, that
    extra channel is the background class. The frontend now validates that
    the difference is either zero or one, slices the background channel when
    present, and keeps the remaining class indices zero-based.
    
    `relax.vision.multibox_transform_loc` gains an `apply_softmax`
    attribute. Its default remains `True`, preserving the existing generic
    Relax behavior, while the TFLite frontend sets it to `False` for
    `DETECTION_POSTPROCESS`.
    
    ### Quantized pooling and depthwise convolution
    
    Average and max pooling now use the same QDQ conversion path as the
    other supported quantized operators.
    
    For per-channel depthwise weights, dequantization is applied along the
    original TFLite channel axis before the weights are reshaped to Relax's
    convolution layout. The reshape also distinguishes the `input_channels
    == 1` case from the general `depth_multiplier > 1` case, producing the
    correct HWIO or HWOI layout respectively.
    
    ## Review map
    
    1. `python/tvm/relax/frontend/tflite/tflite_frontend.py`: TFLite
    detection, pooling, and depthwise conversion.
    2. `include/tvm/relax/attrs/vision.h`, `src/relax/op/vision/`, and the
    Python Relax/TOPI vision layers: `apply_softmax` plumbing with the
    backward-compatible default.
    3. `tests/python/relax/test_frontend_tflite.py`: focused
    detection-postprocess, quantized-pool, and per-channel-depthwise
    frontend regressions.
    4. `tests/python/relax/test_op_vision.py` and
    `tests/python/relax/test_tvmscript_parser_op_vision.py`: operator and
    parser coverage.
    
    ## Validation
    
    Validated locally after the final amended commit.
    
    | Environment | Result |
    | --- | --- |
    | Local external-disk build | Full build passed |
    | Local TFLite frontend suite | 559 passed |
    | Local Relax vision operator and parser suites | 70 passed |
    | Local formatting/static checks | Ruff and clang-format checks passed |
    
    The official quantized SSD MobileNet V2 COCO model checkpoint/artifact
    was also imported, compiled, and executed locally as an out-of-tree
    development validation; it is intentionally not part of the TVM test
    suite.
    
    ## Known limits
    
    This PR is correctness-focused and retains the existing QDQ lowering
    (`int -> float -> int`); it does not claim a performance improvement. In
    the full SSD network, accumulated differences between TFLite fixed-point
    kernels and Relax QDQ rounding can reorder low-confidence tail
    detections. The local model check verifies the leading semantic
    detection; integer-native quantized lowering is left as follow-up work.
---
 include/tvm/relax/attrs/vision.h                   |   5 +-
 .../tvm/relax/frontend/tflite/tflite_frontend.py   | 100 +++++++++++++++++----
 .../tvm/relax/op/vision/multibox_transform_loc.py  |   8 +-
 python/tvm/relax/transform/legalize_ops/vision.py  |   1 +
 python/tvm/topi/vision/multibox_transform_loc.py   |  13 +--
 src/relax/op/vision/multibox_transform_loc.cc      |   8 +-
 src/relax/op/vision/multibox_transform_loc.h       |   2 +-
 tests/python/relax/test_frontend_tflite.py         |  97 +++++++++++++++++---
 tests/python/relax/test_op_vision.py               |  17 +++-
 .../relax/test_tvmscript_parser_op_vision.py       |   2 +
 10 files changed, 208 insertions(+), 45 deletions(-)

diff --git a/include/tvm/relax/attrs/vision.h b/include/tvm/relax/attrs/vision.h
index f4b1830669..9a2384a494 100644
--- a/include/tvm/relax/attrs/vision.h
+++ b/include/tvm/relax/attrs/vision.h
@@ -158,6 +158,7 @@ struct MultiboxTransformLocAttrs : public AttrsNode {
   double threshold;
   ffi::Array<double> variances;
   bool keep_background;
+  bool apply_softmax;
 
   static void RegisterReflection() {
     namespace refl = tvm::ffi::reflection;
@@ -170,7 +171,9 @@ struct MultiboxTransformLocAttrs : public AttrsNode {
                 "(x,y,w,h) scales = TFLite 
1/x_scale,1/y_scale,1/w_scale,1/h_scale on "
                 "encodings. Very large w/h scales can overflow exp in decode.")
         .def_ro("keep_background", &MultiboxTransformLocAttrs::keep_background,
-                "If false, force output scores[:,0,:] to 0 (background 
class).");
+                "If false, force output scores[:,0,:] to 0 (background 
class).")
+        .def_ro("apply_softmax", &MultiboxTransformLocAttrs::apply_softmax,
+                "Whether to apply softmax to class predictions before 
thresholding.");
   }
   TVM_FFI_DECLARE_OBJECT_INFO_FINAL("relax.attrs.MultiboxTransformLocAttrs",
                                     MultiboxTransformLocAttrs, AttrsNode);
diff --git a/python/tvm/relax/frontend/tflite/tflite_frontend.py 
b/python/tvm/relax/frontend/tflite/tflite_frontend.py
index 13d78c4c30..935246cc18 100644
--- a/python/tvm/relax/frontend/tflite/tflite_frontend.py
+++ b/python/tvm/relax/frontend/tflite/tflite_frontend.py
@@ -100,6 +100,7 @@ class OperatorConverter:
         {
             "ABS",
             "ADD",
+            "AVERAGE_POOL_2D",
             "ATAN2",
             "CEIL",
             "CONCATENATION",
@@ -125,6 +126,7 @@ class OperatorConverter:
             "LOGISTIC",
             "LOG_SOFTMAX",
             "MAXIMUM",
+            "MAX_POOL_2D",
             "MEAN",
             "MINIMUM",
             "MUL",
@@ -4650,6 +4652,13 @@ class OperatorConverter:
             # 1 KH KW C(input_c * depth_multiplier)
             _, kernel_h, kernel_w, in_channels = 
to_int_list(self.get_tensor_shape(weight_tensor))
             assert in_channels == input_c * depth_multiplier
+            # Relax grouped convolution expects one input channel per group in
+            # HWOI when input_c > 1.  The single-channel case remains HWIO.
+            depthwise_weight_shape = (
+                (kernel_h, kernel_w, input_c, depth_multiplier)
+                if input_c == 1
+                else (kernel_h, kernel_w, in_channels, 1)
+            )
         else:
             output_channels, kernel_h, kernel_w, in_channels = to_int_list(
                 self.get_tensor_shape(weight_tensor)
@@ -4689,6 +4698,13 @@ class OperatorConverter:
         )
         weight_tensor_type_str = self.get_tensor_type_str(weight_tensor_type)
 
+        per_channel_depthwise = (
+            is_depthwise_conv
+            and input_tensor.qnn_params
+            and weight_tensor.qnn_params
+            and weight_tensor.tensor.Quantization().ScaleLength() > 1
+        )
+
         in_expr = self.get_expr(input_tensor_idx)
 
         # TFLite converts float32 models to float16 models by introducing
@@ -4698,9 +4714,8 @@ class OperatorConverter:
         if self.has_expr(weight_tensor.tensor_idx):
             weight_expr = self.get_expr(weight_tensor.tensor_idx)
             if is_depthwise_conv:
-                weight_expr = relax.op.reshape(
-                    weight_expr, (kernel_h, kernel_w, input_c, 
depth_multiplier)
-                )
+                if not per_channel_depthwise:
+                    weight_expr = relax.op.reshape(weight_expr, 
depthwise_weight_shape)
             else:
                 weight_expr = relax.op.permute_dims(weight_expr, axes=(1, 2, 
3, 0))
         else:
@@ -4713,10 +4728,11 @@ class OperatorConverter:
             # convolution:
             # OC KH KW IC, we require KH KW IC OC (HWIO)
             # depthwise convolution:
-            # 1 KH KW C(input_c * depth_multiplier), we require
-            # KH KW IC M (depth_multiplier) (HWOI)
+            # 1 KH KW C(input_c * depth_multiplier), we require HWIO
+            # [KH,KW,1,M] for one input channel, otherwise HWOI [KH,KW,C*M,1].
             if is_depthwise_conv:
-                weight_value = weight_value.reshape(kernel_h, kernel_w, 
input_c, depth_multiplier)
+                if not per_channel_depthwise:
+                    weight_value = weight_value.reshape(depthwise_weight_shape)
             else:
                 weight_value = weight_value.transpose((1, 2, 3, 0))
 
@@ -4748,11 +4764,39 @@ class OperatorConverter:
             # QuantizedDimension() == 0 (OC in original) → axis 3 in HWIO.
             weight_axis = weight_tensor.qnn_params["axis"]
             if is_depthwise_conv:
-                if weight_axis != 0:
-                    raise tvm.error.OpNotImplemented(
-                        "Per-channel quantized depthwise convolution is not 
supported "
-                        "because the channel axis changes semantics after the "
-                        "[1,KH,KW,C*M] → [KH,KW,C,M] reshape."
+                if per_channel_depthwise:
+                    if weight_axis != 3:
+                        raise tvm.error.OpAttributeInvalid(
+                            "Per-channel DepthwiseConv2D weight 
QuantizedDimension() must be 3 "
+                            f"(the flattened output-channel axis), got 
{weight_axis}"
+                        )
+                    scale_count = 
weight_tensor.tensor.Quantization().ScaleLength()
+                    if scale_count != in_channels:
+                        raise tvm.error.OpAttributeInvalid(
+                            "Per-channel DepthwiseConv2D weight scale count 
must match "
+                            f"input_channels * depth_multiplier 
({in_channels}), got {scale_count}"
+                        )
+                    # Dequantize while the TFLite [1, KH, KW, C*M] channel 
axis is
+                    # still intact.  Reshaping first would split that axis 
into C and M,
+                    # which a single-axis dequantize cannot represent.
+                    w_f32 = relax.op.dequantize(
+                        weight_expr,
+                        scale=weight_tensor.qnn_params["scale"],
+                        zero_point=weight_tensor.qnn_params["zero_point"],
+                        axis=3,
+                    )
+                    w_f32 = relax.op.reshape(w_f32, depthwise_weight_shape)
+                else:
+                    if weight_axis != 0:
+                        raise tvm.error.OpAttributeInvalid(
+                            "Per-tensor DepthwiseConv2D weight 
QuantizedDimension() must be 0, "
+                            f"got {weight_axis}"
+                        )
+                    w_f32 = relax.op.dequantize(
+                        weight_expr,
+                        scale=weight_tensor.qnn_params["scale"],
+                        zero_point=weight_tensor.qnn_params["zero_point"],
+                        axis=0,
                     )
             else:
                 if weight_axis != 0:
@@ -4760,13 +4804,12 @@ class OperatorConverter:
                         f"Conv2D weight QuantizedDimension() must be 0 
(output-channel "
                         f"axis in [OC,KH,KW,IC] layout), got {weight_axis}"
                     )
-                weight_axis = 3
-            w_f32 = relax.op.dequantize(
-                weight_expr,
-                scale=weight_tensor.qnn_params["scale"],
-                zero_point=weight_tensor.qnn_params["zero_point"],
-                axis=weight_axis,
-            )
+                w_f32 = relax.op.dequantize(
+                    weight_expr,
+                    scale=weight_tensor.qnn_params["scale"],
+                    zero_point=weight_tensor.qnn_params["zero_point"],
+                    axis=3,
+                )
             # Float convolution
             out = relax.op.nn.conv2d(in_f32, w_f32, **params)
         else:
@@ -7519,6 +7562,13 @@ class OperatorConverter:
         cls_pred = self.get_expr(inputs[1].tensor_idx)
         loc_prob = self.get_expr(inputs[0].tensor_idx)
         batch_size = inputs[1].tensor.Shape(0)
+        input_num_classes = int(inputs[1].tensor.Shape(2))
+        label_offset = input_num_classes - num_classes
+        if label_offset not in (0, 1):
+            raise ValueError(
+                "DETECTION_POSTPROCESS class predictions must contain 
num_classes "
+                "or num_classes + 1 entries"
+            )
         anchor_values = self.get_tensor_value(inputs[2])
         anchor_boxes = len(anchor_values)
         anchor_type = self.get_tensor_type_str(inputs[2].tensor.Type())
@@ -7533,6 +7583,14 @@ class OperatorConverter:
         if inputs[2].qnn_params:
             anchor_expr = self.dequantize(anchor_expr, inputs[2])
 
+        if label_offset:
+            cls_pred = relax.op.strided_slice(
+                cls_pred,
+                axes=[2],
+                begin=[label_offset],
+                end=[label_offset + num_classes],
+            )
+
         # loc_prob coords are in yxhw format
         # need to convert to xywh
         loc_coords = relax.op.split(loc_prob, 4, axis=2)
@@ -7569,7 +7627,11 @@ class OperatorConverter:
             1 / w_scale,
             1 / h_scale,
         )
-        multibox_transform_loc_attrs["keep_background"] = use_regular_nms
+        # TFLite DetectionPostProcess consumes probabilities, not logits.  Any
+        # optional background class was sliced above, so class 0 is now a real
+        # foreground class and must be kept for both NMS implementations.
+        multibox_transform_loc_attrs["keep_background"] = True
+        multibox_transform_loc_attrs["apply_softmax"] = False
 
         multibox_res = self.bb.emit(
             relax.op.vision.multibox_transform_loc(
diff --git a/python/tvm/relax/op/vision/multibox_transform_loc.py 
b/python/tvm/relax/op/vision/multibox_transform_loc.py
index e4e41a9873..6f5118407a 100644
--- a/python/tvm/relax/op/vision/multibox_transform_loc.py
+++ b/python/tvm/relax/op/vision/multibox_transform_loc.py
@@ -27,8 +27,9 @@ def multibox_transform_loc(
     threshold=0.0,
     variances=(1.0, 1.0, 1.0, 1.0),
     keep_background=True,
+    apply_softmax=True,
 ):
-    """SSD / TFLite-style decode: priors + offsets → boxes; logits → softmax 
scores.
+    """SSD / TFLite-style decode: priors + offsets → boxes; prepare class 
scores.
 
     Box decode follows TFLite ``DecodeCenterSizeBoxes``; expected tensor 
layout matches
     ``tflite_frontend.convert_detection_postprocess`` (loc reorder yxhw→xywh, 
anchor ltrb).
@@ -36,7 +37,7 @@ def multibox_transform_loc(
     Parameters
     ----------
     cls_pred : relax.Expr
-        ``[B, C, N]`` class logits (pre-softmax).
+        ``[B, C, N]`` class logits or scores.
     loc_pred : relax.Expr
         ``[B, 4*N]`` per-anchor encodings as ``(x,y,w,h)`` after reorder (see 
above).
     anchor : relax.Expr
@@ -51,6 +52,8 @@ def multibox_transform_loc(
         encoded height/width terms inside ``exp(...)`` and can overflow in 
float32/float16.
     keep_background : bool
         If False, set output scores at class index 0 to zero.
+    apply_softmax : bool
+        If True, apply softmax over the class axis before thresholding.
 
     Returns
     -------
@@ -82,4 +85,5 @@ def multibox_transform_loc(
         threshold,
         variances,
         keep_background,
+        apply_softmax,
     )
diff --git a/python/tvm/relax/transform/legalize_ops/vision.py 
b/python/tvm/relax/transform/legalize_ops/vision.py
index 618a30641c..a50ba133f2 100644
--- a/python/tvm/relax/transform/legalize_ops/vision.py
+++ b/python/tvm/relax/transform/legalize_ops/vision.py
@@ -183,6 +183,7 @@ def _multibox_transform_loc(bb: BlockBuilder, call: Call) 
-> Expr:
             clip=call.attrs.clip,
             threshold=call.attrs.threshold,
             keep_background=call.attrs.keep_background,
+            apply_softmax=call.attrs.apply_softmax,
         )
 
     return bb.call_te(
diff --git a/python/tvm/topi/vision/multibox_transform_loc.py 
b/python/tvm/topi/vision/multibox_transform_loc.py
index e6816d8eec..0dc458ebf4 100644
--- a/python/tvm/topi/vision/multibox_transform_loc.py
+++ b/python/tvm/topi/vision/multibox_transform_loc.py
@@ -29,8 +29,9 @@ def multibox_transform_loc(
     clip=False,
     threshold=0.0,
     keep_background=True,
+    apply_softmax=True,
 ):
-    """TFLite ``DecodeCenterSizeBoxes``-style decode + softmax score 
post-process.
+    """TFLite ``DecodeCenterSizeBoxes``-style decode + score post-process.
 
     Inputs must match Relax op contracts: ``cls_pred [B,C,N]``, ``loc_pred 
[B,4*N]``,
     ``anchor [1,N,4]`` ltrb; per-anchor loc order ``(x,y,w,h)`` after 
yxhw→xywh reorder.
@@ -38,7 +39,7 @@ def multibox_transform_loc(
     Parameters
     ----------
     cls_pred : te.Tensor
-        ``[B, C, N]`` logits.
+        ``[B, C, N]`` logits or scores.
     loc_pred : te.Tensor
         ``[B, 4*N]`` encodings ``(x,y,w,h)`` per anchor.
     anchor : te.Tensor
@@ -48,16 +49,18 @@ def multibox_transform_loc(
     clip : bool
         Clip ``ymin,xmin,ymax,xmax`` to ``[0,1]``.
     threshold : float
-        After softmax: ``scores *= (scores >= threshold)``.
+        ``scores *= (scores >= threshold)`` after optional softmax.
     keep_background : bool
         If False: ``scores[:,0,:] = 0``.
+    apply_softmax : bool
+        Apply softmax across classes before thresholding.
 
     Returns
     -------
     boxes : te.Tensor
         ``[B, N, 4]`` as ``(ymin,xmin,ymax,xmax)``.
     scores : te.Tensor
-        ``[B, C, N]`` softmax, then threshold mask and optional background 
zero.
+        ``[B, C, N]`` scores after optional softmax, threshold, and background 
masking.
     """
     dtype = cls_pred.dtype
     B = cls_pred.shape[0]
@@ -107,7 +110,7 @@ def multibox_transform_loc(
 
     boxes = te.compute((B, num_anchors, 4), decode_bbox, name="multibox_boxes")
 
-    scores = topi.nn.softmax(cls_pred, axis=1)
+    scores = topi.nn.softmax(cls_pred, axis=1) if apply_softmax else cls_pred
     mask = topi.cast(topi.greater_equal(scores, th), dtype)
     scores = scores * mask
     if not keep_background:
diff --git a/src/relax/op/vision/multibox_transform_loc.cc 
b/src/relax/op/vision/multibox_transform_loc.cc
index 09ac72ea1f..658e382a1e 100644
--- a/src/relax/op/vision/multibox_transform_loc.cc
+++ b/src/relax/op/vision/multibox_transform_loc.cc
@@ -36,7 +36,8 @@ namespace relax {
 TVM_FFI_STATIC_INIT_BLOCK() { MultiboxTransformLocAttrs::RegisterReflection(); 
}
 
 Expr multibox_transform_loc(Expr cls_pred, Expr loc_pred, Expr anchor, bool 
clip, double threshold,
-                            ffi::Array<double> variances, bool 
keep_background) {
+                            ffi::Array<double> variances, bool keep_background,
+                            bool apply_softmax) {
   TVM_FFI_ICHECK_EQ(variances.size(), 4)
       << "multibox_transform_loc: variances must be length 4 (x,y,w,h), got " 
<< variances.size();
 
@@ -45,6 +46,7 @@ Expr multibox_transform_loc(Expr cls_pred, Expr loc_pred, 
Expr anchor, bool clip
   attrs->threshold = threshold;
   attrs->variances = std::move(variances);
   attrs->keep_background = keep_background;
+  attrs->apply_softmax = apply_softmax;
 
   static const Op& op = Op::Get("relax.vision.multibox_transform_loc");
   return Call(Type::Missing(), op, {std::move(cls_pred), std::move(loc_pred), 
std::move(anchor)},
@@ -186,12 +188,12 @@ Type InferTypeMultiboxTransformLoc(const Call& call, 
const BlockBuilder& ctx) {
 
 TVM_REGISTER_OP("relax.vision.multibox_transform_loc")
     .describe(
-        "Decode SSD/TFLite-style priors and offsets into boxes and softmax 
scores. If "
+        "Decode SSD/TFLite-style priors and offsets into boxes and class 
scores. If "
         "cls_pred shape is unknown, N-based loc/anchor shape checks are 
skipped in "
         "inference. Very large variances (w,h) can overflow exp in half box 
sizes.")
     .set_attrs_type<MultiboxTransformLocAttrs>()
     .set_num_inputs(3)
-    .add_argument("cls_pred", "Tensor", "[B,C,N] class logits (pre-softmax).")
+    .add_argument("cls_pred", "Tensor", "[B,C,N] class logits or scores.")
     .add_argument("loc_pred", "Tensor",
                   "[B,4*N] box encodings (x,y,w,h); TFLite yxhw order remapped 
to xywh.")
     .add_argument("anchor", "Tensor", "[1,N,4] priors as ltrb 
(left,top,right,bottom).")
diff --git a/src/relax/op/vision/multibox_transform_loc.h 
b/src/relax/op/vision/multibox_transform_loc.h
index 726bc4c0e5..7c5a4a77a0 100644
--- a/src/relax/op/vision/multibox_transform_loc.h
+++ b/src/relax/op/vision/multibox_transform_loc.h
@@ -34,7 +34,7 @@ namespace relax {
 
 /*! \brief Decode SSD box encodings and prepare class scores 
(TFLite-compatible). */
 Expr multibox_transform_loc(Expr cls_pred, Expr loc_pred, Expr anchor, bool 
clip, double threshold,
-                            ffi::Array<double> variances, bool 
keep_background);
+                            ffi::Array<double> variances, bool 
keep_background, bool apply_softmax);
 
 }  // namespace relax
 }  // namespace tvm
diff --git a/tests/python/relax/test_frontend_tflite.py 
b/tests/python/relax/test_frontend_tflite.py
index e7ebbeaf92..54d1c09fe8 100644
--- a/tests/python/relax/test_frontend_tflite.py
+++ b/tests/python/relax/test_frontend_tflite.py
@@ -3577,7 +3577,7 @@ _DETECTION_POSTPROCESS_SMOKE_CASES = [
             "num_anchors": 4,
         },
         2,
-        False,
+        True,
         id="basic_fast_nms",
     ),
     pytest.param(
@@ -3603,7 +3603,7 @@ _DETECTION_POSTPROCESS_SHAPE_CASES = [
     pytest.param(
         {
             "num_classes": 2,
-            "input_num_classes": 5,
+            "input_num_classes": 2,
             "max_detections": 2,
             "detections_per_class": 2,
             "use_regular_nms": False,
@@ -3612,7 +3612,7 @@ _DETECTION_POSTPROCESS_SHAPE_CASES = [
             "batch_size": 1,
             "num_anchors": 4,
         },
-        id="wider_input_classes",
+        id="matching_input_classes",
     ),
     pytest.param(
         {
@@ -3638,6 +3638,23 @@ _DETECTION_POSTPROCESS_SHAPE_CASES = [
 def test_detection_postprocess_smoke(build_kwargs, expected_topk_count, 
expected_keep_background):
     mod = _build_detection_postprocess_mod(**build_kwargs)
 
+    topk_calls = []
+    multibox_calls = []
+
+    def _visit(expr):
+        if isinstance(expr, relax.Call) and expr.op == 
tvm.ir.Op.get("relax.topk"):
+            topk_calls.append(expr)
+        if isinstance(expr, relax.Call) and expr.op == tvm.ir.Op.get(
+            "relax.vision.multibox_transform_loc"
+        ):
+            multibox_calls.append(expr)
+
+    relax.analysis.post_order_visit(mod["main"].body, _visit)
+    assert len(topk_calls) == expected_topk_count
+    assert len(multibox_calls) == 1
+    assert multibox_calls[0].attrs.keep_background == expected_keep_background
+    assert not multibox_calls[0].attrs.apply_softmax
+
     expected_batch = build_kwargs["batch_size"]
     expected_max_detections = build_kwargs["max_detections"]
     tvm.ir.assert_structural_equal(
@@ -3681,6 +3698,38 @@ def 
test_detection_postprocess_shape_variations(build_kwargs):
     )
 
 
+def test_detection_postprocess_removes_background_without_softmax():
+    """TFLite scores are probabilities; remove its optional background class 
exactly once."""
+    mod = _build_detection_postprocess_mod(
+        num_classes=2,
+        input_num_classes=3,
+        max_detections=2,
+        detections_per_class=2,
+        batch_size=1,
+    )
+    multibox_calls = []
+
+    def _visit(expr):
+        if isinstance(expr, relax.Call) and expr.op == tvm.ir.Op.get(
+            "relax.vision.multibox_transform_loc"
+        ):
+            multibox_calls.append(expr)
+
+    relax.analysis.post_order_visit(mod["main"].body, _visit)
+    assert len(multibox_calls) == 1
+    assert not multibox_calls[0].attrs.apply_softmax
+    assert multibox_calls[0].attrs.keep_background
+    tvm.ir.assert_structural_equal(
+        multibox_calls[0].args[0].ty,
+        relax.TensorType((1, 2, 4), "float32"),
+    )
+
+
+def test_detection_postprocess_rejects_invalid_class_count():
+    with pytest.raises(ValueError, match=r"num_classes \+ 1"):
+        _build_detection_postprocess_mod(num_classes=2, input_num_classes=5)
+
+
 def _make_resize_expected(
     input_shape, output_size, method, coordinate_transformation_mode, 
rounding_method
 ):
@@ -11352,6 +11401,10 @@ def test_quantized_avg_pool2d_uses_astype():
     else:
         tflite_model = tflite.Model.GetRootAsModel(buf, 0)
 
+    # Exercise the public entry point so quantized pool allowlist regressions
+    # cannot be hidden by calling the converter method directly.
+    from_tflite(tflite_model)
+
     subgraph = tflite_model.Subgraphs(0)
     bb = relax.BlockBuilder()
     exp_tab = tflite_frontend.ExprTable()
@@ -12315,8 +12368,8 @@ def 
test_quantized_conv2d_per_channel_weight_with_int32_bias_dequantizes_bias():
     tvm.ir.assert_structural_equal(mod, Expected)
 
 
-def test_per_channel_depthwise_conv_unsupported():
-    """Per-channel quantized depthwise Conv2D raises OpNotImplemented."""
+def test_per_channel_depthwise_conv_dequantizes_before_reshape():
+    """Per-channel depthwise weights keep C*M intact and lower to HWOI."""
     import flatbuffers
     import tflite.Model
 
@@ -12325,9 +12378,12 @@ def test_per_channel_depthwise_conv_unsupported():
     in_q = _build_quantization_parameters(
         builder, scale=[0.5], zero_point=[0], quantized_dimension=0
     )
-    # Per-channel weight: 2 channels, scale vector length 2
+    # Two input channels with depth_multiplier=2 produce four output channels.
     wt_q = _build_quantization_parameters(
-        builder, scale=[0.25, 0.75], zero_point=[0, 0], quantized_dimension=3
+        builder,
+        scale=[0.25, 0.5, 0.75, 1.0],
+        zero_point=[0, 0, 0, 0],
+        quantized_dimension=3,
     )
     out_q = _build_quantization_parameters(
         builder, scale=[1.0], zero_point=[0], quantized_dimension=0
@@ -12337,16 +12393,16 @@ def test_per_channel_depthwise_conv_unsupported():
         builder, 0, [1, 4, 4, 2], tensor_type=_tfl_tensor_type.INT8, 
quantization=in_q
     )
     t_wt = _build_tensor(
-        builder, 1, [1, 3, 3, 2], tensor_type=_tfl_tensor_type.INT8, 
quantization=wt_q
+        builder, 1, [1, 3, 3, 4], tensor_type=_tfl_tensor_type.INT8, 
quantization=wt_q
     )
     t_ou = _build_tensor(
-        builder, 2, [1, 2, 2, 2], tensor_type=_tfl_tensor_type.INT8, 
quantization=out_q
+        builder, 2, [1, 2, 2, 4], tensor_type=_tfl_tensor_type.INT8, 
quantization=out_q
     )
 
     _tfl_depthwise_conv2d_options.DepthwiseConv2DOptionsStart(builder)
     _tfl_depthwise_conv2d_options.DepthwiseConv2DOptionsAddStrideH(builder, 1)
     _tfl_depthwise_conv2d_options.DepthwiseConv2DOptionsAddStrideW(builder, 1)
-    
_tfl_depthwise_conv2d_options.DepthwiseConv2DOptionsAddDepthMultiplier(builder, 
1)
+    
_tfl_depthwise_conv2d_options.DepthwiseConv2DOptionsAddDepthMultiplier(builder, 
2)
     _tfl_depthwise_conv2d_options.DepthwiseConv2DOptionsAddPadding(builder, 1)
     
_tfl_depthwise_conv2d_options.DepthwiseConv2DOptionsAddFusedActivationFunction(builder,
 0)
     dw_opts = _tfl_depthwise_conv2d_options.DepthwiseConv2DOptionsEnd(builder)
@@ -12379,8 +12435,25 @@ def test_per_channel_depthwise_conv_unsupported():
     else:
         tflite_model = tflite.Model.GetRootAsModel(buf, 0)
 
-    with pytest.raises(tvm.error.OpNotImplemented, match="Per-channel"):
-        from_tflite(tflite_model)
+    mod = from_tflite(tflite_model)
+    dequantize_calls = []
+    reshape_calls = []
+
+    def _visit(expr):
+        if isinstance(expr, relax.Call) and expr.op == 
tvm.ir.Op.get("relax.dequantize"):
+            dequantize_calls.append(expr)
+        if isinstance(expr, relax.Call) and expr.op == 
tvm.ir.Op.get("relax.reshape"):
+            reshape_calls.append(expr)
+
+    relax.analysis.post_order_visit(mod["main"].body, _visit)
+    assert any(call.attrs.axis == 3 for call in dequantize_calls)
+    depthwise_reshapes = [
+        call
+        for call in reshape_calls
+        if call.ty.dtype == "float32"
+        and tuple(dim.value for dim in call.ty.shape.values) == (3, 3, 4, 1)
+    ]
+    assert len(depthwise_reshapes) == 1
 
 
 def test_uint8_reshape_requantize_uses_dq_reshape_q():
diff --git a/tests/python/relax/test_op_vision.py 
b/tests/python/relax/test_op_vision.py
index 9010f74606..abdbb6a42e 100644
--- a/tests/python/relax/test_op_vision.py
+++ b/tests/python/relax/test_op_vision.py
@@ -1490,7 +1490,14 @@ def test_multibox_transform_loc_wrong_batch():
 
 
 def _multibox_ref_numpy(
-    cls_pred, loc_pred, anchor, variances, clip=False, threshold=0.0, 
keep_background=True
+    cls_pred,
+    loc_pred,
+    anchor,
+    variances,
+    clip=False,
+    threshold=0.0,
+    keep_background=True,
+    apply_softmax=True,
 ):
     """Numpy reference aligned with ``topi.vision.multibox_transform_loc``."""
 
@@ -1501,7 +1508,11 @@ def _multibox_ref_numpy(
 
     B, C, N = cls_pred.shape
     loc = loc_pred.reshape(B, N, 4)
-    scores = _softmax(cls_pred.astype("float64"), axis=1).astype(np.float32)
+    scores = (
+        _softmax(cls_pred.astype("float64"), axis=1).astype(np.float32)
+        if apply_softmax
+        else cls_pred.copy()
+    )
     if threshold > 0.0:
         scores = np.where(scores >= threshold, scores, 0.0).astype(np.float32)
     if not keep_background:
@@ -1648,6 +1659,7 @@ def test_multibox_transform_loc_legalize_attr_branches():
                 threshold=0.4,
                 variances=(1.0, 1.0, 1.0, 1.0),
                 keep_background=False,
+                apply_softmax=False,
             )
 
     cls_data = np.array(
@@ -1674,6 +1686,7 @@ def test_multibox_transform_loc_legalize_attr_branches():
         clip=True,
         threshold=0.4,
         keep_background=False,
+        apply_softmax=False,
     )
     out = vm["main"](
         tvm.runtime.tensor(cls_data, tvm.cpu()),
diff --git a/tests/python/relax/test_tvmscript_parser_op_vision.py 
b/tests/python/relax/test_tvmscript_parser_op_vision.py
index ac5fa78b39..8ab952adf5 100644
--- a/tests/python/relax/test_tvmscript_parser_op_vision.py
+++ b/tests/python/relax/test_tvmscript_parser_op_vision.py
@@ -291,6 +291,7 @@ def test_multibox_transform_loc():
                 threshold=0.0,
                 variances=(1.0, 1.0, 1.0, 1.0),
                 keep_background=True,
+                apply_softmax=True,
             )
         )
         return gv
@@ -310,6 +311,7 @@ def test_multibox_transform_loc():
                 threshold=0.0,
                 variances=(1.0, 1.0, 1.0, 1.0),
                 keep_background=True,
+                apply_softmax=True,
             )
         )
         bb.emit_func_output(gv)

Reply via email to