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

Add interactive matplotlib selector to PolygonPixelRegion #406

Draft
wants to merge 3 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 132 additions & 7 deletions regions/shapes/polygon.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,20 +77,48 @@
origin = PixCoord(0, 0)
self.origin = origin
self.vertices = vertices + origin
self._rotation = 0.0 * u.degree

@property
def area(self):
"""Return area of polygon computed by the shoelace formula."""

# See https://stackoverflow.com/questions/24467972

# Use offsets to improve numerical precision
x_ = self.vertices.x - self.vertices.x.mean()
y_ = self.vertices.y - self.vertices.y.mean()

# Shoelace formula, for our case where the start vertex
# isn't duplicated at the end, written to avoid an array copy
area_main = np.dot(x_[:-1], y_[1:]) - np.dot(y_[:-1], x_[1:])
area_last = x_[-1] * y_[0] - y_[-1] * x_[0]
return 0.5 * np.abs(area_main + area_last)
# Shoelace formula; for our case where the start vertex is
# not duplicated at the end, index to avoid an array copy.
indices = np.arange(len(x_)) - 1
return 0.5 * abs(np.dot(x_[indices], y_) - np.dot(y_[indices], x_))

@property
def centroid(self):
"""Return centroid (centre of mass) of polygon."""

# See http://paulbourke.net/geometry/polygonmesh/
# https://www.ma.ic.ac.uk/~rn/centroid.pdf

# Use vertex position offsets from mean to improve numerical precision;
# for a triangle the mean already locates the centroid.
x0 = self.vertices.x.mean()
y0 = self.vertices.y.mean()

if len(self.vertices) == 3:
return PixCoord(x0, y0)

x_ = self.vertices.x - x0
y_ = self.vertices.y - y0
indices = np.arange(len(x_)) - 1

Check warning on line 114 in regions/shapes/polygon.py

View check run for this annotation

Codecov / codecov/patch

regions/shapes/polygon.py#L112-L114

Added lines #L112 - L114 were not covered by tests

xs = x_[indices] + x_
ys = y_[indices] + y_
dxy = x_[indices] * y_ - y_[indices] * x_
scl = 1. / (6 * self.area)

Check warning on line 119 in regions/shapes/polygon.py

View check run for this annotation

Codecov / codecov/patch

regions/shapes/polygon.py#L116-L119

Added lines #L116 - L119 were not covered by tests

return PixCoord(np.dot(xs, dxy) * scl + x0, np.dot(ys, dxy) * scl + y0)

Check warning on line 121 in regions/shapes/polygon.py

View check run for this annotation

Codecov / codecov/patch

regions/shapes/polygon.py#L121

Added line #L121 was not covered by tests

def contains(self, pixcoord):
pixcoord = PixCoord._validate(pixcoord, 'pixcoord')
Expand Down Expand Up @@ -133,8 +161,7 @@
bbox = self.bounding_box
ny, nx = bbox.shape

# Find position of pixel edges and recenter so that circle is at
# origin
# Find position of pixel edges and recenter so that circle is at origin
xmin = float(bbox.ixmin) - 0.5
xmax = float(bbox.ixmax) - 0.5
ymin = float(bbox.iymin) - 0.5
Expand Down Expand Up @@ -179,6 +206,86 @@

return Polygon(xy=xy, **mpl_kwargs)

def _update_from_mpl_selector(self, verts, *args, **kwargs):
"""Set position and orientation from selector properties."""
# Polygon selector calls ``callback(self.verts)``.

self.vertices = PixCoord(*np.array(verts).T)

Check warning on line 213 in regions/shapes/polygon.py

View check run for this annotation

Codecov / codecov/patch

regions/shapes/polygon.py#L213

Added line #L213 was not covered by tests

if getattr(self, '_mpl_selector_callback', None) is not None:
self._mpl_selector_callback(self)

Check warning on line 216 in regions/shapes/polygon.py

View check run for this annotation

Codecov / codecov/patch

regions/shapes/polygon.py#L215-L216

Added lines #L215 - L216 were not covered by tests

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

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 : callable, 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 : dict
Additional keyword arguments that are passed to
`matplotlib.widgets.PolygonSelector`.

Returns
-------
selector : `matplotlib.widgets.PolygonSelector`
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 PolygonSelector
import matplotlib._version
_mpl_version = getattr(matplotlib._version, 'version', None)
if _mpl_version is None:
_mpl_version = matplotlib._version.get_versions()['version']

Check warning on line 259 in regions/shapes/polygon.py

View check run for this annotation

Codecov / codecov/patch

regions/shapes/polygon.py#L255-L259

Added lines #L255 - L259 were not covered by tests

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

Check warning on line 262 in regions/shapes/polygon.py

View check run for this annotation

Codecov / codecov/patch

regions/shapes/polygon.py#L261-L262

Added lines #L261 - L262 were not covered by tests

if not hasattr(PolygonSelector, '_scale_polygon'):

Check warning on line 264 in regions/shapes/polygon.py

View check run for this annotation

Codecov / codecov/patch

regions/shapes/polygon.py#L264

Added line #L264 was not covered by tests
raise NotImplementedError('Rescalable ``PolygonSelector`` widgets are not '
f'yet supported with matplotlib {_mpl_version}.')

if sync:
sync_callback = self._update_from_mpl_selector

Check warning on line 269 in regions/shapes/polygon.py

View check run for this annotation

Codecov / codecov/patch

regions/shapes/polygon.py#L268-L269

Added lines #L268 - L269 were not covered by tests
else:
def sync_callback(*args, **kwargs):
pass

Check warning on line 272 in regions/shapes/polygon.py

View check run for this annotation

Codecov / codecov/patch

regions/shapes/polygon.py#L271-L272

Added lines #L271 - L272 were not covered by tests

self._mpl_selector = PolygonSelector(

Check warning on line 274 in regions/shapes/polygon.py

View check run for this annotation

Codecov / codecov/patch

regions/shapes/polygon.py#L274

Added line #L274 was not covered by tests
ax, sync_callback, draw_bounding_box=True,
props={'color': self.visual.get('color', 'black'),
'linewidth': self.visual.get('linewidth', 1),
'linestyle': self.visual.get('linestyle', 'solid')})

self._mpl_selector.verts = list(zip(self.vertices.x, self.vertices.y))
self._mpl_selector.set_active(active)
self._mpl_selector_callback = callback

Check warning on line 282 in regions/shapes/polygon.py

View check run for this annotation

Codecov / codecov/patch

regions/shapes/polygon.py#L280-L282

Added lines #L280 - L282 were not covered by tests

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

Check warning on line 285 in regions/shapes/polygon.py

View check run for this annotation

Codecov / codecov/patch

regions/shapes/polygon.py#L284-L285

Added lines #L284 - L285 were not covered by tests

return self._mpl_selector

Check warning on line 287 in regions/shapes/polygon.py

View check run for this annotation

Codecov / codecov/patch

regions/shapes/polygon.py#L287

Added line #L287 was not covered by tests

def rotate(self, center, angle):
"""
Rotate the region.
Expand All @@ -200,6 +307,24 @@
vertices = self.vertices.rotate(center, angle)
return self.copy(vertices=vertices)

@property
def rotation(self):
"""
Rotation angle to apply in-place rotations (operating on this instance).
Since `.setter` will apply the rotation directly on the vertices, this
value will always be reset to 0.
"""
return self._rotation

@rotation.setter
def rotation(self, angle):
self.vertices = self.vertices.rotate(self.centroid, angle - self._rotation)
self._rotation = 0.0 * u.degree
if hasattr(self, '_mpl_selector'):
self._mpl_selector.verts = list(zip(self.vertices.x, self.vertices.y))
if getattr(self, '_mpl_selector_callback', None) is not None:
self._mpl_selector_callback(self)

Check warning on line 326 in regions/shapes/polygon.py

View check run for this annotation

Codecov / codecov/patch

regions/shapes/polygon.py#L324-L326

Added lines #L324 - L326 were not covered by tests


class RegularPolygonPixelRegion(PolygonPixelRegion):
"""
Expand Down
16 changes: 16 additions & 0 deletions regions/shapes/tests/test_polygon.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,22 @@ def test_eq(self):
reg.vertices = PixCoord([1, 3, 1], [1, 1, 6])
assert reg != self.reg

def test_rotation(self):
"""Test 'in-place' rotation of polygon instance, including full rotation"""
self.reg.rotation = 90 * u.deg
assert_allclose(self.reg.vertices.x, [8/3., 8/3., -1/3.], rtol=1e-9)
assert_allclose(self.reg.vertices.y, [4/3., 10/3., 4/3.], rtol=1e-9)
assert_allclose(self.reg.rotation, 0 * u.deg, rtol=1e-9)

self.reg.rotation = 90 * u.deg
assert_allclose(self.reg.vertices.x, [7/3., 1/3., 7/3.], rtol=1e-9)
assert_allclose(self.reg.vertices.y, [3, 3, 0], rtol=1e-9)

self.reg.rotation = 180 * u.deg
assert_allclose(self.reg.vertices.x, [1, 3, 1], rtol=1e-9)
assert_allclose(self.reg.vertices.y, [1, 1, 4], rtol=1e-9)
assert_allclose(self.reg.rotation, 0 * u.deg, rtol=1e-9)


class TestPolygonSkyRegion(BaseTestSkyRegion):
meta = RegionMeta({'text': 'test'})
Expand Down
Loading