Repository: Project-MONAI/MONAI

Issue:
Different dimensions when loading multi-channel image using nibabel vs itk
**Describe the bug**
I create multi-channel images (e.g. two channels with T1, T2) using itk.compose_image_filter and save to a nifty file.
- When I load this image using the default `LoadImage` the shape of the array is `(Nx, Ny, Nz, 1, 2)`. The **ONE** causes issues in subsequent transforms (`Orientationd`), because the image is interpreted as 4D.
-  If I use the ITK reader LoadImage loads an image with shape `(Nx, Ny, Nz, 2)`, which is what it should be I think.


**To Reproduce**
Steps to reproduce the behavior:
1. Using a recent monai and itk
2. Following code reproduces the different behavior
```
def make_image(
    shape: Sequence[int],
    spacing: Optional[Sequence[float]] = None,
    value: Union[int, float] = 0,
    pixel_type = itk.UC,
):
    """Create (2D/3D) image with specified shape and spacing"""
    dim = len(shape)

    region = itk.ImageRegion[dim]()
    region.SetSize(shape)
    region.SetIndex(tuple([0] * dim))

    image = itk.Image[pixel_type, dim].New()
    image.SetRegions(region)
    if spacing:
        if len(shape) != len(spacing):
            raise ValueError("shape and spacing must have same dimension")
        image.SetSpacing(spacing)
    image.Allocate()

    image[:] = value
    return image

def test_nibabelreader():
    c1 = make_image(shape=(10,10,10), spacing=(1,1,1), pixel_type=itk.F, value=1)
    c2 = make_image(shape=(10,10,10), spacing=(1,1,1), pixel_type=itk.F, value=2)
    arrays = [c1, c2]
    multichannel = itk.compose_image_filter(*arrays)
    fn = "/Users/lloyd/image3d_2c.nii.gz"
    itk.imwrite(multichannel, filename=fn)

    nib_loaded = LoadImaged(keys="image", reader="nibabelreader")({"image":fn})
    itk_loaded = LoadImaged(keys="image", reader="itkreader")({"image":fn})
    print(nib_loaded["image"].shape)
    print(itk_loaded["image"].shape)
```

**Expected behavior**
I think both readers should produce the same image shape in this case.
I think the nibabelreader is interpreting the dimensions incorrectly (unless itk is not following some standard wrt nifty files).

**Screenshots**
na

**Environment**

probably na, but here it is:

> ================================
> Printing MONAI config...
> ================================
> MONAI version: 0.7.0
> Numpy version: 1.21.2
> Pytorch version: 1.9.0
> MONAI flags: HAS_EXT = False, USE_COMPILED = False
> MONAI rev id: bfa054b9c3064628a21f4c35bbe3132964e91f43
> 
> Optional dependencies:
> Pytorch Ignite version: NOT INSTALLED or UNKNOWN VERSION.
> Nibabel version: 3.2.1
> scikit-image version: NOT INSTALLED or UNKNOWN VERSION.
> Pillow version: 8.3.2
> Tensorboard version: 2.6.0
> gdown version: NOT INSTALLED or UNKNOWN VERSION.
> TorchVision version: 0.10.0
> tqdm version: 4.62.3
> lmdb version: NOT INSTALLED or UNKNOWN VERSION.
> psutil version: NOT INSTALLED or UNKNOWN VERSION.
> pandas version: NOT INSTALLED or UNKNOWN VERSION.
> einops version: NOT INSTALLED or UNKNOWN VERSION.
> transformers version: NOT INSTALLED or UNKNOWN VERSION.
> 
> For details about installing the optional dependencies, please visit:
>     https://docs.monai.io/en/latest/installation.html#installing-the-recommended-dependencies
> 
> 
> ================================
> Printing system config...
> ================================
> `psutil` required for `print_system_info`
> 
> ================================
> Printing GPU config...
> ================================
> Num GPUs: 0
> Has CUDA: False
> cuDNN enabled: False
> 

**Additional context**
none



Discussion:
Hi @wyli @thewtex ,

Seems like some mismatched data format in ITK and Nibabel? I think you guys are experts on that, could you please help share some comments?

Thanks in advance.

btw. I tried to load the image directly using nibabel. 
```
    nib_img = nib.load(fn)
    print(nib_img.header)
    print(nib_img.dataobj.shape)
```
The shape of the `dataobj` is `(10, 10, 10, 1, 2)`.

The header looks as follows:
```
<class 'nibabel.nifti1.Nifti1Header'> object, endian='<'
sizeof_hdr      : 348
data_type       : b''
db_name         : b''
extents         : 0
session_error   : 0
regular         : b'r'
dim_info        : 0
dim             : [ 5 10 10 10  1  2  1  1]
intent_p1       : 0.0
intent_p2       : 0.0
intent_p3       : 0.0
intent_code     : vector
datatype        : float32
bitpix          : 32
slice_start     : 0
pixdim          : [1. 1. 1. 1. 0. 0. 0. 0.]
vox_offset      : 0.0
scl_slope       : nan
scl_inter       : nan
slice_end       : 0
slice_code      : unknown
xyzt_units      : 2
cal_max         : 0.0
cal_min         : 0.0
slice_duration  : 0.0
toffset         : 0.0
glmax           : 0
glmin           : 0
descrip         : b''
aux_file        : b''
qform_code      : scanner
sform_code      : scanner
quatern_b       : 0.0
quatern_c       : 0.0
quatern_d       : 1.0
qoffset_x       : -0.0
qoffset_y       : -0.0
qoffset_z       : 0.0
srow_x          : [-1.  0.  0. -0.]
srow_y          : [ 0. -1.  0. -0.]
srow_z          : [0. 0. 1. 0.]
intent_name     : b''
magic           : b'n+1'
```

I don't know enough about nifty headers to judge who is right. Maybe itk is writing the nifty header in a non-conventional way, or nibabel just likes interpreting the shape as 5D, or there are multiple conventions...
Actually, I think nibabel and itk are both right. Nibabel is just reading the nifty header verbatim, and the convention for dimensions after the x,y,z, is **time** followed by channels.
See https://nifti.nimh.nih.gov/pub/dist/src/niftilib/nifti1.h
![image](https://user-images.githubusercontent.com/12702862/138693029-71cbe77e-4ec8-49d5-a7dd-c6e7c94ab541.png)

I think the monai nibabel reader should remove the `1` (for one time step) in the shape, as it does when loading e.g. scalar images. Note the nifty header (loaded by nibabel) contains the **intent_code** (which is vector and not time series in my example).

Resolving patch:
diff --git a/monai/data/image_reader.py b/monai/data/image_reader.py
--- a/monai/data/image_reader.py
+++ b/monai/data/image_reader.py
@@ -323,14 +323,22 @@ class NibabelReader(ImageReader):
 
     Args:
         as_closest_canonical: if True, load the image as closest to canonical axis format.
+        squeeze_non_spatial_dims: if True, non-spatial singletons will be squeezed, e.g. (256,256,1,3) -> (256,256,3)
         kwargs: additional args for `nibabel.load` API. more details about available args:
             https://github.com/nipy/nibabel/blob/master/nibabel/loadsave.py
 
     """
 
-    def __init__(self, as_closest_canonical: bool = False, dtype: DtypeLike = np.float32, **kwargs):
+    def __init__(
+        self,
+        as_closest_canonical: bool = False,
+        squeeze_non_spatial_dims: bool = False,
+        dtype: DtypeLike = np.float32,
+        **kwargs,
+    ):
         super().__init__()
         self.as_closest_canonical = as_closest_canonical
+        self.squeeze_non_spatial_dims = squeeze_non_spatial_dims
         self.dtype = dtype
         self.kwargs = kwargs
 
@@ -395,6 +403,10 @@ def get_data(self, img):
                 header["affine"] = self._get_affine(i)
             header["spatial_shape"] = self._get_spatial_shape(i)
             data = self._get_array_data(i)
+            if self.squeeze_non_spatial_dims:
+                for d in range(len(data.shape), len(header["spatial_shape"]), -1):
+                    if data.shape[d - 1] == 1:
+                        data = data.squeeze(axis=d - 1)
             img_array.append(data)
             header["original_channel_dim"] = "no_channel" if len(data.shape) == len(header["spatial_shape"]) else -1
             _copy_compatible_dict(header, compatible_meta)


Test patch:
diff --git a/tests/test_load_image.py b/tests/test_load_image.py
--- a/tests/test_load_image.py
+++ b/tests/test_load_image.py
@@ -170,6 +170,21 @@ def test_itk_reader_multichannel(self):
             np.testing.assert_allclose(result[:, :, 1], test_image[:, :, 1].T)
             np.testing.assert_allclose(result[:, :, 2], test_image[:, :, 2].T)
 
+    def test_load_nifti_multichannel(self):
+        test_image = np.random.randint(0, 256, size=(31, 64, 16, 2)).astype(np.float32)
+        with tempfile.TemporaryDirectory() as tempdir:
+            filename = os.path.join(tempdir, "test_image.nii.gz")
+            itk_np_view = itk.image_view_from_array(test_image, is_vector=True)
+            itk.imwrite(itk_np_view, filename)
+
+            itk_img, itk_header = LoadImage(reader=ITKReader())(Path(filename))
+            self.assertTupleEqual(tuple(itk_header["spatial_shape"]), (16, 64, 31))
+            self.assertTupleEqual(tuple(itk_img.shape), (16, 64, 31, 2))
+
+            nib_image, nib_header = LoadImage(reader=NibabelReader(squeeze_non_spatial_dims=True))(Path(filename))
+            self.assertTupleEqual(tuple(nib_header["spatial_shape"]), (16, 64, 31))
+            self.assertTupleEqual(tuple(nib_image.shape), (16, 64, 31, 2))
+
     def test_load_png(self):
         spatial_size = (256, 224)
         test_image = np.random.randint(0, 256, size=spatial_size)