diff --git a/CHANGES.rst b/CHANGES.rst index 79a9e43eaf..1da7311aa3 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -12,6 +12,11 @@ Cubeviz Imviz ^^^^^ +- Added the ability to load DS9 region files (``.reg``) using the ``IMPORT DATA`` + button. However, this only works after loading at least one image into Imviz. [#2201] + +- Added support for new ``CircularAnnulusROI`` subset from glue. [#2201] + Mosviz ^^^^^^ diff --git a/docs/imviz/import_data.rst b/docs/imviz/import_data.rst index 82ff507764..6d23609109 100644 --- a/docs/imviz/import_data.rst +++ b/docs/imviz/import_data.rst @@ -43,6 +43,9 @@ application. A notification will appear to let users know if the data import was successful. Afterward, the new data set can be found in the :guilabel:`Data` tab of each viewer's options menu as described in :ref:`cubeviz-selecting-data`. +Once data is loaded, you may use the :guilabel:`Import Data` button again +to load regions from a ``.reg`` file; also see :ref:`imviz-import-regions-api`. + .. _imviz-import-api: Importing data via the API diff --git a/jdaviz/configs/imviz/plugins/parsers.py b/jdaviz/configs/imviz/plugins/parsers.py index 4789830bc1..39b1068528 100644 --- a/jdaviz/configs/imviz/plugins/parsers.py +++ b/jdaviz/configs/imviz/plugins/parsers.py @@ -82,6 +82,11 @@ def parse_data(app, file_obj, ext=None, data_label=None): ) with rdd.open(file_obj) as pf: _parse_image(app, pf, data_label, ext=ext) + + elif file_obj_lower.endswith('.reg'): + # This will load DS9 regions as Subset but only if there is already data. + app._jdaviz_helper.load_regions_from_file(file_obj) + else: # Assume FITS with fits.open(file_obj) as pf: _parse_image(app, pf, data_label, ext=ext) diff --git a/jdaviz/configs/imviz/tests/data/ds9_annulus_01.reg b/jdaviz/configs/imviz/tests/data/ds9_annulus_01.reg new file mode 100644 index 0000000000..b4d5144aa2 --- /dev/null +++ b/jdaviz/configs/imviz/tests/data/ds9_annulus_01.reg @@ -0,0 +1,4 @@ +# Region file format: DS9 version 4.1 +global color=green dashlist=8 3 width=1 font="helvetica 10 normal roman" select=1 highlite=1 dash=0 fixed=0 edit=1 move=1 delete=1 include=1 source=1 +icrs +annulus(197.8929,-1.36599,1.9820003",3.9640007",5.946001") # color=magenta font="helvetica 10 bold roman" text={Annulus} diff --git a/jdaviz/configs/imviz/tests/test_regions.py b/jdaviz/configs/imviz/tests/test_regions.py index 38a60fbc78..145ed2f1ad 100644 --- a/jdaviz/configs/imviz/tests/test_regions.py +++ b/jdaviz/configs/imviz/tests/test_regions.py @@ -1,9 +1,8 @@ -import glue_astronomy import numpy as np +import pytest from astropy import units as u from astropy.coordinates import SkyCoord, Angle from astropy.utils.data import get_pkg_data_filename -from packaging.version import Version from photutils.aperture import CircularAperture, SkyCircularAperture from regions import (PixCoord, CircleSkyRegion, RectanglePixelRegion, CirclePixelRegion, EllipsePixelRegion, PointSkyRegion, PolygonPixelRegion, @@ -11,8 +10,6 @@ from jdaviz.configs.imviz.tests.utils import BaseImviz_WCS_NoWCS -GLUE_ASTRONOMY_LT_0_7_1 = not (Version(glue_astronomy.__version__) >= Version("0.7.1.dev")) - class BaseRegionHandler: """Test to see if region is loaded. @@ -122,13 +119,15 @@ def test_regions_sky_has_wcs(self): self.imviz._apply_interactive_region('bqplot:circle', (1.5, 2.5), (3.6, 4.6)) sky = SkyCoord(ra=337.5202808, dec=-20.833333059999998, unit='deg') - # This will become indistinguishable from normal Subset. + # These will become indistinguishable from normal Subset. my_reg_sky_1 = CircleSkyRegion(sky, Angle(0.5, u.arcsec)) - # Masked subset. my_reg_sky_2 = CircleAnnulusSkyRegion(center=sky, inner_radius=0.0004 * u.deg, outer_radius=0.0005 * u.deg) - # Add them both. - bad_regions = self.imviz.load_regions([my_reg_sky_1, my_reg_sky_2], return_bad_regions=True) + # Masked subset. + my_reg_sky_3 = PolygonPixelRegion(vertices=PixCoord(x=[1, 1, 3, 3, 1], y=[1, 3, 3, 1, 1])) + # Add them all. + bad_regions = self.imviz.load_regions([my_reg_sky_1, my_reg_sky_2, my_reg_sky_3], + return_bad_regions=True) assert len(bad_regions) == 0 # Mimic interactive regions (after) @@ -139,15 +138,28 @@ def test_regions_sky_has_wcs(self): # that check hopefully is already done in glue-astronomy. # Apparently, static region ate up one number... subsets = self.imviz.get_interactive_regions() - assert list(subsets.keys()) == ['Subset 1', 'Subset 2', 'Subset 4', 'Subset 5'], subsets + assert list(subsets.keys()) == ['Subset 1', 'Subset 2', 'Subset 3', 'Subset 5', 'Subset 6'], subsets # noqa: E501 assert isinstance(subsets['Subset 1'], CirclePixelRegion) assert isinstance(subsets['Subset 2'], CirclePixelRegion) - assert isinstance(subsets['Subset 4'], EllipsePixelRegion) - assert isinstance(subsets['Subset 5'], RectanglePixelRegion) + assert isinstance(subsets['Subset 3'], CircleAnnulusPixelRegion) + assert isinstance(subsets['Subset 5'], EllipsePixelRegion) + assert isinstance(subsets['Subset 6'], RectanglePixelRegion) # Check static region self.verify_region_loaded('MaskedSubset 1') + def test_regions_annulus_from_load_data(self): + # This file actually will load 2 annuli + regfile = get_pkg_data_filename('data/ds9_annulus_01.reg') + self.imviz.load_data(regfile) + assert len(self.imviz.app.data_collection) == 2 # Make sure not loaded as data + + subsets = self.imviz.get_interactive_regions() + subset_names = list(subsets.keys()) + assert subset_names == ['Subset 1', 'Subset 2'] + for n in subset_names: + assert isinstance(subsets[n], CircleAnnulusPixelRegion) + def test_photutils_pixel(self): my_aper = CircularAperture((5, 5), r=2) bad_regions = self.imviz.load_regions([my_aper], return_bad_regions=True) @@ -173,18 +185,21 @@ def setup_class(self): self.raw_regions = Regions.read(self.region_file, format='ds9') def test_ds9_load_all(self, imviz_helper): + with pytest.raises(ValueError, match="Cannot load regions without data"): + imviz_helper.load_data(self.region_file) + self.viewer = imviz_helper.default_viewer imviz_helper.load_data(self.arr, data_label='my_image') bad_regions = imviz_helper.load_regions_from_file(self.region_file, return_bad_regions=True) assert len(bad_regions) == 1 - # Will load 8/9 and 6 of that become ROIs. + # Will load 8/9 and 7 of that become ROIs. subsets = imviz_helper.get_interactive_regions() assert list(subsets.keys()) == ['Subset 1', 'Subset 2', 'Subset 3', - 'Subset 4', 'Subset 5', 'Subset 6'], subsets + 'Subset 4', 'Subset 5', 'Subset 6', 'Subset 7'], subsets - for i in (1, 2): # The other 2 are MaskedSubset - self.verify_region_loaded(f'MaskedSubset {i}', count=1) + # The other 1 is MaskedSubset + self.verify_region_loaded('MaskedSubset 1', count=1) def test_ds9_load_two_good(self, imviz_helper): self.viewer = imviz_helper.default_viewer @@ -234,18 +249,12 @@ def test_annulus(self): new_subset = subset_groups[0].subset_state & ~subset_groups[1].subset_state self.viewer.apply_subset_state(new_subset) - # In older glue-astronomy, annulus is no longer accessible by API - # but also should not crash Imviz. subsets = self.imviz.get_interactive_regions() assert len(self.imviz.app.data_collection.subset_groups) == 3 - if GLUE_ASTRONOMY_LT_0_7_1: - expected_subset_keys = ['Subset 1', 'Subset 2'] - else: - expected_subset_keys = ['Subset 1', 'Subset 2', 'Subset 3'] - assert isinstance(subsets['Subset 3'], CircleAnnulusPixelRegion) - assert list(subsets.keys()) == expected_subset_keys, subsets + assert list(subsets.keys()) == ['Subset 1', 'Subset 2', 'Subset 3'], subsets assert isinstance(subsets['Subset 1'], CirclePixelRegion) assert isinstance(subsets['Subset 2'], CirclePixelRegion) + assert isinstance(subsets['Subset 3'], CircleAnnulusPixelRegion) # Clear the regions for next test. self.imviz._delete_all_regions() diff --git a/jdaviz/core/helpers.py b/jdaviz/core/helpers.py index 7a4c171fcd..c6dc0fb457 100644 --- a/jdaviz/core/helpers.py +++ b/jdaviz/core/helpers.py @@ -658,12 +658,17 @@ def load_regions(self, regions, max_num_regions=None, refdata_label=None, If not requested, return `None`. """ + if len(self.app.data_collection) == 0: + raise ValueError('Cannot load regions without data.') + from photutils.aperture import (CircularAperture, SkyCircularAperture, EllipticalAperture, SkyEllipticalAperture, - RectangularAperture, SkyRectangularAperture) + RectangularAperture, SkyRectangularAperture, + CircularAnnulus, SkyCircularAnnulus) from regions import (Regions, CirclePixelRegion, CircleSkyRegion, EllipsePixelRegion, EllipseSkyRegion, - RectanglePixelRegion, RectangleSkyRegion) + RectanglePixelRegion, RectangleSkyRegion, + CircleAnnulusPixelRegion, CircleAnnulusSkyRegion) from jdaviz.core.region_translators import regions2roi, aperture2regions # If user passes in one region obj instead of list, try to be smart. @@ -688,23 +693,27 @@ def load_regions(self, regions, max_num_regions=None, refdata_label=None, has_wcs = data_has_valid_wcs(data, ndim=2) for region in regions: - if isinstance(region, (SkyCircularAperture, SkyEllipticalAperture, - SkyRectangularAperture, CircleSkyRegion, - EllipseSkyRegion, RectangleSkyRegion)) and not has_wcs: + if (isinstance(region, (SkyCircularAperture, SkyEllipticalAperture, + SkyRectangularAperture, SkyCircularAnnulus, + CircleSkyRegion, EllipseSkyRegion, + RectangleSkyRegion, CircleAnnulusSkyRegion)) + and not has_wcs): bad_regions.append((region, 'Sky region provided but data has no valid WCS')) continue # photutils: Convert to regions shape first if isinstance(region, (CircularAperture, SkyCircularAperture, EllipticalAperture, SkyEllipticalAperture, - RectangularAperture, SkyRectangularAperture)): + RectangularAperture, SkyRectangularAperture, + CircularAnnulus, SkyCircularAnnulus)): region = aperture2regions(region) # regions: Convert to ROI. # NOTE: Out-of-bounds ROI will succeed; this is native glue behavior. if isinstance(region, (CirclePixelRegion, CircleSkyRegion, EllipsePixelRegion, EllipseSkyRegion, - RectanglePixelRegion, RectangleSkyRegion)): + RectanglePixelRegion, RectangleSkyRegion, + CircleAnnulusPixelRegion, CircleAnnulusSkyRegion)): state = regions2roi(region, wcs=data.coords) # TODO: Do we want user to specify viewer? Does it matter? diff --git a/jdaviz/core/region_translators.py b/jdaviz/core/region_translators.py index 17e6d3defe..df1d277fb0 100644 --- a/jdaviz/core/region_translators.py +++ b/jdaviz/core/region_translators.py @@ -3,7 +3,7 @@ """ from astropy import units as u from astropy.coordinates import Angle -from glue.core.roi import CircularROI, EllipticalROI, RectangularROI +from glue.core.roi import CircularROI, EllipticalROI, RectangularROI, CircularAnnulusROI from photutils.aperture import (CircularAperture, SkyCircularAperture, EllipticalAperture, SkyEllipticalAperture, RectangularAperture, SkyRectangularAperture, @@ -115,7 +115,8 @@ def regions2roi(region_shape, wcs=None): """ - if isinstance(region_shape, (CircleSkyRegion, EllipseSkyRegion, RectangleSkyRegion)): + if isinstance(region_shape, (CircleSkyRegion, EllipseSkyRegion, RectangleSkyRegion, + CircleAnnulusSkyRegion)): if wcs is None: raise ValueError(f'WCS must be provided for {region_shape}') @@ -140,6 +141,10 @@ def regions2roi(region_shape, wcs=None): roi = RectangularROI( xmin=xmin, xmax=xmax, ymin=ymin, ymax=ymax, theta=region_shape.angle.to_value(u.radian)) + elif isinstance(region_shape, CircleAnnulusPixelRegion): + roi = CircularAnnulusROI( + xc=region_shape.center.x, yc=region_shape.center.y, + inner_radius=region_shape.inner_radius, outer_radius=region_shape.outer_radius) else: raise NotImplementedError(f'{region_shape.__class__.__name__} is not supported') diff --git a/pyproject.toml b/pyproject.toml index ebd23f4b9e..f80d3aa37e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,7 @@ dependencies = [ "traitlets>=5.0.5", "bqplot>=0.12.37", "bqplot-image-gl>=1.4.11", - "glue-core>=1.6.0,!=1.9.0,!=1.10", + "glue-core>=1.11", "glue-jupyter>=0.15.0", "echo>=0.5.0", "ipykernel>=6.19.4", @@ -26,7 +26,7 @@ dependencies = [ "specutils>=1.9", "specreduce>=1.3.0,<1.4.0", "photutils>=1.4", - "glue-astronomy>=0.7", + "glue-astronomy>=0.9", "asteval>=0.9.23", "idna", "vispy>=0.6.5",