Skip to content
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 @@ def __init__(self, vertices, meta=None, visual=None, origin=None):
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

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

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

def contains(self, pixcoord):
pixcoord = PixCoord._validate(pixcoord, 'pixcoord')
Expand Down Expand Up @@ -133,8 +161,7 @@ def to_mask(self, mode='center', subpixels=5):
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 @@ def as_artist(self, origin=(0, 0), **kwargs):

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)

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, **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']

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

if not hasattr(PolygonSelector, '_scale_polygon'):
raise NotImplementedError('Rescalable ``PolygonSelector`` widgets are not '
f'yet supported with matplotlib {_mpl_version}.')

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

self._mpl_selector = PolygonSelector(
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

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

return self._mpl_selector

def rotate(self, center, angle):
"""
Rotate the region.
Expand All @@ -200,6 +307,24 @@ def rotate(self, center, angle):
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)


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