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

Make it possible to initialize and update regions based on interactive Matplotlib widgets #317

Merged
merged 11 commits into from
Aug 6, 2020
4 changes: 4 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@
``pyproject.toml`` to opt in to isolated builds as described in PEP 517/518.
[#315]

- Added a ``as_mpl_selector`` method to the rectangular and ellipse
pixel-based regions. This method returns an interactive Matplotlib
selector widget. [#317]

0.4 (2019-06-17)
================

Expand Down
76 changes: 76 additions & 0 deletions regions/shapes/ellipse.py
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,82 @@ def as_artist(self, origin=(0, 0), **kwargs):
return Ellipse(xy=xy, width=width, height=height, angle=angle,
**mpl_params)

def _update_from_mpl_selector(self, *args, **kwargs):
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._mpl_selector_callback(self)

def as_mpl_selector(self, ax, active=True, sync=True, callback=None, **kwargs):
"""
Matplotlib editable widget for this region (`matplotlib.widgets.EllipseSelector`)

Parameters
----------
ax : `~matplotlib.axes.Axes`
The Matplotlib axes to add the selector to.
active : bool, optional
Whether the selector should be active by default.
sync : bool, optional
If `True` (the default), the region will be kept in sync with the
selector. Otherwise, the selector will be initialized with the
values from the region but the two will then be disconnected.
callback : func, optional
If specified, this function will be called every time the region is
updated. This only has an effect if ``sync`` is `True`. If a
callback is set, it is called for the first time once the selector
has been created.
kwargs
Additional keyword arguments are passed to matplotlib.widgets.EllipseSelector`

Returns
-------
selector : `matplotlib.widgets.EllipseSelector`
The Matplotlib selector.

Notes
-----
Once a selector has been created, you will need to keep a reference to
it until you no longer need it. In addition, you can enable/disable the
selector at any point by calling ``selector.set_active(True)`` or
``selector.set_active(False)``.
"""

from matplotlib.widgets import EllipseSelector

if hasattr(self, '_mpl_selector'):
raise Exception("Cannot attach more than one selector to a region.")

if self.angle.value != 0:
raise NotImplementedError("Cannot create matplotlib selector for rotated ellipse.")

if sync:
sync_callback = self._update_from_mpl_selector
else:
def sync_callback(*args, **kwargs):
pass

self._mpl_selector = EllipseSelector(ax, sync_callback, interactive=True,
rectprops={'edgecolor': self.visual.get('color', 'black'),
'facecolor': 'none',
'linewidth': self.visual.get('linewidth', 1),
'linestyle': self.visual.get('linestyle', 'solid')})
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)
self._mpl_selector.set_active(active)
self._mpl_selector_callback = callback

if sync and self._mpl_selector_callback is not None:
self._mpl_selector_callback(self)

return self._mpl_selector

def rotate(self, center, angle):
"""Make a rotated region.

Expand Down
76 changes: 76 additions & 0 deletions regions/shapes/rectangle.py
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,82 @@ def as_artist(self, origin=(0, 0), **kwargs):
return Rectangle(xy=xy, width=width, height=height,
angle=angle, **mpl_params)

def _update_from_mpl_selector(self, *args, **kwargs):
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._mpl_selector_callback(self)

def as_mpl_selector(self, ax, active=True, sync=True, callback=None, **kwargs):
"""
Matplotlib editable widget for this region (`matplotlib.widgets.RectangleSelector`)

Parameters
----------
ax : `~matplotlib.axes.Axes`
The Matplotlib axes to add the selector to.
active : bool, optional
Whether the selector should be active by default.
sync : bool, optional
If `True` (the default), the region will be kept in sync with the
selector. Otherwise, the selector will be initialized with the
values from the region but the two will then be disconnected.
callback : func, optional
If specified, this function will be called every time the region is
updated. This only has an effect if ``sync`` is `True`. If a
callback is set, it is called for the first time once the selector
has been created.
kwargs
Additional keyword arguments are passed to matplotlib.widgets.RectangleSelector`

Returns
-------
selector : `matplotlib.widgets.RectangleSelector`
The Matplotlib selector.

Notes
-----
Once a selector has been created, you will need to keep a reference to
it until you no longer need it. In addition, you can enable/disable the
selector at any point by calling ``selector.set_active(True)`` or
``selector.set_active(False)``.
"""

from matplotlib.widgets import RectangleSelector

if hasattr(self, '_mpl_selector'):
raise Exception("Cannot attach more than one selector to a region.")

if self.angle.value != 0:
raise NotImplementedError("Cannot create matplotlib selector for rotated rectangle.")

if sync:
sync_callback = self._update_from_mpl_selector
else:
def sync_callback(*args, **kwargs):
pass

self._mpl_selector = RectangleSelector(ax, sync_callback, interactive=True,
rectprops={'edgecolor': self.visual.get('color', 'black'),
'facecolor': 'none',
'linewidth': self.visual.get('linewidth', 1),
'linestyle': self.visual.get('linestyle', 'solid')})
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)
self._mpl_selector.set_active(active)
self._mpl_selector_callback = callback

if sync and self._mpl_selector_callback is not None:
self._mpl_selector_callback(self)

return self._mpl_selector

@property
def corners(self):
"""
Expand Down
70 changes: 69 additions & 1 deletion regions/shapes/tests/test_ellipse.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Licensed under a 3-clause BSD style license - see LICENSE.rst
import numpy as np
from numpy.testing import assert_allclose
from numpy.testing import assert_allclose, assert_equal
import pytest

import astropy.units as u
Expand Down Expand Up @@ -80,6 +80,74 @@ def test_rotate(self):
assert_allclose(reg.center.xy, (1, 4))
assert_allclose(reg.angle.to_value("deg"), 95)

@pytest.mark.parametrize('sync', (False, True))
def test_as_mpl_selector(self, sync):

plt = pytest.importorskip('matplotlib.pyplot')

data = np.random.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)

# 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)

region = self.reg.copy(angle=0 * u.deg)

selector = region.as_mpl_selector(ax, callback=update_mask, sync=sync) # noqa

from matplotlib.backend_bases import MouseEvent, MouseButton

x, y = ax.transData.transform([[7.3, 4.4]])[0]
ax.figure.canvas.callbacks.process('button_press_event',
MouseEvent('button_press_event',
ax.figure.canvas, x, y,
button=MouseButton.LEFT))
x, y = ax.transData.transform([[9.3, 5.4]])[0]
ax.figure.canvas.callbacks.process('motion_notify_event',
MouseEvent('button_press_event',
ax.figure.canvas, x, y,
button=MouseButton.LEFT))
x, y = ax.transData.transform([[9.3, 5.4]])[0]
ax.figure.canvas.callbacks.process('button_release_event',
MouseEvent('button_press_event',
ax.figure.canvas, x, y,
button=MouseButton.LEFT))

ax.figure.canvas.draw()

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, 0 * u.deg)

assert_equal(mask, 0)

with pytest.raises(Exception, match='Cannot attach more than one selector to a region.'):
region.as_mpl_selector(ax)


class TestEllipseSkyRegion(BaseTestSkyRegion):
reg = EllipseSkyRegion(
Expand Down
70 changes: 69 additions & 1 deletion regions/shapes/tests/test_rectangle.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Licensed under a 3-clause BSD style license - see LICENSE.rst
import numpy as np
from numpy.testing import assert_allclose
from numpy.testing import assert_allclose, assert_equal
import pytest

import astropy.units as u
Expand Down Expand Up @@ -105,6 +105,74 @@ def test_rotate(self):
assert_allclose(reg.center.xy, (1, 4))
assert_allclose(reg.angle.to_value("deg"), 95)

@pytest.mark.parametrize('sync', (False, True))
def test_as_mpl_selector(self, sync):

plt = pytest.importorskip('matplotlib.pyplot')

data = np.random.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)

# 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)

region = self.reg.copy(angle=0 * u.deg)

selector = region.as_mpl_selector(ax, callback=update_mask, sync=sync) # noqa

from matplotlib.backend_bases import MouseEvent, MouseButton

x, y = ax.transData.transform([[7.3, 4.4]])[0]
ax.figure.canvas.callbacks.process('button_press_event',
MouseEvent('button_press_event',
ax.figure.canvas, x, y,
button=MouseButton.LEFT))
x, y = ax.transData.transform([[9.3, 5.4]])[0]
ax.figure.canvas.callbacks.process('motion_notify_event',
MouseEvent('button_press_event',
ax.figure.canvas, x, y,
button=MouseButton.LEFT))
x, y = ax.transData.transform([[9.3, 5.4]])[0]
ax.figure.canvas.callbacks.process('button_release_event',
MouseEvent('button_press_event',
ax.figure.canvas, x, y,
button=MouseButton.LEFT))

ax.figure.canvas.draw()

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, 0 * u.deg)

assert_equal(mask, 0)

with pytest.raises(Exception, match='Cannot attach more than one selector to a region.'):
region.as_mpl_selector(ax)


def test_rectangular_pixel_region_bbox():
# odd sizes
Expand Down