Repository: scikit-image/scikit-image

Issue:
h_maxima/minima strange behaviors on floating point image input
## Description
I have been trying to use the h_maxima implementation but this function seems to exhibit unstable behaviors. For example, 

1. when the input is all zeros, h_maxima returns a marker filled with all 1s. This seems incorrect.

2. Also, when my input consists of floating point values, and h set to a floating point threshold, even with a slight 0.1 change in h value, the number of detected instances can vary quite a bit. 

## Way to reproduce
```python
img = np.zeros((10, 10), dtype=float)
marker = h_maxima(img, h=1)
print(np.unique(marker))
```


## 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.6.8 |Anaconda custom (64-bit)| (default, Dec 30 2018, 01:22:34)
[GCC 7.3.0]
Linux-3.10.0-957.5.1.el7.x86_64-x86_64-with-centos-7.6.1810-Core
scikit-image version: 0.15.0
numpy version: 1.16.4

```




Discussion:
@w-hc thanks for the report!

I don't have time to investigate in detail right now, but

1. It's unclear to me whether this is a bug or an ambiguous case... I'll have to think more about it.
2. Without more detail, that is, a specific test image and some code to that shows the maxima "varying quite a bit", we won't be able to help you in this case. It's quite probably that the output is correct for your image. The only guarantee that you can have for h_maxima is that the number of detected maxima should increase with decreasing h.
Here is script and sample image that can reproduce what I see. You need to uncompress this zip file to get the numpy array.

```python
import numpy as np
from skimage.morphology import h_maxima
from scipy import ndimage as ndi
heatmap = np.load('heatmap.npy')
assert heatmap.dtype == np.float32
for h in np.linspace(0.2, 4.0, num=10):
    num_instances = ndi.label(h_maxima(heatmap, h))[1]
    print('at h= {:.3f}, discovered {} instances'.format(h, num_instances))
```

Output:
```
at h= 0.200, discovered 17 instances
at h= 0.622, discovered 25 instances
at h= 1.044, discovered 17 instances
at h= 1.467, discovered 25 instances
at h= 1.889, discovered 17 instances
at h= 2.311, discovered 24 instances
at h= 2.733, discovered 16 instances
at h= 3.156, discovered 23 instances
at h= 3.578, discovered 21 instances
at h= 4.000, discovered 23 instances
```

The correct number of local maxima should be 25.

[heatmap.npy.zip](https://github.com/scikit-image/scikit-image/files/3672479/heatmap.npy.zip)

Here is what this floating array looks like when it is plotted with plt.imshow
<img width="325" alt="Screen Shot 2019-09-30 at 12 58 26" src="https://user-images.githubusercontent.com/17956191/65901897-20175180-e387-11e9-863c-7f9522a38cb7.png">


@w-hc 

> The correct number of local maxima should be 25.

well then just use 0.622, what's the problem??? :joy: 

Kidding of course. Yikes! Either I misunderstand `h_maxima` or this is indeed a significant bug. Thank you so much for the report and for the test case!
Yep, definitely a bug. By computing a bunch of results, displaying them in napari, and slicing through them, I can see individiual maxima blinking on and off, instead of being off and staying off:

```python
import numpy as np
from skimage.morphology import h_maxima as hmaxima
import napari

image = np.load('heatmap.npy')
# use this in ipython --gui=qt, or in a `with napari.gui_qt():` code block
viewer = napari.view_image(image, name='original', colormap='green', blending='additive')
result = np.array([hmaxima(image, f) for f in np.linspace(0.2, 100.0, num=100)])
res_layer = viewer.add_image(result, name='result', colormap='magenta', blending='additive')
```
I suspect that this has to do with the handling of the `resolution` variable here:

https://github.com/scikit-image/scikit-image/blob/e28ccfbd2abf818c1d972bd6b89c873be2f959b3/skimage/morphology/extrema.py#L114-L118

One concerning thing is that if the image is float32, which it is here, `resolution / 2` gets cast to float64, and `h - resolution / 2` is float64, while `image - h` is float32. I don't know whether this will then cause problems but it's plausible that it would.
I tried changing the heatmap dtype to float64 but the error persists, albeit with different number of maxima.

```python
import numpy as np
from skimage.morphology import h_maxima
from scipy import ndimage as ndi
heatmap = np.load('heatmap.npy').astype(np.float64)
assert heatmap.dtype == np.float64
for h in np.linspace(0.2, 4.0, num=10):
    num_instances = ndi.label(h_maxima(heatmap, h))[1]
    print('at h= {:.3f}, discovered {} instances'.format(h, num_instances))
```

```
at h= 0.200, discovered 25 instances
at h= 0.622, discovered 12 instances
at h= 1.044, discovered 25 instances
at h= 1.467, discovered 21 instances
at h= 1.889, discovered 11 instances
at h= 2.311, discovered 14 instances
at h= 2.733, discovered 23 instances
at h= 3.156, discovered 16 instances
at h= 3.578, discovered 17 instances
at h= 4.000, discovered 23 instances
```
Just to confirm, the intensity parameter in `peak_local_max` works differently than h in `h_maxima`, right? 

I have been trying to understand the exact meaning of `h_maxima` and find the doc in skimage quite difficult to parse. 


A local maximum M of height h is a local maximum for which there is at least one path joining M with a higher maximum on which the minimal value is f(M) - h (i.e. the values along the path are not decreasing by more than h with respect to the maximum’s value) and no path for which the minimal value is greater.

After a bit of research, my current understanding is that `h_maxima` is equivalent to:
1. Finding all morphological local maxima
2. Throw away those maxima whose 'height' <= h
    where 'height' is defined as how much you can monotonically descend from this local peak without rising up again. 

It is implemented by morphological reconstruction but in effect it is equivalent to what I described above. (disregarding the fact that the heights of remaining maxima would decrement by h)

However, this understanding seems quite different from what the wordings of the doc imply. 
It looks like a rounding error, if you multiply everything by 10 you get more sensible results.

```
tmp = (10.0 * heatmap)

for h in np.linspace(2, 40, num=10):
    num_instances = ndi.label(h_maxima(tmp, int(h)))[1]
    print('at h= {:.3f}, discovered {} instances'.format(h, num_instances))
```

```
at h= 2.000, discovered 25 instances
at h= 6.222, discovered 25 instances
at h= 10.444, discovered 25 instances
at h= 14.667, discovered 25 instances
at h= 18.889, discovered 24 instances
at h= 23.111, discovered 24 instances
at h= 27.333, discovered 23 instances
at h= 31.556, discovered 23 instances
at h= 35.778, discovered 23 instances
at h= 40.000, discovered 23 instances
```
This is very ad-hoc, but if you change `h_maxima` in `morphology/extrema.py` to:

```
        resolution = 2 * numpy.finfo(image.dtype).resolution
        if h < resolution:
            h = resolution
        h_corrected = h - 2 * resolution
        shifted_img = image - h
```

Then the example works as expected.

Edit. I think you also need to scale resolution based on the magnitude of h for this to work properly.
@HazenBabcock thanks a lot for taking a look! Are you interested in working in a pull request in order to correct this bug? We can help you of course in this process, if you wish.
@emmanuelle Sure, happy to try.

Great, thank you so much :-)! One difficult part is maybe to add a unit test which would fail currently and pass if you can fix the bug, but if the current test suite passes and the example of @w-hc is fixed (when testing manually), this will definitely be an improvement.

Resolving patch:
diff --git a/skimage/morphology/extrema.py b/skimage/morphology/extrema.py
--- a/skimage/morphology/extrema.py
+++ b/skimage/morphology/extrema.py
@@ -96,7 +96,7 @@ def h_maxima(image, h, selem=None):
 
     We create an image (quadratic function with a maximum in the center and
     4 additional constant maxima.
-    The heights of the maxima are: 1, 21, 41, 61, 81, 101
+    The heights of the maxima are: 1, 21, 41, 61, 81
 
     >>> w = 10
     >>> x, y = np.mgrid[0:w,0:w]
@@ -108,24 +108,68 @@ def h_maxima(image, h, selem=None):
 
     >>> maxima = extrema.h_maxima(f, 40)
 
-    The resulting image will contain 4 local maxima.
+    The resulting image will contain 3 local maxima.
     """
+
+    # Check for h value that is larger then range of the image. If this
+    # is True then there are no h-maxima in the image.
+    if h > np.ptp(image):
+        return np.zeros(image.shape, dtype=np.uint8)
+
+    # Check for floating point h value. For this to work properly
+    # we need to explicitly convert image to float64.
+    #
+    # FIXME: This could give incorrect results if image is int64 and
+    #        has a very high dynamic range. The dtype of image is
+    #        changed to float64, and different integer values could
+    #        become the same float due to rounding.
+    #
+    #   >>> ii64 = np.iinfo(np.int64)
+    #   >>> a = np.array([ii64.max, ii64.max - 2])
+    #   >>> a[0] == a[1]
+    #   False
+    #   >>> b = a.astype(np.float64)
+    #   >>> b[0] == b[1]
+    #   True
+    #
+    if np.issubdtype(type(h), np.floating) and \
+       np.issubdtype(image.dtype, np.integer):
+        if ((h % 1) != 0):
+            warn('possible precision loss converting image to '
+                 'floating point. To silence this warning, '
+                 'ensure image and h have same data type.',
+                 stacklevel=2)
+            image = image.astype(np.float_)
+        else:
+            h = image.dtype.type(h)
+
+    if (h == 0):
+        raise ValueError("h = 0 is ambiguous, use local_maxima() "
+                         "instead?")
+
     if np.issubdtype(image.dtype, np.floating):
-        resolution = 2 * np.finfo(image.dtype).resolution
-        if h < resolution:
-            h = resolution
-        h_corrected = h - resolution / 2.0
-        shifted_img = image - h
+        # The purpose of the resolution variable is to allow for the
+        # small rounding errors that inevitably occur when doing
+        # floating point arithmetic. We want shifted_img to be
+        # guaranteed to be h less than image. If we only subtract h
+        # there may be pixels were shifted_img ends up being
+        # slightly greater than image - h.
+        #
+        # The resolution is scaled based on the pixel values in the
+        # image because floating point precision is relative. A
+        # very large value of 1.0e10 will have a large precision,
+        # say +-1.0e4, and a very small value of 1.0e-10 will have
+        # a very small precision, say +-1.0e-16.
+        #
+        resolution = 2 * np.finfo(image.dtype).resolution * np.abs(image)
+        shifted_img = image - h - resolution
     else:
         shifted_img = _subtract_constant_clip(image, h)
-        h_corrected = h
 
     rec_img = greyreconstruct.reconstruction(shifted_img, image,
                                              method='dilation', selem=selem)
     residue_img = image - rec_img
-    h_max = np.zeros(image.shape, dtype=np.uint8)
-    h_max[residue_img >= h_corrected] = 1
-    return h_max
+    return (residue_img >= h).astype(np.uint8)
 
 
 def h_minima(image, h, selem=None):
@@ -177,7 +221,7 @@ def h_minima(image, h, selem=None):
 
     We create an image (quadratic function with a minimum in the center and
     4 additional constant maxima.
-    The depth of the minima are: 1, 21, 41, 61, 81, 101
+    The depth of the minima are: 1, 21, 41, 61, 81
 
     >>> w = 10
     >>> x, y = np.mgrid[0:w,0:w]
@@ -189,24 +233,36 @@ def h_minima(image, h, selem=None):
 
     >>> minima = extrema.h_minima(f, 40)
 
-    The resulting image will contain 4 local minima.
+    The resulting image will contain 3 local minima.
     """
+    if h > np.ptp(image):
+        return np.zeros(image.shape, dtype=np.uint8)
+
+    if np.issubdtype(type(h), np.floating) and \
+       np.issubdtype(image.dtype, np.integer):
+        if ((h % 1) != 0):
+            warn('possible precision loss converting image to '
+                 'floating point. To silence this warning, '
+                 'ensure image and h have same data type.',
+                 stacklevel=2)
+            image = image.astype(np.float_)
+        else:
+            h = image.dtype.type(h)
+
+    if (h == 0):
+        raise ValueError("h = 0 is ambiguous, use local_minima() "
+                         "instead?")
+
     if np.issubdtype(image.dtype, np.floating):
-        resolution = 2 * np.finfo(image.dtype).resolution
-        if h < resolution:
-            h = resolution
-        h_corrected = h - resolution / 2.0
-        shifted_img = image + h
+        resolution = 2 * np.finfo(image.dtype).resolution * np.abs(image)
+        shifted_img = image + h + resolution
     else:
         shifted_img = _add_constant_clip(image, h)
-        h_corrected = h
 
     rec_img = greyreconstruct.reconstruction(shifted_img, image,
                                              method='erosion', selem=selem)
     residue_img = rec_img - image
-    h_min = np.zeros(image.shape, dtype=np.uint8)
-    h_min[residue_img >= h_corrected] = 1
-    return h_min
+    return (residue_img >= h).astype(np.uint8)
 
 
 def _set_edge_values_inplace(image, value):


Test patch:
diff --git a/skimage/morphology/tests/test_extrema.py b/skimage/morphology/tests/test_extrema.py
--- a/skimage/morphology/tests/test_extrema.py
+++ b/skimage/morphology/tests/test_extrema.py
@@ -171,6 +171,120 @@ def test_extrema_float(self):
         error = diff(expected_result, out)
         assert error < eps
 
+    def test_h_maxima_float_image(self):
+        """specific tests for h-maxima float image type"""
+        w = 10
+        x, y = np.mgrid[0:w, 0:w]
+        data = 20 - 0.2 * ((x - w / 2) ** 2 + (y - w / 2) ** 2)
+        data[2:4, 2:4] = 40
+        data[2:4, 7:9] = 60
+        data[7:9, 2:4] = 80
+        data[7:9, 7:9] = 100
+        data = data.astype(np.float32)
+
+        expected_result = np.zeros_like(data)
+        expected_result[(data > 19.9)] = 1.0
+
+        for h in [1.0e-12, 1.0e-6, 1.0e-3, 1.0e-2, 1.0e-1, 0.1]:
+            out = extrema.h_maxima(data, h)
+            error = diff(expected_result, out)
+            assert error < eps
+
+    def test_h_maxima_float_h(self):
+        """specific tests for h-maxima float h parameter"""
+        data = np.array([[0, 0, 0, 0, 0],
+                         [0, 3, 3, 3, 0],
+                         [0, 3, 4, 3, 0],
+                         [0, 3, 3, 3, 0],
+                         [0, 0, 0, 0, 0]], dtype=np.uint8)
+
+        h_vals = np.linspace(1.0, 2.0, 100)
+        failures = 0
+        for i in range(h_vals.size):
+            maxima = extrema.h_maxima(data, h_vals[i])
+
+            if (maxima[2, 2] == 0):
+                failures += 1
+
+        assert (failures == 0)
+
+    def test_h_maxima_large_h(self):
+        """test that h-maxima works correctly for large h"""
+        data = np.array([[10, 10, 10, 10, 10],
+                         [10, 13, 13, 13, 10],
+                         [10, 13, 14, 13, 10],
+                         [10, 13, 13, 13, 10],
+                         [10, 10, 10, 10, 10]], dtype=np.uint8)
+
+        maxima = extrema.h_maxima(data, 5)
+        assert (np.sum(maxima) == 0)
+
+        data = np.array([[10, 10, 10, 10, 10],
+                         [10, 13, 13, 13, 10],
+                         [10, 13, 14, 13, 10],
+                         [10, 13, 13, 13, 10],
+                         [10, 10, 10, 10, 10]], dtype=np.float32)
+
+        maxima = extrema.h_maxima(data, 5.0)
+        assert (np.sum(maxima) == 0)
+
+    def test_h_minima_float_image(self):
+        """specific tests for h-minima float image type"""
+        w = 10
+        x, y = np.mgrid[0:w, 0:w]
+        data = 180 + 0.2 * ((x - w / 2) ** 2 + (y - w / 2) ** 2)
+        data[2:4, 2:4] = 160
+        data[2:4, 7:9] = 140
+        data[7:9, 2:4] = 120
+        data[7:9, 7:9] = 100
+        data = data.astype(np.float32)
+
+        expected_result = np.zeros_like(data)
+        expected_result[(data < 180.1)] = 1.0
+
+        for h in [1.0e-12, 1.0e-6, 1.0e-3, 1.0e-2, 1.0e-1, 0.1]:
+            out = extrema.h_minima(data, h)
+            error = diff(expected_result, out)
+            assert error < eps
+
+    def test_h_minima_float_h(self):
+        """specific tests for h-minima float h parameter"""
+        data = np.array([[4, 4, 4, 4, 4],
+                         [4, 1, 1, 1, 4],
+                         [4, 1, 0, 1, 4],
+                         [4, 1, 1, 1, 4],
+                         [4, 4, 4, 4, 4]], dtype=np.uint8)
+
+        h_vals = np.linspace(1.0, 2.0, 100)
+        failures = 0
+        for i in range(h_vals.size):
+            minima = extrema.h_minima(data, h_vals[i])
+
+            if (minima[2, 2] == 0):
+                failures += 1
+
+        assert (failures == 0)
+
+    def test_h_minima_large_h(self):
+        """test that h-minima works correctly for large h"""
+        data = np.array([[14, 14, 14, 14, 14],
+                         [14, 11, 11, 11, 14],
+                         [14, 11, 10, 11, 14],
+                         [14, 11, 11, 11, 14],
+                         [14, 14, 14, 14, 14]], dtype=np.uint8)
+
+        maxima = extrema.h_minima(data, 5)
+        assert (np.sum(maxima) == 0)
+
+        data = np.array([[14, 14, 14, 14, 14],
+                         [14, 11, 11, 11, 14],
+                         [14, 11, 10, 11, 14],
+                         [14, 11, 11, 11, 14],
+                         [14, 14, 14, 14, 14]], dtype=np.float32)
+
+        maxima = extrema.h_minima(data, 5.0)
+        assert (np.sum(maxima) == 0)
+
 
 class TestLocalMaxima(unittest.TestCase):
     """Some tests for local_minima are included as well."""