From faec19aeb949fab899de362d3cf819bf8bcb8173 Mon Sep 17 00:00:00 2001 From: Axel Donath Date: Thu, 27 Oct 2022 17:07:08 -0400 Subject: [PATCH 1/6] Implemenet CircleSectorPixRegion --- regions/shapes/circle.py | 177 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 175 insertions(+), 2 deletions(-) diff --git a/regions/shapes/circle.py b/regions/shapes/circle.py index 301ed015..bf7ac4fd 100644 --- a/regions/shapes/circle.py +++ b/regions/shapes/circle.py @@ -11,7 +11,7 @@ from ..core.attributes import (ScalarPixCoord, PositiveScalar, PositiveScalarAngle, ScalarSkyCoord, - RegionMetaDescr, RegionVisualDescr) + RegionMetaDescr, RegionVisualDescr, ScalarAngle) from ..core.bounding_box import RegionBoundingBox from ..core.core import PixelRegion, SkyRegion from ..core.mask import RegionMask @@ -20,7 +20,7 @@ from .._utils.wcs_helpers import pixel_scale_angle_at_skycoord from .._geometry import circular_overlap_grid -__all__ = ['CirclePixelRegion', 'CircleSkyRegion'] +__all__ = ['CirclePixelRegion', 'CircleSkyRegion', 'CircleSectorPixelRegion'] class CirclePixelRegion(PixelRegion): @@ -216,3 +216,176 @@ def to_pixel(self, wcs): radius = (self.radius / pixscale).to(u.pix).value return CirclePixelRegion(center, radius, meta=self.meta.copy(), visual=self.visual.copy()) + + +class CircleSectorPixelRegion(PixelRegion): + """ + A circle sector defined using pixel coordinates. + + Parameters + ---------- + center : `~regions.PixCoord` + The center position. + radius : float + The radius in pixels. + angle_start: `~astropy.units.Quantity`, optional + The start angle of the sector, measured anti-clockwise. + angle_stop : `~astropy.units.Quantity`, optional + The stop angle of the sector, measured anti-clockwise. + meta : `~regions.RegionMeta` or `dict`, optional + A dictionary that stores the meta attributes of the region. + visual : `~regions.RegionVisual` or `dict`, optional + A dictionary that stores the visual meta attributes of the + region. + + Examples + -------- + .. plot:: + :include-source: + + from astropy import units as u + from regions import PixCoord, CircleSectorPixelRegion + import matplotlib.pyplot as plt + + fig, ax = plt.subplots(1, 1) + + reg = CircleSectorPixelRegion(PixCoord(x=8, y=7), radius=3.5, angle_start=0 * u.deg, angle_stop=120 * u.deg) + patch = reg.plot(ax=ax, facecolor='none', edgecolor='red', lw=2, + label='Circle') + + ax.legend(handles=(patch,), loc='upper center') + ax.set_xlim(0, 15) + ax.set_ylim(0, 15) + ax.set_aspect('equal') + """ + + _params = ('center', 'radius', 'angle_start', 'angle_stop') + _mpl_artist = 'Patch' + center = ScalarPixCoord('The center pixel position as a |PixCoord|.') + radius = PositiveScalar('The radius in pixels as a float.') + angle_start = ScalarAngle('The start angle measured anti-clockwise as a ' + '|Quantity| angle.') + angle_stop = ScalarAngle('The stop angle measured anti-clockwise as a ' + '|Quantity| angle.') + meta = RegionMetaDescr('The meta attributes as a |RegionMeta|') + visual = RegionVisualDescr('The visual attributes as a |RegionVisual|.') + + def __init__(self, center, radius, angle_start=0 * u.deg, angle_stop=360 * u.deg, meta=None, visual=None): + self.center = center + self.radius = radius + + if angle_start >= angle_stop: + raise ValueError('angle_stop must be greater than angle_start') + + self.angle_start = angle_start + self.angle_stop = angle_stop + self.meta = meta or RegionMeta() + self.visual = visual or RegionVisual() + + @property + def theta(self): + """Opening angle of the sector (`~astropy.coordinates.Angle`)""" + return self.angle_stop - self.angle_start + + @property + def area(self): + return self.radius ** 2 * self.theta.rad / 2. + + def contains(self, pixcoord): + pixcoord = PixCoord._validate(pixcoord, name='pixcoord') + in_circle = self.center.separation(pixcoord) < self.radius + + dx = pixcoord.x - self.center.x + dy = pixcoord.y - self.center.y + angle = Angle(np.arctan2(dy, dx), "rad").wrap_at("0d") + + in_angle = (angle > Angle(self.angle_start).wrap_at("0d")) & (angle < Angle(self.angle_stop).wrap_at("0d")) + in_sector = in_circle & in_angle + + if self.meta.get('include', True): + return in_sector + else: + return np.logical_not(in_sector) + + def to_sky(self, wcs): + raise NotImplementedError + + def to_mask(self, **kwargs): + raise NotImplementedError + + @property + def bounding_box(self): + """Bounding box (`~regions.RegionBoundingBox`).""" + x_start = self.radius * np.cos(self.angle_start) + y_start = self.radius * np.sin(self.angle_start) + + x_stop = self.radius * np.cos(self.angle_stop) + y_stop = self.radius * np.sin(self.angle_stop) + + def wrap(angle): + return Angle(angle).wrap_at("0d") + + cross_0 = wrap(self.angle_start) > wrap(self.angle_stop) + cross_90 = wrap(self.angle_start - 90 * u.deg) > wrap(self.angle_stop - 90 * u.deg) + cross_180 = wrap(self.angle_start - 180 * u.deg) > wrap(self.angle_stop - 180 * u.deg) + cross_270 = wrap(self.angle_start - 270 * u.deg) > wrap(self.angle_stop - 270 * u.deg) + + xmin = self.center.x + min(np.where(cross_180, -self.radius, min(x_start, x_stop)), 0) + xmax = self.center.x + max(np.where(cross_0, self.radius, max(x_start, x_stop)), 0) + ymin = self.center.y + min(np.where(cross_270, -self.radius, min(y_start, y_stop)), 0) + ymax = self.center.y + max(np.where(cross_90, self.radius, max(y_start, y_stop)), 0) + + return RegionBoundingBox.from_float(xmin, xmax, ymin, ymax) + + def as_artist(self, origin=(0, 0), **kwargs): + """ + Return a matplotlib patch object for this region + (`matplotlib.patches.Wedge). + + Parameters + ---------- + origin : array_like, optional + The ``(x, y)`` pixel position of the origin of the displayed + image. + + **kwargs : dict + Any keyword arguments accepted by + `~matplotlib.patches.Circle`. These keywords will override + any visual meta attributes of this region. + + Returns + ------- + artist : `~matplotlib.patches.Wedge` + A matplotlib circle patch. + """ + from matplotlib.patches import Wedge + + center = self.center.x - origin[0], self.center.y - origin[1] + radius = self.radius + mpl_kwargs = self.visual.define_mpl_kwargs(self._mpl_artist) + mpl_kwargs.update(kwargs) + + return Wedge(center=center, r=radius, theta1=self.angle_start.to_value("deg"), theta2=self.angle_stop.to_value("deg"), **mpl_kwargs) + + def rotate(self, center, angle): + """ + Rotate the region. + + Positive ``angle`` corresponds to counter-clockwise rotation. + + Parameters + ---------- + center : `~regions.PixCoord` + The rotation center point. + angle : `~astropy.coordinates.Angle` + The rotation angle. + + Returns + ------- + region : `CirclePixelRegion` + The rotated region (which is an independent copy). + """ + center = self.center.rotate(center, angle) + angle_start = self.angle_start + angle + angle_stop = self.angle_stop + angle + return self.copy(center=center, angle_start=angle_start, angle_stop=angle_stop) From 08af2c122b86a3205aa2736ea3afca0ec12c548a Mon Sep 17 00:00:00 2001 From: Axel Donath Date: Thu, 27 Oct 2022 17:20:22 -0400 Subject: [PATCH 2/6] Add minimal tests --- regions/shapes/circle.py | 2 +- regions/shapes/tests/test_circle.py | 27 ++++++++++++++++++++++++++- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/regions/shapes/circle.py b/regions/shapes/circle.py index bf7ac4fd..9b375307 100644 --- a/regions/shapes/circle.py +++ b/regions/shapes/circle.py @@ -285,7 +285,7 @@ def __init__(self, center, radius, angle_start=0 * u.deg, angle_stop=360 * u.deg @property def theta(self): """Opening angle of the sector (`~astropy.coordinates.Angle`)""" - return self.angle_stop - self.angle_start + return Angle(self.angle_stop - self.angle_start).wrap_at("180d") @property def area(self): diff --git a/regions/shapes/tests/test_circle.py b/regions/shapes/tests/test_circle.py index 753d2dc8..88446924 100644 --- a/regions/shapes/tests/test_circle.py +++ b/regions/shapes/tests/test_circle.py @@ -13,7 +13,7 @@ from ...core import PixCoord, RegionMeta, RegionVisual from ...tests.helpers import make_simple_wcs from ..._utils.optional_deps import HAS_MATPLOTLIB # noqa -from ..circle import CirclePixelRegion, CircleSkyRegion +from ..circle import CirclePixelRegion, CircleSkyRegion, CircleSectorPixelRegion from .test_common import BaseTestPixelRegion, BaseTestSkyRegion @@ -139,3 +139,28 @@ def test_eq(self): def test_zero_size(self): with pytest.raises(ValueError): CircleSkyRegion(SkyCoord(3 * u.deg, 4 * u.deg), 0. * u.arcsec) + + +class TestCircleSectorPixelRegion(BaseTestPixelRegion): + meta = RegionMeta({'text': 'test'}) + visual = RegionVisual({'color': 'blue'}) + reg = CircleSectorPixelRegion(center=PixCoord(3, 4), radius=2, angle_start=30 * u.deg, angle_stop=120 * u.deg, meta=meta, visual=visual) + sample_box = [0, 6, 1, 7] + inside = [(3, 5)] + outside = [(2, 4)] + expected_area = np.pi + expected_repr = '' + expected_str = ('Region: CircleSectorPixelRegion\n' + 'center: PixCoord(x=3, y=4)\n' + 'radius: 2\n' + 'angle_start: 30.0 deg\n' + 'angle_stop: 120.0 deg') + + + @pytest.mark.skipif('not HAS_MATPLOTLIB') + def test_as_artist(self): + patch = self.reg.as_artist() + assert_allclose(patch.center, (3, 4)) + assert_allclose(patch.r, 2) + assert_allclose(patch.theta1, 30) + assert_allclose(patch.theta2, 120) From b3177260081b14fbc8a213c173e4aa3578492337 Mon Sep 17 00:00:00 2001 From: Axel Donath Date: Thu, 27 Oct 2022 17:23:19 -0400 Subject: [PATCH 3/6] Add circle sector pix region docs example --- docs/shapes.rst | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/docs/shapes.rst b/docs/shapes.rst index b48fd299..8871b411 100644 --- a/docs/shapes.rst +++ b/docs/shapes.rst @@ -59,6 +59,21 @@ Circle ... outer_radius=5.2) +`~regions.CircleSectorPixelRegion` + +.. code-block:: python + + >>> from astropy.coordinates import SkyCoord + >>> from astropy import units as u + >>> from regions import PixCoord + >>> from regions import CircleSectorPixelRegion + + >>> region_pix = CircleAnnulusPixelRegion(center=PixCoord(x=42, y=43), + ... radius=4.2, + ... angle_start=0 * u.deg, + ... angle_stop=120 * u.deg) + + Ellipse ******* From de1c9d11cffd613dc9fd7b7534e4d9e606557685 Mon Sep 17 00:00:00 2001 From: Axel Donath Date: Thu, 27 Oct 2022 18:15:59 -0400 Subject: [PATCH 4/6] Fix contains implementation --- regions/shapes/circle.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/regions/shapes/circle.py b/regions/shapes/circle.py index 9b375307..7fcfaf30 100644 --- a/regions/shapes/circle.py +++ b/regions/shapes/circle.py @@ -285,11 +285,11 @@ def __init__(self, center, radius, angle_start=0 * u.deg, angle_stop=360 * u.deg @property def theta(self): """Opening angle of the sector (`~astropy.coordinates.Angle`)""" - return Angle(self.angle_stop - self.angle_start).wrap_at("180d") + return self.angle_stop - self.angle_start @property def area(self): - return self.radius ** 2 * self.theta.rad / 2. + return self.radius ** 2 * self.theta.to_value("rad") / 2. def contains(self, pixcoord): pixcoord = PixCoord._validate(pixcoord, name='pixcoord') @@ -297,9 +297,9 @@ def contains(self, pixcoord): dx = pixcoord.x - self.center.x dy = pixcoord.y - self.center.y - angle = Angle(np.arctan2(dy, dx), "rad").wrap_at("0d") + angle = (Angle(np.arctan2(dy, dx), "rad") - self.angle_start).wrap_at("360d") - in_angle = (angle > Angle(self.angle_start).wrap_at("0d")) & (angle < Angle(self.angle_stop).wrap_at("0d")) + in_angle = (angle > 0 * u.deg) & (angle < self.theta) in_sector = in_circle & in_angle if self.meta.get('include', True): @@ -323,7 +323,7 @@ def bounding_box(self): y_stop = self.radius * np.sin(self.angle_stop) def wrap(angle): - return Angle(angle).wrap_at("0d") + return Angle(angle).wrap_at("360d") cross_0 = wrap(self.angle_start) > wrap(self.angle_stop) cross_90 = wrap(self.angle_start - 90 * u.deg) > wrap(self.angle_stop - 90 * u.deg) From bfc55dd9414123d4d38509d6f66b665746d3963c Mon Sep 17 00:00:00 2001 From: Axel Donath Date: Thu, 27 Oct 2022 18:24:40 -0400 Subject: [PATCH 5/6] Fix codestyle --- regions/shapes/circle.py | 15 +++++++++------ regions/shapes/tests/test_circle.py | 7 ++++--- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/regions/shapes/circle.py b/regions/shapes/circle.py index 7fcfaf30..672273a4 100644 --- a/regions/shapes/circle.py +++ b/regions/shapes/circle.py @@ -249,7 +249,8 @@ class CircleSectorPixelRegion(PixelRegion): fig, ax = plt.subplots(1, 1) - reg = CircleSectorPixelRegion(PixCoord(x=8, y=7), radius=3.5, angle_start=0 * u.deg, angle_stop=120 * u.deg) + reg = CircleSectorPixelRegion(PixCoord(x=8, y=7), radius=3.5, angle_start=0 * u.deg, + angle_stop=120 * u.deg) patch = reg.plot(ax=ax, facecolor='none', edgecolor='red', lw=2, label='Circle') @@ -266,11 +267,12 @@ class CircleSectorPixelRegion(PixelRegion): angle_start = ScalarAngle('The start angle measured anti-clockwise as a ' '|Quantity| angle.') angle_stop = ScalarAngle('The stop angle measured anti-clockwise as a ' - '|Quantity| angle.') + '|Quantity| angle.') meta = RegionMetaDescr('The meta attributes as a |RegionMeta|') visual = RegionVisualDescr('The visual attributes as a |RegionVisual|.') - def __init__(self, center, radius, angle_start=0 * u.deg, angle_stop=360 * u.deg, meta=None, visual=None): + def __init__(self, center, radius, angle_start=0 * u.deg, angle_stop=360 * u.deg, + meta=None, visual=None): self.center = center self.radius = radius @@ -281,7 +283,7 @@ def __init__(self, center, radius, angle_start=0 * u.deg, angle_stop=360 * u.deg self.angle_stop = angle_stop self.meta = meta or RegionMeta() self.visual = visual or RegionVisual() - + @property def theta(self): """Opening angle of the sector (`~astropy.coordinates.Angle`)""" @@ -297,7 +299,7 @@ def contains(self, pixcoord): dx = pixcoord.x - self.center.x dy = pixcoord.y - self.center.y - angle = (Angle(np.arctan2(dy, dx), "rad") - self.angle_start).wrap_at("360d") + angle = (Angle(np.arctan2(dy, dx), "rad") - self.angle_start).wrap_at("360d") in_angle = (angle > 0 * u.deg) & (angle < self.theta) in_sector = in_circle & in_angle @@ -365,7 +367,8 @@ def as_artist(self, origin=(0, 0), **kwargs): mpl_kwargs = self.visual.define_mpl_kwargs(self._mpl_artist) mpl_kwargs.update(kwargs) - return Wedge(center=center, r=radius, theta1=self.angle_start.to_value("deg"), theta2=self.angle_stop.to_value("deg"), **mpl_kwargs) + return Wedge(center=center, r=radius, theta1=self.angle_start.to_value("deg"), + theta2=self.angle_stop.to_value("deg"), **mpl_kwargs) def rotate(self, center, angle): """ diff --git a/regions/shapes/tests/test_circle.py b/regions/shapes/tests/test_circle.py index 88446924..e1b10bbe 100644 --- a/regions/shapes/tests/test_circle.py +++ b/regions/shapes/tests/test_circle.py @@ -144,19 +144,20 @@ def test_zero_size(self): class TestCircleSectorPixelRegion(BaseTestPixelRegion): meta = RegionMeta({'text': 'test'}) visual = RegionVisual({'color': 'blue'}) - reg = CircleSectorPixelRegion(center=PixCoord(3, 4), radius=2, angle_start=30 * u.deg, angle_stop=120 * u.deg, meta=meta, visual=visual) + reg = CircleSectorPixelRegion(center=PixCoord(3, 4), radius=2, angle_start=30 * u.deg, + angle_stop=120 * u.deg, meta=meta, visual=visual) sample_box = [0, 6, 1, 7] inside = [(3, 5)] outside = [(2, 4)] expected_area = np.pi - expected_repr = '' + expected_repr = ('') expected_str = ('Region: CircleSectorPixelRegion\n' 'center: PixCoord(x=3, y=4)\n' 'radius: 2\n' 'angle_start: 30.0 deg\n' 'angle_stop: 120.0 deg') - @pytest.mark.skipif('not HAS_MATPLOTLIB') def test_as_artist(self): patch = self.reg.as_artist() From 4b253c3b50599f24ed8f5f571263d6d90845f043 Mon Sep 17 00:00:00 2001 From: Axel Donath Date: Thu, 27 Oct 2022 18:30:27 -0400 Subject: [PATCH 6/6] Fix docs and test fails --- docs/shapes.rst | 2 +- regions/shapes/tests/test_circle.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/shapes.rst b/docs/shapes.rst index 8871b411..55c05e40 100644 --- a/docs/shapes.rst +++ b/docs/shapes.rst @@ -68,7 +68,7 @@ Circle >>> from regions import PixCoord >>> from regions import CircleSectorPixelRegion - >>> region_pix = CircleAnnulusPixelRegion(center=PixCoord(x=42, y=43), + >>> region_pix = CircleSectorPixelRegion(center=PixCoord(x=42, y=43), ... radius=4.2, ... angle_start=0 * u.deg, ... angle_stop=120 * u.deg) diff --git a/regions/shapes/tests/test_circle.py b/regions/shapes/tests/test_circle.py index e1b10bbe..521a68e6 100644 --- a/regions/shapes/tests/test_circle.py +++ b/regions/shapes/tests/test_circle.py @@ -150,7 +150,7 @@ class TestCircleSectorPixelRegion(BaseTestPixelRegion): inside = [(3, 5)] outside = [(2, 4)] expected_area = np.pi - expected_repr = ('') expected_str = ('Region: CircleSectorPixelRegion\n' 'center: PixCoord(x=3, y=4)\n'