From e8425dcbd9953e96f40d724a48a6498cc5be1b89 Mon Sep 17 00:00:00 2001 From: Derek Homeier Date: Thu, 29 Jul 2021 15:28:26 +0200 Subject: [PATCH 1/6] Support rotation of Ellipse,RectanglePixelRegion MPL selectors --- regions/shapes/ellipse.py | 36 +++++++++++++++++++++++----------- regions/shapes/rectangle.py | 39 ++++++++++++++++++++++++------------- 2 files changed, 51 insertions(+), 24 deletions(-) diff --git a/regions/shapes/ellipse.py b/regions/shapes/ellipse.py index c4bfca37..f4c9a02c 100644 --- a/regions/shapes/ellipse.py +++ b/regions/shapes/ellipse.py @@ -210,13 +210,23 @@ def as_artist(self, origin=(0, 0), **kwargs): **mpl_kwargs) def _update_from_mpl_selector(self, *args, **kwargs): + # _rect_properties replace _rect_bbox in matplotlib#19864 + # "Note that if rotation != 0, ``xmin, ymin`` are interpreted as the + # lower corner, and ``xmax, ymax`` are calculated using only width and + # height assuming no rotation." + xmin, xmax, ymin, ymax = self._mpl_selector.extents - self.center = PixCoord(x=0.5 * (xmin + xmax), - y=0.5 * (ymin + ymax)) - self.width = (xmax - xmin) - self.height = (ymax - ymin) - self.angle = 0. * u.deg - if self._mpl_selector_callback is not None: + self.width = xmax - xmin + self.height = ymax - ymin + if hasattr(self._mpl_selector, 'rotation'): + rotation = self._mpl_selector.rotation + self.center = PixCoord(*self._mpl_selector.center) + else: + self.center = PixCoord(x=0.5 * (xmin + xmax), y=0.5 * (ymin + ymax)) + rotation = 0 + self.angle = rotation * u.radian + + if getattr(self, '_mpl_selector_callback', None) is not None: self._mpl_selector_callback(self) def as_mpl_selector(self, ax, active=True, sync=True, callback=None, @@ -266,7 +276,7 @@ def as_mpl_selector(self, ax, active=True, sync=True, callback=None, if hasattr(self, '_mpl_selector'): raise AttributeError('Cannot attach more than one selector to a region.') - if self.angle.value != 0: + if self.angle.value != 0 and not hasattr(EllipseSelector, '_rotation'): raise NotImplementedError('Cannot create matplotlib selector for rotated ellipse.') if sync: @@ -286,10 +296,14 @@ def sync_callback(*args, **kwargs): ax, sync_callback, interactive=True, drag_from_anywhere=drag_from_anywhere, **kwargs) - self._mpl_selector.extents = (self.center.x - self.width / 2, - self.center.x + self.width / 2, - self.center.y - self.height / 2, - self.center.y + self.height / 2) + xy0 = [self.center.x - self.width / 2, self.center.y - self.height / 2] + self._mpl_selector.extents = (xy0[0], self.center.x + self.width / 2, + xy0[1], self.center.y + self.height / 2) + + if self.angle.value != 0: + self._mpl_selector._set_corner_width_rotation(xy0, self.width, self.height, + self.angle.to_value('radian')) + self._mpl_selector.set_active(active) self._mpl_selector_callback = callback diff --git a/regions/shapes/rectangle.py b/regions/shapes/rectangle.py index 773d9711..4f1ff795 100644 --- a/regions/shapes/rectangle.py +++ b/regions/shapes/rectangle.py @@ -206,13 +206,23 @@ def as_artist(self, origin=(0, 0), **kwargs): angle=angle, **mpl_kwargs) def _update_from_mpl_selector(self, *args, **kwargs): + # _rect_properties replace _rect_bbox in matplotlib#19864 + # "Note that if rotation != 0, ``xmin, ymin`` are interpreted as the + # lower corner, and ``xmax, ymax`` are calculated using only width and + # height assuming no rotation." + xmin, xmax, ymin, ymax = self._mpl_selector.extents - self.center = PixCoord(x=0.5 * (xmin + xmax), - y=0.5 * (ymin + ymax)) - self.width = (xmax - xmin) - self.height = (ymax - ymin) - self.angle = 0. * u.deg - if self._mpl_selector_callback is not None: + self.width = xmax - xmin + self.height = ymax - ymin + if hasattr(self._mpl_selector, 'rotation'): + rotation = self._mpl_selector.rotation + self.center = PixCoord(*self._mpl_selector.center) + else: + self.center = PixCoord(x=0.5 * (xmin + xmax), y=0.5 * (ymin + ymax)) + rotation = 0 + self.angle = rotation * u.radian + + if getattr(self, '_mpl_selector_callback', None) is not None: self._mpl_selector_callback(self) def as_mpl_selector(self, ax, active=True, sync=True, callback=None, @@ -262,9 +272,8 @@ def as_mpl_selector(self, ax, active=True, sync=True, callback=None, if hasattr(self, '_mpl_selector'): raise AttributeError('Cannot attach more than one selector to a region.') - if self.angle.value != 0: - raise NotImplementedError('Cannot create matplotlib selector for ' - 'rotated rectangle.') + if self.angle.value != 0 and not hasattr(RectangleSelector, '_rotation'): + raise NotImplementedError('Cannot create matplotlib selector for rotated rectangle.') if sync: sync_callback = self._update_from_mpl_selector @@ -283,10 +292,14 @@ def sync_callback(*args, **kwargs): ax, sync_callback, interactive=True, drag_from_anywhere=drag_from_anywhere, **kwargs) - self._mpl_selector.extents = (self.center.x - self.width / 2, - self.center.x + self.width / 2, - self.center.y - self.height / 2, - self.center.y + self.height / 2) + xy0 = [self.center.x - self.width / 2, self.center.y - self.height / 2] + self._mpl_selector.extents = (xy0[0], self.center.x + self.width / 2, + xy0[1], self.center.y + self.height / 2) + + if self.angle.value != 0: + self._mpl_selector._set_corner_width_rotation(xy0, self.width, self.height, + self.angle.to_value('radian')) + self._mpl_selector.set_active(active) self._mpl_selector_callback = callback From b9425a42290b8bf4ec0c668b602d1fce168c0b99 Mon Sep 17 00:00:00 2001 From: Derek Homeier Date: Thu, 29 Jul 2021 15:31:29 +0200 Subject: [PATCH 2/6] TST: Update `test_as_mpl_selector` to accept rotated regions --- CHANGES.rst | 3 +++ regions/shapes/ellipse.py | 5 ++--- regions/shapes/rectangle.py | 5 ++--- regions/shapes/tests/test_ellipse.py | 16 ++++++++++------ regions/shapes/tests/test_rectangle.py | 17 +++++++++++------ 5 files changed, 28 insertions(+), 18 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 46ec86e3..b7ef214f 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -125,6 +125,9 @@ New Features - Added the DS9 'boxcircle' point symbol. [#387] +- Enable rotation of the ``as_mpl_selector`` widgets for rectangular + and ellipse regions with matplotlib versions supporting this. [#390] + - Added the ability to add and subtract ``PixCoord`` objects. [#396] - Added an ``origin`` keyword to ``PolygonPixelRegion`` to allow diff --git a/regions/shapes/ellipse.py b/regions/shapes/ellipse.py index f4c9a02c..fa10c81d 100644 --- a/regions/shapes/ellipse.py +++ b/regions/shapes/ellipse.py @@ -276,7 +276,7 @@ def as_mpl_selector(self, ax, active=True, sync=True, callback=None, if hasattr(self, '_mpl_selector'): raise AttributeError('Cannot attach more than one selector to a region.') - if self.angle.value != 0 and not hasattr(EllipseSelector, '_rotation'): + if self.angle.value != 0 and not hasattr(EllipseSelector, 'rotation'): raise NotImplementedError('Cannot create matplotlib selector for rotated ellipse.') if sync: @@ -301,8 +301,7 @@ def sync_callback(*args, **kwargs): xy0[1], self.center.y + self.height / 2) if self.angle.value != 0: - self._mpl_selector._set_corner_width_rotation(xy0, self.width, self.height, - self.angle.to_value('radian')) + self._mpl_selector.rotation = self.angle.to_value('radian') self._mpl_selector.set_active(active) self._mpl_selector_callback = callback diff --git a/regions/shapes/rectangle.py b/regions/shapes/rectangle.py index 4f1ff795..c783f1e8 100644 --- a/regions/shapes/rectangle.py +++ b/regions/shapes/rectangle.py @@ -272,7 +272,7 @@ def as_mpl_selector(self, ax, active=True, sync=True, callback=None, if hasattr(self, '_mpl_selector'): raise AttributeError('Cannot attach more than one selector to a region.') - if self.angle.value != 0 and not hasattr(RectangleSelector, '_rotation'): + if self.angle.value != 0 and not hasattr(RectangleSelector, 'rotation'): raise NotImplementedError('Cannot create matplotlib selector for rotated rectangle.') if sync: @@ -297,8 +297,7 @@ def sync_callback(*args, **kwargs): xy0[1], self.center.y + self.height / 2) if self.angle.value != 0: - self._mpl_selector._set_corner_width_rotation(xy0, self.width, self.height, - self.angle.to_value('radian')) + self._mpl_selector.rotation = self.angle.to_value('radian') self._mpl_selector.set_active(active) self._mpl_selector_callback = callback diff --git a/regions/shapes/tests/test_ellipse.py b/regions/shapes/tests/test_ellipse.py index 574c99ff..6dc6c331 100644 --- a/regions/shapes/tests/test_ellipse.py +++ b/regions/shapes/tests/test_ellipse.py @@ -129,12 +129,16 @@ def update_mask(reg): # For now this will only work with unrotated ellipses. Once this # works with rotated ellipses, the following exception check can # be removed as well as the ``angle=0 * u.deg`` in the call to - # copy() below. - with pytest.raises(NotImplementedError, - match=('Cannot create matplotlib selector for rotated ellipse.')): - self.reg.as_mpl_selector(ax) + # copy() below - should (hopefully) be implemented with mpl 3.6. + if MPL_VERSION < 36: + with pytest.raises(NotImplementedError, + match=('Cannot create matplotlib selector for rotated ellipse.')): + self.reg.as_mpl_selector(ax) + angle = 0 * u.deg + else: + angle = self.reg.angle - region = self.reg.copy(angle=0 * u.deg) + region = self.reg.copy(angle=angle) selector = region.as_mpl_selector(ax, callback=update_mask, sync=sync) @@ -160,7 +164,7 @@ def update_mask(reg): assert_allclose(region.center.y, 4) assert_allclose(region.width, 4) assert_allclose(region.height, 3) - assert_quantity_allclose(region.angle, 0 * u.deg) + assert_quantity_allclose(region.angle, angle) assert_equal(mask, 0) diff --git a/regions/shapes/tests/test_rectangle.py b/regions/shapes/tests/test_rectangle.py index 535b198c..f3ca8444 100644 --- a/regions/shapes/tests/test_rectangle.py +++ b/regions/shapes/tests/test_rectangle.py @@ -135,12 +135,17 @@ def update_mask(reg): # For now this will only work with unrotated rectangles. Once # this works with rotated rectangles, the following exception # check can be removed as well as the ``angle=0 * u.deg`` in the - # call to copy() below. - with pytest.raises(NotImplementedError, - match=('Cannot create matplotlib selector for rotated rectangle.')): - self.reg.as_mpl_selector(ax) + # copy() below - should (hopefully) be implemented with mpl 3.6. + if MPL_VERSION < 36: + with pytest.raises(NotImplementedError, + match=('Cannot create matplotlib selector for rotated rectangle.')): + self.reg.as_mpl_selector(ax) - region = self.reg.copy(angle=0 * u.deg) + angle = 0 * u.deg + else: + angle = self.reg.angle + + region = self.reg.copy(angle=angle) selector = region.as_mpl_selector(ax, callback=update_mask, sync=sync) @@ -164,7 +169,7 @@ def update_mask(reg): assert_allclose(region.center.y, 4) assert_allclose(region.width, 4) assert_allclose(region.height, 3) - assert_quantity_allclose(region.angle, 0 * u.deg) + assert_quantity_allclose(region.angle, angle) assert_equal(mask, 0) From 2a094ffcb5213967b05f6d15e350a94640682a60 Mon Sep 17 00:00:00 2001 From: Derek Homeier Date: Thu, 17 Feb 2022 20:04:05 +0100 Subject: [PATCH 3/6] Switch to mpl #20839 rotation implementation --- regions/shapes/ellipse.py | 14 +++++++------ regions/shapes/rectangle.py | 20 ++++++++++--------- regions/shapes/tests/test_ellipse.py | 27 ++++++++++++-------------- regions/shapes/tests/test_rectangle.py | 25 ++++++++++++------------ 4 files changed, 43 insertions(+), 43 deletions(-) diff --git a/regions/shapes/ellipse.py b/regions/shapes/ellipse.py index fa10c81d..95efabe2 100644 --- a/regions/shapes/ellipse.py +++ b/regions/shapes/ellipse.py @@ -210,10 +210,10 @@ def as_artist(self, origin=(0, 0), **kwargs): **mpl_kwargs) def _update_from_mpl_selector(self, *args, **kwargs): - # _rect_properties replace _rect_bbox in matplotlib#19864 - # "Note that if rotation != 0, ``xmin, ymin`` are interpreted as the + # _rect_properties replace _rect_bbox in matplotlib#19864, unchanged in #20839. + # "Note that if rotation != 0, ``xmin, ymin`` are always interpreted as the # lower corner, and ``xmax, ymax`` are calculated using only width and - # height assuming no rotation." + # height assuming no rotation (as specified for ``selector.extents``)." xmin, xmax, ymin, ymax = self._mpl_selector.extents self.width = xmax - xmin @@ -224,7 +224,7 @@ def _update_from_mpl_selector(self, *args, **kwargs): else: self.center = PixCoord(x=0.5 * (xmin + xmax), y=0.5 * (ymin + ymax)) rotation = 0 - self.angle = rotation * u.radian + self.angle = rotation * u.deg if getattr(self, '_mpl_selector_callback', None) is not None: self._mpl_selector_callback(self) @@ -272,12 +272,14 @@ def as_mpl_selector(self, ax, active=True, sync=True, callback=None, ``selector.set_active(True)`` or ``selector.set_active(False)``. """ from matplotlib.widgets import EllipseSelector + from matplotlib import __version__ as MPL_VER_STR if hasattr(self, '_mpl_selector'): raise AttributeError('Cannot attach more than one selector to a region.') if self.angle.value != 0 and not hasattr(EllipseSelector, 'rotation'): - raise NotImplementedError('Cannot create matplotlib selector for rotated ellipse.') + raise NotImplementedError('Creating selectors for rotated shapes is not ' + f'yet supported with matplotlib {MPL_VER_STR}.') if sync: sync_callback = self._update_from_mpl_selector @@ -301,7 +303,7 @@ def sync_callback(*args, **kwargs): xy0[1], self.center.y + self.height / 2) if self.angle.value != 0: - self._mpl_selector.rotation = self.angle.to_value('radian') + self._mpl_selector.rotation = self.angle.to_value('deg') self._mpl_selector.set_active(active) self._mpl_selector_callback = callback diff --git a/regions/shapes/rectangle.py b/regions/shapes/rectangle.py index c783f1e8..f2637eaa 100644 --- a/regions/shapes/rectangle.py +++ b/regions/shapes/rectangle.py @@ -206,10 +206,10 @@ def as_artist(self, origin=(0, 0), **kwargs): angle=angle, **mpl_kwargs) def _update_from_mpl_selector(self, *args, **kwargs): - # _rect_properties replace _rect_bbox in matplotlib#19864 - # "Note that if rotation != 0, ``xmin, ymin`` are interpreted as the + # _rect_properties replace _rect_bbox in matplotlib#19864, unchanged in #20839. + # "Note that if rotation != 0, ``xmin, ymin`` are always interpreted as the # lower corner, and ``xmax, ymax`` are calculated using only width and - # height assuming no rotation." + # height assuming no rotation (as specified for ``selector.extents``)." xmin, xmax, ymin, ymax = self._mpl_selector.extents self.width = xmax - xmin @@ -220,7 +220,7 @@ def _update_from_mpl_selector(self, *args, **kwargs): else: self.center = PixCoord(x=0.5 * (xmin + xmax), y=0.5 * (ymin + ymax)) rotation = 0 - self.angle = rotation * u.radian + self.angle = rotation * u.deg if getattr(self, '_mpl_selector_callback', None) is not None: self._mpl_selector_callback(self) @@ -268,12 +268,14 @@ def as_mpl_selector(self, ax, active=True, sync=True, callback=None, ``selector.set_active(True)`` or ``selector.set_active(False)``. """ from matplotlib.widgets import RectangleSelector + from matplotlib import __version__ as MPL_VER_STR if hasattr(self, '_mpl_selector'): raise AttributeError('Cannot attach more than one selector to a region.') if self.angle.value != 0 and not hasattr(RectangleSelector, 'rotation'): - raise NotImplementedError('Cannot create matplotlib selector for rotated rectangle.') + raise NotImplementedError('Creating selectors for rotated shapes is not ' + f'yet supported with matplotlib {MPL_VER_STR}.') if sync: sync_callback = self._update_from_mpl_selector @@ -292,12 +294,12 @@ def sync_callback(*args, **kwargs): ax, sync_callback, interactive=True, drag_from_anywhere=drag_from_anywhere, **kwargs) - xy0 = [self.center.x - self.width / 2, self.center.y - self.height / 2] - self._mpl_selector.extents = (xy0[0], self.center.x + self.width / 2, - xy0[1], self.center.y + self.height / 2) + dxy = [self.width / 2, self.height / 2] + self._mpl_selector.extents = (self.center.x - dxy[0], self.center.x + dxy[0], + self.center.y - dxy[1], self.center.y + dxy[1]) if self.angle.value != 0: - self._mpl_selector.rotation = self.angle.to_value('radian') + self._mpl_selector.rotation = self.angle.to_value('deg') self._mpl_selector.set_active(active) self._mpl_selector_callback = callback diff --git a/regions/shapes/tests/test_ellipse.py b/regions/shapes/tests/test_ellipse.py index 6dc6c331..4a21f94d 100644 --- a/regions/shapes/tests/test_ellipse.py +++ b/regions/shapes/tests/test_ellipse.py @@ -115,6 +115,7 @@ def test_as_mpl_selector(self, sync): plt = pytest.importorskip('matplotlib.pyplot') from matplotlib.testing.widgets import do_event + from matplotlib import __version_info__ as MPL_VERSION rng = np.random.default_rng(0) data = rng.random((16, 16)) @@ -130,14 +131,18 @@ def update_mask(reg): # works with rotated ellipses, the following exception check can # be removed as well as the ``angle=0 * u.deg`` in the call to # copy() below - should (hopefully) be implemented with mpl 3.6. - if MPL_VERSION < 36: + expected = [8.3, 4.9, 2.0, 1.0] + if MPL_VERSION < (3, 6, 0): with pytest.raises(NotImplementedError, - match=('Cannot create matplotlib selector for rotated ellipse.')): + match='Creating selectors for rotated shapes is not yet supported'): self.reg.as_mpl_selector(ax) angle = 0 * u.deg else: angle = self.reg.angle + if not sync: + expected = [3, 4, 4, 3] + region = self.reg.copy(angle=angle) selector = region.as_mpl_selector(ax, callback=update_mask, sync=sync) @@ -148,24 +153,16 @@ def update_mask(reg): ax.figure.canvas.draw() - if sync: + assert_allclose(region.center.x, expected[0]) + assert_allclose(region.center.y, expected[1]) + assert_allclose(region.width, expected[2]) + assert_allclose(region.height, expected[3]) - assert_allclose(region.center.x, 8.3) - assert_allclose(region.center.y, 4.9) - assert_allclose(region.width, 2) - assert_allclose(region.height, 1) + if sync: assert_quantity_allclose(region.angle, 0 * u.deg) - assert_equal(mask, region.to_mask(mode='subpixels', subpixels=10).to_image(data.shape)) - else: - - assert_allclose(region.center.x, 3) - assert_allclose(region.center.y, 4) - assert_allclose(region.width, 4) - assert_allclose(region.height, 3) assert_quantity_allclose(region.angle, angle) - assert_equal(mask, 0) with pytest.raises(AttributeError, match=('Cannot attach more than one selector to a reg')): diff --git a/regions/shapes/tests/test_rectangle.py b/regions/shapes/tests/test_rectangle.py index f3ca8444..d0f24384 100644 --- a/regions/shapes/tests/test_rectangle.py +++ b/regions/shapes/tests/test_rectangle.py @@ -121,6 +121,7 @@ def test_eq(self): def test_as_mpl_selector(self, sync): plt = pytest.importorskip('matplotlib.pyplot') from matplotlib.testing.widgets import do_event + from matplotlib import __version_info__ as MPL_VERSION rng = np.random.default_rng(0) data = rng.random((16, 16)) @@ -136,15 +137,19 @@ def update_mask(reg): # this works with rotated rectangles, the following exception # check can be removed as well as the ``angle=0 * u.deg`` in the # copy() below - should (hopefully) be implemented with mpl 3.6. - if MPL_VERSION < 36: + expected = [8.3, 4.9, 2.0, 1.0] + if MPL_VERSION < (3, 6, 0): with pytest.raises(NotImplementedError, - match=('Cannot create matplotlib selector for rotated rectangle.')): + match='Creating selectors for rotated shapes is not yet supported'): self.reg.as_mpl_selector(ax) angle = 0 * u.deg else: angle = self.reg.angle + if not sync: + expected = [3, 4, 4, 3] + region = self.reg.copy(angle=angle) selector = region.as_mpl_selector(ax, callback=update_mask, sync=sync) @@ -155,22 +160,16 @@ def update_mask(reg): ax.figure.canvas.draw() + assert_allclose(region.center.x, expected[0]) + assert_allclose(region.center.y, expected[1]) + assert_allclose(region.width, expected[2]) + assert_allclose(region.height, expected[3]) + if sync: - assert_allclose(region.center.x, 8.3) - assert_allclose(region.center.y, 4.9) - assert_allclose(region.width, 2) - assert_allclose(region.height, 1) assert_quantity_allclose(region.angle, 0 * u.deg) - assert_equal(mask, region.to_mask(mode='subpixels', subpixels=10).to_image(data.shape)) - else: - assert_allclose(region.center.x, 3) - assert_allclose(region.center.y, 4) - assert_allclose(region.width, 4) - assert_allclose(region.height, 3) assert_quantity_allclose(region.angle, angle) - assert_equal(mask, 0) with pytest.raises(AttributeError, match=('Cannot attach more than one selector to a reg')): From 5af413653fbccd71d8d3174b4b07b8b7d2b6d94a Mon Sep 17 00:00:00 2001 From: Derek Homeier Date: Mon, 23 Dec 2024 20:42:14 +0100 Subject: [PATCH 4/6] Fix from_selector rotation direction, width and height --- regions/shapes/ellipse.py | 26 +++++++++++++------------- regions/shapes/rectangle.py | 26 +++++++++++++------------- 2 files changed, 26 insertions(+), 26 deletions(-) diff --git a/regions/shapes/ellipse.py b/regions/shapes/ellipse.py index 95efabe2..92b1d17e 100644 --- a/regions/shapes/ellipse.py +++ b/regions/shapes/ellipse.py @@ -200,7 +200,7 @@ def as_artist(self, origin=(0, 0), **kwargs): xy = self.center.x - origin[0], self.center.y - origin[1] width = self.width height = self.height - # matplotlib expects rotation in degrees (anti-clockwise) + # matplotlib expects rotation in degrees (counter-clockwise) angle = self.angle.to('deg').value mpl_kwargs = self.visual.define_mpl_kwargs(self._mpl_artist) @@ -210,19 +210,19 @@ def as_artist(self, origin=(0, 0), **kwargs): **mpl_kwargs) def _update_from_mpl_selector(self, *args, **kwargs): - # _rect_properties replace _rect_bbox in matplotlib#19864, unchanged in #20839. - # "Note that if rotation != 0, ``xmin, ymin`` are always interpreted as the - # lower corner, and ``xmax, ymax`` are calculated using only width and - # height assuming no rotation (as specified for ``selector.extents``)." - - xmin, xmax, ymin, ymax = self._mpl_selector.extents - self.width = xmax - xmin - self.height = ymax - ymin + # matplotlib#26833 removed _rect_properties / _rect_bbox, + # ``selector.extents`` are now following rotation, i.e. giving min/max x and y + # for the _rotated_ rectangle. For proper width and height use bbox edge distances + # in both dimensions (take 0|2 as lower|upper x, 1|3 as lower|upper y edge). + + xec, yec = self._mpl_selector.edge_centers + self.width = np.sqrt((xec[2] - xec[0])**2 + (yec[2] - yec[0])**2) + self.height = np.sqrt((xec[3] - xec[1])**2 + (yec[3] - yec[1])**2) + self.center = PixCoord(*self._mpl_selector.center) + # matplotlib defines rotation counter-clockwise (available from 3.6.0 on) if hasattr(self._mpl_selector, 'rotation'): - rotation = self._mpl_selector.rotation - self.center = PixCoord(*self._mpl_selector.center) + rotation = -self._mpl_selector.rotation else: - self.center = PixCoord(x=0.5 * (xmin + xmax), y=0.5 * (ymin + ymax)) rotation = 0 self.angle = rotation * u.deg @@ -303,7 +303,7 @@ def sync_callback(*args, **kwargs): xy0[1], self.center.y + self.height / 2) if self.angle.value != 0: - self._mpl_selector.rotation = self.angle.to_value('deg') + self._mpl_selector.rotation = -self.angle.to_value('deg') self._mpl_selector.set_active(active) self._mpl_selector_callback = callback diff --git a/regions/shapes/rectangle.py b/regions/shapes/rectangle.py index f2637eaa..27d7f80f 100644 --- a/regions/shapes/rectangle.py +++ b/regions/shapes/rectangle.py @@ -196,7 +196,7 @@ def as_artist(self, origin=(0, 0), **kwargs): xy = xy[0] - origin[0], xy[1] - origin[1] width = self.width height = self.height - # matplotlib expects rotation in degrees (anti-clockwise) + # matplotlib expects rotation in degrees (counter-clockwise) angle = self.angle.to('deg').value mpl_kwargs = self.visual.define_mpl_kwargs(self._mpl_artist) @@ -206,19 +206,19 @@ def as_artist(self, origin=(0, 0), **kwargs): angle=angle, **mpl_kwargs) def _update_from_mpl_selector(self, *args, **kwargs): - # _rect_properties replace _rect_bbox in matplotlib#19864, unchanged in #20839. - # "Note that if rotation != 0, ``xmin, ymin`` are always interpreted as the - # lower corner, and ``xmax, ymax`` are calculated using only width and - # height assuming no rotation (as specified for ``selector.extents``)." - - xmin, xmax, ymin, ymax = self._mpl_selector.extents - self.width = xmax - xmin - self.height = ymax - ymin + # matplotlib#26833 removed _rect_properties / _rect_bbox, + # ``selector.extents`` are now following rotation, i.e. giving min/max x and y + # for the _rotated_ rectangle. For proper width and height use edge distances + # in both dimensions (take 0|2 as lower|upper x, 1|3 as lower|upper y edge). + + xec, yec = self._mpl_selector.edge_centers + self.width = np.sqrt((xec[2] - xec[0])**2 + (yec[2] - yec[0])**2) + self.height = np.sqrt((xec[3] - xec[1])**2 + (yec[3] - yec[1])**2) + self.center = PixCoord(*self._mpl_selector.center) + # matplotlib defines rotation counter-clockwise (available from 3.6.0 on) if hasattr(self._mpl_selector, 'rotation'): - rotation = self._mpl_selector.rotation - self.center = PixCoord(*self._mpl_selector.center) + rotation = -self._mpl_selector.rotation else: - self.center = PixCoord(x=0.5 * (xmin + xmax), y=0.5 * (ymin + ymax)) rotation = 0 self.angle = rotation * u.deg @@ -299,7 +299,7 @@ def sync_callback(*args, **kwargs): self.center.y - dxy[1], self.center.y + dxy[1]) if self.angle.value != 0: - self._mpl_selector.rotation = self.angle.to_value('deg') + self._mpl_selector.rotation = -self.angle.to_value('deg') self._mpl_selector.set_active(active) self._mpl_selector_callback = callback From 52f97494d516bc6ee77e85ace7777eda3806280d Mon Sep 17 00:00:00 2001 From: Derek Homeier Date: Fri, 27 Dec 2024 21:02:32 +0100 Subject: [PATCH 5/6] TST: fix selector_drag from anywhere, add rotated and resize tests --- regions/shapes/tests/test_ellipse.py | 91 ++++++++++++++++++------ regions/shapes/tests/test_rectangle.py | 95 +++++++++++++++++++------- 2 files changed, 143 insertions(+), 43 deletions(-) diff --git a/regions/shapes/tests/test_ellipse.py b/regions/shapes/tests/test_ellipse.py index 4a21f94d..c97bc69b 100644 --- a/regions/shapes/tests/test_ellipse.py +++ b/regions/shapes/tests/test_ellipse.py @@ -153,10 +153,8 @@ def update_mask(reg): ax.figure.canvas.draw() - assert_allclose(region.center.x, expected[0]) - assert_allclose(region.center.y, expected[1]) - assert_allclose(region.width, expected[2]) - assert_allclose(region.height, expected[3]) + assert_allclose(region.center.xy, expected[:2], atol=1e-12, rtol=0) + assert_allclose((region.width, region.height), expected[2:], atol=1e-12, rtol=0) if sync: assert_quantity_allclose(region.angle, 0 * u.deg) @@ -169,12 +167,17 @@ def update_mask(reg): region.as_mpl_selector(ax) @pytest.mark.parametrize('anywhere', (False, True)) - def test_mpl_selector_drag(self, anywhere): + @pytest.mark.parametrize('rotate', (0, -20)) + def test_mpl_selector_drag(self, anywhere, rotate): """ Test dragging of entire region from central handle and anywhere. """ plt = pytest.importorskip('matplotlib.pyplot') from matplotlib.testing.widgets import do_event + from matplotlib import __version_info__ as MPL_VERSION + + if rotate != 0 and MPL_VERSION < (3, 6, 0): + pytest.xfail('Creating selectors for rotated shapes is not yet supported') rng = np.random.default_rng(0) data = rng.random((16, 16)) @@ -186,12 +189,18 @@ def test_mpl_selector_drag(self, anywhere): def update_mask(reg): mask[:] = reg.to_mask(mode='subpixels', subpixels=10).to_image(data.shape) - region = self.reg.copy(angle=0 * u.deg) + region = self.reg.copy(angle=rotate * u.deg) selector = region.as_mpl_selector(ax, callback=update_mask, drag_from_anywhere=anywhere) + assert selector.drag_from_anywhere is anywhere assert region._mpl_selector.drag_from_anywhere is anywhere + assert_allclose(region.center.xy, (3.0, 4.0), atol=1e-12, rtol=0) + assert_allclose(region.angle.value, rotate, atol=1e-12, rtol=0) + assert_allclose(selector.center, (3.0, 4.0), atol=1e-12, rtol=0) + if MPL_VERSION >= (3, 6, 0): + assert_allclose(selector.rotation, -rotate, atol=1e-12, rtol=0) # click_and_drag(selector, start=(3, 4), end=(3.5, 4.5)) do_event(selector, 'press', xdata=3, ydata=4, button=1) @@ -200,26 +209,68 @@ def update_mask(reg): ax.figure.canvas.draw() - assert_allclose(region.center.x, 3.5) - assert_allclose(region.center.y, 4.5) - assert_allclose(region.width, 4) - assert_allclose(region.height, 3) + assert_allclose(region.center.xy, (3.5, 4.5), atol=1e-12, rtol=0) + assert_allclose((region.width, region.height), (4, 3), atol=1e-12, rtol=0) - do_event(selector, 'press', xdata=3.25, ydata=4.25, button=1) - do_event(selector, 'onmove', xdata=4.25, ydata=5.25, button=1) - do_event(selector, 'release', xdata=4.25, ydata=5.25, button=1) + # click_and_drag from outside centre handle, but inside region + do_event(selector, 'press', xdata=4.0, ydata=5.5, button=1) + do_event(selector, 'onmove', xdata=6.0, ydata=6.5, button=1) + do_event(selector, 'release', xdata=6.0, ydata=6.5, button=1) ax.figure.canvas.draw() - # For drag_from_anywhere=False this will have created a new 1x1 rectangle. + # For drag_from_anywhere=False this will have created a new 2x1 bbox ellipse. if anywhere: - assert_allclose(region.center.x, 4.5) - assert_allclose(region.center.y, 5.5) - assert_allclose(region.width, 4) - assert_allclose(region.height, 3) + assert_allclose(region.center.xy, (5.5, 5.5), atol=1e-12, rtol=0) + assert_allclose((region.width, region.height), (4, 3), atol=1e-12, rtol=0) else: - assert_allclose(region.center.x, 4.5) - assert_allclose(region.center.y, 5.5) + assert_allclose(region.center.xy, (5, 6), atol=1e-12, rtol=0) + assert_allclose((region.width, region.height), (2, 1), atol=1e-12, rtol=0) + + assert_equal(mask, region.to_mask(mode='subpixels', subpixels=10).to_image(data.shape)) + + def test_mpl_selector_resize(self): + """ + Test resizing of region on edge and corner handles. + """ + plt = pytest.importorskip('matplotlib.pyplot') + from matplotlib.testing.widgets import ( + do_event) # click_and_drag # MPL_VERSION >= 36 + + rng = np.random.default_rng(0) + data = rng.random((16, 16)) + mask = np.zeros_like(data) + + ax = plt.subplot(1, 1, 1) + ax.imshow(data) + + def update_mask(reg): + mask[:] = reg.to_mask(mode='subpixels', subpixels=10).to_image(data.shape) + + region = self.reg.copy(angle=0 * u.deg) + + selector = region.as_mpl_selector(ax, callback=update_mask) + assert region._mpl_selector.drag_from_anywhere is False + + # click_and_drag(selector, start=(5, 5.5), end=(7, 5.5)) (drag right edge +1) + do_event(selector, 'press', xdata=5, ydata=5.5, button=1) + do_event(selector, 'onmove', xdata=7, ydata=5.5, button=1) + do_event(selector, 'release', xdata=7, ydata=5.5, button=1) + + ax.figure.canvas.draw() + + assert_allclose(region.center.xy, (4, 4), atol=1e-12, rtol=0) + assert_allclose((region.width, region.height), (6, 3), atol=1e-12, rtol=0) + + # click_and_drag(selector, start=(7, 5.5), end=(5, 7.5)) (upper right corner -2|+2) + do_event(selector, 'press', xdata=7, ydata=5.5, button=1) + do_event(selector, 'onmove', xdata=5, ydata=7.5, button=1) + do_event(selector, 'release', xdata=5, ydata=7.5, button=1) + + ax.figure.canvas.draw() + + assert_allclose(region.center.xy, (3, 5), atol=1e-12, rtol=0) + assert_allclose((region.width, region.height), (4, 5), atol=1e-12, rtol=0) assert_equal(mask, region.to_mask(mode='subpixels', subpixels=10).to_image(data.shape)) diff --git a/regions/shapes/tests/test_rectangle.py b/regions/shapes/tests/test_rectangle.py index d0f24384..94faffd3 100644 --- a/regions/shapes/tests/test_rectangle.py +++ b/regions/shapes/tests/test_rectangle.py @@ -160,10 +160,8 @@ def update_mask(reg): ax.figure.canvas.draw() - assert_allclose(region.center.x, expected[0]) - assert_allclose(region.center.y, expected[1]) - assert_allclose(region.width, expected[2]) - assert_allclose(region.height, expected[3]) + assert_allclose(region.center.xy, expected[:2], atol=1e-12, rtol=0) + assert_allclose((region.width, region.height), expected[2:], atol=1e-12, rtol=0) if sync: assert_quantity_allclose(region.angle, 0 * u.deg) @@ -176,13 +174,17 @@ def update_mask(reg): region.as_mpl_selector(ax) @pytest.mark.parametrize('anywhere', (False, True)) - def test_mpl_selector_drag(self, anywhere): + @pytest.mark.parametrize('rotate', (0, 20)) + def test_mpl_selector_drag(self, anywhere, rotate): """ Test dragging of entire region from central handle and anywhere. """ plt = pytest.importorskip('matplotlib.pyplot') - from matplotlib.testing.widgets import ( - do_event) # click_and_drag # MPL_VERSION >= 36 + from matplotlib.testing.widgets import do_event # click_and_drag # MPL_VERSION >= 36 + from matplotlib import __version_info__ as MPL_VERSION + + if rotate != 0 and MPL_VERSION < (3, 6, 0): + pytest.xfail('Creating selectors for rotated shapes is not yet supported') rng = np.random.default_rng(0) data = rng.random((16, 16)) @@ -194,40 +196,87 @@ def test_mpl_selector_drag(self, anywhere): def update_mask(reg): mask[:] = reg.to_mask(mode='subpixels', subpixels=10).to_image(data.shape) - region = self.reg.copy(angle=0 * u.deg) + region = self.reg.copy(angle=rotate * u.deg) selector = region.as_mpl_selector(ax, callback=update_mask, drag_from_anywhere=anywhere) + assert selector.drag_from_anywhere is anywhere assert region._mpl_selector.drag_from_anywhere is anywhere + assert_allclose(region.center.xy, (3.0, 4.0), atol=1e-12, rtol=0) + assert_allclose(region.angle.value, rotate, atol=1e-12, rtol=0) + assert_allclose(selector.center, (3.0, 4.0), atol=1e-12, rtol=0) + if MPL_VERSION >= (3, 6, 0): + assert_allclose(selector.rotation, -rotate, atol=1e-12, rtol=0) # click_and_drag(selector, start=(3, 4), end=(3.5, 4.5)) - do_event(selector, 'press', xdata=3, ydata=4, button=1) + do_event(selector, 'press', xdata=3.0, ydata=4.0, button=1) do_event(selector, 'onmove', xdata=3.5, ydata=4.5, button=1) do_event(selector, 'release', xdata=3.5, ydata=4.5, button=1) ax.figure.canvas.draw() - assert_allclose(region.center.x, 3.5) - assert_allclose(region.center.y, 4.5) - assert_allclose(region.width, 4) - assert_allclose(region.height, 3) + assert_allclose(region.center.xy, (3.5, 4.5), atol=1e-12, rtol=0) + assert_allclose((region.width, region.height), (4, 3), atol=1e-12, rtol=0) - do_event(selector, 'press', xdata=3.25, ydata=4.25, button=1) - do_event(selector, 'onmove', xdata=4.25, ydata=5.25, button=1) - do_event(selector, 'release', xdata=4.25, ydata=5.25, button=1) + # click_and_drag from outside centre handle, but inside region + do_event(selector, 'press', xdata=3.0, ydata=5.0, button=1) + do_event(selector, 'onmove', xdata=4.0, ydata=7.0, button=1) + do_event(selector, 'release', xdata=4.0, ydata=7.0, button=1) ax.figure.canvas.draw() - # For drag_from_anywhere=False this will have created a new 1x1 rectangle. + # For drag_from_anywhere=False this will have created a new 1x2 rectangle. if anywhere: - assert_allclose(region.center.x, 4.5) - assert_allclose(region.center.y, 5.5) - assert_allclose(region.width, 4) - assert_allclose(region.height, 3) + assert_allclose(region.center.xy, (4.5, 6.5), atol=1e-12, rtol=0) + assert_allclose((region.width, region.height), (4, 3), atol=1e-12, rtol=0) else: - assert_allclose(region.center.x, 4.5) - assert_allclose(region.center.y, 5.5) + assert_allclose(region.center.xy, (3.5, 6.0), atol=1e-12, rtol=0) + assert_allclose((region.width, region.height), (1, 2), atol=1e-12, rtol=0) + + assert_equal(mask, region.to_mask(mode='subpixels', subpixels=10).to_image(data.shape)) + + def test_mpl_selector_resize(self): + """ + Test resizing of region on edge and corner handles. + """ + plt = pytest.importorskip('matplotlib.pyplot') + from matplotlib.testing.widgets import do_event + + rng = np.random.default_rng(0) + data = rng.random((16, 16)) + mask = np.zeros_like(data) + + ax = plt.subplot(1, 1, 1) + ax.imshow(data) + + def update_mask(reg): + mask[:] = reg.to_mask(mode='subpixels', subpixels=10).to_image(data.shape) + + region = self.reg.copy(angle=0 * u.deg) + + selector = region.as_mpl_selector(ax, callback=update_mask) + assert region._mpl_selector.drag_from_anywhere is False + + # click_and_drag(selector, start=(5, 4), end=(6, 4)) (drag right edge +1) + do_event(selector, 'press', xdata=5, ydata=4, button=1) + do_event(selector, 'onmove', xdata=6, ydata=4, button=1) + do_event(selector, 'release', xdata=6, ydata=4, button=1) + + ax.figure.canvas.draw() + + assert_allclose(region.center.xy, (3.5, 4.0), atol=1e-12, rtol=0) + assert_allclose((region.width, region.height), (5, 3), atol=1e-12, rtol=0) + + # click_and_drag(selector, start=(6, 5.5), end=(7, 7.5)) (upper right corner +1|+2) + do_event(selector, 'press', xdata=6, ydata=5.5, button=1) + do_event(selector, 'onmove', xdata=7, ydata=7.5, button=1) + do_event(selector, 'release', xdata=7, ydata=7.5, button=1) + + ax.figure.canvas.draw() + + assert_allclose(region.center.xy, (4, 5), atol=1e-12, rtol=0) + assert_allclose((region.width, region.height), (6, 5), atol=1e-12, rtol=0) assert_equal(mask, region.to_mask(mode='subpixels', subpixels=10).to_image(data.shape)) From 17c357fca089c7a63ac5f41b99b5f117139203d9 Mon Sep 17 00:00:00 2001 From: Derek Homeier Date: Mon, 30 Dec 2024 21:25:12 +0100 Subject: [PATCH 6/6] TST: add tests for rotation by mpl_selector --- regions/shapes/ellipse.py | 2 +- regions/shapes/rectangle.py | 2 +- regions/shapes/tests/test_ellipse.py | 90 ++++++++++++++++++++++-- regions/shapes/tests/test_rectangle.py | 94 ++++++++++++++++++++++++-- 4 files changed, 175 insertions(+), 13 deletions(-) diff --git a/regions/shapes/ellipse.py b/regions/shapes/ellipse.py index 92b1d17e..ac7e486e 100644 --- a/regions/shapes/ellipse.py +++ b/regions/shapes/ellipse.py @@ -271,8 +271,8 @@ def as_mpl_selector(self, ax, active=True, sync=True, callback=None, you can enable/disable the selector at any point by calling ``selector.set_active(True)`` or ``selector.set_active(False)``. """ + from matplotlib import __version__ as MPL_VER_STR # noqa: N812 from matplotlib.widgets import EllipseSelector - from matplotlib import __version__ as MPL_VER_STR if hasattr(self, '_mpl_selector'): raise AttributeError('Cannot attach more than one selector to a region.') diff --git a/regions/shapes/rectangle.py b/regions/shapes/rectangle.py index 27d7f80f..b1ac7bb7 100644 --- a/regions/shapes/rectangle.py +++ b/regions/shapes/rectangle.py @@ -267,8 +267,8 @@ def as_mpl_selector(self, ax, active=True, sync=True, callback=None, you can enable/disable the selector at any point by calling ``selector.set_active(True)`` or ``selector.set_active(False)``. """ + from matplotlib import __version__ as MPL_VER_STR # noqa: N812 from matplotlib.widgets import RectangleSelector - from matplotlib import __version__ as MPL_VER_STR if hasattr(self, '_mpl_selector'): raise AttributeError('Cannot attach more than one selector to a region.') diff --git a/regions/shapes/tests/test_ellipse.py b/regions/shapes/tests/test_ellipse.py index c97bc69b..dc078fcd 100644 --- a/regions/shapes/tests/test_ellipse.py +++ b/regions/shapes/tests/test_ellipse.py @@ -114,8 +114,8 @@ def test_region_bbox_zero_size(self): def test_as_mpl_selector(self, sync): plt = pytest.importorskip('matplotlib.pyplot') + from matplotlib import __version_info__ as MPL_VERSION # noqa: N812 from matplotlib.testing.widgets import do_event - from matplotlib import __version_info__ as MPL_VERSION rng = np.random.default_rng(0) data = rng.random((16, 16)) @@ -173,8 +173,8 @@ def test_mpl_selector_drag(self, anywhere, rotate): Test dragging of entire region from central handle and anywhere. """ plt = pytest.importorskip('matplotlib.pyplot') + from matplotlib import __version_info__ as MPL_VERSION # noqa: N812 from matplotlib.testing.widgets import do_event - from matplotlib import __version_info__ as MPL_VERSION if rotate != 0 and MPL_VERSION < (3, 6, 0): pytest.xfail('Creating selectors for rotated shapes is not yet supported') @@ -234,8 +234,8 @@ def test_mpl_selector_resize(self): Test resizing of region on edge and corner handles. """ plt = pytest.importorskip('matplotlib.pyplot') - from matplotlib.testing.widgets import ( - do_event) # click_and_drag # MPL_VERSION >= 36 + # if MPL_VERSION >= 36: + from matplotlib.testing.widgets import do_event # click_and_drag rng = np.random.default_rng(0) data = rng.random((16, 16)) @@ -274,6 +274,88 @@ def update_mask(reg): assert_equal(mask, region.to_mask(mode='subpixels', subpixels=10).to_image(data.shape)) + def test_mpl_selector_rotate(self): + """ + Test rotating region on corner handles and by setting angle. + """ + plt = pytest.importorskip('matplotlib.pyplot') + from matplotlib.testing.widgets import do_event # click_and_drag + + rng = np.random.default_rng(0) + data = rng.random((16, 16)) + mask = np.zeros_like(data) + + ax = plt.subplot(1, 1, 1) + ax.imshow(data) + + def update_mask(reg): + mask[:] = reg.to_mask(mode='subpixels', subpixels=10).to_image(data.shape) + + region = self.reg.copy(angle=0 * u.deg) + selector = region.as_mpl_selector(ax, callback=update_mask) + + # Need rotation implementation from matplotlib#26833, hopefully to change once released + if not hasattr(selector, '_geometry_state'): + pytest.xfail('Rotating selectors is not yet supported') + + assert region._mpl_selector.drag_from_anywhere is False + assert_allclose(region.center.xy, (3.0, 4.0), atol=1e-12, rtol=0) + assert_allclose((region.width, region.height), (4, 3), atol=1e-12, rtol=0) + assert_quantity_allclose(region.angle, 0 * u.deg) + assert_allclose(region._mpl_selector.rotation, 0.0, atol=1e-12) + assert_allclose(region._mpl_selector.edge_centers, + (np.array([1, 3, 5, 3]), np.array([4, 2.5, 4, 5.5])), + atol=0.01) + assert_allclose(region.bounding_box.extent, (0.5, 5.5, 2.5, 5.5), atol=1e-12, rtol=0) + + # Rotate counter-clockwise using top-right bounding box corner + # click_and_drag(selector, start=(5, 5.5), end=(4.0, 6.0), key='r') + do_event(selector, 'on_key_press', key='r') + do_event(selector, 'press', xdata=5, ydata=5.5, button=1) + do_event(selector, 'onmove', xdata=4, ydata=6, button=1) + do_event(selector, 'release', xdata=4, ydata=6, button=1) + do_event(selector, 'on_key_press', key='r') + + ax.figure.canvas.draw() + + assert_allclose(region.center.xy, (3.0, 4.0), atol=1e-12, rtol=0) + assert_allclose(region._mpl_selector.edge_centers, + (np.array([1.21, 3.67, 4.79, 2.33]), np.array([3.11, 2.66, 4.89, 5.34])), + atol=0.02) + assert_allclose(region._mpl_selector.rotation, -26.56, atol=0.01) + assert_quantity_allclose(region.angle, 26.56 * u.deg, rtol=0.001) + assert_allclose((region.width, region.height), (4.0, 3.0), atol=1e-2, rtol=0) + assert_allclose(region.bounding_box.extent, (0.5, 5.5, 1.5, 6.5), atol=1e-2, rtol=0) + + assert_equal(mask, region.to_mask(mode='subpixels', subpixels=10).to_image(data.shape)) + + # click_and_drag(selector, start=(3, 4), end=(7, 6)) # (shift center +3|+2) + do_event(selector, 'press', xdata=3, ydata=4, button=1) + do_event(selector, 'onmove', xdata=6, ydata=6, button=1) + do_event(selector, 'release', xdata=6, ydata=6, button=1) + + ax.figure.canvas.draw() + + assert_allclose(region.center.xy, (6, 6), atol=1e-2, rtol=0) + assert_allclose((region.width, region.height), (4.0, 3.0), atol=1e-2, rtol=0) + assert_allclose(region._mpl_selector.rotation, -26.56, atol=0.01) + assert_quantity_allclose(region.angle, 26.56 * u.deg, rtol=0.001) + assert_allclose(region.bounding_box.extent, (3.5, 8.5, 3.5, 8.5), atol=1e-2, rtol=0) + + assert_equal(mask, region.to_mask(mode='subpixels', subpixels=10).to_image(data.shape)) + + # and de-rotate shifted ellipse + region._mpl_selector.rotation = 0.0 + region._update_from_mpl_selector() + + assert_allclose(region.center.xy, (6, 6), atol=1e-2, rtol=0) + assert_allclose((region.width, region.height), (4, 3), atol=1e-12, rtol=0) + assert_quantity_allclose(region.angle, 0 * u.deg, atol=1e-12 * u.deg) + assert_allclose(region._mpl_selector.rotation, 0.0, atol=1e-12) + assert_allclose(region.bounding_box.extent, (3.5, 8.5, 3.5, 7.5), atol=1e-2, rtol=0) + + assert_equal(mask, region.to_mask(mode='subpixels', subpixels=10).to_image(data.shape)) + @pytest.mark.parametrize('userargs', ({'useblit': True}, {'grab_range': 20, 'minspanx': 5, 'minspany': 4}, diff --git a/regions/shapes/tests/test_rectangle.py b/regions/shapes/tests/test_rectangle.py index 94faffd3..2ae0d7c2 100644 --- a/regions/shapes/tests/test_rectangle.py +++ b/regions/shapes/tests/test_rectangle.py @@ -120,8 +120,8 @@ def test_eq(self): @pytest.mark.parametrize('sync', (False,)) def test_as_mpl_selector(self, sync): plt = pytest.importorskip('matplotlib.pyplot') + from matplotlib import __version_info__ as MPL_VERSION # noqa: N812 from matplotlib.testing.widgets import do_event - from matplotlib import __version_info__ as MPL_VERSION rng = np.random.default_rng(0) data = rng.random((16, 16)) @@ -180,8 +180,9 @@ def test_mpl_selector_drag(self, anywhere, rotate): Test dragging of entire region from central handle and anywhere. """ plt = pytest.importorskip('matplotlib.pyplot') - from matplotlib.testing.widgets import do_event # click_and_drag # MPL_VERSION >= 36 - from matplotlib import __version_info__ as MPL_VERSION + from matplotlib import __version_info__ as MPL_VERSION # noqa: N812 + # if MPL_VERSION >= 36: + from matplotlib.testing.widgets import do_event # click_and_drag if rotate != 0 and MPL_VERSION < (3, 6, 0): pytest.xfail('Creating selectors for rotated shapes is not yet supported') @@ -220,9 +221,9 @@ def update_mask(reg): assert_allclose((region.width, region.height), (4, 3), atol=1e-12, rtol=0) # click_and_drag from outside centre handle, but inside region - do_event(selector, 'press', xdata=3.0, ydata=5.0, button=1) - do_event(selector, 'onmove', xdata=4.0, ydata=7.0, button=1) - do_event(selector, 'release', xdata=4.0, ydata=7.0, button=1) + do_event(selector, 'press', xdata=2.5, ydata=5.0, button=1) + do_event(selector, 'onmove', xdata=3.5, ydata=7.0, button=1) + do_event(selector, 'release', xdata=3.5, ydata=7.0, button=1) ax.figure.canvas.draw() @@ -231,7 +232,7 @@ def update_mask(reg): assert_allclose(region.center.xy, (4.5, 6.5), atol=1e-12, rtol=0) assert_allclose((region.width, region.height), (4, 3), atol=1e-12, rtol=0) else: - assert_allclose(region.center.xy, (3.5, 6.0), atol=1e-12, rtol=0) + assert_allclose(region.center.xy, (3.0, 6.0), atol=1e-12, rtol=0) assert_allclose((region.width, region.height), (1, 2), atol=1e-12, rtol=0) assert_equal(mask, region.to_mask(mode='subpixels', subpixels=10).to_image(data.shape)) @@ -280,6 +281,85 @@ def update_mask(reg): assert_equal(mask, region.to_mask(mode='subpixels', subpixels=10).to_image(data.shape)) + def test_mpl_selector_rotate(self): + """ + Test rotating region on corner handles and by setting angle. + """ + plt = pytest.importorskip('matplotlib.pyplot') + from matplotlib.testing.widgets import do_event + + rng = np.random.default_rng(0) + data = rng.random((16, 16)) + mask = np.zeros_like(data) + + ax = plt.subplot(1, 1, 1) + ax.imshow(data) + + def update_mask(reg): + mask[:] = reg.to_mask(mode='subpixels', subpixels=10).to_image(data.shape) + + region = self.reg.copy(angle=0 * u.deg) + selector = region.as_mpl_selector(ax, callback=update_mask) + + # Need rotation implementation from matplotlib#26833, hopefully to change once released + if not hasattr(selector, '_geometry_state'): + pytest.xfail('Rotating selectors is not yet supported') + + assert region._mpl_selector.drag_from_anywhere is False + assert_allclose(region.center.xy, (3.0, 4.0), atol=1e-12, rtol=0) + assert_allclose((region.width, region.height), (4, 3), atol=1e-12, rtol=0) + assert_quantity_allclose(region.angle, 0 * u.deg) + assert_allclose(region._mpl_selector.rotation, 0.0, atol=1e-12) + assert_allclose(region.corners, [(1, 2.5), (5, 2.5), (5, 5.5), (1, 5.5)], + atol=1e-12, rtol=0) + + # Rotate counter-clockwise using top-right corner + do_event(selector, 'on_key_press', key='r') + do_event(selector, 'press', xdata=5, ydata=5.5, button=1) + do_event(selector, 'onmove', xdata=4, ydata=6, button=1) + do_event(selector, 'release', xdata=4, ydata=6, button=1) + do_event(selector, 'on_key_press', key='r') + + ax.figure.canvas.draw() + + assert_allclose(region.center.xy, (3.0, 4.0), atol=1e-12, rtol=0) + assert_allclose((region.width, region.height), (4, 3), atol=1e-12, rtol=0) + assert_allclose(region._mpl_selector.rotation, -26.56, atol=0.01) + assert_quantity_allclose(region.angle, 26.56 * u.deg, rtol=0.001) + assert_allclose(region.corners, [(1.88, 1.76), (5.46, 3.55), (4.12, 6.24), (0.54, 4.45)], + atol=1e-2, rtol=0) + + assert_equal(mask, region.to_mask(mode='subpixels', subpixels=10).to_image(data.shape)) + + # click_and_drag(selector, start=(3, 4), end=(7, 6)) # (shift center +3|+2) + do_event(selector, 'press', xdata=3, ydata=4, button=1) + do_event(selector, 'onmove', xdata=6, ydata=6, button=1) + do_event(selector, 'release', xdata=6, ydata=6, button=1) + + ax.figure.canvas.draw() + + assert_allclose(region.center.xy, (6, 6), atol=1e-12, rtol=0) + assert_allclose((region.width, region.height), (4, 3), atol=1e-12, rtol=0) + assert_allclose(region._mpl_selector.rotation, -26.56, atol=0.01) + assert_quantity_allclose(region.angle, 26.56 * u.deg, rtol=0.001) + assert_allclose(region.corners, [(4.88, 3.76), (8.46, 5.55), (7.12, 8.24), (3.54, 6.45)], + atol=1e-2, rtol=0) + + assert_equal(mask, region.to_mask(mode='subpixels', subpixels=10).to_image(data.shape)) + + # and de-rotate shifted rectangle + region._mpl_selector.rotation = 0.0 + region._update_from_mpl_selector() + + assert_allclose(region.center.xy, (6, 6), atol=1e-2, rtol=0) + assert_allclose((region.width, region.height), (4, 3), atol=1e-12, rtol=0) + assert_quantity_allclose(region.angle, 0 * u.deg, atol=1e-12 * u.deg) + assert_allclose(region._mpl_selector.rotation, 0.0, atol=1e-12) + assert_allclose(region.corners, [(4, 4.5), (8, 4.5), (8, 7.5), (4, 7.5)], + atol=1e-12, rtol=0) + + assert_equal(mask, region.to_mask(mode='subpixels', subpixels=10).to_image(data.shape)) + @pytest.mark.parametrize('userargs', ({'useblit': True}, {'grab_range': 20, 'minspanx': 5, 'minspany': 4},