From bb0849ae63fc773ed41a0c16c8a9f24cd255f6f8 Mon Sep 17 00:00:00 2001 From: Derek Homeier Date: Wed, 8 Dec 2021 13:50:58 +0100 Subject: [PATCH 1/3] Add `as_mpl_selector` and `centroid` to `PolygonPixelRegion` --- regions/shapes/polygon.py | 128 +++++++++++++++++++++++++++++++++++--- 1 file changed, 119 insertions(+), 9 deletions(-) diff --git a/regions/shapes/polygon.py b/regions/shapes/polygon.py index 0af0d8a5..3d1a0534 100644 --- a/regions/shapes/polygon.py +++ b/regions/shapes/polygon.py @@ -7,8 +7,7 @@ from astropy.wcs.utils import pixel_to_skycoord, skycoord_to_pixel import numpy as np -from ..core.attributes import (OneDPix, OneDSky, ScalarPix, ScalarLength, - QuantityLength) +from ..core.attributes import OneDPix, OneDSky, ScalarPix, ScalarLength, QuantityLength from ..core.bounding_box import BoundingBox from ..core.core import PixelRegion, SkyRegion from ..core.mask import RegionMask @@ -74,17 +73,44 @@ def __init__(self, vertices, meta=None, visual=None, @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') @@ -129,8 +155,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 @@ -175,6 +200,91 @@ 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_box=True, + props={'color': self.visual.get('color', 'black'), + 'linewidth': self.visual.get('linewidth', 1), + 'linestyle': self.visual.get('linestyle', 'solid')}) + + self._mpl_selector._xs = list(self.vertices.x) + [self.vertices.x[0]] + self._mpl_selector._ys = list(self.vertices.y) + [self.vertices.y[0]] + self._mpl_selector._selection_completed = True + self._mpl_selector._draw_polygon() + self._mpl_selector._update_box() + + 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. From e74ec809498b92e1fcbb2f5da9d17c259ada1844 Mon Sep 17 00:00:00 2001 From: Derek Homeier Date: Thu, 23 Dec 2021 16:02:42 +0100 Subject: [PATCH 2/3] Update selector to new API and `verts.setter` --- regions/shapes/polygon.py | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/regions/shapes/polygon.py b/regions/shapes/polygon.py index 3d1a0534..4e826c68 100644 --- a/regions/shapes/polygon.py +++ b/regions/shapes/polygon.py @@ -266,17 +266,12 @@ def sync_callback(*args, **kwargs): pass self._mpl_selector = PolygonSelector( - ax, sync_callback, draw_box=True, + 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._xs = list(self.vertices.x) + [self.vertices.x[0]] - self._mpl_selector._ys = list(self.vertices.y) + [self.vertices.y[0]] - self._mpl_selector._selection_completed = True - self._mpl_selector._draw_polygon() - self._mpl_selector._update_box() - + self._mpl_selector.verts = list(zip(self.vertices.x, self.vertices.y)) self._mpl_selector.set_active(active) self._mpl_selector_callback = callback From 605fe44477ed102bfe3d9dbaee01ea8971a7ce84 Mon Sep 17 00:00:00 2001 From: Derek Homeier Date: Thu, 30 Dec 2021 16:49:40 +0100 Subject: [PATCH 3/3] Experimental `rotation.setter` for PolygonPixelRegion --- regions/shapes/polygon.py | 19 +++++++++++++++++++ regions/shapes/tests/test_polygon.py | 16 ++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/regions/shapes/polygon.py b/regions/shapes/polygon.py index 4e826c68..2b1ade90 100644 --- a/regions/shapes/polygon.py +++ b/regions/shapes/polygon.py @@ -70,6 +70,7 @@ def __init__(self, vertices, meta=None, visual=None, self.visual = visual or RegionVisual() self.origin = origin self.vertices = vertices + origin + self._rotation = 0.0 * u.degree @property def area(self): @@ -301,6 +302,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): """ diff --git a/regions/shapes/tests/test_polygon.py b/regions/shapes/tests/test_polygon.py index 0d1dc61f..107d00a6 100644 --- a/regions/shapes/tests/test_polygon.py +++ b/regions/shapes/tests/test_polygon.py @@ -115,6 +115,22 @@ def test_origin(self): reg2 = PolygonPixelRegion(relverts, origin=origin) assert_equal(reg1.vertices, reg2.vertices) + 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):