From 7abdf62defddeecc5eb3da65d9cdb522d833f14a Mon Sep 17 00:00:00 2001 From: Pey Lian Lim <2090236+pllim@users.noreply.github.com> Date: Thu, 11 Feb 2021 18:22:10 -0500 Subject: [PATCH 1/7] WIP: Initial implementation of abstract class. Separated out the Ginga implementation. Re-organized and updated tests. --- .readthedocs.yml | 1 + README.rst | 2 +- astrowidgets/core.py | 1075 +++++++---------- astrowidgets/ginga.py | 594 +++++++++ astrowidgets/tests/test_api.py | 283 ----- astrowidgets/tests/test_image_widget.py | 320 ----- astrowidgets/tests/test_widget_api.py | 281 +++++ .../tests/test_widget_pixel_offset.py | 50 + astrowidgets/tests/test_widget_with_wcs.py | 110 ++ setup.cfg | 7 +- tox.ini | 4 +- 11 files changed, 1484 insertions(+), 1243 deletions(-) create mode 100644 astrowidgets/ginga.py delete mode 100644 astrowidgets/tests/test_api.py delete mode 100644 astrowidgets/tests/test_image_widget.py create mode 100644 astrowidgets/tests/test_widget_api.py create mode 100644 astrowidgets/tests/test_widget_pixel_offset.py create mode 100644 astrowidgets/tests/test_widget_with_wcs.py diff --git a/.readthedocs.yml b/.readthedocs.yml index c72bd78..91e18c4 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -16,6 +16,7 @@ python: path: . extra_requirements: - docs + - ginga # Don't build any extra formats formats: [] diff --git a/README.rst b/README.rst index 0f976a3..1a50375 100644 --- a/README.rst +++ b/README.rst @@ -14,7 +14,7 @@ Widgets for the Jupyter notebook and JupyterLab :alt: Documentation Status ``astrowidgets`` aims to be a set of astronomy widgets for Jupyter Lab or Notebook, -leveraging the Astropy ecosystem. The ``ImageWidget`` implements the API documented at +leveraging the Astropy ecosystem. The ``BaseImageWidget`` implements the API documented at `https://github.com/eteq/nb-astroimage-api `_ **Warning** diff --git a/astrowidgets/core.py b/astrowidgets/core.py index 674440e..7f61463 100644 --- a/astrowidgets/core.py +++ b/astrowidgets/core.py @@ -1,55 +1,50 @@ -"""Module containing core functionality of ``astrowidgets``.""" +"""Module containing an abstract base class defining the core functionality +of ``astrowidgets``. -# STDLIB -import functools -import warnings +A working implementation using Ginga is in `astrowidgets.ginga.ImageWidget`. + +""" +from abc import ABCMeta, abstractmethod -# THIRD-PARTY -import numpy as np from astropy.coordinates import SkyCoord -from astropy.io import fits -from astropy.table import Table, vstack +from astropy.table import vstack -# 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 -from ginga.util.wcs import raDegToString, decDegToString +__all__ = ['BaseImageWidget'] -__all__ = ['ImageWidget'] -# Allowed locations for cursor display -ALLOWED_CURSOR_LOCATIONS = ['top', 'bottom', None] +class _MetaWidget(ABCMeta, type(ipyw.VBox)): + pass -# List of marker names that are for internal use only -RESERVED_MARKER_SET_NAMES = ['all'] +class BaseImageWidget(ipyw.VBox, metaclass=_MetaWidget): + """Base class for image widget for Jupyter notebook. -class ImageWidget(ipyw.VBox): - """ - Image widget for Jupyter notebook using Ginga viewer. + .. note:: + + The constructor of subclass must assign ``self.marker`` after + calling ``super().__init__(...)``; also see `marker`. - .. todo:: Any property passed to constructor has to be valid keyword. + Subclass must implement necessary mouse and keyboard event + handlings not laid out here to make some of the documented methods, + attributes, and properties (e.g., `click_center`) work. + + In some cases, subclass might also want to re-implement non-abstract + API as needed, using the implementation in this base class as a guide. Parameters ---------- - logger : obj or ``None`` - Ginga logger. For example:: - - from ginga.misc.log import get_logger - logger = get_logger('my_viewer', log_stderr=False, - log_file='ginga.log', level=40) + image_widget : obj or `None`, optional + Widget for image display. If not given, ``ipywidgets.Image`` is used. - image_width, image_height : int - Dimension of Jupyter notebook's image widget. + cursor_widget : obj or `None`, optional + Widget for cursor information display. If not given, + ``ipywidgets.HTML`` is used. - use_opencv : bool - Let Ginga use ``opencv`` to speed up image transformation; - e.g., rotation and mosaic. If this is enabled and you - do not have ``opencv``, you will get a warning. + image_width : int, optional + Width of Jupyter notebook's image widget. + Height is automatically determined. pixel_coords_offset : int, optional An offset, typically either 0 or 1, to add/subtract to all @@ -57,58 +52,56 @@ class ImageWidget(ipyw.VBox): *In almost all situations the default value, ``0``, is the correct value to use.* - """ - - def __init__(self, logger=None, image_width=500, image_height=500, - use_opencv=True, pixel_coords_offset=0): - super().__init__() - - # TODO: Is this the best place for this? - if use_opencv: - try: - from ginga import trcalc - trcalc.use('opencv') - except ImportError: - warnings.warn('install opencv or set use_opencv=False') + Attributes + ---------- + children : list + Children of the ``ipywidgets.VBox`` that consists of the given + ``image_widget`` and ``cursor_widget`` to be displayed. - self._viewer = EnhancedCanvasView(logger=logger) + print_out : obj + ``ipywidgets.Output`` instance for printing output to a notebook + cell; this is especially useful for debugging. - self._pixel_offset = pixel_coords_offset + ALLOWED_CURSOR_LOCATIONS : list + Possible `cursor` widget placements relative to image widget. - self._jup_img = ipyw.Image(format='jpeg') + RESERVED_MARKER_SET_NAMES : list + Marker names reserved for internal use only. - # Set the image margin to over the widgets default of 2px on - # all sides. - self._jup_img.layout.margin = '0' - - # Set both of those to ensure consistent display in notebook - # and jupyterlab when the image is put into a container smaller - # than the image. + """ + def __init__(self, image_widget=None, cursor_widget=None, image_width=500, + pixel_coords_offset=0): + super().__init__() - self._jup_img.max_width = '100%' - self._jup_img.height = 'auto' + self.ALLOWED_CURSOR_LOCATIONS = ['top', 'bottom', None] + self.RESERVED_MARKER_SET_NAMES = ['all'] - # Set the width of the box containing the image to the desired width - self.layout.width = str(image_width) + if image_widget is None: + self._jup_img = ipyw.Image(format='jpeg') + else: + self._jup_img = image_widget - # Note we are NOT setting the height. That is because the height - # is automatically set by the image aspect ratio. + if cursor_widget is None: + self._jup_coord = ipyw.HTML('Coordinates show up here') + else: + self._jup_coord = cursor_widget - # 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 + if isinstance(self._jup_img, ipyw.Image): + # Set the image margin on all sides. + self._jup_img.layout.margin = '0' - self._viewer.set_widget(self._jup_img) + # 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' - # enable all possible keyboard and pointer operations - self._viewer.get_bindings().enable_all(True) + self._pixel_offset = pixel_coords_offset - # enable draw - self.dc = drawCatalog - self.canvas = self.dc.DrawingCanvas() - self.canvas.enable_draw(True) - self.canvas.enable_edit(True) + # Set the width of the box containing the image to the desired width + # Note: We are NOT setting the height. That is because the height + # is automatically set by the image aspect ratio. + self.layout.width = str(image_width) # Make sure all of the internal state trackers have a value # and start in a state which is definitely allowed: all are @@ -117,19 +110,11 @@ def __init__(self, logger=None, image_width=500, image_height=500, self._click_center = False 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 - bind_map.map_event(None, (), 'ms_right', 'contrast') - # Shift-right-click restores the default contrast - bind_map.map_event(None, ('shift',), 'ms_right', 'contrast_restore') + self._cached_state = {} # Marker - self.marker = {'type': 'circle', 'color': 'cyan', 'radius': 20} + self._marker_dict = {} + self._marker = None # Maintain marker tags as a set because we do not want # duplicate names. self._marktags = set() @@ -138,12 +123,6 @@ def __init__(self, logger=None, image_width=500, image_height=500, 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() @@ -151,12 +130,14 @@ def __init__(self, logger=None, image_width=500, image_height=500, self.children = [self._jup_img, self._jup_coord] @property - def logger(self): - """Logger for this widget.""" - return self._viewer.logger + @abstractmethod + def viewer(self): + """The underlying viewer tied to the backend.""" + pass @property def image_width(self): + """Width of image widget.""" return int(self._jup_img.width) @image_width.setter @@ -164,10 +145,10 @@ def image_width(self, value): # widgets expect width/height as strings, but most users will not, so # do the conversion. self._jup_img.width = str(value) - self._viewer.set_window_size(self.image_width, self.image_height) @property def image_height(self): + """Height of image widget.""" return int(self._jup_img.height) @image_height.setter @@ -175,148 +156,52 @@ def image_height(self, value): # widgets expect width/height as strings, but most users will not, so # do the conversion. 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 + """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. - """ - if self.cursor is None: # no-op - return - - image = viewer.get_image() - if image is not None: - ix = int(data_x + 0.5) - iy = int(data_y + 0.5) - try: - imval = viewer.get_data(ix, iy) - imval = '{:8.3f}'.format(imval) - except Exception: - imval = 'N/A' - - val = 'X: {:.2f}, Y: {:.2f}'.format(data_x + self._pixel_offset, - data_y + self._pixel_offset) - if image.wcs.wcs is not None: - try: - ra, dec = image.pixtoradec(data_x, data_y) - val += ' (RA: {}, DEC: {})'.format( - raDegToString(ra), decDegToString(dec)) - except Exception: - val += ' (RA, DEC: WCS error)' - - val += ', value: {}'.format(imval) - self._jup_coord.value = val - - def _mouse_click_cb(self, viewer, event, data_x, data_y): - """ - Callback to handle mouse clicks. - """ - if self.is_marking: - marker_name = self._interactive_marker_set_name - objs = [] - try: - c_mark = viewer.canvas.get_object_by_tag(marker_name) - except Exception: # Nothing drawn yet - pass - else: # Add to existing marks - objs = c_mark.objects - viewer.canvas.delete_object_by_tag(marker_name) - - # NOTE: By always using CompoundObject, marker handling logic - # is simplified. - obj = self._marker(x=data_x, y=data_y) - objs.append(obj) - viewer.canvas.add(self.dc.CompoundObject(*objs), - tag=marker_name) - self._marktags.add(marker_name) - with self.print_out: - print('Selected {} {}'.format(obj.x, obj.y)) - - elif self.click_center: - self.center_on((data_x, data_y)) - - 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) + return self._pixel_offset - def load_fits(self, fitsorfn, numhdu=None, memmap=None): - """ - Load a FITS file into the viewer. + @abstractmethod + def load_fits(self, filename, **kwargs): + """Load a FITS file 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 + Name of the FITS file. - numhdu : int or ``None`` - Extension number of the desired HDU. - If ``None``, it is determined automatically. - - memmap : bool or ``None`` - Memory mapping. - If ``None``, it is determined automatically. + kwargs : dict, optional + Keywords for the loader specific to the chosen backend, if any. """ - if isinstance(fitsorfn, str): - image = AstroImage(logger=self.logger, inherit_primary_header=True) - image.load_file(fitsorfn, numhdu=numhdu, memmap=memmap) - self._viewer.set_image(image) - - elif isinstance(fitsorfn, (fits.ImageHDU, fits.CompImageHDU, - fits.PrimaryHDU)): - self._viewer.load_hdu(fitsorfn) + pass - def load_nddata(self, nddata): - """ - Load an ``NDData`` object into the viewer. - - .. todo:: Add flag/masking support, etc. + @abstractmethod + def load_nddata(self, nddata, **kwargs): + """Load a `~astropy.nddata.NDData` object into the viewer. Parameters ---------- nddata : `~astropy.nddata.NDData` ``NDData`` with image data and WCS. - """ - from ginga.util.wcsmod.wcs_astropy import AstropyWCS - - image = AstroImage(logger=self.logger) - image.set_data(nddata.data) - _wcs = AstropyWCS(self.logger) - if nddata.wcs: - _wcs.load_header(nddata.wcs.to_header()) - - try: - image.set_wcs(_wcs) - except Exception as e: - print('Unable to set WCS from NDData: {}'.format(str(e))) - self._viewer.set_image(image) + kwargs : dict, optional + Keywords for the loader specific to the chosen backend, if any. - def load_array(self, arr): """ - Load a 2D array into the viewer. + pass + + @abstractmethod + def load_array(self, arr, **kwargs): + """Load a 2D array into the viewer. .. note:: Use :meth:`load_nddata` for WCS support. @@ -325,517 +210,418 @@ def load_array(self, arr): arr : array-like 2D array. + kwargs : dict, optional + Keywords for the loader specific to the chosen backend, if any. + """ - self._viewer.load_data(arr) + pass + @abstractmethod def center_on(self, point): - """ - Centers the view on a particular 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. + to be in data coordinates. If data coordinates is given, + `pixel_offset` needs to be taken into account as well. + """ - 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)) + pass + @abstractmethod def offset_to(self, dx, dy, skycoord_offset=False): - """ - Move the center to a point that is given offset + """Move the center to a point that is given offset away from the current center. Parameters ---------- dx, dy : float - Offset value. Unit is assumed based on + Offset values. Unit is assumed based on ``skycoord_offset``. - skycoord_offset : bool + skycoord_offset : bool, optional If `True`, offset must be given in degrees. Otherwise, they are in pixel values. """ - if skycoord_offset: - coord = 'wcs' - else: - coord = 'data' - - pan_x, pan_y = self._viewer.get_pan(coord=coord) - self._viewer.set_pan(pan_x + dx, pan_y + dy, coord=coord) + pass @property + @abstractmethod 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. """ - return self._viewer.get_scale() + pass @zoom_level.setter - def zoom_level(self, val): - if val == 'fit': - self._viewer.zoom_fit() - else: - self._viewer.scale_to(val, val) + @abstractmethod + def zoom_level(self, value): + pass - 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. + + .. note:: + + Subclass should overwrite this docstring with examples specific to its backend; e.g., using ``marker.__doc__``. - .. todo:: Add more examples. + .. note:: - Marker can be set as follows:: + Its setter must set the following: - {'type': 'circle', 'color': 'cyan', 'radius': 20} - {'type': 'cross', 'color': 'green', 'radius': 20} - {'type': 'plus', 'color': 'red', 'radius': 20} + * ``self._marker_dict`` with the given dictionary. + * ``self._marker`` with an **object** built from the given dictionaru for the backend. """ - # Change the marker from a very ginga-specific type (a partial - # of a ginga drawing canvas type) to a generic dict, which is - # what we expect the user to provide. - # - # That makes things like self.marker = self.marker work. return self._marker_dict @marker.setter - def marker(self, val): - # Make a new copy to avoid modifying the dict that the user passed in. - _marker = val.copy() - marker_type = _marker.pop('type') - if marker_type == 'circle': - self._marker = functools.partial(self.dc.Circle, **_marker) - elif marker_type == 'plus': - _marker['type'] = 'point' - _marker['style'] = 'plus' - self._marker = functools.partial(self.dc.Point, **_marker) - elif marker_type == 'cross': - _marker['type'] = 'point' - _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)) - # 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): + @abstractmethod + def marker(self, value): + # See notes for the marker property. + pass + + def get_marker_names(self): + """Return a list of used marker names. + + Returns + ------- + names : list of str + Sorted list of marker names. + """ - Return the locations of existing markers. + return sorted(self._marktags) + + @abstractmethod + def get_markers_by_name(self, marker_name, x_colname='x', y_colname='y', + skycoord_colname='coord'): + """Return the locations of markers for the given name. Parameters ---------- - x_colname, y_colname : str + marker_name : str + Available names can be obtained by calling :meth:`get_marker_names`. + + x_colname, y_colname : str, optional Column names for X and Y data coordinates. Coordinates returned are 0- or 1-indexed, depending - on ``self.pixel_offset``. + on `pixel_offset`. - skycoord_colname : str - Column name for ``SkyCoord``, which contains + skycoord_colname : str, optional + Column name for `~astropy.coordinates.SkyCoord`, which contains sky coordinates associated with the active image. This is ignored if image has no WCS. Returns ------- - markers_table : `~astropy.table.Table` or ``None`` - Table of markers, if any, or ``None``. - - """ - if marker_name is None: - marker_name = self._default_mark_tag_name - - if marker_name == 'all': - # If it wasn't for the fact that SKyCoord columns can't - # be stacked this would all fit nicely into a list - # comprehension. But they can't, so we delete the - # SkyCoord column if it is present, then add it - # back after we have stacked. - coordinates = [] - tables = [] - for name in self._marktags: - table = self.get_markers(x_colname=x_colname, - y_colname=y_colname, - skycoord_colname=skycoord_colname, - marker_name=name) - if table is None: - # No markers by this name, skip it - continue - - try: - coordinates.extend(c for c in table[skycoord_colname]) - except KeyError: - pass - else: - del table[skycoord_colname] - tables.append(table) - - stacked = vstack(tables, join_type='exact') - - if coordinates: - stacked[skycoord_colname] = SkyCoord(coordinates) - - return stacked - - # 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._marktags - and marker_name != self._default_mark_tag_name): - raise ValueError(f"No markers named '{marker_name}' found.") - - 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) + markers_table : `~astropy.table.Table` or `None` + Table of markers, if any, contains the following columns: + + * x (or as set by ``x_colname``) + * y (or as set by ``y_colname``) + * (OPTIONAL) coord (or as set by ``skycoord_colname``) -- Only if available + * marker name (from ``marker_name``) -- Useful for :meth:`get_all_markers` + + Raises + ------ + ValueError + Marker name is invalid. + + See also + -------- + get_all_markers + + """ + pass + + def get_all_markers(self, x_colname='x', y_colname='y', skycoord_colname='coord'): + """Run :meth:`get_markers_by_name` for all markers.""" + + # If it wasn't for the fact that SkyCoord columns can't + # be stacked this would all fit nicely into a list + # comprehension. But they can't, so we delete the + # SkyCoord column if it is present, then add it + # back after we have stacked. + coordinates = [] + tables = [] + for name in self._marktags: + table = self.get_markers_by_name( + name, x_colname=x_colname, y_colname=y_colname, + skycoord_colname=skycoord_colname) + if table is None: + continue # No markers by this name, skip it + + if skycoord_colname in table.colnames: + coordinates.extend(c for c in table[skycoord_colname]) + del table[skycoord_colname] + + tables.append(table) + + if len(tables) == 0: return None - image = self._viewer.get_image() - xy_col = [] + stacked = vstack(tables, join_type='exact') - if (image is None) or (image.wcs.wcs is None): - # Do not include SkyCoord column - include_skycoord = False - else: - include_skycoord = True - radec_col = [] - - # Extract coordinates from markers - for obj in c_mark.objects: - if obj.coord == 'data': - 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 - xy_col.append([np.nan, np.nan]) - radec_col.append([obj.x, obj.y]) - - # Convert to numpy arrays - xy_col = np.asarray(xy_col) # [[x0, y0], [x1, y1], ...] - - if include_skycoord: - # [[ra0, dec0], [ra1, dec1], ...] - radec_col = np.asarray(radec_col) - - # Fill in X,Y from RA,DEC - mask = np.isnan(xy_col[:, 0]) # One bool per row - if np.any(mask): - xy_col[mask] = image.wcs.wcspt_to_datapt(radec_col[mask]) - - # Fill in RA,DEC from X,Y - mask = np.isnan(radec_col[:, 0]) - if np.any(mask): - radec_col[mask] = image.wcs.datapt_to_wcspt(xy_col[mask]) - - 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( - [xy_col[:, 0], xy_col[:, 1], sky_col], - names=(x_colname, y_colname, skycoord_colname)) - else: - markers_table = Table(xy_col, names=(x_colname, 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) - # Either way, add the marker names - 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))) + return stacked + @abstractmethod def add_markers(self, table, x_colname='x', y_colname='y', skycoord_colname='coord', use_skycoord=False, marker_name=None): - """ - Creates markers in the image at given points. - - .. todo:: - - Later enhancements to include more columns - to control size/style/color of marks, + """Show markers in the image at given points using the current + marker style. Parameters ---------- table : `~astropy.table.Table` - Table containing marker locations. + Table containing marker locations. Compulsory columns depend on + ``use_skycoord``. - x_colname, y_colname : str - Column names for X and Y. - Coordinates can be 0- or 1-indexed, as - given by ``self.pixel_offset``. + x_colname, y_colname : str, optional + Column names for X and Y. Coordinates can be 0- or 1-indexed, as + given by `pixel_offset`. These are only used if + ``use_skycoord=False``. - skycoord_colname : str - Column name with ``SkyCoord`` objects. + skycoord_colname : str, optional + Column name with `~astropy.coordinates.SkyCoord` objects. + This is only used if ``use_skycoord=True``. - use_skycoord : bool - If `True`, use ``skycoord_colname`` to mark. - Otherwise, use ``x_colname`` and ``y_colname``. + use_skycoord : bool, optional + If `False`, mark using ``x_colname`` and ``y_colname``; + otherwise ``skycoord_colname``. - marker_name : str, optional - Name to assign the markers in the table. Providing a name - allows markers to be removed by name at a later time. - """ - # TODO: Resolve https://github.com/ejeschke/ginga/issues/672 - - # For now we always convert marker locations to pixels; see - # comment below. - coord_type = 'data' - - if marker_name is None: - marker_name = self._default_mark_tag_name - - self._validate_marker_name(marker_name) - - self._marktags.add(marker_name) - - # Extract coordinates from table. - # They are always arrays, not scalar. - if use_skycoord: - image = self._viewer.get_image() - if image is None: - raise ValueError('Cannot get image from viewer') - if image.wcs.wcs is None: - raise ValueError( - '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 - # 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) - # 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: - coord_x = np.array([coord_x]) - coord_y = np.array([coord_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 - else: - objs = c_mark.objects - self._viewer.canvas.delete_object_by_tag(marker_name) + marker_name : str or `None`, optional + Name to assign the markers in the table. If not given, an internal + default is used (set by the ``_default_mark_tag_name`` attribute). + A given name cannot fail :meth:`validate_marker_name` check. + Marker name used will be added to ``_marktags`` attribute. + + Raises + ------ + ValueError + Marker name is invalid or operation failed. - # 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) + See also + -------- + get_markers_by_name - def remove_markers(self, marker_name=None): """ - Remove some but not all of the markers by name used when - adding the markers + pass + + @abstractmethod + def remove_markers_by_name(self, marker_name): + """Remove all of the markers by the name used on addition. Parameters ---------- + marker_name : str + Name used when the markers were added. Available names can be + obtained by calling :meth:`get_marker_names`. + This name will be removed from ``_marktags`` attribute. - marker_name : str, optional - Name used when the markers were added. - """ - # TODO: - # arr : ``SkyCoord`` or array-like - # Sky coordinates or 2xN array. - # - # 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 - - 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))) - - try: - self._viewer.canvas.delete_object_by_tag(marker_name) - except KeyError: - raise KeyError('Unable to remove markers named {} from image. ' - ''.format(marker_name)) - else: - self._marktags.remove(marker_name) + Raises + ------ + ValueError + Marker name is invalid. + + See also + -------- + remove_all_markers - def reset_markers(self): - """ - Delete all markers. """ + pass + 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 list(self._marktags): - self.remove_markers(marker_name) + for marker_name in self.get_marker_names(): + self.remove_markers_by_name(marker_name) - @property - def stretch_options(self): + def validate_marker_name(self, marker_name): + """Validate a given marker name. + + Parameters + ---------- + marker_name : str + Marker name to validate. + + Raises + ------ + ValueError + It is not allowed because the name is already defined in the + ``RESERVED_MARKER_SET_NAMES`` attribute. + + """ + if marker_name in self.RESERVED_MARKER_SET_NAMES: + raise ValueError( + f"The marker name {marker_name} is not allowed. Any name is " + f"allowed except these: {', '.join(self.RESERVED_MARKER_SET_NAMES)}") + + def set_cached_state(self): + """Cache the following attributes before modifying their states: + + * ``click_center`` + * ``click_drag`` + * ``scroll_pan`` + + This is used in :meth:`start_marking`, for example. """ - List all available options for image stretching. + 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. """ - return self._viewer.get_color_algorithms() + 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 + @abstractmethod + def stretch_options(self): + """List of all available options for image stretching.""" + pass @property + @abstractmethod def stretch(self): - """ - The image stretching algorithm in use. - """ - return self._viewer.rgbmap.dist + """The image stretching algorithm in use.""" + pass - # TODO: Possible to use astropy.visualization directly? @stretch.setter - def stretch(self, val): - 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) + @abstractmethod + def stretch(self, value): + pass @property + @abstractmethod def autocut_options(self): - """ - List all available options for image auto-cut. - """ - return self._viewer.get_autocut_methods() + """List of all available options for image auto-cut.""" + pass @property + @abstractmethod def cuts(self): + """Current image cut levels as ``(low, high)``. + + To set new cut levels, provide one of the following: + + * A tuple of ``(low, high)`` values. + * One of the options returned by `autocut_options`. + """ - 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() + pass - # TODO: Possible to use astropy.visualization directly? @cuts.setter - def cuts(self, val): - if isinstance(val, 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]) + @abstractmethod + def cuts(self, value): + pass @property + @abstractmethod def colormap_options(self): - """List of colormap names.""" - from ginga import cmap - return cmap.get_names() + """List of available colormap names.""" + pass + @abstractmethod def set_colormap(self, cmap): - """ - Set colormap to the given colormap name. + """Set colormap to the given name. Parameters ---------- @@ -844,68 +630,83 @@ def set_colormap(self, cmap): :meth:`colormap_options`. """ - self._viewer.set_color_map(cmap) + pass @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 @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 @@ -913,25 +714,22 @@ 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') self._click_drag = value - bindmap = self._viewer.get_bindmap() if value: # Only turn off click_center if click_drag is being set to True self.click_center = False - bindmap.map_event(None, (), 'ms_left', 'pan') - else: - bindmap.map_event(None, (), 'ms_left', 'cursor') @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 @@ -939,19 +737,26 @@ def scroll_pan(self): def scroll_pan(self, value): if not isinstance(value, bool): raise ValueError('scroll_pan must be either True or False') - - bindmap = self._viewer.get_bindmap() self._scroll_pan = value - if value: - bindmap.map_event(None, (), 'pa_pan', 'pan') - else: - bindmap.map_event(None, (), 'pa_pan', 'zoom') - def save(self, filename): - """ - Save out the current image view to given PNG filename. + @abstractmethod + def save(self, filename, overwrite=False): + """Save the current image view to given filename. + File type (e.g., PNG) is assumed from the given extension. + + Parameters + ---------- + filename : str + Image filename. If you want to save it in a different + directory, provide the full path. + + overwrite : bool, optional + Overwrite existing file with the same name. + + Raises + ------ + ValueError + Invalid input. + """ - # It turns out the image value is already in PNG format so we just - # to write that out to a file. - with open(filename, 'wb') as f: - f.write(self._jup_img.value) + pass diff --git a/astrowidgets/ginga.py b/astrowidgets/ginga.py new file mode 100644 index 0000000..5b6879a --- /dev/null +++ b/astrowidgets/ginga.py @@ -0,0 +1,594 @@ +"""Module containing ``astrowidgets`` implemented with Ginga backend. + +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 + +import numpy as np +from astropy.coordinates import SkyCoord +from astropy.io import fits +from astropy.table import Table + +from ginga.AstroImage import AstroImage +from ginga.canvas.CanvasObject import drawCatalog +from ginga.web.jupyterw.ImageViewJpw import EnhancedCanvasView +from ginga.util.wcs import raDegToString, decDegToString + +from astrowidgets.core import BaseImageWidget + +__all__ = ['ImageWidget'] + + +class ImageWidget(BaseImageWidget): + """Image widget for Jupyter notebook using Ginga viewer. + + .. todo:: Any property passed to constructor has to be valid keyword. + + Parameters + ---------- + logger : obj + Ginga logger. For example:: + + from ginga.misc.log import get_logger + logger = get_logger('my_viewer', log_stderr=False, + log_file='ginga.log', level=40) + + use_opencv : bool, optional + Let Ginga use ``opencv`` to speed up image transformation; + e.g., rotation and mosaic. If this is enabled and you + do not have ``opencv``, you will get a warning. + + image_height : int, optional + Height of Jupyter notebook's image widget. + + image_width, pixel_coords_offset : int, optional + See `~astrowidgets.core.BaseImageWidget`. + + """ + + def __init__(self, logger, use_opencv=True, image_width=500, + image_height=500, pixel_coords_offset=0): + super().__init__(image_width=image_width, + pixel_coords_offset=pixel_coords_offset) + + # TODO: Is this the best place for this? + if use_opencv: + try: + from ginga import trcalc + trcalc.use('opencv') + except ImportError: + logger.warning('install opencv or set use_opencv=False') + + # 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 + self._viewer.get_bindings().enable_all(True) + + # Enable draw + self.dc = drawCatalog + self.canvas = self.dc.DrawingCanvas() + self.canvas.enable_draw(True) + self.canvas.enable_edit(True) + + # 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 + bind_map.map_event(None, (), 'ms_right', 'contrast') + # Shift-right-click restores the default contrast + bind_map.map_event(None, ('shift',), 'ms_right', 'contrast_restore') + + # Marker + self.marker = {'type': 'circle', 'color': 'cyan', 'radius': 20} + + # 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) + + @property + def viewer(self): + return self._viewer + + @property + def logger(self): + """Logger for this widget.""" + return self._viewer.logger + + # Need this here because we need to overwrite the setter. + @property + def image_width(self): + return super().image_width + + @image_width.setter + def image_width(self, value): + # widgets expect width/height as strings, but most users will not, so + # do the conversion. + self._jup_img.width = str(value) + self._viewer.set_window_size(self.image_width, self.image_height) + + # Need this here because we need to overwrite the setter. + @property + def image_height(self): + return super().image_height + + @image_height.setter + def image_height(self, value): + # widgets expect width/height as strings, but most users will not, so + # do the conversion. + self._jup_img.height = str(value) + self._viewer.set_window_size(self.image_width, self.image_height) + + def _mouse_move_cb(self, viewer, button, data_x, data_y): + """Callback to display position in RA/DEC deg.""" + if self.cursor is None: # no-op + return + + image = viewer.get_image() + if image is not None: + ix = int(data_x + 0.5) + iy = int(data_y + 0.5) + try: + imval = viewer.get_data(ix, iy) + imval = f'{imval:8.3f}' + except Exception: + imval = 'N/A' + + val = (f'X: {data_x + self._pixel_offset:.2f}, ' + f'Y: {data_y + self._pixel_offset:.2f}') + + if image.wcs.wcs is not None: + try: + ra, dec = image.pixtoradec(data_x, data_y) + val += (f' (RA: {raDegToString(ra)},' + f' DEC: {decDegToString(dec)})') + except Exception: + val += ' (RA, DEC: WCS error)' + + 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.""" + if self.is_marking: + marker_name = self._interactive_marker_set_name + objs = [] + try: + c_mark = viewer.canvas.get_object_by_tag(marker_name) + except Exception: # Nothing drawn yet + pass + else: # Add to existing marks + objs = c_mark.objects + viewer.canvas.delete_object_by_tag(marker_name) + + # NOTE: By always using CompoundObject, marker handling logic + # is simplified. + obj = self._marker(x=data_x, y=data_y) + objs.append(obj) + viewer.canvas.add(self.dc.CompoundObject(*objs), tag=marker_name) + self._marktags.add(marker_name) + + # For debugging. + with self.print_out: + 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(f'Centered on X={data_x + self._pixel_offset} ' + f'Y={data_y + self._pixel_offset}') + + def load_fits(self, filename, numhdu=None, memmap=None): + """Load a FITS file or HDU into the viewer. + + Parameters + ---------- + 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 not given, it is + determined automatically. This is only used if a filename is given. + + 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(filename, str): + image = AstroImage(logger=self.logger, inherit_primary_header=True) + image.load_file(filename, numhdu=numhdu, memmap=memmap) + self._viewer.set_image(image) + + elif isinstance(filename, (fits.ImageHDU, fits.CompImageHDU, + fits.PrimaryHDU)): + self._viewer.load_hdu(filename) + else: + raise ValueError(f'Unable to open {filename}') + + def load_nddata(self, nddata): + """Load a `~astropy.nddata.NDData` object into the viewer. + + .. todo:: Add flag/masking support, etc. + + Parameters + ---------- + nddata : `~astropy.nddata.NDData` + ``NDData`` with image data and WCS. + + """ + from ginga.util.wcsmod.wcs_astropy import AstropyWCS + + image = AstroImage(logger=self.logger) + image.set_data(nddata.data) + _wcs = AstropyWCS(self.logger) + if nddata.wcs: + _wcs.load_header(nddata.wcs.to_header()) + + try: + image.set_wcs(_wcs) + except Exception as 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. + + .. note:: Use :meth:`load_nddata` for WCS support. + + Parameters + ---------- + arr : array-like + 2D array. + + """ + self._viewer.load_data(arr) + + def center_on(self, point): + if isinstance(point, SkyCoord): + self._viewer.set_pan(point.ra.deg, point.dec.deg, coord='wcs') + else: + self._viewer.set_pan(*(np.asarray(point) - self._pixel_offset)) + + def offset_to(self, dx, dy, skycoord_offset=False): + if skycoord_offset: + coord = 'wcs' + else: + coord = 'data' + + pan_x, pan_y = self._viewer.get_pan(coord=coord) + self._viewer.set_pan(pan_x + dx, pan_y + dy, coord=coord) + + @property + def zoom_level(self): + return self._viewer.get_scale() + + zoom_level.__doc__ = (BaseImageWidget.zoom_level.__doc__ + + "``'fit'`` means zoom to fit") + + @zoom_level.setter + def zoom_level(self, value): + if value == 'fit': + self._viewer.zoom_fit() + else: + self._viewer.scale_to(value, value) + + @property + def marker(self): + """A dictionary defining the current marker properties. + + .. todo:: Add more examples. + + Marker can be set as follows:: + + {'type': 'circle', 'color': 'cyan', 'radius': 20} + {'type': 'cross', 'color': 'green', 'radius': 20} + {'type': 'plus', 'color': 'red', 'radius': 20} + + """ + # Change the marker from a very ginga-specific type (a partial + # of a ginga drawing canvas type) to a generic dict, which is + # what we expect the user to provide. + # + # That makes things like self.marker = self.marker work. + return super().marker + + @marker.setter + def marker(self, value): + # Make a new copy to avoid modifying the dict that the user passed in. + _marker = value.copy() + marker_type = _marker.pop('type') + if marker_type == 'circle': + self._marker = functools.partial(self.dc.Circle, **_marker) + elif marker_type == 'plus': + _marker['type'] = 'point' + _marker['style'] = 'plus' + self._marker = functools.partial(self.dc.Point, **_marker) + elif marker_type == 'cross': + _marker['type'] = 'point' + _marker['style'] = 'cross' + self._marker = functools.partial(self.dc.Point, **_marker) + else: # TODO: Implement more shapes + raise NotImplementedError(f'Marker type "{marker_type}" not supported') + # Only set this once we have successfully created a marker + self._marker_dict = value + + 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. + if (marker_name not in self._marktags + and marker_name != self._default_mark_tag_name): + raise ValueError(f"No markers named '{marker_name}' found.") + + try: + c_mark = self._viewer.canvas.get_object_by_tag(marker_name) + except Exception: + # 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() + xy_col = [] + + if (image is None) or (image.wcs.wcs is None): + # Do not include SkyCoord column + include_skycoord = False + else: + include_skycoord = True + radec_col = [] + + # Extract coordinates from markers + for obj in c_mark.objects: + if obj.coord == 'data': + 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(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]) + + # Convert to numpy arrays + xy_col = np.asarray(xy_col) # [[x0, y0], [x1, y1], ...] + + if include_skycoord: + # [[ra0, dec0], [ra1, dec1], ...] + radec_col = np.asarray(radec_col) + + # Fill in X,Y from RA,DEC + mask = np.isnan(xy_col[:, 0]) # One bool per row + if np.any(mask): + xy_col[mask] = image.wcs.wcspt_to_datapt(radec_col[mask]) + + # Fill in RA,DEC from X,Y + mask = np.isnan(radec_col[:, 0]) + if np.any(mask): + radec_col[mask] = image.wcs.datapt_to_wcspt(xy_col[mask]) + + 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( + [xy_col[:, 0], xy_col[:, 1], sky_col], + names=(x_colname, y_colname, skycoord_colname)) + else: + markers_table = Table(xy_col, names=(x_colname, y_colname)) + + # Either way, add the marker names + markers_table['marker name'] = marker_name + return markers_table + + # 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. + coord_type = 'data' + + if marker_name is None: + marker_name = self._default_mark_tag_name + + self.validate_marker_name(marker_name) + self._marktags.add(marker_name) + + # Extract coordinates from table. + # They are always arrays, not scalar. + if use_skycoord: + image = self._viewer.get_image() + if image is None: + raise ValueError('Cannot get image from viewer') + if image.wcs.wcs is None: + raise ValueError( + '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 + # to pixel coordinates. + # 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: + coord_x = np.array([coord_x]) + coord_y = np.array([coord_y]) + else: # Use X,Y + coord_x = table[x_colname].data + coord_y = table[y_colname].data + # Convert data coordinates from 1-indexed to 0-indexed + if self._pixel_offset != 0: + # Don't use the in-place operator -= here that modifies + # the input table. + coord_x = coord_x - self._pixel_offset + coord_y = coord_y - self._pixel_offset + + # Prepare canvas and retain existing marks + try: + c_mark = self._viewer.canvas.get_object_by_tag(marker_name) + except Exception: + objs = [] + else: + objs = c_mark.objects + self._viewer.canvas.delete_object_by_tag(marker_name) + + # 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) + + def remove_markers_by_name(self, marker_name): + # TODO: + # arr : ``SkyCoord`` or array-like + # Sky coordinates or 2xN array. + # + # NOTE: How to match? Use np.isclose? + # What if there are 1-to-many matches? + + self.validate_marker_name(marker_name) + if marker_name not in 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: + self.logger.error(f'Unable to remove markers named {marker_name} from image.') + else: + self._marktags.remove(marker_name) + + @property + def stretch_options(self): + return self._viewer.get_color_algorithms() + + @property + def stretch(self): + return self._viewer.rgbmap.dist + + # TODO: Possible to use astropy.visualization directly? + @stretch.setter + def stretch(self, value): + valid_vals = self.stretch_options + 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): + return self._viewer.get_autocut_methods() + + @property + def cuts(self): + return self._viewer.get_cut_levels() + + # TODO: Possible to use astropy.visualization directly? + @cuts.setter + def cuts(self, value): + if isinstance(value, str): # Autocut + valid_vals = self.autocut_options + 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): + from ginga import cmap + return cmap.get_names() + + def set_colormap(self, cmap): + self._viewer.set_color_map(cmap) + + # Need this here because we need to overwrite the setter. + @property + def click_drag(self): + return super().click_drag + + @click_drag.setter + def click_drag(self, value): + if not isinstance(value, bool): + raise ValueError('click_drag must be either True or False') + if self.is_marking and value: + raise ValueError('Interactive marking is in progress. Call ' + 'stop_marking() to end marking before setting ' + 'click_drag') + self._click_drag = value + bindmap = self._viewer.get_bindmap() + if value: + # Only turn off click_center if click_drag is being set to True + self.click_center = False + bindmap.map_event(None, (), 'ms_left', 'pan') + else: + bindmap.map_event(None, (), 'ms_left', 'cursor') + + # Need this here because we need to overwrite the setter. + @property + def scroll_pan(self): + return super().scroll_pan + + @scroll_pan.setter + def scroll_pan(self, value): + if not isinstance(value, bool): + raise ValueError('scroll_pan must be either True or False') + + bindmap = self._viewer.get_bindmap() + self._scroll_pan = value + if value: + bindmap.map_event(None, (), 'pa_pan', 'pan') + else: + bindmap.map_event(None, (), 'pa_pan', 'zoom') + + def save(self, filename, overwrite=False): + if os.path.exists(filename) and not overwrite: + raise ValueError(f'{filename} exists, use overwrite=True to force overwrite') + + ext = os.path.splitext(filename)[1].lower() + if ext != '.png': + raise ValueError(f'Extension {ext} not supported, use .png') + + # It turns out the image value is already in PNG format so we just + # to write that out to a file. + with open(filename, 'wb') as f: + f.write(self._jup_img.value) + + self.logger.info(f'{filename} written') diff --git a/astrowidgets/tests/test_api.py b/astrowidgets/tests/test_api.py deleted file mode 100644 index 396aff1..0000000 --- a/astrowidgets/tests/test_api.py +++ /dev/null @@ -1,283 +0,0 @@ -import numpy as np - -import pytest - -from astropy.io import fits -from astropy.nddata import NDData -from astropy.table import Table - -from ginga.ColorDist import ColorDistBase - -from ..core import ImageWidget, ALLOWED_CURSOR_LOCATIONS - - -def test_load_fits(): - image = ImageWidget() - data = np.random.random([100, 100]) - hdu = fits.PrimaryHDU(data=data) - image.load_fits(hdu) - - -def test_load_nddata(): - image = ImageWidget() - data = np.random.random([100, 100]) - nddata = NDData(data) - image.load_nddata(nddata) - - -def test_load_array(): - image = ImageWidget() - data = np.random.random([100, 100]) - image.load_array(data) - - -def test_center_on(): - image = ImageWidget() - x = 10 - y = 10 - image.center_on((x, y)) - - -def test_offset_to(): - image = ImageWidget() - dx = 10 - dy = 10 - image.offset_to(dx, dy) - - -def test_zoom_level(): - image = ImageWidget() - image.zoom_level = 5 - assert image.zoom_level == 5 - - -def test_zoom(): - image = ImageWidget() - image.zoom_level = 3 - val = 2 - image.zoom(val) - assert image.zoom_level == 6 - - -@pytest.mark.xfail(reason='Not implemented yet') -def test_select_points(): - image = ImageWidget() - image.select_points() - - -def test_get_selection(): - image = ImageWidget() - marks = image.get_markers() - assert isinstance(marks, Table) or marks is None - - -def test_stop_marking(): - image = ImageWidget() - # This is not much of a test... - image.stop_marking(clear_markers=True) - assert image.get_markers() is None - assert image.is_marking is False - - -def test_is_marking(): - image = ImageWidget() - assert image.is_marking in [True, False] - with pytest.raises(AttributeError): - image.is_marking = True - - -def test_start_marking(): - image = ImageWidget() - - # Setting these to check that start_marking affects them. - image.click_center = True - assert image.click_center - image.scroll_pan = False - assert not image.scroll_pan - - marker_style = {'color': 'yellow', 'radius': 10, 'type': 'cross'} - image.start_marking(marker_name='something', - marker=marker_style) - assert image.is_marking - assert image.marker == marker_style - assert not image.click_center - assert not image.click_drag - - # scroll_pan better activate when marking otherwise there is - # no way to pan while interactively marking - assert image.scroll_pan - - # Make sure that when we stop_marking we get our old - # controls back. - image.stop_marking() - assert image.click_center - assert not image.scroll_pan - - # Make sure that click_drag is restored as expected - image.click_drag = True - image.start_marking() - assert not image.click_drag - image.stop_marking() - assert image.click_drag - - -def test_add_markers(): - image = ImageWidget() - table = Table(data=np.random.randint(0, 100, [5, 2]), - names=['x', 'y'], dtype=('int', 'int')) - image.add_markers(table, x_colname='x', y_colname='y', - skycoord_colname='coord') - - -def test_set_markers(): - image = ImageWidget() - image.marker = {'color': 'yellow', 'radius': 10, 'type': 'cross'} - assert 'cross' in str(image.marker) - assert 'yellow' in str(image.marker) - assert '10' in str(image.marker) - - -def test_reset_markers(): - image = ImageWidget() - # First test: this shouldn't raise any errors - # (it also doesn't *do* anything...) - image.reset_markers() - assert image.get_markers() is None - table = Table(data=np.random.randint(0, 100, [5, 2]), - names=['x', 'y'], dtype=('int', 'int')) - image.add_markers(table, x_colname='x', y_colname='y', - skycoord_colname='coord', marker_name='test') - image.add_markers(table, x_colname='x', y_colname='y', - skycoord_colname='coord', marker_name='test2') - image.reset_markers() - with pytest.raises(ValueError): - image.get_markers(marker_name='test') - with pytest.raises(ValueError): - image.get_markers(marker_name='test2') - - -def test_remove_markers(): - image = ImageWidget() - # Add a tag name... - image._marktags.add(image._default_mark_tag_name) - with pytest.raises(ValueError) as e: - image.remove_markers('arf') - assert 'arf' in str(e.value) - - -def test_stretch(): - image = ImageWidget() - with pytest.raises(ValueError) as e: - image.stretch = 'not a valid value' - assert 'must be one of' in str(e.value) - - image.stretch = 'log' - assert isinstance(image.stretch, (ColorDistBase)) - - -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 - - image.cuts = 'histogram' - assert image.cuts == (0.0, 0.0) - - image.cuts = [10, 100] - assert image.cuts == (10, 100) - - -def test_colormap(): - image = ImageWidget() - cmap_desired = 'gray' - cmap_list = image.colormap_options - assert len(cmap_list) > 0 and cmap_desired in cmap_list - - image.set_colormap(cmap_desired) - - -def test_cursor(): - image = ImageWidget() - assert image.cursor in ALLOWED_CURSOR_LOCATIONS - with pytest.raises(ValueError): - image.cursor = 'not a valid option' - image.cursor = 'bottom' - assert image.cursor == 'bottom' - - -def test_click_drag(): - image = ImageWidget() - # Set this to ensure that click_drag turns it off - image._click_center = True - - # Make sure that setting click_drag to False does not turn off - # click_center. - - image.click_drag = False - assert image.click_center - - image.click_drag = True - - assert not image.click_center - - # If is_marking is true then trying to click_drag - # should fail. - image._is_marking = True - with pytest.raises(ValueError) as e: - image.click_drag = True - assert 'Interactive marking' in str(e.value) - - -def test_click_center(): - image = ImageWidget() - assert (image.click_center is True) or (image.click_center is False) - - # Set click_drag True and check that click_center affects it appropriately - image.click_drag = True - - image.click_center = False - assert image.click_drag - - image.click_center = True - assert not image.click_drag - - image.start_marking() - # If marking is in progress then setting click center should fail - with pytest.raises(ValueError) as e: - image.click_center = True - assert 'Cannot set' in str(e.value) - - # setting to False is fine though so no error is expected here - image.click_center = False - - -def test_scroll_pan(): - image = ImageWidget() - - # Make sure scroll_pan is actually settable - for val in [True, False]: - image.scroll_pan = val - assert image.scroll_pan is val - - -def test_save(): - image = ImageWidget() - filename = 'woot.png' - image.save(filename) - - -def test_width_height(): - image = ImageWidget(image_width=250, image_height=100) - assert image.image_width == 250 - assert image.image_height == 100 diff --git a/astrowidgets/tests/test_image_widget.py b/astrowidgets/tests/test_image_widget.py deleted file mode 100644 index 4f4f142..0000000 --- a/astrowidgets/tests/test_image_widget.py +++ /dev/null @@ -1,320 +0,0 @@ -import re - -import pytest -import numpy as np -from astropy.table import Table, vstack -from astropy.wcs import WCS -from astropy.nddata import CCDData -from astropy.coordinates import SkyCoord - -from ..core import ImageWidget, RESERVED_MARKER_SET_NAMES - - -def _make_fake_ccd(with_wcs=True): - """ - Generate a CCDData object for use with ImageWidget tests. - - Parameters - ---------- - - with_wcs : bool, optional - If ``True`` the image will have a WCS attached to it, - which is useful for some of the marker tests. - - Returns - ------- - - `astropy.nddata.CCDData` - CCD image - """ - npix_side = 100 - fake_image = np.random.randn(npix_side, npix_side) - if with_wcs: - wcs = WCS(naxis=2) - wcs.wcs.crpix = (fake_image.shape[0] / 2, fake_image.shape[1] / 2) - wcs.wcs.ctype = ('RA---TAN', 'DEC--TAN') - wcs.wcs.crval = (314.275419158, 31.6662781301) - wcs.wcs.pc = [[0.000153051015113, -3.20700931602e-05], - [3.20704370872e-05, 0.000153072382405]] - else: - wcs = None - - return CCDData(data=fake_image, wcs=wcs, unit='adu') - - -def test_setting_image_width_height(): - image = ImageWidget() - width = 200 - height = 300 - image.image_width = width - image.image_height = height - assert image._viewer.get_window_size() == (width, height) - - -def test_add_marker_does_not_modify_input_table(): - # Regression test for #45 - # Adding markers should not modify the input data table - image = ImageWidget(image_width=300, image_height=300, - pixel_coords_offset=5) - data = np.random.random([300, 300]) - image.load_array(data) - x = [20, 30, 40] - y = [40, 80, 100] - # Create two separate tables for comparison after add_markers. - orig_table = Table(data=[x, y], names=['x', 'y']) - in_table = Table(data=[x, y], names=['x', 'y']) - image.add_markers(in_table) - assert (in_table == orig_table).all() - - -def test_adding_markers_as_world_recovers_with_get_markers(): - """ - Make sure that our internal conversion from world to pixel - coordinates doesn't mess anything up. - """ - fake_ccd = _make_fake_ccd(with_wcs=True) - npix_side = fake_ccd.shape[0] - wcs = fake_ccd.wcs - iw = ImageWidget(pixel_coords_offset=0) - iw.load_nddata(fake_ccd) - # Get me 100 positions please, not right at the edge - marker_locs = np.random.randint(10, - high=npix_side - 10, - size=(100, 2)) - marks_pix = Table(data=marker_locs, names=['x', 'y']) - marks_world = wcs.all_pix2world(marker_locs, 0) - 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() - # 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']) - np.testing.assert_allclose(result['coord'].ra.deg, - mark_coord_table['coord'].ra.deg) - np.testing.assert_allclose(result['coord'].dec.deg, - 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 - """ - npix_side = 200 - image = ImageWidget(image_width=npix_side, - image_height=npix_side) - x = np.array([20, 30, 40]) - y = np.array([40, 80, 100]) - - # This should succeed without error - image.add_markers(Table(data=[x, y], names=['x', 'y']), - marker_name='nonsense') - - # The name 'nonsense', and nothing else, should be in the - # set of markers. - assert set(['nonsense']) == image._marktags - - # Add more markers with the same name - # This should succeed without error - image.add_markers(Table(data=[x, y], names=['x', 'y']), - marker_name='nonsense') - - # check that we get the right number of markers - marks = image.get_markers(marker_name='nonsense') - assert len(marks) == 6 - - # Make sure setting didn't change the default name - assert image._default_mark_tag_name == 'default-marker-name' - - # Try adding markers without a name - image.add_markers(Table(data=[x, y], names=['x', 'y'])) - assert image._marktags == set(['nonsense', image._default_mark_tag_name]) - - # Delete just the nonsense markers - image.remove_markers('nonsense') - - assert 'nonsense' not in image._marktags - assert image._default_mark_tag_name in image._marktags - - # Add the nonsense markers back... - image.add_markers(Table(data=[x, y], names=['x', 'y']), - marker_name='nonsense') - # ...and now delete all of the markers - image.reset_markers() - # We should have no markers on the image - assert image._marktags == set() - - # Simulate a mouse click and make sure the expected marker - # name has been added. - data_x = 50 - data_y = 50 - image._is_marking = True - image._mouse_click_cb(image._viewer, None, data_x, data_y) - assert image._interactive_marker_set_name in image._marktags - - -def test_mark_with_reserved_name_raises_error(): - npix_side = 200 - image = ImageWidget(image_width=npix_side, - image_height=npix_side) - x = np.array([20, 30, 40]) - y = np.array([40, 80, 100]) - for name in RESERVED_MARKER_SET_NAMES: - with pytest.raises(ValueError): - image.add_markers(Table(data=[x, y], names=['x', 'y']), - marker_name=name) - - -def test_get_marker_with_names(): - # Check a few ways of getting markers out - npix_side = 200 - image = ImageWidget(image_width=npix_side, - image_height=npix_side) - - x = np.array([20, 30, 40]) - y = np.array([40, 80, 100]) - input_markers = Table(data=[x, y], names=['x', 'y']) - # Add some markers with our own name - image.add_markers(input_markers, marker_name='nonsense') - - # Add same markers without a name so that name defaults to - # image._default_mark_tag_name - image.add_markers(input_markers) - - # Add pseudo-interactive points - image._is_marking = True - for data_x, data_y in input_markers: - image._mouse_click_cb(image._viewer, None, data_x, data_y) - - # Should have three sets of markers: nonsense, default non-interactive, - # interactive - assert len(image._marktags) == 3 - - for marker in image._marktags: - out_table = image.get_markers(marker_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') - - # That should have given us three copies of the input table - expected = vstack([input_markers] * 3, join_type='exact') - - # Sort before comparing - expected.sort(['x', 'y']) - all_marks.sort(['x', 'y']) - - assert (expected['x'] == all_marks['x']).all() - assert (expected['y'] == all_marks['y']).all() - - -def test_unknown_marker_name_error(): - """ - Regression test for https://github.com/astropy/astrowidgets/issues/97 - - This particular test checks that getting a marker name that - does not exist raises an error. - """ - iw = ImageWidget() - bad_name = 'not a real marker name' - with pytest.raises(ValueError) as e: - iw.get_markers(marker_name=bad_name) - - assert f"No markers named '{bad_name}'" in str(e.value) - - -def test_marker_name_has_no_marks_warning(): - """ - Regression test for https://github.com/astropy/astrowidgets/issues/97 - - This particular test checks that getting an empty table gives a - useful warning message. - """ - iw = ImageWidget() - bad_name = 'empty marker set' - iw.start_marking(marker_name=bad_name) - - with pytest.warns(UserWarning) as record: - iw.get_markers(marker_name=bad_name) - - assert f"Marker set named '{bad_name}' is empty" in str(record[0].message) - - -def test_empty_marker_name_works_with_all(): - """ - Regression test for https://github.com/astropy/astrowidgets/issues/97 - - This particular test checks that an empty table doesn't break - marker_name='all'. The bug only comes up if there is a coordinate - column, so use a fake image a WCS. - """ - iw = ImageWidget() - fake_ccd = _make_fake_ccd(with_wcs=True) - iw.load_nddata(fake_ccd) - - x = np.array([20, 30, 40]) - y = np.array([40, 80, 100]) - input_markers = Table(data=[x, y], names=['x', 'y']) - # Add some markers with our own name - iw.add_markers(input_markers, marker_name='nonsense') - - # Start marking to create a new marker set that is empty - iw.start_marking(marker_name='empty') - - marks = iw.get_markers(marker_name='all') - assert len(marks) == len(x) - assert 'empty' not in marks['marker name'] - - -def test_add_single_marker(): - """ - Test a few things related to naming marker sets - """ - fake_ccd = _make_fake_ccd(with_wcs=True) - npix_side = fake_ccd.shape[0] - wcs = fake_ccd.wcs - iw = ImageWidget(pixel_coords_offset=0) - iw.load_nddata(fake_ccd) - # Get me 100 positions please, not right at the edge - marker_locs = np.random.randint(10, - high=npix_side - 10, - size=(100, 2)) - marks_world = wcs.all_pix2world(marker_locs, 0) - marks_coords = SkyCoord(marks_world, unit='degree') - mark_coord_table = Table(data=[marks_coords], names=['coord']) - iw.add_markers(mark_coord_table[0], use_skycoord=True) diff --git a/astrowidgets/tests/test_widget_api.py b/astrowidgets/tests/test_widget_api.py new file mode 100644 index 0000000..dc1560b --- /dev/null +++ b/astrowidgets/tests/test_widget_api.py @@ -0,0 +1,281 @@ +# TODO: How to enable switching out backend and still run the same tests? + +import pytest + +ginga = pytest.importorskip("ginga") + +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 ginga.ColorDist import ColorDistBase # noqa: E402 +from ginga.misc.log import get_logger # noqa: E402 + +from astrowidgets.ginga import ImageWidget # noqa: E402 + + +class TestGingaWidget: + def setup_class(self): + rng = np.random.default_rng(1234) + self.data = rng.random((100, 100)) + + logger = get_logger('my_viewer', log_stderr=False, level=30) + self.image = ImageWidget(logger, image_width=250, image_height=100) + + def test_width_height(self): + assert self.image.image_width == 250 + assert self.image.image_height == 100 + + width = 200 + height = 300 + self.image.image_width = width + self.image.image_height = height + assert self.image.image_width == width + assert self.image.image_height == height + assert self.image.viewer.get_window_size() == (width, height) + + def test_load_fits(self): + hdu = fits.PrimaryHDU(data=self.data) + self.image.load_fits(hdu) + + def test_load_nddata(self): + nddata = NDData(self.data) + self.image.load_nddata(nddata) + + def test_load_array(self): + self.image.load_array(self.data) + + def test_center_on(self): + self.image.center_on((10, 10)) # X, Y + + def test_offset_to(self): + self.image.offset_to(10, 10) # dX, dY + + def test_zoom_level(self): + self.image.zoom_level = 5 + assert self.image.zoom_level == 5 + + def test_zoom(self): + self.image.zoom_level = 3 + self.image.zoom(2) + assert self.image.zoom_level == 6 # 3 x 2 + + @pytest.mark.xfail(reason='Not implemented yet') + def test_select_points(self): + self.image.select_points() + + def test_marking_operations(self): + marks = self.image.get_all_markers() + assert marks is None + assert not self.image.is_marking + + # Ensure you cannot set it like this. + with pytest.raises(AttributeError): + self.image.is_marking = True + + # Setting these to check that start_marking affects them. + self.image.click_center = True # Disables click_drag + assert self.image.click_center + self.image.scroll_pan = False + assert not self.image.scroll_pan + + # Set the marker style + marker_style = {'color': 'yellow', 'radius': 10, 'type': 'cross'} + m_str = str(self.image.marker) + for key in marker_style.keys(): + assert key in m_str + + self.image.start_marking(marker_name='markymark', marker=marker_style) + assert self.image.is_marking + assert self.image.marker == marker_style + assert not self.image.click_center + assert not self.image.click_drag + + # scroll_pan better activate when marking otherwise there is + # no way to pan while interactively marking + assert self.image.scroll_pan + + # Make sure that when we stop_marking we get our old controls back. + self.image.stop_marking() + assert self.image.click_center + assert not self.image.click_drag + assert not self.image.scroll_pan + + # Regression test for GitHub Issue 97: + # Marker name with no markers should give warning. + with pytest.warns(UserWarning, match='is empty') as warning_lines: + t = self.image.get_markers_by_name('markymark') + assert t is None + assert len(warning_lines) == 1 + + self.image.click_drag = True + self.image.start_marking() + assert not self.image.click_drag + + # Simulate a mouse click to add default marker name to the list. + self.image._mouse_click_cb(self.image.viewer, None, 50, 50) + assert self.image.get_marker_names() == [self.image._interactive_marker_set_name, 'markymark'] + + # Clear markers to not pollute other tests. + self.image.stop_marking(clear_markers=True) + + assert self.image.is_marking is False + assert self.image.get_all_markers() is None + assert len(self.image.get_marker_names()) == 0 + + # Make sure that click_drag is restored as expected + assert self.image.click_drag + + def test_add_markers(self): + rng = np.random.default_rng(1234) + data = rng.integers(0, 100, (5, 2)) + orig_tab = Table(data=data, names=['x', 'y'], dtype=('float', 'float')) + tab = Table(data=data, names=['x', 'y'], dtype=('float', 'float')) + self.image.add_markers(tab, x_colname='x', y_colname='y', + skycoord_colname='coord', marker_name='test1') + + # Make sure setting didn't change the default name + assert self.image._default_mark_tag_name == 'default-marker-name' + + # Regression test for GitHub Issue 45: + # Adding markers should not modify the input data table. + assert (tab == orig_tab).all() + + # Add more markers under different name. + self.image.add_markers(tab, x_colname='x', y_colname='y', + skycoord_colname='coord', marker_name='test2') + assert self.image.get_marker_names() == ['test1', 'test2'] + + # No guarantee markers will come back in the same order, so sort them. + t1 = self.image.get_markers_by_name('test1') + # Sort before comparing + t1.sort('x') + tab.sort('x') + assert np.all(t1['x'] == tab['x']) + assert (t1['y'] == tab['y']).all() + + # That should have given us two copies of the input table + t2 = self.image.get_all_markers() + expected = vstack([tab, tab], join_type='exact') + # Sort before comparing + t2.sort(['x', 'y']) + expected.sort(['x', 'y']) + assert (t2['x'] == expected['x']).all() + assert (t2['y'] == expected['y']).all() + + self.image.remove_markers_by_name('test1') + assert self.image.get_marker_names() == ['test2'] + + # Ensure unable to mark with reserved name + for name in self.image.RESERVED_MARKER_SET_NAMES: + with pytest.raises(ValueError, match='not allowed'): + self.image.add_markers(tab, marker_name=name) + + # Clear markers to not pollute other tests. + self.image.remove_all_markers() + assert len(self.image.get_marker_names()) == 0 + assert self.image.get_all_markers() is None + 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): + with pytest.raises(ValueError, match='must be one of'): + self.image.stretch = 'not a valid value' + + self.image.stretch = 'log' + assert isinstance(self.image.stretch, ColorDistBase) + + def test_cuts(self): + with pytest.raises(ValueError, match='must be one of'): + self.image.cuts = 'not a valid value' + + with pytest.raises(ValueError, match=r'must be given as \(low, high\)'): + self.image.cuts = (1, 10, 100) + + assert 'histogram' in self.image.autocut_options + + self.image.cuts = 'histogram' + np.testing.assert_allclose( + self.image.cuts, (3.948844e-04, 9.990224e-01), rtol=1e-6) + + self.image.cuts = (10, 100) + assert self.image.cuts == (10, 100) + + def test_colormap(self): + cmap_desired = 'gray' + cmap_list = self.image.colormap_options + assert len(cmap_list) > 0 and cmap_desired in cmap_list + self.image.set_colormap(cmap_desired) + + def test_cursor(self): + assert self.image.cursor in self.image.ALLOWED_CURSOR_LOCATIONS + with pytest.raises(ValueError): + self.image.cursor = 'not a valid option' + self.image.cursor = 'bottom' + assert self.image.cursor == 'bottom' + + def test_click_drag(self): + # Set this to ensure that click_drag turns it off + self.image.click_center = True + + # Make sure that setting click_drag to False does not turn off + # click_center. + self.image.click_drag = False + assert self.image.click_center + + self.image.click_drag = True + assert not self.image.click_center + + # If is_marking is true then trying to enable click_drag should fail + self.image._is_marking = True + self.image.click_drag = False + with pytest.raises(ValueError, match='Interactive marking'): + self.image.click_drag = True + self.image._is_marking = False + + def test_click_center(self): + # Set this to ensure that click_center turns it off + self.image.click_drag = True + + # Make sure that setting click_center to False does not turn off + # click_draf. + self.image.click_center = False + assert self.image.click_drag + + self.image.click_center = True + assert not self.image.click_drag + + # If is_marking is true then trying to enable click_center should fail + self.image._is_marking = True + self.image.click_center = False + with pytest.raises(ValueError, match='Interactive marking'): + self.image.click_center = True + self.image._is_marking = False + + def test_scroll_pan(self): + # Make sure scroll_pan is actually settable + for value in [True, False]: + self.image.scroll_pan = value + assert self.image.scroll_pan is value + + def test_save(self, tmpdir): + with pytest.raises(ValueError, match='not supported'): + self.image.save(str(tmpdir.join('woot.jpg'))) + + filename = str(tmpdir.join('woot.png')) + self.image.save(filename) + + with pytest.raises(ValueError, match='exists'): + self.image.save(filename) + + self.image.save(filename, overwrite=True) diff --git a/astrowidgets/tests/test_widget_pixel_offset.py b/astrowidgets/tests/test_widget_pixel_offset.py new file mode 100644 index 0000000..531e777 --- /dev/null +++ b/astrowidgets/tests/test_widget_pixel_offset.py @@ -0,0 +1,50 @@ +# TODO: How to enable switching out backend and still run the same tests? + +import pytest + +ginga = pytest.importorskip("ginga") + +import re # noqa: E402 + +import numpy as np # noqa: E402 + +from ginga.misc.log import get_logger # noqa: E402 + +from astrowidgets.ginga import ImageWidget # noqa: E402 + + +class TestGingaWidgetWithPixelOffset: + def setup_class(self): + # The pixel offset below is nonsensical. It is chosen simply + # to make it easy to check for. + self.offset = 3 + + logger = get_logger('my_viewer', log_stderr=False, level=30) + self.image = ImageWidget(logger, image_width=300, image_height=300, + pixel_coords_offset=self.offset) + + rng = np.random.default_rng(1234) + data = rng.random((300, 300)) + self.image.load_array(data) + + def test_offset_value(self): + # Ensure it cannot change after init + with pytest.raises(AttributeError): + self.image.pixel_offset = 0 + + assert self.image.pixel_offset == self.offset + + def test_move_callback_includes_offset(self): + # Send a fake move to the callback. What gets put in the cursor + # value should be the event we sent in plus the offset. + self.image.click_center = True + data_x = 100 + data_y = 200 + self.image._mouse_move_cb(self.image.viewer, None, data_x, data_y) + output_contents = self.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 + self.offset + assert float(y_out) == data_y + self.offset diff --git a/astrowidgets/tests/test_widget_with_wcs.py b/astrowidgets/tests/test_widget_with_wcs.py new file mode 100644 index 0000000..a633e74 --- /dev/null +++ b/astrowidgets/tests/test_widget_with_wcs.py @@ -0,0 +1,110 @@ +# TODO: How to enable switching out backend and still run the same tests? + +import pytest + +ginga = pytest.importorskip("ginga") + +import numpy as np # noqa: E402 +from astropy.coordinates import SkyCoord # noqa: E402 +from astropy.nddata import CCDData # noqa: E402 +from astropy.table import Table # noqa: E402 +from astropy.wcs import WCS # noqa: E402 + +from ginga.misc.log import get_logger # noqa: E402 + +from astrowidgets.ginga import ImageWidget # noqa: E402 + + +def _make_fake_ccd(with_wcs=True): + """Generate a CCDData object for use with ImageWidget tests. + + Parameters + ---------- + with_wcs : bool, optional + If `True` the image will have a WCS attached to it, + which is useful for some of the marker tests. + + Returns + ------- + image : `astropy.nddata.CCDData` + CCD image. + + """ + rng = np.random.default_rng(1234) + npix_side = 100 + fake_image = rng.random((npix_side, npix_side)) + if with_wcs: + wcs = WCS(naxis=2) + wcs.wcs.crpix = (fake_image.shape[0] / 2, fake_image.shape[1] / 2) + wcs.wcs.ctype = ('RA---TAN', 'DEC--TAN') + wcs.wcs.crval = (314.275419158, 31.6662781301) + wcs.wcs.pc = [[0.000153051015113, -3.20700931602e-05], + [3.20704370872e-05, 0.000153072382405]] + else: + wcs = None + + return CCDData(data=fake_image, wcs=wcs, unit='adu') + + +class TestGingaWidgetWithWCS: + def setup_class(self): + self.fake_ccd = _make_fake_ccd(with_wcs=True) + + logger = get_logger('my_viewer', log_stderr=False, level=30) + self.image = ImageWidget(logger, pixel_coords_offset=0) + self.image.load_nddata(self.fake_ccd) + + def test_adding_markers_as_world_recovers_with_get_markers(self): + """Make sure that our internal conversion from world to pixel + coordinates doesn't mess anything up. + """ + npix_side = self.fake_ccd.shape[0] + wcs = self.fake_ccd.wcs + + # Get me 100 positions please, not right at the edge + rng = np.random.default_rng(1234) + marker_locs = rng.integers(10, high=npix_side - 10, size=(100, 2)) + marks_pix = Table(data=marker_locs, names=('x', 'y')) + marks_world = wcs.all_pix2world(marker_locs, 0) + marks_coords = SkyCoord(marks_world, unit='degree') + mark_coord_table = Table(data=[marks_coords], names=('coord', )) + self.image.add_markers(mark_coord_table, use_skycoord=True) + + result = self.image.get_all_markers() + np.testing.assert_allclose( + result['coord'].ra.deg, mark_coord_table['coord'].ra.deg) + np.testing.assert_allclose( + result['coord'].dec.deg, mark_coord_table['coord'].dec.deg) + # Might as well check the x, y positions too. + np.testing.assert_allclose(result['x'], marks_pix['x']) + np.testing.assert_allclose(result['y'], marks_pix['y']) + + # Clear markers before running other tests. + self.image.remove_all_markers() + + def test_empty_marker_name_works_with_all(self): + """Regression test for GitHub Issue 97. + + This particular test checks that an empty table doesn't break + get_all_markers(). The bug only comes up if there is a coordinate + column, so use a fake image a WCS. + + """ + x = np.array([20, 30, 40]) + y = np.array([40, 80, 100]) + input_markers = Table(data=[x, y], names=['x', 'y']) + # Add some markers with our own name + self.image.add_markers(input_markers, marker_name='nowcs') + + # Start marking to create a new marker set that is empty + self.image.start_marking(marker_name='empty') + self.image.stop_marking() + + assert self.image.get_marker_names() == ['empty', 'nowcs'] + + marks = self.image.get_all_markers() + assert len(marks) == len(x) + assert 'empty' not in marks['marker name'] + + # Clear markers before running other tests. + self.image.remove_all_markers() diff --git a/setup.cfg b/setup.cfg index 31bb032..42d5e62 100644 --- a/setup.cfg +++ b/setup.cfg @@ -36,15 +36,16 @@ zip_safe = False packages = find: install_requires = astropy - ginga - pillow ipywidgets>=7.5 ipyevents>=0.6.3 jupyterlab>=1 - aggdraw python_requires >=3.6 [options.extras_require] +ginga = + ginga + pillow + aggdraw test = pytest-astropy pytest-cov diff --git a/tox.ini b/tox.ini index 5587634..8f21901 100644 --- a/tox.ini +++ b/tox.ini @@ -8,7 +8,9 @@ requires = isolated_build = true [testenv] -extras = test +extras = + test + ginga changedir = test: .tmp/{envname} deps = From d4c0c3691075f80666b008b3e62a467bc5a07f18 Mon Sep 17 00:00:00 2001 From: Pey Lian Lim <2090236+pllim@users.noreply.github.com> Date: Fri, 19 Feb 2021 12:13:37 -0500 Subject: [PATCH 2/7] TST: Handle empty warning in #128 --- astrowidgets/tests/test_widget_api.py | 3 ++- astrowidgets/tests/test_widget_with_wcs.py | 3 ++- setup.cfg | 1 - 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/astrowidgets/tests/test_widget_api.py b/astrowidgets/tests/test_widget_api.py index dc1560b..1baf371 100644 --- a/astrowidgets/tests/test_widget_api.py +++ b/astrowidgets/tests/test_widget_api.py @@ -177,7 +177,8 @@ def test_add_markers(self): self.image.remove_all_markers() assert len(self.image.get_marker_names()) == 0 assert self.image.get_all_markers() is None - assert self.image.get_markers_by_name(self.image._default_mark_tag_name) 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') diff --git a/astrowidgets/tests/test_widget_with_wcs.py b/astrowidgets/tests/test_widget_with_wcs.py index a633e74..beb9dc8 100644 --- a/astrowidgets/tests/test_widget_with_wcs.py +++ b/astrowidgets/tests/test_widget_with_wcs.py @@ -102,7 +102,8 @@ def test_empty_marker_name_works_with_all(self): assert self.image.get_marker_names() == ['empty', 'nowcs'] - marks = self.image.get_all_markers() + with pytest.warns(UserWarning, match='is empty'): + marks = self.image.get_all_markers() assert len(marks) == len(x) assert 'empty' not in marks['marker name'] diff --git a/setup.cfg b/setup.cfg index 42d5e62..3c03cbb 100644 --- a/setup.cfg +++ b/setup.cfg @@ -11,7 +11,6 @@ filterwarnings = ignore:numpy.ufunc size changed:RuntimeWarning ignore:numpy.ndarray size changed:RuntimeWarning ignore::DeprecationWarning:ginga - ignore:Marker set named:UserWarning [flake8] # E501: line too long From 980019593b77e7fad972cb64817ef2e88218621b Mon Sep 17 00:00:00 2001 From: Pey Lian Lim <2090236+pllim@users.noreply.github.com> Date: Fri, 19 Feb 2021 12:14:40 -0500 Subject: [PATCH 3/7] TST: Remove unused select_points test in #127 --- astrowidgets/tests/test_widget_api.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/astrowidgets/tests/test_widget_api.py b/astrowidgets/tests/test_widget_api.py index 1baf371..a006aa0 100644 --- a/astrowidgets/tests/test_widget_api.py +++ b/astrowidgets/tests/test_widget_api.py @@ -62,10 +62,6 @@ def test_zoom(self): self.image.zoom(2) assert self.image.zoom_level == 6 # 3 x 2 - @pytest.mark.xfail(reason='Not implemented yet') - def test_select_points(self): - self.image.select_points() - def test_marking_operations(self): marks = self.image.get_all_markers() assert marks is None From 0a87db86132899bbcd4b8e3927b2dcd0d9562662 Mon Sep 17 00:00:00 2001 From: Pey Lian Lim <2090236+pllim@users.noreply.github.com> Date: Fri, 19 Feb 2021 13:20:40 -0500 Subject: [PATCH 4/7] DOC: Update user-facing doc. TST: Update test matrix and added a test. DOC: Re-organized doc and notebooks. DOC: Update example notebooks --- .github/workflows/ci_workflows.yml | 75 ++--- .gitignore | 1 + astrowidgets/tests/test_abstract_class.py | 18 ++ docs/abstract.rst | 258 ++++++++++++++++++ docs/astrowidgets/api.rst | 7 - docs/astrowidgets/index.rst | 70 ----- docs/ginga.rst | 93 +++++++ docs/index.rst | 20 +- docs/install.rst | 81 +----- example_notebooks/README.md | 4 + example_notebooks/ginga/README.md | 10 + .../{ => ginga}/ginga_wcsaxes.ipynb | 4 +- .../{ => ginga}/ginga_widget.ipynb | 26 +- .../{ => ginga}/gui_interactions.ipynb | 18 +- .../{ => ginga}/named_markers.ipynb | 62 ++++- tox.ini | 38 ++- 16 files changed, 554 insertions(+), 231 deletions(-) create mode 100644 astrowidgets/tests/test_abstract_class.py create mode 100644 docs/abstract.rst delete mode 100644 docs/astrowidgets/api.rst delete mode 100644 docs/astrowidgets/index.rst create mode 100644 docs/ginga.rst create mode 100644 example_notebooks/ginga/README.md rename example_notebooks/{ => ginga}/ginga_wcsaxes.ipynb (98%) rename example_notebooks/{ => ginga}/ginga_widget.ipynb (96%) rename example_notebooks/{ => ginga}/gui_interactions.ipynb (89%) rename example_notebooks/{ => ginga}/named_markers.ipynb (82%) diff --git a/.github/workflows/ci_workflows.yml b/.github/workflows/ci_workflows.yml index f4fec83..6e0934e 100644 --- a/.github/workflows/ci_workflows.yml +++ b/.github/workflows/ci_workflows.yml @@ -9,48 +9,59 @@ on: - cron: '0 5 * * 5' jobs: - pep8: - runs-on: ubuntu-16.04 - steps: - - name: Checkout code - uses: actions/checkout@v2 - - name: Set up Python - uses: actions/setup-python@v2 - with: - python-version: '3.x' - - name: Lint with flake8 - run: | - python -m pip install --upgrade pip flake8 - flake8 astrowidgets --count - tests: + name: ${{ matrix.name }} runs-on: ${{ matrix.os }} strategy: fail-fast: true matrix: - os: [windows-latest, macos-latest, ubuntu-latest] - steps: - - name: Checkout code - uses: actions/checkout@v2 - - name: Set up Python - uses: actions/setup-python@v2 - with: - python-version: '3.8' - - name: Install and build - run: python -m pip install tox --upgrade - - name: Run tests - run: tox -e test + include: + + - name: Code style checks + os: ubuntu-latest + python: 3.x + toxenv: codestyle + + - name: Python 3.9 with abstract only + os: ubuntu-latest + python: 3.9 + toxenv: py39-test + + - name: Python 3.8 with Ginga backend (Linux) + os: ubuntu-latest + python: 3.8 + toxenv: py38-test-alldeps + + - name: Python 3.9 with Ginga backend (Windows) + os: windows-latest + python: 3.9 + toxenv: py39-test-alldeps + + - name: Python 3.7 with Ginga backend (OSX) + os: macos-latest + python: 3.7 + toxenv: py37-test-alldeps + + - name: Python 3.9 with Ginga backend (dev) + os: ubuntu-latest + python: 3.9 + toxenv: py39-test-devdeps + + - name: Python 3.6 with Ginga backend (old) + os: ubuntu-latest + python: 3.6 + toxenv: py36-test-oldestdeps - devtests: - runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v2 + with: + fetch-depth: 0 - name: Set up Python uses: actions/setup-python@v2 with: - python-version: '3.x' - - name: Install and build - run: python -m pip install tox --upgrade + python-version: ${{ matrix.python }} + - name: Install Python dependencies + run: python -m pip install --upgrade tox - name: Run tests - run: tox -e test-devdeps \ No newline at end of file + run: tox -e ${{ matrix.toxenv }} diff --git a/.gitignore b/.gitignore index f0acf70..e85d1c3 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ MANIFEST example_notebooks/test.png */version.py pip-wheel-metadata/ +example_notebooks/ginga/test.png # Sphinx docs/api diff --git a/astrowidgets/tests/test_abstract_class.py b/astrowidgets/tests/test_abstract_class.py new file mode 100644 index 0000000..6808093 --- /dev/null +++ b/astrowidgets/tests/test_abstract_class.py @@ -0,0 +1,18 @@ +"""Test to make sure astrowidgets can install and be used with +only abstract class, without optional backend. + +""" +import pytest + +from astrowidgets.core import BaseImageWidget + + +class DummyWidget(BaseImageWidget): + pass + + +def test_abstract_no_imp(): + # Ensure subclass cannot be used without implementing all the abstracted + # things. + with pytest.raises(TypeError, match="Can't instantiate abstract class"): + DummyWidget() diff --git a/docs/abstract.rst b/docs/abstract.rst new file mode 100644 index 0000000..fedb2ad --- /dev/null +++ b/docs/abstract.rst @@ -0,0 +1,258 @@ +.. _abstract_widget_intro: + +Understanding BaseImageWidget +============================= + +``astrowidgets`` provides an abstract class called +`~astrowidgets.core.BaseImageWidget` to allow developers from different +visualization tools (hereafter known as "backends") to implement their own +solutions using the same set of API. This design is based on +`nb-astroimage-api `_, with the +goal of making all functionality available by a compact and clear API. +This API-first approach would allow manipulating the view programmatically +in a reproducible way. + +The idea of the abstract class is that ``astrowidgets`` users would be able +to switch to the backend of their choice without much refactoring of their own. +This would enable, say, astronomers with different backend preferences to +collaborate more easily via Jupyter Lab/Notebook. + +The following sub-sections lay out the envisioned high-level usage of +``astrowidgets`` regardless of backend inside Jupyter Lab/Notebook. +However, the examples are not exhaustive. For the full API definition, +please see :ref:`abstract_api`. + +.. _abstract_viewer: + +Creating a Viewer +----------------- + +The snippet below is all you should need to make an image widget. +The widget should be a part of the +`ipywidgets framework `_ so that it can +be easily integrated with other controls: + +.. code-block:: python + + from astrowidgets.somebackend import ImageWidget + image = ImageWidget() + image + +.. _abstract_image_load: + +Loading an Image +---------------- + +To load data into the empty viewer created in :ref:`abstract_viewer`, +there should be methods to load different formats: + +.. code-block:: python + + # FITS image of the field of the exoplanet Kelt-16, + # and also contains part of the Veil Nebula + filename = 'https://zenodo.org/record/3356833/files/kelt-16-b-S001-R001-C084-r.fit.bz2?download=1' + image.load_fits(filename) + +.. code-block:: python + + # A Numpy array + import numpy as np + arr = np.arange(100).reshape(10, 10) + image.load_array(arr) + +.. code-block:: python + + # An astropy.nddata.NDData object + from astropy.io import fits + from astropy.nddata import NDData + from astropy.wcs import WCS + with fits.open(filename) as pf: + data = NDData(pf[0].data, wcs=WCS(pf[0].header)) + image.load_nddata(data) + +If additional format support is desired, the API could be added to the +abstract base class if the new format is widely supported and not specific +to a certain backend implementation. + +.. _abstract_cursor_info: + +Cursor Info Display +------------------- + +The widget actually consists of two child widgets: + +* The image display. +* Cursor information panel with the following: + * X and Y cursor locations, taking + `~astrowidgets.core.BaseImageWidget.pixel_offset` into account. + * RA and Dec calculated from the cursor location using the image's WCS, + if available. It is up to the backend on how to handle WCS projection + or distortion. + * Value of the image under the cursor. + +The cursor information panel can have three different states: + +* Positioned below the image display. +* Positioned above the image display. +* Not displayed. + +This state can be set using the `~astrowidgets.core.BaseImageWidget.cursor` +property. + +.. _abstract_size: + +Changing Display Size +--------------------- + +There should be a programmatic way to change the display size of the display +widget: + +.. code-block:: python + + # The height would auto-adjust + image.image_width = 500 # pixels + + # The width would auto-adjust + image.image_height = 500 # pixels + +.. _abstract_colormap: + +Changing Colormap +----------------- + +There should be a programmatic way to change the colormap of the display. +However, the available colormaps may differ from backend to backend: + +.. code-block:: python + + image.set_colormap('viridis') + +.. _abstract_controls: + +Mouse/Keyboard Controls +----------------------- + +Mouse interaction using clicks and scroll should be supported. +Keyboard controls would also be desirable. These controls should be active +when cursor is over the display, but not otherwise. +For example, but not limited to: + +* Scrolling to pan up/down the image. +* Using ``+``/``-`` to zoom in/out. +* Using click-and-drag to change the contrast of the image. + +In the event where the same click/button can be overloaded, the active +functionality can be controlled by the following properties: + +* `~astrowidgets.core.BaseImageWidget.click_center` +* `~astrowidgets.core.BaseImageWidget.click_drag` +* `~astrowidgets.core.BaseImageWidget.scroll_pan` + +There should be programmatic ways to perform these controls as well: + +.. code-block:: python + + # Centering on sky coordinates + from astropy.coordinates import SkyCoord + image.center_on(SkyCoord.from_name('kelt-16')) + + # Centering on pixel coordinates + image.center_on((100, 100)) + + # Moving the center using sky coordinates + from astropy import units as u + image.offset_to(0.1 * u.arcsec, 0.1 * u.arcsec, skycoord_offset=True) + + # Moving the center by pixels + image.offset_to(10, 10) + + # Zooming (two different ways) + image.zoom(2) + image.zoom_level = 1 + + # Changing the display stretch + image.stretch = 'log' + + # Changing the cut levels (two different ways) + image.cuts = 'histogram' + image.cuts = (0, 10) # (low, high) + +Please also see :ref:`abstract_marking`. + +.. _abstract_marking: + +Marking Objects +--------------- + +Another important aspect is to allow users to either interactively or +programmatically mark objects of interest on the displayed image. +Marking mode is tracked using the +`~astrowidgets.core.BaseImageWidget.is_marking` +property and can be turned on and off using +:meth:`~astrowidgets.core.BaseImageWidget.start_marking` and +:meth:`~astrowidgets.core.BaseImageWidget.stop_marking`, respectively. +The marker appearance can be changed using +`~astrowidgets.core.BaseImageWidget.marker`. + +For interactive marking, after a user runs ``start_marking`` but before +``stop_marking``, a click on the image display would mark the object under +the cursor. + +For programmatic marking, user can first build a `~astopy.table.Table` with +either pixel or sky coordinates, and then pass it into +:meth:`~astrowidgets.core.BaseImageWidget.add_markers`. + +User can then call +:meth:`~astrowidgets.core.BaseImageWidget.get_markers_by_name` or +:meth:`~astrowidgets.core.BaseImageWidget.get_all_markers` to obtain the +marked locations. + +To remove the markers, user can call +:meth:`~astrowidgets.core.BaseImageWidget.remove_markers_by_name` or +:meth:`~astrowidgets.core.BaseImageWidget.remove_all_markers`, as appropriate. + +To put this all together, here is an example workflow (out of many) +that may happen: + +1. User calls ``start_marking`` to begin the interactive marking session. +2. User clicks on two stars. +3. User calls ``stop_marking`` to end the interactive marking session. +4. User reads a table from a collaborator containing several galaxies in the + field of view. +5. User changes the marker style from a red circle to a green square by + modifying the ``marker`` property. +6. User programmatically marks the galaxies on display with the new marker style + and a new marker name using ``add_markers``. +7. User obtains all the marked locations for post-processing using + ``get_all_markers``. +8. User removes all the markers from display using ``remove_all_markers``. + +.. _abstract_save: + +Saving an Image +--------------- + +The image display can be programmatically saved to a file, but not the +:ref:`abstract_cursor_info`. Supported output format is controlled by the +individual backend. For example: + +.. code-block:: python + + image.save('myimage.png') + +.. _example_notebooks: + +Example Notebooks +----------------- + +Please see the `example notebooks folder `_ +for examples using a concrete implementation of this abstract class. +Backend-dependent dependencies are required to run them. + +.. _abstract_api: + +API +--- + +.. automodapi:: astrowidgets + :no-inheritance-diagram: diff --git a/docs/astrowidgets/api.rst b/docs/astrowidgets/api.rst deleted file mode 100644 index 6260b50..0000000 --- a/docs/astrowidgets/api.rst +++ /dev/null @@ -1,7 +0,0 @@ -.. _api-docs: - -API Reference -============= - -.. automodapi:: astrowidgets - :no-inheritance-diagram: diff --git a/docs/astrowidgets/index.rst b/docs/astrowidgets/index.rst deleted file mode 100644 index e76cd3e..0000000 --- a/docs/astrowidgets/index.rst +++ /dev/null @@ -1,70 +0,0 @@ -Image widget for Jupyter Lab/notebook -===================================== - -Getting started ---------------- - -Make a viewer -+++++++++++++ - -The snippet below is all you need to make an image widget. The widget is part -of the `ipywidgets framework `_ so that it can -be easily integrated with other controls:: - - >>> from astrowidgets import ImageWidget - >>> image = ImageWidget() - >>> display(image) - -Loading an image -++++++++++++++++ - -An empty viewer is not very useful, though, so load some data from a FITS -file. The FITS file at the link below is an image of the field of the exoplanet -Kelt-16, and also contains part of the Veil Nebula:: - - >>> image.load_fits('https://zenodo.org/record/3356833/files/kelt-16-b-S001-R001-C084-r.fit.bz2?download=1') - -The image widget can also load a Numpy array via -`~astrowidgets.ImageWidget.load_array`. It also understands astropy -`~astropy.nddata.NDData` objects; load them via -`~astrowidgets.ImageWidget.load_data`. - -Navigation -++++++++++ - -In the default configuration, basic navigation is done using these controls: - -* scroll to pan -* use ``+``/``-`` to zoom in/out (cursor must be over the image for this to work) -* right-click and drag to change contrast DS9-style - -API -+++ - -One important design goal is to make all functionality available by a compact, -clear API. The `target API `_ still -needs a few features (e.g., blink), but much of it is already implemented. - -The API-first approach means that manipulating the view programmatically is straightforward. -For example, centering on the position of the object, Kelt-16, and zooming in to 8x the natural -pixel scale is straightforward:: - - >>> from astropy.coordinates import SkyCoord - >>> image.center_on(SkyCoord.from_name('kelt-16')) - >>> image.zoom_level = 8 - -A more detailed description of the interface and the :ref:`api-docs` are available. - -.. toctree:: - :maxdepth: 2 - - api.rst - -Example Notebooks ------------------ - -* `astrowidgets using the Ginga backend `_ -* `Using named markers to keep track of logically related markers `_ -* `Demonstration of GUI interactions `_ - - diff --git a/docs/ginga.rst b/docs/ginga.rst new file mode 100644 index 0000000..fb12a3e --- /dev/null +++ b/docs/ginga.rst @@ -0,0 +1,93 @@ +.. _ginga_backend: + +Widget with Ginga Toolkit +========================= + +``astrowidgets`` comes with an example concrete implementation using +`Ginga `_ as a backend: + +.. code-block:: python + + from astrowidgets.ginga import ImageWidget + from ginga.misc.log import get_logger + logger = get_logger('my_viewer', log_stderr=False, log_file='ginga.log', + level=40) + image = ImageWidget(logger) + +Please see the `Ginga example notebooks folder `_ +for examples using this implementation. + +.. _ginga_dependencies: + +Dependencies +------------ + +The following dependecies need to be installed separately if you wish to use +the Ginga implementation: + +* ``ginga>=2.7.1`` +* ``pillow`` +* ``freetype`` +* ``aggdraw`` +* ``opencv`` (optional, not required but will improve performance) + +.. note:: + + For vectorized drawing in ``aggdraw``, you can clone + https://github.com/ejeschke/aggdraw/ and install its ``vectorized-drawing`` + branch from source. + +For Windows Users +^^^^^^^^^^^^^^^^^ + +It is a known issue that ``FREETYPE_ROOT`` is not set properly if you do +``conda install aggdraw`` on Windows +(https://github.com/conda-forge/freetype-feedstock/issues/12), which results +in ``aggdraw cannot load font (no text renderer)`` error message when +using the widget with Ginga toolkit. The solution is to update to ``aggdraw`` +1.3.5 or later; e.g., ``conda install aggdraw=1.3.5``. + +.. _ginga_opencv: + +Using OpenCV +------------ + +If you wish to use `OpenCV `_ +to handle the drawing in Ginga, you have two options: + +Install OpenCV with pip +^^^^^^^^^^^^^^^^^^^^^^^ + +If you are using pip it looks like the best option is to use the +``opencv-python`` package, which provides pre-built binaries of most of OpenCV:: + + pip install opencv-python + +However, the `opencv-python project `_ +is quite clear about being "unofficial" so you should probably read about +the project before using. + +Install OpenCV with conda +^^^^^^^^^^^^^^^^^^^^^^^^^ + +This should work on conda:: + + conda install -c conda-forge opencv + +If, after installing ``opencv``, you get a warning like this:: + + UserWarning: install opencv or set use_opencv=False + +Then, you should try installing a newer version of ``freetype``:: + + conda install 'freetype\>=2.10' + +For more details, see `this discussion of opencv and astrowidgets +`_. + +.. _ginga_imagewidget_api: + +API +--- + +.. automodapi:: astrowidgets.ginga diff --git a/docs/index.rst b/docs/index.rst index 401a24b..00b4c8e 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,6 +1,6 @@ -************ -astrowidgets -************ +************************************* +Image widget for Jupyter Lab/Notebook +************************************* ``astrowidgets`` aims to be a set of astronomy widgets for Jupyter Lab or Notebook, leveraging the Astropy ecosystem. @@ -14,21 +14,13 @@ or Notebook, leveraging the Astropy ecosystem. Please let us know what would make the tool easier to use on our `GitHub issue tracker`_. -Getting started -=============== +Contents: .. toctree:: :maxdepth: 1 install - astrowidgets/index - - -Reference/API -============= - -.. automodapi:: astrowidgets - :no-main-docstr: - :no-inheritance-diagram: + abstract + ginga .. _GitHub issue tracker: https://github.com/astropy/astrowidgets/issues diff --git a/docs/install.rst b/docs/install.rst index a119e49..6024a24 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 @@ -44,13 +54,8 @@ automatically when you install astrowidgets: * ``astropy`` * ``ipywidgets>=7.5`` * ``ipyevents>=0.6.3`` -* ``ginga>=2.7.1`` -* ``pillow`` -* ``freetype`` -* ``aggdraw`` * ``jupyterlab>=1`` * ``nodejs`` -* ``opencv`` (optional, not installed by default) After installing dependencies, for Jupyter Lab, run:: @@ -59,68 +64,8 @@ After installing dependencies, for Jupyter Lab, run:: For those using ``conda``, dependencies from the ``conda-forge`` channel should be sufficient unless stated otherwise. -Using OpenCV ------------- - -If you wish to use `OpenCV `_ to handle the -drawing in Ginga, you have two options: - -Install OpenCV with pip -^^^^^^^^^^^^^^^^^^^^^^^ - -If you are using pip it looks like the best option is to use the -``opencv-python`` package, which provides pre-built binaries of most of OpenCV:: - - pip install opencv-python - -However, the `opencv-python project -`_ is quite clear about being -"unofficial" so you should probably read about the project before using. - -Install OpenCV with conda -^^^^^^^^^^^^^^^^^^^^^^^^^ - -This should work on conda:: - - conda install -c conda-forge opencv - -If, after installing ``opencv``, you get a warning like this:: - - astrowidgets/core.py:72: UserWarning: install opencv or set use_opencv=False - warnings.warn('install opencv or set use_opencv=False') - -then you should try installing a newer version of ``freetype``:: - - conda install 'freetype\>=2.10' - -For more details, see `this discussion of opencv and astrowidgets -`_. - -Widget with Ginga toolkit -------------------------- - -.. note:: - - For vectorized drawing in ``aggdraw``, you can clone - https://github.com/ejeschke/aggdraw/ and install its ``vectorized-drawing`` - branch from source. - - -Notes for Windows users ------------------------ - -aggdraw -^^^^^^^ - -It is a known issue that ``FREETYPE_ROOT`` is not set properly if you do -``conda install aggdraw`` on Windows -(https://github.com/conda-forge/freetype-feedstock/issues/12), which results -in ``aggdraw cannot load font (no text renderer)`` error message when -using the widget with Ginga toolkit. The solution is to update to ``aggdraw`` -1.3.5 or later; e.g., ``conda install aggdraw=1.3.5``. - -nodejs -^^^^^^ +nodejs on Windows +^^^^^^^^^^^^^^^^^ In Windows 7, ``conda install -c conda-forge nodejs`` might throw an ``IOError``. The workaround for this is to install ``yarn`` and ``nodejs`` diff --git a/example_notebooks/README.md b/example_notebooks/README.md index ca4416d..c593d3d 100644 --- a/example_notebooks/README.md +++ b/example_notebooks/README.md @@ -1 +1,5 @@ This is a folder to store example Jupyter notebooks. + +Available backends: + +* Ginga: See `ginga/` subfolder. diff --git a/example_notebooks/ginga/README.md b/example_notebooks/ginga/README.md new file mode 100644 index 0000000..e82e6d0 --- /dev/null +++ b/example_notebooks/ginga/README.md @@ -0,0 +1,10 @@ +This directory contains example notebooks using `astrowidgets` with +Ginga backend. + +Available notebooks: + +* `ginga_widget.ipynb` demonstrates basic widget functionality. +* `gui_interactions.ipynb` demonstrates some interactive elements. +* `named_markers.ipynb` illustrates usage of named markers to keep track of + logically related objects of interest. +* `ginga_wcsaxes.ipynb` provides an example to overlay WCS axes on an image. diff --git a/example_notebooks/ginga_wcsaxes.ipynb b/example_notebooks/ginga/ginga_wcsaxes.ipynb similarity index 98% rename from example_notebooks/ginga_wcsaxes.ipynb rename to example_notebooks/ginga/ginga_wcsaxes.ipynb index 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 de06d26..f570ac7 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.*" ] }, { @@ -387,15 +387,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}')" ] }, { @@ -405,7 +403,7 @@ "outputs": [], "source": [ "# Erase markers from display\n", - "w.reset_markers()" + "w.remove_all_markers()" ] }, { @@ -439,7 +437,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)" @@ -491,7 +489,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", @@ -539,7 +537,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", @@ -594,7 +592,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/tox.ini b/tox.ini index 8f21901..323e968 100644 --- a/tox.ini +++ b/tox.ini @@ -1,20 +1,44 @@ [tox] envlist = - test{,-devdeps} + py{37,38,39,dev}-test{,-alldeps,-oldestdeps,-devdeps} + codestyle requires = setuptools >= 30.3.0 pip >= 19.3.1 - setuptools_scm isolated_build = true [testenv] -extras = - test - ginga -changedir = - test: .tmp/{envname} + +passenv = HOME WINDIR CC CI + +changedir = .tmp/{envname} + +description = + run tests + alldeps: with all optional dependencies + devdeps: with the latest developer version of all optional dependencies + oldestdeps: with the oldest supported version of key dependencies + 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 + +extras = + test + alldeps: ginga + commands = + pip freeze pytest --pyargs astrowidgets {toxinidir}/docs {posargs} + +[testenv:codestyle] +skip_install = true +description = check code style, e.g. with flake8 +deps = flake8 +changedir = {toxinidir} +commands = flake8 astrowidgets --count From f8082b31d4a3d04e4f07dfdf0a476c42eefb120e Mon Sep 17 00:00:00 2001 From: Pey Lian Lim <2090236+pllim@users.noreply.github.com> Date: Fri, 19 Feb 2021 18:37:07 -0500 Subject: [PATCH 5/7] TST: Disable oldest deps job --- .github/workflows/ci_workflows.yml | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci_workflows.yml b/.github/workflows/ci_workflows.yml index 6e0934e..236a6a2 100644 --- a/.github/workflows/ci_workflows.yml +++ b/.github/workflows/ci_workflows.yml @@ -47,10 +47,11 @@ jobs: python: 3.9 toxenv: py39-test-devdeps - - name: Python 3.6 with Ginga backend (old) - os: ubuntu-latest - python: 3.6 - toxenv: py36-test-oldestdeps + # NOTE: Uncomment when we really support oldest deps + #- name: Python 3.6 with Ginga backend (old) + # os: ubuntu-latest + # python: 3.6 + # toxenv: py36-test-oldestdeps steps: - name: Checkout code From 7efa6a4c6f125bf7f416dc5a8ecee3711cda2a98 Mon Sep 17 00:00:00 2001 From: "P. L. Lim" <2090236+pllim@users.noreply.github.com> Date: Fri, 19 Feb 2021 20:46:04 -0500 Subject: [PATCH 6/7] TST: Fix deps for devdeps job --- tox.ini | 1 + 1 file changed, 1 insertion(+) diff --git a/tox.ini b/tox.ini index 323e968..f144b63 100644 --- a/tox.ini +++ b/tox.ini @@ -31,6 +31,7 @@ deps = extras = test alldeps: ginga + devdeps: ginga commands = pip freeze From 678a2e44a92729e143134a0760ced7b151951813 Mon Sep 17 00:00:00 2001 From: Pey Lian Lim <2090236+pllim@users.noreply.github.com> Date: Mon, 22 Feb 2021 17:49:43 -0500 Subject: [PATCH 7/7] DOC: Fix inheritance diagram for Ginga API. DOC: Cannot use automodapi for Ginga API because we need to inherit docstring. DOC: Do not display inherited members because too confusing. [ci skip] --- astrowidgets/ginga.py | 3 ++- docs/conf.py | 2 ++ docs/ginga.rst | 8 +++++++- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/astrowidgets/ginga.py b/astrowidgets/ginga.py index 5b6879a..9aef7f1 100644 --- a/astrowidgets/ginga.py +++ b/astrowidgets/ginga.py @@ -1,4 +1,5 @@ -"""Module containing ``astrowidgets`` implemented with Ginga backend. +"""The ``astrowidgets.ginga`` module contains a widget implemented with the +Ginga backend. For this to work, ``astrowidgets`` must be installed along with the optional dependencies specified for the Ginga backend; e.g.,:: diff --git a/docs/conf.py b/docs/conf.py index 306c630..c20321f 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -45,6 +45,8 @@ # -- General configuration ---------------------------------------------------- +autodoc_inherit_docstrings = True + # By default, highlight as Python 3. highlight_language = 'python3' diff --git a/docs/ginga.rst b/docs/ginga.rst index fb12a3e..268d7a3 100644 --- a/docs/ginga.rst +++ b/docs/ginga.rst @@ -90,4 +90,10 @@ For more details, see `this discussion of opencv and astrowidgets API --- -.. automodapi:: astrowidgets.ginga +.. automodule:: astrowidgets.ginga + +.. inheritance-diagram:: astrowidgets.ginga.ImageWidget + :top-classes: ipywidgets.VBox, astrowidgets.core.BaseImageWidget + +.. autoclass:: astrowidgets.ginga.ImageWidget + :members: