From 327d3273b2ab1d6a32f69cf3d360e5501fca5741 Mon Sep 17 00:00:00 2001 From: Derek Homeier Date: Thu, 29 Jul 2021 15:28:26 +0200 Subject: [PATCH 1/3] 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 37860fa6..f3b40a09 100644 --- a/regions/shapes/ellipse.py +++ b/regions/shapes/ellipse.py @@ -212,13 +212,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, @@ -270,7 +280,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: @@ -292,10 +302,14 @@ def sync_callback(*args, **kwargs): self._mpl_selector = EllipseSelector(ax, sync_callback, interactive=True, **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 2803aed7..5e0928ce 100644 --- a/regions/shapes/rectangle.py +++ b/regions/shapes/rectangle.py @@ -208,13 +208,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, @@ -266,9 +276,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 @@ -289,10 +298,14 @@ def sync_callback(*args, **kwargs): self._mpl_selector = RectangleSelector(ax, sync_callback, interactive=True, **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 2e7259552c2084ff7b2dfa6be2eb76c7f6148061 Mon Sep 17 00:00:00 2001 From: Derek Homeier Date: Thu, 29 Jul 2021 15:31:29 +0200 Subject: [PATCH 2/3] 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 1fa43b38..58c76796 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -59,6 +59,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 f3b40a09..5590c802 100644 --- a/regions/shapes/ellipse.py +++ b/regions/shapes/ellipse.py @@ -280,7 +280,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: @@ -307,8 +307,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 5e0928ce..2ece4ae3 100644 --- a/regions/shapes/rectangle.py +++ b/regions/shapes/rectangle.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(RectangleSelector, '_rotation'): + if self.angle.value != 0 and not hasattr(RectangleSelector, 'rotation'): raise NotImplementedError('Cannot create matplotlib selector for rotated rectangle.') if sync: @@ -303,8 +303,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 dd368f44..623ecd73 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 ca7d8185..8afbed25 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 0dce5a8401059b24711303335dfc00358bfc485d Mon Sep 17 00:00:00 2001 From: Derek Homeier Date: Thu, 17 Feb 2022 20:04:05 +0100 Subject: [PATCH 3/3] Switch to mpl #20839 rotation implementation --- regions/_utils/optional_deps.py | 9 +++++---- regions/shapes/ellipse.py | 16 ++++++++-------- regions/shapes/rectangle.py | 22 +++++++++++----------- regions/shapes/tests/test_ellipse.py | 24 ++++++++++-------------- regions/shapes/tests/test_rectangle.py | 22 ++++++++++------------ 5 files changed, 44 insertions(+), 49 deletions(-) diff --git a/regions/_utils/optional_deps.py b/regions/_utils/optional_deps.py index 22438364..21d5279e 100644 --- a/regions/_utils/optional_deps.py +++ b/regions/_utils/optional_deps.py @@ -3,11 +3,12 @@ try: import matplotlib HAS_MATPLOTLIB = True - MPL_VERSION = getattr(matplotlib, '__version__', None) - if MPL_VERSION is None: - MPL_VERSION = matplotlib._version.version - MPL_VERSION = MPL_VERSION.split('.') + MPL_VER_STR = getattr(matplotlib, '__version__', None) + if MPL_VER_STR is None: + MPL_VER_STR = matplotlib._version.version + MPL_VERSION = MPL_VER_STR.split('.') MPL_VERSION = 10 * int(MPL_VERSION[0]) + int(MPL_VERSION[1]) except ImportError: HAS_MATPLOTLIB = False + MPL_VER_STR = 'None' MPL_VERSION = 0 diff --git a/regions/shapes/ellipse.py b/regions/shapes/ellipse.py index 5590c802..67e71c50 100644 --- a/regions/shapes/ellipse.py +++ b/regions/shapes/ellipse.py @@ -212,10 +212,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 @@ -226,7 +226,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) @@ -274,14 +274,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 regions._utils.optional_deps import MPL_VERSION + from regions._utils.optional_deps import MPL_VERSION, 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 @@ -307,7 +307,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 2ece4ae3..aaca0f6e 100644 --- a/regions/shapes/rectangle.py +++ b/regions/shapes/rectangle.py @@ -208,10 +208,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 @@ -222,7 +222,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) @@ -270,14 +270,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 regions._utils.optional_deps import MPL_VERSION + from regions._utils.optional_deps import MPL_VERSION, 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 @@ -298,12 +298,12 @@ def sync_callback(*args, **kwargs): self._mpl_selector = RectangleSelector(ax, sync_callback, interactive=True, **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 623ecd73..8133b6e2 100644 --- a/regions/shapes/tests/test_ellipse.py +++ b/regions/shapes/tests/test_ellipse.py @@ -130,14 +130,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. + expected = [8.3, 4.9, 2.0, 1.0] if MPL_VERSION < 36: 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 +152,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 8afbed25..e7b6c3ab 100644 --- a/regions/shapes/tests/test_rectangle.py +++ b/regions/shapes/tests/test_rectangle.py @@ -136,15 +136,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. + expected = [8.3, 4.9, 2.0, 1.0] if MPL_VERSION < 36: 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 +159,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')):