Repository: scikit-image/scikit-image

Issue:
skimage.util.convert not converting to 'float32' with numpy v1.13.3
## Description

During my explorations for PR #4161 CI failure, I found that `skimage.util.convert` doesn't convert double precision images to single precision with `numpy v1.13.3` when called with `dtype='float32'`. Even more strange, it works when `dtype=np.float32`

## Way to reproduce

```python
>>> import numpy as np
>>> from skimage.util.dtype import convert
>>> a = np.random.rand(5, 5)
>>> convert(a, 'float32').dtype
dtype('float64')
>>> convert(a, np.float32).dtype
dtype('float32')
>>> np.__version__
'1.13.3'
```

This doesn't happen with more recent numpy version:

```python
>>> a = np.random.rand(5, 5)
>>> convert(a, 'float32').dtype
dtype('float32')
>>> convert(a, np.float32).dtype
dtype('float32')
>>> np.__version__
'1.16.4'
```

## Version information

```python
3.6.7 | packaged by conda-forge | (default, Jul  2 2019, 02:18:42) 
[GCC 7.3.0]
Linux-4.15.0-64-generic-x86_64-with-debian-buster-sid
scikit-image version: 0.16.dev0
numpy version: 1.13.3

```




Discussion:
@rfezzani Crazy outcome of your investigation! Can this issue be fixed by internally changing any `'float32'`s to `np.float32`?
"internally" meaning inside the "convert" function
I am checking this ;)
OK, the bug comes from `np.issubdtype` that was fixed in `numpy v1.14`:

Using `numpy v1.13`, I obtain:
```python
>>> a = np.random.rand(5)
>>> a.dtype
dtype('float64')
>>> np.issubdtype(a.dtype, 'float32')
True
>>> np.issubdtype(a.dtype, np.dtype('float32'))
True
>>> np.issubdtype(a.dtype, np.float32)
False
>>> np.issubdtype(a.dtype, np.dtype(np.float32))
True
```
There is no obvious fix for `convert`, but I will investigate more ;-)
Numpy 1.13.3 is basically dead. Numpy doesn't recommend supporting older than 1.14. So let's just bump out version number
What about using `dtypeobj_in.kind == dtypeobj_out.kind and np.can_cast(dtypeobj_in, dtypeobj_out)` instead of `np.issubdtype(dtypeobj_in, type_out)` in the convert function?
I can come with a PR for this if you agree with this solution...
@stefanv thoughts about bumping up NumPy dep?

@hmaarrfk I thought about that, but checked and NumPy 1.13 is only 2yo, which isn't that long. Having said that, if someone tried to support skimage from 2y ago simultaneously with today's skimage, they would not have fun, so perhaps we should move a bit faster.
[NEP 20 — Recommend Python and Numpy version support as a community policy standard](https://github.com/numpy/numpy/blob/e7bc0989ea9f99e070c8f54d23dbdd23b9703cda/doc/neps/nep-0029-deprecation_policy.rst)
```
On Jan 16, 2019 drop support for Numpy 1.12 (initially released on Jan 15, 2017)
On Mar 14, 2019 drop support for Python 3.5 (initially released on Sep 13, 2015)
On Jun 08, 2019 drop support for Numpy 1.13 (initially released on Jun 07, 2017)
On Jan 07, 2020 drop support for Numpy 1.14 (initially released on Jan 06, 2018)
On Jun 23, 2020 drop support for Python 3.6 (initially released on Dec 23, 2016)
On Jul 23, 2020 drop support for Numpy 1.15 (initially released on Jul 23, 2018)
On Jan 13, 2021 drop support for Numpy 1.16 (initially released on Jan 13, 2019)
On Jul 26, 2021 drop support for Numpy 1.17 (initially released on Jul 26, 2019)
On Dec 26, 2021 drop support for Python 3.7 (initially released on Jun 27, 2018)
```
By the way, there is code at the bottom of that NEP to calculate this table, so we can automate a check so that we automatically upgrade our NumPy and Python deps.

Resolving patch:
diff --git a/skimage/measure/_moments.py b/skimage/measure/_moments.py
--- a/skimage/measure/_moments.py
+++ b/skimage/measure/_moments.py
@@ -94,7 +94,8 @@ def moments_coords_central(coords, center=None, order=3):
     point, this no longer holds:
 
     >>> coords2 = np.concatenate((coords, [[17, 17]]), axis=0)
-    >>> np.round(moments_coords_central(coords2), 2)
+    >>> np.round(moments_coords_central(coords2),
+    ...          decimals=2)  # doctest: +NORMALIZE_WHITESPACE
     array([[ 17.  ,   0.  ,  22.12,  -2.49],
            [  0.  ,   3.53,   1.73,   7.4 ],
            [ 25.88,   6.02,  36.63,   8.83],
diff --git a/skimage/util/dtype.py b/skimage/util/dtype.py
--- a/skimage/util/dtype.py
+++ b/skimage/util/dtype.py
@@ -54,6 +54,125 @@ def dtype_limits(image, clip_negative=False):
     return imin, imax
 
 
+def _dtype_itemsize(itemsize, *dtypes):
+    """Return first of `dtypes` with itemsize greater than `itemsize`
+
+    Parameters
+    ----------
+    itemsize: int
+        The data type object element size.
+
+    Other Parameters
+    ----------------
+    *dtypes:
+        Any Object accepted by `np.dtype` to be converted to a data
+        type object
+
+    Returns
+    -------
+    dtype: data type object
+        First of `dtypes` with itemsize greater than `itemsize`.
+
+    """
+    return next(dt for dt in dtypes if np.dtype(dt).itemsize >= itemsize)
+
+
+def _dtype_bits(kind, bits, itemsize=1):
+    """Return dtype of `kind` that can store a `bits` wide unsigned int
+
+    Parameters:
+    kind: str
+        Data type kind.
+    bits: int
+        Desired number of bits.
+    itemsize: int
+        The data type object element size.
+
+    Returns
+    -------
+    dtype: data type object
+        Data type of `kind` that can store a `bits` wide unsigned int
+
+    """
+
+    s = next(i for i in (itemsize, ) + (2, 4, 8) if
+             bits < (i * 8) or (bits == (i * 8) and kind == 'u'))
+
+    return np.dtype(kind + str(s))
+
+
+def _scale(a, n, m, copy=True):
+    """Scale an array of unsigned/positive integers from `n` to `m` bits.
+
+    Numbers can be represented exactly only if `m` is a multiple of `n`.
+
+    Parameters
+    ----------
+    a : ndarray
+        Input image array.
+    n : int
+        Number of bits currently used to encode the values in `a`.
+    m : int
+        Desired number of bits to encode the values in `out`.
+    copy : bool, optional
+        If True, allocates and returns new array. Otherwise, modifies
+        `a` in place.
+
+    Returns
+    -------
+    out : array
+        Output image array. Has the same kind as `a`.
+    """
+    kind = a.dtype.kind
+    if n > m and a.max() < 2 ** m:
+        mnew = int(np.ceil(m / 2) * 2)
+        if mnew > m:
+            dtype = "int{}".format(mnew)
+        else:
+            dtype = "uint{}".format(mnew)
+        n = int(np.ceil(n / 2) * 2)
+        warn("Downcasting {} to {} without scaling because max "
+             "value {} fits in {}".format(a.dtype, dtype, a.max(), dtype),
+             stacklevel=3)
+        return a.astype(_dtype_bits(kind, m))
+    elif n == m:
+        return a.copy() if copy else a
+    elif n > m:
+        # downscale with precision loss
+        if copy:
+            b = np.empty(a.shape, _dtype_bits(kind, m))
+            np.floor_divide(a, 2**(n - m), out=b, dtype=a.dtype,
+                            casting='unsafe')
+            return b
+        else:
+            a //= 2**(n - m)
+            return a
+    elif m % n == 0:
+        # exact upscale to a multiple of `n` bits
+        if copy:
+            b = np.empty(a.shape, _dtype_bits(kind, m))
+            np.multiply(a, (2**m - 1) // (2**n - 1), out=b, dtype=b.dtype)
+            return b
+        else:
+            a = a.astype(_dtype_bits(kind, m, a.dtype.itemsize), copy=False)
+            a *= (2**m - 1) // (2**n - 1)
+            return a
+    else:
+        # upscale to a multiple of `n` bits,
+        # then downscale with precision loss
+        o = (m // n + 1) * n
+        if copy:
+            b = np.empty(a.shape, _dtype_bits(kind, o))
+            np.multiply(a, (2**o - 1) // (2**n - 1), out=b, dtype=b.dtype)
+            b //= 2**(o - m)
+            return b
+        else:
+            a = a.astype(_dtype_bits(kind, o, a.dtype.itemsize), copy=False)
+            a *= (2**o - 1) // (2**n - 1)
+            a //= 2**(o - m)
+            return a
+
+
 def convert(image, dtype, force_copy=False, uniform=False):
     """
     Convert an image to the requested data-type.
@@ -121,9 +240,7 @@ def convert(image, dtype, force_copy=False, uniform=False):
     #   is a subclass of that type (e.g. `np.floating` will allow
     #   `float32` and `float64` arrays through)
 
-    type_out = dtype if isinstance(dtype, type) else dtypeobj_out
-
-    if np.issubdtype(dtypeobj_in, type_out):
+    if np.issubdtype(dtype_in, np.obj2sctype(dtype)):
         if force_copy:
             image = image.copy()
         return image
@@ -132,93 +249,6 @@ def convert(image, dtype, force_copy=False, uniform=False):
         raise ValueError("Can not convert from {} to {}."
                          .format(dtypeobj_in, dtypeobj_out))
 
-    def _dtype_itemsize(itemsize, *dtypes):
-        # Return first of `dtypes` with itemsize greater than `itemsize`
-        return next(dt for dt in dtypes if np.dtype(dt).itemsize >= itemsize)
-
-    def _dtype_bits(kind, bits, itemsize=1):
-        # Return dtype of `kind` that can store a `bits` wide unsigned int
-        def compare(x, y, kind='u'):
-            if kind == 'u':
-                return x <= y
-            else:
-                return x < y
-
-        s = next(i for i in (itemsize, ) + (2, 4, 8) if compare(bits, i * 8,
-                                                                kind=kind))
-        return np.dtype(kind + str(s))
-
-    def _scale(a, n, m, copy=True):
-        """Scale an array of unsigned/positive integers from `n` to `m` bits.
-
-        Numbers can be represented exactly only if `m` is a multiple of `n`.
-
-        Parameters
-        ----------
-        a : ndarray
-            Input image array.
-        n : int
-            Number of bits currently used to encode the values in `a`.
-        m : int
-            Desired number of bits to encode the values in `out`.
-        copy : bool, optional
-            If True, allocates and returns new array. Otherwise, modifies
-            `a` in place.
-
-        Returns
-        -------
-        out : array
-            Output image array. Has the same kind as `a`.
-        """
-        kind = a.dtype.kind
-        if n > m and a.max() < 2 ** m:
-            mnew = int(np.ceil(m / 2) * 2)
-            if mnew > m:
-                dtype = "int{}".format(mnew)
-            else:
-                dtype = "uint{}".format(mnew)
-            n = int(np.ceil(n / 2) * 2)
-            warn("Downcasting {} to {} without scaling because max "
-                 "value {} fits in {}".format(a.dtype, dtype, a.max(), dtype),
-                 stacklevel=3)
-            return a.astype(_dtype_bits(kind, m))
-        elif n == m:
-            return a.copy() if copy else a
-        elif n > m:
-            # downscale with precision loss
-            if copy:
-                b = np.empty(a.shape, _dtype_bits(kind, m))
-                np.floor_divide(a, 2**(n - m), out=b, dtype=a.dtype,
-                                casting='unsafe')
-                return b
-            else:
-                a //= 2**(n - m)
-                return a
-        elif m % n == 0:
-            # exact upscale to a multiple of `n` bits
-            if copy:
-                b = np.empty(a.shape, _dtype_bits(kind, m))
-                np.multiply(a, (2**m - 1) // (2**n - 1), out=b, dtype=b.dtype)
-                return b
-            else:
-                a = a.astype(_dtype_bits(kind, m, a.dtype.itemsize), copy=False)
-                a *= (2**m - 1) // (2**n - 1)
-                return a
-        else:
-            # upscale to a multiple of `n` bits,
-            # then downscale with precision loss
-            o = (m // n + 1) * n
-            if copy:
-                b = np.empty(a.shape, _dtype_bits(kind, o))
-                np.multiply(a, (2**o - 1) // (2**n - 1), out=b, dtype=b.dtype)
-                b //= 2**(o - m)
-                return b
-            else:
-                a = a.astype(_dtype_bits(kind, o, a.dtype.itemsize), copy=False)
-                a *= (2**o - 1) // (2**n - 1)
-                a //= 2**(o - m)
-                return a
-
     if kind_in in 'ui':
         imin_in = np.iinfo(dtype_in).min
         imax_in = np.iinfo(dtype_in).max


Test patch:
diff --git a/skimage/util/tests/test_dtype.py b/skimage/util/tests/test_dtype.py
--- a/skimage/util/tests/test_dtype.py
+++ b/skimage/util/tests/test_dtype.py
@@ -132,16 +132,46 @@ def test_clobber():
 
             img_in = func_input_type(img)
             img_in_before = img_in.copy()
-            img_out = func_output_type(img_in)
+            func_output_type(img_in)
 
             assert_equal(img_in, img_in_before)
 
+
 def test_signed_scaling_float32():
     x = np.array([-128,  127], dtype=np.int8)
     y = img_as_float32(x)
     assert_equal(y.max(), 1)
 
+
 def test_float32_passthrough():
     x = np.array([-1, 1], dtype=np.float32)
     y = img_as_float(x)
     assert_equal(y.dtype, x.dtype)
+
+
+float_dtype_list = [float, np.float, np.double, np.single, np.float32,
+                    np.float64, 'float32', 'float64']
+
+
+def test_float_conversion_dtype():
+    """Test any convertion from a float dtype to an other."""
+    x = np.array([-1, 1])
+
+    # Test all combinations of dtypes convertions
+    dtype_combin = np.array(np.meshgrid(float_dtype_list,
+                                        float_dtype_list)).T.reshape(-1, 2)
+
+    for dtype_in, dtype_out in dtype_combin:
+        x = x.astype(dtype_in)
+        y = convert(x, dtype_out)
+        assert y.dtype == np.dtype(dtype_out)
+
+
+def test_subclass_conversion():
+    """Check subclass conversion behavior"""
+    x = np.array([-1, 1])
+
+    for dtype in float_dtype_list:
+        x = x.astype(dtype)
+        y = convert(x, np.floating)
+        assert y.dtype == x.dtype