Repository: scikit-image/scikit-image

Issue:
Gallery example uses private `_invariant_denoise`
### Description:

Our gallery example [Full tutorial on calibrating Denoisers Using J-Invariance](https://scikit-image.org/docs/dev/auto_examples/filters/plot_j_invariant_tutorial.html#the-self-supervised-loss-and-j-invariance) imports the private function `_invariant_denoise` from `skimage.restoration.j_invariant`. 
 
I don't think our documentation should encourage the use of private objects. I propose making `_invariant_denoise` part of our public API which should be a quick fix.

### Way to reproduce:

_No response_

### Traceback or output:

_No response_

### Version information:

```Shell
3.10.6 | packaged by conda-forge | (main, Aug 22 2022, 20:35:26) [GCC 10.4.0]
Linux-6.0.11-arch1-1-x86_64-with-glibc2.36
scikit-image version: 0.20.0.dev0+git20221206.4007a76a1
numpy version: 1.23.4
```



Discussion:
Agreed. Consistently named and grouped with other denoisers. 

Resolving patch:
diff --git a/doc/examples/filters/plot_j_invariant_tutorial.py b/doc/examples/filters/plot_j_invariant_tutorial.py
--- a/doc/examples/filters/plot_j_invariant_tutorial.py
+++ b/doc/examples/filters/plot_j_invariant_tutorial.py
@@ -95,7 +95,7 @@
 # blue line) have the same shape and the same minimizer.
 #
 
-from skimage.restoration.j_invariant import _invariant_denoise
+from skimage.restoration import denoise_invariant
 
 sigma_range = np.arange(sigma/2, 1.5*sigma, 0.025)
 
@@ -103,8 +103,8 @@
                       'channel_axis': -1}
                      for sigma in sigma_range]
 
-denoised_invariant = [_invariant_denoise(noisy, _denoise_wavelet,
-                                         denoiser_kwargs=params)
+denoised_invariant = [denoise_invariant(noisy, _denoise_wavelet,
+                                        denoiser_kwargs=params)
                       for params in parameters_tested]
 
 self_supervised_loss = [mse(img, noisy) for img in denoised_invariant]
@@ -224,8 +224,8 @@
 print(f'Minimum self-supervised loss TV: {np.min(losses_tv):.4f}')
 
 best_parameters_tv = parameters_tested_tv[np.argmin(losses_tv)]
-denoised_calibrated_tv = _invariant_denoise(noisy, denoise_tv_chambolle,
-                                            denoiser_kwargs=best_parameters_tv)
+denoised_calibrated_tv = denoise_invariant(noisy, denoise_tv_chambolle,
+                                           denoiser_kwargs=best_parameters_tv)
 denoised_default_tv = denoise_tv_chambolle(noisy, **best_parameters_tv)
 
 psnr_calibrated_tv = psnr(image, denoised_calibrated_tv)
@@ -240,7 +240,7 @@
 print(f'Minimum self-supervised loss wavelet: {np.min(losses_wavelet):.4f}')
 
 best_parameters_wavelet = parameters_tested_wavelet[np.argmin(losses_wavelet)]
-denoised_calibrated_wavelet = _invariant_denoise(
+denoised_calibrated_wavelet = denoise_invariant(
         noisy, _denoise_wavelet,
         denoiser_kwargs=best_parameters_wavelet)
 denoised_default_wavelet = _denoise_wavelet(noisy, **best_parameters_wavelet)
@@ -261,8 +261,8 @@
 print(f'Minimum self-supervised loss NL means: {np.min(losses_nl):.4f}')
 
 best_parameters_nl = parameters_tested_nl[np.argmin(losses_nl)]
-denoised_calibrated_nl = _invariant_denoise(noisy, denoise_nl_means,
-                                            denoiser_kwargs=best_parameters_nl)
+denoised_calibrated_nl = denoise_invariant(noisy, denoise_nl_means,
+                                           denoiser_kwargs=best_parameters_nl)
 denoised_default_nl = denoise_nl_means(noisy, **best_parameters_nl)
 
 psnr_calibrated_nl = psnr(image, denoised_calibrated_nl)
diff --git a/skimage/restoration/__init__.py b/skimage/restoration/__init__.py
--- a/skimage/restoration/__init__.py
+++ b/skimage/restoration/__init__.py
@@ -9,7 +9,7 @@
 from ._cycle_spin import cycle_spin
 from .non_local_means import denoise_nl_means
 from .inpaint import inpaint_biharmonic
-from .j_invariant import calibrate_denoiser
+from .j_invariant import calibrate_denoiser, denoise_invariant
 from .rolling_ball import rolling_ball, ball_kernel, ellipsoid_kernel
 
 
@@ -22,6 +22,7 @@
            'denoise_bilateral',
            'denoise_wavelet',
            'denoise_nl_means',
+           'denoise_invariant',
            'estimate_sigma',
            'inpaint_biharmonic',
            'cycle_spin',
diff --git a/skimage/restoration/j_invariant.py b/skimage/restoration/j_invariant.py
--- a/skimage/restoration/j_invariant.py
+++ b/skimage/restoration/j_invariant.py
@@ -89,14 +89,16 @@ def _generate_grid_slice(shape, *, offset, stride=3):
     return mask
 
 
-def _invariant_denoise(image, denoise_function, *, stride=4,
-                       masks=None, denoiser_kwargs=None):
-    """Apply a J-invariant version of `denoise_function`.
+def denoise_invariant(image, denoise_function, *, stride=4,
+                      masks=None, denoiser_kwargs=None):
+    """Apply a J-invariant version of a denoising function.
 
     Parameters
     ----------
-    image : ndarray
-        Input data to be denoised (converted using `img_as_float`).
+    image : ndarray ([M[, N[, ...P]][, C]) of ints, uints or floats
+        Input data to be denoised. `image` can be of any numeric type,
+        but it is cast into a ndarray of floats (using `img_as_float`) for the
+        computation of the denoised image.
     denoise_function : function
         Original denoising function.
     stride : int, optional
@@ -112,6 +114,39 @@ def _invariant_denoise(image, denoise_function, *, stride=4,
     -------
     output : ndarray
         Denoised image, of same shape as `image`.
+
+    Notes
+    -----
+    A denoising function is J-invariant if the prediction it makes for each
+    pixel does not depend on the value of that pixel in the original image.
+    The prediction for each pixel may instead use all the relevant information
+    contained in the rest of the image, which is typically quite significant.
+    Any function can be converted into a J-invariant one using a simple masking
+    procedure, as described in [1].
+
+    The pixel-wise error of a J-invariant denoiser is uncorrelated to the noise,
+    so long as the noise in each pixel is independent. Consequently, the average
+    difference between the denoised image and the oisy image, the
+    *self-supervised loss*, is the same as the difference between the denoised
+    image and the original clean image, the *ground-truth loss* (up to a
+    constant).
+
+    This means that the best J-invariant denoiser for a given image can be found
+    using the noisy data alone, by selecting the denoiser minimizing the self-
+    supervised loss.
+
+    References
+    ----------
+    .. [1] J. Batson & L. Royer. Noise2Self: Blind Denoising by Self-Supervision,
+       International Conference on Machine Learning, p. 524-533 (2019).
+
+    Examples
+    --------
+    >>> import skimage
+    >>> from skimage.restoration import denoise_invariant, denoise_wavelet
+    >>> image = skimage.util.img_as_float(skimage.data.chelsea())
+    >>> noisy = skimage.util.random_noise(image, var=0.2 ** 2)
+    >>> denoised = denoise_invariant(noisy, denoise_function=denoise_wavelet)
     """
     image = img_as_float(image)
 
@@ -247,7 +282,7 @@ def calibrate_denoiser(image, denoise_function, denoise_parameters, *,
     best_parameters = parameters_tested[idx]
 
     best_denoise_function = functools.partial(
-        _invariant_denoise,
+        denoise_invariant,
         denoise_function=denoise_function,
         stride=stride,
         denoiser_kwargs=best_parameters,
@@ -294,7 +329,7 @@ def _calibrate_denoiser_search(image, denoise_function, denoise_parameters, *,
     for denoiser_kwargs in parameters_tested:
         multichannel = denoiser_kwargs.get('channel_axis', None) is not None
         if not approximate_loss:
-            denoised = _invariant_denoise(
+            denoised = denoise_invariant(
                 image, denoise_function,
                 stride=stride,
                 denoiser_kwargs=denoiser_kwargs
@@ -306,7 +341,7 @@ def _calibrate_denoiser_search(image, denoise_function, denoise_parameters, *,
             mask = _generate_grid_slice(image.shape[:spatialdims],
                                         offset=n_masks // 2, stride=stride)
 
-            masked_denoised = _invariant_denoise(
+            masked_denoised = denoise_invariant(
                 image, denoise_function,
                 masks=[mask],
                 denoiser_kwargs=denoiser_kwargs


Test patch:
diff --git a/skimage/restoration/tests/test_j_invariant.py b/skimage/restoration/tests/test_j_invariant.py
--- a/skimage/restoration/tests/test_j_invariant.py
+++ b/skimage/restoration/tests/test_j_invariant.py
@@ -9,7 +9,7 @@
 from skimage.metrics import mean_squared_error as mse
 from skimage.restoration import (calibrate_denoiser,
                                  denoise_wavelet)
-from skimage.restoration.j_invariant import _invariant_denoise
+from skimage.restoration.j_invariant import denoise_invariant
 from skimage.util import img_as_float, random_noise
 
 test_img = img_as_float(camera())
@@ -23,7 +23,7 @@
 
 
 def test_invariant_denoise():
-    denoised_img = _invariant_denoise(noisy_img, _denoise_wavelet)
+    denoised_img = denoise_invariant(noisy_img, _denoise_wavelet)
 
     denoised_mse = mse(denoised_img, test_img)
     original_mse = mse(noisy_img, test_img)
@@ -32,7 +32,7 @@ def test_invariant_denoise():
 
 @pytest.mark.parametrize('dtype', [np.float16, np.float32, np.float64])
 def test_invariant_denoise_color(dtype):
-    denoised_img_color = _invariant_denoise(
+    denoised_img_color = denoise_invariant(
         noisy_img_color.astype(dtype), _denoise_wavelet,
         denoiser_kwargs=dict(channel_axis=-1))
     denoised_mse = mse(denoised_img_color, test_img_color)
@@ -42,7 +42,7 @@ def test_invariant_denoise_color(dtype):
 
 
 def test_invariant_denoise_3d():
-    denoised_img_3d = _invariant_denoise(noisy_img_3d, _denoise_wavelet)
+    denoised_img_3d = denoise_invariant(noisy_img_3d, _denoise_wavelet)
 
     denoised_mse = mse(denoised_img_3d, test_img_3d)
     original_mse = mse(noisy_img_3d, test_img_3d)
@@ -58,8 +58,8 @@ def test_calibrate_denoiser_extra_output():
         extra_output=True
     )
 
-    all_denoised = [_invariant_denoise(noisy_img, _denoise_wavelet,
-                                       denoiser_kwargs=denoiser_kwargs)
+    all_denoised = [denoise_invariant(noisy_img, _denoise_wavelet,
+                                      denoiser_kwargs=denoiser_kwargs)
                     for denoiser_kwargs in parameters_tested]
 
     ground_truth_losses = [mse(img, test_img) for img in all_denoised]