diff --git a/CHANGES.rst b/CHANGES.rst index 9f70557b18..e3c633b83c 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -64,6 +64,9 @@ API Changes - User APIs now raise a warning when attempting to set a non-existing attribute to avoid confusion caused by typos, etc. [#2577] +- Viewer API now exposed via ``viz.viewers`` dictionary, currently containing APIs to set axes + limits as well as astrowidgets API commands for Imviz. [#2563] + Cubeviz ^^^^^^^ diff --git a/jdaviz/configs/cubeviz/plugins/tests/test_export_plots.py b/jdaviz/configs/cubeviz/plugins/tests/test_export_plots.py index 377a628d87..2c4d3ebf28 100644 --- a/jdaviz/configs/cubeviz/plugins/tests/test_export_plots.py +++ b/jdaviz/configs/cubeviz/plugins/tests/test_export_plots.py @@ -48,7 +48,7 @@ def test_export_movie_not_cubeviz(imviz_helper): @pytest.mark.skipif(not HAS_OPENCV, reason="opencv-python is not installed") def test_export_movie_cubeviz_exceptions(cubeviz_helper, spectrum1d_cube): cubeviz_helper.load_data(spectrum1d_cube, data_label="test") - cubeviz_helper.default_viewer.shape = (100, 100) + cubeviz_helper.default_viewer._obj.shape = (100, 100) cubeviz_helper.app.get_viewer("uncert-viewer").shape = (100, 100) plugin = cubeviz_helper.plugins["Export Plot"] assert plugin._obj.movie_msg == "" diff --git a/jdaviz/configs/cubeviz/plugins/tests/test_regions.py b/jdaviz/configs/cubeviz/plugins/tests/test_regions.py index c1a862eee4..dbb5364e47 100644 --- a/jdaviz/configs/cubeviz/plugins/tests/test_regions.py +++ b/jdaviz/configs/cubeviz/plugins/tests/test_regions.py @@ -18,7 +18,7 @@ class TestLoadRegions(BaseRegionHandler): def setup_class(self, cubeviz_helper, image_cube_hdu_obj_microns): cubeviz_helper.load_data(image_cube_hdu_obj_microns, data_label='has_microns') self.cubeviz = cubeviz_helper - self.viewer = cubeviz_helper.default_viewer # This is used in BaseRegionHandler + self.viewer = cubeviz_helper.default_viewer._obj # This is used in BaseRegionHandler self.spectrum_viewer = cubeviz_helper.app.get_viewer( cubeviz_helper._default_spectrum_viewer_reference_name ) diff --git a/jdaviz/configs/default/plugins/plot_options/plot_options.py b/jdaviz/configs/default/plugins/plot_options/plot_options.py index eb27af80fc..a5ad8cbe69 100644 --- a/jdaviz/configs/default/plugins/plot_options/plot_options.py +++ b/jdaviz/configs/default/plugins/plot_options/plot_options.py @@ -935,8 +935,8 @@ def _histogram_nbins_changed(self, msg={}): def set_histogram_limits(self, x_min=None, x_max=None, y_min=None, y_max=None): # NOTE: leaving this out of user API until API is finalized with interactive setting - self.stretch_histogram.set_lims(x_min=x_min, x_max=x_max, - y_min=y_min, y_max=y_max) + self.stretch_histogram.set_limits(x_min=x_min, x_max=x_max, + y_min=y_min, y_max=y_max) def _viewer_is_image_viewer(self): # Import here to prevent circular import (and not at the top of the method so the import diff --git a/jdaviz/configs/default/plugins/viewers.py b/jdaviz/configs/default/plugins/viewers.py index 97c0906e03..5d9396b6f3 100644 --- a/jdaviz/configs/default/plugins/viewers.py +++ b/jdaviz/configs/default/plugins/viewers.py @@ -1,4 +1,5 @@ import numpy as np +from echo import delay_callback from glue.viewers.scatter.state import ScatterLayerState as BqplotScatterLayerState from glue_jupyter.bqplot.profile import BqplotProfileView @@ -7,7 +8,9 @@ from jdaviz.configs.imviz.helper import layer_is_image_data from jdaviz.components.toolbar_nested import NestedJupyterToolbar +from jdaviz.core.astrowidgets_api import AstrowidgetsImageViewerMixin from jdaviz.core.registries import viewer_registry +from jdaviz.core.user_api import ViewerUserApi from jdaviz.utils import ColorCycler, get_subset_type __all__ = ['JdavizViewerMixin'] @@ -30,6 +33,63 @@ def __init__(self, *args, **kwargs): # Allow each viewer to cycle through colors for each new addition to the viewer: self.color_cycler = ColorCycler() + @property + def user_api(self): + # default exposed user APIs. Can override this method in any particular viewer. + if isinstance(self, BqplotImageView): + if isinstance(self, AstrowidgetsImageViewerMixin): + expose = ['save', + 'center_on', 'offset_by', 'zoom_level', 'zoom', + 'colormap_options', 'set_colormap', + 'stretch_options', 'stretch', + 'autocut_options', 'cuts', + 'marker', 'add_markers', 'remove_markers', 'reset_markers', + 'blink_once', 'reset_limits'] + else: + # cubeviz image viewers don't inherit from AstrowidgetsImageViewerMixin yet, + # but also shouldn't expose set_limits because of equal aspect ratio concerns + expose = [] + elif isinstance(self, TableViewer): + expose = [] + else: + expose = ['set_limits', 'reset_limits'] + return ViewerUserApi(self, expose=expose) + + def reset_limits(self): + """ + Reset viewer axes limits. + """ + self.state.reset_limits() + + def set_limits(self, x_min=None, x_max=None, y_min=None, y_max=None): + """ + Set viewer axes limits. + + Parameters + ---------- + x_min : float or None, optional + lower-limit of x-axis (in current axes units) + x_max: float or None, optional + upper-limit of x-axis (in current axes units) + y_min : float or None, optional + lower-limit of y-axis (in current axes units) + y_max: float or None, optional + upper-limit of y-axis (in current axes units) + """ + for val in (x_min, x_max, y_min, y_max): + if val is not None and not isinstance(val, (float, int)): + raise TypeError('all arguments must be None, int, or float') + + with delay_callback(self.state, 'x_min', 'x_max', 'y_min', 'y_max'): + if x_min is not None: + self.state.x_min = x_min + if x_max is not None: + self.state.x_max = x_max + if y_min is not None: + self.state.y_min = y_min + if y_max is not None: + self.state.y_max = y_max + @property def native_marks(self): """ diff --git a/jdaviz/configs/imviz/plugins/line_profile_xy/line_profile_xy.py b/jdaviz/configs/imviz/plugins/line_profile_xy/line_profile_xy.py index 20fa3be66a..e23760b84e 100644 --- a/jdaviz/configs/imviz/plugins/line_profile_xy/line_profile_xy.py +++ b/jdaviz/configs/imviz/plugins/line_profile_xy/line_profile_xy.py @@ -127,10 +127,10 @@ def vue_draw_plot(self, msg={}): reset_lims=False) zoomed_data_x = comp.data[y_min:y_max, x] if zoomed_data_x.size > 0: - self.plot_across_x.set_lims(x_min=y_min, - x_max=y_max, - y_min=zoomed_data_x.min() * 0.95, - y_max=zoomed_data_x.max() * 1.05) + self.plot_across_x.set_limits(x_min=y_min, + x_max=y_max, + y_min=zoomed_data_x.min() * 0.95, + y_max=zoomed_data_x.max() * 1.05) self.plot_across_x.update_style('line', line_visible=True, markers_visible=False, color='gray', size=10) self.plot_across_x.viewer.axis_x.label = 'Y (pix)' @@ -141,10 +141,10 @@ def vue_draw_plot(self, msg={}): reset_lims=False) zoomed_data_y = comp.data[y, x_min:x_max] if zoomed_data_y.size > 0: - self.plot_across_y.set_lims(x_min=x_min, - x_max=x_max, - y_min=zoomed_data_y.min() * 0.95, - y_max=zoomed_data_y.max() * 1.05) + self.plot_across_y.set_limits(x_min=x_min, + x_max=x_max, + y_min=zoomed_data_y.min() * 0.95, + y_max=zoomed_data_y.max() * 1.05) self.plot_across_y.update_style('line', line_visible=True, markers_visible=False, color='gray', size=10) self.plot_across_y.viewer.axis_x.label = 'X (pix)' diff --git a/jdaviz/configs/imviz/tests/test_footprints.py b/jdaviz/configs/imviz/tests/test_footprints.py index b480bd9303..c9ef88051d 100644 --- a/jdaviz/configs/imviz/tests/test_footprints.py +++ b/jdaviz/configs/imviz/tests/test_footprints.py @@ -12,6 +12,8 @@ def _get_markers_from_viewer(viewer): + if hasattr(viewer, '_obj'): + viewer = viewer._obj return [m for m in viewer.figure.marks if isinstance(m, FootprintOverlay)] diff --git a/jdaviz/configs/imviz/tests/test_linking.py b/jdaviz/configs/imviz/tests/test_linking.py index 94dd8b6ee9..8e8bd11e1a 100644 --- a/jdaviz/configs/imviz/tests/test_linking.py +++ b/jdaviz/configs/imviz/tests/test_linking.py @@ -24,10 +24,10 @@ def test_pixel_linking(self): @property def default_viewer_limits(self): - return (self.imviz.default_viewer.state.x_min, - self.imviz.default_viewer.state.x_max, - self.imviz.default_viewer.state.y_min, - self.imviz.default_viewer.state.y_max) + return (self.imviz.default_viewer._obj.state.x_min, + self.imviz.default_viewer._obj.state.x_max, + self.imviz.default_viewer._obj.state.y_min, + self.imviz.default_viewer._obj.state.y_max) class TestLink_WCS_NoWCS(BaseImviz_WCS_NoWCS, BaseLinkHandler): @@ -104,7 +104,7 @@ def test_wcslink_affine_with_extras(self): # linking should not change axes limits, but should when resetting assert_allclose(self.default_viewer_limits, orig_pixel_limits) - self.imviz.default_viewer.state.reset_limits() + self.imviz.default_viewer.reset_limits() assert_allclose(self.default_viewer_limits, (-1.5, 9.5, -1, 10)) # Customize display on second image (last loaded). @@ -370,4 +370,4 @@ def test_imviz_no_data(imviz_helper): assert len(links) == 0 with pytest.raises(ValueError, match='No reference data for link look-up'): - imviz_helper.default_viewer.get_link_type('foo') + imviz_helper.default_viewer._obj.get_link_type('foo') diff --git a/jdaviz/configs/imviz/tests/test_regions.py b/jdaviz/configs/imviz/tests/test_regions.py index bd5603e04d..83fbadceb8 100644 --- a/jdaviz/configs/imviz/tests/test_regions.py +++ b/jdaviz/configs/imviz/tests/test_regions.py @@ -188,7 +188,7 @@ 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 + self.viewer = imviz_helper.default_viewer._obj 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 @@ -202,7 +202,7 @@ def test_ds9_load_all(self, imviz_helper): self.verify_region_loaded('MaskedSubset 1', count=1) def test_ds9_load_two_good(self, imviz_helper): - self.viewer = imviz_helper.default_viewer + self.viewer = imviz_helper.default_viewer._obj imviz_helper.load_data(self.arr, data_label='my_image') bad_regions = imviz_helper.load_regions_from_file( self.region_file, max_num_regions=2, return_bad_regions=True) @@ -212,7 +212,7 @@ def test_ds9_load_two_good(self, imviz_helper): self.verify_region_loaded('MaskedSubset 1', count=0) def test_ds9_load_one_bad(self, imviz_helper): - self.viewer = imviz_helper.default_viewer + self.viewer = imviz_helper.default_viewer._obj imviz_helper.load_data(self.arr, data_label='my_image') bad_regions = imviz_helper.load_regions(self.raw_regions[6], return_bad_regions=True) assert len(bad_regions) == 1 @@ -220,7 +220,7 @@ def test_ds9_load_one_bad(self, imviz_helper): self.verify_region_loaded('MaskedSubset 1', count=0) def test_ds9_load_one_good_one_bad(self, imviz_helper): - self.viewer = imviz_helper.default_viewer + self.viewer = imviz_helper.default_viewer._obj imviz_helper.load_data(self.arr, data_label='my_image') bad_regions = imviz_helper.load_regions( [self.raw_regions[3], self.raw_regions[6]], return_bad_regions=True) diff --git a/jdaviz/configs/imviz/tests/test_simple_aper_phot.py b/jdaviz/configs/imviz/tests/test_simple_aper_phot.py index c029ad19cf..3e1bc29772 100644 --- a/jdaviz/configs/imviz/tests/test_simple_aper_phot.py +++ b/jdaviz/configs/imviz/tests/test_simple_aper_phot.py @@ -280,7 +280,7 @@ def setup_class(self, imviz_helper): imviz_helper.load_regions(regions) self.imviz = imviz_helper - self.viewer = imviz_helper.default_viewer + self.viewer = imviz_helper.default_viewer._obj self.phot_plugin = imviz_helper.plugins["Aperture Photometry"]._obj @pytest.mark.parametrize(('data_label', 'local_bkg'), [ diff --git a/jdaviz/configs/imviz/tests/test_tools.py b/jdaviz/configs/imviz/tests/test_tools.py index 4c7a315261..68ad447a2b 100644 --- a/jdaviz/configs/imviz/tests/test_tools.py +++ b/jdaviz/configs/imviz/tests/test_tools.py @@ -7,7 +7,7 @@ class TestPanZoomTools(BaseImviz_WCS_WCS): def test_panzoom_tools(self): - v = self.imviz.default_viewer + v = self.imviz.default_viewer._obj v2 = self.imviz.create_image_viewer() self.imviz.app.add_data_to_viewer('imviz-1', 'has_wcs_1[SCI,1]') @@ -70,7 +70,7 @@ class TestSinglePixelRegion(BaseImviz_WCS_WCS): def test_singlepixelregion(self): self.imviz.link_data(link_type='wcs') - t = self.imviz.default_viewer.toolbar.tools['jdaviz:singlepixelregion'] + t = self.imviz.default_viewer._obj.toolbar.tools['jdaviz:singlepixelregion'] t.activate() # Create a region while viewing reference data. @@ -103,7 +103,7 @@ def test_singlepixelregion(self): def test_blink(imviz_helper): - viewer = imviz_helper.default_viewer + viewer = imviz_helper.default_viewer._obj for i in range(3): imviz_helper.load_data(np.zeros((2, 2)) + i, data_label=f'image_{i}') @@ -142,7 +142,7 @@ def test_compass_open_while_load(imviz_helper): def test_tool_visibility(imviz_helper): imviz_helper.load_data(np.ones((2, 2))) - tb = imviz_helper.default_viewer.toolbar + tb = imviz_helper.default_viewer._obj.toolbar assert not tb.tools_data['jdaviz:boxzoommatch']['visible'] diff --git a/jdaviz/configs/imviz/tests/utils.py b/jdaviz/configs/imviz/tests/utils.py index 8ff48d90e4..7988a8d88e 100644 --- a/jdaviz/configs/imviz/tests/utils.py +++ b/jdaviz/configs/imviz/tests/utils.py @@ -41,7 +41,7 @@ def setup_class(self, imviz_helper): self.wcs = WCS(hdu.header) self.imviz = imviz_helper - self.viewer = imviz_helper.default_viewer + self.viewer = imviz_helper.default_viewer._obj # Since we are not really displaying, need this to test zoom. self.viewer.shape = (100, 100) @@ -88,7 +88,7 @@ def setup_class(self, imviz_helper): self.wcs_1 = WCS(hdu1.header) self.wcs_2 = WCS(hdu2.header) self.imviz = imviz_helper - self.viewer = imviz_helper.default_viewer + self.viewer = imviz_helper.default_viewer._obj # Since we are not really displaying, need this to test zoom. self.viewer.shape = (100, 100) @@ -146,7 +146,7 @@ def setup_class(self, imviz_helper): self.wcs_1 = w_fits self.wcs_2 = w_gwcs self.imviz = imviz_helper - self.viewer = imviz_helper.default_viewer + self.viewer = imviz_helper.default_viewer._obj # Since we are not really displaying, need this to test zoom. self.viewer.shape = (100, 100) @@ -196,7 +196,7 @@ def setup_class(self, imviz_helper): self.wcs_1 = w_gwcs_1 self.wcs_2 = w_gwcs_2 self.imviz = imviz_helper - self.viewer = imviz_helper.default_viewer + self.viewer = imviz_helper.default_viewer._obj # Since we are not really displaying, need this to test zoom. self.viewer.shape = (100, 100) diff --git a/jdaviz/core/helpers.py b/jdaviz/core/helpers.py index faafa0afd0..d3b2dddd13 100644 --- a/jdaviz/core/helpers.py +++ b/jdaviz/core/helpers.py @@ -122,6 +122,19 @@ def plugins(self): return {item['label']: widget_serialization['from_json'](item['widget'], None).user_api for item in self.app.state.tray_items} + @property + def viewers(self): + """ + Access API objects for any viewer. + + Returns + ------- + viewers : dict + dict of viewer objects + """ + return {getattr(viewer, 'reference', k): viewer.user_api + for k, viewer in self.app._viewer_store.items()} + @property def fitted_models(self): """ @@ -611,7 +624,7 @@ def __init__(self, *args, **kwargs): def default_viewer(self): """Default viewer instance. This is typically the first viewer (e.g., "imviz-0" or "cubeviz-0").""" - return self._default_viewer + return self._default_viewer.user_api def load_regions_from_file(self, region_file, region_format='ds9', max_num_regions=20, **kwargs): @@ -729,7 +742,7 @@ def load_regions(self, regions, max_num_regions=None, refdata_label=None, # Subset is global but reference data is viewer-dependent. if refdata_label is None: - data = self.default_viewer.state.reference_data + data = self.default_viewer._obj.state.reference_data else: data = self.app.data_collection[refdata_label] @@ -763,7 +776,7 @@ def load_regions(self, regions, max_num_regions=None, refdata_label=None, # TODO: Do we want user to specify viewer? Does it matter? self.app.session.edit_subset_mode._mode = NewMode - self.default_viewer.apply_roi(state) + self.default_viewer._obj.apply_roi(state) self.app.session.edit_subset_mode.edit_subset = None # No overwrite next iteration # noqa # Last resort: Masked Subset that is static (if data is not a cube) @@ -842,7 +855,7 @@ def get_interactive_regions(self): failed_regs = set() # Subset is global, so we just use default viewer. - for lyr in self.default_viewer.layers: + for lyr in self.default_viewer._obj.layers: if (not hasattr(lyr, 'layer') or not isinstance(lyr.layer, Subset) or lyr.layer.ndim not in (2, 3)): continue @@ -876,7 +889,7 @@ def _apply_interactive_region(self, toolname, from_pix, to_pix): This is for internal testing only. """ self.app.session.edit_subset_mode._mode = NewMode - tool = self.default_viewer.toolbar.tools[toolname] + tool = self.default_viewer._obj.toolbar.tools[toolname] tool.activate() tool.interact.brushing = True tool.interact.selected = [from_pix, to_pix] diff --git a/jdaviz/core/template_mixin.py b/jdaviz/core/template_mixin.py index b21ba5bfac..b11b28c1a9 100644 --- a/jdaviz/core/template_mixin.py +++ b/jdaviz/core/template_mixin.py @@ -3626,7 +3626,7 @@ def add_bins(self, label, sample=[0], bins=2, density=True, **kwargs): colors=kwargs.pop('color', kwargs.pop('colors', 'gray')), **kwargs) - def set_lims(self, x_min=None, x_max=None, y_min=None, y_max=None): + def set_limits(self, x_min=None, x_max=None, y_min=None, y_max=None): with delay_callback(self.viewer.state, 'x_min', 'x_max', 'y_min', 'y_max'): if x_min is not None: self.viewer.state.x_min = x_min diff --git a/jdaviz/core/user_api.py b/jdaviz/core/user_api.py index 6b6d124e96..8db48b085f 100644 --- a/jdaviz/core/user_api.py +++ b/jdaviz/core/user_api.py @@ -1,6 +1,6 @@ import astropy.units as u -__all__ = ['UserApiWrapper', 'PluginUserApi'] +__all__ = ['UserApiWrapper', 'PluginUserApi', 'ViewerUserApi'] _internal_attrs = ('_obj', '_expose', '_readonly', '__doc__') @@ -98,3 +98,20 @@ def __init__(self, plugin, expose=[], readonly=[]): def __repr__(self): return f'<{self._obj._registry_label} API>' + + +class ViewerUserApi(UserApiWrapper): + """ + This is an API wrapper around a viewer. For a full list of attributes/methods, + call dir(viewer_object) and for help on any of those methods, + call help(viewer_object.attribute). + + For example:: + help(viewer_object.show) + """ + def __init__(self, viewer, expose=[], readonly=[]): + expose = list(set(list(expose) + [])) + super().__init__(viewer, expose, readonly) + + def __repr__(self): + return f'<{self._obj.reference} API>' diff --git a/jdaviz/tests/test_app.py b/jdaviz/tests/test_app.py index 89d9413eca..3b0166f032 100644 --- a/jdaviz/tests/test_app.py +++ b/jdaviz/tests/test_app.py @@ -6,7 +6,7 @@ # This applies to all viz but testing with Imviz should be enough. def test_viewer_calling_app(imviz_helper): - viewer = imviz_helper.default_viewer + viewer = imviz_helper.default_viewer._obj assert viewer.session.jdaviz_app is imviz_helper.app diff --git a/jdaviz/tests/test_subsets.py b/jdaviz/tests/test_subsets.py index d43ed794b8..c2244ff31c 100644 --- a/jdaviz/tests/test_subsets.py +++ b/jdaviz/tests/test_subsets.py @@ -489,7 +489,7 @@ def test_composite_region_with_imviz(imviz_helper, image_2d_wcs): arr = np.ones((10, 10)) data_label = 'image-data' - viewer = imviz_helper.default_viewer + viewer = imviz_helper.default_viewer._obj imviz_helper.load_data(arr, data_label=data_label, show_in_viewer=True) viewer.apply_roi(CircularROI(xc=5, yc=5, radius=2)) reg = imviz_helper.app.get_subsets("Subset 1") diff --git a/jdaviz/tests/test_user_api.py b/jdaviz/tests/test_user_api.py new file mode 100644 index 0000000000..e18ba9c0a1 --- /dev/null +++ b/jdaviz/tests/test_user_api.py @@ -0,0 +1,23 @@ +from jdaviz.configs.imviz.tests.utils import BaseImviz_WCS_WCS + + +# This applies to all viz but testing with Imviz should be enough. +class TestImviz_WCS_WCS(BaseImviz_WCS_WCS): + def test_imviz_zoom_level(self): + v = self.imviz.viewers['imviz-0'] + assert v._obj.state.x_min == -0.5 + assert v._obj.state.x_max == 9.5 + + v.zoom(2) + + assert v._obj.state.x_min == 1.5 + assert v._obj.state.x_max == 6.5 + + +def test_specviz_zoom_level(specviz_helper): + v = specviz_helper.viewers['spectrum-viewer'] + v.set_limits(x_min=1, x_max=2, y_min=1, y_max=2) + assert v._obj.state.x_min == 1 + assert v._obj.state.x_max == 2 + assert v._obj.state.y_min == 1 + assert v._obj.state.y_max == 2 diff --git a/notebooks/concepts/imviz_color_display.ipynb b/notebooks/concepts/imviz_color_display.ipynb index 6072f3b052..fd82b3cb1c 100644 --- a/notebooks/concepts/imviz_color_display.ipynb +++ b/notebooks/concepts/imviz_color_display.ipynb @@ -92,7 +92,7 @@ "imviz.load_data(im_g, data_label='Green')\n", "imviz.load_data(im_b, data_label='Blue')\n", "\n", - "viewer = imviz.default_viewer\n", + "viewer = imviz.default_viewer._obj\n", "\n", "imviz.show()" ] diff --git a/notebooks/concepts/imviz_dithered_gwcs.ipynb b/notebooks/concepts/imviz_dithered_gwcs.ipynb index a058215f82..7e716ef3bf 100644 --- a/notebooks/concepts/imviz_dithered_gwcs.ipynb +++ b/notebooks/concepts/imviz_dithered_gwcs.ipynb @@ -124,7 +124,7 @@ "outputs": [], "source": [ "# Home button\n", - "imviz.default_viewer.state.reset_limits()" + "imviz.default_viewer.reset_limits()" ] }, {