Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Standardize function signature of compare_images. #7322

Merged
merged 36 commits into from
May 14, 2024
Merged
Show file tree
Hide file tree
Changes from 22 commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
216a575
Remove obsolete instruction for documenting changes
mkcor Feb 16, 2024
4f1e326
Merge branch 'main' of github.com:scikit-image/scikit-image
mkcor Feb 16, 2024
f5ba6ee
Deprecate parameter image2 and limit to two positional arguments
mkcor Feb 16, 2024
44fdf66
Add test for replaced parameters
mkcor Feb 16, 2024
32d09da
Use pytest
mkcor Feb 16, 2024
96ea4d2
Cover all cases
mkcor Feb 16, 2024
32eadf6
Remove unnecessary default value
mkcor Feb 16, 2024
0e8bd61
Add directives for future release
mkcor Feb 16, 2024
9784f80
Ensure valid existing code does not break
mkcor Feb 19, 2024
535ed04
Handle deprecation in a custom way
mkcor Feb 19, 2024
35c0db6
Capture all deprecation warnings
mkcor Feb 19, 2024
3364ae1
Fix typo in test name
mkcor Feb 19, 2024
234438a
Update instructions for future release
mkcor Feb 19, 2024
7d5ae50
Merge branch 'main' of github.com:scikit-image/scikit-image
mkcor Feb 28, 2024
2c51f12
Keep signature compatible with old API
mkcor Mar 1, 2024
4fefbc5
Merge branch 'main' of github.com:scikit-image/scikit-image
mkcor Mar 1, 2024
65177be
Update TODO
mkcor Mar 1, 2024
3237cfc
Merge branch 'main' into image0-image1
mkcor Mar 1, 2024
4b0c34b
Merge branch 'main' into image0-image1
mkcor Mar 20, 2024
e0d5929
Remove test for new API
mkcor Mar 21, 2024
d08d3b2
Remove warning test cases for new API
mkcor Mar 21, 2024
878d15c
Write custom deprecation class
mkcor Mar 21, 2024
db1b774
Ensure images are 2D for method=checkerboard
mkcor Mar 21, 2024
c723c5d
Merge branch 'main' into image0-image1
mkcor Mar 21, 2024
79acf77
Add edge case with positional args only
mkcor Mar 21, 2024
9b9f757
Account for method-related warning
mkcor Mar 21, 2024
1f93250
Refactor decorator
mkcor Mar 21, 2024
7227424
Merge branch 'main' into image0-image1
mkcor Mar 23, 2024
0756a56
Correct and test stacklevel of deprecation warnings
lagru Mar 23, 2024
14e27e4
Update TODO.txt
mkcor Mar 25, 2024
0f18d56
Apply suggestions from code review
mkcor Mar 25, 2024
770499a
Fix typos
mkcor Mar 25, 2024
17cfe64
Update deprecation messages to 0.24
lagru Apr 23, 2024
c44e916
Merge branch 'main' into pr/7322_image0-image1
lagru Apr 23, 2024
9a10712
Use correct indent for versionchanged directive
lagru Apr 24, 2024
e406346
Merge branch 'main' into pr/7322_image0-image1
lagru May 9, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
5 changes: 5 additions & 0 deletions TODO.txt
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ Version 0.25
and remove `test_gaussian.py::test_deprecated_gaussian_output`.
Make arguments after the deprecated `output` parameter, keyword only:
`gaussian(image, sigma, *, ...)`.
* In `skimage/util/compare.py`, remove deprecated parameter `image2` as
well as `test_compare_images_replaced_param` in `skimage/util/tests/test_compare.py`
(and all `pytest.warns(FutureWarning)` context managers there).
Make all arguments from `method` (inclusive) keyword-only:
`compare_images(image0, image1, *, method='diff', ...)`.
mkcor marked this conversation as resolved.
Show resolved Hide resolved

Version 0.26
------------
Expand Down
131 changes: 124 additions & 7 deletions skimage/util/compare.py
Original file line number Diff line number Diff line change
@@ -1,27 +1,143 @@
import functools
import inspect
import warnings
from itertools import product

import numpy as np

from .dtype import img_as_float
from itertools import product
from skimage._shared.utils import (
DEPRECATED,
)


class _rename_image_params:
"""Deprecate parameters `image1, image2` in favour of `image0, image1` in
function `compare_images`.

Parameters
----------
deprecated_name : str
The name of the deprecated parameter.
start_version : str
The package version in which the warning was introduced.
stop_version : str
The package version in which the warning will be replaced by
an error / the deprecation is completed.
"""

def __init__(
self,
deprecated_name,
*,
start_version,
stop_version,
):
self.deprecated_name = deprecated_name
self.start_version = start_version
self.stop_version = stop_version

def __call__(self, func):
parameters = inspect.signature(func).parameters
if parameters['image2'].default is not DEPRECATED:
raise RuntimeError(
f"Expected `{self.deprecated_name}` to have the value {DEPRECATED!r} "
f"to indicate its status in the rendered signature."
)
warning_message = (
"Since version 0.23, the two input images are named `image0` and "
"`image1` (instead of `image1` and `image2`, respectively). Please use "
"`image0, image1` to avoid this warning for now, and avoid an error "
"from version 0.25 onwards."
)
wm_method = (
"Starting in version 0.25, all arguments following `image0, image1` "
"(including `method`) will be keyword-only. Please pass `method=` "
"in the function call to avoid this warning for now, and avoid an error "
"from version 0.25 onwards."
)

@functools.wraps(func)
def wrapper(*args, **kwargs):
if 'image2' not in kwargs.keys():
kwargs['image2'] = DEPRECATED

# Pass first all args as kwargs
if len(args) > 0:
kwargs['image0'] = args[0]
if len(args) > 1:
kwargs['image1'] = args[1]
if len(args) > 2 and args[len(args) - 1] in [
'diff',
'blend',
'checkerboard',
]:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The wrapper shouldn't recognize the arg for method by value but by position. The function will/should handle if a wrong value is passed as method.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, the function does handle it:

raise ValueError(
'Wrong value for `method`. '
'Must be either "diff", "blend" or "checkerboard".'
)

Basically I wanted to cover both cases, where a user might call:

compare_images(a, b, 'diff') 

and another one might call:

compare_images(a, b, c, 'diff') 

which should definitely error -- but this should also be taken care of by the function, right? I realize that my RuntimeError("Use image0, image1 to pass the two input images.") isn't catching it anyway, it's only catching

compare_images(image0=a, image1=b, image2=c, ...) 

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's why I think we shouldn't use the value of the 3rd and 4th argument to determine which one is method... just don't use image2=DEPRECATED and we can simply use the position reliably? Right now it seems even harder to think of all edge cases. E.g. consider

import skimage as ski
a = np.ones((10, 10)) * 3
b = np.zeros((10, 10))
ski.util.compare_images(a, b, "unknown")

This will neither warn nor error but will simply return as if the user requested compare_images(a, b) (default method=diff). It should definitely error.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Replying to your question: compare_images(a, b, c, 'diff') should definitely error but it's not an expected use case of the old or new API so users shouldn't really encounter this case unless they try to mess around.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Replying to your question: compare_images(a, b, c, 'diff') should definitely error but it's not an expected use case of the old or new API so users shouldn't really encounter this case unless they try to mess around.

Right; there's definitely no reason to 'handle' it within the function itself and perhaps not even through the decorator...

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's why I think we shouldn't use the value of the 3rd and 4th argument to determine which one is method... just don't use image2=DEPRECATED and we can simply use the position reliably? Right now it seems even harder to think of all edge cases. E.g. consider

import skimage as ski
a = np.ones((10, 10)) * 3
b = np.zeros((10, 10))
ski.util.compare_images(a, b, "unknown")

This will neither warn nor error but will simply return as if the user requested compare_images(a, b) (default method=diff). It should definitely error.

Ouch, yes. Maybe we should also check the args' types then? Let me try something based on your suggestion and ensure that yet this other edge case is covered.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the end, I was lazy and went for 9b9f757 (not trying to figure out what the third positional argument was meant to be...).

warnings.warn(wm_method, category=FutureWarning)
kwargs['method'] = args[len(args) - 1]

if kwargs['image2'] is not DEPRECATED:
deprecated_value = kwargs['image2']
kwargs['image2'] = DEPRECATED
if 'image1' in kwargs.keys():
if 'image0' in kwargs.keys():
raise RuntimeError(
"Use `image0, image1` to pass the two input images."
)
else:
warnings.warn(warning_message, category=FutureWarning)
args = (kwargs['image1'], deprecated_value)
else:
if 'image0' in kwargs.keys():
warnings.warn(warning_message, category=FutureWarning)
args = (kwargs['image0'], deprecated_value)

kwargs.pop('image2')
if 'image0' in kwargs.keys():
kwargs.pop('image0')
if 'image1' in kwargs.keys():
kwargs.pop('image1')
return func(*args, **kwargs)

return wrapper


def compare_images(image1, image2, method='diff', *, n_tiles=(8, 8)):
@_rename_image_params("image2", start_version="0.23", stop_version="0.25")
def compare_images(image0, image1, image2=DEPRECATED, method='diff', *, n_tiles=(8, 8)):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm sorry but unfortunately I think looking at deprecate_parameter has lead you on a wrong path and over-complicated things. And also breaks

a = np.ones(10)
b = np.zeros(10)
ski.util.compare_images(a, b, "blend")
# warns but still uses method="diff"

which used to work before and should continue working during the deprecation. I think we can't use an argument with =DEPRECATED here.

Except for that I think you got quite close, especially with the idea to first turn all args into kwargs to more easily handle them. What do you think about the draft and decorator below? It should work for all cases unless I've forgotten a a way to abuse the signature. It also turns method into a keyword-only parameter.

def _rename_image_params(func):

    @functools.wraps(func)
    def wrapper(*args, **kwargs):

        # Turn all into keyword parameters
        for i, (value, param) in enumerate(
            zip(args, ["image0", "image1", "method", "n_tiles"])
        ):
            if i >= 2:
                warnings.warn("method will become keyword-only")
            if param in kwargs:
                raise ValueError(
                    f"{param} given as positional and keyword argument"
                )
            else:
                kwargs[param] = value
        args = tuple()

        # Account for `image2` if given
        if "image2" in kwargs:
            warnings.warn("used deprecated image2")

            # Safely move `image2` to `image1` if that's empty
            if "image1" in kwargs:
                # Safely move `image1` to `image0`
                if "image0" in kwargs:
                    raise ValueError(
                        "three images given, `image0`, `image1` and `image2`"
                    )
                kwargs["image0"] = kwargs.pop("image1")
            kwargs["image1"] = kwargs.pop("image2")

        return func(*args, **kwargs)

    return wrapper

Of course it doesn't have the nice convenience stuff of modifying the docstring and warnings and error messages are drafts and need to be polished. But I think this deprecation is so special that we should just edit the docstring by hand.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm sorry but unfortunately I think looking at deprecate_parameter has lead you on a wrong path and over-complicated things.

No worries! I did this quickly and thought I would iterate afterwards.

And also breaks

a = np.ones(10)
b = np.zeros(10)
ski.util.compare_images(a, b, "blend")
# warns but still uses method="diff"

which used to work before and should continue working during the deprecation.

Pardon me? I just tried locally and it works; it warns, indeed, and it returns

array([0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5])

just like current main. method="diff" returns

array([1., 1., 1., 1., 1., 1., 1., 1., 1., 1.])

instead. I just noticed that method="checkerboard" errors because a and b are 1-dimensional, which doesn't work with

shapex, shapey = img1.shape

So we should probably improve the function by checking, in the method="checkerboard" case, that ndim == 2 😬

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So we should probably improve the function by checking, in the method="checkerboard" case, that ndim == 2 😬

Done db1b774. If you prefer, I can cherry-pick this commit and submit it as a separate PR.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, you are right, it does handle this. I chose my example poorly and got confused by the image2=DEPRECATED as the third positional arg.

... which brings me to the point that we should probably remove it and make it so that the function shows the expected new signature as un-ambiguously as possible.

"""
Return an image showing the differences between two images.

.. versionadded:: 0.16
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mkcor I corrected a minor indent issue in 9a10712. Also, I'm thinking about how we can reduce the visual noise these kind of directives create in our docs. E.g. this directive I'm commenting on doesn't seem all that useful. We should probably reserve using them for breaking changes when they are absolutely necessary to avoid user confusion. And do we plan on removing them ever? Curious what you think. :)

Let's merge once the CI is green.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, @lagru.

E.g. this directive I'm commenting on doesn't seem all that useful.

I guess it was useful back when the latest release was 0.16, 0.17, ... but it's losing this character of usefulness as time goes by, for sure.

We should probably reserve using them for breaking changes when they are absolutely necessary to avoid user confusion.

I guess it's fair to be informative, even if the information isn't technically 'necessary.' When is it information, when does it become noise...? I can hear that we want to strike the right balance.

I thought that, since these directives existed, they were probably very useful somewhere somewhat... 🙄

And do we plan on removing them ever?

I say we remove them in skimage2 😁


Parameters
----------
image1, image2 : ndarray, shape (M, N)
Images to process, must be of the same shape.
image0 : ndarray, shape (M, N)
First input image.

.. versionadded:: 0.23
image1 : ndarray, shape (M, N)
Second input image. Must be of the same shape as `image0`.

.. versionchanged:: 0.23
`image1` changed from being the name of the first image to that of
the second image.
lagru marked this conversation as resolved.
Show resolved Hide resolved
method : string, optional
Method used for the comparison.
Valid values are {'diff', 'blend', 'checkerboard'}.
Details are provided in the note section.

.. versionchanged:: 0.23
This parameter is now keyword-only.
lagru marked this conversation as resolved.
Show resolved Hide resolved
n_tiles : tuple, optional
Used only for the `checkerboard` method. Specifies the number
of tiles (row, column) to divide the image.

Other Parameters
----------------
image2 : DEPRECATED
Deprecated in favor of `image1`.

.. deprecated:: 0.23

Returns
-------
comparison : ndarray, shape (M, N)
Expand All @@ -34,11 +150,12 @@ def compare_images(image1, image2, method='diff', *, n_tiles=(8, 8)):
``'checkerboard'`` makes tiles of dimension `n_tiles` that display
alternatively the first and the second image.
"""
if image1.shape != image2.shape:

if image1.shape != image0.shape:
raise ValueError('Images must have the same shape.')

img1 = img_as_float(image1)
img2 = img_as_float(image2)
img1 = img_as_float(image0)
img2 = img_as_float(image1)

if method == 'diff':
comparison = np.abs(img2 - img1)
Expand Down
36 changes: 24 additions & 12 deletions skimage/util/tests/test_compare.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,13 @@
import numpy as np

from skimage._shared.testing import assert_array_equal
from skimage._shared import testing
import pytest

from skimage.util.compare import compare_images


def test_compate_images_ValueError_shape():
def test_compare_images_ValueError_shape():
img1 = np.zeros((10, 10), dtype=np.uint8)
img2 = np.zeros((10, 1), dtype=np.uint8)
with testing.raises(ValueError):
with pytest.raises(ValueError):
compare_images(img1, img2)


Expand All @@ -20,8 +18,22 @@ def test_compare_images_diff():
img2[3:8, 0:8] = 255
expected_result = np.zeros_like(img1, dtype=np.float64)
expected_result[3:8, 0:3] = 1
result = compare_images(img1, img2, method='diff')
assert_array_equal(result, expected_result)
with pytest.warns(FutureWarning):
result = compare_images(img1, img2, 'diff')
mkcor marked this conversation as resolved.
Show resolved Hide resolved
np.testing.assert_array_equal(result, expected_result)


def test_compare_images_replaced_param():
img1 = np.zeros((10, 10), dtype=np.uint8)
img1[3:8, 3:8] = 255
img2 = np.zeros_like(img1)
img2[3:8, 0:8] = 255
with pytest.warns(FutureWarning):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Might at least use match=".*image[01] here and elsewhere.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure... I removed the case where input images are passed as kwargs following the new API (e0d5929), so we're not quite cycling over all image0-image1 combinations when expecting a warning. Or maybe my command of regular expressions is not up to the mark 😉

compare_images(image1=img1, image2=img2)
with pytest.warns(FutureWarning):
compare_images(image0=img1, image2=img2)
with pytest.warns(FutureWarning):
compare_images(img1, image2=img2)


def test_compare_images_blend():
Expand All @@ -33,7 +45,7 @@ def test_compare_images_blend():
expected_result[3:8, 3:8] = 1
expected_result[3:8, 0:3] = 0.5
result = compare_images(img1, img2, method='blend')
assert_array_equal(result, expected_result)
np.testing.assert_array_equal(result, expected_result)


def test_compare_images_checkerboard_default():
Expand All @@ -45,9 +57,9 @@ def test_compare_images_checkerboard_default():
exp_row2 = np.array([1., 1., 0., 0., 1., 1., 0., 0., 1., 1., 0., 0., 1., 1., 0., 0.])
# fmt: on
for i in (0, 1, 4, 5, 8, 9, 12, 13):
assert_array_equal(res[i, :], exp_row1)
np.testing.assert_array_equal(res[i, :], exp_row1)
for i in (2, 3, 6, 7, 10, 11, 14, 15):
assert_array_equal(res[i, :], exp_row2)
np.testing.assert_array_equal(res[i, :], exp_row2)


def test_compare_images_checkerboard_tuple():
Expand All @@ -61,6 +73,6 @@ def test_compare_images_checkerboard_tuple():
[1.0, 1.0, 0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 1.0, 1.0, 0.0, 0.0]
)
for i in (0, 1, 2, 3, 8, 9, 10, 11):
assert_array_equal(res[i, :], exp_row1)
np.testing.assert_array_equal(res[i, :], exp_row1)
for i in (4, 5, 6, 7, 12, 13, 14, 15):
assert_array_equal(res[i, :], exp_row2)
np.testing.assert_array_equal(res[i, :], exp_row2)