Repository: Project-MONAI/MONAI

Issue:
Flexible UNet implementation with backbones
**Is your feature request related to a problem? Please describe.**
Followup discussion of #896 .
Currently, we don't have a flexible segmentation pipeline that can support different backbones and pretrained weights.
I think it would be very useful to provide such networks, at least for the UNet implementation.
Actually, we already have some similar prototype in the tutorial:
https://github.com/Project-MONAI/tutorials/blob/main/kaggle/RANZCR/4th_place_solution/models/seg_model.py
I think we can start from integrating this implementation.
@wyli @yiheng-wang-nv What do you think?




Discussion:
To be clear about the requirements, there are multiresolution feature encoders implemented such as https://github.com/Project-MONAI/MONAI/blob/78509ae55ef98660907544e92713e44897bb7e48/monai/networks/nets/efficientnet.py#L559

This ticket looks for 
- a unet-style decoder, for multiresolution upsampling/deconv and skip connections
- segmentation head as the final output block of decoder
- A wrapper for encoder/decoder/head, this could be a generic segmentation wrapper
Hi @Nic-Ma and @wyli , sure. @binliunls and I will work on this task together.

Resolving patch:
diff --git a/monai/networks/nets/__init__.py b/monai/networks/nets/__init__.py
--- a/monai/networks/nets/__init__.py
+++ b/monai/networks/nets/__init__.py
@@ -40,6 +40,7 @@
     drop_connect,
     get_efficientnet_image_size,
 )
+from .flexible_unet import FlexibleUNet
 from .fullyconnectednet import FullyConnectedNet, VarFullyConnectedNet
 from .generator import Generator
 from .highresnet import HighResBlock, HighResNet
diff --git a/monai/networks/nets/basic_unet.py b/monai/networks/nets/basic_unet.py
--- a/monai/networks/nets/basic_unet.py
+++ b/monai/networks/nets/basic_unet.py
@@ -118,6 +118,7 @@ def __init__(
         align_corners: Optional[bool] = True,
         halves: bool = True,
         dim: Optional[int] = None,
+        is_pad: bool = True,
     ):
         """
         Args:
@@ -139,6 +140,7 @@ def __init__(
                 Only used in the "nontrainable" mode.
             halves: whether to halve the number of channels during upsampling.
                 This parameter does not work on ``nontrainable`` mode if ``pre_conv`` is `None`.
+            is_pad: whether to pad upsampling features to fit features from encoder. Defaults to True.
 
         .. deprecated:: 0.6.0
             ``dim`` is deprecated, use ``spatial_dims`` instead.
@@ -161,6 +163,7 @@ def __init__(
             align_corners=align_corners,
         )
         self.convs = TwoConv(spatial_dims, cat_chns + up_chns, out_chns, act, norm, bias, dropout)
+        self.is_pad = is_pad
 
     def forward(self, x: torch.Tensor, x_e: Optional[torch.Tensor]):
         """
@@ -172,13 +175,14 @@ def forward(self, x: torch.Tensor, x_e: Optional[torch.Tensor]):
         x_0 = self.upsample(x)
 
         if x_e is not None:
-            # handling spatial shapes due to the 2x maxpooling with odd edge lengths.
-            dimensions = len(x.shape) - 2
-            sp = [0] * (dimensions * 2)
-            for i in range(dimensions):
-                if x_e.shape[-i - 1] != x_0.shape[-i - 1]:
-                    sp[i * 2 + 1] = 1
-            x_0 = torch.nn.functional.pad(x_0, sp, "replicate")
+            if self.is_pad:
+                # handling spatial shapes due to the 2x maxpooling with odd edge lengths.
+                dimensions = len(x.shape) - 2
+                sp = [0] * (dimensions * 2)
+                for i in range(dimensions):
+                    if x_e.shape[-i - 1] != x_0.shape[-i - 1]:
+                        sp[i * 2 + 1] = 1
+                x_0 = torch.nn.functional.pad(x_0, sp, "replicate")
             x = self.convs(torch.cat([x_e, x_0], dim=1))  # input channels: (cat_chns + up_chns)
         else:
             x = self.convs(x_0)
diff --git a/monai/networks/nets/flexible_unet.py b/monai/networks/nets/flexible_unet.py
new file mode 100644
--- /dev/null
+++ b/monai/networks/nets/flexible_unet.py
@@ -0,0 +1,307 @@
+# Copyright (c) MONAI Consortium
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#     http://www.apache.org/licenses/LICENSE-2.0
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from typing import List, Optional, Sequence, Tuple, Union
+
+import torch
+from torch import nn
+
+from monai.networks.blocks import UpSample
+from monai.networks.layers.factories import Conv
+from monai.networks.layers.utils import get_act_layer
+from monai.networks.nets import EfficientNetBNFeatures
+from monai.networks.nets.basic_unet import UpCat
+from monai.utils import InterpolateMode
+
+__all__ = ["FlexibleUNet"]
+
+encoder_feature_channel = {
+    "efficientnet-b0": (16, 24, 40, 112, 320),
+    "efficientnet-b1": (16, 24, 40, 112, 320),
+    "efficientnet-b2": (16, 24, 48, 120, 352),
+    "efficientnet-b3": (24, 32, 48, 136, 384),
+    "efficientnet-b4": (24, 32, 56, 160, 448),
+    "efficientnet-b5": (24, 40, 64, 176, 512),
+    "efficientnet-b6": (32, 40, 72, 200, 576),
+    "efficientnet-b7": (32, 48, 80, 224, 640),
+    "efficientnet-b8": (32, 56, 88, 248, 704),
+    "efficientnet-l2": (72, 104, 176, 480, 1376),
+}
+
+
+def _get_encoder_channels_by_backbone(backbone: str, in_channels: int = 3) -> tuple:
+    """
+    Get the encoder output channels by given backbone name.
+
+    Args:
+        backbone: name of backbone to generate features, can be from [efficientnet-b0, ..., efficientnet-b7].
+        in_channels: channel of input tensor, default to 3.
+
+    Returns:
+        A tuple of output feature map channels' length .
+    """
+    encoder_channel_tuple = encoder_feature_channel[backbone]
+    encoder_channel_list = [in_channels] + list(encoder_channel_tuple)
+    encoder_channel = tuple(encoder_channel_list)
+    return encoder_channel
+
+
+class UNetDecoder(nn.Module):
+    """
+    UNet Decoder.
+    This class refers to `segmentation_models.pytorch
+    <https://github.com/qubvel/segmentation_models.pytorch>`_.
+
+    Args:
+        spatial_dims: number of spatial dimensions.
+        encoder_channels: number of output channels for all feature maps in encoder.
+            `len(encoder_channels)` should be no less than 2.
+        decoder_channels: number of output channels for all feature maps in decoder.
+            `len(decoder_channels)` should equal to `len(encoder_channels) - 1`.
+        act: activation type and arguments.
+        norm: feature normalization type and arguments.
+        dropout: dropout ratio.
+        bias: whether to have a bias term in convolution blocks in this decoder.
+        upsample: upsampling mode, available options are
+            ``"deconv"``, ``"pixelshuffle"``, ``"nontrainable"``.
+        pre_conv: a conv block applied before upsampling.
+            Only used in the "nontrainable" or "pixelshuffle" mode.
+        interp_mode: {``"nearest"``, ``"linear"``, ``"bilinear"``, ``"bicubic"``, ``"trilinear"``}
+            Only used in the "nontrainable" mode.
+        align_corners: set the align_corners parameter for upsample. Defaults to True.
+            Only used in the "nontrainable" mode.
+        is_pad: whether to pad upsampling features to fit the encoder spatial dims.
+
+    """
+
+    def __init__(
+        self,
+        spatial_dims: int,
+        encoder_channels: Sequence[int],
+        decoder_channels: Sequence[int],
+        act: Union[str, tuple],
+        norm: Union[str, tuple],
+        dropout: Union[float, tuple],
+        bias: bool,
+        upsample: str,
+        pre_conv: Optional[str],
+        interp_mode: str,
+        align_corners: Optional[bool],
+        is_pad: bool,
+    ):
+
+        super().__init__()
+        if len(encoder_channels) < 2:
+            raise ValueError("the length of `encoder_channels` should be no less than 2.")
+        if len(decoder_channels) != len(encoder_channels) - 1:
+            raise ValueError("`len(decoder_channels)` should equal to `len(encoder_channels) - 1`.")
+
+        in_channels = [encoder_channels[-1]] + list(decoder_channels[:-1])
+        skip_channels = list(encoder_channels[1:-1][::-1]) + [0]
+        halves = [True] * (len(skip_channels) - 1)
+        halves.append(False)
+        blocks = []
+        for in_chn, skip_chn, out_chn, halve in zip(in_channels, skip_channels, decoder_channels, halves):
+            blocks.append(
+                UpCat(
+                    spatial_dims=spatial_dims,
+                    in_chns=in_chn,
+                    cat_chns=skip_chn,
+                    out_chns=out_chn,
+                    act=act,
+                    norm=norm,
+                    dropout=dropout,
+                    bias=bias,
+                    upsample=upsample,
+                    pre_conv=pre_conv,
+                    interp_mode=interp_mode,
+                    align_corners=align_corners,
+                    halves=halve,
+                    is_pad=is_pad,
+                )
+            )
+        self.blocks = nn.ModuleList(blocks)
+
+    def forward(self, features: List[torch.Tensor], skip_connect: int = 4):
+        skips = features[:-1][::-1]
+        features = features[1:][::-1]
+
+        x = features[0]
+        for i, block in enumerate(self.blocks):
+            if i < skip_connect:
+                skip = skips[i]
+            else:
+                skip = None
+            x = block(x, skip)
+
+        return x
+
+
+class SegmentationHead(nn.Sequential):
+    """
+    Segmentation head.
+    This class refers to `segmentation_models.pytorch
+    <https://github.com/qubvel/segmentation_models.pytorch>`_.
+
+    Args:
+        spatial_dims: number of spatial dimensions.
+        in_channels: number of input channels for the block.
+        out_channels: number of output channels for the block.
+        kernel_size: kernel size for the conv layer.
+        act: activation type and arguments.
+        scale_factor: multiplier for spatial size. Has to match input size if it is a tuple.
+
+    """
+
+    def __init__(
+        self,
+        spatial_dims: int,
+        in_channels: int,
+        out_channels: int,
+        kernel_size: int = 3,
+        act: Optional[Union[Tuple, str]] = None,
+        scale_factor: float = 1.0,
+    ):
+
+        conv_layer = Conv[Conv.CONV, spatial_dims](
+            in_channels=in_channels, out_channels=out_channels, kernel_size=kernel_size, padding=kernel_size // 2
+        )
+        up_layer: nn.Module = nn.Identity()
+        if scale_factor > 1.0:
+            up_layer = UpSample(
+                spatial_dims=spatial_dims,
+                scale_factor=scale_factor,
+                mode="nontrainable",
+                pre_conv=None,
+                interp_mode=InterpolateMode.LINEAR,
+            )
+        if act is not None:
+            act_layer = get_act_layer(act)
+        else:
+            act_layer = nn.Identity()
+        super().__init__(conv_layer, up_layer, act_layer)
+
+
+class FlexibleUNet(nn.Module):
+    """
+    A flexible implementation of UNet-like encoder-decoder architecture.
+    """
+
+    def __init__(
+        self,
+        in_channels: int,
+        out_channels: int,
+        backbone: str,
+        pretrained: bool = False,
+        decoder_channels: Tuple = (256, 128, 64, 32, 16),
+        spatial_dims: int = 2,
+        norm: Union[str, tuple] = ("batch", {"eps": 1e-3, "momentum": 0.1}),
+        act: Union[str, tuple] = ("relu", {"inplace": True}),
+        dropout: Union[float, tuple] = 0.0,
+        decoder_bias: bool = False,
+        upsample: str = "nontrainable",
+        interp_mode: str = "nearest",
+        is_pad: bool = True,
+    ) -> None:
+        """
+        A flexible implement of UNet, in which the backbone/encoder can be replaced with
+        any efficient network. Currently the input must have a 2 or 3 spatial dimension
+        and the spatial size of each dimension must be a multiple of 32 if is pad parameter
+        is False
+
+        TODO(binliu@nvidia.com): Add more backbones/encoders to this class and make a general
+        encoder-decoder structure. ETC:2022.09.01
+
+        Args:
+            in_channels: number of input channels.
+            out_channels: number of output channels.
+            backbone: name of backbones to initialize, only support efficientnet right now,
+                can be from [efficientnet-b0,..., efficientnet-b8, efficientnet-l2].
+            pretrained: whether to initialize pretrained ImageNet weights, only available
+                for spatial_dims=2 and batch norm is used, default to False.
+            decoder_channels: number of output channels for all feature maps in decoder.
+                `len(decoder_channels)` should equal to `len(encoder_channels) - 1`,default
+                to (256, 128, 64, 32, 16).
+            spatial_dims: number of spatial dimensions, default to 2.
+            norm: normalization type and arguments, default to ("batch", {"eps": 1e-3,
+                "momentum": 0.1}).
+            act: activation type and arguments, default to ("relu", {"inplace": True}).
+            dropout: dropout ratio, default to 0.0.
+            decoder_bias: whether to have a bias term in decoder's convolution blocks.
+            upsample: upsampling mode, available options are``"deconv"``, ``"pixelshuffle"``,
+                ``"nontrainable"``.
+            interp_mode: {``"nearest"``, ``"linear"``, ``"bilinear"``, ``"bicubic"``, ``"trilinear"``}
+                Only used in the "nontrainable" mode.
+            is_pad: whether to use padding feature maps to enable the input spatial not necessary
+                to be a multiple of 32. Default to True.
+        """
+        super().__init__()
+
+        if backbone not in encoder_feature_channel:
+            raise ValueError(f"invalid model_name {backbone} found, must be one of {encoder_feature_channel.keys()}.")
+
+        if spatial_dims not in (2, 3):
+            raise ValueError("spatial_dims can only be 2 or 3.")
+
+        adv_prop = "ap" in backbone
+
+        self.backbone = backbone
+        self.spatial_dims = spatial_dims
+        model_name = backbone
+        encoder_channels = _get_encoder_channels_by_backbone(backbone, in_channels)
+        self.encoder = EfficientNetBNFeatures(
+            model_name=model_name,
+            pretrained=pretrained,
+            in_channels=in_channels,
+            spatial_dims=spatial_dims,
+            norm=norm,
+            adv_prop=adv_prop,
+        )
+        self.decoder = UNetDecoder(
+            spatial_dims=spatial_dims,
+            encoder_channels=encoder_channels,
+            decoder_channels=decoder_channels,
+            act=act,
+            norm=norm,
+            dropout=dropout,
+            bias=decoder_bias,
+            upsample=upsample,
+            interp_mode=interp_mode,
+            pre_conv=None,
+            align_corners=None,
+            is_pad=is_pad,
+        )
+        self.segmentation_head = SegmentationHead(
+            spatial_dims=spatial_dims,
+            in_channels=decoder_channels[-1],
+            out_channels=out_channels,
+            kernel_size=3,
+            act=None,
+        )
+
+    def forward(self, inputs: torch.Tensor):
+        """
+        Do a typical encoder-decoder-header inference.
+
+        Args:
+            inputs: input should have spatially N dimensions ``(Batch, in_channels, dim_0[, dim_1, ..., dim_N])``,
+                N is defined by `dimensions`.
+
+        Returns:
+            A torch Tensor of "raw" predictions in shape ``(Batch, out_channels, dim_0[, dim_1, ..., dim_N])``.
+
+        """
+        x = inputs
+        enc_out = self.encoder(x)
+        decoder_out = self.decoder(enc_out)
+        x_seg = self.segmentation_head(decoder_out)
+
+        return x_seg


Test patch:
diff --git a/tests/test_flexible_unet.py b/tests/test_flexible_unet.py
new file mode 100644
--- /dev/null
+++ b/tests/test_flexible_unet.py
@@ -0,0 +1,242 @@
+# Copyright (c) MONAI Consortium
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#     http://www.apache.org/licenses/LICENSE-2.0
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import unittest
+
+import torch
+from parameterized import parameterized
+
+from monai.networks import eval_mode
+from monai.networks.nets import EfficientNetBNFeatures, FlexibleUNet
+from monai.utils import optional_import
+from tests.utils import skip_if_downloading_fails, skip_if_quick
+
+torchvision, has_torchvision = optional_import("torchvision")
+PIL, has_pil = optional_import("PIL")
+
+
+def get_model_names():
+    return [f"efficientnet-b{d}" for d in range(8)]
+
+
+def make_shape_cases(
+    models,
+    spatial_dims,
+    batches,
+    pretrained,
+    in_channels=3,
+    num_classes=10,
+    input_shape=64,
+    norm=("batch", {"eps": 1e-3, "momentum": 0.01}),
+):
+    ret_tests = []
+    for spatial_dim in spatial_dims:  # selected spatial_dims
+        for batch in batches:  # check single batch as well as multiple batch input
+            for model in models:  # selected models
+                for is_pretrained in pretrained:  # pretrained or not pretrained
+                    kwargs = {
+                        "in_channels": in_channels,
+                        "out_channels": num_classes,
+                        "backbone": model,
+                        "pretrained": is_pretrained,
+                        "spatial_dims": spatial_dim,
+                        "norm": norm,
+                    }
+                    ret_tests.append(
+                        [
+                            kwargs,
+                            (batch, in_channels) + (input_shape,) * spatial_dim,
+                            (batch, num_classes) + (input_shape,) * spatial_dim,
+                        ]
+                    )
+    return ret_tests
+
+
+# create list of selected models to speed up redundant tests
+# only test the models B0, B3
+SEL_MODELS = [get_model_names()[i] for i in [0, 3]]
+
+# pretrained=False cases
+# 2D and 3D models are expensive so use selected models
+CASES_2D = make_shape_cases(
+    models=SEL_MODELS,
+    spatial_dims=[2],
+    batches=[1, 4],
+    pretrained=[False],
+    in_channels=3,
+    num_classes=10,
+    norm="instance",
+)
+CASES_3D = make_shape_cases(
+    models=[SEL_MODELS[0]],
+    spatial_dims=[3],
+    batches=[1],
+    pretrained=[False],
+    in_channels=3,
+    num_classes=10,
+    norm="batch",
+)
+
+# varying num_classes and in_channels
+CASES_VARIATIONS = []
+
+# change num_classes test
+# 20 classes
+# 2D
+CASES_VARIATIONS.extend(
+    make_shape_cases(
+        models=SEL_MODELS, spatial_dims=[2], batches=[1], pretrained=[False, True], in_channels=3, num_classes=20
+    )
+)
+# 3D
+CASES_VARIATIONS.extend(
+    make_shape_cases(
+        models=[SEL_MODELS[0]], spatial_dims=[3], batches=[1], pretrained=[False], in_channels=3, num_classes=20
+    )
+)
+
+# change in_channels test
+# 1 channel
+# 2D
+CASES_VARIATIONS.extend(
+    make_shape_cases(
+        models=SEL_MODELS, spatial_dims=[2], batches=[1], pretrained=[False, True], in_channels=1, num_classes=10
+    )
+)
+# 8 channel
+# 2D
+CASES_VARIATIONS.extend(
+    make_shape_cases(
+        models=SEL_MODELS, spatial_dims=[2], batches=[1], pretrained=[False, True], in_channels=8, num_classes=10
+    )
+)
+# 3D
+CASES_VARIATIONS.extend(
+    make_shape_cases(
+        models=[SEL_MODELS[0]], spatial_dims=[3], batches=[1], pretrained=[False], in_channels=1, num_classes=10
+    )
+)
+
+# change input shape test
+# 96
+# 2D 96x96 input
+CASES_VARIATIONS.extend(
+    make_shape_cases(
+        models=SEL_MODELS,
+        spatial_dims=[2],
+        batches=[1],
+        pretrained=[False, True],
+        in_channels=3,
+        num_classes=10,
+        input_shape=96,
+    )
+)
+# 2D 64x64 input
+CASES_VARIATIONS.extend(
+    make_shape_cases(
+        models=SEL_MODELS,
+        spatial_dims=[2],
+        batches=[1],
+        pretrained=[False, True],
+        in_channels=3,
+        num_classes=10,
+        input_shape=64,
+    )
+)
+
+# 3D 32x32x32 input
+CASES_VARIATIONS.extend(
+    make_shape_cases(
+        models=SEL_MODELS,
+        spatial_dims=[2],
+        batches=[1],
+        pretrained=[False],
+        in_channels=3,
+        num_classes=10,
+        input_shape=32,
+    )
+)
+
+# 3D 64x64x64 input
+CASES_VARIATIONS.extend(
+    make_shape_cases(
+        models=SEL_MODELS,
+        spatial_dims=[2],
+        batches=[1],
+        pretrained=[False],
+        in_channels=3,
+        num_classes=10,
+        input_shape=64,
+    )
+)
+
+# pretrain weight verified
+CASES_PRETRAIN = [
+    (
+        {
+            "in_channels": 3,
+            "out_channels": 10,
+            "backbone": SEL_MODELS[0],
+            "pretrained": True,
+            "spatial_dims": 2,
+            "norm": ("batch", {"eps": 1e-3, "momentum": 0.01}),
+        },
+        {
+            "in_channels": 3,
+            "num_classes": 10,
+            "model_name": SEL_MODELS[0],
+            "pretrained": True,
+            "spatial_dims": 2,
+            "norm": ("batch", {"eps": 1e-3, "momentum": 0.01}),
+        },
+        ["_conv_stem.weight"],
+    )
+]
+
+
+@skip_if_quick
+class TestFLEXIBLEUNET(unittest.TestCase):
+    @parameterized.expand(CASES_2D + CASES_3D + CASES_VARIATIONS)
+    def test_shape(self, input_param, input_shape, expected_shape):
+        device = "cuda" if torch.cuda.is_available() else "cpu"
+
+        with skip_if_downloading_fails():
+            net = FlexibleUNet(**input_param).to(device)
+
+        # run inference with random tensor
+        with eval_mode(net):
+            result = net(torch.randn(input_shape).to(device))
+
+        # check output shape
+        self.assertEqual(result.shape, expected_shape)
+
+    @parameterized.expand(CASES_PRETRAIN)
+    def test_pretrain(self, input_param, efficient_input_param, weight_list):
+        device = "cuda" if torch.cuda.is_available() else "cpu"
+
+        with skip_if_downloading_fails():
+            net = FlexibleUNet(**input_param).to(device)
+
+        with skip_if_downloading_fails():
+            eff_net = EfficientNetBNFeatures(**efficient_input_param).to(device)
+
+        for weight_name in weight_list:
+            if weight_name in net.encoder.state_dict() and weight_name in eff_net.state_dict():
+                net_weight = net.encoder.state_dict()[weight_name]
+                download_weight = eff_net.state_dict()[weight_name]
+                weight_diff = torch.abs(net_weight - download_weight)
+                diff_sum = torch.sum(weight_diff)
+                # check if a weight in weight_list equals to the downloaded weight.
+                self.assertLess(abs(diff_sum.item() - 0), 1e-8)
+
+
+if __name__ == "__main__":
+    unittest.main()