Repository: scikit-image/scikit-image

Issue:
threshold_li fails when background mode of a bi-modal image has single value
### Description:

Similar to https://github.com/scikit-image/scikit-image/issues/4140 each mode has a single value.

(assuming background = dark, foreground = light)

```
import numpy as np
from skimage.filters import threshold_li

np.seterr(divide='raise') 
threshold_li(np.array([0, 254, 255]))
```

crashes on `FloatingPointError: divide by zero encountered in log` with the traceback in the field below when applying logarithm to the `mean_back` (mean background value).

where `mean_back == 0.0, mean_fore == 254.5`.

* When foreground mode has a single mode, it does not fail.
  * `threshold_li(np.array([0, 1, 255])) -> 40.821821210804934`
* Background can be higher than 0 and it still fails, since internally a minimum value is subtracted.
  * `threshold_li(np.array([10, 254, 255])) -> 10.0`
* When background has more than value value, it does not fail.
  * `threshold_li(np.array([0, 1, 255])) -> 40.821821210804934`

When there's only warning (default), the threshold value is the same as the single-mode background value, not in between the modes.

```
np.seterr(divide='warn')
threshold_li(np.array([0, 1, 254, 255]))
# 40.754451433127485 - in between - OK
threshold_li(np.array([0, 254, 255]))
# 0.0 - same as background - ?
```

## Proposed solution

It seems that if the background already consists of a single-value, there's no need to continue with the algorithm. The current threshold value is good enough, thus we can just break before the logarithm of zero - in both branch for byte/float image.

```
if mean_back == 0:
    break

t_next = ((mean_back - mean_fore) / (np.log(mean_back) - np.log(mean_fore)))
```

Then it produces quite reasonable values and does not compute log(0):
```
assert threshold_li(np.array([0, 255])) == 127.5
assert threshold_li(np.array([0, 1, 255])) == 40.821821210804934
assert threshold_li(np.array([0, 1, 254, 255])) == 40.754451433127485
assert threshold_li(np.array([0, 1, 255])) == 173.0
```

### Way to reproduce:

```Python
import numpy as np
from skimage.filters import threshold_li

np.seterr(divide='raise') 
threshold_li(np.array([0, 254, 255]))
```


### Traceback or output:

```Shell
File /usr/local/lib/python3.10/dist-packages/skimage/filters/thresholding.py:744, in threshold_li(image, tolerance, initial_guess, iter_callback)
    738 mean_fore = np.average(bin_centers[foreground],
    739                        weights=hist[foreground])
    740 mean_back = np.average(bin_centers[background],
    741                        weights=hist[background])
    743 t_next = ((mean_back - mean_fore)
--> 744           / (np.log(mean_back) - np.log(mean_fore)))
    746 if iter_callback is not None:
    747     iter_callback(t_next + image_min)

FloatingPointError: divide by zero encountered in log
```


### Version information:

```Shell
3.10.10 (main, Feb  8 2023, 14:50:01) [GCC 9.4.0]
Linux-5.4.0-131-generic-x86_64-with-glibc2.31
scikit-image version: 0.19.3
numpy version: 1.23.5
```



Discussion:

Resolving patch:
diff --git a/skimage/filters/thresholding.py b/skimage/filters/thresholding.py
--- a/skimage/filters/thresholding.py
+++ b/skimage/filters/thresholding.py
@@ -725,6 +725,8 @@ def threshold_li(image, *, tolerance=None, initial_guess=None,
 
     # Stop the iterations when the difference between the
     # new and old threshold values is less than the tolerance
+    # or if the background mode has only one value left,
+    # since log(0) is not defined.
 
     if image.dtype.kind in 'iu':
         hist, bin_centers = histogram(image.reshape(-1),
@@ -740,6 +742,9 @@ def threshold_li(image, *, tolerance=None, initial_guess=None,
             mean_back = np.average(bin_centers[background],
                                    weights=hist[background])
 
+            if mean_back == 0:
+                break
+
             t_next = ((mean_back - mean_fore)
                       / (np.log(mean_back) - np.log(mean_fore)))
 
@@ -753,6 +758,9 @@ def threshold_li(image, *, tolerance=None, initial_guess=None,
             mean_fore = np.mean(image[foreground])
             mean_back = np.mean(image[~foreground])
 
+            if mean_back == 0.0:
+                break
+
             t_next = ((mean_back - mean_fore)
                       / (np.log(mean_back) - np.log(mean_fore)))
 


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
@@ -388,19 +388,26 @@ def test_li_negative_inital_guess():
         threshold_li(coins, initial_guess=-5)
 
 
-def test_li_pathological_arrays():
-    # See https://github.com/scikit-image/scikit-image/issues/4140
-    a = np.array([0, 0, 1, 0, 0, 1, 0, 1])
-    b = np.array([0, 0, 0.1, 0, 0, 0.1, 0, 0.1])
-    c = np.array([0, 0, 0.1, 0, 0, 0.1, 0.01, 0.1])
-    d = np.array([0, 0, 1, 0, 0, 1, 0.5, 1])
-    e = np.array([1, 1])
-    f = np.array([1, 2])
-    arrays = [a, b, c, d, e, f]
-    with np.errstate(divide='ignore'):
-        # ignoring "divide by zero encountered in log" error from np.log(0)
-        thresholds = [threshold_li(arr) for arr in arrays]
-    assert np.all(np.isfinite(thresholds))
+@pytest.mark.parametrize(
+    "image",
+    [
+        # See https://github.com/scikit-image/scikit-image/issues/4140
+        [0, 0, 1, 0, 0, 1, 0, 1],
+        [0, 0, 0.1, 0, 0, 0.1, 0, 0.1],
+        [0, 0, 0.1, 0, 0, 0.1, 0.01, 0.1],
+        [0, 0, 1, 0, 0, 1, 0.5, 1],
+        [1, 1],
+        [1, 2],
+        # See https://github.com/scikit-image/scikit-image/issues/6744
+        [0, 254, 255],
+        [0, 1, 255],
+        [0.1, 0.8, 0.9]
+    ]
+)
+def test_li_pathological(image):
+    image = np.array(image)
+    threshold = threshold_li(image)
+    assert np.isfinite(threshold)
 
 
 def test_yen_camera_image():