From 4b182130adefd4a73f7937ef2cf9618f4fafce81 Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Fri, 2 Jul 2021 15:09:41 -0500 Subject: [PATCH 01/50] Initial draft on interface definition --- astrowidgets/interface_definition.py | 81 ++++++++++++++++++++++++++++ astrowidgets/tests/test_api.py | 8 ++- 2 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 astrowidgets/interface_definition.py diff --git a/astrowidgets/interface_definition.py b/astrowidgets/interface_definition.py new file mode 100644 index 0000000..9a5bf45 --- /dev/null +++ b/astrowidgets/interface_definition.py @@ -0,0 +1,81 @@ +from typing import Protocol, runtime_checkable, Any +from abc import abstractmethod + + +@runtime_checkable +class ImageViewerInterface(Protocol): + # This are attributes, not methods. The type annotations are there + # to make sure Protocol knows they are attributes. Python does not + # do any checking at all of these types. + click_center: bool + click_drag: bool + scroll_pan: bool + image_width: int + image_height: int + zoom_level: float + marker: Any + cuts: Any + stretch: str + # viewer: Any + + # The methods, grouped loosely by purpose + + # Methods for loading data + @abstractmethod + def load_fits(self, file): + raise NotImplementedError + + @abstractmethod + def load_array(self, array): + raise NotImplementedError + + @abstractmethod + def load_nddata(self, data): + raise NotImplementedError + + # Saving contents of the view and accessing the view + @abstractmethod + def save(self, filename): + raise NotImplementedError + + # Marker-related methods + @abstractmethod + def start_marking(self): + raise NotImplementedError + + @abstractmethod + def stop_marking(self): + raise NotImplementedError + + @abstractmethod + def add_markers(self): + raise NotImplementedError + + @abstractmethod + def get_markers(self): + raise NotImplementedError + + @abstractmethod + def remove_markers(self): + raise NotImplementedError + + # @abstractmethod + # def get_all_markers(self): + # raise NotImplementedError + + # @abstractmethod + # def get_markers_by_name(self, marker_name=None): + # raise NotImplementedError + + # Methods that modify the view + @abstractmethod + def center_on(self): + raise NotImplementedError + + @abstractmethod + def offset_to(self): + raise NotImplementedError + + @abstractmethod + def zoom(self): + raise NotImplementedError diff --git a/astrowidgets/tests/test_api.py b/astrowidgets/tests/test_api.py index 37113e5..6fc19f9 100644 --- a/astrowidgets/tests/test_api.py +++ b/astrowidgets/tests/test_api.py @@ -10,7 +10,13 @@ from ginga.ColorDist import ColorDistBase -from ..core import ImageWidget, ALLOWED_CURSOR_LOCATIONS +from astrowidgets.core import ImageWidget, ALLOWED_CURSOR_LOCATIONS +from astrowidgets.interface_definition import ImageViewerInterface + + +def test_consistent_interface(): + iw = ImageWidget() + assert isinstance(iw, ImageViewerInterface) def test_load_fits(): From 9ee09dfe089437feadff38987e7c314b7a653b49 Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Mon, 12 Jul 2021 16:53:50 -0500 Subject: [PATCH 02/50] Minimal change implementation of concrete ginga viewer --- astrowidgets/__init__.py | 2 +- astrowidgets/{core.py => ginga.py} | 0 astrowidgets/tests/{test_api.py => test_api_ginga.py} | 2 +- .../tests/{test_image_widget.py => test_ginga_widget.py} | 2 +- example_notebooks/ginga_wcsaxes.ipynb | 2 +- example_notebooks/ginga_widget.ipynb | 2 +- example_notebooks/gui_interactions.ipynb | 2 +- example_notebooks/named_markers.ipynb | 2 +- 8 files changed, 7 insertions(+), 7 deletions(-) rename astrowidgets/{core.py => ginga.py} (100%) rename astrowidgets/tests/{test_api.py => test_api_ginga.py} (99%) rename astrowidgets/tests/{test_image_widget.py => test_ginga_widget.py} (99%) diff --git a/astrowidgets/__init__.py b/astrowidgets/__init__.py index 30fa134..71c90ef 100644 --- a/astrowidgets/__init__.py +++ b/astrowidgets/__init__.py @@ -6,4 +6,4 @@ from ._astropy_init import * # noqa # ---------------------------------------------------------------------------- -from .core import * # noqa +# from .core import * # noqa diff --git a/astrowidgets/core.py b/astrowidgets/ginga.py similarity index 100% rename from astrowidgets/core.py rename to astrowidgets/ginga.py diff --git a/astrowidgets/tests/test_api.py b/astrowidgets/tests/test_api_ginga.py similarity index 99% rename from astrowidgets/tests/test_api.py rename to astrowidgets/tests/test_api_ginga.py index 6fc19f9..a8c61f8 100644 --- a/astrowidgets/tests/test_api.py +++ b/astrowidgets/tests/test_api_ginga.py @@ -10,7 +10,7 @@ from ginga.ColorDist import ColorDistBase -from astrowidgets.core import ImageWidget, ALLOWED_CURSOR_LOCATIONS +from astrowidgets.ginga import ImageWidget, ALLOWED_CURSOR_LOCATIONS from astrowidgets.interface_definition import ImageViewerInterface diff --git a/astrowidgets/tests/test_image_widget.py b/astrowidgets/tests/test_ginga_widget.py similarity index 99% rename from astrowidgets/tests/test_image_widget.py rename to astrowidgets/tests/test_ginga_widget.py index 4f4f142..463d793 100644 --- a/astrowidgets/tests/test_image_widget.py +++ b/astrowidgets/tests/test_ginga_widget.py @@ -7,7 +7,7 @@ from astropy.nddata import CCDData from astropy.coordinates import SkyCoord -from ..core import ImageWidget, RESERVED_MARKER_SET_NAMES +from ..ginga import ImageWidget, RESERVED_MARKER_SET_NAMES def _make_fake_ccd(with_wcs=True): diff --git a/example_notebooks/ginga_wcsaxes.ipynb b/example_notebooks/ginga_wcsaxes.ipynb index 0a37a65..d16c989 100644 --- a/example_notebooks/ginga_wcsaxes.ipynb +++ b/example_notebooks/ginga_wcsaxes.ipynb @@ -23,7 +23,7 @@ "outputs": [], "source": [ "from astropy.nddata import CCDData\n", - "from astrowidgets import ImageWidget as _ImageWidget\n", + "from astrowidgets import ImageWidget.ginga as _ImageWidget\n", "from ginga.canvas.types.astro import WCSAxes\n", "from ginga.misc.log import get_logger" ] diff --git a/example_notebooks/ginga_widget.ipynb b/example_notebooks/ginga_widget.ipynb index c422fca..8f4bf34 100644 --- a/example_notebooks/ginga_widget.ipynb +++ b/example_notebooks/ginga_widget.ipynb @@ -20,7 +20,7 @@ "metadata": {}, "outputs": [], "source": [ - "from astrowidgets import ImageWidget" + "from astrowidgets.ginga import ImageWidget" ] }, { diff --git a/example_notebooks/gui_interactions.ipynb b/example_notebooks/gui_interactions.ipynb index 3daacb7..9f14b11 100644 --- a/example_notebooks/gui_interactions.ipynb +++ b/example_notebooks/gui_interactions.ipynb @@ -32,7 +32,7 @@ "metadata": {}, "outputs": [], "source": [ - "from astrowidgets import ImageWidget" + "from astrowidgets.ginga import ImageWidget" ] }, { diff --git a/example_notebooks/named_markers.ipynb b/example_notebooks/named_markers.ipynb index 0e724d2..51f02e4 100644 --- a/example_notebooks/named_markers.ipynb +++ b/example_notebooks/named_markers.ipynb @@ -20,7 +20,7 @@ "metadata": {}, "outputs": [], "source": [ - "from astrowidgets import ImageWidget\n", + "from astrowidgets import.ginga ImageWidget\n", "from astropy.table import Table \n", "\n", "import numpy as np" From fead302a68118d3ec033c4441de275dec1ac478e Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Fri, 16 Jul 2021 16:29:52 -0500 Subject: [PATCH 03/50] Partial implementation of bqplot astro widget --- astrowidgets/bqplot.py | 655 ++++++++++++++++++++++++++ astrowidgets/tests/test_bqplot_api.py | 303 ++++++++++++ 2 files changed, 958 insertions(+) create mode 100644 astrowidgets/bqplot.py create mode 100644 astrowidgets/tests/test_bqplot_api.py diff --git a/astrowidgets/bqplot.py b/astrowidgets/bqplot.py new file mode 100644 index 0000000..50f2317 --- /dev/null +++ b/astrowidgets/bqplot.py @@ -0,0 +1,655 @@ +import numpy as np + +from astropy.coordinates import SkyCoord +from astropy.io import fits +from astropy.nddata import CCDData +from astropy.table import Table, vstack +from astropy import units as u +import astropy.visualization as apviz + +from bqplot import Figure, LinearScale, Axis, ColorScale, PanZoom, ScatterGL +from bqplot_image_gl import ImageGL +from bqplot_image_gl.interacts import (MouseInteraction, + keyboard_events, mouse_events) + +import ipywidgets as ipw + +from matplotlib import cm as cmp +from matplotlib import pyplot +from matplotlib.colors import to_hex + +import traitlets as trait + +# Allowed locations for cursor display +ALLOWED_CURSOR_LOCATIONS = ['top', 'bottom', None] + +# List of marker names that are for internal use only +RESERVED_MARKER_SET_NAMES = ['all'] + + +class _AstroImage(ipw.VBox): + """ + Encapsulate an image as a bqplot figure inside a box. + + bqplot is involved for its pan/zoom capabilities, and it presents as + a box to obscure the usual bqplot properties and methods. + """ + def __init__(self, image_data=None, + display_width=500, + viewer_aspect_ratio=1.0): + super().__init__() + + self._viewer_aspect_ratio = viewer_aspect_ratio + + self._display_width = display_width + self._display_height = self._viewer_aspect_ratio * self._display_width + + + layout = ipw.Layout(width=f'{self._display_width}px', + height=f'{self._display_height}px', + justify_content='center') + + self._figure_layout = layout + + scale_x = LinearScale(min=0, max=1, #self._image_shape[1], + allow_padding=False) + scale_y = LinearScale(min=0, max=1, #self._image_shape[0], + allow_padding=False) + self._scales = {'x': scale_x, 'y': scale_y} + axis_x = Axis(scale=scale_x, visible=False) + axis_y = Axis(scale=scale_y, orientation='vertical', visible=False) + scales_image = {'x': scale_x, 'y': scale_y, + 'image': ColorScale(max=1.114, min=2902, + scheme='Greys')} + + self._scatter_marks = {} + + self._figure = Figure(scales=self._scales, axes=[axis_x, axis_y], + fig_margin=dict(top=0, left=0, + right=0, bottom=0), + layout=layout) + + self._image = ImageGL(scales=scales_image) + + self._figure.marks = (self._image, ) + + panzoom = PanZoom(scales={'x': [scales_image['x']], + 'y': [scales_image['y']]}) + interaction = MouseInteraction(x_scale=scales_image['x'], + y_scale=scales_image['y'], + move_throttle=70, next=panzoom, + events=keyboard_events + mouse_events) + + self._figure.interaction = interaction + + # Keep track of this separately so that it is easy to change + # its state. + self._panzoom = panzoom + + if image_data: + self.set_data(image_data, reset_view=True) + + self.children = (self._figure, ) + + @property + def data_aspect_ratio(self): + """ + Aspect ratio of the image data, horizontal size over vertical size. + """ + return self._image_shape[0] / self._image_shape[1] + + def reset_scale_to_fit_image(self): + wide = self.data_aspect_ratio < 1 + tall = self.data_aspect_ratio > 1 + square = self.data_aspect_ratio == 1 + + if wide: + self._scales['x'].min = 0 + self._scales['x'].max = self._image_shape[1] + self._set_scale_aspect_ratio_to_match_viewer() + elif tall or square: + self._scales['y'].min = 0 + self._scales['y'].max = self._image_shape[0] + self._set_scale_aspect_ratio_to_match_viewer(reset_scale='x') + + # Great, now let's center + self.center = (self._image_shape[1]/2, + self._image_shape[0]/2) + + + def _set_scale_aspect_ratio_to_match_viewer(self, + reset_scale='y'): + # Set the scales so that they match the aspect ratio + # of the viewer, preserving the current image center. + width_x, width_y = self.scale_widths + frozen_width = dict(y=width_x, x=width_y) + scale_aspect = width_x / width_y + figure_x = float(self._figure.layout.width[:-2]) + figure_y = float(self._figure.layout.height[:-2]) + figure_aspect = figure_x / figure_y + current_center = self.center + if abs(figure_aspect - scale_aspect) > 1e-4: + # Make the scale aspect ratio match the + # figure layout aspect ratio + if reset_scale == 'y': + scale_factor = 1/ figure_aspect + else: + scale_factor = figure_aspect + + self._scales[reset_scale].min = 0 + self._scales[reset_scale].max = frozen_width[reset_scale] * scale_factor + self.center = current_center + + def set_data(self, image_data, reset_view=True): + self._image_shape = image_data.shape + + if reset_view: + self.reset_scale_to_fit_image() + + # Set the image data and map it to the bqplot figure so that + # cursor location corresponds to the underlying array index. + self._image.image = image_data + self._image.x = [0, self._image_shape[1]] + self._image.y = [0, self._image_shape[0]] + + @property + def center(self): + """ + Center of current view in pixels in x, y. + """ + x_center = (self._scales['x'].min + self._scales['x'].max) / 2 + y_center = (self._scales['y'].min + self._scales['y'].max) / 2 + return (x_center, y_center) + + @property + def scale_widths(self): + width_x = self._scales['x'].max - self._scales['x'].min + width_y = self._scales['y'].max - self._scales['y'].min + return (width_x, width_y) + + @center.setter + def center(self, value): + x_c, y_c = value + + width_x, width_y = self.scale_widths + self._scales['x'].max = x_c + width_x / 2 + self._scales['x'].min = x_c - width_x / 2 + self._scales['y'].max = y_c + width_y / 2 + self._scales['y'].min = y_c - width_y / 2 + + def set_color(self, colors): + # colors here means a list of hex colors + self._image.scales['image'].colors = colors + + def save_png(self, filename): + self._figure.save_png(filename) + + def save_svg(self, filename): + self._figure.save_svg(filename) + + def set_pan(self, on_or_off): + self._panzoom.allow_pan = on_or_off + + def set_scroll_zoom(self, on_or_off): + self._panzoom.allow_zoom = on_or_off + + def set_size(self, size, direction): + scale_to_set = self._scales[direction] + cen = {} + cen['x'], cen['y'] = self.center + scale_to_set.min = cen[direction] - size/2 + scale_to_set.max = cen[direction] + size/2 + + reset_scale = 'x' if direction == 'y' else 'y' + + self._set_scale_aspect_ratio_to_match_viewer(reset_scale) + + def set_zoom_level(self, zoom_level): + """ + Set zoom level of viewer. A zoom level of 1 means 1 pixel + in the image is 1 pixel in the viewer, i.e. the scale width + in the horizontal direction matches the width in pixels + of the figure. + """ + + # The width is reset here but the height could be set instead + # and the result would be the same. + figure_width = float(self._figure.layout.width[:-2]) + new_width = figure_width / zoom_level + self.set_size(new_width, 'x') + self._set_scale_aspect_ratio_to_match_viewer('y') + + def plot_named_markers(self, x, y, mark_id, color='yellow', + size=100, style='circle'): + scale_dict = dict(x=self._scales['x'], y=self._scales['y']) + sc = ScatterGL(scales=scale_dict, + x=x, y=y, + colors=[color], + default_size=100, + marker=style, + fill=False) + + self._scatter_marks[mark_id] = sc + self._update_marks() + + def remove_named_markers(self, mark_id): + try: + del self._scatter_marks[mark_id] + except KeyError: + raise ValueError('Markers {mark_id} are not present.') + + self._update_marks() + + def remove_markers(self): + self._scatter_marks = {} + self._update_marks() + + def _update_marks(self): + marks = [self._image] + [mark for mark in self._scatter_marks.values()] + self._figure.marks = marks + + +def bqcolors(colormap, reverse=False): + # bqplot-image-gl has 256 levels + LEVELS = 256 + + # Make a matplotlib colormap object + mpl = cmp.get_cmap(colormap, LEVELS) + + # Get RGBA colors + mpl_colors = mpl(np.linspace(0, 1, LEVELS)) + + # Convert RGBA to hex + bq_colors = [to_hex(mpl_colors[i, :]) for i in range(LEVELS)] + + if reverse: + bq_colors = bq_colors[::-1] + + return bq_colors + + +class MarkerTableManager: + """ + Table for keeping track of positions and names of sets of + logically-related markers. + """ + def __init__(self): + # These column names are for internal use. + self._xcol = 'x' + self._ycol = 'y' + self._names = 'name' + self._marktags = set() + # Let's have a default name for the tag too: + self.default_mark_tag_name = 'default-marker-name' + self._interactive_marker_set_name_default = 'interactive-markers' + self._interactive_marker_set_name = self._interactive_marker_set_name_default + self._init_table() + + def _init_table(self): + self._table = Table(names=(self._xcol, self._ycol, self._names), + dtype=('int32', 'int32', 'str')) + + @property + def xcol(self): + return self._xcol + + @property + def ycol(self): + return self._ycol + + @property + def names(self): + return self._names + + @property + def marker_names(self): + return sorted(set(self._table[self.names])) + + def add_markers(self, x_mark, y_mark, + marker_name=None): + + if marker_name is None: + marker_name = self.default_mark_tag_name + + self._marktags.add(marker_name) + for x, y in zip(x_mark, y_mark): + self._table.add_row([x, y, marker_name]) + + def get_markers_by_name(self, marker_name): + matches = self._table[self._names] == marker_name + return self._table[matches] + + def get_all_markers(self): + return self._table.copy() + + def remove_markers_by_name(self, marker_name): + matches = self._table[self._names] == marker_name + # Only keep the things that don't match + self._table = self._table[~matches] + + def remove_all_markers(self): + self._init_table() + + +""" +next(iter(imviz.app._viewer_store.values())).figure +""" +STRETCHES = dict( + linear=apviz.LinearStretch, + sqrt=apviz.SqrtStretch, + histeq=apviz.HistEqStretch, + log=apviz.LogStretch + # ... +) + + +class ImageWidget(ipw.VBox): + click_center = trait.Bool(default_value=False).tag(sync=True) + click_drag = trait.Bool(default_value=False).tag(sync=True) + scroll_pan = trait.Bool(default_value=False).tag(sync=True) + image_width = trait.Int(help="Width of the image (not viewer)").tag(sync=True) + image_height = trait.Int(help="Height of the image (not viewer)").tag(sync=True) + zoom_level = trait.Float(help="Current zoom of the view").tag(sync=True) + marker = trait.Any(help="Markers").tag(sync=True) + cuts = trait.Any(help="Cut levels", allow_none=True).tag(sync=True) + stretch = trait.Unicode(help='Stretch algorithm name', allow_none=True).tag(sync=True) + + def __init__(self, *args, image_width=500, image_height=500): + super().__init__(*args) + self.image_width = image_width + self.image_height = image_height + viewer_aspect = self.image_width / self.image_height + self._astro_im = _AstroImage(display_width=self.image_width, + viewer_aspect_ratio=viewer_aspect) + self._interval = None + self._stretch = None + self._colormap = 'Grays' + self._marker_table = MarkerTableManager() + self._data = None + self._wcs = None + self._is_marking = False + self.marker = {'color': 'red', 'radius': 20, 'type': 'square'} + + def _interval_and_stretch(self): + """ + Stretch and normalize the data before sending to the viewer. + """ + interval = self._get_interval() + intervaled = interval(self._data) + + stretch = self._get_stretch() + if stretch: + stretched = stretch(intervaled) + else: + stretched = intervaled + + return stretched + + def _send_data(self): + self._astro_im.set_data(self._interval_and_stretch(), + reset_view=False) + + def _get_interval(self): + if self._interval is None: + return apviz.MinMaxInterval() + else: + return self._interval + + def _get_stretch(self): + return self._stretch + + @trait.validate('stretch') + def _validate_stretch(self, proposal): + proposed_stretch = proposal['value'] + if (proposed_stretch not in STRETCHES.keys() and + proposed_stretch is not None): + + raise ValueError(f'{proposed_stretch} is not a valid value. ' + 'The stretch must be None or ' + 'one of these values: ' + f'{sorted(STRETCHES.keys())}') + + return proposed_stretch + + @trait.observe('stretch') + def _observe_stretch(self, change): + self._stretch = STRETCHES[change['new']] if change['new'] else None + + @trait.validate('cuts') + def _validate_cuts(self, proposal): + # Allow these: + # - a two-item thing (tuple, list, whatever) + # - an Astropy interval + # - None + proposed_cuts = proposal['value'] + + bad_value_error = (f"{proposed_cuts} is not a valid value. " + "cuts must be either None, " + "an astropy interval, or list/tuple " + "of length 2.") + + if ((proposed_cuts is None) or + isinstance(proposed_cuts, apviz.BaseInterval)): + return proposed_cuts + else: + try: + length = len(proposed_cuts) + assert length == 2 + # Tests expect this to be a tuple... + proposed_cuts = tuple(proposed_cuts) + except (TypeError, AssertionError): + raise ValueError(bad_value_error) + + return proposed_cuts + + @trait.observe('cuts') + def _observe_cuts(self, change): + # This needs to handle only the case when the cuts is a + # tuple/list of length 2. That is interpreted as a ManualInterval. + cuts = change['new'] + if cuts is not None: + if not isinstance(cuts, apviz.BaseInterval): + self._interval = apviz.ManualInterval(*cuts) + else: + self._interval = cuts + + @trait.observe('zoom_level') + def _update_zoom_level(self, change): + zl = change['new'] + + self._astro_im.set_zoom_level(zl) + + @trait.validate('click_drag') + def _validate_click_drag(self, proposal): + cd = proposal['value'] + if cd and self._is_marking: + raise ValueError('Cannot set click_drag while doing interactive ' + 'marking. Call the stop_marking() method to ' + 'stop marking and then set click_drag.') + return cd + + @trait.observe('click_drag') + def _update_viewer_pan(self, change): + # Turn of click-to-center + if change['new']: + self.click_center = False + + self._astro_im.set_pan(change['new']) + + @trait.observe('scroll_pan') + def _update_viewer_zoom_scroll(self, change): + raise NotImplementedError('😭 sorry, cannot do that yet') + self._astro_im.set_scroll_zoom(change['new']) + + + # The methods, grouped loosely by purpose + + # Methods for loading data + def load_fits(self, file_name_or_HDU): + if isinstance(file_name_or_HDU, str): + ccd = CCDData.read(file) + elif isinstance(file_name_or_HDU, + (fits.ImageHDU, fits.CompImageHDU, fits.PrimaryHDU)): + try: + ccd_unit = u.Unit(file_name_or_HDU.header['bunit']) + except (KeyError, ValueError): + ccd_unit = u.dimensionless_unscaled + ccd = CCDData(file_name_or_HDU.data, + header=file_name_or_HDU.header, + unit=ccd_unit) + else: + raise ValueError(f'{file_name_or_HDU} is an invalid value. It must' + ' be a string or an astropy.io.fits HDU.') + + self._ccd = ccd + self._data = ccd.data + self._wcs = ccd.wcs + self._send_data() + + def load_array(self, array): + self._data = array + self._send_data() + + def load_nddata(self, data): + self._ccd = data + self._data = self._ccd.data + self._send_data() + + # Saving contents of the view and accessing the view + def save(self, filename): + if filename.endswith('.png'): + self._astro_im.save_png(filename) + elif filename.endswith('.svg'): + self._astro_im.save_svg(filename) + else: + raise NotImplementedError('Saving is not implemented for that' + 'file type. Use .png or .svg') + + def set_colormap(self, cmap_name, reverse=False): + self._astro_im.set_color(bqcolors(cmap_name, reverse=reverse)) + self._colormap = cmap_name + + @property + def colormap_options(self): + return pyplot.colormaps() + + # # Marker-related methods + # @abstractmethod + # def start_marking(self): + # raise NotImplementedError + + # @abstractmethod + # def stop_marking(self): + # raise NotImplementedError + + def add_markers(self, table, x_colname='x', y_colname='y', + skycoord_colname='coord', use_skycoord=False, + marker_name=None): + + if use_skycoord: + if self._wcs is None: + raise ValueError('The WCS for the image must be set to use ' + 'world coordinates for markers.') + + x, y = self._wcs.world_to_pixel(table[skycoord_colname]) + else: + x = table[x_colname] + y = table[y_colname] + + # Update the table of marker names and positions + self._marker_table.add_markers(x, y, marker_name=marker_name) + + # Update the figure itself, which expects all markers of + # the same name to be plotted at once. + marks = self.get_markers_by_name(marker_name) + + self._astro_im.plot_named_markers(marks['x'], marks['y'], + marker_name, + color=self.marker['color'], + size=self.marker['radius']**2, + style=self.marker['type']) + + def remove_markers_by_name(self, marker_name): + # Remove from our tracking table + self._marker_table.remove_markers_by_name(marker_name) + + # Remove from the visible canvas + self._astro_im.remove_named_markers(marker_name) + + def remove_all_markers(self): + self._marker_table.remove_all_markers() + self._astro_im.remove_markers() + + def _prepare_return_marker_table(self, marks, x_colname='x', y_colname='y', + skycoord_colname='coord'): + if len(marks) == 0: + return None + + if (self._data is None) or (self._wcs is None): + # Do not include SkyCoord column + include_skycoord = False + else: + include_skycoord = True + radec_col = [] + + if include_skycoord: + coords = self._wcs.pixel_to_world(marks[self._marker_table.xcol], + marks[self._marker_table.ycol]) + marks[skycoord_colname] = coords + + # This might be a null op but should be harmless in that case + marks.rename_column(self._marker_table.xcol, x_colname) + marks.rename_column(self._marker_table.ycol, y_colname) + + return marks + + def get_markers_by_name(self, marker_name=None, x_colname='x', y_colname='y', + skycoord_colname='coord'): + + # We should always allow the default name. The case + # where that table is empty will be handled in a moment. + if (marker_name not in self._marker_table.marker_names + and marker_name != self.marker_table.default_mark_tag_name): + raise ValueError(f"No markers named '{marker_name}' found.") + + marks = self._marker_table.get_markers_by_name(marker_name=marker_name) + + if len(marks) == 0: + # No markers in this table. Issue a warning and continue. + # Test wants this outside of logger, so... + warnings.warn(f"Marker set named '{marker_name}' is empty", UserWarning) + return None + + marks = self._prepare_return_marker_table(marks, + x_colname=x_colname, + y_colname=y_colname, + skycoord_colname=skycoord_colname) + return marks + + def get_all_markers(self, x_colname='x', y_colname='y', + skycoord_colname='coord'): + marks = self._marker_table.get_all_markers() + marks = self._prepare_return_marker_table(marks, + x_colname=x_colname, + y_colname=y_colname, + skycoord_colname=skycoord_colname) + return marks + + # Methods that modify the view + def center_on(self, point): + if isinstance(point, SkyCoord): + if self._wcs is None: + raise ValueError('The image must have a WCS to be able ' + 'to center on a coordinate.') + pixel = self._wcs.world_to_pixel(point) + else: + pixel = point + + self._astro_im.center = pixel + + # @abstractmethod + # def offset_to(self): + # raise NotImplementedError + + def zoom(self, value): + self.zoom_level = self.zoom_level * value diff --git a/astrowidgets/tests/test_bqplot_api.py b/astrowidgets/tests/test_bqplot_api.py new file mode 100644 index 0000000..f4f8299 --- /dev/null +++ b/astrowidgets/tests/test_bqplot_api.py @@ -0,0 +1,303 @@ +import numpy as np + +import pytest + +from astropy.io import fits +from astropy.nddata import NDData +from astropy.table import Table +from astropy.visualization import BaseStretch, AsymmetricPercentileInterval + +from astrowidgets.bqplot import ImageWidget, ALLOWED_CURSOR_LOCATIONS +from astrowidgets.interface_definition import ImageViewerInterface + + +def test_consistent_interface(): + iw = ImageWidget() + assert isinstance(iw, ImageViewerInterface) + + +def test_load_fits(): + image = ImageWidget() + data = np.random.random([100, 100]) + hdu = fits.PrimaryHDU(data=data) + image.load_fits(hdu) + + +def test_load_nddata(): + image = ImageWidget() + data = np.random.random([100, 100]) + nddata = NDData(data) + image.load_nddata(nddata) + + +def test_load_array(): + image = ImageWidget() + data = np.random.random([100, 100]) + image.load_array(data) + + +def test_center_on(): + image = ImageWidget() + x = 10 + y = 10 + image.center_on((x, y)) + + +def test_offset_to(): + image = ImageWidget() + dx = 10 + dy = 10 + image.offset_to(dx, dy) + + +def test_zoom_level(): + image = ImageWidget() + image.zoom_level = 5 + assert image.zoom_level == 5 + + +def test_zoom(): + image = ImageWidget() + image.zoom_level = 3 + val = 2 + image.zoom(val) + assert image.zoom_level == 6 + + +@pytest.mark.xfail(reason='Not implemented yet') +def test_select_points(): + image = ImageWidget() + image.select_points() + + +def test_get_selection(): + image = ImageWidget() + marks = image.get_markers() + assert isinstance(marks, Table) or marks is None + + +def test_stop_marking(): + image = ImageWidget() + # This is not much of a test... + image.stop_marking(clear_markers=True) + assert image.get_markers() is None + assert image.is_marking is False + + +def test_is_marking(): + image = ImageWidget() + assert image.is_marking in [True, False] + with pytest.raises(AttributeError): + image.is_marking = True + + +def test_start_marking(): + image = ImageWidget() + + # Setting these to check that start_marking affects them. + image.click_center = True + assert image.click_center + image.scroll_pan = False + assert not image.scroll_pan + + marker_style = {'color': 'yellow', 'radius': 10, 'type': 'cross'} + image.start_marking(marker_name='something', + marker=marker_style) + assert image.is_marking + assert image.marker == marker_style + assert not image.click_center + assert not image.click_drag + + # scroll_pan better activate when marking otherwise there is + # no way to pan while interactively marking + assert image.scroll_pan + + # Make sure that when we stop_marking we get our old + # controls back. + image.stop_marking() + assert image.click_center + assert not image.scroll_pan + + # Make sure that click_drag is restored as expected + image.click_drag = True + image.start_marking() + assert not image.click_drag + image.stop_marking() + assert image.click_drag + + +def test_add_markers(): + image = ImageWidget() + table = Table(data=np.random.randint(0, 100, [5, 2]), + names=['x', 'y'], dtype=('int', 'int')) + image.add_markers(table, x_colname='x', y_colname='y', + skycoord_colname='coord', marker_name='test') + marks = image.get_markers_by_name('test') + np.testing.assert_allclose(table['x'], marks['x']) + + marks = image.get_all_markers() + np.testing.assert_allclose(table['x'], marks['x']) + + +def test_set_markers(): + image = ImageWidget() + image.marker = {'color': 'yellow', 'radius': 10, 'type': 'cross'} + assert 'cross' in str(image.marker) + assert 'yellow' in str(image.marker) + assert '10' in str(image.marker) + + +def test_reset_markers(): + image = ImageWidget() + # First test: this shouldn't raise any errors + # (it also doesn't *do* anything...) + image.remove_all_markers() + assert image.get_all_markers() is None + table = Table(data=np.random.randint(0, 100, [5, 2]), + names=['x', 'y'], dtype=('int', 'int')) + image.add_markers(table, x_colname='x', y_colname='y', + skycoord_colname='coord', marker_name='test') + image.add_markers(table, x_colname='x', y_colname='y', + skycoord_colname='coord', marker_name='test2') + image.reset_markers() + with pytest.raises(ValueError): + image.get_markers(marker_name='test') + with pytest.raises(ValueError): + image.get_markers(marker_name='test2') + + +def test_remove_markers_by_name(): + image = ImageWidget() + + table = Table(data=np.random.randint(0, 100, [5, 2]), + names=['x', 'y'], dtype=('int', 'int')) + image.add_markers(table, x_colname='x', y_colname='y', + skycoord_colname='coord', marker_name='test') + + with pytest.raises(ValueError) as e: + image.remove_markers_by_name('arf') + assert 'arf' in str(e.value) + + image.remove_markers_by_name('test') + assert image.get_all_markers() is None + +def test_stretch(): + image = ImageWidget() + with pytest.raises(ValueError) as e: + image.stretch = 'not a valid value' + assert 'must be one of' in str(e.value) + + image.stretch = 'log' + assert isinstance(image.stretch, (BaseStretch, str)) + + +def test_cuts(): + image = ImageWidget() + + # An invalid string should raise an error + with pytest.raises(ValueError) as e: + image.cuts = 'not a valid value' + assert 'must be one of' in str(e.value) + + # Setting cuts to something with incorrect length + # should raise an error. + with pytest.raises(ValueError) as e: + image.cuts = (1, 10, 100) + assert 'length 2' in str(e.value) + + # These ought to succeed + + # āš ļø clarify this + # image.cuts = 'histogram' + # assert image.cuts == (0.0, 0.0) + + image.cuts = [10, 100] + assert image.cuts == (10, 100) + + # This should work without error + image.cuts = AsymmetricPercentileInterval(1, 99.5) + + +def test_colormap(): + image = ImageWidget() + cmap_desired = 'viridis' + cmap_list = image.colormap_options + assert len(cmap_list) > 0 and cmap_desired in cmap_list + + image.set_colormap(cmap_desired) + + +def test_cursor(): + image = ImageWidget() + assert image.cursor in ALLOWED_CURSOR_LOCATIONS + with pytest.raises(ValueError): + image.cursor = 'not a valid option' + image.cursor = 'bottom' + assert image.cursor == 'bottom' + + +def test_click_drag(): + image = ImageWidget() + # Set this to ensure that click_drag turns it off + image.click_center = True + + # Make sure that setting click_drag to False does not turn off + # click_center. + + image.click_drag = False + assert image.click_center + + image.click_drag = True + + assert not image.click_center + + # If is_marking is true then trying to click_drag + # should fail. + image._is_marking = True + with pytest.raises(ValueError) as e: + image.click_drag = True + assert 'interactive marking' in str(e.value).lower() + + +def test_click_center(): + image = ImageWidget() + assert (image.click_center is True) or (image.click_center is False) + + # Set click_drag True and check that click_center affects it appropriately + image.click_drag = True + + image.click_center = False + assert image.click_drag + + image.click_center = True + assert not image.click_drag + + image.start_marking() + # If marking is in progress then setting click center should fail + with pytest.raises(ValueError) as e: + image.click_center = True + assert 'Cannot set' in str(e.value) + + # setting to False is fine though so no error is expected here + image.click_center = False + + +def test_scroll_pan(): + image = ImageWidget() + + # Make sure scroll_pan is actually settable + for val in [True, False]: + image.scroll_pan = val + assert image.scroll_pan is val + + +def test_save(): + image = ImageWidget() + filename = 'woot.png' + image.save(filename) + + +def test_width_height(): + image = ImageWidget(image_width=250, image_height=100) + assert image.image_width == 250 + assert image.image_height == 100 From ccd5c39ff3d37212bb3c43f12231a91a808fc0a8 Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Mon, 26 Jul 2021 17:08:25 -0500 Subject: [PATCH 04/50] Update astrowidgets/bqplot.py Co-authored-by: P. L. Lim <2090236+pllim@users.noreply.github.com> --- astrowidgets/bqplot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/astrowidgets/bqplot.py b/astrowidgets/bqplot.py index 50f2317..df14e86 100644 --- a/astrowidgets/bqplot.py +++ b/astrowidgets/bqplot.py @@ -487,7 +487,7 @@ def _update_viewer_zoom_scroll(self, change): # Methods for loading data def load_fits(self, file_name_or_HDU): if isinstance(file_name_or_HDU, str): - ccd = CCDData.read(file) + ccd = CCDData.read(file_name_or_HDU) elif isinstance(file_name_or_HDU, (fits.ImageHDU, fits.CompImageHDU, fits.PrimaryHDU)): try: From f9649c077cae9c73373ef63cc9a0ee2d337b3e14 Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Wed, 28 Jul 2021 17:42:12 -0500 Subject: [PATCH 05/50] Set default cuts and auto-scale image --- astrowidgets/bqplot.py | 28 +++++++++++++++++----------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/astrowidgets/bqplot.py b/astrowidgets/bqplot.py index df14e86..759539d 100644 --- a/astrowidgets/bqplot.py +++ b/astrowidgets/bqplot.py @@ -59,7 +59,7 @@ def __init__(self, image_data=None, axis_x = Axis(scale=scale_x, visible=False) axis_y = Axis(scale=scale_y, orientation='vertical', visible=False) scales_image = {'x': scale_x, 'y': scale_y, - 'image': ColorScale(max=1.114, min=2902, + 'image': ColorScale(max=1, min=0, scheme='Greys')} self._scatter_marks = {} @@ -351,7 +351,8 @@ class ImageWidget(ipw.VBox): image_height = trait.Int(help="Height of the image (not viewer)").tag(sync=True) zoom_level = trait.Float(help="Current zoom of the view").tag(sync=True) marker = trait.Any(help="Markers").tag(sync=True) - cuts = trait.Any(help="Cut levels", allow_none=True).tag(sync=True) + cuts = trait.Any(help="Cut levels", allow_none=True).tag(sync=False) + stretch = trait.Unicode(help='Stretch algorithm name', allow_none=True).tag(sync=True) def __init__(self, *args, image_width=500, image_height=500): @@ -385,9 +386,9 @@ def _interval_and_stretch(self): return stretched - def _send_data(self): + def _send_data(self, reset_view=True): self._astro_im.set_data(self._interval_and_stretch(), - reset_view=False) + reset_view=reset_view) def _get_interval(self): if self._interval is None: @@ -452,6 +453,8 @@ def _observe_cuts(self, change): self._interval = apviz.ManualInterval(*cuts) else: self._interval = cuts + if self._data is not None: + self._send_data() @trait.observe('zoom_level') def _update_zoom_level(self, change): @@ -481,11 +484,10 @@ def _update_viewer_zoom_scroll(self, change): raise NotImplementedError('😭 sorry, cannot do that yet') self._astro_im.set_scroll_zoom(change['new']) - # The methods, grouped loosely by purpose # Methods for loading data - def load_fits(self, file_name_or_HDU): + def load_fits(self, file_name_or_HDU, reset_view=True): if isinstance(file_name_or_HDU, str): ccd = CCDData.read(file_name_or_HDU) elif isinstance(file_name_or_HDU, @@ -504,16 +506,20 @@ def load_fits(self, file_name_or_HDU): self._ccd = ccd self._data = ccd.data self._wcs = ccd.wcs - self._send_data() + self._send_data(reset_view=reset_view) - def load_array(self, array): + def load_array(self, array, reset_view=True): self._data = array - self._send_data() + self._send_data(reset_view=reset_view) - def load_nddata(self, data): + def load_nddata(self, data, reset_view=True): self._ccd = data self._data = self._ccd.data - self._send_data() + self._wcs = data.wcs + if self._wcs is None: + self._wcs = WCS(self._ccd.meta) + + self._send_data(reset_view=reset_view) # Saving contents of the view and accessing the view def save(self, filename): From 6e73adeec867242e28ad5053dd69d6a85b83d283 Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Wed, 28 Jul 2021 17:42:53 -0500 Subject: [PATCH 06/50] Add basic cursor display --- astrowidgets/bqplot.py | 59 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 58 insertions(+), 1 deletion(-) diff --git a/astrowidgets/bqplot.py b/astrowidgets/bqplot.py index 759539d..9a0fa9a 100644 --- a/astrowidgets/bqplot.py +++ b/astrowidgets/bqplot.py @@ -6,6 +6,7 @@ from astropy.table import Table, vstack from astropy import units as u import astropy.visualization as apviz +from astropy.wcs import WCS from bqplot import Figure, LinearScale, Axis, ColorScale, PanZoom, ScatterGL from bqplot_image_gl import ImageGL @@ -177,6 +178,10 @@ def center(self, value): self._scales['y'].max = y_c + width_y / 2 self._scales['y'].min = y_c - width_y / 2 + @property + def interaction(self): + return self._figure.interaction + def set_color(self, colors): # colors here means a list of hex colors self._image.scales['image'].colors = colors @@ -364,12 +369,64 @@ def __init__(self, *args, image_width=500, image_height=500): viewer_aspect_ratio=viewer_aspect) self._interval = None self._stretch = None - self._colormap = 'Grays' + self.set_colormap('Greys_r') self._marker_table = MarkerTableManager() self._data = None self._wcs = None self._is_marking = False + self.marker = {'color': 'red', 'radius': 20, 'type': 'square'} + self.cuts = apviz.AsymmetricPercentileInterval(1, 99) + + self._cursor = ipw.HTML('Coordinates show up here') + self._init_mouse_callbacks() + self.children = [self._astro_im, self._cursor] + + def _init_mouse_callbacks(self): + + def on_mouse_message(interaction, event_data, buffers): + """ + This function simply detects the event type then dispatches + to the method that handles that event. + + The ``event_data`` contains all of the information we need. + """ + if event_data['event'] == 'mousemove': + self._mouse_move(event_data) + + self._astro_im.interaction.on_msg(on_mouse_message) + + def _mouse_move(self, event_data): + if self._data is None: + # Nothing to display, so exit + return + + xc = event_data['domain']['x'] + yc = event_data['domain']['y'] + + # get the array indices into the data so that we can get data values + x_index = int(np.trunc(xc + 0.5)) + y_index = int(np.trunc(yc + 0.5)) + + # Check that the index is in the array. + in_image = (self._data.shape[1] > x_index >= 0) and (self._data.shape[0] > y_index >= 0) + if in_image: + val = self._data[y_index, x_index] + else: + val = None + + if val is not None: + value = f'value: {val:8.3f}' + else: + value = 'value: N/A' + + pixel_location = f'X: {xc:.2f} Y: {yc:.2f}' + if self._wcs is not None: + sky = self._wcs.pixel_to_world(xc, yc) + ra_dec = f'RA: {sky.icrs.ra:3.7f} Dec: {sky.icrs.dec:3.7f}' + else: + ra_dec = '' + self._cursor.value = ', '.join([pixel_location, ra_dec, value]) def _interval_and_stretch(self): """ From 9ba1839c8dffc3bf7bacd275b5eca77ec1588630 Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Wed, 28 Jul 2021 17:48:42 -0500 Subject: [PATCH 07/50] Do not reset view when cuts change --- astrowidgets/bqplot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/astrowidgets/bqplot.py b/astrowidgets/bqplot.py index 9a0fa9a..bf08c25 100644 --- a/astrowidgets/bqplot.py +++ b/astrowidgets/bqplot.py @@ -511,7 +511,7 @@ def _observe_cuts(self, change): else: self._interval = cuts if self._data is not None: - self._send_data() + self._send_data(reset_view=False) @trait.observe('zoom_level') def _update_zoom_level(self, change): From 9e6e037a25ce55460012241b95ebddc30dca4fcd Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Wed, 28 Jul 2021 18:03:31 -0500 Subject: [PATCH 08/50] Make pixel index correspond to center of pixel in viewer --- astrowidgets/bqplot.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/astrowidgets/bqplot.py b/astrowidgets/bqplot.py index bf08c25..f3c3bf4 100644 --- a/astrowidgets/bqplot.py +++ b/astrowidgets/bqplot.py @@ -149,9 +149,11 @@ def set_data(self, image_data, reset_view=True): # Set the image data and map it to the bqplot figure so that # cursor location corresponds to the underlying array index. + # The offset follows the convention that the index corresponds + # to the center of the pixel. self._image.image = image_data - self._image.x = [0, self._image_shape[1]] - self._image.y = [0, self._image_shape[0]] + self._image.x = [-0.5, self._image_shape[1] - 0.5] + self._image.y = [-0.5, self._image_shape[0] - 0.5] @property def center(self): From 0fbb6171b5ab46891d796b18fb0b96bb86b5b62a Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Fri, 27 Aug 2021 08:40:51 -0500 Subject: [PATCH 09/50] PL ginga changes WIP DELETE my ginga Fix rounding error in calculating index --- astrowidgets/bqplot.py | 4 +- astrowidgets/ginga.py | 680 +++++++++-------------------------------- 2 files changed, 141 insertions(+), 543 deletions(-) diff --git a/astrowidgets/bqplot.py b/astrowidgets/bqplot.py index f3c3bf4..0eadda5 100644 --- a/astrowidgets/bqplot.py +++ b/astrowidgets/bqplot.py @@ -407,8 +407,8 @@ def _mouse_move(self, event_data): yc = event_data['domain']['y'] # get the array indices into the data so that we can get data values - x_index = int(np.trunc(xc + 0.5)) - y_index = int(np.trunc(yc + 0.5)) + x_index = int(np.floor(xc + 0.5)) + y_index = int(np.floor(yc + 0.5)) # Check that the index is in the array. in_image = (self._data.shape[1] > x_index >= 0) and (self._data.shape[0] > y_index >= 0) diff --git a/astrowidgets/ginga.py b/astrowidgets/ginga.py index 65f293c..c28c292 100644 --- a/astrowidgets/ginga.py +++ b/astrowidgets/ginga.py @@ -1,44 +1,39 @@ -"""Module containing core functionality of ``astrowidgets``.""" +"""The ``astrowidgets.ginga`` module contains a widget implemented with the +Ginga backend. -# STDLIB +For this to work, ``astrowidgets`` must be installed along with the optional +dependencies specified for the Ginga backend; e.g.,:: + + pip install 'astrowidgets[ginga]' + +""" import functools +import os import warnings -# THIRD-PARTY import numpy as np -from astropy import units as u from astropy.coordinates import SkyCoord from astropy.io import fits -from astropy.table import Table, vstack -from astropy.utils.decorators import deprecated - -# Jupyter widgets -import ipywidgets as ipyw +from astropy.table import Table -# Ginga from ginga.AstroImage import AstroImage from ginga.canvas.CanvasObject import drawCatalog from ginga.web.jupyterw.ImageViewJpw import EnhancedCanvasView from ginga.util.wcs import ra_deg_to_str, dec_deg_to_str -__all__ = ['ImageWidget'] +from astrowidgets.core import BaseImageWidget -# Allowed locations for cursor display -ALLOWED_CURSOR_LOCATIONS = ['top', 'bottom', None] +__all__ = ['ImageWidget'] -# List of marker names that are for internal use only -RESERVED_MARKER_SET_NAMES = ['all'] - -class ImageWidget(ipyw.VBox): - """ - Image widget for Jupyter notebook using Ginga viewer. +class ImageWidget(BaseImageWidget): + """Image widget for Jupyter notebook using Ginga viewer. .. todo:: Any property passed to constructor has to be valid keyword. Parameters ---------- - logger : obj or ``None`` + logger : obj Ginga logger. For example:: from ginga.misc.log import get_logger @@ -47,17 +42,10 @@ class ImageWidget(ipyw.VBox): image_width, image_height : int Dimension of Jupyter notebook's image widget. - - pixel_coords_offset : int, optional - An offset, typically either 0 or 1, to add/subtract to all - pixel values when going to/from the displayed image. - *In almost all situations the default value, ``0``, is the - correct value to use.* - """ def __init__(self, logger=None, image_width=500, image_height=500, - pixel_coords_offset=0, **kwargs): + **kwargs): super().__init__() if 'use_opencv' in kwargs: @@ -69,52 +57,26 @@ def __init__(self, logger=None, image_width=500, image_height=500, self._pixel_offset = pixel_coords_offset - self._jup_img = ipyw.Image(format='jpeg') - - # Set the image margin to over the widgets default of 2px on - # all sides. - self._jup_img.layout.margin = '0' - - # Set both of those to ensure consistent display in notebook - # and jupyterlab when the image is put into a container smaller - # than the image. - - self._jup_img.max_width = '100%' - self._jup_img.height = 'auto' - - # Set the width of the box containing the image to the desired width - self.layout.width = str(image_width) - - # Note we are NOT setting the height. That is because the height - # is automatically set by the image aspect ratio. - - # These need to also be set for now; ginga uses them to figure - # out what size image to make. + # These need to also be set for now. + # Ginga uses them to figure out what size image to make. self._jup_img.width = image_width self._jup_img.height = image_height + self._viewer = EnhancedCanvasView(logger=logger) self._viewer.set_widget(self._jup_img) - # enable all possible keyboard and pointer operations + # Enable all possible keyboard and pointer operations self._viewer.get_bindings().enable_all(True) - # enable draw + # Enable draw self.dc = drawCatalog self.canvas = self.dc.DrawingCanvas() self.canvas.enable_draw(True) self.canvas.enable_edit(True) - # Make sure all of the internal state trackers have a value - # and start in a state which is definitely allowed: all are - # False. - self._is_marking = False - self._click_center = False + # Set a couple of things to match the Ginga defaults + self._scroll_pan = True self._click_drag = False - self._scroll_pan = False - - # Set a couple of things to match the ginga defaults - self.scroll_pan = True - self.click_drag = False bind_map = self._viewer.get_bindmap() # Set up right-click and drag adjusts the contrast @@ -124,34 +86,24 @@ def __init__(self, logger=None, image_width=500, image_height=500, # Marker self.marker = {'type': 'circle', 'color': 'cyan', 'radius': 20} - # Maintain marker tags as a set because we do not want - # duplicate names. - self._marktags = set() - # Let's have a default name for the tag too: - self._default_mark_tag_name = 'default-marker-name' - self._interactive_marker_set_name_default = 'interactive-markers' - self._interactive_marker_set_name = self._interactive_marker_set_name_default - - # coordinates display - self._jup_coord = ipyw.HTML('Coordinates show up here') + # This needs ipyevents 0.3.1 to work self._viewer.add_callback('cursor-changed', self._mouse_move_cb) self._viewer.add_callback('cursor-down', self._mouse_click_cb) - # Define a callback that shows the output of a print - self.print_out = ipyw.Output() - - self._cursor = 'bottom' - self.children = [self._jup_img, self._jup_coord] + @property + def viewer(self): + return self._viewer @property def logger(self): """Logger for this widget.""" return self._viewer.logger + # Need this here because we need to overwrite the setter. @property def image_width(self): - return int(self._jup_img.width) + return super().image_width @image_width.setter def image_width(self, value): @@ -160,9 +112,10 @@ def image_width(self, value): self._jup_img.width = str(value) self._viewer.set_window_size(self.image_width, self.image_height) + # Need this here because we need to overwrite the setter. @property def image_height(self): - return int(self._jup_img.height) + return super().image_height @image_height.setter def image_height(self, value): @@ -171,22 +124,8 @@ def image_height(self, value): self._jup_img.height = str(value) self._viewer.set_window_size(self.image_width, self.image_height) - @property - def pixel_offset(self): - """ - An offset, typically either 0 or 1, to add/subtract to all - pixel values when going to/from the displayed image. - *In almost all situations the default value, ``0``, is the - correct value to use.* - - This value cannot be modified after initialization. - """ - return self._pixel_offset - def _mouse_move_cb(self, viewer, button, data_x, data_y): - """ - Callback to display position in RA/DEC deg. - """ + """Callback to display position in RA/DEC deg.""" if self.cursor is None: # no-op return @@ -196,27 +135,26 @@ def _mouse_move_cb(self, viewer, button, data_x, data_y): iy = int(data_y + 0.5) try: imval = viewer.get_data(ix, iy) - imval = '{:8.3f}'.format(imval) + imval = f'{imval:8.3f}' except Exception: imval = 'N/A' - val = 'X: {:.2f}, Y: {:.2f}'.format(data_x + self._pixel_offset, - data_y + self._pixel_offset) + val = (f'X: {data_x + self._pixel_offset:.2f}, ' + f'Y: {data_y + self._pixel_offset:.2f}') + if image.wcs.wcs is not None: try: ra, dec = image.pixtoradec(data_x, data_y) - val += ' (RA: {}, DEC: {})'.format( - ra_deg_to_str(ra), dec_deg_to_str(dec)) + val += (f' (RA: {ra_deg_to_str(ra)},' + f' DEC: {dec_deg_to_str(dec)})') except Exception: val += ' (RA, DEC: WCS error)' - val += ', value: {}'.format(imval) + val += f', value: {imval}' self._jup_coord.value = val def _mouse_click_cb(self, viewer, event, data_x, data_y): - """ - Callback to handle mouse clicks. - """ + """Callback to handle mouse clicks.""" if self.is_marking: marker_name = self._interactive_marker_set_name objs = [] @@ -232,59 +170,59 @@ def _mouse_click_cb(self, viewer, event, data_x, data_y): # is simplified. obj = self._marker(x=data_x, y=data_y, coord='data') objs.append(obj) - viewer.canvas.add(self.dc.CompoundObject(*objs), - tag=marker_name) + viewer.canvas.add(self.dc.CompoundObject(*objs), tag=marker_name) self._marktags.add(marker_name) + + # For debugging. with self.print_out: - print('Selected {} {}'.format(obj.x, obj.y)) + print(f'Selected {obj.x} {obj.y}') elif self.click_center: self.center_on((data_x, data_y)) + # For debugging. with self.print_out: - print('Centered on X={} Y={}'.format(data_x + self._pixel_offset, - data_y + self._pixel_offset)) + print(f'Centered on X={data_x + self._pixel_offset} ' + f'Y={data_y + self._pixel_offset}') -# def _repr_html_(self): -# """ -# Show widget in Jupyter notebook. -# """ -# from IPython.display import display -# return display(self._widget) - - def load_fits(self, fitsorfn, numhdu=None, memmap=None): - """ - Load a FITS file into the viewer. + def load_fits(self, filename, numhdu=None, memmap=None): + """Load a FITS file or HDU into the viewer. Parameters ---------- - fitsorfn : str or HDU - Either a file name or an HDU (*not* an HDUList). - If file name is given, WCS in primary header is automatically - inherited. If a single HDU is given, WCS must be in the HDU - header. + filename : str or HDU + Name of the FITS file or a HDU (*not* a ``HDUList``). + If a filename is given, any information in the primary header, + including WCS, is automatically inherited. If a HDU is given, + the WCS must be in the HDU header. - numhdu : int or ``None`` - Extension number of the desired HDU. - If ``None``, it is determined automatically. + numhdu : int or `None` + Extension number of the desired HDU. If not given, it is + determined automatically. This is only used if a filename is given. - memmap : bool or ``None`` - Memory mapping. - If ``None``, it is determined automatically. + memmap : bool or `None` + Memory mapping. See `astropy.io.fits.open`. + This is only used if a filename is given. + + Raises + ------ + ValueError + Given ``filename`` type is not supported. """ - if isinstance(fitsorfn, str): + if isinstance(filename, str): image = AstroImage(logger=self.logger, inherit_primary_header=True) - image.load_file(fitsorfn, numhdu=numhdu, memmap=memmap) + image.load_file(filename, numhdu=numhdu, memmap=memmap) self._viewer.set_image(image) - elif isinstance(fitsorfn, (fits.ImageHDU, fits.CompImageHDU, + elif isinstance(filename, (fits.ImageHDU, fits.CompImageHDU, fits.PrimaryHDU)): - self._viewer.load_hdu(fitsorfn) + self._viewer.load_hdu(filename) + else: + raise ValueError(f'Unable to open {filename}') def load_nddata(self, nddata): - """ - Load an ``NDData`` object into the viewer. + """Load a `~astropy.nddata.NDData` object into the viewer. .. todo:: Add flag/masking support, etc. @@ -305,12 +243,11 @@ def load_nddata(self, nddata): try: image.set_wcs(_wcs) except Exception as e: - print('Unable to set WCS from NDData: {}'.format(str(e))) + self.logger.warning(f'Unable to set WCS from NDData: {repr(e)}') self._viewer.set_image(image) def load_array(self, arr): - """ - Load a 2D array into the viewer. + """Load a 2D array into the viewer. .. note:: Use :meth:`load_nddata` for WCS support. @@ -323,39 +260,12 @@ def load_array(self, arr): self._viewer.load_data(arr) def center_on(self, point): - """ - Centers the view on a particular point. - - Parameters - ---------- - point : tuple or `~astropy.coordinates.SkyCoord` - If tuple of ``(X, Y)`` is given, it is assumed - to be in data coordinates. - """ if isinstance(point, SkyCoord): self._viewer.set_pan(point.ra.deg, point.dec.deg, coord='wcs') else: self._viewer.set_pan(*(np.asarray(point) - self._pixel_offset)) - @deprecated('0.3', alternative='offset_by') def offset_to(self, dx, dy, skycoord_offset=False): - """ - Move the center to a point that is given offset - away from the current center. - - .. note:: This is deprecated. Use :meth:`offset_by`. - - Parameters - ---------- - dx, dy : float - Offset value. Unit is assumed based on - ``skycoord_offset``. - - skycoord_offset : bool - If `True`, offset must be given in degrees. - Otherwise, they are in pixel values. - - """ if skycoord_offset: coord = 'wcs' else: @@ -364,117 +274,23 @@ def offset_to(self, dx, dy, skycoord_offset=False): pan_x, pan_y = self._viewer.get_pan(coord=coord) self._viewer.set_pan(pan_x + dx, pan_y + dy, coord=coord) - def offset_by(self, dx, dy): - """ - Move the center to a point that is given offset - away from the current center. - - Parameters - ---------- - dx, dy : float or `~astropy.unit.Quantity` - Offset value. Without a unit, assumed to be pixel offsets. - If a unit is attached, offset by pixel or sky is assumed from - the unit. - - """ - dx_val, dx_coord = _offset_is_pixel_or_sky(dx) - dy_val, dy_coord = _offset_is_pixel_or_sky(dy) - - if dx_coord != dy_coord: - raise ValueError(f'dx is of type {dx_coord} but dy is of type {dy_coord}') - - pan_x, pan_y = self._viewer.get_pan(coord=dx_coord) - self._viewer.set_pan(pan_x + dx_val, pan_y + dy_val, coord=dx_coord) - @property def zoom_level(self): - """ - Zoom level: - - * 1 means real-pixel-size. - * 2 means zoomed in by a factor of 2. - * 0.5 means zoomed out by a factor of 2. - - """ return self._viewer.get_scale() + zoom_level.__doc__ = (BaseImageWidget.zoom_level.__doc__ + + "``'fit'`` means zoom to fit") + @zoom_level.setter - def zoom_level(self, val): - if val == 'fit': + def zoom_level(self, value): + if value == 'fit': self._viewer.zoom_fit() else: - self._viewer.scale_to(val, val) - - def zoom(self, val): - """ - Zoom in or out by the given factor. - - Parameters - ---------- - val : int - The zoom level to zoom the image. - See `zoom_level`. - - """ - self.zoom_level = self.zoom_level * val - - @property - def is_marking(self): - """ - `True` if in marking mode, `False` otherwise. - Marking mode means a mouse click adds a new marker. - This does not affect :meth:`add_markers`. - """ - return self._is_marking - - def start_marking(self, marker_name=None, - marker=None): - """ - Start marking, with option to name this set of markers or - to specify the marker style. - """ - self._cached_state = dict(click_center=self.click_center, - click_drag=self.click_drag, - scroll_pan=self.scroll_pan) - self.click_center = False - self.click_drag = False - # Set scroll_pan to ensure there is a mouse way to pan - self.scroll_pan = True - self._is_marking = True - if marker_name is not None: - self._validate_marker_name(marker_name) - self._interactive_marker_set_name = marker_name - self._marktags.add(marker_name) - else: - self._interactive_marker_set_name = \ - self._interactive_marker_set_name_default - if marker is not None: - self.marker = marker - - def stop_marking(self, clear_markers=False): - """ - Stop marking mode, with option to clear markers, if desired. - - Parameters - ---------- - clear_markers : bool, optional - If ``clear_markers`` is `False`, existing markers are - retained until :meth:`reset_markers` is called. - Otherwise, they are erased. - """ - if self.is_marking: - self._is_marking = False - self.click_center = self._cached_state['click_center'] - self.click_drag = self._cached_state['click_drag'] - self.scroll_pan = self._cached_state['scroll_pan'] - self._cached_state = {} - if clear_markers: - self.reset_markers() + self._viewer.scale_to(value, value) @property def marker(self): - """ - Marker to use. + """A dictionary defining the current marker properties. .. todo:: Add more examples. @@ -490,12 +306,12 @@ def marker(self): # what we expect the user to provide. # # That makes things like self.marker = self.marker work. - return self._marker_dict + return super().marker @marker.setter - def marker(self, val): + def marker(self, value): # Make a new copy to avoid modifying the dict that the user passed in. - _marker = val.copy() + _marker = value.copy() marker_type = _marker.pop('type') if marker_type == 'circle': self._marker = functools.partial(self.dc.Circle, **_marker) @@ -508,69 +324,12 @@ def marker(self, val): _marker['style'] = 'cross' self._marker = functools.partial(self.dc.Point, **_marker) else: # TODO: Implement more shapes - raise NotImplementedError( - 'Marker type "{}" not supported'.format(marker_type)) + raise NotImplementedError(f'Marker type "{marker_type}" not supported') # Only set this once we have successfully created a marker - self._marker_dict = val + self._marker_dict = value - def get_markers(self, x_colname='x', y_colname='y', - skycoord_colname='coord', - marker_name=None): - """ - Return the locations of existing markers. - - Parameters - ---------- - x_colname, y_colname : str - Column names for X and Y data coordinates. - Coordinates returned are 0- or 1-indexed, depending - on ``self.pixel_offset``. - - skycoord_colname : str - Column name for ``SkyCoord``, which contains - sky coordinates associated with the active image. - This is ignored if image has no WCS. - - Returns - ------- - markers_table : `~astropy.table.Table` or ``None`` - Table of markers, if any, or ``None``. - - """ - if marker_name is None: - marker_name = self._default_mark_tag_name - - if marker_name == 'all': - # If it wasn't for the fact that SKyCoord columns can't - # be stacked this would all fit nicely into a list - # comprehension. But they can't, so we delete the - # SkyCoord column if it is present, then add it - # back after we have stacked. - coordinates = [] - tables = [] - for name in self._marktags: - table = self.get_markers(x_colname=x_colname, - y_colname=y_colname, - skycoord_colname=skycoord_colname, - marker_name=name) - if table is None: - # No markers by this name, skip it - continue - - try: - coordinates.extend(c for c in table[skycoord_colname]) - except KeyError: - pass - else: - del table[skycoord_colname] - tables.append(table) - - stacked = vstack(tables, join_type='exact') - - if coordinates: - stacked[skycoord_colname] = SkyCoord(coordinates) - - return stacked + def get_markers_by_name(self, marker_name, x_colname='x', y_colname='y', + skycoord_colname='coord'): # We should always allow the default name. The case # where that table is empty will be handled in a moment. @@ -581,9 +340,9 @@ def get_markers(self, x_colname='x', y_colname='y', try: c_mark = self._viewer.canvas.get_object_by_tag(marker_name) except Exception: - # No markers in this table. Issue a warning and continue - warnings.warn(f"Marker set named '{marker_name}' is empty", - category=UserWarning) + # No markers in this table. Issue a warning and continue. + # Test wants this outside of logger, so... + warnings.warn(f"Marker set named '{marker_name}' is empty", UserWarning) return None image = self._viewer.get_image() @@ -602,10 +361,9 @@ def get_markers(self, x_colname='x', y_colname='y', xy_col.append([obj.x, obj.y]) if include_skycoord: radec_col.append([np.nan, np.nan]) - elif not include_skycoord: # marker in WCS but image has none - self.logger.warning( - 'Skipping ({},{}); image has no WCS'.format(obj.x, obj.y)) - else: # wcs + elif not include_skycoord: # Marker in WCS but image has none + self.logger.warning(f'Skipping ({obj.x},{obj.y}); image has no WCS') + else: # WCS xy_col.append([np.nan, np.nan]) radec_col.append([obj.x, obj.y]) @@ -644,49 +402,12 @@ def get_markers(self, x_colname='x', y_colname='y', markers_table['marker name'] = marker_name return markers_table - def _validate_marker_name(self, marker_name): - """ - Raise an error if the marker_name is not allowed. - """ - if marker_name in RESERVED_MARKER_SET_NAMES: - raise ValueError('The marker name {} is not allowed. Any name is ' - 'allowed except these: ' - '{}'.format(marker_name, - ', '.join(RESERVED_MARKER_SET_NAMES))) - + # TODO: Resolve https://github.com/ejeschke/ginga/issues/672 + # TODO: Later enhancements to include more columns to control + # size/style/color of marks def add_markers(self, table, x_colname='x', y_colname='y', skycoord_colname='coord', use_skycoord=False, marker_name=None): - """ - Creates markers in the image at given points. - - .. todo:: - - Later enhancements to include more columns - to control size/style/color of marks, - - Parameters - ---------- - table : `~astropy.table.Table` - Table containing marker locations. - - x_colname, y_colname : str - Column names for X and Y. - Coordinates can be 0- or 1-indexed, as - given by ``self.pixel_offset``. - - skycoord_colname : str - Column name with ``SkyCoord`` objects. - - use_skycoord : bool - If `True`, use ``skycoord_colname`` to mark. - Otherwise, use ``x_colname`` and ``y_colname``. - - marker_name : str, optional - Name to assign the markers in the table. Providing a name - allows markers to be removed by name at a later time. - """ - # TODO: Resolve https://github.com/ejeschke/ginga/issues/672 # For now we always convert marker locations to pixels; see # comment below. @@ -695,8 +416,7 @@ def add_markers(self, table, x_colname='x', y_colname='y', if marker_name is None: marker_name = self._default_mark_tag_name - self._validate_marker_name(marker_name) - + self.validate_marker_name(marker_name) self._marktags.add(marker_name) # Extract coordinates from table. @@ -710,13 +430,12 @@ def add_markers(self, table, x_colname='x', y_colname='y', 'Image has no valid WCS, ' 'try again with use_skycoord=False') coord_val = table[skycoord_colname] - # TODO: Maybe switch back to letting ginga handle conversion + # TODO: Maybe switch back to letting Ginga handle conversion # to pixel coordinates. - # Convert to pixels here (instead of in ginga) because conversion - # in ginga is currently very slow. - coord_x, coord_y = image.wcs.wcs.all_world2pix(coord_val.ra.deg, - coord_val.dec.deg, - 0) + # Convert to pixels here (instead of in Ginga) because conversion + # in Ginga was reportedly very slow. + coord_x, coord_y = image.wcs.wcs.all_world2pix( + coord_val.ra.deg, coord_val.dec.deg, 0) # In the event a *single* marker has been added, coord_x and coord_y # will be scalars. Make them arrays always. if np.ndim(coord_x) == 0: @@ -727,17 +446,16 @@ def add_markers(self, table, x_colname='x', y_colname='y', coord_y = table[y_colname].data # Convert data coordinates from 1-indexed to 0-indexed if self._pixel_offset != 0: - # Don't use the in-place operator -= here...that modifies + # Don't use the in-place operator -= here that modifies # the input table. coord_x = coord_x - self._pixel_offset coord_y = coord_y - self._pixel_offset # Prepare canvas and retain existing marks - objs = [] try: c_mark = self._viewer.canvas.get_object_by_tag(marker_name) except Exception: - pass + objs = [] else: objs = c_mark.objects self._viewer.canvas.delete_object_by_tag(marker_name) @@ -745,20 +463,9 @@ def add_markers(self, table, x_colname='x', y_colname='y', # TODO: Test to see if we can mix WCS and data on the same canvas objs += [self._marker(x=x, y=y, coord=coord_type) for x, y in zip(coord_x, coord_y)] - self._viewer.canvas.add(self.dc.CompoundObject(*objs), - tag=marker_name) + self._viewer.canvas.add(self.dc.CompoundObject(*objs), tag=marker_name) - def remove_markers(self, marker_name=None): - """ - Remove some but not all of the markers by name used when - adding the markers - - Parameters - ---------- - - marker_name : str, optional - Name used when the markers were added. - """ + def remove_markers_by_name(self, marker_name): # TODO: # arr : ``SkyCoord`` or array-like # Sky coordinates or 2xN array. @@ -766,39 +473,21 @@ def remove_markers(self, marker_name=None): # NOTE: How to match? Use np.isclose? # What if there are 1-to-many matches? - if marker_name is None: - marker_name = self._default_mark_tag_name - + self.validate_marker_name(marker_name) if marker_name not in self._marktags: - # This shouldn't have happened, raise an error - raise ValueError('Marker name {} not found in current markers.' - ' Markers currently in use are ' - '{}'.format(marker_name, - sorted(self._marktags))) + raise ValueError( + f'Marker name {marker_name} not found in current markers. ' + f'Markers currently in use are {self.get_marker_names()}.') try: self._viewer.canvas.delete_object_by_tag(marker_name) except KeyError: - raise KeyError('Unable to remove markers named {} from image. ' - ''.format(marker_name)) + self.logger.error(f'Unable to remove markers named {marker_name} from image.') else: self._marktags.remove(marker_name) - def reset_markers(self): - """ - Delete all markers. - """ - - # Grab the entire list of marker names before iterating - # otherwise what we are iterating over changes. - for marker_name in list(self._marktags): - self.remove_markers(marker_name) - @property def stretch_options(self): - """ - List all available options for image stretching. - """ return self._viewer.get_color_algorithms() @property @@ -810,129 +499,51 @@ def stretch(self): # TODO: Possible to use astropy.visualization directly? @stretch.setter - def stretch(self, val): + def stretch(self, value): valid_vals = self.stretch_options - if val not in valid_vals: - raise ValueError('Value must be one of: {}'.format(valid_vals)) - self._viewer.set_color_algorithm(val) + if value not in valid_vals: + raise ValueError(f'Value must be one of: {valid_vals}') + self._viewer.set_color_algorithm(value) @property def autocut_options(self): - """ - List all available options for image auto-cut. - """ return self._viewer.get_autocut_methods() @property def cuts(self): - """ - Current image cut levels. - To set new cut levels, either provide a tuple of - ``(low, high)`` values or one of the options from - `autocut_options`. - """ return self._viewer.get_cut_levels() # TODO: Possible to use astropy.visualization directly? @cuts.setter - def cuts(self, val): - if isinstance(val, str): # Autocut + def cuts(self, value): + if isinstance(value, str): # Autocut valid_vals = self.autocut_options - if val not in valid_vals: - raise ValueError('Value must be one of: {}'.format(valid_vals)) - self._viewer.set_autocut_params(val) - else: # (low, high) - if len(val) > 2: - raise ValueError('Value must have length 2.') - self._viewer.cut_levels(val[0], val[1]) + if value not in valid_vals: + raise ValueError(f'Value must be one of: {valid_vals}') + self._viewer.set_autocut_params(value) + else: + if len(value) != 2: + raise ValueError('Cut levels must be given as (low, high)') + self._viewer.cut_levels(*value) @property def colormap_options(self): - """List of colormap names.""" from ginga import cmap return cmap.get_names() def set_colormap(self, cmap): - """ - Set colormap to the given colormap name. - - Parameters - ---------- - cmap : str - Colormap name. Possible values can be obtained from - :meth:`colormap_options`. - - """ self._viewer.set_color_map(cmap) - @property - def cursor(self): - """ - Show or hide cursor information (X, Y, WCS). - Acceptable values are 'top', 'bottom', or ``None``. - """ - return self._cursor - - @cursor.setter - def cursor(self, val): - if val is None: - self._jup_coord.layout.visibility = 'hidden' - self._jup_coord.layout.display = 'none' - elif val == 'top' or val == 'bottom': - self._jup_coord.layout.visibility = 'visible' - self._jup_coord.layout.display = 'flex' - if val == 'top': - self.layout.flex_flow = 'column-reverse' - else: - self.layout.flex_flow = 'column' - else: - raise ValueError('Invalid value {} for cursor.' - 'Valid values are: ' - '{}'.format(val, ALLOWED_CURSOR_LOCATIONS)) - self._cursor = val - - @property - def click_center(self): - """ - Settable. - If True, middle-clicking can be used to center. If False, that - interaction is disabled. - - In the future this might go from True/False to being a selectable - button. But not for the first round. - """ - return self._click_center - - @click_center.setter - def click_center(self, val): - if not isinstance(val, bool): - raise ValueError('Must be True or False') - elif self.is_marking and val: - raise ValueError('Cannot set to True while in marking mode') - - if val: - self.click_drag = False - - self._click_center = val - - # TODO: Awaiting https://github.com/ejeschke/ginga/issues/674 + # Need this here because we need to overwrite the setter. @property def click_drag(self): - """ - Settable. - If True, the "click-and-drag" mode is an available interaction for - panning. If False, it is not. - - Note that this should be automatically made `False` when selection mode - is activated. - """ - return self._click_drag + return super().click_drag @click_drag.setter def click_drag(self, value): if not isinstance(value, bool): raise ValueError('click_drag must be either True or False') - if self.is_marking: + if self.is_marking and value: raise ValueError('Interactive marking is in progress. Call ' 'stop_marking() to end marking before setting ' 'click_drag') @@ -945,14 +556,10 @@ def click_drag(self, value): else: bindmap.map_event(None, (), 'ms_left', 'cursor') + # Need this here because we need to overwrite the setter. @property def scroll_pan(self): - """ - Settable. - If True, scrolling moves around in the image. If False, scrolling - (up/down) *zooms* the image in and out. - """ - return self._scroll_pan + return super().scroll_pan @scroll_pan.setter def scroll_pan(self, value): @@ -966,26 +573,17 @@ def scroll_pan(self, value): else: bindmap.map_event(None, (), 'pa_pan', 'zoom') - def save(self, filename): - """ - Save out the current image view to given PNG filename. - """ + def save(self, filename, overwrite=False): + if os.path.exists(filename) and not overwrite: + raise ValueError(f'{filename} exists, use overwrite=True to force overwrite') + + ext = os.path.splitext(filename)[1].lower() + if ext != '.png': + raise ValueError(f'Extension {ext} not supported, use .png') + # It turns out the image value is already in PNG format so we just # to write that out to a file. with open(filename, 'wb') as f: f.write(self._jup_img.value) - -def _offset_is_pixel_or_sky(x): - if isinstance(x, u.Quantity): - if x.unit in (u.dimensionless_unscaled, u.pix): - coord = 'data' - val = x.value - else: - coord = 'wcs' - val = x.to_value(u.deg) - else: - coord = 'data' - val = x - - return val, coord + self.logger.info(f'{filename} written') From 16d348ebe2b2f78fec7bd236bd9c0f745bb3bf6f Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Fri, 27 Aug 2021 10:00:39 -0500 Subject: [PATCH 10/50] Move ginga implementations back to ginga class They had been in a separate base class --- astrowidgets/ginga.py | 344 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 330 insertions(+), 14 deletions(-) diff --git a/astrowidgets/ginga.py b/astrowidgets/ginga.py index c28c292..bb812a7 100644 --- a/astrowidgets/ginga.py +++ b/astrowidgets/ginga.py @@ -11,29 +11,28 @@ import os import warnings +import ipywidgets as ipyw import numpy as np from astropy.coordinates import SkyCoord from astropy.io import fits -from astropy.table import Table +from astropy.table import Table, vstack from ginga.AstroImage import AstroImage from ginga.canvas.CanvasObject import drawCatalog from ginga.web.jupyterw.ImageViewJpw import EnhancedCanvasView from ginga.util.wcs import ra_deg_to_str, dec_deg_to_str -from astrowidgets.core import BaseImageWidget - __all__ = ['ImageWidget'] -class ImageWidget(BaseImageWidget): +class ImageWidget(ipyw.VBox): """Image widget for Jupyter notebook using Ginga viewer. .. todo:: Any property passed to constructor has to be valid keyword. Parameters ---------- - logger : obj + logger : obj or None Ginga logger. For example:: from ginga.misc.log import get_logger @@ -47,16 +46,70 @@ class ImageWidget(BaseImageWidget): def __init__(self, logger=None, image_width=500, image_height=500, **kwargs): super().__init__() - if 'use_opencv' in kwargs: warnings.warn("use_opencv kwarg has been deprecated--" "opencv will be used if it is installed", DeprecationWarning) self._viewer = EnhancedCanvasView(logger=logger) + self.ALLOWED_CURSOR_LOCATIONS = ['top', 'bottom', None] + self.RESERVED_MARKER_SET_NAMES = ['all'] + + if image_widget is None: + self._jup_img = ipyw.Image(format='jpeg') + else: + self._jup_img = image_widget + + if cursor_widget is None: + self._jup_coord = ipyw.HTML('Coordinates show up here') + else: + self._jup_coord = cursor_widget + + if isinstance(self._jup_img, ipyw.Image): + # Set the image margin on all sides. + self._jup_img.layout.margin = '0' + + # Set both of those to ensure consistent display in notebook + # and jupyterlab when the image is put into a container smaller + # than the image. + self._jup_img.max_width = '100%' + self._jup_img.height = 'auto' self._pixel_offset = pixel_coords_offset + # Set the width of the box containing the image to the desired width + # Note: We are NOT setting the height. That is because the height + # is automatically set by the image aspect ratio. + self.layout.width = str(image_width) + + # Make sure all of the internal state trackers have a value + # and start in a state which is definitely allowed: all are + # False. + self._is_marking = False + self._click_center = False + self._click_drag = False + self._scroll_pan = False + self._cached_state = {} + + # Marker + self._marker_dict = {} + self._marker = None + # Maintain marker tags as a set because we do not want + # duplicate names. + self._marktags = set() + # Let's have a default name for the tag too: + self._default_mark_tag_name = 'default-marker-name' + self._interactive_marker_set_name_default = 'interactive-markers' + self._interactive_marker_set_name = self._interactive_marker_set_name_default + + # Define a callback that shows the output of a print + self.print_out = ipyw.Output() + + self._cursor = 'bottom' + self.children = [self._jup_img, self._jup_coord] + + + # These need to also be set for now. # Ginga uses them to figure out what size image to make. self._jup_img.width = image_width @@ -103,7 +156,8 @@ def logger(self): # Need this here because we need to overwrite the setter. @property def image_width(self): - return super().image_width + """Width of image widget.""" + return int(self._jup_img.width) @image_width.setter def image_width(self, value): @@ -115,7 +169,8 @@ def image_width(self, value): # Need this here because we need to overwrite the setter. @property def image_height(self): - return super().image_height + """Height of image widget.""" + return int(self._jup_img.height) @image_height.setter def image_height(self, value): @@ -276,10 +331,14 @@ def offset_to(self, dx, dy, skycoord_offset=False): @property def zoom_level(self): - return self._viewer.get_scale() + """Zoom level (settable): - zoom_level.__doc__ = (BaseImageWidget.zoom_level.__doc__ - + "``'fit'`` means zoom to fit") + * 1 means real-pixel-size. + * 2 means zoomed in by a factor of 2. + * 0.5 means zoomed out by a factor of 2. + * ``fit`` means fit the image to the viewer. + """ + return self._viewer.get_scale() @zoom_level.setter def zoom_level(self, value): @@ -288,6 +347,78 @@ def zoom_level(self, value): else: self._viewer.scale_to(value, value) + + def zoom(self, value): + """Zoom in or out by the given factor. + + Parameters + ---------- + value : int + The zoom level to zoom the image. + See `zoom_level`. + + """ + self.zoom_level = self.zoom_level * value + + @property + def is_marking(self): + """`True` if in marking mode, `False` otherwise. + Marking mode means a mouse click adds a new marker. + This does not affect :meth:`add_markers`. + + """ + return self._is_marking + + def start_marking(self, marker_name=None, marker=None): + """Start marking, with option to name this set of markers or + to specify the marker style. + + This disables `click_center` and `click_drag`, but enables `scroll_pan`. + + Parameters + ---------- + marker_name : str or `None`, optional + Marker name to use. This is useful if you want to set different + groups of markers. If given, this cannot be already defined in + ``RESERVED_MARKER_SET_NAMES`` attribute. If not given, an internal + default is used. + + marker : dict or `None`, optional + Set the marker properties; see `marker`. If not given, the current + setting is used. + + """ + self.set_cached_state() + self.click_center = False + self.click_drag = False + self.scroll_pan = True # Set this to ensure there is a mouse way to pan + self._is_marking = True + if marker_name is not None: + self.validate_marker_name(marker_name) + self._interactive_marker_set_name = marker_name + self._marktags.add(marker_name) + else: + self._interactive_marker_set_name = self._interactive_marker_set_name_default + if marker is not None: + self.marker = marker + + def stop_marking(self, clear_markers=False): + """Stop marking mode, with option to clear all markers, if desired. + + Parameters + ---------- + clear_markers : bool, optional + If `False`, existing markers are retained until + :meth:`remove_all_markers` is called. + Otherwise, they are all erased. + + """ + if self.is_marking: + self._is_marking = False + self.restore_and_clear_cached_state() + if clear_markers: + self.remove_all_markers() + @property def marker(self): """A dictionary defining the current marker properties. @@ -306,7 +437,7 @@ def marker(self): # what we expect the user to provide. # # That makes things like self.marker = self.marker work. - return super().marker + return self._marker_dict @marker.setter def marker(self, value): @@ -328,6 +459,17 @@ def marker(self, value): # Only set this once we have successfully created a marker self._marker_dict = value + def get_marker_names(self): + """Return a list of used marker names. + + Returns + ------- + names : list of str + Sorted list of marker names. + + """ + return sorted(self._marktags) + def get_markers_by_name(self, marker_name, x_colname='x', y_colname='y', skycoord_colname='coord'): @@ -402,6 +544,45 @@ def get_markers_by_name(self, marker_name, x_colname='x', y_colname='y', markers_table['marker name'] = marker_name return markers_table + + def get_all_markers(self, x_colname='x', y_colname='y', skycoord_colname='coord'): + """Run :meth:`get_markers_by_name` for all markers.""" + + # If it wasn't for the fact that SkyCoord columns can't + # be stacked this would all fit nicely into a list + # comprehension. But they can't, so we delete the + # SkyCoord column if it is present, then add it + # back after we have stacked. + coordinates = [] + tables = [] + for name in self._marktags: + table = self.get_markers_by_name( + name, x_colname=x_colname, y_colname=y_colname, + skycoord_colname=skycoord_colname) + if table is None: + continue # No markers by this name, skip it + + if skycoord_colname in table.colnames: + coordinates.extend(c for c in table[skycoord_colname]) + del table[skycoord_colname] + + tables.append(table) + + if len(tables) == 0: + return None + + stacked = vstack(tables, join_type='exact') + + if coordinates: + n_rows = len(stacked) + n_coo = len(coordinates) + if n_coo != n_rows: # This guards against Table auto-broadcast + raise ValueError(f'Expects {n_rows} coordinates but found {n_coo},' + 'some markers may be corrupted') + stacked[skycoord_colname] = SkyCoord(coordinates) + + return stacked + # TODO: Resolve https://github.com/ejeschke/ginga/issues/672 # TODO: Later enhancements to include more columns to control # size/style/color of marks @@ -486,6 +667,60 @@ def remove_markers_by_name(self, marker_name): else: self._marktags.remove(marker_name) + def remove_all_markers(self): + """Delete all markers using :meth:`remove_markers_by_name`.""" + # Grab the entire list of marker names before iterating + # otherwise what we are iterating over changes. + for marker_name in self.get_marker_names(): + self.remove_markers_by_name(marker_name) + + def validate_marker_name(self, marker_name): + """Validate a given marker name. + + Parameters + ---------- + marker_name : str + Marker name to validate. + + Raises + ------ + ValueError + It is not allowed because the name is already defined in the + ``RESERVED_MARKER_SET_NAMES`` attribute. + + """ + if marker_name in self.RESERVED_MARKER_SET_NAMES: + raise ValueError( + f"The marker name {marker_name} is not allowed. Any name is " + f"allowed except these: {', '.join(self.RESERVED_MARKER_SET_NAMES)}") + + def set_cached_state(self): + """Cache the following attributes before modifying their states: + + * ``click_center`` + * ``click_drag`` + * ``scroll_pan`` + + This is used in :meth:`start_marking`, for example. + """ + self._cached_state = dict(click_center=self.click_center, + click_drag=self.click_drag, + scroll_pan=self.scroll_pan) + + def restore_and_clear_cached_state(self): + """Restore the following attributes with their cached states: + + * ``click_center`` + * ``click_drag`` + * ``scroll_pan`` + + Then, clear the cache. This is used in :meth:`stop_marking`, for example. + """ + self.click_center = self._cached_state['click_center'] + self.click_drag = self._cached_state['click_drag'] + self.scroll_pan = self._cached_state['scroll_pan'] + self._cached_state = {} + @property def stretch_options(self): return self._viewer.get_color_algorithms() @@ -534,10 +769,85 @@ def colormap_options(self): def set_colormap(self, cmap): self._viewer.set_color_map(cmap) + @property + def cursor(self): + """Current cursor information panel placement. + + Information must include the following: + + * X and Y cursor positions, depending on `pixel_offset`. + * RA and Dec sky coordinates in HMS-DMS format, if available. + * Value of the image under the cursor. + + You can set it to one of the following: + + * ``'top'`` places it above the image display. + * ``'bottom'`` places it below the image display. + * `None` hides it. + + """ + return self._cursor + + # NOTE: Subclass must re-implement if self._jup_coord is not ipyw.HTML + # or if self.ALLOWED_CURSOR_LOCATIONS is customized. + @cursor.setter + def cursor(self, value): + if value is None: + self._jup_coord.layout.visibility = 'hidden' + self._jup_coord.layout.display = 'none' + elif value in ('top', 'bottom'): + self._jup_coord.layout.visibility = 'visible' + self._jup_coord.layout.display = 'flex' + if value == 'top': + self.layout.flex_flow = 'column-reverse' + else: + self.layout.flex_flow = 'column' + else: + raise ValueError( + f'Invalid value {value} for cursor. ' + f'Valid values are: {self.ALLOWED_CURSOR_LOCATIONS}') + self._cursor = value + + @property + + def click_center(self): + """When `True`, mouse left-click can be used to center an image. + Otherwise, that interaction is disabled. + + You can set this property to `True` or `False`. + This cannot be set to `True` when `is_marking` is also `True`. + Setting this to `True` also disables `click_drag`. + + .. note:: In the future, this might accept non-bool values but not currently. + + """ + return self._click_center + + @click_center.setter + def click_center(self, value): + if not isinstance(value, bool): + raise ValueError('Must be True or False') + elif self.is_marking and value: + raise ValueError('Interactive marking is in progress. Call ' + 'stop_marking() to end marking before setting ' + 'click_center') + if value: + self.click_drag = False + + self._click_center = value + # Need this here because we need to overwrite the setter. @property def click_drag(self): - return super().click_drag + """When `True`, the "click-and-drag" mode is an available interaction + for panning. Otherwise, that interaction is disabled. + + You can set this property to `True` or `False`. + This cannot be set to `True` when `is_marking` is also `True`. + Setting this to `True` also disables `click_center`. + + """ + return self._click_drag @click_drag.setter def click_drag(self, value): @@ -559,7 +869,13 @@ def click_drag(self, value): # Need this here because we need to overwrite the setter. @property def scroll_pan(self): - return super().scroll_pan + """When `True`, scrolling moves around (pans up/down) in the image. + Otherwise, that interaction is disabled and becomes zoom. + + You can set this property to `True` or `False`. + + """ + return self._scroll_pan @scroll_pan.setter def scroll_pan(self, value): From a6161011114494a7e311bfed8ca12e960059fd33 Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Fri, 27 Aug 2021 10:01:16 -0500 Subject: [PATCH 11/50] Factor ginga-specific code out of base test class --- astrowidgets/tests/widget_api_test.py | 275 ++++++++++++++++++++++++++ 1 file changed, 275 insertions(+) create mode 100644 astrowidgets/tests/widget_api_test.py diff --git a/astrowidgets/tests/widget_api_test.py b/astrowidgets/tests/widget_api_test.py new file mode 100644 index 0000000..3256a73 --- /dev/null +++ b/astrowidgets/tests/widget_api_test.py @@ -0,0 +1,275 @@ +# TODO: How to enable switching out backend and still run the same tests? + +import pytest + +import numpy as np # noqa: E402 + +from astropy.io import fits # noqa: E402 +from astropy.nddata import NDData # noqa: E402 +from astropy.table import Table, vstack # noqa: E402 + + +class ImageWidgetAPITest: + def setup_class(self): + rng = np.random.default_rng(1234) + self.data = rng.random((100, 100)) + self.image = self.image_widget_class(image_width=250, image_height=100) + + def test_width_height(self): + assert self.image.image_width == 250 + assert self.image.image_height == 100 + + width = 200 + height = 300 + self.image.image_width = width + self.image.image_height = height + assert self.image.image_width == width + assert self.image.image_height == height + assert self.image.viewer.get_window_size() == (width, height) + + def test_load_fits(self): + hdu = fits.PrimaryHDU(data=self.data) + self.image.load_fits(hdu) + + def test_load_nddata(self): + nddata = NDData(self.data) + self.image.load_nddata(nddata) + + def test_load_array(self): + self.image.load_array(self.data) + + def test_center_on(self): + self.image.center_on((10, 10)) # X, Y + + def test_offset_to(self): + self.image.offset_to(10, 10) # dX, dY + + def test_zoom_level(self): + self.image.zoom_level = 5 + assert self.image.zoom_level == 5 + + def test_zoom(self): + self.image.zoom_level = 3 + self.image.zoom(2) + assert self.image.zoom_level == 6 # 3 x 2 + + def test_marking_operations(self): + marks = self.image.get_all_markers() + assert marks is None + assert not self.image.is_marking + + # Ensure you cannot set it like this. + with pytest.raises(AttributeError): + self.image.is_marking = True + + # Setting these to check that start_marking affects them. + self.image.click_center = True # Disables click_drag + assert self.image.click_center + self.image.scroll_pan = False + assert not self.image.scroll_pan + + # Set the marker style + marker_style = {'color': 'yellow', 'radius': 10, 'type': 'cross'} + m_str = str(self.image.marker) + for key in marker_style.keys(): + assert key in m_str + + self.image.start_marking(marker_name='markymark', marker=marker_style) + assert self.image.is_marking + assert self.image.marker == marker_style + assert not self.image.click_center + assert not self.image.click_drag + + # scroll_pan better activate when marking otherwise there is + # no way to pan while interactively marking + assert self.image.scroll_pan + + # Make sure that when we stop_marking we get our old controls back. + self.image.stop_marking() + assert self.image.click_center + assert not self.image.click_drag + assert not self.image.scroll_pan + + # Regression test for GitHub Issue 97: + # Marker name with no markers should give warning. + with pytest.warns(UserWarning, match='is empty') as warning_lines: + t = self.image.get_markers_by_name('markymark') + assert t is None + assert len(warning_lines) == 1 + + self.image.click_drag = True + self.image.start_marking() + assert not self.image.click_drag + + # Simulate a mouse click to add default marker name to the list. + self.image._mouse_click_cb(self.image.viewer, None, 50, 50) + assert self.image.get_marker_names() == [self.image._interactive_marker_set_name, 'markymark'] + + # Clear markers to not pollute other tests. + self.image.stop_marking(clear_markers=True) + + assert self.image.is_marking is False + assert self.image.get_all_markers() is None + assert len(self.image.get_marker_names()) == 0 + + # Make sure that click_drag is restored as expected + assert self.image.click_drag + + def test_add_markers(self): + rng = np.random.default_rng(1234) + data = rng.integers(0, 100, (5, 2)) + orig_tab = Table(data=data, names=['x', 'y'], dtype=('float', 'float')) + tab = Table(data=data, names=['x', 'y'], dtype=('float', 'float')) + self.image.add_markers(tab, x_colname='x', y_colname='y', + skycoord_colname='coord', marker_name='test1') + + # Make sure setting didn't change the default name + assert self.image._default_mark_tag_name == 'default-marker-name' + + # Regression test for GitHub Issue 45: + # Adding markers should not modify the input data table. + assert (tab == orig_tab).all() + + # Add more markers under different name. + self.image.add_markers(tab, x_colname='x', y_colname='y', + skycoord_colname='coord', marker_name='test2') + assert self.image.get_marker_names() == ['test1', 'test2'] + + # No guarantee markers will come back in the same order, so sort them. + t1 = self.image.get_markers_by_name('test1') + # Sort before comparing + t1.sort('x') + tab.sort('x') + assert np.all(t1['x'] == tab['x']) + assert (t1['y'] == tab['y']).all() + + # That should have given us two copies of the input table + t2 = self.image.get_all_markers() + expected = vstack([tab, tab], join_type='exact') + # Sort before comparing + t2.sort(['x', 'y']) + expected.sort(['x', 'y']) + assert (t2['x'] == expected['x']).all() + assert (t2['y'] == expected['y']).all() + + self.image.remove_markers_by_name('test1') + assert self.image.get_marker_names() == ['test2'] + + # Ensure unable to mark with reserved name + for name in self.image.RESERVED_MARKER_SET_NAMES: + with pytest.raises(ValueError, match='not allowed'): + self.image.add_markers(tab, marker_name=name) + + # Clear markers to not pollute other tests. + self.image.remove_all_markers() + assert len(self.image.get_marker_names()) == 0 + assert self.image.get_all_markers() is None + with pytest.warns(UserWarning, match='is empty'): + assert self.image.get_markers_by_name(self.image._default_mark_tag_name) is None + + with pytest.raises(ValueError, match="No markers named 'test1'"): + self.image.get_markers_by_name('test1') + with pytest.raises(ValueError, match="No markers named 'test2'"): + self.image.get_markers_by_name('test2') + + def test_remove_markers(self): + with pytest.raises(ValueError, match='arf'): + self.image.remove_markers_by_name('arf') + + def test_stretch(self): + original_stretch = self.image.stretch + + with pytest.raises(ValueError, match='must be one of'): + self.image.stretch = 'not a valid value' + + # A bad value should leave the stretch unchanged + assert self.image.stretch is original_stretch + + self.image.stretch = 'log' + # A valid value should change the stretch + assert self.image.stretch is not original_stretch + + def test_cuts(self): + with pytest.raises(ValueError, match='must be one of'): + self.image.cuts = 'not a valid value' + + with pytest.raises(ValueError, match=r'must be given as \(low, high\)'): + self.image.cuts = (1, 10, 100) + + assert 'histogram' in self.image.autocut_options + + self.image.cuts = 'histogram' + np.testing.assert_allclose( + self.image.cuts, (3.948844e-04, 9.990224e-01), rtol=1e-6) + + self.image.cuts = (10, 100) + assert self.image.cuts == (10, 100) + + def test_colormap(self): + cmap_desired = 'gray' + cmap_list = self.image.colormap_options + assert len(cmap_list) > 0 and cmap_desired in cmap_list + self.image.set_colormap(cmap_desired) + + def test_cursor(self): + assert self.image.cursor in self.image.ALLOWED_CURSOR_LOCATIONS + with pytest.raises(ValueError): + self.image.cursor = 'not a valid option' + self.image.cursor = 'bottom' + assert self.image.cursor == 'bottom' + + def test_click_drag(self): + # Set this to ensure that click_drag turns it off + self.image.click_center = True + + # Make sure that setting click_drag to False does not turn off + # click_center. + self.image.click_drag = False + assert self.image.click_center + + self.image.click_drag = True + assert not self.image.click_center + + # If is_marking is true then trying to enable click_drag should fail + self.image._is_marking = True + self.image.click_drag = False + with pytest.raises(ValueError, match='Interactive marking'): + self.image.click_drag = True + self.image._is_marking = False + + def test_click_center(self): + # Set this to ensure that click_center turns it off + self.image.click_drag = True + + # Make sure that setting click_center to False does not turn off + # click_draf. + self.image.click_center = False + assert self.image.click_drag + + self.image.click_center = True + assert not self.image.click_drag + + # If is_marking is true then trying to enable click_center should fail + self.image._is_marking = True + self.image.click_center = False + with pytest.raises(ValueError, match='Interactive marking'): + self.image.click_center = True + self.image._is_marking = False + + def test_scroll_pan(self): + # Make sure scroll_pan is actually settable + for value in [True, False]: + self.image.scroll_pan = value + assert self.image.scroll_pan is value + + def test_save(self, tmpdir): + with pytest.raises(ValueError, match='not supported'): + self.image.save(str(tmpdir.join('woot.jpg'))) + + filename = str(tmpdir.join('woot.png')) + self.image.save(filename) + + with pytest.raises(ValueError, match='exists'): + self.image.save(filename) + + self.image.save(filename, overwrite=True) From aab7edd919a4b14ded46d397e4182dc4658d05ca Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Fri, 27 Aug 2021 10:01:34 -0500 Subject: [PATCH 12/50] Add ginga viewer test --- astrowidgets/tests/test_widget_api_ginga.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 astrowidgets/tests/test_widget_api_ginga.py diff --git a/astrowidgets/tests/test_widget_api_ginga.py b/astrowidgets/tests/test_widget_api_ginga.py new file mode 100644 index 0000000..3da40cb --- /dev/null +++ b/astrowidgets/tests/test_widget_api_ginga.py @@ -0,0 +1,14 @@ +import pytest + +from .widget_api_test import ImageWidgetAPITest + +ginga = pytest.importorskip("ginga", + reason="Package required for test is not " + "available.") +from astrowidgets.ginga import ImageWidget # noqa: E402 + + +class TestGingaWidget(ImageWidgetAPITest): + def setup_class(self): + self.image_widget_class = ImageWidget + super().setup_class(self) From 518c90864149d784a10c77a6b47f0252d6e98620 Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Fri, 27 Aug 2021 10:01:44 -0500 Subject: [PATCH 13/50] Add bqplot viewer test --- astrowidgets/tests/test_widget_api_bqplot.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 astrowidgets/tests/test_widget_api_bqplot.py diff --git a/astrowidgets/tests/test_widget_api_bqplot.py b/astrowidgets/tests/test_widget_api_bqplot.py new file mode 100644 index 0000000..e349544 --- /dev/null +++ b/astrowidgets/tests/test_widget_api_bqplot.py @@ -0,0 +1,14 @@ +import pytest + +from .widget_api_test import ImageWidgetAPITest + +_ = pytest.importorskip("bqplot", + reason="Package required for test is not " + "available.") +from astrowidgets.bqplot import ImageWidget # noqa: E402 + + +class TestGingaWidget(ImageWidgetAPITest): + def setup_class(self): + self.image_widget_class = ImageWidget + super().setup_class(self) From 7cc8a0c294f6c2166313af9c37b09071510b2fc9 Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Fri, 27 Aug 2021 10:16:23 -0500 Subject: [PATCH 14/50] Remove ginga-specific test code --- astrowidgets/tests/widget_api_test.py | 1 - 1 file changed, 1 deletion(-) diff --git a/astrowidgets/tests/widget_api_test.py b/astrowidgets/tests/widget_api_test.py index 3256a73..4ae57f7 100644 --- a/astrowidgets/tests/widget_api_test.py +++ b/astrowidgets/tests/widget_api_test.py @@ -25,7 +25,6 @@ def test_width_height(self): self.image.image_height = height assert self.image.image_width == width assert self.image.image_height == height - assert self.image.viewer.get_window_size() == (width, height) def test_load_fits(self): hdu = fits.PrimaryHDU(data=self.data) From c2fc5b7730d7f4bc6e50c6667663f3181ec5a305 Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Fri, 27 Aug 2021 10:16:46 -0500 Subject: [PATCH 15/50] Make warning message checks more flexible --- astrowidgets/tests/widget_api_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/astrowidgets/tests/widget_api_test.py b/astrowidgets/tests/widget_api_test.py index 4ae57f7..cb9834a 100644 --- a/astrowidgets/tests/widget_api_test.py +++ b/astrowidgets/tests/widget_api_test.py @@ -232,7 +232,7 @@ def test_click_drag(self): # If is_marking is true then trying to enable click_drag should fail self.image._is_marking = True self.image.click_drag = False - with pytest.raises(ValueError, match='Interactive marking'): + with pytest.raises(ValueError, match='[Ii]nteractive marking'): self.image.click_drag = True self.image._is_marking = False From 88a26663e07133b462576f5d32bb0c5f84411439 Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Fri, 27 Aug 2021 10:17:18 -0500 Subject: [PATCH 16/50] Bring bqplot closer to passing API tests --- astrowidgets/bqplot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/astrowidgets/bqplot.py b/astrowidgets/bqplot.py index 0eadda5..2550bde 100644 --- a/astrowidgets/bqplot.py +++ b/astrowidgets/bqplot.py @@ -484,7 +484,7 @@ def _validate_cuts(self, proposal): proposed_cuts = proposal['value'] bad_value_error = (f"{proposed_cuts} is not a valid value. " - "cuts must be either None, " + "cuts must be one of None, " "an astropy interval, or list/tuple " "of length 2.") From 9d08403c63e63252ff33de3c5584b935136132a9 Mon Sep 17 00:00:00 2001 From: Pey Lian Lim <2090236+pllim@users.noreply.github.com> Date: Thu, 1 Jul 2021 13:04:08 -0400 Subject: [PATCH 17/50] New offset_by with new API. Deprecate offset_to with old API. Added tests and updated example notebook. Fixed a test that was writing file into source dir. --- astrowidgets/ginga.py | 57 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 56 insertions(+), 1 deletion(-) diff --git a/astrowidgets/ginga.py b/astrowidgets/ginga.py index bb812a7..6134cd3 100644 --- a/astrowidgets/ginga.py +++ b/astrowidgets/ginga.py @@ -13,9 +13,11 @@ import ipywidgets as ipyw import numpy as np +from astropy import units as u from astropy.coordinates import SkyCoord from astropy.io import fits from astropy.table import Table, vstack +from astropy.utils.decorators import deprecated from ginga.AstroImage import AstroImage from ginga.canvas.CanvasObject import drawCatalog @@ -320,7 +322,25 @@ def center_on(self, point): else: self._viewer.set_pan(*(np.asarray(point) - self._pixel_offset)) + @deprecated('0.3', alternative='offset_by') def offset_to(self, dx, dy, skycoord_offset=False): + """ + Move the center to a point that is given offset + away from the current center. + + .. note:: This is deprecated. Use :meth:`offset_by`. + + Parameters + ---------- + dx, dy : float + Offset value. Unit is assumed based on + ``skycoord_offset``. + + skycoord_offset : bool + If `True`, offset must be given in degrees. + Otherwise, they are in pixel values. + + """ if skycoord_offset: coord = 'wcs' else: @@ -329,6 +349,28 @@ def offset_to(self, dx, dy, skycoord_offset=False): pan_x, pan_y = self._viewer.get_pan(coord=coord) self._viewer.set_pan(pan_x + dx, pan_y + dy, coord=coord) + def offset_by(self, dx, dy): + """ + Move the center to a point that is given offset + away from the current center. + + Parameters + ---------- + dx, dy : float or `~astropy.unit.Quantity` + Offset value. Without a unit, assumed to be pixel offsets. + If a unit is attached, offset by pixel or sky is assumed from + the unit. + + """ + dx_val, dx_coord = _offset_is_pixel_or_sky(dx) + dy_val, dy_coord = _offset_is_pixel_or_sky(dy) + + if dx_coord != dy_coord: + raise ValueError(f'dx is of type {dx_coord} but dy is of type {dy_coord}') + + pan_x, pan_y = self._viewer.get_pan(coord=dx_coord) + self._viewer.set_pan(pan_x + dx_val, pan_y + dy_val, coord=dx_coord) + @property def zoom_level(self): """Zoom level (settable): @@ -902,4 +944,17 @@ def save(self, filename, overwrite=False): with open(filename, 'wb') as f: f.write(self._jup_img.value) - self.logger.info(f'{filename} written') + +def _offset_is_pixel_or_sky(x): + if isinstance(x, u.Quantity): + if x.unit in (u.dimensionless_unscaled, u.pix): + coord = 'data' + val = x.value + else: + coord = 'wcs' + val = x.to_value(u.deg) + else: + coord = 'data' + val = x + + return val, coord From 819c11549a17e0384a7d34afec2a4891064cc30a Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Mon, 30 Aug 2021 09:16:13 -0500 Subject: [PATCH 18/50] Remove incorrect creation of WCS --- astrowidgets/bqplot.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/astrowidgets/bqplot.py b/astrowidgets/bqplot.py index 2550bde..a6d9552 100644 --- a/astrowidgets/bqplot.py +++ b/astrowidgets/bqplot.py @@ -575,8 +575,6 @@ def load_nddata(self, data, reset_view=True): self._ccd = data self._data = self._ccd.data self._wcs = data.wcs - if self._wcs is None: - self._wcs = WCS(self._ccd.meta) self._send_data(reset_view=reset_view) From 9ebc27ff7d161b7a1a16efc3663786237ff5b04b Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Mon, 30 Aug 2021 09:17:11 -0500 Subject: [PATCH 19/50] Ensure save only overwrites if user wants that --- astrowidgets/bqplot.py | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/astrowidgets/bqplot.py b/astrowidgets/bqplot.py index a6d9552..24c3b48 100644 --- a/astrowidgets/bqplot.py +++ b/astrowidgets/bqplot.py @@ -188,10 +188,17 @@ def set_color(self, colors): # colors here means a list of hex colors self._image.scales['image'].colors = colors - def save_png(self, filename): + def _check_file_exists(self, filename, overwrite=False): + if Path(filename).exists() and not overwrite: + raise ValueError(f'File named {filename} already exists. Use ' + f'overwrite=True to overwrite it.') + + def save_png(self, filename, overwrite=False): + self._check_file_exists(filename, overwrite=overwrite) self._figure.save_png(filename) - def save_svg(self, filename): + def save_svg(self, filename, overwrite=False): + self._check_file_exists(filename, overwrite=overwrite) self._figure.save_svg(filename) def set_pan(self, on_or_off): @@ -579,14 +586,14 @@ def load_nddata(self, data, reset_view=True): self._send_data(reset_view=reset_view) # Saving contents of the view and accessing the view - def save(self, filename): + def save(self, filename, overwrite=False): if filename.endswith('.png'): - self._astro_im.save_png(filename) + self._astro_im.save_png(filename, overwrite=overwrite) elif filename.endswith('.svg'): - self._astro_im.save_svg(filename) + self._astro_im.save_svg(filename, overwrite=overwrite) else: - raise NotImplementedError('Saving is not implemented for that' - 'file type. Use .png or .svg') + raise ValueError('Saving is not supported for that' + 'file type. Use .png or .svg') def set_colormap(self, cmap_name, reverse=False): self._astro_im.set_color(bqcolors(cmap_name, reverse=reverse)) From a57ed378c898974b9046676e9268de12d70036db Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Mon, 30 Aug 2021 09:17:39 -0500 Subject: [PATCH 20/50] Change assertion to ValueError to match API --- astrowidgets/bqplot.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/astrowidgets/bqplot.py b/astrowidgets/bqplot.py index 24c3b48..e1c0c04 100644 --- a/astrowidgets/bqplot.py +++ b/astrowidgets/bqplot.py @@ -501,7 +501,10 @@ def _validate_cuts(self, proposal): else: try: length = len(proposed_cuts) - assert length == 2 + if length != 2: + raise ValueError('Cut levels must be given as (low, high).' + + bad_value_error) + # Tests expect this to be a tuple... proposed_cuts = tuple(proposed_cuts) except (TypeError, AssertionError): From 14662e8e1fcdc85a6d69c669453f05b03e56fc39 Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Mon, 30 Aug 2021 09:18:05 -0500 Subject: [PATCH 21/50] Do not alert user that scroll_pan actually doesn't work --- astrowidgets/bqplot.py | 1 - 1 file changed, 1 deletion(-) diff --git a/astrowidgets/bqplot.py b/astrowidgets/bqplot.py index e1c0c04..45c00be 100644 --- a/astrowidgets/bqplot.py +++ b/astrowidgets/bqplot.py @@ -550,7 +550,6 @@ def _update_viewer_pan(self, change): @trait.observe('scroll_pan') def _update_viewer_zoom_scroll(self, change): - raise NotImplementedError('😭 sorry, cannot do that yet') self._astro_im.set_scroll_zoom(change['new']) # The methods, grouped loosely by purpose From feda94b543307ec16cecc132f2cd01dedb4c9cd4 Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Mon, 30 Aug 2021 09:18:45 -0500 Subject: [PATCH 22/50] Handle several marker-related settings --- astrowidgets/bqplot.py | 43 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 40 insertions(+), 3 deletions(-) diff --git a/astrowidgets/bqplot.py b/astrowidgets/bqplot.py index 45c00be..12d2df4 100644 --- a/astrowidgets/bqplot.py +++ b/astrowidgets/bqplot.py @@ -531,13 +531,16 @@ def _update_zoom_level(self, change): self._astro_im.set_zoom_level(zl) + def _currently_marking_error_msg(self, caller): + return (f'Cannot set {caller} while doing interactive ' + f'marking. Call the stop_marking() method to ' + f'stop marking and then set {caller}.') + @trait.validate('click_drag') def _validate_click_drag(self, proposal): cd = proposal['value'] if cd and self._is_marking: - raise ValueError('Cannot set click_drag while doing interactive ' - 'marking. Call the stop_marking() method to ' - 'stop marking and then set click_drag.') + raise ValueError(self._currently_marking_error_msg('click_drag')) return cd @trait.observe('click_drag') @@ -552,6 +555,40 @@ def _update_viewer_pan(self, change): def _update_viewer_zoom_scroll(self, change): self._astro_im.set_scroll_zoom(change['new']) + @trait.validate('click_center') + def _validate_click_center(self, proposal): + new = proposal['value'] + if new and self._is_marking: + raise ValueError(self._currently_marking_error_msg('click_center')) + return new + + @trait.observe('click_center') + def _update_click_center(self, change): + if change['new']: + # click_center has been turned on, so turn off click_drag + self.click_drag = False + + @property + def viewer(self): + return self._astro_im + + @property + def is_marking(self): + """`True` if in marking mode, `False` otherwise. + Marking mode means a mouse click adds a new marker. + This does not affect :meth:`add_markers`. + + """ + return self._is_marking + + @property + def _default_mark_tag_name(self): + """ + This is only here to make a test pass -- it should probably either + be part of the API or not tested. + """ + return self._marker_table.default_mark_tag_name + # The methods, grouped loosely by purpose # Methods for loading data From efdf2004f23183729152ab61974d367b645fdfa3 Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Mon, 30 Aug 2021 09:19:31 -0500 Subject: [PATCH 23/50] Add a few more API attributes --- astrowidgets/bqplot.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/astrowidgets/bqplot.py b/astrowidgets/bqplot.py index 12d2df4..6bdaa82 100644 --- a/astrowidgets/bqplot.py +++ b/astrowidgets/bqplot.py @@ -366,11 +366,16 @@ class ImageWidget(ipw.VBox): zoom_level = trait.Float(help="Current zoom of the view").tag(sync=True) marker = trait.Any(help="Markers").tag(sync=True) cuts = trait.Any(help="Cut levels", allow_none=True).tag(sync=False) - + cursor = trait.Enum(ALLOWED_CURSOR_LOCATIONS, default_value='bottom', + help='Whether and where to display cursor position').tag(sync=True) stretch = trait.Unicode(help='Stretch algorithm name', allow_none=True).tag(sync=True) + # Leave this in since the API seems to call for it + ALLOWED_CURSOR_LOCATIONS = ALLOWED_CURSOR_LOCATIONS + def __init__(self, *args, image_width=500, image_height=500): super().__init__(*args) + self.RESERVED_MARKER_SET_NAMES = ['all'] self.image_width = image_width self.image_height = image_height viewer_aspect = self.image_width / self.image_height @@ -388,6 +393,7 @@ def __init__(self, *args, image_width=500, image_height=500): self.cuts = apviz.AsymmetricPercentileInterval(1, 99) self._cursor = ipw.HTML('Coordinates show up here') + self._init_mouse_callbacks() self.children = [self._astro_im, self._cursor] From 4b206c1912fb5c6b0bde5c56e46df708cf12b314 Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Mon, 30 Aug 2021 09:20:35 -0500 Subject: [PATCH 24/50] Fix bug in error messages --- astrowidgets/bqplot.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/astrowidgets/bqplot.py b/astrowidgets/bqplot.py index 6bdaa82..3b73829 100644 --- a/astrowidgets/bqplot.py +++ b/astrowidgets/bqplot.py @@ -250,7 +250,7 @@ def remove_named_markers(self, mark_id): try: del self._scatter_marks[mark_id] except KeyError: - raise ValueError('Markers {mark_id} are not present.') + raise ValueError(f'Markers {mark_id} are not present.') self._update_marks() @@ -478,9 +478,9 @@ def _validate_stretch(self, proposal): proposed_stretch is not None): raise ValueError(f'{proposed_stretch} is not a valid value. ' - 'The stretch must be None or ' - 'one of these values: ' - f'{sorted(STRETCHES.keys())}') + 'The stretch must be one of None or ' + 'one of these values: ' + f'{sorted(STRETCHES.keys())}') return proposed_stretch From 657f294db5ca7755b446079afabadc65aad5839e Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Mon, 30 Aug 2021 09:21:19 -0500 Subject: [PATCH 25/50] Add several methods for implementing markers --- astrowidgets/bqplot.py | 94 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 92 insertions(+), 2 deletions(-) diff --git a/astrowidgets/bqplot.py b/astrowidgets/bqplot.py index 3b73829..3842f0a 100644 --- a/astrowidgets/bqplot.py +++ b/astrowidgets/bqplot.py @@ -1,6 +1,9 @@ +from pathlib import Path +import warnings + import numpy as np -from astropy.coordinates import SkyCoord +from astropy.coordinates import SkyCoord, SkyOffsetFrame from astropy.io import fits from astropy.nddata import CCDData from astropy.table import Table, vstack @@ -657,10 +660,18 @@ def colormap_options(self): # def stop_marking(self): # raise NotImplementedError + def _validate_marker_name(self, marker_name): + if marker_name in self.RESERVED_MARKER_SET_NAMES: + raise ValueError( + f"The marker name {marker_name} is not allowed. Any name is " + f"allowed except these: {', '.join(self.RESERVED_MARKER_SET_NAMES)}") + def add_markers(self, table, x_colname='x', y_colname='y', skycoord_colname='coord', use_skycoord=False, marker_name=None): + self._validate_marker_name(marker_name) + if use_skycoord: if self._wcs is None: raise ValueError('The WCS for the image must be set to use ' @@ -718,13 +729,16 @@ def _prepare_return_marker_table(self, marks, x_colname='x', y_colname='y', return marks + def get_marker_names(self): + return self._marker_table.marker_names + def get_markers_by_name(self, marker_name=None, x_colname='x', y_colname='y', skycoord_colname='coord'): # We should always allow the default name. The case # where that table is empty will be handled in a moment. if (marker_name not in self._marker_table.marker_names - and marker_name != self.marker_table.default_mark_tag_name): + and marker_name != self._marker_table.default_mark_tag_name): raise ValueError(f"No markers named '{marker_name}' found.") marks = self._marker_table.get_markers_by_name(marker_name=marker_name) @@ -768,3 +782,79 @@ def center_on(self, point): def zoom(self, value): self.zoom_level = self.zoom_level * value + + def start_marking(self, marker_name=None, marker=None): + """Start marking, with option to name this set of markers or + to specify the marker style. + + This disables `click_center` and `click_drag`, but enables `scroll_pan`. + + Parameters + ---------- + marker_name : str or `None`, optional + Marker name to use. This is useful if you want to set different + groups of markers. If given, this cannot be already defined in + ``RESERVED_MARKER_SET_NAMES`` attribute. If not given, an internal + default is used. + + marker : dict or `None`, optional + Set the marker properties; see `marker`. If not given, the current + setting is used. + + """ + self.set_cached_state() + self.click_center = False + self.click_drag = False + self.scroll_pan = True # Set this to ensure there is a mouse way to pan + self._is_marking = True + if marker_name is not None: + self._validate_marker_name(marker_name) + self._interactive_marker_set_name = marker_name + else: + self._interactive_marker_set_name = self._interactive_marker_set_name_default + if marker is not None: + self.marker = marker + + def stop_marking(self, clear_markers=False): + """Stop marking mode, with option to clear all markers, if desired. + + Parameters + ---------- + clear_markers : bool, optional + If `False`, existing markers are retained until + :meth:`remove_all_markers` is called. + Otherwise, they are all erased. + + """ + if self.is_marking: + self._is_marking = False + self.restore_and_clear_cached_state() + if clear_markers: + self.remove_all_markers() + + def set_cached_state(self): + """Cache the following attributes before modifying their states: + + * ``click_center`` + * ``click_drag`` + * ``scroll_pan`` + + This is used in :meth:`start_marking`, for example. + """ + self._cached_state = dict(click_center=self.click_center, + click_drag=self.click_drag, + scroll_pan=self.scroll_pan) + + def restore_and_clear_cached_state(self): + """Restore the following attributes with their cached states: + + * ``click_center`` + * ``click_drag`` + * ``scroll_pan`` + + Then, clear the cache. This is used in :meth:`stop_marking`, for example. + """ + self.click_center = self._cached_state['click_center'] + self.click_drag = self._cached_state['click_drag'] + self.scroll_pan = self._cached_state['scroll_pan'] + self._cached_state = {} From 4bdd27df16507e6d912479017dffc96fa8c889b1 Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Mon, 30 Aug 2021 09:22:12 -0500 Subject: [PATCH 26/50] Implement offset_by --- astrowidgets/bqplot.py | 40 +++++++++++++++++++++++++++++++++++++--- astrowidgets/helpers.py | 16 ++++++++++++++++ 2 files changed, 53 insertions(+), 3 deletions(-) create mode 100644 astrowidgets/helpers.py diff --git a/astrowidgets/bqplot.py b/astrowidgets/bqplot.py index 3842f0a..11bd27a 100644 --- a/astrowidgets/bqplot.py +++ b/astrowidgets/bqplot.py @@ -24,6 +24,8 @@ import traitlets as trait +from .helpers import _offset_is_pixel_or_sky + # Allowed locations for cursor display ALLOWED_CURSOR_LOCATIONS = ['top', 'bottom', None] @@ -776,9 +778,41 @@ def center_on(self, point): self._astro_im.center = pixel - # @abstractmethod - # def offset_to(self): - # raise NotImplementedError + def offset_by(self, dx, dy): + """ + Move the center to a point that is given offset + away from the current center. + + Parameters + ---------- + dx, dy : float or `~astropy.unit.Quantity` + Offset value. Without a unit, assumed to be pixel offsets. + If a unit is attached, offset by pixel or sky is assumed from + the unit. + + """ + dx_val, dx_coord = _offset_is_pixel_or_sky(dx) + dy_val, dy_coord = _offset_is_pixel_or_sky(dy) + + if dx_coord != dy_coord: + raise ValueError(f'dx is of type {dx_coord} but ' + f'dy is of type {dy_coord}') + + if dx_coord == 'data': + x, y = self._astro_im.center + self.center_on((x + dx_val, y + dy_val)) + else: + center_coord = self._wcs.pixel_to_world(*self._astro_im.center) + # dx and dy in this case have units and we need to pass those units + # in to offset. + + offset = SkyOffsetFrame(dx, dy, origin=center_coord.frame) + new_center = SkyCoord(offset.transform_to(center_coord)) + + # This is so much better only available in 4.3 or higher: + # new_center = center_coord.spherical_offsets_by(dx_val, dy_val) + + self.center_on(new_center) def zoom(self, value): self.zoom_level = self.zoom_level * value diff --git a/astrowidgets/helpers.py b/astrowidgets/helpers.py new file mode 100644 index 0000000..5e0721a --- /dev/null +++ b/astrowidgets/helpers.py @@ -0,0 +1,16 @@ +from astropy import units as u + + +def _offset_is_pixel_or_sky(x): + if isinstance(x, u.Quantity): + if x.unit in (u.dimensionless_unscaled, u.pix): + coord = 'data' + val = x.value + else: + coord = 'wcs' + val = x.to_value(u.deg) + else: + coord = 'data' + val = x + + return val, coord From e228174b626448a831f69983e001dc3cc72c1a02 Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Mon, 30 Aug 2021 09:23:44 -0500 Subject: [PATCH 27/50] Rewrite test classes to use fixtures instead of setup_class The problem with the latter is that it is only run ONCE per test suite run, so changes made in one test were affecting the viewer in other tests. --- astrowidgets/tests/test_widget_api_bqplot.py | 14 ++-- astrowidgets/tests/test_widget_api_ginga.py | 4 +- astrowidgets/tests/widget_api_test.py | 70 ++++++++++++++++---- 3 files changed, 68 insertions(+), 20 deletions(-) diff --git a/astrowidgets/tests/test_widget_api_bqplot.py b/astrowidgets/tests/test_widget_api_bqplot.py index e349544..792de5c 100644 --- a/astrowidgets/tests/test_widget_api_bqplot.py +++ b/astrowidgets/tests/test_widget_api_bqplot.py @@ -1,5 +1,7 @@ import pytest +from traitlets import TraitError + from .widget_api_test import ImageWidgetAPITest _ = pytest.importorskip("bqplot", @@ -8,7 +10,11 @@ from astrowidgets.bqplot import ImageWidget # noqa: E402 -class TestGingaWidget(ImageWidgetAPITest): - def setup_class(self): - self.image_widget_class = ImageWidget - super().setup_class(self) +class TestBQplotWidget(ImageWidgetAPITest): + image_widget_class = ImageWidget + cursor_error_classes = (ValueError, TraitError) + + @pytest.mark.skip(reason="Saving is done in javascript and requires " + "a running browser.") + def test_save(self, tmpdir): + pass diff --git a/astrowidgets/tests/test_widget_api_ginga.py b/astrowidgets/tests/test_widget_api_ginga.py index 3da40cb..72112dd 100644 --- a/astrowidgets/tests/test_widget_api_ginga.py +++ b/astrowidgets/tests/test_widget_api_ginga.py @@ -9,6 +9,4 @@ class TestGingaWidget(ImageWidgetAPITest): - def setup_class(self): - self.image_widget_class = ImageWidget - super().setup_class(self) + image_widget_class = ImageWidget diff --git a/astrowidgets/tests/widget_api_test.py b/astrowidgets/tests/widget_api_test.py index cb9834a..dd57fbd 100644 --- a/astrowidgets/tests/widget_api_test.py +++ b/astrowidgets/tests/widget_api_test.py @@ -7,12 +7,42 @@ from astropy.io import fits # noqa: E402 from astropy.nddata import NDData # noqa: E402 from astropy.table import Table, vstack # noqa: E402 +from astropy import units as u # noqa: E402 +from astropy.wcs import WCS # noqa: E402 class ImageWidgetAPITest: - def setup_class(self): + cursor_error_classes = (ValueError) + + @pytest.fixture + def data(self): rng = np.random.default_rng(1234) - self.data = rng.random((100, 100)) + return rng.random((100, 100)) + + @pytest.fixture + def wcs(self): + # This is a copy/paste from the astropy 4.3.1 documentation... + + # Create a new WCS object. The number of axes must be set + # from the start + w = WCS(naxis=2) + + # Set up an "Airy's zenithal" projection + w.wcs.crpix = [-234.75, 8.3393] + w.wcs.cdelt = np.array([-0.066667, 0.066667]) + w.wcs.crval = [0, -90] + w.wcs.ctype = ["RA---AIR", "DEC--AIR"] + w.wcs.set_pv([(2, 1, 45.0)]) + return w + + # This setup is run before each test, ensuring that there are no + # side effects of one test on another + @pytest.fixture(autouse=True) + def setup(self): + """ + Subclasses MUST define ``image_widget_class`` -- doing so as a + class variable does the trick. + """ self.image = self.image_widget_class(image_width=250, image_height=100) def test_width_height(self): @@ -26,22 +56,34 @@ def test_width_height(self): assert self.image.image_width == width assert self.image.image_height == height - def test_load_fits(self): - hdu = fits.PrimaryHDU(data=self.data) + def test_load_fits(self, data): + hdu = fits.PrimaryHDU(data=data) self.image.load_fits(hdu) - def test_load_nddata(self): - nddata = NDData(self.data) + def test_load_nddata(self, data): + nddata = NDData(data) self.image.load_nddata(nddata) - def test_load_array(self): - self.image.load_array(self.data) + def test_load_array(self, data): + self.image.load_array(data) def test_center_on(self): self.image.center_on((10, 10)) # X, Y - def test_offset_to(self): - self.image.offset_to(10, 10) # dX, dY + def test_offset_by(self, data, wcs): + self.image.offset_by(10, 10) # dX, dY + + # A mix of pixel and sky should produce an error + with pytest.raises(ValueError): + self.image.offset_by(10 * u.arcmin, 10) + + # Testing offset by WCS requires a WCS. The viewer will (or ought to + # have) taken care of setting up the WCS internally if initialized with + # an NDData that has a WCS. + ndd = NDData(data=data, wcs=wcs) + self.image.load_nddata(ndd) + + self.image.offset_by(10 * u.arcmin, 10 * u.arcmin) def test_zoom_level(self): self.image.zoom_level = 5 @@ -188,7 +230,7 @@ def test_stretch(self): # A valid value should change the stretch assert self.image.stretch is not original_stretch - def test_cuts(self): + def test_cuts(self, data): with pytest.raises(ValueError, match='must be one of'): self.image.cuts = 'not a valid value' @@ -197,6 +239,8 @@ def test_cuts(self): assert 'histogram' in self.image.autocut_options + # Setting using histogram requires data + self.image.load_array(data) self.image.cuts = 'histogram' np.testing.assert_allclose( self.image.cuts, (3.948844e-04, 9.990224e-01), rtol=1e-6) @@ -212,7 +256,7 @@ def test_colormap(self): def test_cursor(self): assert self.image.cursor in self.image.ALLOWED_CURSOR_LOCATIONS - with pytest.raises(ValueError): + with pytest.raises(self.cursor_error_classes): self.image.cursor = 'not a valid option' self.image.cursor = 'bottom' assert self.image.cursor == 'bottom' @@ -251,7 +295,7 @@ def test_click_center(self): # If is_marking is true then trying to enable click_center should fail self.image._is_marking = True self.image.click_center = False - with pytest.raises(ValueError, match='Interactive marking'): + with pytest.raises(ValueError, match='[Ii]nteractive marking'): self.image.click_center = True self.image._is_marking = False From 1e9962ec7c12e556cc8dacc84d949217f47fd917 Mon Sep 17 00:00:00 2001 From: Pey Lian Lim <2090236+pllim@users.noreply.github.com> Date: Fri, 19 Feb 2021 13:20:40 -0500 Subject: [PATCH 28/50] DOC: Update user-facing doc. TST: Update test matrix and added a test. DOC: Re-organized doc and notebooks. DOC: Update example notebooks --- .github/workflows/ci_workflows.yml | 3 +- .gitignore | 1 + astrowidgets/tests/test_abstract_class.py | 18 ++ docs/abstract.rst | 258 ++++++++++++++++++ docs/astrowidgets/api.rst | 7 - docs/astrowidgets/index.rst | 70 ----- docs/ginga.rst | 93 +++++++ docs/index.rst | 20 +- docs/install.rst | 77 +----- example_notebooks/README.md | 4 + example_notebooks/ginga/README.md | 10 + .../{ => ginga}/ginga_wcsaxes.ipynb | 4 +- .../{ => ginga}/ginga_widget.ipynb | 24 +- .../{ => ginga}/gui_interactions.ipynb | 18 +- .../{ => ginga}/named_markers.ipynb | 62 ++++- tox.ini | 5 + 16 files changed, 485 insertions(+), 189 deletions(-) create mode 100644 astrowidgets/tests/test_abstract_class.py create mode 100644 docs/abstract.rst delete mode 100644 docs/astrowidgets/api.rst create mode 100644 docs/ginga.rst create mode 100644 example_notebooks/ginga/README.md rename example_notebooks/{ => ginga}/ginga_wcsaxes.ipynb (98%) rename example_notebooks/{ => ginga}/ginga_widget.ipynb (97%) rename example_notebooks/{ => ginga}/gui_interactions.ipynb (89%) rename example_notebooks/{ => ginga}/named_markers.ipynb (82%) diff --git a/.github/workflows/ci_workflows.yml b/.github/workflows/ci_workflows.yml index e1c23f8..9222943 100644 --- a/.github/workflows/ci_workflows.yml +++ b/.github/workflows/ci_workflows.yml @@ -31,6 +31,7 @@ jobs: flake8 astrowidgets --count tests: + name: ${{ matrix.name }} runs-on: ${{ matrix.os }} strategy: fail-fast: true @@ -48,8 +49,6 @@ jobs: - name: Run tests run: tox -e py39-test - devtests: - runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v3 diff --git a/.gitignore b/.gitignore index f0acf70..e85d1c3 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ MANIFEST example_notebooks/test.png */version.py pip-wheel-metadata/ +example_notebooks/ginga/test.png # Sphinx docs/api diff --git a/astrowidgets/tests/test_abstract_class.py b/astrowidgets/tests/test_abstract_class.py new file mode 100644 index 0000000..6808093 --- /dev/null +++ b/astrowidgets/tests/test_abstract_class.py @@ -0,0 +1,18 @@ +"""Test to make sure astrowidgets can install and be used with +only abstract class, without optional backend. + +""" +import pytest + +from astrowidgets.core import BaseImageWidget + + +class DummyWidget(BaseImageWidget): + pass + + +def test_abstract_no_imp(): + # Ensure subclass cannot be used without implementing all the abstracted + # things. + with pytest.raises(TypeError, match="Can't instantiate abstract class"): + DummyWidget() diff --git a/docs/abstract.rst b/docs/abstract.rst new file mode 100644 index 0000000..fedb2ad --- /dev/null +++ b/docs/abstract.rst @@ -0,0 +1,258 @@ +.. _abstract_widget_intro: + +Understanding BaseImageWidget +============================= + +``astrowidgets`` provides an abstract class called +`~astrowidgets.core.BaseImageWidget` to allow developers from different +visualization tools (hereafter known as "backends") to implement their own +solutions using the same set of API. This design is based on +`nb-astroimage-api `_, with the +goal of making all functionality available by a compact and clear API. +This API-first approach would allow manipulating the view programmatically +in a reproducible way. + +The idea of the abstract class is that ``astrowidgets`` users would be able +to switch to the backend of their choice without much refactoring of their own. +This would enable, say, astronomers with different backend preferences to +collaborate more easily via Jupyter Lab/Notebook. + +The following sub-sections lay out the envisioned high-level usage of +``astrowidgets`` regardless of backend inside Jupyter Lab/Notebook. +However, the examples are not exhaustive. For the full API definition, +please see :ref:`abstract_api`. + +.. _abstract_viewer: + +Creating a Viewer +----------------- + +The snippet below is all you should need to make an image widget. +The widget should be a part of the +`ipywidgets framework `_ so that it can +be easily integrated with other controls: + +.. code-block:: python + + from astrowidgets.somebackend import ImageWidget + image = ImageWidget() + image + +.. _abstract_image_load: + +Loading an Image +---------------- + +To load data into the empty viewer created in :ref:`abstract_viewer`, +there should be methods to load different formats: + +.. code-block:: python + + # FITS image of the field of the exoplanet Kelt-16, + # and also contains part of the Veil Nebula + filename = 'https://zenodo.org/record/3356833/files/kelt-16-b-S001-R001-C084-r.fit.bz2?download=1' + image.load_fits(filename) + +.. code-block:: python + + # A Numpy array + import numpy as np + arr = np.arange(100).reshape(10, 10) + image.load_array(arr) + +.. code-block:: python + + # An astropy.nddata.NDData object + from astropy.io import fits + from astropy.nddata import NDData + from astropy.wcs import WCS + with fits.open(filename) as pf: + data = NDData(pf[0].data, wcs=WCS(pf[0].header)) + image.load_nddata(data) + +If additional format support is desired, the API could be added to the +abstract base class if the new format is widely supported and not specific +to a certain backend implementation. + +.. _abstract_cursor_info: + +Cursor Info Display +------------------- + +The widget actually consists of two child widgets: + +* The image display. +* Cursor information panel with the following: + * X and Y cursor locations, taking + `~astrowidgets.core.BaseImageWidget.pixel_offset` into account. + * RA and Dec calculated from the cursor location using the image's WCS, + if available. It is up to the backend on how to handle WCS projection + or distortion. + * Value of the image under the cursor. + +The cursor information panel can have three different states: + +* Positioned below the image display. +* Positioned above the image display. +* Not displayed. + +This state can be set using the `~astrowidgets.core.BaseImageWidget.cursor` +property. + +.. _abstract_size: + +Changing Display Size +--------------------- + +There should be a programmatic way to change the display size of the display +widget: + +.. code-block:: python + + # The height would auto-adjust + image.image_width = 500 # pixels + + # The width would auto-adjust + image.image_height = 500 # pixels + +.. _abstract_colormap: + +Changing Colormap +----------------- + +There should be a programmatic way to change the colormap of the display. +However, the available colormaps may differ from backend to backend: + +.. code-block:: python + + image.set_colormap('viridis') + +.. _abstract_controls: + +Mouse/Keyboard Controls +----------------------- + +Mouse interaction using clicks and scroll should be supported. +Keyboard controls would also be desirable. These controls should be active +when cursor is over the display, but not otherwise. +For example, but not limited to: + +* Scrolling to pan up/down the image. +* Using ``+``/``-`` to zoom in/out. +* Using click-and-drag to change the contrast of the image. + +In the event where the same click/button can be overloaded, the active +functionality can be controlled by the following properties: + +* `~astrowidgets.core.BaseImageWidget.click_center` +* `~astrowidgets.core.BaseImageWidget.click_drag` +* `~astrowidgets.core.BaseImageWidget.scroll_pan` + +There should be programmatic ways to perform these controls as well: + +.. code-block:: python + + # Centering on sky coordinates + from astropy.coordinates import SkyCoord + image.center_on(SkyCoord.from_name('kelt-16')) + + # Centering on pixel coordinates + image.center_on((100, 100)) + + # Moving the center using sky coordinates + from astropy import units as u + image.offset_to(0.1 * u.arcsec, 0.1 * u.arcsec, skycoord_offset=True) + + # Moving the center by pixels + image.offset_to(10, 10) + + # Zooming (two different ways) + image.zoom(2) + image.zoom_level = 1 + + # Changing the display stretch + image.stretch = 'log' + + # Changing the cut levels (two different ways) + image.cuts = 'histogram' + image.cuts = (0, 10) # (low, high) + +Please also see :ref:`abstract_marking`. + +.. _abstract_marking: + +Marking Objects +--------------- + +Another important aspect is to allow users to either interactively or +programmatically mark objects of interest on the displayed image. +Marking mode is tracked using the +`~astrowidgets.core.BaseImageWidget.is_marking` +property and can be turned on and off using +:meth:`~astrowidgets.core.BaseImageWidget.start_marking` and +:meth:`~astrowidgets.core.BaseImageWidget.stop_marking`, respectively. +The marker appearance can be changed using +`~astrowidgets.core.BaseImageWidget.marker`. + +For interactive marking, after a user runs ``start_marking`` but before +``stop_marking``, a click on the image display would mark the object under +the cursor. + +For programmatic marking, user can first build a `~astopy.table.Table` with +either pixel or sky coordinates, and then pass it into +:meth:`~astrowidgets.core.BaseImageWidget.add_markers`. + +User can then call +:meth:`~astrowidgets.core.BaseImageWidget.get_markers_by_name` or +:meth:`~astrowidgets.core.BaseImageWidget.get_all_markers` to obtain the +marked locations. + +To remove the markers, user can call +:meth:`~astrowidgets.core.BaseImageWidget.remove_markers_by_name` or +:meth:`~astrowidgets.core.BaseImageWidget.remove_all_markers`, as appropriate. + +To put this all together, here is an example workflow (out of many) +that may happen: + +1. User calls ``start_marking`` to begin the interactive marking session. +2. User clicks on two stars. +3. User calls ``stop_marking`` to end the interactive marking session. +4. User reads a table from a collaborator containing several galaxies in the + field of view. +5. User changes the marker style from a red circle to a green square by + modifying the ``marker`` property. +6. User programmatically marks the galaxies on display with the new marker style + and a new marker name using ``add_markers``. +7. User obtains all the marked locations for post-processing using + ``get_all_markers``. +8. User removes all the markers from display using ``remove_all_markers``. + +.. _abstract_save: + +Saving an Image +--------------- + +The image display can be programmatically saved to a file, but not the +:ref:`abstract_cursor_info`. Supported output format is controlled by the +individual backend. For example: + +.. code-block:: python + + image.save('myimage.png') + +.. _example_notebooks: + +Example Notebooks +----------------- + +Please see the `example notebooks folder `_ +for examples using a concrete implementation of this abstract class. +Backend-dependent dependencies are required to run them. + +.. _abstract_api: + +API +--- + +.. automodapi:: astrowidgets + :no-inheritance-diagram: diff --git a/docs/astrowidgets/api.rst b/docs/astrowidgets/api.rst deleted file mode 100644 index 6260b50..0000000 --- a/docs/astrowidgets/api.rst +++ /dev/null @@ -1,7 +0,0 @@ -.. _api-docs: - -API Reference -============= - -.. automodapi:: astrowidgets - :no-inheritance-diagram: diff --git a/docs/astrowidgets/index.rst b/docs/astrowidgets/index.rst index 1c8bc64..e69de29 100644 --- a/docs/astrowidgets/index.rst +++ b/docs/astrowidgets/index.rst @@ -1,70 +0,0 @@ -Image widget for Jupyter Lab/notebook -===================================== - -Getting started ---------------- - -Make a viewer -+++++++++++++ - -The snippet below is all you need to make an image widget. The widget is part -of the `ipywidgets framework `_ so that it can -be easily integrated with other controls:: - - >>> from astrowidgets import ImageWidget - >>> image = ImageWidget() - >>> display(image) - -Loading an image -++++++++++++++++ - -An empty viewer is not very useful, though, so load some data from a FITS -file. The FITS file at the link below is an image of the field of the exoplanet -Kelt-16, and also contains part of the Veil Nebula:: - - >>> image.load_fits('https://zenodo.org/record/3356833/files/kelt-16-b-S001-R001-C084-r.fit.bz2?download=1') - -The image widget can also load a Numpy array via -`~astrowidgets.ImageWidget.load_array`. It also understands astropy -`~astropy.nddata.NDData` objects; load them via -`~astrowidgets.ImageWidget.load_data`. - -Navigation -++++++++++ - -In the default configuration, basic navigation is done using these controls: - -* scroll to pan -* use ``+``/``-`` to zoom in/out (cursor must be over the image for this to work) -* right-click and drag to change contrast DS9-style - -API -+++ - -One important design goal is to make all functionality available by a compact, -clear API. The `target API `_ still -needs a few features (e.g., blink), but much of it is already implemented. - -The API-first approach means that manipulating the view programmatically is straightforward. -For example, centering on the position of the object, Kelt-16, and zooming in to 8x the natural -pixel scale is straightforward:: - - >>> from astropy.coordinates import SkyCoord - >>> image.center_on(SkyCoord.from_name('kelt-16')) - >>> image.zoom_level = 8 - -A more detailed description of the interface and the :ref:`api-docs` are available. - -.. toctree:: - :maxdepth: 2 - - api.rst - -Example Notebooks ------------------ - -* `astrowidgets using the Ginga backend `_ -* `Using named markers to keep track of logically related markers `_ -* `Demonstration of GUI interactions `_ - - diff --git a/docs/ginga.rst b/docs/ginga.rst new file mode 100644 index 0000000..fb12a3e --- /dev/null +++ b/docs/ginga.rst @@ -0,0 +1,93 @@ +.. _ginga_backend: + +Widget with Ginga Toolkit +========================= + +``astrowidgets`` comes with an example concrete implementation using +`Ginga `_ as a backend: + +.. code-block:: python + + from astrowidgets.ginga import ImageWidget + from ginga.misc.log import get_logger + logger = get_logger('my_viewer', log_stderr=False, log_file='ginga.log', + level=40) + image = ImageWidget(logger) + +Please see the `Ginga example notebooks folder `_ +for examples using this implementation. + +.. _ginga_dependencies: + +Dependencies +------------ + +The following dependecies need to be installed separately if you wish to use +the Ginga implementation: + +* ``ginga>=2.7.1`` +* ``pillow`` +* ``freetype`` +* ``aggdraw`` +* ``opencv`` (optional, not required but will improve performance) + +.. note:: + + For vectorized drawing in ``aggdraw``, you can clone + https://github.com/ejeschke/aggdraw/ and install its ``vectorized-drawing`` + branch from source. + +For Windows Users +^^^^^^^^^^^^^^^^^ + +It is a known issue that ``FREETYPE_ROOT`` is not set properly if you do +``conda install aggdraw`` on Windows +(https://github.com/conda-forge/freetype-feedstock/issues/12), which results +in ``aggdraw cannot load font (no text renderer)`` error message when +using the widget with Ginga toolkit. The solution is to update to ``aggdraw`` +1.3.5 or later; e.g., ``conda install aggdraw=1.3.5``. + +.. _ginga_opencv: + +Using OpenCV +------------ + +If you wish to use `OpenCV `_ +to handle the drawing in Ginga, you have two options: + +Install OpenCV with pip +^^^^^^^^^^^^^^^^^^^^^^^ + +If you are using pip it looks like the best option is to use the +``opencv-python`` package, which provides pre-built binaries of most of OpenCV:: + + pip install opencv-python + +However, the `opencv-python project `_ +is quite clear about being "unofficial" so you should probably read about +the project before using. + +Install OpenCV with conda +^^^^^^^^^^^^^^^^^^^^^^^^^ + +This should work on conda:: + + conda install -c conda-forge opencv + +If, after installing ``opencv``, you get a warning like this:: + + UserWarning: install opencv or set use_opencv=False + +Then, you should try installing a newer version of ``freetype``:: + + conda install 'freetype\>=2.10' + +For more details, see `this discussion of opencv and astrowidgets +`_. + +.. _ginga_imagewidget_api: + +API +--- + +.. automodapi:: astrowidgets.ginga diff --git a/docs/index.rst b/docs/index.rst index 401a24b..00b4c8e 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,6 +1,6 @@ -************ -astrowidgets -************ +************************************* +Image widget for Jupyter Lab/Notebook +************************************* ``astrowidgets`` aims to be a set of astronomy widgets for Jupyter Lab or Notebook, leveraging the Astropy ecosystem. @@ -14,21 +14,13 @@ or Notebook, leveraging the Astropy ecosystem. Please let us know what would make the tool easier to use on our `GitHub issue tracker`_. -Getting started -=============== +Contents: .. toctree:: :maxdepth: 1 install - astrowidgets/index - - -Reference/API -============= - -.. automodapi:: astrowidgets - :no-main-docstr: - :no-inheritance-diagram: + abstract + ginga .. _GitHub issue tracker: https://github.com/astropy/astrowidgets/issues diff --git a/docs/install.rst b/docs/install.rst index e19616a..471a61c 100644 --- a/docs/install.rst +++ b/docs/install.rst @@ -1,6 +1,14 @@ +.. _astrowidgets_install: + Installation ============ +This page contains the installation instructions for the abstract class in +``astrowidgets``. To use the concrete implementation with Ginga, please also +see :ref:`ginga_backend`. + +.. _astrowidgets_install_pip: + Install with pip ---------------- @@ -17,10 +25,12 @@ install `nodejs from here `_:: # jupyter labextension install @jupyter-widgets/jupyterlab-manager +.. _astrowidgets_install_conda: + Install with conda ------------------ -conda installation:: +``conda`` installation:: conda install -c conda-forge astrowidgets nodejs jupyter labextension install @jupyter-widgets/jupyterlab-manager @@ -50,7 +60,6 @@ automatically when you install astrowidgets: * ``aggdraw`` * ``jupyterlab>=3`` * ``nodejs`` -* ``opencv`` (optional, not installed by default) After installing dependencies, for Jupyter Lab, run:: @@ -59,68 +68,8 @@ After installing dependencies, for Jupyter Lab, run:: For those using ``conda``, dependencies from the ``conda-forge`` channel should be sufficient unless stated otherwise. -Using OpenCV ------------- - -If you wish to use `OpenCV `_ to handle the -drawing in Ginga, you have two options: - -Install OpenCV with pip -^^^^^^^^^^^^^^^^^^^^^^^ - -If you are using pip it looks like the best option is to use the -``opencv-python`` package, which provides pre-built binaries of most of OpenCV:: - - pip install opencv-python - -However, the `opencv-python project -`_ is quite clear about being -"unofficial" so you should probably read about the project before using. - -Install OpenCV with conda -^^^^^^^^^^^^^^^^^^^^^^^^^ - -This should work on conda:: - - conda install -c conda-forge opencv - -If, after installing ``opencv``, you get a warning like this:: - - astrowidgets/core.py:72: UserWarning: install opencv or set use_opencv=False - warnings.warn('install opencv or set use_opencv=False') - -then you should try installing a newer version of ``freetype``:: - - conda install 'freetype\>=2.10' - -For more details, see `this discussion of opencv and astrowidgets -`_. - -Widget with Ginga toolkit -------------------------- - -.. note:: - - For vectorized drawing in ``aggdraw``, you can clone - https://github.com/ejeschke/aggdraw/ and install its ``vectorized-drawing`` - branch from source. - - -Notes for Windows users ------------------------ - -aggdraw -^^^^^^^ - -It is a known issue that ``FREETYPE_ROOT`` is not set properly if you do -``conda install aggdraw`` on Windows -(https://github.com/conda-forge/freetype-feedstock/issues/12), which results -in ``aggdraw cannot load font (no text renderer)`` error message when -using the widget with Ginga toolkit. The solution is to update to ``aggdraw`` -1.3.5 or later; e.g., ``conda install aggdraw=1.3.5``. - -nodejs -^^^^^^ +nodejs on Windows +^^^^^^^^^^^^^^^^^ In Windows 7, ``conda install -c conda-forge nodejs`` might throw an ``IOError``. The workaround for this is to install ``yarn`` and ``nodejs`` diff --git a/example_notebooks/README.md b/example_notebooks/README.md index ca4416d..c593d3d 100644 --- a/example_notebooks/README.md +++ b/example_notebooks/README.md @@ -1 +1,5 @@ This is a folder to store example Jupyter notebooks. + +Available backends: + +* Ginga: See `ginga/` subfolder. diff --git a/example_notebooks/ginga/README.md b/example_notebooks/ginga/README.md new file mode 100644 index 0000000..e82e6d0 --- /dev/null +++ b/example_notebooks/ginga/README.md @@ -0,0 +1,10 @@ +This directory contains example notebooks using `astrowidgets` with +Ginga backend. + +Available notebooks: + +* `ginga_widget.ipynb` demonstrates basic widget functionality. +* `gui_interactions.ipynb` demonstrates some interactive elements. +* `named_markers.ipynb` illustrates usage of named markers to keep track of + logically related objects of interest. +* `ginga_wcsaxes.ipynb` provides an example to overlay WCS axes on an image. diff --git a/example_notebooks/ginga_wcsaxes.ipynb b/example_notebooks/ginga/ginga_wcsaxes.ipynb similarity index 98% rename from example_notebooks/ginga_wcsaxes.ipynb rename to example_notebooks/ginga/ginga_wcsaxes.ipynb index d16c989..cc582a0 100644 --- a/example_notebooks/ginga_wcsaxes.ipynb +++ b/example_notebooks/ginga/ginga_wcsaxes.ipynb @@ -23,7 +23,7 @@ "outputs": [], "source": [ "from astropy.nddata import CCDData\n", - "from astrowidgets import ImageWidget.ginga as _ImageWidget\n", + "from astrowidgets.ginga import ImageWidget as _ImageWidget\n", "from ginga.canvas.types.astro import WCSAxes\n", "from ginga.misc.log import get_logger" ] @@ -99,7 +99,7 @@ "metadata": {}, "outputs": [], "source": [ - "w = ImageWidget(logger=logger)" + "w = ImageWidget(logger)" ] }, { diff --git a/example_notebooks/ginga_widget.ipynb b/example_notebooks/ginga/ginga_widget.ipynb similarity index 97% rename from example_notebooks/ginga_widget.ipynb rename to example_notebooks/ginga/ginga_widget.ipynb index 8f4bf34..4da9b7e 100644 --- a/example_notebooks/ginga_widget.ipynb +++ b/example_notebooks/ginga/ginga_widget.ipynb @@ -41,7 +41,7 @@ "metadata": {}, "outputs": [], "source": [ - "w = ImageWidget(logger=logger)" + "w = ImageWidget(logger)" ] }, { @@ -57,6 +57,7 @@ "Or if you wish to load a data array natively (without WCS):\n", "```python\n", "from astropy.io import fits\n", + "# NOTE: memmap=False is needed for remote data on Windows.\n", "with fits.open(filename, memmap=False) as pf:\n", " arr = pf[numhdu].data.copy()\n", "w.load_array(arr)\n", @@ -73,7 +74,6 @@ "numhdu = 0\n", "\n", "# Loads NDData\n", - "# NOTE: memmap=False is needed for remote data on Windows.\n", "# NOTE: Some file also requires unit to be explicitly set in CCDData.\n", "from astropy.nddata import CCDData\n", "ccd = CCDData.read(filename, hdu=numhdu, format='fits')\n", @@ -105,7 +105,7 @@ "Space > `s` > up/down arrow | Cycle through cuts algorithms\n", "Space > `l` | Toggle no/soft/normal lock |\n", "\n", - "*TODO: Check out Contrast Mode next*" + "*NOTE: This list is not exhaustive.*" ] }, { @@ -389,15 +389,13 @@ "outputs": [], "source": [ "# Get table of markers\n", - "markers_table = w.get_markers(marker_name='all')\n", + "markers_table = w.get_all_markers()\n", "\n", "# Default display might be hard to read, so we do this\n", - "print('{:^8s} {:^8s} {:^28s}'.format(\n", - " 'X', 'Y', 'Coordinates'))\n", + "print(f'{\"X\":^8s} {\"Y\":^8s} {\"Coordinates\":^28s}')\n", "for row in markers_table:\n", " c = row['coord'].to_string('hmsdms')\n", - " print('{:8.2f} {:8.2f} {}'.format(\n", - " row['x'], row['y'], c))" + " print(f'{row[\"x\"]:8.2f} {row[\"y\"]:8.2f} {c}')" ] }, { @@ -407,7 +405,7 @@ "outputs": [], "source": [ "# Erase markers from display\n", - "w.reset_markers()" + "w.remove_all_markers()" ] }, { @@ -441,7 +439,7 @@ "outputs": [], "source": [ "# Erase them again\n", - "w.reset_markers()\n", + "w.remove_all_markers()\n", "\n", "# Programmatically re-mark from table using SkyCoord\n", "w.add_markers(markers_table, use_skycoord=True)" @@ -493,7 +491,7 @@ "dpix = 20\n", "\n", "# Image from the viewer.\n", - "img = w._viewer.get_image()\n", + "img = w.viewer.get_image()\n", "\n", "# Random \"stars\" generated.\n", "bad_locs = np.random.randint(\n", @@ -541,7 +539,7 @@ "# Define a function to control marker display\n", "def show_circles(n):\n", " \"\"\"Show and hide circles.\"\"\"\n", - " w.reset_markers()\n", + " w.remove_all_markers()\n", " t2show = t[:n]\n", " w.add_markers(t2show)\n", " with w.print_out:\n", @@ -596,7 +594,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.6.7" + "version": "3.8.3" } }, "nbformat": 4, diff --git a/example_notebooks/gui_interactions.ipynb b/example_notebooks/ginga/gui_interactions.ipynb similarity index 89% rename from example_notebooks/gui_interactions.ipynb rename to example_notebooks/ginga/gui_interactions.ipynb index 9f14b11..c02bb4e 100644 --- a/example_notebooks/gui_interactions.ipynb +++ b/example_notebooks/ginga/gui_interactions.ipynb @@ -32,7 +32,8 @@ "metadata": {}, "outputs": [], "source": [ - "from astrowidgets.ginga import ImageWidget" + "from astrowidgets.ginga import ImageWidget\n", + "from ginga.misc.log import get_logger" ] }, { @@ -41,7 +42,17 @@ "metadata": {}, "outputs": [], "source": [ - "imw = ImageWidget(image_width=300, image_height=300)" + "logger = get_logger('my viewer', log_stderr=True,\n", + " log_file=None, level=30)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "imw = ImageWidget(logger, image_width=300, image_height=300)" ] }, { @@ -54,7 +65,6 @@ "numhdu = 0\n", "\n", "# Loads NDData\n", - "# NOTE: memmap=False is needed for remote data on Windows.\n", "# NOTE: Some file also requires unit to be explicitly set in CCDData.\n", "from astropy.nddata import CCDData\n", "ccd = CCDData.read(filename, hdu=numhdu, format='fits')\n", @@ -130,7 +140,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.6.7" + "version": "3.8.3" } }, "nbformat": 4, diff --git a/example_notebooks/named_markers.ipynb b/example_notebooks/ginga/named_markers.ipynb similarity index 82% rename from example_notebooks/named_markers.ipynb rename to example_notebooks/ginga/named_markers.ipynb index 51f02e4..9ef28b5 100644 --- a/example_notebooks/named_markers.ipynb +++ b/example_notebooks/ginga/named_markers.ipynb @@ -20,8 +20,9 @@ "metadata": {}, "outputs": [], "source": [ - "from astrowidgets import.ginga ImageWidget\n", - "from astropy.table import Table \n", + "from astrowidgets.ginga import ImageWidget\n", + "from astropy.table import Table\n", + "from ginga.misc.log import get_logger\n", "\n", "import numpy as np" ] @@ -32,7 +33,17 @@ "metadata": {}, "outputs": [], "source": [ - "imw = ImageWidget()" + "logger = get_logger('my viewer', log_stderr=True,\n", + " log_file=None, level=30)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "imw = ImageWidget(logger)" ] }, { @@ -45,7 +56,6 @@ "numhdu = 0\n", "\n", "# Loads NDData\n", - "# NOTE: memmap=False is needed for remote data on Windows.\n", "# NOTE: Some file also requires unit to be explicitly set in CCDData.\n", "from astropy.nddata import CCDData\n", "ccd = CCDData.read(filename, hdu=numhdu, format='fits')\n", @@ -150,8 +160,7 @@ "outputs": [], "source": [ "imw.start_marking(marker={'color': 'red', 'radius': 30, 'type': 'circle'},\n", - " marker_name='clicked markers'\n", - " )" + " marker_name='clicked markers')" ] }, { @@ -176,7 +185,7 @@ "metadata": {}, "outputs": [], "source": [ - "imw.get_markers(marker_name='cyan 20')" + "imw.get_markers_by_name('cyan 20')" ] }, { @@ -192,7 +201,7 @@ "metadata": {}, "outputs": [], "source": [ - "imw.get_markers(marker_name='clicked markers')" + "imw.get_markers_by_name('clicked markers')" ] }, { @@ -208,7 +217,7 @@ "metadata": {}, "outputs": [], "source": [ - "imw.start_marking(marker={'color': 'red', 'radius': 10, 'type': 'cross'} )" + "imw.start_marking(marker={'color': 'red', 'radius': 10, 'type': 'cross'})" ] }, { @@ -220,6 +229,15 @@ "imw.stop_marking()" ] }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "imw.get_marker_names()" + ] + }, { "cell_type": "markdown", "metadata": {}, @@ -233,7 +251,16 @@ "metadata": {}, "outputs": [], "source": [ - "imw.remove_markers(marker_name='yellow 10')" + "imw.remove_markers_by_name('yellow 10')" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "imw.get_marker_names()" ] }, { @@ -249,7 +276,7 @@ "metadata": {}, "outputs": [], "source": [ - "imw.get_markers(marker_name='all')" + "imw.get_all_markers()" ] }, { @@ -265,7 +292,16 @@ "metadata": {}, "outputs": [], "source": [ - "imw.reset_markers()" + "imw.remove_all_markers()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "print(imw.get_marker_names(), imw.get_all_markers())" ] } ], @@ -285,7 +321,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.6.7" + "version": "3.8.3" } }, "nbformat": 4, diff --git a/tox.ini b/tox.ini index 9f0b5d9..9ee8136 100644 --- a/tox.ini +++ b/tox.ini @@ -16,6 +16,11 @@ changedir = test: .tmp/{envname} deps = + + oldestdeps: numpy==1.17.* + oldestdeps: astropy==4.0.* + oldestdeps: ginga==3.0.* + devdeps: git+https://github.com/astropy/astropy.git#egg=astropy devdeps: git+https://github.com/ejeschke/ginga.git#egg=ginga From 33799e35e1710c921b11049a254f100bf3c5b41a Mon Sep 17 00:00:00 2001 From: Pey Lian Lim <2090236+pllim@users.noreply.github.com> Date: Mon, 22 Feb 2021 17:49:43 -0500 Subject: [PATCH 29/50] DOC: Fix inheritance diagram for Ginga API. DOC: Cannot use automodapi for Ginga API because we need to inherit docstring. DOC: Do not display inherited members because too confusing. [ci skip] --- docs/conf.py | 2 ++ docs/ginga.rst | 8 +++++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/docs/conf.py b/docs/conf.py index e5c6b1f..ef73a42 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -45,6 +45,8 @@ # -- General configuration ---------------------------------------------------- +autodoc_inherit_docstrings = True + # By default, highlight as Python 3. highlight_language = 'python3' diff --git a/docs/ginga.rst b/docs/ginga.rst index fb12a3e..268d7a3 100644 --- a/docs/ginga.rst +++ b/docs/ginga.rst @@ -90,4 +90,10 @@ For more details, see `this discussion of opencv and astrowidgets API --- -.. automodapi:: astrowidgets.ginga +.. automodule:: astrowidgets.ginga + +.. inheritance-diagram:: astrowidgets.ginga.ImageWidget + :top-classes: ipywidgets.VBox, astrowidgets.core.BaseImageWidget + +.. autoclass:: astrowidgets.ginga.ImageWidget + :members: From bedd9f62628772a5bcc8778527dc5a8f47f40a8c Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Sun, 12 Sep 2021 12:25:29 -0500 Subject: [PATCH 30/50] Allow the image widget to detect GUI zoom changes --- astrowidgets/bqplot.py | 74 ++++++++++++++++++++++++++- astrowidgets/tests/widget_api_test.py | 20 +++++++- 2 files changed, 91 insertions(+), 3 deletions(-) diff --git a/astrowidgets/bqplot.py b/astrowidgets/bqplot.py index 11bd27a..2d1f6d6 100644 --- a/astrowidgets/bqplot.py +++ b/astrowidgets/bqplot.py @@ -68,6 +68,8 @@ def __init__(self, image_data=None, 'image': ColorScale(max=1, min=0, scheme='Greys')} + self._image_shape = None + self._scatter_marks = {} self._figure = Figure(scales=self._scales, axes=[axis_x, axis_y], @@ -238,6 +240,24 @@ def set_zoom_level(self, zoom_level): self.set_size(new_width, 'x') self._set_scale_aspect_ratio_to_match_viewer('y') + def get_zoom_level(self): + """ + Get the zoom level of the current view, if such a view has been set. + + A zoom level of 1 means 1 pixel in the image is 1 pixel in the viewer, + i.e. the scale width in the horizontal direction matches the width in + pixels of the figure. + """ + if self._image_shape is None: + return None + + # The width is used here but the height could be used instead + # and the result would be the same since the pixels are square. + figure_width = float(self._figure.layout.width[:-2]) + scale_width = self.scale_widths[1] + + return figure_width / scale_width + def plot_named_markers(self, x, y, mark_id, color='yellow', size=100, style='circle'): scale_dict = dict(x=self._scales['x'], y=self._scales['y']) @@ -394,12 +414,21 @@ def __init__(self, *args, image_width=500, image_height=500): self._wcs = None self._is_marking = False + # Use this to manage whether or not to send changes in zoom level + # to the viewer. + self._zoom_source_is_gui = False + # Use this to let the method monitoring changes coming from the + # image know that the ImageWidget itself is in the process of + # updating the zoom. + self._updating_zoom = False + self.marker = {'color': 'red', 'radius': 20, 'type': 'square'} self.cuts = apviz.AsymmetricPercentileInterval(1, 99) self._cursor = ipw.HTML('Coordinates show up here') self._init_mouse_callbacks() + self._init_watch_image_changes() self.children = [self._astro_im, self._cursor] def _init_mouse_callbacks(self): @@ -448,6 +477,39 @@ def _mouse_move(self, event_data): ra_dec = '' self._cursor.value = ', '.join([pixel_location, ra_dec, value]) + def _init_watch_image_changes(self): + """ + Watch for changes to the image scale, which indicate the user + has either changed the zoom or has panned, and update the zoom_level. + """ + def update_zoom_level(event): + """ + Watch for changes in the zoom level from the viewer. + """ + + old_zoom = self.zoom_level + new_zoom = self._astro_im.get_zoom_level() + if new_zoom is None or self._updating_zoom: + # There is no image yet, or this object is in the process + # of changing the zoom, so return + return + + # Do nothing if the zoom has not changed + if np.abs(new_zoom - old_zoom) > 1e-3: + # Let the zoom_level handler know the GUI itself + # generated this zoom change which means the GUI + # does not need to be updated. + self._zoom_source_is_gui = True + self.zoom_level = new_zoom + + # Observe changes to the maximum of the x scale. Observing the y scale + # or the minimum instead of the maximum is also fine. + x_scale = self._astro_im._scales['x'] + + # THIS IS TERRIBLE AND MAKES THINGS SUPER LAGGY!!!! Needs to be + # throttled or something. Look at the ImageGL observe options. + x_scale.observe(update_zoom_level, names='max') + def _interval_and_stretch(self): """ Stretch and normalize the data before sending to the viewer. @@ -466,6 +528,7 @@ def _interval_and_stretch(self): def _send_data(self, reset_view=True): self._astro_im.set_data(self._interval_and_stretch(), reset_view=reset_view) + self.zoom_level = self._astro_im.get_zoom_level() def _get_interval(self): if self._interval is None: @@ -539,8 +602,15 @@ def _observe_cuts(self, change): @trait.observe('zoom_level') def _update_zoom_level(self, change): zl = change['new'] - - self._astro_im.set_zoom_level(zl) + if not self._zoom_source_is_gui: + # User has changed the zoom value so update the viewer + self._updating_zoom = True + self._astro_im.set_zoom_level(zl) + self._updating_zoom = False + else: + # GUI updated the value so do nothing except reset the source + # of the event + self._zoom_source_is_gui = False def _currently_marking_error_msg(self, caller): return (f'Cannot set {caller} while doing interactive ' diff --git a/astrowidgets/tests/widget_api_test.py b/astrowidgets/tests/widget_api_test.py index dd57fbd..fddb4b6 100644 --- a/astrowidgets/tests/widget_api_test.py +++ b/astrowidgets/tests/widget_api_test.py @@ -85,8 +85,26 @@ def test_offset_by(self, data, wcs): self.image.offset_by(10 * u.arcmin, 10 * u.arcmin) - def test_zoom_level(self): + def test_zoom_level_initial_value(self, data): + # With no data, value is zero? Or should it be undefined? + assert self.image.zoom_level == 0 + + self.image.load_array(data) + + # After setting data the value should not be zero + assert self.image.zoom_level != 0 + + # In fact, for 100 x 100 data and a 250 x 100 image the zoom level + # should be 250 / 100 + assert np.abs(self.image.zoom_level - 2.5) < 1e-4 + + def test_zoom_level(self, data): + # Set data first, since that is needed to determine zoom level + print(self.image.zoom_level) + self.image.load_array(data) + print(self.image.zoom_level) self.image.zoom_level = 5 + print(self.image.zoom_level) assert self.image.zoom_level == 5 def test_zoom(self): From c8cd3cfabb2b304959e64f92e2049678882b3257 Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Fri, 17 Sep 2021 09:19:30 -0500 Subject: [PATCH 31/50] Add click_center functionality --- astrowidgets/bqplot.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/astrowidgets/bqplot.py b/astrowidgets/bqplot.py index 2d1f6d6..1815960 100644 --- a/astrowidgets/bqplot.py +++ b/astrowidgets/bqplot.py @@ -442,6 +442,8 @@ def on_mouse_message(interaction, event_data, buffers): """ if event_data['event'] == 'mousemove': self._mouse_move(event_data) + elif event_data['event'] == 'click': + self._mouse_click(event_data) self._astro_im.interaction.on_msg(on_mouse_message) @@ -471,12 +473,23 @@ def _mouse_move(self, event_data): pixel_location = f'X: {xc:.2f} Y: {yc:.2f}' if self._wcs is not None: - sky = self._wcs.pixel_to_world(xc, yc) + sky = self._wcs.pixel_to_world(yc, xc) ra_dec = f'RA: {sky.icrs.ra:3.7f} Dec: {sky.icrs.dec:3.7f}' else: ra_dec = '' self._cursor.value = ', '.join([pixel_location, ra_dec, value]) + def _mouse_click(self, event_data): + if self._data is None: + # Nothing to display, so exit + return + + xc = event_data['domain']['x'] + yc = event_data['domain']['y'] + + if self.click_center: + self.center_on((xc, yc)) + def _init_watch_image_changes(self): """ Watch for changes to the image scale, which indicate the user From da5ac8ea2defac49e48470d40b8b52f6e4ebfd3e Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Fri, 17 Sep 2021 09:20:06 -0500 Subject: [PATCH 32/50] Respond to changes in cursor value --- astrowidgets/bqplot.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/astrowidgets/bqplot.py b/astrowidgets/bqplot.py index 1815960..fe6a072 100644 --- a/astrowidgets/bqplot.py +++ b/astrowidgets/bqplot.py @@ -662,6 +662,17 @@ def _update_click_center(self, change): # click_center has been turned on, so turn off click_drag self.click_drag = False + @trait.observe('cursor') + def _update_cursor_position(self, change): + if change['new'] == 'top': + self.layout.flex_flow = 'column-reverse' + self._cursor.layout.visibility = 'visible' + elif change['new'] == 'bottom': + self.layout.flex_flow = 'column' + self._cursor.layout.visibility = 'visible' + elif change['new'] is None: + self._cursor.layout.visibility = 'hidden' + @property def viewer(self): return self._astro_im From 5c47658953bd8dc053673b458216d2ff66e92d12 Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Fri, 17 Sep 2021 09:20:34 -0500 Subject: [PATCH 33/50] Fix bug in setting up stretch --- astrowidgets/bqplot.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/astrowidgets/bqplot.py b/astrowidgets/bqplot.py index fe6a072..ffa390f 100644 --- a/astrowidgets/bqplot.py +++ b/astrowidgets/bqplot.py @@ -567,7 +567,13 @@ def _validate_stretch(self, proposal): @trait.observe('stretch') def _observe_stretch(self, change): - self._stretch = STRETCHES[change['new']] if change['new'] else None + if change['new'] == 'histeq': + self._stretch = STRETCHES[change['new']](self._data) + else: + self._stretch = STRETCHES[change['new']]() if change['new'] else None + + if self._stretch is not None and change['new'] != change['old']: + self._send_data() @trait.validate('cuts') def _validate_cuts(self, proposal): From 0c5048a73f0394974276ef814d0f32f488c2041a Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Fri, 17 Sep 2021 09:20:57 -0500 Subject: [PATCH 34/50] Fix errors in marking setup --- astrowidgets/bqplot.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/astrowidgets/bqplot.py b/astrowidgets/bqplot.py index ffa390f..b808d65 100644 --- a/astrowidgets/bqplot.py +++ b/astrowidgets/bqplot.py @@ -941,11 +941,12 @@ def start_marking(self, marker_name=None, marker=None): self.click_drag = False self.scroll_pan = True # Set this to ensure there is a mouse way to pan self._is_marking = True + mt = self._marker_table if marker_name is not None: self._validate_marker_name(marker_name) - self._interactive_marker_set_name = marker_name + mt._interactive_marker_set_name = marker_name else: - self._interactive_marker_set_name = self._interactive_marker_set_name_default + mt._interactive_marker_set_name = mt._interactive_marker_set_name_default if marker is not None: self.marker = marker From a7e9384ff2f8bf3a4b3c78de3aa42e967793f4e3 Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Fri, 17 Sep 2021 09:41:13 -0500 Subject: [PATCH 35/50] Handle case when user does not specify marker name in add_markers --- astrowidgets/bqplot.py | 8 ++++---- astrowidgets/tests/widget_api_test.py | 10 ++++++++++ 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/astrowidgets/bqplot.py b/astrowidgets/bqplot.py index b808d65..0957312 100644 --- a/astrowidgets/bqplot.py +++ b/astrowidgets/bqplot.py @@ -792,10 +792,10 @@ def add_markers(self, table, x_colname='x', y_colname='y', marks = self.get_markers_by_name(marker_name) self._astro_im.plot_named_markers(marks['x'], marks['y'], - marker_name, - color=self.marker['color'], - size=self.marker['radius']**2, - style=self.marker['type']) + marker_name, + color=self.marker['color'], + size=self.marker['radius']**2, + style=self.marker['type']) def remove_markers_by_name(self, marker_name): # Remove from our tracking table diff --git a/astrowidgets/tests/widget_api_test.py b/astrowidgets/tests/widget_api_test.py index fddb4b6..d01b91b 100644 --- a/astrowidgets/tests/widget_api_test.py +++ b/astrowidgets/tests/widget_api_test.py @@ -219,6 +219,16 @@ def test_add_markers(self): with pytest.raises(ValueError, match='not allowed'): self.image.add_markers(tab, marker_name=name) + + # Add markers with no marker name and check we can retrieve them + # using the default marker name + self.image.add_markers(tab, x_colname='x', y_colname='y', + skycoord_colname='coord') + # Don't care about the order of the marker names so use set instead of + # list. + assert (set(self.image.get_marker_names()) == + set(['test2', self.image._default_mark_tag_name])) + # Clear markers to not pollute other tests. self.image.remove_all_markers() assert len(self.image.get_marker_names()) == 0 From 584ebf5356732277ecd85c5acc784bec90c6923c Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Wed, 22 Sep 2021 09:43:57 -0500 Subject: [PATCH 36/50] Add click-to-mark functionality --- astrowidgets/bqplot.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/astrowidgets/bqplot.py b/astrowidgets/bqplot.py index 0957312..b3ce0da 100644 --- a/astrowidgets/bqplot.py +++ b/astrowidgets/bqplot.py @@ -490,6 +490,29 @@ def _mouse_click(self, event_data): if self.click_center: self.center_on((xc, yc)) + if self.is_marking: + print('marky marking') + # Just hand off to the method that actually does the work + self._add_new_single_marker(xc, yc) + + def _add_new_single_marker(self, x_mark, y_mark): + # We have location of the new marker and should have the name + # of the marker tag and the marker style, so just need to update + # the table and draw the new maker. + + marker_name = self._marker_table._interactive_marker_set_name + # update the marker table + self._marker_table.add_markers([x_mark], [y_mark], marker_name=marker_name) + + # First approach: get any current markers by that name, add this one + # remove the old ones and draw the new ones. + marks = self.get_markers_by_name(marker_name=marker_name) + self._astro_im.plot_named_markers(marks['x'], marks['y'], + marker_name, + color=self.marker['color'], + size=self.marker['radius']**2, + style=self.marker['type']) + def _init_watch_image_changes(self): """ Watch for changes to the image scale, which indicate the user @@ -772,6 +795,10 @@ def add_markers(self, table, x_colname='x', y_colname='y', skycoord_colname='coord', use_skycoord=False, marker_name=None): + # Handle the case where marker_name is None + if marker_name is None: + marker_name = self._marker_table.default_mark_tag_name + self._validate_marker_name(marker_name) if use_skycoord: From 3de921adea3b5080d2013b1bbf5c64065024868e Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Wed, 27 Oct 2021 09:29:01 -0500 Subject: [PATCH 37/50] WIP bqplot widget notebook --- example_notebooks/ginga/bqplot_widget.ipynb | 978 ++++++++++++++++++++ 1 file changed, 978 insertions(+) create mode 100644 example_notebooks/ginga/bqplot_widget.ipynb diff --git a/example_notebooks/ginga/bqplot_widget.ipynb b/example_notebooks/ginga/bqplot_widget.ipynb new file mode 100644 index 0000000..d305e0f --- /dev/null +++ b/example_notebooks/ginga/bqplot_widget.ipynb @@ -0,0 +1,978 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Widget Example Using bqplot" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "See https://astrowidgets.readthedocs.io for additional details about the widget, including installation notes." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [], + "source": [ + "from astrowidgets.bqplot import ImageWidget" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [], + "source": [ + "# from ginga.misc.log import get_logger\n", + "\n", + "# logger = get_logger('my viewer', log_stderr=True,\n", + "# log_file=None, level=30)" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [], + "source": [ + "w = ImageWidget()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "For this example, we use an image from Astropy data repository and load it as `CCDData`. Feel free to modify `filename` to point to your desired image.\n", + "\n", + "Alternately, for local FITS file, you could load it like this instead:\n", + "```python\n", + "w.load_fits(filename, numhdu=numhdu)\n", + "``` \n", + "Or if you wish to load a data array natively (without WCS):\n", + "```python\n", + "from astropy.io import fits\n", + "# NOTE: memmap=False is needed for remote data on Windows.\n", + "with fits.open(filename, memmap=False) as pf:\n", + " arr = pf[numhdu].data.copy()\n", + "w.load_array(arr)\n", + "```" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [], + "source": [ + "filename = 'http://data.astropy.org/photometry/spitzer_example_image.fits'\n", + "numhdu = 0\n", + "\n", + "# Loads NDData\n", + "# NOTE: Some file also requires unit to be explicitly set in CCDData.\n", + "from astropy.nddata import CCDData\n", + "ccd = CCDData.read(filename, hdu=numhdu, format='fits')\n", + "w.load_nddata(ccd)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Ginga key bindings documented at http://ginga.readthedocs.io/en/latest/quickref.html . Note that not all documented bindings would work here. Please use an alternate binding, if available, if the chosen one is not working.\n", + "\n", + "Here are the ones that worked during testing with Firefox 52.8.0 on RHEL7 64-bit:\n", + "\n", + "Key | Action | Notes\n", + "--- | --- | ---\n", + "`+` | Zoom in |\n", + "`-` | Zoom out |\n", + "Number (0-9) | Zoom in to specified level | 0 = 10\n", + "Shift + number | Zoom out to specified level | Numpad does not work\n", + "` (backtick) | Reset zoom |\n", + "Space > `q` > arrow | Pan |\n", + "ESC | Exit mode (pan, etc) |\n", + "`c` | Center image\n", + "Space > `d` > up/down arrow | Cycle through color distributions\n", + "Space > `d` > Shift + `d` | Go back to linear color distribution\n", + "Space > `s` > Shift + `s` | Set cut level to min/max\n", + "Space > `s` > Shift + `a` | Set cut level to 0/255 (for 8bpp RGB images)\n", + "Space > `s` > up/down arrow | Cycle through cuts algorithms\n", + "Space > `l` | Toggle no/soft/normal lock |\n", + "\n", + "*NOTE: This list is not exhaustive.*" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "A viewer will be shown after running the next cell.\n", + "In Jupyter Lab, you can split it out into a separate view by right-clicking on the viewer and then select\n", + "\"Create New View for Output\". Then, you can drag the new\n", + "\"Output View\" tab, say, to the right side of the workspace. Both viewers are connected to the same events." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [ + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "446131d7e9a24639b5a1be203c32e159", + "version_major": 2, + "version_minor": 0 + }, + "text/plain": [ + "ImageWidget(children=(_AstroImage(children=(Figure(axes=[Axis(scale=LinearScale(allow_padding=False, max=1025.…" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "w" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "This next cell captures print outputs. You can pop it out like the viewer above. It is very convenient for debugging purpose." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [ + { + "ename": "AttributeError", + "evalue": "'ImageWidget' object has no attribute 'print_out'", + "output_type": "error", + "traceback": [ + "\u001b[0;31m---------------------------------------------------------------------------\u001b[0m", + "\u001b[0;31mAttributeError\u001b[0m Traceback (most recent call last)", + "\u001b[0;32m\u001b[0m in \u001b[0;36m\u001b[0;34m\u001b[0m\n\u001b[1;32m 1\u001b[0m \u001b[0;31m# Capture print outputs from the widget\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m----> 2\u001b[0;31m \u001b[0mdisplay\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mw\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mprint_out\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m", + "\u001b[0;31mAttributeError\u001b[0m: 'ImageWidget' object has no attribute 'print_out'" + ] + } + ], + "source": [ + "# Capture print outputs from the widget\n", + "display(w.print_out)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The following cell changes the visibility or position of the cursor info bar.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "top\n" + ] + } + ], + "source": [ + "w.cursor = 'top' # 'top', 'bottom', None\n", + "print(w.cursor)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# šŸ‘† ~FAILURE ABOVE~ FIXED! 😃 -- cursor does not move" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The rest of the calls demonstrate how the widget API works. Comment/uncomment as needed. Feel free to experiment." + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": {}, + "outputs": [], + "source": [ + "# Programmatically center to (X, Y) on viewer\n", + "w.center_on((1, 1))" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "metadata": {}, + "outputs": [], + "source": [ + "# Programmatically offset w.r.t. current center\n", + "w.offset_by(10, 10)" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "metadata": {}, + "outputs": [], + "source": [ + "from astropy.coordinates import SkyCoord\n", + "\n", + "# Change the values here if you are not using given\n", + "# example image.\n", + "ra_str = '01h13m23.193s'\n", + "dec_str = '+00d12m32.19s'\n", + "frame = 'galactic'\n", + "\n", + "# Programmatically center to SkyCoord on viewer\n", + "w.center_on(SkyCoord(ra_str, dec_str, frame=frame))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# šŸ‘† ~FAILURE ABOVE~ šŸ˜• -- issue is that the test image is in Galactic coordinates so either the frame needs to be galactic here or a different center is needed -- image moves completely out of view" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "metadata": {}, + "outputs": [], + "source": [ + "from astropy import units as u\n", + "\n", + "# Change the values if needed.\n", + "deg_offset = 0.1 * u.deg\n", + "\n", + "# Programmatically offset (in degrees) w.r.t.\n", + "# SkyCoord center\n", + "w.offset_by(deg_offset, deg_offset)" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "0.4878048780487805\n" + ] + } + ], + "source": [ + "# Show zoom level\n", + "print(w.zoom_level)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# šŸ‘† ~FAILURE ABOVE~ 😃 fixed! -- zoom_level should never be zero!" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "metadata": {}, + "outputs": [], + "source": [ + "w.zoom_level = 1" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# šŸ‘† ~FAILURE ABOVE~ -- setting zoom_level to 1 took image out of view -- šŸ˜• now it works, but not sure what I did to fix it\n", + "\n", + "with X: -349501.59 Y: -186964.83\n" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "metadata": {}, + "outputs": [], + "source": [ + "# Programmatically zoom image on viewer\n", + "w.zoom(2)" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "metadata": {}, + "outputs": [], + "source": [ + "# Capture what viewer is showing and save RGB image.\n", + "# Need https://github.com/ejeschke/ginga/pull/688 to work.\n", + "w.save('test.png', overwrite=True)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# šŸ‘† FAILURE ABOVE -- saving *downloads* the image but does not put it in the directory notebook is in" + ] + }, + { + "cell_type": "code", + "execution_count": 19, + "metadata": {}, + "outputs": [ + { + "ename": "AttributeError", + "evalue": "'ImageWidget' object has no attribute 'stretch_options'", + "output_type": "error", + "traceback": [ + "\u001b[0;31m---------------------------------------------------------------------------\u001b[0m", + "\u001b[0;31mAttributeError\u001b[0m Traceback (most recent call last)", + "\u001b[0;32m\u001b[0m in \u001b[0;36m\u001b[0;34m\u001b[0m\n\u001b[1;32m 1\u001b[0m \u001b[0;31m# Get all available image stretch options\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m----> 2\u001b[0;31m \u001b[0mprint\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mw\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mstretch_options\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m", + "\u001b[0;31mAttributeError\u001b[0m: 'ImageWidget' object has no attribute 'stretch_options'" + ] + } + ], + "source": [ + "# Get all available image stretch options\n", + "print(w.stretch_options)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# šŸ‘† FAILURE ABOVE -- There should be stretch_options" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n" + ] + } + ], + "source": [ + "# Get image stretch algorithm in use\n", + "print(w.stretch)" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "histeq\n" + ] + } + ], + "source": [ + "# Change the stretch\n", + "w.stretch = 'histeq'\n", + "print(w.stretch)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# šŸ‘† ~FAILURE ABOVE~ -- changing stretch does not change display -- šŸ˜• the change looks terrible but it does change" + ] + }, + { + "cell_type": "code", + "execution_count": 26, + "metadata": {}, + "outputs": [ + { + "ename": "AttributeError", + "evalue": "'ImageWidget' object has no attribute 'autocut_options'", + "output_type": "error", + "traceback": [ + "\u001b[0;31m---------------------------------------------------------------------------\u001b[0m", + "\u001b[0;31mAttributeError\u001b[0m Traceback (most recent call last)", + "\u001b[0;32m\u001b[0m in \u001b[0;36m\u001b[0;34m\u001b[0m\n\u001b[1;32m 1\u001b[0m \u001b[0;31m# Get all available image cuts options\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m----> 2\u001b[0;31m \u001b[0mprint\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mw\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mautocut_options\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m", + "\u001b[0;31mAttributeError\u001b[0m: 'ImageWidget' object has no attribute 'autocut_options'" + ] + } + ], + "source": [ + "# Get all available image cuts options\n", + "print(w.autocut_options)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# šŸ‘† FAILURE ABOVE -- There should be autocut options I guess" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n" + ] + } + ], + "source": [ + "# Get image cut levels in use\n", + "print(w.cuts)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# šŸ‘† FAILURE ABOVE -- this isn't *wrong* but maybe a __str__ would be nice" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "(10, 15)\n" + ] + } + ], + "source": [ + "# Change the cuts by providing explicit low/high values\n", + "w.cuts = (10, 15)\n", + "print(w.cuts)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# šŸ‘† ~FAILURE ABOVE~ -- yeah, it is a failure...works now, though šŸ¤·ā€ā™‚ļø HA HA HA NO -- fails again, WTF? -- AAAH, the issue was with whether the stretch had been set to something." + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "metadata": {}, + "outputs": [], + "source": [ + "w.stretch = 'log'" + ] + }, + { + "cell_type": "code", + "execution_count": 24, + "metadata": {}, + "outputs": [ + { + "ename": "ValueError", + "evalue": "Cut levels must be given as (low, high).zscale is not a valid value. cuts must be one of None, an astropy interval, or list/tuple of length 2.", + "output_type": "error", + "traceback": [ + "\u001b[0;31m---------------------------------------------------------------------------\u001b[0m", + "\u001b[0;31mValueError\u001b[0m Traceback (most recent call last)", + "\u001b[0;32m\u001b[0m in \u001b[0;36m\u001b[0;34m\u001b[0m\n\u001b[1;32m 1\u001b[0m \u001b[0;31m# Change the cuts with an autocut algorithm\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m----> 2\u001b[0;31m \u001b[0mw\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mcuts\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0;34m'zscale'\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 3\u001b[0m \u001b[0mprint\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mw\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mcuts\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n", + "\u001b[0;32m~/miniconda3/envs/awid-jlab3/lib/python3.9/site-packages/traitlets/traitlets.py\u001b[0m in \u001b[0;36m__set__\u001b[0;34m(self, obj, value)\u001b[0m\n\u001b[1;32m 602\u001b[0m \u001b[0;32mraise\u001b[0m \u001b[0mTraitError\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m'The \"%s\" trait is read-only.'\u001b[0m \u001b[0;34m%\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mname\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 603\u001b[0m \u001b[0;32melse\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m--> 604\u001b[0;31m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mset\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mobj\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mvalue\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 605\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 606\u001b[0m \u001b[0;32mdef\u001b[0m \u001b[0m_validate\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mself\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mobj\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mvalue\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n", + "\u001b[0;32m~/miniconda3/envs/awid-jlab3/lib/python3.9/site-packages/traitlets/traitlets.py\u001b[0m in \u001b[0;36mset\u001b[0;34m(self, obj, value)\u001b[0m\n\u001b[1;32m 576\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 577\u001b[0m \u001b[0;32mdef\u001b[0m \u001b[0mset\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mself\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mobj\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mvalue\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m--> 578\u001b[0;31m \u001b[0mnew_value\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0m_validate\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mobj\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mvalue\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 579\u001b[0m \u001b[0;32mtry\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 580\u001b[0m \u001b[0mold_value\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mobj\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0m_trait_values\u001b[0m\u001b[0;34m[\u001b[0m\u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mname\u001b[0m\u001b[0;34m]\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n", + "\u001b[0;32m~/miniconda3/envs/awid-jlab3/lib/python3.9/site-packages/traitlets/traitlets.py\u001b[0m in \u001b[0;36m_validate\u001b[0;34m(self, obj, value)\u001b[0m\n\u001b[1;32m 610\u001b[0m \u001b[0mvalue\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mvalidate\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mobj\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mvalue\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 611\u001b[0m \u001b[0;32mif\u001b[0m \u001b[0mobj\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0m_cross_validation_lock\u001b[0m \u001b[0;32mis\u001b[0m \u001b[0;32mFalse\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m--> 612\u001b[0;31m \u001b[0mvalue\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0m_cross_validate\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mobj\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mvalue\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 613\u001b[0m \u001b[0;32mreturn\u001b[0m \u001b[0mvalue\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 614\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n", + "\u001b[0;32m~/miniconda3/envs/awid-jlab3/lib/python3.9/site-packages/traitlets/traitlets.py\u001b[0m in \u001b[0;36m_cross_validate\u001b[0;34m(self, obj, value)\u001b[0m\n\u001b[1;32m 616\u001b[0m \u001b[0;32mif\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mname\u001b[0m \u001b[0;32min\u001b[0m \u001b[0mobj\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0m_trait_validators\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 617\u001b[0m \u001b[0mproposal\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mBunch\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m{\u001b[0m\u001b[0;34m'trait'\u001b[0m\u001b[0;34m:\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0;34m'value'\u001b[0m\u001b[0;34m:\u001b[0m \u001b[0mvalue\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0;34m'owner'\u001b[0m\u001b[0;34m:\u001b[0m \u001b[0mobj\u001b[0m\u001b[0;34m}\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m--> 618\u001b[0;31m \u001b[0mvalue\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mobj\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0m_trait_validators\u001b[0m\u001b[0;34m[\u001b[0m\u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mname\u001b[0m\u001b[0;34m]\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mobj\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mproposal\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 619\u001b[0m \u001b[0;32melif\u001b[0m \u001b[0mhasattr\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mobj\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0;34m'_%s_validate'\u001b[0m \u001b[0;34m%\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mname\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 620\u001b[0m \u001b[0mmeth_name\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0;34m'_%s_validate'\u001b[0m \u001b[0;34m%\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mname\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n", + "\u001b[0;32m~/miniconda3/envs/awid-jlab3/lib/python3.9/site-packages/traitlets/traitlets.py\u001b[0m in \u001b[0;36m__call__\u001b[0;34m(self, *args, **kwargs)\u001b[0m\n\u001b[1;32m 973\u001b[0m \u001b[0;34m\"\"\"Pass `*args` and `**kwargs` to the handler's function if it exists.\"\"\"\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 974\u001b[0m \u001b[0;32mif\u001b[0m \u001b[0mhasattr\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mself\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0;34m'func'\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m--> 975\u001b[0;31m \u001b[0;32mreturn\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mfunc\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m*\u001b[0m\u001b[0margs\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0;34m**\u001b[0m\u001b[0mkwargs\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 976\u001b[0m \u001b[0;32melse\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 977\u001b[0m \u001b[0;32mreturn\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0m_init_call\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m*\u001b[0m\u001b[0margs\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0;34m**\u001b[0m\u001b[0mkwargs\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n", + "\u001b[0;32m~/development/astronomy/astrowidgets/astrowidgets/bqplot.py\u001b[0m in \u001b[0;36m_validate_cuts\u001b[0;34m(self, proposal)\u001b[0m\n\u001b[1;32m 578\u001b[0m \u001b[0mlength\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mlen\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mproposed_cuts\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 579\u001b[0m \u001b[0;32mif\u001b[0m \u001b[0mlength\u001b[0m \u001b[0;34m!=\u001b[0m \u001b[0;36m2\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m--> 580\u001b[0;31m raise ValueError('Cut levels must be given as (low, high).'\n\u001b[0m\u001b[1;32m 581\u001b[0m + bad_value_error)\n\u001b[1;32m 582\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n", + "\u001b[0;31mValueError\u001b[0m: Cut levels must be given as (low, high).zscale is not a valid value. cuts must be one of None, an astropy interval, or list/tuple of length 2." + ] + } + ], + "source": [ + "# Change the cuts with an autocut algorithm\n", + "w.cuts = 'zscale'\n", + "print(w.cuts)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# šŸ‘† FAILURE ABOVE -- yeah, it is a failure..." + ] + }, + { + "cell_type": "code", + "execution_count": 19, + "metadata": {}, + "outputs": [], + "source": [ + "# This enables click to center.\n", + "w.click_center = True" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Now, click on the image to center it." + ] + }, + { + "cell_type": "markdown", + "metadata": { + "tags": [] + }, + "source": [ + "# šŸ‘† ~FAILURE ABOVE~ -- clicking does nothing -- FIXED 😃\n", + "\n", + "Actually, I knew this would be the case...." + ] + }, + { + "cell_type": "code", + "execution_count": 20, + "metadata": {}, + "outputs": [], + "source": [ + "# Turn it back off so marking (next cell) can be done.\n", + "w.click_center = False" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "True\n" + ] + } + ], + "source": [ + "# This enables marking mode.\n", + "w.start_marking()\n", + "print(w.is_marking)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# šŸ‘† ~FAILURE ABOVE~ 😃 Fixed -- yeah, it is a failure..." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Now, click on the image to mark a point of interest." + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "False\n" + ] + } + ], + "source": [ + "# When done, set back to False.\n", + "w.stop_marking()\n", + "print(w.is_marking)" + ] + }, + { + "cell_type": "code", + "execution_count": 21, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + " X Y Coordinates \n", + " 119.00 242.00 01h13m23.32s +00d12m37.8s\n", + " 691.00 209.00 01h12m37.56s +00d11m58.2s\n", + " 710.00 305.00 01h12m36.04s +00d13m53.4s\n", + " 68.00 291.00 01h13m27.4s +00d13m36.6s\n" + ] + } + ], + "source": [ + "# Get table of markers\n", + "markers_table = w.get_all_markers()\n", + "\n", + "# Default display might be hard to read, so we do this\n", + "print(f'{\"X\":^8s} {\"Y\":^8s} {\"Coordinates\":^28s}')\n", + "for row in markers_table:\n", + " c = row['coord'].to_string('hmsdms')\n", + " print(f'{row[\"x\"]:8.2f} {row[\"y\"]:8.2f} {c}')" + ] + }, + { + "cell_type": "code", + "execution_count": 55, + "metadata": {}, + "outputs": [], + "source": [ + "# Erase markers from display\n", + "w.remove_all_markers()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The following works even when we have set `w.is_marking=False`. This is because `w.is_marking` only controls the interactive marking and does not affect marking programmatically." + ] + }, + { + "cell_type": "code", + "execution_count": 44, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/Users/mattcraig/miniconda3/envs/awid-jlab3/lib/python3.9/site-packages/traittypes/traittypes.py:97: UserWarning: Given trait value dtype \"int32\" does not match required type \"float64\". A coerced copy has been created.\n", + " warnings.warn(\n" + ] + } + ], + "source": [ + "# Programmatically re-mark from table using X, Y.\n", + "# To be fancy, first 2 points marked as bigger\n", + "# and thicker red circles.\n", + "w.marker = {'type': 'circle', 'color': 'red', 'radius': 50,\n", + " 'linewidth': 2}\n", + "# šŸ‘‡šŸ‘‡šŸ‘‡šŸ‘‡šŸ‘‡šŸ‘‡ HAD TO ADD DIFFERENT MARKER NAMES FOR THIS TO WORK šŸ‘‡šŸ‘‡šŸ‘‡šŸ‘‡\n", + "w.add_markers(markers_table[:2], marker_name='first')\n", + "# You can also change the type of marker to cross or plus\n", + "w.marker = {'type': 'cross', 'color': 'cyan', 'radius': 20}\n", + "w.add_markers(markers_table[2:], marker_name='second')" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## šŸ‘† FAILURE -- first two are not showing up as big red circles" + ] + }, + { + "cell_type": "code", + "execution_count": 37, + "metadata": {}, + "outputs": [ + { + "ename": "TypeError", + "evalue": "'NoneType' object is not subscriptable", + "output_type": "error", + "traceback": [ + "\u001b[0;31m---------------------------------------------------------------------------\u001b[0m", + "\u001b[0;31mTypeError\u001b[0m Traceback (most recent call last)", + "\u001b[0;32m\u001b[0m in \u001b[0;36m\u001b[0;34m\u001b[0m\n\u001b[1;32m 3\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 4\u001b[0m \u001b[0;31m# Programmatically re-mark from table using SkyCoord\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m----> 5\u001b[0;31m \u001b[0mw\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0madd_markers\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mmarkers_table\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0muse_skycoord\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0;32mTrue\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m", + "\u001b[0;32m~/development/astronomy/astrowidgets/astrowidgets/bqplot.py\u001b[0m in \u001b[0;36madd_markers\u001b[0;34m(self, table, x_colname, y_colname, skycoord_colname, use_skycoord, marker_name)\u001b[0m\n\u001b[1;32m 680\u001b[0m 'world coordinates for markers.')\n\u001b[1;32m 681\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m--> 682\u001b[0;31m \u001b[0mx\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0my\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0m_wcs\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mworld_to_pixel\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mtable\u001b[0m\u001b[0;34m[\u001b[0m\u001b[0mskycoord_colname\u001b[0m\u001b[0;34m]\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 683\u001b[0m \u001b[0;32melse\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 684\u001b[0m \u001b[0mx\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mtable\u001b[0m\u001b[0;34m[\u001b[0m\u001b[0mx_colname\u001b[0m\u001b[0;34m]\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n", + "\u001b[0;31mTypeError\u001b[0m: 'NoneType' object is not subscriptable" + ] + } + ], + "source": [ + "# Erase them again\n", + "w.remove_all_markers()\n", + "\n", + "# Programmatically re-mark from table using SkyCoord\n", + "w.add_markers(markers_table, use_skycoord=True)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Start marking again\n", + "w.start_marking()\n", + "print(w.is_marking)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Stop marking AND clear markers.\n", + "# Note that this deletes ALL of the markers\n", + "w.stop_marking(clear_markers=True)\n", + "print(w.is_marking)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The next cell randomly generates some \"stars\" to mark. In the real world, you would probably detect real stars using `photutils` package." + ] + }, + { + "cell_type": "code", + "execution_count": 53, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + " x y \n", + "----- -----\n", + "141.0 451.0\n", + "941.0 239.0\n", + "195.0 321.0\n", + "327.0 287.0\n", + "108.0 101.0\n", + "919.0 352.0\n", + "331.0 194.0\n", + "533.0 464.0\n", + "322.0 298.0\n", + "853.0 398.0\n", + " ... ...\n", + "697.0 47.0\n", + "437.0 327.0\n", + "138.0 393.0\n", + "420.0 335.0\n", + "744.0 445.0\n", + "964.0 480.0\n", + "769.0 340.0\n", + "149.0 302.0\n", + "979.0 399.0\n", + "478.0 423.0\n", + "789.0 153.0\n", + "Length = 482 rows\n" + ] + } + ], + "source": [ + "import numpy as np\n", + "from astropy.table import Table\n", + "\n", + "# Maximum umber of \"stars\" to generate randomly.\n", + "max_stars = 1000\n", + "\n", + "# Number of pixels from edge to avoid.\n", + "dpix = 20\n", + "\n", + "# Image from the viewer.\n", + "img = w._data #w.viewer.get_image()\n", + "\n", + "# Random \"stars\" generated.\n", + "bad_locs = np.random.randint(\n", + " dpix, high=img.shape[1] - dpix, size=[max_stars, 2])\n", + "\n", + "# Only want those not near the edges.\n", + "mask = ((dpix < bad_locs[:, 0]) &\n", + " (bad_locs[:, 0] < img.shape[0] - dpix) &\n", + " (dpix < bad_locs[:, 1]) &\n", + " (bad_locs[:, 1] < img.shape[1] - dpix))\n", + "locs = bad_locs[mask]\n", + "\n", + "# Put them in table\n", + "t = Table([locs[:, 1], locs[:, 0]], names=('x', 'y'), dtype=('float', 'float'))\n", + "print(t)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# šŸ‘† FAILURE ABOVE -- to be fair, this is relying on an implementation detail of ginga\n", + "\n", + "Fixed temporarily by changing `img`" + ] + }, + { + "cell_type": "code", + "execution_count": 56, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/Users/mattcraig/miniconda3/envs/awid-jlab3/lib/python3.9/site-packages/traittypes/traittypes.py:97: UserWarning: Given trait value dtype \"int32\" does not match required type \"float64\". A coerced copy has been created.\n", + " warnings.warn(\n" + ] + } + ], + "source": [ + "# Mark those \"stars\" based on given table with X and Y.\n", + "w.add_markers(t)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# šŸ‘† ~FAILURE ABOVE~ 😃 fixed! -- Really?! Does anything in here work?! This _should_ have been caught by the tests.... 😃 it is now!" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The following illustrates how to control number of markers displayed using interactive widget from `ipywidgets`." + ] + }, + { + "cell_type": "code", + "execution_count": 58, + "metadata": {}, + "outputs": [], + "source": [ + "# Set the marker properties as you like.\n", + "w.marker = {'type': 'circle', 'color': 'red', 'radius': 10,\n", + " 'linewidth': 2}\n", + "\n", + "# Define a function to control marker display\n", + "def show_circles(n):\n", + " \"\"\"Show and hide circles.\"\"\"\n", + " w.remove_all_markers()\n", + " t2show = t[:n]\n", + " w.add_markers(t2show)\n", + " with w.print_out:\n", + " print('Displaying {} markers...'.format(len(t2show)))" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "We redisplay the image widget below above the slider. Note that the slider affects both this view of the image widget and the one near the top of the notebook." + ] + }, + { + "cell_type": "code", + "execution_count": 59, + "metadata": {}, + "outputs": [ + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "446131d7e9a24639b5a1be203c32e159", + "version_major": 2, + "version_minor": 0 + }, + "text/plain": [ + "ImageWidget(children=(_AstroImage(children=(Figure(axes=[Axis(scale=LinearScale(allow_padding=False, max=1022.…" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "application/vnd.jupyter.widget-view+json": { + "model_id": "51d32b6b02de4103b39e2ef757e3bc49", + "version_major": 2, + "version_minor": 0 + }, + "text/plain": [ + "interactive(children=(IntSlider(value=0, continuous_update=False, description='n', max=482), Output()), _dom_c…" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "from IPython.display import display\n", + "\n", + "import ipywidgets as ipyw\n", + "from ipywidgets import interactive\n", + "\n", + "# Show the slider widget.\n", + "slider = interactive(show_circles,\n", + " n=ipyw.IntSlider(min=0,max=len(t),step=1,value=0, continuous_update=False))\n", + "display(w, slider)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Now, use the slider. The chosen `n` represents the first `n` \"stars\" being displayed." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.9.5" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} From 838d027312c777150daba57e780de4226c20545c Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Thu, 4 May 2023 19:09:42 -0400 Subject: [PATCH 38/50] Propogate marker API changes --- astrowidgets/interface_definition.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/astrowidgets/interface_definition.py b/astrowidgets/interface_definition.py index 9a5bf45..a0e7d1b 100644 --- a/astrowidgets/interface_definition.py +++ b/astrowidgets/interface_definition.py @@ -52,20 +52,20 @@ def add_markers(self): raise NotImplementedError @abstractmethod - def get_markers(self): + def remove_all_markers(self): raise NotImplementedError @abstractmethod - def remove_markers(self): + def remove_markers_by_name(self, marker_name=None): raise NotImplementedError - # @abstractmethod - # def get_all_markers(self): - # raise NotImplementedError + @abstractmethod + def get_all_markers(self): + raise NotImplementedError - # @abstractmethod - # def get_markers_by_name(self, marker_name=None): - # raise NotImplementedError + @abstractmethod + def get_markers_by_name(self, marker_name=None): + raise NotImplementedError # Methods that modify the view @abstractmethod @@ -73,7 +73,7 @@ def center_on(self): raise NotImplementedError @abstractmethod - def offset_to(self): + def offset_by(self): raise NotImplementedError @abstractmethod From da4fd3f4389b35569fdfb037b7941a462bd9ee3f Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Thu, 4 May 2023 19:10:36 -0400 Subject: [PATCH 39/50] Use proper numpy type names --- astrowidgets/bqplot.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/astrowidgets/bqplot.py b/astrowidgets/bqplot.py index b3ce0da..4d09123 100644 --- a/astrowidgets/bqplot.py +++ b/astrowidgets/bqplot.py @@ -326,7 +326,7 @@ def __init__(self): def _init_table(self): self._table = Table(names=(self._xcol, self._ycol, self._names), - dtype=('int32', 'int32', 'str')) + dtype=(np.float64, np.float64, 'str')) @property def xcol(self): From a196d29ba609c655fa35e487d007999848647d9c Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Thu, 4 May 2023 19:10:47 -0400 Subject: [PATCH 40/50] Finish most of API implementation --- astrowidgets/bqplot.py | 35 +++++++++++++++++++++-------------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/astrowidgets/bqplot.py b/astrowidgets/bqplot.py index 4d09123..772287b 100644 --- a/astrowidgets/bqplot.py +++ b/astrowidgets/bqplot.py @@ -414,6 +414,7 @@ def __init__(self, *args, image_width=500, image_height=500): self._wcs = None self._is_marking = False + self.scroll_pan = True # Use this to manage whether or not to send changes in zoom level # to the viewer. self._zoom_source_is_gui = False @@ -422,6 +423,10 @@ def __init__(self, *args, image_width=500, image_height=500): # updating the zoom. self._updating_zoom = False + # Provide an Output widget to which prints can be directed for + # debugging. + self._print_out = ipw.Output() + self.marker = {'color': 'red', 'radius': 20, 'type': 'square'} self.cuts = apviz.AsymmetricPercentileInterval(1, 99) @@ -776,15 +781,6 @@ def set_colormap(self, cmap_name, reverse=False): def colormap_options(self): return pyplot.colormaps() - # # Marker-related methods - # @abstractmethod - # def start_marking(self): - # raise NotImplementedError - - # @abstractmethod - # def stop_marking(self): - # raise NotImplementedError - def _validate_marker_name(self, marker_name): if marker_name in self.RESERVED_MARKER_SET_NAMES: raise ValueError( @@ -818,11 +814,12 @@ def add_markers(self, table, x_colname='x', y_colname='y', # the same name to be plotted at once. marks = self.get_markers_by_name(marker_name) - self._astro_im.plot_named_markers(marks['x'], marks['y'], - marker_name, - color=self.marker['color'], - size=self.marker['radius']**2, - style=self.marker['type']) + if marks: + self._astro_im.plot_named_markers(marks['x'], marks['y'], + marker_name, + color=self.marker['color'], + size=self.marker['radius']**2, + style=self.marker['type']) def remove_markers_by_name(self, marker_name): # Remove from our tracking table @@ -1020,3 +1017,13 @@ def restore_and_clear_cached_state(self): self.click_drag = self._cached_state['click_drag'] self.scroll_pan = self._cached_state['scroll_pan'] self._cached_state = {} + + @property + def print_out(self): + """ + Return an output widget for display in the notebook which + captures any printed output produced by the viewer widget. + + Intended primarily for debugging. + """ + return self._print_out From bf3f0bb14171fd15533f35f92eafc71db0a5f613 Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Thu, 4 May 2023 19:11:11 -0400 Subject: [PATCH 41/50] Clean up tests --- astrowidgets/tests/test_bqplot_api.py | 29 +++++++++++++++++---------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/astrowidgets/tests/test_bqplot_api.py b/astrowidgets/tests/test_bqplot_api.py index f4f8299..16c28a9 100644 --- a/astrowidgets/tests/test_bqplot_api.py +++ b/astrowidgets/tests/test_bqplot_api.py @@ -2,6 +2,8 @@ import pytest +from traitlets.traitlets import TraitError + from astropy.io import fits from astropy.nddata import NDData from astropy.table import Table @@ -43,11 +45,11 @@ def test_center_on(): image.center_on((x, y)) -def test_offset_to(): +def test_offset_by(): image = ImageWidget() dx = 10 dy = 10 - image.offset_to(dx, dy) + image.offset_by(dx, dy) def test_zoom_level(): @@ -72,7 +74,7 @@ def test_select_points(): def test_get_selection(): image = ImageWidget() - marks = image.get_markers() + marks = image.get_all_markers() assert isinstance(marks, Table) or marks is None @@ -80,7 +82,7 @@ def test_stop_marking(): image = ImageWidget() # This is not much of a test... image.stop_marking(clear_markers=True) - assert image.get_markers() is None + assert image.get_all_markers() is None assert image.is_marking is False @@ -147,7 +149,7 @@ def test_set_markers(): assert '10' in str(image.marker) -def test_reset_markers(): +def test_remove_all_markers(): image = ImageWidget() # First test: this shouldn't raise any errors # (it also doesn't *do* anything...) @@ -159,11 +161,11 @@ def test_reset_markers(): skycoord_colname='coord', marker_name='test') image.add_markers(table, x_colname='x', y_colname='y', skycoord_colname='coord', marker_name='test2') - image.reset_markers() + image.remove_all_markers() with pytest.raises(ValueError): - image.get_markers(marker_name='test') + image.get_markers_by_name(marker_name='test') with pytest.raises(ValueError): - image.get_markers(marker_name='test2') + image.get_markers_by_name(marker_name='test2') def test_remove_markers_by_name(): @@ -183,10 +185,15 @@ def test_remove_markers_by_name(): def test_stretch(): image = ImageWidget() + data = np.random.random([100, 100]) + # image.load_array(data) with pytest.raises(ValueError) as e: image.stretch = 'not a valid value' assert 'must be one of' in str(e.value) - + # šŸ‘‡šŸ‘‡šŸ‘‡šŸ‘‡ handle this case more gracefully šŸ‘‡šŸ‘‡šŸ‘‡ + # (no data set, so finding the limits fails) + # (should probably record the choice, then apply) + # (when data is loaded) image.stretch = 'log' assert isinstance(image.stretch, (BaseStretch, str)) @@ -230,7 +237,7 @@ def test_colormap(): def test_cursor(): image = ImageWidget() assert image.cursor in ALLOWED_CURSOR_LOCATIONS - with pytest.raises(ValueError): + with pytest.raises(TraitError): image.cursor = 'not a valid option' image.cursor = 'bottom' assert image.cursor == 'bottom' @@ -294,7 +301,7 @@ def test_scroll_pan(): def test_save(): image = ImageWidget() filename = 'woot.png' - image.save(filename) + image.save(filename, overwrite=True) def test_width_height(): From 5fd58a313aae37f4a8710b16f563bcf462b502b6 Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Thu, 4 May 2023 19:12:30 -0400 Subject: [PATCH 42/50] Remove cell outputs in sample notebook --- example_notebooks/ginga/bqplot_widget.ipynb | 400 +++++--------------- 1 file changed, 101 insertions(+), 299 deletions(-) diff --git a/example_notebooks/ginga/bqplot_widget.ipynb b/example_notebooks/ginga/bqplot_widget.ipynb index d305e0f..acf9613 100644 --- a/example_notebooks/ginga/bqplot_widget.ipynb +++ b/example_notebooks/ginga/bqplot_widget.ipynb @@ -16,16 +16,17 @@ }, { "cell_type": "code", - "execution_count": 1, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ - "from astrowidgets.bqplot import ImageWidget" + "from astrowidgets.bqplot import ImageWidget\n", + "from sidecar import Sidecar" ] }, { "cell_type": "code", - "execution_count": 2, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -37,7 +38,7 @@ }, { "cell_type": "code", - "execution_count": 3, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -66,7 +67,7 @@ }, { "cell_type": "code", - "execution_count": 4, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -120,24 +121,9 @@ }, { "cell_type": "code", - "execution_count": 5, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "application/vnd.jupyter.widget-view+json": { - "model_id": "446131d7e9a24639b5a1be203c32e159", - "version_major": 2, - "version_minor": 0 - }, - "text/plain": [ - "ImageWidget(children=(_AstroImage(children=(Figure(axes=[Axis(scale=LinearScale(allow_padding=False, max=1025.…" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "w" ] @@ -151,26 +137,32 @@ }, { "cell_type": "code", - "execution_count": 6, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "ename": "AttributeError", - "evalue": "'ImageWidget' object has no attribute 'print_out'", - "output_type": "error", - "traceback": [ - "\u001b[0;31m---------------------------------------------------------------------------\u001b[0m", - "\u001b[0;31mAttributeError\u001b[0m Traceback (most recent call last)", - "\u001b[0;32m\u001b[0m in \u001b[0;36m\u001b[0;34m\u001b[0m\n\u001b[1;32m 1\u001b[0m \u001b[0;31m# Capture print outputs from the widget\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m----> 2\u001b[0;31m \u001b[0mdisplay\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mw\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mprint_out\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m", - "\u001b[0;31mAttributeError\u001b[0m: 'ImageWidget' object has no attribute 'print_out'" - ] - } - ], + "outputs": [], "source": [ "# Capture print outputs from the widget\n", "display(w.print_out)" ] }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "s = Sidecar(name='output')\n", + "with s:\n", + " display(w.print_out)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# šŸ‘† ~FAILURE ABOVE~ FIXED! 😃 -- no print_out thing" + ] + }, { "cell_type": "markdown", "metadata": {}, @@ -180,17 +172,9 @@ }, { "cell_type": "code", - "execution_count": 6, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "top\n" - ] - } - ], + "outputs": [], "source": [ "w.cursor = 'top' # 'top', 'bottom', None\n", "print(w.cursor)" @@ -212,7 +196,7 @@ }, { "cell_type": "code", - "execution_count": 7, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -222,7 +206,7 @@ }, { "cell_type": "code", - "execution_count": 8, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -232,7 +216,7 @@ }, { "cell_type": "code", - "execution_count": 9, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -257,7 +241,7 @@ }, { "cell_type": "code", - "execution_count": 10, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -273,17 +257,9 @@ }, { "cell_type": "code", - "execution_count": 11, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "0.4878048780487805\n" - ] - } - ], + "outputs": [], "source": [ "# Show zoom level\n", "print(w.zoom_level)" @@ -298,7 +274,7 @@ }, { "cell_type": "code", - "execution_count": 12, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -316,7 +292,7 @@ }, { "cell_type": "code", - "execution_count": 13, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -326,7 +302,7 @@ }, { "cell_type": "code", - "execution_count": 14, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -344,21 +320,9 @@ }, { "cell_type": "code", - "execution_count": 19, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "ename": "AttributeError", - "evalue": "'ImageWidget' object has no attribute 'stretch_options'", - "output_type": "error", - "traceback": [ - "\u001b[0;31m---------------------------------------------------------------------------\u001b[0m", - "\u001b[0;31mAttributeError\u001b[0m Traceback (most recent call last)", - "\u001b[0;32m\u001b[0m in \u001b[0;36m\u001b[0;34m\u001b[0m\n\u001b[1;32m 1\u001b[0m \u001b[0;31m# Get all available image stretch options\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m----> 2\u001b[0;31m \u001b[0mprint\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mw\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mstretch_options\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m", - "\u001b[0;31mAttributeError\u001b[0m: 'ImageWidget' object has no attribute 'stretch_options'" - ] - } - ], + "outputs": [], "source": [ "# Get all available image stretch options\n", "print(w.stretch_options)" @@ -373,17 +337,9 @@ }, { "cell_type": "code", - "execution_count": 14, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "\n" - ] - } - ], + "outputs": [], "source": [ "# Get image stretch algorithm in use\n", "print(w.stretch)" @@ -391,17 +347,9 @@ }, { "cell_type": "code", - "execution_count": 15, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "histeq\n" - ] - } - ], + "outputs": [], "source": [ "# Change the stretch\n", "w.stretch = 'histeq'\n", @@ -417,21 +365,9 @@ }, { "cell_type": "code", - "execution_count": 26, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "ename": "AttributeError", - "evalue": "'ImageWidget' object has no attribute 'autocut_options'", - "output_type": "error", - "traceback": [ - "\u001b[0;31m---------------------------------------------------------------------------\u001b[0m", - "\u001b[0;31mAttributeError\u001b[0m Traceback (most recent call last)", - "\u001b[0;32m\u001b[0m in \u001b[0;36m\u001b[0;34m\u001b[0m\n\u001b[1;32m 1\u001b[0m \u001b[0;31m# Get all available image cuts options\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m----> 2\u001b[0;31m \u001b[0mprint\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mw\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mautocut_options\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m", - "\u001b[0;31mAttributeError\u001b[0m: 'ImageWidget' object has no attribute 'autocut_options'" - ] - } - ], + "outputs": [], "source": [ "# Get all available image cuts options\n", "print(w.autocut_options)" @@ -446,17 +382,9 @@ }, { "cell_type": "code", - "execution_count": 14, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "\n" - ] - } - ], + "outputs": [], "source": [ "# Get image cut levels in use\n", "print(w.cuts)" @@ -471,17 +399,9 @@ }, { "cell_type": "code", - "execution_count": 15, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "(10, 15)\n" - ] - } - ], + "outputs": [], "source": [ "# Change the cuts by providing explicit low/high values\n", "w.cuts = (10, 15)\n", @@ -497,7 +417,7 @@ }, { "cell_type": "code", - "execution_count": 16, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -506,27 +426,9 @@ }, { "cell_type": "code", - "execution_count": 24, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "ename": "ValueError", - "evalue": "Cut levels must be given as (low, high).zscale is not a valid value. cuts must be one of None, an astropy interval, or list/tuple of length 2.", - "output_type": "error", - "traceback": [ - "\u001b[0;31m---------------------------------------------------------------------------\u001b[0m", - "\u001b[0;31mValueError\u001b[0m Traceback (most recent call last)", - "\u001b[0;32m\u001b[0m in \u001b[0;36m\u001b[0;34m\u001b[0m\n\u001b[1;32m 1\u001b[0m \u001b[0;31m# Change the cuts with an autocut algorithm\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m----> 2\u001b[0;31m \u001b[0mw\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mcuts\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0;34m'zscale'\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 3\u001b[0m \u001b[0mprint\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mw\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mcuts\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n", - "\u001b[0;32m~/miniconda3/envs/awid-jlab3/lib/python3.9/site-packages/traitlets/traitlets.py\u001b[0m in \u001b[0;36m__set__\u001b[0;34m(self, obj, value)\u001b[0m\n\u001b[1;32m 602\u001b[0m \u001b[0;32mraise\u001b[0m \u001b[0mTraitError\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m'The \"%s\" trait is read-only.'\u001b[0m \u001b[0;34m%\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mname\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 603\u001b[0m \u001b[0;32melse\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m--> 604\u001b[0;31m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mset\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mobj\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mvalue\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 605\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 606\u001b[0m \u001b[0;32mdef\u001b[0m \u001b[0m_validate\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mself\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mobj\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mvalue\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n", - "\u001b[0;32m~/miniconda3/envs/awid-jlab3/lib/python3.9/site-packages/traitlets/traitlets.py\u001b[0m in \u001b[0;36mset\u001b[0;34m(self, obj, value)\u001b[0m\n\u001b[1;32m 576\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 577\u001b[0m \u001b[0;32mdef\u001b[0m \u001b[0mset\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mself\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mobj\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mvalue\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m--> 578\u001b[0;31m \u001b[0mnew_value\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0m_validate\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mobj\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mvalue\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 579\u001b[0m \u001b[0;32mtry\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 580\u001b[0m \u001b[0mold_value\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mobj\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0m_trait_values\u001b[0m\u001b[0;34m[\u001b[0m\u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mname\u001b[0m\u001b[0;34m]\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n", - "\u001b[0;32m~/miniconda3/envs/awid-jlab3/lib/python3.9/site-packages/traitlets/traitlets.py\u001b[0m in \u001b[0;36m_validate\u001b[0;34m(self, obj, value)\u001b[0m\n\u001b[1;32m 610\u001b[0m \u001b[0mvalue\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mvalidate\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mobj\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mvalue\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 611\u001b[0m \u001b[0;32mif\u001b[0m \u001b[0mobj\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0m_cross_validation_lock\u001b[0m \u001b[0;32mis\u001b[0m \u001b[0;32mFalse\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m--> 612\u001b[0;31m \u001b[0mvalue\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0m_cross_validate\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mobj\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mvalue\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 613\u001b[0m \u001b[0;32mreturn\u001b[0m \u001b[0mvalue\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 614\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n", - "\u001b[0;32m~/miniconda3/envs/awid-jlab3/lib/python3.9/site-packages/traitlets/traitlets.py\u001b[0m in \u001b[0;36m_cross_validate\u001b[0;34m(self, obj, value)\u001b[0m\n\u001b[1;32m 616\u001b[0m \u001b[0;32mif\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mname\u001b[0m \u001b[0;32min\u001b[0m \u001b[0mobj\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0m_trait_validators\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 617\u001b[0m \u001b[0mproposal\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mBunch\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m{\u001b[0m\u001b[0;34m'trait'\u001b[0m\u001b[0;34m:\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0;34m'value'\u001b[0m\u001b[0;34m:\u001b[0m \u001b[0mvalue\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0;34m'owner'\u001b[0m\u001b[0;34m:\u001b[0m \u001b[0mobj\u001b[0m\u001b[0;34m}\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m--> 618\u001b[0;31m \u001b[0mvalue\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mobj\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0m_trait_validators\u001b[0m\u001b[0;34m[\u001b[0m\u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mname\u001b[0m\u001b[0;34m]\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mobj\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0mproposal\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 619\u001b[0m \u001b[0;32melif\u001b[0m \u001b[0mhasattr\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mobj\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0;34m'_%s_validate'\u001b[0m \u001b[0;34m%\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mname\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 620\u001b[0m \u001b[0mmeth_name\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0;34m'_%s_validate'\u001b[0m \u001b[0;34m%\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mname\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n", - "\u001b[0;32m~/miniconda3/envs/awid-jlab3/lib/python3.9/site-packages/traitlets/traitlets.py\u001b[0m in \u001b[0;36m__call__\u001b[0;34m(self, *args, **kwargs)\u001b[0m\n\u001b[1;32m 973\u001b[0m \u001b[0;34m\"\"\"Pass `*args` and `**kwargs` to the handler's function if it exists.\"\"\"\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 974\u001b[0m \u001b[0;32mif\u001b[0m \u001b[0mhasattr\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mself\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0;34m'func'\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m--> 975\u001b[0;31m \u001b[0;32mreturn\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mfunc\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m*\u001b[0m\u001b[0margs\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0;34m**\u001b[0m\u001b[0mkwargs\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 976\u001b[0m \u001b[0;32melse\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 977\u001b[0m \u001b[0;32mreturn\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0m_init_call\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0;34m*\u001b[0m\u001b[0margs\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0;34m**\u001b[0m\u001b[0mkwargs\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n", - "\u001b[0;32m~/development/astronomy/astrowidgets/astrowidgets/bqplot.py\u001b[0m in \u001b[0;36m_validate_cuts\u001b[0;34m(self, proposal)\u001b[0m\n\u001b[1;32m 578\u001b[0m \u001b[0mlength\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mlen\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mproposed_cuts\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 579\u001b[0m \u001b[0;32mif\u001b[0m \u001b[0mlength\u001b[0m \u001b[0;34m!=\u001b[0m \u001b[0;36m2\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m--> 580\u001b[0;31m raise ValueError('Cut levels must be given as (low, high).'\n\u001b[0m\u001b[1;32m 581\u001b[0m + bad_value_error)\n\u001b[1;32m 582\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n", - "\u001b[0;31mValueError\u001b[0m: Cut levels must be given as (low, high).zscale is not a valid value. cuts must be one of None, an astropy interval, or list/tuple of length 2." - ] - } - ], + "outputs": [], "source": [ "# Change the cuts with an autocut algorithm\n", "w.cuts = 'zscale'\n", @@ -542,7 +444,7 @@ }, { "cell_type": "code", - "execution_count": 19, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -570,7 +472,7 @@ }, { "cell_type": "code", - "execution_count": 20, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -580,17 +482,9 @@ }, { "cell_type": "code", - "execution_count": 16, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "True\n" - ] - } - ], + "outputs": [], "source": [ "# This enables marking mode.\n", "w.start_marking()\n", @@ -613,40 +507,27 @@ }, { "cell_type": "code", - "execution_count": 17, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "False\n" - ] - } - ], + "outputs": [], "source": [ "# When done, set back to False.\n", "w.stop_marking()\n", "print(w.is_marking)" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# šŸ‘†~FAILURE ABOVE~ FIXED 😃 -- `stop_marking` breaks zoom " + ] + }, { "cell_type": "code", - "execution_count": 21, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - " X Y Coordinates \n", - " 119.00 242.00 01h13m23.32s +00d12m37.8s\n", - " 691.00 209.00 01h12m37.56s +00d11m58.2s\n", - " 710.00 305.00 01h12m36.04s +00d13m53.4s\n", - " 68.00 291.00 01h13m27.4s +00d13m36.6s\n" - ] - } - ], + "outputs": [], "source": [ "# Get table of markers\n", "markers_table = w.get_all_markers()\n", @@ -660,7 +541,7 @@ }, { "cell_type": "code", - "execution_count": 55, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -677,26 +558,21 @@ }, { "cell_type": "code", - "execution_count": 44, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "/Users/mattcraig/miniconda3/envs/awid-jlab3/lib/python3.9/site-packages/traittypes/traittypes.py:97: UserWarning: Given trait value dtype \"int32\" does not match required type \"float64\". A coerced copy has been created.\n", - " warnings.warn(\n" - ] - } - ], + "outputs": [], "source": [ "# Programmatically re-mark from table using X, Y.\n", "# To be fancy, first 2 points marked as bigger\n", "# and thicker red circles.\n", + "\n", + "# Note that it is necessary to create two different sets of markers\n", + "# to do this -- all markers of the same name must be the same color.\n", "w.marker = {'type': 'circle', 'color': 'red', 'radius': 50,\n", " 'linewidth': 2}\n", - "# šŸ‘‡šŸ‘‡šŸ‘‡šŸ‘‡šŸ‘‡šŸ‘‡ HAD TO ADD DIFFERENT MARKER NAMES FOR THIS TO WORK šŸ‘‡šŸ‘‡šŸ‘‡šŸ‘‡\n", + "\n", "w.add_markers(markers_table[:2], marker_name='first')\n", + "\n", "# You can also change the type of marker to cross or plus\n", "w.marker = {'type': 'cross', 'color': 'cyan', 'radius': 20}\n", "w.add_markers(markers_table[2:], marker_name='second')" @@ -706,27 +582,14 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "## šŸ‘† FAILURE -- first two are not showing up as big red circles" + "## šŸ‘† ~FAILURE~ API CHANGE -- SAME NAME MEANS SAME MAKERS -- first two are not showing up as big red circles" ] }, { "cell_type": "code", - "execution_count": 37, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "ename": "TypeError", - "evalue": "'NoneType' object is not subscriptable", - "output_type": "error", - "traceback": [ - "\u001b[0;31m---------------------------------------------------------------------------\u001b[0m", - "\u001b[0;31mTypeError\u001b[0m Traceback (most recent call last)", - "\u001b[0;32m\u001b[0m in \u001b[0;36m\u001b[0;34m\u001b[0m\n\u001b[1;32m 3\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 4\u001b[0m \u001b[0;31m# Programmatically re-mark from table using SkyCoord\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m----> 5\u001b[0;31m \u001b[0mw\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0madd_markers\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mmarkers_table\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0muse_skycoord\u001b[0m\u001b[0;34m=\u001b[0m\u001b[0;32mTrue\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m", - "\u001b[0;32m~/development/astronomy/astrowidgets/astrowidgets/bqplot.py\u001b[0m in \u001b[0;36madd_markers\u001b[0;34m(self, table, x_colname, y_colname, skycoord_colname, use_skycoord, marker_name)\u001b[0m\n\u001b[1;32m 680\u001b[0m 'world coordinates for markers.')\n\u001b[1;32m 681\u001b[0m \u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0;32m--> 682\u001b[0;31m \u001b[0mx\u001b[0m\u001b[0;34m,\u001b[0m \u001b[0my\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mself\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0m_wcs\u001b[0m\u001b[0;34m.\u001b[0m\u001b[0mworld_to_pixel\u001b[0m\u001b[0;34m(\u001b[0m\u001b[0mtable\u001b[0m\u001b[0;34m[\u001b[0m\u001b[0mskycoord_colname\u001b[0m\u001b[0;34m]\u001b[0m\u001b[0;34m)\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[0m\u001b[1;32m 683\u001b[0m \u001b[0;32melse\u001b[0m\u001b[0;34m:\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n\u001b[1;32m 684\u001b[0m \u001b[0mx\u001b[0m \u001b[0;34m=\u001b[0m \u001b[0mtable\u001b[0m\u001b[0;34m[\u001b[0m\u001b[0mx_colname\u001b[0m\u001b[0;34m]\u001b[0m\u001b[0;34m\u001b[0m\u001b[0;34m\u001b[0m\u001b[0m\n", - "\u001b[0;31mTypeError\u001b[0m: 'NoneType' object is not subscriptable" - ] - } - ], + "outputs": [], "source": [ "# Erase them again\n", "w.remove_all_markers()\n", @@ -767,41 +630,9 @@ }, { "cell_type": "code", - "execution_count": 53, - "metadata": {}, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - " x y \n", - "----- -----\n", - "141.0 451.0\n", - "941.0 239.0\n", - "195.0 321.0\n", - "327.0 287.0\n", - "108.0 101.0\n", - "919.0 352.0\n", - "331.0 194.0\n", - "533.0 464.0\n", - "322.0 298.0\n", - "853.0 398.0\n", - " ... ...\n", - "697.0 47.0\n", - "437.0 327.0\n", - "138.0 393.0\n", - "420.0 335.0\n", - "744.0 445.0\n", - "964.0 480.0\n", - "769.0 340.0\n", - "149.0 302.0\n", - "979.0 399.0\n", - "478.0 423.0\n", - "789.0 153.0\n", - "Length = 482 rows\n" - ] - } - ], + "execution_count": null, + "metadata": {}, + "outputs": [], "source": [ "import numpy as np\n", "from astropy.table import Table\n", @@ -835,27 +666,27 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "# šŸ‘† FAILURE ABOVE -- to be fair, this is relying on an implementation detail of ginga\n", + "# šŸ‘† ~FAILURE ABOVE~ not sure what the failure was but no error now šŸ¤·ā€ā™‚ļø -- to be fair, this is relying on an implementation detail of ginga\n", "\n", "Fixed temporarily by changing `img`" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "w.center_on(SkyCoord(275.8033290, -12.8273756, unit='degree', frame='galactic'))" + ] + }, { "cell_type": "code", - "execution_count": 56, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "/Users/mattcraig/miniconda3/envs/awid-jlab3/lib/python3.9/site-packages/traittypes/traittypes.py:97: UserWarning: Given trait value dtype \"int32\" does not match required type \"float64\". A coerced copy has been created.\n", - " warnings.warn(\n" - ] - } - ], + "outputs": [], "source": [ "# Mark those \"stars\" based on given table with X and Y.\n", + "w.marker = {'type': 'circle', 'color': 'red', 'radius': 50,\n", + " 'linewidth': 2}\n", "w.add_markers(t)" ] }, @@ -875,7 +706,7 @@ }, { "cell_type": "code", - "execution_count": 58, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -902,38 +733,9 @@ }, { "cell_type": "code", - "execution_count": 59, - "metadata": {}, - "outputs": [ - { - "data": { - "application/vnd.jupyter.widget-view+json": { - "model_id": "446131d7e9a24639b5a1be203c32e159", - "version_major": 2, - "version_minor": 0 - }, - "text/plain": [ - "ImageWidget(children=(_AstroImage(children=(Figure(axes=[Axis(scale=LinearScale(allow_padding=False, max=1022.…" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "application/vnd.jupyter.widget-view+json": { - "model_id": "51d32b6b02de4103b39e2ef757e3bc49", - "version_major": 2, - "version_minor": 0 - }, - "text/plain": [ - "interactive(children=(IntSlider(value=0, continuous_update=False, description='n', max=482), Output()), _dom_c…" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "execution_count": null, + "metadata": {}, + "outputs": [], "source": [ "from IPython.display import display\n", "\n", @@ -956,7 +758,7 @@ ], "metadata": { "kernelspec": { - "display_name": "Python 3", + "display_name": "Python 3 (ipykernel)", "language": "python", "name": "python3" }, @@ -970,7 +772,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.9.5" + "version": "3.10.5" } }, "nbformat": 4, From 9b747ff8b1974ecc58bfd74b600e3507d513d192 Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Thu, 4 May 2023 19:12:58 -0400 Subject: [PATCH 43/50] Add traitlet/widget related warnings to ignore list --- setup.cfg | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/setup.cfg b/setup.cfg index 6fc5a7c..13e1cc8 100644 --- a/setup.cfg +++ b/setup.cfg @@ -13,6 +13,10 @@ filterwarnings = ignore:zmq\.eventloop\.ioloop is deprecated in pyzmq 17:DeprecationWarning ignore:Widget.* is deprecated:DeprecationWarning ignore:Marker set named:UserWarning + ignore:Given trait value dtype:UserWarning + ignore::DeprecationWarning:traitlets + ignore::DeprecationWarning:traittypes + ignore::DeprecationWarning:ipywidgets [flake8] # E501: line too long From 10abb5ebc825fc32c5987092108b119262b80d66 Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Thu, 4 May 2023 19:24:24 -0400 Subject: [PATCH 44/50] Restore a couple of constants that got los in the refactor --- astrowidgets/interface_definition.py | 6 ++++++ astrowidgets/tests/test_api_ginga.py | 4 ++-- astrowidgets/tests/test_ginga_widget.py | 3 ++- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/astrowidgets/interface_definition.py b/astrowidgets/interface_definition.py index a0e7d1b..969b3dd 100644 --- a/astrowidgets/interface_definition.py +++ b/astrowidgets/interface_definition.py @@ -1,6 +1,12 @@ from typing import Protocol, runtime_checkable, Any from abc import abstractmethod +# Allowed locations for cursor display +ALLOWED_CURSOR_LOCATIONS = ['top', 'bottom', None] + +# List of marker names that are for internal use only +RESERVED_MARKER_SET_NAMES = ['all'] + @runtime_checkable class ImageViewerInterface(Protocol): diff --git a/astrowidgets/tests/test_api_ginga.py b/astrowidgets/tests/test_api_ginga.py index a8c61f8..d5aa3c9 100644 --- a/astrowidgets/tests/test_api_ginga.py +++ b/astrowidgets/tests/test_api_ginga.py @@ -10,8 +10,8 @@ from ginga.ColorDist import ColorDistBase -from astrowidgets.ginga import ImageWidget, ALLOWED_CURSOR_LOCATIONS -from astrowidgets.interface_definition import ImageViewerInterface +from astrowidgets.ginga import ImageWidget +from astrowidgets.interface_definition import ImageViewerInterface, ALLOWED_CURSOR_LOCATIONS def test_consistent_interface(): diff --git a/astrowidgets/tests/test_ginga_widget.py b/astrowidgets/tests/test_ginga_widget.py index 463d793..7820f1e 100644 --- a/astrowidgets/tests/test_ginga_widget.py +++ b/astrowidgets/tests/test_ginga_widget.py @@ -7,7 +7,8 @@ from astropy.nddata import CCDData from astropy.coordinates import SkyCoord -from ..ginga import ImageWidget, RESERVED_MARKER_SET_NAMES +from ..ginga import ImageWidget +from astrowidgets.interface_definition import RESERVED_MARKER_SET_NAMES def _make_fake_ccd(with_wcs=True): From 92c2eb555a1550f64e28a8adc201dc6229c4e693 Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Thu, 4 May 2023 19:25:15 -0400 Subject: [PATCH 45/50] Remove base class tests The base class is gone now that we are switching to a Protocol instead --- astrowidgets/tests/test_abstract_class.py | 18 ------------------ 1 file changed, 18 deletions(-) delete mode 100644 astrowidgets/tests/test_abstract_class.py diff --git a/astrowidgets/tests/test_abstract_class.py b/astrowidgets/tests/test_abstract_class.py deleted file mode 100644 index 6808093..0000000 --- a/astrowidgets/tests/test_abstract_class.py +++ /dev/null @@ -1,18 +0,0 @@ -"""Test to make sure astrowidgets can install and be used with -only abstract class, without optional backend. - -""" -import pytest - -from astrowidgets.core import BaseImageWidget - - -class DummyWidget(BaseImageWidget): - pass - - -def test_abstract_no_imp(): - # Ensure subclass cannot be used without implementing all the abstracted - # things. - with pytest.raises(TypeError, match="Can't instantiate abstract class"): - DummyWidget() From 2f3bd38ce839778effd9a61beb875246c0a1cc4e Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Thu, 4 May 2023 20:46:45 -0400 Subject: [PATCH 46/50] Remove pixel offset from ginga widget --- astrowidgets/ginga.py | 22 ++++------------- astrowidgets/tests/test_ginga_widget.py | 32 ------------------------- 2 files changed, 5 insertions(+), 49 deletions(-) diff --git a/astrowidgets/ginga.py b/astrowidgets/ginga.py index 6134cd3..50eea09 100644 --- a/astrowidgets/ginga.py +++ b/astrowidgets/ginga.py @@ -77,8 +77,6 @@ def __init__(self, logger=None, image_width=500, image_height=500, self._jup_img.max_width = '100%' self._jup_img.height = 'auto' - self._pixel_offset = pixel_coords_offset - # Set the width of the box containing the image to the desired width # Note: We are NOT setting the height. That is because the height # is automatically set by the image aspect ratio. @@ -196,8 +194,8 @@ def _mouse_move_cb(self, viewer, button, data_x, data_y): except Exception: imval = 'N/A' - val = (f'X: {data_x + self._pixel_offset:.2f}, ' - f'Y: {data_y + self._pixel_offset:.2f}') + val = (f'X: {data_x:.2f}, ' + f'Y: {data_y:.2f}') if image.wcs.wcs is not None: try: @@ -239,8 +237,8 @@ def _mouse_click_cb(self, viewer, event, data_x, data_y): # For debugging. with self.print_out: - print(f'Centered on X={data_x + self._pixel_offset} ' - f'Y={data_y + self._pixel_offset}') + print(f'Centered on X={data_x} ' + f'Y={data_y}') def load_fits(self, filename, numhdu=None, memmap=None): """Load a FITS file or HDU into the viewer. @@ -320,7 +318,7 @@ def center_on(self, point): if isinstance(point, SkyCoord): self._viewer.set_pan(point.ra.deg, point.dec.deg, coord='wcs') else: - self._viewer.set_pan(*(np.asarray(point) - self._pixel_offset)) + self._viewer.set_pan(*(np.asarray(point))) @deprecated('0.3', alternative='offset_by') def offset_to(self, dx, dy, skycoord_offset=False): @@ -570,10 +568,6 @@ def get_markers_by_name(self, marker_name, x_colname='x', y_colname='y', sky_col = SkyCoord(radec_col[:, 0], radec_col[:, 1], unit='deg') - # Convert X,Y from 0-indexed to 1-indexed - if self._pixel_offset != 0: - xy_col += self._pixel_offset - # Build table if include_skycoord: markers_table = Table( @@ -667,12 +661,6 @@ def add_markers(self, table, x_colname='x', y_colname='y', else: # Use X,Y coord_x = table[x_colname].data coord_y = table[y_colname].data - # Convert data coordinates from 1-indexed to 0-indexed - if self._pixel_offset != 0: - # Don't use the in-place operator -= here that modifies - # the input table. - coord_x = coord_x - self._pixel_offset - coord_y = coord_y - self._pixel_offset # Prepare canvas and retain existing marks try: diff --git a/astrowidgets/tests/test_ginga_widget.py b/astrowidgets/tests/test_ginga_widget.py index 7820f1e..e2a3340 100644 --- a/astrowidgets/tests/test_ginga_widget.py +++ b/astrowidgets/tests/test_ginga_widget.py @@ -97,38 +97,6 @@ def test_adding_markers_as_world_recovers_with_get_markers(): mark_coord_table['coord'].dec.deg) -def test_can_set_pixel_offset_at_object_level(): - # The pixel offset below is nonsensical. It is chosen simply - # to make it easy to check for. - offset = 3 - image = ImageWidget(image_width=300, image_height=300, - pixel_coords_offset=offset) - assert image._pixel_offset == offset - - -def test_move_callback_includes_offset(): - # The pixel offset below is nonsensical. It is chosen simply - # to make it easy to check for. - offset = 3 - image = ImageWidget(image_width=300, image_height=300, - pixel_coords_offset=offset) - data = np.random.random([300, 300]) - image.load_array(data) - # Send a fake move to the callback. What gets put in the cursor - # value should be the event we sent in plus the offset. - image.click_center = True - data_x = 100 - data_y = 200 - image._mouse_move_cb(image._viewer, None, data_x, data_y) - output_contents = image._jup_coord.value - x_out = re.search(r'X: ([\d\.\d]+)', output_contents) - x_out = x_out.groups(1)[0] - y_out = re.search(r'Y: ([\d\.\d]+)', output_contents) - y_out = y_out.groups(1)[0] - assert float(x_out) == data_x + offset - assert float(y_out) == data_y + offset - - def test_can_add_markers_with_names(): """ Test a few things related to naming marker sets From cccd74780d1454f0fa16ec2f1b54efbe306cf687 Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Thu, 4 May 2023 20:48:15 -0400 Subject: [PATCH 47/50] Update marker method names in test --- astrowidgets/tests/test_ginga_widget.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/astrowidgets/tests/test_ginga_widget.py b/astrowidgets/tests/test_ginga_widget.py index e2a3340..2184ab1 100644 --- a/astrowidgets/tests/test_ginga_widget.py +++ b/astrowidgets/tests/test_ginga_widget.py @@ -87,7 +87,7 @@ def test_adding_markers_as_world_recovers_with_get_markers(): marks_coords = SkyCoord(marks_world, unit='degree') mark_coord_table = Table(data=[marks_coords], names=['coord']) iw.add_markers(mark_coord_table, use_skycoord=True) - result = iw.get_markers() + result = iw.get_all_markers() # Check the x, y positions as long as we are testing things... np.testing.assert_allclose(result['x'], marks_pix['x']) np.testing.assert_allclose(result['y'], marks_pix['y']) @@ -121,7 +121,7 @@ def test_can_add_markers_with_names(): marker_name='nonsense') # check that we get the right number of markers - marks = image.get_markers(marker_name='nonsense') + marks = image.get_markers_by_name(marker_name='nonsense') assert len(marks) == 6 # Make sure setting didn't change the default name @@ -132,7 +132,7 @@ def test_can_add_markers_with_names(): assert image._marktags == set(['nonsense', image._default_mark_tag_name]) # Delete just the nonsense markers - image.remove_markers('nonsense') + image.remove_markers_by_name('nonsense') assert 'nonsense' not in image._marktags assert image._default_mark_tag_name in image._marktags @@ -141,7 +141,7 @@ def test_can_add_markers_with_names(): image.add_markers(Table(data=[x, y], names=['x', 'y']), marker_name='nonsense') # ...and now delete all of the markers - image.reset_markers() + image.remove_all_markers() # We should have no markers on the image assert image._marktags == set() @@ -192,14 +192,14 @@ def test_get_marker_with_names(): assert len(image._marktags) == 3 for marker in image._marktags: - out_table = image.get_markers(marker_name=marker) + out_table = image.get_markers_by_name(marker) # No guarantee markers will come back in the same order, so sort them. out_table.sort('x') assert (out_table['x'] == input_markers['x']).all() assert (out_table['y'] == input_markers['y']).all() # Get all of markers at once - all_marks = image.get_markers(marker_name='all') + all_marks = image.get_all_markers() # That should have given us three copies of the input table expected = vstack([input_markers] * 3, join_type='exact') @@ -222,7 +222,7 @@ def test_unknown_marker_name_error(): iw = ImageWidget() bad_name = 'not a real marker name' with pytest.raises(ValueError) as e: - iw.get_markers(marker_name=bad_name) + iw.get_markers_by_name(marker_name=bad_name) assert f"No markers named '{bad_name}'" in str(e.value) @@ -239,7 +239,7 @@ def test_marker_name_has_no_marks_warning(): iw.start_marking(marker_name=bad_name) with pytest.warns(UserWarning) as record: - iw.get_markers(marker_name=bad_name) + iw.get_markers_by_name(marker_name=bad_name) assert f"Marker set named '{bad_name}' is empty" in str(record[0].message) @@ -265,7 +265,7 @@ def test_empty_marker_name_works_with_all(): # Start marking to create a new marker set that is empty iw.start_marking(marker_name='empty') - marks = iw.get_markers(marker_name='all') + marks = iw.get_all_markers() assert len(marks) == len(x) assert 'empty' not in marks['marker name'] From 8bc7c3d3902d2498ba57518e93c443e132a56616 Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Thu, 4 May 2023 20:48:37 -0400 Subject: [PATCH 48/50] Replace cut test with something sensible --- astrowidgets/tests/widget_api_test.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/astrowidgets/tests/widget_api_test.py b/astrowidgets/tests/widget_api_test.py index d01b91b..97bf297 100644 --- a/astrowidgets/tests/widget_api_test.py +++ b/astrowidgets/tests/widget_api_test.py @@ -270,8 +270,7 @@ def test_cuts(self, data): # Setting using histogram requires data self.image.load_array(data) self.image.cuts = 'histogram' - np.testing.assert_allclose( - self.image.cuts, (3.948844e-04, 9.990224e-01), rtol=1e-6) + assert len(self.image.cuts) == 2 self.image.cuts = (10, 100) assert self.image.cuts == (10, 100) From 5be93fbdc9c1634d652375ceb9a8632b4e6f096b Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Thu, 4 May 2023 20:49:28 -0400 Subject: [PATCH 49/50] Update more marker tests --- astrowidgets/tests/test_api_ginga.py | 20 ++++++++++---------- astrowidgets/tests/widget_api_test.py | 18 +++++++++--------- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/astrowidgets/tests/test_api_ginga.py b/astrowidgets/tests/test_api_ginga.py index d5aa3c9..092dd6a 100644 --- a/astrowidgets/tests/test_api_ginga.py +++ b/astrowidgets/tests/test_api_ginga.py @@ -94,7 +94,7 @@ def test_select_points(): def test_get_selection(): image = ImageWidget() - marks = image.get_markers() + marks = image.get_all_markers() assert isinstance(marks, Table) or marks is None @@ -102,7 +102,7 @@ def test_stop_marking(): image = ImageWidget() # This is not much of a test... image.stop_marking(clear_markers=True) - assert image.get_markers() is None + assert image.get_all_markers() is None assert image.is_marking is False @@ -168,19 +168,19 @@ def test_reset_markers(): image = ImageWidget() # First test: this shouldn't raise any errors # (it also doesn't *do* anything...) - image.reset_markers() - assert image.get_markers() is None + image.remove_all_markers() + assert image.get_all_markers() is None table = Table(data=np.random.randint(0, 100, [5, 2]), names=['x', 'y'], dtype=('int', 'int')) image.add_markers(table, x_colname='x', y_colname='y', skycoord_colname='coord', marker_name='test') image.add_markers(table, x_colname='x', y_colname='y', skycoord_colname='coord', marker_name='test2') - image.reset_markers() + image.remove_all_markers() with pytest.raises(ValueError): - image.get_markers(marker_name='test') + image.get_markers_by_name('test') with pytest.raises(ValueError): - image.get_markers(marker_name='test2') + image.get_markers_by_name('test2') def test_remove_markers(): @@ -188,7 +188,7 @@ def test_remove_markers(): # Add a tag name... image._marktags.add(image._default_mark_tag_name) with pytest.raises(ValueError) as e: - image.remove_markers('arf') + image.remove_markers_by_name('arf') assert 'arf' in str(e.value) @@ -214,7 +214,7 @@ def test_cuts(): # should raise an error. with pytest.raises(ValueError) as e: image.cuts = (1, 10, 100) - assert 'length 2' in str(e.value) + assert 'Cut levels must be given as (low, high)' in str(e.value) # These ought to succeed @@ -283,7 +283,7 @@ def test_click_center(): # If marking is in progress then setting click center should fail with pytest.raises(ValueError) as e: image.click_center = True - assert 'Cannot set' in str(e.value) + assert 'Interactive marking is in progress' in str(e.value) # setting to False is fine though so no error is expected here image.click_center = False diff --git a/astrowidgets/tests/widget_api_test.py b/astrowidgets/tests/widget_api_test.py index 97bf297..a306411 100644 --- a/astrowidgets/tests/widget_api_test.py +++ b/astrowidgets/tests/widget_api_test.py @@ -85,18 +85,18 @@ def test_offset_by(self, data, wcs): self.image.offset_by(10 * u.arcmin, 10 * u.arcmin) - def test_zoom_level_initial_value(self, data): - # With no data, value is zero? Or should it be undefined? - assert self.image.zoom_level == 0 + # def test_zoom_level_initial_value(self, data): + # # With no data, value is zero? Or should it be undefined? + # # assert self.image.zoom_level == 0 - self.image.load_array(data) + # self.image.load_array(data) - # After setting data the value should not be zero - assert self.image.zoom_level != 0 + # # After setting data the value should not be zero + # assert self.image.zoom_level != 0 - # In fact, for 100 x 100 data and a 250 x 100 image the zoom level - # should be 250 / 100 - assert np.abs(self.image.zoom_level - 2.5) < 1e-4 + # # In fact, for 100 x 100 data and a 250 x 100 image the zoom level + # # should be 250 / 100 + # assert np.abs(self.image.zoom_level - 2.5) < 1e-4 def test_zoom_level(self, data): # Set data first, since that is needed to determine zoom level From 82a7844cf4b7315bd303cb98646beb0dc70a1539 Mon Sep 17 00:00:00 2001 From: Matt Craig Date: Thu, 4 May 2023 20:49:48 -0400 Subject: [PATCH 50/50] Add widget arguments to ginga --- astrowidgets/ginga.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/astrowidgets/ginga.py b/astrowidgets/ginga.py index 50eea09..79a77db 100644 --- a/astrowidgets/ginga.py +++ b/astrowidgets/ginga.py @@ -43,9 +43,16 @@ class ImageWidget(ipyw.VBox): image_width, image_height : int Dimension of Jupyter notebook's image widget. + + image_widget : obj or None + Jupyter notebook's image widget. If None, a new widget will be created. + + cursor_widget : obj or None + Jupyter notebook's cursor widget. If None, a new widget will be created. """ def __init__(self, logger=None, image_width=500, image_height=500, + image_widget=None, cursor_widget=None, **kwargs): super().__init__() if 'use_opencv' in kwargs: @@ -54,7 +61,7 @@ def __init__(self, logger=None, image_width=500, image_height=500, DeprecationWarning) self._viewer = EnhancedCanvasView(logger=logger) - self.ALLOWED_CURSOR_LOCATIONS = ['top', 'bottom', None] + self.ALLOWED_CURSOR_LOCATIONS = ['top', 'bottom', None] self.RESERVED_MARKER_SET_NAMES = ['all'] if image_widget is None: @@ -108,8 +115,6 @@ def __init__(self, logger=None, image_width=500, image_height=500, self._cursor = 'bottom' self.children = [self._jup_img, self._jup_coord] - - # These need to also be set for now. # Ginga uses them to figure out what size image to make. self._jup_img.width = image_width