Repository: scikit-image/scikit-image

Issue:
SLIC segmentation results differ depending on input image datatype
I just realized that segmentation output can vary a lot depending on the datatype of the input array.

See reproducible example below


```python
from skimage import segmentation, data
import numpy as np
import matplotlib.pyplot as plt


img = data.cat()
labels_0 = segmentation.slic(img)
labels_1 = segmentation.slic(img.astype(np.float32))
fig, [ax0, ax1] = plt.subplots(1,2)
ax0.imshow(segmentation.mark_boundaries(img, labels_0))
ax1.imshow(segmentation.mark_boundaries(img, labels_1))
plt.show()
```


## Version information

```python
3.8.10 (default, Jun  2 2021, 10:49:15) 
[GCC 10.3.0]
Linux-5.8.0-63-generic-x86_64-with-glibc2.32
scikit-image version: 0.18.2
numpy version: 1.21.1
```



Discussion:
I think it is not so much the datatype, but the scaling that matters here. Internally `skimage.img_as_float(img)` results in rescaling the `uint8` image to floats in the range [0.0, 1.0]. The `.astype` call here converts to floats with values in the original integer range of `img`. This difference in absolute scale is the source of the difference.

Using the following gives `labels_1` that looks the same as `labels_0`
```Python
img_f32 = img.astype(np.float32)
img_f32 /= img_f32.max()
labels_1 = segmentation.slic(img_f32)
```

For this algorithm I think we should always just rescale internally to [0.0, 1.0] range so the default `compactness` value is a reasonable starting point. A few releases back `img_as_float` always rescaled to [0, 1.0] but in more recent releases it preserves the original floating point range. We do in most cases want to move toward preserving range, but this function only outputs labels anyway, so I think it is best to just the input to [0.0, 1.0] so that the default settings are reasonable regardless of the input data scale.


Resolving patch:
diff --git a/skimage/segmentation/slic_superpixels.py b/skimage/segmentation/slic_superpixels.py
--- a/skimage/segmentation/slic_superpixels.py
+++ b/skimage/segmentation/slic_superpixels.py
@@ -243,7 +243,15 @@ def slic(image, n_segments=100, compactness=10., max_num_iter=10, sigma=0,
 
     image = img_as_float(image)
     float_dtype = utils._supported_float_type(image.dtype)
-    image = image.astype(float_dtype, copy=False)
+    # copy=True so subsequent in-place operations do not modify the function input
+    image = image.astype(float_dtype, copy=True)
+
+    # Rescale image to [0, 1] to make choice of compactness insensitive to
+    # input image scale.
+    image -= image.min()
+    imax = image.max()
+    if imax != 0:
+        image /= imax
 
     use_mask = mask is not None
     dtype = image.dtype


Test patch:
diff --git a/skimage/segmentation/tests/test_slic.py b/skimage/segmentation/tests/test_slic.py
--- a/skimage/segmentation/tests/test_slic.py
+++ b/skimage/segmentation/tests/test_slic.py
@@ -4,6 +4,7 @@
 import pytest
 from numpy.testing import assert_equal
 
+from skimage import data, img_as_float
 from skimage._shared.testing import test_parallel, expected_warnings
 from skimage.segmentation import slic
 
@@ -101,6 +102,24 @@ def test_gray_2d_deprecated_multichannel():
                    start_label=0)
 
 
+def test_slic_consistency_across_image_magnitude():
+    # verify that that images of various scales across integer and float dtypes
+    # give the same segmentation result
+    img_uint8 = data.cat()[:256, :128]
+    img_uint16 = 256 * img_uint8.astype(np.uint16)
+    img_float32 = img_as_float(img_uint8)
+    img_float32_norm = img_float32 / img_float32.max()
+
+    seg1 = slic(img_uint8)
+    seg2 = slic(img_uint16)
+    seg3 = slic(img_float32)
+    seg4 = slic(img_float32_norm)
+
+    np.testing.assert_array_equal(seg1, seg2)
+    np.testing.assert_array_equal(seg1, seg3)
+    np.testing.assert_array_equal(seg1, seg4)
+
+
 def test_color_3d():
     rnd = np.random.RandomState(0)
     img = np.zeros((20, 21, 22, 3))