From fc5dd72e10c43a2096fb7b6410276d4542b65ebe Mon Sep 17 00:00:00 2001 From: "P. L. Lim" <2090236+pllim@users.noreply.github.com> Date: Thu, 11 May 2023 16:29:29 -0400 Subject: [PATCH 1/4] FEAT: CircularAnnulusROI --- glue/core/roi.py | 121 ++++++++++++++++++++++++++++++------ glue/core/tests/test_roi.py | 93 ++++++++++++++++++++++----- 2 files changed, 180 insertions(+), 34 deletions(-) diff --git a/glue/core/roi.py b/glue/core/roi.py index 572f9cc5b..509896c33 100644 --- a/glue/core/roi.py +++ b/glue/core/roi.py @@ -13,7 +13,7 @@ np.seterr(all='ignore') -__all__ = ['Roi', 'RectangularROI', 'CircularROI', 'PolygonalROI', +__all__ = ['Roi', 'RectangularROI', 'CircularROI', 'CircularAnnulusROI', 'PolygonalROI', 'AbstractMplRoi', 'MplRectangularROI', 'MplCircularROI', 'MplPolygonalROI', 'MplXRangeROI', 'MplYRangeROI', 'XRangeROI', 'RangeROI', 'YRangeROI', 'VertexROIBase', @@ -54,7 +54,7 @@ def pixel_to_axes(axes, x, y): return axes.transAxes.inverted().transform(xy) -class Roi(object): # pragma: no cover +class Roi: # pragma: no cover """ A geometrical 2D region of interest. @@ -215,7 +215,7 @@ class RectangularROI(Roi): """ def __init__(self, xmin=None, xmax=None, ymin=None, ymax=None, theta=None): - super(RectangularROI, self).__init__() + super().__init__() self.xmin = xmin self.xmax = xmax self.ymin = ymin @@ -364,7 +364,7 @@ class RangeROI(Roi): End value of the range. """ def __init__(self, orientation, min=None, max=None): - super(RangeROI, self).__init__() + super().__init__() self.min = min self.max = max @@ -448,17 +448,16 @@ def __setgluestate__(cls, rec, context): class XRangeROI(RangeROI): def __init__(self, min=None, max=None): - super(XRangeROI, self).__init__('x', min=min, max=max) + super().__init__('x', min=min, max=max) class YRangeROI(RangeROI): def __init__(self, min=None, max=None): - super(YRangeROI, self).__init__('y', min=min, max=max) + super().__init__('y', min=min, max=max) class CircularROI(Roi): - """ A 2D circular region of interest. @@ -473,7 +472,7 @@ class CircularROI(Roi): """ def __init__(self, xc=None, yc=None, radius=None): - super(CircularROI, self).__init__() + super().__init__() self.xc = xc self.yc = yc self.radius = radius @@ -544,6 +543,90 @@ def __setgluestate__(cls, rec, context): return cls(xc=rec['xc'], yc=rec['yc'], radius=rec['radius']) +class CircularAnnulusROI(Roi): + """ + A 2D circular annulus region of interest. + + Parameters + ---------- + xc : float, optional + `x` coordinate of center. + yc : float, optional + `y` coordinate of center. + inner_radius : float, optional + Inner radius of the circular annulus. + outer_radius : float, optional + Outer radius of the circular annulus. + """ + def __init__(self, xc=None, yc=None, inner_radius=None, outer_radius=None): + super().__init__() + self.xc = xc + self.yc = yc + self.inner_radius = inner_radius + self.outer_radius = outer_radius + + def contains(self, x, y): + if not self.defined(): + raise UndefinedROI + + if not isinstance(x, np.ndarray): + x = np.asarray(x) + if not isinstance(y, np.ndarray): + y = np.asarray(y) + + dx = x - self.xc + dy = y - self.yc + r = np.sqrt((dx * dx) + (dy * dy)) + # Python likes to do inclusive for min limit and exclusive for max limit, so why not. + return (r >= self.inner_radius) & (r < self.outer_radius) + + def reset(self): + """Reset the circular annulus region.""" + self.xc = None + self.yc = None + self.inner_radius = None + self.outer_radius = None + + def defined(self): + number = (float, int) + if (isinstance(self.xc, number) and isinstance(self.yc, number) and + isinstance(self.inner_radius, number) and isinstance(self.outer_radius, number) and + (self.inner_radius > 0) and (self.outer_radius > self.inner_radius)): + status = True + else: + status = False + return status + + # FIXME: We need 2 polygons, with the inner one inverted, just like subset state. + def to_polygon(self): + if not self.defined(): + return [], [] + raise NotImplementedError("Cannot transform annulus to simple polygon") + + def transformed(self, xfunc=None, yfunc=None): + raise NotImplementedError("Cannot transform annulus to simple polygon") + + def center(self): + return self.xc, self.yc + + def move_to(self, x, y): + self.xc = x + self.yc = y + + def __gluestate__(self, context): + return dict(xc=context.do(self.xc), + yc=context.do(self.yc), + inner_radius=context.do(self.inner_radius), + outer_radius=context.do(self.outer_radius)) + + @classmethod + def __setgluestate__(cls, rec, context): + return cls(xc=rec['xc'], + yc=rec['yc'], + inner_radius=rec['inner_radius'], + outer_radius=rec['outer_radius']) + + class EllipticalROI(Roi): """ A 2D elliptical region of interest with semimajor/minor axes `radius_[xy]`. @@ -568,7 +651,7 @@ class EllipticalROI(Roi): """ def __init__(self, xc=None, yc=None, radius_x=None, radius_y=None, theta=None): - super(EllipticalROI, self).__init__() + super().__init__() self.xc = xc self.yc = yc self.radius_x = radius_x @@ -698,7 +781,7 @@ class VertexROIBase(Roi): """ def __init__(self, vx=None, vy=None): - super(VertexROIBase, self).__init__() + super().__init__() self.vx = [] if vx is None else list(vx) self.vy = [] if vy is None else list(vy) self.theta = 0 @@ -935,7 +1018,7 @@ class Projected3dROI(Roi): """ def __init__(self, roi_2d=None, projection_matrix=None): - super(Projected3dROI, self).__init__() + super().__init__() self.roi_2d = roi_2d self.projection_matrix = np.asarray(projection_matrix) @@ -1013,7 +1096,7 @@ def __str__(self): return result -class AbstractMplRoi(object): +class AbstractMplRoi: """ Base class for objects which use Matplotlib user events to edit/display ROIs. @@ -1153,7 +1236,7 @@ class MplRectangularROI(AbstractMplRoi): def __init__(self, axes, data_space=True): - super(MplRectangularROI, self).__init__(axes, data_space=data_space) + super().__init__(axes, data_space=data_space) self._xi = None self._yi = None @@ -1270,7 +1353,7 @@ class MplXRangeROI(AbstractMplRoi): def __init__(self, axes, data_space=True): - super(MplXRangeROI, self).__init__(axes, data_space=data_space) + super().__init__(axes, data_space=data_space) self._xi = None @@ -1376,7 +1459,7 @@ class MplYRangeROI(AbstractMplRoi): def __init__(self, axes, data_space=True): - super(MplYRangeROI, self).__init__(axes, data_space=data_space) + super().__init__(axes, data_space=data_space) self._yi = None @@ -1488,7 +1571,7 @@ class MplCircularROI(AbstractMplRoi): def __init__(self, axes, data_space=True): - super(MplCircularROI, self).__init__(axes, data_space=data_space) + super().__init__(axes, data_space=data_space) self.plot_opts = {'edgecolor': PATCH_COLOR, 'facecolor': PATCH_COLOR, @@ -1632,7 +1715,7 @@ class MplPolygonalROI(AbstractMplRoi): def __init__(self, axes, roi=None, data_space=True): - super(MplPolygonalROI, self).__init__(axes, roi=roi, data_space=data_space) + super().__init__(axes, roi=roi, data_space=data_space) self.plot_opts = {'edgecolor': PATCH_COLOR, 'facecolor': PATCH_COLOR, @@ -1739,7 +1822,7 @@ class MplPathROI(MplPolygonalROI): def __init__(self, axes, roi=None): - super(MplPolygonalROI, self).__init__(axes) + super().__init__(axes) self.plot_opts = {'edgecolor': PATCH_COLOR, 'facecolor': PATCH_COLOR, @@ -1756,7 +1839,7 @@ def start_selection(self, event): self._background_cache = None self._axes.figure.canvas.draw() - super(MplPathROI, self).start_selection(event) + super().start_selection(event) def _sync_patch(self): diff --git a/glue/core/tests/test_roi.py b/glue/core/tests/test_roi.py index a0ae1dd07..76aae3c38 100644 --- a/glue/core/tests/test_roi.py +++ b/glue/core/tests/test_roi.py @@ -12,7 +12,7 @@ from .. import roi as r from ..component import CategoricalComponent -from ..roi import (RectangularROI, UndefinedROI, CircularROI, PolygonalROI, CategoricalROI, +from ..roi import (RectangularROI, UndefinedROI, CircularROI, CircularAnnulusROI, PolygonalROI, CategoricalROI, MplCircularROI, MplRectangularROI, MplPolygonalROI, MplPickROI, PointROI, XRangeROI, MplXRangeROI, YRangeROI, MplYRangeROI, RangeROI, Projected3dROI, EllipticalROI) @@ -30,7 +30,7 @@ def roundtrip_roi(roi): return obj.object('__main__') -class TestPoint(object): +class TestPoint: def setup_method(self, method): self.roi = PointROI(1, 2) @@ -55,7 +55,7 @@ def test_center(self): assert self.roi.center() == (1, 2) -class TestRectangle(object): +class TestRectangle: def setup_method(self, method): self.roi = RectangularROI() @@ -155,14 +155,14 @@ def test_serialization(self): assert_almost_equal(new_roi.ymax, 4) -class TestRange(object): +class TestRange: def test_wrong_orientation(self): with pytest.raises(ValueError): RangeROI(orientation='a') -class TestXRange(object): +class TestXRange: def test_undefined_on_init(self): assert not XRangeROI().defined() @@ -212,7 +212,7 @@ def test_serialization(self): assert new_roi.ori == 'x' -class TestYRange(object): +class TestYRange: def test_undefined_on_init(self): assert not YRangeROI().defined() @@ -261,7 +261,7 @@ def test_serialization(self): assert new_roi.ori == 'y' -class TestCircle(object): +class TestCircle: def setup_method(self, method): self.roi = CircularROI() @@ -337,7 +337,70 @@ def test_serialization(self): assert_almost_equal(new_roi.radius, 5) -class TestEllipse(object): +# NOTE: Unlike the other tests here, we use test functions and not different +# class methods to guarantee tests run in given order, just in case. + +def test_circular_annulus_defined(): + roi = CircularAnnulusROI(xc=-1.6, yc=100, inner_radius=1, outer_radius=3.5) + + # test_set_center + roi.move_to(0, 0) + assert not roi.contains(0, 0) + assert roi.contains(0, 2) + + # test_contains_many + x = y = [2] * 5 + x_arr = np.asarray(x) + assert all(roi.contains(x, y)) + assert all(roi.contains(x_arr, np.asarray(y))) + assert not any(roi.contains(x_arr + 10, y)) + + # test_multidim + shape = (2, 2) + x = np.array([1.1, 1.2, 1.3, 1.4]).reshape(shape) + y = np.array([-1.1, -1.2, -1.3, -1.4]).reshape(shape) + assert roi.contains(x, y).all() + assert not roi.contains(x + 3.5, y).any() + assert roi.contains(x, y).shape == shape + + # test_serialization + new_roi = roundtrip_roi(roi) + assert_almost_equal(new_roi.xc, roi.xc) + assert_almost_equal(new_roi.yc, roi.yc) + assert_almost_equal(new_roi.inner_radius, roi.inner_radius) + assert_almost_equal(new_roi.outer_radius, roi.outer_radius) + + # test_poly + with pytest.raises(NotImplementedError, match="Cannot transform annulus to simple polygon"): + roi.to_polygon() + + # test_reset + assert roi.defined() + roi.reset() + assert not roi.defined() + + +@pytest.mark.parametrize( + ('xc', 'yc', 'r1', 'r2'), + [(None, None, None, None), + (0, 0, None, None), + (0, 0, 1, 1), + (0, 0, 2, 1)]) +def test_circular_annulus_undefined(xc, yc, r1, r2): + roi = CircularAnnulusROI(xc=xc, yc=yc, inner_radius=r1, outer_radius=r2) + assert not roi.defined() + + # test_contains_on_undefined_contains_raises + with pytest.raises(UndefinedROI): + roi.contains(1, 1) + + # test_poly_undefined + x, y = roi.to_polygon() + assert x == [] + assert y == [] + + +class TestEllipse: def setup_method(self, method): self.roi_empty = EllipticalROI() @@ -440,7 +503,7 @@ def test_serialize_rotated(self): assert new_roi.contains(-1.5, 0.5) -class TestPolygon(object): +class TestPolygon: def setup_method(self, method): self.roi = PolygonalROI() @@ -601,7 +664,7 @@ def test_serialization(self): assert_almost_equal(new_roi.vy, np.array([-sqh, 0, sqh, 0]) + 0.5) -class TestProjected3dROI(object): +class TestProjected3dROI: # matrix that converts xyzw to yxzw xyzw2yxzw = np.array([[0, 1, 0, 0], [0, 0, 1, 0], [1, 0, 0, 0], [0, 0, 0, 1]]) x = [1, 2, 3] @@ -646,7 +709,7 @@ def test_rotate2d(self): assert roi.contains3d(self.x_nd, self.y_nd, self.z_nd).tolist() == [[True, False], [True, True], [False, True]] -class TestCategorical(object): +class TestCategorical: def test_empty(self): @@ -696,7 +759,7 @@ def test_empty_categories(self): np.testing.assert_array_equal(contains, [0, 0, 0]) -class DummyEvent(object): +class DummyEvent: def __init__(self, x, y, inaxes=True, key=None): self.inaxes = inaxes self.xdata = x @@ -704,7 +767,7 @@ def __init__(self, x, y, inaxes=True, key=None): self.key = key -class MockAxes(object): +class MockAxes: def __init__(self): self.figure = MagicMock() self.figure.canvas = MagicMock() @@ -713,7 +776,7 @@ def add_patch(self, patch): pass -class TestMpl(object): +class TestMpl: def setup_method(self, method): self.axes = MagicMock() @@ -1235,7 +1298,7 @@ def test_canvas_syncs_properly(self): """No patch to test for.""" -class TestUtil(object): +class TestUtil: def setup_method(self, method): self.axes = AXES From fcc436a6639e923680900f8d278a85f37ea23b66 Mon Sep 17 00:00:00 2001 From: "P. L. Lim" <2090236+pllim@users.noreply.github.com> Date: Fri, 12 May 2023 09:17:44 -0400 Subject: [PATCH 2/4] Implement polygon approx for annulus --- glue/core/roi.py | 13 ++++++++++--- glue/core/tests/test_roi.py | 7 +++++-- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/glue/core/roi.py b/glue/core/roi.py index 509896c33..7df30c402 100644 --- a/glue/core/roi.py +++ b/glue/core/roi.py @@ -597,14 +597,21 @@ def defined(self): status = False return status - # FIXME: We need 2 polygons, with the inner one inverted, just like subset state. def to_polygon(self): if not self.defined(): return [], [] - raise NotImplementedError("Cannot transform annulus to simple polygon") + theta = np.linspace(0, 2 * np.pi, num=20) + x_inner = self.xc + self.inner_radius * np.cos(theta) + y_inner = self.yc + self.inner_radius * np.sin(theta) + x_outer = self.xc + self.outer_radius * np.cos(theta) + y_outer = self.yc + self.outer_radius * np.sin(theta) + # theta=2pi=360deg=0deg --> x=r, y=0 + x = np.concatenate((x_inner, x_outer[::-1], x_inner[0]), axis=None) + y = np.concatenate((y_inner, y_outer[::-1], y_inner[0]), axis=None) + return x, y def transformed(self, xfunc=None, yfunc=None): - raise NotImplementedError("Cannot transform annulus to simple polygon") + return PolygonalROI(*self.to_polygon()).transformed(xfunc=xfunc, yfunc=yfunc) def center(self): return self.xc, self.yc diff --git a/glue/core/tests/test_roi.py b/glue/core/tests/test_roi.py index 76aae3c38..d4083a884 100644 --- a/glue/core/tests/test_roi.py +++ b/glue/core/tests/test_roi.py @@ -371,8 +371,11 @@ def test_circular_annulus_defined(): assert_almost_equal(new_roi.outer_radius, roi.outer_radius) # test_poly - with pytest.raises(NotImplementedError, match="Cannot transform annulus to simple polygon"): - roi.to_polygon() + x, y = roi.to_polygon() + poly = PolygonalROI(vx=x, vy=y) + assert not poly.contains(0, 0) + assert poly.contains(0, 2) + assert not poly.contains(2, 0) # We have to cut it at theta=0 # test_reset assert roi.defined() From 3b3d217fc6c7565030c524364371c851b5a5bb7a Mon Sep 17 00:00:00 2001 From: "P. L. Lim" <2090236+pllim@users.noreply.github.com> Date: Fri, 12 May 2023 10:09:18 -0400 Subject: [PATCH 3/4] Increase num points to 100 from 20 for calls in to_polygon involving theta --- glue/core/roi.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/glue/core/roi.py b/glue/core/roi.py index 7df30c402..2fa574adc 100644 --- a/glue/core/roi.py +++ b/glue/core/roi.py @@ -518,7 +518,7 @@ def defined(self): def to_polygon(self): if not self.defined(): return [], [] - theta = np.linspace(0, 2 * np.pi, num=20) + theta = np.linspace(0, 2 * np.pi, num=100) x = self.xc + self.radius * np.cos(theta) y = self.yc + self.radius * np.sin(theta) return x, y @@ -600,7 +600,7 @@ def defined(self): def to_polygon(self): if not self.defined(): return [], [] - theta = np.linspace(0, 2 * np.pi, num=20) + theta = np.linspace(0, 2 * np.pi, num=100) x_inner = self.xc + self.inner_radius * np.cos(theta) y_inner = self.yc + self.inner_radius * np.sin(theta) x_outer = self.xc + self.outer_radius * np.cos(theta) @@ -724,7 +724,7 @@ def get_center(self): # pragma: no cover def to_polygon(self): if not self.defined(): return [], [] - theta = np.linspace(0, 2 * np.pi, num=20) + theta = np.linspace(0, 2 * np.pi, num=100) x = self.radius_x * np.cos(theta) y = self.radius_y * np.sin(theta) x, y = rotation_matrix_2d(self.theta) @ (x, y) From 7fd98944c3bc3a93ebe594fe97da3816bdc4f071 Mon Sep 17 00:00:00 2001 From: "P. L. Lim" <2090236+pllim@users.noreply.github.com> Date: Fri, 12 May 2023 18:08:27 -0400 Subject: [PATCH 4/4] Prevent glue from changing annulus to polygon --- glue/core/subset.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/glue/core/subset.py b/glue/core/subset.py index 6d968455b..00fbf2718 100644 --- a/glue/core/subset.py +++ b/glue/core/subset.py @@ -5,7 +5,8 @@ import numpy as np from glue.core.roi import (PolygonalROI, CategoricalROI, RangeROI, XRangeROI, - YRangeROI, RectangularROI, CircularROI, EllipticalROI, Projected3dROI) + YRangeROI, RectangularROI, CircularROI, EllipticalROI, CircularAnnulusROI, + Projected3dROI) from glue.core.contracts import contract from glue.core.util import split_component_view from glue.core.registry import Registry @@ -2018,7 +2019,8 @@ def roi_to_subset_state(roi, x_att=None, y_att=None, x_categories=None, y_catego # The selection is polygon-like or requires a pretransform and components are numerical - if not isinstance(roi, (PolygonalROI, RectangularROI, CircularROI, EllipticalROI, RangeROI)): + if not isinstance(roi, (PolygonalROI, RectangularROI, CircularROI, EllipticalROI, RangeROI, + CircularAnnulusROI)): roi = PolygonalROI(*roi.to_polygon()) subset_state = RoiSubsetState()