Repository: scikit-image/scikit-image

Issue:
funky behavior of `rescale_intensity` 
## Description
Looks like `rescale_intensity` does some weird stuff when the `in_range` argument has `np.int8` dtype. Unfortunately, this naturally arises when `in_range` is a result computed from the data to be rescaled. E.g., if I have an array of `int8` that I want to rescale, the min and max of that array will also be `int8`, which apparently induces weird output from `rescale_intensity`.   

## Way to reproduce
```python
from skimage.exposure import rescale_intensity
import numpy as np

out_range = 'uint8'
arr = np.array([-128, 0, 127]).astype('int16')

print("arr:", arr)

clims_a = (-128, 127)
a = rescale_intensity(arr, in_range=clims_a, out_range=out_range)
print("a:  ", a)

clims_b = (np.int8(-128), np.int8(127))
b = rescale_intensity(arr, in_range=clims_b, out_range=out_range)
print("b:  ", b)
```

## Version information
```python
3.8.1 (default, Jan  8 2020, 22:29:32) 
[GCC 7.3.0]
Linux-3.10.0-957.10.1.el7.x86_64-x86_64-with-glibc2.10
scikit-image version: 0.16.2
numpy version: 1.18.1
```

## Output
```python
arr: [-128    0  127]
a:   [  0 128 255]
b:   [     0 -32640    511]
```



Discussion:
That is indeed a crazy bug! Looks like overflow:

```python
In [7]: exposure.rescale_intensity(arr, in_range=clims_b, out_range='uint8')                                                                                                                  
/home/jni/projects/scikit-image/skimage/exposure/exposure.py:361: RuntimeWarning: overflow encountered in byte_scalars
  image = (image - imin) / float(imax - imin)
Out[7]: array([     0, -32640,    511], dtype=int16)

In [8]: exposure.rescale_intensity(arr2, in_range=clims_b, out_range='uint8')                                                                                                                 
/home/jni/projects/scikit-image/skimage/exposure/exposure.py:361: RuntimeWarning: overflow encountered in byte_scalars
  image = (image - imin) / float(imax - imin)
Out[8]: array([   0, -128,   -1], dtype=int8)
```

(arr2 is the same array but int8 type).

I mean, the weird and crazy thing is that, at a minimum, the output dtype should be the dtype requested, and we can't even do that. Thanks for reporting!
Ok, so the whole function is actually tiny, and imho not fit for purpose:

https://github.com/scikit-image/scikit-image/blob/9aac8495b7b9e50d8660c0e32f2301835bc1ddb2/skimage/exposure/exposure.py#L344-L362

Specifically:

- calculations are done in the input dtype (see `float(imax - imin)`), virtually guaranteeing overflow for signed integers
- the output is given in the same dtype as the input, even if a different dtype was selected for the output.

imho both of these things should be fixed, along with corresponding tests:

- computations should be in float. We end up in float anyway when doing the division, so this would not be a big hit on performance, and it is the only way (?) to prevent under/overflow errors without complicated logic about integer dtypes.
- if a dtype is given as the output range, the output image should be in that dtype.
- if a *range* is given as the output range... Well, I'm not sure what to do about the output range, but my intuition is that we should either make it float, *or*, based on the input dtypes, find the smallest dtype which contains the requested range. I lean towards float.

@scikit-image/core this should be an easyish PR to do, *once* a decision is made on all three points above. Any thoughts? I won't be at the Atlantic meeting but I've added this to the agenda for discussion. Thanks!
I am +3 on all suggestions, and avoiding magical dtype guessing.
@jni, @lagru noticed this additional problem:

```
Use `rescale_intensity` to rescale to the proper range for float dtypes:

>>> image_float = 1.0 * image
>>> rescale_intensity(image_float)
array([0. , 0.5, 1. ])
```

In the future, we want to move away from this kind of dtype based rescaling.  For now, we will probably not want to return floats out of the 0-1 range?
Also +1 about the three points.

Resolving patch:
diff --git a/skimage/exposure/_adapthist.py b/skimage/exposure/_adapthist.py
--- a/skimage/exposure/_adapthist.py
+++ b/skimage/exposure/_adapthist.py
@@ -65,6 +65,10 @@ def equalize_adapthist(image, kernel_size=None,
        - The image is converted back to RGB space and returned
     * For RGBA images, the original alpha channel is removed.
 
+    .. versionchanged:: 0.17
+        The values returned by this function are slightly shifted upwards
+        because of an internal change in rounding behavior.
+
     References
     ----------
     .. [1] http://tog.acm.org/resources/GraphicsGems/
@@ -75,7 +79,9 @@ def equalize_adapthist(image, kernel_size=None,
         return img_as_float(image)  # convert to float for consistency
 
     image = img_as_uint(image)
-    image = rescale_intensity(image, out_range=(0, NR_OF_GRAY - 1))
+    image = np.round(
+        rescale_intensity(image, out_range=(0, NR_OF_GRAY - 1))
+    ).astype(np.uint16)
 
     if kernel_size is None:
         kernel_size = (image.shape[0] // 8, image.shape[1] // 8)
diff --git a/skimage/exposure/exposure.py b/skimage/exposure/exposure.py
--- a/skimage/exposure/exposure.py
+++ b/skimage/exposure/exposure.py
@@ -266,6 +266,50 @@ def intensity_range(image, range_values='image', clip_negative=False):
     return i_min, i_max
 
 
+def _output_dtype(dtype_or_range):
+    """Determine the output dtype for rescale_intensity.
+
+    The dtype is determined according to the following rules:
+    - if ``dtype_or_range`` is a dtype, that is the output dtype.
+    - if ``dtype_or_range`` is a dtype string, that is the dtype used, unless
+      it is not a NumPy data type (e.g. 'uint12' for 12-bit unsigned integers),
+      in which case the data type that can contain it will be used
+      (e.g. uint16 in this case).
+    - if ``dtype_or_range`` is a pair of values, the output data type will be
+      float.
+
+    Parameters
+    ----------
+    dtype_or_range : type, string, or 2-tuple of int/float
+        The desired range for the output, expressed as either a NumPy dtype or
+        as a (min, max) pair of numbers.
+
+    Returns
+    -------
+    out_dtype : type
+        The data type appropriate for the desired output.
+    """
+    if type(dtype_or_range) in [list, tuple, np.ndarray]:
+        # pair of values: always return float.
+        return np.float_
+    if type(dtype_or_range) == type:
+        # already a type: return it
+        return dtype_or_range
+    if dtype_or_range in DTYPE_RANGE:
+        # string key in DTYPE_RANGE dictionary
+        try:
+            # if it's a canonical numpy dtype, convert
+            return np.dtype(dtype_or_range).type
+        except TypeError:  # uint10, uint12, uint14
+            # otherwise, return uint16
+            return np.uint16
+    else:
+        raise ValueError(
+            'Incorrect value for out_range, should be a valid image data '
+            f'type or a pair of values, got {dtype_or_range}.'
+        )
+
+
 def rescale_intensity(image, in_range='image', out_range='dtype'):
     """Return image after stretching or shrinking its intensity levels.
 
@@ -297,6 +341,12 @@ def rescale_intensity(image, in_range='image', out_range='dtype'):
         Image array after rescaling its intensity. This image is the same dtype
         as the input image.
 
+    Notes
+    -----
+    .. versionchanged:: 0.17
+        The dtype of the output array has changed to match the output dtype, or
+        float if the output range is specified by a pair of floats.
+
     See Also
     --------
     equalize_hist
@@ -334,17 +384,26 @@ def rescale_intensity(image, in_range='image', out_range='dtype'):
     array([0.5, 1. , 1. ])
 
     If you have an image with signed integers but want to rescale the image to
-    just the positive range, use the `out_range` parameter:
+    just the positive range, use the `out_range` parameter. In that case, the
+    output dtype will be float:
 
     >>> image = np.array([-10, 0, 10], dtype=np.int8)
     >>> rescale_intensity(image, out_range=(0, 127))
-    array([  0,  63, 127], dtype=int8)
+    array([  0. ,  63.5, 127. ])
 
+    To get the desired range with a specific dtype, use ``.astype()``:
+
+    >>> rescale_intensity(image, out_range=(0, 127)).astype(np.int8)
+    array([  0,  63, 127], dtype=int8)
     """
-    dtype = image.dtype.type
+    if out_range in ['dtype', 'image']:
+        out_dtype = _output_dtype(image.dtype.type)
+    else:
+        out_dtype = _output_dtype(out_range)
 
-    imin, imax = intensity_range(image, in_range)
-    omin, omax = intensity_range(image, out_range, clip_negative=(imin >= 0))
+    imin, imax = map(float, intensity_range(image, in_range))
+    omin, omax = map(float, intensity_range(image, out_range,
+                                            clip_negative=(imin >= 0)))
 
     # Fast test for multiple values, operations with at least 1 NaN return NaN
     if np.isnan(imin + imax + omin + omax):
@@ -358,8 +417,8 @@ def rescale_intensity(image, in_range='image', out_range='dtype'):
     image = np.clip(image, imin, imax)
 
     if imin != imax:
-        image = (image - imin) / float(imax - imin)
-    return np.asarray(image * (omax - omin) + omin, dtype=dtype)
+        image = (image - imin) / (imax - imin)
+    return np.asarray(image * (omax - omin) + omin, dtype=out_dtype)
 
 
 def _assert_non_negative(image):


Test patch:
diff --git a/skimage/exposure/tests/test_exposure.py b/skimage/exposure/tests/test_exposure.py
--- a/skimage/exposure/tests/test_exposure.py
+++ b/skimage/exposure/tests/test_exposure.py
@@ -237,10 +237,16 @@ def test_rescale_in_range_clip():
 
 
 def test_rescale_out_range():
+    """Check that output range is correct.
+
+    .. versionchanged:: 0.17
+        This function used to return dtype matching the input dtype. It now
+        matches the output.
+    """
     image = np.array([-10, 0, 10], dtype=np.int8)
     out = exposure.rescale_intensity(image, out_range=(0, 127))
-    assert out.dtype == np.int8
-    assert_array_almost_equal(out, [0, 63, 127])
+    assert out.dtype == np.float_
+    assert_array_almost_equal(out, [0, 63.5, 127])
 
 
 def test_rescale_named_in_range():
@@ -312,6 +318,40 @@ def test_rescale_nan_warning(in_range, out_range):
         exposure.rescale_intensity(image, in_range, out_range)
 
 
+@pytest.mark.parametrize(
+    "out_range, out_dtype", [
+        ('uint8', np.uint8),
+        ('uint10', np.uint16),
+        ('uint12', np.uint16),
+        ('uint16', np.uint16),
+        ('float', np.float_),
+    ]
+)
+def test_rescale_output_dtype(out_range, out_dtype):
+    image = np.array([-128, 0, 127], dtype=np.int8)
+    output_image = exposure.rescale_intensity(image, out_range=out_range)
+    assert output_image.dtype == out_dtype
+
+
+def test_rescale_no_overflow():
+    image = np.array([-128, 0, 127], dtype=np.int8)
+    output_image = exposure.rescale_intensity(image, out_range=np.uint8)
+    testing.assert_array_equal(output_image, [0, 128, 255])
+    assert output_image.dtype == np.uint8
+
+
+def test_rescale_float_output():
+    image = np.array([-128, 0, 127], dtype=np.int8)
+    output_image = exposure.rescale_intensity(image, out_range=(0, 255))
+    testing.assert_array_equal(output_image, [0, 128, 255])
+    assert output_image.dtype == np.float_
+
+
+def test_rescale_raises_on_incorrect_out_range():
+    image = np.array([-128, 0, 127], dtype=np.int8)
+    with testing.raises(ValueError):
+        _ = exposure.rescale_intensity(image, out_range='flat')
+
 # Test adaptive histogram equalization
 # ====================================
 
@@ -324,7 +364,7 @@ def test_adapthist_grayscale():
     adapted = exposure.equalize_adapthist(img, kernel_size=(57, 51),
                                           clip_limit=0.01, nbins=128)
     assert img.shape == adapted.shape
-    assert_almost_equal(peak_snr(img, adapted), 102.078, 3)
+    assert_almost_equal(peak_snr(img, adapted), 102.019, 3)
     assert_almost_equal(norm_brightness_err(img, adapted), 0.0529, 3)