Repository: scikit-image/scikit-image

Issue:
threshold_li fails for "zeros" input images due to undefined `threshold`
## Description

When running `skimage.filters.threshold_li`, if the input image is all 0, the 
`tolerance` is set to zero causing the while loop never to execute and a somewhat cryptic error is
encountered because `threshold` never gets set. 

A typical traceback is 

```
Traceback (most recent call last):
  File "test.py", line 3, in <module>
    threshold_li(np.zeros((100,100)))
  File "./skimage/filters/thresholding.py", line 509, in threshold_li
    return threshold + immin
UnboundLocalError: local variable 'threshold' referenced before assignment
```

I recognise that this is a special case, but I just wanted to raise this issue as a way of
asking 

> do we want this somewhat cryptic error, or should we catch this 
> using a check on the input image"?  
## Way to reproduce

All that's needed to reproduce this (Python 3.4.1, skimage 0.13dev) is: 

```
import numpy as np
from skimage.filters import threshold_li
threshold_li(np.zeros((100,100)))
```



Discussion:
Closed via #2338.

@stefanv huh? Why did you reopen?

Probably, is a missclick. I _actually_ do think that this is closed in #2338.
Please, feel free to reopen with an explanation.

A zombie issue coming back to haunt us!

```python
import numpy as np
from skimage import filters
filters.threshold_li(np.zeros((5, 5)) / 0)
```

```pytb
/home/jni/miniconda3/envs/cf/bin/ipython:1: RuntimeWarning: invalid value encountered in true_divide
  #!/home/jni/miniconda3/envs/cf/bin/python
/home/jni/miniconda3/envs/cf/lib/python3.6/site-packages/numpy/core/_methods.py:29: RuntimeWarning: invalid value encountered in reduce
  return umr_minimum(a, axis, None, out, keepdims)
/home/jni/miniconda3/envs/cf/lib/python3.6/site-packages/numpy/core/_methods.py:26: RuntimeWarning: invalid value encountered in reduce
  return umr_maximum(a, axis, None, out, keepdims)
---------------------------------------------------------------------------
UnboundLocalError                         Traceback (most recent call last)
<ipython-input-11-e57608735ae9> in <module>()
----> 1 filters.threshold_li(np.zeros((5, 5)) / 0)

~/projects/scikit-image/skimage/filters/thresholding.py in threshold_li(image)
    532             new_thresh = temp + tolerance
    533 
--> 534     return threshold + immin
    535 
    536 

UnboundLocalError: local variable 'threshold' referenced before assignment
```


Discovered while working on a failing CellProfiler pipeline. Fun fact: the source of the nan image was also us:

```python
>>> import numpy as np
>>> from skimage import exposure
>>> exposure.rescale_intensity(np.zeros((5, 5))
/home/jni/projects/scikit-image/skimage/exposure/exposure.py:296: RuntimeWarning: invalid value encountered in true_divide
  image = (image - imin) / float(imax - imin)
array([[nan, nan, nan, nan, nan],
       [nan, nan, nan, nan, nan],
       [nan, nan, nan, nan, nan],
       [nan, nan, nan, nan, nan],
       [nan, nan, nan, nan, nan]])

``` 

@bethac07 suggests that we could return the same image back if the range is 0 in rescale intensity. Thoughts?
@jni - this is because 

```python
    np.nan == np.nan
```

returns `False` (which is essentially the check I added in #2338 ). 

I don't think checking the range will work here as expected, or at least e.g. `np.ptp` returns `nan` on a `nan` input array. 

Also, even if we were to use `nan`-aware functions, such as `nanmin` or `nanmax`, most threshold functions would likely still choke with **any** `nan` elements.  

I would suggest checking for any `nan` (e.g. `np.any(np.isnan(image))`) and handling that explicitly.  

@jmetz yeah I traced it back to that. Honestly in addition to fixing this, I want to propose that we stop raising ValueErrors on flat images, and instead return the single value of the image. (In this case, it would be `np.nan`. But in cases with single nans, they would be ignored.

As a side note, in addition to the above check, `UnboundLocalError` signals an antipattern. In this case, it should be impossible to reach the return statement without declaring threshold. It should be set to *some* value above the while loop.
@jni it's not clear to me what the most sensible treatment of flat images would be, though returning the single value sounds like a reasonable approach. 

If `nan`s interspersed with non-`nan`s should be ignored we could 

a) use `np.ma.masked_invalid` to return a masked version of the image with all invalid values masked
b) go more basic and perform something like `image = image[np.isfinite(image)]` 

I totally agree regarding the `UnboundLocalError` - in fact you've uncovered a bug in my port of the algorithm (https://github.com/fiji/Auto_Threshold/blob/Auto_Threshold-1.16.5/src/main/java/fiji/threshold/Auto_Threshold.java) - the ImageJ version used a `do-while` which I've replaced by a `while`. 

In summary, I'd suggest that perhaps the points raised constitute **2 new issues** instead of re-opening and adding to this issue. Basically they should be something like

a) Threshold functions not handling `nan`s in images. 
b) `threshold_li` `while` loop fails to execute causing `UnboundLocalError`. 

We could further split a) into the case of all `nan`s vs some `nan`s if desired - I'm not sure what level of granularity is preferred here?
On Mon, 10 Sep 2018 14:42:04 +0000, Jeremy Metz wrote:
> @jni it's not clear to me what the most sensible treatment of flat
> images would be, though returning the single value sounds like a
> reasonable approach.

I cannot think of any other return value that would make sense.  And
throwing an error feels unnecessary.  The user can also interpret that
threshold in a way that makes sense:

```
image > T   # all False
image <= T  # all True
```

> a) Threshold functions not handling `nan`s in images. 
> b) `threshold_li` `while` loop fails to execute causing
> `UnboundLocalError`.

+1 for creating these two issues and addressing them separately,
possibly (b) first.



Resolving patch:
diff --git a/skimage/filters/thresholding.py b/skimage/filters/thresholding.py
--- a/skimage/filters/thresholding.py
+++ b/skimage/filters/thresholding.py
@@ -459,14 +459,74 @@ def threshold_isodata(image, nbins=256, return_all=False):
         return thresholds[0]
 
 
-def threshold_li(image):
-    """Return threshold value based on adaptation of Li's Minimum Cross Entropy method.
+# Computing a histogram using np.histogram on a uint8 image with bins=256
+# doesn't work and results in aliasing problems. We use a fully specified set
+# of bins to ensure that each uint8 value false into its own bin.
+_DEFAULT_ENTROPY_BINS = tuple(np.arange(-0.5, 255.51, 1))
+
+
+def _cross_entropy(image, threshold, bins=_DEFAULT_ENTROPY_BINS):
+    """Compute cross-entropy between distributions above and below a threshold.
 
     Parameters
     ----------
-    image : (N, M) ndarray
+    image : array
+        The input array of values.
+    threshold : float
+        The value dividing the foreground and background in ``image``.
+    bins : int or array of float, optional
+        The number of bins or the bin edges. (Any valid value to the ``bins``
+        argument of ``np.histogram`` will work here.) For an exact calculation,
+        each unique value should have its own bin. The default value for bins
+        ensures exact handling of uint8 images: ``bins=256`` results in
+        aliasing problems due to bin width not being equal to 1.
+
+    Returns
+    -------
+    nu : float
+        The cross-entropy target value as defined in [1]_.
+
+    Notes
+    -----
+    See Li and Lee, 1993 [1]_; this is the objective function `threshold_li`
+    minimizes. This function can be improved but this implementation most
+    closely matches equation 8 in [1]_ and equations 1-3 in [2]_.
+
+    References
+    ----------
+    .. [1] Li C.H. and Lee C.K. (1993) "Minimum Cross Entropy Thresholding"
+           Pattern Recognition, 26(4): 617-625
+           :DOI:`10.1016/0031-3203(93)90115-D`
+    .. [2] Li C.H. and Tam P.K.S. (1998) "An Iterative Algorithm for Minimum
+           Cross Entropy Thresholding" Pattern Recognition Letters, 18(8): 771-776
+           :DOI:`10.1016/S0167-8655(98)00057-9`
+    """
+    histogram, bin_edges = np.histogram(image, bins=bins, density=True)
+    bin_centers = np.convolve(bin_edges, [0.5, 0.5], mode='valid')
+    t = np.flatnonzero(bin_centers > threshold)[0]
+    m0a = np.sum(histogram[:t])  # 0th moment, background
+    m0b = np.sum(histogram[t:])
+    m1a = np.sum(histogram[:t] * bin_centers[:t])  # 1st moment, background
+    m1b = np.sum(histogram[t:] * bin_centers[t:])
+    mua = m1a / m0a  # mean value, background
+    mub = m1b / m0b
+    nu = -m1a * np.log(mua) - m1b * np.log(mub)
+    return nu
+
+
+def threshold_li(image, *, tolerance=None):
+    """Compute threshold value by Li's iterative Minimum Cross Entropy method.
+
+    Parameters
+    ----------
+    image : ndarray
         Input image.
 
+    tolerance : float, optional
+        Finish the computation when the change in the threshold in an iteration
+        is less than this value. By default, this is half of the range of the
+        input image, divided by 256.
+
     Returns
     -------
     threshold : float
@@ -494,44 +554,47 @@ def threshold_li(image):
     >>> thresh = threshold_li(image)
     >>> binary = image > thresh
     """
-    # Make sure image has more than one value
-    if np.all(image == image.flat[0]):
-        raise ValueError("threshold_li is expected to work with images "
-                         "having more than one value. The input image seems "
-                         "to have just one value {0}.".format(image.flat[0]))
+    # Remove nan:
+    image = image[~np.isnan(image)]
+    if image.size == 0:
+        return np.nan
 
-    # Copy to ensure input image is not modified
-    image = image.copy()
-    # Requires positive image (because of log(mean))
-    immin = np.min(image)
-    image -= immin
-    imrange = np.max(image)
-    tolerance = 0.5 * imrange / 256
-
-    # Calculate the mean gray-level
-    mean = np.mean(image)
+    # Make sure image has more than one value; otherwise, return that value
+    # This works even for np.inf
+    if np.all(image == image.flat[0]):
+        return image.flat[0]
+
+    # At this point, the image only contains np.inf, -np.inf, or valid numbers
+    image = image[np.isfinite(image)]
+    # if there are no finite values in the image, return 0. This is because
+    # at this point we *know* that there are *both* inf and -inf values,
+    # because inf == inf evaluates to True. We might as well separate them.
+    if image.size == 0:
+        return 0.
+
+    # Li's algorithm requires positive image (because of log(mean))
+    image_min = np.min(image)
+    image -= image_min
+    image_range = np.max(image)
+    tolerance = tolerance or 0.5 * image_range / 256
 
     # Initial estimate
-    new_thresh = mean
-    old_thresh = new_thresh + 2 * tolerance
+    t_curr = np.mean(image)
+    t_next = t_curr + 2 * tolerance
 
     # Stop the iterations when the difference between the
     # new and old threshold values is less than the tolerance
-    while abs(new_thresh - old_thresh) > tolerance:
-        old_thresh = new_thresh
-        threshold = old_thresh + tolerance   # range
-        # Calculate the means of background and object pixels
-        mean_back = image[image <= threshold].mean()
-        mean_obj = image[image > threshold].mean()
+    while abs(t_next - t_curr) > tolerance:
+        t_curr = t_next
+        foreground = (image > t_curr)
+        mean_fore = np.mean(image[foreground])
+        mean_back = np.mean(image[~foreground])
 
-        temp = (mean_back - mean_obj) / (np.log(mean_back) - np.log(mean_obj))
+        t_next = ((mean_back - mean_fore) /
+                  (np.log(mean_back) - np.log(mean_fore)))
 
-        if temp < 0:
-            new_thresh = temp - tolerance
-        else:
-            new_thresh = temp + tolerance
-
-    return threshold + immin
+    threshold = t_next + image_min
+    return threshold
 
 
 def threshold_minimum(image, nbins=256, max_iter=10000):


Test patch:
diff --git a/skimage/filters/tests/test_thresholding.py b/skimage/filters/tests/test_thresholding.py
--- a/skimage/filters/tests/test_thresholding.py
+++ b/skimage/filters/tests/test_thresholding.py
@@ -16,7 +16,8 @@
                                           threshold_triangle,
                                           threshold_minimum,
                                           try_all_threshold,
-                                          _mean_std)
+                                          _mean_std,
+                                          _cross_entropy)
 from skimage._shared import testing
 from skimage._shared.testing import assert_equal, assert_almost_equal
 
@@ -51,19 +52,18 @@ def test_otsu_float_image(self):
         assert 2 <= threshold_otsu(image) < 3
 
     def test_li(self):
-        assert int(threshold_li(self.image)) == 2
+        assert 2 < threshold_li(self.image) < 3
 
     def test_li_negative_int(self):
         image = self.image - 2
-        assert int(threshold_li(image)) == 0
+        assert 0 < threshold_li(image) < 1
 
     def test_li_float_image(self):
-        image = np.float64(self.image)
-        assert 2 <= threshold_li(image) < 3
+        image = self.image.astype(float)
+        assert 2 < threshold_li(image) < 3
 
     def test_li_constant_image(self):
-        with testing.raises(ValueError):
-            threshold_li(np.ones((10, 10)))
+        assert threshold_li(np.ones((10, 10))) == 1.
 
     def test_yen(self):
         assert threshold_yen(self.image) == 2
@@ -216,23 +216,60 @@ def test_otsu_one_color_image():
 
 
 def test_li_camera_image():
-    camera = skimage.img_as_ubyte(data.camera())
-    assert 63 < threshold_li(camera) < 65
+    image = skimage.img_as_ubyte(data.camera())
+    threshold = threshold_li(image)
+    ce_actual = _cross_entropy(image, threshold)
+    assert 62 < threshold_li(image) < 63
+    assert ce_actual < _cross_entropy(image, threshold + 1)
+    assert ce_actual < _cross_entropy(image, threshold - 1)
 
 
 def test_li_coins_image():
-    coins = skimage.img_as_ubyte(data.coins())
-    assert 95 < threshold_li(coins) < 97
+    image = skimage.img_as_ubyte(data.coins())
+    threshold = threshold_li(image)
+    ce_actual = _cross_entropy(image, threshold)
+    assert 94 < threshold_li(image) < 95
+    assert ce_actual < _cross_entropy(image, threshold + 1)
+    # in the case of the coins image, the minimum cross-entropy is achieved one
+    # threshold below that found by the iterative method. Not sure why that is
+    # but `threshold_li` does find the stationary point of the function (ie the
+    # tolerance can be reduced arbitrarily but the exact same threshold is
+    # found), so my guess some kind of histogram binning effect.
+    assert ce_actual < _cross_entropy(image, threshold - 2)
 
 
 def test_li_coins_image_as_float():
     coins = skimage.img_as_float(data.coins())
-    assert 0.37 < threshold_li(coins) < 0.38
+    assert 94/255 < threshold_li(coins) < 95/255
 
 
 def test_li_astro_image():
-    img = skimage.img_as_ubyte(data.astronaut())
-    assert 66 < threshold_li(img) < 68
+    image = skimage.img_as_ubyte(data.astronaut())
+    threshold = threshold_li(image)
+    ce_actual = _cross_entropy(image, threshold)
+    assert 64 < threshold < 65
+    assert ce_actual < _cross_entropy(image, threshold + 1)
+    assert ce_actual < _cross_entropy(image, threshold - 1)
+
+
+def test_li_nan_image():
+    image = np.full((5, 5), np.nan)
+    assert np.isnan(threshold_li(image))
+
+
+def test_li_inf_image():
+    image = np.array([np.inf, np.nan])
+    assert threshold_li(image) == np.inf
+
+
+def test_li_inf_minus_inf():
+    image = np.array([np.inf, -np.inf])
+    assert threshold_li(image) == 0
+
+
+def test_li_constant_image_with_nan():
+    image = np.array([8, 8, 8, 8, np.nan])
+    assert threshold_li(image) == 8
 
 
 def test_yen_camera_image():