Repository: scikit-image/scikit-image

Issue:
Otsu thresholding admits `hist` as input parameter on `v0.19.0` while multi-Otsu does not
## Description

As it can be seen in the API docs for version `0.19.0.dev0`, multi-Otsu still has the older signature, while 2-class Otsu has the newer one:

https://scikit-image.org/docs/dev/api/skimage.filters.html#skimage.filters.threshold_otsu
https://scikit-image.org/docs/dev/api/skimage.filters.html#skimage.filters.threshold_multiotsu

Feeding the histogram directly has many advantageous use cases, one of them is extracting thresholds for multiple images at the same time (e.g. for video sequences). The feature requested here is to update the signature for multi-Otsu to accept histograms as well.

Currently there is another PR open on multi-Otsu, awaiting integration: https://github.com/scikit-image/scikit-image/pull/5198

I've proposed a workaround there and the author proposed to submit a new, separate PR.
I've opened this issue to introduce and sustain the new PR that I hope to submit in the following days.

<!--
(Note: for guidance on how to use `scikit-image`, please post instead on https://forum.image.sc/tag/scikit-image)
-->

## Way to reproduce

This issue is about interfaces and doesn't involve any particular implementation.


## Version information

As mentioned above, the signature inconsistency happens at least in skimage version `0.19.0.dev0`.



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
@@ -248,7 +248,7 @@ def threshold_local(image, block_size, method='gaussian', offset=0,
     return thresh_image - offset
 
 
-def _validate_image_histogram(image, hist, nbins=None):
+def _validate_image_histogram(image, hist, nbins=None, normalize=False):
     """Ensure that either image or hist were given, return valid histogram.
 
     If hist is given, image is ignored.
@@ -263,13 +263,18 @@ def _validate_image_histogram(image, hist, nbins=None):
     nbins : int, optional
         The number of bins with which to compute the histogram, if `hist` is
         None.
+    normalize : bool
+        If hist is not given, it will be computed by this function. This
+        parameter determines whether the computed histogram is normalized
+        (i.e. entries sum up to 1) or not.
 
     Returns
     -------
     counts : 1D array of float
         Each element is the number of pixels falling in each intensity bin.
     bin_centers : 1D array
-        Each element is the value corresponding to the center of each intensity bin.
+        Each element is the value corresponding to the center of each intensity
+        bin.
 
     Raises
     ------
@@ -284,7 +289,7 @@ def _validate_image_histogram(image, hist, nbins=None):
         else:
             counts = hist
             bin_centers = np.arange(counts.size)
-        
+
         if counts[0] == 0 or counts[-1] == 0:
             # Trim histogram from both ends by removing starting and
             # ending zeroes as in histogram(..., source_range="image")
@@ -294,7 +299,7 @@ def _validate_image_histogram(image, hist, nbins=None):
             counts, bin_centers = counts[start:end], bin_centers[start:end]
     else:
         counts, bin_centers = histogram(
-                image.ravel(), nbins, source_range='image'
+                image.ravel(), nbins, source_range='image', normalize=normalize
             )
     return counts.astype(float), bin_centers
 
@@ -307,15 +312,16 @@ def threshold_otsu(image=None, nbins=256, *, hist=None):
 
     Parameters
     ----------
-    image : (N, M[, ..., P]) ndarray
+    image : (N, M[, ..., P]) ndarray, optional
         Grayscale input image.
     nbins : int, optional
         Number of bins used to calculate histogram. This value is ignored for
         integer arrays.
     hist : array, or 2-tuple of arrays, optional
         Histogram from which to determine the threshold, and optionally a
-        corresponding array of bin center intensities.
-        An alternative use of this function is to pass it only hist.
+        corresponding array of bin center intensities. If no hist provided,
+        this function will compute it from the image.
+
 
     Returns
     -------
@@ -1159,16 +1165,20 @@ def apply_hysteresis_threshold(image, low, high):
     return thresholded
 
 
-def threshold_multiotsu(image, classes=3, nbins=256):
-    r"""Generate `classes`-1 threshold values to divide gray levels in `image`.
+def threshold_multiotsu(image=None, classes=3, nbins=256, *, hist=None):
+    r"""Generate `classes`-1 threshold values to divide gray levels in `image`,
+    following Otsu's method for multiple classes.
 
     The threshold values are chosen to maximize the total sum of pairwise
     variances between the thresholded graylevel classes. See Notes and [1]_
     for more details.
 
+    Either image or hist must be provided. If hist is provided, the actual
+    histogram of the image is ignored.
+
     Parameters
     ----------
-    image : (N, M[, ..., P]) ndarray
+    image : (N, M[, ..., P]) ndarray, optional
         Grayscale input image.
     classes : int, optional
         Number of classes to be thresholded, i.e. the number of resulting
@@ -1176,6 +1186,10 @@ def threshold_multiotsu(image, classes=3, nbins=256):
     nbins : int, optional
         Number of bins used to calculate the histogram. This value is ignored
         for integer arrays.
+    hist : array, or 2-tuple of arrays, optional
+        Histogram from which to determine the threshold, and optionally a
+        corresponding array of bin center intensities. If no hist provided,
+        this function will compute it from the image (see notes).
 
     Returns
     -------
@@ -1195,6 +1209,11 @@ def threshold_multiotsu(image, classes=3, nbins=256):
     is the number of histogram bins and :math:`C` is the number of
     classes desired.
 
+    If no hist is given, this function will make use of
+    `skimage.exposure.histogram`, which behaves differently than
+    `np.histogram`. While both allowed, use the former for consistent
+    behaviour.
+
     The input image must be grayscale.
 
     References
@@ -1216,19 +1235,15 @@ def threshold_multiotsu(image, classes=3, nbins=256):
     >>> thresholds = threshold_multiotsu(image)
     >>> regions = np.digitize(image, bins=thresholds)
     >>> regions_colorized = label2rgb(regions)
-
     """
-
-    if len(image.shape) > 2 and image.shape[-1] in (3, 4):
+    if image is not None and image.ndim > 2 and image.shape[-1] in (3, 4):
         warn(f'threshold_multiotsu is expected to work correctly only for '
              f'grayscale images; image shape {image.shape} looks like '
              f'that of an RGB image.')
 
     # calculating the histogram and the probability of each gray level.
-    prob, bin_centers = histogram(image.ravel(),
-                                  nbins=nbins,
-                                  source_range='image',
-                                  normalize=True)
+    prob, bin_centers = _validate_image_histogram(image, hist, nbins,
+                                                  normalize=True)
     prob = prob.astype('float32')
 
     nvalues = np.count_nonzero(prob)


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
@@ -268,11 +268,11 @@ def test_otsu_camera_image_counts():
     camera = util.img_as_ubyte(data.camera())
     counts, bin_centers = histogram(camera.ravel(), 256, source_range='image')
     assert 101 < threshold_otsu(hist=counts) < 103
-    
-    
+
+
 def test_otsu_zero_count_histogram():
     """Issue #5497.
-    
+
     As the histogram returned by np.bincount starts with zero,
     it resulted in NaN-related issues.
     """
@@ -730,3 +730,19 @@ def test_multiotsu_lut():
             result = _get_multiotsu_thresh_indices(prob, classes - 1)
 
             assert np.array_equal(result_lut, result)
+
+
+def test_multiotsu_missing_img_and_hist():
+    with pytest.raises(Exception):
+        threshold_multiotsu()
+
+
+def test_multiotsu_hist_parameter():
+    for classes in [2, 3, 4]:
+        for name in ['camera', 'moon', 'coins', 'text', 'clock', 'page']:
+            img = getattr(data, name)()
+            sk_hist = histogram(img, nbins=256)
+            #
+            thresh_img = threshold_multiotsu(img, classes)
+            thresh_sk_hist = threshold_multiotsu(classes=classes, hist=sk_hist)
+            assert np.allclose(thresh_img, thresh_sk_hist)