Repository: scikit-image/scikit-image

Issue:
pyramid_gaussian does not work with channel_axis != -1
## Description

I tried to use pyramid_gaussian to create an image pyramid of a multichannel image. In my case, axis 1 was used for the channels. However, I noticed that any channel_axis other than -1 yields an error.

<!--
(Note: for guidance on how to use `scikit-image`, please post instead on https://forum.image.sc/tag/scikit-image)
-->

## Way to reproduce
```python

from skimage import data
from skimage.transform import pyramid_gaussian


image = np.swapaxes(data.astronaut(), axis1=0, axis2=2)
pyramid = tuple(pyramid_gaussian(image, downscale=2, channel_axis=1))

```


## Version information
```python
3.9.7 (default, Sep 16 2021, 16:59:28) [MSC v.1916 64 bit (AMD64)]
Windows-10-10.0.19042-SP0
scikit-image version: 0.19.1
numpy version: 1.20.3
```


```python
Traceback (most recent call last):
  File "C:\Users\Adrian\miniconda3\envs\lca\lib\site-packages\IPython\core\interactiveshell.py", line 3444, in run_code
    exec(code_obj, self.user_global_ns, self.user_ns)
  File "<ipython-input-5-7e3b4094a17d>", line 1, in <module>
    pyramid = tuple(pyramid_gaussian(image, downscale=2, channel_axis=1))
  File "C:\Users\Adrian\miniconda3\envs\lca\lib\site-packages\skimage\_shared\utils.py", line 373, in fixed_func
    out = np.moveaxis(out, -1, channel_axis[0])
  File "<__array_function__ internals>", line 5, in moveaxis
  File "C:\Users\Adrian\miniconda3\envs\lca\lib\site-packages\numpy\core\numeric.py", line 1461, in moveaxis
    source = normalize_axis_tuple(source, a.ndim, 'source')
  File "C:\Users\Adrian\miniconda3\envs\lca\lib\site-packages\numpy\core\numeric.py", line 1391, in normalize_axis_tuple
    axis = tuple([normalize_axis_index(ax, ndim, argname) for ax in axis])
  File "C:\Users\Adrian\miniconda3\envs\lca\lib\site-packages\numpy\core\numeric.py", line 1391, in <listcomp>
    axis = tuple([normalize_axis_index(ax, ndim, argname) for ax in axis])
numpy.AxisError: source: axis -1 is out of bounds for array of dimension 0

```



Discussion:
Thanks for reporting this, I can confirm that this is broken. I am not sure how I missed adding test cases for these functions with axis != -1, but will add some once it is fixed. 

We do have a `channel_as_last_axis` decorator that is supposed to temporarily swap axes when needed, but these particular pyramid functions return a `generator` rather than a tuple of arrays, which is making them incompatible with the current decorator. I will see about fixing it in the functions themselves rather than relying on the decorator in this case.

Resolving patch:
diff --git a/skimage/transform/pyramids.py b/skimage/transform/pyramids.py
--- a/skimage/transform/pyramids.py
+++ b/skimage/transform/pyramids.py
@@ -9,14 +9,15 @@
 from ..transform import resize
 
 
-def _smooth(image, sigma, mode, cval, multichannel=None):
+def _smooth(image, sigma, mode, cval, channel_axis):
     """Return image with each channel smoothed by the Gaussian filter."""
     smoothed = np.empty_like(image)
 
     # apply Gaussian filter to all channels independently
-    if multichannel:
-        sigma = (sigma, ) * (image.ndim - 1) + (0, )
-        channel_axis = -1
+    if channel_axis is not None:
+        # can rely on gaussian to insert a 0 entry at channel_axis
+        channel_axis = channel_axis % image.ndim
+        sigma = (sigma,) * (image.ndim - 1)
     else:
         channel_axis = None
     gaussian(image, sigma, output=smoothed, mode=mode, cval=cval,
@@ -29,7 +30,6 @@ def _check_factor(factor):
         raise ValueError('scale factor must be greater than 1')
 
 
-@utils.channel_as_last_axis()
 @utils.deprecate_multichannel_kwarg(multichannel_position=6)
 def pyramid_reduce(image, downscale=2, sigma=None, order=1,
                    mode='reflect', cval=0, multichannel=False,
@@ -81,26 +81,28 @@ def pyramid_reduce(image, downscale=2, sigma=None, order=1,
 
     """
     _check_factor(downscale)
-    multichannel = channel_axis is not None
 
     image = convert_to_float(image, preserve_range)
-
-    out_shape = tuple([math.ceil(d / float(downscale)) for d in image.shape])
-    if multichannel:
-        out_shape = out_shape[:-1]
+    if channel_axis is not None:
+        channel_axis = channel_axis % image.ndim
+        out_shape = tuple(
+            math.ceil(d / float(downscale)) if ax != channel_axis else d
+            for ax, d in enumerate(image.shape)
+        )
+    else:
+        out_shape = tuple(math.ceil(d / float(downscale)) for d in image.shape)
 
     if sigma is None:
         # automatically determine sigma which covers > 99% of distribution
         sigma = 2 * downscale / 6.0
 
-    smoothed = _smooth(image, sigma, mode, cval, multichannel)
+    smoothed = _smooth(image, sigma, mode, cval, channel_axis)
     out = resize(smoothed, out_shape, order=order, mode=mode, cval=cval,
                  anti_aliasing=False)
 
     return out
 
 
-@utils.channel_as_last_axis()
 @utils.deprecate_multichannel_kwarg(multichannel_position=6)
 def pyramid_expand(image, upscale=2, sigma=None, order=1,
                    mode='reflect', cval=0, multichannel=False,
@@ -152,13 +154,15 @@ def pyramid_expand(image, upscale=2, sigma=None, order=1,
 
     """
     _check_factor(upscale)
-    multichannel = channel_axis is not None
-
     image = convert_to_float(image, preserve_range)
-
-    out_shape = tuple([math.ceil(upscale * d) for d in image.shape])
-    if multichannel:
-        out_shape = out_shape[:-1]
+    if channel_axis is not None:
+        channel_axis = channel_axis % image.ndim
+        out_shape = tuple(
+            math.ceil(upscale * d) if ax != channel_axis else d
+            for ax, d in enumerate(image.shape)
+        )
+    else:
+        out_shape = tuple(math.ceil(upscale * d) for d in image.shape)
 
     if sigma is None:
         # automatically determine sigma which covers > 99% of distribution
@@ -166,12 +170,11 @@ def pyramid_expand(image, upscale=2, sigma=None, order=1,
 
     resized = resize(image, out_shape, order=order,
                      mode=mode, cval=cval, anti_aliasing=False)
-    out = _smooth(resized, sigma, mode, cval, multichannel)
+    out = _smooth(resized, sigma, mode, cval, channel_axis)
 
     return out
 
 
-@utils.channel_as_last_axis()
 @utils.deprecate_multichannel_kwarg(multichannel_position=7)
 def pyramid_gaussian(image, max_layer=-1, downscale=2, sigma=None, order=1,
                      mode='reflect', cval=0, multichannel=False,
@@ -252,18 +255,17 @@ def pyramid_gaussian(image, max_layer=-1, downscale=2, sigma=None, order=1,
         layer_image = pyramid_reduce(prev_layer_image, downscale, sigma, order,
                                      mode, cval, channel_axis=channel_axis)
 
-        prev_shape = np.asarray(current_shape)
+        prev_shape = current_shape
         prev_layer_image = layer_image
-        current_shape = np.asarray(layer_image.shape)
+        current_shape = layer_image.shape
 
         # no change to previous pyramid layer
-        if np.all(current_shape == prev_shape):
+        if current_shape == prev_shape:
             break
 
         yield layer_image
 
 
-@utils.channel_as_last_axis()
 @utils.deprecate_multichannel_kwarg(multichannel_position=7)
 def pyramid_laplacian(image, max_layer=-1, downscale=2, sigma=None, order=1,
                       mode='reflect', cval=0, multichannel=False,
@@ -330,7 +332,6 @@ def pyramid_laplacian(image, max_layer=-1, downscale=2, sigma=None, order=1,
 
     """
     _check_factor(downscale)
-    multichannel = channel_axis is not None
 
     # cast to float for consistent data type in pyramid
     image = convert_to_float(image, preserve_range)
@@ -341,26 +342,37 @@ def pyramid_laplacian(image, max_layer=-1, downscale=2, sigma=None, order=1,
 
     current_shape = image.shape
 
-    smoothed_image = _smooth(image, sigma, mode, cval, multichannel)
+    smoothed_image = _smooth(image, sigma, mode, cval, channel_axis)
     yield image - smoothed_image
 
+    if channel_axis is not None:
+        channel_axis = channel_axis % image.ndim
+        shape_without_channels = list(current_shape)
+        shape_without_channels.pop(channel_axis)
+        shape_without_channels = tuple(shape_without_channels)
+    else:
+        shape_without_channels = current_shape
+
     # build downsampled images until max_layer is reached or downscale process
     # does not change image size
     if max_layer == -1:
-        max_layer = int(np.ceil(math.log(np.max(current_shape), downscale)))
+        max_layer = math.ceil(math.log(max(shape_without_channels), downscale))
 
     for layer in range(max_layer):
 
-        out_shape = tuple(
-            [math.ceil(d / float(downscale)) for d in current_shape])
-
-        if multichannel:
-            out_shape = out_shape[:-1]
+        if channel_axis is not None:
+            out_shape = tuple(
+                math.ceil(d / float(downscale)) if ax != channel_axis else d
+                for ax, d in enumerate(current_shape)
+            )
+        else:
+            out_shape = tuple(math.ceil(d / float(downscale))
+                              for d in current_shape)
 
         resized_image = resize(smoothed_image, out_shape, order=order,
                                mode=mode, cval=cval, anti_aliasing=False)
         smoothed_image = _smooth(resized_image, sigma, mode, cval,
-                                 multichannel)
-        current_shape = np.asarray(resized_image.shape)
+                                 channel_axis)
+        current_shape = resized_image.shape
 
         yield resized_image - smoothed_image


Test patch:
diff --git a/skimage/transform/tests/test_pyramids.py b/skimage/transform/tests/test_pyramids.py
--- a/skimage/transform/tests/test_pyramids.py
+++ b/skimage/transform/tests/test_pyramids.py
@@ -28,9 +28,10 @@ def test_pyramid_reduce_rgb_deprecated_multichannel():
 
 @pytest.mark.parametrize('channel_axis', [0, 1, -1])
 def test_pyramid_reduce_rgb(channel_axis):
+    image = data.astronaut()
     rows, cols, dim = image.shape
-    image_ = np.moveaxis(image, -1, channel_axis)
-    out_ = pyramids.pyramid_reduce(image_, downscale=2,
+    image = np.moveaxis(image, source=-1, destination=channel_axis)
+    out_ = pyramids.pyramid_reduce(image, downscale=2,
                                    channel_axis=channel_axis)
     out = np.moveaxis(out_, channel_axis, -1)
     assert_array_equal(out.shape, (rows / 2, cols / 2, dim))
@@ -56,11 +57,16 @@ def test_pyramid_reduce_nd():
         assert_array_equal(out.shape, expected_shape)
 
 
-def test_pyramid_expand_rgb():
+@pytest.mark.parametrize('channel_axis', [0, 1, 2, -1, -2, -3])
+def test_pyramid_expand_rgb(channel_axis):
+    image = data.astronaut()
     rows, cols, dim = image.shape
+    image = np.moveaxis(image, source=-1, destination=channel_axis)
     out = pyramids.pyramid_expand(image, upscale=2,
-                                  channel_axis=-1)
-    assert_array_equal(out.shape, (rows * 2, cols * 2, dim))
+                                   channel_axis=channel_axis)
+    expected_shape = [rows * 2, cols * 2]
+    expected_shape.insert(channel_axis % image.ndim, dim)
+    assert_array_equal(out.shape, expected_shape)
 
 
 def test_pyramid_expand_rgb_deprecated_multichannel():
@@ -90,13 +96,17 @@ def test_pyramid_expand_nd():
         assert_array_equal(out.shape, expected_shape)
 
 
-def test_build_gaussian_pyramid_rgb():
+@pytest.mark.parametrize('channel_axis', [0, 1, 2, -1, -2, -3])
+def test_build_gaussian_pyramid_rgb(channel_axis):
+    image = data.astronaut()
     rows, cols, dim = image.shape
+    image = np.moveaxis(image, source=-1, destination=channel_axis)
     pyramid = pyramids.pyramid_gaussian(image, downscale=2,
-                                        channel_axis=-1)
+                                        channel_axis=channel_axis)
     for layer, out in enumerate(pyramid):
-        layer_shape = (rows / 2 ** layer, cols / 2 ** layer, dim)
-        assert_array_equal(out.shape, layer_shape)
+        layer_shape = [rows / 2 ** layer, cols / 2 ** layer]
+        layer_shape.insert(channel_axis % image.ndim, dim)
+        assert out.shape == tuple(layer_shape)
 
 
 def test_build_gaussian_pyramid_rgb_deprecated_multichannel():
@@ -137,13 +147,17 @@ def test_build_gaussian_pyramid_nd():
             assert_array_equal(out.shape, layer_shape)
 
 
-def test_build_laplacian_pyramid_rgb():
+@pytest.mark.parametrize('channel_axis', [0, 1, 2, -1, -2, -3])
+def test_build_laplacian_pyramid_rgb(channel_axis):
+    image = data.astronaut()
     rows, cols, dim = image.shape
+    image = np.moveaxis(image, source=-1, destination=channel_axis)
     pyramid = pyramids.pyramid_laplacian(image, downscale=2,
-                                         channel_axis=-1)
+                                         channel_axis=channel_axis)
     for layer, out in enumerate(pyramid):
-        layer_shape = (rows / 2 ** layer, cols / 2 ** layer, dim)
-        assert_array_equal(out.shape, layer_shape)
+        layer_shape = [rows / 2 ** layer, cols / 2 ** layer]
+        layer_shape.insert(channel_axis % image.ndim, dim)
+        assert out.shape == tuple(layer_shape)
 
 
 def test_build_laplacian_pyramid_rgb_deprecated_multichannel():
@@ -171,27 +185,46 @@ def test_build_laplacian_pyramid_nd():
         pyramid = pyramids.pyramid_laplacian(img, downscale=2,
                                              channel_axis=None)
         for layer, out in enumerate(pyramid):
-            print(out.shape)
             layer_shape = original_shape / 2 ** layer
             assert_array_equal(out.shape, layer_shape)
 
 
-def test_laplacian_pyramid_max_layers():
+@pytest.mark.parametrize('channel_axis', [0, 1, 2, -1, -2, -3])
+def test_laplacian_pyramid_max_layers(channel_axis):
     for downscale in [2, 3, 5, 7]:
-        img = np.random.randn(32, 8)
+        if channel_axis is None:
+            shape = (32, 8)
+            shape_without_channels = shape
+        else:
+            shape_without_channels = (32, 8)
+            ndim = len(shape_without_channels) + 1
+            n_channels = 5
+            shape = list(shape_without_channels)
+            shape.insert(channel_axis % ndim, n_channels)
+            shape = tuple(shape)
+        img = np.ones(shape)
         pyramid = pyramids.pyramid_laplacian(img, downscale=downscale,
-                                             channel_axis=None)
-        max_layer = int(np.ceil(math.log(np.max(img.shape), downscale)))
+                                             channel_axis=channel_axis)
+        max_layer = math.ceil(math.log(max(shape_without_channels), downscale))
         for layer, out in enumerate(pyramid):
+
+            if channel_axis is None:
+                out_shape_without_channels = out.shape
+            else:
+                assert out.shape[channel_axis] == n_channels
+                out_shape_without_channels = list(out.shape)
+                out_shape_without_channels.pop(channel_axis)
+                out_shape_without_channels = tuple(out_shape_without_channels)
+
             if layer < max_layer:
                 # should not reach all axes as size 1 prior to final level
-                assert np.max(out.shape) > 1
+                assert max(out_shape_without_channels) > 1
 
         # total number of images is max_layer + 1
         assert_equal(max_layer, layer)
 
         # final layer should be size 1 on all axes
-        assert_array_equal((out.shape), (1, 1))
+        assert out_shape_without_channels == (1, 1)
 
 
 def test_check_factor():