Repository: scikit-image/scikit-image

Issue:
transform.resize uses anti_aliasing as default and fails to correctly clip values when anti_aliasing is used
## Description

The function transform.resize applies anti_aliasing as default when downsizing images, which is misleading as the default value for kwarg *anti_aliasing* is None.
Moreover, when applying anti_aliasing, when *scipy.ndimage.gaussian_filter* produce values outside the given input range, the algorithm fails to correctly clip the values while clip=True (default).

## Way to reproduce

```python
img = np.ones((208, 208))
transform.resize(img, (128, 128), anti_aliasing=None, clip=True).max()
```
>>> 1.0000000000000004

```python
img = np.ones((208, 208))
sigma = (208 / 128 - 1) * 0.5
ndi.gaussian_filter(img, sigma).max()
```
>>> 1.0000000000000004


## Version information
```python
# Paste the output of the following python commands
from __future__ import print_function
import sys; print(sys.version)
import platform; print(platform.platform())
import skimage; print("scikit-image version: {}".format(skimage.__version__))
import numpy; print("numpy version: {}".format(numpy.__version__))
```

```python
3.8.4 | packaged by conda-forge | (default, Jul 17 2020, 15:16:46) 
[GCC 7.5.0]
Linux-5.4.0-65-generic-x86_64-with-glibc2.10
scikit-image version: 0.17.2
numpy version: 1.19.5
```



Discussion:
Hi @abouysso, happy to see you here :wink:. Thank you for the report.
I confirm the `clip` bug: version 0.18 and current master branch is also affected.
I agree with you, the documentation concerning the `anti_aliasing` parameter must specify clearly that it is applied by default in case of down-scaling.
Your analysis seems also valid, the clipping is applied after filtering,  it is the root cause of the bug.
I will come with a PR soon. :)

Resolving patch:
diff --git a/skimage/transform/_warps.py b/skimage/transform/_warps.py
--- a/skimage/transform/_warps.py
+++ b/skimage/transform/_warps.py
@@ -74,7 +74,7 @@ def resize(image, output_shape, order=None, mode='reflect', cval=0, clip=True,
 
     Performs interpolation to up-size or down-size N-dimensional images. Note
     that anti-aliasing should be enabled when down-sizing images to avoid
-    aliasing artifacts. For down-sampling with an integer factor also see
+    aliasing artifacts. For downsampling with an integer factor also see
     `skimage.transform.downscale_local_mean`.
 
     Parameters
@@ -114,13 +114,13 @@ def resize(image, output_shape, order=None, mode='reflect', cval=0, clip=True,
         Also see https://scikit-image.org/docs/dev/user_guide/data_types.html
     anti_aliasing : bool, optional
         Whether to apply a Gaussian filter to smooth the image prior
-        to down-scaling. It is crucial to filter when down-sampling
-        the image to avoid aliasing artifacts. If input image data
-        type is bool, no anti-aliasing is applied.
+        to downsampling. It is crucial to filter when downsampling
+        the image to avoid aliasing artifacts. If not specified, it is set to
+        True when downsampling an image whose data type is not bool.
     anti_aliasing_sigma : {float, tuple of floats}, optional
-        Standard deviation for Gaussian filtering to avoid aliasing artifacts.
+        Standard deviation for Gaussian filtering used when anti-aliasing.
         By default, this value is chosen as (s - 1) / 2 where s is the
-        down-scaling factor, where s > 1. For the up-size case, s < 1, no
+        downsampling factor, where s > 1. For the up-size case, s < 1, no
         anti-aliasing is performed prior to rescaling.
 
     Notes
@@ -143,18 +143,25 @@ def resize(image, output_shape, order=None, mode='reflect', cval=0, clip=True,
 
     image, output_shape = _preprocess_resize_output_shape(image, output_shape)
     input_shape = image.shape
+    input_type = image.dtype
 
-    if image.dtype == np.float16:
+    if input_type == np.float16:
         image = image.astype(np.float32)
 
     if anti_aliasing is None:
-        anti_aliasing = not image.dtype == bool
+        anti_aliasing = (not input_type == bool and
+                         any(x < y for x, y in zip(output_shape, input_shape)))
 
-    if image.dtype == bool and anti_aliasing:
+    if input_type == bool and anti_aliasing:
         raise ValueError("anti_aliasing must be False for boolean images")
 
-    factors = (np.asarray(input_shape, dtype=float) /
-               np.asarray(output_shape, dtype=float))
+    factors = np.divide(input_shape, output_shape)
+    order = _validate_interpolation_order(input_type, order)
+    if order > 0:
+        image = convert_to_float(image, preserve_range)
+
+    # Save input value range for clip
+    img_bounds = np.array([image.min(), image.max()]) if clip else None
 
     # Translate modes used by np.pad to those used by scipy.ndimage
     ndi_mode = _to_ndimage_mode(mode)
@@ -175,13 +182,9 @@ def resize(image, output_shape, order=None, mode='reflect', cval=0, clip=True,
 
     if NumpyVersion(scipy.__version__) >= '1.6.0':
         # The grid_mode kwarg was introduced in SciPy 1.6.0
-        order = _validate_interpolation_order(image.dtype, order)
         zoom_factors = [1 / f for f in factors]
-        if order > 0:
-            image = convert_to_float(image, preserve_range)
         out = ndi.zoom(image, zoom_factors, order=order, mode=ndi_mode,
                        cval=cval, grid_mode=True)
-        _clip_warp_output(image, out, order, mode, cval, clip)
 
     # TODO: Remove the fallback code below once SciPy >= 1.6.0 is required.
 
@@ -211,12 +214,12 @@ def resize(image, output_shape, order=None, mode='reflect', cval=0, clip=True,
         tform.params[0, 1] = 0
         tform.params[1, 0] = 0
 
+        # clip outside of warp to clip w.r.t input values, not filtered values.
         out = warp(image, tform, output_shape=output_shape, order=order,
-                   mode=mode, cval=cval, clip=clip,
+                   mode=mode, cval=cval, clip=False,
                    preserve_range=preserve_range)
 
     else:  # n-dimensional interpolation
-        order = _validate_interpolation_order(image.dtype, order)
 
         coord_arrays = [factors[i] * (np.arange(d) + 0.5) - 0.5
                         for i, d in enumerate(output_shape)]
@@ -225,12 +228,10 @@ def resize(image, output_shape, order=None, mode='reflect', cval=0, clip=True,
                                          sparse=False,
                                          indexing='ij'))
 
-        image = convert_to_float(image, preserve_range)
-
         out = ndi.map_coordinates(image, coord_map, order=order,
                                   mode=ndi_mode, cval=cval)
 
-        _clip_warp_output(image, out, order, mode, cval, clip)
+    _clip_warp_output(img_bounds, out, mode, cval, clip)
 
     return out
 
@@ -687,7 +688,7 @@ def warp_coords(coord_map, shape, dtype=np.float64):
     return coords
 
 
-def _clip_warp_output(input_image, output_image, order, mode, cval, clip):
+def _clip_warp_output(input_image, output_image, mode, cval, clip):
     """Clip output image to range of values of input image.
 
     Note that this function modifies the values of `output_image` in-place
@@ -702,22 +703,19 @@ def _clip_warp_output(input_image, output_image, order, mode, cval, clip):
 
     Other parameters
     ----------------
-    order : int, optional
-        The order of the spline interpolation, default is 1. The order has to
-        be in the range 0-5. See `skimage.transform.warp` for detail.
-    mode : {'constant', 'edge', 'symmetric', 'reflect', 'wrap'}, optional
+    mode : {'constant', 'edge', 'symmetric', 'reflect', 'wrap'}
         Points outside the boundaries of the input are filled according
         to the given mode.  Modes match the behaviour of `numpy.pad`.
-    cval : float, optional
+    cval : float
         Used in conjunction with mode 'constant', the value outside
         the image boundaries.
-    clip : bool, optional
+    clip : bool
         Whether to clip the output to the range of values of the input image.
         This is enabled by default, since higher order interpolation may
         produce values outside the given input range.
 
     """
-    if clip and order != 0:
+    if clip:
         min_val = input_image.min()
         max_val = input_image.max()
 
@@ -983,7 +981,7 @@ def coord_map(*args):
         warped = ndi.map_coordinates(image, coords, prefilter=prefilter,
                                      mode=ndi_mode, order=order, cval=cval)
 
-    _clip_warp_output(image, warped, order, mode, cval, clip)
+    _clip_warp_output(image, warped, mode, cval, clip)
 
     return warped
 


Test patch:
diff --git a/skimage/transform/tests/test_warps.py b/skimage/transform/tests/test_warps.py
--- a/skimage/transform/tests/test_warps.py
+++ b/skimage/transform/tests/test_warps.py
@@ -376,6 +376,25 @@ def test_resize_dtype():
     assert resize(x_f32, (10, 10), preserve_range=True).dtype == x_f32.dtype
 
 
+@pytest.mark.parametrize('order', [0, 1])
+@pytest.mark.parametrize('preserve_range', [True, False])
+@pytest.mark.parametrize('anti_aliasing', [True, False])
+@pytest.mark.parametrize('dtype', [np.float64, np.uint8])
+def test_resize_clip(order, preserve_range, anti_aliasing, dtype):
+    # test if clip as expected
+    if dtype == np.uint8 and (preserve_range or order==0):
+        expected_max = 255
+    else:
+        expected_max = 1.0
+    x = np.ones((5, 5), dtype=dtype)
+    if dtype == np.uint8:
+        x *= 255
+    resized = resize(x, (3, 3), order=order, preserve_range=preserve_range,
+                     anti_aliasing=anti_aliasing)
+
+    assert resized.max() == expected_max
+
+
 @pytest.mark.parametrize('dtype', [np.float16, np.float32, np.float64])
 def test_swirl(dtype):
     image = img_as_float(checkerboard()).astype(dtype, copy=False)