Source code for astrowidgets.core

"""Module containing core functionality of ``astrowidgets``."""

# STDLIB
import functools
import warnings

# THIRD-PARTY
import numpy as np
from astropy import units as u
from astropy.coordinates import SkyCoord
from astropy.io import fits
from astropy.table import Table, vstack
from astropy.utils.decorators import deprecated

# Jupyter widgets
import ipywidgets as ipyw

# Ginga
from ginga.AstroImage import AstroImage
from ginga.canvas.CanvasObject import drawCatalog
from ginga.web.jupyterw.ImageViewJpw import EnhancedCanvasView
from ginga.util.wcs import ra_deg_to_str, dec_deg_to_str

__all__ = ['ImageWidget']

# Allowed locations for cursor display
ALLOWED_CURSOR_LOCATIONS = ['top', 'bottom', None]

# List of marker names that are for internal use only
RESERVED_MARKER_SET_NAMES = ['all']


[docs]class ImageWidget(ipyw.VBox): """ Image widget for Jupyter notebook using Ginga viewer. .. todo:: Any property passed to constructor has to be valid keyword. Parameters ---------- logger : obj 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_width, image_height : int Dimension of Jupyter notebook's image widget. 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. pixel_coords_offset : int, optional An offset, typically either 0 or 1, to add/subtract to all pixel values when going to/from the displayed image. *In almost all situations the default value, ``0``, is the correct value to use.* """ def __init__(self, logger=None, image_width=500, image_height=500, 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') self._viewer = EnhancedCanvasView(logger=logger) self._pixel_offset = pixel_coords_offset self._jup_img = ipyw.Image(format='jpeg') # Set the image margin to over the widgets default of 2px on # all sides. self._jup_img.layout.margin = '0' # Set both of those to ensure consistent display in notebook # and jupyterlab when the image is put into a container smaller # than the image. self._jup_img.max_width = '100%' self._jup_img.height = 'auto' # Set the width of the box containing the image to the desired width self.layout.width = str(image_width) # Note we are NOT setting the height. That is because the height # is automatically set by the image aspect ratio. # These need to also be set for now; ginga uses them to figure # out what size image to make. self._jup_img.width = image_width self._jup_img.height = image_height 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) # Make sure all of the internal state trackers have a value # and start in a state which is definitely allowed: all are # False. self._is_marking = False self._click_center = False self._click_drag = False self._scroll_pan = False # 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} # Maintain marker tags as a set because we do not want # duplicate names. self._marktags = set() # Let's have a default name for the tag too: self._default_mark_tag_name = 'default-marker-name' self._interactive_marker_set_name_default = 'interactive-markers' self._interactive_marker_set_name = self._interactive_marker_set_name_default # coordinates display self._jup_coord = ipyw.HTML('Coordinates show up here') # This needs ipyevents 0.3.1 to work self._viewer.add_callback('cursor-changed', self._mouse_move_cb) self._viewer.add_callback('cursor-down', self._mouse_click_cb) # Define a callback that shows the output of a print self.print_out = ipyw.Output() self._cursor = 'bottom' self.children = [self._jup_img, self._jup_coord] @property def logger(self): """Logger for this widget.""" return self._viewer.logger @property def image_width(self): return int(self._jup_img.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) @property def image_height(self): return int(self._jup_img.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) @property def pixel_offset(self): """ An offset, typically either 0 or 1, to add/subtract to all pixel values when going to/from the displayed image. *In almost all situations the default value, ``0``, is the correct value to use.* This value cannot be modified after initialization. """ return self._pixel_offset def _mouse_move_cb(self, viewer, button, data_x, data_y): """ Callback to display position in RA/DEC deg. """ 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( ra_deg_to_str(ra), dec_deg_to_str(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, coord='data') 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)
[docs] def load_fits(self, fitsorfn, numhdu=None, memmap=None): """ 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. 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. """ 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)
[docs] def load_nddata(self, nddata): """ Load an ``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: print('Unable to set WCS from NDData: {}'.format(str(e))) self._viewer.set_image(image)
[docs] 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)
[docs] def center_on(self, point): """ Centers the view on a particular point. Parameters ---------- point : tuple or `~astropy.coordinates.SkyCoord` If tuple of ``(X, Y)`` is given, it is assumed to be in data coordinates. """ if isinstance(point, SkyCoord): self._viewer.set_pan(point.ra.deg, point.dec.deg, coord='wcs') else: self._viewer.set_pan(*(np.asarray(point) - self._pixel_offset))
[docs] @deprecated('0.3', alternative='offset_by') def offset_to(self, dx, dy, skycoord_offset=False): """ Move the center to a point that is given offset away from the current center. .. note:: This is deprecated. Use :meth:`offset_by`. Parameters ---------- dx, dy : float Offset value. Unit is assumed based on ``skycoord_offset``. skycoord_offset : bool If `True`, offset must be given in degrees. Otherwise, they are in pixel values. """ if skycoord_offset: coord = 'wcs' else: coord = 'data' pan_x, pan_y = self._viewer.get_pan(coord=coord) self._viewer.set_pan(pan_x + dx, pan_y + dy, coord=coord)
[docs] def offset_by(self, dx, dy): """ Move the center to a point that is given offset away from the current center. Parameters ---------- dx, dy : float or `~astropy.unit.Quantity` Offset value. Without a unit, assumed to be pixel offsets. If a unit is attached, offset by pixel or sky is assumed from the unit. """ dx_val, dx_coord = _offset_is_pixel_or_sky(dx) dy_val, dy_coord = _offset_is_pixel_or_sky(dy) if dx_coord != dy_coord: raise ValueError(f'dx is of type {dx_coord} but dy is of type {dy_coord}') pan_x, pan_y = self._viewer.get_pan(coord=dx_coord) self._viewer.set_pan(pan_x + dx_val, pan_y + dy_val, coord=dx_coord)
@property def zoom_level(self): """ Zoom level: * 1 means real-pixel-size. * 2 means zoomed in by a factor of 2. * 0.5 means zoomed out by a factor of 2. """ return self._viewer.get_scale() @zoom_level.setter def zoom_level(self, val): if val == 'fit': self._viewer.zoom_fit() else: self._viewer.scale_to(val, val)
[docs] def zoom(self, val): """ Zoom in or out by the given factor. Parameters ---------- val : int The zoom level to zoom the image. See `zoom_level`. """ self.zoom_level = self.zoom_level * val
@property def is_marking(self): """ `True` if in marking mode, `False` otherwise. Marking mode means a mouse click adds a new marker. This does not affect :meth:`add_markers`. """ return self._is_marking
[docs] def start_marking(self, marker_name=None, marker=None): """ Start marking, with option to name this set of markers or to specify the marker style. """ self._cached_state = dict(click_center=self.click_center, click_drag=self.click_drag, scroll_pan=self.scroll_pan) self.click_center = False self.click_drag = False # Set scroll_pan to ensure there is a mouse way to pan self.scroll_pan = True self._is_marking = True if marker_name is not None: self._validate_marker_name(marker_name) self._interactive_marker_set_name = marker_name self._marktags.add(marker_name) else: self._interactive_marker_set_name = \ self._interactive_marker_set_name_default if marker is not None: self.marker = marker
[docs] def stop_marking(self, clear_markers=False): """ Stop marking mode, with option to clear markers, if desired. Parameters ---------- clear_markers : bool, optional If ``clear_markers`` is `False`, existing markers are retained until :meth:`reset_markers` is called. Otherwise, they are erased. """ if self.is_marking: self._is_marking = False self.click_center = self._cached_state['click_center'] self.click_drag = self._cached_state['click_drag'] self.scroll_pan = self._cached_state['scroll_pan'] self._cached_state = {} if clear_markers: self.reset_markers()
@property def marker(self): """ Marker to use. .. 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 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
[docs] def get_markers(self, x_colname='x', y_colname='y', skycoord_colname='coord', marker_name=None): """ Return the locations of existing markers. Parameters ---------- x_colname, y_colname : str Column names for X and Y data coordinates. Coordinates returned are 0- or 1-indexed, depending on ``self.pixel_offset``. skycoord_colname : str Column name for ``SkyCoord``, which contains sky coordinates associated with the active image. This is ignored if image has no WCS. Returns ------- markers_table : `~astropy.table.Table` or ``None`` Table of markers, if any, or ``None``. """ if marker_name is None: marker_name = self._default_mark_tag_name if marker_name == 'all': # If it wasn't for the fact that SKyCoord columns can't # be stacked this would all fit nicely into a list # comprehension. But they can't, so we delete the # SkyCoord column if it is present, then add it # back after we have stacked. coordinates = [] tables = [] for name in self._marktags: table = self.get_markers(x_colname=x_colname, y_colname=y_colname, skycoord_colname=skycoord_colname, marker_name=name) if table is None: # No markers by this name, skip it continue try: coordinates.extend(c for c in table[skycoord_colname]) except KeyError: pass else: del table[skycoord_colname] tables.append(table) stacked = vstack(tables, join_type='exact') if coordinates: stacked[skycoord_colname] = SkyCoord(coordinates) return stacked # 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) 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( '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)) # 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)))
[docs] def add_markers(self, table, x_colname='x', y_colname='y', skycoord_colname='coord', use_skycoord=False, marker_name=None): """ Creates markers in the image at given points. .. todo:: Later enhancements to include more columns to control size/style/color of marks, Parameters ---------- table : `~astropy.table.Table` Table containing marker locations. x_colname, y_colname : str Column names for X and Y. Coordinates can be 0- or 1-indexed, as given by ``self.pixel_offset``. skycoord_colname : str Column name with ``SkyCoord`` objects. use_skycoord : bool If `True`, use ``skycoord_colname`` to mark. Otherwise, use ``x_colname`` and ``y_colname``. marker_name : str, optional Name to assign the markers in the table. Providing a name allows markers to be removed by name at a later time. """ # TODO: Resolve https://github.com/ejeschke/ginga/issues/672 # For now we always convert marker locations to pixels; see # comment below. 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) # 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)
[docs] def remove_markers(self, marker_name=None): """ Remove some but not all of the markers by name used when adding the markers Parameters ---------- marker_name : str, optional Name used when the markers were added. """ # 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)
[docs] def reset_markers(self): """ Delete all markers. """ # Grab the entire list of marker names before iterating # otherwise what we are iterating over changes. for marker_name in list(self._marktags): self.remove_markers(marker_name)
@property def stretch_options(self): """ List all available options for image stretching. """ return self._viewer.get_color_algorithms() @property def stretch(self): """ The image stretching algorithm in use. """ return self._viewer.rgbmap.dist # 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) @property def autocut_options(self): """ List all available options for image auto-cut. """ return self._viewer.get_autocut_methods() @property def cuts(self): """ Current image cut levels. To set new cut levels, either provide a tuple of ``(low, high)`` values or one of the options from `autocut_options`. """ return self._viewer.get_cut_levels() # TODO: Possible to use astropy.visualization directly? @cuts.setter def cuts(self, val): if isinstance(val, str): # Autocut 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]) @property def colormap_options(self): """List of colormap names.""" from ginga import cmap return cmap.get_names()
[docs] def set_colormap(self, cmap): """ Set colormap to the given colormap name. Parameters ---------- cmap : str Colormap name. Possible values can be obtained from :meth:`colormap_options`. """ self._viewer.set_color_map(cmap)
@property def cursor(self): """ Show or hide cursor information (X, Y, WCS). Acceptable values are 'top', 'bottom', or ``None``. """ return self._cursor @cursor.setter def cursor(self, val): if val is None: self._jup_coord.layout.visibility = 'hidden' self._jup_coord.layout.display = 'none' elif val == 'top' or val == 'bottom': self._jup_coord.layout.visibility = 'visible' self._jup_coord.layout.display = 'flex' if val == 'top': self.layout.flex_flow = 'column-reverse' else: self.layout.flex_flow = 'column' else: raise ValueError('Invalid value {} for cursor.' 'Valid values are: ' '{}'.format(val, ALLOWED_CURSOR_LOCATIONS)) self._cursor = val @property def click_center(self): """ Settable. If True, middle-clicking can be used to center. If False, that interaction is disabled. In the future this might go from True/False to being a selectable button. But not for the first round. """ return self._click_center @click_center.setter def click_center(self, val): if not isinstance(val, bool): raise ValueError('Must be True or False') elif self.is_marking and val: raise ValueError('Cannot set to True while in marking mode') if val: self.click_drag = False self._click_center = val # TODO: Awaiting https://github.com/ejeschke/ginga/issues/674 @property def click_drag(self): """ Settable. If True, the "click-and-drag" mode is an available interaction for panning. If False, it is not. Note that this should be automatically made `False` when selection mode is activated. """ return self._click_drag @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: 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. """ return self._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')
[docs] def save(self, filename): """ Save out the current image view to given PNG filename. """ # It turns out the image value is already in PNG format so we just # to write that out to a file. with open(filename, 'wb') as f: f.write(self._jup_img.value)
def _offset_is_pixel_or_sky(x): if isinstance(x, u.Quantity): if x.unit in (u.dimensionless_unscaled, u.pix): coord = 'data' val = x.value else: coord = 'wcs' val = x.to_value(u.deg) else: coord = 'data' val = x return val, coord