Repository: scikit-image/scikit-image

Issue:
SSIM should fail unless data_range specified (especially for float)
### Description:

See https://github.com/scikit-image/scikit-image/pull/6595

### Way to reproduce:

_No response_

### Traceback or output:

_No response_

### Version information:

_No response_


Discussion:
/cc @mcourteaux 
Nice, working on this, I find places in the tests where the SSIM is already incorrectly calculated. Amazing. :P 
Here is an example:
https://github.com/scikit-image/scikit-image/blob/e68ff347ab6ce7bace14c911aed752c2323b65c0/skimage/metrics/tests/test_structural_similarity.py#L157-L168

My question is: ideally, this test checks if all SSIMs are close, instead of just "statistically low enough". But that requires to compare the results of different parameterizations. I would then just delete this parameterization and make it a single test, I guess?

Resolving patch:
diff --git a/skimage/metrics/_structural_similarity.py b/skimage/metrics/_structural_similarity.py
--- a/skimage/metrics/_structural_similarity.py
+++ b/skimage/metrics/_structural_similarity.py
@@ -194,11 +194,22 @@ def structural_similarity(im1, im2,
         raise ValueError('Window size must be odd.')
 
     if data_range is None:
+        if (np.issubdtype(im1.dtype, np.floating) or
+            np.issubdtype(im2.dtype, np.floating)):
+            raise ValueError(
+                'Since image dtype is floating point, you must specify '
+                'the data_range parameter. Please read the documentation '
+                'carefully (including the note). It is recommended that '
+                'you always specify the data_range anyway.')
         if im1.dtype != im2.dtype:
-            warn("Inputs have mismatched dtype.  Setting data_range based on "
-                 "im1.dtype.", stacklevel=2)
+            warn("Inputs have mismatched dtypes. Setting data_range based on im1.dtype.",
+                 stacklevel=2)
         dmin, dmax = dtype_range[im1.dtype.type]
         data_range = dmax - dmin
+        if np.issubdtype(im1.dtype, np.integer) and (im1.dtype != np.uint8):
+            warn("Setting data_range based on im1.dtype. " +
+                 ("data_range = %.0f. " % data_range) +
+                 "Please specify data_range explicitly to avoid mistakes.", stacklevel=2)
 
     ndim = im1.ndim
 


Test patch:
diff --git a/skimage/metrics/tests/test_structural_similarity.py b/skimage/metrics/tests/test_structural_similarity.py
--- a/skimage/metrics/tests/test_structural_similarity.py
+++ b/skimage/metrics/tests/test_structural_similarity.py
@@ -87,13 +87,15 @@ def test_structural_similarity_dtype(dtype):
     X = np.random.rand(N, N)
     Y = np.random.rand(N, N)
     if np.dtype(dtype).kind in 'iub':
+        data_range = 255.0
         X = (X * 255).astype(np.uint8)
         Y = (X * 255).astype(np.uint8)
     else:
+        data_range = 1.0
         X = X.astype(dtype, copy=False)
         Y = Y.astype(dtype, copy=False)
 
-    S1 = structural_similarity(X, Y)
+    S1 = structural_similarity(X, Y, data_range=data_range)
     assert S1.dtype == np.float64
 
     assert S1 < 0.1
@@ -150,7 +152,8 @@ def test_structural_similarity_multichannel_deprecated():
     Xc = np.tile(X[..., np.newaxis], (1, 1, 3))
     Yc = np.tile(Y[..., np.newaxis], (1, 1, 3))
     with expected_warnings(["`multichannel` is a deprecated argument"]):
-        S2 = structural_similarity(Xc, Yc, multichannel=True, win_size=3)
+        S2 = structural_similarity(Xc, Yc, multichannel=True, win_size=3,
+                                   data_range=255)
     assert_almost_equal(S1, S2)
 
 
@@ -163,7 +166,7 @@ def test_structural_similarity_nD(dtype):
         X = (np.random.rand(*xsize) * 255).astype(dtype)
         Y = (np.random.rand(*xsize) * 255).astype(dtype)
 
-        mssim = structural_similarity(X, Y, win_size=3)
+        mssim = structural_similarity(X, Y, win_size=3, data_range=255.0)
         assert mssim.dtype == np.float64
         assert mssim < 0.05
 
@@ -204,27 +207,44 @@ def test_gaussian_structural_similarity_vs_IPOL():
     https://github.com/scikit-image/scikit-image/pull/4913#issuecomment-700653165
     """
     mssim_IPOL = 0.357959091663361
+    assert cam.dtype == np.uint8
+    assert cam_noisy.dtype == np.uint8
     mssim = structural_similarity(cam, cam_noisy, gaussian_weights=True,
                                   use_sample_covariance=False)
     assert_almost_equal(mssim, mssim_IPOL, decimal=3)
 
 
-def test_mssim_vs_legacy():
+@pytest.mark.parametrize(
+    'dtype', [np.uint8, np.int32, np.float16, np.float32, np.float64]
+)
+def test_mssim_vs_legacy(dtype):
     # check that ssim with default options matches skimage 0.17 result
     mssim_skimage_0pt17 = 0.3674518327910367
-    mssim = structural_similarity(cam, cam_noisy)
+    assert cam.dtype == np.uint8
+    assert cam_noisy.dtype == np.uint8
+    mssim = structural_similarity(cam.astype(dtype),
+                                  cam_noisy.astype(dtype), data_range=255)
     assert_almost_equal(mssim, mssim_skimage_0pt17)
 
 
-def test_mssim_mixed_dtype():
+def test_ssim_warns_about_data_range():
     mssim = structural_similarity(cam, cam_noisy)
-    with expected_warnings(['Inputs have mismatched dtype']):
-        mssim_mixed = structural_similarity(cam, cam_noisy.astype(np.float32))
-    assert_almost_equal(mssim, mssim_mixed)
+    with expected_warnings(['Setting data_range based on im1.dtype']):
+        mssim_uint16 = structural_similarity(cam.astype(np.uint16),
+                                             cam_noisy.astype(np.uint16))
+        # The value computed for mssim_uint16 is wrong, because the
+        # dtype of im1 led to infer an erroneous data_range. The user
+        # is getting a warning about avoiding mistakes.
+        assert mssim_uint16 > 0.99
+
+    with expected_warnings(['Setting data_range based on im1.dtype',
+                            'Inputs have mismatched dtypes']):
+        mssim_mixed = structural_similarity(cam, cam_noisy.astype(np.int32))
 
     # no warning when user supplies data_range
     mssim_mixed = structural_similarity(
         cam, cam_noisy.astype(np.float32), data_range=255)
+
     assert_almost_equal(mssim, mssim_mixed)
 
 
@@ -233,14 +253,21 @@ def test_structural_similarity_small_image(dtype):
     X = np.zeros((5, 5), dtype=dtype)
     # structural_similarity can be computed for small images if win_size is
     # a) odd and b) less than or equal to the images' smaller side
-    assert_equal(structural_similarity(X, X, win_size=3), 1.0)
-    assert_equal(structural_similarity(X, X, win_size=5), 1.0)
+    assert_equal(structural_similarity(X, X, win_size=3, data_range=1.0), 1.0)
+    assert_equal(structural_similarity(X, X, win_size=5, data_range=1.0), 1.0)
     # structural_similarity errors for small images if user doesn't specify
     # win_size
     with pytest.raises(ValueError):
         structural_similarity(X, X)
 
 
+@pytest.mark.parametrize('dtype', [np.float16, np.float32, np.float64])
+def test_structural_similarity_errors_on_float_without_data_range(dtype):
+    X = np.zeros((64, 64), dtype=dtype)
+    with pytest.raises(ValueError):
+        structural_similarity(X, X)
+
+
 def test_invalid_input():
     # size mismatch
     X = np.zeros((9, 9), dtype=np.float64)
diff --git a/skimage/restoration/tests/test_denoise.py b/skimage/restoration/tests/test_denoise.py
--- a/skimage/restoration/tests/test_denoise.py
+++ b/skimage/restoration/tests/test_denoise.py
@@ -28,8 +28,13 @@
 
 astro = img_as_float(data.astronaut()[:128, :128])
 astro_gray = color.rgb2gray(astro)
+# Make sure that all tests below that rely on 0-1 range are valid:
+assert np.max(astro_gray) <= 1.0
+
 checkerboard_gray = img_as_float(data.checkerboard())
 checkerboard = color.gray2rgb(checkerboard_gray)
+assert np.max(checkerboard_gray) <= 1.0
+
 # versions with one odd-sized dimension
 astro_gray_odd = astro_gray[:, :-1]
 astro_odd = astro[:, :-1]
@@ -170,14 +175,20 @@ def test_denoise_tv_chambolle_weighting():
     img2d += 0.15 * rstate.standard_normal(img2d.shape)
     img2d = np.clip(img2d, 0, 1)
 
+    ssim_noisy = structural_similarity(astro_gray, img2d, data_range=1.0)
+
     # generate 4D image by tiling
     img4d = np.tile(img2d[..., None, None], (1, 1, 2, 2))
 
     w = 0.2
     denoised_2d = restoration.denoise_tv_chambolle(img2d, weight=w)
     denoised_4d = restoration.denoise_tv_chambolle(img4d, weight=w)
-    assert structural_similarity(denoised_2d,
-                                 denoised_4d[:, :, 0, 0]) > 0.99
+    assert denoised_2d.dtype == np.float64
+    assert denoised_4d.dtype == np.float64
+    ssim_2d = structural_similarity(denoised_2d, astro_gray, data_range=1.0)
+    ssim = structural_similarity(denoised_2d, denoised_4d[:, :, 0, 0], data_range=1.0)
+    assert ssim > 0.98 # clsoe to 1.0
+    assert ssim_2d > ssim_noisy # quality must increase after denoising
 
 
 def test_denoise_tv_bregman_2d():