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/__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/bqplot.py b/astrowidgets/bqplot.py new file mode 100644 index 0000000..772287b --- /dev/null +++ b/astrowidgets/bqplot.py @@ -0,0 +1,1029 @@ +from pathlib import Path +import warnings + +import numpy as np + +from astropy.coordinates import SkyCoord, SkyOffsetFrame +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 astropy.wcs import WCS + +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 + +from .helpers import _offset_is_pixel_or_sky + +# 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, min=0, + scheme='Greys')} + + self._image_shape = None + + 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. + # The offset follows the convention that the index corresponds + # to the center of the pixel. + self._image.image = image_data + 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): + """ + 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 + + @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 + + 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, overwrite=False): + self._check_file_exists(filename, overwrite=overwrite) + 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 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']) + 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(f'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=(np.float64, np.float64, '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=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 + self._astro_im = _AstroImage(display_width=self.image_width, + viewer_aspect_ratio=viewer_aspect) + self._interval = None + self._stretch = None + self.set_colormap('Greys_r') + self._marker_table = MarkerTableManager() + self._data = None + 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 + # 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 + + # 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) + + 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): + + 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) + elif event_data['event'] == 'click': + self._mouse_click(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.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) + 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(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)) + + 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 + 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. + """ + 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, 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: + 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 one of None or ' + 'one of these values: ' + f'{sorted(STRETCHES.keys())}') + + return proposed_stretch + + @trait.observe('stretch') + def _observe_stretch(self, change): + 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): + # 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 one of 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) + 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): + 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 + if self._data is not None: + self._send_data(reset_view=False) + + @trait.observe('zoom_level') + def _update_zoom_level(self, change): + zl = change['new'] + 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 ' + 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(self._currently_marking_error_msg('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): + 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 + + @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 + + @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 + 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, + (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(reset_view=reset_view) + + def load_array(self, array, reset_view=True): + self._data = array + self._send_data(reset_view=reset_view) + + def load_nddata(self, data, reset_view=True): + self._ccd = data + self._data = self._ccd.data + self._wcs = data.wcs + + self._send_data(reset_view=reset_view) + + # Saving contents of the view and accessing the view + def save(self, filename, overwrite=False): + if filename.endswith('.png'): + self._astro_im.save_png(filename, overwrite=overwrite) + elif filename.endswith('.svg'): + self._astro_im.save_svg(filename, overwrite=overwrite) + else: + 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)) + self._colormap = cmap_name + + @property + def colormap_options(self): + return pyplot.colormaps() + + 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): + + # 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: + 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) + + 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 + 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_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): + 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 + + 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 + + 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 + mt = self._marker_table + if marker_name is not None: + self._validate_marker_name(marker_name) + mt._interactive_marker_set_name = marker_name + else: + mt._interactive_marker_set_name = mt._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 = {} + + @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 diff --git a/astrowidgets/core.py b/astrowidgets/ginga.py similarity index 57% rename from astrowidgets/core.py rename to astrowidgets/ginga.py index 65f293c..79a77db 100644 --- a/astrowidgets/core.py +++ b/astrowidgets/ginga.py @@ -1,10 +1,17 @@ -"""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 ipywidgets as ipyw import numpy as np from astropy import units as u from astropy.coordinates import SkyCoord @@ -12,10 +19,6 @@ from astropy.table import Table, vstack from astropy.utils.decorators import deprecated -# Jupyter widgets -import ipywidgets as ipyw - -# Ginga from ginga.AstroImage import AstroImage from ginga.canvas.CanvasObject import drawCatalog from ginga.web.jupyterw.ImageViewJpw import EnhancedCanvasView @@ -23,22 +26,15 @@ __all__ = ['ImageWidget'] -# 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 ImageWidget(ipyw.VBox): - """ - Image widget for Jupyter notebook using Ginga viewer. + """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 or None Ginga logger. For example:: from ginga.misc.log import get_logger @@ -48,73 +44,97 @@ 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.* + 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, - pixel_coords_offset=0, **kwargs): + image_widget=None, cursor_widget=None, + **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'] - self._pixel_offset = pixel_coords_offset - - self._jup_img = ipyw.Image(format='jpeg') + if image_widget is None: + self._jup_img = ipyw.Image(format='jpeg') + else: + self._jup_img = image_widget - # Set the image margin to over the widgets default of 2px on - # all sides. - self._jup_img.layout.margin = '0' + if cursor_widget is None: + self._jup_coord = ipyw.HTML('Coordinates show up here') + else: + self._jup_coord = cursor_widget - # Set both of those to ensure consistent display in notebook - # and jupyterlab when the image is put into a container smaller - # than the image. + if isinstance(self._jup_img, ipyw.Image): + # Set the image margin on all sides. + self._jup_img.layout.margin = '0' - self._jup_img.max_width = '100%' - self._jup_img.height = 'auto' + # 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 + # 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) - # Note we are NOT setting the height. That is because the height - # is automatically set by the image aspect ratio. + # 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 = {} - # These need to also be set for now; ginga uses them to figure - # out what size image to make. + # 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 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,33 +144,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): + """Width of image widget.""" return int(self._jup_img.width) @image_width.setter @@ -160,8 +171,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): + """Height of image widget.""" return int(self._jup_img.height) @image_height.setter @@ -171,22 +184,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 +195,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:.2f}, ' + f'Y: {data_y:.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 +230,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)) - -# def _repr_html_(self): -# """ -# Show widget in Jupyter notebook. -# """ -# from IPython.display import display -# return display(self._widget) + print(f'Centered on X={data_x} ' + f'Y={data_y}') - 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 +303,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,19 +320,10 @@ 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)) + self._viewer.set_pan(*(np.asarray(point))) @deprecated('0.3', alternative='offset_by') def offset_to(self, dx, dy, skycoord_offset=False): @@ -388,93 +376,97 @@ def offset_by(self, dx, dy): @property def zoom_level(self): - """ - Zoom level: + """Zoom level (settable): * 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, val): - if val == 'fit': + def zoom_level(self, value): + if value == 'fit': self._viewer.zoom_fit() else: - self._viewer.scale_to(val, val) + self._viewer.scale_to(value, value) - def zoom(self, val): - """ - Zoom in or out by the given factor. + + def zoom(self, value): + """Zoom in or out by the given factor. Parameters ---------- - val : int + value : int The zoom level to zoom the image. See `zoom_level`. """ - self.zoom_level = self.zoom_level * val + self.zoom_level = self.zoom_level * value @property def is_marking(self): - """ - `True` if in marking mode, `False` otherwise. + """`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 + 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._cached_state = dict(click_center=self.click_center, - click_drag=self.click_drag, - scroll_pan=self.scroll_pan) + self.set_cached_state() 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.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.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 + 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. + """Stop marking mode, with option to clear all 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 `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.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 = {} + self.restore_and_clear_cached_state() if clear_markers: - self.reset_markers() + self.remove_all_markers() @property def marker(self): - """ - Marker to use. + """A dictionary defining the current marker properties. .. todo:: Add more examples. @@ -493,9 +485,9 @@ def marker(self): return self._marker_dict @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 +500,23 @@ 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 - - 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``. + self._marker_dict = value - skycoord_colname : str - Column name for ``SkyCoord``, which contains - sky coordinates associated with the active image. - This is ignored if image has no WCS. + def get_marker_names(self): + """Return a list of used marker names. Returns ------- - markers_table : `~astropy.table.Table` or ``None`` - Table of markers, if any, or ``None``. + names : list of str + Sorted list of marker names. """ - 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') + return sorted(self._marktags) - 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 +527,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 +548,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]) @@ -628,10 +573,6 @@ def get_markers(self, 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( @@ -644,49 +585,51 @@ 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))) - 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. + def get_all_markers(self, x_colname='x', y_colname='y', skycoord_colname='coord'): + """Run :meth:`get_markers_by_name` for all markers.""" - .. todo:: + # 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 - Later enhancements to include more columns - to control size/style/color of marks, + if skycoord_colname in table.colnames: + coordinates.extend(c for c in table[skycoord_colname]) + del table[skycoord_colname] - Parameters - ---------- - table : `~astropy.table.Table` - Table containing marker locations. + tables.append(table) - x_colname, y_colname : str - Column names for X and Y. - Coordinates can be 0- or 1-indexed, as - given by ``self.pixel_offset``. + if len(tables) == 0: + return None - skycoord_colname : str - Column name with ``SkyCoord`` objects. + stacked = vstack(tables, join_type='exact') - use_skycoord : bool - If `True`, use ``skycoord_colname`` to mark. - Otherwise, use ``x_colname`` and ``y_colname``. + 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) - 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 + 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 + def add_markers(self, table, x_colname='x', y_colname='y', + skycoord_colname='coord', use_skycoord=False, + marker_name=None): # For now we always convert marker locations to pixels; see # comment below. @@ -695,8 +638,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 +652,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: @@ -725,19 +666,12 @@ 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 - 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 +679,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 +689,75 @@ 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): + 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. + """ - Delete all markers. + 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) - # 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) + 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): - """ - List all available options for image stretching. - """ return self._viewer.get_color_algorithms() @property @@ -810,121 +769,118 @@ 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``. + """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, val): - if val is None: + def cursor(self, value): + if value is None: self._jup_coord.layout.visibility = 'hidden' self._jup_coord.layout.display = 'none' - elif val == 'top' or val == 'bottom': + elif value in ('top', 'bottom'): self._jup_coord.layout.visibility = 'visible' self._jup_coord.layout.display = 'flex' - if val == 'top': + if value == '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 + raise ValueError( + f'Invalid value {value} for cursor. ' + f'Valid values are: {self.ALLOWED_CURSOR_LOCATIONS}') + self._cursor = value @property + def click_center(self): - """ - Settable. - If True, middle-clicking can be used to center. If False, that - interaction is disabled. + """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. - 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): + def click_center(self, value): + if not isinstance(value, 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: + 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 = val + self._click_center = value - # 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. + """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`. - Note that this should be automatically made `False` when selection mode - is activated. """ return self._click_drag @@ -932,7 +888,7 @@ def click_drag(self): 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,12 +901,14 @@ 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. + """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 @@ -966,10 +924,14 @@ 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: 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 diff --git a/astrowidgets/interface_definition.py b/astrowidgets/interface_definition.py new file mode 100644 index 0000000..969b3dd --- /dev/null +++ b/astrowidgets/interface_definition.py @@ -0,0 +1,87 @@ +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): + # 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 remove_all_markers(self): + raise NotImplementedError + + @abstractmethod + def remove_markers_by_name(self, marker_name=None): + 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_by(self): + raise NotImplementedError + + @abstractmethod + def zoom(self): + raise NotImplementedError diff --git a/astrowidgets/tests/test_api.py b/astrowidgets/tests/test_api_ginga.py similarity index 91% rename from astrowidgets/tests/test_api.py rename to astrowidgets/tests/test_api_ginga.py index 37113e5..092dd6a 100644 --- a/astrowidgets/tests/test_api.py +++ b/astrowidgets/tests/test_api_ginga.py @@ -10,7 +10,13 @@ from ginga.ColorDist import ColorDistBase -from ..core import ImageWidget, ALLOWED_CURSOR_LOCATIONS +from astrowidgets.ginga import ImageWidget +from astrowidgets.interface_definition import ImageViewerInterface, ALLOWED_CURSOR_LOCATIONS + + +def test_consistent_interface(): + iw = ImageWidget() + assert isinstance(iw, ImageViewerInterface) def test_load_fits(): @@ -88,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 @@ -96,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 @@ -162,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(): @@ -182,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) @@ -208,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 @@ -277,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/test_bqplot_api.py b/astrowidgets/tests/test_bqplot_api.py new file mode 100644 index 0000000..16c28a9 --- /dev/null +++ b/astrowidgets/tests/test_bqplot_api.py @@ -0,0 +1,310 @@ +import numpy as np + +import pytest + +from traitlets.traitlets import TraitError + +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_by(): + image = ImageWidget() + dx = 10 + dy = 10 + image.offset_by(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_all_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_all_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_remove_all_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.remove_all_markers() + with pytest.raises(ValueError): + image.get_markers_by_name(marker_name='test') + with pytest.raises(ValueError): + image.get_markers_by_name(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() + 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)) + + +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(TraitError): + 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, overwrite=True) + + +def test_width_height(): + image = ImageWidget(image_width=250, image_height=100) + assert image.image_width == 250 + assert image.image_height == 100 diff --git a/astrowidgets/tests/test_image_widget.py b/astrowidgets/tests/test_ginga_widget.py similarity index 84% rename from astrowidgets/tests/test_image_widget.py rename to astrowidgets/tests/test_ginga_widget.py index 4f4f142..2184ab1 100644 --- a/astrowidgets/tests/test_image_widget.py +++ b/astrowidgets/tests/test_ginga_widget.py @@ -7,7 +7,8 @@ from astropy.nddata import CCDData from astropy.coordinates import SkyCoord -from ..core 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): @@ -86,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']) @@ -96,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 @@ -152,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 @@ -163,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 @@ -172,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() @@ -223,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') @@ -253,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) @@ -270,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) @@ -296,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'] diff --git a/astrowidgets/tests/test_widget_api_bqplot.py b/astrowidgets/tests/test_widget_api_bqplot.py new file mode 100644 index 0000000..792de5c --- /dev/null +++ b/astrowidgets/tests/test_widget_api_bqplot.py @@ -0,0 +1,20 @@ +import pytest + +from traitlets import TraitError + +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 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 new file mode 100644 index 0000000..72112dd --- /dev/null +++ b/astrowidgets/tests/test_widget_api_ginga.py @@ -0,0 +1,12 @@ +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): + image_widget_class = ImageWidget diff --git a/astrowidgets/tests/widget_api_test.py b/astrowidgets/tests/widget_api_test.py new file mode 100644 index 0000000..a306411 --- /dev/null +++ b/astrowidgets/tests/widget_api_test.py @@ -0,0 +1,345 @@ +# 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 +from astropy import units as u # noqa: E402 +from astropy.wcs import WCS # noqa: E402 + + +class ImageWidgetAPITest: + cursor_error_classes = (ValueError) + + @pytest.fixture + def data(self): + rng = np.random.default_rng(1234) + 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): + 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 + + def test_load_fits(self, data): + hdu = fits.PrimaryHDU(data=data) + self.image.load_fits(hdu) + + def test_load_nddata(self, data): + nddata = NDData(data) + self.image.load_nddata(nddata) + + 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_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_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): + 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) + + + # 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 + 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, data): + 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 + + # Setting using histogram requires data + self.image.load_array(data) + self.image.cuts = 'histogram' + assert len(self.image.cuts) == 2 + + 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(self.cursor_error_classes): + 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='[Ii]nteractive 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='[Ii]nteractive 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) 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/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 new file mode 100644 index 0000000..268d7a3 --- /dev/null +++ b/docs/ginga.rst @@ -0,0 +1,99 @@ +.. _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 +--- + +.. automodule:: astrowidgets.ginga + +.. inheritance-diagram:: astrowidgets.ginga.ImageWidget + :top-classes: ipywidgets.VBox, astrowidgets.core.BaseImageWidget + +.. autoclass:: astrowidgets.ginga.ImageWidget + :members: 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/bqplot_widget.ipynb b/example_notebooks/ginga/bqplot_widget.ipynb new file mode 100644 index 0000000..acf9613 --- /dev/null +++ b/example_notebooks/ginga/bqplot_widget.ipynb @@ -0,0 +1,780 @@ +{ + "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": null, + "metadata": {}, + "outputs": [], + "source": [ + "from astrowidgets.bqplot import ImageWidget\n", + "from sidecar import Sidecar" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "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": null, + "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": null, + "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": null, + "metadata": {}, + "outputs": [], + "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": null, + "metadata": {}, + "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": {}, + "source": [ + "The following cell changes the visibility or position of the cursor info bar.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "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": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Programmatically center to (X, Y) on viewer\n", + "w.center_on((1, 1))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Programmatically offset w.r.t. current center\n", + "w.offset_by(10, 10)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "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": null, + "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": null, + "metadata": {}, + "outputs": [], + "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": null, + "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": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Programmatically zoom image on viewer\n", + "w.zoom(2)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "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": null, + "metadata": {}, + "outputs": [], + "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": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Get image stretch algorithm in use\n", + "print(w.stretch)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "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": null, + "metadata": {}, + "outputs": [], + "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": null, + "metadata": {}, + "outputs": [], + "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": null, + "metadata": {}, + "outputs": [], + "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": null, + "metadata": {}, + "outputs": [], + "source": [ + "w.stretch = 'log'" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "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": null, + "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": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Turn it back off so marking (next cell) can be done.\n", + "w.click_center = False" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "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": null, + "metadata": {}, + "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": null, + "metadata": {}, + "outputs": [], + "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": null, + "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": null, + "metadata": {}, + "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", + "\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')" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 👆 ~FAILURE~ API CHANGE -- SAME NAME MEANS SAME MAKERS -- first two are not showing up as big red circles" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "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": null, + "metadata": {}, + "outputs": [], + "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~ 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": null, + "metadata": {}, + "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)" + ] + }, + { + "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": null, + "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": null, + "metadata": {}, + "outputs": [], + "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 (ipykernel)", + "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.10.5" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} 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 0a37a65..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 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 96% rename from example_notebooks/ginga_widget.ipynb rename to example_notebooks/ginga/ginga_widget.ipynb index c422fca..4da9b7e 100644 --- a/example_notebooks/ginga_widget.ipynb +++ b/example_notebooks/ginga/ginga_widget.ipynb @@ -20,7 +20,7 @@ "metadata": {}, "outputs": [], "source": [ - "from astrowidgets import ImageWidget" + "from astrowidgets.ginga import ImageWidget" ] }, { @@ -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 3daacb7..c02bb4e 100644 --- a/example_notebooks/gui_interactions.ipynb +++ b/example_notebooks/ginga/gui_interactions.ipynb @@ -32,7 +32,8 @@ "metadata": {}, "outputs": [], "source": [ - "from astrowidgets 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 0e724d2..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 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/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 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